+33
-3
@@ -102,13 +102,40 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
if (this.state === 'SPEAKING') this.setState('LISTENING');
|
if (this.state === 'SPEAKING') this.setState('LISTENING');
|
||||||
}
|
}
|
||||||
_speakReply(spoken) {
|
_speakReply(spoken) {
|
||||||
const playing = this.voiceLoop?.speak?.(spoken);
|
const play = async () => {
|
||||||
if (!playing) { this._finishSpeech(); return; }
|
await this.ensureTts();
|
||||||
Promise.resolve(playing).catch((error) => {
|
const playing = this.voiceLoop?.speak?.(spoken);
|
||||||
|
if (!playing) {
|
||||||
|
const reason = this.voiceLoop?.status?.errors?.tts;
|
||||||
|
if (reason && reason !== 'Spoken replies are disabled') this.emit('Error', 'TTS', reason);
|
||||||
|
this._finishSpeech();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await playing;
|
||||||
|
};
|
||||||
|
Promise.resolve(play()).catch((error) => {
|
||||||
this.emit('Error', 'TTS', error.message);
|
this.emit('Error', 'TTS', error.message);
|
||||||
if (this.state === 'SPEAKING') this._finishSpeech();
|
if (this.state === 'SPEAKING') this._finishSpeech();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async ensureTts() {
|
||||||
|
if (!this.voiceLoop) return;
|
||||||
|
const settings = voiceSettings();
|
||||||
|
if (!settings.ttsEnabled) return;
|
||||||
|
if (this.voiceLoop.status.tts) return;
|
||||||
|
if (!this.voiceLoop.tts) this.voiceLoop.tts = new QvacVoiceAdapter({ role: 'tts' });
|
||||||
|
try {
|
||||||
|
await this.voiceLoop.tts.start();
|
||||||
|
this.voiceLoop.status.tts = true;
|
||||||
|
delete this.voiceLoop.status.errors.tts;
|
||||||
|
console.log('jarvisd: voice: TTS ready');
|
||||||
|
} catch (error) {
|
||||||
|
const message = String(error?.message || error);
|
||||||
|
this.voiceLoop.status.errors.tts = message;
|
||||||
|
console.error(`jarvisd: voice: TTS: ${message}`);
|
||||||
|
this.emit('Error', 'TTS', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
cancel() { this.voiceLoop?.interrupt?.(); this.harness.cancel(); this.scheduler.cancelQueued((job) => job.lane === 'voice'); this.computer.revoke(); this.voice.cancel(); this.setState('ARMED'); cancelQvac().catch((error) => this.emit('Error', 'QVAC_CANCEL', error.message)); }
|
cancel() { this.voiceLoop?.interrupt?.(); this.harness.cancel(); this.scheduler.cancelQueued((job) => job.lane === 'voice'); this.computer.revoke(); this.voice.cancel(); this.setState('ARMED'); cancelQvac().catch((error) => this.emit('Error', 'QVAC_CANCEL', error.message)); }
|
||||||
computerGrant(persist = false) { const result = this.computer.grant({ persist }); this.emit('ComputerStep', JSON.stringify({ action: 'grant', ...result })); this.input.grant({ persist }).then((backend) => { this.computer.setBackend(backend.backend); this.emit('ComputerStep', JSON.stringify({ action: 'backend', ...backend })); }).catch((error) => this.emit('Error', 'CU_GRANT', error.message)); return result; }
|
computerGrant(persist = false) { const result = this.computer.grant({ persist }); this.emit('ComputerStep', JSON.stringify({ action: 'grant', ...result })); this.input.grant({ persist }).then((backend) => { this.computer.setBackend(backend.backend); this.emit('ComputerStep', JSON.stringify({ action: 'backend', ...backend })); }).catch((error) => this.emit('Error', 'CU_GRANT', error.message)); return result; }
|
||||||
computerRevoke() { this.input.revoke(); this.computer.revoke(); this.emit('ComputerStep', JSON.stringify({ action: 'revoke' })); }
|
computerRevoke() { this.input.revoke(); this.computer.revoke(); this.emit('ComputerStep', JSON.stringify({ action: 'revoke' })); }
|
||||||
@@ -117,12 +144,15 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
const settings = voiceSettings();
|
const settings = voiceSettings();
|
||||||
const asr = new QvacVoiceAdapter({ role: 'asr' });
|
const asr = new QvacVoiceAdapter({ role: 'asr' });
|
||||||
const tts = settings.ttsEnabled ? new QvacVoiceAdapter({ role: 'tts' }) : null;
|
const tts = settings.ttsEnabled ? new QvacVoiceAdapter({ role: 'tts' }) : null;
|
||||||
|
if (!settings.ttsEnabled) console.log('jarvisd: voice: TTS disabled in config.json');
|
||||||
const loop = new VoiceLoop({ daemon: this, wake: createWakeEngine(settings), asr, tts });
|
const loop = new VoiceLoop({ daemon: this, wake: createWakeEngine(settings), asr, tts });
|
||||||
loop.on('error', (error) => { console.error(`jarvisd: voice: ${error.message}`); this.emit('Error', 'VOICE', error.message); });
|
loop.on('error', (error) => { console.error(`jarvisd: voice: ${error.message}`); this.emit('Error', 'VOICE', error.message); });
|
||||||
this.voiceLoop = loop;
|
this.voiceLoop = loop;
|
||||||
try { await loop.start(); this.emit('StateChanged', this.state); } catch (error) {
|
try { await loop.start(); this.emit('StateChanged', this.state); } catch (error) {
|
||||||
this.voiceLoop = null; await loop.stop?.().catch(() => {}); this.emit('Error', 'VOICE_UNAVAILABLE', error.message); throw error;
|
this.voiceLoop = null; await loop.stop?.().catch(() => {}); this.emit('Error', 'VOICE_UNAVAILABLE', error.message); throw error;
|
||||||
}
|
}
|
||||||
|
if (loop.status.tts) console.log('jarvisd: voice: TTS ready');
|
||||||
|
else if (loop.status.errors.tts) console.error(`jarvisd: voice: TTS: ${loop.status.errors.tts}`);
|
||||||
}
|
}
|
||||||
setPushToTalk(pressed) { this.voiceLoop?.setPushToTalk(pressed); this.emit('PushToTalk', Boolean(pressed)); }
|
setPushToTalk(pressed) { this.voiceLoop?.setPushToTalk(pressed); this.emit('PushToTalk', Boolean(pressed)); }
|
||||||
runtimeStatus() { return JSON.stringify({ local: true, qvac: qvacStatus(), scheduler: this.scheduler.status(), scheduler_metrics: this.scheduler.metrics(), telemetry: this.telemetry.snapshot(), computer: this.computer.status(), voice: this.voiceLoop ? { ...this.voiceLoop.metrics.snapshot(), ...this.voiceLoop.status } : null, p2p: { enabled: process.env.JARVIS_P2P_ENABLE === '1', inference: false, memorySync: false } }); }
|
runtimeStatus() { return JSON.stringify({ local: true, qvac: qvacStatus(), scheduler: this.scheduler.status(), scheduler_metrics: this.scheduler.metrics(), telemetry: this.telemetry.snapshot(), computer: this.computer.status(), voice: this.voiceLoop ? { ...this.voiceLoop.metrics.snapshot(), ...this.voiceLoop.status } : null, p2p: { enabled: process.env.JARVIS_P2P_ENABLE === '1', inference: false, memorySync: false } }); }
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
export const MIN_UTTERANCE_CHARS = 3;
|
export const MIN_UTTERANCE_CHARS = 3;
|
||||||
|
|
||||||
|
export function isSpeakable(text) {
|
||||||
|
const value = String(text || '').trim();
|
||||||
|
if (!value || value === '[object Object]') return false;
|
||||||
|
if (/\[no speech detected\]|\[blank_audio\]/i.test(value)) return false;
|
||||||
|
if (/^\[[^\]]+\]$/.test(value)) return false;
|
||||||
|
return /[\p{L}\p{N}]/u.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function isMeaningfulTranscript(text) {
|
export function isMeaningfulTranscript(text) {
|
||||||
const value = String(text || '').trim();
|
const value = String(text || '').trim();
|
||||||
if (!value || /\[no speech detected\]|\[blank_audio\]/i.test(value)) return false;
|
if (!isSpeakable(value)) return false;
|
||||||
if (/^\[[^\]]+\]$/.test(value)) return false;
|
|
||||||
return value.replace(/[^\p{L}\p{N}]/gu, '').length >= MIN_UTTERANCE_CHARS;
|
return value.replace(/[^\p{L}\p{N}]/gu, '').length >= MIN_UTTERANCE_CHARS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export class QvacVoiceAdapter extends EventEmitter {
|
|||||||
assertSdkVersion();
|
assertSdkVersion();
|
||||||
await acquireQvac({ auxiliaryOnly: true }); this.acquired = true;
|
await acquireQvac({ auxiliaryOnly: true }); this.acquired = true;
|
||||||
try {
|
try {
|
||||||
if (this.role !== 'tts') this.asrId = await loadAuxiliaryModel(this.asrModel, { vadModelSrc: 'VAD_SILERO_5_1_2', audio_format: 's16le', language: 'en', no_timestamps: true, vad_params: { threshold: 0.6, min_speech_duration_ms: 300, min_silence_duration_ms: 700, max_speech_duration_s: 15, speech_pad_ms: 200 } }, 'whisper');
|
if (this.role !== 'tts') this.asrId = await loadAuxiliaryModel(this.asrModel, { vadModelSrc: 'VAD_SILERO_5_1_2', audio_format: 's16le', language: 'en', no_timestamps: true, vad_params: { threshold: 0.6, min_speech_duration_ms: 300, min_silence_duration_ms: 700, max_speech_duration_s: 15, speech_pad_ms: 200 } }, 'whispercpp-transcription');
|
||||||
if (this.role !== 'asr') this.ttsId = await loadAuxiliaryModel(this.ttsModel, { ttsEngine: 'supertonic', language: 'en', voice: 'F1', ttsSpeed: 1.05, ttsNumInferenceSteps: 5 }, 'tts');
|
if (this.role !== 'asr') this.ttsId = await loadAuxiliaryModel(this.ttsModel, { ttsEngine: 'supertonic', language: 'en', voice: 'F1', ttsSpeed: 1.05, ttsNumInferenceSteps: 5 }, 'tts-ggml');
|
||||||
} catch (error) { await this.stop(); throw error; }
|
} catch (error) { await this.stop(); throw error; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-7
@@ -3,7 +3,7 @@ import { PipeWireCapture } from './audio-pipewire.js';
|
|||||||
import { PipeWirePlayback } from './audio-playback.js';
|
import { PipeWirePlayback } from './audio-playback.js';
|
||||||
import { WakeEngine } from './wake-engine.js';
|
import { WakeEngine } from './wake-engine.js';
|
||||||
import { VadSegmenter } from './vad.js';
|
import { VadSegmenter } from './vad.js';
|
||||||
import { isMeaningfulTranscript, SentenceBuffer } from './transcript.js';
|
import { isMeaningfulTranscript, isSpeakable, SentenceBuffer } from './transcript.js';
|
||||||
import { VoiceMetrics } from './voice-metrics.js';
|
import { VoiceMetrics } from './voice-metrics.js';
|
||||||
|
|
||||||
export const POST_PLAYBACK_COOLDOWN_MS = 400;
|
export const POST_PLAYBACK_COOLDOWN_MS = 400;
|
||||||
@@ -37,8 +37,14 @@ export class VoiceLoop extends EventEmitter {
|
|||||||
async start() {
|
async start() {
|
||||||
if (this.running) return;
|
if (this.running) return;
|
||||||
for (const [name, adapter] of [['tts', this.tts], ['asr', this.asr]]) {
|
for (const [name, adapter] of [['tts', this.tts], ['asr', this.asr]]) {
|
||||||
try { if (adapter) { await adapter.start?.(); this.status[name] = true; } }
|
try {
|
||||||
catch (error) { this.status.errors[name] = error.message; this.emit('error', new Error(`${name.toUpperCase()}: ${error.message}`)); }
|
if (adapter) { await adapter.start?.(); this.status[name] = true; }
|
||||||
|
else if (name === 'tts') this.status.errors.tts = 'Spoken replies are disabled';
|
||||||
|
} catch (error) {
|
||||||
|
const message = String(error?.message || error);
|
||||||
|
this.status.errors[name] = message;
|
||||||
|
this.emit('error', new Error(`${name.toUpperCase()}: ${message}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.running = true;
|
this.running = true;
|
||||||
if (this.status.asr) {
|
if (this.status.asr) {
|
||||||
@@ -53,7 +59,7 @@ export class VoiceLoop extends EventEmitter {
|
|||||||
pushAudio(chunk) {
|
pushAudio(chunk) {
|
||||||
if (!this.running || this.daemon?.locked || this.daemon?.state === 'SLEEPING') return;
|
if (!this.running || this.daemon?.locked || this.daemon?.state === 'SLEEPING') return;
|
||||||
if (this.isSpeaking || this.now() < this.cooldownUntil) { this.metrics.feedbackDrop(); return; }
|
if (this.isSpeaking || this.now() < this.cooldownUntil) { this.metrics.feedbackDrop(); return; }
|
||||||
this.daemon?.emit('ListeningLevel', Math.min(1, Math.max(0, chunk.length ? 0.01 : 0)));
|
try { this.daemon?.emit('ListeningLevel', Math.min(1, Math.max(0, chunk?.length ? 0.01 : 0))); } catch {}
|
||||||
if (!this.ptt) this.wake.push(chunk);
|
if (!this.ptt) this.wake.push(chunk);
|
||||||
if (this.ptt || this.daemon?.state === 'LISTENING') this.vad.push(chunk);
|
if (this.ptt || this.daemon?.state === 'LISTENING') this.vad.push(chunk);
|
||||||
}
|
}
|
||||||
@@ -89,9 +95,9 @@ export class VoiceLoop extends EventEmitter {
|
|||||||
}
|
}
|
||||||
async speak(text) {
|
async speak(text) {
|
||||||
const generation = this._generation || 0;
|
const generation = this._generation || 0;
|
||||||
if (!isMeaningfulTranscript(text) || !this.tts?.speak) { this._releaseSpeaking(); return; }
|
if (!isSpeakable(text) || !this.tts?.speak || !this.status.tts) { this._releaseSpeaking(); return; }
|
||||||
this.metrics.reply();
|
this.metrics.reply();
|
||||||
const sentences = String(text).match(/[^.!?]+[.!?]+|[^.!?]+$/g)?.map((part) => part.trim()).filter(isMeaningfulTranscript) || [];
|
const sentences = String(text).match(/[^.!?]+[.!?]+|[^.!?]+$/g)?.map((part) => part.trim()).filter(isSpeakable) || [];
|
||||||
if (!sentences.length) { this._releaseSpeaking(); return; }
|
if (!sentences.length) { this._releaseSpeaking(); return; }
|
||||||
this._speechQueue = this._speechQueue.catch(() => {}).then(async () => {
|
this._speechQueue = this._speechQueue.catch(() => {}).then(async () => {
|
||||||
if (generation !== this._generation) return;
|
if (generation !== this._generation) return;
|
||||||
@@ -104,7 +110,7 @@ export class VoiceLoop extends EventEmitter {
|
|||||||
}
|
}
|
||||||
async speakSentence(text) {
|
async speakSentence(text) {
|
||||||
this.isSpeaking = true; this.wake.pause(); this.daemon?.emit('StateChanged', 'SPEAKING');
|
this.isSpeaking = true; this.wake.pause(); this.daemon?.emit('StateChanged', 'SPEAKING');
|
||||||
try { const audio = await this.tts.speak(text); await this.playback.play(audio.samples); this.daemon?.emit('SpeakingLevel', 0); }
|
try { const audio = await this.tts.speak(text); await this.playback.play(audio.samples); try { this.daemon?.emit('SpeakingLevel', 0); } catch {} }
|
||||||
finally {
|
finally {
|
||||||
this.isSpeaking = false; this.cooldownUntil = this.now() + this.cooldownMs; this.wake.resume();
|
this.isSpeaking = false; this.cooldownUntil = this.now() + this.cooldownMs; this.wake.resume();
|
||||||
if (this.daemon?.state === 'SPEAKING') { this.daemon.voice?.finishSpeaking?.(); this.daemon.setState?.('LISTENING'); }
|
if (this.daemon?.state === 'SPEAKING') { this.daemon.voice?.finishSpeaking?.(); this.daemon.setState?.('LISTENING'); }
|
||||||
|
|||||||
@@ -2,15 +2,24 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
|
||||||
export function voiceSettings() {
|
function flag(value, fallback = true) {
|
||||||
let config = {};
|
if (value === undefined || value === null) return fallback;
|
||||||
try { config = JSON.parse(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'jarvis/config.json'), 'utf8')); } catch {}
|
if (typeof value === 'string') return !['false', '0', 'off', 'no'].includes(value.trim().toLowerCase());
|
||||||
|
return value !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function voiceSettings(source = null) {
|
||||||
|
let config = source;
|
||||||
|
if (!config) {
|
||||||
|
config = {};
|
||||||
|
try { config = JSON.parse(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'jarvis/config.json'), 'utf8')); } catch {}
|
||||||
|
}
|
||||||
const phrase = config.wakePhrase || config.wake_phrase || 'hey jarvis';
|
const phrase = config.wakePhrase || config.wake_phrase || 'hey jarvis';
|
||||||
const aliases = Array.isArray(config.aliases) ? config.aliases : ['jarvis', 'okay jarvis'];
|
const aliases = Array.isArray(config.aliases) ? config.aliases : ['jarvis', 'okay jarvis'];
|
||||||
return {
|
return {
|
||||||
command: process.env.JARVIS_WAKE_COMMAND || config.wakeCommand || config.wake_command || '',
|
command: process.env.JARVIS_WAKE_COMMAND || config.wakeCommand || config.wake_command || '',
|
||||||
phrases: [phrase, ...aliases].map((item) => String(item || '').trim()).filter(Boolean),
|
phrases: [phrase, ...aliases].map((item) => String(item || '').trim()).filter(Boolean),
|
||||||
ttsEnabled: config.ttsEnabled !== false && config.tts_enabled !== false,
|
ttsEnabled: flag(config.ttsEnabled ?? config.tts_enabled, true),
|
||||||
modelProfile: config.modelProfile || config.model_profile || 'laptop-16gb',
|
modelProfile: config.modelProfile || config.model_profile || 'laptop-16gb',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -12,7 +12,10 @@ leftover files from older versions cannot mix with the new tree. Failed staging
|
|||||||
directories next to the install prefix are removed first. Existing config and
|
directories next to the install prefix are removed first. Existing config and
|
||||||
data under `~/.config/jarvis`, `~/.local/share/jarvis`, and the model cache are
|
data under `~/.config/jarvis`, `~/.local/share/jarvis`, and the model cache are
|
||||||
preserved. If the service was running, the installer restarts it after the
|
preserved. If the service was running, the installer restarts it after the
|
||||||
replacement; `--enable` also enables the unit. Each of those steps is appended
|
replacement; `--enable` also enables the unit. The GNOME HUD files are always
|
||||||
|
copied into `~/.local/share/gnome-shell/extensions/[email protected]`, then the
|
||||||
|
installer asks the running Shell to reload and enable the extension so the
|
||||||
|
panel and overlay come back without a logout. Each of those steps is appended
|
||||||
to `~/.local/state/jarvis/install.log` (or `$XDG_STATE_HOME/jarvis/install.log`)
|
to `~/.local/state/jarvis/install.log` (or `$XDG_STATE_HOME/jarvis/install.log`)
|
||||||
and to the system log as tag `jarvis-install`. Run
|
and to the system log as tag `jarvis-install`. Run
|
||||||
`systemctl --user daemon-reload` only when you install the service file by
|
`systemctl --user daemon-reload` only when you install the service file by
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ optional, explicitly enabled fallback.
|
|||||||
|
|
||||||
## The extension is missing
|
## The extension is missing
|
||||||
|
|
||||||
Re-run the installer so it registers the bundle through GNOME's extension
|
Preferences can open while the panel HUD is gone if the extension is installed
|
||||||
installer. The installer also keeps a copy at
|
but not enabled in the running GNOME Shell. Re-run the installer so it copies
|
||||||
|
the HUD files and asks Shell to reload/enable `[email protected]`. The
|
||||||
|
installer also keeps a copy at
|
||||||
`~/.local/share/gnome-shell/extensions/[email protected]`:
|
`~/.local/share/gnome-shell/extensions/[email protected]`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+64
-44
@@ -79,23 +79,16 @@ else
|
|||||||
log "no previous GNOME extension at ${EXT_DIR}"
|
log "no previous GNOME extension at ${EXT_DIR}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v gnome-extensions >/dev/null; then
|
EXT_UUID="[email protected]"
|
||||||
if gnome-extensions disable [email protected] >/dev/null 2>&1; then
|
SHOULD_ENABLE_EXTENSION=false
|
||||||
log "disabled GNOME extension [email protected]"
|
if [[ "${1:-}" == "--enable" ]]; then SHOULD_ENABLE_EXTENSION=true; fi
|
||||||
else
|
if command -v gsettings >/dev/null; then
|
||||||
log "GNOME extension [email protected] was not enabled or could not be disabled"
|
ENABLED_EXTENSIONS="$(gsettings get org.gnome.shell enabled-extensions 2>/dev/null || printf '[]')"
|
||||||
fi
|
if [[ "${ENABLED_EXTENSIONS}" == *"${EXT_UUID}"* ]]; then SHOULD_ENABLE_EXTENSION=true; fi
|
||||||
if gnome-extensions uninstall [email protected] >/dev/null 2>&1; then
|
|
||||||
log "uninstalled GNOME extension [email protected]"
|
|
||||||
else
|
|
||||||
log "GNOME extension [email protected] was not registered or could not be uninstalled"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "gnome-extensions not found; removing extension files directly"
|
|
||||||
fi
|
fi
|
||||||
if [[ -e "${EXT_DIR}" ]]; then
|
if command -v gnome-extensions >/dev/null && gnome-extensions info "${EXT_UUID}" 2>/dev/null | grep -q 'Enabled: Yes'; then
|
||||||
log "removing leftover extension files at ${EXT_DIR}"
|
SHOULD_ENABLE_EXTENSION=true
|
||||||
rm -rf "${EXT_DIR}"
|
log "GNOME extension ${EXT_UUID} is currently enabled; it will be reactivated after the file replace"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
@@ -131,25 +124,65 @@ chmod 755 "${BARE_BIN}"
|
|||||||
log "using Bare runtime ${BARE_BIN}"
|
log "using Bare runtime ${BARE_BIN}"
|
||||||
EXT_TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/jarvis-extension.XXXXXX")"
|
EXT_TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/jarvis-extension.XXXXXX")"
|
||||||
EXT_ZIP="${EXT_TMP_DIR}/jarvis-extension.zip"
|
EXT_ZIP="${EXT_TMP_DIR}/jarvis-extension.zip"
|
||||||
|
log "replacing GNOME extension files at ${EXT_DIR}"
|
||||||
|
rm -rf "${EXT_DIR}"
|
||||||
mkdir -p "${EXT_DIR}"
|
mkdir -p "${EXT_DIR}"
|
||||||
|
cp -a "${ROOT_DIR}/apps/gnome-extension/[email protected]/." "${EXT_DIR}/"
|
||||||
|
log "copied GNOME extension files to ${EXT_DIR}"
|
||||||
( cd "${ROOT_DIR}/apps/gnome-extension/[email protected]" && zip -q -r "${EXT_ZIP}" . )
|
( cd "${ROOT_DIR}/apps/gnome-extension/[email protected]" && zip -q -r "${EXT_ZIP}" . )
|
||||||
if command -v gnome-extensions >/dev/null; then
|
if command -v gnome-extensions >/dev/null; then
|
||||||
if gnome-extensions install --force "${EXT_ZIP}"; then
|
if gnome-extensions install --force "${EXT_ZIP}"; then
|
||||||
log "registered GNOME extension from ${EXT_ZIP}"
|
log "registered GNOME extension from ${EXT_ZIP}"
|
||||||
else
|
else
|
||||||
log "warning: GNOME extension registration failed; copying files to ${EXT_DIR}"
|
log "warning: gnome-extensions install failed; the HUD files are already in ${EXT_DIR}"
|
||||||
echo "GNOME extension registration failed; installing the files directly." >&2
|
echo "GNOME extension registration failed; the files were copied directly." >&2
|
||||||
cp -a "${ROOT_DIR}/apps/gnome-extension/[email protected]/." "${EXT_DIR}/"
|
|
||||||
log "copied GNOME extension files to ${EXT_DIR}"
|
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
cp -a "${ROOT_DIR}/apps/gnome-extension/[email protected]/." "${EXT_DIR}/"
|
|
||||||
log "copied GNOME extension files to ${EXT_DIR}"
|
|
||||||
fi
|
fi
|
||||||
if command -v glib-compile-schemas >/dev/null && [[ -d "${EXT_DIR}/schemas" ]]; then
|
if command -v glib-compile-schemas >/dev/null && [[ -d "${EXT_DIR}/schemas" ]]; then
|
||||||
glib-compile-schemas "${EXT_DIR}/schemas" >/dev/null
|
glib-compile-schemas "${EXT_DIR}/schemas" >/dev/null
|
||||||
log "compiled GSettings schemas in ${EXT_DIR}/schemas"
|
log "compiled GSettings schemas in ${EXT_DIR}/schemas"
|
||||||
fi
|
fi
|
||||||
|
persist_enabled_extension() {
|
||||||
|
command -v gsettings >/dev/null || return 0
|
||||||
|
gsettings writable org.gnome.shell enabled-extensions >/dev/null 2>&1 || return 0
|
||||||
|
ENABLED_EXTENSIONS="$(gsettings get org.gnome.shell enabled-extensions 2>/dev/null || printf '[]')"
|
||||||
|
if [[ "${ENABLED_EXTENSIONS}" == *"${EXT_UUID}"* ]]; then
|
||||||
|
log "org.gnome.shell enabled-extensions already lists ${EXT_UUID}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ "${ENABLED_EXTENSIONS}" == "[]" ]]; then
|
||||||
|
ENABLED_EXTENSIONS="['${EXT_UUID}']"
|
||||||
|
else
|
||||||
|
ENABLED_EXTENSIONS="${ENABLED_EXTENSIONS%]}, '${EXT_UUID}']"
|
||||||
|
fi
|
||||||
|
gsettings set org.gnome.shell enabled-extensions "${ENABLED_EXTENSIONS}"
|
||||||
|
log "persisted ${EXT_UUID} in org.gnome.shell enabled-extensions"
|
||||||
|
}
|
||||||
|
activate_extension() {
|
||||||
|
persist_enabled_extension
|
||||||
|
if command -v gdbus >/dev/null; then
|
||||||
|
if gdbus call --session --dest org.gnome.Shell.Extensions --object-path /org/gnome/Shell/Extensions --method org.gnome.Shell.Extensions.ReloadExtension "${EXT_UUID}" >/dev/null 2>&1; then
|
||||||
|
log "reloaded ${EXT_UUID} in the running GNOME Shell"
|
||||||
|
else
|
||||||
|
log "GNOME Shell did not reload ${EXT_UUID} yet"
|
||||||
|
fi
|
||||||
|
if gdbus call --session --dest org.gnome.Shell.Extensions --object-path /org/gnome/Shell/Extensions --method org.gnome.Shell.Extensions.EnableExtension "${EXT_UUID}" >/dev/null 2>&1; then
|
||||||
|
log "GNOME Shell EnableExtension succeeded for ${EXT_UUID}"
|
||||||
|
else
|
||||||
|
log "GNOME Shell EnableExtension did not activate ${EXT_UUID}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if command -v gnome-extensions >/dev/null && gnome-extensions enable "${EXT_UUID}" >/dev/null 2>&1; then
|
||||||
|
log "enabled GNOME extension ${EXT_UUID}"
|
||||||
|
else
|
||||||
|
log "gnome-extensions enable ${EXT_UUID} failed"
|
||||||
|
fi
|
||||||
|
if command -v gnome-extensions >/dev/null && gnome-extensions info "${EXT_UUID}" 2>/dev/null | grep -q 'Enabled: Yes'; then
|
||||||
|
log "GNOME extension ${EXT_UUID} is enabled in this session"
|
||||||
|
else
|
||||||
|
log "GNOME extension ${EXT_UUID} is installed but not active in this session; log out and back in, or run: gnome-extensions enable ${EXT_UUID}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
sed "s#__JARVIS_BARE__#${BARE_BIN}#" "${ROOT_DIR}/packaging/jarvisd.service" > "${HOME}/.config/systemd/user/jarvisd.service"
|
sed "s#__JARVIS_BARE__#${BARE_BIN}#" "${ROOT_DIR}/packaging/jarvisd.service" > "${HOME}/.config/systemd/user/jarvisd.service"
|
||||||
log "wrote ${HOME}/.config/systemd/user/jarvisd.service"
|
log "wrote ${HOME}/.config/systemd/user/jarvisd.service"
|
||||||
if [[ -f "${CONFIG_DIR}/config.json" ]]; then
|
if [[ -f "${CONFIG_DIR}/config.json" ]]; then
|
||||||
@@ -173,28 +206,15 @@ restart_user_service() {
|
|||||||
}
|
}
|
||||||
if [[ "${1:-}" == "--enable" ]]; then
|
if [[ "${1:-}" == "--enable" ]]; then
|
||||||
restart_user_service enable
|
restart_user_service enable
|
||||||
if command -v gnome-extensions >/dev/null && gnome-extensions enable [email protected] >/dev/null 2>&1; then
|
activate_extension
|
||||||
log "enabled GNOME extension [email protected]"
|
else
|
||||||
elif command -v gsettings >/dev/null && gsettings writable org.gnome.shell enabled-extensions >/dev/null 2>&1; then
|
if [[ "${PREVIOUSLY_ACTIVE}" == true ]]; then
|
||||||
# A running GNOME Shell keeps its extension catalogue until the next
|
restart_user_service
|
||||||
# session. Persist the enabled state so the extension starts on login.
|
log "restarted jarvisd.service with the upgraded files"
|
||||||
ENABLED_EXTENSIONS="$(gsettings get org.gnome.shell enabled-extensions 2>/dev/null || printf '[]')"
|
fi
|
||||||
if [[ "${ENABLED_EXTENSIONS}" != *"[email protected]"* ]]; then
|
if [[ "${SHOULD_ENABLE_EXTENSION}" == true ]]; then
|
||||||
if [[ "${ENABLED_EXTENSIONS}" == "[]" ]]; then
|
activate_extension
|
||||||
ENABLED_EXTENSIONS="['[email protected]']"
|
|
||||||
else
|
|
||||||
ENABLED_EXTENSIONS="${ENABLED_EXTENSIONS%]}, '[email protected]']"
|
|
||||||
fi
|
|
||||||
gsettings set org.gnome.shell enabled-extensions "${ENABLED_EXTENSIONS}"
|
|
||||||
log "persisted [email protected] in org.gnome.shell enabled-extensions"
|
|
||||||
fi
|
|
||||||
log "GNOME Shell will enable Jarvis after the next login (the current Shell session has not rescanned extensions)"
|
|
||||||
else
|
|
||||||
log "GNOME Shell has not rescanned extensions; log out and back in, then run: gnome-extensions enable [email protected]"
|
|
||||||
fi
|
fi
|
||||||
elif [[ "${PREVIOUSLY_ACTIVE}" == true ]]; then
|
|
||||||
restart_user_service
|
|
||||||
log "restarted jarvisd.service with the upgraded files"
|
|
||||||
fi
|
fi
|
||||||
log "install complete; log file ${LOG_FILE}"
|
log "install complete; log file ${LOG_FILE}"
|
||||||
echo "Installed JARVIS-QVAC to ${APP_DIR}"
|
echo "Installed JARVIS-QVAC to ${APP_DIR}"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { QvacScheduler } from '../daemon/qvac-scheduler.js';
|
|||||||
import { JarvisDaemon } from '../daemon/index.js';
|
import { JarvisDaemon } from '../daemon/index.js';
|
||||||
import { HarnessBridge } from '../daemon/harness-bridge.js';
|
import { HarnessBridge } from '../daemon/harness-bridge.js';
|
||||||
import { spokenReply } from '../skills/voice-prompt.js';
|
import { spokenReply } from '../skills/voice-prompt.js';
|
||||||
|
import { voiceSettings } from '../daemon/voice-settings.js';
|
||||||
|
|
||||||
test('voice state machine handles wake, reply, cancel, and idle sleep', () => {
|
test('voice state machine handles wake, reply, cancel, and idle sleep', () => {
|
||||||
let now = 0;
|
let now = 0;
|
||||||
@@ -37,6 +38,13 @@ test('spoken replies use harness text and strip HUD sidecars', () => {
|
|||||||
assert.equal(spokenReply('[object Object]'), '');
|
assert.equal(spokenReply('[object Object]'), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('voice settings default TTS on and honor an explicit disable', () => {
|
||||||
|
assert.equal(voiceSettings({}).ttsEnabled, true);
|
||||||
|
assert.equal(voiceSettings({ ttsEnabled: true }).ttsEnabled, true);
|
||||||
|
assert.equal(voiceSettings({ ttsEnabled: false }).ttsEnabled, false);
|
||||||
|
assert.equal(voiceSettings({ tts_enabled: false }).ttsEnabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('ask extracts harness reply text instead of stringifying the object', async () => {
|
test('ask extracts harness reply text instead of stringifying the object', async () => {
|
||||||
const daemon = new JarvisDaemon();
|
const daemon = new JarvisDaemon();
|
||||||
daemon.harness = { ask: async () => ({ ok: true, text: 'Hello there.', reason: 'stop' }), cancel() {}, close: async () => {} };
|
daemon.harness = { ask: async () => ({ ok: true, text: 'Hello there.', reason: 'stop' }), cancel() {}, close: async () => {} };
|
||||||
|
|||||||
+19
-1
@@ -65,6 +65,16 @@ exit 0
|
|||||||
await writeFile(path.join(bin, 'gnome-extensions'), `#!/bin/sh
|
await writeFile(path.join(bin, 'gnome-extensions'), `#!/bin/sh
|
||||||
echo "gnome-extensions $*" >> '${log}'
|
echo "gnome-extensions $*" >> '${log}'
|
||||||
if [ "$1" = install ]; then exit 1; fi
|
if [ "$1" = install ]; then exit 1; fi
|
||||||
|
if [ "$1" = info ]; then printf '%s\\n' 'Enabled: Yes' 'State: ACTIVE'; exit 0; fi
|
||||||
|
exit 0
|
||||||
|
`);
|
||||||
|
await writeFile(path.join(bin, 'gsettings'), `#!/bin/sh
|
||||||
|
echo "gsettings $*" >> '${log}'
|
||||||
|
if [ "$1" = get ]; then printf "%s\\n" "['[email protected]']"; fi
|
||||||
|
exit 0
|
||||||
|
`);
|
||||||
|
await writeFile(path.join(bin, 'gdbus'), `#!/bin/sh
|
||||||
|
echo "gdbus $*" >> '${log}'
|
||||||
exit 0
|
exit 0
|
||||||
`);
|
`);
|
||||||
await writeFile(path.join(bin, 'logger'), `#!/bin/sh
|
await writeFile(path.join(bin, 'logger'), `#!/bin/sh
|
||||||
@@ -73,6 +83,8 @@ exit 0
|
|||||||
`);
|
`);
|
||||||
await chmod(path.join(bin, 'systemctl'), 0o755);
|
await chmod(path.join(bin, 'systemctl'), 0o755);
|
||||||
await chmod(path.join(bin, 'gnome-extensions'), 0o755);
|
await chmod(path.join(bin, 'gnome-extensions'), 0o755);
|
||||||
|
await chmod(path.join(bin, 'gsettings'), 0o755);
|
||||||
|
await chmod(path.join(bin, 'gdbus'), 0o755);
|
||||||
await chmod(path.join(bin, 'logger'), 0o755);
|
await chmod(path.join(bin, 'logger'), 0o755);
|
||||||
|
|
||||||
const result = await run('bash', [path.join(root, 'packaging/install.sh'), '--enable'], {
|
const result = await run('bash', [path.join(root, 'packaging/install.sh'), '--enable'], {
|
||||||
@@ -96,14 +108,20 @@ exit 0
|
|||||||
assert.doesNotMatch(unit, /\/usr\/bin\/node/);
|
assert.doesNotMatch(unit, /\/usr\/bin\/node/);
|
||||||
const commands = await readFile(log, 'utf8');
|
const commands = await readFile(log, 'utf8');
|
||||||
assert.match(commands, /systemctl --user stop jarvisd\.service/);
|
assert.match(commands, /systemctl --user stop jarvisd\.service/);
|
||||||
assert.match(commands, /gnome-extensions uninstall jarvis@qvac\.local/);
|
assert.match(commands, /gnome-extensions enable jarvis@qvac\.local/);
|
||||||
|
assert.match(commands, /gdbus call .*ReloadExtension/);
|
||||||
|
assert.match(commands, /gdbus call .*EnableExtension/);
|
||||||
assert.match(commands, /systemctl --user restart jarvisd\.service/);
|
assert.match(commands, /systemctl --user restart jarvisd\.service/);
|
||||||
const installLog = await readFile(path.join(home, '.local/state/jarvis/install.log'), 'utf8');
|
const installLog = await readFile(path.join(home, '.local/state/jarvis/install.log'), 'utf8');
|
||||||
assert.match(installLog, /stopping running jarvisd\.service/);
|
assert.match(installLog, /stopping running jarvisd\.service/);
|
||||||
assert.match(installLog, /removing leftover staging directory/);
|
assert.match(installLog, /removing leftover staging directory/);
|
||||||
assert.match(installLog, /removing previous application tree/);
|
assert.match(installLog, /removing previous application tree/);
|
||||||
|
assert.match(installLog, /copied GNOME extension files/);
|
||||||
|
assert.match(installLog, /reloaded jarvis@qvac\.local in the running GNOME Shell/);
|
||||||
|
assert.match(installLog, /enabled GNOME extension jarvis@qvac\.local/);
|
||||||
assert.match(installLog, /kept existing .*config\.json/);
|
assert.match(installLog, /kept existing .*config\.json/);
|
||||||
assert.match(installLog, /install complete; log file/);
|
assert.match(installLog, /install complete; log file/);
|
||||||
|
assert.doesNotMatch(installLog, /will enable Jarvis after the next login/);
|
||||||
assert.match(result.stdout, /jarvis-install: stopping running jarvisd\.service/);
|
assert.match(result.stdout, /jarvis-install: stopping running jarvisd\.service/);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(work, { recursive: true, force: true });
|
await rm(work, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { VoiceLoop, fastCommand } from '../daemon/voice-loop.js';
|
import { VoiceLoop, fastCommand } from '../daemon/voice-loop.js';
|
||||||
import { pcmS16le } from '../daemon/voice-adapters.js';
|
import { pcmS16le } from '../daemon/voice-adapters.js';
|
||||||
import { WakeEngine } from '../daemon/wake-engine.js';
|
import { WakeEngine } from '../daemon/wake-engine.js';
|
||||||
import { VadSegmenter } from '../daemon/vad.js';
|
import { VadSegmenter } from '../daemon/vad.js';
|
||||||
import { PipeWireCapture, pcmRms } from '../daemon/audio-pipewire.js';
|
import { PipeWireCapture, pcmRms } from '../daemon/audio-pipewire.js';
|
||||||
import { SentenceBuffer, isMeaningfulTranscript } from '../daemon/transcript.js';
|
import { SentenceBuffer, isMeaningfulTranscript, isSpeakable } from '../daemon/transcript.js';
|
||||||
|
|
||||||
test('Phase 4 transcript filtering and sentence buffering are deterministic', () => {
|
test('Phase 4 transcript filtering and sentence buffering are deterministic', () => {
|
||||||
assert.equal(isMeaningfulTranscript('[BLANK_AUDIO]'), false);
|
assert.equal(isMeaningfulTranscript('[BLANK_AUDIO]'), false);
|
||||||
assert.equal(isMeaningfulTranscript('hi'), false);
|
assert.equal(isMeaningfulTranscript('hi'), false);
|
||||||
|
assert.equal(isSpeakable('OK.'), true);
|
||||||
|
assert.equal(isSpeakable('[object Object]'), false);
|
||||||
assert.equal(isMeaningfulTranscript('what time is it'), true);
|
assert.equal(isMeaningfulTranscript('what time is it'), true);
|
||||||
const out = []; const buffer = new SentenceBuffer({ onSentence: (s) => out.push(s) });
|
const out = []; const buffer = new SentenceBuffer({ onSentence: (s) => out.push(s) });
|
||||||
buffer.push('First sentence. Second'); buffer.push(' sentence!'); buffer.flush();
|
buffer.push('First sentence. Second'); buffer.push(' sentence!'); buffer.flush();
|
||||||
@@ -94,6 +97,9 @@ test('ASR failure preserves speech output and reports unavailable microphone', a
|
|||||||
await loop.start(); await loop.speak('Speech still works.');
|
await loop.start(); await loop.speak('Speech still works.');
|
||||||
assert.equal(spoken, 'Speech still works.'); assert.equal(captured, false);
|
assert.equal(spoken, 'Speech still works.'); assert.equal(captured, false);
|
||||||
assert.equal(loop.status.tts, true); assert.equal(loop.status.asr, false);
|
assert.equal(loop.status.tts, true); assert.equal(loop.status.asr, false);
|
||||||
|
spoken = '';
|
||||||
|
await loop.speak('OK.');
|
||||||
|
assert.equal(spoken, 'OK.');
|
||||||
assert.match(loop.status.errors.asr, /Whisper/); await loop.stop();
|
assert.match(loop.status.errors.asr, /Whisper/); await loop.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,3 +117,9 @@ test('TTS failure preserves ASR and push to talk without wake command', async ()
|
|||||||
test('QVAC numeric PCM arrays preserve signed 16-bit samples', () => {
|
test('QVAC numeric PCM arrays preserve signed 16-bit samples', () => {
|
||||||
assert.deepEqual([...pcmS16le([256, -32768, 32767]).samples], [256, -32768, 32767]);
|
assert.deepEqual([...pcmS16le([256, -32768, 32767]).samples], [256, -32768, 32767]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('voice adapters load canonical ASR and TTS plugins', () => {
|
||||||
|
const source = readFileSync(new URL('../daemon/voice-adapters.js', import.meta.url), 'utf8');
|
||||||
|
assert.match(source, /whispercpp-transcription/);
|
||||||
|
assert.match(source, /tts-ggml/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user