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}