diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs index 5c26ea529..fe339700f 100644 --- a/crates/export/src/job.rs +++ b/crates/export/src/job.rs @@ -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()))?) } }; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 66905c991..ebea2550e 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -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 { - 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]) -> Result>> { - let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).collect()).collect(); + let ints: Vec> = 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, 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, 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, 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> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).collect()).collect(); + let ints: Vec> = 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, 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 diff --git a/crates/export/src/mxf_out.rs b/crates/export/src/mxf_out.rs index ecd8ede23..a7adabb76 100644 --- a/crates/export/src/mxf_out.rs +++ b/crates/export/src/mxf_out.rs @@ -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); diff --git a/crates/export/src/pcm.rs b/crates/export/src/pcm.rs index 7601886f0..96f1300ff 100644 --- a/crates/export/src/pcm.rs +++ b/crates/export/src/pcm.rs @@ -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 { - frames.checked_mul(u64::from(channels))?.checked_mul(u64::from(sample_bits(bits) / 8)) +fn data_len(frames: u64, channels: u16, format: SampleFormat) -> Option { + 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, String> { +pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, format: SampleFormat, frames: u64) -> Result, 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) { - 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) { + 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, String> { +fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result, 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, String> { - write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, bits) +pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result, 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, 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, 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 = 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 = (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"]) diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index a59f481e1..ab954adc9 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -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() diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index b2ada9813..d0b7a86a6 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -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::>() + }; + 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::>() + }; + 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 = (0..n) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 32) as u32 as i32 + }) + .collect(); + let extremes: Vec = (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}"); + } + } + } +} diff --git a/crates/flac/README.md b/crates/flac/README.md index 3909ab793..258651ce0 100644 --- a/crates/flac/README.md +++ b/crates/flac/README.md @@ -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 | |---|---|---|---| diff --git a/crates/flac/src/frame.rs b/crates/flac/src/frame.rs index 6ccd8a50f..285658bf3 100644 --- a/crates/flac/src/frame.rs +++ b/crates/flac/src/frame.rs @@ -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, } } diff --git a/crates/flac/src/lib.rs b/crates/flac/src/lib.rs index 5e278365b..0844b7c3c 100644 --- a/crates/flac/src/lib.rs +++ b/crates/flac/src/lib.rs @@ -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 = l.iter().zip(r).map(|(&a, &b)| a - b).collect(); let mid: Vec = 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)); diff --git a/crates/flac/tests/ffmpeg_oracle.rs b/crates/flac/tests/ffmpeg_oracle.rs index 80a0c7538..d3ebeff52 100644 --- a/crates/flac/tests/ffmpeg_oracle.rs +++ b/crates/flac/tests/ffmpeg_oracle.rs @@ -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 { - 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 { "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], 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], 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 = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN }).collect(); + let right: Vec = 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()); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 25a23480d..d5c4e4579 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4385c0dd5..6d55d3094 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1c44965f4..b8c160eca 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 13673e6cf..34bf689ce 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 ファイル diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 17498760e..9b9edaa40 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 7459caa13..16d6114b7 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -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 文件 diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 0716f7ad9..9dac65003 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -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(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 { + 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 { 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); } }); } diff --git a/crates/ui-egui/tests/export_ui.rs b/crates/ui-egui/tests/export_ui.rs index 5739290d9..6a3375259 100644 --- a/crates/ui-egui/tests/export_ui.rs +++ b/crates/ui-egui/tests/export_ui.rs @@ -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"); +} diff --git a/docs/architecture.md b/docs/architecture.md index e98ed51d1..f8995621b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 (`_A1.mxf` …, `Report::extra_files`) | | `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `000.`, `001.` … | | `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.