aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-09-09 14:49:06 +0200
committersalmonik <salmonik@tuta.io>2026-09-09 14:49:06 +0200
commited364a7ab735085d0b5b47567d5d55b143a307cd (patch)
treedf98ffcfa8010ea25a498d067eb66c8bb879985b
parentwire recording into voice manager, config, events, /record command (diff)
downloadsenator-sperma-ed364a7ab735085d0b5b47567d5d55b143a307cd.tar.gz
senator-sperma-ed364a7ab735085d0b5b47567d5d55b143a307cd.zip
test recorder ring semantics; extract mix_tick
-rw-r--r--src/recording/mod.rs237
1 files changed, 187 insertions, 50 deletions
diff --git a/src/recording/mod.rs b/src/recording/mod.rs
index 6ee4b11..74ec516 100644
--- a/src/recording/mod.rs
+++ b/src/recording/mod.rs
@@ -28,7 +28,6 @@
28 28
29use std::process::Stdio; 29use std::process::Stdio;
30use std::sync::{Arc, Mutex, Weak}; 30use std::sync::{Arc, Mutex, Weak};
31use std::time::Instant;
32 31
33use poise::serenity_prelude as serenity; 32use poise::serenity_prelude as serenity;
34use songbird::{Event, EventContext, EventHandler}; 33use songbird::{Event, EventContext, EventHandler};
@@ -53,7 +52,6 @@ struct Inner {
53 head: usize, 52 head: usize,
54 /// Monotonic count of 20 ms frames written since recording started. 53 /// Monotonic count of 20 ms frames written since recording started.
55 frames_written: u64, 54 frames_written: u64,
56 started: Instant,
57} 55}
58 56
59impl Recorder { 57impl Recorder {
@@ -71,7 +69,6 @@ impl Recorder {
71 ring: vec![0i16; window_samples], 69 ring: vec![0i16; window_samples],
72 head: 0, 70 head: 0,
73 frames_written: 0, 71 frames_written: 0,
74 started: Instant::now(),
75 }), 72 }),
76 sample_rate, 73 sample_rate,
77 samples_per_frame, 74 samples_per_frame,
@@ -83,11 +80,17 @@ impl Recorder {
83 self.sample_rate 80 self.sample_rate
84 } 81 }
85 82
86 /// Snapshot `min(elapsed, window, written)` seconds of audio, oldest→newest, 83 /// Snapshot the last `min(frames_written, window)` frames of audio,
87 /// as little-endian mono i16 bytes (the `s16le` format ffmpeg consumes). 84 /// oldest→newest, as little-endian mono i16 bytes (the `s16le` format
85 /// ffmpeg consumes).
88 /// 86 ///
89 /// Read-only: the ring is left intact (flush-and-keep, per the `/record` 87 /// Read-only: the ring is left intact (flush-and-keep, per the `/record`
90 /// design — successive calls may overlap). 88 /// design — successive calls may overlap).
89 ///
90 /// `frames_written` is authoritative: the ring holds exactly the last
91 /// `min(frames_written, TICKS_PER_SEC * window_seconds)` frames, each
92 /// written by one real 20 ms tick. A wall-clock term would drift from the
93 /// mixer whenever ticks stall or bunch; the ring is the ground truth.
91 pub fn flush(&self) -> Vec<u8> { 94 pub fn flush(&self) -> Vec<u8> {
92 let inner = match self.inner.lock() { 95 let inner = match self.inner.lock() {
93 Ok(g) => g, 96 Ok(g) => g,
@@ -97,33 +100,67 @@ impl Recorder {
97 } 100 }
98 }; 101 };
99 let len = self.window_samples; 102 let len = self.window_samples;
100 let spf = self.samples_per_frame; 103 let d = inner
101 let written_samples = inner
102 .frames_written 104 .frames_written
103 .saturating_mul(spf as u64) 105 .saturating_mul(self.samples_per_frame as u64)
104 .min(len as u64) as usize; 106 .min(len as u64) as usize;
105 let elapsed_samples =
106 (inner.started.elapsed().as_secs_f64() * self.sample_rate as f64) as usize;
107 let d = written_samples.min(elapsed_samples).min(len);
108 if d == 0 { 107 if d == 0 {
109 return Vec::new(); 108 return Vec::new();
110 } 109 }
111 // Oldest live sample sits at `head - d` (mod len); read `d` forward. 110 // Oldest live sample sits at `head - d` (mod len); read `d` forward.
112 let start = (inner.head + len - d) % len; 111 let start = (inner.head + len - d) % len;
113 let mut samples: Vec<i16> = Vec::with_capacity(d); 112 let mut bytes = Vec::with_capacity(d * 2);
114 if start + d <= len { 113 if start + d <= len {
115 samples.extend_from_slice(&inner.ring[start..start + d]); 114 push_le_i16(&mut bytes, &inner.ring[start..start + d]);
116 } else { 115 } else {
117 let first = len - start; 116 let first = len - start;
118 samples.extend_from_slice(&inner.ring[start..]); 117 push_le_i16(&mut bytes, &inner.ring[start..]);
119 samples.extend_from_slice(&inner.ring[..d - first]); 118 push_le_i16(&mut bytes, &inner.ring[..d - first]);
120 }
121 let mut bytes = Vec::with_capacity(samples.len() * 2);
122 for s in &samples {
123 bytes.extend_from_slice(&s.to_le_bytes());
124 } 119 }
125 bytes 120 bytes
126 } 121 }
122
123 /// Absorb one 20 ms tick: zero the window about to be written (silence
124 /// placement), saturating-add each speaker's decoded mono i16 frame, then
125 /// advance `head` by one frame.
126 ///
127 /// Split out from the `EventHandler` so tests can drive the ring without
128 /// constructing songbird's `#[non_exhaustive]` `VoiceTick`.
129 pub fn mix_tick(&self, speakers: &[Vec<i16>]) {
130 let mut inner = match self.inner.lock() {
131 Ok(g) => g,
132 Err(_) => return,
133 };
134 let spf = self.samples_per_frame;
135 let len = self.window_samples;
136
137 // Zero the window about to be written. This is the silence-placement
138 // step: it clears samples that aged out one window-length ago, so a
139 // tick with no speakers writes pure silence rather than stale audio.
140 for i in 0..spf {
141 let pos = (inner.head + i) % len;
142 inner.ring[pos] = 0;
143 }
144 // Mix every speaking SSRC's decoded mono i16 into the window.
145 // `saturating_add` prevents wraparound when several speakers overlap
146 // (the only real failure mode on the hot path); multi-speaker overlaps
147 // may clip slightly, the correct trade for a 50 Hz × N-packet mix.
148 for audio in speakers {
149 let n = audio.len().min(spf);
150 for i in 0..n {
151 let pos = (inner.head + i) % len;
152 inner.ring[pos] = inner.ring[pos].saturating_add(audio[i]);
153 }
154 }
155 inner.head = (inner.head + spf) % len;
156 inner.frames_written += 1;
157 }
158}
159
160fn push_le_i16(out: &mut Vec<u8>, samples: &[i16]) {
161 for s in samples {
162 out.extend_from_slice(&s.to_le_bytes());
163 }
127} 164}
128 165
129/// `songbird::EventHandler` registered on each `Call` while recording is 166/// `songbird::EventHandler` registered on each `Call` while recording is
@@ -140,7 +177,6 @@ impl RecorderEventHandler {
140 } 177 }
141 } 178 }
142} 179}
143
144#[async_trait::async_trait] 180#[async_trait::async_trait]
145impl EventHandler for RecorderEventHandler { 181impl EventHandler for RecorderEventHandler {
146 async fn act(&self, ctx: &EventContext<'_>) -> Option<Event> { 182 async fn act(&self, ctx: &EventContext<'_>) -> Option<Event> {
@@ -154,36 +190,12 @@ impl EventHandler for RecorderEventHandler {
154 // until songbird drops the Call and us along with it. 190 // until songbird drops the Call and us along with it.
155 return None; 191 return None;
156 }; 192 };
157 let spf = rec.samples_per_frame; 193 let frames: Vec<Vec<i16>> = tick
158 let len = rec.window_samples; 194 .speaking
159 let mut inner = match rec.inner.lock() { 195 .values()
160 Ok(g) => g, 196 .filter_map(|vd| vd.decoded_voice.clone())
161 Err(_) => return None, 197 .collect();
162 }; 198 rec.mix_tick(&frames);
163
164 // Zero the window about to be written. This is the silence-placement
165 // step: it clears samples that aged out one window-length ago, so a
166 // tick with no speakers writes pure silence rather than stale audio.
167 for i in 0..spf {
168 let pos = (inner.head + i) % len;
169 inner.ring[pos] = 0;
170 }
171 // Mix every speaking SSRC's decoded mono i16 into the window.
172 // `saturating_add` prevents wraparound when several speakers overlap
173 // (the only real failure mode on the hot path); multi-speaker overlaps
174 // may clip slightly, the correct trade for a 50 Hz × N-packet mix.
175 for vd in tick.speaking.values() {
176 if let Some(audio) = vd.decoded_voice.as_ref() {
177 let n = audio.len().min(spf);
178 for i in 0..n {
179 let pos = (inner.head + i) % len;
180 inner.ring[pos] = inner.ring[pos].saturating_add(audio[i]);
181 }
182 }
183 }
184 inner.head = (inner.head + spf) % len;
185 inner.frames_written += 1;
186 let _ = rec; // drop the strong ref obtained via upgrade()
187 None // keep listening 199 None // keep listening
188 } 200 }
189} 201}
@@ -306,3 +318,128 @@ pub async fn on_bot_voice_state(
306 } 318 }
307 } 319 }
308} 320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 /// Decode the `s16le` byte stream `flush` produces back into i16 samples.
327 fn to_i16(bytes: &[u8]) -> Vec<i16> {
328 bytes
329 .chunks_exact(2)
330 .map(|c| i16::from_le_bytes([c[0], c[1]]))
331 .collect()
332 }
333
334 #[test]
335 fn empty_recorder_flushes_nothing() {
336 let rec = Recorder::new(48_000, 1);
337 assert!(rec.flush().is_empty());
338 }
339
340 #[test]
341 fn single_speaker_values_survive() {
342 let rec = Recorder::new(48_000, 1);
343 let spf = rec.samples_per_frame;
344 // 10 ticks of a constant 100.
345 for _ in 0..10 {
346 rec.mix_tick(&[vec![100i16; spf]]);
347 }
348 let out = to_i16(&rec.flush());
349 assert_eq!(out.len(), 10 * spf);
350 assert!(out.iter().all(|&s| s == 100));
351 }
352
353 #[test]
354 fn silent_tick_writes_silence_not_stale_audio() {
355 let rec = Recorder::new(48_000, 1);
356 let spf = rec.samples_per_frame;
357 // Fill the whole 50-frame window with 100s.
358 for _ in 0..TICKS_PER_SEC {
359 rec.mix_tick(&[vec![100i16; spf]]);
360 }
361 // A tick with no speakers must age out one frame of the 100s…
362 rec.mix_tick(&[]);
363 let out = to_i16(&rec.flush());
364 assert_eq!(out.len(), TICKS_PER_SEC * spf);
365 // …and write 960 pure-silence samples at the *newest* end, keeping
366 // 49 frames of the old audio as the oldest content.
367 assert!(out[out.len() - spf..].iter().all(|&s| s == 0));
368 assert!(out[..(TICKS_PER_SEC - 1) * spf].iter().all(|&s| s == 100));
369 }
370
371 #[test]
372 fn two_speakers_mix_saturating() {
373 let rec = Recorder::new(48_000, 1);
374 let spf = rec.samples_per_frame;
375 rec.mix_tick(&[vec![20000i16; spf], vec![10000i16; spf]]);
376 let out = to_i16(&rec.flush());
377 assert!(out.iter().all(|&s| s == 30000));
378
379 // Overflow saturates instead of wrapping.
380 let rec = Recorder::new(48_000, 1);
381 rec.mix_tick(&[vec![30000i16; spf], vec![30000i16; spf]]);
382 let out = to_i16(&rec.flush());
383 assert!(out.iter().all(|&s| s == i16::MAX));
384 }
385
386 #[test]
387 fn ring_wraps_and_flush_orders_oldest_to_newest() {
388 // 2-frame window keeps the wrap tight and the assertion unambiguous.
389 // window_samples = 2 * spf ⇒ a whole number of 20 ms frames.
390 let rec = Recorder::new(48_000, 0); // 0 s → 0 samples; not a whole frame…
391 // …so build one manually with a 2-frame window instead.
392 drop(rec);
393 let spf = 960usize;
394 let rec = Recorder {
395 inner: Mutex::new(Inner {
396 ring: vec![0i16; 2 * spf],
397 head: 0,
398 frames_written: 0,
399 }),
400 sample_rate: 48_000,
401 samples_per_frame: spf,
402 window_samples: 2 * spf,
403 };
404 // Frame A = 7s, then frame B = 9s, then frame C = 11s (overwrites A).
405 rec.mix_tick(&[vec![7i16; spf]]);
406 rec.mix_tick(&[vec![9i16; spf]]);
407 rec.mix_tick(&[vec![11i16; spf]]);
408 let out = to_i16(&rec.flush());
409 assert_eq!(out.len(), 2 * spf);
410 // Oldest→newest must be B, C — the wrapped-over A is gone.
411 assert!(out[..spf].iter().all(|&s| s == 9));
412 assert!(out[spf..].iter().all(|&s| s == 11));
413 }
414
415 #[test]
416 fn flush_is_read_only() {
417 let rec = Recorder::new(48_000, 1);
418 let spf = rec.samples_per_frame;
419 rec.mix_tick(&[vec![50i16; spf]]);
420 let first = to_i16(&rec.flush());
421 let second = to_i16(&rec.flush());
422 assert_eq!(first, second);
423 }
424
425 #[tokio::test]
426 async fn encode_mp3_produces_mp3_frames() {
427 // 1 s of a 440 Hz sine at 48 kHz mono.
428 let sample_rate = 48_000u32;
429 let mut pcm = Vec::with_capacity(sample_rate as usize * 2);
430 for n in 0..sample_rate {
431 let t = n as f64 / sample_rate as f64;
432 let s = (2.0 * std::f64::consts::PI * 440.0 * t).sin() * 8000.0;
433 pcm.extend_from_slice(&(s as i16).to_le_bytes());
434 }
435 let mp3 = encode_mp3(&pcm, sample_rate)
436 .await
437 .expect("mp3 encode failed");
438 assert!(!mp3.is_empty(), "ffmpeg produced no mp3 bytes");
439 // MP3 frame sync: 0xFF followed by a byte with the upper 3 bits set
440 // (0xE0 mask), or an ID3 tag header.
441 let has_frame = mp3.windows(2).any(|w| w[0] == 0xFF && (w[1] & 0xE0) == 0xE0);
442 let has_id3 = mp3.starts_with(b"ID3");
443 assert!(has_frame || has_id3, "output is not an mp3 stream");
444 }
445}