use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::session::instrument_key;
use crate::state::InstrumentType;
pub const MAX_PRESETS: usize = 128;
pub const MAX_NAME_LEN: usize = 32;
pub const FORMAT_VERSION: u32 = 1;
#[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>,
}
#[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(),
};
match self.find(name) {
Some(idx) => {
self.presets[idx] = preset;
Ok(StoreOutcome::Replaced)
}
None => {
if self.presets.len() >= MAX_PRESETS {
return Err(PresetError::BankFull { max: MAX_PRESETS });
}
self.presets.push(preset);
Ok(StoreOutcome::Added)
}
}
}
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<&[f32], PresetError>> {
let preset = self.presets.get(index)?;
Some(preset.check(instrument, want_count).map(|()| preset.params.as_slice()))
}
}
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(())
}
}
#[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] = 0.437_1;
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");
assert_eq!(loaded, panel.as_slice(), "the panel came back changed");
let _ = std::fs::remove_dir_all(&dir);
}
#[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,
};
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(),
};
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);
}
}