Skip to main content

phosphor_app/
preset.rs

1//! User presets — one bank file per instrument type.
2//!
3//! A factory patch lives in a `const` table and is reachable from the patch
4//! knob. A user preset is the whole parameter block as the player left it —
5//! including whichever factory patch they started from — written to
6//! `<app dir>/presets/<instrument>.json`, which is `~/.phosphor/presets` on
7//! Unix and `%APPDATA%\phosphor\presets` on Windows. See [`crate::paths`].
8//!
9//! Presets are deliberately *not* appended to the factory bank. The patch
10//! selector stores a normalised fraction, so the index it lands on depends on
11//! how many entries the bank has; adding an entry would move every stored
12//! value in every saved session. A preset bank sits beside the factory table
13//! and moves nothing.
14//!
15//! That reasoning applies to a preset's *own* selectors as well, and this
16//! format got it wrong for a version: a preset is a whole parameter block, and
17//! a block stores the kit, the patch and the cartridge as the same normalised
18//! fraction a session did. The layout fingerprint below does not move when a
19//! bank changes size — it is derived from parameter *names* — so a preset
20//! saved on the 909 reopened on the 707 once the rack went from ten kits to
21//! fifteen, with nothing on screen to say so. Selectors are now stored by
22//! position as well; see [`Preset::discrete`] and [`crate::discrete`].
23//!
24//! Human-readable JSON with atomic writes (tmp + rename), the same shape as
25//! the `.phos` session format.
26
27use std::path::{Path, PathBuf};
28
29use anyhow::Result;
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33use crate::session::{instrument_key, SessionSelector};
34use crate::state::InstrumentType;
35
36// ── Limits ──
37
38/// Most presets one instrument's bank will hold.
39///
40/// A hardware bank is this size and nobody dials 128 sounds by hand, so the
41/// cap is not a limit anyone reaches on purpose. It exists so a held key or a
42/// script cannot grow the file without bound, and so the browser's list has a
43/// known maximum height.
44pub const MAX_PRESETS: usize = 128;
45
46/// Longest preset name, in characters.
47pub const MAX_NAME_LEN: usize = 32;
48
49/// Current preset file format version.
50///
51/// * **1** — every control stored as the normalised `f32` the panel holds,
52///   selectors included.
53/// * **2** — selectors additionally stored by the position they pick, in
54///   [`Preset::discrete`]. A fraction only names a patch as long as the bank
55///   is the size it was when the fraction was written, and two banks have
56///   since changed size. Version 1 presets still load — the fraction is the
57///   only evidence of what the player chose, and it is right whenever the bank
58///   has not moved — but they load with [`LoadedPreset::legacy_selectors`] set
59///   so the player is told to check the patch.
60pub const FORMAT_VERSION: u32 = 2;
61
62/// What a preset without a `version` field was written by.
63const LEGACY_VERSION: u32 = 1;
64
65const fn legacy_version() -> u32 {
66    LEGACY_VERSION
67}
68
69// ── Errors ──
70
71/// Why a preset was refused. Every variant carries the numbers the caller
72/// needs to say what went wrong rather than a pre-formatted sentence.
73#[derive(Debug, Clone, PartialEq, Eq, Error)]
74pub enum PresetError {
75    #[error("saved for the {saved}, not the {wanted}")]
76    WrongInstrument { saved: String, wanted: String },
77
78    #[error("saved with {saved} controls, this instrument has {wanted}")]
79    ParamCountMismatch { saved: usize, wanted: usize },
80
81    #[error("saved against a different panel layout ({saved}, this build is {wanted})")]
82    LayoutMismatch { saved: String, wanted: String },
83
84    #[error("file claims {declared} controls but carries {actual}")]
85    Corrupt { declared: usize, actual: usize },
86
87    #[error("a preset needs a name")]
88    NameEmpty,
89
90    #[error("name is {len} characters, the limit is {max}")]
91    NameTooLong { len: usize, max: usize },
92
93    #[error("this instrument already has {max} presets — delete one first")]
94    BankFull { max: usize },
95}
96
97// ── File format ──
98
99/// One instrument's bank of user presets.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct PresetFile {
102    pub version: u32,
103    /// Instrument key this file belongs to, matching the `.phos` spelling.
104    pub instrument: String,
105    pub presets: Vec<Preset>,
106}
107
108/// A single stored preset.
109///
110/// `instrument`, `param_count`, `layout` and `version` are all redundant with
111/// the file they sit in — deliberately. A preset that gets hand-copied between
112/// files, or survives a parameter layout change, still carries enough to be
113/// refused rather than loaded into the wrong panel, and enough to say which
114/// format wrote it rather than inheriting the claim of the file it landed in.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct Preset {
117    pub name: String,
118    pub instrument: String,
119    /// Fingerprint of the parameter layout this was saved against.
120    pub layout: String,
121    pub param_count: usize,
122    pub params: Vec<f32>,
123    /// Where every selector on this panel was pointing, by position rather
124    /// than by knob fraction. Empty in version 1 presets.
125    ///
126    /// The same shape the session format stores, and the same type: one
127    /// spelling of "this control was on position 3" for both files, so a
128    /// change to how positions are counted cannot move one and leave the
129    /// other behind.
130    #[serde(default)]
131    pub discrete: Vec<SessionSelector>,
132    /// The format version that wrote this preset. Absent in version 1 files.
133    #[serde(default = "legacy_version")]
134    pub version: u32,
135}
136
137/// A preset that passed [`Preset::check`], with its selectors put back.
138///
139/// Not a bare `Vec<f32>`, because two things about the load are worth saying
140/// out loud in the bottom bar and neither is an error: a bank that has shrunk
141/// since the preset was written no longer holds the entry it names, and a
142/// version 1 preset has no positions at all and is only right if nothing has
143/// been added to the bank since.
144#[derive(Debug, Clone, PartialEq)]
145pub struct LoadedPreset {
146    /// The panel to apply, selectors already resolved.
147    pub params: Vec<f32>,
148    /// Selectors that could not be restored exactly, as
149    /// `(parameter, wanted, given)`.
150    pub clamped: Vec<(usize, usize, usize)>,
151    /// Whether this preset predates positional selectors.
152    pub legacy_selectors: bool,
153}
154
155/// What `store` did with a name that may or may not have been in the bank.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum StoreOutcome {
158    Added,
159    /// The name was already taken and its slot was rewritten in place.
160    Replaced,
161}
162
163// ── Parameter layout ──
164
165/// The parameter names for an instrument, in panel order.
166///
167/// The sampler shares the phosphor synth's engine and therefore its panel;
168/// its presets still live in their own file, because the two are separate
169/// instruments as far as the player is concerned.
170pub fn param_names(instrument: InstrumentType) -> &'static [&'static str] {
171    match instrument {
172        InstrumentType::Synth | InstrumentType::Sampler => &phosphor_dsp::synth::PARAM_NAMES,
173        InstrumentType::DrumRack => &phosphor_dsp::drum_rack::PARAM_NAMES,
174        InstrumentType::DX7 => &phosphor_dsp::dx7::PARAM_NAMES,
175        InstrumentType::Jupiter8 => &phosphor_dsp::jupiter::PARAM_NAMES,
176        InstrumentType::Odyssey => &phosphor_dsp::odyssey::PARAM_NAMES,
177        InstrumentType::Juno60 => &phosphor_dsp::juno::PARAM_NAMES,
178        InstrumentType::Rhodes => &phosphor_dsp::rhodes::PARAM_NAMES,
179        InstrumentType::LittlePhatty => &phosphor_dsp::phatty::PARAM_NAMES,
180        InstrumentType::Prophet6 => &phosphor_dsp::prophet6::PARAM_NAMES,
181        // Nothing to save: a sequencer track's presets are its child's, and
182        // its own settings are pattern data in the session.
183        InstrumentType::Sequencer => &[],
184    }
185}
186
187/// A fingerprint of an instrument's parameter layout.
188///
189/// A count alone does not catch a reorder, and this project reorders: the
190/// Juno went from 16 controls to 25, the Jupiter from 16 to 32 and the drum
191/// rack from 6 to 35, and each of those rewrote the order of the slots it
192/// already had into front-panel order. A preset saved against the old order
193/// has the right number of values in the wrong holes, which is exactly the
194/// silent-and-plausible failure the count check exists to prevent.
195///
196/// So the fingerprint is derived from the parameter *names* rather than
197/// declared by hand: a version integer someone has to remember to bump is a
198/// version integer that eventually does not get bumped. Renaming a control
199/// invalidates presets that would otherwise still be correct — the trade is
200/// deliberate, because a refusal is recoverable and a wrong sound is not.
201///
202/// FNV-1a rather than `DefaultHasher`, whose algorithm the standard library
203/// explicitly reserves the right to change; this value goes in a file.
204pub fn layout_fingerprint(instrument: InstrumentType) -> String {
205    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
206    const PRIME: u64 = 0x0000_0100_0000_01b3;
207
208    let mut hash = OFFSET;
209    for name in param_names(instrument) {
210        // The separator keeps ["ab", "c"] from hashing as ["a", "bc"].
211        for byte in name.bytes().chain(std::iter::once(0xff)) {
212            hash ^= u64::from(byte);
213            hash = hash.wrapping_mul(PRIME);
214        }
215    }
216    format!("{hash:016x}")
217}
218
219/// How many parameters this instrument's panel has.
220pub fn param_count(instrument: InstrumentType) -> usize {
221    param_names(instrument).len()
222}
223
224/// The panel an instrument starts on.
225///
226/// One list, because there were two: adding a track wrote one out and
227/// changing a sequencer's child needed the same thing, and two lists of
228/// per-instrument defaults is one list that eventually forgets an instrument.
229///
230/// The Prophet-6 is the only bank whose defaults are decoded from a ROM
231/// rather than written out, so it answers with a call instead of a constant.
232pub fn defaults(instrument: InstrumentType) -> Vec<f32> {
233    match instrument {
234        InstrumentType::Synth | InstrumentType::Sampler => {
235            phosphor_dsp::synth::PARAM_DEFAULTS.to_vec()
236        }
237        InstrumentType::DrumRack => phosphor_dsp::drum_rack::PARAM_DEFAULTS.to_vec(),
238        InstrumentType::DX7 => phosphor_dsp::dx7::PARAM_DEFAULTS.to_vec(),
239        InstrumentType::Jupiter8 => phosphor_dsp::jupiter::PARAM_DEFAULTS.to_vec(),
240        InstrumentType::Odyssey => phosphor_dsp::odyssey::PARAM_DEFAULTS.to_vec(),
241        InstrumentType::Juno60 => phosphor_dsp::juno::PARAM_DEFAULTS.to_vec(),
242        InstrumentType::Rhodes => phosphor_dsp::rhodes::PARAM_DEFAULTS.to_vec(),
243        InstrumentType::LittlePhatty => phosphor_dsp::phatty::PARAM_DEFAULTS.to_vec(),
244        InstrumentType::Prophet6 => phosphor_dsp::prophet6::param_defaults().to_vec(),
245        // The sequencer has no panel of its own: its controls are pattern
246        // data, and the panel on one of its tracks belongs to the child.
247        InstrumentType::Sequencer => Vec::new(),
248    }
249}
250
251// ── Paths ──
252
253/// `~/.phosphor/presets` on Unix, `%APPDATA%\phosphor\presets` on Windows, or
254/// `None` when the environment names no home directory at all.
255///
256/// Same shape as the theme's config path, and now literally the same lookup:
257/// this read `HOME` directly for a long time, which Windows does not set, so
258/// every preset save on Windows resolved to `None` and the call sites read
259/// `None` as "do nothing". See [`crate::paths`] for the rule and its tests.
260///
261/// With no home directory there is still nowhere to put presets, and the
262/// answer is still to do nothing rather than to scatter files relative to the
263/// working directory.
264pub fn default_dir() -> Option<PathBuf> {
265    crate::paths::preset_dir()
266}
267
268/// The bank file for one instrument inside `dir`.
269pub fn bank_path(dir: &Path, instrument: InstrumentType) -> PathBuf {
270    dir.join(format!("{}.json", instrument_key(instrument)))
271}
272
273// ── Load / save ──
274
275/// Read an instrument's bank. A bank that has never been written is empty,
276/// not an error — that is the first-run case. A bank that exists but does not
277/// parse *is* an error, so a corrupt file is reported rather than silently
278/// replaced the next time the player saves.
279pub fn load_bank(dir: &Path, instrument: InstrumentType) -> Result<PresetFile> {
280    let path = bank_path(dir, instrument);
281    if !path.exists() {
282        return Ok(PresetFile::new(instrument));
283    }
284    let json = std::fs::read_to_string(&path)?;
285    let bank: PresetFile = serde_json::from_str(&json)?;
286    Ok(bank)
287}
288
289/// Write an instrument's bank. Atomic: tmp file then rename, so an interrupted
290/// write cannot leave a half-written bank where the whole bank used to be.
291pub fn save_bank(dir: &Path, instrument: InstrumentType, bank: &PresetFile) -> Result<()> {
292    std::fs::create_dir_all(dir)?;
293    let path = bank_path(dir, instrument);
294    let json = serde_json::to_string_pretty(bank)?;
295
296    let tmp = path.with_extension("json.tmp");
297    std::fs::write(&tmp, &json)?;
298    std::fs::rename(&tmp, &path)?;
299
300    tracing::debug!("preset bank saved: {} ({} presets)", path.display(), bank.presets.len());
301    Ok(())
302}
303
304// ── Bank operations ──
305
306impl PresetFile {
307    pub fn new(instrument: InstrumentType) -> Self {
308        Self {
309            version: FORMAT_VERSION,
310            instrument: instrument_key(instrument).to_string(),
311            presets: Vec::new(),
312        }
313    }
314
315    /// Preset names in file order — what the browser lists.
316    pub fn names(&self) -> Vec<&str> {
317        self.presets.iter().map(|p| p.name.as_str()).collect()
318    }
319
320    /// Index of the preset with this name, if the bank holds one.
321    ///
322    /// Names are compared after trimming, so " pad" and "pad " are the same
323    /// slot rather than two rows the player cannot tell apart.
324    pub fn find(&self, name: &str) -> Option<usize> {
325        let name = name.trim();
326        self.presets.iter().position(|p| p.name == name)
327    }
328
329    /// Store `params` under `name`.
330    ///
331    /// A name already in the bank rewrites that slot in place rather than
332    /// adding a second row: the browser lists by name, so two rows called
333    /// "warm pad" are two rows the player cannot choose between, and `d`
334    /// would delete an arbitrary one of them. Callers are expected to confirm
335    /// before overwriting — `find` says whether they need to.
336    pub fn store(
337        &mut self,
338        name: &str,
339        instrument: InstrumentType,
340        params: &[f32],
341    ) -> Result<StoreOutcome, PresetError> {
342        let name = name.trim();
343        if name.is_empty() {
344            return Err(PresetError::NameEmpty);
345        }
346        let len = name.chars().count();
347        if len > MAX_NAME_LEN {
348            return Err(PresetError::NameTooLong { len, max: MAX_NAME_LEN });
349        }
350
351        let preset = Preset {
352            name: name.to_string(),
353            instrument: instrument_key(instrument).to_string(),
354            layout: layout_fingerprint(instrument),
355            param_count: params.len(),
356            params: params.to_vec(),
357            // Which controls these are comes from the instrument's own
358            // `is_discrete` rather than a list here, so a panel that gains a
359            // switch starts storing it without this file being edited.
360            discrete: crate::session::selectors_of(instrument, params),
361            version: FORMAT_VERSION,
362        };
363
364        let outcome = match self.find(name) {
365            Some(idx) => {
366                self.presets[idx] = preset;
367                StoreOutcome::Replaced
368            }
369            None => {
370                // Only a new slot can overflow the bank; overwriting a name
371                // stays legal at the cap, which is what a full bank needs to
372                // remain usable.
373                if self.presets.len() >= MAX_PRESETS {
374                    return Err(PresetError::BankFull { max: MAX_PRESETS });
375                }
376                self.presets.push(preset);
377                StoreOutcome::Added
378            }
379        };
380
381        // The file now holds an entry this build wrote, so its version is this
382        // build's. Presets already in it keep their own — the entry knows what
383        // wrote it, and that is what a load reads.
384        self.version = FORMAT_VERSION;
385        Ok(outcome)
386    }
387
388    /// Remove the preset at `index`, returning it.
389    pub fn remove(&mut self, index: usize) -> Option<Preset> {
390        (index < self.presets.len()).then(|| self.presets.remove(index))
391    }
392
393    /// The parameter block at `index`, if it is safe to load into this
394    /// instrument's current panel.
395    ///
396    /// Owned rather than borrowed because the block that comes back is not the
397    /// block on disk: every selector is repointed at the position the preset
398    /// named, which for a bank that has changed size is a different fraction
399    /// from the one that was stored.
400    pub fn params_at(
401        &self,
402        index: usize,
403        instrument: InstrumentType,
404        want_count: usize,
405    ) -> Option<Result<LoadedPreset, PresetError>> {
406        let preset = self.presets.get(index)?;
407        Some(preset.check(instrument, want_count).map(|()| preset.resolve(instrument)))
408    }
409}
410
411impl Preset {
412    /// Whether this preset can be loaded into `instrument`'s current panel of
413    /// `want_count` controls. Refuses rather than truncating or padding: a
414    /// block that is the wrong length or the wrong order is a different panel,
415    /// and copying it in slot by slot produces a plausible wrong sound with
416    /// nothing on screen to say so.
417    pub fn check(&self, instrument: InstrumentType, want_count: usize) -> Result<(), PresetError> {
418        let wanted_key = instrument_key(instrument);
419        if self.instrument != wanted_key {
420            return Err(PresetError::WrongInstrument {
421                saved: self.instrument.clone(),
422                wanted: wanted_key.to_string(),
423            });
424        }
425        if self.param_count != self.params.len() {
426            return Err(PresetError::Corrupt {
427                declared: self.param_count,
428                actual: self.params.len(),
429            });
430        }
431        if self.params.len() != want_count {
432            return Err(PresetError::ParamCountMismatch {
433                saved: self.params.len(),
434                wanted: want_count,
435            });
436        }
437        let wanted_layout = layout_fingerprint(instrument);
438        if self.layout != wanted_layout {
439            return Err(PresetError::LayoutMismatch {
440                saved: self.layout.clone(),
441                wanted: wanted_layout,
442            });
443        }
444        Ok(())
445    }
446
447    /// The panel to apply, with every selector put back where it was pointing.
448    ///
449    /// The stored fractions go in first and the positions are written over
450    /// them, so a control the preset has no position for — anything a version 1
451    /// preset holds, or a switch added since — keeps its fraction rather than
452    /// being reset to a default. Call [`check`](Self::check) first: this
453    /// assumes the block belongs to `instrument`.
454    #[must_use]
455    pub fn resolve(&self, instrument: InstrumentType) -> LoadedPreset {
456        let mut params = self.params.clone();
457        let clamped = crate::session::apply_selectors(instrument, &mut params, &self.discrete);
458        LoadedPreset {
459            params,
460            clamped,
461            legacy_selectors: self.version < FORMAT_VERSION,
462        }
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    /// A scratch directory of our own, so the tests never touch the player's
471    /// `~/.phosphor` and can run beside each other.
472    fn scratch(tag: &str) -> PathBuf {
473        let dir = std::env::temp_dir()
474            .join(format!("phosphor-presets-{}-{tag}", std::process::id()));
475        let _ = std::fs::remove_dir_all(&dir);
476        dir
477    }
478
479    fn juno_panel() -> Vec<f32> {
480        phosphor_dsp::juno::PARAM_DEFAULTS.to_vec()
481    }
482
483    /// A preset written to disk comes back with every value bit-identical.
484    /// f32 through JSON is the part worth checking: a value that round-trips
485    /// to within a rounding error is a filter cutoff that moved.
486    #[test]
487    fn a_preset_round_trips_through_the_file() {
488        let dir = scratch("round-trip");
489        let mut panel = juno_panel();
490        panel[phosphor_dsp::juno::P_CUTOFF] = 0.317_25;
491        panel[phosphor_dsp::juno::P_RESO] = 0.812_5;
492        // A selector is set to a position rather than to an arbitrary fraction,
493        // because a load puts selectors back by position — see
494        // `a_selector_comes_back_as_the_entry_it_named`. Everything else here
495        // is a level, and a level has to survive JSON exactly.
496        panel[phosphor_dsp::juno::P_PATCH] = phosphor_dsp::juno::patch_knob(24);
497
498        let mut bank = PresetFile::new(InstrumentType::Juno60);
499        assert_eq!(
500            bank.store("evening pad", InstrumentType::Juno60, &panel),
501            Ok(StoreOutcome::Added)
502        );
503        save_bank(&dir, InstrumentType::Juno60, &bank).unwrap();
504
505        let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
506        assert_eq!(reopened.names(), vec!["evening pad"]);
507        let loaded = reopened
508            .params_at(0, InstrumentType::Juno60, panel.len())
509            .unwrap()
510            .expect("its own panel should load");
511        // Every level comes back bit-identical. The selectors come back on the
512        // exact centre of the position they named, which is not always the
513        // fraction that was stored: the Juno's PWM MODE default is the rounded
514        // literal 0.16667 and the centre of that position is 0.166_666_67.
515        // Same switch position either way — this is a load putting a selector
516        // where it belongs rather than where a hand-written constant left it.
517        for (index, (before, after)) in panel.iter().zip(loaded.params.iter()).enumerate() {
518            if crate::discrete::is_discrete(InstrumentType::Juno60, index) {
519                assert_eq!(
520                    crate::discrete::index_of(InstrumentType::Juno60, index, *after),
521                    crate::discrete::index_of(InstrumentType::Juno60, index, *before),
522                    "control {index} came back on a different position"
523                );
524            } else {
525                assert_eq!(before, after, "control {index} came back changed");
526            }
527        }
528        assert!(loaded.clamped.is_empty());
529        assert!(!loaded.legacy_selectors, "a preset written now is not an old one");
530
531        let _ = std::fs::remove_dir_all(&dir);
532    }
533
534    /// The defect this format's version 2 exists for, played out on the
535    /// control it happened to.
536    ///
537    /// The rack went from ten kits to fifteen. A preset written against ten
538    /// stored the 909 as the fraction 0.15, and 0.15 of fifteen kits is the
539    /// 707 — a different drum machine, arriving without a word said, because
540    /// the layout fingerprint is derived from parameter *names* and a name does
541    /// not move when a bank grows. The position does not have that problem.
542    #[test]
543    fn a_selector_survives_the_bank_growing() {
544        use phosphor_dsp::drum_rack;
545
546        let dir = scratch("bank-grew");
547        let mut panel = drum_rack::PARAM_DEFAULTS.to_vec();
548        panel[drum_rack::P_KIT] = drum_rack::kit_knob(1);
549        assert_eq!(
550            drum_rack::discrete_label(drum_rack::P_KIT, panel[drum_rack::P_KIT]),
551            Some("909"),
552            "this test is pinned to the 909 being position 1"
553        );
554
555        let mut bank = PresetFile::new(InstrumentType::DrumRack);
556        bank.store("my kit", InstrumentType::DrumRack, &panel).unwrap();
557        assert_eq!(
558            bank.presets[0].discrete.iter().find(|s| s.param == drum_rack::P_KIT),
559            Some(&SessionSelector { param: drum_rack::P_KIT, index: 1 }),
560            "the kit was not stored by position"
561        );
562
563        // Now rewrite the stored *fraction* as a ten-kit build would have
564        // written it, leaving the position alone. This is the file the player
565        // has on disk.
566        bank.presets[0].params[drum_rack::P_KIT] = 1.5 / 10.0;
567        save_bank(&dir, InstrumentType::DrumRack, &bank).unwrap();
568
569        // Read against fifteen kits, the fraction alone is the 707...
570        let reopened = load_bank(&dir, InstrumentType::DrumRack).unwrap();
571        assert_eq!(
572            drum_rack::discrete_label(drum_rack::P_KIT, reopened.presets[0].params[drum_rack::P_KIT]),
573            Some("707"),
574            "the fraction no longer names the 707, so this test proves nothing"
575        );
576        // ...and the loaded preset is still the 909.
577        let loaded = reopened
578            .params_at(0, InstrumentType::DrumRack, panel.len())
579            .unwrap()
580            .expect("its own panel should load");
581        assert_eq!(
582            drum_rack::discrete_label(drum_rack::P_KIT, loaded.params[drum_rack::P_KIT]),
583            Some("909"),
584            "the preset opened on a different drum machine"
585        );
586        assert!(loaded.clamped.is_empty());
587        assert!(!loaded.legacy_selectors);
588
589        let _ = std::fs::remove_dir_all(&dir);
590    }
591
592    /// The DX7 picks a voice with two selectors — a cartridge and a voice
593    /// button — so "index 0 is the patch" would restore half of it. Both are
594    /// stored, and both come back across a bank that has grown.
595    #[test]
596    fn both_dx7_selectors_survive_a_round_trip() {
597        use phosphor_dsp::dx7;
598
599        let mut panel = dx7::PARAM_DEFAULTS.to_vec();
600        let (bank_knob, patch_knob) = dx7::voice_knobs(147);
601        panel[dx7::P_BANK] = bank_knob;
602        panel[dx7::P_PATCH] = patch_knob;
603
604        let mut bank = PresetFile::new(InstrumentType::DX7);
605        bank.store("timpani", InstrumentType::DX7, &panel).unwrap();
606
607        let stored: Vec<usize> = bank.presets[0].discrete.iter().map(|s| s.param).collect();
608        assert!(stored.contains(&dx7::P_BANK), "the cartridge was not stored");
609        assert!(stored.contains(&dx7::P_PATCH), "the voice was not stored");
610
611        // Both fractions scrambled to what a smaller bank would have written,
612        // the positions left alone.
613        bank.presets[0].params[dx7::P_BANK] = 0.0;
614        bank.presets[0].params[dx7::P_PATCH] = 0.0;
615
616        let loaded = bank
617            .params_at(0, InstrumentType::DX7, panel.len())
618            .unwrap()
619            .expect("its own panel should load");
620        assert_eq!(loaded.params[dx7::P_BANK], bank_knob, "the cartridge did not come back");
621        assert_eq!(loaded.params[dx7::P_PATCH], patch_knob, "the voice did not come back");
622    }
623
624    /// A bank that has *shrunk* has nothing at the far end of it any more. The
625    /// nearest thing to what the player chose is its last entry, and the load
626    /// says which entries it had to move rather than moving them quietly.
627    #[test]
628    fn a_selector_past_the_end_of_the_bank_is_clamped_and_reported() {
629        use phosphor_dsp::drum_rack;
630
631        let panel = drum_rack::PARAM_DEFAULTS.to_vec();
632        let mut bank = PresetFile::new(InstrumentType::DrumRack);
633        bank.store("from the future", InstrumentType::DrumRack, &panel).unwrap();
634        let selector = bank.presets[0]
635            .discrete
636            .iter_mut()
637            .find(|s| s.param == drum_rack::P_KIT)
638            .expect("the kit is a selector");
639        selector.index = 900;
640
641        let loaded = bank
642            .params_at(0, InstrumentType::DrumRack, panel.len())
643            .unwrap()
644            .expect("its own panel should load");
645        assert_eq!(
646            loaded.clamped,
647            vec![(drum_rack::P_KIT, 900, drum_rack::KIT_COUNT - 1)],
648            "a position the rack no longer has was not reported"
649        );
650        assert_eq!(
651            loaded.params[drum_rack::P_KIT],
652            drum_rack::kit_knob(drum_rack::KIT_COUNT - 1),
653            "the kit did not land on the last one the rack has"
654        );
655    }
656
657    /// A version 1 preset — written before positions were stored — still
658    /// loads, because the fraction is the only evidence of what the player
659    /// chose and it is right whenever the bank has not moved. What it does not
660    /// do is load quietly: `legacy_selectors` is what the bottom bar turns
661    /// into "check the patch".
662    #[test]
663    fn a_version_1_preset_loads_from_its_fractions_and_says_so() {
664        use phosphor_dsp::drum_rack;
665
666        let dir = scratch("version-1");
667        // A version 1 file, written out as that format really was: no
668        // `discrete` array and no per-preset `version`.
669        let params: Vec<String> = drum_rack::PARAM_DEFAULTS
670            .iter()
671            .enumerate()
672            .map(|(i, v)| {
673                if i == drum_rack::P_KIT { (1.5f32 / 10.0).to_string() } else { v.to_string() }
674            })
675            .collect();
676        let json = format!(
677            r#"{{"version":1,"instrument":"drums","presets":[{{"name":"old",
678               "instrument":"drums","layout":"{}","param_count":{},"params":[{}]}}]}}"#,
679            layout_fingerprint(InstrumentType::DrumRack),
680            drum_rack::PARAM_COUNT,
681            params.join(",")
682        );
683        std::fs::create_dir_all(&dir).unwrap();
684        std::fs::write(bank_path(&dir, InstrumentType::DrumRack), json).unwrap();
685
686        let bank = load_bank(&dir, InstrumentType::DrumRack).unwrap();
687        assert_eq!(bank.version, 1);
688        assert_eq!(bank.presets[0].version, LEGACY_VERSION, "a missing version is version 1");
689        assert!(bank.presets[0].discrete.is_empty());
690
691        let loaded = bank
692            .params_at(0, InstrumentType::DrumRack, drum_rack::PARAM_COUNT)
693            .unwrap()
694            .expect("an old preset still loads");
695        assert!(loaded.legacy_selectors, "an old preset loaded without a word said");
696        // The fraction is all it has, so it reads as the 707 — which is the
697        // defect, and the reason the bottom bar is told to say so.
698        assert_eq!(
699            drum_rack::discrete_label(drum_rack::P_KIT, loaded.params[drum_rack::P_KIT]),
700            Some("707")
701        );
702
703        let _ = std::fs::remove_dir_all(&dir);
704    }
705
706    /// Every instrument's selectors get stored, not just the ones this file
707    /// happens to name. A panel that gains a switch has to start carrying it
708    /// without this module being edited.
709    #[test]
710    fn every_selector_on_every_instrument_is_stored() {
711        for instrument in InstrumentType::ALL {
712            let count = param_count(*instrument);
713            let panel = vec![0.5f32; count];
714            let mut bank = PresetFile::new(*instrument);
715            bank.store("all", *instrument, &panel).unwrap();
716
717            let stored: Vec<usize> =
718                bank.presets[0].discrete.iter().map(|s| s.param).collect();
719            let wanted: Vec<usize> = (0..count)
720                .filter(|&p| crate::discrete::is_discrete(*instrument, p))
721                .collect();
722            assert_eq!(stored, wanted, "{instrument:?} did not store all of its selectors");
723            assert!(
724                !wanted.is_empty() || instrument.is_sequencer(),
725                "{instrument:?} has no selectors at all"
726            );
727        }
728    }
729
730    /// A bank that has never been written is empty rather than an error —
731    /// the first time a player opens the browser there is no file yet.
732    #[test]
733    fn a_bank_that_does_not_exist_is_empty() {
734        let dir = scratch("missing");
735        let bank = load_bank(&dir, InstrumentType::DX7).unwrap();
736        assert!(bank.presets.is_empty());
737        assert_eq!(bank.instrument, "dx7");
738    }
739
740    /// The case this whole mechanism exists for: a preset written when the
741    /// Juno had 16 controls, opened after it grew to 25. Loading it would put
742    /// 16 values into the first 16 of 25 holes — a plausible sound that is not
743    /// the one that was saved.
744    #[test]
745    fn a_preset_with_the_wrong_control_count_is_refused() {
746        let dir = scratch("count");
747        let mut bank = PresetFile::new(InstrumentType::Juno60);
748        bank.store("old panel", InstrumentType::Juno60, &juno_panel()).unwrap();
749        // Rewrite it as the 16-control panel the Juno used to have.
750        bank.presets[0].params.truncate(16);
751        bank.presets[0].param_count = 16;
752        save_bank(&dir, InstrumentType::Juno60, &bank).unwrap();
753
754        let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
755        let want = param_count(InstrumentType::Juno60);
756        assert_eq!(
757            reopened.params_at(0, InstrumentType::Juno60, want).unwrap(),
758            Err(PresetError::ParamCountMismatch { saved: 16, wanted: want })
759        );
760
761        let _ = std::fs::remove_dir_all(&dir);
762    }
763
764    /// A count alone does not catch a reorder. The Odyssey and the phosphor
765    /// synth are different panels; so is one instrument before and after its
766    /// controls were shuffled into front-panel order.
767    #[test]
768    fn a_preset_from_a_reordered_panel_is_refused() {
769        let panel = juno_panel();
770        let mut preset = Preset {
771            name: "reordered".into(),
772            instrument: "juno60".into(),
773            layout: layout_fingerprint(InstrumentType::Juno60),
774            param_count: panel.len(),
775            params: panel,
776            discrete: Vec::new(),
777            version: FORMAT_VERSION,
778        };
779        assert_eq!(preset.check(InstrumentType::Juno60, 25), Ok(()));
780
781        // Same instrument, same count, panel shuffled: the fingerprint moves.
782        preset.layout = "0000000000000000".into();
783        assert!(matches!(
784            preset.check(InstrumentType::Juno60, 25),
785            Err(PresetError::LayoutMismatch { .. })
786        ));
787    }
788
789    /// Reordering the names really does move the fingerprint — the property
790    /// the layout check depends on.
791    #[test]
792    fn the_fingerprint_separates_every_instrument() {
793        let mut seen = Vec::new();
794        for inst in InstrumentType::ALL {
795            let fp = layout_fingerprint(*inst);
796            assert_eq!(fp.len(), 16, "{fp} is not a 64-bit fingerprint");
797            seen.push((inst, fp));
798        }
799        // The sampler shares the phosphor synth's panel, so those two match by
800        // design; every other pair is a different panel.
801        for (a, fa) in &seen {
802            for (b, fb) in &seen {
803                let shared_panel = matches!(
804                    (a, b),
805                    (InstrumentType::Synth, InstrumentType::Sampler)
806                        | (InstrumentType::Sampler, InstrumentType::Synth)
807                );
808                if a != b && !shared_panel {
809                    assert_ne!(fa, fb, "{a:?} and {b:?} fingerprint the same");
810                }
811            }
812        }
813    }
814
815    /// One file per instrument keeps a DX7 preset out of a Juno's browser, but
816    /// a preset that gets hand-copied between files still has to be caught.
817    #[test]
818    fn a_preset_saved_for_another_instrument_is_refused() {
819        let dir = scratch("instrument");
820        let mut dx7 = PresetFile::new(InstrumentType::DX7);
821        dx7.store("e.piano", InstrumentType::DX7, &phosphor_dsp::dx7::PARAM_DEFAULTS).unwrap();
822
823        // As if the player pasted the entry into juno60.json by hand.
824        let mut juno = PresetFile::new(InstrumentType::Juno60);
825        juno.presets.push(dx7.presets[0].clone());
826        save_bank(&dir, InstrumentType::Juno60, &juno).unwrap();
827
828        let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
829        assert_eq!(
830            reopened.params_at(0, InstrumentType::Juno60, 9).unwrap(),
831            Err(PresetError::WrongInstrument {
832                saved: "dx7".into(),
833                wanted: "juno60".into()
834            }),
835            "a DX7 preset loaded into a Juno"
836        );
837
838        // ...and the bank files are separate to begin with.
839        assert_ne!(
840            bank_path(&dir, InstrumentType::DX7),
841            bank_path(&dir, InstrumentType::Juno60)
842        );
843
844        let _ = std::fs::remove_dir_all(&dir);
845    }
846
847    /// Saving under a name the bank already holds rewrites that slot instead
848    /// of adding a second row with the same label.
849    #[test]
850    fn saving_over_a_name_replaces_it_in_place() {
851        let mut bank = PresetFile::new(InstrumentType::Juno60);
852        let mut first = juno_panel();
853        first[phosphor_dsp::juno::P_CUTOFF] = 0.2;
854        let mut second = juno_panel();
855        second[phosphor_dsp::juno::P_CUTOFF] = 0.9;
856
857        bank.store("brass", InstrumentType::Juno60, &first).unwrap();
858        bank.store("strings", InstrumentType::Juno60, &juno_panel()).unwrap();
859        assert_eq!(
860            bank.store("brass", InstrumentType::Juno60, &second),
861            Ok(StoreOutcome::Replaced)
862        );
863
864        assert_eq!(bank.names(), vec!["brass", "strings"], "the slot moved or duplicated");
865        assert_eq!(bank.presets[0].params[phosphor_dsp::juno::P_CUTOFF], 0.9);
866
867        // Whitespace is trimmed, so " brass" is the same slot rather than a
868        // second row the player cannot tell from the first.
869        assert_eq!(
870            bank.store("  brass  ", InstrumentType::Juno60, &first),
871            Ok(StoreOutcome::Replaced)
872        );
873        assert_eq!(bank.presets.len(), 2);
874    }
875
876    /// The bank has a ceiling, and a full bank can still be overwritten.
877    #[test]
878    fn the_bank_stops_at_its_limit() {
879        let mut bank = PresetFile::new(InstrumentType::Juno60);
880        let panel = juno_panel();
881        for i in 0..MAX_PRESETS {
882            bank.store(&format!("p{i}"), InstrumentType::Juno60, &panel).unwrap();
883        }
884        assert_eq!(
885            bank.store("one more", InstrumentType::Juno60, &panel),
886            Err(PresetError::BankFull { max: MAX_PRESETS })
887        );
888        assert_eq!(
889            bank.store("p0", InstrumentType::Juno60, &panel),
890            Ok(StoreOutcome::Replaced),
891            "a full bank became read-only"
892        );
893
894        bank.remove(0);
895        assert_eq!(bank.presets.len(), MAX_PRESETS - 1);
896        assert_eq!(
897            bank.store("one more", InstrumentType::Juno60, &panel),
898            Ok(StoreOutcome::Added)
899        );
900    }
901
902    /// A name has to be something a player can pick out of a list.
903    #[test]
904    fn names_are_bounded_and_non_empty() {
905        let mut bank = PresetFile::new(InstrumentType::Juno60);
906        let panel = juno_panel();
907        assert_eq!(
908            bank.store("   ", InstrumentType::Juno60, &panel),
909            Err(PresetError::NameEmpty)
910        );
911        let long = "x".repeat(MAX_NAME_LEN + 1);
912        assert_eq!(
913            bank.store(&long, InstrumentType::Juno60, &panel),
914            Err(PresetError::NameTooLong { len: MAX_NAME_LEN + 1, max: MAX_NAME_LEN })
915        );
916        assert!(bank.presets.is_empty());
917    }
918
919    /// A hand-edited file whose declared count disagrees with what it carries
920    /// is refused rather than trusted.
921    #[test]
922    fn a_preset_that_contradicts_itself_is_refused() {
923        let panel = juno_panel();
924        let preset = Preset {
925            name: "hand edited".into(),
926            instrument: "juno60".into(),
927            layout: layout_fingerprint(InstrumentType::Juno60),
928            param_count: 99,
929            params: panel.clone(),
930            discrete: Vec::new(),
931            version: FORMAT_VERSION,
932        };
933        assert_eq!(
934            preset.check(InstrumentType::Juno60, panel.len()),
935            Err(PresetError::Corrupt { declared: 99, actual: panel.len() })
936        );
937    }
938
939    /// Every instrument's declared panel size matches the block a new track
940    /// gets, so no instrument is born unable to save a preset.
941    ///
942    /// The sequencer is exempt and is the only thing that ever will be: it is
943    /// not an instrument, it drives one, and the panel on one of its tracks
944    /// belongs to the child.
945    #[test]
946    fn every_instrument_has_a_panel() {
947        for inst in InstrumentType::ALL {
948            if inst.is_sequencer() {
949                assert_eq!(param_count(*inst), 0, "the sequencer grew a panel");
950                continue;
951            }
952            assert!(param_count(*inst) > 0, "{inst:?} has no parameters");
953            assert_eq!(
954                crate::preset::defaults(*inst).len(),
955                param_count(*inst),
956                "{inst:?} is born with a block of the wrong size"
957            );
958        }
959        assert_eq!(param_count(InstrumentType::Juno60), phosphor_dsp::juno::PARAM_COUNT);
960        assert_eq!(param_count(InstrumentType::Jupiter8), phosphor_dsp::jupiter::PARAM_COUNT);
961        assert_eq!(param_count(InstrumentType::DX7), phosphor_dsp::dx7::PARAM_COUNT);
962        assert_eq!(param_count(InstrumentType::Odyssey), phosphor_dsp::odyssey::PARAM_COUNT);
963        assert_eq!(param_count(InstrumentType::Rhodes), phosphor_dsp::rhodes::PARAM_COUNT);
964        assert_eq!(param_count(InstrumentType::DrumRack), phosphor_dsp::drum_rack::PARAM_COUNT);
965        assert_eq!(param_count(InstrumentType::Synth), phosphor_dsp::synth::PARAM_COUNT);
966        assert_eq!(param_count(InstrumentType::Sampler), phosphor_dsp::synth::PARAM_COUNT);
967        assert_eq!(param_count(InstrumentType::LittlePhatty), phosphor_dsp::phatty::PARAM_COUNT);
968        assert_eq!(param_count(InstrumentType::Prophet6), phosphor_dsp::prophet6::PARAM_COUNT);
969    }
970}