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
+32 -2
View File
@@ -102,13 +102,40 @@ export class JarvisDaemon extends EventEmitter {
if (this.state === 'SPEAKING') this.setState('LISTENING');
}
_speakReply(spoken) {
const play = async () => {
await this.ensureTts();
const playing = this.voiceLoop?.speak?.(spoken);
if (!playing) { this._finishSpeech(); return; }
Promise.resolve(playing).catch((error) => {
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 } }); }
+9 -2
View File
@@ -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;
}
+2 -2
View File
@@ -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; }
}
+13 -7
View File
@@ -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'); }
+12 -3
View File
@@ -2,15 +2,24 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
export function voiceSettings() {
let config = {};
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',
};
}
+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
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/[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`)
and to the system log as tag `jarvis-install`. Run
`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
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 `[email protected]`. The
installer also keeps a copy at
`~/.local/share/gnome-shell/extensions/[email protected]`:
```bash
+62 -42
View File
@@ -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 [email protected] >/dev/null 2>&1; then
log "disabled GNOME extension [email protected]"
else
log "GNOME extension [email protected] was not enabled or could not be disabled"
EXT_UUID="[email protected]"
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 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
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/[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}" . )
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/[email protected]/." "${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/[email protected]/." "${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,29 +206,16 @@ restart_user_service() {
}
if [[ "${1:-}" == "--enable" ]]; then
restart_user_service enable
if command -v gnome-extensions >/dev/null && gnome-extensions enable [email protected] >/dev/null 2>&1; then
log "enabled GNOME extension [email protected]"
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}" != *"[email protected]"* ]]; then
if [[ "${ENABLED_EXTENSIONS}" == "[]" ]]; then
ENABLED_EXTENSIONS="['[email protected]']"
activate_extension
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
elif [[ "${PREVIOUSLY_ACTIVE}" == true ]]; then
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
fi
log "install complete; log file ${LOG_FILE}"
echo "Installed JARVIS-QVAC to ${APP_DIR}"
echo "Run: ${APP_DIR}/packaging/first-run.sh"
+8
View File
@@ -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 () => {} };
+19 -1
View File
@@ -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" "['[email protected]']"; 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 });
+13 -1
View File
@@ -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/);
});