Skip to main content

audiobox/mixer/
spatial.rs

1//! Point-source 3D placement, horizontal speaker layouts and an explicit stereo fold.
2//! Coordinates are right-handed: +X right, +Y up, default forward -Z. Positions
3//! and distances use the same units; velocities and sound speed use units/second.
4//! Velocity controls Doppler only: the host supplies updated positions explicitly.
5//!
6//! Surround speaker directions are horizontal: quad +/-45 and +/-135 degrees;
7//! 5.1 front +/-30, centre 0, surrounds +/-110; 7.1 adds sides +/-90 and backs
8//! +/-150. Angles are clockwise from forward. Elevation affects distance and
9//! Doppler; a directly vertical/coincident emitter uses the front direction.
10//! There is no HRTF, geometric occlusion, propagation delay or bass management.
11//!
12//! Let d=clamp(distance,min,max) and r=rolloff. Linear gain is
13//! clamp(1-r*(d-min)/(max-min),0,1), inverse is min/(min+r*(d-min)), exponential is
14//! (d/min)^(-r). These follow the clamped models in the
15//! [OpenAL specification](https://www.openal.org/documentation/openal-1.1-specification.pdf).
16//! Max distance clamps evaluation, not voice lifetime; inverse/exponential retain
17//! a floor. The independent existing Space2d model keeps its previous behavior.
18//!
19//! Doppler uses the listener-to-emitter unit vector n and ratio
20//! (c+factor*dot(listener_velocity,n))/(c+factor*dot(emitter_velocity,n)). Scaled
21//! radial velocities clamp to +/-0.9c and the ratio to 0.25..=4; a coincident source
22//! has ratio 1. The final rate*pitch*Doppler playback speed clamps to 1/1024..=512,
23//! preserving the mixer's existing fixed-point/resampler bound.
24//!
25//! Directional gains have unit power and are renormalized during ramps. Distance
26//! remains a separate ramp used by quietest-voice policy. Listener/emitter targets
27//! apply in command sequence order, using the listener then current; spatial
28//! clocks freeze with their voice/bus. Legacy voices route to the front pair on
29//! surround output. Effects process every channel, compressors/limiters link all
30//! channels, and modulation phase offsets repeat according to channel index.
31//! Channel order/masks follow
32//! [WAVEFORMATEXTENSIBLE](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ksmedia/ns-ksmedia-waveformatextensible).
33use crate::math::{exp2, log2, sin_turns};
34use crate::{Error, Result};
35
36pub const MAX_OUTPUT_CHANNELS: usize = 8;
37
38/// Interleaved WAVE speaker order. LFE receives no point-source signal.
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum OutputLayout {
41    Mono,
42    Stereo,
43    Quad,
44    Surround51,
45    Surround71,
46}
47impl OutputLayout {
48    pub const fn channels(self) -> usize {
49        match self {
50            Self::Mono => 1,
51            Self::Stereo => 2,
52            Self::Quad => 4,
53            Self::Surround51 => 6,
54            Self::Surround71 => 8,
55        }
56    }
57    pub fn from_channels(channels: usize) -> Result<Self> {
58        match channels {
59            1 => Ok(Self::Mono),
60            2 => Ok(Self::Stereo),
61            4 => Ok(Self::Quad),
62            6 => Ok(Self::Surround51),
63            8 => Ok(Self::Surround71),
64            _ => Err(Error::invalid(
65                "Output layout needs 1, 2, 4, 6 or 8 channels",
66            )),
67        }
68    }
69    /// WAVEFORMATEXTENSIBLE speaker mask; 7.1 uses back and side pairs.
70    pub const fn channel_mask(self) -> u32 {
71        match self {
72            Self::Mono => 4,
73            Self::Stereo => 3,
74            Self::Quad => 0x33,
75            Self::Surround51 => 0x3f,
76            Self::Surround71 => 0x63f,
77        }
78    }
79    /// Stereo matrix fold. Requires complete input frames and enough output room;
80    /// the unwritten output suffix is untouched. No allocation on valid input.
81    /// Each full-range column has unit squared gain, preserving individual-channel
82    /// and uncorrelated-input power. Correlated channels can sum above unity; apply
83    /// headroom/limiting AFTER this fold. LFE is omitted (no bass management).
84    pub fn downmix_stereo(self, input: &[f32], out: &mut [f32]) -> Result<usize> {
85        let ch = self.channels();
86        let frames = input.len() / ch;
87        if input.len() % ch != 0 || out.len() / 2 < frames {
88            return Err(Error::invalid(
89                "Downmix needs complete frames and sufficient stereo output",
90            ));
91        }
92        if self == Self::Stereo {
93            out[..input.len()].copy_from_slice(input);
94            return Ok(frames);
95        }
96        let matrix = self.stereo_matrix();
97        for (frame, target) in input.chunks_exact(ch).zip(out.chunks_exact_mut(2)) {
98            let (mut l, mut r) = (0.0, 0.0);
99            for c in 0..ch {
100                l += frame[c] as f64 * matrix[c][0];
101                r += frame[c] as f64 * matrix[c][1];
102            }
103            target[0] = l as f32;
104            target[1] = r as f32;
105        }
106        Ok(frames)
107    }
108    fn stereo_matrix(self) -> [[f64; 2]; 8] {
109        let mut m = [[0.0; 2]; 8];
110        let centre = std::f64::consts::FRAC_1_SQRT_2;
111        if self == Self::Mono {
112            m[0] = [centre, centre];
113            return m;
114        }
115        m[0] = [1.0, 0.0];
116        m[1] = [0.0, 1.0];
117        if self == Self::Quad {
118            m[2] = [1.0, 0.0];
119            m[3] = [0.0, 1.0];
120        } else {
121            m[2] = [centre, centre];
122            m[4] = [1.0, 0.0];
123            m[5] = [0.0, 1.0];
124            if self == Self::Surround71 {
125                m[6] = [1.0, 0.0];
126                m[7] = [0.0, 1.0];
127            }
128        }
129        m
130    }
131    fn pan(self, x: f64, z: f64) -> [f64; 8] {
132        let mut gains = [0.0; 8];
133        if self == Self::Mono {
134            gains[0] = 1.0;
135            return gains;
136        }
137        let norm = (x * x + z * z).sqrt();
138        let (x, z) = if norm < 1e-12 {
139            (0.0, 1.0)
140        } else {
141            (x / norm, z / norm)
142        };
143        if self == Self::Stereo {
144            let turns = (x + 1.0) / 8.0;
145            gains[0] = sin_turns(0.25 - turns);
146            gains[1] = sin_turns(turns);
147            return gains;
148        }
149        // Clockwise adjacent speaker pairs. Solve the 2x2 amplitude system and
150        // normalize its positive gains, avoiding an atan2/platform trig call.
151        let speakers: &[(usize, f64, f64)] = match self {
152            Self::Quad => &[(0, -Q, Q), (1, Q, Q), (3, Q, -Q), (2, -Q, -Q)],
153            Self::Surround51 => &[
154                (0, -0.5, T),
155                (2, 0.0, 1.0),
156                (1, 0.5, T),
157                (5, S110, C110),
158                (4, -S110, C110),
159            ],
160            _ => &[
161                (0, -0.5, T),
162                (2, 0.0, 1.0),
163                (1, 0.5, T),
164                (7, 1.0, 0.0),
165                (5, 0.5, -T),
166                (4, -0.5, -T),
167                (6, -1.0, 0.0),
168            ],
169        };
170        for i in 0..speakers.len() {
171            let (a, ax, az) = speakers[i];
172            let (b, bx, bz) = speakers[(i + 1) % speakers.len()];
173            let det = ax * bz - bx * az;
174            let ga = (x * bz - bx * z) / det;
175            let gb = (ax * z - x * az) / det;
176            if ga >= -1e-12 && gb >= -1e-12 {
177                let (ga, gb) = (ga.max(0.0), gb.max(0.0));
178                let power = (ga * ga + gb * gb).sqrt();
179                gains[a] = ga / power;
180                gains[b] = gb / power;
181                break;
182            }
183        }
184        gains
185    }
186}
187const Q: f64 = std::f64::consts::FRAC_1_SQRT_2;
188const T: f64 = 0.866_025_403_784_438_6;
189const S110: f64 = 0.939_692_620_785_908_4;
190const C110: f64 = -0.342_020_143_325_668_7;
191
192/// Listener pose. Orientation axes need not be unit or exactly perpendicular;
193/// preparation orthonormalizes them. Zero/near-parallel axes are rejected.
194#[derive(Clone, Copy, Debug, PartialEq)]
195pub struct Listener {
196    pub position: [f32; 3],
197    pub velocity: [f32; 3],
198    pub forward: [f32; 3],
199    pub up: [f32; 3],
200}
201impl Default for Listener {
202    fn default() -> Self {
203        Self {
204            position: [0.0; 3],
205            velocity: [0.0; 3],
206            forward: [0.0, 0.0, -1.0],
207            up: [0.0, 1.0, 0.0],
208        }
209    }
210}
211impl Listener {
212    pub fn validate(&self) -> Result<()> {
213        finite(self.position)?;
214        finite(self.velocity)?;
215        finite(self.forward)?;
216        finite(self.up)?;
217        let f = normalize(vector(self.forward));
218        let u = normalize(vector(self.up));
219        if f.is_none()
220            || u.is_none()
221            || length(cross(f.unwrap_or([0.0; 3]), u.unwrap_or([0.0; 3]))) < 1e-6
222        {
223            return Err(Error::invalid(
224                "Listener orientation axes must be nonzero and not parallel",
225            ));
226        }
227        Ok(())
228    }
229}
230
231#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
232pub enum DistanceModel {
233    Linear,
234    #[default]
235    Inverse,
236    Exponential,
237}
238/// Clamped distance models: below min gain is one; beyond max distance is clamped
239/// to max. Inverse/exponential therefore retain a nonzero floor. Rolloff zero
240/// disables attenuation. Equations are documented in the module README/reference.
241#[derive(Clone, Copy, Debug, PartialEq)]
242pub struct Attenuation {
243    /// Reference distance, 0.001..=1e9 units. Default 1.
244    pub min_distance: f32,
245    /// Maximum evaluated distance, greater than min and <=1e12 units. Default 100.
246    pub max_distance: f32,
247    /// 0..=16. Default 1.
248    pub rolloff: f32,
249    pub model: DistanceModel,
250}
251impl Default for Attenuation {
252    fn default() -> Self {
253        Self {
254            min_distance: 1.0,
255            max_distance: 100.0,
256            rolloff: 1.0,
257            model: DistanceModel::Inverse,
258        }
259    }
260}
261impl Attenuation {
262    pub fn validate(&self) -> Result<()> {
263        if !(0.001..=1e9).contains(&self.min_distance)
264            || !self.max_distance.is_finite()
265            || self.max_distance <= self.min_distance
266            || self.max_distance > 1e12
267            || !(0.0..=16.0).contains(&self.rolloff)
268        {
269            return Err(Error::invalid(
270                "Invalid 3D attenuation distances or rolloff",
271            ));
272        }
273        Ok(())
274    }
275    pub fn gain(&self, distance: f64) -> Result<f64> {
276        self.validate()?;
277        if !distance.is_finite() || distance < 0.0 {
278            return Err(Error::invalid("Distance must be finite and nonnegative"));
279        }
280        Ok(self.evaluate(distance))
281    }
282    fn evaluate(&self, distance: f64) -> f64 {
283        let (min, max, r) = (
284            self.min_distance as f64,
285            self.max_distance as f64,
286            self.rolloff as f64,
287        );
288        let d = distance.clamp(min, max);
289        match self.model {
290            DistanceModel::Linear => (1.0 - r * (d - min) / (max - min)).clamp(0.0, 1.0),
291            DistanceModel::Inverse => min / (min + r * (d - min)),
292            DistanceModel::Exponential => exp2(-r * log2(d / min)),
293        }
294    }
295}
296/// A mono point emitter. Stereo inputs collapse to (L+R)/2, which can cancel
297/// antiphase content. Non-spatial voices retain their usual stereo image.
298#[derive(Clone, Copy, Debug, Default, PartialEq)]
299pub struct Emitter {
300    pub position: [f32; 3],
301    pub velocity: [f32; 3],
302    pub attenuation: Attenuation,
303}
304impl Emitter {
305    pub fn validate(&self) -> Result<()> {
306        finite(self.position)?;
307        finite(self.velocity)?;
308        self.attenuation.validate()
309    }
310}
311
312#[derive(Clone, Copy, Debug, PartialEq)]
313pub struct SpatialConfig {
314    /// 1..=1e6 units/second. Default 343.3 (metres/second in air).
315    pub speed_of_sound: f32,
316    /// Velocity multiplier 0..=16. Zero disables Doppler. Default 1.
317    pub doppler_factor: f32,
318}
319impl Default for SpatialConfig {
320    fn default() -> Self {
321        Self {
322            speed_of_sound: 343.3,
323            doppler_factor: 1.0,
324        }
325    }
326}
327/// Normalized direction gains plus independent attenuation and playback ratio.
328#[derive(Clone, Copy, Debug, PartialEq)]
329pub struct Placement {
330    pub gains: [f64; 8],
331    pub distance_gain: f64,
332    pub doppler: f64,
333}
334impl SpatialConfig {
335    pub fn validate(&self) -> Result<()> {
336        if !(1.0..=1e6).contains(&self.speed_of_sound)
337            || !(0.0..=16.0).contains(&self.doppler_factor)
338        {
339            return Err(Error::invalid("Invalid sound speed or Doppler factor"));
340        }
341        Ok(())
342    }
343    pub fn place(
344        &self,
345        listener: &Listener,
346        emitter: &Emitter,
347        layout: OutputLayout,
348    ) -> Result<Placement> {
349        self.validate()?;
350        listener.validate()?;
351        emitter.validate()?;
352        Ok(self.place_validated(listener, emitter, layout))
353    }
354    pub(super) fn place_validated(
355        &self,
356        listener: &Listener,
357        emitter: &Emitter,
358        layout: OutputLayout,
359    ) -> Placement {
360        let relative =
361            std::array::from_fn(|i| emitter.position[i] as f64 - listener.position[i] as f64);
362        let distance = length(relative);
363        let forward = normalize(vector(listener.forward)).unwrap();
364        let right = normalize(cross(forward, vector(listener.up))).unwrap();
365        let gains = layout.pan(dot(relative, right), dot(relative, forward));
366        let doppler = if distance == 0.0 || self.doppler_factor == 0.0 {
367            1.0
368        } else {
369            let direction = relative.map(|v| v / distance);
370            let c = self.speed_of_sound as f64;
371            let factor = self.doppler_factor as f64;
372            let l = (factor * dot(vector(listener.velocity), direction)).clamp(-0.9 * c, 0.9 * c);
373            let s = (factor * dot(vector(emitter.velocity), direction)).clamp(-0.9 * c, 0.9 * c);
374            ((c + l) / (c + s)).clamp(0.25, 4.0)
375        };
376        Placement {
377            gains,
378            distance_gain: emitter.attenuation.evaluate(distance),
379            doppler,
380        }
381    }
382}
383fn finite(v: [f32; 3]) -> Result<()> {
384    if v.iter().all(|x| x.is_finite()) {
385        Ok(())
386    } else {
387        Err(Error::invalid("Spatial vectors must be finite"))
388    }
389}
390fn vector(v: [f32; 3]) -> [f64; 3] {
391    v.map(|x| x as f64)
392}
393fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
394    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
395}
396fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
397    [
398        a[1] * b[2] - a[2] * b[1],
399        a[2] * b[0] - a[0] * b[2],
400        a[0] * b[1] - a[1] * b[0],
401    ]
402}
403fn length(v: [f64; 3]) -> f64 {
404    dot(v, v).sqrt()
405}
406fn normalize(v: [f64; 3]) -> Option<[f64; 3]> {
407    let n = length(v);
408    (n > 0.0).then(|| v.map(|x| x / n))
409}