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