use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
use symphonia::core::audio::Channels;
use symphonia::core::audio::sample::SampleFormat;
use symphonia::core::codecs::CodecParameters;
use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, well_known as codec_ids};
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, TrackType};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions};
use symphonia::core::meta::{MetadataOptions, RawValue, StandardTag, Tag};
pub const AUDIO_STREAM_KIND: &str = "audio_stream";
const MAX_TAG_VALUE: usize = 512;
const MAX_TAGS: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Exactness {
Exact,
Estimated,
}
impl Exactness {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Exact => "exact",
Self::Estimated => "estimated",
}
}
}
impl std::fmt::Display for Exactness {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct AudioDuration {
pub ms: u64,
pub exactness: Exactness,
}
impl std::fmt::Display for AudioDuration {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ms ({})", self.ms, self.exactness)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct AudioTag {
pub name: String,
pub value: String,
pub source_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AudioFacts {
pub container: String,
pub codec: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample_rate_hz: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bit_depth: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channels: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_layout: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample_format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frames: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<AudioDuration>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<AudioTag>,
}
impl AudioFacts {
#[must_use]
pub fn summary(&self) -> String {
let mut parts = vec![self.container.clone()];
if self.codec != self.container {
parts.push(self.codec.clone());
}
if let Some(rate) = self.sample_rate_hz {
parts.push(format!("{rate} Hz"));
}
if let Some(channels) = self.channels {
parts.push(channel_word(channels));
}
if let Some(layout) = &self.channel_layout {
parts.push(layout.clone());
}
if let Some(depth) = self.bit_depth {
parts.push(format!("{depth}-bit"));
}
if let Some(format) = &self.sample_format {
parts.push(format.clone());
}
if let Some(frames) = self.frames {
parts.push(format!("{frames} frames"));
}
if let Some(duration) = self.duration {
parts.push(duration.to_string());
} else {
parts.push("duration unknown".to_owned());
}
let mut out = parts.join(", ");
for tag in &self.tags {
let _ = write!(out, "\n{}: {}", tag.name, tag.value);
}
out
}
}
#[must_use]
pub fn read(bytes: &[u8], extension: Option<&str>) -> Option<AudioFacts> {
std::panic::catch_unwind(|| read_inner(bytes, extension))
.ok()
.flatten()
}
fn read_inner(bytes: &[u8], extension: Option<&str>) -> Option<AudioFacts> {
let source = std::io::Cursor::new(bytes);
let stream = MediaSourceStream::new(Box::new(source), MediaSourceStreamOptions::default());
let mut hint = Hint::new();
if let Some(ext) = extension {
hint.with_extension(ext);
}
let mut reader = symphonia::default::get_probe()
.probe(
&hint,
stream,
FormatOptions::default(),
MetadataOptions::default(),
)
.ok()?;
let container = reader.format_info().short_name.to_owned();
let track = reader.default_track(TrackType::Audio)?;
let params = match track.codec_params.as_ref() {
Some(CodecParameters::Audio(params)) => Some(params),
_ => None,
};
let sample_rate_hz = params.and_then(|p| p.sample_rate);
let frames = track.num_frames;
let duration = duration_of(frames, sample_rate_hz, &container);
let facts = AudioFacts {
codec: params.map_or_else(|| "unknown".to_owned(), |p| codec_name(p.codec)),
sample_rate_hz,
bit_depth: params.and_then(bit_depth_of),
channels: params
.and_then(|p| p.channels.as_ref())
.and_then(channel_count),
channel_layout: params
.and_then(|p| p.channels.as_ref())
.and_then(channel_layout),
sample_format: params
.and_then(|p| p.sample_format)
.map(|f| sample_format_name(f).to_owned()),
frames,
duration,
tags: read_tags(&mut reader),
container,
};
Some(facts)
}
fn read_tags(reader: &mut Box<dyn symphonia::core::formats::FormatReader + '_>) -> Vec<AudioTag> {
let mut out: Vec<AudioTag> = Vec::new();
{
let mut log = reader.metadata();
loop {
if let Some(revision) = log.current() {
out.extend(revision.media.tags.iter().filter_map(normalize_tag));
}
if log.pop().is_none() {
break;
}
}
}
out.sort();
out.dedup_by(|later, earlier| later.name == earlier.name && later.value == earlier.value);
out.truncate(MAX_TAGS);
out
}
fn normalize_tag(tag: &Tag) -> Option<AudioTag> {
if matches!(tag.raw.value, RawValue::Binary(_)) {
return None;
}
let value = cap_tag_value(&tag.raw.value.to_string());
if value.is_empty() {
return None;
}
let source_key = tag.raw.key.clone();
let name = tag
.std
.as_ref()
.and_then(standard_tag_name)
.unwrap_or_else(|| source_key.to_lowercase());
Some(AudioTag {
name,
value,
source_key,
})
}
fn standard_tag_name(std: &StandardTag) -> Option<String> {
let debug = format!("{std:?}");
let variant = debug.split('(').next().unwrap_or_default();
let mut chars = variant.chars();
if !chars.next().is_some_and(|c| c.is_ascii_uppercase()) || !chars.all(char::is_alphanumeric) {
return None;
}
let mut out = String::with_capacity(variant.len() + 4);
for (i, c) in variant.char_indices() {
if c.is_ascii_uppercase() && i > 0 {
out.push('_');
}
out.push(c.to_ascii_lowercase());
}
Some(out)
}
fn cap_tag_value(value: &str) -> String {
let flat: String = value
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let flat = flat.trim();
if flat.len() <= MAX_TAG_VALUE {
return flat.to_owned();
}
let mut end = MAX_TAG_VALUE;
while end > 0 && !flat.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &flat[..end])
}
fn duration_of(
frames: Option<u64>,
sample_rate_hz: Option<u32>,
container: &str,
) -> Option<AudioDuration> {
let frames = frames?;
let rate = u64::from(sample_rate_hz?);
if rate == 0 {
return None;
}
let ms = u64::try_from(u128::from(frames) * 1000 / u128::from(rate)).unwrap_or(u64::MAX);
Some(AudioDuration {
ms,
exactness: exactness_of_container(container),
})
}
fn exactness_of_container(container: &str) -> Exactness {
match container {
"wave" | "flac" => Exactness::Exact,
_ => Exactness::Estimated,
}
}
fn bit_depth_of(params: &AudioCodecParameters) -> Option<u32> {
params.bits_per_coded_sample.or(params.bits_per_sample)
}
fn channel_count(channels: &Channels) -> Option<u16> {
u16::try_from(channels.count()).ok().filter(|n| *n > 0)
}
fn channel_layout(channels: &Channels) -> Option<String> {
match channels {
Channels::Positioned(positions) => {
let names: Vec<String> = positions
.iter_names()
.map(|(name, _)| name.to_lowercase())
.collect();
(!names.is_empty()).then(|| names.join("+"))
}
Channels::Discrete(_) => Some("discrete".to_owned()),
Channels::Ambisonic(order) => Some(format!("ambisonic_order_{order}")),
Channels::Custom(_) => Some("custom".to_owned()),
Channels::None => None,
_ => Some("other".to_owned()),
}
}
fn channel_word(channels: u16) -> String {
match channels {
1 => "mono".to_owned(),
2 => "stereo".to_owned(),
n => format!("{n} channels"),
}
}
fn sample_format_name(format: SampleFormat) -> &'static str {
match format {
SampleFormat::U8 => "u8",
SampleFormat::U16 => "u16",
SampleFormat::U24 => "u24",
SampleFormat::U32 => "u32",
SampleFormat::S8 => "s8",
SampleFormat::S16 => "s16",
SampleFormat::S24 => "s24",
SampleFormat::S32 => "s32",
SampleFormat::F32 => "f32",
SampleFormat::F64 => "f64",
}
}
fn codec_name(codec: AudioCodecId) -> String {
let known = [
(codec_ids::CODEC_ID_PCM_S8, "pcm_s8"),
(codec_ids::CODEC_ID_PCM_U8, "pcm_u8"),
(codec_ids::CODEC_ID_PCM_S16LE, "pcm_s16le"),
(codec_ids::CODEC_ID_PCM_S16BE, "pcm_s16be"),
(codec_ids::CODEC_ID_PCM_S24LE, "pcm_s24le"),
(codec_ids::CODEC_ID_PCM_S24BE, "pcm_s24be"),
(codec_ids::CODEC_ID_PCM_S32LE, "pcm_s32le"),
(codec_ids::CODEC_ID_PCM_S32BE, "pcm_s32be"),
(codec_ids::CODEC_ID_PCM_F32LE, "pcm_f32le"),
(codec_ids::CODEC_ID_PCM_F32BE, "pcm_f32be"),
(codec_ids::CODEC_ID_PCM_F64LE, "pcm_f64le"),
(codec_ids::CODEC_ID_PCM_F64BE, "pcm_f64be"),
(codec_ids::CODEC_ID_PCM_ALAW, "pcm_alaw"),
(codec_ids::CODEC_ID_PCM_MULAW, "pcm_mulaw"),
(codec_ids::CODEC_ID_ADPCM_MS, "adpcm_ms"),
(codec_ids::CODEC_ID_ADPCM_IMA_WAV, "adpcm_ima_wav"),
(codec_ids::CODEC_ID_MP1, "mp1"),
(codec_ids::CODEC_ID_MP2, "mp2"),
(codec_ids::CODEC_ID_MP3, "mp3"),
(codec_ids::CODEC_ID_FLAC, "flac"),
];
known
.iter()
.find(|(id, _)| *id == codec)
.map_or_else(|| format!("codec_{codec}"), |(_, name)| (*name).to_owned())
}
#[cfg(test)]
mod tests {
use super::{
AudioDuration, AudioFacts, AudioTag, Exactness, cap_tag_value, channel_word, codec_name,
duration_of, exactness_of_container, standard_tag_name,
};
use symphonia::core::codecs::audio::well_known as codec_ids;
use symphonia::core::meta::StandardTag;
fn facts() -> AudioFacts {
AudioFacts {
container: "flac".to_owned(),
codec: "flac".to_owned(),
sample_rate_hz: Some(16_000),
bit_depth: Some(16),
channels: Some(1),
channel_layout: Some("front_center".to_owned()),
sample_format: None,
frames: Some(4096),
duration: Some(AudioDuration {
ms: 256,
exactness: Exactness::Exact,
}),
tags: Vec::new(),
}
}
#[test]
fn duration_is_integer_milliseconds_from_frames_and_rate() {
let d = duration_of(Some(4096), Some(16_000), "flac").expect("duration");
assert_eq!(d.ms, 256);
assert_eq!(d.exactness, Exactness::Exact);
assert_eq!(
duration_of(Some(11_520), Some(44_100), "mp3")
.expect("duration")
.ms,
261
);
}
#[test]
fn a_missing_frame_count_or_rate_yields_no_duration_at_all() {
assert_eq!(duration_of(None, Some(16_000), "flac"), None);
assert_eq!(duration_of(Some(4096), None, "flac"), None);
assert_eq!(duration_of(Some(4096), Some(0), "flac"), None);
}
#[test]
fn mpeg_audio_is_always_estimated_and_lossless_containers_are_exact() {
assert_eq!(exactness_of_container("wave"), Exactness::Exact);
assert_eq!(exactness_of_container("flac"), Exactness::Exact);
assert_eq!(exactness_of_container("mp3"), Exactness::Estimated);
assert_eq!(exactness_of_container("mp2"), Exactness::Estimated);
assert_eq!(exactness_of_container("ogg"), Exactness::Estimated);
}
#[test]
fn every_duration_rendering_names_its_exactness() {
assert_eq!(
AudioDuration {
ms: 256,
exactness: Exactness::Exact
}
.to_string(),
"256 ms (exact)"
);
assert_eq!(
AudioDuration {
ms: 26_122,
exactness: Exactness::Estimated
}
.to_string(),
"26122 ms (estimated)"
);
}
#[test]
fn a_summary_reads_as_a_sentence_and_carries_the_tags() {
let mut f = facts();
f.tags = vec![AudioTag {
name: "artist".to_owned(),
value: "Someone".to_owned(),
source_key: "ARTIST".to_owned(),
}];
let summary = f.summary();
assert!(
summary.starts_with(
"flac, 16000 Hz, mono, front_center, 16-bit, 4096 frames, 256 ms (exact)"
),
"got {summary}"
);
assert!(summary.contains("\nartist: Someone"), "got {summary}");
}
#[test]
fn a_summary_says_when_the_duration_is_unknown() {
let mut f = facts();
f.duration = None;
assert!(f.summary().contains("duration unknown"), "{}", f.summary());
}
#[test]
fn standard_tag_names_are_snake_cased() {
use std::sync::Arc;
let name = |t: StandardTag| standard_tag_name(&t);
assert_eq!(
name(StandardTag::Album(Arc::new("x".to_owned()))),
Some("album".to_owned())
);
assert_eq!(
name(StandardTag::AlbumArtist(Arc::new("x".to_owned()))),
Some("album_artist".to_owned())
);
assert_eq!(
name(StandardTag::TrackTitle(Arc::new("x".to_owned()))),
Some("track_title".to_owned())
);
assert_eq!(
name(StandardTag::TrackNumber(3)),
Some("track_number".to_owned())
);
}
#[test]
fn a_tag_value_is_flattened_and_bounded() {
assert_eq!(cap_tag_value(" a\nb "), "a b");
let long = cap_tag_value(&"x".repeat(super::MAX_TAG_VALUE + 10));
assert!(long.ends_with('…'));
assert!(long.len() <= super::MAX_TAG_VALUE + '…'.len_utf8());
let wide = cap_tag_value(&"é".repeat(super::MAX_TAG_VALUE));
assert!(wide.ends_with('…'));
}
#[test]
fn codec_ids_render_as_names_or_as_their_id() {
assert_eq!(codec_name(codec_ids::CODEC_ID_FLAC), "flac");
assert_eq!(codec_name(codec_ids::CODEC_ID_MP3), "mp3");
assert_eq!(codec_name(codec_ids::CODEC_ID_PCM_S16LE), "pcm_s16le");
assert!(codec_name(codec_ids::CODEC_ID_OPUS).starts_with("codec_"));
}
#[test]
fn channel_counts_read_as_words() {
assert_eq!(channel_word(1), "mono");
assert_eq!(channel_word(2), "stereo");
assert_eq!(channel_word(6), "6 channels");
}
#[test]
fn absent_fields_are_omitted_from_json_entirely() {
let bare = AudioFacts {
container: "mp3".to_owned(),
codec: "mp3".to_owned(),
sample_rate_hz: None,
bit_depth: None,
channels: None,
channel_layout: None,
sample_format: None,
frames: None,
duration: None,
tags: Vec::new(),
};
assert_eq!(
serde_json::to_string(&bare).expect("serialize"),
r#"{"container":"mp3","codec":"mp3"}"#
);
}
#[test]
fn duration_serialises_with_its_exactness_attached() {
let json = serde_json::to_value(facts()).expect("serialize");
assert_eq!(json["duration"]["ms"], 256);
assert_eq!(json["duration"]["exactness"], "exact");
assert!(json.get("duration_ms").is_none());
}
}