Record takes straight to disk

The input callback appended every block to one growing Vec: it
reallocated on the audio thread as the take grew, kept every input
channel in RAM, and the take existed only in memory until Stop, so a
crash during a take lost it.

- The input callback copies each block into a preallocated lock-free
  ring (atomic slots, no unsafe) and never locks or allocates. Samples
  that don't fit are paid back as silence before the next block, so the
  take keeps its timing; the loss is reported in the status line.
- A writer thread drains the ring every 10 ms into one 32-bit float WAV
  per armed track, holding only that track's input channels. Files go
  to the session's Audio Files folder, or to its autosave folder while
  the session has never been saved, named like the incumbent's takes
  (<track>_<NN>.wav). An existing file is never overwritten.
- The header is rewritten every second, so a file left by a crash plays
  up to the last update. Above 4 GiB it switches to RF64 in place.
- If a take file can't be created, Record doesn't start and says why;
  a write error ends that take where it failed.
- At Stop the files become the takes' sources. Loop recording keeps one
  file per track, one clip per pass (at most 1000) on its own playlist,
  in one undo step.
- Saving uses a file already in the target's Audio Files folder in
  place instead of rewriting it, so neither Save nor autosave copies a
  take. A take recorded at another rate than the session's is saved once
  at the session's rate.

soundcraft-audio-io gains wav_float_header (its length doesn't depend
on the frame count, so it can be rewritten in place); encode shares the
header code. Splitting a loop take into passes moved from the UI into
soundcraft_engine::io::add_recording, where it is tested.
This commit is contained in:
grovebit 2026-10-10 00:46:34 +01:00
parent 522e59f8f5
commit cfe6b42d00
7 changed files with 481 additions and 202 deletions

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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());
}

View File

@ -477,6 +477,11 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
if exists_rel && !src.unsaved {
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 target = rel_target.clone();
@ -548,17 +553,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| {
@ -568,28 +570,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)]

View File

@ -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

View File

@ -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,171 @@ 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);
block.clear();
shared.ring.pop_all(&mut block);
takes.iter_mut().for_each(|t| t.write(&block, channels));
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);
}
}

View File

@ -402,17 +402,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.
@ -438,6 +435,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) {
@ -527,49 +537,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);
}
}