'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); class StyleDeclaration { constructor() { this.values = new Map(); this.display = ''; } setProperty(name, value) { this.values.set(name, String(value)); } removeProperty(name) { this.values.delete(name); } getPropertyValue(name) { return this.values.get(name) || ''; } } class Element { constructor(id) { this.id = id; this.className = ''; this.textContent = ''; this.value = ''; this.dataset = {}; this.style = new StyleDeclaration(); this.attributes = new Map(); this.listeners = new Map(); this.children = []; this.firstChild = null; } get classList() { const element = this; return { 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); }, }; } addEventListener(type, callback) { this.listeners.set(type, callback); } removeEventListener(type, callback) { if (this.listeners.get(type) === callback) this.listeners.delete(type); } setAttribute(name, value) { this.attributes.set(name, String(value)); } getAttribute(name) { return this.attributes.get(name) || null; } insertBefore(child) { this.children.unshift(child); this.firstChild = this.children[0]; } querySelector(selector) { if (selector === 'option[value="atlas"]') { return this.children.find(child => child.value === 'atlas') || null; } return null; } click() { const callback = this.listeners.get('click'); if (callback) callback({preventDefault() {}, stopImmediatePropagation() {}}); } } function flush() { return new Promise(resolve => setImmediate(resolve)); } async function boot(scriptPath, reduced) { const elements = new Map(); for (const id of ['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg', 'settingsTtsEngine', 'voiceInstrumentStyles']) { elements.set(id, new Element(id)); } const modeButton = elements.get('btnVoiceMode'); const bar = elements.get('voiceModeBar'); const indicator = elements.get('voiceModeIndicator'); const label = elements.get('voiceModeLabel'); const styleLink = elements.get('voiceInstrumentStyles'); styleLink.setAttribute('href', 'static/atlas-voice.css?v=test'); bar.style.display = 'none'; let now = 1000; let nextTimer = 1; const intervals = new Map(); const timeouts = new Map(); const captures = []; const recorders = []; const analysers = []; const assistantRows = []; const toasts = []; const reducedMotion = {matches: reduced, addEventListener() {}, removeEventListener() {}}; let rejectNextCapture = false; let transcriptResolve; let uploadedFile = null; let sent = 0; let lastAudio = null; class FakeDate extends Date { static now() { return now; } } class FakeAnalyser { constructor() { this.fftSize = 0; this.level = 0; } getByteTimeDomainData(samples) { const sample = 128 + Math.round(this.level * 128); samples.fill(sample); } } class FakeAudioContext { createAnalyser() { const analyser = new FakeAnalyser(); analysers.push(analyser); return analyser; } createBiquadFilter() { return {type: '', frequency: {value: 0}, Q: {value: 0}, connect() {}}; } createMediaStreamSource() { return {connect() {}}; } close() { return Promise.resolve(); } } class FakeMediaRecorder { static isTypeSupported() { return true; } constructor(stream, options) { this.stream = stream; this.state = 'inactive'; this.mimeType = options?.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.onstop) queueMicrotask(() => this.onstop()); } } class FakeAudio { constructor() { this.currentTime = 0; this.onended = null; this.onerror = null; this.paused = false; lastAudio = this; } play() { this.paused = false; return Promise.resolve(); } pause() { this.paused = true; } finish() { if (this.onended) this.onended(); } } const document = { getElementById(id) { return elements.get(id) || null; }, createElement() { return new Element('created'); }, querySelectorAll(selector) { assert.equal(selector, '.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]'); return assistantRows; }, }; const localValues = new Map(); const localStorage = { getItem(key) { return localValues.has(key) ? localValues.get(key) : null; }, setItem(key, value) { localValues.set(key, String(value)); }, }; const stream = {getTracks() { return [{stop() {}}]; }}; const navigator = { mediaDevices: { getSupportedConstraints() { return {}; }, async getUserMedia() { if (rejectNextCapture) { rejectNextCapture = false; throw new Error('Microphone unavailable'); } captures.push(stream); return stream; }, }, }; async function fetch(url, options) { if (url === '/api/transcribe/capability') { return {ok: true, json: async () => ({available: true, provider: 'local_command'})}; } if (url === '/api/transcribe') { const file = options?.body?.get('file'); assert.ok(file, 'transcription request did not carry a file'); uploadedFile = { bytes: new Uint8Array(await file.arrayBuffer()), name: file.name, type: file.type, }; return new Promise(resolve => { transcriptResolve = resolve; }); } if (url === '/api/tts') { return {ok: true, blob: async () => new Blob(['wave'], {type: 'audio/wav'})}; } throw new Error(`unexpected fetch: ${url}`); } const window = { MediaRecorder: FakeMediaRecorder, AudioContext: FakeAudioContext, setInterval(callback) { const id = nextTimer++; intervals.set(id, callback); return id; }, clearInterval(id) { intervals.delete(id); }, setTimeout(callback, delay) { const id = nextTimer++; timeouts.set(id, {callback, delay}); return id; }, clearTimeout(id) { timeouts.delete(id); }, matchMedia(query) { assert.equal(query, '(prefers-reduced-motion: reduce)'); return reducedMotion; }, showToast(message) { toasts.push(message); }, autoResize() {}, send() { sent += 1; }, stopTTS() {}, _splitForTTS(text) { return [text]; }, _stripForTTS(text) { return text; }, URL: {createObjectURL() { return 'blob:voice'; }, revokeObjectURL() {}}, }; const context = { Audio: FakeAudio, Blob, Date: FakeDate, File, FormData, MediaRecorder: FakeMediaRecorder, URL: window.URL, Uint8Array, clearInterval: window.clearInterval, console, document, fetch, localStorage, navigator, queueMicrotask, S: {busy: false, session: {session_id: 'session-1'}}, window, }; window.window = window; window.document = document; window.fetch = fetch; window.localStorage = localStorage; window.navigator = navigator; Object.assign(window, {Blob, Date: FakeDate, File, FormData, URL: window.URL, Uint8Array}); vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), context, {filename: scriptPath}); await flush(); await flush(); return { elements, modeButton, bar, indicator, label, styleLink, intervals, timeouts, window, captures, recorders, analysers, assistantRows, toasts, reducedMotion, get sent() { return sent; }, get lastAudio() { return lastAudio; }, get uploadedFile() { return uploadedFile; }, set now(value) { now = value; }, rejectCapture() { rejectNextCapture = true; }, resolveTranscript(payload) { assert.ok(transcriptResolve, 'transcription request was not started'); transcriptResolve({ok: true, json: async () => payload}); }, runIntervals() { for (const callback of [...intervals.values()]) callback(); }, runTimeout(delay) { const match = [...timeouts].find(([, timer]) => timer.delay === delay); assert.ok(match, `timer ${delay}ms was not scheduled`); timeouts.delete(match[0]); match[1].callback(); }, }; } async function normalMotionContract(scriptPath, mediaFixture) { const probe = await boot(scriptPath, false); const originalStyleLink = probe.styleLink; probe.modeButton.click(); assert.match(probe.indicator.className, /\blistening\b/); assert.equal(probe.label.textContent, 'Listening'); assert.equal(probe.bar.style.display, ''); await flush(); assert.equal(probe.captures.length, 1); const mediaChunks = mediaFixture.chunks; const preSpeechCount = mediaFixture.pre_speech_chunk_count; for (const chunk of mediaChunks.slice(0, preSpeechCount)) { probe.recorders[0].ondataavailable({ data: new Blob([chunk], {type: mediaFixture.mime_type}), }); } probe.analysers[0].level = 0.3; probe.runIntervals(); probe.runIntervals(); probe.runIntervals(); for (const chunk of mediaChunks.slice(preSpeechCount)) { probe.recorders[0].ondataavailable({ data: new Blob([chunk], {type: mediaFixture.mime_type}), }); } probe.now = 4000; probe.analysers[0].level = 0; probe.runIntervals(); await flush(); await flush(); assert.match(probe.indicator.className, /\btranscribing\b/); assert.equal(probe.label.textContent, 'Transcribing…'); assert.ok(probe.uploadedFile, 'transcription upload was not captured'); assert.equal(probe.uploadedFile.name, 'voice-input.webm'); assert.equal(probe.uploadedFile.type, mediaFixture.mime_type); const expectedUpload = Buffer.concat(mediaChunks); assert.deepEqual(Buffer.from(probe.uploadedFile.bytes), expectedUpload); probe.resolveTranscript({transcript: 'Hello Hermes'}); await flush(); await flush(); assert.match(probe.indicator.className, /\bthinking\b/); assert.equal(probe.label.textContent, 'Thinking…'); assert.equal(probe.sent, 1); probe.assistantRows.push({dataset: {rawText: 'A calm answer.'}}); const capturesBeforeSpeech = probe.captures.length; probe.window.autoReadLastAssistant(); assert.match(probe.indicator.className, /\bspeaking\b/); assert.equal(probe.label.textContent, 'Speaking'); assert.equal(probe.captures.length, capturesBeforeSpeech); await flush(); await flush(); assert.match(probe.indicator.className, /\bis-playing\b/); assert.equal(probe.captures.length, capturesBeforeSpeech); probe.lastAudio.finish(); await flush(); assert.doesNotMatch(probe.indicator.className, /\bis-playing\b/); return {probe, originalStyleLink, capturesBeforeSpeech}; } async function main() { const scriptPath = process.argv[2]; const fixturePath = process.argv[3]; assert.ok( scriptPath && fixturePath, 'usage: node hermes_voice_instrument_probe.js ', ); const mediaFixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); const payload = fs.readFileSync(path.join(path.dirname(fixturePath), mediaFixture.payload_file)); let offset = 0; mediaFixture.chunks = mediaFixture.chunk_sizes.map(size => { const chunk = payload.subarray(offset, offset + size); offset += size; return chunk; }); assert.equal(offset, payload.length, 'fixture chunk sizes do not cover the payload'); const first = await normalMotionContract(scriptPath, mediaFixture); const {probe, originalStyleLink, capturesBeforeSpeech} = first; assert.equal(probe.captures.length, capturesBeforeSpeech); assert.strictEqual(probe.elements.get('voiceInstrumentStyles'), originalStyleLink); probe.modeButton.click(); assert.equal(probe.bar.style.display, 'none'); assert.match(probe.indicator.className, /\bidle\b/); probe.modeButton.click(); await flush(); probe.modeButton.click(); assert.equal(probe.bar.style.display, 'none'); assert.strictEqual(probe.elements.get('voiceInstrumentStyles'), originalStyleLink); const reduced = await boot(scriptPath, true); reduced.modeButton.click(); await flush(); const initialScale = reduced.indicator.style.getPropertyValue('--voice-ripple-scale'); reduced.analysers[0].level = 0.5; reduced.runIntervals(); assert.equal(reduced.indicator.style.getPropertyValue('--voice-ripple-scale'), initialScale); reduced.modeButton.click(); const unavailable = await boot(scriptPath, false); unavailable.rejectCapture(); unavailable.modeButton.click(); await flush(); await flush(); assert.match(unavailable.indicator.className, /\berror\b/); assert.equal(unavailable.label.textContent, 'Microphone unavailable'); assert.equal(unavailable.bar.style.display, ''); unavailable.runTimeout(3200); assert.equal(unavailable.bar.style.display, 'none'); assert.match(unavailable.indicator.className, /\bidle\b/); process.stdout.write('voice instrument DOM contract passed\n'); } main().catch(error => { console.error(error.stack || error); process.exitCode = 1; });