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        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
128// ── Save ──
129
130pub 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    // Ensure parent directory exists
135    if let Some(parent) = path.parent() {
136        if !parent.exists() {
137            std::fs::create_dir_all(parent)?;
138        }
139    }
140
141    // Atomic write: write to tmp, then rename
142    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        // Only save instrument tracks (not bus tracks)
155        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
203// ── Load ──
204
205pub 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
213/// Get the InstrumentType from a session track string.
214pub fn parse_instrument_type(s: &str) -> Option<InstrumentType> {
215    string_to_instrument_type(s)
216}
217
218// ── Selectors ──
219
220/// Every selector on `params`, as the position it is pointing at.
221///
222/// Which controls those are comes from the instrument's own `is_discrete`
223/// rather than from a list here: a panel that gains a switch has to start
224/// storing it without this file being edited, because the failure this guards
225/// against is silent.
226#[must_use]
227pub fn selectors_of(instrument: InstrumentType, params: &[f32]) -> Vec<SessionSelector> {
228    (0..params.len())
229        .filter(|&param| 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
237/// Point the selectors in `params` at the positions the session stored.
238///
239/// Returns the entries that could not be restored exactly, as
240/// `(parameter, wanted, given)` — a bank that has *shrunk* since the session
241/// was written has nothing at the far end of it any more, and the nearest
242/// thing to what the player chose is its last entry. Anything the instrument
243/// does not call a selector is ignored rather than written blind.
244pub 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
267/// Get the notes for a clip as NoteSnapshots.
268pub 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}