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    /// The step sequencer on this track, when it has one.
59    ///
60    /// Absent — not null — on every track that is not one, so a session with
61    /// no sequencer in it is byte for byte the file it was before sequencers
62    /// existed. That is what `session_digest` is run against.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub sequencer: Option<crate::sequencer::SessionSequencer>,
65}
66
67/// One discrete control, stored by what it selects.
68///
69/// The knob fraction is still in `synth_params` — this is the authority when
70/// both are present, and the fraction is what a version 1 file has to fall
71/// back on.
72#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
73pub struct SessionSelector {
74    /// Index into `synth_params`.
75    pub param: usize,
76    /// Which position of that control, counting from zero.
77    pub index: usize,
78}
79
80#[derive(Serialize, Deserialize)]
81pub struct SessionClip {
82    pub start_tick: i64,
83    pub length_ticks: i64,
84    pub notes: Vec<SessionNote>,
85}
86
87#[derive(Serialize, Deserialize)]
88pub struct SessionNote {
89    pub note: u8,
90    pub velocity: u8,
91    pub start_frac: f64,
92    pub duration_frac: f64,
93}
94
95// ── InstrumentType <-> String conversion ──
96
97/// The stable on-disk spelling of an instrument type.
98///
99/// One source of truth: sessions store it per track, and the preset banks are
100/// named after it, so a rename here has to move both together rather than
101/// leaving one format reading files the other cannot write.
102pub fn instrument_key(t: InstrumentType) -> &'static str {
103    match t {
104        InstrumentType::Synth => "synth",
105        InstrumentType::DrumRack => "drums",
106        InstrumentType::DX7 => "dx7",
107        InstrumentType::Jupiter8 => "jupiter8",
108        InstrumentType::Odyssey => "odyssey",
109        InstrumentType::Juno60 => "juno60",
110        InstrumentType::Rhodes => "rhodes",
111        InstrumentType::Sampler => "sampler",
112        InstrumentType::LittlePhatty => "phatty",
113        InstrumentType::Prophet6 => "prophet6",
114        InstrumentType::Sequencer => "sequencer",
115    }
116}
117
118fn instrument_type_to_string(t: InstrumentType) -> String {
119    instrument_key(t).to_string()
120}
121
122fn string_to_instrument_type(s: &str) -> Option<InstrumentType> {
123    match s {
124        "synth" => Some(InstrumentType::Synth),
125        "drums" => Some(InstrumentType::DrumRack),
126        "dx7" => Some(InstrumentType::DX7),
127        "jupiter8" => Some(InstrumentType::Jupiter8),
128        "odyssey" => Some(InstrumentType::Odyssey),
129        "juno60" => Some(InstrumentType::Juno60),
130        "rhodes" => Some(InstrumentType::Rhodes),
131        "sampler" => Some(InstrumentType::Sampler),
132        "phatty" => Some(InstrumentType::LittlePhatty),
133        "prophet6" => Some(InstrumentType::Prophet6),
134        "sequencer" => Some(InstrumentType::Sequencer),
135        _ => None,
136    }
137}
138
139// ── Save ──
140
141pub fn save(path: &Path, nav: &NavState, transport: &Transport) -> Result<()> {
142    let session = extract_session(nav, transport);
143    let json = serde_json::to_string_pretty(&session)?;
144
145    // Ensure parent directory exists
146    if let Some(parent) = path.parent() {
147        if !parent.exists() {
148            std::fs::create_dir_all(parent)?;
149        }
150    }
151
152    // Atomic write: write to tmp, then rename
153    let tmp = path.with_extension("phos.tmp");
154    std::fs::write(&tmp, &json)?;
155    std::fs::rename(&tmp, path)?;
156
157    tracing::debug!("session saved: {}", path.display());
158    Ok(())
159}
160
161fn extract_session(nav: &NavState, transport: &Transport) -> SessionFile {
162    let mut tracks = Vec::new();
163
164    for track in &nav.tracks {
165        // Only save instrument tracks (not bus tracks)
166        if track.instrument_type.is_none() {
167            continue;
168        }
169
170        let clips: Vec<SessionClip> = track.clips.iter().map(|clip| {
171            SessionClip {
172                start_tick: clip.start_tick,
173                length_ticks: clip.length_ticks,
174                notes: clip.notes.iter().map(|n| SessionNote {
175                    note: n.note,
176                    velocity: n.velocity,
177                    start_frac: n.start_frac,
178                    duration_frac: n.duration_frac,
179                }).collect(),
180            }
181        }).collect();
182
183        tracks.push(SessionTrack {
184            name: track.name.clone(),
185            instrument_type: track.instrument_type
186                .map(instrument_type_to_string)
187                .unwrap_or_default(),
188            synth_params: track.synth_params.clone(),
189            discrete: track.instrument_type
190                .map(|i| selectors_of(i, &track.synth_params))
191                .unwrap_or_default(),
192            muted: track.muted,
193            soloed: track.soloed,
194            armed: track.armed,
195            volume: track.volume,
196            color_index: track.color_index,
197            clips,
198            sequencer: track.sequencer.as_ref().map(|state| {
199                crate::sequencer::SessionSequencer::from_state(
200                    state,
201                    track.instrument_type.unwrap_or(crate::sequencer::DEFAULT_CHILD),
202                    &track.synth_params,
203                )
204            }),
205        });
206    }
207
208    SessionFile {
209        version: FORMAT_VERSION,
210        transport: SessionTransport {
211            tempo_bpm: transport.tempo_bpm(),
212            loop_enabled: nav.loop_editor.enabled,
213            loop_start_bar: nav.loop_editor.start_bar,
214            loop_end_bar: nav.loop_editor.end_bar,
215            metronome: transport.is_metronome_on(),
216        },
217        tracks,
218    }
219}
220
221// ── Load ──
222
223pub fn load(path: &Path) -> Result<SessionFile> {
224    let json = std::fs::read_to_string(path)?;
225    let session: SessionFile = serde_json::from_str(&json)?;
226    tracing::debug!("session loaded: {} (v{}, {} tracks)",
227        path.display(), session.version, session.tracks.len());
228    Ok(session)
229}
230
231/// Get the InstrumentType from a session track string.
232pub fn parse_instrument_type(s: &str) -> Option<InstrumentType> {
233    string_to_instrument_type(s)
234}
235
236// ── Selectors ──
237
238/// Every selector on `params`, as the position it is pointing at.
239///
240/// Which controls those are comes from the instrument's own `is_discrete`
241/// rather than from a list here: a panel that gains a switch has to start
242/// storing it without this file being edited, because the failure this guards
243/// against is silent.
244#[must_use]
245pub fn selectors_of(instrument: InstrumentType, params: &[f32]) -> Vec<SessionSelector> {
246    (0..params.len())
247        .filter(|&param| crate::discrete::is_discrete(instrument, param))
248        .filter_map(|param| {
249            crate::discrete::index_of(instrument, param, params[param])
250                .map(|index| SessionSelector { param, index })
251        })
252        .collect()
253}
254
255/// Point the selectors in `params` at the positions the session stored.
256///
257/// Returns the entries that could not be restored exactly, as
258/// `(parameter, wanted, given)` — a bank that has *shrunk* since the session
259/// was written has nothing at the far end of it any more, and the nearest
260/// thing to what the player chose is its last entry. Anything the instrument
261/// does not call a selector is ignored rather than written blind.
262pub fn apply_selectors(
263    instrument: InstrumentType,
264    params: &mut [f32],
265    stored: &[SessionSelector],
266) -> Vec<(usize, usize, usize)> {
267    let mut clamped = Vec::new();
268    for selector in stored {
269        if selector.param >= params.len() {
270            continue;
271        }
272        let Some(positions) = crate::discrete::positions(instrument, selector.param) else {
273            continue;
274        };
275        let index = selector.index.min(positions.len().saturating_sub(1));
276        let Some(&knob) = positions.get(index) else { continue };
277        if index != selector.index {
278            clamped.push((selector.param, selector.index, index));
279        }
280        params[selector.param] = knob;
281    }
282    clamped
283}
284
285/// Get the notes for a clip as NoteSnapshots.
286pub fn session_notes_to_snapshots(notes: &[SessionNote]) -> Vec<phosphor_core::clip::NoteSnapshot> {
287    notes.iter().map(|n| phosphor_core::clip::NoteSnapshot {
288        note: n.note,
289        velocity: n.velocity,
290        start_frac: n.start_frac,
291        duration_frac: n.duration_frac,
292    }).collect()
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn round_trip_serialize() {
301        let session = SessionFile {
302            version: FORMAT_VERSION,
303            transport: SessionTransport {
304                tempo_bpm: 120.0,
305                loop_enabled: true,
306                loop_start_bar: 1,
307                loop_end_bar: 5,
308                metronome: true,
309            },
310            tracks: vec![
311                SessionTrack {
312                    name: "synth".into(),
313                    instrument_type: "dx7".into(),
314                    synth_params: vec![0.0, 0.5, 0.7],
315                    discrete: vec![SessionSelector { param: 0, index: 3 }],
316                    muted: false,
317                    soloed: false,
318                    armed: true,
319                    volume: 0.75,
320                    color_index: 2,
321                    sequencer: None,
322                    clips: vec![
323                        SessionClip {
324                            start_tick: 0,
325                            length_ticks: 3840,
326                            notes: vec![
327                                SessionNote { note: 60, velocity: 100, start_frac: 0.0, duration_frac: 0.25 },
328                                SessionNote { note: 64, velocity: 80, start_frac: 0.25, duration_frac: 0.25 },
329                            ],
330                        },
331                    ],
332                },
333            ],
334        };
335
336        let json = serde_json::to_string_pretty(&session).unwrap();
337        let loaded: SessionFile = serde_json::from_str(&json).unwrap();
338
339        assert_eq!(loaded.version, FORMAT_VERSION);
340        assert_eq!(loaded.transport.tempo_bpm, 120.0);
341        assert!(loaded.transport.loop_enabled);
342        assert_eq!(loaded.tracks.len(), 1);
343        assert_eq!(loaded.tracks[0].name, "synth");
344        assert_eq!(loaded.tracks[0].instrument_type, "dx7");
345        assert_eq!(loaded.tracks[0].synth_params, vec![0.0, 0.5, 0.7]);
346        assert_eq!(
347            loaded.tracks[0].discrete,
348            vec![SessionSelector { param: 0, index: 3 }]
349        );
350        assert_eq!(loaded.tracks[0].clips.len(), 1);
351        assert_eq!(loaded.tracks[0].clips[0].notes.len(), 2);
352        assert_eq!(loaded.tracks[0].clips[0].notes[0].note, 60);
353    }
354
355    #[test]
356    fn instrument_type_round_trip() {
357        for inst in InstrumentType::ALL {
358            let s = instrument_type_to_string(*inst);
359            let back = string_to_instrument_type(&s);
360            assert_eq!(back, Some(*inst), "Failed round-trip for {s}");
361        }
362    }
363}