Skip to main content

web_audio_api/node/
panner.rs

1use std::any::Any;
2use std::collections::HashMap;
3use std::f32::consts::PI;
4use std::sync::{Mutex, OnceLock};
5
6use float_eq::float_eq;
7use hrtf::{HrirSphere, HrtfContext, HrtfProcessor, Vec3};
8
9use crate::context::{AudioContextRegistration, AudioParamId, BaseAudioContext};
10use crate::param::{AudioParam, AudioParamDescriptor};
11use crate::render::{
12    AudioParamValues, AudioProcessor, AudioRenderQuantum, AudioWorkletGlobalScope,
13};
14use crate::RENDER_QUANTUM_SIZE;
15
16use super::{AudioNode, AudioNodeOptions, ChannelConfig, ChannelCountMode, ChannelInterpretation};
17
18/// Assert that the given value number is a valid value for coneOuterGain
19///
20/// # Panics
21///
22/// This function will panic if:
23/// - the given value is not finite and lower than zero
24#[track_caller]
25#[inline(always)]
26#[allow(clippy::manual_range_contains)]
27pub(crate) fn assert_valid_cone_outer_gain(value: f64) {
28    assert!(
29        value >= 0. && value <= 1.,
30        "InvalidStateError - coneOuterGain must be in the range [0, 1]"
31    );
32}
33
34/// Load the HRTF processor for the given sample_rate
35///
36/// The included data contains the impulse responses at 44100 Hertz, so it needs to be resampled
37/// for other values (which can easily take 100s of milliseconds). Therefore cache the result (per
38/// sample rate) in a global variable and clone it every time a new panner is created.
39pub(crate) fn load_hrtf_processor(sample_rate: u32) -> (HrtfProcessor, usize) {
40    static INSTANCE: OnceLock<Mutex<HashMap<u32, (HrtfProcessor, usize)>>> = OnceLock::new();
41    let cache = INSTANCE.get_or_init(|| Mutex::new(HashMap::new()));
42
43    // To avoid poisening the cache mutex, don't use the `entry()` API on HashMap
44    {
45        if let Some(value) = cache.lock().unwrap().get(&sample_rate) {
46            return value.clone();
47        }
48    }
49
50    // The following snippet might panic
51    let resource = include_bytes!("../../resources/IRC_1003_C.bin");
52    let hrir_sphere = HrirSphere::new(&resource[..], sample_rate).unwrap();
53    let len = hrir_sphere.len();
54
55    let interpolation_steps = 1; // TODO?
56    let samples_per_step = RENDER_QUANTUM_SIZE / interpolation_steps;
57    let processor = HrtfProcessor::new(hrir_sphere, interpolation_steps, samples_per_step);
58
59    let value = (processor, len);
60    cache.lock().unwrap().insert(sample_rate, value.clone());
61
62    value
63}
64
65/// Spatialization algorithm used to position the audio in 3D space
66#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
67pub enum PanningModelType {
68    #[default]
69    EqualPower,
70    HRTF,
71}
72
73impl From<u8> for PanningModelType {
74    fn from(i: u8) -> Self {
75        match i {
76            0 => PanningModelType::EqualPower,
77            1 => PanningModelType::HRTF,
78            _ => unreachable!(),
79        }
80    }
81}
82
83/// Algorithm to reduce the volume of an audio source as it moves away from the listener
84#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
85pub enum DistanceModelType {
86    Linear,
87    #[default]
88    Inverse,
89    Exponential,
90}
91
92impl From<u8> for DistanceModelType {
93    fn from(i: u8) -> Self {
94        match i {
95            0 => DistanceModelType::Linear,
96            1 => DistanceModelType::Inverse,
97            2 => DistanceModelType::Exponential,
98            _ => unreachable!(),
99        }
100    }
101}
102
103/// Options for constructing a [`PannerNode`]
104// dictionary PannerOptions : AudioNodeOptions {
105//   PanningModelType panningModel = "equalpower";
106//   DistanceModelType distanceModel = "inverse";
107//   float positionX = 0;
108//   float positionY = 0;
109//   float positionZ = 0;
110//   float orientationX = 1;
111//   float orientationY = 0;
112//   float orientationZ = 0;
113//   double refDistance = 1;
114//   double maxDistance = 10000;
115//   double rolloffFactor = 1;
116//   double coneInnerAngle = 360;
117//   double coneOuterAngle = 360;
118//   double coneOuterGain = 0;
119// };
120#[derive(Clone, Debug)]
121pub struct PannerOptions {
122    pub panning_model: PanningModelType,
123    pub distance_model: DistanceModelType,
124    pub position_x: f32,
125    pub position_y: f32,
126    pub position_z: f32,
127    pub orientation_x: f32,
128    pub orientation_y: f32,
129    pub orientation_z: f32,
130    pub ref_distance: f64,
131    pub max_distance: f64,
132    pub rolloff_factor: f64,
133    pub cone_inner_angle: f64,
134    pub cone_outer_angle: f64,
135    pub cone_outer_gain: f64,
136    pub audio_node_options: AudioNodeOptions,
137}
138
139impl Default for PannerOptions {
140    fn default() -> Self {
141        PannerOptions {
142            panning_model: PanningModelType::default(),
143            distance_model: DistanceModelType::default(),
144            position_x: 0.,
145            position_y: 0.,
146            position_z: 0.,
147            orientation_x: 1.,
148            orientation_y: 0.,
149            orientation_z: 0.,
150            ref_distance: 1.,
151            max_distance: 10000.,
152            rolloff_factor: 1.,
153            cone_inner_angle: 360.,
154            cone_outer_angle: 360.,
155            cone_outer_gain: 0.,
156            audio_node_options: AudioNodeOptions {
157                channel_count: 2,
158                channel_count_mode: ChannelCountMode::ClampedMax,
159                channel_interpretation: ChannelInterpretation::Speakers,
160            },
161        }
162    }
163}
164
165enum ControlMessage {
166    DistanceModel(DistanceModelType),
167    // Box this payload - one large variant can penalize the memory layout of this enum
168    PanningModel(Box<Option<HrtfState>>),
169    RefDistance(f64),
170    MaxDistance(f64),
171    RollOffFactor(f64),
172    ConeInnerAngle(f64),
173    ConeOuterAngle(f64),
174    ConeOuterGain(f64),
175}
176
177/// Assert that the channel count is valid for the PannerNode
178/// see <https://webaudio.github.io/web-audio-api/#audionode-channelcount-constraints>
179///
180/// # Panics
181///
182/// This function panics if given count is greater than 2
183///
184#[track_caller]
185#[inline(always)]
186fn assert_valid_channel_count(count: usize) {
187    assert!(
188        count <= 2,
189        "NotSupportedError - PannerNode channel count cannot be greater than two"
190    );
191}
192
193/// Assert that the channel count is valid for the PannerNode
194/// see <https://webaudio.github.io/web-audio-api/#audionode-channelcountmode-constraints>
195///
196/// # Panics
197///
198/// This function panics if given count mode is [`ChannelCountMode::Max`]
199///
200#[track_caller]
201#[inline(always)]
202fn assert_valid_channel_count_mode(mode: ChannelCountMode) {
203    assert_ne!(
204        mode,
205        ChannelCountMode::Max,
206        "NotSupportedError - PannerNode channel count mode cannot be set to max"
207    );
208}
209
210/// Internal state of the HRTF renderer
211struct HrtfState {
212    len: usize,
213    processor: HrtfProcessor,
214    output_interleaved: Vec<(f32, f32)>,
215    prev_sample_vector: Vec3,
216    prev_left_samples: Vec<f32>,
217    prev_right_samples: Vec<f32>,
218    prev_distance_gain: f32,
219}
220
221impl HrtfState {
222    fn new(processor: HrtfProcessor, len: usize) -> Self {
223        Self {
224            len,
225            processor,
226            output_interleaved: vec![(0., 0.); RENDER_QUANTUM_SIZE],
227            prev_sample_vector: Vec3::new(0., 0., 1.),
228            prev_left_samples: vec![],  // will resize accordingly
229            prev_right_samples: vec![], // will resize accordingly
230            prev_distance_gain: 0.,
231        }
232    }
233
234    fn process(
235        &mut self,
236        source: &[f32],
237        new_distance_gain: f32,
238        projected_source: [f32; 3],
239    ) -> &[(f32, f32)] {
240        // reset state of output buffer
241        self.output_interleaved.fill((0., 0.));
242
243        let new_sample_vector = Vec3 {
244            x: projected_source[0],
245            z: projected_source[1],
246            y: projected_source[2],
247        };
248
249        let context = HrtfContext {
250            source,
251            output: &mut self.output_interleaved,
252            new_sample_vector,
253            prev_sample_vector: self.prev_sample_vector,
254            prev_left_samples: &mut self.prev_left_samples,
255            prev_right_samples: &mut self.prev_right_samples,
256            new_distance_gain,
257            prev_distance_gain: self.prev_distance_gain,
258        };
259
260        self.processor.process_samples(context);
261
262        self.prev_sample_vector = new_sample_vector;
263        self.prev_distance_gain = new_distance_gain;
264
265        &self.output_interleaved
266    }
267
268    fn tail_time_samples(&self) -> usize {
269        self.len
270    }
271}
272
273/// `PannerNode` positions / spatializes an incoming audio stream in three-dimensional space.
274///
275/// - MDN documentation: <https://developer.mozilla.org/en-US/docs/Web/API/PannerNode>
276/// - specification: <https://www.w3.org/TR/webaudio/#pannernode> and
277///   <https://www.w3.org/TR/webaudio/#Spatialization>
278/// - see also: [`BaseAudioContext::create_panner`]
279///
280/// # Usage
281/// ```no_run
282/// use web_audio_api::context::{BaseAudioContext, AudioContext};
283/// use web_audio_api::node::AudioNode;
284/// use web_audio_api::node::AudioScheduledSourceNode;
285///
286/// // Setup a new audio context
287/// let context = AudioContext::default();
288///
289/// // Create a friendly tone
290/// let mut tone = context.create_oscillator();
291/// tone.frequency().set_value_at_time(300.0f32, 0.);
292/// tone.start();
293///
294/// // Connect tone > panner node > destination node
295/// let panner = context.create_panner();
296/// tone.connect(&panner);
297/// panner.connect(&context.destination());
298///
299/// // The panner node is 1 unit in front of listener
300/// panner.position_z().set_value_at_time(1., 0.);
301///
302/// // And sweeps 10 units left to right, every second
303/// let mut moving = context.create_oscillator();
304/// moving.start();
305/// moving.frequency().set_value_at_time(1., 0.);
306/// let gain = context.create_gain();
307/// gain.gain().set_value_at_time(10., 0.);
308/// moving.connect(&gain);
309/// gain.connect(panner.position_x());
310///
311/// // enjoy listening
312/// std::thread::sleep(std::time::Duration::from_secs(4));
313/// ```
314///
315/// # Examples
316///
317/// - `cargo run --release --example spatial`
318/// - `cargo run --release --example panner_cone`
319#[derive(Debug)]
320pub struct PannerNode {
321    registration: AudioContextRegistration,
322    channel_config: ChannelConfig,
323    position_x: AudioParam,
324    position_y: AudioParam,
325    position_z: AudioParam,
326    orientation_x: AudioParam,
327    orientation_y: AudioParam,
328    orientation_z: AudioParam,
329    cone_inner_angle: f64,
330    cone_outer_angle: f64,
331    cone_outer_gain: f64,
332    distance_model: DistanceModelType,
333    ref_distance: f64,
334    max_distance: f64,
335    rolloff_factor: f64,
336    panning_model: PanningModelType,
337}
338
339impl AudioNode for PannerNode {
340    fn registration(&self) -> &AudioContextRegistration {
341        &self.registration
342    }
343
344    fn channel_config(&self) -> &ChannelConfig {
345        &self.channel_config
346    }
347
348    fn number_of_inputs(&self) -> usize {
349        1
350    }
351
352    fn number_of_outputs(&self) -> usize {
353        1
354    }
355
356    // same limitations as for the StereoPannerNode
357    // see: https://webaudio.github.io/web-audio-api/#panner-channel-limitations
358    fn set_channel_count(&self, count: usize) {
359        assert_valid_channel_count(count);
360        self.channel_config.set_count(count, self.registration());
361    }
362
363    fn set_channel_count_mode(&self, mode: ChannelCountMode) {
364        assert_valid_channel_count_mode(mode);
365        self.channel_config
366            .set_count_mode(mode, self.registration());
367    }
368}
369
370impl PannerNode {
371    /// returns a `PannerNode` instance
372    ///
373    /// # Arguments
374    ///
375    /// * `context` - audio context in which the audio node will live.
376    /// * `options` - stereo panner options
377    ///
378    /// # Panics
379    ///
380    /// Will panic if:
381    ///
382    /// * `options.channel_config.count` is greater than 2
383    /// * `options.channel_config.mode` is `ChannelCountMode::Max`
384    ///
385    /// Can panic when loading HRIR-sphere
386    #[allow(clippy::missing_panics_doc)]
387    pub fn new<C: BaseAudioContext>(context: &C, options: PannerOptions) -> Self {
388        let mut node = context.base().register(|registration| {
389            use crate::spatial::PARAM_OPTS;
390
391            let PannerOptions {
392                position_x,
393                position_y,
394                position_z,
395                orientation_x,
396                orientation_y,
397                orientation_z,
398                distance_model,
399                ref_distance,
400                max_distance,
401                rolloff_factor,
402                cone_inner_angle,
403                cone_outer_angle,
404                cone_outer_gain,
405                audio_node_options: channel_config,
406                panning_model,
407            } = options;
408
409            assert!(
410                ref_distance >= 0.,
411                "RangeError - refDistance cannot be negative"
412            );
413            assert!(
414                max_distance > 0.,
415                "RangeError - maxDistance must be strictly positive"
416            );
417            assert!(
418                rolloff_factor >= 0.,
419                "RangeError - rolloffFactor cannot be negative"
420            );
421            assert_valid_cone_outer_gain(cone_outer_gain);
422            assert_valid_channel_count(channel_config.channel_count);
423            assert_valid_channel_count_mode(channel_config.channel_count_mode);
424
425            // position params
426            let (param_px, render_px) = context.create_audio_param(PARAM_OPTS, &registration);
427            let (param_py, render_py) = context.create_audio_param(PARAM_OPTS, &registration);
428            let (param_pz, render_pz) = context.create_audio_param(PARAM_OPTS, &registration);
429            param_px.set_value(position_x);
430            param_py.set_value(position_y);
431            param_pz.set_value(position_z);
432
433            // orientation params
434            let orientation_x_opts = AudioParamDescriptor {
435                default_value: 1.0,
436                ..PARAM_OPTS
437            };
438            let (param_ox, render_ox) =
439                context.create_audio_param(orientation_x_opts, &registration);
440            let (param_oy, render_oy) = context.create_audio_param(PARAM_OPTS, &registration);
441            let (param_oz, render_oz) = context.create_audio_param(PARAM_OPTS, &registration);
442            param_ox.set_value(orientation_x);
443            param_oy.set_value(orientation_y);
444            param_oz.set_value(orientation_z);
445
446            let render = PannerRenderer {
447                position_x: render_px,
448                position_y: render_py,
449                position_z: render_pz,
450                orientation_x: render_ox,
451                orientation_y: render_oy,
452                orientation_z: render_oz,
453                distance_model,
454                ref_distance,
455                max_distance,
456                rolloff_factor,
457                cone_inner_angle,
458                cone_outer_angle,
459                cone_outer_gain,
460                hrtf_state: None,
461                tail_time_counter: 0,
462            };
463
464            let node = PannerNode {
465                registration,
466                channel_config: channel_config.into(),
467                position_x: param_px,
468                position_y: param_py,
469                position_z: param_pz,
470                orientation_x: param_ox,
471                orientation_y: param_oy,
472                orientation_z: param_oz,
473                distance_model,
474                ref_distance,
475                max_distance,
476                rolloff_factor,
477                cone_inner_angle,
478                cone_outer_angle,
479                cone_outer_gain,
480                panning_model,
481            };
482
483            // instruct to BaseContext to add the AudioListener if it has not already
484            context.base().ensure_audio_listener_present();
485
486            (node, Box::new(render))
487        });
488
489        // after the node is registered, connect the AudioListener
490        context
491            .base()
492            .connect_listener_to_panner(node.registration().id());
493
494        // load the HRTF sphere if requested
495        node.set_panning_model(options.panning_model);
496
497        node
498    }
499
500    pub fn position_x(&self) -> &AudioParam {
501        &self.position_x
502    }
503
504    pub fn position_y(&self) -> &AudioParam {
505        &self.position_y
506    }
507
508    pub fn position_z(&self) -> &AudioParam {
509        &self.position_z
510    }
511
512    pub fn set_position(&self, x: f32, y: f32, z: f32) {
513        self.position_x.set_value(x);
514        self.position_y.set_value(y);
515        self.position_z.set_value(z);
516    }
517
518    pub fn orientation_x(&self) -> &AudioParam {
519        &self.orientation_x
520    }
521
522    pub fn orientation_y(&self) -> &AudioParam {
523        &self.orientation_y
524    }
525
526    pub fn orientation_z(&self) -> &AudioParam {
527        &self.orientation_z
528    }
529
530    pub fn set_orientation(&self, x: f32, y: f32, z: f32) {
531        self.orientation_x.set_value(x);
532        self.orientation_y.set_value(y);
533        self.orientation_z.set_value(z);
534    }
535
536    pub fn distance_model(&self) -> DistanceModelType {
537        self.distance_model
538    }
539
540    pub fn set_distance_model(&mut self, value: DistanceModelType) {
541        self.distance_model = value;
542        self.registration
543            .post_message(ControlMessage::DistanceModel(value));
544    }
545
546    pub fn ref_distance(&self) -> f64 {
547        self.ref_distance
548    }
549
550    /// Set the refDistance attribute
551    ///
552    /// # Panics
553    ///
554    /// Panics if the provided value is negative.
555    pub fn set_ref_distance(&mut self, value: f64) {
556        assert!(value >= 0., "RangeError - refDistance cannot be negative");
557        self.ref_distance = value;
558        self.registration
559            .post_message(ControlMessage::RefDistance(value));
560    }
561
562    pub fn max_distance(&self) -> f64 {
563        self.max_distance
564    }
565
566    /// Set the maxDistance attribute
567    ///
568    /// # Panics
569    ///
570    /// Panics if the provided value is negative.
571    pub fn set_max_distance(&mut self, value: f64) {
572        assert!(
573            value > 0.,
574            "RangeError - maxDistance must be strictly positive"
575        );
576        self.max_distance = value;
577        self.registration
578            .post_message(ControlMessage::MaxDistance(value));
579    }
580
581    pub fn rolloff_factor(&self) -> f64 {
582        self.rolloff_factor
583    }
584
585    /// Set the rolloffFactor attribute
586    ///
587    /// # Panics
588    ///
589    /// Panics if the provided value is negative.
590    pub fn set_rolloff_factor(&mut self, value: f64) {
591        assert!(value >= 0., "RangeError - rolloffFactor cannot be negative");
592        self.rolloff_factor = value;
593        self.registration
594            .post_message(ControlMessage::RollOffFactor(value));
595    }
596
597    pub fn cone_inner_angle(&self) -> f64 {
598        self.cone_inner_angle
599    }
600
601    pub fn set_cone_inner_angle(&mut self, value: f64) {
602        self.cone_inner_angle = value;
603        self.registration
604            .post_message(ControlMessage::ConeInnerAngle(value));
605    }
606
607    pub fn cone_outer_angle(&self) -> f64 {
608        self.cone_outer_angle
609    }
610
611    pub fn set_cone_outer_angle(&mut self, value: f64) {
612        self.cone_outer_angle = value;
613        self.registration
614            .post_message(ControlMessage::ConeOuterAngle(value));
615    }
616
617    pub fn cone_outer_gain(&self) -> f64 {
618        self.cone_outer_gain
619    }
620
621    /// Set the coneOuterGain attribute
622    ///
623    /// # Panics
624    ///
625    /// Panics if the provided value is not in the range [0, 1]
626    pub fn set_cone_outer_gain(&mut self, value: f64) {
627        assert_valid_cone_outer_gain(value);
628        self.cone_outer_gain = value;
629        self.registration
630            .post_message(ControlMessage::ConeOuterGain(value));
631    }
632
633    pub fn panning_model(&self) -> PanningModelType {
634        self.panning_model
635    }
636
637    #[allow(clippy::missing_panics_doc)] // loading the provided HRTF will not panic
638    pub fn set_panning_model(&mut self, value: PanningModelType) {
639        let hrtf_option = match value {
640            PanningModelType::EqualPower => None,
641            PanningModelType::HRTF => {
642                let sample_rate = self.context().sample_rate() as u32;
643                let (processor, len) = load_hrtf_processor(sample_rate);
644                Some(HrtfState::new(processor, len))
645            }
646        };
647
648        self.panning_model = value;
649        self.registration
650            .post_message(ControlMessage::PanningModel(Box::new(hrtf_option)));
651    }
652}
653
654#[derive(Copy, Clone)]
655struct SpatialParams {
656    dist_gain: f32,
657    cone_gain: f32,
658    azimuth: f32,
659    elevation: f32,
660}
661
662struct PannerRenderer {
663    position_x: AudioParamId,
664    position_y: AudioParamId,
665    position_z: AudioParamId,
666    orientation_x: AudioParamId,
667    orientation_y: AudioParamId,
668    orientation_z: AudioParamId,
669    distance_model: DistanceModelType,
670    ref_distance: f64,
671    max_distance: f64,
672    rolloff_factor: f64,
673    cone_inner_angle: f64,
674    cone_outer_angle: f64,
675    cone_outer_gain: f64,
676    hrtf_state: Option<HrtfState>, // use EqualPower panning model if `None`
677    tail_time_counter: usize,
678}
679
680impl AudioProcessor for PannerRenderer {
681    fn process(
682        &mut self,
683        inputs: &[AudioRenderQuantum],
684        outputs: &mut [AudioRenderQuantum],
685        params: AudioParamValues<'_>,
686        _scope: &AudioWorkletGlobalScope,
687    ) -> bool {
688        // Single input/output node
689        let input = &inputs[0];
690        let output = &mut outputs[0];
691
692        // early exit for silence
693        if input.is_silent() {
694            // HRTF panner has tail time equal to the max length of the impulse response buffers
695            // (12 ms)
696            let tail_time = match &self.hrtf_state {
697                None => false,
698                Some(hrtf_state) => hrtf_state.tail_time_samples() > self.tail_time_counter,
699            };
700            if !tail_time {
701                output.make_silent();
702                return false;
703            }
704
705            self.tail_time_counter += RENDER_QUANTUM_SIZE;
706        }
707
708        // for borrow reasons, take the hrtf_state out of self
709        let mut hrtf_state = self.hrtf_state.take();
710
711        // source parameters (Panner)
712        let source_position_x = params.get(&self.position_x);
713        let source_position_y = params.get(&self.position_y);
714        let source_position_z = params.get(&self.position_z);
715        let source_orientation_x = params.get(&self.orientation_x);
716        let source_orientation_y = params.get(&self.orientation_y);
717        let source_orientation_z = params.get(&self.orientation_z);
718
719        // listener parameters (AudioListener)
720        let [listener_position_x, listener_position_y, listener_position_z, listener_forward_x, listener_forward_y, listener_forward_z, listener_up_x, listener_up_y, listener_up_z] =
721            params.listener_params();
722
723        // build up the a-rate iterator for spatial variables
724        let mut a_rate_params = source_position_x
725            .iter()
726            .cycle()
727            .zip(source_position_y.iter().cycle())
728            .zip(source_position_z.iter().cycle())
729            .zip(source_orientation_x.iter().cycle())
730            .zip(source_orientation_y.iter().cycle())
731            .zip(source_orientation_z.iter().cycle())
732            .zip(listener_position_x.iter().cycle())
733            .zip(listener_position_y.iter().cycle())
734            .zip(listener_position_z.iter().cycle())
735            .zip(listener_forward_x.iter().cycle())
736            .zip(listener_forward_y.iter().cycle())
737            .zip(listener_forward_z.iter().cycle())
738            .zip(listener_up_x.iter().cycle())
739            .zip(listener_up_y.iter().cycle())
740            .zip(listener_up_z.iter().cycle())
741            .map(|tuple| {
742                // unpack giant tuple
743                let ((((((sp_so_lp, lfx), lfy), lfz), lux), luy), luz) = tuple;
744                let (((sp_so, lpx), lpy), lpz) = sp_so_lp;
745                let (((sp, sox), soy), soz) = sp_so;
746                let ((spx, spy), spz) = sp;
747
748                // define base vectors in 3D
749                let source_position = [*spx, *spy, *spz];
750                let source_orientation = [*sox, *soy, *soz];
751                let listener_position = [*lpx, *lpy, *lpz];
752                let listener_forward = [*lfx, *lfy, *lfz];
753                let listener_up = [*lux, *luy, *luz];
754
755                // determine distance and cone gain
756                let dist_gain = self.dist_gain(source_position, listener_position);
757                let cone_gain =
758                    self.cone_gain(source_position, source_orientation, listener_position);
759
760                // azimuth and elevation of the panner in frame of reference of the listener
761                let (azimuth, elevation) = crate::spatial::azimuth_and_elevation(
762                    source_position,
763                    listener_position,
764                    listener_forward,
765                    listener_up,
766                );
767
768                SpatialParams {
769                    dist_gain,
770                    cone_gain,
771                    azimuth,
772                    elevation,
773                }
774            });
775
776        if let Some(hrtf_state) = &mut hrtf_state {
777            // HRTF panning - always k-rate so take a single value from the a-rate iter
778            let SpatialParams {
779                dist_gain,
780                cone_gain,
781                azimuth,
782                elevation,
783            } = a_rate_params.next().unwrap();
784
785            let new_distance_gain = cone_gain * dist_gain;
786
787            // convert az/el to cartesian coordinates to determine unit direction
788            let az_rad = azimuth * PI / 180.;
789            let el_rad = elevation * PI / 180.;
790            let x = az_rad.sin() * el_rad.cos();
791            let z = az_rad.cos() * el_rad.cos();
792            let y = el_rad.sin();
793            let mut projected_source = [x, y, z];
794
795            if float_eq!(&projected_source[..], &[0.; 3][..], abs_all <= 1E-6) {
796                projected_source = [0., 0., 1.];
797            }
798
799            // Currently, only mono-to-stereo panning is supported (todo issue #241).
800            // Stereo-to-stereo is typically implemented by using 2 HRTF-kernels, feeding each
801            // channels into their respective kernel, and summing the result per ear.  This will
802            // usually double the output volume as compared to mono-to-stereo.  Hence we double
803            // the input signal for stereo inputs to correct for our lack of implementation.
804            *output = input.clone();
805            let mut overall_gain_correction = 1.;
806            if output.number_of_channels() == 2 {
807                overall_gain_correction *= 2.; // stereo-to-stereo panning typically doubles volume
808                output.mix(1, ChannelInterpretation::Speakers);
809            }
810
811            let output_interleaved =
812                hrtf_state.process(output.channel_data(0), new_distance_gain, projected_source);
813
814            output.set_number_of_channels(2);
815            let [left, right] = output.stereo_mut();
816
817            output_interleaved
818                .iter()
819                .zip(&mut left[..])
820                .zip(&mut right[..])
821                .for_each(|((p, l), r)| {
822                    *l = overall_gain_correction * p.0;
823                    *r = overall_gain_correction * p.1;
824                });
825        } else {
826            // EqualPower panning
827
828            // Optimize for static Panner & Listener
829            let single_valued = source_position_x.len() == 1
830                && source_position_y.len() == 1
831                && source_position_z.len() == 1
832                && source_orientation_x.len() == 1
833                && source_orientation_y.len() == 1
834                && source_orientation_z.len() == 1
835                && listener_position_x.len() == 1
836                && listener_position_y.len() == 1
837                && listener_position_z.len() == 1
838                && listener_forward_x.len() == 1
839                && listener_forward_y.len() == 1
840                && listener_forward_z.len() == 1
841                && listener_up_x.len() == 1
842                && listener_up_y.len() == 1
843                && listener_up_z.len() == 1;
844
845            if single_valued {
846                let param_value = a_rate_params.next().unwrap();
847                match input.number_of_channels() {
848                    1 => {
849                        *output = input.clone();
850                        output.mix(2, ChannelInterpretation::Speakers);
851                        let [left, right] = output.stereo_mut();
852                        left.iter_mut()
853                            .zip(&mut right[..])
854                            .for_each(|(l, r)| apply_mono_to_stereo_gain(param_value, l, r));
855                    }
856                    2 => {
857                        output.set_number_of_channels(2);
858                        let [left, right] = output.stereo_mut();
859                        input
860                            .channel_data(0)
861                            .iter()
862                            .copied()
863                            .zip(input.channel_data(1).iter().copied())
864                            .zip(&mut left[..])
865                            .zip(&mut right[..])
866                            .for_each(|(((il, ir), ol), or)| {
867                                apply_stereo_to_stereo_gain(param_value, il, ir, ol, or)
868                            });
869                    }
870                    _ => unreachable!(),
871                }
872            } else {
873                match input.number_of_channels() {
874                    1 => {
875                        *output = input.clone();
876                        output.mix(2, ChannelInterpretation::Speakers);
877                        let [left, right] = output.stereo_mut();
878                        a_rate_params
879                            .zip(&mut left[..])
880                            .zip(&mut right[..])
881                            .for_each(|((p, l), r)| apply_mono_to_stereo_gain(p, l, r));
882                    }
883                    2 => {
884                        output.set_number_of_channels(2);
885                        let [left, right] = output.stereo_mut();
886                        a_rate_params
887                            .zip(input.channel_data(0).iter().copied())
888                            .zip(input.channel_data(1).iter().copied())
889                            .zip(&mut left[..])
890                            .zip(&mut right[..])
891                            .for_each(|((((p, il), ir), ol), or)| {
892                                apply_stereo_to_stereo_gain(p, il, ir, ol, or)
893                            });
894                    }
895                    _ => unreachable!(),
896                }
897            }
898        }
899
900        // put the hrtf_state back into self (borrow reasons)
901        self.hrtf_state = hrtf_state;
902
903        // tail time only for HRTF panning
904        self.hrtf_state.is_some()
905    }
906
907    fn onmessage(&mut self, msg: &mut dyn Any) {
908        if let Some(control) = msg.downcast_mut::<ControlMessage>() {
909            match control {
910                ControlMessage::DistanceModel(value) => self.distance_model = *value,
911                ControlMessage::RefDistance(value) => self.ref_distance = *value,
912                ControlMessage::MaxDistance(value) => self.max_distance = *value,
913                ControlMessage::RollOffFactor(value) => self.rolloff_factor = *value,
914                ControlMessage::ConeInnerAngle(value) => self.cone_inner_angle = *value,
915                ControlMessage::ConeOuterAngle(value) => self.cone_outer_angle = *value,
916                ControlMessage::ConeOuterGain(value) => self.cone_outer_gain = *value,
917                ControlMessage::PanningModel(value) => self.hrtf_state = value.take(),
918            }
919
920            return;
921        }
922
923        log::warn!("PannerRenderer: Dropping incoming message {msg:?}");
924    }
925}
926
927impl PannerRenderer {
928    fn cone_gain(
929        &self,
930        source_position: [f32; 3],
931        source_orientation: [f32; 3],
932        listener_position: [f32; 3],
933    ) -> f32 {
934        let abs_inner_angle = self.cone_inner_angle.abs() as f32 / 2.;
935        let abs_outer_angle = self.cone_outer_angle.abs() as f32 / 2.;
936        if abs_inner_angle >= 180. && abs_outer_angle >= 180. {
937            1. // no cone specified
938        } else {
939            let cone_outer_gain = self.cone_outer_gain as f32;
940
941            let abs_angle =
942                crate::spatial::angle(source_position, source_orientation, listener_position);
943
944            if abs_angle < abs_inner_angle {
945                1. // No attenuation
946            } else if abs_angle >= abs_outer_angle {
947                cone_outer_gain // Max attenuation
948            } else {
949                // Between inner and outer cones: inner -> outer, x goes from 0 -> 1
950                let x = (abs_angle - abs_inner_angle) / (abs_outer_angle - abs_inner_angle);
951                (1. - x) + cone_outer_gain * x
952            }
953        }
954    }
955
956    fn dist_gain(&self, source_position: [f32; 3], listener_position: [f32; 3]) -> f32 {
957        let distance_model = self.distance_model;
958        let ref_distance = self.ref_distance;
959        let distance = crate::spatial::distance(source_position, listener_position) as f64;
960
961        let dist_gain = match distance_model {
962            DistanceModelType::Linear => {
963                let rolloff_factor = self.rolloff_factor.clamp(0., 1.);
964                let max_distance = self.max_distance;
965                let d2ref = ref_distance.min(max_distance);
966                let d2max = ref_distance.max(max_distance);
967                let d_clamped = distance.clamp(d2ref, d2max);
968                1. - rolloff_factor * (d_clamped - d2ref) / (d2max - d2ref)
969            }
970            DistanceModelType::Inverse => {
971                let rolloff_factor = self.rolloff_factor.max(0.);
972                if distance > 0. {
973                    ref_distance
974                        / (ref_distance
975                            + rolloff_factor * (ref_distance.max(distance) - ref_distance))
976                } else {
977                    1.
978                }
979            }
980            DistanceModelType::Exponential => {
981                let rolloff_factor = self.rolloff_factor.max(0.);
982                (distance.max(ref_distance) / ref_distance).powf(-rolloff_factor)
983            }
984        };
985        dist_gain as f32
986    }
987}
988
989fn apply_mono_to_stereo_gain(spatial_params: SpatialParams, l: &mut f32, r: &mut f32) {
990    let SpatialParams {
991        dist_gain,
992        cone_gain,
993        azimuth,
994        ..
995    } = spatial_params;
996
997    // Determine left/right ear gain. Clamp azimuth to range of [-180, 180].
998    let mut azimuth = azimuth.clamp(-180., 180.);
999
1000    // Then wrap to range [-90, 90].
1001    if azimuth < -90. {
1002        azimuth = -180. - azimuth;
1003    } else if azimuth > 90. {
1004        azimuth = 180. - azimuth;
1005    }
1006
1007    // x is the horizontal plane orientation of the sound
1008    let x = (azimuth + 90.) / 180.;
1009    let gain_l = (x * PI / 2.).cos();
1010    let gain_r = (x * PI / 2.).sin();
1011
1012    // multiply signal with gain per ear
1013    *l *= gain_l * dist_gain * cone_gain;
1014    *r *= gain_r * dist_gain * cone_gain;
1015}
1016
1017fn apply_stereo_to_stereo_gain(
1018    spatial_params: SpatialParams,
1019    il: f32,
1020    ir: f32,
1021    ol: &mut f32,
1022    or: &mut f32,
1023) {
1024    let SpatialParams {
1025        dist_gain,
1026        cone_gain,
1027        azimuth,
1028        ..
1029    } = spatial_params;
1030
1031    // Determine left/right ear gain. Clamp azimuth to range of [-180, 180].
1032    let mut azimuth = azimuth.clamp(-180., 180.);
1033
1034    // Then wrap to range [-90, 90].
1035    if azimuth < -90. {
1036        azimuth = -180. - azimuth;
1037    } else if azimuth > 90. {
1038        azimuth = 180. - azimuth;
1039    }
1040
1041    // x is the horizontal plane orientation of the sound
1042    let x = if azimuth <= 0. {
1043        (azimuth + 90.) / 90.
1044    } else {
1045        azimuth / 90.
1046    };
1047    let gain_l = (x * PI / 2.).cos();
1048    let gain_r = (x * PI / 2.).sin();
1049
1050    // multiply signal with gain per ear
1051    if azimuth <= 0. {
1052        *ol = (il + ir * gain_l) * dist_gain * cone_gain;
1053        *or = ir * gain_r * dist_gain * cone_gain;
1054    } else {
1055        *ol = il * gain_l * dist_gain * cone_gain;
1056        *or = (ir + il * gain_r) * dist_gain * cone_gain;
1057    }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062    use float_eq::{assert_float_eq, assert_float_ne};
1063
1064    use crate::context::{BaseAudioContext, OfflineAudioContext};
1065    use crate::node::{AudioBufferSourceNode, AudioBufferSourceOptions, AudioScheduledSourceNode};
1066    use crate::AudioBuffer;
1067
1068    use super::*;
1069
1070    #[test]
1071    fn test_audioparam_value_applies_immediately() {
1072        let context = OfflineAudioContext::new(1, 128, 48000.);
1073        let options = PannerOptions {
1074            position_x: 12.,
1075            ..Default::default()
1076        };
1077        let src = PannerNode::new(&context, options);
1078        assert_float_eq!(src.position_x.value(), 12., abs_all <= 0.);
1079    }
1080
1081    #[test]
1082    fn test_equal_power_mono_to_stereo() {
1083        let sample_rate = 44100.;
1084        let length = RENDER_QUANTUM_SIZE * 4;
1085        let mut context = OfflineAudioContext::new(2, length, sample_rate);
1086
1087        // 128 input samples of value 1.
1088        let input = AudioBuffer::from(vec![vec![1.; RENDER_QUANTUM_SIZE]], sample_rate);
1089        let mut src = AudioBufferSourceNode::new(&context, AudioBufferSourceOptions::default());
1090        src.set_buffer(input);
1091        src.start();
1092
1093        let options = PannerOptions {
1094            panning_model: PanningModelType::EqualPower,
1095            ..PannerOptions::default()
1096        };
1097        let panner = PannerNode::new(&context, options);
1098        assert_eq!(panner.panning_model(), PanningModelType::EqualPower);
1099        panner.set_channel_count(1);
1100        panner.position_x().set_value(1.); // sound comes from the right
1101
1102        src.connect(&panner);
1103        panner.connect(&context.destination());
1104
1105        let output = context.start_rendering_sync();
1106        let original = vec![1.; RENDER_QUANTUM_SIZE];
1107        let zero = vec![0.; RENDER_QUANTUM_SIZE];
1108
1109        // assert first quantum fully panned to the right
1110        assert_float_eq!(
1111            output.get_channel_data(0)[..128],
1112            &zero[..],
1113            abs_all <= 1E-6
1114        );
1115        assert_float_eq!(
1116            output.get_channel_data(1)[..128],
1117            &original[..],
1118            abs_all <= 1E-6
1119        );
1120
1121        // assert no tail-time
1122        assert_float_eq!(
1123            output.get_channel_data(0)[128..256],
1124            &zero[..],
1125            abs_all <= 1E-6
1126        );
1127        assert_float_eq!(
1128            output.get_channel_data(1)[128..256],
1129            &zero[..],
1130            abs_all <= 1E-6
1131        );
1132    }
1133
1134    #[test]
1135    fn test_equal_power_azimuth_mono_to_stereo() {
1136        let sample_rate = 44100.;
1137        let length = RENDER_QUANTUM_SIZE;
1138        let mut context = OfflineAudioContext::new(2, length, sample_rate);
1139
1140        // 128 input samples of value 1.
1141        let input = AudioBuffer::from(vec![vec![1.; RENDER_QUANTUM_SIZE]], sample_rate);
1142        let mut src = AudioBufferSourceNode::new(&context, AudioBufferSourceOptions::default());
1143        src.set_buffer(input);
1144        src.start();
1145
1146        let options = PannerOptions {
1147            panning_model: PanningModelType::EqualPower,
1148            ..PannerOptions::default()
1149        };
1150        let panner = PannerNode::new(&context, options);
1151        assert_eq!(panner.panning_model(), PanningModelType::EqualPower);
1152        panner.position_y().set_value(1.); // sound comes from above
1153
1154        src.connect(&panner);
1155        panner.connect(&context.destination());
1156
1157        let output = context.start_rendering_sync();
1158        let sqrt2 = vec![(1.0f32 / 2.).sqrt(); RENDER_QUANTUM_SIZE];
1159
1160        // assert both ears receive equal volume
1161        assert_float_eq!(
1162            output.get_channel_data(0)[..128],
1163            &sqrt2[..],
1164            abs_all <= 1E-6
1165        );
1166        assert_float_eq!(
1167            output.get_channel_data(1)[..128],
1168            &sqrt2[..],
1169            abs_all <= 1E-6
1170        );
1171    }
1172
1173    #[test]
1174    fn test_equal_power_stereo_to_stereo() {
1175        let sample_rate = 44100.;
1176        let length = RENDER_QUANTUM_SIZE;
1177        let mut context = OfflineAudioContext::new(2, length, sample_rate);
1178
1179        // put listener at (10, 0, 0), directed at (1, 0, 0)
1180        let listener = context.listener();
1181        listener.position_x().set_value(10.);
1182        listener.position_y().set_value(0.);
1183        listener.position_z().set_value(0.);
1184        listener.forward_x().set_value(1.);
1185        listener.forward_y().set_value(0.);
1186        listener.forward_z().set_value(0.);
1187        listener.up_x().set_value(0.);
1188        listener.up_y().set_value(0.);
1189        listener.up_z().set_value(1.);
1190
1191        // 128 input samples of value 1, stereo
1192        let input = AudioBuffer::from(
1193            vec![vec![1.; RENDER_QUANTUM_SIZE], vec![1.; RENDER_QUANTUM_SIZE]],
1194            sample_rate,
1195        );
1196        let mut src = AudioBufferSourceNode::new(&context, AudioBufferSourceOptions::default());
1197        src.set_buffer(input);
1198        src.start();
1199
1200        // add panner at (10, 10, 0) - no cone/direction
1201        let panner = context.create_panner();
1202        panner.position_x().set_value(10.);
1203        panner.position_y().set_value(10.);
1204        panner.position_z().set_value(0.);
1205
1206        src.connect(&panner);
1207        panner.connect(&context.destination());
1208
1209        let output = context.start_rendering_sync();
1210
1211        // left channel should full signal (both channels summed) = 2.,
1212        // but distance = 10 so times 0.1
1213        assert_float_eq!(
1214            output.get_channel_data(0)[..RENDER_QUANTUM_SIZE],
1215            &[0.2; RENDER_QUANTUM_SIZE][..],
1216            abs_all <= 0.001
1217        );
1218        // right channel should silent
1219        assert_float_eq!(
1220            output.get_channel_data(1)[..RENDER_QUANTUM_SIZE],
1221            &[0.; RENDER_QUANTUM_SIZE][..],
1222            abs_all <= 0.001
1223        );
1224    }
1225
1226    #[test]
1227    fn test_hrtf() {
1228        let sample_rate = 44100.;
1229        let length = RENDER_QUANTUM_SIZE * 4;
1230        let mut context = OfflineAudioContext::new(2, length, sample_rate);
1231
1232        // 128 input samples of value 1.
1233        let input = AudioBuffer::from(vec![vec![1.; RENDER_QUANTUM_SIZE]], sample_rate);
1234        let mut src = AudioBufferSourceNode::new(&context, AudioBufferSourceOptions::default());
1235        src.set_buffer(input);
1236        src.start();
1237
1238        let options = PannerOptions {
1239            panning_model: PanningModelType::HRTF,
1240            ..PannerOptions::default()
1241        };
1242        let panner = PannerNode::new(&context, options);
1243        assert_eq!(panner.panning_model(), PanningModelType::HRTF);
1244        panner.position_x().set_value(1.); // sound comes from the right
1245
1246        src.connect(&panner);
1247        panner.connect(&context.destination());
1248
1249        let output = context.start_rendering_sync();
1250        let original = vec![1.; RENDER_QUANTUM_SIZE];
1251
1252        // assert first quantum not equal to input buffer (both left and right)
1253        assert_float_ne!(
1254            output.get_channel_data(0)[..128],
1255            &original[..],
1256            abs_all <= 1E-6
1257        );
1258        assert_float_ne!(
1259            output.get_channel_data(1)[..128],
1260            &original[..],
1261            abs_all <= 1E-6
1262        );
1263
1264        // assert some samples non-zero in the tail time
1265        let left = output.channel_data(0).as_slice();
1266        assert!(left[128..256].iter().any(|v| *v >= 1E-6));
1267
1268        let right = output.channel_data(1).as_slice();
1269        assert!(right[128..256].iter().any(|v| *v >= 1E-6));
1270    }
1271
1272    #[test]
1273    fn test_hrtf_loads_at_minimum_sample_rate() {
1274        let (_processor, len) = load_hrtf_processor(crate::MIN_SAMPLE_RATE as u32);
1275
1276        assert!(len > 0);
1277        assert!(
1278            len < 512,
1279            "minimum-rate HRTF should use the resampled HRIR length, got {len}"
1280        );
1281    }
1282
1283    #[test]
1284    fn test_hrtf_renders_at_minimum_sample_rate() {
1285        let sample_rate = crate::MIN_SAMPLE_RATE;
1286        let length = RENDER_QUANTUM_SIZE * 4;
1287        let mut context = OfflineAudioContext::new(2, length, sample_rate);
1288
1289        let input = AudioBuffer::from(vec![vec![1.; RENDER_QUANTUM_SIZE]], sample_rate);
1290        let mut src = AudioBufferSourceNode::new(&context, AudioBufferSourceOptions::default());
1291        src.set_buffer(input);
1292        src.start();
1293
1294        let options = PannerOptions {
1295            panning_model: PanningModelType::HRTF,
1296            ..PannerOptions::default()
1297        };
1298        let panner = PannerNode::new(&context, options);
1299        panner.position_x().set_value(1.);
1300
1301        src.connect(&panner);
1302        panner.connect(&context.destination());
1303
1304        let output = context.start_rendering_sync();
1305        let left = output.channel_data(0).as_slice();
1306        let right = output.channel_data(1).as_slice();
1307
1308        assert!(left.iter().all(|v| v.is_finite()));
1309        assert!(right.iter().all(|v| v.is_finite()));
1310        assert!(left.iter().any(|v| *v != 0.));
1311        assert!(right.iter().any(|v| *v != 0.));
1312    }
1313
1314    #[test]
1315    fn test_arate_position_automation_varies_within_quantum() {
1316        // Regression test for when `single_valued` method only inspected the
1317        // nine *listener* parameter buffers and ignored the panner's own
1318        // position/orientation params. A static listener is the common case,
1319        // so the check almost always passed and any automation on the panner's
1320        // positionX/Y/Z (a-rate per spec) was silently evaluated once per
1321        // quantum, i.e. degraded to k-rate: the output moves in 128-frame
1322        // stair steps instead of per-sample.
1323        let mut context = OfflineAudioContext::new(2, 256, 48000.);
1324
1325        let mut src = context.create_constant_source();
1326        src.offset().set_value(1.);
1327        src.start();
1328
1329        let panner = context.create_panner();
1330        // Sweep the source across the listener within the very first quantum.
1331        panner.position_x().set_value_at_time(-10., 0.);
1332        panner
1333            .position_x()
1334            .linear_ramp_to_value_at_time(10., 128. / 48000.);
1335
1336        src.connect(&panner);
1337        panner.connect(&context.destination());
1338
1339        let result = context.start_rendering_sync();
1340        let left = result.get_channel_data(0);
1341
1342        // With per-sample (a-rate) evaluation the left-channel gain must vary
1343        // inside the first quantum; the k-rate degradation renders the whole
1344        // quantum with the frame-0 position and the samples are all equal.
1345        let varies = left.windows(2).take(127).any(|w| w[0] != w[1]);
1346        assert!(
1347            varies,
1348            "position automation was evaluated once per quantum (k-rate degradation)"
1349        );
1350    }
1351}