Skip to main content

rff_filter/
audio.rs

1//! Audio filters — the `-af` / `-filter:a` chain.
2//!
3//! An [`AudioFilterChain`] transforms decoded [`AudioFrame`]s between the
4//! decoder and the encoder, mirroring [`crate::FilterChain`] for video. The
5//! supported set is deliberately the high-traffic core:
6//!
7//! * `volume=0.5` / `volume=-6dB` — gain (linear factor or decibels),
8//! * `atrim=start=1:end=3.5` (also positional `atrim=1:3.5`, `duration=`) —
9//!   sample-accurate trimming against the frame's presentation time,
10//! * `aresample=48000` — resampling, surfaced via
11//!   [`AudioFilterChain::resample_target`] and performed by the engine's
12//!   resampler (the same one implicit rate conversion uses),
13//! * `anull` — pass-through.
14//!
15//! Frames are interleaved `s16`/`f32`; planar layouts are rejected rather than
16//! silently mis-sliced.
17
18use rff_core::{AudioFrame, Error, Result, SampleFormat};
19
20enum AFilter {
21    Volume(f32),
22    ATrim {
23        start: Option<f64>,
24        end: Option<f64>,
25    },
26}
27
28/// An ordered `-af` chain plus the resample target it requests, if any.
29#[derive(Default)]
30pub struct AudioFilterChain {
31    filters: Vec<AFilter>,
32    resample_to: Option<u32>,
33}
34
35/// Parse `key=value` or bare-positional arguments of `atrim`.
36fn parse_atrim(args: &[&str]) -> Result<AFilter> {
37    let (mut start, mut end, mut duration) = (None, None, None);
38    let mut positional = 0usize;
39    for a in args {
40        let a = a.trim();
41        if a.is_empty() {
42            continue;
43        }
44        let (key, value) = match a.split_once('=') {
45            Some((k, v)) => (k.trim(), v.trim()),
46            None => {
47                let key = match positional {
48                    0 => "start",
49                    _ => "end",
50                };
51                positional += 1;
52                (key, a)
53            }
54        };
55        let v: f64 = value
56            .parse()
57            .map_err(|_| Error::Option(format!("atrim: bad number `{value}`")))?;
58        match key {
59            "start" => start = Some(v),
60            "end" => end = Some(v),
61            "duration" => duration = Some(v),
62            other => return Err(Error::Option(format!("atrim: unknown key `{other}`"))),
63        }
64    }
65    if let (None, Some(d)) = (end, duration) {
66        end = Some(start.unwrap_or(0.0) + d);
67    }
68    Ok(AFilter::ATrim { start, end })
69}
70
71/// Parse a volume value: a linear factor (`0.5`) or decibels (`-6dB`).
72fn parse_volume(value: &str) -> Result<f32> {
73    let value = value.trim();
74    if let Some(db) = value
75        .strip_suffix("dB")
76        .or_else(|| value.strip_suffix("db"))
77    {
78        let db: f32 = db
79            .trim()
80            .parse()
81            .map_err(|_| Error::Option(format!("volume: bad dB value `{value}`")))?;
82        return Ok(10f32.powf(db / 20.0));
83    }
84    value
85        .parse()
86        .map_err(|_| Error::Option(format!("volume: bad value `{value}`")))
87}
88
89impl AudioFilterChain {
90    /// Parse an FFmpeg-style `-af` spec. Empty/blank yields a pass-through chain.
91    pub fn parse(spec: &str) -> Result<AudioFilterChain> {
92        let mut chain = AudioFilterChain::default();
93        for token in spec.split(',') {
94            let token = token.trim();
95            if token.is_empty() {
96                continue;
97            }
98            let (name, args) = token.split_once('=').unwrap_or((token, ""));
99            let parts: Vec<&str> = if args.is_empty() {
100                Vec::new()
101            } else {
102                args.split(':').collect()
103            };
104            match name {
105                "volume" => {
106                    let v = parts
107                        .first()
108                        .ok_or_else(|| Error::Option("volume: missing value".into()))?;
109                    chain.filters.push(AFilter::Volume(parse_volume(v)?));
110                }
111                "atrim" => chain.filters.push(parse_atrim(&parts)?),
112                "aresample" => {
113                    // `aresample=48000` or `aresample=osr=48000`.
114                    let v = parts
115                        .first()
116                        .map(|p| p.trim_start_matches("osr=").trim())
117                        .ok_or_else(|| Error::Option("aresample: missing rate".into()))?;
118                    let rate: u32 = v
119                        .parse()
120                        .map_err(|_| Error::Option(format!("aresample: bad rate `{v}`")))?;
121                    chain.resample_to = Some(rate);
122                }
123                "anull" => {}
124                other => {
125                    return Err(Error::unsupported(format!(
126                        "unknown audio filter `{other}` (have: volume, atrim, aresample, anull)"
127                    )))
128                }
129            }
130        }
131        Ok(chain)
132    }
133
134    /// True when no per-frame work is queued (`aresample` alone still counts as
135    /// empty here: the engine's resampler performs it).
136    pub fn is_empty(&self) -> bool {
137        self.filters.is_empty()
138    }
139
140    /// The output sample rate an `aresample` in the chain asked for.
141    pub fn resample_target(&self) -> Option<u32> {
142        self.resample_to
143    }
144
145    /// Run the chain on one frame. `t` is the frame's start time in seconds
146    /// (output-relative). Returns `None` when trimming consumed the whole frame.
147    pub fn apply(&mut self, mut frame: AudioFrame, t: Option<f64>) -> Result<Option<AudioFrame>> {
148        for f in &self.filters {
149            frame = match f {
150                AFilter::Volume(gain) => {
151                    apply_volume(&mut frame, *gain)?;
152                    frame
153                }
154                AFilter::ATrim { start, end } => match apply_atrim(frame, t, *start, *end)? {
155                    Some(f) => f,
156                    None => return Ok(None),
157                },
158            };
159        }
160        Ok(Some(frame))
161    }
162}
163
164fn apply_volume(frame: &mut AudioFrame, gain: f32) -> Result<()> {
165    match frame.format {
166        SampleFormat::F32 => {
167            for chunk in frame.planes[0].chunks_exact_mut(4) {
168                let v = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) * gain;
169                chunk.copy_from_slice(&v.to_le_bytes());
170            }
171            Ok(())
172        }
173        SampleFormat::S16 => {
174            for chunk in frame.planes[0].chunks_exact_mut(2) {
175                let v = i16::from_le_bytes([chunk[0], chunk[1]]) as f32 * gain;
176                let v = v.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
177                chunk.copy_from_slice(&v.to_le_bytes());
178            }
179            Ok(())
180        }
181        other => Err(Error::unsupported(format!(
182            "volume: sample format `{}` (interleaved s16/f32 only)",
183            other.name()
184        ))),
185    }
186}
187
188/// Sample-accurate trim: keep the intersection of this frame with
189/// `[start, end)`. The frame's start time `t` must be known. Public because
190/// the engine's `-ss`/`-t` window uses the same slice for audio — an audio
191/// "frame" can be arbitrarily large (a WAV decodes as one), so keep/drop at
192/// frame granularity would cut wildly wrong.
193pub fn trim_audio_frame(
194    frame: AudioFrame,
195    t: Option<f64>,
196    start: Option<f64>,
197    end: Option<f64>,
198) -> Result<Option<AudioFrame>> {
199    apply_atrim(frame, t, start, end)
200}
201
202fn apply_atrim(
203    frame: AudioFrame,
204    t: Option<f64>,
205    start: Option<f64>,
206    end: Option<f64>,
207) -> Result<Option<AudioFrame>> {
208    let t = t.ok_or_else(|| Error::unsupported("atrim: input has no timestamps"))?;
209    let rate = frame.sample_rate.max(1) as f64;
210    let n = frame.samples;
211    let s0 = match start {
212        Some(s) if s > t => (((s - t) * rate).round() as usize).min(n),
213        _ => 0,
214    };
215    let s1 = match end {
216        Some(e) => {
217            let upto = ((e - t) * rate).round();
218            if upto <= 0.0 {
219                0
220            } else {
221                (upto as usize).min(n)
222            }
223        }
224        None => n,
225    };
226    if s1 <= s0 {
227        return Ok(None);
228    }
229    if s0 == 0 && s1 == n {
230        return Ok(Some(frame));
231    }
232    let bytes_per = frame.format.bytes_per_sample() * frame.channels.max(1) as usize;
233    if frame.format == SampleFormat::F32Planar {
234        return Err(Error::unsupported("atrim: planar audio not supported"));
235    }
236    let plane = &frame.planes[0][s0 * bytes_per..s1 * bytes_per];
237    Ok(Some(AudioFrame {
238        sample_rate: frame.sample_rate,
239        channels: frame.channels,
240        format: frame.format,
241        samples: s1 - s0,
242        planes: vec![plane.to_vec()],
243        pts: frame.pts, // pts unit is the caller's; the engine tracks time via `t`
244    }))
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn s16_frame(samples: &[i16], rate: u32, channels: u16) -> AudioFrame {
252        AudioFrame {
253            sample_rate: rate,
254            channels,
255            format: SampleFormat::S16,
256            samples: samples.len() / channels.max(1) as usize,
257            planes: vec![samples.iter().flat_map(|s| s.to_le_bytes()).collect()],
258            pts: Some(0),
259        }
260    }
261
262    #[test]
263    fn volume_scales_and_clamps_s16() {
264        let mut chain = AudioFilterChain::parse("volume=2.0").unwrap();
265        let f = s16_frame(&[100, -100, 30000], 48_000, 1);
266        let out = chain.apply(f, Some(0.0)).unwrap().unwrap();
267        let got: Vec<i16> = out.planes[0]
268            .chunks_exact(2)
269            .map(|b| i16::from_le_bytes([b[0], b[1]]))
270            .collect();
271        assert_eq!(got, vec![200, -200, 32767]); // clamped, not wrapped
272    }
273
274    #[test]
275    fn volume_parses_decibels() {
276        let g = parse_volume("-6dB").unwrap();
277        assert!((g - 0.501).abs() < 0.01, "got {g}");
278        assert_eq!(parse_volume("0dB").unwrap(), 1.0);
279    }
280
281    #[test]
282    fn atrim_slices_sample_accurately() {
283        // 1 kHz-rate mono frame starting at t=1.0 with 1000 samples (1 s).
284        let samples: Vec<i16> = (0..1000).map(|i| i as i16).collect();
285        let mut chain = AudioFilterChain::parse("atrim=start=1.25:end=1.5").unwrap();
286        let out = chain
287            .apply(s16_frame(&samples, 1000, 1), Some(1.0))
288            .unwrap()
289            .unwrap();
290        assert_eq!(out.samples, 250);
291        let first = i16::from_le_bytes([out.planes[0][0], out.planes[0][1]]);
292        assert_eq!(first, 250);
293    }
294
295    #[test]
296    fn atrim_consumes_frames_outside_the_window() {
297        let mut chain = AudioFilterChain::parse("atrim=end=0.5").unwrap();
298        let out = chain
299            .apply(s16_frame(&[1, 2, 3, 4], 8000, 1), Some(2.0))
300            .unwrap();
301        assert!(out.is_none());
302    }
303
304    #[test]
305    fn aresample_is_surfaced_not_applied() {
306        let chain = AudioFilterChain::parse("aresample=44100").unwrap();
307        assert_eq!(chain.resample_target(), Some(44_100));
308        assert!(chain.is_empty()); // engine's resampler does the work
309    }
310
311    #[test]
312    fn unknown_filters_error() {
313        assert!(AudioFilterChain::parse("acompressor").is_err());
314    }
315}