diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 0515d0b..dcf39b1 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -174,6 +174,7 @@ fn main() -> eframe::Result { Box::new(move |cc| { let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) }; let mut app = SoundApp::new(engine, player, services()); + app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave"))); if let Some(ui) = load_prefs() { app.ui = ui; } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 1608f6d..d7afba6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -301,11 +301,28 @@ pub fn sanitize_name(n: &str) -> String { /// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written /// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written. pub fn save_session(e: &mut Engine, path: &str) -> Result { + let (fresh, written, p) = write_session(e.session(), path, false)?; + // Keep the in-memory document in sync (paths, unsaved flags) without an undo step. + *e.session_mut() = fresh; + e.path = Some(p.to_string_lossy().into_owned()); + e.mark_clean(); + Ok(written) +} + +/// Write a copy of the session (and any audio it needs) without touching the open document — +/// used for autosave / crash recovery. Audio files in the target folder are overwritten in place. +pub fn save_copy(e: &Engine, path: &str) -> Result { + write_session(e.session(), path, true).map(|(_, n, _)| n) +} + +/// Write `session` to `path`; returns the session with updated media paths, the number of audio +/// files written and the final path. +fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> { let p = PathBuf::from(path); let p = if p.extension().is_none() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }; let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); let audio_dir = dir.join("Audio Files"); - let mut s = e.session().clone(); + let mut s = session.clone(); let mut written = 0; for src in &mut s.sources { let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name)); @@ -316,12 +333,22 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result { let Some(audio) = s.pool.get(src.id) else { continue }; std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?; let mut target = rel_target.clone(); - let mut k = 1; - while dir.join(&target).exists() && !(exists_rel && target == src.path) { - target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name)); - k += 1; - if k > 999 { - break; + if overwrite { + // One file per source id so autosaves never collide or pile up. + target = format!("Audio Files/{}_{}.wav", sanitize_name(&src.name), src.id.0); + if dir.join(&target).exists() { + src.path = target; + src.unsaved = false; + continue; + } + } else { + let mut k = 1; + while dir.join(&target).exists() && !(exists_rel && target == src.path) { + target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name)); + k += 1; + if k > 999 { + break; + } } } let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None }; @@ -332,7 +359,7 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result { src.format = FileFormat::Wav; written += 1; } - if let Some(stem) = p.file_stem().and_then(|x| x.to_str()) { + if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) { s.name = stem.to_string(); } let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; @@ -340,12 +367,7 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result { let tmp = p.with_extension("scraft.tmp"); std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?; std::fs::rename(&tmp, &p).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?; - // Keep the in-memory document in sync (paths, unsaved flags) without an undo step. - let fresh = s; - *e.session_mut() = fresh; - e.path = Some(p.to_string_lossy().into_owned()); - e.mark_clean(); - Ok(written) + Ok((s, written, p)) } /// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal). @@ -421,3 +443,26 @@ pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec>, ra e.push_undo("Record", before); Ok(out) } + +#[cfg(test)] +mod save_tests { + use super::*; + + #[test] + fn autosave_copy_leaves_document_untouched_and_reuses_audio() { + let mut e = crate::demo::demo_engine(); + e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap(); + let dir = std::env::temp_dir().join(format!("soundcraft-autosave-{}", std::process::id())); + let path = dir.join("Midnight Groove.scraft"); + let p = path.to_string_lossy().into_owned(); + let n1 = save_copy(&e, &p).unwrap(); + let n2 = save_copy(&e, &p).unwrap(); + assert!(n1 >= 5); + assert_eq!(n2, 0, "second autosave must not rewrite audio"); + assert!(e.is_dirty() && e.path.is_none()); + let mut r = Engine::default(); + open_session(&mut r, &p).unwrap(); + assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b1c683f..96e24e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -166,9 +166,14 @@ pub struct SoundApp { /// Input capture, opened on first record. pub recorder: Option, record_start: Samples, + /// Punch range to trim the take to (pre/post-roll recordings). + punch: Option, recorder_failed: bool, pub ui: UiState, pub services: Services, + /// Folder for crash-recovery autosaves (native apps set it). + pub autosave_dir: Option, + last_autosave: f64, pub dialogs: dialogs::Dialogs, pub meters: HashMap, pub main_meter: MeterDisplay, @@ -202,9 +207,12 @@ impl SoundApp { player, recorder: None, record_start: 0, + punch: None, recorder_failed: false, ui: UiState::default(), services, + autosave_dir: None, + last_autosave: 0.0, dialogs: dialogs::Dialogs::default(), meters: HashMap::new(), main_meter: MeterDisplay::default(), @@ -305,21 +313,72 @@ impl SoundApp { if let Some(p) = &self.player { p.set_recording(true); } - self.record_start = self.engine.session().edit.selection.start; + let already_playing = self.is_playing(); if let Some(r) = &self.recorder { r.arm(); } self.engine.transport.recording = true; - if !self.is_playing() { - let sel = self.engine.session().edit.selection; - if self.engine.session().edit.loop_record && !sel.is_empty() { - self.start_play(sel.start, None, Some(sel)); - } else { - // Record into the selection when there is one (punch), else open-ended. - let end = (!sel.is_empty()).then_some(sel.end); - self.start_play(sel.start, end, None); + if already_playing { + // Punch in on the fly at the current playhead. + self.record_start = self.position(); + self.punch = None; + return; + } + let e = self.engine.session().edit.clone(); + let sel = e.selection; + if e.loop_record && !sel.is_empty() { + self.record_start = sel.start; + self.punch = None; + self.start_play(sel.start, None, Some(sel)); + } else { + // Record into the selection when there is one (punch), with pre/post-roll if enabled; + // the take is trimmed back to the punch range afterwards. + let (pre, post) = if e.pre_post_roll { (e.pre_roll, e.post_roll) } else { (0, 0) }; + let from = (sel.start - pre).max(0); + self.record_start = from; + self.punch = (!sel.is_empty()).then_some(sel); + let end = (!sel.is_empty()).then_some(sel.end + post); + self.start_play(from, end, None); + } + } + + /// Every two minutes, copy a modified session to the autosave folder (not while recording). + fn autosave(&mut self, now: f64) { + let Some(dir) = self.autosave_dir.clone() else { return }; + if self.last_autosave == 0.0 { + self.last_autosave = now; + } + if now - self.last_autosave < 120.0 || !self.engine.is_dirty() || self.engine.transport.recording { + return; + } + self.last_autosave = now; + let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name); + let path = dir.join(&name).join(format!("{name}.scraft")); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + match soundcraft_engine::io::save_copy(&self.engine, &path.to_string_lossy()) { + Ok(_) => log::info!("autosaved to {}", path.display()), + Err(e) => log::warn!("autosave failed: {e}"), + } + } + + /// The most recent autosave, if any. + pub fn latest_autosave(&self) -> Option { + let dir = self.autosave_dir.as_ref()?; + let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None; + for d in std::fs::read_dir(dir).ok()?.flatten() { + for f in std::fs::read_dir(d.path()).into_iter().flatten().flatten() { + let p = f.path(); + if p.extension().and_then(|x| x.to_str()) == Some("scraft") + && let Ok(m) = f.metadata().and_then(|m| m.modified()) + && best.as_ref().is_none_or(|(t, _)| m > *t) + { + best = Some((m, p)); + } } } + best.map(|(_, p)| p) } /// Open the input device once (for recording and input monitoring) and feed the player. @@ -376,7 +435,18 @@ impl SoundApp { return; } match soundcraft_engine::io::add_recording(&mut self.engine, self.record_start, take.channels, rate) { - Ok(ids) => self.ui.status = format!("Recorded {} clip(s)", ids.len()), + Ok(ids) => { + if let Some(p) = self.punch.take() { + // Keep only the punch range of each new take (merged into the Record undo step). + let clip_ids: Vec = ids.iter().map(|c| c.0).collect(); + let _ = self.engine.execute_merged( + "edit.trim_to_fill_selection", + &serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}), + "record", + ); + } + self.ui.status = format!("Recorded {} clip(s)", ids.len()); + } Err(e) => self.ui.status = e.to_string(), } } @@ -572,6 +642,7 @@ impl SoundApp { } self.handle_transport(dt); self.write_automation(ctx); + self.autosave(now); if self.recorder.is_none() && !self.recorder_failed && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)