Skip to main content

tono_core/render/
output.rs

1//! The output stage: loop-body extraction, loudness normalization + true-peak
2//! limiting, and the write-time stereo treatments (Haas / Wide).
3
4use super::Signal;
5use crate::dsl::{Normalize, Stereo};
6use crate::dsp::{
7    db_to_lin, loudness_lufs, loudness_lufs_gated, peak_limit, true_peak, true_peak_oversampled,
8};
9use std::f32::consts::FRAC_PI_2;
10
11/// Extract the loop region `[start_secs, end_secs)` and equal-power crossfade
12/// its last `crossfade_secs` onto its head, returning a buffer that repeats
13/// seamlessly. The output length is the region minus the crossfade.
14///
15/// Overlap-loop: with region `r` of length `L` and crossfade `x`, the body is
16/// `r[0..L-x]` with its first `x` samples replaced by a sin/cos blend of the
17/// head (`r[i]`, fading in) and the tail (`r[L-x+i]`, fading out). The wrap
18/// `out[last] → out[0]` then lands on adjacent original samples, so there is no
19/// discontinuity.
20pub fn make_loop_buffer(
21    samples: &[f32],
22    sr: u32,
23    start_secs: f32,
24    end_secs: Option<f32>,
25    crossfade_secs: f32,
26) -> Signal {
27    let len = samples.len();
28    let s = ((start_secs * sr as f32) as usize).min(len);
29    let e = end_secs
30        .map(|x| (x * sr as f32) as usize)
31        .unwrap_or(len)
32        .min(len);
33    if e <= s {
34        return samples.to_vec();
35    }
36    let region = &samples[s..e];
37    let l = region.len();
38    let x = ((crossfade_secs * sr as f32) as usize).min(l / 2);
39    if x == 0 {
40        return region.to_vec();
41    }
42    let out_len = l - x;
43    let mut out = region[..out_len].to_vec();
44    for (i, o) in out.iter_mut().take(x).enumerate() {
45        let t = (i as f32 + 0.5) / x as f32;
46        let fade_in = (FRAC_PI_2 * t).sin();
47        let fade_out = (FRAC_PI_2 * t).cos();
48        *o = region[i] * fade_in + region[out_len + i] * fade_out;
49    }
50    out
51}
52
53/// The loop-seam discontinuity in dB: the sample jump from the last sample back
54/// to the first (lower ⇒ a cleaner seamless loop).
55pub fn loop_seam_db(samples: &[f32]) -> f32 {
56    if samples.len() < 2 {
57        return -120.0;
58    }
59    let jump = (samples[0] - samples[samples.len() - 1]).abs();
60    20.0 * jump.max(1e-9).log10()
61}
62
63/// Opt-in output stage: gain-match to a LUFS target (if given), soft-limiting
64/// peaks into the `ceiling_dbtp` true-peak ceiling. Unlike a whole-buffer
65/// attenuation, the soft-knee limiter only compresses the peaks, so dense /
66/// peaky material (a BGM mix, layered impacts) actually REACHES the loudness
67/// target instead of being dragged back down. Two measure→gain→limit passes
68/// converge within ~1 dB.
69pub(super) fn normalize_output(samples: &mut [f32], nz: &Normalize) {
70    let ceil = db_to_lin(nz.ceiling_dbtp);
71    if let Some(target) = nz.target_lufs {
72        for _ in 0..2 {
73            let cur = loudness_lufs(samples);
74            if cur <= -120.0 {
75                break;
76            }
77            let g = db_to_lin(target - cur);
78            for x in samples.iter_mut() {
79                *x *= g;
80            }
81            soft_limit(samples, ceil);
82        }
83    }
84    // Safety: catch inter-sample residue above the ceiling, then sample peak.
85    true_peak_limit(samples, nz.ceiling_dbtp);
86    peak_limit(&mut [samples]);
87}
88
89/// Engine ≥ 4 output stage over the whole program (1 = mono, 2 = stereo):
90/// loudness is measured jointly with gated BS.1770 at the actual sample rate
91/// and corrected with ONE shared gain (per-channel matching collapsed any
92/// asymmetric mix toward center), and the ceiling is enforced against a real
93/// oversampled true-peak estimate (the legacy linear estimate could never see
94/// an inter-sample over, so the documented dBTP ceiling was not honored).
95pub(super) fn normalize_output_v4(channels: &mut [&mut [f32]], nz: &Normalize, sr: u32) {
96    let ceil = db_to_lin(nz.ceiling_dbtp);
97    if let Some(target) = nz.target_lufs {
98        for _ in 0..2 {
99            let cur = {
100                let views: Vec<&[f32]> = channels.iter().map(|c| &**c).collect();
101                loudness_lufs_gated(&views, sr)
102            };
103            if cur <= -120.0 {
104                break;
105            }
106            let g = db_to_lin(target - cur);
107            for c in channels.iter_mut() {
108                for x in c.iter_mut() {
109                    *x *= g;
110                }
111                soft_limit(c, ceil);
112            }
113        }
114    }
115    // Shared true-peak gain, then the joint sample-peak safety net.
116    let tp = channels
117        .iter()
118        .map(|c| true_peak_oversampled(c))
119        .fold(0.0f32, f32::max);
120    if tp > ceil && tp > 0.0 {
121        let g = ceil / tp;
122        for c in channels.iter_mut() {
123            for x in c.iter_mut() {
124                *x *= g;
125            }
126        }
127    }
128    peak_limit(channels);
129}
130
131/// Soft-knee peak limiter: transparent below `0.7 × ceil`, smoothly (tanh)
132/// compressed above, never exceeding `ceil`. C1-continuous at the knee.
133fn soft_limit(samples: &mut [f32], ceil: f32) {
134    const KNEE: f32 = 0.7;
135    // A degenerate ceiling must not turn the mix into inf/NaN.
136    let ceil = ceil.max(1e-9);
137    for x in samples.iter_mut() {
138        let v = *x / ceil;
139        let a = v.abs();
140        if a > KNEE {
141            let compressed = KNEE + (1.0 - KNEE) * ((a - KNEE) / (1.0 - KNEE)).tanh();
142            *x = v.signum() * compressed * ceil;
143        }
144    }
145}
146
147/// Scale so the estimated true peak sits at or below `ceiling_dbtp`. Pure
148/// attenuation (never boosts), so it composes after loudness matching.
149fn true_peak_limit(samples: &mut [f32], ceiling_dbtp: f32) {
150    let ceil = db_to_lin(ceiling_dbtp);
151    let tp = true_peak(samples);
152    if tp > ceil && tp > 0.0 {
153        let g = ceil / tp;
154        for x in samples.iter_mut() {
155            *x *= g;
156        }
157    }
158}
159
160/// Turn a finished mono render into a stereo (left, right) pair per the doc's
161/// [`Stereo`] mode. Mono is the identity; Haas / Wide add width. The pair is
162/// jointly peak-limited so widening never clips.
163pub fn stereoize(mono: &[f32], stereo: Stereo, sr: u32) -> (Vec<f32>, Vec<f32>) {
164    let (mut l, mut r) = match stereo {
165        Stereo::Mono => (mono.to_vec(), mono.to_vec()),
166        Stereo::Haas { ms, pan } => {
167            let d = ((ms / 1000.0) * sr as f32) as usize;
168            let delayed: Vec<f32> = (0..mono.len())
169                .map(|i| if i >= d { mono[i - d] } else { 0.0 })
170                .collect();
171            // pan >= 0 → right leads (left is the delayed/trailing side).
172            if pan >= 0.0 {
173                (delayed, mono.to_vec())
174            } else {
175                (mono.to_vec(), delayed)
176            }
177        }
178        Stereo::Wide { amount } => {
179            let dec = allpass_decorrelate(mono, sr);
180            let a = amount.clamp(0.0, 1.0);
181            let mut l = Vec::with_capacity(mono.len());
182            let mut r = Vec::with_capacity(mono.len());
183            for i in 0..mono.len() {
184                let mid = mono[i];
185                let side = a * (mono[i] - dec[i]) * 0.5;
186                l.push(mid + side);
187                r.push(mid - side);
188            }
189            (l, r)
190        }
191    };
192    peak_limit(&mut [&mut l, &mut r]);
193    (l, r)
194}
195
196/// Decorrelate a mono signal with a short Schroeder all-pass chain (for the
197/// `Wide` pseudo-stereo mode).
198fn allpass_decorrelate(input: &[f32], sr: u32) -> Vec<f32> {
199    let scale = sr as f32 / 44_100.0;
200    let mut sig = input.to_vec();
201    for &tune in &[225usize, 556, 441] {
202        let len = ((tune as f32 * scale) as usize).max(1);
203        let mut buf = vec![0.0f32; len];
204        let mut idx = 0usize;
205        let g = 0.7;
206        for s in sig.iter_mut() {
207            let buffered = buf[idx];
208            let y = -*s * g + buffered;
209            buf[idx] = *s + buffered * g;
210            idx = (idx + 1) % len;
211            *s = y;
212        }
213    }
214    sig
215}