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:
+6
-1
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: ''
|
||||
|
||||
@@ -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 { 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) => {
|
||||
|
||||
Reference in New Issue
Block a user