Files
KGStudio/src/components/piano-roll/PianoRoll.tsx
T

1468 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo } from 'react';
import './PianoRoll.css';
import * as Tone from 'tone';
import { useProjectStore } from '../../stores/projectStore';
import { FaGripLines } from 'react-icons/fa';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, TOOLBAR_CONSTANTS } from '../../constants';
import PianoRollHeader from './PianoRollHeader';
import PianoRollToolbar from './PianoRollToolbar';
import NoteAttributeBar from './NoteAttributeBar';
import PianoRollContent from './PianoRollContent';
import { KGCore } from '../../core/KGCore';
import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { ConfigManager } from '../../core/config/ConfigManager';
import { beatsToBar } from '../../util/midiUtil';
import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
import { showAlert } from '../../util/dialogUtil';
import {
normalizeSpectrogramHeightResolution,
type SpectrogramHeightResolution,
} from '../../util/spectrogramUtil';
import {
buildAudioChordWindowsForRegion,
type AudioChordDetectionRequest,
type DetectedAudioChord,
} from '../../util/audioChordDetection';
import type { AudioChordDetectionWorkerMessage } from '../../workers/audioChordDetectionWorker';
import type { PianoRollAutomationType } from './pianoRollAutomation';
import type { SheetMeasureMetric } from './sheetNotationTypes';
import { getSheetPlayheadPixel, getSheetQuantizationOptions, parseSheetQuantization } from './sheetNotation';
import {
createPendingModeSwitchRequest,
getRegionStartScrollLeft,
getScrollLeftForViewportRequest,
type PendingModeSwitchRequest,
} from './pianoRollViewport';
interface PianoRollProps {
onClose: () => void;
regionId: string | null;
initialPosition?: { x: number; y: number };
initialSize?: { width: number; height: number };
mode?: 'midi-edit' | 'spectrogram' | 'hybrid';
requestedSheetMusicViewEnabled?: boolean;
pianoRollViewRequestVersion?: number;
audioRegion?: KGAudioRegion;
trackId?: string;
projectName?: string;
}
const PianoRoll: React.FC<PianoRollProps> = ({
onClose,
regionId,
initialPosition,
initialSize,
mode = 'midi-edit',
requestedSheetMusicViewEnabled = false,
pianoRollViewRequestVersion = 0,
audioRegion,
trackId,
projectName,
}) => {
const isSpectrogram = mode === 'spectrogram';
const isHybrid = mode === 'hybrid';
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion, refreshProjectState } = useProjectStore();
// Tool state for piano roll
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
// Spectrogram controls (only used in spectrogram mode)
const [spectrogramThresholdDb, setSpectrogramThresholdDb] = useState<number>(-25);
const [spectrogramPower, setSpectrogramPower] = useState<number>(0.5);
const [spectrogramHeightResolution, setSpectrogramHeightResolution] =
useState<SpectrogramHeightResolution>(3);
const [isDetectingChords, setIsDetectingChords] = useState(false);
const [detectChordProgressPercent, setDetectChordProgressPercent] = useState(0);
// Piano roll zoom (1x8x); updates --region-grid-beat-width CSS variable
const [pianoRollZoom, setPianoRollZoom] = useState<number>(() => KGPianoRollState.instance().getPianoRollZoom());
const [automationEnabled, setAutomationEnabled] = useState(false);
const [automationType, setAutomationType] = useState<PianoRollAutomationType>('pitch-bend');
const [sheetMusicViewEnabled, setSheetMusicViewEnabled] = useState(false);
const [sheetMusicTrackScopeEnabled, setSheetMusicTrackScopeEnabled] = useState(false);
const [sheetQuantization, setSheetQuantization] = useState('16,48');
const [sheetMeasureMetrics, setSheetMeasureMetrics] = useState<SheetMeasureMetric[]>([]);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleInputValue, setTitleInputValue] = useState('');
const titleInputRef = useRef<HTMLInputElement>(null);
// Quantization state
const [quantPosition, setQuantPosition] = useState<string>('1/8');
const [quantLength, setQuantLength] = useState<string>('1/8');
// Snapping state
const [snapping, setSnapping] = useState<string>('NO SNAP');
// Chord guide state
const [chordGuide, setChordGuide] = useState<string>('N');
// Piano roll state with temporary initial values
const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 });
// Blink effect state for toolbar button feedback
const [blinkButton, setBlinkButton] = useState<string | null>(null);
const [size, setSize] = useState(initialSize || { width: 800, height: PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT });
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [activeRegion, setActiveRegion] = useState<KGMidiRegion | null>(null);
const selectedNotes = useMemo(
() => activeRegion?.getNotes().filter(n => selectedNoteIds.includes(n.getId())) ?? [],
[activeRegion, selectedNoteIds]
);
const parentMidiTrack = useMemo(() => {
if (!activeRegion) {
return null;
}
return tracks.find(track => track.getId().toString() === activeRegion.getTrackId()) ?? null;
}, [activeRegion, tracks]);
const activeInstrument = useMemo<InstrumentType>(() => (
parentMidiTrack instanceof KGMidiTrack ? parentMidiTrack.getInstrument() : 'acoustic_grand_piano'
), [parentMidiTrack]);
const parsedSheetQuantization = useMemo(
() => parseSheetQuantization(sheetQuantization),
[sheetQuantization]
);
const pianoRollRef = useRef<HTMLDivElement>(null);
const pianoRollContentRef = useRef<HTMLDivElement>(null);
const pianoRollNoteScrollRef = useRef<HTMLDivElement>(null);
const pianoGridRef = useRef<HTMLDivElement>(null);
const wasDraggingRef = useRef<boolean>(false);
// Refs for auto-scroll during playback
const pianoRollExpectedScrollLeftRef = useRef<number>(-1);
const pianoRollIsPlayingRef = useRef(false);
const pendingZoomAnchorBeatRef = useRef<number | null>(null);
const pendingModeSwitchRequestRef = useRef<PendingModeSwitchRequest | null>(null);
const previousSheetMusicViewEnabledRef = useRef<boolean>(false);
const previousActiveRegionIdRef = useRef<string | null>(null);
const lastAppliedViewRequestVersionRef = useRef<number>(0);
// Ref for storing the setNoteUpdateCounter function
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
// Ref for storing the deleteSelectedNotes function
const deleteSelectedNotesRef = useRef<(() => boolean) | null>(null);
// Calculate initial position and size once on mount
useEffect(() => {
// Skip if initialPosition or initialSize were provided as props
if (!initialPosition || !initialSize) {
// Calculate initial position based on window dimensions
const calculateInitialPosition = () => {
// Dynamically get heights from CSS computed styles
const statusBarElement = document.querySelector('.status-bar');
// Get actual heights from DOM elements, or use fallback values if elements don't exist yet
const statusBarHeight = statusBarElement ? statusBarElement.clientHeight : 30;
const pianoRollHeight = PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT;
// Compute left offset when instrument selection panel is open
const rootStyles = getComputedStyle(document.documentElement);
const instrumentPanelWidthStr = rootStyles.getPropertyValue('--instrument-selection-width') || '300px';
const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Positioning piano roll with heights - statusBar: ${statusBarHeight}px, pianoRoll: ${pianoRollHeight}px`);
}
return {
x: showInstrumentSelection ? instrumentPanelWidth : 0,
y: window.innerHeight - statusBarHeight - pianoRollHeight
};
};
const calculateInitialSize = () => {
const rootStyles = getComputedStyle(document.documentElement);
const chatBoxWidthStr = rootStyles.getPropertyValue('--chat-box-width') || '350px';
const instrumentPanelWidthStr = rootStyles.getPropertyValue('--instrument-selection-width') || '300px';
const chatBoxWidth = parseInt(chatBoxWidthStr, 10) || 350;
const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300;
let availableWidth = window.innerWidth;
if (showChatBox || showKGOnePanel || showEventListPanel) availableWidth -= chatBoxWidth;
if (showInstrumentSelection) availableWidth -= instrumentPanelWidth;
// Ensure a sensible minimum starting width
const clampedWidth = Math.max(400, availableWidth);
return {
width: clampedWidth,
height: PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT
};
};
// Set position and size only if not provided as props
if (!initialPosition) {
setPosition(calculateInitialPosition());
}
if (!initialSize) {
setSize(calculateInitialSize());
}
}
// Intentionally run once on mount to capture layout at open time
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array means this runs once on mount
// Find and set the active region when regionId changes
useEffect(() => {
if (!regionId) {
setActiveRegion(null);
return;
}
let found: KGMidiRegion | null = null;
for (const track of tracks) {
const region = track.getRegions().find(r => r.getId() === regionId);
if (region && region instanceof KGMidiRegion) {
found = region;
break;
}
}
// Always update — clears stale MIDI region when switching to an audio region
setActiveRegion(found);
if (found && DEBUG_MODE.PIANO_ROLL) {
console.log(`Active region set in PianoRoll: ${found.getId()}`);
console.log(`Region details: name=${found.getName()}, trackId=${found.getTrackId()}, trackIndex=${found.getTrackIndex()}`);
}
}, [regionId, tracks]);
// Sync local state with KGPianoRollState on mount
useEffect(() => {
const pianoRollState = KGPianoRollState.instance();
// Sync snapping state
const currentSnap = pianoRollState.getCurrentSnap();
setSnapping(currentSnap);
// Sync tool state
const currentTool = pianoRollState.getActiveTool() as 'pointer' | 'pencil';
setActiveTool(currentTool);
setAutomationEnabled(pianoRollState.getAutomationViewEnabled());
setAutomationType(pianoRollState.getCurrentAutomationType() as PianoRollAutomationType);
setSheetMusicViewEnabled(pianoRollState.getSheetMusicViewEnabled());
setSheetMusicTrackScopeEnabled(pianoRollState.getSheetMusicTrackScopeEnabled());
setSheetQuantization(pianoRollState.getSheetQuantization());
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Synced piano roll state on mount - snap: ${currentSnap}, tool: ${currentTool}`);
}
}, []); // Empty dependency array means this runs once on mount
useEffect(() => {
if (isSpectrogram) {
return;
}
if (pianoRollViewRequestVersion === 0 || lastAppliedViewRequestVersionRef.current === pianoRollViewRequestVersion) {
return;
}
lastAppliedViewRequestVersionRef.current = pianoRollViewRequestVersion;
if (activeRegion) {
pendingModeSwitchRequestRef.current = createPendingModeSwitchRequest({
playheadBeat: playheadPosition,
regionStartBeat: activeRegion.getStartFromBeat(),
regionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
sourceSheetMusicViewEnabled: sheetMusicViewEnabled,
destinationSheetMusicViewEnabled: requestedSheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled: requestedSheetMusicViewEnabled && sheetMusicTrackScopeEnabled,
});
} else {
pendingModeSwitchRequestRef.current = null;
}
setSheetMusicViewEnabled(requestedSheetMusicViewEnabled);
KGPianoRollState.instance().setSheetMusicViewEnabled(requestedSheetMusicViewEnabled);
}, [
activeRegion,
isSpectrogram,
pianoRollViewRequestVersion,
playheadPosition,
requestedSheetMusicViewEnabled,
sheetMusicTrackScopeEnabled,
sheetMusicViewEnabled,
]);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
const initializeSpectrogramHeightResolution = async () => {
const configManager = ConfigManager.instance();
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
const applyResolutionFromConfig = () => {
setSpectrogramHeightResolution(
normalizeSpectrogramHeightResolution(
configManager.get('editor.spectrogram_height_resolution')
)
);
};
applyResolutionFromConfig();
unsubscribe = configManager.addChangeListener((changedKeys) => {
if (
changedKeys.includes('__all__') ||
changedKeys.includes('editor.spectrogram_height_resolution')
) {
applyResolutionFromConfig();
}
});
};
void initializeSpectrogramHeightResolution();
return () => {
unsubscribe?.();
};
}, []);
// Add keyboard event listener for Escape
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Close on Escape key
if (event.key === 'Escape') {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Closing piano roll with ESC key');
}
onClose();
}
};
// Add event listener
window.addEventListener('keydown', handleKeyDown);
// Remove event listener on cleanup
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
// Handle mouse events for dragging and resizing
const handleMouseDown = (e: React.MouseEvent, action: 'drag' | 'resize') => {
if (action === 'drag') {
setIsDragging(true);
wasDraggingRef.current = false; // Reset the dragging flag
if (pianoRollRef.current) {
const rect = pianoRollRef.current.getBoundingClientRect();
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
});
}
} else if (action === 'resize') {
setIsResizing(true);
e.preventDefault();
}
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
// Set the flag to true as soon as any movement happens
wasDraggingRef.current = true;
setPosition({
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y
});
} else if (isResizing) {
setSize({
width: Math.max(400, e.clientX - position.x),
height: Math.max(300, e.clientY - position.y)
});
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
// We keep wasDraggingRef.current as is - it will be used in handleTitleClick
// and reset on the next mousedown
};
if (isDragging || isResizing) {
document.addEventListener('mousemove', handleMouseMove as unknown as EventListener);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove as unknown as EventListener);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, isResizing, dragOffset, position]);
useEffect(() => {
if (!isEditingTitle && activeRegion) {
setTitleInputValue(activeRegion.getName());
}
}, [activeRegion, isEditingTitle]);
const cancelTitleEdit = () => {
setTitleInputValue(activeRegion?.getName() ?? '');
setIsEditingTitle(false);
};
const commitTitleEdit = async () => {
if (!activeRegion) {
setIsEditingTitle(false);
return;
}
const newName = titleInputValue.trim();
setIsEditingTitle(false);
setTitleInputValue(activeRegion.getName());
if (!newName || newName === activeRegion.getName()) return;
try {
const command = new UpdateRegionCommand(activeRegion.getId(), { name: newName });
KGCore.instance().executeCommand(command);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Executed UpdateRegionCommand: renamed region ${activeRegion.getId()} to "${newName}" using command pattern`);
}
const updatedTracks = [...tracks];
useProjectStore.setState({ tracks: updatedTracks });
} catch (error) {
console.error('Error renaming region:', error);
await showAlert('Failed to rename region. Please try again.');
}
};
const handleDetectChords = useCallback(async () => {
if (!audioRegion || !projectName || !trackId) {
await showAlert('Open an audio region in spectrogram mode before detecting chords.');
return;
}
const project = KGCore.instance().getCurrentProject();
const windows = buildAudioChordWindowsForRegion(project, audioRegion);
if (windows.length === 0) {
await showAlert('The selected audio region has no audible span to analyze.');
return;
}
setIsDetectingChords(true);
setDetectChordProgressPercent(0);
let worker: Worker | null = null;
try {
let audioBuffer = KGAudioInterface.instance().getAudioBuffer(trackId, audioRegion.getAudioFileId());
if (!audioBuffer) {
const rawBuffer = await KGAudioFileStorage.loadAudioFile(projectName, audioRegion.getAudioFileId());
const actx = Tone.getContext().rawContext as AudioContext;
audioBuffer = await actx.decodeAudioData(rawBuffer);
}
const monoPcm = new Float32Array(audioBuffer.length);
for (let channelIndex = 0; channelIndex < audioBuffer.numberOfChannels; channelIndex++) {
const channelData = audioBuffer.getChannelData(channelIndex);
for (let sampleIndex = 0; sampleIndex < audioBuffer.length; sampleIndex++) {
monoPcm[sampleIndex] += channelData[sampleIndex] / audioBuffer.numberOfChannels;
}
}
const request: AudioChordDetectionRequest = {
pcm: monoPcm,
sampleRate: audioBuffer.sampleRate,
clipStartOffsetSeconds: audioRegion.getClipStartOffsetSeconds(),
windows,
};
const detectedChords = await new Promise<DetectedAudioChord[]>((resolve, reject) => {
worker = new Worker(
new URL('../../workers/audioChordDetectionWorker.ts', import.meta.url),
{ type: 'module' },
);
worker.onmessage = (event: MessageEvent<AudioChordDetectionWorkerMessage>) => {
if (event.data.type === 'progress') {
setDetectChordProgressPercent(event.data.progress.percent);
return;
}
resolve(event.data.results);
};
worker.onerror = () => {
reject(new Error('Chord detection worker failed.'));
};
worker.postMessage(request, [request.pcm.buffer]);
});
const replacements = detectedChords
.filter(result => result.symbol !== 'N' && result.endBeat > result.startBeat)
.map(result => ({
startBeat: result.startBeat,
length: result.endBeat - result.startBeat,
symbol: result.symbol,
}));
const spanStartBeat = windows[0].startBeat;
const spanEndBeat = windows[windows.length - 1].endBeat;
KGCore.instance().executeCommand(
new ReplaceChordRegionsInRangeCommand(spanStartBeat, spanEndBeat, replacements),
);
refreshProjectState();
} catch (error) {
console.error('Error detecting chords:', error);
await showAlert('Failed to detect chords from this audio region.');
} finally {
if (worker) {
worker.terminate();
}
setIsDetectingChords(false);
setDetectChordProgressPercent(0);
}
}, [audioRegion, projectName, refreshProjectState, trackId]);
// Handle title click to rename the region
const handleTitleClick = () => {
// If we were just dragging, don't show the rename dialog
if (wasDraggingRef.current) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log("Skipping inline rename because the window was just dragged");
}
return;
}
if (!activeRegion) return;
setTitleInputValue(activeRegion.getName());
setIsEditingTitle(true);
window.setTimeout(() => {
titleInputRef.current?.focus();
titleInputRef.current?.select();
}, 0);
};
// Handle tool selection
const handleToolSelect = (tool: 'pointer' | 'pencil') => {
setActiveTool(tool);
KGPianoRollState.instance().setActiveTool(tool);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected tool: ${tool}`);
}
};
// Handle snapping selection
const handleSnappingSelect = useCallback((value: string) => {
setSnapping(value);
KGPianoRollState.instance().setCurrentSnap(value);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected snapping: ${value}`);
}
}, []);
// Handle mode selection
const handleModeSelect = useCallback((value: string) => {
setSelectedMode(value);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected mode: ${value}`);
}
}, [setSelectedMode]);
// Handle chord guide selection
const handleChordGuideSelect = useCallback((value: string) => {
setChordGuide(value);
}, []);
// Update suitable chords whenever chord guide, key signature, or mode changes
useEffect(() => {
const pianoRollState = KGPianoRollState.instance();
if (chordGuide === 'N') {
// Disabled - clear chord data
pianoRollState.setCurrentSuitableChords({});
pianoRollState.setCurrentSuitableChordsPitchClasses({});
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Chord guide disabled - cleared suitable chords`);
}
} else {
// Get suitable chords for the selected function (T/S/D)
const functionType = chordGuide as 'T' | 'S' | 'D';
const suitableChords = getSuitableChords(keySignature, selectedMode, functionType);
// Convert note names to pitch classes (ensuring ascending order)
const chordsPitchClasses: Record<string, number[]> = {};
for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) {
const pitchClasses: number[] = [];
let previousPitch = -1;
for (const noteName of noteNames) {
let pitchClass = noteNameToPitchClass(noteName);
// If this pitch is lower than the previous one, add an octave
if (previousPitch >= 0 && pitchClass <= previousPitch) {
pitchClass += 12;
}
pitchClasses.push(pitchClass);
previousPitch = pitchClass;
}
chordsPitchClasses[chordSymbol] = pitchClasses;
}
// Update piano roll state
pianoRollState.setCurrentSuitableChords(suitableChords);
pianoRollState.setCurrentSuitableChordsPitchClasses(chordsPitchClasses);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Chord guide updated: ${chordGuide} (${functionType})`);
console.log(`Suitable chords for ${keySignature} in ${selectedMode} mode:`, suitableChords);
console.log(`Pitch classes:`, chordsPitchClasses);
}
}
}, [chordGuide, keySignature, selectedMode]);
// Handler for receiving the setNoteUpdateCounter function from PianoRollContent
const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => {
triggerNoteUpdateRef.current = setNoteFn;
};
// Handler for receiving the deleteSelectedNotes function from PianoRollContent
const handleSetDeleteNotesTrigger = (deleteFn: () => boolean) => {
deleteSelectedNotesRef.current = deleteFn;
};
// Quantize selected notes based on the selected quantization value
const quantizeSelectedNotes = useCallback((quantValue: string) => {
if (!activeRegion) return;
// Get the KGCore instance
const core = KGCore.instance();
// Get all selected notes
const selectedItems = core.getSelectedItems();
const selectedNotes = selectedItems.filter(item =>
item instanceof KGMidiNote &&
activeRegion.getNotes().some(note => note.getId() === item.getId())
) as KGMidiNote[];
if (selectedNotes.length === 0) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('No notes selected for quantization');
}
return;
}
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantizing ${selectedNotes.length} selected notes with value: ${quantValue}`);
}
// Parse the quantization value (e.g., "1/4", "1/8", "1/16", "1/32")
const denominator = parseInt(quantValue.split('/')[1]);
if (isNaN(denominator)) {
console.error(`Invalid quantization value: ${quantValue}`);
return;
}
// Calculate the quantization step in beats
// In a 4/4 time signature, a quarter note (1/4) is 1 beat
// In a 6/8 time signature, an eighth note (1/8) is 1 beat
const { numerator, denominator: timeSigDenominator } = timeSignature;
// Calculate beats per whole note based on time signature
// In 4/4, a whole note is 4 beats
// In 6/8, a whole note is 6 beats (because each beat is an eighth note)
const beatsPerWholeNote = numerator * (4 / timeSigDenominator);
// Calculate the quantization step in beats
// quantizationStep should ALWAYS be 4 / denominator regardless of time signature
const quantizationStep = 4 / denominator;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Time signature: ${numerator}/${timeSigDenominator}`);
console.log(`Beats per whole note: ${beatsPerWholeNote}`);
console.log(`Quantization step: ${quantizationStep} beats`);
}
// Apply quantization to each selected note
selectedNotes.forEach(note => {
// Get the current start beat
const currentStartBeat = note.getStartBeat();
// Calculate the quantized start beat
const quantizedStartBeat = Math.round(currentStartBeat / quantizationStep) * quantizationStep;
// Calculate the duration of the note
const duration = note.getEndBeat() - currentStartBeat;
// Set the new start beat and maintain the duration
note.setStartBeat(quantizedStartBeat);
note.setEndBeat(quantizedStartBeat + duration);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantized note ${note.getId()}: ${currentStartBeat} -> ${quantizedStartBeat}`);
}
});
// Find the track that contains this region and update it
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
if (track) {
updateTrack(track);
}
// Trigger a re-render by incrementing the note update counter
if (triggerNoteUpdateRef.current) {
triggerNoteUpdateRef.current(prev => prev + 1);
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Triggered note update to re-render quantized notes');
}
}
}, [activeRegion, timeSignature, updateTrack, tracks]);
// Quantize selected notes length based on the selected quantization value
const quantizeNoteLength = useCallback((quantValue: string) => {
if (!activeRegion) return;
// Get the KGCore instance
const core = KGCore.instance();
// Get all selected notes
const selectedItems = core.getSelectedItems();
const selectedNotes = selectedItems.filter(item =>
item instanceof KGMidiNote &&
activeRegion.getNotes().some(note => note.getId() === item.getId())
) as KGMidiNote[];
if (selectedNotes.length === 0) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('No notes selected for length quantization');
}
return;
}
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantizing length of ${selectedNotes.length} selected notes with value: ${quantValue}`);
}
// Parse the quantization value (e.g., "1/1", "1/2", "1/4", "1/8", "1/16", "1/32")
const denominator = parseInt(quantValue.split('/')[1]);
if (isNaN(denominator)) {
console.error(`Invalid quantization value: ${quantValue}`);
return;
}
// Calculate the quantization step in beats
// In a 4/4 time signature, a quarter note (1/4) is 1 beat
// In a 6/8 time signature, an eighth note (1/8) is 1 beat
const { numerator, denominator: timeSigDenominator } = timeSignature;
// Calculate beats per whole note based on time signature
// In 4/4, a whole note is 4 beats
// In 6/8, a whole note is 6 beats (because each beat is an eighth note)
const beatsPerWholeNote = numerator * (4 / timeSigDenominator);
// Calculate the quantization step in beats
// quantizationStep should ALWAYS be 4 / denominator regardless of time signature
const quantizationStep = 4 / denominator;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Time signature: ${numerator}/${timeSigDenominator}`);
console.log(`Beats per whole note: ${beatsPerWholeNote}`);
console.log(`Length quantization step: ${quantizationStep} beats`);
}
// Apply quantization to each selected note
selectedNotes.forEach(note => {
// Get the current start and end beats
const startBeat = note.getStartBeat();
const currentEndBeat = note.getEndBeat();
// Calculate the current duration
const currentDuration = currentEndBeat - startBeat;
// Calculate the quantized duration
// If the current duration is less than the quantization step,
// extend it to match the quantization step exactly
// Otherwise, round to the nearest multiple of quantizationStep
let quantizedDuration;
if (currentDuration < quantizationStep) {
// For notes shorter than the quantization step, extend to exactly one step
quantizedDuration = quantizationStep;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Extending short note ${note.getId()} from ${currentDuration} to ${quantizedDuration}`);
}
} else {
// For longer notes, round to nearest multiple of quantizationStep
quantizedDuration = Math.round(currentDuration / quantizationStep) * quantizationStep;
}
// Ensure minimum note length
quantizedDuration = Math.max(PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH, quantizedDuration);
// Set the new end beat while maintaining the start beat
note.setEndBeat(startBeat + quantizedDuration);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantized note length ${note.getId()}: ${currentDuration} -> ${quantizedDuration}`);
}
});
// Find the track that contains this region and update it
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
if (track) {
updateTrack(track);
}
// Trigger a re-render by incrementing the note update counter
if (triggerNoteUpdateRef.current) {
triggerNoteUpdateRef.current(prev => prev + 1);
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Triggered note update to re-render quantized note lengths');
}
}
}, [activeRegion, timeSignature, updateTrack, tracks]);
// Handle quantization selection
const handleQuantSelect = useCallback((type: 'position' | 'length', value: string) => {
if (type === 'position') {
setQuantPosition(value);
// Apply quantization immediately when position quantization is changed
quantizeSelectedNotes(value);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`quant-position selected: ${value}`);
}
} else {
setQuantLength(value);
// Apply length quantization immediately when length quantization is changed
quantizeNoteLength(value);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`quant-length selected: ${value}`);
}
}
}, [quantizeSelectedNotes, quantizeNoteLength]);
const handleZoomChange = useCallback((nextZoom: number) => {
if (nextZoom === pianoRollZoom) return;
const container = pianoRollNoteScrollRef.current;
pendingZoomAnchorBeatRef.current = null;
if (container) {
const keysWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60;
const visibleMusicWidth = Math.max(0, container.clientWidth - keysWidth);
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || TOOLBAR_CONSTANTS.BASE_BAR_WIDTH;
if (visibleMusicWidth > 0 && beatWidth > 0) {
pendingZoomAnchorBeatRef.current = (container.scrollLeft + visibleMusicWidth / 2) / beatWidth;
} else {
pendingZoomAnchorBeatRef.current = null;
}
}
KGPianoRollState.instance().setPianoRollZoom(nextZoom);
KGCore.instance().getCurrentProject().setPianoRollZoom(nextZoom);
setPianoRollZoom(nextZoom);
}, [pianoRollZoom]);
const handleAutomationToggle = useCallback(() => {
setAutomationEnabled(current => {
const next = !current;
KGPianoRollState.instance().setAutomationViewEnabled(next);
return next;
});
}, []);
const handleAutomationTypeChange = useCallback((value: PianoRollAutomationType) => {
setAutomationType(value);
KGPianoRollState.instance().setCurrentAutomationType(value);
}, []);
const handleSheetMusicViewToggle = useCallback(() => {
if (activeRegion) {
pendingModeSwitchRequestRef.current = createPendingModeSwitchRequest({
playheadBeat: playheadPosition,
regionStartBeat: activeRegion.getStartFromBeat(),
regionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
sourceSheetMusicViewEnabled: sheetMusicViewEnabled,
destinationSheetMusicViewEnabled: !sheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled: !sheetMusicViewEnabled && sheetMusicTrackScopeEnabled,
});
} else {
pendingModeSwitchRequestRef.current = null;
}
setSheetMusicViewEnabled(current => {
const next = !current;
KGPianoRollState.instance().setSheetMusicViewEnabled(next);
return next;
});
}, [activeRegion, playheadPosition, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled]);
const handleSheetQuantizationChange = useCallback((value: string) => {
setSheetQuantization(value);
KGPianoRollState.instance().setSheetQuantization(value);
}, []);
const handleSheetMusicTrackScopeToggle = useCallback(() => {
if (activeRegion) {
pendingModeSwitchRequestRef.current = createPendingModeSwitchRequest({
playheadBeat: playheadPosition,
regionStartBeat: activeRegion.getStartFromBeat(),
regionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
sourceSheetMusicViewEnabled: sheetMusicViewEnabled,
destinationSheetMusicViewEnabled: sheetMusicViewEnabled,
destinationSheetMusicTrackScopeEnabled: !sheetMusicTrackScopeEnabled,
});
} else {
pendingModeSwitchRequestRef.current = null;
}
setSheetMusicTrackScopeEnabled(current => {
const next = !current;
KGPianoRollState.instance().setSheetMusicTrackScopeEnabled(next);
return next;
});
}, [activeRegion, playheadPosition, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled]);
const handleSheetMeasureMetricsChange = useCallback((metrics: SheetMeasureMetric[]) => {
setSheetMeasureMetrics((current) => {
if (
current.length === metrics.length &&
current.every((metric, index) => (
metric.barIndex === metrics[index].barIndex &&
metric.startBeat === metrics[index].startBeat &&
metric.endBeat === metrics[index].endBeat &&
metric.leftPx === metrics[index].leftPx &&
metric.widthPx === metrics[index].widthPx
))
) {
return current;
}
return metrics;
});
}, []);
const centerPianoRollOnDefaultVerticalPosition = useCallback(() => {
const container = pianoRollNoteScrollRef.current;
if (!container) {
return;
}
const keyHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
const c4Position = 4 * 12 * keyHeight;
const totalHeight = 8 * 12 * keyHeight;
const viewportHeight = container.clientHeight;
const scrollPosition = (totalHeight - c4Position) - (viewportHeight / 2);
container.scrollTop = Math.max(0, scrollPosition);
}, []);
// Calculate C4 position and scroll to it when piano roll opens
useEffect(() => {
centerPianoRollOnDefaultVerticalPosition();
}, [centerPianoRollOnDefaultVerticalPosition]);
useEffect(() => {
const previous = previousSheetMusicViewEnabledRef.current;
if (previous !== sheetMusicViewEnabled && !sheetMusicViewEnabled) {
centerPianoRollOnDefaultVerticalPosition();
}
previousSheetMusicViewEnabledRef.current = sheetMusicViewEnabled;
}, [centerPianoRollOnDefaultVerticalPosition, sheetMusicViewEnabled]);
// Sync isPlayingRef for use inside scroll event closure
useEffect(() => {
pianoRollIsPlayingRef.current = isPlaying;
}, [isPlaying]);
// Detect manual horizontal scroll during playback
useEffect(() => {
const container = pianoRollNoteScrollRef.current;
if (!container) return;
const handleScroll = () => {
if (!pianoRollIsPlayingRef.current) return;
if (Math.abs(container.scrollLeft - pianoRollExpectedScrollLeftRef.current) < 1) return;
useProjectStore.getState().setAutoScrollEnabled(false);
};
container.addEventListener('scroll', handleScroll);
return () => {
container.removeEventListener('scroll', handleScroll);
};
}, []);
// Auto-scroll to keep playhead centered during playback
useEffect(() => {
if (!isPlaying || !autoScrollEnabled) return;
const container = pianoRollNoteScrollRef.current;
if (!container) return;
const playheadPixel = sheetMusicViewEnabled && activeRegion
? getSheetPlayheadPixel(
sheetMusicTrackScopeEnabled
? Math.max(0, playheadPosition)
: Math.max(0, playheadPosition - activeRegion.getStartFromBeat()),
sheetMeasureMetrics
)
: (() => {
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || 40;
return playheadPosition * beatWidth;
})();
// Center the playhead in the visible grid area (excluding the 60px sticky piano keys panel)
const keysWidth = sheetMusicViewEnabled
? 0
: (parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60);
const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2;
const clampedScrollLeft = Math.max(
0,
Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth)
);
pianoRollExpectedScrollLeftRef.current = clampedScrollLeft;
container.scrollLeft = clampedScrollLeft;
}, [playheadPosition, isPlaying, autoScrollEnabled, sheetMusicViewEnabled, sheetMusicTrackScopeEnabled, sheetMeasureMetrics, activeRegion]);
// Handle scroll requests from main content bar numbers clicks
useEffect(() => {
if (pianoRollScrollRequest === null) return;
const container = pianoRollNoteScrollRef.current;
if (!container) return;
const playheadPixel = sheetMusicViewEnabled && activeRegion
? getSheetPlayheadPixel(
sheetMusicTrackScopeEnabled
? Math.max(0, pianoRollScrollRequest)
: Math.max(0, pianoRollScrollRequest - activeRegion.getStartFromBeat()),
sheetMeasureMetrics
)
: (() => {
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || 40;
return pianoRollScrollRequest * beatWidth;
})();
// Center the playhead in the visible grid area (excluding the 60px sticky piano keys panel)
const keysWidth = sheetMusicViewEnabled
? 0
: (parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60);
const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2;
const clampedScrollLeft = Math.max(
0,
Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth)
);
container.scrollLeft = clampedScrollLeft;
// Clear the request after handling
useProjectStore.setState({ pianoRollScrollRequest: null });
}, [pianoRollScrollRequest, sheetMusicViewEnabled, sheetMusicTrackScopeEnabled, sheetMeasureMetrics, activeRegion]);
// Update --region-grid-beat-width when zoom changes and preserve the centered beat position.
useLayoutEffect(() => {
const beatWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * pianoRollZoom;
document.documentElement.style.setProperty('--region-grid-beat-width', `${beatWidth}px`);
if (triggerNoteUpdateRef.current) {
triggerNoteUpdateRef.current(prev => prev + 1);
}
const anchorBeat = pendingZoomAnchorBeatRef.current;
const container = pianoRollNoteScrollRef.current;
if (anchorBeat !== null && container) {
const keysWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')
) || 60;
const visibleMusicWidth = Math.max(0, container.clientWidth - keysWidth);
const targetPixel = anchorBeat * beatWidth;
const targetScrollLeft = targetPixel - visibleMusicWidth / 2;
const clampedScrollLeft = Math.max(
0,
Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth)
);
pianoRollExpectedScrollLeftRef.current = clampedScrollLeft;
container.scrollLeft = clampedScrollLeft;
pendingZoomAnchorBeatRef.current = null;
}
return () => {
document.documentElement.style.setProperty('--region-grid-beat-width', '40px');
};
}, [pianoRollZoom]);
// Scroll horizontally to the active region's starting position
useEffect(() => {
if (!pianoRollNoteScrollRef.current || !activeRegion) {
previousActiveRegionIdRef.current = activeRegion?.getId() ?? null;
return;
}
if (pendingModeSwitchRequestRef.current !== null) {
previousActiveRegionIdRef.current = activeRegion.getId();
return;
}
if (previousActiveRegionIdRef.current !== activeRegion.getId()) {
// Get the starting beat of the region
const startBeat = activeRegion.getStartFromBeat();
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Scrolling to region's starting position: startBeat=${startBeat}`);
}
const scrollPosition = getRegionStartScrollLeft(startBeat);
// Scroll to the calculated position
pianoRollNoteScrollRef.current.scrollLeft = Math.max(0, scrollPosition);
previousActiveRegionIdRef.current = activeRegion.getId();
}
}, [activeRegion]);
useLayoutEffect(() => {
const request = pendingModeSwitchRequestRef.current;
const container = pianoRollNoteScrollRef.current;
if (!request || !container || !activeRegion) {
return;
}
if (request.destinationSheetMusicViewEnabled && sheetMeasureMetrics.length === 0) {
return;
}
const targetScrollLeft = getScrollLeftForViewportRequest({
request,
container,
sheetMeasureMetrics,
activeRegionStartBeat: activeRegion.getStartFromBeat(),
activeRegionEndBeat: activeRegion.getStartFromBeat() + activeRegion.getLength(),
songEndBeat: maxBars * timeSignature.numerator,
});
pianoRollExpectedScrollLeftRef.current = targetScrollLeft;
container.scrollLeft = targetScrollLeft;
pendingModeSwitchRequestRef.current = null;
}, [activeRegion, maxBars, sheetMeasureMetrics, sheetMusicTrackScopeEnabled, sheetMusicViewEnabled, timeSignature.numerator]);
// Add keyboard event listener for piano roll hotkeys (snapping and quantization)
useEffect(() => {
const handlePianoRollKeyDown = (event: KeyboardEvent) => {
// Skip if user is typing in an input field (including ChatBox)
const target = event.target as HTMLElement;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.hasAttribute('data-chatbox-input') ||
target.closest('.chatbox-input')
)) {
return;
}
// Handle delete key for selected notes
if (event.key === 'Backspace' || event.key === 'Delete') {
if (deleteSelectedNotesRef.current) {
const deleted = deleteSelectedNotesRef.current();
if (deleted) {
// Prevent default behavior only if notes were actually deleted
event.preventDefault();
}
}
return;
}
// Handle piano roll hotkeys
const configManager = ConfigManager.instance();
if (configManager.getIsInitialized()) {
// Chord guide switch hotkey
const switch_key = configManager.get('hotkeys.piano_roll.switch') as string;
if (event.key && event.key.toLowerCase() === switch_key.toLowerCase()) {
// Call the switchChord function exposed by PianoGrid
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const switchChord = (window as any).__pianoGridSwitchChord;
if (typeof switchChord === 'function') {
switchChord();
event.preventDefault();
}
return;
}
// Snapping hotkeys
const snap_none_key = configManager.get('hotkeys.piano_roll.snap_none') as string;
const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string;
const snap_1_8_key = configManager.get('hotkeys.piano_roll.snap_1_8') as string;
const snap_1_16_key = configManager.get('hotkeys.piano_roll.snap_1_16') as string;
// Quantize position hotkeys
const qua_pos_1_4_key = configManager.get('hotkeys.piano_roll.qua_pos_1_4') as string;
const qua_pos_1_8_key = configManager.get('hotkeys.piano_roll.qua_pos_1_8') as string;
const qua_pos_1_16_key = configManager.get('hotkeys.piano_roll.qua_pos_1_16') as string;
// Quantize length hotkeys
const qua_len_1_4_key = configManager.get('hotkeys.piano_roll.qua_len_1_4') as string;
const qua_len_1_8_key = configManager.get('hotkeys.piano_roll.qua_len_1_8') as string;
const qua_len_1_16_key = configManager.get('hotkeys.piano_roll.qua_len_1_16') as string;
let actionType: 'snap' | 'quantize' | null = null;
let actionValue: string | null = null;
let quantType: 'position' | 'length' | null = null;
// Check snapping hotkeys
if (event.key === snap_none_key) {
actionType = 'snap';
actionValue = 'NO SNAP';
} else if (event.key === snap_1_4_key) {
actionType = 'snap';
actionValue = '1/4';
} else if (event.key === snap_1_8_key) {
actionType = 'snap';
actionValue = '1/8';
} else if (event.key === snap_1_16_key) {
actionType = 'snap';
actionValue = '1/16';
}
// Check quantize position hotkeys
else if (event.key === qua_pos_1_4_key) {
actionType = 'quantize';
actionValue = '1/4';
quantType = 'position';
} else if (event.key === qua_pos_1_8_key) {
actionType = 'quantize';
actionValue = '1/8';
quantType = 'position';
} else if (event.key === qua_pos_1_16_key) {
actionType = 'quantize';
actionValue = '1/16';
quantType = 'position';
}
// Check quantize length hotkeys
else if (event.key === qua_len_1_4_key) {
actionType = 'quantize';
actionValue = '1/4';
quantType = 'length';
} else if (event.key === qua_len_1_8_key) {
actionType = 'quantize';
actionValue = '1/8';
quantType = 'length';
} else if (event.key === qua_len_1_16_key) {
actionType = 'quantize';
actionValue = '1/16';
quantType = 'length';
}
if (actionType && actionValue) {
// Prevent default behavior
event.preventDefault();
if (actionType === 'snap') {
// Validate the snap value exists in snap options
if (KGPianoRollState.SNAP_OPTIONS.includes(actionValue)) {
// Change snapping value
handleSnappingSelect(actionValue);
// Trigger blink effect for visual feedback
setBlinkButton('snapping');
setTimeout(() => setBlinkButton(null), 200);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Snap hotkey triggered: ${event.key}${actionValue}`);
}
}
} else if (actionType === 'quantize' && quantType) {
// Validate the quantValue exists in the appropriate options
const validOptions = quantType === 'length' ? KGPianoRollState.QUANT_LEN_OPTIONS : KGPianoRollState.QUANT_POS_OPTIONS;
if (validOptions.includes(actionValue)) {
// Apply quantization
handleQuantSelect(quantType, actionValue);
// Trigger blink effect for visual feedback
const buttonName = quantType === 'length' ? 'quant-length' : 'quant-position';
setBlinkButton(buttonName);
setTimeout(() => setBlinkButton(null), 200);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantize ${quantType} hotkey triggered: ${event.key}${actionValue}`);
}
}
}
}
}
};
// Add event listener
window.addEventListener('keydown', handlePianoRollKeyDown);
// Remove event listener on cleanup
return () => {
window.removeEventListener('keydown', handlePianoRollKeyDown);
};
}, [handleQuantSelect, handleSnappingSelect]);
// Get the title for the piano roll based on the active region
const getPianoRollTitle = () => {
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";
// Calculate the bar and beat position of the region
const startBeat = activeRegion.getStartFromBeat();
const { bar, beatInBar } = beatsToBar(startBeat, timeSignature);
// Format as 1-indexed bar and beat (bar + 1, beatInBar + 1)
const barNumber = bar + 1;
const beatNumber = beatInBar + 1;
return `${activeRegion.getName()} (at ${barNumber}:${beatNumber})`;
};
return (
<div
className="piano-roll-panel"
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex: 2000
}}
ref={pianoRollRef}
>
<PianoRollHeader
onClose={onClose}
title={getPianoRollTitle()}
isEditingTitle={isEditingTitle}
titleInputValue={titleInputValue}
onTitleClick={handleTitleClick}
onTitleInputChange={setTitleInputValue}
onTitleCommit={() => { void commitTitleEdit(); }}
onTitleCancel={cancelTitleEdit}
onMouseDown={(e) => handleMouseDown(e, 'drag')}
titleInputRef={titleInputRef}
/>
<PianoRollToolbar
sheetMusicViewEnabled={sheetMusicViewEnabled}
onSheetMusicViewToggle={handleSheetMusicViewToggle}
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
onSheetMusicTrackScopeToggle={handleSheetMusicTrackScopeToggle}
sheetQuantization={sheetQuantization}
onSheetQuantizationChange={handleSheetQuantizationChange}
sheetQuantizationOptions={getSheetQuantizationOptions()}
activeTool={activeTool}
onToolSelect={handleToolSelect}
quantPosition={quantPosition}
quantLength={quantLength}
onQuantSelect={handleQuantSelect}
snapping={snapping}
onSnappingSelect={handleSnappingSelect}
selectedMode={selectedMode}
onModeChange={handleModeSelect}
chordGuide={chordGuide}
onChordGuideChange={handleChordGuideSelect}
blinkButton={blinkButton}
mode={mode}
thresholdDb={spectrogramThresholdDb}
onThresholdChange={setSpectrogramThresholdDb}
power={spectrogramPower}
onPowerChange={setSpectrogramPower}
zoom={pianoRollZoom}
onZoomChange={handleZoomChange}
showAutomationControls={!isSpectrogram}
automationEnabled={automationEnabled}
automationType={automationType}
onAutomationToggle={handleAutomationToggle}
onAutomationTypeChange={handleAutomationTypeChange}
onDetectChords={handleDetectChords}
detectingChords={isDetectingChords}
/>
<NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isSpectrogram} activeRegion={activeRegion} />
<PianoRollContent
contentRef={pianoRollContentRef}
noteScrollRef={pianoRollNoteScrollRef}
pianoGridRef={pianoGridRef}
maxBars={maxBars}
timeSignature={timeSignature}
activeRegion={activeRegion}
updateTrack={updateTrack}
tracks={tracks}
onSetNoteUpdateTrigger={handleSetNoteUpdateTrigger}
onSetDeleteNotesTrigger={handleSetDeleteNotesTrigger}
selectedMode={selectedMode}
keySignature={keySignature}
chordGuide={chordGuide}
mode={mode}
audioRegion={audioRegion}
trackId={trackId}
projectName={projectName}
bpm={bpm}
spectrogramThresholdDb={spectrogramThresholdDb}
spectrogramPower={spectrogramPower}
spectrogramHeightResolution={spectrogramHeightResolution}
pianoRollZoom={pianoRollZoom}
automationEnabled={automationEnabled}
automationType={automationType}
automationRedrawVersion={automationRedrawVersion}
sheetMusicViewEnabled={sheetMusicViewEnabled}
sheetMusicTrackScopeEnabled={sheetMusicTrackScopeEnabled}
sheetQuantization={parsedSheetQuantization}
sheetKeySignature={keySignature}
sheetInstrument={activeInstrument}
onSheetMeasureMetricsChange={handleSheetMeasureMetricsChange}
overlayMessage={isDetectingChords ? `Detecting chords… (${detectChordProgressPercent.toString().padStart(2, '0')}% completed)` : null}
overlayProgressPercent={isDetectingChords ? detectChordProgressPercent : null}
/>
<div
className="resize-handle"
onMouseDown={(e) => handleMouseDown(e, 'resize')}
>
<FaGripLines />
</div>
</div>
);
};
export default PianoRoll;