Recover analyzer DSP and realtime pipeline corrections
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = fs.readFileSync(new URL('../www/core/binary_protocol.js', import.meta.url), 'utf8');
|
||||
const context = vm.createContext({ ArrayBuffer, DataView, Float32Array, Number });
|
||||
vm.runInContext(source.replaceAll('export function', 'function'), context, {
|
||||
filename: 'binary_protocol.js',
|
||||
});
|
||||
|
||||
function spectroPacket() {
|
||||
const buffer = new ArrayBuffer(28);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint32(0, 0x50585350, true);
|
||||
view.setUint32(4, 42, true);
|
||||
view.setUint32(8, 48000, true);
|
||||
view.setUint32(12, 8192, true);
|
||||
view.setUint32(16, 2, true);
|
||||
view.setFloat32(20, -80, true);
|
||||
view.setFloat32(24, -12.5, true);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function visualsPacket() {
|
||||
const buffer = new ArrayBuffer(64);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint32(0, 0x50585653, true);
|
||||
view.setUint16(4, 1, true);
|
||||
view.setUint16(6, 3, true);
|
||||
view.setUint32(8, 7, true);
|
||||
view.setUint32(12, 2, true);
|
||||
view.setUint32(16, 2, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint32(24, 5, true);
|
||||
view.setUint32(28, 48000, true);
|
||||
[-0.5, 0.25, 0.5, -0.25, -0.5, 0.5, -0.25, 0.25]
|
||||
.forEach((value, index) => view.setFloat32(32 + index * 4, value, true));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const spectro = context.decodePhoenixSpectroBuffer(spectroPacket());
|
||||
assert.equal(spectro.seq, 42);
|
||||
assert.equal(spectro.sampleRate, 48000);
|
||||
assert.equal(spectro.fftSize, 8192);
|
||||
assert.deepEqual(Array.from(spectro.bins), [-80, -12.5]);
|
||||
|
||||
const visual = context.decodePhoenixVisualsBuffer(visualsPacket());
|
||||
assert.equal(visual.seq, 7);
|
||||
assert.deepEqual(Array.from(visual.left), [-0.5, 0.5]);
|
||||
assert.deepEqual(Array.from(visual.right), [0.25, -0.25]);
|
||||
assert.equal(visual.wave.columns, 2);
|
||||
assert.equal(visual.wave.channels, 1);
|
||||
assert.deepEqual(Array.from(visual.wave.data), [-0.5, 0.5, -0.25, 0.25]);
|
||||
|
||||
assert.equal(context.decodePhoenixSpectroBuffer(spectroPacket().slice(0, 24)), null);
|
||||
assert.equal(context.decodePhoenixVisualsBuffer(visualsPacket().slice(0, 60)), null);
|
||||
const wrongVersion = visualsPacket();
|
||||
new DataView(wrongVersion).setUint16(4, 2, true);
|
||||
assert.equal(context.decodePhoenixVisualsBuffer(wrongVersion), null);
|
||||
|
||||
console.log('binary protocol regression tests passed');
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8');
|
||||
const functionSource = source.match(/export function buildRtaRuntimeConfig[\s\S]*?\n\}/)?.[0];
|
||||
assert.ok(functionSource, 'buildRtaRuntimeConfig must remain testable');
|
||||
const context = vm.createContext({
|
||||
Number,
|
||||
getRtwCenters(mode) {
|
||||
return mode === '1_3' ? new Array(31).fill(0) : [];
|
||||
},
|
||||
});
|
||||
vm.runInContext(functionSource.replace('export function', 'function'), context);
|
||||
|
||||
const forced = context.buildRtaRuntimeConfig({
|
||||
RTA_BAR_LAYOUT: 'rtw',
|
||||
RTA_ENGINE: 'fft',
|
||||
RTA_BPO_MODE: '1_12',
|
||||
RTA_FREQ_RANGE: 'lf',
|
||||
RTA_IIR_ORDER: 2,
|
||||
});
|
||||
assert.equal(forced.engine, 'iir');
|
||||
assert.equal(forced.bpo, '1_3');
|
||||
assert.equal(forced.freqRange, 'norm');
|
||||
assert.equal(forced.order, 6);
|
||||
assert.equal(forced.rtwCenters.length, 31);
|
||||
|
||||
const extension = context.buildRtaRuntimeConfig({
|
||||
RTA_BAR_LAYOUT: 'iec',
|
||||
RTA_ENGINE: 'fft',
|
||||
RTA_BPO_MODE: '1_12',
|
||||
RTA_FREQ_RANGE: 'lf',
|
||||
RTA_IIR_ORDER: 8,
|
||||
});
|
||||
assert.equal(extension.engine, 'fft');
|
||||
assert.equal(extension.bpo, '1_12');
|
||||
assert.equal(extension.freqRange, 'lf');
|
||||
assert.equal(extension.order, 8);
|
||||
|
||||
console.log('RTA profile regression tests passed');
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const viewSource = fs.readFileSync(new URL('../www/views/spectrogram.js', import.meta.url), 'utf8');
|
||||
const timingSource = viewSource.match(/export function stepScrollAccumulator[\s\S]*?\n\}/)?.[0];
|
||||
assert.ok(timingSource, 'stepScrollAccumulator must remain testable');
|
||||
const timingContext = vm.createContext({ Number, Math });
|
||||
vm.runInContext(timingSource.replace('export function', 'function'), timingContext);
|
||||
const { stepScrollAccumulator } = timingContext;
|
||||
const pixelsSource = viewSource.match(/export function spectrogramPixelsPerSourceFrame[\s\S]*?\n\}/)?.[0];
|
||||
assert.ok(pixelsSource, 'spectrogramPixelsPerSourceFrame must remain testable');
|
||||
timingContext.BASE_SCROLL_CSS_PX_PER_SECOND = 60;
|
||||
vm.runInContext(pixelsSource.replace('export function', 'function'), timingContext);
|
||||
const { spectrogramPixelsPerSourceFrame } = timingContext;
|
||||
|
||||
function emittedColumns(rate, frames, pixelScale = 1) {
|
||||
let accumulator = 0;
|
||||
let columns = 0;
|
||||
for (let frame = 0; frame < frames; frame++) {
|
||||
const step = stepScrollAccumulator(accumulator, rate * pixelScale, 1);
|
||||
accumulator = step.accumulator;
|
||||
columns += step.columns;
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
assert.equal(emittedColumns(0.5, 20), 10, '0.5x must emit every second source frame');
|
||||
assert.equal(emittedColumns(1, 20), 20);
|
||||
assert.equal(emittedColumns(2, 20), 40);
|
||||
assert.equal(emittedColumns(4, 20), 80);
|
||||
assert.equal(emittedColumns(6, 20), 120);
|
||||
assert.equal(emittedColumns(1, 10, 1.7), 17, 'DPR scaling must preserve CSS scroll speed');
|
||||
|
||||
function emittedInOneSecond(rate, fftSize) {
|
||||
const sampleRate = 48000;
|
||||
const hop = Math.max(128, Math.floor(fftSize / 8));
|
||||
const sourceFrames = sampleRate / hop;
|
||||
const pixelsPerFrame = spectrogramPixelsPerSourceFrame(rate, sampleRate, fftSize);
|
||||
return emittedColumns(pixelsPerFrame, sourceFrames);
|
||||
}
|
||||
|
||||
assert.equal(emittedInOneSecond(0.5, 4096), 30);
|
||||
assert.equal(emittedInOneSecond(1, 4096), 60);
|
||||
assert.equal(emittedInOneSecond(1, 8192), 60, 'FFT size must not alter real-time scroll speed');
|
||||
assert.equal(emittedInOneSecond(2, 8192), 120);
|
||||
assert.equal(emittedInOneSecond(4, 8192), 240);
|
||||
assert.equal(emittedInOneSecond(6, 8192), 360);
|
||||
|
||||
const messages = [];
|
||||
const calls = { drawImage: 0, putImageData: [] };
|
||||
const context2d = {
|
||||
imageSmoothingEnabled: true,
|
||||
globalCompositeOperation: 'source-over',
|
||||
fillStyle: '#000',
|
||||
save() {},
|
||||
restore() {},
|
||||
fillRect() {},
|
||||
drawImage() { calls.drawImage += 1; },
|
||||
createImageData(width, height) {
|
||||
return { width, height, data: new Uint8ClampedArray(width * height * 4) };
|
||||
},
|
||||
putImageData(image, x, y) { calls.putImageData.push({ image, x, y }); },
|
||||
};
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext() { return context2d; },
|
||||
};
|
||||
const workerContext = vm.createContext({
|
||||
self: {},
|
||||
postMessage(message) { messages.push(message); },
|
||||
performance: { now: (() => { let now = 0; return () => ++now; })() },
|
||||
Float32Array,
|
||||
Uint8ClampedArray,
|
||||
Number,
|
||||
Math,
|
||||
});
|
||||
const workerSource = fs.readFileSync(new URL('../www/workers/spectrogram.worker.js', import.meta.url), 'utf8');
|
||||
vm.runInContext(workerSource, workerContext, { filename: 'spectrogram.worker.js' });
|
||||
|
||||
workerContext.self.onmessage({
|
||||
data: { type: 'init', canvas, width: 8, height: 4, topDb: 0, bottomDb: -80, gamma: 1 },
|
||||
});
|
||||
assert.equal(messages.at(-1)?.type, 'ready');
|
||||
|
||||
workerContext.self.onmessage({
|
||||
data: { type: 'column', id: 7, repeat: 2, data: new Float32Array([-80, -40, -20, 0]) },
|
||||
});
|
||||
const ack = messages.at(-1);
|
||||
assert.equal(ack.type, 'drawn');
|
||||
assert.equal(ack.id, 7);
|
||||
assert.equal(ack.repeat, 2);
|
||||
assert.equal(calls.drawImage, 1, 'existing canvas should be shifted once');
|
||||
assert.equal(calls.putImageData.length, 1, 'only the new stripe should be uploaded');
|
||||
assert.equal(calls.putImageData[0].image.width, 2);
|
||||
assert.equal(calls.putImageData[0].x, 6);
|
||||
|
||||
// Roughly ten minutes at 100 source columns/s. The worker has no history or
|
||||
// pending-message collection that can grow with runtime.
|
||||
messages.length = 0;
|
||||
context2d.putImageData = () => {};
|
||||
const longRunColumn = new Float32Array([-80, -40, -20, 0]);
|
||||
for (let index = 0; index < 60_000; index++) {
|
||||
workerContext.self.onmessage({
|
||||
data: { type: 'column', id: 100 + index, repeat: 1, data: longRunColumn },
|
||||
});
|
||||
}
|
||||
assert.equal(messages.length, 60_000);
|
||||
assert.equal(messages.at(-1)?.type, 'drawn');
|
||||
assert.equal(messages.at(-1)?.id, 60_099);
|
||||
|
||||
console.log('spectrogram timing and worker tests passed');
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const base = process.env.PHOENIX_TEST_WS_BASE || 'ws://127.0.0.1:8789';
|
||||
|
||||
function openSocket(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(`${base}${path}`);
|
||||
socket.addEventListener('open', () => resolve(socket), { once: true });
|
||||
socket.addEventListener('error', () => reject(new Error(`failed to open ${path}`)), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
const sockets = await Promise.all([
|
||||
openSocket('/api/v1/metrics/ws'),
|
||||
openSocket('/api/v1/spectro/ws'),
|
||||
openSocket('/api/v1/visuals/ws'),
|
||||
]);
|
||||
const [metrics] = sockets;
|
||||
let count = 0;
|
||||
let lastSeq = 0;
|
||||
let invalidPayload = false;
|
||||
metrics.addEventListener('message', (event) => {
|
||||
const frame = JSON.parse(String(event.data));
|
||||
const seq = Number(frame.seq);
|
||||
if (seq <= lastSeq || 'spectro' in frame || 'wave_env' in frame || 'xy_l' in frame || 'wave_l' in frame) {
|
||||
invalidPayload = true;
|
||||
}
|
||||
lastSeq = seq;
|
||||
count += 1;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
sockets.forEach((socket) => socket.close());
|
||||
assert.equal(invalidPayload, false, 'metrics must be ordered and contain no large visual payloads');
|
||||
assert.ok(count >= 50 && count <= 75, `expected about 60 metrics/s, received ${count}`);
|
||||
console.log(`runtime websocket test passed (${count} metrics in 1.1 s; all three streams opened)`);
|
||||
Reference in New Issue
Block a user