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}