diff --git a/crates/audio/README.md b/crates/audio/README.md index c1f7dcef..7853f11b 100644 --- a/crates/audio/README.md +++ b/crates/audio/README.md @@ -264,7 +264,7 @@ match craft_audio::decode(&bytes, None) { `WAVEFORMATEXTENSIBLE_CHANNEL_MASK` comment. This includes the usual layouts for 5 and 6 channels: without the comment, back surrounds are read as side surrounds. It includes the usual 7-channel layout too: without the comment, its back left, back right and back centre are read as back - centre, side left and side right. + centre, side left and side right. craft-audio reads the comment in FLAC files, not in FLAC in Ogg. - FLAC at 32 bits codes stereo channels independently, because a side channel would need 33 bits. - Outside RFC 9639's streamable subset (§7), which some decoders require: FLAC at 32 bits, at a sample rate the frame header cannot name, or with a layout that needs the mask comment. diff --git a/crates/audio/src/layout.rs b/crates/audio/src/layout.rs index e4028f0e..5cd054ca 100644 --- a/crates/audio/src/layout.rs +++ b/crates/audio/src/layout.rs @@ -263,6 +263,19 @@ impl std::fmt::Display for ChannelLayout { } } +/// The layout decoders assume for 1–8 FLAC channels with no channel mask comment, as a WAV mask. +/// RFC 9639 §9.1.3 leaves "back/surround" open for 5 and 6 channels; libFLAC and ffmpeg read side +/// surrounds. 7 channels are 6.1 with sides (FL FR FC LFE BC SL SR). +#[cfg(any(feature = "symphonia", feature = "flac-encode"))] +pub(crate) fn flac_assumed_mask(channels: usize) -> u32 { + match channels { + 5 => 0x607, + 6 => 0x60F, + 7 => 0x70F, + n => ChannelLayout::default_for(n as u16).mask().unwrap_or(0), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/audio/src/read/flac.rs b/crates/audio/src/read/flac.rs index 896150e2..ad4f4d88 100644 --- a/crates/audio/src/read/flac.rs +++ b/crates/audio/src/read/flac.rs @@ -67,16 +67,66 @@ pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option { 17..=24 => SampleFormat::Int24, _ => SampleFormat::Int32, }; - Some(AudioInfo::new(Container::Flac, CodecId::Flac, sample_format, sample_rate, flac_layout(usize::from(channels)), 0)) + Some(AudioInfo::new(Container::Flac, CodecId::Flac, sample_format, sample_rate, layout_of(b, at, channels), 0)) } -/// The FLAC channel assignment for a channel count (RFC 9639 §9.1.3). -pub(crate) fn flac_layout(channels: usize) -> ChannelLayout { - match channels { - 7 => ChannelLayout::SURROUND_6_1, - 1..=8 => ChannelLayout::default_for(crate::layout::count(channels)), - n => ChannelLayout::unordered(crate::layout::count(n)), +/// The layout of the FLAC stream at `at`, from its STREAMINFO channel count and its channel mask +/// comment, or `None` when it does not start with a STREAMINFO block. +pub(crate) fn layout(b: &[u8], at: usize) -> Option { + let head = be_u32(b, at.checked_add(4)?)?; + if (head >> 24) & 0x7f != 0 { + return None; } + // In STREAMINFO: 3 bits of channels - 1 after the 20 bits of rate. + let channels = u16::from((b.get(at.checked_add(4 + 4 + 10 + 2)?)? >> 1) & 0x7) + 1; + Some(layout_of(b, at, channels)) +} + +/// `channels` channels described by the channel mask comment of the stream at `at`, or else in +/// the layout decoders assume for the count (RFC 9639 §8.6.2, §9.1.3). +fn layout_of(b: &[u8], at: usize, channels: u16) -> ChannelLayout { + let mask = channel_mask(b, at).unwrap_or_else(|| crate::layout::flac_assumed_mask(usize::from(channels))); + ChannelLayout::from_mask_and_channels(mask, channels) +} + +/// The value of the first `WAVEFORMATEXTENSIBLE_CHANNEL_MASK` comment that holds a `0x` hexadecimal +/// number, in the stream at `at`. +fn channel_mask(b: &[u8], at: usize) -> Option { + let mut pos = at.checked_add(4)?; + loop { + let head = be_u32(b, pos)?; + let (body, len) = (pos.checked_add(4)?, (head & 0x00ff_ffff) as usize); + let end = body.checked_add(len)?.min(b.len()); + if (head >> 24) & 0x7f == 4 { + return comments_mask(b.get(body..end)?); + } + if head >> 31 == 1 { + return None; + } + pos = body.checked_add(len)?; + } +} + +/// The channel mask in a Vorbis comment block's body. +fn comments_mask(block: &[u8]) -> Option { + let vendor = le_u32(block, 0)? as usize; + let mut at = 4usize.checked_add(vendor)?; + let count = le_u32(block, at)?; + at += 4; + for _ in 0..count { + let len = le_u32(block, at)? as usize; + let comment = block.get(at + 4..(at + 4).checked_add(len)?)?; + at += 4 + len; + let Some(eq) = comment.iter().position(|&c| c == b'=') else { continue }; + if !comment[..eq].eq_ignore_ascii_case(b"WAVEFORMATEXTENSIBLE_CHANNEL_MASK") { + continue; + } + let hex = comment[eq + 1..].strip_prefix(b"0x").or_else(|| comment[eq + 1..].strip_prefix(b"0X")); + if let Some(mask) = hex.and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u32::from_str_radix(h, 16).ok()) { + return Some(mask); + } + } + None } /// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds. @@ -102,3 +152,42 @@ fn overruns(b: &[u8], mut at: usize, end: usize, read: fn(&[u8], usize) -> Optio } false } + +#[cfg(test)] +mod tests { + use super::comments_mask; + + fn block(comments: &[&str]) -> Vec { + let mut b = 3u32.to_le_bytes().to_vec(); + b.extend_from_slice(b"abc"); + b.extend_from_slice(&(comments.len() as u32).to_le_bytes()); + for c in comments { + b.extend_from_slice(&(c.len() as u32).to_le_bytes()); + b.extend_from_slice(c.as_bytes()); + } + b + } + + #[test] + fn the_channel_mask_comment_is_found_in_any_case() { + assert_eq!(comments_mask(&block(&["TITLE=x", "WAVEFORMATEXTENSIBLE_CHANNEL_MASK=0x0107"])), Some(0x107)); + assert_eq!(comments_mask(&block(&["waveformatextensible_channel_mask=0X3f"])), Some(0x3F)); + assert_eq!(comments_mask(&block(&["TITLE=x"])), None); + } + + #[test] + fn a_bad_channel_mask_comment_is_ignored() { + for value in ["63", "0x", "0xZZ", "0x1FFFFFFFF"] { + assert_eq!(comments_mask(&block(&[&format!("WAVEFORMATEXTENSIBLE_CHANNEL_MASK={value}")])), None, "{value}"); + } + assert_eq!(comments_mask(&block(&["WAVEFORMATEXTENSIBLE_CHANNEL_MASK=bad", "WAVEFORMATEXTENSIBLE_CHANNEL_MASK=0x3"])), Some(3)); + } + + #[test] + fn a_truncated_comment_block_has_no_mask() { + let whole = block(&["WAVEFORMATEXTENSIBLE_CHANNEL_MASK=0x3"]); + for cut in 0..whole.len() { + assert_eq!(comments_mask(&whole[..cut]), None, "{cut}"); + } + } +} diff --git a/crates/audio/src/read/symph.rs b/crates/audio/src/read/symph.rs index f53aca1b..a27d2308 100644 --- a/crates/audio/src/read/symph.rs +++ b/crates/audio/src/read/symph.rs @@ -81,9 +81,10 @@ fn guarded(f: impl FnOnce() -> Result) -> Result { catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into()))) } -/// What [`open`] found: a reader, or a stream that holds no audio frames and has no reader. +/// What [`open`] found: a reader (with the layout of a FLAC stream), or a stream that holds no +/// audio frames and has no reader. enum Opened { - Reader(Box), + Reader(Box, Option), Empty(AudioInfo), } @@ -124,6 +125,8 @@ enum Precheck { Empty(AudioInfo), /// The size checks ran for the container the probe reports under this short name. Checked(&'static str), + /// As `Checked`, for a FLAC stream with this layout, which symphonia's reader does not parse. + Flac(&'static str, Option), /// No container size check was needed. Unchecked, } @@ -330,7 +333,7 @@ fn precheck(b: &[u8], start: usize, detected: Option) -> Result { super::caf::check(b, at)?; @@ -356,10 +359,11 @@ fn open(bytes: Cow<'_, [u8]>, detected: Option) -> Result { if start > 0 && start >= bytes.len() { return Err(AudioError::Malformed("ID3v2 tag extends past the end of the file".into())); } - let checked = precheck(&bytes, start, detected)?; - if let Precheck::Empty(info) = checked { - return Ok(Opened::Empty(info)); - } + let (checked, flac_layout) = match precheck(&bytes, start, detected)? { + Precheck::Empty(info) => return Ok(Opened::Empty(info)), + Precheck::Flac(expected, layout) => (Precheck::Checked(expected), layout), + checked => (checked, None), + }; // symphonia reads from an owned stream: a borrowed buffer is copied once, a buffer read from // a shared source is moved in. let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.into_owned())), MediaSourceStreamOptions::default()); @@ -381,7 +385,7 @@ fn open(bytes: Cow<'_, [u8]>, detected: Option) -> Result { } _ => {} } - Ok(Opened::Reader(reader)) + Ok(Opened::Reader(reader, flac_layout.filter(|_| probed == "flac"))) } /// The codec symphonia reports, as a [`CodecId`]. @@ -543,8 +547,8 @@ impl Symph { /// Open audio track `track` (the first when `None`) of the file in `bytes`. pub(crate) fn open(bytes: Cow<'_, [u8]>, detected: Option, track: Option) -> Result { guarded(move || { - let reader = match open(bytes, detected)? { - Opened::Reader(reader) => reader, + let (reader, flac_layout) = match open(bytes, detected)? { + Opened::Reader(reader, layout) => (reader, layout), Opened::Empty(info) => { // a stream with no audio still has one track, as WAV and AIFF do if let Some(n) = track.filter(|&n| n > 0) { @@ -566,7 +570,10 @@ impl Symph { }); } }; - let (track_id, info) = info_of(reader.as_ref(), detected, track)?; + let (track_id, mut info) = info_of(reader.as_ref(), detected, track)?; + if let Some(layout) = flac_layout.filter(|l| l.channels() == info.channels) { + info.layout = layout; + } let t = reader.tracks().iter().find(|t| t.id == track_id).ok_or_else(|| AudioError::Malformed("track vanished".into()))?; let params = audio_params(t).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?; let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0)); @@ -759,7 +766,7 @@ impl Symph { pub(crate) fn tracks(bytes: Cow<'_, [u8]>, detected: Option) -> Result> { guarded(move || { let reader = match open(bytes, detected)? { - Opened::Reader(reader) => reader, + Opened::Reader(reader, _) => reader, Opened::Empty(info) => return Ok(vec![(info.codec, info.sample_rate, info.channels)]), }; Ok(audio_tracks(reader.as_ref()) diff --git a/crates/audio/src/write/flac/mod.rs b/crates/audio/src/write/flac/mod.rs index 5ada9ad0..720b59b4 100644 --- a/crates/audio/src/write/flac/mod.rs +++ b/crates/audio/src/write/flac/mod.rs @@ -39,23 +39,11 @@ fn check(channels: usize, sample_rate: u32, depth: BitDepth) -> Result { /// Frames quantized and handed to the encoder at a time: its block size. const CHUNK: usize = 4096; -/// The layout decoders assume for 1–8 channels with no channel mask comment, as a WAV mask. RFC -/// 9639 §9.1.3 leaves "back/surround" open for 5 and 6 channels; libFLAC and ffmpeg read side -/// surrounds. 7 channels are 6.1 with sides (FL FR FC LFE BC SL SR). -fn assumed_mask(channels: usize) -> u32 { - match channels { - 5 => 0x607, - 6 => 0x60F, - 7 => 0x70F, - n => ChannelLayout::default_for(n as u16).mask().unwrap_or(0), - } -} - /// The `WAVEFORMATEXTENSIBLE_CHANNEL_MASK` comment (RFC 9639 §8.6.2) for a layout decoders would /// not assume: `mask` (the usual layout for the count when `None`). fn mask_comment(channels: usize, mask: Option) -> Option { let mask = mask.unwrap_or_else(|| ChannelLayout::default_for(channels as u16).mask().unwrap_or(0)); - (mask != assumed_mask(channels)).then(|| format!("WAVEFORMATEXTENSIBLE_CHANNEL_MASK=0x{mask:04X}")) + (mask != crate::layout::flac_assumed_mask(channels)).then(|| format!("WAVEFORMATEXTENSIBLE_CHANNEL_MASK=0x{mask:04X}")) } fn encoder_error(e: encoder::Error) -> AudioError { @@ -150,12 +138,9 @@ impl Writer { pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool, mask: Option) -> Result> { validate_buffer(buf)?; - let bps = check(buf.channels.len(), buf.sample_rate, depth)?; + check(buf.channels.len(), buf.sample_rate, depth)?; let frames = buf.frames(); - let mut out = Vec::new(); - out.try_reserve(frames.saturating_mul(buf.channels.len()).saturating_mul(usize::from(bps) / 8) / 2) - .map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?; - let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither, mask)?; + let mut w = Writer::new(Cursor::new(Vec::new()), buf.channels.len(), buf.sample_rate, depth, dither, mask)?; w.write(&buf.channels, frames)?; Ok(w.finish()?.into_inner()) } diff --git a/crates/audio/src/write/mod.rs b/crates/audio/src/write/mod.rs index 9492d5e1..eaad8e96 100644 --- a/crates/audio/src/write/mod.rs +++ b/crates/audio/src/write/mod.rs @@ -26,6 +26,7 @@ use std::io::{Seek, Write}; /// # } /// ``` pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result> { + check_built(opts.format)?; // Checked for every format; WAV and FLAC record it. let mask = opts.layout.map(|l| wav_mask(&l, buf.channels.len())).transpose()?; match opts.format { @@ -36,15 +37,15 @@ pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result> { #[cfg(feature = "flac-encode")] EncodeFormat::Flac => flac::encode(buf, opts.bit_depth, opts.dither, mask), #[allow(unreachable_patterns)] - f => Err(not_built(f)), + f => Err(AudioError::Unsupported(format!("writing {f:?}"))), } } -/// The error for a format whose writer is not built. -#[allow(dead_code)] -fn not_built(format: EncodeFormat) -> AudioError { - let feature = if format == EncodeFormat::Flac { "flac-encode" } else { "pcm-encode" }; - AudioError::Unsupported(format!("writing {format:?} needs the `{feature}` feature")) +/// Refuses a format whose writer is not built, before anything else about the request is checked. +fn check_built(format: EncodeFormat) -> Result<()> { + let (built, feature) = + if format == EncodeFormat::Flac { (cfg!(feature = "flac-encode"), "flac-encode") } else { (cfg!(feature = "pcm-encode"), "pcm-encode") }; + if built { Ok(()) } else { Err(AudioError::Unsupported(format!("writing {format:?} needs the `{feature}` feature"))) } } /// The file extensions of the formats [`encode`] can write (those whose features are on). @@ -150,6 +151,7 @@ impl FileEncoder { /// Starts a file of `channels` channels at `sample_rate` at the current position of `out`. /// `opts.layout`, when set, must have `channels` channels. pub fn new(out: W, opts: &EncodeOptions, sample_rate: u32, channels: u16) -> Result { + check_built(opts.format)?; let (depth, dither, channels) = (opts.bit_depth, opts.dither, usize::from(channels)); let mask = opts.layout.map(|l| wav_mask(&l, channels)).transpose()?; let writer = match opts.format { @@ -167,7 +169,7 @@ impl FileEncoder { #[cfg(feature = "flac-encode")] EncodeFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither, mask)?), #[allow(unreachable_patterns)] - f => return Err(not_built(f)), + f => return Err(AudioError::Unsupported(format!("writing {f:?}"))), }; Ok(FileEncoder { writer, channels }) } @@ -259,5 +261,29 @@ mod tests { let enc = FileEncoder::new(std::io::Cursor::new(Vec::new()), &opts, 48_000, 1); assert!(matches!(enc, Err(AudioError::Unsupported(_)))); assert_eq!(encode_format_for(name), None); + // Also with a layout it could not use. + let opts = opts.with_layout(ChannelLayout::STEREO); + assert!(matches!(encode(&buf, &opts), Err(AudioError::Unsupported(_)))); + let enc = FileEncoder::new(std::io::Cursor::new(Vec::new()), &opts, 48_000, 1); + assert!(matches!(enc, Err(AudioError::Unsupported(_)))); + } + + /// Streaming in any block sizes writes the bytes `encode` writes. + #[cfg(feature = "flac-encode")] + #[test] + fn streamed_flac_matches_encode() { + let channels: Vec> = (0..2).map(|c| (0..20_000).map(|i| ((i * (c + 3)) as f32 * 0.01).sin() * 0.5).collect()).collect(); + let buf = AudioBuffer { sample_rate: 48_000, channels }; + let opts = EncodeOptions::new(EncodeFormat::Flac); + let whole = encode(&buf, &opts).unwrap(); + let mut enc = FileEncoder::new(std::io::Cursor::new(Vec::new()), &opts, 48_000, 2).unwrap(); + let mut at = 0; + for n in [1000, 4096, 1, 9000, 5903] { + let block: Vec<&[f32]> = buf.channels.iter().map(|c| &c[at..at + n]).collect(); + enc.write(&block, n).unwrap(); + at += n; + } + assert_eq!(at, buf.frames()); + assert_eq!(enc.finish().unwrap().into_inner(), whole); } } diff --git a/crates/audio/tests/audio/roundtrip.rs b/crates/audio/tests/audio/roundtrip.rs index 567dc2d7..35f1b156 100644 --- a/crates/audio/tests/audio/roundtrip.rs +++ b/crates/audio/tests/audio/roundtrip.rs @@ -386,3 +386,29 @@ fn a_byte_rate_past_32_bits_is_an_error() { assert!(matches!(wav_float_header(4_000_000_000, 2, 0), Err(craft_audio::AudioError::Encode(_)))); assert!(wav_float_header(4_000_000_000 / 8, 2, 0).is_ok()); } + +/// The channel mask comment of a FLAC file is its layout, for `probe`, `decode` and `Decoder`, and +/// for a file with no audio frames. +#[test] +fn flac_layouts_read_back_from_the_mask_comment() { + use craft_audio::{DecodeOptions, Decoder}; + let cases = [ + (6, Some(ChannelLayout::SURROUND_5_1_SIDE), ChannelLayout::SURROUND_5_1_SIDE), + (6, None, ChannelLayout::SURROUND_5_1), + (7, Some(ChannelLayout::SURROUND_6_1), ChannelLayout::SURROUND_6_1), + (7, None, ChannelLayout::default_for(7)), + (4, Some(ChannelLayout::from_mask(0x107)), ChannelLayout::from_mask(0x107)), + (2, Some(ChannelLayout::unordered(2)), ChannelLayout::unordered(2)), + ]; + for (channels, layout, expected) in cases { + for frames in [4096, 0] { + let mut opts = EncodeOptions::new(EncodeFormat::Flac); + opts.layout = layout; + let file = craft_audio::encode(&AudioBuffer::new(48_000, channels, frames), &opts).unwrap(); + assert_eq!(probe(&file, None).unwrap().layout, expected, "probe {channels} {layout:?} {frames}"); + assert_eq!(decode(&file, None).unwrap().0.layout, expected, "decode {channels} {layout:?} {frames}"); + let dec = Decoder::from_bytes(&file, None, &DecodeOptions::default()).unwrap(); + assert_eq!(dec.info().layout, expected, "Decoder {channels} {layout:?} {frames}"); + } + } +} diff --git a/crates/audio/tests/audio/stream.rs b/crates/audio/tests/audio/stream.rs index fcd27def..183bdab9 100644 --- a/crates/audio/tests/audio/stream.rs +++ b/crates/audio/tests/audio/stream.rs @@ -182,8 +182,16 @@ fn seeking_to_or_past_the_end_ends_the_stream() { assert!(dec.next_block().unwrap().is_none(), "{format:?} seek({target})"); // Seeking back still works. assert_eq!(dec.seek(4000).unwrap(), 4000, "{format:?}"); - let left: usize = std::iter::from_fn(|| dec.next_block().unwrap()).map(|b| b.frames()).sum(); - assert_eq!(left, 800, "{format:?}"); + let mut left = vec![Vec::new(); buf.channels.len()]; + while let Some(b) = dec.next_block().unwrap() { + for (l, c) in left.iter_mut().zip(b.channels) { + l.extend(c); + } + } + for (l, source) in left.iter().zip(&buf.channels) { + assert_eq!(l.len(), 800, "{format:?}"); + assert!(l.iter().zip(&source[4000..]).all(|(a, b)| (a - b.clamp(-1.0, 1.0)).abs() <= 2.0 / 32768.0), "{format:?}"); + } } } }