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