Skip to main content

rust_synth/
persistence.rs

1//! Preset save / load — human-readable TOML.
2//!
3//! Each preset captures the *live* state: global BPM + master gain and,
4//! per track, every tonal parameter and mute flag. The preset kind
5//! (`PadZimmer`, `DroneSub`, …) is stored for sanity-checking on load,
6//! but cannot be changed at runtime — kinds are baked into the audio
7//! graph built at startup, so load only copies params where the kind
8//! matches the current slot.
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::path::{Path, PathBuf};
13
14use crate::audio::engine::EngineHandle;
15use crate::audio::preset::PresetKind;
16
17#[derive(Debug, Serialize, Deserialize)]
18pub struct PresetFile {
19    pub name: String,
20    pub bpm: f32,
21    pub master_gain: f32,
22    #[serde(default = "default_brightness")]
23    pub brightness: f32,
24    pub tracks: Vec<TrackPreset>,
25}
26
27fn default_brightness() -> f32 {
28    0.7
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32pub struct TrackPreset {
33    pub name: String,
34    pub kind: String,
35    pub freq: f32,
36    pub gain: f32,
37    pub cutoff: f32,
38    pub resonance: f32,
39    pub detune: f32,
40    pub sweep_k: f32,
41    pub sweep_center: f32,
42    pub reverb_mix: f32,
43    pub supermass: f32,
44    pub pulse_depth: f32,
45    #[serde(default = "default_hits")]
46    pub pattern_hits: f32,
47    #[serde(default)]
48    pub pattern_rotation: f32,
49    #[serde(default = "default_lfo_rate")]
50    pub lfo_rate: f32,
51    #[serde(default)]
52    pub lfo_depth: f32,
53    #[serde(default = "default_lfo_target")]
54    pub lfo_target: f32,
55    #[serde(default = "default_character")]
56    pub character: f32,
57    #[serde(default)]
58    pub arp: f32,
59    pub mute: bool,
60}
61
62fn default_lfo_rate() -> f32 {
63    0.5
64}
65fn default_lfo_target() -> f32 {
66    1.0
67}
68fn default_character() -> f32 {
69    0.5
70}
71
72fn default_hits() -> f32 {
73    4.0
74}
75
76pub fn save(dir: &Path, engine: &EngineHandle) -> Result<PathBuf> {
77    std::fs::create_dir_all(dir).context("create preset dir")?;
78    let name = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S").to_string();
79    let path = dir.join(format!("{name}.toml"));
80
81    let tracks_guard = engine.tracks.lock();
82    let preset = PresetFile {
83        name: name.clone(),
84        bpm: engine.global.bpm.value(),
85        master_gain: engine.global.master_gain.value(),
86        brightness: engine.global.brightness.value(),
87        tracks: tracks_guard
88            .iter()
89            .map(|t| {
90                let s = t.params.snapshot();
91                TrackPreset {
92                    name: t.name.clone(),
93                    kind: kind_to_str(t.kind).to_string(),
94                    freq: s.freq,
95                    gain: s.gain,
96                    cutoff: s.cutoff,
97                    resonance: s.resonance,
98                    detune: s.detune,
99                    sweep_k: s.sweep_k,
100                    sweep_center: s.sweep_center,
101                    reverb_mix: s.reverb_mix,
102                    supermass: s.supermass,
103                    pulse_depth: s.pulse_depth,
104                    pattern_hits: s.pattern_hits,
105                    pattern_rotation: s.pattern_rotation,
106                    lfo_rate: s.lfo_rate,
107                    lfo_depth: s.lfo_depth,
108                    lfo_target: s.lfo_target,
109                    character: s.character,
110                    arp: s.arp,
111                    mute: s.muted,
112                }
113            })
114            .collect(),
115    };
116    drop(tracks_guard);
117
118    let text = toml::to_string_pretty(&preset).context("serialize preset")?;
119    std::fs::write(&path, text).with_context(|| format!("write {}", path.display()))?;
120    Ok(path)
121}
122
123pub fn load(path: &Path, engine: &EngineHandle) -> Result<usize> {
124    let text = std::fs::read_to_string(path)
125        .with_context(|| format!("read {}", path.display()))?;
126    let preset: PresetFile = toml::from_str(&text).context("parse preset TOML")?;
127
128    engine.global.bpm.set_value(preset.bpm.clamp(20.0, 200.0));
129    engine.global.master_gain.set_value(preset.master_gain.clamp(0.0, 1.5));
130    engine.global.brightness.set_value(preset.brightness.clamp(0.0, 1.0));
131
132    let tracks_guard = engine.tracks.lock();
133    let mut applied = 0;
134    for (i, snap) in preset.tracks.iter().enumerate() {
135        let Some(track) = tracks_guard.get(i) else {
136            break;
137        };
138        if kind_to_str(track.kind) != snap.kind {
139            continue; // slot mismatch — skip quietly
140        }
141        let p = &track.params;
142        p.freq.set_value(snap.freq);
143        p.gain.set_value(snap.gain);
144        p.cutoff.set_value(snap.cutoff);
145        p.resonance.set_value(snap.resonance);
146        p.detune.set_value(snap.detune);
147        p.sweep_k.set_value(snap.sweep_k);
148        p.sweep_center.set_value(snap.sweep_center);
149        p.reverb_mix.set_value(snap.reverb_mix);
150        p.supermass.set_value(snap.supermass);
151        p.pulse_depth.set_value(snap.pulse_depth);
152        p.pattern_hits.set_value(snap.pattern_hits.clamp(0.0, 16.0));
153        p.pattern_rotation
154            .set_value(snap.pattern_rotation.rem_euclid(16.0));
155        p.lfo_rate.set_value(snap.lfo_rate.clamp(0.01, 20.0));
156        p.lfo_depth.set_value(snap.lfo_depth.clamp(0.0, 1.0));
157        p.lfo_target.set_value(snap.lfo_target.clamp(0.0, 4.0));
158        p.character.set_value(snap.character.clamp(0.0, 1.0));
159        p.arp.set_value(snap.arp.clamp(0.0, 1.0));
160        p.mute.set_value(if snap.mute { 1.0 } else { 0.0 });
161        applied += 1;
162    }
163    Ok(applied)
164}
165
166/// Find the most recently modified `.toml` in `dir` and load it.
167pub fn load_latest(dir: &Path, engine: &EngineHandle) -> Result<Option<(PathBuf, usize)>> {
168    if !dir.exists() {
169        return Ok(None);
170    }
171    let latest = std::fs::read_dir(dir)?
172        .filter_map(|e| e.ok())
173        .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("toml"))
174        .max_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
175
176    let Some(entry) = latest else {
177        return Ok(None);
178    };
179    let path = entry.path();
180    let applied = load(&path, engine)?;
181    Ok(Some((path, applied)))
182}
183
184fn kind_to_str(k: PresetKind) -> &'static str {
185    match k {
186        PresetKind::PadZimmer => "PadZimmer",
187        PresetKind::DroneSub => "DroneSub",
188        PresetKind::Shimmer => "Shimmer",
189        PresetKind::Heartbeat => "Heartbeat",
190        PresetKind::BassPulse => "BassPulse",
191        PresetKind::Bell => "Bell",
192        PresetKind::SuperSaw => "SuperSaw",
193        PresetKind::PluckSaw => "PluckSaw",
194    }
195}