Skip to main content

phosphor_core/
project.rs

1//! Shared domain models for the audio engine and UI.
2//!
3//! These types live in phosphor-core so both the audio thread (mixer)
4//! and the UI thread (TUI/GUI) can reference the same data without
5//! duplicating definitions. Audio-thread-safe state uses atomics.
6
7use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering};
8
9use crate::engine::VuLevels;
10
11/// Identifies a track by index.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub struct TrackId(pub usize);
14
15/// What kind of track this is — determines routing and capabilities.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TrackKind {
18    /// Has a synth/plugin, receives MIDI.
19    Instrument,
20    /// Plays back audio clips.
21    Audio,
22    /// Send bus A.
23    SendA,
24    /// Send bus B.
25    SendB,
26    /// Master output bus.
27    Master,
28}
29
30/// Audio-thread-safe track configuration.
31///
32/// Written by the UI thread, read by the audio thread — all fields
33/// are atomic so no locks are needed.
34#[derive(Debug)]
35pub struct TrackConfig {
36    pub muted: AtomicBool,
37    pub soloed: AtomicBool,
38    pub armed: AtomicBool,
39    /// Whether this track is currently selected for MIDI input.
40    /// Only one track should be selected at a time.
41    pub midi_active: AtomicBool,
42    /// Fader position as a linear gain, stored as f32 bits in an AtomicU32.
43    ///
44    /// Written by the UI thread, read once per buffer by the audio thread.
45    /// Constrained to [`TrackConfig::MIN_VOLUME`]..=[`TrackConfig::MAX_VOLUME`]
46    /// by [`TrackConfig::set_volume`], which is the only way to write it.
47    pub volume: AtomicU32,
48}
49
50impl TrackConfig {
51    /// Bottom of the fader: silence.
52    pub const MIN_VOLUME: f32 = 0.0;
53
54    /// Unity gain — the track reaches the master bus at the level the
55    /// instrument produced it.
56    pub const UNITY_VOLUME: f32 = 1.0;
57
58    /// Top of the fader, +6 dB.
59    ///
60    /// Makeup gain above unity, not decoration. The instruments are trimmed
61    /// so that ordinary playing peaks near −12 dBFS, which leaves room for
62    /// several tracks to sum; a user who is playing one quiet pad on its own
63    /// needs somewhere to get that back, and the alternative is the operating
64    /// system's volume control, which raises everything else on the machine
65    /// too. The master limiter is what makes the top of the range safe.
66    pub const MAX_VOLUME: f32 = 2.0;
67
68    /// Where a new track's fader starts, −2.5 dB.
69    ///
70    /// Below unity so that adding a second and third track does not
71    /// immediately need the limiter, and so the fader has visible travel in
72    /// both directions before it is touched.
73    pub const DEFAULT_VOLUME: f32 = 0.75;
74
75    pub fn new() -> Self {
76        Self {
77            muted: AtomicBool::new(false),
78            soloed: AtomicBool::new(false),
79            armed: AtomicBool::new(false),
80            midi_active: AtomicBool::new(false),
81            volume: AtomicU32::new(Self::DEFAULT_VOLUME.to_bits()),
82        }
83    }
84
85    pub fn get_volume(&self) -> f32 {
86        f32::from_bits(self.volume.load(Ordering::Relaxed))
87    }
88
89    /// Set the fader position, clamped to the fader's travel.
90    ///
91    /// The clamp is here rather than at the call sites because this value is
92    /// read on the audio thread and multiplied into every sample of the
93    /// track: a caller that computes a position wrongly would otherwise turn
94    /// a UI arithmetic slip into a full-scale burst. A NaN is not a fader
95    /// position at all, so it is ignored rather than stored — storing it
96    /// would multiply the track to NaN, which the master limiter turns into
97    /// silence, and a silent track with no visible cause is worse than a
98    /// dropped keystroke.
99    pub fn set_volume(&self, v: f32) {
100        if v.is_nan() {
101            return;
102        }
103        let clamped = v.clamp(Self::MIN_VOLUME, Self::MAX_VOLUME);
104        self.volume.store(clamped.to_bits(), Ordering::Relaxed);
105    }
106
107    pub fn is_muted(&self) -> bool {
108        self.muted.load(Ordering::Relaxed)
109    }
110
111    pub fn is_soloed(&self) -> bool {
112        self.soloed.load(Ordering::Relaxed)
113    }
114
115    pub fn is_armed(&self) -> bool {
116        self.armed.load(Ordering::Relaxed)
117    }
118
119    pub fn is_midi_active(&self) -> bool {
120        self.midi_active.load(Ordering::Relaxed)
121    }
122}
123
124impl Default for TrackConfig {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130/// What a sequencer track's pattern player is doing, for the UI to draw.
131///
132/// Four small atomics rather than a channel, and for the same reason as
133/// [`VuLevels`]: the UI redraws on a timer and wants the state *now* rather
134/// than a history of it, and publishing must not make the audio thread
135/// allocate, block, or care whether anyone is listening.
136///
137/// The step and the queued slot are things the UI could work out for itself —
138/// both are functions of the transport position — but only by reimplementing
139/// the audio thread's arithmetic and hoping the two never disagree. Reading
140/// what actually played is one store per callback and cannot drift.
141#[derive(Debug, Default)]
142pub struct PatternStatus {
143    /// Which of the eight slots is sounding.
144    live_slot: AtomicU8,
145    /// The queued slot plus one, or zero when nothing is queued — so that
146    /// "nothing" and "slot 0" are different values in one byte.
147    queued_slot: AtomicU8,
148    /// The step the playhead was over on the last callback.
149    step: AtomicU8,
150    /// Whether the pattern is running at all.
151    running: AtomicBool,
152}
153
154impl PatternStatus {
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Called once per callback from the audio thread.
160    pub fn publish(&self, live_slot: u8, queued_slot: Option<u8>, step: u8, running: bool) {
161        self.live_slot.store(live_slot, Ordering::Relaxed);
162        self.queued_slot
163            .store(queued_slot.map_or(0, |s| s.saturating_add(1)), Ordering::Relaxed);
164        self.step.store(step, Ordering::Relaxed);
165        self.running.store(running, Ordering::Relaxed);
166    }
167
168    pub fn live_slot(&self) -> u8 {
169        self.live_slot.load(Ordering::Relaxed)
170    }
171
172    pub fn queued_slot(&self) -> Option<u8> {
173        match self.queued_slot.load(Ordering::Relaxed) {
174            0 => None,
175            n => Some(n - 1),
176        }
177    }
178
179    pub fn step(&self) -> u8 {
180        self.step.load(Ordering::Relaxed)
181    }
182
183    pub fn is_running(&self) -> bool {
184        self.running.load(Ordering::Relaxed)
185    }
186}
187
188/// Shared handle for a track — the UI holds an `Arc<TrackHandle>` to
189/// read VU levels and write mute/solo/arm/volume.
190#[derive(Debug)]
191pub struct TrackHandle {
192    pub id: usize,
193    pub kind: TrackKind,
194    pub config: TrackConfig,
195    pub vu: VuLevels,
196    /// Where the step sequencer on this track is, when it has one. Left at
197    /// its defaults on every other track, which costs four bytes.
198    pub pattern: PatternStatus,
199}
200
201impl TrackHandle {
202    pub fn new(id: usize, kind: TrackKind) -> Self {
203        Self {
204            id,
205            kind,
206            config: TrackConfig::new(),
207            vu: VuLevels::new(),
208            pattern: PatternStatus::new(),
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn track_config_defaults() {
219        let cfg = TrackConfig::new();
220        assert!(!cfg.is_muted());
221        assert!(!cfg.is_soloed());
222        assert!(!cfg.is_armed());
223        assert!((cfg.get_volume() - TrackConfig::DEFAULT_VOLUME).abs() < 0.001);
224    }
225
226    #[test]
227    fn track_config_volume_round_trip() {
228        let cfg = TrackConfig::new();
229        cfg.set_volume(0.42);
230        assert!((cfg.get_volume() - 0.42).abs() < 0.001);
231    }
232
233    /// The whole fader travel round-trips, including the makeup gain above
234    /// unity that the range was widened for.
235    #[test]
236    fn track_config_volume_spans_the_fader() {
237        let cfg = TrackConfig::new();
238        for v in [0.0f32, 0.25, TrackConfig::DEFAULT_VOLUME, TrackConfig::UNITY_VOLUME, 1.5, 2.0] {
239            cfg.set_volume(v);
240            assert_eq!(cfg.get_volume().to_bits(), v.to_bits(), "fader lost {v}");
241        }
242    }
243
244    /// The clamp is the reason `volume` has no other writer: whatever the UI
245    /// computes, the audio thread only ever sees a value inside the travel.
246    #[test]
247    fn track_config_volume_is_clamped() {
248        let cfg = TrackConfig::new();
249        cfg.set_volume(-1.0);
250        assert_eq!(cfg.get_volume(), TrackConfig::MIN_VOLUME);
251        cfg.set_volume(50.0);
252        assert_eq!(cfg.get_volume(), TrackConfig::MAX_VOLUME);
253        cfg.set_volume(f32::INFINITY);
254        assert_eq!(cfg.get_volume(), TrackConfig::MAX_VOLUME);
255        cfg.set_volume(f32::NEG_INFINITY);
256        assert_eq!(cfg.get_volume(), TrackConfig::MIN_VOLUME);
257    }
258
259    /// A NaN leaves the fader where it was. Storing it would multiply the
260    /// track to NaN and the master limiter would render it as silence.
261    #[test]
262    fn track_config_volume_ignores_nan() {
263        let cfg = TrackConfig::new();
264        cfg.set_volume(1.25);
265        cfg.set_volume(f32::NAN);
266        assert_eq!(cfg.get_volume(), 1.25);
267    }
268
269    #[test]
270    fn track_config_atomics() {
271        let cfg = TrackConfig::new();
272        cfg.muted.store(true, Ordering::Relaxed);
273        assert!(cfg.is_muted());
274        cfg.soloed.store(true, Ordering::Relaxed);
275        assert!(cfg.is_soloed());
276        cfg.armed.store(true, Ordering::Relaxed);
277        assert!(cfg.is_armed());
278    }
279
280    #[test]
281    fn track_handle_new() {
282        let h = TrackHandle::new(0, TrackKind::Instrument);
283        assert_eq!(h.id, 0);
284        assert_eq!(h.kind, TrackKind::Instrument);
285        assert!(!h.config.is_muted());
286    }
287
288    #[test]
289    fn track_kind_variants() {
290        assert_ne!(TrackKind::Instrument, TrackKind::Audio);
291        assert_ne!(TrackKind::SendA, TrackKind::SendB);
292        assert_ne!(TrackKind::Master, TrackKind::Audio);
293    }
294}