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');