make Spectrum and Spectrogram more frequency accurate and detail preserving

This commit is contained in:
Boof2015
2026-08-16 16:43:31 -04:00
parent f6d9719c43
commit 3b302d5526
29 changed files with 1715 additions and 242 deletions
+30
View File
@@ -82,6 +82,36 @@ test('spectrum keeps stereo chunks for the side overlay path and still exposes m
assert.equal(router.flushPendingSpectrumStereoSamples().length, 0)
})
test('spectrogram preserves stereo chunks so out-of-phase content cannot cancel before analysis', () => {
const router = new AudioRouter()
const sessionId = router.beginSession(48000, 2, 'native-macos')
router.setVisualizerConsumerDemand('test-consumer', { spectrogram: true })
router.ingestChunk(createChunk(0.5), createChunk(-0.5), {
sessionId,
channelCount: 2,
sequence: 1,
capturedAt: performance.now() - 5,
})
const stereoChunks = router.flushPendingSpectrogramStereoSamples()
assert.equal(stereoChunks.length, 1)
assert.deepEqual(Array.from(stereoChunks[0]?.left ?? []), [0.5, 0.5, 0.5, 0.5])
assert.deepEqual(Array.from(stereoChunks[0]?.right ?? []), [-0.5, -0.5, -0.5, -0.5])
assert.equal(router.flushPendingSpectrogramSamples().length, 0)
router.ingestChunk(createChunk(2), createChunk(4), {
sessionId,
channelCount: 2,
sequence: 2,
capturedAt: performance.now() - 5,
})
const compatibilityMono = router.flushPendingSpectrogramSamples()
assert.deepEqual(Array.from(compatibilityMono[0] ?? []), [3, 3, 3, 3])
assert.equal(router.flushPendingSpectrogramStereoSamples().length, 0)
})
test('waveform keeps stereo chunks for stereo mode while mono flushes still expose the left channel', () => {
const router = new AudioRouter()
const sessionId = router.beginSession(48000, 2, 'native-macos')
+72
View File
@@ -50,7 +50,13 @@ function createProfile(name: string): Profile {
profile.windowBounds = { x: 10, y: 20, width: 840, height: 180 }
profile.scopeSettings.spectrum.showSideLine = true
profile.scopeSettings.spectrum.heatmapSmoothing = 0.67
profile.scopeSettings.spectrum.scaleMode = 'mel'
profile.scopeSettings.spectrum.frequencyRangeMode = 'audible'
profile.scopeSettings.spectrogram.colorScheme = 'mono'
profile.scopeSettings.spectrogram.clarityMode = 'focused'
profile.scopeSettings.spectrogram.scaleMode = 'linear'
profile.scopeSettings.spectrogram.frequencyRangeMode = 'extended'
profile.scopeSettings.spectrogram.showGrid = true
profile.scopeSettings.spectrogram.rotation = 90
profile.scopeSettings.spectrogram.mirrorHorizontal = true
return profile
@@ -68,6 +74,12 @@ test('profile file serialization excludes geometry and round-trips with local me
assert.equal(JSON.stringify(file).includes('inputGainDb'), false)
assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true })
assert.equal(file.scopeSettings.spectrum.heatmapSmoothing, 0.67)
assert.equal(file.scopeSettings.spectrum.scaleMode, 'mel')
assert.equal(file.scopeSettings.spectrum.frequencyRangeMode, 'audible')
assert.equal(file.scopeSettings.spectrogram.scaleMode, 'linear')
assert.equal(file.scopeSettings.spectrogram.clarityMode, 'focused')
assert.equal(file.scopeSettings.spectrogram.frequencyRangeMode, 'extended')
assert.equal(file.scopeSettings.spectrogram.showGrid, true)
assert.equal(file.scopeSettings.spectrogram.rotation, 90)
assert.equal(file.scopeSettings.spectrogram.mirrorHorizontal, true)
assert.equal('orientation' in file.scopeSettings.spectrogram, false)
@@ -80,7 +92,13 @@ test('profile file serialization excludes geometry and round-trips with local me
assert.deepEqual(restored.scopePopouts.spectrum.windowBounds, profile.scopePopouts.spectrum.windowBounds)
assert.equal(restored.scopeSettings.spectrum.showSideLine, true)
assert.equal(restored.scopeSettings.spectrum.heatmapSmoothing, 0.67)
assert.equal(restored.scopeSettings.spectrum.scaleMode, 'mel')
assert.equal(restored.scopeSettings.spectrum.frequencyRangeMode, 'audible')
assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono')
assert.equal(restored.scopeSettings.spectrogram.clarityMode, 'focused')
assert.equal(restored.scopeSettings.spectrogram.scaleMode, 'linear')
assert.equal(restored.scopeSettings.spectrogram.frequencyRangeMode, 'extended')
assert.equal(restored.scopeSettings.spectrogram.showGrid, true)
assert.equal(restored.scopeSettings.spectrogram.rotation, 90)
assert.equal(restored.scopeSettings.spectrogram.mirrorHorizontal, true)
assert.equal(restored.scopeSettings.nowPlaying.showControls, true)
@@ -184,6 +202,60 @@ test('mergeScopeSettings defaults missing or invalid VU needle channel settings
assert.equal(missing.vumeter.needleChannels, 'stereo')
})
test('mergeScopeSettings normalizes frequency scales, ranges, clarity, and supported spectrogram speeds', () => {
const valid = mergeScopeSettings({
spectrum: { scaleMode: 'mel', frequencyRangeMode: 'extended' },
spectrogram: {
scaleMode: 'linear',
frequencyRangeMode: 'audible',
clarityMode: 'focused',
showGrid: true,
scrollSpeed: 8,
},
})
const invalid = mergeScopeSettings({
spectrum: { scaleMode: 'bark', frequencyRangeMode: 'full' },
spectrogram: {
scaleMode: 42,
frequencyRangeMode: 12,
clarityMode: 'etched',
showGrid: 'yes',
scrollSpeed: 99,
},
})
const legacy = mergeScopeSettings({
spectrogram: { scrollSpeed: 0.5 },
})
const missing = mergeScopeSettings({})
assert.equal(valid.spectrum.scaleMode, 'mel')
assert.equal(valid.spectrum.frequencyRangeMode, 'extended')
assert.equal(valid.spectrogram.scaleMode, 'linear')
assert.equal(valid.spectrogram.frequencyRangeMode, 'audible')
assert.equal(valid.spectrogram.clarityMode, 'focused')
assert.equal(valid.spectrogram.showGrid, true)
assert.equal(valid.spectrogram.scrollSpeed, 8)
assert.equal(invalid.spectrum.scaleMode, 'log')
assert.equal(invalid.spectrum.frequencyRangeMode, 'extended')
assert.equal(invalid.spectrogram.scaleMode, 'log')
assert.equal(invalid.spectrogram.frequencyRangeMode, 'extended')
assert.equal(invalid.spectrogram.clarityMode, 'sharper')
assert.equal(invalid.spectrogram.showGrid, false)
assert.equal(invalid.spectrogram.scrollSpeed, 8)
assert.equal(legacy.spectrogram.scrollSpeed, 0.5)
assert.equal(missing.spectrum.scaleMode, 'log')
assert.equal(missing.spectrum.frequencyRangeMode, 'extended')
assert.equal(missing.spectrogram.scaleMode, 'log')
assert.equal(missing.spectrogram.frequencyRangeMode, 'extended')
assert.equal(missing.spectrogram.clarityMode, 'sharper')
assert.equal(missing.spectrogram.showGrid, false)
const newProfile = createDefaultProfile('New')
assert.equal(newProfile.scopeSettings.spectrum.frequencyRangeMode, 'extended')
assert.equal(newProfile.scopeSettings.spectrogram.frequencyRangeMode, 'extended')
assert.equal(newProfile.scopeSettings.spectrogram.showGrid, true)
})
test('library saves, renames, deletes, and resolves filename collisions', async () => {
const harness = await createHarness()
+351 -8
View File
@@ -117,6 +117,8 @@ import { SpectrumAnalyzer, type SpectrumAnalyzerOptions } from '../src/renderer/
import { BridgeSpectrumAnalyzer } from '../src/plugin-ui/BridgeSpectrumAnalyzer'
import { decodeSpectrumFrame } from '../src/plugin-ui/juceBridge'
import { formatSpectrumPeakDbfs } from '../src/plugin-ui/peakOverlay'
import { spectrogramSettingsToOptions } from '../src/plugin-ui/spectrogramOptions'
import { spectrumSettingsToOptions } from '../src/plugin-ui/spectrumOptions'
import { Spectrogram, type SpectrogramOptions } from '../src/renderer/visualizers/Spectrogram'
import { Vectorscope } from '../src/renderer/visualizers/Vectorscope'
import { Waveform } from '../src/renderer/visualizers/Waveform'
@@ -145,6 +147,14 @@ import {
type Profile,
} from '../src/types/profile'
import type { WindowCapabilities } from '../src/types/windowCapabilities'
import {
buildFrequencyGuides,
clampFrequencyRangeToNyquist,
frequencyBoundsForRange,
normalizeFrequencyScaleMode,
normalizedPositionAtFrequency,
type FrequencyScaleMode,
} from '../src/types/frequencyScale'
type WindowWithRaf = typeof globalThis & Pick<Window, 'requestAnimationFrame' | 'cancelAnimationFrame'>
type FakeElectronAPI = {
@@ -1720,6 +1730,29 @@ test('scope measurement helpers resolve MiniMeters-style cursor axis values', ()
assert.deepEqual(spectrum.values.slice(0, 2), ['-50.00dB', '1.00kHz'])
assert.match(spectrum.values[2] ?? '', /^B5 /)
const slaneyOneKhz = 1000 / (200 / 3)
const slaneyMin = 20 / (200 / 3)
const slaneyMax = slaneyOneKhz + Math.log(20) / (Math.log(6.4) / 27)
const oneKhzPositions = new Map<FrequencyScaleMode, number>([
['log', spectrumX],
['mel', (slaneyOneKhz - slaneyMin) / (slaneyMax - slaneyMin)],
['linear', (1000 - 20) / (20000 - 20)],
])
for (const [scaleType, x] of oneKhzPositions) {
const scaleMeasurement = resolveSpectrumMeasurement(
{ x, y: 0.5 },
{
sampleRate: 48000,
minFrequency: 20,
maxFrequency: 20000,
minDecibels: -90,
maxDecibels: -10,
scaleType,
},
)
assert.equal(scaleMeasurement.values[1], '1.00kHz')
}
const nyquistLimited = resolveSpectrumMeasurement(
{ x: 1, y: 0 },
{
@@ -1773,6 +1806,98 @@ test('spectrogram measurement follows scale mode and rendered history speed', ()
},
)
assert.deepEqual(measurement.values.slice(0, 2), ['266.67ms ago', '20.00kHz'])
const expectedHistory = new Map([
[1, '1.28s ago'],
[2, '640.00ms ago'],
[4, '320.00ms ago'],
[8, '160.00ms ago'],
])
for (const [scrollSpeed, expectedTime] of expectedHistory) {
const historyMeasurement = resolveSpectrogramMeasurement(
{ x: 0, y: 1 },
{
sampleRate: 48000,
minFrequency: 20,
maxFrequency: 20000,
scaleMode: 'linear',
fftSize: 4096,
scrollSpeed,
canvasPixelWidth: 121,
},
)
assert.deepEqual(historyMeasurement.values.slice(0, 2), [expectedTime, '20.00Hz'])
}
})
test('frequency scale transforms are monotonic, invertible, and Nyquist-safe', () => {
const minFrequency = 20
const maxFrequency = 20000
const positions = [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1]
for (const scaleMode of ['log', 'mel', 'linear'] as const) {
const frequencies = positions.map((position) => (
frequencyAtNormalizedPosition(position, minFrequency, maxFrequency, scaleMode)
))
assertAlmostEqual(frequencies[0], minFrequency, 1e-12, `${scaleMode} minimum`)
assertAlmostEqual(frequencies.at(-1) ?? 0, maxFrequency, 1e-9, `${scaleMode} maximum`)
for (let index = 1; index < frequencies.length; index += 1) {
assert.ok(frequencies[index] > frequencies[index - 1], `${scaleMode} should be monotonic`)
assertAlmostEqual(
normalizedPositionAtFrequency(frequencies[index], minFrequency, maxFrequency, scaleMode),
positions[index],
1e-12,
`${scaleMode} inverse at ${positions[index]}`,
)
}
}
const slaneyMelAtOneKhz = 1000 / (200 / 3)
const slaneyMelAtTwentyKhz = 15 + Math.log(20) / (Math.log(6.4) / 27)
const expectedOneKhzPosition = (
slaneyMelAtOneKhz - (20 / (200 / 3))
) / (
slaneyMelAtTwentyKhz - (20 / (200 / 3))
)
assertAlmostEqual(
normalizedPositionAtFrequency(1000, 20, 20000, 'mel'),
expectedOneKhzPosition,
1e-12,
'Slaney mel 1kHz anchor',
)
assert.deepEqual(clampFrequencyRangeToNyquist(32000, 20, 20000), {
minFrequency: 20,
maxFrequency: 16000,
})
assert.equal(normalizeFrequencyScaleMode('bark'), 'log')
})
test('frequency ranges and adaptive guides cover extended audio without crowding compact scopes', () => {
assert.deepEqual(frequencyBoundsForRange('extended', 44100), {
minFrequency: 10,
maxFrequency: 22050,
})
assert.deepEqual(frequencyBoundsForRange('extended', 96000), {
minFrequency: 10,
maxFrequency: 24000,
})
assert.deepEqual(frequencyBoundsForRange('audible', 48000), {
minFrequency: 20,
maxFrequency: 20000,
})
const wideGuides = buildFrequencyGuides(10, 24000, 'log', 1500)
assert.ok(wideGuides.some(({ frequencyHz, kind }) => frequencyHz === 30 && kind === 'minor'))
assert.ok(wideGuides.some(({ frequencyHz, kind, label }) => (
frequencyHz === 1000 && kind === 'major' && label === '1k'
)))
const compactGuides = buildFrequencyGuides(10, 24000, 'log', 320)
assert.equal(compactGuides.some(({ kind }) => kind === 'minor'), false)
assert.ok(compactGuides.every((guide, index) => (
index === 0 || guide.normalizedPosition > compactGuides[index - 1].normalizedPosition
)))
})
test('measurement readout flips and clamps at viewport edges', () => {
@@ -1794,10 +1919,15 @@ test('measurement readout flips and clamps at viewport edges', () => {
)
})
test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer options', () => {
test('scopeSettingsToOptions wires frequency ranges and overlays into desktop and plugin options', () => {
const profile = createDefaultProfile('Default')
profile.scopeSettings.spectrum.showSideLine = true
profile.scopeSettings.spectrum.heatmapSmoothing = 0.64
profile.scopeSettings.spectrum.scaleMode = 'mel'
profile.scopeSettings.spectrum.frequencyRangeMode = 'audible'
profile.scopeSettings.spectrogram.frequencyRangeMode = 'extended'
profile.scopeSettings.spectrogram.clarityMode = 'focused'
profile.scopeSettings.spectrogram.showGrid = true
const theme = resolveTheme(createDefaultTheme())
const options = scopeSettingsToOptions('spectrum', profile.scopeSettings.spectrum, theme.spectrum)
@@ -1808,10 +1938,37 @@ test('scopeSettingsToOptions wires spectrum side overlay settings into analyzer
assert.equal(options.lineColor, theme.spectrum.line)
assert.equal(options.backgroundColor, theme.spectrum.background)
assert.equal(options.gridColor, theme.spectrum.guides)
assert.equal(options.scaleType, 'mel')
assert.equal(options.minFrequency, 20)
assert.equal(options.maxFrequency, 20000)
const pluginOptions = spectrumSettingsToOptions(profile.scopeSettings.spectrum, theme.spectrum)
assert.equal(pluginOptions.scaleType, 'mel')
assert.equal(pluginOptions.minFrequency, 20)
assert.equal(pluginOptions.maxFrequency, 20000)
const spectrogramOptions = scopeSettingsToOptions(
'spectrogram',
profile.scopeSettings.spectrogram,
theme.spectrogram,
)
assert.equal(spectrogramOptions.minFrequency, 10)
assert.equal(spectrogramOptions.maxFrequency, 24000)
assert.equal(spectrogramOptions.clarityMode, 'focused')
assert.equal(spectrogramOptions.showGrid, true)
const pluginSpectrogramOptions = spectrogramSettingsToOptions(
profile.scopeSettings.spectrogram,
theme.spectrogram,
)
assert.equal(pluginSpectrogramOptions.minFrequency, 10)
assert.equal(pluginSpectrogramOptions.maxFrequency, 24000)
assert.equal(pluginSpectrogramOptions.clarityMode, 'focused')
assert.equal(pluginSpectrogramOptions.showGrid, true)
})
test('SpectrumAnalyzer grid only draws frequency guides when enabled', () => {
const renderGrid = (showGrid: boolean): FakeCanvasRecorder => {
const renderGrid = (showGrid: boolean, scaleType: FrequencyScaleMode = 'log'): FakeCanvasRecorder => {
const recorder = createFakeCanvasRecorder()
const dom = installFakeCanvasDom(() => createFakeCanvas(recorder))
const dataSource = {
@@ -1823,6 +1980,7 @@ test('SpectrumAnalyzer grid only draws frequency guides when enabled', () => {
}
const analyzer = new SpectrumAnalyzer(createFakeCanvas(), {
showGrid,
scaleType,
dataSource,
nativeAnalyzer: null,
})
@@ -1857,6 +2015,62 @@ test('SpectrumAnalyzer grid only draws frequency guides when enabled', () => {
const disabled = renderGrid(false)
assert.deepEqual(disabled.fillTexts, [])
assert.deepEqual(disabled.lineStrokes, [])
for (const scaleType of ['log', 'mel', 'linear'] as const) {
const recorder = renderGrid(true, scaleType)
const oneKhz = recorder.fillTexts.find(({ text }) => text === '1k')
assert.ok(oneKhz)
assertAlmostEqual(
oneKhz.x,
normalizedPositionAtFrequency(1000, 20, 20000, scaleType) * 320,
1e-9,
`${scaleType} 1kHz grid position`,
)
}
})
test('SpectrumAnalyzer applies frequency scale changes without recreation', () => {
const recorder = createFakeCanvasRecorder()
const dom = installFakeCanvasDom(() => createFakeCanvas(recorder))
const dataSource = {
getPendingSpectrumSamples: () => [],
getPendingSpectrumStereoSamples: () => [],
getSampleRate: () => 48000,
isPlaying: () => false,
subscribeToSessionChanges: () => () => {},
}
const analyzer = new SpectrumAnalyzer(createFakeCanvas(), {
showGrid: true,
scaleType: 'log',
dataSource,
nativeAnalyzer: null,
})
try {
const state = analyzer as unknown as {
ensureStaticLayer: (minFrequency: number, maxFrequency: number) => void
}
state.ensureStaticLayer(20, 20000)
const logPosition = recorder.fillTexts.find(({ text }) => text === '1k')?.x
assert.notEqual(logPosition, undefined)
analyzer.setOptions({ scaleType: 'linear' })
state.ensureStaticLayer(20, 20000)
const oneKhzLabels = recorder.fillTexts.filter(({ text }) => text === '1k')
assert.equal(oneKhzLabels.length, 2)
const linearPosition = oneKhzLabels.at(-1)?.x
assert.notEqual(linearPosition, undefined)
assert.notEqual(linearPosition, logPosition)
assertAlmostEqual(
linearPosition ?? 0,
normalizedPositionAtFrequency(1000, 20, 20000, 'linear') * 320,
1e-9,
'live linear 1kHz grid position',
)
} finally {
analyzer.dispose()
dom.restore()
}
})
test('SpectrumAnalyzer applies and restores transient measurement smoothing without resetting', () => {
@@ -2282,6 +2496,53 @@ test('Spectrogram vertical orientation shifts existing rows upward with copy com
assert.equal(drawCall?.args[2], -1)
})
test('Spectrogram draws adaptive frequency guides in the selected scale', () => {
const renderGrid = (showGrid: boolean, scaleMode: FrequencyScaleMode): FakeCanvasRecorder => {
const recorder = createFakeCanvasRecorder()
const dom = installFakeCanvasDom(() => createFakeCanvas(recorder, 320, 600))
const spectrogram = new Spectrogram(createFakeCanvas(recorder, 320, 600), {
showGrid,
scaleMode,
minFrequency: 20,
maxFrequency: 20000,
dataSource: {
getPendingSpectrogramSamples: () => [],
getPendingSpectrogramStereoSamples: () => [],
getSampleRate: () => 48000,
isPlaying: () => false,
subscribeToSessionChanges: () => () => {},
},
nativeAnalyzer: null,
})
try {
const state = spectrogram as unknown as { drawFrame: () => void }
state.drawFrame()
return recorder
} finally {
spectrogram.dispose()
dom.restore()
}
}
const enabled = renderGrid(true, 'mel')
const oneKhz = enabled.fillTexts.find(({ text }) => text === '1k')
assert.ok(oneKhz)
assertAlmostEqual(
oneKhz.y,
600 - (normalizedPositionAtFrequency(1000, 20, 20000, 'mel') * 600) - 2,
1e-9,
'Mel spectrogram 1kHz grid position',
)
const wideLog = renderGrid(true, 'log')
assert.ok(wideLog.lineStrokes.length > wideLog.fillTexts.length)
const disabled = renderGrid(false, 'log')
assert.deepEqual(disabled.fillTexts, [])
assert.deepEqual(disabled.lineStrokes, [])
})
test('Spectrogram paints multiple native analyzer columns in order', () => {
const recorder = createFakeCanvasRecorder()
const dom = installFakeCanvasDom(() => createFakeCanvas(recorder))
@@ -2329,6 +2590,59 @@ test('Spectrogram paints multiple native analyzer columns in order', () => {
}
})
test('Spectrogram forwards stereo channels without a cancellation-prone mono downmix', () => {
const dom = installFakeCanvasDom()
const canvas = createFakeCanvas(null, 4, 2)
const left = Float32Array.from([0.5, 0, -0.5, 0])
const right = Float32Array.from([-0.5, 0, 0.5, 0])
let pending = [{ left, right }]
let processedLeft: Float32Array | null = null
let processedRight: Float32Array | null = null
const dataSource = {
getPendingSpectrogramSamples: (): Float32Array[] => assert.fail('stereo input must not be downmixed'),
getPendingSpectrogramStereoSamples: () => {
const chunks = pending
pending = []
return chunks
},
getSampleRate: () => 48000,
isPlaying: () => true,
subscribeToSessionChanges: () => () => {},
}
const emptyResult = (): SpectrogramNativeResult => ({
display: new Float32Array(0),
heat: new Float32Array(0),
columnCount: 0,
rowCount: 2,
})
const nativeAnalyzer: SpectrogramNativeAnalyzer = {
isAvailable: () => true,
configure: () => {},
process: () => assert.fail('stereo input must use processStereo'),
processStereo: (nextLeft, nextRight) => {
processedLeft = nextLeft
processedRight = nextRight
return emptyResult()
},
reset: () => {},
}
const spectrogram = new Spectrogram(canvas, {
dataSource,
nativeAnalyzer,
showGrid: false,
})
try {
const state = spectrogram as unknown as { drawFrame: () => void }
state.drawFrame()
assert.equal(processedLeft, left)
assert.equal(processedRight, right)
} finally {
spectrogram.dispose()
dom.restore()
}
})
test('Spectrogram forwards orientation and row count to the native analyzer', () => {
const dom = installFakeCanvasDom()
const canvas = createFakeCanvas(null, 3, 5)
@@ -2378,6 +2692,11 @@ test('Spectrogram forwards orientation and row count to the native analyzer', ()
assert.equal(capturedConfig?.scrollSpeed, 4)
assert.equal(capturedConfig?.tiltDbPerOctave, 5.5)
assert.equal(capturedConfig?.sampleRate, 44100)
spectrogram.setOptions({ scaleMode: 'mel' })
pending = [Float32Array.from([0, 0])]
state.drawFrame()
assert.equal(capturedConfig?.scaleMode, 'mel')
} finally {
spectrogram.dispose()
dom.restore()
@@ -2590,6 +2909,8 @@ test('SpectrumAnalyzer keeps a left-only Mid curve while reporting the left chan
fillGradient: false,
smoothing: 0,
tiltDbPerOctave: 0,
minFrequency: 20,
maxFrequency: 20000,
fftSize,
dataSource: {
getPendingSpectrumSamples: () => assert.fail('peak capture must preserve stereo channel data'),
@@ -3333,27 +3654,35 @@ test('scopeSummary includes loudness readout source', () => {
test('scopeSummary includes spectrum peak mode when enabled', () => {
const profile = createDefaultProfile('Default')
assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048')
assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'LOG · Extended · Fill · FFT 2048')
profile.scopeSettings.spectrum.scaleMode = 'mel'
assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'MEL · Extended · Fill · FFT 2048')
profile.scopeSettings.spectrum.scaleMode = 'log'
profile.scopeSettings.spectrum.peakInfoMode = 'on'
assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · Peak')
assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'LOG · Extended · Fill · FFT 2048 · Peak')
profile.scopeSettings.spectrum.peakInfoMode = 'following'
assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · Peak Follow')
assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'LOG · Extended · Fill · FFT 2048 · Peak Follow')
})
test('scopeSummary includes visual-scope rotation and mirroring', () => {
const profile = createDefaultProfile('Default')
assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '0° · LOG · sharper')
assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '0° · LOG · Extended · sharper')
profile.scopeSettings.spectrogram.clarityMode = 'focused'
assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '0° · LOG · Extended · focused')
profile.scopeSettings.spectrogram.clarityMode = 'sharper'
profile.scopeSettings.spectrogram.rotation = 270
profile.scopeSettings.spectrogram.mirrorHorizontal = true
assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '270° · LOG · sharper · Mirror')
assert.equal(scopeSummary('spectrogram', profile.scopeSettings.spectrogram), '270° · LOG · Extended · sharper · Mirror')
profile.scopeSettings.spectrum.rotation = 90
profile.scopeSettings.spectrum.mirrorHorizontal = true
assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'Fill · FFT 2048 · R90° · Mirror')
assert.equal(scopeSummary('spectrum', profile.scopeSettings.spectrum), 'LOG · Extended · Fill · FFT 2048 · R90° · Mirror')
})
test('scopeSummary summarizes now playing field visibility', () => {
@@ -3387,6 +3716,20 @@ test('ScopePopoutDataSource switches waveform batches between mono and stereo qu
assert.equal(dataSource.getPendingWaveformSamples()[0], nextMonoChunk)
})
test('ScopePopoutDataSource preserves spectrogram stereo batches', () => {
const dataSource = new ScopePopoutDataSource('spectrogram')
const left = new Float32Array([0.5, -0.5])
const right = new Float32Array([-0.5, 0.5])
dataSource.pushAudioBatch([{ left, right }])
assert.equal(dataSource.getPendingSpectrogramSamples().length, 0)
const batch = dataSource.getPendingSpectrogramStereoSamples()
assert.equal(batch.length, 1)
assert.equal(batch[0]?.left, left)
assert.equal(batch[0]?.right, right)
})
test('applying a profile snapshot does not change the machine-local frame target', () => {
const previousPerformanceState = usePerformanceStore.getState()
const previousSettingsState = useSettingsStore.getState()
+384
View File
@@ -0,0 +1,384 @@
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)
}
})