Skip to main content

oxurack_rt/
lib.rs

1//! Real-time MIDI clock and I/O thread for oxurack.
2//!
3//! `oxurack-rt` runs on a dedicated OS thread elevated to real-time
4//! priority. It handles:
5//!
6//! - **Clock generation** (master mode) at a configurable tempo, producing
7//!   24-PPQN MIDI clock ticks.
8//! - **Clock tracking** (slave mode) using a PLL-based tempo estimator
9//!   locked to an external MIDI clock source.
10//! - **Clock passthrough** (passthrough mode) forwarding an external clock
11//!   to output ports with optional multiplication/division.
12//! - **MIDI I/O** via `midir`, forwarding input messages to the ECS world
13//!   and sending output messages on command.
14//! - **Lock-free communication** with the ECS world through bounded SPSC
15//!   queues (`rtrb`).
16//!
17//! # Architecture
18//!
19//! ```text
20//! ┌────────────────────┐     rtrb queues     ┌──────────────────┐
21//! │   ECS world        │◄═══ RtEvent ═══════►│   RT thread      │
22//! │ (RtHandles)        │════ EcsCommand ════►│ (rt_thread_main) │
23//! └────────────────────┘                     └──────────────────┘
24//! ```
25//!
26//! The caller creates a [`Runtime`] via [`Runtime::start`], receiving
27//! [`RtHandles`] for queue access. Dropping the `Runtime` (or calling
28//! [`Runtime::stop`]) shuts down the thread gracefully.
29
30pub mod clock;
31mod error;
32mod messages;
33mod midi_io;
34mod priority;
35mod queues;
36mod thread;
37mod timing;
38
39// Re-export the public API.
40pub use error::Error;
41pub use messages::{EcsCommand, MidiMessage, RtErrorCode, RtEvent, TransportEvent};
42pub use midi_io::{list_midi_input_ports, list_midi_output_ports};
43pub use queues::RtHandles;
44
45use std::sync::Arc;
46use std::sync::atomic::{AtomicBool, Ordering};
47use std::thread::JoinHandle;
48
49/// Configuration for a MIDI output port connection.
50///
51/// The `name` field is matched case-insensitively as a substring
52/// against the system's available MIDI output port names.
53#[derive(Debug, Clone)]
54pub struct MidiOutputConfig {
55    /// Human-readable name (or substring) used to match an available port.
56    pub name: String,
57}
58
59/// Configuration for a MIDI input port connection.
60///
61/// The `name` field is matched case-insensitively as a substring
62/// against the system's available MIDI input port names.
63#[derive(Debug, Clone)]
64pub struct MidiInputConfig {
65    /// Human-readable name (or substring) used to match an available port.
66    pub name: String,
67}
68
69/// Selects how the RT thread generates or tracks MIDI clock.
70#[non_exhaustive]
71#[derive(Debug, Clone)]
72pub enum ClockMode {
73    /// This system is the clock master: it generates clock ticks at the
74    /// specified tempo and optionally sends MIDI transport messages.
75    Master {
76        /// Initial tempo in beats per minute.
77        tempo_bpm: f64,
78        /// Whether to send MIDI Start/Stop/Continue messages on the
79        /// output ports.
80        send_transport: bool,
81    },
82
83    /// This system tracks an external MIDI clock source on the given
84    /// input port, using a PLL to smooth jitter.
85    Slave {
86        /// Name (or substring) of the MIDI input port carrying the
87        /// external clock.
88        clock_input_port: String,
89        /// Timeout in nanoseconds: if no clock tick is received within
90        /// this window, the slave reports [`RtErrorCode::ClockDropout`].
91        timeout_ns: u64,
92    },
93
94    /// This system receives an external MIDI clock and re-emits it on
95    /// all output ports, optionally multiplied or divided.
96    ///
97    /// Unlike [`Slave`](ClockMode::Slave), passthrough mode does not
98    /// apply PLL smoothing or oscillator interpolation. It forwards
99    /// clock ticks directly, making it suitable for deterministic clock
100    /// distribution chains where jitter smoothing is undesirable.
101    Passthrough {
102        /// Name (or substring) of the MIDI input port carrying the
103        /// external clock.
104        clock_input_port: String,
105        /// Timeout in nanoseconds: if no clock tick is received within
106        /// this window, a [`RtErrorCode::ClockDropout`] event is emitted.
107        timeout_ns: u64,
108        /// Clock multiplication factor (1 = forward unchanged). For each
109        /// input tick, `multiply / divide` output ticks are emitted.
110        multiply: u8,
111        /// Clock division factor (1 = forward unchanged). For each
112        /// input tick, `multiply / divide` output ticks are emitted.
113        divide: u8,
114    },
115}
116
117/// Full configuration for starting the RT runtime.
118///
119/// Specifies the clock mode (master, slave, or passthrough), which
120/// MIDI ports to open, and the capacity of the lock-free
121/// communication queues.
122#[derive(Debug, Clone)]
123pub struct RuntimeConfig {
124    /// Clock mode selection (master, slave, or passthrough).
125    pub clock_mode: ClockMode,
126    /// MIDI output port configurations.
127    pub outputs: Vec<MidiOutputConfig>,
128    /// MIDI input port configurations.
129    pub inputs: Vec<MidiInputConfig>,
130    /// Capacity of the RT-to-ECS event queue.
131    pub event_queue_capacity: usize,
132    /// Capacity of the ECS-to-RT command queue.
133    pub command_queue_capacity: usize,
134    /// If `true`, the runtime will continue at normal OS priority when
135    /// RT priority elevation fails. If `false` (the default), a failed
136    /// elevation is treated as a fatal startup error.
137    pub allow_normal_priority: bool,
138}
139
140/// Handle to the running RT thread.
141///
142/// Owns the join handle for the spawned thread. Dropping the `Runtime`
143/// signals shutdown and joins the thread. Use [`Runtime::start`] to
144/// create one.
145pub struct Runtime {
146    /// Join handle for the RT thread (`None` after `stop` or `drop`).
147    pub(crate) thread: Option<JoinHandle<()>>,
148    /// Atomic flag shared with the RT thread to signal shutdown.
149    shutdown: Arc<AtomicBool>,
150}
151
152impl std::fmt::Debug for Runtime {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("Runtime")
155            .field("running", &self.thread.is_some())
156            .finish()
157    }
158}
159
160impl Runtime {
161    /// Spawns the RT thread with the given configuration.
162    ///
163    /// Blocks until the thread has elevated its priority and opened all
164    /// MIDI ports. Returns a `Runtime` handle (for lifecycle management)
165    /// and [`RtHandles`] (for queue communication with the ECS world).
166    ///
167    /// # Errors
168    ///
169    /// Returns [`Error::MidiInit`] if MIDI ports cannot be opened,
170    /// [`Error::PortNotFound`] if a configured port name has no match,
171    /// or [`Error::PriorityElevation`] if RT priority cannot be obtained
172    /// and [`RuntimeConfig::allow_normal_priority`] is `false`.
173    pub fn start(config: RuntimeConfig) -> Result<(Self, RtHandles), Error> {
174        let (rt_queues, ecs_handles) = crate::queues::create_queues(
175            config.event_queue_capacity,
176            config.command_queue_capacity,
177        );
178
179        let shutdown = Arc::new(AtomicBool::new(false));
180        let shutdown_clone = Arc::clone(&shutdown);
181
182        let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
183
184        let thread = std::thread::Builder::new()
185            .name("oxurack-rt".into())
186            .spawn(move || {
187                crate::thread::rt_thread_main(rt_queues, config, ready_tx, shutdown_clone);
188            })
189            .map_err(|e| Error::MidiInit(format!("failed to spawn RT thread: {e}")))?;
190
191        // Wait for the thread to signal readiness (or error).
192        let ready_result = ready_rx.recv().map_err(|_| Error::ThreadPanicked)?;
193        ready_result?;
194
195        Ok((
196            Self {
197                thread: Some(thread),
198                shutdown,
199            },
200            ecs_handles,
201        ))
202    }
203
204    /// Gracefully shuts down the RT thread.
205    ///
206    /// Sets the shutdown flag and joins the thread. This is also called
207    /// automatically on [`Drop`].
208    ///
209    /// # Errors
210    ///
211    /// Returns [`Error::AlreadyStopped`] if the runtime was already
212    /// shut down, or [`Error::ThreadPanicked`] if the thread panicked.
213    pub fn stop(&mut self) -> Result<(), Error> {
214        self.shutdown.store(true, Ordering::Relaxed);
215        if let Some(thread) = self.thread.take() {
216            thread.join().map_err(|_| Error::ThreadPanicked)?;
217        } else {
218            return Err(Error::AlreadyStopped);
219        }
220        Ok(())
221    }
222}
223
224impl Drop for Runtime {
225    fn drop(&mut self) {
226        self.shutdown.store(true, Ordering::Relaxed);
227        if let Some(thread) = self.thread.take() {
228            let _ = thread.join();
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_runtime_debug() {
239        let config = RuntimeConfig {
240            clock_mode: ClockMode::Master {
241                tempo_bpm: 120.0,
242                send_transport: false,
243            },
244            outputs: Vec::new(),
245            inputs: Vec::new(),
246            event_queue_capacity: 1024,
247            command_queue_capacity: 1024,
248            allow_normal_priority: true,
249        };
250        let (mut runtime, _handles) = Runtime::start(config).unwrap();
251
252        let debug_running = format!("{runtime:?}");
253        assert!(
254            debug_running.contains("running: true"),
255            "expected 'running: true' in debug output, got: {debug_running}"
256        );
257
258        runtime.stop().unwrap();
259
260        let debug_stopped = format!("{runtime:?}");
261        assert!(
262            debug_stopped.contains("running: false"),
263            "expected 'running: false' in debug output, got: {debug_stopped}"
264        );
265    }
266
267    #[test]
268    fn test_rt_handles_debug() {
269        let config = RuntimeConfig {
270            clock_mode: ClockMode::Master {
271                tempo_bpm: 120.0,
272                send_transport: false,
273            },
274            outputs: Vec::new(),
275            inputs: Vec::new(),
276            event_queue_capacity: 1024,
277            command_queue_capacity: 1024,
278            allow_normal_priority: true,
279        };
280        let (mut runtime, handles) = Runtime::start(config).unwrap();
281
282        let debug = format!("{handles:?}");
283        assert!(
284            debug.contains("RtHandles"),
285            "expected 'RtHandles' in debug output, got: {debug}"
286        );
287
288        runtime.stop().unwrap();
289    }
290
291    #[test]
292    fn test_runtime_config_debug() {
293        let config = RuntimeConfig {
294            clock_mode: ClockMode::Master {
295                tempo_bpm: 120.0,
296                send_transport: false,
297            },
298            outputs: Vec::new(),
299            inputs: Vec::new(),
300            event_queue_capacity: 1024,
301            command_queue_capacity: 1024,
302            allow_normal_priority: true,
303        };
304        let debug = format!("{config:?}");
305        assert!(
306            debug.contains("RuntimeConfig"),
307            "expected 'RuntimeConfig' in debug output, got: {debug}"
308        );
309    }
310
311    #[test]
312    fn test_clock_mode_debug() {
313        let master = ClockMode::Master {
314            tempo_bpm: 120.0,
315            send_transport: true,
316        };
317        let debug = format!("{master:?}");
318        assert!(
319            debug.contains("Master"),
320            "expected 'Master' in debug output, got: {debug}"
321        );
322
323        let slave = ClockMode::Slave {
324            clock_input_port: "test".to_string(),
325            timeout_ns: 1_000_000_000,
326        };
327        let debug = format!("{slave:?}");
328        assert!(
329            debug.contains("Slave"),
330            "expected 'Slave' in debug output, got: {debug}"
331        );
332
333        let passthrough = ClockMode::Passthrough {
334            clock_input_port: "test".to_string(),
335            timeout_ns: 1_000_000_000,
336            multiply: 2,
337            divide: 1,
338        };
339        let debug = format!("{passthrough:?}");
340        assert!(
341            debug.contains("Passthrough"),
342            "expected 'Passthrough' in debug output, got: {debug}"
343        );
344    }
345
346    #[test]
347    fn test_midi_output_config_debug() {
348        let config = MidiOutputConfig {
349            name: "test-port".to_string(),
350        };
351        let debug = format!("{config:?}");
352        assert!(
353            debug.contains("test-port"),
354            "expected port name in debug output, got: {debug}"
355        );
356    }
357
358    #[test]
359    fn test_midi_input_config_debug() {
360        let config = MidiInputConfig {
361            name: "test-input".to_string(),
362        };
363        let debug = format!("{config:?}");
364        assert!(
365            debug.contains("test-input"),
366            "expected port name in debug output, got: {debug}"
367        );
368    }
369}