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 }
106}
107
108fn instrument_type_to_string(t: InstrumentType) -> String {
109 instrument_key(t).to_string()
110}
111
112fn string_to_instrument_type(s: &str) -> Option<InstrumentType> {
113 match s {
114 "synth" => Some(InstrumentType::Synth),
115 "drums" => Some(InstrumentType::DrumRack),
116 "dx7" => Some(InstrumentType::DX7),
117 "jupiter8" => Some(InstrumentType::Jupiter8),
118 "odyssey" => Some(InstrumentType::Odyssey),
119 "juno60" => Some(InstrumentType::Juno60),
120 "rhodes" => Some(InstrumentType::Rhodes),
121 "sampler" => Some(InstrumentType::Sampler),
122 _ => None,
123 }
124}
125
126pub fn save(path: &Path, nav: &NavState, transport: &Transport) -> Result<()> {
129 let session = extract_session(nav, transport);
130 let json = serde_json::to_string_pretty(&session)?;
131
132 if let Some(parent) = path.parent() {
134 if !parent.exists() {
135 std::fs::create_dir_all(parent)?;
136 }
137 }
138
139 let tmp = path.with_extension("phos.tmp");
141 std::fs::write(&tmp, &json)?;
142 std::fs::rename(&tmp, path)?;
143
144 tracing::debug!("session saved: {}", path.display());
145 Ok(())
146}
147
148fn extract_session(nav: &NavState, transport: &Transport) -> SessionFile {
149 let mut tracks = Vec::new();
150
151 for track in &nav.tracks {
152 if track.instrument_type.is_none() {
154 continue;
155 }
156
157 let clips: Vec<SessionClip> = track.clips.iter().map(|clip| {
158 SessionClip {
159 start_tick: clip.start_tick,
160 length_ticks: clip.length_ticks,
161 notes: clip.notes.iter().map(|n| SessionNote {
162 note: n.note,
163 velocity: n.velocity,
164 start_frac: n.start_frac,
165 duration_frac: n.duration_frac,
166 }).collect(),
167 }
168 }).collect();
169
170 tracks.push(SessionTrack {
171 name: track.name.clone(),
172 instrument_type: track.instrument_type
173 .map(instrument_type_to_string)
174 .unwrap_or_default(),
175 synth_params: track.synth_params.clone(),
176 discrete: track.instrument_type
177 .map(|i| selectors_of(i, &track.synth_params))
178 .unwrap_or_default(),
179 muted: track.muted,
180 soloed: track.soloed,
181 armed: track.armed,
182 volume: track.volume,
183 color_index: track.color_index,
184 clips,
185 });
186 }
187
188 SessionFile {
189 version: FORMAT_VERSION,
190 transport: SessionTransport {
191 tempo_bpm: transport.tempo_bpm(),
192 loop_enabled: nav.loop_editor.enabled,
193 loop_start_bar: nav.loop_editor.start_bar,
194 loop_end_bar: nav.loop_editor.end_bar,
195 metronome: transport.is_metronome_on(),
196 },
197 tracks,
198 }
199}
200
201pub fn load(path: &Path) -> Result<SessionFile> {
204 let json = std::fs::read_to_string(path)?;
205 let session: SessionFile = serde_json::from_str(&json)?;
206 tracing::debug!("session loaded: {} (v{}, {} tracks)",
207 path.display(), session.version, session.tracks.len());
208 Ok(session)
209}
210
211pub fn parse_instrument_type(s: &str) -> Option<InstrumentType> {
213 string_to_instrument_type(s)
214}
215
216#[must_use]
225pub fn selectors_of(instrument: InstrumentType, params: &[f32]) -> Vec<SessionSelector> {
226 (0..params.len())
227 .filter(|¶m| crate::discrete::is_discrete(instrument, param))
228 .filter_map(|param| {
229 crate::discrete::index_of(instrument, param, params[param])
230 .map(|index| SessionSelector { param, index })
231 })
232 .collect()
233}
234
235pub fn apply_selectors(
243 instrument: InstrumentType,
244 params: &mut [f32],
245 stored: &[SessionSelector],
246) -> Vec<(usize, usize, usize)> {
247 let mut clamped = Vec::new();
248 for selector in stored {
249 if selector.param >= params.len() {
250 continue;
251 }
252 let Some(positions) = crate::discrete::positions(instrument, selector.param) else {
253 continue;
254 };
255 let index = selector.index.min(positions.len().saturating_sub(1));
256 let Some(&knob) = positions.get(index) else { continue };
257 if index != selector.index {
258 clamped.push((selector.param, selector.index, index));
259 }
260 params[selector.param] = knob;
261 }
262 clamped
263}
264
265pub fn session_notes_to_snapshots(notes: &[SessionNote]) -> Vec<phosphor_core::clip::NoteSnapshot> {
267 notes.iter().map(|n| phosphor_core::clip::NoteSnapshot {
268 note: n.note,
269 velocity: n.velocity,
270 start_frac: n.start_frac,
271 duration_frac: n.duration_frac,
272 }).collect()
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn round_trip_serialize() {
281 let session = SessionFile {
282 version: FORMAT_VERSION,
283 transport: SessionTransport {
284 tempo_bpm: 120.0,
285 loop_enabled: true,
286 loop_start_bar: 1,
287 loop_end_bar: 5,
288 metronome: true,
289 },
290 tracks: vec![
291 SessionTrack {
292 name: "synth".into(),
293 instrument_type: "dx7".into(),
294 synth_params: vec![0.0, 0.5, 0.7],
295 discrete: vec![SessionSelector { param: 0, index: 3 }],
296 muted: false,
297 soloed: false,
298 armed: true,
299 volume: 0.75,
300 color_index: 2,
301 clips: vec![
302 SessionClip {
303 start_tick: 0,
304 length_ticks: 3840,
305 notes: vec![
306 SessionNote { note: 60, velocity: 100, start_frac: 0.0, duration_frac: 0.25 },
307 SessionNote { note: 64, velocity: 80, start_frac: 0.25, duration_frac: 0.25 },
308 ],
309 },
310 ],
311 },
312 ],
313 };
314
315 let json = serde_json::to_string_pretty(&session).unwrap();
316 let loaded: SessionFile = serde_json::from_str(&json).unwrap();
317
318 assert_eq!(loaded.version, FORMAT_VERSION);
319 assert_eq!(loaded.transport.tempo_bpm, 120.0);
320 assert!(loaded.transport.loop_enabled);
321 assert_eq!(loaded.tracks.len(), 1);
322 assert_eq!(loaded.tracks[0].name, "synth");
323 assert_eq!(loaded.tracks[0].instrument_type, "dx7");
324 assert_eq!(loaded.tracks[0].synth_params, vec![0.0, 0.5, 0.7]);
325 assert_eq!(
326 loaded.tracks[0].discrete,
327 vec![SessionSelector { param: 0, index: 3 }]
328 );
329 assert_eq!(loaded.tracks[0].clips.len(), 1);
330 assert_eq!(loaded.tracks[0].clips[0].notes.len(), 2);
331 assert_eq!(loaded.tracks[0].clips[0].notes[0].note, 60);
332 }
333
334 #[test]
335 fn instrument_type_round_trip() {
336 for inst in InstrumentType::ALL {
337 let s = instrument_type_to_string(*inst);
338 let back = string_to_instrument_type(&s);
339 assert_eq!(back, Some(*inst), "Failed round-trip for {s}");
340 }
341 }
342}