Fix TTS
Rolling release / release (push) Successful in 14m2s

This commit is contained in:
2026-09-11 21:56:55 -04:00
parent 324f0d5e3a
commit e4546f8e95
11 changed files with 182 additions and 67 deletions
+33 -3
View File
@@ -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 } }); }
+9 -2
View File
@@ -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;
} }
+2 -2
View File
@@ -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
View File
@@ -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'); }
+13 -4
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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}"
+8
View File
@@ -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
View File
@@ -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 });
+13 -1
View File
@@ -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/);
});