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