Skip to main content

rlx_core/dsp/
stereo.rs

1//! The stereo field: where the mix sits between the two channels, and how far
2//! apart they are (ADR-0215).
3//!
4//! **Absolute, never levelled.** Nothing here is divided by a running peak, the
5//! way [`gain`](super::gain) divides the bands: `balance = 0` means genuinely
6//! centred, so a source carrying no stereo information reads still instead of
7//! stretching its noise floor into a confident wandering pan. The price is a
8//! narrow usable range on real material, which `shot --report` publishes.
9//!
10//! **Nothing upstream reads this module, and it reads nothing but the hop's two
11//! channels.** The mono analysis stays a function of the channel average alone,
12//! so every field that existed before this module reads bit-identically for a
13//! stereo stream and for the mono-duplicated stream carrying its channel
14//! average — `core/tests/dsp.rs` holds that as an assertion.
15
16// Hot-path panic-denial pragma (Plan 0002 Phase 2). Runs every analysis hop.
17#![deny(
18    clippy::unwrap_used,
19    clippy::expect_used,
20    clippy::indexing_slicing,
21    clippy::panic,
22    clippy::unreachable
23)]
24
25use super::gain::WAVE_FLOOR;
26
27/// One hop's whole-mix stereo field.
28#[derive(Debug, Clone, Copy, Default, PartialEq)]
29pub struct StereoField {
30    /// Where the mix sits, `-1` hard left to `+1` hard right, `0` centred.
31    pub balance: f32,
32    /// How decorrelated the two channels are: `0` identical, `0.5` fully
33    /// decorrelated, `1` polarity-inverted.
34    pub spread: f32,
35}
36
37impl StereoField {
38    /// Measure one hop from channels 0 and 1.
39    ///
40    /// `balance` is the ratio of the two channels' RMS through [`ratio`], and
41    /// `spread` is `(1 - corr) / 2` over the normalized correlation
42    /// `Σ(l·r) / sqrt(Σl² · Σr²)`. Both read exactly `0` when the larger
43    /// channel's RMS sits below [`WAVE_FLOOR`], and identical channels read
44    /// exactly `0` for both: the correlation's numerator and denominator are
45    /// then the same sum, and `sqrt(a·a)` is exactly `a` under round-to-nearest.
46    ///
47    /// Only the overlapping prefix of the two slices is read, so a caller that
48    /// has one channel shorter measures what it has rather than reaching past
49    /// it. The sums stay far from overflow at hop length: every sample of a
50    /// validated stream is finite and the squares of a `±1` hop total at most
51    /// its own sample count.
52    pub fn measure(left: &[f32], right: &[f32]) -> Self {
53        let n = left.len().min(right.len());
54        if n == 0 {
55            return Self::default();
56        }
57        let mut sum_ll = 0.0f32;
58        let mut sum_rr = 0.0f32;
59        let mut sum_lr = 0.0f32;
60        for (l, r) in left.iter().zip(right).take(n) {
61            sum_ll += l * l;
62            sum_rr += r * r;
63            sum_lr += l * r;
64        }
65        let inv_n = 1.0 / n as f32;
66        let rms_l = (sum_ll * inv_n).sqrt();
67        let rms_r = (sum_rr * inv_n).sqrt();
68        if rms_l.max(rms_r) < WAVE_FLOOR {
69            // Below the floor the ratio of two noise floors is noise. Zero is
70            // the truth about a silent hop's position, and the only value that
71            // cannot be mistaken for one.
72            return Self::default();
73        }
74        // Floored rather than guarded: the branch above already covers silence,
75        // and this keeps the division total on its own terms.
76        let corr = sum_lr / (sum_ll * sum_rr).sqrt().max(f32::MIN_POSITIVE);
77        Self {
78            balance: ratio(rms_l, rms_r),
79            // One channel exactly silent beside a loud one leaves the
80            // correlation undefined; the floored denominator reads it as `0`,
81            // which is the fully-decorrelated midpoint — a hard pan is a wide
82            // image, not a narrow one. A channel merely *below* the floor is a
83            // different case and needs no special handling: a scaled copy of
84            // the loud one still correlates exactly, so it reads `0`.
85            spread: (1.0 - corr.clamp(-1.0, 1.0)) * 0.5,
86        }
87    }
88}
89
90/// The absolute left/right ratio of two non-negative magnitudes, `-1..=1`,
91/// negative to the left.
92///
93/// **The one place the floor is applied**, so the whole-mix field and the
94/// per-band one cannot disagree about what silence reads: exactly `0` when the
95/// larger of the two sits below [`WAVE_FLOOR`], rather than a ratio of noise.
96/// The division itself is floored as well as guarded, so it is total even if
97/// the guard above it ever moves.
98pub fn ratio(left: f32, right: f32) -> f32 {
99    if left.max(right) < WAVE_FLOOR {
100        return 0.0;
101    }
102    (right - left) / (left + right).max(WAVE_FLOOR)
103}
104
105/// The per-band field: [`ratio`] taken over each band's own per-channel energy,
106/// `(bass, mid, treb)` in and out.
107///
108/// The two triples come from [`BandSplitter::split`](super::bands::BandSplitter)
109/// run on each channel's spectrum, so the band edges are **by construction**
110/// the ones `bass`/`mid`/`treb` already use rather than a second set that
111/// happens to agree. A band whose louder channel sits below [`WAVE_FLOOR`]
112/// reads exactly `0`: the treble of a bass-only source has no position, and a
113/// ratio of two leakage floors would invent one.
114pub fn band_balance(left: (f32, f32, f32), right: (f32, f32, f32)) -> (f32, f32, f32) {
115    (
116        ratio(left.0, right.0),
117        ratio(left.1, right.1),
118        ratio(left.2, right.2),
119    )
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::audio::AudioFormat;
126    use crate::dsp::{Analyzer, HOP_SIZE, WARMUP_HOPS};
127
128    /// A 512-sample hop of a sine, so a pair can be built at two gains.
129    fn hop(amp: f32) -> Vec<f32> {
130        (0..HOP_SIZE)
131            .map(|i| amp * (std::f32::consts::TAU * 8.0 * i as f32 / HOP_SIZE as f32).sin())
132            .collect()
133    }
134
135    /// The far landmark no `--signal` kind can reach: two channels that cancel
136    /// exactly. `spread` is the only quantity that distinguishes it from a
137    /// centred mono signal — `balance` reads `0` for both.
138    #[test]
139    fn a_polarity_inverted_pair_reads_the_top_of_the_spread_range() {
140        let left = hop(0.5);
141        let right: Vec<f32> = left.iter().map(|s| -s).collect();
142        let field = StereoField::measure(&left, &right);
143        assert!(
144            (field.spread - 1.0).abs() < 1e-4,
145            "an inverted pair must read the top of the range, got {}",
146            field.spread
147        );
148        assert!(
149            field.balance.abs() < 1e-4,
150            "an inverted pair is still centred, got {}",
151            field.balance
152        );
153    }
154
155    /// Identical channels are the whole of the compatibility case: every
156    /// pre-existing `--signal` kind and every mono stream lands here, and both
157    /// quantities have to read **exactly** zero rather than near it.
158    #[test]
159    fn identical_channels_read_exactly_zero_for_both() {
160        let left = hop(0.5);
161        let field = StereoField::measure(&left, &left);
162        assert_eq!(field, StereoField::default());
163    }
164
165    /// A one-waveform pair at two gains: the RMS ratio is the gain ratio, so
166    /// `balance` is algebraic rather than fitted, and the correlation is 1.
167    #[test]
168    fn a_gain_ratio_is_the_balance_and_costs_no_spread() {
169        // `p = -0.8`'s own gains — the stimulus the harness synthesizes.
170        let left = hop(0.9);
171        let right: Vec<f32> = left.iter().map(|s| s * (0.2 / 1.8)).collect();
172        let field = StereoField::measure(&left, &right);
173        assert!(
174            (field.balance + 0.8).abs() < 1e-3,
175            "gains 1.0 / 0.111 should read -0.800, got {}",
176            field.balance
177        );
178        assert!(
179            field.spread.abs() < 1e-4,
180            "one waveform at two gains is perfectly correlated, got {}",
181            field.spread
182        );
183    }
184
185    /// Silence is a position nothing can be inferred from, and the floor says so
186    /// rather than dividing one noise floor by another.
187    #[test]
188    fn a_sub_floor_hop_reads_zero_rather_than_a_ratio_of_noise() {
189        let silent = vec![0.0f32; HOP_SIZE];
190        assert_eq!(
191            StereoField::measure(&silent, &silent),
192            StereoField::default()
193        );
194        // A whisper an eighth of the floor, hard panned: still nothing.
195        let whisper: Vec<f32> = hop(WAVE_FLOOR * 0.125);
196        let field = StereoField::measure(&silent, &whisper);
197        assert_eq!(field, StereoField::default());
198        assert!(field.balance.is_finite() && field.spread.is_finite());
199        // ...and an empty hop is a measurement of nothing, not a division by it.
200        assert_eq!(StereoField::measure(&[], &[]), StereoField::default());
201    }
202
203    /// The per-band field shares the whole-mix field's floor, which is the only
204    /// thing standing between `treb_balance` on a bass-only source and a
205    /// confident position derived from two spectral leakage floors.
206    #[test]
207    fn a_band_below_the_floor_has_no_position() {
208        let loud = (0.05, 0.02, WAVE_FLOOR * 0.5);
209        let quiet = (0.01, 0.02, WAVE_FLOOR * 0.1);
210        let (bass, mid, treb) = band_balance(loud, quiet);
211        assert!(
212            bass < -0.5,
213            "a band with energy keeps its ratio, got {bass}"
214        );
215        assert_eq!(mid, 0.0, "equal energy is centred");
216        assert_eq!(treb, 0.0, "a band under the floor reads exactly zero");
217    }
218
219    /// The claim a one-channel stream makes: channel 1 is filled from channel 0
220    /// exactly as `waveform_pair` fills it, so the field reads `0` — the truth
221    /// about a stream that carries no position at all.
222    #[test]
223    fn a_one_channel_stream_reads_a_centred_narrow_field() {
224        let format = AudioFormat {
225            sample_rate: 48_000,
226            channels: 1,
227        };
228        let mut analyzer = Analyzer::new(format).expect("valid format");
229        let pcm = crate::signal::noise(3, 1.0, 0.8, format);
230        let mut hops = 0usize;
231        for chunk in pcm.chunks(HOP_SIZE) {
232            analyzer.push_interleaved(chunk);
233            let frame = analyzer.take_frame();
234            hops += 1;
235            if hops <= WARMUP_HOPS {
236                continue;
237            }
238            assert_eq!(
239                (
240                    frame.balance,
241                    frame.spread,
242                    frame.bass_balance,
243                    frame.mid_balance,
244                    frame.treb_balance
245                ),
246                (0.0, 0.0, 0.0, 0.0, 0.0),
247                "a one-channel stream has no stereo field to read"
248            );
249        }
250        assert!(hops > WARMUP_HOPS, "the clip never cleared warm-up");
251    }
252}