Skip to main content

phosphor_app/state/
track_ops.rs

1//! NavState methods: track ops.
2
3use super::*;
4
5impl NavState {
6
7    pub fn toggle_mute(&mut self) {
8        if let Some(t) = self.current_track_mut() {
9            t.muted = !t.muted;
10            t.sync_to_audio();
11        }
12    }
13
14
15    pub fn toggle_solo(&mut self) {
16        if let Some(t) = self.current_track_mut() {
17            t.soloed = !t.soloed;
18            t.sync_to_audio();
19        }
20    }
21
22
23    pub fn toggle_arm(&mut self) {
24        if let Some(t) = self.current_track_mut() {
25            t.armed = !t.armed;
26            t.sync_to_audio();
27        }
28    }
29
30    /// Move the selected track's fader by `steps` presses, syncing to the
31    /// audio thread. Returns the new linear gain, or `None` if there is no
32    /// track under the cursor.
33    ///
34    /// Not undoable, and deliberately: the fader is a continuous control like
35    /// the synth parameters, and none of those push onto the undo stack
36    /// either. The stack is for structural edits — notes, clips, tracks —
37    /// where the change cannot be reversed by looking at the screen and
38    /// pressing the other key.
39    pub fn adjust_volume(&mut self, steps: i32) -> Option<f32> {
40        self.current_track_mut().map(|t| t.adjust_volume(steps))
41    }
42
43
44    pub fn digit_input(&mut self, ch: char) {
45        if self.focused_pane == Pane::Tracks && self.track_selected {
46            self.number_buf.push_digit(ch);
47        }
48    }
49
50
51    pub fn tick(&mut self) {
52        if let Some(clip_num) = self.number_buf.check_timeout() {
53            self.jump_to_clip(clip_num);
54        }
55    }
56
57
58    pub fn jump_to_clip(&mut self, clip_number: usize) {
59        if let Some(track) = self.current_track() {
60            tracing::debug!(
61                "jump_to_clip: looking for #{}, track has {} clips: {:?}",
62                clip_number, track.clips.len(),
63                track.clips.iter().map(|c| (c.number, c.start_tick, c.length_ticks)).collect::<Vec<_>>()
64            );
65            if let Some(idx) = track.clips.iter().position(|c| c.number == clip_number) {
66                self.track_element = TrackElement::Clip(idx);
67                self.open_clip_view(self.track_cursor, idx);
68                tracing::debug!("jump_to_clip: selected idx={}", idx);
69            }
70        }
71    }
72
73
74    pub fn activate_element(&mut self) {
75        match self.track_element {
76            TrackElement::Mute => self.toggle_mute(),
77            TrackElement::Solo => self.toggle_solo(),
78            TrackElement::RecordArm => self.toggle_arm(),
79            TrackElement::Fx => {
80                self.fx_menu.open = true;
81                self.fx_menu.cursor = 0;
82            }
83            TrackElement::Volume => {
84                // Lock the fader so h/l adjusts it instead of stepping to the
85                // next element — the same Enter-to-lock, Esc-to-release shape
86                // as a clip and as the transport's BPM field. Only on tracks
87                // that have a fader: a bus track's header does not draw one,
88                // and locking a control that is not on screen is a dead end.
89                if self.current_track().is_some_and(super::TrackState::is_live) {
90                    self.element_locked = true;
91                }
92            }
93            TrackElement::Clip(idx) => {
94                self.element_locked = true;
95                self.open_clip_view(self.track_cursor, idx);
96                self.clip_view.clip_tab = ClipTab::PianoRoll;
97                self.clip_view.focus = ClipViewFocus::PianoRoll;
98                tracing::debug!(
99                    "clip locked: track={} clip={} start={} len={}",
100                    self.track_cursor, idx,
101                    self.tracks.get(self.track_cursor).and_then(|t| t.clips.get(idx)).map(|c| c.start_tick).unwrap_or(-1),
102                    self.tracks.get(self.track_cursor).and_then(|t| t.clips.get(idx)).map(|c| c.length_ticks).unwrap_or(-1),
103                );
104            }
105            _ => {}
106        }
107    }
108
109    /// Add a new instrument track. Inserts before the send/master tracks.
110    /// `handle` is the shared audio-thread handle for this track.
111    /// `mixer_id` is the track's ID in the mixer.
112
113    /// Add a new instrument track. Inserts before the send/master tracks.
114    /// `handle` is the shared audio-thread handle for this track.
115    /// `mixer_id` is the track's ID in the mixer.
116    pub fn add_instrument_track(
117        &mut self,
118        instrument: InstrumentType,
119        mixer_id: usize,
120        handle: std::sync::Arc<phosphor_core::project::TrackHandle>,
121    ) {
122        let name = match instrument {
123            InstrumentType::Synth => "synth",
124            InstrumentType::DrumRack => "drums",
125            InstrumentType::DX7 => "dx7",
126            InstrumentType::Jupiter8 => "jup8",
127            InstrumentType::Odyssey => "odyss",
128            InstrumentType::Juno60 => "juno",
129            InstrumentType::Rhodes => "rhode",
130            InstrumentType::Sampler => "smplr",
131        };
132
133        // Find insert position: before sends/master
134        let insert_pos = self.tracks.iter().position(|t| {
135            matches!(t.kind, TrackKind::SendA | TrackKind::SendB | TrackKind::Master)
136        }).unwrap_or(self.tracks.len());
137
138        let color = insert_pos % 8;
139        let mut track = TrackState::new(name, color, true, TrackKind::Instrument, vec![]);
140        track.mixer_id = Some(mixer_id);
141        track.handle = Some(handle);
142        track.instrument_type = Some(instrument);
143        track.synth_params = match instrument {
144            InstrumentType::Synth | InstrumentType::Sampler => {
145                phosphor_dsp::synth::PARAM_DEFAULTS.to_vec()
146            }
147            InstrumentType::DrumRack => {
148                phosphor_dsp::drum_rack::PARAM_DEFAULTS.to_vec()
149            }
150            InstrumentType::DX7 => {
151                phosphor_dsp::dx7::PARAM_DEFAULTS.to_vec()
152            }
153            InstrumentType::Jupiter8 => {
154                phosphor_dsp::jupiter::PARAM_DEFAULTS.to_vec()
155            }
156            InstrumentType::Odyssey => {
157                phosphor_dsp::odyssey::PARAM_DEFAULTS.to_vec()
158            }
159            InstrumentType::Juno60 => {
160                phosphor_dsp::juno::PARAM_DEFAULTS.to_vec()
161            }
162            InstrumentType::Rhodes => {
163                phosphor_dsp::rhodes::PARAM_DEFAULTS.to_vec()
164            }
165        };
166        // Sync the initial armed state to audio
167        track.sync_to_audio();
168        self.tracks.insert(insert_pos, track);
169
170        // Move cursor to the new track and open clip view with synth controls
171        self.track_cursor = insert_pos;
172        if self.track_cursor >= self.track_scroll + MAX_VISIBLE_TRACKS {
173            self.track_scroll = self.track_cursor + 1 - MAX_VISIBLE_TRACKS;
174        }
175
176        // Select the track, show synth controls, and route MIDI to it
177        self.track_selected = true;
178        self.track_element = TrackElement::Label;
179        self.show_current_track_controls();
180    }
181
182
183    pub fn open_clip_view(&mut self, track_idx: usize, clip_idx: usize) {
184        self.clip_view_visible = true;
185        self.clip_view_target = Some((track_idx, clip_idx));
186        self.clip_view.fx_cursor = 0;
187        tracing::debug!(
188            "open_clip_view: track={} clip={} (notes={})",
189            track_idx, clip_idx,
190            self.tracks.get(track_idx).and_then(|t| t.clips.get(clip_idx)).map(|c| c.notes.len()).unwrap_or(0)
191        );
192    }
193
194    /// Show controls for the currently selected track and route MIDI to it.
195    /// For instrument tracks: opens clip view with Synth tab, activates MIDI input.
196    /// For bus tracks: no clip view, deactivates MIDI.
197
198    pub fn fx_menu_select(&mut self) {
199        // Add FX
200        if let Some(fx_type) = FxType::ALL.get(self.fx_menu.cursor) {
201            let inst = FxInstance::new(*fx_type);
202            if let Some(t) = self.current_track_mut() {
203                t.fx_chain.push(inst);
204            }
205        }
206        self.fx_menu.open = false;
207    }
208
209
210    pub fn active_fx_chain_len(&self) -> usize {
211        match self.clip_view.fx_panel_tab {
212            FxPanelTab::TrackFx | FxPanelTab::Synth => {
213                self.current_track().map(|t| t.fx_chain.len().max(1)).unwrap_or(1)
214            }
215        }
216    }
217
218    /// Keep clip_view_target in sync with the currently selected clip element.
219    /// Called every frame as a safety net and after clip-modifying operations.
220    pub fn sync_clip_view_target(&mut self) {
221        if self.track_selected {
222            if let TrackElement::Clip(idx) = self.track_element {
223                let track_idx = self.track_cursor;
224                if let Some(track) = self.tracks.get(track_idx) {
225                    if idx < track.clips.len() {
226                        self.clip_view_target = Some((track_idx, idx));
227                        self.clip_view_visible = true;
228                        return;
229                    }
230                }
231            }
232        }
233    }
234
235    /// Remove phantom clips: when two clips overlap at the same start position,
236    /// keep the longer one and absorb the shorter one's notes (rescaled).
237    /// Returns (mixer_id, removed_clip_index) pairs so the caller can sync audio.
238    pub fn dedup_clips(&mut self) -> Vec<(usize, usize)> {
239        let ppq = phosphor_core::transport::Transport::PPQ;
240        let tolerance = ppq;
241        let mut removed = Vec::new();
242
243        for track in &mut self.tracks {
244            if track.clips.len() < 2 { continue; }
245
246            track.clips.sort_by(|a, b| {
247                a.start_tick.cmp(&b.start_tick)
248                    .then(b.length_ticks.cmp(&a.length_ticks))
249            });
250
251            let mut i = 0;
252            while i + 1 < track.clips.len() {
253                let starts_close = (track.clips[i].start_tick - track.clips[i + 1].start_tick).abs() <= tolerance;
254                if starts_close {
255                    let shorter_len = track.clips[i + 1].length_ticks;
256                    let longer_len = track.clips[i].length_ticks;
257                    if longer_len > 0 {
258                        let scale = shorter_len as f64 / longer_len as f64;
259                        let absorbed: Vec<_> = track.clips[i + 1].notes.iter().map(|n| {
260                            let mut rescaled = *n;
261                            rescaled.start_frac *= scale;
262                            rescaled.duration_frac *= scale;
263                            rescaled
264                        }).collect();
265                        track.clips[i].notes.extend(absorbed);
266                    }
267                    tracing::debug!(
268                        "dedup: absorbed clip #{} (len={}) into clip #{} (len={}) on '{}'",
269                        track.clips[i + 1].number, shorter_len,
270                        track.clips[i].number, longer_len, track.name
271                    );
272                    // Record the removal for audio thread sync
273                    if let Some(mid) = track.mixer_id {
274                        removed.push((mid, i + 1));
275                    }
276                    track.clips.remove(i + 1);
277                } else {
278                    i += 1;
279                }
280            }
281
282            for (idx, clip) in track.clips.iter_mut().enumerate() {
283                clip.number = idx + 1;
284            }
285        }
286        removed
287    }
288
289    // ── Accessors ──
290
291
292    /// Receive a clip snapshot from the audio thread and add it to the
293    /// corresponding TUI track's clip list.
294    /// `is_recording` = true when transport is actively recording (snapshots are fresh overdubs).
295    /// When NOT recording, snapshots matching the viewed clip are stale (from panic/reset) and ignored.
296    /// Returns (mixer_id, count_absorbed) so caller can send RemoveClip commands to audio.
297    pub fn receive_clip_snapshot(&mut self, snap: phosphor_core::clip::ClipSnapshot, is_recording: bool) -> Option<(usize, usize)> {
298        tracing::debug!(
299            "clip received: track={} events={} notes={} ticks={}..{} recording={}",
300            snap.track_id, snap.event_count, snap.notes.len(),
301            snap.start_tick, snap.start_tick + snap.length_ticks, is_recording,
302        );
303
304        // When NOT recording AND no grace remaining, ignore snapshots.
305        // These are stale commits from panic/reset_all that would re-add
306        // deleted notes or create phantom clips.
307        // Accept if: (a) currently recording, OR (b) grace counter > 0
308        // (final commits from tracks that just stopped recording).
309        if !is_recording && self.recording_grace == 0 {
310            tracing::debug!("IGNORED: snapshot while not recording (stale from panic/reset)");
311            return None;
312        }
313        // Decrement grace after accepting a post-recording snapshot
314        if !is_recording && self.recording_grace > 0 {
315            self.recording_grace -= 1;
316        }
317
318        // Find the track index (we need it for clip_view_target fixup)
319        let track_idx = match self.tracks.iter().position(|t| t.mixer_id == Some(snap.track_id)) {
320            Some(idx) => idx,
321            None => return None,
322        };
323
324        let mut absorbed_count = 0usize;
325        {
326            let track = &mut self.tracks[track_idx];
327            let ppq = phosphor_core::transport::Transport::PPQ;
328            let beats = (snap.length_ticks as f64 / ppq as f64).ceil() as u16;
329            let width = beats.max(2);
330            let snap_end = snap.start_tick + snap.length_ticks;
331
332            // Absorb any clips that the new recording fully covers.
333            // A clip is covered if it starts within the snap range and ends within it.
334            let mut absorbed_notes = Vec::new();
335            track.clips.retain(|c| {
336                let c_end = c.start_tick + c.length_ticks;
337                let covered = c.start_tick >= snap.start_tick && c_end <= snap_end;
338                if covered {
339                    tracing::debug!(
340                        "  absorbing clip #{}: tick {}..{} (snap covers {}..{})",
341                        c.number, c.start_tick, c_end, snap.start_tick, snap_end
342                    );
343                    // Rescale notes to snap's coordinate space
344                    let offset = (c.start_tick - snap.start_tick) as f64 / snap.length_ticks as f64;
345                    let scale = c.length_ticks as f64 / snap.length_ticks as f64;
346                    for mut n in c.notes.clone() {
347                        n.start_frac = n.start_frac * scale + offset;
348                        n.duration_frac *= scale;
349                        absorbed_notes.push(n);
350                    }
351                    absorbed_count += 1;
352                    false
353                } else {
354                    true
355                }
356            });
357
358            // Combine absorbed notes with the new recording's notes
359            let mut all_notes = absorbed_notes;
360            all_notes.extend(snap.notes);
361
362            // Try to merge into an existing clip with a close start
363            let merge_tolerance = ppq;
364            if let Some(existing) = track.clips.iter_mut().find(|c| {
365                (c.start_tick - snap.start_tick).abs() <= merge_tolerance
366            }) {
367                // Rescale if lengths differ
368                if snap.length_ticks != existing.length_ticks && existing.length_ticks > 0 {
369                    let scale = snap.length_ticks as f64 / existing.length_ticks as f64;
370                    let offset = (snap.start_tick - existing.start_tick) as f64 / existing.length_ticks as f64;
371                    for n in &mut all_notes {
372                        n.start_frac = n.start_frac * scale + offset;
373                        n.duration_frac *= scale;
374                    }
375                }
376                existing.notes.extend(all_notes);
377                existing.has_content = true;
378                existing.length_ticks = existing.length_ticks.max(snap.length_ticks);
379                existing.width = width.max(existing.width);
380                tracing::debug!(
381                    "  merged into existing clip: now {} notes, len={}",
382                    existing.notes.len(), existing.length_ticks
383                );
384            } else {
385                // Create new clip
386                let clip_number = track.clips.len() + 1;
387                tracing::debug!(
388                    "  new clip: #{} at tick {} len {} ({} notes, absorbed {})",
389                    clip_number, snap.start_tick, snap.length_ticks, all_notes.len(), absorbed_count
390                );
391                track.clips.push(Clip {
392                    number: clip_number,
393                    width,
394                    has_content: true,
395                    start_tick: snap.start_tick,
396                    length_ticks: snap.length_ticks,
397                    notes: all_notes,
398                    hidden_notes: Vec::new(),
399                });
400            }
401
402            // Renumber clips sequentially
403            for (i, c) in track.clips.iter_mut().enumerate() {
404                c.number = i + 1;
405            }
406        }
407
408        // Fix clip_view_target if it was pointing at this track
409        // (clips may have been absorbed/reordered)
410        if let Some((ti, ci)) = self.clip_view_target {
411            if ti == track_idx {
412                let num_clips = self.tracks[track_idx].clips.len();
413                if num_clips == 0 {
414                    self.clip_view_target = None;
415                    self.clip_view_visible = false;
416                } else if ci >= num_clips {
417                    // Target was past the end — point to the last clip
418                    self.clip_view_target = Some((track_idx, num_clips - 1));
419                    tracing::debug!(
420                        "  clip_view_target fixed: {} -> {}", ci, num_clips - 1
421                    );
422                }
423            }
424        }
425
426        // Return absorption info so caller can sync removed clips to audio
427        if absorbed_count > 0 {
428            Some((snap.track_id, absorbed_count))
429        } else {
430            None
431        }
432    }
433}
434
435// ── Initial Data ──
436
437/// Initial tracks: just the bus tracks. Instruments are added by the user via Space+A.
438pub fn initial_tracks() -> Vec<TrackState> {
439    vec![
440        TrackState::new("snd a", 5, false, TrackKind::SendA, vec![]),
441        TrackState::new("snd b", 6, false, TrackKind::SendB, vec![]),
442        TrackState::new("mstr", 7, false, TrackKind::Master, vec![]),
443    ]
444}