aboutsummaryrefslogtreecommitdiffstats
path: root/src/voice/mod.rs
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-07-08 02:13:35 +0200
committersalmonik <raisersoft@proton.me>2026-07-08 02:13:35 +0200
commitcd8ed8bc5793985df2c1692072a932ceee517b95 (patch)
tree42d18a4140259b5c68b4dd05b7fcb398f78b8d7f /src/voice/mod.rs
parentMerge pull request 'Voice related fixes, alongside simplifying assistant' (#5... (diff)
downloadsenator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.tar.gz
senator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.zip
OPEN THE RUST GATES
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to 'src/voice/mod.rs')
-rw-r--r--src/voice/mod.rs404
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 @@
1use std::io::Cursor;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::task::{Context, Poll};
5use std::time::Duration;
6
7use serenity::all::{ChannelId, GuildId};
8use songbird::input::{AsyncAdapterStream, AsyncReadOnlySource, RawAdapter};
9use songbird::{Call, Songbird};
10use symphonia::core::audio::sample::Sample;
11use symphonia::core::codecs::audio::AudioDecoderOptions;
12use symphonia::core::formats::probe::Hint;
13use symphonia::core::formats::{FormatOptions, TrackType};
14use symphonia::core::io::MediaSourceStream;
15use symphonia::core::meta::MetadataOptions;
16use tokio::sync::Mutex;
17use tracing::{info, warn};
18
19use crate::error::{Error, Result};
20use crate::lang;
21
22#[derive(Clone)]
23pub struct VoiceManager {
24 pub songbird: Arc<Songbird>,
25}
26
27impl 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.
81pub 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
86fn 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).
149pub 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.
172pub 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.
187pub 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)]
234pub 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.
250struct FfmpegChild {
251 _child: tokio::process::Child,
252 stdout: tokio::process::ChildStdout,
253}
254
255impl 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`.
276pub 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)]
339pub 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)]
372pub 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}