Stream bounces to disk instead of rendering them in memory
A bounce rendered the whole mix (or bus) into memory and then encoded a second copy, and the render silently stopped at 2^29 samples: about 93 minutes of stereo at 48 kHz, 31 of 5.1, 15 of 7.1.4. Longer bounces came out cut short while file.bounce_mix still reported the requested length. - audio-io: FileEncoder writes WAV/BWF (RF64 above 4 GiB), AIFF and FLAC block by block into any Write + Seek, byte for byte what encode writes for the whole buffer. check_room fails up front when an AIFF (4 GiB) or a FLAC (2^36 frames) can't hold the bounce. AIFF and FLAC encode run on the same writers; their output is unchanged. - mix: RangeRender renders the main mix or one bus over a range of any length, a block at a time; render_range and render_bus (in memory, still capped) are built on it. - engine: io::Bounce streams the main mix or a bus, or its stereo fold-down, into "<name>.part" next to the target, syncs it and renames it into place, so a failed bounce leaves no partial file and keeps the old one. In the browser it encodes into memory (failing instead of aborting when memory runs out) and delivers the file as before. Normalize renders twice (peak, then the scaled write); levels are measured as blocks are written. Stems stream the same way and share the 4-hour limit. A 100-minute bounce of the demo session now writes all 288,000,000 frames with 83 MB peak memory.
This commit is contained in:
parent
6cf896e76c
commit
5755795663
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use crate::pcm::{Quantizer, validate_buffer};
|
||||
use crate::{AudioBuffer, AudioError, BitDepth, Result};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
const BLOCK_SIZE: usize = 4096;
|
||||
const MAX_PARTITION_ORDER: u32 = 6;
|
||||
|
|
@ -269,31 +270,24 @@ fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
//! encoder), with optional TPDF dither.
|
||||
//! * [`wav_float_header`] heads a float WAV written while it grows (recording).
|
||||
//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing.
|
||||
//! * [`FileEncoder`] writes the same formats block by block into any `Write + Seek`, so a file
|
||||
//! of any length never has to fit in memory.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod aiff;
|
||||
|
|
@ -21,6 +23,7 @@ mod symph;
|
|||
mod wav;
|
||||
|
||||
pub use buffer::AudioBuffer;
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
/// Errors from probing, decoding and encoding.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -37,6 +40,9 @@ pub enum AudioError {
|
|||
/// Encoding failed (e.g. an empty buffer).
|
||||
#[error("audio encode failed: {0}")]
|
||||
Encode(String),
|
||||
/// Writing the output failed.
|
||||
#[error("audio write failed: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Result alias for this crate.
|
||||
|
|
@ -286,6 +292,60 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
|
|||
}
|
||||
}
|
||||
|
||||
/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing
|
||||
/// the same bytes [`encode_with_channel_mask`] would for the whole buffer.
|
||||
pub struct FileEncoder<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)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
//! Byte-level PCM helpers shared by the WAV, AIFF and FLAC code: bounded readers, sample
|
||||
//! decoding to `f32`, and quantization (with optional TPDF dither) for encoding.
|
||||
|
||||
use crate::{AudioError, Result};
|
||||
use crate::{AudioError, BitDepth, Result};
|
||||
use std::io::Write;
|
||||
|
||||
/// Maximum number of decoded `f32` samples (all channels) we will produce: 4 GiB of output.
|
||||
pub(crate) const MAX_DECODE_SAMPLES: u64 = (4u64 << 30) / 4;
|
||||
|
|
@ -156,6 +157,49 @@ impl Quantizer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Bits per sample at `depth`, and whether they are float.
|
||||
pub(crate) fn sample_format(depth: BitDepth) -> (u16, bool) {
|
||||
match depth {
|
||||
BitDepth::Int16 => (16, false),
|
||||
BitDepth::Int24 => (24, false),
|
||||
BitDepth::Int32 => (32, false),
|
||||
BitDepth::Float32 => (32, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaves `frames` frames of planar audio (missing samples are silence) as `bits`-bit
|
||||
/// samples and writes them to `out`, a few thousand frames at a time through `scratch`.
|
||||
pub(crate) fn write_interleaved(
|
||||
out: &mut impl Write,
|
||||
scratch: &mut Vec<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) }
|
||||
|
|
@ -163,13 +207,18 @@ pub(crate) fn clamp_float(x: f32) -> f32 {
|
|||
|
||||
/// Check that a buffer is encodable: at least one channel and a non-zero sample rate.
|
||||
pub(crate) fn validate_buffer(buf: &crate::AudioBuffer) -> Result<()> {
|
||||
if buf.channels.is_empty() {
|
||||
validate_layout(buf.channels.len(), buf.sample_rate)
|
||||
}
|
||||
|
||||
/// Check that a stream is encodable: at least one channel and a non-zero sample rate.
|
||||
pub(crate) fn validate_layout(channels: usize, sample_rate: u32) -> Result<()> {
|
||||
if channels == 0 {
|
||||
return Err(AudioError::Encode("buffer has no channels".into()));
|
||||
}
|
||||
if buf.channels.len() > usize::from(u16::MAX) {
|
||||
if channels > usize::from(u16::MAX) {
|
||||
return Err(AudioError::Encode("too many channels".into()));
|
||||
}
|
||||
if buf.sample_rate == 0 {
|
||||
if sample_rate == 0 {
|
||||
return Err(AudioError::Encode("sample rate is 0".into()));
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
//! Native WAV / BWF / RF64 reader and writer (RIFF chunk layout per the Microsoft multimedia
|
||||
//! specification, `bext` per EBU Tech 3285, `ds64` per EBU Tech 3306).
|
||||
|
||||
use crate::pcm::{PcmKind, Quantizer, clamp_float, deinterleave, le_u16, le_u32, le_u64, tag, validate_buffer};
|
||||
use crate::pcm::{PcmKind, Quantizer, deinterleave, le_u16, le_u32, le_u64, sample_format, tag, validate_buffer, validate_layout, write_interleaved};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, BwfInfo, FileFormat, Result, SampleFormat};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
const WAVE_FORMAT_PCM: u16 = 1;
|
||||
const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
|
||||
|
|
@ -255,6 +256,74 @@ fn push_chunk(out: &mut Vec<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.
|
||||
|
|
@ -269,36 +338,15 @@ pub(crate) fn encode(
|
|||
validate_buffer(buf)?;
|
||||
let channels = buf.channels.len();
|
||||
let frames = buf.frames();
|
||||
let (bits, is_float): (u16, bool) = match depth {
|
||||
BitDepth::Int16 => (16, false),
|
||||
BitDepth::Int24 => (24, false),
|
||||
BitDepth::Int32 => (32, false),
|
||||
BitDepth::Float32 => (32, true),
|
||||
};
|
||||
let bytes = usize::from(bits / 8);
|
||||
let head = header(channels, buf.sample_rate, (bits, is_float), bwf, mask, frames as u64, force_rf64)?;
|
||||
let data_len = (bytes * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
|
||||
let format = sample_format(depth);
|
||||
let head = header(channels, buf.sample_rate, format, bwf, mask, frames as u64, force_rf64)?;
|
||||
let data_len = (usize::from(format.0 / 8) * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
|
||||
.map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?;
|
||||
out.extend_from_slice(&head);
|
||||
let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels);
|
||||
for f in 0..frames {
|
||||
for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) {
|
||||
let s = ch.get(f).copied().unwrap_or(0.0);
|
||||
if is_float {
|
||||
out.extend_from_slice(&clamp_float(s).to_le_bytes());
|
||||
} else {
|
||||
let v = q.quantize(s).to_le_bytes();
|
||||
out.extend_from_slice(v.get(..bytes).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
if data_len % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
Ok(out)
|
||||
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, bwf, mask, force_rf64)?;
|
||||
w.write(&buf.channels, frames)?;
|
||||
Ok(w.finish()?.into_inner())
|
||||
}
|
||||
|
||||
/// Everything before the sample data of a WAV holding `frames` frames.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -292,11 +292,15 @@ 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}),
|
||||
)
|
||||
let done = crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold)?.run()?;
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"bytes": done.bytes,
|
||||
"seconds": e.session().sample_rate.seconds(r.len()),
|
||||
"peak_db": done.peak_db,
|
||||
"lufs": done.lufs,
|
||||
"true_peak_db": done.true_peak_db,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Write the score of the chosen MIDI tracks as MusicXML (or SVG when `svg`).
|
||||
|
|
|
|||
|
|
@ -237,61 +237,220 @@ 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. The file appears at its path only once complete; a bounce that
|
||||
/// fails 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>,
|
||||
/// Measuring the peak to normalize to, the first of two passes.
|
||||
measuring: bool,
|
||||
render: soundcraft_mix::RangeRender,
|
||||
block: Vec<Vec<f32>>,
|
||||
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"));
|
||||
let enc = (|| -> std::result::Result<_, soundcraft_audio_io::AudioError> {
|
||||
let enc = soundcraft_audio_io::FileEncoder::new(Out::create(&part)?, opts, channels, s.sample_rate.hz(), Some(fmt.channel_mask()))?;
|
||||
enc.check_room(u64::try_from(r.len()).unwrap_or(0))?;
|
||||
Ok(enc)
|
||||
})()
|
||||
.map_err(|err| {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
EngineError::Io(format!("{}: {err}", path.display()))
|
||||
})?;
|
||||
let render = soundcraft_mix::RangeRender::new(&s, bus, r, 1024);
|
||||
let meter = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, channels);
|
||||
Ok(Bounce {
|
||||
session: s,
|
||||
path: path.to_path_buf(),
|
||||
part,
|
||||
range: r,
|
||||
bus,
|
||||
fold,
|
||||
measuring: normalize,
|
||||
render,
|
||||
block: Vec::new(),
|
||||
gain: 1.0,
|
||||
peak: 0.0,
|
||||
meter,
|
||||
enc: Some(enc),
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders the whole bounce and completes its file.
|
||||
pub fn run(mut self) -> Result<Bounced> {
|
||||
loop {
|
||||
match self.render.next_block(&self.session) {
|
||||
Some((chunk, n)) => {
|
||||
fill(&mut self.block, chunk, n, self.fold, self.gain);
|
||||
self.peak = self.peak.max(soundcraft_dsp::offline::peak(&self.block));
|
||||
if !self.measuring {
|
||||
self.meter.process(&self.block, n);
|
||||
if let Some(enc) = &mut self.enc {
|
||||
enc.write(&self.block, n).map_err(|err| EngineError::Io(format!("{}: {err}", self.path.display())))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
None if self.measuring => {
|
||||
self.gain = if self.peak > 0.0 { soundcraft_dsp::db_to_gain(-0.1) / self.peak } else { 1.0 };
|
||||
self.peak = 0.0;
|
||||
self.measuring = false;
|
||||
self.render = soundcraft_mix::RangeRender::new(&self.session, self.bus, self.range, 1024);
|
||||
}
|
||||
None => return self.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<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)
|
||||
|
|
@ -303,6 +462,9 @@ pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Re
|
|||
/// [`bounce_stems`] in the main format, or folded down to stereo when `fold_stereo`.
|
||||
pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, fold_stereo: bool) -> Result<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()));
|
||||
}
|
||||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?;
|
||||
let ext = match opts.format {
|
||||
FileFormat::Aiff => "aif",
|
||||
|
|
@ -321,11 +483,8 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions,
|
|||
for t in &mut solo.tracks {
|
||||
t.mixer.solo = t.id == id;
|
||||
}
|
||||
let (ch, fmt) = render_main(&solo, r, fold_stereo);
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch };
|
||||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name)));
|
||||
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
|
||||
Bounce::new(Arc::new(solo), &path, r, opts, None, false, fold_stereo)?.run()?;
|
||||
out.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
Ok(out)
|
||||
|
|
@ -608,7 +767,12 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
|||
/// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the
|
||||
/// old or the new contents, never a mix.
|
||||
pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> {
|
||||
if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) {
|
||||
replace_with(tmp, file, write_synced(tmp, bytes))
|
||||
}
|
||||
|
||||
/// Renames `tmp` over `file` once it is `written`, or removes it when writing or renaming failed.
|
||||
fn replace_with(tmp: &Path, file: &Path, written: std::io::Result<()>) -> Result<()> {
|
||||
if let Err(err) = written.and_then(|()| std::fs::rename(tmp, file)) {
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
return Err(EngineError::Io(format!("{}: {err}", file.display())));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
//! Bounces stream the main mix or a bus to their file, exactly as rendered in memory.
|
||||
|
||||
use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat};
|
||||
use soundcraft_engine::io::{Bounce, bounce_stems};
|
||||
use soundcraft_time::Range;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounces_write_exactly_the_mix_and_report_its_levels() {
|
||||
let e = soundcraft_engine::demo::demo_engine();
|
||||
let s = e.session();
|
||||
let dir = scratch("bounce-exact");
|
||||
let path = dir.join("Mix.wav");
|
||||
let r = Range::new(s.sample_rate.samples(10.0) + 7, s.sample_rate.samples(13.0));
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let verb = s.bus_by_name("Verb").unwrap().id;
|
||||
for bus in [None, Some(verb)] {
|
||||
let mut mix = match bus {
|
||||
Some(b) => soundcraft_mix::render_bus(s, b, r, 1024),
|
||||
None => soundcraft_mix::render_range(s, r, 1024),
|
||||
};
|
||||
assert!(mix.iter().flatten().any(|v| v.abs() > 1e-3), "{bus:?}");
|
||||
for normalize in [false, true] {
|
||||
let done = Bounce::new(e.session_arc(), &path, r, &opts, bus, normalize, false).unwrap().run().unwrap();
|
||||
if normalize {
|
||||
soundcraft_dsp::offline::normalize(&mut mix, -0.1, false);
|
||||
}
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let (_, file) = soundcraft_audio_io::decode(&bytes, None).unwrap();
|
||||
assert_eq!(file.channels, mix, "{bus:?} normalize: {normalize}");
|
||||
let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, mix.len());
|
||||
lm.process(&mix, mix[0].len());
|
||||
let levels = (soundcraft_dsp::offline::peak_db(&mix), lm.integrated_lufs(), lm.true_peak_db());
|
||||
assert_eq!((done.bytes, (done.peak_db, done.lufs, done.true_peak_db)), (bytes.len() as u64, levels));
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() {
|
||||
let e = soundcraft_engine::demo::demo_engine();
|
||||
let s = e.session();
|
||||
let dir = scratch("bounce-too-big");
|
||||
let path = dir.join("Mix.aif");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
let opts = EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let err = Bounce::new(e.session_arc(), &path, Range::new(0, s.sample_rate.samples(3.9 * 3600.0)), &opts, None, false, false).err().unwrap();
|
||||
assert!(err.to_string().contains("4 GiB"), "{err}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"old");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
||||
let err = bounce_stems(&e, &dir.to_string_lossy(), Range::new(0, s.sample_rate.samples(4.1 * 3600.0)), &opts).unwrap_err();
|
||||
assert!(err.to_string().contains("4 hours"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
@ -1734,88 +1734,103 @@ fn apply_matrix(src: &[Vec<f32>], frames: usize, dst: &mut [Vec<f32>], m: &Matri
|
|||
}
|
||||
}
|
||||
|
||||
/// Ceiling on one offline render (samples summed over channels): 2^29 f32 = 2 GiB, about 93
|
||||
/// minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory.
|
||||
/// Ceiling on one in-memory offline render (samples summed over channels): 2^29 f32 = 2 GiB, about
|
||||
/// 93 minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory;
|
||||
/// [`RangeRender`] streams any length.
|
||||
pub const MAX_RENDER_SAMPLES: usize = 1 << 29;
|
||||
|
||||
/// Offline render of the main mix over `range` (planar, [`main_channels`] channels in SMPTE/WAV
|
||||
/// order). The mix's delay-compensation latency is removed, so the result lines up with the
|
||||
/// timeline.
|
||||
pub fn render_range(s: &Session, range: Range, block: usize) -> Vec<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),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next stretch of the mix of `s` (the session given to [`RangeRender::new`]) as
|
||||
/// `(channels, n)`: the first `n` samples of each channel. `None` once the range is done.
|
||||
pub fn next_block(&mut self, s: &Session) -> Option<(&[Vec<f32>], usize)> {
|
||||
while self.done < self.total {
|
||||
let n = (self.total - self.done).min(self.eng.max_block());
|
||||
self.render(s, self.start + self.done as i64, n);
|
||||
if self.done == 0 {
|
||||
// The first block tells the mix's latency: render that much more and drop the head.
|
||||
self.latency = if self.bus.is_some() { self.eng.track_latency } else { self.eng.latency };
|
||||
self.total = self.total.saturating_add(self.latency);
|
||||
}
|
||||
let skip = self.latency.saturating_sub(self.done).min(n);
|
||||
self.done += n;
|
||||
if skip < n {
|
||||
if skip > 0 {
|
||||
for c in &mut self.tmp {
|
||||
c.copy_within(skip..n, 0);
|
||||
}
|
||||
}
|
||||
return Some((&self.tmp, n - skip));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped;
|
||||
/// Ambisonics play their W channel). Mono/stereo input is returned unchanged.
|
||||
pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
|
||||
|
|
@ -2334,6 +2349,37 @@ mod tests {
|
|||
assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_renders_line_up_with_the_timeline_in_any_block_size() {
|
||||
let mut s = Session::default();
|
||||
let mut imp = vec![0.0f32; 48_000];
|
||||
imp[1000] = 1.0;
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![imp] })));
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let bus = s.add_bus("Stem", ChannelFormat::Stereo);
|
||||
let cid = s.new_clip_id();
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.playlist_mut().unwrap().clips.push(Clip::audio(cid, "i", SourceId(900), 0, 0, 48_000));
|
||||
tr.mixer.inserts[0] = Some(Insert::new("maximizer"));
|
||||
let mut send = soundcraft_model::SendSlot::new(Route::Bus(bus));
|
||||
send.level_db = 0.0;
|
||||
tr.mixer.sends[0] = Some(send);
|
||||
assert!(soundcraft_dsp::create("maximizer").unwrap().latency() > 16);
|
||||
for source in [None, Some(bus)] {
|
||||
for block in [16, 100, 512] {
|
||||
let mut got = Vec::new();
|
||||
let mut render = RangeRender::new(&s, source, Range::new(900, 2900), block);
|
||||
while let Some((chunk, n)) = render.next_block(&s) {
|
||||
assert!(n <= block);
|
||||
got.extend_from_slice(&chunk[0][..n]);
|
||||
}
|
||||
assert_eq!(got.len(), 2000, "{source:?} block {block}");
|
||||
let peak = got.iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).map(|(i, _)| i);
|
||||
assert_eq!(peak, Some(100), "{source:?} block {block}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_monitoring_replaces_clips() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue