//! Audio recorder via an ffmpeg subprocess. Produces m4a/AAC files on disk. //! //! Portability note: this module is intentionally UI-agnostic and carries no //! `egui`/`eframe` deps so it can be lifted into a shared client-core crate //! once a second Rust client (Windows variant, etc.) arrives. //! //! Call `Recorder::start` inside a Tokio runtime context (entered with //! `Runtime::enter()` or via `#[tokio::main]`). use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; use thiserror::Error; use tokio::io::AsyncWriteExt; use tokio::process::{Child, Command}; use tokio::sync::{mpsc, oneshot}; use tokio::time::timeout; use tracing::warn; /// Max time to wait for ffmpeg to finalize the MOOV atom after stop; past /// this we hard-kill and emit `Failed`. const STOP_GRACEFUL_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug)] pub enum RecorderEvent { /// ffmpeg spawned successfully; audio capture has begun. Started, /// ffmpeg exited cleanly; the m4a file is ready. Finished { output: PathBuf }, /// ffmpeg or I/O failed; output file may be missing or corrupt. Failed { error: String }, } #[derive(Debug, Error)] pub enum RecorderError { #[error("ffmpeg not found in PATH")] FfmpegMissing, #[error("spawn ffmpeg failed: {0}")] Spawn(#[from] std::io::Error), } /// Handle to a running recorder. Dropping it does NOT stop the recorder — /// call `stop()` explicitly (consumes self). pub struct Recorder { stop_tx: oneshot::Sender<()>, } impl Recorder { /// Spawn a recorder task that captures the system default audio input /// to `output_path`. Returns a handle + event receiver. The task /// continues until `stop()` is called, then finalizes the m4a. pub fn start( output_path: PathBuf, ) -> Result<(Self, mpsc::UnboundedReceiver), RecorderError> { if which::which("ffmpeg").is_err() { return Err(RecorderError::FfmpegMissing); } let (events_tx, events_rx) = mpsc::unbounded_channel(); let (stop_tx, stop_rx) = oneshot::channel(); let mut cmd = Command::new("ffmpeg"); cmd.args(ffmpeg_args(&output_path)) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()); let child = cmd.spawn()?; tokio::spawn(run_recorder_task(child, output_path, stop_rx, events_tx)); Ok((Self { stop_tx }, events_rx)) } /// Request the recorder to finalize and exit. Consumes the handle — /// no double-stop. Completion arrives as `Finished` (or `Failed`) on /// the event receiver. pub fn stop(self) { let _ = self.stop_tx.send(()); } } async fn run_recorder_task( mut child: Child, output_path: PathBuf, stop_rx: oneshot::Receiver<()>, events_tx: mpsc::UnboundedSender, ) { let _ = events_tx.send(RecorderEvent::Started); tokio::select! { exit_result = child.wait() => { let event = match exit_result { Ok(status) if status.success() => RecorderEvent::Finished { output: output_path }, Ok(status) => RecorderEvent::Failed { error: format!("ffmpeg exited unexpectedly: {status}"), }, Err(e) => RecorderEvent::Failed { error: format!("wait ffmpeg: {e}"), }, }; let _ = events_tx.send(event); } _ = stop_rx => { finalize(&mut child, output_path, &events_tx).await; } } } /// Send 'q' to ffmpeg's stdin, wait up to `STOP_GRACEFUL_TIMEOUT` for a /// clean exit; hard-kill on timeout. async fn finalize( child: &mut Child, output_path: PathBuf, events_tx: &mpsc::UnboundedSender, ) { if let Some(stdin) = child.stdin.as_mut() && let Err(e) = stdin.write_all(b"q\n").await { warn!(error = %e, "failed to write stop to ffmpeg stdin"); } let event = match timeout(STOP_GRACEFUL_TIMEOUT, child.wait()).await { Ok(Ok(status)) if status.success() => RecorderEvent::Finished { output: output_path }, Ok(Ok(status)) => RecorderEvent::Failed { error: format!("ffmpeg exited with {status}"), }, Ok(Err(e)) => RecorderEvent::Failed { error: format!("wait ffmpeg: {e}"), }, Err(_elapsed) => { warn!("ffmpeg did not exit within timeout; killing"); let _ = child.kill().await; RecorderEvent::Failed { error: "ffmpeg stop timeout — output may be corrupt".into(), } } }; let _ = events_tx.send(event); } fn ffmpeg_args(output: &Path) -> Vec { let mut args: Vec = vec!["-loglevel".into(), "error".into()]; for s in audio_input_args() { args.push(s.to_string()); } args.extend([ "-c:a".into(), "aac".into(), "-b:a".into(), "96k".into(), "-ar".into(), "48000".into(), "-ac".into(), "1".into(), "-movflags".into(), "+faststart".into(), "-y".into(), output.to_string_lossy().into_owned(), ]); args } #[cfg(target_os = "linux")] fn audio_input_args() -> Vec<&'static str> { // PulseAudio interface (also works under PipeWire via pipewire-pulse). vec!["-f", "pulse", "-i", "default"] } #[cfg(target_os = "windows")] fn audio_input_args() -> Vec<&'static str> { // TODO(windows-client): enumerate dshow devices via // `ffmpeg -list_devices true -f dshow -i dummy` and cache the first // non-loopback capture device. "audio=default" below is a placeholder; // most Windows systems do not have a device literally named "default", // so the Windows client must resolve a real name before shipping. vec!["-f", "dshow", "-i", "audio=default"] } #[cfg(test)] mod tests { use super::*; #[test] fn ffmpeg_args_contain_output_path_and_encoder() { let path = Path::new("/tmp/test.m4a"); let args = ffmpeg_args(path); assert!(args.contains(&"/tmp/test.m4a".to_string())); assert!(args.contains(&"-y".to_string())); assert!(args.contains(&"aac".to_string())); assert!(args.contains(&"+faststart".to_string())); } #[test] fn ffmpeg_args_have_exactly_one_input() { let args = ffmpeg_args(Path::new("/tmp/x.m4a")); let input_flags = args.iter().filter(|s| *s == "-i").count(); assert_eq!(input_flags, 1); } }