1use anyhow::{Context, Result};
12use std::path::Path;
13
14pub const TONE_THRESHOLD: f64 = 0.2;
16
17pub 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 (2.0 * power) / (n as f64 * energy)
42}
43
44#[derive(Debug, Clone, Copy, Default)]
47pub struct ToneAnalysis {
48 pub score: f64,
50 pub rms: f64,
52 pub samples: usize,
54}
55
56pub 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
78pub 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; 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()); v.extend_from_slice(&1u16.to_le_bytes()); v.extend_from_slice(&1u16.to_le_bytes()); 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()); v.extend_from_slice(&16u16.to_le_bytes()); 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); 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 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}