From 27f4b9610af5346959558c39f43698b9b1aee7ea Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:55:07 -0400 Subject: [PATCH] fix incorrectly reported key --- src/renderer/visualizers/SpectrumAnalyzer.ts | 63 ++++++++++++++----- test/renderer-helpers.test.ts | 64 ++++++++++++++++++-- 2 files changed, 107 insertions(+), 20 deletions(-) diff --git a/src/renderer/visualizers/SpectrumAnalyzer.ts b/src/renderer/visualizers/SpectrumAnalyzer.ts index 59da2de..e8c0b4a 100644 --- a/src/renderer/visualizers/SpectrumAnalyzer.ts +++ b/src/renderer/visualizers/SpectrumAnalyzer.ts @@ -60,6 +60,11 @@ type SpectrumPointFillResult = { peakInfo: SpectrumPeakInfo | null } +type SpectrumRangePeak = { + rawDb: number + frequencyHz: number +} + type HeatStop = { at: number; color: [number, number, number] } const LEGACY_DEFAULT_HEAT_COLORS: [string, string, string] = [ @@ -75,7 +80,7 @@ const SPECTRUM_DB_CEILING = 12 const SIDE_LINE_WIDTH_RATIO = 0.75 const PEAK_SELECTION_MAX_DISTANCE_OCTAVES = 0.5 const PEAK_SELECTION_SWITCH_THRESHOLD_DB = 4 -const PEAK_SELECTION_LOW_FREQUENCY_BIAS_DB_PER_OCTAVE = 2.5 +const PEAK_SELECTION_LOW_FREQUENCY_BIAS_DB_PER_OCTAVE = 0.75 const PEAK_SELECTION_UPWARD_SWITCH_THRESHOLD_DB = 2 const NOOP_SPECTRUM_PEAK_INFO_CALLBACK = (_peakInfo: SpectrumPeakInfo | null): void => {} @@ -506,26 +511,53 @@ export class SpectrumAnalyzer { return minFrequency + t * (maxFrequency - minFrequency) } - private getPeakInRange(data: Float32Array, startIndex: number, endIndex: number): number { + private resolvePeakInRange( + data: Float32Array, + startIndex: number, + endIndex: number, + binWidth: number, + ): SpectrumRangePeak { const clampedStart = Math.max(0, Math.min(data.length - 1, startIndex)) const clampedEnd = Math.max(0, Math.min(data.length - 1, endIndex)) const lo = Math.floor(Math.min(clampedStart, clampedEnd)) const hi = Math.ceil(Math.max(clampedStart, clampedEnd)) if (hi <= lo) { - return this.getInterpolatedValue(data, clampedStart) + const rawDb = this.getInterpolatedValue(data, clampedStart) + return { + rawDb, + frequencyHz: Math.max(0, clampedStart * binWidth), + } } - let peak = -Infinity + let peakBin = lo + let peakDb = Number.NEGATIVE_INFINITY for (let i = lo; i <= hi; i += 1) { - peak = Math.max(peak, data[i]) + if (data[i] > peakDb) { + peakDb = data[i] + peakBin = i + } } - return Math.max( - peak, - this.getInterpolatedValue(data, clampedStart), - this.getInterpolatedValue(data, clampedEnd) - ) + if (peakBin > 0 && peakBin < data.length - 1) { + const y1 = data[peakBin - 1] + const y2 = data[peakBin] + const y3 = data[peakBin + 1] + const denominator = y1 - (2 * y2) + y3 + if (Math.abs(denominator) > 1e-9) { + const offset = Math.max(-0.5, Math.min(0.5, 0.5 * (y1 - y3) / denominator)) + const interpolatedDb = y2 - (0.25 * (y1 - y3) * offset) + return { + rawDb: interpolatedDb, + frequencyHz: Math.max(0, (peakBin + offset) * binWidth), + } + } + } + + return { + rawDb: peakDb, + frequencyHz: Math.max(0, peakBin * binWidth), + } } private applyTilt(db: number, frequency: number, tiltDbPerOctave = this.options.tiltDbPerOctave): number { @@ -748,9 +780,12 @@ export class SpectrumAnalyzer { const bin1 = frequency1 / binWidth const centerBin = (bin0 + bin1) * 0.5 const binSpan = Math.abs(bin1 - bin0) + const resolvedPeak = (capturePeakInfo || binSpan > 1) + ? this.resolvePeakInRange(frequencyData, bin0, bin1, binWidth) + : null const rawDb = binSpan <= 1 ? this.getInterpolatedValue(frequencyData, Math.min(centerBin, bufferLength - 1)) - : this.getPeakInRange(frequencyData, bin0, bin1) + : (resolvedPeak?.rawDb ?? this.getInterpolatedValue(frequencyData, Math.min(centerBin, bufferLength - 1))) const db = this.applyTilt(rawDb, centerFrequency, tiltDbPerOctave) const normalized = (db - this.options.minDecibels) / (this.options.maxDecibels - this.options.minDecibels) @@ -764,13 +799,13 @@ export class SpectrumAnalyzer { if (capturePeakInfo) { this.primaryPointDb[index] = db - this.primaryPointFrequency[index] = centerFrequency + this.primaryPointFrequency[index] = resolvedPeak?.frequencyHz ?? centerFrequency } } return { pointCount: numPoints, - peakInfo: capturePeakInfo ? this.selectPeakInfo(numPoints, width, height) : null, + peakInfo: capturePeakInfo ? this.selectPeakInfo(numPoints, height) : null, } } @@ -885,7 +920,7 @@ export class SpectrumAnalyzer { return bestIndex } - private selectPeakInfo(pointCount: number, _width: number, height: number): SpectrumPeakInfo | null { + private selectPeakInfo(pointCount: number, height: number): SpectrumPeakInfo | null { if (pointCount <= 0) { this.lastSelectedPeakInfo = null return null diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 9581842..30015d6 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -1092,33 +1092,39 @@ test('SpectrumAnalyzer reports peak info from the visible spectrum curve', () => try { const state = analyzer as unknown as { drawFrame: () => void - primaryPointX: Float32Array + primaryPointFrequency: Float32Array isLocalPeak: (index: number, pointCount: number) => boolean } state.drawFrame() assert.ok(peakInfo, 'expected peak info to be reported') - assert.ok(peakInfo.frequencyHz > 420 && peakInfo.frequencyHz < 460, `expected peak frequency near 440 Hz, got ${peakInfo.frequencyHz}`) + assert.ok(peakInfo.frequencyHz > 437 && peakInfo.frequencyHz < 443, `expected peak frequency near 440 Hz, got ${peakInfo.frequencyHz}`) assert.match(peakInfo.key, /^A4 [+-]?\d+c$/) assert.ok(peakInfo.db > -20, `expected an audible peak dB, got ${peakInfo.db}`) assert.ok(peakInfo.normalizedX >= 0 && peakInfo.normalizedX <= 1, 'peak x should be normalized') assert.ok(peakInfo.normalizedY >= 0 && peakInfo.normalizedY <= 1, 'peak y should be normalized') - const selectedX = peakInfo.normalizedX * 320 let selectedIndex = 0 let smallestDistance = Number.POSITIVE_INFINITY - for (let index = 0; index < state.primaryPointX.length; index += 1) { - const distance = Math.abs(state.primaryPointX[index] - selectedX) + for (let index = 0; index < state.primaryPointFrequency.length; index += 1) { + if (!state.isLocalPeak(index, state.primaryPointFrequency.length)) { + continue + } + const distance = Math.abs(state.primaryPointFrequency[index] - peakInfo.frequencyHz) if (distance < smallestDistance) { smallestDistance = distance selectedIndex = index } } assert.equal( - state.isLocalPeak(selectedIndex, state.primaryPointX.length), + state.isLocalPeak(selectedIndex, state.primaryPointFrequency.length), true, 'reported peak should stay anchored to a local maximum on the rendered curve', ) + assert.ok( + smallestDistance < 3, + `expected reported peak frequency to stay near a local-peak candidate, got distance ${smallestDistance}`, + ) } finally { analyzer.dispose() dom.restore() @@ -1186,6 +1192,52 @@ test('SpectrumAnalyzer smooths peak selection without smoothing the reported pos } }) +test('SpectrumAnalyzer does not over-bias toward an octave-lower peak', () => { + const dom = installFakeCanvasDom() + const sampleRate = 48000 + const frame = createCompositeStereoChunk([ + { frequencyHz: 174.614, amplitude: 0.8 }, + { frequencyHz: 349.228, amplitude: 1.0 }, + ], sampleRate, 4096) + let peakInfo: SpectrumPeakInfo | null = null + const dataSource = { + getPendingSpectrumSamples: () => [], + getPendingSpectrumStereoSamples: () => [frame], + getSampleRate: () => sampleRate, + isPlaying: () => true, + subscribeToSessionChanges: () => () => {}, + } + + const analyzer = new SpectrumAnalyzer(createFakeCanvas(), { + showSideLine: true, + showGrid: false, + fillGradient: false, + smoothing: 0, + tiltDbPerOctave: 0, + fftSize: 4096, + dataSource, + capturePeakInfo: true, + onPeakInfo: (nextPeakInfo) => { + peakInfo = nextPeakInfo + }, + }) + + try { + const state = analyzer as unknown as { drawFrame: () => void } + state.drawFrame() + + assert.ok(peakInfo, 'expected peak info to be reported') + assert.match(peakInfo.key, /^F4 [+-]?\d+c$/) + assert.ok( + peakInfo.frequencyHz > 347 && peakInfo.frequencyHz < 351, + `expected peak frequency near 349 Hz, got ${peakInfo.frequencyHz}`, + ) + } finally { + analyzer.dispose() + dom.restore() + } +}) + test('astra playback progress advances from updatedAt while playing', () => { const progress = getAstraPlaybackProgress({ playbackState: 'playing',