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}