atlas-iac/testing/probes/hermes_voice_instrument_probe.js

402 lines
13 KiB
JavaScript

'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[0],
...mediaChunks.slice(preSpeechCount - 3),
]);
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 <atlas-voice.js> <media-fixture.json>',
);
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;
});