midi hotplug
This commit is contained in:
parent
06314dbf57
commit
5320e14745
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
35
src/main.js
35
src/main.js
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue