Skip to main content

jugar_probar/av_sync/
extraction.rs

1//! Audio extraction from video files via ffmpeg.
2//!
3//! Shells out to ffmpeg to extract mono f32 PCM audio from video containers.
4//! ffmpeg is a runtime dependency (already required by rmedia).
5
6use crate::result::ProbarError;
7use std::path::Path;
8
9/// Default sample rate for extraction (matches AAC standard).
10pub const DEFAULT_SAMPLE_RATE: u32 = 48000;
11
12/// Build the ffmpeg command for audio extraction.
13///
14/// Returns the command arguments as a vector of strings.
15#[must_use]
16pub fn build_ffmpeg_args(video_path: &Path, sample_rate: u32) -> Vec<String> {
17    vec![
18        "-i".to_string(),
19        video_path.to_string_lossy().to_string(),
20        "-f".to_string(),
21        "f32le".to_string(),
22        "-acodec".to_string(),
23        "pcm_f32le".to_string(),
24        "-ac".to_string(),
25        "1".to_string(),
26        "-ar".to_string(),
27        sample_rate.to_string(),
28        "pipe:1".to_string(),
29    ]
30}
31
32/// Extract audio from a video file as mono f32 PCM samples.
33///
34/// Shells out to ffmpeg and captures stdout as raw PCM data.
35///
36/// # Errors
37///
38/// Returns `ProbarError::FfmpegError` if ffmpeg is not found or fails.
39pub fn extract_audio(video_path: &Path, sample_rate: u32) -> Result<Vec<f32>, ProbarError> {
40    let args = build_ffmpeg_args(video_path, sample_rate);
41
42    let output = std::process::Command::new("ffmpeg")
43        .args(&args)
44        .stdout(std::process::Stdio::piped())
45        .stderr(std::process::Stdio::piped())
46        .output()
47        .map_err(|e| ProbarError::FfmpegError {
48            message: format!("Failed to execute ffmpeg: {e}"),
49        })?;
50
51    if !output.status.success() {
52        let stderr = String::from_utf8_lossy(&output.stderr);
53        return Err(ProbarError::FfmpegError {
54            message: format!("ffmpeg exited with {}: {}", output.status, stderr),
55        });
56    }
57
58    // Convert raw bytes to f32 samples (little-endian)
59    let bytes = &output.stdout;
60    if bytes.len() % 4 != 0 {
61        return Err(ProbarError::FfmpegError {
62            message: format!(
63                "ffmpeg output length {} is not a multiple of 4 bytes",
64                bytes.len()
65            ),
66        });
67    }
68
69    let samples: Vec<f32> = bytes
70        .chunks_exact(4)
71        .map(|chunk| {
72            let arr: [u8; 4] = [chunk[0], chunk[1], chunk[2], chunk[3]];
73            f32::from_le_bytes(arr)
74        })
75        .collect();
76
77    Ok(samples)
78}
79
80/// Derive the default EDL path from a video path.
81///
82/// Convention: `video.mp4` -> `video.edl.json`
83#[must_use]
84pub fn default_edl_path(video_path: &Path) -> std::path::PathBuf {
85    let stem = video_path.file_stem().unwrap_or_default();
86    let edl_name = format!("{}.edl.json", stem.to_string_lossy());
87    match video_path.parent() {
88        Some(parent) if !parent.as_os_str().is_empty() => parent.join(edl_name),
89        _ => std::path::PathBuf::from(edl_name),
90    }
91}
92
93#[cfg(test)]
94#[allow(clippy::unwrap_used, clippy::expect_used)]
95mod tests {
96    use super::*;
97    use std::path::PathBuf;
98
99    #[test]
100    fn test_build_ffmpeg_args() {
101        let path = Path::new("/tmp/video.mp4");
102        let args = build_ffmpeg_args(path, 48000);
103        assert_eq!(args[0], "-i");
104        assert_eq!(args[1], "/tmp/video.mp4");
105        assert_eq!(args[2], "-f");
106        assert_eq!(args[3], "f32le");
107        assert_eq!(args[4], "-acodec");
108        assert_eq!(args[5], "pcm_f32le");
109        assert_eq!(args[6], "-ac");
110        assert_eq!(args[7], "1");
111        assert_eq!(args[8], "-ar");
112        assert_eq!(args[9], "48000");
113        assert_eq!(args[10], "pipe:1");
114    }
115
116    #[test]
117    fn test_build_ffmpeg_args_custom_rate() {
118        let path = Path::new("test.mp4");
119        let args = build_ffmpeg_args(path, 44100);
120        assert_eq!(args[9], "44100");
121    }
122
123    #[test]
124    fn test_build_ffmpeg_args_length() {
125        let path = Path::new("test.mp4");
126        let args = build_ffmpeg_args(path, 48000);
127        assert_eq!(args.len(), 11);
128    }
129
130    #[test]
131    fn test_default_edl_path_mp4() {
132        let video = PathBuf::from("/output/demo-bench.mp4");
133        let edl = default_edl_path(&video);
134        assert_eq!(edl, PathBuf::from("/output/demo-bench.edl.json"));
135    }
136
137    #[test]
138    fn test_default_edl_path_mov() {
139        let video = PathBuf::from("/output/render.mov");
140        let edl = default_edl_path(&video);
141        assert_eq!(edl, PathBuf::from("/output/render.edl.json"));
142    }
143
144    #[test]
145    fn test_default_edl_path_no_parent() {
146        let video = PathBuf::from("video.mp4");
147        let edl = default_edl_path(&video);
148        assert_eq!(edl, PathBuf::from("video.edl.json"));
149    }
150
151    #[test]
152    fn test_default_edl_path_nested() {
153        let video = PathBuf::from("/a/b/c/test.mp4");
154        let edl = default_edl_path(&video);
155        assert_eq!(edl, PathBuf::from("/a/b/c/test.edl.json"));
156    }
157
158    #[test]
159    fn test_default_sample_rate() {
160        assert_eq!(DEFAULT_SAMPLE_RATE, 48000);
161    }
162
163    #[test]
164    fn test_extract_audio_missing_ffmpeg() {
165        // This test verifies error handling when ffmpeg is at a nonexistent path
166        let result = extract_audio(Path::new("/nonexistent/video.mp4"), 48000);
167        assert!(result.is_err());
168    }
169}