feat: implemented editable automation lane in piano roll window

This commit is contained in:
Xiaohan-Tian
2026-05-08 14:39:45 -07:00
parent eead27dbd8
commit 6e5d9466f4
7 changed files with 1010 additions and 179 deletions
+41 -84
View File
@@ -11,6 +11,11 @@ import { KGPianoRollState } from '../core/state/KGPianoRollState';
import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands';
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
import { DeleteMidiEventsCommand } from '../core/commands/note/DeleteMidiEventsCommand';
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent';
import { getSnappedBeatPosition, getSnappedLength } from '../components/piano-roll/pianoRollSnap';
import { useProjectStore } from '../stores/projectStore';
interface UseNoteOperationsProps {
activeRegion: KGMidiRegion | null;
@@ -53,79 +58,6 @@ export const useNoteOperations = ({
// Get KGCore instance for accessing selected items
const core = KGCore.instance();
// Utility function to calculate snapped beat position
const getSnappedBeatPosition = (beatPosition: number, timeSignature: { numerator: number; denominator: number }, useFloorSnapping: boolean = false): number => {
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
// If no snapping is enabled, return the original position
if (currentSnap === 'NO SNAP') {
return beatPosition;
}
// Parse the snap value (e.g., "1/4", "1/8", "1/16", "1/32")
const denominator = parseInt(currentSnap.split('/')[1]);
if (isNaN(denominator)) {
return beatPosition; // Fallback to no snapping if invalid
}
// Calculate the snap 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: timeSigNumerator, denominator: timeSigDenominator } = timeSignature;
// Calculate beats per whole note based on time signature
// const beatsPerWholeNote = timeSigNumerator * (4 / timeSigDenominator);
// Calculate the snap step in beats
// snapStep should ALWAYS be 4 / denominator regardless of time signature
const snapStep = 4 / denominator;
// Choose snapping method: floor for note creation, round for dragging
const snappedPosition = useFloorSnapping
? Math.floor(beatPosition / snapStep) * snapStep
: Math.round(beatPosition / snapStep) * snapStep;
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Snapping (${useFloorSnapping ? 'floor' : 'round'}): ${beatPosition} -> ${snappedPosition} (snap: ${currentSnap}, step: ${snapStep})`);
}
return snappedPosition;
};
// Utility function to calculate snapped note length
const getSnappedLength = (length: number, timeSignature: { numerator: number; denominator: number }): number => {
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
// If no snapping is enabled, return the original length
if (currentSnap === 'NO SNAP') {
return length;
}
// Parse the snap value (e.g., "1/4", "1/8", "1/16", "1/32")
const denominator = parseInt(currentSnap.split('/')[1]);
if (isNaN(denominator)) {
return length; // Fallback to no snapping if invalid
}
// Calculate the snap step in beats using same formula as position snapping
// const { numerator, denominator: timeSigDenominator } = timeSignature;
// const beatsPerWholeNote = numerator * (4 / timeSigDenominator);
// snapStep should ALWAYS be 4 / denominator regardless of time signature
const snapStep = 4 / denominator;
// Snap length to nearest multiple of snap step
const snappedLength = Math.round(length / snapStep) * snapStep;
// Ensure minimum note length is respected
const finalLength = Math.max(PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH, snappedLength);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Length snapping: ${length} -> ${finalLength} (snap: ${currentSnap}, step: ${snapStep})`);
}
return finalLength;
};
// Utility function to delete selected notes from the active region using commands
const deleteSelectedNotes = () => {
if (!activeRegion) return false;
@@ -136,22 +68,36 @@ export const useNoteOperations = ({
item instanceof KGMidiNote &&
activeRegion.getNotes().some(note => note.getId() === item.getId())
) as KGMidiNote[];
const selectedPitchBends = selectedItems.filter(item =>
item instanceof KGMidiPitchBend &&
activeRegion.getPitchBends().some(pitchBend => pitchBend.getId() === item.getId())
) as KGMidiPitchBend[];
const selectedControllerEvents = selectedItems.filter(item =>
item instanceof KGMidiControllerEvent &&
activeRegion.getAllControllerEventsFlattened().some(({ event }) => event.getId() === item.getId())
) as KGMidiControllerEvent[];
if (selectedNotes.length === 0) {
if (selectedNotes.length === 0 && selectedPitchBends.length === 0 && selectedControllerEvents.length === 0) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('No notes selected for deletion');
console.log('No MIDI events selected for deletion');
}
return false;
}
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Deleting ${selectedNotes.length} selected notes using command`);
console.log(`Deleting MIDI events: notes=${selectedNotes.length}, pitchBends=${selectedPitchBends.length}, controllers=${selectedControllerEvents.length}`);
}
// Create and execute the delete notes command
const noteIds = selectedNotes.map(note => note.getId());
const command = new DeleteNotesCommand(noteIds);
const pitchBendIds = selectedPitchBends.map(pitchBend => pitchBend.getId());
const controllerEventIds = selectedControllerEvents.map(controllerEvent => controllerEvent.getId());
const command = pitchBendIds.length > 0 || controllerEventIds.length > 0
? new DeleteMidiEventsCommand(noteIds, pitchBendIds, controllerEventIds)
: new DeleteNotesCommand(noteIds);
core.executeCommand(command);
if (pitchBendIds.length > 0 || controllerEventIds.length > 0) {
useProjectStore.getState().bumpAutomationRedrawVersion();
}
// Find the track that contains this region and update it for UI sync
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
@@ -163,7 +109,7 @@ export const useNoteOperations = ({
setNoteUpdateCounter(prev => prev + 1);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Deleted ${selectedNotes.length} notes using DeleteNotesCommand`);
console.log('Deleted selected MIDI events using command');
}
return true;
@@ -189,7 +135,7 @@ export const useNoteOperations = ({
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
const beatNumber = currentSnap === 'NO SNAP'
? Math.floor(rawBeatNumber) // Snap to 1-beat grid when no snapping is selected
: getSnappedBeatPosition(rawBeatNumber, timeSignature, true); // Use floor snapping for note creation
: getSnappedBeatPosition(rawBeatNumber, currentSnap, true); // Use floor snapping for note creation
// Calculate the pitch (MIDI note number)
// The piano roll is drawn from bottom to top, with higher notes at the top
@@ -418,7 +364,11 @@ export const useNoteOperations = ({
// Apply length snapping to the visual feedback
const newLengthInBeats = newWidth / beatWidth;
const snappedLengthInBeats = getSnappedLength(newLengthInBeats, timeSignature);
const snappedLengthInBeats = getSnappedLength(
newLengthInBeats,
KGPianoRollState.instance().getCurrentSnap(),
PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH,
);
const snappedWidth = snappedLengthInBeats * beatWidth;
// Adjust position if necessary for start resize to maintain snapped length
@@ -507,7 +457,11 @@ export const useNoteOperations = ({
// Apply length snapping to the final resize commit
const originalLength = newEndBeat - newStartBeat;
const snappedLength = getSnappedLength(originalLength, timeSignature);
const snappedLength = getSnappedLength(
originalLength,
KGPianoRollState.instance().getCurrentSnap(),
PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH,
);
// Adjust the note bounds to use the snapped length
if (resizeEdge === 'end') {
@@ -691,7 +645,10 @@ export const useNoteOperations = ({
// Apply horizontal snapping based on current snap setting
const regionStartBeat = activeRegion.getStartFromBeat();
const rawBeatPosition = (rawNewLeft / beatWidth) - regionStartBeat;
const snappedBeatPosition = getSnappedBeatPosition(rawBeatPosition, timeSignature);
const snappedBeatPosition = getSnappedBeatPosition(
rawBeatPosition,
KGPianoRollState.instance().getCurrentSnap(),
);
const snappedLeft = (snappedBeatPosition + regionStartBeat) * beatWidth;
// Use snapped horizontal position, but keep raw vertical position
@@ -894,4 +851,4 @@ export const useNoteOperations = ({
handleNoteDragEnd,
deleteSelectedNotes
};
};
};