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