Recover analyzer DSP and realtime pipeline corrections

This commit is contained in:
Mikei386
2026-07-21 19:57:52 +02:00
parent e499bac928
commit 91aeccb938
27 changed files with 3410 additions and 924 deletions
+61
View File
@@ -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');
+41
View File
@@ -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');
+113
View File
@@ -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');
+36
View File
@@ -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)`);