Skip to main content

phosphor_core/
lib.rs

1pub mod audio;
2pub mod clip;
3pub mod cpal_backend;
4pub mod engine;
5pub mod metronome;
6pub mod mixer;
7pub mod pattern;
8pub mod project;
9pub mod transport;
10
11use serde::{Deserialize, Serialize};
12
13// ── An allocation counter for the audio path ──
14//
15// The same device as `phosphor_dsp::synth::tests::allocations_during`, and for
16// the same reason: "the callback never calls the allocator" is a property of
17// the code rather than of its output, so no test that only reads the output
18// can catch a breach of it. A global allocator has to be installed per test
19// binary, which is why this exists here as well as there.
20//
21// Counted per thread rather than globally, because cargo runs tests in
22// parallel and a global count would see every other test's work; the
23// thread-local is declared with `const` so that reading it cannot itself
24// allocate, and `try_with` is used so that an allocation during thread
25// teardown cannot panic inside the allocator.
26#[cfg(test)]
27pub(crate) mod alloc_count {
28    use std::alloc::{GlobalAlloc, Layout, System};
29    use std::cell::Cell;
30
31    thread_local! {
32        static ALLOCATIONS: Cell<u64> = const { Cell::new(0) };
33    }
34
35    struct Counting;
36
37    fn note_allocation() {
38        let _ = ALLOCATIONS.try_with(|c| c.set(c.get() + 1));
39    }
40
41    // SAFETY: every method forwards to the system allocator with the same
42    // pointer and layout it was given, so the allocator's contract is the
43    // system allocator's contract. The counter is a thread-local `Cell` of a
44    // plain integer, which allocates nothing and cannot re-enter.
45    unsafe impl GlobalAlloc for Counting {
46        unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
47            note_allocation();
48            System.alloc(layout)
49        }
50        unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
51            System.dealloc(ptr, layout);
52        }
53        unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
54            note_allocation();
55            System.alloc_zeroed(layout)
56        }
57        unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
58            note_allocation();
59            System.realloc(ptr, layout, new_size)
60        }
61    }
62
63    #[global_allocator]
64    static COUNTING: Counting = Counting;
65
66    /// How many times the allocator was reached on this thread while `body`
67    /// ran.
68    pub(crate) fn allocations_during(body: impl FnOnce()) -> u64 {
69        let before = ALLOCATIONS.with(Cell::get);
70        body();
71        ALLOCATIONS.with(Cell::get) - before
72    }
73}
74
75/// Configuration for the audio engine.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77pub struct EngineConfig {
78    /// Audio buffer size in samples. Lower = less latency, more CPU.
79    /// Typical values: 32, 64, 128, 256, 512.
80    pub buffer_size: u32,
81    /// Sample rate in Hz. Typical values: 44100, 48000, 96000.
82    pub sample_rate: u32,
83}
84
85impl Default for EngineConfig {
86    /// The numbers to run at when there is no device to ask — `--no-audio`,
87    /// or a backend that would not open.
88    ///
89    /// Deliberately not the general-purpose starting point it looks like. As
90    /// soon as a device is open, the config comes from the device via
91    /// `From<StreamFormat>`; reaching for this instead is how an engine ends
92    /// up at a rate the stream is not running at. It is here for the case
93    /// where nothing is listening, and in that case the numbers only have to
94    /// be self-consistent.
95    fn default() -> Self {
96        Self {
97            buffer_size: 64,
98            sample_rate: 44100,
99        }
100    }
101}
102
103/// What the command line asked for. Both halves optional, and unspecified is
104/// the ordinary case.
105///
106/// Separate from [`EngineConfig`] because they are different facts: this is a
107/// request, that is what the engine runs at. Collapsing the two is what let an
108/// engine synthesising at 44100 feed a stream running at 48000 — every note
109/// 1.47 semitones sharp, 120 BPM playing back at 130.6.
110///
111/// `None` means follow the device. That is the default because on CoreAudio
112/// pinning a sample rate changes the machine's nominal rate for every other
113/// application too, and opening a DAW is not consent to that.
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
115pub struct AudioRequest {
116    /// Sample rate in Hz, or `None` to run at whatever the device is set to.
117    pub sample_rate: Option<u32>,
118    /// Block size in samples, or `None` to let the device choose.
119    pub buffer_size: Option<u32>,
120}
121
122impl AudioRequest {
123    /// Ask for nothing and take what the device is already doing.
124    #[must_use]
125    pub const fn follow_device() -> Self {
126        Self { sample_rate: None, buffer_size: None }
127    }
128
129    /// The config to run at when there is no device to follow. Anything the
130    /// command line named is honoured; the rest comes from
131    /// [`EngineConfig::default`].
132    #[must_use]
133    pub fn without_device(self) -> EngineConfig {
134        let fallback = EngineConfig::default();
135        EngineConfig {
136            sample_rate: self.sample_rate.unwrap_or(fallback.sample_rate),
137            buffer_size: self.buffer_size.unwrap_or(fallback.buffer_size),
138        }
139    }
140}
141
142impl From<EngineConfig> for AudioRequest {
143    /// "Run at exactly this" as a request. Used by callers that already hold
144    /// concrete numbers — the headless test apps, which open no device at all.
145    fn from(config: EngineConfig) -> Self {
146        Self {
147            sample_rate: Some(config.sample_rate),
148            buffer_size: Some(config.buffer_size),
149        }
150    }
151}
152
153impl From<crate::cpal_backend::StreamFormat> for EngineConfig {
154    /// The config the engine must be built from once a device has been opened.
155    ///
156    /// `buffer_size` falls back to the largest block the device may deliver
157    /// when the device was left to choose its own: nothing sizes a buffer from
158    /// this field any more — the mixer takes `max_buffer_frames` directly — so
159    /// the honest value for a block size we were never told is the worst case
160    /// rather than a guess.
161    fn from(format: crate::cpal_backend::StreamFormat) -> Self {
162        Self {
163            buffer_size: format.buffer_size.unwrap_or(format.max_buffer_frames),
164            sample_rate: format.sample_rate,
165        }
166    }
167}
168
169impl EngineConfig {
170    /// Buffer duration in seconds.
171    pub fn buffer_duration_secs(&self) -> f64 {
172        self.buffer_size as f64 / self.sample_rate as f64
173    }
174
175    /// Buffer duration in milliseconds.
176    pub fn buffer_duration_ms(&self) -> f64 {
177        self.buffer_duration_secs() * 1000.0
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn default_config_is_sensible() {
187        let config = EngineConfig::default();
188        assert_eq!(config.buffer_size, 64);
189        assert_eq!(config.sample_rate, 44100);
190    }
191
192    #[test]
193    fn an_empty_request_asks_for_nothing() {
194        let request = AudioRequest::follow_device();
195        assert_eq!(request.sample_rate, None);
196        assert_eq!(request.buffer_size, None);
197        assert_eq!(request, AudioRequest::default());
198    }
199
200    /// `--no-audio` has no device to follow, so it needs concrete numbers.
201    #[test]
202    fn without_a_device_the_gaps_are_filled_from_the_default() {
203        assert_eq!(
204            AudioRequest::follow_device().without_device(),
205            EngineConfig::default()
206        );
207    }
208
209    /// ...but anything the command line did name still stands, device or no
210    /// device.
211    #[test]
212    fn without_a_device_what_was_asked_for_is_still_honoured() {
213        let request = AudioRequest { sample_rate: Some(96000), buffer_size: None };
214        let config = request.without_device();
215        assert_eq!(config.sample_rate, 96000);
216        assert_eq!(config.buffer_size, EngineConfig::default().buffer_size);
217    }
218
219    #[test]
220    fn a_concrete_config_converts_to_a_request_for_exactly_it() {
221        let config = EngineConfig { buffer_size: 256, sample_rate: 96000 };
222        assert_eq!(
223            AudioRequest::from(config),
224            AudioRequest { sample_rate: Some(96000), buffer_size: Some(256) }
225        );
226        assert_eq!(AudioRequest::from(config).without_device(), config);
227    }
228
229    /// A block size the device was never pinned to has no honest nominal
230    /// value, so the worst case stands in for it.
231    #[test]
232    fn a_device_chosen_block_size_reports_the_worst_case() {
233        use crate::cpal_backend::{Requested, StreamFormat};
234        let format = StreamFormat {
235            sample_rate: 48000,
236            buffer_size: None,
237            max_buffer_frames: 4096,
238            channels: 2,
239            sample_rate_request: Requested::Unasked,
240            buffer_size_request: Requested::Unasked,
241        };
242        let config = EngineConfig::from(format);
243        assert_eq!(config.sample_rate, 48000);
244        assert_eq!(config.buffer_size, 4096);
245    }
246
247    #[test]
248    fn buffer_duration_calculation() {
249        let config = EngineConfig {
250            buffer_size: 64,
251            sample_rate: 44100,
252        };
253        let ms = config.buffer_duration_ms();
254        assert!((ms - 1.451).abs() < 0.01, "Expected ~1.45ms, got {ms}ms");
255    }
256
257    #[test]
258    fn buffer_duration_various_sizes() {
259        for (size, rate, expected_ms) in [
260            (64, 44100, 1.451),
261            (128, 44100, 2.902),
262            (256, 48000, 5.333),
263            (64, 96000, 0.667),
264        ] {
265            let config = EngineConfig {
266                buffer_size: size,
267                sample_rate: rate,
268            };
269            let ms = config.buffer_duration_ms();
270            assert!(
271                (ms - expected_ms).abs() < 0.01,
272                "size={size} rate={rate}: expected {expected_ms}ms, got {ms}ms"
273            );
274        }
275    }
276}