Voices taken from CoreScope feat/audio-voices (ac257569), plus a compat shim for stock audio.js, an idempotent install/uninstall script, a derived-image Dockerfile, and tests that run against a real public/ dir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
4.5 KiB
JavaScript
92 lines
4.5 KiB
JavaScript
/* Smoke test: load a CoreScope public/ dir's audio scripts in the order its
|
|
* index.html lists them, using a fake AudioContext, then check every pack
|
|
* voice registered, constellation stayed the default, and each voice plays
|
|
* every packet type through the real engine without throwing.
|
|
*
|
|
* node tests/smoke.js <corescope public dir, after install.sh>
|
|
*/
|
|
'use strict';
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const PACK_VOICES = ['metal', 'synthmetal', 'technoir', 'ambient', 'dub', 'acid', 'lofi'];
|
|
const TYPES = ['ADVERT', 'GRP_TXT', 'TXT_MSG', 'TRACE', 'REQ', 'UNKNOWN'];
|
|
|
|
function makeParam(value) {
|
|
const p = { value: value || 0 };
|
|
['setValueAtTime', 'linearRampToValueAtTime', 'exponentialRampToValueAtTime', 'setTargetAtTime', 'cancelScheduledValues']
|
|
.forEach((m) => { p[m] = () => p; });
|
|
return p;
|
|
}
|
|
|
|
class FakeCtx {
|
|
constructor() { this.state = 'running'; this.currentTime = 0; this.sampleRate = 8000; this.destination = this._node(); }
|
|
_node(extra) { return Object.assign({ connect: (t) => t, disconnect() {} }, extra); }
|
|
createGain() { return this._node({ gain: makeParam(1) }); }
|
|
createOscillator() { return this._node({ type: 'sine', frequency: makeParam(440), detune: makeParam(), start() {}, stop() {} }); }
|
|
createBiquadFilter() { return this._node({ type: 'lowpass', frequency: makeParam(350), Q: makeParam(1), gain: makeParam() }); }
|
|
createWaveShaper() { return this._node({ curve: null, oversample: 'none' }); }
|
|
createDynamicsCompressor() { return this._node({ threshold: makeParam(), knee: makeParam(), ratio: makeParam(), attack: makeParam(), release: makeParam() }); }
|
|
createStereoPanner() { return this._node({ pan: makeParam() }); }
|
|
createDelay() { return this._node({ delayTime: makeParam() }); }
|
|
createConvolver() { return this._node({ buffer: null }); }
|
|
createBufferSource() { return this._node({ buffer: null, start() {}, stop() {} }); }
|
|
createBuffer(channels, length) {
|
|
const data = Array.from({ length: channels }, () => new Float32Array(length));
|
|
return { numberOfChannels: channels, length, getChannelData: (c) => data[c] };
|
|
}
|
|
resume() {}
|
|
}
|
|
|
|
const publicDir = process.argv[2];
|
|
if (!publicDir) { console.error('usage: node tests/smoke.js <corescope public dir>'); process.exit(2); }
|
|
|
|
const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf8');
|
|
const scripts = [...html.matchAll(/<script src="(audio[^"?]*\.js)/g)].map((m) => m[1]).filter((f) => f !== 'audio-lab.js');
|
|
|
|
const store = {};
|
|
const ctx = {
|
|
console, Math, Float32Array, Object, Array, WeakMap, Map, Set,
|
|
setTimeout: () => 0, clearTimeout() {},
|
|
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; } },
|
|
document: { createElement: () => ({}), body: { appendChild() {} }, addEventListener() {} },
|
|
AudioContext: FakeCtx,
|
|
};
|
|
ctx.window = ctx;
|
|
vm.createContext(ctx);
|
|
scripts.forEach((f) => vm.runInContext(fs.readFileSync(path.join(publicDir, f), 'utf8'), ctx, { filename: f }));
|
|
|
|
let failed = 0;
|
|
function test(name, fn) {
|
|
try { fn(); console.log(` ok ${name}`); } catch (e) { failed++; console.log(` FAIL ${name}: ${e.message}`); }
|
|
}
|
|
|
|
const MA = ctx.MeshAudio;
|
|
test(`scripts load in order: ${scripts.join(', ')}`, () => assert.ok(MA && MA.synthkit));
|
|
test('every pack voice registers', () => {
|
|
const names = MA.getVoiceNames();
|
|
PACK_VOICES.forEach((v) => assert.ok(names.includes(v), `missing ${v} (have ${names.join(', ')})`));
|
|
});
|
|
test('constellation stays the default voice', () => assert.strictEqual(MA.getVoiceName(), 'constellation'));
|
|
|
|
MA.setEnabled(true);
|
|
PACK_VOICES.forEach((name) => test(`${name} plays every packet type`, () => {
|
|
MA.setVoice(name);
|
|
TYPES.forEach((t) => MA.sonifyPacket({
|
|
raw: 'a1b2c3' + '00112233445566778899aabbccddeeff', observation_count: 2,
|
|
decoded: { header: { payloadTypeName: t }, payload: { lat: 40, lon: -100 }, path: { hops: ['ab'] } },
|
|
}));
|
|
// sonifyPacket swallows voice errors, so also call play() directly to surface them.
|
|
const voice = ctx._meshAudioVoices[name];
|
|
const ac = new FakeCtx();
|
|
TYPES.forEach((t) => {
|
|
const dur = voice.play(ac, ac.createGain(), { payloadBytes: [0, 0x42, 0x9a, 0xff, 0x11, 0xc3], typeName: t, hopCount: 2, obsCount: 3, payload: {}, hops: [] }, { bpm: 120, tempoMultiplier: 1 });
|
|
assert.ok(dur > 0, `${t} duration ${dur}`);
|
|
});
|
|
}));
|
|
|
|
console.log(failed ? `\n${failed} failed` : '\nall passed');
|
|
process.exit(failed ? 1 : 0);
|