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:
1 parent
839e46aec9
commit
6d868a63c4
8 files changed
+114
-4
No files matched your search
@@ -1,5 +1,12 @@
|
||||
# Crucible changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Keep takes** (on by default) saves every capture's loop to a `takes`
|
||||
subfolder of the export folder at once. Takes are still not part of the
|
||||
project, but a reload or a crash no longer loses them. Existing files are
|
||||
never overwritten.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- Change the CLAP ID to `io.narl.bass-foundry.crucible`. Hosts see this as a new
|
||||
|
||||
@@ -52,7 +52,10 @@ the playlist or Edison. **Open folder** shows the latest export in Explorer.
|
||||
Crucible adds no latency and passes audio through untouched. Auditioned slices are
|
||||
mixed into its output only while they play. The history holds 72 s, enough for
|
||||
16 bars at 60 BPM. The newest eight takes stay in memory until the plugin is
|
||||
removed; they are not saved with the project, so export what you want to keep.
|
||||
removed; they are not saved with the project. With **Keep takes** on (the
|
||||
default), every capture's loop is also written at once to
|
||||
`takes/<date>_<time>_<tempo>bpm_<bars>bars.wav` in the export folder, so a reload
|
||||
or a crash loses nothing. Existing files are never overwritten.
|
||||
|
||||
Bar alignment follows the host's song position and time signature. Root notes are
|
||||
detected between 35 Hz and 1 kHz. Unpitched slices, such as drums or noise, get
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 223 KiB |
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user