194 lines
12 KiB
JavaScript
194 lines
12 KiB
JavaScript
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/[email protected]/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');
|
|
assert.equal(voiceSettings({ modelProfile: 'laptop-4gb-mm' }).modelProfile, 'laptop-4gb-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: '<script>' }).assistantName, 'Jarvis');
|
|
assert.equal(voiceSettings({ assistantPrompt: 'Be dry.\nSkip filler. ' }).assistantPrompt, 'Be dry.\nSkip filler.');
|
|
});
|