use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
use clap::Parser;
use euhadra::canary::decoder::PrefixFormat;
use euhadra::canary::{CanaryAdapter, CanaryConfig};
use euhadra::eval::metrics::{cer_lenient, wer_lenient};
use euhadra::parakeet::ParakeetAdapter;
use euhadra::prelude::*;
use euhadra::vad::{EarshotVad, EnergyVad, SegmenterConfig, VadBackend};
use euhadra::whisper_local::read_wav;
#[derive(Parser, Debug)]
#[command(about = "ΔWER with and without voice activity detection")]
struct Cli {
#[arg(long, default_value = "data/fleurs_subset")]
data_dir: PathBuf,
#[arg(long)]
canary_en_dir: Option<PathBuf>,
#[arg(long)]
parakeet_en_dir: Option<PathBuf>,
#[arg(long)]
parakeet_ja_dir: Option<PathBuf>,
#[arg(long, value_delimiter = ',', default_value = "en,ja")]
langs: Vec<String>,
#[arg(long, default_value_t = 0)]
limit: usize,
#[arg(long, default_value_t = 5.0)]
lead_silence: f32,
#[arg(long, default_value_t = 5.0)]
trail_silence: f32,
#[arg(long, value_delimiter = ',', default_value = "-100,-45")]
noise_db: Vec<f32>,
#[arg(long, value_delimiter = ',', default_value = "none,energy,earshot")]
detectors: Vec<String>,
#[arg(long, value_delimiter = ',')]
thresholds: Vec<f32>,
#[arg(long, value_delimiter = ',', default_value = "speech-only,join")]
policies: Vec<String>,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
max_delta: Option<f64>,
#[arg(long)]
max_segments: Option<f64>,
#[arg(long, default_value = "earshot")]
gate_detector: String,
}
struct Row {
id: String,
audio_path: PathBuf,
reference: String,
}
fn load_manifest(data_dir: &Path, lang: &str) -> std::io::Result<Vec<Row>> {
let raw = std::fs::read_to_string(data_dir.join(lang).join("manifest.tsv"))?;
Ok(raw
.lines()
.skip(1)
.filter(|l| !l.trim().is_empty())
.filter_map(|line| {
let cols: Vec<&str> = line.splitn(3, '\t').collect();
(cols.len() == 3).then(|| Row {
id: cols[0].to_string(),
audio_path: data_dir.join(cols[1]),
reference: cols[2].to_string(),
})
})
.collect())
}
fn silence(samples: usize, db: f32, seed: u64) -> Vec<f32> {
if db <= -100.0 {
return vec![0.0; samples];
}
let amplitude = 10f32.powf(db / 20.0) * 3f32.sqrt();
let mut state = seed | 1;
(0..samples)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
let unit = (state >> 40) as f32 / 8_388_608.0 - 1.0;
unit * amplitude
})
.collect()
}
fn pad(chunk: &AudioChunk, lead: f32, trail: f32, db: f32, seed: u64) -> AudioChunk {
let rate = chunk.sample_rate as f32;
let mut samples = silence((lead * rate) as usize, db, seed);
samples.extend_from_slice(&chunk.samples);
samples.extend(silence((trail * rate) as usize, db, seed ^ 0xABCD_EF01));
AudioChunk {
samples,
sample_rate: chunk.sample_rate,
channels: chunk.channels,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Detector {
None,
Energy,
Earshot,
}
impl Detector {
fn backend(&self) -> Option<Box<dyn VadBackend>> {
match self {
Detector::None => None,
Detector::Energy => Some(Box::new(EnergyVad::new())),
Detector::Earshot => Some(Box::new(EarshotVad::new())),
}
}
fn label(&self) -> &'static str {
match self {
Detector::None => "none",
Detector::Energy => "energy",
Detector::Earshot => "earshot",
}
}
}
#[derive(Debug, Default, Clone, serde::Serialize)]
struct Cell {
error_rate: f64,
delta: f64,
segments: f64,
inflated: usize,
seconds: f64,
}
fn main() {
let cli = Cli::parse();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
let mut report: BTreeMap<String, BTreeMap<String, Cell>> = BTreeMap::new();
let mut probes: BTreeMap<String, String> = BTreeMap::new();
for lang in &cli.langs {
let use_canary = lang == "en" && cli.canary_en_dir.is_some();
let model_dir = match lang.as_str() {
"en" if use_canary => cli.canary_en_dir.clone(),
"en" => cli.parakeet_en_dir.clone(),
"ja" => cli.parakeet_ja_dir.clone(),
other => {
eprintln!("[skip] {other}: no model directory wired for this language");
continue;
}
};
let Some(model_dir) = model_dir else {
eprintln!("[skip] {lang}: model directory not supplied");
continue;
};
let mut rows = load_manifest(&cli.data_dir, lang)
.unwrap_or_else(|e| panic!("manifest for {lang}: {e}"));
if cli.limit > 0 {
rows.truncate(cli.limit);
}
eprintln!(
"[{lang}] {} utterances, {} at {}",
rows.len(),
if use_canary { "canary" } else { "parakeet" },
model_dir.display()
);
let asr: std::sync::Arc<dyn AsrAdapter> = load_adapter(&model_dir, lang, use_canary);
let by_chars = matches!(lang.as_str(), "ja" | "zh");
let audio: Vec<(Row, AudioChunk)> = rows
.into_iter()
.map(|row| {
let chunk = read_wav(&row.audio_path)
.unwrap_or_else(|e| panic!("read {}: {e}", row.audio_path.display()));
(row, chunk)
})
.collect();
let clean = runtime.block_on(measure(
&asr,
&audio,
|_, chunk| chunk.clone(),
Detector::None,
FinalPass::WholeUtterance,
None,
by_chars,
None,
));
eprintln!(
"[{lang}] clean / no detector: {:.4} ({:.1}s)",
clean.error_rate, clean.seconds
);
report
.entry(lang.clone())
.or_default()
.insert("clean|none|tdefault|whole".into(), clean.clone());
for &db in &cli.noise_db {
let rate = audio[0].1.sample_rate;
let seconds = cli.lead_silence + cli.trail_silence;
let only = AudioChunk {
samples: silence((seconds * rate as f32) as usize, db, 0x5EED),
sample_rate: rate,
channels: 1,
};
let text = runtime
.block_on(asr.transcribe(std::slice::from_ref(&only)))
.map(|t| t.text.trim().to_string())
.unwrap_or_else(|e| format!("<error: {e}>"));
eprintln!("[{lang}] {seconds:.0}s of {db} dBFS alone → {text:?}");
probes.insert(format!("{lang}|{db}"), text);
}
for &db in &cli.noise_db {
let lead = cli.lead_silence;
let trail = cli.trail_silence;
let padder = move |index: usize, chunk: &AudioChunk| {
pad(chunk, lead, trail, db, 0x1234_5678 ^ index as u64)
};
for detector in [Detector::None, Detector::Energy, Detector::Earshot] {
if !cli.detectors.iter().any(|d| d == detector.label()) {
continue;
}
let thresholds: Vec<Option<f32>> =
if detector == Detector::None || cli.thresholds.is_empty() {
vec![None]
} else {
cli.thresholds.iter().copied().map(Some).collect()
};
let policies: Vec<FinalPass> = if detector == Detector::None {
vec![FinalPass::WholeUtterance]
} else {
[FinalPass::SpeechOnly, FinalPass::JoinSegments]
.into_iter()
.filter(|p| cli.policies.iter().any(|s| s == policy_label(*p)))
.collect()
};
for &threshold in &thresholds {
for &policy in &policies {
let mut cell = runtime.block_on(measure(
&asr,
&audio,
padder,
detector,
policy,
threshold,
by_chars,
Some(&clean),
));
cell.delta = cell.error_rate - clean.error_rate;
let key = format!(
"{db}|{}|t{}|{}",
detector.label(),
match threshold {
Some(t) => t.to_string(),
None => "default".to_string(),
},
policy_label(policy)
);
eprintln!(
"[{lang}] {key}: {:.4} (Δ{:+.4}) segments {:.2} inflated {} ({:.1}s)",
cell.error_rate, cell.delta, cell.segments, cell.inflated, cell.seconds
);
report.entry(lang.clone()).or_default().insert(key, cell);
}
}
}
}
}
let json = serde_json::json!({
"note": "Synthetic silence measures an upper bound; a real room's \
background noise is what makes a level detector fail.",
"lead_silence_s": cli.lead_silence,
"trail_silence_s": cli.trail_silence,
"silence_only_transcripts": probes,
"results": report,
});
let rendered = serde_json::to_string_pretty(&json).expect("serialise report");
if let Some(path) = &cli.out {
std::fs::write(path, format!("{rendered}\n")).unwrap_or_else(|e| panic!("write out: {e}"));
eprintln!("[done] wrote {}", path.display());
} else {
println!("{rendered}");
}
let failures = gate(&cli, &report);
if !failures.is_empty() {
for line in &failures {
eprintln!("[FAIL] {line}");
}
std::process::exit(1);
}
}
fn gate(cli: &Cli, report: &BTreeMap<String, BTreeMap<String, Cell>>) -> Vec<String> {
if cli.max_delta.is_none() && cli.max_segments.is_none() {
return Vec::new();
}
let mut failures = Vec::new();
let mut judged = 0usize;
for (lang, rows) in report {
for (key, cell) in rows {
let mut parts = key.split('|');
let (Some(_noise), Some(detector), Some(threshold), Some(policy)) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
continue;
};
if detector != cli.gate_detector || threshold != "tdefault" || policy != "speech-only" {
continue;
}
judged += 1;
if let Some(limit) = cli.max_delta {
if cell.delta > limit {
failures.push(format!(
"{lang} {key}: Δ {:+.4} exceeds {limit:+.4}",
cell.delta
));
}
}
if let Some(limit) = cli.max_segments {
if cell.segments > limit {
failures.push(format!(
"{lang} {key}: {:.2} utterances per recording exceeds {limit:.2}",
cell.segments
));
}
}
}
}
if judged == 0 {
failures.push(format!(
"no rows matched detector {:?} under speech-only at its own \
calibration; the gate examined nothing",
cli.gate_detector
));
} else {
eprintln!("[gate] {judged} row(s) judged, {} breach(es)", failures.len());
}
failures
}
fn load_adapter(dir: &Path, lang: &str, canary: bool) -> std::sync::Arc<dyn AsrAdapter> {
if canary {
let cfg = CanaryConfig::istupakov_default().with_int8_weights();
let cfg = CanaryConfig {
prefix_format: PrefixFormat::NemoCanary2,
..cfg
};
let adapter = CanaryAdapter::load_with_config(dir, cfg)
.unwrap_or_else(|e| panic!("load canary from {}: {e}", dir.display()))
.with_language(lang);
std::sync::Arc::new(adapter)
} else {
std::sync::Arc::new(
ParakeetAdapter::load(dir)
.unwrap_or_else(|e| panic!("load parakeet from {}: {e}", dir.display())),
)
}
}
fn policy_label(policy: FinalPass) -> &'static str {
match policy {
FinalPass::SpeechOnly => "speech-only",
FinalPass::WholeUtterance => "whole",
FinalPass::JoinSegments => "join",
_ => "unknown",
}
}
#[allow(clippy::too_many_arguments)]
async fn measure<F>(
asr: &std::sync::Arc<dyn AsrAdapter>,
audio: &[(Row, AudioChunk)],
prepare: F,
detector: Detector,
policy: FinalPass,
threshold: Option<f32>,
by_chars: bool,
clean: Option<&Cell>,
) -> Cell
where
F: Fn(usize, &AudioChunk) -> AudioChunk,
{
struct Shared(std::sync::Arc<dyn AsrAdapter>);
#[async_trait::async_trait]
impl AsrAdapter for Shared {
async fn transcribe(&self, chunks: &[AudioChunk]) -> Result<Transcript, AsrError> {
self.0.transcribe(chunks).await
}
}
let mut builder = PipelineBuilder::new()
.asr(Shared(std::sync::Arc::clone(asr)))
.final_pass(policy)
.segmenter_config({
let mut config = SegmenterConfig::default();
if threshold.is_some() {
config.threshold = threshold;
}
config
});
if let Some(backend) = detector.backend() {
builder = builder.vad(backend);
}
let pipeline = builder.build().expect("pipeline");
let started = Instant::now();
let mut total = 0.0;
let mut segments = 0.0;
let mut inflated = 0;
for (index, (row, chunk)) in audio.iter().enumerate() {
let prepared = prepare(index, chunk);
let result = pipeline.transcribe(std::slice::from_ref(&prepared)).await;
let (text, found) = match result {
Ok(result) => (
result.text().to_string(),
result.diagnostics.speech_segments.len(),
),
Err(e) => {
eprintln!(" [{}] {e}", row.id);
(String::new(), 0)
}
};
total += if by_chars {
cer_lenient(&row.reference, &text)
} else {
wer_lenient(&row.reference, &text)
};
segments += found as f64;
if let Some(clean) = clean {
let _ = clean;
if text.chars().count() as f64 > row.reference.chars().count() as f64 * 1.25 {
inflated += 1;
}
}
}
let n = audio.len().max(1) as f64;
Cell {
error_rate: total / n,
delta: 0.0,
segments: segments / n,
inflated,
seconds: started.elapsed().as_secs_f64(),
}
}