diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 90477ccc..80de7e1d 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -151,6 +151,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/audio-io/src/aiff.rs b/crates/audio-io/src/aiff.rs index dfa76234..d8462c0e 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 88b333e0..8a8c0ce1 100644 --- a/crates/audio-io/src/flac.rs +++ b/crates/audio-io/src/flac.rs @@ -4,6 +4,7 @@ use crate::pcm::{Quantizer, be_u32, le_u32, 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()) } /// Refuse the FLAC stream at `at` if a comment or picture field claims more bytes than its diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 20f62133..1b2b43f5 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; @@ -23,6 +25,7 @@ mod symph; mod wav; pub use buffer::AudioBuffer; +use std::io::{Seek, Write}; /// Errors from probing, decoding and encoding. #[derive(Debug, thiserror::Error)] @@ -39,6 +42,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. @@ -288,6 +294,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 a998fecf..35c761aa 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; @@ -162,6 +163,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) } @@ -169,13 +213,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 d6ce7cd3..1aa847d4 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; @@ -306,6 +307,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. @@ -320,36 +389,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 00000000..61a28946 --- /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 5ea6b452..fcda93b6 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 2acf60be..87bba997 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -281,6 +281,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 }; @@ -298,11 +313,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 (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}), - ) + 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 da65d60b..2c50f303 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(crate) use edit::remember_selection; pub type Run = fn(&mut Engine, &Value) -> Result; diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index e423655a..13c483d4 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -282,61 +282,260 @@ 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, 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, + 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, + 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, + 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")); + crate::file_access::check_write(path).map_err(EngineError::Io)?; + crate::file_access::check_write(&part).map_err(EngineError::Io)?; + 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, + normalize, + measuring: normalize, + render, + block: Vec::new(), + done: 0, + gain: 1.0, + peak: 0.0, + meter, + enc: Some(enc), + }) + } + + 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); + 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.done = 0; + self.measuring = false; + self.render = soundcraft_mix::RangeRender::new(&self.session, self.bus, self.range, 1024); + } + 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); + } + } + } + + 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) @@ -348,6 +547,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())); + } make_dir(dir)?; let ext = match opts.format { FileFormat::Aiff => "aif", @@ -366,11 +568,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))); - write_path(&path, bytes)?; + Bounce::new(Arc::new(solo), &path, r, opts, None, false, fold_stereo)?.run()?; out.push(path.to_string_lossy().into_owned()); } Ok(out) @@ -712,7 +911,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> { crate::file_access::check_write(file).map_err(EngineError::Io)?; crate::file_access::check_write(tmp).map_err(EngineError::Io)?; - 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. +/// The caller has checked both paths ([`crate::file_access::check_write`]). +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 00000000..ea4cf127 --- /dev/null +++ b/crates/engine/tests/bounce.rs @@ -0,0 +1,87 @@ +//! 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 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); + } + 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); +} + +#[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/mix/src/lib.rs b/crates/mix/src/lib.rs index 57734712..1da863a9 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -218,6 +218,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 { @@ -228,7 +232,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; @@ -967,7 +971,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; @@ -1556,7 +1560,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) { @@ -1933,88 +1937,102 @@ fn apply_matrix_span(src: &[Vec], span: std::ops::Range, dst: &mut [ } } -/// 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), + } + } + + /// 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)> { + 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); + } + } + Some((&self.tmp, n - skip)) + } +} + /// 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> { @@ -2744,6 +2762,81 @@ 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); + 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); diff --git a/crates/ui-egui/src/bounce.rs b/crates/ui-egui/src/bounce.rs new file mode 100644 index 00000000..4368b4eb --- /dev/null +++ b/crates/ui-egui/src/bounce.rs @@ -0,0 +1,84 @@ +//! 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; +} + +/// 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 { + 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 6d274156..185149d9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -262,6 +262,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; @@ -386,10 +387,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 74132bdf..0778412c 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; @@ -295,6 +296,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, /// Test-only: owns the process-wide palette lock while this app exists, so frames painted /// and colour assertions in one test can never straddle another test's palette change. #[cfg(test)] @@ -362,6 +365,7 @@ impl SoundApp { last_frame: None, last_meter_poll: f64::NEG_INFINITY, video: video_track::VideoPool::default(), + bounce: None, #[cfg(test)] _palette_lock: theme::lock_test_palette(), } @@ -374,6 +378,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; } @@ -1056,6 +1063,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 cf79b9a6..751f64a4 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -180,11 +180,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 00000000..9745ae4e --- /dev/null +++ b/crates/ui-egui/tests/bounce.rs @@ -0,0 +1,116 @@ +//! 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}; + +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 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"); + 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); +} diff --git a/docs/architecture.md b/docs/architecture.md index a041434e..5bb0b21f 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