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, 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/// Shared handle for a track — the UI holds an `Arc<TrackHandle>` to
131/// read VU levels and write mute/solo/arm/volume.
132#[derive(Debug)]
133pub struct TrackHandle {
134    pub id: usize,
135    pub kind: TrackKind,
136    pub config: TrackConfig,
137    pub vu: VuLevels,
138}
139
140impl TrackHandle {
141    pub fn new(id: usize, kind: TrackKind) -> Self {
142        Self {
143            id,
144            kind,
145            config: TrackConfig::new(),
146            vu: VuLevels::new(),
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn track_config_defaults() {
157        let cfg = TrackConfig::new();
158        assert!(!cfg.is_muted());
159        assert!(!cfg.is_soloed());
160        assert!(!cfg.is_armed());
161        assert!((cfg.get_volume() - TrackConfig::DEFAULT_VOLUME).abs() < 0.001);
162    }
163
164    #[test]
165    fn track_config_volume_round_trip() {
166        let cfg = TrackConfig::new();
167        cfg.set_volume(0.42);
168        assert!((cfg.get_volume() - 0.42).abs() < 0.001);
169    }
170
171    /// The whole fader travel round-trips, including the makeup gain above
172    /// unity that the range was widened for.
173    #[test]
174    fn track_config_volume_spans_the_fader() {
175        let cfg = TrackConfig::new();
176        for v in [0.0f32, 0.25, TrackConfig::DEFAULT_VOLUME, TrackConfig::UNITY_VOLUME, 1.5, 2.0] {
177            cfg.set_volume(v);
178            assert_eq!(cfg.get_volume().to_bits(), v.to_bits(), "fader lost {v}");
179        }
180    }
181
182    /// The clamp is the reason `volume` has no other writer: whatever the UI
183    /// computes, the audio thread only ever sees a value inside the travel.
184    #[test]
185    fn track_config_volume_is_clamped() {
186        let cfg = TrackConfig::new();
187        cfg.set_volume(-1.0);
188        assert_eq!(cfg.get_volume(), TrackConfig::MIN_VOLUME);
189        cfg.set_volume(50.0);
190        assert_eq!(cfg.get_volume(), TrackConfig::MAX_VOLUME);
191        cfg.set_volume(f32::INFINITY);
192        assert_eq!(cfg.get_volume(), TrackConfig::MAX_VOLUME);
193        cfg.set_volume(f32::NEG_INFINITY);
194        assert_eq!(cfg.get_volume(), TrackConfig::MIN_VOLUME);
195    }
196
197    /// A NaN leaves the fader where it was. Storing it would multiply the
198    /// track to NaN and the master limiter would render it as silence.
199    #[test]
200    fn track_config_volume_ignores_nan() {
201        let cfg = TrackConfig::new();
202        cfg.set_volume(1.25);
203        cfg.set_volume(f32::NAN);
204        assert_eq!(cfg.get_volume(), 1.25);
205    }
206
207    #[test]
208    fn track_config_atomics() {
209        let cfg = TrackConfig::new();
210        cfg.muted.store(true, Ordering::Relaxed);
211        assert!(cfg.is_muted());
212        cfg.soloed.store(true, Ordering::Relaxed);
213        assert!(cfg.is_soloed());
214        cfg.armed.store(true, Ordering::Relaxed);
215        assert!(cfg.is_armed());
216    }
217
218    #[test]
219    fn track_handle_new() {
220        let h = TrackHandle::new(0, TrackKind::Instrument);
221        assert_eq!(h.id, 0);
222        assert_eq!(h.kind, TrackKind::Instrument);
223        assert!(!h.config.is_muted());
224    }
225
226    #[test]
227    fn track_kind_variants() {
228        assert_ne!(TrackKind::Instrument, TrackKind::Audio);
229        assert_ne!(TrackKind::SendA, TrackKind::SendB);
230        assert_ne!(TrackKind::Master, TrackKind::Audio);
231    }
232}