Skip to main content

phosphor_app/state/
params.rs

1//! NavState methods: params.
2
3use super::*;
4
5impl NavState {
6
7    /// Adjust the currently selected synth parameter by delta.
8    /// Returns the (mixer_id, param_index, new_value) if changed, for sending to audio.
9    pub fn adjust_synth_param(&mut self, delta: f32) -> Option<(usize, usize, f32)> {
10        let idx = self.clip_view.synth_param_cursor;
11        if let Some(track) = self.tracks.get_mut(self.track_cursor) {
12            if idx < track.synth_params.len() {
13                // A selector steps by *index* rather than by adding a fraction
14                // of the knob's travel: 256 voices, or 56 patches and a
15                // three-position range switch, or fifteen kits, are coarse
16                // enough that an accumulated rounding error lands on the wrong
17                // side of a step boundary, which reads as a keypress that did
18                // nothing.
19                //
20                // Which controls those are is the instrument's own answer —
21                // see `crate::discrete`, which is also what the session format
22                // stores them through, so the two cannot drift apart.
23                let instrument = track.instrument_type?;
24                let new_val = if crate::discrete::is_discrete(instrument, idx) {
25                    crate::discrete::step(instrument, idx, track.synth_params[idx], delta > 0.0)
26                } else {
27                    (track.synth_params[idx] + delta).clamp(0.0, 1.0)
28                };
29                track.synth_params[idx] = new_val;
30
31                // When patch selector changes, sync all params from preset.
32                // The banks no longer agree on how many parameters an
33                // instrument has, so this collects rather than matching on a
34                // fixed-size array, and writes through a zip so a track
35                // carrying a shorter block than its instrument now has cannot
36                // index off the end of itself.
37                if idx == 0 {
38                    let new_params: Option<Vec<f32>> = match track.instrument_type {
39                        Some(InstrumentType::Synth | InstrumentType::Sampler) => {
40                            Some(phosphor_dsp::synth::PhosphorSynth::params_for_patch(new_val).to_vec())
41                        }
42                        Some(InstrumentType::Jupiter8) => {
43                            Some(phosphor_dsp::jupiter::Jupiter8Synth::params_for_patch(new_val).to_vec())
44                        }
45                        Some(InstrumentType::Odyssey) => {
46                            Some(phosphor_dsp::odyssey::OdysseySynth::params_for_patch(new_val).to_vec())
47                        }
48                        Some(InstrumentType::Juno60) => {
49                            Some(phosphor_dsp::juno::Juno60Synth::params_for_patch(new_val).to_vec())
50                        }
51                        Some(InstrumentType::Rhodes) => {
52                            Some(phosphor_dsp::rhodes::RhodesPiano::params_for_patch(new_val).to_vec())
53                        }
54                        _ => None,
55                    };
56                    if let Some(preset_params) = new_params {
57                        for (slot, v) in track.synth_params.iter_mut().zip(preset_params) {
58                            *slot = v;
59                        }
60                    }
61                }
62
63                if let Some(mixer_id) = track.mixer_id {
64                    return Some((mixer_id, idx, new_val));
65                }
66            }
67        }
68        None
69    }
70
71
72    /// Show controls for the currently selected track and route MIDI to it.
73    /// For instrument tracks: opens clip view with Synth tab, activates MIDI input.
74    /// For bus tracks: no clip view, deactivates MIDI.
75    pub fn show_current_track_controls(&mut self) {
76        // Deactivate MIDI on ALL tracks first
77        for track in &self.tracks {
78            if let Some(ref h) = track.handle {
79                h.config.midi_active.store(false, std::sync::atomic::Ordering::Relaxed);
80            }
81        }
82
83        if let Some(track) = self.tracks.get(self.track_cursor) {
84            if track.is_live() {
85                if let Some(ref h) = track.handle {
86                    h.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
87                }
88                self.clip_view_visible = true;
89
90                // Use the currently selected clip element, or default to clip 0
91                let clip_idx = match self.track_element {
92                    super::TrackElement::Clip(i) if i < track.clips.len() => i,
93                    _ => 0,
94                };
95                self.clip_view_target = Some((self.track_cursor, clip_idx));
96
97                // If track has recorded clips, show piano roll. Otherwise show synth.
98                if !track.clips.is_empty() {
99                    self.clip_view.clip_tab = ClipTab::PianoRoll;
100                    self.clip_view.focus = ClipViewFocus::PianoRoll;
101                    self.clip_view.piano_roll.focus = PianoRollFocus::Navigation;
102                    self.clip_view.piano_roll.column = 0;
103                } else {
104                    self.clip_view.fx_panel_tab = FxPanelTab::Synth;
105                    self.clip_view.focus = ClipViewFocus::FxPanel;
106                    self.clip_view.synth_param_cursor = 0;
107                }
108            } else {
109                // Bus track — hide clip view
110                self.clip_view_visible = false;
111                self.clip_view_target = None;
112            }
113        }
114    }
115
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use phosphor_dsp::{drum_rack, dx7, juno, jupiter, rhodes};
122
123    /// A nav state whose selected track is a DX7 at its default parameters.
124    fn dx7_track() -> NavState {
125        let mut nav = NavState::new(super::super::initial_tracks());
126        let mut track = TrackState::new("dx7", 0, true, TrackKind::Instrument, vec![]);
127        track.instrument_type = Some(InstrumentType::DX7);
128        track.synth_params = dx7::PARAM_DEFAULTS.to_vec();
129        nav.tracks.insert(0, track);
130        nav.track_cursor = 0;
131        nav
132    }
133
134    fn selected(nav: &NavState) -> (usize, usize) {
135        let p = &nav.tracks[0].synth_params;
136        (dx7::bank_index(p[dx7::P_BANK]), dx7::patch_index(p[dx7::P_PATCH]))
137    }
138
139    #[test]
140    fn dx7_selectors_move_one_step_per_keypress() {
141        // Both DX7 selectors are discrete: a keypress is one voice or one
142        // cartridge, not a fraction of the knob's travel. The patch knob alone
143        // would otherwise take 256 presses to cross the factory set.
144        let mut nav = dx7_track();
145        nav.clip_view.synth_param_cursor = dx7::P_PATCH;
146        let (bank, patch) = selected(&nav);
147        for step in 1..=5 {
148            nav.adjust_synth_param(0.05);
149            assert_eq!(selected(&nav), (bank, patch + step), "patch knob step {step}");
150        }
151        for step in (0..5).rev() {
152            nav.adjust_synth_param(-0.05);
153            assert_eq!(selected(&nav), (bank, patch + step), "patch knob back to {step}");
154        }
155
156        nav.clip_view.synth_param_cursor = dx7::P_BANK;
157        for step in 1..dx7::BANK_COUNT {
158            nav.adjust_synth_param(0.05);
159            assert_eq!(selected(&nav), (step, patch), "bank knob step {step}");
160        }
161        // ...and neither runs off its end.
162        for _ in 0..4 {
163            nav.adjust_synth_param(0.05);
164        }
165        assert_eq!(selected(&nav), (dx7::BANK_COUNT - 1, patch));
166    }
167
168    /// A nav state whose selected track is a Juno-60 at its default panel.
169    fn juno_track() -> NavState {
170        let mut nav = NavState::new(super::super::initial_tracks());
171        let mut track = TrackState::new("juno", 0, true, TrackKind::Instrument, vec![]);
172        track.instrument_type = Some(InstrumentType::Juno60);
173        track.synth_params = juno::PARAM_DEFAULTS.to_vec();
174        nav.tracks.insert(0, track);
175        nav.track_cursor = 0;
176        nav
177    }
178
179    fn juno_patch(nav: &NavState) -> usize {
180        juno::patch_index(nav.tracks[0].synth_params[juno::P_PATCH])
181    }
182
183    #[test]
184    fn juno_selectors_move_one_step_per_keypress() {
185        // 56 factory patches: a keypress is one patch, not a fraction of the
186        // knob's travel, and the whole bank has to be reachable from either
187        // end. The three-position PWM switch is here too, because a switch
188        // that gained a position is the one most likely to be stepped by a
189        // stale fraction.
190        let mut nav = juno_track();
191        nav.clip_view.synth_param_cursor = juno::P_PATCH;
192        for step in 1..juno::PATCH_COUNT {
193            nav.adjust_synth_param(0.05);
194            assert_eq!(juno_patch(&nav), step, "patch knob step {step}");
195        }
196        nav.adjust_synth_param(0.05);
197        assert_eq!(juno_patch(&nav), juno::PATCH_COUNT - 1, "patch knob ran off the top");
198        for step in (0..juno::PATCH_COUNT - 1).rev() {
199            nav.adjust_synth_param(-0.05);
200            assert_eq!(juno_patch(&nav), step, "patch knob back to {step}");
201        }
202
203        // Selecting a patch loads its panel: 78 SYNTHESIZER DRUM is the one
204        // with the filter at self-oscillation and no oscillator at all.
205        for _ in 0..juno::PATCH_COUNT {
206            nav.adjust_synth_param(0.05);
207        }
208        let panel = &nav.tracks[0].synth_params;
209        assert_eq!(juno_patch(&nav), juno::PATCH_COUNT - 1);
210        assert!((panel[juno::P_RESO] - 1.0).abs() < 1e-6, "res {}", panel[juno::P_RESO]);
211
212        // A fresh panel, because the switch has to start where 11 STRINGS 1
213        // leaves it rather than where the last patch of the sweep did.
214        let mut nav = juno_track();
215        nav.clip_view.synth_param_cursor = juno::P_PWM_MODE;
216        let label = |nav: &NavState| {
217            juno::discrete_label(juno::P_PWM_MODE, nav.tracks[0].synth_params[juno::P_PWM_MODE])
218        };
219        assert_eq!(label(&nav), Some("LFO"));
220        let mut seen = Vec::new();
221        for _ in 0..3 {
222            nav.adjust_synth_param(0.05);
223            seen.push(label(&nav));
224        }
225        assert_eq!(seen, [Some("MAN"), Some("ENV"), Some("ENV")]);
226    }
227
228    /// A nav state whose selected track is a Rhodes at its default panel.
229    fn rhodes_track() -> NavState {
230        let mut nav = NavState::new(super::super::initial_tracks());
231        let mut track = TrackState::new("rhode", 0, true, TrackKind::Instrument, vec![]);
232        track.instrument_type = Some(InstrumentType::Rhodes);
233        track.synth_params = rhodes::PARAM_DEFAULTS.to_vec();
234        nav.tracks.insert(0, track);
235        nav.track_cursor = 0;
236        nav
237    }
238
239    #[test]
240    fn the_rhodes_patch_knob_moves_one_piano_per_keypress() {
241        // Twenty-six patches, stepped by index, and selecting one loads its
242        // panel. The Rhodes' panel is entirely continuous apart from this
243        // knob, so it is the only control here that can stall on a boundary.
244        let mut nav = rhodes_track();
245        nav.clip_view.synth_param_cursor = rhodes::P_PATCH;
246        let patch = |nav: &NavState| {
247            rhodes::patch_index(nav.tracks[0].synth_params[rhodes::P_PATCH])
248        };
249        assert_eq!(rhodes::PATCH_NAMES[patch(&nav)], "MK1 Stage");
250        for step in 1..rhodes::PATCH_COUNT {
251            nav.adjust_synth_param(0.05);
252            assert_eq!(patch(&nav), step, "patch knob step {step}");
253        }
254        nav.adjust_synth_param(0.05);
255        assert_eq!(patch(&nav), rhodes::PATCH_COUNT - 1, "patch knob ran off the top");
256        // ...and the panel that arrived with the last patch is that patch's.
257        let panel = &nav.tracks[0].synth_params;
258        let want = rhodes::RhodesPiano::params_for_patch(panel[rhodes::P_PATCH]);
259        for i in 1..rhodes::PARAM_COUNT {
260            assert!(
261                (panel[i] - want[i]).abs() < 1e-6,
262                "{} came back as {} where the patch says {}",
263                rhodes::PARAM_NAMES[i], panel[i], want[i]
264            );
265        }
266        for step in (0..rhodes::PATCH_COUNT - 1).rev() {
267            nav.adjust_synth_param(-0.05);
268            assert_eq!(patch(&nav), step, "patch knob back to {step}");
269        }
270
271        // Every other control is a fader, and moving one moves only it.
272        let mut nav = rhodes_track();
273        nav.clip_view.synth_param_cursor = rhodes::P_VOICING;
274        let before = nav.tracks[0].synth_params.clone();
275        nav.adjust_synth_param(0.05);
276        let after = &nav.tracks[0].synth_params;
277        assert!((after[rhodes::P_VOICING] - (before[rhodes::P_VOICING] + 0.05)).abs() < 1e-6);
278        for i in 0..after.len() {
279            if i != rhodes::P_VOICING {
280                assert_eq!(before[i], after[i], "{} moved with the voicing", rhodes::PARAM_NAMES[i]);
281            }
282        }
283    }
284
285    /// A nav state whose selected track is a Jupiter-8 at its default panel.
286    fn jupiter_track() -> NavState {
287        let mut nav = NavState::new(super::super::initial_tracks());
288        let mut track = TrackState::new("jupiter", 0, true, TrackKind::Instrument, vec![]);
289        track.instrument_type = Some(InstrumentType::Jupiter8);
290        track.synth_params = jupiter::PARAM_DEFAULTS.to_vec();
291        nav.tracks.insert(0, track);
292        nav.track_cursor = 0;
293        nav
294    }
295
296    #[test]
297    fn jupiter_selectors_move_one_step_per_keypress() {
298        // 64 patches and seven switches. The patch knob used to step by
299        // 1/(n - 0.01) of the travel, which is a fraction that does not
300        // divide the bank: the accumulated error lands on the wrong side of a
301        // boundary and the keypress reads as having done nothing.
302        let mut nav = jupiter_track();
303        nav.clip_view.synth_param_cursor = jupiter::P_PATCH;
304        let patch = |nav: &NavState| {
305            jupiter::patch_index(nav.tracks[0].synth_params[jupiter::P_PATCH])
306        };
307        for step in 1..jupiter::PATCH_COUNT {
308            nav.adjust_synth_param(0.05);
309            assert_eq!(patch(&nav), step, "patch knob step {step}");
310        }
311        nav.adjust_synth_param(0.05);
312        assert_eq!(patch(&nav), jupiter::PATCH_COUNT - 1, "patch knob ran off the top");
313        for step in (0..jupiter::PATCH_COUNT - 1).rev() {
314            nav.adjust_synth_param(-0.05);
315            assert_eq!(patch(&nav), step, "patch knob back to {step}");
316        }
317
318        // A fresh panel, because the waveform switch has to start where patch
319        // 0 leaves it rather than where the last patch of the sweep did.
320        let mut nav = jupiter_track();
321        nav.clip_view.synth_param_cursor = jupiter::P_VCO2_WAVE;
322        let label = |nav: &NavState| {
323            jupiter::discrete_label(
324                jupiter::P_VCO2_WAVE,
325                nav.tracks[0].synth_params[jupiter::P_VCO2_WAVE],
326            )
327        };
328        assert_eq!(label(&nav), Some("SAW"));
329        let mut seen = Vec::new();
330        for _ in 0..3 {
331            nav.adjust_synth_param(0.05);
332            seen.push(label(&nav));
333        }
334        assert_eq!(seen, [Some("PLS"), Some("NOISE"), Some("NOISE")]);
335    }
336
337    /// A nav state whose selected track is a drum rack at its default panel.
338    fn drum_track() -> NavState {
339        let mut nav = NavState::new(super::super::initial_tracks());
340        let mut track = TrackState::new("drums", 0, true, TrackKind::Instrument, vec![]);
341        track.instrument_type = Some(InstrumentType::DrumRack);
342        track.synth_params = drum_rack::PARAM_DEFAULTS.to_vec();
343        nav.tracks.insert(0, track);
344        nav.track_cursor = 0;
345        nav
346    }
347
348    #[test]
349    fn the_drum_kit_selector_moves_one_kit_per_keypress() {
350        // Fifteen kits, stepped by index. This used to add a fraction of the
351        // knob's travel per press, which does not divide the selector evenly:
352        // the accumulated error lands on the wrong side of a boundary and the
353        // keypress reads as having done nothing. The list is driven off
354        // `KIT_LABELS` so that adding a kit does not need this test edited.
355        let mut nav = drum_track();
356        nav.clip_view.synth_param_cursor = drum_rack::P_KIT;
357        let kit = |nav: &NavState| {
358            drum_rack::discrete_label(drum_rack::P_KIT, nav.tracks[0].synth_params[drum_rack::P_KIT])
359        };
360        let last = *drum_rack::KIT_LABELS.last().unwrap();
361        assert_eq!(kit(&nav), Some("808"));
362        for label in drum_rack::KIT_LABELS.iter().skip(1) {
363            nav.adjust_synth_param(0.05);
364            assert_eq!(kit(&nav), Some(*label));
365        }
366        nav.adjust_synth_param(0.05);
367        assert_eq!(kit(&nav), Some(last), "the kit knob ran off the top");
368        for label in drum_rack::KIT_LABELS.iter().rev().skip(1) {
369            nav.adjust_synth_param(-0.05);
370            assert_eq!(kit(&nav), Some(*label));
371        }
372
373        // The rest of the panel is continuous, and moving one control moves
374        // only that control.
375        nav.clip_view.synth_param_cursor = drum_rack::P_BD_DECAY;
376        let before = nav.tracks[0].synth_params.clone();
377        nav.adjust_synth_param(-0.05);
378        let after = &nav.tracks[0].synth_params;
379        assert!((after[drum_rack::P_BD_DECAY] - 0.45).abs() < 1e-6);
380        for i in 0..after.len() {
381            if i != drum_rack::P_BD_DECAY {
382                assert_eq!(before[i], after[i], "{} moved with the kick's decay", drum_rack::PARAM_NAMES[i]);
383            }
384        }
385    }
386
387    #[test]
388    fn the_dx7_bank_knob_is_the_last_parameter() {
389        // Sessions store `synth_params` positionally, so the bank selector was
390        // appended rather than filed next to the patch selector: inserting it
391        // would load every saved value of every existing session one slot out.
392        let nav = dx7_track();
393        assert_eq!(nav.tracks[0].synth_params.len(), dx7::PARAM_COUNT);
394        assert_eq!(dx7::P_BANK, dx7::PARAM_COUNT - 1);
395        assert_eq!(dx7::PARAM_NAMES[dx7::P_GAIN], "gain", "index 0-7 must not move");
396    }
397}