402 lines
13 KiB
JavaScript
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;
|
|
});
|