//! 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::process::{Child, Command}; use tokio::sync::{mpsc, oneshot}; use tokio::time::timeout; use tracing::{info, 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> { let input = audio_input_args(); Self::spawn(output_path, &input) } /// Test-only entry point: spawn with custom input args (e.g. `lavfi` /// test sources instead of a real audio device). Lets integration /// tests exercise the full lifecycle without depending on hardware. #[cfg(test)] fn start_with_input_args( output_path: PathBuf, input_args: &[&str], ) -> Result<(Self, mpsc::UnboundedReceiver), RecorderError> { Self::spawn(output_path, input_args) } fn spawn( output_path: PathBuf, input_args: &[&str], ) -> 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, input_args)) .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; } } } /// Graceful stop. On Unix, ffmpeg does NOT poll stdin for keys when /// stdin is a pipe (its `term_init()` calls `isatty()` and disables /// `stdin_interaction` for non-TTY), so writing `q` is silently /// ignored. SIGINT is honored regardless of TTY status — ffmpeg's /// signal handler sets a flag, the main loop breaks out between /// frames, MOOV atom is written, `exit(0)` fires. /// /// On Windows there is no equivalent via `tokio::process` without /// launching the child in its own process group; we fall back to /// writing `q` to stdin and rely on the timeout for corrupt-output /// detection. Proper Windows graceful stop is future work. async fn finalize( child: &mut Child, output_path: PathBuf, events_tx: &mpsc::UnboundedSender, ) { #[cfg(unix)] { if let Some(pid) = child.id() { match send_sigint(pid) { Ok(()) => info!(pid, "SIGINT sent to ffmpeg"), Err(e) => warn!(pid, error = %e, "SIGINT failed"), } } } #[cfg(windows)] { use tokio::io::AsyncWriteExt; if let Some(mut stdin) = child.stdin.take() && let Err(e) = stdin.write_all(b"q").await { warn!(error = %e, "failed to write stop to ffmpeg stdin"); } } // SIGINT causes ffmpeg to exit with a non-zero status (often 255) // even when the MOOV atom was written cleanly. Don't second-guess // the exit code — verify the output file was produced and is // non-empty. Real corruption will surface server-side at decode. let event = match timeout(STOP_GRACEFUL_TIMEOUT, child.wait()).await { Ok(Ok(status)) => match tokio::fs::metadata(&output_path).await { Ok(meta) if meta.len() > 0 => RecorderEvent::Finished { output: output_path, }, Ok(_) => RecorderEvent::Failed { error: format!("ffmpeg exit {status} and empty output"), }, Err(e) => RecorderEvent::Failed { error: format!("ffmpeg exit {status} and no output: {e}"), }, }, 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); } /// Send SIGINT to a child PID. Used only on Unix. #[cfg(unix)] fn send_sigint(pid: u32) -> std::io::Result<()> { // SAFETY: libc::kill accepts any pid_t; SIGINT is a well-known // signal. If the child already exited we get ESRCH, which we // convert to io::Error for the caller to log. let result = unsafe { libc::kill(pid as i32, libc::SIGINT) }; if result == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) } } fn ffmpeg_args(output: &Path, input_args: &[&str]) -> Vec { let mut args: Vec = vec!["-loglevel".into(), "error".into()]; for s in 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 input = audio_input_args(); let args = ffmpeg_args(path, &input); 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 input = audio_input_args(); let args = ffmpeg_args(Path::new("/tmp/x.m4a"), &input); let input_flags = args.iter().filter(|s| *s == "-i").count(); assert_eq!(input_flags, 1); } /// End-to-end: spawn ffmpeg with a lavfi silence source, stop via /// `Recorder::stop()`, verify the m4a is finalized (exists, has /// non-trivial size, starts with a valid MP4 `ftyp` atom). /// /// `#[ignore]` because it requires ffmpeg in PATH and takes ~1s. /// Run with: `cargo test -p doctate-desktop -- --ignored`. #[tokio::test] #[ignore = "requires ffmpeg in PATH"] async fn records_lavfi_source_and_stops_cleanly() { let tmp = tempfile::TempDir::new().unwrap(); let output = tmp.path().join("lavfi.m4a"); let (recorder, mut events) = Recorder::start_with_input_args( output.clone(), &["-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono"], ) .expect("start recorder"); match events.recv().await { Some(RecorderEvent::Started) => {} other => panic!("expected Started, got {other:?}"), } tokio::time::sleep(Duration::from_millis(500)).await; recorder.stop(); let event = tokio::time::timeout(Duration::from_secs(10), events.recv()) .await .expect("recorder event timed out") .expect("event channel closed"); let output_file = match event { RecorderEvent::Finished { output } => output, other => panic!("expected Finished, got {other:?}"), }; assert!(output_file.exists(), "output file should exist"); let bytes = std::fs::read(&output_file).unwrap(); assert!( bytes.len() > 500, "output should be > 500 bytes, got {}", bytes.len() ); // MP4/m4a: "ftyp" box magic at offset 4. assert_eq!(&bytes[4..8], b"ftyp", "should start with MP4 ftyp atom"); } }