Add MIDI input

This commit is contained in:
Skyler Lehmkuhl 2025-11-03 06:16:17 -05:00
parent f6a91abccd
commit 06314dbf57
11 changed files with 462 additions and 11 deletions

89
daw-backend/Cargo.lock generated
View File

@ -17,6 +17,18 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2562ad8dcf0f789f65c6fdaad8a8a9708ed6b488e649da28c01656ad66b8b47"
dependencies = [
"alsa-sys",
"bitflags 1.3.2",
"libc",
"nix",
]
[[package]]
name = "alsa"
version = "0.9.1"
@ -187,6 +199,16 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@ -213,13 +235,33 @@ dependencies = [
"bindgen",
]
[[package]]
name = "coremidi"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a7847ca018a67204508b77cb9e6de670125075f7464fff5f673023378fa34f5"
dependencies = [
"core-foundation",
"core-foundation-sys",
"coremidi-sys",
]
[[package]]
name = "coremidi-sys"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc9504310988d938e49fff1b5f1e56e3dafe39bb1bae580c19660b58b83a191e"
dependencies = [
"core-foundation-sys",
]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"alsa 0.9.1",
"core-foundation-sys",
"coreaudio-rs",
"dasp_sample",
@ -233,7 +275,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows",
"windows 0.54.0",
]
[[package]]
@ -415,6 +457,7 @@ dependencies = [
"dasp_rms",
"dasp_sample",
"dasp_signal",
"midir",
"midly",
"pathdiff",
"petgraph 0.6.5",
@ -682,6 +725,22 @@ version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "midir"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a456444d83e7ead06ae6a5c0a215ed70282947ff3897fb45fcb052b757284731"
dependencies = [
"alsa 0.7.1",
"bitflags 1.3.2",
"coremidi",
"js-sys",
"libc",
"wasm-bindgen",
"web-sys",
"windows 0.43.0",
]
[[package]]
name = "midly"
version = "0.5.3"
@ -738,6 +797,17 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "nix"
version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
@ -1615,6 +1685,21 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04662ed0e3e5630dfa9b26e4cb823b817f1a9addda855d973a9458c236556244"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows"
version = "0.54.0"

View File

@ -8,6 +8,7 @@ cpal = "0.15"
symphonia = { version = "0.5", features = ["all"] }
rtrb = "0.3"
midly = "0.5"
midir = "0.9"
serde = { version = "1.0", features = ["derive"] }
ratatui = "0.26"
crossterm = "0.27"

View File

@ -7,6 +7,7 @@ use crate::audio::project::Project;
use crate::audio::recording::{MidiRecordingState, RecordingState};
use crate::audio::track::{Track, TrackId, TrackNode};
use crate::command::{AudioEvent, Command, Query, QueryResponse};
use crate::io::MidiInputManager;
use petgraph::stable_graph::NodeIndex;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
@ -23,6 +24,7 @@ pub struct Engine {
// Lock-free communication
command_rx: rtrb::Consumer<Command>,
midi_command_rx: Option<rtrb::Consumer<Command>>,
event_tx: rtrb::Producer<AudioEvent>,
query_rx: rtrb::Consumer<Query>,
query_response_tx: rtrb::Producer<QueryResponse>,
@ -50,6 +52,9 @@ pub struct Engine {
// MIDI recording state
midi_recording_state: Option<MidiRecordingState>,
// MIDI input manager for external MIDI devices
midi_input_manager: Option<MidiInputManager>,
}
impl Engine {
@ -76,6 +81,7 @@ impl Engine {
playing: false,
channels,
command_rx,
midi_command_rx: None,
event_tx,
query_rx,
query_response_tx,
@ -89,6 +95,7 @@ impl Engine {
input_rx: None,
recording_progress_counter: 0,
midi_recording_state: None,
midi_input_manager: None,
}
}
@ -97,6 +104,16 @@ impl Engine {
self.input_rx = Some(input_rx);
}
/// Set the MIDI input manager for external MIDI devices
pub fn set_midi_input_manager(&mut self, manager: MidiInputManager) {
self.midi_input_manager = Some(manager);
}
/// Set the MIDI command receiver for external MIDI input
pub fn set_midi_command_rx(&mut self, midi_command_rx: rtrb::Consumer<Command>) {
self.midi_command_rx = Some(midi_command_rx);
}
/// Add an audio track to the engine
pub fn add_track(&mut self, track: Track) -> TrackId {
// For backwards compatibility, we'll extract the track data and add it to the project
@ -182,6 +199,21 @@ impl Engine {
self.handle_command(cmd);
}
// Process all pending MIDI commands
loop {
let midi_cmd = if let Some(ref mut midi_rx) = self.midi_command_rx {
midi_rx.pop().ok()
} else {
None
};
if let Some(cmd) = midi_cmd {
self.handle_command(cmd);
} else {
break;
}
}
// Process all pending queries
while let Ok(query) = self.query_rx.pop() {
self.handle_query(query);
@ -711,6 +743,13 @@ impl Engine {
}
}
Command::SetActiveMidiTrack(track_id) => {
// Update the active MIDI track for external MIDI input routing
if let Some(ref midi_manager) = self.midi_input_manager {
midi_manager.set_active_track(track_id);
}
}
// Node graph commands
Command::GraphAddNode(track_id, node_type, x, y) => {
eprintln!("[DEBUG] GraphAddNode received: track_id={}, node_type={}, x={}, y={}", track_id, node_type, x, y);
@ -2093,6 +2132,11 @@ impl EngineController {
let _ = self.command_tx.push(Command::SendMidiNoteOff(track_id, note));
}
/// Set the active MIDI track for external MIDI input routing
pub fn set_active_midi_track(&mut self, track_id: Option<TrackId>) {
let _ = self.command_tx.push(Command::SetActiveMidiTrack(track_id));
}
// Node graph operations
/// Add a node to a track's instrument graph

View File

@ -113,6 +113,8 @@ pub enum Command {
SendMidiNoteOn(TrackId, u8, u8),
/// Send a live MIDI note off event to a track's instrument (track_id, note)
SendMidiNoteOff(TrackId, u8),
/// Set the active MIDI track for external MIDI input routing (track_id or None)
SetActiveMidiTrack(Option<TrackId>),
// Node graph commands
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)

View File

@ -0,0 +1,190 @@
use crate::audio::track::TrackId;
use crate::command::Command;
use midir::{MidiInput, MidiInputConnection};
use std::sync::{Arc, Mutex};
/// Manages external MIDI input devices and routes MIDI to the currently active track
pub struct MidiInputManager {
connections: Vec<ActiveMidiConnection>,
active_track_id: Arc<Mutex<Option<TrackId>>>,
}
struct ActiveMidiConnection {
#[allow(dead_code)]
device_name: String,
#[allow(dead_code)]
connection: MidiInputConnection<()>,
}
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();
// Wrap command producer in Arc<Mutex> for sharing across MIDI callbacks
let shared_command_tx = Arc::new(Mutex::new(command_tx));
// Initialize MIDI input
let mut midi_in = MidiInput::new("Lightningbeam")
.map_err(|e| format!("Failed to initialize MIDI input: {}", e))?;
// Get all available MIDI input ports
let ports = midi_in.ports();
println!("MIDI Input: Found {} device(s)", ports.len());
// 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));
}
}
// Connect to each available device
for (port, port_name) in port_infos {
println!("MIDI: Connecting to device: {}", port_name);
// Recreate MidiInput for this connection
midi_in = MidiInput::new("Lightningbeam")
.map_err(|e| format!("Failed to recreate MIDI input: {}", e))?;
let device_name = port_name.clone();
let cmd_tx = shared_command_tx.clone();
let active_id = active_track_id.clone();
match midi_in.connect(
&port,
&format!("lightningbeam-{}", port_name),
move |_timestamp, message, _| {
Self::on_midi_message(message, &cmd_tx, &active_id, &device_name);
},
(),
) {
Ok(connection) => {
connections.push(ActiveMidiConnection {
device_name: port_name.clone(),
connection,
});
println!("MIDI: Connected to: {}", port_name);
// Need to recreate MidiInput for next iteration
midi_in = MidiInput::new("Lightningbeam")
.map_err(|e| format!("Failed to recreate MIDI input: {}", e))?;
}
Err(e) => {
eprintln!("MIDI: Failed to connect to {}: {}", port_name, e);
// Recreate MidiInput to continue with other ports
midi_in = MidiInput::new("Lightningbeam")
.map_err(|e| format!("Failed to recreate MIDI input: {}", e))?;
}
}
}
println!("MIDI Input: Connected to {} device(s)", connections.len());
Ok(Self {
connections,
active_track_id,
})
}
/// MIDI input callback - parses MIDI messages and sends commands to audio engine
fn on_midi_message(
message: &[u8],
command_tx: &Mutex<rtrb::Producer<Command>>,
active_track_id: &Arc<Mutex<Option<TrackId>>>,
device_name: &str,
) {
if message.is_empty() {
return;
}
// Get the currently active track
let track_id = {
let active = active_track_id.lock().unwrap();
match *active {
Some(id) => id,
None => {
// No active track, ignore MIDI input
return;
}
}
};
let status_byte = message[0];
let status = status_byte & 0xF0;
let _channel = status_byte & 0x0F;
match status {
0x90 => {
// Note On
if message.len() >= 3 {
let note = message[1];
let velocity = message[2];
// Treat velocity 0 as Note Off (per MIDI spec)
if velocity == 0 {
let mut tx = command_tx.lock().unwrap();
let _ = tx.push(Command::SendMidiNoteOff(track_id, note));
println!("MIDI [{}] Note Off: {} (velocity 0)", device_name, note);
} else {
let mut tx = command_tx.lock().unwrap();
let _ = tx.push(Command::SendMidiNoteOn(track_id, note, velocity));
println!("MIDI [{}] Note On: {} vel {}", device_name, note, velocity);
}
}
}
0x80 => {
// Note Off
if message.len() >= 3 {
let note = message[1];
let mut tx = command_tx.lock().unwrap();
let _ = tx.push(Command::SendMidiNoteOff(track_id, note));
println!("MIDI [{}] Note Off: {}", device_name, note);
}
}
0xB0 => {
// Control Change
if message.len() >= 3 {
let controller = message[1];
let value = message[2];
println!("MIDI [{}] CC: {} = {}", device_name, controller, value);
// TODO: Map to automation lanes in Phase 5
}
}
0xE0 => {
// Pitch Bend
if message.len() >= 3 {
let lsb = message[1] as u16;
let msb = message[2] as u16;
let value = (msb << 7) | lsb;
println!("MIDI [{}] Pitch Bend: {}", device_name, value);
// TODO: Map to pitch automation in Phase 5
}
}
_ => {
// Other MIDI messages (aftertouch, program change, etc.)
// Ignore for now
}
}
}
/// Set the currently active MIDI track
pub fn set_active_track(&self, track_id: Option<TrackId>) {
let mut active = self.active_track_id.lock().unwrap();
*active = track_id;
match track_id {
Some(id) => println!("MIDI Input: Routing to track {}", id),
None => println!("MIDI Input: No active track"),
}
}
/// Get the number of connected devices
pub fn device_count(&self) -> usize {
self.connections.len()
}
}

View File

@ -1,7 +1,9 @@
pub mod audio_file;
pub mod midi_file;
pub mod midi_input;
pub mod wav_writer;
pub use audio_file::{AudioFile, WaveformPeak};
pub use midi_file::load_midi_file;
pub use midi_input::MidiInputManager;
pub use wav_writer::WavWriter;

View File

@ -57,7 +57,7 @@ impl AudioSystem {
let channels = default_output_config.channels() as u32;
// Create queues
let (command_tx, command_rx) = rtrb::RingBuffer::new(256);
let (command_tx, command_rx) = rtrb::RingBuffer::new(512); // Larger buffer for MIDI + UI commands
let (event_tx, event_rx) = rtrb::RingBuffer::new(256);
let (query_tx, query_rx) = rtrb::RingBuffer::new(16); // Smaller buffer for synchronous queries
let (query_response_tx, query_response_rx) = rtrb::RingBuffer::new(16);
@ -72,6 +72,21 @@ impl AudioSystem {
engine.set_input_rx(input_rx);
let controller = engine.get_controller(command_tx, query_tx, query_response_rx);
// Initialize MIDI input manager for external MIDI devices
// Create a separate command channel for MIDI input
let (midi_command_tx, midi_command_rx) = rtrb::RingBuffer::new(256);
match io::MidiInputManager::new(midi_command_tx) {
Ok(midi_manager) => {
println!("MIDI input initialized successfully");
engine.set_midi_input_manager(midi_manager);
engine.set_midi_command_rx(midi_command_rx);
}
Err(e) => {
eprintln!("Warning: Failed to initialize MIDI input: {}", e);
eprintln!("External MIDI controllers will not be available");
}
}
// Build output stream
let output_config: cpal::StreamConfig = default_output_config.clone().into();
let mut output_buffer = vec![0.0f32; 16384];

101
src-tauri/Cargo.lock generated
View File

@ -58,6 +58,18 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2562ad8dcf0f789f65c6fdaad8a8a9708ed6b488e649da28c01656ad66b8b47"
dependencies = [
"alsa-sys",
"bitflags 1.3.2",
"libc",
"nix 0.24.3",
]
[[package]]
name = "alsa"
version = "0.9.1"
@ -584,7 +596,7 @@ dependencies = [
"bitflags 2.8.0",
"block",
"cocoa-foundation",
"core-foundation",
"core-foundation 0.10.0",
"core-graphics",
"foreign-types",
"libc",
@ -599,7 +611,7 @@ checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d"
dependencies = [
"bitflags 2.8.0",
"block",
"core-foundation",
"core-foundation 0.10.0",
"core-graphics-types",
"libc",
"objc",
@ -653,6 +665,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.0"
@ -676,7 +698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.8.0",
"core-foundation",
"core-foundation 0.10.0",
"core-graphics-types",
"foreign-types",
"libc",
@ -689,7 +711,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.8.0",
"core-foundation",
"core-foundation 0.10.0",
"libc",
]
@ -713,13 +735,33 @@ dependencies = [
"bindgen",
]
[[package]]
name = "coremidi"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a7847ca018a67204508b77cb9e6de670125075f7464fff5f673023378fa34f5"
dependencies = [
"core-foundation 0.9.4",
"core-foundation-sys",
"coremidi-sys",
]
[[package]]
name = "coremidi-sys"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc9504310988d938e49fff1b5f1e56e3dafe39bb1bae580c19660b58b83a191e"
dependencies = [
"core-foundation-sys",
]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"alsa 0.9.1",
"core-foundation-sys",
"coreaudio-rs",
"dasp_sample",
@ -1024,6 +1066,7 @@ dependencies = [
"dasp_rms",
"dasp_sample",
"dasp_signal",
"midir",
"midly",
"pathdiff",
"petgraph 0.6.5",
@ -2496,6 +2539,22 @@ dependencies = [
"autocfg",
]
[[package]]
name = "midir"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a456444d83e7ead06ae6a5c0a215ed70282947ff3897fb45fcb052b757284731"
dependencies = [
"alsa 0.7.1",
"bitflags 1.3.2",
"coremidi",
"js-sys",
"libc",
"wasm-bindgen",
"web-sys",
"windows 0.43.0",
]
[[package]]
name = "midly"
version = "0.5.3"
@ -2629,6 +2688,17 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
@ -3691,7 +3761,7 @@ checksum = "6a24763657bff09769a8ccf12c8b8a50416fb035fe199263b4c5071e4e3f006f"
dependencies = [
"ashpd",
"block2",
"core-foundation",
"core-foundation 0.10.0",
"core-foundation-sys",
"glib-sys",
"gobject-sys",
@ -4544,7 +4614,7 @@ checksum = "3731d04d4ac210cd5f344087733943b9bfb1a32654387dad4d1c70de21aee2c9"
dependencies = [
"bitflags 2.8.0",
"cocoa",
"core-foundation",
"core-foundation 0.10.0",
"core-graphics",
"crossbeam-channel",
"dispatch",
@ -5763,6 +5833,21 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04662ed0e3e5630dfa9b26e4cb823b817f1a9addda855d973a9458c236556244"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows"
version = "0.54.0"
@ -6324,7 +6409,7 @@ dependencies = [
"futures-core",
"futures-util",
"hex",
"nix",
"nix 0.29.0",
"ordered-stream",
"serde",
"serde_repr",

View File

@ -510,6 +510,20 @@ pub async fn audio_send_midi_note_off(
}
}
#[tauri::command]
pub async fn audio_set_active_midi_track(
state: tauri::State<'_, Arc<Mutex<AudioState>>>,
track_id: Option<u32>,
) -> Result<(), String> {
let mut audio_state = state.lock().unwrap();
if let Some(controller) = &mut audio_state.controller {
controller.set_active_midi_track(track_id);
Ok(())
} else {
Err("Audio not initialized".to_string())
}
}
#[tauri::command]
pub async fn audio_load_midi_file(
state: tauri::State<'_, Arc<Mutex<AudioState>>>,

View File

@ -217,6 +217,7 @@ pub fn run() {
audio::audio_update_midi_clip_notes,
audio::audio_send_midi_note_on,
audio::audio_send_midi_note_off,
audio::audio_set_active_midi_track,
audio::audio_get_pool_file_info,
audio::audio_get_pool_waveform,
audio::graph_add_node,

View File

@ -2938,6 +2938,18 @@ class TimelineWindowV2 extends Widget {
if (track.object.type === 'midi' || track.object.type === 'audio') {
setTimeout(() => this.context.reloadNodeEditor?.(), 50);
}
// Set active MIDI track for external MIDI input routing
if (track.object.type === 'midi') {
invoke('audio_set_active_midi_track', { trackId: track.object.audioTrackId }).catch(err => {
console.error('Failed to set active MIDI track:', err);
});
}
} else {
// Non-audio track selected, clear active MIDI track
invoke('audio_set_active_midi_track', { trackId: null }).catch(err => {
console.error('Failed to clear active MIDI track:', err);
});
}
// Update the stage UI to reflect selection changes