1use 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
26pub const MAX_PRESETS: usize = 128;
35
36pub const MAX_NAME_LEN: usize = 32;
38
39pub const FORMAT_VERSION: u32 = 1;
41
42#[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#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PresetFile {
75 pub version: u32,
76 pub instrument: String,
78 pub presets: Vec<Preset>,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct Preset {
89 pub name: String,
90 pub instrument: String,
91 pub layout: String,
93 pub param_count: usize,
94 pub params: Vec<f32>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum StoreOutcome {
100 Added,
101 Replaced,
103}
104
105pub 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
123pub 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 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
155pub fn param_count(instrument: InstrumentType) -> usize {
157 param_names(instrument).len()
158}
159
160pub fn default_dir() -> Option<PathBuf> {
168 std::env::var("HOME")
169 .ok()
170 .map(|home| PathBuf::from(home).join(".phosphor").join("presets"))
171}
172
173pub fn bank_path(dir: &Path, instrument: InstrumentType) -> PathBuf {
175 dir.join(format!("{}.json", instrument_key(instrument)))
176}
177
178pub 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
194pub 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
209impl 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 pub fn names(&self) -> Vec<&str> {
222 self.presets.iter().map(|p| p.name.as_str()).collect()
223 }
224
225 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 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 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 pub fn remove(&mut self, index: usize) -> Option<Preset> {
284 (index < self.presets.len()).then(|| self.presets.remove(index))
285 }
286
287 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 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 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 #[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 #[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 #[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 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 #[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 preset.layout = "0000000000000000".into();
434 assert!(matches!(
435 preset.check(InstrumentType::Juno60, 25),
436 Err(PresetError::LayoutMismatch { .. })
437 ));
438 }
439
440 #[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 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 #[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 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 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 #[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 assert_eq!(
521 bank.store(" brass ", InstrumentType::Juno60, &first),
522 Ok(StoreOutcome::Replaced)
523 );
524 assert_eq!(bank.presets.len(), 2);
525 }
526
527 #[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 #[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 #[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 #[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}