+33
-3
@@ -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 } }); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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'); }
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
+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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+64
-44
@@ -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"
|
||||
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"
|
||||
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 [[ -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,28 +206,15 @@ 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]']"
|
||||
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]"
|
||||
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}"
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user