feat: added MIDI keyboard support
This commit is contained in:
+9
-4
@@ -93,18 +93,23 @@ export class KGCore {
|
|||||||
if (this.isPlaying) {
|
if (this.isPlaying) {
|
||||||
await this.stopPlaying();
|
await this.stopPlaying();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispose audio interface
|
// Dispose audio interface
|
||||||
const audioInterface = KGAudioInterface.instance();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
await audioInterface.dispose();
|
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
|
// Dispose config manager
|
||||||
const configManager = ConfigManager.instance();
|
const configManager = ConfigManager.instance();
|
||||||
await configManager.dispose();
|
await configManager.dispose();
|
||||||
|
|
||||||
// Clear playback timer
|
// Clear playback timer
|
||||||
this.stopPlaybackUpdates();
|
this.stopPlaybackUpdates();
|
||||||
|
|
||||||
console.log("KGCore resources disposed successfully");
|
console.log("KGCore resources disposed successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error disposing KGCore resources:", error);
|
console.error("Error disposing KGCore resources:", error);
|
||||||
|
|||||||
@@ -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
@@ -5,11 +5,15 @@ import './index.css';
|
|||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import { KGCore } from './core/KGCore';
|
import { KGCore } from './core/KGCore';
|
||||||
import { KGAudioInterface } from './core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from './core/audio-interface/KGAudioInterface';
|
||||||
|
import { KGMidiInput } from './core/midi-input/KGMidiInput';
|
||||||
import { KGDebugger } from './core/KGDebugger';
|
import { KGDebugger } from './core/KGDebugger';
|
||||||
|
|
||||||
// Initialize KGCore instance
|
// Initialize KGCore instance
|
||||||
await KGCore.instance().initialize();
|
await KGCore.instance().initialize();
|
||||||
|
|
||||||
|
// Initialize KGMidiInput instance
|
||||||
|
await KGMidiInput.instance().initialize();
|
||||||
|
|
||||||
// Attach debugger to global window in development mode
|
// Attach debugger to global window in development mode
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
(window as unknown as { KGDebugger: KGDebugger }).KGDebugger = KGDebugger.instance();
|
(window as unknown as { KGDebugger: KGDebugger }).KGDebugger = KGDebugger.instance();
|
||||||
@@ -31,17 +35,42 @@ const tryStartAudioContext = async () => {
|
|||||||
console.log('Audio context start failed:', error);
|
console.log('Audio context start failed:', error);
|
||||||
audioContextStarted = false; // Allow retry
|
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
|
// Listen for first user interaction
|
||||||
document.addEventListener('click', tryStartAudioContext, { passive: true });
|
document.addEventListener('click', handleFirstInteraction, { passive: true });
|
||||||
document.addEventListener('touchstart', tryStartAudioContext, { passive: true });
|
document.addEventListener('touchstart', handleFirstInteraction, { passive: true });
|
||||||
document.addEventListener('keydown', tryStartAudioContext, { passive: true });
|
document.addEventListener('keydown', handleFirstInteraction, { passive: true });
|
||||||
|
|
||||||
// Add event listener for beforeunload event
|
// Add event listener for beforeunload event
|
||||||
window.addEventListener('beforeunload', (event) => {
|
window.addEventListener('beforeunload', (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user