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