diff options
| author | salmonik <raisersoft@proton.me> | 2026-07-08 02:13:35 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-07-08 02:13:35 +0200 |
| commit | cd8ed8bc5793985df2c1692072a932ceee517b95 (patch) | |
| tree | 42d18a4140259b5c68b4dd05b7fcb398f78b8d7f /src/voice | |
| parent | Merge pull request 'Voice related fixes, alongside simplifying assistant' (#5... (diff) | |
| download | senator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.tar.gz senator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.zip | |
OPEN THE RUST GATES
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to 'src/voice')
| -rw-r--r-- | src/voice/mod.rs | 404 |
1 files changed, 404 insertions, 0 deletions
diff --git a/src/voice/mod.rs b/src/voice/mod.rs new file mode 100644 index 0000000..0d5b89e --- /dev/null +++ b/src/voice/mod.rs | |||
| @@ -0,0 +1,404 @@ | |||
| 1 | use std::io::Cursor; | ||
| 2 | use std::pin::Pin; | ||
| 3 | use std::sync::Arc; | ||
| 4 | use std::task::{Context, Poll}; | ||
| 5 | use std::time::Duration; | ||
| 6 | |||
| 7 | use serenity::all::{ChannelId, GuildId}; | ||
| 8 | use songbird::input::{AsyncAdapterStream, AsyncReadOnlySource, RawAdapter}; | ||
| 9 | use songbird::{Call, Songbird}; | ||
| 10 | use symphonia::core::audio::sample::Sample; | ||
| 11 | use symphonia::core::codecs::audio::AudioDecoderOptions; | ||
| 12 | use symphonia::core::formats::probe::Hint; | ||
| 13 | use symphonia::core::formats::{FormatOptions, TrackType}; | ||
| 14 | use symphonia::core::io::MediaSourceStream; | ||
| 15 | use symphonia::core::meta::MetadataOptions; | ||
| 16 | use tokio::sync::Mutex; | ||
| 17 | use tracing::{info, warn}; | ||
| 18 | |||
| 19 | use crate::error::{Error, Result}; | ||
| 20 | use crate::lang; | ||
| 21 | |||
| 22 | #[derive(Clone)] | ||
| 23 | pub struct VoiceManager { | ||
| 24 | pub songbird: Arc<Songbird>, | ||
| 25 | } | ||
| 26 | |||
| 27 | impl VoiceManager { | ||
| 28 | pub async fn new(ctx: &serenity::client::Context) -> Self { | ||
| 29 | Self { | ||
| 30 | songbird: songbird::get(ctx) | ||
| 31 | .await | ||
| 32 | .expect("Songbird not registered via register_songbird()"), | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | pub async fn connect( | ||
| 37 | &self, | ||
| 38 | guild_id: GuildId, | ||
| 39 | channel_id: ChannelId, | ||
| 40 | ) -> Result<Arc<Mutex<Call>>> { | ||
| 41 | let mut last_err = None; | ||
| 42 | for attempt in 1..=3 { | ||
| 43 | match self.songbird.join(guild_id, channel_id).await { | ||
| 44 | Ok(call) => { | ||
| 45 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 46 | return Ok(call); | ||
| 47 | } | ||
| 48 | Err(e) => { | ||
| 49 | warn!( | ||
| 50 | "Voice join attempt {attempt}/3 failed for guild {}: {e}", | ||
| 51 | guild_id | ||
| 52 | ); | ||
| 53 | if let songbird::error::JoinError::Driver(ref driver_err) = e { | ||
| 54 | warn!("Driver error details: {driver_err:?}"); | ||
| 55 | } | ||
| 56 | last_err = Some(e); | ||
| 57 | tokio::time::sleep(Duration::from_secs(1)).await; | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | Err(Error::SongbirdJoin(last_err.unwrap())) | ||
| 62 | } | ||
| 63 | |||
| 64 | pub async fn disconnect(&self, guild_id: GuildId) -> Result<()> { | ||
| 65 | self.songbird | ||
| 66 | .remove(guild_id) | ||
| 67 | .await | ||
| 68 | .map_err(|e| Error::Custom(lang::fmt_voice_remove_failed(&e)))?; | ||
| 69 | Ok(()) | ||
| 70 | } | ||
| 71 | |||
| 72 | pub fn get_call(&self, guild_id: GuildId) -> Option<Arc<Mutex<Call>>> { | ||
| 73 | self.songbird.get(guild_id) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | // ── Audio decoding ───────────────────────────────────────────────────── | ||
| 78 | |||
| 79 | /// Decode an in-memory audio buffer into raw f32 stereo PCM for songbird. | ||
| 80 | /// Supports mp3, m4a/aac, flac, ogg, wav, opus via symphonia auto-detection. | ||
| 81 | pub fn decode_audio(data: &[u8]) -> Result<songbird::input::Input> { | ||
| 82 | let mss = MediaSourceStream::new(Box::new(Cursor::new(data.to_vec())), Default::default()); | ||
| 83 | decode_stream(mss) | ||
| 84 | } | ||
| 85 | |||
| 86 | fn decode_stream(mss: MediaSourceStream) -> Result<songbird::input::Input> { | ||
| 87 | let hint = Hint::new(); | ||
| 88 | let fmt_opts = FormatOptions::default(); | ||
| 89 | let meta_opts = MetadataOptions::default(); | ||
| 90 | |||
| 91 | let mut format = symphonia::default::get_probe() | ||
| 92 | .probe(&hint, mss, fmt_opts, meta_opts) | ||
| 93 | .map_err(|e| Error::Custom(lang::fmt_voice_probe_failed(&e)))?; | ||
| 94 | |||
| 95 | let track = format | ||
| 96 | .default_track(TrackType::Audio) | ||
| 97 | .ok_or_else(|| Error::Custom(lang::L_VOICE_NO_TRACK.into()))?; | ||
| 98 | let track_id = track.id; | ||
| 99 | |||
| 100 | let codec_params = track | ||
| 101 | .codec_params | ||
| 102 | .as_ref() | ||
| 103 | .and_then(|p| p.audio()) | ||
| 104 | .ok_or_else(|| Error::Custom(lang::L_VOICE_NO_CODEC.into()))?; | ||
| 105 | |||
| 106 | let sample_rate = codec_params.sample_rate.unwrap_or(48000); | ||
| 107 | let channels = codec_params | ||
| 108 | .channels | ||
| 109 | .clone() | ||
| 110 | .map(|c| c.count() as u32) | ||
| 111 | .unwrap_or(2); | ||
| 112 | |||
| 113 | let dec_opts = AudioDecoderOptions::default(); | ||
| 114 | let mut decoder = symphonia::default::get_codecs() | ||
| 115 | .make_audio_decoder(codec_params, &dec_opts) | ||
| 116 | .map_err(|e| Error::Custom(lang::fmt_voice_decoder_failed(&e)))?; | ||
| 117 | |||
| 118 | let mut pcm_f32: Vec<f32> = Vec::new(); | ||
| 119 | |||
| 120 | loop { | ||
| 121 | match format.next_packet() { | ||
| 122 | Ok(Some(packet)) => { | ||
| 123 | if packet.track_id != track_id { | ||
| 124 | continue; | ||
| 125 | } | ||
| 126 | if let Ok(buf) = decoder.decode(&packet) { | ||
| 127 | let n = buf.samples_interleaved(); | ||
| 128 | let start = pcm_f32.len(); | ||
| 129 | pcm_f32.resize(start + n, f32::MID); | ||
| 130 | buf.copy_to_slice_interleaved(&mut pcm_f32[start..]); | ||
| 131 | } | ||
| 132 | } | ||
| 133 | Ok(None) => break, | ||
| 134 | Err(_) => break, | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | let pcm_bytes: Vec<u8> = pcm_f32.iter().flat_map(|&s| s.to_le_bytes()).collect(); | ||
| 139 | let cursor = Cursor::new(pcm_bytes); | ||
| 140 | let adapter = RawAdapter::new(cursor, sample_rate, channels); | ||
| 141 | |||
| 142 | Ok(adapter.into()) | ||
| 143 | } | ||
| 144 | |||
| 145 | // ── yt-dlp helper ────────────────────────────────────────────────────── | ||
| 146 | |||
| 147 | /// Download audio via yt-dlp, collect stdout into memory, return the raw bytes. | ||
| 148 | /// Works for both finite media and live streams (yt-dlp handles the difference). | ||
| 149 | pub async fn ytdlp_download(url: &str) -> Result<Vec<u8>> { | ||
| 150 | let output = tokio::process::Command::new("yt-dlp") | ||
| 151 | .args(["-f", "bestaudio", "-o", "-", "--no-playlist", url]) | ||
| 152 | .stdout(std::process::Stdio::piped()) | ||
| 153 | .stderr(std::process::Stdio::piped()) | ||
| 154 | .output() | ||
| 155 | .await | ||
| 156 | .map_err(|e| Error::Custom(lang::fmt_voice_ytdlp_launch(&e)))?; | ||
| 157 | |||
| 158 | if !output.status.success() { | ||
| 159 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 160 | // Keep the full yt-dlp trace in the logs for debugging — the user | ||
| 161 | // only ever sees the short, translated `fmt_ytdlp_stderr` message. | ||
| 162 | warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end()); | ||
| 163 | return Err(Error::Custom(lang::fmt_ytdlp_stderr(&stderr))); | ||
| 164 | } | ||
| 165 | |||
| 166 | Ok(output.stdout) | ||
| 167 | } | ||
| 168 | |||
| 169 | // ── Compression helpers ─────────────────────────────────────────────── | ||
| 170 | |||
| 171 | /// Download a URL into memory via reqwest. | ||
| 172 | pub async fn download_url(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> { | ||
| 173 | let resp = client | ||
| 174 | .get(url) | ||
| 175 | .send() | ||
| 176 | .await | ||
| 177 | .map_err(|e| Error::Custom(format!("HTTP GET failed for {url}: {e}")))?; | ||
| 178 | let bytes = resp | ||
| 179 | .bytes() | ||
| 180 | .await | ||
| 181 | .map_err(|e| Error::Custom(format!("Failed to read body for {url}: {e}")))?; | ||
| 182 | Ok(bytes.to_vec()) | ||
| 183 | } | ||
| 184 | |||
| 185 | /// DEBUG: bypass encode/decode — just pipe through ffmpeg as PCM. | ||
| 186 | /// If this plays, the streaming pipeline works; bug is in Opus roundtrip. | ||
| 187 | pub async fn degrade_audio(input: &[u8]) -> Result<songbird::input::Input> { | ||
| 188 | use std::process::Stdio; | ||
| 189 | |||
| 190 | let mut child = tokio::process::Command::new("ffmpeg") | ||
| 191 | .args([ | ||
| 192 | "-y", "-i", "pipe:0", "-vn", "-f", "f32le", "-ac", "2", "-ar", "8000", "-b:a", "500", | ||
| 193 | "pipe:1", | ||
| 194 | ]) | ||
| 195 | .stdin(Stdio::piped()) | ||
| 196 | .stdout(Stdio::piped()) | ||
| 197 | .stderr(Stdio::null()) | ||
| 198 | .kill_on_drop(true) | ||
| 199 | .spawn() | ||
| 200 | .map_err(|e| Error::Custom(format!("ffmpeg passthrough spawn: {e}")))?; | ||
| 201 | |||
| 202 | let mut stdin = child.stdin.take().unwrap(); | ||
| 203 | let stdout = child | ||
| 204 | .stdout | ||
| 205 | .take() | ||
| 206 | .ok_or_else(|| Error::Custom("stdout not captured".into()))?; | ||
| 207 | |||
| 208 | let owned_input = input.to_vec(); | ||
| 209 | tokio::spawn(async move { | ||
| 210 | use tokio::io::AsyncWriteExt; | ||
| 211 | let _ = stdin.write_all(&owned_input).await; | ||
| 212 | }); | ||
| 213 | |||
| 214 | let ffmpeg_child = FfmpegChild { | ||
| 215 | _child: child, | ||
| 216 | stdout, | ||
| 217 | }; | ||
| 218 | |||
| 219 | let async_source = AsyncReadOnlySource::new(ffmpeg_child); | ||
| 220 | let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024); | ||
| 221 | let raw = RawAdapter::new(adapter, 48000, 2); | ||
| 222 | Ok(raw.into()) | ||
| 223 | } | ||
| 224 | |||
| 225 | // ── HTTP streaming via songbird's built-in HttpRequest (legacy) ───── | ||
| 226 | |||
| 227 | /// Create a songbird `Input` that streams audio directly from an HTTP(S) URL. | ||
| 228 | /// Works for both finite files and infinite Icecast/Shoutcast live streams. | ||
| 229 | /// | ||
| 230 | /// Superseded by `ffmpeg_stream_input` for the `/play` command (which needs | ||
| 231 | /// HLS/playlist/Icecast-metadata handling that symphonia can't provide), but | ||
| 232 | /// retained for other callers that want lightweight symphonia-based streaming. | ||
| 233 | #[allow(dead_code)] | ||
| 234 | pub fn http_stream_input(client: reqwest::Client, url: &str) -> songbird::input::Input { | ||
| 235 | info!(%url, "streaming via HttpRequest"); | ||
| 236 | songbird::input::HttpRequest::new(client, url.to_string()).into() | ||
| 237 | } | ||
| 238 | |||
| 239 | // ── Live streaming via ffmpeg subprocess ───────────────────────────── | ||
| 240 | |||
| 241 | /// Adapter holding an ffmpeg child process and its stdout pipe. | ||
| 242 | /// | ||
| 243 | /// Implements `AsyncRead` by delegating to `ChildStdout`. The `Child` is kept | ||
| 244 | /// alive for the lifetime of the stream and killed on drop via | ||
| 245 | /// `kill_on_drop(true)`, so stopping playback terminates ffmpeg. | ||
| 246 | /// | ||
| 247 | /// `Sync`: the `Child` handle is only touched on `Drop` (a single, one-shot | ||
| 248 | /// operation); `ChildStdout` is only read by the single `AsyncAdapterSink` | ||
| 249 | /// task spawned inside `AsyncAdapterStream`. No field is accessed concurrently. | ||
| 250 | struct FfmpegChild { | ||
| 251 | _child: tokio::process::Child, | ||
| 252 | stdout: tokio::process::ChildStdout, | ||
| 253 | } | ||
| 254 | |||
| 255 | impl tokio::io::AsyncRead for FfmpegChild { | ||
| 256 | fn poll_read( | ||
| 257 | mut self: Pin<&mut Self>, | ||
| 258 | cx: &mut Context<'_>, | ||
| 259 | buf: &mut tokio::io::ReadBuf<'_>, | ||
| 260 | ) -> Poll<std::io::Result<()>> { | ||
| 261 | Pin::new(&mut self.stdout).poll_read(cx, buf) | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | /// Stream an HTTP(S) URL live via an ffmpeg subprocess, producing a songbird | ||
| 266 | /// `Input` of raw f32 PCM at 48 kHz stereo — NO full download. | ||
| 267 | /// | ||
| 268 | /// ffmpeg handles every streaming scenario the Python bot relied on it for: | ||
| 269 | /// Icecast/Shoutcast radio (incl. inline metadata), HLS `.m3u8` (segment | ||
| 270 | /// fetching), `.m3u`/`.pls` playlists, and direct media files. The | ||
| 271 | /// `-reconnect` flags let ffmpeg recover from dropped connections. | ||
| 272 | /// | ||
| 273 | /// Byte path: `ffmpeg stdout` → `AsyncReadOnlySource` (unseekable async | ||
| 274 | /// media source) → `AsyncAdapterStream` (async→sync ring buffer) → | ||
| 275 | /// `RawAdapter` (prepends `SbirdRaw` f32 header) → songbird `Input`. | ||
| 276 | pub async fn ffmpeg_stream_input(url: &str) -> Result<songbird::input::Input> { | ||
| 277 | use std::process::Stdio; | ||
| 278 | |||
| 279 | let mut child = tokio::process::Command::new("ffmpeg") | ||
| 280 | .args([ | ||
| 281 | "-reconnect", | ||
| 282 | "1", | ||
| 283 | "-reconnect_streamed", | ||
| 284 | "1", | ||
| 285 | "-reconnect_delay_max", | ||
| 286 | "5", | ||
| 287 | "-i", | ||
| 288 | url, | ||
| 289 | "-vn", | ||
| 290 | "-f", | ||
| 291 | "f32le", | ||
| 292 | "-ar", | ||
| 293 | "48000", | ||
| 294 | "-ac", | ||
| 295 | "2", | ||
| 296 | "pipe:1", | ||
| 297 | ]) | ||
| 298 | .stdout(Stdio::piped()) | ||
| 299 | .stderr(Stdio::piped()) | ||
| 300 | .kill_on_drop(true) | ||
| 301 | .spawn() | ||
| 302 | .map_err(|e| Error::Custom(lang::fmt_voice_ffmpeg_launch(&e)))?; | ||
| 303 | |||
| 304 | let stdout = child | ||
| 305 | .stdout | ||
| 306 | .take() | ||
| 307 | .ok_or_else(|| Error::Custom("ffmpeg stdout was not captured".into()))?; | ||
| 308 | |||
| 309 | // Drain stderr in background so ffmpeg doesn't block; log first 4 KiB on drop. | ||
| 310 | if let Some(stderr) = child.stderr.take() { | ||
| 311 | let url_owned = url.to_string(); | ||
| 312 | tokio::spawn(async move { | ||
| 313 | use tokio::io::AsyncReadExt; | ||
| 314 | let mut buf = Vec::with_capacity(4096); | ||
| 315 | let mut reader = tokio::io::BufReader::new(stderr); | ||
| 316 | let _ = reader.read_to_end(&mut buf).await; | ||
| 317 | let text = String::from_utf8_lossy(&buf); | ||
| 318 | if !text.is_empty() { | ||
| 319 | warn!(url = %url_owned, "ffmpeg stderr: {}", text.trim_end()); | ||
| 320 | } | ||
| 321 | }); | ||
| 322 | } | ||
| 323 | |||
| 324 | let ffmpeg_child = FfmpegChild { | ||
| 325 | _child: child, | ||
| 326 | stdout, | ||
| 327 | }; | ||
| 328 | |||
| 329 | let async_source = AsyncReadOnlySource::new(ffmpeg_child); | ||
| 330 | let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024); | ||
| 331 | let raw = RawAdapter::new(adapter, 48000, 2); | ||
| 332 | Ok(raw.into()) | ||
| 333 | } | ||
| 334 | |||
| 335 | /// Smoke-test an HTTP stream URL: creates a raw reqwest GET, reads the first | ||
| 336 | /// chunk directly from the async bytes stream (bypasses AsyncAdapterStream). | ||
| 337 | /// Logs whether data arrives — isolates the hang to songbird's sync adapter. | ||
| 338 | #[allow(dead_code)] | ||
| 339 | pub async fn smoke_test_stream(client: reqwest::Client, url: String) { | ||
| 340 | use futures_util::StreamExt; | ||
| 341 | |||
| 342 | info!(%url, "smoke_test: sending GET"); | ||
| 343 | let resp = match client.get(url.as_str()).send().await { | ||
| 344 | Ok(r) => r, | ||
| 345 | Err(e) => { | ||
| 346 | warn!(%url, error = %e, "smoke_test: GET FAILED"); | ||
| 347 | return; | ||
| 348 | } | ||
| 349 | }; | ||
| 350 | info!(%url, status = %resp.status(), "smoke_test: response received"); | ||
| 351 | |||
| 352 | let mut stream = resp.bytes_stream(); | ||
| 353 | info!(%url, "smoke_test: awaiting first chunk"); | ||
| 354 | match stream.next().await { | ||
| 355 | Some(Ok(chunk)) => { | ||
| 356 | info!(%url, len = chunk.len(), "smoke_test: first chunk arrived"); | ||
| 357 | } | ||
| 358 | Some(Err(e)) => { | ||
| 359 | warn!(%url, error = %e, "smoke_test: chunk error"); | ||
| 360 | } | ||
| 361 | None => { | ||
| 362 | info!(%url, "smoke_test: stream ended immediately (0 bytes)"); | ||
| 363 | } | ||
| 364 | } | ||
| 365 | } | ||
| 366 | |||
| 367 | /// Diagnostic: fetch headers and full body for a URL, logging key metrics. | ||
| 368 | /// Helps debug symphonia probe EOF issues (Content-Length vs actual bytes). | ||
| 369 | /// | ||
| 370 | /// **Warning**: hangs forever on infinite streams (Icecast). Finite URLs only. | ||
| 371 | #[allow(dead_code)] | ||
| 372 | pub async fn debug_fetch(client: &reqwest::Client, url: &str) { | ||
| 373 | match client.get(url).send().await { | ||
| 374 | Ok(resp) => { | ||
| 375 | let status = resp.status(); | ||
| 376 | let headers = resp.headers().clone(); | ||
| 377 | let content_length = headers | ||
| 378 | .get("content-length") | ||
| 379 | .and_then(|v| v.to_str().ok()) | ||
| 380 | .and_then(|v| v.parse::<u64>().ok()); | ||
| 381 | let content_type = headers.get("content-type").and_then(|v| v.to_str().ok()); | ||
| 382 | |||
| 383 | info!( | ||
| 384 | %url, %status, | ||
| 385 | ?content_length, ?content_type, | ||
| 386 | "HTTP response headers" | ||
| 387 | ); | ||
| 388 | |||
| 389 | match resp.bytes().await { | ||
| 390 | Ok(body) => { | ||
| 391 | let actual_len = body.len() as u64; | ||
| 392 | let reported_len = content_length.unwrap_or(0); | ||
| 393 | info!( | ||
| 394 | %url, reported = reported_len, actual = actual_len, | ||
| 395 | delta = actual_len as i64 - reported_len as i64, | ||
| 396 | "buffered .bytes(): reported vs actual" | ||
| 397 | ); | ||
| 398 | } | ||
| 399 | Err(e) => warn!(%url, error = %e, ".bytes() failed"), | ||
| 400 | } | ||
| 401 | } | ||
| 402 | Err(e) => warn!(%url, error = %e, "HTTP GET failed"), | ||
| 403 | } | ||
| 404 | } | ||
