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}