Port the original #27 detected-language pipeline onto the verified PR #39 prerequisite while preserving the current-main conversation instrument and host continuity changes. Keep voice selection server-side with no user selector or client voice field. Reuse 207c16ab only for its stricter exact-code trust boundary, omitting malformed or absent language so Piper defaults to Amy.
464 lines
15 KiB
JavaScript
464 lines
15 KiB
JavaScript
// Deterministic browser stub that drives dockerfiles/hermes-webui-atlas-voice.js
|
||
// through complete hands-free turns with no microphone, audio device or GPU.
|
||
//
|
||
// The script under test is an IIFE with no exported seams, so the only honest
|
||
// way to assert what reaches POST /api/tts is to run it against a fake DOM and
|
||
// fake clock and record the requests it actually makes. Usage:
|
||
//
|
||
// node atlas_voice_language_probe.js <path-to-atlas-voice.js>
|
||
//
|
||
// It prints one JSON object describing every scenario to stdout.
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const vm = require('vm');
|
||
|
||
const SCRIPT_PATH = process.argv[2];
|
||
if (!SCRIPT_PATH) {
|
||
throw new Error('usage: atlas_voice_language_probe.js <atlas-voice.js>');
|
||
}
|
||
const SOURCE = fs.readFileSync(SCRIPT_PATH, 'utf8');
|
||
|
||
function flush() {
|
||
// Four macrotask hops drain the promise chains the script builds around
|
||
// fetch()/json()/blob()/play() without ever waiting on wall-clock time.
|
||
return new Promise((resolve) => {
|
||
let hops = 0;
|
||
(function hop() {
|
||
hops += 1;
|
||
if (hops > 12) {
|
||
resolve();
|
||
return;
|
||
}
|
||
setImmediate(hop);
|
||
})();
|
||
});
|
||
}
|
||
|
||
function makeElement(id) {
|
||
return {
|
||
id,
|
||
style: {
|
||
values: new Map(),
|
||
setProperty(name, value) { this.values.set(name, String(value)); },
|
||
removeProperty(name) { this.values.delete(name); },
|
||
getPropertyValue(name) { return this.values.get(name) || ''; },
|
||
},
|
||
dataset: {},
|
||
attributes: {},
|
||
value: '',
|
||
textContent: '',
|
||
className: '',
|
||
classList: {
|
||
entries: new Set(),
|
||
add(name) { this.entries.add(name); },
|
||
remove(name) { this.entries.delete(name); },
|
||
contains(name) { return this.entries.has(name); },
|
||
},
|
||
listeners: [],
|
||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||
getAttribute(name) {
|
||
return Object.prototype.hasOwnProperty.call(this.attributes, name)
|
||
? this.attributes[name] : null;
|
||
},
|
||
addEventListener(type, handler) { this.listeners.push({ type, handler }); },
|
||
removeEventListener(type, handler) {
|
||
this.listeners = this.listeners.filter((entry) => entry.handler !== handler);
|
||
},
|
||
click() {
|
||
const event = { preventDefault() {}, stopImmediatePropagation() {} };
|
||
this.listeners
|
||
.filter((entry) => entry.type === 'click')
|
||
.forEach((entry) => entry.handler(event));
|
||
},
|
||
querySelector() { return null; },
|
||
insertBefore() {},
|
||
appendChild() {},
|
||
};
|
||
}
|
||
|
||
function makeHarness() {
|
||
const clock = { now: 1000000 };
|
||
const timeouts = [];
|
||
const intervals = new Map();
|
||
let timerId = 1;
|
||
|
||
const ttsRequests = [];
|
||
const transcribeCalls = [];
|
||
const toasts = [];
|
||
const sends = [];
|
||
|
||
let capability = { ok: true, available: true, provider: 'local_command' };
|
||
let transcribeResponse = { ok: true, transcript: 'hello', language: 'en' };
|
||
let transcribeStatus = 200;
|
||
let assistantRows = [];
|
||
let loud = false;
|
||
let recorder = null;
|
||
|
||
const storage = new Map();
|
||
const elements = {};
|
||
['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg']
|
||
.forEach((id) => { elements[id] = makeElement(id); });
|
||
|
||
function MediaRecorder() {
|
||
this.state = 'recording';
|
||
this.ondataavailable = null;
|
||
this.onstop = null;
|
||
recorder = this;
|
||
}
|
||
MediaRecorder.prototype.start = function start() { this.state = 'recording'; };
|
||
MediaRecorder.prototype.stop = function stop() {
|
||
if (this.state === 'inactive') return;
|
||
this.state = 'inactive';
|
||
if (this.onstop) this.onstop();
|
||
};
|
||
MediaRecorder.isTypeSupported = function isTypeSupported() { return true; };
|
||
|
||
function AudioContext() {
|
||
this.createAnalyser = () => ({
|
||
fftSize: 2048,
|
||
getByteTimeDomainData(samples) {
|
||
for (let i = 0; i < samples.length; i += 1) {
|
||
samples[i] = loud ? (i % 2 ? 200 : 56) : 128;
|
||
}
|
||
},
|
||
});
|
||
this.createBiquadFilter = () => ({
|
||
type: '', frequency: { value: 0 }, Q: { value: 0 }, connect() {},
|
||
});
|
||
this.createMediaStreamSource = () => ({ connect() {} });
|
||
this.close = () => {};
|
||
}
|
||
|
||
function AudioElement() {
|
||
this.currentTime = 0;
|
||
this.onended = null;
|
||
this.onerror = null;
|
||
this.pause = () => {};
|
||
this.play = () => {
|
||
setImmediate(() => { if (this.onended) this.onended(); });
|
||
return Promise.resolve();
|
||
};
|
||
}
|
||
|
||
async function fetchStub(url, init) {
|
||
if (url === '/api/transcribe/capability') {
|
||
return { ok: true, status: 200, json: async () => capability };
|
||
}
|
||
if (url === '/api/transcribe') {
|
||
transcribeCalls.push({ body: init && init.body });
|
||
return {
|
||
ok: transcribeStatus < 400,
|
||
status: transcribeStatus,
|
||
json: async () => transcribeResponse,
|
||
};
|
||
}
|
||
if (url === '/api/tts') {
|
||
ttsRequests.push(JSON.parse(init.body));
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
blob: async () => ({ synthetic: true }),
|
||
json: async () => ({}),
|
||
};
|
||
}
|
||
throw new Error(`unexpected fetch: ${url}`);
|
||
}
|
||
|
||
const context = {
|
||
console,
|
||
Uint8Array,
|
||
Promise,
|
||
Math,
|
||
JSON,
|
||
String,
|
||
Number,
|
||
Error,
|
||
parseInt,
|
||
isNaN,
|
||
Set,
|
||
Map,
|
||
Array,
|
||
Object,
|
||
Date: { now: () => clock.now },
|
||
Blob: function Blob(parts, options) { this.parts = parts; this.type = (options || {}).type || ''; },
|
||
File: function File(parts, name, options) {
|
||
this.parts = parts; this.name = name; this.type = (options || {}).type || '';
|
||
},
|
||
FormData: function FormData() { this.entries = []; this.append = (k, v) => this.entries.push([k, v]); },
|
||
URL: { createObjectURL: () => 'blob:atlas-test', revokeObjectURL() {} },
|
||
Audio: AudioElement,
|
||
MediaRecorder,
|
||
AudioContext,
|
||
fetch: fetchStub,
|
||
localStorage: {
|
||
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
|
||
setItem: (key, value) => { storage.set(key, String(value)); },
|
||
removeItem: (key) => { storage.delete(key); },
|
||
},
|
||
navigator: {
|
||
mediaDevices: {
|
||
getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }),
|
||
getSupportedConstraints: () => ({}),
|
||
},
|
||
},
|
||
document: {
|
||
getElementById: (id) => elements[id] || null,
|
||
querySelectorAll: () => assistantRows,
|
||
createElement: () => ({ value: '', textContent: '' }),
|
||
},
|
||
S: { session: { session_id: 'session-1' }, busy: false },
|
||
setTimeout: (fn, delay) => {
|
||
const id = timerId; timerId += 1;
|
||
timeouts.push({ id, fn, at: clock.now + (delay || 0) });
|
||
return id;
|
||
},
|
||
clearTimeout: (id) => {
|
||
const index = timeouts.findIndex((entry) => entry.id === id);
|
||
if (index >= 0) timeouts.splice(index, 1);
|
||
},
|
||
setInterval: (fn, delay) => {
|
||
const id = timerId; timerId += 1;
|
||
intervals.set(id, { fn, delay: delay || 0 });
|
||
return id;
|
||
},
|
||
clearInterval: (id) => { intervals.delete(id); },
|
||
};
|
||
context.window = context;
|
||
context.showToast = (message) => { toasts.push(message); };
|
||
context.send = () => { sends.push(elements.msg.value); };
|
||
context.autoResize = () => {};
|
||
|
||
vm.createContext(context);
|
||
vm.runInContext(SOURCE, context, { filename: 'atlas-voice.js' });
|
||
|
||
function runDueTimeouts() {
|
||
const due = timeouts.filter((entry) => entry.at <= clock.now);
|
||
due.forEach((entry) => {
|
||
const index = timeouts.indexOf(entry);
|
||
if (index >= 0) timeouts.splice(index, 1);
|
||
entry.fn();
|
||
});
|
||
}
|
||
|
||
function tick(ms) {
|
||
clock.now += ms;
|
||
Array.from(intervals.values()).forEach((entry) => entry.fn());
|
||
runDueTimeouts();
|
||
}
|
||
|
||
return {
|
||
context,
|
||
elements,
|
||
ttsRequests,
|
||
transcribeCalls,
|
||
toasts,
|
||
sends,
|
||
clock,
|
||
recorder: () => recorder,
|
||
setLoud: (value) => { loud = value; },
|
||
setCapability: (value) => { capability = value; },
|
||
setTranscribeResponse: (value, status) => {
|
||
transcribeResponse = value;
|
||
transcribeStatus = status === undefined ? 200 : status;
|
||
},
|
||
setAssistantReply: (text) => { assistantRows = [{ dataset: { rawText: text } }]; },
|
||
setSession: (id) => { context.S.session = { session_id: id }; },
|
||
advance: (ms) => { clock.now += ms; },
|
||
tick,
|
||
runDueTimeouts,
|
||
flush,
|
||
};
|
||
}
|
||
|
||
// Walk one capture window: pre-roll audio, three loud frames so the VAD latches
|
||
// speech, then silence past the hangover so MediaRecorder.stop() fires.
|
||
async function captureSpeech(harness) {
|
||
const active = harness.recorder();
|
||
if (!active) throw new Error('voice mode never created a recorder');
|
||
active.ondataavailable({ data: { size: 512 } });
|
||
harness.setLoud(true);
|
||
for (let i = 0; i < 4; i += 1) harness.tick(100);
|
||
active.ondataavailable({ data: { size: 512 } });
|
||
harness.setLoud(false);
|
||
harness.advance(2500);
|
||
harness.tick(100);
|
||
await harness.flush();
|
||
}
|
||
|
||
async function startVoiceMode(harness) {
|
||
await harness.flush();
|
||
harness.elements.btnVoiceMode.click();
|
||
await harness.flush();
|
||
}
|
||
|
||
// One complete turn: speak, transcribe, let the app "answer", read it back.
|
||
async function runTurn(harness, { transcript, language, reply }) {
|
||
const payload = { ok: true, transcript };
|
||
if (language !== undefined) payload.language = language;
|
||
harness.setTranscribeResponse(payload);
|
||
await captureSpeech(harness);
|
||
harness.setAssistantReply(reply || 'An answer.');
|
||
harness.context.autoReadLastAssistant();
|
||
await harness.flush();
|
||
}
|
||
|
||
async function restartListening(harness) {
|
||
harness.advance(1000);
|
||
harness.runDueTimeouts();
|
||
await harness.flush();
|
||
}
|
||
|
||
const scenarios = {};
|
||
|
||
scenarios.english_turn_speaks_english = async () => {
|
||
const harness = makeHarness();
|
||
await startVoiceMode(harness);
|
||
await runTurn(harness, { transcript: 'What is the weather?', language: 'en' });
|
||
return { tts: harness.ttsRequests };
|
||
};
|
||
|
||
scenarios.russian_turn_speaks_russian = async () => {
|
||
const harness = makeHarness();
|
||
await startVoiceMode(harness);
|
||
await runTurn(harness, { transcript: 'Как дела?', language: 'ru', reply: 'Всё хорошо.' });
|
||
return { tts: harness.ttsRequests };
|
||
};
|
||
|
||
scenarios.spanish_turn_speaks_spanish = async () => {
|
||
const harness = makeHarness();
|
||
await startVoiceMode(harness);
|
||
await runTurn(harness, { transcript: '¿Qué tal?', language: 'es', reply: 'Muy bien.' });
|
||
return { tts: harness.ttsRequests };
|
||
};
|
||
|
||
scenarios.missing_language_falls_back = async () => {
|
||
const harness = makeHarness();
|
||
await startVoiceMode(harness);
|
||
await runTurn(harness, { transcript: 'Hello there.' });
|
||
return { tts: harness.ttsRequests };
|
||
};
|
||
|
||
scenarios.unsupported_language_falls_back = async () => {
|
||
const harness = makeHarness();
|
||
await startVoiceMode(harness);
|
||
await runTurn(harness, { transcript: 'Bonjour tout le monde.', language: 'fr' });
|
||
return { tts: harness.ttsRequests };
|
||
};
|
||
|
||
scenarios.hostile_language_values_are_dropped = async () => {
|
||
const results = [];
|
||
const hostile = [
|
||
'ru; rm -rf /',
|
||
'../../ru_RU-irina-medium',
|
||
'ru |