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