diff options
Diffstat (limited to 'src/recording')
| -rw-r--r-- | src/recording/mod.rs | 308 |
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 | |||
| 29 | use std::process::Stdio; | ||
| 30 | use std::sync::{Arc, Mutex, Weak}; | ||
| 31 | use std::time::Instant; | ||
| 32 | |||
| 33 | use poise::serenity_prelude as serenity; | ||
| 34 | use songbird::{Event, EventContext, EventHandler}; | ||
| 35 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | ||
| 36 | use tracing::warn; | ||
| 37 | |||
| 38 | use crate::error::{Error, Result}; | ||
| 39 | |||
| 40 | /// The songbird mixer clock fires one `VoiceTick` per 20 ms ⇒ 50 ticks/sec. | ||
| 41 | const TICKS_PER_SEC: usize = 50; | ||
| 42 | |||
| 43 | pub struct Recorder { | ||
| 44 | inner: Mutex<Inner>, | ||
| 45 | sample_rate: u32, | ||
| 46 | samples_per_frame: usize, | ||
| 47 | window_samples: usize, | ||
| 48 | } | ||
| 49 | |||
| 50 | struct 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 | |||
| 59 | impl 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. | ||
| 132 | pub struct RecorderEventHandler { | ||
| 133 | weak: Weak<Recorder>, | ||
| 134 | } | ||
| 135 | |||
| 136 | impl 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] | ||
| 145 | impl 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. | ||
| 196 | pub 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. | ||
| 281 | pub 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 | } | ||
