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_jupiter {
35                        match idx {
36                            0 => 1.0 / (phosphor_dsp::jupiter::PATCH_COUNT as f32 - 0.01),
37                            _ => 0.25,
38                        }
39                    } else if is_odyssey {
40                        match idx {
41                            0 => 1.0 / (phosphor_dsp::odyssey::PATCH_COUNT as f32 - 0.01),
42                            6 => 0.34, // 3 filter types
43                            _ => 0.5,
44                        }
45                    } else if is_juno {
46                        match idx {
47                            0 => 1.0 / (phosphor_dsp::juno::PATCH_COUNT as f32 - 0.01),
48                            12 => 0.25, // 4 chorus modes
49                            _ => 0.5,   // on/off switches
50                        }
51                    } else {
52                        match track.instrument_type {
53                            Some(InstrumentType::DrumRack) => 0.1, // 10 kits
54                            _ => 0.25,
55                        }
56                    };
57                    if delta > 0.0 { step } else { -step }
58                } else {
59                    delta
60                };
61                let new_val = if is_dx7 && is_discrete {
62                    // The DX7 steps its two selectors by index rather than by
63                    // adding a fraction of the knob's travel: 32 voices and 8
64                    // cartridges are coarse enough that an accumulated rounding
65                    // error lands on the wrong side of a step boundary, which
66                    // reads as a keypress that did nothing.
67                    phosphor_dsp::dx7::step_discrete(idx, track.synth_params[idx], delta > 0.0)
68                } else {
69                    (track.synth_params[idx] + actual_delta).clamp(0.0, 1.0)
70                };
71                track.synth_params[idx] = new_val;
72
73                // When patch selector changes, sync all params from preset
74                if idx == 0 {
75                    let new_params = match track.instrument_type {
76                        Some(InstrumentType::Jupiter8) => {
77                            Some(phosphor_dsp::jupiter::Jupiter8Synth::params_for_patch(new_val))
78                        }
79                        Some(InstrumentType::Odyssey) => {
80                            Some(phosphor_dsp::odyssey::OdysseySynth::params_for_patch(new_val))
81                        }
82                        Some(InstrumentType::Juno60) => {
83                            Some(phosphor_dsp::juno::Juno60Synth::params_for_patch(new_val))
84                        }
85                        _ => None,
86                    };
87                    if let Some(preset_params) = new_params {
88                        for (i, &v) in preset_params.iter().enumerate() {
89                            track.synth_params[i] = v;
90                        }
91                    }
92                }
93
94                if let Some(mixer_id) = track.mixer_id {
95                    return Some((mixer_id, idx, new_val));
96                }
97            }
98        }
99        None
100    }
101
102
103    /// Show controls for the currently selected track and route MIDI to it.
104    /// For instrument tracks: opens clip view with Synth tab, activates MIDI input.
105    /// For bus tracks: no clip view, deactivates MIDI.
106    pub fn show_current_track_controls(&mut self) {
107        // Deactivate MIDI on ALL tracks first
108        for track in &self.tracks {
109            if let Some(ref h) = track.handle {
110                h.config.midi_active.store(false, std::sync::atomic::Ordering::Relaxed);
111            }
112        }
113
114        if let Some(track) = self.tracks.get(self.track_cursor) {
115            if track.is_live() {
116                if let Some(ref h) = track.handle {
117                    h.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
118                }
119                self.clip_view_visible = true;
120
121                // Use the currently selected clip element, or default to clip 0
122                let clip_idx = match self.track_element {
123                    super::TrackElement::Clip(i) if i < track.clips.len() => i,
124                    _ => 0,
125                };
126                self.clip_view_target = Some((self.track_cursor, clip_idx));
127
128                // If track has recorded clips, show piano roll. Otherwise show synth.
129                if !track.clips.is_empty() {
130                    self.clip_view.clip_tab = ClipTab::PianoRoll;
131                    self.clip_view.focus = ClipViewFocus::PianoRoll;
132                    self.clip_view.piano_roll.focus = PianoRollFocus::Navigation;
133                    self.clip_view.piano_roll.column = 0;
134                } else {
135                    self.clip_view.fx_panel_tab = FxPanelTab::Synth;
136                    self.clip_view.focus = ClipViewFocus::FxPanel;
137                    self.clip_view.synth_param_cursor = 0;
138                }
139            } else {
140                // Bus track — hide clip view
141                self.clip_view_visible = false;
142                self.clip_view_target = None;
143            }
144        }
145    }
146
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use phosphor_dsp::dx7;
153
154    /// A nav state whose selected track is a DX7 at its default parameters.
155    fn dx7_track() -> NavState {
156        let mut nav = NavState::new(super::super::initial_tracks());
157        let mut track = TrackState::new("dx7", 0, true, TrackKind::Instrument, vec![]);
158        track.instrument_type = Some(InstrumentType::DX7);
159        track.synth_params = dx7::PARAM_DEFAULTS.to_vec();
160        nav.tracks.insert(0, track);
161        nav.track_cursor = 0;
162        nav
163    }
164
165    fn selected(nav: &NavState) -> (usize, usize) {
166        let p = &nav.tracks[0].synth_params;
167        (dx7::bank_index(p[dx7::P_BANK]), dx7::patch_index(p[dx7::P_PATCH]))
168    }
169
170    #[test]
171    fn dx7_selectors_move_one_step_per_keypress() {
172        // Both DX7 selectors are discrete: a keypress is one voice or one
173        // cartridge, not a fraction of the knob's travel. The patch knob alone
174        // would otherwise take 256 presses to cross the factory set.
175        let mut nav = dx7_track();
176        nav.clip_view.synth_param_cursor = dx7::P_PATCH;
177        let (bank, patch) = selected(&nav);
178        for step in 1..=5 {
179            nav.adjust_synth_param(0.05);
180            assert_eq!(selected(&nav), (bank, patch + step), "patch knob step {step}");
181        }
182        for step in (0..5).rev() {
183            nav.adjust_synth_param(-0.05);
184            assert_eq!(selected(&nav), (bank, patch + step), "patch knob back to {step}");
185        }
186
187        nav.clip_view.synth_param_cursor = dx7::P_BANK;
188        for step in 1..dx7::BANK_COUNT {
189            nav.adjust_synth_param(0.05);
190            assert_eq!(selected(&nav), (step, patch), "bank knob step {step}");
191        }
192        // ...and neither runs off its end.
193        for _ in 0..4 {
194            nav.adjust_synth_param(0.05);
195        }
196        assert_eq!(selected(&nav), (dx7::BANK_COUNT - 1, patch));
197    }
198
199    #[test]
200    fn the_dx7_bank_knob_is_the_last_parameter() {
201        // Sessions store `synth_params` positionally, so the bank selector was
202        // appended rather than filed next to the patch selector: inserting it
203        // would load every saved value of every existing session one slot out.
204        let nav = dx7_track();
205        assert_eq!(nav.tracks[0].synth_params.len(), dx7::PARAM_COUNT);
206        assert_eq!(dx7::P_BANK, dx7::PARAM_COUNT - 1);
207        assert_eq!(dx7::PARAM_NAMES[dx7::P_GAIN], "gain", "index 0-7 must not move");
208    }
209}