aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-09-09 14:36:50 +0200
committersalmonik <salmonik@tuta.io>2026-09-09 14:36:50 +0200
commit0d1fdab3a10b30d83da50b7f20acc18b7a350a22 (patch)
tree2c463b26add0a50bd7150c7eeee665d315a988d2
parentfirst draft (diff)
downloadsenator-sperma-0d1fdab3a10b30d83da50b7f20acc18b7a350a22.tar.gz
senator-sperma-0d1fdab3a10b30d83da50b7f20acc18b7a350a22.zip
wire recording into voice manager, config, events, /record command
-rw-r--r--Cargo.lock1
-rw-r--r--Cargo.toml3
-rw-r--r--src/commands/mod.rs2
-rw-r--r--src/config.rs22
-rw-r--r--src/events/mod.rs14
-rw-r--r--src/lang.rs19
-rw-r--r--src/main.rs22
-rw-r--r--src/voice/mod.rs80
8 files changed, 158 insertions, 5 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 7ab8ebc..19d2232 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4636,6 +4636,7 @@ dependencies = [
4636name = "senator-sperma" 4636name = "senator-sperma"
4637version = "0.4.1" 4637version = "0.4.1"
4638dependencies = [ 4638dependencies = [
4639 "async-trait",
4639 "chrono", 4640 "chrono",
4640 "cron", 4641 "cron",
4641 "dashmap 6.2.1", 4642 "dashmap 6.2.1",
diff --git a/Cargo.toml b/Cargo.toml
index a1d8b5b..b839b89 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,7 +10,8 @@ debug = 1
10# Discord 10# Discord
11poise = "0.6.2" 11poise = "0.6.2"
12serenity = "0.12.5" 12serenity = "0.12.5"
13songbird = "0.6" 13songbird = { version = "0.6", features = ["receive"] }
14async-trait = "0.1"
14 15
15# Async runtime 16# Async runtime
16tokio = { version = "1.52.3", features = [ 17tokio = { version = "1.52.3", features = [
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index 12e2a3d..bce95d4 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -1,3 +1,4 @@
1pub mod record;
1pub mod music; 2pub mod music;
2pub mod tts; 3pub mod tts;
3pub mod ai; 4pub mod ai;
@@ -24,5 +25,6 @@ pub fn all_commands() -> Vec<poise::Command<Data, crate::error::Error>> {
24 cmds.extend(trigger::commands()); 25 cmds.extend(trigger::commands());
25 cmds.extend(utilities::commands()); 26 cmds.extend(utilities::commands());
26 cmds.extend(leaderboard::commands()); 27 cmds.extend(leaderboard::commands());
28 cmds.extend(record::commands());
27 cmds 29 cmds
28} 30}
diff --git a/src/config.rs b/src/config.rs
index 3b92dd8..95c1a18 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -24,6 +24,8 @@ pub struct Config {
24 /// Piper voice name, used to resolve the model file when `tts_model_path` 24 /// Piper voice name, used to resolve the model file when `tts_model_path`
25 /// points at a directory. Ignored when it points at a `.onnx` file directly. 25 /// points at a directory. Ignored when it points at a `.onnx` file directly.
26 pub tts_voice: String, 26 pub tts_voice: String,
27 pub recording_enabled: bool,
28 pub recording_window_seconds: u64,
27} 29}
28 30
29struct EnvVar { 31struct EnvVar {
@@ -124,6 +126,18 @@ const ENV_VARS: &[EnvVar] = &[
124 default: Some("info"), 126 default: Some("info"),
125 description: "tracing/logging level filter", 127 description: "tracing/logging level filter",
126 }, 128 },
129 EnvVar {
130 name: "RECORDING_ENABLED",
131 required: false,
132 default: Some("true"),
133 description: "enable continuous voice recording while in a VC (set false to opt out)",
134 },
135 EnvVar {
136 name: "RECORDING_WINDOW_SECONDS",
137 required: false,
138 default: Some("900"),
139 description: "rolling recording buffer length in seconds (memory ~sample_rate*2 bytes/sec)",
140 },
127]; 141];
128 142
129pub fn check_env_vars() { 143pub fn check_env_vars() {
@@ -186,6 +200,14 @@ impl Config {
186 .ok() 200 .ok()
187 .and_then(|v| v.parse().ok()), 201 .and_then(|v| v.parse().ok()),
188 assistant_keyword: env::var("ASSISTANT_KEYWORD").unwrap_or_else(|_| "jarvis".into()), 202 assistant_keyword: env::var("ASSISTANT_KEYWORD").unwrap_or_else(|_| "jarvis".into()),
203 recording_enabled: env::var("RECORDING_ENABLED")
204 .unwrap_or_else(|_| "true".into())
205 .parse()
206 .unwrap_or(true),
207 recording_window_seconds: env::var("RECORDING_WINDOW_SECONDS")
208 .unwrap_or_else(|_| "900".into())
209 .parse()
210 .unwrap_or(900),
189 copyparty_url: env::var("COPYPARTY_URL") 211 copyparty_url: env::var("COPYPARTY_URL")
190 .unwrap_or_else(|_| "http://localhost:3939".into()), 212 .unwrap_or_else(|_| "http://localhost:3939".into()),
191 copyparty_url_upload: env::var("COPYPARTY_URL_UPLOAD") 213 copyparty_url_upload: env::var("COPYPARTY_URL_UPLOAD")
diff --git a/src/events/mod.rs b/src/events/mod.rs
index 1c8c0dc..5006444 100644
--- a/src/events/mod.rs
+++ b/src/events/mod.rs
@@ -71,6 +71,20 @@ pub async fn handle_voice_state_update(
71 let user_id = new.user_id; 71 let user_id = new.user_id;
72 let guild_id = new.guild_id; 72 let guild_id = new.guild_id;
73 73
74 // The bot's own voice state drives the recording lifecycle (catches admin
75 // moves, kicks, and gateway drops that VoiceManager::connect/disconnect
76 // can't see). Must run before the generic bot-skip below.
77 if user_id == ctx.cache.current_user().id {
78 crate::recording::on_bot_voice_state(
79 &data.voice_manager,
80 guild_id,
81 old.as_ref().and_then(|v| v.channel_id),
82 new.channel_id,
83 )
84 .await;
85 return Ok(());
86 }
87
74 // skip bots (Python behavior) 88 // skip bots (Python behavior)
75 let member = guild_id 89 let member = guild_id
76 .and_then(|gid| ctx.cache.guild(gid)) 90 .and_then(|gid| ctx.cache.guild(gid))
diff --git a/src/lang.rs b/src/lang.rs
index 238d2df..4bba9b5 100644
--- a/src/lang.rs
+++ b/src/lang.rs
@@ -97,6 +97,25 @@ pub const L_MUSIC_STOPPED: &str = "wyszed";
97pub const L_MUSIC_NO_AUDIO_ATTACHMENTS: &str = "Brak plików audio w wiadomości"; 97pub const L_MUSIC_NO_AUDIO_ATTACHMENTS: &str = "Brak plików audio w wiadomości";
98pub const L_VOICE_USER_NOT_IN_VC: &str = "You are not in a voice channel"; 98pub const L_VOICE_USER_NOT_IN_VC: &str = "You are not in a voice channel";
99 99
100// ── Recording / nagranie ──────────────────────────────────────────────
101
102/// Nagrywanie jest wyłączone w konfiguracji (`RECORDING_ENABLED=false`).
103pub const L_RECORD_DISABLED: &str = "Nagrywanie jest wyłączone w konfiguracji";
104/// Caller isn't in any voice channel.
105pub const L_RECORD_NOT_IN_VOICE: &str = "Nie jesteś na kanale głosowym";
106/// Bot isn't connected to a voice channel — nothing to dump.
107pub const L_RECORD_NOT_RECORDING: &str = "Bot nie nagrywa — nie jest na kanale głosowym";
108/// Caller is in a different VC than the bot (same-VC gate).
109pub const L_RECORD_NOT_SAME_VC: &str = "Musisz być na tym samym kanale głosowym co bot";
110/// Buffer has no audio yet (called before the first 20 ms tick landed).
111pub const L_RECORD_NOTHING: &str = "Brak nagrania do zapisania";
112pub const L_RECORD_ENCODE_FAILED: &str = "Nie udało się zakodować nagrania (ffmpeg)";
113pub const L_RECORD_UPLOAD_FAILED: &str = "Nie udało się wgrać nagrania";
114
115pub fn fmt_record_uploaded(url: &str) -> String {
116 format!("Nagranie: {url}")
117}
118
100pub fn fmt_music_search_failed(e: &dyn std::fmt::Display) -> String { 119pub fn fmt_music_search_failed(e: &dyn std::fmt::Display) -> String {
101 format!("Szukanie na copyparty zawiodło: `{e}`") 120 format!("Szukanie na copyparty zawiodło: `{e}`")
102} 121}
diff --git a/src/main.rs b/src/main.rs
index 5ab33ab..8225751 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -14,6 +14,7 @@ mod error;
14mod events; 14mod events;
15mod lang; 15mod lang;
16mod openrouter; 16mod openrouter;
17mod recording;
17mod tasks; 18mod tasks;
18mod tts; 19mod tts;
19mod voice; 20mod voice;
@@ -185,7 +186,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
185 let copyparty = copyparty.clone(); 186 let copyparty = copyparty.clone();
186 let triggers = triggers.clone(); 187 let triggers = triggers.clone();
187 Box::pin(async move { 188 Box::pin(async move {
188 let vm = voice::VoiceManager::new(ctx).await?; 189 let vm = voice::VoiceManager::new(
190 ctx,
191 config.recording_enabled,
192 config.recording_window_seconds,
193 )
194 .await?;
189 195
190 let music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>> = Arc::new(tokio::sync::Mutex::new(HashMap::new())); 196 let music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>> = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
191 197
@@ -231,10 +237,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
231 }) 237 })
232 .build(); 238 .build();
233 239
240 // Configure songbird: when recording is enabled, decode incoming voice to
241 // mono 48 kHz i16 so that `CoreEvent::VoiceTick` carries `decoded_voice`
242 // (without this, `decoded_voice` is `None` and there's nothing to record).
243 let mut songbird_config = songbird::Config::default();
244 if config_for_client.recording_enabled {
245 songbird_config.decode_mode =
246 songbird::driver::DecodeMode::Decode(songbird::driver::DecodeConfig::new(
247 songbird::driver::Channels::Mono,
248 songbird::driver::SampleRate::Hz48000,
249 ));
250 }
251
234 let mut client = 252 let mut client =
235 serenity::ClientBuilder::new(&config_for_client.token, serenity::GatewayIntents::all()) 253 serenity::ClientBuilder::new(&config_for_client.token, serenity::GatewayIntents::all())
236 .framework(framework) 254 .framework(framework)
237 .register_songbird() 255 .register_songbird_from_config(songbird_config)
238 .await?; 256 .await?;
239 257
240 client.start().await?; 258 client.start().await?;
diff --git a/src/voice/mod.rs b/src/voice/mod.rs
index 9c92202..fcb0b07 100644
--- a/src/voice/mod.rs
+++ b/src/voice/mod.rs
@@ -18,20 +18,85 @@ use tracing::{info, warn};
18 18
19use crate::error::{Error, Result}; 19use crate::error::{Error, Result};
20use crate::lang; 20use crate::lang;
21use crate::recording::{Recorder, RecorderEventHandler};
22
23/// Sample rate (Hz) songbird decodes incoming voice into — drives ring sizing
24/// and the ffmpeg `s16le` encode. Must match the `DecodeConfig` set in `main.rs`.
25const RECORDING_SAMPLE_RATE: u32 = 48000;
21 26
22#[derive(Clone)] 27#[derive(Clone)]
23pub struct VoiceManager { 28pub struct VoiceManager {
24 pub songbird: Arc<Songbird>, 29 pub songbird: Arc<Songbird>,
30 /// Live recorder for the bot's current voice connection, if any.
31 ///
32 /// `None` whenever the bot is not connected (or recording is disabled) —
33 /// so the ~86 MB ring only exists while actually recording a 15 min window.
34 pub recorder: Arc<Mutex<Option<Arc<Recorder>>>>,
35 pub recording_enabled: bool,
36 pub recording_window_seconds: u64,
25} 37}
26 38
27impl VoiceManager { 39impl VoiceManager {
28 pub async fn new(ctx: &serenity::client::Context) -> Result<Self> { 40 pub async fn new(
41 ctx: &serenity::client::Context,
42 recording_enabled: bool,
43 recording_window_seconds: u64,
44 ) -> Result<Self> {
29 let songbird = songbird::get(ctx) 45 let songbird = songbird::get(ctx)
30 .await 46 .await
31 .ok_or_else(|| Error::Custom( 47 .ok_or_else(|| Error::Custom(
32 "Songbird not registered via register_songbird()".into(), 48 "Songbird not registered via register_songbird()".into(),
33 ))?; 49 ))?;
34 Ok(Self { songbird }) 50 Ok(Self {
51 songbird,
52 recorder: Arc::new(Mutex::new(None)),
53 recording_enabled,
54 recording_window_seconds,
55 })
56 }
57
58 pub fn recording_enabled(&self) -> bool {
59 self.recording_enabled
60 }
61
62 /// Construct a `Recorder`, register its `VoiceTick` handler on `call`, and
63 /// store the strong `Arc` in the slot. Idempotent: if the slot already
64 /// holds a recorder, the caller is responsible for not double-registering.
65 pub async fn start_recording(&self, call: &Arc<Mutex<Call>>) {
66 if !self.recording_enabled {
67 return;
68 }
69 let recorder = Arc::new(Recorder::new(
70 RECORDING_SAMPLE_RATE,
71 self.recording_window_seconds,
72 ));
73 let handler = RecorderEventHandler::new(&recorder);
74 {
75 let mut call_lock = call.lock().await;
76 call_lock.add_global_event(
77 songbird::Event::Core(songbird::CoreEvent::VoiceTick),
78 handler,
79 );
80 }
81 *self.recorder.lock().await = Some(recorder);
82 info!(
83 window_secs = self.recording_window_seconds,
84 "recording: started rolling buffer for this voice connection"
85 );
86 }
87
88 /// Drop the live recorder (frees the ring immediately). The songbird `Call`
89 /// may still exist briefly; the handler's `Weak` upgrades to `None` thereafter.
90 pub async fn stop_recording(&self) {
91 let prev = self.recorder.lock().await.take();
92 if prev.is_some() {
93 info!("recording: stopped, ring buffer freed");
94 }
95 }
96
97 /// Clone of the live recorder `Arc`, if any — used by `/record` to snapshot.
98 pub async fn get_recorder(&self) -> Option<Arc<Recorder>> {
99 self.recorder.lock().await.clone()
35 } 100 }
36 101
37 pub async fn connect( 102 pub async fn connect(
@@ -44,6 +109,13 @@ impl VoiceManager {
44 match self.songbird.join(guild_id, channel_id).await { 109 match self.songbird.join(guild_id, channel_id).await {
45 Ok(call) => { 110 Ok(call) => {
46 tokio::time::sleep(Duration::from_millis(500)).await; 111 tokio::time::sleep(Duration::from_millis(500)).await;
112 // Reliable setup point: we hold the `Call` right after a
113 // successful join, so register the VoiceTick handler here.
114 // (The bot's own VoiceStateUpdate also reconciles, but may
115 // race songbird's Call creation — this never misses.)
116 if self.recording_enabled {
117 self.start_recording(&call).await;
118 }
47 return Ok(call); 119 return Ok(call);
48 } 120 }
49 Err(e) => { 121 Err(e) => {
@@ -68,6 +140,9 @@ impl VoiceManager {
68 } 140 }
69 141
70 pub async fn disconnect(&self, guild_id: GuildId) -> Result<()> { 142 pub async fn disconnect(&self, guild_id: GuildId) -> Result<()> {
143 // Free the ring before tearing down the Call — the handler's `Weak`
144 // will then no-op any in-flight ticks until songbird drops it.
145 self.stop_recording().await;
71 self.songbird 146 self.songbird
72 .remove(guild_id) 147 .remove(guild_id)
73 .await 148 .await
@@ -80,6 +155,7 @@ impl VoiceManager {
80 } 155 }
81} 156}
82 157
158
83// ── Audio decoding ───────────────────────────────────────────────────── 159// ── Audio decoding ─────────────────────────────────────────────────────
84 160
85/// Decode an in-memory audio buffer into raw f32 stereo PCM for songbird. 161/// Decode an in-memory audio buffer into raw f32 stereo PCM for songbird.