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