413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
import React, { useMemo, useState, useRef, useEffect, useCallback } from 'react';
|
|
import { DEBUG_MODE } from '../../constants';
|
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
|
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
|
import { KGTrack } from '../../core/track/KGTrack';
|
|
import { KGCore } from '../../core/KGCore';
|
|
import { useProjectStore } from '../../stores/projectStore';
|
|
import PianoNote from './PianoNote';
|
|
import PianoKeys from './PianoKeys';
|
|
import PianoGridHeader from './PianoGridHeader';
|
|
import PianoGrid from './PianoGrid';
|
|
import { useNoteOperations } from '../../hooks/useNoteOperations';
|
|
import { useNoteSelection } from '../../hooks/useNoteSelection';
|
|
import type { KeySignature } from '../../core/KGProject';
|
|
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
|
import { velocityToColor } from '../../util/velocityColor';
|
|
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
|
|
import PianoRollAutomationLane from './PianoRollAutomationLane';
|
|
import type { PianoRollAutomationType } from './pianoRollAutomation';
|
|
import type { SheetMeasureMetric, SheetQuantization } from './sheetNotationTypes';
|
|
import type { InstrumentType } from '../../core/track/KGMidiTrack';
|
|
import SheetMusicView from './SheetMusicView';
|
|
|
|
const NOOP_SHEET_METRICS_CHANGE = (_metrics: SheetMeasureMetric[]) => {};
|
|
|
|
interface PianoRollContentProps {
|
|
contentRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
noteScrollRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
pianoGridRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
maxBars: number;
|
|
timeSignature: { numerator: number; denominator: number };
|
|
activeRegion: KGMidiRegion | null;
|
|
updateTrack: (track: KGTrack) => void;
|
|
tracks: KGTrack[];
|
|
onSetNoteUpdateTrigger?: (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => void;
|
|
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
|
|
selectedMode: string;
|
|
keySignature: KeySignature;
|
|
chordGuide: string;
|
|
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
|
|
audioRegion?: KGAudioRegion;
|
|
trackId?: string;
|
|
projectName?: string;
|
|
bpm?: number;
|
|
spectrogramThresholdDb?: number;
|
|
spectrogramPower?: number;
|
|
spectrogramHeightResolution?: SpectrogramHeightResolution;
|
|
pianoRollZoom?: number;
|
|
automationEnabled?: boolean;
|
|
automationType?: PianoRollAutomationType;
|
|
automationRedrawVersion?: number;
|
|
sheetMusicViewEnabled?: boolean;
|
|
sheetMusicTrackScopeEnabled?: boolean;
|
|
sheetQuantization?: SheetQuantization;
|
|
sheetKeySignature?: KeySignature;
|
|
sheetInstrument?: InstrumentType;
|
|
onSheetMeasureMetricsChange?: (metrics: SheetMeasureMetric[]) => void;
|
|
}
|
|
|
|
const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|
contentRef,
|
|
noteScrollRef,
|
|
pianoGridRef,
|
|
maxBars,
|
|
timeSignature,
|
|
activeRegion,
|
|
updateTrack,
|
|
tracks,
|
|
onSetNoteUpdateTrigger,
|
|
onSetDeleteNotesTrigger,
|
|
selectedMode,
|
|
keySignature,
|
|
chordGuide,
|
|
mode = 'midi-edit',
|
|
audioRegion,
|
|
trackId,
|
|
projectName,
|
|
bpm = 120,
|
|
spectrogramThresholdDb = -25,
|
|
spectrogramPower = 0.5,
|
|
spectrogramHeightResolution = 3,
|
|
pianoRollZoom = 1,
|
|
automationEnabled = false,
|
|
automationType = 'pitch-bend',
|
|
automationRedrawVersion = 0,
|
|
sheetMusicViewEnabled = false,
|
|
sheetMusicTrackScopeEnabled = false,
|
|
sheetQuantization,
|
|
sheetKeySignature = 'C major',
|
|
sheetInstrument = 'acoustic_grand_piano',
|
|
onSheetMeasureMetricsChange,
|
|
}) => {
|
|
const isSpectrogram = mode === 'spectrogram';
|
|
const showAutomationLane = automationEnabled && !isSpectrogram && !sheetMusicViewEnabled;
|
|
const [spectrogramLoading, setSpectrogramLoading] = useState(false);
|
|
const [noteScrollLeft, setNoteScrollLeft] = useState(0);
|
|
const handleSpectrogramLoadingChange = useCallback((loading: boolean) => {
|
|
setSpectrogramLoading(loading);
|
|
}, []);
|
|
|
|
// Get KGCore instance
|
|
const core = KGCore.instance();
|
|
|
|
// Recording state
|
|
const isRecording = useProjectStore(s => s.isRecording);
|
|
const recordingNotes = useProjectStore(s => s.recordingNotes);
|
|
const forwardAutomationHorizontalWheel = useCallback((delta: number) => {
|
|
const container = noteScrollRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
container.scrollLeft += delta;
|
|
setNoteScrollLeft(container.scrollLeft);
|
|
}, [noteScrollRef]);
|
|
|
|
// Use the note operations hook for resize and drag functionality
|
|
const {
|
|
resizingNoteId,
|
|
draggingNoteId,
|
|
tempNoteStyles,
|
|
noteUpdateCounter,
|
|
setNoteUpdateCounter,
|
|
handleGridDoubleClick,
|
|
handleGridClick,
|
|
handleNoteResizeStart,
|
|
handleNoteResize,
|
|
handleNoteResizeEnd,
|
|
handleNoteDragStart,
|
|
handleNoteDrag,
|
|
handleNoteDragEnd,
|
|
deleteSelectedNotes
|
|
} = useNoteOperations({
|
|
activeRegion,
|
|
timeSignature,
|
|
updateTrack,
|
|
tracks,
|
|
pianoGridRef
|
|
});
|
|
|
|
// Expose setNoteUpdateCounter to parent component
|
|
useEffect(() => {
|
|
if (onSetNoteUpdateTrigger) {
|
|
onSetNoteUpdateTrigger(setNoteUpdateCounter);
|
|
}
|
|
}, [onSetNoteUpdateTrigger, setNoteUpdateCounter]);
|
|
|
|
// Expose deleteSelectedNotes to parent component
|
|
useEffect(() => {
|
|
if (onSetDeleteNotesTrigger) {
|
|
onSetDeleteNotesTrigger(deleteSelectedNotes);
|
|
}
|
|
}, [onSetDeleteNotesTrigger, deleteSelectedNotes]);
|
|
|
|
// Use the note selection hook for selection functionality
|
|
const {
|
|
selectedNoteIds,
|
|
isBoxSelectingRef,
|
|
selectionBoxRef,
|
|
selectionBoxRender,
|
|
handleNoteClick,
|
|
handleBackgroundClick,
|
|
handleBackgroundMouseDown,
|
|
cleanupSelectionListeners
|
|
} = useNoteSelection({
|
|
activeRegion,
|
|
updateTrack,
|
|
tracks
|
|
});
|
|
|
|
// Combined click handler for both pointer and pencil modes
|
|
const handleCombinedClick = (e: React.MouseEvent) => {
|
|
if (isSpectrogram) return;
|
|
handleBackgroundClick(e);
|
|
handleGridClick(e);
|
|
};
|
|
|
|
// Sync selected notes with KGCore on mount and when selection changes
|
|
useEffect(() => {
|
|
if (!activeRegion) return;
|
|
|
|
// Get currently selected items from KGCore
|
|
const selectedItems = core.getSelectedItems();
|
|
|
|
// Filter for KGMidiNote items that belong to this region
|
|
const selectedNotes = selectedItems.filter(item =>
|
|
item instanceof KGMidiNote &&
|
|
activeRegion.getNotes().some(note => note.getId() === item.getId())
|
|
) as KGMidiNote[];
|
|
|
|
// Make sure the note objects have the correct selection state
|
|
activeRegion.getNotes().forEach(note => {
|
|
const isSelected = selectedNotes.some(selectedNote => selectedNote.getId() === note.getId());
|
|
if (isSelected && !note.isSelected()) {
|
|
note.select();
|
|
} else if (!isSelected && note.isSelected()) {
|
|
note.deselect();
|
|
}
|
|
});
|
|
}, [activeRegion]);
|
|
|
|
// Clean up event listeners on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
cleanupSelectionListeners();
|
|
};
|
|
}, []);
|
|
|
|
// Memoize the notes rendering to prevent unnecessary recalculations
|
|
const memoizedNotes = useMemo(() => {
|
|
if (isSpectrogram || sheetMusicViewEnabled || !activeRegion) return null;
|
|
|
|
if (DEBUG_MODE.PIANO_ROLL) {
|
|
console.log(`Rendering notes for region: ${activeRegion.getId()}`);
|
|
console.log(`Number of notes: ${activeRegion.getNotes().length}`);
|
|
console.log(`Note update counter: ${noteUpdateCounter}`);
|
|
console.log(`Selected notes: ${Array.from(selectedNoteIds).join(', ')}`);
|
|
}
|
|
|
|
const notes = activeRegion.getNotes();
|
|
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
|
|
const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
|
|
const regionStartBeat = activeRegion.getStartFromBeat();
|
|
|
|
return notes.map((note, index) => {
|
|
// Calculate position and size
|
|
const startBeat = note.getStartBeat() + regionStartBeat; // Absolute beat position
|
|
const endBeat = note.getEndBeat() + regionStartBeat; // Absolute beat position
|
|
const pitch = note.getPitch();
|
|
|
|
// Convert pitch to y position (higher notes have lower y values)
|
|
// We need to find the index of the pitch in our piano roll
|
|
const pitchIndex = 107 - pitch; // Reverse the pitch to get the index (B7 is 107)
|
|
|
|
// Calculate position and dimensions
|
|
const left = startBeat * beatWidth;
|
|
const top = pitchIndex * noteHeight;
|
|
const width = (endBeat - startBeat) * beatWidth;
|
|
|
|
const noteId = note.getId();
|
|
|
|
// Check if this note is being resized or dragged and has a temporary style
|
|
if ((resizingNoteId === noteId || draggingNoteId === noteId) && tempNoteStyles[noteId]) {
|
|
// Use the temporary style for position and size
|
|
const tempStyle = tempNoteStyles[noteId];
|
|
|
|
return (
|
|
<PianoNote
|
|
key={`note-${noteId}`}
|
|
id={noteId}
|
|
index={index}
|
|
left={parseFloat(tempStyle.left as string)}
|
|
top={parseFloat(tempStyle.top as string)}
|
|
width={parseFloat(tempStyle.width as string)}
|
|
height={noteHeight}
|
|
velocity={note.getVelocity()}
|
|
onResizeStart={handleNoteResizeStart}
|
|
onResize={handleNoteResize}
|
|
onResizeEnd={handleNoteResizeEnd}
|
|
onDragStart={handleNoteDragStart}
|
|
onDrag={handleNoteDrag}
|
|
onDragEnd={handleNoteDragEnd}
|
|
onClick={handleNoteClick}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PianoNote
|
|
key={`note-${noteId}`}
|
|
id={noteId}
|
|
index={index}
|
|
left={left}
|
|
top={top}
|
|
width={width}
|
|
height={noteHeight}
|
|
velocity={note.getVelocity()}
|
|
onResizeStart={handleNoteResizeStart}
|
|
onResize={handleNoteResize}
|
|
onResizeEnd={handleNoteResizeEnd}
|
|
onDragStart={handleNoteDragStart}
|
|
onDrag={handleNoteDrag}
|
|
onDragEnd={handleNoteDragEnd}
|
|
onClick={handleNoteClick}
|
|
/>
|
|
);
|
|
});
|
|
}, [mode, activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks, sheetMusicViewEnabled]);
|
|
|
|
const recordingNoteOverlays = useMemo(() => {
|
|
if (!isRecording || !activeRegion || recordingNotes.length === 0) return null;
|
|
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
|
|
const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
|
|
const regionStartBeat = activeRegion.getStartFromBeat();
|
|
return recordingNotes.map((note, index) => (
|
|
<div
|
|
key={`recording-note-${index}`}
|
|
className="piano-grid-recording-note"
|
|
style={{
|
|
left: (note.startBeat + regionStartBeat) * beatWidth,
|
|
top: (107 - note.pitch) * noteHeight,
|
|
width: Math.max((note.endBeat - note.startBeat) * beatWidth, 4),
|
|
height: noteHeight,
|
|
backgroundColor: velocityToColor(note.velocity, 0.35),
|
|
borderColor: velocityToColor(note.velocity, 0.85),
|
|
}}
|
|
/>
|
|
));
|
|
}, [isRecording, recordingNotes, activeRegion]);
|
|
|
|
useEffect(() => {
|
|
if (!sheetMusicViewEnabled) {
|
|
return;
|
|
}
|
|
|
|
const container = noteScrollRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
container.scrollTop = 0;
|
|
}, [noteScrollRef, sheetMusicViewEnabled]);
|
|
|
|
return (
|
|
<div className="piano-roll-content-outer">
|
|
<div
|
|
className="piano-roll-content"
|
|
ref={contentRef}
|
|
data-testid={showAutomationLane ? 'piano-roll-content-split' : 'piano-roll-content-single'}
|
|
>
|
|
<div className={`piano-roll-main-section ${showAutomationLane ? 'with-automation' : ''}`}>
|
|
<div
|
|
className="piano-roll-note-scroll"
|
|
ref={noteScrollRef}
|
|
onScroll={(event) => setNoteScrollLeft(event.currentTarget.scrollLeft)}
|
|
>
|
|
{!sheetMusicViewEnabled && (
|
|
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} />
|
|
)}
|
|
<div className={`piano-roll-body ${sheetMusicViewEnabled ? 'sheet-music-body' : ''}`}>
|
|
{!sheetMusicViewEnabled && <PianoKeys activeRegion={activeRegion} />}
|
|
{sheetMusicViewEnabled && activeRegion && sheetQuantization ? (
|
|
<SheetMusicView
|
|
activeRegion={activeRegion}
|
|
midiRegions={tracks.filter(track => track.getId().toString() === activeRegion?.getTrackId()).flatMap(track => (
|
|
track.getRegions().filter(region => region instanceof KGMidiRegion)
|
|
)) as KGMidiRegion[]}
|
|
maxBars={maxBars}
|
|
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
|
|
timeSignature={timeSignature}
|
|
keySignature={sheetKeySignature}
|
|
instrument={sheetInstrument}
|
|
quantization={sheetQuantization}
|
|
noteScrollRef={noteScrollRef}
|
|
onMetricsChange={onSheetMeasureMetricsChange ?? NOOP_SHEET_METRICS_CHANGE}
|
|
/>
|
|
) : (
|
|
<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}
|
|
regionStartBeat={activeRegion?.getStartFromBeat() || 0}
|
|
selectedMode={selectedMode}
|
|
keySignature={keySignature}
|
|
chordGuide={chordGuide}
|
|
audioRegion={audioRegion}
|
|
trackId={trackId}
|
|
projectName={projectName}
|
|
bpm={bpm}
|
|
spectrogramThresholdDb={spectrogramThresholdDb}
|
|
spectrogramPower={spectrogramPower}
|
|
spectrogramHeightResolution={spectrogramHeightResolution}
|
|
pianoRollZoom={pianoRollZoom}
|
|
onSpectrogramLoadingChange={handleSpectrogramLoadingChange}
|
|
>
|
|
{memoizedNotes}
|
|
{!isSpectrogram && recordingNoteOverlays}
|
|
</PianoGrid>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{showAutomationLane && (
|
|
<div className="piano-roll-automation-section">
|
|
<PianoRollAutomationLane
|
|
activeRegion={activeRegion}
|
|
automationType={automationType}
|
|
maxBars={maxBars}
|
|
timeSignature={timeSignature}
|
|
bpm={bpm}
|
|
redrawVersion={automationRedrawVersion}
|
|
horizontalScrollLeft={noteScrollLeft}
|
|
onHorizontalWheel={forwardAutomationHorizontalWheel}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{spectrogramLoading && (
|
|
<div className="spectrogram-loading-overlay">
|
|
<div className="spectrogram-loading-label">
|
|
Computing spectrogram…
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PianoRollContent;
|