midi hotplug

This commit is contained in:
Skyler Lehmkuhl 2025-11-03 09:48:38 -05:00
parent 06314dbf57
commit 5320e14745
7 changed files with 158 additions and 23 deletions

View File

@ -717,6 +717,9 @@ impl Engine {
// Send a live MIDI note on event to the specified track's instrument // Send a live MIDI note on event to the specified track's instrument
self.project.send_midi_note_on(track_id, note, velocity); self.project.send_midi_note_on(track_id, note, velocity);
// Emit event to UI for visual feedback
let _ = self.event_tx.push(AudioEvent::NoteOn(note, velocity));
// If MIDI recording is active on this track, capture the event // If MIDI recording is active on this track, capture the event
if let Some(recording) = &mut self.midi_recording_state { if let Some(recording) = &mut self.midi_recording_state {
if recording.track_id == track_id { if recording.track_id == track_id {
@ -732,6 +735,9 @@ impl Engine {
// Send a live MIDI note off event to the specified track's instrument // Send a live MIDI note off event to the specified track's instrument
self.project.send_midi_note_off(track_id, note); self.project.send_midi_note_off(track_id, note);
// Emit event to UI for visual feedback
let _ = self.event_tx.push(AudioEvent::NoteOff(note));
// If MIDI recording is active on this track, capture the event // If MIDI recording is active on this track, capture the event
if let Some(recording) = &mut self.midi_recording_state { if let Some(recording) = &mut self.midi_recording_state {
if recording.track_id == track_id { if recording.track_id == track_id {

View File

@ -2,11 +2,14 @@ use crate::audio::track::TrackId;
use crate::command::Command; use crate::command::Command;
use midir::{MidiInput, MidiInputConnection}; use midir::{MidiInput, MidiInputConnection};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
/// Manages external MIDI input devices and routes MIDI to the currently active track /// Manages external MIDI input devices and routes MIDI to the currently active track
pub struct MidiInputManager { pub struct MidiInputManager {
connections: Vec<ActiveMidiConnection>, connections: Arc<Mutex<Vec<ActiveMidiConnection>>>,
active_track_id: Arc<Mutex<Option<TrackId>>>, active_track_id: Arc<Mutex<Option<TrackId>>>,
command_tx: Arc<Mutex<rtrb::Producer<Command>>>,
} }
struct ActiveMidiConnection { struct ActiveMidiConnection {
@ -20,11 +23,50 @@ impl MidiInputManager {
/// Create a new MIDI input manager and auto-connect to all available devices /// Create a new MIDI input manager and auto-connect to all available devices
pub fn new(command_tx: rtrb::Producer<Command>) -> Result<Self, String> { pub fn new(command_tx: rtrb::Producer<Command>) -> Result<Self, String> {
let active_track_id = Arc::new(Mutex::new(None)); let active_track_id = Arc::new(Mutex::new(None));
let mut connections = Vec::new(); let connections = Arc::new(Mutex::new(Vec::new()));
// Wrap command producer in Arc<Mutex> for sharing across MIDI callbacks // Wrap command producer in Arc<Mutex> for sharing across MIDI callbacks
let shared_command_tx = Arc::new(Mutex::new(command_tx)); let shared_command_tx = Arc::new(Mutex::new(command_tx));
// Connect to all currently available devices
Self::connect_to_devices(&connections, &shared_command_tx, &active_track_id)?;
// Create the manager
let manager = Self {
connections: connections.clone(),
active_track_id: active_track_id.clone(),
command_tx: shared_command_tx.clone(),
};
// Spawn hot-plug monitoring thread
let hotplug_connections = connections.clone();
let hotplug_command_tx = shared_command_tx.clone();
let hotplug_active_id = active_track_id.clone();
thread::spawn(move || {
loop {
thread::sleep(Duration::from_secs(2)); // Check every 2 seconds
// Try to connect to new devices
if let Err(e) = Self::connect_to_devices(
&hotplug_connections,
&hotplug_command_tx,
&hotplug_active_id,
) {
eprintln!("MIDI hot-plug scan error: {}", e);
}
}
});
Ok(manager)
}
/// Connect to all available MIDI devices (skips already connected devices)
fn connect_to_devices(
connections: &Arc<Mutex<Vec<ActiveMidiConnection>>>,
command_tx: &Arc<Mutex<rtrb::Producer<Command>>>,
active_track_id: &Arc<Mutex<Option<TrackId>>>,
) -> Result<(), String> {
// Initialize MIDI input // Initialize MIDI input
let mut midi_in = MidiInput::new("Lightningbeam") let mut midi_in = MidiInput::new("Lightningbeam")
.map_err(|e| format!("Failed to initialize MIDI input: {}", e))?; .map_err(|e| format!("Failed to initialize MIDI input: {}", e))?;
@ -32,18 +74,31 @@ impl MidiInputManager {
// Get all available MIDI input ports // Get all available MIDI input ports
let ports = midi_in.ports(); let ports = midi_in.ports();
println!("MIDI Input: Found {} device(s)", ports.len()); // Get list of already connected device names
let connected_devices: Vec<String> = {
let conns = connections.lock().unwrap();
conns.iter().map(|c| c.device_name.clone()).collect()
};
// We need to recreate MidiInput for each connection since connect() consumes it
// Store port info first // Store port info first
let mut port_infos = Vec::new(); let mut port_infos = Vec::new();
for port in &ports { for port in &ports {
if let Ok(port_name) = midi_in.port_name(port) { if let Ok(port_name) = midi_in.port_name(port) {
port_infos.push((port.clone(), port_name)); // Skip if already connected
if !connected_devices.contains(&port_name) {
port_infos.push((port.clone(), port_name));
}
} }
} }
// Connect to each available device // If no new devices, return early
if port_infos.is_empty() {
return Ok(());
}
println!("MIDI: Found {} new device(s)", port_infos.len());
// Connect to each new device
for (port, port_name) in port_infos { for (port, port_name) in port_infos {
println!("MIDI: Connecting to device: {}", port_name); println!("MIDI: Connecting to device: {}", port_name);
@ -52,7 +107,7 @@ impl MidiInputManager {
.map_err(|e| format!("Failed to recreate MIDI input: {}", e))?; .map_err(|e| format!("Failed to recreate MIDI input: {}", e))?;
let device_name = port_name.clone(); let device_name = port_name.clone();
let cmd_tx = shared_command_tx.clone(); let cmd_tx = command_tx.clone();
let active_id = active_track_id.clone(); let active_id = active_track_id.clone();
match midi_in.connect( match midi_in.connect(
@ -64,7 +119,8 @@ impl MidiInputManager {
(), (),
) { ) {
Ok(connection) => { Ok(connection) => {
connections.push(ActiveMidiConnection { let mut conns = connections.lock().unwrap();
conns.push(ActiveMidiConnection {
device_name: port_name.clone(), device_name: port_name.clone(),
connection, connection,
}); });
@ -83,12 +139,10 @@ impl MidiInputManager {
} }
} }
println!("MIDI Input: Connected to {} device(s)", connections.len()); let conn_count = connections.lock().unwrap().len();
println!("MIDI Input: Total connected devices: {}", conn_count);
Ok(Self { Ok(())
connections,
active_track_id,
})
} }
/// MIDI input callback - parses MIDI messages and sends commands to audio engine /// MIDI input callback - parses MIDI messages and sends commands to audio engine
@ -185,6 +239,6 @@ impl MidiInputManager {
/// Get the number of connected devices /// Get the number of connected devices
pub fn device_count(&self) -> usize { pub fn device_count(&self) -> usize {
self.connections.len() self.connections.lock().unwrap().len()
} }
} }

View File

@ -44,7 +44,12 @@ impl AudioSystem {
/// ///
/// # Arguments /// # Arguments
/// * `event_emitter` - Optional event emitter for pushing events to external systems /// * `event_emitter` - Optional event emitter for pushing events to external systems
pub fn new(event_emitter: Option<std::sync::Arc<dyn EventEmitter>>) -> Result<Self, String> { /// * `buffer_size` - Audio buffer size in frames (128, 256, 512, 1024, etc.)
/// Smaller = lower latency but higher CPU usage. Default: 256
pub fn new(
event_emitter: Option<std::sync::Arc<dyn EventEmitter>>,
buffer_size: u32,
) -> Result<Self, String> {
let host = cpal::default_host(); let host = cpal::default_host();
// Get output device // Get output device
@ -87,14 +92,38 @@ impl AudioSystem {
} }
} }
// Build output stream // Build output stream with configurable buffer size
let output_config: cpal::StreamConfig = default_output_config.clone().into(); let mut output_config: cpal::StreamConfig = default_output_config.clone().into();
// Set the requested buffer size
output_config.buffer_size = cpal::BufferSize::Fixed(buffer_size);
let mut output_buffer = vec![0.0f32; 16384]; let mut output_buffer = vec![0.0f32; 16384];
// Log audio configuration
println!("Audio Output Configuration:");
println!(" Sample Rate: {} Hz", output_config.sample_rate.0);
println!(" Channels: {}", output_config.channels);
println!(" Buffer Size: {:?}", output_config.buffer_size);
// Calculate expected latency
if let cpal::BufferSize::Fixed(size) = output_config.buffer_size {
let latency_ms = (size as f64 / output_config.sample_rate.0 as f64) * 1000.0;
println!(" Expected Latency: {:.2} ms", latency_ms);
}
let mut first_callback = true;
let output_stream = output_device let output_stream = output_device
.build_output_stream( .build_output_stream(
&output_config, &output_config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
if first_callback {
let frames = data.len() / output_config.channels as usize;
let latency_ms = (frames as f64 / output_config.sample_rate.0 as f64) * 1000.0;
println!("Audio callback buffer size: {} samples ({} frames, {:.2} ms latency)",
data.len(), frames, latency_ms);
first_callback = false;
}
let buf = &mut output_buffer[..data.len()]; let buf = &mut output_buffer[..data.len()];
buf.fill(0.0); buf.fill(0.0);
engine.process(buf); engine.process(buf);

View File

@ -46,8 +46,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let (event_tx, event_rx) = rtrb::RingBuffer::new(256); let (event_tx, event_rx) = rtrb::RingBuffer::new(256);
let emitter = Arc::new(TuiEventEmitter::new(event_tx)); let emitter = Arc::new(TuiEventEmitter::new(event_tx));
// Initialize audio system with event emitter // Initialize audio system with event emitter and default buffer size
let mut audio_system = AudioSystem::new(Some(emitter))?; let mut audio_system = AudioSystem::new(Some(emitter), 256)?;
println!("Audio system initialized:"); println!("Audio system initialized:");
println!(" Sample rate: {} Hz", audio_system.sample_rate); println!(" Sample rate: {} Hz", audio_system.sample_rate);

View File

@ -33,6 +33,7 @@ pub struct AudioState {
controller: Option<EngineController>, controller: Option<EngineController>,
sample_rate: u32, sample_rate: u32,
channels: u32, channels: u32,
buffer_size: u32,
next_track_id: u32, next_track_id: u32,
next_pool_index: usize, next_pool_index: usize,
next_graph_node_id: u32, next_graph_node_id: u32,
@ -46,6 +47,7 @@ impl Default for AudioState {
controller: None, controller: None,
sample_rate: 0, sample_rate: 0,
channels: 0, channels: 0,
buffer_size: 256, // Default buffer size
next_track_id: 0, next_track_id: 0,
next_pool_index: 0, next_pool_index: 0,
next_graph_node_id: 0, next_graph_node_id: 0,
@ -116,9 +118,15 @@ impl EventEmitter for TauriEventEmitter {
pub async fn audio_init( pub async fn audio_init(
state: tauri::State<'_, Arc<Mutex<AudioState>>>, state: tauri::State<'_, Arc<Mutex<AudioState>>>,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
buffer_size: Option<u32>,
) -> Result<String, String> { ) -> Result<String, String> {
let mut audio_state = state.lock().unwrap(); let mut audio_state = state.lock().unwrap();
// Update buffer size if provided (from config)
if let Some(size) = buffer_size {
audio_state.buffer_size = size;
}
// Check if already initialized - if so, reset DAW state (for hot-reload) // Check if already initialized - if so, reset DAW state (for hot-reload)
if let Some(controller) = &mut audio_state.controller { if let Some(controller) = &mut audio_state.controller {
controller.reset(); controller.reset();
@ -134,12 +142,15 @@ pub async fn audio_init(
// Create TauriEventEmitter // Create TauriEventEmitter
let emitter = Arc::new(TauriEventEmitter { app_handle }); let emitter = Arc::new(TauriEventEmitter { app_handle });
// Get buffer size from audio_state (default is 256)
let buffer_size = audio_state.buffer_size;
// AudioSystem handles all cpal initialization internally // AudioSystem handles all cpal initialization internally
let system = AudioSystem::new(Some(emitter))?; let system = AudioSystem::new(Some(emitter), buffer_size)?;
let info = format!( let info = format!(
"Audio initialized: {} Hz, {} ch", "Audio initialized: {} Hz, {} ch, {} frame buffer",
system.sample_rate, system.channels system.sample_rate, system.channels, buffer_size
); );
// Leak the stream to keep it alive for the lifetime of the app // Leak the stream to keep it alive for the lifetime of the app
@ -149,6 +160,7 @@ pub async fn audio_init(
audio_state.controller = Some(system.controller); audio_state.controller = Some(system.controller);
audio_state.sample_rate = system.sample_rate; audio_state.sample_rate = system.sample_rate;
audio_state.channels = system.channels; audio_state.channels = system.channels;
audio_state.buffer_size = buffer_size;
audio_state.next_track_id = 0; audio_state.next_track_id = 0;
audio_state.next_pool_index = 0; audio_state.next_pool_index = 0;
audio_state.next_graph_node_id = 0; audio_state.next_graph_node_id = 0;

View File

@ -751,7 +751,7 @@ window.addEventListener("DOMContentLoaded", () => {
(async () => { (async () => {
try { try {
console.log('Initializing audio system...'); console.log('Initializing audio system...');
const result = await invoke('audio_init'); const result = await invoke('audio_init', { bufferSize: config.audioBufferSize });
console.log('Audio system initialized:', result); console.log('Audio system initialized:', result);
} catch (error) { } catch (error) {
if (error === 'Audio already initialized') { if (error === 'Audio already initialized') {
@ -1297,6 +1297,26 @@ async function handleAudioEvent(event) {
// Preset loaded - layers are already populated during graph reload // Preset loaded - layers are already populated during graph reload
console.log('GraphPresetLoaded event received for track:', event.track_id); console.log('GraphPresetLoaded event received for track:', event.track_id);
break; break;
case 'NoteOn':
// MIDI note started - update virtual piano visual feedback
if (context.pianoWidget) {
context.pianoWidget.pressedKeys.add(event.note);
if (context.pianoRedraw) {
context.pianoRedraw();
}
}
break;
case 'NoteOff':
// MIDI note stopped - update virtual piano visual feedback
if (context.pianoWidget) {
context.pianoWidget.pressedKeys.delete(event.note);
if (context.pianoRedraw) {
context.pianoRedraw();
}
}
break;
} }
} }
@ -10346,6 +10366,18 @@ function showPreferencesDialog() {
<label>Scroll Speed</label> <label>Scroll Speed</label>
<input type="number" id="pref-scroll-speed" min="0.1" max="10" step="0.1" value="${config.scrollSpeed}" /> <input type="number" id="pref-scroll-speed" min="0.1" max="10" step="0.1" value="${config.scrollSpeed}" />
</div> </div>
<div class="form-group">
<label>Audio Buffer Size (frames)</label>
<select id="pref-audio-buffer-size">
<option value="128" ${config.audioBufferSize === 128 ? 'selected' : ''}>128 (~3ms - Low latency)</option>
<option value="256" ${config.audioBufferSize === 256 ? 'selected' : ''}>256 (~6ms - Balanced)</option>
<option value="512" ${config.audioBufferSize === 512 ? 'selected' : ''}>512 (~12ms - Stable)</option>
<option value="1024" ${config.audioBufferSize === 1024 ? 'selected' : ''}>1024 (~23ms - Very stable)</option>
<option value="2048" ${config.audioBufferSize === 2048 ? 'selected' : ''}>2048 (~46ms - Low-end systems)</option>
<option value="4096" ${config.audioBufferSize === 4096 ? 'selected' : ''}>4096 (~93ms - Very low-end systems)</option>
</select>
<small style="display: block; margin-top: 4px; color: #888;">Requires app restart to take effect</small>
</div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" id="pref-reopen-session" ${config.reopenLastSession ? 'checked' : ''} /> <input type="checkbox" id="pref-reopen-session" ${config.reopenLastSession ? 'checked' : ''} />
@ -10392,6 +10424,7 @@ function showPreferencesDialog() {
config.fileWidth = parseInt(dialog.querySelector('#pref-width').value); config.fileWidth = parseInt(dialog.querySelector('#pref-width').value);
config.fileHeight = parseInt(dialog.querySelector('#pref-height').value); config.fileHeight = parseInt(dialog.querySelector('#pref-height').value);
config.scrollSpeed = parseFloat(dialog.querySelector('#pref-scroll-speed').value); config.scrollSpeed = parseFloat(dialog.querySelector('#pref-scroll-speed').value);
config.audioBufferSize = parseInt(dialog.querySelector('#pref-audio-buffer-size').value);
config.reopenLastSession = dialog.querySelector('#pref-reopen-session').checked; config.reopenLastSession = dialog.querySelector('#pref-reopen-session').checked;
config.restoreLayoutFromFile = dialog.querySelector('#pref-restore-layout').checked; config.restoreLayoutFromFile = dialog.querySelector('#pref-restore-layout').checked;
config.debug = dialog.querySelector('#pref-debug').checked; config.debug = dialog.querySelector('#pref-debug').checked;

View File

@ -84,6 +84,7 @@ export let config = {
debug: false, debug: false,
reopenLastSession: false, reopenLastSession: false,
lastImportFilterIndex: 0, // Index of last used filter in import dialog (0=Image, 1=Audio, 2=Lightningbeam) lastImportFilterIndex: 0, // Index of last used filter in import dialog (0=Image, 1=Audio, 2=Lightningbeam)
audioBufferSize: 256, // Audio buffer size in frames (128, 256, 512, 1024, etc. - requires restart)
// Layout settings // Layout settings
currentLayout: "animation", // Current active layout key currentLayout: "animation", // Current active layout key
defaultLayout: "animation", // Default layout for new files defaultLayout: "animation", // Default layout for new files