feat: added an option to let user choose the vertical resolution of the spectrogram, also fixed a bug that peak of pitch in the spectrogram has been mapped to the start of the note bin instead of the center

This commit is contained in:
Xiaohan-Tian
2026-05-04 20:14:04 -07:00
parent d089456676
commit 8a6e904ab4
12 changed files with 302 additions and 41 deletions
+5 -1
View File
@@ -8,6 +8,7 @@ import type { KeySignature } from '../../core/KGProject';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import SpectrogramCanvas from './SpectrogramCanvas';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
interface PianoGridProps {
gridRef: MutableRefObject<HTMLDivElement | null>;
@@ -32,6 +33,7 @@ interface PianoGridProps {
bpm?: number;
spectrogramThresholdDb?: number;
spectrogramPower?: number;
spectrogramHeightResolution?: SpectrogramHeightResolution;
pianoRollZoom?: number;
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
onSpectrogramLoadingChange?: (loading: boolean) => void;
@@ -62,6 +64,7 @@ const PianoGrid: React.FC<PianoGridProps> = ({
bpm = 120,
spectrogramThresholdDb = -25,
spectrogramPower = 0.5,
spectrogramHeightResolution = 3,
pianoRollZoom = 1,
onSpectrogramLoadingChange,
}) => {
@@ -245,6 +248,7 @@ const PianoGrid: React.FC<PianoGridProps> = ({
thresholdDb={spectrogramThresholdDb}
power={spectrogramPower}
zoom={pianoRollZoom}
heightResolution={spectrogramHeightResolution}
onLoadingChange={onSpectrogramLoadingChange}
/>
)}
@@ -305,4 +309,4 @@ const PianoGrid: React.FC<PianoGridProps> = ({
);
};
export default PianoGrid;
export default PianoGrid;
+42
View File
@@ -18,6 +18,10 @@ import { beatsToBar } from '../../util/midiUtil';
import { UpdateRegionCommand } from '../../core/commands';
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
import { showAlert, showPrompt } from '../../util/dialogUtil';
import {
normalizeSpectrogramHeightResolution,
type SpectrogramHeightResolution,
} from '../../util/spectrogramUtil';
interface PianoRollProps {
onClose: () => void;
@@ -50,6 +54,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Spectrogram controls (only used in spectrogram mode)
const [spectrogramThresholdDb, setSpectrogramThresholdDb] = useState<number>(-25);
const [spectrogramPower, setSpectrogramPower] = useState<number>(0.5);
const [spectrogramHeightResolution, setSpectrogramHeightResolution] =
useState<SpectrogramHeightResolution>(3);
// Piano roll zoom (1x8x); updates --region-grid-beat-width CSS variable
const [pianoRollZoom, setPianoRollZoom] = useState<number>(1);
@@ -201,6 +207,41 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}
}, []); // Empty dependency array means this runs once on mount
useEffect(() => {
let unsubscribe: (() => void) | undefined;
const initializeSpectrogramHeightResolution = async () => {
const configManager = ConfigManager.instance();
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
const applyResolutionFromConfig = () => {
setSpectrogramHeightResolution(
normalizeSpectrogramHeightResolution(
configManager.get('editor.spectrogram_height_resolution')
)
);
};
applyResolutionFromConfig();
unsubscribe = configManager.addChangeListener((changedKeys) => {
if (
changedKeys.includes('__all__') ||
changedKeys.includes('editor.spectrogram_height_resolution')
) {
applyResolutionFromConfig();
}
});
};
void initializeSpectrogramHeightResolution();
return () => {
unsubscribe?.();
};
}, []);
// Add keyboard event listener for Escape
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1048,6 +1089,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
bpm={bpm}
spectrogramThresholdDb={spectrogramThresholdDb}
spectrogramPower={spectrogramPower}
spectrogramHeightResolution={spectrogramHeightResolution}
pianoRollZoom={pianoRollZoom}
/>
@@ -14,6 +14,7 @@ import { useNoteSelection } from '../../hooks/useNoteSelection';
import type { KeySignature } from '../../core/KGProject';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { velocityToColor } from '../../util/velocityColor';
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
interface PianoRollContentProps {
contentRef: React.MutableRefObject<HTMLDivElement | null>;
@@ -35,6 +36,7 @@ interface PianoRollContentProps {
bpm?: number;
spectrogramThresholdDb?: number;
spectrogramPower?: number;
spectrogramHeightResolution?: SpectrogramHeightResolution;
pianoRollZoom?: number;
}
@@ -58,6 +60,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
bpm = 120,
spectrogramThresholdDb = -25,
spectrogramPower = 0.5,
spectrogramHeightResolution = 3,
pianoRollZoom = 1,
}) => {
const isSpectrogram = mode === 'spectrogram';
@@ -295,6 +298,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
bpm={bpm}
spectrogramThresholdDb={spectrogramThresholdDb}
spectrogramPower={spectrogramPower}
spectrogramHeightResolution={spectrogramHeightResolution}
pianoRollZoom={pianoRollZoom}
onSpectrogramLoadingChange={handleSpectrogramLoadingChange}
>
+31 -16
View File
@@ -4,6 +4,12 @@ import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
import type { SpectrogramRequest, SpectrogramResult } from '../../workers/spectrogramWorker';
import {
getSpectrogramVisibleBinRange,
normalizeSpectrogramHeightResolution,
SPECTROGRAM_VISIBLE_SEMITONES,
type SpectrogramHeightResolution,
} from '../../util/spectrogramUtil';
interface SpectrogramCanvasProps {
audioRegion: KGAudioRegion;
@@ -13,12 +19,10 @@ interface SpectrogramCanvasProps {
thresholdDb: number;
power: number;
zoom: number;
heightResolution: SpectrogramHeightResolution;
onLoadingChange?: (loading: boolean) => void;
}
const PITCH_BINS = 128;
const HOP_SIZE = 1024;
// Piecewise-linear RGB colormap: black → dark blue → blue → purple → red → orange → yellow
// Hue rotates 240°→300°→0°→60°, bypassing green entirely.
const COLORMAP_STOPS: Array<[number, [number, number, number]]> = [
@@ -56,6 +60,7 @@ const SpectrogramCanvas: React.FC<SpectrogramCanvasProps> = ({
thresholdDb,
power,
zoom,
heightResolution,
onLoadingChange,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -80,6 +85,7 @@ const SpectrogramCanvas: React.FC<SpectrogramCanvasProps> = ({
regionDurationSeconds: number,
thresholdDb: number,
power: number,
heightResolution: SpectrogramHeightResolution,
) => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -92,31 +98,31 @@ const SpectrogramCanvas: React.FC<SpectrogramCanvasProps> = ({
const totalBeats = (regionDurationSeconds * bpm) / 60;
// Always draw at 1x resolution; zoom is applied as CSS width stretch
const canvasWidth = Math.ceil(totalBeats * 40);
const canvasHeight = PITCH_BINS * noteHeight;
const canvasHeight = SPECTROGRAM_VISIBLE_SEMITONES * noteHeight;
// Convert dB threshold to linear: values below this → black
const linearThreshold = Math.pow(10, thresholdDb / 20);
const visibleRange = getSpectrogramVisibleBinRange(heightResolution);
const visiblePitchBins = visibleRange.end - visibleRange.start;
// 1. Paint at natural STFT resolution onto an offscreen canvas (timeSteps × 128).
// Row i = pitchIndex i = pitch (107 i). Apply threshold then power curve.
// 1. Paint at natural spectrogram resolution onto an offscreen canvas.
// Result data is low-to-high pitch; draw only the visible C0-B7 window, reversed for display.
const offscreen = document.createElement('canvas');
offscreen.width = result.timeSteps;
offscreen.height = PITCH_BINS;
offscreen.height = visiblePitchBins;
const offCtx = offscreen.getContext('2d');
if (!offCtx) return;
const imgData = offCtx.createImageData(result.timeSteps, PITCH_BINS);
const imgData = offCtx.createImageData(result.timeSteps, visiblePitchBins);
const pixels = imgData.data;
for (let row = 0; row < PITCH_BINS; row++) {
const pitch = 107 - row;
for (let row = 0; row < visiblePitchBins; row++) {
const sourceRow = visibleRange.end - 1 - row;
for (let col = 0; col < result.timeSteps; col++) {
const idx = (row * result.timeSteps + col) * 4;
pixels[idx + 3] = 255; // always opaque
if (pitch < 12 || pitch > 107) continue; // outside range → black
const raw = result.data[col * PITCH_BINS + pitch];
const raw = result.data[col * result.pitchBins + sourceRow];
// Hard threshold: values below noise floor → 0 (black)
// Re-scale surviving range to [0,1] then apply power curve
@@ -157,9 +163,10 @@ const SpectrogramCanvas: React.FC<SpectrogramCanvasProps> = ({
regionDurationRef.current,
thresholdDb,
power,
normalizeSpectrogramHeightResolution(heightResolution),
);
}
}, [thresholdDb, power, renderSpectrogram]);
}, [thresholdDb, power, renderSpectrogram, heightResolution]);
// Zoom changes: stretch width only, pin height to canvas pixel height
useEffect(() => {
@@ -211,7 +218,14 @@ const SpectrogramCanvas: React.FC<SpectrogramCanvasProps> = ({
rawResultRef.current = e.data;
sampleRateRef.current = sampleRate;
regionDurationRef.current = regionDurationSeconds;
renderSpectrogram(e.data, sampleRate, regionDurationSeconds, thresholdDb, power);
renderSpectrogram(
e.data,
sampleRate,
regionDurationSeconds,
thresholdDb,
power,
normalizeSpectrogramHeightResolution(heightResolution),
);
setLoading(false);
worker.terminate();
workerRef.current = null;
@@ -223,6 +237,7 @@ const SpectrogramCanvas: React.FC<SpectrogramCanvasProps> = ({
clipStartOffsetSeconds: audioRegion.getClipStartOffsetSeconds(),
regionDurationSeconds,
bpm,
heightResolution: normalizeSpectrogramHeightResolution(heightResolution),
};
worker.postMessage(request, [request.pcm.buffer]);
} catch (err) {
@@ -241,7 +256,7 @@ const SpectrogramCanvas: React.FC<SpectrogramCanvasProps> = ({
workerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioRegion, trackId, projectName, bpm]);
}, [audioRegion, trackId, projectName, bpm, heightResolution]);
return (
<canvas