Merge pull request #23 from KGAudioLab/feat/2025-12-19-midi-keyboard-support

Feat/2025 12 19 midi keyboard support
This commit is contained in:
Xiaohan-Tian
2025-12-21 22:42:32 -08:00
committed by GitHub
7 changed files with 554 additions and 27 deletions
+6 -1
View File
@@ -58,11 +58,16 @@
"qua_len_1_16": "0" "qua_len_1_16": "0"
} }
}, },
"editor": {
"playhead_update_frequency": 10
},
"chatbox": { "chatbox": {
"default_open": true "default_open": true
}, },
"audio": { "audio": {
"enable_audio_capture_for_screen_sharing": false "enable_audio_capture_for_screen_sharing": false,
"lookahead_time": 0.05,
"playback_delay": 0.2
}, },
"templates": { "templates": {
"custom_instructions": "" "custom_instructions": ""
@@ -1,9 +1,15 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager'; import { ConfigManager } from '../../../core/config/ConfigManager';
import { KGAudioInterface } from '../../../core/audio-interface/KGAudioInterface';
const BehaviorSettings: React.FC = () => { const BehaviorSettings: React.FC = () => {
const [playheadUpdateFrequency, setPlayheadUpdateFrequency] = useState<number>(10);
const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState<boolean>(true); const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState<boolean>(true);
const [audioLookaheadTime, setAudioLookaheadTime] = useState<string>('50');
const [playbackDelay, setPlaybackDelay] = useState<string>('200');
const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false); const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false);
const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]);
const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]);
const configManager = ConfigManager.instance(); const configManager = ConfigManager.instance();
@@ -14,7 +20,12 @@ const BehaviorSettings: React.FC = () => {
await configManager.initialize(); await configManager.initialize();
} }
setPlayheadUpdateFrequency((configManager.get('editor.playhead_update_frequency') as number) ?? 10);
setChatboxDefaultOpen((configManager.get('chatbox.default_open') as boolean) ?? true); setChatboxDefaultOpen((configManager.get('chatbox.default_open') as boolean) ?? true);
const lookaheadTimeSeconds = (configManager.get('audio.lookahead_time') as number) ?? 0.05;
setAudioLookaheadTime(((lookaheadTimeSeconds * 1000).toFixed(0)));
const playbackDelaySeconds = (configManager.get('audio.playback_delay') as number) ?? 0.2;
setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0)));
setEnableAudioCapture((configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean) ?? false); setEnableAudioCapture((configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean) ?? false);
}; };
@@ -22,12 +33,75 @@ const BehaviorSettings: React.FC = () => {
}, [configManager]); }, [configManager]);
// Save configuration when values change // Save configuration when values change
const handlePlayheadUpdateFrequencyChange = async (value: string) => {
const numValue = parseInt(value, 10);
setPlayheadUpdateFrequency(numValue);
await configManager.set('editor.playhead_update_frequency', numValue);
console.log(`Playhead update frequency changed to: ${numValue} fps`);
};
const handleChatboxDefaultOpenChange = async (value: string) => { const handleChatboxDefaultOpenChange = async (value: string) => {
const boolValue = value === 'yes'; const boolValue = value === 'yes';
setChatboxDefaultOpen(boolValue); setChatboxDefaultOpen(boolValue);
await configManager.set('chatbox.default_open', boolValue); await configManager.set('chatbox.default_open', boolValue);
}; };
const handleAudioLookaheadTimeChange = async (value: string) => {
// Allow empty string, treat as 0 ms
const numValueMs = value === '' ? 0 : parseFloat(value);
const numValueSeconds = numValueMs / 1000;
const errors: string[] = [];
// Validate the input
if (isNaN(numValueMs)) {
errors.push('Lookahead time must be a valid number');
} else if (numValueSeconds < 0) {
errors.push('Lookahead time must be between 0 and 0.5 seconds (0-500ms)');
} else if (numValueSeconds > 0.5) {
errors.push('Lookahead time must be between 0 and 0.5 seconds (0-500ms)');
}
setLookaheadValidationErrors(errors);
// Only apply if valid
if (errors.length === 0) {
setAudioLookaheadTime(value);
await configManager.set('audio.lookahead_time', numValueSeconds);
// Apply the change immediately without restart
const audioInterface = KGAudioInterface.instance();
audioInterface.setLookaheadTime(numValueSeconds);
console.log(`Audio lookahead time changed to: ${numValueSeconds}s (${numValueMs}ms)`);
}
};
const handlePlaybackDelayChange = async (value: string) => {
// Allow empty string, treat as 0 ms
const numValueMs = value === '' ? 0 : parseFloat(value);
const numValueSeconds = numValueMs / 1000;
const errors: string[] = [];
// Validate the input
if (isNaN(numValueMs)) {
errors.push('Playback delay must be a valid number');
} else if (numValueSeconds < 0) {
errors.push('Playback delay must be between 0 and 0.5 seconds (0-500ms)');
} else if (numValueSeconds > 0.5) {
errors.push('Playback delay must be between 0 and 0.5 seconds (0-500ms)');
}
setPlaybackDelayValidationErrors(errors);
// Only apply if valid
if (errors.length === 0) {
setPlaybackDelay(value);
await configManager.set('audio.playback_delay', numValueSeconds);
console.log(`Playback delay changed to: ${numValueSeconds}s (${numValueMs}ms)`);
}
};
const handleEnableAudioCaptureChange = async (value: string) => { const handleEnableAudioCaptureChange = async (value: string) => {
const boolValue = value === 'yes'; const boolValue = value === 'yes';
setEnableAudioCapture(boolValue); setEnableAudioCapture(boolValue);
@@ -39,8 +113,30 @@ const BehaviorSettings: React.FC = () => {
<div className="settings-section-header"> <div className="settings-section-header">
<h3>Behavior</h3> <h3>Behavior</h3>
</div> </div>
<div className="settings-section-content"> <div className="settings-section-content">
<div className="settings-group">
<h4>Editor</h4>
<div className="settings-item">
<label className="settings-label">
Playhead Update Frequency (fps)
</label>
<select
className="settings-select"
value={playheadUpdateFrequency}
onChange={(e) => handlePlayheadUpdateFrequencyChange(e.target.value)}
>
<option value="10">10</option>
<option value="30">30</option>
<option value="60">60</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Update frequency for the playhead animation during playback. Higher values (60 fps) provide smoother animation but use more CPU. Lower values (10 fps) are more efficient. Changes apply immediately without restart.
</div>
</div>
</div>
<div className="settings-group"> <div className="settings-group">
<h4>Chat Box</h4> <h4>Chat Box</h4>
@@ -61,7 +157,61 @@ const BehaviorSettings: React.FC = () => {
<div className="settings-group"> <div className="settings-group">
<h4>Audio</h4> <h4>Audio</h4>
<div className="settings-item">
<label className="settings-label">
Lookahead Time (ms)
</label>
<input
type="number"
className="settings-select"
value={audioLookaheadTime}
onChange={(e) => handleAudioLookaheadTimeChange(e.target.value)}
min="0"
max="500"
step="1"
/>
{lookaheadValidationErrors.length > 0 && (
<div className="settings-validation-errors">
{lookaheadValidationErrors.map((error, index) => (
<div key={index} className="settings-validation-error">
{error}
</div>
))}
</div>
)}
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Audio scheduling lookahead time (0-500ms). Lower values (10-20ms) reduce MIDI input latency but may cause audio glitches on slower systems. Higher values (100ms+) are better for playback stability. Changes apply immediately without restart.
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Playback Delay (ms)
</label>
<input
type="number"
className="settings-select"
value={playbackDelay}
onChange={(e) => handlePlaybackDelayChange(e.target.value)}
min="0"
max="500"
step="1"
/>
{playbackDelayValidationErrors.length > 0 && (
<div className="settings-validation-errors">
{playbackDelayValidationErrors.map((error, index) => (
<div key={index} className="settings-validation-error">
{error}
</div>
))}
</div>
)}
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Playback will start with a short delay after pressing the start button (0-500ms). Increasing this value might help stabilize playback, especially for the first few ticks if the lookahead value is too low. Changes apply immediately without restart.
</div>
</div>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Capture Audio for Screen Sharing Capture Audio for Screen Sharing
+27 -8
View File
@@ -1,6 +1,5 @@
import type { Selectable } from '../components/interfaces'; import type { Selectable } from '../components/interfaces';
import { KGProject } from './KGProject'; import { KGProject } from './KGProject';
import { PLAYING_CONSTANTS } from '../constants/uiConstants';
import { KGAudioInterface } from './audio-interface/KGAudioInterface'; import { KGAudioInterface } from './audio-interface/KGAudioInterface';
import { ConfigManager } from './config/ConfigManager'; import { ConfigManager } from './config/ConfigManager';
import { KGMidiRegion } from './region/KGMidiRegion'; import { KGMidiRegion } from './region/KGMidiRegion';
@@ -93,18 +92,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);
@@ -293,10 +297,15 @@ export class KGCore {
if (this.playbackIntervalId !== null) { if (this.playbackIntervalId !== null) {
this.stopPlaybackUpdates(); // Clear any existing timer this.stopPlaybackUpdates(); // Clear any existing timer
} }
// Get playhead update frequency from config (in fps)
const configManager = ConfigManager.instance();
const updateFrequency = (configManager.get('editor.playhead_update_frequency') as number) ?? 10;
const updateIntervalMs = 1000 / updateFrequency; // Convert fps to milliseconds
this.playbackIntervalId = window.setInterval(() => { this.playbackIntervalId = window.setInterval(() => {
this.onPlaybackUpdate(); this.onPlaybackUpdate();
}, PLAYING_CONSTANTS.UPDATE_INTERVAL_MS); }, updateIntervalMs);
} }
private stopPlaybackUpdates(): void { private stopPlaybackUpdates(): void {
@@ -315,9 +324,19 @@ export class KGCore {
// Calculate current playhead position based on elapsed time // Calculate current playhead position based on elapsed time
const elapsedMs = performance.now() - this.playbackStartTime; const elapsedMs = performance.now() - this.playbackStartTime;
// Get playback delay from config
const configManager = ConfigManager.instance();
const playbackDelaySeconds = (configManager.get('audio.playback_delay') as number) ?? 0.2;
const playbackDelayMs = playbackDelaySeconds * 1000;
// Subtract the delay from elapsed time for visual sync
// During the initial delay period, playhead stays at start position
const adjustedElapsedMs = Math.max(0, elapsedMs - playbackDelayMs);
const bpm = this.currentProject.getBpm(); const bpm = this.currentProject.getBpm();
const beatsPerMs = bpm / (60 * 1000); const beatsPerMs = bpm / (60 * 1000);
const newPosition = this.playbackStartPosition + (elapsedMs * beatsPerMs); const newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs);
// Stop playback at the end of project (maxBars) // Stop playback at the end of project (maxBars)
const maxBars = this.currentProject.getMaxBars(); const maxBars = this.currentProject.getMaxBars();
+38 -8
View File
@@ -64,6 +64,11 @@ export class KGAudioInterface {
} }
try { try {
const configManager = ConfigManager.instance();
// Reduce lookahead time to 0.05 seconds to improve MIDI input responsiveness
Tone.getContext().lookAhead = configManager.get('audio.lookahead_time') as number;
// Set up master gain for volume control // Set up master gain for volume control
this.masterGain = new Tone.Gain(this.masterVolume).toDestination(); this.masterGain = new Tone.Gain(this.masterVolume).toDestination();
@@ -72,7 +77,6 @@ export class KGAudioInterface {
Tone.Transport.timeSignature = [TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.numerator, TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.denominator]; // Default time signature Tone.Transport.timeSignature = [TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.numerator, TIME_CONSTANTS.DEFAULT_TIME_SIGNATURE.denominator]; // Default time signature
// Check config and setup audio capture if enabled // Check config and setup audio capture if enabled
const configManager = ConfigManager.instance();
const enableCapture = configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean; const enableCapture = configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean;
if (enableCapture) { if (enableCapture) {
@@ -250,7 +254,11 @@ export class KGAudioInterface {
this.clearScheduledEvents(); this.clearScheduledEvents();
console.log("Preparing playback"); console.log("Preparing playback");
// Get playback delay from ConfigManager
const configManager = ConfigManager.instance();
const playbackDelay = (configManager.get('audio.playback_delay') as number) ?? 0.2;
try { try {
// Set project BPM and time signature FIRST (this affects timing calculations) // Set project BPM and time signature FIRST (this affects timing calculations)
Tone.Transport.bpm.value = project.getBpm(); Tone.Transport.bpm.value = project.getBpm();
@@ -292,21 +300,21 @@ export class KGAudioInterface {
// Convert beats to Tone.js time format for scheduling // Convert beats to Tone.js time format for scheduling
const noteStartTime = this.beatsToToneTime(noteStartBeat); const noteStartTime = this.beatsToToneTime(noteStartBeat);
const noteDuration = this.beatsToToneTime(noteDurationBeats); const noteDuration = this.beatsToToneTime(noteDurationBeats);
// Convert MIDI note number to note name // Convert MIDI note number to note name
const noteName = pitchToNoteNameString(note.getPitch()); const noteName = pitchToNoteNameString(note.getPitch());
const velocity = note.getVelocity() / 127; // Normalize to 0-1 const velocity = note.getVelocity() / 127; // Normalize to 0-1
console.log( console.log(
`Scheduling note ${noteName} at beat ${Number(noteStartBeat.toFixed ? noteStartBeat.toFixed(3) : noteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}` `Scheduling note ${noteName} at beat ${Number(noteStartBeat.toFixed ? noteStartBeat.toFixed(3) : noteStartBeat.toLocaleString(undefined, {maximumFractionDigits: 3}))}, Tone time: ${Number(Number(noteStartTime).toFixed(3))}, duration: ${Number(Number(noteDuration).toFixed(3))}, delay: ${playbackDelay}s`
); );
// Schedule the note // Schedule the note with delay offset
const eventId = Tone.Transport.schedule((time) => { const eventId = Tone.Transport.schedule((time) => {
// Check if track should play considering solo logic // Check if track should play considering solo logic
const hasSoloedTracks = this.hasSoloedTracks(); const hasSoloedTracks = this.hasSoloedTracks();
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) { if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
audioBus.triggerAttackRelease(noteName, noteDuration, time, velocity); audioBus.triggerAttackRelease(noteName, noteDuration, time + playbackDelay, velocity);
} }
}, noteStartTime); }, noteStartTime);
@@ -716,4 +724,26 @@ export class KGAudioInterface {
console.log('Audio context sample rate:', Tone.getContext().sampleRate); console.log('Audio context sample rate:', Tone.getContext().sampleRate);
console.log('Audio context state:', Tone.getContext().state); console.log('Audio context state:', Tone.getContext().state);
} }
/**
* Set the audio lookahead time
* Lower values reduce MIDI input latency but may cause audio glitches
* @param seconds Lookahead time in seconds (e.g., 0.01 for 10ms, 0.1 for 100ms)
*/
public setLookaheadTime(seconds: number): void {
try {
Tone.getContext().lookAhead = seconds;
console.log(`Audio lookahead time set to: ${seconds}s (${seconds * 1000}ms)`);
} catch (error) {
console.error('Error setting lookahead time:', error);
}
}
/**
* Get the current audio lookahead time
* @returns Lookahead time in seconds
*/
public getLookaheadTime(): number {
return Tone.getContext().lookAhead;
}
} }
+11 -1
View File
@@ -64,11 +64,16 @@ interface AppConfig {
qua_len_1_16: string; qua_len_1_16: string;
}; };
}; };
editor: {
playhead_update_frequency: number;
};
chatbox: { chatbox: {
default_open: boolean; default_open: boolean;
}; };
audio: { audio: {
enable_audio_capture_for_screen_sharing: boolean; enable_audio_capture_for_screen_sharing: boolean;
lookahead_time: number;
playback_delay: number;
}; };
templates: { templates: {
custom_instructions: string; custom_instructions: string;
@@ -223,11 +228,16 @@ export class ConfigManager {
qua_len_1_16: '0' qua_len_1_16: '0'
}, },
}, },
editor: {
playhead_update_frequency: 10
},
chatbox: { chatbox: {
default_open: true default_open: true
}, },
audio: { audio: {
enable_audio_capture_for_screen_sharing: false enable_audio_capture_for_screen_sharing: false,
lookahead_time: 0.05,
playback_delay: 0.2
}, },
templates: { templates: {
custom_instructions: '' custom_instructions: ''
+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 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) => {