feat: improve spectrogram pitch mapping and vertical smoothing

This commit is contained in:
Xiaohan-Tian
2026-07-06 19:34:18 -07:00
parent 08c9cf24a6
commit f4e22e50a4
5 changed files with 329 additions and 45 deletions
+15
View File
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
frequencyToMidiPitch,
getSpectrogramAnalysisResolution,
getSpectrogramPitchBinCount,
getSpectrogramVisibleBinRange,
mapFrequencyToSpectrogramPosition,
mapMidiPitchToSpectrogramPosition,
normalizeSpectrogramHeightResolution,
} from './spectrogramUtil';
@@ -21,6 +24,12 @@ describe('spectrogramUtil', () => {
expect(getSpectrogramPitchBinCount(5)).toBe(640);
});
it('doubles the internal analysis resolution for each user-facing option', () => {
expect(getSpectrogramAnalysisResolution(1)).toBe(2);
expect(getSpectrogramAnalysisResolution(3)).toBe(6);
expect(getSpectrogramAnalysisResolution(5)).toBe(10);
});
it('maps MIDI pitches into full-range spectrogram positions', () => {
expect(mapMidiPitchToSpectrogramPosition(0, 1)).toBe(0);
expect(mapMidiPitchToSpectrogramPosition(12.5, 3)).toBe(38.5);
@@ -29,6 +38,12 @@ describe('spectrogramUtil', () => {
expect(mapMidiPitchToSpectrogramPosition(128, 3)).toBeNull();
});
it('converts frequencies into MIDI pitch space and spectrogram positions', () => {
expect(frequencyToMidiPitch(440)).toBeCloseTo(69, 6);
expect(mapFrequencyToSpectrogramPosition(440, 5)).toBeCloseTo(347, 6);
expect(frequencyToMidiPitch(0)).toBeNull();
});
it('derives the visible C0-B7 subrange inside the full-resolution buffer', () => {
expect(getSpectrogramVisibleBinRange(1)).toEqual({ start: 12, end: 108 });
expect(getSpectrogramVisibleBinRange(3)).toEqual({ start: 36, end: 324 });
+28 -3
View File
@@ -6,6 +6,7 @@ export const SPECTROGRAM_FULL_SEMITONES =
SPECTROGRAM_FULL_MAX_MIDI_PITCH - SPECTROGRAM_FULL_MIN_MIDI_PITCH + 1;
export const SPECTROGRAM_VISIBLE_SEMITONES =
SPECTROGRAM_MAX_MIDI_PITCH - SPECTROGRAM_MIN_MIDI_PITCH + 1;
export const SPECTROGRAM_ANALYSIS_RESOLUTION_MULTIPLIER = 2;
export type SpectrogramHeightResolution = 1 | 3 | 5;
@@ -19,13 +20,13 @@ export function normalizeSpectrogramHeightResolution(value: unknown): Spectrogra
}
export function getSpectrogramPitchBinCount(
resolution: SpectrogramHeightResolution,
resolution: number,
): number {
return SPECTROGRAM_FULL_SEMITONES * resolution;
}
export function getSpectrogramVisibleBinRange(
resolution: SpectrogramHeightResolution,
resolution: number,
): { start: number; end: number } {
return {
start: (SPECTROGRAM_MIN_MIDI_PITCH - SPECTROGRAM_FULL_MIN_MIDI_PITCH) * resolution,
@@ -33,9 +34,15 @@ export function getSpectrogramVisibleBinRange(
};
}
export function getSpectrogramAnalysisResolution(
resolution: SpectrogramHeightResolution,
): number {
return resolution * SPECTROGRAM_ANALYSIS_RESOLUTION_MULTIPLIER;
}
export function mapMidiPitchToSpectrogramPosition(
midiPitch: number,
resolution: SpectrogramHeightResolution,
resolution: number,
): number | null {
const pitchOffset = midiPitch - SPECTROGRAM_FULL_MIN_MIDI_PITCH;
if (pitchOffset < 0 || pitchOffset > SPECTROGRAM_FULL_MAX_MIDI_PITCH) {
@@ -48,3 +55,21 @@ export function mapMidiPitchToSpectrogramPosition(
const maxBin = getSpectrogramPitchBinCount(resolution) - 1;
return Math.max(0, Math.min(maxBin, scaled));
}
export function frequencyToMidiPitch(frequency: number): number | null {
if (!Number.isFinite(frequency) || frequency <= 0) {
return null;
}
return 69 + 12 * Math.log2(frequency / 440);
}
export function mapFrequencyToSpectrogramPosition(
frequency: number,
resolution: number,
): number | null {
const midiPitch = frequencyToMidiPitch(frequency);
if (midiPitch === null) {
return null;
}
return mapMidiPitchToSpectrogramPosition(midiPitch, resolution);
}