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}