export: 32-bit integer and 32-bit float audio; Sample Size lists every size and greys out the unsupported ones
- AudioSettings gains `float`; `bits` takes 16, 24 or 32. `SampleFormat` (Int16 / Int24 / Int32 / Float32) and `ExportSettings::supports_sample_format` decide what each codec and container stores: WAV, AIFF and QuickTime PCM all four, MXF integers, FLAC integers. `validate` refuses float without 32 bits and float in FLAC or MXF. - WAV float is WAVE_FORMAT_IEEE_FLOAT (EXTENSIBLE for more than two channels) with a `fact` chunk; AIFF float is AIFF-C `fl32`; QuickTime float is `lpcm` with the float flag; MXF writes 32-bit integers. Float keeps the mix as it is, peaks above 0 dBFS included; 16 and 24 bit write the same bytes as before. - FLAC encoder: 8-32 bits. 32-bit stereo is coded independently (a side channel would need 33 bits), the sample range check no longer overflows, and 32-bit frames point at STREAMINFO for the size, which symphonia 0.5 (FilmCraft's own FLAC import) and ffmpeg both read. - Export mode: Sample Size shows 16 bit, 24 bit, 32 bit and 32 bit float like Audio Format does, greying out what the format cannot store with the reason on hover; a size that stops being available (float after switching to FLAC) becomes 32-bit integer. es, fr, pt-br, id, ja and zh-cn strings. - Tests: writer layouts and ffprobe codecs for every new format, round trips through ffmpeg and FilmCraft's own readers in WAV, AIFF, MOV, MXF and FLAC, headroom of a +6 dB tone, refused combinations, full-scale 32-bit FLAC through ffmpeg (MD5 too) and symphonia, and a UI test of the selector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nzevSsTNZxHPpUngfGTAw
This commit is contained in:
19 files changed
+698
-124
No files matched your search
+12
-14
@@ -363,18 +363,9 @@ impl Exporter {
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let bps = if self.settings.audio.bits >= 24 { 3 } else { 2 };
|
||||
let mut pcm = Vec::with_capacity(n * buf.len() * bps);
|
||||
for i in 0..n {
|
||||
for c in &buf {
|
||||
let s = c[i].clamp(-1.0, 1.0);
|
||||
if bps == 3 {
|
||||
pcm.extend_from_slice(&((s * 8_388_607.0).round() as i32).to_le_bytes()[..3]);
|
||||
} else {
|
||||
pcm.extend_from_slice(&((s * 32767.0).round() as i16).to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
// little-endian, like the track's `lpcm` / `sowt` description says
|
||||
let mut pcm = Vec::new();
|
||||
crate::pcm::encode(crate::pcm::PcmContainer::Wav, &crate::audio_out::interleave(&buf), self.settings.audio.sample_format(), &mut pcm);
|
||||
mux.write_sample(at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true })
|
||||
.map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
}
|
||||
@@ -428,8 +419,15 @@ impl Exporter {
|
||||
Some(mux.add_track(c).map_err(|e| ExportError::Io(e.to_string()))?)
|
||||
}
|
||||
(Some(a), None) => {
|
||||
let bits = if self.settings.audio.bits >= 24 { 24 } else { 16 };
|
||||
let pcm = PcmConfig { bits, float: false, big_endian: false, signed: true, channels: a.channels as _, sample_rate: a.sr as f64 };
|
||||
let f = self.settings.audio.sample_format();
|
||||
let pcm = PcmConfig {
|
||||
bits: f.bits() as _,
|
||||
float: f.is_float(),
|
||||
big_endian: false,
|
||||
signed: true,
|
||||
channels: a.channels as _,
|
||||
sample_rate: a.sr as f64,
|
||||
};
|
||||
Some(mux.add_track(TrackConfig::new(SampleEntry::pcm(pcm), a.sr)).map_err(|e| ExportError::Io(e.to_string()))?)
|
||||
}
|
||||
};
|
||||
|
||||
+19
-11
@@ -625,6 +625,14 @@ impl ExportSettings {
|
||||
if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) {
|
||||
return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into()));
|
||||
}
|
||||
if self.audio.float && self.audio.bits != 32 {
|
||||
return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into()));
|
||||
}
|
||||
let sample_format = self.audio.sample_format();
|
||||
if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) {
|
||||
let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() };
|
||||
return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label())));
|
||||
}
|
||||
if self.field_order != FieldOrder::Progressive && self.has_video() {
|
||||
return Err(ExportError::Unsupported(format!("{} field order: FilmCraft's encoders write progressive frames", self.field_order.label())));
|
||||
}
|
||||
@@ -1108,19 +1116,19 @@ impl AudioEncoder for AacEncoder {
|
||||
/// FLAC (our encoder) in MP4 / QuickTime: one frame of 4096 samples per access unit.
|
||||
pub(crate) struct FlacAudio {
|
||||
enc: filmcraft_flac::Encoder,
|
||||
bits: u16,
|
||||
format: SampleFormat,
|
||||
/// Samples of the frame `flush` returns (shorter than a full block).
|
||||
tail: u32,
|
||||
}
|
||||
|
||||
impl FlacAudio {
|
||||
pub(crate) fn new(sample_rate: u32, channels: u32, s: &ExportSettings) -> Result<Self> {
|
||||
let bits = if s.audio.bits >= 24 { 24 } else { 16 };
|
||||
let format = s.audio.sample_format();
|
||||
let ch = u8::try_from(channels).map_err(|_| ExportError::Unsupported("FLAC: too many audio channels".into()))?;
|
||||
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, bits as u8);
|
||||
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, format.bits() as u8);
|
||||
cfg.level = s.audio.flac_level.min(8);
|
||||
let enc = filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?;
|
||||
Ok(FlacAudio { enc, bits, tail: 0 })
|
||||
Ok(FlacAudio { enc, format, tail: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,7 +1137,7 @@ impl AudioEncoder for FlacAudio {
|
||||
SampleEntry::flac(&self.enc.streaminfo())
|
||||
}
|
||||
fn encode(&mut self, planar: &[Vec<f32>]) -> Result<Vec<Vec<u8>>> {
|
||||
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).collect()).collect();
|
||||
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| self.format.quantise(s)).collect()).collect();
|
||||
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
|
||||
self.enc.encode_frames(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))
|
||||
}
|
||||
@@ -1851,12 +1859,12 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?,
|
||||
Format::Wav | Format::Aiff | Format::Flac => {
|
||||
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?;
|
||||
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);
|
||||
let (ch, sr, fmt) = (a.channels as u16, a.sr, settings.audio.sample_format());
|
||||
let frames = a.remaining();
|
||||
// the sizes are known up front: refuse an AIFF that cannot hold them before measuring
|
||||
let mut writer = match settings.format {
|
||||
Format::Flac => {
|
||||
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, if bits >= 24 { 24 } else { 16 });
|
||||
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, fmt.bits() as u8);
|
||||
cfg.level = settings.audio.flac_level.min(8);
|
||||
AudioWriter::Flac(Box::new(filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?))
|
||||
}
|
||||
@@ -1864,7 +1872,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
_ => AudioWriter::Pcm(pcm::PcmContainer::Aiff),
|
||||
};
|
||||
let head = match &writer {
|
||||
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, bits, frames).map_err(ExportError::Unsupported)?,
|
||||
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, fmt, frames).map_err(ExportError::Unsupported)?,
|
||||
AudioWriter::Flac(enc) => enc.header(),
|
||||
};
|
||||
// one second per chunk: memory stays flat however long the range is
|
||||
@@ -1885,9 +1893,9 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
}
|
||||
buf.clear();
|
||||
match &mut writer {
|
||||
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), bits, &mut buf),
|
||||
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), fmt, &mut buf),
|
||||
AudioWriter::Flac(enc) => {
|
||||
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).collect()).collect();
|
||||
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| fmt.quantise(s)).collect()).collect();
|
||||
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
|
||||
buf = enc.encode(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))?;
|
||||
}
|
||||
@@ -1898,7 +1906,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
}
|
||||
}
|
||||
match &mut writer {
|
||||
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?,
|
||||
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, fmt)).map_err(io)?,
|
||||
AudioWriter::Flac(enc) => {
|
||||
out.write_all(&enc.finish()).map_err(io)?;
|
||||
// the frame sizes and MD5 are known now: fill them into STREAMINFO
|
||||
|
||||
@@ -108,7 +108,8 @@ impl MxfMux {
|
||||
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
|
||||
let seed = format!("{}|{now}", settings.path);
|
||||
let ids = PackageIds::from_seed(&seed, &s.name);
|
||||
let bits = if settings.audio.bits >= 24 { 24 } else { 16 };
|
||||
// integers only: `ExportSettings::validate` refuses float PCM in MXF
|
||||
let bits = u32::from(settings.audio.sample_format().bits());
|
||||
let mut cfg = WriterConfig::new(if atom { Pattern::OpAtom } else { Pattern::Op1a }, rate, ids.clone());
|
||||
cfg.picture = Some(picture(settings, s.width, s.height));
|
||||
cfg.modified = Timestamp::from_unix((now / 1_000_000_000) as i64);
|
||||
|
||||
+190
-59
@@ -1,10 +1,13 @@
|
||||
//! Audio-only files: WAV (RIFF, little-endian PCM; RF64 past 4 GiB) and AIFF (big-endian PCM),
|
||||
//! 16 or 24 bit, plus image-sequence file naming.
|
||||
//! 16, 24 or 32-bit integer or 32-bit float ([`SampleFormat`]), plus image-sequence file naming.
|
||||
//! Float WAV is `WAVE_FORMAT_IEEE_FLOAT` with a `fact` chunk; float AIFF is AIFF-C (`fl32`).
|
||||
//!
|
||||
//! The header is written first from the known length ([`header`]), then the samples in chunks
|
||||
//! ([`encode`]) and the pad byte ([`pad`]), so an export streams to disk instead of building the
|
||||
//! file in memory.
|
||||
|
||||
use crate::SampleFormat;
|
||||
|
||||
/// The container of an audio-only export.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PcmContainer {
|
||||
@@ -12,47 +15,55 @@ pub enum PcmContainer {
|
||||
Aiff,
|
||||
}
|
||||
|
||||
/// Quantise one sample to a signed integer of `bits` (16 or 24).
|
||||
pub(crate) fn quantise(s: f32, bits: u16) -> i32 {
|
||||
let max = if bits >= 24 { 8_388_607.0 } else { 32_767.0 };
|
||||
(s.clamp(-1.0, 1.0) * max).round() as i32
|
||||
}
|
||||
|
||||
/// The sample size written for a requested `bits`: 24, else 16.
|
||||
fn sample_bits(bits: u16) -> u16 {
|
||||
if bits >= 24 { 24 } else { 16 }
|
||||
}
|
||||
|
||||
/// Bytes of sample data for `frames` frames, or None when that does not fit 64 bits.
|
||||
fn data_len(frames: u64, channels: u16, bits: u16) -> Option<u64> {
|
||||
frames.checked_mul(u64::from(channels))?.checked_mul(u64::from(sample_bits(bits) / 8))
|
||||
fn data_len(frames: u64, channels: u16, format: SampleFormat) -> Option<u64> {
|
||||
frames.checked_mul(u64::from(channels))?.checked_mul(format.bytes() as u64)
|
||||
}
|
||||
|
||||
/// The pad byte after an odd-sized data chunk (RIFF and AIFF chunks are word-aligned).
|
||||
pub fn pad(frames: u64, channels: u16, bits: u16) -> &'static [u8] {
|
||||
if data_len(frames, channels, bits).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
|
||||
pub fn pad(frames: u64, channels: u16, format: SampleFormat) -> &'static [u8] {
|
||||
if data_len(frames, channels, format).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
|
||||
}
|
||||
|
||||
/// `KSDATAFORMAT_SUBTYPE_PCM` / `_IEEE_FLOAT` after their first two bytes (the format tag).
|
||||
const SUBTYPE_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
|
||||
|
||||
/// AIFF-C format version 1 (`FVER`).
|
||||
const AIFC_VERSION_1: u32 = 0xA280_5140;
|
||||
|
||||
/// The AIFF-C compression name of `fl32`, as a Pascal string of even length.
|
||||
const FL32_NAME: &[u8] = b"\x1532-bit floating point";
|
||||
|
||||
/// Everything before the samples of a file of `frames` frames. A WAV whose RIFF size would not
|
||||
/// fit 32 bits (4 GiB: 24-bit 5.1 at 96 kHz after about 41 minutes) is written as RF64 (EBU Tech
|
||||
/// 3306), with the 64-bit sizes in a `ds64` chunk. AIFF has no 64-bit form, so a longer AIFF is
|
||||
/// an error naming WAV instead.
|
||||
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u16, frames: u64) -> Result<Vec<u8>, String> {
|
||||
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, format: SampleFormat, frames: u64) -> Result<Vec<u8>, String> {
|
||||
let too_long = || "the audio is too long to write".to_string();
|
||||
let bits = sample_bits(bits);
|
||||
let bits = format.bits();
|
||||
let bps = bits / 8;
|
||||
let data = data_len(frames, channels, bits).ok_or_else(too_long)?;
|
||||
let float = format.is_float();
|
||||
let data = data_len(frames, channels, format).ok_or_else(too_long)?;
|
||||
let block_align = channels.checked_mul(bps).ok_or("too many audio channels")?;
|
||||
match container {
|
||||
PcmContainer::Wav => {
|
||||
let byte_rate = u32::try_from(u64::from(sample_rate) * u64::from(block_align)).map_err(|_| "the audio sample rate is too high".to_string())?;
|
||||
let ext = channels > 2;
|
||||
let fmt_len: u32 = if ext { 40 } else { 16 };
|
||||
// plain PCM has no cbSize; IEEE float has an empty one
|
||||
let fmt_len: u32 = if ext {
|
||||
40
|
||||
} else if float {
|
||||
18
|
||||
} else {
|
||||
16
|
||||
};
|
||||
// every format but integer PCM has a `fact` chunk (the frame count; RF64: in `ds64`)
|
||||
let fact_len: u64 = if float { 12 } else { 0 };
|
||||
let padded = data.checked_add(data & 1).ok_or_else(too_long)?;
|
||||
// "WAVE" + fmt chunk + data chunk header + samples
|
||||
let riff = (4 + 8 + u64::from(fmt_len) + 8).checked_add(padded).ok_or_else(too_long)?;
|
||||
// "WAVE" + fmt chunk + fact chunk + data chunk header + samples
|
||||
let riff = (4 + 8 + u64::from(fmt_len) + fact_len + 8).checked_add(padded).ok_or_else(too_long)?;
|
||||
let rf64 = riff > u64::from(u32::MAX);
|
||||
let mut v = Vec::with_capacity(80);
|
||||
let mut v = Vec::with_capacity(100);
|
||||
if rf64 {
|
||||
const DS64: u64 = 8 + 28;
|
||||
v.extend_from_slice(b"RF64");
|
||||
@@ -68,21 +79,30 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
|
||||
v.extend_from_slice(&(riff as u32).to_le_bytes());
|
||||
v.extend_from_slice(b"WAVE");
|
||||
}
|
||||
let tag: u16 = if float { 3 } else { 1 };
|
||||
v.extend_from_slice(b"fmt ");
|
||||
v.extend_from_slice(&fmt_len.to_le_bytes());
|
||||
v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes());
|
||||
v.extend_from_slice(&(if ext { 0xFFFE } else { tag }).to_le_bytes());
|
||||
v.extend_from_slice(&channels.to_le_bytes());
|
||||
v.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
v.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
v.extend_from_slice(&block_align.to_le_bytes());
|
||||
v.extend_from_slice(&bits.to_le_bytes());
|
||||
if ext {
|
||||
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM
|
||||
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM / _IEEE_FLOAT
|
||||
v.extend_from_slice(&22u16.to_le_bytes());
|
||||
v.extend_from_slice(&bits.to_le_bytes());
|
||||
let mask: u32 = if channels == 6 { 0x3F } else { 0 };
|
||||
v.extend_from_slice(&mask.to_le_bytes());
|
||||
v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]);
|
||||
v.extend_from_slice(&tag.to_le_bytes());
|
||||
v.extend_from_slice(&SUBTYPE_TAIL);
|
||||
} else if float {
|
||||
v.extend_from_slice(&0u16.to_le_bytes());
|
||||
}
|
||||
if float {
|
||||
v.extend_from_slice(b"fact");
|
||||
v.extend_from_slice(&4u32.to_le_bytes());
|
||||
v.extend_from_slice(&(if rf64 { u32::MAX } else { u32::try_from(frames).unwrap_or(u32::MAX) }).to_le_bytes());
|
||||
}
|
||||
v.extend_from_slice(b"data");
|
||||
v.extend_from_slice(&(if rf64 { u32::MAX } else { data as u32 }).to_le_bytes());
|
||||
@@ -93,16 +113,31 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
|
||||
let frames32 = u32::try_from(frames).map_err(|_| aiff_too_long())?;
|
||||
// SSND: offset + block size + samples
|
||||
let ssnd = u32::try_from(data.checked_add(8).ok_or_else(aiff_too_long)?).map_err(|_| aiff_too_long())?;
|
||||
let form = (4u32 + (8 + 18)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
|
||||
let mut v = Vec::with_capacity(54);
|
||||
// floats need AIFF-C: a version chunk, and the compression type and name in COMM
|
||||
let comm: u32 = if float { 18 + 4 + FL32_NAME.len() as u32 } else { 18 };
|
||||
let fver: u32 = if float { 8 + 4 } else { 0 };
|
||||
let form =
|
||||
(4u32 + fver + (8 + comm)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
|
||||
let mut v = Vec::with_capacity(90);
|
||||
v.extend_from_slice(b"FORM");
|
||||
v.extend_from_slice(&form.to_be_bytes());
|
||||
v.extend_from_slice(b"AIFFCOMM");
|
||||
v.extend_from_slice(&18u32.to_be_bytes());
|
||||
if float {
|
||||
v.extend_from_slice(b"AIFCFVER");
|
||||
v.extend_from_slice(&4u32.to_be_bytes());
|
||||
v.extend_from_slice(&AIFC_VERSION_1.to_be_bytes());
|
||||
} else {
|
||||
v.extend_from_slice(b"AIFF");
|
||||
}
|
||||
v.extend_from_slice(b"COMM");
|
||||
v.extend_from_slice(&comm.to_be_bytes());
|
||||
v.extend_from_slice(&channels.to_be_bytes());
|
||||
v.extend_from_slice(&frames32.to_be_bytes());
|
||||
v.extend_from_slice(&bits.to_be_bytes());
|
||||
v.extend_from_slice(&extended(sample_rate));
|
||||
if float {
|
||||
v.extend_from_slice(b"fl32");
|
||||
v.extend_from_slice(FL32_NAME);
|
||||
}
|
||||
v.extend_from_slice(b"SSND");
|
||||
v.extend_from_slice(&ssnd.to_be_bytes());
|
||||
v.extend_from_slice(&0u32.to_be_bytes()); // offset
|
||||
@@ -112,13 +147,12 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
|
||||
}
|
||||
}
|
||||
|
||||
/// Append interleaved samples as `bits`-bit PCM (little-endian for WAV, big-endian for AIFF).
|
||||
pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut Vec<u8>) {
|
||||
let bits = sample_bits(bits);
|
||||
let bps = usize::from(bits / 8);
|
||||
/// Append interleaved samples in `format` (little-endian for WAV, big-endian for AIFF).
|
||||
pub fn encode(container: PcmContainer, interleaved: &[f32], format: SampleFormat, out: &mut Vec<u8>) {
|
||||
let bps = format.bytes();
|
||||
out.reserve(interleaved.len().saturating_mul(bps));
|
||||
for &s in interleaved {
|
||||
let q = quantise(s, bits);
|
||||
let q = if format.is_float() { SampleFormat::float(s).to_bits() as i32 } else { format.quantise(s) };
|
||||
match container {
|
||||
PcmContainer::Wav => out.extend_from_slice(q.to_le_bytes().get(..bps).unwrap_or_default()),
|
||||
PcmContainer::Aiff => out.extend_from_slice(q.to_be_bytes().get(4 - bps..).unwrap_or_default()),
|
||||
@@ -127,19 +161,19 @@ pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut
|
||||
}
|
||||
|
||||
/// A whole file in memory (short audio; exports stream with [`header`] and [`encode`]).
|
||||
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
|
||||
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
|
||||
let frames = (interleaved.len() / usize::from(channels.max(1))) as u64;
|
||||
let mut v = header(container, channels, sample_rate, bits, frames)?;
|
||||
encode(container, interleaved, bits, &mut v);
|
||||
v.extend_from_slice(pad(frames, channels, bits));
|
||||
let mut v = header(container, channels, sample_rate, format, frames)?;
|
||||
encode(container, interleaved, format, &mut v);
|
||||
v.extend_from_slice(pad(frames, channels, format));
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// A RIFF/WAVE file of interleaved samples. More than two channels are written as
|
||||
/// `WAVE_FORMAT_EXTENSIBLE` with the speaker mask of the layout (6 channels: L, R, C, LFE, Ls, Rs
|
||||
/// = `0x3F`).
|
||||
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
|
||||
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, bits)
|
||||
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
|
||||
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, format)
|
||||
}
|
||||
|
||||
/// IEEE 754 80-bit extended encoding of a sample rate (AIFF `COMM`).
|
||||
@@ -156,9 +190,9 @@ fn extended(rate: u32) -> [u8; 10] {
|
||||
out
|
||||
}
|
||||
|
||||
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`) of interleaved samples.
|
||||
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
|
||||
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, bits)
|
||||
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`; AIFF-C for floats) of interleaved samples.
|
||||
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
|
||||
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, format)
|
||||
}
|
||||
|
||||
/// File name of frame `index` of an image sequence of `count` frames exported to `path`:
|
||||
@@ -181,6 +215,11 @@ pub fn image_sequence_path(path: &str, index: u64, count: u64) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const I16: SampleFormat = SampleFormat::Int16;
|
||||
const I24: SampleFormat = SampleFormat::Int24;
|
||||
const I32: SampleFormat = SampleFormat::Int32;
|
||||
const F32: SampleFormat = SampleFormat::Float32;
|
||||
|
||||
#[test]
|
||||
fn extended_sample_rates() {
|
||||
assert_eq!(extended(44_100), [0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0]);
|
||||
@@ -198,10 +237,10 @@ mod tests {
|
||||
#[test]
|
||||
fn wav_and_aiff_layout() {
|
||||
let s = [0.5f32, -0.5, 1.0, -1.0];
|
||||
let w = write_wav(&s, 2, 48_000, 24).unwrap();
|
||||
let w = write_wav(&s, 2, 48_000, I24).unwrap();
|
||||
assert_eq!(w.len(), 44 + 12);
|
||||
assert_eq!(&w[34..36], &24u16.to_le_bytes());
|
||||
let a = write_aiff(&s, 2, 48_000, 16).unwrap();
|
||||
let a = write_aiff(&s, 2, 48_000, I16).unwrap();
|
||||
assert_eq!(&a[..4], b"FORM");
|
||||
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
|
||||
assert_eq!(&a[a.len() - 2..], &(-32767i16).to_be_bytes());
|
||||
@@ -220,7 +259,7 @@ mod tests {
|
||||
#[test]
|
||||
fn wav_past_4_gib_is_rf64() {
|
||||
let frames = 96_000u64 * 60 * 60; // an hour: 6.2 GB
|
||||
let h = header(PcmContainer::Wav, 6, 96_000, 24, frames).unwrap();
|
||||
let h = header(PcmContainer::Wav, 6, 96_000, I24, frames).unwrap();
|
||||
let data = frames * 6 * 3;
|
||||
assert_eq!(&h[..4], b"RF64");
|
||||
assert_eq!(le32(&h, 4), u32::MAX);
|
||||
@@ -238,37 +277,129 @@ mod tests {
|
||||
fn wav_up_to_4_gib_stays_riff() {
|
||||
// the longest stereo 16-bit RIFF: 36 bytes of header after the size field
|
||||
let max = (u64::from(u32::MAX) - 36) / 4;
|
||||
let h = header(PcmContainer::Wav, 2, 48_000, 16, max).unwrap();
|
||||
let h = header(PcmContainer::Wav, 2, 48_000, I16, max).unwrap();
|
||||
assert_eq!(&h[..4], b"RIFF");
|
||||
assert_eq!(u64::from(le32(&h, 4)), 36 + max * 4);
|
||||
assert_eq!(u64::from(le32(&h, 40)), max * 4);
|
||||
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, 16, max + 1).unwrap()[..4], b"RF64");
|
||||
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, I16, max + 1).unwrap()[..4], b"RF64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_past_4_gib_is_refused_not_wrapped() {
|
||||
let e = header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60 * 8).unwrap_err();
|
||||
let e = header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60 * 8).unwrap_err();
|
||||
assert!(e.contains("export WAV"), "{e}");
|
||||
assert!(header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60).is_ok());
|
||||
assert!(header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_are_errors() {
|
||||
for c in [PcmContainer::Wav, PcmContainer::Aiff] {
|
||||
assert!(header(c, 2, 48_000, 24, u64::MAX).is_err());
|
||||
assert!(header(c, u16::MAX, 48_000, 24, 1).is_err());
|
||||
assert!(header(c, 2, 48_000, I24, u64::MAX).is_err());
|
||||
assert!(header(c, u16::MAX, 48_000, I24, 1).is_err());
|
||||
}
|
||||
assert!(header(PcmContainer::Wav, 6, u32::MAX, 24, 1).is_err());
|
||||
assert!(header(PcmContainer::Wav, 6, u32::MAX, I24, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_data_chunks_are_padded() {
|
||||
// one 24-bit mono frame: 3 bytes of data, one pad byte, counted in the RIFF size
|
||||
let w = write_wav(&[0.25], 1, 48_000, 24).unwrap();
|
||||
let w = write_wav(&[0.25], 1, 48_000, I24).unwrap();
|
||||
assert_eq!((w.len(), le32(&w, 4) as usize, le32(&w, 40)), (48, 40, 3));
|
||||
let a = write_aiff(&[0.25], 1, 48_000, 24).unwrap();
|
||||
let a = write_aiff(&[0.25], 1, 48_000, I24).unwrap();
|
||||
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
|
||||
assert!(pad(2, 1, 24).is_empty());
|
||||
assert!(pad(2, 1, I24).is_empty());
|
||||
}
|
||||
|
||||
fn be32(b: &[u8], at: usize) -> u32 {
|
||||
u32::from_be_bytes(b[at..at + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_samples_clip_at_full_scale() {
|
||||
assert_eq!([1.0, -1.0, 2.0, -2.0, f32::NAN, 0.5].map(|s| I32.quantise(s)), [i32::MAX, -i32::MAX, i32::MAX, -i32::MAX, 0, 1 << 30]);
|
||||
assert_eq!([1.0, -1.0, 4.0].map(|s| I24.quantise(s)), [8_388_607, -8_388_607, 8_388_607]);
|
||||
assert_eq!([1.0, -1.0, f32::INFINITY].map(|s| I16.quantise(s)), [32_767, -32_767, 32_767]);
|
||||
assert_eq!(F32.quantise(0.5), 0);
|
||||
assert_eq!([2.5, f32::NAN, f32::INFINITY, f32::NEG_INFINITY].map(SampleFormat::float), [2.5, 0.0, f32::MAX, f32::MIN]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_and_aiff_32_bit_integer() {
|
||||
let w = write_wav(&[1.0, -1.0], 1, 48_000, I32).unwrap();
|
||||
assert_eq!(w.len(), 44 + 8);
|
||||
assert_eq!(u16::from_le_bytes([w[20], w[21]]), 1, "integer PCM");
|
||||
assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]])), (4, 32));
|
||||
assert_eq!(&w[44..], [i32::MAX.to_le_bytes(), (-i32::MAX).to_le_bytes()].concat());
|
||||
let a = write_aiff(&[1.0, -1.0], 1, 48_000, I32).unwrap();
|
||||
assert_eq!(&a[8..12], b"AIFF");
|
||||
assert_eq!(u16::from_be_bytes([a[26], a[27]]), 32);
|
||||
assert_eq!(&a[a.len() - 8..], [i32::MAX.to_be_bytes(), (-i32::MAX).to_be_bytes()].concat());
|
||||
}
|
||||
|
||||
/// Float keeps the mix as it is: peaks above full scale survive.
|
||||
#[test]
|
||||
fn wav_32_bit_float() {
|
||||
let s = [0.25f32, -2.0, 1.5, f32::NAN];
|
||||
let w = write_wav(&s, 2, 48_000, F32).unwrap();
|
||||
assert_eq!(&w[12..16], b"fmt ");
|
||||
assert_eq!(le32(&w, 16), 18);
|
||||
assert_eq!(u16::from_le_bytes([w[20], w[21]]), 3, "WAVE_FORMAT_IEEE_FLOAT");
|
||||
assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]]), u16::from_le_bytes([w[36], w[37]])), (8, 32, 0));
|
||||
assert_eq!((&w[38..42], le32(&w, 42), le32(&w, 46)), (&b"fact"[..], 4, 2));
|
||||
assert_eq!((&w[50..54], le32(&w, 54)), (&b"data"[..], 16));
|
||||
assert_eq!(le32(&w, 4) as usize, w.len() - 8);
|
||||
let back: Vec<f32> = w[58..].chunks(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect();
|
||||
assert_eq!(back, [0.25, -2.0, 1.5, 0.0]);
|
||||
// 5.1: WAVE_FORMAT_EXTENSIBLE with the IEEE float sub-format, still with `fact`
|
||||
let w = write_wav(&[0.5; 12], 6, 48_000, F32).unwrap();
|
||||
assert_eq!((le32(&w, 16), u16::from_le_bytes([w[20], w[21]])), (40, 0xFFFE));
|
||||
assert_eq!(&w[44..60], [&3u16.to_le_bytes()[..], &SUBTYPE_TAIL].concat());
|
||||
assert_eq!((&w[60..64], le32(&w, 68)), (&b"fact"[..], 2));
|
||||
// RF64: the frame count is in `ds64`
|
||||
let h = header(PcmContainer::Wav, 2, 48_000, F32, 48_000 * 60 * 60 * 4).unwrap();
|
||||
assert_eq!(&h[..4], b"RF64");
|
||||
let fact = h.windows(4).position(|c| c == b"fact").unwrap();
|
||||
assert_eq!(le32(&h, fact + 8), u32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_32_bit_float_is_aifc() {
|
||||
let a = write_aiff(&[0.25, -3.0], 1, 44_100, F32).unwrap();
|
||||
assert_eq!((&a[..4], be32(&a, 4) as usize, &a[8..12]), (&b"FORM"[..], a.len() - 8, &b"AIFC"[..]));
|
||||
assert_eq!((&a[12..16], be32(&a, 16), be32(&a, 20)), (&b"FVER"[..], 4, AIFC_VERSION_1));
|
||||
assert_eq!((&a[24..28], be32(&a, 28)), (&b"COMM"[..], 44));
|
||||
assert_eq!((u16::from_be_bytes([a[32], a[33]]), be32(&a, 34), u16::from_be_bytes([a[38], a[39]])), (1, 2, 32));
|
||||
assert_eq!(&a[50..54], b"fl32");
|
||||
assert_eq!(&a[54..76], FL32_NAME);
|
||||
assert_eq!((&a[76..80], be32(&a, 80)), (&b"SSND"[..], 8 + 8));
|
||||
assert_eq!(&a[92..], [0.25f32.to_be_bytes(), (-3.0f32).to_be_bytes()].concat());
|
||||
}
|
||||
|
||||
/// ffprobe (an external oracle; skipped where it is not installed) reads every new format.
|
||||
#[test]
|
||||
fn ffprobe_reads_32_bit_formats() {
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-pcm32-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let s: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.01).sin() * 1.5).collect();
|
||||
let cases = [
|
||||
("i32.wav", write_wav(&s, 2, 48_000, I32).unwrap(), "pcm_s32le"),
|
||||
("f32.wav", write_wav(&s, 2, 48_000, F32).unwrap(), "pcm_f32le"),
|
||||
("f32-6.wav", write_wav(&s, 6, 48_000, F32).unwrap(), "pcm_f32le"),
|
||||
("i32.aiff", write_aiff(&s, 2, 48_000, I32).unwrap(), "pcm_s32be"),
|
||||
("f32.aifc", write_aiff(&s, 2, 48_000, F32).unwrap(), "pcm_f32be"),
|
||||
];
|
||||
for (name, bytes, codec) in cases {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let out = std::process::Command::new("ffprobe")
|
||||
.args(["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,bits_per_sample", "-of", "default=nw=1"])
|
||||
.arg(&path)
|
||||
.output();
|
||||
let Ok(out) = out else { break };
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains(&format!("codec_name={codec}")) && text.contains("bits_per_sample=32"), "{name}: {text}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A reader takes the RF64 sizes: ffprobe reports the duration from `ds64` (skipped without it).
|
||||
@@ -278,8 +409,8 @@ mod tests {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("long.wav");
|
||||
let frames = 48_000u64 * 60 * 60 * 5; // 5 hours of 24-bit stereo: 5.2 GB
|
||||
let mut f = header(PcmContainer::Wav, 2, 48_000, 24, frames).unwrap();
|
||||
encode(PcmContainer::Wav, &[0.0; 9_600], 24, &mut f);
|
||||
let mut f = header(PcmContainer::Wav, 2, 48_000, I24, frames).unwrap();
|
||||
encode(PcmContainer::Wav, &[0.0; 9_600], I24, &mut f);
|
||||
std::fs::write(&path, &f).unwrap();
|
||||
let out = std::process::Command::new("ffprobe")
|
||||
.args(["-v", "error", "-show_entries", "format=format_name,duration", "-of", "default=nw=1"])
|
||||
|
||||
+101
-10
@@ -223,15 +223,96 @@ pub struct AudioSettings {
|
||||
pub channels: u32,
|
||||
/// AAC bitrate.
|
||||
pub bitrate_kbps: u32,
|
||||
/// PCM and FLAC sample size: 16 or 24.
|
||||
/// PCM and FLAC sample size: 16, 24 or 32 (other values round down to one of them, 16 at least).
|
||||
pub bits: u16,
|
||||
/// 32-bit floating-point PCM instead of integers (needs `bits` 32; WAV, AIFF and QuickTime).
|
||||
pub float: bool,
|
||||
/// FLAC compression level, 0 (fastest) to 8 (smallest). Every level is lossless.
|
||||
pub flac_level: u8,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
fn default() -> Self {
|
||||
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, flac_level: 5 }
|
||||
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, float: false, flac_level: 5 }
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioSettings {
|
||||
/// The sample format [`AudioSettings::bits`] and [`AudioSettings::float`] ask for.
|
||||
pub fn sample_format(&self) -> SampleFormat {
|
||||
match self.bits {
|
||||
32.. if self.float => SampleFormat::Float32,
|
||||
32.. => SampleFormat::Int32,
|
||||
24.. => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int16,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for `f` ([`AudioSettings::bits`] and [`AudioSettings::float`]).
|
||||
pub fn set_sample_format(&mut self, f: SampleFormat) {
|
||||
self.bits = f.bits();
|
||||
self.float = f.is_float();
|
||||
}
|
||||
}
|
||||
|
||||
/// How PCM and FLAC audio stores its samples. The mix is 32-bit float: the integer formats
|
||||
/// clip it at full scale, 32-bit float keeps it as it is (peaks above 0 dBFS included).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SampleFormat {
|
||||
Int16,
|
||||
Int24,
|
||||
Int32,
|
||||
Float32,
|
||||
}
|
||||
|
||||
impl SampleFormat {
|
||||
pub const ALL: [SampleFormat; 4] = [SampleFormat::Int16, SampleFormat::Int24, SampleFormat::Int32, SampleFormat::Float32];
|
||||
|
||||
pub fn bits(self) -> u16 {
|
||||
match self {
|
||||
SampleFormat::Int16 => 16,
|
||||
SampleFormat::Int24 => 24,
|
||||
SampleFormat::Int32 | SampleFormat::Float32 => 32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes per sample.
|
||||
pub fn bytes(self) -> usize {
|
||||
usize::from(self.bits() / 8)
|
||||
}
|
||||
|
||||
pub fn is_float(self) -> bool {
|
||||
self == SampleFormat::Float32
|
||||
}
|
||||
|
||||
/// The name the Export settings show.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
SampleFormat::Int16 => "16 bit",
|
||||
SampleFormat::Int24 => "24 bit",
|
||||
SampleFormat::Int32 => "32 bit",
|
||||
SampleFormat::Float32 => "32 bit float",
|
||||
}
|
||||
}
|
||||
|
||||
/// One mix sample as a signed integer of this size, clipped at full scale (0 for
|
||||
/// [`SampleFormat::Float32`], which is never quantised). Not-a-number becomes silence.
|
||||
pub fn quantise(self, s: f32) -> i32 {
|
||||
// f32 products for 16 and 24 bit (the bytes every earlier export wrote); f64 for 32 bit,
|
||||
// whose full scale 2^31 - 1 f32 cannot hold. NaN casts to 0, the casts saturate.
|
||||
let s = s.clamp(-1.0, 1.0);
|
||||
match self {
|
||||
SampleFormat::Int16 => (s * 32_767.0).round() as i32,
|
||||
SampleFormat::Int24 => (s * 8_388_607.0).round() as i32,
|
||||
SampleFormat::Int32 => (f64::from(s) * 2_147_483_647.0).round() as i32,
|
||||
SampleFormat::Float32 => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// One mix sample as stored by [`SampleFormat::Float32`]: as it is, NaN as silence and
|
||||
/// infinities as the largest finite values.
|
||||
pub fn float(s: f32) -> f32 {
|
||||
if s.is_nan() { 0.0 } else { s.clamp(f32::MIN, f32::MAX) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +554,16 @@ impl ExportSettings {
|
||||
Resolved { width: w, height: h, rate, sample_rate: self.audio.sample_rate.unwrap_or(seq_sr), channels, target_kbps: target, max_kbps: max, keyint }
|
||||
}
|
||||
|
||||
/// Whether the audio codec and container of this export store samples as `f`: PCM in WAV,
|
||||
/// AIFF and QuickTime takes all four, MXF integers only; FLAC integers only; AAC none.
|
||||
pub fn supports_sample_format(&self, f: SampleFormat) -> bool {
|
||||
match self.audio_codec() {
|
||||
AudioCodec::Aac => false,
|
||||
AudioCodec::Flac => !f.is_float(),
|
||||
AudioCodec::Pcm | AudioCodec::Auto => !(f.is_float() && self.format.is_mxf()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio codec actually used.
|
||||
pub fn audio_codec(&self) -> AudioCodec {
|
||||
match (self.format, self.audio.codec) {
|
||||
@@ -548,7 +639,7 @@ impl ExportSettings {
|
||||
Format::Wav | Format::Aiff | Format::Flac | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
|
||||
};
|
||||
let audio_bps = if self.has_audio() || self.format.is_audio_only() {
|
||||
let pcm = r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 };
|
||||
let pcm = r.sample_rate as f64 * r.channels as f64 * f64::from(self.audio.sample_format().bits());
|
||||
match self.audio_codec() {
|
||||
AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0,
|
||||
// typical of music and dialogue; noise compresses less, silence far more
|
||||
@@ -649,13 +740,13 @@ impl ExportSettings {
|
||||
};
|
||||
match self.audio_codec() {
|
||||
AudioCodec::Aac => format!("AAC, {} kbps, {} Hz, {ch}", self.audio.bitrate_kbps, r.sample_rate),
|
||||
AudioCodec::Flac => format!(
|
||||
"FLAC (lossless) {}-bit, level {}, {} Hz, {ch}",
|
||||
if self.audio.bits >= 24 { 24 } else { 16 },
|
||||
self.audio.flac_level.min(8),
|
||||
r.sample_rate
|
||||
),
|
||||
_ => format!("Uncompressed {}-bit PCM, {} Hz, {ch}", if self.audio.bits >= 24 { 24 } else { 16 }, r.sample_rate),
|
||||
AudioCodec::Flac => {
|
||||
format!("FLAC (lossless) {}-bit, level {}, {} Hz, {ch}", self.audio.sample_format().bits(), self.audio.flac_level.min(8), r.sample_rate)
|
||||
}
|
||||
_ => {
|
||||
let f = self.audio.sample_format();
|
||||
format!("Uncompressed {}-bit{} PCM, {} Hz, {ch}", f.bits(), if f.is_float() { " float" } else { "" }, r.sample_rate)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"No audio".to_string()
|
||||
|
||||
@@ -1135,3 +1135,202 @@ fn loudness_normalization_corrects_limiter_loss_and_bounds_impossible_targets()
|
||||
assert_eq!(chunks.get(), 3);
|
||||
assert_eq!(reads_at_cancel.get(), Some(reads.load(Ordering::Relaxed)));
|
||||
}
|
||||
|
||||
/// 32-bit integer and float audio in every container that takes them, read back by ffprobe (an
|
||||
/// external oracle) and, where FilmCraft imports the format, by FilmCraft's own readers.
|
||||
#[test]
|
||||
fn thirty_two_bit_audio_in_every_container() {
|
||||
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
|
||||
let dir = Scratch::new("pcm32");
|
||||
let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 2)));
|
||||
let cases: [(Format, SampleFormat, &str, &str, &str); 8] = [
|
||||
(Format::Wav, SampleFormat::Float32, "f.wav", "pcm_f32le", "32"),
|
||||
(Format::Wav, SampleFormat::Int32, "i.wav", "pcm_s32le", "32"),
|
||||
(Format::Aiff, SampleFormat::Float32, "f.aif", "pcm_f32be", "32"),
|
||||
(Format::Aiff, SampleFormat::Int32, "i.aif", "pcm_s32be", "32"),
|
||||
(Format::ProRes, SampleFormat::Float32, "f.mov", "pcm_f32le", "32"),
|
||||
(Format::ProRes, SampleFormat::Int32, "i.mov", "pcm_s32le", "32"),
|
||||
(Format::MxfOp1a, SampleFormat::Int32, "i.mxf", "pcm_s32le", "32"),
|
||||
(Format::Flac, SampleFormat::Int32, "i.flac", "flac", "32"),
|
||||
];
|
||||
for (format, sf, name, codec, bits) in cases {
|
||||
let path = dir.path(name);
|
||||
let mut s = ExportSettings { format, path: path.clone(), range, ..Default::default() };
|
||||
s.audio.set_sample_format(sf);
|
||||
assert!(s.supports_sample_format(sf), "{name}");
|
||||
export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
// FilmCraft's own importer: the -6 dB tone at a peak of 0.5
|
||||
if !matches!(format, Format::MxfOp1a) {
|
||||
let src = filmcraft_codecs::open_bytes(name, bytes.into()).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let a = src.audio(0, 24_000, 48_000).unwrap();
|
||||
let peak = a.channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
assert!((peak - 0.5).abs() < 0.01, "{name}: peak {peak}");
|
||||
}
|
||||
if let Some(j) = ffprobe_json(&["-show_streams"], &path) {
|
||||
let st = j["streams"].as_array().unwrap().iter().find(|s| s["codec_type"] == "audio").unwrap().clone();
|
||||
assert_eq!(st["codec_name"].as_str(), Some(codec), "{name}");
|
||||
let depth = if codec == "flac" { &st["bits_per_raw_sample"] } else { &st["bits_per_sample"] };
|
||||
assert_eq!(depth.as_str().map(str::to_string).or_else(|| depth.as_u64().map(|v| v.to_string())).as_deref(), Some(bits), "{name}");
|
||||
}
|
||||
}
|
||||
// every 32-bit file holds the same samples (float exactly, integers within one step of 2^-31)
|
||||
if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("32-bit audio") {
|
||||
let raw = |name: &str| {
|
||||
let out = std::process::Command::new(&ffmpeg)
|
||||
.args(["-v", "error", "-i", &dir.path(name), "-map", "0:a", "-f", "f32le", "-c:a", "pcm_f32le", "-"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
out.stdout.chunks_exact(4).map(|b| f32::from_le_bytes(b.try_into().unwrap())).collect::<Vec<f32>>()
|
||||
};
|
||||
let reference = raw("f.wav");
|
||||
assert_eq!(reference.len(), 24_000 * 2);
|
||||
for name in ["f.aif", "f.mov"] {
|
||||
assert!(raw(name) == reference, "{name}: float samples differ from the WAV's");
|
||||
}
|
||||
for name in ["i.wav", "i.aif", "i.mov", "i.mxf", "i.flac"] {
|
||||
let got = raw(name);
|
||||
assert_eq!(got.len(), reference.len(), "{name}");
|
||||
let worst = got.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(worst < 1e-6, "{name}: differs by {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mix is 32-bit float: 32-bit float export keeps a +6 dB tone's peaks of 2.0, integers clip
|
||||
/// them at full scale.
|
||||
#[test]
|
||||
fn float_export_keeps_peaks_above_full_scale() {
|
||||
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(6.0));
|
||||
let dir = Scratch::new("pcm-hot");
|
||||
let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 4)));
|
||||
let peak = |sf: SampleFormat, name: &str| {
|
||||
let path = dir.path(name);
|
||||
let mut s = ExportSettings { format: Format::Wav, path: path.clone(), range, ..Default::default() };
|
||||
s.audio.set_sample_format(sf);
|
||||
export(&p, seq, &s, &m, &Progress::default()).unwrap();
|
||||
let src = filmcraft_codecs::open_bytes(name, std::fs::read(&path).unwrap().into()).unwrap();
|
||||
src.audio(0, 12_000, 48_000).unwrap().channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs()))
|
||||
};
|
||||
let float = peak(SampleFormat::Float32, "f.wav");
|
||||
assert!((float - 2.0).abs() < 0.02, "float keeps +6 dBFS: {float}");
|
||||
for (sf, name) in [(SampleFormat::Int32, "i32.wav"), (SampleFormat::Int24, "i24.wav"), (SampleFormat::Int16, "i16.wav")] {
|
||||
let p = peak(sf, name);
|
||||
assert!(p <= 1.0 && p > 0.99, "{name} clips at full scale: {p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_formats_each_codec_and_container_stores() {
|
||||
use SampleFormat::*;
|
||||
let supported = |format: Format, codec: AudioCodec| {
|
||||
let s = ExportSettings { format, audio: AudioSettings { codec, ..Default::default() }, ..Default::default() };
|
||||
SampleFormat::ALL.iter().filter(|&&f| s.supports_sample_format(f)).copied().collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(supported(Format::Wav, AudioCodec::Auto), [Int16, Int24, Int32, Float32]);
|
||||
assert_eq!(supported(Format::Aiff, AudioCodec::Auto), [Int16, Int24, Int32, Float32]);
|
||||
assert_eq!(supported(Format::ProRes, AudioCodec::Pcm), [Int16, Int24, Int32, Float32]);
|
||||
assert_eq!(supported(Format::MxfOp1a, AudioCodec::Auto), [Int16, Int24, Int32]);
|
||||
assert_eq!(supported(Format::MxfOpAtom, AudioCodec::Auto), [Int16, Int24, Int32]);
|
||||
assert_eq!(supported(Format::Flac, AudioCodec::Auto), [Int16, Int24, Int32]);
|
||||
assert_eq!(supported(Format::ProRes, AudioCodec::Flac), [Int16, Int24, Int32]);
|
||||
assert!(supported(Format::H264, AudioCodec::Auto).is_empty(), "MP4 is AAC");
|
||||
// `bits` rounds down to a size; `float` only with 32
|
||||
let fmt = |bits, float| AudioSettings { bits, float, ..Default::default() }.sample_format();
|
||||
assert_eq!(
|
||||
[fmt(0, false), fmt(20, false), fmt(24, false), fmt(31, false), fmt(32, false), fmt(64, false), fmt(32, true)],
|
||||
[Int16, Int16, Int24, Int24, Int32, Int32, Float32]
|
||||
);
|
||||
}
|
||||
|
||||
/// Hostile or contradictory settings (CLI, control channel, MCP, old presets) are errors, never
|
||||
/// a panic or a silently different file.
|
||||
#[test]
|
||||
fn unsupported_sample_formats_are_refused() {
|
||||
let refused = |format: Format, codec: AudioCodec, bits: u16, float: bool| {
|
||||
let s = ExportSettings { format, path: "x".into(), audio: AudioSettings { codec, bits, float, ..Default::default() }, ..Default::default() };
|
||||
s.validate().err().map(|e| e.to_string())
|
||||
};
|
||||
assert!(refused(Format::Wav, AudioCodec::Auto, 24, true).is_some_and(|e| e.contains("32")));
|
||||
assert!(refused(Format::Flac, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("FLAC")));
|
||||
assert!(refused(Format::ProRes, AudioCodec::Flac, 32, true).is_some_and(|e| e.contains("FLAC")));
|
||||
assert!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("MXF")));
|
||||
for (format, codec) in [(Format::Wav, AudioCodec::Auto), (Format::Aiff, AudioCodec::Auto), (Format::ProRes, AudioCodec::Pcm)] {
|
||||
assert_eq!(refused(format, codec, 32, true), None, "{format:?}");
|
||||
}
|
||||
assert_eq!(refused(Format::Flac, AudioCodec::Auto, 32, false), None);
|
||||
assert_eq!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, false), None);
|
||||
// AAC has no sample size: the PCM / FLAC choice is ignored, as before
|
||||
assert_eq!(refused(Format::H264, AudioCodec::Auto, 32, true), None);
|
||||
// no audio, nothing to refuse
|
||||
let s = ExportSettings {
|
||||
format: Format::MxfOp1a,
|
||||
include_audio: false,
|
||||
audio: AudioSettings { bits: 32, float: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
assert!(s.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_format_settings_round_trip_summary_and_estimate() {
|
||||
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "wav", "audio": {"bits": 32, "float": true}})).unwrap();
|
||||
assert_eq!(s.audio.sample_format(), SampleFormat::Float32);
|
||||
let back: ExportSettings = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
|
||||
assert_eq!(back.audio.sample_format(), SampleFormat::Float32);
|
||||
// settings saved before 32-bit audio read integers
|
||||
let old: AudioSettings = serde_json::from_value(serde_json::json!({"bits": 24})).unwrap();
|
||||
assert_eq!((old.float, old.sample_format()), (false, SampleFormat::Int24));
|
||||
let sum = |sf: SampleFormat, format: Format| {
|
||||
let mut s = ExportSettings { format, ..Default::default() };
|
||||
s.audio.set_sample_format(sf);
|
||||
(
|
||||
s.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).audio,
|
||||
s.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)),
|
||||
)
|
||||
};
|
||||
let (f, f_bytes) = sum(SampleFormat::Float32, Format::Wav);
|
||||
assert!(f.starts_with("Uncompressed 32-bit float PCM, 48000 Hz"), "{f}");
|
||||
let (i, _) = sum(SampleFormat::Int32, Format::Wav);
|
||||
assert!(i.starts_with("Uncompressed 32-bit PCM"), "{i}");
|
||||
let (_, i16_bytes) = sum(SampleFormat::Int16, Format::Wav);
|
||||
assert!(f_bytes > i16_bytes * 19 / 10 && f_bytes < i16_bytes * 21 / 10, "{f_bytes} vs {i16_bytes}");
|
||||
let (flac, _) = sum(SampleFormat::Int32, Format::Flac);
|
||||
assert!(flac.starts_with("FLAC (lossless) 32-bit"), "{flac}");
|
||||
}
|
||||
|
||||
/// FilmCraft imports its own 32-bit FLAC (symphonia rejected the frame header's 32-bit size code
|
||||
/// as reserved: "unsupported media"), at full scale and with noise, sample for sample.
|
||||
#[test]
|
||||
fn own_importer_reads_32_bit_flac_at_full_scale() {
|
||||
let n = 20_000usize;
|
||||
let mut seed = 7u64;
|
||||
let noise: Vec<i32> = (0..n)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 32) as u32 as i32
|
||||
})
|
||||
.collect();
|
||||
let extremes: Vec<i32> = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN + 1 }).collect();
|
||||
for level in [0u8, 5, 8] {
|
||||
let mut enc = filmcraft_flac::Encoder::new(filmcraft_flac::EncoderConfig { level, ..filmcraft_flac::EncoderConfig::new(48_000, 2, 32) }).unwrap();
|
||||
let mut file = enc.header();
|
||||
file.extend(enc.encode(&[&extremes, &noise]).unwrap());
|
||||
file.extend(enc.finish());
|
||||
let at = filmcraft_flac::STREAMINFO_OFFSET as usize;
|
||||
file[at..at + 34].copy_from_slice(&enc.streaminfo());
|
||||
let src = filmcraft_codecs::open_bytes("x.flac", file.into()).unwrap_or_else(|e| panic!("level {level}: {e}"));
|
||||
let a = src.audio(0, n, 48_000).unwrap();
|
||||
for (c, want) in [&extremes, &noise].iter().enumerate() {
|
||||
let got = &a.channels[c];
|
||||
assert_eq!(got.len(), n, "level {level} channel {c}");
|
||||
for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() {
|
||||
let w = (f64::from(w) / 2_147_483_648.0) as f32;
|
||||
assert!((g - w).abs() <= 1e-6, "level {level} channel {c} sample {i}: {g} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ symphonia bootstrap decoder in `filmcraft-codecs`.
|
||||
```rust
|
||||
use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET};
|
||||
|
||||
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–24)
|
||||
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–32)
|
||||
cfg.level = 8; // 0 fastest … 8 smallest; 5 by default
|
||||
let mut enc = Encoder::new(cfg)?;
|
||||
let mut file = enc.header(); // fLaC + STREAMINFO + VORBIS_COMMENT (vendor)
|
||||
@@ -37,10 +37,11 @@ let last_len = enc.pending_samples(); // duration
|
||||
| Subframes | constant, verbatim, fixed predictors (order 0–4) and linear prediction (up to order 12), whichever is smallest |
|
||||
| Linear prediction | Tukey(0.5) windowed autocorrelation, Levinson–Durbin, 12/15-bit quantised coefficients with error feedback |
|
||||
| Residual | partitioned Rice codes (4- or 5-bit parameters), the partition order chosen per subframe |
|
||||
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest |
|
||||
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest (32-bit: independent only, a side channel would need 33 bits) |
|
||||
|
||||
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, 32-bit samples,
|
||||
seek tables.
|
||||
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, seek tables.
|
||||
32-bit samples (frame headers point at STREAMINFO for the size) decode only in recent decoders (libFLAC 1.4+,
|
||||
ffmpeg 6+).
|
||||
|
||||
| Level | LPC order | Rice partition order | Stereo decorrelation |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -53,6 +53,8 @@ fn sample_size_code(bps: u8) -> u8 {
|
||||
16 => 4,
|
||||
20 => 5,
|
||||
24 => 6,
|
||||
// 32 bits has code 7 since RFC 9639, which earlier decoders (symphonia 0.5, FilmCraft's own
|
||||
// FLAC import) reject as reserved; 0 ("as STREAMINFO says") decodes everywhere
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
+10
-7
@@ -1,6 +1,6 @@
|
||||
//! Clean-room FLAC encoder (RFC 9639, Free Lossless Audio Codec).
|
||||
//!
|
||||
//! - [`Encoder`]: planar integer samples (8–24 bit, 1–8 channels) → a `.flac` stream: the
|
||||
//! - [`Encoder`]: planar integer samples (8–32 bit, 1–8 channels) → a `.flac` stream: the
|
||||
//! [`Encoder::header`] (`fLaC`, STREAMINFO, a VORBIS_COMMENT naming the encoder), then frames of
|
||||
//! a fixed block size. Each channel of each frame is coded as constant, verbatim, a fixed
|
||||
//! polynomial predictor or linear prediction, whichever is smallest, with partitioned Rice codes;
|
||||
@@ -61,7 +61,8 @@ pub struct EncoderConfig {
|
||||
pub sample_rate: u32,
|
||||
/// 1–8 (FLAC channel order: L R C LFE Ls Rs … for 5.1).
|
||||
pub channels: u8,
|
||||
/// 8–24.
|
||||
/// 8–32. At 32 bits stereo channels are coded independently: the side channel of
|
||||
/// left/side, side/right and mid/side coding would need 33 bits.
|
||||
pub bits_per_sample: u8,
|
||||
/// Samples per channel per frame, 16–65535 (4096 by default).
|
||||
pub block_size: u16,
|
||||
@@ -92,8 +93,8 @@ impl Encoder {
|
||||
if !(1..=8).contains(&cfg.channels) {
|
||||
return Err(Error::InvalidConfig("1 to 8 channels"));
|
||||
}
|
||||
if !(8..=24).contains(&cfg.bits_per_sample) {
|
||||
return Err(Error::InvalidConfig("8 to 24 bits per sample"));
|
||||
if !(8..=32).contains(&cfg.bits_per_sample) {
|
||||
return Err(Error::InvalidConfig("8 to 32 bits per sample"));
|
||||
}
|
||||
if !(1..=1_048_575).contains(&cfg.sample_rate) {
|
||||
return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz"));
|
||||
@@ -170,8 +171,9 @@ impl Encoder {
|
||||
if planar.iter().any(|c| c.len() != n) {
|
||||
return Err(Error::UnequalChannels);
|
||||
}
|
||||
let max = (1i32 << (self.cfg.bits_per_sample - 1)) - 1;
|
||||
if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| s > max || s < -max - 1) {
|
||||
// i64: at 32 bits the range is all of i32 (1 << 31 overflows i32)
|
||||
let max = (1i64 << (self.cfg.bits_per_sample - 1)) - 1;
|
||||
if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| i64::from(s) > max || i64::from(s) < -max - 1) {
|
||||
return Err(Error::SampleRange(bad));
|
||||
}
|
||||
for (p, c) in self.pending.iter_mut().zip(planar) {
|
||||
@@ -217,7 +219,8 @@ impl Encoder {
|
||||
let effort = self.effort;
|
||||
let code = |x: &[i32], b: u8| predict::choose(x, b, effort);
|
||||
let data = match block {
|
||||
[l, r] if effort.stereo => {
|
||||
// the side channel takes one bit more than the samples: at most 32 (i32)
|
||||
[l, r] if effort.stereo && bps < 32 => {
|
||||
let side: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| a - b).collect();
|
||||
let mid: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| (a + b) >> 1).collect();
|
||||
let (cl, cr, cs, cm) = (code(l, bps), code(r, bps), code(&side, bps + 1), code(&mid, bps));
|
||||
|
||||
@@ -24,7 +24,7 @@ impl Rng {
|
||||
/// Test signals at `bps`, full scale: music-like (two sines and noise), a sweep, white noise,
|
||||
/// a full-scale square, silence and a lone click.
|
||||
fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec<i32> {
|
||||
let max = f64::from((1i32 << (bps - 1)) - 1);
|
||||
let max = ((1i64 << (bps - 1)) - 1) as f64;
|
||||
let mut r = Rng(seed | 1);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
@@ -43,7 +43,7 @@ fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec<i32> {
|
||||
"click" => f64::from(u8::from(i == n / 2)),
|
||||
_ => 0.0,
|
||||
};
|
||||
((v * max).round() as i32).clamp(-(max as i32) - 1, max as i32)
|
||||
((v * max).round() as i64).clamp(-(max as i64) - 1, max as i64) as i32
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -93,13 +93,14 @@ fn round_trip(name: &str, planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize)
|
||||
}
|
||||
|
||||
// STREAMINFO: total samples, rate, and the MD5 against ffmpeg's hash of the decoded audio. FLAC
|
||||
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16
|
||||
// and 24 bit compare
|
||||
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16,
|
||||
// 24 and 32 bit compare
|
||||
let si = &file[STREAMINFO_OFFSET as usize..STREAMINFO_OFFSET as usize + 34];
|
||||
let pcm = match bps {
|
||||
8 => Some("s8"),
|
||||
16 => Some("s16le"),
|
||||
24 => Some("s24le"),
|
||||
32 => Some("s32le"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(pcm) = pcm {
|
||||
@@ -118,7 +119,7 @@ fn round_trip(name: &str, planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize)
|
||||
#[test]
|
||||
fn every_signal_round_trips_exactly() {
|
||||
for (kind, seed) in [("music", 1), ("sweep", 2), ("noise", 3), ("square", 4), ("silence", 5), ("click", 6)] {
|
||||
for bps in [16u8, 24] {
|
||||
for bps in [16u8, 24, 32] {
|
||||
let planar = vec![signal(kind, 20_000, bps, seed), signal(kind, 20_000, bps, seed + 100)];
|
||||
if round_trip(&format!("{kind}-{bps}"), &planar, EncoderConfig::new(48_000, 2, bps), 4800).is_none() {
|
||||
return;
|
||||
@@ -129,11 +130,13 @@ fn every_signal_round_trips_exactly() {
|
||||
|
||||
#[test]
|
||||
fn channel_layouts_rates_and_sizes() {
|
||||
let cases: [(&str, u8, u8, u32, usize); 6] = [
|
||||
let cases: [(&str, u8, u8, u32, usize); 8] = [
|
||||
("mono-8bit", 1, 8, 8_000, 3_001),
|
||||
("mono-24-44k1", 1, 24, 44_100, 9_999),
|
||||
("stereo-20bit", 2, 20, 96_000, 10_000),
|
||||
("5.1-24", 6, 24, 48_000, 12_345),
|
||||
("mono-32", 1, 32, 48_000, 7_777),
|
||||
("5.1-32", 6, 32, 96_000, 12_345),
|
||||
("odd-rate", 2, 16, 37_800, 5_000),
|
||||
("tiny", 2, 16, 48_000, 1),
|
||||
];
|
||||
@@ -167,7 +170,8 @@ fn levels_and_block_sizes_trade_time_for_size() {
|
||||
fn bad_input_is_an_error_not_a_panic() {
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 0, 16)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 9, 16)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 32)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 33)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 7)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig::new(0, 2, 16)).is_err());
|
||||
assert!(Encoder::new(EncoderConfig { block_size: 8, ..EncoderConfig::new(48_000, 2, 16) }).is_err());
|
||||
let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16)).unwrap();
|
||||
@@ -179,3 +183,21 @@ fn bad_input_is_an_error_not_a_panic() {
|
||||
assert!(enc.finish().is_empty());
|
||||
assert_eq!(&enc.header()[..4], b"fLaC");
|
||||
}
|
||||
|
||||
/// 32-bit stereo at the extremes of i32: left minus right would need 33 bits, so the encoder must
|
||||
/// not code a side channel (it overflowed `i32` and wrote garbage); every level decodes exactly.
|
||||
#[test]
|
||||
fn full_scale_32_bit_stereo_round_trips() {
|
||||
let n = 9_000;
|
||||
let left: Vec<i32> = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN }).collect();
|
||||
let right: Vec<i32> = left.iter().map(|&v| if v == i32::MAX { i32::MIN } else { i32::MAX }).collect();
|
||||
let planar = vec![left, right];
|
||||
for level in [0u8, 5, 8] {
|
||||
let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 32) };
|
||||
if round_trip(&format!("extremes-32-level{level}"), &planar, cfg, 4096).is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 32)).unwrap();
|
||||
assert!(enc.encode(&[&[i32::MIN, i32::MAX], &[0, -1]]).is_ok());
|
||||
}
|
||||
@@ -2922,6 +2922,10 @@
|
||||
Sample Size Tamaño de muestra
|
||||
16 bit 16 bits
|
||||
24 bit 24 bits
|
||||
32 bit 32 bits
|
||||
32 bit float 32 bits de coma flotante
|
||||
FLAC stores whole-number samples only FLAC solo guarda muestras enteras
|
||||
MXF export writes integer PCM only La exportación MXF solo escribe PCM entero
|
||||
Size / Opacity Tamaño / opacidad
|
||||
Image Overlay Superposición de imagen
|
||||
PNG or JPEG file Archivo PNG o JPEG
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -2922,6 +2922,10 @@
|
||||
Sample Size Taille d'échantillon
|
||||
16 bit 16 bits
|
||||
24 bit 24 bits
|
||||
32 bit 32 bits
|
||||
32 bit float 32 bits à virgule flottante
|
||||
FLAC stores whole-number samples only FLAC ne stocke que des échantillons entiers
|
||||
MXF export writes integer PCM only L’export MXF n’écrit que du PCM entier
|
||||
Size / Opacity Taille / opacité
|
||||
Image Overlay Superposition d'image
|
||||
PNG or JPEG file Fichier PNG ou JPEG
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 83.
|
@@ -2927,6 +2927,10 @@
|
||||
Sample Size Ukuran Sampel
|
||||
16 bit 16 bit
|
||||
24 bit 24 bit
|
||||
32 bit 32 bit
|
||||
32 bit float 32 bit titik mengambang
|
||||
FLAC stores whole-number samples only FLAC hanya menyimpan sampel bilangan bulat
|
||||
MXF export writes integer PCM only Ekspor MXF hanya menulis PCM bilangan bulat
|
||||
Size / Opacity Ukuran / Opasitas
|
||||
Image Overlay Overlay Gambar
|
||||
PNG or JPEG file Berkas PNG atau JPEG
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 14.
|
@@ -2911,6 +2911,10 @@
|
||||
Sample Size サンプルサイズ
|
||||
16 bit 16 ビット
|
||||
24 bit 24 ビット
|
||||
32 bit 32 ビット
|
||||
32 bit float 32 ビット浮動小数点
|
||||
FLAC stores whole-number samples only FLAC は整数サンプルのみ保存できます
|
||||
MXF export writes integer PCM only MXF 書き出しは整数 PCM のみです
|
||||
Size / Opacity サイズ / 不透明度
|
||||
Image Overlay 画像のオーバーレイ
|
||||
PNG or JPEG file PNG または JPEG ファイル
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 59.
|
@@ -2924,6 +2924,10 @@
|
||||
Sample Size Tamanho da amostra
|
||||
16 bit 16 bits
|
||||
24 bit 24 bits
|
||||
32 bit 32 bits
|
||||
32 bit float 32 bits de ponto flutuante
|
||||
FLAC stores whole-number samples only O FLAC só armazena amostras inteiras
|
||||
MXF export writes integer PCM only A exportação MXF só grava PCM inteiro
|
||||
Size / Opacity Tamanho / opacidade
|
||||
Image Overlay Sobreposição de imagem
|
||||
PNG or JPEG file Arquivo PNG ou JPEG
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 4.
|
@@ -2905,6 +2905,10 @@
|
||||
Sample Size 采样大小
|
||||
16 bit 16 位
|
||||
24 bit 24 位
|
||||
32 bit 32 位
|
||||
32 bit float 32 位浮点
|
||||
FLAC stores whole-number samples only FLAC 只能存储整数采样
|
||||
MXF export writes integer PCM only MXF 导出仅写入整数 PCM
|
||||
Size / Opacity 大小 / 不透明度
|
||||
Image Overlay 图像叠加
|
||||
PNG or JPEG file PNG 或 JPEG 文件
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -24,7 +24,7 @@ use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
|
||||
use filmcraft_engine::export::presets::{DEFAULT_PRESET, preset_key};
|
||||
use filmcraft_engine::export::{
|
||||
AudioCodec, BitrateMode, ExportSettings, ExrCompression, FieldOrder, Format, GpuRendering, H264Profile, HardwareEncoding, Multiplexer, MxfVideoCodec,
|
||||
Placement, Scaling, TextOverlay, builtin_presets, format_bytes,
|
||||
Placement, SampleFormat, Scaling, TextOverlay, builtin_presets, format_bytes,
|
||||
};
|
||||
use filmcraft_engine::time::{FrameRate, Tick};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -310,11 +310,22 @@ fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui
|
||||
|
||||
/// A combo box over `options` (label, enabled); returns the chosen index.
|
||||
fn combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, selected: &str, options: &[(String, bool)], width: f32) -> Option<usize> {
|
||||
let why = tl!("Not supported by FilmCraft's encoders yet");
|
||||
let options: Vec<(String, Option<&str>)> = options.iter().map(|(l, on)| (l.clone(), (!on).then_some(why))).collect();
|
||||
combo_why(ui, reg, id, selected, &options, width)
|
||||
}
|
||||
|
||||
/// [`combo`] whose options carry why they are greyed out (label, `Some(reason)` when disabled).
|
||||
fn combo_why(ui: &mut egui::Ui, reg: &mut Reg, id: &str, selected: &str, options: &[(String, Option<&str>)], width: f32) -> Option<usize> {
|
||||
let mut chosen = None;
|
||||
let r = egui::ComboBox::from_id_salt(id).selected_text(crate::i18n::t(selected)).width(width).show_ui(ui, |ui| {
|
||||
for (i, (label, enabled)) in options.iter().enumerate() {
|
||||
let resp = ui.add_enabled(*enabled, egui::Button::selectable(label == selected, crate::i18n::t(label)));
|
||||
let resp = if *enabled { resp } else { resp.on_disabled_hover_text(tl!("Not supported by FilmCraft's encoders yet")) };
|
||||
for (i, (label, why)) in options.iter().enumerate() {
|
||||
let enabled = why.is_none();
|
||||
let resp = ui.add_enabled(enabled, egui::Button::selectable(label == selected, crate::i18n::t(label)));
|
||||
let resp = match why {
|
||||
Some(why) => resp.on_disabled_hover_text(*why),
|
||||
None => resp,
|
||||
};
|
||||
reg.add(format!("{id}.option.{i}"), resp.rect, label.clone());
|
||||
if resp.clicked() {
|
||||
chosen = Some(i);
|
||||
@@ -902,10 +913,22 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
|
||||
});
|
||||
} else {
|
||||
row(ui, t, tl!("Sample Size"), |ui| {
|
||||
let sizes = [tl!("16 bit"), tl!("24 bit")];
|
||||
let cur = if s.audio.bits >= 24 { sizes[1] } else { sizes[0] };
|
||||
if let Some(i) = combo(ui, reg, "export.audio.bits", cur, &opts(&sizes), 120.0) {
|
||||
s.audio.bits = if i == 0 { 16 } else { 24 };
|
||||
// every size, the ones this codec and container cannot store greyed out; a size the
|
||||
// audio format no longer takes (float after switching to FLAC) becomes 32-bit integer
|
||||
if !s.supports_sample_format(s.audio.sample_format()) {
|
||||
s.audio.set_sample_format(SampleFormat::Int32);
|
||||
}
|
||||
let sizes = [tl!("16 bit"), tl!("24 bit"), tl!("32 bit"), tl!("32 bit float")];
|
||||
let why = |f: SampleFormat| match (s.supports_sample_format(f), s.audio_codec()) {
|
||||
(true, _) => None,
|
||||
(false, AudioCodec::Flac) => Some(tl!("FLAC stores whole-number samples only")),
|
||||
(false, _) if s.format.is_mxf() => Some(tl!("MXF export writes integer PCM only")),
|
||||
(false, _) => Some(tl!("Not supported by FilmCraft's encoders yet")),
|
||||
};
|
||||
let labels: Vec<(String, Option<&str>)> = SampleFormat::ALL.iter().zip(sizes).map(|(&f, l)| (l.to_string(), why(f))).collect();
|
||||
let cur = SampleFormat::ALL.iter().position(|&f| f == s.audio.sample_format()).and_then(|i| sizes.get(i).copied()).unwrap_or(sizes[0]);
|
||||
if let Some(&f) = combo_why(ui, reg, "export.audio.bits", cur, &labels, 120.0).and_then(|i| SampleFormat::ALL.get(i)) {
|
||||
s.audio.set_sample_format(f);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -441,3 +441,72 @@ fn export_mode_file_name_can_be_emptied_and_retyped() {
|
||||
d.frames(2);
|
||||
assert_eq!(d.app().ui.export.file_name, "Final");
|
||||
}
|
||||
|
||||
/// Sample Size lists every size (16, 24, 32 bit and 32 bit float) the way Audio Format lists every
|
||||
/// codec: what the chosen audio format and container cannot store is shown greyed out and cannot
|
||||
/// be picked, and a size that stops being available (float after switching to FLAC) becomes
|
||||
/// 32-bit integer.
|
||||
#[test]
|
||||
fn sample_size_lists_every_size_and_greys_out_what_the_format_cannot_store() {
|
||||
use filmcraft_engine::export::{AudioCodec, ExportSettings, Format, SampleFormat};
|
||||
let mut d = Driver::new("sample-size");
|
||||
d.ok("ui.set", json!({"mode": "export"}));
|
||||
d.frames(4);
|
||||
// collapse Video so the Audio section sits under the format
|
||||
d.click("export.section.video");
|
||||
d.app().ui.export.settings = ExportSettings { format: Format::Wav, ..Default::default() };
|
||||
d.frames(4);
|
||||
let sizes = ["16 bit", "24 bit", "32 bit", "32 bit float"];
|
||||
let open_sizes = |d: &mut Driver| {
|
||||
if !d.has("export.audio.bits.option.0") {
|
||||
d.click("export.audio.bits");
|
||||
}
|
||||
for (i, label) in sizes.iter().enumerate() {
|
||||
let id = format!("export.audio.bits.option.{i}");
|
||||
let e = d.ok("ui.elements", json!({"prefix": id}));
|
||||
assert_eq!(e.as_array().and_then(|a| a.iter().find(|e| e["id"] == json!(id))).map(|e| e["label"].clone()), Some(json!(label)), "{id}");
|
||||
}
|
||||
};
|
||||
|
||||
// WAV: all four; pick 32 bit float
|
||||
open_sizes(&mut d);
|
||||
d.snapshot("export-sample-size-wav");
|
||||
d.click("export.audio.bits.option.3");
|
||||
assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Float32);
|
||||
|
||||
// FLAC: float is greyed out, so the float choice becomes 32-bit integer and clicking float
|
||||
// changes nothing
|
||||
d.app().ui.export.settings.format = Format::Flac;
|
||||
d.frames(4);
|
||||
assert_eq!(d.app().ui.export.settings.audio_codec(), AudioCodec::Flac);
|
||||
assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Int32);
|
||||
open_sizes(&mut d);
|
||||
d.snapshot("export-sample-size-flac");
|
||||
d.click("export.audio.bits.option.3");
|
||||
assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Int32, "32 bit float is not available for FLAC");
|
||||
if d.has("export.audio.bits.option.0") {
|
||||
d.click("export.audio.bits");
|
||||
}
|
||||
d.click("export.audio.bits");
|
||||
d.click("export.audio.bits.option.1");
|
||||
assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Int24);
|
||||
|
||||
// MXF: integer PCM only
|
||||
d.app().ui.export.settings = ExportSettings { format: Format::MxfOp1a, ..Default::default() };
|
||||
d.frames(4);
|
||||
open_sizes(&mut d);
|
||||
d.snapshot("export-sample-size-mxf");
|
||||
d.click("export.audio.bits.option.3");
|
||||
assert!(!d.app().ui.export.settings.audio.float, "32 bit float is not available for MXF");
|
||||
if d.has("export.audio.bits.option.0") {
|
||||
d.click("export.audio.bits");
|
||||
}
|
||||
|
||||
// QuickTime PCM (ProRes): all four again
|
||||
d.app().ui.export.settings = ExportSettings { format: Format::ProRes, ..Default::default() };
|
||||
d.frames(4);
|
||||
open_sizes(&mut d);
|
||||
d.click("export.audio.bits.option.3");
|
||||
assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Float32);
|
||||
d.snapshot("export-sample-size-prores");
|
||||
}
|
||||
@@ -668,11 +668,11 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
|
||||
| `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) |
|
||||
| `mjpeg` | built in | MOV |
|
||||
| `aac` | `filmcraft-aac` (AAC-LC, the audio settings' bitrate) | audio only: MPEG-4 audio `.m4a` (`isobmff`, priming in an edit list), or raw ADTS when the path ends in `.aac` |
|
||||
| `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) |
|
||||
| `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24/32-bit integer | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) |
|
||||
| `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`<stem>_A1.mxf` …, `Report::extra_files`) |
|
||||
| `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
|
||||
| `tga`, `dpx`, `exr` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268; OpenEXR half-float linear RGB(A), uncompressed scan lines) | numbered stills, as above |
|
||||
| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) |
|
||||
| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1; `WAVE_FORMAT_IEEE_FLOAT` + `fact` for float) / AIFF (16-, 24- or 32-bit PCM; 32-bit float as AIFF-C `fl32`) |
|
||||
|
||||
- **Reproducible.** The same project and settings give the same file on every machine. Frames render
|
||||
in batches of one per core, but the muxer gets them in fixed groups of 16 output frames
|
||||
@@ -682,7 +682,9 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
|
||||
- **Settings.** `ExportSettings` (serde, camelCase, every field optional) holds Video (frame size or
|
||||
Match Source, frame rate, Scale to Fit / Fill / Stretch, pixel aspect, field order — progressive
|
||||
only, profile / level, bitrate encoding, target / maximum / adaptive bitrate, keyframe distance,
|
||||
maximum render quality), Audio (codec, sample rate, channels, AAC bitrate, PCM sample size),
|
||||
maximum render quality), Audio (codec, sample rate, channels, AAC bitrate, PCM / FLAC sample size: `bits` 16, 24 or 32
|
||||
and `float` for 32-bit float PCM in WAV, AIFF and QuickTime — `supports_sample_format` says
|
||||
which a codec and container store, and Export mode greys out the rest),
|
||||
Multiplexer (audio channels: mono, stereo or 5.1, §5.1.1), Captions (burn-in or SRT / WebVTT sidecar), Effects (image / name / timecode
|
||||
overlays, video limiter, loudness normalization), Metadata (`udta` `©nam`, `©ART`, `©cpy`, `©des`,
|
||||
`©cmt`). `settings.summary()` / `estimate_bytes()` feed Export mode's Summary.
|
||||
|
||||
Reference in new issue
Block a user