aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-09-09 12:50:56 +0200
committersalmonik <salmonik@tuta.io>2026-09-09 12:50:56 +0200
commit5eea9f2fef1a840f9c9e6ec6d471fe4f87f1d1fd (patch)
tree8172563321f0737df9426bbd577c0a18bef9471f
parentfeat: piper now not a subprocess, cache system for TTS (diff)
downloadsenator-sperma-5eea9f2fef1a840f9c9e6ec6d471fe4f87f1d1fd.tar.gz
senator-sperma-5eea9f2fef1a840f9c9e6ec6d471fe4f87f1d1fd.zip
make /grajcuj w\ compress finally work as compressing a file and then
/grajcuj'ing Signed-off-by: salmonik <salmonik@tuta.io>
-rw-r--r--CONTEXT.md7
-rw-r--r--src/commands/music.rs21
-rw-r--r--src/commands/utilities.rs72
-rw-r--r--src/voice/mod.rs80
4 files changed, 132 insertions, 48 deletions
diff --git a/CONTEXT.md b/CONTEXT.md
index bdd2790..990dc8e 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -47,3 +47,10 @@ holds no implementation decisions or specs (those live in `docs/adr/`).
47- **Pre-generation** — on first voice event for a user, synthesize all 6 event 47- **Pre-generation** — on first voice event for a user, synthesize all 6 event
48 phrases (join/leave/deafen/undeafen/mute/unmute) with their display name and 48 phrases (join/leave/deafen/undeafen/mute/unmute) with their display name and
49 cache them. ~120ms first time, instant after. Lazy — no startup cost. 49 cache them. ~120ms first time, instant after. Lazy — no startup cost.
50
51## Music Playback
52
53- **Compressed playback** — `/grajcuj \w compressed`: downloads audio, runs
54 through the /compress pipeline (Opus 500bps, 12 kHz, mono → OGG), then
55 decodes the compressed file via symphonia for playback. Produces properly
56 destroyed audio via Opus codec, not just raw low-sample-rate PCM.
diff --git a/src/commands/music.rs b/src/commands/music.rs
index 6acac00..019a015 100644
--- a/src/commands/music.rs
+++ b/src/commands/music.rs
@@ -305,8 +305,23 @@ async fn play_impl(
305 } 305 }
306 }; 306 };
307 307
308 info!("degrading audio ({} bytes)", audio_bytes.len()); 308 info!("compressing audio ({} bytes)", audio_bytes.len());
309 let degraded = crate::voice::degrade_audio(&audio_bytes).await?; 309 let compressed_bytes =
310 crate::commands::utilities::compress_audio_bytes(&audio_bytes, &src.display_name)
311 .await
312 .ok_or_else(|| Error::Custom("compression failed".into()))?;
313 info!("compressed to {} bytes", compressed_bytes.len());
314
315 // Decode the compressed OGG via ffmpeg (symphonia lacks Opus support).
316 // Write to temp file, then stream through ffmpeg to raw PCM.
317 let compressed_path = format!(
318 "{}/play_compressed_{}.ogg",
319 crate::config::TEMP_PATH,
320 uuid::Uuid::new_v4()
321 );
322 let _ = tokio::fs::create_dir_all(crate::config::TEMP_PATH).await;
323 tokio::fs::write(&compressed_path, &compressed_bytes).await?;
324 let decoded = crate::voice::ffmpeg_decode_file(&compressed_path).await?;
310 325
311 // Deferred stop: only interrupt current track now that we're ready. 326 // Deferred stop: only interrupt current track now that we're ready.
312 { 327 {
@@ -320,7 +335,7 @@ async fn play_impl(
320 .voice_manager 335 .voice_manager
321 .connect(guild_id, channel_id) 336 .connect(guild_id, channel_id)
322 .await?; 337 .await?;
323 input = degraded; 338 input = decoded;
324 } else { 339 } else {
325 // ── Normal path: stop first, then stream/download ────────── 340 // ── Normal path: stop first, then stream/download ──────────
326 { 341 {
diff --git a/src/commands/utilities.rs b/src/commands/utilities.rs
index 3f7e812..337979e 100644
--- a/src/commands/utilities.rs
+++ b/src/commands/utilities.rs
@@ -17,7 +17,7 @@ type Context<'a> = poise::Context<'a, Data, Error>;
17const LARGE_FILE_THRESHOLD: usize = 10 * 1024 * 1024; 17const LARGE_FILE_THRESHOLD: usize = 10 * 1024 * 1024;
18 18
19#[derive(Clone, Copy, PartialEq, Eq)] 19#[derive(Clone, Copy, PartialEq, Eq)]
20enum FileType { 20pub(crate) enum FileType {
21 Video, 21 Video,
22 Audio, 22 Audio,
23 Image, 23 Image,
@@ -29,7 +29,7 @@ enum FileType {
29/// Mirrors Python's `mimetypes.guess_type`-based `filetype()` helper without 29/// Mirrors Python's `mimetypes.guess_type`-based `filetype()` helper without
30/// pulling in a mime crate: the set of extensions below covers everything the 30/// pulling in a mime crate: the set of extensions below covers everything the
31/// original bot handled. 31/// original bot handled.
32fn file_type(filename: &str) -> FileType { 32pub(crate) fn file_type(filename: &str) -> FileType {
33 let Some((_, ext)) = filename.rsplit_once('.') else { 33 let Some((_, ext)) = filename.rsplit_once('.') else {
34 return FileType::Unknown; 34 return FileType::Unknown;
35 }; 35 };
@@ -58,7 +58,7 @@ fn ext_of(filename: &str) -> Option<String> {
58} 58}
59 59
60/// Filename stem: everything before the final dot of the basename. 60/// Filename stem: everything before the final dot of the basename.
61fn stem_of(filename: &str) -> &str { 61pub(crate) fn stem_of(filename: &str) -> &str {
62 let base = filename.rsplit('/').next().unwrap_or(filename); 62 let base = filename.rsplit('/').next().unwrap_or(filename);
63 match base.rsplit_once('.') { 63 match base.rsplit_once('.') {
64 Some((stem, _)) if !stem.is_empty() => stem, 64 Some((stem, _)) if !stem.is_empty() => stem,
@@ -134,7 +134,7 @@ async fn resolve_input(
134/// - Video keeps its container extension (ffmpeg re-muxes in place). 134/// - Video keeps its container extension (ffmpeg re-muxes in place).
135/// - Audio is always re-encoded to Opus in an `.ogg` container. 135/// - Audio is always re-encoded to Opus in an `.ogg` container.
136/// - JPEG/GIF keep their extension; other images are rewritten to `.jpg`. 136/// - JPEG/GIF keep their extension; other images are rewritten to `.jpg`.
137fn compress_output_ext(ft: FileType, original: &str) -> String { 137pub(crate) fn compress_output_ext(ft: FileType, original: &str) -> String {
138 let orig = ext_of(original); 138 let orig = ext_of(original);
139 match ft { 139 match ft {
140 FileType::Video => orig.unwrap_or_else(|| "mp4".to_string()), 140 FileType::Video => orig.unwrap_or_else(|| "mp4".to_string()),
@@ -149,7 +149,7 @@ fn compress_output_ext(ft: FileType, original: &str) -> String {
149 149
150/// Build the ffmpeg/magick command for extreme-low-quality compression. 150/// Build the ffmpeg/magick command for extreme-low-quality compression.
151/// Returns `None` for unsupported (`Unknown`) file types. 151/// Returns `None` for unsupported (`Unknown`) file types.
152fn build_compress_command( 152pub(crate) fn build_compress_command(
153 ft: FileType, 153 ft: FileType,
154 input: &str, 154 input: &str,
155 output: &str, 155 output: &str,
@@ -266,6 +266,68 @@ async fn compress_attachment_bytes(
266 res.map(|b| (b, out_filename)) 266 res.map(|b| (b, out_filename))
267} 267}
268 268
269/// Compress audio bytes for playback using Vorbis (symphonia-decodable).
270/// Uses Vorbis instead of Opus because symphonia 0.6 lacks an Opus decoder.
271/// Returns the compressed .ogg bytes on success.
272pub(crate) async fn compress_audio_bytes(input_bytes: &[u8], original_name: &str) -> Option<Vec<u8>> {
273 let safe_name = sanitize_filename(original_name);
274 let safe_name = if safe_name.is_empty() { "audio".to_string() } else { safe_name };
275 let stem = stem_of(&safe_name).to_string();
276 let out_filename = format!("compressed_{stem}.ogg");
277 let input_path = temp_path_for(&safe_name);
278 let output_path = temp_path_for(&out_filename);
279 let cleanup_paths = [input_path.as_str(), output_path.as_str()];
280
281 let _ = tokio::fs::create_dir_all(crate::config::TEMP_PATH).await;
282 if let Err(e) = tokio::fs::write(&input_path, input_bytes).await {
283 tracing::warn!("compress_audio_bytes: failed to write input: {e}");
284 return None;
285 }
286 tracing::debug!(
287 input = %input_path,
288 output = %output_path,
289 bytes = input_bytes.len(),
290 "compress_audio_bytes: running ffmpeg"
291 );
292
293 // Opus at 500bps, 12 kHz, mono — same as /compress.
294 // Decoded via ffmpeg in the caller (symphonia lacks Opus support).
295 let mut cmd = tokio::process::Command::new("ffmpeg");
296 cmd.arg("-y")
297 .arg("-i")
298 .arg(&input_path)
299 .arg("-c:a")
300 .arg("libopus")
301 .arg("-b:a")
302 .arg("500")
303 .arg("-ar")
304 .arg("12000")
305 .arg("-ac")
306 .arg("1")
307 .arg(&output_path);
308
309 let res = match cmd.output().await {
310 Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(),
311 Ok(out) => {
312 let stderr = String::from_utf8_lossy(&out.stderr);
313 tracing::warn!(
314 input = %input_path,
315 output = %output_path,
316 exit = %out.status,
317 "compress_audio_bytes ffmpeg failed:\n{}",
318 stderr.chars().take(500).collect::<String>()
319 );
320 None
321 }
322 Err(e) => {
323 tracing::warn!("compress_audio_bytes ffmpeg spawn error: {e}");
324 None
325 }
326 };
327 cleanup(&cleanup_paths).await;
328 res
329}
330
269/// Strip characters that Discord/coping filenames can't safely carry. Mirrors 331/// Strip characters that Discord/coping filenames can't safely carry. Mirrors
270/// Python's `Downloader._sanitize_filename`. 332/// Python's `Downloader._sanitize_filename`.
271fn sanitize_filename(name: &str) -> String { 333fn sanitize_filename(name: &str) -> String {
diff --git a/src/voice/mod.rs b/src/voice/mod.rs
index 4087eba..9c92202 100644
--- a/src/voice/mod.rs
+++ b/src/voice/mod.rs
@@ -188,46 +188,6 @@ pub async fn download_url(client: &reqwest::Client, url: &str) -> Result<Vec<u8>
188 Ok(bytes.to_vec()) 188 Ok(bytes.to_vec())
189} 189}
190 190
191/// DEBUG: bypass encode/decode — just pipe through ffmpeg as PCM.
192/// If this plays, the streaming pipeline works; bug is in Opus roundtrip.
193pub async fn degrade_audio(input: &[u8]) -> Result<songbird::input::Input> {
194 use std::process::Stdio;
195
196 let mut child = tokio::process::Command::new("ffmpeg")
197 .args([
198 "-y", "-i", "pipe:0", "-vn", "-f", "f32le", "-ac", "2", "-ar", "8000", "-b:a", "500",
199 "pipe:1",
200 ])
201 .stdin(Stdio::piped())
202 .stdout(Stdio::piped())
203 .stderr(Stdio::null())
204 .kill_on_drop(true)
205 .spawn()
206 .map_err(|e| Error::Custom(format!("ffmpeg passthrough spawn: {e}")))?;
207
208 let mut stdin = child.stdin.take().unwrap();
209 let stdout = child
210 .stdout
211 .take()
212 .ok_or_else(|| Error::Custom("stdout not captured".into()))?;
213
214 let owned_input = input.to_vec();
215 tokio::spawn(async move {
216 use tokio::io::AsyncWriteExt;
217 let _ = stdin.write_all(&owned_input).await;
218 });
219
220 let ffmpeg_child = FfmpegChild {
221 _child: child,
222 stdout,
223 };
224
225 let async_source = AsyncReadOnlySource::new(ffmpeg_child);
226 let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024);
227 let raw = RawAdapter::new(adapter, 48000, 2);
228 Ok(raw.into())
229}
230
231// ── HTTP streaming via songbird's built-in HttpRequest (legacy) ───── 191// ── HTTP streaming via songbird's built-in HttpRequest (legacy) ─────
232 192
233/// Create a songbird `Input` that streams audio directly from an HTTP(S) URL. 193/// Create a songbird `Input` that streams audio directly from an HTTP(S) URL.
@@ -268,6 +228,46 @@ impl tokio::io::AsyncRead for FfmpegChild {
268 } 228 }
269} 229}
270 230
231/// Decode a local audio file via ffmpeg to raw f32 PCM at 48 kHz stereo.
232/// No reconnect flags (those are for HTTP streams only).
233pub async fn ffmpeg_decode_file(path: &str) -> Result<songbird::input::Input> {
234 use std::process::Stdio;
235
236 let mut child = tokio::process::Command::new("ffmpeg")
237 .args([
238 "-i",
239 path,
240 "-vn",
241 "-f",
242 "f32le",
243 "-ar",
244 "48000",
245 "-ac",
246 "2",
247 "pipe:1",
248 ])
249 .stdout(Stdio::piped())
250 .stderr(Stdio::piped())
251 .kill_on_drop(true)
252 .spawn()
253 .map_err(|e| Error::Custom(format!("ffmpeg decode spawn: {e}")))?;
254
255 let stdout = child
256 .stdout
257 .take()
258 .ok_or_else(|| Error::Custom("ffmpeg stdout not captured".into()))?;
259
260 let ffmpeg_child = FfmpegChild {
261 _child: child,
262 stdout,
263 };
264
265 let async_source = AsyncReadOnlySource::new(ffmpeg_child);
266 let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024);
267 let raw = RawAdapter::new(adapter, 48000, 2);
268 Ok(raw.into())
269}
270
271/// Stream an HTTP(S) URL live via an ffmpeg subprocess, producing a songbird 271/// Stream an HTTP(S) URL live via an ffmpeg subprocess, producing a songbird
272/// `Input` of raw f32 PCM at 48 kHz stereo — NO full download. 272/// `Input` of raw f32 PCM at 48 kHz stereo — NO full download.
273/// 273///