servo_media_audio/
panner_node.rs

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