1use std::path::Path;
7use serde::{Serialize, Deserialize};
8use anyhow::Result;
9
10use crate::state::{NavState, InstrumentType};
11use phosphor_core::transport::Transport;
12
13pub const FORMAT_VERSION: u32 = 2;
26
27#[derive(Serialize, Deserialize)]
28pub struct SessionFile {
29 pub version: u32,
30 pub transport: SessionTransport,
31 pub tracks: Vec<SessionTrack>,
32}
33
34#[derive(Serialize, Deserialize)]
35pub struct SessionTransport {
36 pub tempo_bpm: f64,
37 pub loop_enabled: bool,
38 pub loop_start_bar: u32,
39 pub loop_end_bar: u32,
40 pub metronome: bool,
41}
42
43#[derive(Serialize, Deserialize)]
44pub struct SessionTrack {
45 pub name: String,
46 pub instrument_type: String,
47 pub synth_params: Vec<f32>,
48 #[serde(default)]
51 pub discrete: Vec<SessionSelector>,
52 pub muted: bool,
53 pub soloed: bool,
54 pub armed: bool,
55 pub volume: f32,
56 pub color_index: usize,
57 pub clips: Vec<SessionClip>,
58}
59
60#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
66pub struct SessionSelector {
67 pub param: usize,
69 pub index: usize,
71}
72
73#[derive(Serialize, Deserialize)]
74pub struct SessionClip {
75 pub start_tick: i64,
76 pub length_ticks: i64,
77 pub notes: Vec<SessionNote>,
78}
79
80#[derive(Serialize, Deserialize)]
81pub struct SessionNote {
82 pub note: u8,
83 pub velocity: u8,
84 pub start_frac: f64,
85 pub duration_frac: f64,
86}
87
88pub fn instrument_key(t: InstrumentType) -> &'static str {
96 match t {
97 InstrumentType::Synth => "synth",
98 InstrumentType::DrumRack => "drums",
99 InstrumentType::DX7 => "dx7",
100 InstrumentType::Jupiter8 => "jupiter8",
101 InstrumentType::Odyssey => "odyssey",
102 InstrumentType::Juno60 => "juno60",
103 InstrumentType::Rhodes => "rhodes",
104 InstrumentType::Sampler => "sampler",
105 InstrumentType::LittlePhatty => "phatty",
106 }
107}
108
109fn instrument_type_to_string(t: InstrumentType) -> String {
110 instrument_key(t).to_string()
111}
112
113fn string_to_instrument_type(s: &str) -> Option<InstrumentType> {
114 match s {
115 "synth" => Some(InstrumentType::Synth),
116 "drums" => Some(InstrumentType::DrumRack),
117 "dx7" => Some(InstrumentType::DX7),
118 "jupiter8" => Some(InstrumentType::Jupiter8),
119 "odyssey" => Some(InstrumentType::Odyssey),
120 "juno60" => Some(InstrumentType::Juno60),
121 "rhodes" => Some(InstrumentType::Rhodes),
122 "sampler" => Some(InstrumentType::Sampler),
123 "phatty" => Some(InstrumentType::LittlePhatty),
124 _ => None,
125 }
126}
127
128pub fn save(path: &Path, nav: &NavState, transport: &Transport) -> Result<()> {
131 let session = extract_session(nav, transport);
132 let json = serde_json::to_string_pretty(&session)?;
133
134 if let Some(parent) = path.parent() {
136 if !parent.exists() {
137 std::fs::create_dir_all(parent)?;
138 }
139 }
140
141 let tmp = path.with_extension("phos.tmp");
143 std::fs::write(&tmp, &json)?;
144 std::fs::rename(&tmp, path)?;
145
146 tracing::debug!("session saved: {}", path.display());
147 Ok(())
148}
149
150fn extract_session(nav: &NavState, transport: &Transport) -> SessionFile {
151 let mut tracks = Vec::new();
152
153 for track in &nav.tracks {
154 if track.instrument_type.is_none() {
156 continue;
157 }
158
159 let clips: Vec<SessionClip> = track.clips.iter().map(|clip| {
160 SessionClip {
161 start_tick: clip.start_tick,
162 length_ticks: clip.length_ticks,
163 notes: clip.notes.iter().map(|n| SessionNote {
164 note: n.note,
165 velocity: n.velocity,
166 start_frac: n.start_frac,
167 duration_frac: n.duration_frac,
168 }).collect(),
169 }
170 }).collect();
171
172 tracks.push(SessionTrack {
173 name: track.name.clone(),
174 instrument_type: track.instrument_type
175 .map(instrument_type_to_string)
176 .unwrap_or_default(),
177 synth_params: track.synth_params.clone(),
178 discrete: track.instrument_type
179 .map(|i| selectors_of(i, &track.synth_params))
180 .unwrap_or_default(),
181 muted: track.muted,
182 soloed: track.soloed,
183 armed: track.armed,
184 volume: track.volume,
185 color_index: track.color_index,
186 clips,
187 });
188 }
189
190 SessionFile {
191 version: FORMAT_VERSION,
192 transport: SessionTransport {
193 tempo_bpm: transport.tempo_bpm(),
194 loop_enabled: nav.loop_editor.enabled,
195 loop_start_bar: nav.loop_editor.start_bar,
196 loop_end_bar: nav.loop_editor.end_bar,
197 metronome: transport.is_metronome_on(),
198 },
199 tracks,
200 }
201}
202
203pub fn load(path: &Path) -> Result<SessionFile> {
206 let json = std::fs::read_to_string(path)?;
207 let session: SessionFile = serde_json::from_str(&json)?;
208 tracing::debug!("session loaded: {} (v{}, {} tracks)",
209 path.display(), session.version, session.tracks.len());
210 Ok(session)
211}
212
213pub fn parse_instrument_type(s: &str) -> Option<InstrumentType> {
215 string_to_instrument_type(s)
216}
217
218#[must_use]
227pub fn selectors_of(instrument: InstrumentType, params: &[f32]) -> Vec<SessionSelector> {
228 (0..params.len())
229 .filter(|¶m| crate::discrete::is_discrete(instrument, param))
230 .filter_map(|param| {
231 crate::discrete::index_of(instrument, param, params[param])
232 .map(|index| SessionSelector { param, index })
233 })
234 .collect()
235}
236
237pub fn apply_selectors(
245 instrument: InstrumentType,
246 params: &mut [f32],
247 stored: &[SessionSelector],
248) -> Vec<(usize, usize, usize)> {
249 let mut clamped = Vec::new();
250 for selector in stored {
251 if selector.param >= params.len() {
252 continue;
253 }
254 let Some(positions) = crate::discrete::positions(instrument, selector.param) else {
255 continue;
256 };
257 let index = selector.index.min(positions.len().saturating_sub(1));
258 let Some(&knob) = positions.get(index) else { continue };
259 if index != selector.index {
260 clamped.push((selector.param, selector.index, index));
261 }
262 params[selector.param] = knob;
263 }
264 clamped
265}
266
267pub fn session_notes_to_snapshots(notes: &[SessionNote]) -> Vec<phosphor_core::clip::NoteSnapshot> {
269 notes.iter().map(|n| phosphor_core::clip::NoteSnapshot {
270 note: n.note,
271 velocity: n.velocity,
272 start_frac: n.start_frac,
273 duration_frac: n.duration_frac,
274 }).collect()
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn round_trip_serialize() {
283 let session = SessionFile {
284 version: FORMAT_VERSION,
285 transport: SessionTransport {
286 tempo_bpm: 120.0,
287 loop_enabled: true,
288 loop_start_bar: 1,
289 loop_end_bar: 5,
290 metronome: true,
291 },
292 tracks: vec![
293 SessionTrack {
294 name: "synth".into(),
295 instrument_type: "dx7".into(),
296 synth_params: vec![0.0, 0.5, 0.7],
297 discrete: vec![SessionSelector { param: 0, index: 3 }],
298 muted: false,
299 soloed: false,
300 armed: true,
301 volume: 0.75,
302 color_index: 2,
303 clips: vec![
304 SessionClip {
305 start_tick: 0,
306 length_ticks: 3840,
307 notes: vec![
308 SessionNote { note: 60, velocity: 100, start_frac: 0.0, duration_frac: 0.25 },
309 SessionNote { note: 64, velocity: 80, start_frac: 0.25, duration_frac: 0.25 },
310 ],
311 },
312 ],
313 },
314 ],
315 };
316
317 let json = serde_json::to_string_pretty(&session).unwrap();
318 let loaded: SessionFile = serde_json::from_str(&json).unwrap();
319
320 assert_eq!(loaded.version, FORMAT_VERSION);
321 assert_eq!(loaded.transport.tempo_bpm, 120.0);
322 assert!(loaded.transport.loop_enabled);
323 assert_eq!(loaded.tracks.len(), 1);
324 assert_eq!(loaded.tracks[0].name, "synth");
325 assert_eq!(loaded.tracks[0].instrument_type, "dx7");
326 assert_eq!(loaded.tracks[0].synth_params, vec![0.0, 0.5, 0.7]);
327 assert_eq!(
328 loaded.tracks[0].discrete,
329 vec![SessionSelector { param: 0, index: 3 }]
330 );
331 assert_eq!(loaded.tracks[0].clips.len(), 1);
332 assert_eq!(loaded.tracks[0].clips[0].notes.len(), 2);
333 assert_eq!(loaded.tracks[0].clips[0].notes[0].note, 60);
334 }
335
336 #[test]
337 fn instrument_type_round_trip() {
338 for inst in InstrumentType::ALL {
339 let s = instrument_type_to_string(*inst);
340 let back = string_to_instrument_type(&s);
341 assert_eq!(back, Some(*inst), "Failed round-trip for {s}");
342 }
343 }
344}