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