diff options
| author | salmonik <salmonik@tuta.io> | 2026-09-09 14:36:50 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-09-09 14:36:50 +0200 |
| commit | 0d1fdab3a10b30d83da50b7f20acc18b7a350a22 (patch) | |
| tree | 2c463b26add0a50bd7150c7eeee665d315a988d2 | |
| parent | first draft (diff) | |
| download | senator-sperma-0d1fdab3a10b30d83da50b7f20acc18b7a350a22.tar.gz senator-sperma-0d1fdab3a10b30d83da50b7f20acc18b7a350a22.zip | |
wire recording into voice manager, config, events, /record command
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | Cargo.toml | 3 | ||||
| -rw-r--r-- | src/commands/mod.rs | 2 | ||||
| -rw-r--r-- | src/config.rs | 22 | ||||
| -rw-r--r-- | src/events/mod.rs | 14 | ||||
| -rw-r--r-- | src/lang.rs | 19 | ||||
| -rw-r--r-- | src/main.rs | 22 | ||||
| -rw-r--r-- | src/voice/mod.rs | 80 |
8 files changed, 158 insertions, 5 deletions
| @@ -4636,6 +4636,7 @@ dependencies = [ | |||
| 4636 | name = "senator-sperma" | 4636 | name = "senator-sperma" |
| 4637 | version = "0.4.1" | 4637 | version = "0.4.1" |
| 4638 | dependencies = [ | 4638 | dependencies = [ |
| 4639 | "async-trait", | ||
| 4639 | "chrono", | 4640 | "chrono", |
| 4640 | "cron", | 4641 | "cron", |
| 4641 | "dashmap 6.2.1", | 4642 | "dashmap 6.2.1", |
| @@ -10,7 +10,8 @@ debug = 1 | |||
| 10 | # Discord | 10 | # Discord |
| 11 | poise = "0.6.2" | 11 | poise = "0.6.2" |
| 12 | serenity = "0.12.5" | 12 | serenity = "0.12.5" |
| 13 | songbird = "0.6" | 13 | songbird = { version = "0.6", features = ["receive"] } |
| 14 | async-trait = "0.1" | ||
| 14 | 15 | ||
| 15 | # Async runtime | 16 | # Async runtime |
| 16 | tokio = { version = "1.52.3", features = [ | 17 | tokio = { 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 @@ | |||
| 1 | pub mod record; | ||
| 1 | pub mod music; | 2 | pub mod music; |
| 2 | pub mod tts; | 3 | pub mod tts; |
| 3 | pub mod ai; | 4 | pub 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 | ||
| 29 | struct EnvVar { | 31 | struct 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 | ||
| 129 | pub fn check_env_vars() { | 143 | pub 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"; | |||
| 97 | pub const L_MUSIC_NO_AUDIO_ATTACHMENTS: &str = "Brak plików audio w wiadomości"; | 97 | pub const L_MUSIC_NO_AUDIO_ATTACHMENTS: &str = "Brak plików audio w wiadomości"; |
| 98 | pub const L_VOICE_USER_NOT_IN_VC: &str = "You are not in a voice channel"; | 98 | pub 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`). | ||
| 103 | pub const L_RECORD_DISABLED: &str = "Nagrywanie jest wyłączone w konfiguracji"; | ||
| 104 | /// Caller isn't in any voice channel. | ||
| 105 | pub 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. | ||
| 107 | pub 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). | ||
| 109 | pub 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). | ||
| 111 | pub const L_RECORD_NOTHING: &str = "Brak nagrania do zapisania"; | ||
| 112 | pub const L_RECORD_ENCODE_FAILED: &str = "Nie udało się zakodować nagrania (ffmpeg)"; | ||
| 113 | pub const L_RECORD_UPLOAD_FAILED: &str = "Nie udało się wgrać nagrania"; | ||
| 114 | |||
| 115 | pub fn fmt_record_uploaded(url: &str) -> String { | ||
| 116 | format!("Nagranie: {url}") | ||
| 117 | } | ||
| 118 | |||
| 100 | pub fn fmt_music_search_failed(e: &dyn std::fmt::Display) -> String { | 119 | pub 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; | |||
| 14 | mod events; | 14 | mod events; |
| 15 | mod lang; | 15 | mod lang; |
| 16 | mod openrouter; | 16 | mod openrouter; |
| 17 | mod recording; | ||
| 17 | mod tasks; | 18 | mod tasks; |
| 18 | mod tts; | 19 | mod tts; |
| 19 | mod voice; | 20 | mod 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 | ||
| 19 | use crate::error::{Error, Result}; | 19 | use crate::error::{Error, Result}; |
| 20 | use crate::lang; | 20 | use crate::lang; |
| 21 | use 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`. | ||
| 25 | const RECORDING_SAMPLE_RATE: u32 = 48000; | ||
| 21 | 26 | ||
| 22 | #[derive(Clone)] | 27 | #[derive(Clone)] |
| 23 | pub struct VoiceManager { | 28 | pub 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 | ||
| 27 | impl VoiceManager { | 39 | impl 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. |
