mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 10:36:46 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! A take being recorded is never replaced or quit away from.
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::{Engine, EngineError};
|
||||
|
||||
#[test]
|
||||
fn nothing_replaces_or_quits_a_session_while_it_records() {
|
||||
let mut e = Engine::default();
|
||||
e.transport.recording = true;
|
||||
for (id, params) in [
|
||||
("session.new", json!({})),
|
||||
("session.open", json!({"path": "Song.scraft"})),
|
||||
("session.close", json!({})),
|
||||
("session.revert", json!({})),
|
||||
("app.quit", json!({})),
|
||||
] {
|
||||
assert!(matches!(e.execute(id, ¶ms), Err(EngineError::Disabled(_, why)) if why == "stop recording first"), "{id}");
|
||||
}
|
||||
assert!(e.execute("track.new", &json!({})).is_ok(), "editing goes on");
|
||||
e.transport.recording = false;
|
||||
assert!(e.execute("session.close", &json!({})).is_ok());
|
||||
}
|
||||
+142
-3
@@ -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
@@ -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
@@ -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, ¶ms) {
|
||||
Ok(v) => {
|
||||
if replacing || (id.starts_with("session.save") && !self.engine.is_dirty()) {
|
||||
self.release_autosave(false);
|
||||
}
|
||||
if id == "app.quit" {
|
||||
self.quit_requested = true;
|
||||
}
|
||||
@@ -470,10 +494,14 @@ impl SoundApp {
|
||||
return;
|
||||
}
|
||||
self.ensure_input();
|
||||
if let Err(err) = self.arm_recorder() {
|
||||
self.ui.status = format!("Cannot record: {err}");
|
||||
return;
|
||||
}
|
||||
let takes = match self.arm_recorder() {
|
||||
Ok(takes) => takes,
|
||||
Err(err) => {
|
||||
self.ui.status = format!("Cannot record: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.lost_shown = 0;
|
||||
if let Some(p) = &self.player {
|
||||
p.set_recording(true);
|
||||
}
|
||||
@@ -483,6 +511,7 @@ impl SoundApp {
|
||||
// Punch in on the fly at the current playhead.
|
||||
self.record_start = self.position();
|
||||
self.punch = None;
|
||||
self.plan_recording(takes, None);
|
||||
return;
|
||||
}
|
||||
let e = self.engine.session().edit.clone();
|
||||
@@ -491,6 +520,7 @@ impl SoundApp {
|
||||
self.record_start = sel.start;
|
||||
self.punch = None;
|
||||
self.start_play(sel.start, None, Some(sel));
|
||||
self.plan_recording(takes, Some(sel.len()));
|
||||
} else {
|
||||
// Record into the selection when there is one (punch), with pre/post-roll if enabled;
|
||||
// the take is trimmed back to the punch range afterwards.
|
||||
@@ -500,12 +530,23 @@ impl SoundApp {
|
||||
self.punch = (!sel.is_empty()).then_some(sel);
|
||||
let end = (!sel.is_empty()).then_some(sel.end + post);
|
||||
self.start_play(from, end, None);
|
||||
self.plan_recording(takes, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember where the armed `takes` go (split into passes of `pass` when loop recording) and
|
||||
/// autosave that plan with the session.
|
||||
fn plan_recording(&mut self, takes: Vec<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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user