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    }
181}
182
183/// A fingerprint of an instrument's parameter layout.
184///
185/// A count alone does not catch a reorder, and this project reorders: the
186/// Juno went from 16 controls to 25, the Jupiter from 16 to 32 and the drum
187/// rack from 6 to 35, and each of those rewrote the order of the slots it
188/// already had into front-panel order. A preset saved against the old order
189/// has the right number of values in the wrong holes, which is exactly the
190/// silent-and-plausible failure the count check exists to prevent.
191///
192/// So the fingerprint is derived from the parameter *names* rather than
193/// declared by hand: a version integer someone has to remember to bump is a
194/// version integer that eventually does not get bumped. Renaming a control
195/// invalidates presets that would otherwise still be correct — the trade is
196/// deliberate, because a refusal is recoverable and a wrong sound is not.
197///
198/// FNV-1a rather than `DefaultHasher`, whose algorithm the standard library
199/// explicitly reserves the right to change; this value goes in a file.
200pub fn layout_fingerprint(instrument: InstrumentType) -> String {
201    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
202    const PRIME: u64 = 0x0000_0100_0000_01b3;
203
204    let mut hash = OFFSET;
205    for name in param_names(instrument) {
206        // The separator keeps ["ab", "c"] from hashing as ["a", "bc"].
207        for byte in name.bytes().chain(std::iter::once(0xff)) {
208            hash ^= u64::from(byte);
209            hash = hash.wrapping_mul(PRIME);
210        }
211    }
212    format!("{hash:016x}")
213}
214
215/// How many parameters this instrument's panel has.
216pub fn param_count(instrument: InstrumentType) -> usize {
217    param_names(instrument).len()
218}
219
220// ── Paths ──
221
222/// `~/.phosphor/presets` on Unix, `%APPDATA%\phosphor\presets` on Windows, or
223/// `None` when the environment names no home directory at all.
224///
225/// Same shape as the theme's config path, and now literally the same lookup:
226/// this read `HOME` directly for a long time, which Windows does not set, so
227/// every preset save on Windows resolved to `None` and the call sites read
228/// `None` as "do nothing". See [`crate::paths`] for the rule and its tests.
229///
230/// With no home directory there is still nowhere to put presets, and the
231/// answer is still to do nothing rather than to scatter files relative to the
232/// working directory.
233pub fn default_dir() -> Option<PathBuf> {
234    crate::paths::preset_dir()
235}
236
237/// The bank file for one instrument inside `dir`.
238pub fn bank_path(dir: &Path, instrument: InstrumentType) -> PathBuf {
239    dir.join(format!("{}.json", instrument_key(instrument)))
240}
241
242// ── Load / save ──
243
244/// Read an instrument's bank. A bank that has never been written is empty,
245/// not an error — that is the first-run case. A bank that exists but does not
246/// parse *is* an error, so a corrupt file is reported rather than silently
247/// replaced the next time the player saves.
248pub fn load_bank(dir: &Path, instrument: InstrumentType) -> Result<PresetFile> {
249    let path = bank_path(dir, instrument);
250    if !path.exists() {
251        return Ok(PresetFile::new(instrument));
252    }
253    let json = std::fs::read_to_string(&path)?;
254    let bank: PresetFile = serde_json::from_str(&json)?;
255    Ok(bank)
256}
257
258/// Write an instrument's bank. Atomic: tmp file then rename, so an interrupted
259/// write cannot leave a half-written bank where the whole bank used to be.
260pub fn save_bank(dir: &Path, instrument: InstrumentType, bank: &PresetFile) -> Result<()> {
261    std::fs::create_dir_all(dir)?;
262    let path = bank_path(dir, instrument);
263    let json = serde_json::to_string_pretty(bank)?;
264
265    let tmp = path.with_extension("json.tmp");
266    std::fs::write(&tmp, &json)?;
267    std::fs::rename(&tmp, &path)?;
268
269    tracing::debug!("preset bank saved: {} ({} presets)", path.display(), bank.presets.len());
270    Ok(())
271}
272
273// ── Bank operations ──
274
275impl PresetFile {
276    pub fn new(instrument: InstrumentType) -> Self {
277        Self {
278            version: FORMAT_VERSION,
279            instrument: instrument_key(instrument).to_string(),
280            presets: Vec::new(),
281        }
282    }
283
284    /// Preset names in file order — what the browser lists.
285    pub fn names(&self) -> Vec<&str> {
286        self.presets.iter().map(|p| p.name.as_str()).collect()
287    }
288
289    /// Index of the preset with this name, if the bank holds one.
290    ///
291    /// Names are compared after trimming, so " pad" and "pad " are the same
292    /// slot rather than two rows the player cannot tell apart.
293    pub fn find(&self, name: &str) -> Option<usize> {
294        let name = name.trim();
295        self.presets.iter().position(|p| p.name == name)
296    }
297
298    /// Store `params` under `name`.
299    ///
300    /// A name already in the bank rewrites that slot in place rather than
301    /// adding a second row: the browser lists by name, so two rows called
302    /// "warm pad" are two rows the player cannot choose between, and `d`
303    /// would delete an arbitrary one of them. Callers are expected to confirm
304    /// before overwriting — `find` says whether they need to.
305    pub fn store(
306        &mut self,
307        name: &str,
308        instrument: InstrumentType,
309        params: &[f32],
310    ) -> Result<StoreOutcome, PresetError> {
311        let name = name.trim();
312        if name.is_empty() {
313            return Err(PresetError::NameEmpty);
314        }
315        let len = name.chars().count();
316        if len > MAX_NAME_LEN {
317            return Err(PresetError::NameTooLong { len, max: MAX_NAME_LEN });
318        }
319
320        let preset = Preset {
321            name: name.to_string(),
322            instrument: instrument_key(instrument).to_string(),
323            layout: layout_fingerprint(instrument),
324            param_count: params.len(),
325            params: params.to_vec(),
326            // Which controls these are comes from the instrument's own
327            // `is_discrete` rather than a list here, so a panel that gains a
328            // switch starts storing it without this file being edited.
329            discrete: crate::session::selectors_of(instrument, params),
330            version: FORMAT_VERSION,
331        };
332
333        let outcome = match self.find(name) {
334            Some(idx) => {
335                self.presets[idx] = preset;
336                StoreOutcome::Replaced
337            }
338            None => {
339                // Only a new slot can overflow the bank; overwriting a name
340                // stays legal at the cap, which is what a full bank needs to
341                // remain usable.
342                if self.presets.len() >= MAX_PRESETS {
343                    return Err(PresetError::BankFull { max: MAX_PRESETS });
344                }
345                self.presets.push(preset);
346                StoreOutcome::Added
347            }
348        };
349
350        // The file now holds an entry this build wrote, so its version is this
351        // build's. Presets already in it keep their own — the entry knows what
352        // wrote it, and that is what a load reads.
353        self.version = FORMAT_VERSION;
354        Ok(outcome)
355    }
356
357    /// Remove the preset at `index`, returning it.
358    pub fn remove(&mut self, index: usize) -> Option<Preset> {
359        (index < self.presets.len()).then(|| self.presets.remove(index))
360    }
361
362    /// The parameter block at `index`, if it is safe to load into this
363    /// instrument's current panel.
364    ///
365    /// Owned rather than borrowed because the block that comes back is not the
366    /// block on disk: every selector is repointed at the position the preset
367    /// named, which for a bank that has changed size is a different fraction
368    /// from the one that was stored.
369    pub fn params_at(
370        &self,
371        index: usize,
372        instrument: InstrumentType,
373        want_count: usize,
374    ) -> Option<Result<LoadedPreset, PresetError>> {
375        let preset = self.presets.get(index)?;
376        Some(preset.check(instrument, want_count).map(|()| preset.resolve(instrument)))
377    }
378}
379
380impl Preset {
381    /// Whether this preset can be loaded into `instrument`'s current panel of
382    /// `want_count` controls. Refuses rather than truncating or padding: a
383    /// block that is the wrong length or the wrong order is a different panel,
384    /// and copying it in slot by slot produces a plausible wrong sound with
385    /// nothing on screen to say so.
386    pub fn check(&self, instrument: InstrumentType, want_count: usize) -> Result<(), PresetError> {
387        let wanted_key = instrument_key(instrument);
388        if self.instrument != wanted_key {
389            return Err(PresetError::WrongInstrument {
390                saved: self.instrument.clone(),
391                wanted: wanted_key.to_string(),
392            });
393        }
394        if self.param_count != self.params.len() {
395            return Err(PresetError::Corrupt {
396                declared: self.param_count,
397                actual: self.params.len(),
398            });
399        }
400        if self.params.len() != want_count {
401            return Err(PresetError::ParamCountMismatch {
402                saved: self.params.len(),
403                wanted: want_count,
404            });
405        }
406        let wanted_layout = layout_fingerprint(instrument);
407        if self.layout != wanted_layout {
408            return Err(PresetError::LayoutMismatch {
409                saved: self.layout.clone(),
410                wanted: wanted_layout,
411            });
412        }
413        Ok(())
414    }
415
416    /// The panel to apply, with every selector put back where it was pointing.
417    ///
418    /// The stored fractions go in first and the positions are written over
419    /// them, so a control the preset has no position for — anything a version 1
420    /// preset holds, or a switch added since — keeps its fraction rather than
421    /// being reset to a default. Call [`check`](Self::check) first: this
422    /// assumes the block belongs to `instrument`.
423    #[must_use]
424    pub fn resolve(&self, instrument: InstrumentType) -> LoadedPreset {
425        let mut params = self.params.clone();
426        let clamped = crate::session::apply_selectors(instrument, &mut params, &self.discrete);
427        LoadedPreset {
428            params,
429            clamped,
430            legacy_selectors: self.version < FORMAT_VERSION,
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    /// A scratch directory of our own, so the tests never touch the player's
440    /// `~/.phosphor` and can run beside each other.
441    fn scratch(tag: &str) -> PathBuf {
442        let dir = std::env::temp_dir()
443            .join(format!("phosphor-presets-{}-{tag}", std::process::id()));
444        let _ = std::fs::remove_dir_all(&dir);
445        dir
446    }
447
448    fn juno_panel() -> Vec<f32> {
449        phosphor_dsp::juno::PARAM_DEFAULTS.to_vec()
450    }
451
452    /// A preset written to disk comes back with every value bit-identical.
453    /// f32 through JSON is the part worth checking: a value that round-trips
454    /// to within a rounding error is a filter cutoff that moved.
455    #[test]
456    fn a_preset_round_trips_through_the_file() {
457        let dir = scratch("round-trip");
458        let mut panel = juno_panel();
459        panel[phosphor_dsp::juno::P_CUTOFF] = 0.317_25;
460        panel[phosphor_dsp::juno::P_RESO] = 0.812_5;
461        // A selector is set to a position rather than to an arbitrary fraction,
462        // because a load puts selectors back by position — see
463        // `a_selector_comes_back_as_the_entry_it_named`. Everything else here
464        // is a level, and a level has to survive JSON exactly.
465        panel[phosphor_dsp::juno::P_PATCH] = phosphor_dsp::juno::patch_knob(24);
466
467        let mut bank = PresetFile::new(InstrumentType::Juno60);
468        assert_eq!(
469            bank.store("evening pad", InstrumentType::Juno60, &panel),
470            Ok(StoreOutcome::Added)
471        );
472        save_bank(&dir, InstrumentType::Juno60, &bank).unwrap();
473
474        let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
475        assert_eq!(reopened.names(), vec!["evening pad"]);
476        let loaded = reopened
477            .params_at(0, InstrumentType::Juno60, panel.len())
478            .unwrap()
479            .expect("its own panel should load");
480        // Every level comes back bit-identical. The selectors come back on the
481        // exact centre of the position they named, which is not always the
482        // fraction that was stored: the Juno's PWM MODE default is the rounded
483        // literal 0.16667 and the centre of that position is 0.166_666_67.
484        // Same switch position either way — this is a load putting a selector
485        // where it belongs rather than where a hand-written constant left it.
486        for (index, (before, after)) in panel.iter().zip(loaded.params.iter()).enumerate() {
487            if crate::discrete::is_discrete(InstrumentType::Juno60, index) {
488                assert_eq!(
489                    crate::discrete::index_of(InstrumentType::Juno60, index, *after),
490                    crate::discrete::index_of(InstrumentType::Juno60, index, *before),
491                    "control {index} came back on a different position"
492                );
493            } else {
494                assert_eq!(before, after, "control {index} came back changed");
495            }
496        }
497        assert!(loaded.clamped.is_empty());
498        assert!(!loaded.legacy_selectors, "a preset written now is not an old one");
499
500        let _ = std::fs::remove_dir_all(&dir);
501    }
502
503    /// The defect this format's version 2 exists for, played out on the
504    /// control it happened to.
505    ///
506    /// The rack went from ten kits to fifteen. A preset written against ten
507    /// stored the 909 as the fraction 0.15, and 0.15 of fifteen kits is the
508    /// 707 — a different drum machine, arriving without a word said, because
509    /// the layout fingerprint is derived from parameter *names* and a name does
510    /// not move when a bank grows. The position does not have that problem.
511    #[test]
512    fn a_selector_survives_the_bank_growing() {
513        use phosphor_dsp::drum_rack;
514
515        let dir = scratch("bank-grew");
516        let mut panel = drum_rack::PARAM_DEFAULTS.to_vec();
517        panel[drum_rack::P_KIT] = drum_rack::kit_knob(1);
518        assert_eq!(
519            drum_rack::discrete_label(drum_rack::P_KIT, panel[drum_rack::P_KIT]),
520            Some("909"),
521            "this test is pinned to the 909 being position 1"
522        );
523
524        let mut bank = PresetFile::new(InstrumentType::DrumRack);
525        bank.store("my kit", InstrumentType::DrumRack, &panel).unwrap();
526        assert_eq!(
527            bank.presets[0].discrete.iter().find(|s| s.param == drum_rack::P_KIT),
528            Some(&SessionSelector { param: drum_rack::P_KIT, index: 1 }),
529            "the kit was not stored by position"
530        );
531
532        // Now rewrite the stored *fraction* as a ten-kit build would have
533        // written it, leaving the position alone. This is the file the player
534        // has on disk.
535        bank.presets[0].params[drum_rack::P_KIT] = 1.5 / 10.0;
536        save_bank(&dir, InstrumentType::DrumRack, &bank).unwrap();
537
538        // Read against fifteen kits, the fraction alone is the 707...
539        let reopened = load_bank(&dir, InstrumentType::DrumRack).unwrap();
540        assert_eq!(
541            drum_rack::discrete_label(drum_rack::P_KIT, reopened.presets[0].params[drum_rack::P_KIT]),
542            Some("707"),
543            "the fraction no longer names the 707, so this test proves nothing"
544        );
545        // ...and the loaded preset is still the 909.
546        let loaded = reopened
547            .params_at(0, InstrumentType::DrumRack, panel.len())
548            .unwrap()
549            .expect("its own panel should load");
550        assert_eq!(
551            drum_rack::discrete_label(drum_rack::P_KIT, loaded.params[drum_rack::P_KIT]),
552            Some("909"),
553            "the preset opened on a different drum machine"
554        );
555        assert!(loaded.clamped.is_empty());
556        assert!(!loaded.legacy_selectors);
557
558        let _ = std::fs::remove_dir_all(&dir);
559    }
560
561    /// The DX7 picks a voice with two selectors — a cartridge and a voice
562    /// button — so "index 0 is the patch" would restore half of it. Both are
563    /// stored, and both come back across a bank that has grown.
564    #[test]
565    fn both_dx7_selectors_survive_a_round_trip() {
566        use phosphor_dsp::dx7;
567
568        let mut panel = dx7::PARAM_DEFAULTS.to_vec();
569        let (bank_knob, patch_knob) = dx7::voice_knobs(147);
570        panel[dx7::P_BANK] = bank_knob;
571        panel[dx7::P_PATCH] = patch_knob;
572
573        let mut bank = PresetFile::new(InstrumentType::DX7);
574        bank.store("timpani", InstrumentType::DX7, &panel).unwrap();
575
576        let stored: Vec<usize> = bank.presets[0].discrete.iter().map(|s| s.param).collect();
577        assert!(stored.contains(&dx7::P_BANK), "the cartridge was not stored");
578        assert!(stored.contains(&dx7::P_PATCH), "the voice was not stored");
579
580        // Both fractions scrambled to what a smaller bank would have written,
581        // the positions left alone.
582        bank.presets[0].params[dx7::P_BANK] = 0.0;
583        bank.presets[0].params[dx7::P_PATCH] = 0.0;
584
585        let loaded = bank
586            .params_at(0, InstrumentType::DX7, panel.len())
587            .unwrap()
588            .expect("its own panel should load");
589        assert_eq!(loaded.params[dx7::P_BANK], bank_knob, "the cartridge did not come back");
590        assert_eq!(loaded.params[dx7::P_PATCH], patch_knob, "the voice did not come back");
591    }
592
593    /// A bank that has *shrunk* has nothing at the far end of it any more. The
594    /// nearest thing to what the player chose is its last entry, and the load
595    /// says which entries it had to move rather than moving them quietly.
596    #[test]
597    fn a_selector_past_the_end_of_the_bank_is_clamped_and_reported() {
598        use phosphor_dsp::drum_rack;
599
600        let panel = drum_rack::PARAM_DEFAULTS.to_vec();
601        let mut bank = PresetFile::new(InstrumentType::DrumRack);
602        bank.store("from the future", InstrumentType::DrumRack, &panel).unwrap();
603        let selector = bank.presets[0]
604            .discrete
605            .iter_mut()
606            .find(|s| s.param == drum_rack::P_KIT)
607            .expect("the kit is a selector");
608        selector.index = 900;
609
610        let loaded = bank
611            .params_at(0, InstrumentType::DrumRack, panel.len())
612            .unwrap()
613            .expect("its own panel should load");
614        assert_eq!(
615            loaded.clamped,
616            vec![(drum_rack::P_KIT, 900, drum_rack::KIT_COUNT - 1)],
617            "a position the rack no longer has was not reported"
618        );
619        assert_eq!(
620            loaded.params[drum_rack::P_KIT],
621            drum_rack::kit_knob(drum_rack::KIT_COUNT - 1),
622            "the kit did not land on the last one the rack has"
623        );
624    }
625
626    /// A version 1 preset — written before positions were stored — still
627    /// loads, because the fraction is the only evidence of what the player
628    /// chose and it is right whenever the bank has not moved. What it does not
629    /// do is load quietly: `legacy_selectors` is what the bottom bar turns
630    /// into "check the patch".
631    #[test]
632    fn a_version_1_preset_loads_from_its_fractions_and_says_so() {
633        use phosphor_dsp::drum_rack;
634
635        let dir = scratch("version-1");
636        // A version 1 file, written out as that format really was: no
637        // `discrete` array and no per-preset `version`.
638        let params: Vec<String> = drum_rack::PARAM_DEFAULTS
639            .iter()
640            .enumerate()
641            .map(|(i, v)| {
642                if i == drum_rack::P_KIT { (1.5f32 / 10.0).to_string() } else { v.to_string() }
643            })
644            .collect();
645        let json = format!(
646            r#"{{"version":1,"instrument":"drums","presets":[{{"name":"old",
647               "instrument":"drums","layout":"{}","param_count":{},"params":[{}]}}]}}"#,
648            layout_fingerprint(InstrumentType::DrumRack),
649            drum_rack::PARAM_COUNT,
650            params.join(",")
651        );
652        std::fs::create_dir_all(&dir).unwrap();
653        std::fs::write(bank_path(&dir, InstrumentType::DrumRack), json).unwrap();
654
655        let bank = load_bank(&dir, InstrumentType::DrumRack).unwrap();
656        assert_eq!(bank.version, 1);
657        assert_eq!(bank.presets[0].version, LEGACY_VERSION, "a missing version is version 1");
658        assert!(bank.presets[0].discrete.is_empty());
659
660        let loaded = bank
661            .params_at(0, InstrumentType::DrumRack, drum_rack::PARAM_COUNT)
662            .unwrap()
663            .expect("an old preset still loads");
664        assert!(loaded.legacy_selectors, "an old preset loaded without a word said");
665        // The fraction is all it has, so it reads as the 707 — which is the
666        // defect, and the reason the bottom bar is told to say so.
667        assert_eq!(
668            drum_rack::discrete_label(drum_rack::P_KIT, loaded.params[drum_rack::P_KIT]),
669            Some("707")
670        );
671
672        let _ = std::fs::remove_dir_all(&dir);
673    }
674
675    /// Every instrument's selectors get stored, not just the ones this file
676    /// happens to name. A panel that gains a switch has to start carrying it
677    /// without this module being edited.
678    #[test]
679    fn every_selector_on_every_instrument_is_stored() {
680        for instrument in InstrumentType::ALL {
681            let count = param_count(*instrument);
682            let panel = vec![0.5f32; count];
683            let mut bank = PresetFile::new(*instrument);
684            bank.store("all", *instrument, &panel).unwrap();
685
686            let stored: Vec<usize> =
687                bank.presets[0].discrete.iter().map(|s| s.param).collect();
688            let wanted: Vec<usize> = (0..count)
689                .filter(|&p| crate::discrete::is_discrete(*instrument, p))
690                .collect();
691            assert_eq!(stored, wanted, "{instrument:?} did not store all of its selectors");
692            assert!(!wanted.is_empty(), "{instrument:?} has no selectors at all");
693        }
694    }
695
696    /// A bank that has never been written is empty rather than an error —
697    /// the first time a player opens the browser there is no file yet.
698    #[test]
699    fn a_bank_that_does_not_exist_is_empty() {
700        let dir = scratch("missing");
701        let bank = load_bank(&dir, InstrumentType::DX7).unwrap();
702        assert!(bank.presets.is_empty());
703        assert_eq!(bank.instrument, "dx7");
704    }
705
706    /// The case this whole mechanism exists for: a preset written when the
707    /// Juno had 16 controls, opened after it grew to 25. Loading it would put
708    /// 16 values into the first 16 of 25 holes — a plausible sound that is not
709    /// the one that was saved.
710    #[test]
711    fn a_preset_with_the_wrong_control_count_is_refused() {
712        let dir = scratch("count");
713        let mut bank = PresetFile::new(InstrumentType::Juno60);
714        bank.store("old panel", InstrumentType::Juno60, &juno_panel()).unwrap();
715        // Rewrite it as the 16-control panel the Juno used to have.
716        bank.presets[0].params.truncate(16);
717        bank.presets[0].param_count = 16;
718        save_bank(&dir, InstrumentType::Juno60, &bank).unwrap();
719
720        let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
721        let want = param_count(InstrumentType::Juno60);
722        assert_eq!(
723            reopened.params_at(0, InstrumentType::Juno60, want).unwrap(),
724            Err(PresetError::ParamCountMismatch { saved: 16, wanted: want })
725        );
726
727        let _ = std::fs::remove_dir_all(&dir);
728    }
729
730    /// A count alone does not catch a reorder. The Odyssey and the phosphor
731    /// synth are different panels; so is one instrument before and after its
732    /// controls were shuffled into front-panel order.
733    #[test]
734    fn a_preset_from_a_reordered_panel_is_refused() {
735        let panel = juno_panel();
736        let mut preset = Preset {
737            name: "reordered".into(),
738            instrument: "juno60".into(),
739            layout: layout_fingerprint(InstrumentType::Juno60),
740            param_count: panel.len(),
741            params: panel,
742            discrete: Vec::new(),
743            version: FORMAT_VERSION,
744        };
745        assert_eq!(preset.check(InstrumentType::Juno60, 25), Ok(()));
746
747        // Same instrument, same count, panel shuffled: the fingerprint moves.
748        preset.layout = "0000000000000000".into();
749        assert!(matches!(
750            preset.check(InstrumentType::Juno60, 25),
751            Err(PresetError::LayoutMismatch { .. })
752        ));
753    }
754
755    /// Reordering the names really does move the fingerprint — the property
756    /// the layout check depends on.
757    #[test]
758    fn the_fingerprint_separates_every_instrument() {
759        let mut seen = Vec::new();
760        for inst in InstrumentType::ALL {
761            let fp = layout_fingerprint(*inst);
762            assert_eq!(fp.len(), 16, "{fp} is not a 64-bit fingerprint");
763            seen.push((inst, fp));
764        }
765        // The sampler shares the phosphor synth's panel, so those two match by
766        // design; every other pair is a different panel.
767        for (a, fa) in &seen {
768            for (b, fb) in &seen {
769                let shared_panel = matches!(
770                    (a, b),
771                    (InstrumentType::Synth, InstrumentType::Sampler)
772                        | (InstrumentType::Sampler, InstrumentType::Synth)
773                );
774                if a != b && !shared_panel {
775                    assert_ne!(fa, fb, "{a:?} and {b:?} fingerprint the same");
776                }
777            }
778        }
779    }
780
781    /// One file per instrument keeps a DX7 preset out of a Juno's browser, but
782    /// a preset that gets hand-copied between files still has to be caught.
783    #[test]
784    fn a_preset_saved_for_another_instrument_is_refused() {
785        let dir = scratch("instrument");
786        let mut dx7 = PresetFile::new(InstrumentType::DX7);
787        dx7.store("e.piano", InstrumentType::DX7, &phosphor_dsp::dx7::PARAM_DEFAULTS).unwrap();
788
789        // As if the player pasted the entry into juno60.json by hand.
790        let mut juno = PresetFile::new(InstrumentType::Juno60);
791        juno.presets.push(dx7.presets[0].clone());
792        save_bank(&dir, InstrumentType::Juno60, &juno).unwrap();
793
794        let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
795        assert_eq!(
796            reopened.params_at(0, InstrumentType::Juno60, 9).unwrap(),
797            Err(PresetError::WrongInstrument {
798                saved: "dx7".into(),
799                wanted: "juno60".into()
800            }),
801            "a DX7 preset loaded into a Juno"
802        );
803
804        // ...and the bank files are separate to begin with.
805        assert_ne!(
806            bank_path(&dir, InstrumentType::DX7),
807            bank_path(&dir, InstrumentType::Juno60)
808        );
809
810        let _ = std::fs::remove_dir_all(&dir);
811    }
812
813    /// Saving under a name the bank already holds rewrites that slot instead
814    /// of adding a second row with the same label.
815    #[test]
816    fn saving_over_a_name_replaces_it_in_place() {
817        let mut bank = PresetFile::new(InstrumentType::Juno60);
818        let mut first = juno_panel();
819        first[phosphor_dsp::juno::P_CUTOFF] = 0.2;
820        let mut second = juno_panel();
821        second[phosphor_dsp::juno::P_CUTOFF] = 0.9;
822
823        bank.store("brass", InstrumentType::Juno60, &first).unwrap();
824        bank.store("strings", InstrumentType::Juno60, &juno_panel()).unwrap();
825        assert_eq!(
826            bank.store("brass", InstrumentType::Juno60, &second),
827            Ok(StoreOutcome::Replaced)
828        );
829
830        assert_eq!(bank.names(), vec!["brass", "strings"], "the slot moved or duplicated");
831        assert_eq!(bank.presets[0].params[phosphor_dsp::juno::P_CUTOFF], 0.9);
832
833        // Whitespace is trimmed, so " brass" is the same slot rather than a
834        // second row the player cannot tell from the first.
835        assert_eq!(
836            bank.store("  brass  ", InstrumentType::Juno60, &first),
837            Ok(StoreOutcome::Replaced)
838        );
839        assert_eq!(bank.presets.len(), 2);
840    }
841
842    /// The bank has a ceiling, and a full bank can still be overwritten.
843    #[test]
844    fn the_bank_stops_at_its_limit() {
845        let mut bank = PresetFile::new(InstrumentType::Juno60);
846        let panel = juno_panel();
847        for i in 0..MAX_PRESETS {
848            bank.store(&format!("p{i}"), InstrumentType::Juno60, &panel).unwrap();
849        }
850        assert_eq!(
851            bank.store("one more", InstrumentType::Juno60, &panel),
852            Err(PresetError::BankFull { max: MAX_PRESETS })
853        );
854        assert_eq!(
855            bank.store("p0", InstrumentType::Juno60, &panel),
856            Ok(StoreOutcome::Replaced),
857            "a full bank became read-only"
858        );
859
860        bank.remove(0);
861        assert_eq!(bank.presets.len(), MAX_PRESETS - 1);
862        assert_eq!(
863            bank.store("one more", InstrumentType::Juno60, &panel),
864            Ok(StoreOutcome::Added)
865        );
866    }
867
868    /// A name has to be something a player can pick out of a list.
869    #[test]
870    fn names_are_bounded_and_non_empty() {
871        let mut bank = PresetFile::new(InstrumentType::Juno60);
872        let panel = juno_panel();
873        assert_eq!(
874            bank.store("   ", InstrumentType::Juno60, &panel),
875            Err(PresetError::NameEmpty)
876        );
877        let long = "x".repeat(MAX_NAME_LEN + 1);
878        assert_eq!(
879            bank.store(&long, InstrumentType::Juno60, &panel),
880            Err(PresetError::NameTooLong { len: MAX_NAME_LEN + 1, max: MAX_NAME_LEN })
881        );
882        assert!(bank.presets.is_empty());
883    }
884
885    /// A hand-edited file whose declared count disagrees with what it carries
886    /// is refused rather than trusted.
887    #[test]
888    fn a_preset_that_contradicts_itself_is_refused() {
889        let panel = juno_panel();
890        let preset = Preset {
891            name: "hand edited".into(),
892            instrument: "juno60".into(),
893            layout: layout_fingerprint(InstrumentType::Juno60),
894            param_count: 99,
895            params: panel.clone(),
896            discrete: Vec::new(),
897            version: FORMAT_VERSION,
898        };
899        assert_eq!(
900            preset.check(InstrumentType::Juno60, panel.len()),
901            Err(PresetError::Corrupt { declared: 99, actual: panel.len() })
902        );
903    }
904
905    /// Every instrument's declared panel size matches the block a new track
906    /// gets, so no instrument is born unable to save a preset.
907    #[test]
908    fn every_instrument_has_a_panel() {
909        for inst in InstrumentType::ALL {
910            assert!(param_count(*inst) > 0, "{inst:?} has no parameters");
911        }
912        assert_eq!(param_count(InstrumentType::Juno60), phosphor_dsp::juno::PARAM_COUNT);
913        assert_eq!(param_count(InstrumentType::Jupiter8), phosphor_dsp::jupiter::PARAM_COUNT);
914        assert_eq!(param_count(InstrumentType::DX7), phosphor_dsp::dx7::PARAM_COUNT);
915        assert_eq!(param_count(InstrumentType::Odyssey), phosphor_dsp::odyssey::PARAM_COUNT);
916        assert_eq!(param_count(InstrumentType::Rhodes), phosphor_dsp::rhodes::PARAM_COUNT);
917        assert_eq!(param_count(InstrumentType::DrumRack), phosphor_dsp::drum_rack::PARAM_COUNT);
918        assert_eq!(param_count(InstrumentType::Synth), phosphor_dsp::synth::PARAM_COUNT);
919        assert_eq!(param_count(InstrumentType::Sampler), phosphor_dsp::synth::PARAM_COUNT);
920    }
921}