#![deny(missing_docs)]
pub mod ffi;
use std::fs::File;
use std::path::Path;
use std::time::Instant;
use ebur128::{EbuR128, Mode};
use serde::{Deserialize, Serialize};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::errors::Error as SymError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use thiserror::Error;
pub const SCHEMA_VERSION: u32 = 1;
const MIN_DECODED_PERCENT: u64 = 99;
#[derive(Debug, Clone, Copy)]
pub struct ScanConfig {
pub threshold_db: f64,
pub min_gap_sec: f64,
pub strict: bool,
pub max_decode_secs: Option<f64>,
}
impl Default for ScanConfig {
fn default() -> Self {
Self {
threshold_db: -30.0,
min_gap_sec: 5.0,
strict: false,
max_decode_secs: None,
}
}
}
impl ScanConfig {
pub fn validate(&self) -> Result<(), ScanError> {
if !self.threshold_db.is_finite() {
return Err(ScanError::Config(
"threshold must be a finite number".into(),
));
}
if !self.min_gap_sec.is_finite() || self.min_gap_sec < 0.0 {
return Err(ScanError::Config(
"min-gap must be a finite number >= 0".into(),
));
}
if let Some(secs) = self.max_decode_secs
&& (!secs.is_finite() || secs <= 0.0)
{
return Err(ScanError::Config(
"timeout must be a finite number > 0".into(),
));
}
Ok(())
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ScanError {
#[error("could not open {path}: {source}")]
Open {
path: String,
#[source]
source: std::io::Error,
},
#[error("could not determine audio format: {0}")]
Format(String),
#[error("file has no decodable audio track")]
NoTrack,
#[error("no decoder available for this codec")]
NoDecoder,
#[error("stream is missing its {0}")]
MissingStreamInfo(&'static str),
#[error("decode failed: {0}")]
Decode(String),
#[error("invalid config: {0}")]
Config(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Ok,
Partial,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Analysis {
pub schema_version: u32,
pub path: String,
pub container: String,
pub codec: String,
pub sample_rate: u32,
pub channels: u32,
pub bits_per_sample: Option<u32>,
pub duration_sec: f64,
pub integrated_lufs: Option<f64>,
pub loudness_range_lu: Option<f64>,
pub true_peak_dbtp: Option<f64>,
pub silence_threshold_db: f64,
pub silence_min_gap_sec: f64,
pub silences: Vec<[f64; 2]>,
pub status: Status,
pub skipped_packets: u32,
pub warnings: Vec<String>,
}
struct SilenceTracker {
sample_rate: f64,
threshold_db: f64,
min_gap: f64,
win_frames: u64,
win_power_sum: f64,
win_filled: u64,
frames_seen: u64,
silence_start: Option<f64>,
out: Vec<[f64; 2]>,
}
impl SilenceTracker {
fn new(sample_rate: u32, threshold_db: f64, min_gap: f64) -> Self {
let win = ((sample_rate as f64) * 0.030).max(1.0) as u64;
Self {
sample_rate: sample_rate as f64,
threshold_db,
min_gap,
win_frames: win,
win_power_sum: 0.0,
win_filled: 0,
frames_seen: 0,
silence_start: None,
out: Vec::new(),
}
}
fn push(&mut self, frame_power: f64) {
self.win_power_sum += frame_power;
self.win_filled += 1;
self.frames_seen += 1;
if self.win_filled >= self.win_frames {
self.flush_window();
}
}
fn flush_window(&mut self) {
if self.win_filled == 0 {
return;
}
let rms = (self.win_power_sum / self.win_filled as f64).sqrt();
let db = if rms > 1e-12 {
20.0 * rms.log10()
} else {
-200.0
};
let win_end = self.frames_seen as f64 / self.sample_rate;
let win_start = win_end - (self.win_filled as f64 / self.sample_rate);
if db < self.threshold_db {
if self.silence_start.is_none() {
self.silence_start = Some(win_start);
}
} else if let Some(start) = self.silence_start.take()
&& win_start - start >= self.min_gap
{
self.out.push([round3(start), round3(win_start)]);
}
self.win_power_sum = 0.0;
self.win_filled = 0;
}
fn finish(mut self) -> Vec<[f64; 2]> {
self.flush_window();
let end = self.frames_seen as f64 / self.sample_rate;
if let Some(start) = self.silence_start.take()
&& end - start >= self.min_gap
{
self.out.push([round3(start), round3(end)]);
}
self.out
}
}
pub fn analyze_path(path: impl AsRef<Path>, config: &ScanConfig) -> Result<Analysis, ScanError> {
config.validate()?;
let path = path.as_ref();
let file = File::open(path).map_err(|e| ScanError::Open {
path: path.display().to_string(),
source: e,
})?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
let container = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
if !container.is_empty() {
hint.with_extension(&container);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions {
enable_gapless: true,
..Default::default()
},
&MetadataOptions::default(),
)
.map_err(|e| ScanError::Format(e.to_string()))?;
let mut format = probed.format;
let track = format.default_track().ok_or(ScanError::NoTrack)?;
let track_id = track.id;
let params = track.codec_params.clone();
let sample_rate = params
.sample_rate
.ok_or(ScanError::MissingStreamInfo("sample rate"))?;
let channels = params
.channels
.map(|c| c.count() as u32)
.ok_or(ScanError::MissingStreamInfo("channel layout"))?;
let bits_per_sample = params.bits_per_sample;
let declared_frames = params.n_frames;
let codec = symphonia::default::get_codecs()
.get_codec(params.codec)
.map(|d| d.short_name.to_string())
.unwrap_or_else(|| "unknown".to_string());
let mut decoder = symphonia::default::get_codecs()
.make(¶ms, &DecoderOptions::default())
.map_err(|_| ScanError::NoDecoder)?;
let mut ebu = EbuR128::new(channels, sample_rate, Mode::I | Mode::LRA | Mode::TRUE_PEAK)
.map_err(|e| ScanError::Decode(format!("ebur128 init: {e}")))?;
let mut silence = SilenceTracker::new(sample_rate, config.threshold_db, config.min_gap_sec);
let mut sample_buf: Option<SampleBuffer<f32>> = None;
let mut buf_cap_frames: u64 = 0;
let mut total_frames: u64 = 0;
let mut skipped_packets: u32 = 0;
let mut early_end = false;
let mut timed_out = false;
let mut layout_changed: Option<(u32, u32)> = None;
let ch = channels.max(1) as usize;
let decode_started = Instant::now();
loop {
if let Some(limit) = config.max_decode_secs
&& decode_started.elapsed().as_secs_f64() > limit
{
timed_out = true;
break;
}
let packet = match format.next_packet() {
Ok(p) => p,
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => {
early_end = true;
break;
}
Err(e) => return Err(ScanError::Decode(format!("reading packet: {e}"))),
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(SymError::DecodeError(_)) => {
skipped_packets += 1;
continue;
}
Err(SymError::IoError(_)) => {
early_end = true;
break;
}
Err(e) => return Err(ScanError::Decode(format!("decoding: {e}"))),
};
let spec = *decoded.spec();
if spec.channels.count() as u32 != channels || spec.rate != sample_rate {
layout_changed = Some((spec.channels.count() as u32, spec.rate));
break;
}
let frames = decoded.frames();
let cap = decoded.capacity() as u64;
if sample_buf.is_none() || frames as u64 > buf_cap_frames {
buf_cap_frames = cap;
sample_buf = Some(SampleBuffer::<f32>::new(buf_cap_frames, spec));
}
let buf = sample_buf.as_mut().unwrap();
buf.copy_interleaved_ref(decoded);
let samples = buf.samples();
ebu.add_frames_f32(samples)
.map_err(|e| ScanError::Decode(format!("ebur128 add_frames: {e}")))?;
let avail_frames = samples.len() / ch;
for f in 0..avail_frames {
let mut sumsq = 0.0f64;
for c in 0..ch {
let s = samples[f * ch + c] as f64;
sumsq += s * s;
}
silence.push(sumsq / ch as f64);
}
total_frames += avail_frames as u64;
}
let duration_sec = round3(total_frames as f64 / sample_rate as f64);
let integrated_lufs = ebu
.loudness_global()
.ok()
.filter(|v| v.is_finite())
.map(round2);
let loudness_range_lu = integrated_lufs.and_then(|_| {
ebu.loudness_range()
.ok()
.filter(|v| v.is_finite())
.map(round2)
});
let true_peak_dbtp = max_true_peak(&ebu, channels);
let mut warnings: Vec<String> = Vec::new();
if skipped_packets > 0 {
warnings.push(format!("skipped {skipped_packets} corrupt packet(s)"));
}
if early_end {
warnings.push("decode ended early on a stream error".to_string());
}
if timed_out && let Some(limit) = config.max_decode_secs {
warnings.push(format!("decode exceeded timeout of {limit}s"));
}
if let Some((a, r)) = layout_changed {
warnings.push(format!(
"stream changed layout mid-file: {channels}ch/{sample_rate}Hz -> {a}ch/{r}Hz"
));
}
if let Some(declared) = declared_frames
&& declared > 0
&& total_frames < declared.saturating_mul(MIN_DECODED_PERCENT) / 100
{
let declared_sec = round3(declared as f64 / sample_rate as f64);
warnings.push(format!(
"truncated: decoded {duration_sec}s of {declared_sec}s declared"
));
}
let status = if warnings.is_empty() {
Status::Ok
} else {
Status::Partial
};
if config.strict && !warnings.is_empty() {
return Err(ScanError::Decode(format!(
"incomplete decode (strict): {}",
warnings.join("; ")
)));
}
Ok(Analysis {
schema_version: SCHEMA_VERSION,
path: path.display().to_string(),
container,
codec,
sample_rate,
channels,
bits_per_sample,
duration_sec,
integrated_lufs,
loudness_range_lu,
true_peak_dbtp,
silence_threshold_db: config.threshold_db,
silence_min_gap_sec: config.min_gap_sec,
silences: silence.finish(),
status,
skipped_packets,
warnings,
})
}
fn max_true_peak(ebu: &EbuR128, channels: u32) -> Option<f64> {
let mut peak = 0.0f64;
let mut measured = false;
for c in 0..channels {
if let Ok(p) = ebu.true_peak(c) {
measured = true;
if p > peak {
peak = p;
}
}
}
if measured && peak > 0.0 {
let dbtp = 20.0 * peak.log10();
dbtp.is_finite().then(|| round2(dbtp))
} else {
None
}
}
fn round2(x: f64) -> f64 {
(x * 100.0).round() / 100.0
}
fn round3(x: f64) -> f64 {
(x * 1000.0).round() / 1000.0
}