Skip to main content

ringo_agent/
audio.rs

1//! Tone detection on received call audio (for `verify-audio`) and WAV output
2//! (for `--save-audio`).
3//!
4//! The backend captures each agent's received (and, when saving, sent) audio
5//! in-process via ringo's own ausrc/auplay module — no baresip sndfile, no WAV
6//! dumps on disk. We run a [Goertzel](https://en.wikipedia.org/wiki/Goertzel_algorithm)
7//! filter on the captured samples at the expected frequency: the score is ~1.0
8//! for a clean tone at that frequency and ~0 for silence/noise/other tones,
9//! independent of sample count.
10
11use anyhow::{Context, Result};
12use std::path::Path;
13
14/// A received audio tone counts as present at/above this score.
15pub const TONE_THRESHOLD: f64 = 0.2;
16
17/// Normalized Goertzel score for `freq` over `samples` at `sample_rate`.
18/// `~1.0` for a pure tone at `freq`, `~0` otherwise; `0` when silent/empty.
19pub fn tone_score(samples: &[i16], sample_rate: u32, freq: f64) -> f64 {
20    let n = samples.len();
21    if n == 0 || sample_rate == 0 {
22        return 0.0;
23    }
24    let omega = 2.0 * std::f64::consts::PI * freq / f64::from(sample_rate);
25    let coeff = 2.0 * omega.cos();
26    let (mut s1, mut s2) = (0.0f64, 0.0f64);
27    let mut energy = 0.0f64;
28    for &x in samples {
29        let xf = f64::from(x);
30        energy += xf * xf;
31        let s = xf + coeff * s1 - s2;
32        s2 = s1;
33        s1 = s;
34    }
35    if energy == 0.0 {
36        return 0.0;
37    }
38    let power = s2 * s2 + s1 * s1 - coeff * s1 * s2;
39    // Normalize so a pure sine at `freq` scores ~1 regardless of N (see derivation
40    // in the module docs): |X(f)|² ≈ N²A²/4 and Σx² ≈ NA²/2 → 2P/(N·E) ≈ 1.
41    (2.0 * power) / (n as f64 * energy)
42}
43
44/// Result of analysing a recording for a tone, with diagnostics to tell apart
45/// "tone present" / "audio but wrong tone" / "silence (no media)".
46#[derive(Debug, Clone, Copy, Default)]
47pub struct ToneAnalysis {
48    /// Goertzel score at the target frequency (~1.0 = present, ~0 = absent).
49    pub score: f64,
50    /// RMS amplitude of the analysed window (~0 = silence / no audio received).
51    pub rms: f64,
52    /// Number of samples analysed.
53    pub samples: usize,
54}
55
56/// Analyse the last `window` of mono 16-bit `samples` at `sample_rate` for
57/// `freq`. The tail is used because the tone may begin after media setup.
58pub fn analyze_tone_samples(
59    samples: &[i16],
60    sample_rate: u32,
61    freq: u32,
62    window: std::time::Duration,
63) -> ToneAnalysis {
64    let want = (f64::from(sample_rate) * window.as_secs_f64()) as usize;
65    let tail = &samples[samples.len().saturating_sub(want)..];
66    let rms = if tail.is_empty() {
67        0.0
68    } else {
69        (tail.iter().map(|&x| f64::from(x).powi(2)).sum::<f64>() / tail.len() as f64).sqrt()
70    };
71    ToneAnalysis {
72        score: tone_score(tail, sample_rate, f64::from(freq)),
73        rms,
74        samples: tail.len(),
75    }
76}
77
78/// Write mono 16-bit PCM `samples` at `sample_rate` to `path` as a WAV file
79/// (used by `--save-audio`; the audio is captured in-process, so we serialise
80/// it ourselves — no libsndfile).
81pub fn write_wav(path: &Path, samples: &[i16], sample_rate: u32) -> Result<()> {
82    let data_len = samples.len() * 2;
83    let byte_rate = sample_rate * 2; // mono, 16-bit
84    let mut v = Vec::with_capacity(44 + data_len);
85    v.extend_from_slice(b"RIFF");
86    v.extend_from_slice(&((36 + data_len) as u32).to_le_bytes());
87    v.extend_from_slice(b"WAVE");
88    v.extend_from_slice(b"fmt ");
89    v.extend_from_slice(&16u32.to_le_bytes()); // fmt chunk size
90    v.extend_from_slice(&1u16.to_le_bytes()); // PCM
91    v.extend_from_slice(&1u16.to_le_bytes()); // mono
92    v.extend_from_slice(&sample_rate.to_le_bytes());
93    v.extend_from_slice(&byte_rate.to_le_bytes());
94    v.extend_from_slice(&2u16.to_le_bytes()); // block align
95    v.extend_from_slice(&16u16.to_le_bytes()); // bits/sample
96    v.extend_from_slice(b"data");
97    v.extend_from_slice(&(data_len as u32).to_le_bytes());
98    for s in samples {
99        v.extend_from_slice(&s.to_le_bytes());
100    }
101    std::fs::write(path, v).with_context(|| format!("write WAV {}", path.display()))
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use std::f64::consts::PI;
108
109    fn sine(freq: f64, sr: u32, n: usize) -> Vec<i16> {
110        (0..n)
111            .map(|i| (8000.0 * (2.0 * PI * freq * i as f64 / f64::from(sr)).sin()) as i16)
112            .collect()
113    }
114
115    #[test]
116    fn detects_matching_tone_rejects_others() {
117        let sr = 8000;
118        let s = sine(440.0, sr, 8000);
119        assert!(tone_score(&s, sr, 440.0) > 0.8, "expected strong 440 match");
120        assert!(
121            tone_score(&s, sr, 1000.0) < 0.1,
122            "1000 Hz should not match a 440 tone"
123        );
124    }
125
126    #[test]
127    fn silence_and_empty_score_zero() {
128        assert_eq!(tone_score(&[], 8000, 440.0), 0.0);
129        assert_eq!(tone_score(&[0i16; 4000], 8000, 440.0), 0.0);
130    }
131
132    #[test]
133    fn write_wav_roundtrips_header_and_samples() {
134        let s = sine(440.0, 8000, 8000);
135        let dir = std::env::temp_dir();
136        let path = dir.join("ringo-flow-write-wav-test.wav");
137        write_wav(&path, &s, 8000).unwrap();
138        let b = std::fs::read(&path).unwrap();
139        let _ = std::fs::remove_file(&path);
140        assert_eq!(&b[0..4], b"RIFF");
141        assert_eq!(&b[8..12], b"WAVE");
142        assert_eq!(u32::from_le_bytes([b[24], b[25], b[26], b[27]]), 8000); // srate
143        // 44-byte header + 2 bytes/sample
144        assert_eq!(b.len(), 44 + s.len() * 2);
145        let parsed: Vec<i16> = b[44..]
146            .chunks_exact(2)
147            .map(|c| i16::from_le_bytes([c[0], c[1]]))
148            .collect();
149        assert_eq!(parsed, s);
150    }
151
152    #[test]
153    fn threshold_separates_tone_from_noise() {
154        let sr = 8000;
155        // alternating +/- = high-frequency content, not 440 Hz
156        let noise: Vec<i16> = (0..8000)
157            .map(|i| if i % 2 == 0 { 4000 } else { -4000 })
158            .collect();
159        assert!(tone_score(&noise, sr, 440.0) < TONE_THRESHOLD);
160        assert!(tone_score(&sine(440.0, sr, 8000), sr, 440.0) >= TONE_THRESHOLD);
161    }
162}