feat(media): transcode local files + URLs to 16kHz wav via ffmpeg/yt-dlp

This commit is contained in:
iamdoubz committed 2026-07-11 00:38:16 -05:00
1 parent d72509df5f
commit 002f8caa2b
1 file changed
+156
+156
View File
@@ -0,0 +1,156 @@
//! Media import: turn an arbitrary local audio/video file, a direct media URL,
//! or a streaming/YouTube page URL into WhispAssist's canonical 16kHz-mono
//! 16-bit WAV, so it can go through the same transcription/diarization path as a
//! live recording (manual "add a meeting from a file/URL" feature).
//!
//! Two external tools do the work and are deliberately **not bundled** (same
//! call as `readpst` for .pst, ADR-0008): they must be installed and on PATH.
//! - `ffmpeg` transcodes whatever we have to the target WAV.
//! - `yt-dlp` resolves URLs (YouTube and other sites via its extractors, and
//! direct media URLs via its generic extractor) down to an audio file that
//! ffmpeg can then convert.
//! A missing tool surfaces as a clear, named error rather than a generic
//! "program not found".
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, thiserror::Error)]
pub enum MediaError {
#[error("{0} isn't installed or on PATH — install it and try again")]
ToolMissing(&'static str),
#[error("{tool} failed: {message}")]
Failed {
tool: &'static str,
message: String,
},
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
/// Whether `source` should be resolved as a URL (via yt-dlp) rather than opened
/// as a local file path.
pub fn is_url(source: &str) -> bool {
let s = source.trim_start();
s.starts_with("http://") || s.starts_with("https://")
}
/// A `Command` for `program` that, on Windows, never pops a console window —
/// WhispAssist is a GUI app with no console of its own (same fix as `readpst`).
fn command(program: &str) -> Command {
let mut cmd = Command::new(program);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd
}
/// Run `cmd`, mapping a missing binary to `ToolMissing(program)` and a non-zero
/// exit to `Failed` carrying the tail of the tool's output (where the real
/// error message from ffmpeg/yt-dlp lives — both are verbose).
fn run(program: &'static str, cmd: &mut Command) -> Result<(), MediaError> {
let output = cmd.output().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => MediaError::ToolMissing(program),
_ => MediaError::Io(e),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let text = if stderr.trim().is_empty() { stdout } else { stderr };
let tail: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
let start = tail.len().saturating_sub(6);
return Err(MediaError::Failed {
tool: program,
message: tail[start..].join("\n"),
});
}
Ok(())
}
/// Transcode `source` (a local file path or a URL) into a 16kHz-mono 16-bit WAV
/// at `dest_wav`. URLs are first fetched with yt-dlp into `work_dir` (which the
/// caller creates and cleans up), then ffmpeg converts whatever landed. Blocks;
/// run it off the async runtime.
pub fn import_to_wav(source: &str, dest_wav: &Path, work_dir: &Path) -> Result<(), MediaError> {
if is_url(source) {
// bestaudio keeps the download small; ffmpeg does the actual 16kHz-mono
// conversion in one predictable pass. `--no-part` avoids a leftover
// `.part` file so `first_file_in` finds the finished download.
let template = work_dir.join("download.%(ext)s");
let mut cmd = command("yt-dlp");
cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-part", "-o"])
.arg(&template)
.arg(source);
run("yt-dlp", &mut cmd)?;
let downloaded = first_file_in(work_dir)?.ok_or(MediaError::Failed {
tool: "yt-dlp",
message: "no media file was produced".into(),
})?;
ffmpeg_to_wav(&downloaded, dest_wav)
} else {
let input = Path::new(source);
if !input.exists() {
return Err(MediaError::Failed {
tool: "import",
message: format!("file not found: {source}"),
});
}
ffmpeg_to_wav(input, dest_wav)
}
}
/// ffmpeg: any input → 16kHz mono 16-bit PCM WAV (drops video, matches the
/// format `audio::read_wav_mono_16k` and the diarizer both expect).
fn ffmpeg_to_wav(input: &Path, dest_wav: &Path) -> Result<(), MediaError> {
let mut cmd = command("ffmpeg");
cmd.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(input)
.args(["-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le"])
.arg(dest_wav);
run("ffmpeg", &mut cmd)
}
/// The first regular file in `dir` (the single yt-dlp download; the caller uses
/// a fresh temp dir per import so there's nothing else there).
fn first_file_in(dir: &Path) -> Result<Option<PathBuf>, MediaError> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if entry.file_type()?.is_file() {
return Ok(Some(entry.path()));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_url_distinguishes_urls_from_paths() {
assert!(is_url("https://youtube.com/watch?v=abc"));
assert!(is_url("http://example.com/a.mp4"));
assert!(is_url(" https://leading-space.example/x")); // trimmed
assert!(!is_url(r"C:\Users\me\meeting.mp4"));
assert!(!is_url("/home/me/meeting.m4a"));
assert!(!is_url("meeting.wav"));
assert!(!is_url("ftp://not-http.example/x"));
}
#[test]
fn import_reports_a_missing_local_file_without_touching_a_tool() {
let dir = std::env::temp_dir().join(format!("wa-media-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let err = import_to_wav(
"no-such-file.mp4",
&dir.join("out.wav"),
&dir,
)
.unwrap_err();
assert!(matches!(err, MediaError::Failed { tool: "import", .. }));
let _ = std::fs::remove_dir_all(&dir);
}
}