aboutsummaryrefslogtreecommitdiffstats
path: root/src/recording
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-09-09 13:42:38 +0200
committersalmonik <salmonik@tuta.io>2026-09-09 13:42:38 +0200
commit25115ac447e5356ca865e625515eb7c1f55b39bc (patch)
treecd8b0b62b8ac68e73168f5c43b430c2740b4329a /src/recording
parent/grajcuj plays video-like extensions too (diff)
downloadsenator-sperma-25115ac447e5356ca865e625515eb7c1f55b39bc.tar.gz
senator-sperma-25115ac447e5356ca865e625515eb7c1f55b39bc.zip
first draft
Diffstat (limited to '')
-rw-r--r--src/recording/mod.rs308
1 files changed, 308 insertions, 0 deletions
diff --git a/src/recording/mod.rs b/src/recording/mod.rs
new file mode 100644
index 0000000..6ee4b11
--- /dev/null
+++ b/src/recording/mod.rs
@@ -0,0 +1,308 @@
1//! Voice channel recording: a rolling in-memory PCM ring buffer holding the
2//! last `window` seconds of mixed audio, flushable to an mp3 on demand.
3//!
4//! ## How audio arrives
5//! songbird delivers a `CoreEvent::VoiceTick` every 20 ms carrying
6//! already-decoded, jitter-buffered mono i16 PCM per speaking SSRC — but only
7//! when the driver is configured with `DecodeMode::Decode(Channels::Mono,
8//! SampleRate::Hz48000)` (set in `main.rs` via `register_songbird_from_config`).
9//! That tick is *both* the audio source and the wall-clock driver: there is no
10//! separate tokio timer, no `CancellationToken`, no per-SSRC Opus decoder, no
11//! f32→s16 conversion. songbird pays the decode cost; we only mix.
12//!
13//! ## Lifecycle / memory
14//! The `EventHandler` registered on the `Call` holds a `Weak<Recorder>`. The
15//! sole strong `Arc<Recorder>` lives in `VoiceManager.recorder`. Tearing that
16//! slot down (`None`) drops the ring immediately — ~86 MB at 15 min / 48 kHz —
17//! even while the songbird `Call` itself lingers. While the slot is `None`,
18//! `Weak::upgrade` in the handler returns `None` and the tick is a no-op.
19//!
20//! ## Ring semantics
21//! `head` points at the first sample of the 20 ms window currently being
22//! written. Each tick: zero `[head, head+960)` (clearing audio aged out by one
23//! window-length, which is how silence is placed when nobody speaks), saturating
24//! add every speaker's 960 mono i16 samples into that window, then advance
25//! `head += 960` (mod `window_samples`). The newest live sample is therefore
26//! `head - 1`; the oldest is `head` (exactly one window old once the ring has
27//! wrapped). `flush` reads `min(elapsed, window, written)` samples oldest-→newest.
28
29use std::process::Stdio;
30use std::sync::{Arc, Mutex, Weak};
31use std::time::Instant;
32
33use poise::serenity_prelude as serenity;
34use songbird::{Event, EventContext, EventHandler};
35use tokio::io::{AsyncReadExt, AsyncWriteExt};
36use tracing::warn;
37
38use crate::error::{Error, Result};
39
40/// The songbird mixer clock fires one `VoiceTick` per 20 ms ⇒ 50 ticks/sec.
41const TICKS_PER_SEC: usize = 50;
42
43pub struct Recorder {
44 inner: Mutex<Inner>,
45 sample_rate: u32,
46 samples_per_frame: usize,
47 window_samples: usize,
48}
49
50struct Inner {
51 ring: Vec<i16>,
52 /// First sample index of the 20 ms window currently being written.
53 head: usize,
54 /// Monotonic count of 20 ms frames written since recording started.
55 frames_written: u64,
56 started: Instant,
57}
58
59impl Recorder {
60 pub fn new(sample_rate: u32, window_seconds: u64) -> Self {
61 let samples_per_frame = (sample_rate as usize) / TICKS_PER_SEC;
62 let window_samples = (sample_rate as usize) * (window_seconds as usize);
63 // `head` advances by `samples_per_frame` and wraps mod `window_samples`,
64 // so the window length must be a whole number of frames.
65 debug_assert!(
66 window_samples % samples_per_frame == 0,
67 "window must be a whole number of 20 ms frames"
68 );
69 Self {
70 inner: Mutex::new(Inner {
71 ring: vec![0i16; window_samples],
72 head: 0,
73 frames_written: 0,
74 started: Instant::now(),
75 }),
76 sample_rate,
77 samples_per_frame,
78 window_samples,
79 }
80 }
81
82 pub fn sample_rate(&self) -> u32 {
83 self.sample_rate
84 }
85
86 /// Snapshot `min(elapsed, window, written)` seconds of audio, oldest→newest,
87 /// as little-endian mono i16 bytes (the `s16le` format ffmpeg consumes).
88 ///
89 /// Read-only: the ring is left intact (flush-and-keep, per the `/record`
90 /// design — successive calls may overlap).
91 pub fn flush(&self) -> Vec<u8> {
92 let inner = match self.inner.lock() {
93 Ok(g) => g,
94 Err(e) => {
95 warn!("recorder mutex poisoned: {e}");
96 return Vec::new();
97 }
98 };
99 let len = self.window_samples;
100 let spf = self.samples_per_frame;
101 let written_samples = inner
102 .frames_written
103 .saturating_mul(spf as u64)
104 .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 {
109 return Vec::new();
110 }
111 // Oldest live sample sits at `head - d` (mod len); read `d` forward.
112 let start = (inner.head + len - d) % len;
113 let mut samples: Vec<i16> = Vec::with_capacity(d);
114 if start + d <= len {
115 samples.extend_from_slice(&inner.ring[start..start + d]);
116 } else {
117 let first = len - start;
118 samples.extend_from_slice(&inner.ring[start..]);
119 samples.extend_from_slice(&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 }
125 bytes
126 }
127}
128
129/// `songbird::EventHandler` registered on each `Call` while recording is
130/// enabled. Holds only a `Weak<Recorder>`, so dropping the strong `Arc` in
131/// `VoiceManager.recorder` immediately frees the ring; subsequent ticks no-op.
132pub struct RecorderEventHandler {
133 weak: Weak<Recorder>,
134}
135
136impl RecorderEventHandler {
137 pub fn new(recorder: &Arc<Recorder>) -> Self {
138 Self {
139 weak: Arc::downgrade(recorder),
140 }
141 }
142}
143
144#[async_trait::async_trait]
145impl EventHandler for RecorderEventHandler {
146 async fn act(&self, ctx: &EventContext<'_>) -> Option<Event> {
147 // Only registered for `CoreEvent::VoiceTick`, but match defensively.
148 let tick = match ctx {
149 EventContext::VoiceTick(t) => t,
150 _ => return None,
151 };
152 let Some(rec) = self.weak.upgrade() else {
153 // Recorder torn down — stop doing work, stay registered (cheap no-op)
154 // until songbird drops the Call and us along with it.
155 return None;
156 };
157 let spf = rec.samples_per_frame;
158 let len = rec.window_samples;
159 let mut inner = match rec.inner.lock() {
160 Ok(g) => g,
161 Err(_) => return None,
162 };
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
188 }
189}
190
191/// Encode little-endian mono s16 PCM to mp3 via an ffmpeg subprocess.
192///
193/// Mirrors the bot's other ffmpeg-subprocess patterns (e.g. `ffmpeg_stream_input`)
194/// but for a *finite* buffer: write all PCM to stdin (EOF on drop), drain stderr
195/// so ffmpeg can't block on a full pipe, collect mp3 from stdout.
196pub async fn encode_mp3(pcm_s16le: &[u8], sample_rate: u32) -> Result<Vec<u8>> {
197 let mut child = tokio::process::Command::new("ffmpeg")
198 .args([
199 "-y",
200 "-f",
201 "s16le",
202 "-ar",
203 &sample_rate.to_string(),
204 "-ac",
205 "1",
206 "-i",
207 "pipe:0",
208 "-c:a",
209 "libmp3lame",
210 "-b:a",
211 "64k",
212 "-f",
213 "mp3",
214 "pipe:1",
215 ])
216 .stdin(Stdio::piped())
217 .stdout(Stdio::piped())
218 .stderr(Stdio::piped())
219 .kill_on_drop(true)
220 .spawn()
221 .map_err(|e| Error::Custom(format!("ffmpeg spawn: {e}")))?;
222
223 let stdin = child.stdin.take();
224 let stdout = child
225 .stdout
226 .take()
227 .ok_or_else(|| Error::Custom("ffmpeg stdout not captured".into()))?;
228 let stderr = child.stderr.take();
229
230 let pcm_owned = pcm_s16le.to_vec();
231 let write_task = tokio::spawn(async move {
232 if let Some(mut stdin) = stdin {
233 let _ = stdin.write_all(&pcm_owned).await;
234 // `stdin` drops here ⇒ ffmpeg sees EOF on its input.
235 }
236 });
237
238 // Drain stderr in the background so a full pipe can't deadlock ffmpeg,
239 // and surface the trace in the logs if encoding fails.
240 if let Some(stderr) = stderr {
241 tokio::spawn(async move {
242 let mut buf = Vec::with_capacity(4096);
243 let mut reader = tokio::io::BufReader::new(stderr);
244 let _ = reader.read_to_end(&mut buf).await;
245 if !buf.is_empty() {
246 warn!(
247 "ffmpeg (mp3 encode) stderr: {}",
248 String::from_utf8_lossy(&buf).trim_end()
249 );
250 }
251 });
252 }
253
254 let mut mp3 = Vec::new();
255 let mut reader = tokio::io::BufReader::new(stdout);
256 reader
257 .read_to_end(&mut mp3)
258 .await
259 .map_err(|e| Error::Custom(format!("ffmpeg read: {e}")))?;
260
261 let _ = write_task.await;
262 let status = child
263 .wait()
264 .await
265 .map_err(|e| Error::Custom(format!("ffmpeg wait: {e}")))?;
266 if !status.success() {
267 return Err(Error::Custom(
268 "ffmpeg mp3 encode failed (non-zero exit)".into(),
269 ));
270 }
271 Ok(mp3)
272}
273
274/// Reconcile the recorder with the bot's own voice state.
275///
276/// Called from `events::handle_voice_state_update` for the bot itself (before
277/// the existing bot-skip). The explicit-join path (`VoiceManager::connect`)
278/// starts the recorder itself; this handles the cases `connect` can't see:
279/// admin moves (re-setup if the recorder was dropped) and all leaves —
280/// `/stop`, kicks, gateway drops — which tear it down.
281pub async fn on_bot_voice_state(
282 voice_manager: &crate::voice::VoiceManager,
283 guild_id: Option<serenity::GuildId>,
284 old_channel: Option<serenity::ChannelId>,
285 new_channel: Option<serenity::ChannelId>,
286) {
287 if !voice_manager.recording_enabled() {
288 return;
289 }
290 // Already torn down by `VoiceManager::disconnect` in the `/stop` path; this
291 // is the catch-all for leaves `disconnect` didn't initiate (kicks, drops).
292 if new_channel.is_none() {
293 voice_manager.stop_recording().await;
294 return;
295 }
296 // Joined or moved into a channel. If the recorder is missing (e.g. a move
297 // broke the prior Call), try to (re)register on the current Call.
298 if new_channel != old_channel {
299 let existing = voice_manager.get_recorder().await.is_some();
300 if !existing {
301 if let Some(gid) = guild_id {
302 if let Some(call) = voice_manager.get_call(gid) {
303 voice_manager.start_recording(&call).await;
304 }
305 }
306 }
307 }
308}