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