mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-19 03:54:23 +02:00
make Spectrum and Spectrogram more frequency accurate and detail preserving
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user