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
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 let Some(recording) = &mut self.midi_recording_state {
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
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 let Some(recording) = &mut self.midi_recording_state {
if recording.track_id == track_id {

View File

@ -2,11 +2,14 @@ use crate::audio::track::TrackId;
use crate::command::Command;
use midir::{MidiInput, MidiInputConnection};
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
pub struct MidiInputManager {
connections: Vec<ActiveMidiConnection>,
connections: Arc<Mutex<Vec<ActiveMidiConnection>>>,
active_track_id: Arc<Mutex<Option<TrackId>>>,
command_tx: Arc<Mutex<rtrb::Producer<Command>>>,
}
struct ActiveMidiConnection {
@ -20,11 +23,50 @@ impl MidiInputManager {
/// Create a new MIDI input manager and auto-connect to all available devices
pub fn new(command_tx: rtrb::Producer<Command>) -> Result<Self, String> {
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
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
let mut midi_in = MidiInput::new("Lightningbeam")
.map_err(|e| format!("Failed to initialize MIDI input: {}", e))?;
@ -32,18 +74,31 @@ impl MidiInputManager {
// Get all available MIDI input 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
let mut port_infos = Vec::new();
for port in &ports {
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 {
println!("MIDI: Connecting to device: {}", port_name);
@ -52,7 +107,7 @@ impl MidiInputManager {
.map_err(|e| format!("Failed to recreate MIDI input: {}", e))?;
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();
match midi_in.connect(
@ -64,7 +119,8 @@ impl MidiInputManager {
(),
) {
Ok(connection) => {
connections.push(ActiveMidiConnection {
let mut conns = connections.lock().unwrap();
conns.push(ActiveMidiConnection {
device_name: port_name.clone(),
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 {
connections,
active_track_id,
})
Ok(())
}
/// MIDI input callback - parses MIDI messages and sends commands to audio engine
@ -185,6 +239,6 @@ impl MidiInputManager {
/// Get the number of connected devices
pub fn device_count(&self) -> usize {
self.connections.len()
self.connections.lock().unwrap().len()
}
}

View File

@ -44,7 +44,12 @@ impl AudioSystem {
///
/// # Arguments
/// * `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();
// Get output device
@ -87,14 +92,38 @@ impl AudioSystem {
}
}
// Build output stream
let output_config: cpal::StreamConfig = default_output_config.clone().into();
// Build output stream with configurable buffer size
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];
// 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
.build_output_stream(
&output_config,
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()];
buf.fill(0.0);
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 emitter = Arc::new(TuiEventEmitter::new(event_tx));
// Initialize audio system with event emitter
let mut audio_system = AudioSystem::new(Some(emitter))?;
// Initialize audio system with event emitter and default buffer size
let mut audio_system = AudioSystem::new(Some(emitter), 256)?;
println!("Audio system initialized:");
println!(" Sample rate: {} Hz", audio_system.sample_rate);

View File

@ -33,6 +33,7 @@ pub struct AudioState {
controller: Option<EngineController>,
sample_rate: u32,
channels: u32,
buffer_size: u32,
next_track_id: u32,
next_pool_index: usize,
next_graph_node_id: u32,
@ -46,6 +47,7 @@ impl Default for AudioState {
controller: None,
sample_rate: 0,
channels: 0,
buffer_size: 256, // Default buffer size
next_track_id: 0,
next_pool_index: 0,
next_graph_node_id: 0,
@ -116,9 +118,15 @@ impl EventEmitter for TauriEventEmitter {
pub async fn audio_init(
state: tauri::State<'_, Arc<Mutex<AudioState>>>,
app_handle: tauri::AppHandle,
buffer_size: Option<u32>,
) -> Result<String, String> {
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)
if let Some(controller) = &mut audio_state.controller {
controller.reset();
@ -134,12 +142,15 @@ pub async fn audio_init(
// Create TauriEventEmitter
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
let system = AudioSystem::new(Some(emitter))?;
let system = AudioSystem::new(Some(emitter), buffer_size)?;
let info = format!(
"Audio initialized: {} Hz, {} ch",
system.sample_rate, system.channels
"Audio initialized: {} Hz, {} ch, {} frame buffer",
system.sample_rate, system.channels, buffer_size
);
// 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.sample_rate = system.sample_rate;
audio_state.channels = system.channels;
audio_state.buffer_size = buffer_size;
audio_state.next_track_id = 0;
audio_state.next_pool_index = 0;
audio_state.next_graph_node_id = 0;

View File

@ -751,7 +751,7 @@ window.addEventListener("DOMContentLoaded", () => {
(async () => {
try {
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);
} catch (error) {
if (error === 'Audio already initialized') {
@ -1297,6 +1297,26 @@ async function handleAudioEvent(event) {
// Preset loaded - layers are already populated during graph reload
console.log('GraphPresetLoaded event received for track:', event.track_id);
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>
<input type="number" id="pref-scroll-speed" min="0.1" max="10" step="0.1" value="${config.scrollSpeed}" />
</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">
<label>
<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.fileHeight = parseInt(dialog.querySelector('#pref-height').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.restoreLayoutFromFile = dialog.querySelector('#pref-restore-layout').checked;
config.debug = dialog.querySelector('#pref-debug').checked;

View File

@ -84,6 +84,7 @@ export let config = {
debug: false,
reopenLastSession: false,
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
currentLayout: "animation", // Current active layout key
defaultLayout: "animation", // Default layout for new files