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 { PIANO_ROLL_CONSTANTS, DEBUG_MODE } from '../../constants';
import { useProjectStore } from '../../stores/projectStore';
// 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})`;
}
import { velocityToColor } from '../../util/velocityColor';
interface PianoNoteProps {
id: string;
+1 -2
View File
@@ -418,8 +418,7 @@
.piano-grid-recording-note {
position: absolute;
background-color: rgba(255, 60, 60, 0.35);
border: 1px solid rgba(255, 60, 60, 0.85);
border: 1px solid transparent;
pointer-events: none;
z-index: 3;
}
@@ -13,6 +13,7 @@ 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';
interface PianoRollContentProps {
contentRef: React.MutableRefObject<HTMLDivElement | null>;
@@ -259,6 +260,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
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),
}}
/>
));
+3 -3
View File
@@ -16,7 +16,7 @@ export class KGMidiInput {
private connectedInputs: Map<string, MIDIInput> = new Map();
// 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 constructor to prevent direct instantiation
@@ -165,7 +165,7 @@ export class KGMidiInput {
if (command === 0x90 && velocity > 0) {
console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`);
this.triggerNoteOn(pitch, velocity);
this.onRecordNoteOn?.(pitch);
this.onRecordNoteOn?.(pitch, velocity);
}
// Note Off: command = 0x80 (128) or Note On with velocity 0
else if (command === 0x80 || (command === 0x90 && velocity === 0)) {
@@ -273,7 +273,7 @@ export class KGMidiInput {
// ===== RECORDING =====
public setRecordingCallbacks(
onNoteOn: ((pitch: number) => void) | null,
onNoteOn: ((pitch: number, velocity: number) => void) | null,
onNoteOff: ((pitch: number) => void) | null
): void {
this.onRecordNoteOn = onNoteOn;
+21 -12
View File
@@ -101,7 +101,7 @@ interface ProjectState {
// Recording state
isRecording: boolean;
recordingTargetRegionId: string | null;
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number }>;
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
recordingOriginalPlayhead: number;
// Undo/redo state
@@ -199,7 +199,7 @@ interface ProjectState {
}
// 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;
function getRecordingLoopEndBeatRelative(): number | null {
@@ -883,18 +883,23 @@ export const useProjectStore = create<ProjectState>((set, get) => {
};
KGMidiInput.instance().setRecordingCallbacks(
(pitch: number) => {
(pitch: number, velocity: number) => {
const beat = buildCorrectedBeat();
_recordingActiveNotes.set(pitch, beat);
_recordingActiveNotes.set(pitch, { startBeat: beat, velocity });
},
(pitch: number) => {
const endBeat = buildCorrectedBeat();
const startBeat = _recordingActiveNotes.get(pitch);
if (startBeat !== undefined) {
const activeNote = _recordingActiveNotes.get(pitch);
if (activeNote !== undefined) {
_recordingActiveNotes.delete(pitch);
const finalizedEndBeat = finalizeRecordedNote(startBeat, endBeat);
const finalizedEndBeat = finalizeRecordedNote(activeNote.startBeat, endBeat);
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 endBeatForHeld = KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat;
_recordingActiveNotes.forEach((startBeat, pitch) => {
finalNotes.push({ pitch, startBeat, endBeat: finalizeRecordedNote(startBeat, endBeatForHeld) });
_recordingActiveNotes.forEach((activeNote, pitch) => {
finalNotes.push({
pitch,
startBeat: activeNote.startBeat,
endBeat: finalizeRecordedNote(activeNote.startBeat, endBeatForHeld),
velocity: activeNote.velocity,
});
});
_recordingActiveNotes.clear();
@@ -938,7 +948,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
startBeat: n.startBeat,
endBeat: n.endBeat,
pitch: n.pitch,
velocity: 127,
velocity: n.velocity,
}));
const command = new CreateNotesCommand(noteData);
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');
act(() => {
noteOn?.(60);
noteOn?.(60, 96);
noteOff?.(60);
});
@@ -365,6 +365,7 @@ describe('Project Store Synchronization Integration Tests', () => {
expect(mockAudioInterface.stopPlayback).toHaveBeenCalled();
expect(setRecordingCallbacksSpy).toHaveBeenLastCalledWith(null, null);
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 () => {
@@ -400,7 +401,7 @@ describe('Project Store Synchronization Integration Tests', () => {
const noteOff = setRecordingCallbacksSpy.mock.calls.at(-1)?.[1];
act(() => {
noteOn?.(60);
noteOn?.(60, 72);
noteOff?.(60);
});
@@ -412,6 +413,50 @@ describe('Project Store Synchronization Integration Tests', () => {
expect(notes).toHaveLength(1);
expect(notes[0].getStartBeat()).toBeCloseTo(15);
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})`;
}