1use 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 #[serde(default)]
25 pub scale_mode: f32,
26 pub tracks: Vec<TrackPreset>,
27}
28
29fn default_brightness() -> f32 {
30 0.7
31}
32
33#[derive(Debug, Serialize, Deserialize)]
34pub struct TrackPreset {
35 pub name: String,
36 pub kind: String,
37 pub freq: f32,
38 pub gain: f32,
39 pub cutoff: f32,
40 pub resonance: f32,
41 pub detune: f32,
42 pub sweep_k: f32,
43 pub sweep_center: f32,
44 pub reverb_mix: f32,
45 pub supermass: f32,
46 pub pulse_depth: f32,
47 #[serde(default = "default_hits")]
48 pub pattern_hits: f32,
49 #[serde(default)]
50 pub pattern_rotation: f32,
51 #[serde(default = "default_lfo_rate")]
52 pub lfo_rate: f32,
53 #[serde(default)]
54 pub lfo_depth: f32,
55 #[serde(default = "default_lfo_target")]
56 pub lfo_target: f32,
57 #[serde(default = "default_character")]
58 pub character: f32,
59 #[serde(default)]
60 pub arp: f32,
61 pub mute: bool,
62}
63
64fn default_lfo_rate() -> f32 {
65 0.5
66}
67fn default_lfo_target() -> f32 {
68 1.0
69}
70fn default_character() -> f32 {
71 0.5
72}
73
74fn default_hits() -> f32 {
75 4.0
76}
77
78pub fn save(dir: &Path, engine: &EngineHandle) -> Result<PathBuf> {
79 std::fs::create_dir_all(dir).context("create preset dir")?;
80 let name = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S").to_string();
81 let path = dir.join(format!("{name}.toml"));
82
83 let tracks_guard = engine.tracks.lock();
84 let preset = PresetFile {
85 name: name.clone(),
86 bpm: engine.global.bpm.value(),
87 master_gain: engine.global.master_gain.value(),
88 brightness: engine.global.brightness.value(),
89 scale_mode: engine.global.scale_mode.value(),
90 tracks: tracks_guard
91 .iter()
92 .map(|t| {
93 let s = t.params.snapshot();
94 TrackPreset {
95 name: t.name.clone(),
96 kind: kind_to_str(t.kind).to_string(),
97 freq: s.freq,
98 gain: s.gain,
99 cutoff: s.cutoff,
100 resonance: s.resonance,
101 detune: s.detune,
102 sweep_k: s.sweep_k,
103 sweep_center: s.sweep_center,
104 reverb_mix: s.reverb_mix,
105 supermass: s.supermass,
106 pulse_depth: s.pulse_depth,
107 pattern_hits: s.pattern_hits,
108 pattern_rotation: s.pattern_rotation,
109 lfo_rate: s.lfo_rate,
110 lfo_depth: s.lfo_depth,
111 lfo_target: s.lfo_target,
112 character: s.character,
113 arp: s.arp,
114 mute: s.muted,
115 }
116 })
117 .collect(),
118 };
119 drop(tracks_guard);
120
121 let text = toml::to_string_pretty(&preset).context("serialize preset")?;
122 std::fs::write(&path, text).with_context(|| format!("write {}", path.display()))?;
123 Ok(path)
124}
125
126pub fn load(path: &Path, engine: &EngineHandle) -> Result<usize> {
127 let text = std::fs::read_to_string(path)
128 .with_context(|| format!("read {}", path.display()))?;
129 let preset: PresetFile = toml::from_str(&text).context("parse preset TOML")?;
130
131 engine.global.bpm.set_value(preset.bpm.clamp(20.0, 200.0));
132 engine.global.master_gain.set_value(preset.master_gain.clamp(0.0, 1.5));
133 engine.global.brightness.set_value(preset.brightness.clamp(0.0, 1.0));
134 engine.global.scale_mode.set_value(preset.scale_mode.clamp(0.0, 2.0));
135
136 let tracks_guard = engine.tracks.lock();
137 let mut applied = 0;
138 for (i, snap) in preset.tracks.iter().enumerate() {
139 let Some(track) = tracks_guard.get(i) else {
140 break;
141 };
142 if kind_to_str(track.kind) != snap.kind {
143 continue; }
145 let p = &track.params;
146 p.freq.set_value(snap.freq);
147 p.gain.set_value(snap.gain);
148 p.cutoff.set_value(snap.cutoff);
149 p.resonance.set_value(snap.resonance);
150 p.detune.set_value(snap.detune);
151 p.sweep_k.set_value(snap.sweep_k);
152 p.sweep_center.set_value(snap.sweep_center);
153 p.reverb_mix.set_value(snap.reverb_mix);
154 p.supermass.set_value(snap.supermass);
155 p.pulse_depth.set_value(snap.pulse_depth);
156 p.pattern_hits.set_value(snap.pattern_hits.clamp(0.0, 16.0));
157 p.pattern_rotation
158 .set_value(snap.pattern_rotation.rem_euclid(16.0));
159 p.lfo_rate.set_value(snap.lfo_rate.clamp(0.01, 20.0));
160 p.lfo_depth.set_value(snap.lfo_depth.clamp(0.0, 1.0));
161 p.lfo_target.set_value(snap.lfo_target.clamp(0.0, 4.0));
162 p.character.set_value(snap.character.clamp(0.0, 1.0));
163 p.arp.set_value(snap.arp.clamp(0.0, 1.0));
164 p.mute.set_value(if snap.mute { 1.0 } else { 0.0 });
165 applied += 1;
166 }
167 Ok(applied)
168}
169
170pub fn load_latest(dir: &Path, engine: &EngineHandle) -> Result<Option<(PathBuf, usize)>> {
172 if !dir.exists() {
173 return Ok(None);
174 }
175 let latest = std::fs::read_dir(dir)?
176 .filter_map(|e| e.ok())
177 .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("toml"))
178 .max_by_key(|e| e.metadata().and_then(|m| m.modified()).ok());
179
180 let Some(entry) = latest else {
181 return Ok(None);
182 };
183 let path = entry.path();
184 let applied = load(&path, engine)?;
185 Ok(Some((path, applied)))
186}
187
188fn kind_to_str(k: PresetKind) -> &'static str {
189 match k {
190 PresetKind::PadZimmer => "PadZimmer",
191 PresetKind::DroneSub => "DroneSub",
192 PresetKind::Shimmer => "Shimmer",
193 PresetKind::Heartbeat => "Heartbeat",
194 PresetKind::BassPulse => "BassPulse",
195 PresetKind::Bell => "Bell",
196 PresetKind::SuperSaw => "SuperSaw",
197 PresetKind::PluckSaw => "PluckSaw",
198 }
199}