ez_ffmpeg/core/frame_export/audio.rs
1//! The [`SampleExtractor`] builder and its `samples()` / `collect_samples()` runs.
2
3use super::audio_iter::SampleIter;
4use super::audio_options::Channels;
5use super::audio_resolve::{resolve_and_build_desc, AudioResolvePlan};
6use super::audio_sink::SampleSink;
7use super::error::FrameExportError;
8use crate::core::context::demuxer::Demuxer;
9use crate::core::context::ffmpeg_context::FfmpegContext;
10use crate::core::context::filter_complex::FilterComplex;
11use crate::core::context::input::Input;
12use crate::core::context::output::Output;
13use crate::core::filter::frame_pipeline_builder::FramePipelineBuilder;
14use crate::core::scheduler::ffmpeg_scheduler::FfmpegScheduler;
15use ffmpeg_sys_next::AVMediaType::AVMEDIA_TYPE_AUDIO;
16
17/// Whisper's canonical input sample rate (16 kHz).
18const WHISPER_SAMPLE_RATE: u32 = 16_000;
19
20/// Decodes an input's audio into owned, interleaved `f32` PCM in one pass, for
21/// ASR / audio-ML pipelines.
22///
23/// Build one with [`SampleExtractor::new`], chain options, then call
24/// [`samples`](SampleExtractor::samples) for a streaming iterator or
25/// [`collect_samples`](SampleExtractor::collect_samples) for one flat buffer.
26///
27/// Defaults **preserve the source**: the source sample rate and channel layout
28/// pass through untouched, and only the sample *format* is pinned (packed
29/// `f32`) — so the default output is whatever rate/channel shape the source
30/// has, NOT a model-ready 16 kHz mono stream. Normalization is opt-in —
31/// [`sample_rate`](Self::sample_rate) and [`channels`](Self::channels) insert
32/// a resample / channel-layout conversion — so music and analysis users get
33/// no silent rate surprise. Use [`for_whisper`](Self::for_whisper) (a thin
34/// preset over those two setters) when a speech model expects 16 kHz mono.
35///
36/// ```no_run
37/// use ez_ffmpeg::frame_export::SampleExtractor;
38///
39/// # fn main() -> Result<(), ez_ffmpeg::error::Error> {
40/// // 16 kHz mono f32 — the whisper-rs / candle handoff shape.
41/// let pcm: Vec<f32> = SampleExtractor::for_whisper("input.mp4").collect_samples()?;
42/// # let _ = pcm;
43/// # Ok(())
44/// # }
45/// ```
46///
47/// # Threading & teardown
48///
49/// A run drives the normal scheduler with the sample sink mounted on the audio
50/// output pipeline. The returned [`SampleIter`] is `Send` and fused (exactly one
51/// terminal error, then `None` forever); dropping it early aborts the run
52/// cleanly (see [`SampleIter`] for the teardown ordering).
53pub struct SampleExtractor {
54 input: Input,
55 audio_stream_index: Option<usize>,
56 sample_rate: Option<u32>,
57 channels: Option<Channels>,
58 start_time_us: Option<i64>,
59 duration_us: Option<i64>,
60 channel_capacity: usize,
61}
62
63impl SampleExtractor {
64 /// Creates an extractor over `input` (a path, URL, or anything convertible
65 /// into an [`Input`]). Defaults: best audio stream, source rate, source
66 /// layout, packed `f32`, channel capacity 4.
67 pub fn new(input: impl Into<Input>) -> Self {
68 Self {
69 input: input.into(),
70 audio_stream_index: None,
71 sample_rate: None,
72 channels: None,
73 start_time_us: None,
74 duration_us: None,
75 channel_capacity: 4,
76 }
77 }
78
79 /// Creates an extractor preset for whisper-style ASR: 16 kHz, mono, `f32`.
80 ///
81 /// Thin convenience over [`new`](Self::new) followed by
82 /// `.sample_rate(16000).channels(Channels::Mono)`; every other option still
83 /// applies and can be overridden afterwards.
84 pub fn for_whisper(input: impl Into<Input>) -> Self {
85 Self::new(input)
86 .sample_rate(WHISPER_SAMPLE_RATE)
87 .channels(Channels::Mono)
88 }
89
90 /// Selects an explicit audio stream by absolute index (default: best audio
91 /// stream).
92 pub fn audio_stream_index(mut self, index: usize) -> Self {
93 self.audio_stream_index = Some(index);
94 self
95 }
96
97 /// Resamples to this output rate in Hz (default: source rate). `0` is
98 /// rejected at [`samples`](Self::samples) time.
99 pub fn sample_rate(mut self, hz: u32) -> Self {
100 self.sample_rate = Some(hz);
101 self
102 }
103
104 /// Converts to this channel layout (default: source layout). The
105 /// conversion downmixes, upmixes, or passes through depending on the
106 /// source layout.
107 pub fn channels(mut self, channels: Channels) -> Self {
108 self.channels = Some(channels);
109 self
110 }
111
112 /// Seeks to this start time (microseconds) before extracting.
113 pub fn start_time_us(mut self, us: i64) -> Self {
114 self.start_time_us = Some(us);
115 self
116 }
117
118 /// Limits extraction to this many microseconds of content past the start.
119 pub fn duration_us(mut self, us: i64) -> Self {
120 self.duration_us = Some(us);
121 self
122 }
123
124 /// Sets the prefetch channel capacity (default 4, minimum 1). Chunks are
125 /// small (a few KiB each), so a slightly deeper queue is nearly free.
126 pub fn channel_capacity(mut self, capacity: usize) -> Self {
127 self.channel_capacity = capacity;
128 self
129 }
130
131 /// Starts the run and returns a streaming iterator over the exported chunks.
132 ///
133 /// Option and stream-resolution errors surface here, before any chunk is
134 /// produced; runtime failures surface as the iterator's terminal `Err`.
135 pub fn samples(self) -> crate::error::Result<SampleIter> {
136 self.validate()?;
137
138 let capacity = self.channel_capacity.max(1);
139 let (tx, rx) = crossbeam_channel::bounded(capacity);
140
141 // Output-side sink on the single graph output stream ([export] => 0).
142 let sink = SampleSink::new(tx);
143 let sink_pipeline = FramePipelineBuilder::new(AVMEDIA_TYPE_AUDIO)
144 .filter("frame_export_sample_sink", Box::new(sink))
145 .set_stream_index(0)
146 .build();
147
148 // Input time window (no decoder fast path applies to audio).
149 let mut input = self.input;
150 if let Some(start) = self.start_time_us {
151 input = input.set_start_time_us(start);
152 }
153 if let Some(dur) = self.duration_us {
154 input = input.set_recording_time_us(dur);
155 }
156
157 // Null output. The audio codec pin is load-bearing: `pcm_f32le` advertises
158 // `sample_fmts={flt}`, so the encoder-negotiated `aformat` appended to the
159 // graph output agrees with our own `sample_fmts=flt` and the tap sees
160 // packed f32. The null muxer's default `pcm_s16le` would instead force
161 // `s16` at the output, which the sink rejects.
162 let output = Output::from("-")
163 .set_format("null")
164 .set_audio_codec("pcm_f32le")
165 .add_stream_map("[export]")
166 .add_frame_pipeline(sink_pipeline);
167
168 // Deferred resolver: runs against the opened demuxer (S8/S9).
169 let resolve_plan = AudioResolvePlan {
170 stream_index: self.audio_stream_index,
171 sample_rate: self.sample_rate,
172 channels: self.channels,
173 };
174 let resolver = move |demuxs: &[Demuxer]| -> crate::error::Result<FilterComplex> {
175 let demux = demuxs.first().ok_or(FrameExportError::NoAudioStream)?;
176 // SAFETY: the demuxer is opened for the lifetime of this call.
177 let desc = unsafe { resolve_and_build_desc(demux.in_fmt_ctx_ptr(), &resolve_plan)? };
178 Ok(desc.into())
179 };
180
181 let context = FfmpegContext::builder()
182 .input(input)
183 .output(output)
184 .add_deferred_filter_desc(Box::new(resolver))
185 .build()?;
186 let scheduler = FfmpegScheduler::new(context).start()?;
187 Ok(SampleIter::new(rx, scheduler))
188 }
189
190 /// Runs to completion and flattens every exported chunk into one interleaved
191 /// `f32` buffer. With explicit normalization (e.g.
192 /// [`for_whisper`](Self::for_whisper)) this is the whisper-rs / candle
193 /// handoff shape; with the source-preserving defaults it is simply the
194 /// source's own rate/channel shape, and the flat buffer carries no
195 /// rate/channel metadata — stream [`samples`](Self::samples) when you
196 /// need it per chunk.
197 ///
198 /// Memory is `duration_s × rate × channels × 4` bytes (1 h @ 16 kHz mono ≈
199 /// 230 MB); use [`samples`](Self::samples) to stream when that is too large.
200 /// On a terminal error the partial samples are dropped and the error is
201 /// returned.
202 pub fn collect_samples(self) -> crate::error::Result<Vec<f32>> {
203 let mut out: Vec<f32> = Vec::new();
204 for chunk in self.samples()? {
205 let chunk = chunk?;
206 if out.is_empty() {
207 // First non-empty chunk: move its buffer in with no copy.
208 out = chunk.into_vec();
209 } else {
210 out.extend_from_slice(chunk.as_slice());
211 }
212 }
213 Ok(out)
214 }
215
216 fn validate(&self) -> crate::error::Result<()> {
217 if self.channel_capacity == 0 {
218 return Err(invalid("channel_capacity must be >= 1"));
219 }
220 if self.sample_rate == Some(0) {
221 return Err(invalid("sample_rate must be > 0"));
222 }
223 // Mirrors FrameExtractor: atrim treats duration=0 as "no limit", so a
224 // non-positive window must be rejected here or a live input runs
225 // unbounded instead of erroring. Effective value: a recording time
226 // preconfigured on the Input feeds the same trim machinery.
227 if let Some(d) = self.duration_us.or(self.input.recording_time_us) {
228 if d <= 0 {
229 return Err(invalid(
230 "duration_us (or the Input's recording time) must be > 0",
231 ));
232 }
233 }
234 Ok(())
235 }
236}
237
238fn invalid(msg: &str) -> crate::error::Error {
239 FrameExportError::InvalidOption(msg.to_string()).into()
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn zero_capacity_is_rejected() {
248 let r = SampleExtractor::new("x.mp4").channel_capacity(0).validate();
249 assert!(matches!(
250 r,
251 Err(crate::error::Error::FrameExport(
252 FrameExportError::InvalidOption(_)
253 ))
254 ));
255 }
256
257 #[test]
258 fn zero_sample_rate_is_rejected() {
259 assert!(SampleExtractor::new("x.mp4")
260 .sample_rate(0)
261 .validate()
262 .is_err());
263 }
264
265 // Regression: duration_us(0) used to pass validation and reach atrim as
266 // duration=0, which FFmpeg reads as "no limit" — the opposite of the
267 // documented meaning, and an unbounded run on live inputs.
268 #[test]
269 fn non_positive_duration_is_rejected() {
270 assert!(SampleExtractor::new("x.mp4")
271 .duration_us(0)
272 .validate()
273 .is_err());
274 assert!(SampleExtractor::new("x.mp4")
275 .duration_us(-1)
276 .validate()
277 .is_err());
278 }
279
280 #[test]
281 fn defaults_validate() {
282 assert!(SampleExtractor::new("x.mp4").validate().is_ok());
283 assert!(SampleExtractor::new("x.mp4")
284 .sample_rate(16000)
285 .channels(Channels::Mono)
286 .duration_us(250_000)
287 .validate()
288 .is_ok());
289 }
290
291 #[test]
292 fn whisper_preset_sets_rate_and_mono() {
293 let e = SampleExtractor::for_whisper("x.mp4");
294 assert_eq!(e.sample_rate, Some(WHISPER_SAMPLE_RATE));
295 assert_eq!(e.channels, Some(Channels::Mono));
296 assert!(e.validate().is_ok());
297 }
298}