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() {