feat: honor MIDI input notes' velocity during recording

This commit is contained in:
Xiaohan-Tian
2026-05-04 19:34:54 -07:00
parent 066946ea64
commit d089456676
7 changed files with 101 additions and 35 deletions
+1 -14
View File
@@ -1,20 +1,7 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { PIANO_ROLL_CONSTANTS, DEBUG_MODE } from '../../constants'; import { PIANO_ROLL_CONSTANTS, DEBUG_MODE } from '../../constants';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
import { velocityToColor } from '../../util/velocityColor';
// Purple (vel=0) → green (vel=64) → red (vel=127), matching Logic Pro
function velocityToColor(v: number, alpha = 1): string {
const lerp = (a: number, b: number, t: number) => Math.round(a + (b - a) * t);
let r: number, g: number, b: number;
if (v <= 64) {
const t = v / 64;
r = lerp(123, 90, t); g = lerp(95, 176, t); b = lerp(160, 106, t);
} else {
const t = (v - 64) / 63;
r = lerp(90, 255, t); g = lerp(176, 85, t); b = lerp(106, 85, t);
}
return alpha === 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
interface PianoNoteProps { interface PianoNoteProps {
id: string; id: string;
+1 -2
View File
@@ -418,8 +418,7 @@
.piano-grid-recording-note { .piano-grid-recording-note {
position: absolute; position: absolute;
background-color: rgba(255, 60, 60, 0.35); border: 1px solid transparent;
border: 1px solid rgba(255, 60, 60, 0.85);
pointer-events: none; pointer-events: none;
z-index: 3; z-index: 3;
} }
@@ -13,6 +13,7 @@ import { useNoteOperations } from '../../hooks/useNoteOperations';
import { useNoteSelection } from '../../hooks/useNoteSelection'; import { useNoteSelection } from '../../hooks/useNoteSelection';
import type { KeySignature } from '../../core/KGProject'; import type { KeySignature } from '../../core/KGProject';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion'; import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { velocityToColor } from '../../util/velocityColor';
interface PianoRollContentProps { interface PianoRollContentProps {
contentRef: React.MutableRefObject<HTMLDivElement | null>; contentRef: React.MutableRefObject<HTMLDivElement | null>;
@@ -259,6 +260,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
top: (107 - note.pitch) * noteHeight, top: (107 - note.pitch) * noteHeight,
width: Math.max((note.endBeat - note.startBeat) * beatWidth, 4), width: Math.max((note.endBeat - note.startBeat) * beatWidth, 4),
height: noteHeight, height: noteHeight,
backgroundColor: velocityToColor(note.velocity, 0.35),
borderColor: velocityToColor(note.velocity, 0.85),
}} }}
/> />
)); ));
+3 -3
View File
@@ -16,7 +16,7 @@ export class KGMidiInput {
private connectedInputs: Map<string, MIDIInput> = new Map(); private connectedInputs: Map<string, MIDIInput> = new Map();
// Recording callbacks // Recording callbacks
private onRecordNoteOn: ((pitch: number) => void) | null = null; private onRecordNoteOn: ((pitch: number, velocity: number) => void) | null = null;
private onRecordNoteOff: ((pitch: number) => void) | null = null; private onRecordNoteOff: ((pitch: number) => void) | null = null;
// Private constructor to prevent direct instantiation // Private constructor to prevent direct instantiation
@@ -165,7 +165,7 @@ export class KGMidiInput {
if (command === 0x90 && velocity > 0) { if (command === 0x90 && velocity > 0) {
console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`); console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`);
this.triggerNoteOn(pitch, velocity); this.triggerNoteOn(pitch, velocity);
this.onRecordNoteOn?.(pitch); this.onRecordNoteOn?.(pitch, velocity);
} }
// Note Off: command = 0x80 (128) or Note On with velocity 0 // Note Off: command = 0x80 (128) or Note On with velocity 0
else if (command === 0x80 || (command === 0x90 && velocity === 0)) { else if (command === 0x80 || (command === 0x90 && velocity === 0)) {
@@ -273,7 +273,7 @@ export class KGMidiInput {
// ===== RECORDING ===== // ===== RECORDING =====
public setRecordingCallbacks( public setRecordingCallbacks(
onNoteOn: ((pitch: number) => void) | null, onNoteOn: ((pitch: number, velocity: number) => void) | null,
onNoteOff: ((pitch: number) => void) | null onNoteOff: ((pitch: number) => void) | null
): void { ): void {
this.onRecordNoteOn = onNoteOn; this.onRecordNoteOn = onNoteOn;
+21 -12
View File
@@ -101,7 +101,7 @@ interface ProjectState {
// Recording state // Recording state
isRecording: boolean; isRecording: boolean;
recordingTargetRegionId: string | null; recordingTargetRegionId: string | null;
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number }>; recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
recordingOriginalPlayhead: number; recordingOriginalPlayhead: number;
// Undo/redo state // Undo/redo state
@@ -199,7 +199,7 @@ interface ProjectState {
} }
// Module-level recording state (not reactive — only used for timing during active recording) // Module-level recording state (not reactive — only used for timing during active recording)
let _recordingActiveNotes: Map<number, number> = new Map(); // pitch → region-relative startBeat let _recordingActiveNotes: Map<number, { startBeat: number; velocity: number }> = new Map(); // pitch → note-on data
let _recordingRegionStartBeat: number = 0; let _recordingRegionStartBeat: number = 0;
function getRecordingLoopEndBeatRelative(): number | null { function getRecordingLoopEndBeatRelative(): number | null {
@@ -883,18 +883,23 @@ export const useProjectStore = create<ProjectState>((set, get) => {
}; };
KGMidiInput.instance().setRecordingCallbacks( KGMidiInput.instance().setRecordingCallbacks(
(pitch: number) => { (pitch: number, velocity: number) => {
const beat = buildCorrectedBeat(); const beat = buildCorrectedBeat();
_recordingActiveNotes.set(pitch, beat); _recordingActiveNotes.set(pitch, { startBeat: beat, velocity });
}, },
(pitch: number) => { (pitch: number) => {
const endBeat = buildCorrectedBeat(); const endBeat = buildCorrectedBeat();
const startBeat = _recordingActiveNotes.get(pitch); const activeNote = _recordingActiveNotes.get(pitch);
if (startBeat !== undefined) { if (activeNote !== undefined) {
_recordingActiveNotes.delete(pitch); _recordingActiveNotes.delete(pitch);
const finalizedEndBeat = finalizeRecordedNote(startBeat, endBeat); const finalizedEndBeat = finalizeRecordedNote(activeNote.startBeat, endBeat);
set(state => ({ set(state => ({
recordingNotes: [...state.recordingNotes, { pitch, startBeat, endBeat: finalizedEndBeat }], recordingNotes: [...state.recordingNotes, {
pitch,
startBeat: activeNote.startBeat,
endBeat: finalizedEndBeat,
velocity: activeNote.velocity,
}],
})); }));
} }
} }
@@ -925,8 +930,13 @@ export const useProjectStore = create<ProjectState>((set, get) => {
const correctionBeats = (playbackDelaySec + recordingOffsetSec) * (bpm / 60); const correctionBeats = (playbackDelaySec + recordingOffsetSec) * (bpm / 60);
const endBeatForHeld = KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat; const endBeatForHeld = KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat;
_recordingActiveNotes.forEach((startBeat, pitch) => { _recordingActiveNotes.forEach((activeNote, pitch) => {
finalNotes.push({ pitch, startBeat, endBeat: finalizeRecordedNote(startBeat, endBeatForHeld) }); finalNotes.push({
pitch,
startBeat: activeNote.startBeat,
endBeat: finalizeRecordedNote(activeNote.startBeat, endBeatForHeld),
velocity: activeNote.velocity,
});
}); });
_recordingActiveNotes.clear(); _recordingActiveNotes.clear();
@@ -938,7 +948,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
startBeat: n.startBeat, startBeat: n.startBeat,
endBeat: n.endBeat, endBeat: n.endBeat,
pitch: n.pitch, pitch: n.pitch,
velocity: 127, velocity: n.velocity,
})); }));
const command = new CreateNotesCommand(noteData); const command = new CreateNotesCommand(noteData);
KGCore.instance().executeCommand(command); KGCore.instance().executeCommand(command);
@@ -1272,4 +1282,3 @@ export const useProjectStore = create<ProjectState>((set, get) => {
@@ -349,7 +349,7 @@ describe('Project Store Synchronization Integration Tests', () => {
expect(noteOff).toBeTypeOf('function'); expect(noteOff).toBeTypeOf('function');
act(() => { act(() => {
noteOn?.(60); noteOn?.(60, 96);
noteOff?.(60); noteOff?.(60);
}); });
@@ -365,6 +365,7 @@ describe('Project Store Synchronization Integration Tests', () => {
expect(mockAudioInterface.stopPlayback).toHaveBeenCalled(); expect(mockAudioInterface.stopPlayback).toHaveBeenCalled();
expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null); expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null);
expect(testRegion.getNotes()).toHaveLength(1); expect(testRegion.getNotes()).toHaveLength(1);
expect(testRegion.getNotes()[0].getVelocity()).toBe(96);
}); });
it('should cut a held looped recording note at the loop end when note off arrives after wrap', async () => { it('should cut a held looped recording note at the loop end when note off arrives after wrap', async () => {
@@ -400,7 +401,7 @@ describe('Project Store Synchronization Integration Tests', () => {
const noteOff = setRecordingCallbacksSpy.mock.calls.at(-1)?.[1]; const noteOff = setRecordingCallbacksSpy.mock.calls.at(-1)?.[1];
act(() => { act(() => {
noteOn?.(60); noteOn?.(60, 72);
noteOff?.(60); noteOff?.(60);
}); });
@@ -412,6 +413,50 @@ describe('Project Store Synchronization Integration Tests', () => {
expect(notes).toHaveLength(1); expect(notes).toHaveLength(1);
expect(notes[0].getStartBeat()).toBeCloseTo(15); expect(notes[0].getStartBeat()).toBeCloseTo(15);
expect(notes[0].getEndBeat()).toBeCloseTo(16); expect(notes[0].getEndBeat()).toBeCloseTo(16);
expect(notes[0].getVelocity()).toBe(72);
});
it('should preserve velocity when finalizing a held recording note on stop', async () => {
const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano');
const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16);
testTrack.addRegion(testRegion);
testProject.setTracks([testTrack]);
await act(async () => {
await useProjectStore.getState().loadProject(testProject);
});
const core = KGCore.instance();
vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined);
vi.spyOn(mockAudioInterface, 'getTransportPosition')
.mockReturnValueOnce(5)
.mockReturnValueOnce(6.5);
const setRecordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks');
const { setActiveRegionId, setPlayheadPosition, startRecording, stopTransport } = useProjectStore.getState();
act(() => {
setActiveRegionId(testRegion.getId());
setPlayheadPosition(4);
});
await act(async () => {
await startRecording();
});
const noteOn = setRecordingCallbacksSpy.mock.calls.at(-1)?.[0];
expect(noteOn).toBeTypeOf('function');
act(() => {
noteOn?.(60, 48);
});
await act(async () => {
await stopTransport();
});
const notes = testRegion.getNotes();
expect(notes).toHaveLength(1);
expect(notes[0].getVelocity()).toBe(48);
}); });
}); });
+23
View File
@@ -0,0 +1,23 @@
// Purple (vel=0) -> green (vel=64) -> red (vel=127), matching the piano roll note colors.
export function velocityToColor(v: number, alpha = 1): string {
const clampedVelocity = Math.max(0, Math.min(127, v));
const lerp = (a: number, b: number, t: number) => Math.round(a + (b - a) * t);
let r: number;
let g: number;
let b: number;
if (clampedVelocity <= 64) {
const t = clampedVelocity / 64;
r = lerp(123, 90, t);
g = lerp(95, 176, t);
b = lerp(160, 106, t);
} else {
const t = (clampedVelocity - 64) / 63;
r = lerp(90, 255, t);
g = lerp(176, 85, t);
b = lerp(106, 85, t);
}
return alpha === 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${alpha})`;
}