From b5ea46527477c426b47241058d48f6f2e5a078a0 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:36:15 +0100 Subject: [PATCH 01/11] Autosave each session to its own folder and offer unsaved work at launch Autosave wrote every session called "Untitled" to the same Autosave/Untitled folder and reused audio files there by source id, so a second untitled session, or a take recorded after an undo, could be autosaved with another take's audio. Its first run also copied all of the session's audio again. Nothing ever offered an autosave back (latest_autosave had no callers), so after a crash the work could only be found by digging in the settings folder. Each open session now autosaves to a folder of its own, locked while the session is open. Audio already saved with the session is referenced where it is; audio that exists only in memory is copied once per recording or render. Saving, closing the session, opening another and quitting with SoundCraft's Quit delete the folder. A crash, or an exit that skips Quit with unsaved changes, leaves it behind, after one last autosave when SoundCraft gets the chance. At the next launch, SoundCraft offers each kept folder, newest first, to recover or discard. Recovered work opens as an unsaved version of the session it came from, so Save writes it back there; unsaved changes in the session it replaces are kept the same way. Folders older than their session file are dropped. --- apps/soundcraft/src/main.rs | 2 + crates/engine/src/autosave.rs | 268 +++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/ui-egui/src/lib.rs | 74 ++++++++- crates/ui-egui/src/recovery.rs | 48 ++++++ crates/ui-egui/tests/recovery.rs | 103 ++++++++++++ 6 files changed, 489 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/autosave.rs create mode 100644 crates/ui-egui/src/recovery.rs create mode 100644 crates/ui-egui/tests/recovery.rs diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 28723b7a..65db539b 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -88,6 +88,7 @@ impl eframe::App for App { self.0.ui(ui); } fn on_exit(&mut self) { + self.0.close_autosave(); save_prefs(&self.0.ui); if let Some(logger) = self.1 { logger.report_audio_thread(); @@ -258,6 +259,7 @@ fn main() -> eframe::Result { 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"))); + app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default(); app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets"))); app.extra.ui_scale = prefs.ui_scale; app.ui = prefs; diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs new file mode 100644 index 00000000..a9897188 --- /dev/null +++ b/crates/engine/src/autosave.rs @@ -0,0 +1,268 @@ +//! Crash-recovery copies of open sessions, one folder each, and the recovery of the ones left +//! behind with unsaved work. + +use crate::io::{open_session, sanitize_name}; +use crate::{Engine, EngineError, Result}; +use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat}; +use soundcraft_model::{Session, SourceAudio, SourceId}; +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Weak}; +use std::time::SystemTime; + +const SESSION: &str = "session.scraft"; +const ORIGIN: &str = "origin"; +const LOCK: &str = "lock"; + +/// An open session's autosave folder, locked while the session is open so a later launch can tell +/// it from one a crash left behind. +pub struct Autosave { + dir: PathBuf, + lock: Option, + copies: BTreeMap, String)>, + next: u64, +} + +impl Autosave { + /// A new folder under `root` for the session called `name`. + pub fn create(root: &Path, name: &str) -> Result { + let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis(); + let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id())); + let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?; + lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; + Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1 }) + } + + /// Write the session as it is now. Audio saved with it is referenced where it is; audio that + /// exists only in memory is copied, once per recording or render. + pub fn write(&mut self, e: &Engine) -> Result<()> { + let base = e.path.as_deref().and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok()); + let audio_dir = self.dir.join("Audio Files"); + let mut s = e.session().clone(); + let mut copies = BTreeMap::new(); + for src in &mut s.sources { + let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) }; + let audio = s.pool.get(src.id); + if let Some(f) = &file + && (!src.unsaved && f.is_file() || audio.is_none()) + { + src.path = f.to_string_lossy().into_owned(); + continue; + } + let Some(audio) = audio else { continue }; + let name = match self.copies.remove(&src.id) { + Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name, + _ => { + let name = format!("{} {}.wav", sanitize_name(&src.name), self.next); + self.next += 1; + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None }; + let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?; + std::fs::create_dir_all(&audio_dir) + .and_then(|()| write_synced(&audio_dir.join(&name), &bytes)) + .map_err(|err| io_error(&audio_dir, err))?; + name + } + }; + src.path = audio_dir.join(&name).to_string_lossy().into_owned(); + src.unsaved = true; + copies.insert(src.id, (Arc::downgrade(audio), name)); + } + let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + write_atomically(&self.dir.join(ORIGIN), e.path.as_deref().unwrap_or_default().as_bytes())?; + write_atomically(&self.dir.join(SESSION), text.as_bytes())?; + for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() { + let _ = std::fs::remove_file(audio_dir.join(name)); + } + Ok(()) + } + + /// Delete the folder: the session closed with nothing left to recover. + pub fn discard(mut self) { + drop(self.lock.take()); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// Unsaved work left behind in an autosave folder. +#[derive(Debug, Clone, PartialEq)] +pub struct Leftover { + pub dir: PathBuf, + /// The session's name. + pub name: String, + /// The session file it was autosaved from, if it had one. + pub origin: Option, + pub saved: SystemTime, +} + +/// Leftover autosaves under `root`, newest first: folders no open session holds, written after the +/// session file they came from. Empty and outdated ones are deleted. +pub fn leftovers(root: &Path) -> Vec { + let mut out = Vec::new(); + for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|d| d.path()) { + let Ok(lock) = std::fs::OpenOptions::new().write(true).open(dir.join(LOCK)) else { continue }; + if lock.try_lock().is_err() { + continue; + } + let origin = std::fs::read_to_string(dir.join(ORIGIN)).ok().filter(|o| !o.is_empty()); + let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok()); + let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified()); + drop(lock); + match saved { + Ok(saved) if origin_saved.is_none_or(|o| saved > o) => { + let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default(); + let named = serde_json::from_str::(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string)); + let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned()); + out.push(Leftover { dir, name, origin, saved }); + } + _ => { + let _ = std::fs::remove_dir_all(&dir); + } + } + } + out.sort_by_key(|l| std::cmp::Reverse(l.saved)); + out +} + +/// Open `left` as an unsaved version of the session it came from, so Save writes it back there. +/// Returns the media that couldn't be loaded. +pub fn recover(e: &mut Engine, left: &Leftover) -> Result> { + let mut r = Engine::default(); + let missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?; + let mut s = r.session().clone(); + if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) { + for src in s.sources.iter_mut().filter(|src| !src.unsaved) { + if let Ok(rel) = Path::new(&src.path).strip_prefix(home) { + src.path = rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::>().join("/"); + } + } + } + e.replace_session(Session::default()); + *e.session_mut() = s; + e.path = left.origin.clone(); + Ok(missing) +} + +/// Delete a leftover autosave. +pub fn discard(left: &Leftover) { + let _ = std::fs::remove_dir_all(&left.dir); +} + +fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut f = std::fs::File::create(path)?; + f.write_all(bytes)?; + f.sync_all() +} + +fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> { + let tmp = path.with_extension("tmp"); + write_synced(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|err| { + let _ = std::fs::remove_file(&tmp); + io_error(path, err) + }) +} + +fn io_error(path: &Path, err: std::io::Error) -> EngineError { + EngineError::Io(format!("{}: {err}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use soundcraft_audio_io::AudioBuffer; + use soundcraft_model::{ChannelFormat, Clip, TrackKind}; + + fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + fn record(e: &mut Engine, level: f32) { + let before = e.session_arc(); + let s = e.session_mut(); + let t = match s.tracks.first() { + Some(t) => t.id, + None => s.add_track(TrackKind::Audio, ChannelFormat::Mono, None), + }; + let src = + crate::io::add_source(s, "Audio 1_01", AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; 4_800]] }, None, FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(s, t, Clip::audio(id, "Audio 1_01", src, 0, 0, 4_800)); + e.push_undo("Record", before); + } + + fn levels(e: &Engine) -> Vec { + let s = e.session(); + s.sources.iter().map(|src| s.pool.get(src.id).map_or(f32::NAN, |a| a.buffer.channels[0][0])).collect() + } + + #[test] + fn saved_audio_is_referenced_and_takes_are_copied_once() { + let dir = folder("autosave-copies"); + let path = dir.join("Song/Song.scraft"); + let mut e = Engine::default(); + record(&mut e, 0.5); + crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap(); + record(&mut e, -0.25); + let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap(); + a.write(&e).unwrap(); + a.write(&e).unwrap(); + assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1); + drop(a); + let left = leftovers(&dir.join("Autosave")); + let mut r = Engine::default(); + assert_eq!(recover(&mut r, &left[0]).unwrap(), Vec::::new()); + let paths: Vec<_> = r.session().sources.iter().map(|s| (s.path.starts_with("Audio Files/"), s.unsaved)).collect(); + assert_eq!(paths, [(true, false), (false, true)]); + assert_eq!(levels(&r), [0.5, -0.25]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_take_recorded_after_undo_gets_its_own_copy() { + let dir = folder("autosave-undo"); + let mut e = Engine::default(); + record(&mut e, 0.5); + let mut a = Autosave::create(&dir, "Untitled").unwrap(); + a.write(&e).unwrap(); + e.execute("edit.undo", &json!({})).unwrap(); + record(&mut e, -0.25); + a.write(&e).unwrap(); + drop(a); + let mut r = Engine::default(); + recover(&mut r, &leftovers(&dir)[0]).unwrap(); + assert_eq!(levels(&r), [-0.25]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unsaved_work_is_offered_once_its_session_closes() { + let dir = folder("autosave-leftovers"); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + let root = dir.join("Autosave"); + std::fs::create_dir_all(&dir).unwrap(); + let mut e = Engine::default(); + crate::io::save_session(&mut e, &path).unwrap(); + record(&mut e, 0.5); + let mut a = Autosave::create(&root, "Song").unwrap(); + a.write(&e).unwrap(); + assert!(leftovers(&root).is_empty()); + drop(a); + let left = leftovers(&root); + assert_eq!((left.len(), left[0].name.as_str(), left[0].origin.as_deref()), (1, "Song", Some(path.as_str()))); + let mut r = Engine::default(); + recover(&mut r, &left[0]).unwrap(); + assert!(r.is_dirty()); + r.execute("session.save", &json!({})).unwrap(); + assert!(leftovers(&root).is_empty()); + assert!(!left[0].dir.exists()); + let mut reopened = Engine::default(); + open_session(&mut reopened, &path).unwrap(); + assert_eq!(levels(&reopened), [0.5]); + Autosave::create(&root, "Song").unwrap().discard(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b607534d..c2a0b2d8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -5,6 +5,7 @@ //! The engine owns the [`Session`] behind an `Arc` (copy-on-write), so undo snapshots are cheap. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod autosave; pub mod catalog; pub mod catalog_more; pub mod clip_group_file; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a7514..30ea7f07 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ pub mod mix_window; pub mod ops_windows; pub mod palette; pub mod panels; +pub mod recovery; pub mod score_editor; pub mod shortcuts; pub mod theme; @@ -232,6 +233,10 @@ pub struct SoundApp { pub native_menu_bar: bool, /// Folder for crash-recovery autosaves (native apps set it). pub autosave_dir: Option, + /// This session's autosave folder, once it has been autosaved. + autosaved: Option, + /// Unsaved work that earlier runs left in the autosave folder, offered for recovery. + pub leftovers: Vec, /// Folder for user plugin presets (native apps set it). pub preset_dir: Option, last_autosave: Option, @@ -296,6 +301,8 @@ impl SoundApp { services, native_menu_bar: false, autosave_dir: None, + autosaved: None, + leftovers: Vec::new(), preset_dir: None, last_autosave: None, dialogs: dialogs::Dialogs::default(), @@ -343,8 +350,12 @@ impl SoundApp { // Third-party plugins keep state the session can't see: store it first. self.capture_plugin_states(); } + let replacing = matches!(id, "session.new" | "session.open" | "session.close" | "session.revert"); match self.engine.execute(id, ¶ms) { Ok(v) => { + if replacing || (id.starts_with("session.save") && !self.engine.is_dirty()) { + self.release_autosave(false); + } if id == "app.quit" { self.quit_requested = true; } @@ -520,14 +531,62 @@ impl SoundApp { } self.last_autosave = Some(std::time::Instant::now()); self.capture_plugin_states(); - 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); + self.autosave_to(&dir); + } + + /// Write the session to its own folder under `root`; false when that failed. + fn autosave_to(&mut self, root: &std::path::Path) -> bool { + let written = match &mut self.autosaved { + Some(a) => a.write(&self.engine), + None => soundcraft_engine::autosave::Autosave::create(root, &self.engine.session().name) + .and_then(|mut a| a.write(&self.engine).map(|()| self.autosaved = Some(a))), + }; + if let Err(e) = &written { + log::warn!("autosave failed: {e}"); + self.ui.status = format!("Autosave failed: {e}"); } - 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}"), + written.is_ok() + } + + /// Autosave now, if the app has an autosave folder. + fn write_autosave(&mut self) -> bool { + self.autosave_dir.clone().is_some_and(|root| self.autosave_to(&root)) + } + + /// Done with this session's autosave folder: keep it for the next launch, or delete it. + fn release_autosave(&mut self, keep: bool) { + if let Some(a) = self.autosaved.take() + && !keep + { + a.discard(); + } + } + + /// Done with the open session. Unsaved changes stay in its autosave folder for the next + /// launch, unless the user quit with SoundCraft's Quit. + pub fn close_autosave(&mut self) { + let keep = self.engine.is_dirty() && !self.quit_requested; + if keep { + self.capture_plugin_states(); + self.write_autosave(); + } + self.release_autosave(keep); + } + + /// Replace the open session with unsaved work an earlier run left behind. + pub fn recover(&mut self, left: &soundcraft_engine::autosave::Leftover) { + self.close_autosave(); + match soundcraft_engine::autosave::recover(&mut self.engine, left) { + Ok(missing) => { + if self.write_autosave() { + soundcraft_engine::autosave::discard(left); + } + self.ui.status = match missing.len() { + 0 => format!("Recovered “{}”. Save it to keep the changes.", left.name), + n => format!("Recovered “{}”, but {n} audio file(s) are missing. Save it to keep the changes.", left.name), + }; + } + Err(e) => self.ui.status = format!("Couldn't recover “{}”: {e}", left.name), } } @@ -899,6 +958,7 @@ impl SoundApp { video_window::show(self, &ctx); ops_windows::show(self, &ctx); extra_windows::show(self, &ctx); + recovery::show(self, &ctx); score_editor::show(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs new file mode 100644 index 00000000..be2810c6 --- /dev/null +++ b/crates/ui-egui/src/recovery.rs @@ -0,0 +1,48 @@ +//! The offer to recover unsaved work an earlier run left in the autosave folder. + +use crate::SoundApp; +use crate::theme::{Tokens, bold}; +use egui::{Align2, vec2}; + +/// Offer the newest leftover autosave, one at a time. +pub fn show(app: &mut SoundApp, ctx: &egui::Context) { + let Some(left) = app.leftovers.first().cloned() else { return }; + let mut open = true; + let mut recover = None; + egui::Window::new(egui::RichText::new("Recover Unsaved Work").font(bold(13.0))) + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0)) + .show(ctx, |ui| { + let age = left.saved.elapsed().map_or_else(|_| "recently".into(), |d| ago(d.as_secs())); + ui.label(format!("“{}” has unsaved changes from an earlier run, autosaved {age}.", left.name)); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Discard").clicked() { + recover = Some(false); + } + if ui.add(egui::Button::new(egui::RichText::new("Recover").strong()).fill(Tokens::current().accent)).clicked() { + recover = Some(true); + } + }); + }); + if !open || recover.is_some() { + app.leftovers.retain(|l| *l != left); + } + match recover { + Some(true) => app.recover(&left), + Some(false) => soundcraft_engine::autosave::discard(&left), + None => {} + } +} + +fn ago(secs: u64) -> String { + let (n, unit) = match secs { + 0..60 => return "less than a minute ago".into(), + 60..3_600 => (secs / 60, "minute"), + 3_600..86_400 => (secs / 3_600, "hour"), + _ => (secs / 86_400, "day"), + }; + format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }) +} diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs new file mode 100644 index 00000000..73e0367b --- /dev/null +++ b/crates/ui-egui/tests/recovery.rs @@ -0,0 +1,103 @@ +//! Unsaved work outlives a crash or an exit without Quit and is offered at the next launch. +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_engine::autosave::leftovers; +use soundcraft_ui_egui::{Services, SoundApp}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, PoisonError}; + +/// One test at a time: a child process another test starts (fontconfig's `fc-match`) holds copies +/// of this process's open files until it execs, so a released autosave lock can still look taken. +fn one_at_a_time() -> MutexGuard<'static, ()> { + static ONE: Mutex<()> = Mutex::new(()); + ONE.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn launch(root: &Path) -> SoundApp { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.autosave_dir = Some(root.to_path_buf()); + app.leftovers = leftovers(root); + app +} + +#[test] +fn an_exit_without_quit_offers_unsaved_changes_at_the_next_launch() { + let _one = one_at_a_time(); + let dir = folder("recovery-quit"); + let root = dir.join("Autosave"); + let path = dir.join("Song.scraft"); + let mut first = launch(&root); + first.run("session.save_as", json!({"path": path})).unwrap(); + first.run("track.new", json!({"name": "Vocal"})).unwrap(); + first.close_autosave(); + drop(first); + + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + launch(&root), + ); + h.run_steps(3); + h.get_by_label("Recover").click(); + h.run_steps(3); + let e = &h.state().engine; + assert!(e.session().track_by_name("Vocal").is_some() && e.is_dirty()); + assert_eq!(e.path, Some(path.to_string_lossy().into_owned())); + assert!(leftovers(&root).is_empty()); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1); + h.state_mut().run("session.save", json!({})).unwrap(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn recovering_keeps_the_unsaved_work_it_replaces() { + let _one = one_at_a_time(); + let dir = folder("recovery-replace"); + let root = dir.join("Autosave"); + let mut first = launch(&root); + first.run("track.new", json!({"name": "Vocal"})).unwrap(); + first.close_autosave(); + let left = leftovers(&root); + assert_eq!((left.len(), left[0].name.as_str(), left[0].origin.as_deref()), (1, "Untitled", None)); + let mut next = launch(&root); + next.run("track.new", json!({"name": "Drums"})).unwrap(); + next.recover(&left[0]); + assert!(next.engine.session().track_by_name("Vocal").is_some() && next.engine.path.is_none()); + let kept = leftovers(&root); + assert_eq!(kept.len(), 1); + let mut third = launch(&root); + third.recover(&kept[0]); + assert!(third.engine.session().track_by_name("Drums").is_some()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn closing_the_session_or_quitting_drops_its_autosave() { + let _one = one_at_a_time(); + let dir = folder("recovery-close"); + let root = dir.join("Autosave"); + for end in ["session.close", "app.quit"] { + let mut first = launch(&root); + first.run("track.new", json!({"name": "Vocal"})).unwrap(); + first.close_autosave(); + let mut next = launch(&root); + let left = next.leftovers[0].clone(); + next.recover(&left); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1, "{end}"); + next.run(end, json!({})).unwrap(); + next.close_autosave(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0, "{end}"); + } + let _ = std::fs::remove_dir_all(&dir); +} From 5fff2e8c78c7302bf898f73435c03c4b493b0e85 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:37:20 +0100 Subject: [PATCH 02/11] Keep working when the interface hits a bug Nothing caught a panic outside a command: one in the interface's frame code ended the process in that frame (exit code 101), losing every unsaved change, and the log got a single line with no backtrace. Each frame's logic and drawing now run guarded. A panic there autosaves the session (once per change, so a bug that repeats every frame doesn't flood the disk), says so in the status line and lets the app carry on. If the app goes down after all, the autosave is offered at the next launch. The panic hook now logs a backtrace. --- apps/soundcraft/src/main.rs | 4 ++-- crates/engine/src/lib.rs | 2 +- crates/ui-egui/src/lib.rs | 18 ++++++++++++++++++ crates/ui-egui/tests/recovery.rs | 21 +++++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 65db539b..a04353f7 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -44,7 +44,7 @@ impl eframe::App for App { if let Some(menu) = &mut self.2 { menu.process(&mut self.0); } - self.0.logic(ctx); + self.0.guarded(|app| app.logic(ctx)); #[cfg(target_os = "macos")] if let Some(menu) = &mut self.2 { menu.sync(&self.0); @@ -85,7 +85,7 @@ impl eframe::App for App { self.0.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.0.guarded(|app| app.ui(ui)); } fn on_exit(&mut self) { self.0.close_autosave(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2a0b2d8..66c87944 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -375,7 +375,7 @@ fn install_panic_hook() { ONCE.call_once(|| { let prev = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - log::error!("panic: {info}"); + log::error!("panic: {info}\n{}", std::backtrace::Backtrace::force_capture()); prev(info); })); }); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 30ea7f07..b3389022 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -237,6 +237,8 @@ pub struct SoundApp { autosaved: Option, /// Unsaved work that earlier runs left in the autosave folder, offered for recovery. pub leftovers: Vec, + /// The document revision last autosaved after a caught panic. + panic_rev: u64, /// Folder for user plugin presets (native apps set it). pub preset_dir: Option, last_autosave: Option, @@ -303,6 +305,7 @@ impl SoundApp { autosave_dir: None, autosaved: None, leftovers: Vec::new(), + panic_rev: 0, preset_dir: None, last_autosave: None, dialogs: dialogs::Dialogs::default(), @@ -573,6 +576,21 @@ impl SoundApp { self.release_autosave(keep); } + /// Run part of a frame. If it panics, the session is autosaved (once per change) and the app + /// carries on instead of quitting. + pub fn guarded(&mut self, f: impl FnOnce(&mut SoundApp)) { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))).is_ok() || self.panic_rev == self.engine.revision { + return; + } + self.panic_rev = self.engine.revision; + let saved = self.engine.is_dirty() && self.write_autosave(); + self.ui.status = if saved { + "SoundCraft hit an internal error (see the log). Your changes were autosaved: save them, then restart SoundCraft.".into() + } else { + "SoundCraft hit an internal error (see the log). Restart SoundCraft to be safe.".into() + }; + } + /// Replace the open session with unsaved work an earlier run left behind. pub fn recover(&mut self, left: &soundcraft_engine::autosave::Leftover) { self.close_autosave(); diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs index 73e0367b..69b8b73d 100644 --- a/crates/ui-egui/tests/recovery.rs +++ b/crates/ui-egui/tests/recovery.rs @@ -101,3 +101,24 @@ fn closing_the_session_or_quitting_drops_its_autosave() { } let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_panic_in_a_frame_autosaves_and_the_app_carries_on() { + let _one = one_at_a_time(); + let dir = folder("recovery-panic"); + let root = dir.join("Autosave"); + let mut app = launch(&root); + app.run("track.new", json!({"name": "Vocal"})).unwrap(); + app.guarded(|_| std::panic::panic_any("a bug in a frame")); + assert!(app.ui.status.contains("autosaved"), "{}", app.ui.status); + app.guarded(|app| { + app.run("track.new", json!({"name": "Bass"})).unwrap(); + }); + assert!(app.engine.session().track_by_name("Bass").is_some()); + drop(app); + let left = leftovers(&root); + let mut next = launch(&root); + next.recover(&left[0]); + assert!(next.engine.session().track_by_name("Vocal").is_some()); + let _ = std::fs::remove_dir_all(&dir); +} From 98d543f0d816d426e94e3036da6d5fbd90199dc3 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:40:21 +0100 Subject: [PATCH 03/11] Go silent instead of crashing when the audio engine hits a bug Nothing caught a panic in the audio output callback. On macOS it unwinds into CoreAudio's thread, which most likely aborts the app; on Windows and Linux the stream thread dies while the transport keeps showing playback with no sound. A panic in the silent clock that runs without an audio device stopped the clock for good. Each block is now filled under a guard. A panic makes that block and every later one silent and marks the engine failed. The app then stops the transport, keeping what was recorded, and says to reconnect the audio device or restart. --- crates/playback/src/lib.rs | 61 ++++++++++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 4 +++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index bd63c382..54d6dc61 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -76,6 +76,30 @@ struct Shared { meters: Mutex, } +/// Set once a block panicked: output stays silent from then on. +#[derive(Default)] +struct Fault { + failed: AtomicBool, + /// The UI has been told. + reported: AtomicBool, +} + +impl Fault { + fn newly_failed(&self) -> bool { + self.failed.load(Ordering::Relaxed) && !self.reported.swap(true, Ordering::Relaxed) + } +} + +/// Fill one block with `fill`. If it panics, the block and every later one are silent and the +/// transport stops, rather than the panic taking the app down. +fn guarded_block(fault: &Fault, shared: &Shared, out: &mut [f32], fill: impl FnOnce(&mut [f32])) { + if fault.failed.load(Ordering::Relaxed) || std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fill(out))).is_err() { + fault.failed.store(true, Ordering::Relaxed); + shared.playing.store(false, Ordering::Relaxed); + out.fill(0.0); + } +} + /// State owned by the audio callback. struct AudioState { rx: Receiver, @@ -347,6 +371,7 @@ impl Side { /// The transport and audio device. pub struct Player { shared: Arc, + fault: Arc, tx: Sender, side: Mutex, #[cfg(not(target_arch = "wasm32"))] @@ -370,6 +395,11 @@ impl Player { p } + /// True once after the audio engine hit an internal error and went silent. + pub fn newly_failed(&self) -> bool { + self.fault.newly_failed() + } + fn side(&self) -> std::sync::MutexGuard<'_, Side> { self.side.lock().unwrap_or_else(PoisonError::into_inner) } @@ -391,6 +421,8 @@ impl Player { let rate = config.sample_rate.0; let channels = usize::from(config.channels); let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx); + let fault = Arc::new(Fault::default()); + let (failed, guard) = (Arc::clone(&fault), Arc::clone(&shared)); // cpal may call this on the audio thread (on ALSA in a loop while the device // stays broken): marked, so the logger hands the record off instead of writing it. let err_fn = |e: cpal::StreamError| { @@ -402,7 +434,7 @@ impl Player { &config, move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { mark_audio_thread(); - state.fill(data, channels); + guarded_block(&failed, &guard, data, |d| state.fill(d, channels)); }, err_fn, None, @@ -414,6 +446,7 @@ impl Player { log::info!("audio device: {name} @ {rate} Hz, {channels} ch"); Player { shared, + fault, tx, side, _stream: Some(stream), @@ -465,6 +498,8 @@ impl Player { ) -> Player { let rate = session.sample_rate.hz(); let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx); + let fault = Arc::new(Fault::default()); + let failed = Arc::clone(&fault); let spawn = std::thread::Builder::new().name("soundcraft-clock".into()).spawn(move || { let mut buf = vec![0.0f32; 4096]; let mut last = std::time::Instant::now(); @@ -475,7 +510,8 @@ impl Player { last = now; let n = (frames * 2).min(buf.len()); if let Some(b) = buf.get_mut(..n) { - state.fill(b, 2); + let shared = Arc::clone(&state.shared); + guarded_block(&failed, &shared, b, |b| state.fill(b, 2)); } if Arc::strong_count(&state.shared) <= 1 { break; @@ -487,6 +523,7 @@ impl Player { } Player { shared, + fault, tx, side, _stream: None, @@ -508,6 +545,7 @@ impl Player { ) -> Player { Player { shared, + fault: Arc::default(), tx, side, _stream: None, @@ -765,6 +803,25 @@ mod tests { assert_eq!(out.main, source.main); } + #[test] + fn a_block_that_panics_goes_silent_and_is_reported_once() { + let shared = Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(true), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }; + let fault = Fault::default(); + let mut out = [0.5f32; 8]; + guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25)); + assert_eq!((out, fault.newly_failed()), ([0.25; 8], false)); + guarded_block(&fault, &shared, &mut out, |_| std::panic::panic_any("a bug in the mix")); + guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25)); + assert_eq!(out, [0.0; 8]); + assert!(!shared.playing.load(Ordering::Relaxed)); + assert_eq!((fault.newly_failed(), fault.newly_failed()), (true, false)); + } + #[test] fn audio_state_plays_and_stops_at_end() { let s = Arc::new(soundcraft_model::Session::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b3389022..2632ba00 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -894,6 +894,10 @@ impl SoundApp { self.engine.end_merge(); } } + if self.player.as_ref().is_some_and(Player::newly_failed) { + self.stop_play(); + self.ui.status = "The audio engine hit an internal error (see the log) and went silent. Use Reconnect audio device in Setup ▸ Playback Engine, or restart SoundCraft.".into(); + } // Stop the old playback before sending a replacement session to the audio thread. self.handle_transport(dt); if self.engine.revision != self.last_rev { From 334cf40506aa9bcbb4a6f109f6c0267790681180 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:18:15 +0100 Subject: [PATCH 04/11] Autosave after every change, on a thread of its own Autosave ran every two minutes and not at all while recording, so a crash could lose minutes of work. It also wrote on the UI thread: the first autosave after a recording copied the take, which could freeze the window for seconds. The open session now goes to a writer thread after every change, recording or not. The first change after a pause is written at once; while changes keep coming, as in a fader drag, the newest is written at most once a second. The UI waits for the writer only where it needs the result: after a caught panic, when recovering and when quitting. A bug in a write is caught and reported, and the next change tries again. Plugins' own state still reaches the session every two minutes and not while recording, since plugins save it on the audio thread. --- crates/engine/src/autosave.rs | 208 ++++++++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 54 +++++--- crates/ui-egui/tests/recovery.rs | 38 +++++- 3 files changed, 267 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index a9897188..063380b3 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -1,5 +1,5 @@ -//! Crash-recovery copies of open sessions, one folder each, and the recovery of the ones left -//! behind with unsaved work. +//! Crash-recovery copies of open sessions, one folder each, written in the background, and the +//! recovery of the ones left behind with unsaved work. use crate::io::{open_session, sanitize_name}; use crate::{Engine, EngineError, Result}; @@ -8,16 +8,162 @@ use soundcraft_model::{Session, SourceAudio, SourceId}; use std::collections::BTreeMap; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Weak}; -use std::time::SystemTime; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; +use std::sync::{Arc, Mutex, PoisonError, Weak}; +use std::time::{Duration, Instant, SystemTime}; const SESSION: &str = "session.scraft"; const ORIGIN: &str = "origin"; const LOCK: &str = "lock"; +/// While changes keep coming, the newest is written at most this often. +const INTERVAL: Duration = Duration::from_secs(1); +const STOPPED: &str = "the autosave thread stopped"; + +/// Writes autosaves on a thread of its own, so the UI never waits on the disk. +pub struct Autosaver { + jobs: Option>, + thread: Option>, + failure: Arc>>, +} + +enum Job { + Write(Arc, Option), + /// Done with the session's folder: keep it for the next launch, or delete it. + Close(bool), + Flush(Sender>), +} + +impl Autosaver { + /// A writer for autosave folders under `root`. + pub fn new(root: PathBuf) -> Autosaver { + let (tx, rx) = channel(); + let failure = Arc::new(Mutex::new(None)); + let writer = Writer { root, failure: Arc::clone(&failure), folder: None, pending: None, last: None, written: Ok(()) }; + let thread = std::thread::Builder::new().name("soundcraft-autosave".into()).spawn(move || writer.run(&rx)); + if let Err(e) = &thread { + report(&failure, format!("{STOPPED}: {e}")); + } + Autosaver { jobs: Some(tx), thread: thread.ok(), failure } + } + + /// Autosave `session`, opened from the session file `origin`, soon. + pub fn write(&self, session: Arc, origin: Option) { + self.send(Job::Write(session, origin)); + } + + /// Done with the open session's folder: keep it for the next launch, or delete it. + pub fn close(&self, keep: bool) { + self.send(Job::Close(keep)); + } + + /// Wait until everything handed over so far is done. A failure returned here isn't reported + /// again by `failure`. + pub fn flush(&self) -> Result<()> { + let (tx, rx) = channel(); + self.send(Job::Flush(tx)); + let done = rx.recv().unwrap_or_else(|_| Err(STOPPED.into())); + if done.is_err() { + self.failure(); + } + done.map_err(EngineError::Io) + } + + /// The newest failure since the last call. + pub fn failure(&self) -> Option { + self.failure.lock().unwrap_or_else(PoisonError::into_inner).take() + } + + fn send(&self, job: Job) { + if self.jobs.as_ref().is_none_or(|tx| tx.send(job).is_err()) { + report(&self.failure, STOPPED.into()); + } + } +} + +impl Drop for Autosaver { + /// What was handed over is written, and an open folder stays for the next launch. + fn drop(&mut self) { + drop(self.jobs.take()); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +/// The writer thread. +struct Writer { + root: PathBuf, + failure: Arc>>, + /// The open session's folder. + folder: Option, + /// The newest session not written yet. + pending: Option<(Arc, Option)>, + last: Option, + /// How the newest write into the open folder went, for `Flush`. + written: std::result::Result<(), String>, +} + +impl Writer { + fn run(mut self, jobs: &Receiver) { + loop { + let wait = match (&self.pending, self.last) { + (None, _) => Duration::MAX, + (Some(_), last) => last.map_or(Duration::ZERO, |t| INTERVAL.saturating_sub(t.elapsed())), + }; + match jobs.recv_timeout(wait) { + Ok(Job::Write(session, origin)) => self.pending = Some((session, origin)), + Ok(Job::Flush(reply)) => { + self.write_pending(); + let _ = reply.send(self.written.clone()); + } + Ok(Job::Close(keep)) => { + if keep { + self.write_pending(); + } + self.pending = None; + self.written = Ok(()); + if let Some(a) = self.folder.take() + && !keep + { + a.discard(); + } + } + Err(RecvTimeoutError::Timeout) => self.write_pending(), + Err(RecvTimeoutError::Disconnected) => { + self.write_pending(); + return; + } + } + } + } + + fn write_pending(&mut self) { + let Some((session, origin)) = self.pending.take() else { return }; + self.last = Some(Instant::now()); + let written = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> { + if self.folder.is_none() { + self.folder = Some(Autosave::create(&self.root, &session.name)?); + } + self.folder.as_mut().map_or(Ok(()), |a| a.write(&session, origin.as_deref())) + })); + self.written = match written { + Ok(written) => written.map_err(|e| e.to_string()), + Err(_) => Err("internal error (see the log)".into()), + }; + if let Err(e) = &self.written { + report(&self.failure, e.clone()); + } + } +} + +fn report(failure: &Mutex>, e: String) { + log::warn!("autosave failed: {e}"); + *failure.lock().unwrap_or_else(PoisonError::into_inner) = Some(e); +} /// An open session's autosave folder, locked while the session is open so a later launch can tell /// it from one a crash left behind. -pub struct Autosave { +struct Autosave { dir: PathBuf, lock: Option, copies: BTreeMap, String)>, @@ -26,7 +172,7 @@ pub struct Autosave { impl Autosave { /// A new folder under `root` for the session called `name`. - pub fn create(root: &Path, name: &str) -> Result { + fn create(root: &Path, name: &str) -> Result { let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis(); let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id())); let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?; @@ -34,12 +180,12 @@ impl Autosave { Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1 }) } - /// Write the session as it is now. Audio saved with it is referenced where it is; audio that - /// exists only in memory is copied, once per recording or render. - pub fn write(&mut self, e: &Engine) -> Result<()> { - let base = e.path.as_deref().and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok()); + /// Write `session`, opened from the session file `origin`. Audio saved with it is referenced + /// where it is; audio that exists only in memory is copied, once per recording or render. + fn write(&mut self, session: &Session, origin: Option<&str>) -> Result<()> { + let base = origin.and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok()); let audio_dir = self.dir.join("Audio Files"); - let mut s = e.session().clone(); + let mut s = session.clone(); let mut copies = BTreeMap::new(); for src in &mut s.sources { let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) }; @@ -69,7 +215,7 @@ impl Autosave { copies.insert(src.id, (Arc::downgrade(audio), name)); } let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; - write_atomically(&self.dir.join(ORIGIN), e.path.as_deref().unwrap_or_default().as_bytes())?; + write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?; write_atomically(&self.dir.join(SESSION), text.as_bytes())?; for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() { let _ = std::fs::remove_file(audio_dir.join(name)); @@ -78,7 +224,7 @@ impl Autosave { } /// Delete the folder: the session closed with nothing left to recover. - pub fn discard(mut self) { + fn discard(mut self) { drop(self.lock.take()); let _ = std::fs::remove_dir_all(&self.dir); } @@ -207,8 +353,8 @@ mod tests { crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap(); record(&mut e, -0.25); let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap(); - a.write(&e).unwrap(); - a.write(&e).unwrap(); + a.write(e.session(), e.path.as_deref()).unwrap(); + a.write(e.session(), e.path.as_deref()).unwrap(); assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1); drop(a); let left = leftovers(&dir.join("Autosave")); @@ -226,10 +372,10 @@ mod tests { let mut e = Engine::default(); record(&mut e, 0.5); let mut a = Autosave::create(&dir, "Untitled").unwrap(); - a.write(&e).unwrap(); + a.write(e.session(), None).unwrap(); e.execute("edit.undo", &json!({})).unwrap(); record(&mut e, -0.25); - a.write(&e).unwrap(); + a.write(e.session(), None).unwrap(); drop(a); let mut r = Engine::default(); recover(&mut r, &leftovers(&dir)[0]).unwrap(); @@ -237,6 +383,32 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn the_writer_saves_the_newest_change_and_drops_the_folder_when_done() { + let root = folder("autosave-writer"); + let w = Autosaver::new(root.clone()); + let mut e = Engine::default(); + for _ in 0..5 { + record(&mut e, 0.5); + w.write(e.session_arc(), None); + } + w.flush().unwrap(); + let file = std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join(SESSION)).collect::>(); + let mut r = Engine::default(); + open_session(&mut r, &file[0].to_string_lossy()).unwrap(); + assert_eq!((file.len(), r.session().sources.len()), (1, 5)); + w.close(false); + w.flush().unwrap(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + record(&mut e, 0.5); + w.write(e.session_arc(), None); + drop(w); + let mut r = Engine::default(); + recover(&mut r, &leftovers(&root)[0]).unwrap(); + assert_eq!(r.session().sources.len(), 6); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn unsaved_work_is_offered_once_its_session_closes() { let dir = folder("autosave-leftovers"); @@ -247,7 +419,7 @@ mod tests { crate::io::save_session(&mut e, &path).unwrap(); record(&mut e, 0.5); let mut a = Autosave::create(&root, "Song").unwrap(); - a.write(&e).unwrap(); + a.write(e.session(), e.path.as_deref()).unwrap(); assert!(leftovers(&root).is_empty()); drop(a); let left = leftovers(&root); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2632ba00..b20e1224 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -233,8 +233,10 @@ pub struct SoundApp { pub native_menu_bar: bool, /// Folder for crash-recovery autosaves (native apps set it). pub autosave_dir: Option, - /// This session's autosave folder, once it has been autosaved. - autosaved: Option, + /// Writes the open session's autosaves in the background, once there is something to write. + autosaver: Option, + /// The document revision last handed to the autosaver. + autosaved_rev: u64, /// Unsaved work that earlier runs left in the autosave folder, offered for recovery. pub leftovers: Vec, /// The document revision last autosaved after a caught panic. @@ -303,7 +305,8 @@ impl SoundApp { services, native_menu_bar: false, autosave_dir: None, - autosaved: None, + autosaver: None, + autosaved_rev: 0, leftovers: Vec::new(), panic_rev: 0, preset_dir: None, @@ -537,15 +540,36 @@ impl SoundApp { self.autosave_to(&dir); } - /// Write the session to its own folder under `root`; false when that failed. + /// Autosave a modified session within a second of every change, also while recording. The + /// plugins' own state reaches the session every two minutes (`autosave`). + fn autosave_changes(&mut self) { + if let Some(e) = self.autosaver.as_ref().and_then(soundcraft_engine::autosave::Autosaver::failure) { + self.ui.status = format!("Autosave failed: {e}"); + } + if self.autosave_dir.is_none() || self.engine.revision == self.autosaved_rev { + return; + } + self.autosaved_rev = self.engine.revision; + if !self.engine.is_dirty() { + self.release_autosave(false); + } else if let Some(root) = self.autosave_dir.clone() { + self.queue_autosave(root); + } + } + + /// Hand the session to the autosaver for its folder under `root`. + fn queue_autosave(&mut self, root: std::path::PathBuf) { + self.autosaved_rev = self.engine.revision; + let w = self.autosaver.get_or_insert_with(|| soundcraft_engine::autosave::Autosaver::new(root)); + w.write(self.engine.session_arc(), self.engine.path.clone()); + } + + /// Write the session to its own folder under `root` and wait for it; false when that failed. fn autosave_to(&mut self, root: &std::path::Path) -> bool { - let written = match &mut self.autosaved { - Some(a) => a.write(&self.engine), - None => soundcraft_engine::autosave::Autosave::create(root, &self.engine.session().name) - .and_then(|mut a| a.write(&self.engine).map(|()| self.autosaved = Some(a))), - }; + self.queue_autosave(root.to_path_buf()); + let Some(w) = &self.autosaver else { return false }; + let written = w.flush(); if let Err(e) = &written { - log::warn!("autosave failed: {e}"); self.ui.status = format!("Autosave failed: {e}"); } written.is_ok() @@ -558,10 +582,8 @@ impl SoundApp { /// Done with this session's autosave folder: keep it for the next launch, or delete it. fn release_autosave(&mut self, keep: bool) { - if let Some(a) = self.autosaved.take() - && !keep - { - a.discard(); + if let Some(w) = &self.autosaver { + w.close(keep); } } @@ -574,6 +596,9 @@ impl SoundApp { self.write_autosave(); } self.release_autosave(keep); + if let Some(w) = &self.autosaver { + let _ = w.flush(); + } } /// Run part of a frame. If it panics, the session is autosaved (once per change) and the app @@ -906,6 +931,7 @@ impl SoundApp { p.update_session(self.engine.session_arc()); } } + self.autosave_changes(); self.write_automation(ctx); self.autosave(); if self.recorder.is_none() diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs index 69b8b73d..4efe5967 100644 --- a/crates/ui-egui/tests/recovery.rs +++ b/crates/ui-egui/tests/recovery.rs @@ -21,6 +21,22 @@ fn folder(name: &str) -> PathBuf { dir } +/// Autosaves are written in the background. +fn eventually(done: impl Fn() -> bool) -> bool { + for _ in 0..300 { + if done() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + done() +} + +fn autosaved(root: &Path) -> String { + let folders = std::fs::read_dir(root).into_iter().flatten().flatten(); + folders.map(|d| std::fs::read_to_string(d.path().join("session.scraft")).unwrap_or_default()).collect() +} + fn launch(root: &Path) -> SoundApp { let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); app.autosave_dir = Some(root.to_path_buf()); @@ -56,7 +72,27 @@ fn an_exit_without_quit_offers_unsaved_changes_at_the_next_launch() { assert!(leftovers(&root).is_empty()); assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1); h.state_mut().run("session.save", json!({})).unwrap(); - assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + assert!(eventually(|| std::fs::read_dir(&root).unwrap().count() == 0)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn every_change_is_autosaved_within_a_second() { + let _one = one_at_a_time(); + let dir = folder("recovery-changes"); + let root = dir.join("Autosave"); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + launch(&root), + ); + for name in ["Vocal", "Bass"] { + h.state_mut().run("track.new", json!({"name": name})).unwrap(); + h.run_steps(1); + assert!(eventually(|| autosaved(&root).contains(name)), "{name}"); + } let _ = std::fs::remove_dir_all(&dir); } From ddb9c9543d88fafb04d0e3a97ddcc3858faac796 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:39:03 +0100 Subject: [PATCH 05/11] Stop recording and keep the take when the input or the disk fails A failing input only reached the log. When the interface was unplugged mid-take (on macOS its callbacks simply stop), when writing a take failed (a full disk), or when the input callback panicked, recording went on with the transport running and the take silently ended early. A panic in the callback could also take the input stream down for good. The recorder now reports trouble: the device was disconnected, it sent nothing for two seconds while recording, its callback hit a bug (caught, after which it does nothing more), or writing a take failed. The app stops recording at once, keeps the take up to that point and says what happened and what to do. Input lost to a slow disk is reported while recording instead of only at Stop. Reconnect audio device now reopens the input as well as the output, so monitoring works again afterwards. --- crates/playback/src/record.rs | 162 ++++++++++++++++++++++++++++++---- crates/ui-egui/src/lib.rs | 47 ++++++++++ crates/ui-egui/src/windows.rs | 2 +- 3 files changed, 195 insertions(+), 16 deletions(-) diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index ec784ac6..0039c950 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -8,8 +8,8 @@ use std::fs::File; use std::io::{Seek, SeekFrom, Write}; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, Instant}; use soundcraft_model::TrackId; @@ -18,6 +18,19 @@ use soundcraft_model::TrackId; pub type Takes = (Vec<(TrackId, PathBuf)>, Option); const RING_SECONDS: usize = 4; +/// How long a recording input may go without a callback before it counts as gone. +const STALL: Duration = Duration::from_secs(2); +const GONE: u8 = 1; +const BROKEN: u8 = 2; + +/// Why the input can't go on. +#[derive(Debug, Clone, PartialEq)] +pub enum Trouble { + /// The device went away or stopped sending audio, or its callback hit a bug. + Device(String), + /// Writing a take failed (a full disk, say). + Disk(String), +} /// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots /// are atomics, so it needs no lock and no `unsafe`. @@ -62,9 +75,29 @@ struct Shared { ring: CaptureRing, /// Samples lost while the ring was full. dropped: AtomicUsize, + /// Input callbacks so far, to tell a device that stopped sending. + beats: AtomicU64, + /// Set from the input stream when it can't go on: `GONE` or `BROKEN`. + fault: AtomicU8, + /// The first failure writing a take, until it's reported. + failed: Mutex>, } impl Shared { + fn new(ring: CaptureRing) -> Shared { + let (beats, fault, failed) = (AtomicU64::new(0), AtomicU8::new(0), Mutex::new(None)); + Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0), beats, fault, failed } + } + + /// Run the input callback's `work`. After a panic in it, the input is broken and does + /// nothing more. + fn guarded(&self, work: impl FnOnce()) { + self.beats.fetch_add(1, Ordering::Relaxed); + if self.fault.load(Ordering::Relaxed) != BROKEN && std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).is_err() { + self.fault.store(BROKEN, Ordering::Relaxed); + } + } + /// The input callback's work while armed. Samples that don't fit are owed (`owed`) and queued /// as silence before the next block, so the take keeps its timing. fn capture(&self, data: &[f32], owed: &mut usize) { @@ -85,6 +118,8 @@ pub struct Recorder { pub sample_rate: u32, pub channels: usize, writer: Option>, + /// The callback count last seen, and since when. + seen: (u64, Instant), } impl Recorder { @@ -103,8 +138,8 @@ impl Recorder { let config = cfg.config(); let channels = usize::from(config.channels).max(1); let ring = CaptureRing::new((config.sample_rate.0 as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25)); - let shared = Arc::new(Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0) }); - let sh = Arc::clone(&shared); + let shared = Arc::new(Shared::new(ring)); + let (sh, errors) = (Arc::clone(&shared), Arc::clone(&shared)); let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0); let mon = Arc::clone(&monitor); let mut owed = 0; @@ -113,23 +148,29 @@ impl Recorder { &config, move |data: &[f32], _: &cpal::InputCallbackInfo| { crate::mark_audio_thread(); - mon.push(data); - if sh.armed.load(Ordering::Acquire) { - sh.capture(data, &mut owed); - } else { - owed = 0; - } + sh.guarded(|| { + mon.push(data); + if sh.armed.load(Ordering::Acquire) { + sh.capture(data, &mut owed); + } else { + owed = 0; + } + }); }, - |e| { + move |e| { // Possibly the audio thread (see `crate::mark_audio_thread`). crate::mark_audio_thread(); + if matches!(e, cpal::StreamError::DeviceNotAvailable) { + let _ = errors.fault.compare_exchange(0, GONE, Ordering::Relaxed, Ordering::Relaxed); + } log::warn!("input stream error: {e}"); }, None, ) .map_err(|e| e.to_string())?; stream.play().map_err(|e| e.to_string())?; - Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None }) + let seen = (0, Instant::now()); + Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None, seen }) } #[cfg(target_os = "freebsd")] @@ -147,6 +188,7 @@ impl Recorder { takes.into_iter().map(|(id, inputs, path)| TakeWriter::create(id, inputs, path, self.sample_rate)).collect::, _>>()?; let ring = &self.shared.ring; ring.tail.store(ring.head.load(Ordering::Acquire), Ordering::Release); + *self.shared.failed.lock().unwrap_or_else(PoisonError::into_inner) = None; self.shared.armed.store(true, Ordering::Release); let (shared, channels) = (Arc::clone(&self.shared), self.channels); match std::thread::Builder::new().name("take writer".into()).spawn(move || write_takes(&shared, takes, channels)) { @@ -159,6 +201,31 @@ impl Recorder { Ok(()) } + /// Why the input can't go on, if it can't: the device went away or, while recording, stopped + /// sending audio; its callback hit a bug; or writing a take failed (reported once). + pub fn trouble(&mut self) -> Option { + if let Some(e) = self.shared.failed.lock().unwrap_or_else(PoisonError::into_inner).take() { + return Some(Trouble::Disk(e)); + } + let name = &self.device_name; + match self.shared.fault.load(Ordering::Relaxed) { + GONE => return Some(Trouble::Device(format!("“{name}” was disconnected"))), + BROKEN => return Some(Trouble::Device("the audio input hit an internal error (see the log)".into())), + _ => {} + } + let beats = self.shared.beats.load(Ordering::Relaxed); + if beats != self.seen.0 || !self.shared.armed.load(Ordering::Relaxed) { + self.seen = (beats, Instant::now()); + } + (self.seen.1.elapsed() >= STALL).then(|| Trouble::Device(format!("“{name}” stopped sending audio"))) + } + + /// Input the current take has lost to a slow disk so far, in milliseconds. + pub fn lost_ms(&self) -> u64 { + let frames = self.shared.dropped.load(Ordering::Relaxed) / self.channels.max(1); + (frames as u64).saturating_mul(1000) / u64::from(self.sample_rate.max(1)) + } + pub fn finish(&mut self) -> Takes { self.shared.armed.store(false, Ordering::Release); let (takes, problem) = self.writer.take().and_then(|w| w.join().ok()).unwrap_or_default(); @@ -176,7 +243,7 @@ impl Drop for Recorder { } fn write_takes(shared: &Shared, mut takes: Vec, channels: usize) -> Takes { - let (mut block, mut header_written) = (Vec::new(), Instant::now()); + let (mut block, mut header_written, mut reported) = (Vec::new(), Instant::now(), false); loop { let stopping = !shared.armed.load(Ordering::Acquire); // The ring holds interleaved samples and a pop can end mid-frame: write whole frames and @@ -185,6 +252,10 @@ fn write_takes(shared: &Shared, mut takes: Vec, channels: usize) -> let whole = block.len() - block.len() % channels.max(1); takes.iter_mut().for_each(|t| t.write(&block[..whole], channels)); block.drain(..whole); + if !reported && let Some(e) = takes.iter().find_map(|t| t.error.clone()) { + *shared.failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(e); + reported = true; + } if stopping { break; } @@ -269,7 +340,7 @@ mod tests { #[test] fn a_full_ring_owes_the_lost_samples_as_silence() { - let shared = Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(8), dropped: AtomicUsize::new(0) }; + let shared = Shared::new(CaptureRing::new(8)); let (mut owed, mut out) = (0, Vec::new()); shared.capture(&[1.0; 6], &mut owed); shared.capture(&[2.0; 4], &mut owed); @@ -280,6 +351,66 @@ mod tests { assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2)); } + fn recorder(shared: &Arc) -> Recorder { + Recorder { + shared: Arc::clone(shared), + monitor: InputRing::new(1, 48_000), + #[cfg(not(target_os = "freebsd"))] + _stream: None, + device_name: "Mic In".into(), + sample_rate: 48_000, + channels: 1, + writer: None, + seen: (0, Instant::now()), + } + } + + #[test] + fn a_bug_a_stall_or_a_lost_device_is_trouble() { + let shared = Arc::new(Shared::new(CaptureRing::new(64))); + let mut r = recorder(&shared); + shared.armed.store(true, Ordering::Release); + assert_eq!(r.trouble(), None); + r.seen.1 = Instant::now().checked_sub(STALL).unwrap(); + assert_eq!(r.trouble(), Some(Trouble::Device("“Mic In” stopped sending audio".into()))); + shared.guarded(|| {}); + assert_eq!(r.trouble(), None, "it sent again"); + shared.armed.store(false, Ordering::Release); + r.seen.1 = Instant::now().checked_sub(STALL).unwrap(); + assert_eq!(r.trouble(), None, "a quiet input that isn't recording is fine"); + shared.guarded(|| std::panic::panic_any("a bug in the input")); + let mut ran = false; + shared.guarded(|| ran = true); + assert!(!ran); + assert_eq!(r.trouble(), Some(Trouble::Device("the audio input hit an internal error (see the log)".into()))); + let gone = Arc::new(Shared::new(CaptureRing::new(64))); + gone.fault.store(GONE, Ordering::Relaxed); + assert_eq!(recorder(&gone).trouble(), Some(Trouble::Device("“Mic In” was disconnected".into()))); + } + + #[test] + fn a_failed_write_is_trouble_while_still_recording() { + let dir = std::env::temp_dir().join(format!("soundcraft-failed-take-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut take = TakeWriter::create(TrackId(1), vec![0], dir.join("Vox_01.wav"), 48_000).unwrap(); + take.file = File::open(&take.path).unwrap(); + let shared = Arc::new(Shared::new(CaptureRing::new(1 << 12))); + shared.armed.store(true, Ordering::Release); + let mut r = recorder(&shared); + let sh = Arc::clone(&shared); + let writer = std::thread::spawn(move || write_takes(&sh, vec![take], 1)); + shared.capture(&[0.5; 480], &mut 0); + let trouble = (0..300).find_map(|_| { + std::thread::sleep(Duration::from_millis(10)); + r.trouble() + }); + assert!(matches!(&trouble, Some(Trouble::Disk(e)) if e.contains("Vox_01.wav")), "{trouble:?}"); + assert_eq!(r.trouble(), None, "reported once"); + shared.armed.store(false, Ordering::Release); + assert!(writer.join().unwrap().1.is_some()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn takes_stream_into_their_files() { let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id())); @@ -292,7 +423,8 @@ mod tests { vox.write(&[0.25; 150], 3); assert_eq!(decode(&dir.join("Vox_01.wav"))[0].len(), 100, "the header covers what it has seen"); - let shared = Arc::new(Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(1 << 16), dropped: AtomicUsize::new(0) }); + let shared = Arc::new(Shared::new(CaptureRing::new(1 << 16))); + shared.armed.store(true, Ordering::Release); let sh = Arc::clone(&shared); let writer = std::thread::spawn(move || write_takes(&sh, vec![gtr, vox], 3)); let input: Vec = (0..3 * 4_800).map(|i| i as f32 / 20_000.0).collect(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b20e1224..f4214e0d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ use serde_json::{Value, json}; use soundcraft_engine::{Engine, TransportRequest}; use soundcraft_model::TrackId; use soundcraft_playback::Player; +use soundcraft_playback::record::Trouble; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; use std::sync::mpsc::Receiver; @@ -226,6 +227,8 @@ pub struct SoundApp { /// Punch range to trim the take to (pre/post-roll recordings). punch: Option, recorder_failed: bool, + /// Input this take lost to a slow disk, as last reported (ms). + lost_shown: u64, pub ui: UiState, pub audio_health_report: Option>, pub services: Services, @@ -300,6 +303,7 @@ impl SoundApp { record_start: 0, punch: None, recorder_failed: false, + lost_shown: 0, ui: UiState::default(), audio_health_report: None, services, @@ -460,6 +464,7 @@ impl SoundApp { self.ui.status = format!("Cannot record: {err}"); return; } + self.lost_shown = 0; if let Some(p) = &self.player { p.set_recording(true); } @@ -676,6 +681,47 @@ impl SoundApp { } } + /// Stop recording, keeping the take, when the input device or the disk fails, and say as soon + /// as a slow disk loses input. + fn watch_input(&mut self) { + let recording = self.engine.transport.recording; + let Some(r) = &mut self.recorder else { return }; + let Some(trouble) = r.trouble() else { + let lost = r.lost_ms(); + if recording && lost > self.lost_shown { + self.lost_shown = lost; + self.ui.status = format!("The disk is too slow: this take has lost {lost} ms of input so far."); + } + return; + }; + if recording { + self.stop_play(); + } + let (what, then) = match trouble { + Trouble::Device(what) => { + self.recorder = None; + self.recorder_failed = true; + (what, "Reconnect it, then use Reconnect audio device in Setup ▸ Playback Engine.") + } + Trouble::Disk(what) => (what, "Check the disk, then record again."), + }; + self.ui.status = if recording { + format!("Recording stopped: {what}. The take is kept up to there. {then}") + } else { + format!("Audio input: {what}. {then}") + }; + } + + /// Reopen the audio devices, keeping a take in progress. + pub fn reconnect_audio(&mut self) { + if self.is_playing() || self.engine.transport.recording { + self.stop_play(); + } + self.recorder = None; + self.recorder_failed = false; + self.player = Some(Player::new(self.engine.session_arc())); + } + fn finish_recording(&mut self) { self.engine.transport.recording = false; if let Some(p) = &self.player { @@ -923,6 +969,7 @@ impl SoundApp { self.stop_play(); self.ui.status = "The audio engine hit an internal error (see the log) and went silent. Use Reconnect audio device in Setup ▸ Playback Engine, or restart SoundCraft.".into(); } + self.watch_input(); // Stop the old playback before sending a replacement session to the audio thread. self.handle_transport(dt); if self.engine.revision != self.last_rev { diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 12b35404..72c5d017 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -432,7 +432,7 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { let _ = app.run("options.delay_compensation", json!({"value": dc})); } if ui.button("Reconnect audio device").clicked() { - app.player = Some(soundcraft_playback::Player::new(app.engine.session_arc())); + app.reconnect_audio(); } }); app.ui.show_playback_engine = open; From 0b9ead430f4da9275c905744be95f88db7771767 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:42:39 +0100 Subject: [PATCH 06/11] Stop the transport when the audio output device goes away Losing the output device only reached the log: the transport kept "playing" a frozen playhead, and a recording ran on with nobody told. The output now reports trouble once: the device was disconnected, or it stopped asking for audio for two seconds while playing (not checked in the web build, which has no clock for it), or the engine hit a bug as before. The app stops the transport, keeps a take in progress, and says what happened and how to reconnect. --- crates/playback/src/lib.rs | 100 +++++++++++++++++++++++++++++++++---- crates/ui-egui/src/lib.rs | 35 +++++++++++-- 2 files changed, 121 insertions(+), 14 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 54d6dc61..cb94dabd 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -76,23 +76,66 @@ struct Shared { meters: Mutex, } -/// Set once a block panicked: output stays silent from then on. +/// What stopped the audio output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputTrouble { + /// A block panicked; the output stays silent. + Bug, + /// The device went away. + Gone, + /// The device stopped asking for audio while playing. + Stalled, +} + +/// How the audio output is doing, for the UI. #[derive(Default)] struct Fault { + /// A block panicked: output stays silent from then on. failed: AtomicBool, + gone: AtomicBool, + /// Blocks so far, to tell a device that stopped asking for audio. + beats: std::sync::atomic::AtomicU64, /// The UI has been told. reported: AtomicBool, } impl Fault { - fn newly_failed(&self) -> bool { - self.failed.load(Ordering::Relaxed) && !self.reported.swap(true, Ordering::Relaxed) + /// What stopped the output, once. + fn trouble(&self, stalled: bool) -> Option { + let trouble = if self.failed.load(Ordering::Relaxed) { + OutputTrouble::Bug + } else if self.gone.load(Ordering::Relaxed) { + OutputTrouble::Gone + } else if stalled { + OutputTrouble::Stalled + } else { + return None; + }; + (!self.reported.swap(true, Ordering::Relaxed)).then_some(trouble) + } +} + +/// How long a playing output may go without a block before it counts as stalled. +#[cfg(not(target_arch = "wasm32"))] +const STALL: std::time::Duration = std::time::Duration::from_secs(2); + +/// True once the block count `beats` has stood still for `STALL` while playing; `seen` is the +/// count last seen and since when. +#[cfg(not(target_arch = "wasm32"))] +fn stalled(seen: &mut Option<(u64, std::time::Instant)>, beats: u64, playing: bool) -> bool { + match *seen { + Some((b, since)) if playing && b == beats => since.elapsed() >= STALL, + _ => { + *seen = Some((beats, std::time::Instant::now())); + false + } } } /// Fill one block with `fill`. If it panics, the block and every later one are silent and the /// transport stops, rather than the panic taking the app down. fn guarded_block(fault: &Fault, shared: &Shared, out: &mut [f32], fill: impl FnOnce(&mut [f32])) { + fault.beats.fetch_add(1, Ordering::Relaxed); if fault.failed.load(Ordering::Relaxed) || std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fill(out))).is_err() { fault.failed.store(true, Ordering::Relaxed); shared.playing.store(false, Ordering::Relaxed); @@ -372,6 +415,9 @@ impl Side { pub struct Player { shared: Arc, fault: Arc, + /// The block count last seen while playing, and since when. + #[cfg(not(target_arch = "wasm32"))] + watch: Mutex>, tx: Sender, side: Mutex, #[cfg(not(target_arch = "wasm32"))] @@ -395,9 +441,17 @@ impl Player { p } - /// True once after the audio engine hit an internal error and went silent. - pub fn newly_failed(&self) -> bool { - self.fault.newly_failed() + /// What stopped the audio output, once: a bug in the engine, or the device went away or + /// stopped asking for audio while playing. + pub fn trouble(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + let stalled = { + let playing = self.shared.playing.load(Ordering::Relaxed) && !self.silent; + stalled(&mut self.watch.lock().unwrap_or_else(PoisonError::into_inner), self.fault.beats.load(Ordering::Relaxed), playing) + }; + #[cfg(target_arch = "wasm32")] + let stalled = false; + self.fault.trouble(stalled) } fn side(&self) -> std::sync::MutexGuard<'_, Side> { @@ -425,8 +479,12 @@ impl Player { let (failed, guard) = (Arc::clone(&fault), Arc::clone(&shared)); // cpal may call this on the audio thread (on ALSA in a loop while the device // stays broken): marked, so the logger hands the record off instead of writing it. - let err_fn = |e: cpal::StreamError| { + let gone = Arc::clone(&fault); + let err_fn = move |e: cpal::StreamError| { mark_audio_thread(); + if matches!(e, cpal::StreamError::DeviceNotAvailable) { + gone.gone.store(true, Ordering::Relaxed); + } log::warn!("audio stream error: {e}"); }; use cpal::traits::{DeviceTrait, StreamTrait}; @@ -447,6 +505,8 @@ impl Player { Player { shared, fault, + #[cfg(not(target_arch = "wasm32"))] + watch: Mutex::new(None), tx, side, _stream: Some(stream), @@ -524,6 +584,7 @@ impl Player { Player { shared, fault, + watch: Mutex::new(None), tx, side, _stream: None, @@ -814,12 +875,33 @@ mod tests { let fault = Fault::default(); let mut out = [0.5f32; 8]; guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25)); - assert_eq!((out, fault.newly_failed()), ([0.25; 8], false)); + assert_eq!((out, fault.trouble(false)), ([0.25; 8], None)); guarded_block(&fault, &shared, &mut out, |_| std::panic::panic_any("a bug in the mix")); guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25)); assert_eq!(out, [0.0; 8]); assert!(!shared.playing.load(Ordering::Relaxed)); - assert_eq!((fault.newly_failed(), fault.newly_failed()), (true, false)); + assert_eq!((fault.trouble(false), fault.trouble(false)), (Some(OutputTrouble::Bug), None)); + assert_eq!(fault.beats.load(Ordering::Relaxed), 3); + } + + #[test] + fn a_lost_or_stalled_device_is_reported_once() { + let ago = |seen: &mut Option<(u64, std::time::Instant)>| { + if let Some(s) = seen { + s.1 = std::time::Instant::now().checked_sub(STALL).unwrap(); + } + }; + let mut seen = None; + assert!(!stalled(&mut seen, 5, true)); + ago(&mut seen); + assert!(stalled(&mut seen, 5, true)); + assert!(!stalled(&mut seen, 6, true), "it asked again"); + ago(&mut seen); + assert!(!stalled(&mut seen, 6, false), "stopped, so nothing to ask for"); + assert_eq!(Fault::default().trouble(true), Some(OutputTrouble::Stalled)); + let fault = Fault::default(); + fault.gone.store(true, Ordering::Relaxed); + assert_eq!((fault.trouble(false), fault.trouble(true)), (Some(OutputTrouble::Gone), None)); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f4214e0d..259b9fd5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,8 +31,8 @@ pub mod windows; use serde_json::{Value, json}; use soundcraft_engine::{Engine, TransportRequest}; use soundcraft_model::TrackId; -use soundcraft_playback::Player; use soundcraft_playback::record::Trouble; +use soundcraft_playback::{OutputTrouble, Player}; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; use std::sync::mpsc::Receiver; @@ -681,6 +681,34 @@ impl SoundApp { } } + /// Stop the transport, keeping a take in progress, when the audio output fails. + fn watch_output(&mut self) { + let Some(p) = &self.player else { return }; + let Some(trouble) = p.trouble() else { return }; + let name = p.device_name.clone(); + let (cause, then) = match trouble { + OutputTrouble::Bug => ( + "the audio engine hit an internal error (see the log)".to_string(), + "Use Reconnect audio device in Setup ▸ Playback Engine, or restart SoundCraft.", + ), + OutputTrouble::Gone => { + (format!("“{name}” was disconnected"), "Plug it back in, then use Reconnect audio device in Setup ▸ Playback Engine.") + } + OutputTrouble::Stalled => (format!("“{name}” stopped asking for audio"), "Use Reconnect audio device in Setup ▸ Playback Engine."), + }; + let (recording, playing) = (self.engine.transport.recording, self.engine.transport.playing); + if recording || playing { + self.stop_play(); + } + self.ui.status = if recording { + format!("Recording stopped: {cause}. The take is kept up to there. {then}") + } else if playing { + format!("Playback stopped: {cause}. {then}") + } else { + format!("Audio output: {cause}. {then}") + }; + } + /// Stop recording, keeping the take, when the input device or the disk fails, and say as soon /// as a slow disk loses input. fn watch_input(&mut self) { @@ -965,10 +993,7 @@ impl SoundApp { self.engine.end_merge(); } } - if self.player.as_ref().is_some_and(Player::newly_failed) { - self.stop_play(); - self.ui.status = "The audio engine hit an internal error (see the log) and went silent. Use Reconnect audio device in Setup ▸ Playback Engine, or restart SoundCraft.".into(); - } + self.watch_output(); self.watch_input(); // Stop the old playback before sending a replacement session to the audio thread. self.handle_transport(dt); From 62cb92c4dfac2e66374cd352b65606f1b77ba483 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:45:14 +0100 Subject: [PATCH 07/11] Never replace or quit a session while it records Opening, creating, closing or reverting a session in the middle of a take dropped the recorder: the take was written to disk but never added to any session. Quitting mid-take did the same, and on_exit didn't finish the take either. New, Open, Close, Revert and Quit now wait for the take: the engine refuses them with "stop recording first", and their menu items are greyed out while recording, as is Recover in the recovery window. Any exit that still happens mid-take (closing the window, the system quitting the app, a remote quit) finishes the take first and keeps the session, take included, in the autosave folder for the next launch. --- crates/engine/src/lib.rs | 9 +++++++++ crates/engine/tests/recording.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 13 +++++++++++-- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/recovery.rs | 3 ++- crates/ui-egui/tests/recovery.rs | 16 ++++++++++++++++ 6 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/recording.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 66c87944..657f4957 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -252,10 +252,19 @@ impl Engine { } } + /// Whether command `id` must wait for the take being recorded: nothing may replace the + /// session or quit under it. + pub fn blocked_while_recording(&self, id: &str) -> bool { + self.transport.recording && matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit") + } + /// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the /// document is restored. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if self.blocked_while_recording(id) { + return Err(EngineError::Disabled(id.to_string(), "stop recording first".into())); + } if let Err(reason) = (spec.enabled)(self) { // A call that names its targets (tracks, clips, a range) does not need a selection; // the command itself validates them. diff --git a/crates/engine/tests/recording.rs b/crates/engine/tests/recording.rs new file mode 100644 index 00000000..fc754fc9 --- /dev/null +++ b/crates/engine/tests/recording.rs @@ -0,0 +1,21 @@ +//! A take being recorded is never replaced or quit away from. +use serde_json::json; +use soundcraft_engine::{Engine, EngineError}; + +#[test] +fn nothing_replaces_or_quits_a_session_while_it_records() { + let mut e = Engine::default(); + e.transport.recording = true; + for (id, params) in [ + ("session.new", json!({})), + ("session.open", json!({"path": "Song.scraft"})), + ("session.close", json!({})), + ("session.revert", json!({})), + ("app.quit", json!({})), + ] { + assert!(matches!(e.execute(id, ¶ms), Err(EngineError::Disabled(_, why)) if why == "stop recording first"), "{id}"); + } + assert!(e.execute("track.new", &json!({})).is_ok(), "editing goes on"); + e.transport.recording = false; + assert!(e.execute("session.close", &json!({})).is_ok()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 259b9fd5..16cd755f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -593,9 +593,14 @@ impl SoundApp { } /// Done with the open session. Unsaved changes stay in its autosave folder for the next - /// launch, unless the user quit with SoundCraft's Quit. + /// launch, unless the user quit with SoundCraft's Quit; a take still recording is finished + /// and always stays. pub fn close_autosave(&mut self) { - let keep = self.engine.is_dirty() && !self.quit_requested; + let recording = self.engine.transport.recording; + if recording { + self.stop_play(); + } + let keep = self.engine.is_dirty() && (recording || !self.quit_requested); if keep { self.capture_plugin_states(); self.write_autosave(); @@ -623,6 +628,10 @@ impl SoundApp { /// Replace the open session with unsaved work an earlier run left behind. pub fn recover(&mut self, left: &soundcraft_engine::autosave::Leftover) { + if self.engine.transport.recording { + self.ui.status = "Stop recording before recovering other work.".into(); + return; + } self.close_autosave(); match soundcraft_engine::autosave::recover(&mut self.engine, left) { Ok(missing) => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 32a74a49..0aa46ea1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -289,7 +289,7 @@ pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) { true } - Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()), + Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok() && !app.engine.blocked_while_recording(i)), None => false, }; (enabled, checked_state(app, path, id)) diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index be2810c6..838dd6dc 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -22,7 +22,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { if ui.button("Discard").clicked() { recover = Some(false); } - if ui.add(egui::Button::new(egui::RichText::new("Recover").strong()).fill(Tokens::current().accent)).clicked() { + let recover_button = egui::Button::new(egui::RichText::new("Recover").strong()).fill(Tokens::current().accent); + if ui.add_enabled(!app.engine.transport.recording, recover_button).on_disabled_hover_text("Stop recording first").clicked() { recover = Some(true); } }); diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs index 4efe5967..00ea2654 100644 --- a/crates/ui-egui/tests/recovery.rs +++ b/crates/ui-egui/tests/recovery.rs @@ -138,6 +138,22 @@ fn closing_the_session_or_quitting_drops_its_autosave() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn quitting_while_recording_keeps_the_take_for_the_next_launch() { + let _one = one_at_a_time(); + let dir = folder("recovery-recording"); + let root = dir.join("Autosave"); + let mut app = launch(&root); + app.run("track.new", json!({"name": "Vocal"})).unwrap(); + app.engine.transport.recording = true; + assert!(app.run("app.quit", json!({})).is_err(), "Quit waits for the take"); + app.quit_requested = true; + app.close_autosave(); + assert!(!app.engine.transport.recording); + assert_eq!(leftovers(&root).len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_panic_in_a_frame_autosaves_and_the_app_carries_on() { let _one = one_at_a_time(); From 30122bcea190bcffaa500016fce5b0cc9fe3362f Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:51:51 +0100 Subject: [PATCH 08/11] Recover the takes of a recording a crash interrupted Takes stream to disk while recording, but they only join the session at Stop. After a crash the files were on disk with nothing pointing at them and no record of where they started, and the header of each was up to a second behind its audio. When recording starts, its plan (each track's take file, where the takes start, the loop length and punch range) is autosaved with the session, and every autosave until Stop keeps it there. Stop places the takes from that same plan. Recovering a folder that still holds a plan first repairs each take's header to cover all of its audio, then places the takes as Stop would have, skipping any the session already has. The recovery window says when there's an unfinished recording, and such a folder is kept even if its session file was saved later. --- crates/engine/src/autosave.rs | 129 ++++++++++++++++++++++++++------- crates/engine/src/io.rs | 31 ++++++++ crates/ui-egui/src/lib.rs | 69 ++++++++++++------ crates/ui-egui/src/recovery.rs | 3 +- 4 files changed, 183 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 063380b3..9af29248 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -1,7 +1,7 @@ //! Crash-recovery copies of open sessions, one folder each, written in the background, and the //! recovery of the ones left behind with unsaved work. -use crate::io::{open_session, sanitize_name}; +use crate::io::{Recording, open_session, sanitize_name}; use crate::{Engine, EngineError, Result}; use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat}; use soundcraft_model::{Session, SourceAudio, SourceId}; @@ -15,6 +15,8 @@ use std::time::{Duration, Instant, SystemTime}; const SESSION: &str = "session.scraft"; const ORIGIN: &str = "origin"; const LOCK: &str = "lock"; +/// The plan of a recording still running when the folder was written. +const RECORDING: &str = "recording.json"; /// While changes keep coming, the newest is written at most this often. const INTERVAL: Duration = Duration::from_secs(1); const STOPPED: &str = "the autosave thread stopped"; @@ -27,7 +29,7 @@ pub struct Autosaver { } enum Job { - Write(Arc, Option), + Write(Arc, Option, Option), /// Done with the session's folder: keep it for the next launch, or delete it. Close(bool), Flush(Sender>), @@ -46,9 +48,10 @@ impl Autosaver { Autosaver { jobs: Some(tx), thread: thread.ok(), failure } } - /// Autosave `session`, opened from the session file `origin`, soon. - pub fn write(&self, session: Arc, origin: Option) { - self.send(Job::Write(session, origin)); + /// Autosave `session`, opened from the session file `origin`, soon, with the plan of the + /// recording running in it. + pub fn write(&self, session: Arc, origin: Option, recording: Option) { + self.send(Job::Write(session, origin, recording)); } /// Done with the open session's folder: keep it for the next launch, or delete it. @@ -97,7 +100,7 @@ struct Writer { /// The open session's folder. folder: Option, /// The newest session not written yet. - pending: Option<(Arc, Option)>, + pending: Option<(Arc, Option, Option)>, last: Option, /// How the newest write into the open folder went, for `Flush`. written: std::result::Result<(), String>, @@ -111,7 +114,7 @@ impl Writer { (Some(_), last) => last.map_or(Duration::ZERO, |t| INTERVAL.saturating_sub(t.elapsed())), }; match jobs.recv_timeout(wait) { - Ok(Job::Write(session, origin)) => self.pending = Some((session, origin)), + Ok(Job::Write(session, origin, recording)) => self.pending = Some((session, origin, recording)), Ok(Job::Flush(reply)) => { self.write_pending(); let _ = reply.send(self.written.clone()); @@ -138,13 +141,13 @@ impl Writer { } fn write_pending(&mut self) { - let Some((session, origin)) = self.pending.take() else { return }; + let Some((session, origin, recording)) = self.pending.take() else { return }; self.last = Some(Instant::now()); let written = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> { if self.folder.is_none() { self.folder = Some(Autosave::create(&self.root, &session.name)?); } - self.folder.as_mut().map_or(Ok(()), |a| a.write(&session, origin.as_deref())) + self.folder.as_mut().map_or(Ok(()), |a| a.write(&session, origin.as_deref(), recording.as_ref())) })); self.written = match written { Ok(written) => written.map_err(|e| e.to_string()), @@ -168,6 +171,8 @@ struct Autosave { lock: Option, copies: BTreeMap, String)>, next: u64, + /// A recording's plan is in the folder. + recording: bool, } impl Autosave { @@ -177,12 +182,13 @@ impl Autosave { let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id())); let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?; lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; - Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1 }) + Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false }) } - /// Write `session`, opened from the session file `origin`. Audio saved with it is referenced - /// where it is; audio that exists only in memory is copied, once per recording or render. - fn write(&mut self, session: &Session, origin: Option<&str>) -> Result<()> { + /// Write `session`, opened from the session file `origin`, and the plan of the `recording` + /// running in it. Audio saved with it is referenced where it is; audio that exists only in + /// memory is copied, once per recording or render. + fn write(&mut self, session: &Session, origin: Option<&str>, recording: Option<&Recording>) -> Result<()> { let base = origin.and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok()); let audio_dir = self.dir.join("Audio Files"); let mut s = session.clone(); @@ -220,6 +226,13 @@ impl Autosave { for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() { let _ = std::fs::remove_file(audio_dir.join(name)); } + if let Some(r) = recording { + let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?; + write_atomically(&self.dir.join(RECORDING), plan.as_bytes())?; + } else if self.recording { + let _ = std::fs::remove_file(self.dir.join(RECORDING)); + } + self.recording = recording.is_some(); Ok(()) } @@ -239,10 +252,12 @@ pub struct Leftover { /// The session file it was autosaved from, if it had one. pub origin: Option, pub saved: SystemTime, + /// A recording was still running in it. + pub recording: bool, } /// Leftover autosaves under `root`, newest first: folders no open session holds, written after the -/// session file they came from. Empty and outdated ones are deleted. +/// session file they came from or holding a recording. Empty and outdated ones are deleted. pub fn leftovers(root: &Path) -> Vec { let mut out = Vec::new(); for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|d| d.path()) { @@ -253,13 +268,14 @@ pub fn leftovers(root: &Path) -> Vec { let origin = std::fs::read_to_string(dir.join(ORIGIN)).ok().filter(|o| !o.is_empty()); let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok()); let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified()); + let recording = dir.join(RECORDING).is_file(); drop(lock); match saved { - Ok(saved) if origin_saved.is_none_or(|o| saved > o) => { + Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => { let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default(); let named = serde_json::from_str::(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string)); let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned()); - out.push(Leftover { dir, name, origin, saved }); + out.push(Leftover { dir, name, origin, saved, recording }); } _ => { let _ = std::fs::remove_dir_all(&dir); @@ -270,12 +286,14 @@ pub fn leftovers(root: &Path) -> Vec { out } -/// Open `left` as an unsaved version of the session it came from, so Save writes it back there. -/// Returns the media that couldn't be loaded. +/// Open `left` as an unsaved version of the session it came from, so Save writes it back there, +/// with the takes of a recording that never finished placed as Stop would have. Returns the +/// media that couldn't be loaded. pub fn recover(e: &mut Engine, left: &Leftover) -> Result> { let mut r = Engine::default(); let missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?; let mut s = r.session().clone(); + let unfinished = unfinished_takes(&left.dir, &s); if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) { for src in s.sources.iter_mut().filter(|src| !src.unsaved) { if let Ok(rel) = Path::new(&src.path).strip_prefix(home) { @@ -286,9 +304,38 @@ pub fn recover(e: &mut Engine, left: &Leftover) -> Result> { e.replace_session(Session::default()); *e.session_mut() = s; e.path = left.origin.clone(); + if let Some((plan, takes)) = unfinished { + crate::io::place_recording(e, &plan, takes); + } Ok(missing) } +/// The takes of the recording `dir` holds the plan of, repaired, minus those already in `s`. +fn unfinished_takes(dir: &Path, s: &Session) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> { + let plan: Recording = serde_json::from_str(&std::fs::read_to_string(dir.join(RECORDING)).ok()?).ok()?; + let known: Vec = s.sources.iter().filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect(); + let takes = plan + .takes + .iter() + .filter(|t| std::fs::canonicalize(&t.path).is_ok_and(|p| !known.contains(&p))) + .filter(|t| repair_take(&t.path, t.channels, plan.rate).is_ok_and(|frames| frames > 0)) + .map(|t| (t.track, t.path.clone())) + .collect(); + Some((plan, takes)) +} + +/// Make a take file's header cover all the audio in it; returns its frames. The recorder +/// rewrites the header only every second, so a crash leaves up to a second uncovered. +fn repair_take(path: &Path, channels: usize, rate: u32) -> std::io::Result { + let header = |frames| soundcraft_audio_io::wav_float_header(channels, rate, frames).map_err(std::io::Error::other); + let audio = std::fs::metadata(path)?.len().saturating_sub(header(0)?.len() as u64); + let frames = audio / (channels.max(1) as u64 * 4); + let mut f = std::fs::OpenOptions::new().write(true).open(path)?; + f.write_all(&header(frames)?)?; + f.sync_all()?; + Ok(frames) +} + /// Delete a leftover autosave. pub fn discard(left: &Leftover) { let _ = std::fs::remove_dir_all(&left.dir); @@ -353,8 +400,8 @@ mod tests { crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap(); record(&mut e, -0.25); let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap(); - a.write(e.session(), e.path.as_deref()).unwrap(); - a.write(e.session(), e.path.as_deref()).unwrap(); + a.write(e.session(), e.path.as_deref(), None).unwrap(); + a.write(e.session(), e.path.as_deref(), None).unwrap(); assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1); drop(a); let left = leftovers(&dir.join("Autosave")); @@ -372,10 +419,10 @@ mod tests { let mut e = Engine::default(); record(&mut e, 0.5); let mut a = Autosave::create(&dir, "Untitled").unwrap(); - a.write(e.session(), None).unwrap(); + a.write(e.session(), None, None).unwrap(); e.execute("edit.undo", &json!({})).unwrap(); record(&mut e, -0.25); - a.write(e.session(), None).unwrap(); + a.write(e.session(), None, None).unwrap(); drop(a); let mut r = Engine::default(); recover(&mut r, &leftovers(&dir)[0]).unwrap(); @@ -390,7 +437,7 @@ mod tests { let mut e = Engine::default(); for _ in 0..5 { record(&mut e, 0.5); - w.write(e.session_arc(), None); + w.write(e.session_arc(), None, None); } w.flush().unwrap(); let file = std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join(SESSION)).collect::>(); @@ -401,7 +448,7 @@ mod tests { w.flush().unwrap(); assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); record(&mut e, 0.5); - w.write(e.session_arc(), None); + w.write(e.session_arc(), None, None); drop(w); let mut r = Engine::default(); recover(&mut r, &leftovers(&root)[0]).unwrap(); @@ -409,6 +456,38 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn a_take_still_recording_at_a_crash_is_placed_on_recovery() { + use crate::io::Take; + let dir = folder("autosave-take"); + let root = dir.join("Autosave"); + let mut e = Engine::default(); + let track = + soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap()); + let take = dir.join("Audio Files/Vox_01.wav"); + std::fs::create_dir_all(take.parent().unwrap()).unwrap(); + let audio: Vec = (0..480).flat_map(|i| (i as f32 / 1000.0).to_le_bytes()).collect(); + std::fs::write(&take, [soundcraft_audio_io::wav_float_header(1, 48_000, 100).unwrap(), audio].concat()).unwrap(); + let plan = Recording { start: 48_000, pass: None, punch: None, rate: 48_000, takes: vec![Take { track, path: take, channels: 1 }] }; + let clips = |e: &Engine| e.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.length)).collect::>(); + let w = Autosaver::new(root.clone()); + w.write(e.session_arc(), None, Some(plan.clone())); + drop(w); + let left = leftovers(&root); + assert!(left[0].recording); + let mut r = Engine::default(); + recover(&mut r, &left[0]).unwrap(); + assert_eq!(clips(&r), [(48_000, 480)], "all of the take, its last second included"); + discard(&left[0]); + let w = Autosaver::new(root.clone()); + w.write(r.session_arc(), None, Some(plan)); + drop(w); + let mut again = Engine::default(); + recover(&mut again, &leftovers(&root)[0]).unwrap(); + assert_eq!((clips(&again), again.session().sources.len()), (vec![(48_000, 480)], 1), "placed once"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn unsaved_work_is_offered_once_its_session_closes() { let dir = folder("autosave-leftovers"); @@ -419,7 +498,7 @@ mod tests { crate::io::save_session(&mut e, &path).unwrap(); record(&mut e, 0.5); let mut a = Autosave::create(&root, "Song").unwrap(); - a.write(e.session(), e.path.as_deref()).unwrap(); + a.write(e.session(), e.path.as_deref(), None).unwrap(); assert!(leftovers(&root).is_empty()); drop(a); let left = leftovers(&root); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e4..7b3d6369 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -925,6 +925,37 @@ pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec< .collect() } +/// A recording's plan, made when it starts: each record-enabled track's take file, where the +/// takes start on the timeline, the loop length they split into when loop recording, and the +/// punch range they're trimmed to. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Recording { + pub start: Samples, + pub pass: Option, + pub punch: Option, + /// The input's sample rate, which the take files are written at. + pub rate: u32, + pub takes: Vec, +} + +/// One track's take file. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Take { + pub track: TrackId, + pub path: PathBuf, + pub channels: usize, +} + +/// Place recorded take files as `plan` says, as one undo step ("Record"). +pub fn place_recording(e: &mut Engine, plan: &Recording, takes: Vec<(TrackId, PathBuf)>) -> Vec { + let ids = add_recording(e, plan.start, takes, plan.pass); + if let Some(p) = plan.punch { + let clips: Vec = ids.iter().map(|c| c.0).collect(); + let _ = e.execute_merged("edit.trim_to_fill_selection", &json!({"clips": clips, "start": p.start, "end": p.end}), "record"); + } + ids +} + /// Turn recorded take files into clips at `start`, as one undo step ("Record"); each file is the /// source of its clips. With `pass` (loop recording), a take becomes one clip per pass (at most /// 1000), each pass after the first on a new playlist. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 16cd755f..2661072a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -229,6 +229,8 @@ pub struct SoundApp { recorder_failed: bool, /// Input this take lost to a slow disk, as last reported (ms). lost_shown: u64, + /// Where the takes being recorded go, autosaved with the session so a crash can't lose them. + take_plan: Option, pub ui: UiState, pub audio_health_report: Option>, pub services: Services, @@ -238,7 +240,7 @@ pub struct SoundApp { pub autosave_dir: Option, /// Writes the open session's autosaves in the background, once there is something to write. autosaver: Option, - /// The document revision last handed to the autosaver. + /// The document revision last handed to the autosaver (0 once that autosave is stale). autosaved_rev: u64, /// Unsaved work that earlier runs left in the autosave folder, offered for recovery. pub leftovers: Vec, @@ -304,6 +306,7 @@ impl SoundApp { punch: None, recorder_failed: false, lost_shown: 0, + take_plan: None, ui: UiState::default(), audio_health_report: None, services, @@ -460,10 +463,13 @@ impl SoundApp { return; } self.ensure_input(); - if let Err(err) = self.arm_recorder() { - self.ui.status = format!("Cannot record: {err}"); - return; - } + let takes = match self.arm_recorder() { + Ok(takes) => takes, + Err(err) => { + self.ui.status = format!("Cannot record: {err}"); + return; + } + }; self.lost_shown = 0; if let Some(p) = &self.player { p.set_recording(true); @@ -474,6 +480,7 @@ impl SoundApp { // Punch in on the fly at the current playhead. self.record_start = self.position(); self.punch = None; + self.plan_recording(takes, None); return; } let e = self.engine.session().edit.clone(); @@ -482,6 +489,7 @@ impl SoundApp { self.record_start = sel.start; self.punch = None; self.start_play(sel.start, None, Some(sel)); + self.plan_recording(takes, Some(sel.len())); } 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. @@ -491,12 +499,23 @@ impl SoundApp { self.punch = (!sel.is_empty()).then_some(sel); let end = (!sel.is_empty()).then_some(sel.end + post); self.start_play(from, end, None); + self.plan_recording(takes, None); + } + } + + /// Remember where the armed `takes` go (split into passes of `pass` when loop recording) and + /// autosave that plan with the session. + fn plan_recording(&mut self, takes: Vec, pass: Option) { + let rate = self.recorder.as_ref().map_or(0, |r| r.sample_rate); + self.take_plan = Some(soundcraft_engine::io::Recording { start: self.record_start, pass, punch: self.punch, rate, takes }); + if let Some(root) = self.autosave_dir.clone() { + self.queue_autosave(root); } } /// Arm the input for the record-enabled tracks; a session never saved records into its - /// autosave folder. - fn arm_recorder(&mut self) -> Result<(), String> { + /// autosave folder. Returns the takes it records. + fn arm_recorder(&mut self) -> Result, String> { let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name); let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name)); let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) }; @@ -504,7 +523,12 @@ impl SoundApp { if plan.is_empty() { return Err("no audio track is record-enabled".into()); } - r.arm(plan) + let takes = plan + .iter() + .map(|(track, inputs, path)| soundcraft_engine::io::Take { track: *track, path: path.clone(), channels: inputs.len() }) + .collect(); + r.arm(plan)?; + Ok(takes) } /// Reads every live third-party plugin's state from the audio engine and stores it on its @@ -566,7 +590,7 @@ impl SoundApp { fn queue_autosave(&mut self, root: std::path::PathBuf) { self.autosaved_rev = self.engine.revision; let w = self.autosaver.get_or_insert_with(|| soundcraft_engine::autosave::Autosaver::new(root)); - w.write(self.engine.session_arc(), self.engine.path.clone()); + w.write(self.engine.session_arc(), self.engine.path.clone(), self.take_plan.clone()); } /// Write the session to its own folder under `root` and wait for it; false when that failed. @@ -764,23 +788,22 @@ impl SoundApp { if let Some(p) = &self.player { p.set_recording(false); } + let plan = self.take_plan.take(); let Some(r) = &mut self.recorder else { return }; let (takes, problem) = r.finish(); let tracks = takes.len().max(1); - let sel = self.engine.session().edit.selection; - let looped = self.engine.session().edit.loop_record && !sel.is_empty(); - let (start, pass) = if looped { (sel.start, Some(sel.len())) } else { (self.record_start, None) }; - let ids = soundcraft_engine::io::add_recording(&mut self.engine, start, takes, pass); - 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 = if looped { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) }; + let plan = plan.unwrap_or_else(|| soundcraft_engine::io::Recording { + start: self.record_start, + pass: None, + punch: self.punch, + rate: 0, + takes: Vec::new(), + }); + self.punch = None; + self.autosaved_rev = 0; + let ids = soundcraft_engine::io::place_recording(&mut self.engine, &plan, takes); + self.ui.status = + if plan.pass.is_some() { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) }; if let Some(p) = problem { log::warn!("recording: {p}"); self.ui.status = format!("{} ({p})", self.ui.status); diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 838dd6dc..bd66e297 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -16,7 +16,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { .anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0)) .show(ctx, |ui| { let age = left.saved.elapsed().map_or_else(|_| "recently".into(), |d| ago(d.as_secs())); - ui.label(format!("“{}” has unsaved changes from an earlier run, autosaved {age}.", left.name)); + let what = if left.recording { "unsaved changes and an unfinished recording" } else { "unsaved changes" }; + ui.label(format!("“{}” has {what} from an earlier run, autosaved {age}.", left.name)); ui.separator(); ui.horizontal(|ui| { if ui.button("Discard").clicked() { From e44c49cada6f8557aeeb40586ab3cf09622f0a59 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:26:14 +0100 Subject: [PATCH 09/11] Record NaN and infinite input samples as silence A device or driver that sends NaN or infinite samples got them written into the take, where they poison whatever the clip is mixed with, and into the input monitor. Both paths now turn them into silence before they are queued, so a take keeps its length and timing. --- crates/playback/src/record.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index 0039c950..0f75aa53 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -70,6 +70,11 @@ impl CaptureRing { } } +/// An input sample, or silence for NaN and infinities, which would poison the take and the mix. +fn finite(x: f32) -> f32 { + if x.is_finite() { x } else { 0.0 } +} + struct Shared { armed: AtomicBool, ring: CaptureRing, @@ -102,7 +107,7 @@ impl Shared { /// as silence before the next block, so the take keeps its timing. fn capture(&self, data: &[f32], owed: &mut usize) { *owed -= self.ring.push(*owed, |_| 0.0); - let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().unwrap_or(0.0)) } else { 0 }; + let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().map_or(0.0, finite)) } else { 0 }; *owed = owed.saturating_add(data.len() - kept); self.dropped.fetch_add(data.len() - kept, Ordering::Relaxed); } @@ -351,6 +356,15 @@ mod tests { assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2)); } + #[test] + fn nan_and_infinite_input_records_as_silence() { + let shared = Shared::new(CaptureRing::new(8)); + let (mut owed, mut out) = (0, Vec::new()); + shared.capture(&[0.5, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5], &mut owed); + shared.ring.pop_all(&mut out); + assert_eq!(out, [0.5, 0.0, 0.0, 0.0, -0.5]); + } + fn recorder(shared: &Arc) -> Recorder { Recorder { shared: Arc::clone(shared), @@ -474,7 +488,7 @@ impl InputRing { pub fn push(&self, samples: &[f32]) { if let Ok(mut s) = self.state.try_lock() { - s.data.extend(samples.iter().copied()); + s.data.extend(samples.iter().copied().map(finite)); while s.data.len() > self.cap { s.data.pop_front(); } @@ -569,4 +583,14 @@ mod ring_tests { ring.pop_into(&mut out, 16, 48_000); assert!(out[0].iter().all(|v| *v == 0.0)); } + + /// NaN and infinities from the device monitor as silence. + #[test] + fn monitor_silences_nan_and_infinite_input() { + let ring = InputRing::new(1, 48_000); + ring.push(&[0.5, f32::NAN, f32::INFINITY, -0.5, 0.0, 0.0, 0.0, 0.0]); + let mut out = vec![vec![1.0f32; 4]]; + ring.pop_into(&mut out, 4, 48_000); + assert_eq!(out[0], [0.5, 0.0, 0.0, -0.5]); + } } From dac86131eeb25dec9a8e0118eba9c800dd2105c6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:28:36 -0400 Subject: [PATCH 10/11] Recovery repairs only take files in the folders recordings write to recording.json names the take files whose headers recovery rewrites. Before writing, check that each is a regular file (not a symlink) directly in the Audio Files folder a recording of that session writes into (next to the session file, or in its folder under the autosave root) and that it starts with the float WAV header the recorder writes. Anything else is left untouched, logged and listed with the media recovery couldn't load. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/autosave.rs | 130 +++++++++++++++++++++++++++++----- 1 file changed, 113 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 9af29248..d42e81fd 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -291,9 +291,9 @@ pub fn leftovers(root: &Path) -> Vec { /// media that couldn't be loaded. pub fn recover(e: &mut Engine, left: &Leftover) -> Result> { let mut r = Engine::default(); - let missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?; + let mut missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?; let mut s = r.session().clone(); - let unfinished = unfinished_takes(&left.dir, &s); + let unfinished = unfinished_takes(left, &s, &mut missing); if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) { for src in s.sources.iter_mut().filter(|src| !src.unsaved) { if let Ok(rel) = Path::new(&src.path).strip_prefix(home) { @@ -310,32 +310,83 @@ pub fn recover(e: &mut Engine, left: &Leftover) -> Result> { Ok(missing) } -/// The takes of the recording `dir` holds the plan of, repaired, minus those already in `s`. -fn unfinished_takes(dir: &Path, s: &Session) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> { - let plan: Recording = serde_json::from_str(&std::fs::read_to_string(dir.join(RECORDING)).ok()?).ok()?; +/// The takes of the recording `left` holds the plan of, repaired, minus those already in `s`. +/// A take the plan names that isn't a take file SoundCraft recorded, in a folder it records into, +/// is left untouched and added to `skipped`. +fn unfinished_takes(left: &Leftover, s: &Session, skipped: &mut Vec) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> { + let plan: Recording = serde_json::from_str(&std::fs::read_to_string(left.dir.join(RECORDING)).ok()?).ok()?; let known: Vec = s.sources.iter().filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect(); - let takes = plan - .takes - .iter() - .filter(|t| std::fs::canonicalize(&t.path).is_ok_and(|p| !known.contains(&p))) - .filter(|t| repair_take(&t.path, t.channels, plan.rate).is_ok_and(|frames| frames > 0)) - .map(|t| (t.track, t.path.clone())) - .collect(); + let folders = take_folders(left); + let mut takes = Vec::new(); + for t in &plan.takes { + if std::fs::canonicalize(&t.path).is_ok_and(|p| known.contains(&p)) { + continue; + } + match repair_take(&t.path, &folders, t.channels, plan.rate) { + Ok(frames) if frames > 0 => takes.push((t.track, t.path.clone())), + Ok(_) => {} + Err(err) => { + log::warn!("recovery skipped the take {}: {err}", t.path.display()); + skipped.push(format!("{}: {err}", t.path.display())); + } + } + } Some((plan, takes)) } +/// The folders a recording in `left`'s session writes its takes into: the `Audio Files` next to +/// the session file, or, never saved, the one in its folder under the autosave root. +fn take_folders(left: &Leftover) -> Vec { + let saved = left.origin.as_deref().and_then(|o| Path::new(o).parent()).map(|d| d.join("Audio Files")); + let unsaved = left.dir.parent().map(|root| root.join(sanitize_name(&left.name)).join("Audio Files")); + saved.into_iter().chain(unsaved).filter_map(|d| std::fs::canonicalize(d).ok()).collect() +} + /// Make a take file's header cover all the audio in it; returns its frames. The recorder -/// rewrites the header only every second, so a crash leaves up to a second uncovered. -fn repair_take(path: &Path, channels: usize, rate: u32) -> std::io::Result { +/// rewrites the header only every second, so a crash leaves up to a second uncovered. Only a +/// regular file in one of `folders` that starts with the header the recorder writes is touched. +fn repair_take(path: &Path, folders: &[PathBuf], channels: usize, rate: u32) -> std::io::Result { + let refuse = |why: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, why.to_string()); + if !std::fs::symlink_metadata(path)?.file_type().is_file() { + return Err(refuse("not a regular file")); + } + let folder = path.parent().and_then(|d| std::fs::canonicalize(d).ok()); + if !folder.is_some_and(|d| folders.contains(&d)) { + return Err(refuse("not in a folder SoundCraft records into")); + } let header = |frames| soundcraft_audio_io::wav_float_header(channels, rate, frames).map_err(std::io::Error::other); - let audio = std::fs::metadata(path)?.len().saturating_sub(header(0)?.len() as u64); + let expected = header(0)?; + let mut f = std::fs::OpenOptions::new().read(true).write(true).open(path)?; + let mut head = vec![0u8; expected.len()]; + if std::io::Read::read_exact(&mut f, &mut head).is_err() || !is_take_header(&head, &expected) { + return Err(refuse("not a take SoundCraft recorded")); + } + let audio = f.metadata()?.len().saturating_sub(expected.len() as u64); let frames = audio / (channels.max(1) as u64 * 4); - let mut f = std::fs::OpenOptions::new().write(true).open(path)?; + std::io::Seek::rewind(&mut f)?; f.write_all(&header(frames)?)?; f.sync_all()?; Ok(frames) } +/// `head` is a take header like `expected` (the recorder's header for no frames yet): RIFF or +/// RF64 WAVE, the same format chunk at the same place, and the data chunk right after it all. +/// Only the sizes and frame counts may differ. +fn is_take_header(head: &[u8], expected: &[u8]) -> bool { + let magic = matches!(head.get(..4), Some(b"RIFF" | b"RF64")) && head.get(8..12) == Some(b"WAVE"); + let fmt = expected.windows(4).position(|w| w == b"fmt "); + let fmt_len = fmt.and_then(|at| expected.get(at + 4..at + 8)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + let same_fmt = match (fmt, fmt_len) { + (Some(at), Some(n)) => { + let end = at.saturating_add(8).saturating_add(n as usize); + expected.get(at..end).is_some_and(|chunk| head.get(at..end) == Some(chunk)) + } + _ => false, + }; + let data = expected.len().checked_sub(8).and_then(|at| head.get(at..at + 4)) == Some(b"data"); + magic && same_fmt && data +} + /// Delete a leftover autosave. pub fn discard(left: &Leftover) { let _ = std::fs::remove_dir_all(&left.dir); @@ -464,7 +515,7 @@ mod tests { let mut e = Engine::default(); let track = soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap()); - let take = dir.join("Audio Files/Vox_01.wav"); + let take = root.join(sanitize_name(&e.session().name)).join("Audio Files/Vox_01.wav"); std::fs::create_dir_all(take.parent().unwrap()).unwrap(); let audio: Vec = (0..480).flat_map(|i| (i as f32 / 1000.0).to_le_bytes()).collect(); std::fs::write(&take, [soundcraft_audio_io::wav_float_header(1, 48_000, 100).unwrap(), audio].concat()).unwrap(); @@ -488,6 +539,51 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn recovery_never_writes_a_file_that_isnt_a_take_in_a_take_folder() { + use crate::io::Take; + let dir = folder("autosave-hostile-take"); + let root = dir.join("Autosave"); + let mut e = Engine::default(); + let track = + soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap()); + let takes = root.join(sanitize_name(&e.session().name)).join("Audio Files"); + std::fs::create_dir_all(&takes).unwrap(); + let wav = [soundcraft_audio_io::wav_float_header(1, 48_000, 0).unwrap(), vec![0; 4_000]].concat(); + let text = b"not audio at all, but long enough to be mistaken for a header if nobody looked at it ......".repeat(4); + let files = + [(takes.join("notes.wav"), text.clone()), (dir.join("Elsewhere/Vox_01.wav"), wav.clone()), (takes.join("../Vox_02.wav"), wav.clone())]; + for (path, bytes) in &files { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + let mut paths: Vec = files.iter().map(|(p, _)| p.clone()).collect(); + #[cfg(unix)] + { + let link = takes.join("Vox_03.wav"); + std::os::unix::fs::symlink(&files[1].0, &link).unwrap(); + paths.push(link); + } + let plan = Recording { + start: 0, + pass: None, + punch: None, + rate: 48_000, + takes: paths.iter().map(|p| Take { track, path: p.clone(), channels: 1 }).collect(), + }; + let w = Autosaver::new(root.clone()); + w.write(e.session_arc(), None, Some(plan)); + drop(w); + let mut r = Engine::default(); + let skipped = recover(&mut r, &leftovers(&root)[0]).unwrap(); + assert_eq!(skipped.len(), paths.len(), "{skipped:?}"); + assert!(r.session().sources.is_empty()); + for (path, bytes) in &files { + assert_eq!(&std::fs::read(path).unwrap(), bytes, "{} untouched", path.display()); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn unsaved_work_is_offered_once_its_session_closes() { let dir = folder("autosave-leftovers"); From 3cc0dc77cbbc915502465c18c033fd6b2282f1bf Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:56:26 -0400 Subject: [PATCH 11/11] Fix build after merging main into #368 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_access.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index be4ea9d2..b0f02fd3 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -443,8 +443,9 @@ mod tests { /// the file system itself would bypass them, so none may: every file under `src/` except /// `io.rs`, this module and the test files. Audio is decoded into the session when it enters /// it (import, open, sampler load), so playback and bounce read nothing from disk and need no - /// check of their own. No engine module reads a fixed folder (plug-in scans live in the host - /// crates), so nothing else is skipped. + /// check of their own. Plug-in scans live in the host crates. The one engine module that reads + /// and writes a fixed folder is `autosave.rs`: crash-recovery copies in the app's own data + /// folder, which no command names, so it is skipped too. #[test] fn commands_do_not_touch_the_file_system_themselves() { fn walk(dir: &Path, files: &mut Vec) { @@ -464,8 +465,9 @@ mod tests { let mut offenders = vec![]; for path in files { let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - // `io` and this module are the checked gateway; `tests*.rs` are test-only modules. - if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs") || name.starts_with("tests") { + // `io` and this module are the checked gateway; `autosave` writes the app's own + // recovery folder; `tests*.rs` are test-only modules. + if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs" | "autosave.rs") || name.starts_with("tests") { continue; } let text = std::fs::read_to_string(&path).unwrap();