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 diff --git a/Cargo.toml b/Cargo.toml index df7c9af..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" diff --git a/README.md b/README.md index 6204241..7495430 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. 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 + +[`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 @@ -51,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; @@ -64,15 +73,17 @@ impl Conductor for MyConductor { vec![] } - fn handle_input(&mut self, 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() } } 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) } ``` 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/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/mseq_core/src/conductor.rs b/mseq_core/src/conductor.rs index 3140d03..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,6 +43,10 @@ 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, 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 /// /// A `Vec` containing the actions to be passed to the MIDI controller @@ -37,8 +58,21 @@ 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. + /// 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 + /// + /// - `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 /// @@ -49,7 +83,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 { - vec![] + fn handle_input( + &mut self, + _input_id: usize, + _input: MidiMessage, + _context: &Context, + ) -> InputResponse { + InputResponse::default() } } diff --git a/mseq_core/src/context.rs b/mseq_core/src/context.rs index 637f5ed..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; @@ -24,7 +25,6 @@ pub struct Context { step: u32, running: bool, on_pause: bool, - pause: bool, sys_instructions: Vec, } @@ -40,7 +40,6 @@ impl Default for Context { step: 0, running: true, on_pause: true, - pause: false, sys_instructions: vec![], } } @@ -69,9 +68,14 @@ 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 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.pause = true; self.sys_instructions.push(Instruction::StopAllNotes); self.sys_instructions.push(Instruction::Stop); } @@ -120,9 +124,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); @@ -131,7 +133,18 @@ impl Context { .update(self) .into_iter() .for_each(|instruction| controller.execute(instruction)); - }; + } + } + + /// 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. @@ -142,8 +155,6 @@ impl Context { if !self.on_pause { self.step += 1; controller.update(self.step); - } else if self.pause { - self.pause = false; } } @@ -165,20 +176,27 @@ 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, ) { - if self.is_paused() { - input_queue - .drain(..) - .flat_map(|message| conductor.handle_input(message, self)) - .for_each(drop); - } else { - input_queue - .drain(..) - .flat_map(|message| conductor.handle_input(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.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/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 4fccdcc..977b7a2 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,32 +61,37 @@ impl Conductor for DebugInputConductor { fn handle_input( &mut self, + input_id: usize, input: mseq_core::MidiMessage, _context: &Context, - ) -> Vec { - match input { + ) -> InputResponse { + // 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. + let semitones = 3 + input_id as i8; + let messages = match input { mseq_core::MidiMessage::NoteOff { channel, note } => { - vec![Instruction::MidiMessage { - midi_message: MidiMessage::NoteOff { - channel, - note: note.transpose(3), - }, + 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(3), - }, + vec![MidiMessage::NoteOn { + channel, + note: note.transpose(semitones), }] } _ => vec![], + }; + InputResponse { + messages, + ..Default::default() } } } -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 +116,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(); } @@ -124,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 new file mode 100644 index 0000000..38c296f --- /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, + ) -> InputResponse { + self.received.borrow_mut().push((input_id, input)); + InputResponse::default() + } +} + +/// 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/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"] } diff --git a/src/lib.rs b/src/lib.rs index 67e37f5..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,15 +37,17 @@ //! vec![] //! } //! -//! fn handle_input(&mut self, 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() //! } //! } //! //! 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) //! } //! ``` @@ -69,7 +71,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; @@ -99,17 +101,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 +121,63 @@ 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 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))); + + // 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 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(conductor, controller, &mut queue); + ctx.handle_input(input_id, conductor, controller, &mut pending); } }); - 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(slave) = slave_system { + run_slave(run, slave) } else { - run_no_input(ctx, midi_controller, conductor) + run_master(run) } } @@ -200,8 +234,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(); @@ -212,7 +245,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 { @@ -222,43 +255,49 @@ fn run_slave( ctx.process_pre_tick(conductor, controller); } - // Check the slave system queue enum SysMessage { Start, Stop, Continue, } - let mut sys_message = None; - - // We quit the loop if we receive clock message + // 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 queue = &mut *mutex; let mut quit_loop = false; - - while let Some(message) = queue.pop_front() { - match message { - MidiMessage::Clock => { - quit_loop = true; - } - MidiMessage::Start => { - sys_message = Some(SysMessage::Start); - } - MidiMessage::Stop => { - sys_message = Some(SysMessage::Stop); + let mut transport = vec![]; + { + 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!(), } - MidiMessage::Continue => { - sys_message = Some(SysMessage::Continue); + } + } + + // 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; + for message in transport { + match message { + SysMessage::Start => ctx.start(), + SysMessage::Stop => ctx.pause(), + SysMessage::Continue => ctx.resume(), } - _ => unreachable!(), } + ctx.flush_sys_instructions(controller); } if quit_loop { break; } - - let _r = sys_cond_var.wait(mutex).unwrap(); } let mut r = run.lock().unwrap(); @@ -268,19 +307,16 @@ 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) { + // 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) + { ctx.set_bpm(bpm as u8); } 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; diff --git a/src/midi_connection.rs b/src/midi_connection.rs index 60ab6a0..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()), + } + } -pub(crate) struct InQueues { - pub message: QueueCondvar, - pub slave_system: Option, - _connection: midir::MidiInputConnection<(QueueCondvar, Option)>, + /// 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: NotifyQueue, + pub slave_system: Option, + _connection: midir::MidiInputConnection<(NotifyQueue, Option)>, } /// MIDI input connection parameters. @@ -155,14 +176,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 and a warning is logged. 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 +222,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, @@ -199,14 +232,9 @@ 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 { - Some(( - Arc::new(Mutex::new(InputQueue::new())), - Arc::new(Condvar::new()), - )) + let message = NotifyQueue::new(); + let slave_system = if is_slave { + Some(NotifyQueue::new()) } else { None }; @@ -219,27 +247,21 @@ pub(crate) fn connect(params: MidiInParam) -> Result { 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(slave) = &input.1 { + slave.push(m); } + } else { + input.0.push(m); } } }, input, )?; - Ok(InQueues { + Ok(InConnection { message, slave_system, _connection,