use crate::analyse::rms_db;
use time::Duration;
pub const DEAD_AIR_LIMIT: Duration = Duration::seconds(10);
pub const HEAD_DURATION: Duration = Duration::seconds(1);
pub const TAIL_DURATION: Duration = Duration::seconds(3);
pub const BOOKEND_MAX_RMS_DB: f32 = -45.0;
#[derive(Debug, Clone)]
pub struct DeadAirViolation {
pub start_sample: usize,
pub duration: Duration,
}
pub const DIGITAL_ZERO_MIN_RUN: usize = 24;
const SCAN_WINDOW_MS: usize = 10;
pub fn detect_dead_air(
samples: &[i16],
sample_rate: u32,
silence_threshold_db: f32,
) -> Vec<DeadAirViolation> {
let window = (sample_rate as usize * SCAN_WINDOW_MS) / 1000;
if window == 0 {
return Vec::new();
}
let limit_windows = (DEAD_AIR_LIMIT.whole_milliseconds() as usize) / SCAN_WINDOW_MS;
let mut violations = Vec::new();
let mut run_start: Option<usize> = None;
let mut run_len = 0usize;
for (win_idx, chunk) in samples.chunks(window).enumerate() {
let is_silent = rms_db(chunk) < silence_threshold_db;
if is_silent {
if run_start.is_none() {
run_start = Some(win_idx * window);
}
run_len += 1;
} else {
if let Some(start) = run_start.take() {
if run_len > limit_windows {
violations.push(DeadAirViolation {
start_sample: start,
duration: Duration::milliseconds((run_len * SCAN_WINDOW_MS) as i64),
});
}
}
run_len = 0;
}
}
if let Some(start) = run_start {
if run_len > limit_windows {
violations.push(DeadAirViolation {
start_sample: start,
duration: Duration::milliseconds((run_len * SCAN_WINDOW_MS) as i64),
});
}
}
violations
}
pub fn check_bookends(samples: &[i16], sample_rate: u32) -> (bool, bool) {
let head_samples = (sample_rate as i64 * HEAD_DURATION.whole_seconds()) as usize;
let tail_samples = (sample_rate as i64 * TAIL_DURATION.whole_seconds()) as usize;
let head_ok = if samples.len() >= head_samples {
rms_db(&samples[..head_samples]) <= BOOKEND_MAX_RMS_DB
} else {
rms_db(samples) <= BOOKEND_MAX_RMS_DB
};
let tail_ok = if samples.len() >= tail_samples {
rms_db(&samples[samples.len() - tail_samples..]) <= BOOKEND_MAX_RMS_DB
} else {
rms_db(samples) <= BOOKEND_MAX_RMS_DB
};
(head_ok, tail_ok)
}
pub fn count_digital_zero_runs(samples: &[i16]) -> usize {
let mut count = 0usize;
let mut run = 0usize;
let mut in_run = false;
for &s in samples {
if s == 0 {
run += 1;
if run >= DIGITAL_ZERO_MIN_RUN && !in_run {
count += 1;
in_run = true;
}
} else {
run = 0;
in_run = false;
}
}
count
}
pub fn max_dead_air(samples: &[i16], sample_rate: u32, silence_threshold_db: f32) -> Duration {
detect_dead_air(samples, sample_rate, silence_threshold_db)
.into_iter()
.map(|v| v.duration)
.max()
.unwrap_or(Duration::ZERO)
}
#[cfg(test)]
mod tests {
use super::*;
const SR: u32 = 24_000;
const THRESHOLD: f32 = -60.0;
fn silence(secs: f32) -> Vec<i16> {
vec![0i16; (SR as f32 * secs) as usize]
}
fn loud(secs: f32) -> Vec<i16> {
let n = (SR as f32 * secs) as usize;
(0..n)
.map(|i| {
let v = 8000.0 * (2.0 * std::f32::consts::PI * 440.0 * i as f32 / SR as f32).sin();
v as i16
})
.collect()
}
#[test]
fn dead_air_within_limit_is_not_flagged() {
let mut samples = loud(1.0);
samples.extend(silence(5.0));
samples.extend(loud(1.0));
let violations = detect_dead_air(&samples, SR, THRESHOLD);
assert!(
violations.is_empty(),
"Expected no violations for 5 s silence"
);
}
#[test]
fn dead_air_exceeding_limit_is_flagged() {
let mut samples = loud(0.5);
samples.extend(silence(12.0));
samples.extend(loud(0.5));
let violations = detect_dead_air(&samples, SR, THRESHOLD);
assert!(
!violations.is_empty(),
"Expected dead-air violation for 12 s silence"
);
assert!(violations[0].duration > DEAD_AIR_LIMIT);
}
#[test]
fn multiple_dead_air_blocks_detected() {
let mut samples = loud(0.3);
samples.extend(silence(11.0));
samples.extend(loud(0.3));
samples.extend(silence(15.0));
samples.extend(loud(0.3));
let violations = detect_dead_air(&samples, SR, THRESHOLD);
assert_eq!(violations.len(), 2, "Expected two violations");
}
#[test]
fn empty_track_has_no_dead_air() {
assert!(detect_dead_air(&[], SR, THRESHOLD).is_empty());
}
#[test]
fn head_with_loud_audio_fails() {
let mut samples = loud(2.0);
samples.extend(silence(2.0));
let (head_ok, _) = check_bookends(&samples, SR);
assert!(!head_ok, "Expected head to fail with loud audio at start");
}
#[test]
fn silence_head_passes() {
let mut samples = silence(2.0);
samples.extend(loud(1.0));
samples.extend(silence(4.0)); let (head_ok, tail_ok) = check_bookends(&samples, SR);
assert!(head_ok, "Expected head to pass with silence");
assert!(tail_ok, "Expected tail to pass with silence");
}
#[test]
fn loud_tail_fails() {
let mut samples = silence(2.0);
samples.extend(loud(5.0)); let (_, tail_ok) = check_bookends(&samples, SR);
assert!(!tail_ok, "Expected tail to fail with loud audio at end");
}
#[test]
fn digital_zero_run_detected() {
let mut samples = vec![1i16; 100];
samples.extend(vec![0i16; 100]);
samples.extend(vec![1i16; 100]);
assert_eq!(count_digital_zero_runs(&samples), 1);
}
#[test]
fn short_zero_gap_not_flagged() {
let mut samples = vec![1i16; 50];
samples.extend(vec![0i16; 5]);
samples.extend(vec![1i16; 50]);
assert_eq!(count_digital_zero_runs(&samples), 0);
}
#[test]
fn multiple_zero_runs_counted() {
let mut samples = Vec::new();
for _ in 0..3 {
samples.extend(vec![1i16; 10]);
samples.extend(vec![0i16; 50]); }
assert_eq!(count_digital_zero_runs(&samples), 3);
}
#[test]
fn max_dead_air_returns_longest() {
let mut samples = loud(0.2);
samples.extend(silence(11.0));
samples.extend(loud(0.2));
samples.extend(silence(14.0));
samples.extend(loud(0.2));
let max = max_dead_air(&samples, SR, THRESHOLD);
assert!(
max > Duration::seconds(13) && max < Duration::seconds(15),
"Unexpected max dead air: {:?}",
max
);
}
}