feat: added visual feedback when pressing piano keys
This commit is contained in:
@@ -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]) });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user