feat: added visual feedback when pressing piano keys

This commit is contained in:
Xiaohan-Tian
2026-05-19 17:13:13 -07:00
parent 6db66be471
commit 06e2162eea
5 changed files with 331 additions and 34 deletions
+30 -6
View File
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGAudioTrack } from '../track/KGAudioTrack';
import { KGMidiTrack } from '../track/KGMidiTrack';
type TestMidiEvent = { data: Uint8Array };
type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void;
const { getStateMock, audioInterfaceMock } = vi.hoisted(() => ({
getStateMock: vi.fn(),
audioInterfaceMock: {
@@ -45,19 +48,40 @@ describe('KGMidiInput pitch bend', () => {
it('routes live MIDI note on/off through the live monitoring path', () => {
const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (event: { data: Uint8Array }) => void;
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
addLiveNoteActivityListener: (...args: [TestLiveNoteActivityListener]) => void;
};
const listener = vi.fn();
midiInput.addLiveNoteActivityListener(listener);
midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) });
midiInput.handleMIDIMessage({ data: new Uint8Array([0x80, 60, 0]) });
expect(audioInterfaceMock.triggerLiveMidiNoteAttack).toHaveBeenCalledWith('1', 60, 100);
expect(audioInterfaceMock.releaseLiveMidiNote).toHaveBeenCalledWith('1', 60);
expect(listener).toHaveBeenNthCalledWith(1, { pitch: 60, isNoteOn: true });
expect(listener).toHaveBeenNthCalledWith(2, { pitch: 60, isNoteOn: false });
});
it('stops notifying removed live note activity listeners', () => {
const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
addLiveNoteActivityListener: (...args: [TestLiveNoteActivityListener]) => void;
removeLiveNoteActivityListener: (...args: [TestLiveNoteActivityListener]) => void;
};
const listener = vi.fn();
midiInput.addLiveNoteActivityListener(listener);
midiInput.removeLiveNoteActivityListener(listener);
midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) });
expect(listener).not.toHaveBeenCalled();
});
it('latches live note ownership to the note-on track', () => {
const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (event: { data: Uint8Array }) => void;
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
};
midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) });
@@ -73,7 +97,7 @@ describe('KGMidiInput pitch bend', () => {
it('normalizes MIDI pitch bend and forwards it to the selected track', () => {
const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (event: { data: Uint8Array }) => void;
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
};
midiInput.handleMIDIMessage({ data: new Uint8Array([0xe0, 0x00, 0x40]) });
@@ -87,7 +111,7 @@ describe('KGMidiInput pitch bend', () => {
it('maps supported CC messages to live expression and sustain for standard pedals', () => {
const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (event: { data: Uint8Array }) => void;
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
};
midiInput.handleMIDIMessage({ data: new Uint8Array([0xb0, 0x01, 0x20]) });
@@ -106,7 +130,7 @@ describe('KGMidiInput pitch bend', () => {
it('calibrates inverted sustain pedals from the first observed CC64 message', () => {
const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (event: { data: Uint8Array }) => void;
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
};
midiInput.handleMIDIMessage({ data: new Uint8Array([0xb0, 0x40, 0x00]) });
@@ -123,7 +147,7 @@ describe('KGMidiInput pitch bend', () => {
});
const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (event: { data: Uint8Array }) => void;
handleMIDIMessage: (...args: [TestMidiEvent]) => void;
};
midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) });
+29
View File
@@ -2,6 +2,13 @@ import { KGAudioInterface } from '../audio-interface/KGAudioInterface';
import { useProjectStore } from '../../stores/projectStore';
import { KGMidiTrack } from '../track/KGMidiTrack';
export interface LiveMidiNoteActivityEvent {
pitch: number;
isNoteOn: boolean;
}
type LiveNoteActivityListener = (...args: [LiveMidiNoteActivityEvent]) => void;
/**
* KGMidiInput - MIDI input manager for the DAW
* Implements the singleton pattern for global MIDI device management
@@ -32,6 +39,7 @@ export class KGMidiInput {
private onRecordControlChange: ((controller: number, value: number) => void) | null = null;
private liveNoteTrackOwnership: Map<number, string[]> = new Map();
private sustainPolarityInverted: boolean | null = null;
private liveNoteActivityListeners: LiveNoteActivityListener[] = [];
// Private constructor to prevent direct instantiation
private constructor() {
@@ -178,12 +186,14 @@ export class KGMidiInput {
// Note On: command = 0x90 (144)
if (command === 0x90 && velocity > 0) {
console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`);
this.emitLiveNoteActivity({ pitch, isNoteOn: true });
this.triggerNoteOn(pitch, velocity);
this.onRecordNoteOn?.(pitch, velocity);
}
// Note Off: command = 0x80 (128) or Note On with velocity 0
else if (command === 0x80 || (command === 0x90 && velocity === 0)) {
console.log(`MIDI Note Off: pitch=${pitch}, channel=${channel}`);
this.emitLiveNoteActivity({ pitch, isNoteOn: false });
this.triggerNoteOff(pitch);
this.onRecordNoteOff?.(pitch);
}
@@ -354,6 +364,16 @@ export class KGMidiInput {
return this.sustainPolarityInverted ? !rawPressed : rawPressed;
}
private emitLiveNoteActivity(event: LiveMidiNoteActivityEvent): void {
for (const listener of this.liveNoteActivityListeners) {
try {
listener(event);
} catch {
// Swallow listener errors to avoid disrupting MIDI handling.
}
}
}
/**
* Clean up MIDI resources
*/
@@ -374,6 +394,7 @@ export class KGMidiInput {
this.isInitialized = false;
this.liveNoteTrackOwnership.clear();
this.sustainPolarityInverted = null;
this.liveNoteActivityListeners = [];
console.log("MIDI resources disposed successfully");
} catch (error) {
@@ -395,6 +416,14 @@ export class KGMidiInput {
this.onRecordControlChange = onControlChange;
}
public addLiveNoteActivityListener(listener: LiveNoteActivityListener): void {
this.liveNoteActivityListeners.push(listener);
}
public removeLiveNoteActivityListener(listener: LiveNoteActivityListener): void {
this.liveNoteActivityListeners = this.liveNoteActivityListeners.filter(current => current !== listener);
}
// ===== GETTERS =====
public getIsInitialized(): boolean {