From 5eea9f2fef1a840f9c9e6ec6d471fe4f87f1d1fd Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 9 Sep 2026 12:50:56 +0200 Subject: make /grajcuj w\ compress finally work as compressing a file and then /grajcuj'ing Signed-off-by: salmonik --- src/commands/music.rs | 21 +++++++++++-- src/commands/utilities.rs | 72 +++++++++++++++++++++++++++++++++++++++--- src/voice/mod.rs | 80 +++++++++++++++++++++++------------------------ 3 files changed, 125 insertions(+), 48 deletions(-) (limited to 'src') 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( } }; - info!("degrading audio ({} bytes)", audio_bytes.len()); - let degraded = crate::voice::degrade_audio(&audio_bytes).await?; + info!("compressing audio ({} bytes)", audio_bytes.len()); + let compressed_bytes = + crate::commands::utilities::compress_audio_bytes(&audio_bytes, &src.display_name) + .await + .ok_or_else(|| Error::Custom("compression failed".into()))?; + info!("compressed to {} bytes", compressed_bytes.len()); + + // Decode the compressed OGG via ffmpeg (symphonia lacks Opus support). + // Write to temp file, then stream through ffmpeg to raw PCM. + let compressed_path = format!( + "{}/play_compressed_{}.ogg", + crate::config::TEMP_PATH, + uuid::Uuid::new_v4() + ); + let _ = tokio::fs::create_dir_all(crate::config::TEMP_PATH).await; + tokio::fs::write(&compressed_path, &compressed_bytes).await?; + let decoded = crate::voice::ffmpeg_decode_file(&compressed_path).await?; // Deferred stop: only interrupt current track now that we're ready. { @@ -320,7 +335,7 @@ async fn play_impl( .voice_manager .connect(guild_id, channel_id) .await?; - input = degraded; + input = decoded; } else { // ── Normal path: stop first, then stream/download ────────── { 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>; const LARGE_FILE_THRESHOLD: usize = 10 * 1024 * 1024; #[derive(Clone, Copy, PartialEq, Eq)] -enum FileType { +pub(crate) enum FileType { Video, Audio, Image, @@ -29,7 +29,7 @@ enum FileType { /// Mirrors Python's `mimetypes.guess_type`-based `filetype()` helper without /// pulling in a mime crate: the set of extensions below covers everything the /// original bot handled. -fn file_type(filename: &str) -> FileType { +pub(crate) fn file_type(filename: &str) -> FileType { let Some((_, ext)) = filename.rsplit_once('.') else { return FileType::Unknown; }; @@ -58,7 +58,7 @@ fn ext_of(filename: &str) -> Option { } /// Filename stem: everything before the final dot of the basename. -fn stem_of(filename: &str) -> &str { +pub(crate) fn stem_of(filename: &str) -> &str { let base = filename.rsplit('/').next().unwrap_or(filename); match base.rsplit_once('.') { Some((stem, _)) if !stem.is_empty() => stem, @@ -134,7 +134,7 @@ async fn resolve_input( /// - Video keeps its container extension (ffmpeg re-muxes in place). /// - Audio is always re-encoded to Opus in an `.ogg` container. /// - JPEG/GIF keep their extension; other images are rewritten to `.jpg`. -fn compress_output_ext(ft: FileType, original: &str) -> String { +pub(crate) fn compress_output_ext(ft: FileType, original: &str) -> String { let orig = ext_of(original); match ft { FileType::Video => orig.unwrap_or_else(|| "mp4".to_string()), @@ -149,7 +149,7 @@ fn compress_output_ext(ft: FileType, original: &str) -> String { /// Build the ffmpeg/magick command for extreme-low-quality compression. /// Returns `None` for unsupported (`Unknown`) file types. -fn build_compress_command( +pub(crate) fn build_compress_command( ft: FileType, input: &str, output: &str, @@ -266,6 +266,68 @@ async fn compress_attachment_bytes( res.map(|b| (b, out_filename)) } +/// Compress audio bytes for playback using Vorbis (symphonia-decodable). +/// Uses Vorbis instead of Opus because symphonia 0.6 lacks an Opus decoder. +/// Returns the compressed .ogg bytes on success. +pub(crate) async fn compress_audio_bytes(input_bytes: &[u8], original_name: &str) -> Option> { + let safe_name = sanitize_filename(original_name); + let safe_name = if safe_name.is_empty() { "audio".to_string() } else { safe_name }; + let stem = stem_of(&safe_name).to_string(); + let out_filename = format!("compressed_{stem}.ogg"); + let input_path = temp_path_for(&safe_name); + let output_path = temp_path_for(&out_filename); + let cleanup_paths = [input_path.as_str(), output_path.as_str()]; + + let _ = tokio::fs::create_dir_all(crate::config::TEMP_PATH).await; + if let Err(e) = tokio::fs::write(&input_path, input_bytes).await { + tracing::warn!("compress_audio_bytes: failed to write input: {e}"); + return None; + } + tracing::debug!( + input = %input_path, + output = %output_path, + bytes = input_bytes.len(), + "compress_audio_bytes: running ffmpeg" + ); + + // Opus at 500bps, 12 kHz, mono — same as /compress. + // Decoded via ffmpeg in the caller (symphonia lacks Opus support). + let mut cmd = tokio::process::Command::new("ffmpeg"); + cmd.arg("-y") + .arg("-i") + .arg(&input_path) + .arg("-c:a") + .arg("libopus") + .arg("-b:a") + .arg("500") + .arg("-ar") + .arg("12000") + .arg("-ac") + .arg("1") + .arg(&output_path); + + let res = match cmd.output().await { + Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(), + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + tracing::warn!( + input = %input_path, + output = %output_path, + exit = %out.status, + "compress_audio_bytes ffmpeg failed:\n{}", + stderr.chars().take(500).collect::() + ); + None + } + Err(e) => { + tracing::warn!("compress_audio_bytes ffmpeg spawn error: {e}"); + None + } + }; + cleanup(&cleanup_paths).await; + res +} + /// Strip characters that Discord/coping filenames can't safely carry. Mirrors /// Python's `Downloader._sanitize_filename`. fn 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 Ok(bytes.to_vec()) } -/// DEBUG: bypass encode/decode — just pipe through ffmpeg as PCM. -/// If this plays, the streaming pipeline works; bug is in Opus roundtrip. -pub async fn degrade_audio(input: &[u8]) -> Result { - use std::process::Stdio; - - let mut child = tokio::process::Command::new("ffmpeg") - .args([ - "-y", "-i", "pipe:0", "-vn", "-f", "f32le", "-ac", "2", "-ar", "8000", "-b:a", "500", - "pipe:1", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true) - .spawn() - .map_err(|e| Error::Custom(format!("ffmpeg passthrough spawn: {e}")))?; - - let mut stdin = child.stdin.take().unwrap(); - let stdout = child - .stdout - .take() - .ok_or_else(|| Error::Custom("stdout not captured".into()))?; - - let owned_input = input.to_vec(); - tokio::spawn(async move { - use tokio::io::AsyncWriteExt; - let _ = stdin.write_all(&owned_input).await; - }); - - let ffmpeg_child = FfmpegChild { - _child: child, - stdout, - }; - - let async_source = AsyncReadOnlySource::new(ffmpeg_child); - let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024); - let raw = RawAdapter::new(adapter, 48000, 2); - Ok(raw.into()) -} - // ── HTTP streaming via songbird's built-in HttpRequest (legacy) ───── /// Create a songbird `Input` that streams audio directly from an HTTP(S) URL. @@ -268,6 +228,46 @@ impl tokio::io::AsyncRead for FfmpegChild { } } +/// Decode a local audio file via ffmpeg to raw f32 PCM at 48 kHz stereo. +/// No reconnect flags (those are for HTTP streams only). +pub async fn ffmpeg_decode_file(path: &str) -> Result { + use std::process::Stdio; + + let mut child = tokio::process::Command::new("ffmpeg") + .args([ + "-i", + path, + "-vn", + "-f", + "f32le", + "-ar", + "48000", + "-ac", + "2", + "pipe:1", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| Error::Custom(format!("ffmpeg decode spawn: {e}")))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::Custom("ffmpeg stdout not captured".into()))?; + + let ffmpeg_child = FfmpegChild { + _child: child, + stdout, + }; + + let async_source = AsyncReadOnlySource::new(ffmpeg_child); + let adapter = AsyncAdapterStream::new(Box::new(async_source), 64 * 1024); + let raw = RawAdapter::new(adapter, 48000, 2); + Ok(raw.into()) +} + /// Stream an HTTP(S) URL live via an ffmpeg subprocess, producing a songbird /// `Input` of raw f32 PCM at 48 kHz stereo — NO full download. /// -- cgit v1.2.3