Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI

on:
push:
branches: [ main ]
branches: [ '**' ]

env:
CARGO_TERM_COLOR: always
Expand Down
20 changes: 10 additions & 10 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ members = ["mseq_core", "mseq_tracks"]

[package]
name = "mseq"
version = "2.2.4"
version = "3.0.0"
edition = "2024"
license = "LGPL-2.1"
readme = "README.md"
Expand All @@ -15,18 +15,18 @@ 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"
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<MidiInParam>`, 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

Expand All @@ -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;

Expand All @@ -64,15 +73,17 @@ impl Conductor for MyConductor {
vec![]
}

fn handle_input(&mut self, input: MidiMessage, _ctx: &Context) -> Vec<Instruction> {
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)
}
```
Expand Down
2 changes: 1 addition & 1 deletion examples/acid_arp_track.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion examples/clock_div_track.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion examples/impl_track.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion examples/midi_track.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion examples/slave_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
8 changes: 4 additions & 4 deletions mseq_core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mseq_core"
version = "0.1.6"
version = "1.0.0"
edition = "2024"
license = "LGPL-2.1"
readme = "README.md"
Expand All @@ -11,7 +11,7 @@ keywords = ["midi", "music", "sequencer"]
categories = ["multimedia"]

[dependencies]
serde = {version = "1.0.208", default-features = false, features = ["derive", "alloc"] }
serde = {version = "1.0.228", default-features = false, features = ["derive", "alloc"] }
thiserror = { version="2.0.18", default-features = false }
hashbrown = "0.17.0"
log = "0.4.29"
hashbrown = "0.17.1"
log = "0.4.33"
3 changes: 2 additions & 1 deletion mseq_core/src/bpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 44 additions & 5 deletions mseq_core/src/conductor.rs
Original file line number Diff line number Diff line change
@@ -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<Instruction>,
/// MIDI messages forwarded directly to the output, bypassing the controller.
/// Always sent, including while paused.
pub messages: Vec<MidiMessage>,
}

/// Entry point for user-defined sequencer behavior.
///
/// The `Conductor` trait must be implemented by the user to define how their sequencer
Expand All @@ -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<Instruction>` containing the actions to be passed to the MIDI controller
Expand All @@ -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<Instruction>` 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
///
Expand All @@ -49,7 +83,12 @@ pub trait Conductor {
/// # Returns
///
/// A `Vec<Instruction>` to be sent to the MIDI output immediately.
fn handle_input(&mut self, _input: MidiMessage, _context: &Context) -> Vec<Instruction> {
vec![]
fn handle_input(
&mut self,
_input_id: usize,
_input: MidiMessage,
_context: &Context,
) -> InputResponse {
InputResponse::default()
}
}
56 changes: 37 additions & 19 deletions mseq_core/src/context.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::Conductor;
use crate::InputResponse;
use crate::Instruction;
use crate::MidiController;
use crate::MidiMessage;
Expand All @@ -24,7 +25,6 @@ pub struct Context {
step: u32,
running: bool,
on_pause: bool,
pause: bool,
sys_instructions: Vec<Instruction>,
}

Expand All @@ -40,7 +40,6 @@ impl Default for Context {
step: 0,
running: true,
on_pause: true,
pause: false,
sys_instructions: vec![],
}
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -120,9 +124,7 @@ impl Context {
conductor: &mut impl Conductor,
controller: &mut MidiController<impl MidiOut>,
) {
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);
Expand All @@ -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<impl MidiOut>) {
core::mem::take(&mut self.sys_instructions)
.into_iter()
.for_each(|instruction| controller.execute(instruction));
}

/// MIDI logic called after the clock tick.
Expand All @@ -142,8 +155,6 @@ impl Context {
if !self.on_pause {
self.step += 1;
controller.update(self.step);
} else if self.pause {
self.pause = false;
}
}

Expand All @@ -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<impl MidiOut>,
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);
}
}
}
}
}
2 changes: 1 addition & 1 deletion mseq_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
14 changes: 14 additions & 0 deletions mseq_core/src/midi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading