diff --git a/crates/audio/src/read/flac.rs b/crates/audio/src/read/flac.rs index 1dc4d0f3..a16e7bdf 100644 --- a/crates/audio/src/read/flac.rs +++ b/crates/audio/src/read/flac.rs @@ -124,15 +124,16 @@ fn comment_mask(key: &[u8], value: &[u8]) -> Option { u32::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok() } -/// The channel mask comment of each FLAC stream that has one in the Ogg file `b`, by serial. -pub(crate) fn ogg_channel_masks(b: &[u8]) -> Vec<(u32, u32)> { +/// The audio streams of the Ogg file `b` in file order, as `tracks` lists them: each one's serial +/// and, for FLAC, its channel mask comment. Empty when the demuxer cannot read the file. +pub(crate) fn ogg_audio_streams(b: &[u8]) -> Vec<(u32, Option)> { let Ok(file) = ogg::open(b) else { return Vec::new() }; - let comments = |s: &ogg::Stream| { + let mask = |s: &ogg::Stream| { // After the first packet, each header packet is a metadata block with its 4-byte header. let block = s.headers.iter().skip(1).find(|h| h.first().is_some_and(|t| t & 0x7f == 4))?; comments_mask(block.get(4..)?) }; - file.streams.iter().filter(|s| s.codec == ogg::Codec::Flac).filter_map(|s| Some((s.serial, comments(s)?))).collect() + file.streams.iter().filter(|s| s.codec.codec_id().is_audio()).map(|s| (s.serial, mask(s).filter(|_| s.codec == ogg::Codec::Flac))).collect() } /// The channel mask in a Vorbis comment block's body. diff --git a/crates/audio/src/read/symph.rs b/crates/audio/src/read/symph.rs index bf78a14e..126bd5c8 100644 --- a/crates/audio/src/read/symph.rs +++ b/crates/audio/src/read/symph.rs @@ -81,20 +81,21 @@ fn guarded(f: impl FnOnce() -> Result) -> Result { catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into()))) } -/// What symphonia's FLAC and Ogg readers leave out about a file's layout. -enum FlacLayouts { +/// What [`open`] read from the file for what symphonia's FLAC and Ogg readers leave out. +enum Preread { /// Not a FLAC file or an Ogg file. - Unknown, + Nothing, /// A FLAC file's layout. - File(ChannelLayout), - /// The channel masks of an Ogg file's FLAC streams, by serial (symphonia's track ID). The tags - /// its reader collects do not say which track they are from. - Ogg(Vec<(u32, u32)>), + Flac(ChannelLayout), + /// An Ogg file's audio streams in file order: each one's serial, which is symphonia's track ID, + /// and its FLAC channel mask. Symphonia orders the tracks by serial, and the tags its reader + /// collects do not say which track they are from. + Ogg(Vec<(u32, Option)>), } /// What [`open`] found: a reader, or a stream that holds no audio frames and has no reader. enum Opened { - Reader(Box, FlacLayouts), + Reader(Box, Preread), Empty(AudioInfo), } @@ -375,7 +376,7 @@ fn open(bytes: Cow<'_, [u8]>, detected: Option) -> Result { checked => (checked, None), }; let body = bytes.get(start..).unwrap_or_default(); - let ogg_masks = craft_media::ogg::sniff(body).then(|| super::flac::ogg_channel_masks(body)); + let ogg_streams = craft_media::ogg::sniff(body).then(|| super::flac::ogg_audio_streams(body)); // 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()); @@ -397,12 +398,12 @@ fn open(bytes: Cow<'_, [u8]>, detected: Option) -> Result { } _ => {} } - let layouts = match (flac_layout.filter(|_| probed == "flac"), ogg_masks) { - (Some(layout), _) => FlacLayouts::File(layout), - (None, Some(masks)) => FlacLayouts::Ogg(masks), - _ => FlacLayouts::Unknown, + let preread = match (flac_layout.filter(|_| probed == "flac"), ogg_streams.filter(|_| probed == "ogg")) { + (Some(layout), _) => Preread::Flac(layout), + (None, Some(streams)) => Preread::Ogg(streams), + _ => Preread::Nothing, }; - Ok(Opened::Reader(reader, layouts)) + Ok(Opened::Reader(reader, preread)) } /// The codec symphonia reports, as a [`CodecId`]. @@ -509,10 +510,22 @@ fn audio_tracks(reader: &dyn FormatReader) -> impl Iterator { reader.tracks().iter().filter(|t| audio_params(t).is_some_and(|p| p.codec != CODEC_ID_NULL_AUDIO)) } -/// The `n`-th audio track (the first when `None`), with the stream information the UI shows. -fn info_of(reader: &dyn FormatReader, detected: Option, n: Option) -> Result<(u32, AudioInfo)> { +/// The `n`-th audio track (the first when `None`), counting an Ogg file's `streams` in file order +/// when they are known. With no `n`, the first stream symphonia has a track for: it has none for +/// some of the codecs `tracks` lists. +fn pick_track<'a>(reader: &'a dyn FormatReader, streams: &[(u32, Option)], n: Option) -> Option<&'a Track> { + let with_serial = |serial: u32| audio_tracks(reader).find(|t| t.id == serial); + match n { + _ if streams.is_empty() => audio_tracks(reader).nth(n.unwrap_or(0)), + Some(n) => with_serial(streams.get(n)?.0), + None => streams.iter().find_map(|s| with_serial(s.0)), + } +} + +/// The `n`-th audio track (see [`pick_track`]), with the stream information the UI shows. +fn info_of(reader: &dyn FormatReader, detected: Option, ogg_streams: &[(u32, Option)], n: Option) -> Result<(u32, AudioInfo)> { let container = container_of(reader.format_info().short_name, detected); - let track = audio_tracks(reader).nth(n.unwrap_or(0)).ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?; + let track = pick_track(reader, ogg_streams, n).ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?; let p = audio_params(track).ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?; let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?; let layout = layout_of(p.channels.as_ref()); @@ -564,8 +577,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, flac_layouts) = match open(bytes, detected)? { - Opened::Reader(reader, layouts) => (reader, layouts), + let (reader, preread) = match open(bytes, detected)? { + Opened::Reader(reader, preread) => (reader, preread), 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) { @@ -587,11 +600,12 @@ impl Symph { }); } }; - let (track_id, mut info) = info_of(reader.as_ref(), detected, track)?; - match flac_layouts { - FlacLayouts::File(layout) if layout.channels() == info.channels => info.layout = layout, - FlacLayouts::Ogg(masks) if info.codec == CodecId::Flac => { - let mask = masks.iter().find(|(serial, _)| *serial == track_id).map(|&(_, mask)| mask); + let ogg_streams = if let Preread::Ogg(streams) = &preread { streams.as_slice() } else { &[] }; + let (track_id, mut info) = info_of(reader.as_ref(), detected, ogg_streams, track)?; + match &preread { + Preread::Flac(layout) if layout.channels() == info.channels => info.layout = *layout, + Preread::Ogg(streams) if info.codec == CodecId::Flac => { + let mask = streams.iter().find(|(serial, _)| *serial == track_id).and_then(|&(_, mask)| mask); info.layout = super::flac::layout_from(mask, info.channels); } _ => {} diff --git a/crates/audio/src/write/flac/oracle_tests.rs b/crates/audio/src/write/flac/oracle_tests.rs index 7b5fc538..75d02d63 100644 --- a/crates/audio/src/write/flac/oracle_tests.rs +++ b/crates/audio/src/write/flac/oracle_tests.rs @@ -266,31 +266,57 @@ fn ogg_flac_reads_the_mask_comment() { } } -/// Each track of an Ogg file has its own mask comment. The tracks are told apart by their audio, -/// because the track order follows the serial numbers ffmpeg picks at random. +/// Two streams in an Ogg file written by ffmpeg, which picks their serial numbers at random. The +/// tests below write each file a few times, so that both serial orders occur. #[cfg(feature = "symphonia")] -#[test] -fn ogg_flac_tracks_read_their_own_mask_comments() { - use crate::{ChannelLayout, DecodeOptions}; - let Some(ff) = ffmpeg() else { return }; - let path = common::fixture_subdir("flac").join("ogg-two-masks.oga"); - let status = Command::new(&ff) - .args(["-v", "error", "-y", "-f", "lavfi", "-i", "aevalsrc=0.5|0.5|0.5|0.5:c=4c:d=0.1:s=48000"]) - .args(["-f", "lavfi", "-i", "aevalsrc=0.25|0.25|0.25|0.25:c=4c:d=0.1:s=48000"]) - .args(["-map", "0", "-map", "1", "-c:a", "flac", "-f", "ogg"]) - .args(["-metadata:s:a:0", "WAVEFORMATEXTENSIBLE_CHANNEL_MASK=0x0107", "-metadata:s:a:1", "WAVEFORMATEXTENSIBLE_CHANNEL_MASK=0x000F"]) +fn two_ogg_streams(ff: &Path, name: &str, args: &[&str]) -> Option> { + let path = common::fixture_subdir("flac").join(format!("{name}.ogg")); + let status = Command::new(ff) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "aevalsrc=0|0|0|0:c=4c:d=0.1:s=48000"]) + .args(["-f", "lavfi", "-i", "aevalsrc=0|0|0|0:c=4c:d=0.1:s=44100"]) + .args(["-map", "0", "-map", "1"]) + .args(args) + .args(["-f", "ogg"]) .arg(&path) .status() .unwrap(); - assert!(status.success()); - let bytes = std::fs::read(&path).unwrap(); - for track in [0, 1] { - let opts = DecodeOptions::default().with_track(track); - let (info, buf) = crate::decode_with(&bytes, None, &opts).unwrap(); - let mask = if buf.channels[0][0] > 0.4 { 0x107 } else { 0xF }; - let expected = ChannelLayout::from_mask_and_channels(mask, 4); - assert_eq!(info.layout, expected, "decode track {track}"); - assert_eq!(crate::probe_with(&bytes, None, &opts).unwrap().layout, expected, "probe track {track}"); + status.success().then(|| std::fs::read(&path).unwrap()) +} + +/// A track index means the same stream in `tracks`, `probe` and `decode`: the file's order. Each +/// track has its own mask comment, or else the layout decoders assume for the count. +#[cfg(feature = "symphonia")] +#[test] +fn ogg_flac_tracks_are_in_file_order_with_their_own_mask_comments() { + use crate::{ChannelLayout, DecodeOptions}; + let Some(ff) = ffmpeg() else { return }; + let args = ["-c:a", "flac", "-metadata:s:a:0", "WAVEFORMATEXTENSIBLE_CHANNEL_MASK=0x0107"]; + for _ in 0..8 { + let bytes = two_ogg_streams(&ff, "ogg-one-mask", &args).unwrap(); + let rates: Vec<_> = crate::tracks(&bytes, None).unwrap().iter().map(|t| t.sample_rate).collect(); + assert_eq!(rates, [Some(48_000), Some(44_100)]); + for (track, rate, layout) in [(0, 48_000, ChannelLayout::from_mask(0x107)), (1, 44_100, ChannelLayout::default_for(4))] { + let opts = DecodeOptions::default().with_track(track); + let info = crate::probe_with(&bytes, None, &opts).unwrap(); + assert_eq!((info.sample_rate, info.layout), (rate, layout), "probe track {track}"); + let info = crate::decode_with(&bytes, None, &opts).unwrap().0; + assert_eq!((info.sample_rate, info.layout), (rate, layout), "decode track {track}"); + } + assert_eq!(crate::decode(&bytes, None).unwrap().0.sample_rate, 48_000, "the first track"); + } +} + +/// A FLAC stream before a stream symphonia cannot decode is still the first track. +#[cfg(feature = "symphonia")] +#[test] +fn ogg_flac_before_opus_is_the_first_track() { + use crate::CodecId; + let Some(ff) = ffmpeg() else { return }; + let args = ["-c:a:0", "flac", "-c:a:1", "libopus", "-ac:a:1", "2"]; + for _ in 0..8 { + let Some(bytes) = two_ogg_streams(&ff, "ogg-flac-opus", &args) else { return }; + assert_eq!(crate::tracks(&bytes, None).unwrap()[0].codec, CodecId::Flac); + assert_eq!(crate::decode(&bytes, None).unwrap().0.codec, CodecId::Flac); } }