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#[derive(Serialize, Deserialize)]
16pub struct SessionFile {
17    pub version: u32,
18    pub transport: SessionTransport,
19    pub tracks: Vec<SessionTrack>,
20}
21
22#[derive(Serialize, Deserialize)]
23pub struct SessionTransport {
24    pub tempo_bpm: f64,
25    pub loop_enabled: bool,
26    pub loop_start_bar: u32,
27    pub loop_end_bar: u32,
28    pub metronome: bool,
29}
30
31#[derive(Serialize, Deserialize)]
32pub struct SessionTrack {
33    pub name: String,
34    pub instrument_type: String,
35    pub synth_params: Vec<f32>,
36    pub muted: bool,
37    pub soloed: bool,
38    pub armed: bool,
39    pub volume: f32,
40    pub color_index: usize,
41    pub clips: Vec<SessionClip>,
42}
43
44#[derive(Serialize, Deserialize)]
45pub struct SessionClip {
46    pub start_tick: i64,
47    pub length_ticks: i64,
48    pub notes: Vec<SessionNote>,
49}
50
51#[derive(Serialize, Deserialize)]
52pub struct SessionNote {
53    pub note: u8,
54    pub velocity: u8,
55    pub start_frac: f64,
56    pub duration_frac: f64,
57}
58
59// ── InstrumentType <-> String conversion ──
60
61/// The stable on-disk spelling of an instrument type.
62///
63/// One source of truth: sessions store it per track, and the preset banks are
64/// named after it, so a rename here has to move both together rather than
65/// leaving one format reading files the other cannot write.
66pub fn instrument_key(t: InstrumentType) -> &'static str {
67    match t {
68        InstrumentType::Synth => "synth",
69        InstrumentType::DrumRack => "drums",
70        InstrumentType::DX7 => "dx7",
71        InstrumentType::Jupiter8 => "jupiter8",
72        InstrumentType::Odyssey => "odyssey",
73        InstrumentType::Juno60 => "juno60",
74        InstrumentType::Sampler => "sampler",
75    }
76}
77
78fn instrument_type_to_string(t: InstrumentType) -> String {
79    instrument_key(t).to_string()
80}
81
82fn string_to_instrument_type(s: &str) -> Option<InstrumentType> {
83    match s {
84        "synth" => Some(InstrumentType::Synth),
85        "drums" => Some(InstrumentType::DrumRack),
86        "dx7" => Some(InstrumentType::DX7),
87        "jupiter8" => Some(InstrumentType::Jupiter8),
88        "odyssey" => Some(InstrumentType::Odyssey),
89        "juno60" => Some(InstrumentType::Juno60),
90        "sampler" => Some(InstrumentType::Sampler),
91        _ => None,
92    }
93}
94
95// ── Save ──
96
97pub fn save(path: &Path, nav: &NavState, transport: &Transport) -> Result<()> {
98    let session = extract_session(nav, transport);
99    let json = serde_json::to_string_pretty(&session)?;
100
101    // Ensure parent directory exists
102    if let Some(parent) = path.parent() {
103        if !parent.exists() {
104            std::fs::create_dir_all(parent)?;
105        }
106    }
107
108    // Atomic write: write to tmp, then rename
109    let tmp = path.with_extension("phos.tmp");
110    std::fs::write(&tmp, &json)?;
111    std::fs::rename(&tmp, path)?;
112
113    tracing::debug!("session saved: {}", path.display());
114    Ok(())
115}
116
117fn extract_session(nav: &NavState, transport: &Transport) -> SessionFile {
118    let mut tracks = Vec::new();
119
120    for track in &nav.tracks {
121        // Only save instrument tracks (not bus tracks)
122        if track.instrument_type.is_none() {
123            continue;
124        }
125
126        let clips: Vec<SessionClip> = track.clips.iter().map(|clip| {
127            SessionClip {
128                start_tick: clip.start_tick,
129                length_ticks: clip.length_ticks,
130                notes: clip.notes.iter().map(|n| SessionNote {
131                    note: n.note,
132                    velocity: n.velocity,
133                    start_frac: n.start_frac,
134                    duration_frac: n.duration_frac,
135                }).collect(),
136            }
137        }).collect();
138
139        tracks.push(SessionTrack {
140            name: track.name.clone(),
141            instrument_type: track.instrument_type
142                .map(instrument_type_to_string)
143                .unwrap_or_default(),
144            synth_params: track.synth_params.clone(),
145            muted: track.muted,
146            soloed: track.soloed,
147            armed: track.armed,
148            volume: track.volume,
149            color_index: track.color_index,
150            clips,
151        });
152    }
153
154    SessionFile {
155        version: 1,
156        transport: SessionTransport {
157            tempo_bpm: transport.tempo_bpm(),
158            loop_enabled: nav.loop_editor.enabled,
159            loop_start_bar: nav.loop_editor.start_bar,
160            loop_end_bar: nav.loop_editor.end_bar,
161            metronome: transport.is_metronome_on(),
162        },
163        tracks,
164    }
165}
166
167// ── Load ──
168
169pub fn load(path: &Path) -> Result<SessionFile> {
170    let json = std::fs::read_to_string(path)?;
171    let session: SessionFile = serde_json::from_str(&json)?;
172    tracing::debug!("session loaded: {} (v{}, {} tracks)",
173        path.display(), session.version, session.tracks.len());
174    Ok(session)
175}
176
177/// Get the InstrumentType from a session track string.
178pub fn parse_instrument_type(s: &str) -> Option<InstrumentType> {
179    string_to_instrument_type(s)
180}
181
182/// Get the notes for a clip as NoteSnapshots.
183pub fn session_notes_to_snapshots(notes: &[SessionNote]) -> Vec<phosphor_core::clip::NoteSnapshot> {
184    notes.iter().map(|n| phosphor_core::clip::NoteSnapshot {
185        note: n.note,
186        velocity: n.velocity,
187        start_frac: n.start_frac,
188        duration_frac: n.duration_frac,
189    }).collect()
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn round_trip_serialize() {
198        let session = SessionFile {
199            version: 1,
200            transport: SessionTransport {
201                tempo_bpm: 120.0,
202                loop_enabled: true,
203                loop_start_bar: 1,
204                loop_end_bar: 5,
205                metronome: true,
206            },
207            tracks: vec![
208                SessionTrack {
209                    name: "synth".into(),
210                    instrument_type: "dx7".into(),
211                    synth_params: vec![0.0, 0.5, 0.7],
212                    muted: false,
213                    soloed: false,
214                    armed: true,
215                    volume: 0.75,
216                    color_index: 2,
217                    clips: vec![
218                        SessionClip {
219                            start_tick: 0,
220                            length_ticks: 3840,
221                            notes: vec![
222                                SessionNote { note: 60, velocity: 100, start_frac: 0.0, duration_frac: 0.25 },
223                                SessionNote { note: 64, velocity: 80, start_frac: 0.25, duration_frac: 0.25 },
224                            ],
225                        },
226                    ],
227                },
228            ],
229        };
230
231        let json = serde_json::to_string_pretty(&session).unwrap();
232        let loaded: SessionFile = serde_json::from_str(&json).unwrap();
233
234        assert_eq!(loaded.version, 1);
235        assert_eq!(loaded.transport.tempo_bpm, 120.0);
236        assert_eq!(loaded.transport.loop_enabled, true);
237        assert_eq!(loaded.tracks.len(), 1);
238        assert_eq!(loaded.tracks[0].name, "synth");
239        assert_eq!(loaded.tracks[0].instrument_type, "dx7");
240        assert_eq!(loaded.tracks[0].synth_params, vec![0.0, 0.5, 0.7]);
241        assert_eq!(loaded.tracks[0].clips.len(), 1);
242        assert_eq!(loaded.tracks[0].clips[0].notes.len(), 2);
243        assert_eq!(loaded.tracks[0].clips[0].notes[0].note, 60);
244    }
245
246    #[test]
247    fn instrument_type_round_trip() {
248        for inst in InstrumentType::ALL {
249            let s = instrument_type_to_string(*inst);
250            let back = string_to_instrument_type(&s);
251            assert_eq!(back, Some(*inst), "Failed round-trip for {s}");
252        }
253    }
254}