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