feat: added waveform view for audio regions

This commit is contained in:
Xiaohan-Tian
2026-05-26 23:23:01 -07:00
parent 7fc5ac1590
commit c4e4470182
22 changed files with 497 additions and 69 deletions
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useRef } from 'react';
import * as Tone from 'tone';
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
import { KGCore } from '../../core/KGCore';
import { beatRangeToSeconds } from '../../util/globalTrackUtil';
interface AudioWaveformCanvasProps {
audioRegion: KGAudioRegion;
trackId: string;
projectName: string;
zoom: number;
}
const LIGHT_ROW_COLOR = '#4a4a4a';
const DARK_ROW_COLOR = '#282828';
const BASE_BEAT_WIDTH = 40;
const AudioWaveformCanvas: React.FC<AudioWaveformCanvasProps> = ({
audioRegion,
trackId,
projectName,
zoom,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const audioBufferRef = useRef<AudioBuffer | null>(null);
const drawWaveform = useCallback((audioBuffer: AudioBuffer) => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const context = canvas.getContext('2d');
if (!context) {
return;
}
const project = KGCore.instance().getCurrentProject();
const regionStartBeat = audioRegion.getStartFromBeat();
const regionEndBeat = regionStartBeat + audioRegion.getLength();
const clipStartOffsetSeconds = audioRegion.getClipStartOffsetSeconds();
const visibleDurationSeconds = Math.min(
beatRangeToSeconds(project, regionStartBeat, regionEndBeat),
Math.max(0, audioRegion.getAudioDurationSeconds() - clipStartOffsetSeconds),
);
const zoomedBeatWidth = BASE_BEAT_WIDTH * zoom;
const renderWidth = Math.max(1, Math.ceil(audioRegion.getLength() * zoomedBeatWidth));
const parentHeight = canvas.parentElement?.clientHeight ?? 0;
const canvasHeight = Math.max(160, parentHeight || canvas.clientHeight || 320);
const centerY = canvasHeight / 2;
const amplitudeScale = canvasHeight * 0.42;
canvas.width = renderWidth;
canvas.height = canvasHeight;
canvas.style.width = `${renderWidth}px`;
canvas.style.height = `${canvasHeight}px`;
context.clearRect(0, 0, renderWidth, canvasHeight);
context.fillStyle = DARK_ROW_COLOR;
context.fillRect(0, 0, renderWidth, canvasHeight);
const channelData = audioBuffer.getChannelData(0);
const totalSamples = channelData.length;
const sampleRate = audioBuffer.sampleRate;
const renderStartSample = Math.max(0, Math.min(totalSamples, Math.floor(clipStartOffsetSeconds * sampleRate)));
const renderEndSample = Math.max(
renderStartSample,
Math.min(totalSamples, renderStartSample + Math.floor(visibleDurationSeconds * sampleRate)),
);
const renderSampleCount = renderEndSample - renderStartSample;
if (renderSampleCount <= 0) {
return;
}
const samplesPerPixel = Math.max(1, Math.ceil(renderSampleCount / renderWidth));
for (let x = 0; x < renderWidth; x++) {
const startSample = renderStartSample + (x * samplesPerPixel);
const endSample = Math.min(renderEndSample, startSample + samplesPerPixel);
let min = 1;
let max = -1;
for (let sampleIndex = startSample; sampleIndex < endSample; sampleIndex++) {
const sample = channelData[sampleIndex];
if (sample < min) min = sample;
if (sample > max) max = sample;
}
const topHeight = Math.max(1, Math.abs(max) * amplitudeScale);
const bottomHeight = Math.max(1, Math.abs(min) * amplitudeScale);
context.fillStyle = LIGHT_ROW_COLOR;
context.fillRect(x, centerY - topHeight, 1, topHeight);
context.fillRect(x, centerY, 1, bottomHeight);
}
}, [audioRegion]);
useEffect(() => {
let cancelled = false;
const loadAndDraw = async () => {
try {
let audioBuffer = KGAudioInterface.instance().getAudioBuffer(trackId, audioRegion.getAudioFileId());
if (!audioBuffer) {
const arrayBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioRegion.getAudioFileId());
if (cancelled) {
return;
}
const audioContext = Tone.getContext().rawContext as AudioContext;
audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
}
if (cancelled || !audioBuffer) {
return;
}
audioBufferRef.current = audioBuffer;
drawWaveform(audioBuffer);
} catch (error) {
if (!cancelled) {
console.error('AudioWaveformCanvas: failed to render waveform', error);
}
}
};
void loadAndDraw();
return () => {
cancelled = true;
};
}, [audioRegion, drawWaveform, projectName, trackId]);
useEffect(() => {
const parent = canvasRef.current?.parentElement;
if (!parent) {
return;
}
const resizeObserver = new ResizeObserver(() => {
if (audioBufferRef.current) {
drawWaveform(audioBufferRef.current);
}
});
resizeObserver.observe(parent);
return () => resizeObserver.disconnect();
}, [drawWaveform]);
return (
<canvas
ref={canvasRef}
data-testid="audio-waveform-canvas"
style={{
position: 'absolute',
top: 0,
left: `${audioRegion.getStartFromBeat() * BASE_BEAT_WIDTH * zoom}px`,
zIndex: 0,
pointerEvents: 'none',
}}
/>
);
};
export default AudioWaveformCanvas;
+13 -2
View File
@@ -7,6 +7,7 @@ import { generatePianoGridBackground, getMatchingChordsForPitch } from '../../ut
import type { KeySignature } from '../../core/KGProject';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import SpectrogramCanvas from './SpectrogramCanvas';
import AudioWaveformCanvas from './AudioWaveformCanvas';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
@@ -35,7 +36,7 @@ interface PianoGridProps {
spectrogramPower?: number;
spectrogramHeightResolution?: SpectrogramHeightResolution;
pianoRollZoom?: number;
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
onSpectrogramLoadingChange?: (loading: boolean) => void;
}
@@ -66,6 +67,7 @@ const PianoGrid: React.FC<PianoGridProps> = ({
spectrogramPower = 0.5,
spectrogramHeightResolution = 3,
pianoRollZoom = 1,
mode = 'midi-edit',
onSpectrogramLoadingChange,
}) => {
const [cursorPosition, setCursorPosition] = useState<CursorPosition | null>(null);
@@ -239,7 +241,16 @@ const PianoGrid: React.FC<PianoGridProps> = ({
onMouseLeave={handleMouseLeave}
>
{/* Spectrogram layer — rendered at z-index 0, behind all highlights and notes */}
{audioRegion && trackId && projectName && (
{audioRegion && mode === 'audio-waveform' && trackId && projectName && (
<AudioWaveformCanvas
audioRegion={audioRegion}
trackId={trackId}
projectName={projectName}
zoom={pianoRollZoom}
/>
)}
{audioRegion && (mode === 'spectrogram' || mode === 'hybrid') && trackId && projectName && (
<SpectrogramCanvas
audioRegion={audioRegion}
trackId={trackId}
@@ -8,11 +8,13 @@ import { getSnappedBeatPosition } from './pianoRollSnap';
interface PianoGridHeaderProps {
maxBars: number;
timeSignature?: { numerator: number; denominator: number };
hasPianoKeys?: boolean;
}
const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
maxBars,
timeSignature = { numerator: 4, denominator: 4 } // Default to 4/4 if not provided
timeSignature = { numerator: 4, denominator: 4 }, // Default to 4/4 if not provided
hasPianoKeys = true,
}) => {
// Get store access for playhead position updates
const { setPlayheadPosition, requestMainContentScroll } = useProjectStore();
@@ -29,9 +31,11 @@ const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
const relativeX = clientX - rect.left;
// Account for the piano keys width offset
const pianoKeysWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60;
const pianoKeysWidth = hasPianoKeys
? (parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60)
: 0;
const adjustedX = relativeX - pianoKeysWidth;
@@ -138,7 +142,7 @@ const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
return (
<div
className="piano-grid-header"
className={`piano-grid-header${hasPianoKeys ? '' : ' no-piano-keys'}`}
ref={headerElementRef}
onMouseDown={handleMouseDown}
onClick={handlePianoGridHeaderClick}
+27
View File
@@ -181,6 +181,13 @@
line-height: 1;
}
.piano-roll-toolbar .spectrogram-view-icon {
width: 12px;
height: 12px;
overflow: visible;
flex-shrink: 0;
}
.piano-roll-toolbar .tool-button:hover {
--toolbar-button-bg: #3a3a3a;
--toolbar-button-fg: #e0e0e0;
@@ -467,6 +474,11 @@
cursor: url("data:image/svg+xml,%3csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M4 6h8l-4 4-4-4z' fill='%23e0e0e0'/%3e%3c/svg%3e") 8 8, pointer;
}
.piano-grid-header.no-piano-keys {
width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width));
padding-left: 0;
}
.piano-bar-number {
border-left: 1px solid #3a3a3a;
padding-left: 10px;
@@ -484,6 +496,11 @@
width: max-content;
}
.piano-roll-body.audio-waveform-body {
min-height: 0;
height: 100%;
}
.sheet-music-view {
position: relative;
flex: 1;
@@ -616,6 +633,16 @@
z-index: 5;
}
.piano-roll-body.audio-waveform-body .piano-grid-container {
min-height: 0;
height: 100%;
}
.piano-roll-body.audio-waveform-body .piano-grid {
min-height: 0;
height: 100%;
}
.piano-octave {
display: flex;
flex-direction: column;
+37 -12
View File
@@ -67,7 +67,7 @@ interface PianoRollProps {
regionId: string | null;
initialPosition?: { x: number; y: number };
initialSize?: { width: number; height: number };
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
requestedSheetMusicViewEnabled?: boolean;
pianoRollViewRequestVersion?: number;
audioRegion?: KGAudioRegion;
@@ -87,8 +87,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
trackId,
projectName,
}) => {
const isSpectrogram = mode === 'spectrogram';
const isHybrid = mode === 'hybrid';
const [currentMode, setCurrentMode] = useState<'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid'>(mode);
const isSpectrogram = currentMode === 'spectrogram';
const isAudioWaveform = currentMode === 'audio-waveform';
const isAudioOnly = isAudioWaveform || isSpectrogram;
const isHybrid = currentMode === 'hybrid';
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion, refreshProjectState, setBpm } = useProjectStore();
// Tool state for piano roll
@@ -136,6 +139,10 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [activeRegion, setActiveRegion] = useState<KGMidiRegion | null>(null);
useEffect(() => {
setCurrentMode(mode);
}, [mode]);
const selectedNotes = useMemo(
() => activeRegion?.getNotes().filter(n => selectedNoteIds.includes(n.getId())) ?? [],
[activeRegion, selectedNoteIds]
@@ -285,7 +292,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}, []); // Empty dependency array means this runs once on mount
useEffect(() => {
if (isSpectrogram) {
if (isAudioOnly) {
return;
}
@@ -312,7 +319,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
KGPianoRollState.instance().setSheetMusicViewEnabled(requestedSheetMusicViewEnabled);
}, [
activeRegion,
isSpectrogram,
isAudioOnly,
pianoRollViewRequestVersion,
playheadPosition,
requestedSheetMusicViewEnabled,
@@ -508,7 +515,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
let detectedChords: DetectedAudioChord[] | DetectedMidiChord[];
if (audioRegion) {
if (!projectName || !trackId) {
await showAlert('Open an audio region in spectrogram mode before detecting chords.');
await showAlert('Open an audio region in spectrogram mode before detecting chords.');
return;
}
@@ -1081,6 +1088,14 @@ const PianoRoll: React.FC<PianoRollProps> = ({
});
}, [activeRegion, playheadPosition, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled]);
const handleAudioSpectrogramToggle = useCallback(() => {
if (isHybrid || !audioRegion) {
return;
}
setCurrentMode(current => current === 'spectrogram' ? 'audio-waveform' : 'spectrogram');
}, [audioRegion, isHybrid]);
const handleSheetMeasureMetricsChange = useCallback((metrics: SheetMeasureMetric[]) => {
setSheetMeasureMetrics((current) => {
if (
@@ -1106,6 +1121,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
return;
}
if (isAudioWaveform) {
container.scrollTop = 0;
return;
}
const keyHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
const c4Position = 4 * 12 * keyHeight;
const totalHeight = 8 * 12 * keyHeight;
@@ -1113,7 +1133,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const scrollPosition = (totalHeight - c4Position) - (viewportHeight / 2);
container.scrollTop = Math.max(0, scrollPosition);
}, []);
}, [isAudioWaveform]);
// Calculate C4 position and scroll to it when piano roll opens
useEffect(() => {
@@ -1470,7 +1490,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Get the title for the piano roll based on the active region
const getPianoRollTitle = () => {
if (isSpectrogram) return audioRegion ? `SPECTROGRAM — ${audioRegion.getName()}` : 'SPECTROGRAM';
if (currentMode === 'spectrogram') return audioRegion ? `SPECTROGRAM — ${audioRegion.getName()}` : 'SPECTROGRAM';
if (currentMode === 'audio-waveform') return audioRegion ? `WAVEFORM — ${audioRegion.getName()}` : 'WAVEFORM';
if (isHybrid) {
const midiName = activeRegion?.getName() ?? 'MIDI';
const audioName = audioRegion?.getName() ?? 'Audio';
@@ -1523,6 +1544,10 @@ const PianoRoll: React.FC<PianoRollProps> = ({
sheetQuantization={sheetQuantization}
onSheetQuantizationChange={handleSheetQuantizationChange}
sheetQuantizationOptions={getSheetQuantizationOptions()}
showAudioSpectrogramToggle={!!audioRegion && !isHybrid}
audioSpectrogramEnabled={currentMode === 'spectrogram'}
onAudioSpectrogramToggle={handleAudioSpectrogramToggle}
sheetMusicToggleDisabled={!activeRegion}
activeTool={activeTool}
onToolSelect={handleToolSelect}
quantPosition={quantPosition}
@@ -1535,14 +1560,14 @@ const PianoRoll: React.FC<PianoRollProps> = ({
chordGuide={chordGuide}
onChordGuideChange={handleChordGuideSelect}
blinkButton={blinkButton}
mode={mode}
mode={currentMode}
thresholdDb={spectrogramThresholdDb}
onThresholdChange={setSpectrogramThresholdDb}
power={spectrogramPower}
onPowerChange={setSpectrogramPower}
zoom={pianoRollZoom}
onZoomChange={handleZoomChange}
showAutomationControls={!isSpectrogram}
showAutomationControls={!isAudioOnly}
automationEnabled={automationEnabled}
automationType={automationType}
onAutomationToggle={handleAutomationToggle}
@@ -1553,7 +1578,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
detectingTempo={isDetectingTempo}
/>
<NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isSpectrogram} activeRegion={activeRegion} />
<NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isAudioOnly} activeRegion={activeRegion} />
<PianoRollContent
contentRef={pianoRollContentRef}
@@ -1569,7 +1594,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
selectedMode={selectedMode}
keySignature={keySignature}
chordGuide={chordGuide}
mode={mode}
mode={currentMode}
audioRegion={audioRegion}
trackId={trackId}
projectName={projectName}
@@ -46,7 +46,13 @@ vi.mock('../../hooks/useNoteSelection', () => ({
vi.mock('./PianoGridHeader', () => ({ default: () => <div data-testid="piano-grid-header" /> }));
vi.mock('./PianoKeys', () => ({ default: () => <div data-testid="piano-keys" /> }));
vi.mock('./PianoGrid', () => ({ default: ({ children }: { children?: React.ReactNode }) => <div data-testid="piano-grid">{children}</div> }));
const pianoGridSpy = vi.fn();
vi.mock('./PianoGrid', () => ({
default: (props: { children?: React.ReactNode; mode?: string }) => {
pianoGridSpy(props);
return <div data-testid="piano-grid">{props.children}</div>;
},
}));
vi.mock('./PianoNote', () => ({ default: () => <div data-testid="piano-note" /> }));
vi.mock('./PianoRollAutomationLane', () => ({ default: () => <div data-testid="automation-lane" /> }));
const sheetMusicViewSpy = vi.fn();
@@ -76,6 +82,7 @@ describe('PianoRollContent', () => {
beforeEach(() => {
sheetMusicViewSpy.mockClear();
pianoGridSpy.mockClear();
});
it('keeps the single-pane layout when automation is disabled', () => {
@@ -120,6 +127,21 @@ describe('PianoRollContent', () => {
expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument();
});
it('suppresses the automation lane in waveform mode and forwards the explicit mode', () => {
render(
<PianoRollContent
{...baseProps}
mode="audio-waveform"
automationEnabled={true}
automationType="cc-7"
/>
);
expect(screen.getByTestId('piano-roll-content-single')).toBeInTheDocument();
expect(screen.queryByTestId('automation-lane')).not.toBeInTheDocument();
expect(pianoGridSpy).toHaveBeenCalledWith(expect.objectContaining({ mode: 'audio-waveform' }));
});
it('shows an overlay message when one is supplied by the parent panel', () => {
render(
<PianoRollContent
+19 -13
View File
@@ -37,7 +37,7 @@ interface PianoRollContentProps {
selectedMode: string;
keySignature: KeySignature;
chordGuide: string;
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
audioRegion?: KGAudioRegion;
trackId?: string;
projectName?: string;
@@ -94,8 +94,9 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
overlayMessage = null,
overlayProgressPercent = null,
}) => {
const isAudioView = mode === 'audio-waveform' || mode === 'spectrogram';
const isSpectrogram = mode === 'spectrogram';
const showAutomationLane = automationEnabled && !isSpectrogram && !sheetMusicViewEnabled;
const showAutomationLane = automationEnabled && !isAudioView && !sheetMusicViewEnabled;
const [spectrogramLoading, setSpectrogramLoading] = useState(false);
const [noteScrollLeft, setNoteScrollLeft] = useState(0);
const handleSpectrogramLoadingChange = useCallback((loading: boolean) => {
@@ -174,7 +175,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
// Combined click handler for both pointer and pencil modes
const handleCombinedClick = (e: React.MouseEvent) => {
if (isSpectrogram) return;
if (isAudioView) return;
handleBackgroundClick(e);
handleGridClick(e);
};
@@ -212,7 +213,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
// Memoize the notes rendering to prevent unnecessary recalculations
const memoizedNotes = useMemo(() => {
if (isSpectrogram || sheetMusicViewEnabled || !activeRegion) return null;
if (isAudioView || sheetMusicViewEnabled || !activeRegion) return null;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Rendering notes for region: ${activeRegion.getId()}`);
@@ -289,7 +290,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
/>
);
});
}, [mode, activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks, sheetMusicViewEnabled]);
}, [isAudioView, activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks, sheetMusicViewEnabled]);
const recordingNoteOverlays = useMemo(() => {
if (!isRecording || !activeRegion || recordingNotes.length === 0) return null;
@@ -341,10 +342,14 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
onScroll={(event) => setNoteScrollLeft(event.currentTarget.scrollLeft)}
>
{!sheetMusicViewEnabled && (
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} />
<PianoGridHeader
maxBars={maxBars}
timeSignature={timeSignature}
hasPianoKeys={mode !== 'audio-waveform'}
/>
)}
<div className={`piano-roll-body ${sheetMusicViewEnabled ? 'sheet-music-body' : ''}`}>
{!sheetMusicViewEnabled && <PianoKeys activeRegion={activeRegion} />}
<div className={`piano-roll-body ${sheetMusicViewEnabled ? 'sheet-music-body' : ''} ${mode === 'audio-waveform' ? 'audio-waveform-body' : ''}`}>
{!sheetMusicViewEnabled && mode !== 'audio-waveform' && <PianoKeys activeRegion={activeRegion} />}
{sheetMusicViewEnabled && activeRegion && sheetQuantization ? (
<SheetMusicView
activeRegion={activeRegion}
@@ -363,10 +368,10 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
<PianoGrid
gridRef={pianoGridRef}
onDoubleClick={isSpectrogram ? () => {} : handleGridDoubleClick}
onClick={isSpectrogram ? () => {} : handleCombinedClick}
onMouseDown={isSpectrogram ? () => {} : handleBackgroundMouseDown}
isBoxSelecting={isSpectrogram ? false : isBoxSelectingRef.current}
selectionBox={isSpectrogram ? { startX: 0, startY: 0, endX: 0, endY: 0 } : selectionBoxRef.current}
onClick={isAudioView ? () => {} : handleCombinedClick}
onMouseDown={isAudioView ? () => {} : handleBackgroundMouseDown}
isBoxSelecting={isAudioView ? false : isBoxSelectingRef.current}
selectionBox={isAudioView ? { startX: 0, startY: 0, endX: 0, endY: 0 } : selectionBoxRef.current}
regionStartBeat={activeRegion?.getStartFromBeat() || 0}
selectedMode={selectedMode}
keySignature={keySignature}
@@ -379,10 +384,11 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
spectrogramPower={spectrogramPower}
spectrogramHeightResolution={spectrogramHeightResolution}
pianoRollZoom={pianoRollZoom}
mode={mode}
onSpectrogramLoadingChange={handleSpectrogramLoadingChange}
>
{memoizedNotes}
{!isSpectrogram && recordingNoteOverlays}
{!isAudioView && recordingNoteOverlays}
</PianoGrid>
)}
</div>
@@ -119,6 +119,37 @@ describe('PianoRollToolbar', () => {
expect(screen.queryByRole('button', { name: /Pitch Bend/i })).not.toBeInTheDocument();
});
it('shows the spectrogram toggle for pure audio waveform mode and toggles it', () => {
const onAudioSpectrogramToggle = vi.fn();
render(
<PianoRollToolbar
{...baseProps}
mode="audio-waveform"
showAudioSpectrogramToggle={true}
audioSpectrogramEnabled={false}
onAudioSpectrogramToggle={onAudioSpectrogramToggle}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Spectrogram View' }));
expect(onAudioSpectrogramToggle).toHaveBeenCalledTimes(1);
expect(screen.getByRole('button', { name: 'Spectrogram View' }).className).not.toContain('active');
});
it('hides the spectrogram toggle in hybrid mode', () => {
render(
<PianoRollToolbar
{...baseProps}
mode="hybrid"
showAudioSpectrogramToggle={false}
/>
);
expect(screen.queryByRole('button', { name: 'Spectrogram View' })).not.toBeInTheDocument();
});
it('shows the detect chords action in spectrogram mode and triggers it', () => {
const onDetectChords = vi.fn();
+55 -18
View File
@@ -17,6 +17,10 @@ const POWER_OPTIONS = [
];
interface PianoRollToolbarProps {
showAudioSpectrogramToggle?: boolean;
audioSpectrogramEnabled?: boolean;
onAudioSpectrogramToggle?: () => void;
sheetMusicToggleDisabled?: boolean;
sheetMusicViewEnabled?: boolean;
onSheetMusicViewToggle?: () => void;
sheetMusicTrackScopeEnabled?: boolean;
@@ -36,7 +40,7 @@ interface PianoRollToolbarProps {
chordGuide: string;
onChordGuideChange: (value: string) => void;
blinkButton?: string | null;
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
thresholdDb?: number;
onThresholdChange?: (db: number) => void;
power?: number;
@@ -55,6 +59,10 @@ interface PianoRollToolbarProps {
}
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
showAudioSpectrogramToggle = false,
audioSpectrogramEnabled = false,
onAudioSpectrogramToggle,
sheetMusicToggleDisabled = false,
sheetMusicViewEnabled = false,
onSheetMusicViewToggle,
sheetMusicTrackScopeEnabled = false,
@@ -91,7 +99,9 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
onDetectTempo,
detectingTempo = false,
}) => {
const showMidiControls = mode !== 'spectrogram' && !sheetMusicViewEnabled; // midi-edit and hybrid
const showMidiControls = mode !== 'spectrogram' && mode !== 'audio-waveform' && !sheetMusicViewEnabled;
const showAudioOnlyControls = mode === 'audio-waveform' && !sheetMusicViewEnabled;
const showSpectrogramOnlyControls = mode === 'spectrogram' && !sheetMusicViewEnabled;
const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid');
const showSpecMenu = !sheetMusicViewEnabled && (!!onDetectChords || !!onDetectTempo);
@@ -123,18 +133,39 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showMoreMenu]);
const spectrogramToggleButton = showAudioSpectrogramToggle ? (
<button
className={`tool-button icon-only sheet-mode-toggle ${audioSpectrogramEnabled ? 'active' : ''}`}
onClick={() => onAudioSpectrogramToggle?.()}
title="Spectrogram View"
aria-label="Spectrogram View"
>
<svg className="spectrogram-view-icon" width="12" height="12" viewBox="0 0 10 10" fill="currentColor">
<rect x="3" y="0.5" width="6.5" height="2.5" rx="0.4" />
<rect x="1.5" y="3.75" width="6.5" height="2.5" rx="0.4" />
<rect x="0" y="7" width="6.5" height="2.5" rx="0.4" />
</svg>
</button>
) : null;
const sheetMusicToggleButton = (
<button
className={`tool-button sheet-mode-toggle ${sheetMusicViewEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicViewToggle?.()}
title="Sheet Music View"
aria-label="Sheet Music View"
disabled={sheetMusicToggleDisabled}
>
</button>
);
return (
<div className="piano-roll-toolbar">
{showMidiControls && (
<div className="toolbar-left">
<button
className={`tool-button sheet-mode-toggle ${sheetMusicViewEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicViewToggle?.()}
title="Sheet Music View"
aria-label="Sheet Music View"
>
</button>
{spectrogramToggleButton}
{sheetMusicToggleButton}
<button
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
onClick={() => onToolSelect('pointer')}
@@ -193,16 +224,22 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
</div>
)}
{showAudioOnlyControls && (
<div className="toolbar-left">
{spectrogramToggleButton}
</div>
)}
{showSpectrogramOnlyControls && (
<div className="toolbar-left">
{spectrogramToggleButton}
</div>
)}
{sheetMusicViewEnabled && (
<div className="toolbar-left">
<button
className={`tool-button sheet-mode-toggle ${sheetMusicViewEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicViewToggle?.()}
title="Sheet Music View"
aria-label="Sheet Music View"
>
</button>
{spectrogramToggleButton}
{sheetMusicToggleButton}
{mode !== 'spectrogram' && (
<button
className={`tool-button icon-only sheet-track-scope-toggle ${sheetMusicTrackScopeEnabled ? 'active' : ''}`}