diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 1931d2cd..9324d078 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,9 +85,10 @@ 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(); 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..d42e81fd --- /dev/null +++ b/crates/engine/src/autosave.rs @@ -0,0 +1,615 @@ +//! 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::{Recording, 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::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"; +/// 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"; + +/// 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, 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, 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. + 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, 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, recording)) => self.pending = Some((session, origin, recording)), + 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, 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(), recording.as_ref())) + })); + 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. +struct Autosave { + dir: PathBuf, + lock: Option, + copies: BTreeMap, String)>, + next: u64, + /// A recording's plan is in the folder. + recording: bool, +} + +impl Autosave { + /// A new folder under `root` for the session called `name`. + 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, recording: false }) + } + + /// 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(); + 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), 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)); + } + 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(()) + } + + /// Delete the folder: the session closed with nothing left to recover. + 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, + /// 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 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()) { + 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()); + let recording = dir.join(RECORDING).is_file(); + drop(lock); + match saved { + 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, recording }); + } + _ => { + 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, +/// 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 mut missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?; + let mut s = r.session().clone(); + 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) { + 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(); + if let Some((plan, takes)) = unfinished { + crate::io::place_recording(e, &plan, takes); + } + Ok(missing) +} + +/// 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 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. 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 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); + 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); +} + +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.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")); + 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.session(), None, None).unwrap(); + e.execute("edit.undo", &json!({})).unwrap(); + record(&mut e, -0.25); + a.write(e.session(), None, None).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 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, 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, 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 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 = 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(); + 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 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"); + 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.session(), e.path.as_deref(), None).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/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(); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index ca5d7fe0..a278e0b7 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1041,6 +1041,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/engine/src/lib.rs b/crates/engine/src/lib.rs index b73384cc..ef17f08f 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; @@ -260,10 +261,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. @@ -396,7 +406,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/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/playback/src/lib.rs b/crates/playback/src/lib.rs index 5ac74098..15f1148b 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -82,6 +82,73 @@ struct Shared { meters: Mutex, } +/// 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 { + /// 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); + out.fill(0.0); + } +} + /// State owned by the audio callback. struct AudioState { rx: Receiver, @@ -374,6 +441,10 @@ impl Side { /// The transport and audio device. 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"))] @@ -398,6 +469,19 @@ impl Player { p } + /// 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> { self.side.lock().unwrap_or_else(PoisonError::into_inner) } @@ -425,10 +509,16 @@ 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| { + 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}; @@ -436,7 +526,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, @@ -448,6 +538,9 @@ impl Player { log::info!("audio device: {name} @ {rate} Hz, {channels} ch"); Player { shared, + fault, + #[cfg(not(target_arch = "wasm32"))] + watch: Mutex::new(None), tx, side, _stream: Some(stream), @@ -499,6 +592,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(); @@ -509,7 +604,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; @@ -521,6 +617,8 @@ impl Player { } Player { shared, + fault, + watch: Mutex::new(None), tx, side, _stream: None, @@ -542,6 +640,7 @@ impl Player { ) -> Player { Player { shared, + fault: Arc::default(), tx, side, _stream: None, @@ -800,6 +899,46 @@ 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.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.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] fn audio_state_plays_and_stops_at_end() { let s = Arc::new(soundcraft_model::Session::default()); diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index ec784ac6..0f75aa53 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`. @@ -57,19 +70,44 @@ 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, /// 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) { *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); } @@ -85,6 +123,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 +143,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 +153,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 +193,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 +206,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 +248,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 +257,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 +345,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 +356,75 @@ 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), + 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 +437,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(); @@ -342,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(); } @@ -437,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]); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e0e2f2ba..1d4f2211 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; @@ -30,7 +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; @@ -232,6 +234,10 @@ 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, + /// 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, @@ -239,6 +245,14 @@ pub struct SoundApp { pub native_menu_bar: bool, /// Folder for crash-recovery autosaves (native apps set it). 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 (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, + /// 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, @@ -305,11 +319,17 @@ impl SoundApp { record_start: 0, punch: None, recorder_failed: false, + lost_shown: 0, + take_plan: None, ui: UiState::default(), audio_health_report: None, services, native_menu_bar: false, autosave_dir: None, + autosaver: None, + autosaved_rev: 0, + leftovers: Vec::new(), + panic_rev: 0, preset_dir: None, last_autosave: None, dialogs: dialogs::Dialogs::default(), @@ -360,8 +380,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; } @@ -470,10 +494,14 @@ 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); } @@ -483,6 +511,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(); @@ -491,6 +520,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. @@ -500,12 +530,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()) }; @@ -513,7 +554,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 @@ -551,14 +597,108 @@ 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); + } + + /// 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}"); } - 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}"), + 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(), self.take_plan.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 { + self.queue_autosave(root.to_path_buf()); + let Some(w) = &self.autosaver else { return false }; + let written = w.flush(); + if let Err(e) = &written { + self.ui.status = format!("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(w) = &self.autosaver { + w.close(keep); + } + } + + /// Done with the open session. Unsaved changes stay in its autosave folder for the next + /// 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 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(); + } + 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 + /// 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) { + 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) => { + 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), } } @@ -605,28 +745,96 @@ 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) { + 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 { 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); @@ -856,6 +1064,8 @@ impl SoundApp { self.engine.end_merge(); } } + self.watch_output(); + 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 { @@ -864,6 +1074,7 @@ impl SoundApp { p.update_session(self.engine.session_arc()); } } + self.autosave_changes(); self.write_automation(ctx); self.autosave(); if self.recorder.is_none() @@ -938,6 +1149,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/menus.rs b/crates/ui-egui/src/menus.rs index 3c200198..75c3f350 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -295,7 +295,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 new file mode 100644 index 00000000..bd66e297 --- /dev/null +++ b/crates/ui-egui/src/recovery.rs @@ -0,0 +1,50 @@ +//! 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())); + 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() { + recover = Some(false); + } + 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); + } + }); + }); + 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/src/windows.rs b/crates/ui-egui/src/windows.rs index b44c1b15..cf79b9a6 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -431,7 +431,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; diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs new file mode 100644 index 00000000..00ea2654 --- /dev/null +++ b/crates/ui-egui/tests/recovery.rs @@ -0,0 +1,176 @@ +//! 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 +} + +/// 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()); + 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!(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); +} + +#[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); +} + +#[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(); + 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); +}