use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine as _;
use rodio::cpal;
use rodio::cpal::traits::{DeviceTrait as _, HostTrait as _};
use rodio::cpal::{SampleFormat, SupportedStreamConfig};
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::any::Any;
use std::ffi::OsString;
use std::fmt;
use std::fs::{self, File};
use std::io::{BufReader, ErrorKind, Read, Write};
use std::num::NonZero;
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdout, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::oneshot;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
pub const DEFAULT_TARGET_LUFS: f32 = -18.0;
const MAX_GAIN_DB: f32 = 6.0;
const MIN_GAIN_DB: f32 = -18.0;
const OUTPUT_TRUE_PEAK_CEIL_DBTP: f32 = -2.0;
const LIMITER_LINEAR_CEIL: f32 = 0.794_328;
const LIMITER_GUARD_MARGIN_DB: f32 = 0.3;
const HOT_MASTER_LUFS_THRESHOLD: f32 = -14.0;
const HOT_MASTER_PROTECT_SLOPE: f32 = 0.35;
const HOT_MASTER_PROTECT_MAX_DB: f32 = 1.5;
const DEBUG_PROBE_DEFAULT_CAPTURE_MS: u32 = 12_000;
const DEBUG_PROBE_MIN_CAPTURE_MS: u32 = 500;
const DEBUG_PROBE_MAX_CAPTURE_MS: u32 = 30_000;
const DEBUG_PROBE_DEFAULT_SAMPLE_RATE: u32 = 48_000;
const DEBUG_PROBE_MIN_SAMPLE_RATE: u32 = 8_000;
const DEBUG_PROBE_MAX_SAMPLE_RATE: u32 = 192_000;
const DEBUG_PROBE_DEFAULT_CHANNELS: u16 = 2;
const DEBUG_PROBE_MAX_CHANNELS: u16 = 2;
const EBUR128_SHORT_TERM_SILENCE_FLOOR_LUFS: f32 = -100.0;
const DBFS_FLOOR: f32 = -240.0;
pub const DEFAULT_AUDIO_DURATION_PROBE_SAMPLE_RATE: u32 = 48_000;
pub const DEFAULT_AUDIO_DURATION_PROBE_CHANNELS: u16 = 1;
pub const DEFAULT_AUDIO_PCM_DECODE_CHANNELS: u16 = 1;
pub const DEFAULT_WAVEFORM_SAMPLE_RATE: u32 = 48_000;
pub const DEFAULT_WAVEFORM_BASE_POINTS_PER_SECOND: u32 = 3200;
pub const DEFAULT_WAVEFORM_SAMPLES_PER_POINT: u32 =
DEFAULT_WAVEFORM_SAMPLE_RATE / DEFAULT_WAVEFORM_BASE_POINTS_PER_SECOND;
pub const DEFAULT_TAIL_FINGERPRINT_SAMPLE_RATE: u32 = 8_000;
pub const DEFAULT_TAIL_FINGERPRINT_MAX_SEARCH_MS: u32 = 75_000;
pub const DEFAULT_TAIL_FINGERPRINT_WINDOW_MS: u32 = 1_000;
pub const DEFAULT_TAIL_FINGERPRINT_HOP_MS: u32 = 500;
pub const DEFAULT_TAIL_FINGERPRINT_SPECTRAL_BANDS: u32 = 24;
pub const DEFAULT_TAIL_FINGERPRINT_SILENCE_THRESHOLD_DB: f32 = -55.0;
pub const DEFAULT_TAIL_FINGERPRINT_SILENCE_PAD_MS: u32 = 300;
const TAIL_FINGERPRINT_FFT_SIZE: usize = 8192;
const TAIL_FINGERPRINT_MIN_HZ: f32 = 80.0;
const TAIL_FINGERPRINT_MAX_HZ_GUARD: f32 = 150.0;
const DEFAULT_AUDIO_RESAMPLE_FILTER_SIZE: u32 = 8;
const DEFAULT_STOP_FADE_DURATION: Duration = Duration::from_millis(100);
const STOP_FADE_STEPS: u32 = 20;
#[derive(Debug, Serialize, Deserialize, Clone, Type, PartialEq)]
pub struct AudioStatus {
pub path: Option<String>,
pub playing: bool,
pub paused: bool,
pub position_ms: u32,
pub duration_ms: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Type, PartialEq, Eq)]
pub struct AudioEndedPayload {
pub session_id: u64,
pub path: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, Type, PartialEq, Eq)]
pub struct AudioStoppedPayload {
pub session_id: u64,
pub path: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, Type, PartialEq, Eq)]
pub struct AudioPausedPayload {
pub session_id: u64,
pub path: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, Type, PartialEq, Eq)]
pub struct AudioResumedPayload {
pub session_id: u64,
pub path: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, Type, PartialEq, Eq)]
pub struct AudioFailedPayload {
pub session_id: u64,
pub path: String,
pub action: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AudioPlayRequest {
pub session_id: u64,
pub path: String,
#[serde(default)]
pub time_range: Option<PlaybackTimeRange>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlaybackNormalization {
pub target_lufs: f32,
pub integrated_lufs: Option<f32>,
pub true_peak_dbtp: Option<f32>,
}
impl Default for PlaybackNormalization {
fn default() -> Self {
Self {
target_lufs: DEFAULT_TARGET_LUFS,
integrated_lufs: None,
true_peak_dbtp: None,
}
}
}
#[derive(Debug, Clone)]
pub struct PlaybackRequest {
pub path: PathBuf,
pub session_id: Option<u64>,
pub normalization: Option<PlaybackNormalization>,
pub time_range: Option<PlaybackTimeRange>,
}
impl PlaybackRequest {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
session_id: None,
normalization: None,
time_range: None,
}
}
pub fn with_session_id(mut self, session_id: u64) -> Self {
self.session_id = Some(session_id);
self
}
pub fn with_normalization(mut self, normalization: PlaybackNormalization) -> Self {
self.normalization = Some(normalization);
self
}
pub fn with_time_range(mut self, time_range: PlaybackTimeRange) -> Self {
self.time_range = Some(time_range);
self
}
}
#[derive(Debug, Clone)]
pub struct AudioLoudnessAnalysisRequest {
pub path: PathBuf,
pub time_range: Option<PlaybackTimeRange>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AudioLoudnessAnalysis {
pub integrated_lufs: f32,
pub true_peak_dbtp: Option<f32>,
pub lra: Option<f32>,
pub short_lufs_p50: Option<f32>,
pub short_lufs_p80: Option<f32>,
pub short_lufs_p95: Option<f32>,
pub short_lufs_max: Option<f32>,
pub presence_db: Option<f32>,
}
#[derive(Debug, Clone)]
pub struct AudioPcmDecodeRequest {
pub path: PathBuf,
pub time_range: Option<PlaybackTimeRange>,
pub sample_rate: u32,
pub channels: u16,
pub filter_chain: Option<String>,
}
impl AudioPcmDecodeRequest {
pub fn new(path: impl Into<PathBuf>, sample_rate: u32) -> Self {
Self {
path: path.into(),
time_range: None,
sample_rate,
channels: DEFAULT_AUDIO_PCM_DECODE_CHANNELS,
filter_chain: None,
}
}
pub fn with_time_range(mut self, time_range: PlaybackTimeRange) -> Self {
self.time_range = Some(time_range);
self
}
pub fn with_channels(mut self, channels: u16) -> Self {
self.channels = channels;
self
}
pub fn with_filter_chain(mut self, filter_chain: impl Into<String>) -> Self {
self.filter_chain = Some(filter_chain.into());
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AudioWaveformPeak {
pub min: f32,
pub max: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AudioWaveformAnalysis {
pub sample_rate: u32,
pub samples_per_point: u32,
pub points_per_second: u32,
pub start_ms: u32,
pub duration_ms: u32,
pub peaks: Vec<AudioWaveformPeak>,
}
#[derive(Debug, Clone)]
pub struct AudioWaveformAnalysisRequest {
pub path: PathBuf,
pub time_range: Option<PlaybackTimeRange>,
pub sample_rate: u32,
pub points_per_second: u32,
}
impl AudioWaveformAnalysisRequest {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
time_range: None,
sample_rate: DEFAULT_WAVEFORM_SAMPLE_RATE,
points_per_second: DEFAULT_WAVEFORM_BASE_POINTS_PER_SECOND,
}
}
pub fn with_time_range(mut self, time_range: PlaybackTimeRange) -> Self {
self.time_range = Some(time_range);
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AudioTailFingerprintFrame {
pub source_start_ms: u32,
pub source_end_ms: u32,
pub rms_db: f32,
pub bands: Vec<f32>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AudioTailFingerprintAnalysis {
pub sample_rate: u32,
pub search_start_ms: u32,
pub decoded_duration_ms: u32,
pub effective_end_ms: u32,
pub window_ms: u32,
pub hop_ms: u32,
pub spectral_bands: u32,
pub frames: Vec<AudioTailFingerprintFrame>,
}
#[derive(Debug, Clone)]
pub struct AudioTailFingerprintAnalysisRequest {
pub path: PathBuf,
pub time_range: Option<PlaybackTimeRange>,
pub sample_rate: u32,
pub max_search_ms: u32,
pub window_ms: u32,
pub hop_ms: u32,
pub spectral_bands: u32,
pub silence_threshold_db: f32,
pub silence_pad_ms: u32,
}
impl AudioTailFingerprintAnalysisRequest {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
time_range: None,
sample_rate: DEFAULT_TAIL_FINGERPRINT_SAMPLE_RATE,
max_search_ms: DEFAULT_TAIL_FINGERPRINT_MAX_SEARCH_MS,
window_ms: DEFAULT_TAIL_FINGERPRINT_WINDOW_MS,
hop_ms: DEFAULT_TAIL_FINGERPRINT_HOP_MS,
spectral_bands: DEFAULT_TAIL_FINGERPRINT_SPECTRAL_BANDS,
silence_threshold_db: DEFAULT_TAIL_FINGERPRINT_SILENCE_THRESHOLD_DB,
silence_pad_ms: DEFAULT_TAIL_FINGERPRINT_SILENCE_PAD_MS,
}
}
pub fn with_time_range(mut self, time_range: PlaybackTimeRange) -> Self {
self.time_range = Some(time_range);
self
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)]
pub struct PlaybackTimeRange {
pub start_ms: u32,
pub duration_ms: Option<u32>,
}
impl PlaybackTimeRange {
pub fn from_start_seconds(start_seconds: u32) -> Self {
Self {
start_ms: seconds_to_millis(start_seconds),
duration_ms: None,
}
}
pub fn bounded_seconds(start_seconds: u32, end_seconds: u32) -> Result<Self, String> {
let Some(duration_seconds) = end_seconds.checked_sub(start_seconds) else {
return Err(format!(
"playback range end `{end_seconds}` must be greater than start `{start_seconds}`"
));
};
if duration_seconds == 0 {
return Err(format!(
"playback range end `{end_seconds}` must be greater than start `{start_seconds}`"
));
}
Ok(Self {
start_ms: seconds_to_millis(start_seconds),
duration_ms: Some(seconds_to_millis(duration_seconds)),
})
}
}
#[derive(Debug, Clone)]
pub struct ResolvedAudioPlayRequest {
session_id: u64,
path: String,
time_range: Option<PlaybackTimeRange>,
gain_db: f32,
target_lufs: f32,
integrated_lufs: Option<f32>,
has_canonical_loudness: bool,
track_true_peak_dbtp: Option<f32>,
ffmpeg_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AudioDebugSpectrogramRequest {
pub path: String,
pub target_lufs: Option<f32>,
pub track_lufs: Option<f32>,
pub track_true_peak_dbtp: Option<f32>,
pub width: Option<u32>,
pub height: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AudioDebugSpectrogram {
pub raw_data_url: String,
pub processed_data_url: String,
pub gain_db: f32,
pub playback_filter: String,
pub width: u32,
pub height: u32,
pub duration_ms: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AudioDebugProbeRequest {
pub path: String,
pub target_lufs: Option<f32>,
pub track_lufs: Option<f32>,
pub track_true_peak_dbtp: Option<f32>,
pub offset_ms: Option<u32>,
pub capture_ms: Option<u32>,
pub sample_rate: Option<u32>,
pub channels: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AudioDebugProbeStats {
pub sample_count: u32,
pub finite_sample_count: u32,
pub non_finite_count: u32,
pub over_unity_count: u32,
pub over_unity_ratio: f32,
pub peak_dbfs: f32,
pub rms_dbfs: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AudioDebugProbeResult {
pub output_dir: String,
pub metadata_json: String,
pub raw_wav: String,
pub processed_wav: String,
pub output_wav: String,
pub gain_db: f32,
pub playback_filter: String,
pub sample_rate: u32,
pub channels: u16,
pub offset_ms: u32,
pub requested_capture_ms: u32,
pub actual_capture_ms: u32,
pub raw_stats: AudioDebugProbeStats,
pub processed_stats: AudioDebugProbeStats,
pub output_stats: AudioDebugProbeStats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AudioDebugProbeMetadata {
created_epoch_ms: u64,
input_path: String,
gain_db: f32,
playback_filter: String,
sample_rate: u32,
channels: u16,
offset_ms: u32,
requested_capture_ms: u32,
actual_capture_ms: u32,
raw_stats: AudioDebugProbeStats,
processed_stats: AudioDebugProbeStats,
output_stats: AudioDebugProbeStats,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AudioPlayAck {
pub session_id: u64,
pub path: String,
pub duration_ms: Option<u32>,
pub gain: f32,
pub gain_db: f32,
pub target_lufs: f32,
pub integrated_lufs: Option<f32>,
pub has_canonical_loudness: bool,
}
pub trait AudioEventSink: Send + Sync + 'static {
fn emit_state(&self, status: &AudioStatus);
fn emit_ended(&self, payload: &AudioEndedPayload);
fn emit_stopped(&self, payload: &AudioStoppedPayload);
fn emit_paused(&self, payload: &AudioPausedPayload);
fn emit_resumed(&self, payload: &AudioResumedPayload);
fn emit_failed(&self, payload: &AudioFailedPayload);
}
#[derive(Debug, Default)]
pub struct NoopAudioEventSink;
impl AudioEventSink for NoopAudioEventSink {
fn emit_state(&self, _status: &AudioStatus) {}
fn emit_ended(&self, _payload: &AudioEndedPayload) {}
fn emit_stopped(&self, _payload: &AudioStoppedPayload) {}
fn emit_paused(&self, _payload: &AudioPausedPayload) {}
fn emit_resumed(&self, _payload: &AudioResumedPayload) {}
fn emit_failed(&self, _payload: &AudioFailedPayload) {}
}
pub struct PlaybackBuilder {
ffmpeg_path: PathBuf,
event_sink: Arc<dyn AudioEventSink>,
default_normalization: PlaybackNormalization,
session_seed: u64,
}
impl PlaybackBuilder {
pub fn new(ffmpeg_path: impl Into<PathBuf>) -> Self {
Self {
ffmpeg_path: ffmpeg_path.into(),
event_sink: Arc::new(NoopAudioEventSink),
default_normalization: PlaybackNormalization::default(),
session_seed: 1,
}
}
pub fn event_sink(mut self, event_sink: Arc<dyn AudioEventSink>) -> Self {
self.event_sink = event_sink;
self
}
pub fn default_normalization(mut self, normalization: PlaybackNormalization) -> Self {
self.default_normalization = normalization;
self
}
pub fn session_seed(mut self, session_seed: u64) -> Self {
self.session_seed = session_seed.max(1);
self
}
pub fn build(self) -> Result<Playback, String> {
if !self.ffmpeg_path.exists() {
return Err(format!(
"ffmpeg binary not found: {}",
self.ffmpeg_path.display()
));
}
Ok(Playback {
shared: Arc::new(PlaybackShared {
ffmpeg_path: self.ffmpeg_path,
default_normalization: self.default_normalization,
next_session_id: AtomicU64::new(self.session_seed),
engine: AudioEngine::spawn(self.event_sink)?,
}),
})
}
}
struct PlaybackShared {
ffmpeg_path: PathBuf,
default_normalization: PlaybackNormalization,
next_session_id: AtomicU64,
engine: AudioEngine,
}
#[derive(Clone)]
pub struct Playback {
shared: Arc<PlaybackShared>,
}
impl Playback {
pub fn builder(ffmpeg_path: impl Into<PathBuf>) -> PlaybackBuilder {
PlaybackBuilder::new(ffmpeg_path)
}
pub fn new(ffmpeg_path: impl Into<PathBuf>) -> Result<Self, String> {
Self::builder(ffmpeg_path).build()
}
pub fn ffmpeg_path(&self) -> &Path {
self.shared.ffmpeg_path.as_path()
}
pub fn default_normalization(&self) -> &PlaybackNormalization {
&self.shared.default_normalization
}
pub async fn play(&self, path: impl Into<PathBuf>) -> Result<AudioPlayAck, EngineRequestError> {
self.play_request(PlaybackRequest::new(path)).await
}
pub async fn play_request(
&self,
request: PlaybackRequest,
) -> Result<AudioPlayAck, EngineRequestError> {
let resolved = self.resolve_request(request);
self.shared.engine.play(resolved).await
}
pub async fn pause(&self) -> Result<(), EngineRequestError> {
self.shared.engine.pause().await
}
pub async fn resume(&self) -> Result<(), EngineRequestError> {
self.shared.engine.resume().await
}
pub async fn stop(&self) -> Result<(), EngineRequestError> {
self.shared.engine.stop().await
}
pub async fn status(&self) -> Result<AudioStatus, EngineRequestError> {
self.shared.engine.status().await
}
pub fn next_session_id(&self) -> u64 {
self.shared.next_session_id.fetch_add(1, Ordering::Relaxed)
}
pub fn resolve_request(&self, request: PlaybackRequest) -> ResolvedAudioPlayRequest {
let normalization = request
.normalization
.map(|value| merge_normalization(self.shared.default_normalization.clone(), value))
.unwrap_or_else(|| self.shared.default_normalization.clone());
resolve_audio_play_request(
AudioPlayRequest {
session_id: request.session_id.unwrap_or_else(|| self.next_session_id()),
path: request.path.to_string_lossy().to_string(),
time_range: request.time_range,
},
normalization,
self.shared.ffmpeg_path.clone(),
)
}
}
#[derive(Clone)]
pub struct AudioEngine {
tx: Sender<EngineCmd>,
}
#[derive(Debug)]
pub enum EngineRequestError {
Disconnected,
ResponseDropped,
Command(String),
}
impl EngineRequestError {
pub fn should_reset(&self) -> bool {
matches!(self, Self::Disconnected | Self::ResponseDropped)
}
}
impl fmt::Display for EngineRequestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Disconnected => f.write_str("audio engine thread stopped"),
Self::ResponseDropped => f.write_str("audio engine response dropped"),
Self::Command(message) => f.write_str(message),
}
}
}
impl std::error::Error for EngineRequestError {}
struct PlayerState {
session_id: Option<u64>,
sink: Option<Player>,
path: Option<String>,
started_at: Option<Instant>,
paused_started_at: Option<Instant>,
paused_total: Duration,
duration_ms: Option<u32>,
paused: bool,
}
impl PlayerState {
fn new() -> Self {
Self {
session_id: None,
sink: None,
path: None,
started_at: None,
paused_started_at: None,
paused_total: Duration::from_millis(0),
duration_ms: None,
paused: false,
}
}
fn clear_immediate(&mut self) {
if let Some(sink) = self.sink.take() {
sink.stop();
}
self.reset();
}
fn clear_with_stop_fade(&mut self) {
if let Some(sink) = self.sink.take() {
fade_out_and_stop_player(sink, DEFAULT_STOP_FADE_DURATION);
}
self.reset();
}
fn reset(&mut self) {
self.session_id = None;
self.path = None;
self.started_at = None;
self.paused_started_at = None;
self.paused_total = Duration::from_millis(0);
self.duration_ms = None;
self.paused = false;
}
fn position_ms_at(&self, now: Instant) -> u32 {
let Some(started_at) = self.started_at else {
return 0;
};
let effective_now = if self.paused {
self.paused_started_at.unwrap_or(now)
} else {
now
};
let elapsed = effective_now.saturating_duration_since(started_at);
let active = elapsed.saturating_sub(self.paused_total);
saturating_millis_u32(active)
}
fn position_ms(&self) -> u32 {
self.position_ms_at(Instant::now())
}
fn resume_at(&mut self, resumed_at: Instant) {
self.paused = false;
if let Some(paused_at) = self.paused_started_at.take() {
self.paused_total += resumed_at.saturating_duration_since(paused_at);
}
}
fn to_status(&self) -> AudioStatus {
let playing = self
.sink
.as_ref()
.map(|sink| !sink.empty() && !self.paused)
.unwrap_or(false);
AudioStatus {
path: self.path.clone(),
playing,
paused: self.paused,
position_ms: self.position_ms(),
duration_ms: self.duration_ms,
}
}
}
struct AudioBackend {
device_sink: MixerDeviceSink,
output_channels: u16,
output_sample_rate: u32,
}
struct FfmpegPcmSource {
child: Option<Child>,
stdout: BufReader<ChildStdout>,
channels: u16,
sample_rate: u32,
}
impl Iterator for FfmpegPcmSource {
type Item = f32;
fn next(&mut self) -> Option<Self::Item> {
let mut bytes = [0u8; 4];
match self.stdout.read_exact(&mut bytes) {
Ok(()) => Some(sanitize_pcm_sample(f32::from_le_bytes(bytes))),
Err(error) => {
if !matches!(
error.kind(),
ErrorKind::UnexpectedEof | ErrorKind::BrokenPipe
) {
eprintln!("ffmpeg pcm stream read error: {error}");
}
None
}
}
}
}
impl Source for FfmpegPcmSource {
fn current_span_len(&self) -> Option<usize> {
None
}
fn channels(&self) -> rodio::ChannelCount {
NonZero::new(self.channels).expect("stream channel count must be non-zero")
}
fn sample_rate(&self) -> rodio::SampleRate {
NonZero::new(self.sample_rate).expect("stream sample rate must be non-zero")
}
fn total_duration(&self) -> Option<Duration> {
None
}
}
impl Drop for FfmpegPcmSource {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
enum EngineCmd {
Play {
req: ResolvedAudioPlayRequest,
respond: oneshot::Sender<Result<AudioPlayAck, String>>,
},
Pause {
respond: oneshot::Sender<Result<(), String>>,
},
Resume {
respond: oneshot::Sender<Result<(), String>>,
},
Stop {
respond: oneshot::Sender<Result<(), String>>,
},
Status {
respond: oneshot::Sender<Result<AudioStatus, String>>,
},
}
impl AudioEngine {
pub fn spawn(event_sink: Arc<dyn AudioEventSink>) -> Result<Self, String> {
let (tx, rx) = mpsc::channel::<EngineCmd>();
std::thread::Builder::new()
.name("slisic-audio-engine".to_string())
.spawn(move || run_engine_loop(event_sink, rx))
.map_err(|e| e.to_string())?;
Ok(Self { tx })
}
pub async fn play(
&self,
req: ResolvedAudioPlayRequest,
) -> Result<AudioPlayAck, EngineRequestError> {
self.send_request(|respond| EngineCmd::Play {
req: req.clone(),
respond,
})
.await
}
pub async fn pause(&self) -> Result<(), EngineRequestError> {
self.send_request(|respond| EngineCmd::Pause { respond })
.await
}
pub async fn resume(&self) -> Result<(), EngineRequestError> {
self.send_request(|respond| EngineCmd::Resume { respond })
.await
}
pub async fn stop(&self) -> Result<(), EngineRequestError> {
self.send_request(|respond| EngineCmd::Stop { respond })
.await
}
pub async fn status(&self) -> Result<AudioStatus, EngineRequestError> {
self.send_request(|respond| EngineCmd::Status { respond })
.await
}
async fn send_request<T, F>(&self, make_cmd: F) -> Result<T, EngineRequestError>
where
F: FnOnce(oneshot::Sender<Result<T, String>>) -> EngineCmd,
{
let (respond, rx) = oneshot::channel();
if self.tx.send(make_cmd(respond)).is_err() {
return Err(EngineRequestError::Disconnected);
}
match rx.await {
Ok(Ok(value)) => Ok(value),
Ok(Err(error)) => Err(EngineRequestError::Command(error)),
Err(_) => Err(EngineRequestError::ResponseDropped),
}
}
}
pub fn resolve_audio_play_request(
req: AudioPlayRequest,
normalization: PlaybackNormalization,
ffmpeg_path: PathBuf,
) -> ResolvedAudioPlayRequest {
let gain_db = compute_gain_db(
Some(normalization.target_lufs),
normalization.integrated_lufs,
normalization.true_peak_dbtp,
);
ResolvedAudioPlayRequest {
session_id: req.session_id,
path: req.path,
time_range: req.time_range,
gain_db,
target_lufs: normalization.target_lufs,
integrated_lufs: normalization.integrated_lufs,
has_canonical_loudness: normalization.integrated_lufs.is_some(),
track_true_peak_dbtp: normalization.true_peak_dbtp,
ffmpeg_path,
}
}
fn merge_normalization(
base: PlaybackNormalization,
request: PlaybackNormalization,
) -> PlaybackNormalization {
PlaybackNormalization {
target_lufs: request.target_lufs,
integrated_lufs: request.integrated_lufs.or(base.integrated_lufs),
true_peak_dbtp: request.true_peak_dbtp.or(base.true_peak_dbtp),
}
}
pub fn ensure_debug_mode_available(command_name: &str) -> Result<(), String> {
if cfg!(debug_assertions) {
Ok(())
} else {
Err(format!("{command_name} is available in debug mode only"))
}
}
pub fn ensure_audio_file_exists(path: impl AsRef<Path>) -> Result<PathBuf, String> {
let input = path.as_ref().to_path_buf();
if !input.exists() {
return Err(format!("audio file not found: {}", input.display()));
}
Ok(input)
}
pub fn generate_debug_spectrogram_with_binary(
ffmpeg: &Path,
req: AudioDebugSpectrogramRequest,
) -> Result<AudioDebugSpectrogram, String> {
let input = ensure_audio_file_exists(&req.path)?;
let width = clamp_spectrogram_size(req.width, 1400, 640, 4096);
let height = clamp_spectrogram_size(req.height, 260, 120, 1024);
let gain_db = compute_gain_db(req.target_lufs, req.track_lufs, req.track_true_peak_dbtp);
let playback_filter = build_playback_filter(gain_db, req.track_true_peak_dbtp);
let mut duration_ms = open_decoder(input.as_path())
.ok()
.and_then(|(_, duration)| duration);
if duration_ms.is_none() {
duration_ms = probe_duration_ms_with_ffprobe(ffmpeg, input.as_path());
}
let raw_filter = spectrogram_filter_chain(None, width, height);
let processed_filter = spectrogram_filter_chain(Some(&playback_filter), width, height);
let raw_png = run_ffmpeg_spectrogram_png(ffmpeg, input.as_path(), &raw_filter)?;
let processed_png = run_ffmpeg_spectrogram_png(ffmpeg, input.as_path(), &processed_filter)?;
Ok(AudioDebugSpectrogram {
raw_data_url: format!("data:image/png;base64,{}", BASE64_STANDARD.encode(raw_png)),
processed_data_url: format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(processed_png)
),
gain_db,
playback_filter,
width,
height,
duration_ms,
})
}
pub fn analyze_loudness_with_binary(
ffmpeg: &Path,
req: AudioLoudnessAnalysisRequest,
) -> Result<AudioLoudnessAnalysis, String> {
let input = ensure_audio_file_exists(&req.path)?;
let output = run_ffmpeg_loudnorm_analysis(ffmpeg, input.as_path(), req.time_range)?;
let mut analysis = parse_loudnorm_analysis(&output)?;
let short_output = run_ffmpeg_ebur128_analysis(ffmpeg, input.as_path(), req.time_range)?;
let short_stats = parse_ebur128_short_term_stats(&short_output);
analysis.short_lufs_p50 = short_stats.short_lufs_p50;
analysis.short_lufs_p80 = short_stats.short_lufs_p80;
analysis.short_lufs_p95 = short_stats.short_lufs_p95;
analysis.short_lufs_max = short_stats.short_lufs_max;
analysis.presence_db = analyze_presence_db(ffmpeg, input.as_path(), req.time_range)?;
Ok(analysis)
}
pub fn probe_audio_duration_ms_with_binary(
ffmpeg: &Path,
input: &Path,
) -> Result<Option<u32>, String> {
let request =
AudioPcmDecodeRequest::new(input.to_path_buf(), DEFAULT_AUDIO_DURATION_PROBE_SAMPLE_RATE)
.with_channels(DEFAULT_AUDIO_DURATION_PROBE_CHANNELS);
let mut sample_count = 0_u64;
match run_ffmpeg_decode_f32_stream(ffmpeg, &request, |samples| {
sample_count = sample_count.saturating_add(samples.len() as u64);
Ok(())
}) {
Ok(()) => {}
Err(error) if error.starts_with("ffmpeg decode failed:") => return Ok(None),
Err(error) => return Err(error),
}
if sample_count == 0 {
return Ok(None);
}
Ok(Some(duration_ms_from_sample_count(
sample_count,
DEFAULT_AUDIO_DURATION_PROBE_SAMPLE_RATE,
DEFAULT_AUDIO_DURATION_PROBE_CHANNELS,
)))
}
pub fn decode_audio_pcm_f32_with_binary(
ffmpeg: &Path,
req: AudioPcmDecodeRequest,
) -> Result<Vec<f32>, String> {
let mut samples = Vec::new();
run_ffmpeg_decode_f32_stream(ffmpeg, &req, |chunk| {
samples.extend_from_slice(chunk);
Ok(())
})?;
if samples.is_empty() {
return Err("ffmpeg decode output is empty".to_string());
}
Ok(samples)
}
pub fn analyze_waveform_with_binary(
ffmpeg: &Path,
req: AudioWaveformAnalysisRequest,
) -> Result<AudioWaveformAnalysis, String> {
let sample_rate = req.sample_rate.max(1);
let points_per_second = req.points_per_second.max(1).min(sample_rate);
let samples_per_point = (sample_rate / points_per_second).max(1);
let start_ms = req.time_range.map(|range| range.start_ms).unwrap_or(0);
let decode_request = AudioPcmDecodeRequest {
path: req.path,
time_range: req.time_range,
sample_rate,
channels: DEFAULT_AUDIO_PCM_DECODE_CHANNELS,
filter_chain: None,
};
let mut accumulator = WaveformPeakAccumulator::new(samples_per_point);
run_ffmpeg_decode_f32_stream(ffmpeg, &decode_request, |samples| {
for &sample in samples {
accumulator.push_sample(sample);
}
Ok(())
})?;
let frame_count = accumulator.frame_count();
if frame_count == 0 {
return Err("ffmpeg waveform decode produced no audio frames".to_string());
}
Ok(AudioWaveformAnalysis {
sample_rate,
samples_per_point,
points_per_second: sample_rate / samples_per_point,
start_ms,
duration_ms: duration_ms_from_sample_count(frame_count, sample_rate, 1),
peaks: accumulator.finish(),
})
}
pub fn analyze_tail_fingerprint_with_binary(
ffmpeg: &Path,
req: AudioTailFingerprintAnalysisRequest,
) -> Result<AudioTailFingerprintAnalysis, String> {
let sample_rate = req.sample_rate.max(1);
let requested_range = req.time_range;
let requested_start_ms = requested_range.map(|range| range.start_ms).unwrap_or(0);
let requested_duration_ms = requested_range
.and_then(|range| range.duration_ms)
.unwrap_or(req.max_search_ms);
let search_duration_ms = requested_duration_ms.min(req.max_search_ms.max(1));
let search_start_ms = requested_start_ms
.saturating_add(requested_duration_ms.saturating_sub(search_duration_ms));
let decode_request = AudioPcmDecodeRequest {
path: req.path,
time_range: Some(PlaybackTimeRange {
start_ms: search_start_ms,
duration_ms: Some(search_duration_ms),
}),
sample_rate,
channels: DEFAULT_AUDIO_PCM_DECODE_CHANNELS,
filter_chain: None,
};
let samples = decode_audio_pcm_f32_with_binary(ffmpeg, decode_request)?;
analyze_tail_fingerprint_samples(
&samples,
sample_rate,
search_start_ms,
req.window_ms,
req.hop_ms,
req.spectral_bands,
req.silence_threshold_db,
req.silence_pad_ms,
)
}
fn run_ffmpeg_decode_f32_stream(
ffmpeg: &Path,
req: &AudioPcmDecodeRequest,
mut on_samples: impl FnMut(&[f32]) -> Result<(), String>,
) -> Result<(), String> {
let input = ensure_audio_file_exists(&req.path)?;
let sample_rate = req.sample_rate.max(1);
let channels = req.channels.max(1);
let filter_chain = normalize_optional_filter_chain(req.filter_chain.as_deref());
run_ffmpeg_decode_f32_chunks(
ffmpeg,
input.as_path(),
filter_chain.as_deref(),
sample_rate,
channels,
req.time_range,
|bytes| {
let samples = parse_f32le_samples(bytes)?;
on_samples(&samples)
},
)
}
pub fn run_audio_debug_probe(
ffmpeg: &Path,
output_root: &Path,
req: AudioDebugProbeRequest,
) -> Result<AudioDebugProbeResult, String> {
let input = ensure_audio_file_exists(&req.path)?;
let offset_ms = req.offset_ms.unwrap_or(0);
let capture_ms = clamp_debug_probe_capture_ms(req.capture_ms);
let sample_rate = clamp_debug_probe_sample_rate(req.sample_rate);
let channels = clamp_debug_probe_channels(req.channels);
let gain_db = compute_gain_db(req.target_lufs, req.track_lufs, req.track_true_peak_dbtp);
let playback_filter = build_playback_filter(gain_db, req.track_true_peak_dbtp);
run_audio_debug_probe_with_binary(
ffmpeg,
output_root,
input.as_path(),
gain_db,
&playback_filter,
sample_rate,
channels,
offset_ms,
capture_ms,
)
}
fn db_to_linear(db: f32) -> f32 {
10f32.powf(db / 20.0)
}
fn dbfs_from_linear(linear: f32) -> f32 {
if !linear.is_finite() || linear <= 0.0 {
DBFS_FLOOR
} else {
(20.0 * linear.log10()).max(DBFS_FLOOR)
}
}
fn sanitize_pcm_sample(sample: f32) -> f32 {
if sample.is_finite() {
sample.clamp(-1.0, 1.0)
} else {
0.0
}
}
fn compute_gain_db(
target_lufs: Option<f32>,
track_lufs: Option<f32>,
_track_true_peak_dbtp: Option<f32>,
) -> f32 {
let target = target_lufs.unwrap_or(DEFAULT_TARGET_LUFS);
let current = track_lufs.unwrap_or(target);
let mut gain_db = (target - current).clamp(MIN_GAIN_DB, MAX_GAIN_DB);
let hot_master_guard = ((current - HOT_MASTER_LUFS_THRESHOLD).max(0.0)
* HOT_MASTER_PROTECT_SLOPE)
.min(HOT_MASTER_PROTECT_MAX_DB);
gain_db -= hot_master_guard;
gain_db.clamp(MIN_GAIN_DB, MAX_GAIN_DB)
}
fn should_apply_limiter(gain_db: f32, track_true_peak_dbtp: Option<f32>) -> bool {
let Some(tp) = track_true_peak_dbtp else {
return true;
};
let predicted = tp + gain_db;
predicted > (OUTPUT_TRUE_PEAK_CEIL_DBTP - LIMITER_GUARD_MARGIN_DB)
}
fn build_playback_filter(gain_db: f32, track_true_peak_dbtp: Option<f32>) -> String {
if should_apply_limiter(gain_db, track_true_peak_dbtp) {
format!(
"volume={gain_db:.3}dB,alimiter=limit={LIMITER_LINEAR_CEIL:.6}:attack=5:release=80:level=disabled:latency=1"
)
} else {
format!("volume={gain_db:.3}dB")
}
}
fn clamp_spectrogram_size(size: Option<u32>, fallback: u32, min: u32, max: u32) -> u32 {
size.unwrap_or(fallback).clamp(min, max)
}
fn spectrogram_filter_chain(prefix: Option<&str>, width: u32, height: u32) -> String {
let suffix = format!(
"aformat=channel_layouts=mono,showspectrumpic=s={width}x{height}:legend=disabled:scale=log"
);
match prefix {
Some(prefix) if !prefix.trim().is_empty() => format!("{prefix},{suffix}"),
_ => suffix,
}
}
fn clamp_debug_probe_capture_ms(value: Option<u32>) -> u32 {
value
.unwrap_or(DEBUG_PROBE_DEFAULT_CAPTURE_MS)
.clamp(DEBUG_PROBE_MIN_CAPTURE_MS, DEBUG_PROBE_MAX_CAPTURE_MS)
}
fn clamp_debug_probe_sample_rate(value: Option<u32>) -> u32 {
value
.unwrap_or(DEBUG_PROBE_DEFAULT_SAMPLE_RATE)
.clamp(DEBUG_PROBE_MIN_SAMPLE_RATE, DEBUG_PROBE_MAX_SAMPLE_RATE)
}
fn clamp_debug_probe_channels(value: Option<u16>) -> u16 {
value
.unwrap_or(DEBUG_PROBE_DEFAULT_CHANNELS)
.clamp(1, DEBUG_PROBE_MAX_CHANNELS)
}
fn safe_probe_slug(input: &Path) -> String {
let base = input
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("track")
.trim();
let mut slug = String::new();
for ch in base.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
slug.push(ch);
} else {
slug.push('_');
}
}
let slug = slug.trim_matches('_').to_string();
if slug.is_empty() {
"track".to_string()
} else {
slug
}
}
fn analyze_probe_stats(samples: &[f32]) -> AudioDebugProbeStats {
let mut finite_count: u64 = 0;
let mut non_finite_count: u64 = 0;
let mut over_unity_count: u64 = 0;
let mut sum_sq: f64 = 0.0;
let mut peak_abs: f32 = 0.0;
for &sample in samples {
if !sample.is_finite() {
non_finite_count = non_finite_count.saturating_add(1);
continue;
}
finite_count = finite_count.saturating_add(1);
let abs = sample.abs();
if abs > peak_abs {
peak_abs = abs;
}
if abs > 1.0 {
over_unity_count = over_unity_count.saturating_add(1);
}
let s = sample as f64;
sum_sq += s * s;
}
let finite_for_ratio = finite_count.max(1);
let rms = if finite_count == 0 {
0.0
} else {
((sum_sq / finite_count as f64).sqrt()) as f32
};
AudioDebugProbeStats {
sample_count: samples.len().min(u32::MAX as usize) as u32,
finite_sample_count: finite_count.min(u32::MAX as u64) as u32,
non_finite_count: non_finite_count.min(u32::MAX as u64) as u32,
over_unity_count: over_unity_count.min(u32::MAX as u64) as u32,
over_unity_ratio: (over_unity_count as f64 / finite_for_ratio as f64) as f32,
peak_dbfs: dbfs_from_linear(peak_abs),
rms_dbfs: dbfs_from_linear(rms),
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct ShortTermLoudnessStats {
short_lufs_p50: Option<f32>,
short_lufs_p80: Option<f32>,
short_lufs_p95: Option<f32>,
short_lufs_max: Option<f32>,
}
fn loudness_percentile(mut values: Vec<f32>, percentile: f32) -> Option<f32> {
values.retain(|value| value.is_finite());
if values.is_empty() {
return None;
}
values.sort_by(|left, right| left.total_cmp(right));
let last = values.len().saturating_sub(1);
let index = ((last as f32) * percentile.clamp(0.0, 1.0)).round() as usize;
values.get(index).copied()
}
fn loudness_max(values: Vec<f32>) -> Option<f32> {
values
.into_iter()
.filter(|value| value.is_finite())
.max_by(|left, right| left.total_cmp(right))
}
fn write_f32_wav(
path: &Path,
samples: &[f32],
sample_rate: u32,
channels: u16,
) -> Result<(), String> {
let data_bytes = samples
.len()
.checked_mul(4)
.ok_or_else(|| "wav data size overflow".to_string())?;
if data_bytes > u32::MAX as usize {
return Err("wav data too large".to_string());
}
let data_bytes_u32 = data_bytes as u32;
let riff_chunk_size = 36u32
.checked_add(data_bytes_u32)
.ok_or_else(|| "wav riff size overflow".to_string())?;
let bytes_per_sample = 4u32;
let block_align = channels
.checked_mul(bytes_per_sample as u16)
.ok_or_else(|| "wav block align overflow".to_string())?;
let byte_rate = sample_rate
.checked_mul(block_align as u32)
.ok_or_else(|| "wav byte rate overflow".to_string())?;
let mut file = File::create(path).map_err(|e| e.to_string())?;
file.write_all(b"RIFF").map_err(|e| e.to_string())?;
file.write_all(&riff_chunk_size.to_le_bytes())
.map_err(|e| e.to_string())?;
file.write_all(b"WAVE").map_err(|e| e.to_string())?;
file.write_all(b"fmt ").map_err(|e| e.to_string())?;
file.write_all(&16u32.to_le_bytes())
.map_err(|e| e.to_string())?;
file.write_all(&3u16.to_le_bytes())
.map_err(|e| e.to_string())?;
file.write_all(&channels.to_le_bytes())
.map_err(|e| e.to_string())?;
file.write_all(&sample_rate.to_le_bytes())
.map_err(|e| e.to_string())?;
file.write_all(&byte_rate.to_le_bytes())
.map_err(|e| e.to_string())?;
file.write_all(&block_align.to_le_bytes())
.map_err(|e| e.to_string())?;
file.write_all(&32u16.to_le_bytes())
.map_err(|e| e.to_string())?;
file.write_all(b"data").map_err(|e| e.to_string())?;
file.write_all(&data_bytes_u32.to_le_bytes())
.map_err(|e| e.to_string())?;
for sample in samples {
file.write_all(&sample.to_le_bytes())
.map_err(|e| e.to_string())?;
}
file.flush().map_err(|e| e.to_string())
}
fn run_ffmpeg_decode_f32_chunks(
ffmpeg: &Path,
input: &Path,
filter_chain: Option<&str>,
sample_rate: u32,
channels: u16,
time_range: Option<PlaybackTimeRange>,
mut on_aligned_bytes: impl FnMut(&[u8]) -> Result<(), String>,
) -> Result<(), String> {
let mut cmd = Command::new(ffmpeg);
cmd.arg("-hide_banner")
.arg("-loglevel")
.arg("error")
.arg("-nostdin");
if let Some(range) = time_range {
if range.start_ms > 0 {
cmd.arg("-ss")
.arg(format_millis_as_seconds(range.start_ms));
}
}
cmd.arg("-i").arg(input);
if let Some(range) = time_range {
if let Some(duration_ms) = range.duration_ms {
cmd.arg("-t").arg(format_millis_as_seconds(duration_ms));
}
}
let decode_filter = build_audio_pcm_decode_filter(filter_chain, sample_rate);
cmd.arg("-vn")
.arg("-sn")
.arg("-dn")
.arg("-ac")
.arg(channels.to_string())
.arg("-ar")
.arg(sample_rate.to_string())
.arg("-filter:a")
.arg(decode_filter)
.arg("-f")
.arg("f32le")
.arg("-c:a")
.arg("pcm_f32le")
.arg("pipe:1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
let mut child = cmd.spawn().map_err(|e| e.to_string())?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "ffmpeg decode stdout pipe is missing".to_string())?;
let stderr = child
.stderr
.take()
.ok_or_else(|| "ffmpeg decode stderr pipe is missing".to_string())?;
let stderr_reader = std::thread::spawn(move || {
let mut reader = BufReader::new(stderr);
let mut message = String::new();
let _ = reader.read_to_string(&mut message);
message
});
let mut reader = BufReader::new(stdout);
let mut buffer = [0_u8; 64 * 1024];
let mut pending = Vec::<u8>::new();
let mut total_bytes = 0_u64;
loop {
let read = reader
.read(&mut buffer)
.map_err(|error| format!("failed to read ffmpeg decode output: {error}"))?;
if read == 0 {
break;
}
total_bytes = total_bytes.saturating_add(read as u64);
pending.extend_from_slice(&buffer[..read]);
let aligned_len = pending.len() / 4 * 4;
if aligned_len > 0 {
on_aligned_bytes(&pending[..aligned_len])?;
pending.drain(..aligned_len);
}
}
if !pending.is_empty() {
return Err("ffmpeg decode output is not aligned to f32".to_string());
}
let status = child.wait().map_err(|error| error.to_string())?;
let stderr_message = stderr_reader.join().unwrap_or_default();
if !status.success() {
return Err(format!("ffmpeg decode failed: {stderr_message}"));
}
if total_bytes == 0 {
return Err("ffmpeg decode output is empty".to_string());
}
Ok(())
}
fn normalize_optional_filter_chain(filter_chain: Option<&str>) -> Option<String> {
filter_chain
.map(str::trim)
.filter(|filter| !filter.is_empty())
.map(ToOwned::to_owned)
}
fn build_audio_pcm_decode_filter(filter_chain: Option<&str>, sample_rate: u32) -> String {
let resample = format!(
"aresample={}:filter_size={}:phase_shift=0:linear_interp=1",
sample_rate, DEFAULT_AUDIO_RESAMPLE_FILTER_SIZE
);
match normalize_optional_filter_chain(filter_chain) {
Some(filter) => format!("{filter},{resample}"),
None => resample,
}
}
fn run_ffmpeg_debug_decode_f32(
ffmpeg: &Path,
input: &Path,
filter_chain: Option<&str>,
sample_rate: u32,
channels: u16,
offset_ms: u32,
capture_ms: u32,
) -> Result<Vec<f32>, String> {
let mut cmd = Command::new(ffmpeg);
cmd.arg("-hide_banner")
.arg("-loglevel")
.arg("error")
.arg("-nostdin");
if offset_ms > 0 {
cmd.arg("-ss")
.arg(format!("{:.3}", offset_ms as f64 / 1000.0));
}
cmd.arg("-i").arg(input);
if let Some(filter) = filter_chain {
if let Some(filter) = normalize_optional_filter_chain(Some(filter)) {
cmd.arg("-filter:a").arg(filter);
}
}
cmd.arg("-t")
.arg(format!("{:.3}", capture_ms as f64 / 1000.0))
.arg("-vn")
.arg("-sn")
.arg("-dn")
.arg("-ac")
.arg(channels.to_string())
.arg("-ar")
.arg(sample_rate.to_string())
.arg("-f")
.arg("f32le")
.arg("-c:a")
.arg("pcm_f32le")
.arg("pipe:1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
let output = cmd.output().map_err(|e| e.to_string())?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return Err(format!("ffmpeg decode failed: {err}"));
}
if output.stdout.is_empty() {
return Err("ffmpeg decode output is empty".to_string());
}
if output.stdout.len() % 4 != 0 {
return Err("ffmpeg decode output is not aligned to f32".to_string());
}
parse_f32le_samples(&output.stdout)
}
fn parse_f32le_samples(bytes: &[u8]) -> Result<Vec<f32>, String> {
if bytes.len() % 4 != 0 {
return Err("ffmpeg decode output is not aligned to f32".to_string());
}
let mut samples = Vec::with_capacity(bytes.len() / 4);
for chunk in bytes.chunks_exact(4) {
samples.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
Ok(samples)
}
fn duration_ms_from_sample_count(sample_count: u64, sample_rate: u32, channels: u16) -> u32 {
if sample_rate == 0 {
return 0;
}
let channels = channels.max(1) as u64;
let frame_count = sample_count / channels;
let sample_rate = sample_rate as u64;
let duration_ms = frame_count
.saturating_mul(1_000)
.saturating_add(sample_rate / 2)
/ sample_rate;
duration_ms.min(u32::MAX as u64) as u32
}
struct WaveformPeakAccumulator {
samples_per_point: u32,
frame_count: u64,
current_count: u32,
current_min: f32,
current_max: f32,
peaks: Vec<AudioWaveformPeak>,
}
impl WaveformPeakAccumulator {
fn new(samples_per_point: u32) -> Self {
Self {
samples_per_point: samples_per_point.max(1),
frame_count: 0,
current_count: 0,
current_min: 0.0,
current_max: 0.0,
peaks: Vec::new(),
}
}
fn push_sample(&mut self, sample: f32) {
let sample = sanitize_pcm_sample(sample);
if self.current_count == 0 {
self.current_min = sample;
self.current_max = sample;
} else {
self.current_min = self.current_min.min(sample);
self.current_max = self.current_max.max(sample);
}
self.current_count += 1;
self.frame_count += 1;
if self.current_count >= self.samples_per_point {
self.flush_current_peak();
}
}
fn frame_count(&self) -> u64 {
self.frame_count
}
fn finish(mut self) -> Vec<AudioWaveformPeak> {
self.flush_current_peak();
self.peaks
}
fn flush_current_peak(&mut self) {
if self.current_count == 0 {
return;
}
self.peaks.push(AudioWaveformPeak {
min: self.current_min,
max: self.current_max,
});
self.current_count = 0;
self.current_min = 0.0;
self.current_max = 0.0;
}
}
fn analyze_tail_fingerprint_samples(
samples: &[f32],
sample_rate: u32,
search_start_ms: u32,
window_ms: u32,
hop_ms: u32,
spectral_bands: u32,
silence_threshold_db: f32,
silence_pad_ms: u32,
) -> Result<AudioTailFingerprintAnalysis, String> {
let sample_rate = sample_rate.max(1);
let window_ms = window_ms.max(1);
let hop_ms = hop_ms.max(1);
let spectral_bands = spectral_bands.max(1);
let window_samples = samples_from_ms(window_ms, sample_rate).max(1);
let hop_samples = samples_from_ms(hop_ms, sample_rate).max(1);
let effective_end_sample =
effective_tail_end_sample(samples, sample_rate, hop_ms, silence_threshold_db, silence_pad_ms);
let mut frames = Vec::new();
let mut end = effective_end_sample;
while end >= window_samples {
let start = end - window_samples;
let feature = spectral_fingerprint_feature(
samples,
start,
end,
sample_rate,
spectral_bands as usize,
);
frames.push(AudioTailFingerprintFrame {
source_start_ms: search_start_ms.saturating_add(ms_from_sample_index(start, sample_rate)),
source_end_ms: search_start_ms.saturating_add(ms_from_sample_index(end, sample_rate)),
rms_db: rms_db(samples, start, end),
bands: feature,
});
end = end.saturating_sub(hop_samples);
}
if frames.is_empty() {
return Err("tail fingerprint analysis produced no frames".to_string());
}
Ok(AudioTailFingerprintAnalysis {
sample_rate,
search_start_ms,
decoded_duration_ms: ms_from_sample_index(samples.len(), sample_rate),
effective_end_ms: search_start_ms
.saturating_add(ms_from_sample_index(effective_end_sample, sample_rate)),
window_ms,
hop_ms,
spectral_bands,
frames,
})
}
fn effective_tail_end_sample(
samples: &[f32],
sample_rate: u32,
hop_ms: u32,
silence_threshold_db: f32,
silence_pad_ms: u32,
) -> usize {
if samples.is_empty() {
return 0;
}
let block = samples_from_ms(hop_ms.max(1), sample_rate.max(1)).max(1);
let pad = samples_from_ms(silence_pad_ms, sample_rate.max(1));
let mut end = samples.len();
while end > 0 {
let start = end.saturating_sub(block);
if rms_db(samples, start, end) > silence_threshold_db {
return end.saturating_add(pad).min(samples.len());
}
end = start;
}
samples.len()
}
fn spectral_fingerprint_feature(
samples: &[f32],
start: usize,
end: usize,
sample_rate: u32,
band_count: usize,
) -> Vec<f32> {
let band_count = band_count.max(1);
let mut real = vec![0.0_f32; TAIL_FINGERPRINT_FFT_SIZE];
let mut imag = vec![0.0_f32; TAIL_FINGERPRINT_FFT_SIZE];
let available = end.saturating_sub(start).max(1);
for index in 0..TAIL_FINGERPRINT_FFT_SIZE {
let source_index = start + index.saturating_mul(available) / TAIL_FINGERPRINT_FFT_SIZE;
let sample = samples
.get(source_index.min(samples.len().saturating_sub(1)))
.copied()
.unwrap_or(0.0);
real[index] = sanitize_pcm_sample(sample) * hann_window(index, TAIL_FINGERPRINT_FFT_SIZE);
}
radix2_fft(&mut real, &mut imag);
let edges = spectral_band_edges(sample_rate, band_count);
let mut bands = vec![0.0_f32; band_count];
for bin in 1..(TAIL_FINGERPRINT_FFT_SIZE / 2) {
let hz = bin as f32 * sample_rate.max(1) as f32 / TAIL_FINGERPRINT_FFT_SIZE as f32;
let Some(band_index) = spectral_band_index(&edges, hz) else {
continue;
};
bands[band_index] += real[bin] * real[bin] + imag[bin] * imag[bin];
}
let values = bands.into_iter().map(f32::ln_1p).collect::<Vec<_>>();
normalize_fingerprint_vector(&values)
}
fn spectral_band_edges(sample_rate: u32, band_count: usize) -> Vec<f32> {
let band_count = band_count.max(1);
let min_hz = TAIL_FINGERPRINT_MIN_HZ;
let nyquist = sample_rate.max(1) as f32 / 2.0;
let max_hz = (nyquist - TAIL_FINGERPRINT_MAX_HZ_GUARD).max(min_hz + 1.0);
(0..=band_count)
.map(|index| {
let t = index as f32 / band_count as f32;
min_hz * (max_hz / min_hz).powf(t)
})
.collect()
}
fn spectral_band_index(edges: &[f32], hz: f32) -> Option<usize> {
if edges.len() < 2 {
return None;
}
(0..edges.len() - 1).find(|&index| hz >= edges[index] && hz < edges[index + 1])
}
fn normalize_fingerprint_vector(values: &[f32]) -> Vec<f32> {
if values.is_empty() {
return Vec::new();
}
let mean = values.iter().sum::<f32>() / values.len() as f32;
let mut centered = values.iter().map(|value| value - mean).collect::<Vec<_>>();
let norm = centered
.iter()
.map(|value| value * value)
.sum::<f32>()
.sqrt();
if norm <= 1.0e-8 || !norm.is_finite() {
centered.fill(0.0);
return centered;
}
for value in &mut centered {
*value /= norm;
}
centered
}
fn radix2_fft(real: &mut [f32], imag: &mut [f32]) {
let n = real.len();
if n == 0 || n != imag.len() {
return;
}
debug_assert!(n.is_power_of_two());
let mut j = 0_usize;
for i in 1..n {
let mut bit = n >> 1;
while j & bit != 0 {
j ^= bit;
bit >>= 1;
}
j ^= bit;
if i < j {
real.swap(i, j);
imag.swap(i, j);
}
}
let mut len = 2_usize;
while len <= n {
let angle = -2.0 * std::f32::consts::PI / len as f32;
let w_len_real = angle.cos();
let w_len_imag = angle.sin();
let half = len / 2;
let mut i = 0_usize;
while i < n {
let mut w_real = 1.0_f32;
let mut w_imag = 0.0_f32;
for k in 0..half {
let u_real = real[i + k];
let u_imag = imag[i + k];
let v_real = real[i + k + half] * w_real - imag[i + k + half] * w_imag;
let v_imag = real[i + k + half] * w_imag + imag[i + k + half] * w_real;
real[i + k] = u_real + v_real;
imag[i + k] = u_imag + v_imag;
real[i + k + half] = u_real - v_real;
imag[i + k + half] = u_imag - v_imag;
let next_real = w_real * w_len_real - w_imag * w_len_imag;
let next_imag = w_real * w_len_imag + w_imag * w_len_real;
w_real = next_real;
w_imag = next_imag;
}
i += len;
}
len <<= 1;
}
}
fn hann_window(index: usize, size: usize) -> f32 {
if size <= 1 {
return 1.0;
}
0.5 - 0.5 * (2.0 * std::f32::consts::PI * index as f32 / (size - 1) as f32).cos()
}
fn samples_from_ms(milliseconds: u32, sample_rate: u32) -> usize {
let samples = milliseconds as u64 * sample_rate.max(1) as u64 / 1_000;
samples.min(usize::MAX as u64) as usize
}
fn ms_from_sample_index(sample_index: usize, sample_rate: u32) -> u32 {
if sample_rate == 0 {
return 0;
}
let milliseconds = sample_index as u64 * 1_000 / sample_rate as u64;
milliseconds.min(u32::MAX as u64) as u32
}
fn rms_db(samples: &[f32], start: usize, end: usize) -> f32 {
if end <= start || start >= samples.len() {
return DBFS_FLOOR;
}
let end = end.min(samples.len());
let mut sum = 0.0_f32;
let mut count = 0_usize;
for &sample in &samples[start..end] {
let sample = sanitize_pcm_sample(sample);
sum += sample * sample;
count += 1;
}
if count == 0 {
return DBFS_FLOOR;
}
let rms = (sum / count as f32).sqrt().max(1.0e-8);
20.0 * rms.log10()
}
fn run_audio_debug_probe_with_binary(
ffmpeg: &Path,
output_root: &Path,
input: &Path,
gain_db: f32,
playback_filter: &str,
sample_rate: u32,
channels: u16,
offset_ms: u32,
capture_ms: u32,
) -> Result<AudioDebugProbeResult, String> {
let mut raw = run_ffmpeg_debug_decode_f32(
ffmpeg,
input,
None,
sample_rate,
channels,
offset_ms,
capture_ms,
)?;
let mut processed = run_ffmpeg_debug_decode_f32(
ffmpeg,
input,
Some(playback_filter),
sample_rate,
channels,
offset_ms,
capture_ms,
)?;
let frame_stride = channels as usize;
let sample_count = raw.len().min(processed.len()) / frame_stride * frame_stride;
if sample_count == 0 {
return Err("probe decode produced no aligned audio frame".to_string());
}
raw.truncate(sample_count);
processed.truncate(sample_count);
let mut output = Vec::with_capacity(sample_count);
output.extend(processed.iter().map(|&sample| sanitize_pcm_sample(sample)));
let raw_stats = analyze_probe_stats(&raw);
let processed_stats = analyze_probe_stats(&processed);
let output_stats = analyze_probe_stats(&output);
fs::create_dir_all(output_root).map_err(|e| e.to_string())?;
let epoch_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let slug = safe_probe_slug(input);
let output_dir = output_root.join(format!("{slug}-{epoch_ms}"));
fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
let raw_wav = output_dir.join("raw.wav");
let processed_wav = output_dir.join("processed.wav");
let output_wav = output_dir.join("output.wav");
let metadata_json = output_dir.join("metadata.json");
write_f32_wav(&raw_wav, &raw, sample_rate, channels)?;
write_f32_wav(&processed_wav, &processed, sample_rate, channels)?;
write_f32_wav(&output_wav, &output, sample_rate, channels)?;
let frame_count = sample_count / frame_stride;
let actual_capture_ms = ((frame_count as f64 * 1000.0) / sample_rate as f64).round() as u32;
let metadata = AudioDebugProbeMetadata {
created_epoch_ms: epoch_ms.min(u64::MAX as u128) as u64,
input_path: input.to_string_lossy().to_string(),
gain_db,
playback_filter: playback_filter.to_string(),
sample_rate,
channels,
offset_ms,
requested_capture_ms: capture_ms,
actual_capture_ms,
raw_stats: raw_stats.clone(),
processed_stats: processed_stats.clone(),
output_stats: output_stats.clone(),
};
let metadata_raw = serde_json::to_vec_pretty(&metadata).map_err(|e| e.to_string())?;
fs::write(&metadata_json, metadata_raw).map_err(|e| e.to_string())?;
Ok(AudioDebugProbeResult {
output_dir: output_dir.to_string_lossy().to_string(),
metadata_json: metadata_json.to_string_lossy().to_string(),
raw_wav: raw_wav.to_string_lossy().to_string(),
processed_wav: processed_wav.to_string_lossy().to_string(),
output_wav: output_wav.to_string_lossy().to_string(),
gain_db,
playback_filter: playback_filter.to_string(),
sample_rate,
channels,
offset_ms,
requested_capture_ms: capture_ms,
actual_capture_ms,
raw_stats,
processed_stats,
output_stats,
})
}
fn run_ffmpeg_spectrogram_png(
ffmpeg: &Path,
input: &Path,
filter_chain: &str,
) -> Result<Vec<u8>, String> {
let mut cmd = Command::new(ffmpeg);
cmd.arg("-hide_banner")
.arg("-loglevel")
.arg("error")
.arg("-nostdin")
.arg("-i")
.arg(input)
.arg("-lavfi")
.arg(filter_chain)
.arg("-frames:v")
.arg("1")
.arg("-f")
.arg("image2pipe")
.arg("-vcodec")
.arg("png")
.arg("pipe:1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
let output = cmd.output().map_err(|e| e.to_string())?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return Err(format!("ffmpeg spectrogram failed: {err}"));
}
if output.stdout.is_empty() {
return Err("ffmpeg spectrogram output is empty".to_string());
}
Ok(output.stdout)
}
fn run_ffmpeg_loudnorm_analysis(
ffmpeg: &Path,
input: &Path,
time_range: Option<PlaybackTimeRange>,
) -> Result<String, String> {
let mut cmd = Command::new(ffmpeg);
for arg in build_ffmpeg_loudnorm_analysis_args(input, time_range) {
cmd.arg(arg);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
let output = cmd
.output()
.map_err(|error| format!("failed to run ffmpeg loudness analysis: {error}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}\n{stderr}");
if !output.status.success() {
return Err(format!("ffmpeg loudness analysis failed: {combined}"));
}
Ok(combined)
}
fn run_ffmpeg_ebur128_analysis(
ffmpeg: &Path,
input: &Path,
time_range: Option<PlaybackTimeRange>,
) -> Result<String, String> {
run_ffmpeg_text_analysis(
ffmpeg,
build_ffmpeg_ebur128_analysis_args(input, time_range),
"ffmpeg ebur128 analysis",
)
}
fn analyze_presence_db(
ffmpeg: &Path,
input: &Path,
time_range: Option<PlaybackTimeRange>,
) -> Result<Option<f32>, String> {
let full = run_ffmpeg_astats_analysis(
ffmpeg,
input,
time_range,
"astats=metadata=0:reset=0",
)
.and_then(|output| parse_astats_overall_rms_db(&output))?;
let presence = run_ffmpeg_astats_analysis(
ffmpeg,
input,
time_range,
"bandpass=f=2500:width_type=h:width=4000,astats=metadata=0:reset=0",
)
.and_then(|output| parse_astats_overall_rms_db(&output))?;
Ok(match (full, presence) {
(Some(full), Some(presence)) => Some(presence - full),
_ => None,
})
}
fn run_ffmpeg_astats_analysis(
ffmpeg: &Path,
input: &Path,
time_range: Option<PlaybackTimeRange>,
filter: &str,
) -> Result<String, String> {
run_ffmpeg_text_analysis(
ffmpeg,
build_ffmpeg_astats_analysis_args(input, time_range, filter),
"ffmpeg astats analysis",
)
}
fn run_ffmpeg_text_analysis(
ffmpeg: &Path,
args: Vec<OsString>,
label: &str,
) -> Result<String, String> {
let mut cmd = Command::new(ffmpeg);
for arg in args {
cmd.arg(arg);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
let output = cmd
.output()
.map_err(|error| format!("failed to run {label}: {error}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}\n{stderr}");
if !output.status.success() {
return Err(format!("{label} failed: {combined}"));
}
Ok(combined)
}
fn build_ffmpeg_loudnorm_analysis_args(
input: &Path,
time_range: Option<PlaybackTimeRange>,
) -> Vec<OsString> {
let mut args = vec![
OsString::from("-hide_banner"),
OsString::from("-loglevel"),
OsString::from("info"),
OsString::from("-nostdin"),
];
if let Some(range) = time_range {
if range.start_ms > 0 {
args.push(OsString::from("-ss"));
args.push(OsString::from(format_millis_as_seconds(range.start_ms)));
}
}
args.push(OsString::from("-i"));
args.push(input.as_os_str().to_owned());
if let Some(range) = time_range {
if let Some(duration_ms) = range.duration_ms {
args.push(OsString::from("-t"));
args.push(OsString::from(format_millis_as_seconds(duration_ms)));
}
}
args.extend([
OsString::from("-filter:a"),
OsString::from("loudnorm=I=-18:TP=-2:LRA=11:print_format=json"),
OsString::from("-f"),
OsString::from("null"),
OsString::from("-"),
]);
args
}
fn build_ffmpeg_ebur128_analysis_args(
input: &Path,
time_range: Option<PlaybackTimeRange>,
) -> Vec<OsString> {
build_ffmpeg_filter_analysis_args(
input,
time_range,
"ebur128=peak=true",
"info",
)
}
fn build_ffmpeg_astats_analysis_args(
input: &Path,
time_range: Option<PlaybackTimeRange>,
filter: &str,
) -> Vec<OsString> {
build_ffmpeg_filter_analysis_args(input, time_range, filter, "info")
}
fn build_ffmpeg_filter_analysis_args(
input: &Path,
time_range: Option<PlaybackTimeRange>,
filter: &str,
loglevel: &str,
) -> Vec<OsString> {
let mut args = vec![
OsString::from("-hide_banner"),
OsString::from("-loglevel"),
OsString::from(loglevel),
OsString::from("-nostdin"),
];
if let Some(range) = time_range {
if range.start_ms > 0 {
args.push(OsString::from("-ss"));
args.push(OsString::from(format_millis_as_seconds(range.start_ms)));
}
}
args.push(OsString::from("-i"));
args.push(input.as_os_str().to_owned());
if let Some(range) = time_range {
if let Some(duration_ms) = range.duration_ms {
args.push(OsString::from("-t"));
args.push(OsString::from(format_millis_as_seconds(duration_ms)));
}
}
args.extend([
OsString::from("-filter:a"),
OsString::from(filter),
OsString::from("-f"),
OsString::from("null"),
OsString::from("-"),
]);
args
}
fn parse_loudnorm_analysis(output: &str) -> Result<AudioLoudnessAnalysis, String> {
let json = extract_last_json_object(output)
.ok_or_else(|| "ffmpeg loudness analysis did not emit loudnorm JSON".to_string())?;
let value: serde_json::Value = serde_json::from_str(json)
.map_err(|error| format!("failed to parse loudnorm JSON: {error}"))?;
let integrated_lufs = parse_loudnorm_number(&value, "input_i")?
.ok_or_else(|| "loudnorm JSON is missing input_i".to_string())?;
let true_peak_dbtp = parse_loudnorm_number(&value, "input_tp")?;
let lra = parse_loudnorm_number(&value, "input_lra")?;
Ok(AudioLoudnessAnalysis {
integrated_lufs,
true_peak_dbtp,
lra,
short_lufs_p50: None,
short_lufs_p80: None,
short_lufs_p95: None,
short_lufs_max: None,
presence_db: None,
})
}
fn parse_ebur128_short_term_stats(output: &str) -> ShortTermLoudnessStats {
let values = parse_ebur128_short_term_values(output);
ShortTermLoudnessStats {
short_lufs_p50: loudness_percentile(values.clone(), 0.50),
short_lufs_p80: loudness_percentile(values.clone(), 0.80),
short_lufs_p95: loudness_percentile(values.clone(), 0.95),
short_lufs_max: loudness_max(values),
}
}
fn parse_ebur128_short_term_values(output: &str) -> Vec<f32> {
output
.lines()
.filter_map(|line| parse_labeled_number_after(line, " S:"))
.filter(|value| value.is_finite() && *value > EBUR128_SHORT_TERM_SILENCE_FLOOR_LUFS)
.collect()
}
fn parse_astats_overall_rms_db(output: &str) -> Result<Option<f32>, String> {
let mut in_overall = false;
let mut last_any = None;
for line in output.lines() {
if line.contains("Overall") {
in_overall = true;
continue;
}
if let Some(value) = parse_labeled_number_after(line, "RMS level dB:") {
if !value.is_finite() {
return Err("astats RMS level is not finite".to_string());
}
if in_overall {
return Ok(Some(value));
}
last_any = Some(value);
}
}
Ok(last_any)
}
fn parse_labeled_number_after(line: &str, label: &str) -> Option<f32> {
let start = line.find(label)? + label.len();
let rest = line[start..].trim_start();
let token = rest
.split_whitespace()
.next()?
.trim_matches(|ch: char| ch == ',' || ch == ';');
token.parse::<f32>().ok()
}
fn parse_loudnorm_number(value: &serde_json::Value, key: &str) -> Result<Option<f32>, String> {
let Some(raw) = value.get(key) else {
return Ok(None);
};
let parsed = match raw {
serde_json::Value::Number(number) => number.as_f64().map(|value| value as f32),
serde_json::Value::String(text) => text.parse::<f32>().ok(),
_ => None,
};
match parsed {
Some(value) if value.is_finite() => Ok(Some(value)),
Some(_) => Err(format!("loudnorm field `{key}` is not finite")),
None => Err(format!("loudnorm field `{key}` is not numeric")),
}
}
fn extract_last_json_object(text: &str) -> Option<&str> {
let bytes = text.as_bytes();
let mut depth = 0usize;
let mut start = None;
let mut last = None;
for (index, byte) in bytes.iter().enumerate() {
match byte {
b'{' => {
if depth == 0 {
start = Some(index);
}
depth += 1;
}
b'}' if depth > 0 => {
depth -= 1;
if depth == 0 {
if let Some(start_index) = start {
last = text.get(start_index..=index);
}
start = None;
}
}
_ => {}
}
}
last
}
fn resolve_ffprobe_path(ffmpeg: &Path) -> PathBuf {
let exe = if cfg!(windows) {
"ffprobe.exe"
} else {
"ffprobe"
};
if let Some(parent) = ffmpeg.parent() {
let sibling = parent.join(exe);
if sibling.exists() {
return sibling;
}
}
PathBuf::from(exe)
}
fn probe_duration_ms_with_ffprobe(ffmpeg: &Path, input: &Path) -> Option<u32> {
let ffprobe = resolve_ffprobe_path(ffmpeg);
let mut cmd = Command::new(ffprobe);
cmd.arg("-v")
.arg("error")
.arg("-show_entries")
.arg("format=duration")
.arg("-of")
.arg("default=noprint_wrappers=1:nokey=1")
.arg(input)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
let output = cmd.output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let secs = text.trim().parse::<f64>().ok()?;
if !secs.is_finite() || secs <= 0.0 {
return None;
}
let millis = (secs * 1000.0).round();
if !millis.is_finite() || millis <= 0.0 {
return None;
}
Some((millis as u128).min(u32::MAX as u128) as u32)
}
fn saturating_millis_u32(duration: Duration) -> u32 {
duration.as_millis().min(u32::MAX as u128) as u32
}
fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
return (*message).to_string();
}
if let Some(message) = payload.downcast_ref::<String>() {
return message.clone();
}
"unknown panic payload".to_string()
}
fn catch_engine<T, F>(stage: &str, op: F) -> Result<T, String>
where
F: FnOnce() -> Result<T, String>,
{
match panic::catch_unwind(AssertUnwindSafe(op)) {
Ok(result) => result,
Err(payload) => Err(format!(
"audio engine panic during {stage}: {}",
panic_payload_to_string(payload)
)),
}
}
fn open_decoder(path: &Path) -> Result<(Decoder<BufReader<File>>, Option<u32>), String> {
let file = File::open(path).map_err(|e| e.to_string())?;
let decoder = Decoder::new(BufReader::new(file)).map_err(|e| e.to_string())?;
let duration_ms = decoder.total_duration().map(saturating_millis_u32);
Ok((decoder, duration_ms))
}
fn pick_preferred_output_config(device: &cpal::Device) -> Result<SupportedStreamConfig, String> {
let default_config = device.default_output_config().map_err(|e| e.to_string())?;
if default_config.sample_format() == SampleFormat::F32 {
return Ok(default_config);
}
let default_rate = default_config.sample_rate();
let default_channels = default_config.channels();
let mut best_f32: Option<(i64, SupportedStreamConfig)> = None;
for range in device
.supported_output_configs()
.map_err(|e| e.to_string())?
{
if range.sample_format() != SampleFormat::F32 {
continue;
}
let chosen_rate = default_rate.clamp(range.min_sample_rate(), range.max_sample_rate());
let chosen = range.with_sample_rate(chosen_rate);
let channel_penalty =
(i32::from(chosen.channels()) - i32::from(default_channels)).abs() as i64 * 1_000_000;
let rate_penalty = (i64::from(chosen_rate) - i64::from(default_rate)).abs();
let score = channel_penalty + rate_penalty;
match best_f32 {
Some((best_score, _)) if score >= best_score => {}
_ => best_f32 = Some((score, chosen)),
}
}
Ok(best_f32.map(|(_, config)| config).unwrap_or(default_config))
}
fn try_backend_from_device(device: &cpal::Device) -> Result<AudioBackend, String> {
let config = pick_preferred_output_config(device)?;
let device_sink = DeviceSinkBuilder::from_device(device.clone())
.map_err(|e| e.to_string())?
.with_supported_config(&config)
.open_stream()
.map_err(|e| e.to_string())?;
Ok(AudioBackend {
device_sink,
output_channels: config.channels(),
output_sample_rate: config.sample_rate(),
})
}
fn create_audio_backend() -> Result<AudioBackend, String> {
let host = cpal::default_host();
let default_device = host
.default_output_device()
.ok_or_else(|| "audio output device not found".to_string())?;
let mut last_error = match try_backend_from_device(&default_device) {
Ok(backend) => return Ok(backend),
Err(err) => err,
};
for device in host.output_devices().map_err(|e| e.to_string())? {
match try_backend_from_device(&device) {
Ok(backend) => return Ok(backend),
Err(err) => last_error = err,
}
}
Err(last_error)
}
fn open_ffmpeg_stream_with_binary(
ffmpeg: &Path,
input: &Path,
time_range: Option<PlaybackTimeRange>,
gain_db: f32,
track_true_peak_dbtp: Option<f32>,
output_channels: u16,
output_sample_rate: u32,
) -> Result<(FfmpegPcmSource, Option<u32>), String> {
let filter = build_playback_filter(gain_db, track_true_peak_dbtp);
let mut cmd = Command::new(ffmpeg);
for arg in build_ffmpeg_stream_args(
input,
time_range,
&filter,
output_channels,
output_sample_rate,
) {
cmd.arg(arg);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
let mut child = cmd.spawn().map_err(|e| e.to_string())?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "ffmpeg stdout pipe is missing".to_string())?;
Ok((
FfmpegPcmSource {
child: Some(child),
stdout: BufReader::new(stdout),
channels: output_channels,
sample_rate: output_sample_rate,
},
time_range.and_then(|range| range.duration_ms),
))
}
fn build_ffmpeg_stream_args(
input: &Path,
time_range: Option<PlaybackTimeRange>,
filter: &str,
output_channels: u16,
output_sample_rate: u32,
) -> Vec<OsString> {
let mut args = vec![
OsString::from("-hide_banner"),
OsString::from("-loglevel"),
OsString::from("error"),
OsString::from("-nostdin"),
];
if let Some(range) = time_range {
if range.start_ms > 0 {
args.push(OsString::from("-ss"));
args.push(OsString::from(format_millis_as_seconds(range.start_ms)));
}
}
args.push(OsString::from("-i"));
args.push(input.as_os_str().to_owned());
args.push(OsString::from("-filter:a"));
args.push(OsString::from(filter));
if let Some(range) = time_range {
if let Some(duration_ms) = range.duration_ms {
args.push(OsString::from("-t"));
args.push(OsString::from(format_millis_as_seconds(duration_ms)));
}
}
args.extend([
OsString::from("-vn"),
OsString::from("-sn"),
OsString::from("-dn"),
OsString::from("-ac"),
OsString::from(output_channels.to_string()),
OsString::from("-ar"),
OsString::from(output_sample_rate.to_string()),
OsString::from("-f"),
OsString::from("f32le"),
OsString::from("-c:a"),
OsString::from("pcm_f32le"),
OsString::from("pipe:1"),
]);
args
}
fn run_engine_loop(event_sink: Arc<dyn AudioEventSink>, rx: mpsc::Receiver<EngineCmd>) {
let backend = create_audio_backend();
let mut state = PlayerState::new();
loop {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(cmd) => {
let result = panic::catch_unwind(AssertUnwindSafe(|| {
handle_cmd(event_sink.as_ref(), &backend, &mut state, cmd);
}));
if let Err(payload) = result {
eprintln!(
"audio engine command loop panicked: {}",
panic_payload_to_string(payload)
);
state.clear_immediate();
}
}
Err(RecvTimeoutError::Timeout) => {
let result = panic::catch_unwind(AssertUnwindSafe(|| {
emit_state_and_maybe_end(event_sink.as_ref(), &mut state);
}));
if let Err(payload) = result {
eprintln!(
"audio engine tick loop panicked: {}",
panic_payload_to_string(payload)
);
state.clear_immediate();
}
}
Err(RecvTimeoutError::Disconnected) => break,
}
}
}
fn emit_state_and_maybe_end(event_sink: &dyn AudioEventSink, state: &mut PlayerState) {
if state.path.is_none() {
return;
}
let sink_empty = state.sink.as_ref().map(|sink| sink.empty()).unwrap_or(true);
let status = state.to_status();
event_sink.emit_state(&status);
if sink_empty {
if let (Some(session_id), Some(path)) = (state.session_id, state.path.clone()) {
event_sink.emit_ended(&AudioEndedPayload { session_id, path });
}
state.clear_immediate();
}
}
fn handle_cmd(
event_sink: &dyn AudioEventSink,
backend: &Result<AudioBackend, String>,
state: &mut PlayerState,
cmd: EngineCmd,
) {
match cmd {
EngineCmd::Play { req, respond } => {
let failure_context = (req.session_id, req.path.clone());
let result = catch_engine("play", || handle_play(backend, state, req));
if let Err(error) = &result {
emit_transport_failure(
event_sink,
Some((failure_context.0, failure_context.1.as_str())),
state,
"play",
error.clone(),
);
}
let _ = respond.send(result);
emit_state_and_maybe_end(event_sink, state);
}
EngineCmd::Pause { respond } => {
let result = catch_engine("pause", || handle_pause(state));
if let Err(error) = &result {
emit_transport_failure(event_sink, None, state, "pause", error.clone());
} else {
emit_transport_paused(event_sink, state);
}
let _ = respond.send(result);
emit_state_and_maybe_end(event_sink, state);
}
EngineCmd::Resume { respond } => {
let result = catch_engine("resume", || handle_resume(state));
if let Err(error) = &result {
emit_transport_failure(event_sink, None, state, "resume", error.clone());
} else {
emit_transport_resumed(event_sink, state);
}
let _ = respond.send(result);
emit_state_and_maybe_end(event_sink, state);
}
EngineCmd::Stop { respond } => {
let stopped_identity = state.session_id.zip(state.path.clone());
let result = catch_engine("stop", || {
state.clear_with_stop_fade();
Ok(())
});
if result.is_ok() {
emit_transport_stopped(
event_sink,
stopped_identity
.as_ref()
.map(|(session_id, path)| (*session_id, path.as_str())),
state,
);
}
let _ = respond.send(result);
}
EngineCmd::Status { respond } => {
let _ = respond.send(catch_engine("status", || Ok(state.to_status())));
}
}
}
fn emit_transport_paused(event_sink: &dyn AudioEventSink, state: &PlayerState) {
if let (true, Some(session_id), Some(path)) =
(state.paused, state.session_id, state.path.clone())
{
event_sink.emit_paused(&AudioPausedPayload { session_id, path });
}
}
fn emit_transport_resumed(event_sink: &dyn AudioEventSink, state: &PlayerState) {
if let (false, Some(session_id), Some(path)) =
(state.paused, state.session_id, state.path.clone())
{
event_sink.emit_resumed(&AudioResumedPayload { session_id, path });
}
}
fn emit_transport_stopped(
event_sink: &dyn AudioEventSink,
explicit: Option<(u64, &str)>,
state: &PlayerState,
) {
let explicit = explicit.map(|(session_id, path)| (session_id, path.to_string()));
if let Some((session_id, path)) = explicit.or_else(|| state.session_id.zip(state.path.clone()))
{
event_sink.emit_stopped(&AudioStoppedPayload { session_id, path });
}
}
fn emit_transport_failure(
event_sink: &dyn AudioEventSink,
explicit: Option<(u64, &str)>,
state: &PlayerState,
action: &str,
error: String,
) {
let explicit = explicit.map(|(session_id, path)| (session_id, path.to_string()));
if let Some((session_id, path)) = explicit.or_else(|| state.session_id.zip(state.path.clone()))
{
event_sink.emit_failed(&AudioFailedPayload {
session_id,
path,
action: action.to_string(),
error,
});
}
}
fn handle_play(
backend: &Result<AudioBackend, String>,
state: &mut PlayerState,
req: ResolvedAudioPlayRequest,
) -> Result<AudioPlayAck, String> {
let backend = backend.as_ref().map_err(|e| e.clone())?;
let path = Path::new(&req.path);
if !path.exists() {
return Err(format!("audio file not found: {}", req.path));
}
let (source, duration_ms) = catch_engine("ffmpeg-stream-open", || {
open_ffmpeg_stream_with_binary(
&req.ffmpeg_path,
path,
req.time_range,
req.gain_db,
req.track_true_peak_dbtp,
backend.output_channels,
backend.output_sample_rate,
)
})?;
let sink = Player::connect_new(backend.device_sink.mixer());
sink.append(source);
sink.play();
let previous_sink = state.sink.take();
state.session_id = Some(req.session_id);
state.path = Some(req.path.clone());
state.started_at = Some(Instant::now());
state.paused_started_at = None;
state.paused_total = Duration::from_millis(0);
state.duration_ms = duration_ms;
state.paused = false;
state.sink = Some(sink);
if let Some(previous_sink) = previous_sink {
fade_out_and_stop_player(previous_sink, DEFAULT_STOP_FADE_DURATION);
}
Ok(AudioPlayAck {
session_id: req.session_id,
path: req.path,
duration_ms,
gain: db_to_linear(req.gain_db),
gain_db: req.gain_db,
target_lufs: req.target_lufs,
integrated_lufs: req.integrated_lufs,
has_canonical_loudness: req.has_canonical_loudness,
})
}
fn handle_pause(state: &mut PlayerState) -> Result<(), String> {
let Some(sink) = state.sink.as_ref() else {
return Ok(());
};
if !state.paused {
sink.pause();
state.paused = true;
state.paused_started_at = Some(Instant::now());
}
Ok(())
}
fn handle_resume(state: &mut PlayerState) -> Result<(), String> {
let Some(sink) = state.sink.as_ref() else {
return Ok(());
};
if state.paused {
sink.play();
state.resume_at(Instant::now());
}
Ok(())
}
fn fade_out_and_stop_player(player: Player, duration: Duration) {
if duration.is_zero() || player.empty() || player.is_paused() {
player.stop();
return;
}
let initial_volume = player.volume();
if !initial_volume.is_finite() || initial_volume <= 0.0 {
player.stop();
return;
}
let step_duration = duration / STOP_FADE_STEPS;
if step_duration.is_zero() {
player.set_volume(0.0);
player.stop();
return;
}
for step in 1..=STOP_FADE_STEPS {
player.set_volume(stop_fade_gain(initial_volume, step, STOP_FADE_STEPS));
std::thread::sleep(step_duration);
if player.empty() {
break;
}
}
player.set_volume(0.0);
player.stop();
}
fn stop_fade_gain(initial_volume: f32, step: u32, total_steps: u32) -> f32 {
if total_steps == 0 || step >= total_steps {
return 0.0;
}
let t = (step as f32 / total_steps as f32).clamp(0.0, 1.0);
let smooth = t * t * (3.0 - 2.0 * t);
initial_volume * (1.0 - smooth).clamp(0.0, 1.0)
}
fn seconds_to_millis(seconds: u32) -> u32 {
seconds.saturating_mul(1000)
}
fn format_millis_as_seconds(milliseconds: u32) -> String {
format!("{:.3}", milliseconds as f64 / 1000.0)
}
#[cfg(test)]
mod tests {
use super::{
analyze_probe_stats, build_ffmpeg_astats_analysis_args, build_ffmpeg_ebur128_analysis_args,
build_ffmpeg_loudnorm_analysis_args, build_ffmpeg_stream_args, build_playback_filter,
compute_gain_db, ensure_audio_file_exists, analyze_tail_fingerprint_samples,
ensure_debug_mode_available, extract_last_json_object, merge_normalization,
parse_astats_overall_rms_db, parse_ebur128_short_term_stats, parse_loudnorm_analysis,
build_audio_pcm_decode_filter, duration_ms_from_sample_count, parse_f32le_samples,
normalize_fingerprint_vector, effective_tail_end_sample, spectral_fingerprint_feature,
sanitize_pcm_sample, stop_fade_gain, AudioPlayRequest, AudioWaveformPeak,
PlaybackNormalization, PlaybackRequest, PlaybackTimeRange, WaveformPeakAccumulator,
DEFAULT_TARGET_LUFS, STOP_FADE_STEPS,
};
use std::path::Path;
fn args_to_strings(args: Vec<std::ffi::OsString>) -> Vec<String> {
args.into_iter()
.map(|arg| arg.to_string_lossy().to_string())
.collect()
}
#[test]
fn compute_gain_should_boost_canonical_loudness_even_without_true_peak() {
assert_eq!(compute_gain_db(Some(-18.0), Some(-24.0), None), 6.0);
assert!(build_playback_filter(6.0, None).contains("alimiter="));
}
#[test]
fn compute_gain_should_keep_safety_floor_when_canonical_loudness_is_missing() {
let gain_db = compute_gain_db(Some(-16.0), None, None);
assert_eq!(gain_db, 0.0);
assert!(build_playback_filter(gain_db, None).contains("alimiter="));
}
#[test]
fn debug_mode_guard_returns_explicit_command_error() {
if cfg!(debug_assertions) {
assert_eq!(
ensure_debug_mode_available("audio_debug_pipeline_probe"),
Ok(())
);
}
}
#[test]
fn missing_audio_file_returns_explicit_error() {
let err = ensure_audio_file_exists("C:/missing/file.flac").unwrap_err();
assert!(err.starts_with("audio file not found: "));
}
#[test]
fn sanitize_pcm_sample_should_clamp_and_zero_non_finite() {
assert_eq!(sanitize_pcm_sample(1.4), 1.0);
assert_eq!(sanitize_pcm_sample(-1.2), -1.0);
assert_eq!(sanitize_pcm_sample(f32::NAN), 0.0);
}
#[test]
fn pcm_decode_filter_composes_domain_filter_before_resample() {
assert_eq!(
build_audio_pcm_decode_filter(Some("highpass=f=80"), 16_000),
"highpass=f=80,aresample=16000:filter_size=8:phase_shift=0:linear_interp=1"
);
assert_eq!(
build_audio_pcm_decode_filter(None, 48_000),
"aresample=48000:filter_size=8:phase_shift=0:linear_interp=1"
);
}
#[test]
fn f32le_parser_rejects_unaligned_audio_bytes() {
assert_eq!(
parse_f32le_samples(&0.25_f32.to_le_bytes()).unwrap(),
vec![0.25]
);
assert_eq!(
parse_f32le_samples(&[1, 2, 3]).unwrap_err(),
"ffmpeg decode output is not aligned to f32"
);
}
#[test]
fn duration_probe_uses_decoded_frame_count() {
assert_eq!(duration_ms_from_sample_count(48_000, 48_000, 1), 1_000);
assert_eq!(duration_ms_from_sample_count(96_000, 48_000, 2), 1_000);
assert_eq!(duration_ms_from_sample_count(24_024, 48_000, 1), 501);
}
fn dot(left: &[f32], right: &[f32]) -> f32 {
left.iter()
.zip(right.iter())
.map(|(left, right)| left * right)
.sum()
}
fn sine_wave(freq_hz: f32, sample_rate: u32, duration_ms: u32, gain: f32) -> Vec<f32> {
let samples = (sample_rate as u64 * duration_ms as u64 / 1_000) as usize;
(0..samples)
.map(|index| {
let t = index as f32 / sample_rate as f32;
(2.0 * std::f32::consts::PI * freq_hz * t).sin() * gain
})
.collect()
}
#[test]
fn tail_fingerprint_normalization_is_mean_centered_and_scale_stable() {
let normalized = normalize_fingerprint_vector(&[1.0, 2.0, 4.0, 8.0]);
let mean = normalized.iter().sum::<f32>() / normalized.len() as f32;
let norm = normalized
.iter()
.map(|value| value * value)
.sum::<f32>()
.sqrt();
assert!(mean.abs() < 1.0e-6);
assert!((norm - 1.0).abs() < 1.0e-6);
assert_eq!(
normalize_fingerprint_vector(&[3.0, 3.0, 3.0]),
vec![0.0, 0.0, 0.0]
);
}
#[test]
fn tail_fingerprint_effective_end_ignores_trailing_silence_with_pad() {
let sample_rate = 1_000;
let mut samples = vec![0.05; 2_000];
samples.extend(std::iter::repeat_n(0.0, 1_000));
assert_eq!(
effective_tail_end_sample(&samples, sample_rate, 100, -55.0, 100),
2_100
);
}
#[test]
fn tail_spectral_feature_is_scale_stable_and_frequency_sensitive() {
let sample_rate = 8_000;
let low = sine_wave(220.0, sample_rate, 1_000, 0.25);
let low_louder = sine_wave(220.0, sample_rate, 1_000, 0.75);
let high = sine_wave(1_200.0, sample_rate, 1_000, 0.25);
let low_feature = spectral_fingerprint_feature(&low, 0, low.len(), sample_rate, 24);
let low_louder_feature =
spectral_fingerprint_feature(&low_louder, 0, low_louder.len(), sample_rate, 24);
let high_feature = spectral_fingerprint_feature(&high, 0, high.len(), sample_rate, 24);
assert!(dot(&low_feature, &low_louder_feature) > 0.98);
assert!(dot(&low_feature, &high_feature) < 0.70);
}
#[test]
fn tail_fingerprint_analysis_builds_reverse_frames_on_source_timeline() {
let sample_rate = 8_000;
let mut samples = sine_wave(440.0, sample_rate, 3_000, 0.20);
samples.extend(std::iter::repeat_n(0.0, sample_rate as usize));
let analysis = analyze_tail_fingerprint_samples(
&samples,
sample_rate,
10_000,
1_000,
500,
24,
-55.0,
250,
)
.expect("tail fingerprint should produce frames");
assert_eq!(analysis.sample_rate, sample_rate);
assert_eq!(analysis.search_start_ms, 10_000);
assert_eq!(analysis.decoded_duration_ms, 4_000);
assert_eq!(analysis.effective_end_ms, 13_250);
assert_eq!(analysis.frames[0].source_end_ms, 13_250);
assert_eq!(analysis.frames[0].source_start_ms, 12_250);
assert!(analysis.frames[0].rms_db > -30.0);
assert_eq!(analysis.frames[0].bands.len(), 24);
}
#[test]
fn waveform_accumulator_keeps_bucket_peak_shape() {
let mut accumulator = WaveformPeakAccumulator::new(3);
for sample in [0.2, -0.8, 0.4, 0.1, -0.3] {
accumulator.push_sample(sample);
}
assert_eq!(accumulator.frame_count(), 5);
assert_eq!(
accumulator.finish(),
vec![
AudioWaveformPeak {
min: -0.8,
max: 0.4,
},
AudioWaveformPeak {
min: -0.3,
max: 0.1,
},
]
);
}
#[test]
fn analyze_probe_stats_should_report_over_unity_and_non_finite() {
let stats = analyze_probe_stats(&[0.0, 0.5, 1.2, -1.5, f32::NAN, f32::INFINITY]);
assert_eq!(stats.sample_count, 6);
assert_eq!(stats.finite_sample_count, 4);
assert_eq!(stats.non_finite_count, 2);
assert_eq!(stats.over_unity_count, 2);
}
#[test]
fn playback_request_defaults_to_engine_normalization_and_generated_session() {
let request = PlaybackRequest::new("track.mp3");
assert_eq!(request.path, std::path::PathBuf::from("track.mp3"));
assert_eq!(request.session_id, None);
assert_eq!(request.normalization, None);
assert_eq!(request.time_range, None);
}
#[test]
fn playback_request_accepts_bounded_time_range() {
let range = PlaybackTimeRange::bounded_seconds(12, 45).unwrap();
let request = PlaybackRequest::new("track.mp3").with_time_range(range);
assert_eq!(request.time_range, Some(range));
assert_eq!(range.start_ms, 12_000);
assert_eq!(range.duration_ms, Some(33_000));
}
#[test]
fn bounded_time_range_requires_end_after_start() {
assert!(PlaybackTimeRange::bounded_seconds(42, 42).is_err());
assert!(PlaybackTimeRange::bounded_seconds(43, 42).is_err());
}
#[test]
fn ffmpeg_stream_args_seek_before_input_and_limit_output_duration() {
let range = PlaybackTimeRange::bounded_seconds(12, 45).unwrap();
let args = args_to_strings(build_ffmpeg_stream_args(
Path::new("track.flac"),
Some(range),
"volume=1.0",
2,
48_000,
));
assert!(args.windows(2).any(|pair| pair == ["-ss", "12.000"]));
assert!(args.windows(2).any(|pair| pair == ["-i", "track.flac"]));
assert!(args.windows(2).any(|pair| pair == ["-t", "33.000"]));
let seek_index = args.iter().position(|arg| arg == "-ss").unwrap();
let input_index = args.iter().position(|arg| arg == "-i").unwrap();
let duration_index = args.iter().position(|arg| arg == "-t").unwrap();
assert!(seek_index < input_index);
assert!(duration_index > input_index);
}
#[test]
fn loudnorm_analysis_parses_ffmpeg_json_evidence() {
let output = r#"
[Parsed_loudnorm_0 @ 000]
{
"input_i" : "-22.37",
"input_tp" : "-1.18",
"input_lra" : "7.40"
}
"#;
let analysis = parse_loudnorm_analysis(output).unwrap();
assert_eq!(analysis.integrated_lufs, -22.37);
assert_eq!(analysis.true_peak_dbtp, Some(-1.18));
assert_eq!(analysis.lra, Some(7.40));
assert_eq!(analysis.short_lufs_p50, None);
assert_eq!(analysis.presence_db, None);
}
#[test]
fn ebur128_short_term_stats_parse_percentiles_from_filter_log() {
let output = r#"
[Parsed_ebur128_0 @ 000] t: 0.999 TARGET:-23 LUFS M:-120.7 S:-120.7 I:-70.0 LUFS
[Parsed_ebur128_0 @ 000] t: 2.999 TARGET:-23 LUFS M:-20.0 S: -19.0 I:-21.0 LUFS
[Parsed_ebur128_0 @ 000] t: 3.999 TARGET:-23 LUFS M:-19.0 S: -17.0 I:-20.0 LUFS
[Parsed_ebur128_0 @ 000] t: 4.999 TARGET:-23 LUFS M:-21.0 S: -21.0 I:-20.0 LUFS
[Parsed_ebur128_0 @ 000] t: 5.999 TARGET:-23 LUFS M:-18.0 S: -15.0 I:-19.0 LUFS
[Parsed_ebur128_0 @ 000] t: 6.999 TARGET:-23 LUFS M:-18.0 S: -13.0 I:-19.0 LUFS
"#;
let stats = parse_ebur128_short_term_stats(output);
assert_eq!(stats.short_lufs_p50, Some(-17.0));
assert_eq!(stats.short_lufs_p80, Some(-15.0));
assert_eq!(stats.short_lufs_p95, Some(-13.0));
assert_eq!(stats.short_lufs_max, Some(-13.0));
}
#[test]
fn astats_rms_prefers_overall_section_for_presence_ratio() {
let output = r#"
[Parsed_astats_0 @ 000] RMS level dB: -22.000000
[Parsed_astats_0 @ 000] Overall
[Parsed_astats_0 @ 000] RMS level dB: -18.500000
"#;
assert_eq!(parse_astats_overall_rms_db(output).unwrap(), Some(-18.5));
}
#[test]
fn loudnorm_json_extractor_uses_last_complete_object() {
let output = r#"noise {"ignored":"yes"} tail {"input_i":"-18.5"}"#;
assert_eq!(
extract_last_json_object(output),
Some(r#"{"input_i":"-18.5"}"#)
);
}
#[test]
fn loudnorm_analysis_rejects_missing_integrated_loudness() {
let err = parse_loudnorm_analysis(r#"{"input_tp":"-1.0"}"#).unwrap_err();
assert_eq!(err, "loudnorm JSON is missing input_i");
}
#[test]
fn loudnorm_analysis_args_seek_before_input_and_limit_music_range() {
let range = PlaybackTimeRange {
start_ms: 32_380,
duration_ms: Some(91_250),
};
let args = args_to_strings(build_ffmpeg_loudnorm_analysis_args(
Path::new("album.m4a"),
Some(range),
));
assert!(args.windows(2).any(|pair| pair == ["-ss", "32.380"]));
assert!(args.windows(2).any(|pair| pair == ["-i", "album.m4a"]));
assert!(args.windows(2).any(|pair| pair == ["-t", "91.250"]));
assert!(args.iter().any(|arg| arg.contains("print_format=json")));
let seek_index = args.iter().position(|arg| arg == "-ss").unwrap();
let input_index = args.iter().position(|arg| arg == "-i").unwrap();
let duration_index = args.iter().position(|arg| arg == "-t").unwrap();
assert!(seek_index < input_index);
assert!(duration_index > input_index);
}
#[test]
fn ebur128_analysis_args_use_r128_filter_for_short_term_profile() {
let range = PlaybackTimeRange {
start_ms: 1_500,
duration_ms: Some(4_000),
};
let args = args_to_strings(build_ffmpeg_ebur128_analysis_args(
Path::new("album.m4a"),
Some(range),
));
assert!(args.windows(2).any(|pair| pair == ["-ss", "1.500"]));
assert!(args.windows(2).any(|pair| pair == ["-t", "4.000"]));
assert!(args.iter().any(|arg| arg == "ebur128=peak=true"));
}
#[test]
fn astats_analysis_args_accept_presence_filter_chain() {
let args = args_to_strings(build_ffmpeg_astats_analysis_args(
Path::new("album.m4a"),
None,
"bandpass=f=2500:width_type=h:width=4000,astats=metadata=0:reset=0",
));
assert!(args.iter().any(|arg| arg
== "bandpass=f=2500:width_type=h:width=4000,astats=metadata=0:reset=0"));
}
#[test]
fn audio_play_request_keeps_time_range_backward_compatible() {
let request: AudioPlayRequest =
serde_json::from_str(r#"{"session_id":7,"path":"track.flac"}"#).unwrap();
assert_eq!(request.session_id, 7);
assert_eq!(request.path, "track.flac");
assert_eq!(request.time_range, None);
}
#[test]
fn stop_fade_gain_reaches_zero_without_gain_spikes() {
let mut previous = 1.0;
for step in 0..=STOP_FADE_STEPS {
let gain = stop_fade_gain(1.0, step, STOP_FADE_STEPS);
assert!(gain <= previous);
previous = gain;
}
assert_eq!(stop_fade_gain(1.0, STOP_FADE_STEPS, STOP_FADE_STEPS), 0.0);
assert_eq!(stop_fade_gain(0.5, 0, STOP_FADE_STEPS), 0.5);
}
#[test]
fn merge_normalization_prefers_request_overrides_and_preserves_base_metadata() {
let merged = merge_normalization(
PlaybackNormalization {
target_lufs: -17.0,
integrated_lufs: Some(-20.0),
true_peak_dbtp: Some(-1.0),
},
PlaybackNormalization {
target_lufs: DEFAULT_TARGET_LUFS,
integrated_lufs: None,
true_peak_dbtp: Some(-0.5),
},
);
assert_eq!(merged.target_lufs, DEFAULT_TARGET_LUFS);
assert_eq!(merged.integrated_lufs, Some(-20.0));
assert_eq!(merged.true_peak_dbtp, Some(-0.5));
}
}