import test from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { SETTINGS_FIELDS, voiceSettings } from '../daemon/voice-settings.js'; import { mkdtempSync } from 'node:fs'; import { normalizeSettings, mergeSettings, settingVisible } from '../apps/gnome-extension/jarvis@qvac.local/settings-values.js'; import { TTS_PRESETS, ttsConfiguration, scaleSpeech } from '../daemon/tts-config.js'; import { QvacVoiceAdapter } from '../daemon/voice-adapters.js'; import { Agent } from '../daemon/qvac-master.js'; import { JarvisDaemon } from '../daemon/index.js'; import { ComputerUseSession } from '../computer-use/session.js'; import { PipeWireCapture } from '../daemon/audio-pipewire.js'; import { PipeWirePlayback } from '../daemon/audio-playback.js'; import { VadSegmenter } from '../daemon/vad.js'; import { VoiceLoop } from '../daemon/voice-loop.js'; import { ttsLoadConfigSchema } from '../node_modules/@qvac/inference/dist/schemas/text-to-speech.js'; import * as registry from '../node_modules/@qvac/inference/dist/models/registry/models.js'; process.env.XDG_DATA_HOME ||= mkdtempSync(path.join(tmpdir(), 'jarvis-settings-data-')); test('settings migrate aliases, retain explicit disables, and reject invalid numeric input', () => { const settings = voiceSettings({ tts_enabled: false, voice_id: 'M3', language: 'es-MX', computer_step_budget: '50', tts_speed: 1.4 }); assert.equal(settings.ttsEnabled, false); assert.equal(settings.voiceId, 'M3'); assert.equal(settings.asrLanguage, 'es'); assert.equal(settings.computerSteps, 50); assert.equal(settings.ttsSpeed, 1.4); assert.equal(voiceSettings({ ttsSpeed: 100 }).ttsSpeed, 1.05); assert.throws(() => voiceSettings({ ttsSpeed: 100 }, { strict: true }), /Speaking speed/); assert.throws(() => voiceSettings({ computerSteps: '' }, { strict: true }), /Actions per grant/); assert.equal(voiceSettings({ ttsEnabled: true, tts_enabled: false }).ttsEnabled, true); }); test('saving one window preserves unrelated changes from another and removes conflicting aliases', () => { const merged = mergeSettings({ tts_enabled: false, ttsSpeed: 1.5, unrelated: 'kept' }, { ttsEnabled: true }, SETTINGS_FIELDS); assert.deepEqual(merged, { ttsSpeed: 1.5, unrelated: 'kept', ttsEnabled: true }); assert.equal(normalizeSettings(merged, SETTINGS_FIELDS).ttsSpeed, 1.5); assert.equal(voiceSettings({ modelProfile: 'laptop-8gb-mm' }).modelProfile, 'laptop-8gb-mm'); }); test('every speech preset uses a real registry asset and passes the installed SDK schema', () => { for (const ttsPreset of Object.keys(TTS_PRESETS)) { const settings = voiceSettings({ ttsPreset, ttsLanguage: 'fr', voiceId: 'M4', ttsSpeed: 1.25, ttsDescription: 'A warm voice speaks softly.' }); const config = ttsConfiguration(settings); assert.ok(registry[config.model], config.model); assert.equal(ttsLoadConfigSchema.safeParse(config.config).success, true, ttsPreset); if (ttsPreset === 'supertonic-en') assert.equal(config.config.language, 'en'); if (ttsPreset === 'supertonic3') assert.equal(config.config.language, 'fr'); if (ttsPreset === 'parler') { assert.equal(config.config.description, settings.ttsDescription); assert.equal(config.config.voice, undefined); } } for (const choice of SETTINGS_FIELDS.find(f => f.key === 'asrModel').options) assert.ok(registry[choice.value], choice.value); }); test('irrelevant voice controls hide when choosing a different engine', () => { const field = key => SETTINGS_FIELDS.find(f => f.key === key); assert.equal(settingVisible(field('ttsReferenceAudio'), { ttsPreset: 'chatterbox' }), true); assert.equal(settingVisible(field('voiceId'), { ttsPreset: 'chatterbox' }), false); assert.equal(settingVisible(field('ttsDescription'), { ttsPreset: 'parler' }), true); }); test('reply volume scales PCM without modifying the original', () => { const original = Int16Array.from([32767, -32768, 1000]); assert.deepEqual([...scaleSpeech(original, 50)], [16384, -16384, 500]); assert.deepEqual([...scaleSpeech(original, 0)], [0, 0, 0]); assert.equal(original[0], 32767); }); test('voice adapter sends selected config and uses each model native sample rate', async () => { const original = Agent.engine.ensureInit; const loads = []; Agent.engine.ensureInit = async () => ({ TTS_S3GEN_EN_CHATTERBOX: registry.TTS_S3GEN_EN_CHATTERBOX, loadModel: async options => { loads.push(options); return 'test-model'; }, unloadModel: async () => {}, textToSpeech: async () => ({ buffer: Int16Array.from([1000, -1000]) }), }); try { for (const ttsPreset of ['supertonic3', 'chatterbox']) { const adapter = new QvacVoiceAdapter({ role: 'tts', settings: voiceSettings({ ttsPreset, voiceId: 'M2', ttsSpeed: 1.3, ttsLanguage: 'fr', ttsVolume: 50, ttsReferenceAudio: '/tmp/reference.wav' }) }); try { await adapter.start(); const audio = await adapter.speak('Bonjour.'); assert.equal(audio.sampleRate, ttsPreset === 'chatterbox' ? 24000 : 44100); assert.deepEqual([...audio.samples], [500, -500]); } finally { await adapter.stop(); } } assert.equal(loads[0].modelConfig.voice, 'M2'); assert.equal(loads[0].modelConfig.ttsSpeed, 1.3); assert.equal(loads[1].modelConfig.referenceAudioSrc, '/tmp/reference.wav'); assert.deepEqual(loads[1].modelConfig.s3genModelSrc, registry.TTS_S3GEN_EN_CHATTERBOX); } finally { Agent.engine.ensureInit = original; } }); test('desktop mode and configured duration are enforced at the session boundary', () => { let now = 0; const session = new ComputerUseSession({ mode: 'observe', grantMinutes: 1, clock: () => now }); session.grant(); assert.throws(() => session.beginStep(), /observe-only/); now = 60001; assert.equal(session.status().active, false); session.mode = 'off'; assert.throws(() => session.grant(), /disabled/); }); function child() { const result = new EventEmitter(); result.stdout = new EventEmitter(); result.stderr = new EventEmitter(); result.stdin = new EventEmitter(); result.kill = () => {}; result.stdin.end = () => queueMicrotask(() => result.emit('close', 0)); return result; } test('PipeWire routes to the selected devices and uses the synthesis sample rate', async () => { let captureArgs; let playbackArgs; const capture = new PipeWireCapture({ target: 'my-mic', spawnImpl: (_command, args) => { captureArgs = args; return child(); } }); capture.start(); capture.stop(); assert.equal(captureArgs[captureArgs.indexOf('--target') + 1], 'my-mic'); const playback = new PipeWirePlayback({ target: 'my-speakers', spawnImpl: (_command, args) => { playbackArgs = args; return child(); } }); await playback.play(new Int16Array(100), 24000); assert.equal(playbackArgs[playbackArgs.indexOf('--rate') + 1], '24000'); assert.equal(playbackArgs[playbackArgs.indexOf('--target') + 1], 'my-speakers'); }); test('Hold Talk only mode ignores wake and automatic listening', () => { const capture = new EventEmitter(); let wakeFrames = 0; let vadFrames = 0; const wake = new EventEmitter(); wake.push = () => wakeFrames++; const vad = new EventEmitter(); vad.push = () => vadFrames++; const loop = new VoiceLoop({ daemon: { state: 'LISTENING', emit() {} }, capture, wake, vad, listeningMode: 'ptt' }); loop.running = true; loop.pushAudio(Buffer.alloc(10)); assert.equal(wakeFrames, 0); assert.equal(vadFrames, 0); loop.ptt = true; loop.pushAudio(Buffer.alloc(10)); assert.equal(vadFrames, 1); }); test('VAD discards short noises and bounds the whole recording including pauses', () => { const vad = new VadSegmenter({ params: { minSpeechDurationMs: 300, minSilenceDurationMs: 500, maxSpeechDurationMs: 600 } }); const speech = Buffer.alloc(3200); for (let i = 0; i < speech.length; i += 2) speech.writeInt16LE(20000, i); const silence = Buffer.alloc(3200); const utterances = []; vad.on('utterance', audio => utterances.push(audio)); vad.push(speech); for (let i = 0; i < 5; i++) vad.push(silence); assert.equal(utterances.length, 0); assert.equal(vad.speaking, false); for (let i = 0; i < 3; i++) { vad.push(speech); vad.push(silence); } assert.equal(utterances.length, 1); assert.equal(utterances[0].length, 19200); }); test('Apply reloads voice and desktop settings and keeps restart requirements until restart', async () => { const previous = process.env.XDG_CONFIG_HOME; const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-settings-')); process.env.XDG_CONFIG_HOME = dir; await mkdir(path.join(dir, 'jarvis')); const daemon = new JarvisDaemon(); let stopped = 0; daemon.setState = state => { daemon.state = state; }; daemon.input = { revoke() {} }; daemon.computer.audit = null; daemon.startVoice = async () => { daemon.voiceLoop = { stop: async () => stopped++, status: { tts: true, errors: {} } }; }; daemon.voiceLoop = { stop: async () => stopped++, status: {} }; try { await writeFile(path.join(dir, 'jarvis/config.json'), JSON.stringify({ voiceId: 'M5', computerMode: 'observe', computerSteps: 7, modelProfile: 'desktop-gpu' })); const result = JSON.parse(await daemon.reloadSettings()); assert.equal(daemon.settings.voiceId, 'M5'); assert.equal(daemon.computer.mode, 'observe'); assert.equal(daemon.computer.stepsMax, 7); assert.ok(result.restartRequired.includes('modelProfile')); assert.equal(stopped, 1); assert.ok(JSON.parse(await daemon.reloadSettings()).restartRequired.includes('modelProfile')); daemon._activeAsk = Promise.resolve(); await assert.rejects(daemon.reloadSettings(), /current request/); } finally { clearInterval(daemon._idleTimer); clearInterval(daemon._telemetryTimer); if (previous === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previous; await rm(dir, { recursive: true, force: true }); } }); test('voice preview uses selected text without changing chat history or the repeat reply', async () => { const daemon = new JarvisDaemon(); const speech = []; const replies = []; daemon.setState = state => { daemon.state = state; }; daemon.lastReply = 'Original conversation reply'; daemon.on('Reply', text => replies.push(text)); daemon.voiceLoop = { status: { tts: true }, speak: async text => speech.push(text) }; try { await daemon.previewVoice('A sample of my chosen voice.'); assert.deepEqual(speech, ['A sample of my chosen voice.']); assert.deepEqual(replies, []); assert.equal(daemon.lastReply, 'Original conversation reply'); assert.equal(daemon.state, 'LISTENING'); daemon.settings = { ...daemon.settings, listeningMode: 'single' }; await daemon.previewVoice('Another sample.'); assert.equal(daemon.state, 'ARMED'); daemon.locked = true; await assert.rejects(daemon.previewVoice('Locked sample'), /Unlock/); } finally { clearInterval(daemon._idleTimer); clearInterval(daemon._telemetryTimer); } }); test('a disabled microphone never starts capture while speech output remains available', async () => { const capture = new EventEmitter(); capture.start = () => assert.fail('microphone should be disabled'); const loop = new VoiceLoop({ capture, asr: null, tts: { start: async () => {} } }); await loop.start(); assert.equal(loop.status.capture, false); assert.equal(loop.status.asr, false); assert.equal(loop.status.tts, false); assert.equal(await loop.ensureTts(), true); assert.equal(loop.status.tts, true); }); test('catalog defaults enable CPU wake, workspace files, and idle VRAM unload', () => { const settings = voiceSettings({}); assert.equal(settings.fsAccess, 'workspace'); assert.equal(settings.freeVramOnIdle, true); assert.equal(settings.wakeCommand, 'jarvis-wake-bridge'); assert.equal(settings.assistantName, 'Jarvis'); assert.equal(settings.assistantPrompt, ''); assert.equal(voiceSettings({ assistantName: ' Ada ' }).assistantName, 'Ada'); assert.equal(voiceSettings({ assistantName: '