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//! Human-readable JSON with atomic writes (tmp + rename), the same shape as
15//! the `.phos` session format.
16
17use std::path::{Path, PathBuf};
18
19use anyhow::Result;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22
23use crate::session::instrument_key;
24use crate::state::InstrumentType;
25
26// ── Limits ──
27
28/// Most presets one instrument's bank will hold.
29///
30/// A hardware bank is this size and nobody dials 128 sounds by hand, so the
31/// cap is not a limit anyone reaches on purpose. It exists so a held key or a
32/// script cannot grow the file without bound, and so the browser's list has a
33/// known maximum height.
34pub const MAX_PRESETS: usize = 128;
35
36/// Longest preset name, in characters.
37pub const MAX_NAME_LEN: usize = 32;
38
39/// Current preset file format version.
40pub const FORMAT_VERSION: u32 = 1;
41
42// ── Errors ──
43
44/// Why a preset was refused. Every variant carries the numbers the caller
45/// needs to say what went wrong rather than a pre-formatted sentence.
46#[derive(Debug, Clone, PartialEq, Eq, Error)]
47pub enum PresetError {
48    #[error("saved for the {saved}, not the {wanted}")]
49    WrongInstrument { saved: String, wanted: String },
50
51    #[error("saved with {saved} controls, this instrument has {wanted}")]
52    ParamCountMismatch { saved: usize, wanted: usize },
53
54    #[error("saved against a different panel layout ({saved}, this build is {wanted})")]
55    LayoutMismatch { saved: String, wanted: String },
56
57    #[error("file claims {declared} controls but carries {actual}")]
58    Corrupt { declared: usize, actual: usize },
59
60    #[error("a preset needs a name")]
61    NameEmpty,
62
63    #[error("name is {len} characters, the limit is {max}")]
64    NameTooLong { len: usize, max: usize },
65
66    #[error("this instrument already has {max} presets — delete one first")]
67    BankFull { max: usize },
68}
69
70// ── File format ──
71
72/// One instrument's bank of user presets.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PresetFile {
75    pub version: u32,
76    /// Instrument key this file belongs to, matching the `.phos` spelling.
77    pub instrument: String,
78    pub presets: Vec<Preset>,
79}
80
81/// A single stored preset.
82///
83/// `instrument`, `param_count` and `layout` are all redundant with the file
84/// they sit in — deliberately. A preset that gets hand-copied between files,
85/// or survives a parameter layout change, still carries enough to be refused
86/// rather than loaded into the wrong panel.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct Preset {
89    pub name: String,
90    pub instrument: String,
91    /// Fingerprint of the parameter layout this was saved against.
92    pub layout: String,
93    pub param_count: usize,
94    pub params: Vec<f32>,
95}
96
97/// What `store` did with a name that may or may not have been in the bank.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum StoreOutcome {
100    Added,
101    /// The name was already taken and its slot was rewritten in place.
102    Replaced,
103}
104
105// ── Parameter layout ──
106
107/// The parameter names for an instrument, in panel order.
108///
109/// The sampler shares the phosphor synth's engine and therefore its panel;
110/// its presets still live in their own file, because the two are separate
111/// instruments as far as the player is concerned.
112pub fn param_names(instrument: InstrumentType) -> &'static [&'static str] {
113    match instrument {
114        InstrumentType::Synth | InstrumentType::Sampler => &phosphor_dsp::synth::PARAM_NAMES,
115        InstrumentType::DrumRack => &phosphor_dsp::drum_rack::PARAM_NAMES,
116        InstrumentType::DX7 => &phosphor_dsp::dx7::PARAM_NAMES,
117        InstrumentType::Jupiter8 => &phosphor_dsp::jupiter::PARAM_NAMES,
118        InstrumentType::Odyssey => &phosphor_dsp::odyssey::PARAM_NAMES,
119        InstrumentType::Juno60 => &phosphor_dsp::juno::PARAM_NAMES,
120    }
121}
122
123/// A fingerprint of an instrument's parameter layout.
124///
125/// A count alone does not catch a reorder, and this project reorders: the
126/// Juno went from 16 controls to 25, the Jupiter from 16 to 32 and the drum
127/// rack from 6 to 35, and each of those rewrote the order of the slots it
128/// already had into front-panel order. A preset saved against the old order
129/// has the right number of values in the wrong holes, which is exactly the
130/// silent-and-plausible failure the count check exists to prevent.
131///
132/// So the fingerprint is derived from the parameter *names* rather than
133/// declared by hand: a version integer someone has to remember to bump is a
134/// version integer that eventually does not get bumped. Renaming a control
135/// invalidates presets that would otherwise still be correct — the trade is
136/// deliberate, because a refusal is recoverable and a wrong sound is not.
137///
138/// FNV-1a rather than `DefaultHasher`, whose algorithm the standard library
139/// explicitly reserves the right to change; this value goes in a file.
140pub fn layout_fingerprint(instrument: InstrumentType) -> String {
141    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
142    const PRIME: u64 = 0x0000_0100_0000_01b3;
143
144    let mut hash = OFFSET;
145    for name in param_names(instrument) {
146        // The separator keeps ["ab", "c"] from hashing as ["a", "bc"].
147        for byte in name.bytes().chain(std::iter::once(0xff)) {
148            hash ^= u64::from(byte);
149            hash = hash.wrapping_mul(PRIME);
150        }
151    }
152    format!("{hash:016x}")
153}
154
155/// How many parameters this instrument's panel has.
156pub fn param_count(instrument: InstrumentType) -> usize {
157    param_names(instrument).len()
158}
159
160// ── Paths ──
161
162/// `~/.phosphor/presets`, or `None` when HOME is unset.
163///
164/// Same shape as the theme's config path: with no home directory there is
165/// nowhere to put presets, and the answer is to do nothing rather than to
166/// scatter files relative to the working directory.
167pub fn default_dir() -> Option<PathBuf> {
168    std::env::var("HOME")
169        .ok()
170        .map(|home| PathBuf::from(home).join(".phosphor").join("presets"))
171}
172
173/// The bank file for one instrument inside `dir`.
174pub fn bank_path(dir: &Path, instrument: InstrumentType) -> PathBuf {
175    dir.join(format!("{}.json", instrument_key(instrument)))
176}
177
178// ── Load / save ──
179
180/// Read an instrument's bank. A bank that has never been written is empty,
181/// not an error — that is the first-run case. A bank that exists but does not
182/// parse *is* an error, so a corrupt file is reported rather than silently
183/// replaced the next time the player saves.
184pub fn load_bank(dir: &Path, instrument: InstrumentType) -> Result<PresetFile> {
185    let path = bank_path(dir, instrument);
186    if !path.exists() {
187        return Ok(PresetFile::new(instrument));
188    }
189    let json = std::fs::read_to_string(&path)?;
190    let bank: PresetFile = serde_json::from_str(&json)?;
191    Ok(bank)
192}
193
194/// Write an instrument's bank. Atomic: tmp file then rename, so an interrupted
195/// write cannot leave a half-written bank where the whole bank used to be.
196pub fn save_bank(dir: &Path, instrument: InstrumentType, bank: &PresetFile) -> Result<()> {
197    std::fs::create_dir_all(dir)?;
198    let path = bank_path(dir, instrument);
199    let json = serde_json::to_string_pretty(bank)?;
200
201    let tmp = path.with_extension("json.tmp");
202    std::fs::write(&tmp, &json)?;
203    std::fs::rename(&tmp, &path)?;
204
205    tracing::debug!("preset bank saved: {} ({} presets)", path.display(), bank.presets.len());
206    Ok(())
207}
208
209// ── Bank operations ──
210
211impl PresetFile {
212    pub fn new(instrument: InstrumentType) -> Self {
213        Self {
214            version: FORMAT_VERSION,
215            instrument: instrument_key(instrument).to_string(),
216            presets: Vec::new(),
217        }
218    }
219
220    /// Preset names in file order — what the browser lists.
221    pub fn names(&self) -> Vec<&str> {
222        self.presets.iter().map(|p| p.name.as_str()).collect()
223    }
224
225    /// Index of the preset with this name, if the bank holds one.
226    ///
227    /// Names are compared after trimming, so " pad" and "pad " are the same
228    /// slot rather than two rows the player cannot tell apart.
229    pub fn find(&self, name: &str) -> Option<usize> {
230        let name = name.trim();
231        self.presets.iter().position(|p| p.name == name)
232    }
233
234    /// Store `params` under `name`.
235    ///
236    /// A name already in the bank rewrites that slot in place rather than
237    /// adding a second row: the browser lists by name, so two rows called
238    /// "warm pad" are two rows the player cannot choose between, and `d`
239    /// would delete an arbitrary one of them. Callers are expected to confirm
240    /// before overwriting — `find` says whether they need to.
241    pub fn store(
242        &mut self,
243        name: &str,
244        instrument: InstrumentType,
245        params: &[f32],
246    ) -> Result<StoreOutcome, PresetError> {
247        let name = name.trim();
248        if name.is_empty() {
249            return Err(PresetError::NameEmpty);
250        }
251        let len = name.chars().count();
252        if len > MAX_NAME_LEN {
253            return Err(PresetError::NameTooLong { len, max: MAX_NAME_LEN });
254        }
255
256        let preset = Preset {
257            name: name.to_string(),
258            instrument: instrument_key(instrument).to_string(),
259            layout: layout_fingerprint(instrument),
260            param_count: params.len(),
261            params: params.to_vec(),
262        };
263
264        match self.find(name) {
265            Some(idx) => {
266                self.presets[idx] = preset;
267                Ok(StoreOutcome::Replaced)
268            }
269            None => {
270                // Only a new slot can overflow the bank; overwriting a name
271                // stays legal at the cap, which is what a full bank needs to
272                // remain usable.
273                if self.presets.len() >= MAX_PRESETS {
274                    return Err(PresetError::BankFull { max: MAX_PRESETS });
275                }
276                self.presets.push(preset);
277                Ok(StoreOutcome::Added)
278            }
279        }
280    }
281
282    /// Remove the preset at `index`, returning it.
283    pub fn remove(&mut self, index: usize) -> Option<Preset> {
284        (index < self.presets.len()).then(|| self.presets.remove(index))
285    }
286
287    /// The parameter block at `index`, if it is safe to load into this
288    /// instrument's current panel.
289    pub fn params_at(
290        &self,
291        index: usize,
292        instrument: InstrumentType,
293        want_count: usize,
294    ) -> Option<Result<&[f32], PresetError>> {
295        let preset = self.presets.get(index)?;
296        Some(preset.check(instrument, want_count).map(|()| preset.params.as_slice()))
297    }
298}
299
300impl Preset {
301    /// Whether this preset can be loaded into `instrument`'s current panel of
302    /// `want_count` controls. Refuses rather than truncating or padding: a
303    /// block that is the wrong length or the wrong order is a different panel,
304    /// and copying it in slot by slot produces a plausible wrong sound with
305    /// nothing on screen to say so.
306    pub fn check(&self, instrument: InstrumentType, want_count: usize) -> Result<(), PresetError> {
307        let wanted_key = instrument_key(instrument);
308        if self.instrument != wanted_key {
309            return Err(PresetError::WrongInstrument {
310                saved: self.instrument.clone(),
311                wanted: wanted_key.to_string(),
312            });
313        }
314        if self.param_count != self.params.len() {
315            return Err(PresetError::Corrupt {
316                declared: self.param_count,
317                actual: self.params.len(),
318            });
319        }
320        if self.params.len() != want_count {
321            return Err(PresetError::ParamCountMismatch {
322                saved: self.params.len(),
323                wanted: want_count,
324            });
325        }
326        let wanted_layout = layout_fingerprint(instrument);
327        if self.layout != wanted_layout {
328            return Err(PresetError::LayoutMismatch {
329                saved: self.layout.clone(),
330                wanted: wanted_layout,
331            });
332        }
333        Ok(())
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    /// A scratch directory of our own, so the tests never touch the player's
342    /// `~/.phosphor` and can run beside each other.
343    fn scratch(tag: &str) -> PathBuf {
344        let dir = std::env::temp_dir()
345            .join(format!("phosphor-presets-{}-{tag}", std::process::id()));
346        let _ = std::fs::remove_dir_all(&dir);
347        dir
348    }
349
350    fn juno_panel() -> Vec<f32> {
351        phosphor_dsp::juno::PARAM_DEFAULTS.to_vec()
352    }
353
354    /// A preset written to disk comes back with every value bit-identical.
355    /// f32 through JSON is the part worth checking: a value that round-trips
356    /// to within a rounding error is a filter cutoff that moved.
357    #[test]
358    fn a_preset_round_trips_through_the_file() {
359        let dir = scratch("round-trip");
360        let mut panel = juno_panel();
361        panel[phosphor_dsp::juno::P_CUTOFF] = 0.317_25;
362        panel[phosphor_dsp::juno::P_RESO] = 0.812_5;
363        panel[phosphor_dsp::juno::P_PATCH] = 0.437_1;
364
365        let mut bank = PresetFile::new(InstrumentType::Juno60);
366        assert_eq!(
367            bank.store("evening pad", InstrumentType::Juno60, &panel),
368            Ok(StoreOutcome::Added)
369        );
370        save_bank(&dir, InstrumentType::Juno60, &bank).unwrap();
371
372        let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
373        assert_eq!(reopened.names(), vec!["evening pad"]);
374        let loaded = reopened
375            .params_at(0, InstrumentType::Juno60, panel.len())
376            .unwrap()
377            .expect("its own panel should load");
378        assert_eq!(loaded, panel.as_slice(), "the panel came back changed");
379
380        let _ = std::fs::remove_dir_all(&dir);
381    }
382
383    /// A bank that has never been written is empty rather than an error —
384    /// the first time a player opens the browser there is no file yet.
385    #[test]
386    fn a_bank_that_does_not_exist_is_empty() {
387        let dir = scratch("missing");
388        let bank = load_bank(&dir, InstrumentType::DX7).unwrap();
389        assert!(bank.presets.is_empty());
390        assert_eq!(bank.instrument, "dx7");
391    }
392
393    /// The case this whole mechanism exists for: a preset written when the
394    /// Juno had 16 controls, opened after it grew to 25. Loading it would put
395    /// 16 values into the first 16 of 25 holes — a plausible sound that is not
396    /// the one that was saved.
397    #[test]
398    fn a_preset_with_the_wrong_control_count_is_refused() {
399        let dir = scratch("count");
400        let mut bank = PresetFile::new(InstrumentType::Juno60);
401        bank.store("old panel", InstrumentType::Juno60, &juno_panel()).unwrap();
402        // Rewrite it as the 16-control panel the Juno used to have.
403        bank.presets[0].params.truncate(16);
404        bank.presets[0].param_count = 16;
405        save_bank(&dir, InstrumentType::Juno60, &bank).unwrap();
406
407        let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
408        let want = param_count(InstrumentType::Juno60);
409        assert_eq!(
410            reopened.params_at(0, InstrumentType::Juno60, want).unwrap(),
411            Err(PresetError::ParamCountMismatch { saved: 16, wanted: want })
412        );
413
414        let _ = std::fs::remove_dir_all(&dir);
415    }
416
417    /// A count alone does not catch a reorder. The Odyssey and the phosphor
418    /// synth are different panels; so is one instrument before and after its
419    /// controls were shuffled into front-panel order.
420    #[test]
421    fn a_preset_from_a_reordered_panel_is_refused() {
422        let panel = juno_panel();
423        let mut preset = Preset {
424            name: "reordered".into(),
425            instrument: "juno60".into(),
426            layout: layout_fingerprint(InstrumentType::Juno60),
427            param_count: panel.len(),
428            params: panel,
429        };
430        assert_eq!(preset.check(InstrumentType::Juno60, 25), Ok(()));
431
432        // Same instrument, same count, panel shuffled: the fingerprint moves.
433        preset.layout = "0000000000000000".into();
434        assert!(matches!(
435            preset.check(InstrumentType::Juno60, 25),
436            Err(PresetError::LayoutMismatch { .. })
437        ));
438    }
439
440    /// Reordering the names really does move the fingerprint — the property
441    /// the layout check depends on.
442    #[test]
443    fn the_fingerprint_separates_every_instrument() {
444        let mut seen = Vec::new();
445        for inst in InstrumentType::ALL {
446            let fp = layout_fingerprint(*inst);
447            assert_eq!(fp.len(), 16, "{fp} is not a 64-bit fingerprint");
448            seen.push((inst, fp));
449        }
450        // The sampler shares the phosphor synth's panel, so those two match by
451        // design; every other pair is a different panel.
452        for (a, fa) in &seen {
453            for (b, fb) in &seen {
454                let shared_panel = matches!(
455                    (a, b),
456                    (InstrumentType::Synth, InstrumentType::Sampler)
457                        | (InstrumentType::Sampler, InstrumentType::Synth)
458                );
459                if a != b && !shared_panel {
460                    assert_ne!(fa, fb, "{a:?} and {b:?} fingerprint the same");
461                }
462            }
463        }
464    }
465
466    /// One file per instrument keeps a DX7 preset out of a Juno's browser, but
467    /// a preset that gets hand-copied between files still has to be caught.
468    #[test]
469    fn a_preset_saved_for_another_instrument_is_refused() {
470        let dir = scratch("instrument");
471        let mut dx7 = PresetFile::new(InstrumentType::DX7);
472        dx7.store("e.piano", InstrumentType::DX7, &phosphor_dsp::dx7::PARAM_DEFAULTS).unwrap();
473
474        // As if the player pasted the entry into juno60.json by hand.
475        let mut juno = PresetFile::new(InstrumentType::Juno60);
476        juno.presets.push(dx7.presets[0].clone());
477        save_bank(&dir, InstrumentType::Juno60, &juno).unwrap();
478
479        let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
480        assert_eq!(
481            reopened.params_at(0, InstrumentType::Juno60, 9).unwrap(),
482            Err(PresetError::WrongInstrument {
483                saved: "dx7".into(),
484                wanted: "juno60".into()
485            }),
486            "a DX7 preset loaded into a Juno"
487        );
488
489        // ...and the bank files are separate to begin with.
490        assert_ne!(
491            bank_path(&dir, InstrumentType::DX7),
492            bank_path(&dir, InstrumentType::Juno60)
493        );
494
495        let _ = std::fs::remove_dir_all(&dir);
496    }
497
498    /// Saving under a name the bank already holds rewrites that slot instead
499    /// of adding a second row with the same label.
500    #[test]
501    fn saving_over_a_name_replaces_it_in_place() {
502        let mut bank = PresetFile::new(InstrumentType::Juno60);
503        let mut first = juno_panel();
504        first[phosphor_dsp::juno::P_CUTOFF] = 0.2;
505        let mut second = juno_panel();
506        second[phosphor_dsp::juno::P_CUTOFF] = 0.9;
507
508        bank.store("brass", InstrumentType::Juno60, &first).unwrap();
509        bank.store("strings", InstrumentType::Juno60, &juno_panel()).unwrap();
510        assert_eq!(
511            bank.store("brass", InstrumentType::Juno60, &second),
512            Ok(StoreOutcome::Replaced)
513        );
514
515        assert_eq!(bank.names(), vec!["brass", "strings"], "the slot moved or duplicated");
516        assert_eq!(bank.presets[0].params[phosphor_dsp::juno::P_CUTOFF], 0.9);
517
518        // Whitespace is trimmed, so " brass" is the same slot rather than a
519        // second row the player cannot tell from the first.
520        assert_eq!(
521            bank.store("  brass  ", InstrumentType::Juno60, &first),
522            Ok(StoreOutcome::Replaced)
523        );
524        assert_eq!(bank.presets.len(), 2);
525    }
526
527    /// The bank has a ceiling, and a full bank can still be overwritten.
528    #[test]
529    fn the_bank_stops_at_its_limit() {
530        let mut bank = PresetFile::new(InstrumentType::Juno60);
531        let panel = juno_panel();
532        for i in 0..MAX_PRESETS {
533            bank.store(&format!("p{i}"), InstrumentType::Juno60, &panel).unwrap();
534        }
535        assert_eq!(
536            bank.store("one more", InstrumentType::Juno60, &panel),
537            Err(PresetError::BankFull { max: MAX_PRESETS })
538        );
539        assert_eq!(
540            bank.store("p0", InstrumentType::Juno60, &panel),
541            Ok(StoreOutcome::Replaced),
542            "a full bank became read-only"
543        );
544
545        bank.remove(0);
546        assert_eq!(bank.presets.len(), MAX_PRESETS - 1);
547        assert_eq!(
548            bank.store("one more", InstrumentType::Juno60, &panel),
549            Ok(StoreOutcome::Added)
550        );
551    }
552
553    /// A name has to be something a player can pick out of a list.
554    #[test]
555    fn names_are_bounded_and_non_empty() {
556        let mut bank = PresetFile::new(InstrumentType::Juno60);
557        let panel = juno_panel();
558        assert_eq!(
559            bank.store("   ", InstrumentType::Juno60, &panel),
560            Err(PresetError::NameEmpty)
561        );
562        let long = "x".repeat(MAX_NAME_LEN + 1);
563        assert_eq!(
564            bank.store(&long, InstrumentType::Juno60, &panel),
565            Err(PresetError::NameTooLong { len: MAX_NAME_LEN + 1, max: MAX_NAME_LEN })
566        );
567        assert!(bank.presets.is_empty());
568    }
569
570    /// A hand-edited file whose declared count disagrees with what it carries
571    /// is refused rather than trusted.
572    #[test]
573    fn a_preset_that_contradicts_itself_is_refused() {
574        let panel = juno_panel();
575        let preset = Preset {
576            name: "hand edited".into(),
577            instrument: "juno60".into(),
578            layout: layout_fingerprint(InstrumentType::Juno60),
579            param_count: 99,
580            params: panel.clone(),
581        };
582        assert_eq!(
583            preset.check(InstrumentType::Juno60, panel.len()),
584            Err(PresetError::Corrupt { declared: 99, actual: panel.len() })
585        );
586    }
587
588    /// Every instrument's declared panel size matches the block a new track
589    /// gets, so no instrument is born unable to save a preset.
590    #[test]
591    fn every_instrument_has_a_panel() {
592        for inst in InstrumentType::ALL {
593            assert!(param_count(*inst) > 0, "{inst:?} has no parameters");
594        }
595        assert_eq!(param_count(InstrumentType::Juno60), phosphor_dsp::juno::PARAM_COUNT);
596        assert_eq!(param_count(InstrumentType::Jupiter8), phosphor_dsp::jupiter::PARAM_COUNT);
597        assert_eq!(param_count(InstrumentType::DX7), phosphor_dsp::dx7::PARAM_COUNT);
598        assert_eq!(param_count(InstrumentType::Odyssey), phosphor_dsp::odyssey::PARAM_COUNT);
599        assert_eq!(param_count(InstrumentType::DrumRack), phosphor_dsp::drum_rack::PARAM_COUNT);
600        assert_eq!(param_count(InstrumentType::Synth), phosphor_dsp::synth::PARAM_COUNT);
601        assert_eq!(param_count(InstrumentType::Sampler), phosphor_dsp::synth::PARAM_COUNT);
602    }
603}