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
684 lines
24 KiB
JavaScript
684 lines
24 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(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);
|
||
});
|