feat(crucible): keep every captured take

With Keep takes on (default), each capture is written to
<folder>/takes/ as a WAV named by time, tempo and length, so a take
is not lost when the next capture replaces it. Files are never
overwritten: a clash gets a numbered suffix. Saving runs off the UI
thread and the status line shows the path or the error.
This commit is contained in:
nvrl committed 2026-11-06 12:24:10 +01:00
1 parent 839e46aec9
commit 6d868a63c4
8 files changed
+114 -4

No files matched your search

+46
View File
@@ -34,6 +34,52 @@ pub fn stamp(unix: u64) -> String {
)
}
/// Writes a captured take's loop to `root/takes/` right away, so it survives a reload or a
/// crash. Never overwrites: a name already taken gets a counter. Returns the file written.
pub fn save_take(take: &Take, root: &Path, unix: u64) -> std::io::Result<PathBuf> {
let folder = root.join("takes");
std::fs::create_dir_all(&folder)?;
let base = format!(
"{}_{:.0}bpm_{}bar{}",
stamp(unix),
take.tempo(),
take.bars,
if take.bars == 1 { "" } else { "s" }
);
let wave = wav::Wave {
channels: 2,
rate: take.rate.round() as u32,
samples: take.audio.clone(),
root: None,
frame: None,
};
let bytes = wav::encode(&wave);
for n in 1..1000 {
let name = if n == 1 {
format!("{base}.wav")
} else {
format!("{base}_{n}.wav")
};
let path = folder.join(name);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
std::io::Write::write_all(&mut file, &bytes)?;
return Ok(path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"too many takes with the same name",
))
}
pub fn export(
take: &Take,
settings: &Settings,
+1 -1
View File
@@ -5,7 +5,7 @@ mod player;
mod ring;
mod settings;
mod take;
pub use export::{export, stamp, Exported};
pub use export::{export, save_take, stamp, Exported};
pub use player::{Clip, Player, Queues};
pub use ring::{Marker, Recorder, Ring, Transport, MARKERS};
pub use settings::{default_folder, Division, Mode, Settings};
+3
View File
@@ -64,6 +64,8 @@ pub struct Settings {
pub trim: bool,
/// Also write a wavetable from the selected slice.
pub wavetable: bool,
/// Save every captured take's loop to the `takes` subfolder at once.
pub keep_takes: bool,
/// Export folder; empty selects the default.
pub folder: String,
}
@@ -78,6 +80,7 @@ impl Default for Settings {
normalize: true,
trim: true,
wavetable: false,
keep_takes: true,
folder: String::new(),
}
}
+15
View File
@@ -231,6 +231,21 @@ fn export_writes_loop_slices_and_wavetable() {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn save_take_writes_the_loop_and_never_overwrites() {
let dir = std::env::temp_dir().join(format!("crucible-takes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut saw = Saw::new(RATE, 55.);
let take = take_of((0..96000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
let first = save_take(&take, &dir, 1_700_000_000).unwrap();
let second = save_take(&take, &dir, 1_700_000_000).unwrap();
assert!(first.ends_with("takes/2023-11-14_221320_120bpm_1bar.wav"));
assert!(second.ends_with("takes/2023-11-14_221320_120bpm_1bar_2.wav"));
let w = wav::decode(&std::fs::read(&first).unwrap()).unwrap();
assert_eq!(w.samples, take.audio);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn audition_plays_and_returns_clips_for_freeing() {
let q = Queues::default();
+38 -2
View File
@@ -1,8 +1,8 @@
//! Crucible editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use crucible_core::{
capture, export, render, Division, Exported, Mode, Queues, Ring, Settings, Take, Transport,
OVERVIEW,
capture, export, render, save_take, Division, Exported, Mode, Queues, Ring, Settings, Take,
Transport, OVERVIEW,
};
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use std::sync::{Arc, Mutex};
@@ -33,6 +33,8 @@ pub struct State {
names: Vec<String>,
/// Filled by the export thread when it finishes.
job: Option<Arc<Mutex<Option<std::io::Result<Exported>>>>>,
/// Filled by the take-saving thread when it finishes.
saving: Option<Arc<Mutex<Option<std::io::Result<std::path::PathBuf>>>>>,
last_export: Option<std::path::PathBuf>,
}
impl Default for State {
@@ -49,6 +51,7 @@ impl Default for State {
counter: 0,
names: Vec::new(),
job: None,
saving: None,
last_export: None,
}
}
@@ -136,6 +139,17 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link:
Err(e) => format!("Export failed: {e}"),
};
}
let saved = state
.saving
.as_ref()
.and_then(|job| job.lock().ok().and_then(|mut slot| slot.take()));
if let Some(result) = saved {
state.saving = None;
state.status += &match result {
Ok(path) => format!(" Saved {}", path.display()),
Err(e) => format!(" Saving the take failed: {e}"),
};
}
ui.add_space(2.);
header(ui, "CRUCIBLE", "RESAMPLER", |ui| {
let db = 20. * state.peak.max(1e-6).log10();
@@ -204,6 +218,19 @@ fn capture_row(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, li
match capture(ring, settings.bars) {
Ok(take) => {
let synced = take.synced;
if settings.keep_takes {
let slot = Arc::new(Mutex::new(None));
let done = slot.clone();
let take = take.clone();
let folder = settings.folder();
std::thread::spawn(move || {
let result = save_take(&take, &folder, unix_now());
if let Ok(mut slot) = done.lock() {
*slot = Some(result);
}
});
state.saving = Some(slot);
}
state.add(take, settings);
state.status = if synced {
format!("Captured {} bars on the bar grid.", settings.bars)
@@ -481,6 +508,15 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link:
state,
"Also write wavetable.wav from the selected slice: 2048-sample frames for Serum or Vital.",
);
let r = chip(ui, settings.keep_takes, "KEEP TAKES");
if r.clicked() {
settings.keep_takes = !settings.keep_takes;
}
hint(
&r,
state,
"Save every capture's loop to the takes folder at once, so a reload or crash loses nothing.",
);
ui.separator();
let take = state.take;
let slice = state.slice;