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/.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/Cargo.toml b/Cargo.toml index df7c9af..6ed5dd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,29 +4,29 @@ 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" 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"] [dependencies] -mseq_core = "0.1.6" -mseq_tracks = "0.2.5" -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/README.md b/README.md index 6204241..f57f0a0 100644 --- a/README.md +++ b/README.md @@ -7,75 +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 + +Add the crate with `cargo add mseq`, then implement a [`Conductor`] and hand it to [`run`]: + +```rust +use mseq::{run, Conductor, Context, Instruction, MidiNote, Note}; + +struct MyConductor; + +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![] + } + + 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![] + } +} + +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()) +} +``` + +## Architecture -- 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 -- Thread-safe, minimal core designed for real-time responsiveness -- Step-based deterministic tracks with [`DeteTrack`] +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: -## Overview +

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

-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. +## Conductor -- **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. +A [`Conductor`] defines how your sequencer behaves: -## Conductor Trait +- [`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. -A `Conductor` defines how your sequencer behaves: +How the sequencer is clocked depends on the inputs you give to [`run`]: -- [`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. +- **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: +Implementing [`Track`] yourself is just as easy, from simple step sequencers to more complex algorithmic patterns. -```rust -use mseq::{run, Conductor, Context, Instruction, MidiMessage}; +## MIDI Inputs -struct MyConductor; +[`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`. -impl Conductor for MyConductor { - fn init(&mut self, _ctx: &mut Context) -> Vec { - vec![] - } - - 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: MidiMessage, _ctx: &Context) -> Vec { - vec![] - } -} +## Features -fn main() -> Result<(), mseq::MSeqError> { - let conductor = MyConductor; - let out_port = None; - let midi_in = None; - 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/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/Cargo.toml b/mseq_core/Cargo.toml index ebecc78..341a3cb 100644 --- a/mseq_core/Cargo.toml +++ b/mseq_core/Cargo.toml @@ -1,17 +1,26 @@ [package] name = "mseq_core" -version = "0.1.6" +version = "1.0.0" edition = "2024" 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"] +[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.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" + +[dev-dependencies] +mseq_core = { path = ".", features = ["test-internals"] } 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/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..348cd3b 100644 --- a/mseq_core/src/conductor.rs +++ b/mseq_core/src/conductor.rs @@ -26,6 +26,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 +41,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. + /// 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 /// @@ -48,8 +58,16 @@ pub trait Conductor { /// /// # Returns /// - /// A `Vec` to be sent to the MIDI output immediately. - fn handle_input(&mut self, _input: MidiMessage, _context: &Context) -> Vec { + /// 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, + ) -> Vec { vec![] } } diff --git a/mseq_core/src/context.rs b/mseq_core/src/context.rs index 637f5ed..7d77b2a 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![], } } @@ -69,9 +67,15 @@ 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, except for + /// [`Instruction::MidiMessage`] instructions which are still forwarded directly + /// to the MIDI output. pub fn pause(&mut self) { self.on_pause = true; - self.pause = true; self.sys_instructions.push(Instruction::StopAllNotes); self.sys_instructions.push(Instruction::Stop); } @@ -99,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, @@ -113,16 +117,14 @@ 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, 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,19 +133,28 @@ 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. - /// 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(); if !self.on_pause { self.step += 1; controller.update(self.step); - } else if self.pause { - self.pause = false; } } @@ -159,26 +170,25 @@ 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. pub fn handle_input( &mut self, + input_id: usize, conductor: &mut impl Conductor, controller: &mut MidiController, - input_queue: &mut InputQueue, + input_queue: 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(); + 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 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_core/src/midi.rs b/mseq_core/src/midi.rs index 15bb3af..b4b7452 100644 --- a/mseq_core/src/midi.rs +++ b/mseq_core/src/midi.rs @@ -117,9 +117,23 @@ 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. + /// 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 c9d9663..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() { @@ -282,7 +280,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 { @@ -312,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| { @@ -339,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() { @@ -348,6 +358,8 @@ impl MidiController { } } + /// Forwards a [`MidiMessage`] straight to the MIDI output, bypassing the + /// controller's note buffering and step scheduling. 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/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/input_test.rs b/mseq_core/tests/input_test.rs index 4fccdcc..0d30374 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 { + // 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![], - } + }; + messages + .into_iter() + .map(|midi_message| Instruction::MidiMessage { midi_message }) + .collect() } } -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,30 @@ 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, + std::mem::take(queue), + ); + } } midi_controller.finish(); } @@ -124,3 +156,108 @@ 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, + ) -> Vec { + vec![ + Instruction::MidiMessage { + midi_message: MidiMessage::NoteOn { + channel: 1, + note: MSG_NOTE, + }, + }, + Instruction::StartNote { + midi_note: INSTR_NOTE, + channel_id: 1, + }, + ] + } +} + +/// 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 { + 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, + std::mem::take(&mut queue), + ); + + controller.update(1); + + { + let inner = debug_conn.borrow(); + // The midi message was forwarded even while paused... + assert!(inner.notes_on.contains_key(&(1, MSG_NOTE.midi_value()))); + // ...but the other 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, + std::mem::take(&mut queue), + ); + controller.update(2); + 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..4930d52 --- /dev/null +++ b/mseq_core/tests/slave_input_test.rs @@ -0,0 +1,171 @@ +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, + std::mem::take(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_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, diff --git a/mseq_tracks/Cargo.toml b/mseq_tracks/Cargo.toml index 6840284..b0bd0fa 100644 --- a/mseq_tracks/Cargo.toml +++ b/mseq_tracks/Cargo.toml @@ -1,29 +1,42 @@ [package] name = "mseq_tracks" -version = "0.2.5" +version = "1.0.0" edition = "2024" 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 = "0.1.6" -thiserror = "2.0.18" -midir = "0.11.0" -serde = {version = "1.0.208", features = ["derive"] } -csv = {version = "1.4.0"} -midly = "0.5.3" -fs-err = "3.3.0" -log = "0.4.29" -hashbrown = "0.17" -toml = "1.1.2" -itertools = "0.14.0" +mseq_core = { version = "1.0.0", path = "../mseq_core" } +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/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/mseq_tracks/src/lib.rs b/mseq_tracks/src/lib.rs index acf9140..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 @@ -46,7 +50,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), diff --git a/src/lib.rs b/src/lib.rs index 67e37f5..4f88140 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,40 +22,50 @@ //! 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: 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![] //! } //! } //! //! 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) //! } //! ``` //! +//! ## 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 //! - 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)] @@ -69,7 +79,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; @@ -89,27 +99,29 @@ 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`: 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 +129,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 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, 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 +242,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 +253,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 +263,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 +315,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,