use crate::Curve;
use crate::error::{AutoeqError, Result};
use crate::read::load_source;
use crate::response;
use log::{info, warn};
use math_audio_dsp::analysis::compute_average_response;
use math_audio_iir_fir::Biquad;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use super::crossover;
use super::dba;
use super::eq;
use super::multisub;
use super::optimize::{ChannelOptimizationResult, RoomOptimizationResult};
use super::output;
use super::types::{
CardioidConfig, ChannelDspChain, CrossoverConfig, DBAConfig, DriverDspChain, MultiSubGroup,
OptimizationMetadata, RoomConfig, SpeakerConfig, SubwooferStrategy, SystemConfig,
};
pub fn align_channels_to_lowest(
channels: &HashMap<String, Curve>,
ranges: &HashMap<String, (f64, f64)>,
) -> HashMap<String, f64> {
let mut means = HashMap::new();
let mut min_mean = f64::INFINITY;
for (name, curve) in channels {
let (min_f, max_f) = ranges.get(name).cloned().unwrap_or((100.0, 2000.0));
let freqs_f32: Vec<f32> = curve.freq.iter().map(|&f| f as f32).collect();
let spl_f32: Vec<f32> = curve.spl.iter().map(|&s| s as f32).collect();
let mean =
compute_average_response(&freqs_f32, &spl_f32, Some((min_f as f32, max_f as f32)))
as f64;
means.insert(name.clone(), mean);
if mean < min_mean {
min_mean = mean;
}
}
let mut gains = HashMap::new();
for (name, mean) in means {
let diff = min_mean - mean;
gains.insert(name.clone(), diff);
info!(
" Level alignment for '{}': {:.2} dB (mean {:.2} -> {:.2})",
name, diff, mean, min_mean
);
}
gains
}
fn compute_flat_loss(curve: &Curve, min_freq: f64, max_freq: f64) -> f64 {
let freqs_f32: Vec<f32> = curve.freq.iter().map(|&f| f as f32).collect();
let spl_f32: Vec<f32> = curve.spl.iter().map(|&s| s as f32).collect();
let mean = compute_average_response(
&freqs_f32,
&spl_f32,
Some((min_freq as f32, max_freq as f32)),
) as f64;
let normalized_spl = &curve.spl - mean;
crate::loss::flat_loss(&curve.freq, &normalized_spl, min_freq, max_freq)
}
fn mark_route_owned_plugin(
mut plugin: super::types::PluginConfigWrapper,
) -> super::types::PluginConfigWrapper {
if let Some(params) = plugin.parameters.as_object_mut() {
params.insert(
"room_eq_stage".to_string(),
serde_json::json!("route_owned"),
);
params.insert(
"label".to_string(),
serde_json::json!("room_eq_route_owned"),
);
}
plugin
}
fn mark_plugin_stage(
mut plugin: super::types::PluginConfigWrapper,
stage: &str,
) -> super::types::PluginConfigWrapper {
if let Some(params) = plugin.parameters.as_object_mut() {
params.insert("room_eq_stage".to_string(), serde_json::json!(stage));
}
plugin
}
fn mark_plugins_stage(
plugins: Vec<super::types::PluginConfigWrapper>,
stage: &str,
) -> Vec<super::types::PluginConfigWrapper> {
plugins
.into_iter()
.map(|plugin| mark_plugin_stage(plugin, stage))
.collect()
}
#[allow(clippy::type_complexity)]
fn run_channel_via_generic_path(
role: &str,
source: &crate::MeasurementSource,
config: &RoomConfig,
alignment_gain_db: f64,
sample_rate: f64,
output_dir: &Path,
) -> Result<(
ChannelDspChain,
ChannelOptimizationResult,
f64,
f64,
Option<Vec<f64>>,
Option<Vec<String>>,
)> {
let derived_multiseat_config =
super::home_cinema::derive_all_channel_multiseat_config(config, role, source);
let derived_config;
let effective_config = if let Some(multi_config) = derived_multiseat_config.clone() {
derived_config = {
let mut cloned = config.clone();
cloned.optimizer.multi_measurement = Some(multi_config);
cloned
};
&derived_config
} else {
config
};
let mut processed = super::speaker_eq::process_single_speaker(
role,
source,
effective_config,
sample_rate,
output_dir,
None,
None,
None,
)?;
let mut multiseat_rejection = None;
if derived_multiseat_config.is_some() {
let acceptance = super::home_cinema::all_channel_multiseat_acceptance(
config,
role,
source,
&processed.3,
&processed.4,
);
if !acceptance.accepted {
warn!(
"All-channel multi-seat correction rejected for '{}': {}. Re-running without derived multi-seat correction.",
role,
acceptance.advisories.join(", ")
);
multiseat_rejection = Some(acceptance.advisories);
processed = super::speaker_eq::process_single_speaker(
role,
source,
config,
sample_rate,
output_dir,
None,
None,
None,
)?;
}
}
let (
raw_chain,
pre_score,
post_score,
initial_curve,
final_curve,
biquads,
_mean_spl,
_arrival_ms,
fir_coeffs,
) = processed;
let mut plugins: Vec<_> = Vec::with_capacity(raw_chain.plugins.len() + 1);
if alignment_gain_db.abs() > 0.01 {
plugins.push(output::create_gain_plugin(alignment_gain_db));
}
plugins.extend(raw_chain.plugins);
let chain = ChannelDspChain {
channel: role.to_string(),
plugins,
drivers: raw_chain.drivers,
initial_curve: raw_chain.initial_curve,
final_curve: raw_chain.final_curve,
eq_response: raw_chain.eq_response,
pre_ir: raw_chain.pre_ir,
post_ir: raw_chain.post_ir,
target_curve: raw_chain.target_curve,
};
let channel_result = ChannelOptimizationResult {
name: role.to_string(),
pre_score,
post_score,
initial_curve,
final_curve,
biquads,
fir_coeffs: fir_coeffs.clone(),
};
Ok((
chain,
channel_result,
pre_score,
post_score,
fir_coeffs,
multiseat_rejection,
))
}
fn complex_sum_mains(curves: &[&Curve]) -> Curve {
use num_complex::Complex;
assert!(!curves.is_empty(), "complex_sum_mains needs ≥ 1 curve");
let n = curves.iter().map(|c| c.spl.len()).min().unwrap();
let freq = curves[0].freq.slice(ndarray::s![..n]).to_owned();
let divisor = curves.len() as f64;
let mut spl = ndarray::Array1::<f64>::zeros(n);
let mut phase = ndarray::Array1::<f64>::zeros(n);
for i in 0..n {
let mut sum = Complex::new(0.0_f64, 0.0);
for c in curves {
let mag = 10.0_f64.powf(c.spl[i] / 20.0);
let phi = c.phase.as_ref().expect("phase checked by caller")[i].to_radians();
sum += Complex::from_polar(mag, phi);
}
sum /= divisor;
spl[i] = 20.0 * sum.norm().max(1e-12).log10();
phase[i] = sum.arg().to_degrees();
}
Curve {
freq,
spl,
phase: Some(phase),
..Default::default()
}
}
fn average_mains_magnitude(curves: &[&Curve]) -> Curve {
assert!(
!curves.is_empty(),
"average_mains_magnitude needs >= 1 curve"
);
let ref_freq = curves[0].freq.clone();
let mut spl = ndarray::Array1::<f64>::zeros(ref_freq.len());
for curve in curves {
let interpolated = crate::read::interpolate_log_space(&ref_freq, curve);
spl += &interpolated.spl;
}
spl.mapv_inplace(|v| v / curves.len() as f64);
Curve {
freq: ref_freq,
spl,
phase: None,
..Default::default()
}
}
fn curve_has_usable_phase(curve: &Curve) -> bool {
curve
.phase
.as_ref()
.map(|phase| phase.len() >= curve.freq.len() && phase.iter().all(|v| v.is_finite()))
.unwrap_or(false)
}
fn all_curves_have_usable_phase(curves: &[&Curve]) -> bool {
curves.iter().all(|curve| curve_has_usable_phase(curve))
}
fn all_curves_share_frequency_grid(curves: &[&Curve]) -> bool {
let Some(reference) = curves.first() else {
return false;
};
super::frequency_grid::is_valid_frequency_grid(&reference.freq)
&& curves.iter().skip(1).all(|curve| {
super::frequency_grid::is_valid_frequency_grid(&curve.freq)
&& super::frequency_grid::same_frequency_grid(&reference.freq, &curve.freq)
})
}
fn normalize_crossover_delays(main_delay_ms: f64, sub_delay_ms: f64) -> (f64, f64) {
let common_delay_ms = main_delay_ms.min(sub_delay_ms);
(
main_delay_ms - common_delay_ms,
sub_delay_ms - common_delay_ms,
)
}
fn apply_delay_and_polarity_to_curve(curve: &Curve, delay_ms: f64, invert: bool) -> Curve {
let mut adjusted = curve.clone();
let Some(phase) = adjusted.phase.as_mut() else {
return adjusted;
};
let delay_s = delay_ms / 1000.0;
for (idx, phase_deg) in phase.iter_mut().enumerate() {
let freq_hz = adjusted.freq[idx];
*phase_deg -= 360.0 * freq_hz * delay_s;
if invert {
*phase_deg += 180.0;
}
}
adjusted
}
fn apply_curve_delta_to_reference_curve(
reference_curve: &Curve,
initial_curve: &Curve,
final_curve: &Curve,
) -> Curve {
let initial_on_reference =
crate::read::interpolate_log_space(&reference_curve.freq, initial_curve);
let final_on_reference = crate::read::interpolate_log_space(&reference_curve.freq, final_curve);
let phase = match (
reference_curve.phase.as_ref(),
initial_on_reference.phase.as_ref(),
final_on_reference.phase.as_ref(),
) {
(Some(reference_phase), Some(initial_phase), Some(final_phase)) => {
Some(reference_phase + &(final_phase - initial_phase))
}
_ => reference_curve.phase.clone(),
};
Curve {
freq: reference_curve.freq.clone(),
spl: &reference_curve.spl + &(&final_on_reference.spl - &initial_on_reference.spl),
phase,
..Default::default()
}
}
#[allow(clippy::too_many_arguments)]
fn predict_bass_management_sum(
main_curve: &Curve,
sub_curve: &Curve,
xover_type: &str,
xover_freq: f64,
sample_rate: f64,
main_gain_db: f64,
sub_gain_db: f64,
main_delay_ms: f64,
sub_delay_ms: f64,
sub_inverted: bool,
) -> Option<Curve> {
use num_complex::Complex;
if !curve_has_usable_phase(main_curve) || !curve_has_usable_phase(sub_curve) {
return None;
}
let sub_on_main_grid = crate::read::interpolate_log_space(&main_curve.freq, sub_curve);
if !curve_has_usable_phase(&sub_on_main_grid) {
return None;
}
let hp_biquads = create_crossover_filters(xover_type, xover_freq, sample_rate, false);
let lp_biquads = create_crossover_filters(xover_type, xover_freq, sample_rate, true);
let main_resp =
response::compute_peq_complex_response(&hp_biquads, &main_curve.freq, sample_rate);
let sub_resp =
response::compute_peq_complex_response(&lp_biquads, &sub_on_main_grid.freq, sample_rate);
let mut main_filtered = response::apply_complex_response(main_curve, &main_resp);
for spl in main_filtered.spl.iter_mut() {
*spl += main_gain_db;
}
let main_filtered = apply_delay_and_polarity_to_curve(&main_filtered, main_delay_ms, false);
let mut sub_filtered = response::apply_complex_response(&sub_on_main_grid, &sub_resp);
for spl in sub_filtered.spl.iter_mut() {
*spl += sub_gain_db;
}
let sub_filtered = apply_delay_and_polarity_to_curve(&sub_filtered, sub_delay_ms, sub_inverted);
let main_phase = main_filtered.phase.as_ref()?;
let sub_phase = sub_filtered.phase.as_ref()?;
let mut spl = ndarray::Array1::<f64>::zeros(main_filtered.freq.len());
let mut phase = ndarray::Array1::<f64>::zeros(main_filtered.freq.len());
for i in 0..main_filtered.freq.len() {
let main = Complex::from_polar(
10.0_f64.powf(main_filtered.spl[i] / 20.0),
main_phase[i].to_radians(),
);
let sub = Complex::from_polar(
10.0_f64.powf(sub_filtered.spl[i] / 20.0),
sub_phase[i].to_radians(),
);
let sum = main + sub;
spl[i] = 20.0 * sum.norm().max(1e-12).log10();
phase[i] = sum.arg().to_degrees();
}
Some(Curve {
freq: main_filtered.freq,
spl,
phase: Some(phase),
..Default::default()
})
}
fn bass_management_objective(curve: Option<&Curve>, xover_freq: f64) -> Option<f64> {
let curve = curve?;
let min_freq = (xover_freq / 2.0).max(20.0);
let max_freq = (xover_freq * 2.0).min(2_000.0).max(min_freq + 1.0);
Some(compute_flat_loss(curve, min_freq, max_freq))
}
fn bass_management_crossover_type_candidates(requested: &str) -> Vec<String> {
let requested = requested.trim();
if requested.eq_ignore_ascii_case("auto") || requested.eq_ignore_ascii_case("optimize") {
vec![
"LR24".to_string(),
"LR48".to_string(),
"BW12".to_string(),
"BW24".to_string(),
]
} else {
vec![requested.to_string()]
}
}
fn select_bass_management_crossover_type(
requested: &str,
main_curve: &Curve,
sub_curve: &Curve,
xover_freq: f64,
sample_rate: f64,
) -> String {
let candidates = bass_management_crossover_type_candidates(requested);
if candidates.len() == 1 {
return candidates[0].clone();
}
candidates
.iter()
.filter(|candidate| candidate.parse::<crate::loss::CrossoverType>().is_ok())
.filter_map(|candidate| {
let predicted = predict_bass_management_sum(
main_curve,
sub_curve,
candidate,
xover_freq,
sample_rate,
0.0,
0.0,
0.0,
0.0,
false,
);
bass_management_objective(predicted.as_ref(), xover_freq)
.map(|objective| (candidate.clone(), objective))
})
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(candidate, _)| candidate)
.unwrap_or_else(|| "LR24".to_string())
}
fn resolve_single_source<'a>(
role: &str,
config: &'a RoomConfig,
sys: &SystemConfig,
) -> Result<&'a crate::MeasurementSource> {
let meas_key = sys
.speakers
.get(role)
.ok_or_else(|| AutoeqError::InvalidConfiguration {
message: format!("Missing speaker mapping for '{}'", role),
})?;
let cfg = config
.speakers
.get(meas_key)
.ok_or_else(|| AutoeqError::InvalidConfiguration {
message: format!("Missing speaker config for key '{}'", meas_key),
})?;
match cfg {
SpeakerConfig::Single(s) => Ok(s),
_ => Err(AutoeqError::InvalidConfiguration {
message: format!("Workflow requires Single speaker config for '{}'", role),
}),
}
}
fn load_logical_channels(
config: &RoomConfig,
sys: &SystemConfig,
) -> Result<HashMap<String, Curve>> {
let mut curves = HashMap::new();
for (role, meas_key) in &sys.speakers {
if let Some(cfg) = config.speakers.get(meas_key) {
let source = match cfg {
SpeakerConfig::Single(s) => s,
_ => {
return Err(AutoeqError::InvalidConfiguration {
message: format!("Workflow requires Single speaker config for '{}'", role),
});
}
};
let curve = load_source(source).map_err(|e| AutoeqError::InvalidMeasurement {
message: e.to_string(),
})?;
curves.insert(role.clone(), curve);
}
}
Ok(curves)
}
#[derive(Clone)]
struct SubDriverInfo {
name: String,
gain: f64,
delay: f64,
inverted: bool,
initial_curve: Option<Curve>,
}
struct SubPreprocessResult {
combined_curve: Curve,
drivers: Option<Vec<SubDriverInfo>>,
}
fn preprocess_sub(
lfe_config: &SpeakerConfig,
strategy: &SubwooferStrategy,
optimizer: &super::types::OptimizerConfig,
sample_rate: f64,
) -> Result<SubPreprocessResult> {
match lfe_config {
SpeakerConfig::Single(source) => {
let curve = load_source(source).map_err(|e| AutoeqError::InvalidMeasurement {
message: e.to_string(),
})?;
Ok(SubPreprocessResult {
combined_curve: curve,
drivers: None,
})
}
SpeakerConfig::MultiSub(ms) => match strategy {
SubwooferStrategy::Mso => preprocess_multisub_mso(ms, optimizer, sample_rate),
SubwooferStrategy::Single => preprocess_multisub_independent(ms),
SubwooferStrategy::Dba => Err(AutoeqError::InvalidConfiguration {
message: "SubwooferStrategy::Dba requires SpeakerConfig::Dba, not MultiSub"
.to_string(),
}),
},
SpeakerConfig::Cardioid(c) => preprocess_cardioid(c),
SpeakerConfig::Dba(d) => preprocess_dba(d, optimizer, sample_rate),
SpeakerConfig::Group(_) => Err(AutoeqError::InvalidConfiguration {
message: "Group speaker config should not reach stereo sub workflow; use generic path"
.to_string(),
}),
}
}
fn preprocess_multisub_mso(
ms: &MultiSubGroup,
optimizer: &super::types::OptimizerConfig,
sample_rate: f64,
) -> Result<SubPreprocessResult> {
info!(" MSO optimization for {} subwoofers", ms.subwoofers.len());
let (result, combined) = multisub::optimize_multisub(&ms.subwoofers, optimizer, sample_rate)
.map_err(|e| AutoeqError::OptimizationFailed {
message: format!("MSO optimization failed: {}", e),
})?;
info!(
" MSO result: gains={:?}, delays={:?}",
result.gains, result.delays
);
let mut drivers = Vec::new();
for (i, source) in ms.subwoofers.iter().enumerate() {
let curve = load_source(source).map_err(|e| AutoeqError::InvalidMeasurement {
message: e.to_string(),
})?;
drivers.push(SubDriverInfo {
name: format!("{}_{}", ms.name, i + 1),
gain: result.gains.get(i).copied().unwrap_or(0.0),
delay: result.delays.get(i).copied().unwrap_or(0.0),
inverted: false,
initial_curve: Some(curve),
});
}
Ok(SubPreprocessResult {
combined_curve: combined,
drivers: Some(drivers),
})
}
fn preprocess_multisub_independent(ms: &MultiSubGroup) -> Result<SubPreprocessResult> {
info!(
" Independent sub averaging for {} subwoofers",
ms.subwoofers.len()
);
let mut curves = Vec::new();
for source in &ms.subwoofers {
let curve = load_source(source).map_err(|e| AutoeqError::InvalidMeasurement {
message: e.to_string(),
})?;
curves.push(curve);
}
let ref_freq = curves[0].freq.clone();
let mut sum_power = ndarray::Array1::<f64>::zeros(ref_freq.len());
for curve in &curves {
let interp = crate::read::interpolate_log_space(&ref_freq, curve);
sum_power += &interp.spl.mapv(|db| 10.0_f64.powf(db / 10.0));
}
let avg_spl = sum_power.mapv(|p| 10.0 * p.log10());
let combined = Curve {
freq: ref_freq,
spl: avg_spl,
phase: None,
..Default::default()
};
let drivers: Vec<SubDriverInfo> = curves
.into_iter()
.enumerate()
.map(|(i, curve)| SubDriverInfo {
name: format!("{}_{}", ms.name, i + 1),
gain: 0.0,
delay: 0.0,
inverted: false,
initial_curve: Some(curve),
})
.collect();
Ok(SubPreprocessResult {
combined_curve: combined,
drivers: Some(drivers),
})
}
fn preprocess_cardioid(c: &CardioidConfig) -> Result<SubPreprocessResult> {
let front_curve = load_source(&c.front).map_err(|e| AutoeqError::InvalidMeasurement {
message: format!("Cardioid front: {}", e),
})?;
let rear_curve = load_source(&c.rear).map_err(|e| AutoeqError::InvalidMeasurement {
message: format!("Cardioid rear: {}", e),
})?;
if !super::frequency_grid::is_valid_frequency_grid(&front_curve.freq)
|| !super::frequency_grid::is_valid_frequency_grid(&rear_curve.freq)
{
return Err(AutoeqError::InvalidMeasurement {
message: "Cardioid preprocessing requires valid frequency grids".to_string(),
});
}
if front_curve.spl.len() != front_curve.freq.len()
|| rear_curve.spl.len() != rear_curve.freq.len()
|| front_curve
.phase
.as_ref()
.is_some_and(|phase| phase.len() != front_curve.freq.len())
|| rear_curve
.phase
.as_ref()
.is_some_and(|phase| phase.len() != rear_curve.freq.len())
{
return Err(AutoeqError::InvalidMeasurement {
message: "Cardioid preprocessing curve arrays must match frequency-grid length"
.to_string(),
});
}
if !super::frequency_grid::same_frequency_grid(&front_curve.freq, &rear_curve.freq) {
return Err(AutoeqError::InvalidMeasurement {
message: "Cardioid preprocessing requires front and rear curves to share the same frequency grid".to_string(),
});
}
let delay_ms = c.separation_meters / 343.0 * 1000.0;
info!(
" Cardioid: separation={:.2}m, delay={:.2}ms",
c.separation_meters, delay_ms
);
use num_complex::Complex;
let n_points = front_curve.freq.len();
let mut combined_spl = ndarray::Array1::zeros(n_points);
let front_phase_zeros = ndarray::Array1::zeros(n_points);
let rear_phase_zeros = ndarray::Array1::zeros(n_points);
let front_phase = front_curve.phase.as_ref().unwrap_or(&front_phase_zeros);
let rear_phase = rear_curve.phase.as_ref().unwrap_or(&rear_phase_zeros);
for i in 0..n_points {
let f = front_curve.freq[i];
let omega = 2.0 * std::f64::consts::PI * f;
let f_mag = 10.0_f64.powf(front_curve.spl[i] / 20.0);
let f_phi = front_phase[i].to_radians();
let f_c = Complex::from_polar(f_mag, f_phi);
let r_mag = 10.0_f64.powf(rear_curve.spl[i] / 20.0);
let r_phi_meas = rear_phase[i].to_radians();
let delay_s = delay_ms / 1000.0;
let delay_phi = -omega * delay_s;
let invert_phi = std::f64::consts::PI;
let r_phi_total = r_phi_meas + delay_phi + invert_phi;
let r_c = Complex::from_polar(r_mag, r_phi_total);
let sum = f_c + r_c;
combined_spl[i] = 20.0 * sum.norm().log10();
}
let combined = Curve {
freq: front_curve.freq.clone(),
spl: combined_spl,
phase: None,
..Default::default()
};
let drivers = vec![
SubDriverInfo {
name: "Front Sub".to_string(),
gain: 0.0,
delay: 0.0,
inverted: false,
initial_curve: Some(front_curve),
},
SubDriverInfo {
name: "Rear Sub".to_string(),
gain: 0.0,
delay: delay_ms,
inverted: true,
initial_curve: Some(rear_curve),
},
];
Ok(SubPreprocessResult {
combined_curve: combined,
drivers: Some(drivers),
})
}
fn preprocess_dba(
d: &DBAConfig,
optimizer: &super::types::OptimizerConfig,
sample_rate: f64,
) -> Result<SubPreprocessResult> {
info!(" DBA optimization");
let (result, combined) = dba::optimize_dba(d, optimizer, sample_rate).map_err(|e| {
AutoeqError::OptimizationFailed {
message: format!("DBA optimization failed: {}", e),
}
})?;
info!(
" DBA result: gains={:?}, delays={:?}",
result.gains, result.delays
);
let front_curve =
dba::sum_array_response(&d.front).map_err(|e| AutoeqError::InvalidMeasurement {
message: format!("DBA front array: {}", e),
})?;
let rear_curve =
dba::sum_array_response(&d.rear).map_err(|e| AutoeqError::InvalidMeasurement {
message: format!("DBA rear array: {}", e),
})?;
let drivers = vec![
SubDriverInfo {
name: "Front Array".to_string(),
gain: result.gains.first().copied().unwrap_or(0.0),
delay: result.delays.first().copied().unwrap_or(0.0),
inverted: false,
initial_curve: Some(front_curve),
},
SubDriverInfo {
name: "Rear Array".to_string(),
gain: result.gains.get(1).copied().unwrap_or(0.0),
delay: result.delays.get(1).copied().unwrap_or(0.0),
inverted: true,
initial_curve: Some(rear_curve),
},
];
Ok(SubPreprocessResult {
combined_curve: combined,
drivers: Some(drivers),
})
}
#[derive(Debug, Clone)]
struct GroupCrossoverPlan {
crossover_type: String,
frequency_hz: f64,
configured_hz: f64,
frequency_range: Option<(f64, f64)>,
}
#[derive(Debug, Clone)]
struct BassManagementJointGroupInput {
group_id: String,
roles: Vec<String>,
plan: GroupCrossoverPlan,
virtual_main: Curve,
phase_available: bool,
advisories: Vec<String>,
}
fn grouped_home_cinema_roles(main_roles: &[String]) -> BTreeMap<String, Vec<String>> {
let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
for role in main_roles {
let role_id =
super::home_cinema::group_id_for_role(super::home_cinema::role_for_channel(role));
groups
.entry(role_id.to_string())
.or_default()
.push(role.clone());
}
groups
}
fn group_crossover_plan(
config: &RoomConfig,
fallback: &CrossoverConfig,
group_id: &str,
) -> Result<GroupCrossoverPlan> {
let selected = config
.system
.as_ref()
.and_then(|system| system.bass_management.as_ref())
.and_then(|bm| bm.group_crossovers.get(group_id))
.and_then(|key| {
config
.crossovers
.as_ref()
.and_then(|crossovers| crossovers.get(key))
})
.unwrap_or(fallback);
let (min_hz, max_hz, configured_hz) = if let Some(freq) = selected.frequency {
(freq, freq, freq)
} else if let Some((min, max)) = selected.frequency_range {
(min, max, (min.max(1.0) * max.max(1.0)).sqrt())
} else {
return Err(AutoeqError::InvalidConfiguration {
message: format!(
"Bass-management crossover for group '{group_id}' requires 'frequency' or 'frequency_range'"
),
});
};
Ok(GroupCrossoverPlan {
crossover_type: selected.crossover_type.clone(),
frequency_hz: configured_hz,
configured_hz,
frequency_range: (min_hz != max_hz).then_some((min_hz, max_hz)),
})
}
#[allow(clippy::too_many_arguments)]
fn optimize_home_cinema_group_crossovers(
config: &RoomConfig,
main_roles: &[String],
aligned_curves: &HashMap<String, Curve>,
aligned_pre_eq_curves: &HashMap<String, Curve>,
sub_role: &str,
fallback_crossover: &CrossoverConfig,
sample_rate: f64,
bass_management: Option<&super::home_cinema::EffectiveBassManagement>,
) -> Result<BTreeMap<String, super::home_cinema::BassManagementGroupReport>> {
let mut reports = BTreeMap::new();
let mut joint_inputs = Vec::new();
let sub_curve = &aligned_pre_eq_curves[sub_role];
for (group_id, roles) in grouped_home_cinema_roles(main_roles) {
let mut advisories = Vec::new();
let plan = group_crossover_plan(config, fallback_crossover, &group_id)?;
let main_refs: Vec<&Curve> = roles
.iter()
.map(|role| &aligned_pre_eq_curves[role])
.collect();
let mut measured_refs: Vec<&Curve> =
roles.iter().map(|role| &aligned_curves[role]).collect();
measured_refs.push(&aligned_curves[sub_role]);
let mut phase_refs = main_refs.clone();
phase_refs.push(sub_curve);
let measured_phase_available = all_curves_have_usable_phase(&measured_refs);
let shared_grid_available = all_curves_share_frequency_grid(&measured_refs)
&& all_curves_share_frequency_grid(&phase_refs);
let phase_available = measured_phase_available && shared_grid_available;
if !measured_phase_available {
advisories.push("missing_phase_group_crossover_alignment_skipped".to_string());
} else if !shared_grid_available {
advisories
.push("frequency_grid_mismatch_group_crossover_alignment_skipped".to_string());
}
let virtual_main = if phase_available {
complex_sum_mains(&main_refs)
} else {
average_mains_magnitude(&main_refs)
};
joint_inputs.push(BassManagementJointGroupInput {
group_id: group_id.clone(),
roles: roles.clone(),
plan: plan.clone(),
virtual_main: virtual_main.clone(),
phase_available,
advisories: advisories.clone(),
});
let selected_type = select_bass_management_crossover_type(
&plan.crossover_type,
&virtual_main,
sub_curve,
plan.frequency_hz,
sample_rate,
);
let selected_type_str = selected_type.as_str();
let objective_before_curve = predict_bass_management_sum(
&virtual_main,
sub_curve,
selected_type_str,
plan.frequency_hz,
sample_rate,
0.0,
0.0,
0.0,
0.0,
false,
);
let objective_before =
bass_management_objective(objective_before_curve.as_ref(), plan.frequency_hz);
let mut final_freq = plan.frequency_hz;
let mut main_delay_ms = 0.0;
let mut bass_delay_ms = 0.0;
let mut polarity_inverted = false;
let mut trim_db = 0.0;
let mut objective_after = objective_before;
if phase_available {
let crossover_type_enum: crate::loss::CrossoverType = selected_type_str
.parse()
.map_err(|e: String| AutoeqError::InvalidConfiguration { message: e })?;
let fixed_freqs = plan
.frequency_range
.is_none()
.then_some(vec![plan.frequency_hz]);
let mut xo_optimizer_config = config.optimizer.clone();
xo_optimizer_config.min_db = 0.0;
xo_optimizer_config.max_db = 0.0;
let (xo_gains, xo_delays, xo_freqs, _, inversions) = crossover::optimize_crossover(
vec![virtual_main.clone(), sub_curve.clone()],
crossover_type_enum,
sample_rate,
&xo_optimizer_config,
fixed_freqs,
plan.frequency_range,
)
.map_err(|e| AutoeqError::OptimizationFailed {
message: e.to_string(),
})?;
final_freq = xo_freqs.first().copied().unwrap_or(plan.frequency_hz);
let (main_delay, bass_delay) = normalize_crossover_delays(
xo_delays.first().copied().unwrap_or(0.0),
xo_delays.get(1).copied().unwrap_or(0.0),
);
main_delay_ms = main_delay;
bass_delay_ms = bass_delay;
polarity_inverted = inversions.get(1).copied().unwrap_or(false);
let hp = create_crossover_filters(selected_type_str, final_freq, sample_rate, false);
let lp = create_crossover_filters(selected_type_str, final_freq, sample_rate, true);
let apply = |curve: &Curve, filters: &[Biquad], gain: f64, delay: f64, invert: bool| {
let resp =
response::compute_peq_complex_response(filters, &curve.freq, sample_rate);
let mut c = response::apply_complex_response(curve, &resp);
for spl in c.spl.iter_mut() {
*spl += gain;
}
apply_delay_and_polarity_to_curve(&c, delay, invert)
};
let main_post = apply(
&virtual_main,
&hp,
xo_gains.first().copied().unwrap_or(0.0),
main_delay_ms,
false,
);
let sub_post = apply(
sub_curve,
&lp,
xo_gains.get(1).copied().unwrap_or(0.0),
bass_delay_ms,
polarity_inverted,
);
let main_freqs: Vec<f32> = main_post.freq.iter().map(|&f| f as f32).collect();
let main_spl: Vec<f32> = main_post.spl.iter().map(|&s| s as f32).collect();
let sub_freqs: Vec<f32> = sub_post.freq.iter().map(|&f| f as f32).collect();
let sub_spl: Vec<f32> = sub_post.spl.iter().map(|&s| s as f32).collect();
let main_mean =
compute_average_response(&main_freqs, &main_spl, Some((final_freq as f32, 2000.0)))
as f64;
let sub_mean =
compute_average_response(&sub_freqs, &sub_spl, Some((20.0, final_freq as f32)))
as f64;
let requested_trim = xo_gains.get(1).copied().unwrap_or(0.0) + main_mean - sub_mean;
let (limited_trim, gain_limited) =
super::home_cinema::limited_sub_gain(requested_trim, bass_management);
trim_db = limited_trim;
if gain_limited {
advisories.push("group_sub_trim_limited_for_headroom".to_string());
}
let objective_after_curve = predict_bass_management_sum(
&virtual_main,
sub_curve,
selected_type_str,
final_freq,
sample_rate,
xo_gains.first().copied().unwrap_or(0.0),
trim_db,
main_delay_ms,
bass_delay_ms,
polarity_inverted,
);
objective_after = bass_management_objective(objective_after_curve.as_ref(), final_freq);
if objective_after >= objective_before {
advisories.push("group_optimizer_no_improvement".to_string());
final_freq = plan.frequency_hz;
main_delay_ms = 0.0;
bass_delay_ms = 0.0;
polarity_inverted = false;
trim_db = 0.0;
objective_after = objective_before;
}
}
if advisories.is_empty() {
advisories.push("ok".to_string());
}
reports.insert(
group_id.clone(),
crate::roomeq::home_cinema::BassManagementGroupReport {
group_id,
roles,
crossover_type: selected_type_str.to_string(),
selected_crossover_hz: Some(final_freq),
configured_crossover_hz: Some(plan.configured_hz),
main_delay_ms,
bass_route_delay_ms: bass_delay_ms,
polarity_inverted,
trim_db,
objective_before,
objective_after,
advisories,
},
);
}
if let Some(joint_reports) = optimize_home_cinema_joint_group_crossovers(
config,
&joint_inputs,
&reports,
sub_curve,
sample_rate,
) {
reports = joint_reports;
}
Ok(reports)
}
fn optimize_home_cinema_joint_group_crossovers(
config: &RoomConfig,
inputs: &[BassManagementJointGroupInput],
current_reports: &BTreeMap<String, super::home_cinema::BassManagementGroupReport>,
sub_curve: &Curve,
sample_rate: f64,
) -> Option<BTreeMap<String, super::home_cinema::BassManagementGroupReport>> {
let optimizable: Vec<&BassManagementJointGroupInput> = inputs
.iter()
.filter(|input| input.phase_available)
.collect();
if optimizable.is_empty() {
return None;
}
let mut lower_bounds = Vec::new();
let mut upper_bounds = Vec::new();
let mut initial = Vec::new();
let mut type_candidates = Vec::new();
for input in &optimizable {
let candidates: Vec<String> =
bass_management_crossover_type_candidates(&input.plan.crossover_type)
.into_iter()
.filter(|candidate| candidate.parse::<crate::loss::CrossoverType>().is_ok())
.collect();
let candidates = if candidates.is_empty() {
vec!["LR24".to_string()]
} else {
candidates
};
let current_report = current_reports.get(&input.group_id);
let selected_type = current_report
.map(|report| report.crossover_type.clone())
.unwrap_or_else(|| {
select_bass_management_crossover_type(
&input.plan.crossover_type,
&input.virtual_main,
sub_curve,
input.plan.frequency_hz,
sample_rate,
)
});
let initial_type_idx = candidates
.iter()
.position(|candidate| candidate == &selected_type)
.unwrap_or(0) as f64;
type_candidates.push(candidates);
let (min_freq, max_freq) = input
.plan
.frequency_range
.unwrap_or((input.plan.frequency_hz, input.plan.frequency_hz));
lower_bounds.extend_from_slice(&[min_freq, 0.0, 0.0, 0.0, 0.0, config.optimizer.min_db]);
upper_bounds.extend_from_slice(&[
max_freq,
(type_candidates.last().unwrap().len().saturating_sub(1)) as f64,
20.0,
20.0,
1.0,
config
.system
.as_ref()
.and_then(|system| system.bass_management.as_ref())
.map(|bm| bm.max_sub_boost_db.max(0.0))
.unwrap_or(config.optimizer.max_db.max(0.0)),
]);
initial.extend_from_slice(&[
current_report
.and_then(|report| report.selected_crossover_hz)
.unwrap_or(input.plan.frequency_hz),
initial_type_idx,
current_report
.map(|report| report.main_delay_ms)
.unwrap_or(0.0),
current_report
.map(|report| report.bass_route_delay_ms)
.unwrap_or(0.0),
current_report
.map(|report| f64::from(report.polarity_inverted))
.unwrap_or(0.0),
current_report.map(|report| report.trim_db).unwrap_or(0.0),
]);
}
let objective = |params: &[f64]| -> f64 {
let mut total = 0.0;
let mut trim_power = 0.0;
for (idx, input) in optimizable.iter().enumerate() {
let base = idx * 6;
let freq = params[base].clamp(lower_bounds[base], upper_bounds[base]);
let candidates = &type_candidates[idx];
let type_idx = params[base + 1]
.round()
.clamp(0.0, (candidates.len().saturating_sub(1)) as f64)
as usize;
let xover_type = &candidates[type_idx];
let main_delay = params[base + 2].clamp(0.0, 20.0);
let bass_delay = params[base + 3].clamp(0.0, 20.0);
let inverted = params[base + 4] >= 0.5;
let trim = params[base + 5].clamp(lower_bounds[base + 5], upper_bounds[base + 5]);
let predicted = predict_bass_management_sum(
&input.virtual_main,
sub_curve,
xover_type,
freq,
sample_rate,
0.0,
trim,
main_delay,
bass_delay,
inverted,
);
let Some(group_loss) = bass_management_objective(predicted.as_ref(), freq) else {
return 1.0e12;
};
total += group_loss;
trim_power += 10.0_f64.powf(trim / 10.0);
}
let trim_power_db = 10.0 * trim_power.max(1e-12).log10();
let allowed = config
.system
.as_ref()
.and_then(|system| system.bass_management.as_ref())
.map(|bm| bm.headroom_margin_db)
.unwrap_or(6.0);
let headroom_excess = (trim_power_db - allowed).max(0.0);
total + headroom_excess * headroom_excess * 2.0
};
let baseline = optimizable
.iter()
.filter_map(|input| {
current_reports
.get(&input.group_id)
.and_then(|report| report.objective_after.or(report.objective_before))
})
.sum::<f64>();
let baseline = if baseline.is_finite() && baseline > 0.0 {
baseline
} else {
objective(&initial)
};
let (best, best_score) = differential_evolution_minimize(
&lower_bounds,
&upper_bounds,
&initial,
&objective,
config.optimizer.population,
config.optimizer.max_iter,
config.optimizer.seed.unwrap_or(0x514_ba55),
);
if best_score >= baseline - 1.0e-6 {
return None;
}
let mut reports = BTreeMap::new();
for input in inputs {
if !input.phase_available {
let mut advisories = input.advisories.clone();
if advisories.is_empty() {
advisories.push("phase_unavailable_joint_group_skipped".to_string());
}
reports.insert(
input.group_id.clone(),
super::home_cinema::BassManagementGroupReport {
group_id: input.group_id.clone(),
roles: input.roles.clone(),
crossover_type: input.plan.crossover_type.clone(),
selected_crossover_hz: Some(input.plan.frequency_hz),
configured_crossover_hz: Some(input.plan.configured_hz),
main_delay_ms: 0.0,
bass_route_delay_ms: 0.0,
polarity_inverted: false,
trim_db: 0.0,
objective_before: None,
objective_after: None,
advisories,
},
);
}
}
let mut decoded = Vec::new();
for (idx, input) in optimizable.iter().enumerate() {
let base = idx * 6;
let freq = best[base].clamp(lower_bounds[base], upper_bounds[base]);
let candidates = &type_candidates[idx];
let type_idx = best[base + 1]
.round()
.clamp(0.0, (candidates.len().saturating_sub(1)) as f64)
as usize;
let main_delay = best[base + 2].clamp(0.0, 20.0);
let bass_delay = best[base + 3].clamp(0.0, 20.0);
let inverted = best[base + 4] >= 0.5;
let trim = best[base + 5].clamp(lower_bounds[base + 5], upper_bounds[base + 5]);
decoded.push((
idx,
input,
freq,
candidates[type_idx].clone(),
main_delay,
bass_delay,
inverted,
trim,
));
}
let min_delay = decoded
.iter()
.flat_map(|(_, _, _, _, main_delay, bass_delay, _, _)| [*main_delay, *bass_delay])
.fold(f64::INFINITY, f64::min)
.is_finite()
.then(|| {
decoded
.iter()
.flat_map(|(_, _, _, _, main_delay, bass_delay, _, _)| [*main_delay, *bass_delay])
.fold(f64::INFINITY, f64::min)
})
.unwrap_or(0.0);
for (_, input, freq, xover_type, main_delay, bass_delay, inverted, trim) in decoded {
let objective_before_curve = predict_bass_management_sum(
&input.virtual_main,
sub_curve,
&xover_type,
input.plan.frequency_hz,
sample_rate,
0.0,
0.0,
0.0,
0.0,
false,
);
let objective_after_curve = predict_bass_management_sum(
&input.virtual_main,
sub_curve,
&xover_type,
freq,
sample_rate,
0.0,
trim,
main_delay - min_delay,
bass_delay - min_delay,
inverted,
);
let mut advisories = input.advisories.clone();
advisories.push("joint_de_optimized".to_string());
reports.insert(
input.group_id.clone(),
crate::roomeq::home_cinema::BassManagementGroupReport {
group_id: input.group_id.clone(),
roles: input.roles.clone(),
crossover_type: xover_type,
selected_crossover_hz: Some(freq),
configured_crossover_hz: Some(input.plan.configured_hz),
main_delay_ms: main_delay - min_delay,
bass_route_delay_ms: bass_delay - min_delay,
polarity_inverted: inverted,
trim_db: trim,
objective_before: bass_management_objective(
objective_before_curve.as_ref(),
input.plan.frequency_hz,
),
objective_after: bass_management_objective(objective_after_curve.as_ref(), freq),
advisories,
},
);
}
Some(reports)
}
fn differential_evolution_minimize<F>(
lower_bounds: &[f64],
upper_bounds: &[f64],
initial: &[f64],
objective: &F,
requested_population: usize,
requested_evals: usize,
seed: u64,
) -> (Vec<f64>, f64)
where
F: Fn(&[f64]) -> f64,
{
let dims = initial.len();
let population_size = requested_population.max(dims * 4).clamp(12, 96);
let max_evals = requested_evals
.max(population_size * 4)
.clamp(population_size, 2_000);
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let mut population = Vec::with_capacity(population_size);
population.push(initial.to_vec());
for _ in 1..population_size {
population.push(
lower_bounds
.iter()
.zip(upper_bounds.iter())
.map(|(lo, hi)| {
if (*hi - *lo).abs() <= f64::EPSILON {
*lo
} else {
rng.random_range(*lo..=*hi)
}
})
.collect::<Vec<_>>(),
);
}
let mut scores: Vec<f64> = population
.iter()
.map(|candidate| objective(candidate))
.collect();
let mut evals = population_size;
let mut best_idx = scores
.iter()
.enumerate()
.min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(idx, _)| idx)
.unwrap_or(0);
while evals < max_evals {
for target_idx in 0..population_size {
if evals >= max_evals {
break;
}
let mut a;
let mut b;
let mut c;
loop {
a = rng.random_range(0..population_size);
if a != target_idx {
break;
}
}
loop {
b = rng.random_range(0..population_size);
if b != target_idx && b != a {
break;
}
}
loop {
c = rng.random_range(0..population_size);
if c != target_idx && c != a && c != b {
break;
}
}
let forced_dim = rng.random_range(0..dims);
let mut trial = population[target_idx].clone();
for dim in 0..dims {
if dim == forced_dim || rng.random::<f64>() < 0.9 {
let value =
population[a][dim] + 0.7 * (population[b][dim] - population[c][dim]);
trial[dim] = value.clamp(lower_bounds[dim], upper_bounds[dim]);
}
}
let trial_score = objective(&trial);
evals += 1;
if trial_score < scores[target_idx] {
population[target_idx] = trial;
scores[target_idx] = trial_score;
if trial_score < scores[best_idx] {
best_idx = target_idx;
}
}
}
}
(population[best_idx].clone(), scores[best_idx])
}
fn bass_management_sub_output_results(
fallback_role: &str,
drivers: Option<&[SubDriverInfo]>,
shared_gain_db: f64,
strategy: &SubwooferStrategy,
) -> Vec<super::home_cinema::BassManagementSubOutputReport> {
let strategy_source = match strategy {
SubwooferStrategy::Single => "single",
SubwooferStrategy::Mso => "mso",
SubwooferStrategy::Dba => "dba",
};
let Some(drivers) = drivers else {
return vec![super::home_cinema::BassManagementSubOutputReport {
output_role: fallback_role.to_string(),
gain_db: shared_gain_db,
delay_ms: 0.0,
polarity_inverted: false,
strategy_source: strategy_source.to_string(),
headroom_contribution_db: shared_gain_db,
}];
};
drivers
.iter()
.map(|driver| super::home_cinema::BassManagementSubOutputReport {
output_role: driver.name.clone(),
gain_db: driver.gain + shared_gain_db,
delay_ms: driver.delay,
polarity_inverted: driver.inverted,
strategy_source: if matches!(strategy, SubwooferStrategy::Dba) {
if driver.inverted {
"dba_rear".to_string()
} else {
"dba_front".to_string()
}
} else {
strategy_source.to_string()
},
headroom_contribution_db: driver.gain + shared_gain_db,
})
.collect()
}
fn limit_bass_management_sub_output_gains(
sub_outputs: &mut [super::home_cinema::BassManagementSubOutputReport],
bass_management: Option<&super::home_cinema::EffectiveBassManagement>,
) -> bool {
let Some(bm) = bass_management else {
return false;
};
let max_boost = bm.config.max_sub_boost_db.max(0.0);
let mut limited = false;
for output in sub_outputs {
if output.gain_db > max_boost {
output.gain_db = max_boost;
output.headroom_contribution_db = output.gain_db;
limited = true;
}
}
limited
}
#[allow(dead_code)]
fn refine_bass_management_sub_outputs(
config: &RoomConfig,
main_roles: &[String],
aligned_pre_eq_curves: &HashMap<String, Curve>,
group_results: &mut BTreeMap<String, super::home_cinema::BassManagementGroupReport>,
sub_outputs: &mut [super::home_cinema::BassManagementSubOutputReport],
drivers: Option<&[SubDriverInfo]>,
sample_rate: f64,
) -> Vec<String> {
let Some(drivers) = drivers else {
return Vec::new();
};
if drivers.len() != sub_outputs.len() || drivers.is_empty() {
return vec!["joint_sub_output_skipped_driver_metadata_mismatch".to_string()];
}
if drivers.iter().any(|driver| {
driver
.initial_curve
.as_ref()
.map(|curve| !curve_has_usable_phase(curve))
.unwrap_or(true)
}) {
return vec!["joint_sub_output_skipped_missing_phase".to_string()];
}
let mut group_inputs = Vec::new();
for (group_id, roles) in grouped_home_cinema_roles(main_roles) {
let Some(group) = group_results.get(&group_id) else {
continue;
};
if group.selected_crossover_hz.is_none() {
continue;
}
let main_refs: Vec<&Curve> = roles
.iter()
.filter_map(|role| aligned_pre_eq_curves.get(role))
.collect();
if main_refs.len() != roles.len()
|| !all_curves_have_usable_phase(&main_refs)
|| !all_curves_share_frequency_grid(&main_refs)
{
continue;
}
group_inputs.push((group_id, complex_sum_mains(&main_refs)));
}
if group_inputs.is_empty() {
return vec!["joint_sub_output_skipped_no_phase_valid_groups".to_string()];
}
let mut lower_bounds = Vec::new();
let mut upper_bounds = Vec::new();
let mut initial = Vec::new();
for output in sub_outputs.iter() {
let is_dba_front = output.strategy_source == "dba_front";
let is_dba_rear = output.strategy_source == "dba_rear";
if is_dba_front {
lower_bounds.extend_from_slice(&[output.gain_db, 0.0, 0.0]);
upper_bounds.extend_from_slice(&[output.gain_db, 0.001, 0.0]);
} else if is_dba_rear {
lower_bounds.extend_from_slice(&[config.optimizer.min_db.min(-30.0), 0.0, 1.0]);
upper_bounds.extend_from_slice(&[0.0, 100.0, 1.0]);
} else {
let gain_span = config.optimizer.max_db.max(6.0);
lower_bounds.push(output.gain_db - gain_span);
upper_bounds.push(output.gain_db + gain_span);
lower_bounds.push(0.0);
upper_bounds.push(20.0);
lower_bounds.push(0.0);
upper_bounds.push(1.0);
}
initial.extend_from_slice(&[
output.gain_db,
output.delay_ms.max(0.0),
f64::from(output.polarity_inverted),
]);
}
let decode = |params: &[f64]| -> Vec<super::home_cinema::BassManagementSubOutputReport> {
let min_delay = params
.chunks_exact(3)
.map(|chunk| chunk[1].max(0.0))
.fold(f64::INFINITY, f64::min);
let min_delay = if min_delay.is_finite() {
min_delay
} else {
0.0
};
sub_outputs
.iter()
.enumerate()
.map(|(idx, output)| {
let base = idx * 3;
let gain = params[base].clamp(lower_bounds[base], upper_bounds[base]);
let delay = params[base + 1].clamp(lower_bounds[base + 1], upper_bounds[base + 1])
- min_delay;
let polarity = params[base + 2]
.round()
.clamp(lower_bounds[base + 2], upper_bounds[base + 2])
>= 0.5;
super::home_cinema::BassManagementSubOutputReport {
output_role: output.output_role.clone(),
gain_db: gain,
delay_ms: delay.max(0.0),
polarity_inverted: polarity,
strategy_source: output.strategy_source.clone(),
headroom_contribution_db: gain,
}
})
.collect()
};
let objective = |params: &[f64]| -> f64 {
let decoded = decode(params);
let mut total = 0.0;
for (group_id, virtual_main) in &group_inputs {
let Some(group) = group_results.get(group_id) else {
continue;
};
let Some(freq) = group.selected_crossover_hz else {
continue;
};
let Some(virtual_sub) =
sum_sub_output_responses_on_grid(&virtual_main.freq, drivers, &decoded)
else {
return 1.0e12;
};
let predicted = predict_bass_management_sum(
virtual_main,
&virtual_sub,
&group.crossover_type,
freq,
sample_rate,
0.0,
group.trim_db,
group.main_delay_ms,
group.bass_route_delay_ms,
group.polarity_inverted,
);
let Some(loss) = bass_management_objective(predicted.as_ref(), freq) else {
return 1.0e12;
};
total += loss;
}
let gain_power = decoded
.iter()
.map(|output| 10.0_f64.powf(output.gain_db / 10.0))
.sum::<f64>();
let gain_power_db = 10.0 * gain_power.max(1e-12).log10();
let allowed = config
.system
.as_ref()
.and_then(|system| system.bass_management.as_ref())
.map(|bm| bm.headroom_margin_db)
.unwrap_or(6.0);
let headroom_excess = (gain_power_db - allowed).max(0.0);
total + headroom_excess * headroom_excess * 2.0
};
let baseline = objective(&initial);
let (best, best_score) = differential_evolution_minimize(
&lower_bounds,
&upper_bounds,
&initial,
&objective,
config.optimizer.population,
config.optimizer.max_iter,
config.optimizer.seed.unwrap_or(0x5ab_014),
);
if best_score >= baseline - 1.0e-6 {
return vec!["joint_sub_output_no_improvement".to_string()];
}
let decoded = decode(&best);
for (target, optimized) in sub_outputs.iter_mut().zip(decoded) {
*target = optimized;
}
for (group_id, virtual_main) in group_inputs {
if let Some(group) = group_results.get_mut(&group_id)
&& let Some(freq) = group.selected_crossover_hz
&& let Some(virtual_sub) =
sum_sub_output_responses_on_grid(&virtual_main.freq, drivers, sub_outputs)
{
let predicted = predict_bass_management_sum(
&virtual_main,
&virtual_sub,
&group.crossover_type,
freq,
sample_rate,
0.0,
group.trim_db,
group.main_delay_ms,
group.bass_route_delay_ms,
group.polarity_inverted,
);
group.objective_after = bass_management_objective(predicted.as_ref(), freq);
group.advisories.retain(|advisory| {
advisory != "ok" && advisory != "joint_sub_output_no_improvement"
});
group
.advisories
.push("joint_sub_output_de_optimized".to_string());
}
}
vec!["joint_sub_output_de_optimized".to_string()]
}
#[allow(clippy::too_many_arguments)]
fn optimize_bass_management_joint_solution(
config: &RoomConfig,
main_roles: &[String],
aligned_pre_eq_curves: &HashMap<String, Curve>,
group_results: &mut BTreeMap<String, super::home_cinema::BassManagementGroupReport>,
sub_outputs: &mut [super::home_cinema::BassManagementSubOutputReport],
drivers: Option<&[SubDriverInfo]>,
sub_role: &str,
sample_rate: f64,
) -> Vec<String> {
let driver_inputs = if let Some(drivers) = drivers {
drivers.to_vec()
} else {
vec![SubDriverInfo {
name: sub_role.to_string(),
gain: 0.0,
delay: 0.0,
inverted: false,
initial_curve: aligned_pre_eq_curves.get(sub_role).cloned(),
}]
};
if driver_inputs.len() != sub_outputs.len() || driver_inputs.is_empty() {
return vec!["joint_route_optimizer_skipped_driver_metadata_mismatch".to_string()];
}
if driver_inputs.iter().any(|driver| {
driver
.initial_curve
.as_ref()
.map(|curve| !curve_has_usable_phase(curve))
.unwrap_or(true)
}) {
return vec!["joint_route_optimizer_skipped_missing_sub_phase".to_string()];
}
let mut group_inputs = Vec::new();
for (group_id, roles) in grouped_home_cinema_roles(main_roles) {
let Some(group) = group_results.get(&group_id).cloned() else {
continue;
};
if group.selected_crossover_hz.is_none() {
continue;
}
let main_refs: Vec<&Curve> = roles
.iter()
.filter_map(|role| aligned_pre_eq_curves.get(role))
.collect();
if main_refs.len() != roles.len()
|| !all_curves_have_usable_phase(&main_refs)
|| !all_curves_share_frequency_grid(&main_refs)
{
continue;
}
group_inputs.push((group_id, roles, group, complex_sum_mains(&main_refs)));
}
if group_inputs.is_empty() {
return vec!["joint_route_optimizer_skipped_no_phase_valid_groups".to_string()];
}
let mut lower_bounds = Vec::new();
let mut upper_bounds = Vec::new();
let mut initial = Vec::new();
let mut type_candidates = Vec::new();
for (group_id, _, group, _) in &group_inputs {
let candidates = bass_management_crossover_type_candidates(&group.crossover_type)
.into_iter()
.filter(|candidate| candidate.parse::<crate::loss::CrossoverType>().is_ok())
.collect::<Vec<_>>();
let candidates = if candidates.is_empty() {
vec![group.crossover_type.clone()]
} else {
candidates
};
let type_idx = candidates
.iter()
.position(|candidate| candidate == &group.crossover_type)
.unwrap_or(0) as f64;
let current_freq = group
.selected_crossover_hz
.or(group.configured_crossover_hz)
.unwrap_or(80.0);
let octave = 2.0_f64.sqrt();
let role_bounds = if group_id == "height" {
(60.0, 200.0)
} else {
(40.0, 160.0)
};
let min_freq = (current_freq / octave).clamp(role_bounds.0, role_bounds.1);
let max_freq = (current_freq * octave).clamp(min_freq, role_bounds.1);
type_candidates.push(candidates);
lower_bounds.extend_from_slice(&[min_freq, 0.0, 0.0, 0.0, 0.0, config.optimizer.min_db]);
upper_bounds.extend_from_slice(&[
max_freq,
(type_candidates.last().unwrap().len().saturating_sub(1)) as f64,
20.0,
20.0,
1.0,
config
.system
.as_ref()
.and_then(|system| system.bass_management.as_ref())
.map(|bm| bm.max_sub_boost_db.max(0.0))
.unwrap_or(config.optimizer.max_db.max(0.0)),
]);
initial.extend_from_slice(&[
current_freq,
type_idx,
group.main_delay_ms.max(0.0),
group.bass_route_delay_ms.max(0.0),
f64::from(group.polarity_inverted),
group.trim_db,
]);
}
let output_offset = initial.len();
let max_output_boost = config
.system
.as_ref()
.and_then(|system| system.bass_management.as_ref())
.map(|bm| bm.max_sub_boost_db.max(0.0))
.unwrap_or(config.optimizer.max_db.max(0.0));
for output in sub_outputs.iter() {
let is_dba_front = output.strategy_source == "dba_front";
let is_dba_rear = output.strategy_source == "dba_rear";
if is_dba_front {
lower_bounds.extend_from_slice(&[output.gain_db, 0.0, 0.0]);
upper_bounds.extend_from_slice(&[output.gain_db, 0.001, 0.0]);
} else if is_dba_rear {
lower_bounds.extend_from_slice(&[config.optimizer.min_db.min(-30.0), 0.0, 1.0]);
upper_bounds.extend_from_slice(&[0.0, 100.0, 1.0]);
} else {
let gain_span = config.optimizer.max_db.max(6.0);
lower_bounds.extend_from_slice(&[output.gain_db - gain_span, 0.0, 0.0]);
upper_bounds.extend_from_slice(&[max_output_boost, 20.0, 1.0]);
}
initial.extend_from_slice(&[
output.gain_db,
output.delay_ms.max(0.0),
f64::from(output.polarity_inverted),
]);
}
let decode = |params: &[f64]| {
let mut groups = Vec::new();
let mut delays = Vec::new();
for (idx, (group_id, roles, group, _)) in group_inputs.iter().enumerate() {
let base = idx * 6;
let candidates = &type_candidates[idx];
let type_idx = params[base + 1]
.round()
.clamp(0.0, (candidates.len().saturating_sub(1)) as f64)
as usize;
let main_delay = params[base + 2].clamp(lower_bounds[base + 2], upper_bounds[base + 2]);
let bass_delay = params[base + 3].clamp(lower_bounds[base + 3], upper_bounds[base + 3]);
delays.push(main_delay);
delays.push(bass_delay);
groups.push(super::home_cinema::BassManagementGroupReport {
group_id: group_id.clone(),
roles: roles.clone(),
crossover_type: candidates[type_idx].clone(),
selected_crossover_hz: Some(
params[base].clamp(lower_bounds[base], upper_bounds[base]),
),
configured_crossover_hz: group.configured_crossover_hz,
main_delay_ms: main_delay,
bass_route_delay_ms: bass_delay,
polarity_inverted: params[base + 4].round().clamp(0.0, 1.0) >= 0.5,
trim_db: params[base + 5].clamp(lower_bounds[base + 5], upper_bounds[base + 5]),
objective_before: group.objective_before,
objective_after: group.objective_after,
advisories: group.advisories.clone(),
});
}
let mut outputs = Vec::new();
for (idx, output) in sub_outputs.iter().enumerate() {
let base = output_offset + idx * 3;
let delay = params[base + 1].clamp(lower_bounds[base + 1], upper_bounds[base + 1]);
delays.push(delay);
outputs.push(super::home_cinema::BassManagementSubOutputReport {
output_role: output.output_role.clone(),
gain_db: params[base].clamp(lower_bounds[base], upper_bounds[base]),
delay_ms: delay,
polarity_inverted: params[base + 2]
.round()
.clamp(lower_bounds[base + 2], upper_bounds[base + 2])
>= 0.5,
strategy_source: output.strategy_source.clone(),
headroom_contribution_db: params[base]
.clamp(lower_bounds[base], upper_bounds[base]),
});
}
let common_delay = delays.into_iter().fold(f64::INFINITY, f64::min);
let common_delay = if common_delay.is_finite() {
common_delay
} else {
0.0
};
for group in &mut groups {
group.main_delay_ms = (group.main_delay_ms - common_delay).max(0.0);
group.bass_route_delay_ms = (group.bass_route_delay_ms - common_delay).max(0.0);
}
for output in &mut outputs {
output.delay_ms = (output.delay_ms - common_delay).max(0.0);
}
(groups, outputs)
};
let objective = |params: &[f64]| -> f64 {
let (groups, outputs) = decode(params);
let mut total = 0.0;
for ((_, _, _, virtual_main), group) in group_inputs.iter().zip(groups.iter()) {
let Some(freq) = group.selected_crossover_hz else {
return 1.0e12;
};
let Some(virtual_sub) =
sum_sub_output_responses_on_grid(&virtual_main.freq, &driver_inputs, &outputs)
else {
return 1.0e12;
};
let predicted = predict_bass_management_sum(
virtual_main,
&virtual_sub,
&group.crossover_type,
freq,
sample_rate,
0.0,
group.trim_db,
group.main_delay_ms,
group.bass_route_delay_ms,
group.polarity_inverted,
);
let Some(loss) = bass_management_objective(predicted.as_ref(), freq) else {
return 1.0e12;
};
total += loss;
}
let optimization = joint_bass_management_report_from_parts(&groups, &outputs);
let graph = super::home_cinema::bass_management_routing_graph(config, Some(&optimization));
if let Some(effective) = super::home_cinema::effective_bass_management(config) {
if let Some(headroom) = super::home_cinema::simulate_bass_bus_headroom(
graph.as_ref(),
&effective.config.headroom_model,
effective.config.headroom_margin_db,
sample_rate,
) {
let headroom_excess = (-headroom.margin_db).max(0.0);
total += headroom_excess * headroom_excess * 2.0;
}
}
total
};
let baseline = objective(&initial);
let (best, best_score) = differential_evolution_minimize(
&lower_bounds,
&upper_bounds,
&initial,
&objective,
config.optimizer.population,
config.optimizer.max_iter,
config.optimizer.seed.unwrap_or(0x14_ba55),
);
if best_score >= baseline - 1.0e-6 {
return vec!["joint_optimizer_no_improvement".to_string()];
}
let (mut decoded_groups, decoded_outputs) = decode(&best);
for ((_, _, _, virtual_main), group) in group_inputs.iter().zip(decoded_groups.iter_mut()) {
if let Some(freq) = group.selected_crossover_hz
&& let Some(virtual_sub) = sum_sub_output_responses_on_grid(
&virtual_main.freq,
&driver_inputs,
&decoded_outputs,
)
{
let before = predict_bass_management_sum(
virtual_main,
&virtual_sub,
&group.crossover_type,
group.configured_crossover_hz.unwrap_or(freq),
sample_rate,
0.0,
0.0,
0.0,
0.0,
false,
);
let after = predict_bass_management_sum(
virtual_main,
&virtual_sub,
&group.crossover_type,
freq,
sample_rate,
0.0,
group.trim_db,
group.main_delay_ms,
group.bass_route_delay_ms,
group.polarity_inverted,
);
group.objective_before = bass_management_objective(before.as_ref(), freq);
group.objective_after = bass_management_objective(after.as_ref(), freq);
}
group
.advisories
.retain(|advisory| advisory != "ok" && advisory != "joint_optimizer_no_improvement");
group
.advisories
.push("joint_route_de_optimized".to_string());
}
for group in decoded_groups {
group_results.insert(group.group_id.clone(), group);
}
for (target, optimized) in sub_outputs.iter_mut().zip(decoded_outputs) {
*target = optimized;
}
vec!["joint_route_de_optimized".to_string()]
}
fn joint_bass_management_report_from_parts(
groups: &[super::home_cinema::BassManagementGroupReport],
outputs: &[super::home_cinema::BassManagementSubOutputReport],
) -> super::home_cinema::BassManagementOptimizationReport {
let first_group = groups.first();
let applied_gain = outputs
.iter()
.map(|output| output.gain_db)
.fold(f64::NEG_INFINITY, f64::max);
let applied_gain = if applied_gain.is_finite() {
applied_gain
} else {
0.0
};
super::home_cinema::BassManagementOptimizationReport {
applied: true,
phase_required: true,
phase_available: true,
configured_crossover_hz: first_group.and_then(|group| group.configured_crossover_hz),
optimized_crossover_hz: first_group.and_then(|group| group.selected_crossover_hz),
crossover_range_hz: None,
crossover_type: first_group
.map(|group| group.crossover_type.clone())
.unwrap_or_else(|| "LR24".to_string()),
main_delay_ms: first_group.map(|group| group.main_delay_ms).unwrap_or(0.0),
sub_delay_ms: first_group
.map(|group| group.bass_route_delay_ms)
.unwrap_or(0.0),
relative_sub_delay_ms: first_group
.map(|group| group.bass_route_delay_ms - group.main_delay_ms)
.unwrap_or(0.0),
sub_polarity_inverted: first_group
.map(|group| group.polarity_inverted)
.unwrap_or(false),
requested_sub_gain_db: applied_gain,
applied_sub_gain_db: applied_gain,
gain_limited: false,
estimated_bass_bus_peak_gain_db: None,
objective_before: groups
.iter()
.filter_map(|group| group.objective_before)
.reduce(|a, b| a + b),
objective_after: groups
.iter()
.filter_map(|group| group.objective_after)
.reduce(|a, b| a + b),
group_results: groups.to_vec(),
sub_output_results: outputs.to_vec(),
advisories: vec!["joint_route_solution".to_string()],
}
}
fn predict_bass_output_curve_from_routes(
sub_curve: &Curve,
graph: &super::home_cinema::BassManagementRoutingGraph,
output_role: &str,
sample_rate: f64,
) -> Option<Curve> {
use num_complex::Complex;
if !curve_has_usable_phase(sub_curve) {
return None;
}
let phase = sub_curve.phase.as_ref()?;
let mut complex_sum = vec![Complex::new(0.0, 0.0); sub_curve.freq.len()];
let mut any_route = false;
for route in graph.routes.iter().filter(|route| {
route.destination == output_role
&& (route.route_kind == "redirected_bass_lowpass_to_sub"
|| route.route_kind == "lfe_lowpass_to_sub")
}) {
any_route = true;
let filters = if let Some(freq) = route.low_pass_hz {
create_crossover_filters(&route.crossover_type, freq, sample_rate, true)
} else {
Vec::new()
};
let response =
response::compute_peq_complex_response(&filters, &sub_curve.freq, sample_rate);
let polarity_phase = if route.polarity_inverted { 180.0 } else { 0.0 };
for idx in 0..sub_curve.freq.len() {
let freq_hz = sub_curve.freq[idx];
let delay_phase = -360.0 * freq_hz * route.delay_ms / 1000.0;
let mag = 10.0_f64.powf((sub_curve.spl[idx] + route.gain_db) / 20.0);
let phase_rad = (phase[idx] + delay_phase + polarity_phase).to_radians();
complex_sum[idx] += Complex::from_polar(mag, phase_rad) * response[idx];
}
}
if !any_route {
return None;
}
let mut spl = ndarray::Array1::<f64>::zeros(sub_curve.freq.len());
let mut output_phase = ndarray::Array1::<f64>::zeros(sub_curve.freq.len());
for (idx, value) in complex_sum.iter().enumerate() {
spl[idx] = 20.0 * value.norm().max(1e-12).log10();
output_phase[idx] = value.arg().to_degrees();
}
Some(Curve {
freq: sub_curve.freq.clone(),
spl,
phase: Some(output_phase),
..Default::default()
})
}
fn predict_bass_bus_curve_from_routes(
reference_curve: &Curve,
graph: &super::home_cinema::BassManagementRoutingGraph,
output_base_curves: &HashMap<String, Curve>,
fallback_curve: &Curve,
sample_rate: f64,
) -> Option<Curve> {
use num_complex::Complex;
if !curve_has_usable_phase(reference_curve) {
return None;
}
let mut complex_sum = vec![Complex::new(0.0, 0.0); reference_curve.freq.len()];
let mut any_route = false;
for route in graph.routes.iter().filter(|route| {
route.route_kind == "redirected_bass_lowpass_to_sub"
|| route.route_kind == "lfe_lowpass_to_sub"
}) {
let base_curve = output_base_curves
.get(&route.destination)
.unwrap_or(fallback_curve);
if !curve_has_usable_phase(base_curve) {
continue;
}
let curve = if super::frequency_grid::same_frequency_grid(
&reference_curve.freq,
&base_curve.freq,
) {
base_curve.clone()
} else {
crate::read::interpolate_log_space(&reference_curve.freq, base_curve)
};
let Some(phase) = curve.phase.as_ref() else {
continue;
};
any_route = true;
let filters = if let Some(freq) = route.low_pass_hz {
create_crossover_filters(&route.crossover_type, freq, sample_rate, true)
} else {
Vec::new()
};
let response =
response::compute_peq_complex_response(&filters, &reference_curve.freq, sample_rate);
let polarity_phase = if route.polarity_inverted { 180.0 } else { 0.0 };
for idx in 0..reference_curve.freq.len() {
let freq_hz = reference_curve.freq[idx];
let delay_phase = -360.0 * freq_hz * route.delay_ms / 1000.0;
let mag = 10.0_f64.powf((curve.spl[idx] + route.gain_db) / 20.0);
let phase_rad = (phase[idx] + delay_phase + polarity_phase).to_radians();
complex_sum[idx] += Complex::from_polar(mag, phase_rad) * response[idx];
}
}
if !any_route {
return None;
}
let mut spl = ndarray::Array1::<f64>::zeros(reference_curve.freq.len());
let mut output_phase = ndarray::Array1::<f64>::zeros(reference_curve.freq.len());
for (idx, value) in complex_sum.iter().enumerate() {
spl[idx] = 20.0 * value.norm().max(1e-12).log10();
output_phase[idx] = value.arg().to_degrees();
}
Some(Curve {
freq: reference_curve.freq.clone(),
spl,
phase: Some(output_phase),
..Default::default()
})
}
fn bass_route_upper_frequency_hz(
graph: Option<&super::home_cinema::BassManagementRoutingGraph>,
fallback_hz: f64,
) -> f64 {
graph
.and_then(|graph| {
graph
.routes
.iter()
.filter_map(|route| route.low_pass_hz)
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
})
.unwrap_or(fallback_hz)
}
fn representative_bass_route_signature(
graph: Option<&super::home_cinema::BassManagementRoutingGraph>,
fallback_type: &str,
fallback_hz: f64,
) -> (String, f64) {
graph
.and_then(|graph| {
graph
.routes
.iter()
.filter(|route| {
route.route_kind == "redirected_bass_lowpass_to_sub"
|| route.route_kind == "lfe_lowpass_to_sub"
})
.filter_map(|route| {
route
.low_pass_hz
.map(|freq| (route.crossover_type.clone(), freq))
})
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
})
.unwrap_or_else(|| (fallback_type.to_string(), fallback_hz))
}
fn sum_sub_output_responses_on_grid(
target_freq: &ndarray::Array1<f64>,
drivers: &[SubDriverInfo],
outputs: &[super::home_cinema::BassManagementSubOutputReport],
) -> Option<Curve> {
use num_complex::Complex;
if drivers.len() != outputs.len() || drivers.is_empty() {
return None;
}
let mut complex_sum = vec![Complex::new(0.0, 0.0); target_freq.len()];
for (driver, output) in drivers.iter().zip(outputs.iter()) {
let curve = driver.initial_curve.as_ref()?;
if !curve_has_usable_phase(curve) {
return None;
}
let interpolated = crate::read::interpolate_log_space(target_freq, curve);
let phase = interpolated.phase.as_ref()?;
for idx in 0..target_freq.len() {
let freq_hz = target_freq[idx];
let gain_db = output.gain_db;
let delay_phase = -360.0 * freq_hz * output.delay_ms / 1000.0;
let polarity_phase = if output.polarity_inverted { 180.0 } else { 0.0 };
let mag = 10.0_f64.powf((interpolated.spl[idx] + gain_db) / 20.0);
let phase_rad = (phase[idx] + delay_phase + polarity_phase).to_radians();
complex_sum[idx] += Complex::from_polar(mag, phase_rad);
}
}
let mut spl = ndarray::Array1::<f64>::zeros(target_freq.len());
let mut phase = ndarray::Array1::<f64>::zeros(target_freq.len());
for (idx, value) in complex_sum.iter().enumerate() {
spl[idx] = 20.0 * value.norm().max(1e-12).log10();
phase[idx] = value.arg().to_degrees();
}
Some(Curve {
freq: target_freq.clone(),
spl,
phase: Some(phase),
..Default::default()
})
}
pub fn optimize_stereo_2_0(
config: &RoomConfig,
sys: &SystemConfig,
sample_rate: f64,
output_dir: &Path,
) -> Result<RoomOptimizationResult> {
info!("Running Stereo 2.0 Optimization Workflow");
let curves = load_logical_channels(config, sys)?;
let mut ranges = HashMap::new();
for role in curves.keys() {
ranges.insert(role.clone(), (100.0, 2000.0));
}
let gains = align_channels_to_lowest(&curves, &ranges);
let mut channel_chains = HashMap::new();
let mut channel_results = HashMap::new();
let mut pre_scores = Vec::new();
let mut post_scores = Vec::new();
for role in curves.keys() {
let gain = *gains.get(role).unwrap_or(&0.0);
let source = resolve_single_source(role, config, sys)?;
info!(" Optimizing '{}' with alignment gain {:.2} dB", role, gain);
let (chain, ch_result, pre_score, post_score, _fir, _multiseat_rejection) =
run_channel_via_generic_path(role, source, config, gain, sample_rate, output_dir)?;
info!(
" '{}' pre_score={:.4} post_score={:.4}",
role, pre_score, post_score
);
channel_chains.insert(role.clone(), chain);
channel_results.insert(role.clone(), ch_result);
pre_scores.push(pre_score);
post_scores.push(post_score);
}
let avg_pre = pre_scores.iter().sum::<f64>() / pre_scores.len() as f64;
let avg_post = post_scores.iter().sum::<f64>() / post_scores.len() as f64;
info!(
"Average pre-score: {:.4}, post-score: {:.4}",
avg_pre, avg_post
);
let epa_cfg = config.optimizer.epa_config.clone().unwrap_or_default();
let epa_per_channel = crate::roomeq::output::compute_epa_per_channel(&channel_chains, &epa_cfg);
Ok(RoomOptimizationResult {
channels: channel_chains,
channel_results,
combined_pre_score: avg_pre,
combined_post_score: avg_post,
metadata: OptimizationMetadata {
pre_score: avg_pre,
post_score: avg_post,
algorithm: config.optimizer.algorithm.clone(),
loss_type: Some(config.optimizer.loss_type.clone()),
iterations: config.optimizer.max_iter,
timestamp: chrono::Utc::now().to_rfc3339(),
inter_channel_deviation: None,
epa_per_channel,
group_delay: None,
perceptual_metrics: None,
home_cinema_layout: None,
multi_seat_coverage: None,
multi_seat_correction: None,
bass_management: None,
timing_diagnostics: None,
},
})
}
pub fn optimize_stereo_2_1(
config: &RoomConfig,
sys: &SystemConfig,
sample_rate: f64,
output_dir: &Path,
) -> Result<RoomOptimizationResult> {
info!("Running Stereo 2.1 Optimization Workflow");
let sub_role = super::home_cinema::bass_output_role(config, sys);
let mut curves = HashMap::new();
for role in ["L", "R"] {
let meas_key = sys
.speakers
.get(role)
.ok_or(AutoeqError::InvalidConfiguration {
message: format!("Missing speaker mapping for '{}'", role),
})?;
let cfg = config
.speakers
.get(meas_key)
.ok_or(AutoeqError::InvalidConfiguration {
message: format!("Missing speaker config for key '{}'", meas_key),
})?;
let source = match cfg {
SpeakerConfig::Single(s) => s,
_ => {
return Err(AutoeqError::InvalidConfiguration {
message: format!("'{}' must be a Single speaker config", role),
});
}
};
let curve = load_source(source).map_err(|e| AutoeqError::InvalidMeasurement {
message: e.to_string(),
})?;
curves.insert(role.to_string(), curve);
}
let sub_sys = sys
.subwoofers
.as_ref()
.ok_or(AutoeqError::InvalidConfiguration {
message: "Missing subwoofers configuration".to_string(),
})?;
let lfe_meas_key =
sys.speakers
.get(sub_role.as_str())
.ok_or(AutoeqError::InvalidConfiguration {
message: format!("Missing speaker mapping for '{}'", sub_role),
})?;
let lfe_speaker_config =
config
.speakers
.get(lfe_meas_key)
.ok_or(AutoeqError::InvalidConfiguration {
message: format!("Missing speaker config for key '{}'", lfe_meas_key),
})?;
let sub_preprocess = preprocess_sub(
lfe_speaker_config,
&sub_sys.config,
&config.optimizer,
sample_rate,
)?;
curves.insert(sub_role.clone(), sub_preprocess.combined_curve.clone());
let xover_key = sub_sys
.crossover
.as_deref()
.ok_or(AutoeqError::InvalidConfiguration {
message: "Subwoofer config requires 'crossover' reference".to_string(),
})?;
let xover_config = config
.crossovers
.as_ref()
.and_then(|m| m.get(xover_key))
.ok_or(AutoeqError::InvalidConfiguration {
message: format!("Crossover '{}' not found in crossovers section", xover_key),
})?;
let xover_type_str = &xover_config.crossover_type;
let bass_management = super::home_cinema::effective_bass_management(config);
let (min_xo, max_xo, est_xo) = if let Some(f) = xover_config.frequency {
(f, f, f)
} else if let Some((min, max)) = xover_config.frequency_range {
(min, max, (min * max).sqrt())
} else {
return Err(AutoeqError::InvalidConfiguration {
message: "Subwoofer crossover requires 'frequency' or 'frequency_range'".to_string(),
});
};
let mut ranges = HashMap::new();
ranges.insert("L".to_string(), (max_xo, 2000.0));
ranges.insert("R".to_string(), (max_xo, 2000.0));
let sub_min_align = config.optimizer.min_freq.max(20.0);
ranges.insert(sub_role.clone(), (sub_min_align, max_xo));
let gains = align_channels_to_lowest(&curves, &ranges);
let mut aligned_curves = HashMap::new();
for (role, curve) in &curves {
let mut c = curve.clone();
let g = *gains.get(role).unwrap_or(&0.0);
for s in c.spl.iter_mut() {
*s += g;
}
aligned_curves.insert(role.clone(), c);
}
let mut pre_eq_plugins: HashMap<String, Vec<super::types::PluginConfigWrapper>> =
HashMap::new();
let mut linearized_curves: HashMap<String, Curve> = HashMap::new();
for role in ["L", "R"] {
let source = resolve_single_source(role, config, sys)?;
let mut per_config = config.clone();
per_config.optimizer.min_freq = min_xo;
info!(
" Pre-EQ via generic path for '{}' (min_freq={:.1} Hz)",
role, min_xo
);
let (chain, ch_result, _pre_score, _post_score, _fir, _multiseat_rejection) =
run_channel_via_generic_path(role, source, &per_config, 0.0, sample_rate, output_dir)?;
pre_eq_plugins.insert(role.to_string(), chain.plugins);
linearized_curves.insert(role.to_string(), ch_result.final_curve);
}
{
let sub_source = crate::MeasurementSource::InMemory(sub_preprocess.combined_curve.clone());
let mut sub_config = config.clone();
sub_config.optimizer.max_freq = max_xo;
info!(
" Pre-EQ via generic path for '{}' (max_freq={:.1} Hz)",
sub_role, max_xo
);
let (chain, ch_result, _pre_score, _post_score, _fir, _multiseat_rejection) =
run_channel_via_generic_path(
&sub_role,
&sub_source,
&sub_config,
0.0,
sample_rate,
output_dir,
)?;
pre_eq_plugins.insert(sub_role.clone(), chain.plugins);
linearized_curves.insert(sub_role.clone(), ch_result.final_curve);
}
let mut aligned_pre_eq_curves: HashMap<String, Curve> = HashMap::new();
for role in ["L", "R", sub_role.as_str()] {
let mut c = linearized_curves[role].clone();
let g = *gains.get(role).unwrap_or(&0.0);
for s in c.spl.iter_mut() {
*s += g;
}
aligned_pre_eq_curves.insert(role.to_string(), c);
}
let l_curve = &aligned_pre_eq_curves["L"];
let r_curve = &aligned_pre_eq_curves["R"];
let sub_curve = &aligned_pre_eq_curves[&sub_role];
let measured_phase_inputs = [
&aligned_curves["L"],
&aligned_curves["R"],
&aligned_curves[&sub_role],
];
let phase_inputs = [l_curve, r_curve, sub_curve];
let measured_phase_available = all_curves_have_usable_phase(&measured_phase_inputs);
let shared_grid_available = all_curves_share_frequency_grid(&measured_phase_inputs)
&& all_curves_share_frequency_grid(&phase_inputs);
let phase_available = measured_phase_available && shared_grid_available;
let mut optimization_advisories = Vec::new();
if !measured_phase_available {
optimization_advisories.push("missing_phase_crossover_alignment_skipped".to_string());
} else if !shared_grid_available {
optimization_advisories
.push("frequency_grid_mismatch_crossover_alignment_skipped".to_string());
}
let virtual_main = if phase_available {
complex_sum_mains(&[l_curve, r_curve])
} else {
average_mains_magnitude(&[l_curve, r_curve])
};
let final_xover_type = select_bass_management_crossover_type(
xover_type_str,
&virtual_main,
sub_curve,
est_xo,
sample_rate,
);
let xover_type_str = final_xover_type.as_str();
let crossover_type_enum: crate::loss::CrossoverType = xover_type_str
.parse()
.map_err(|e: String| AutoeqError::InvalidConfiguration { message: e })?;
let (fixed_freqs, range_opt) = if xover_config.frequency.is_some() {
(Some(vec![est_xo]), None)
} else {
(None, Some((min_xo, max_xo)))
};
let mut xo_optimizer_config = config.optimizer.clone();
xo_optimizer_config.min_db = 0.0;
xo_optimizer_config.max_db = 0.0;
let objective_before_curve = predict_bass_management_sum(
&virtual_main,
sub_curve,
xover_type_str,
est_xo,
sample_rate,
0.0,
0.0,
0.0,
0.0,
false,
);
let objective_before = bass_management_objective(objective_before_curve.as_ref(), est_xo);
let (main_gain_post, main_delay_raw, sub_gain_raw, sub_delay_raw, sub_inverted, final_xo_freq) =
if phase_available {
let (xo_gains, xo_delays, xo_freqs, _, inversions) = crossover::optimize_crossover(
vec![virtual_main.clone(), sub_curve.clone()],
crossover_type_enum,
sample_rate,
&xo_optimizer_config,
fixed_freqs,
range_opt,
)
.map_err(|e| AutoeqError::OptimizationFailed {
message: e.to_string(),
})?;
(
xo_gains[0],
xo_delays[0],
xo_gains[1],
xo_delays[1],
inversions[1],
xo_freqs[0],
)
} else {
(0.0, 0.0, 0.0, 0.0, false, est_xo)
};
let (main_delay_post, sub_delay_post) =
normalize_crossover_delays(main_delay_raw, sub_delay_raw);
let sub_gain_post = sub_gain_raw;
info!(
" Crossover Optimized: Freq={:.1} Hz, Main Gain={:.2}, Sub Gain={:.2}, Main Delay={:.2}, Sub Delay={:.2}",
final_xo_freq, main_gain_post, sub_gain_post, main_delay_post, sub_delay_post
);
let hp_biquads = create_crossover_filters(xover_type_str, final_xo_freq, sample_rate, false);
let lp_biquads = create_crossover_filters(xover_type_str, final_xo_freq, sample_rate, true);
let apply_chain =
|curve: &Curve, filters: &[Biquad], gain: f64, delay: f64, invert: bool| -> Curve {
let resp = response::compute_peq_complex_response(filters, &curve.freq, sample_rate);
let mut c = response::apply_complex_response(curve, &resp);
for s in c.spl.iter_mut() {
*s += gain;
}
apply_delay_and_polarity_to_curve(&c, delay, invert)
};
let l_post = apply_chain(
&aligned_pre_eq_curves["L"],
&hp_biquads,
main_gain_post,
main_delay_post,
false,
);
let r_post = apply_chain(
&aligned_pre_eq_curves["R"],
&hp_biquads,
main_gain_post,
main_delay_post,
false,
);
let sub_post_initial = apply_chain(
&aligned_pre_eq_curves[&sub_role],
&lp_biquads,
sub_gain_post,
sub_delay_post,
sub_inverted,
);
let main_freqs_f32: Vec<f32> = l_post.freq.iter().map(|&f| f as f32).collect();
let main_spl_f32: Vec<f32> = l_post.spl.iter().map(|&s| s as f32).collect();
let sub_freqs_f32: Vec<f32> = sub_post_initial.freq.iter().map(|&f| f as f32).collect();
let sub_spl_f32: Vec<f32> = sub_post_initial.spl.iter().map(|&s| s as f32).collect();
let main_mean = compute_average_response(
&main_freqs_f32,
&main_spl_f32,
Some((final_xo_freq as f32, 2000.0)),
) as f64;
let sub_mean = compute_average_response(
&sub_freqs_f32,
&sub_spl_f32,
Some((20.0, final_xo_freq as f32)),
) as f64;
let sub_correction = main_mean - sub_mean;
info!(
" Re-aligning Subwoofer: Main={:.2} dB, Sub={:.2} dB, Correction={:+.2} dB",
main_mean, sub_mean, sub_correction
);
let lfe_physical_gain = bass_management
.as_ref()
.filter(|bm| bm.config.apply_lfe_gain_to_chain)
.map(|bm| bm.config.lfe_playback_gain_db)
.unwrap_or(0.0);
let requested_sub_gain = sub_gain_post + sub_correction + lfe_physical_gain;
let (sub_gain_post, sub_gain_limited) =
super::home_cinema::limited_sub_gain(requested_sub_gain, bass_management.as_ref());
if sub_gain_limited {
log::warn!(
" Bass management limited sub gain from {:+.2} dB to {:+.2} dB for headroom",
requested_sub_gain,
sub_gain_post
);
optimization_advisories.push("sub_gain_limited_for_headroom".to_string());
}
let mut sub_post = sub_post_initial.clone();
for s in sub_post.spl.iter_mut() {
*s += sub_gain_post - sub_gain_raw;
}
let objective_after_curve = predict_bass_management_sum(
&virtual_main,
sub_curve,
xover_type_str,
final_xo_freq,
sample_rate,
main_gain_post,
sub_gain_post,
main_delay_post,
sub_delay_post,
sub_inverted,
);
let objective_after = bass_management_objective(objective_after_curve.as_ref(), final_xo_freq);
if optimization_advisories.is_empty() {
optimization_advisories.push("ok".to_string());
}
let mut bass_management_optimization = super::home_cinema::BassManagementOptimizationReport {
applied: phase_available,
phase_required: true,
phase_available,
configured_crossover_hz: Some(est_xo),
optimized_crossover_hz: Some(final_xo_freq),
crossover_range_hz: xover_config.frequency_range,
crossover_type: xover_type_str.to_string(),
main_delay_ms: main_delay_post,
sub_delay_ms: sub_delay_post,
relative_sub_delay_ms: sub_delay_post - main_delay_post,
sub_polarity_inverted: sub_inverted,
requested_sub_gain_db: requested_sub_gain,
applied_sub_gain_db: sub_gain_post,
gain_limited: sub_gain_limited,
estimated_bass_bus_peak_gain_db: None,
objective_before,
objective_after,
group_results: Vec::new(),
sub_output_results: Vec::new(),
advisories: optimization_advisories,
};
let bass_routing_graph = super::home_cinema::bass_management_routing_graph(
config,
Some(&bass_management_optimization),
);
if let Some(graph) = bass_routing_graph.as_ref()
&& let Some(route_predicted_sub) = predict_bass_output_curve_from_routes(
&aligned_pre_eq_curves[&sub_role],
graph,
&sub_role,
sample_rate,
)
{
sub_post = route_predicted_sub;
}
let deprecated_peak_gain_extra = if bass_management_optimization.sub_output_results.is_empty() {
sub_gain_post
} else {
0.0
};
bass_management_optimization.estimated_bass_bus_peak_gain_db =
super::home_cinema::estimated_bass_bus_peak_gain_db_for_config(
config,
bass_routing_graph.as_ref(),
deprecated_peak_gain_extra,
sample_rate,
);
let mut post_eq_filters = HashMap::new();
let main_post_max_freq = config.optimizer.max_freq;
for role in ["L", "R"] {
let mut opt_config = config.optimizer.clone();
opt_config.min_freq = final_xo_freq + 20.0;
let post_curve = if role == "L" { &l_post } else { &r_post };
let (filters, _) = eq::optimize_channel_eq(
post_curve,
&opt_config,
config.target_curve.as_ref(),
sample_rate,
)
.map_err(|e| AutoeqError::OptimizationFailed {
message: e.to_string(),
})?;
let pre = compute_flat_loss(post_curve, opt_config.min_freq, main_post_max_freq);
let eq_resp =
response::compute_peq_complex_response(&filters, &post_curve.freq, sample_rate);
let post_curve_after = response::apply_complex_response(post_curve, &eq_resp);
let post = compute_flat_loss(&post_curve_after, opt_config.min_freq, main_post_max_freq);
if post < pre {
post_eq_filters.insert(role.to_string(), filters);
} else {
log::warn!(
" {} Post-EQ discarded: score regressed from {:.4} to {:.4}",
role,
pre,
post
);
post_eq_filters.insert(role.to_string(), Vec::new());
}
}
{
let mut opt_config = config.optimizer.clone();
opt_config.max_freq = final_xo_freq - 20.0;
let sub_min_score = config.optimizer.min_freq.max(20.0);
let (filters, _) = eq::optimize_channel_eq(
&sub_post,
&opt_config,
config.target_curve.as_ref(),
sample_rate,
)
.map_err(|e| AutoeqError::OptimizationFailed {
message: e.to_string(),
})?;
let pre = compute_flat_loss(&sub_post, sub_min_score, final_xo_freq);
let eq_resp = response::compute_peq_complex_response(&filters, &sub_post.freq, sample_rate);
let sub_after_eq = response::apply_complex_response(&sub_post, &eq_resp);
let post = compute_flat_loss(&sub_after_eq, sub_min_score, final_xo_freq);
if post < pre {
post_eq_filters.insert(sub_role.clone(), filters);
} else {
log::warn!(
" Sub Post-EQ discarded: score regressed from {:.4} to {:.4}",
pre,
post
);
}
}
let mut channel_chains = HashMap::new();
for role in ["L", "R"] {
let mut plugins = Vec::new();
let align_gain = *gains.get(role).unwrap_or(&0.0);
if align_gain.abs() > 0.01 {
plugins.push(output::create_gain_plugin(align_gain));
}
if let Some(stack) = pre_eq_plugins.get(role) {
plugins.extend(stack.clone());
}
plugins.push(output::create_crossover_plugin(
xover_type_str,
final_xo_freq,
"high",
));
if main_gain_post.abs() > 0.01 {
plugins.push(output::create_gain_plugin(main_gain_post));
}
if main_delay_post.abs() > 0.01 {
plugins.push(output::create_delay_plugin(main_delay_post));
}
let eqs = post_eq_filters.get(role);
if let Some(e) = eqs {
plugins.push(output::create_eq_plugin(e));
}
let intermediate = if role == "L" { &l_post } else { &r_post };
let final_curve_obj = if let Some(e) = eqs {
let resp = response::compute_peq_complex_response(e, &intermediate.freq, sample_rate);
response::apply_complex_response(intermediate, &resp)
} else {
intermediate.clone()
};
let initial_data: super::types::CurveData = (&aligned_curves[role]).into();
let final_data: super::types::CurveData = (&final_curve_obj).into();
let eq_resp = super::output::compute_eq_response(&initial_data, &final_data);
let chain = ChannelDspChain {
channel: role.to_string(),
plugins,
drivers: None,
initial_curve: Some(initial_data),
final_curve: Some(final_data),
eq_response: Some(eq_resp),
pre_ir: None,
post_ir: None,
target_curve: None,
};
channel_chains.insert(role.to_string(), chain);
}
let mut sub_plugins = Vec::new();
let sub_align_gain = *gains.get(&sub_role).unwrap_or(&0.0);
if sub_align_gain.abs() > 0.01 {
sub_plugins.push(output::create_gain_plugin(sub_align_gain));
}
if let Some(stack) = pre_eq_plugins.get(&sub_role) {
sub_plugins.extend(stack.clone());
}
sub_plugins.push(output::create_crossover_plugin(
xover_type_str,
final_xo_freq,
"low",
));
if sub_inverted || sub_gain_post.abs() > 0.01 {
sub_plugins.push(output::create_gain_plugin_with_invert(
sub_gain_post,
sub_inverted,
));
}
if sub_delay_post.abs() > 0.01 {
sub_plugins.push(output::create_delay_plugin(sub_delay_post));
}
let sub_eqs = post_eq_filters.get(&sub_role);
if let Some(e) = sub_eqs {
sub_plugins.push(output::create_eq_plugin(e));
}
let final_sub_curve = if let Some(e) = sub_eqs {
let resp = response::compute_peq_complex_response(e, &sub_post.freq, sample_rate);
response::apply_complex_response(&sub_post, &resp)
} else {
sub_post.clone()
};
let driver_chains = sub_preprocess.drivers.as_ref().map(|drivers| {
drivers
.iter()
.enumerate()
.map(|(i, d)| {
let mut driver_plugins = Vec::new();
if d.inverted || d.gain.abs() > 0.01 {
if d.inverted {
driver_plugins.push(output::create_gain_plugin_with_invert(d.gain, true));
} else {
driver_plugins.push(output::create_gain_plugin(d.gain));
}
}
if d.delay.abs() > 0.001 {
driver_plugins.push(output::create_delay_plugin(d.delay));
}
let driver_curve = d
.initial_curve
.as_ref()
.map(output::extend_curve_to_full_range)
.map(|c| (&c).into());
DriverDspChain {
name: d.name.clone(),
index: i,
plugins: driver_plugins,
initial_curve: driver_curve,
}
})
.collect()
});
let sub_initial_data: super::types::CurveData = (&aligned_curves[&sub_role]).into();
let sub_final_data: super::types::CurveData = (&final_sub_curve).into();
let sub_eq_resp = super::output::compute_eq_response(&sub_initial_data, &sub_final_data);
let sub_chain = ChannelDspChain {
channel: sub_role.clone(),
plugins: sub_plugins,
drivers: driver_chains,
initial_curve: Some(sub_initial_data),
final_curve: Some(sub_final_data),
eq_response: Some(sub_eq_resp),
pre_ir: None,
post_ir: None,
target_curve: None,
};
channel_chains.insert(sub_role.clone(), sub_chain);
let max_freq = config.optimizer.max_freq;
let sub_min_score = config.optimizer.min_freq.max(20.0);
let mut channel_results = HashMap::new();
let mut pre_scores = Vec::new();
let mut post_scores = Vec::new();
for role in ["L", "R"] {
let intermediate = if role == "L" { &l_post } else { &r_post };
let pre_score = compute_flat_loss(intermediate, final_xo_freq, max_freq);
let final_curve_obj = if let Some(e) = post_eq_filters.get(role) {
let resp = response::compute_peq_complex_response(e, &intermediate.freq, sample_rate);
response::apply_complex_response(intermediate, &resp)
} else {
intermediate.clone()
};
let post_score = compute_flat_loss(&final_curve_obj, final_xo_freq, max_freq);
pre_scores.push(pre_score);
post_scores.push(post_score);
channel_results.insert(
role.to_string(),
ChannelOptimizationResult {
name: role.to_string(),
pre_score,
post_score,
initial_curve: aligned_curves[role].clone(),
final_curve: final_curve_obj,
biquads: post_eq_filters.get(role).cloned().unwrap_or_default(),
fir_coeffs: None,
},
);
}
{
let pre_score = compute_flat_loss(&sub_post, sub_min_score, final_xo_freq);
let post_score = compute_flat_loss(&final_sub_curve, sub_min_score, final_xo_freq);
pre_scores.push(pre_score);
post_scores.push(post_score);
channel_results.insert(
sub_role.clone(),
ChannelOptimizationResult {
name: sub_role.clone(),
pre_score,
post_score,
initial_curve: aligned_curves[&sub_role].clone(),
final_curve: final_sub_curve.clone(),
biquads: post_eq_filters.get(&sub_role).cloned().unwrap_or_default(),
fir_coeffs: None,
},
);
}
let avg_pre = pre_scores.iter().sum::<f64>() / pre_scores.len() as f64;
let avg_post = post_scores.iter().sum::<f64>() / post_scores.len() as f64;
info!(
"Average pre-score: {:.4}, post-score: {:.4}",
avg_pre, avg_post
);
let epa_cfg = config.optimizer.epa_config.clone().unwrap_or_default();
let epa_per_channel = crate::roomeq::output::compute_epa_per_channel(&channel_chains, &epa_cfg);
Ok(RoomOptimizationResult {
channels: channel_chains,
channel_results,
combined_pre_score: avg_pre,
combined_post_score: avg_post,
metadata: OptimizationMetadata {
pre_score: avg_pre,
post_score: avg_post,
algorithm: config.optimizer.algorithm.clone(),
loss_type: Some(config.optimizer.loss_type.clone()),
iterations: config.optimizer.max_iter,
timestamp: chrono::Utc::now().to_rfc3339(),
inter_channel_deviation: None,
epa_per_channel,
group_delay: None,
perceptual_metrics: None,
home_cinema_layout: Some(super::home_cinema::analyze_layout(config)),
multi_seat_coverage: Some(super::home_cinema::multi_seat_coverage(config)),
multi_seat_correction: None,
bass_management:
super::home_cinema::bass_management_report_with_optimization_and_sample_rate(
config,
Some(sub_gain_post),
sub_gain_limited,
Some(bass_management_optimization),
sample_rate,
),
timing_diagnostics: None,
},
})
}
pub fn optimize_home_cinema(
config: &RoomConfig,
sys: &SystemConfig,
sample_rate: f64,
_output_dir: &Path,
) -> Result<RoomOptimizationResult> {
let sub_role = super::home_cinema::bass_output_role(config, sys);
let has_sub = sys.speakers.contains_key(&sub_role);
let main_roles: Vec<String> = sys
.speakers
.keys()
.filter(|r| *r != &sub_role && !super::home_cinema::role_for_channel(r).is_sub_or_lfe())
.cloned()
.collect();
info!(
"Running Home Cinema Optimization Workflow ({} mains{})",
main_roles.len(),
if has_sub { " + bass-managed sub" } else { "" }
);
let mut curves = HashMap::new();
for role in &main_roles {
let meas_key = sys
.speakers
.get(role)
.ok_or(AutoeqError::InvalidConfiguration {
message: format!("Missing speaker mapping for '{}'", role),
})?;
let cfg = config
.speakers
.get(meas_key)
.ok_or(AutoeqError::InvalidConfiguration {
message: format!("Missing speaker config for key '{}'", meas_key),
})?;
let source = match cfg {
SpeakerConfig::Single(s) => s,
_ => {
return Err(AutoeqError::InvalidConfiguration {
message: format!(
"'{}' must be a Single speaker config in home cinema workflow",
role
),
});
}
};
let curve = load_source(source).map_err(|e| AutoeqError::InvalidMeasurement {
message: e.to_string(),
})?;
curves.insert(role.clone(), curve);
}
let sub_preprocess = if has_sub {
let sub_sys = sys
.subwoofers
.as_ref()
.ok_or(AutoeqError::InvalidConfiguration {
message: format!(
"Missing subwoofers configuration for home cinema with '{}'",
sub_role
),
})?;
let lfe_meas_key =
sys.speakers
.get(&sub_role)
.ok_or(AutoeqError::InvalidConfiguration {
message: format!("Missing speaker mapping for '{}'", sub_role),
})?;
let lfe_speaker_config =
config
.speakers
.get(lfe_meas_key)
.ok_or(AutoeqError::InvalidConfiguration {
message: format!("Missing speaker config for key '{}'", lfe_meas_key),
})?;
let sp = preprocess_sub(
lfe_speaker_config,
&sub_sys.config,
&config.optimizer,
sample_rate,
)?;
curves.insert(sub_role.clone(), sp.combined_curve.clone());
Some(sp)
} else {
None
};
if has_sub {
optimize_home_cinema_with_sub(
config,
sys,
&main_roles,
&curves,
sub_preprocess.unwrap(),
sample_rate,
_output_dir,
)
} else {
optimize_home_cinema_no_sub(config, sys, &main_roles, &curves, sample_rate, _output_dir)
}
}
fn optimize_home_cinema_no_sub(
config: &RoomConfig,
sys: &SystemConfig,
main_roles: &[String],
curves: &HashMap<String, Curve>,
sample_rate: f64,
output_dir: &Path,
) -> Result<RoomOptimizationResult> {
let mut ranges = HashMap::new();
for role in main_roles {
ranges.insert(role.clone(), (100.0, 2000.0));
}
let gains = align_channels_to_lowest(curves, &ranges);
let mut channel_chains = HashMap::new();
let mut channel_results = HashMap::new();
let mut pre_scores = Vec::new();
let mut post_scores = Vec::new();
let mut multi_seat_rejections: HashMap<String, Vec<String>> = HashMap::new();
for role in main_roles {
let gain = *gains.get(role).unwrap_or(&0.0);
let source = resolve_single_source(role, config, sys)?;
info!(" Optimizing '{}' with alignment gain {:.2} dB", role, gain);
let (chain, ch_result, pre_score, post_score, _fir, multiseat_rejection) =
run_channel_via_generic_path(role, source, config, gain, sample_rate, output_dir)?;
if let Some(advisories) = multiseat_rejection {
multi_seat_rejections.insert(role.clone(), advisories);
}
info!(
" '{}' pre_score={:.4} post_score={:.4}",
role, pre_score, post_score
);
channel_chains.insert(role.clone(), chain);
channel_results.insert(role.clone(), ch_result);
pre_scores.push(pre_score);
post_scores.push(post_score);
}
let avg_pre = pre_scores.iter().sum::<f64>() / pre_scores.len() as f64;
let avg_post = post_scores.iter().sum::<f64>() / post_scores.len() as f64;
info!(
"Average pre-score: {:.4}, post-score: {:.4}",
avg_pre, avg_post
);
let epa_cfg = config.optimizer.epa_config.clone().unwrap_or_default();
let epa_per_channel = crate::roomeq::output::compute_epa_per_channel(&channel_chains, &epa_cfg);
let multi_seat_correction = Some(super::home_cinema::multi_seat_correction_report(
config,
&channel_results,
Some(&multi_seat_rejections),
));
Ok(RoomOptimizationResult {
channels: channel_chains,
channel_results,
combined_pre_score: avg_pre,
combined_post_score: avg_post,
metadata: OptimizationMetadata {
pre_score: avg_pre,
post_score: avg_post,
algorithm: config.optimizer.algorithm.clone(),
loss_type: Some(config.optimizer.loss_type.clone()),
iterations: config.optimizer.max_iter,
timestamp: chrono::Utc::now().to_rfc3339(),
inter_channel_deviation: None,
epa_per_channel,
group_delay: None,
perceptual_metrics: None,
home_cinema_layout: Some(super::home_cinema::analyze_layout(config)),
multi_seat_coverage: Some(super::home_cinema::multi_seat_coverage(config)),
multi_seat_correction,
bass_management: None,
timing_diagnostics: None,
},
})
}
fn optimize_home_cinema_with_sub(
config: &RoomConfig,
sys: &SystemConfig,
main_roles: &[String],
curves: &HashMap<String, Curve>,
sub_preprocess: SubPreprocessResult,
sample_rate: f64,
output_dir: &Path,
) -> Result<RoomOptimizationResult> {
let sub_role = super::home_cinema::bass_output_role(config, sys);
let sub_sys = sys.subwoofers.as_ref().unwrap();
let xover_key = sub_sys
.crossover
.as_deref()
.ok_or(AutoeqError::InvalidConfiguration {
message: "Subwoofer config requires 'crossover' reference".to_string(),
})?;
let xover_config = config
.crossovers
.as_ref()
.and_then(|m| m.get(xover_key))
.ok_or(AutoeqError::InvalidConfiguration {
message: format!("Crossover '{}' not found in crossovers section", xover_key),
})?;
let xover_type_str = &xover_config.crossover_type;
let bass_management = super::home_cinema::effective_bass_management(config);
let (min_xo, max_xo, est_xo) = if let Some(f) = xover_config.frequency {
(f, f, f)
} else if let Some((min, max)) = xover_config.frequency_range {
(min, max, (min * max).sqrt())
} else {
return Err(AutoeqError::InvalidConfiguration {
message: "Subwoofer crossover requires 'frequency' or 'frequency_range'".to_string(),
});
};
let mut ranges = HashMap::new();
for role in main_roles {
ranges.insert(role.clone(), (max_xo, 2000.0));
}
let sub_min_align = config.optimizer.min_freq.max(20.0);
ranges.insert(sub_role.clone(), (sub_min_align, max_xo));
let gains = align_channels_to_lowest(curves, &ranges);
let mut aligned_curves = HashMap::new();
for (role, curve) in curves {
let mut c = curve.clone();
let g = *gains.get(role).unwrap_or(&0.0);
for s in c.spl.iter_mut() {
*s += g;
}
aligned_curves.insert(role.clone(), c);
}
let mut pre_eq_plugins: HashMap<String, Vec<super::types::PluginConfigWrapper>> =
HashMap::new();
let mut linearized_curves: HashMap<String, Curve> = HashMap::new();
let mut multi_seat_rejections: HashMap<String, Vec<String>> = HashMap::new();
for role in main_roles {
let source = resolve_single_source(role, config, sys)?;
let mut per_config = config.clone();
per_config.optimizer.min_freq = min_xo;
info!(
" Pre-EQ via generic path for '{}' (min_freq={:.1} Hz)",
role, min_xo
);
let (chain, ch_result, _pre, _post, _fir, multiseat_rejection) =
run_channel_via_generic_path(role, source, &per_config, 0.0, sample_rate, output_dir)?;
if let Some(advisories) = multiseat_rejection {
multi_seat_rejections.insert(role.clone(), advisories);
}
pre_eq_plugins.insert(role.clone(), mark_plugins_stage(chain.plugins, "pre_route"));
linearized_curves.insert(role.clone(), ch_result.final_curve);
}
{
let sub_source = crate::MeasurementSource::InMemory(sub_preprocess.combined_curve.clone());
let mut sub_config = config.clone();
sub_config.optimizer.max_freq = max_xo;
info!(
" Pre-EQ via generic path for '{}' (max_freq={:.1} Hz)",
sub_role, max_xo
);
let (chain, ch_result, _pre, _post, _fir, _multiseat_rejection) =
run_channel_via_generic_path(
&sub_role,
&sub_source,
&sub_config,
0.0,
sample_rate,
output_dir,
)?;
pre_eq_plugins.insert(
sub_role.clone(),
mark_plugins_stage(chain.plugins, "pre_route"),
);
linearized_curves.insert(sub_role.clone(), ch_result.final_curve);
}
let mut aligned_pre_eq_curves: HashMap<String, Curve> = HashMap::new();
for role in main_roles {
let mut c = linearized_curves[role].clone();
let g = *gains.get(role).unwrap_or(&0.0);
for s in c.spl.iter_mut() {
*s += g;
}
aligned_pre_eq_curves.insert(role.clone(), c);
}
{
let mut c = linearized_curves[&sub_role].clone();
let g = *gains.get(&sub_role).unwrap_or(&0.0);
for s in c.spl.iter_mut() {
*s += g;
}
aligned_pre_eq_curves.insert(sub_role.clone(), c);
}
let main_refs: Vec<&Curve> = main_roles
.iter()
.map(|r| &aligned_pre_eq_curves[r])
.collect();
let sub_curve = &aligned_pre_eq_curves[&sub_role];
let mut measured_phase_check_refs: Vec<&Curve> =
main_roles.iter().map(|r| &aligned_curves[r]).collect();
measured_phase_check_refs.push(&aligned_curves[&sub_role]);
let mut phase_check_refs = main_refs.clone();
phase_check_refs.push(sub_curve);
let measured_phase_available = all_curves_have_usable_phase(&measured_phase_check_refs);
let shared_grid_available = all_curves_share_frequency_grid(&measured_phase_check_refs)
&& all_curves_share_frequency_grid(&phase_check_refs);
let phase_available = measured_phase_available && shared_grid_available;
let mut optimization_advisories = Vec::new();
if !measured_phase_available {
optimization_advisories.push("missing_phase_crossover_alignment_skipped".to_string());
} else if !shared_grid_available {
optimization_advisories
.push("frequency_grid_mismatch_crossover_alignment_skipped".to_string());
}
let virtual_main = if phase_available {
complex_sum_mains(&main_refs)
} else {
average_mains_magnitude(&main_refs)
};
let final_xover_type = select_bass_management_crossover_type(
xover_type_str,
&virtual_main,
sub_curve,
est_xo,
sample_rate,
);
let xover_type_str = final_xover_type.as_str();
let crossover_type_enum: crate::loss::CrossoverType = xover_type_str
.parse()
.map_err(|e: String| AutoeqError::InvalidConfiguration { message: e })?;
let (fixed_freqs, range_opt) = if xover_config.frequency.is_some() {
(Some(vec![est_xo]), None)
} else {
(None, Some((min_xo, max_xo)))
};
let mut xo_optimizer_config = config.optimizer.clone();
xo_optimizer_config.min_db = 0.0;
xo_optimizer_config.max_db = 0.0;
let objective_before_curve = predict_bass_management_sum(
&virtual_main,
sub_curve,
xover_type_str,
est_xo,
sample_rate,
0.0,
0.0,
0.0,
0.0,
false,
);
let objective_before = bass_management_objective(objective_before_curve.as_ref(), est_xo);
let (main_gain_post, main_delay_raw, sub_gain_raw, sub_delay_raw, sub_inverted, final_xo_freq) =
if phase_available {
let (xo_gains, xo_delays, xo_freqs, _, inversions) = crossover::optimize_crossover(
vec![virtual_main.clone(), sub_curve.clone()],
crossover_type_enum,
sample_rate,
&xo_optimizer_config,
fixed_freqs,
range_opt,
)
.map_err(|e| AutoeqError::OptimizationFailed {
message: e.to_string(),
})?;
(
xo_gains[0],
xo_delays[0],
xo_gains[1],
xo_delays[1],
inversions[1],
xo_freqs[0],
)
} else {
(0.0, 0.0, 0.0, 0.0, false, est_xo)
};
let (main_delay_post, sub_delay_post) =
normalize_crossover_delays(main_delay_raw, sub_delay_raw);
let sub_gain_post = sub_gain_raw;
info!(
" Crossover Optimized: Freq={:.1} Hz, Main Gain={:.2}, Sub Gain={:.2}, Main Delay={:.2}, Sub Delay={:.2}",
final_xo_freq, main_gain_post, sub_gain_post, main_delay_post, sub_delay_post
);
let mut group_results_by_id = if bass_management
.as_ref()
.map(|bm| bm.config.optimize_groups)
.unwrap_or(true)
{
optimize_home_cinema_group_crossovers(
config,
main_roles,
&aligned_curves,
&aligned_pre_eq_curves,
&sub_role,
xover_config,
sample_rate,
bass_management.as_ref(),
)?
} else {
super::home_cinema::bass_management_groups(config, None)
.into_iter()
.map(|group| (group.group_id.clone(), group))
.collect()
};
let _hp_biquads = create_crossover_filters(xover_type_str, final_xo_freq, sample_rate, false);
let lp_biquads = create_crossover_filters(xover_type_str, final_xo_freq, sample_rate, true);
let apply_chain =
|curve: &Curve, filters: &[Biquad], gain: f64, delay: f64, invert: bool| -> Curve {
let resp = response::compute_peq_complex_response(filters, &curve.freq, sample_rate);
let mut c = response::apply_complex_response(curve, &resp);
for s in c.spl.iter_mut() {
*s += gain;
}
apply_delay_and_polarity_to_curve(&c, delay, invert)
};
let mut main_post_curves = HashMap::new();
for role in main_roles {
let group_id =
super::home_cinema::group_id_for_role(super::home_cinema::role_for_channel(role));
let group = group_results_by_id.get(group_id);
let role_xover_type = group
.map(|g| g.crossover_type.as_str())
.unwrap_or(xover_type_str);
let role_xover_freq = group
.and_then(|g| g.selected_crossover_hz)
.unwrap_or(final_xo_freq);
let role_main_delay = group.map(|g| g.main_delay_ms).unwrap_or(main_delay_post);
let role_hp_biquads =
create_crossover_filters(role_xover_type, role_xover_freq, sample_rate, false);
let post = apply_chain(
&aligned_pre_eq_curves[role],
&role_hp_biquads,
main_gain_post,
role_main_delay,
false,
);
main_post_curves.insert(role.clone(), post);
}
let preliminary_sub_output_results = bass_management_sub_output_results(
&sub_role,
sub_preprocess.drivers.as_deref(),
sub_gain_post,
&sub_sys.config,
);
let sub_output_base_curves: HashMap<String, Curve> = sub_preprocess
.drivers
.as_ref()
.map(|drivers| {
let combined_initial = aligned_curves.get(&sub_role);
let combined_final = aligned_pre_eq_curves.get(&sub_role);
let sub_alignment_gain = *gains.get(&sub_role).unwrap_or(&0.0);
drivers
.iter()
.filter_map(|driver| {
driver.initial_curve.as_ref().map(|curve| {
let mut aligned_driver = curve.clone();
if sub_alignment_gain.abs() > f64::EPSILON {
for spl in aligned_driver.spl.iter_mut() {
*spl += sub_alignment_gain;
}
}
let corrected = match (combined_initial, combined_final) {
(Some(initial_curve), Some(final_curve)) => {
apply_curve_delta_to_reference_curve(
&aligned_driver,
initial_curve,
final_curve,
)
}
_ => aligned_driver,
};
(driver.name.clone(), corrected)
})
})
.collect()
})
.unwrap_or_default();
let preliminary_bass_management_optimization = joint_bass_management_report_from_parts(
&group_results_by_id.values().cloned().collect::<Vec<_>>(),
&preliminary_sub_output_results,
);
let preliminary_bass_routing_graph = super::home_cinema::bass_management_routing_graph(
config,
Some(&preliminary_bass_management_optimization),
);
let sub_post_initial = if let Some(graph) = preliminary_bass_routing_graph.as_ref()
&& let Some(route_predicted_sub) = predict_bass_bus_curve_from_routes(
&aligned_pre_eq_curves[&sub_role],
graph,
&sub_output_base_curves,
&aligned_pre_eq_curves[&sub_role],
sample_rate,
) {
route_predicted_sub
} else {
apply_chain(
&aligned_pre_eq_curves[&sub_role],
&lp_biquads,
sub_gain_post,
sub_delay_post,
sub_inverted,
)
};
let ref_main_post = &main_post_curves[&main_roles[0]];
let main_freqs_f32: Vec<f32> = ref_main_post.freq.iter().map(|&f| f as f32).collect();
let main_spl_f32: Vec<f32> = ref_main_post.spl.iter().map(|&s| s as f32).collect();
let sub_freqs_f32: Vec<f32> = sub_post_initial.freq.iter().map(|&f| f as f32).collect();
let sub_spl_f32: Vec<f32> = sub_post_initial.spl.iter().map(|&s| s as f32).collect();
let main_mean = math_audio_dsp::analysis::compute_average_response(
&main_freqs_f32,
&main_spl_f32,
Some((
group_results_by_id
.get(super::home_cinema::group_id_for_role(
super::home_cinema::role_for_channel(&main_roles[0]),
))
.and_then(|g| g.selected_crossover_hz)
.unwrap_or(final_xo_freq) as f32,
2000.0,
)),
) as f64;
let sub_mean = math_audio_dsp::analysis::compute_average_response(
&sub_freqs_f32,
&sub_spl_f32,
Some((
20.0,
preliminary_bass_routing_graph
.as_ref()
.map(|graph| bass_route_upper_frequency_hz(Some(graph), final_xo_freq))
.unwrap_or(final_xo_freq) as f32,
)),
) as f64;
let sub_correction = main_mean - sub_mean;
info!(
" Re-aligning Subwoofer: Main={:.2} dB, Sub={:.2} dB, Correction={:+.2} dB",
main_mean, sub_mean, sub_correction
);
let lfe_physical_gain = bass_management
.as_ref()
.filter(|bm| bm.config.apply_lfe_gain_to_chain)
.map(|bm| bm.config.lfe_playback_gain_db)
.unwrap_or(0.0);
let requested_sub_gain = sub_gain_post + sub_correction + lfe_physical_gain;
let (sub_gain_post, mut sub_gain_limited) =
super::home_cinema::limited_sub_gain(requested_sub_gain, bass_management.as_ref());
if sub_gain_limited {
log::warn!(
" Bass management limited sub gain from {:+.2} dB to {:+.2} dB for headroom",
requested_sub_gain,
sub_gain_post
);
optimization_advisories.push("sub_gain_limited_for_headroom".to_string());
}
let mut sub_post = sub_post_initial.clone();
for s in sub_post.spl.iter_mut() {
*s += sub_gain_post - sub_gain_raw;
}
let objective_after_curve = predict_bass_management_sum(
&virtual_main,
sub_curve,
xover_type_str,
final_xo_freq,
sample_rate,
main_gain_post,
sub_gain_post,
main_delay_post,
sub_delay_post,
sub_inverted,
);
let objective_after = bass_management_objective(objective_after_curve.as_ref(), final_xo_freq);
if optimization_advisories.is_empty() {
optimization_advisories.push("ok".to_string());
}
let mut sub_output_results = bass_management_sub_output_results(
&sub_role,
sub_preprocess.drivers.as_deref(),
sub_gain_post,
&sub_sys.config,
);
if limit_bass_management_sub_output_gains(&mut sub_output_results, bass_management.as_ref()) {
sub_gain_limited = true;
optimization_advisories.retain(|existing| existing != "ok");
if !optimization_advisories.contains(&"sub_gain_limited_for_headroom".to_string()) {
optimization_advisories.push("sub_gain_limited_for_headroom".to_string());
}
}
let sub_output_advisories = if phase_available
&& bass_management
.as_ref()
.map(|bm| bm.config.optimize_groups)
.unwrap_or(true)
{
optimize_bass_management_joint_solution(
config,
main_roles,
&aligned_pre_eq_curves,
&mut group_results_by_id,
&mut sub_output_results,
sub_preprocess.drivers.as_deref(),
&sub_role,
sample_rate,
)
} else {
Vec::new()
};
for advisory in sub_output_advisories {
optimization_advisories.retain(|existing| existing != "ok");
if !optimization_advisories.contains(&advisory) {
optimization_advisories.push(advisory);
}
}
if limit_bass_management_sub_output_gains(&mut sub_output_results, bass_management.as_ref()) {
sub_gain_limited = true;
optimization_advisories.retain(|existing| existing != "ok");
if !optimization_advisories.contains(&"sub_gain_limited_for_headroom".to_string()) {
optimization_advisories.push("sub_gain_limited_for_headroom".to_string());
}
}
let route_applied_sub_gain_db = sub_output_results
.iter()
.map(|output| output.gain_db)
.fold(f64::NEG_INFINITY, f64::max);
let route_applied_sub_gain_db = if route_applied_sub_gain_db.is_finite() {
route_applied_sub_gain_db
} else {
sub_gain_post
};
let primary_group = group_results_by_id
.get("lcr")
.or_else(|| group_results_by_id.values().next());
let metadata_main_delay_ms = primary_group
.map(|group| group.main_delay_ms)
.unwrap_or(main_delay_post);
let metadata_sub_delay_ms = primary_group
.map(|group| group.bass_route_delay_ms)
.unwrap_or(sub_delay_post);
let metadata_sub_inverted = primary_group
.map(|group| group.polarity_inverted)
.unwrap_or(sub_inverted);
let metadata_crossover_type = primary_group
.map(|group| group.crossover_type.clone())
.unwrap_or_else(|| xover_type_str.to_string());
let metadata_crossover_hz = primary_group
.and_then(|group| group.selected_crossover_hz)
.unwrap_or(final_xo_freq);
let aggregate_objective_before = group_results_by_id
.values()
.filter_map(|group| group.objective_before)
.reduce(|a, b| a + b)
.or(objective_before);
let aggregate_objective_after = group_results_by_id
.values()
.filter_map(|group| group.objective_after)
.reduce(|a, b| a + b)
.or(objective_after);
let mut bass_management_optimization = super::home_cinema::BassManagementOptimizationReport {
applied: phase_available,
phase_required: true,
phase_available,
configured_crossover_hz: Some(est_xo),
optimized_crossover_hz: Some(metadata_crossover_hz),
crossover_range_hz: xover_config.frequency_range,
crossover_type: metadata_crossover_type,
main_delay_ms: metadata_main_delay_ms,
sub_delay_ms: metadata_sub_delay_ms,
relative_sub_delay_ms: metadata_sub_delay_ms - metadata_main_delay_ms,
sub_polarity_inverted: metadata_sub_inverted,
requested_sub_gain_db: requested_sub_gain,
applied_sub_gain_db: route_applied_sub_gain_db,
gain_limited: sub_gain_limited,
estimated_bass_bus_peak_gain_db: None,
objective_before: aggregate_objective_before,
objective_after: aggregate_objective_after,
group_results: group_results_by_id.values().cloned().collect(),
sub_output_results,
advisories: optimization_advisories,
};
let bass_routing_graph = super::home_cinema::bass_management_routing_graph(
config,
Some(&bass_management_optimization),
);
let deprecated_peak_gain_extra = if bass_management_optimization.sub_output_results.is_empty() {
sub_gain_post
} else {
0.0
};
bass_management_optimization.estimated_bass_bus_peak_gain_db =
super::home_cinema::estimated_bass_bus_peak_gain_db_for_config(
config,
bass_routing_graph.as_ref(),
deprecated_peak_gain_extra,
sample_rate,
);
let bass_route_upper_hz =
bass_route_upper_frequency_hz(bass_routing_graph.as_ref(), final_xo_freq);
let (representative_bass_route_type, representative_bass_route_hz) =
representative_bass_route_signature(
bass_routing_graph.as_ref(),
xover_type_str,
final_xo_freq,
);
if let Some(graph) = bass_routing_graph.as_ref()
&& let Some(route_predicted_sub) = predict_bass_bus_curve_from_routes(
&aligned_pre_eq_curves[&sub_role],
graph,
&sub_output_base_curves,
&aligned_pre_eq_curves[&sub_role],
sample_rate,
)
{
sub_post = route_predicted_sub;
}
let mut post_eq_filters = HashMap::new();
let main_post_max_freq = config.optimizer.max_freq;
for role in main_roles {
let mut opt_config = config.optimizer.clone();
let group_id =
super::home_cinema::group_id_for_role(super::home_cinema::role_for_channel(role));
let role_xover_freq = group_results_by_id
.get(group_id)
.and_then(|g| g.selected_crossover_hz)
.unwrap_or(final_xo_freq);
opt_config.min_freq = role_xover_freq + 20.0;
let post_curve = &main_post_curves[role];
let (filters, _) = eq::optimize_channel_eq(
post_curve,
&opt_config,
config.target_curve.as_ref(),
sample_rate,
)
.map_err(|e| AutoeqError::OptimizationFailed {
message: e.to_string(),
})?;
let pre = compute_flat_loss(post_curve, opt_config.min_freq, main_post_max_freq);
let eq_resp =
response::compute_peq_complex_response(&filters, &post_curve.freq, sample_rate);
let post_curve_after = response::apply_complex_response(post_curve, &eq_resp);
let post = compute_flat_loss(&post_curve_after, opt_config.min_freq, main_post_max_freq);
if post < pre {
post_eq_filters.insert(role.clone(), filters);
} else {
log::warn!(
" {} Post-EQ discarded: score regressed from {:.4} to {:.4}",
role,
pre,
post
);
post_eq_filters.insert(role.clone(), Vec::new());
}
}
{
let mut opt_config = config.optimizer.clone();
opt_config.max_freq = bass_route_upper_hz - 20.0;
let sub_min_score = config.optimizer.min_freq.max(20.0);
let (filters, _) = eq::optimize_channel_eq(
&sub_post,
&opt_config,
config.target_curve.as_ref(),
sample_rate,
)
.map_err(|e| AutoeqError::OptimizationFailed {
message: e.to_string(),
})?;
let pre = compute_flat_loss(&sub_post, sub_min_score, bass_route_upper_hz);
let eq_resp = response::compute_peq_complex_response(&filters, &sub_post.freq, sample_rate);
let sub_after_eq = response::apply_complex_response(&sub_post, &eq_resp);
let post = compute_flat_loss(&sub_after_eq, sub_min_score, bass_route_upper_hz);
if post < pre {
post_eq_filters.insert(sub_role.clone(), filters);
} else {
log::warn!(
" Sub Post-EQ discarded: score regressed from {:.4} to {:.4}",
pre,
post
);
}
}
let mut channel_chains = HashMap::new();
for role in main_roles {
let mut plugins = Vec::new();
let align_gain = *gains.get(role).unwrap_or(&0.0);
if align_gain.abs() > 0.01 {
plugins.push(mark_plugin_stage(
output::create_gain_plugin(align_gain),
"pre_route",
));
}
if let Some(stack) = pre_eq_plugins.get(role) {
plugins.extend(stack.clone());
}
let group_id =
super::home_cinema::group_id_for_role(super::home_cinema::role_for_channel(role));
let group = group_results_by_id.get(group_id);
let role_xover_type = group
.map(|g| g.crossover_type.as_str())
.unwrap_or(xover_type_str);
let role_xover_freq = group
.and_then(|g| g.selected_crossover_hz)
.unwrap_or(final_xo_freq);
let role_main_delay = group.map(|g| g.main_delay_ms).unwrap_or(main_delay_post);
plugins.push(mark_route_owned_plugin(output::create_crossover_plugin(
role_xover_type,
role_xover_freq,
"high",
)));
if main_gain_post.abs() > 0.01 {
plugins.push(mark_route_owned_plugin(output::create_gain_plugin(
main_gain_post,
)));
}
if role_main_delay.abs() > 0.01 {
plugins.push(mark_route_owned_plugin(output::create_delay_plugin(
role_main_delay,
)));
}
let eqs = post_eq_filters.get(role);
if let Some(e) = eqs
&& !e.is_empty()
{
plugins.push(mark_plugin_stage(output::create_eq_plugin(e), "post_route"));
}
let intermediate = &main_post_curves[role];
let final_curve_obj = if let Some(e) = eqs {
if !e.is_empty() {
let resp =
response::compute_peq_complex_response(e, &intermediate.freq, sample_rate);
response::apply_complex_response(intermediate, &resp)
} else {
intermediate.clone()
}
} else {
intermediate.clone()
};
let initial_data: super::types::CurveData = (&aligned_curves[role]).into();
let final_data: super::types::CurveData = (&final_curve_obj).into();
let eq_resp = super::output::compute_eq_response(&initial_data, &final_data);
let chain = ChannelDspChain {
channel: role.clone(),
plugins,
drivers: None,
initial_curve: Some(initial_data),
final_curve: Some(final_data),
eq_response: Some(eq_resp),
pre_ir: None,
post_ir: None,
target_curve: None,
};
channel_chains.insert(role.clone(), chain);
}
let mut sub_plugins = Vec::new();
let sub_align_gain = *gains.get(&sub_role).unwrap_or(&0.0);
if sub_align_gain.abs() > 0.01 {
sub_plugins.push(mark_plugin_stage(
output::create_gain_plugin(sub_align_gain),
"pre_route",
));
}
if let Some(stack) = pre_eq_plugins.get(&sub_role) {
sub_plugins.extend(stack.clone());
}
sub_plugins.push(mark_route_owned_plugin(output::create_crossover_plugin(
&representative_bass_route_type,
representative_bass_route_hz,
"low",
)));
if metadata_sub_inverted || route_applied_sub_gain_db.abs() > 0.01 {
sub_plugins.push(mark_route_owned_plugin(
output::create_gain_plugin_with_invert(
route_applied_sub_gain_db,
metadata_sub_inverted,
),
));
}
if metadata_sub_delay_ms.abs() > 0.01 {
sub_plugins.push(mark_route_owned_plugin(output::create_delay_plugin(
metadata_sub_delay_ms,
)));
}
let sub_eqs = post_eq_filters.get(&sub_role);
if let Some(e) = sub_eqs
&& !e.is_empty()
{
sub_plugins.push(mark_plugin_stage(output::create_eq_plugin(e), "post_route"));
}
let final_sub_curve = if let Some(e) = sub_eqs {
if !e.is_empty() {
let resp = response::compute_peq_complex_response(e, &sub_post.freq, sample_rate);
response::apply_complex_response(&sub_post, &resp)
} else {
sub_post.clone()
}
} else {
sub_post.clone()
};
let sub_output_by_role: HashMap<String, super::home_cinema::BassManagementSubOutputReport> =
bass_management_optimization
.sub_output_results
.iter()
.cloned()
.map(|output| (output.output_role.clone(), output))
.collect();
let driver_chains = sub_preprocess.drivers.as_ref().map(|drivers| {
drivers
.iter()
.enumerate()
.map(|(i, d)| {
let mut driver_plugins = Vec::new();
let output_settings = sub_output_by_role.get(&d.name);
let gain_db = output_settings
.map(|output| output.gain_db - route_applied_sub_gain_db)
.unwrap_or(d.gain);
let delay_ms = output_settings
.map(|output| output.delay_ms)
.unwrap_or(d.delay);
let inverted = output_settings
.map(|output| output.polarity_inverted)
.unwrap_or(d.inverted);
if inverted || gain_db.abs() > 0.01 {
if inverted {
driver_plugins.push(mark_plugin_stage(
output::create_gain_plugin_with_invert(gain_db, true),
"post_route",
));
} else {
driver_plugins.push(mark_plugin_stage(
output::create_gain_plugin(gain_db),
"post_route",
));
}
}
if delay_ms.abs() > 0.001 {
driver_plugins.push(mark_plugin_stage(
output::create_delay_plugin(delay_ms),
"post_route",
));
}
let driver_curve = d
.initial_curve
.as_ref()
.map(output::extend_curve_to_full_range)
.map(|c| (&c).into());
DriverDspChain {
name: d.name.clone(),
index: i,
plugins: driver_plugins,
initial_curve: driver_curve,
}
})
.collect()
});
let sub_initial_data: super::types::CurveData = (&aligned_curves[&sub_role]).into();
let sub_final_data: super::types::CurveData = (&final_sub_curve).into();
let sub_eq_resp = super::output::compute_eq_response(&sub_initial_data, &sub_final_data);
let sub_chain = ChannelDspChain {
channel: sub_role.clone(),
plugins: sub_plugins,
drivers: driver_chains,
initial_curve: Some(sub_initial_data),
final_curve: Some(sub_final_data),
eq_response: Some(sub_eq_resp),
pre_ir: None,
post_ir: None,
target_curve: None,
};
channel_chains.insert(sub_role.clone(), sub_chain);
let max_freq = config.optimizer.max_freq;
let sub_min_score = config.optimizer.min_freq.max(20.0);
let mut channel_results = HashMap::new();
let mut pre_scores = Vec::new();
let mut post_scores = Vec::new();
for role in main_roles {
let intermediate = &main_post_curves[role];
let group_id =
super::home_cinema::group_id_for_role(super::home_cinema::role_for_channel(role));
let role_xover_freq = group_results_by_id
.get(group_id)
.and_then(|g| g.selected_crossover_hz)
.unwrap_or(final_xo_freq);
let pre_score = compute_flat_loss(intermediate, role_xover_freq, max_freq);
let final_curve_obj = if let Some(e) = post_eq_filters.get(role) {
if !e.is_empty() {
let resp =
response::compute_peq_complex_response(e, &intermediate.freq, sample_rate);
response::apply_complex_response(intermediate, &resp)
} else {
intermediate.clone()
}
} else {
intermediate.clone()
};
let post_score = compute_flat_loss(&final_curve_obj, role_xover_freq, max_freq);
pre_scores.push(pre_score);
post_scores.push(post_score);
channel_results.insert(
role.clone(),
ChannelOptimizationResult {
name: role.clone(),
pre_score,
post_score,
initial_curve: aligned_curves[role].clone(),
final_curve: final_curve_obj,
biquads: post_eq_filters.get(role).cloned().unwrap_or_default(),
fir_coeffs: None,
},
);
}
{
let pre_score = compute_flat_loss(&sub_post, sub_min_score, bass_route_upper_hz);
let post_score = compute_flat_loss(&final_sub_curve, sub_min_score, bass_route_upper_hz);
pre_scores.push(pre_score);
post_scores.push(post_score);
channel_results.insert(
sub_role.clone(),
ChannelOptimizationResult {
name: sub_role.clone(),
pre_score,
post_score,
initial_curve: aligned_curves[&sub_role].clone(),
final_curve: final_sub_curve.clone(),
biquads: post_eq_filters.get(&sub_role).cloned().unwrap_or_default(),
fir_coeffs: None,
},
);
}
let avg_pre = pre_scores.iter().sum::<f64>() / pre_scores.len() as f64;
let avg_post = post_scores.iter().sum::<f64>() / post_scores.len() as f64;
info!(
"Average pre-score: {:.4}, post-score: {:.4}",
avg_pre, avg_post
);
let epa_cfg = config.optimizer.epa_config.clone().unwrap_or_default();
let epa_per_channel = crate::roomeq::output::compute_epa_per_channel(&channel_chains, &epa_cfg);
let multi_seat_correction = Some(super::home_cinema::multi_seat_correction_report(
config,
&channel_results,
Some(&multi_seat_rejections),
));
Ok(RoomOptimizationResult {
channels: channel_chains,
channel_results,
combined_pre_score: avg_pre,
combined_post_score: avg_post,
metadata: OptimizationMetadata {
pre_score: avg_pre,
post_score: avg_post,
algorithm: config.optimizer.algorithm.clone(),
loss_type: Some(config.optimizer.loss_type.clone()),
iterations: config.optimizer.max_iter,
timestamp: chrono::Utc::now().to_rfc3339(),
inter_channel_deviation: None,
epa_per_channel,
group_delay: None,
perceptual_metrics: None,
home_cinema_layout: Some(super::home_cinema::analyze_layout(config)),
multi_seat_coverage: Some(super::home_cinema::multi_seat_coverage(config)),
multi_seat_correction,
bass_management:
super::home_cinema::bass_management_report_with_optimization_and_sample_rate(
config,
Some(route_applied_sub_gain_db),
sub_gain_limited,
Some(bass_management_optimization),
sample_rate,
),
timing_diagnostics: None,
},
})
}
fn create_crossover_filters(
type_str: &str,
freq: f64,
sample_rate: f64,
is_lowpass: bool,
) -> Vec<Biquad> {
use math_audio_iir_fir::*;
let type_lower = type_str.to_lowercase();
let peq = match type_lower.as_str() {
"lr24" | "lr4" => {
if is_lowpass {
peq_linkwitzriley_lowpass(4, freq, sample_rate)
} else {
peq_linkwitzriley_highpass(4, freq, sample_rate)
}
}
"lr48" | "lr8" => {
if is_lowpass {
peq_linkwitzriley_lowpass(8, freq, sample_rate)
} else {
peq_linkwitzriley_highpass(8, freq, sample_rate)
}
}
"bw12" | "butterworth12" => {
if is_lowpass {
peq_butterworth_lowpass(2, freq, sample_rate)
} else {
peq_butterworth_highpass(2, freq, sample_rate)
}
}
"bw24" | "butterworth24" => {
if is_lowpass {
peq_butterworth_lowpass(4, freq, sample_rate)
} else {
peq_butterworth_highpass(4, freq, sample_rate)
}
}
_ => {
log::warn!("Unknown crossover type '{}', defaulting to LR24", type_str);
if is_lowpass {
peq_linkwitzriley_lowpass(4, freq, sample_rate)
} else {
peq_linkwitzriley_highpass(4, freq, sample_rate)
}
}
};
peq.into_iter().map(|(_, b)| b).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MeasurementSource;
use crate::roomeq::optimize::optimize_room;
use crate::roomeq::types::{
BassManagementConfig, CrossoverConfig, MultiSeatConfig, MultiSeatStrategy, OptimizerConfig,
RoomConfig, SpeakerConfig, SubwooferSystemConfig, SystemConfig, SystemModel,
};
use ndarray::array;
use std::collections::HashMap;
fn phase_curve(phase_deg: f64) -> Curve {
Curve {
freq: array![40.0, 80.0, 160.0],
spl: array![0.0, 0.0, 0.0],
phase: Some(array![phase_deg, phase_deg, phase_deg]),
..Default::default()
}
}
fn bass_management_workflow_curve(
base_level: f64,
acoustic_delay_ms: f64,
with_phase: bool,
) -> Curve {
let n = 96;
let freq: Vec<f64> = (0..n)
.map(|i| 20.0 * (1000.0f64).powf(i as f64 / (n - 1) as f64))
.collect();
let spl: Vec<f64> = freq
.iter()
.map(|&f| {
let bass_shelf = if f < 80.0 {
-3.0 * (80.0 / f).log2().min(2.0)
} else {
0.0
};
base_level + bass_shelf
})
.collect();
let phase = with_phase.then(|| {
ndarray::Array1::from_vec(
freq.iter()
.map(|&f| -360.0 * f * acoustic_delay_ms / 1000.0)
.collect(),
)
});
Curve {
freq: ndarray::Array1::from_vec(freq),
spl: ndarray::Array1::from_vec(spl),
phase,
..Default::default()
}
}
fn bass_management_workflow_config(with_phase: bool, max_sub_boost_db: f64) -> RoomConfig {
let mut speakers = HashMap::new();
speakers.insert(
"left".to_string(),
SpeakerConfig::Single(MeasurementSource::InMemory(bass_management_workflow_curve(
76.0, 0.0, with_phase,
))),
);
speakers.insert(
"right".to_string(),
SpeakerConfig::Single(MeasurementSource::InMemory(bass_management_workflow_curve(
76.5, 0.1, with_phase,
))),
);
speakers.insert(
"sub".to_string(),
SpeakerConfig::Single(MeasurementSource::InMemory(bass_management_workflow_curve(
62.0, 3.0, with_phase,
))),
);
let mut system_speakers = HashMap::new();
system_speakers.insert("L".to_string(), "left".to_string());
system_speakers.insert("R".to_string(), "right".to_string());
system_speakers.insert("LFE".to_string(), "sub".to_string());
let mut crossovers = HashMap::new();
crossovers.insert(
"bass".to_string(),
CrossoverConfig {
crossover_type: "LR24".to_string(),
frequency: Some(80.0),
frequencies: None,
frequency_range: None,
},
);
RoomConfig {
version: "test".to_string(),
system: Some(SystemConfig {
model: SystemModel::HomeCinema,
speakers: system_speakers,
subwoofers: Some(SubwooferSystemConfig {
config: super::SubwooferStrategy::Single,
crossover: Some("bass".to_string()),
mapping: HashMap::new(),
}),
bass_management: Some(BassManagementConfig {
enabled: true,
redirect_bass: true,
max_sub_boost_db,
..BassManagementConfig::default()
}),
}),
speakers,
crossovers: Some(crossovers),
target_curve: None,
optimizer: OptimizerConfig {
num_filters: 1,
max_iter: 4,
population: 4,
seed: Some(7),
refine: false,
allow_delay: Some(false),
decomposed_correction: None,
min_freq: 20.0,
max_freq: 20_000.0,
..OptimizerConfig::default()
},
recording_config: None,
cea2034_cache: None,
}
}
fn home_cinema_multiseat_guardrail_config() -> RoomConfig {
let primary = bass_management_workflow_curve(76.0, 0.0, true);
let mut null_seat = bass_management_workflow_curve(76.0, 0.0, true);
for (freq, spl) in null_seat.freq.iter().zip(null_seat.spl.iter_mut()) {
if *freq >= 70.0 && *freq <= 140.0 {
*spl -= 24.0;
}
}
let mut speakers = HashMap::new();
speakers.insert(
"left".to_string(),
SpeakerConfig::Single(MeasurementSource::InMemoryMultiple(vec![
primary.clone(),
null_seat.clone(),
])),
);
speakers.insert(
"right".to_string(),
SpeakerConfig::Single(MeasurementSource::InMemoryMultiple(vec![
primary, null_seat,
])),
);
let mut system_speakers = HashMap::new();
system_speakers.insert("L".to_string(), "left".to_string());
system_speakers.insert("R".to_string(), "right".to_string());
RoomConfig {
version: "test".to_string(),
system: Some(SystemConfig {
model: SystemModel::HomeCinema,
speakers: system_speakers,
subwoofers: None,
bass_management: None,
}),
speakers,
crossovers: None,
target_curve: None,
optimizer: OptimizerConfig {
num_filters: 1,
max_iter: 3,
population: 4,
seed: Some(19),
refine: false,
allow_delay: Some(false),
decomposed_correction: None,
multi_seat: Some(MultiSeatConfig {
enabled: false,
strategy: MultiSeatStrategy::PrimaryWithConstraints,
primary_seat: 0,
max_deviation_db: 6.0,
..Default::default()
}),
min_freq: 40.0,
max_freq: 500.0,
..OptimizerConfig::default()
},
recording_config: None,
cea2034_cache: None,
}
}
fn total_delay_ms(chain: &ChannelDspChain) -> f64 {
chain
.plugins
.iter()
.filter(|p| p.plugin_type == "delay")
.map(|p| p.parameters["delay_ms"].as_f64().unwrap_or(0.0))
.sum()
}
fn has_crossover_plugin(chain: &ChannelDspChain, mode: &str) -> bool {
chain
.plugins
.iter()
.any(|p| p.plugin_type == "crossover" && p.parameters["output"].as_str() == Some(mode))
}
fn crossover_plugin_frequency(chain: &ChannelDspChain, mode: &str) -> Option<f64> {
chain.plugins.iter().find_map(|p| {
(p.plugin_type == "crossover" && p.parameters["output"].as_str() == Some(mode))
.then(|| p.parameters["frequency"].as_f64())
.flatten()
})
}
#[test]
fn home_cinema_bass_management_delays_are_normalized() {
let (main, sub) = normalize_crossover_delays(-1.5, 0.25);
assert_eq!(main, 0.0);
assert!((sub - 1.75).abs() < 1e-9);
let (main, sub) = normalize_crossover_delays(2.0, 5.0);
assert_eq!(main, 0.0);
assert_eq!(sub, 3.0);
}
#[test]
fn home_cinema_bass_management_delay_and_polarity_update_phase() {
let curve = phase_curve(0.0);
let adjusted = apply_delay_and_polarity_to_curve(&curve, 1.0, true);
let phase = adjusted.phase.expect("phase");
assert!((phase[0] - 165.6).abs() < 1e-6);
assert!((phase[1] - 151.2).abs() < 1e-6);
assert!((phase[2] - 122.4).abs() < 1e-6);
}
#[test]
fn home_cinema_bass_management_prediction_requires_phase() {
let main = phase_curve(0.0);
let mut sub = phase_curve(0.0);
sub.phase = None;
let predicted = predict_bass_management_sum(
&main, &sub, "LR24", 80.0, 48_000.0, 0.0, 0.0, 0.0, 0.0, false,
);
assert!(predicted.is_none());
}
#[test]
fn home_cinema_bass_management_alignment_requires_matching_grids() {
let main = phase_curve(0.0);
let mut shifted = phase_curve(0.0);
shifted.freq = array![41.0, 82.0, 164.0];
assert!(all_curves_have_usable_phase(&[&main, &shifted]));
assert!(!all_curves_share_frequency_grid(&[&main, &shifted]));
}
#[test]
fn cardioid_preprocess_rejects_mismatched_frequency_grids() {
let front = phase_curve(0.0);
let mut rear = phase_curve(0.0);
rear.freq = array![41.0, 82.0, 164.0];
let cardioid = CardioidConfig {
name: "cardioid".to_string(),
speaker_name: None,
front: MeasurementSource::InMemory(front),
rear: MeasurementSource::InMemory(rear),
separation_meters: 0.5,
};
match preprocess_cardioid(&cardioid) {
Ok(_) => panic!("cardioid preprocessing should reject mismatched frequency grids"),
Err(err) => assert!(
err.to_string().contains("same frequency grid"),
"unexpected error: {err}"
),
}
}
#[test]
fn home_cinema_bass_management_prediction_has_phase_when_valid() {
let main = phase_curve(0.0);
let sub = phase_curve(0.0);
let predicted = predict_bass_management_sum(
&main, &sub, "LR24", 80.0, 48_000.0, 0.0, 0.0, 0.0, 1.0, false,
)
.expect("prediction");
assert_eq!(predicted.freq.len(), main.freq.len());
assert!(predicted.phase.is_some());
assert!(bass_management_objective(Some(&predicted), 80.0).is_some());
}
#[test]
fn home_cinema_bass_management_sub_curve_is_predicted_from_routes() {
let sub = phase_curve(0.0);
let graph = crate::roomeq::home_cinema::BassManagementRoutingGraph {
physical_sub_output: "Sub".to_string(),
input_channels: vec!["L".to_string(), "R".to_string(), "Sub".to_string()],
output_channels: vec!["L".to_string(), "R".to_string(), "Sub".to_string()],
routes: vec![
crate::roomeq::home_cinema::BassManagementRoute {
group_id: Some("lcr".to_string()),
source_channel: "L".to_string(),
source_index: 0,
destination: "Sub".to_string(),
destination_index: 2,
pre_chain_channel: Some("Sub".to_string()),
post_chain_channel: Some("Sub".to_string()),
route_kind: "redirected_bass_lowpass_to_sub".to_string(),
crossover_type: "LR24".to_string(),
high_pass_hz: None,
low_pass_hz: Some(80.0),
gain_db: 0.0,
gain_linear: 1.0,
matrix_gain: 1.0,
delay_ms: 0.0,
polarity_inverted: false,
},
crate::roomeq::home_cinema::BassManagementRoute {
group_id: Some("surround".to_string()),
source_channel: "R".to_string(),
source_index: 1,
destination: "Sub".to_string(),
destination_index: 2,
pre_chain_channel: Some("Sub".to_string()),
post_chain_channel: Some("Sub".to_string()),
route_kind: "redirected_bass_lowpass_to_sub".to_string(),
crossover_type: "BW12".to_string(),
high_pass_hz: None,
low_pass_hz: Some(120.0),
gain_db: -6.0,
gain_linear: 10.0_f64.powf(-6.0 / 20.0),
matrix_gain: 1.0,
delay_ms: 2.0,
polarity_inverted: true,
},
],
matrix: None,
advisories: vec!["ok".to_string()],
};
let predicted = predict_bass_output_curve_from_routes(&sub, &graph, "Sub", 48_000.0)
.expect("route-predicted sub curve");
let single_route_graph = crate::roomeq::home_cinema::BassManagementRoutingGraph {
routes: vec![graph.routes[0].clone()],
..graph
};
let single =
predict_bass_output_curve_from_routes(&sub, &single_route_graph, "Sub", 48_000.0)
.expect("single route curve");
assert_eq!(predicted.freq.len(), sub.freq.len());
assert!(predicted.phase.is_some());
assert!(
predicted
.spl
.iter()
.zip(single.spl.iter())
.any(|(a, b)| (a - b).abs() > 1e-6),
"sub final curve prediction must include every route branch, not just a representative low-pass"
);
}
#[test]
fn home_cinema_bass_bus_curve_is_predicted_across_multiple_sub_outputs() {
let reference = phase_curve(0.0);
let mut sub_a = phase_curve(0.0);
sub_a.spl = array![0.0, 0.0, 0.0];
let mut sub_b = phase_curve(0.0);
sub_b.spl = array![-6.0, -6.0, -6.0];
let graph = crate::roomeq::home_cinema::BassManagementRoutingGraph {
physical_sub_output: "LFE".to_string(),
input_channels: vec![
"L".to_string(),
"R".to_string(),
"subs_1".to_string(),
"subs_2".to_string(),
],
output_channels: vec![
"L".to_string(),
"R".to_string(),
"subs_1".to_string(),
"subs_2".to_string(),
],
routes: vec![
crate::roomeq::home_cinema::BassManagementRoute {
group_id: Some("lcr".to_string()),
source_channel: "L".to_string(),
source_index: 0,
destination: "subs_1".to_string(),
destination_index: 2,
pre_chain_channel: Some("LFE".to_string()),
post_chain_channel: Some("subs_1".to_string()),
route_kind: "redirected_bass_lowpass_to_sub".to_string(),
crossover_type: "LR24".to_string(),
high_pass_hz: None,
low_pass_hz: Some(80.0),
gain_db: 0.0,
gain_linear: 1.0,
matrix_gain: 1.0,
delay_ms: 0.0,
polarity_inverted: false,
},
crate::roomeq::home_cinema::BassManagementRoute {
group_id: Some("surround".to_string()),
source_channel: "R".to_string(),
source_index: 1,
destination: "subs_2".to_string(),
destination_index: 3,
pre_chain_channel: Some("LFE".to_string()),
post_chain_channel: Some("subs_2".to_string()),
route_kind: "redirected_bass_lowpass_to_sub".to_string(),
crossover_type: "BW12".to_string(),
high_pass_hz: None,
low_pass_hz: Some(120.0),
gain_db: 0.0,
gain_linear: 1.0,
matrix_gain: 1.0,
delay_ms: 1.0,
polarity_inverted: false,
},
],
matrix: None,
advisories: vec!["ok".to_string()],
};
let output_base_curves = HashMap::from([
("subs_1".to_string(), sub_a.clone()),
("subs_2".to_string(), sub_b.clone()),
]);
let predicted = predict_bass_bus_curve_from_routes(
&reference,
&graph,
&output_base_curves,
&reference,
48_000.0,
)
.expect("route-predicted bass bus curve");
let single_graph = crate::roomeq::home_cinema::BassManagementRoutingGraph {
routes: vec![graph.routes[0].clone()],
..graph
};
let single = predict_bass_bus_curve_from_routes(
&reference,
&single_graph,
&output_base_curves,
&reference,
48_000.0,
)
.expect("single-output route-predicted bus curve");
assert_eq!(predicted.freq.len(), reference.freq.len());
assert!(predicted.phase.is_some());
assert!(
predicted
.spl
.iter()
.zip(single.spl.iter())
.any(|(a, b)| (a - b).abs() > 1e-6),
"bass bus prediction must include every routed physical sub output"
);
}
#[test]
fn apply_curve_delta_to_reference_curve_preserves_driver_delta_and_phase() {
let reference = Curve {
freq: array![40.0, 80.0],
spl: array![1.0, -2.0],
phase: Some(array![10.0, 20.0]),
..Default::default()
};
let initial = Curve {
freq: array![40.0, 80.0],
spl: array![0.0, 0.0],
phase: Some(array![0.0, 5.0]),
..Default::default()
};
let final_curve = Curve {
freq: array![40.0, 80.0],
spl: array![3.0, 4.0],
phase: Some(array![30.0, 45.0]),
..Default::default()
};
let corrected = apply_curve_delta_to_reference_curve(&reference, &initial, &final_curve);
assert_eq!(corrected.spl, array![4.0, 2.0]);
assert_eq!(corrected.phase.unwrap(), array![40.0, 60.0]);
}
#[test]
fn representative_bass_route_signature_uses_emitted_route_shape() {
let graph = crate::roomeq::home_cinema::BassManagementRoutingGraph {
physical_sub_output: "LFE".to_string(),
input_channels: vec!["L".to_string(), "R".to_string(), "LFE".to_string()],
output_channels: vec!["L".to_string(), "R".to_string(), "LFE".to_string()],
routes: vec![
crate::roomeq::home_cinema::BassManagementRoute {
group_id: Some("lcr".to_string()),
source_channel: "L".to_string(),
source_index: 0,
destination: "LFE".to_string(),
destination_index: 2,
pre_chain_channel: Some("LFE".to_string()),
post_chain_channel: Some("LFE".to_string()),
route_kind: "redirected_bass_lowpass_to_sub".to_string(),
crossover_type: "LR24".to_string(),
high_pass_hz: None,
low_pass_hz: Some(80.0),
gain_db: 0.0,
gain_linear: 1.0,
matrix_gain: 1.0,
delay_ms: 0.0,
polarity_inverted: false,
},
crate::roomeq::home_cinema::BassManagementRoute {
group_id: Some("height".to_string()),
source_channel: "R".to_string(),
source_index: 1,
destination: "LFE".to_string(),
destination_index: 2,
pre_chain_channel: Some("LFE".to_string()),
post_chain_channel: Some("LFE".to_string()),
route_kind: "redirected_bass_lowpass_to_sub".to_string(),
crossover_type: "BW12".to_string(),
high_pass_hz: None,
low_pass_hz: Some(120.0),
gain_db: 0.0,
gain_linear: 1.0,
matrix_gain: 1.0,
delay_ms: 0.0,
polarity_inverted: false,
},
],
matrix: None,
advisories: vec!["ok".to_string()],
};
let (crossover_type, crossover_hz) =
representative_bass_route_signature(Some(&graph), "LR24", 80.0);
assert_eq!(crossover_type, "BW12");
assert_eq!(crossover_hz, 120.0);
}
#[test]
fn home_cinema_bass_management_workflow_reports_exported_dsp() {
let config = bass_management_workflow_config(true, 6.0);
let result = optimize_room(&config, 48_000.0, None, None).expect("room optimization");
let report = result
.metadata
.bass_management
.as_ref()
.expect("bass management report");
let optimization = report.optimization.as_ref().expect("optimization report");
assert!(optimization.applied);
assert!(optimization.phase_available);
assert!(
optimization.advisories.contains(&"ok".to_string())
|| optimization
.advisories
.contains(&"joint_route_de_optimized".to_string())
|| optimization
.advisories
.contains(&"joint_optimizer_no_improvement".to_string())
);
assert!(optimization.objective_before.is_some());
assert!(optimization.objective_after.is_some());
assert!(optimization.main_delay_ms >= -1e-9);
assert!(optimization.sub_delay_ms >= -1e-9);
assert_eq!(
report.applied_sub_gain_db,
Some(optimization.applied_sub_gain_db)
);
assert!(optimization.estimated_bass_bus_peak_gain_db.is_some());
let routing = report.routing_graph.as_ref().expect("routing graph");
assert_eq!(routing.physical_sub_output, "LFE");
assert!(routing.routes.iter().any(|route| {
route.route_kind == "redirected_bass_lowpass_to_sub"
&& route.source_channel == "L"
&& route.destination == "LFE"
}));
assert!(routing.routes.iter().any(|route| {
route.route_kind == "lfe_lowpass_to_sub"
&& route.source_channel == "LFE"
&& (route.gain_db - (10.0 + optimization.applied_sub_gain_db)).abs() < 1e-9
}));
let routing_matrix = routing.matrix.as_ref().expect("routing matrix");
assert_eq!(routing_matrix.output_channel_map, vec![2]);
assert_eq!(routing_matrix.route_count, 3);
for role in ["L", "R"] {
let chain = result.channels.get(role).expect("main chain");
assert!(has_crossover_plugin(chain, "high"));
let group = optimization
.group_results
.iter()
.find(|group| group.group_id == "lcr")
.expect("lcr group result");
assert!((total_delay_ms(chain) - group.main_delay_ms).abs() < 1e-6);
let chain_phase = chain
.final_curve
.as_ref()
.and_then(|c| c.phase.as_ref())
.expect("chain final phase");
let result_phase = result.channel_results[role]
.final_curve
.phase
.as_ref()
.expect("result final phase");
assert_eq!(chain_phase.len(), result_phase.len());
}
let sub_chain = result.channels.get("LFE").expect("sub chain");
let dsp_output = result.to_dsp_chain_output();
let matrix_plugin = dsp_output
.global_plugins
.iter()
.find(|plugin| plugin.plugin_type == "matrix")
.expect("bass-management global matrix plugin");
assert_eq!(
matrix_plugin.parameters["label"].as_str(),
Some("home_cinema_bass_management")
);
assert_eq!(
matrix_plugin.parameters["input_channel_map"]
.as_array()
.expect("input map")
.len(),
routing_matrix.input_channel_map.len()
);
assert!(has_crossover_plugin(sub_chain, "low"));
assert!((total_delay_ms(sub_chain) - optimization.sub_delay_ms).abs() < 1e-6);
assert!(
sub_chain
.final_curve
.as_ref()
.and_then(|c| c.phase.as_ref())
.is_some()
);
}
#[test]
fn home_cinema_all_channel_multiseat_guardrail_reruns_and_reports_rejection() {
let config = home_cinema_multiseat_guardrail_config();
let result = optimize_room(&config, 48_000.0, None, None).expect("room optimization");
let report = result
.metadata
.multi_seat_correction
.as_ref()
.expect("multi-seat correction report");
assert!(report.enabled);
assert!(!report.applied);
assert!(
report
.advisories
.contains(&"all_channel_corrections_rejected_by_guardrails".to_string()),
"expected guardrail rejection advisory, got {:?}",
report.advisories
);
for role in ["L", "R"] {
let channel = report
.channels
.iter()
.find(|channel| channel.channel == role)
.expect("channel report");
assert_eq!(channel.status, "rejected_guardrails");
assert!(
channel
.advisories
.iter()
.any(|advisory| advisory == "non_primary_seat_constraint_failed"
|| advisory == "weighted_target_fit_collapsed"),
"expected rejection reason for {role}, got {:?}",
channel.advisories
);
assert!(channel.seats.is_empty());
}
}
#[test]
fn home_cinema_bass_management_workflow_skips_alignment_without_phase() {
let config = bass_management_workflow_config(false, 6.0);
let result = optimize_room(&config, 48_000.0, None, None).expect("room optimization");
let optimization = result
.metadata
.bass_management
.as_ref()
.and_then(|r| r.optimization.as_ref())
.expect("optimization report");
assert!(!optimization.applied);
assert!(!optimization.phase_available);
assert_eq!(optimization.main_delay_ms, 0.0);
assert_eq!(optimization.sub_delay_ms, 0.0);
assert!(!optimization.sub_polarity_inverted);
assert!(optimization.objective_before.is_none());
assert!(optimization.objective_after.is_none());
assert!(
optimization
.advisories
.contains(&"missing_phase_crossover_alignment_skipped".to_string())
);
}
#[test]
fn home_cinema_bass_management_workflow_limits_sub_boost_for_headroom() {
let config = bass_management_workflow_config(true, 0.0);
let result = optimize_room(&config, 48_000.0, None, None).expect("room optimization");
let report = result
.metadata
.bass_management
.as_ref()
.expect("bass management report");
let optimization = report.optimization.as_ref().expect("optimization report");
assert!(optimization.applied_sub_gain_db <= 1e-9);
assert!(
optimization
.sub_output_results
.iter()
.all(|output| output.gain_db <= 1e-9),
"physical sub outputs must respect the configured max_sub_boost cap"
);
assert_eq!(
report.applied_sub_gain_db,
Some(optimization.applied_sub_gain_db)
);
assert!(report.gain_limited == optimization.gain_limited);
if optimization.gain_limited {
assert!(
optimization
.advisories
.contains(&"sub_gain_limited_for_headroom".to_string())
);
}
}
#[test]
fn home_cinema_bass_management_workflow_selects_auto_crossover_type() {
let mut config = bass_management_workflow_config(true, 6.0);
config
.crossovers
.as_mut()
.and_then(|crossovers| crossovers.get_mut("bass"))
.expect("bass crossover")
.crossover_type = "auto".to_string();
let result = optimize_room(&config, 48_000.0, None, None).expect("room optimization");
let routing = result
.metadata
.bass_management
.as_ref()
.and_then(|report| report.routing_graph.as_ref())
.expect("routing graph");
let optimization = result
.metadata
.bass_management
.as_ref()
.and_then(|report| report.optimization.as_ref())
.expect("optimization report");
assert_ne!(optimization.crossover_type, "auto");
assert!(["LR24", "LR48", "BW12", "BW24"].contains(&optimization.crossover_type.as_str()));
assert!(
routing
.routes
.iter()
.all(|route| route.crossover_type == optimization.crossover_type)
);
}
#[test]
fn home_cinema_bass_management_workflow_applies_configured_group_crossovers_when_optimization_disabled()
{
let mut config = bass_management_workflow_config(true, 6.0);
let mut speakers = HashMap::new();
for role in ["L", "R", "SL", "SR", "TFL", "TFR"] {
speakers.insert(
role.to_string(),
SpeakerConfig::Single(MeasurementSource::InMemory(bass_management_workflow_curve(
76.0, 0.0, true,
))),
);
}
speakers.insert(
"sub".to_string(),
SpeakerConfig::Single(MeasurementSource::InMemory(bass_management_workflow_curve(
62.0, 3.0, true,
))),
);
config.speakers = speakers;
let system = config.system.as_mut().expect("system");
system.speakers = HashMap::from([
("L".to_string(), "L".to_string()),
("R".to_string(), "R".to_string()),
("SL".to_string(), "SL".to_string()),
("SR".to_string(), "SR".to_string()),
("TFL".to_string(), "TFL".to_string()),
("TFR".to_string(), "TFR".to_string()),
("LFE".to_string(), "sub".to_string()),
]);
system.bass_management = Some(BassManagementConfig {
enabled: true,
redirect_bass: true,
optimize_groups: false,
group_crossovers: HashMap::from([
("surround".to_string(), "surround".to_string()),
("height".to_string(), "height".to_string()),
]),
..BassManagementConfig::default()
});
config.crossovers.as_mut().expect("crossovers").extend([
(
"surround".to_string(),
CrossoverConfig {
crossover_type: "BW12".to_string(),
frequency: Some(100.0),
frequencies: None,
frequency_range: None,
},
),
(
"height".to_string(),
CrossoverConfig {
crossover_type: "LR48".to_string(),
frequency: Some(140.0),
frequencies: None,
frequency_range: None,
},
),
]);
let result = optimize_room(&config, 48_000.0, None, None).expect("room optimization");
assert_eq!(
crossover_plugin_frequency(result.channels.get("L").expect("L chain"), "high"),
Some(80.0)
);
assert_eq!(
crossover_plugin_frequency(result.channels.get("SL").expect("SL chain"), "high"),
Some(100.0)
);
assert_eq!(
crossover_plugin_frequency(result.channels.get("TFL").expect("TFL chain"), "high"),
Some(140.0)
);
let report = result
.metadata
.bass_management
.as_ref()
.expect("bass management report");
let routing = report.routing_graph.as_ref().expect("routing graph");
assert!(routing.routes.iter().any(|route| {
route.source_channel == "SL"
&& route.route_kind == "main_highpass_to_self"
&& route.crossover_type == "BW12"
&& route.high_pass_hz == Some(100.0)
}));
assert!(routing.routes.iter().any(|route| {
route.source_channel == "TFL"
&& route.route_kind == "main_highpass_to_self"
&& route.crossover_type == "LR48"
&& route.high_pass_hz == Some(140.0)
}));
let optimization = report.optimization.as_ref().expect("optimization");
assert!(optimization.group_results.iter().any(|group| {
group.group_id == "surround"
&& group.crossover_type == "BW12"
&& group.selected_crossover_hz == Some(100.0)
&& group
.advisories
.contains(&"group_optimization_disabled".to_string())
}));
}
#[test]
fn home_cinema_bass_management_routes_carry_shared_sub_gain() {
let outputs =
bass_management_sub_output_results("LFE", None, 4.5, &SubwooferStrategy::Single);
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0].output_role, "LFE");
assert!((outputs[0].gain_db - 4.5).abs() < 1e-9);
assert!((outputs[0].headroom_contribution_db - 4.5).abs() < 1e-9);
let driver_outputs = bass_management_sub_output_results(
"LFE",
Some(&[
SubDriverInfo {
name: "Sub A".to_string(),
gain: -2.0,
delay: 1.0,
inverted: false,
initial_curve: None,
},
SubDriverInfo {
name: "Sub B".to_string(),
gain: -4.0,
delay: 2.0,
inverted: true,
initial_curve: None,
},
]),
3.0,
&SubwooferStrategy::Mso,
);
assert_eq!(driver_outputs.len(), 2);
assert!((driver_outputs[0].gain_db - 1.0).abs() < 1e-9);
assert!((driver_outputs[1].gain_db + 1.0).abs() < 1e-9);
}
#[test]
fn home_cinema_bass_management_sub_output_refinement_requires_phase() {
let config = bass_management_workflow_config(true, 6.0);
let mut group_results = BTreeMap::new();
group_results.insert(
"lcr".to_string(),
crate::roomeq::home_cinema::BassManagementGroupReport {
group_id: "lcr".to_string(),
roles: vec!["L".to_string(), "R".to_string()],
crossover_type: "LR24".to_string(),
selected_crossover_hz: Some(80.0),
configured_crossover_hz: Some(80.0),
main_delay_ms: 0.0,
bass_route_delay_ms: 0.0,
polarity_inverted: false,
trim_db: 0.0,
objective_before: None,
objective_after: None,
advisories: vec!["ok".to_string()],
},
);
let mut outputs = vec![crate::roomeq::home_cinema::BassManagementSubOutputReport {
output_role: "Sub A".to_string(),
gain_db: 0.0,
delay_ms: 0.0,
polarity_inverted: false,
strategy_source: "mso".to_string(),
headroom_contribution_db: 0.0,
}];
let mut driver_curve = phase_curve(0.0);
driver_curve.phase = None;
let drivers = vec![SubDriverInfo {
name: "Sub A".to_string(),
gain: 0.0,
delay: 0.0,
inverted: false,
initial_curve: Some(driver_curve),
}];
let mut aligned = HashMap::new();
aligned.insert("L".to_string(), phase_curve(0.0));
aligned.insert("R".to_string(), phase_curve(0.0));
let advisories = refine_bass_management_sub_outputs(
&config,
&["L".to_string(), "R".to_string()],
&aligned,
&mut group_results,
&mut outputs,
Some(&drivers),
48_000.0,
);
assert_eq!(
advisories,
vec!["joint_sub_output_skipped_missing_phase".to_string()]
);
}
#[test]
fn home_cinema_bass_management_sub_output_refinement_updates_physical_outputs() {
let mut config = bass_management_workflow_config(true, 6.0);
config.optimizer.max_iter = 320;
config.optimizer.population = 28;
config.optimizer.seed = Some(99);
let mut group_results = BTreeMap::new();
group_results.insert(
"lcr".to_string(),
crate::roomeq::home_cinema::BassManagementGroupReport {
group_id: "lcr".to_string(),
roles: vec!["L".to_string(), "R".to_string()],
crossover_type: "LR24".to_string(),
selected_crossover_hz: Some(80.0),
configured_crossover_hz: Some(80.0),
main_delay_ms: 0.0,
bass_route_delay_ms: 0.0,
polarity_inverted: false,
trim_db: 0.0,
objective_before: None,
objective_after: None,
advisories: vec!["ok".to_string()],
},
);
let mut outputs = vec![
crate::roomeq::home_cinema::BassManagementSubOutputReport {
output_role: "Sub A".to_string(),
gain_db: 12.0,
delay_ms: 0.0,
polarity_inverted: false,
strategy_source: "mso".to_string(),
headroom_contribution_db: 12.0,
},
crate::roomeq::home_cinema::BassManagementSubOutputReport {
output_role: "Sub B".to_string(),
gain_db: 12.0,
delay_ms: 0.0,
polarity_inverted: false,
strategy_source: "mso".to_string(),
headroom_contribution_db: 12.0,
},
];
let mut loud_sub_a = phase_curve(0.0);
loud_sub_a.spl = array![12.0, 12.0, 12.0];
let mut loud_sub_b = phase_curve(0.0);
loud_sub_b.spl = array![12.0, 12.0, 12.0];
let drivers = vec![
SubDriverInfo {
name: "Sub A".to_string(),
gain: 12.0,
delay: 0.0,
inverted: false,
initial_curve: Some(loud_sub_a),
},
SubDriverInfo {
name: "Sub B".to_string(),
gain: 12.0,
delay: 0.0,
inverted: false,
initial_curve: Some(loud_sub_b),
},
];
let mut aligned = HashMap::new();
aligned.insert("L".to_string(), phase_curve(0.0));
aligned.insert("R".to_string(), phase_curve(0.0));
let advisories = refine_bass_management_sub_outputs(
&config,
&["L".to_string(), "R".to_string()],
&aligned,
&mut group_results,
&mut outputs,
Some(&drivers),
48_000.0,
);
assert!(
advisories.contains(&"joint_sub_output_de_optimized".to_string()),
"expected physical sub output DE to improve, got {advisories:?}"
);
assert!(outputs.iter().all(|output| output.gain_db < 12.0));
assert!(outputs.iter().all(|output| output.delay_ms >= 0.0));
assert!(
group_results["lcr"]
.advisories
.contains(&"joint_sub_output_de_optimized".to_string())
);
}
#[test]
fn bass_management_joint_de_minimizer_improves_seed_solution() {
let lower = [-10.0, -10.0];
let upper = [10.0, 10.0];
let initial = [8.0, -7.0];
let objective = |x: &[f64]| (x[0] - 1.25).powi(2) + (x[1] + 2.5).powi(2);
let (best, score) =
differential_evolution_minimize(&lower, &upper, &initial, &objective, 24, 300, 42);
assert!(score < objective(&initial));
assert!((best[0] - 1.25).abs() < 1.0);
assert!((best[1] + 2.5).abs() < 1.0);
}
}