import assert from 'node:assert/strict' import { createRequire } from 'node:module' import test from 'node:test' const require = createRequire(import.meta.url) const { spectrogram } = require('../native/build/Release/visualizer_dsp.node') const MIN_FREQUENCY = 20 const MAX_FREQUENCY = 20000 const CLASSIC_GAMMA = 1.4 const SHARPER_GAMMA = 1.1 function createTone(frequencyHz, sampleRate, length, amplitude = 1) { return Float32Array.from( { length }, (_, index) => amplitude * Math.sin((2 * Math.PI * frequencyHz * index) / sampleRate), ) } function createCompositeTone(tones, sampleRate, length) { return Float32Array.from( { length }, (_, index) => tones.reduce((sample, tone) => ( sample + tone.amplitude * Math.sin((2 * Math.PI * tone.frequencyHz * index) / sampleRate) ), 0), ) } function configure(overrides = {}) { spectrogram.configure({ fftSize: 4096, sampleRate: 48000, rowCount: 401, minFrequency: MIN_FREQUENCY, maxFrequency: MAX_FREQUENCY, minDecibels: -100, maxDecibels: 0, scrollSpeed: 2, contrast: 1, tiltDbPerOctave: 0, clarityMode: 'classic', scaleMode: 'log', orientation: 'horizontal', ...overrides, }) spectrogram.reset() } function hzToMelSlaney(frequencyHz) { const linearSpacing = 200 / 3 const minimumLogMel = 1000 / linearSpacing const logStep = Math.log(6.4) / 27 return frequencyHz < 1000 ? frequencyHz / linearSpacing : minimumLogMel + Math.log(frequencyHz / 1000) / logStep } function expectedNormalizedPosition(frequencyHz, scaleMode, minFrequency, maxFrequency) { if (scaleMode === 'linear') { return (frequencyHz - minFrequency) / (maxFrequency - minFrequency) } if (scaleMode === 'mel') { const melMin = hzToMelSlaney(minFrequency) const melMax = hzToMelSlaney(maxFrequency) return (hzToMelSlaney(frequencyHz) - melMin) / (melMax - melMin) } return Math.log10(frequencyHz / minFrequency) / Math.log10(maxFrequency / minFrequency) } function findPeakRow(values) { let peakRow = 0 for (let row = 1; row < values.length; row += 1) { if (values[row] > values[peakRow]) peakRow = row } return peakRow } function decodeClassicDisplayDb(value, minDecibels, maxDecibels) { const normalized = Math.pow(value, 1 / CLASSIC_GAMMA) return minDecibels + normalized * (maxDecibels - minDecibels) } function decodeSharperDisplayDb(value, gamma, minDecibels, maxDecibels) { const normalized = Math.pow(value, 1 / gamma) return minDecibels + normalized * (maxDecibels - minDecibels) } function finalColumn(result) { const offset = (result.columnCount - 1) * result.rowCount return result.display.subarray(offset, offset + result.rowCount) } function localPeak(values, centerRow, radius = 2) { let peak = 0 for ( let row = Math.max(0, Math.round(centerRow) - radius); row <= Math.min(values.length - 1, Math.round(centerRow) + radius); row += 1 ) { peak = Math.max(peak, values[row]) } return peak } function assertAlmostEqual(actual, expected, tolerance, message) { assert.ok( Math.abs(actual - expected) <= tolerance, `${message}: expected ${expected} +/- ${tolerance}, got ${actual}`, ) } test('spectrogram places tones accurately on log, mel, and linear axes', () => { const rowCount = 401 const configurations = [ { sampleRate: 44100, fftSize: 2048 }, { sampleRate: 48000, fftSize: 4096 }, { sampleRate: 96000, fftSize: 8192 }, ] const frequencies = [55, 440, 1000, 5234.5, 15000, 19777] const amplitudes = [0.001, 0.1] for (const { sampleRate, fftSize } of configurations) { const maximum = Math.min(MAX_FREQUENCY, sampleRate / 2) for (const scaleMode of ['log', 'mel', 'linear']) { for (const frequencyHz of frequencies) { if (frequencyHz > maximum) continue for (const amplitude of amplitudes) { configure({ sampleRate, fftSize, rowCount, scaleMode }) const result = spectrogram.process(createTone(frequencyHz, sampleRate, fftSize, amplitude)) const peakRow = findPeakRow(result.display) const expectedRow = ( 1 - expectedNormalizedPosition(frequencyHz, scaleMode, MIN_FREQUENCY, maximum) ) * (rowCount - 1) assert.equal(result.columnCount, 1) assert.ok(result.display[peakRow] > 0.05, `${scaleMode} ${frequencyHz}Hz should remain visible`) assertAlmostEqual( peakRow, expectedRow, 1, `${scaleMode} ${frequencyHz}Hz at ${sampleRate}Hz / FFT ${fftSize}, amplitude ${amplitude}`, ) } } } } }) test('spectrogram log rows retain narrow high-frequency tones between row centers', () => { configure({ scaleMode: 'log', rowCount: 401 }) const result = spectrogram.process(createTone(15000, 48000, 4096, 0.01)) const peakRow = findPeakRow(result.display) const expectedRow = ( 1 - expectedNormalizedPosition(15000, 'log', MIN_FREQUENCY, MAX_FREQUENCY) ) * 400 assert.ok(result.display[peakRow] > 0.3) assertAlmostEqual(peakRow, expectedRow, 1, '15kHz log-axis regression') }) test('spectrogram Hann normalization reports calibrated tone levels', () => { const minDecibels = -120 const maxDecibels = 12 for (const fftSize of [1024, 4096, 8192]) { for (const binPosition of [37, 37.37]) { const frequencyHz = binPosition * 48000 / fftSize for (const amplitude of [1, 0.5, 0.1]) { configure({ fftSize, rowCount: 1, minDecibels, maxDecibels }) const result = spectrogram.process(createTone(frequencyHz, 48000, fftSize, amplitude)) const measuredDbfs = decodeClassicDisplayDb(result.display[0], minDecibels, maxDecibels) assertAlmostEqual( measuredDbfs, 20 * Math.log10(amplitude), 0.3, `amplitude ${amplitude} at FFT ${fftSize}, bin ${binPosition}`, ) } } } configure({ fftSize: 4096, rowCount: 1, minDecibels, maxDecibels }) const silence = spectrogram.process(new Float32Array(4096)) assert.equal(silence.display[0], 0) assert.ok(silence.display.every(Number.isFinite)) assert.ok(silence.heat.every(Number.isFinite)) }) test('spectrogram Sharper conserves calibrated power while frequency-reassigning every visible bin', () => { const sampleRate = 48000 const minDecibels = -120 const maxDecibels = 12 for (const fftSize of [1024, 4096, 8192]) { const hopSize = fftSize / 16 for (const binPosition of [37, 37.37]) { const frequencyHz = binPosition * sampleRate / fftSize for (const amplitude of [0.5, 0.1]) { configure({ fftSize, sampleRate, rowCount: 1, minDecibels, maxDecibels, clarityMode: 'sharper', }) const result = spectrogram.process(createTone( frequencyHz, sampleRate, fftSize + hopSize, amplitude, )) const measuredDbfs = decodeSharperDisplayDb( finalColumn(result)[0], SHARPER_GAMMA, minDecibels, maxDecibels, ) assertAlmostEqual( measuredDbfs, 20 * Math.log10(amplitude), 0.3, `Sharper amplitude ${amplitude} at FFT ${fftSize}, bin ${binPosition}`, ) } } } }) test('spectrogram Sharper keeps reassigned tones on the correct Log, Mel, and Linear rows', () => { const rowCount = 601 for (const { sampleRate, fftSize } of [ { sampleRate: 44100, fftSize: 2048 }, { sampleRate: 48000, fftSize: 4096 }, { sampleRate: 96000, fftSize: 8192 }, ]) { const hopSize = fftSize / 16 const maximum = Math.min(MAX_FREQUENCY, sampleRate / 2) for (const scaleMode of ['log', 'mel', 'linear']) { for (const frequencyHz of [440, 5234.5, 15000]) { configure({ sampleRate, fftSize, rowCount, scaleMode, clarityMode: 'sharper' }) const result = spectrogram.process(createTone( frequencyHz, sampleRate, fftSize + hopSize, 0.01, )) const peakRow = findPeakRow(finalColumn(result)) const expectedRow = ( 1 - expectedNormalizedPosition(frequencyHz, scaleMode, MIN_FREQUENCY, maximum) ) * (rowCount - 1) assertAlmostEqual( peakRow, expectedRow, 1, `Sharper ${scaleMode} ${frequencyHz}Hz at ${sampleRate}Hz / FFT ${fftSize}`, ) } } } }) test('spectrogram Sharper resolves quiet nearby detail without retaining the Classic blob', () => { const sampleRate = 48000 const fftSize = 4096 const rowCount = 801 const hopSize = fftSize / 16 const strongFrequencyHz = 1000 const quietFrequencyHz = 1120 const strongOnly = [{ frequencyHz: strongFrequencyHz, amplitude: 1 }] const withQuietDetail = [...strongOnly, { frequencyHz: quietFrequencyHz, amplitude: 0.01 }] const renderFinalColumn = (clarityMode, tones) => { configure({ fftSize, sampleRate, rowCount, clarityMode }) return finalColumn(spectrogram.process(createCompositeTone( tones, sampleRate, fftSize + hopSize, ))) } const classicTone = renderFinalColumn('classic', strongOnly) const sharperTone = renderFinalColumn('sharper', strongOnly) const classicHalfHeightRows = classicTone.filter((value) => value >= Math.max(...classicTone) * 0.5).length const sharperHalfHeightRows = sharperTone.filter((value) => value >= Math.max(...sharperTone) * 0.5).length assert.ok(sharperHalfHeightRows <= 3, `expected a narrow Sharper line, got ${sharperHalfHeightRows} rows`) assert.ok(sharperHalfHeightRows < classicHalfHeightRows) const detailed = renderFinalColumn('sharper', withQuietDetail) const quietRow = ( 1 - expectedNormalizedPosition(quietFrequencyHz, 'log', MIN_FREQUENCY, MAX_FREQUENCY) ) * (rowCount - 1) const quietDisplay = localPeak(detailed, quietRow) const quietDbfs = decodeSharperDisplayDb(quietDisplay, SHARPER_GAMMA, -100, 0) assertAlmostEqual(quietDbfs, -40, 2, 'quiet detail beside a full-scale tone') }) test('spectrogram Focused restores the former sparse peak-isolation profile', () => { const sampleRate = 48000 const fftSize = 4096 const rowCount = 801 const hopSize = fftSize / 16 let noiseState = 7 const signal = Float32Array.from({ length: fftSize + hopSize }, (_, index) => { noiseState = ((noiseState * 1664525) + 1013904223) >>> 0 const noise = (((noiseState / 0x100000000) * 2) - 1) * 0.03 return ( (0.55 * Math.sin((2 * Math.PI * 220 * index) / sampleRate)) + (0.3 * Math.sin((2 * Math.PI * 440.3 * index) / sampleRate)) + (0.15 * Math.sin((2 * Math.PI * 997 * index) / sampleRate)) + noise ) }) const render = (clarityMode) => { configure({ fftSize, sampleRate, rowCount, clarityMode }) return finalColumn(spectrogram.process(signal)) } const focused = render('focused') const sharper = render('sharper') const focusedActiveRows = Array.from(focused).filter((value) => value > 0.1).length const sharperActiveRows = Array.from(sharper).filter((value) => value > 0.1).length assert.ok(Math.max(...focused) > 0.5, 'Focused should retain strong spectral peaks') assert.ok( focusedActiveRows < sharperActiveRows * 0.6, `Focused should isolate peaks (${focusedActiveRows} active rows vs ${sharperActiveRows} in Sharper)`, ) }) test('spectrogram combines stereo energy without dropping anti-phase content', () => { assert.equal(typeof spectrogram.processStereo, 'function') const fftSize = 4096 const minDecibels = -120 const maxDecibels = 12 const amplitude = 0.5 const frequencyHz = 83 * 48000 / fftSize const tone = createTone(frequencyHz, 48000, fftSize, amplitude) const invertedTone = Float32Array.from(tone, (sample) => -sample) const silence = new Float32Array(fftSize) const expectedStereoDbfs = 20 * Math.log10(amplitude) for (const [label, left, right, expectedDbfs] of [ ['centered', tone, tone, expectedStereoDbfs], ['anti-phase', tone, invertedTone, expectedStereoDbfs], ['left-only', tone, silence, expectedStereoDbfs - (20 * Math.log10(Math.sqrt(2)))], ]) { configure({ fftSize, rowCount: 1, minDecibels, maxDecibels }) const result = spectrogram.processStereo(left, right) const measuredDbfs = decodeClassicDisplayDb(result.display[0], minDecibels, maxDecibels) assertAlmostEqual(measuredDbfs, expectedDbfs, 0.3, `${label} stereo level`) } }) test('spectrogram clamps its frequency mapping to Nyquist', () => { const sampleRate = 32000 const fftSize = 4096 const rowCount = 401 const frequencyHz = 15000 const nyquist = sampleRate / 2 configure({ sampleRate, fftSize, rowCount, maxFrequency: MAX_FREQUENCY, scaleMode: 'log' }) const result = spectrogram.process(createTone(frequencyHz, sampleRate, fftSize, 0.01)) const peakRow = findPeakRow(result.display) const expectedRow = ( 1 - expectedNormalizedPosition(frequencyHz, 'log', MIN_FREQUENCY, nyquist) ) * (rowCount - 1) assertAlmostEqual(peakRow, expectedRow, 1, 'Nyquist-clamped row') }) test('spectrogram scroll speeds produce the expected analysis hop counts through x8', () => { const fftSize = 1024 const extraSamples = 512 for (const scrollSpeed of [1, 2, 4, 8]) { configure({ fftSize, rowCount: 8, scrollSpeed }) const result = spectrogram.process(new Float32Array(fftSize + extraSamples)) const hopSize = fftSize / Math.round(8 * scrollSpeed) const expectedColumns = 1 + Math.floor(extraSamples / hopSize) assert.equal(result.columnCount, expectedColumns, `x${scrollSpeed} column count`) assert.equal(result.display.length, expectedColumns * 8) assert.equal(result.heat.length, expectedColumns * 8) } })