1use ndarray::Array2;
20use num_complex::Complex64;
21use rayon::prelude::*;
22use serde::{Deserialize, Serialize};
23use std::f64::consts::PI;
24use wasm_bindgen::prelude::*;
25
26pub use wasm_bindgen_rayon::init_thread_pool;
28
29mod bem_solver;
31mod scattering_objects;
32
33pub use bem_solver::{BemAssemblyMethod, BemConfig, BemResult, BemSolverMethod, FmmConfig};
35pub use scattering_objects::{BoxObject, CylinderObject, ScatteringObjectConfig, SphereObject};
36
37pub const ABSORPTION_FREQUENCIES: [f64; 6] = [125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0];
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct WallMaterial {
47 pub name: String,
48 pub absorption: [f64; 6],
50}
51
52impl WallMaterial {
53 pub fn new(name: &str, absorption: [f64; 6]) -> Self {
55 Self {
56 name: name.to_string(),
57 absorption,
58 }
59 }
60
61 pub fn absorption_at_frequency(&self, frequency: f64) -> f64 {
63 if frequency <= ABSORPTION_FREQUENCIES[0] {
65 return self.absorption[0];
66 }
67 if frequency >= ABSORPTION_FREQUENCIES[5] {
68 return self.absorption[5];
69 }
70
71 for i in 0..5 {
73 if frequency >= ABSORPTION_FREQUENCIES[i] && frequency < ABSORPTION_FREQUENCIES[i + 1] {
74 let log_f = frequency.ln();
75 let log_f1 = ABSORPTION_FREQUENCIES[i].ln();
76 let log_f2 = ABSORPTION_FREQUENCIES[i + 1].ln();
77 let t = (log_f - log_f1) / (log_f2 - log_f1);
78 return self.absorption[i] * (1.0 - t) + self.absorption[i + 1] * t;
79 }
80 }
81
82 self.absorption[5]
83 }
84
85 pub fn reflection_at_frequency(&self, frequency: f64) -> f64 {
87 let alpha = self.absorption_at_frequency(frequency).clamp(0.0, 0.99);
88 (1.0 - alpha).sqrt()
89 }
90
91 pub fn concrete() -> Self {
95 Self::new("Concrete", [0.01, 0.01, 0.02, 0.02, 0.02, 0.03])
96 }
97
98 pub fn brick() -> Self {
100 Self::new("Brick", [0.03, 0.03, 0.03, 0.04, 0.05, 0.07])
101 }
102
103 pub fn drywall() -> Self {
105 Self::new("Drywall", [0.29, 0.10, 0.05, 0.04, 0.07, 0.09])
106 }
107
108 pub fn plaster() -> Self {
110 Self::new("Plaster", [0.01, 0.02, 0.02, 0.03, 0.04, 0.05])
111 }
112
113 pub fn glass() -> Self {
115 Self::new("Glass", [0.18, 0.06, 0.04, 0.03, 0.02, 0.02])
116 }
117
118 pub fn wood_thin() -> Self {
120 Self::new("Wood (thin)", [0.42, 0.21, 0.10, 0.08, 0.06, 0.06])
121 }
122
123 pub fn wood_thick() -> Self {
125 Self::new("Wood (thick)", [0.14, 0.10, 0.06, 0.08, 0.10, 0.10])
126 }
127
128 pub fn carpet_thin() -> Self {
130 Self::new("Carpet (thin)", [0.02, 0.06, 0.14, 0.37, 0.60, 0.65])
131 }
132
133 pub fn carpet_thick() -> Self {
135 Self::new("Carpet (thick)", [0.08, 0.24, 0.57, 0.69, 0.71, 0.73])
136 }
137
138 pub fn acoustic_tile() -> Self {
140 Self::new("Acoustic Tile", [0.50, 0.70, 0.60, 0.70, 0.70, 0.50])
141 }
142
143 pub fn curtains() -> Self {
145 Self::new("Curtains", [0.07, 0.31, 0.49, 0.75, 0.70, 0.60])
146 }
147
148 pub fn acoustic_foam() -> Self {
150 Self::new("Acoustic Foam", [0.08, 0.25, 0.60, 0.90, 0.95, 0.90])
151 }
152
153 pub fn hardwood() -> Self {
155 Self::new("Hardwood", [0.15, 0.11, 0.10, 0.07, 0.06, 0.07])
156 }
157
158 pub fn concrete_floor() -> Self {
160 Self::new("Concrete Floor", [0.01, 0.01, 0.02, 0.02, 0.02, 0.02])
161 }
162
163 #[allow(clippy::should_implement_trait)]
165 pub fn default() -> Self {
166 Self::plaster()
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum WallSurface {
173 Left,
175 Right,
177 Front,
179 Back,
181 Floor,
183 Ceiling,
185}
186
187impl WallSurface {
188 pub fn all() -> [WallSurface; 6] {
189 [
190 WallSurface::Left,
191 WallSurface::Right,
192 WallSurface::Front,
193 WallSurface::Back,
194 WallSurface::Floor,
195 WallSurface::Ceiling,
196 ]
197 }
198}
199
200#[wasm_bindgen]
201extern "C" {
202 #[wasm_bindgen(js_namespace = console)]
203 fn log(s: &str);
204}
205
206macro_rules! console_log {
207 ($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
208}
209
210#[wasm_bindgen]
212pub fn init_panic_hook() {
213 console_error_panic_hook::set_once();
214}
215
216#[derive(Debug, Clone, Copy)]
222pub struct Point3D {
223 pub x: f64,
224 pub y: f64,
225 pub z: f64,
226}
227
228impl Point3D {
229 pub fn new(x: f64, y: f64, z: f64) -> Self {
230 Self { x, y, z }
231 }
232
233 pub fn distance_to(&self, other: &Point3D) -> f64 {
234 let dx = self.x - other.x;
235 let dy = self.y - other.y;
236 let dz = self.z - other.z;
237 (dx * dx + dy * dy + dz * dz).sqrt()
238 }
239}
240
241pub struct RectangularRoom {
243 pub width: f64,
244 pub depth: f64,
245 pub height: f64,
246}
247
248impl RectangularRoom {
249 pub fn new(width: f64, depth: f64, height: f64) -> Self {
250 Self {
251 width,
252 depth,
253 height,
254 }
255 }
256
257 pub fn get_edges(&self) -> Vec<(Point3D, Point3D)> {
258 vec![
259 (
261 Point3D::new(0.0, 0.0, 0.0),
262 Point3D::new(self.width, 0.0, 0.0),
263 ),
264 (
265 Point3D::new(self.width, 0.0, 0.0),
266 Point3D::new(self.width, self.depth, 0.0),
267 ),
268 (
269 Point3D::new(self.width, self.depth, 0.0),
270 Point3D::new(0.0, self.depth, 0.0),
271 ),
272 (
273 Point3D::new(0.0, self.depth, 0.0),
274 Point3D::new(0.0, 0.0, 0.0),
275 ),
276 (
278 Point3D::new(0.0, 0.0, self.height),
279 Point3D::new(self.width, 0.0, self.height),
280 ),
281 (
282 Point3D::new(self.width, 0.0, self.height),
283 Point3D::new(self.width, self.depth, self.height),
284 ),
285 (
286 Point3D::new(self.width, self.depth, self.height),
287 Point3D::new(0.0, self.depth, self.height),
288 ),
289 (
290 Point3D::new(0.0, self.depth, self.height),
291 Point3D::new(0.0, 0.0, self.height),
292 ),
293 (
295 Point3D::new(0.0, 0.0, 0.0),
296 Point3D::new(0.0, 0.0, self.height),
297 ),
298 (
299 Point3D::new(self.width, 0.0, 0.0),
300 Point3D::new(self.width, 0.0, self.height),
301 ),
302 (
303 Point3D::new(self.width, self.depth, 0.0),
304 Point3D::new(self.width, self.depth, self.height),
305 ),
306 (
307 Point3D::new(0.0, self.depth, 0.0),
308 Point3D::new(0.0, self.depth, self.height),
309 ),
310 ]
311 }
312}
313
314pub struct LShapedRoom {
316 pub width1: f64,
317 pub depth1: f64,
318 pub width2: f64,
319 pub depth2: f64,
320 pub height: f64,
321}
322
323impl LShapedRoom {
324 pub fn new(width1: f64, depth1: f64, width2: f64, depth2: f64, height: f64) -> Self {
325 Self {
326 width1,
327 depth1,
328 width2,
329 depth2,
330 height,
331 }
332 }
333
334 pub fn contains_xy(&self, x: f64, y: f64) -> bool {
339 let total_depth = self.depth1 + self.depth2;
340
341 if x >= 0.0 && x <= self.width1 && y >= 0.0 && y <= self.depth1 {
343 return true;
344 }
345
346 if x >= 0.0 && x <= self.width2 && y >= self.depth1 && y <= total_depth {
348 return true;
349 }
350
351 false
352 }
353
354 pub fn contains(&self, p: &Point3D) -> bool {
356 self.contains_xy(p.x, p.y) && p.z >= 0.0 && p.z <= self.height
357 }
358
359 pub fn is_valid_image_source(
364 &self,
365 image: &Point3D,
366 listener: &Point3D,
367 _source: &Point3D,
368 ) -> bool {
369 let _midpoint = Point3D::new(
375 (image.x + listener.x) / 2.0,
376 (image.y + listener.y) / 2.0,
377 (image.z + listener.z) / 2.0,
378 );
379
380 let segments = 10;
387 for i in 1..segments {
388 let t = i as f64 / segments as f64;
389 let test_point = Point3D::new(
390 listener.x + t * (image.x - listener.x),
391 listener.y + t * (image.y - listener.y),
392 listener.z + t * (image.z - listener.z),
393 );
394
395 if !self.contains(&test_point) {
398 if i < segments - 1 {
400 let prev_t = (i - 1) as f64 / segments as f64;
403 let prev_point = Point3D::new(
404 listener.x + prev_t * (image.x - listener.x),
405 listener.y + prev_t * (image.y - listener.y),
406 listener.z + prev_t * (image.z - listener.z),
407 );
408
409 if self.contains(&prev_point) {
410 continue;
412 } else {
413 }
416 }
417 }
418 }
419
420 let total_depth = self.depth1 + self.depth2;
423
424 if image.x > self.width2 && image.y > self.depth1 && image.y < total_depth {
428 return false;
429 }
430
431 true
432 }
433
434 pub fn get_first_order_images(&self, source: &Point3D) -> Vec<(Point3D, &'static str)> {
450 let total_depth = self.depth1 + self.depth2;
451 let mut images = Vec::new();
452
453 images.push((Point3D::new(-source.x, source.y, source.z), "left"));
455
456 if source.y <= self.depth1 {
458 images.push((
460 Point3D::new(2.0 * self.width1 - source.x, source.y, source.z),
461 "right",
462 ));
463 } else {
464 images.push((
466 Point3D::new(2.0 * self.width2 - source.x, source.y, source.z),
467 "right",
468 ));
469 }
470
471 images.push((Point3D::new(source.x, -source.y, source.z), "front"));
473
474 images.push((
476 Point3D::new(source.x, 2.0 * total_depth - source.y, source.z),
477 "back",
478 ));
479
480 images.push((Point3D::new(source.x, source.y, -source.z), "floor"));
482
483 images.push((
485 Point3D::new(source.x, source.y, 2.0 * self.height - source.z),
486 "ceiling",
487 ));
488
489 if source.y < self.depth1 && source.x > self.width2 {
496 images.push((
498 Point3D::new(source.x, 2.0 * self.depth1 - source.y, source.z),
499 "step_horizontal",
500 ));
501 }
502
503 if source.y < self.depth1 && source.x > self.width2 {
507 images.push((
509 Point3D::new(2.0 * self.width2 - source.x, source.y, source.z),
510 "step_vertical",
511 ));
512 }
513
514 if source.y > self.depth1 && source.x < self.width2 {
517 images.push((
521 Point3D::new(2.0 * self.width2 - source.x, source.y, source.z),
522 "step_vertical",
523 ));
524 }
525
526 images
527 }
528
529 pub fn get_edges(&self) -> Vec<(Point3D, Point3D)> {
530 let total_depth = self.depth1 + self.depth2;
531 vec![
532 (
534 Point3D::new(0.0, 0.0, 0.0),
535 Point3D::new(self.width1, 0.0, 0.0),
536 ),
537 (
538 Point3D::new(self.width1, 0.0, 0.0),
539 Point3D::new(self.width1, self.depth1, 0.0),
540 ),
541 (
542 Point3D::new(self.width1, self.depth1, 0.0),
543 Point3D::new(self.width2, self.depth1, 0.0),
544 ),
545 (
546 Point3D::new(self.width2, self.depth1, 0.0),
547 Point3D::new(self.width2, total_depth, 0.0),
548 ),
549 (
550 Point3D::new(self.width2, total_depth, 0.0),
551 Point3D::new(0.0, total_depth, 0.0),
552 ),
553 (
554 Point3D::new(0.0, total_depth, 0.0),
555 Point3D::new(0.0, 0.0, 0.0),
556 ),
557 (
559 Point3D::new(0.0, 0.0, self.height),
560 Point3D::new(self.width1, 0.0, self.height),
561 ),
562 (
563 Point3D::new(self.width1, 0.0, self.height),
564 Point3D::new(self.width1, self.depth1, self.height),
565 ),
566 (
567 Point3D::new(self.width1, self.depth1, self.height),
568 Point3D::new(self.width2, self.depth1, self.height),
569 ),
570 (
571 Point3D::new(self.width2, self.depth1, self.height),
572 Point3D::new(self.width2, total_depth, self.height),
573 ),
574 (
575 Point3D::new(self.width2, total_depth, self.height),
576 Point3D::new(0.0, total_depth, self.height),
577 ),
578 (
579 Point3D::new(0.0, total_depth, self.height),
580 Point3D::new(0.0, 0.0, self.height),
581 ),
582 (
584 Point3D::new(0.0, 0.0, 0.0),
585 Point3D::new(0.0, 0.0, self.height),
586 ),
587 (
588 Point3D::new(self.width1, 0.0, 0.0),
589 Point3D::new(self.width1, 0.0, self.height),
590 ),
591 (
592 Point3D::new(self.width1, self.depth1, 0.0),
593 Point3D::new(self.width1, self.depth1, self.height),
594 ),
595 (
596 Point3D::new(self.width2, self.depth1, 0.0),
597 Point3D::new(self.width2, self.depth1, self.height),
598 ),
599 (
600 Point3D::new(self.width2, total_depth, 0.0),
601 Point3D::new(self.width2, total_depth, self.height),
602 ),
603 (
604 Point3D::new(0.0, total_depth, 0.0),
605 Point3D::new(0.0, total_depth, self.height),
606 ),
607 ]
608 }
609}
610
611pub enum RoomGeometry {
613 Rectangular(RectangularRoom),
614 LShaped(LShapedRoom),
615}
616
617impl RoomGeometry {
618 pub fn get_edges(&self) -> Vec<(Point3D, Point3D)> {
619 match self {
620 RoomGeometry::Rectangular(r) => r.get_edges(),
621 RoomGeometry::LShaped(r) => r.get_edges(),
622 }
623 }
624}
625
626pub enum CrossoverFilter {
628 FullRange,
629 Lowpass {
630 cutoff_freq: f64,
631 order: u32,
632 },
633 Highpass {
634 cutoff_freq: f64,
635 order: u32,
636 },
637 Bandpass {
638 low_cutoff: f64,
639 high_cutoff: f64,
640 order: u32,
641 },
642}
643
644impl CrossoverFilter {
645 pub fn amplitude_at_frequency(&self, frequency: f64) -> f64 {
646 match self {
647 CrossoverFilter::FullRange => 1.0,
648 CrossoverFilter::Lowpass { cutoff_freq, order } => {
649 let ratio = frequency / cutoff_freq;
650 1.0 / (1.0 + ratio.powi(*order as i32 * 2)).sqrt()
651 }
652 CrossoverFilter::Highpass { cutoff_freq, order } => {
653 let ratio = cutoff_freq / frequency;
654 1.0 / (1.0 + ratio.powi(*order as i32 * 2)).sqrt()
655 }
656 CrossoverFilter::Bandpass {
657 low_cutoff,
658 high_cutoff,
659 order,
660 } => {
661 let hp = 1.0 / (1.0 + (low_cutoff / frequency).powi(*order as i32 * 2)).sqrt();
662 let lp = 1.0 / (1.0 + (frequency / high_cutoff).powi(*order as i32 * 2)).sqrt();
663 hp * lp
664 }
665 }
666 }
667}
668
669pub struct DirectivityPattern {
671 pub horizontal_angles: Vec<f64>,
672 pub vertical_angles: Vec<f64>,
673 pub magnitude: Array2<f64>,
674}
675
676impl DirectivityPattern {
677 pub fn omnidirectional() -> Self {
678 let horizontal_angles: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
679 let vertical_angles: Vec<f64> = (0..19).map(|i| i as f64 * 10.0).collect();
680 let magnitude = Array2::ones((vertical_angles.len(), horizontal_angles.len()));
681
682 Self {
683 horizontal_angles,
684 vertical_angles,
685 magnitude,
686 }
687 }
688
689 pub fn interpolate(&self, theta: f64, phi: f64) -> f64 {
690 let theta_deg = theta.to_degrees();
691 let mut phi_deg = phi.to_degrees();
692
693 while phi_deg < 0.0 {
694 phi_deg += 360.0;
695 }
696 while phi_deg >= 360.0 {
697 phi_deg -= 360.0;
698 }
699
700 let h_idx = (phi_deg / 10.0).floor() as usize;
701 let v_idx = (theta_deg / 10.0).floor() as usize;
702
703 let h_idx = h_idx.min(self.horizontal_angles.len() - 1);
704 let v_idx = v_idx.min(self.vertical_angles.len() - 1);
705
706 let h_next = (h_idx + 1) % self.horizontal_angles.len();
707 let v_next = (v_idx + 1).min(self.vertical_angles.len() - 1);
708
709 let h_frac = (phi_deg / 10.0) - h_idx as f64;
710 let v_frac = (theta_deg / 10.0) - v_idx as f64;
711
712 let m00 = self.magnitude[[v_idx, h_idx]];
713 let m01 = self.magnitude[[v_idx, h_next]];
714 let m10 = self.magnitude[[v_next, h_idx]];
715 let m11 = self.magnitude[[v_next, h_next]];
716
717 let m0 = m00 * (1.0 - h_frac) + m01 * h_frac;
718 let m1 = m10 * (1.0 - h_frac) + m11 * h_frac;
719
720 m0 * (1.0 - v_frac) + m1 * v_frac
721 }
722}
723
724pub struct Source {
726 pub position: Point3D,
727 pub directivity: DirectivityPattern,
728 pub amplitude: f64,
729 pub crossover: CrossoverFilter,
730 pub name: String,
731 pub delay_sec: f64,
733 pub invert_phase: bool,
735}
736
737impl Source {
738 pub fn new(position: Point3D, directivity: DirectivityPattern, amplitude: f64) -> Self {
739 Self {
740 position,
741 directivity,
742 amplitude,
743 crossover: CrossoverFilter::FullRange,
744 name: String::from("Source"),
745 delay_sec: 0.0,
746 invert_phase: false,
747 }
748 }
749
750 pub fn with_crossover(mut self, crossover: CrossoverFilter) -> Self {
751 self.crossover = crossover;
752 self
753 }
754
755 pub fn with_name(mut self, name: String) -> Self {
756 self.name = name;
757 self
758 }
759
760 pub fn with_delay_ms(mut self, delay_ms: f64) -> Self {
761 self.delay_sec = delay_ms / 1000.0;
762 self
763 }
764
765 pub fn with_phase_inversion(mut self, invert: bool) -> Self {
766 self.invert_phase = invert;
767 self
768 }
769
770 pub fn phase_factor(&self, frequency: f64) -> Complex64 {
773 let omega = 2.0 * PI * frequency;
774 let delay_phase = Complex64::new(0.0, -omega * self.delay_sec).exp();
776 if self.invert_phase {
778 -delay_phase
779 } else {
780 delay_phase
781 }
782 }
783
784 pub fn amplitude_towards(&self, point: &Point3D, frequency: f64) -> f64 {
785 let dx = point.x - self.position.x;
786 let dy = point.y - self.position.y;
787 let dz = point.z - self.position.z;
788
789 let r = (dx * dx + dy * dy + dz * dz).sqrt();
790 if r < 1e-10 {
791 return self.amplitude * self.crossover.amplitude_at_frequency(frequency);
792 }
793
794 let theta = (dz / r).acos();
795 let phi = dy.atan2(dx);
796
797 let directivity_factor = self.directivity.interpolate(theta, phi);
798 let crossover_factor = self.crossover.amplitude_at_frequency(frequency);
799 self.amplitude * directivity_factor * crossover_factor
800 }
801}
802
803#[derive(Debug, Clone, Serialize, Deserialize)]
809#[serde(tag = "type")]
810pub enum RoomGeometryConfig {
811 #[serde(rename = "rectangular")]
812 Rectangular { width: f64, depth: f64, height: f64 },
813 #[serde(rename = "lshaped")]
814 LShaped {
815 width1: f64,
816 depth1: f64,
817 width2: f64,
818 depth2: f64,
819 height: f64,
820 },
821}
822
823#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
825pub struct Point3DConfig {
826 pub x: f64,
827 pub y: f64,
828 pub z: f64,
829}
830
831impl From<Point3DConfig> for Point3D {
832 fn from(p: Point3DConfig) -> Self {
833 Point3D::new(p.x, p.y, p.z)
834 }
835}
836
837impl From<Point3DConfig> for math_audio_bem::room_acoustics::Point3D {
838 fn from(p: Point3DConfig) -> Self {
839 math_audio_bem::room_acoustics::Point3D::new(p.x, p.y, p.z)
840 }
841}
842
843#[derive(Debug, Clone, Default, Serialize, Deserialize)]
845#[serde(tag = "type")]
846pub enum CrossoverConfig {
847 #[serde(rename = "fullrange")]
848 #[default]
849 FullRange,
850 #[serde(rename = "lowpass")]
851 Lowpass { cutoff_freq: f64, order: u32 },
852 #[serde(rename = "highpass")]
853 Highpass { cutoff_freq: f64, order: u32 },
854 #[serde(rename = "bandpass")]
855 Bandpass {
856 low_cutoff: f64,
857 high_cutoff: f64,
858 order: u32,
859 },
860}
861
862impl CrossoverConfig {
863 fn to_filter(&self) -> CrossoverFilter {
864 match self {
865 CrossoverConfig::FullRange => CrossoverFilter::FullRange,
866 CrossoverConfig::Lowpass { cutoff_freq, order } => CrossoverFilter::Lowpass {
867 cutoff_freq: *cutoff_freq,
868 order: *order,
869 },
870 CrossoverConfig::Highpass { cutoff_freq, order } => CrossoverFilter::Highpass {
871 cutoff_freq: *cutoff_freq,
872 order: *order,
873 },
874 CrossoverConfig::Bandpass {
875 low_cutoff,
876 high_cutoff,
877 order,
878 } => CrossoverFilter::Bandpass {
879 low_cutoff: *low_cutoff,
880 high_cutoff: *high_cutoff,
881 order: *order,
882 },
883 }
884 }
885}
886
887#[derive(Debug, Clone, Default, Serialize, Deserialize)]
889#[serde(tag = "type")]
890pub enum DirectivityConfig {
891 #[serde(rename = "omnidirectional")]
892 #[default]
893 Omnidirectional,
894 #[serde(rename = "cardioid")]
895 Cardioid { front_back_ratio: f64 },
896 #[serde(rename = "spinorama")]
898 Spinorama {
899 horizontal: Vec<SpinoramaCurve>,
901 vertical: Vec<SpinoramaCurve>,
903 },
904}
905
906#[derive(Debug, Clone, Serialize, Deserialize)]
908pub struct SpinoramaCurve {
909 pub angle: f64,
911 pub freq: Vec<f64>,
913 pub spl: Vec<f64>,
915}
916
917#[derive(Debug, Clone, Serialize, Deserialize)]
919#[serde(tag = "type")]
920pub enum WallMaterialConfig {
921 #[serde(rename = "preset")]
923 Preset { name: String },
924 #[serde(rename = "custom")]
926 Custom {
927 name: String,
928 #[serde(default = "default_absorption")]
929 absorption: [f64; 6],
930 },
931}
932
933fn default_absorption() -> [f64; 6] {
934 [0.02, 0.02, 0.03, 0.03, 0.04, 0.05] }
936
937impl Default for WallMaterialConfig {
938 fn default() -> Self {
939 WallMaterialConfig::Preset {
940 name: "plaster".to_string(),
941 }
942 }
943}
944
945impl WallMaterialConfig {
946 pub fn to_material(&self) -> WallMaterial {
948 match self {
949 WallMaterialConfig::Preset { name } => {
950 match name.to_lowercase().as_str() {
951 "concrete" => WallMaterial::concrete(),
952 "brick" => WallMaterial::brick(),
953 "drywall" | "gypsum" => WallMaterial::drywall(),
954 "plaster" => WallMaterial::plaster(),
955 "glass" => WallMaterial::glass(),
956 "wood_thin" | "wood-thin" | "thin_wood" => WallMaterial::wood_thin(),
957 "wood_thick" | "wood-thick" | "thick_wood" | "wood" => {
958 WallMaterial::wood_thick()
959 }
960 "carpet_thin" | "carpet-thin" | "thin_carpet" => WallMaterial::carpet_thin(),
961 "carpet_thick" | "carpet-thick" | "thick_carpet" | "carpet" => {
962 WallMaterial::carpet_thick()
963 }
964 "acoustic_tile" | "acoustic-tile" | "ceiling_tile" => {
965 WallMaterial::acoustic_tile()
966 }
967 "curtains" | "drapes" => WallMaterial::curtains(),
968 "acoustic_foam" | "acoustic-foam" | "foam" => WallMaterial::acoustic_foam(),
969 "hardwood" | "wood_floor" | "wood-floor" => WallMaterial::hardwood(),
970 "concrete_floor" | "concrete-floor" => WallMaterial::concrete_floor(),
971 _ => WallMaterial::plaster(), }
973 }
974 WallMaterialConfig::Custom { name, absorption } => WallMaterial::new(name, *absorption),
975 }
976 }
977
978 pub fn absorption_at_frequency(&self, frequency: f64) -> f64 {
980 self.to_material().absorption_at_frequency(frequency)
981 }
982}
983
984#[derive(Debug, Clone, Serialize, Deserialize)]
986pub struct WallMaterialsConfig {
987 #[serde(default)]
989 pub left: WallMaterialConfig,
990 #[serde(default)]
992 pub right: WallMaterialConfig,
993 #[serde(default)]
995 pub front: WallMaterialConfig,
996 #[serde(default)]
998 pub back: WallMaterialConfig,
999 #[serde(default = "default_floor_material")]
1001 pub floor: WallMaterialConfig,
1002 #[serde(default = "default_ceiling_material")]
1004 pub ceiling: WallMaterialConfig,
1005}
1006
1007fn default_floor_material() -> WallMaterialConfig {
1008 WallMaterialConfig::Preset {
1009 name: "hardwood".to_string(),
1010 }
1011}
1012
1013fn default_ceiling_material() -> WallMaterialConfig {
1014 WallMaterialConfig::Preset {
1015 name: "plaster".to_string(),
1016 }
1017}
1018
1019impl Default for WallMaterialsConfig {
1020 fn default() -> Self {
1021 Self {
1022 left: WallMaterialConfig::default(),
1023 right: WallMaterialConfig::default(),
1024 front: WallMaterialConfig::default(),
1025 back: WallMaterialConfig::default(),
1026 floor: default_floor_material(),
1027 ceiling: default_ceiling_material(),
1028 }
1029 }
1030}
1031
1032impl WallMaterialsConfig {
1033 pub fn get_material(&self, surface: WallSurface) -> WallMaterial {
1035 match surface {
1036 WallSurface::Left => self.left.to_material(),
1037 WallSurface::Right => self.right.to_material(),
1038 WallSurface::Front => self.front.to_material(),
1039 WallSurface::Back => self.back.to_material(),
1040 WallSurface::Floor => self.floor.to_material(),
1041 WallSurface::Ceiling => self.ceiling.to_material(),
1042 }
1043 }
1044
1045 pub fn reflection_at(&self, surface: WallSurface, frequency: f64) -> f64 {
1047 self.get_material(surface)
1048 .reflection_at_frequency(frequency)
1049 }
1050
1051 pub fn average_absorption_at(&self, frequency: f64) -> f64 {
1053 let surfaces = WallSurface::all();
1054 let total: f64 = surfaces
1055 .iter()
1056 .map(|&s| self.get_material(s).absorption_at_frequency(frequency))
1057 .sum();
1058 total / surfaces.len() as f64
1059 }
1060}
1061
1062#[derive(Debug, Clone, Serialize, Deserialize)]
1064pub struct SourceConfig {
1065 pub name: String,
1066 pub position: Point3DConfig,
1067 #[serde(default = "default_amplitude")]
1068 pub amplitude: f64,
1069 #[serde(default)]
1070 pub directivity: DirectivityConfig,
1071 #[serde(default)]
1072 pub crossover: CrossoverConfig,
1073 #[serde(default)]
1075 pub delay_ms: f64,
1076 #[serde(default)]
1078 pub invert_phase: bool,
1079}
1080
1081fn default_amplitude() -> f64 {
1082 1.0
1083}
1084
1085#[derive(Debug, Clone, Serialize, Deserialize)]
1087pub struct FrequencyConfig {
1088 pub min_freq: f64,
1089 pub max_freq: f64,
1090 pub num_points: usize,
1091 #[serde(default = "default_spacing")]
1092 pub spacing: String,
1093}
1094
1095fn default_spacing() -> String {
1096 "logarithmic".to_string()
1097}
1098
1099#[derive(Debug, Clone, Serialize, Deserialize)]
1101pub struct SolverConfig {
1102 #[serde(default = "default_method")]
1104 pub method: String,
1105 #[serde(default = "default_mesh_resolution")]
1106 pub mesh_resolution: usize,
1107 #[serde(default = "default_speed_of_sound")]
1108 pub speed_of_sound: f64,
1109 #[serde(default = "default_temperature")]
1111 pub temperature: f64,
1112 #[serde(default = "default_humidity")]
1114 pub humidity: f64,
1115 #[serde(default = "default_air_absorption")]
1117 pub air_absorption: bool,
1118 #[serde(default = "default_edge_diffraction")]
1120 pub edge_diffraction: bool,
1121 #[serde(default = "default_hybrid_crossover_width")]
1123 pub hybrid_crossover_width: f64,
1124 #[serde(default = "default_max_mode_order")]
1126 pub max_mode_order: u32,
1127 #[serde(default = "default_modal_damping")]
1129 pub modal_damping: f64,
1130 #[serde(default)]
1132 pub bem_config: BemConfig,
1133}
1134
1135fn default_method() -> String {
1136 "direct".to_string()
1137}
1138fn default_mesh_resolution() -> usize {
1139 2
1140}
1141fn default_speed_of_sound() -> f64 {
1142 343.0
1143}
1144fn default_temperature() -> f64 {
1145 20.0
1146} fn default_humidity() -> f64 {
1148 50.0
1149} fn default_air_absorption() -> bool {
1151 true
1152}
1153fn default_edge_diffraction() -> bool {
1154 false
1155} fn default_hybrid_crossover_width() -> f64 {
1157 0.5
1158} fn default_max_mode_order() -> u32 {
1160 20
1161}
1162fn default_modal_damping() -> f64 {
1163 10.0
1164} impl Default for SolverConfig {
1167 fn default() -> Self {
1168 Self {
1169 method: default_method(),
1170 mesh_resolution: default_mesh_resolution(),
1171 speed_of_sound: default_speed_of_sound(),
1172 temperature: default_temperature(),
1173 humidity: default_humidity(),
1174 air_absorption: default_air_absorption(),
1175 edge_diffraction: default_edge_diffraction(),
1176 hybrid_crossover_width: default_hybrid_crossover_width(),
1177 max_mode_order: default_max_mode_order(),
1178 modal_damping: default_modal_damping(),
1179 bem_config: BemConfig::default(),
1180 }
1181 }
1182}
1183
1184pub fn calculate_air_absorption(frequency: f64, temperature: f64, humidity: f64) -> f64 {
1200 let base_absorption = match frequency {
1212 f if f < 500.0 => 0.0001 * (f / 500.0).powi(2),
1213 f if f < 2000.0 => 0.0001 + 0.0009 * ((f - 500.0) / 1500.0),
1214 f if f < 8000.0 => 0.001 + 0.009 * ((f - 2000.0) / 6000.0),
1215 _ => 0.01 + 0.005 * ((frequency - 8000.0) / 8000.0),
1216 };
1217
1218 let temp_factor = 1.0 + 0.02 * (temperature - 20.0).abs();
1220
1221 let humidity_factor = if humidity < 40.0 {
1223 1.0 + 0.01 * (40.0 - humidity)
1224 } else {
1225 1.0 + 0.005 * (humidity - 40.0)
1226 };
1227
1228 base_absorption * temp_factor * humidity_factor
1229}
1230
1231#[derive(Debug, Clone, Serialize, Deserialize)]
1233pub struct VisualizationConfig {
1234 #[serde(default = "default_generate_slices")]
1235 pub generate_slices: bool,
1236 #[serde(default = "default_slice_resolution")]
1237 pub slice_resolution: usize,
1238 #[serde(default)]
1239 pub slice_frequency_indices: Vec<usize>,
1240 #[serde(default = "default_generate_impulse_response")]
1242 pub generate_impulse_response: bool,
1243 #[serde(default)]
1245 pub impulse_response: ImpulseResponseConfig,
1246 #[serde(default)]
1248 pub binaural: BinauralConfig,
1249}
1250
1251fn default_generate_slices() -> bool {
1252 true
1253}
1254fn default_slice_resolution() -> usize {
1255 50
1256}
1257fn default_generate_impulse_response() -> bool {
1258 false
1259}
1260
1261impl Default for VisualizationConfig {
1262 fn default() -> Self {
1263 Self {
1264 generate_slices: true,
1265 slice_resolution: 50,
1266 slice_frequency_indices: Vec::new(),
1267 generate_impulse_response: false,
1268 impulse_response: ImpulseResponseConfig::default(),
1269 binaural: BinauralConfig::default(),
1270 }
1271 }
1272}
1273
1274#[derive(Debug, Clone, Serialize, Deserialize)]
1276pub struct SimulationConfig {
1277 pub room: RoomGeometryConfig,
1278 pub sources: Vec<SourceConfig>,
1279 pub listening_positions: Vec<Point3DConfig>,
1280 pub frequencies: FrequencyConfig,
1281 #[serde(default)]
1282 pub solver: SolverConfig,
1283 #[serde(default)]
1284 pub visualization: VisualizationConfig,
1285 #[serde(default)]
1286 pub wall_materials: WallMaterialsConfig,
1287 #[serde(default)]
1288 pub metadata: MetadataConfig,
1289 #[serde(default)]
1292 pub scattering_objects: Vec<ScatteringObjectConfig>,
1293}
1294
1295#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1297pub struct MetadataConfig {
1298 #[serde(default)]
1299 pub description: String,
1300 #[serde(default)]
1301 pub author: String,
1302 #[serde(default)]
1303 pub date: String,
1304 #[serde(default)]
1305 pub notes: String,
1306}
1307
1308#[derive(Debug, Clone, Serialize)]
1313pub struct SourceResponse {
1314 pub source_name: String,
1315 pub spl: Vec<f64>,
1316}
1317
1318#[derive(Debug, Clone, Serialize)]
1319pub struct SliceOutput {
1320 pub frequency: f64,
1321 pub x: Vec<f64>,
1322 pub y: Vec<f64>,
1323 #[serde(skip_serializing_if = "Option::is_none")]
1324 pub z: Option<Vec<f64>>,
1325 pub spl: Vec<f64>,
1326 pub shape: [usize; 2],
1327}
1328
1329#[derive(Debug, Clone, Serialize)]
1330pub struct RoomOutput {
1331 pub width: f64,
1332 pub depth: f64,
1333 pub height: f64,
1334 #[serde(skip_serializing_if = "Option::is_none")]
1335 pub room_type: Option<String>,
1336 pub edges: Vec<[[f64; 3]; 2]>,
1337}
1338
1339#[derive(Debug, Clone, Serialize)]
1340pub struct SimulationResults {
1341 pub room: RoomOutput,
1342 pub sources: Vec<SourceOutputInfo>,
1343 pub listening_position: [f64; 3],
1344 pub frequencies: Vec<f64>,
1345 pub frequency_response: Vec<f64>,
1346 #[serde(skip_serializing_if = "Option::is_none")]
1347 pub source_responses: Option<Vec<SourceResponse>>,
1348 #[serde(skip_serializing_if = "Option::is_none")]
1349 pub horizontal_slices: Option<Vec<SliceOutput>>,
1350 #[serde(skip_serializing_if = "Option::is_none")]
1351 pub vertical_slices: Option<Vec<SliceOutput>>,
1352 pub solver: String,
1353 #[serde(skip_serializing_if = "Option::is_none")]
1354 pub mesh_nodes: Option<usize>,
1355 #[serde(skip_serializing_if = "Option::is_none")]
1356 pub mesh_elements: Option<usize>,
1357 #[serde(skip_serializing_if = "Option::is_none")]
1358 pub metadata: Option<MetadataConfig>,
1359 #[serde(skip_serializing_if = "Option::is_none")]
1360 pub room_modes: Option<Vec<RoomMode>>,
1361 #[serde(skip_serializing_if = "Option::is_none")]
1362 pub room_acoustics: Option<RoomAcoustics>,
1363 #[serde(skip_serializing_if = "Option::is_none")]
1364 pub impulse_response: Option<ImpulseResponse>,
1365 #[serde(skip_serializing_if = "Option::is_none")]
1366 pub binaural_response: Option<BinauralResponse>,
1367}
1368
1369#[derive(Debug, Clone, Serialize)]
1370pub struct SourceOutputInfo {
1371 pub name: String,
1372 pub position: [f64; 3],
1373 #[serde(skip_serializing_if = "Option::is_none")]
1374 pub crossover: Option<String>,
1375}
1376
1377#[derive(Debug, Clone, Serialize)]
1383pub struct RoomMode {
1384 pub frequency: f64,
1386 pub indices: [u32; 3],
1388 pub mode_type: String,
1390 pub description: String,
1392}
1393
1394pub fn calculate_room_modes(
1403 length_x: f64, length_y: f64, length_z: f64, speed_of_sound: f64,
1407 max_frequency: f64,
1408 max_order: u32,
1409) -> Vec<RoomMode> {
1410 let mut modes = Vec::new();
1411 let c = speed_of_sound;
1412
1413 for n in 0..=max_order {
1414 for m in 0..=max_order {
1415 for p in 0..=max_order {
1416 if n == 0 && m == 0 && p == 0 {
1418 continue;
1419 }
1420
1421 let nx = n as f64 / length_x;
1423 let my = m as f64 / length_y;
1424 let pz = p as f64 / length_z;
1425 let freq = (c / 2.0) * (nx * nx + my * my + pz * pz).sqrt();
1426
1427 if freq > max_frequency {
1428 continue;
1429 }
1430
1431 let zero_count = [n, m, p].iter().filter(|&&x| x == 0).count();
1433 let mode_type = match zero_count {
1434 2 => "axial",
1435 1 => "tangential",
1436 0 => "oblique",
1437 _ => continue, };
1439
1440 let description = match (n, m, p) {
1442 (n, 0, 0) if n > 0 => format!("{},0,0 - Length mode (X)", n),
1443 (0, m, 0) if m > 0 => format!("0,{},0 - Width mode (Y)", m),
1444 (0, 0, p) if p > 0 => format!("0,0,{} - Height mode (Z)", p),
1445 (n, m, 0) => format!("{},{},0 - Floor tangential", n, m),
1446 (n, 0, p) => format!("{},0,{} - Side tangential", n, p),
1447 (0, m, p) => format!("0,{},{} - Front tangential", m, p),
1448 (n, m, p) => format!("{},{},{} - Oblique", n, m, p),
1449 };
1450
1451 modes.push(RoomMode {
1452 frequency: freq,
1453 indices: [n, m, p],
1454 mode_type: mode_type.to_string(),
1455 description,
1456 });
1457 }
1458 }
1459 }
1460
1461 modes.sort_by(|a, b| a.frequency.partial_cmp(&b.frequency).unwrap());
1463
1464 modes
1465}
1466
1467#[allow(clippy::too_many_arguments)]
1486pub fn calculate_modal_pressure(
1487 source: &Point3D,
1488 listener: &Point3D,
1489 frequency: f64,
1490 room_width: f64, room_depth: f64, room_height: f64, speed_of_sound: f64,
1494 max_mode_order: u32,
1495 modal_damping: f64, ) -> Complex64 {
1497 let volume = room_width * room_depth * room_height;
1498
1499 let r = source.distance_to(listener).max(0.1);
1501 let omega = 2.0 * PI * frequency;
1502 let omega_sq = omega * omega;
1503 let k = omega / speed_of_sound;
1504 let c_sq = speed_of_sound * speed_of_sound;
1505
1506 let prefactor = c_sq / volume;
1508
1509 let mut modal_sum = Complex64::new(0.0, 0.0);
1510
1511 for n in 0..=max_mode_order {
1512 for m in 0..=max_mode_order {
1513 for p in 0..=max_mode_order {
1514 if n == 0 && m == 0 && p == 0 {
1516 continue;
1517 }
1518
1519 let nx = n as f64 / room_width;
1522 let my = m as f64 / room_depth;
1523 let pz = p as f64 / room_height;
1524 let omega_n = speed_of_sound * PI * (nx * nx + my * my + pz * pz).sqrt();
1525 let omega_n_sq = omega_n * omega_n;
1526
1527 let mode_freq = omega_n / (2.0 * PI);
1529
1530 if mode_freq > frequency * 4.0 || mode_freq < frequency / 4.0 {
1532 continue;
1533 }
1534
1535 let source_mode = (n as f64 * PI * source.x / room_width).cos()
1538 * (m as f64 * PI * source.y / room_depth).cos()
1539 * (p as f64 * PI * source.z / room_height).cos();
1540
1541 let listener_mode = (n as f64 * PI * listener.x / room_width).cos()
1543 * (m as f64 * PI * listener.y / room_depth).cos()
1544 * (p as f64 * PI * listener.z / room_height).cos();
1545
1546 let epsilon = |i: u32| if i == 0 { 1.0 } else { 2.0 };
1548 let mode_norm = epsilon(n) * epsilon(m) * epsilon(p);
1549
1550 let delta_n = omega_n / (2.0 * modal_damping);
1557 let denominator = Complex64::new(omega_n_sq - omega_sq, -2.0 * delta_n * omega);
1558 let transfer_function = Complex64::new(1.0, 0.0) / denominator;
1559
1560 let mode_amplitude = mode_norm * source_mode * listener_mode;
1564 modal_sum += transfer_function * mode_amplitude;
1565 }
1566 }
1567 }
1568
1569 modal_sum *= prefactor;
1573
1574 let phase = Complex64::new(0.0, k * r).exp();
1576
1577 modal_sum * phase
1578}
1579
1580pub fn hybrid_crossover_weight(
1588 frequency: f64,
1589 schroeder_frequency: f64,
1590 crossover_width_octaves: f64,
1591) -> f64 {
1592 if schroeder_frequency <= 0.0 {
1593 return 1.0; }
1595
1596 let octaves_from_schroeder = (frequency / schroeder_frequency).log2();
1598
1599 let half_width = crossover_width_octaves / 2.0;
1601
1602 if octaves_from_schroeder < -half_width {
1603 0.0
1605 } else if octaves_from_schroeder > half_width {
1606 1.0
1608 } else {
1609 let t = (octaves_from_schroeder + half_width) / crossover_width_octaves;
1611 (1.0 - (t * PI).cos()) / 2.0
1613 }
1614}
1615
1616#[derive(Debug, Clone, Serialize)]
1622pub struct RoomAcoustics {
1623 pub rt60_sabine: f64,
1625 pub rt60_eyring: f64,
1627 pub volume: f64,
1629 pub surface_area: f64,
1631 pub average_alpha: f64,
1633 pub total_absorption: f64,
1635 pub schroeder_frequency: f64,
1637 pub critical_distance: f64,
1639}
1640
1641pub fn rt60_sabine(volume: f64, total_absorption: f64) -> f64 {
1651 if total_absorption > 0.0 {
1652 0.161 * volume / total_absorption
1653 } else {
1654 f64::INFINITY }
1656}
1657
1658pub fn rt60_eyring(volume: f64, surface_area: f64, average_alpha: f64) -> f64 {
1669 if average_alpha > 0.0 && average_alpha < 1.0 {
1670 let clamped_alpha = average_alpha.min(0.99); 0.161 * volume / (-surface_area * (1.0 - clamped_alpha).ln())
1672 } else if average_alpha >= 1.0 {
1673 0.0 } else {
1675 f64::INFINITY }
1677}
1678
1679pub fn critical_distance(volume: f64, rt60: f64) -> f64 {
1686 if rt60 > 0.0 {
1687 0.057 * (volume / rt60).sqrt()
1688 } else {
1689 f64::INFINITY }
1691}
1692
1693pub fn calculate_room_acoustics(
1695 width: f64,
1696 depth: f64,
1697 height: f64,
1698 materials: &WallMaterialsConfig,
1699 frequency: f64,
1700) -> RoomAcoustics {
1701 let volume = width * depth * height;
1703
1704 let area_left_right = depth * height; let area_front_back = width * height; let area_floor_ceiling = width * depth; let surface_area = 2.0 * (area_left_right + area_front_back + area_floor_ceiling);
1710
1711 let alpha_left = materials.left.absorption_at_frequency(frequency);
1713 let alpha_right = materials.right.absorption_at_frequency(frequency);
1714 let alpha_front = materials.front.absorption_at_frequency(frequency);
1715 let alpha_back = materials.back.absorption_at_frequency(frequency);
1716 let alpha_floor = materials.floor.absorption_at_frequency(frequency);
1717 let alpha_ceiling = materials.ceiling.absorption_at_frequency(frequency);
1718
1719 let total_absorption = alpha_left * area_left_right
1721 + alpha_right * area_left_right
1722 + alpha_front * area_front_back
1723 + alpha_back * area_front_back
1724 + alpha_floor * area_floor_ceiling
1725 + alpha_ceiling * area_floor_ceiling;
1726
1727 let average_alpha = total_absorption / surface_area;
1729
1730 let rt60_sab = rt60_sabine(volume, total_absorption);
1732 let rt60_eyr = rt60_eyring(volume, surface_area, average_alpha);
1733
1734 let schroeder_freq = 2000.0 * (rt60_eyr / volume).sqrt();
1736 let crit_dist = critical_distance(volume, rt60_eyr);
1737
1738 RoomAcoustics {
1739 rt60_sabine: rt60_sab,
1740 rt60_eyring: rt60_eyr,
1741 volume,
1742 surface_area,
1743 average_alpha,
1744 total_absorption,
1745 schroeder_frequency: schroeder_freq,
1746 critical_distance: crit_dist,
1747 }
1748}
1749
1750#[derive(Debug, Clone, Serialize)]
1756pub struct ImpulseResponse {
1757 pub time: Vec<f64>,
1759 pub amplitude: Vec<f64>,
1761 pub sample_rate: f64,
1763 pub duration: f64,
1765 pub peak_amplitude: f64,
1767 pub energy_decay: Vec<f64>,
1769}
1770
1771#[derive(Debug, Clone, Serialize, Deserialize)]
1773pub struct ImpulseResponseConfig {
1774 #[serde(default = "default_ir_sample_rate")]
1776 pub sample_rate: f64,
1777 #[serde(default)]
1779 pub duration: Option<f64>,
1780 #[serde(default)]
1782 pub fft_size: Option<usize>,
1783 #[serde(default = "default_ir_min_freq")]
1785 pub min_freq: f64,
1786 #[serde(default)]
1788 pub max_freq: Option<f64>,
1789}
1790
1791fn default_ir_sample_rate() -> f64 {
1792 48000.0
1793}
1794fn default_ir_min_freq() -> f64 {
1795 20.0
1796}
1797
1798impl Default for ImpulseResponseConfig {
1799 fn default() -> Self {
1800 Self {
1801 sample_rate: default_ir_sample_rate(),
1802 duration: None,
1803 fft_size: None,
1804 min_freq: default_ir_min_freq(),
1805 max_freq: None,
1806 }
1807 }
1808}
1809
1810pub fn calculate_impulse_response(
1821 frequencies: &[f64],
1822 complex_response: &[Complex64],
1823 config: &ImpulseResponseConfig,
1824) -> ImpulseResponse {
1825 let sample_rate = config.sample_rate;
1826 let nyquist = sample_rate / 2.0;
1827
1828 let duration = config.duration.unwrap_or(0.5); let min_fft_size = (duration * sample_rate).ceil() as usize;
1831 let fft_size = config.fft_size.unwrap_or_else(|| {
1832 let mut size = 1;
1834 while size < min_fft_size {
1835 size *= 2;
1836 }
1837 size.max(1024)
1838 });
1839
1840 let freq_resolution = sample_rate / fft_size as f64;
1842
1843 let num_bins = fft_size / 2 + 1;
1845 let mut spectrum: Vec<Complex64> = vec![Complex64::new(0.0, 0.0); fft_size];
1846
1847 #[allow(clippy::needless_range_loop)]
1849 for bin in 0..num_bins {
1850 let freq = bin as f64 * freq_resolution;
1851
1852 if freq < config.min_freq || freq > nyquist {
1854 spectrum[bin] = Complex64::new(0.0, 0.0);
1855 continue;
1856 }
1857
1858 let value = interpolate_complex(frequencies, complex_response, freq);
1860 spectrum[bin] = value;
1861 }
1862
1863 for k in 1..fft_size / 2 {
1866 spectrum[fft_size - k] = spectrum[k].conj();
1867 }
1868
1869 let time_domain = ifft_real(&spectrum);
1872
1873 let peak = time_domain.iter().map(|&x| x.abs()).fold(0.0_f64, f64::max);
1875 let normalized: Vec<f64> = if peak > 1e-10 {
1876 time_domain.iter().map(|&x| x / peak).collect()
1877 } else {
1878 time_domain.clone()
1879 };
1880
1881 let time: Vec<f64> = (0..fft_size).map(|i| i as f64 / sample_rate).collect();
1883
1884 let energy_decay = calculate_energy_decay(&normalized);
1886
1887 ImpulseResponse {
1888 time,
1889 amplitude: normalized,
1890 sample_rate,
1891 duration: fft_size as f64 / sample_rate,
1892 peak_amplitude: peak,
1893 energy_decay,
1894 }
1895}
1896
1897fn interpolate_complex(frequencies: &[f64], values: &[Complex64], target_freq: f64) -> Complex64 {
1899 if frequencies.is_empty() || values.is_empty() {
1900 return Complex64::new(0.0, 0.0);
1901 }
1902
1903 if target_freq <= frequencies[0] {
1905 return values[0];
1906 }
1907 if target_freq >= frequencies[frequencies.len() - 1] {
1908 return values[values.len() - 1];
1909 }
1910
1911 for i in 0..frequencies.len() - 1 {
1913 if target_freq >= frequencies[i] && target_freq < frequencies[i + 1] {
1914 let log_f = target_freq.ln();
1916 let log_f1 = frequencies[i].ln();
1917 let log_f2 = frequencies[i + 1].ln();
1918 let t = (log_f - log_f1) / (log_f2 - log_f1);
1919
1920 let mag1 = values[i].norm();
1922 let mag2 = values[i + 1].norm();
1923 let phase1 = values[i].arg();
1924 let phase2 = values[i + 1].arg();
1925
1926 let mut phase_diff = phase2 - phase1;
1928 while phase_diff > PI {
1929 phase_diff -= 2.0 * PI;
1930 }
1931 while phase_diff < -PI {
1932 phase_diff += 2.0 * PI;
1933 }
1934
1935 let mag = mag1 * (1.0 - t) + mag2 * t;
1936 let phase = phase1 + t * phase_diff;
1937
1938 return Complex64::from_polar(mag, phase);
1939 }
1940 }
1941
1942 values[values.len() - 1]
1943}
1944
1945fn ifft_real(spectrum: &[Complex64]) -> Vec<f64> {
1948 let n = spectrum.len();
1949 let mut output = vec![0.0; n];
1950
1951 if n <= 4096 {
1954 #[allow(clippy::needless_range_loop)]
1955 for k in 0..n {
1956 let mut sum = Complex64::new(0.0, 0.0);
1957 for (m, &spec_val) in spectrum.iter().enumerate() {
1958 let angle = 2.0 * PI * (k as f64) * (m as f64) / (n as f64);
1959 let twiddle = Complex64::new(angle.cos(), angle.sin());
1960 sum += spec_val * twiddle;
1961 }
1962 output[k] = sum.re / n as f64;
1963 }
1964 } else {
1965 output = cooley_tukey_ifft(spectrum);
1967 }
1968
1969 output
1970}
1971
1972fn cooley_tukey_ifft(spectrum: &[Complex64]) -> Vec<f64> {
1974 let n = spectrum.len();
1975
1976 let mut data: Vec<Complex64> = spectrum.to_vec();
1978 let mut j = 0;
1979 for i in 0..n {
1980 if i < j {
1981 data.swap(i, j);
1982 }
1983 let mut m = n / 2;
1984 while m >= 1 && j >= m {
1985 j -= m;
1986 m /= 2;
1987 }
1988 j += m;
1989 }
1990
1991 let mut len = 2;
1993 while len <= n {
1994 let half = len / 2;
1995 let angle_step = 2.0 * PI / len as f64; for start in (0..n).step_by(len) {
1998 for k in 0..half {
1999 let angle = angle_step * k as f64;
2000 let twiddle = Complex64::new(angle.cos(), angle.sin());
2001
2002 let even = data[start + k];
2003 let odd = data[start + k + half] * twiddle;
2004
2005 data[start + k] = even + odd;
2006 data[start + k + half] = even - odd;
2007 }
2008 }
2009
2010 len *= 2;
2011 }
2012
2013 data.iter().map(|c| c.re / n as f64).collect()
2015}
2016
2017fn calculate_energy_decay(ir: &[f64]) -> Vec<f64> {
2020 let n = ir.len();
2021 if n == 0 {
2022 return vec![];
2023 }
2024
2025 let energy: Vec<f64> = ir.iter().map(|&x| x * x).collect();
2027
2028 let mut decay = vec![0.0; n];
2030 let mut cumsum = 0.0;
2031
2032 for i in (0..n).rev() {
2033 cumsum += energy[i];
2034 decay[i] = cumsum;
2035 }
2036
2037 let max_energy = decay[0].max(1e-10);
2039 decay
2040 .iter()
2041 .map(|&e| 10.0 * (e / max_energy).log10())
2042 .collect()
2043}
2044
2045const DEFAULT_HEAD_RADIUS: f64 = 0.0875; const DEFAULT_EAR_SPACING: f64 = 0.175; #[derive(Debug, Clone, Serialize)]
2057pub struct BinauralResponse {
2058 pub left: ImpulseResponse,
2060 pub right: ImpulseResponse,
2062 pub itd: f64,
2064 pub ild: f64,
2066 pub head_position: [f64; 3],
2068 pub head_yaw: f64,
2070}
2071
2072#[derive(Debug, Clone, Serialize, Deserialize)]
2074pub struct BinauralConfig {
2075 #[serde(default)]
2077 pub enabled: bool,
2078 #[serde(default)]
2080 pub head_position: Option<Point3DConfig>,
2081 #[serde(default)]
2083 pub head_yaw: f64,
2084 #[serde(default = "default_head_radius")]
2086 pub head_radius: f64,
2087 #[serde(default = "default_ear_spacing")]
2089 pub ear_spacing: f64,
2090 #[serde(default)]
2092 pub ir_config: ImpulseResponseConfig,
2093}
2094
2095fn default_head_radius() -> f64 {
2096 DEFAULT_HEAD_RADIUS
2097}
2098fn default_ear_spacing() -> f64 {
2099 DEFAULT_EAR_SPACING
2100}
2101
2102impl Default for BinauralConfig {
2103 fn default() -> Self {
2104 Self {
2105 enabled: false,
2106 head_position: None,
2107 head_yaw: 0.0,
2108 head_radius: DEFAULT_HEAD_RADIUS,
2109 ear_spacing: DEFAULT_EAR_SPACING,
2110 ir_config: ImpulseResponseConfig::default(),
2111 }
2112 }
2113}
2114
2115pub fn calculate_ear_positions(
2117 head_center: &Point3D,
2118 head_yaw_deg: f64,
2119 ear_spacing: f64,
2120) -> (Point3D, Point3D) {
2121 let yaw_rad = head_yaw_deg.to_radians();
2122 let half_spacing = ear_spacing / 2.0;
2123
2124 let ear_dx = -yaw_rad.sin() * half_spacing;
2127 let ear_dy = yaw_rad.cos() * half_spacing;
2128
2129 let left_ear = Point3D::new(
2131 head_center.x - ear_dx,
2132 head_center.y - ear_dy,
2133 head_center.z,
2134 );
2135
2136 let right_ear = Point3D::new(
2138 head_center.x + ear_dx,
2139 head_center.y + ear_dy,
2140 head_center.z,
2141 );
2142
2143 (left_ear, right_ear)
2144}
2145
2146pub fn calculate_itd(
2155 source: &Point3D,
2156 head_center: &Point3D,
2157 head_yaw_deg: f64,
2158 head_radius: f64,
2159 speed_of_sound: f64,
2160) -> f64 {
2161 let dx = source.x - head_center.x;
2163 let dy = source.y - head_center.y;
2164
2165 let source_azimuth = dx.atan2(dy);
2167
2168 let head_facing = head_yaw_deg.to_radians();
2170
2171 let theta = source_azimuth - head_facing;
2173
2174 (head_radius / speed_of_sound) * (theta + theta.sin())
2176}
2177
2178pub fn approximate_hrtf_magnitude(
2185 source: &Point3D,
2186 head_center: &Point3D,
2187 head_yaw_deg: f64,
2188 frequency: f64,
2189) -> (f64, f64) {
2190 let dx = source.x - head_center.x;
2192 let dy = source.y - head_center.y;
2193 let source_azimuth = dx.atan2(dy);
2194 let head_facing = head_yaw_deg.to_radians();
2195 let theta = source_azimuth - head_facing;
2196
2197 let freq_factor = (frequency / 1000.0).clamp(0.1, 4.0);
2200
2201 let max_ild_db = 6.0 * freq_factor; let ild_db = max_ild_db * theta.sin();
2206
2207 let left_gain = 10.0_f64.powf(-ild_db / 20.0);
2209 let right_gain = 10.0_f64.powf(ild_db / 20.0);
2210
2211 let avg = (left_gain + right_gain) / 2.0;
2213 (left_gain / avg, right_gain / avg)
2214}
2215
2216pub fn calculate_binaural_response(
2218 frequencies: &[f64],
2219 left_pressures: &[Complex64],
2220 right_pressures: &[Complex64],
2221 config: &BinauralConfig,
2222 _speed_of_sound: f64,
2223 head_position: Point3D,
2224) -> BinauralResponse {
2225 let left_ir = calculate_impulse_response(frequencies, left_pressures, &config.ir_config);
2227 let right_ir = calculate_impulse_response(frequencies, right_pressures, &config.ir_config);
2228
2229 let mut itd_sum = 0.0;
2231 let mut weight_sum = 0.0;
2232
2233 for i in 0..frequencies
2234 .len()
2235 .min(left_pressures.len())
2236 .min(right_pressures.len())
2237 {
2238 let left_phase = left_pressures[i].arg();
2239 let right_phase = right_pressures[i].arg();
2240 let freq = frequencies[i];
2241
2242 if freq > 20.0 && freq < 1500.0 {
2243 let phase_diff = left_phase - right_phase;
2245 let itd_sample = phase_diff / (2.0 * PI * freq);
2246 let weight = (left_pressures[i].norm() + right_pressures[i].norm()) / 2.0;
2247 itd_sum += itd_sample * weight;
2248 weight_sum += weight;
2249 }
2250 }
2251
2252 let itd = if weight_sum > 1e-10 {
2253 itd_sum / weight_sum
2254 } else {
2255 0.0
2256 };
2257
2258 let left_energy: f64 = left_pressures.iter().map(|p| p.norm_sqr()).sum();
2260 let right_energy: f64 = right_pressures.iter().map(|p| p.norm_sqr()).sum();
2261
2262 let ild = if right_energy > 1e-10 {
2263 10.0 * (left_energy / right_energy).log10()
2264 } else if left_energy > 1e-10 {
2265 20.0 } else {
2267 0.0
2268 };
2269
2270 BinauralResponse {
2271 left: left_ir,
2272 right: right_ir,
2273 itd,
2274 ild,
2275 head_position: [head_position.x, head_position.y, head_position.z],
2276 head_yaw: config.head_yaw,
2277 }
2278}
2279
2280pub fn edge_diffraction_coefficient(
2298 wedge_angle: f64, r_source: f64, r_receiver: f64, theta_source: f64, theta_receiver: f64, k: f64, ) -> Complex64 {
2305 let n = PI / wedge_angle;
2307
2308 let path_length = r_source + r_receiver;
2310
2311 let nu = n;
2314
2315 let beta_plus = theta_source + theta_receiver;
2317 let beta_minus = theta_source - theta_receiver;
2318
2319 let d_plus = cot_diffraction_term(beta_plus, nu);
2321 let d_minus = cot_diffraction_term(beta_minus, nu);
2322
2323 let distance_factor = 1.0 / (4.0 * PI * (r_source * r_receiver).sqrt());
2325
2326 let phase = Complex64::new(0.0, k * path_length).exp();
2328
2329 let diffraction_amplitude = distance_factor * (d_plus + d_minus).abs();
2331
2332 phase * diffraction_amplitude
2333}
2334
2335fn cot_diffraction_term(beta: f64, nu: f64) -> f64 {
2337 let arg = (PI + beta) / (2.0 * nu);
2340
2341 if arg.sin().abs() < 1e-10 {
2343 return 0.0;
2344 }
2345
2346 arg.cos() / arg.sin()
2347}
2348
2349#[derive(Debug, Clone)]
2351pub struct DiffractionEdge {
2352 pub start: Point3D,
2354 pub end: Point3D,
2356 pub wedge_angle: f64,
2358}
2359
2360impl DiffractionEdge {
2361 pub fn new(start: Point3D, end: Point3D, wedge_angle: f64) -> Self {
2362 Self {
2363 start,
2364 end,
2365 wedge_angle,
2366 }
2367 }
2368
2369 pub fn direction(&self) -> Point3D {
2371 let dx = self.end.x - self.start.x;
2372 let dy = self.end.y - self.start.y;
2373 let dz = self.end.z - self.start.z;
2374 let len = (dx * dx + dy * dy + dz * dz).sqrt();
2375 Point3D::new(dx / len, dy / len, dz / len)
2376 }
2377
2378 pub fn length(&self) -> f64 {
2380 self.start.distance_to(&self.end)
2381 }
2382
2383 pub fn closest_point(&self, point: &Point3D) -> Point3D {
2385 let edge_vec = Point3D::new(
2386 self.end.x - self.start.x,
2387 self.end.y - self.start.y,
2388 self.end.z - self.start.z,
2389 );
2390 let point_vec = Point3D::new(
2391 point.x - self.start.x,
2392 point.y - self.start.y,
2393 point.z - self.start.z,
2394 );
2395
2396 let edge_len_sq =
2397 edge_vec.x * edge_vec.x + edge_vec.y * edge_vec.y + edge_vec.z * edge_vec.z;
2398 if edge_len_sq < 1e-10 {
2399 return self.start;
2400 }
2401
2402 let dot = point_vec.x * edge_vec.x + point_vec.y * edge_vec.y + point_vec.z * edge_vec.z;
2403 let t = (dot / edge_len_sq).clamp(0.0, 1.0);
2404
2405 Point3D::new(
2406 self.start.x + t * edge_vec.x,
2407 self.start.y + t * edge_vec.y,
2408 self.start.z + t * edge_vec.z,
2409 )
2410 }
2411
2412 pub fn diffraction_contribution(
2414 &self,
2415 source: &Point3D,
2416 receiver: &Point3D,
2417 k: f64,
2418 ) -> Complex64 {
2419 let midpoint = Point3D::new(
2421 (source.x + receiver.x) / 2.0,
2422 (source.y + receiver.y) / 2.0,
2423 (source.z + receiver.z) / 2.0,
2424 );
2425 let diff_point = self.closest_point(&midpoint);
2426
2427 let r_source = source.distance_to(&diff_point);
2429 let r_receiver = receiver.distance_to(&diff_point);
2430
2431 if r_source < 0.01 || r_receiver < 0.01 {
2433 return Complex64::new(0.0, 0.0);
2434 }
2435
2436 let theta_source = PI / 4.0; let theta_receiver = PI / 4.0;
2440
2441 edge_diffraction_coefficient(
2442 self.wedge_angle,
2443 r_source,
2444 r_receiver,
2445 theta_source,
2446 theta_receiver,
2447 k,
2448 )
2449 }
2450}
2451
2452pub fn get_rectangular_room_edges(width: f64, depth: f64, height: f64) -> Vec<DiffractionEdge> {
2454 let wedge_90 = PI / 2.0; vec![
2457 DiffractionEdge::new(
2459 Point3D::new(0.0, 0.0, 0.0),
2460 Point3D::new(width, 0.0, 0.0),
2461 wedge_90,
2462 ),
2463 DiffractionEdge::new(
2464 Point3D::new(width, 0.0, 0.0),
2465 Point3D::new(width, depth, 0.0),
2466 wedge_90,
2467 ),
2468 DiffractionEdge::new(
2469 Point3D::new(width, depth, 0.0),
2470 Point3D::new(0.0, depth, 0.0),
2471 wedge_90,
2472 ),
2473 DiffractionEdge::new(
2474 Point3D::new(0.0, depth, 0.0),
2475 Point3D::new(0.0, 0.0, 0.0),
2476 wedge_90,
2477 ),
2478 DiffractionEdge::new(
2480 Point3D::new(0.0, 0.0, height),
2481 Point3D::new(width, 0.0, height),
2482 wedge_90,
2483 ),
2484 DiffractionEdge::new(
2485 Point3D::new(width, 0.0, height),
2486 Point3D::new(width, depth, height),
2487 wedge_90,
2488 ),
2489 DiffractionEdge::new(
2490 Point3D::new(width, depth, height),
2491 Point3D::new(0.0, depth, height),
2492 wedge_90,
2493 ),
2494 DiffractionEdge::new(
2495 Point3D::new(0.0, depth, height),
2496 Point3D::new(0.0, 0.0, height),
2497 wedge_90,
2498 ),
2499 DiffractionEdge::new(
2501 Point3D::new(0.0, 0.0, 0.0),
2502 Point3D::new(0.0, 0.0, height),
2503 wedge_90,
2504 ),
2505 DiffractionEdge::new(
2506 Point3D::new(width, 0.0, 0.0),
2507 Point3D::new(width, 0.0, height),
2508 wedge_90,
2509 ),
2510 DiffractionEdge::new(
2511 Point3D::new(width, depth, 0.0),
2512 Point3D::new(width, depth, height),
2513 wedge_90,
2514 ),
2515 DiffractionEdge::new(
2516 Point3D::new(0.0, depth, 0.0),
2517 Point3D::new(0.0, depth, height),
2518 wedge_90,
2519 ),
2520 ]
2521}
2522
2523fn greens_function_3d(r: f64, k: f64) -> Complex64 {
2529 if r < 1e-10 {
2530 return Complex64::new(0.0, 0.0);
2531 }
2532 let ikr = Complex64::new(0.0, k * r);
2533 ikr.exp() / (4.0 * PI * r)
2534}
2535
2536fn pressure_to_spl(pressure: Complex64) -> f64 {
2538 let magnitude = pressure.norm();
2539 let p_ref = 20e-6;
2540 20.0 * (magnitude / p_ref).max(1e-10).log10()
2541}
2542
2543fn log_space(start: f64, end: f64, num: usize) -> Vec<f64> {
2545 if num <= 1 {
2546 return vec![start];
2547 }
2548 let log_start = start.ln();
2549 let log_end = end.ln();
2550 (0..num)
2551 .map(|i| {
2552 let log_val = log_start + (log_end - log_start) * i as f64 / (num - 1) as f64;
2553 log_val.exp()
2554 })
2555 .collect()
2556}
2557
2558fn lin_space(start: f64, end: f64, num: usize) -> Vec<f64> {
2560 if num <= 1 {
2561 return vec![start];
2562 }
2563 (0..num)
2564 .map(|i| start + (end - start) * i as f64 / (num - 1) as f64)
2565 .collect()
2566}
2567
2568fn create_cardioid_pattern(front_back_ratio: f64) -> DirectivityPattern {
2570 let horizontal_angles: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
2571 let vertical_angles: Vec<f64> = (0..19).map(|i| i as f64 * 10.0).collect();
2572
2573 let mut magnitude = Array2::zeros((vertical_angles.len(), horizontal_angles.len()));
2574
2575 let a = (front_back_ratio + 1.0) / (2.0 * front_back_ratio);
2578 let b = (front_back_ratio - 1.0) / (2.0 * front_back_ratio);
2579
2580 for (v_idx, &theta_deg) in vertical_angles.iter().enumerate() {
2581 for (h_idx, &phi_deg) in horizontal_angles.iter().enumerate() {
2582 let theta = theta_deg.to_radians();
2583 let phi = phi_deg.to_radians();
2584 let cos_angle = theta.sin() * phi.cos();
2585 let response = (a + b * cos_angle).max(0.0);
2586 magnitude[[v_idx, h_idx]] = response;
2587 }
2588 }
2589
2590 DirectivityPattern {
2591 horizontal_angles,
2592 vertical_angles,
2593 magnitude,
2594 }
2595}
2596
2597fn create_spinorama_pattern(
2610 horizontal: &[SpinoramaCurve],
2611 vertical: &[SpinoramaCurve],
2612 frequency: f64,
2613) -> DirectivityPattern {
2614 let horizontal_angles: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
2616 let vertical_angles: Vec<f64> = (0..19).map(|i| i as f64 * 10.0).collect();
2617
2618 let mut magnitude = Array2::ones((vertical_angles.len(), horizontal_angles.len()));
2619
2620 let on_axis_spl = horizontal
2622 .iter()
2623 .find(|c| (c.angle - 0.0).abs() < 0.5)
2624 .map(|c| interpolate_spl_at_frequency(&c.freq, &c.spl, frequency))
2625 .unwrap_or(0.0);
2626
2627 let get_spl_at_angle = |curves: &[SpinoramaCurve], angle: f64| -> f64 {
2629 let search_angle = if angle > 180.0 { angle - 360.0 } else { angle };
2632
2633 let mut closest_below: Option<&SpinoramaCurve> = None;
2635 let mut closest_above: Option<&SpinoramaCurve> = None;
2636
2637 for curve in curves {
2638 if curve.angle <= search_angle
2639 && (closest_below.is_none() || curve.angle > closest_below.unwrap().angle)
2640 {
2641 closest_below = Some(curve);
2642 }
2643 if curve.angle >= search_angle
2644 && (closest_above.is_none() || curve.angle < closest_above.unwrap().angle)
2645 {
2646 closest_above = Some(curve);
2647 }
2648 }
2649
2650 match (closest_below, closest_above) {
2651 (Some(below), Some(above)) if (below.angle - above.angle).abs() < 0.5 => {
2652 interpolate_spl_at_frequency(&below.freq, &below.spl, frequency)
2654 }
2655 (Some(below), Some(above)) => {
2656 let spl_below = interpolate_spl_at_frequency(&below.freq, &below.spl, frequency);
2658 let spl_above = interpolate_spl_at_frequency(&above.freq, &above.spl, frequency);
2659 let t = (search_angle - below.angle) / (above.angle - below.angle);
2660 spl_below * (1.0 - t) + spl_above * t
2661 }
2662 (Some(only), None) | (None, Some(only)) => {
2663 interpolate_spl_at_frequency(&only.freq, &only.spl, frequency)
2664 }
2665 (None, None) => on_axis_spl, }
2667 };
2668
2669 for (v_idx, &theta_deg) in vertical_angles.iter().enumerate() {
2675 for (h_idx, &phi_deg) in horizontal_angles.iter().enumerate() {
2676 let h_spl = get_spl_at_angle(horizontal, phi_deg);
2678 let v_spl = get_spl_at_angle(vertical, theta_deg);
2679
2680 let theta_rad = theta_deg.to_radians();
2682 let blend = theta_rad.sin(); let combined_spl = h_spl * blend + v_spl * (1.0 - blend);
2685
2686 let db_diff = combined_spl - on_axis_spl;
2688 let linear_mag = 10.0_f64.powf(db_diff / 20.0);
2689
2690 magnitude[[v_idx, h_idx]] = linear_mag.clamp(0.0, 10.0);
2691 }
2692 }
2693
2694 DirectivityPattern {
2695 horizontal_angles,
2696 vertical_angles,
2697 magnitude,
2698 }
2699}
2700
2701fn interpolate_spl_at_frequency(freq: &[f64], spl: &[f64], target_freq: f64) -> f64 {
2703 if freq.is_empty() || spl.is_empty() {
2704 return 0.0;
2705 }
2706
2707 if target_freq <= freq[0] {
2709 return spl[0];
2710 }
2711 if target_freq >= freq[freq.len() - 1] {
2712 return spl[spl.len() - 1];
2713 }
2714
2715 for i in 0..freq.len() - 1 {
2717 if target_freq >= freq[i] && target_freq < freq[i + 1] {
2718 let log_f = target_freq.ln();
2719 let log_f1 = freq[i].ln();
2720 let log_f2 = freq[i + 1].ln();
2721 let t = (log_f - log_f1) / (log_f2 - log_f1);
2722 return spl[i] * (1.0 - t) + spl[i + 1] * t;
2723 }
2724 }
2725
2726 spl[spl.len() - 1]
2727}
2728
2729#[wasm_bindgen]
2735pub struct RoomSimulatorWasm {
2736 config: SimulationConfig,
2737 room_geometry: RoomGeometry,
2738 sources: Vec<Source>,
2739 listening_position: Point3D,
2740 frequencies: Vec<f64>,
2741 speed_of_sound: f64,
2742 wall_materials: WallMaterialsConfig,
2743 temperature: f64,
2745 humidity: f64,
2747 air_absorption_enabled: bool,
2749 edge_diffraction_enabled: bool,
2751 diffraction_edges: Vec<DiffractionEdge>,
2753}
2754
2755#[wasm_bindgen]
2756impl RoomSimulatorWasm {
2757 #[wasm_bindgen(constructor)]
2759 pub fn new(config_json: &str) -> Result<RoomSimulatorWasm, JsValue> {
2760 init_panic_hook();
2761
2762 let config: SimulationConfig = serde_json::from_str(config_json)
2763 .map_err(|e| JsValue::from_str(&format!("Config parse error: {}", e)))?;
2764
2765 let room_geometry = match &config.room {
2767 RoomGeometryConfig::Rectangular {
2768 width,
2769 depth,
2770 height,
2771 } => {
2772 console_log!(
2773 "Creating rectangular room: {}x{}x{} m",
2774 width,
2775 depth,
2776 height
2777 );
2778 RoomGeometry::Rectangular(RectangularRoom::new(*width, *depth, *height))
2779 }
2780 RoomGeometryConfig::LShaped {
2781 width1,
2782 depth1,
2783 width2,
2784 depth2,
2785 height,
2786 } => {
2787 console_log!(
2788 "Creating L-shaped room: {}x{} + {}x{} x {} m",
2789 width1,
2790 depth1,
2791 width2,
2792 depth2,
2793 height
2794 );
2795 RoomGeometry::LShaped(LShapedRoom::new(
2796 *width1, *depth1, *width2, *depth2, *height,
2797 ))
2798 }
2799 };
2800
2801 let sources: Vec<Source> = config
2803 .sources
2804 .iter()
2805 .map(|s| {
2806 let directivity = match &s.directivity {
2807 DirectivityConfig::Omnidirectional => DirectivityPattern::omnidirectional(),
2808 DirectivityConfig::Cardioid { front_back_ratio } => {
2809 create_cardioid_pattern(*front_back_ratio)
2810 }
2811 DirectivityConfig::Spinorama {
2812 horizontal,
2813 vertical,
2814 } => {
2815 create_spinorama_pattern(horizontal, vertical, 1000.0)
2818 }
2819 };
2820
2821 Source::new(s.position.into(), directivity, s.amplitude)
2822 .with_name(s.name.clone())
2823 .with_crossover(s.crossover.to_filter())
2824 .with_delay_ms(s.delay_ms)
2825 .with_phase_inversion(s.invert_phase)
2826 })
2827 .collect();
2828
2829 console_log!("Created {} sources", sources.len());
2830 for source in &sources {
2831 if source.delay_sec > 0.0 || source.invert_phase {
2832 console_log!(
2833 " {}: delay={:.2}ms, invert={}",
2834 source.name,
2835 source.delay_sec * 1000.0,
2836 source.invert_phase
2837 );
2838 }
2839 }
2840
2841 let listening_position = config
2842 .listening_positions
2843 .first()
2844 .map(|p| (*p).into())
2845 .unwrap_or(Point3D::new(0.0, 0.0, 0.0));
2846
2847 let frequencies = if config.frequencies.spacing == "linear" {
2848 lin_space(
2849 config.frequencies.min_freq,
2850 config.frequencies.max_freq,
2851 config.frequencies.num_points,
2852 )
2853 } else {
2854 log_space(
2855 config.frequencies.min_freq,
2856 config.frequencies.max_freq,
2857 config.frequencies.num_points,
2858 )
2859 };
2860
2861 console_log!(
2862 "Frequency range: {:.1} - {:.1} Hz ({} points)",
2863 frequencies.first().unwrap_or(&0.0),
2864 frequencies.last().unwrap_or(&0.0),
2865 frequencies.len()
2866 );
2867
2868 let speed_of_sound = config.solver.speed_of_sound;
2869 let wall_materials = config.wall_materials.clone();
2870 let temperature = config.solver.temperature;
2871 let humidity = config.solver.humidity;
2872 let air_absorption_enabled = config.solver.air_absorption;
2873
2874 console_log!(
2876 "Wall materials: Left={}, Right={}, Front={}, Back={}, Floor={}, Ceiling={}",
2877 wall_materials.get_material(WallSurface::Left).name,
2878 wall_materials.get_material(WallSurface::Right).name,
2879 wall_materials.get_material(WallSurface::Front).name,
2880 wall_materials.get_material(WallSurface::Back).name,
2881 wall_materials.get_material(WallSurface::Floor).name,
2882 wall_materials.get_material(WallSurface::Ceiling).name,
2883 );
2884
2885 if air_absorption_enabled {
2887 console_log!(
2888 "Air absorption enabled: T={:.1}°C, RH={:.0}%",
2889 temperature,
2890 humidity
2891 );
2892 }
2893
2894 let edge_diffraction_enabled = config.solver.edge_diffraction;
2895
2896 let diffraction_edges = if edge_diffraction_enabled {
2898 match &room_geometry {
2899 RoomGeometry::Rectangular(r) => {
2900 console_log!("Edge diffraction enabled: computing 12 room edges");
2901 get_rectangular_room_edges(r.width, r.depth, r.height)
2902 }
2903 RoomGeometry::LShaped(l) => {
2904 console_log!("Edge diffraction enabled for L-shaped room (simplified)");
2907 get_rectangular_room_edges(l.width1, l.depth1 + l.depth2, l.height)
2908 }
2909 }
2910 } else {
2911 Vec::new()
2912 };
2913
2914 Ok(RoomSimulatorWasm {
2915 config,
2916 room_geometry,
2917 sources,
2918 listening_position,
2919 frequencies,
2920 speed_of_sound,
2921 wall_materials,
2922 temperature,
2923 humidity,
2924 air_absorption_enabled,
2925 edge_diffraction_enabled,
2926 diffraction_edges,
2927 })
2928 }
2929
2930 fn wavenumber(&self, frequency: f64) -> f64 {
2931 2.0 * PI * frequency / self.speed_of_sound
2932 }
2933
2934 fn point_inside_room(&self, point: &Point3D) -> bool {
2936 match &self.room_geometry {
2937 RoomGeometry::Rectangular(r) => {
2938 point.x >= 0.0
2939 && point.x <= r.width
2940 && point.y >= 0.0
2941 && point.y <= r.depth
2942 && point.z >= 0.0
2943 && point.z <= r.height
2944 }
2945 RoomGeometry::LShaped(l) => l.contains(point),
2946 }
2947 }
2948
2949 fn air_absorption_factor(&self, distance: f64, frequency: f64) -> f64 {
2952 if !self.air_absorption_enabled || distance < 1e-6 {
2953 return 1.0;
2954 }
2955
2956 let alpha = calculate_air_absorption(frequency, self.temperature, self.humidity);
2957 (-alpha * distance).exp()
2959 }
2960
2961 fn calculate_direct_field(&self, point: &Point3D, frequency: f64) -> Complex64 {
2962 let (room_width, room_depth, room_height) = self.get_room_dimensions();
2963
2964 let method = self.config.solver.method.as_str();
2966
2967 if method == "bem" || method == "hybrid-bem" {
2969 return self.calculate_bem_or_hybrid_bem(point, frequency, method);
2970 }
2971
2972 if method == "modal" || method == "hybrid" {
2974 if let RoomGeometry::Rectangular(_) = &self.room_geometry {
2976 let mut modal_pressure = Complex64::new(0.0, 0.0);
2978 for source in &self.sources {
2979 let amplitude = source.amplitude_towards(point, frequency);
2980 let phase_factor = source.phase_factor(frequency);
2981
2982 let modal = calculate_modal_pressure(
2983 &source.position,
2984 point,
2985 frequency,
2986 room_width,
2987 room_depth,
2988 room_height,
2989 self.config.solver.speed_of_sound,
2990 self.config.solver.max_mode_order,
2991 self.config.solver.modal_damping,
2992 );
2993
2994 modal_pressure += modal * amplitude * phase_factor;
2995 }
2996
2997 if method == "modal" {
2998 return modal_pressure;
3000 }
3001
3002 let volume = room_width * room_depth * room_height;
3004 let avg_absorption = self.wall_materials.average_absorption_at(frequency);
3005 let surface_area = 2.0
3006 * (room_width * room_depth
3007 + room_width * room_height
3008 + room_depth * room_height);
3009 let total_abs = avg_absorption * surface_area;
3010 let rt60 = rt60_sabine(volume, total_abs);
3011 let schroeder_freq = 2000.0 * (rt60 / volume).sqrt();
3012
3013 let ism_weight = hybrid_crossover_weight(
3014 frequency,
3015 schroeder_freq,
3016 self.config.solver.hybrid_crossover_width,
3017 );
3018
3019 if ism_weight < 1e-6 {
3021 return modal_pressure;
3022 }
3023
3024 let ism_pressure = self.calculate_ism_field(point, frequency);
3026
3027 return modal_pressure * (1.0 - ism_weight) + ism_pressure * ism_weight;
3029 }
3030 }
3031
3032 self.calculate_ism_field(point, frequency)
3034 }
3035
3036 fn calculate_bem_or_hybrid_bem(
3044 &self,
3045 point: &Point3D,
3046 frequency: f64,
3047 method: &str,
3048 ) -> Complex64 {
3049 let (room_width, room_depth, room_height) = self.get_room_dimensions();
3050
3051 let low_freq_pressure = if let RoomGeometry::Rectangular(_) = &self.room_geometry {
3053 let mut modal_pressure = Complex64::new(0.0, 0.0);
3055 for source in &self.sources {
3056 let amplitude = source.amplitude_towards(point, frequency);
3057 let phase_factor = source.phase_factor(frequency);
3058
3059 let modal = calculate_modal_pressure(
3060 &source.position,
3061 point,
3062 frequency,
3063 room_width,
3064 room_depth,
3065 room_height,
3066 self.config.solver.speed_of_sound,
3067 self.config.solver.max_mode_order,
3068 self.config.solver.modal_damping,
3069 );
3070
3071 modal_pressure += modal * amplitude * phase_factor;
3072 }
3073
3074 if !self.config.scattering_objects.is_empty() {
3076 let scattering_result = bem_solver::solve_bem(
3077 &self.room_geometry,
3078 &self.sources,
3079 &self.config.scattering_objects,
3080 point,
3081 frequency,
3082 self.config.solver.speed_of_sound,
3083 &self.config.solver.bem_config,
3084 );
3085 if let Ok(result) = scattering_result {
3086 let k =
3088 2.0 * std::f64::consts::PI * frequency / self.config.solver.speed_of_sound;
3089 let mut direct_field = Complex64::new(0.0, 0.0);
3090 for source in &self.sources {
3091 let amp = source.amplitude_towards(point, frequency);
3092 let dist = source.position.distance_to(point);
3093 direct_field += bem_solver::greens_function(dist, k)
3094 * amp
3095 * source.phase_factor(frequency);
3096 }
3097 let scattered = result.pressure - direct_field;
3099 modal_pressure += scattered;
3100 }
3101 }
3102
3103 modal_pressure
3104 } else {
3105 let bem_result = bem_solver::solve_bem(
3107 &self.room_geometry,
3108 &self.sources,
3109 &self.config.scattering_objects,
3110 point,
3111 frequency,
3112 self.config.solver.speed_of_sound,
3113 &self.config.solver.bem_config,
3114 );
3115
3116 match bem_result {
3117 Ok(result) => result.pressure,
3118 Err(_) => return self.calculate_ism_field(point, frequency),
3119 }
3120 };
3121
3122 if method == "bem" {
3123 return low_freq_pressure;
3125 }
3126
3127 let volume = room_width * room_depth * room_height;
3130 let avg_absorption = self.wall_materials.average_absorption_at(frequency);
3131 let surface_area =
3132 2.0 * (room_width * room_depth + room_width * room_height + room_depth * room_height);
3133 let total_abs = avg_absorption * surface_area;
3134 let rt60 = rt60_sabine(volume, total_abs);
3135 let schroeder_freq = 2000.0 * (rt60 / volume).sqrt();
3136
3137 let ism_weight = hybrid_crossover_weight(
3139 frequency,
3140 schroeder_freq,
3141 self.config.solver.hybrid_crossover_width,
3142 );
3143
3144 if ism_weight < 1e-6 {
3146 return low_freq_pressure;
3147 }
3148
3149 if ism_weight > 1.0 - 1e-6 {
3151 return self.calculate_ism_field(point, frequency);
3152 }
3153
3154 let ism_pressure = self.calculate_ism_field(point, frequency);
3156 low_freq_pressure * (1.0 - ism_weight) + ism_pressure * ism_weight
3157 }
3158
3159 fn calculate_ism_field(&self, point: &Point3D, frequency: f64) -> Complex64 {
3161 let k = self.wavenumber(frequency);
3162 let mut total_pressure = Complex64::new(0.0, 0.0);
3163
3164 let reflection_order = match self.config.solver.method.as_str() {
3166 "direct" => 0,
3167 "image-source-1" => 1,
3168 "image-source-2" => 2,
3169 "image-source-3" => 3,
3170 "modal" => 0, "bem" => 0, "hybrid" => 2, "hybrid-bem" => 2, _ => 2, };
3176
3177 let (room_width, room_depth, room_height) = self.get_room_dimensions();
3179
3180 let r_left = self
3182 .wall_materials
3183 .reflection_at(WallSurface::Left, frequency);
3184 let r_right = self
3185 .wall_materials
3186 .reflection_at(WallSurface::Right, frequency);
3187 let r_front = self
3188 .wall_materials
3189 .reflection_at(WallSurface::Front, frequency);
3190 let r_back = self
3191 .wall_materials
3192 .reflection_at(WallSurface::Back, frequency);
3193 let r_floor = self
3194 .wall_materials
3195 .reflection_at(WallSurface::Floor, frequency);
3196 let r_ceiling = self
3197 .wall_materials
3198 .reflection_at(WallSurface::Ceiling, frequency);
3199
3200 for source in &self.sources {
3201 let amplitude = source.amplitude_towards(point, frequency);
3202 let phase_factor = source.phase_factor(frequency);
3204
3205 let r_direct = source.position.distance_to(point);
3207 let air_atten_direct = self.air_absorption_factor(r_direct, frequency);
3208 total_pressure +=
3209 greens_function_3d(r_direct, k) * amplitude * air_atten_direct * phase_factor;
3210
3211 if reflection_order >= 1 {
3212 let first_order_images: Vec<(Point3D, f64)> = match &self.room_geometry {
3214 RoomGeometry::LShaped(l_room) => {
3215 l_room
3217 .get_first_order_images(&source.position)
3218 .into_iter()
3219 .filter_map(|(image, wall_name)| {
3220 if !l_room.is_valid_image_source(&image, point, &source.position) {
3222 return None;
3223 }
3224 let refl = match wall_name {
3226 "left" => r_left,
3227 "right" => r_right,
3228 "front" => r_front,
3229 "back" => r_back,
3230 "floor" => r_floor,
3231 "ceiling" => r_ceiling,
3232 "step_horizontal" | "step_vertical" => {
3233 (r_right + r_back) / 2.0
3235 }
3236 _ => r_right, };
3238 Some((image, refl))
3239 })
3240 .collect()
3241 }
3242 RoomGeometry::Rectangular(_) => {
3243 vec![
3245 (
3247 Point3D::new(
3248 -source.position.x,
3249 source.position.y,
3250 source.position.z,
3251 ),
3252 r_left,
3253 ),
3254 (
3256 Point3D::new(
3257 2.0 * room_width - source.position.x,
3258 source.position.y,
3259 source.position.z,
3260 ),
3261 r_right,
3262 ),
3263 (
3265 Point3D::new(
3266 source.position.x,
3267 -source.position.y,
3268 source.position.z,
3269 ),
3270 r_front,
3271 ),
3272 (
3274 Point3D::new(
3275 source.position.x,
3276 2.0 * room_depth - source.position.y,
3277 source.position.z,
3278 ),
3279 r_back,
3280 ),
3281 (
3283 Point3D::new(
3284 source.position.x,
3285 source.position.y,
3286 -source.position.z,
3287 ),
3288 r_floor,
3289 ),
3290 (
3292 Point3D::new(
3293 source.position.x,
3294 source.position.y,
3295 2.0 * room_height - source.position.z,
3296 ),
3297 r_ceiling,
3298 ),
3299 ]
3300 }
3301 };
3302
3303 for (image_src, refl_coeff) in &first_order_images {
3304 let r_image = image_src.distance_to(point);
3305 if r_image > 1e-6 {
3306 let air_atten = self.air_absorption_factor(r_image, frequency);
3307 total_pressure += greens_function_3d(r_image, k)
3308 * amplitude
3309 * refl_coeff
3310 * air_atten
3311 * phase_factor;
3312 }
3313 }
3314 }
3315
3316 if let RoomGeometry::Rectangular(_) = &self.room_geometry {
3319 if reflection_order >= 2 {
3320 let second_order_images = [
3323 (
3325 Point3D::new(-source.position.x, -source.position.y, source.position.z),
3326 r_left * r_front,
3327 ),
3328 (
3330 Point3D::new(
3331 -source.position.x,
3332 2.0 * room_depth - source.position.y,
3333 source.position.z,
3334 ),
3335 r_left * r_back,
3336 ),
3337 (
3339 Point3D::new(
3340 2.0 * room_width - source.position.x,
3341 -source.position.y,
3342 source.position.z,
3343 ),
3344 r_right * r_front,
3345 ),
3346 (
3348 Point3D::new(
3349 2.0 * room_width - source.position.x,
3350 2.0 * room_depth - source.position.y,
3351 source.position.z,
3352 ),
3353 r_right * r_back,
3354 ),
3355 (
3357 Point3D::new(-source.position.x, source.position.y, -source.position.z),
3358 r_left * r_floor,
3359 ),
3360 (
3362 Point3D::new(
3363 -source.position.x,
3364 source.position.y,
3365 2.0 * room_height - source.position.z,
3366 ),
3367 r_left * r_ceiling,
3368 ),
3369 (
3371 Point3D::new(
3372 2.0 * room_width - source.position.x,
3373 source.position.y,
3374 -source.position.z,
3375 ),
3376 r_right * r_floor,
3377 ),
3378 (
3380 Point3D::new(
3381 2.0 * room_width - source.position.x,
3382 source.position.y,
3383 2.0 * room_height - source.position.z,
3384 ),
3385 r_right * r_ceiling,
3386 ),
3387 (
3389 Point3D::new(source.position.x, -source.position.y, -source.position.z),
3390 r_front * r_floor,
3391 ),
3392 (
3394 Point3D::new(
3395 source.position.x,
3396 -source.position.y,
3397 2.0 * room_height - source.position.z,
3398 ),
3399 r_front * r_ceiling,
3400 ),
3401 (
3403 Point3D::new(
3404 source.position.x,
3405 2.0 * room_depth - source.position.y,
3406 -source.position.z,
3407 ),
3408 r_back * r_floor,
3409 ),
3410 (
3412 Point3D::new(
3413 source.position.x,
3414 2.0 * room_depth - source.position.y,
3415 2.0 * room_height - source.position.z,
3416 ),
3417 r_back * r_ceiling,
3418 ),
3419 ];
3420
3421 for (image_src, refl_coeff) in &second_order_images {
3422 let r_image = image_src.distance_to(point);
3423 if r_image > 1e-6 {
3424 let air_atten = self.air_absorption_factor(r_image, frequency);
3425 total_pressure += greens_function_3d(r_image, k)
3426 * amplitude
3427 * refl_coeff
3428 * air_atten
3429 * phase_factor;
3430 }
3431 }
3432 }
3433
3434 if reflection_order >= 3 {
3435 let third_order_images = [
3438 (
3440 Point3D::new(
3441 -source.position.x,
3442 -source.position.y,
3443 -source.position.z,
3444 ),
3445 r_left * r_front * r_floor,
3446 ),
3447 (
3449 Point3D::new(
3450 -source.position.x,
3451 -source.position.y,
3452 2.0 * room_height - source.position.z,
3453 ),
3454 r_left * r_front * r_ceiling,
3455 ),
3456 (
3458 Point3D::new(
3459 -source.position.x,
3460 2.0 * room_depth - source.position.y,
3461 -source.position.z,
3462 ),
3463 r_left * r_back * r_floor,
3464 ),
3465 (
3467 Point3D::new(
3468 -source.position.x,
3469 2.0 * room_depth - source.position.y,
3470 2.0 * room_height - source.position.z,
3471 ),
3472 r_left * r_back * r_ceiling,
3473 ),
3474 (
3476 Point3D::new(
3477 2.0 * room_width - source.position.x,
3478 -source.position.y,
3479 -source.position.z,
3480 ),
3481 r_right * r_front * r_floor,
3482 ),
3483 (
3485 Point3D::new(
3486 2.0 * room_width - source.position.x,
3487 -source.position.y,
3488 2.0 * room_height - source.position.z,
3489 ),
3490 r_right * r_front * r_ceiling,
3491 ),
3492 (
3494 Point3D::new(
3495 2.0 * room_width - source.position.x,
3496 2.0 * room_depth - source.position.y,
3497 -source.position.z,
3498 ),
3499 r_right * r_back * r_floor,
3500 ),
3501 (
3503 Point3D::new(
3504 2.0 * room_width - source.position.x,
3505 2.0 * room_depth - source.position.y,
3506 2.0 * room_height - source.position.z,
3507 ),
3508 r_right * r_back * r_ceiling,
3509 ),
3510 ];
3511
3512 for (image_src, refl_coeff) in &third_order_images {
3513 let r_image = image_src.distance_to(point);
3514 if r_image > 1e-6 {
3515 let air_atten = self.air_absorption_factor(r_image, frequency);
3516 total_pressure += greens_function_3d(r_image, k)
3517 * amplitude
3518 * refl_coeff
3519 * air_atten
3520 * phase_factor;
3521 }
3522 }
3523 }
3524 }
3525
3526 if self.edge_diffraction_enabled && !self.diffraction_edges.is_empty() {
3528 for edge in &self.diffraction_edges {
3529 let diff_contrib = edge.diffraction_contribution(&source.position, point, k);
3530 total_pressure += diff_contrib * amplitude * phase_factor;
3532 }
3533 }
3534 }
3535
3536 total_pressure
3537 }
3538
3539 fn calculate_source_field(
3542 &self,
3543 source_idx: usize,
3544 point: &Point3D,
3545 frequency: f64,
3546 ) -> Complex64 {
3547 if source_idx >= self.sources.len() {
3548 return Complex64::new(0.0, 0.0);
3549 }
3550
3551 let (room_width, room_depth, room_height) = self.get_room_dimensions();
3552 let method = self.config.solver.method.as_str();
3553 let source = &self.sources[source_idx];
3554
3555 if method == "bem" || method == "hybrid-bem" {
3557 return self
3558 .calculate_single_source_bem_or_hybrid(source_idx, point, frequency, method);
3559 }
3560
3561 if (method == "modal" || method == "hybrid")
3563 && let RoomGeometry::Rectangular(_) = &self.room_geometry {
3564 let amplitude = source.amplitude_towards(point, frequency);
3565 let phase_factor = source.phase_factor(frequency);
3566
3567 let modal_pressure = calculate_modal_pressure(
3568 &source.position,
3569 point,
3570 frequency,
3571 room_width,
3572 room_depth,
3573 room_height,
3574 self.config.solver.speed_of_sound,
3575 self.config.solver.max_mode_order,
3576 self.config.solver.modal_damping,
3577 ) * amplitude
3578 * phase_factor;
3579
3580 if method == "modal" {
3581 return modal_pressure;
3582 }
3583
3584 let volume = room_width * room_depth * room_height;
3586 let avg_absorption = self.wall_materials.average_absorption_at(frequency);
3587 let surface_area = 2.0
3588 * (room_width * room_depth
3589 + room_width * room_height
3590 + room_depth * room_height);
3591 let total_abs = avg_absorption * surface_area;
3592 let rt60 = rt60_sabine(volume, total_abs);
3593 let schroeder_freq = 2000.0 * (rt60 / volume).sqrt();
3594
3595 let ism_weight = hybrid_crossover_weight(
3596 frequency,
3597 schroeder_freq,
3598 self.config.solver.hybrid_crossover_width,
3599 );
3600
3601 if ism_weight < 1e-6 {
3602 return modal_pressure;
3603 }
3604
3605 let ism_pressure = self.calculate_single_source_ism(source_idx, point, frequency);
3606 return modal_pressure * (1.0 - ism_weight) + ism_pressure * ism_weight;
3607 }
3608
3609 self.calculate_single_source_ism(source_idx, point, frequency)
3611 }
3612
3613 fn calculate_single_source_bem_or_hybrid(
3615 &self,
3616 source_idx: usize,
3617 point: &Point3D,
3618 frequency: f64,
3619 method: &str,
3620 ) -> Complex64 {
3621 let (room_width, room_depth, room_height) = self.get_room_dimensions();
3622 let source = &self.sources[source_idx];
3623
3624 let low_freq_pressure = if let RoomGeometry::Rectangular(_) = &self.room_geometry {
3626 let amplitude = source.amplitude_towards(point, frequency);
3627 let phase_factor = source.phase_factor(frequency);
3628
3629 calculate_modal_pressure(
3630 &source.position,
3631 point,
3632 frequency,
3633 room_width,
3634 room_depth,
3635 room_height,
3636 self.config.solver.speed_of_sound,
3637 self.config.solver.max_mode_order,
3638 self.config.solver.modal_damping,
3639 ) * amplitude
3640 * phase_factor
3641 } else {
3642 let k = self.wavenumber(frequency);
3644 let amplitude = source.amplitude_towards(point, frequency);
3645 let dist = source.position.distance_to(point);
3646 let phase_factor = source.phase_factor(frequency);
3647 greens_function_3d(dist, k) * amplitude * phase_factor
3648 };
3649
3650 if method == "bem" {
3651 return low_freq_pressure;
3652 }
3653
3654 let volume = room_width * room_depth * room_height;
3656 let avg_absorption = self.wall_materials.average_absorption_at(frequency);
3657 let surface_area =
3658 2.0 * (room_width * room_depth + room_width * room_height + room_depth * room_height);
3659 let total_abs = avg_absorption * surface_area;
3660 let rt60 = rt60_sabine(volume, total_abs);
3661 let schroeder_freq = 2000.0 * (rt60 / volume).sqrt();
3662
3663 let ism_weight = hybrid_crossover_weight(
3664 frequency,
3665 schroeder_freq,
3666 self.config.solver.hybrid_crossover_width,
3667 );
3668
3669 if ism_weight < 1e-6 {
3670 return low_freq_pressure;
3671 }
3672
3673 if ism_weight > 1.0 - 1e-6 {
3674 return self.calculate_single_source_ism(source_idx, point, frequency);
3675 }
3676
3677 let ism_pressure = self.calculate_single_source_ism(source_idx, point, frequency);
3678 low_freq_pressure * (1.0 - ism_weight) + ism_pressure * ism_weight
3679 }
3680
3681 fn calculate_single_source_ism(
3683 &self,
3684 source_idx: usize,
3685 point: &Point3D,
3686 frequency: f64,
3687 ) -> Complex64 {
3688 let k = self.wavenumber(frequency);
3689 let source = &self.sources[source_idx];
3690 let amplitude = source.amplitude_towards(point, frequency);
3691 let phase_factor = source.phase_factor(frequency);
3692
3693 let reflection_order = match self.config.solver.method.as_str() {
3695 "direct" => 0,
3696 "image-source-1" => 1,
3697 "image-source-2" => 2,
3698 "image-source-3" => 3,
3699 "modal" => 0,
3700 "bem" => 0,
3701 "hybrid" => 2,
3702 "hybrid-bem" => 2,
3703 _ => 2,
3704 };
3705
3706 let (room_width, room_depth, room_height) = self.get_room_dimensions();
3707
3708 let r_left = self
3710 .wall_materials
3711 .reflection_at(WallSurface::Left, frequency);
3712 let r_right = self
3713 .wall_materials
3714 .reflection_at(WallSurface::Right, frequency);
3715 let r_front = self
3716 .wall_materials
3717 .reflection_at(WallSurface::Front, frequency);
3718 let r_back = self
3719 .wall_materials
3720 .reflection_at(WallSurface::Back, frequency);
3721 let r_floor = self
3722 .wall_materials
3723 .reflection_at(WallSurface::Floor, frequency);
3724 let r_ceiling = self
3725 .wall_materials
3726 .reflection_at(WallSurface::Ceiling, frequency);
3727
3728 let mut total_pressure = Complex64::new(0.0, 0.0);
3729
3730 let r_direct = source.position.distance_to(point);
3732 let air_atten_direct = self.air_absorption_factor(r_direct, frequency);
3733 total_pressure +=
3734 greens_function_3d(r_direct, k) * amplitude * air_atten_direct * phase_factor;
3735
3736 if reflection_order >= 1 {
3737 let first_order_images: Vec<(Point3D, f64)> = match &self.room_geometry {
3739 RoomGeometry::LShaped(l_room) => l_room
3740 .get_first_order_images(&source.position)
3741 .into_iter()
3742 .filter_map(|(image, wall_name)| {
3743 if !l_room.is_valid_image_source(&image, point, &source.position) {
3744 return None;
3745 }
3746 let refl = match wall_name {
3747 "left" => r_left,
3748 "right" => r_right,
3749 "front" => r_front,
3750 "back" => r_back,
3751 "floor" => r_floor,
3752 "ceiling" => r_ceiling,
3753 "step_horizontal" | "step_vertical" => (r_right + r_back) / 2.0,
3754 _ => r_right,
3755 };
3756 Some((image, refl))
3757 })
3758 .collect(),
3759 RoomGeometry::Rectangular(_) => {
3760 vec![
3761 (
3762 Point3D::new(-source.position.x, source.position.y, source.position.z),
3763 r_left,
3764 ),
3765 (
3766 Point3D::new(
3767 2.0 * room_width - source.position.x,
3768 source.position.y,
3769 source.position.z,
3770 ),
3771 r_right,
3772 ),
3773 (
3774 Point3D::new(source.position.x, -source.position.y, source.position.z),
3775 r_front,
3776 ),
3777 (
3778 Point3D::new(
3779 source.position.x,
3780 2.0 * room_depth - source.position.y,
3781 source.position.z,
3782 ),
3783 r_back,
3784 ),
3785 (
3786 Point3D::new(source.position.x, source.position.y, -source.position.z),
3787 r_floor,
3788 ),
3789 (
3790 Point3D::new(
3791 source.position.x,
3792 source.position.y,
3793 2.0 * room_height - source.position.z,
3794 ),
3795 r_ceiling,
3796 ),
3797 ]
3798 }
3799 };
3800
3801 for (image_src, refl_coeff) in first_order_images {
3802 let r_image = image_src.distance_to(point);
3803 if r_image > 1e-6 {
3804 let air_atten = self.air_absorption_factor(r_image, frequency);
3805 total_pressure += greens_function_3d(r_image, k)
3806 * amplitude
3807 * refl_coeff
3808 * air_atten
3809 * phase_factor;
3810 }
3811 }
3812
3813 if reflection_order >= 2
3815 && let RoomGeometry::Rectangular(_) = &self.room_geometry {
3816 for nx in -2i32..=2 {
3817 for ny in -2i32..=2 {
3818 for nz in -2i32..=2 {
3819 let order = nx.abs() + ny.abs() + nz.abs();
3820 if order < 2 || order > reflection_order {
3821 continue;
3822 }
3823
3824 let image_x = if nx % 2 == 0 {
3825 source.position.x + (nx as f64) * room_width
3826 } else {
3827 -source.position.x + (nx as f64 + 1.0) * room_width
3828 };
3829 let image_y = if ny % 2 == 0 {
3830 source.position.y + (ny as f64) * room_depth
3831 } else {
3832 -source.position.y + (ny as f64 + 1.0) * room_depth
3833 };
3834 let image_z = if nz % 2 == 0 {
3835 source.position.z + (nz as f64) * room_height
3836 } else {
3837 -source.position.z + (nz as f64 + 1.0) * room_height
3838 };
3839
3840 let image_pos = Point3D::new(image_x, image_y, image_z);
3841 let r_image = image_pos.distance_to(point);
3842
3843 if r_image > 1e-6 {
3844 let refl_coeff = r_left.powi((nx.abs() + 1) / 2)
3845 * r_right.powi(nx.abs() / 2)
3846 * r_front.powi((ny.abs() + 1) / 2)
3847 * r_back.powi(ny.abs() / 2)
3848 * r_floor.powi((nz.abs() + 1) / 2)
3849 * r_ceiling.powi(nz.abs() / 2);
3850
3851 let air_atten = self.air_absorption_factor(r_image, frequency);
3852 total_pressure += greens_function_3d(r_image, k)
3853 * amplitude
3854 * refl_coeff
3855 * air_atten
3856 * phase_factor;
3857 }
3858 }
3859 }
3860 }
3861 }
3862 }
3863
3864 total_pressure
3865 }
3866
3867 #[wasm_bindgen]
3869 pub fn run_simulation(&self) -> Result<String, JsValue> {
3870 console_log!("Starting simulation...");
3871
3872 let compute_ir = self.config.visualization.generate_impulse_response;
3873 let compute_binaural = self.config.visualization.binaural.enabled;
3874
3875 let (left_ear_pos, right_ear_pos) = if compute_binaural {
3877 let binaural_config = &self.config.visualization.binaural;
3878 let head_center = binaural_config
3879 .head_position
3880 .as_ref()
3881 .map(|p| Point3D::new(p.x, p.y, p.z))
3882 .unwrap_or(self.listening_position);
3883 calculate_ear_positions(
3884 &head_center,
3885 binaural_config.head_yaw,
3886 binaural_config.ear_spacing,
3887 )
3888 } else {
3889 (self.listening_position, self.listening_position)
3890 };
3891
3892 let head_center_for_hrtf = self
3894 .config
3895 .visualization
3896 .binaural
3897 .head_position
3898 .as_ref()
3899 .map(|p| Point3D::new(p.x, p.y, p.z))
3900 .unwrap_or(self.listening_position);
3901 let head_yaw = self.config.visualization.binaural.head_yaw;
3902
3903 console_log!(
3904 "Computing frequency response using {} threads...",
3905 rayon::current_num_threads()
3906 );
3907
3908 let freq_results: Vec<_> = self
3911 .frequencies
3912 .par_iter()
3913 .enumerate()
3914 .map(|(idx, &freq)| {
3915 let pressure = self.calculate_direct_field(&self.listening_position, freq);
3917 let spl = pressure_to_spl(pressure);
3918
3919 let source_spls: Vec<f64> = (0..self.sources.len())
3921 .map(|src_idx| {
3922 let p =
3923 self.calculate_source_field(src_idx, &self.listening_position, freq);
3924 pressure_to_spl(p)
3925 })
3926 .collect();
3927
3928 let binaural = if compute_binaural {
3930 let left_pressure = self.calculate_direct_field(&left_ear_pos, freq);
3931 let right_pressure = self.calculate_direct_field(&right_ear_pos, freq);
3932
3933 let (mut left_total, mut right_total) = (left_pressure, right_pressure);
3935 for source in &self.sources {
3936 let (left_gain, right_gain) = approximate_hrtf_magnitude(
3937 &source.position,
3938 &head_center_for_hrtf,
3939 head_yaw,
3940 freq,
3941 );
3942 left_total *= Complex64::new(left_gain, 0.0);
3943 right_total *= Complex64::new(right_gain, 0.0);
3944 }
3945 Some((left_total, right_total))
3946 } else {
3947 None
3948 };
3949
3950 (idx, spl, pressure, source_spls, binaural)
3951 })
3952 .collect();
3953
3954 let mut combined_spl = Vec::with_capacity(self.frequencies.len());
3956 let mut complex_pressures = Vec::with_capacity(self.frequencies.len());
3957 let mut source_responses: Vec<Vec<f64>> = self
3958 .sources
3959 .iter()
3960 .map(|_| Vec::with_capacity(self.frequencies.len()))
3961 .collect();
3962 let mut left_ear_pressures = Vec::with_capacity(self.frequencies.len());
3963 let mut right_ear_pressures = Vec::with_capacity(self.frequencies.len());
3964
3965 for (_idx, spl, pressure, source_spls, binaural) in freq_results {
3966 combined_spl.push(spl);
3967
3968 if compute_ir {
3969 complex_pressures.push(pressure);
3970 }
3971
3972 for (src_idx, src_spl) in source_spls.into_iter().enumerate() {
3973 source_responses[src_idx].push(src_spl);
3974 }
3975
3976 if let Some((left, right)) = binaural {
3977 left_ear_pressures.push(left);
3978 right_ear_pressures.push(right);
3979 }
3980 }
3981
3982 console_log!("Frequency response computed");
3983
3984 let source_responses_output: Vec<SourceResponse> = self
3985 .sources
3986 .iter()
3987 .zip(source_responses)
3988 .map(|(source, spl)| SourceResponse {
3989 source_name: source.name.clone(),
3990 spl,
3991 })
3992 .collect();
3993
3994 let (horizontal_slices, vertical_slices) = if self.config.visualization.generate_slices {
3995 console_log!("Computing spatial slices...");
3996 self.compute_slices()
3997 } else {
3998 (None, None)
3999 };
4000
4001 let room_output = self.build_room_output();
4002
4003 let (room_modes, room_acoustics) = match &self.config.room {
4005 RoomGeometryConfig::Rectangular {
4006 width,
4007 depth,
4008 height,
4009 } => {
4010 let max_freq = self.frequencies.last().copied().unwrap_or(500.0);
4011 let modes = calculate_room_modes(
4012 *width,
4013 *depth,
4014 *height,
4015 self.config.solver.speed_of_sound,
4016 max_freq,
4017 10, );
4019 let acoustics =
4021 calculate_room_acoustics(*width, *depth, *height, &self.wall_materials, 1000.0);
4022 (Some(modes), Some(acoustics))
4023 }
4024 _ => (None, None), };
4026
4027 let impulse_response = if compute_ir && !complex_pressures.is_empty() {
4029 console_log!("Computing impulse response...");
4030 let ir_config = &self.config.visualization.impulse_response;
4031
4032 let ir_duration = ir_config
4034 .duration
4035 .or_else(|| room_acoustics.as_ref().map(|ra| ra.rt60_eyring + 0.1));
4036
4037 let config_with_duration = ImpulseResponseConfig {
4038 duration: ir_duration,
4039 ..ir_config.clone()
4040 };
4041
4042 Some(calculate_impulse_response(
4043 &self.frequencies,
4044 &complex_pressures,
4045 &config_with_duration,
4046 ))
4047 } else {
4048 None
4049 };
4050
4051 let binaural_response = if compute_binaural && !left_ear_pressures.is_empty() {
4053 console_log!("Computing binaural response...");
4054 let binaural_config = &self.config.visualization.binaural;
4055 let head_center = binaural_config
4056 .head_position
4057 .as_ref()
4058 .map(|p| Point3D::new(p.x, p.y, p.z))
4059 .unwrap_or(self.listening_position);
4060
4061 Some(calculate_binaural_response(
4062 &self.frequencies,
4063 &left_ear_pressures,
4064 &right_ear_pressures,
4065 binaural_config,
4066 self.config.solver.speed_of_sound,
4067 head_center,
4068 ))
4069 } else {
4070 None
4071 };
4072
4073 let sources_output: Vec<SourceOutputInfo> = self
4074 .sources
4075 .iter()
4076 .zip(self.config.sources.iter())
4077 .map(|(source, config)| {
4078 let crossover_str = match &config.crossover {
4079 CrossoverConfig::FullRange => None,
4080 CrossoverConfig::Lowpass { cutoff_freq, order } => {
4081 Some(format!("LP {}Hz {}nd", cutoff_freq, order))
4082 }
4083 CrossoverConfig::Highpass { cutoff_freq, order } => {
4084 Some(format!("HP {}Hz {}nd", cutoff_freq, order))
4085 }
4086 CrossoverConfig::Bandpass {
4087 low_cutoff,
4088 high_cutoff,
4089 order,
4090 } => Some(format!("BP {}-{}Hz {}nd", low_cutoff, high_cutoff, order)),
4091 };
4092
4093 SourceOutputInfo {
4094 name: source.name.clone(),
4095 position: [source.position.x, source.position.y, source.position.z],
4096 crossover: crossover_str,
4097 }
4098 })
4099 .collect();
4100
4101 let results = SimulationResults {
4102 room: room_output,
4103 sources: sources_output,
4104 listening_position: [
4105 self.listening_position.x,
4106 self.listening_position.y,
4107 self.listening_position.z,
4108 ],
4109 frequencies: self.frequencies.clone(),
4110 frequency_response: combined_spl,
4111 source_responses: Some(source_responses_output),
4112 horizontal_slices,
4113 vertical_slices,
4114 solver: self.config.solver.method.clone(),
4115 mesh_nodes: None,
4116 mesh_elements: None,
4117 metadata: Some(self.config.metadata.clone()),
4118 room_modes,
4119 room_acoustics,
4120 impulse_response,
4121 binaural_response,
4122 };
4123
4124 console_log!("Simulation complete!");
4125
4126 serde_json::to_string(&results)
4127 .map_err(|e| JsValue::from_str(&format!("JSON serialization error: {}", e)))
4128 }
4129
4130 #[wasm_bindgen]
4132 pub fn compute_frequency_point(&self, freq_index: usize) -> Result<String, JsValue> {
4133 if freq_index >= self.frequencies.len() {
4134 return Err(JsValue::from_str("Frequency index out of bounds"));
4135 }
4136
4137 let freq = self.frequencies[freq_index];
4138 let pressure = self.calculate_direct_field(&self.listening_position, freq);
4139 let spl = pressure_to_spl(pressure);
4140
4141 let result = serde_json::json!({
4142 "frequency": freq,
4143 "spl": spl,
4144 "index": freq_index,
4145 "total": self.frequencies.len()
4146 });
4147
4148 serde_json::to_string(&result).map_err(|e| JsValue::from_str(&format!("JSON error: {}", e)))
4149 }
4150
4151 #[wasm_bindgen]
4153 pub fn compute_horizontal_slice(&self, frequency: f64) -> Result<String, JsValue> {
4154 console_log!("Computing horizontal slice at {:.1} Hz", frequency);
4155
4156 let resolution = self.config.visualization.slice_resolution;
4157 let (room_width, room_depth, _) = self.get_room_dimensions();
4158
4159 let x_points = lin_space(0.0, room_width, resolution);
4160 let y_points = lin_space(0.0, room_depth, resolution);
4161
4162 let mut spl_values = Vec::with_capacity(resolution * resolution);
4163
4164 for &y in &y_points {
4165 for &x in &x_points {
4166 let point = Point3D::new(x, y, self.listening_position.z);
4167 let pressure = self.calculate_direct_field(&point, frequency);
4168 spl_values.push(pressure_to_spl(pressure));
4169 }
4170 }
4171
4172 let result = SliceOutput {
4173 frequency,
4174 x: x_points,
4175 y: y_points,
4176 z: None,
4177 spl: spl_values,
4178 shape: [resolution, resolution],
4179 };
4180
4181 serde_json::to_string(&result).map_err(|e| JsValue::from_str(&format!("JSON error: {}", e)))
4182 }
4183
4184 #[wasm_bindgen]
4186 pub fn compute_vertical_slice(&self, frequency: f64) -> Result<String, JsValue> {
4187 console_log!("Computing vertical slice at {:.1} Hz", frequency);
4188
4189 let resolution = self.config.visualization.slice_resolution;
4190 let (room_width, _, room_height) = self.get_room_dimensions();
4191
4192 let x_points = lin_space(0.0, room_width, resolution);
4193 let z_points = lin_space(0.0, room_height, resolution);
4194
4195 let mut spl_values = Vec::with_capacity(resolution * resolution);
4196
4197 for &z in &z_points {
4198 for &x in &x_points {
4199 let point = Point3D::new(x, self.listening_position.y, z);
4200 let pressure = self.calculate_direct_field(&point, frequency);
4201 spl_values.push(pressure_to_spl(pressure));
4202 }
4203 }
4204
4205 let result = SliceOutput {
4206 frequency,
4207 x: x_points,
4208 y: Vec::new(),
4209 z: Some(z_points),
4210 spl: spl_values,
4211 shape: [resolution, resolution],
4212 };
4213
4214 serde_json::to_string(&result).map_err(|e| JsValue::from_str(&format!("JSON error: {}", e)))
4215 }
4216
4217 #[wasm_bindgen]
4219 pub fn get_room_info(&self) -> String {
4220 let room_output = self.build_room_output();
4221 serde_json::to_string(&room_output).unwrap_or_else(|_| "{}".to_string())
4222 }
4223
4224 #[wasm_bindgen]
4226 pub fn get_config(&self) -> String {
4227 serde_json::to_string(&self.config).unwrap_or_else(|_| "{}".to_string())
4228 }
4229
4230 #[wasm_bindgen]
4232 pub fn num_frequencies(&self) -> usize {
4233 self.frequencies.len()
4234 }
4235
4236 #[wasm_bindgen]
4238 pub fn num_sources(&self) -> usize {
4239 self.sources.len()
4240 }
4241
4242 fn get_room_dimensions(&self) -> (f64, f64, f64) {
4243 match &self.room_geometry {
4244 RoomGeometry::Rectangular(r) => (r.width, r.depth, r.height),
4245 RoomGeometry::LShaped(r) => (r.width1, r.depth1 + r.depth2, r.height),
4246 }
4247 }
4248
4249 fn build_room_output(&self) -> RoomOutput {
4250 let edges = self.room_geometry.get_edges();
4251 let edges_arrays: Vec<[[f64; 3]; 2]> = edges
4252 .iter()
4253 .map(|(p1, p2)| [[p1.x, p1.y, p1.z], [p2.x, p2.y, p2.z]])
4254 .collect();
4255
4256 match &self.room_geometry {
4257 RoomGeometry::Rectangular(r) => RoomOutput {
4258 width: r.width,
4259 depth: r.depth,
4260 height: r.height,
4261 room_type: Some("rectangular".to_string()),
4262 edges: edges_arrays,
4263 },
4264 RoomGeometry::LShaped(r) => RoomOutput {
4265 width: r.width1,
4266 depth: r.depth1 + r.depth2,
4267 height: r.height,
4268 room_type: Some("lshaped".to_string()),
4269 edges: edges_arrays,
4270 },
4271 }
4272 }
4273
4274 fn compute_slices(&self) -> (Option<Vec<SliceOutput>>, Option<Vec<SliceOutput>>) {
4275 let resolution = self.config.visualization.slice_resolution;
4276 let (room_width, room_depth, room_height) = self.get_room_dimensions();
4277
4278 let x_points = lin_space(0.0, room_width, resolution);
4279 let y_points = lin_space(0.0, room_depth, resolution);
4280 let z_points = lin_space(0.0, room_height, resolution);
4281
4282 let freq_indices: Vec<usize> =
4283 if self.config.visualization.slice_frequency_indices.is_empty() {
4284 let step = (self.frequencies.len() / 10).max(1);
4285 (0..self.frequencies.len()).step_by(step).collect()
4286 } else {
4287 self.config
4288 .visualization
4289 .slice_frequency_indices
4290 .iter()
4291 .filter(|&&i| i < self.frequencies.len())
4292 .copied()
4293 .collect()
4294 };
4295
4296 console_log!(
4297 "Computing {} spatial slices in parallel...",
4298 freq_indices.len()
4299 );
4300
4301 let slices: Vec<(SliceOutput, SliceOutput)> = freq_indices
4303 .par_iter()
4304 .map(|&freq_idx| {
4305 let freq = self.frequencies[freq_idx];
4306
4307 let h_spl: Vec<f64> = y_points
4310 .iter()
4311 .flat_map(|&y| {
4312 x_points.iter().map(move |&x| {
4313 let point = Point3D::new(x, y, self.listening_position.z);
4314 if !self.point_inside_room(&point) {
4316 return -100.0; }
4318 let pressure = self.calculate_direct_field(&point, freq);
4319 pressure_to_spl(pressure)
4320 })
4321 })
4322 .collect();
4323
4324 let h_slice = SliceOutput {
4325 frequency: freq,
4326 x: x_points.clone(),
4327 y: y_points.clone(),
4328 z: None,
4329 spl: h_spl,
4330 shape: [resolution, resolution],
4331 };
4332
4333 let v_spl: Vec<f64> = z_points
4336 .iter()
4337 .flat_map(|&z| {
4338 x_points.iter().map(move |&x| {
4339 let point = Point3D::new(x, self.listening_position.y, z);
4340 if !self.point_inside_room(&point) {
4342 return -100.0; }
4344 let pressure = self.calculate_direct_field(&point, freq);
4345 pressure_to_spl(pressure)
4346 })
4347 })
4348 .collect();
4349
4350 let v_slice = SliceOutput {
4351 frequency: freq,
4352 x: x_points.clone(),
4353 y: Vec::new(),
4354 z: Some(z_points.clone()),
4355 spl: v_spl,
4356 shape: [resolution, resolution],
4357 };
4358
4359 (h_slice, v_slice)
4360 })
4361 .collect();
4362
4363 let (horizontal_slices, vertical_slices): (Vec<_>, Vec<_>) = slices.into_iter().unzip();
4365
4366 console_log!("Spatial slices computed");
4367
4368 (Some(horizontal_slices), Some(vertical_slices))
4369 }
4370}
4371
4372#[wasm_bindgen]
4378pub fn create_default_config() -> String {
4379 let config = SimulationConfig {
4380 room: RoomGeometryConfig::Rectangular {
4381 width: 5.0,
4382 depth: 4.0,
4383 height: 2.5,
4384 },
4385 sources: vec![
4386 SourceConfig {
4387 name: "Left Speaker".to_string(),
4388 position: Point3DConfig {
4389 x: 1.5,
4390 y: 0.5,
4391 z: 1.2,
4392 },
4393 amplitude: 1.0,
4394 directivity: DirectivityConfig::Omnidirectional,
4395 crossover: CrossoverConfig::FullRange,
4396 delay_ms: 0.0,
4397 invert_phase: false,
4398 },
4399 SourceConfig {
4400 name: "Right Speaker".to_string(),
4401 position: Point3DConfig {
4402 x: 3.5,
4403 y: 0.5,
4404 z: 1.2,
4405 },
4406 amplitude: 1.0,
4407 directivity: DirectivityConfig::Omnidirectional,
4408 crossover: CrossoverConfig::FullRange,
4409 delay_ms: 0.0,
4410 invert_phase: false,
4411 },
4412 ],
4413 listening_positions: vec![Point3DConfig {
4414 x: 2.5,
4415 y: 2.5,
4416 z: 1.2,
4417 }],
4418 frequencies: FrequencyConfig {
4419 min_freq: 20.0,
4420 max_freq: 500.0,
4421 num_points: 100,
4422 spacing: "logarithmic".to_string(),
4423 },
4424 solver: SolverConfig::default(),
4425 visualization: VisualizationConfig::default(),
4426 wall_materials: WallMaterialsConfig::default(),
4427 metadata: MetadataConfig {
4428 description: "Default stereo setup".to_string(),
4429 author: "Room Simulator WASM".to_string(),
4430 date: String::new(),
4431 notes: String::new(),
4432 },
4433 scattering_objects: Vec::new(),
4434 };
4435
4436 serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".to_string())
4437}
4438
4439#[wasm_bindgen]
4441pub fn get_material_presets() -> String {
4442 let presets = vec![
4443 (
4444 "concrete",
4445 "Concrete/Brick (painted)",
4446 [0.01, 0.01, 0.02, 0.02, 0.02, 0.03],
4447 ),
4448 (
4449 "brick",
4450 "Unpainted Brick",
4451 [0.03, 0.03, 0.03, 0.04, 0.05, 0.07],
4452 ),
4453 (
4454 "drywall",
4455 "Drywall/Gypsum",
4456 [0.29, 0.10, 0.05, 0.04, 0.07, 0.09],
4457 ),
4458 (
4459 "plaster",
4460 "Plaster on Brick",
4461 [0.01, 0.02, 0.02, 0.03, 0.04, 0.05],
4462 ),
4463 (
4464 "glass",
4465 "Glass (large pane)",
4466 [0.18, 0.06, 0.04, 0.03, 0.02, 0.02],
4467 ),
4468 (
4469 "wood_thin",
4470 "Wood Paneling (thin)",
4471 [0.42, 0.21, 0.10, 0.08, 0.06, 0.06],
4472 ),
4473 (
4474 "wood_thick",
4475 "Heavy Wood/Door",
4476 [0.14, 0.10, 0.06, 0.08, 0.10, 0.10],
4477 ),
4478 (
4479 "carpet_thin",
4480 "Carpet (thin)",
4481 [0.02, 0.06, 0.14, 0.37, 0.60, 0.65],
4482 ),
4483 (
4484 "carpet_thick",
4485 "Carpet (heavy)",
4486 [0.08, 0.24, 0.57, 0.69, 0.71, 0.73],
4487 ),
4488 (
4489 "acoustic_tile",
4490 "Acoustic Ceiling Tiles",
4491 [0.50, 0.70, 0.60, 0.70, 0.70, 0.50],
4492 ),
4493 (
4494 "curtains",
4495 "Curtains/Drapes",
4496 [0.07, 0.31, 0.49, 0.75, 0.70, 0.60],
4497 ),
4498 (
4499 "acoustic_foam",
4500 "Acoustic Foam",
4501 [0.08, 0.25, 0.60, 0.90, 0.95, 0.90],
4502 ),
4503 (
4504 "hardwood",
4505 "Hardwood Floor",
4506 [0.15, 0.11, 0.10, 0.07, 0.06, 0.07],
4507 ),
4508 (
4509 "concrete_floor",
4510 "Concrete Floor",
4511 [0.01, 0.01, 0.02, 0.02, 0.02, 0.02],
4512 ),
4513 ];
4514
4515 let result: Vec<serde_json::Value> = presets
4516 .iter()
4517 .map(|(id, name, absorption)| {
4518 serde_json::json!({
4519 "id": id,
4520 "name": name,
4521 "absorption": {
4522 "125": absorption[0],
4523 "250": absorption[1],
4524 "500": absorption[2],
4525 "1000": absorption[3],
4526 "2000": absorption[4],
4527 "4000": absorption[5],
4528 }
4529 })
4530 })
4531 .collect();
4532
4533 serde_json::to_string(&result).unwrap_or_else(|_| "[]".to_string())
4534}
4535
4536#[wasm_bindgen]
4551pub fn get_room_modes(
4552 width: f64,
4553 depth: f64,
4554 height: f64,
4555 speed_of_sound: f64,
4556 max_frequency: f64,
4557) -> String {
4558 let modes = calculate_room_modes(width, depth, height, speed_of_sound, max_frequency, 10);
4559 serde_json::to_string(&modes).unwrap_or_else(|_| "[]".to_string())
4560}
4561
4562#[wasm_bindgen]
4575pub fn get_schroeder_frequency(volume: f64, rt60: f64) -> f64 {
4576 2000.0 * (rt60 / volume).sqrt()
4577}
4578
4579#[wasm_bindgen]
4598pub fn get_rt60(width: f64, depth: f64, height: f64, average_absorption: f64) -> String {
4599 let volume = width * depth * height;
4600 let surface_area = 2.0 * (width * depth + width * height + depth * height);
4601 let total_absorption = average_absorption * surface_area;
4602
4603 let rt60_sab = rt60_sabine(volume, total_absorption);
4604 let rt60_eyr = rt60_eyring(volume, surface_area, average_absorption);
4605 let schroeder_freq = 2000.0 * (rt60_eyr / volume).sqrt();
4606 let crit_dist = critical_distance(volume, rt60_eyr);
4607
4608 let result = RoomAcoustics {
4609 rt60_sabine: rt60_sab,
4610 rt60_eyring: rt60_eyr,
4611 volume,
4612 surface_area,
4613 average_alpha: average_absorption,
4614 total_absorption,
4615 schroeder_frequency: schroeder_freq,
4616 critical_distance: crit_dist,
4617 };
4618
4619 serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
4620}
4621
4622#[wasm_bindgen]
4632pub fn compute_impulse_response(input_json: &str) -> String {
4633 #[derive(Deserialize)]
4634 struct IrInput {
4635 frequencies: Vec<f64>,
4636 magnitudes: Vec<f64>,
4637 phases: Vec<f64>,
4638 #[serde(default)]
4639 config: Option<ImpulseResponseConfig>,
4640 }
4641
4642 let input: IrInput = match serde_json::from_str(input_json) {
4643 Ok(v) => v,
4644 Err(e) => return format!(r#"{{"error": "Invalid JSON: {}"}}"#, e),
4645 };
4646
4647 if input.frequencies.len() != input.magnitudes.len()
4648 || input.frequencies.len() != input.phases.len()
4649 {
4650 return r#"{"error": "frequencies, magnitudes, and phases arrays must have same length"}"#
4651 .to_string();
4652 }
4653
4654 let complex_response: Vec<Complex64> = input
4656 .magnitudes
4657 .iter()
4658 .zip(input.phases.iter())
4659 .map(|(&mag, &phase)| Complex64::from_polar(mag, phase))
4660 .collect();
4661
4662 let config = input.config.unwrap_or_default();
4663 let ir = calculate_impulse_response(&input.frequencies, &complex_response, &config);
4664
4665 serde_json::to_string(&ir)
4666 .unwrap_or_else(|_| r#"{"error": "Serialization failed"}"#.to_string())
4667}
4668
4669#[wasm_bindgen]
4671pub fn validate_config(config_json: &str) -> String {
4672 match serde_json::from_str::<SimulationConfig>(config_json) {
4673 Ok(config) => {
4674 let mut warnings = Vec::new();
4675
4676 let (w, d, h) = match &config.room {
4677 RoomGeometryConfig::Rectangular {
4678 width,
4679 depth,
4680 height,
4681 } => (*width, *depth, *height),
4682 RoomGeometryConfig::LShaped {
4683 width1,
4684 depth1,
4685 depth2,
4686 height,
4687 ..
4688 } => (*width1, depth1 + depth2, *height),
4689 };
4690
4691 if w <= 0.0 || d <= 0.0 || h <= 0.0 {
4692 warnings.push("Room dimensions must be positive".to_string());
4693 }
4694
4695 if config.sources.is_empty() {
4696 warnings.push("At least one source is required".to_string());
4697 }
4698
4699 for (idx, source) in config.sources.iter().enumerate() {
4700 if source.position.x < 0.0
4701 || source.position.x > w
4702 || source.position.y < 0.0
4703 || source.position.y > d
4704 || source.position.z < 0.0
4705 || source.position.z > h
4706 {
4707 warnings.push(format!("Source {} is outside room bounds", idx + 1));
4708 }
4709 }
4710
4711 if config.listening_positions.is_empty() {
4712 warnings.push("At least one listening position is required".to_string());
4713 }
4714
4715 for (idx, lp) in config.listening_positions.iter().enumerate() {
4716 if lp.x < 0.0 || lp.x > w || lp.y < 0.0 || lp.y > d || lp.z < 0.0 || lp.z > h {
4717 warnings.push(format!(
4718 "Listening position {} is outside room bounds",
4719 idx + 1
4720 ));
4721 }
4722 }
4723
4724 if config.frequencies.min_freq <= 0.0 {
4725 warnings.push("Minimum frequency must be positive".to_string());
4726 }
4727 if config.frequencies.max_freq <= config.frequencies.min_freq {
4728 warnings.push("Maximum frequency must be greater than minimum".to_string());
4729 }
4730 if config.frequencies.num_points < 2 {
4731 warnings.push("At least 2 frequency points are required".to_string());
4732 }
4733
4734 if warnings.is_empty() {
4735 serde_json::json!({"valid": true, "warnings": []}).to_string()
4736 } else {
4737 serde_json::json!({"valid": false, "warnings": warnings}).to_string()
4738 }
4739 }
4740 Err(e) => serde_json::json!({"valid": false, "error": e.to_string()}).to_string(),
4741 }
4742}