diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 782cb9a..87241c6 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -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 { 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> { + 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) +} diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index cf14fb7..df56010 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -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, + frames: u64, + force_rf64: bool, +) -> Result> { + 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) +} diff --git a/crates/audio-io/tests/roundtrip.rs b/crates/audio-io/tests/roundtrip.rs index b2291e1..a53c272 100644 --- a/crates/audio-io/tests/roundtrip.rs +++ b/crates/audio-io/tests/roundtrip.rs @@ -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()); +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 53d38e2..65aac5a 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -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> { 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>, rate: u32) -> Result> { - 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 +/// `_.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, 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>, 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) -> Vec { 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> = (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)] diff --git a/crates/playback/Cargo.toml b/crates/playback/Cargo.toml index 6f728a5..5ff9904 100644 --- a/crates/playback/Cargo.toml +++ b/crates/playback/Cargo.toml @@ -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 diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index 09eb684..fb1e6c4 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -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>, +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); + +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) { + 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>, + 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>, } -/// 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 = 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, 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::, _>>()?; + 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, 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, + path: PathBuf, + file: File, + rate: u32, + frames: u64, + error: Option, +} + +impl TakeWriter { + fn create(id: TrackId, inputs: Vec, path: PathBuf, rate: u32) -> Result { + 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 = 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> { + 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 = (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::>(); + 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); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d..26c14e4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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> = - 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 = 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 = 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 = 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); } }