feat: added MIDI keyboard support

This commit is contained in:
Xiaohan-Tian
2025-12-19 11:44:58 -08:00
parent a6ff25d2db
commit d3a2fe1f8d
3 changed files with 329 additions and 11 deletions
+9 -4
View File
@@ -93,18 +93,23 @@ export class KGCore {
if (this.isPlaying) {
await this.stopPlaying();
}
// Dispose audio interface
const audioInterface = KGAudioInterface.instance();
await audioInterface.dispose();
// Dispose MIDI input (dynamic import to avoid circular dependency)
const { KGMidiInput } = await import('./midi-input/KGMidiInput');
const midiInput = KGMidiInput.instance();
await midiInput.dispose();
// Dispose config manager
const configManager = ConfigManager.instance();
await configManager.dispose();
// Clear playback timer
this.stopPlaybackUpdates();
console.log("KGCore resources disposed successfully");
} catch (error) {
console.error("Error disposing KGCore resources:", error);
+284
View File
@@ -0,0 +1,284 @@
import { KGAudioInterface } from '../audio-interface/KGAudioInterface';
import { useProjectStore } from '../../stores/projectStore';
/**
* KGMidiInput - MIDI input manager for the DAW
* Implements the singleton pattern for global MIDI device management
* Handles Web MIDI API integration for keyboard input
*/
export class KGMidiInput {
// Private static instance for singleton pattern
private static _instance: KGMidiInput | null = null;
// MIDI state
private midiAccess: MIDIAccess | null = null;
private isInitialized: boolean = false;
private connectedInputs: Map<string, MIDIInput> = new Map();
// Private constructor to prevent direct instantiation
private constructor() {
console.log("KGMidiInput initialized");
}
/**
* Get the singleton instance of KGMidiInput
* Creates the instance if it doesn't exist yet
*/
public static instance(): KGMidiInput {
if (!KGMidiInput._instance) {
KGMidiInput._instance = new KGMidiInput();
}
return KGMidiInput._instance;
}
/**
* Initialize the MIDI input manager
*/
public async initialize(): Promise<void> {
if (this.isInitialized) {
return;
}
try {
console.log("KGMidiInput ready for MIDI access request");
this.isInitialized = true;
} catch (error) {
console.error("Failed to initialize MIDI input manager:", error);
throw error;
}
}
/**
* Request MIDI access from browser
* This must be called after a user gesture (click, keydown, etc.)
*/
public async requestMIDIAccess(): Promise<void> {
if (this.midiAccess) {
console.log("MIDI access already granted");
return;
}
try {
// Check if Web MIDI API is available
if (!navigator.requestMIDIAccess) {
throw new Error("Web MIDI API is not supported in this browser");
}
// Request MIDI access
this.midiAccess = await navigator.requestMIDIAccess();
console.log("MIDI access granted");
// Set up device listeners
this.setupDeviceListeners();
// Connect to all existing inputs
this.connectToAllInputs();
} catch (error) {
console.error("Failed to request MIDI access:", error);
throw error;
}
}
/**
* Set up listeners for MIDI device connection/disconnection
*/
private setupDeviceListeners(): void {
if (!this.midiAccess) {
return;
}
this.midiAccess.onstatechange = (event: MIDIConnectionEvent) => {
const port = event.port;
if (port && port.type === "input") {
if (port.state === "connected") {
console.log(`MIDI device connected: ${port.name}`);
this.connectToInput(port as MIDIInput);
} else if (port.state === "disconnected") {
console.log(`MIDI device disconnected: ${port.name}`);
this.disconnectFromInput(port.id);
}
}
};
}
/**
* Connect to all available MIDI inputs
*/
private connectToAllInputs(): void {
if (!this.midiAccess) {
return;
}
this.midiAccess.inputs.forEach((input) => {
this.connectToInput(input);
});
console.log(`Connected to ${this.connectedInputs.size} MIDI input device(s)`);
}
/**
* Connect to a specific MIDI input
*/
private connectToInput(input: MIDIInput): void {
// Set up message handler
input.onmidimessage = (event: MIDIMessageEvent) => {
this.handleMIDIMessage(event);
};
// Store the input
this.connectedInputs.set(input.id, input);
console.log(`Listening to MIDI input: ${input.name} (${input.id})`);
}
/**
* Disconnect from a specific MIDI input
*/
private disconnectFromInput(inputId: string): void {
const input = this.connectedInputs.get(inputId);
if (input) {
input.onmidimessage = null;
}
this.connectedInputs.delete(inputId);
}
/**
* Handle incoming MIDI messages
*/
private handleMIDIMessage(event: MIDIMessageEvent): void {
if (!event.data) {
return;
}
const [status, pitch, velocity] = event.data;
// Extract command (high nibble) and channel (low nibble)
const command = status & 0xf0;
const channel = status & 0x0f;
// Note On: command = 0x90 (144)
if (command === 0x90 && velocity > 0) {
console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`);
this.triggerNoteOn(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.triggerNoteOff(pitch);
}
// Control Change: command = 0xB0 (176)
else if (command === 0xb0) {
console.log(`MIDI Control Change: controller=${pitch}, value=${velocity}, channel=${channel}`);
// TODO: Handle control changes (modulation, sustain pedal, etc.)
}
// Pitch Bend: command = 0xE0 (224)
else if (command === 0xe0) {
const pitchBendValue = (velocity << 7) | pitch;
console.log(`MIDI Pitch Bend: value=${pitchBendValue}, channel=${channel}`);
// TODO: Handle pitch bend
}
}
/**
* Trigger note on - play sound for MIDI note
*/
private triggerNoteOn(pitch: number, velocity: number): void {
try {
// Get the selected track ID from the store
const selectedTrackId = useProjectStore.getState().selectedTrackId;
// Don't play if no track is selected
if (!selectedTrackId) {
console.log('No track selected - MIDI input ignored');
return;
}
// Get audio interface and start playing the note
const audioInterface = KGAudioInterface.instance();
if (audioInterface.getIsInitialized()) {
// Try to start audio context if not started yet
if (!audioInterface.getIsAudioContextStarted()) {
audioInterface.startAudioContext().catch(() => {
// Silently fail if still not allowed - browser policy
});
}
// Trigger note attack if audio context is ready
if (audioInterface.getIsAudioContextStarted()) {
audioInterface.triggerNoteAttack(selectedTrackId, pitch, velocity);
console.log(`MIDI triggered note attack: pitch=${pitch}, velocity=${velocity}, track=${selectedTrackId}`);
}
}
} catch (error) {
console.error(`Error triggering MIDI note on (pitch ${pitch}):`, error);
}
}
/**
* Trigger note off - stop sound for MIDI note
*/
private triggerNoteOff(pitch: number): void {
try {
// Get the selected track ID from the store
const selectedTrackId = useProjectStore.getState().selectedTrackId;
// Don't try to release if no track is selected
if (!selectedTrackId) {
return;
}
// Get audio interface and stop playing the note
const audioInterface = KGAudioInterface.instance();
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
audioInterface.releaseNote(selectedTrackId, pitch);
console.log(`MIDI released note: pitch=${pitch}, track=${selectedTrackId}`);
}
} catch (error) {
console.error(`Error triggering MIDI note off (pitch ${pitch}):`, error);
}
}
/**
* Clean up MIDI resources
*/
public async dispose(): Promise<void> {
try {
// Disconnect from all inputs
this.connectedInputs.forEach((input, inputId) => {
this.disconnectFromInput(inputId);
});
this.connectedInputs.clear();
// Clear MIDI access
if (this.midiAccess) {
this.midiAccess.onstatechange = null;
this.midiAccess = null;
}
this.isInitialized = false;
console.log("MIDI resources disposed successfully");
} catch (error) {
console.error("Error disposing MIDI resources:", error);
}
}
// ===== GETTERS =====
public getIsInitialized(): boolean {
return this.isInitialized;
}
public getMIDIAccess(): MIDIAccess | null {
return this.midiAccess;
}
public getConnectedInputs(): MIDIInput[] {
return Array.from(this.connectedInputs.values());
}
public getConnectedInputCount(): number {
return this.connectedInputs.size;
}
}
+36 -7
View File
@@ -5,11 +5,15 @@ import './index.css';
import App from './App.tsx';
import { KGCore } from './core/KGCore';
import { KGAudioInterface } from './core/audio-interface/KGAudioInterface';
import { KGMidiInput } from './core/midi-input/KGMidiInput';
import { KGDebugger } from './core/KGDebugger';
// Initialize KGCore instance
await KGCore.instance().initialize();
// Initialize KGMidiInput instance
await KGMidiInput.instance().initialize();
// Attach debugger to global window in development mode
if (import.meta.env.DEV) {
(window as unknown as { KGDebugger: KGDebugger }).KGDebugger = KGDebugger.instance();
@@ -31,17 +35,42 @@ const tryStartAudioContext = async () => {
console.log('Audio context start failed:', error);
audioContextStarted = false; // Allow retry
}
// Remove listeners after first attempt (whether successful or not)
document.removeEventListener('click', tryStartAudioContext);
document.removeEventListener('touchstart', tryStartAudioContext);
document.removeEventListener('keydown', tryStartAudioContext);
}
};
// Request MIDI access on first user interaction
let midiAccessRequested = false;
const tryRequestMIDIAccess = async () => {
if (!midiAccessRequested) {
midiAccessRequested = true;
try {
const midiInput = KGMidiInput.instance();
if (!midiInput.getMIDIAccess()) {
await midiInput.requestMIDIAccess();
console.log('MIDI access granted on first user interaction');
}
} catch (error) {
console.log('MIDI access failed:', error);
midiAccessRequested = false; // Allow retry
}
}
};
// Combined handler for first user interaction
const handleFirstInteraction = async () => {
await tryStartAudioContext();
await tryRequestMIDIAccess();
// Remove listeners after first attempt
document.removeEventListener('click', handleFirstInteraction);
document.removeEventListener('touchstart', handleFirstInteraction);
document.removeEventListener('keydown', handleFirstInteraction);
};
// Listen for first user interaction
document.addEventListener('click', tryStartAudioContext, { passive: true });
document.addEventListener('touchstart', tryStartAudioContext, { passive: true });
document.addEventListener('keydown', tryStartAudioContext, { passive: true });
document.addEventListener('click', handleFirstInteraction, { passive: true });
document.addEventListener('touchstart', handleFirstInteraction, { passive: true });
document.addEventListener('keydown', handleFirstInteraction, { passive: true });
// Add event listener for beforeunload event
window.addEventListener('beforeunload', (event) => {