5f7e46256c
Introduces new modules for audio recording (`recorder.rs`) and asynchronous uploading (`uploader.rs`) with retry logic. The `recorder` module uses `ffmpeg` as a subprocess to capture audio and save it as M4A files. It handles starting, stopping, and reporting recording events. The `uploader` module manages a queue of recordings to be uploaded to the server. It supports persistent storage of pending uploads via sidecar JSON files, exponential backoff for retries, and emits events for UI feedback. New dependencies were added to `Cargo.toml` and `Cargo.lock` to support these features, including `reqwest`, `serde_json`, `tokio`, `uuid`, and `which`.
202 lines
6.4 KiB
Rust
202 lines
6.4 KiB
Rust
//! 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<RecorderEvent>), 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<RecorderEvent>,
|
|
) {
|
|
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<RecorderEvent>,
|
|
) {
|
|
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<String> {
|
|
let mut args: Vec<String> = 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);
|
|
}
|
|
}
|