servo_media_audio/
panner_node.rs

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