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
61fn instrument_type_to_string(t: InstrumentType) -> String {
62    match t {
63        InstrumentType::Synth => "synth".into(),
64        InstrumentType::DrumRack => "drums".into(),
65        InstrumentType::DX7 => "dx7".into(),
66        InstrumentType::Jupiter8 => "jupiter8".into(),
67        InstrumentType::Odyssey => "odyssey".into(),
68        InstrumentType::Juno60 => "juno60".into(),
69        InstrumentType::Sampler => "sampler".into(),
70    }
71}
72
73fn string_to_instrument_type(s: &str) -> Option<InstrumentType> {
74    match s {
75        "synth" => Some(InstrumentType::Synth),
76        "drums" => Some(InstrumentType::DrumRack),
77        "dx7" => Some(InstrumentType::DX7),
78        "jupiter8" => Some(InstrumentType::Jupiter8),
79        "odyssey" => Some(InstrumentType::Odyssey),
80        "juno60" => Some(InstrumentType::Juno60),
81        "sampler" => Some(InstrumentType::Sampler),
82        _ => None,
83    }
84}
85
86// ── Save ──
87
88pub fn save(path: &Path, nav: &NavState, transport: &Transport) -> Result<()> {
89    let session = extract_session(nav, transport);
90    let json = serde_json::to_string_pretty(&session)?;
91
92    // Ensure parent directory exists
93    if let Some(parent) = path.parent() {
94        if !parent.exists() {
95            std::fs::create_dir_all(parent)?;
96        }
97    }
98
99    // Atomic write: write to tmp, then rename
100    let tmp = path.with_extension("phos.tmp");
101    std::fs::write(&tmp, &json)?;
102    std::fs::rename(&tmp, path)?;
103
104    tracing::debug!("session saved: {}", path.display());
105    Ok(())
106}
107
108fn extract_session(nav: &NavState, transport: &Transport) -> SessionFile {
109    let mut tracks = Vec::new();
110
111    for track in &nav.tracks {
112        // Only save instrument tracks (not bus tracks)
113        if track.instrument_type.is_none() {
114            continue;
115        }
116
117        let clips: Vec<SessionClip> = track.clips.iter().map(|clip| {
118            SessionClip {
119                start_tick: clip.start_tick,
120                length_ticks: clip.length_ticks,
121                notes: clip.notes.iter().map(|n| SessionNote {
122                    note: n.note,
123                    velocity: n.velocity,
124                    start_frac: n.start_frac,
125                    duration_frac: n.duration_frac,
126                }).collect(),
127            }
128        }).collect();
129
130        tracks.push(SessionTrack {
131            name: track.name.clone(),
132            instrument_type: track.instrument_type
133                .map(instrument_type_to_string)
134                .unwrap_or_default(),
135            synth_params: track.synth_params.clone(),
136            muted: track.muted,
137            soloed: track.soloed,
138            armed: track.armed,
139            volume: track.volume,
140            color_index: track.color_index,
141            clips,
142        });
143    }
144
145    SessionFile {
146        version: 1,
147        transport: SessionTransport {
148            tempo_bpm: transport.tempo_bpm(),
149            loop_enabled: nav.loop_editor.enabled,
150            loop_start_bar: nav.loop_editor.start_bar,
151            loop_end_bar: nav.loop_editor.end_bar,
152            metronome: transport.is_metronome_on(),
153        },
154        tracks,
155    }
156}
157
158// ── Load ──
159
160pub fn load(path: &Path) -> Result<SessionFile> {
161    let json = std::fs::read_to_string(path)?;
162    let session: SessionFile = serde_json::from_str(&json)?;
163    tracing::debug!("session loaded: {} (v{}, {} tracks)",
164        path.display(), session.version, session.tracks.len());
165    Ok(session)
166}
167
168/// Get the InstrumentType from a session track string.
169pub fn parse_instrument_type(s: &str) -> Option<InstrumentType> {
170    string_to_instrument_type(s)
171}
172
173/// Get the notes for a clip as NoteSnapshots.
174pub fn session_notes_to_snapshots(notes: &[SessionNote]) -> Vec<phosphor_core::clip::NoteSnapshot> {
175    notes.iter().map(|n| phosphor_core::clip::NoteSnapshot {
176        note: n.note,
177        velocity: n.velocity,
178        start_frac: n.start_frac,
179        duration_frac: n.duration_frac,
180    }).collect()
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn round_trip_serialize() {
189        let session = SessionFile {
190            version: 1,
191            transport: SessionTransport {
192                tempo_bpm: 120.0,
193                loop_enabled: true,
194                loop_start_bar: 1,
195                loop_end_bar: 5,
196                metronome: true,
197            },
198            tracks: vec![
199                SessionTrack {
200                    name: "synth".into(),
201                    instrument_type: "dx7".into(),
202                    synth_params: vec![0.0, 0.5, 0.7],
203                    muted: false,
204                    soloed: false,
205                    armed: true,
206                    volume: 0.75,
207                    color_index: 2,
208                    clips: vec![
209                        SessionClip {
210                            start_tick: 0,
211                            length_ticks: 3840,
212                            notes: vec![
213                                SessionNote { note: 60, velocity: 100, start_frac: 0.0, duration_frac: 0.25 },
214                                SessionNote { note: 64, velocity: 80, start_frac: 0.25, duration_frac: 0.25 },
215                            ],
216                        },
217                    ],
218                },
219            ],
220        };
221
222        let json = serde_json::to_string_pretty(&session).unwrap();
223        let loaded: SessionFile = serde_json::from_str(&json).unwrap();
224
225        assert_eq!(loaded.version, 1);
226        assert_eq!(loaded.transport.tempo_bpm, 120.0);
227        assert_eq!(loaded.transport.loop_enabled, true);
228        assert_eq!(loaded.tracks.len(), 1);
229        assert_eq!(loaded.tracks[0].name, "synth");
230        assert_eq!(loaded.tracks[0].instrument_type, "dx7");
231        assert_eq!(loaded.tracks[0].synth_params, vec![0.0, 0.5, 0.7]);
232        assert_eq!(loaded.tracks[0].clips.len(), 1);
233        assert_eq!(loaded.tracks[0].clips[0].notes.len(), 2);
234        assert_eq!(loaded.tracks[0].clips[0].notes[0].note, 60);
235    }
236
237    #[test]
238    fn instrument_type_round_trip() {
239        for inst in InstrumentType::ALL {
240            let s = instrument_type_to_string(*inst);
241            let back = string_to_instrument_type(&s);
242            assert_eq!(back, Some(*inst), "Failed round-trip for {s}");
243        }
244    }
245}