phosphor_app/state/track.rs
1//! Track state — TrackState, TrackElement, Clip, MidiNote.
2
3use phosphor_core::project::{TrackConfig, TrackKind};
4
5// ── Track Element Navigation ──
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum TrackElement {
9 Label,
10 Fx,
11 Volume,
12 Mute,
13 Solo,
14 RecordArm,
15 Clip(usize),
16}
17
18impl TrackElement {
19 pub fn move_right(self, num_clips: usize) -> Self {
20 match self {
21 Self::Label => Self::Fx,
22 Self::Fx => Self::Volume,
23 Self::Volume => Self::Mute,
24 Self::Mute => Self::Solo,
25 Self::Solo => Self::RecordArm,
26 Self::RecordArm => {
27 if num_clips > 0 { Self::Clip(0) } else { Self::RecordArm }
28 }
29 Self::Clip(i) => {
30 if i + 1 < num_clips { Self::Clip(i + 1) } else { Self::Clip(i) }
31 }
32 }
33 }
34
35 pub fn move_left(self) -> Self {
36 match self {
37 Self::Label => Self::Label,
38 Self::Fx => Self::Label,
39 Self::Volume => Self::Fx,
40 Self::Mute => Self::Volume,
41 Self::Solo => Self::Mute,
42 Self::RecordArm => Self::Solo,
43 Self::Clip(0) => Self::RecordArm,
44 Self::Clip(i) => Self::Clip(i - 1),
45 }
46 }
47}
48
49// ── Data Models ──
50
51#[derive(Debug, Clone)]
52pub struct Clip {
53 pub number: usize,
54 pub width: u16,
55 pub has_content: bool,
56 /// Start position on the timeline (ticks).
57 pub start_tick: i64,
58 /// Length in ticks.
59 pub length_ticks: i64,
60 /// Notes for piano roll display (from ClipSnapshot).
61 pub notes: Vec<phosphor_core::clip::NoteSnapshot>,
62 /// Notes hidden by shrinking the clip. Stored with start_frac and
63 /// duration_frac as absolute tick ratios (tick / original_length_when_hidden)
64 /// converted to tick offsets for stable restore.
65 /// Format: (tick_offset_from_clip_start, duration_ticks, note, velocity)
66 pub hidden_notes: Vec<(i64, i64, u8, u8)>,
67}
68
69#[derive(Debug, Clone)]
70pub struct TrackState {
71 pub name: String,
72 pub muted: bool,
73 pub soloed: bool,
74 pub armed: bool,
75 pub color_index: usize,
76 pub kind: TrackKind,
77 pub clips: Vec<Clip>,
78 /// Track-level FX chain.
79 pub fx_chain: Vec<super::FxInstance>,
80 /// Fader position as a linear gain, mirroring the audio thread's
81 /// `TrackConfig::volume`. Travel is
82 /// [`TrackConfig::MIN_VOLUME`]..=[`TrackConfig::MAX_VOLUME`]; the audio
83 /// thread's copy is clamped to it by `TrackConfig::set_volume`, so this
84 /// mirror is the only place an out-of-range value could survive.
85 pub volume: f32,
86 /// Unique ID for this track (matches the mixer's track ID).
87 pub mixer_id: Option<usize>,
88 /// Handle to the audio engine's track state. When present, mute/solo/arm/volume
89 /// writes go directly to the audio thread via atomics.
90 pub handle: Option<std::sync::Arc<phosphor_core::project::TrackHandle>>,
91 /// What type of instrument this track has.
92 ///
93 /// On a sequencer track this is the *child*: the thing in the plugin slot
94 /// making the sound. There is no separate instrument type for a
95 /// sequencer, which is what lets the child's panel, preset bank and saved
96 /// parameters all keep working untouched. What marks the track is
97 /// [`TrackState::sequencer`].
98 pub instrument_type: Option<super::InstrumentType>,
99 /// Parameter values (mirrors the audio thread's plugin params).
100 pub synth_params: Vec<f32>,
101 /// The step sequencer driving this track, when it has one.
102 ///
103 /// Edited only through [`crate::sequencer::ops::dispatch`] — see that
104 /// module for why there is exactly one way in.
105 ///
106 /// Boxed because eight patterns are nineteen kilobytes, and a
107 /// `TrackState` is cloned whole for every undo entry and moved whenever
108 /// the track list grows. A track with no sequencer should pay a pointer
109 /// for the possibility, not a pattern bank.
110 pub sequencer: Option<Box<crate::sequencer::SequencerState>>,
111}
112
113impl TrackState {
114 /// One press of the fader, in dB.
115 ///
116 /// The fader is stepped in dB rather than in linear gain so that every
117 /// press moves the readout by exactly one. A linear step small enough to
118 /// be useful near the top of the travel is a 6 dB jump near the bottom,
119 /// and three consecutive linear detents around unity all round to the
120 /// same displayed number — a control that does not appear to respond.
121 pub const VOLUME_STEP_DB: f32 = 1.0;
122
123 /// Bottom of the fader's travel, below which it goes to silence.
124 ///
125 /// −40 dB rather than the −60 a drawn fader usually bottoms out at: this
126 /// one is stepped a keypress at a time, and 20 extra presses to reach a
127 /// level that is inaudible anyway is travel nobody wants. Muting a track
128 /// outright is `m`.
129 pub const VOLUME_FLOOR_DB: f32 = -40.0;
130
131 pub fn new(name: &str, color_index: usize, armed: bool, kind: TrackKind, clips: Vec<Clip>) -> Self {
132 Self {
133 name: name.to_string(),
134 muted: false,
135 soloed: false,
136 armed,
137 color_index,
138 kind,
139 clips,
140 fx_chain: Vec::new(),
141 volume: TrackConfig::DEFAULT_VOLUME,
142 mixer_id: None,
143 handle: None,
144 instrument_type: None,
145 synth_params: Vec::new(),
146 sequencer: None,
147 }
148 }
149
150 /// Sync mute/solo/arm/volume to the audio thread handle (if wired up).
151 pub fn sync_to_audio(&self) {
152 if let Some(ref h) = self.handle {
153 h.config.muted.store(self.muted, std::sync::atomic::Ordering::Relaxed);
154 h.config.soloed.store(self.soloed, std::sync::atomic::Ordering::Relaxed);
155 h.config.armed.store(self.armed, std::sync::atomic::Ordering::Relaxed);
156 h.config.set_volume(self.volume);
157 }
158 }
159
160 /// The fader position in dB relative to unity. `None` at the bottom of
161 /// the travel, where the answer is negative infinity.
162 pub fn volume_db(&self) -> Option<f32> {
163 (self.volume > 0.0).then(|| 20.0 * self.volume.log10())
164 }
165
166 /// Move the fader by `steps` presses and push the result to the audio
167 /// thread. Returns the new linear gain.
168 ///
169 /// The current position is rounded onto the dB grid before stepping, so
170 /// the fader self-corrects: the default of 0.75 is −2.5 dB, off the grid,
171 /// and the first press lands it on −2 or −3 and every press after that
172 /// moves exactly one. A session saved with a hand-edited volume snaps the
173 /// same way.
174 pub fn adjust_volume(&mut self, steps: i32) -> f32 {
175 let top_db = 20.0 * TrackConfig::MAX_VOLUME.log10();
176 // One step below the floor is the silent position, so stepping down
177 // from the bottom of the travel reaches it and stepping up leaves it.
178 let silent_db = Self::VOLUME_FLOOR_DB - Self::VOLUME_STEP_DB;
179 let current_db = self.volume_db().map_or(silent_db, |db| db.round());
180
181 let target_db =
182 (current_db + steps as f32 * Self::VOLUME_STEP_DB).clamp(silent_db, top_db);
183 self.volume = if target_db < Self::VOLUME_FLOOR_DB {
184 TrackConfig::MIN_VOLUME
185 } else {
186 10.0f32
187 .powf(target_db / 20.0)
188 .clamp(TrackConfig::MIN_VOLUME, TrackConfig::MAX_VOLUME)
189 };
190
191 self.sync_to_audio();
192 self.volume
193 }
194
195 /// Read VU levels from the audio thread handle.
196 pub fn vu_levels(&self) -> (f32, f32) {
197 self.handle.as_ref().map(|h| h.vu.get()).unwrap_or((0.0, 0.0))
198 }
199
200 /// Whether this track is wired to the audio engine.
201 pub fn is_live(&self) -> bool {
202 self.handle.is_some()
203 }
204}