feat: implemented piano roll hybrid mode to allow user edit MIDI notes while observing spectrogram as reference

This commit is contained in:
Xiaohan-Tian
2026-05-01 18:28:45 -07:00
parent ba0e27e50a
commit 83034455cc
11 changed files with 162 additions and 55 deletions
+45 -20
View File
@@ -44,6 +44,8 @@ const MainContent: React.FC<MainContentProps> = ({
pianoRollMode, pianoRollMode,
openMidiPianoRoll, openMidiPianoRoll,
openSpectrogramViewer, openSpectrogramViewer,
openHybridMode,
hybridAudioRegionId,
addTrack, addTrack,
addAudioTrack, addAudioTrack,
projectName, projectName,
@@ -552,6 +554,19 @@ const MainContent: React.FC<MainContentProps> = ({
openSpectrogramViewer(regionId); openSpectrogramViewer(regionId);
}; };
// Handle hybrid mode open (+ button clicked on opposite-type region)
const handleOpenHybrid = (regionId: string) => {
if (pianoRollMode === 'midi-edit' && activeRegionId) {
openHybridMode(activeRegionId, regionId);
} else if (pianoRollMode === 'spectrogram' && activeRegionId) {
openHybridMode(regionId, activeRegionId);
}
};
// + button is visible only when piano roll is open and mode is not hybrid
const showHybridButtonForAudio = showPianoRoll && pianoRollMode === 'midi-edit';
const showHybridButtonForMidi = showPianoRoll && pianoRollMode === 'spectrogram';
// Handle piano roll close // Handle piano roll close
const handlePianoRollClose = () => { const handlePianoRollClose = () => {
setShowPianoRoll(false); setShowPianoRoll(false);
@@ -844,6 +859,9 @@ const MainContent: React.FC<MainContentProps> = ({
onRegionClick={handleRegionClick} onRegionClick={handleRegionClick}
onOpenPianoRoll={handleOpenPianoRoll} onOpenPianoRoll={handleOpenPianoRoll}
onOpenSpectrogram={handleOpenSpectrogram} onOpenSpectrogram={handleOpenSpectrogram}
showHybridButtonForAudio={showHybridButtonForAudio}
showHybridButtonForMidi={showHybridButtonForMidi}
onOpenHybrid={handleOpenHybrid}
onExternalDropComplete={handleExternalDropComplete} onExternalDropComplete={handleExternalDropComplete}
/> />
</div> </div>
@@ -855,26 +873,33 @@ const MainContent: React.FC<MainContentProps> = ({
onClose={handlePianoRollClose} onClose={handlePianoRollClose}
regionId={activeRegionId} regionId={activeRegionId}
mode={pianoRollMode} mode={pianoRollMode}
audioRegion={pianoRollMode === 'spectrogram' && activeRegionId audioRegion={(() => {
? (() => { // spectrogram mode: audio region IS the activeRegionId
for (const track of tracks) { // hybrid mode: audio region is hybridAudioRegionId
const region = track.getRegions().find(r => r.getId() === activeRegionId); const audioId = pianoRollMode === 'spectrogram' ? activeRegionId
if (region && region.getCurrentType() === 'KGAudioRegion') { : pianoRollMode === 'hybrid' ? hybridAudioRegionId
return region as unknown as KGAudioRegion; : null;
} if (!audioId) return undefined;
} for (const track of tracks) {
return undefined; const region = track.getRegions().find(r => r.getId() === audioId);
})() if (region && region.getCurrentType() === 'KGAudioRegion') {
: undefined} return region as unknown as KGAudioRegion;
trackId={pianoRollMode === 'spectrogram' && activeRegionId }
? (() => { }
for (const track of tracks) { return undefined;
const region = track.getRegions().find(r => r.getId() === activeRegionId); })()}
if (region) return track.getId().toString(); trackId={(() => {
} const audioId = pianoRollMode === 'spectrogram' ? activeRegionId
return undefined; : pianoRollMode === 'hybrid' ? hybridAudioRegionId
})() : null;
: undefined} if (!audioId) return undefined;
for (const track of tracks) {
if (track.getRegions().some(r => r.getId() === audioId)) {
return track.getId().toString();
}
}
return undefined;
})()}
projectName={savedProjectName} projectName={savedProjectName}
/>, />,
document.body document.body
+1
View File
@@ -32,6 +32,7 @@ interface PianoGridProps {
bpm?: number; bpm?: number;
spectrogramThresholdDb?: number; spectrogramThresholdDb?: number;
spectrogramPower?: number; spectrogramPower?: number;
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
} }
interface CursorPosition { interface CursorPosition {
+10 -2
View File
@@ -77,10 +77,17 @@
/* Ensure toolbar and its dropdowns appear above piano roll content */ /* Ensure toolbar and its dropdowns appear above piano roll content */
} }
.piano-roll-toolbar .tool-button {
width: 20px;
height: 20px;
}
/* Override pointer-events for piano roll toolbar sections */ /* Override pointer-events for piano roll toolbar sections */
.piano-roll-toolbar .toolbar-left, .piano-roll-toolbar .toolbar-left,
.piano-roll-toolbar .toolbar-right { .piano-roll-toolbar .toolbar-right {
pointer-events: auto; pointer-events: auto;
/* max-width: 70%; */
width: auto;
} }
.piano-roll-toolbar .quant-button { .piano-roll-toolbar .quant-button {
@@ -101,6 +108,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-left: 5px;
} }
.spectrogram-control-label { .spectrogram-control-label {
@@ -121,7 +129,7 @@
.spectrogram-threshold-value { .spectrogram-threshold-value {
font-size: 10px; font-size: 10px;
color: #e0e0e0; color: #e0e0e0;
min-width: 44px; min-width: 30px;
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
} }
@@ -131,7 +139,7 @@
text-align: center; text-align: center;
font-size: 14px; font-size: 14px;
color: #e0e0e0; color: #e0e0e0;
text-transform: uppercase; /* text-transform: uppercase; */
cursor: pointer; cursor: pointer;
padding: 5px; padding: 5px;
border-radius: 3px; border-radius: 3px;
+7 -1
View File
@@ -23,7 +23,7 @@ interface PianoRollProps {
regionId: string | null; regionId: string | null;
initialPosition?: { x: number; y: number }; initialPosition?: { x: number; y: number };
initialSize?: { width: number; height: number }; initialSize?: { width: number; height: number };
mode?: 'midi-edit' | 'spectrogram'; mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
audioRegion?: KGAudioRegion; audioRegion?: KGAudioRegion;
trackId?: string; trackId?: string;
projectName?: string; projectName?: string;
@@ -40,6 +40,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
projectName, projectName,
}) => { }) => {
const isSpectrogram = mode === 'spectrogram'; const isSpectrogram = mode === 'spectrogram';
const isHybrid = mode === 'hybrid';
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm } = useProjectStore(); const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm } = useProjectStore();
// Tool state for piano roll // Tool state for piano roll
@@ -870,6 +871,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Get the title for the piano roll based on the active region // Get the title for the piano roll based on the active region
const getPianoRollTitle = () => { const getPianoRollTitle = () => {
if (isSpectrogram) return audioRegion ? `SPECTROGRAM — ${audioRegion.getName()}` : 'SPECTROGRAM'; if (isSpectrogram) return audioRegion ? `SPECTROGRAM — ${audioRegion.getName()}` : 'SPECTROGRAM';
if (isHybrid) {
const midiName = activeRegion?.getName() ?? 'MIDI';
const audioName = audioRegion?.getName() ?? 'Audio';
return `${midiName} + ${audioName}`;
}
if (!activeRegion) return "EDIT NOTE CLIP"; if (!activeRegion) return "EDIT NOTE CLIP";
// Calculate the bar and beat position of the region // Calculate the bar and beat position of the region
@@ -27,7 +27,7 @@ interface PianoRollContentProps {
selectedMode: string; selectedMode: string;
keySignature: KeySignature; keySignature: KeySignature;
chordGuide: string; chordGuide: string;
mode?: 'midi-edit' | 'spectrogram'; mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
audioRegion?: KGAudioRegion; audioRegion?: KGAudioRegion;
trackId?: string; trackId?: string;
projectName?: string; projectName?: string;
+20 -23
View File
@@ -24,7 +24,7 @@ interface PianoRollToolbarProps {
chordGuide: string; chordGuide: string;
onChordGuideChange: (value: string) => void; onChordGuideChange: (value: string) => void;
blinkButton?: string | null; blinkButton?: string | null;
mode?: 'midi-edit' | 'spectrogram'; mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
thresholdDb?: number; thresholdDb?: number;
onThresholdChange?: (db: number) => void; onThresholdChange?: (db: number) => void;
power?: number; power?: number;
@@ -51,11 +51,27 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
onPowerChange, onPowerChange,
}) => { }) => {
const isSpectrogram = mode === 'spectrogram'; const isSpectrogram = mode === 'spectrogram';
const showMidiControls = mode !== 'spectrogram'; // midi-edit and hybrid
const showSpecControls = mode === 'spectrogram' || mode === 'hybrid';
return ( return (
<div className="piano-roll-toolbar"> <div className="piano-roll-toolbar">
{!isSpectrogram && ( {showMidiControls && (
<div className="toolbar-left"> <div className="toolbar-left">
<button
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
onClick={() => onToolSelect('pointer')}
title="Pointer Tool"
>
<FaMousePointer />
</button>
<button
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
onClick={() => onToolSelect('pencil')}
title="Pencil Tool"
>
<FaPencilAlt />
</button>
<KGDropdown <KGDropdown
options={Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => ({ label: data.name, value: id }))} options={Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => ({ label: data.name, value: id }))}
value={selectedMode} value={selectedMode}
@@ -80,27 +96,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
</div> </div>
)} )}
{!isSpectrogram && (
<div className="toolbar-center">
<button
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
onClick={() => onToolSelect('pointer')}
title="Pointer Tool"
>
<FaMousePointer />
</button>
<button
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
onClick={() => onToolSelect('pencil')}
title="Pencil Tool"
>
<FaPencilAlt />
</button>
</div>
)}
<div className="toolbar-right"> <div className="toolbar-right">
{!isSpectrogram && ( {showMidiControls && (
<> <>
<KGDropdown <KGDropdown
options={KGPianoRollState.SNAP_OPTIONS} options={KGPianoRollState.SNAP_OPTIONS}
@@ -127,7 +124,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
</> </>
)} )}
{isSpectrogram && ( {showSpecControls && (
<div className="spectrogram-toolbar-controls"> <div className="spectrogram-toolbar-controls">
<span className="spectrogram-control-label">Floor</span> <span className="spectrogram-control-label">Floor</span>
<input <input
+21
View File
@@ -140,6 +140,27 @@
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.35);
} }
.region-hybrid-btn {
position: absolute;
top: 4px;
left: 24px;
background: rgba(0, 0, 0, 0.25);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
padding: 2px;
margin: 0;
cursor: pointer;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
}
.region-hybrid-btn:hover {
background: rgba(0, 0, 0, 0.35);
}
/* Instrument dropdown specific styles */ /* Instrument dropdown specific styles */
.instrument-dropdown .quant-dropdown { .instrument-dropdown .quant-dropdown {
min-width: 80px; min-width: 80px;
+27 -2
View File
@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import './Region.css'; import './Region.css';
import { FaPencilAlt } from 'react-icons/fa'; import { FaPencilAlt, FaPlus } from 'react-icons/fa';
import { MdGraphicEq } from 'react-icons/md'; import { MdGraphicEq } from 'react-icons/md';
import type { ResizeAction } from '../interfaces'; import type { ResizeAction } from '../interfaces';
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants'; import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
@@ -30,6 +30,9 @@ interface RegionItemProps {
onOpenPianoRoll?: (regionId: string) => void; onOpenPianoRoll?: (regionId: string) => void;
// Open spectrogram viewer for audio regions // Open spectrogram viewer for audio regions
onOpenSpectrogram?: (regionId: string) => void; onOpenSpectrogram?: (regionId: string) => void;
// Enter hybrid mode (show + when piano roll is open with the opposite region type selected)
showHybridButton?: boolean;
onOpenHybrid?: (regionId: string) => void;
// MIDI region data for rendering notes // MIDI region data for rendering notes
midiRegion?: KGMidiRegion; midiRegion?: KGMidiRegion;
// Audio region data for rendering waveform // Audio region data for rendering waveform
@@ -53,6 +56,8 @@ const RegionItem: React.FC<RegionItemProps> = ({
onClick, onClick,
onOpenPianoRoll, onOpenPianoRoll,
onOpenSpectrogram, onOpenSpectrogram,
showHybridButton,
onOpenHybrid,
midiRegion, midiRegion,
audioRegion, audioRegion,
audioBuffer audioBuffer
@@ -64,7 +69,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
const [resizeEdge, setResizeEdge] = useState<ResizeAction>('none'); const [resizeEdge, setResizeEdge] = useState<ResizeAction>('none');
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const initialMousePosRef = useRef<{x: number, y: number}>({x: 0, y: 0}); const initialMousePosRef = useRef<{ x: number, y: number }>({ x: 0, y: 0 });
// Use refs to track states for immediate access // Use refs to track states for immediate access
const isResizingRef = useRef<boolean>(false); const isResizingRef = useRef<boolean>(false);
const isDraggingRef = useRef<boolean>(false); const isDraggingRef = useRef<boolean>(false);
@@ -600,6 +605,26 @@ const RegionItem: React.FC<RegionItemProps> = ({
<MdGraphicEq size={10} /> <MdGraphicEq size={10} />
</button> </button>
)} )}
{showHybridButton && (
<button
className="region-hybrid-btn"
title="Open in hybrid mode"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onOpenHybrid) {
onOpenHybrid(id);
}
}}
aria-label="Open hybrid mode"
>
<FaPlus size={10} />
</button>
)}
<canvas ref={canvasRef} /> <canvas ref={canvasRef} />
</div> </div>
</div> </div>
+8
View File
@@ -27,6 +27,9 @@ interface TrackGridItemProps {
onRegionClick?: (regionId: string) => void; onRegionClick?: (regionId: string) => void;
onOpenPianoRoll?: (regionId: string) => void; onOpenPianoRoll?: (regionId: string) => void;
onOpenSpectrogram?: (regionId: string) => void; onOpenSpectrogram?: (regionId: string) => void;
showHybridButtonForAudio?: boolean;
showHybridButtonForMidi?: boolean;
onOpenHybrid?: (regionId: string) => void;
allTracks?: KGTrack[]; // Added to access all tracks for drag operations allTracks?: KGTrack[]; // Added to access all tracks for drag operations
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void; onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
} }
@@ -49,6 +52,9 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onRegionClick, onRegionClick,
onOpenPianoRoll, onOpenPianoRoll,
onOpenSpectrogram, onOpenSpectrogram,
showHybridButtonForAudio,
showHybridButtonForMidi,
onOpenHybrid,
allTracks, allTracks,
onKGOneClipDrop, onKGOneClipDrop,
}) => { }) => {
@@ -571,6 +577,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onOpenSpectrogram={audioRegion ? (regionId) => { onOpenSpectrogram={audioRegion ? (regionId) => {
onOpenSpectrogram?.(regionId); onOpenSpectrogram?.(regionId);
} : undefined} } : undefined}
showHybridButton={audioRegion ? showHybridButtonForAudio : showHybridButtonForMidi}
onOpenHybrid={onOpenHybrid}
midiRegion={midiRegion} midiRegion={midiRegion}
audioRegion={audioRegion} audioRegion={audioRegion}
audioBuffer={audioBuffer} audioBuffer={audioBuffer}
+9
View File
@@ -31,6 +31,9 @@ interface TrackGridPanelProps {
onRegionClick?: (regionId: string) => void; onRegionClick?: (regionId: string) => void;
onOpenPianoRoll?: (regionId: string) => void; onOpenPianoRoll?: (regionId: string) => void;
onOpenSpectrogram?: (regionId: string) => void; onOpenSpectrogram?: (regionId: string) => void;
showHybridButtonForAudio?: boolean;
showHybridButtonForMidi?: boolean;
onOpenHybrid?: (regionId: string) => void;
onExternalDropComplete?: (trackIndex: number, regionUI: RegionUI) => void; onExternalDropComplete?: (trackIndex: number, regionUI: RegionUI) => void;
} }
@@ -48,6 +51,9 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
onRegionClick, onRegionClick,
onOpenPianoRoll, onOpenPianoRoll,
onOpenSpectrogram, onOpenSpectrogram,
showHybridButtonForAudio,
showHybridButtonForMidi,
onOpenHybrid,
onExternalDropComplete, onExternalDropComplete,
}) => { }) => {
const gridContainerRef = useRef<HTMLDivElement>(null); const gridContainerRef = useRef<HTMLDivElement>(null);
@@ -654,6 +660,9 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
onRegionClick={handleRegionClick} onRegionClick={handleRegionClick}
onOpenPianoRoll={onOpenPianoRoll} onOpenPianoRoll={onOpenPianoRoll}
onOpenSpectrogram={onOpenSpectrogram} onOpenSpectrogram={onOpenSpectrogram}
showHybridButtonForAudio={showHybridButtonForAudio}
showHybridButtonForMidi={showHybridButtonForMidi}
onOpenHybrid={onOpenHybrid}
allTracks={tracks} allTracks={tracks}
onKGOneClipDrop={handleExternalDrop} onKGOneClipDrop={handleExternalDrop}
/> />
+13 -6
View File
@@ -78,7 +78,8 @@ interface ProjectState {
// Piano roll state // Piano roll state
showPianoRoll: boolean; showPianoRoll: boolean;
activeRegionId: string | null; activeRegionId: string | null;
pianoRollMode: 'midi-edit' | 'spectrogram'; pianoRollMode: 'midi-edit' | 'spectrogram' | 'hybrid';
hybridAudioRegionId: string | null;
// ChatBox state // ChatBox state
showChatBox: boolean; showChatBox: boolean;
@@ -149,6 +150,7 @@ interface ProjectState {
setActiveRegionId: (regionId: string | null) => void; setActiveRegionId: (regionId: string | null) => void;
openMidiPianoRoll: (regionId: string) => void; openMidiPianoRoll: (regionId: string) => void;
openSpectrogramViewer: (regionId: string) => void; openSpectrogramViewer: (regionId: string) => void;
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
// Project state cleanup // Project state cleanup
cleanupProjectState: () => void; cleanupProjectState: () => void;
@@ -308,6 +310,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
showPianoRoll: false, showPianoRoll: false,
activeRegionId: null, activeRegionId: null,
pianoRollMode: 'midi-edit' as const, pianoRollMode: 'midi-edit' as const,
hybridAudioRegionId: null,
// Initial ChatBox state // Initial ChatBox state
showChatBox: initialChatBoxState, showChatBox: initialChatBoxState,
@@ -1038,20 +1041,24 @@ export const useProjectStore = create<ProjectState>((set, get) => {
}, },
openMidiPianoRoll: (regionId: string) => { openMidiPianoRoll: (regionId: string) => {
set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'midi-edit' }); set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'midi-edit', hybridAudioRegionId: null });
}, },
openSpectrogramViewer: (regionId: string) => { openSpectrogramViewer: (regionId: string) => {
set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'spectrogram' }); set({ showPianoRoll: true, activeRegionId: regionId, pianoRollMode: 'spectrogram', hybridAudioRegionId: null });
},
openHybridMode: (midiRegionId: string, audioRegionId: string) => {
set({ showPianoRoll: true, activeRegionId: midiRegionId, hybridAudioRegionId: audioRegionId, pianoRollMode: 'hybrid' });
}, },
// Project state cleanup - used when starting new/loading projects // Project state cleanup - used when starting new/loading projects
cleanupProjectState: () => { cleanupProjectState: () => {
// Close piano roll if it's visible // Close piano roll if it's visible
set({ showPianoRoll: false }); set({ showPianoRoll: false });
// Clear active region // Clear active region and hybrid state
set({ activeRegionId: null }); set({ activeRegionId: null, hybridAudioRegionId: null, pianoRollMode: 'midi-edit' });
// Clear any selected items // Clear any selected items
KGCore.instance().clearSelectedItems(); KGCore.instance().clearSelectedItems();