From 5755795663ec034a94a4a81e2ff4605243c89954 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:42:25 +0100 Subject: [PATCH 1/4] Stream bounces to disk instead of rendering them in memory A bounce rendered the whole mix (or bus) into memory and then encoded a second copy, and the render silently stopped at 2^29 samples: about 93 minutes of stereo at 48 kHz, 31 of 5.1, 15 of 7.1.4. Longer bounces came out cut short while file.bounce_mix still reported the requested length. - audio-io: FileEncoder writes WAV/BWF (RF64 above 4 GiB), AIFF and FLAC block by block into any Write + Seek, byte for byte what encode writes for the whole buffer. check_room fails up front when an AIFF (4 GiB) or a FLAC (2^36 frames) can't hold the bounce. AIFF and FLAC encode run on the same writers; their output is unchanged. - mix: RangeRender renders the main mix or one bus over a range of any length, a block at a time; render_range and render_bus (in memory, still capped) are built on it. - engine: io::Bounce streams the main mix or a bus, or its stereo fold-down, into ".part" next to the target, syncs it and renames it into place, so a failed bounce leaves no partial file and keeps the old one. In the browser it encodes into memory (failing instead of aborting when memory runs out) and delivers the file as before. Normalize renders twice (peak, then the scaled write); levels are measured as blocks are written. Stems stream the same way and share the 4-hour limit. A 100-minute bounce of the demo session now writes all 288,000,000 frames with 83 MB peak memory. --- crates/audio-io/src/aiff.rs | 107 +++++++++---- crates/audio-io/src/flac.rs | 154 +++++++++++++----- crates/audio-io/src/lib.rs | 60 +++++++ crates/audio-io/src/pcm.rs | 57 ++++++- crates/audio-io/src/wav.rs | 104 ++++++++---- crates/audio-io/tests/stream.rs | 64 ++++++++ crates/dsp/src/offline.rs | 3 +- crates/engine/src/cmd/file.rs | 14 +- crates/engine/src/io.rs | 272 +++++++++++++++++++++++++------- crates/engine/tests/bounce.rs | 63 ++++++++ crates/mix/src/lib.rs | 176 +++++++++++++-------- docs/architecture.md | 7 +- 12 files changed, 848 insertions(+), 233 deletions(-) create mode 100644 crates/audio-io/tests/stream.rs create mode 100644 crates/engine/tests/bounce.rs 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 From 52e19a871a357ee29dd8d65476f5705deb84b149 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:01:14 +0100 Subject: [PATCH 2/4] Keep the app responsive while bouncing Bounce Mix rendered the whole file inside one UI frame: the window stopped drawing and nothing could cancel the bounce until the file was written, which takes minutes for long or plugin-heavy sessions. - engine: io::Bounce renders a slice at a time (step) and reports its progress; dropping an unfinished one cancels it, removing the partial file and keeping any older one. cmd::bounce_job builds it from file.bounce_mix's params; the command and stems still run it to completion. - ui: the Bounce dialog starts the job and logic() renders it for 30 ms per call (32 blocks in the browser, where Instant panics), requesting another call each time, so it carries on while the window is hidden (eframe then runs logic alone). A modal shows the file, a progress bar and Cancel (or Esc); the status line reports the result, and the Task Manager lists the running bounce. - It stays on the UI thread because hosted plugins must be created there. --- crates/engine/src/cmd/file.rs | 25 +++++---- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/io.rs | 48 +++++++++++++++-- crates/engine/tests/bounce.rs | 26 ++++++++- crates/ui-egui/src/bounce.rs | 75 ++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 10 ++-- crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/windows.rs | 14 +++-- crates/ui-egui/tests/bounce.rs | 97 ++++++++++++++++++++++++++++++++++ 9 files changed, 279 insertions(+), 23 deletions(-) create mode 100644 crates/ui-egui/src/bounce.rs create mode 100644 crates/ui-egui/tests/bounce.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 6a6f5f7..72a2480 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -275,6 +275,21 @@ fn fold_down_param(p: &Value, id: &str) -> Result { } fn bounce(e: &mut Engine, p: &Value) -> Result { + let job = bounce_job(e, p)?; + let (path, seconds) = (job.path().to_string_lossy().into_owned(), e.session().sample_rate.seconds(job.range().len())); + let done = job.run()?; + Ok(json!({ + "path": path, + "bytes": done.bytes, + "seconds": seconds, + "peak_db": done.peak_db, + "lufs": done.lufs, + "true_peak_db": done.true_peak_db, + })) +} + +/// The bounce `file.bounce_mix` runs for `p`, not rendered yet. +pub fn bounce_job(e: &Engine, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string(); let r = range_param(e, "file.bounce_mix", p)?; let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; @@ -292,15 +307,7 @@ 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 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, - })) + crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold) } /// Write the score of the chosen MIDI tracks as MusicXML (or SVG when `svg`). diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d0a453c..5137fc0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -36,6 +36,8 @@ mod view; mod view_more; mod vst3; +pub use file::bounce_job; + pub type Run = fn(&mut Engine, &Value) -> Result; /// `Err(reason)` = disabled. pub type Enabled = fn(&Engine) -> std::result::Result<(), String>; diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 56e3d2f..f11c0ce 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -238,8 +238,9 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> { } /// 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. +/// bounce has to fit in memory, and rendered a slice at a time ([`Bounce::step`]) so a UI can show +/// progress. The file appears at its path only once complete; a bounce that fails, or is dropped +/// unfinished, leaves any older file there alone. pub struct Bounce { session: Arc, path: PathBuf, @@ -247,10 +248,12 @@ pub struct Bounce { range: Range, bus: Option, fold: Option, + normalize: bool, /// Measuring the peak to normalize to, the first of two passes. measuring: bool, render: soundcraft_mix::RangeRender, block: Vec>, + done: usize, gain: f32, peak: f32, meter: soundcraft_dsp::meter::LoudnessMeter, @@ -303,9 +306,11 @@ impl Bounce { range: r, bus, fold, + normalize, measuring: normalize, render, block: Vec::new(), + done: 0, gain: 1.0, peak: 0.0, meter, @@ -313,12 +318,32 @@ impl Bounce { }) } - /// Renders the whole bounce and completes its file. - pub fn run(mut self) -> Result { + pub fn path(&self) -> &Path { + &self.path + } + + pub fn range(&self) -> Range { + self.range + } + + /// How far along the bounce is, from 0 to 1. + pub fn progress(&self) -> f32 { + let pass = (self.done as f64 / self.range.len().max(1) as f64).min(1.0) as f32; + match (self.normalize, self.measuring) { + (false, _) => pass, + (true, true) => pass / 2.0, + (true, false) => 0.5 + pass / 2.0, + } + } + + /// Renders blocks until `more` returns false (at least one per call). Returns the finished + /// bounce once its file is complete. + pub fn step(&mut self, mut more: impl FnMut() -> bool) -> Result> { loop { match self.render.next_block(&self.session) { Some((chunk, n)) => { fill(&mut self.block, chunk, n, self.fold, self.gain); + self.done += n; self.peak = self.peak.max(soundcraft_dsp::offline::peak(&self.block)); if !self.measuring { self.meter.process(&self.block, n); @@ -330,10 +355,23 @@ impl Bounce { 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.done = 0; self.measuring = false; self.render = soundcraft_mix::RangeRender::new(&self.session, self.bus, self.range, 1024); } - None => return self.finish(), + None => return self.finish().map(Some), + } + if !more() { + return Ok(None); + } + } + } + + /// Renders the whole bounce (see [`Bounce::step`]). + pub fn run(mut self) -> Result { + loop { + if let Some(done) = self.step(|| true)? { + return Ok(done); } } } diff --git a/crates/engine/tests/bounce.rs b/crates/engine/tests/bounce.rs index 9f0b814..ea4cf12 100644 --- a/crates/engine/tests/bounce.rs +++ b/crates/engine/tests/bounce.rs @@ -28,7 +28,16 @@ fn bounces_write_exactly_the_mix_and_report_its_levels() { }; 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(); + let mut bounce = Bounce::new(e.session_arc(), &path, r, &opts, bus, normalize, false).unwrap(); + let mut progress = vec![bounce.progress()]; + let done = loop { + if let Some(done) = bounce.step(|| false).unwrap() { + break done; + } + progress.push(bounce.progress()); + }; + let last = progress[progress.len() - 1]; + assert!(progress.len() > 100 && progress.is_sorted() && progress[0] < 0.01 && last > 0.99 && last <= 1.0, "{progress:?}"); if normalize { soundcraft_dsp::offline::normalize(&mut mix, -0.1, false); } @@ -61,3 +70,18 @@ fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() { assert!(err.to_string().contains("4 hours"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn dropping_an_unfinished_bounce_cancels_it_and_keeps_the_old_file() { + let e = soundcraft_engine::demo::demo_engine(); + let dir = scratch("bounce-cancel"); + let path = dir.join("Mix.wav"); + std::fs::write(&path, b"old").unwrap(); + let mut bounce = Bounce::new(e.session_arc(), &path, Range::new(0, 48_000), &EncodeOptions::default(), None, false, false).unwrap(); + assert_eq!(bounce.step(|| false).unwrap(), None); + assert!(dir.join("Mix.wav.part").exists()); + drop(bounce); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/bounce.rs b/crates/ui-egui/src/bounce.rs new file mode 100644 index 0000000..7049cdf --- /dev/null +++ b/crates/ui-egui/src/bounce.rs @@ -0,0 +1,75 @@ +//! Bounce Mix renders a slice per frame behind a progress window, so the app keeps drawing and the +//! bounce can be cancelled. + +use crate::SoundApp; +use crate::theme::{Tokens, bold}; +use serde_json::Value; + +/// Starts the bounce `file.bounce_mix` runs for `params`; [`step`] renders it. +pub fn start(app: &mut SoundApp, params: &Value) { + if app.bounce.is_some() { + app.ui.status = "A bounce is already running".into(); + return; + } + // Third-party plugins keep state the session can't see: store it first. + app.capture_plugin_states(); + match soundcraft_engine::cmd::bounce_job(&app.engine, params) { + Ok(job) => app.bounce = Some(job), + Err(e) => app.ui.status = e.to_string(), + } +} + +/// Renders the running bounce for a moment. Called from `logic`, which eframe keeps calling while +/// the window is hidden as long as a repaint is requested, so a bounce doesn't pause then. +pub fn step(app: &mut SoundApp, ctx: &egui::Context) { + let Some(job) = &mut app.bounce else { return }; + app.ui.status = match job.step(slice()) { + Ok(None) => { + ctx.request_repaint(); + return; + } + Ok(Some(done)) => { + format!("Bounced {} (peak {:.1} dBFS, {:.1} LUFS)", job.path().file_name().unwrap_or_default().to_string_lossy(), done.peak_db, done.lufs) + } + Err(e) => e.to_string(), + }; + app.bounce = None; +} + +/// Whether to render another block in this call: for 30 ms. +#[cfg(not(target_arch = "wasm32"))] +fn slice() -> impl FnMut() -> bool { + let until = std::time::Instant::now() + std::time::Duration::from_millis(30); + move || std::time::Instant::now() < until +} + +/// The browser has no `Instant` (it panics there), so a call renders 32 blocks. +#[cfg(target_arch = "wasm32")] +fn slice() -> impl FnMut() -> bool { + let mut blocks = 32; + move || { + blocks -= 1; + blocks > 0 + } +} + +/// Shows how far the running bounce got, with Cancel (or Esc). +pub fn show(app: &mut SoundApp, ctx: &egui::Context) { + let Some(job) = &app.bounce else { return }; + let mut cancel = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + egui::Modal::new(egui::Id::new("bounce_progress")).show(ctx, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); + ui.set_width(320.0); + let name = job.path().file_name().unwrap_or_default().to_string_lossy(); + ui.label(egui::RichText::new(format!("Bouncing {name}")).font(bold(13.0))); + ui.add(egui::ProgressBar::new(job.progress())); + ui.horizontal(|ui| { + cancel |= ui.button("Cancel").clicked(); + ui.label(egui::RichText::new(format!("{:.0}%", job.progress() * 100.0)).color(Tokens::current().text_dim)); + }); + }); + if cancel { + app.bounce = None; + app.ui.status = "Bounce cancelled".into(); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f1a34b..b8478bc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -250,6 +250,7 @@ fn default_dir() -> String { /// Show the open dialog, if any. pub fn show(app: &mut SoundApp, ctx: &egui::Context) { audiosuite_window(app, ctx); + crate::bounce::show(app, ctx); let Some(mut d) = app.dialogs.open.take() else { return }; let mut keep = true; let mut action: Option<(String, Value)> = None; @@ -361,10 +362,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ); if buttons(ui, "Bounce", enter) { let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() }); - action = Some(( - "file.bounce_mix".into(), - json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}), - )); + crate::bounce::start( + app, + &json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}), + ); + keep = false; } } Dialog::RenameTrack { id, name } => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..67dbe7a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4,6 +4,7 @@ //! executing commands. It can be replaced without touching anything below it. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod bounce; pub mod control; pub mod credits; pub mod dialogs; @@ -270,6 +271,8 @@ pub struct SoundApp { last_meter_poll: f64, /// Movies for the Video window and Video track (decoded in the background). pub video: video_track::VideoPool, + /// The Bounce Mix in progress, rendered a slice per frame. + pub bounce: Option, } /// Whether another automation-write pass is due: the transport moved at least @@ -326,6 +329,7 @@ impl SoundApp { last_frame: None, last_meter_poll: f64::NEG_INFINITY, video: video_track::VideoPool::default(), + bounce: None, } } @@ -808,6 +812,7 @@ impl SoundApp { self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1; control::drain(self, ctx); self.apply_editor_edits(); + bounce::step(self, ctx); if !ctx.input(|i| i.pointer.any_down()) { if matches!(self.gesture, Some(Gesture::Fader { .. })) { self.gesture = None; diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 12b3540..f96d79d 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -178,11 +178,17 @@ fn system_usage(app: &mut SoundApp, ctx: &egui::Context) { fn task_manager(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_task_manager; win(ctx, &mut open, "Task Manager", vec2(320.0, 120.0), |ui| { - ui.label("No background tasks are running."); + match &app.bounce { + Some(b) => { + ui.label(format!("Bouncing {} ({:.0}%)", b.path().display(), b.progress() * 100.0)); + ui.add(egui::ProgressBar::new(b.progress())); + } + None => { + ui.label("No background tasks are running."); + } + } ui.label( - egui::RichText::new("Renders, bounces and AudioSuite processes run to completion before returning.") - .small() - .color(Tokens::current().text_dim), + egui::RichText::new("Renders and AudioSuite processes run to completion before returning.").small().color(Tokens::current().text_dim), ); }); app.ui.show_task_manager = open; diff --git a/crates/ui-egui/tests/bounce.rs b/crates/ui-egui/tests/bounce.rs new file mode 100644 index 0000000..32f8f69 --- /dev/null +++ b/crates/ui-egui/tests/bounce.rs @@ -0,0 +1,97 @@ +//! Bounce Mix renders across frames behind a progress window instead of freezing the app. +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use soundcraft_ui_egui::dialogs::Dialog; +use soundcraft_ui_egui::{Services, SoundApp}; +use std::path::{Path, PathBuf}; + +fn harness() -> Harness<'static, SoundApp> { + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(1_000).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h +} + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-ui-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn bounce_from_dialog(h: &mut Harness<'_, SoundApp>, file: &Path) { + let app = h.state_mut(); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + if let Some(Dialog::Bounce { path, .. }) = &mut app.dialogs.open { + *path = file.to_string_lossy().into_owned(); + } + h.run_steps(2); + h.get_by_label("Bounce").click(); + h.run_steps(3); + assert!(h.state().bounce.is_some(), "{}", h.state().ui.status); + assert!(h.query_by_label("Bounce Mix").is_none()); +} + +#[test] +fn a_bounce_renders_across_frames_with_progress_until_its_file_is_written() { + let dir = scratch("bounce-frames"); + let mut h = harness(); + bounce_from_dialog(&mut h, &dir.join("Mix.wav")); + let frames = h.state().bounce.as_ref().map_or(0, |b| b.range().len()); + let mut progress = Vec::new(); + for _ in 0..4 { + progress.push(h.state().bounce.as_ref().map_or(1.0, |b| b.progress())); + h.get_by_label("Cancel"); + h.step(); + } + assert!(progress.is_sorted() && progress[0] < progress[3] && progress[3] < 1.0, "{progress:?}"); + h.run(); + assert!(h.state().bounce.is_none()); + assert!(h.state().ui.status.starts_with("Bounced Mix.wav (peak"), "{}", h.state().ui.status); + let (_, file) = soundcraft_audio_io::decode(&std::fs::read(dir.join("Mix.wav")).unwrap(), None).unwrap(); + assert_eq!(file.frames() as i64, frames); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_bounce_keeps_going_while_the_window_is_hidden() { + let dir = scratch("bounce-hidden"); + let mut h = harness(); + bounce_from_dialog(&mut h, &dir.join("Hidden.wav")); + let ctx = h.ctx.clone(); + let mut calls = 0; + while h.state().bounce.is_some() { + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| h.state_mut().logic(ctx)); + assert!(h.state().bounce.is_none() || ctx.has_requested_repaint()); + calls += 1; + assert!(calls < 10_000); + } + assert!(calls > 3, "{calls}"); + assert!(h.state().ui.status.starts_with("Bounced Hidden.wav"), "{}", h.state().ui.status); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn cancel_or_escape_stops_a_bounce_and_leaves_no_file() { + let dir = scratch("bounce-cancel"); + let mut h = harness(); + bounce_from_dialog(&mut h, &dir.join("Clicked.wav")); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().bounce.is_none()); + assert_eq!(h.state().ui.status, "Bounce cancelled"); + bounce_from_dialog(&mut h, &dir.join("Escaped.wav")); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().bounce.is_none()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); +} From d19d44cd1bc4f90c7130007af1143d4873763e84 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:03:05 +0100 Subject: [PATCH 3/4] Cap bogus plugin latency so offline renders can't stall CLAP and VST3 plugins report their latency unchecked (up to u32::MAX samples), and an offline render skips that many samples before its first block, inside one call. A plugin reporting garbage made a bounce render about 25 hours of audio before producing anything, with the UI stuck in that call: no progress, no Cancel. Freeze and Commit stalled the same way. - A strip reports at most 2^20 samples of latency (about 22 s at 48 kHz), the most delay compensation could align anyway (MAX_LATENCY, shared with the delay lines). That holds for the latency an aux's inserts report before the aux is processed, too. - RangeRender::next_block renders one block per call, returning no frames while it skips the latency, so a bounce yields to the UI there too. --- crates/mix/src/lib.rs | 95 ++++++++++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 24 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 20d9f8a..6cf9e94 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -214,6 +214,10 @@ struct Strip { outbuf: Vec>, } +/// Most latency delay compensation handles: 2^20 samples, about 22 s at 48 kHz. A strip reports +/// at most this, whatever its plugins claim, so a bogus value can't stall offline renders. +const MAX_LATENCY: usize = 1 << 20; + /// A multichannel delay line (samples), resized only when the delay changes. #[derive(Default)] struct DelayLine { @@ -224,7 +228,7 @@ struct DelayLine { impl DelayLine { fn set(&mut self, len: usize, ch: usize) { - let len = len.min(1 << 20); + let len = len.min(MAX_LATENCY); if len != self.len || self.bufs.len() != ch { self.len = len; self.idx = 0; @@ -933,7 +937,7 @@ fn process_strip( } } strip.gr = gr; - strip.latency = latency; + strip.latency = latency.min(MAX_LATENCY); let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5); let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t); strip.muted = t.mixer.mute || auto_mute || soloed_out; @@ -1400,7 +1404,7 @@ fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) { apply_params(slot, ins, t, i, pos); latency = latency.saturating_add(slot.plugin.latency()); } - strip.latency = latency; + strip.latency = latency.min(MAX_LATENCY); } fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) { @@ -1805,29 +1809,28 @@ impl RangeRender { } } - /// 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. + /// Renders one more block of the mix of `s` (the session given to [`RangeRender::new`]) and + /// returns it as `(channels, n)`: the next `n` samples of the range lead each channel (none + /// while the mix's latency is still being skipped). `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)); + if self.done >= self.total { + return None; + } + 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 > 0 && skip < n { + for c in &mut self.tmp { + c.copy_within(skip..n, 0); } } - None + Some((&self.tmp, n - skip)) } } @@ -2364,22 +2367,66 @@ mod tests { 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); + let latency = soundcraft_dsp::create("maximizer").unwrap().latency(); + assert!(latency > 16); for source in [None, Some(bus)] { for block in [16, 100, 512] { let mut got = Vec::new(); + let mut skipped = 0; let mut render = RangeRender::new(&s, source, Range::new(900, 2900), block); while let Some((chunk, n)) = render.next_block(&s) { assert!(n <= block); + skipped += usize::from(n == 0); got.extend_from_slice(&chunk[0][..n]); } assert_eq!(got.len(), 2000, "{source:?} block {block}"); + assert_eq!(skipped, latency / block, "{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 a_plugins_bogus_latency_is_capped() { + struct Latent(Box); + impl Plugin for Latent { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.0.info() + } + fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { + self.0.prepare(sr, mb, ch); + } + fn reset(&mut self) { + self.0.reset(); + } + fn set_param(&mut self, id: &str, v: f32) -> bool { + self.0.set_param(id, v) + } + fn param(&self, id: &str) -> Option { + self.0.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + self.0.process(io, frames); + } + fn latency(&self) -> usize { + u32::MAX as usize + } + } + let (mut s, t) = session_with_dc(0.5, 48_000); + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Mono, None); + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + for track in [t, aux] { + s.track_mut(track).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.latent")); + let mut gain = soundcraft_dsp::create("gain").unwrap(); + gain.prepare(48_000.0, 512, 1); + eng.adopt(vec![PreparedInstance { track, slot: 0, id: "clap:test.latent".into(), ch: 1, plugin: Box::new(Latent(gain)) }]); + } + render_block(&mut eng, &s, 0); + assert_eq!((eng.track_latency, eng.latency), (MAX_LATENCY, 2 * MAX_LATENCY)); + } + #[test] fn input_monitoring_replaces_clips() { let (mut s, t) = session_with_dc(0.5, 4800); From 8f8884ec05252ee3b6b7283f1e7c3d5a1afa3a8e Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:34:40 +0100 Subject: [PATCH 4/4] Refuse to replace the session while bouncing A bounce keeps its own snapshot of the session, decoded audio included. The progress window blocks the mouse, but shortcuts, the macOS menu bar, dropped files and the control channel still reach SoundApp::run, so a second session could be opened beside the one bouncing; two large ones could exhaust memory, which aborts the app. New, Open, Close and Revert now fail with "Finish or cancel the bounce first" while a bounce runs, and so do sessions opened or dropped in the browser. --- apps/soundcraft-web/src/web.rs | 3 +++ crates/ui-egui/src/bounce.rs | 9 +++++++++ crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/tests/bounce.rs | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 02f1e13..8891074 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -150,6 +150,9 @@ impl WebShell { } fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) { + if matches!(purpose, Purpose::Session) && soundcraft_ui_egui::bounce::refuses_session_switch(&mut self.app) { + return; + } let e = &mut self.app.engine; let at = e.session().edit.selection.start.max(0); let result = match purpose { diff --git a/crates/ui-egui/src/bounce.rs b/crates/ui-egui/src/bounce.rs index 7049cdf..4368b4e 100644 --- a/crates/ui-egui/src/bounce.rs +++ b/crates/ui-egui/src/bounce.rs @@ -36,6 +36,15 @@ pub fn step(app: &mut SoundApp, ctx: &egui::Context) { app.bounce = None; } +/// Refuses to replace the session while a bounce holds its own copy: two large sessions could run +/// out of memory. True when refused; the status line says why. +pub fn refuses_session_switch(app: &mut SoundApp) -> bool { + if app.bounce.is_some() { + app.ui.status = "Finish or cancel the bounce first".into(); + } + app.bounce.is_some() +} + /// Whether to render another block in this call: for 30 ms. #[cfg(not(target_arch = "wasm32"))] fn slice() -> impl FnMut() -> bool { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 67dbe7a..35e0a10 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -340,6 +340,9 @@ impl SoundApp { /// Run a command; errors go to the status line. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Result { + if matches!(id, "session.new" | "session.open" | "session.close" | "session.revert") && bounce::refuses_session_switch(self) { + return Err(self.ui.status.clone()); + } if let Some(r) = menus::run_ui_command(self, id, ¶ms) { return r; } diff --git a/crates/ui-egui/tests/bounce.rs b/crates/ui-egui/tests/bounce.rs index 32f8f69..9745ae4 100644 --- a/crates/ui-egui/tests/bounce.rs +++ b/crates/ui-egui/tests/bounce.rs @@ -1,6 +1,7 @@ //! Bounce Mix renders across frames behind a progress window instead of freezing the app. use egui::vec2; use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; use soundcraft_ui_egui::dialogs::Dialog; use soundcraft_ui_egui::{Services, SoundApp}; use std::path::{Path, PathBuf}; @@ -79,6 +80,24 @@ fn a_bounce_keeps_going_while_the_window_is_hidden() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn the_session_cannot_be_replaced_while_it_bounces() { + let dir = scratch("bounce-replace"); + let mut h = harness(); + let tracks = h.state().engine.session().tracks.len(); + bounce_from_dialog(&mut h, &dir.join("Mix.wav")); + for id in ["session.new", "session.open", "session.close", "session.revert"] { + let err = h.state_mut().run(id, json!({"path": dir.join("Other.scraft").to_string_lossy()})).unwrap_err(); + assert!(err.contains("bounce"), "{id}: {err}"); + } + assert_eq!(h.state().engine.session().tracks.len(), tracks); + assert!(h.state().bounce.is_some()); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state_mut().run("session.close", json!({})).is_ok()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn cancel_or_escape_stops_a_bounce_and_leaves_no_file() { let dir = scratch("bounce-cancel");