use std::collections::HashMap;
use std::process::ExitCode;
use tokio::sync::mpsc;
use whisrs::transcription::{
local_whisper::LocalWhisperBackend, TranscriptionBackend, TranscriptionConfig,
};
const CHUNK_SAMPLES: usize = 1600;
const NGRAM_RANGE: std::ops::RangeInclusive<usize> = 3..=6;
fn default_model_path() -> String {
dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("whisrs/models/ggml-base.en.bin")
.to_string_lossy()
.into_owned()
}
fn leads_with_punct(text: &str) -> bool {
text.starts_with([
'.', ',', '!', '?', ';', ':', ')', ']', '}', '。', ',', '!', '?', ';', ':', ')',
'】', '}', '…', '\u{2019}', '\u{201D}',
])
}
fn join_daemon_style(chunks: &[String]) -> String {
let mut full = String::new();
for batch in chunks {
if batch.is_empty() {
continue;
}
if full.is_empty()
|| batch.starts_with(' ')
|| full.ends_with(' ')
|| leads_with_punct(batch)
{
full.push_str(batch);
} else {
full.push(' ');
full.push_str(batch);
}
}
full
}
fn normalize_words(text: &str) -> Vec<String> {
text.to_lowercase()
.split_whitespace()
.map(|w| {
w.chars()
.filter(|c| c.is_alphanumeric() || *c == '\'')
.collect::<String>()
})
.filter(|w| !w.is_empty())
.collect()
}
fn ngram_counts(words: &[String], n: usize) -> HashMap<String, usize> {
let mut counts: HashMap<String, usize> = HashMap::new();
for win in words.windows(n) {
*counts.entry(win.join(" ")).or_default() += 1;
}
counts
}
fn repeated_ngrams(
transcript: &[String],
truth: Option<&[String]>,
n: usize,
) -> Vec<(String, usize, usize)> {
let counts = ngram_counts(transcript, n);
let truth_counts = truth.map(|t| ngram_counts(t, n));
let mut out: Vec<(String, usize, usize)> = counts
.into_iter()
.filter_map(|(gram, count)| {
let allowed = truth_counts
.as_ref()
.map_or(1, |tc| tc.get(&gram).copied().unwrap_or(0).max(1));
(count > allowed).then_some((gram, count, allowed))
})
.collect();
out.sort();
out
}
fn inserted_words(transcript: &[String], truth: &[String]) -> Vec<(String, usize)> {
let mut budget: HashMap<&str, usize> = HashMap::new();
for w in truth {
*budget.entry(w.as_str()).or_default() += 1;
}
let mut extra: HashMap<String, usize> = HashMap::new();
for w in transcript {
match budget.get_mut(w.as_str()) {
Some(c) if *c > 0 => *c -= 1,
_ => *extra.entry(w.clone()).or_default() += 1,
}
}
let mut out: Vec<(String, usize)> = extra.into_iter().collect();
out.sort();
out
}
fn truth_coverage(transcript: &[String], truth: &[String]) -> f64 {
if truth.is_empty() {
return 1.0;
}
let mut available: HashMap<&str, usize> = HashMap::new();
for w in transcript {
*available.entry(w.as_str()).or_default() += 1;
}
let mut matched = 0usize;
for w in truth {
if let Some(c) = available.get_mut(w.as_str()) {
if *c > 0 {
*c -= 1;
matched += 1;
}
}
}
matched as f64 / truth.len() as f64
}
fn read_wav_samples(path: &str) -> anyhow::Result<Vec<i16>> {
let reader = hound::WavReader::open(path)
.map_err(|e| anyhow::anyhow!("failed to open WAV {path}: {e}"))?;
let spec = reader.spec();
anyhow::ensure!(
spec.channels == 1
&& spec.sample_rate == 16_000
&& spec.bits_per_sample == 16
&& spec.sample_format == hound::SampleFormat::Int,
"expected 16 kHz mono s16 WAV, got {} Hz {} ch {} bit {:?} — \
regenerate with scripts/gen-issue55-fixture.sh",
spec.sample_rate,
spec.channels,
spec.bits_per_sample,
spec.sample_format,
);
Ok(reader.into_samples::<i16>().collect::<Result<_, _>>()?)
}
#[tokio::main]
async fn main() -> anyhow::Result<ExitCode> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let mut args = std::env::args().skip(1);
let wav_path = args
.next()
.or_else(|| std::env::var("WHISRS_ISSUE55_WAV").ok())
.unwrap_or_else(|| "fixtures/issue55.wav".to_string());
let model_path = args
.next()
.or_else(|| std::env::var("WHISRS_ISSUE55_MODEL").ok())
.unwrap_or_else(default_model_path);
let truth_path = args
.next()
.or_else(|| std::env::var("WHISRS_ISSUE55_TRUTH").ok())
.or_else(|| {
let default = "fixtures/issue55.txt";
std::path::Path::new(default)
.exists()
.then(|| default.to_string())
});
let segmentation =
std::env::var("WHISRS_ISSUE55_SEGMENTATION").unwrap_or_else(|_| "silence".to_string());
let phrase_silence_ms: u64 = std::env::var("WHISRS_ISSUE55_PHRASE_SILENCE_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(400);
let min_coverage: Option<f64> = std::env::var("WHISRS_ISSUE55_MIN_COVERAGE")
.ok()
.and_then(|v| v.parse().ok());
let samples = read_wav_samples(&wav_path)?;
println!(
"== issue #55 stream check ==\n\
wav: {wav_path} ({:.1}s, {} samples)\n\
model: {model_path}\n\
mode: {segmentation} (phrase_silence_ms={phrase_silence_ms})",
samples.len() as f64 / 16_000.0,
samples.len(),
);
let backend =
LocalWhisperBackend::new(model_path).with_segmentation(&segmentation, phrase_silence_ms);
let config = TranscriptionConfig {
language: "en".to_string(),
model: "base.en".to_string(),
prompt: None,
};
let (audio_tx, audio_rx) = mpsc::channel::<Vec<i16>>(256);
let (text_tx, mut text_rx) = mpsc::channel::<String>(64);
let backend_task =
tokio::spawn(async move { backend.transcribe_stream(audio_rx, text_tx, &config).await });
let collector = tokio::spawn(async move {
let mut chunks: Vec<String> = Vec::new();
while let Some(t) = text_rx.recv().await {
chunks.push(t);
}
chunks
});
for chunk in samples.chunks(CHUNK_SAMPLES) {
if audio_tx.send(chunk.to_vec()).await.is_err() {
break; }
}
drop(audio_tx);
backend_task.await??;
let chunks = collector.await?;
println!("\n-- emitted batches ({}) --", chunks.len());
for (i, c) in chunks.iter().enumerate() {
println!("[{i:>2}] {c:?}");
}
let transcript = join_daemon_style(&chunks);
println!("\n-- final joined transcript --\n{transcript}");
let truth_text = match &truth_path {
Some(p) => Some(std::fs::read_to_string(p)?),
None => None,
};
let words = normalize_words(&transcript);
let truth_words = truth_text.as_deref().map(normalize_words);
println!("\n-- repetition detector (n-grams, case/punct-insensitive) --");
let mut repetition_found = false;
for n in NGRAM_RANGE {
let reps = repeated_ngrams(&words, truth_words.as_deref(), n);
for (gram, count, allowed) in &reps {
println!("REPEATED {n}-gram x{count} (expected <= {allowed}): {gram:?}");
repetition_found = true;
}
}
if !repetition_found {
println!("no repeated n-grams detected");
}
if let Some(truth_words) = &truth_words {
println!("\n-- inserted words vs ground truth ({:?}) --", truth_path);
let inserted = inserted_words(&words, truth_words);
if inserted.is_empty() {
println!("no inserted words");
} else {
for (w, count) in &inserted {
println!("INSERTED x{count}: {w:?}");
}
}
}
let mut coverage_failed = false;
if let Some(truth_words) = &truth_words {
let coverage = truth_coverage(&words, truth_words);
println!("\n-- ground-truth coverage --");
println!(
"{:.1}% of {} truth words present in transcript",
coverage * 100.0,
truth_words.len()
);
if let Some(min) = min_coverage {
if coverage < min {
println!(
"COVERAGE FAIL: {:.1}% < required {:.1}%",
coverage * 100.0,
min * 100.0
);
coverage_failed = true;
}
}
}
if repetition_found {
println!("\nRESULT: FAIL — repetition detected (issue #55 reproduced)");
Ok(ExitCode::from(1))
} else if coverage_failed {
println!("\nRESULT: FAIL — transcript does not cover the full utterance");
Ok(ExitCode::from(1))
} else {
println!("\nRESULT: OK — transcript is repetition-free");
Ok(ExitCode::SUCCESS)
}
}