diff --git a/src/components/piano-roll/PianoNote.tsx b/src/components/piano-roll/PianoNote.tsx index 00d187a..7d00b11 100644 --- a/src/components/piano-roll/PianoNote.tsx +++ b/src/components/piano-roll/PianoNote.tsx @@ -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; @@ -290,4 +277,4 @@ const PianoNote: React.FC = ({ ); }; -export default PianoNote; \ No newline at end of file +export default PianoNote; diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css index 8108b65..708288f 100644 --- a/src/components/piano-roll/PianoRoll.css +++ b/src/components/piano-roll/PianoRoll.css @@ -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; } diff --git a/src/components/piano-roll/PianoRollContent.tsx b/src/components/piano-roll/PianoRollContent.tsx index 3bea46c..f33fe9a 100644 --- a/src/components/piano-roll/PianoRollContent.tsx +++ b/src/components/piano-roll/PianoRollContent.tsx @@ -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; @@ -259,6 +260,8 @@ const PianoRollContent: React.FC = ({ 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), }} /> )); @@ -311,4 +314,4 @@ const PianoRollContent: React.FC = ({ ); }; -export default PianoRollContent; \ No newline at end of file +export default PianoRollContent; diff --git a/src/core/midi-input/KGMidiInput.ts b/src/core/midi-input/KGMidiInput.ts index eb4aae1..d5301fe 100644 --- a/src/core/midi-input/KGMidiInput.ts +++ b/src/core/midi-input/KGMidiInput.ts @@ -16,7 +16,7 @@ export class KGMidiInput { private connectedInputs: Map = 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; diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 72470d2..0e2193f 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -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 = new Map(); // pitch → region-relative startBeat +let _recordingActiveNotes: Map = new Map(); // pitch → note-on data let _recordingRegionStartBeat: number = 0; function getRecordingLoopEndBeatRelative(): number | null { @@ -883,18 +883,23 @@ export const useProjectStore = create((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((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((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((set, get) => { - diff --git a/src/test/integration/store/project-store-sync.integration.test.ts b/src/test/integration/store/project-store-sync.integration.test.ts index 1bd18c7..8cf8a77 100644 --- a/src/test/integration/store/project-store-sync.integration.test.ts +++ b/src/test/integration/store/project-store-sync.integration.test.ts @@ -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); }); }); diff --git a/src/util/velocityColor.ts b/src/util/velocityColor.ts new file mode 100644 index 0000000..8ae597c --- /dev/null +++ b/src/util/velocityColor.ts @@ -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})`; +}