Skip to main content

audiobox/
true_peak.rs

1//! Offline linked true-peak limiting. Detection reconstructs eight phases with
2//! a normalized 64-tap Blackman-windowed sinc; output remains at its original rate.
3use crate::math::{cos_turns, db_to_gain, exp, sin_turns};
4use crate::{Audio, Error, Result};
5use std::collections::VecDeque;
6use std::f64::consts::PI;
7
8const PHASES: usize = 8;
9const TAPS: usize = 64;
10const HALF: usize = TAPS / 2;
11
12/// Opt-in offline true-peak limiter. The existing [`crate::LimiterOptions`] and
13/// [`Audio::limit`] keep their sample-peak behavior.
14#[derive(Clone, Copy, Debug)]
15pub struct TruePeakLimiterOptions {
16    /// Finite nominal ceiling in dBTP (default -1). Its linear value after the
17    /// safety margin must be positive and finite.
18    pub ceiling_db: f64,
19    /// Finite nonnegative lookahead in milliseconds, rounded to input frames and
20    /// clamped to the clip length. Default 5. Offline rendering compensates it.
21    pub lookahead_ms: f64,
22    /// Finite positive exponential release time constant in milliseconds. Default 50.
23    pub release_ms: f64,
24    /// Extra headroom below the nominal ceiling, 0..=12 dB (default 0.3).
25    /// Different reconstruction filters can measure different intersample peaks.
26    pub safety_margin_db: f64,
27    /// Budget for output PCM/channel vectors, the peak queue and reconstruction
28    /// kernel, in bytes (default 1 GiB). Excludes input, metadata and allocator overhead.
29    pub max_working_bytes: usize,
30}
31
32impl Default for TruePeakLimiterOptions {
33    fn default() -> Self {
34        Self {
35            ceiling_db: -1.0,
36            lookahead_ms: 5.0,
37            release_ms: 50.0,
38            safety_margin_db: 0.3,
39            max_working_bytes: 1 << 30,
40        }
41    }
42}
43
44struct Detector {
45    // Phase zero is the original sample, included separately and exactly.
46    kernel: [[f64; PHASES - 1]; TAPS],
47}
48
49impl Detector {
50    fn new() -> Self {
51        let mut kernel = [[0.0; PHASES - 1]; TAPS];
52        for phase in 1..PHASES {
53            let mut sum = 0.0;
54            for (tap, row) in kernel.iter_mut().enumerate() {
55                let t = tap as f64 - (HALF - 1) as f64 - phase as f64 / PHASES as f64;
56                let sinc = sin_turns(t / 2.0) / (PI * t);
57                let window =
58                    0.42 + 0.5 * cos_turns(t / TAPS as f64) + 0.08 * cos_turns(t / HALF as f64);
59                row[phase - 1] = sinc * window;
60                sum += row[phase - 1];
61            }
62            for row in &mut kernel {
63                row[phase - 1] /= sum;
64            }
65        }
66        Self { kernel }
67    }
68
69    // The reconstructed interval [frame, frame+1), zero outside the PCM. The
70    // interior path adds all phases side by side without per-tap bounds checks.
71    fn peak_at(&self, audio: &Audio, frame: i64) -> f64 {
72        let mut peak = 0.0f64;
73        for channel in &audio.data {
74            if frame >= 0 && (frame as usize) < channel.len() {
75                peak = peak.max(channel[frame as usize].abs() as f64);
76            }
77            let mut sums = [0.0; PHASES - 1];
78            let start = frame - (HALF - 1) as i64;
79            if start >= 0 && start as usize + TAPS <= channel.len() {
80                for (&x, row) in channel[start as usize..start as usize + TAPS]
81                    .iter()
82                    .zip(&self.kernel)
83                {
84                    for (sum, &k) in sums.iter_mut().zip(row) {
85                        *sum += x as f64 * k;
86                    }
87                }
88            } else {
89                for (tap, row) in self.kernel.iter().enumerate() {
90                    let at = start + tap as i64;
91                    if at >= 0 && (at as usize) < channel.len() {
92                        let x = channel[at as usize] as f64;
93                        for (sum, &k) in sums.iter_mut().zip(row) {
94                            *sum += x * k;
95                        }
96                    }
97                }
98            }
99            for sum in sums {
100                peak = peak.max(sum.abs());
101            }
102        }
103        peak
104    }
105
106    fn edges(&self, audio: &Audio) -> (f64, f64) {
107        let head = (-(HALF as i64)..0)
108            .map(|frame| self.peak_at(audio, frame))
109            .fold(0.0, f64::max);
110        let end = audio.frames() as i64;
111        let tail = (end..end + HALF as i64)
112            .map(|frame| self.peak_at(audio, frame))
113            .fold(0.0, f64::max);
114        (head, tail)
115    }
116
117    fn peak(&self, audio: &Audio) -> f64 {
118        let (head, tail) = self.edges(audio);
119        (0..audio.frames())
120            .map(|i| self.peak_at(audio, i as i64))
121            .fold(head.max(tail), f64::max)
122    }
123}
124
125impl Audio {
126    /// Limit reconstructed peaks offline, linking gain across every channel.
127    ///
128    /// Eight-times/64-tap detection drives instantaneous attack, lookahead and
129    /// exponential release at the source rate. A final whole-output check against
130    /// that reconstruction and the existing four-times estimator can apply an
131    /// additional uniform attenuation. The default 0.3 dB safety margin is explicit;
132    /// this finite reconstruction is not a proof for every continuous-time signal.
133    ///
134    /// Output has exactly the input's rate, channels, frame count and leading
135    /// alignment. Lookahead and FIR context are read ahead from the complete input;
136    /// no leading delay or release frames are added. Zero-extended FIR tails are
137    /// checked even though they are not appended to the returned PCM.
138    ///
139    /// Workspace is checked before allocation. Invalid settings return
140    /// [`crate::ErrorCode::InvalidArgument`]; workspace budget/size/reservation failures return
141    /// [`crate::ErrorCode::LimitExceeded`]. This is not a realtime callback API.
142    pub fn limit_true_peak(&self, options: TruePeakLimiterOptions) -> Result<Self> {
143        if !options.ceiling_db.is_finite()
144            || !options.lookahead_ms.is_finite()
145            || options.lookahead_ms < 0.0
146            || !options.release_ms.is_finite()
147            || options.release_ms <= 0.0
148            || !options.safety_margin_db.is_finite()
149            || !(0.0..=12.0).contains(&options.safety_margin_db)
150        {
151            return Err(Error::invalid("Invalid true-peak limiter settings"));
152        }
153        let ceiling = db_to_gain(options.ceiling_db - options.safety_margin_db);
154        if !ceiling.is_finite() || ceiling <= 0.0 {
155            return Err(Error::invalid("Invalid true-peak limiter ceiling"));
156        }
157        let frames = self.frames();
158        if frames as u128 + TAPS as u128 > i64::MAX as u128 {
159            return Err(Error::limit("True-peak limiter frame count overflow"));
160        }
161        let look = (options.lookahead_ms * self.rate as f64 / 1000.0)
162            .round()
163            .min(frames as f64) as usize;
164        let capacity = frames.min(look.saturating_add(1));
165        let bytes = frames
166            .checked_mul(self.channels())
167            .and_then(|v| v.checked_mul(size_of::<f32>()))
168            .and_then(|v| v.checked_add(self.channels().checked_mul(size_of::<Vec<f32>>())?))
169            .and_then(|v| v.checked_add(capacity.checked_mul(size_of::<(usize, f64)>())?))
170            .and_then(|v| v.checked_add(size_of::<Detector>()))
171            .ok_or_else(|| Error::limit("True-peak limiter workspace size overflow"))?;
172        if bytes > options.max_working_bytes {
173            return Err(Error::limit("True-peak limiter exceeds workspace budget"));
174        }
175        let allocation = |_| Error::limit("Cannot allocate true-peak limiter workspace");
176        let mut data = Vec::new();
177        data.try_reserve_exact(self.channels())
178            .map_err(allocation)?;
179        for channel in &self.data {
180            let mut copy = Vec::new();
181            copy.try_reserve_exact(frames).map_err(allocation)?;
182            copy.extend_from_slice(channel);
183            data.push(copy);
184        }
185        let mut peaks = VecDeque::<(usize, f64)>::new();
186        peaks.try_reserve_exact(capacity).map_err(allocation)?;
187        if frames == 0 {
188            return Ok(self.with_data(data));
189        }
190        let detector = Detector::new();
191        let (head, tail) = detector.edges(self);
192        // Reserve a few f32 ULPs before the final rounded PCM multiplication.
193        let protected = ceiling * (1.0 - 8.0 * f32::EPSILON as f64);
194        let release = exp(-1.0 / (options.release_ms * self.rate as f64 / 1000.0));
195        let mut gain = 1.0;
196        let mut next = 0;
197        for i in 0..frames {
198            // Expire before adding the next frame, keeping the prepared queue
199            // bounded by min(frames, lookahead+1), even for decreasing peaks.
200            while peaks.front().is_some_and(|(at, _)| *at < i) {
201                peaks.pop_front();
202            }
203            while next < frames && next <= i.saturating_add(look) {
204                let mut peak = detector.peak_at(self, next as i64);
205                if next == 0 {
206                    peak = peak.max(head);
207                }
208                if next + 1 == frames {
209                    peak = peak.max(tail);
210                }
211                while peaks.back().is_some_and(|(_, p)| *p <= peak) {
212                    peaks.pop_back();
213                }
214                peaks.push_back((next, peak));
215                next += 1;
216            }
217            let peak = peaks.front().map_or(0.0, |(_, p)| *p);
218            let target = if peak > protected {
219                protected / peak
220            } else {
221                1.0
222            };
223            gain = if target < gain {
224                target
225            } else {
226                target + (gain - target) * release
227            };
228            for channel in &mut data {
229                channel[i] = (channel[i] as f64 * gain) as f32;
230            }
231        }
232        let mut output = self.with_data(data);
233        let peak = detector.peak(&output).max(output.render_true_peak());
234        if peak > protected {
235            let correction = protected / peak;
236            for sample in output.data.iter_mut().flatten() {
237                *sample = (*sample as f64 * correction) as f32;
238            }
239        }
240        Ok(output)
241    }
242}