feat(media): transcode local files + URLs to 16kHz wav via ffmpeg/yt-dlp
This commit is contained in:
1 parent
d72509df5f
commit
002f8caa2b
1 file changed
+156
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user