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"
|
"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
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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: ''
|
||||||
|
|||||||
@@ -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