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 the preset selector changes, sync all params from the
32                // preset. Index 0 for every instrument — except the Prophet-6,
33                // whose preset is two selectors, a bank and a program, so
34                // moving either one has to reload the panel.
35                let is_program_selector = idx == 0
36                    || (instrument == InstrumentType::Prophet6
37                        && idx == phosphor_dsp::prophet6::P_BANK);
38                // The banks no longer agree on how many parameters an
39                // instrument has, so this collects rather than matching on a
40                // fixed-size array, and writes through a zip so a track
41                // carrying a shorter block than its instrument now has cannot
42                // index off the end of itself.
43                if is_program_selector {
44                    let new_params: Option<Vec<f32>> = match track.instrument_type {
45                        Some(InstrumentType::Synth | InstrumentType::Sampler) => {
46                            Some(phosphor_dsp::synth::PhosphorSynth::params_for_patch(new_val).to_vec())
47                        }
48                        Some(InstrumentType::Jupiter8) => {
49                            Some(phosphor_dsp::jupiter::Jupiter8Synth::params_for_patch(new_val).to_vec())
50                        }
51                        Some(InstrumentType::Odyssey) => {
52                            Some(phosphor_dsp::odyssey::OdysseySynth::params_for_patch(new_val).to_vec())
53                        }
54                        Some(InstrumentType::Juno60) => {
55                            Some(phosphor_dsp::juno::Juno60Synth::params_for_patch(new_val).to_vec())
56                        }
57                        Some(InstrumentType::Rhodes) => {
58                            Some(phosphor_dsp::rhodes::RhodesPiano::params_for_patch(new_val).to_vec())
59                        }
60                        Some(InstrumentType::LittlePhatty) => {
61                            Some(phosphor_dsp::phatty::LittlePhatty::params_for_patch(new_val).to_vec())
62                        }
63                        Some(InstrumentType::Prophet6) => Some(
64                            phosphor_dsp::prophet6::params_for_program(
65                                track.synth_params[phosphor_dsp::prophet6::P_BANK],
66                                track.synth_params[phosphor_dsp::prophet6::P_PROGRAM],
67                            )
68                            .to_vec(),
69                        ),
70                        _ => None,
71                    };
72                    if let Some(preset_params) = new_params {
73                        for (slot, v) in track.synth_params.iter_mut().zip(preset_params) {
74                            *slot = v;
75                        }
76                    }
77                }
78
79                if let Some(mixer_id) = track.mixer_id {
80                    return Some((mixer_id, idx, new_val));
81                }
82            }
83        }
84        None
85    }
86
87
88    /// Show controls for the currently selected track and route MIDI to it.
89    /// For instrument tracks: opens clip view with Synth tab, activates MIDI input.
90    /// For bus tracks: no clip view, deactivates MIDI.
91    pub fn show_current_track_controls(&mut self) {
92        // Deactivate MIDI on ALL tracks first
93        for track in &self.tracks {
94            if let Some(ref h) = track.handle {
95                h.config.midi_active.store(false, std::sync::atomic::Ordering::Relaxed);
96            }
97        }
98
99        if let Some(track) = self.tracks.get(self.track_cursor) {
100            if track.is_live() {
101                if let Some(ref h) = track.handle {
102                    h.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
103                }
104                self.clip_view_visible = true;
105
106                // Use the currently selected clip element, or default to clip 0
107                let clip_idx = match self.track_element {
108                    super::TrackElement::Clip(i) if i < track.clips.len() => i,
109                    _ => 0,
110                };
111                self.clip_view_target = Some((self.track_cursor, clip_idx));
112
113                // A sequencer track opens on its grid: the pattern is the
114                // thing being worked on, and its clips — if it has any — are
115                // bounces of it rather than what is playing.
116                if track.sequencer.is_some() {
117                    self.clip_view.clip_tab = ClipTab::Sequencer;
118                    self.clip_view.focus = ClipViewFocus::PianoRoll;
119                    self.clip_view.sequencer.focus_band(SeqBand::Grid);
120                    // And the keyboard goes with it. The grid displaying in
121                    // one pane while the keys land in another is a sequencer
122                    // you can see but not touch — the tab and the inner focus
123                    // above are only two thirds of "opened on its grid".
124                    self.focused_pane = Pane::ClipView;
125                } else if !track.clips.is_empty() {
126                    self.clip_view.clip_tab = ClipTab::PianoRoll;
127                    self.clip_view.focus = ClipViewFocus::PianoRoll;
128                    self.clip_view.piano_roll.focus = PianoRollFocus::Navigation;
129                    self.clip_view.piano_roll.column = 0;
130                } else {
131                    self.clip_view.fx_panel_tab = FxPanelTab::Synth;
132                    self.clip_view.focus = ClipViewFocus::FxPanel;
133                    self.clip_view.synth_param_cursor = 0;
134                }
135            } else {
136                // Bus track — hide clip view
137                self.clip_view_visible = false;
138                self.clip_view_target = None;
139            }
140        }
141    }
142
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use phosphor_dsp::{drum_rack, dx7, juno, jupiter, rhodes};
149
150    /// A nav state whose selected track is a DX7 at its default parameters.
151    fn dx7_track() -> NavState {
152        let mut nav = NavState::new(super::super::initial_tracks());
153        let mut track = TrackState::new("dx7", 0, true, TrackKind::Instrument, vec![]);
154        track.instrument_type = Some(InstrumentType::DX7);
155        track.synth_params = dx7::PARAM_DEFAULTS.to_vec();
156        nav.tracks.insert(0, track);
157        nav.track_cursor = 0;
158        nav
159    }
160
161    fn selected(nav: &NavState) -> (usize, usize) {
162        let p = &nav.tracks[0].synth_params;
163        (dx7::bank_index(p[dx7::P_BANK]), dx7::patch_index(p[dx7::P_PATCH]))
164    }
165
166    #[test]
167    fn dx7_selectors_move_one_step_per_keypress() {
168        // Both DX7 selectors are discrete: a keypress is one voice or one
169        // cartridge, not a fraction of the knob's travel. The patch knob alone
170        // would otherwise take 256 presses to cross the factory set.
171        let mut nav = dx7_track();
172        nav.clip_view.synth_param_cursor = dx7::P_PATCH;
173        let (bank, patch) = selected(&nav);
174        for step in 1..=5 {
175            nav.adjust_synth_param(0.05);
176            assert_eq!(selected(&nav), (bank, patch + step), "patch knob step {step}");
177        }
178        for step in (0..5).rev() {
179            nav.adjust_synth_param(-0.05);
180            assert_eq!(selected(&nav), (bank, patch + step), "patch knob back to {step}");
181        }
182
183        nav.clip_view.synth_param_cursor = dx7::P_BANK;
184        for step in 1..dx7::BANK_COUNT {
185            nav.adjust_synth_param(0.05);
186            assert_eq!(selected(&nav), (step, patch), "bank knob step {step}");
187        }
188        // ...and neither runs off its end.
189        for _ in 0..4 {
190            nav.adjust_synth_param(0.05);
191        }
192        assert_eq!(selected(&nav), (dx7::BANK_COUNT - 1, patch));
193    }
194
195    /// A nav state whose selected track is a Juno-60 at its default panel.
196    fn juno_track() -> NavState {
197        let mut nav = NavState::new(super::super::initial_tracks());
198        let mut track = TrackState::new("juno", 0, true, TrackKind::Instrument, vec![]);
199        track.instrument_type = Some(InstrumentType::Juno60);
200        track.synth_params = juno::PARAM_DEFAULTS.to_vec();
201        nav.tracks.insert(0, track);
202        nav.track_cursor = 0;
203        nav
204    }
205
206    fn juno_patch(nav: &NavState) -> usize {
207        juno::patch_index(nav.tracks[0].synth_params[juno::P_PATCH])
208    }
209
210    #[test]
211    fn juno_selectors_move_one_step_per_keypress() {
212        // 56 factory patches: a keypress is one patch, not a fraction of the
213        // knob's travel, and the whole bank has to be reachable from either
214        // end. The three-position PWM switch is here too, because a switch
215        // that gained a position is the one most likely to be stepped by a
216        // stale fraction.
217        let mut nav = juno_track();
218        nav.clip_view.synth_param_cursor = juno::P_PATCH;
219        for step in 1..juno::PATCH_COUNT {
220            nav.adjust_synth_param(0.05);
221            assert_eq!(juno_patch(&nav), step, "patch knob step {step}");
222        }
223        nav.adjust_synth_param(0.05);
224        assert_eq!(juno_patch(&nav), juno::PATCH_COUNT - 1, "patch knob ran off the top");
225        for step in (0..juno::PATCH_COUNT - 1).rev() {
226            nav.adjust_synth_param(-0.05);
227            assert_eq!(juno_patch(&nav), step, "patch knob back to {step}");
228        }
229
230        // Selecting a patch loads its panel: 78 SYNTHESIZER DRUM is the one
231        // with the filter at self-oscillation and no oscillator at all.
232        for _ in 0..juno::PATCH_COUNT {
233            nav.adjust_synth_param(0.05);
234        }
235        let panel = &nav.tracks[0].synth_params;
236        assert_eq!(juno_patch(&nav), juno::PATCH_COUNT - 1);
237        assert!((panel[juno::P_RESO] - 1.0).abs() < 1e-6, "res {}", panel[juno::P_RESO]);
238
239        // A fresh panel, because the switch has to start where 11 STRINGS 1
240        // leaves it rather than where the last patch of the sweep did.
241        let mut nav = juno_track();
242        nav.clip_view.synth_param_cursor = juno::P_PWM_MODE;
243        let label = |nav: &NavState| {
244            juno::discrete_label(juno::P_PWM_MODE, nav.tracks[0].synth_params[juno::P_PWM_MODE])
245        };
246        assert_eq!(label(&nav), Some("LFO"));
247        let mut seen = Vec::new();
248        for _ in 0..3 {
249            nav.adjust_synth_param(0.05);
250            seen.push(label(&nav));
251        }
252        assert_eq!(seen, [Some("MAN"), Some("ENV"), Some("ENV")]);
253    }
254
255    /// A nav state whose selected track is a Rhodes at its default panel.
256    fn rhodes_track() -> NavState {
257        let mut nav = NavState::new(super::super::initial_tracks());
258        let mut track = TrackState::new("rhode", 0, true, TrackKind::Instrument, vec![]);
259        track.instrument_type = Some(InstrumentType::Rhodes);
260        track.synth_params = rhodes::PARAM_DEFAULTS.to_vec();
261        nav.tracks.insert(0, track);
262        nav.track_cursor = 0;
263        nav
264    }
265
266    #[test]
267    fn the_rhodes_patch_knob_moves_one_piano_per_keypress() {
268        // Twenty-six patches, stepped by index, and selecting one loads its
269        // panel. The Rhodes' panel is entirely continuous apart from this
270        // knob, so it is the only control here that can stall on a boundary.
271        let mut nav = rhodes_track();
272        nav.clip_view.synth_param_cursor = rhodes::P_PATCH;
273        let patch = |nav: &NavState| {
274            rhodes::patch_index(nav.tracks[0].synth_params[rhodes::P_PATCH])
275        };
276        assert_eq!(rhodes::PATCH_NAMES[patch(&nav)], "MK1 Stage");
277        for step in 1..rhodes::PATCH_COUNT {
278            nav.adjust_synth_param(0.05);
279            assert_eq!(patch(&nav), step, "patch knob step {step}");
280        }
281        nav.adjust_synth_param(0.05);
282        assert_eq!(patch(&nav), rhodes::PATCH_COUNT - 1, "patch knob ran off the top");
283        // ...and the panel that arrived with the last patch is that patch's.
284        let panel = &nav.tracks[0].synth_params;
285        let want = rhodes::RhodesPiano::params_for_patch(panel[rhodes::P_PATCH]);
286        for i in 1..rhodes::PARAM_COUNT {
287            assert!(
288                (panel[i] - want[i]).abs() < 1e-6,
289                "{} came back as {} where the patch says {}",
290                rhodes::PARAM_NAMES[i], panel[i], want[i]
291            );
292        }
293        for step in (0..rhodes::PATCH_COUNT - 1).rev() {
294            nav.adjust_synth_param(-0.05);
295            assert_eq!(patch(&nav), step, "patch knob back to {step}");
296        }
297
298        // Every other control is a fader, and moving one moves only it.
299        let mut nav = rhodes_track();
300        nav.clip_view.synth_param_cursor = rhodes::P_VOICING;
301        let before = nav.tracks[0].synth_params.clone();
302        nav.adjust_synth_param(0.05);
303        let after = &nav.tracks[0].synth_params;
304        assert!((after[rhodes::P_VOICING] - (before[rhodes::P_VOICING] + 0.05)).abs() < 1e-6);
305        for i in 0..after.len() {
306            if i != rhodes::P_VOICING {
307                assert_eq!(before[i], after[i], "{} moved with the voicing", rhodes::PARAM_NAMES[i]);
308            }
309        }
310    }
311
312    /// A nav state whose selected track is a Jupiter-8 at its default panel.
313    fn jupiter_track() -> NavState {
314        let mut nav = NavState::new(super::super::initial_tracks());
315        let mut track = TrackState::new("jupiter", 0, true, TrackKind::Instrument, vec![]);
316        track.instrument_type = Some(InstrumentType::Jupiter8);
317        track.synth_params = jupiter::PARAM_DEFAULTS.to_vec();
318        nav.tracks.insert(0, track);
319        nav.track_cursor = 0;
320        nav
321    }
322
323    #[test]
324    fn jupiter_selectors_move_one_step_per_keypress() {
325        // 64 patches and seven switches. The patch knob used to step by
326        // 1/(n - 0.01) of the travel, which is a fraction that does not
327        // divide the bank: the accumulated error lands on the wrong side of a
328        // boundary and the keypress reads as having done nothing.
329        let mut nav = jupiter_track();
330        nav.clip_view.synth_param_cursor = jupiter::P_PATCH;
331        let patch = |nav: &NavState| {
332            jupiter::patch_index(nav.tracks[0].synth_params[jupiter::P_PATCH])
333        };
334        for step in 1..jupiter::PATCH_COUNT {
335            nav.adjust_synth_param(0.05);
336            assert_eq!(patch(&nav), step, "patch knob step {step}");
337        }
338        nav.adjust_synth_param(0.05);
339        assert_eq!(patch(&nav), jupiter::PATCH_COUNT - 1, "patch knob ran off the top");
340        for step in (0..jupiter::PATCH_COUNT - 1).rev() {
341            nav.adjust_synth_param(-0.05);
342            assert_eq!(patch(&nav), step, "patch knob back to {step}");
343        }
344
345        // A fresh panel, because the waveform switch has to start where patch
346        // 0 leaves it rather than where the last patch of the sweep did.
347        let mut nav = jupiter_track();
348        nav.clip_view.synth_param_cursor = jupiter::P_VCO2_WAVE;
349        let label = |nav: &NavState| {
350            jupiter::discrete_label(
351                jupiter::P_VCO2_WAVE,
352                nav.tracks[0].synth_params[jupiter::P_VCO2_WAVE],
353            )
354        };
355        assert_eq!(label(&nav), Some("SAW"));
356        let mut seen = Vec::new();
357        for _ in 0..3 {
358            nav.adjust_synth_param(0.05);
359            seen.push(label(&nav));
360        }
361        assert_eq!(seen, [Some("PLS"), Some("NOISE"), Some("NOISE")]);
362    }
363
364    /// A nav state whose selected track is a Prophet-6 at its default panel.
365    fn prophet6_track() -> NavState {
366        let mut nav = NavState::new(super::super::initial_tracks());
367        let mut track = TrackState::new("p6", 0, true, TrackKind::Instrument, vec![]);
368        track.instrument_type = Some(InstrumentType::Prophet6);
369        track.synth_params = phosphor_dsp::prophet6::param_defaults().to_vec();
370        nav.tracks.insert(0, track);
371        nav.track_cursor = 0;
372        nav
373    }
374
375    /// Both of the Prophet-6's preset selectors reload the panel.
376    ///
377    /// It is the second instrument in the rack whose preset is two controls
378    /// rather than one — the DX7 was the first — and the first whose *panel*
379    /// is loaded from them, so the editor's "index 0 reloads the preset" rule
380    /// is not enough on its own: stepping the bank has to reload as well, or
381    /// four fifths of the factory set is unreachable from the panel.
382    #[test]
383    fn both_prophet_six_selectors_load_the_program() {
384        use phosphor_dsp::prophet6;
385
386        let mut nav = prophet6_track();
387        let panel = |nav: &NavState| nav.tracks[0].synth_params.clone();
388        assert_eq!(panel(&nav), prophet6::params_for_program(0.0, 0.0).to_vec());
389
390        // Step the program knob: the whole panel follows it.
391        nav.clip_view.synth_param_cursor = prophet6::P_PROGRAM;
392        for step in 1..8 {
393            nav.adjust_synth_param(0.05);
394            let expected = prophet6::params_for_program(
395                nav.tracks[0].synth_params[prophet6::P_BANK],
396                nav.tracks[0].synth_params[prophet6::P_PROGRAM],
397            );
398            assert_eq!(panel(&nav), expected.to_vec(), "program knob step {step}");
399            assert_eq!(
400                prophet6::program_index(
401                    nav.tracks[0].synth_params[prophet6::P_BANK],
402                    nav.tracks[0].synth_params[prophet6::P_PROGRAM],
403                ),
404                step
405            );
406        }
407
408        // Step the bank knob: same, a hundred programs further along each time.
409        nav.clip_view.synth_param_cursor = prophet6::P_BANK;
410        for bank in 1..prophet6::BANK_COUNT {
411            nav.adjust_synth_param(0.05);
412            let expected = prophet6::params_for_program(
413                nav.tracks[0].synth_params[prophet6::P_BANK],
414                nav.tracks[0].synth_params[prophet6::P_PROGRAM],
415            );
416            assert_eq!(panel(&nav), expected.to_vec(), "bank knob step {bank}");
417            assert_eq!(
418                prophet6::program_index(
419                    nav.tracks[0].synth_params[prophet6::P_BANK],
420                    nav.tracks[0].synth_params[prophet6::P_PROGRAM],
421                ),
422                bank * prophet6::PROGRAMS_PER_BANK + 7,
423                "the bank knob lost the program knob's position"
424            );
425        }
426
427        // And an ordinary knob does not reload anything.
428        nav.clip_view.synth_param_cursor = prophet6::P_LP_CUTOFF;
429        let before = panel(&nav);
430        nav.adjust_synth_param(0.05);
431        let after = panel(&nav);
432        for (index, (a, b)) in before.iter().zip(&after).enumerate() {
433            if index != prophet6::P_LP_CUTOFF {
434                assert_eq!(a, b, "{} moved with the cutoff", prophet6::PARAM_NAMES[index]);
435            }
436        }
437        assert!(after[prophet6::P_LP_CUTOFF] > before[prophet6::P_LP_CUTOFF]);
438    }
439
440    /// A nav state whose selected track is a drum rack at its default panel.
441    fn drum_track() -> NavState {
442        let mut nav = NavState::new(super::super::initial_tracks());
443        let mut track = TrackState::new("drums", 0, true, TrackKind::Instrument, vec![]);
444        track.instrument_type = Some(InstrumentType::DrumRack);
445        track.synth_params = drum_rack::PARAM_DEFAULTS.to_vec();
446        nav.tracks.insert(0, track);
447        nav.track_cursor = 0;
448        nav
449    }
450
451    #[test]
452    fn the_drum_kit_selector_moves_one_kit_per_keypress() {
453        // Fifteen kits, stepped by index. This used to add a fraction of the
454        // knob's travel per press, which does not divide the selector evenly:
455        // the accumulated error lands on the wrong side of a boundary and the
456        // keypress reads as having done nothing. The list is driven off
457        // `KIT_LABELS` so that adding a kit does not need this test edited.
458        let mut nav = drum_track();
459        nav.clip_view.synth_param_cursor = drum_rack::P_KIT;
460        let kit = |nav: &NavState| {
461            drum_rack::discrete_label(drum_rack::P_KIT, nav.tracks[0].synth_params[drum_rack::P_KIT])
462        };
463        let last = *drum_rack::KIT_LABELS.last().unwrap();
464        assert_eq!(kit(&nav), Some("808"));
465        for label in drum_rack::KIT_LABELS.iter().skip(1) {
466            nav.adjust_synth_param(0.05);
467            assert_eq!(kit(&nav), Some(*label));
468        }
469        nav.adjust_synth_param(0.05);
470        assert_eq!(kit(&nav), Some(last), "the kit knob ran off the top");
471        for label in drum_rack::KIT_LABELS.iter().rev().skip(1) {
472            nav.adjust_synth_param(-0.05);
473            assert_eq!(kit(&nav), Some(*label));
474        }
475
476        // The rest of the panel is continuous, and moving one control moves
477        // only that control.
478        nav.clip_view.synth_param_cursor = drum_rack::P_BD_DECAY;
479        let before = nav.tracks[0].synth_params.clone();
480        nav.adjust_synth_param(-0.05);
481        let after = &nav.tracks[0].synth_params;
482        assert!((after[drum_rack::P_BD_DECAY] - 0.45).abs() < 1e-6);
483        for i in 0..after.len() {
484            if i != drum_rack::P_BD_DECAY {
485                assert_eq!(before[i], after[i], "{} moved with the kick's decay", drum_rack::PARAM_NAMES[i]);
486            }
487        }
488    }
489
490    #[test]
491    fn the_dx7_bank_knob_is_the_last_parameter() {
492        // Sessions store `synth_params` positionally, so the bank selector was
493        // appended rather than filed next to the patch selector: inserting it
494        // would load every saved value of every existing session one slot out.
495        let nav = dx7_track();
496        assert_eq!(nav.tracks[0].synth_params.len(), dx7::PARAM_COUNT);
497        assert_eq!(dx7::P_BANK, dx7::PARAM_COUNT - 1);
498        assert_eq!(dx7::PARAM_NAMES[dx7::P_GAIN], "gain", "index 0-7 must not move");
499    }
500}