1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
//! Hybrid frequency crossover — blending wave-based and geometric results.
//!
//! Provides an API for combining low-frequency wave-based simulation results
//! (FDTD, FEM, BEM) with high-frequency geometric results (ray tracing,
//! image-source, radiosity) at a crossover frequency near the Schroeder
//! frequency of the room.
use crate::material::NUM_BANDS;
use serde::{Deserialize, Serialize};
/// Configuration for hybrid frequency crossover blending.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CrossoverConfig {
/// Crossover frequency in Hz (typically near the Schroeder frequency).
pub crossover_hz: f32,
/// Transition bandwidth in octaves (how gradually the blend occurs).
/// 0.5 = half-octave transition, 1.0 = one-octave transition.
pub transition_octaves: f32,
}
impl Default for CrossoverConfig {
fn default() -> Self {
Self {
crossover_hz: 500.0,
transition_octaves: 1.0,
}
}
}
/// Compute per-band blending weights for wave-based vs geometric results.
///
/// Returns an array of weights where 0.0 = fully wave-based and
/// 1.0 = fully geometric. The transition between them is a smooth
/// sigmoid centred on the crossover frequency.
#[must_use]
#[inline]
pub fn blend_weights(config: &CrossoverConfig) -> [f32; NUM_BANDS] {
let fc = config.crossover_hz;
let bw = config.transition_octaves.max(0.1);
std::array::from_fn(|band| {
let f = crate::material::FREQUENCY_BANDS[band];
if fc <= 0.0 {
return 1.0; // all geometric
}
// Sigmoid blend in log-frequency space
let octaves_above = (f / fc).log2() / bw;
let weight = 1.0 / (1.0 + (-4.0 * octaves_above).exp());
weight.clamp(0.0, 1.0)
})
}
/// Blend two per-band results using crossover weights.
///
/// `wave_result` is the wave-based simulation result (per-band values).
/// `geometric_result` is the geometric simulation result (per-band values).
/// Returns the blended result.
#[must_use]
#[inline]
pub fn blend_results(
wave_result: &[f32; NUM_BANDS],
geometric_result: &[f32; NUM_BANDS],
config: &CrossoverConfig,
) -> [f32; NUM_BANDS] {
let weights = blend_weights(config);
std::array::from_fn(|band| {
wave_result[band] * (1.0 - weights[band]) + geometric_result[band] * weights[band]
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn low_freq_favours_wave() {
let config = CrossoverConfig {
crossover_hz: 500.0,
transition_octaves: 0.5,
};
let weights = blend_weights(&config);
// 63 Hz is well below 500 Hz → should favour wave (low weight)
assert!(
weights[0] < 0.3,
"63 Hz should favour wave, got weight {}",
weights[0]
);
}
#[test]
fn high_freq_favours_geometric() {
let config = CrossoverConfig {
crossover_hz: 500.0,
transition_octaves: 0.5,
};
let weights = blend_weights(&config);
// 8000 Hz is well above 500 Hz → should favour geometric (high weight)
assert!(
weights[7] > 0.9,
"8 kHz should favour geometric, got weight {}",
weights[7]
);
}
#[test]
fn crossover_frequency_is_midpoint() {
let config = CrossoverConfig {
crossover_hz: 500.0,
transition_octaves: 1.0,
};
let weights = blend_weights(&config);
// 500 Hz band (index 3) should be near 0.5
assert!(
(weights[3] - 0.5).abs() < 0.15,
"crossover freq should be ~0.5, got {}",
weights[3]
);
}
#[test]
fn blend_results_works() {
let wave = [1.0; NUM_BANDS];
let geom = [0.0; NUM_BANDS];
let config = CrossoverConfig::default();
let blended = blend_results(&wave, &geom, &config);
// Low bands should be closer to 1.0 (wave), high bands closer to 0.0 (geometric)
assert!(blended[0] > blended[7]);
}
#[test]
fn weights_in_valid_range() {
let config = CrossoverConfig::default();
let weights = blend_weights(&config);
for &w in &weights {
assert!((0.0..=1.0).contains(&w), "weight {w} out of range");
}
}
}