aboutsummaryrefslogtreecommitdiffstats
path: root/src/commands
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/commands/music.rs21
-rw-r--r--src/commands/utilities.rs72
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>;
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 {