This commit is contained in:
Brandon Thomas committed 2026-10-13 05:12:31 -04:00
commit edcdf770b2
19 files changed
+1199 -244

No files matched your search

+3
View File
@@ -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 {
+73 -34
View File
@@ -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<u8>, 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<Vec<u8>> {
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<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
depth: BitDepth,
quantizers: Vec<Quantizer>,
frames: u64,
bytes: Vec<u8>,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
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<u64> {
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<f32>], 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<W> {
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<Vec<u8>> {
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)
}
+115 -39
View File
@@ -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<Vec<u8>> {
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<u32> {
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<u8> {
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<i64> = 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<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
bps: u32,
quantizers: Vec<Quantizer>,
pending: Vec<Vec<i64>>,
frames: u64,
frame_no: u64,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
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<u64> {
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<f32>], 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<W> {
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<Vec<u8>> {
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
+60
View File
@@ -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<W: Write + Seek>(Writer<W>);
enum Writer<W> {
Wav(wav::Writer<W>),
Aiff(aiff::Writer<W>),
Flac(flac::Writer<W>),
}
impl<W: Write + Seek> FileEncoder<W> {
/// 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<u32>) -> Result<Self> {
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<f32>], 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<W> {
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<u32> {
wav::read_channel_mask(bytes)
+53 -4
View File
@@ -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<u8>,
block: &[Vec<f32>],
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(())
+76 -28
View File
@@ -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<u8>, 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<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
format: (u16, bool),
bwf: Option<BwfInfo>,
mask: Option<u32>,
force_rf64: bool,
quantizers: Vec<Quantizer>,
frames: u64,
bytes: Vec<u8>,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(
mut out: W,
channels: usize,
sample_rate: u32,
depth: BitDepth,
dither: bool,
bwf: Option<&BwfInfo>,
mask: Option<u32>,
force_rf64: bool,
) -> Result<Self> {
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<Vec<u8>> {
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<u64> {
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<f32>], 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<W> {
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.
+64
View File
@@ -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<u32>) -> Vec<u8> {
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<Vec<f32>> = 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());
}
}
+2 -1
View File
@@ -52,7 +52,8 @@ fn max_len(ch: &[Vec<f32>]) -> usize {
ch.iter().map(Vec::len).max().unwrap_or(0)
}
fn peak(ch: &[Vec<f32>]) -> f32 {
/// The largest finite sample magnitude in any channel.
pub fn peak(ch: &[Vec<f32>]) -> f32 {
ch.iter().flatten().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs()))
}
+16 -5
View File
@@ -281,6 +281,21 @@ fn fold_down_param(p: &Value, id: &str) -> Result<bool> {
}
fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
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<crate::io::Bounce> {
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<Value> {
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`).
+2
View File
@@ -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<Value>;
+259 -54
View File
@@ -282,61 +282,260 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
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<u8>, (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<u8>, (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<Session>,
path: PathBuf,
part: PathBuf,
range: Range,
bus: Option<BusId>,
) -> Result<(Vec<u8>, (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<ChannelFormat>,
normalize: bool,
/// Measuring the peak to normalize to, the first of two passes.
measuring: bool,
render: soundcraft_mix::RangeRender,
block: Vec<Vec<f32>>,
done: usize,
gain: f32,
peak: f32,
meter: soundcraft_dsp::meter::LoudnessMeter,
enc: Option<soundcraft_audio_io::FileEncoder<Out>>,
}
/// The main mix over `r` and its format (folded to stereo on request).
fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec<Vec<f32>>, 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<BusId>) -> (Vec<Vec<f32>>, 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<Session>,
path: &Path,
r: Range,
opts: &EncodeOptions,
bus: Option<BusId>,
normalize: bool,
fold_stereo: bool,
) -> Result<Bounce> {
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<Option<Bounced>> {
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<Bounced> {
loop {
if let Some(done) = self.step(|| true)? {
return Ok(done);
}
}
}
fn finish(&mut self) -> Result<Bounced> {
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<std::fs::File>),
Memory(std::io::Cursor<Vec<u8>>),
}
impl Out {
fn create(part: &Path) -> std::io::Result<Out> {
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<u64> {
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<usize> {
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<u64> {
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<BusId>, fold_stereo: bool) -> (ChannelFormat, usize, Option<ChannelFormat>) {
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<Vec<f32>>, chunk: &[Vec<f32>], n: usize, fold: Option<ChannelFormat>, 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<Vec<String>> {
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())));
}
+87
View File
@@ -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);
}
+161 -68
View File
@@ -218,6 +218,10 @@ struct Strip {
outbuf: Vec<Vec<f32>>,
}
/// 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<f32>], span: std::ops::Range<usize>, 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<Vec<f32>> {
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<Vec<f32>> {
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<BusId>, range: Range, block: usize) -> Vec<Vec<f32>> {
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<Vec<f32>> = (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<BusId>) -> 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<BusId>,
tmp: Vec<Vec<f32>>,
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<BusId>, 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<f32>], 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<f32>]) -> Vec<Vec<f32>> {
@@ -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<dyn Plugin>);
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<f32> {
self.0.param(id)
}
fn process(&mut self, io: &mut [Vec<f32>], 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);
+84
View File
@@ -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();
}
}
+6 -4
View File
@@ -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 } => {
+8
View File
@@ -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<soundcraft_engine::io::Bounce>,
/// 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<Value, String> {
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, &params) {
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;
+10 -4
View File
@@ -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;
+116
View File
@@ -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);
}
+4 -3
View File
@@ -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