diff --git a/crates/audio-io/src/aiff.rs b/crates/audio-io/src/aiff.rs index dfa7623..d8462c0 100644 --- a/crates/audio-io/src/aiff.rs +++ b/crates/audio-io/src/aiff.rs @@ -1,8 +1,9 @@ //! Native AIFF / AIFF-C reader and writer (Apple "Audio Interchange File Format" 1.3 and the //! AIFF-C draft: big-endian IFF chunks, 80-bit IEEE extended sample rate). -use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, clamp_float, deinterleave, tag, validate_buffer}; +use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, deinterleave, sample_format, tag, validate_buffer, validate_layout, write_interleaved}; use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat}; +use std::io::{Cursor, Seek, SeekFrom, Write}; /// AIFF-C version 1 timestamp for the FVER chunk. const AIFC_VERSION_1: u32 = 0xA280_5140; @@ -162,25 +163,82 @@ fn push_chunk(out: &mut Vec, id: &[u8; 4], body: &[u8]) { /// Encode AIFF (integer depths) or AIFF-C `fl32` (Float32). pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { validate_buffer(buf)?; - let channels = buf.channels.len(); - let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?; let frames = buf.frames(); + let head_len = header(buf.channels.len(), buf.sample_rate, depth, frames as u64)?.len(); + let data_len = usize::from(sample_format(depth).0 / 8).saturating_mul(buf.channels.len()).saturating_mul(frames); + let total = head_len.saturating_add(data_len).saturating_add(1); + let mut out = Vec::new(); + out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?; + let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?; + w.write(&buf.channels, frames)?; + Ok(w.finish()?.into_inner()) +} + +/// An AIFF written block by block: a placeholder header first, then the real one (the same +/// length) written over it by [`Writer::finish`]. +pub(crate) struct Writer { + out: W, + start: u64, + channels: usize, + sample_rate: u32, + depth: BitDepth, + quantizers: Vec, + frames: u64, + bytes: Vec, +} + +impl Writer { + pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result { + validate_layout(channels, sample_rate)?; + let start = out.stream_position()?; + out.write_all(&header(channels, sample_rate, depth, 0)?)?; + let quantizers = Quantizer::per_channel(u32::from(sample_format(depth).0), dither, channels); + Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new() }) + } + + /// The frame count after `frames` more, or an error when AIFF can't hold that many (4 GiB). + pub(crate) fn check_room(&self, frames: u64) -> Result { + let total = self.frames.saturating_add(frames); + header(self.channels, self.sample_rate, self.depth, total)?; + Ok(total) + } + + /// Appends `frames` frames of planar audio (missing samples are silence), unless they don't + /// fit (see [`Writer::check_room`]). + pub(crate) fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + let total = self.check_room(frames as u64)?; + write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true)?; + self.frames = total; + Ok(()) + } + + pub(crate) fn finish(mut self) -> Result { + let data_len = u64::from(sample_format(self.depth).0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames); + if data_len % 2 == 1 { + self.out.write_all(&[0])?; + } + let head = header(self.channels, self.sample_rate, self.depth, self.frames)?; + self.out.seek(SeekFrom::Start(self.start))?; + self.out.write_all(&head)?; + self.out.seek(SeekFrom::End(0))?; + self.out.flush()?; + Ok(self.out) + } +} + +/// Everything before the sample data of an AIFF holding `frames` frames; its length doesn't +/// depend on `frames`. +fn header(channels: usize, sample_rate: u32, depth: BitDepth, frames: u64) -> Result> { + let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?; let frames_u32 = u32::try_from(frames).map_err(|_| AudioError::TooLarge("too many frames for AIFF".into()))?; - let (bits, is_float): (u16, bool) = match depth { - BitDepth::Int16 => (16, false), - BitDepth::Int24 => (24, false), - BitDepth::Int32 => (32, false), - BitDepth::Float32 => (32, true), - }; - let bytes = usize::from(bits / 8); - let data_len = - bytes.checked_mul(channels).and_then(|b| b.checked_mul(frames)).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; + let (bits, is_float) = sample_format(depth); + let data_len = u64::from(bits / 8).saturating_mul(channels as u64).saturating_mul(frames); let mut comm = Vec::with_capacity(40); comm.extend_from_slice(&channels_i16.to_be_bytes()); comm.extend_from_slice(&frames_u32.to_be_bytes()); comm.extend_from_slice(&bits.to_be_bytes()); - comm.extend_from_slice(&u32_to_extended(buf.sample_rate)); + comm.extend_from_slice(&u32_to_extended(sample_rate)); if is_float { comm.extend_from_slice(b"fl32"); let name = b"32-bit float"; @@ -204,29 +262,10 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result head.extend_from_slice(&ssnd_len.to_be_bytes()); head.extend_from_slice(&[0u8; 8]); // offset, blockSize - let total = head.len().saturating_add(data_len).saturating_add(data_len & 1); + let total = (head.len() as u64).saturating_add(data_len).saturating_add(data_len & 1); let form_len = u32::try_from(total - 8).map_err(|_| AudioError::TooLarge("AIFF file exceeds 4 GiB".into()))?; if let Some(slot) = head.get_mut(4..8) { slot.copy_from_slice(&form_len.to_be_bytes()); } - - let mut out = Vec::new(); - out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?; - out.extend_from_slice(&head); - let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels); - for f in 0..frames { - for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) { - let s = ch.get(f).copied().unwrap_or(0.0); - if is_float { - out.extend_from_slice(&clamp_float(s).to_be_bytes()); - } else { - let v = q.quantize(s).to_be_bytes(); - out.extend_from_slice(v.get(4 - bytes..).unwrap_or_default()); - } - } - } - if data_len % 2 == 1 { - out.push(0); - } - Ok(out) + Ok(head) } diff --git a/crates/audio-io/src/flac.rs b/crates/audio-io/src/flac.rs index 2f5550b..8545183 100644 --- a/crates/audio-io/src/flac.rs +++ b/crates/audio-io/src/flac.rs @@ -4,6 +4,7 @@ use crate::pcm::{Quantizer, validate_buffer}; use crate::{AudioBuffer, AudioError, BitDepth, Result}; +use std::io::{Cursor, Seek, SeekFrom, Write}; const BLOCK_SIZE: usize = 4096; const MAX_PARTITION_ORDER: u32 = 6; @@ -269,31 +270,24 @@ fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) { } } -pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { - validate_buffer(buf)?; - let channels = buf.channels.len(); +/// Checks that FLAC can hold the layout; returns the bits per sample. +fn check(channels: usize, sample_rate: u32, depth: BitDepth) -> Result { + crate::pcm::validate_layout(channels, sample_rate)?; if channels > 8 { return Err(AudioError::Unsupported(format!("FLAC supports at most 8 channels, got {channels}"))); } - if buf.sample_rate > 655_350 { - return Err(AudioError::Unsupported(format!("FLAC sample rate {} exceeds 655350 Hz", buf.sample_rate))); + if sample_rate > 655_350 { + return Err(AudioError::Unsupported(format!("FLAC sample rate {sample_rate} exceeds 655350 Hz"))); } - let bps: u32 = match depth { - BitDepth::Int16 => 16, - BitDepth::Int24 => 24, - BitDepth::Int32 => 32, - BitDepth::Float32 => return Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())), - }; - let frames = buf.frames(); - let total = frames as u64; - if total >= 1u64 << 36 { - return Err(AudioError::TooLarge("too many frames for FLAC".into())); + match depth { + BitDepth::Int16 => Ok(16), + BitDepth::Int24 => Ok(24), + BitDepth::Int32 => Ok(32), + BitDepth::Float32 => Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())), } +} - let mut out = Vec::new(); - out.try_reserve(frames.saturating_mul(channels).saturating_mul(bps as usize / 8) / 2) - .map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?; - out.extend_from_slice(b"fLaC"); +fn stream_info(channels: usize, sample_rate: u32, bps: u32, total: u64) -> Vec { let mut si = BitWriter::new(); si.write(1, 1); // last metadata block si.write(0, 7); // STREAMINFO @@ -302,7 +296,7 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result si.write(BLOCK_SIZE as u64, 16); si.write(0, 24); si.write(0, 24); - si.write(u64::from(buf.sample_rate), 20); + si.write(u64::from(sample_rate), 20); si.write(channels as u64 - 1, 3); si.write(u64::from(bps - 1), 5); si.write(total >> 32, 4); @@ -310,25 +304,87 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result for _ in 0..4 { si.write(0, 32); // MD5 unknown } - out.extend_from_slice(&si.into_bytes()); + si.into_bytes() +} - let (sr_code, sr_extra) = sample_rate_code(buf.sample_rate); - let mut quantizers = Quantizer::per_channel(bps, dither, channels); - let mut block: Vec = Vec::with_capacity(BLOCK_SIZE); - let mut frame_no: u64 = 0; - let mut start = 0usize; - while start < frames { - let len = (frames - start).min(BLOCK_SIZE); +/// A FLAC stream written block by block; [`Writer::finish`] fills in the total sample count. +pub(crate) struct Writer { + out: W, + start: u64, + channels: usize, + sample_rate: u32, + bps: u32, + quantizers: Vec, + pending: Vec>, + frames: u64, + frame_no: u64, +} + +impl Writer { + pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result { + let bps = check(channels, sample_rate, depth)?; + let start = out.stream_position()?; + out.write_all(b"fLaC")?; + out.write_all(&stream_info(channels, sample_rate, bps, 0))?; + Ok(Writer { + out, + start, + channels, + sample_rate, + bps, + quantizers: Quantizer::per_channel(bps, dither, channels), + pending: (0..channels).map(|_| Vec::with_capacity(BLOCK_SIZE)).collect(), + frames: 0, + frame_no: 0, + }) + } + + /// The frame count after `frames` more, or an error past what FLAC can count (2^36 frames). + pub(crate) fn check_room(&self, frames: u64) -> Result { + let total = self.frames.saturating_add(frames); + if total >= 1u64 << 36 { + return Err(AudioError::TooLarge("too many frames for FLAC".into())); + } + Ok(total) + } + + /// Appends `frames` frames of planar audio (missing samples are silence), unless they don't + /// fit (see [`Writer::check_room`]). + pub(crate) fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + let total = self.check_room(frames as u64)?; + let mut done = 0; + while done < frames { + let held = self.pending.first().map_or(0, Vec::len); + let n = (BLOCK_SIZE - held).min(frames - done); + for (c, (p, q)) in self.pending.iter_mut().zip(self.quantizers.iter_mut()).enumerate() { + let src = block.get(c).and_then(|ch| ch.get(done..)).unwrap_or_default(); + p.extend((0..n).map(|i| i64::from(q.quantize(src.get(i).copied().unwrap_or(0.0))))); + } + done += n; + if held + n == BLOCK_SIZE { + self.write_frame()?; + } + } + self.frames = total; + Ok(()) + } + + fn write_frame(&mut self) -> Result<()> { + let len = self.pending.first().map_or(0, Vec::len); + if len == 0 { + return Ok(()); + } + let (sr_code, sr_extra) = sample_rate_code(self.sample_rate); let mut w = BitWriter::new(); w.write(0x3FFE, 14); w.write(0, 1); w.write(0, 1); // fixed blocksize stream w.write(7, 4); // 16-bit (blocksize-1) follows w.write(sr_code, 4); - w.write(channels as u64 - 1, 4); - w.write(bps_code(bps), 3); + w.write(self.channels as u64 - 1, 4); + w.write(bps_code(self.bps), 3); w.write(0, 1); - write_utf8_number(&mut w, frame_no); + write_utf8_number(&mut w, self.frame_no); w.write(len as u64 - 1, 16); if let Some((v, n)) = sr_extra { w.write(v, n); @@ -339,19 +395,39 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result w.write(u64::from(b), 8); } w.write(u64::from(crc8(&header)), 8); - for (ch, q) in buf.channels.iter().zip(quantizers.iter_mut()) { - block.clear(); - block.extend(ch.get(start..start + len).unwrap_or_default().iter().map(|&s| i64::from(q.quantize(s)))); - write_subframe(&mut w, &block, bps); + for p in &mut self.pending { + write_subframe(&mut w, p, self.bps); + p.clear(); } let mut frame = w.into_bytes(); let crc = crc16(&frame); frame.extend_from_slice(&crc.to_be_bytes()); - out.extend_from_slice(&frame); - start += len; - frame_no += 1; + self.out.write_all(&frame)?; + self.frame_no += 1; + Ok(()) } - Ok(out) + + pub(crate) fn finish(mut self) -> Result { + self.write_frame()?; + let info = stream_info(self.channels, self.sample_rate, self.bps, self.frames); + self.out.seek(SeekFrom::Start(self.start.saturating_add(4)))?; + self.out.write_all(&info)?; + self.out.seek(SeekFrom::End(0))?; + self.out.flush()?; + Ok(self.out) + } +} + +pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { + validate_buffer(buf)?; + let bps = check(buf.channels.len(), buf.sample_rate, depth)?; + let frames = buf.frames(); + let mut out = Vec::new(); + out.try_reserve(frames.saturating_mul(buf.channels.len()).saturating_mul(bps as usize / 8) / 2) + .map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?; + let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?; + w.write(&buf.channels, frames)?; + Ok(w.finish()?.into_inner()) } #[cfg(test)] diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 87241c6..ac8ad60 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -10,6 +10,8 @@ //! 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. +//! * [`FileEncoder`] writes the same formats block by block into any `Write + Seek`, so a file +//! of any length never has to fit in memory. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod aiff; @@ -21,6 +23,7 @@ mod symph; mod wav; pub use buffer::AudioBuffer; +use std::io::{Seek, Write}; /// Errors from probing, decoding and encoding. #[derive(Debug, thiserror::Error)] @@ -37,6 +40,9 @@ pub enum AudioError { /// Encoding failed (e.g. an empty buffer). #[error("audio encode failed: {0}")] Encode(String), + /// Writing the output failed. + #[error("audio write failed: {0}")] + Io(#[from] std::io::Error), } /// Result alias for this crate. @@ -286,6 +292,60 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u } } +/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing +/// the same bytes [`encode_with_channel_mask`] would for the whole buffer. +pub struct FileEncoder(Writer); + +enum Writer { + Wav(wav::Writer), + Aiff(aiff::Writer), + Flac(flac::Writer), +} + +impl FileEncoder { + /// Starts a file at the current position of `out`. `mask` overrides the WAV speaker mask + /// derived from the channel count. + pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option) -> Result { + let (depth, dither) = (opts.bit_depth, opts.dither); + Ok(FileEncoder(match opts.format { + FileFormat::Wav => Writer::Wav(wav::Writer::new(out, channels, sample_rate, depth, dither, opts.bwf.as_ref(), mask, false)?), + FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?), + FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?), + other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))), + })) + } + + /// Fails, without writing anything, when `frames` more frames would not fit the format (an + /// AIFF holds 4 GiB, a FLAC 2^36 frames). + pub fn check_room(&self, frames: u64) -> Result<()> { + match &self.0 { + Writer::Wav(w) => w.check_room(frames), + Writer::Aiff(w) => w.check_room(frames), + Writer::Flac(w) => w.check_room(frames), + } + .map(drop) + } + + /// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are + /// silence), unless they don't fit (see [`FileEncoder::check_room`]). + pub fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + match &mut self.0 { + Writer::Wav(w) => w.write(block, frames), + Writer::Aiff(w) => w.write(block, frames), + Writer::Flac(w) => w.write(block, frames), + } + } + + /// Completes the headers and hands back `out`, positioned at the end of the file. + pub fn finish(self) -> Result { + match self.0 { + Writer::Wav(w) => w.finish(), + Writer::Aiff(w) => w.finish(), + Writer::Flac(w) => w.finish(), + } + } +} + /// The WAVE_FORMAT_EXTENSIBLE speaker mask of a WAV file, if it has one. pub fn wav_channel_mask(bytes: &[u8]) -> Option { wav::read_channel_mask(bytes) diff --git a/crates/audio-io/src/pcm.rs b/crates/audio-io/src/pcm.rs index a1fe894..c256ea8 100644 --- a/crates/audio-io/src/pcm.rs +++ b/crates/audio-io/src/pcm.rs @@ -1,7 +1,8 @@ //! Byte-level PCM helpers shared by the WAV, AIFF and FLAC code: bounded readers, sample //! decoding to `f32`, and quantization (with optional TPDF dither) for encoding. -use crate::{AudioError, Result}; +use crate::{AudioError, BitDepth, Result}; +use std::io::Write; /// Maximum number of decoded `f32` samples (all channels) we will produce: 4 GiB of output. pub(crate) const MAX_DECODE_SAMPLES: u64 = (4u64 << 30) / 4; @@ -156,6 +157,49 @@ impl Quantizer { } } +/// Bits per sample at `depth`, and whether they are float. +pub(crate) fn sample_format(depth: BitDepth) -> (u16, bool) { + match depth { + BitDepth::Int16 => (16, false), + BitDepth::Int24 => (24, false), + BitDepth::Int32 => (32, false), + BitDepth::Float32 => (32, true), + } +} + +/// Interleaves `frames` frames of planar audio (missing samples are silence) as `bits`-bit +/// samples and writes them to `out`, a few thousand frames at a time through `scratch`. +pub(crate) fn write_interleaved( + out: &mut impl Write, + scratch: &mut Vec, + block: &[Vec], + frames: usize, + quantizers: &mut [Quantizer], + (bits, is_float): (u16, bool), + big_endian: bool, +) -> std::io::Result<()> { + let size = usize::from(bits / 8); + let mut start = 0; + while start < frames { + let end = frames.min(start + 4096); + scratch.clear(); + for f in start..end { + for (c, q) in quantizers.iter_mut().enumerate() { + let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0); + match (is_float, big_endian) { + (true, false) => scratch.extend_from_slice(&clamp_float(s).to_le_bytes()), + (true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()), + (false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()), + (false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()), + } + } + } + out.write_all(scratch)?; + start = end; + } + Ok(()) +} + /// Clamp a float sample to [-1, 1], mapping NaN to 0. pub(crate) fn clamp_float(x: f32) -> f32 { if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) } @@ -163,13 +207,18 @@ pub(crate) fn clamp_float(x: f32) -> f32 { /// Check that a buffer is encodable: at least one channel and a non-zero sample rate. pub(crate) fn validate_buffer(buf: &crate::AudioBuffer) -> Result<()> { - if buf.channels.is_empty() { + validate_layout(buf.channels.len(), buf.sample_rate) +} + +/// Check that a stream is encodable: at least one channel and a non-zero sample rate. +pub(crate) fn validate_layout(channels: usize, sample_rate: u32) -> Result<()> { + if channels == 0 { return Err(AudioError::Encode("buffer has no channels".into())); } - if buf.channels.len() > usize::from(u16::MAX) { + if channels > usize::from(u16::MAX) { return Err(AudioError::Encode("too many channels".into())); } - if buf.sample_rate == 0 { + if sample_rate == 0 { return Err(AudioError::Encode("sample rate is 0".into())); } Ok(()) diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index df56010..43574a8 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -1,8 +1,9 @@ //! Native WAV / BWF / RF64 reader and writer (RIFF chunk layout per the Microsoft multimedia //! specification, `bext` per EBU Tech 3285, `ds64` per EBU Tech 3306). -use crate::pcm::{PcmKind, Quantizer, clamp_float, deinterleave, le_u16, le_u32, le_u64, tag, validate_buffer}; +use crate::pcm::{PcmKind, Quantizer, deinterleave, le_u16, le_u32, le_u64, sample_format, tag, validate_buffer, validate_layout, write_interleaved}; use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, BwfInfo, FileFormat, Result, SampleFormat}; +use std::io::{Cursor, Seek, SeekFrom, Write}; const WAVE_FORMAT_PCM: u16 = 1; const WAVE_FORMAT_IEEE_FLOAT: u16 = 3; @@ -255,6 +256,74 @@ fn push_chunk(out: &mut Vec, id: &[u8; 4], body: &[u8]) { } } +/// A WAV written block by block: a placeholder header first, then the real one (the same length, +/// RF64 above 4 GiB, or always when `force_rf64`) written over it by [`Writer::finish`]. +pub(crate) struct Writer { + out: W, + start: u64, + channels: usize, + sample_rate: u32, + format: (u16, bool), + bwf: Option, + mask: Option, + force_rf64: bool, + quantizers: Vec, + frames: u64, + bytes: Vec, +} + +impl Writer { + pub(crate) fn new( + mut out: W, + channels: usize, + sample_rate: u32, + depth: BitDepth, + dither: bool, + bwf: Option<&BwfInfo>, + mask: Option, + force_rf64: bool, + ) -> Result { + validate_layout(channels, sample_rate)?; + let format = sample_format(depth); + let start = out.stream_position()?; + out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?; + let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels); + Ok(Writer { out, start, channels, sample_rate, format, bwf: bwf.cloned(), mask, force_rf64, quantizers, frames: 0, bytes: Vec::new() }) + } + + fn header(&self, frames: u64) -> Result> { + header(self.channels, self.sample_rate, self.format, self.bwf.as_ref(), self.mask, frames, self.force_rf64) + } + + /// The frame count after `frames` more (RF64 has room for any bounce). + pub(crate) fn check_room(&self, frames: u64) -> Result { + let total = self.frames.saturating_add(frames); + self.header(total)?; + Ok(total) + } + + /// Appends `frames` frames of planar audio (missing samples are silence). + pub(crate) fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + let total = self.check_room(frames as u64)?; + write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false)?; + self.frames = total; + Ok(()) + } + + pub(crate) fn finish(mut self) -> Result { + let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames); + if data_len % 2 == 1 { + self.out.write_all(&[0])?; + } + let head = self.header(self.frames)?; + self.out.seek(SeekFrom::Start(self.start))?; + self.out.write_all(&head)?; + self.out.seek(SeekFrom::End(0))?; + self.out.flush()?; + Ok(self.out) + } +} + /// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests); /// it is used automatically when the file would exceed 4 GiB. /// `mask` overrides the speaker mask derived from the channel count. @@ -269,36 +338,15 @@ pub(crate) fn encode( validate_buffer(buf)?; let channels = buf.channels.len(); let frames = buf.frames(); - let (bits, is_float): (u16, bool) = match depth { - BitDepth::Int16 => (16, false), - BitDepth::Int24 => (24, false), - BitDepth::Int32 => (32, false), - BitDepth::Float32 => (32, true), - }; - let bytes = usize::from(bits / 8); - 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 format = sample_format(depth); + let head = header(channels, buf.sample_rate, format, bwf, mask, frames as u64, force_rf64)?; + let data_len = (usize::from(format.0 / 8) * 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)) .map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?; - out.extend_from_slice(&head); - let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels); - for f in 0..frames { - for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) { - let s = ch.get(f).copied().unwrap_or(0.0); - if is_float { - out.extend_from_slice(&clamp_float(s).to_le_bytes()); - } else { - let v = q.quantize(s).to_le_bytes(); - out.extend_from_slice(v.get(..bytes).unwrap_or_default()); - } - } - } - if data_len % 2 == 1 { - out.push(0); - } - Ok(out) + let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, bwf, mask, force_rf64)?; + w.write(&buf.channels, frames)?; + Ok(w.finish()?.into_inner()) } /// Everything before the sample data of a WAV holding `frames` frames. diff --git a/crates/audio-io/tests/stream.rs b/crates/audio-io/tests/stream.rs new file mode 100644 index 0000000..61a2894 --- /dev/null +++ b/crates/audio-io/tests/stream.rs @@ -0,0 +1,64 @@ +use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, BwfInfo, EncodeOptions, FileEncoder, FileFormat, encode, encode_with_channel_mask}; +use std::io::Cursor; + +fn tone(channels: usize, frames: usize) -> AudioBuffer { + let channels = (0..channels).map(|c| (0..frames).map(|i| ((i * (c + 3)) as f32 * 0.013).sin() * 1.2).collect()).collect(); + AudioBuffer { sample_rate: 44_100, channels } +} + +fn streamed(buf: &AudioBuffer, opts: &EncodeOptions, mask: Option) -> Vec { + let mut out = Cursor::new(b"pre".to_vec()); + out.set_position(3); + let mut enc = FileEncoder::new(out, opts, buf.channels.len(), buf.sample_rate, mask).unwrap(); + let mut start = 0; + for len in [1, 7, 4095, 4097, 333].into_iter().cycle() { + if start >= buf.frames() { + break; + } + let len = len.min(buf.frames() - start); + let block: Vec> = buf.channels.iter().map(|c| c[start..start + len].to_vec()).collect(); + enc.write(&block, len).unwrap(); + start += len; + } + enc.finish().unwrap().into_inner() +} + +#[test] +fn streamed_files_match_whole_buffer_encodes() { + let bwf = BwfInfo { description: "take".into(), time_reference: 123_456, ..Default::default() }; + let cases = [ + (FileFormat::Wav, BitDepth::Int24, true, None, None), + (FileFormat::Wav, BitDepth::Float32, false, None, None), + (FileFormat::Wav, BitDepth::Int16, true, Some(bwf), Some(0x60F)), + (FileFormat::Aiff, BitDepth::Int24, true, None, None), + (FileFormat::Aiff, BitDepth::Float32, false, None, None), + (FileFormat::Flac, BitDepth::Int16, true, None, None), + (FileFormat::Flac, BitDepth::Int24, false, None, None), + ]; + for (format, bit_depth, dither, bwf, mask) in cases { + let opts = EncodeOptions { format, bit_depth, dither, bwf }; + for channels in [1, 6] { + let buf = tone(channels, 20_001); + let whole = match mask { + Some(m) => encode_with_channel_mask(&buf, &opts, m), + None => encode(&buf, &opts), + } + .unwrap(); + assert_eq!(streamed(&buf, &opts, mask), [b"pre".as_slice(), &whole].concat(), "{opts:?}, {channels} channels"); + } + } +} + +#[test] +fn blocks_past_a_format_limit_are_refused_before_anything_is_written() { + let buf = tone(2, 100); + for (format, limit) in [(FileFormat::Aiff, 1usize << 30), (FileFormat::Flac, 1 << 36)] { + let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None }; + let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 2, buf.sample_rate, None).unwrap(); + enc.write(&buf.channels, 100).unwrap(); + assert!(enc.check_room(limit as u64 / 2).is_ok(), "{format:?}"); + assert!(matches!(enc.check_room(limit as u64), Err(AudioError::TooLarge(_))), "{format:?}"); + assert!(matches!(enc.write(&[], limit), Err(AudioError::TooLarge(_))), "{format:?}"); + assert_eq!(enc.finish().unwrap().into_inner(), encode(&buf, &opts).unwrap()); + } +} diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index b9a5eac..859871b 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -52,7 +52,8 @@ fn max_len(ch: &[Vec]) -> usize { ch.iter().map(Vec::len).max().unwrap_or(0) } -fn peak(ch: &[Vec]) -> f32 { +/// The largest finite sample magnitude in any channel. +pub fn peak(ch: &[Vec]) -> f32 { ch.iter().flatten().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs())) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ddbabf8..6a6f5f7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -292,11 +292,15 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { let normalize = bool_or(p, "normalize", false); let fold = fold_down_param(p, "file.bounce_mix")?; let bus = bounce_bus(e, p)?; - let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?; - crate::io::write_file(&path, &bytes)?; - Ok( - json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}), - ) + let done = crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold)?.run()?; + Ok(json!({ + "path": path, + "bytes": done.bytes, + "seconds": e.session().sample_rate.seconds(r.len()), + "peak_db": done.peak_db, + "lufs": done.lufs, + "true_peak_db": done.true_peak_db, + })) } /// Write the score of the chosen MIDI tracks as MusicXML (or SVG when `svg`). diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..56e3d2f 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -237,61 +237,220 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> { soundcraft_midi::write_smf(&smf).map_err(|err| EngineError::Io(err.to_string())) } -/// Render the main mix. Returns the encoded file and (peak dBFS, integrated LUFS). -pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) -> Result<(Vec, (f32, f32, f32))> { - bounce_bytes_with(e, r, opts, normalize, false) -} - -/// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV -/// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`. -pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec, (f32, f32, f32))> { - bounce_source(e, r, opts, normalize, fold_stereo, None) -} - -/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named. -pub fn bounce_source( - e: &Engine, - r: Range, - opts: &EncodeOptions, - normalize: bool, - fold_stereo: bool, +/// A bounce of the main mix, or of one bus, over a range, written to its file block by block so no +/// bounce has to fit in memory. The file appears at its path only once complete; a bounce that +/// fails leaves any older file there alone. +pub struct Bounce { + session: Arc, + path: PathBuf, + part: PathBuf, + range: Range, bus: Option, -) -> Result<(Vec, (f32, f32, f32))> { - let s = e.session(); - if r.len() > s.sample_rate.samples(4.0 * 3600.0) { - return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); - } - let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus); - if normalize { - soundcraft_dsp::offline::normalize(&mut ch, -0.1, false); - } - let peak = soundcraft_dsp::offline::peak_db(&ch); - let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, ch.len()); - let frames = ch.first().map_or(0, Vec::len); - lm.process(&ch, frames); - let lufs = lm.integrated_lufs(); - let true_peak = lm.true_peak_db(); - let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch }; - let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?; - Ok((bytes, (peak, lufs, true_peak))) + fold: Option, + /// Measuring the peak to normalize to, the first of two passes. + measuring: bool, + render: soundcraft_mix::RangeRender, + block: Vec>, + gain: f32, + peak: f32, + meter: soundcraft_dsp::meter::LoudnessMeter, + enc: Option>, } -/// The main mix over `r` and its format (folded to stereo on request). -fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec>, ChannelFormat) { - render_bounce(s, r, fold_stereo, None) +/// A finished bounce: the size of its file and its levels. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Bounced { + pub bytes: u64, + pub peak_db: f32, + pub lufs: f32, + pub true_peak_db: f32, } -/// The main mix, or one bus, over `r`. -fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option) -> (Vec>, ChannelFormat) { - let (ch, fmt) = if let Some(id) = bus { - let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo); - (soundcraft_mix::render_bus(s, id, r, 1024), fmt) - } else { - let ch = soundcraft_mix::render_range(s, r, 1024); - let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() }; - (ch, fmt) +impl Bounce { + /// Starts a bounce of `s`'s main mix, or of `bus`, over `r` to `path`: every channel (SMPTE/WAV + /// order, with the WAV speaker mask of its format), or an ITU stereo fold-down when + /// `fold_stereo`, peak-normalized to -0.1 dBFS when `normalize` (which renders twice). + pub fn new( + s: Arc, + path: &Path, + r: Range, + opts: &EncodeOptions, + bus: Option, + normalize: bool, + fold_stereo: bool, + ) -> Result { + if r.len() > s.sample_rate.samples(MAX_BOUNCE_SECS) { + return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); + } + let (fmt, channels, fold) = bounce_layout(&s, bus, fold_stereo); + let name = path.file_name().map_or_else(|| "bounce".into(), |n| n.to_string_lossy().into_owned()); + let part = path.with_file_name(format!("{name}.part")); + let enc = (|| -> std::result::Result<_, soundcraft_audio_io::AudioError> { + let enc = soundcraft_audio_io::FileEncoder::new(Out::create(&part)?, opts, channels, s.sample_rate.hz(), Some(fmt.channel_mask()))?; + enc.check_room(u64::try_from(r.len()).unwrap_or(0))?; + Ok(enc) + })() + .map_err(|err| { + let _ = std::fs::remove_file(&part); + EngineError::Io(format!("{}: {err}", path.display())) + })?; + let render = soundcraft_mix::RangeRender::new(&s, bus, r, 1024); + let meter = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, channels); + Ok(Bounce { + session: s, + path: path.to_path_buf(), + part, + range: r, + bus, + fold, + measuring: normalize, + render, + block: Vec::new(), + gain: 1.0, + peak: 0.0, + meter, + enc: Some(enc), + }) + } + + /// Renders the whole bounce and completes its file. + pub fn run(mut self) -> Result { + loop { + match self.render.next_block(&self.session) { + Some((chunk, n)) => { + fill(&mut self.block, chunk, n, self.fold, self.gain); + self.peak = self.peak.max(soundcraft_dsp::offline::peak(&self.block)); + if !self.measuring { + self.meter.process(&self.block, n); + if let Some(enc) = &mut self.enc { + enc.write(&self.block, n).map_err(|err| EngineError::Io(format!("{}: {err}", self.path.display())))?; + } + } + } + None if self.measuring => { + self.gain = if self.peak > 0.0 { soundcraft_dsp::db_to_gain(-0.1) / self.peak } else { 1.0 }; + self.peak = 0.0; + self.measuring = false; + self.render = soundcraft_mix::RangeRender::new(&self.session, self.bus, self.range, 1024); + } + None => return self.finish(), + } + } + } + + fn finish(&mut self) -> Result { + let bytes = match self.enc.take() { + Some(enc) => enc.finish().map_err(|err| EngineError::Io(format!("{}: {err}", self.path.display())))?.complete(&self.part, &self.path)?, + None => 0, + }; + Ok(Bounced { + bytes, + peak_db: soundcraft_dsp::gain_to_db(self.peak), + lufs: self.meter.integrated_lufs(), + true_peak_db: self.meter.true_peak_db(), + }) + } +} + +impl Drop for Bounce { + /// Removes the part file unless the bounce completed (which renamed it). + fn drop(&mut self) { + drop(self.enc.take()); + let _ = std::fs::remove_file(&self.part); + } +} + +/// Where a bounce is written: its part file, or memory in the browser, which has no file system. +enum Out { + File(std::io::BufWriter), + Memory(std::io::Cursor>), +} + +impl Out { + fn create(part: &Path) -> std::io::Result { + if cfg!(target_arch = "wasm32") { + return Ok(Out::Memory(std::io::Cursor::new(Vec::new()))); + } + Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, std::fs::File::create(part)?))) + } + + /// Puts the finished file at `path` and returns its size: the part file is synced and renamed + /// over it; in the browser the bytes are delivered ([`write_file`]). + fn complete(self, part: &Path, path: &Path) -> Result { + match self { + Out::File(out) => { + let mut len = 0; + let synced = out.into_inner().map_err(std::io::IntoInnerError::into_error).and_then(|f| { + f.sync_all()?; + len = f.metadata()?.len(); + Ok(()) + }); + replace_with(part, path, synced).map(|()| len) + } + Out::Memory(m) => { + let bytes = m.into_inner(); + write_file(&path.to_string_lossy(), &bytes)?; + Ok(bytes.len() as u64) + } + } + } +} + +impl std::io::Write for Out { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Out::File(f) => f.write(buf), + Out::Memory(m) => { + // A long bounce may not fit in the browser's memory: fail instead of aborting. + let end = usize::try_from(m.position()).unwrap_or(usize::MAX).saturating_add(buf.len()); + let v = m.get_mut(); + v.try_reserve(end.saturating_sub(v.len())).map_err(|_| std::io::ErrorKind::OutOfMemory)?; + m.write(buf) + } + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Out::File(f) => f.flush(), + Out::Memory(_) => Ok(()), + } + } +} + +impl std::io::Seek for Out { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + match self { + Out::File(f) => f.seek(pos), + Out::Memory(m) => m.seek(pos), + } + } +} + +const MAX_BOUNCE_SECS: f64 = 4.0 * 3600.0; + +/// The format, channel count and fold-down source of a bounce of the main mix or `bus`: every +/// channel (a mono or stereo main mix as stereo), or an ITU stereo fold-down of a surround source +/// when `fold_stereo`. +fn bounce_layout(s: &Session, bus: Option, fold_stereo: bool) -> (ChannelFormat, usize, Option) { + let nch = soundcraft_mix::source_channels(s, bus); + let fmt = match bus { + Some(id) => s.bus(id).map_or(ChannelFormat::Stereo, |b| b.format), + None if nch <= 2 => ChannelFormat::Stereo, + None => s.main_format(), }; - if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) } + if fold_stereo && nch > 2 { (ChannelFormat::Stereo, 2, Some(fmt)) } else { (fmt, nch, None) } +} + +/// The first `n` frames of `chunk`, folded down from `fold` when set and scaled by `gain`. +fn fill(block: &mut Vec>, chunk: &[Vec], n: usize, fold: Option, gain: f32) { + let folded = fold.map(|from| soundcraft_mix::fold_down_stereo(from, chunk)); + let src = folded.as_deref().unwrap_or(chunk); + block.resize_with(src.len(), Vec::new); + for (d, c) in block.iter_mut().zip(src) { + d.clear(); + d.extend(c.iter().take(n).map(|v| v * gain)); + } } /// Bounce one file per audio/instrument track (each soloed, so its sends and auxes are included) @@ -303,6 +462,9 @@ pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Re /// [`bounce_stems`] in the main format, or folded down to stereo when `fold_stereo`. pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, fold_stereo: bool) -> Result> { let s = e.session(); + if r.len() > s.sample_rate.samples(MAX_BOUNCE_SECS) { + return Err(EngineError::BadParams("file.bounce_stems".into(), "bounces are limited to 4 hours".into())); + } std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?; let ext = match opts.format { FileFormat::Aiff => "aif", @@ -321,11 +483,8 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, for t in &mut solo.tracks { t.mixer.solo = t.id == id; } - let (ch, fmt) = render_main(&solo, r, fold_stereo); - let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch }; - let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?; let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name))); - std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?; + Bounce::new(Arc::new(solo), &path, r, opts, None, false, fold_stereo)?.run()?; out.push(path.to_string_lossy().into_owned()); } Ok(out) @@ -608,7 +767,12 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess /// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the /// old or the new contents, never a mix. pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> { - if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) { + replace_with(tmp, file, write_synced(tmp, bytes)) +} + +/// Renames `tmp` over `file` once it is `written`, or removes it when writing or renaming failed. +fn replace_with(tmp: &Path, file: &Path, written: std::io::Result<()>) -> Result<()> { + if let Err(err) = written.and_then(|()| std::fs::rename(tmp, file)) { let _ = std::fs::remove_file(tmp); return Err(EngineError::Io(format!("{}: {err}", file.display()))); } diff --git a/crates/engine/tests/bounce.rs b/crates/engine/tests/bounce.rs new file mode 100644 index 0000000..9f0b814 --- /dev/null +++ b/crates/engine/tests/bounce.rs @@ -0,0 +1,63 @@ +//! Bounces stream the main mix or a bus to their file, exactly as rendered in memory. + +use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat}; +use soundcraft_engine::io::{Bounce, bounce_stems}; +use soundcraft_time::Range; +use std::path::PathBuf; + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn bounces_write_exactly_the_mix_and_report_its_levels() { + let e = soundcraft_engine::demo::demo_engine(); + let s = e.session(); + let dir = scratch("bounce-exact"); + let path = dir.join("Mix.wav"); + let r = Range::new(s.sample_rate.samples(10.0) + 7, s.sample_rate.samples(13.0)); + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None }; + let verb = s.bus_by_name("Verb").unwrap().id; + for bus in [None, Some(verb)] { + let mut mix = match bus { + Some(b) => soundcraft_mix::render_bus(s, b, r, 1024), + None => soundcraft_mix::render_range(s, r, 1024), + }; + assert!(mix.iter().flatten().any(|v| v.abs() > 1e-3), "{bus:?}"); + for normalize in [false, true] { + let done = Bounce::new(e.session_arc(), &path, r, &opts, bus, normalize, false).unwrap().run().unwrap(); + if normalize { + soundcraft_dsp::offline::normalize(&mut mix, -0.1, false); + } + let bytes = std::fs::read(&path).unwrap(); + let (_, file) = soundcraft_audio_io::decode(&bytes, None).unwrap(); + assert_eq!(file.channels, mix, "{bus:?} normalize: {normalize}"); + let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, mix.len()); + lm.process(&mix, mix[0].len()); + let levels = (soundcraft_dsp::offline::peak_db(&mix), lm.integrated_lufs(), lm.true_peak_db()); + assert_eq!((done.bytes, (done.peak_db, done.lufs, done.true_peak_db)), (bytes.len() as u64, levels)); + } + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() { + let e = soundcraft_engine::demo::demo_engine(); + let s = e.session(); + let dir = scratch("bounce-too-big"); + let path = dir.join("Mix.aif"); + std::fs::write(&path, b"old").unwrap(); + let opts = EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, dither: false, bwf: None }; + let err = Bounce::new(e.session_arc(), &path, Range::new(0, s.sample_rate.samples(3.9 * 3600.0)), &opts, None, false, false).err().unwrap(); + assert!(err.to_string().contains("4 GiB"), "{err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let err = bounce_stems(&e, &dir.to_string_lossy(), Range::new(0, s.sample_rate.samples(4.1 * 3600.0)), &opts).unwrap_err(); + assert!(err.to_string().contains("4 hours"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..20d9f8a 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1734,88 +1734,103 @@ fn apply_matrix(src: &[Vec], frames: usize, dst: &mut [Vec], m: &Matri } } -/// Ceiling on one offline render (samples summed over channels): 2^29 f32 = 2 GiB, about 93 -/// minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory. +/// Ceiling on one in-memory offline render (samples summed over channels): 2^29 f32 = 2 GiB, about +/// 93 minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory; +/// [`RangeRender`] streams any length. pub const MAX_RENDER_SAMPLES: usize = 1 << 29; /// Offline render of the main mix over `range` (planar, [`main_channels`] channels in SMPTE/WAV /// order). The mix's delay-compensation latency is removed, so the result lines up with the /// timeline. pub fn render_range(s: &Session, range: Range, block: usize) -> Vec> { - let nch = main_channels(s); - let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch.max(1)); - // Probe the latency with one block, then render `len + latency` and drop the head. - let latency = { - let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = probe.max_block(); - let mut tmp = vec![vec![0.0f32; b]; nch]; - probe.render(s, range.start, b.min(len.max(1)), &mut tmp); - probe.latency - }; - let total = len.saturating_add(latency); - let mut out = vec![vec![0.0f32; total]; nch]; - let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = eng.max_block(); - let mut tmp = vec![vec![0.0f32; b]; nch]; - let mut done = 0usize; - while done < total { - let n = (total - done).min(b); - eng.render(s, range.start + done as i64, n, &mut tmp); - for (o, t) in out.iter_mut().zip(tmp.iter()) { - if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) { - d.copy_from_slice(sr); - } - } - done += n; - } - if latency > 0 { - for c in &mut out { - c.drain(..latency.min(c.len())); - } - } - out + render_in_memory(s, None, range, block) } /// Offline render of one bus over `range` (planar, the bus format's channel count). /// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline. /// An unknown bus is silent. pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec> { - let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0); - if nch == 0 { - return Vec::new(); - } - let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch); - let latency = { - let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = probe.max_block(); - probe.render(s, range.start, b.min(len.max(1)), &mut []); - probe.track_latency - }; - let total = len.saturating_add(latency); - let mut out = vec![vec![0.0f32; total]; nch]; - let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = eng.max_block(); - let mut tmp = vec![vec![0.0f32; b]; nch]; - let mut done = 0usize; - while done < total { - let n = (total - done).min(b); - eng.render(s, range.start + done as i64, n, &mut []); - eng.copy_bus(bus, n, &mut tmp); - for (o, t) in out.iter_mut().zip(tmp.iter()) { - if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) { - d.copy_from_slice(sr); - } - } - done += n; - } - if latency > 0 { - for c in &mut out { - c.drain(..latency.min(c.len())); + render_in_memory(s, Some(bus), range, block) +} + +fn render_in_memory(s: &Session, bus: Option, range: Range, block: usize) -> Vec> { + let nch = source_channels(s, bus); + let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch.max(1)); + let mut out: Vec> = (0..nch).map(|_| Vec::with_capacity(len)).collect(); + let mut render = RangeRender::new(s, bus, Range::new(range.start, range.start + len as i64), block); + while let Some((chunk, n)) = render.next_block(s) { + for (o, c) in out.iter_mut().zip(chunk) { + o.extend_from_slice(c.get(..n).unwrap_or_default()); } } out } +/// Channels of the main mix, or of `bus` (none for an unknown bus). +pub fn source_channels(s: &Session, bus: Option) -> usize { + match bus { + Some(id) => s.bus(id).map_or(0, |b| b.format.channels()), + None => main_channels(s), + } +} + +/// [`render_range`] or [`render_bus`] of any length, a block at a time. +pub struct RangeRender { + eng: MixEngine, + bus: Option, + tmp: Vec>, + start: Samples, + done: usize, + total: usize, + latency: usize, +} + +impl RangeRender { + /// Renders the main mix over `range`, or `bus` when one is named. + pub fn new(s: &Session, bus: Option, range: Range, block: usize) -> Self { + let nch = source_channels(s, bus); + let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let tmp = vec![vec![0.0f32; eng.max_block()]; nch]; + let len = if nch == 0 { 0 } else { usize::try_from(range.len().max(0)).unwrap_or(0) }; + RangeRender { eng, bus, tmp, start: range.start, done: 0, total: len, latency: 0 } + } + + fn render(&mut self, s: &Session, pos: Samples, n: usize) { + match self.bus { + Some(id) => { + self.eng.render(s, pos, n, &mut []); + self.eng.copy_bus(id, n, &mut self.tmp); + } + None => self.eng.render(s, pos, n, &mut self.tmp), + } + } + + /// The next stretch of the mix of `s` (the session given to [`RangeRender::new`]) as + /// `(channels, n)`: the first `n` samples of each channel. `None` once the range is done. + pub fn next_block(&mut self, s: &Session) -> Option<(&[Vec], usize)> { + while self.done < self.total { + let n = (self.total - self.done).min(self.eng.max_block()); + self.render(s, self.start + self.done as i64, n); + if self.done == 0 { + // The first block tells the mix's latency: render that much more and drop the head. + self.latency = if self.bus.is_some() { self.eng.track_latency } else { self.eng.latency }; + self.total = self.total.saturating_add(self.latency); + } + let skip = self.latency.saturating_sub(self.done).min(n); + self.done += n; + if skip < n { + if skip > 0 { + for c in &mut self.tmp { + c.copy_within(skip..n, 0); + } + } + return Some((&self.tmp, n - skip)); + } + } + None + } +} + /// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped; /// Ambisonics play their W channel). Mono/stereo input is returned unchanged. pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec]) -> Vec> { @@ -2334,6 +2349,37 @@ mod tests { assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0])); } + #[test] + fn offline_renders_line_up_with_the_timeline_in_any_block_size() { + let mut s = Session::default(); + let mut imp = vec![0.0f32; 48_000]; + imp[1000] = 1.0; + s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![imp] }))); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let bus = s.add_bus("Stem", ChannelFormat::Stereo); + let cid = s.new_clip_id(); + let tr = s.track_mut(t).unwrap(); + tr.playlist_mut().unwrap().clips.push(Clip::audio(cid, "i", SourceId(900), 0, 0, 48_000)); + tr.mixer.inserts[0] = Some(Insert::new("maximizer")); + let mut send = soundcraft_model::SendSlot::new(Route::Bus(bus)); + send.level_db = 0.0; + tr.mixer.sends[0] = Some(send); + assert!(soundcraft_dsp::create("maximizer").unwrap().latency() > 16); + for source in [None, Some(bus)] { + for block in [16, 100, 512] { + let mut got = Vec::new(); + let mut render = RangeRender::new(&s, source, Range::new(900, 2900), block); + while let Some((chunk, n)) = render.next_block(&s) { + assert!(n <= block); + got.extend_from_slice(&chunk[0][..n]); + } + assert_eq!(got.len(), 2000, "{source:?} block {block}"); + let peak = got.iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).map(|(i, _)| i); + assert_eq!(peak, Some(100), "{source:?} block {block}"); + } + } + } + #[test] fn input_monitoring_replaces_clips() { let (mut s, t) = session_with_dc(0.5, 4800); diff --git a/docs/architecture.md b/docs/architecture.md index a041434..5bb0b21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (crate diagram brought up to date: video, vst3-host, au-host; device I/O limits) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (bounces stream to disk) · **Target:** Avid Pro Tools Ultimate 2026.4.1 SoundCraft is a Cargo workspace of small crates with enforced layering (`cargo xtask layers`). Nothing below the UI knows about egui, so the interface can be replaced. @@ -48,8 +48,9 @@ report. fades, clip effects) → trim → inserts → pre-fader sends → fader/mute (automation, VCA, trim automation) → post-fader sends → pan → busses → aux inputs → master faders. Independent strips process in parallel; plugin delay compensation aligns every path. The same engine renders -bounces offline (`render_range`) and realtime playback (`soundcraft_playback::Player`, which owns -it on the audio thread and receives new session snapshots through a channel). +bounces offline (`RangeRender`, streamed to the file block by block) and realtime playback +(`soundcraft_playback::Player`, which owns it on the audio thread and receives new session +snapshots through a channel). Device I/O today is the system's *default* output and *default* input device, opened as two separate cpal streams; the mix renders 512-sample blocks and resamples to the device rate when