Skip to main content

opus_pure/
soft_clip.rs

1//! Soft clipping for float output on its way to integer PCM.
2//!
3//! An Opus decoder's float output is not bounded by ±1. The codec rings, and a
4//! signal mastered close to full scale comes back slightly over it — this is
5//! true of libopus as well, and is not a defect in either. It only becomes
6//! audible when the float is converted to integer PCM, where anything past the
7//! range saturates into hard, broadband distortion.
8//!
9//! Hard clamping is the obvious fix and the wrong one: it flattens the peak
10//! into a straight line, and the corner at each end of that line is a
11//! discontinuity in the first derivative that spreads energy across the whole
12//! spectrum. What this does instead is bend the waveform down smoothly — the
13//! libopus `opus_pcm_soft_clip` algorithm, which fits `x + a·x²` over the run
14//! between the zero crossings either side of a peak, choosing `a` so the peak
15//! lands exactly at 1.
16//!
17//! # Why it holds state
18//!
19//! A peak can straddle a frame boundary. Fitting each frame in isolation would
20//! bend one half of such a peak and leave the other half alone, putting a step
21//! at the boundary — the same discontinuity the curve exists to avoid, just
22//! moved. So the coefficient in use at the end of a frame carries into the
23//! next, and the first samples of that frame continue the curve until the
24//! signal crosses zero. [`SoftClip`] is that carry.
25//!
26//! This is why it is a type rather than a function: the state has to live
27//! somewhere, one copy per stream, and a caller who has to remember to thread a
28//! scratch array through every call will eventually not.
29//!
30//! # When you need it
31//!
32//! [`OpusDecoder::decode_s16`] applies this already, matching libopus, where
33//! `opus_decode` soft-clips and `opus_decode_float` does not. Reach for
34//! [`SoftClip`] directly when you take the float output yourself and convert it
35//! to integer PCM downstream.
36//!
37//! [`OpusDecoder::decode_s16`]: crate::OpusDecoder::decode_s16
38
39/// Carries the soft-clipping curve across frame boundaries for one stream.
40///
41/// One per decoder, or per stream if you are clipping several. Construct it
42/// with the channel count the buffers you will pass are interleaved at.
43///
44/// ```
45/// use opus_pure::SoftClip;
46///
47/// let mut clip = SoftClip::new(2);
48/// let mut pcm = vec![0.0f32; 960 * 2];
49/// // ...decode into `pcm`...
50/// clip.apply(&mut pcm);
51/// ```
52#[derive(Clone, Debug)]
53pub struct SoftClip {
54    /// The `a` in `x + a·x²` left over from the previous frame, per channel.
55    /// Zero means no curve is in progress.
56    mem: Vec<f32>,
57}
58
59impl SoftClip {
60    /// A new soft clipper for interleaved audio with `channels` channels.
61    pub fn new(channels: usize) -> Self {
62        Self {
63            mem: vec![0.0; channels],
64        }
65    }
66
67    /// The channel count this was built for.
68    pub fn channels(&self) -> usize {
69        self.mem.len()
70    }
71
72    /// Forget any curve in progress, as if the next frame were the first.
73    ///
74    /// Use this on a discontinuity — a seek, or a new stream through the same
75    /// clipper — where carrying the previous curve forward would bend audio
76    /// that has nothing to do with it.
77    pub fn reset(&mut self) {
78        self.mem.fill(0.0);
79    }
80
81    /// Soft-clip `pcm` in place, into ±1.
82    ///
83    /// `pcm` is interleaved at [`channels`](Self::channels). Any trailing
84    /// samples that do not complete a frame are left untouched, and a zero
85    /// channel count or an empty buffer is a no-op rather than an error: this
86    /// is a filter, and having nothing to filter is not a mistake.
87    pub fn apply(&mut self, pcm: &mut [f32]) {
88        let channels = self.mem.len();
89        if channels == 0 {
90            return;
91        }
92        let n = pcm.len() / channels;
93        if n == 0 {
94            return;
95        }
96
97        // Bring everything into [-2, +2] first. That is the domain the curve
98        // below is fitted over; outside it the fit's derivative is zero, so
99        // clamping here introduces no discontinuity that the curve would then
100        // have to smooth (libopus `opus_limit2_checkwithin1`).
101        for s in &mut pcm[..n * channels] {
102            *s = if *s > -2.0 { *s } else { -2.0 };
103            *s = if *s < 2.0 { *s } else { 2.0 };
104        }
105
106        for c in 0..channels {
107            self.mem[c] = clip_channel(pcm, n, channels, c, self.mem[c]);
108        }
109    }
110}
111
112/// Soft-clip one channel of an interleaved buffer, returning the curve
113/// coefficient to carry into the next frame.
114///
115/// A direct port of the per-channel body of libopus `opus_pcm_soft_clip_impl`.
116fn clip_channel(pcm: &mut [f32], n: usize, channels: usize, c: usize, carried: f32) -> f32 {
117    let at = |i: usize| i * channels + c;
118    let mut a = carried;
119
120    // Finish the curve the previous frame left in progress. It applies until
121    // the signal crosses zero, which is where the peak it was fitted to ends.
122    // A zero `a` stops this immediately, since `x * 0.0 >= 0.0`.
123    for i in 0..n {
124        let x = pcm[at(i)];
125        if x * a >= 0.0 {
126            break;
127        }
128        pcm[at(i)] = x + a * x * x;
129    }
130
131    let mut curr = 0usize;
132    // The first sample as the previous step left it, kept for the ramp below.
133    let x0 = pcm[at(0)];
134
135    loop {
136        // Find the next sample outside ±1. Everything before it is already in
137        // range and needs no curve.
138        let mut peak = curr;
139        while peak < n {
140            let x = pcm[at(peak)];
141            // Kept as the two comparisons libopus writes rather than a range
142            // test: the clamp above has already removed any NaN, on which the
143            // two spellings would disagree.
144            #[allow(clippy::manual_range_contains)]
145            if x > 1.0 || x < -1.0 {
146                break;
147            }
148            peak += 1;
149        }
150        if peak == n {
151            // Nothing left over the limit, so no curve carries forward.
152            a = 0.0;
153            break;
154        }
155
156        // Widen to the zero crossings either side: the curve has to span a
157        // whole excursion, or its ends would not meet the signal smoothly.
158        let x_peak = pcm[at(peak)];
159        let mut start = peak;
160        let mut end = peak;
161        let mut maxval = x_peak.abs();
162        let mut peak_pos = peak;
163        while start > 0 && x_peak * pcm[at(start - 1)] >= 0.0 {
164            start -= 1;
165        }
166        while end < n && x_peak * pcm[at(end)] >= 0.0 {
167            let mag = pcm[at(end)].abs();
168            if mag > maxval {
169                maxval = mag;
170                peak_pos = end;
171            }
172            end += 1;
173        }
174
175        // The excursion runs off the front of the frame: its zero crossing is
176        // in audio already delivered, so the curve has no left-hand anchor.
177        let clipped_at_start = start == 0 && x_peak * pcm[at(0)] >= 0.0;
178
179        // Choose `a` so that maxval + a·maxval² lands exactly on 1.
180        a = (maxval - 1.0) / (maxval * maxval);
181        // libopus nudges `a` up by 2^-22 so that a compiler reassociating this
182        // arithmetic cannot leave the result a hair over 1. Far too small to
183        // hear, and it keeps the output inside the range the caller was
184        // promised even at 24-bit.
185        a += a * 2.4e-7;
186        if x_peak > 0.0 {
187            a = -a;
188        }
189
190        for i in start..end {
191            let x = pcm[at(i)];
192            pcm[at(i)] = x + a * x * x;
193        }
194
195        if clipped_at_start && peak_pos >= 2 {
196            // No left-hand anchor, so the curve has just moved sample 0. Ramp
197            // that offset away over the run up to the peak, rather than
198            // stepping at the boundary with the previous frame.
199            let mut offset = x0 - pcm[at(0)];
200            let delta = offset / peak_pos as f32;
201            for i in curr..peak_pos {
202                offset -= delta;
203                pcm[at(i)] += offset;
204                pcm[at(i)] = pcm[at(i)].clamp(-1.0, 1.0);
205            }
206        }
207
208        curr = end;
209        if curr == n {
210            break;
211        }
212    }
213
214    a
215}
216
217/// One float sample as 16-bit PCM, by libopus's rule (`FLOAT2INT16`).
218///
219/// Full scale is 32768, not 32767: the scale factor is a power of two, so the
220/// conversion is exact in the direction that matters and the asymmetry lives in
221/// the saturation instead. Rounding is to nearest with ties to even, which is
222/// what every `float2int` libopus selects does — SSE `cvtss2si`, NEON
223/// `vcvtns_s32_f32` and `lrintf` under the default rounding mode alike. A cast
224/// would truncate towards zero instead and pull the whole signal inwards by up
225/// to half an LSB.
226///
227/// NaN saturates to the negative rail, as it does in C, where the comparison
228/// against the lower bound is false and hands back the bound.
229#[inline]
230pub(crate) fn float_to_i16(x: f32) -> i16 {
231    let x = x * 32768.0;
232    let x = if x > -32768.0 { x } else { -32768.0 };
233    let x = if x < 32767.0 { x } else { 32767.0 };
234    x.round_ties_even() as i16
235}
236
237/// One 16-bit PCM sample as a float, by libopus's rule (`INT16TORES`).
238///
239/// Exact for every input: 32768 is a power of two, so this only shifts the
240/// exponent.
241#[inline]
242pub(crate) fn i16_to_float(x: i16) -> f32 {
243    x as f32 * (1.0 / 32768.0)
244}