diff --git a/src/components/MainContent.test.tsx b/src/components/MainContent.test.tsx index 7ada95d..dd038f4 100644 --- a/src/components/MainContent.test.tsx +++ b/src/components/MainContent.test.tsx @@ -50,6 +50,7 @@ const storeState = { requestedSheetMusicViewEnabled: false, pianoRollViewRequestVersion: 0, openMidiPianoRoll: vi.fn(), + openAudioWaveformViewer: vi.fn(), openSpectrogramViewer: vi.fn(), openHybridMode: vi.fn(), hybridAudioRegionId: null as string | null, @@ -166,6 +167,7 @@ describe('MainContent', () => { storeState.setShowPianoRoll.mockClear(); storeState.setActiveRegionId.mockClear(); storeState.openMidiPianoRoll.mockClear(); + storeState.openAudioWaveformViewer.mockClear(); storeState.openSpectrogramViewer.mockClear(); storeState.addTrack.mockClear(); storeState.addAudioTrack.mockClear(); @@ -220,7 +222,8 @@ describe('MainContent', () => { fireEvent.click(screen.getByRole('button', { name: 'select-audio-region' })); expect(storeState.activeRegionId).toBe('audio-1'); - expect(storeState.openSpectrogramViewer).toHaveBeenCalledWith('audio-1'); + expect(storeState.openAudioWaveformViewer).toHaveBeenCalledWith('audio-1'); + expect(storeState.openSpectrogramViewer).not.toHaveBeenCalled(); }); it('cmd-click adds a second regular region without range fill', () => { diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 09e544d..f1855b8 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -58,6 +58,7 @@ const MainContent: React.FC = ({ requestedSheetMusicViewEnabled, pianoRollViewRequestVersion, openMidiPianoRoll, + openAudioWaveformViewer, openSpectrogramViewer, openHybridMode, hybridAudioRegionId, @@ -95,6 +96,7 @@ const MainContent: React.FC = ({ setShowPianoRoll, setActiveRegionId, openMidiPianoRoll, + openAudioWaveformViewer, openSpectrogramViewer, openHybridMode, pianoRollMode, @@ -321,7 +323,7 @@ const MainContent: React.FC = ({ }, [globalTracks, mainContentRegions, refreshProjectState, selectedRegionIds, timeSignature.numerator, tracks]); const showHybridButtonForAudio = showPianoRoll && pianoRollMode === 'midi-edit'; - const showHybridButtonForMidi = showPianoRoll && pianoRollMode === 'spectrogram'; + const showHybridButtonForMidi = showPianoRoll && (pianoRollMode === 'audio-waveform' || pianoRollMode === 'spectrogram'); const beatTicksPerBar = Math.max(0, timeSignature.numerator - 1); return ( @@ -412,6 +414,7 @@ const MainContent: React.FC = ({ onRegionLassoSelection={mainContentRegions.handleRegionLassoSelection} onRegionLassoCommit={mainContentRegions.handleRegionLassoCommit} onOpenPianoRoll={mainContentRegions.handleOpenPianoRoll} + onOpenWaveform={mainContentRegions.handleOpenWaveform} onOpenSpectrogram={mainContentRegions.handleOpenSpectrogram} showHybridButtonForAudio={showHybridButtonForAudio} showHybridButtonForMidi={showHybridButtonForMidi} @@ -441,7 +444,7 @@ const MainContent: React.FC = ({ requestedSheetMusicViewEnabled={requestedSheetMusicViewEnabled} pianoRollViewRequestVersion={pianoRollViewRequestVersion} audioRegion={(() => { - const audioId = pianoRollMode === 'spectrogram' + const audioId = pianoRollMode === 'audio-waveform' || pianoRollMode === 'spectrogram' ? activeRegionId : pianoRollMode === 'hybrid' ? hybridAudioRegionId @@ -459,7 +462,7 @@ const MainContent: React.FC = ({ return undefined; })()} trackId={(() => { - const audioId = pianoRollMode === 'spectrogram' + const audioId = pianoRollMode === 'audio-waveform' || pianoRollMode === 'spectrogram' ? activeRegionId : pianoRollMode === 'hybrid' ? hybridAudioRegionId diff --git a/src/components/piano-roll/AudioWaveformCanvas.tsx b/src/components/piano-roll/AudioWaveformCanvas.tsx new file mode 100644 index 0000000..5018727 --- /dev/null +++ b/src/components/piano-roll/AudioWaveformCanvas.tsx @@ -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 = ({ + audioRegion, + trackId, + projectName, + zoom, +}) => { + const canvasRef = useRef(null); + const audioBufferRef = useRef(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 ( + + ); +}; + +export default AudioWaveformCanvas; diff --git a/src/components/piano-roll/PianoGrid.tsx b/src/components/piano-roll/PianoGrid.tsx index bd6932e..492b60e 100644 --- a/src/components/piano-roll/PianoGrid.tsx +++ b/src/components/piano-roll/PianoGrid.tsx @@ -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 = ({ spectrogramPower = 0.5, spectrogramHeightResolution = 3, pianoRollZoom = 1, + mode = 'midi-edit', onSpectrogramLoadingChange, }) => { const [cursorPosition, setCursorPosition] = useState(null); @@ -239,7 +241,16 @@ const PianoGrid: React.FC = ({ onMouseLeave={handleMouseLeave} > {/* Spectrogram layer — rendered at z-index 0, behind all highlights and notes */} - {audioRegion && trackId && projectName && ( + {audioRegion && mode === 'audio-waveform' && trackId && projectName && ( + + )} + + {audioRegion && (mode === 'spectrogram' || mode === 'hybrid') && trackId && projectName && ( = ({ 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 = ({ 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 = ({ return (
= ({ 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 = ({ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [activeRegion, setActiveRegion] = useState(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 = ({ }, []); // Empty dependency array means this runs once on mount useEffect(() => { - if (isSpectrogram) { + if (isAudioOnly) { return; } @@ -312,7 +319,7 @@ const PianoRoll: React.FC = ({ KGPianoRollState.instance().setSheetMusicViewEnabled(requestedSheetMusicViewEnabled); }, [ activeRegion, - isSpectrogram, + isAudioOnly, pianoRollViewRequestVersion, playheadPosition, requestedSheetMusicViewEnabled, @@ -508,7 +515,7 @@ const PianoRoll: React.FC = ({ 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 = ({ }); }, [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 = ({ 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 = ({ 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 = ({ // 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 = ({ 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 = ({ 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 = ({ detectingTempo={isDetectingTempo} /> - + = ({ selectedMode={selectedMode} keySignature={keySignature} chordGuide={chordGuide} - mode={mode} + mode={currentMode} audioRegion={audioRegion} trackId={trackId} projectName={projectName} diff --git a/src/components/piano-roll/PianoRollContent.test.tsx b/src/components/piano-roll/PianoRollContent.test.tsx index 0e4173e..c079365 100644 --- a/src/components/piano-roll/PianoRollContent.test.tsx +++ b/src/components/piano-roll/PianoRollContent.test.tsx @@ -46,7 +46,13 @@ vi.mock('../../hooks/useNoteSelection', () => ({ vi.mock('./PianoGridHeader', () => ({ default: () =>
})); vi.mock('./PianoKeys', () => ({ default: () =>
})); -vi.mock('./PianoGrid', () => ({ default: ({ children }: { children?: React.ReactNode }) =>
{children}
})); +const pianoGridSpy = vi.fn(); +vi.mock('./PianoGrid', () => ({ + default: (props: { children?: React.ReactNode; mode?: string }) => { + pianoGridSpy(props); + return
{props.children}
; + }, +})); vi.mock('./PianoNote', () => ({ default: () =>
})); vi.mock('./PianoRollAutomationLane', () => ({ default: () =>
})); 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( + + ); + + 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( = ({ 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 = ({ // 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 = ({ // 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 = ({ /> ); }); - }, [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 = ({ onScroll={(event) => setNoteScrollLeft(event.currentTarget.scrollLeft)} > {!sheetMusicViewEnabled && ( - + )} -
- {!sheetMusicViewEnabled && } +
+ {!sheetMusicViewEnabled && mode !== 'audio-waveform' && } {sheetMusicViewEnabled && activeRegion && sheetQuantization ? ( = ({ {} : 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 = ({ spectrogramPower={spectrogramPower} spectrogramHeightResolution={spectrogramHeightResolution} pianoRollZoom={pianoRollZoom} + mode={mode} onSpectrogramLoadingChange={handleSpectrogramLoadingChange} > {memoizedNotes} - {!isSpectrogram && recordingNoteOverlays} + {!isAudioView && recordingNoteOverlays} )}
diff --git a/src/components/piano-roll/PianoRollToolbar.test.tsx b/src/components/piano-roll/PianoRollToolbar.test.tsx index cd94d52..acd552c 100644 --- a/src/components/piano-roll/PianoRollToolbar.test.tsx +++ b/src/components/piano-roll/PianoRollToolbar.test.tsx @@ -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( + + ); + + 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( + + ); + + 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(); diff --git a/src/components/piano-roll/PianoRollToolbar.tsx b/src/components/piano-roll/PianoRollToolbar.tsx index 57b58a0..f5e2072 100644 --- a/src/components/piano-roll/PianoRollToolbar.tsx +++ b/src/components/piano-roll/PianoRollToolbar.tsx @@ -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 = ({ + showAudioSpectrogramToggle = false, + audioSpectrogramEnabled = false, + onAudioSpectrogramToggle, + sheetMusicToggleDisabled = false, sheetMusicViewEnabled = false, onSheetMusicViewToggle, sheetMusicTrackScopeEnabled = false, @@ -91,7 +99,9 @@ const PianoRollToolbar: React.FC = ({ 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 = ({ return () => document.removeEventListener('mousedown', handleClickOutside); }, [showMoreMenu]); + const spectrogramToggleButton = showAudioSpectrogramToggle ? ( + + ) : null; + + const sheetMusicToggleButton = ( + + ); + return (
{showMidiControls && (
- + {spectrogramToggleButton} + {sheetMusicToggleButton}
)} + {showAudioOnlyControls && ( +
+ {spectrogramToggleButton} +
+ )} + + {showSpectrogramOnlyControls && ( +
+ {spectrogramToggleButton} +
+ )} + {sheetMusicViewEnabled && (
- + {spectrogramToggleButton} + {sheetMusicToggleButton} {mode !== 'spectrogram' && (