Skip to main content

phosphor_app/
preset.rs

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