Punch-in while playing, pre/post-roll takes trimmed to the punch range, autosave and recovery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-05 23:43:20 -04:00
parent a9f553767c
commit fd2a5fda4a
3 changed files with 141 additions and 24 deletions

View File

@ -174,6 +174,7 @@ fn main() -> eframe::Result {
Box::new(move |cc| {
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")));
if let Some(ui) = load_prefs() {
app.ui = ui;
}

View File

@ -301,11 +301,28 @@ pub fn sanitize_name(n: &str) -> String {
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
/// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written.
pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
let (fresh, written, p) = write_session(e.session(), path, false)?;
// Keep the in-memory document in sync (paths, unsaved flags) without an undo step.
*e.session_mut() = fresh;
e.path = Some(p.to_string_lossy().into_owned());
e.mark_clean();
Ok(written)
}
/// Write a copy of the session (and any audio it needs) without touching the open document —
/// used for autosave / crash recovery. Audio files in the target folder are overwritten in place.
pub fn save_copy(e: &Engine, path: &str) -> Result<usize> {
write_session(e.session(), path, true).map(|(_, n, _)| n)
}
/// Write `session` to `path`; returns the session with updated media paths, the number of audio
/// files written and the final path.
fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> {
let p = PathBuf::from(path);
let p = if p.extension().is_none() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p };
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
let audio_dir = dir.join("Audio Files");
let mut s = e.session().clone();
let mut s = session.clone();
let mut written = 0;
for src in &mut s.sources {
let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name));
@ -316,12 +333,22 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
let Some(audio) = s.pool.get(src.id) else { continue };
std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?;
let mut target = rel_target.clone();
let mut k = 1;
while dir.join(&target).exists() && !(exists_rel && target == src.path) {
target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name));
k += 1;
if k > 999 {
break;
if overwrite {
// One file per source id so autosaves never collide or pile up.
target = format!("Audio Files/{}_{}.wav", sanitize_name(&src.name), src.id.0);
if dir.join(&target).exists() {
src.path = target;
src.unsaved = false;
continue;
}
} else {
let mut k = 1;
while dir.join(&target).exists() && !(exists_rel && target == src.path) {
target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name));
k += 1;
if k > 999 {
break;
}
}
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
@ -332,7 +359,7 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
src.format = FileFormat::Wav;
written += 1;
}
if let Some(stem) = p.file_stem().and_then(|x| x.to_str()) {
if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) {
s.name = stem.to_string();
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
@ -340,12 +367,7 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
let tmp = p.with_extension("scraft.tmp");
std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?;
std::fs::rename(&tmp, &p).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?;
// Keep the in-memory document in sync (paths, unsaved flags) without an undo step.
let fresh = s;
*e.session_mut() = fresh;
e.path = Some(p.to_string_lossy().into_owned());
e.mark_clean();
Ok(written)
Ok((s, written, p))
}
/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal).
@ -421,3 +443,26 @@ pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec<Vec<f32>>, ra
e.push_undo("Record", before);
Ok(out)
}
#[cfg(test)]
mod save_tests {
use super::*;
#[test]
fn autosave_copy_leaves_document_untouched_and_reuses_audio() {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap();
let dir = std::env::temp_dir().join(format!("soundcraft-autosave-{}", std::process::id()));
let path = dir.join("Midnight Groove.scraft");
let p = path.to_string_lossy().into_owned();
let n1 = save_copy(&e, &p).unwrap();
let n2 = save_copy(&e, &p).unwrap();
assert!(n1 >= 5);
assert_eq!(n2, 0, "second autosave must not rewrite audio");
assert!(e.is_dirty() && e.path.is_none());
let mut r = Engine::default();
open_session(&mut r, &p).unwrap();
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -166,9 +166,14 @@ pub struct SoundApp {
/// Input capture, opened on first record.
pub recorder: Option<soundcraft_playback::record::Recorder>,
record_start: Samples,
/// Punch range to trim the take to (pre/post-roll recordings).
punch: Option<Range>,
recorder_failed: bool,
pub ui: UiState,
pub services: Services,
/// Folder for crash-recovery autosaves (native apps set it).
pub autosave_dir: Option<std::path::PathBuf>,
last_autosave: f64,
pub dialogs: dialogs::Dialogs,
pub meters: HashMap<TrackId, MeterDisplay>,
pub main_meter: MeterDisplay,
@ -202,9 +207,12 @@ impl SoundApp {
player,
recorder: None,
record_start: 0,
punch: None,
recorder_failed: false,
ui: UiState::default(),
services,
autosave_dir: None,
last_autosave: 0.0,
dialogs: dialogs::Dialogs::default(),
meters: HashMap::new(),
main_meter: MeterDisplay::default(),
@ -305,21 +313,72 @@ impl SoundApp {
if let Some(p) = &self.player {
p.set_recording(true);
}
self.record_start = self.engine.session().edit.selection.start;
let already_playing = self.is_playing();
if let Some(r) = &self.recorder {
r.arm();
}
self.engine.transport.recording = true;
if !self.is_playing() {
let sel = self.engine.session().edit.selection;
if self.engine.session().edit.loop_record && !sel.is_empty() {
self.start_play(sel.start, None, Some(sel));
} else {
// Record into the selection when there is one (punch), else open-ended.
let end = (!sel.is_empty()).then_some(sel.end);
self.start_play(sel.start, end, None);
if already_playing {
// Punch in on the fly at the current playhead.
self.record_start = self.position();
self.punch = None;
return;
}
let e = self.engine.session().edit.clone();
let sel = e.selection;
if e.loop_record && !sel.is_empty() {
self.record_start = sel.start;
self.punch = None;
self.start_play(sel.start, None, Some(sel));
} 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.
let (pre, post) = if e.pre_post_roll { (e.pre_roll, e.post_roll) } else { (0, 0) };
let from = (sel.start - pre).max(0);
self.record_start = from;
self.punch = (!sel.is_empty()).then_some(sel);
let end = (!sel.is_empty()).then_some(sel.end + post);
self.start_play(from, end, None);
}
}
/// Every two minutes, copy a modified session to the autosave folder (not while recording).
fn autosave(&mut self, now: f64) {
let Some(dir) = self.autosave_dir.clone() else { return };
if self.last_autosave == 0.0 {
self.last_autosave = now;
}
if now - self.last_autosave < 120.0 || !self.engine.is_dirty() || self.engine.transport.recording {
return;
}
self.last_autosave = now;
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);
}
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}"),
}
}
/// The most recent autosave, if any.
pub fn latest_autosave(&self) -> Option<std::path::PathBuf> {
let dir = self.autosave_dir.as_ref()?;
let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
for d in std::fs::read_dir(dir).ok()?.flatten() {
for f in std::fs::read_dir(d.path()).into_iter().flatten().flatten() {
let p = f.path();
if p.extension().and_then(|x| x.to_str()) == Some("scraft")
&& let Ok(m) = f.metadata().and_then(|m| m.modified())
&& best.as_ref().is_none_or(|(t, _)| m > *t)
{
best = Some((m, p));
}
}
}
best.map(|(_, p)| p)
}
/// Open the input device once (for recording and input monitoring) and feed the player.
@ -376,7 +435,18 @@ impl SoundApp {
return;
}
match soundcraft_engine::io::add_recording(&mut self.engine, self.record_start, take.channels, rate) {
Ok(ids) => self.ui.status = format!("Recorded {} clip(s)", ids.len()),
Ok(ids) => {
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 = format!("Recorded {} clip(s)", ids.len());
}
Err(e) => self.ui.status = e.to_string(),
}
}
@ -572,6 +642,7 @@ impl SoundApp {
}
self.handle_transport(dt);
self.write_automation(ctx);
self.autosave(now);
if self.recorder.is_none()
&& !self.recorder_failed
&& self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)