From 303056eec476e03d7502e0695f8275bfe96924b5 Mon Sep 17 00:00:00 2001 From: heojunfo Date: Fri, 9 Oct 2026 15:33:04 +0900 Subject: [PATCH] Export: AIFF 32-bit float writes AIFF-C fl32 instead of 24-bit (#317) The Output Module offers 32 Bit Float for AIFF and the queue reports F32, but audio_file silently wrote 24-bit PCM because plain AIFF has no float. Write an AIFF-C file instead: FORM type AIFC, an FVER chunk (AIFC Version 1), and a COMM chunk naming the fl32 compression type, with big-endian IEEE float samples in SSND. 16- and 24-bit stay plain AIFF. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/export/src/lib.rs | 2 +- crates/export/src/webm.rs | 33 +++++++++++++++++++------- crates/export/tests/render_settings.rs | 19 ++++++++++++++- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 81e0f5c2..711e1c8b 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -10,7 +10,7 @@ //! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0; or AV1 (`effectcraft-av1enc`) | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`: SILK / hybrid / CELT by bitrate and application), 48 kHz | //! | HEVC | MP4 (`hvc1`) | `effectcraft-hevcenc` Main / Main 10 4:2:0, I + P slices, bitrate or constant quality, level, key-frame interval | no | AAC-LC (FilmCraft) | //! | AV1 | MP4 (`av01`) | `effectcraft-av1enc` Main 8/10-bit 4:2:0, key + inter frames | no | AAC-LC (FilmCraft) | -//! | WAV / AIFF | RIFF WAVE / AIFF | — | — | PCM 16/24-bit (WAV also 32-bit float), mono/stereo (audio only) | +//! | WAV / AIFF | RIFF WAVE / AIFF (AIFF-C for float) | — | — | PCM 16/24-bit or 32-bit float, mono/stereo (audio only) | //! //! Every frame goes through [`pipeline`]: the Render Settings overrides (Effects, Solo Switches, //! Guide Layers, Frame Blending, Motion Blur, Color Depth), field rendering (two fields half a diff --git a/crates/export/src/webm.rs b/crates/export/src/webm.rs index 98993b9e..f3a1a18d 100644 --- a/crates/export/src/webm.rs +++ b/crates/export/src/webm.rs @@ -9,8 +9,8 @@ //! frames every two seconds, inter frames between them: SimpleBlocks carry the key flag, inter //! BlockGroups a ReferenceBlock; the alpha stream's key frames line up with the colour stream's), //! Opus from `effectcraft-opusenc` (CELT, 48 kHz stereo, 20 ms packets, -//! `CodecDelay` = pre-skip). WAV (RIFF, 16-bit PCM) and AIFF (big-endian 16-bit, 80-bit -//! extended sample rate) follow their published file layouts. +//! `CodecDelay` = pre-skip). WAV (RIFF, PCM or IEEE float) and AIFF (big-endian PCM, 80-bit +//! extended sample rate; AIFF-C `fl32` for float) follow their published file layouts. use std::io::{Seek, SeekFrom, Write}; @@ -322,14 +322,11 @@ fn ieee_extended(v: f64) -> [u8; 10] { } /// WAV or AIFF: the comp's audio over the render span (Audio Output: sample rate, mono/stereo, -/// 16/24-bit integer or 32-bit float; AIFF has no float in plain AIFF, so it writes 24-bit). +/// 16/24-bit integer or 32-bit float; plain AIFF has no float, so 32-bit float is AIFF-C `fl32`). pub(crate) fn audio_file(job: &Cx, comp: &Comp, aiff: bool, st: &mut State) -> Result { let sr = job.output.audio_sample_rate.clamp(8_000, 192_000); let channels = if job.output.audio_channels == 1 { 1u16 } else { 2 }; - let fmt = match job.output.audio_format { - AudioFormat::F32 if aiff => AudioFormat::S24, - f => f, - }; + let fmt = job.output.audio_format; let bytes_per_sample: u16 = match fmt { AudioFormat::S16 => 2, AudioFormat::S24 => 3, @@ -363,15 +360,33 @@ pub(crate) fn audio_file(job: &Cx, comp: &Comp, aiff: bool, st: &mut State) -> R let frames = (pcm.len() / block_align as usize) as u32; let mut out = vec![]; if aiff { + // AIFF-C (Apple, 1991) for float: a format version chunk, and the COMM chunk names the + // compression type and its Pascal-string name, padded to an even length. + let float = fmt == AudioFormat::F32; let mut comm = vec![]; comm.extend_from_slice(&channels.to_be_bytes()); comm.extend_from_slice(&frames.to_be_bytes()); comm.extend_from_slice(&(bytes_per_sample * 8).to_be_bytes()); comm.extend_from_slice(&ieee_extended(sr as f64)); + let mut fver = vec![]; + if float { + const NAME: &[u8] = b"32-bit floating point"; + comm.extend_from_slice(b"fl32"); + comm.push(NAME.len() as u8); + comm.extend_from_slice(NAME); + if (1 + NAME.len()) % 2 == 1 { + comm.push(0); + } + fver.extend_from_slice(b"FVER"); + fver.extend_from_slice(&4u32.to_be_bytes()); + // AIFC Version 1 (May 23, 1990, 2:40 pm). + fver.extend_from_slice(&0xA280_5140u32.to_be_bytes()); + } let ssnd_len = 8 + pcm.len(); out.extend_from_slice(b"FORM"); - out.extend_from_slice(&((4 + 8 + comm.len() + 8 + ssnd_len + ssnd_len % 2) as u32).to_be_bytes()); - out.extend_from_slice(b"AIFF"); + out.extend_from_slice(&((4 + fver.len() + 8 + comm.len() + 8 + ssnd_len + ssnd_len % 2) as u32).to_be_bytes()); + out.extend_from_slice(if float { b"AIFC" } else { b"AIFF" }); + out.extend_from_slice(&fver); out.extend_from_slice(b"COMM"); out.extend_from_slice(&(comm.len() as u32).to_be_bytes()); out.extend_from_slice(&comm); diff --git a/crates/export/tests/render_settings.rs b/crates/export/tests/render_settings.rs index dfa62a7f..db1caca5 100644 --- a/crates/export/tests/render_settings.rs +++ b/crates/export/tests/render_settings.rs @@ -319,11 +319,28 @@ fn wav_and_aiff_channels_and_formats() { let d = files.lock().unwrap()[0].1.clone(); let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]); assert_eq!((u16_at(20), u16_at(22), u16_at(32), u16_at(34)), (3, 2, 8, 32), "IEEE float stereo"); - // AIFF has no float: 24-bit. + // Plain AIFF has no float: 32-bit float is AIFF-C with `fl32` samples (#317). let mut aiff = OutputModule::for_format(OutputFormat::Aiff); aiff.audio_format = AudioFormat::F32; let files = run(&p, cid, &s, &aiff, "/w/c.aif"); let d = files.lock().unwrap()[0].1.clone(); + let u32_at = |o: usize| u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]); + assert_eq!((&d[..4], u32_at(4) as usize + 8, &d[8..12]), (&b"FORM"[..], d.len(), &b"AIFC"[..])); + assert_eq!((&d[12..16], u32_at(16), u32_at(20)), (&b"FVER"[..], 4, 0xA280_5140)); + assert_eq!(&d[24..28], b"COMM"); + let comm = 32; + assert_eq!(u32_at(28) as usize, 18 + 4 + 22, "COMM: AIFF fields, compression type, name"); + assert_eq!(u16::from_be_bytes([d[comm], d[comm + 1]]), 2, "stereo"); + assert_eq!(u32_at(comm + 2), 24_000, "half a second at 48 kHz"); + assert_eq!(u16::from_be_bytes([d[comm + 6], d[comm + 7]]), 32, "sample size"); + assert_eq!(&d[comm + 18..comm + 22], b"fl32"); + assert_eq!(&d[comm + 22..comm + 44], b"\x1532-bit floating point"); + let ssnd = comm + 44; + assert_eq!((&d[ssnd..ssnd + 4], u32_at(ssnd + 4) as usize), (&b"SSND"[..], 8 + 24_000 * 2 * 4)); + // 16- and 24-bit stay plain AIFF. + aiff.audio_format = AudioFormat::S24; + let files = run(&p, cid, &s, &aiff, "/w/d.aif"); + let d = files.lock().unwrap()[0].1.clone(); assert_eq!(&d[8..12], b"AIFF"); assert_eq!(u16::from_be_bytes([d[26], d[27]]), 24, "sample size"); }