Recover the takes of a recording a crash interrupted

Takes stream to disk while recording, but they only join the session at
Stop. After a crash the files were on disk with nothing pointing at
them and no record of where they started, and the header of each was up
to a second behind its audio.

When recording starts, its plan (each track's take file, where the
takes start, the loop length and punch range) is autosaved with the
session, and every autosave until Stop keeps it there. Stop places the
takes from that same plan. Recovering a folder that still holds a plan
first repairs each take's header to cover all of its audio, then places
the takes as Stop would have, skipping any the session already has. The
recovery window says when there's an unfinished recording, and such a
folder is kept even if its session file was saved later.
This commit is contained in:
grovebit
2026-10-12 23:33:48 +01:00
parent 62cb92c4df
commit 30122bcea1
4 changed files with 183 additions and 49 deletions
+104 -25
View File
@@ -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<Session>, Option<String>),
Write(Arc<Session>, Option<String>, Option<Recording>),
/// Done with the session's folder: keep it for the next launch, or delete it.
Close(bool),
Flush(Sender<std::result::Result<(), String>>),
@@ -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<Session>, origin: Option<String>) {
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<Session>, origin: Option<String>, recording: Option<Recording>) {
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<Autosave>,
/// The newest session not written yet.
pending: Option<(Arc<Session>, Option<String>)>,
pending: Option<(Arc<Session>, Option<String>, Option<Recording>)>,
last: Option<Instant>,
/// 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<std::fs::File>,
copies: BTreeMap<SourceId, (Weak<SourceAudio>, 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<String>,
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<Leftover> {
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<Leftover> {
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::<serde_json::Value>(&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<Leftover> {
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<Vec<String>> {
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<Vec<String>> {
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<PathBuf> = 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<u64> {
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::<Vec<_>>();
@@ -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<u8> = (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::<Vec<_>>();
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);
+31
View File
@@ -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<Samples>,
pub punch: Option<Range>,
/// The input's sample rate, which the take files are written at.
pub rate: u32,
pub takes: Vec<Take>,
}
/// 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<soundcraft_model::ClipId> {
let ids = add_recording(e, plan.start, takes, plan.pass);
if let Some(p) = plan.punch {
let clips: Vec<u64> = 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.
+46 -23
View File
@@ -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<soundcraft_engine::io::Recording>,
pub ui: UiState,
pub audio_health_report: Option<Result<Value, String>>,
pub services: Services,
@@ -238,7 +240,7 @@ pub struct SoundApp {
pub autosave_dir: Option<std::path::PathBuf>,
/// Writes the open session's autosaves in the background, once there is something to write.
autosaver: Option<soundcraft_engine::autosave::Autosaver>,
/// 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<soundcraft_engine::autosave::Leftover>,
@@ -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<soundcraft_engine::io::Take>, pass: Option<Samples>) {
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<Vec<soundcraft_engine::io::Take>, 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<u64> = 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);
+2 -1
View File
@@ -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() {