From 517571e1914228d709e8ae878eaf3102083596a1 Mon Sep 17 00:00:00 2001 From: julien Date: Tue, 9 Jun 2026 21:03:46 +0200 Subject: [PATCH 01/11] Support N MIDI inputs, each with its own queue Replace the single optional MIDI input with a list of inputs. Each input gets its own queue, condvar and midir connection, plus a dedicated consumer thread that drains only that queue. - run() now takes Vec (empty = standalone/no input) - Conductor::handle_input and Context::handle_input gain an input_id (0-based index of the source input) - Slave mode: the first input marked slave is the clock/transport source; the others are message-only inputs - Per-input port prompt names the input being selected - input_test now drives multiple independent inputs and asserts per-input routing via input-dependent transposition - Patch mseq_core/mseq_tracks to local paths so the workspace builds against the new (breaking) core API during development Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 5 +++ examples/acid_arp_track.rs | 2 +- examples/clock_div_track.rs | 2 +- examples/impl_track.rs | 2 +- examples/midi_track.rs | 2 +- examples/slave_mode.rs | 2 +- mseq_core/src/conductor.rs | 14 +++++++- mseq_core/src/context.rs | 5 +-- mseq_core/tests/input_test.rs | 48 ++++++++++++++++++++------ src/lib.rs | 65 ++++++++++++++++++++++------------- src/midi_connection.rs | 26 ++++++++++---- 11 files changed, 123 insertions(+), 50 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index df7c9af..d36cc41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,3 +30,8 @@ itertools = "0.14.0" [dev-dependencies] env_logger = "0.11.9" rand = "0.10.1" + +# Build against the local workspace crates during development. +[patch.crates-io] +mseq_core = { path = "mseq_core" } +mseq_tracks = { path = "mseq_tracks" } diff --git a/examples/acid_arp_track.rs b/examples/acid_arp_track.rs index dd8899e..7064876 100644 --- a/examples/acid_arp_track.rs +++ b/examples/acid_arp_track.rs @@ -49,7 +49,7 @@ fn main() { MyConductor { acid, arp }, // The midi port will be selected at runtime by the user None, - None, + Vec::new(), ) { println!("An error occured: {:?}", e); } diff --git a/examples/clock_div_track.rs b/examples/clock_div_track.rs index 2160680..a340de6 100644 --- a/examples/clock_div_track.rs +++ b/examples/clock_div_track.rs @@ -43,7 +43,7 @@ fn main() { MyConductor { clk_div }, // The midi port will be selected at runtime by the user None, - None, + Vec::new(), ) { println!("An error occured: {:?}", e); } diff --git a/examples/impl_track.rs b/examples/impl_track.rs index 3adc946..8c4d237 100644 --- a/examples/impl_track.rs +++ b/examples/impl_track.rs @@ -66,7 +66,7 @@ fn main() { }, // The midi port will be selected at runtime by the user None, - None, + Vec::new(), ) { println!("An error occured: {:?}", e); } diff --git a/examples/midi_track.rs b/examples/midi_track.rs index 09cf605..9cd29a4 100644 --- a/examples/midi_track.rs +++ b/examples/midi_track.rs @@ -35,7 +35,7 @@ fn main() { MyConductor { track }, // The midi port will be selected at runtime by the user None, - None, + Vec::new(), ) { println!("An error occured: {:?}", e); } diff --git a/examples/slave_mode.rs b/examples/slave_mode.rs index bb293db..d4a4453 100644 --- a/examples/slave_mode.rs +++ b/examples/slave_mode.rs @@ -49,7 +49,7 @@ fn main() { MyConductor {}, // The midi port will be selected at runtime by the user None, - Some(midi_in_param), + vec![midi_in_param], ) { println!("An error occured: {:?}", e); } diff --git a/mseq_core/src/conductor.rs b/mseq_core/src/conductor.rs index 3140d03..f50b3cd 100644 --- a/mseq_core/src/conductor.rs +++ b/mseq_core/src/conductor.rs @@ -40,6 +40,13 @@ pub trait Conductor { /// The returned `Vec` is passed directly to the MIDI controller or output backend, /// allowing the conductor to immediately produce output in response to the input. /// + /// # Parameters + /// + /// - `input_id`: 0-based index identifying which MIDI input produced the message. It matches the + /// position of the corresponding input in the list of inputs passed to the runtime. When a single + /// input is used, this is always `0`. + /// - `input`: The received [`MidiMessage`]. + /// /// # Intercepted Messages /// /// Depending on the platform-specific implementation, certain MIDI messages such as @@ -49,7 +56,12 @@ pub trait Conductor { /// # Returns /// /// A `Vec` to be sent to the MIDI output immediately. - fn handle_input(&mut self, _input: MidiMessage, _context: &Context) -> Vec { + fn handle_input( + &mut self, + _input_id: usize, + _input: MidiMessage, + _context: &Context, + ) -> Vec { vec![] } } diff --git a/mseq_core/src/context.rs b/mseq_core/src/context.rs index 637f5ed..0d547bd 100644 --- a/mseq_core/src/context.rs +++ b/mseq_core/src/context.rs @@ -165,6 +165,7 @@ impl Context { /// `handle_input` is used internally to enable code reuse across platforms and unify MIDI input processing. pub fn handle_input( &mut self, + input_id: usize, conductor: &mut impl Conductor, controller: &mut MidiController, input_queue: &mut InputQueue, @@ -172,12 +173,12 @@ impl Context { if self.is_paused() { input_queue .drain(..) - .flat_map(|message| conductor.handle_input(message, self)) + .flat_map(|message| conductor.handle_input(input_id, message, self)) .for_each(drop); } else { input_queue .drain(..) - .flat_map(|message| conductor.handle_input(message, self)) + .flat_map(|message| conductor.handle_input(input_id, message, self)) .for_each(|instruction| controller.execute(instruction)); } } diff --git a/mseq_core/tests/input_test.rs b/mseq_core/tests/input_test.rs index 4fccdcc..9ef137b 100644 --- a/mseq_core/tests/input_test.rs +++ b/mseq_core/tests/input_test.rs @@ -6,6 +6,9 @@ use common::*; use mseq_core::*; use std::collections::HashMap; +/// Number of independent MIDI inputs simulated by the test. +const NUM_INPUTS: usize = 2; + struct DebugInputConductor { midi_out: Rc>, } @@ -22,10 +25,13 @@ impl Conductor for DebugInputConductor { return vec![]; } - // Check forwarding worked + // Check forwarding worked: each input transposes by a different amount + // (3 + input_id), so the same incoming note produces one distinct output + // note per input. Here input 0 -> CS4 and input 1 -> D4. if (21..=24).contains(&context.get_step()) { + let midi_out = self.midi_out.borrow(); assert!( - self.midi_out.borrow().notes_on.contains_key(&( + midi_out.notes_on.contains_key(&( 1, MidiNote { note: Note::CS, @@ -35,6 +41,17 @@ impl Conductor for DebugInputConductor { .midi_value() )) ); + assert!( + midi_out.notes_on.contains_key(&( + 1, + MidiNote { + note: Note::D, + octave: 4, + vel: 160, + } + .midi_value() + )) + ); } else { assert!(self.midi_out.borrow().notes_on.is_empty()); } @@ -44,15 +61,19 @@ impl Conductor for DebugInputConductor { fn handle_input( &mut self, + input_id: usize, input: mseq_core::MidiMessage, _context: &Context, ) -> Vec { + // Transpose by an input-dependent amount to prove that `input_id` is + // correctly forwarded and that each input is handled independently. + let semitones = 3 + input_id as i8; match input { mseq_core::MidiMessage::NoteOff { channel, note } => { vec![Instruction::MidiMessage { midi_message: MidiMessage::NoteOff { channel, - note: note.transpose(3), + note: note.transpose(semitones), }, }] } @@ -60,7 +81,7 @@ impl Conductor for DebugInputConductor { vec![Instruction::MidiMessage { midi_message: MidiMessage::NoteOn { channel, - note: note.transpose(3), + note: note.transpose(semitones), }, }] } @@ -69,7 +90,7 @@ impl Conductor for DebugInputConductor { } } -fn input_test_simulation(ctx: &Context, input_queue: &mut InputQueue) { +fn input_test_simulation(ctx: &Context, _input_id: usize, input_queue: &mut InputQueue) { if ctx.get_step() == 20 { input_queue.push_back(MidiMessage::NoteOn { channel: 1, @@ -94,20 +115,25 @@ fn input_test_simulation(ctx: &Context, input_queue: &mut InputQueue) { fn test_conductor_with_input( mut conductor: impl Conductor, mut midi_controller: MidiController, - input_simulation: impl Fn(&Context, &mut InputQueue), + input_simulation: impl Fn(&Context, usize, &mut InputQueue), ) { let mut ctx = Context::default(); - let mut input_queue = InputQueue::new(); + // One independent queue per input. + let mut input_queues: Vec = (0..NUM_INPUTS).map(|_| InputQueue::new()).collect(); conductor.init(&mut ctx); while ctx.is_running() { - // Simulate incoming input - input_simulation(&ctx, &mut input_queue); + // Simulate incoming input on each input + for (input_id, queue) in input_queues.iter_mut().enumerate() { + input_simulation(&ctx, input_id, queue); + } ctx.process_pre_tick(&mut conductor, &mut midi_controller); ctx.process_post_tick(&mut midi_controller); - // Simulate input handling - ctx.handle_input(&mut conductor, &mut midi_controller, &mut input_queue); + // Simulate input handling, one input (and its queue) at a time + for (input_id, queue) in input_queues.iter_mut().enumerate() { + ctx.handle_input(input_id, &mut conductor, &mut midi_controller, queue); + } } midi_controller.finish(); } diff --git a/src/lib.rs b/src/lib.rs index 67e37f5..094c021 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,15 +37,15 @@ //! vec![] //! } //! -//! fn handle_input(&mut self, input: MidiMessage, _ctx: &Context) -> Vec { +//! fn handle_input(&mut self, _input_id: usize, input: MidiMessage, _ctx: &Context) -> Vec { //! vec![] //! } //! } //! //! fn main() -> Result<(), mseq::MSeqError> { //! let conductor = MyConductor; -//! let out_port = None; // Ask user for output port -//! let midi_in = None; // Run standalone (no input, master clock/transport) +//! let out_port = None; // Ask user for output port +//! let midi_in = Vec::new(); // Run standalone (no input, master clock/transport) //! run(conductor, out_port, midi_in) //! } //! ``` @@ -99,17 +99,19 @@ pub enum MSeqError { /// - `out_port`: MIDI output port ID used to send messages. /// If set to `None`, information about available MIDI output ports will be displayed and the user /// will be prompted to select one. -/// - `midi_in`: Optional [`MidiInParam`] specifying how to configure the MIDI input connection. -/// If provided, the sequencer can run in either **master mode** (internal clock) or **slave mode** -/// (synchronized to external MIDI clock and transport messages). +/// - `midi_in`: List of [`MidiInParam`], one per MIDI input to open. Each input gets its own queue, +/// and the input's 0-based position in this list is passed to [`Conductor::handle_input`] as the +/// `input_id`. An empty list runs the sequencer standalone (no input). +/// At most one input acts as the clock/transport source: the first one with `slave` set to `true`. /// /// # Behavior -/// - **No input (`midi_in = None`)** → sequencer runs with its internal clock and transport, -/// generating MIDI clock and transport messages, but ignoring any external MIDI input. -/// - **Master mode** → sequencer generates its own MIDI clock and transport messages while also -/// handling incoming MIDI events (except for clock/transport). -/// - **Slave mode** → sequencer synchronizes to external MIDI clock, Start/Stop/Continue messages, -/// and dynamically adjusts BPM to match the external clock source. +/// - **No input (`midi_in` empty)** → sequencer runs with its internal clock and transport, +/// generating MIDI clock and transport messages, but ignoring any external MIDI input. +/// - **Master mode** (no input marked `slave`) → sequencer generates its own MIDI clock and transport +/// messages while also handling incoming MIDI events (except for clock/transport). +/// - **Slave mode** (the first `slave` input) → sequencer synchronizes to that input's MIDI clock, +/// Start/Stop/Continue messages, and dynamically adjusts BPM to match the external clock source. +/// The remaining inputs are handled as message-only inputs. /// /// # Errors /// Returns an [`MSeqError`] if a MIDI port cannot be opened, if MIDI input/output fails, or if track @@ -117,33 +119,48 @@ pub enum MSeqError { pub fn run( conductor: impl Conductor + std::marker::Send + 'static, out_port: Option, - midi_in: Option, + midi_in: Vec, ) -> Result<(), MSeqError> { let midi_out = StdMidiOut::new(out_port)?; let midi_controller = MidiController::new(midi_out); let ctx = Context::default(); - if let Some(params) = midi_in { - let run = Arc::new(Mutex::new((conductor, midi_controller, ctx))); + if midi_in.is_empty() { + return run_no_input(ctx, midi_controller, conductor); + } + + // At most one input is the clock/transport source: the first one marked as slave. + let slave_idx = midi_in.iter().position(|p| p.slave); + + let run = Arc::new(Mutex::new((conductor, midi_controller, ctx))); + + // Connect every input, keeping the connections alive for the whole run. + let connections = midi_in + .into_iter() + .enumerate() + .map(|(input_id, params)| connect(input_id, params, Some(input_id) == slave_idx)) + .collect::, _>>()?; + + // Spawn one consumer thread per input, each draining its own queue. + for (input_id, conn) in connections.iter().enumerate() { let run_consumer = run.clone(); - let midi_in = connect(params)?; - let message = midi_in.message.clone(); + let message = conn.message.clone(); thread::spawn(move || { loop { let r = run_consumer.lock().unwrap(); let mut r = message.1.wait(r).unwrap(); let (ref mut conductor, ref mut controller, ref mut ctx) = *r; let mut queue = message.0.lock().unwrap(); - ctx.handle_input(conductor, controller, &mut queue); + ctx.handle_input(input_id, conductor, controller, &mut queue); } }); - if let Some((sys_queue, cond_var)) = midi_in.slave_system { - run_slave(run, sys_queue, cond_var) - } else { - run_master(run) - } + } + + let slave_system = connections.iter().find_map(|c| c.slave_system.clone()); + if let Some((sys_queue, cond_var)) = slave_system { + run_slave(run, sys_queue, cond_var) } else { - run_no_input(ctx, midi_controller, conductor) + run_master(run) } } diff --git a/src/midi_connection.rs b/src/midi_connection.rs index 60ab6a0..aaa39ae 100644 --- a/src/midi_connection.rs +++ b/src/midi_connection.rs @@ -142,7 +142,7 @@ impl MidiOut for StdMidiOut { type QueueCondvar = (Arc>, Arc); -pub(crate) struct InQueues { +pub(crate) struct InConnection { pub message: QueueCondvar, pub slave_system: Option, _connection: midir::MidiInputConnection<(QueueCondvar, Option)>, @@ -155,14 +155,25 @@ pub struct MidiInParam { pub ignore: Ignore, /// MIDI port id used to receive the midi messages. If set to `None`, information about the MIDI ports /// will be displayed and the input port will be asked to the user with a prompt. + /// + /// When using several inputs, prefer specifying explicit port ids: with multiple inputs left to + /// `None`, the user is prompted once per input, and if a single port is available every such input + /// would auto-bind to that same port. pub port: Option, - /// Boolean flag to select the sequencer mode. - /// If set to `true`, the sequencer will run in **slave mode**, synchronizing to external MIDI clock and transport messages. + /// Boolean flag to select the sequencer mode. + /// If set to `true`, the sequencer will run in **slave mode**, synchronizing to external MIDI clock and transport messages. /// If set to `false`, the sequencer will run in **master mode**, generating its own MIDI clock and transport messages. + /// + /// When several inputs set this flag, only the first one (by position) is used as the clock and + /// transport source; the others are treated as message-only inputs. pub slave: bool, } -pub(crate) fn connect(params: MidiInParam) -> Result { +pub(crate) fn connect( + input_id: usize, + params: MidiInParam, + is_slave: bool, +) -> Result { let mut midi_in = MidiInput::new("in")?; midi_in.ignore(params.ignore); @@ -190,7 +201,8 @@ pub(crate) fn connect(params: MidiInParam) -> Result { println!("{}: {}", i, midi_in.port_name(p).unwrap()); } - let port_number: usize = prompt_default("Select input port", 0)?; + let port_number: usize = + prompt_default(format!("Select input port for input {input_id}"), 0)?; match in_ports.get(port_number) { None => return Err(MidiError::PortNumber()), Some(x) => x, @@ -202,7 +214,7 @@ pub(crate) fn connect(params: MidiInParam) -> Result { let message_queue = Arc::new(Mutex::new(InputQueue::new())); let message = (message_queue.clone(), Arc::new(Condvar::new())); - let slave_system = if params.slave { + let slave_system = if is_slave { Some(( Arc::new(Mutex::new(InputQueue::new())), Arc::new(Condvar::new()), @@ -239,7 +251,7 @@ pub(crate) fn connect(params: MidiInParam) -> Result { input, )?; - Ok(InQueues { + Ok(InConnection { message, slave_system, _connection, From ce38858b8a2dbf6e60145664b8f14923b4b4ce6f Mon Sep 17 00:00:00 2001 From: julien Date: Tue, 9 Jun 2026 21:08:58 +0200 Subject: [PATCH 02/11] Add slave-mode multi-input test; extract MidiMessage::is_transport Add a test asserting that in slave mode with multiple inputs, only the clock of the slave input (input 0) advances the step: clocks arriving on a non-slave input are dropped at routing and never reach the conductor or move the step. Extract the transport/channel classification into MidiMessage::is_transport so the production input callback and the test share the same routing logic. Co-Authored-By: Claude Opus 4.8 --- mseq_core/src/midi.rs | 14 +++ mseq_core/tests/slave_input_test.rs | 166 ++++++++++++++++++++++++++++ src/midi_connection.rs | 22 ++-- 3 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 mseq_core/tests/slave_input_test.rs diff --git a/mseq_core/src/midi.rs b/mseq_core/src/midi.rs index 15bb3af..87c1ea8 100644 --- a/mseq_core/src/midi.rs +++ b/mseq_core/src/midi.rs @@ -117,6 +117,20 @@ pub enum MidiMessage { } impl MidiMessage { + /// Returns `true` for transport/system messages + /// ([`MidiMessage::Clock`], [`MidiMessage::Start`], [`MidiMessage::Continue`], + /// [`MidiMessage::Stop`]), which drive synchronization in slave mode, and `false` + /// for channel messages (note, CC, PC, pitch bend). + /// + /// In slave mode these messages are intercepted from the clock source input and + /// are not forwarded to [`crate::Conductor::handle_input`]. + pub fn is_transport(&self) -> bool { + matches!( + self, + MidiMessage::Clock | MidiMessage::Start | MidiMessage::Continue | MidiMessage::Stop + ) + } + /// Parses a byte slice into a `MidiMessage` struct. /// /// This function is not intended to be called directly by end users. diff --git a/mseq_core/tests/slave_input_test.rs b/mseq_core/tests/slave_input_test.rs new file mode 100644 index 0000000..66e350d --- /dev/null +++ b/mseq_core/tests/slave_input_test.rs @@ -0,0 +1,166 @@ +mod common; + +use common::*; +use mseq_core::*; +use std::cell::RefCell; +use std::collections::HashMap; +use std::collections::VecDeque; +use std::rc::Rc; +use std::time::Instant; + +/// Number of independent MIDI inputs simulated by the test. Input 0 is the slave +/// (clock/transport source); the others are message-only inputs. +const NUM_INPUTS: usize = 2; +const SLAVE_INPUT: usize = 0; + +/// Conductor that records every message delivered to `handle_input`, together with +/// the input it came from. Used to prove that transport messages from non-slave +/// inputs never reach the conductor. +struct RecordingConductor { + received: Rc>>, +} + +impl Conductor for RecordingConductor { + fn init(&mut self, _context: &mut Context) -> Vec { + vec![] + } + + fn update(&mut self, _context: &mut Context) -> Vec { + vec![] + } + + fn handle_input( + &mut self, + input_id: usize, + input: MidiMessage, + _context: &Context, + ) -> Vec { + self.received.borrow_mut().push((input_id, input)); + vec![] + } +} + +/// Mirrors the routing performed by the midir input callback in `mseq::connect`: +/// transport messages (Clock/Start/Stop/Continue) are forwarded to the system queue +/// only for the slave input and dropped for any other input; channel messages always +/// go to the input's own message queue. +fn route( + msg: MidiMessage, + is_slave: bool, + system_queue: &mut VecDeque, + message_queue: &mut InputQueue, +) { + if msg.is_transport() { + if is_slave { + system_queue.push_back(msg); + } + // Dropped for non-slave inputs. + } else { + message_queue.push_back(msg); + } +} + +/// Mirrors the per-clock stepping of `mseq::run_slave`: the sequencer advances exactly +/// one step for every Clock message read from the slave system queue, after applying any +/// Start/Stop/Continue that arrived before it. +fn run_slave_sim( + ctx: &mut Context, + conductor: &mut impl Conductor, + controller: &mut MidiController, + system_queue: VecDeque, +) { + let mut pending_start = false; + for m in system_queue { + match m { + MidiMessage::Clock => { + ctx.process_pre_tick(conductor, controller); + if pending_start { + ctx.start(); + pending_start = false; + } + ctx.process_post_tick(controller); + } + MidiMessage::Start => pending_start = true, + MidiMessage::Stop => ctx.pause(), + MidiMessage::Continue => ctx.resume(), + _ => unreachable!("only transport messages reach the system queue"), + } + } +} + +#[test] +fn test_slave_multi_input() { + let debug_conn = Rc::new(RefCell::new(DebugMidiOutInner { + notes_on: HashMap::new(), + start_timestamp: Instant::now(), + })); + let mut controller = MidiController::new(DebugMidiOut(debug_conn)); + + let received = Rc::new(RefCell::new(vec![])); + let mut conductor = RecordingConductor { + received: received.clone(), + }; + + let mut ctx = Context::default(); + conductor.init(&mut ctx); + // Slave mode starts paused, like `mseq::run_slave`. + ctx.pause(); + + // One system queue for the slave input and one message queue per input. + let mut system_queue: VecDeque = VecDeque::new(); + let mut message_queues: Vec = (0..NUM_INPUTS).map(|_| InputQueue::new()).collect(); + + const CLOCKS_INPUT_0: usize = 5; + const CLOCKS_INPUT_1: usize = 3; + + // Input 0 (the slave): Start then a stream of clocks. These must drive the step. + route( + MidiMessage::Start, + true, + &mut system_queue, + &mut message_queues[SLAVE_INPUT], + ); + for _ in 0..CLOCKS_INPUT_0 { + route( + MidiMessage::Clock, + true, + &mut system_queue, + &mut message_queues[SLAVE_INPUT], + ); + } + + // Input 1 (non-slave): clocks (which must be dropped and never advance the step) + // plus one channel message (which must still reach the conductor). + let note_on = MidiMessage::NoteOn { + channel: 1, + note: MidiNote::new(Note::C, 4, 100), + }; + for _ in 0..CLOCKS_INPUT_1 { + route( + MidiMessage::Clock, + false, + &mut system_queue, + &mut message_queues[1], + ); + } + route(note_on, false, &mut system_queue, &mut message_queues[1]); + + // Input 1's clocks were dropped at routing: only its channel message remains. + assert_eq!(message_queues[1].len(), 1); + + // Drive the slave loop from input 0's system queue. + run_slave_sim(&mut ctx, &mut conductor, &mut controller, system_queue); + + // Only input 0's clocks advanced the step. + assert_eq!(ctx.get_step(), CLOCKS_INPUT_0 as u32); + + // Now deliver every input's channel messages, as the per-input consumer threads do. + for (input_id, queue) in message_queues.iter_mut().enumerate() { + ctx.handle_input(input_id, &mut conductor, &mut controller, queue); + } + + // The conductor only ever saw input 1's channel message: no transport message + // (from any input) reached `handle_input`, and the step is unchanged. + assert_eq!(*received.borrow(), vec![(1, note_on)]); + assert_eq!(ctx.get_step(), CLOCKS_INPUT_0 as u32); +} diff --git a/src/midi_connection.rs b/src/midi_connection.rs index aaa39ae..e149c0a 100644 --- a/src/midi_connection.rs +++ b/src/midi_connection.rs @@ -231,20 +231,16 @@ pub(crate) fn connect( move |_, message, input| { let m = MidiMessage::parse(message); if let Some(m) = m { - match m { - MidiMessage::Clock - | MidiMessage::Start - | MidiMessage::Stop - | MidiMessage::Continue => { - if let Some((q, cv)) = &input.1 { - q.lock().unwrap().push_back(m); - cv.notify_all(); - } - } - _ => { - input.0.0.lock().unwrap().push_back(m); - input.0.1.notify_all(); + if m.is_transport() { + // Transport messages are only consumed from the slave clock source; + // for any other input they are dropped. + if let Some((q, cv)) = &input.1 { + q.lock().unwrap().push_back(m); + cv.notify_all(); } + } else { + input.0.0.lock().unwrap().push_back(m); + input.0.1.notify_all(); } } }, From 946365f3d4071906313526b984855145d0fc0178 Mon Sep 17 00:00:00 2001 From: julien Date: Tue, 9 Jun 2026 21:20:52 +0200 Subject: [PATCH 03/11] Warn when multiple inputs are marked as slave Only one clock/transport source is supported, so log a warning when more than one input has `slave: true`; the first one is used and the rest become message-only inputs. Co-Authored-By: Claude Opus 4.8 --- src/lib.rs | 8 ++++++++ src/midi_connection.rs | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 094c021..32444d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -131,6 +131,14 @@ pub fn run( // At most one input is the clock/transport source: the first one marked as slave. let slave_idx = midi_in.iter().position(|p| p.slave); + let slave_count = midi_in.iter().filter(|p| p.slave).count(); + if slave_count > 1 { + log::warn!( + "{slave_count} MIDI inputs are marked as slave, but a single clock/transport source is \ + supported: using input {} as the clock source, the others are treated as message-only inputs.", + slave_idx.unwrap() + ); + } let run = Arc::new(Mutex::new((conductor, midi_controller, ctx))); diff --git a/src/midi_connection.rs b/src/midi_connection.rs index e149c0a..ea6adbc 100644 --- a/src/midi_connection.rs +++ b/src/midi_connection.rs @@ -165,7 +165,7 @@ pub struct MidiInParam { /// If set to `false`, the sequencer will run in **master mode**, generating its own MIDI clock and transport messages. /// /// When several inputs set this flag, only the first one (by position) is used as the clock and - /// transport source; the others are treated as message-only inputs. + /// transport source; the others are treated as message-only inputs and a warning is logged. pub slave: bool, } From 6560ccf02df699f11d6f9c826677ec3423073899 Mon Sep 17 00:00:00 2001 From: julien Date: Tue, 9 Jun 2026 21:32:34 +0200 Subject: [PATCH 04/11] Run CI on all branches Trigger the CI workflow on push to any branch instead of only main. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 004a704..a1370c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [ main ] + branches: [ '**' ] env: CARGO_TERM_COLOR: always From 5d6a2881ebc0d4443ae469102a25d0c011c6a60b Mon Sep 17 00:00:00 2001 From: julien Date: Tue, 9 Jun 2026 21:40:03 +0200 Subject: [PATCH 05/11] Use version+path deps instead of patch.crates-io Declare mseq_core and mseq_tracks with both a version and a local path so the workspace builds against the local sources during development while still publishing proper version requirements. Removes the patch.crates-io section. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 9 ++------- mseq_tracks/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d36cc41..b05efa6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,8 @@ keywords = ["midi", "music", "sequencer"] categories = ["multimedia"] [dependencies] -mseq_core = "0.1.6" -mseq_tracks = "0.2.5" +mseq_core = { version = "0.1.6", path = "mseq_core" } +mseq_tracks = { version = "0.2.5", path = "mseq_tracks" } spin_sleep = "1.2.1" thiserror = "2.0.18" midir = "0.11.0" @@ -30,8 +30,3 @@ itertools = "0.14.0" [dev-dependencies] env_logger = "0.11.9" rand = "0.10.1" - -# Build against the local workspace crates during development. -[patch.crates-io] -mseq_core = { path = "mseq_core" } -mseq_tracks = { path = "mseq_tracks" } diff --git a/mseq_tracks/Cargo.toml b/mseq_tracks/Cargo.toml index 6840284..66543e0 100644 --- a/mseq_tracks/Cargo.toml +++ b/mseq_tracks/Cargo.toml @@ -11,7 +11,7 @@ keywords = ["midi", "music", "sequencer"] categories = ["multimedia"] [dependencies] -mseq_core = "0.1.6" +mseq_core = { version = "0.1.6", path = "../mseq_core" } thiserror = "2.0.18" midir = "0.11.0" serde = {version = "1.0.208", features = ["derive"] } From 8262830b3a6e8470b4aef617732a9f35d7a0a171 Mon Sep 17 00:00:00 2001 From: julien Date: Wed, 10 Jun 2026 19:55:20 +0200 Subject: [PATCH 06/11] bpm bug fixed --- mseq_core/src/bpm.rs | 3 ++- src/lib.rs | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/mseq_core/src/bpm.rs b/mseq_core/src/bpm.rs index c2b7c5b..5c3b782 100644 --- a/mseq_core/src/bpm.rs +++ b/mseq_core/src/bpm.rs @@ -17,7 +17,8 @@ impl Bpm { } fn compute_period_us(bpm: u8) -> u64 { - 60 * 1000000 / 24 / bpm as u64 + // Guard against a 0 bpm, which would divide by zero. + 60 * 1000000 / 24 / bpm.max(1) as u64 } pub(crate) fn get_period_us(&self) -> u64 { diff --git a/src/lib.rs b/src/lib.rs index 32444d4..454990e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -293,7 +293,12 @@ fn run_slave( if bpm_counter == 24 { bpm_counter = 0; let duration = bmp_time_stamp.elapsed().as_millis(); - if let Some(bpm) = 60000_u128.checked_div(duration) { + // 24 MIDI clocks make one beat; bpm = 60000ms / beat_duration. Ignore + // out-of-range readings (e.g. the long idle window before Start, or a + // stalled clock) so we never feed 0 to set_bpm and keep the last valid bpm. + if let Some(bpm) = 60000_u128.checked_div(duration) + && (1..=255).contains(&bpm) + { ctx.set_bpm(bpm as u8); } bmp_time_stamp = Instant::now(); From 3f55c76ea6dfbe8a0e93f1240cfba4d2a9759de4 Mon Sep 17 00:00:00 2001 From: julien Date: Wed, 10 Jun 2026 23:34:22 +0200 Subject: [PATCH 07/11] flush sys instructions, even if no clock signal --- mseq_core/src/context.rs | 15 ++++++++++--- src/lib.rs | 48 ++++++++++++++++++++-------------------- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/mseq_core/src/context.rs b/mseq_core/src/context.rs index 0d547bd..272c0a4 100644 --- a/mseq_core/src/context.rs +++ b/mseq_core/src/context.rs @@ -120,9 +120,7 @@ impl Context { conductor: &mut impl Conductor, controller: &mut MidiController, ) { - core::mem::take(&mut self.sys_instructions) - .into_iter() - .for_each(|instruction| controller.execute(instruction)); + self.flush_sys_instructions(controller); if self.on_pause { conductor.update(self); @@ -134,6 +132,17 @@ impl Context { }; } + /// Immediately sends the pending system instructions (Start / Stop / Continue / + /// StopAllNotes) queued by [`start`](Self::start), [`pause`](Self::pause) and + /// [`resume`](Self::resume). The slave loop calls this so transport changes take + /// effect right away instead of waiting for the next external clock tick. + /// This function is not intended to be called directly by users. + pub fn flush_sys_instructions(&mut self, controller: &mut MidiController) { + core::mem::take(&mut self.sys_instructions) + .into_iter() + .for_each(|instruction| controller.execute(instruction)); + } + /// MIDI logic called after the clock tick. /// This function is not intended to be called directly by users. /// `process_post_tick` is used internally to enable code reuse across platforms. diff --git a/src/lib.rs b/src/lib.rs index 454990e..0bd1604 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -253,32 +253,39 @@ fn run_slave( Stop, Continue, } - let mut sys_message = None; - - // We quit the loop if we receive clock message + // Wait for the next clock message, but apply any transport message + // (Start / Stop / Continue) as soon as it arrives so pause/start/resume take + // effect immediately, even if the master stops sending clock on stop. loop { let mut mutex = sys_queue.lock().unwrap(); - let queue = &mut *mutex; let mut quit_loop = false; + let mut transport = vec![]; - while let Some(message) = queue.pop_front() { + while let Some(message) = mutex.pop_front() { match message { - MidiMessage::Clock => { - quit_loop = true; - } - MidiMessage::Start => { - sys_message = Some(SysMessage::Start); - } - MidiMessage::Stop => { - sys_message = Some(SysMessage::Stop); - } - MidiMessage::Continue => { - sys_message = Some(SysMessage::Continue); - } + MidiMessage::Clock => quit_loop = true, + MidiMessage::Start => transport.push(SysMessage::Start), + MidiMessage::Stop => transport.push(SysMessage::Stop), + MidiMessage::Continue => transport.push(SysMessage::Continue), _ => unreachable!(), } } + // Apply and emit transport changes now. sys_queue is held across the run + // lock here; this is the only place these locks nest, so no deadlock. + if !transport.is_empty() { + let mut r = run.lock().unwrap(); + let (_, ref mut controller, ref mut ctx) = *r; + for message in transport { + match message { + SysMessage::Start => ctx.start(), + SysMessage::Stop => ctx.pause(), + SysMessage::Continue => ctx.resume(), + } + } + ctx.flush_sys_instructions(controller); + } + if quit_loop { break; } @@ -304,13 +311,6 @@ fn run_slave( bmp_time_stamp = Instant::now(); } - if let Some(sys_message) = sys_message { - match sys_message { - SysMessage::Start => ctx.start(), - SysMessage::Stop => ctx.pause(), - SysMessage::Continue => ctx.resume(), - } - } ctx.process_post_tick(controller); if !ctx.is_running() { break; From 11f6b36e43d4a139fb8013671f80fde45346ae8a Mon Sep 17 00:00:00 2001 From: julien Date: Tue, 16 Jun 2026 21:26:51 +0200 Subject: [PATCH 08/11] Fix input-queue wakeups, tidy slave loop, drop dead pause field - Consumer threads: park on the queue's own mutex/condvar (the pair the midir callback notifies) with a predicate loop, so messages that arrive before parking can't be lost; drain via an O(1) swap so the real-time callback isn't blocked while the conductor runs. - run_slave: drain the transport queue, release it, then apply transport under the run lock alone, removing the only place these locks nested. - Replace the (Arc>, Arc) tuple with a named NotifyQueue struct (queue/condvar fields, new()/push() helpers). - Remove the unused Context::pause field and its dead branch. Co-Authored-By: Claude Opus 4.8 --- mseq_core/src/context.rs | 5 --- src/lib.rs | 66 +++++++++++++++++++++------------------- src/midi_connection.rs | 46 ++++++++++++++++++---------- 3 files changed, 65 insertions(+), 52 deletions(-) diff --git a/mseq_core/src/context.rs b/mseq_core/src/context.rs index 272c0a4..5180b37 100644 --- a/mseq_core/src/context.rs +++ b/mseq_core/src/context.rs @@ -24,7 +24,6 @@ pub struct Context { step: u32, running: bool, on_pause: bool, - pause: bool, sys_instructions: Vec, } @@ -40,7 +39,6 @@ impl Default for Context { step: 0, running: true, on_pause: true, - pause: false, sys_instructions: vec![], } } @@ -71,7 +69,6 @@ impl Context { /// Pauses the sequencer and send a MIDI stop message. pub fn pause(&mut self) { self.on_pause = true; - self.pause = true; self.sys_instructions.push(Instruction::StopAllNotes); self.sys_instructions.push(Instruction::Stop); } @@ -151,8 +148,6 @@ impl Context { if !self.on_pause { self.step += 1; controller.update(self.step); - } else if self.pause { - self.pause = false; } } diff --git a/src/lib.rs b/src/lib.rs index 0bd1604..0961b39 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,7 +69,7 @@ pub use mseq_core::*; pub use mseq_tracks::*; use clock::Clock; -use std::sync::{Arc, Condvar, Mutex}; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; use std::time::Instant; @@ -152,21 +152,28 @@ pub fn run( // Spawn one consumer thread per input, each draining its own queue. for (input_id, conn) in connections.iter().enumerate() { let run_consumer = run.clone(); - let message = conn.message.clone(); + let channel = conn.message.clone(); thread::spawn(move || { loop { - let r = run_consumer.lock().unwrap(); - let mut r = message.1.wait(r).unwrap(); + // Wait for messages, then swap them out so the callback isn't blocked + // while we process. + let mut pending = { + let mut queue = channel.queue.lock().unwrap(); + while queue.is_empty() { + queue = channel.condvar.wait(queue).unwrap(); + } + core::mem::take(&mut *queue) + }; + let mut r = run_consumer.lock().unwrap(); let (ref mut conductor, ref mut controller, ref mut ctx) = *r; - let mut queue = message.0.lock().unwrap(); - ctx.handle_input(input_id, conductor, controller, &mut queue); + ctx.handle_input(input_id, conductor, controller, &mut pending); } }); } let slave_system = connections.iter().find_map(|c| c.slave_system.clone()); - if let Some((sys_queue, cond_var)) = slave_system { - run_slave(run, sys_queue, cond_var) + if let Some(slave) = slave_system { + run_slave(run, slave) } else { run_master(run) } @@ -225,8 +232,7 @@ fn run_master( fn run_slave( run: Arc, Context)>>, - sys_queue: Arc>, - sys_cond_var: Arc, + slave: NotifyQueue, ) -> Result<(), MSeqError> { { let mut r = run.lock().unwrap(); @@ -237,7 +243,7 @@ fn run_slave( ctx.pause(); } - // We use the average duration over 24 clock messages (1 beat) to set the BPM + // Derive BPM from the duration of 24 clocks (1 beat). let mut bpm_counter = 0; let mut bmp_time_stamp = Instant::now(); loop { @@ -247,32 +253,33 @@ fn run_slave( ctx.process_pre_tick(conductor, controller); } - // Check the slave system queue enum SysMessage { Start, Stop, Continue, } - // Wait for the next clock message, but apply any transport message - // (Start / Stop / Continue) as soon as it arrives so pause/start/resume take - // effect immediately, even if the master stops sending clock on stop. + // Wait for the next clock, but apply transport messages immediately so + // pause/start/resume work even when the master stops clock on stop. loop { - let mut mutex = sys_queue.lock().unwrap(); let mut quit_loop = false; let mut transport = vec![]; - - while let Some(message) = mutex.pop_front() { - match message { - MidiMessage::Clock => quit_loop = true, - MidiMessage::Start => transport.push(SysMessage::Start), - MidiMessage::Stop => transport.push(SysMessage::Stop), - MidiMessage::Continue => transport.push(SysMessage::Continue), - _ => unreachable!(), + { + let mut mutex = slave.queue.lock().unwrap(); + while mutex.is_empty() { + mutex = slave.condvar.wait(mutex).unwrap(); + } + while let Some(message) = mutex.pop_front() { + match message { + MidiMessage::Clock => quit_loop = true, + MidiMessage::Start => transport.push(SysMessage::Start), + MidiMessage::Stop => transport.push(SysMessage::Stop), + MidiMessage::Continue => transport.push(SysMessage::Continue), + _ => unreachable!(), + } } } - // Apply and emit transport changes now. sys_queue is held across the run - // lock here; this is the only place these locks nest, so no deadlock. + // Apply transport with sys_queue released, so the callback isn't blocked. if !transport.is_empty() { let mut r = run.lock().unwrap(); let (_, ref mut controller, ref mut ctx) = *r; @@ -289,8 +296,6 @@ fn run_slave( if quit_loop { break; } - - let _r = sys_cond_var.wait(mutex).unwrap(); } let mut r = run.lock().unwrap(); @@ -300,9 +305,8 @@ fn run_slave( if bpm_counter == 24 { bpm_counter = 0; let duration = bmp_time_stamp.elapsed().as_millis(); - // 24 MIDI clocks make one beat; bpm = 60000ms / beat_duration. Ignore - // out-of-range readings (e.g. the long idle window before Start, or a - // stalled clock) so we never feed 0 to set_bpm and keep the last valid bpm. + // bpm = 60000ms / beat (24 clocks); skip out-of-range readings so a + // stalled or pre-Start clock keeps the last valid bpm. if let Some(bpm) = 60000_u128.checked_div(duration) && (1..=255).contains(&bpm) { diff --git a/src/midi_connection.rs b/src/midi_connection.rs index ea6adbc..d1f319b 100644 --- a/src/midi_connection.rs +++ b/src/midi_connection.rs @@ -140,12 +140,33 @@ impl MidiOut for StdMidiOut { } } -type QueueCondvar = (Arc>, Arc); +/// A MIDI message queue paired with the condvar the producer (the midir input +/// callback) notifies on every push, so a consumer can park until work arrives. +#[derive(Clone)] +pub(crate) struct NotifyQueue { + pub queue: Arc>, + pub condvar: Arc, +} + +impl NotifyQueue { + fn new() -> Self { + Self { + queue: Arc::new(Mutex::new(InputQueue::new())), + condvar: Arc::new(Condvar::new()), + } + } + + /// Push a message and wake the waiting consumer. + fn push(&self, message: MidiMessage) { + self.queue.lock().unwrap().push_back(message); + self.condvar.notify_all(); + } +} pub(crate) struct InConnection { - pub message: QueueCondvar, - pub slave_system: Option, - _connection: midir::MidiInputConnection<(QueueCondvar, Option)>, + pub message: NotifyQueue, + pub slave_system: Option, + _connection: midir::MidiInputConnection<(NotifyQueue, Option)>, } /// MIDI input connection parameters. @@ -211,14 +232,9 @@ pub(crate) fn connect( } }; - let message_queue = Arc::new(Mutex::new(InputQueue::new())); - let message = (message_queue.clone(), Arc::new(Condvar::new())); - + let message = NotifyQueue::new(); let slave_system = if is_slave { - Some(( - Arc::new(Mutex::new(InputQueue::new())), - Arc::new(Condvar::new()), - )) + Some(NotifyQueue::new()) } else { None }; @@ -234,13 +250,11 @@ pub(crate) fn connect( if m.is_transport() { // Transport messages are only consumed from the slave clock source; // for any other input they are dropped. - if let Some((q, cv)) = &input.1 { - q.lock().unwrap().push_back(m); - cv.notify_all(); + if let Some(slave) = &input.1 { + slave.push(m); } } else { - input.0.0.lock().unwrap().push_back(m); - input.0.1.notify_all(); + input.0.push(m); } } }, From e1b9d5782dc17ee564a73f7f6ca9d5eb02918909 Mon Sep 17 00:00:00 2001 From: julien Date: Tue, 16 Jun 2026 23:37:39 +0200 Subject: [PATCH 09/11] Document multiple MIDI inputs in README Update the README to match the new multi-input API: the multiple-inputs feature, an "input_id" note on handle_input, a "MIDI Inputs" section, and the corrected usage example (handle_input gains input_id, midi_in is a Vec). Co-Authored-By: Claude Opus 4.8 --- README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6204241..5013b9d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - Real-time MIDI clock generation and synchronization - Master/slave transport control with Start/Stop/Continue handling +- Multiple MIDI inputs, each with its own queue and an `input_id` for routing - Flexible [`Conductor`] trait for defining sequencer logic - Easy-to-implement tracks via the [`Track`] trait - Thread-safe, minimal core designed for real-time responsiveness @@ -30,7 +31,15 @@ A `Conductor` defines how your sequencer behaves: - [`Conductor::init`] → called once at startup to initialize state and produce initial [`Instruction`]s (e.g., send program changes or reset messages). - [`Conductor::update`] → called at every clock tick to advance the sequencer state and emit the instructions for that tick (e.g., note on/off events). -- [`Conductor::handle_input`] → called when a new [`MidiMessage`] arrives, allowing the conductor to react to external inputs in real time. +- [`Conductor::handle_input`] → called when a new [`MidiMessage`] arrives, allowing the conductor to react to external inputs in real time. The `input_id` argument (0-based, matching the input's position in the list passed to [`run`]) identifies which input the message came from. + +## MIDI Inputs + +[`run`] accepts a `Vec`, opening one MIDI input per entry. Each input gets its own queue and is identified by its 0-based position in the list, which is forwarded to [`Conductor::handle_input`] as `input_id`. + +- An empty `Vec` runs the sequencer standalone (no input). +- At most one input acts as the clock/transport source: the first one with `slave` set to `true`. Any other `slave` inputs are treated as message-only inputs (a warning is logged). +- With multiple inputs, prefer setting an explicit `port` on each `MidiInParam` rather than leaving it as `None`. ## Tracks @@ -64,15 +73,15 @@ impl Conductor for MyConductor { vec![] } - fn handle_input(&mut self, input: MidiMessage, _ctx: &Context) -> Vec { + fn handle_input(&mut self, _input_id: usize, _input: MidiMessage, _ctx: &Context) -> Vec { vec![] } } fn main() -> Result<(), mseq::MSeqError> { let conductor = MyConductor; - let out_port = None; - let midi_in = None; + let out_port = None; // Ask the user for the output port + let midi_in = Vec::new(); // Run standalone (no MIDI input) run(conductor, out_port, midi_in) } ``` From 0d9ed66ae91ed0a59818badab0c4ee22caa86376 Mon Sep 17 00:00:00 2001 From: julien Date: Wed, 17 Jun 2026 19:33:43 +0200 Subject: [PATCH 10/11] Keep conductor instructions while paused Previously process_pre_tick and handle_input called the conductor while paused but discarded the returned instructions. Always execute them now; pausing only freezes the step counter (step-driven tracks hold position), it no longer silences update/handle_input output. Update the Conductor::update, Conductor::handle_input and Context::pause docs to describe the new pause semantics. Co-Authored-By: Claude Opus 4.8 --- mseq_core/src/conductor.rs | 11 ++++++++++- mseq_core/src/context.rs | 31 ++++++++++++------------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/mseq_core/src/conductor.rs b/mseq_core/src/conductor.rs index f50b3cd..7c65dbd 100644 --- a/mseq_core/src/conductor.rs +++ b/mseq_core/src/conductor.rs @@ -26,6 +26,13 @@ pub trait Conductor { /// This method is responsible for progressing the sequencer and producing /// the set of instructions that should be executed at the current tick (e.g., sending MIDI events). /// + /// `update` is called on every tick regardless of pause state, and the returned + /// instructions are always sent to the MIDI output. Pausing (via + /// [`Context::pause`]) only stops the step counter from advancing (so + /// step-driven tracks hold their position); it does not silence the + /// instructions returned here. Use [`Context::is_paused`] if you want to alter + /// behavior while paused. + /// /// # Returns /// /// A `Vec` containing the actions to be passed to the MIDI controller @@ -38,7 +45,9 @@ pub trait Conductor { /// It allows the conductor to react to external inputs by updating internal state or triggering events. /// /// The returned `Vec` is passed directly to the MIDI controller or output backend, - /// allowing the conductor to immediately produce output in response to the input. + /// allowing the conductor to immediately produce output in response to the input. This happens + /// regardless of pause state; pausing does not drop these instructions. Use + /// [`Context::is_paused`] if you want to alter behavior while paused. /// /// # Parameters /// diff --git a/mseq_core/src/context.rs b/mseq_core/src/context.rs index 5180b37..30d5886 100644 --- a/mseq_core/src/context.rs +++ b/mseq_core/src/context.rs @@ -67,6 +67,10 @@ impl Context { } /// Pauses the sequencer and send a MIDI stop message. + /// + /// While paused, the step counter stops advancing, so step-driven tracks hold + /// their position. [`Conductor::update`] is still called every tick and its + /// returned instructions are still sent; pausing does not drop them. pub fn pause(&mut self) { self.on_pause = true; self.sys_instructions.push(Instruction::StopAllNotes); @@ -119,14 +123,10 @@ impl Context { ) { self.flush_sys_instructions(controller); - if self.on_pause { - conductor.update(self); - } else { - conductor - .update(self) - .into_iter() - .for_each(|instruction| controller.execute(instruction)); - }; + conductor + .update(self) + .into_iter() + .for_each(|instruction| controller.execute(instruction)); } /// Immediately sends the pending system instructions (Start / Stop / Continue / @@ -174,16 +174,9 @@ impl Context { controller: &mut MidiController, input_queue: &mut InputQueue, ) { - if self.is_paused() { - input_queue - .drain(..) - .flat_map(|message| conductor.handle_input(input_id, message, self)) - .for_each(drop); - } else { - input_queue - .drain(..) - .flat_map(|message| conductor.handle_input(input_id, message, self)) - .for_each(|instruction| controller.execute(instruction)); - } + input_queue + .drain(..) + .flat_map(|message| conductor.handle_input(input_id, message, self)) + .for_each(|instruction| controller.execute(instruction)); } } From 474a21a4b1bfe3cbc3ef67d7a7e39da5de161e43 Mon Sep 17 00:00:00 2001 From: julien Date: Wed, 17 Jun 2026 20:31:17 +0200 Subject: [PATCH 11/11] Add direct-forward channel to handle_input Conductor::handle_input now returns an InputResponse with two channels: - instructions: processed by the MIDI controller (buffered/step-scheduled), executed only while running and dropped while paused. - messages: forwarded straight to the MIDI output, bypassing the controller, sent always including while paused. This lets a conductor echo/transform incoming MIDI immediately and even while paused, without the controller's clock-quantized note buffering. Also revert update's pause behavior to dropping its instructions while paused (undoing the previous "keep instructions while paused" change for update), and update the related docs. Co-Authored-By: Claude Opus 4.8 --- README.md | 10 ++- mseq_core/src/conductor.rs | 44 +++++++--- mseq_core/src/context.rs | 40 +++++++--- mseq_core/src/lib.rs | 2 +- mseq_core/src/midi_controller.rs | 6 +- mseq_core/tests/input_test.rs | 119 +++++++++++++++++++++++++--- mseq_core/tests/slave_input_test.rs | 4 +- src/lib.rs | 8 +- 8 files changed, 186 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 5013b9d..7495430 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ A `Conductor` defines how your sequencer behaves: - [`Conductor::init`] → called once at startup to initialize state and produce initial [`Instruction`]s (e.g., send program changes or reset messages). - [`Conductor::update`] → called at every clock tick to advance the sequencer state and emit the instructions for that tick (e.g., note on/off events). -- [`Conductor::handle_input`] → called when a new [`MidiMessage`] arrives, allowing the conductor to react to external inputs in real time. The `input_id` argument (0-based, matching the input's position in the list passed to [`run`]) identifies which input the message came from. +- [`Conductor::handle_input`] → called when a new [`MidiMessage`] arrives, allowing the conductor to react to external inputs in real time. The `input_id` argument (0-based, matching the input's position in the list passed to [`run`]) identifies which input the message came from. It returns an [`InputResponse`] with two channels: `instructions` are processed by the controller (only while running), while `messages` are forwarded directly to the MIDI output, bypassing the controller, and are sent even while paused. ## MIDI Inputs @@ -60,7 +60,7 @@ This makes it easy to implement custom track types, from simple step sequencers The entry point of the crate is the [`run`] function: ```rust -use mseq::{run, Conductor, Context, Instruction, MidiMessage}; +use mseq::{run, Conductor, Context, InputResponse, Instruction, MidiMessage}; struct MyConductor; @@ -73,8 +73,10 @@ impl Conductor for MyConductor { vec![] } - fn handle_input(&mut self, _input_id: usize, _input: MidiMessage, _ctx: &Context) -> Vec { - vec![] + fn handle_input(&mut self, _input_id: usize, _input: MidiMessage, _ctx: &Context) -> InputResponse { + // `instructions` go through the controller (only while running); + // `messages` are forwarded directly (always, even while paused). + InputResponse::default() } } diff --git a/mseq_core/src/conductor.rs b/mseq_core/src/conductor.rs index 7c65dbd..89822e3 100644 --- a/mseq_core/src/conductor.rs +++ b/mseq_core/src/conductor.rs @@ -1,8 +1,25 @@ -use alloc::vec; use alloc::vec::Vec; use crate::{Context, MidiMessage, midi_controller::Instruction}; +/// Output of [`Conductor::handle_input`]. +/// +/// Carries two independent channels: +/// - `instructions` are handed to the MIDI controller, where notes are buffered and +/// step-scheduled. They are executed only while the sequencer is running and are +/// dropped while paused. +/// - `messages` are forwarded directly to the MIDI output, bypassing the controller's +/// note buffering. They are always sent, including while paused. +#[derive(Default)] +pub struct InputResponse { + /// Instructions processed by the MIDI controller (buffered / step-scheduled). + /// Executed only while the sequencer is running; dropped while paused. + pub instructions: Vec, + /// MIDI messages forwarded directly to the output, bypassing the controller. + /// Always sent, including while paused. + pub messages: Vec, +} + /// Entry point for user-defined sequencer behavior. /// /// The `Conductor` trait must be implemented by the user to define how their sequencer @@ -26,12 +43,9 @@ pub trait Conductor { /// This method is responsible for progressing the sequencer and producing /// the set of instructions that should be executed at the current tick (e.g., sending MIDI events). /// - /// `update` is called on every tick regardless of pause state, and the returned - /// instructions are always sent to the MIDI output. Pausing (via - /// [`Context::pause`]) only stops the step counter from advancing (so - /// step-driven tracks hold their position); it does not silence the - /// instructions returned here. Use [`Context::is_paused`] if you want to alter - /// behavior while paused. + /// `update` is called on every tick, but while paused (via [`Context::pause`]) + /// the returned instructions are dropped rather than sent to the MIDI output. + /// Use [`Context::is_paused`] if you want to alter behavior while paused. /// /// # Returns /// @@ -44,10 +58,14 @@ pub trait Conductor { /// This method is called whenever a new [`MidiMessage`] is received. /// It allows the conductor to react to external inputs by updating internal state or triggering events. /// - /// The returned `Vec` is passed directly to the MIDI controller or output backend, - /// allowing the conductor to immediately produce output in response to the input. This happens - /// regardless of pause state; pausing does not drop these instructions. Use - /// [`Context::is_paused`] if you want to alter behavior while paused. + /// The returned [`InputResponse`] carries two channels: + /// + /// - `instructions` are passed to the MIDI controller (buffered / step-scheduled). + /// They are executed only while the sequencer is running and are dropped while paused. + /// - `messages` are forwarded directly to the MIDI output, bypassing the controller. + /// They are always sent, including while paused. + /// + /// Use [`Context::is_paused`] if you want to alter behavior while paused. /// /// # Parameters /// @@ -70,7 +88,7 @@ pub trait Conductor { _input_id: usize, _input: MidiMessage, _context: &Context, - ) -> Vec { - vec![] + ) -> InputResponse { + InputResponse::default() } } diff --git a/mseq_core/src/context.rs b/mseq_core/src/context.rs index 30d5886..7ffd391 100644 --- a/mseq_core/src/context.rs +++ b/mseq_core/src/context.rs @@ -1,4 +1,5 @@ use crate::Conductor; +use crate::InputResponse; use crate::Instruction; use crate::MidiController; use crate::MidiMessage; @@ -69,8 +70,10 @@ impl Context { /// Pauses the sequencer and send a MIDI stop message. /// /// While paused, the step counter stops advancing, so step-driven tracks hold - /// their position. [`Conductor::update`] is still called every tick and its - /// returned instructions are still sent; pausing does not drop them. + /// their position. [`Conductor::update`] is still called every tick but its + /// returned instructions are dropped, and the instructions returned by + /// [`Conductor::handle_input`] are dropped too. The direct messages returned by + /// [`Conductor::handle_input`] are still forwarded. pub fn pause(&mut self) { self.on_pause = true; self.sys_instructions.push(Instruction::StopAllNotes); @@ -123,10 +126,14 @@ impl Context { ) { self.flush_sys_instructions(controller); - conductor - .update(self) - .into_iter() - .for_each(|instruction| controller.execute(instruction)); + if self.on_pause { + conductor.update(self); + } else { + conductor + .update(self) + .into_iter() + .for_each(|instruction| controller.execute(instruction)); + } } /// Immediately sends the pending system instructions (Start / Stop / Continue / @@ -174,9 +181,22 @@ impl Context { controller: &mut MidiController, input_queue: &mut InputQueue, ) { - input_queue - .drain(..) - .flat_map(|message| conductor.handle_input(input_id, message, self)) - .for_each(|instruction| controller.execute(instruction)); + let paused = self.is_paused(); + for message in input_queue.drain(..) { + let InputResponse { + instructions, + messages, + } = conductor.handle_input(input_id, message, self); + // Messages are forwarded directly, even while paused. + for m in messages { + controller.send_message(m); + } + // Instructions go through the controller only while running. + if !paused { + for instruction in instructions { + controller.execute(instruction); + } + } + } } } diff --git a/mseq_core/src/lib.rs b/mseq_core/src/lib.rs index 5887f67..aa7e142 100644 --- a/mseq_core/src/lib.rs +++ b/mseq_core/src/lib.rs @@ -42,7 +42,7 @@ mod midi_out; mod note; mod track; -pub use conductor::Conductor; +pub use conductor::{Conductor, InputResponse}; pub use context::*; pub use midi::*; pub use midi_controller::*; diff --git a/mseq_core/src/midi_controller.rs b/mseq_core/src/midi_controller.rs index c9d9663..eb80f27 100644 --- a/mseq_core/src/midi_controller.rs +++ b/mseq_core/src/midi_controller.rs @@ -348,7 +348,11 @@ impl MidiController { } } - fn send_message(&mut self, message: MidiMessage) { + /// Forwards a [`MidiMessage`] straight to the MIDI output, bypassing the + /// controller's note buffering and step scheduling. + /// + /// This function is not intended to be called directly by the user. + pub(crate) fn send_message(&mut self, message: MidiMessage) { if let Err(e) = self.midi_out.send_message(message) { error!("MIDI: {e}"); } diff --git a/mseq_core/tests/input_test.rs b/mseq_core/tests/input_test.rs index 9ef137b..977b7a2 100644 --- a/mseq_core/tests/input_test.rs +++ b/mseq_core/tests/input_test.rs @@ -64,28 +64,29 @@ impl Conductor for DebugInputConductor { input_id: usize, input: mseq_core::MidiMessage, _context: &Context, - ) -> Vec { + ) -> InputResponse { // Transpose by an input-dependent amount to prove that `input_id` is - // correctly forwarded and that each input is handled independently. + // correctly forwarded and that each input is handled independently. The + // transposed note is forwarded directly via the `messages` channel. let semitones = 3 + input_id as i8; - match input { + let messages = match input { mseq_core::MidiMessage::NoteOff { channel, note } => { - vec![Instruction::MidiMessage { - midi_message: MidiMessage::NoteOff { - channel, - note: note.transpose(semitones), - }, + vec![MidiMessage::NoteOff { + channel, + note: note.transpose(semitones), }] } mseq_core::MidiMessage::NoteOn { channel, note } => { - vec![Instruction::MidiMessage { - midi_message: MidiMessage::NoteOn { - channel, - note: note.transpose(semitones), - }, + vec![MidiMessage::NoteOn { + channel, + note: note.transpose(semitones), }] } _ => vec![], + }; + InputResponse { + messages, + ..Default::default() } } } @@ -150,3 +151,95 @@ fn test_input() { }; test_conductor_with_input(conductor, midi, input_test_simulation); } + +/// Conductor that returns both channels for every input: a direct `messages` +/// note and a controller `instructions` note, on distinct pitches so the two +/// paths can be told apart. +struct DualChannelConductor; + +const MSG_NOTE: MidiNote = MidiNote { + note: Note::E, + octave: 4, + vel: 100, +}; +const INSTR_NOTE: MidiNote = MidiNote { + note: Note::G, + octave: 4, + vel: 100, +}; + +impl Conductor for DualChannelConductor { + fn init(&mut self, _context: &mut Context) -> Vec { + vec![] + } + + fn update(&mut self, _context: &mut Context) -> Vec { + vec![] + } + + fn handle_input( + &mut self, + _input_id: usize, + _input: MidiMessage, + _context: &Context, + ) -> InputResponse { + InputResponse { + instructions: vec![Instruction::MidiMessage { + midi_message: MidiMessage::NoteOn { + channel: 1, + note: INSTR_NOTE, + }, + }], + messages: vec![MidiMessage::NoteOn { + channel: 1, + note: MSG_NOTE, + }], + } + } +} + +/// While paused, `handle_input` forwards the `messages` channel directly but drops +/// the `instructions` channel; once running, both reach the output. +#[test] +fn test_handle_input_pause_forwarding() { + let debug_conn = Rc::new(RefCell::new(DebugMidiOutInner { + notes_on: HashMap::new(), + start_timestamp: Instant::now(), + })); + let mut controller = MidiController::new(DebugMidiOut(debug_conn.clone())); + let mut conductor = DualChannelConductor; + + // Context::default starts paused. + let mut ctx = Context::default(); + assert!(ctx.is_paused()); + + let mut queue = InputQueue::new(); + queue.push_back(MidiMessage::NoteOn { + channel: 1, + note: MidiNote::new(Note::C, 4, 100), + }); + ctx.handle_input(0, &mut conductor, &mut controller, &mut queue); + + { + let inner = debug_conn.borrow(); + // The direct message was forwarded even while paused... + assert!(inner.notes_on.contains_key(&(1, MSG_NOTE.midi_value()))); + // ...but the instruction was dropped. + assert!(!inner.notes_on.contains_key(&(1, INSTR_NOTE.midi_value()))); + } + + // Once running, the instruction channel reaches the output too. + ctx.start(); + assert!(!ctx.is_paused()); + queue.push_back(MidiMessage::NoteOn { + channel: 1, + note: MidiNote::new(Note::C, 4, 100), + }); + ctx.handle_input(0, &mut conductor, &mut controller, &mut queue); + assert!( + debug_conn + .borrow() + .notes_on + .contains_key(&(1, INSTR_NOTE.midi_value())) + ); +} diff --git a/mseq_core/tests/slave_input_test.rs b/mseq_core/tests/slave_input_test.rs index 66e350d..38c296f 100644 --- a/mseq_core/tests/slave_input_test.rs +++ b/mseq_core/tests/slave_input_test.rs @@ -34,9 +34,9 @@ impl Conductor for RecordingConductor { input_id: usize, input: MidiMessage, _context: &Context, - ) -> Vec { + ) -> InputResponse { self.received.borrow_mut().push((input_id, input)); - vec![] + InputResponse::default() } } diff --git a/src/lib.rs b/src/lib.rs index 0961b39..b6db858 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,7 +22,7 @@ //! The entry point of the crate is the [`run`] function: //! //! ```no_run -//! use mseq::{run, Conductor, Context, Instruction, MidiInParam, MidiMessage}; +//! use mseq::{run, Conductor, Context, InputResponse, Instruction, MidiInParam, MidiMessage}; //! //! struct MyConductor; //! @@ -37,8 +37,10 @@ //! vec![] //! } //! -//! fn handle_input(&mut self, _input_id: usize, input: MidiMessage, _ctx: &Context) -> Vec { -//! vec![] +//! fn handle_input(&mut self, _input_id: usize, _input: MidiMessage, _ctx: &Context) -> InputResponse { +//! // `instructions` go through the controller (only while running); +//! // `messages` are forwarded directly (always, even while paused). +//! InputResponse::default() //! } //! } //!