use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::session::{instrument_key, SessionSelector};
use crate::state::InstrumentType;
pub const MAX_PRESETS: usize = 128;
pub const MAX_NAME_LEN: usize = 32;
pub const FORMAT_VERSION: u32 = 2;
const LEGACY_VERSION: u32 = 1;
const fn legacy_version() -> u32 {
LEGACY_VERSION
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PresetError {
#[error("saved for the {saved}, not the {wanted}")]
WrongInstrument { saved: String, wanted: String },
#[error("saved with {saved} controls, this instrument has {wanted}")]
ParamCountMismatch { saved: usize, wanted: usize },
#[error("saved against a different panel layout ({saved}, this build is {wanted})")]
LayoutMismatch { saved: String, wanted: String },
#[error("file claims {declared} controls but carries {actual}")]
Corrupt { declared: usize, actual: usize },
#[error("a preset needs a name")]
NameEmpty,
#[error("name is {len} characters, the limit is {max}")]
NameTooLong { len: usize, max: usize },
#[error("this instrument already has {max} presets — delete one first")]
BankFull { max: usize },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresetFile {
pub version: u32,
pub instrument: String,
pub presets: Vec<Preset>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Preset {
pub name: String,
pub instrument: String,
pub layout: String,
pub param_count: usize,
pub params: Vec<f32>,
#[serde(default)]
pub discrete: Vec<SessionSelector>,
#[serde(default = "legacy_version")]
pub version: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedPreset {
pub params: Vec<f32>,
pub clamped: Vec<(usize, usize, usize)>,
pub legacy_selectors: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreOutcome {
Added,
Replaced,
}
pub fn param_names(instrument: InstrumentType) -> &'static [&'static str] {
match instrument {
InstrumentType::Synth | InstrumentType::Sampler => &phosphor_dsp::synth::PARAM_NAMES,
InstrumentType::DrumRack => &phosphor_dsp::drum_rack::PARAM_NAMES,
InstrumentType::DX7 => &phosphor_dsp::dx7::PARAM_NAMES,
InstrumentType::Jupiter8 => &phosphor_dsp::jupiter::PARAM_NAMES,
InstrumentType::Odyssey => &phosphor_dsp::odyssey::PARAM_NAMES,
InstrumentType::Juno60 => &phosphor_dsp::juno::PARAM_NAMES,
}
}
pub fn layout_fingerprint(instrument: InstrumentType) -> String {
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET;
for name in param_names(instrument) {
for byte in name.bytes().chain(std::iter::once(0xff)) {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(PRIME);
}
}
format!("{hash:016x}")
}
pub fn param_count(instrument: InstrumentType) -> usize {
param_names(instrument).len()
}
pub fn default_dir() -> Option<PathBuf> {
std::env::var("HOME")
.ok()
.map(|home| PathBuf::from(home).join(".phosphor").join("presets"))
}
pub fn bank_path(dir: &Path, instrument: InstrumentType) -> PathBuf {
dir.join(format!("{}.json", instrument_key(instrument)))
}
pub fn load_bank(dir: &Path, instrument: InstrumentType) -> Result<PresetFile> {
let path = bank_path(dir, instrument);
if !path.exists() {
return Ok(PresetFile::new(instrument));
}
let json = std::fs::read_to_string(&path)?;
let bank: PresetFile = serde_json::from_str(&json)?;
Ok(bank)
}
pub fn save_bank(dir: &Path, instrument: InstrumentType, bank: &PresetFile) -> Result<()> {
std::fs::create_dir_all(dir)?;
let path = bank_path(dir, instrument);
let json = serde_json::to_string_pretty(bank)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &path)?;
tracing::debug!("preset bank saved: {} ({} presets)", path.display(), bank.presets.len());
Ok(())
}
impl PresetFile {
pub fn new(instrument: InstrumentType) -> Self {
Self {
version: FORMAT_VERSION,
instrument: instrument_key(instrument).to_string(),
presets: Vec::new(),
}
}
pub fn names(&self) -> Vec<&str> {
self.presets.iter().map(|p| p.name.as_str()).collect()
}
pub fn find(&self, name: &str) -> Option<usize> {
let name = name.trim();
self.presets.iter().position(|p| p.name == name)
}
pub fn store(
&mut self,
name: &str,
instrument: InstrumentType,
params: &[f32],
) -> Result<StoreOutcome, PresetError> {
let name = name.trim();
if name.is_empty() {
return Err(PresetError::NameEmpty);
}
let len = name.chars().count();
if len > MAX_NAME_LEN {
return Err(PresetError::NameTooLong { len, max: MAX_NAME_LEN });
}
let preset = Preset {
name: name.to_string(),
instrument: instrument_key(instrument).to_string(),
layout: layout_fingerprint(instrument),
param_count: params.len(),
params: params.to_vec(),
discrete: crate::session::selectors_of(instrument, params),
version: FORMAT_VERSION,
};
let outcome = match self.find(name) {
Some(idx) => {
self.presets[idx] = preset;
StoreOutcome::Replaced
}
None => {
if self.presets.len() >= MAX_PRESETS {
return Err(PresetError::BankFull { max: MAX_PRESETS });
}
self.presets.push(preset);
StoreOutcome::Added
}
};
self.version = FORMAT_VERSION;
Ok(outcome)
}
pub fn remove(&mut self, index: usize) -> Option<Preset> {
(index < self.presets.len()).then(|| self.presets.remove(index))
}
pub fn params_at(
&self,
index: usize,
instrument: InstrumentType,
want_count: usize,
) -> Option<Result<LoadedPreset, PresetError>> {
let preset = self.presets.get(index)?;
Some(preset.check(instrument, want_count).map(|()| preset.resolve(instrument)))
}
}
impl Preset {
pub fn check(&self, instrument: InstrumentType, want_count: usize) -> Result<(), PresetError> {
let wanted_key = instrument_key(instrument);
if self.instrument != wanted_key {
return Err(PresetError::WrongInstrument {
saved: self.instrument.clone(),
wanted: wanted_key.to_string(),
});
}
if self.param_count != self.params.len() {
return Err(PresetError::Corrupt {
declared: self.param_count,
actual: self.params.len(),
});
}
if self.params.len() != want_count {
return Err(PresetError::ParamCountMismatch {
saved: self.params.len(),
wanted: want_count,
});
}
let wanted_layout = layout_fingerprint(instrument);
if self.layout != wanted_layout {
return Err(PresetError::LayoutMismatch {
saved: self.layout.clone(),
wanted: wanted_layout,
});
}
Ok(())
}
#[must_use]
pub fn resolve(&self, instrument: InstrumentType) -> LoadedPreset {
let mut params = self.params.clone();
let clamped = crate::session::apply_selectors(instrument, &mut params, &self.discrete);
LoadedPreset {
params,
clamped,
legacy_selectors: self.version < FORMAT_VERSION,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir()
.join(format!("phosphor-presets-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn juno_panel() -> Vec<f32> {
phosphor_dsp::juno::PARAM_DEFAULTS.to_vec()
}
#[test]
fn a_preset_round_trips_through_the_file() {
let dir = scratch("round-trip");
let mut panel = juno_panel();
panel[phosphor_dsp::juno::P_CUTOFF] = 0.317_25;
panel[phosphor_dsp::juno::P_RESO] = 0.812_5;
panel[phosphor_dsp::juno::P_PATCH] = phosphor_dsp::juno::patch_knob(24);
let mut bank = PresetFile::new(InstrumentType::Juno60);
assert_eq!(
bank.store("evening pad", InstrumentType::Juno60, &panel),
Ok(StoreOutcome::Added)
);
save_bank(&dir, InstrumentType::Juno60, &bank).unwrap();
let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
assert_eq!(reopened.names(), vec!["evening pad"]);
let loaded = reopened
.params_at(0, InstrumentType::Juno60, panel.len())
.unwrap()
.expect("its own panel should load");
for (index, (before, after)) in panel.iter().zip(loaded.params.iter()).enumerate() {
if crate::discrete::is_discrete(InstrumentType::Juno60, index) {
assert_eq!(
crate::discrete::index_of(InstrumentType::Juno60, index, *after),
crate::discrete::index_of(InstrumentType::Juno60, index, *before),
"control {index} came back on a different position"
);
} else {
assert_eq!(before, after, "control {index} came back changed");
}
}
assert!(loaded.clamped.is_empty());
assert!(!loaded.legacy_selectors, "a preset written now is not an old one");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_selector_survives_the_bank_growing() {
use phosphor_dsp::drum_rack;
let dir = scratch("bank-grew");
let mut panel = drum_rack::PARAM_DEFAULTS.to_vec();
panel[drum_rack::P_KIT] = drum_rack::kit_knob(1);
assert_eq!(
drum_rack::discrete_label(drum_rack::P_KIT, panel[drum_rack::P_KIT]),
Some("909"),
"this test is pinned to the 909 being position 1"
);
let mut bank = PresetFile::new(InstrumentType::DrumRack);
bank.store("my kit", InstrumentType::DrumRack, &panel).unwrap();
assert_eq!(
bank.presets[0].discrete.iter().find(|s| s.param == drum_rack::P_KIT),
Some(&SessionSelector { param: drum_rack::P_KIT, index: 1 }),
"the kit was not stored by position"
);
bank.presets[0].params[drum_rack::P_KIT] = 1.5 / 10.0;
save_bank(&dir, InstrumentType::DrumRack, &bank).unwrap();
let reopened = load_bank(&dir, InstrumentType::DrumRack).unwrap();
assert_eq!(
drum_rack::discrete_label(drum_rack::P_KIT, reopened.presets[0].params[drum_rack::P_KIT]),
Some("707"),
"the fraction no longer names the 707, so this test proves nothing"
);
let loaded = reopened
.params_at(0, InstrumentType::DrumRack, panel.len())
.unwrap()
.expect("its own panel should load");
assert_eq!(
drum_rack::discrete_label(drum_rack::P_KIT, loaded.params[drum_rack::P_KIT]),
Some("909"),
"the preset opened on a different drum machine"
);
assert!(loaded.clamped.is_empty());
assert!(!loaded.legacy_selectors);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn both_dx7_selectors_survive_a_round_trip() {
use phosphor_dsp::dx7;
let mut panel = dx7::PARAM_DEFAULTS.to_vec();
let (bank_knob, patch_knob) = dx7::voice_knobs(147);
panel[dx7::P_BANK] = bank_knob;
panel[dx7::P_PATCH] = patch_knob;
let mut bank = PresetFile::new(InstrumentType::DX7);
bank.store("timpani", InstrumentType::DX7, &panel).unwrap();
let stored: Vec<usize> = bank.presets[0].discrete.iter().map(|s| s.param).collect();
assert!(stored.contains(&dx7::P_BANK), "the cartridge was not stored");
assert!(stored.contains(&dx7::P_PATCH), "the voice was not stored");
bank.presets[0].params[dx7::P_BANK] = 0.0;
bank.presets[0].params[dx7::P_PATCH] = 0.0;
let loaded = bank
.params_at(0, InstrumentType::DX7, panel.len())
.unwrap()
.expect("its own panel should load");
assert_eq!(loaded.params[dx7::P_BANK], bank_knob, "the cartridge did not come back");
assert_eq!(loaded.params[dx7::P_PATCH], patch_knob, "the voice did not come back");
}
#[test]
fn a_selector_past_the_end_of_the_bank_is_clamped_and_reported() {
use phosphor_dsp::drum_rack;
let panel = drum_rack::PARAM_DEFAULTS.to_vec();
let mut bank = PresetFile::new(InstrumentType::DrumRack);
bank.store("from the future", InstrumentType::DrumRack, &panel).unwrap();
let selector = bank.presets[0]
.discrete
.iter_mut()
.find(|s| s.param == drum_rack::P_KIT)
.expect("the kit is a selector");
selector.index = 900;
let loaded = bank
.params_at(0, InstrumentType::DrumRack, panel.len())
.unwrap()
.expect("its own panel should load");
assert_eq!(
loaded.clamped,
vec![(drum_rack::P_KIT, 900, drum_rack::KIT_COUNT - 1)],
"a position the rack no longer has was not reported"
);
assert_eq!(
loaded.params[drum_rack::P_KIT],
drum_rack::kit_knob(drum_rack::KIT_COUNT - 1),
"the kit did not land on the last one the rack has"
);
}
#[test]
fn a_version_1_preset_loads_from_its_fractions_and_says_so() {
use phosphor_dsp::drum_rack;
let dir = scratch("version-1");
let params: Vec<String> = drum_rack::PARAM_DEFAULTS
.iter()
.enumerate()
.map(|(i, v)| {
if i == drum_rack::P_KIT { (1.5f32 / 10.0).to_string() } else { v.to_string() }
})
.collect();
let json = format!(
r#"{{"version":1,"instrument":"drums","presets":[{{"name":"old",
"instrument":"drums","layout":"{}","param_count":{},"params":[{}]}}]}}"#,
layout_fingerprint(InstrumentType::DrumRack),
drum_rack::PARAM_COUNT,
params.join(",")
);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(bank_path(&dir, InstrumentType::DrumRack), json).unwrap();
let bank = load_bank(&dir, InstrumentType::DrumRack).unwrap();
assert_eq!(bank.version, 1);
assert_eq!(bank.presets[0].version, LEGACY_VERSION, "a missing version is version 1");
assert!(bank.presets[0].discrete.is_empty());
let loaded = bank
.params_at(0, InstrumentType::DrumRack, drum_rack::PARAM_COUNT)
.unwrap()
.expect("an old preset still loads");
assert!(loaded.legacy_selectors, "an old preset loaded without a word said");
assert_eq!(
drum_rack::discrete_label(drum_rack::P_KIT, loaded.params[drum_rack::P_KIT]),
Some("707")
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn every_selector_on_every_instrument_is_stored() {
for instrument in InstrumentType::ALL {
let count = param_count(*instrument);
let panel = vec![0.5f32; count];
let mut bank = PresetFile::new(*instrument);
bank.store("all", *instrument, &panel).unwrap();
let stored: Vec<usize> =
bank.presets[0].discrete.iter().map(|s| s.param).collect();
let wanted: Vec<usize> = (0..count)
.filter(|&p| crate::discrete::is_discrete(*instrument, p))
.collect();
assert_eq!(stored, wanted, "{instrument:?} did not store all of its selectors");
assert!(!wanted.is_empty(), "{instrument:?} has no selectors at all");
}
}
#[test]
fn a_bank_that_does_not_exist_is_empty() {
let dir = scratch("missing");
let bank = load_bank(&dir, InstrumentType::DX7).unwrap();
assert!(bank.presets.is_empty());
assert_eq!(bank.instrument, "dx7");
}
#[test]
fn a_preset_with_the_wrong_control_count_is_refused() {
let dir = scratch("count");
let mut bank = PresetFile::new(InstrumentType::Juno60);
bank.store("old panel", InstrumentType::Juno60, &juno_panel()).unwrap();
bank.presets[0].params.truncate(16);
bank.presets[0].param_count = 16;
save_bank(&dir, InstrumentType::Juno60, &bank).unwrap();
let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
let want = param_count(InstrumentType::Juno60);
assert_eq!(
reopened.params_at(0, InstrumentType::Juno60, want).unwrap(),
Err(PresetError::ParamCountMismatch { saved: 16, wanted: want })
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_preset_from_a_reordered_panel_is_refused() {
let panel = juno_panel();
let mut preset = Preset {
name: "reordered".into(),
instrument: "juno60".into(),
layout: layout_fingerprint(InstrumentType::Juno60),
param_count: panel.len(),
params: panel,
discrete: Vec::new(),
version: FORMAT_VERSION,
};
assert_eq!(preset.check(InstrumentType::Juno60, 25), Ok(()));
preset.layout = "0000000000000000".into();
assert!(matches!(
preset.check(InstrumentType::Juno60, 25),
Err(PresetError::LayoutMismatch { .. })
));
}
#[test]
fn the_fingerprint_separates_every_instrument() {
let mut seen = Vec::new();
for inst in InstrumentType::ALL {
let fp = layout_fingerprint(*inst);
assert_eq!(fp.len(), 16, "{fp} is not a 64-bit fingerprint");
seen.push((inst, fp));
}
for (a, fa) in &seen {
for (b, fb) in &seen {
let shared_panel = matches!(
(a, b),
(InstrumentType::Synth, InstrumentType::Sampler)
| (InstrumentType::Sampler, InstrumentType::Synth)
);
if a != b && !shared_panel {
assert_ne!(fa, fb, "{a:?} and {b:?} fingerprint the same");
}
}
}
}
#[test]
fn a_preset_saved_for_another_instrument_is_refused() {
let dir = scratch("instrument");
let mut dx7 = PresetFile::new(InstrumentType::DX7);
dx7.store("e.piano", InstrumentType::DX7, &phosphor_dsp::dx7::PARAM_DEFAULTS).unwrap();
let mut juno = PresetFile::new(InstrumentType::Juno60);
juno.presets.push(dx7.presets[0].clone());
save_bank(&dir, InstrumentType::Juno60, &juno).unwrap();
let reopened = load_bank(&dir, InstrumentType::Juno60).unwrap();
assert_eq!(
reopened.params_at(0, InstrumentType::Juno60, 9).unwrap(),
Err(PresetError::WrongInstrument {
saved: "dx7".into(),
wanted: "juno60".into()
}),
"a DX7 preset loaded into a Juno"
);
assert_ne!(
bank_path(&dir, InstrumentType::DX7),
bank_path(&dir, InstrumentType::Juno60)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_over_a_name_replaces_it_in_place() {
let mut bank = PresetFile::new(InstrumentType::Juno60);
let mut first = juno_panel();
first[phosphor_dsp::juno::P_CUTOFF] = 0.2;
let mut second = juno_panel();
second[phosphor_dsp::juno::P_CUTOFF] = 0.9;
bank.store("brass", InstrumentType::Juno60, &first).unwrap();
bank.store("strings", InstrumentType::Juno60, &juno_panel()).unwrap();
assert_eq!(
bank.store("brass", InstrumentType::Juno60, &second),
Ok(StoreOutcome::Replaced)
);
assert_eq!(bank.names(), vec!["brass", "strings"], "the slot moved or duplicated");
assert_eq!(bank.presets[0].params[phosphor_dsp::juno::P_CUTOFF], 0.9);
assert_eq!(
bank.store(" brass ", InstrumentType::Juno60, &first),
Ok(StoreOutcome::Replaced)
);
assert_eq!(bank.presets.len(), 2);
}
#[test]
fn the_bank_stops_at_its_limit() {
let mut bank = PresetFile::new(InstrumentType::Juno60);
let panel = juno_panel();
for i in 0..MAX_PRESETS {
bank.store(&format!("p{i}"), InstrumentType::Juno60, &panel).unwrap();
}
assert_eq!(
bank.store("one more", InstrumentType::Juno60, &panel),
Err(PresetError::BankFull { max: MAX_PRESETS })
);
assert_eq!(
bank.store("p0", InstrumentType::Juno60, &panel),
Ok(StoreOutcome::Replaced),
"a full bank became read-only"
);
bank.remove(0);
assert_eq!(bank.presets.len(), MAX_PRESETS - 1);
assert_eq!(
bank.store("one more", InstrumentType::Juno60, &panel),
Ok(StoreOutcome::Added)
);
}
#[test]
fn names_are_bounded_and_non_empty() {
let mut bank = PresetFile::new(InstrumentType::Juno60);
let panel = juno_panel();
assert_eq!(
bank.store(" ", InstrumentType::Juno60, &panel),
Err(PresetError::NameEmpty)
);
let long = "x".repeat(MAX_NAME_LEN + 1);
assert_eq!(
bank.store(&long, InstrumentType::Juno60, &panel),
Err(PresetError::NameTooLong { len: MAX_NAME_LEN + 1, max: MAX_NAME_LEN })
);
assert!(bank.presets.is_empty());
}
#[test]
fn a_preset_that_contradicts_itself_is_refused() {
let panel = juno_panel();
let preset = Preset {
name: "hand edited".into(),
instrument: "juno60".into(),
layout: layout_fingerprint(InstrumentType::Juno60),
param_count: 99,
params: panel.clone(),
discrete: Vec::new(),
version: FORMAT_VERSION,
};
assert_eq!(
preset.check(InstrumentType::Juno60, panel.len()),
Err(PresetError::Corrupt { declared: 99, actual: panel.len() })
);
}
#[test]
fn every_instrument_has_a_panel() {
for inst in InstrumentType::ALL {
assert!(param_count(*inst) > 0, "{inst:?} has no parameters");
}
assert_eq!(param_count(InstrumentType::Juno60), phosphor_dsp::juno::PARAM_COUNT);
assert_eq!(param_count(InstrumentType::Jupiter8), phosphor_dsp::jupiter::PARAM_COUNT);
assert_eq!(param_count(InstrumentType::DX7), phosphor_dsp::dx7::PARAM_COUNT);
assert_eq!(param_count(InstrumentType::Odyssey), phosphor_dsp::odyssey::PARAM_COUNT);
assert_eq!(param_count(InstrumentType::DrumRack), phosphor_dsp::drum_rack::PARAM_COUNT);
assert_eq!(param_count(InstrumentType::Synth), phosphor_dsp::synth::PARAM_COUNT);
assert_eq!(param_count(InstrumentType::Sampler), phosphor_dsp::synth::PARAM_COUNT);
}
}