diff options
Diffstat (limited to '')
| -rw-r--r-- | src/commands/music.rs | 21 | ||||
| -rw-r--r-- | src/commands/utilities.rs | 72 |
2 files changed, 85 insertions, 8 deletions
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>; | |||
| 17 | const LARGE_FILE_THRESHOLD: usize = 10 * 1024 * 1024; | 17 | const LARGE_FILE_THRESHOLD: usize = 10 * 1024 * 1024; |
| 18 | 18 | ||
| 19 | #[derive(Clone, Copy, PartialEq, Eq)] | 19 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 20 | enum FileType { | 20 | pub(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. |
| 32 | fn file_type(filename: &str) -> FileType { | 32 | pub(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. |
| 61 | fn stem_of(filename: &str) -> &str { | 61 | pub(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`. |
| 137 | fn compress_output_ext(ft: FileType, original: &str) -> String { | 137 | pub(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. |
| 152 | fn build_compress_command( | 152 | pub(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. | ||
| 272 | pub(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`. |
| 271 | fn sanitize_filename(name: &str) -> String { | 333 | fn sanitize_filename(name: &str) -> String { |
