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
@@ -0,0 +1,146 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import PianoKeys from './PianoKeys';
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void;
const storeState = {
tracks: [createMockMidiTrack({ id: 1 })],
playheadPosition: 0,
isPlaying: false,
};
const audioInterfaceMock = {
getIsInitialized: vi.fn(),
getIsAudioContextStarted: vi.fn(),
startAudioContext: vi.fn(),
triggerNoteAttack: vi.fn(),
releaseNote: vi.fn(),
};
let liveNoteActivityListener: TestLiveNoteActivityListener | null = null;
const midiInputMock = {
addLiveNoteActivityListener: vi.fn((listener: TestLiveNoteActivityListener) => {
liveNoteActivityListener = listener;
}),
removeLiveNoteActivityListener: vi.fn((listener: TestLiveNoteActivityListener) => {
if (liveNoteActivityListener === listener) {
liveNoteActivityListener = null;
}
}),
};
vi.mock('../../stores/projectStore', () => ({
useProjectStore: (selector: (...args: [typeof storeState]) => unknown) => selector(storeState),
}));
vi.mock('../../core/audio-interface/KGAudioInterface', () => ({
KGAudioInterface: {
instance: () => audioInterfaceMock,
},
}));
vi.mock('../../core/midi-input/KGMidiInput', () => ({
KGMidiInput: {
instance: () => midiInputMock,
},
}));
describe('PianoKeys', () => {
const activeRegion = createMockMidiRegion({
trackId: '1',
notes: [createMockMidiNote({ id: 'note-c4', pitch: 60, startBeat: 0, endBeat: 2 })],
});
beforeEach(() => {
storeState.tracks = [createMockMidiTrack({ id: 1 })];
storeState.playheadPosition = 0;
storeState.isPlaying = false;
liveNoteActivityListener = null;
vi.clearAllMocks();
audioInterfaceMock.getIsInitialized.mockReturnValue(true);
audioInterfaceMock.getIsAudioContextStarted.mockReturnValue(true);
audioInterfaceMock.startAudioContext.mockResolvedValue(undefined);
});
it('shows dot and background feedback for mouse preview while held', () => {
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
fireEvent.mouseDown(key);
expect(key.className).toContain('visual-active');
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
fireEvent.mouseUp(key);
expect(key.className).not.toContain('visual-active');
expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument();
});
it('shows MIDI activity dot without background feedback', () => {
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
expect(midiInputMock.addLiveNoteActivityListener).toHaveBeenCalledTimes(1);
act(() => {
liveNoteActivityListener?.({ pitch: 60, isNoteOn: true });
});
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
expect(key.className).not.toContain('visual-active');
act(() => {
liveNoteActivityListener?.({ pitch: 60, isNoteOn: false });
});
expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument();
});
it('shows playback background feedback without dot for sounding notes in the active region', () => {
storeState.isPlaying = true;
storeState.playheadPosition = 1;
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
expect(key.className).toContain('playback-active');
expect(key.className).toContain('visual-active');
expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument();
});
it('preserves source-specific feedback while mouse, MIDI, and playback overlap', () => {
storeState.isPlaying = true;
storeState.playheadPosition = 1;
const { container, rerender } = render(<PianoKeys activeRegion={activeRegion} />);
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
fireEvent.mouseDown(key);
act(() => {
liveNoteActivityListener?.({ pitch: 60, isNoteOn: true });
});
expect(key.className).toContain('visual-active');
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
storeState.isPlaying = false;
rerender(<PianoKeys activeRegion={activeRegion} />);
expect(key.className).toContain('visual-active');
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
fireEvent.mouseUp(key);
expect(key.className).not.toContain('visual-active');
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
act(() => {
liveNoteActivityListener?.({ pitch: 60, isNoteOn: false });
});
expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument();
});
});
+95 -27
View File
@@ -1,30 +1,94 @@
import React, { useState, useRef } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { noteNameToPitch, midiPercussionKeyMap, pitchToNoteNameString } from '../../util/midiUtil'; import { noteNameToPitch, midiPercussionKeyMap } from '../../util/midiUtil';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
import { KGMidiTrack } from '../../core/track/KGMidiTrack'; import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { KGMidiInput, type LiveMidiNoteActivityEvent } from '../../core/midi-input/KGMidiInput';
interface PianoKeysProps { interface PianoKeysProps {
activeRegion: KGMidiRegion | null; activeRegion: KGMidiRegion | null;
} }
function incrementPitchCount(source: Map<number, number>, pitch: number): Map<number, number> {
const next = new Map(source);
next.set(pitch, (next.get(pitch) ?? 0) + 1);
return next;
}
function decrementPitchCount(source: Map<number, number>, pitch: number): Map<number, number> {
const next = new Map(source);
const current = next.get(pitch) ?? 0;
if (current <= 1) {
next.delete(pitch);
} else {
next.set(pitch, current - 1);
}
return next;
}
const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => { const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
const [pressedKeys, setPressedKeys] = useState<Set<string>>(new Set()); const [mouseActivePitches, setMouseActivePitches] = useState<Map<number, number>>(new Map());
const pressedKeysRef = useRef<Set<string>>(new Set()); const [midiActivePitches, setMidiActivePitches] = useState<Map<number, number>>(new Map());
const { tracks } = useProjectStore(); const mouseActivePitchesRef = useRef<Map<number, number>>(new Map());
const tracks = useProjectStore(state => state.tracks);
const playheadPosition = useProjectStore(state => state.playheadPosition);
const isPlaying = useProjectStore(state => state.isPlaying);
// Check if current active region belongs to a drum track // Check if current active region belongs to a drum track
const isDrumTrack = React.useMemo(() => { const isDrumTrack = useMemo(() => {
if (!activeRegion) return false; if (!activeRegion) return false;
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
return track instanceof KGMidiTrack && track.getInstrument() === 'standard'; return track instanceof KGMidiTrack && track.getInstrument() === 'standard';
}, [activeRegion, tracks]); }, [activeRegion, tracks]);
const playbackActivePitches = useMemo(() => {
if (!activeRegion || !isPlaying) {
return new Set<number>();
}
const activePitches = new Set<number>();
const absolutePlayhead = playheadPosition;
const regionStartBeat = activeRegion.getStartFromBeat();
activeRegion.getNotes().forEach(note => {
const startBeat = regionStartBeat + note.getStartBeat();
const endBeat = regionStartBeat + note.getEndBeat();
if (absolutePlayhead >= startBeat && absolutePlayhead < endBeat) {
activePitches.add(note.getPitch());
}
});
return activePitches;
}, [activeRegion, isPlaying, playheadPosition]);
useEffect(() => {
const midiInput = KGMidiInput.instance();
const handleLiveNoteActivity = (event: LiveMidiNoteActivityEvent) => {
setMidiActivePitches(current => (
event.isNoteOn
? incrementPitchCount(current, event.pitch)
: decrementPitchCount(current, event.pitch)
));
};
midiInput.addLiveNoteActivityListener(handleLiveNoteActivity);
return () => {
midiInput.removeLiveNoteActivityListener(handleLiveNoteActivity);
};
}, []);
// Handle mouse down on piano key // Handle mouse down on piano key
const handleKeyMouseDown = (keyId: string) => { const handleKeyMouseDown = (keyId: string) => {
const pitch = noteNameToPitch(keyId);
// Prevent double pressing the same key // Prevent double pressing the same key
if (pressedKeysRef.current.has(keyId)) { if ((mouseActivePitchesRef.current.get(pitch) ?? 0) > 0) {
return; return;
} }
@@ -37,9 +101,6 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
const trackId = activeRegion.getTrackId(); const trackId = activeRegion.getTrackId();
try { try {
// Convert note name to pitch (keyId is always a note name like "C4")
const pitch = noteNameToPitch(keyId);
// Get audio interface and start playing the note // Get audio interface and start playing the note
const audioInterface = KGAudioInterface.instance(); const audioInterface = KGAudioInterface.instance();
if (audioInterface.getIsInitialized()) { if (audioInterface.getIsInitialized()) {
@@ -54,11 +115,9 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
if (audioInterface.getIsAudioContextStarted()) { if (audioInterface.getIsAudioContextStarted()) {
audioInterface.triggerNoteAttack(trackId, pitch, 127); audioInterface.triggerNoteAttack(trackId, pitch, 127);
// Update pressed keys state const nextMouseActivePitches = incrementPitchCount(mouseActivePitchesRef.current, pitch);
const newPressedKeys = new Set(pressedKeysRef.current); mouseActivePitchesRef.current = nextMouseActivePitches;
newPressedKeys.add(keyId); setMouseActivePitches(nextMouseActivePitches);
pressedKeysRef.current = newPressedKeys;
setPressedKeys(newPressedKeys);
console.log(`Started playing piano key: ${keyId} (pitch ${pitch})`); console.log(`Started playing piano key: ${keyId} (pitch ${pitch})`);
} }
@@ -70,8 +129,10 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
// Handle mouse up on piano key // Handle mouse up on piano key
const handleKeyMouseUp = (keyId: string) => { const handleKeyMouseUp = (keyId: string) => {
const pitch = noteNameToPitch(keyId);
// Only release if key was actually pressed // Only release if key was actually pressed
if (!pressedKeysRef.current.has(keyId)) { if ((mouseActivePitchesRef.current.get(pitch) ?? 0) === 0) {
return; return;
} }
@@ -83,19 +144,14 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
const trackId = activeRegion.getTrackId(); const trackId = activeRegion.getTrackId();
try { try {
// Convert note name to pitch (keyId is always a note name like "C4")
const pitch = noteNameToPitch(keyId);
// Get audio interface and stop playing the note // Get audio interface and stop playing the note
const audioInterface = KGAudioInterface.instance(); const audioInterface = KGAudioInterface.instance();
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) { if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
audioInterface.releaseNote(trackId, pitch); audioInterface.releaseNote(trackId, pitch);
// Update pressed keys state const nextMouseActivePitches = decrementPitchCount(mouseActivePitchesRef.current, pitch);
const newPressedKeys = new Set(pressedKeysRef.current); mouseActivePitchesRef.current = nextMouseActivePitches;
newPressedKeys.delete(keyId); setMouseActivePitches(nextMouseActivePitches);
pressedKeysRef.current = newPressedKeys;
setPressedKeys(newPressedKeys);
console.log(`Stopped playing piano key: ${keyId} (pitch ${pitch})`); console.log(`Stopped playing piano key: ${keyId} (pitch ${pitch})`);
} }
@@ -123,14 +179,25 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
const note = notes[i]; const note = notes[i];
const isSharp = note.includes('#'); const isSharp = note.includes('#');
const keyId = `${note}${octave}`; const keyId = `${note}${octave}`;
const isPressed = pressedKeys.has(keyId); const pitch = noteNameToPitch(keyId);
const keyClass = `piano-key ${isSharp ? 'sharp' : 'natural'} ${isPressed ? 'pressed' : ''}`; const isMouseActive = (mouseActivePitches.get(pitch) ?? 0) > 0;
const isMidiActive = (midiActivePitches.get(pitch) ?? 0) > 0;
const isPlaybackActive = playbackActivePitches.has(pitch);
const showIndicator = isMouseActive || isMidiActive;
const hasBackgroundFeedback = isMouseActive || isPlaybackActive;
const keyClass = [
'piano-key',
isSharp ? 'sharp' : 'natural',
isMouseActive ? 'mouse-active' : '',
isMidiActive ? 'midi-active' : '',
isPlaybackActive ? 'playback-active' : '',
hasBackgroundFeedback ? 'visual-active' : '',
].filter(Boolean).join(' ');
const isC = note === 'C'; const isC = note === 'C';
// For drum tracks, show drum labels when available // For drum tracks, show drum labels when available
let labelContent = null; let labelContent = null;
if (isDrumTrack) { if (isDrumTrack) {
const pitch = noteNameToPitch(keyId);
const drumInfo = midiPercussionKeyMap[pitch]; const drumInfo = midiPercussionKeyMap[pitch];
if (drumInfo) { if (drumInfo) {
labelContent = <span className="key-label">{drumInfo.shortName}</span>; labelContent = <span className="key-label">{drumInfo.shortName}</span>;
@@ -153,6 +220,7 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
}} }}
> >
{labelContent} {labelContent}
{showIndicator ? <span className="piano-key-activity-dot" data-testid={`piano-key-dot-${keyId}`} /> : null}
</div> </div>
); );
} }
+30
View File
@@ -572,6 +572,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
border-bottom: 1px solid #3a3a3a; border-bottom: 1px solid #3a3a3a;
position: relative;
transition: background-color 0.08s ease;
} }
.piano-key.natural { .piano-key.natural {
@@ -584,9 +586,37 @@
color: #e0e0e0; color: #e0e0e0;
} }
.piano-key.natural.visual-active {
background-color: #b8b8b8;
}
.piano-key.sharp.visual-active {
background-color: #5a5a5a;
}
.key-label { .key-label {
font-size: 10px; font-size: 10px;
padding-left: 5px; padding-left: 5px;
padding-right: 18px;
position: relative;
z-index: 1;
}
.piano-key-activity-dot {
position: absolute;
right: 6px;
top: 50%;
width: 8px;
height: 8px;
margin-top: -4px;
border-radius: 999px;
background-color: #000;
border: 1px solid #000;
}
.piano-key.sharp .piano-key-activity-dot {
background-color: #fff;
border-color: #fff;
} }
.piano-grid { .piano-grid {
+30 -6
View File
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGAudioTrack } from '../track/KGAudioTrack'; import { KGAudioTrack } from '../track/KGAudioTrack';
import { KGMidiTrack } from '../track/KGMidiTrack'; import { KGMidiTrack } from '../track/KGMidiTrack';
type TestMidiEvent = { data: Uint8Array };
type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void;
const { getStateMock, audioInterfaceMock } = vi.hoisted(() => ({ const { getStateMock, audioInterfaceMock } = vi.hoisted(() => ({
getStateMock: vi.fn(), getStateMock: vi.fn(),
audioInterfaceMock: { audioInterfaceMock: {
@@ -45,19 +48,40 @@ describe('KGMidiInput pitch bend', () => {
it('routes live MIDI note on/off through the live monitoring path', () => { it('routes live MIDI note on/off through the live monitoring path', () => {
const midiInput = KGMidiInput.instance() as unknown as { 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([0x90, 60, 100]) });
midiInput.handleMIDIMessage({ data: new Uint8Array([0x80, 60, 0]) }); midiInput.handleMIDIMessage({ data: new Uint8Array([0x80, 60, 0]) });
expect(audioInterfaceMock.triggerLiveMidiNoteAttack).toHaveBeenCalledWith('1', 60, 100); expect(audioInterfaceMock.triggerLiveMidiNoteAttack).toHaveBeenCalledWith('1', 60, 100);
expect(audioInterfaceMock.releaseLiveMidiNote).toHaveBeenCalledWith('1', 60); 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', () => { it('latches live note ownership to the note-on track', () => {
const midiInput = KGMidiInput.instance() as unknown as { const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (event: { data: Uint8Array }) => void; handleMIDIMessage: (...args: [TestMidiEvent]) => void;
}; };
midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) }); 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', () => { it('normalizes MIDI pitch bend and forwards it to the selected track', () => {
const midiInput = KGMidiInput.instance() as unknown as { const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (event: { data: Uint8Array }) => void; handleMIDIMessage: (...args: [TestMidiEvent]) => void;
}; };
midiInput.handleMIDIMessage({ data: new Uint8Array([0xe0, 0x00, 0x40]) }); 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', () => { it('maps supported CC messages to live expression and sustain for standard pedals', () => {
const midiInput = KGMidiInput.instance() as unknown as { const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (event: { data: Uint8Array }) => void; handleMIDIMessage: (...args: [TestMidiEvent]) => void;
}; };
midiInput.handleMIDIMessage({ data: new Uint8Array([0xb0, 0x01, 0x20]) }); 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', () => { it('calibrates inverted sustain pedals from the first observed CC64 message', () => {
const midiInput = KGMidiInput.instance() as unknown as { const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (event: { data: Uint8Array }) => void; handleMIDIMessage: (...args: [TestMidiEvent]) => void;
}; };
midiInput.handleMIDIMessage({ data: new Uint8Array([0xb0, 0x40, 0x00]) }); midiInput.handleMIDIMessage({ data: new Uint8Array([0xb0, 0x40, 0x00]) });
@@ -123,7 +147,7 @@ describe('KGMidiInput pitch bend', () => {
}); });
const midiInput = KGMidiInput.instance() as unknown as { const midiInput = KGMidiInput.instance() as unknown as {
handleMIDIMessage: (event: { data: Uint8Array }) => void; handleMIDIMessage: (...args: [TestMidiEvent]) => void;
}; };
midiInput.handleMIDIMessage({ data: new Uint8Array([0x90, 60, 100]) }); 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 { useProjectStore } from '../../stores/projectStore';
import { KGMidiTrack } from '../track/KGMidiTrack'; import { KGMidiTrack } from '../track/KGMidiTrack';
export interface LiveMidiNoteActivityEvent {
pitch: number;
isNoteOn: boolean;
}
type LiveNoteActivityListener = (...args: [LiveMidiNoteActivityEvent]) => void;
/** /**
* KGMidiInput - MIDI input manager for the DAW * KGMidiInput - MIDI input manager for the DAW
* Implements the singleton pattern for global MIDI device management * 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 onRecordControlChange: ((controller: number, value: number) => void) | null = null;
private liveNoteTrackOwnership: Map<number, string[]> = new Map(); private liveNoteTrackOwnership: Map<number, string[]> = new Map();
private sustainPolarityInverted: boolean | null = null; private sustainPolarityInverted: boolean | null = null;
private liveNoteActivityListeners: LiveNoteActivityListener[] = [];
// Private constructor to prevent direct instantiation // Private constructor to prevent direct instantiation
private constructor() { private constructor() {
@@ -178,12 +186,14 @@ export class KGMidiInput {
// Note On: command = 0x90 (144) // Note On: command = 0x90 (144)
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.emitLiveNoteActivity({ pitch, isNoteOn: true });
this.triggerNoteOn(pitch, velocity); this.triggerNoteOn(pitch, velocity);
this.onRecordNoteOn?.(pitch, velocity); 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)) {
console.log(`MIDI Note Off: pitch=${pitch}, channel=${channel}`); console.log(`MIDI Note Off: pitch=${pitch}, channel=${channel}`);
this.emitLiveNoteActivity({ pitch, isNoteOn: false });
this.triggerNoteOff(pitch); this.triggerNoteOff(pitch);
this.onRecordNoteOff?.(pitch); this.onRecordNoteOff?.(pitch);
} }
@@ -354,6 +364,16 @@ export class KGMidiInput {
return this.sustainPolarityInverted ? !rawPressed : rawPressed; 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 * Clean up MIDI resources
*/ */
@@ -374,6 +394,7 @@ export class KGMidiInput {
this.isInitialized = false; this.isInitialized = false;
this.liveNoteTrackOwnership.clear(); this.liveNoteTrackOwnership.clear();
this.sustainPolarityInverted = null; this.sustainPolarityInverted = null;
this.liveNoteActivityListeners = [];
console.log("MIDI resources disposed successfully"); console.log("MIDI resources disposed successfully");
} catch (error) { } catch (error) {
@@ -395,6 +416,14 @@ export class KGMidiInput {
this.onRecordControlChange = onControlChange; 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 ===== // ===== GETTERS =====
public getIsInitialized(): boolean { public getIsInitialized(): boolean {