feat: honor MIDI input notes' velocity during recording
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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})`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user