Merge pull request #368 from grovebit/recording-safety

Keep takes when the input, output or disk fails, or the app crashes
This commit is contained in:
Brandon Thomas
2026-10-13 06:30:06 -04:00
committed by GitHub
13 changed files with 1472 additions and 58 deletions
+4 -2
View File
@@ -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;
+615
View File
@@ -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<Sender<Job>>,
thread: Option<std::thread::JoinHandle<()>>,
failure: Arc<Mutex<Option<String>>>,
}
enum Job {
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>>),
}
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<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.
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<String> {
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<Mutex<Option<String>>>,
/// The open session's folder.
folder: Option<Autosave>,
/// The newest session not written yet.
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>,
}
impl Writer {
fn run(mut self, jobs: &Receiver<Job>) {
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<Option<String>>, 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<std::fs::File>,
copies: BTreeMap<SourceId, (Weak<SourceAudio>, 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<Autosave> {
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<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 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()) {
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::<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, 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<Vec<String>> {
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::<Vec<_>>().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<String>) -> 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<PathBuf> = 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<PathBuf> {
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<u64> {
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<f32> {
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::<String>::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::<Vec<_>>();
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<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 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<PathBuf> = 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);
}
}
+6 -4
View File
@@ -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<PathBuf>) {
@@ -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();
+31
View File
@@ -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<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.
+11 -1
View File
@@ -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<Value> {
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);
}));
});
+21
View File
@@ -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, &params), 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());
}
+142 -3
View File
@@ -82,6 +82,73 @@ struct Shared {
meters: Mutex<MeterSnapshot>,
}
/// 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<OutputTrouble> {
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<Cmd>,
@@ -374,6 +441,10 @@ impl Side {
/// The transport and audio device.
pub struct Player {
shared: Arc<Shared>,
fault: Arc<Fault>,
/// The block count last seen while playing, and since when.
#[cfg(not(target_arch = "wasm32"))]
watch: Mutex<Option<(u64, std::time::Instant)>>,
tx: Sender<Cmd>,
side: Mutex<Side>,
#[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<OutputTrouble> {
#[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());
+173 -17
View File
@@ -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<String>);
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<Option<String>>,
}
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<std::thread::JoinHandle<Takes>>,
/// 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::<Result<Vec<_>, _>>()?;
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<Trouble> {
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<TakeWriter>, 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<TakeWriter>, 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<Shared>) -> 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<f32> = (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]);
}
}
+241 -29
View File
@@ -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<Range>,
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,
@@ -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<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 (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>,
/// 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<std::path::PathBuf>,
last_autosave: Option<std::time::Instant>,
@@ -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, &params) {
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<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()) };
@@ -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<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);
@@ -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);
+1 -1
View File
@@ -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))
+50
View File
@@ -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" })
}
+1 -1
View File
@@ -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;
+176
View File
@@ -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);
}