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