Files
ai-spel-frontend/tests/voice.test.cjs
T
Ein Anderssono 89c238bcea
Build and publish frontend / publish (push) Successful in 50s
Show Piper startup and download stages
2026-10-16 08:07:44 +02:00

342 lines
16 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '..', 'voice.js'), 'utf8');
const apiWindow = {};
vm.runInNewContext(source, {window: apiWindow, globalThis: apiWindow, Promise, String, Math, Object});
const GameVoice = apiWindow.GameVoice;
function makeHarness({supported = true, tts = true, piper = false, onTranscript = () => Promise.resolve()} = {}) {
const recognitions = [];
const utterances = [];
const states = [];
const timers = new Map();
const timerDelays = new Map();
let timerId = 0;
class Recognition {
constructor() { recognitions.push(this); }
start() { this.started = true; }
stop() { this.stopped = true; }
abort() { this.aborted = true; }
}
class Utterance {
constructor(text) { this.text = text; utterances.push(this); }
}
const audioSources = [];
const piperProgressCallbacks = [];
class AudioContext {
constructor() { this.state = 'running'; this.destination = {}; }
resume() { this.state = 'running'; return Promise.resolve(); }
decodeAudioData() { return Promise.resolve({duration: 1}); }
createBufferSource() {
const source = {playbackRate: {value: 1}, detune: {value: 0}, connect() {}, start() { this.started = true; }, stop() { this.stopped = true; }};
audioSources.push(source);
return source;
}
}
const synthesis = tts ? {
spoken: [], cancelled: 0,
getVoices: () => [{name: 'Svensk', lang: 'sv-SE'}],
speak(utterance) { this.spoken.push(utterance); },
cancel() { this.cancelled += 1; },
} : undefined;
const window = {
setTimeout(callback, delay = 0) { const id = ++timerId; timers.set(id, callback); timerDelays.set(id, delay); return id; },
clearTimeout(id) { timers.delete(id); timerDelays.delete(id); },
speechSynthesis: synthesis,
SpeechSynthesisUtterance: tts ? Utterance : undefined,
SpeechRecognition: supported ? Recognition : undefined,
AudioContext,
GamePiper: piper ? {synthesize: (_text, onProgress) => {
piperProgressCallbacks.push(onProgress);
return Promise.resolve({arrayBuffer: () => Promise.resolve(new ArrayBuffer(8))});
}} : undefined,
};
vm.runInNewContext(source, {window, globalThis: window, Promise, String, Math, Object});
const controller = window.GameVoice.createVoiceController({
window,
onState: (state, message) => states.push({state, message}),
onTranscript,
});
return {
controller, recognitions, utterances, synthesis, states, timers, audioSources, piperProgressCallbacks,
runTimer(delay) {
const match = [...timerDelays.entries()].find(([, value]) => value === delay);
if (!match) throw new Error(`No timer found with delay ${delay}`);
timers.get(match[0])();
},
};
}
test('recognition is single-shot Swedish and sends only final transcript once', async () => {
const received = [];
const h = makeHarness({onTranscript: text => { received.push(text); }});
assert.equal(h.controller.startListening(), true);
const recognition = h.recognitions[0];
assert.equal(recognition.lang, 'sv-SE');
assert.equal(recognition.continuous, false);
assert.equal(recognition.interimResults, false);
recognition.onresult({resultIndex: 0, results: [{isFinal: false, 0: {transcript: 'delvis'}}]});
recognition.onresult({resultIndex: 0, results: [{isFinal: true, 0: {transcript: ' Hej världen '}}]});
recognition.onresult({resultIndex: 0, results: [{isFinal: true, 0: {transcript: 'dubblett'}}]});
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(received, ['Hej världen']);
assert.equal(h.controller.getState(), 'IDLE');
});
test('second microphone activation stops the active recognition session', () => {
const h = makeHarness();
h.controller.startListening();
h.controller.startListening();
assert.equal(h.recognitions.length, 1);
assert.equal(h.recognitions[0].aborted, true);
assert.equal(h.controller.getState(), 'IDLE');
});
test('unsupported recognition stays recoverable and leaves text input available', () => {
const h = makeHarness({supported: false});
assert.equal(h.controller.startListening(), false);
assert.equal(h.controller.getState(), 'IDLE');
assert.match(h.states.at(-1).message, /skriva/);
});
test('permission errors reset state and a later session can start', () => {
const h = makeHarness();
h.controller.startListening();
h.recognitions[0].onerror({error: 'not-allowed'});
assert.equal(h.controller.getState(), 'IDLE');
assert.match(h.states.at(-1).message, /behörighet nekades/);
assert.equal(h.controller.startListening(), true);
assert.equal(h.recognitions.length, 2);
});
test('late recognition results after cancellation are ignored', () => {
let delivered = 0;
const h = makeHarness({onTranscript: () => { delivered += 1; }});
h.controller.startListening();
const old = h.recognitions[0];
h.controller.stopListening();
old.onresult({resultIndex: 0, results: [{isFinal: true, 0: {transcript: 'för sent'}}]});
assert.equal(delivered, 0);
});
test('recognition watchdog closes a session that never ends and permits retry', () => {
const h = makeHarness();
h.controller.startListening();
const watchdog = [...h.timers.values()][0];
watchdog();
assert.equal(h.controller.getState(), 'IDLE');
assert.match(h.states.at(-1).message, /en minut/);
assert.equal(h.controller.startListening(), true);
assert.equal(h.recognitions.length, 2);
});
test('TTS uses the system default voice unless a named Swedish voice is selected', () => {
const h = makeHarness();
h.controller.update({mode: 'game-voice', rate: 1.4});
assert.equal(h.controller.speak('Hej.'), true);
const first = h.utterances[0];
assert.equal(first.lang, 'sv-SE');
assert.equal(first.rate, 1.4);
assert.equal(first.voice, null);
h.controller.cancelSpeech();
first.onend();
assert.equal(h.controller.getState(), 'IDLE');
assert.equal(h.controller.speak('Namngiven röst.', {voiceName: 'Svensk'}), true);
assert.equal(h.utterances.at(-1).voice.name, 'Svensk');
});
test('a player gesture restores the iOS speech primer after cancelling a queued reply', () => {
const h = makeHarness();
assert.equal(h.controller.activateFromUserGesture(), true);
assert.equal(h.synthesis.spoken[0].text, ' ');
h.controller.speak('Tidigare replik');
h.controller.cancelSpeech();
const countAfterCancel = h.synthesis.spoken.length;
assert.equal(h.controller.activateFromUserGesture(), true);
assert.equal(h.synthesis.spoken.length, countAfterCancel + 1);
assert.equal(h.synthesis.spoken.at(-1).text, ' ');
h.controller.speak('Nytt svar');
assert.equal(h.synthesis.spoken.at(-1).text, 'Nytt svar');
});
test('speech is reported as in progress only after the native onstart event', () => {
const h = makeHarness();
h.controller.speak('Hej.');
assert.equal(h.controller.getState(), 'QUEUED');
assert.match(h.states.at(-1).message, /Startar uppläsningen/);
h.utterances[0].onstart();
assert.equal(h.controller.getState(), 'SPEAKING');
assert.match(h.states.at(-1).message, /Webbläsaren har startat talsyntesen/);
assert.match(h.states.at(-1).message, /Läs senaste repliken/);
});
test('iOS user gesture primes speech silently and idle speech is not cancelled before speak', () => {
const h = makeHarness();
assert.equal(h.controller.activateFromUserGesture(), true);
assert.equal(h.synthesis.spoken[0].text, ' ');
assert.equal(h.synthesis.spoken[0].volume, 0);
const cancelledBeforeReply = h.synthesis.cancelled;
assert.equal(h.controller.speak('Ett svar efter nätverksanropet'), true);
assert.equal(h.synthesis.spoken.at(-1).text, 'Ett svar efter nätverksanropet');
assert.equal(h.synthesis.cancelled, cancelledBeforeReply);
});
test('replacing active speech waits one event-loop turn after WebKit cancel', () => {
const h = makeHarness();
h.controller.speak('Första repliken');
const cancelledBeforeReplace = h.synthesis.cancelled;
h.controller.speak('Nästa replik');
assert.equal(h.synthesis.cancelled, cancelledBeforeReplace + 1);
assert.notEqual(h.synthesis.spoken.at(-1).text, 'Nästa replik');
h.runTimer(100);
assert.equal(h.synthesis.spoken.at(-1).text, 'Nästa replik');
});
test('speech start timeout tells the player how to recover when mobile playback is blocked', () => {
const h = makeHarness();
h.controller.speak('En replik');
const cancelledBeforeTimeout = h.synthesis.cancelled;
h.runTimer(4000);
assert.match(h.states.at(-1).message, /Tryck på Läs senaste repliken/);
assert.equal(h.synthesis.cancelled, cancelledBeforeTimeout + 1);
const cancelledBeforeRetry = h.synthesis.cancelled;
h.controller.speak('Manuell återläsning');
assert.equal(h.synthesis.spoken.at(-1).text, 'Manuell återläsning');
assert.equal(h.synthesis.cancelled, cancelledBeforeRetry);
});
test('iOS not-allowed speech error offers an immediate gesture-based retry', () => {
const h = makeHarness();
h.controller.speak('En replik');
h.utterances[0].onerror({error: 'not-allowed'});
assert.match(h.states.at(-1).message, /Safari blockerade uppläsningen/);
assert.match(h.states.at(-1).message, /Läs senaste repliken/);
assert.equal(h.controller.speak('Försök igen'), true);
assert.equal(h.synthesis.spoken.at(-1).text, 'Försök igen');
});
test('character voice identity gives stable distinct masculine, feminine, and neutral timbres', () => {
const profile = GameVoice.characterVoiceProfile;
const masculine = profile('masculine', 'character-1');
const feminine = profile('feminine', 'character-1');
const neutral = profile('neutral', 'character-1');
assert.equal(profile('masculine', 'character-1').pitch, masculine.pitch);
assert.ok(masculine.pitch < neutral.pitch);
assert.ok(neutral.pitch < feminine.pitch);
assert.notEqual(profile('masculine', 'character-2').pitch, masculine.pitch);
});
test('narrator uses neutral pitch while character replies use their saved voice identity', () => {
const h = makeHarness();
h.controller.update({mode: 'game-voice'});
h.controller.speak('Berättarröst', {role: 'narrator', voiceIdentity: 'feminine', characterId: 'narrator'});
h.controller.speak('NPC-replik', {voiceIdentity: 'masculine', characterId: 'npc-1'});
h.runTimer(100);
assert.equal(h.utterances[0].pitch, 1);
assert.ok(h.utterances[1].pitch < 1);
});
test('Piper WASM output uses the unlocked audio context and starts only after local synthesis', async () => {
const h = makeHarness({piper: true});
assert.equal(h.controller.activateFromUserGesture(), true);
assert.equal(h.controller.speak('Ett svar från Piper.', {role: 'narrator', voiceIdentity: 'neutral'}), true);
assert.equal(h.controller.getState(), 'QUEUED');
await new Promise(resolve => setImmediate(resolve));
assert.equal(h.audioSources.length, 1);
assert.equal(h.audioSources[0].started, true);
assert.equal(h.audioSources[0].detune.value, 0);
assert.equal(h.controller.getState(), 'SPEAKING');
h.audioSources[0].onended();
assert.equal(h.controller.getState(), 'IDLE');
});
test('Piper model download progress is reported as a bounded percentage', async () => {
const h = makeHarness({piper: true});
h.controller.activateFromUserGesture();
h.controller.speak('Ett svar från Piper.');
await new Promise(resolve => setImmediate(resolve));
h.piperProgressCallbacks[0]({stage: 'loading-model'});
assert.match(h.states.at(-1).message, /Ansluter till servern/);
h.piperProgressCallbacks[0]({loaded: 31_500_000, total: 63_000_000});
assert.equal(h.states.at(-1).state, 'PROCESSING');
assert.match(h.states.at(-1).message, /50 procent/);
h.piperProgressCallbacks[0]({loaded: 90_000_000, total: 63_000_000});
assert.match(h.states.at(-1).message, /100 procent/);
h.piperProgressCallbacks[0]({loaded: 10 * 1024 * 1024, total: 0});
assert.match(h.states.at(-1).message, /10 MB hämtade/);
});
test('Piper output keeps per-character pitch and rate stable', async () => {
const h = makeHarness({piper: true});
h.controller.activateFromUserGesture();
h.controller.speak('NPC-svar.', {voiceIdentity: 'masculine', characterId: 'npc-1'});
await new Promise(resolve => setImmediate(resolve));
assert.ok(h.audioSources[0].detune.value < 0);
assert.ok(h.audioSources[0].playbackRate.value < 1);
});
test('TTS and microphone are mutually exclusive', () => {
const h = makeHarness();
h.controller.update({mode: 'game-voice'});
h.controller.startListening();
h.controller.speak('Svar');
assert.equal(h.recognitions[0].aborted, true);
assert.equal(h.controller.getState(), 'QUEUED');
h.utterances.at(-1).onstart();
assert.equal(h.controller.getState(), 'SPEAKING');
});
test('game speech stays automatic even with legacy text-only settings and gracefully degrades without synthesis', () => {
const screenReader = makeHarness();
screenReader.controller.update({mode: 'text-only', autoSpeak: false});
assert.equal(screenReader.controller.speak('dialog'), true);
assert.equal(screenReader.synthesis.spoken[0].text, 'dialog');
const noTts = makeHarness({tts: false});
noTts.controller.update({mode: 'game-voice'});
assert.equal(noTts.controller.speak('dialog'), false);
});
test('system commands require an explicit prefix; ordinary dialogue words are untouched', () => {
const global = {};
vm.runInNewContext(source, {window: global, globalThis: global, Promise, String, Math, Object});
assert.equal(GameVoice.parseVoiceCommand('upprepa'), null);
assert.equal(GameVoice.parseVoiceCommand('hjälp'), null);
assert.equal(GameVoice.parseVoiceCommand('röstkommando upprepa.' ).command, 'repeat');
assert.equal(GameVoice.parseVoiceCommand('röstkommando alternativ').command, 'settings');
assert.equal(GameVoice.parseVoiceCommand('röstinställningar').command, 'settings');
assert.equal(GameVoice.parseVoiceCommand('röstinställningar', {requirePrefix: true}), null);
assert.equal(GameVoice.parseVoiceCommand('röstkommando alternativ', {requirePrefix: true}).command, 'settings');
});
test('settings survive reload and invalid storage falls back to accessible defaults', () => {
const global = {};
vm.runInNewContext(source, {window: global, globalThis: global, Promise, String, Math, Object});
const values = new Map();
const storage = {getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value)};
const defaults = GameVoice.loadSettings(storage, 'new-player');
assert.equal(defaults.mode, 'game-voice');
assert.equal(defaults.autoSpeak, true);
values.set('voice-v1', JSON.stringify({mode: 'text-only', autoSpeak: false, rate: 1.4, voiceName: 'Svensk', soundSignals: false}));
const migrated = GameVoice.loadSettings(storage, 'voice-v2', 'voice-v1');
assert.equal(migrated.mode, 'game-voice');
assert.equal(migrated.autoSpeak, true);
assert.equal(migrated.rate, 1.4);
assert.equal(migrated.voiceName, 'Svensk');
assert.equal(migrated.soundSignals, false);
const requested = {...GameVoice.DEFAULT_SETTINGS, mode: 'game-voice', autoSpeak: true, rate: 1.5, voiceName: 'Svensk'};
assert.equal(GameVoice.saveSettings(storage, 'voice', requested), true);
const loaded = GameVoice.loadSettings(storage, 'voice');
assert.equal(loaded.mode, 'game-voice');
assert.equal(loaded.autoSpeak, true);
assert.equal(loaded.rate, 1.5);
assert.equal(loaded.voiceName, 'Svensk');
values.set('voice', '{trasig');
assert.equal(GameVoice.loadSettings(storage, 'voice').mode, 'game-voice');
assert.equal(GameVoice.loadSettings(storage, 'voice').autoSpeak, true);
assert.equal(GameVoice.saveSettings({setItem() { throw new Error('blocked'); }}, 'voice', requested), false);
});