From 0d1fdab3a10b30d83da50b7f20acc18b7a350a22 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 9 Sep 2026 14:36:50 +0200 Subject: wire recording into voice manager, config, events, /record command --- src/commands/mod.rs | 2 ++ src/config.rs | 22 +++++++++++++++ src/events/mod.rs | 14 ++++++++++ src/lang.rs | 19 +++++++++++++ src/main.rs | 22 +++++++++++++-- src/voice/mod.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 155 insertions(+), 4 deletions(-) (limited to 'src') 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 @@ +pub mod record; pub mod music; pub mod tts; pub mod ai; @@ -24,5 +25,6 @@ pub fn all_commands() -> Vec> { cmds.extend(trigger::commands()); cmds.extend(utilities::commands()); cmds.extend(leaderboard::commands()); + cmds.extend(record::commands()); cmds } 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 { /// Piper voice name, used to resolve the model file when `tts_model_path` /// points at a directory. Ignored when it points at a `.onnx` file directly. pub tts_voice: String, + pub recording_enabled: bool, + pub recording_window_seconds: u64, } struct EnvVar { @@ -124,6 +126,18 @@ const ENV_VARS: &[EnvVar] = &[ default: Some("info"), description: "tracing/logging level filter", }, + EnvVar { + name: "RECORDING_ENABLED", + required: false, + default: Some("true"), + description: "enable continuous voice recording while in a VC (set false to opt out)", + }, + EnvVar { + name: "RECORDING_WINDOW_SECONDS", + required: false, + default: Some("900"), + description: "rolling recording buffer length in seconds (memory ~sample_rate*2 bytes/sec)", + }, ]; pub fn check_env_vars() { @@ -186,6 +200,14 @@ impl Config { .ok() .and_then(|v| v.parse().ok()), assistant_keyword: env::var("ASSISTANT_KEYWORD").unwrap_or_else(|_| "jarvis".into()), + recording_enabled: env::var("RECORDING_ENABLED") + .unwrap_or_else(|_| "true".into()) + .parse() + .unwrap_or(true), + recording_window_seconds: env::var("RECORDING_WINDOW_SECONDS") + .unwrap_or_else(|_| "900".into()) + .parse() + .unwrap_or(900), copyparty_url: env::var("COPYPARTY_URL") .unwrap_or_else(|_| "http://localhost:3939".into()), 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( let user_id = new.user_id; let guild_id = new.guild_id; + // The bot's own voice state drives the recording lifecycle (catches admin + // moves, kicks, and gateway drops that VoiceManager::connect/disconnect + // can't see). Must run before the generic bot-skip below. + if user_id == ctx.cache.current_user().id { + crate::recording::on_bot_voice_state( + &data.voice_manager, + guild_id, + old.as_ref().and_then(|v| v.channel_id), + new.channel_id, + ) + .await; + return Ok(()); + } + // skip bots (Python behavior) let member = guild_id .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"; pub const L_MUSIC_NO_AUDIO_ATTACHMENTS: &str = "Brak plików audio w wiadomości"; pub const L_VOICE_USER_NOT_IN_VC: &str = "You are not in a voice channel"; +// ── Recording / nagranie ────────────────────────────────────────────── + +/// Nagrywanie jest wyłączone w konfiguracji (`RECORDING_ENABLED=false`). +pub const L_RECORD_DISABLED: &str = "Nagrywanie jest wyłączone w konfiguracji"; +/// Caller isn't in any voice channel. +pub const L_RECORD_NOT_IN_VOICE: &str = "Nie jesteś na kanale głosowym"; +/// Bot isn't connected to a voice channel — nothing to dump. +pub const L_RECORD_NOT_RECORDING: &str = "Bot nie nagrywa — nie jest na kanale głosowym"; +/// Caller is in a different VC than the bot (same-VC gate). +pub const L_RECORD_NOT_SAME_VC: &str = "Musisz być na tym samym kanale głosowym co bot"; +/// Buffer has no audio yet (called before the first 20 ms tick landed). +pub const L_RECORD_NOTHING: &str = "Brak nagrania do zapisania"; +pub const L_RECORD_ENCODE_FAILED: &str = "Nie udało się zakodować nagrania (ffmpeg)"; +pub const L_RECORD_UPLOAD_FAILED: &str = "Nie udało się wgrać nagrania"; + +pub fn fmt_record_uploaded(url: &str) -> String { + format!("Nagranie: {url}") +} + pub fn fmt_music_search_failed(e: &dyn std::fmt::Display) -> String { format!("Szukanie na copyparty zawiodło: `{e}`") } 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; mod events; mod lang; mod openrouter; +mod recording; mod tasks; mod tts; mod voice; @@ -185,7 +186,12 @@ async fn main() -> Result<(), Box> { let copyparty = copyparty.clone(); let triggers = triggers.clone(); Box::pin(async move { - let vm = voice::VoiceManager::new(ctx).await?; + let vm = voice::VoiceManager::new( + ctx, + config.recording_enabled, + config.recording_window_seconds, + ) + .await?; let music_tracks: Arc>> = Arc::new(tokio::sync::Mutex::new(HashMap::new())); @@ -231,10 +237,22 @@ async fn main() -> Result<(), Box> { }) .build(); + // Configure songbird: when recording is enabled, decode incoming voice to + // mono 48 kHz i16 so that `CoreEvent::VoiceTick` carries `decoded_voice` + // (without this, `decoded_voice` is `None` and there's nothing to record). + let mut songbird_config = songbird::Config::default(); + if config_for_client.recording_enabled { + songbird_config.decode_mode = + songbird::driver::DecodeMode::Decode(songbird::driver::DecodeConfig::new( + songbird::driver::Channels::Mono, + songbird::driver::SampleRate::Hz48000, + )); + } + let mut client = serenity::ClientBuilder::new(&config_for_client.token, serenity::GatewayIntents::all()) .framework(framework) - .register_songbird() + .register_songbird_from_config(songbird_config) .await?; 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}; use crate::error::{Error, Result}; use crate::lang; +use crate::recording::{Recorder, RecorderEventHandler}; + +/// Sample rate (Hz) songbird decodes incoming voice into — drives ring sizing +/// and the ffmpeg `s16le` encode. Must match the `DecodeConfig` set in `main.rs`. +const RECORDING_SAMPLE_RATE: u32 = 48000; #[derive(Clone)] pub struct VoiceManager { pub songbird: Arc, + /// Live recorder for the bot's current voice connection, if any. + /// + /// `None` whenever the bot is not connected (or recording is disabled) — + /// so the ~86 MB ring only exists while actually recording a 15 min window. + pub recorder: Arc>>>, + pub recording_enabled: bool, + pub recording_window_seconds: u64, } impl VoiceManager { - pub async fn new(ctx: &serenity::client::Context) -> Result { + pub async fn new( + ctx: &serenity::client::Context, + recording_enabled: bool, + recording_window_seconds: u64, + ) -> Result { let songbird = songbird::get(ctx) .await .ok_or_else(|| Error::Custom( "Songbird not registered via register_songbird()".into(), ))?; - Ok(Self { songbird }) + Ok(Self { + songbird, + recorder: Arc::new(Mutex::new(None)), + recording_enabled, + recording_window_seconds, + }) + } + + pub fn recording_enabled(&self) -> bool { + self.recording_enabled + } + + /// Construct a `Recorder`, register its `VoiceTick` handler on `call`, and + /// store the strong `Arc` in the slot. Idempotent: if the slot already + /// holds a recorder, the caller is responsible for not double-registering. + pub async fn start_recording(&self, call: &Arc>) { + if !self.recording_enabled { + return; + } + let recorder = Arc::new(Recorder::new( + RECORDING_SAMPLE_RATE, + self.recording_window_seconds, + )); + let handler = RecorderEventHandler::new(&recorder); + { + let mut call_lock = call.lock().await; + call_lock.add_global_event( + songbird::Event::Core(songbird::CoreEvent::VoiceTick), + handler, + ); + } + *self.recorder.lock().await = Some(recorder); + info!( + window_secs = self.recording_window_seconds, + "recording: started rolling buffer for this voice connection" + ); + } + + /// Drop the live recorder (frees the ring immediately). The songbird `Call` + /// may still exist briefly; the handler's `Weak` upgrades to `None` thereafter. + pub async fn stop_recording(&self) { + let prev = self.recorder.lock().await.take(); + if prev.is_some() { + info!("recording: stopped, ring buffer freed"); + } + } + + /// Clone of the live recorder `Arc`, if any — used by `/record` to snapshot. + pub async fn get_recorder(&self) -> Option> { + self.recorder.lock().await.clone() } pub async fn connect( @@ -44,6 +109,13 @@ impl VoiceManager { match self.songbird.join(guild_id, channel_id).await { Ok(call) => { tokio::time::sleep(Duration::from_millis(500)).await; + // Reliable setup point: we hold the `Call` right after a + // successful join, so register the VoiceTick handler here. + // (The bot's own VoiceStateUpdate also reconciles, but may + // race songbird's Call creation — this never misses.) + if self.recording_enabled { + self.start_recording(&call).await; + } return Ok(call); } Err(e) => { @@ -68,6 +140,9 @@ impl VoiceManager { } pub async fn disconnect(&self, guild_id: GuildId) -> Result<()> { + // Free the ring before tearing down the Call — the handler's `Weak` + // will then no-op any in-flight ticks until songbird drops it. + self.stop_recording().await; self.songbird .remove(guild_id) .await @@ -80,6 +155,7 @@ impl VoiceManager { } } + // ── Audio decoding ───────────────────────────────────────────────────── /// Decode an in-memory audio buffer into raw f32 stereo PCM for songbird. -- cgit v1.2.3