Skip to main content

fyrox_sound/renderer/
hrtf.rs

1// Copyright (c) 2019-present Dmitry Stepanov and Fyrox Engine contributors.
2//
3// Permission is hereby granted, free of charge, to any person obtaining a copy
4// of this software and associated documentation files (the "Software"), to deal
5// in the Software without restriction, including without limitation the rights
6// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7// copies of the Software, and to permit persons to whom the Software is
8// furnished to do so, subject to the following conditions:
9//
10// The above copyright notice and this permission notice shall be included in all
11// copies or substantial portions of the Software.
12//
13// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19// SOFTWARE.
20
21//! Head-Related Transfer Function (HRTF) module. Provides all needed types and methods for HRTF rendering.
22//!
23//! # Overview
24//!
25//! HRTF stands for [Head-Related Transfer Function](https://en.wikipedia.org/wiki/Head-related_transfer_function)
26//! and can work only with spatial sounds. For each of such sound source after it was processed by HRTF you can
27//! definitely tell from which locationsound came from. In other words HRTF improves perception of sound to
28//! the level of real life.
29//!
30//! # HRIR Spheres
31//!
32//! This library uses Head-Related Impulse Response (HRIR) spheres to create HRTF spheres. HRTF sphere is a set of
33//! points in 3D space which are connected into a mesh forming triangulated sphere. Each point contains spectrum
34//! for left and right ears which will be used to modify samples from each spatial sound source to create binaural
35//! sound. HRIR spheres can be found [here](https://github.com/mrDIMAS/hrir_sphere_builder/tree/master/hrtf_base/IRCAM)
36//!
37//! # Usage
38//!
39//! To use HRTF you need to change default renderer to HRTF renderer like so:
40//!
41//! ```no_run
42//! use fyrox_sound::context::{self, SoundContext};
43//! use fyrox_sound::renderer::hrtf::{HrirSphereResource, HrirSphereResourceExt, HrtfRenderer};
44//! use fyrox_sound::renderer::Renderer;
45//! use std::path::{Path, PathBuf};
46//! use hrtf::HrirSphere;
47//! use fyrox_resource::untyped::ResourceKind;
48//!
49//! fn use_hrtf(context: &mut SoundContext) {
50//!     // IRC_1002_C.bin is HRIR sphere in binary format, can be any valid HRIR sphere
51//!     // from base mentioned above.
52//!     let hrir_path = PathBuf::from("examples/data/IRC_1002_C.bin");
53//!     let hrir_sphere = HrirSphere::from_file(&hrir_path, context::SAMPLE_RATE).unwrap();
54//!
55//!     context.state().set_renderer(Renderer::HrtfRenderer(HrtfRenderer::new(HrirSphereResource::from_hrir_sphere(hrir_sphere, ResourceKind::Embedded))));
56//! }
57//! ```
58//!
59//! # Performance
60//!
61//! HRTF is `heavy`. Usually it 4-5 slower than default renderer, this is essential because HRTF requires some heavy
62//! math (fast Fourier transform, convolution, etc.). On Ryzen 1700 it takes 400-450 μs (0.4 - 0.45 ms) per source.
63//! In most cases this is ok, engine works in separate thread and it has around 100 ms to prepare new portion of
64//! samples for output device.
65//!
66//! # Known problems
67//!
68//! This renderer still suffers from small audible clicks in very fast moving sounds, clicks sounds more like
69//! "buzzing" - it is due the fact that hrtf is different from frame to frame which gives "bumps" in amplitude
70//! of signal because of phase shift each impulse response have. This can be fixed by short cross fade between
71//! small amount of samples from previous frame with same amount of frames of current as proposed in
72//! [here](http://csoundjournal.com/issue9/newHRTFOpcodes.html)
73//!
74//! Clicks can be reproduced by using clean sine wave of 440 Hz on some source moving around listener.
75
76use crate::{
77    context::{self, DistanceModel, SoundContext},
78    listener::Listener,
79    renderer::render_source_2d_only,
80    source::SoundSource,
81};
82use fyrox_core::{
83    log::Log,
84    reflect::prelude::*,
85    uuid::{uuid, Uuid},
86    visitor::{Visit, VisitResult, Visitor},
87    TypeUuidProvider,
88};
89use fyrox_resource::untyped::ResourceKind;
90use fyrox_resource::{
91    io::ResourceIo,
92    loader::{BoxedLoaderFuture, LoaderPayload, ResourceLoader},
93    state::LoadError,
94    Resource, ResourceData,
95};
96use hrtf::HrirSphere;
97use std::{error::Error, ops::Deref};
98use std::{fmt::Debug, fmt::Formatter, path::PathBuf, sync::Arc};
99use std::{fmt::Display, path::Path};
100
101/// An error that occurs during HRIR sphere loading.
102pub struct HrtfError(pub hrtf::HrtfError);
103
104impl std::error::Error for HrtfError {}
105
106impl From<hrtf::HrtfError> for HrtfError {
107    fn from(value: hrtf::HrtfError) -> Self {
108        Self(value)
109    }
110}
111
112impl From<HrtfError> for hrtf::HrtfError {
113    fn from(value: HrtfError) -> Self {
114        value.0
115    }
116}
117
118impl Deref for HrtfError {
119    type Target = hrtf::HrtfError;
120
121    fn deref(&self) -> &Self::Target {
122        &self.0
123    }
124}
125
126impl Debug for HrtfError {
127    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
128        Debug::fmt(&self.0, f)
129    }
130}
131
132impl Display for HrtfError {
133    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
134        match &self.0 {
135            hrtf::HrtfError::IoError(error) => Display::fmt(error, f),
136            hrtf::HrtfError::InvalidFileFormat => f.write_str("Invalid file format"),
137            hrtf::HrtfError::InvalidLength(n) => write!(f, "Invalid length {n}"),
138        }
139    }
140}
141
142/// See module docs.
143#[derive(Clone, Debug, Default, Reflect)]
144pub struct HrtfRenderer {
145    hrir_resource: Option<HrirSphereResource>,
146    #[reflect(hidden)]
147    processor: Option<hrtf::HrtfProcessor>,
148}
149
150impl Visit for HrtfRenderer {
151    fn visit(&mut self, name: &str, visitor: &mut Visitor) -> VisitResult {
152        let mut region = visitor.enter_region(name)?;
153
154        Log::verify(self.hrir_resource.visit("HrirResource", &mut region));
155
156        Ok(())
157    }
158}
159
160impl HrtfRenderer {
161    /// Creates new HRTF renderer using specified HRTF sphere. See module docs for more info.
162    pub fn new(hrir_sphere_resource: HrirSphereResource) -> Self {
163        Self {
164            processor: Some(hrtf::HrtfProcessor::new(
165                {
166                    let sphere = hrir_sphere_resource.data_ref().hrir_sphere.clone().unwrap();
167                    sphere
168                },
169                SoundContext::HRTF_INTERPOLATION_STEPS,
170                SoundContext::HRTF_BLOCK_LEN,
171            )),
172            hrir_resource: Some(hrir_sphere_resource),
173        }
174    }
175
176    /// Sets a desired HRIR sphere resource. Current state of the renderer will be reset and then it will be recreated
177    /// on the next render call only if the resource is fully loaded.
178    pub fn set_hrir_sphere_resource(&mut self, resource: Option<HrirSphereResource>) {
179        self.hrir_resource = resource;
180        self.processor = None;
181    }
182
183    /// Returns current HRIR sphere resource (if any).
184    pub fn hrir_sphere_resource(&self) -> Option<HrirSphereResource> {
185        self.hrir_resource.clone()
186    }
187
188    pub(crate) fn render_source(
189        &mut self,
190        source: &mut SoundSource,
191        listener: &Listener,
192        distance_model: DistanceModel,
193        out_buf: &mut [(f32, f32)],
194    ) {
195        // Re-create HRTF processor on the fly only when a respective HRIR sphere resource is fully loaded.
196        // This is a poor-man's async support for crippled OSes such as WebAssembly.
197        if self.processor.is_none() {
198            if let Some(resource) = self.hrir_resource.as_ref() {
199                let mut header = resource.state();
200                if let Some(hrir) = header.data() {
201                    self.processor = Some(hrtf::HrtfProcessor::new(
202                        hrir.hrir_sphere.clone().unwrap(),
203                        SoundContext::HRTF_INTERPOLATION_STEPS,
204                        SoundContext::HRTF_BLOCK_LEN,
205                    ));
206                }
207            }
208        }
209
210        // Render as 2D first with k = (1.0 - spatial_blend).
211        render_source_2d_only(source, out_buf);
212
213        // Then add HRTF part with k = spatial_blend
214        let new_distance_gain = source.gain()
215            * source.spatial_blend()
216            * source.calculate_distance_gain(listener, distance_model);
217        let new_sampling_vector = source.calculate_sampling_vector(listener);
218
219        if let Some(processor) = self.processor.as_mut() {
220            processor.process_samples(hrtf::HrtfContext {
221                source: &source.frame_samples,
222                output: out_buf,
223                new_sample_vector: hrtf::Vec3::new(
224                    new_sampling_vector.x,
225                    new_sampling_vector.y,
226                    new_sampling_vector.z,
227                ),
228                prev_sample_vector: hrtf::Vec3::new(
229                    source.prev_sampling_vector.x,
230                    source.prev_sampling_vector.y,
231                    source.prev_sampling_vector.z,
232                ),
233                prev_left_samples: &mut source.prev_left_samples,
234                prev_right_samples: &mut source.prev_right_samples,
235                prev_distance_gain: source.prev_distance_gain.unwrap_or(new_distance_gain),
236                new_distance_gain,
237            });
238        }
239
240        source.prev_sampling_vector = new_sampling_vector;
241        source.prev_distance_gain = Some(new_distance_gain);
242    }
243}
244
245/// Wrapper for [`HrirSphere`] to be able to use it in the resource manager, that will handle async resource
246/// loading automatically.
247#[derive(Reflect, Default, Clone, Visit)]
248pub struct HrirSphereResourceData {
249    #[reflect(hidden)]
250    #[visit(skip)]
251    hrir_sphere: Option<HrirSphere>,
252}
253
254impl Debug for HrirSphereResourceData {
255    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
256        f.debug_struct("HrirSphereResourceData").finish()
257    }
258}
259
260impl TypeUuidProvider for HrirSphereResourceData {
261    fn type_uuid() -> Uuid {
262        uuid!("c92a0fa3-0ed3-49a9-be44-8f06271c6be2")
263    }
264}
265
266impl ResourceData for HrirSphereResourceData {
267    fn type_uuid(&self) -> Uuid {
268        <Self as TypeUuidProvider>::type_uuid()
269    }
270
271    fn save(&mut self, _path: &Path) -> Result<(), Box<dyn Error>> {
272        Err("Saving is not supported!".to_string().into())
273    }
274
275    fn can_be_saved(&self) -> bool {
276        false
277    }
278
279    fn try_clone_box(&self) -> Option<Box<dyn ResourceData>> {
280        Some(Box::new(self.clone()))
281    }
282}
283
284/// Resource loader for [`HrirSphereResource`].
285pub struct HrirSphereLoader;
286
287impl ResourceLoader for HrirSphereLoader {
288    fn extensions(&self) -> &[&str] {
289        &["hrir"]
290    }
291
292    fn data_type_uuid(&self) -> Uuid {
293        <HrirSphereResourceData as TypeUuidProvider>::type_uuid()
294    }
295
296    fn load(&self, path: PathBuf, io: Arc<dyn ResourceIo>) -> BoxedLoaderFuture {
297        Box::pin(async move {
298            let reader = io.file_reader(&path).await.map_err(LoadError::new)?;
299            let hrir_sphere = HrirSphere::new(reader, context::SAMPLE_RATE)
300                .map_err(HrtfError::from)
301                .map_err(LoadError::new)?;
302            Ok(LoaderPayload::new(HrirSphereResourceData {
303                hrir_sphere: Some(hrir_sphere),
304            }))
305        })
306    }
307}
308
309/// An alias to `Resource<HrirSphereResourceData>`.
310pub type HrirSphereResource = Resource<HrirSphereResourceData>;
311
312/// A set of extension methods for [`HrirSphereResource`]
313pub trait HrirSphereResourceExt {
314    /// Creates a new HRIR sphere resource directly from pre-loaded HRIR sphere. It could be used if you
315    /// do not use a resource manager, but want to load HRIR spheres manually.
316    fn from_hrir_sphere(hrir_sphere: HrirSphere, kind: ResourceKind) -> Self;
317}
318
319impl HrirSphereResourceExt for HrirSphereResource {
320    fn from_hrir_sphere(hrir_sphere: HrirSphere, kind: ResourceKind) -> Self {
321        Resource::new_ok(
322            Uuid::new_v4(),
323            kind,
324            HrirSphereResourceData {
325                hrir_sphere: Some(hrir_sphere),
326            },
327        )
328    }
329}