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
+4 -1
View File
@@ -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', () => {
+6 -3
View File
@@ -58,6 +58,7 @@ const MainContent: React.FC<MainContentProps> = ({
requestedSheetMusicViewEnabled,
pianoRollViewRequestVersion,
openMidiPianoRoll,
openAudioWaveformViewer,
openSpectrogramViewer,
openHybridMode,
hybridAudioRegionId,
@@ -95,6 +96,7 @@ const MainContent: React.FC<MainContentProps> = ({
setShowPianoRoll,
setActiveRegionId,
openMidiPianoRoll,
openAudioWaveformViewer,
openSpectrogramViewer,
openHybridMode,
pianoRollMode,
@@ -321,7 +323,7 @@ const MainContent: React.FC<MainContentProps> = ({
}, [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<MainContentProps> = ({
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<MainContentProps> = ({
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<MainContentProps> = ({
return undefined;
})()}
trackId={(() => {
const audioId = pianoRollMode === 'spectrogram'
const audioId = pianoRollMode === 'audio-waveform' || pianoRollMode === 'spectrogram'
? activeRegionId
: pianoRollMode === 'hybrid'
? hybridAudioRegionId
@@ -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(
const pianoKeysWidth = hasPianoKeys
? (parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60;
) || 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;
+36 -11
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,
@@ -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();
+51 -14
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]);
return (
<div className="piano-roll-toolbar">
{showMidiControls && (
<div className="toolbar-left">
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">
{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' : ''}`}
-4
View File
@@ -151,10 +151,6 @@
background: rgba(0, 0, 0, 0.35);
}
.region-waveform-btn {
display: none;
}
.region-waveform-btn:hover {
background: rgba(0, 0, 0, 0.35);
}
+22 -1
View File
@@ -1,8 +1,9 @@
import React from 'react';
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, screen } from '@testing-library/react';
import RegionItem from './RegionItem';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { KGMainContentState } from '../../core/state/KGMainContentState';
vi.mock('../../stores/projectStore', () => ({
@@ -191,4 +192,24 @@ describe('RegionItem', () => {
width: '120px',
});
});
it('shows the waveform button for audio regions and triggers the waveform open callback', () => {
const onOpenWaveform = vi.fn();
const audioRegion = new KGAudioRegion('audio-1', 'track-2', 1, 'Audio Region', 0, 4);
render(
<RegionItem
id="audio-1"
name="Audio Region"
style={{ left: '0px', width: '120px', position: 'absolute' }}
onClick={vi.fn()}
onOpenWaveform={onOpenWaveform}
audioRegion={audioRegion}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'View waveform' }));
expect(onOpenWaveform).toHaveBeenCalledWith('audio-1');
});
});
+3
View File
@@ -39,6 +39,7 @@ interface RegionItemProps {
onClick?: (regionId: string, options: RegionClickOptions) => void;
// Explicit open piano roll action from header pencil icon
onOpenPianoRoll?: (regionId: string) => void;
onOpenWaveform?: (regionId: string) => void;
// Open spectrogram viewer for audio regions
onOpenSpectrogram?: (regionId: string) => void;
// Enter hybrid mode (show + when piano roll is open with the opposite region type selected)
@@ -73,6 +74,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
onDragEnd,
onClick,
onOpenPianoRoll,
onOpenWaveform,
onOpenSpectrogram,
showHybridButton,
onOpenHybrid,
@@ -732,6 +734,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenWaveform?.(id);
}}
aria-label="View waveform"
>
+5
View File
@@ -47,6 +47,7 @@ interface TrackGridItemProps {
onRegionFineMoveEnd?: (regionId: string, deltaInBars: number) => void;
onRegionClick?: (regionId: string, options: RegionClickOptions) => void;
onOpenPianoRoll?: (regionId: string) => void;
onOpenWaveform?: (regionId: string) => void;
onOpenSpectrogram?: (regionId: string) => void;
showHybridButtonForAudio?: boolean;
showHybridButtonForMidi?: boolean;
@@ -78,6 +79,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onRegionFineMoveEnd,
onRegionClick,
onOpenPianoRoll,
onOpenWaveform,
onOpenSpectrogram,
showHybridButtonForAudio,
showHybridButtonForMidi,
@@ -802,6 +804,9 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onRegionClick(regionId, { shiftKey: false, metaKey: false, ctrlKey: false });
}
}}
onOpenWaveform={audioRegion ? (regionId) => {
onOpenWaveform?.(regionId);
} : undefined}
onOpenSpectrogram={audioRegion ? (regionId) => {
onOpenSpectrogram?.(regionId);
} : undefined}
+3
View File
@@ -48,6 +48,7 @@ interface TrackGridPanelProps {
onRegionLassoSelection?: (regionIds: string[], options: RegionClickOptions) => void;
onRegionLassoCommit?: () => void;
onOpenPianoRoll?: (regionId: string) => void;
onOpenWaveform?: (regionId: string) => void;
onOpenSpectrogram?: (regionId: string) => void;
showHybridButtonForAudio?: boolean;
showHybridButtonForMidi?: boolean;
@@ -70,6 +71,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
onRegionLassoSelection,
onRegionLassoCommit,
onOpenPianoRoll,
onOpenWaveform,
onOpenSpectrogram,
showHybridButtonForAudio,
showHybridButtonForMidi,
@@ -1038,6 +1040,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
onRegionFineMoveEnd={handleRegionFineMoveEnd}
onRegionClick={handleRegionClick}
onOpenPianoRoll={onOpenPianoRoll}
onOpenWaveform={onOpenWaveform}
onOpenSpectrogram={onOpenSpectrogram}
showHybridButtonForAudio={showHybridButtonForAudio}
showHybridButtonForMidi={showHybridButtonForMidi}
@@ -46,6 +46,7 @@ const storeState = {
showPianoRoll: false,
openMidiPianoRoll: vi.fn(),
openMidiPianoRollWithSheetMusicView: vi.fn(),
openAudioWaveformViewer: vi.fn(),
openSpectrogramViewer: vi.fn(),
playheadPosition: 12,
refreshProjectState: vi.fn(),
@@ -165,6 +166,7 @@ describe('useGlobalKeyboardHandler region shortcuts', () => {
storeState.selectedRegionIds = ['region-a', 'region-b'];
storeState.openMidiPianoRoll.mockClear();
storeState.openMidiPianoRollWithSheetMusicView.mockClear();
storeState.openAudioWaveformViewer.mockClear();
storeState.openSpectrogramViewer.mockClear();
vi.mocked(showAlert).mockClear();
});
@@ -217,6 +219,7 @@ describe('useGlobalKeyboardHandler region shortcuts', () => {
fireEvent.keyDown(document.body, { key: 'e' });
expect(storeState.openMidiPianoRollWithSheetMusicView).toHaveBeenCalledWith('region-b', false);
expect(storeState.openAudioWaveformViewer).not.toHaveBeenCalled();
expect(storeState.openSpectrogramViewer).not.toHaveBeenCalled();
});
@@ -260,9 +263,29 @@ describe('useGlobalKeyboardHandler region shortcuts', () => {
expect(showAlert).toHaveBeenCalledWith('Sheet music view is only available for MIDI regions.');
expect(storeState.openSpectrogramViewer).not.toHaveBeenCalled();
expect(storeState.openAudioWaveformViewer).not.toHaveBeenCalled();
expect(storeState.openMidiPianoRollWithSheetMusicView).not.toHaveBeenCalled();
});
it('opens a selected audio region in waveform view on E', () => {
mockTracks = [
{
getRegions: () => [
{
getId: () => 'region-b',
},
],
},
];
render(<HookHarness />);
fireEvent.keyDown(document.body, { key: 'e' });
expect(storeState.openAudioWaveformViewer).toHaveBeenCalledWith('region-b');
expect(storeState.openMidiPianoRollWithSheetMusicView).not.toHaveBeenCalled();
expect(storeState.openSpectrogramViewer).not.toHaveBeenCalled();
});
it('does not close the editor when N is pressed while piano roll is already open', () => {
storeState.showPianoRoll = true;
mockTracks = [
+4 -3
View File
@@ -17,7 +17,7 @@ import { showAlert } from '../util/dialogUtil';
* Handles keyboard shortcuts defined in the configuration
*/
export const useGlobalKeyboardHandler = () => {
const { undo, redo, setStatus, isPlaying, startPlaying, stopTransport, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll, showPianoRoll, openMidiPianoRollWithSheetMusicView, openSpectrogramViewer, playheadPosition, refreshProjectState } = useProjectStore();
const { undo, redo, setStatus, isPlaying, startPlaying, stopTransport, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll, showPianoRoll, openMidiPianoRollWithSheetMusicView, openAudioWaveformViewer, openSpectrogramViewer, playheadPosition, refreshProjectState } = useProjectStore();
const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null;
useEffect(() => {
@@ -232,7 +232,7 @@ export const useGlobalKeyboardHandler = () => {
return;
}
// Check for edit/view shortcut (E) — open piano roll for MIDI, spectrogram for audio
// Check for edit/view shortcut (E) — open piano roll for MIDI, waveform for audio
if (event.key.toLowerCase() === 'e' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
event.preventDefault();
if (showPianoRoll) {
@@ -258,7 +258,7 @@ export const useGlobalKeyboardHandler = () => {
if (foundMidi) {
openMidiPianoRollWithSheetMusicView(candidateId, false);
} else if (foundAudio) {
openSpectrogramViewer(candidateId);
openAudioWaveformViewer(candidateId);
}
return;
}
@@ -335,6 +335,7 @@ export const useGlobalKeyboardHandler = () => {
setShowPianoRoll,
showPianoRoll,
openMidiPianoRollWithSheetMusicView,
openAudioWaveformViewer,
openSpectrogramViewer,
playheadPosition,
refreshProjectState,
+13 -4
View File
@@ -34,9 +34,10 @@ interface UseMainContentRegionsParams {
setShowPianoRoll: (show: boolean) => void;
setActiveRegionId: (regionId: string | null) => void;
openMidiPianoRoll: (regionId: string) => void;
openAudioWaveformViewer: (regionId: string) => void;
openSpectrogramViewer: (regionId: string) => void;
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
pianoRollMode: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
updateTrack: (track: KGTrack) => void;
maxBars: number;
}
@@ -64,6 +65,7 @@ export interface UseMainContentRegionsResult {
handleRegionLassoCommit: () => void;
handleEmptyMainContentClick: (event: React.MouseEvent<HTMLDivElement>) => void;
handleOpenPianoRoll: (regionId: string) => void;
handleOpenWaveform: (regionId: string) => void;
handleOpenSpectrogram: (regionId: string) => void;
handleOpenHybrid: (regionId: string) => void;
}
@@ -80,6 +82,7 @@ export function useMainContentRegions({
setShowPianoRoll,
setActiveRegionId,
openMidiPianoRoll,
openAudioWaveformViewer,
openSpectrogramViewer,
openHybridMode,
pianoRollMode,
@@ -238,15 +241,15 @@ export function useMainContentRegions({
}
if (lastSelectedRegion instanceof KGAudioRegion) {
openSpectrogramViewer(lastSelectedRegionId);
openAudioWaveformViewer(lastSelectedRegionId);
} else if (lastSelectedRegion instanceof KGMidiRegion) {
openMidiPianoRoll(lastSelectedRegionId);
}
}, [
clearAllSelections,
globalTracks,
openAudioWaveformViewer,
openMidiPianoRoll,
openSpectrogramViewer,
setActiveRegionId,
showPianoRoll,
tracks,
@@ -729,6 +732,11 @@ export function useMainContentRegions({
openMidiPianoRoll(regionId);
}, [handleRegionClick, openMidiPianoRoll]);
const handleOpenWaveform = useCallback((regionId: string) => {
handleRegionClick(regionId, DEFAULT_REGION_CLICK_OPTIONS);
openAudioWaveformViewer(regionId);
}, [handleRegionClick, openAudioWaveformViewer]);
const handleOpenSpectrogram = useCallback((regionId: string) => {
handleRegionClick(regionId, DEFAULT_REGION_CLICK_OPTIONS);
openSpectrogramViewer(regionId);
@@ -737,7 +745,7 @@ export function useMainContentRegions({
const handleOpenHybrid = useCallback((regionId: string) => {
if (pianoRollMode === 'midi-edit' && activeRegionId) {
openHybridMode(activeRegionId, regionId);
} else if (pianoRollMode === 'spectrogram' && activeRegionId) {
} else if ((pianoRollMode === 'audio-waveform' || pianoRollMode === 'spectrogram') && activeRegionId) {
openHybridMode(regionId, activeRegionId);
}
}, [activeRegionId, openHybridMode, pianoRollMode]);
@@ -759,6 +767,7 @@ export function useMainContentRegions({
handleRegionLassoCommit,
handleEmptyMainContentClick,
handleOpenPianoRoll,
handleOpenWaveform,
handleOpenSpectrogram,
handleOpenHybrid,
};
+19
View File
@@ -176,6 +176,25 @@ describe('projectStore piano roll state', () => {
expect(state.pianoRollViewRequestVersion).toBe(1);
});
it('opens an audio region in waveform view and clears hybrid state', async () => {
const { useProjectStore } = await import('./projectStore');
act(() => {
useProjectStore.getState().openHybridMode('midi-a', 'audio-a');
});
act(() => {
useProjectStore.getState().openAudioWaveformViewer('audio-b');
});
const state = useProjectStore.getState();
expect(state.showPianoRoll).toBe(true);
expect(state.pianoRollMode).toBe('audio-waveform');
expect(state.activeRegionId).toBe('audio-b');
expect(state.hybridAudioRegionId).toBeNull();
expect(state.requestedSheetMusicViewEnabled).toBe(false);
});
it('tracks playback preparation around startPlaying success', async () => {
let resolveStart: (() => void) | null = null;
mockCore.startPlaying.mockImplementationOnce(() => new Promise<void>((resolve) => {
+12 -1
View File
@@ -109,7 +109,7 @@ interface ProjectState {
// Piano roll state
showPianoRoll: boolean;
activeRegionId: string | null;
pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
pianoRollMode: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
hybridAudioRegionId: string | null;
requestedSheetMusicViewEnabled: boolean;
pianoRollViewRequestVersion: number;
@@ -211,6 +211,7 @@ interface ProjectState {
setActiveRegionId: (regionId: string | null) => void;
openMidiPianoRoll: (regionId: string) => void;
openMidiPianoRollWithSheetMusicView: (regionId: string, sheetMusicViewEnabled: boolean) => void;
openAudioWaveformViewer: (regionId: string) => void;
openSpectrogramViewer: (regionId: string) => void;
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
bumpAutomationRedrawVersion: () => void;
@@ -1625,6 +1626,16 @@ export const useProjectStore = create<ProjectState>((set, get) => {
}));
},
openAudioWaveformViewer: (regionId: string) => {
set({
showPianoRoll: true,
activeRegionId: regionId,
pianoRollMode: 'audio-waveform',
hybridAudioRegionId: null,
requestedSheetMusicViewEnabled: false,
});
},
openSpectrogramViewer: (regionId: string) => {
set({
showPianoRoll: true,
+1 -1
View File
@@ -6,7 +6,7 @@ import { splitSelectedRegionAtPlayhead } from './regionEditUtil';
const storeState = {
showPianoRoll: false,
pianoRollMode: 'midi-edit' as 'midi-edit' | 'spectrogram' | 'hybrid',
pianoRollMode: 'midi-edit' as 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid',
activeRegionId: null as string | null,
selectedNoteIds: [] as string[],
setShowPianoRoll: vi.fn(),