Skip to main content

servo_media_audio/
panner_node.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::f32::consts::PI as PI32;
6use std::f64::consts::PI;
7
8use euclid::default::Vector3D;
9use malloc_size_of_derive::MallocSizeOf;
10use num_traits::{Float, Zero};
11
12use crate::audio_node::{AudioNodeEngine, AudioNodeMessage, AudioNodeType, BlockInfo, ChannelInfo};
13use crate::block::{Block, Chunk, FRAMES_PER_BLOCK, Tick};
14use crate::param::{Param, ParamDir, ParamType};
15
16// .normalize(), but it takes into account zero vectors
17pub fn normalize_zero<T: Float>(v: Vector3D<T>) -> Vector3D<T> {
18    let len = v.length();
19    if len.is_zero() { v } else { v / len }
20}
21
22#[derive(Copy, Clone, Debug, PartialEq, Eq, MallocSizeOf)]
23pub enum PanningModel {
24    EqualPower,
25    HRTF,
26}
27
28#[derive(Copy, Clone, Debug, PartialEq, Eq, MallocSizeOf)]
29pub enum DistanceModel {
30    Linear,
31    Inverse,
32    Exponential,
33}
34
35#[derive(Copy, Clone, Debug, MallocSizeOf)]
36pub struct PannerNodeOptions {
37    pub panning_model: PanningModel,
38    pub distance_model: DistanceModel,
39    pub position_x: f32,
40    pub position_y: f32,
41    pub position_z: f32,
42    pub orientation_x: f32,
43    pub orientation_y: f32,
44    pub orientation_z: f32,
45    pub ref_distance: f64,
46    pub max_distance: f64,
47    pub rolloff_factor: f64,
48    pub cone_inner_angle: f64,
49    pub cone_outer_angle: f64,
50    pub cone_outer_gain: f64,
51}
52
53#[derive(MallocSizeOf)]
54pub enum PannerNodeMessage {
55    SetPanningModel(PanningModel),
56    SetDistanceModel(DistanceModel),
57    SetRefDistance(f64),
58    SetMaxDistance(f64),
59    SetRolloff(f64),
60    SetConeInner(f64),
61    SetConeOuter(f64),
62    SetConeGain(f64),
63}
64
65impl Default for PannerNodeOptions {
66    fn default() -> Self {
67        PannerNodeOptions {
68            panning_model: PanningModel::EqualPower,
69            distance_model: DistanceModel::Inverse,
70            position_x: 0.,
71            position_y: 0.,
72            position_z: 0.,
73            orientation_x: 1.,
74            orientation_y: 0.,
75            orientation_z: 0.,
76            ref_distance: 1.,
77            max_distance: 10000.,
78            rolloff_factor: 1.,
79            cone_inner_angle: 360.,
80            cone_outer_angle: 360.,
81            cone_outer_gain: 0.,
82        }
83    }
84}
85
86#[derive(AudioNodeCommon)]
87pub(crate) struct PannerNode {
88    channel_info: ChannelInfo,
89    panning_model: PanningModel,
90    distance_model: DistanceModel,
91    position_x: Param,
92    position_y: Param,
93    position_z: Param,
94    orientation_x: Param,
95    orientation_y: Param,
96    orientation_z: Param,
97    ref_distance: f64,
98    max_distance: f64,
99    rolloff_factor: f64,
100    cone_inner_angle: f64,
101    cone_outer_angle: f64,
102    cone_outer_gain: f64,
103    listener_data: Option<Block>,
104}
105
106impl PannerNode {
107    pub fn new(options: PannerNodeOptions, channel_info: ChannelInfo) -> Self {
108        if options.panning_model == PanningModel::HRTF {
109            log::warn!("HRTF requested but not supported")
110        }
111        Self {
112            channel_info,
113            panning_model: options.panning_model,
114            distance_model: options.distance_model,
115            position_x: Param::new(options.position_x),
116            position_y: Param::new(options.position_y),
117            position_z: Param::new(options.position_z),
118            orientation_x: Param::new(options.orientation_x),
119            orientation_y: Param::new(options.orientation_y),
120            orientation_z: Param::new(options.orientation_z),
121            ref_distance: options.ref_distance,
122            max_distance: options.max_distance,
123            rolloff_factor: options.rolloff_factor,
124            cone_inner_angle: options.cone_inner_angle,
125            cone_outer_angle: options.cone_outer_angle,
126            cone_outer_gain: options.cone_outer_gain,
127            listener_data: None,
128        }
129    }
130
131    pub fn update_parameters(&mut self, info: &BlockInfo, tick: Tick) -> bool {
132        let mut changed = self.position_x.update(info, tick);
133        changed |= self.position_y.update(info, tick);
134        changed |= self.position_z.update(info, tick);
135        changed |= self.orientation_x.update(info, tick);
136        changed |= self.orientation_y.update(info, tick);
137        changed |= self.orientation_z.update(info, tick);
138        changed
139    }
140
141    /// Computes azimuth, elevation, and distance of source with respect to a
142    /// given AudioListener's position, forward, and up vectors
143    /// in degrees
144    ///
145    /// <https://webaudio.github.io/web-audio-api/#azimuth-elevation>
146    /// <https://webaudio.github.io/web-audio-api/#Spatialization-distance-effects>
147    fn azimuth_elevation_distance(
148        &self,
149        listener: (Vector3D<f32>, Vector3D<f32>, Vector3D<f32>),
150    ) -> (f64, f64, f64) {
151        let (listener_position, listener_forward, listener_up) = listener;
152        let listener_position = listener_position.to_f64();
153        let listener_forward = listener_forward.to_f64();
154        let listener_up = listener_up.to_f64();
155        let source_position = Vector3D::new(
156            self.position_x.value(),
157            self.position_y.value(),
158            self.position_z.value(),
159        )
160        .to_f64();
161        let diff = source_position - listener_position;
162        let source_listener = normalize_zero(diff);
163        let distance = diff.length();
164
165        // degenerate case
166        if source_listener.length().is_zero() {
167            return (0., 0., distance);
168        }
169
170        let listener_right = listener_forward.cross(listener_up);
171
172        if listener_right.length().is_zero() {
173            // Handle the case where listener's 'up' and 'forward' vectors are linearly
174            // dependent, in which case 'right' cannot be determined
175            return (0., 0., distance);
176        }
177
178        let listener_right_norm = normalize_zero(listener_right);
179        let listener_forward_norm = normalize_zero(listener_forward);
180
181        let up = listener_right_norm.cross(listener_forward_norm);
182
183        let up_projection = source_listener.dot(up);
184        let projected_source = normalize_zero(source_listener - up * up_projection);
185        let mut azimuth = 180. * projected_source.dot(listener_right_norm).acos() / PI;
186
187        let front_back = projected_source.dot(listener_forward_norm);
188        if front_back < 0. {
189            azimuth = 360. - azimuth;
190        }
191        if (0. ..=270.).contains(&azimuth) {
192            azimuth = 90. - azimuth;
193        } else {
194            azimuth = 450. - azimuth;
195        }
196
197        let mut elevation = 90. - 180. * source_listener.dot(up).acos() / PI;
198
199        if elevation > 90. {
200            elevation = 180. - elevation;
201        } else if elevation < -90. {
202            elevation = -180. - elevation;
203        }
204
205        (azimuth, elevation, distance)
206    }
207
208    /// <https://webaudio.github.io/web-audio-api/#Spatialization-sound-cones>
209    fn cone_gain(&self, listener: (Vector3D<f32>, Vector3D<f32>, Vector3D<f32>)) -> f64 {
210        let (listener_position, _, _) = listener;
211        let source_position = Vector3D::new(
212            self.position_x.value(),
213            self.position_y.value(),
214            self.position_z.value(),
215        );
216        let source_orientation = Vector3D::new(
217            self.orientation_x.value(),
218            self.orientation_y.value(),
219            self.orientation_z.value(),
220        );
221
222        if source_orientation.length().is_zero() ||
223            (self.cone_inner_angle == 360. && self.cone_outer_angle == 360.)
224        {
225            return 1.;
226        }
227
228        let normalized_source_orientation = normalize_zero(source_orientation);
229
230        let source_to_listener = normalize_zero(source_position - listener_position);
231        // Angle between the source orientation vector and the source-listener vector
232        let angle = 180. * source_to_listener.dot(normalized_source_orientation).acos() / PI32;
233        let abs_angle = angle.abs() as f64;
234
235        // Divide by 2 here since API is entire angle (not half-angle)
236        let abs_inner_angle = self.cone_inner_angle.abs() / 2.;
237        let abs_outer_angle = self.cone_outer_angle.abs() / 2.;
238
239        if abs_angle < abs_inner_angle {
240            // no attenuation
241            1.
242        } else if abs_angle >= abs_outer_angle {
243            // max attenuation
244            self.cone_outer_gain
245        } else {
246            // gain changes linearly from 1 to cone_outer_gain
247            // as we go from inner to outer
248            let x = (abs_angle - abs_inner_angle) / (abs_outer_angle - abs_inner_angle);
249            (1. - x) + self.cone_outer_gain * x
250        }
251    }
252
253    fn linear_distance(&self, mut distance: f64, rolloff_factor: f64) -> f64 {
254        if distance > self.max_distance {
255            distance = self.max_distance;
256        }
257        if distance < self.ref_distance {
258            distance = self.ref_distance;
259        }
260        let denom = self.max_distance - self.ref_distance;
261        1. - rolloff_factor * (distance - self.ref_distance) / denom
262    }
263
264    fn inverse_distance(&self, mut distance: f64, rolloff_factor: f64) -> f64 {
265        if distance < self.ref_distance {
266            distance = self.ref_distance;
267        }
268        let denom = self.ref_distance + rolloff_factor * (distance - self.ref_distance);
269        self.ref_distance / denom
270    }
271
272    fn exponential_distance(&self, mut distance: f64, rolloff_factor: f64) -> f64 {
273        if distance < self.ref_distance {
274            distance = self.ref_distance;
275        }
276
277        (distance / self.ref_distance).powf(-rolloff_factor)
278    }
279
280    fn distance_gain_fn(&self) -> fn(&Self, f64, f64) -> f64 {
281        match self.distance_model {
282            DistanceModel::Linear => |x, d, r| x.linear_distance(d, r),
283            DistanceModel::Inverse => |x, d, r| x.inverse_distance(d, r),
284            DistanceModel::Exponential => |x, d, r| x.exponential_distance(d, r),
285        }
286    }
287}
288
289impl AudioNodeEngine for PannerNode {
290    fn node_type(&self) -> AudioNodeType {
291        AudioNodeType::PannerNode
292    }
293
294    fn process(&mut self, mut inputs: Chunk, info: &BlockInfo) -> Chunk {
295        debug_assert!(inputs.len() == 1);
296
297        let listener_data = if let Some(listener_data) = self.listener_data.take() {
298            listener_data
299        } else {
300            return inputs;
301        };
302
303        // We clamp this early
304        let rolloff_factor =
305            if self.distance_model == DistanceModel::Linear && self.rolloff_factor > 1. {
306                1.
307            } else {
308                self.rolloff_factor
309            };
310
311        {
312            let block = &mut inputs.blocks[0];
313
314            block.explicit_repeat();
315
316            let mono = if block.chan_count() == 1 {
317                block.resize_silence(2);
318                true
319            } else {
320                debug_assert!(block.chan_count() == 2);
321                false
322            };
323
324            let distance_gain_fn = self.distance_gain_fn();
325
326            if self.panning_model == PanningModel::EqualPower {
327                let (l, r) = block.data_mut().split_at_mut(FRAMES_PER_BLOCK.0 as usize);
328                for frame in 0..FRAMES_PER_BLOCK.0 {
329                    let frame = Tick(frame);
330                    self.update_parameters(info, frame);
331                    let data = listener_data.listener_data(frame);
332                    let (mut azimuth, _elev, dist) = self.azimuth_elevation_distance(data);
333                    let distance_gain = distance_gain_fn(self, dist, rolloff_factor);
334                    let cone_gain = self.cone_gain(data);
335
336                    // https://webaudio.github.io/web-audio-api/#Spatialization-equal-power-panning
337
338                    // clamp to [-180, 180], then wrap to [-90, 90]
339                    azimuth = azimuth.clamp(-180., 180.);
340                    if azimuth < -90. {
341                        azimuth = -180. - azimuth;
342                    } else if azimuth > 90. {
343                        azimuth = 180. - azimuth;
344                    }
345
346                    let x = if mono {
347                        (azimuth + 90.) / 180.
348                    } else if azimuth <= 0. {
349                        (azimuth + 90.) / 90.
350                    } else {
351                        azimuth / 90.
352                    };
353                    let x = x * PI / 2.;
354
355                    let mut gain_l = x.cos();
356                    let mut gain_r = x.sin();
357                    // 9. * PI / 2 is often slightly negative, clamp
358                    if gain_l <= 0. {
359                        gain_l = 0.
360                    }
361                    if gain_r <= 0. {
362                        gain_r = 0.;
363                    }
364
365                    let index = frame.0 as usize;
366                    if mono {
367                        let input = l[index];
368                        l[index] = input * gain_l as f32;
369                        r[index] = input * gain_r as f32;
370                    } else if azimuth <= 0. {
371                        l[index] += r[index] * gain_l as f32;
372                        r[index] *= gain_r as f32;
373                    } else {
374                        r[index] += l[index] * gain_r as f32;
375                        l[index] *= gain_l as f32;
376                    }
377                    l[index] = l[index] * distance_gain as f32 * cone_gain as f32;
378                    r[index] = r[index] * distance_gain as f32 * cone_gain as f32;
379                }
380            }
381        }
382
383        inputs
384    }
385
386    fn input_count(&self) -> u32 {
387        1
388    }
389
390    fn get_param(&mut self, id: ParamType) -> &mut Param {
391        match id {
392            ParamType::Position(ParamDir::X) => &mut self.position_x,
393            ParamType::Position(ParamDir::Y) => &mut self.position_y,
394            ParamType::Position(ParamDir::Z) => &mut self.position_z,
395            ParamType::Orientation(ParamDir::X) => &mut self.orientation_x,
396            ParamType::Orientation(ParamDir::Y) => &mut self.orientation_y,
397            ParamType::Orientation(ParamDir::Z) => &mut self.orientation_z,
398            _ => panic!("Unknown param {:?} for PannerNode", id),
399        }
400    }
401
402    fn set_listenerdata(&mut self, data: Block) {
403        self.listener_data = Some(data);
404    }
405
406    fn message_specific(&mut self, message: AudioNodeMessage, _sample_rate: f32) {
407        if let AudioNodeMessage::PannerNode(p) = message {
408            match p {
409                PannerNodeMessage::SetPanningModel(p) => {
410                    if p == PanningModel::HRTF {
411                        log::warn!("HRTF requested but not supported");
412                    }
413                    self.panning_model = p;
414                },
415                PannerNodeMessage::SetDistanceModel(d) => self.distance_model = d,
416                PannerNodeMessage::SetRefDistance(val) => self.ref_distance = val,
417                PannerNodeMessage::SetMaxDistance(val) => self.max_distance = val,
418                PannerNodeMessage::SetRolloff(val) => self.rolloff_factor = val,
419                PannerNodeMessage::SetConeInner(val) => self.cone_inner_angle = val,
420                PannerNodeMessage::SetConeOuter(val) => self.cone_outer_angle = val,
421                PannerNodeMessage::SetConeGain(val) => self.cone_outer_gain = val,
422            }
423        }
424    }
425}