atlas-iac/testing/tests/data/atlas_voice_language_probe.js
jenkins 4d93ef5a0e hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix
Final conversation-mode polish from mobile testing:
- The Workspace toggle now sits with the chat/Telegram nav at every
  width: nav.rail on desktop, the top app titlebar on mobile. The
  floating pill that pushed the mobile composer's control row (and the
  conversation-mode button) off screen is gone - a fallback exists only
  for headless DOMs and is pinned to a top corner, never over the
  composer.
- The conversation orb watermark is now the Hermes character avatar
  (static/hermes-agent-192.png) instead of the caduceus staff.
- User-facing 'hands-free' copy renamed to 'Conversation mode'.
- Wrong-voice fix: strongReplyLanguage flagged Spanish on a single
  accented char, so an English reply naming European cities (Zürich,
  Málaga) overrode the correct English STT detection and was spoken by
  the Spanish voice. Detection now requires density (Cyrillic >=4 at
  >=50%, or inverted punctuation / >=2 accents corroborated by Spanish
  stopwords); plain English always speaks English, forced language wins,
  accent-free Spanish still routes via trusted STT. 294 voice tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 19:22:32 -03:00

684 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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(options = {}) {
const clock = { now: 1000000 };
const timeouts = [];
const intervals = new Map();
let timerId = 1;
const ttsRequests = [];
const ttsStreamRequests = [];
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;
let lastAudio = 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.paused = false;
this.pause = () => { this.paused = true; };
this.play = () => {
this.paused = false;
if (!options.manualAudio) setImmediate(() => { if (this.onended) this.onended(); });
return Promise.resolve();
};
this.finish = () => { if (this.onended) this.onended(); };
lastAudio = this;
}
async function fetchStub(url, init) {
if (url === '/api/transcribe/capability') {
return { ok: true, status: 200, json: async () => capability };
}
if (url === '/api/voice/streaming/capability') {
return {
ok: !!options.streamingCapability,
status: options.streamingCapability ? 200 : 404,
json: async () => options.streamingCapability || {},
};
}
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 () => ({}),
};
}
if (url === '/api/tts/stream') {
ttsStreamRequests.push(JSON.parse(init.body));
return {
ok: !options.ttsStreamFails,
status: options.ttsStreamFails ? 503 : 200,
body: null,
headers: { get() { return null; } },
};
}
throw new Error(`unexpected fetch: ${url}`);
}
const context = {
AbortController,
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,
AudioWorkletNode: options.enableAudioWorklet ? function AudioWorkletNode() {} : undefined,
MediaRecorder,
AudioContext,
ReadableStream: options.enableAudioWorklet ? function ReadableStream() {} : undefined,
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,
ttsStreamRequests,
transcribeCalls,
toasts,
sends,
clock,
recorder: () => recorder,
get lastAudio() { return lastAudio; },
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\\u0000',
'RUSSIAN',
{ language: 'ru' },
['ru'],
42,
null,
'r',
'ru ru',
'x'.repeat(4096),
];
for (const language of hostile) {
const harness = makeHarness();
// eslint-disable-next-line no-await-in-loop
await startVoiceMode(harness);
// eslint-disable-next-line no-await-in-loop
await runTurn(harness, { transcript: 'Say something.', language });
results.push({ sent: String(language), tts: harness.ttsRequests });
}
return { results };
};
scenarios.voice_field_is_never_sent = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Как дела?', language: 'ru' });
return { tts: harness.ttsRequests };
};
scenarios.language_does_not_leak_into_later_turn = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Как дела?', language: 'ru', reply: 'Всё хорошо.' });
await restartListening(harness);
await runTurn(harness, { transcript: 'And in English?', language: undefined });
await restartListening(harness);
await runTurn(harness, { transcript: '¿Y ahora?', language: 'es' });
return { tts: harness.ttsRequests };
};
scenarios.empty_transcript_does_not_arm_a_language = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: ' ', language: 'ru' });
await captureSpeech(harness);
const sendsAfterBlank = harness.sends.slice();
// A reply landing while the blank turn winds down must not inherit a
// language that transcript never earned.
harness.setAssistantReply('A stray answer.');
harness.context.autoReadLastAssistant();
await harness.flush();
await restartListening(harness);
await runTurn(harness, { transcript: 'Hello.', language: undefined });
return { sendsAfterBlank, tts: harness.ttsRequests, sends: harness.sends };
};
scenarios.session_change_discards_language = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: 'Как дела?', language: 'ru' });
await captureSpeech(harness);
harness.setSession('session-2');
harness.setAssistantReply('Reply that belongs to another chat.');
harness.context.autoReadLastAssistant();
await harness.flush();
const afterSwitch = harness.ttsRequests.slice();
await restartListening(harness);
await runTurn(harness, { transcript: 'Hello again.', language: undefined });
return { afterSwitch, tts: harness.ttsRequests };
};
scenarios.deactivation_discards_language = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: 'Как дела?', language: 'ru' });
await captureSpeech(harness);
harness.elements.btnVoiceMode.click();
await harness.flush();
harness.setAssistantReply('Late reply after the user left voice mode.');
harness.context.autoReadLastAssistant();
await harness.flush();
const afterDeactivate = harness.ttsRequests.slice();
harness.elements.btnVoiceMode.click();
await harness.flush();
await runTurn(harness, { transcript: 'Fresh start.', language: undefined });
return { afterDeactivate, tts: harness.ttsRequests };
};
scenarios.transcribe_error_speaks_nothing = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ error: 'Whisper is down' }, 503);
await captureSpeech(harness);
harness.setAssistantReply('Some earlier answer.');
harness.context.autoReadLastAssistant();
await harness.flush();
return { tts: harness.ttsRequests, toasts: harness.toasts };
};
scenarios.adaptive_chunks_are_sentence_gated = async () => {
const harness = makeHarness();
await harness.flush();
const partial = harness.context._atlasAdaptiveChunks(
'Dr. Rivera is still explaining this opening thought without a safe sentence boundary yet',
false,
);
const complete = harness.context._atlasAdaptiveChunks(
'This opening clause gives Hermes a clean and quick place to begin speaking, while the rest of the first sentence remains coherent. '
+ 'The following explanation is deliberately long enough to demonstrate that later punctuation aligned chunks use a much larger target and retain natural prosody for listeners. '
+ 'A compact final tail remains.',
true,
);
return { partial, complete };
};
scenarios.spoken_urls_are_skipped_without_damaging_text = async () => {
const harness = makeHarness();
await harness.flush();
const clean = harness.context._atlasStripHttpUrlsForSpeech;
return {
sentence: clean('Read this https://example.com/docs. Then continue.'),
wrapped: clean('Open (https://example.com/a_(b)). Next.'),
punctuated: clean('Try https://example.com/a?q=1, or https://example.org/x!'),
domains: clean('Keep example.com and sub.example.org exactly as written.'),
prose: clean('No links here; keep this sentence exactly as written.'),
};
};
scenarios.spoken_urls_are_elided_before_all_voice_routes = async () => {
const results = [];
for (const language of ['en', 'ru', 'es']) {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, {
transcript: 'Read the answer.',
language,
reply: 'Keep example.com, but skip https://private.example/path. Done.',
});
results.push({ language, tts: harness.ttsRequests });
}
return { results };
};
scenarios.spanish_reply_without_detection_uses_reply_heuristic = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Que tal', reply: '¿Claro que sí! Todo está listo para continuar.' });
return { tts: harness.ttsRequests };
};
scenarios.reply_script_evidence_corrects_wrong_detection = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Answer in Spanish please.', language: 'en',
reply: 'Está bien: la razón es fácil de explicar.' });
return { tts: harness.ttsRequests };
};
// FIX 4: reply-language resolution driven directly through the shipped pure
// helpers. Plain English prose full of accented European place names must never
// be flipped off the English voice; Cyrillic → ru; clear Spanish → es; a user
// force always wins. (Root cause of the live bug: the old strongReplyLanguage
// returned 'es' for a single accented char and outranked a correct 'en' STT.)
scenarios.reply_language_resolution = async () => {
const harness = makeHarness();
await harness.flush();
const internals = harness.context.__atlasVoiceInternals;
const strong = (text) => internals.strongReplyLanguage(text);
const resolve = (text, stt, forced) => internals.resolveReplyLanguage(text, stt || '', forced || '');
const europe = 'Here is something interesting about Europe: Zürich, München and Málaga '
+ 'are lovely, and the café in Genève is famous. Kraków is worth a visit too.';
const cyrillic = 'Вот что интересно о Европе: Цюрих, Мюнхен и Малага прекрасны, '
+ 'а кафе в Женеве знаменито, и по всему городу очень вкусная еда.';
const spanish = 'Aquí tienes algo interesante sobre Europa: Zúrich, Múnich y Málaga '
+ 'son preciosas, y la comida está muy buena en toda la ciudad.';
return {
europeStrong: strong(europe),
europeDetectionless: resolve(europe, '', ''),
europeWithEnglishStt: resolve(europe, 'en', ''),
cyrillicStrong: strong(cyrillic),
cyrillicDetectionless: resolve(cyrillic, '', ''),
spanishStrong: strong(spanish),
spanishDetectionless: resolve(spanish, '', ''),
forcedOverridesEnglishText: resolve(europe, 'en', 'ru'),
forcedOverridesCyrillic: resolve(cyrillic, '', 'en'),
};
};
scenarios.canonical_pcm_fallback_builds_a_valid_wav = async () => {
const harness = makeHarness();
await harness.flush();
const pcm = new Uint8Array([0x00, 0x00, 0xff, 0x7f, 0x00, 0x80]);
const blob = harness.context._atlasPcm16WavBlob([pcm.buffer], 16000);
const bytes = new Uint8Array(blob.parts[0]);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const text = (start, length) => String.fromCharCode(...bytes.slice(start, start + length));
return {
type: blob.type,
size: bytes.length,
riff: text(0, 4),
wave: text(8, 4),
format: view.getUint16(20, true),
channels: view.getUint16(22, true),
rate: view.getUint32(24, true),
bits: view.getUint16(34, true),
data: view.getUint32(40, true),
pcm: Array.from(bytes.slice(44)),
};
};
scenarios.applied_aec_settings_are_fail_safe = async () => {
const harness = makeHarness();
await harness.flush();
const check = harness.context._atlasCaptureAecIsUsable;
const stream = (value) => ({
getAudioTracks: () => [{ getSettings: () => ({ echoCancellation: value }) }],
});
return {
applied: check(stream(true)),
rejected: check(stream(false)),
unknown: check({ getAudioTracks: () => [{ getSettings: () => ({}) }] }),
unsupported: check({}),
};
};
scenarios.first_sentence_speaks_before_stream_completion = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: 'Tell me something useful.', language: 'en' });
await captureSpeech(harness);
harness.setAssistantReply('This answer is still streaming without a complete sentence');
harness.tick(100);
await harness.flush();
const beforeBoundary = harness.ttsRequests.length;
harness.setAssistantReply('This answer now has its first complete sentence. The remainder is still');
harness.tick(100);
await harness.flush();
const afterBoundary = harness.ttsRequests.length;
harness.setAssistantReply('This answer now has its first complete sentence. The remainder is still being generated and is now complete.');
harness.context.autoReadLastAssistant();
await harness.flush();
return { beforeBoundary, afterBoundary, tts: harness.ttsRequests };
};
scenarios.final_renderer_revision_closes_speech_queue = async () => {
const harness = makeHarness();
await startVoiceMode(harness);
harness.setTranscribeResponse({ ok: true, transcript: 'Explain this.', language: 'en' });
await captureSpeech(harness);
harness.setAssistantReply('Hermes starts with a complete sentence. The draft tail is still');
harness.tick(100);
await harness.flush();
const beforeRevision = harness.ttsRequests.length;
harness.setAssistantReply('The renderer revised the complete sentence. The final tail is now safe and complete.');
harness.context.autoReadLastAssistant();
await harness.flush();
return { beforeRevision, afterRevision: harness.ttsRequests.length };
};
scenarios.streaming_tts_failure_falls_back_to_wav = async () => {
const harness = makeHarness({
enableAudioWorklet: true,
ttsStreamFails: true,
streamingCapability: {
tts: { available: true, transport: 'http', format: 'pcm_s16le', sample_rate: 22050 },
stt: { available: false },
},
});
await startVoiceMode(harness);
await runTurn(harness, { transcript: 'Fallback please.', language: 'en', reply: 'A complete answer.' });
return { stream: harness.ttsStreamRequests, wav: harness.ttsRequests };
};
scenarios.one_ahead_is_bounded_and_turn_cancel_stops_audio = async () => {
const harness = makeHarness({ manualAudio: true });
await startVoiceMode(harness);
const reply = 'This opening sentence is sufficiently complete for a short first speech chunk. '
+ 'This second section contains enough carefully chosen words to become a larger punctuation aligned chunk without losing its natural rhythm or clarity for the listener. '
+ 'This third section is also deliberately long enough to require another queued synthesis request after playback advances.';
await runTurn(harness, { transcript: 'Read it.', language: 'en', reply });
const beforeFirstEnds = harness.ttsRequests.length;
const firstAudio = harness.lastAudio;
harness.elements.btnVoiceMode.click();
await harness.flush();
return {
beforeFirstEnds,
afterCancel: harness.ttsRequests.length,
active: harness.context._voiceModeActive(),
firstAudioPaused: !!(firstAudio && firstAudio.paused),
};
};
(async () => {
const output = {};
const names = Object.keys(scenarios);
for (const name of names) {
// eslint-disable-next-line no-await-in-loop
output[name] = await scenarios[name]();
}
process.stdout.write(JSON.stringify(output, null, 1));
})().catch((error) => {
process.stderr.write(String((error && error.stack) || error));
process.exit(1);
});