Skip to main content

phosphor_app/
session.rs

1//! Session save/load — .phos file format.
2//!
3//! Serializes the full project state to a human-readable JSON file.
4//! Atomic writes (tmp + rename) prevent corruption.
5
6use std::path::Path;
7use serde::{Serialize, Deserialize};
8use anyhow::Result;
9
10use crate::state::{NavState, InstrumentType};
11use phosphor_core::transport::Transport;
12
13// ── Session file format ──
14
15/// Current `.phos` format version.
16///
17/// * **1** — every synth parameter stored as the normalised `f32` the panel
18///   holds, selectors included.
19/// * **2** — selectors additionally stored by the position they pick, in
20///   [`SessionTrack::discrete`]. A fraction only names a patch as long as the
21///   bank is the size it was when the fraction was written, and two banks have
22///   since changed size; see [`crate::discrete`]. Version 1 files still load —
23///   see `do_load` — but their selectors are only right if nothing has been
24///   added to the bank since.
25pub 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    /// Where every selector on this panel was pointing, by position rather
49    /// than by knob fraction. Absent in version 1 files.
50    #[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/// One discrete control, stored by what it selects.
61///
62/// The knob fraction is still in `synth_params` — this is the authority when
63/// both are present, and the fraction is what a version 1 file has to fall
64/// back on.
65#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
66pub struct SessionSelector {
67    /// Index into `synth_params`.
68    pub param: usize,
69    /// Which position of that control, counting from zero.
70    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
88// ── InstrumentType <-> String conversion ──
89
90/// The stable on-disk spelling of an instrument type.
91///
92/// One source of truth: sessions store it per track, and the preset banks are
93/// named after it, so a rename here has to move both together rather than
94/// leaving one format reading files the other cannot write.
95pub 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
126// ── Save ──
127
128pub 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    // Ensure parent directory exists
133    if let Some(parent) = path.parent() {
134        if !parent.exists() {
135            std::fs::create_dir_all(parent)?;
136        }
137    }
138
139    // Atomic write: write to tmp, then rename
140    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        // Only save instrument tracks (not bus tracks)
153        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
201// ── Load ──
202
203pub 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
211/// Get the InstrumentType from a session track string.
212pub fn parse_instrument_type(s: &str) -> Option<InstrumentType> {
213    string_to_instrument_type(s)
214}
215
216// ── Selectors ──
217
218/// Every selector on `params`, as the position it is pointing at.
219///
220/// Which controls those are comes from the instrument's own `is_discrete`
221/// rather than from a list here: a panel that gains a switch has to start
222/// storing it without this file being edited, because the failure this guards
223/// against is silent.
224#[must_use]
225pub fn selectors_of(instrument: InstrumentType, params: &[f32]) -> Vec<SessionSelector> {
226    (0..params.len())
227        .filter(|&param| 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
235/// Point the selectors in `params` at the positions the session stored.
236///
237/// Returns the entries that could not be restored exactly, as
238/// `(parameter, wanted, given)` — a bank that has *shrunk* since the session
239/// was written has nothing at the far end of it any more, and the nearest
240/// thing to what the player chose is its last entry. Anything the instrument
241/// does not call a selector is ignored rather than written blind.
242pub 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
265/// Get the notes for a clip as NoteSnapshots.
266pub 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}