From d076f8d750f2b98ed37dff5511fb90813e1d16cf Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 8 May 2026 19:09:59 +0200 Subject: [PATCH 01/12] wip, not sure if correct --- crates/examples/poly.rs | 4 +- crates/src/context.rs | 39 ++++- crates/src/midi.rs | 145 +++++++++++++--- crates/src/nodes/midi/midi_sequencer.rs | 209 ++++++++++++++++++++++++ crates/src/nodes/midi/mod.rs | 1 + crates/src/nodes/midi/voice.rs | 2 +- 6 files changed, 368 insertions(+), 32 deletions(-) create mode 100644 crates/src/nodes/midi/midi_sequencer.rs diff --git a/crates/examples/poly.rs b/crates/examples/poly.rs index 80e79f1..ccfe8c6 100644 --- a/crates/examples/poly.rs +++ b/crates/examples/poly.rs @@ -153,7 +153,7 @@ fn main() { map { range: [-1.0, 1.0], new_range: [0.3, 0.7 ] }, } - midi { + midi { poly_voice { chan: 0, voices: 5 } } @@ -180,7 +180,7 @@ fn main() { let ports = PortBuilder::default().audio_out(2).build(); - let (midi_rt_fe, _writer_fe) = start_midi_thread( + let midi_rt_fe = start_midi_thread( 256, "my_port", MidiPortKind::Index(0), diff --git a/crates/src/context.rs b/crates/src/context.rs index a042e6c..cb745b6 100644 --- a/crates/src/context.rs +++ b/crates/src/context.rs @@ -1,10 +1,10 @@ -use std::time::Instant; +use std::time::{Duration, Instant}; use slotmap::new_key_type; use crate::{ config::Config, - midi::{MidiError, MidiMessage, MidiStore}, + midi::{MidiError, MidiMessage, MidiStore, MidiWriter, MidiWriterFrontend}, resources::{ Resources, params::{ParamError, ParamKey}, @@ -21,6 +21,7 @@ new_key_type! { struct ExternalAudioKey; } pub struct AudioContext { config: Config, midi_store: Option, + midi_writer_frontend: Option, resources: Resources, block_start: Instant, } @@ -31,6 +32,7 @@ impl AudioContext { config, midi_store: None, resources, + midi_writer_frontend: None, block_start: Instant::now(), } } @@ -38,38 +40,69 @@ impl AudioContext { pub fn set_sample_rate(&mut self, sr: usize) { self.config.sample_rate = sr; } + /// For a time being, this is a quick hack inside oversampling. I would recommend not using, as it does not reflex internal state!!! pub fn set_block_size(&mut self, block_size: usize) { self.config.block_size = block_size; } + pub fn get_config(&self) -> Config { self.config } + pub fn get_resources(&self) -> &Resources { &self.resources } + pub fn set_resources(&mut self, resources: Resources) { self.resources = resources; } + pub fn get_resources_mut(&mut self) -> &mut Resources { &mut self.resources } + pub fn get_param(&self, key: &ParamKey) -> Result { self.resources.get_param(key) } + + pub fn sample_rate_f32(&self) -> f32 { + self.config.sample_rate as f32 + } + + #[inline(always)] + pub fn sample_rate(&self) -> usize { + self.config.sample_rate + } + // Add a midi store to the runtime. pub fn set_midi_store(&mut self, store: MidiStore) { self.midi_store = Some(store); } + + pub fn set_midi_writer_frontend(&mut self, frontend: MidiWriterFrontend) { + self.midi_writer_frontend = Some(frontend); + } + + pub fn get_midi_writer_frontend(&mut self) -> Option<&mut MidiWriterFrontend> { + self.midi_writer_frontend.as_mut() + } + + #[inline(always)] pub fn get_midi_store(&self) -> Option<&MidiStore> { self.midi_store.as_ref() } + + #[inline(always)] pub fn set_instant(&mut self) { self.block_start = Instant::now() } - pub fn get_instant(&mut self) -> Instant { + + #[inline(always)] + pub fn get_instant(&self) -> Instant { self.block_start } + /// Insert a midi message into the store. #[inline(always)] pub fn insert_midi_msg(&mut self, msg: MidiMessage) -> Result<(), MidiError> { diff --git a/crates/src/midi.rs b/crates/src/midi.rs index 33d3bd5..aa3c18e 100644 --- a/crates/src/midi.rs +++ b/crates/src/midi.rs @@ -6,7 +6,7 @@ use std::{ }; use crossbeam::{ - channel::{Receiver, Sender, bounded}, + channel::{Receiver, RecvError, RecvTimeoutError, Sender, bounded}, select, }; use midir::{Ignore, MidiInput, MidiInputConnection, MidiOutput, MidiOutputConnection}; @@ -106,35 +106,132 @@ impl MidiListener { } } +use std::cmp::{Ordering, Reverse}; +use std::collections::BinaryHeap; + +struct Scheduled { + instant: Instant, + seq: u64, // FIFO tiebreaker for equal instants + msg: MidiMessage, +} + +impl PartialEq for Scheduled { + fn eq(&self, o: &Self) -> bool { + self.instant == o.instant && self.seq == o.seq + } +} +impl Eq for Scheduled {} +impl Ord for Scheduled { + fn cmp(&self, o: &Self) -> Ordering { + self.instant.cmp(&o.instant).then(self.seq.cmp(&o.seq)) + } +} +impl PartialOrd for Scheduled { + fn partial_cmp(&self, o: &Self) -> Option { + Some(self.cmp(o)) + } +} + +/// A MidiWriter actor. It drains a queue, and sorts +/// this into a binary heap, then writes messages in order +/// of time stamp increasing. It briefly spinlocks if there are +/// small pauses between messages that are ready to be written. pub struct MidiWriter { receiver: MidiReceiver, + /// Not technically a priority queue, but we are storing messages so that the oldest pop first + queue: BinaryHeap>, + seq: u64, } impl MidiWriter { pub fn new(receiver: MidiReceiver) -> Self { - Self { receiver } + Self::with_capacity(receiver, 1024) + } + + pub fn with_capacity(receiver: MidiReceiver, cap: usize) -> Self { + Self { + receiver, + queue: BinaryHeap::with_capacity(cap), + seq: 0, + } } - pub fn send_to_midi_output( + + fn send_to_midi_output( &mut self, msg: MidiMessage, - connection: &mut MidiOutputConnection, + conn: &mut MidiOutputConnection, ) -> Result<(), MidiError> { let encoded = msg.encode(); - let sliced = &encoded.data[..encoded.len]; - connection - .send(sliced) + conn.send(&encoded.data[..encoded.len]) // Some messages have different lengths, so we slice to the len on the message type .map_err(|x| MidiError::SendError(x.to_string())) } - /// Drain the incoming messages and send to the system. - /// You will most likely want to run this on a dedicated thread. - /// - /// TODO: Use some sort of timing to eliminate jitter. + pub fn run(mut self, connection: &mut MidiOutputConnection) { + /* The general idea here is as follows: + * + * We first filter and add all messages to a BinaryHeap, stored with increasing instants. + * This data structure lets us make a queue where messages are monotonically increasing, and + * our data structure handles the sorting for us. + * + * Then, we have two behaviors depending on if we have some messages in the queue: + * + * With incoming messages, we send them, and wait for the next. If the time before the next + * message is small, we briefly spinlock to avoid jitter here. If we left it up to the OS + * scheduler, we would lose the fine grained accuracy here. + * + * The other branch, is a thread where we have no messages in our queue. Here, we just + * block on the select! macro. + */ loop { - select! { - recv(self.receiver) -> msg => { - if let Ok(inner) = msg { - let _ = self.send_to_midi_output(inner.0, connection); + while let Some(top) = self.queue.peek() { + let dt_next_message = top.0.instant.saturating_duration_since(Instant::now()); + // If the next message is at or before our time to run, send to the Midi driver + if dt_next_message <= Duration::ZERO { + let s = self.queue.pop().unwrap().0; + let _ = self.send_to_midi_output(s.msg, connection); + } + // Here, we spin for very small next incoming messages + else if dt_next_message < Duration::from_micros(500) { + std::hint::spin_loop(); + } + // Message not ready, move to recv, but only until our next message + else { + break; + } + } + + match self.queue.peek() { + Some(next) => { + let deadline = next.0.instant; + // Now, we let ourselves block for new messages up to the time of the next message + match self.receiver.recv_deadline(deadline) { + Ok((msg, instant)) => { + self.queue.push(Reverse(Scheduled { + instant, + seq: self.seq, + msg, + })); + self.seq = self.seq.wrapping_add(1); + } + Err(inner) => { + match inner { + RecvTimeoutError::Disconnected => break, // exit thread + RecvTimeoutError::Timeout => (), // deadline reached, restart the loop and send ready messages if we have them + } + } + } + } + None => { + match self.receiver.recv() { + Ok((msg, instant)) => { + self.queue.push(Reverse(Scheduled { + instant, + seq: self.seq, + msg, + })); + self.seq = self.seq.wrapping_add(1); + } + Err(_) => break, // exit thread } } } @@ -292,7 +389,7 @@ impl MidiStore { pub struct MidiRuntimeFrontend { _reader_handle: MidiInputConnection<()>, _writer_handle: JoinHandle<()>, - writer_frontend: Arc, + writer_frontend: MidiWriterFrontend, reader_consumer: MidiReceiver, } @@ -300,7 +397,7 @@ impl MidiRuntimeFrontend { pub fn new( reader_handle: MidiInputConnection<()>, writer_handle: JoinHandle<()>, - writer_frontend: Arc, + writer_frontend: MidiWriterFrontend, consumer: MidiReceiver, ) -> Self { Self { @@ -327,10 +424,9 @@ pub fn start_midi_thread( in_port: MidiPortKind, out_port: MidiPortKind, port_name: &'static str, -) -> Result<(MidiRuntimeFrontend, Arc), MidiError> { - // Setup the MidiInput with our client name +) -> Result { let mut input = MidiInput::new(client_name).expect("Could not create MidiInput device!"); - // Ignore unsupported messages + // This message type not supported for now input.ignore(Ignore::SysexAndActiveSense); let in_ports = input.ports(); @@ -354,10 +450,7 @@ pub fn start_midi_thread( port_name, move |_, message, _| { let instant = Instant::now(); - if let Ok(msg) = parse_midi(message, instant) { - // Init midi offset if not yet set - // TODO: Proper app wide error handling if midi_listener.send_to_store(msg, instant).is_err() { eprintln!("MIDI DROP"); } @@ -388,17 +481,17 @@ pub fn start_midi_thread( midi_writer.run(&mut output_connection); }); - let writer_frontend = Arc::new(MidiWriterFrontend::new(midi_writer_prod)); + let writer_frontend = MidiWriterFrontend::new(midi_writer_prod); // Assemble the final midi runtime. let runtime = MidiRuntimeFrontend::new( reader_handle, writer_handle, - writer_frontend.clone(), + writer_frontend, midi_reader_consumer, ); - Ok((runtime, writer_frontend)) + Ok(runtime) } /// A small struct to easily create variable slices of our midi diff --git a/crates/src/nodes/midi/midi_sequencer.rs b/crates/src/nodes/midi/midi_sequencer.rs new file mode 100644 index 0000000..4b35cf0 --- /dev/null +++ b/crates/src/nodes/midi/midi_sequencer.rs @@ -0,0 +1,209 @@ +use std::time::Duration; + +use crate::{ + context::AudioContext, + midi::{MidiMessage, MidiMessageKind}, + msg::{NodeMessage, RtValue}, + node::{Inputs, Node}, + ports::{PortBuilder, Ports}, +}; + +/// A single step in the sequencer. +#[derive(Clone, Debug)] +pub struct SequencerStep { + pub freq: f32, + pub vel: f32, + /// 0.0 is muted, 1.0 fires + pub gate: f32, + /// Portion of the step duration the gate is held high. Range [0.0, 1.0]. + pub length: f32, +} + +impl Default for SequencerStep { + fn default() -> Self { + Self { + freq: 440.0, + vel: 0.0, + gate: 0.0, + length: 0.0, + } + } +} + +const MAXIMUM_SIZE: usize = 256; + +#[derive(Clone)] +pub struct MidiSequencer { + midi_chan: u8, + last_idx: usize, + prev_note: Option, + held_note: Option, + steps: Box<[SequencerStep]>, + num_steps: usize, // Essentially, we take the first 0..num_steps, so we can preallocate the max step size + ports: Ports, +} + +impl MidiSequencer { + pub fn new(midi_chan: u8, num_steps: usize) -> Self { + let ports = PortBuilder::default() + .audio_in_named(&["phasor"]) + .audio_out_named(&["freq", "vel", "gate"]) + .build(); + + Self { + midi_chan, + last_idx: 0, + prev_note: None, + held_note: None, + steps: vec![SequencerStep::default(); MAXIMUM_SIZE].into(), + num_steps, + ports, + } + } + + #[inline(always)] + fn step_index(&self, phase: f32) -> usize { + let num_steps = self.num_steps; + // map range of phasor (0,1) to (0,num_steps) + let idx = (phase.min(0.999_999) * num_steps as f32).floor() as usize; + // Clamp index to last elemenet + idx.min(num_steps - 1) + } + + #[inline(always)] + fn phase_within_step(&self, phase: f32) -> f32 { + (phase * self.num_steps as f32).fract() + } +} + +impl Node for MidiSequencer { + fn process(&mut self, ctx: &mut AudioContext, inputs: &Inputs, _outputs: &mut [&mut [f32]]) { + let phasor_in = inputs[0].expect("MidiSequencer requires a phasor!"); + + let cfg = ctx.get_config(); + + let sr = cfg.sample_rate; + let block_size = cfg.block_size; + + let block_start = ctx.get_instant(); + + let writer = ctx + .get_midi_writer_frontend() + .expect("No MidiWriterFrontend found on current runtime. Did you register it?"); + + for n in 0..block_size { + let phase = phasor_in[n]; + let idx = self.step_index(phase); + + // edge detection + if idx != self.last_idx { + let when_to_write = block_start + Duration::from_secs_f32((n / sr) as f32); + + let step = &self.steps[idx]; + + if let Some(prev_note) = self.held_note.take() { + let _ = writer.send_to_system_midi( + MidiMessage { + data: MidiMessageKind::NoteOff { + note: prev_note, + velocity: 0, + }, + instant: when_to_write, + channel_idx: self.midi_chan, + }, + when_to_write, + ); + } + if step.gate > 0.0 { + let note = ftom(step.freq); + let _ = writer.send_to_system_midi( + MidiMessage { + data: MidiMessageKind::NoteOn { + note, + velocity: (step.vel * 127.0) as u8, + }, + instant: when_to_write, + channel_idx: self.midi_chan, + }, + when_to_write, + ); + self.held_note = Some(note); + // also schedule the NoteOff for `when + step.length * step_duration` + } + self.last_idx = idx; + } + } + } + + fn handle_msg(&mut self, msg: NodeMessage) { + match msg { + NodeMessage::SetParam(inner) => { + if let ("num_steps", RtValue::U32(n)) = (inner.param_name, inner.value) { + self.num_steps = (n as usize).min(MAXIMUM_SIZE) + } + } + NodeMessage::SetStep(payload) => { + if let Some(step) = self.steps.get_mut(payload.index) { + if let Some(v) = payload.freq { + step.freq = v; + } + if let Some(v) = payload.vel { + step.vel = v; + } + if let Some(v) = payload.gate { + step.gate = v; + } + if let Some(v) = payload.length { + step.length = v.clamp(0.0, 1.0); + } + } + } + _ => (), + } + } + + fn ports(&self) -> &Ports { + &self.ports + } +} + +/// I am not dealing with pitch bend for the time being +fn ftom(freq: f32) -> u8 { + (69.0 + 12.0 * (freq / 440.0).log2()) + .round() + .clamp(0.0, 127.0) as u8 +} + +use crate::{ + builder::{ResourceBuilderView, ValidationError}, + dsl::ir::DSLParams, + node::DynNode, + spec::NodeDefinition, +}; + +impl NodeDefinition for MidiSequencer { + const NAME: &'static str = "midi_sequencer"; + const DESCRIPTION: &'static str = + "Midi step sequencer sending note information to the selected midi channel"; + const REQUIRED_PARAMS: &'static [&'static str] = &["num_steps", "midi_chan"]; + const OPTIONAL_PARAMS: &'static [&'static str] = &[]; + + fn create( + _rb: &mut ResourceBuilderView, + p: &DSLParams, + ) -> Result, ValidationError> { + let midi_chan = p + .get_usize("midi_chan") + .expect("Must pass midi_chan to MidiSequencer!"); + + let num_steps = p + .get_usize("num_steps") + .expect("Must pass num_steps to sequencer"); + Ok(Box::new(Self::new( + midi_chan + .try_into() + .expect("Could not cast midi channel to u8!"), + num_steps, + ))) + } +} diff --git a/crates/src/nodes/midi/mod.rs b/crates/src/nodes/midi/mod.rs index 5e2e12b..ffbdf30 100644 --- a/crates/src/nodes/midi/mod.rs +++ b/crates/src/nodes/midi/mod.rs @@ -1 +1,2 @@ +pub mod midi_sequencer; pub mod voice; diff --git a/crates/src/nodes/midi/voice.rs b/crates/src/nodes/midi/voice.rs index d2f8e8c..bd93441 100644 --- a/crates/src/nodes/midi/voice.rs +++ b/crates/src/nodes/midi/voice.rs @@ -88,7 +88,7 @@ impl Node for Voice { } #[inline(always)] -fn mtof(note: u8) -> f32 { +pub fn mtof(note: u8) -> f32 { 440.0 * 2.0_f32.powf((note as f32 - 69.0) / 12.0) } From 56a11a60a9454a5c5cc442ec091db6bc2a45c621 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 8 May 2026 19:10:22 +0200 Subject: [PATCH 02/12] clippy --- crates/src/context.rs | 4 ++-- crates/src/midi.rs | 11 +++-------- crates/src/nodes/audio/pan.rs | 3 +-- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/src/context.rs b/crates/src/context.rs index cb745b6..e0584c1 100644 --- a/crates/src/context.rs +++ b/crates/src/context.rs @@ -1,10 +1,10 @@ -use std::time::{Duration, Instant}; +use std::time::Instant; use slotmap::new_key_type; use crate::{ config::Config, - midi::{MidiError, MidiMessage, MidiStore, MidiWriter, MidiWriterFrontend}, + midi::{MidiError, MidiMessage, MidiStore, MidiWriterFrontend}, resources::{ Resources, params::{ParamError, ParamKey}, diff --git a/crates/src/midi.rs b/crates/src/midi.rs index aa3c18e..c3065bc 100644 --- a/crates/src/midi.rs +++ b/crates/src/midi.rs @@ -1,14 +1,10 @@ use std::{ fmt::Debug, - sync::Arc, thread::JoinHandle, time::{Duration, Instant}, }; -use crossbeam::{ - channel::{Receiver, RecvError, RecvTimeoutError, Sender, bounded}, - select, -}; +use crossbeam::channel::{Receiver, RecvTimeoutError, Sender, bounded}; use midir::{Ignore, MidiInput, MidiInputConnection, MidiOutput, MidiOutputConnection}; pub type MidiProducer = Sender<(MidiMessage, Instant)>; @@ -450,11 +446,10 @@ pub fn start_midi_thread( port_name, move |_, message, _| { let instant = Instant::now(); - if let Ok(msg) = parse_midi(message, instant) { - if midi_listener.send_to_store(msg, instant).is_err() { + if let Ok(msg) = parse_midi(message, instant) + && midi_listener.send_to_store(msg, instant).is_err() { eprintln!("MIDI DROP"); } - } }, (), ) diff --git a/crates/src/nodes/audio/pan.rs b/crates/src/nodes/audio/pan.rs index 9850621..ef564f4 100644 --- a/crates/src/nodes/audio/pan.rs +++ b/crates/src/nodes/audio/pan.rs @@ -29,8 +29,7 @@ impl Pan { impl Node for Pan { fn process(&mut self, _ctx: &mut AudioContext, inputs: &Inputs, outputs: &mut [&mut [f32]]) { - let input = inputs - .get(0) + let input = inputs.first() .and_then(|x| *x) .expect("No mono input for pan node!"); From d1a86c178580a61ca9af39ff76b3807a3248f5f1 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 8 May 2026 19:11:01 +0200 Subject: [PATCH 03/12] cleanup --- crates/src/midi.rs | 7 ++++--- crates/src/nodes/midi/midi_sequencer.rs | 2 -- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/src/midi.rs b/crates/src/midi.rs index c3065bc..a74192f 100644 --- a/crates/src/midi.rs +++ b/crates/src/midi.rs @@ -447,9 +447,10 @@ pub fn start_midi_thread( move |_, message, _| { let instant = Instant::now(); if let Ok(msg) = parse_midi(message, instant) - && midi_listener.send_to_store(msg, instant).is_err() { - eprintln!("MIDI DROP"); - } + && midi_listener.send_to_store(msg, instant).is_err() + { + eprintln!("MIDI DROP"); + } }, (), ) diff --git a/crates/src/nodes/midi/midi_sequencer.rs b/crates/src/nodes/midi/midi_sequencer.rs index 4b35cf0..5f7ccef 100644 --- a/crates/src/nodes/midi/midi_sequencer.rs +++ b/crates/src/nodes/midi/midi_sequencer.rs @@ -36,7 +36,6 @@ const MAXIMUM_SIZE: usize = 256; pub struct MidiSequencer { midi_chan: u8, last_idx: usize, - prev_note: Option, held_note: Option, steps: Box<[SequencerStep]>, num_steps: usize, // Essentially, we take the first 0..num_steps, so we can preallocate the max step size @@ -53,7 +52,6 @@ impl MidiSequencer { Self { midi_chan, last_idx: 0, - prev_note: None, held_note: None, steps: vec![SequencerStep::default(); MAXIMUM_SIZE].into(), num_steps, From d00f3849ff23a3e28f312561b718f2ec5081aa7e Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 8 May 2026 19:11:36 +0200 Subject: [PATCH 04/12] bump --- crates/Cargo.lock | 2 +- crates/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 70dc598..0b41567 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -676,7 +676,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "legato" -version = "0.0.28" +version = "0.0.29" dependencies = [ "approx", "arc-swap", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 5434a23..64fc473 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "legato" description = "Legato is a WIP audiograph and DSL for quickly developing audio applications" -version = "0.0.28" +version = "0.0.29" edition = "2024" repository="https://github.com/legato-dsp/legato" license = "AGPL-3.0" From c0d08c30b7ebc12b3d4e69abcecd30b58da0827d Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 8 May 2026 19:14:40 +0200 Subject: [PATCH 05/12] add midi sequencer to registry --- crates/src/registry.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/src/registry.rs b/crates/src/registry.rs index 178d35f..8c7ae36 100644 --- a/crates/src/registry.rs +++ b/crates/src/registry.rs @@ -31,7 +31,10 @@ use crate::{ sequencer::StepSequencer, signal::Signal, }, - midi::voice::{PolyVoice, Voice}, + midi::{ + midi_sequencer::MidiSequencer, + voice::{PolyVoice, Voice}, + }, }, spec::{NodeDefinition, NodeSpec}, }; @@ -129,5 +132,6 @@ pub fn midi_registry_factory() -> NodeRegistry { let mut registry = NodeRegistry::new(); registry.register_node::(); registry.register_node::(); + registry.register_node::(); registry } From c71815442036e06515efb8c47450abe9cf4446a0 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 8 May 2026 19:14:57 +0200 Subject: [PATCH 06/12] v bump --- crates/Cargo.lock | 2 +- crates/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 0b41567..237c2e4 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -676,7 +676,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "legato" -version = "0.0.29" +version = "0.0.30" dependencies = [ "approx", "arc-swap", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 64fc473..36a3979 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "legato" description = "Legato is a WIP audiograph and DSL for quickly developing audio applications" -version = "0.0.29" +version = "0.0.30" edition = "2024" repository="https://github.com/legato-dsp/legato" license = "AGPL-3.0" From 99ef2a7ae357f374deea9f15f3eb7e1152d181a0 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 8 May 2026 21:58:59 +0200 Subject: [PATCH 07/12] midi fixes, v bump --- crates/Cargo.lock | 2 +- crates/Cargo.toml | 2 +- crates/src/context.rs | 38 +++++++++++++++++++------ crates/src/lib.rs | 15 ++-------- crates/src/midi.rs | 3 +- crates/src/nodes/midi/midi_sequencer.rs | 13 ++------- 6 files changed, 38 insertions(+), 35 deletions(-) diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 237c2e4..c43d51e 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -676,7 +676,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "legato" -version = "0.0.30" +version = "0.0.31" dependencies = [ "approx", "arc-swap", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 36a3979..3c9f681 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "legato" description = "Legato is a WIP audiograph and DSL for quickly developing audio applications" -version = "0.0.30" +version = "0.0.31" edition = "2024" repository="https://github.com/legato-dsp/legato" license = "AGPL-3.0" diff --git a/crates/src/context.rs b/crates/src/context.rs index e0584c1..9999d32 100644 --- a/crates/src/context.rs +++ b/crates/src/context.rs @@ -4,7 +4,7 @@ use slotmap::new_key_type; use crate::{ config::Config, - midi::{MidiError, MidiMessage, MidiStore, MidiWriterFrontend}, + midi::{MidiError, MidiMessage, MidiRuntimeFrontend, MidiStore}, resources::{ Resources, params::{ParamError, ParamKey}, @@ -21,7 +21,7 @@ new_key_type! { struct ExternalAudioKey; } pub struct AudioContext { config: Config, midi_store: Option, - midi_writer_frontend: Option, + midi_runtime_frontend: Option, resources: Resources, block_start: Instant, } @@ -32,7 +32,7 @@ impl AudioContext { config, midi_store: None, resources, - midi_writer_frontend: None, + midi_runtime_frontend: None, block_start: Instant::now(), } } @@ -41,6 +41,22 @@ impl AudioContext { self.config.sample_rate = sr; } + pub(crate) fn update_midi(&mut self) { + self.clear_midi(); + + let Some(runtime) = self.midi_runtime_frontend.take() else { + return; + }; + + while let Some(msg) = runtime.recv() { + if let Err(e) = self.insert_midi_msg(msg) { + eprintln!("{:?}", e); + } + } + + self.midi_runtime_frontend = Some(runtime); + } + /// For a time being, this is a quick hack inside oversampling. I would recommend not using, as it does not reflex internal state!!! pub fn set_block_size(&mut self, block_size: usize) { self.config.block_size = block_size; @@ -80,12 +96,16 @@ impl AudioContext { self.midi_store = Some(store); } - pub fn set_midi_writer_frontend(&mut self, frontend: MidiWriterFrontend) { - self.midi_writer_frontend = Some(frontend); - } - - pub fn get_midi_writer_frontend(&mut self) -> Option<&mut MidiWriterFrontend> { - self.midi_writer_frontend.as_mut() + pub fn send_to_system_midi( + &mut self, + msg: MidiMessage, + instant: Instant, + ) -> Result<(), MidiError> { + if let Some(inner) = &mut self.midi_runtime_frontend { + inner.writer_frontend.send_to_system_midi(msg, instant) + } else { + Err(MidiError::MissingRuntime) + } } #[inline(always)] diff --git a/crates/src/lib.rs b/crates/src/lib.rs index 3252fb6..c24adb5 100644 --- a/crates/src/lib.rs +++ b/crates/src/lib.rs @@ -72,18 +72,9 @@ impl LegatoApp { /// /// This gives the data in a [[L,L,L], [R,R,R], etc] layout pub fn next_block(&mut self, external_inputs: Option<&Inputs>) -> OutputView<'_> { - // If we have a midi runtime, drain it. - if let Some(midi_runtime) = &self.midi_runtime_frontend { - let ctx = self.runtime.get_context_mut(); - // Clear our old messages - ctx.clear_midi(); - while let Some(msg) = midi_runtime.recv() { - // TOOD: Realtime logging with channel maybe? - if let Err(e) = ctx.insert_midi_msg(msg) { - eprintln!("{:?}", e); - } - } - } + let ctx = self.runtime.get_context_mut(); + ctx.update_midi(); + // Drain messages for sample update self.runtime.drain_external_sample_msg(); diff --git a/crates/src/midi.rs b/crates/src/midi.rs index a74192f..c9a2eb8 100644 --- a/crates/src/midi.rs +++ b/crates/src/midi.rs @@ -19,6 +19,7 @@ pub enum MidiError { RingbufferFull, ConnectionError(String), SendError(String), + MissingRuntime, } #[derive(Clone, Copy, PartialEq)] @@ -385,7 +386,7 @@ impl MidiStore { pub struct MidiRuntimeFrontend { _reader_handle: MidiInputConnection<()>, _writer_handle: JoinHandle<()>, - writer_frontend: MidiWriterFrontend, + pub(crate) writer_frontend: MidiWriterFrontend, reader_consumer: MidiReceiver, } diff --git a/crates/src/nodes/midi/midi_sequencer.rs b/crates/src/nodes/midi/midi_sequencer.rs index 5f7ccef..ea0dbc7 100644 --- a/crates/src/nodes/midi/midi_sequencer.rs +++ b/crates/src/nodes/midi/midi_sequencer.rs @@ -67,11 +67,6 @@ impl MidiSequencer { // Clamp index to last elemenet idx.min(num_steps - 1) } - - #[inline(always)] - fn phase_within_step(&self, phase: f32) -> f32 { - (phase * self.num_steps as f32).fract() - } } impl Node for MidiSequencer { @@ -85,10 +80,6 @@ impl Node for MidiSequencer { let block_start = ctx.get_instant(); - let writer = ctx - .get_midi_writer_frontend() - .expect("No MidiWriterFrontend found on current runtime. Did you register it?"); - for n in 0..block_size { let phase = phasor_in[n]; let idx = self.step_index(phase); @@ -100,7 +91,7 @@ impl Node for MidiSequencer { let step = &self.steps[idx]; if let Some(prev_note) = self.held_note.take() { - let _ = writer.send_to_system_midi( + let _ = ctx.send_to_system_midi( MidiMessage { data: MidiMessageKind::NoteOff { note: prev_note, @@ -114,7 +105,7 @@ impl Node for MidiSequencer { } if step.gate > 0.0 { let note = ftom(step.freq); - let _ = writer.send_to_system_midi( + let _ = ctx.send_to_system_midi( MidiMessage { data: MidiMessageKind::NoteOn { note, From 4348977cf01417655a2db208b7112842761783cf Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 8 May 2026 22:03:34 +0200 Subject: [PATCH 08/12] set runtime --- crates/Cargo.lock | 2 +- crates/Cargo.toml | 2 +- crates/src/builder.rs | 11 ++++------- crates/src/context.rs | 4 ++++ crates/src/lib.rs | 6 ------ 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/crates/Cargo.lock b/crates/Cargo.lock index c43d51e..17278db 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -676,7 +676,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "legato" -version = "0.0.31" +version = "0.0.32" dependencies = [ "approx", "arc-swap", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 3c9f681..b7ddd88 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "legato" description = "Legato is a WIP audiograph and DSL for quickly developing audio applications" -version = "0.0.31" +version = "0.0.32" edition = "2024" repository="https://github.com/legato-dsp/legato" license = "AGPL-3.0" diff --git a/crates/src/builder.rs b/crates/src/builder.rs index efab541..ba12cad 100644 --- a/crates/src/builder.rs +++ b/crates/src/builder.rs @@ -467,7 +467,7 @@ impl LegatoBuilder where S: CanBuild, { - pub fn build(self) -> (LegatoApp, LegatoFrontend) { + pub fn build(mut self) -> (LegatoApp, LegatoFrontend) { let mut runtime = self.runtime; let cfg = runtime.get_config(); @@ -482,18 +482,15 @@ where // Allocate all of the audio buffers needed at runtime runtime.prepare(); - if self.midi_runtime_frontend.is_some() { + if let Some(fe) = self.midi_runtime_frontend.take() { let ctx = runtime.get_context_mut(); ctx.set_midi_store(MidiStore::new(256)); + ctx.set_midi_runtime_frontend(fe); } let (producer, consumer) = rtrb::RingBuffer::new(512); - let mut app = LegatoApp::new(runtime, consumer); - - if let Some(midi_rt) = self.midi_runtime_frontend { - app.set_midi_runtime(midi_rt); - } + let app = LegatoApp::new(runtime, consumer); let rt_frontend = RuntimeFrontend::new(resources_frontend); diff --git a/crates/src/context.rs b/crates/src/context.rs index 9999d32..ad9aa5b 100644 --- a/crates/src/context.rs +++ b/crates/src/context.rs @@ -96,6 +96,10 @@ impl AudioContext { self.midi_store = Some(store); } + pub fn set_midi_runtime_frontend(&mut self, frontend: MidiRuntimeFrontend) { + self.midi_runtime_frontend = Some(frontend) + } + pub fn send_to_system_midi( &mut self, msg: MidiMessage, diff --git a/crates/src/lib.rs b/crates/src/lib.rs index c24adb5..25905de 100644 --- a/crates/src/lib.rs +++ b/crates/src/lib.rs @@ -53,7 +53,6 @@ pub enum LegatoError { pub struct LegatoApp { runtime: Runtime, - midi_runtime_frontend: Option, msg_consumer: rtrb::Consumer, } @@ -61,7 +60,6 @@ impl LegatoApp { pub fn new(runtime: Runtime, receiver: rtrb::Consumer) -> Self { Self { runtime, - midi_runtime_frontend: None, msg_consumer: receiver, } } @@ -86,10 +84,6 @@ impl LegatoApp { self.runtime.next_block(external_inputs) } - pub fn set_midi_runtime(&mut self, rt: MidiRuntimeFrontend) { - self.midi_runtime_frontend = Some(rt); - } - pub fn get_config(&self) -> Config { self.runtime.get_config() } From 80ae7f054c7be39f69a730d8015b13f083ae4cd1 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 9 May 2026 00:02:58 +0200 Subject: [PATCH 09/12] somewhat working midi example --- crates/src/midi.rs | 7 +++++-- crates/src/nodes/midi/midi_sequencer.rs | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/src/midi.rs b/crates/src/midi.rs index c9a2eb8..1a0b77d 100644 --- a/crates/src/midi.rs +++ b/crates/src/midi.rs @@ -185,7 +185,9 @@ impl MidiWriter { // If the next message is at or before our time to run, send to the Midi driver if dt_next_message <= Duration::ZERO { let s = self.queue.pop().unwrap().0; - let _ = self.send_to_midi_output(s.msg, connection); + if let Err(err) = self.send_to_midi_output(s.msg, connection) { + eprintln!("Error thrown when sending midi out {:?}", err); + } } // Here, we spin for very small next incoming messages else if dt_next_message < Duration::from_micros(500) { @@ -471,7 +473,8 @@ pub fn start_midi_thread( let mut output_connection = output .connect(output_port, port_name) - .map_err(|x| MidiError::ConnectionError(x.to_string()))?; + .map_err(|x| MidiError::ConnectionError(x.to_string())) + .unwrap(); // Spawning the writer thread, we keep the handle as we will use this on the MidiRuntime struct let writer_handle = std::thread::spawn(move || { diff --git a/crates/src/nodes/midi/midi_sequencer.rs b/crates/src/nodes/midi/midi_sequencer.rs index ea0dbc7..c02f1ea 100644 --- a/crates/src/nodes/midi/midi_sequencer.rs +++ b/crates/src/nodes/midi/midi_sequencer.rs @@ -82,7 +82,7 @@ impl Node for MidiSequencer { for n in 0..block_size { let phase = phasor_in[n]; - let idx = self.step_index(phase); + let idx = self.step_index(phase).min(self.num_steps - 1); // edge detection if idx != self.last_idx { From d53f4d4cd3000b1ded474947b74480a1e3c50d97 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 9 May 2026 00:05:55 +0200 Subject: [PATCH 10/12] note length, needs more tests --- crates/src/nodes/midi/midi_sequencer.rs | 44 ++++++++++++++++++------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/src/nodes/midi/midi_sequencer.rs b/crates/src/nodes/midi/midi_sequencer.rs index c02f1ea..df81b63 100644 --- a/crates/src/nodes/midi/midi_sequencer.rs +++ b/crates/src/nodes/midi/midi_sequencer.rs @@ -37,6 +37,7 @@ pub struct MidiSequencer { midi_chan: u8, last_idx: usize, held_note: Option, + note_off_sent: bool, steps: Box<[SequencerStep]>, num_steps: usize, // Essentially, we take the first 0..num_steps, so we can preallocate the max step size ports: Ports, @@ -53,6 +54,7 @@ impl MidiSequencer { midi_chan, last_idx: 0, held_note: None, + note_off_sent: false, steps: vec![SequencerStep::default(); MAXIMUM_SIZE].into(), num_steps, ports, @@ -79,16 +81,16 @@ impl Node for MidiSequencer { let block_size = cfg.block_size; let block_start = ctx.get_instant(); - for n in 0..block_size { let phase = phasor_in[n]; - let idx = self.step_index(phase).min(self.num_steps - 1); + let idx = self.step_index(phase); + let local_phase = (phase * self.num_steps as f32).fract(); - // edge detection - if idx != self.last_idx { - let when_to_write = block_start + Duration::from_secs_f32((n / sr) as f32); + let when = block_start + Duration::from_secs_f32(n as f32 / sr as f32); - let step = &self.steps[idx]; + // Step edge: send NoteOff for previous note, NoteOn for new step + if idx != self.last_idx { + self.note_off_sent = false; if let Some(prev_note) = self.held_note.take() { let _ = ctx.send_to_system_midi( @@ -97,12 +99,14 @@ impl Node for MidiSequencer { note: prev_note, velocity: 0, }, - instant: when_to_write, + instant: when, channel_idx: self.midi_chan, }, - when_to_write, + when, ); } + + let step = &self.steps[idx]; if step.gate > 0.0 { let note = ftom(step.freq); let _ = ctx.send_to_system_midi( @@ -111,16 +115,34 @@ impl Node for MidiSequencer { note, velocity: (step.vel * 127.0) as u8, }, - instant: when_to_write, + instant: when, channel_idx: self.midi_chan, }, - when_to_write, + when, ); self.held_note = Some(note); - // also schedule the NoteOff for `when + step.length * step_duration` } + self.last_idx = idx; } + + // Within-step NoteOff based on length + if !self.note_off_sent { + let step = &self.steps[idx]; + if local_phase >= step.length { + if let Some(note) = self.held_note.take() { + let _ = ctx.send_to_system_midi( + MidiMessage { + data: MidiMessageKind::NoteOff { note, velocity: 0 }, + instant: when, + channel_idx: self.midi_chan, + }, + when, + ); + } + self.note_off_sent = true; + } + } } } From 156d08ccb1047422bc7828242af4291ec290bc89 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 6 Jun 2026 13:18:54 +0200 Subject: [PATCH 11/12] fix: cargo fmt --- crates/src/nodes/audio/pan.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/src/nodes/audio/pan.rs b/crates/src/nodes/audio/pan.rs index ef564f4..3fe8d4c 100644 --- a/crates/src/nodes/audio/pan.rs +++ b/crates/src/nodes/audio/pan.rs @@ -29,7 +29,8 @@ impl Pan { impl Node for Pan { fn process(&mut self, _ctx: &mut AudioContext, inputs: &Inputs, outputs: &mut [&mut [f32]]) { - let input = inputs.first() + let input = inputs + .first() .and_then(|x| *x) .expect("No mono input for pan node!"); From 0163b807dad8044cbc30f9263cbc8a854fc2ec36 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 6 Jun 2026 13:19:27 +0200 Subject: [PATCH 12/12] fix: clippy lint --- crates/src/lib.rs | 1 - crates/src/nodes/audio/noise.rs | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/src/lib.rs b/crates/src/lib.rs index 25905de..3f97f93 100644 --- a/crates/src/lib.rs +++ b/crates/src/lib.rs @@ -6,7 +6,6 @@ use crate::{ builder::ValidationError, config::Config, executor::OutputView, - midi::MidiRuntimeFrontend, msg::{LegatoMsg, NodeMessage}, node::Inputs, resources::{ diff --git a/crates/src/nodes/audio/noise.rs b/crates/src/nodes/audio/noise.rs index 1ec4f2c..ddf795b 100644 --- a/crates/src/nodes/audio/noise.rs +++ b/crates/src/nodes/audio/noise.rs @@ -12,6 +12,12 @@ pub struct Noise { ports: Ports, } +impl Default for Noise { + fn default() -> Self { + Self::new() + } +} + impl Noise { pub fn new() -> Self { Self {