aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-04 01:09:53 +0100
committersalmonik <salmonik@tuta.io>2026-11-04 01:09:53 +0100
commit76c1824bd6f0dcb157544862652b65af8f6539ce (patch)
tree1b89a5394d2cfb207d3fe37ae6d40d4a86dfd04a
parenttriggers: scope groups to the guild that created them (diff)
downloadsenator-sperma-76c1824bd6f0dcb157544862652b65af8f6539ce.tar.gz
senator-sperma-76c1824bd6f0dcb157544862652b65af8f6539ce.zip
recording: keep one buffer per guild
-rw-r--r--src/commands/record.rs7
-rw-r--r--src/recording/mod.rs21
-rw-r--r--src/voice/mod.rs47
3 files changed, 41 insertions, 34 deletions
diff --git a/src/commands/record.rs b/src/commands/record.rs
index 48cbc59..dfe736d 100644
--- a/src/commands/record.rs
+++ b/src/commands/record.rs
@@ -1,3 +1,4 @@
1use poise::serenity_prelude as serenity;
1use tracing::warn; 2use tracing::warn;
2 3
3use crate::error::Result; 4use crate::error::Result;
@@ -52,14 +53,14 @@ pub async fn record(ctx: Context<'_>) -> Result<()> {
52 ctx.say(lang::L_RECORD_NOT_SAME_VC).await?; 53 ctx.say(lang::L_RECORD_NOT_SAME_VC).await?;
53 Ok(()) 54 Ok(())
54 } 55 }
55 Some(_) => record_impl(ctx).await, 56 Some(_) => record_impl(ctx, guild_id).await,
56 } 57 }
57} 58}
58 59
59async fn record_impl(ctx: Context<'_>) -> Result<()> { 60async fn record_impl(ctx: Context<'_>, guild_id: serenity::GuildId) -> Result<()> {
60 let data = ctx.data(); 61 let data = ctx.data();
61 62
62 let Some(rec) = data.voice_manager.get_recorder().await else { 63 let Some(rec) = data.voice_manager.get_recorder(guild_id).await else {
63 ctx.say(lang::L_RECORD_NOT_RECORDING).await?; 64 ctx.say(lang::L_RECORD_NOT_RECORDING).await?;
64 return Ok(()); 65 return Ok(());
65 }; 66 };
diff --git a/src/recording/mod.rs b/src/recording/mod.rs
index d0a94be..4cd2891 100644
--- a/src/recording/mod.rs
+++ b/src/recording/mod.rs
@@ -304,22 +304,21 @@ pub async fn on_bot_voice_state(
304 if !voice_manager.recording_enabled() { 304 if !voice_manager.recording_enabled() {
305 return; 305 return;
306 } 306 }
307 let Some(guild_id) = guild_id else {
308 return;
309 };
307 // Already torn down by `VoiceManager::disconnect` in the `/stop` path; this 310 // Already torn down by `VoiceManager::disconnect` in the `/stop` path; this
308 // is the catch-all for leaves `disconnect` didn't initiate (kicks, drops). 311 // is the catch-all for leaves `disconnect` didn't initiate (kicks, drops).
309 if new_channel.is_none() { 312 if new_channel.is_none() {
310 voice_manager.stop_recording().await; 313 voice_manager.stop_recording(guild_id).await;
311 return; 314 return;
312 } 315 }
313 // Joined or moved into a channel. If the recorder is missing (e.g. a move 316 // Joined or moved into a channel. If this guild's recorder is missing
314 // broke the prior Call), try to (re)register on the current Call. 317 // (e.g. a move broke the prior Call), try to (re)register on the current
315 if new_channel != old_channel { 318 // Call.
316 let existing = voice_manager.get_recorder().await.is_some(); 319 if new_channel != old_channel && voice_manager.get_recorder(guild_id).await.is_none() {
317 if !existing { 320 if let Some(call) = voice_manager.get_call(guild_id) {
318 if let Some(gid) = guild_id { 321 voice_manager.start_recording(guild_id, &call).await;
319 if let Some(call) = voice_manager.get_call(gid) {
320 voice_manager.start_recording(&call).await;
321 }
322 }
323 } 322 }
324 } 323 }
325} 324}
diff --git a/src/voice/mod.rs b/src/voice/mod.rs
index 0b6022c..bbb73a0 100644
--- a/src/voice/mod.rs
+++ b/src/voice/mod.rs
@@ -1,3 +1,4 @@
1use std::collections::HashMap;
1use std::io::Cursor; 2use std::io::Cursor;
2use std::pin::Pin; 3use std::pin::Pin;
3use std::sync::Arc; 4use std::sync::Arc;
@@ -28,11 +29,13 @@ const RECORDING_SAMPLE_RATE: u32 = 48000;
28#[derive(Clone)] 29#[derive(Clone)]
29pub struct VoiceManager { 30pub struct VoiceManager {
30 pub songbird: Arc<Songbird>, 31 pub songbird: Arc<Songbird>,
31 /// Live recorder for the bot's current voice connection, if any. 32 /// Live recorder per guild, if any.
32 /// 33 ///
33 /// `None` whenever the bot is not connected (or recording is disabled) — 34 /// Entries exist only while the bot is connected (and recording) in that
34 /// so the ~86 MB ring only exists while actually recording a 15 min window. 35 /// guild, so the ~86 MB ring only exists while actually recording. Keying
35 pub recorder: Arc<Mutex<Option<Arc<Recorder>>>>, 36 /// by guild prevents one guild's connection from replacing or stopping
37 /// another guild's buffer.
38 pub recorders: Arc<Mutex<HashMap<GuildId, Arc<Recorder>>>>,
36 pub recording_enabled: bool, 39 pub recording_enabled: bool,
37 pub recording_window_seconds: u64, 40 pub recording_window_seconds: u64,
38} 41}
@@ -50,7 +53,7 @@ impl VoiceManager {
50 ))?; 53 ))?;
51 Ok(Self { 54 Ok(Self {
52 songbird, 55 songbird,
53 recorder: Arc::new(Mutex::new(None)), 56 recorders: Arc::new(Mutex::new(HashMap::new())),
54 recording_enabled, 57 recording_enabled,
55 recording_window_seconds, 58 recording_window_seconds,
56 }) 59 })
@@ -60,10 +63,10 @@ impl VoiceManager {
60 self.recording_enabled 63 self.recording_enabled
61 } 64 }
62 65
63 /// Construct a `Recorder`, register its `VoiceTick` handler on `call`, and 66 /// Construct a `Recorder` for `guild_id`, register its `VoiceTick` handler
64 /// store the strong `Arc` in the slot. Idempotent: if the slot already 67 /// on `call`, and store the strong `Arc`. Replacing an existing entry drops
65 /// holds a recorder, the caller is responsible for not double-registering. 68 /// the previous ring; its handler's `Weak` then no-ops.
66 pub async fn start_recording(&self, call: &Arc<Mutex<Call>>) { 69 pub async fn start_recording(&self, guild_id: GuildId, call: &Arc<Mutex<Call>>) {
67 if !self.recording_enabled { 70 if !self.recording_enabled {
68 return; 71 return;
69 } 72 }
@@ -79,25 +82,29 @@ impl VoiceManager {
79 handler, 82 handler,
80 ); 83 );
81 } 84 }
82 *self.recorder.lock().await = Some(recorder); 85 self.recorders
86 .lock()
87 .await
88 .insert(guild_id, recorder);
83 info!( 89 info!(
90 guild = %guild_id,
84 window_secs = self.recording_window_seconds, 91 window_secs = self.recording_window_seconds,
85 "recording: started rolling buffer for this voice connection" 92 "recording: started rolling buffer for this voice connection"
86 ); 93 );
87 } 94 }
88 95
89 /// Drop the live recorder (frees the ring immediately). The songbird `Call` 96 /// Drop this guild's live recorder (frees the ring immediately). The
90 /// may still exist briefly; the handler's `Weak` upgrades to `None` thereafter. 97 /// songbird `Call` may still exist briefly; the handler's `Weak` then no-ops.
91 pub async fn stop_recording(&self) { 98 pub async fn stop_recording(&self, guild_id: GuildId) {
92 let prev = self.recorder.lock().await.take(); 99 let prev = self.recorders.lock().await.remove(&guild_id);
93 if prev.is_some() { 100 if prev.is_some() {
94 info!("recording: stopped, ring buffer freed"); 101 info!(guild = %guild_id, "recording: stopped, ring buffer freed");
95 } 102 }
96 } 103 }
97 104
98 /// Clone of the live recorder `Arc`, if any — used by `/record` to snapshot. 105 /// Clone of this guild's live recorder, if any — used by `/record`.
99 pub async fn get_recorder(&self) -> Option<Arc<Recorder>> { 106 pub async fn get_recorder(&self, guild_id: GuildId) -> Option<Arc<Recorder>> {
100 self.recorder.lock().await.clone() 107 self.recorders.lock().await.get(&guild_id).cloned()
101 } 108 }
102 109
103 pub async fn connect( 110 pub async fn connect(
@@ -115,7 +122,7 @@ impl VoiceManager {
115 // (The bot's own VoiceStateUpdate also reconciles, but may 122 // (The bot's own VoiceStateUpdate also reconciles, but may
116 // race songbird's Call creation — this never misses.) 123 // race songbird's Call creation — this never misses.)
117 if self.recording_enabled { 124 if self.recording_enabled {
118 self.start_recording(&call).await; 125 self.start_recording(guild_id, &call).await;
119 } 126 }
120 return Ok(call); 127 return Ok(call);
121 } 128 }
@@ -143,7 +150,7 @@ impl VoiceManager {
143 pub async fn disconnect(&self, guild_id: GuildId) -> Result<()> { 150 pub async fn disconnect(&self, guild_id: GuildId) -> Result<()> {
144 // Free the ring before tearing down the Call — the handler's `Weak` 151 // Free the ring before tearing down the Call — the handler's `Weak`
145 // will then no-op any in-flight ticks until songbird drops it. 152 // will then no-op any in-flight ticks until songbird drops it.
146 self.stop_recording().await; 153 self.stop_recording(guild_id).await;
147 self.songbird 154 self.songbird
148 .remove(guild_id) 155 .remove(guild_id)
149 .await 156 .await