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"
}
},
"editor": {
"playhead_update_frequency": 10
},
"chatbox": {
"default_open": true
},
"audio": {
"enable_audio_capture_for_screen_sharing": false
"enable_audio_capture_for_screen_sharing": false,
"lookahead_time": 0.05,
"playback_delay": 0.2
},
"templates": {
"custom_instructions": ""
@@ -1,9 +1,15 @@
import React, { useState, useEffect } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager';
import { KGAudioInterface } from '../../../core/audio-interface/KGAudioInterface';
const BehaviorSettings: React.FC = () => {
const [playheadUpdateFrequency, setPlayheadUpdateFrequency] = useState<number>(10);
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 [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]);
const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]);
const configManager = ConfigManager.instance();
@@ -14,7 +20,12 @@ const BehaviorSettings: React.FC = () => {
await configManager.initialize();
}
setPlayheadUpdateFrequency((configManager.get('editor.playhead_update_frequency') as number) ?? 10);
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);
};
@@ -22,12 +33,75 @@ const BehaviorSettings: React.FC = () => {
}, [configManager]);
// 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 boolValue = value === 'yes';
setChatboxDefaultOpen(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 boolValue = value === 'yes';
setEnableAudioCapture(boolValue);
@@ -41,6 +115,28 @@ const BehaviorSettings: React.FC = () => {
</div>
<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">
<h4>Chat Box</h4>
@@ -62,6 +158,60 @@ const BehaviorSettings: React.FC = () => {
<div className="settings-group">
<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">
<label className="settings-label">
Capture Audio for Screen Sharing
+22 -3
View File
@@ -1,6 +1,5 @@
import type { Selectable } from '../components/interfaces';
import { KGProject } from './KGProject';
import { PLAYING_CONSTANTS } from '../constants/uiConstants';
import { KGAudioInterface } from './audio-interface/KGAudioInterface';
import { ConfigManager } from './config/ConfigManager';
import { KGMidiRegion } from './region/KGMidiRegion';
@@ -98,6 +97,11 @@ export class KGCore {
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();
@@ -294,9 +298,14 @@ export class KGCore {
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.onPlaybackUpdate();
}, PLAYING_CONSTANTS.UPDATE_INTERVAL_MS);
}, updateIntervalMs);
}
private stopPlaybackUpdates(): void {
@@ -315,9 +324,19 @@ export class KGCore {
// Calculate current playhead position based on elapsed time
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 beatsPerMs = bpm / (60 * 1000);
const newPosition = this.playbackStartPosition + (elapsedMs * beatsPerMs);
const newPosition = this.playbackStartPosition + (adjustedElapsedMs * beatsPerMs);
// Stop playback at the end of project (maxBars)
const maxBars = this.currentProject.getMaxBars();
+34 -4
View File
@@ -64,6 +64,11 @@ export class KGAudioInterface {
}
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
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
// 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;
if (enableCapture) {
@@ -251,6 +255,10 @@ export class KGAudioInterface {
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 {
// Set project BPM and time signature FIRST (this affects timing calculations)
Tone.Transport.bpm.value = project.getBpm();
@@ -298,15 +306,15 @@ export class KGAudioInterface {
const velocity = note.getVelocity() / 127; // Normalize to 0-1
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) => {
// Check if track should play considering solo logic
const hasSoloedTracks = this.hasSoloedTracks();
if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) {
audioBus.triggerAttackRelease(noteName, noteDuration, time, velocity);
audioBus.triggerAttackRelease(noteName, noteDuration, time + playbackDelay, velocity);
}
}, noteStartTime);
@@ -716,4 +724,26 @@ export class KGAudioInterface {
console.log('Audio context sample rate:', Tone.getContext().sampleRate);
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;
};
};
editor: {
playhead_update_frequency: number;
};
chatbox: {
default_open: boolean;
};
audio: {
enable_audio_capture_for_screen_sharing: boolean;
lookahead_time: number;
playback_delay: number;
};
templates: {
custom_instructions: string;
@@ -223,11 +228,16 @@ export class ConfigManager {
qua_len_1_16: '0'
},
},
editor: {
playhead_update_frequency: 10
},
chatbox: {
default_open: true
},
audio: {
enable_audio_capture_for_screen_sharing: false
enable_audio_capture_for_screen_sharing: false,
lookahead_time: 0.05,
playback_delay: 0.2
},
templates: {
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 { 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) => {