atlas-iac/testing/probes/hermes_voice_capture_probe.js
jenkins 3fd760de0e hermes(voice): fix HHermesProcessed + first-sentence-stop; orb mark, lang selector
Real root cause (confirmed against the live build-24 DOM): the caption
and TTS extraction fell back to turn.textContent whenever a settle-frame
race left no readable answer segment, scraping the avatar letter,
author name and 'Processed 13s' chip - and that truncated reply made
TTS speak only the first segment then drop to Listening even with the
mic muted (the muted-mic first-sentence-stop). Extraction now prefers
each answer segment's data-raw-text, else the answer .msg-body only
(excluding thinking/tool/worklog/role chrome), and the textContent
fallback is gone; a genuinely mid-flight reply retries briefly so the
whole thing is read before the overlay drains. Also: the app's own
caduceus mark embedded in the conversation orb as a subtle watermark; a
corner language selector (Auto + en/es/ru) that forces both the STT
hint and the reply voice; and a thinking affordance after 2.5s of dead
time. New response probe + extraction test prove caption==body-only and
that every sentence reaches the TTS queue. 278 voice-lane tests green.

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

949 lines
32 KiB
JavaScript

// Deterministic continuous-capture probe for dockerfiles/hermes-webui-atlas-voice.js.
//
// Drives the real script through complete streaming hands-free turns against a
// stub STT WebSocket whose VAD, speculative EOS-freeze, resume and commit
// semantics faithfully mirror dockerfiles/hermes-jetson-stt-server.py. Words
// are encoded as distinct constant PCM amplitudes so the "transcript" of any
// audio snapshot is exactly the ordered set of words whose samples survived
// endpointing — a first-word-only regression is directly observable as a
// truncated transcript in the sent composer text.
//
// node hermes_voice_capture_probe.js <path-to-atlas-voice.js>
//
// Prints one JSON object per scenario to stdout; the python wrapper asserts.
'use strict';
const fs = require('fs');
const vm = require('vm');
const SCRIPT_PATH = process.argv[2];
if (!SCRIPT_PATH) {
throw new Error('usage: hermes_voice_capture_probe.js <atlas-voice.js>');
}
const SOURCE = fs.readFileSync(SCRIPT_PATH, 'utf8');
class FakeURL extends URL {}
FakeURL.createObjectURL = () => 'blob:capture-probe';
FakeURL.revokeObjectURL = () => {};
const SAMPLE_RATE = 16000;
const TICK_MS = 100;
const TICK_SAMPLES = (SAMPLE_RATE * TICK_MS) / 1000;
// Server constants mirrored from hermes-jetson-stt-server.py.
const SERVER_END_SILENCE_SAMPLES = (SAMPLE_RATE * 650) / 1000;
const SERVER_TAIL_SAMPLES = (SAMPLE_RATE * 220) / 1000;
function flush() {
return new Promise((resolve) => {
let hops = 0;
(function hop() {
hops += 1;
if (hops > 16) { resolve(); return; }
setImmediate(hop);
})();
});
}
function makeElement(id) {
const element = {
id,
value: '',
textContent: '',
className: '',
dataset: {},
attributes: {},
children: [],
style: {
values: new Map(),
display: '',
setProperty(name, value) { this.values.set(name, String(value)); },
getPropertyValue(name) { return this.values.get(name) || ''; },
removeProperty(name) { this.values.delete(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) {
element.listeners = element.listeners.filter((entry) => entry.handler !== handler);
},
appendChild(child) { element.children.push(child); child.parentNode = element; return child; },
removeChild(child) {
element.children = element.children.filter((entry) => entry !== child);
child.parentNode = null;
return child;
},
contains() { return false; },
focus() {},
querySelector() { return null; },
querySelectorAll() { return []; },
insertBefore() {},
click() {
const event = { preventDefault() {}, stopImmediatePropagation() {} };
element.listeners
.filter((entry) => entry.type === 'click')
.forEach((entry) => entry.handler(event));
},
};
element.classList = {
add(name) {
const names = new Set(element.className.split(/\s+/).filter(Boolean));
names.add(name);
element.className = [...names].join(' ');
},
remove(name) {
element.className = element.className
.split(/\s+/).filter((value) => value && value !== name).join(' ');
},
contains(name) { return element.className.split(/\s+/).includes(name); },
toggle(name, force) {
const present = this.contains(name);
const next = force === undefined ? !present : !!force;
if (next) this.add(name); else this.remove(name);
return next;
},
};
return element;
}
// Faithful port of StreamingTranscription's endpointing/speculation contract.
class StubSttServer {
constructor(words) {
// words: {amplitude(int16) -> word string}
this.words = words;
this.chunks = []; // Int16Array chunks in arrival order
this.totalSamples = 0;
this.noiseFloor = 0.004;
this.heardSpeech = false;
this.atEos = false;
this.silenceSamples = 0;
this.lastSpeechSample = 0;
this.clientActive = false;
this.epoch = 0;
this.frozenSnapshotSamples = -1;
this.committed = false;
this.events = [];
}
snapshotSamples() {
if (this.atEos && this.frozenSnapshotSamples >= 0) return this.frozenSnapshotSamples;
let cutoff = this.totalSamples;
if (this.heardSpeech && this.lastSpeechSample) {
cutoff = Math.min(cutoff, this.lastSpeechSample + SERVER_TAIL_SAMPLES);
}
return cutoff;
}
append(int16) {
if (this.committed) throw new Error('server: audio after commit');
this.chunks.push(int16);
this.totalSamples += int16.length;
let energy = 0;
for (let index = 0; index < int16.length; index += 1) {
const value = int16[index] / 32768;
energy += value * value;
}
const rms = int16.length ? Math.sqrt(energy / int16.length) : 0;
const threshold = Math.max(0.012, this.noiseFloor * 2.5 + 0.004);
const speech = this.clientActive || rms >= threshold;
if (!this.heardSpeech && !speech) {
this.noiseFloor = this.noiseFloor * 0.96 + rms * 0.04;
}
if (speech) {
if (this.atEos) {
this.epoch += 1;
this.frozenSnapshotSamples = -1;
this.events.push('recover-by-rms');
}
this.heardSpeech = true;
this.atEos = false;
this.silenceSamples = 0;
this.lastSpeechSample = this.totalSamples;
} else if (this.heardSpeech) {
this.silenceSamples += int16.length;
if (this.silenceSamples >= SERVER_END_SILENCE_SAMPLES && !this.atEos) {
this.atEos = true;
this.frozenSnapshotSamples = this.snapshotSamples();
this.events.push('auto-eos-freeze');
}
}
}
speculate() {
this.clientActive = false;
this.atEos = true;
this.frozenSnapshotSamples = this.snapshotSamples();
this.events.push('speculate-freeze');
}
resume() {
this.atEos = false;
this.silenceSamples = 0;
this.epoch += 1;
this.clientActive = true;
this.frozenSnapshotSamples = -1;
this.events.push('resume-clear');
}
transcript() {
const cutoff = this.snapshotSamples();
const decoded = [];
let position = 0;
let currentWord = null;
let runLength = 0;
const flushRun = () => {
// Ignore sub-40ms runs: resampler transition samples, not words.
if (currentWord && runLength >= SAMPLE_RATE * 0.04) decoded.push(currentWord);
currentWord = null;
runLength = 0;
};
for (const chunk of this.chunks) {
for (let index = 0; index < chunk.length; index += 1) {
if (position >= cutoff) { flushRun(); return decoded.join(' '); }
position += 1;
const magnitude = Math.abs(chunk[index]);
let word = null;
for (const [amplitude, name] of Object.entries(this.words)) {
if (Math.abs(magnitude - Number(amplitude)) <= 200) { word = name; break; }
}
if (word === currentWord) {
runLength += 1;
} else {
flushRun();
currentWord = word;
runLength = word ? 1 : 0;
}
}
}
flushRun();
return decoded.join(' ');
}
}
function makeHarness(options = {}) {
const clock = { now: 1000000 };
const timeouts = [];
const intervals = new Map();
let timerId = 1;
const sends = [];
const toasts = [];
const transcribeUploads = [];
const ttsCalls = [];
const servers = [];
const captureNodes = [];
const analysers = [];
const recorders = [];
let assistantRows = [];
let streamCounter = 0;
const words = options.words || { 8000: 'alpha', 12000: 'bravo', 16000: 'charlie' };
const micTracks = [];
const elements = {};
['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg']
.forEach((id) => { elements[id] = makeElement(id); });
const bodyElement = makeElement('body');
// ── Microphone model ────────────────────────────────────────────────────
// Per tick the mic has one (analyser level, pcm amplitude) pair. The level
// feeds the script's VAD analyser; the amplitude becomes the PCM the
// capture worklet delivers to the streaming session.
const mic = { level: 0, amplitude: 0 };
class FakeAnalyser {
constructor() { this.fftSize = 0; analysers.push(this); }
getByteTimeDomainData(samples) {
for (let index = 0; index < samples.length; index += 1) {
const offset = Math.round(mic.level * 127);
samples[index] = 128 + (index % 2 ? offset : -offset);
}
}
disconnect() {}
}
class FakeAudioContext {
constructor() {
this.sampleRate = SAMPLE_RATE;
this.destination = {};
this.audioWorklet = { addModule: async () => {} };
this.closed = false;
}
createAnalyser() { return new FakeAnalyser(); }
createBiquadFilter() {
return { type: '', frequency: { value: 0 }, Q: { value: 0 }, connect() {}, disconnect() {} };
}
createMediaStreamSource() { return { connect() {}, disconnect() {} }; }
createGain() { return { gain: { value: 0, setTargetAtTime() {} }, connect() {}, disconnect() {} }; }
resume() { return Promise.resolve(); }
close() { this.closed = true; return Promise.resolve(); }
}
class FakeAudioWorkletNode {
constructor(_context, name) {
this.name = name;
this.connected = true;
const node = this;
this.port = {
onmessage: null,
postMessage(message) {
if ((message || {}).type === 'flush') {
queueMicrotask(() => {
if (node.port.onmessage) node.port.onmessage({ data: { type: 'flushed' } });
});
}
},
};
if (name === 'atlas-pcm-capture') captureNodes.push(this);
}
connect() {}
disconnect() { this.connected = false; }
}
class FakeMediaRecorder {
static isTypeSupported() { return true; }
constructor(stream, recorderOptions) {
this.stream = stream;
this.state = 'inactive';
this.mimeType = (recorderOptions && recorderOptions.mimeType) || 'audio/webm;codecs=opus';
this.ondataavailable = null;
this.onstop = null;
recorders.push(this);
}
start() { this.state = 'recording'; }
stop() {
if (this.state === 'inactive') return;
this.state = 'inactive';
if (this.ondataavailable) {
this.ondataavailable({ data: { size: 512, type: 'audio/webm;codecs=opus' } });
}
const recorder = this;
// Real recorders finalize asynchronously.
queueMicrotask(() => { if (recorder.onstop) recorder.onstop(); });
}
}
class FakeWebSocket {
constructor(url, protocols) {
this.url = url;
this.protocols = protocols;
this.readyState = 0;
this.bufferedAmount = 0;
this.binaryType = '';
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.onclose = null;
this.server = new StubSttServer(words);
this.turnId = '';
servers.push(this);
const socket = this;
queueMicrotask(() => {
if (socket.readyState !== 0) return;
socket.readyState = 1;
if (socket.onopen) socket.onopen();
});
}
deliver(payload) {
const socket = this;
queueMicrotask(() => {
if (socket.readyState !== 1) return;
if (socket.onmessage) socket.onmessage({ data: JSON.stringify(payload) });
});
}
send(data) {
if (this.readyState !== 1) throw new Error('socket not open');
if (typeof data === 'string') {
const message = JSON.parse(data);
if (message.type === 'start') { this.turnId = message.turn_id; this.startLanguage = message.language; return; }
if (message.type === 'speculate') { this.server.speculate(); return; }
if (message.type === 'resume') { this.server.resume(); return; }
if (message.type === 'cancel') { this.server.events.push('cancel'); return; }
if (message.type === 'commit') {
this.server.committed = true;
this.deliver({
type: 'final',
turn_id: this.turnId,
transcript: this.server.transcript(),
language: 'en',
});
}
return;
}
this.server.append(new Int16Array(data));
// Mirror the server's rolling-partial stream: one stable partial per
// decoded-transcript change, so the client's dynamic endpoint sees the
// same signal the Jetson emits.
const partialText = this.server.transcript();
if (partialText && partialText !== this.lastPartialText) {
this.lastPartialText = partialText;
this.partialRevision = (this.partialRevision || 0) + 1;
this.deliver({
type: 'partial',
rolling: true,
turn_id: this.turnId,
revision: this.partialRevision,
transcript: partialText,
stable_transcript: partialText,
});
}
}
close(code) { this.readyState = 3; this.closeCode = code; }
}
async function fetchStub(url, init) {
if (url === '/api/transcribe/capability') {
return { ok: true, status: 200, json: async () => ({ available: true, provider: 'local_command' }) };
}
if (url === '/api/voice/streaming/capability') {
return {
ok: true,
status: 200,
json: async () => ({
tts: { available: false },
stt: { available: true, transport: 'websocket', format: 'pcm_s16le', sample_rate: 16000 },
preflight: { available: false },
}),
};
}
if (url === '/api/transcribe') {
transcribeUploads.push({ body: init && init.body });
return { ok: true, status: 200, json: async () => ({ transcript: 'CONTAINER-FALLBACK', language: 'en' }) };
}
if (url === '/api/tts') {
ttsCalls.push(JSON.parse((init && init.body) || '{}'));
return { ok: true, status: 200, blob: async () => ({ synthetic: true }), json: async () => ({}) };
}
if (String(url).indexOf('api/chat/cancel') >= 0) {
context.S.busy = false;
context.S.activeStreamId = null;
if (context.S.session) context.S.session.active_stream_id = null;
return { ok: true, status: 200, json: async () => ({ cancelled: true }) };
}
throw new Error(`unexpected fetch: ${url}`);
}
const storage = new Map();
const context = {
AbortController,
console,
Uint8Array,
Int16Array,
Float32Array,
ArrayBuffer,
DataView,
Promise,
Math,
JSON,
String,
Number,
Error,
parseInt,
parseFloat,
isNaN,
Set,
Map,
Array,
Object,
URL: FakeURL,
queueMicrotask,
Date: { now: () => clock.now },
Blob: function Blob(parts, blobOptions) {
this.parts = parts;
this.type = (blobOptions || {}).type || '';
this.size = 512;
},
File: function File(parts, name, fileOptions) {
this.parts = parts; this.name = name; this.type = (fileOptions || {}).type || '';
},
FormData: function FormData() {
this.entries = []; this.append = (key, value) => this.entries.push([key, value]);
},
Audio: function Audio() {
this.play = () => Promise.resolve();
this.pause = () => {};
this.onended = null;
this.onerror = null;
},
WebSocket: FakeWebSocket,
AudioWorkletNode: FakeAudioWorkletNode,
MediaRecorder: FakeMediaRecorder,
AudioContext: FakeAudioContext,
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: {
getSupportedConstraints: () => ({}),
getUserMedia: async () => {
const track = {
stop() {},
enabled: true,
getSettings: () => ({ echoCancellation: true }),
};
micTracks.push(track);
return { getTracks: () => [track], getAudioTracks: () => [track] };
},
},
},
document: {
baseURI: 'https://chat.test/',
body: bodyElement,
getElementById: (id) => elements[id] || null,
querySelectorAll: () => assistantRows,
createElement: (tag) => makeElement(`created-${tag}`),
addEventListener() {},
removeEventListener() {},
},
location: { protocol: 'https:', host: 'chat.test', href: 'https://chat.test/' },
crypto: { getRandomValues(bytes) { for (let i = 0; i < bytes.length; i += 1) bytes[i] = i + 1; } },
S: { session: { session_id: 'session-1' }, busy: false, activeStreamId: null },
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.window.location = context.location;
context.window.crypto = context.crypto;
context.showToast = (message) => { toasts.push(message); };
context.send = () => {
streamCounter += 1;
sends.push(elements.msg.value);
context.S.busy = true;
context.S.activeStreamId = `stream-${streamCounter}`;
if (context.S.session) context.S.session.active_stream_id = context.S.activeStreamId;
};
context.autoResize = () => {};
context.stopTTS = () => {};
context._stripForTTS = (text) => text;
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 currentCaptureNode() {
for (let index = captureNodes.length - 1; index >= 0; index -= 1) {
if (captureNodes[index].connected) return captureNodes[index];
}
return null;
}
function deliverMicFrame() {
const node = currentCaptureNode();
if (!node || !node.port.onmessage) return;
const samples = new Float32Array(TICK_SAMPLES);
const value = mic.amplitude / 32768;
for (let index = 0; index < samples.length; index += 1) {
samples[index] = index % 2 ? value : -value;
}
node.port.onmessage({ data: { type: 'pcm', samples: samples.buffer } });
}
async function tick(ms) {
const steps = Math.max(1, Math.round(ms / TICK_MS));
for (let step = 0; step < steps; step += 1) {
clock.now += TICK_MS;
deliverMicFrame();
Array.from(intervals.values()).forEach((entry) => entry.fn());
runDueTimeouts();
// eslint-disable-next-line no-await-in-loop
await flush();
}
}
async function speak(word, ms) {
const amplitude = Number(Object.keys(words).find((key) => words[key] === word));
if (!amplitude) throw new Error(`unknown word: ${word}`);
mic.level = Math.min(0.9, amplitude / 32768 + 0.25);
mic.amplitude = amplitude;
await tick(ms);
}
async function silence(ms) {
mic.level = 0;
mic.amplitude = 0;
await tick(ms);
}
return {
context,
elements,
sends,
toasts,
transcribeUploads,
ttsCalls,
servers,
recorders,
clock,
tick,
speak,
silence,
flush,
runDueTimeouts,
setAssistantReply(text) { assistantRows = [{ dataset: { rawText: text } }]; },
setAssistantError(text) { assistantRows = [{ dataset: { rawText: text, error: '1' } }]; },
clearAssistantRows() { assistantRows = []; },
state() { return elements.voiceModeBar.dataset.voiceState || ''; },
body() { return bodyElement; },
trackStates() { return micTracks.map((track) => track.enabled); },
overlay() {
return bodyElement.children.find(
(child) => String(child.className).indexOf('voice-conversation') >= 0,
) || null;
},
completeResponse() {
context.S.busy = false;
context.S.activeStreamId = null;
if (context.S.session) context.S.session.active_stream_id = null;
context.autoReadLastAssistant();
},
async start() {
await flush();
elements.btnVoiceMode.click();
await flush();
await flush();
},
};
}
const scenarios = {};
// The reported live regression: a normal multi-word utterance with a natural
// inter-word pause long enough for the client to speculate (>=605ms at the
// default 1100ms endpoint) must still transcribe completely.
scenarios.multiword_utterance_with_interword_pause = async () => {
const harness = makeHarness();
await harness.start();
await harness.silence(300);
await harness.speak('alpha', 400);
await harness.silence(700); // client speculates at ~605ms
await harness.speak('bravo', 600);
await harness.silence(1400); // past the 1100ms endpoint
await harness.tick(400); // commit retries + final delivery
return {
sends: harness.sends,
state: harness.state(),
serverEvents: harness.servers.map((socket) => socket.server.events),
toasts: harness.toasts,
};
};
// Continuous capture: the mic stays hot after an utterance is dispatched, and
// the next utterance (spoken while the first response is still in flight)
// barges in and is transcribed completely, including words after a pause.
scenarios.second_utterance_during_response_is_complete = async () => {
const harness = makeHarness();
await harness.start();
await harness.silence(300);
await harness.speak('alpha', 500);
await harness.silence(1400);
await harness.tick(400); // first send dispatched
const firstSends = harness.sends.slice();
// The model is now answering (S.busy). Assistant text starts streaming.
harness.setAssistantReply('Partial answer already visible.');
await harness.tick(200);
// User talks over the response with a two-word interruption + pause.
await harness.speak('bravo', 500);
await harness.silence(700);
await harness.speak('charlie', 500);
await harness.silence(1400);
await harness.tick(400);
return {
firstSends,
sends: harness.sends,
state: harness.state(),
serverEvents: harness.servers.map((socket) => socket.server.events),
toasts: harness.toasts,
};
};
// The mic never re-acquires getUserMedia across utterances and a second
// utterance right after a normally completed response is sent alone: a
// normal completion clears any stitch context.
scenarios.back_to_back_utterances_stay_hot = async () => {
const harness = makeHarness();
await harness.start();
await harness.silence(300);
await harness.speak('alpha', 1300);
await harness.silence(1500);
await harness.tick(400); // first send dispatched (past the endpoint)
const sendsAfterFirst = harness.sends.slice();
harness.completeResponse();
await harness.tick(400);
await harness.speak('bravo', 1300);
await harness.silence(1500);
await harness.tick(400);
return {
sendsAfterFirst,
sends: harness.sends,
micAcquisitions: harness.trackStates().length,
recorderCount: harness.recorders.length,
serverCount: harness.servers.length,
toasts: harness.toasts,
};
};
// The exact live regression: "The … <thinking pause> rest of the sentence"
// used to endpoint on the first inter-word gap (>=1100ms) and send the first
// word alone. A young utterance now holds its endpoint to 1800ms, so the
// pause stays inside one utterance and one complete message is sent.
scenarios.thinking_pause_after_first_word_does_not_split = async () => {
const harness = makeHarness();
await harness.start();
await harness.silence(300);
await harness.speak('alpha', 400);
await harness.silence(1300); // longer than the old 1100ms endpoint
await harness.speak('bravo', 700);
await harness.silence(2000);
await harness.tick(400);
return {
sends: harness.sends,
serverEvents: harness.servers.map((socket) => socket.server.events),
toasts: harness.toasts,
};
};
// Conversation mode: activation builds the full-screen overlay, captions
// follow the turn, mute drives the real capture track, and both the exit
// control and Escape tear the overlay down completely.
scenarios.conversation_overlay_lifecycle = async () => {
const harness = makeHarness();
await harness.start();
const overlay = harness.overlay();
if (!overlay) return { overlayPresent: false };
const find = (root, needle) => root.children.find(
(child) => String(child.className).indexOf(needle) >= 0,
);
const role = overlay.getAttribute('role');
const ariaModal = overlay.getAttribute('aria-modal');
await harness.silence(300);
const listeningState = overlay.dataset.voiceState;
await harness.speak('alpha', 1300);
await harness.silence(1500);
await harness.tick(400);
const captions = find(overlay, 'voice-conversation-captions');
const userCaption = captions ? captions.children[0].textContent : '';
const captionsLive = captions ? captions.getAttribute('aria-live') : '';
harness.setAssistantReply('A visible answer.');
await harness.tick(300);
const assistantCaption = captions ? captions.children[1].textContent : '';
const thinkingState = overlay.dataset.voiceState;
const controls = find(overlay, 'voice-conversation-controls');
const muteBtn = controls.children[0];
const exitBtn = controls.children[1];
muteBtn.click();
const mutedPressed = muteBtn.getAttribute('aria-pressed');
const mutedTracks = harness.trackStates();
muteBtn.click();
const unmutedTracks = harness.trackStates();
exitBtn.click();
await harness.flush();
const removedOnExit = !harness.overlay();
const inactiveAfterExit = !harness.context._voiceModeActive();
// Re-activate and leave through Escape instead.
harness.elements.btnVoiceMode.click();
await harness.flush();
const overlayAgain = harness.overlay();
const keydown = overlayAgain.listeners.find((entry) => entry.type === 'keydown');
keydown.handler({ key: 'Escape', preventDefault() {} });
await harness.flush();
return {
overlayPresent: true,
role,
ariaModal,
listeningState,
thinkingState,
userCaption,
assistantCaption,
captionsLive,
mutedPressed,
mutedTracks,
unmutedTracks,
removedOnExit,
inactiveAfterExit,
removedOnEscape: !harness.overlay(),
sends: harness.sends,
};
};
// Dynamic endpointing: a >=3-word partial reads as a plausibly complete
// utterance and endpoints at the base silence window; 1-2 word partials keep
// the young-utterance hold so thinking pauses still never clip.
scenarios.three_word_partial_endpoints_at_base_silence = async () => {
const harness = makeHarness();
await harness.start();
await harness.silence(300);
await harness.speak('alpha', 300);
await harness.speak('bravo', 300);
await harness.speak('charlie', 400);
await harness.silence(1200); // past the base 1100ms endpoint, well under the 1800ms hold
await harness.tick(300);
return { sends: harness.sends, state: harness.state() };
};
scenarios.two_word_young_utterance_keeps_the_hold = async () => {
const harness = makeHarness();
await harness.start();
await harness.silence(300);
await harness.speak('alpha', 300);
await harness.speak('bravo', 300);
await harness.silence(1300); // longer than base, shorter than the hold
const sendsEarly = harness.sends.slice();
await harness.silence(700);
await harness.tick(300);
return { sendsEarly, sends: harness.sends };
};
// An errored turn (error/system envelope in the transcript) is never spoken,
// never captioned as a reply, and fully resynchronizes capture.
scenarios.errored_turn_is_not_spoken_and_capture_resyncs = async () => {
const harness = makeHarness();
await harness.start();
await harness.silence(300);
await harness.speak('alpha', 1300);
await harness.silence(1500);
await harness.tick(400); // dispatched; thinking
const serversBefore = harness.servers.length;
const ttsBefore = harness.ttsCalls.length;
harness.setAssistantError('**Task cancelled:** Task cancelled.');
await harness.tick(300); // the response observer sees the envelope
const stateAfterError = harness.state();
const labelAfterError = harness.elements.voiceModeLabel.textContent;
await harness.speak('bravo', 1300);
await harness.silence(1500);
await harness.tick(400);
return {
stateAfterError,
labelAfterError,
ttsDuringError: harness.ttsCalls.length - ttsBefore,
freshSessions: harness.servers.length - serversBefore,
sends: harness.sends,
};
};
// Voice barge-in appends a single-line cut marker naming the sentence that
// was playing, so the model knows where its reply was cut off.
scenarios.barge_cut_marker_records_spoken_tail = async () => {
const harness = makeHarness();
await harness.start();
await harness.silence(300);
await harness.speak('alpha', 1300);
await harness.silence(1500);
await harness.tick(400); // dispatched; thinking
harness.setAssistantReply('The first point is ready. The second point needs many more words before it becomes another speakable chunk.');
await harness.tick(300); // first sentence reaches TTS and starts playing
await harness.speak('bravo', 900); // talk over the reply
await harness.silence(1900);
await harness.tick(400);
return { sends: harness.sends, ttsTexts: harness.ttsCalls.map((request) => request.text) };
};
// The detected utterance language re-biases the following streaming STT
// session (sticky per hands-free session).
scenarios.sticky_language_biases_next_stt_session = async () => {
const harness = makeHarness();
await harness.start();
await harness.silence(300);
await harness.speak('alpha', 1300);
await harness.silence(1500);
await harness.tick(400);
harness.completeResponse();
await harness.tick(400);
await harness.speak('bravo', 1300);
await harness.silence(1500);
await harness.tick(400);
return { startLanguages: harness.servers.map((socket) => socket.startLanguage) };
};
// FIX 3: choosing a language in the conversation overlay FORCES both the
// streaming STT hint and the reply TTS voice for the whole session, overriding
// auto-detection, until Auto is chosen again.
scenarios.language_override_forces_stt_and_voice = async () => {
const harness = makeHarness();
await harness.start();
const overlay = harness.overlay();
if (!overlay) return { overlayPresent: false };
const langWrap = overlay.children.find(
(child) => String(child.className).indexOf('voice-conversation-lang') >= 0,
);
const menu = langWrap ? langWrap.children.find(
(child) => String(child.className).indexOf('voice-conversation-lang-menu') >= 0,
) : null;
const items = menu ? menu.children : [];
const ru = items.find((item) => item.getAttribute('data-lang') === 'ru');
const auto = items.find((item) => item.getAttribute('data-lang') === '');
const langBtn = langWrap ? langWrap.children.find(
(child) => String(child.className).indexOf('voice-conversation-lang-btn') >= 0,
) : null;
if (!ru) return { overlayPresent: true, ruItemPresent: false };
// Force Russian.
ru.click();
const forcedChecked = ru.getAttribute('aria-checked');
const autoChecked = auto ? auto.getAttribute('aria-checked') : null;
const btnForced = langBtn ? String(langBtn.className).indexOf('is-forced') >= 0 : false;
// Run a turn with an English reply: STT hint and TTS voice must both be 'ru'.
await harness.silence(300);
await harness.speak('alpha', 1300);
await harness.silence(1500);
await harness.tick(400);
harness.setAssistantReply('A short English reply. It has two sentences.');
harness.completeResponse();
await harness.tick(600);
const startLanguages = harness.servers.map((socket) => socket.startLanguage);
const ttsLanguages = harness.ttsCalls.map((call) => call.language);
// Release back to Auto.
if (auto) auto.click();
const releasedChecked = auto ? auto.getAttribute('aria-checked') : null;
const btnForcedAfterAuto = langBtn ? String(langBtn.className).indexOf('is-forced') >= 0 : false;
return {
overlayPresent: true,
ruItemPresent: true,
forcedChecked,
autoChecked,
btnForced,
startLanguages,
ttsLanguages,
releasedChecked,
btnForcedAfterAuto,
};
};
(async () => {
const output = {};
for (const name of Object.keys(scenarios)) {
// 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);
});