From 517571e1914228d709e8ae878eaf3102083596a1 Mon Sep 17 00:00:00 2001 From: julien Date: Tue, 9 Jun 2026 21:03:46 +0200 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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() //! } //! } //! From 7c7515ffbbae71ab6ab97b4eccf6c8dd39561719 Mon Sep 17 00:00:00 2001 From: julien Date: Sun, 5 Jul 2026 10:38:31 +0200 Subject: [PATCH 12/17] Bump dependencies and crate versions for release Update third-party dependencies to their latest stable versions (spin_sleep 1.3.3, serde 1.0.228, fs-err 3.3.1, log 0.4.33, itertools 0.15, hashbrown 0.17.1, env_logger 0.11.11, rand 0.10.2). Bump crate versions for publication: mseq 3.0.0, mseq_core 1.0.0, mseq_tracks 1.0.0. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 20 ++++++++++---------- mseq_core/Cargo.toml | 8 ++++---- mseq_tracks/Cargo.toml | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b05efa6..15d83d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["mseq_core", "mseq_tracks"] [package] name = "mseq" -version = "2.2.4" +version = "3.0.0" edition = "2024" license = "LGPL-2.1" readme = "README.md" @@ -15,18 +15,18 @@ keywords = ["midi", "music", "sequencer"] categories = ["multimedia"] [dependencies] -mseq_core = { version = "0.1.6", path = "mseq_core" } -mseq_tracks = { version = "0.2.5", path = "mseq_tracks" } -spin_sleep = "1.2.1" +mseq_core = { version = "1.0.0", path = "mseq_core" } +mseq_tracks = { version = "1.0.0", path = "mseq_tracks" } +spin_sleep = "1.3.3" thiserror = "2.0.18" midir = "0.11.0" promptly = "0.3.1" -serde = {version = "1.0.208", features = ["derive"] } +serde = {version = "1.0.228", features = ["derive"] } csv = {version = "1.4.0"} -fs-err = "3.3.0" -log = "0.4.29" -itertools = "0.14.0" +fs-err = "3.3.1" +log = "0.4.33" +itertools = "0.15.0" [dev-dependencies] -env_logger = "0.11.9" -rand = "0.10.1" +env_logger = "0.11.11" +rand = "0.10.2" diff --git a/mseq_core/Cargo.toml b/mseq_core/Cargo.toml index ebecc78..d82f0f4 100644 --- a/mseq_core/Cargo.toml +++ b/mseq_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mseq_core" -version = "0.1.6" +version = "1.0.0" edition = "2024" license = "LGPL-2.1" readme = "README.md" @@ -11,7 +11,7 @@ keywords = ["midi", "music", "sequencer"] categories = ["multimedia"] [dependencies] -serde = {version = "1.0.208", default-features = false, features = ["derive", "alloc"] } +serde = {version = "1.0.228", default-features = false, features = ["derive", "alloc"] } thiserror = { version="2.0.18", default-features = false } -hashbrown = "0.17.0" -log = "0.4.29" +hashbrown = "0.17.1" +log = "0.4.33" diff --git a/mseq_tracks/Cargo.toml b/mseq_tracks/Cargo.toml index 66543e0..9375e97 100644 --- a/mseq_tracks/Cargo.toml +++ b/mseq_tracks/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mseq_tracks" -version = "0.2.5" +version = "1.0.0" edition = "2024" license = "LGPL-2.1" readme = "README.md" @@ -11,17 +11,17 @@ keywords = ["midi", "music", "sequencer"] categories = ["multimedia"] [dependencies] -mseq_core = { version = "0.1.6", path = "../mseq_core" } +mseq_core = { version = "1.0.0", path = "../mseq_core" } thiserror = "2.0.18" midir = "0.11.0" -serde = {version = "1.0.208", features = ["derive"] } +serde = {version = "1.0.228", features = ["derive"] } csv = {version = "1.4.0"} midly = "0.5.3" -fs-err = "3.3.0" -log = "0.4.29" -hashbrown = "0.17" +fs-err = "3.3.1" +log = "0.4.33" +hashbrown = "0.17.1" toml = "1.1.2" -itertools = "0.14.0" +itertools = "0.15.0" [features] default = ["std"] From d7c5a970d26c38883ed05cc71f991ec24daa85ea Mon Sep 17 00:00:00 2001 From: feldspath Date: Wed, 12 Aug 2026 14:00:09 +0200 Subject: [PATCH 13/17] Remove InputResponse Change-Id: I61d41760aaa7461c3cbd3fa96ec52a934dab7f4c --- .gitignore | 1 + README.md | 40 +++++++++---------- mseq_core/Cargo.toml | 9 +++++ mseq_core/src/conductor.rs | 35 ++++------------ mseq_core/src/context.rs | 36 +++++++---------- mseq_core/src/lib.rs | 2 +- mseq_core/src/midi_controller.rs | 18 ++++++++- mseq_core/tests/input_test.rs | 62 +++++++++++++++++++---------- mseq_core/tests/slave_input_test.rs | 11 +++-- src/lib.rs | 22 +++++----- 10 files changed, 126 insertions(+), 110 deletions(-) diff --git a/.gitignore b/.gitignore index 4fffb2f..1b89935 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /Cargo.lock +/.vscode \ No newline at end of file diff --git a/README.md b/README.md index 7495430..76ad6c6 100644 --- a/README.md +++ b/README.md @@ -9,37 +9,37 @@ ## Features -- 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 -- Step-based deterministic tracks with [`DeteTrack`] +- 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 +- Step-based deterministic tracks with [`DeteTrack`] ## Overview The sequencer is driven by a user-provided [`Conductor`] implementation, which defines how the sequencer initializes, progresses at each clock tick, and reacts to external MIDI messages. -- **No input** → runs standalone with its internal clock and transport, generating MIDI clock and transport messages but ignoring external MIDI input. -- **Master mode** → runs with its internal clock while also processing incoming MIDI events (except for external clock/transport). -- **Slave mode** → synchronizes playback to an external MIDI clock and responds to Start/Stop/Continue messages, dynamically adjusting BPM to match the clock source. +- **No input** → runs standalone with its internal clock and transport, generating MIDI clock and transport messages but ignoring external MIDI input. +- **Master mode** → runs with its internal clock while also processing incoming MIDI events (except for external clock/transport). +- **Slave mode** → synchronizes playback to an external MIDI clock and responds to Start/Stop/Continue messages, dynamically adjusting BPM to match the clock source. ## Conductor Trait 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. 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. +- [`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. It returns a `Vec` processed by the controller: `Instruction::MidiMessage` instructions are forwarded to the output even while paused, while all other instructions are executed only while running. ## 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`. +- 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 @@ -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, InputResponse, Instruction, MidiMessage}; +use mseq::{run, Conductor, Context, Instruction, MidiMessage}; struct MyConductor; @@ -73,10 +73,8 @@ impl Conductor for MyConductor { 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() + fn handle_input(&mut self, _input_id: usize, _input: MidiMessage, _ctx: &Context) -> Vec { + vec![] } } diff --git a/mseq_core/Cargo.toml b/mseq_core/Cargo.toml index d82f0f4..e873c98 100644 --- a/mseq_core/Cargo.toml +++ b/mseq_core/Cargo.toml @@ -10,8 +10,17 @@ description = "Library for developing MIDI Sequencers." keywords = ["midi", "music", "sequencer"] categories = ["multimedia"] +[features] +# Internal-only feature that widens the visibility of a few crate internals so +# that integration tests can reach them. +# Not part of the public API and should not be enabled by consumers. +test-internals = [] + [dependencies] serde = {version = "1.0.228", default-features = false, features = ["derive", "alloc"] } thiserror = { version="2.0.18", default-features = false } hashbrown = "0.17.1" log = "0.4.33" + +[dev-dependencies] +mseq_core = { path = ".", features = ["test-internals"] } diff --git a/mseq_core/src/conductor.rs b/mseq_core/src/conductor.rs index 89822e3..348cd3b 100644 --- a/mseq_core/src/conductor.rs +++ b/mseq_core/src/conductor.rs @@ -1,25 +1,8 @@ +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 @@ -58,13 +41,6 @@ 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 [`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 @@ -82,13 +58,16 @@ pub trait Conductor { /// /// # Returns /// - /// A `Vec` to be sent to the MIDI output immediately. + /// A `Vec` processed by the MIDI controller. [`Instruction::MidiMessage`] + /// instructions are forwarded to the MIDI output even while the sequencer is paused; + /// all other instructions are executed only while the sequencer is running and are + /// dropped while paused. fn handle_input( &mut self, _input_id: usize, _input: MidiMessage, _context: &Context, - ) -> InputResponse { - InputResponse::default() + ) -> Vec { + vec![] } } diff --git a/mseq_core/src/context.rs b/mseq_core/src/context.rs index 7ffd391..7d77b2a 100644 --- a/mseq_core/src/context.rs +++ b/mseq_core/src/context.rs @@ -1,5 +1,4 @@ use crate::Conductor; -use crate::InputResponse; use crate::Instruction; use crate::MidiController; use crate::MidiMessage; @@ -72,8 +71,9 @@ impl Context { /// While paused, the step counter stops advancing, so step-driven tracks hold /// 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. + /// [`Conductor::handle_input`] are dropped too, except for + /// [`Instruction::MidiMessage`] instructions which are still forwarded directly + /// to the MIDI output. pub fn pause(&mut self) { self.on_pause = true; self.sys_instructions.push(Instruction::StopAllNotes); @@ -103,7 +103,7 @@ impl Context { } /// MIDI logic called at the initialization. - /// This function is not intended to be called directly by users. + /// This function is not intended to be called directly by users. /// `init` is used internally to enable code reuse across platforms. pub fn init( &mut self, @@ -117,7 +117,7 @@ impl Context { } /// MIDI logic called before the clock tick. - /// This function is not intended to be called directly by users. + /// This function is not intended to be called directly by users. /// `process_pre_tick` is used internally to enable code reuse across platforms. pub fn process_pre_tick( &mut self, @@ -148,7 +148,7 @@ impl Context { } /// MIDI logic called after the clock tick. - /// This function is not intended to be called directly by users. + /// This function is not intended to be called directly by users. /// `process_post_tick` is used internally to enable code reuse across platforms. pub fn process_post_tick(&mut self, controller: &mut MidiController) { controller.send_clock(); @@ -170,7 +170,7 @@ impl Context { /// Internal MIDI input handler. /// - /// This function is not intended to be called directly by users. + /// This function is not intended to be called directly by users. /// Instead, users should implement [`Conductor::handle_input`] for their custom input handler logic. /// /// `handle_input` is used internally to enable code reuse across platforms and unify MIDI input processing. @@ -179,24 +179,16 @@ impl Context { input_id: usize, conductor: &mut impl Conductor, controller: &mut MidiController, - input_queue: &mut InputQueue, + input_queue: InputQueue, ) { 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 { + input_queue + .into_iter() + .flat_map(|input| conductor.handle_input(input_id, input, self)) + .for_each(|instruction| { + if !paused || matches!(instruction, Instruction::MidiMessage { .. }) { controller.execute(instruction); } - } - } + }); } } diff --git a/mseq_core/src/lib.rs b/mseq_core/src/lib.rs index aa7e142..5887f67 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, InputResponse}; +pub use conductor::Conductor; 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 eb80f27..fd77119 100644 --- a/mseq_core/src/midi_controller.rs +++ b/mseq_core/src/midi_controller.rs @@ -282,7 +282,23 @@ impl MidiController { } } + /// Advance the controller to `next_step`, flushing the pending note off/on + /// messages for the current step. + /// + /// This is an internal entry point driven by [`Context`](crate::Context); + /// it is only made `pub` for integration tests via the internal + /// `test-internals` feature and is not part of the public API. + #[cfg(feature = "test-internals")] + pub fn update(&mut self, next_step: u32) { + self.update_internal(next_step) + } + + #[cfg(not(feature = "test-internals"))] pub(crate) fn update(&mut self, next_step: u32) { + self.update_internal(next_step) + } + + fn update_internal(&mut self, next_step: u32) { // First send the off signal to every note that end this step. let notes = self.play_note_set.remove(&self.step); if let Some(notes_off) = notes { @@ -352,7 +368,7 @@ impl MidiController { /// 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) { + 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 977b7a2..0d30374 100644 --- a/mseq_core/tests/input_test.rs +++ b/mseq_core/tests/input_test.rs @@ -64,7 +64,7 @@ impl Conductor for DebugInputConductor { input_id: usize, input: mseq_core::MidiMessage, _context: &Context, - ) -> InputResponse { + ) -> Vec { // Transpose by an input-dependent amount to prove that `input_id` is // correctly forwarded and that each input is handled independently. The // transposed note is forwarded directly via the `messages` channel. @@ -84,10 +84,10 @@ impl Conductor for DebugInputConductor { } _ => vec![], }; - InputResponse { - messages, - ..Default::default() - } + messages + .into_iter() + .map(|midi_message| Instruction::MidiMessage { midi_message }) + .collect() } } @@ -133,7 +133,12 @@ fn test_conductor_with_input( // 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); + ctx.handle_input( + input_id, + &mut conductor, + &mut midi_controller, + std::mem::take(queue), + ); } } midi_controller.finish(); @@ -182,24 +187,24 @@ impl Conductor for DualChannelConductor { _input_id: usize, _input: MidiMessage, _context: &Context, - ) -> InputResponse { - InputResponse { - instructions: vec![Instruction::MidiMessage { + ) -> Vec { + vec![ + Instruction::MidiMessage { midi_message: MidiMessage::NoteOn { channel: 1, - note: INSTR_NOTE, + note: MSG_NOTE, }, - }], - messages: vec![MidiMessage::NoteOn { - channel: 1, - note: MSG_NOTE, - }], - } + }, + Instruction::StartNote { + midi_note: INSTR_NOTE, + channel_id: 1, + }, + ] } } -/// While paused, `handle_input` forwards the `messages` channel directly but drops -/// the `instructions` channel; once running, both reach the output. +/// While paused, `handle_input` forwards all `Instruction::MidiMessage`s but drops +/// other `Instruction`s; once running, both reach the output. #[test] fn test_handle_input_pause_forwarding() { let debug_conn = Rc::new(RefCell::new(DebugMidiOutInner { @@ -218,13 +223,20 @@ fn test_handle_input_pause_forwarding() { channel: 1, note: MidiNote::new(Note::C, 4, 100), }); - ctx.handle_input(0, &mut conductor, &mut controller, &mut queue); + ctx.handle_input( + 0, + &mut conductor, + &mut controller, + std::mem::take(&mut queue), + ); + + controller.update(1); { let inner = debug_conn.borrow(); - // The direct message was forwarded even while paused... + // The midi message was forwarded even while paused... assert!(inner.notes_on.contains_key(&(1, MSG_NOTE.midi_value()))); - // ...but the instruction was dropped. + // ...but the other instruction was dropped. assert!(!inner.notes_on.contains_key(&(1, INSTR_NOTE.midi_value()))); } @@ -235,7 +247,13 @@ fn test_handle_input_pause_forwarding() { channel: 1, note: MidiNote::new(Note::C, 4, 100), }); - ctx.handle_input(0, &mut conductor, &mut controller, &mut queue); + ctx.handle_input( + 0, + &mut conductor, + &mut controller, + std::mem::take(&mut queue), + ); + controller.update(2); assert!( debug_conn .borrow() diff --git a/mseq_core/tests/slave_input_test.rs b/mseq_core/tests/slave_input_test.rs index 38c296f..4930d52 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, - ) -> InputResponse { + ) -> Vec { self.received.borrow_mut().push((input_id, input)); - InputResponse::default() + vec![] } } @@ -156,7 +156,12 @@ fn test_slave_multi_input() { // 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); + ctx.handle_input( + input_id, + &mut conductor, + &mut controller, + std::mem::take(queue), + ); } // The conductor only ever saw input 1's channel message: no transport message diff --git a/src/lib.rs b/src/lib.rs index b6db858..c5d5b52 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ //! # mseq //! -//! `mseq` is a lightweight MIDI sequencer framework written in Rust. +//! `mseq` is a lightweight MIDI sequencer framework written in Rust. //! It provides a flexible core for building sequencers that can run in **standalone**, **master**, or //! **slave** mode, with synchronization over standard MIDI clock and transport messages. //! @@ -22,7 +22,7 @@ //! The entry point of the crate is the [`run`] function: //! //! ```no_run -//! use mseq::{run, Conductor, Context, InputResponse, Instruction, MidiInParam, MidiMessage}; +//! use mseq::{run, Conductor, Context, Instruction, MidiInParam, MidiMessage}; //! //! struct MyConductor; //! @@ -37,10 +37,8 @@ //! 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() +//! fn handle_input(&mut self, _input_id: usize, _input: MidiMessage, _ctx: &Context) -> Vec { +//! vec![] //! } //! } //! @@ -57,7 +55,7 @@ //! - Real-time MIDI clock generation and synchronization //! - Master/slave transport control with Start/Stop/Continue handling //! - Flexible [`Conductor`] trait for defining sequencer logic -//! - Easy-to-implement tracks via the [`Track`] trait +//! - Easy-to-implement tracks via the [`Track`] trait //! - Thread-safe, minimal core designed for real-time responsiveness #![warn(missing_docs)] @@ -91,14 +89,14 @@ pub enum MSeqError { Track(#[from] TrackError), } -/// `mseq` entry point. +/// `mseq` entry point. /// -/// This function starts the MIDI sequencer by running the given [`Conductor`] implementation. +/// This function starts the MIDI sequencer by running the given [`Conductor`] implementation. /// /// # Parameters /// - `conductor`: User-provided implementation of the [`Conductor`] trait, which defines how the /// sequencer generates and responds to musical events. -/// - `out_port`: MIDI output port ID used to send messages. +/// - `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`: List of [`MidiInParam`], one per MIDI input to open. Each input gets its own queue, @@ -159,7 +157,7 @@ pub fn run( loop { // Wait for messages, then swap them out so the callback isn't blocked // while we process. - let mut pending = { + let pending = { let mut queue = channel.queue.lock().unwrap(); while queue.is_empty() { queue = channel.condvar.wait(queue).unwrap(); @@ -168,7 +166,7 @@ pub fn run( }; let mut r = run_consumer.lock().unwrap(); let (ref mut conductor, ref mut controller, ref mut ctx) = *r; - ctx.handle_input(input_id, conductor, controller, &mut pending); + ctx.handle_input(input_id, conductor, controller, pending); } }); } From 03f9dc185c97d3b2163f648f9a1c29a82c624912 Mon Sep 17 00:00:00 2001 From: julien Date: Thu, 13 Aug 2026 22:29:59 +0200 Subject: [PATCH 14/17] Clean up documentation comments Remove "not intended to be called by the user" notes from private and pub(crate) functions in MidiController, where visibility already enforces it. Fix an unescaped backtick in TrackError::Midly that rustdoc warned about, and drop stray trailing whitespace in MidiMessage::parse's docs. Co-Authored-By: Claude Opus 5 --- mseq_core/src/midi.rs | 2 +- mseq_core/src/midi_controller.rs | 8 -------- mseq_tracks/src/lib.rs | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/mseq_core/src/midi.rs b/mseq_core/src/midi.rs index 87c1ea8..b4b7452 100644 --- a/mseq_core/src/midi.rs +++ b/mseq_core/src/midi.rs @@ -133,7 +133,7 @@ impl MidiMessage { /// Parses a byte slice into a `MidiMessage` struct. /// - /// This function is not intended to be called directly by end users. + /// This function is not intended to be called directly by end users. /// It is used internally to ensure consistent MIDI message parsing logic across platforms. /// /// Returns `Some(MidiMessage)` if the byte slice represents a known and valid MIDI message, diff --git a/mseq_core/src/midi_controller.rs b/mseq_core/src/midi_controller.rs index fd77119..3e9816b 100644 --- a/mseq_core/src/midi_controller.rs +++ b/mseq_core/src/midi_controller.rs @@ -273,8 +273,6 @@ impl MidiController { } } - /// This function is not intended to be called directly by the user. - /// /// This function directly sends a MIDI message. fn send_continue(&mut self) { if let Err(e) = self.midi_out.send_continue() { @@ -328,8 +326,6 @@ impl MidiController { self.step = next_step; } - /// This function is not intended to be called directly by the user. - /// /// This function directly sends MIDI messages. pub(crate) fn stop_all_notes(&mut self) { self.start_note_set.iter().for_each(|n| { @@ -355,8 +351,6 @@ impl MidiController { self.play_note_set.clear(); } - /// This function is not intended to be called directly by the user. - /// /// This function directly send a MIDI message. pub(crate) fn stop(&mut self) { if let Err(e) = self.midi_out.send_stop() { @@ -366,8 +360,6 @@ impl MidiController { /// 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. fn send_message(&mut self, message: MidiMessage) { if let Err(e) = self.midi_out.send_message(message) { error!("MIDI: {e}"); diff --git a/mseq_tracks/src/lib.rs b/mseq_tracks/src/lib.rs index acf9140..3634934 100644 --- a/mseq_tracks/src/lib.rs +++ b/mseq_tracks/src/lib.rs @@ -46,7 +46,7 @@ pub enum TrackError { #[cfg(feature = "std")] #[error("Failed to read file: {0}")] Io(#[from] std::io::Error), - /// MIDI parsing error from `midly + /// MIDI parsing error from `midly` #[cfg(feature = "std")] #[error("Midly error: {0}")] Midly(#[from] midly::Error), From 4270568f6e92c6a46eabf010983d4ad5e21050ce Mon Sep 17 00:00:00 2001 From: julien Date: Fri, 21 Aug 2026 23:19:50 +0200 Subject: [PATCH 15/17] Do not panic on a zero-length DeteTrack DeteTrack derives Default, so DeteTrack::default() and any deserialized track can carry len = 0. Both play_step and the public get_notes_start_at_step then computed step % self.len and panicked. They now log a warning and return no instruction. The warning fires on every call rather than once: a flag field would leak into DeteTrack's Serialize, Deserialize, PartialEq and Eq derives. Co-Authored-By: Claude Opus 5 --- mseq_core/src/track.rs | 11 +++++++++++ mseq_core/tests/unit_tests.rs | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/mseq_core/src/track.rs b/mseq_core/src/track.rs index ccf8c35..bcb7b44 100644 --- a/mseq_core/src/track.rs +++ b/mseq_core/src/track.rs @@ -1,6 +1,7 @@ use crate::MidiNote; use crate::midi_controller::Instruction; use crate::note::Note; +use log::warn; use serde::{Deserialize, Serialize}; use alloc::string::{String, ToString}; @@ -29,6 +30,8 @@ pub trait Track { /// `DeteTrack` implements the [`Track`] trait by playing a fixed pattern /// in a continuous loop. Each call to `play_step` produces the same /// sequence of instructions based on the step index modulo the pattern length. +/// +/// A track with a length of 0 plays nothing and logs a warning. #[derive(Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct DeteTrack { len: u32, @@ -42,6 +45,10 @@ pub struct DeteTrack { impl Track for DeteTrack { fn play_step(&mut self, step: u32) -> Vec { + if self.len == 0 { + warn!("Track {} has a length of 0, no note is played", self.name); + return vec![]; + } let cur_step = step % self.len; self.notes .iter() @@ -96,6 +103,10 @@ impl DeteTrack { /// Returns the all `(note, length)`, that start at `step`. Transposition and start step are /// taken into account. pub fn get_notes_start_at_step(&self, step: u32) -> Vec<(MidiNote, u32)> { + if self.len == 0 { + warn!("Track {} has a length of 0, no note is played", self.name); + return vec![]; + } let mut notes = vec![]; let cur_step = step % self.len; for n in &self.notes { diff --git a/mseq_core/tests/unit_tests.rs b/mseq_core/tests/unit_tests.rs index d1001a7..3ef3001 100644 --- a/mseq_core/tests/unit_tests.rs +++ b/mseq_core/tests/unit_tests.rs @@ -182,6 +182,25 @@ fn dete_track_transpose() { test_conductor(conductor, midi); } +#[test] +fn dete_track_zero_len() { + // A track of length 0 must stay silent instead of panicking. + let mut track = DeteTrack::default(); + assert!(track.play_step(0).is_empty()); + assert!(track.play_step(7).is_empty()); + assert!(track.get_notes_start_at_step(0).is_empty()); + + let mut track = DeteTrack::new( + 0, + vec![(MidiNote::new(Note::A, 4, 89), 0, 12)], + Note::A, + 1, + "test_zero_len", + ); + assert!(track.play_step(0).is_empty()); + assert!(track.get_notes_start_at_step(3).is_empty()); +} + pub fn test_conductor( mut conductor: impl Conductor, mut midi_controller: MidiController, From 21377c3a7254a85633de02470e65163fe74396de Mon Sep 17 00:00:00 2001 From: julien Date: Fri, 21 Aug 2026 23:19:57 +0200 Subject: [PATCH 16/17] Make mseq_tracks no_std when the std feature is off The std feature gated the file loaders, but the crate had no #![no_std] and pulled csv, midly, toml and fs-err unconditionally, so --no-default-features removed most of the API without making the crate usable on a bare-metal target. Every dependency used only behind cfg(feature = "std") is now optional and enabled by that feature, serde drops down to default-features = false with derive and alloc, and the crate declares no_std when std is off. cargo build -p mseq_tracks --no-default-features --target thumbv7em-none-eabihf now succeeds. Also drop midir, which mseq_tracks never referenced, and give each crate its own description: all three shared "Library for developing MIDI Sequencers.", which made them indistinguishable on crates.io. Co-Authored-By: Claude Opus 5 --- Cargo.toml | 2 +- mseq_core/Cargo.toml | 2 +- mseq_tracks/Cargo.toml | 37 +++++++++++++++++++++++++------------ mseq_tracks/src/lib.rs | 16 ++++++++++------ 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 15d83d4..6ed5dd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ license = "LGPL-2.1" readme = "README.md" repository = "https://github.com/MF-Room/mseq" authors = ["Julien Eudine ", "Marius Debussche "] -description = "Library for developing MIDI Sequencers." +description = "Framework for building MIDI sequencers, with clock and transport synchronization." keywords = ["midi", "music", "sequencer"] categories = ["multimedia"] diff --git a/mseq_core/Cargo.toml b/mseq_core/Cargo.toml index e873c98..341a3cb 100644 --- a/mseq_core/Cargo.toml +++ b/mseq_core/Cargo.toml @@ -6,7 +6,7 @@ license = "LGPL-2.1" readme = "README.md" repository = "https://github.com/MF-Room/mseq/tree/main/mseq_core" authors = ["Julien Eudine ", "Marius Debussche "] -description = "Library for developing MIDI Sequencers." +description = "Portable no_std core of the mseq MIDI sequencer framework." keywords = ["midi", "music", "sequencer"] categories = ["multimedia"] diff --git a/mseq_tracks/Cargo.toml b/mseq_tracks/Cargo.toml index 9375e97..b0bd0fa 100644 --- a/mseq_tracks/Cargo.toml +++ b/mseq_tracks/Cargo.toml @@ -6,24 +6,37 @@ license = "LGPL-2.1" readme = "README.md" repository = "https://github.com/MF-Room/mseq/tree/main/mseq_tracks" authors = ["Julien Eudine ", "Marius Debussche "] -description = "Library for developing MIDI Sequencers." +description = "Track builders and loaders (acid, arp, clock divider, MIDI files) for the mseq sequencer." keywords = ["midi", "music", "sequencer"] categories = ["multimedia"] [dependencies] mseq_core = { version = "1.0.0", path = "../mseq_core" } -thiserror = "2.0.18" -midir = "0.11.0" -serde = {version = "1.0.228", features = ["derive"] } -csv = {version = "1.4.0"} -midly = "0.5.3" -fs-err = "3.3.1" -log = "0.4.33" -hashbrown = "0.17.1" -toml = "1.1.2" -itertools = "0.15.0" +serde = { version = "1.0.228", default-features = false, features = ["derive", "alloc"] } + +# Only used by the file loading code, which the `std` feature gates. +csv = { version = "1.4.0", optional = true } +fs-err = { version = "3.3.1", optional = true } +hashbrown = { version = "0.17.1", optional = true } +itertools = { version = "0.15.0", optional = true } +log = { version = "0.4.33", optional = true } +midly = { version = "0.5.3", optional = true } +thiserror = { version = "2.0.18", optional = true } +toml = { version = "1.1.2", optional = true } [features] default = ["std"] -std = [] +# File loading (`load_from_file`, the `midi` and `index` modules) and the `TrackError` type. +# Without it the crate is `no_std` and only the in-memory track builders remain. +std = [ + "dep:csv", + "dep:fs-err", + "dep:hashbrown", + "dep:itertools", + "dep:log", + "dep:midly", + "dep:thiserror", + "dep:toml", + "serde/std", +] diff --git a/mseq_tracks/src/lib.rs b/mseq_tracks/src/lib.rs index 3634934..989a107 100644 --- a/mseq_tracks/src/lib.rs +++ b/mseq_tracks/src/lib.rs @@ -1,13 +1,18 @@ -//! # mseq_tracks – MSeq Tracks Construction and Loading +//! # mseq_tracks //! -//! This crate provides utilities to define, generate, and load different kinds of -//! sequencer tracks into the MSeq engine. It serves as a bridge between high-level -//! musical concepts (e.g., acid patterns, arpeggios, dividers, MIDI files) and -//! the internal [`DeteTrack`] representation used by the sequencer. +//! Track builders and loaders for the [`mseq`](https://crates.io/crates/mseq) sequencer. +//! This crate turns high level musical descriptions (acid patterns, arpeggios, dividers, +//! MIDI files) into the [`DeteTrack`] type that the sequencer plays. +//! +//! You do not normally depend on it directly: `mseq` re-exports every module below, so +//! `mseq::acid::load_from_file(..)` works out of the box. //! //! [`DeteTrack`]: mseq_core::DeteTrack #![warn(missing_docs)] +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; /// The `acid` module provides tools for generating and loading acid-style /// tracks into MSeq. @@ -23,7 +28,6 @@ pub mod div; /// The `midi` module provides tools for generating and loading MIDI /// tracks into MSeq. pub mod midi; -extern crate alloc; #[cfg(feature = "std")] /// The `index` module provides functionality for loading multiple track types From ab8d06ae2e41d39f985c009d6982f324cda6b451 Mon Sep 17 00:00:00 2001 From: julien Date: Fri, 21 Aug 2026 23:20:05 +0200 Subject: [PATCH 17/17] Add an architecture schematic and rework the READMEs Add docs/architecture.svg, a single figure showing what the user implements (Conductor, Tracks) and what the engine does with it: the direct-forward path, the sequencer path and transport. Both the mseq and mseq_core READMEs embed it. Fix the root README, whose rustdoc-style links all rendered as literal [Conductor] text since the file is not included by src/lib.rs. Reference definitions now point at docs.rs. Move the usage example to the top, make it play a note, and have it call ctx.start(), the sequencer starting paused being the one gotcha that made a first conductor silent. Replace the DeteTrack::default() snippet, which panicked, with a real DeteTrack::new call. Merge the Overview and Conductor Trait sections, which said the same thing twice. Give mseq_core a line saying it has no MIDI I/O or run loop of its own, and mseq_tracks a track table, an example and its feature flags. Keep the crate-level docs in sync with each README. Co-Authored-By: Claude Opus 5 --- README.md | 147 ++++++++++++++++++++++++++---------------- docs/architecture.svg | 90 ++++++++++++++++++++++++++ mseq_core/README.md | 28 +++++++- mseq_core/src/lib.rs | 13 +++- mseq_tracks/README.md | 45 +++++++++++-- src/lib.rs | 28 +++++--- 6 files changed, 281 insertions(+), 70 deletions(-) create mode 100644 docs/architecture.svg diff --git a/README.md b/README.md index 76ad6c6..f57f0a0 100644 --- a/README.md +++ b/README.md @@ -7,84 +7,123 @@ `mseq` is a lightweight MIDI sequencer framework written in Rust. It provides a flexible core for building sequencers that can run in **standalone**, **master**, or **slave** mode, with synchronization over standard MIDI clock and transport messages. -## Features +## Quick Start -- 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 -- Step-based deterministic tracks with [`DeteTrack`] +Add the crate with `cargo add mseq`, then implement a [`Conductor`] and hand it to [`run`]: -## Overview +```rust +use mseq::{run, Conductor, Context, Instruction, MidiNote, Note}; -The sequencer is driven by a user-provided [`Conductor`] implementation, which defines how the sequencer initializes, progresses at each clock tick, and reacts to external MIDI messages. +struct MyConductor; -- **No input** → runs standalone with its internal clock and transport, generating MIDI clock and transport messages but ignoring external MIDI input. -- **Master mode** → runs with its internal clock while also processing incoming MIDI events (except for external clock/transport). -- **Slave mode** → synchronizes playback to an external MIDI clock and responds to Start/Stop/Continue messages, dynamically adjusting BPM to match the clock source. +impl Conductor for MyConductor { + fn init(&mut self, ctx: &mut Context) -> Vec { + ctx.set_bpm(120); + // The sequencer starts paused: nothing plays until you call start(). + ctx.start(); + vec![] + } -## Conductor Trait + fn update(&mut self, ctx: &mut Context) -> Vec { + // update() runs on every MIDI clock pulse, so there are 24 steps per quarter note. + if ctx.get_step() % 24 == 0 { + return vec![Instruction::PlayNote { + midi_note: MidiNote::new(Note::C, 4, 100), + len: 12, + channel_id: 1, + }]; + } + vec![] + } +} -A `Conductor` defines how your sequencer behaves: +fn main() -> Result<(), mseq::MSeqError> { + // `None` asks the user to pick an output port, the empty Vec means no MIDI input. + run(MyConductor, None, Vec::new()) +} +``` -- [`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. It returns a `Vec` processed by the controller: `Instruction::MidiMessage` instructions are forwarded to the output even while paused, while all other instructions are executed only while running. +## Architecture -## MIDI Inputs +You implement a [`Conductor`], and optionally one or more [`Track`]s. The engine calls `init` once at +startup, `update` on every MIDI clock pulse, and `handle_input` whenever a MIDI message comes in. +Every call receives a `Context` and returns a `Vec`: control changes and raw messages +are forwarded straight to the output, while notes are played on the step grid with their note-offs +scheduled for you. Transport (`start`, `pause`, `resume`, `quit`) is driven through the `Context`. +This is the whole surface you deal with: -[`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`. +

+ mseq_core: what the user implements and what the engine does with it +

-- 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`. +## Conductor + +A [`Conductor`] defines how your sequencer behaves: + +- [`Conductor::init`] is called once at startup, to set up state and emit initial [`Instruction`]s (program changes, reset messages, and so on). Call `ctx.start()` here to leave the initial pause. +- [`Conductor::update`] is called at every clock tick, to advance the sequencer and emit the instructions for that tick. +- [`Conductor::handle_input`] is called when a [`MidiMessage`] arrives, with a 0-based `input_id` telling you which input it came from. While paused, only `Instruction::MidiMessage` is forwarded to the output, and every other instruction is dropped. + +How the sequencer is clocked depends on the inputs you give to [`run`]: + +- **No input** → runs standalone with its internal clock and transport, generating MIDI clock and transport messages but ignoring external MIDI input. +- **Master mode** → runs with its internal clock while also processing incoming MIDI events (except for external clock/transport). +- **Slave mode** → synchronizes playback to an external MIDI clock and responds to Start/Stop/Continue messages, dynamically adjusting BPM to match the clock source. ## Tracks -Sequencers can also be built around the [`Track`] trait, which provides a simple interface for describing step-based musical patterns. Each track produces a set of [`Instruction`]s at a given step. +Sequencers can also be built around the [`Track`] trait, which describes step-based musical patterns. Each track produces a set of [`Instruction`]s at a given step, so a track is usually played by calling `play_step(ctx.get_step())` from `update` and returning the result. The provided [`DeteTrack`] implements a deterministic looping track: ```rust -use mseq::{Track, DeteTrack, Instruction}; - -let mut track = DeteTrack::default(); -// On each tick, play the instructions for the current step -let instructions: Vec = track.play_step(step); +use mseq::{DeteTrack, MidiNote, Note}; + +// Two notes over 24 steps (one quarter note), looping, on MIDI channel 1. +let track = DeteTrack::new( + 24, + vec![ + // (note, start step, length in steps) + (MidiNote::new(Note::A, 4, 89), 0, 12), + (MidiNote::new(Note::C, 5, 89), 12, 12), + ], + Note::A, // Root note, used as the reference for transposition + 1, + "my_track", +); ``` -This makes it easy to implement custom track types, from simple step sequencers to more complex algorithmic patterns. - -## Usage -The entry point of the crate is the [`run`] function: -```rust -use mseq::{run, Conductor, Context, Instruction, MidiMessage}; +Implementing [`Track`] yourself is just as easy, from simple step sequencers to more complex algorithmic patterns. -struct MyConductor; +## MIDI Inputs -impl Conductor for MyConductor { - fn init(&mut self, _ctx: &mut Context) -> Vec { - vec![] - } +[`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`. - fn update(&mut self, _ctx: &mut Context) -> Vec { - vec![] - } +- 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`. - fn handle_input(&mut self, _input_id: usize, _input: MidiMessage, _ctx: &Context) -> Vec { - vec![] - } -} +## Features -fn main() -> Result<(), mseq::MSeqError> { - let conductor = MyConductor; - 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) -} -``` +- 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 +- Step-based deterministic tracks with [`DeteTrack`] ## Examples + You can find ready-to-run examples in the [examples](https://github.com/MF-Room/mseq/tree/main/examples) directory. They demonstrate various usage patterns, from simple standalone sequencers to multi-track setups. + +[`Conductor`]: https://docs.rs/mseq/latest/mseq/trait.Conductor.html +[`Conductor::init`]: https://docs.rs/mseq/latest/mseq/trait.Conductor.html#tymethod.init +[`Conductor::update`]: https://docs.rs/mseq/latest/mseq/trait.Conductor.html#tymethod.update +[`Conductor::handle_input`]: https://docs.rs/mseq/latest/mseq/trait.Conductor.html#method.handle_input +[`Track`]: https://docs.rs/mseq/latest/mseq/trait.Track.html +[`DeteTrack`]: https://docs.rs/mseq/latest/mseq/struct.DeteTrack.html +[`Instruction`]: https://docs.rs/mseq/latest/mseq/enum.Instruction.html +[`MidiMessage`]: https://docs.rs/mseq/latest/mseq/enum.MidiMessage.html +[`run`]: https://docs.rs/mseq/latest/mseq/fn.run.html diff --git a/docs/architecture.svg b/docs/architecture.svg new file mode 100644 index 0000000..9c4b998 --- /dev/null +++ b/docs/architecture.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + USER CODE + + + + Tracks + + Track::play_step + + + + + Track::play_step + + + + Conductor + + init + once, at startup + + update + on every MIDI clock pulse + + handle_input + on every incoming message + channel messages only + + + + ENGINE + + + + + Direct forward + SendCC · SendPitchBend · MidiMessage + sent right away + + + Sequencer + PlayNote · StartNote · StopNote + sent on the step + + + Transport + Clock · Start · Stop · Continue + + + + MIDI IN + + + MIDI OUT + + + + + + + + + + + + + + + + + + + instructions + start · pause + resume · quit + diff --git a/mseq_core/README.md b/mseq_core/README.md index 45ab3bd..ccd51c5 100644 --- a/mseq_core/README.md +++ b/mseq_core/README.md @@ -7,16 +7,42 @@ Core framework for building custom MIDI sequencers. your own MIDI sequencer, with a focus on portability and modularity. This crate is built with `#![no_std]`, making it suitable for embedded platforms as well as standard operating systems. + +It is the portable engine only: it has no MIDI I/O and no run loop of its own, so it is always +used through a platform layer. On a desktop OS, use [`mseq`](https://crates.io/crates/mseq), +which wraps this crate and provides both. + ## Getting Started + To create a custom sequencer, you typically: + - Implement the `Conductor` trait to define your sequencer's control logic. + The sequencer starts paused, so call `context.start()` from `init` to get it playing. - Define one or more tracks by either: - Implementing the `Track` trait for custom behavior. - Instantiating `DeteTrack` for deterministic, looping patterns. + +## Architecture + +You implement a `Conductor`, and optionally one or more `Track`s. The engine calls `init` once at +startup, `update` on every MIDI clock pulse, and `handle_input` whenever a MIDI message comes in. +Every call receives a `Context` and returns a `Vec`: control changes and raw messages +are forwarded straight to the output, while notes are played on the step grid with their note-offs +scheduled for you. Transport (`start`, `pause`, `resume`, `quit`) is driven through the `Context`. +This is the whole surface you deal with: + +

+ mseq_core: what the user implements and what the engine does with it +

+ ## Platform Support -- For OS-based systems, use the [`mseq`](https://crates.io/crates/mseq) crate — a reference implementation of `mseq_core` for standard platforms. + +- For OS-based systems, use the [`mseq`](https://crates.io/crates/mseq) crate, a reference implementation of `mseq_core` for standard platforms. - For embedded development (e.g., STM32F4), see the [`mseq_embedded`](https://github.com/MF-Room/mseq_embedded) repository, which provides an STM32-specific integration of `mseq_core`. + ## Crate Features + - No `std` dependency (`#![no_std]` compatible). - Modular and extensible design. - Reusable across multiple platforms. diff --git a/mseq_core/src/lib.rs b/mseq_core/src/lib.rs index 5887f67..6d9d6b7 100644 --- a/mseq_core/src/lib.rs +++ b/mseq_core/src/lib.rs @@ -8,18 +8,29 @@ //! This crate is built with `#![no_std]`, making it suitable for embedded platforms //! as well as standard operating systems. //! +//! It is the portable engine only: it has no MIDI I/O and no run loop of its own, so it is always +//! used through a platform layer. On a desktop OS, use [`mseq`](https://crates.io/crates/mseq), +//! which wraps this crate and provides both. +//! //! ## Getting Started //! //! To create a custom sequencer, you typically: //! //! - Implement the [`Conductor`] trait to define your sequencer's control logic. +//! The sequencer starts paused, so call [`Context::start`] from [`Conductor::init`] to get it +//! playing. //! - Define one or more tracks by either: //! - Implementing the [`Track`] trait for custom behavior. //! - Instantiating [`DeteTrack`] for deterministic, looping patterns. //! +//! ## Architecture +//! +//! See the [schematic](https://github.com/MF-Room/mseq/blob/main/mseq_core/README.md#architecture) +//! of what you implement and what the engine does with it. +//! //! ## Platform Support //! -//! - For OS-based systems, use the [`mseq`](https://crates.io/crates/mseq) crate — a reference implementation of `mseq_core` for standard platforms. +//! - For OS-based systems, use the [`mseq`](https://crates.io/crates/mseq) crate, a reference implementation of `mseq_core` for standard platforms. //! - For embedded development (e.g., STM32F4), see the [`mseq_embedded`](https://github.com/MF-Room/mseq_embedded) repository, which provides an STM32-specific integration of `mseq_core`. //! //! ## Crate Features diff --git a/mseq_tracks/README.md b/mseq_tracks/README.md index 56fe805..2ffe796 100644 --- a/mseq_tracks/README.md +++ b/mseq_tracks/README.md @@ -1,8 +1,43 @@ -# mseq_tracks – MSeq Tracks Construction and Loading +# mseq_tracks [![doc](https://docs.rs/mseq_tracks/badge.svg)](https://docs.rs/mseq_tracks) [![crates.io](https://img.shields.io/crates/v/mseq_tracks.svg)](https://crates.io/crates/mseq_tracks) -This crate provides utilities to define, generate, and load different kinds of -sequencer tracks into the [`MSeq`](https://crates.io/crates/mseq) engine. It serves as a bridge between high-level -musical concepts (e.g., acid patterns, arpeggios, dividers, MIDI files) and -the internal `DeteTrack` representation used by the sequencer. +Track builders and loaders for the [`mseq`](https://crates.io/crates/mseq) sequencer. This crate +turns high level musical descriptions (acid patterns, arpeggios, clock dividers, MIDI files) into +the `DeteTrack` type that the sequencer plays. + +You do not normally depend on it directly: `mseq` re-exports every module below, so +`mseq::acid::load_from_file(..)` works out of the box. + +## Track Types + +| Module | Produces | Built from | +|---|---|---| +| `acid` | TB-303 style patterns, with rests and ties | `acid::new` or a CSV file | +| `arp` | Arpeggios at a quarter, eighth or sixteenth division | `arp::new` or a CSV file | +| `div` | Clock divider patterns triggering a single note | `div::new` or a CSV file | +| `midi` | A single track MIDI file | `midi::load_from_file` | +| `index` | Several tracks at once | a TOML index file | + +Every entry returns a `DeteTrack`, ready to be played from `Conductor::update`. + +## Example + +```rust +use mseq::{Note, Track}; + +let mut acid = mseq::acid::load_from_file("examples/res/acid_0.csv", Note::A, 1, "my_acid").unwrap(); + +// From Conductor::update, play the track at the current step: +let instructions = acid.play_step(ctx.get_step()); +``` + +Sample CSV files live in [examples/res](https://github.com/MF-Room/mseq/tree/main/examples/res), +and a sample index file in +[mseq_tracks/tests/res/index.toml](https://github.com/MF-Room/mseq/blob/main/mseq_tracks/tests/res/index.toml). + +## Feature Flags + +- `std` (default): file loading and the `TrackError` type. Without it the crate is `no_std`, + the `midi` and `index` modules disappear, and only the in-memory builders `acid::new`, + `arp::new` and `div::new` remain. diff --git a/src/lib.rs b/src/lib.rs index c5d5b52..4f88140 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,22 +22,27 @@ //! 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, Instruction, MidiNote, Note}; //! //! struct MyConductor; //! //! impl Conductor for MyConductor { -//! fn init(&mut self, _ctx: &mut Context) -> Vec { -//! // Return setup instructions (e.g., reset all controllers) +//! fn init(&mut self, ctx: &mut Context) -> Vec { +//! ctx.set_bpm(120); +//! // The sequencer starts paused: nothing plays until you call start(). +//! ctx.start(); //! vec![] //! } //! -//! fn update(&mut self, _ctx: &mut Context) -> Vec { -//! // Called each clock tick: return note events or other MIDI instructions -//! vec![] -//! } -//! -//! fn handle_input(&mut self, _input_id: usize, _input: MidiMessage, _ctx: &Context) -> Vec { +//! fn update(&mut self, ctx: &mut Context) -> Vec { +//! // update() runs on every MIDI clock pulse, so there are 24 steps per quarter note. +//! if ctx.get_step() % 24 == 0 { +//! return vec![Instruction::PlayNote { +//! midi_note: MidiNote::new(Note::C, 4, 100), +//! len: 12, +//! channel_id: 1, +//! }]; +//! } //! vec![] //! } //! } @@ -50,6 +55,11 @@ //! } //! ``` //! +//! ## Architecture +//! +//! See the [schematic](https://github.com/MF-Room/mseq/blob/main/README.md#architecture) +//! of what you implement and what the engine does with it. +//! //! ## Features //! //! - Real-time MIDI clock generation and synchronization