use std::path::{Path, PathBuf};
pub const DEFAULT_BARGE_GRACE_MS: u64 = 500;
pub const PRE_SPEECH_SLACK_MS: u64 = 250;
pub struct PauseGuard {
path: PathBuf,
created: bool,
}
impl PauseGuard {
pub fn hold(path: &Path) -> Self {
let created =
std::fs::write(path, format!("speaking, pid {}\n", std::process::id())).is_ok();
if !created {
eprintln!(
"warning: could not create pause file {} — the ears may hear this utterance",
path.display()
);
}
Self {
path: path.to_path_buf(),
created,
}
}
pub fn held(&self) -> bool {
self.created
}
}
impl Drop for PauseGuard {
fn drop(&mut self) {
if self.created {
std::fs::remove_file(&self.path).ok();
}
}
}
pub fn paused(path: &Path) -> bool {
path.exists()
}
pub fn clear_stale(path: &Path) -> bool {
if path.exists() {
std::fs::remove_file(path).ok();
true
} else {
false
}
}
pub fn default_pause_path(log: &Path) -> PathBuf {
PathBuf::from(format!("{}.pause", log.display()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpeechWindow {
pub start_ms: u64,
pub end_ms: u64,
}
impl SpeechWindow {
pub fn overlaps(&self, utc_ms: u64, grace_ms: u64) -> bool {
utc_ms >= self.start_ms.saturating_sub(PRE_SPEECH_SLACK_MS)
&& utc_ms <= self.end_ms + grace_ms
}
}
#[derive(Debug, Clone, Copy)]
pub struct SpeechFilter {
pub min_chars: usize,
pub min_dur_s: f64,
pub barge_grace_ms: u64,
}
impl Default for SpeechFilter {
fn default() -> Self {
Self {
min_chars: 2,
min_dur_s: 0.6,
barge_grace_ms: DEFAULT_BARGE_GRACE_MS,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Utterance<'a> {
pub text: &'a str,
pub prompt: &'a str,
pub utc_ms: u64,
pub dur_s: f64,
}
pub fn drop_reason(
u: &Utterance<'_>,
f: &SpeechFilter,
spoke: Option<SpeechWindow>,
) -> Option<&'static str> {
let text = u.text.trim();
let prompt = u.prompt.trim();
if text.chars().count() < f.min_chars {
return Some("too-short-text");
}
if !prompt.is_empty()
&& (text == prompt || (prompt.chars().count() >= 12 && text.contains(prompt)))
{
return Some("prompt-parrot (no speech)");
}
audio_drop_reason(u.dur_s, u.utc_ms, f, spoke)
}
pub fn audio_drop_reason(
dur_s: f64,
utc_ms: u64,
f: &SpeechFilter,
spoke: Option<SpeechWindow>,
) -> Option<&'static str> {
if dur_s > 0.0 && dur_s < f.min_dur_s {
return Some("too-short-segment");
}
if let Some(window) = spoke {
if window.overlaps(utc_ms, f.barge_grace_ms) {
return Some("barge-in stub: overlapped our speech, presumed self-echo");
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const PROMPT: &str = "Transcribe exactly what is being said.";
fn utt<'a>(text: &'a str, dur_s: f64, utc_ms: u64) -> Utterance<'a> {
Utterance {
text,
prompt: PROMPT,
utc_ms,
dur_s,
}
}
#[test]
fn parroted_prompt_is_not_speech() {
let f = SpeechFilter::default();
assert_eq!(
drop_reason(&utt(PROMPT, 2.0, 0), &f, None),
Some("prompt-parrot (no speech)")
);
assert_eq!(
drop_reason(&utt(&format!(" {PROMPT} "), 2.0, 0), &f, None),
Some("prompt-parrot (no speech)")
);
assert_eq!(
drop_reason(&utt(&format!("uh {PROMPT}"), 2.0, 0), &f, None),
Some("prompt-parrot (no speech)")
);
assert_eq!(
drop_reason(&utt("what is the weather", 2.0, 0), &f, None),
None
);
}
#[test]
fn a_short_prompt_never_swallows_ordinary_speech() {
let f = SpeechFilter::default();
let short = Utterance {
text: "yes, tell me more about it",
prompt: "yes",
utc_ms: 0,
dur_s: 2.0,
};
assert_eq!(drop_reason(&short, &f, None), None);
let echoed = Utterance {
text: "yes",
prompt: "yes",
utc_ms: 0,
dur_s: 2.0,
};
assert_eq!(
drop_reason(&echoed, &f, None),
Some("prompt-parrot (no speech)")
);
}
#[test]
fn vad_blips_and_empty_transcripts_are_dropped() {
let f = SpeechFilter::default();
assert_eq!(
drop_reason(&utt("", 2.0, 0), &f, None),
Some("too-short-text")
);
assert_eq!(
drop_reason(&utt("a", 2.0, 0), &f, None),
Some("too-short-text")
);
assert_eq!(
drop_reason(&utt("hm", 0.46, 0), &f, None),
Some("too-short-segment")
);
assert_eq!(drop_reason(&utt("hello there", 0.0, 0), &f, None), None);
}
#[test]
fn overlap_with_our_own_speech_is_self_echo() {
let f = SpeechFilter::default();
let window = SpeechWindow {
start_ms: 10_000,
end_ms: 12_000,
};
let echo = drop_reason(&utt("hello there", 2.0, 11_000), &f, Some(window));
assert_eq!(
echo,
Some("barge-in stub: overlapped our speech, presumed self-echo")
);
assert!(window.overlaps(10_000 - PRE_SPEECH_SLACK_MS, f.barge_grace_ms));
assert!(!window.overlaps(10_000 - PRE_SPEECH_SLACK_MS - 1, f.barge_grace_ms));
assert!(window.overlaps(12_000 + f.barge_grace_ms, f.barge_grace_ms));
assert!(!window.overlaps(12_000 + f.barge_grace_ms + 1, f.barge_grace_ms));
assert_eq!(
drop_reason(&utt("hello there", 2.0, 13_000), &f, Some(window)),
None
);
}
#[test]
fn the_audio_only_stage_stands_alone_for_an_embedding_consumer() {
let f = SpeechFilter::default();
let window = SpeechWindow {
start_ms: 10_000,
end_ms: 12_000,
};
assert_eq!(
audio_drop_reason(0.46, 0, &f, None),
Some("too-short-segment")
);
assert_eq!(
audio_drop_reason(2.0, 11_000, &f, Some(window)),
Some("barge-in stub: overlapped our speech, presumed self-echo")
);
assert_eq!(audio_drop_reason(2.0, 13_000, &f, Some(window)), None);
assert_eq!(audio_drop_reason(2.0, 0, &f, None), None);
}
#[test]
fn the_pause_file_is_held_for_a_scope_and_always_released() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ears.pause");
assert!(!paused(&path));
{
let guard = PauseGuard::hold(&path);
assert!(guard.held());
assert!(paused(&path), "the ears must see the hold while we speak");
}
assert!(!paused(&path), "the ears must always resume");
let unwound = std::panic::catch_unwind(|| {
let _guard = PauseGuard::hold(&path);
panic!("synthesis exploded mid-utterance");
});
assert!(unwound.is_err());
assert!(
!paused(&path),
"a crash mid-utterance must not deafen the ears forever"
);
}
#[test]
fn a_stale_pause_file_is_cleared_not_trusted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ears.pause");
std::fs::write(&path, "speaking, pid 1\n").unwrap();
assert!(clear_stale(&path));
assert!(!paused(&path));
assert!(!clear_stale(&path));
}
#[test]
fn default_pause_path_sits_beside_the_log() {
assert_eq!(
default_pause_path(Path::new("/tmp/ears.jsonl")),
PathBuf::from("/tmp/ears.jsonl.pause")
);
}
}