mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-10 20:33:12 +00:00
Merge pull request #84 from grovebit/record-to-disk
Record takes straight to disk
This commit is contained in:
7 files changed
+484
-202
No files matched your search
@@ -8,6 +8,7 @@
|
||||
//! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`.
|
||||
//! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in
|
||||
//! encoder), with optional TPDF dither.
|
||||
//! * [`wav_float_header`] heads a float WAV written while it grows (recording).
|
||||
//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -289,3 +290,13 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
|
||||
pub fn wav_channel_mask(bytes: &[u8]) -> Option<u32> {
|
||||
wav::read_channel_mask(bytes)
|
||||
}
|
||||
|
||||
/// The header of a 32-bit float WAV holding `frames` frames (little-endian samples follow it). Its
|
||||
/// length doesn't depend on `frames`, so a recorder can rewrite it in place as the file grows; it
|
||||
/// switches to RF64 above 4 GiB.
|
||||
pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Result<Vec<u8>> {
|
||||
if channels == 0 || sample_rate == 0 {
|
||||
return Err(AudioError::Encode("a WAV needs channels and a sample rate".into()));
|
||||
}
|
||||
wav::header(channels, sample_rate, (32, true), None, None, frames, false)
|
||||
}
|
||||
+69
-54
@@ -276,60 +276,8 @@ pub(crate) fn encode(
|
||||
BitDepth::Float32 => (32, true),
|
||||
};
|
||||
let bytes = usize::from(bits / 8);
|
||||
let block = bytes.checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
|
||||
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
|
||||
let data_len = block.checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
|
||||
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
|
||||
|
||||
let mut fmt = Vec::with_capacity(40);
|
||||
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
|
||||
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
|
||||
fmt.extend_from_slice(&buf.sample_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&buf.sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
|
||||
fmt.extend_from_slice(&block_align.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
if extensible {
|
||||
fmt.extend_from_slice(&22u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
|
||||
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
|
||||
fmt.extend_from_slice(&GUID_TAIL);
|
||||
}
|
||||
|
||||
let mut head = Vec::with_capacity(1024);
|
||||
let rf64_body_len = 28usize;
|
||||
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
|
||||
let mut meta = Vec::new();
|
||||
if let Some(bwf) = bwf {
|
||||
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
|
||||
}
|
||||
push_chunk(&mut meta, b"fmt ", &fmt);
|
||||
if is_float {
|
||||
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
|
||||
}
|
||||
let total = 4 + (8 + rf64_body_len) + meta.len() + 8 + data_len + (data_len & 1);
|
||||
let rf64 = force_rf64 || u32::try_from(total).is_err();
|
||||
|
||||
if rf64 {
|
||||
head.extend_from_slice(b"RF64");
|
||||
head.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
let mut ds64 = Vec::with_capacity(rf64_body_len);
|
||||
ds64.extend_from_slice(&(total as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&(data_len as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&(frames as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&0u32.to_le_bytes());
|
||||
push_chunk(&mut head, b"ds64", &ds64);
|
||||
} else {
|
||||
head.extend_from_slice(b"RIFF");
|
||||
head.extend_from_slice(&(total as u32).to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
|
||||
}
|
||||
head.extend_from_slice(&meta);
|
||||
head.extend_from_slice(b"data");
|
||||
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
|
||||
let head = header(channels, buf.sample_rate, (bits, is_float), bwf, mask, frames as u64, force_rf64)?;
|
||||
let data_len = (bytes * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
|
||||
@@ -352,3 +300,70 @@ pub(crate) fn encode(
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Everything before the sample data of a WAV holding `frames` frames.
|
||||
pub(crate) fn header(
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
(bits, is_float): (u16, bool),
|
||||
bwf: Option<&BwfInfo>,
|
||||
mask: Option<u32>,
|
||||
frames: u64,
|
||||
force_rf64: bool,
|
||||
) -> Result<Vec<u8>> {
|
||||
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
|
||||
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
|
||||
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
|
||||
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
|
||||
|
||||
let mut fmt = Vec::with_capacity(40);
|
||||
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
|
||||
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
|
||||
fmt.extend_from_slice(&block_align.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
if extensible {
|
||||
fmt.extend_from_slice(&22u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
|
||||
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
|
||||
fmt.extend_from_slice(&GUID_TAIL);
|
||||
}
|
||||
|
||||
let mut head = Vec::with_capacity(1024);
|
||||
let rf64_body_len = 28usize;
|
||||
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
|
||||
let mut meta = Vec::new();
|
||||
if let Some(bwf) = bwf {
|
||||
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
|
||||
}
|
||||
push_chunk(&mut meta, b"fmt ", &fmt);
|
||||
if is_float {
|
||||
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
|
||||
}
|
||||
let total = (4 + (8 + rf64_body_len) + meta.len() + 8) as u64 + data_len + (data_len & 1);
|
||||
let rf64 = force_rf64 || u32::try_from(total).is_err();
|
||||
|
||||
if rf64 {
|
||||
head.extend_from_slice(b"RF64");
|
||||
head.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
let mut ds64 = Vec::with_capacity(rf64_body_len);
|
||||
ds64.extend_from_slice(&total.to_le_bytes());
|
||||
ds64.extend_from_slice(&data_len.to_le_bytes());
|
||||
ds64.extend_from_slice(&frames.to_le_bytes());
|
||||
ds64.extend_from_slice(&0u32.to_le_bytes());
|
||||
push_chunk(&mut head, b"ds64", &ds64);
|
||||
} else {
|
||||
head.extend_from_slice(b"RIFF");
|
||||
head.extend_from_slice(&(total as u32).to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
|
||||
}
|
||||
head.extend_from_slice(&meta);
|
||||
head.extend_from_slice(b"data");
|
||||
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
|
||||
Ok(head)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use soundcraft_audio_io::{
|
||||
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe,
|
||||
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header,
|
||||
};
|
||||
|
||||
fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer {
|
||||
@@ -268,3 +268,19 @@ fn multichannel_wav_speaker_masks() {
|
||||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap();
|
||||
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
|
||||
let take = sine(48_000, 2, 1_000);
|
||||
let mut file = wav_float_header(2, 48_000, 0).unwrap();
|
||||
let len = file.len();
|
||||
file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes()));
|
||||
assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000);
|
||||
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap());
|
||||
assert_eq!(decode(&file, None).unwrap().1.frames(), 600);
|
||||
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap());
|
||||
assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap());
|
||||
let big = wav_float_header(2, 48_000, 600_000_000).unwrap();
|
||||
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
|
||||
assert!(wav_float_header(0, 48_000, 0).is_err());
|
||||
}
|
||||
+120
-28
@@ -556,6 +556,11 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
if exists_rel && !src.unsaved && !shared {
|
||||
continue;
|
||||
}
|
||||
let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string);
|
||||
if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) {
|
||||
src.path = format!("Audio Files/{f}");
|
||||
continue;
|
||||
}
|
||||
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 stem = sanitize_name(&src.name);
|
||||
@@ -727,17 +732,14 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
/// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record").
|
||||
/// `channels` is the planar input capture at `rate`; each track takes the input channels its
|
||||
/// input route names ("In 3" → channel 2), defaulting to the first channels.
|
||||
pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec<Vec<f32>>, rate: u32) -> Result<Vec<soundcraft_model::ClipId>> {
|
||||
let frames = channels.first().map_or(0, Vec::len);
|
||||
if frames == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let armed: Vec<(TrackId, usize, usize, String)> = e
|
||||
.session()
|
||||
.tracks
|
||||
/// The record-enabled audio tracks, each with the input channels it records (those its input
|
||||
/// route names, "In 3" → 2, wrapped to the device's `input_channels`) and a new take file
|
||||
/// `<track>_<NN>.wav` in the `Audio Files` of the session's folder (`unsaved_dir` until it is saved).
|
||||
pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<(TrackId, Vec<usize>, PathBuf)> {
|
||||
let dir = e.path.as_deref().and_then(|p| Path::new(p).parent()).unwrap_or(unsaved_dir);
|
||||
let s = e.session();
|
||||
let take_no = s.sources.len() + 1;
|
||||
s.tracks
|
||||
.iter()
|
||||
.filter(|t| t.mixer.record_arm && t.kind == TrackKind::Audio && !t.inactive)
|
||||
.map(|t| {
|
||||
@@ -747,28 +749,118 @@ pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec<Vec<f32>>, ra
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
(t.id, first, t.channels(), t.name.clone())
|
||||
let inputs = (0..t.channels()).map(|k| first.saturating_add(k) % input_channels.max(1)).collect();
|
||||
let file = |n: usize| dir.join("Audio Files").join(format!("{}_{n:02}.wav", sanitize_name(&t.name)));
|
||||
let path = (take_no..take_no + 1_000).map(file).find(|p| !p.exists()).unwrap_or_else(|| file(take_no));
|
||||
(t.id, inputs, path)
|
||||
})
|
||||
.collect();
|
||||
if armed.is_empty() {
|
||||
return Err(EngineError::BadParams("record".into(), "no tracks are record-enabled".into()));
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBuf)>, pass: Option<Samples>) -> Vec<soundcraft_model::ClipId> {
|
||||
let before = e.session_arc();
|
||||
let mut out = Vec::new();
|
||||
let take_no = e.session().sources.len() + 1;
|
||||
for (tid, first, n, name) in armed {
|
||||
let chans: Vec<Vec<f32>> = (0..n).map(|k| channels.get((first + k) % channels.len().max(1)).cloned().unwrap_or_default()).collect();
|
||||
let buf = AudioBuffer { sample_rate: rate, channels: chans };
|
||||
let clip_name = format!("{name}_{take_no:02}");
|
||||
for (tid, path) in takes {
|
||||
let read = std::fs::read(&path).map_err(|x| x.to_string()).and_then(|b| soundcraft_audio_io::decode(&b, None).map_err(|x| x.to_string()));
|
||||
let Ok((_, buf)) = read.inspect_err(|err| e.message(format!("{}: {err}", path.display()))) else { continue };
|
||||
let s = e.session_mut();
|
||||
let src = add_source(s, &clip_name, buf, None, FileFormat::Wav);
|
||||
let len = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0));
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(s, tid, Clip::audio(cid, clip_name, src, 0, start, len));
|
||||
out.push(cid);
|
||||
if s.track(tid).is_none() {
|
||||
continue;
|
||||
}
|
||||
let resampled = buf.sample_rate != s.sample_rate.hz();
|
||||
let name = path.file_stem().and_then(|x| x.to_str()).unwrap_or("Take");
|
||||
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
|
||||
let Some(x) = s.sources.last_mut() else { continue };
|
||||
x.unsaved = resampled;
|
||||
let len = i64::try_from(x.frames).unwrap_or(0);
|
||||
let pass = pass.unwrap_or(len).max(1);
|
||||
for offset in (0..len).step_by(usize::try_from(pass).unwrap_or(usize::MAX)).take(1_000) {
|
||||
let n = pass.min(len - offset);
|
||||
if offset > 0 && n < pass / 8 {
|
||||
break;
|
||||
}
|
||||
if offset > 0
|
||||
&& let Some(t) = s.track_mut(tid)
|
||||
{
|
||||
t.playlists.push(soundcraft_model::Playlist::new(format!("{}.{:02}", t.name, t.playlists.len() + 1)));
|
||||
t.active_playlist = t.playlists.len() - 1;
|
||||
}
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset, start, n));
|
||||
out.push(cid);
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
e.push_undo("Record", before);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod record_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn armed(e: &mut Engine, name: &str, format: &str, input: &str) -> TrackId {
|
||||
let id = e.execute("track.new", &json!({"name": name, "format": format})).unwrap()["tracks"][0].as_u64().unwrap();
|
||||
e.execute("track.input", &json!({"tracks": [id], "input": input})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [id], "value": true})).unwrap();
|
||||
TrackId(id)
|
||||
}
|
||||
|
||||
fn write_take(path: &Path, frames: usize) {
|
||||
let mut bytes = soundcraft_audio_io::wav_float_header(1, 48_000, frames as u64).unwrap();
|
||||
bytes.extend((0..frames).flat_map(|i| (i as f32 / frames as f32 - 0.5).to_le_bytes()));
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armed_tracks_get_new_take_files_used_in_place() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut e = Engine::default();
|
||||
let vox = armed(&mut e, "Vox", "mono", "In 2");
|
||||
let gtr = armed(&mut e, "Gtr", "stereo", "In 3-4");
|
||||
write_take(&dir.join("Audio Files/Vox_01.wav"), 10);
|
||||
let plan = plan_takes(&e, &dir, 4);
|
||||
assert_eq!(plan, vec![(vox, vec![1], dir.join("Audio Files/Vox_02.wav")), (gtr, vec![2, 3], dir.join("Audio Files/Gtr_01.wav"))]);
|
||||
assert_eq!(plan_takes(&e, &dir, 2)[1].1, vec![0, 1]);
|
||||
|
||||
let session = dir.join("Song.scraft").to_string_lossy().into_owned();
|
||||
e.execute("session.save_as", &json!({"path": session})).unwrap();
|
||||
write_take(&plan[0].2, 4_800);
|
||||
let clips = add_recording(&mut e, 96_000, vec![(vox, plan[0].2.clone())], None);
|
||||
let clip = e.session().find_clip(clips[0]).unwrap().1.clone();
|
||||
assert_eq!((clip.start, clip.length), (96_000, 4_800));
|
||||
assert_eq!(save_session(&mut e, &session).unwrap(), 0);
|
||||
let src = e.session().sources.last().unwrap().clone();
|
||||
assert_eq!((src.name.as_str(), src.path.as_str(), src.unsaved, src.frames), ("Vox_02", "Audio Files/Vox_02.wav", false, 4_800));
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(e.session().find_clip(clips[0]).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_take_becomes_one_clip_per_pass_and_autosaves_in_place() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-loop-take-{}", std::process::id()));
|
||||
let path = dir.join("Audio Files/Vox_01.wav");
|
||||
write_take(&path, 1_050);
|
||||
let mut e = Engine::default();
|
||||
let vox = armed(&mut e, "Vox", "mono", "In 1");
|
||||
assert_eq!(add_recording(&mut e, 1_000, vec![(vox, path.clone())], Some(400)).len(), 3);
|
||||
assert_eq!(save_copy(&e, &dir.join("Untitled.scraft").to_string_lossy()).unwrap(), 0);
|
||||
let t = e.session().track(vox).unwrap();
|
||||
let passes: Vec<_> = t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| (c.source_offset(), c.start, c.length))).collect();
|
||||
assert_eq!(passes, vec![(0, 1_000, 400), (400, 1_000, 400), (800, 1_000, 250)]);
|
||||
assert_eq!((t.active_playlist, t.playlists[2].name.as_str()), (2, "Vox.03"));
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(e.session().track(vox).unwrap().playlists.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
e.push_undo("Record", before);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -9,6 +9,7 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-time.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-model.workspace = true
|
||||
soundcraft-mix.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
@@ -20,8 +21,5 @@ cpal.workspace = true
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
cpal = { workspace = true, features = ["wasm-bindgen"] }
|
||||
|
||||
[dev-dependencies]
|
||||
soundcraft-audio-io.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+233
-70
@@ -1,22 +1,78 @@
|
||||
//! Audio input capture for recording.
|
||||
//!
|
||||
//! A [`Recorder`] opens the default input device and appends interleaved samples to a shared
|
||||
//! buffer while armed. The callback only `try_lock`s; if the UI thread holds the lock the block is
|
||||
//! kept in a small local backlog and appended next time, so nothing blocks the audio thread.
|
||||
//! A [`Recorder`] opens the default input device. While armed, the input callback copies each
|
||||
//! block into a preallocated lock-free ring (it never locks or allocates), and a writer thread
|
||||
//! streams the ring into one WAV file per take, rewriting the header every second so a crash leaves
|
||||
//! a playable file.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
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::time::{Duration, Instant};
|
||||
|
||||
/// Captured audio: planar f32 at `sample_rate`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Take {
|
||||
pub sample_rate: u32,
|
||||
pub channels: Vec<Vec<f32>>,
|
||||
use soundcraft_model::TrackId;
|
||||
|
||||
/// What [`Recorder::finish`] returns: the takes that hold audio and the first problem met.
|
||||
pub type Takes = (Vec<(TrackId, PathBuf)>, Option<String>);
|
||||
|
||||
const RING_SECONDS: usize = 4;
|
||||
|
||||
/// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots
|
||||
/// are atomics, so it needs no lock and no `unsafe`.
|
||||
struct CaptureRing {
|
||||
slots: Box<[AtomicU32]>,
|
||||
mask: usize,
|
||||
head: AtomicUsize,
|
||||
tail: AtomicUsize,
|
||||
}
|
||||
|
||||
impl CaptureRing {
|
||||
fn new(capacity: usize) -> CaptureRing {
|
||||
let n = capacity.max(2).next_power_of_two();
|
||||
CaptureRing { slots: (0..n).map(|_| AtomicU32::new(0)).collect(), mask: n - 1, head: AtomicUsize::new(0), tail: AtomicUsize::new(0) }
|
||||
}
|
||||
|
||||
/// Queue up to `n` samples (`sample(i)` is the i-th); returns how many fit.
|
||||
fn push(&self, n: usize, sample: impl Fn(usize) -> f32) -> usize {
|
||||
let head = self.head.load(Ordering::Relaxed);
|
||||
let n = n.min(self.slots.len().saturating_sub(head.wrapping_sub(self.tail.load(Ordering::Acquire))));
|
||||
for i in 0..n {
|
||||
if let Some(slot) = self.slots.get(head.wrapping_add(i) & self.mask) {
|
||||
slot.store(sample(i).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
self.head.store(head.wrapping_add(n), Ordering::Release);
|
||||
n
|
||||
}
|
||||
|
||||
fn pop_all(&self, out: &mut Vec<f32>) {
|
||||
let (tail, head) = (self.tail.load(Ordering::Relaxed), self.head.load(Ordering::Acquire));
|
||||
out.extend(
|
||||
(0..head.wrapping_sub(tail))
|
||||
.map(|i| self.slots.get(tail.wrapping_add(i) & self.mask).map_or(0.0, |s| f32::from_bits(s.load(Ordering::Relaxed)))),
|
||||
);
|
||||
self.tail.store(head, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
armed: AtomicBool,
|
||||
data: Mutex<Vec<f32>>,
|
||||
ring: CaptureRing,
|
||||
/// Samples lost while the ring was full.
|
||||
dropped: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// 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 };
|
||||
*owed = owed.saturating_add(data.len() - kept);
|
||||
self.dropped.fetch_add(data.len() - kept, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Recorder {
|
||||
@@ -28,12 +84,9 @@ pub struct Recorder {
|
||||
pub device_name: String,
|
||||
pub sample_rate: u32,
|
||||
pub channels: usize,
|
||||
writer: Option<std::thread::JoinHandle<Takes>>,
|
||||
}
|
||||
|
||||
/// Cap: one hour of 8-channel 192 kHz audio; 64 Mi samples (256 MB of f32) on 32-bit targets
|
||||
/// (wasm32, i686), where the 64-bit product doesn't fit in `usize`.
|
||||
const MAX_SAMPLES: usize = if usize::BITS >= 64 { (3600u64 * 192_000 * 8) as usize } else { 64 << 20 };
|
||||
|
||||
impl Recorder {
|
||||
/// Open the default input device. Errors when there is none (the caller shows a message).
|
||||
#[cfg(not(target_os = "freebsd"))]
|
||||
@@ -48,29 +101,23 @@ impl Recorder {
|
||||
return Err(format!("unsupported input sample format {:?}", cfg.sample_format()));
|
||||
}
|
||||
let config = cfg.config();
|
||||
let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) });
|
||||
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 monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0);
|
||||
let mon = Arc::clone(&monitor);
|
||||
let mut backlog: Vec<f32> = Vec::new();
|
||||
let mut owed = 0;
|
||||
let stream = device
|
||||
.build_input_stream(
|
||||
&config,
|
||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||
crate::mark_audio_thread();
|
||||
mon.push(data);
|
||||
if !sh.armed.load(Ordering::Relaxed) {
|
||||
backlog.clear();
|
||||
return;
|
||||
}
|
||||
if let Ok(mut d) = sh.data.try_lock() {
|
||||
if d.len() < MAX_SAMPLES {
|
||||
d.extend_from_slice(&backlog);
|
||||
d.extend_from_slice(data);
|
||||
}
|
||||
backlog.clear();
|
||||
} else if backlog.len() < 1 << 20 {
|
||||
backlog.extend_from_slice(data);
|
||||
if sh.armed.load(Ordering::Acquire) {
|
||||
sh.capture(data, &mut owed);
|
||||
} else {
|
||||
owed = 0;
|
||||
}
|
||||
},
|
||||
|e| {
|
||||
@@ -82,14 +129,7 @@ impl Recorder {
|
||||
)
|
||||
.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: usize::from(config.channels).max(1),
|
||||
})
|
||||
Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None })
|
||||
}
|
||||
|
||||
#[cfg(target_os = "freebsd")]
|
||||
@@ -97,51 +137,174 @@ impl Recorder {
|
||||
Err("recording is not supported on this platform yet".into())
|
||||
}
|
||||
|
||||
/// Start capturing (clears any previous take).
|
||||
pub fn arm(&self) {
|
||||
self.shared.data.lock().unwrap_or_else(PoisonError::into_inner).clear();
|
||||
self.shared.armed.store(true, Ordering::Relaxed);
|
||||
/// Create each take's file and start recording into them: a track's take records the given
|
||||
/// input channels into a new file.
|
||||
pub fn arm(&mut self, takes: Vec<(TrackId, Vec<usize>, PathBuf)>) -> Result<(), String> {
|
||||
if self.writer.is_some() {
|
||||
return Err("already recording".into());
|
||||
}
|
||||
let takes =
|
||||
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.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)) {
|
||||
Ok(w) => self.writer = Some(w),
|
||||
Err(e) => {
|
||||
self.shared.armed.store(false, Ordering::Release);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_armed(&self) -> bool {
|
||||
self.shared.armed.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Stop capturing and return the take.
|
||||
pub fn take(&self) -> Take {
|
||||
self.shared.armed.store(false, Ordering::Relaxed);
|
||||
let data = std::mem::take(&mut *self.shared.data.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
deinterleave(&data, self.channels, self.sample_rate)
|
||||
}
|
||||
|
||||
/// Samples captured so far (per channel).
|
||||
pub fn captured_frames(&self) -> usize {
|
||||
self.shared.data.try_lock().map_or(0, |d| d.len() / self.channels.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();
|
||||
let lost = self.shared.dropped.swap(0, Ordering::Relaxed) / self.channels.max(1);
|
||||
let slow =
|
||||
(lost > 0).then(|| format!("{} ms of input lost: the disk was too slow", lost.saturating_mul(1000) / self.sample_rate.max(1) as usize));
|
||||
(takes, problem.or(slow))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deinterleave(data: &[f32], channels: usize, sample_rate: u32) -> Take {
|
||||
let ch = channels.max(1);
|
||||
let frames = data.len() / ch;
|
||||
let mut out = vec![Vec::with_capacity(frames); ch];
|
||||
for frame in data.chunks_exact(ch) {
|
||||
for (c, v) in frame.iter().enumerate() {
|
||||
if let Some(o) = out.get_mut(c) {
|
||||
o.push(*v);
|
||||
}
|
||||
impl Drop for Recorder {
|
||||
fn drop(&mut self) {
|
||||
self.finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn write_takes(shared: &Shared, mut takes: Vec<TakeWriter>, channels: usize) -> Takes {
|
||||
let (mut block, mut header_written) = (Vec::new(), Instant::now());
|
||||
loop {
|
||||
let stopping = !shared.armed.load(Ordering::Acquire);
|
||||
// The ring holds interleaved samples and a pop can end mid-frame: write whole frames and
|
||||
// keep the remainder for the next pop, so channels never shift.
|
||||
shared.ring.pop_all(&mut block);
|
||||
let whole = block.len() - block.len() % channels.max(1);
|
||||
takes.iter_mut().for_each(|t| t.write(&block[..whole], channels));
|
||||
block.drain(..whole);
|
||||
if stopping {
|
||||
break;
|
||||
}
|
||||
if header_written.elapsed() >= Duration::from_secs(1) {
|
||||
header_written = Instant::now();
|
||||
takes.iter_mut().for_each(TakeWriter::write_header);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let (mut done, mut problem) = (Vec::new(), None);
|
||||
for mut t in takes {
|
||||
t.write_header();
|
||||
if t.frames == 0 {
|
||||
let _ = std::fs::remove_file(&t.path);
|
||||
} else {
|
||||
done.push((t.id, t.path));
|
||||
}
|
||||
problem = problem.or(t.error);
|
||||
}
|
||||
(done, problem)
|
||||
}
|
||||
|
||||
/// One take's file. After an error it stops writing; its header keeps covering what was written.
|
||||
struct TakeWriter {
|
||||
id: TrackId,
|
||||
inputs: Vec<usize>,
|
||||
path: PathBuf,
|
||||
file: File,
|
||||
rate: u32,
|
||||
frames: u64,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl TakeWriter {
|
||||
fn create(id: TrackId, inputs: Vec<usize>, path: PathBuf, rate: u32) -> Result<TakeWriter, String> {
|
||||
let file = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| File::create_new(&path));
|
||||
let file = file.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None };
|
||||
take.write_header();
|
||||
take.error.take().map_or(Ok(take), Err)
|
||||
}
|
||||
|
||||
/// Append this take's channels of `block` (interleaved, `channels` wide).
|
||||
fn write(&mut self, block: &[f32], channels: usize) {
|
||||
let channels = channels.max(1);
|
||||
if self.error.is_some() {
|
||||
return;
|
||||
}
|
||||
let bytes: Vec<u8> = block
|
||||
.chunks_exact(channels)
|
||||
.flat_map(|f| self.inputs.iter().flat_map(move |&i| f.get(i).copied().unwrap_or(0.0).to_le_bytes()))
|
||||
.collect();
|
||||
match self.file.write_all(&bytes) {
|
||||
Ok(()) => self.frames += (block.len() / channels) as u64,
|
||||
Err(e) => self.fail(e),
|
||||
}
|
||||
}
|
||||
Take { sample_rate, channels: out }
|
||||
|
||||
fn write_header(&mut self) {
|
||||
let header = match soundcraft_audio_io::wav_float_header(self.inputs.len(), self.rate, self.frames) {
|
||||
Ok(h) => h,
|
||||
Err(e) => return self.fail(e),
|
||||
};
|
||||
let f = &mut self.file;
|
||||
if let Err(e) = f.seek(SeekFrom::Start(0)).and_then(|_| f.write_all(&header)).and_then(|()| f.seek(SeekFrom::End(0))) {
|
||||
self.fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&mut self, e: impl std::fmt::Display) {
|
||||
self.error.get_or_insert_with(|| format!("{}: {e}", self.path.display()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn decode(path: &PathBuf) -> Vec<Vec<f32>> {
|
||||
soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap().1.channels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deinterleaves() {
|
||||
let t = super::deinterleave(&[1.0, 2.0, 3.0, 4.0, 5.0], 2, 48_000);
|
||||
assert_eq!(t.channels, vec![vec![1.0, 3.0], vec![2.0, 4.0]]);
|
||||
let t = super::deinterleave(&[], 0, 48_000);
|
||||
assert_eq!(t.channels.len(), 1);
|
||||
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 (mut owed, mut out) = (0, Vec::new());
|
||||
shared.capture(&[1.0; 6], &mut owed);
|
||||
shared.capture(&[2.0; 4], &mut owed);
|
||||
shared.ring.pop_all(&mut out);
|
||||
shared.capture(&[3.0; 2], &mut owed);
|
||||
shared.ring.pop_all(&mut out);
|
||||
assert_eq!(out, [vec![1.0; 6], vec![2.0; 2], vec![0.0; 2], vec![3.0; 2]].concat());
|
||||
assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_stream_into_their_files() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let gtr = TakeWriter::create(TrackId(1), vec![1, 2], dir.join("Gtr_01.wav"), 48_000).unwrap();
|
||||
let mut vox = TakeWriter::create(TrackId(2), vec![0], dir.join("Vox_01.wav"), 48_000).unwrap();
|
||||
assert!(TakeWriter::create(TrackId(3), vec![0], dir.join("Vox_01.wav"), 48_000).is_err(), "never overwrites a file");
|
||||
vox.write(&[0.5; 300], 3);
|
||||
vox.write_header();
|
||||
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 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();
|
||||
let mut owed = 0;
|
||||
input.chunks(3 * 480).for_each(|block| shared.capture(block, &mut owed));
|
||||
shared.armed.store(false, Ordering::Release);
|
||||
let (done, problem) = writer.join().unwrap();
|
||||
let channel = |c: usize| input.iter().skip(c).step_by(3).copied().collect::<Vec<f32>>();
|
||||
assert_eq!((done.len(), problem), (2, None));
|
||||
assert_eq!(decode(&done[0].1), vec![channel(1), channel(2)]);
|
||||
assert_eq!(decode(&done[1].1), vec![[vec![0.5; 100], vec![0.25; 50], channel(0)].concat()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-46
@@ -421,17 +421,14 @@ impl SoundApp {
|
||||
return;
|
||||
}
|
||||
self.ensure_input();
|
||||
if self.recorder.is_none() {
|
||||
self.ui.status = "Cannot record: no audio input device".into();
|
||||
if let Err(err) = self.arm_recorder() {
|
||||
self.ui.status = format!("Cannot record: {err}");
|
||||
return;
|
||||
}
|
||||
if let Some(p) = &self.player {
|
||||
p.set_recording(true);
|
||||
}
|
||||
let already_playing = self.is_playing();
|
||||
if let Some(r) = &self.recorder {
|
||||
r.arm();
|
||||
}
|
||||
self.engine.transport.recording = true;
|
||||
if already_playing {
|
||||
// Punch in on the fly at the current playhead.
|
||||
@@ -457,6 +454,19 @@ impl SoundApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm the input for the record-enabled tracks; a session never saved records into its
|
||||
/// autosave folder.
|
||||
fn arm_recorder(&mut self) -> Result<(), 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()) };
|
||||
let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels);
|
||||
if plan.is_empty() {
|
||||
return Err("no audio track is record-enabled".into());
|
||||
}
|
||||
r.arm(plan)
|
||||
}
|
||||
|
||||
/// Reads every live third-party plugin's state from the audio engine and stores it on its
|
||||
/// insert (`mix.insert_state`), so saves and bounces include it.
|
||||
pub fn capture_plugin_states(&mut self) {
|
||||
@@ -552,49 +562,26 @@ impl SoundApp {
|
||||
if let Some(p) = &self.player {
|
||||
p.set_recording(false);
|
||||
}
|
||||
let Some(r) = &self.recorder else { return };
|
||||
let take = r.take();
|
||||
let rate = take.sample_rate;
|
||||
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;
|
||||
if self.engine.session().edit.loop_record && !sel.is_empty() {
|
||||
// Split the capture into one take per loop pass; each pass gets its own playlist.
|
||||
let pass = usize::try_from(soundcraft_time::to_samples(sel.len() as f64 * f64::from(rate) / self.engine.session().sample_rate.as_f64()))
|
||||
.unwrap_or(0)
|
||||
.max(1);
|
||||
let total = take.channels.first().map_or(0, Vec::len);
|
||||
let passes = total.div_ceil(pass).max(1);
|
||||
let mut made = 0;
|
||||
for k in 0..passes {
|
||||
let chunk: Vec<Vec<f32>> =
|
||||
take.channels.iter().map(|c| c.get(k * pass..((k + 1) * pass).min(c.len())).map(<[f32]>::to_vec).unwrap_or_default()).collect();
|
||||
if chunk.first().is_none_or(|c| c.len() < pass / 8) {
|
||||
continue;
|
||||
}
|
||||
if made > 0 {
|
||||
let armed: Vec<u64> = self.engine.session().tracks.iter().filter(|t| t.mixer.record_arm).map(|t| t.id.0).collect();
|
||||
let _ = self.engine.execute("track.playlist_new", &serde_json::json!({"tracks": armed}));
|
||||
}
|
||||
if soundcraft_engine::io::add_recording(&mut self.engine, sel.start, chunk, rate).is_ok() {
|
||||
made += 1;
|
||||
}
|
||||
}
|
||||
self.ui.status = format!("Loop-recorded {made} take(s)");
|
||||
return;
|
||||
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",
|
||||
);
|
||||
}
|
||||
match soundcraft_engine::io::add_recording(&mut self.engine, self.record_start, take.channels, rate) {
|
||||
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(),
|
||||
self.ui.status = if looped { 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user