From e4546f8e95350f92bc80b9c36d49bac47d701ec7 Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Fri, 11 Sep 2026 21:56:55 -0400 Subject: [PATCH] Fix TTS --- daemon/index.js | 36 +++++++++++-- daemon/transcript.js | 11 +++- daemon/voice-adapters.js | 4 +- daemon/voice-loop.js | 20 ++++--- daemon/voice-settings.js | 17 ++++-- docs/MIGRATIONS.md | 5 +- docs/troubleshooting.md | 6 ++- packaging/install.sh | 108 ++++++++++++++++++++++---------------- test/daemon.test.js | 8 +++ test/install.test.js | 20 ++++++- test/voice-phase4.test.js | 14 ++++- 11 files changed, 182 insertions(+), 67 deletions(-) diff --git a/daemon/index.js b/daemon/index.js index 3f58740..6430a7c 100644 --- a/daemon/index.js +++ b/daemon/index.js @@ -102,13 +102,40 @@ export class JarvisDaemon extends EventEmitter { if (this.state === 'SPEAKING') this.setState('LISTENING'); } _speakReply(spoken) { - const playing = this.voiceLoop?.speak?.(spoken); - if (!playing) { this._finishSpeech(); return; } - Promise.resolve(playing).catch((error) => { + const play = async () => { + await this.ensureTts(); + 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); 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)); } 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' })); } @@ -117,12 +144,15 @@ export class JarvisDaemon extends EventEmitter { const settings = voiceSettings(); const asr = new QvacVoiceAdapter({ role: 'asr' }); 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 }); loop.on('error', (error) => { console.error(`jarvisd: voice: ${error.message}`); this.emit('Error', 'VOICE', error.message); }); this.voiceLoop = loop; 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; } + 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)); } 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 } }); } diff --git a/daemon/transcript.js b/daemon/transcript.js index fd75d09..0a8e879 100644 --- a/daemon/transcript.js +++ b/daemon/transcript.js @@ -1,9 +1,16 @@ 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) { const value = String(text || '').trim(); - if (!value || /\[no speech detected\]|\[blank_audio\]/i.test(value)) return false; - if (/^\[[^\]]+\]$/.test(value)) return false; + if (!isSpeakable(value)) return false; return value.replace(/[^\p{L}\p{N}]/gu, '').length >= MIN_UTTERANCE_CHARS; } diff --git a/daemon/voice-adapters.js b/daemon/voice-adapters.js index 66472eb..14f8d3a 100644 --- a/daemon/voice-adapters.js +++ b/daemon/voice-adapters.js @@ -29,8 +29,8 @@ export class QvacVoiceAdapter extends EventEmitter { assertSdkVersion(); await acquireQvac({ auxiliaryOnly: true }); this.acquired = true; 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 !== 'asr') this.ttsId = await loadAuxiliaryModel(this.ttsModel, { ttsEngine: 'supertonic', language: 'en', voice: 'F1', ttsSpeed: 1.05, ttsNumInferenceSteps: 5 }, 'tts'); + 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-ggml'); } catch (error) { await this.stop(); throw error; } } diff --git a/daemon/voice-loop.js b/daemon/voice-loop.js index e94a20f..ba9490d 100644 --- a/daemon/voice-loop.js +++ b/daemon/voice-loop.js @@ -3,7 +3,7 @@ import { PipeWireCapture } from './audio-pipewire.js'; import { PipeWirePlayback } from './audio-playback.js'; import { WakeEngine } from './wake-engine.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'; export const POST_PLAYBACK_COOLDOWN_MS = 400; @@ -37,8 +37,14 @@ export class VoiceLoop extends EventEmitter { async start() { if (this.running) return; for (const [name, adapter] of [['tts', this.tts], ['asr', this.asr]]) { - try { if (adapter) { await adapter.start?.(); this.status[name] = true; } } - catch (error) { this.status.errors[name] = error.message; this.emit('error', new Error(`${name.toUpperCase()}: ${error.message}`)); } + try { + 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; if (this.status.asr) { @@ -53,7 +59,7 @@ export class VoiceLoop extends EventEmitter { pushAudio(chunk) { if (!this.running || this.daemon?.locked || this.daemon?.state === 'SLEEPING') 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.daemon?.state === 'LISTENING') this.vad.push(chunk); } @@ -89,9 +95,9 @@ export class VoiceLoop extends EventEmitter { } async speak(text) { 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(); - 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; } this._speechQueue = this._speechQueue.catch(() => {}).then(async () => { if (generation !== this._generation) return; @@ -104,7 +110,7 @@ export class VoiceLoop extends EventEmitter { } async speakSentence(text) { 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 { 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'); } diff --git a/daemon/voice-settings.js b/daemon/voice-settings.js index dcb754c..62f222a 100644 --- a/daemon/voice-settings.js +++ b/daemon/voice-settings.js @@ -2,15 +2,24 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -export function voiceSettings() { - let config = {}; - try { config = JSON.parse(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'jarvis/config.json'), 'utf8')); } catch {} +function flag(value, fallback = true) { + if (value === undefined || value === null) return fallback; + 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 aliases = Array.isArray(config.aliases) ? config.aliases : ['jarvis', 'okay jarvis']; return { command: process.env.JARVIS_WAKE_COMMAND || config.wakeCommand || config.wake_command || '', 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', }; } diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md index 7c78347..d2d7b78 100644 --- a/docs/MIGRATIONS.md +++ b/docs/MIGRATIONS.md @@ -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 data under `~/.config/jarvis`, `~/.local/share/jarvis`, and the model cache are 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/jarvis@qvac.local`, 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`) and to the system log as tag `jarvis-install`. Run `systemctl --user daemon-reload` only when you install the service file by diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5bf130e..1bcc801 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -24,8 +24,10 @@ optional, explicitly enabled fallback. ## The extension is missing -Re-run the installer so it registers the bundle through GNOME's extension -installer. The installer also keeps a copy at +Preferences can open while the panel HUD is gone if the extension is installed +but not enabled in the running GNOME Shell. Re-run the installer so it copies +the HUD files and asks Shell to reload/enable `jarvis@qvac.local`. The +installer also keeps a copy at `~/.local/share/gnome-shell/extensions/jarvis@qvac.local`: ```bash diff --git a/packaging/install.sh b/packaging/install.sh index 1c5240e..a11b814 100755 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -79,23 +79,16 @@ else log "no previous GNOME extension at ${EXT_DIR}" fi -if command -v gnome-extensions >/dev/null; then - if gnome-extensions disable jarvis@qvac.local >/dev/null 2>&1; then - log "disabled GNOME extension jarvis@qvac.local" - else - log "GNOME extension jarvis@qvac.local was not enabled or could not be disabled" - fi - if gnome-extensions uninstall jarvis@qvac.local >/dev/null 2>&1; then - log "uninstalled GNOME extension jarvis@qvac.local" - else - log "GNOME extension jarvis@qvac.local was not registered or could not be uninstalled" - fi -else - log "gnome-extensions not found; removing extension files directly" +EXT_UUID="jarvis@qvac.local" +SHOULD_ENABLE_EXTENSION=false +if [[ "${1:-}" == "--enable" ]]; then SHOULD_ENABLE_EXTENSION=true; fi +if command -v gsettings >/dev/null; then + ENABLED_EXTENSIONS="$(gsettings get org.gnome.shell enabled-extensions 2>/dev/null || printf '[]')" + if [[ "${ENABLED_EXTENSIONS}" == *"${EXT_UUID}"* ]]; then SHOULD_ENABLE_EXTENSION=true; fi fi -if [[ -e "${EXT_DIR}" ]]; then - log "removing leftover extension files at ${EXT_DIR}" - rm -rf "${EXT_DIR}" +if command -v gnome-extensions >/dev/null && gnome-extensions info "${EXT_UUID}" 2>/dev/null | grep -q 'Enabled: Yes'; then + SHOULD_ENABLE_EXTENSION=true + log "GNOME extension ${EXT_UUID} is currently enabled; it will be reactivated after the file replace" fi shopt -s nullglob @@ -131,25 +124,65 @@ chmod 755 "${BARE_BIN}" log "using Bare runtime ${BARE_BIN}" EXT_TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/jarvis-extension.XXXXXX")" EXT_ZIP="${EXT_TMP_DIR}/jarvis-extension.zip" +log "replacing GNOME extension files at ${EXT_DIR}" +rm -rf "${EXT_DIR}" mkdir -p "${EXT_DIR}" +cp -a "${ROOT_DIR}/apps/gnome-extension/jarvis@qvac.local/." "${EXT_DIR}/" +log "copied GNOME extension files to ${EXT_DIR}" ( cd "${ROOT_DIR}/apps/gnome-extension/jarvis@qvac.local" && zip -q -r "${EXT_ZIP}" . ) if command -v gnome-extensions >/dev/null; then if gnome-extensions install --force "${EXT_ZIP}"; then log "registered GNOME extension from ${EXT_ZIP}" else - log "warning: GNOME extension registration failed; copying files to ${EXT_DIR}" - echo "GNOME extension registration failed; installing the files directly." >&2 - cp -a "${ROOT_DIR}/apps/gnome-extension/jarvis@qvac.local/." "${EXT_DIR}/" - log "copied GNOME extension files to ${EXT_DIR}" + log "warning: gnome-extensions install failed; the HUD files are already in ${EXT_DIR}" + echo "GNOME extension registration failed; the files were copied directly." >&2 fi -else - cp -a "${ROOT_DIR}/apps/gnome-extension/jarvis@qvac.local/." "${EXT_DIR}/" - log "copied GNOME extension files to ${EXT_DIR}" fi if command -v glib-compile-schemas >/dev/null && [[ -d "${EXT_DIR}/schemas" ]]; then glib-compile-schemas "${EXT_DIR}/schemas" >/dev/null log "compiled GSettings schemas in ${EXT_DIR}/schemas" 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" log "wrote ${HOME}/.config/systemd/user/jarvisd.service" if [[ -f "${CONFIG_DIR}/config.json" ]]; then @@ -173,28 +206,15 @@ restart_user_service() { } if [[ "${1:-}" == "--enable" ]]; then restart_user_service enable - if command -v gnome-extensions >/dev/null && gnome-extensions enable jarvis@qvac.local >/dev/null 2>&1; then - log "enabled GNOME extension jarvis@qvac.local" - elif command -v gsettings >/dev/null && gsettings writable org.gnome.shell enabled-extensions >/dev/null 2>&1; then - # A running GNOME Shell keeps its extension catalogue until the next - # session. Persist the enabled state so the extension starts on login. - ENABLED_EXTENSIONS="$(gsettings get org.gnome.shell enabled-extensions 2>/dev/null || printf '[]')" - if [[ "${ENABLED_EXTENSIONS}" != *"jarvis@qvac.local"* ]]; then - if [[ "${ENABLED_EXTENSIONS}" == "[]" ]]; then - ENABLED_EXTENSIONS="['jarvis@qvac.local']" - else - ENABLED_EXTENSIONS="${ENABLED_EXTENSIONS%]}, 'jarvis@qvac.local']" - fi - gsettings set org.gnome.shell enabled-extensions "${ENABLED_EXTENSIONS}" - log "persisted jarvis@qvac.local 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 jarvis@qvac.local" + activate_extension +else + if [[ "${PREVIOUSLY_ACTIVE}" == true ]]; then + restart_user_service + log "restarted jarvisd.service with the upgraded files" + fi + if [[ "${SHOULD_ENABLE_EXTENSION}" == true ]]; then + activate_extension fi -elif [[ "${PREVIOUSLY_ACTIVE}" == true ]]; then - restart_user_service - log "restarted jarvisd.service with the upgraded files" fi log "install complete; log file ${LOG_FILE}" echo "Installed JARVIS-QVAC to ${APP_DIR}" diff --git a/test/daemon.test.js b/test/daemon.test.js index fe13d86..8bc7aac 100644 --- a/test/daemon.test.js +++ b/test/daemon.test.js @@ -5,6 +5,7 @@ import { QvacScheduler } from '../daemon/qvac-scheduler.js'; import { JarvisDaemon } from '../daemon/index.js'; import { HarnessBridge } from '../daemon/harness-bridge.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', () => { let now = 0; @@ -37,6 +38,13 @@ test('spoken replies use harness text and strip HUD sidecars', () => { 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 () => { const daemon = new JarvisDaemon(); daemon.harness = { ask: async () => ({ ok: true, text: 'Hello there.', reason: 'stop' }), cancel() {}, close: async () => {} }; diff --git a/test/install.test.js b/test/install.test.js index f3410df..ef94b59 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -65,6 +65,16 @@ exit 0 await writeFile(path.join(bin, 'gnome-extensions'), `#!/bin/sh echo "gnome-extensions $*" >> '${log}' 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" "['jarvis@qvac.local']"; fi +exit 0 +`); + await writeFile(path.join(bin, 'gdbus'), `#!/bin/sh +echo "gdbus $*" >> '${log}' exit 0 `); 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, 'gnome-extensions'), 0o755); + await chmod(path.join(bin, 'gsettings'), 0o755); + await chmod(path.join(bin, 'gdbus'), 0o755); await chmod(path.join(bin, 'logger'), 0o755); const result = await run('bash', [path.join(root, 'packaging/install.sh'), '--enable'], { @@ -96,14 +108,20 @@ exit 0 assert.doesNotMatch(unit, /\/usr\/bin\/node/); const commands = await readFile(log, 'utf8'); 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/); const installLog = await readFile(path.join(home, '.local/state/jarvis/install.log'), 'utf8'); assert.match(installLog, /stopping running jarvisd\.service/); assert.match(installLog, /removing leftover staging directory/); 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, /install complete; log file/); + assert.doesNotMatch(installLog, /will enable Jarvis after the next login/); assert.match(result.stdout, /jarvis-install: stopping running jarvisd\.service/); } finally { await rm(work, { recursive: true, force: true }); diff --git a/test/voice-phase4.test.js b/test/voice-phase4.test.js index 2c7176b..ff0e7aa 100644 --- a/test/voice-phase4.test.js +++ b/test/voice-phase4.test.js @@ -1,16 +1,19 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { EventEmitter } from 'node:events'; import { VoiceLoop, fastCommand } from '../daemon/voice-loop.js'; import { pcmS16le } from '../daemon/voice-adapters.js'; import { WakeEngine } from '../daemon/wake-engine.js'; import { VadSegmenter } from '../daemon/vad.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', () => { assert.equal(isMeaningfulTranscript('[BLANK_AUDIO]'), 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); const out = []; const buffer = new SentenceBuffer({ onSentence: (s) => out.push(s) }); 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.'); assert.equal(spoken, 'Speech still works.'); assert.equal(captured, 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(); }); @@ -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', () => { 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/); +});