use std::path::{Path, PathBuf};
use super::import::Imported;
use super::{Setup, convolver, raw};
use crate::config::{self, Config, DspEar, DspMeasurement, DspProfile, DspRole, DspScope};
#[derive(Debug, Clone, PartialEq)]
pub struct Summary {
pub name: String,
pub devices: Vec<String>,
pub bands: usize,
pub layers: usize,
pub rates: Vec<u32>,
pub problem: Option<String>,
pub role: DspRole,
pub measured: bool,
pub members: Vec<String>,
pub playing: Option<String>,
pub preset: bool,
pub edited: bool,
pub used_on: Vec<String>,
pub everywhere: bool,
pub held_by: Vec<String>,
pub scope_locked: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct ChainView {
pub device: String,
pub correction: Option<String>,
pub target: Option<String>,
pub tuning: Vec<TuningView>,
pub preset: Option<String>,
pub edited: bool,
pub left_out: Vec<String>,
pub notes: Option<String>,
pub flat: bool,
pub sentence: String,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct TuningView {
pub name: String,
pub on: bool,
}
pub fn chain_view(device: &str) -> ChainView {
let o = overview_for(Some(device.to_owned()));
let cfg = Config::cached();
let all = &cfg.dsp.profiles;
let chosen = cfg.dsp.profile_for(device);
let target = chosen
.and_then(|c| aims_at(c, all))
.map(|t| target_name(&t));
let includes = chosen.is_some_and(|c| shown_role(c, all) == DspRole::Baked);
let playing: Vec<&str> = if includes {
Vec::new()
} else {
o.chain
.iter()
.filter(|(name, on)| *on && !o.left_out_eqs.contains(name))
.map(|(name, _)| name.as_str())
.collect()
};
let sentence = match (&o.active, playing.is_empty()) {
(None, true) => format!("{device} is flat: the music plays untouched."),
(active, _) => {
let mut parts = Vec::new();
if let Some(c) = active {
parts.push(match &target {
Some(t) => format!("corrected by {c} to {t}"),
None => format!("corrected by {c}"),
});
}
if !playing.is_empty() {
parts.push(format!("tuned with {}", and_list(&playing)));
}
format!("Music to {device}, {}.", parts.join(", then "))
}
};
ChainView {
device: device.to_owned(),
flat: o.active.is_none() && o.chain.is_empty(),
correction: o.active,
target,
tuning: o
.chain
.into_iter()
.map(|(name, on)| TuningView { name, on })
.collect(),
edited: o.preset.as_ref().is_some_and(|(_, e)| *e),
preset: o.preset.map(|(name, _)| name),
left_out: o.left_out_eqs,
notes: o.left_out,
sentence,
}
}
fn and_list(items: &[&str]) -> String {
match items {
[] => String::new(),
[one] => (*one).to_owned(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
}
}
pub fn set_chain(
device: &str,
correction: Option<Option<&str>>,
tuning: Option<&[String]>,
) -> Result<(), String> {
let cfg = Config::cached();
let all = &cfg.dsp.profiles;
let choices = |names: Vec<&str>| {
if names.is_empty() {
"none yet".to_owned()
} else {
names.join(", ")
}
};
if let Some(Some(name)) = correction {
let corrections: Vec<&str> = all
.iter()
.filter(|p| !p.preset && shown_role(p, all).corrects())
.map(|p| p.name.as_str())
.collect();
if all.iter().any(|p| p.name == name && p.preset) {
return Err(format!(
"{name} is a preset, not a correction: set it with `koan dsp preset use {name:?}`"
));
}
if !corrections.contains(&name) {
return Err(format!(
"No correction called {name}. Corrections: {}",
choices(corrections)
));
}
}
if let Some(eqs) = tuning {
let valid: Vec<&str> = all
.iter()
.filter(|p| !p.preset && check_tuning(&p.name, all).is_ok())
.map(|p| p.name.as_str())
.collect();
for (i, eq) in eqs.iter().enumerate() {
if !all.iter().any(|p| &p.name == eq) {
return Err(format!("No EQ called {eq}. EQs: {}", choices(valid)));
}
check_tuning(eq, all)?;
if eqs[..i].contains(eq) {
return Err(format!("{eq} is in the tuning twice"));
}
}
let then = match correction {
Some(c) => c.and_then(|c| all.iter().find(|p| p.name == c)),
None => cfg.dsp.profile_for(device),
};
if let Some(c) = then
&& !eqs.is_empty()
&& shown_role(c, all) == DspRole::Baked
{
return Err(baked_already(&c.name));
}
}
let correction: Option<String> = match correction {
Some(c) => c.map(str::to_owned),
None => cfg.dsp.profile_for(device).map(|p| p.name.clone()),
};
let entries: Vec<(String, bool)> = match tuning {
Some(eqs) => eqs.iter().map(|e| (e.clone(), true)).collect(),
None => tunings_for(device),
};
let preset = preset_for(device).map(|(name, _)| name);
persist(|cfg| {
set_output(
cfg,
device,
correction.as_deref(),
&entries,
preset.as_deref(),
)
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct OutputState {
pub device: String,
pub preset: Option<(String, bool)>,
pub flat: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Overview {
pub enabled: bool,
pub device: Option<String>,
pub active: Option<String>,
pub tuning: Option<String>,
pub tuning_plays: bool,
pub left_out: Option<String>,
pub left_out_eqs: Vec<String>,
pub tunings: Vec<(String, String)>,
pub chain: Vec<(String, bool)>,
pub preset: Option<(String, bool)>,
pub profiles: Vec<Summary>,
pub outputs: Vec<OutputState>,
}
pub fn current_device() -> Option<String> {
let cfg = Config::cached();
if let Some(name) = &cfg.playback.output_device
&& crate::audio::list_output_devices()
.is_ok_and(|devices| devices.iter().any(|d| &d.name == name))
{
return Some(name.clone());
}
crate::audio::default_output_device().ok().map(|d| d.name)
}
pub fn overview() -> Overview {
overview_for(current_device())
}
pub fn overview_for(device: Option<String>) -> Overview {
let cfg = Config::cached();
let base = config::config_dir();
let chain = device
.as_deref()
.and_then(|d| super::output_chain(&cfg.dsp, d));
Overview {
enabled: cfg.dsp.enabled,
active: device
.as_deref()
.and_then(|d| cfg.dsp.profile_for(d))
.map(|p| p.name.clone()),
tuning_plays: chain.as_ref().is_some_and(|c| c.tuning_plays),
left_out_eqs: chain
.as_ref()
.map(|c| c.left_out_eqs.clone())
.unwrap_or_default(),
left_out: chain.and_then(|c| c.left_out),
outputs: outputs(&cfg.dsp),
tunings: cfg.dsp.tunings.iter().fold(Vec::new(), |mut first, t| {
if !first.iter().any(|(d, _): &(String, String)| d == &t.device) {
first.push((t.device.clone(), t.tuning.clone()));
}
first
}),
tuning: device.as_deref().and_then(tuning_for),
chain: device.as_deref().map(tunings_for).unwrap_or_default(),
preset: device.as_deref().and_then(preset_for),
device,
profiles: cfg
.dsp
.profiles
.iter()
.map(|p| {
let (rates, problem) = match Setup::load(p, &cfg.dsp.profiles, &base) {
Ok(setup) => (setup.map(|s| s.rates()).unwrap_or_default(), None),
Err(e) => (Vec::new(), Some(e.to_string())),
};
Summary {
name: p.name.clone(),
devices: p.devices.clone(),
bands: p.filters.len(),
layers: p.layers.len(),
rates,
problem,
role: shown_role(p, &cfg.dsp.profiles),
measured: p.measurement.is_some(),
members: if p.group {
p.layers.iter().map(|l| l.profile.clone()).collect()
} else {
Vec::new()
},
playing: p
.group
.then(|| p.layers.iter().find(|l| l.on).map(|l| l.profile.clone()))
.flatten(),
preset: p.preset,
edited: edited(p),
used_on: used_on(&cfg.dsp, &p.name),
held_by: cfg
.dsp
.profiles
.iter()
.filter(|q| {
(q.preset || q.group) && q.layers.iter().any(|l| l.profile == p.name)
})
.map(|q| q.name.clone())
.collect(),
everywhere: scope(p, &cfg.dsp.profiles) == DspScope::Everywhere,
scope_locked: scope_locked(p, &cfg.dsp.profiles),
}
})
.collect(),
}
}
fn outputs(dsp: &crate::config::DspConfig) -> Vec<OutputState> {
let mut devices: Vec<&str> = Vec::new();
for d in dsp
.profiles
.iter()
.filter(|p| !p.preset)
.flat_map(|p| p.devices.iter())
.chain(dsp.tunings.iter().map(|t| &t.device))
.chain(dsp.presets.iter().map(|p| &p.device))
{
if !devices.contains(&d.as_str()) {
devices.push(d);
}
}
devices
.into_iter()
.map(|d| OutputState {
device: d.to_owned(),
preset: preset_for(d),
flat: dsp.profile_for(d).is_none() && !dsp.tunings.iter().any(|t| t.device == d),
})
.collect()
}
fn used_on(dsp: &crate::config::DspConfig, name: &str) -> Vec<String> {
let mut on: Vec<String> = Vec::new();
let correcting = dsp
.profiles
.iter()
.filter(|p| p.name == name && !p.preset)
.flat_map(|p| p.devices.iter());
let tuning = dsp
.tunings
.iter()
.filter(|t| t.tuning == name)
.map(|t| &t.device);
let preset = dsp
.presets
.iter()
.filter(|p| p.preset == name)
.map(|p| &p.device);
for d in correcting.chain(tuning).chain(preset) {
if !on.contains(d) {
on.push(d.clone());
}
}
on
}
pub fn save(imported: Imported, name: Option<&str>) -> Result<String, String> {
let name = name
.map(str::trim)
.filter(|n| !n.is_empty())
.unwrap_or(&imported.name)
.to_string();
let impulses = if imported.impulses.is_empty() {
None
} else {
Some(write_impulses(&name, &imported)?)
};
let filters = (!imported.filters.is_empty()).then_some(imported.filters);
let source = imported.source;
persist(|cfg| {
let profile = profile_mut(&mut cfg.dsp.profiles, &name);
for s in source {
if !profile.source.contains(&s) {
profile.source.push(s);
}
}
if let Some(filters) = filters {
profile.filters = filters;
profile.preamp_db = None;
profile.original = Some(crate::config::DspOriginal {
filters: profile.filters.clone(),
preamp_db: None,
});
}
if let Some(impulses) = impulses {
profile.impulses = impulses;
}
})
.map_err(|e| e.to_string())?;
Ok(name)
}
fn write_impulses(name: &str, imported: &Imported) -> Result<Vec<PathBuf>, String> {
let rel = Path::new("dsp").join(slug(name));
let dir = config::config_dir().join(&rel);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let mut paths = Vec::new();
for ir in &imported.impulses {
let shared_rate = imported
.impulses
.iter()
.filter(|i| i.rate == ir.rate)
.count()
> 1;
let stem = match (shared_rate, ir.channels) {
(true, Some(n)) => format!("{}-{n}ch", ir.rate),
_ => ir.rate.to_string(),
};
let file = match ir.as_channels() {
Some(channels) => {
let channels: Vec<Vec<f32>> = channels.into_iter().map(<[f32]>::to_vec).collect();
let file = format!("{stem}.wav");
raw::write_wav(&dir.join(&file), ir.rate, &channels).map_err(|e| e.to_string())?;
file
}
None => convolver::write(&dir, &stem, ir)?
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default(),
};
paths.push(rel.join(file));
}
Ok(paths)
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImpulseDetail {
pub file: String,
pub rate: u32,
pub channels: Option<usize>,
pub taps: usize,
pub routes: usize,
pub mixes: bool,
pub delayed: bool,
pub peak_ms: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Detail {
pub name: String,
pub devices: Vec<String>,
pub source: Vec<String>,
pub filters: Vec<crate::config::DspFilter>,
pub impulses: Vec<ImpulseDetail>,
pub preamp_db: f64,
pub preamp_rate: u32,
pub preamp_set: bool,
pub problem: Option<String>,
pub layers: Vec<crate::config::DspLayer>,
pub group: bool,
pub everywhere: bool,
pub scope_set: bool,
pub role: DspRole,
pub role_set: bool,
pub corrects_twice: Option<String>,
pub corrects: Option<String>,
pub corrects_baked: bool,
pub tunings: Vec<String>,
pub layer_roles: Vec<Option<DspRole>>,
pub measured: bool,
pub made_for: Option<String>,
pub tuned_for: Option<String>,
pub preset: bool,
pub edited: bool,
pub can_revert: bool,
pub read_only: bool,
}
pub fn detail(name: &str) -> Option<Detail> {
let cfg = Config::cached();
let profile = cfg.dsp.profiles.iter().find(|p| p.name == name)?;
let base = config::config_dir();
let mut problem = None;
let mut impulses = Vec::new();
for path in &profile.impulses {
match super::load_impulses(path, &base) {
Ok(irs) => impulses.extend(irs.into_iter().map(|ir| {
ImpulseDetail {
file: path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default(),
rate: ir.rate,
channels: ir.channels,
taps: ir.taps(),
routes: ir.routes.len(),
mixes: ir.mixes(),
delayed: ir.in_delays.iter().chain(&ir.out_delays).any(|&d| d > 0),
peak_ms: ir.delay() as f64 * 1000.0 / ir.rate as f64,
}
})),
Err(e) => problem = Some(e.to_string()),
}
}
impulses.sort_by_key(|i| i.rate);
let preamp_rate = impulses
.iter()
.map(|i| i.rate)
.find(|&r| r == 48000)
.or(impulses.first().map(|i| i.rate))
.unwrap_or(48000);
let mut adjusted = profile.clone().sanitize();
match super::chain_noted(profile, &cfg.dsp.profiles, &mut Vec::new()) {
Ok((_, notes)) => {
for n in notes {
if !adjusted.contains(&n) {
adjusted.push(n);
}
}
}
Err(e) => problem = Some(e.to_string()),
}
let setup = Setup::load(profile, &cfg.dsp.profiles, &base)
.ok()
.flatten();
if let Some(cut) = setup.as_ref().and_then(|s| s.headroom_cut(preamp_rate, 2)) {
adjusted.push(format!("preamp −{cut:.1} dB for headroom"));
}
if !adjusted.is_empty() {
let note = format!("Adjusted: {}", adjusted.join("; "));
problem = Some(problem.map_or(note.clone(), |p| format!("{p}. {note}")));
}
let preamp_db = setup.map_or(0.0, |s| s.preamp_db(preamp_rate, 2));
let chain_summary = summary(name).unwrap_or_default();
Some(Detail {
name: profile.name.clone(),
devices: profile.devices.clone(),
source: profile.source.clone(),
filters: profile.filters.clone(),
impulses,
preamp_db,
preamp_rate,
preamp_set: profile.preamp_db.is_some(),
layers: profile.layers.clone(),
group: profile.group,
problem,
everywhere: scope(profile, &cfg.dsp.profiles) == DspScope::Everywhere,
scope_set: profile.scope.is_some(),
role: shown_role(profile, &cfg.dsp.profiles),
role_set: profile.role.is_some(),
corrects_twice: corrects_twice(profile, &cfg.dsp.profiles),
corrects: chain_summary.correction,
corrects_baked: chain_summary.baked,
tunings: chain_summary.tunings,
layer_roles: profile
.layers
.iter()
.map(|l| {
cfg.dsp
.profiles
.iter()
.find(|p| p.name == l.profile)
.map(role)
})
.collect(),
measured: profile.measurement.is_some(),
made_for: profile.target.as_ref().map(|t| t.made_for.clone()),
tuned_for: profile.tuned_for.clone(),
preset: profile.preset,
edited: edited(profile),
can_revert: profile.original.is_some(),
read_only: shown_role(profile, &cfg.dsp.profiles).corrects(),
})
}
pub fn rename(old: &str, new: &str) -> Result<(), String> {
let new = new.trim();
if new.is_empty() {
return Err("It needs a name".into());
}
if new == old {
return Ok(());
}
let cfg = Config::cached();
if cfg.dsp.profiles.iter().any(|p| p.name == new) {
return Err(format!("There is already an EQ called {new}"));
}
if !cfg.dsp.profiles.iter().any(|p| p.name == old) {
return Err(format!("No EQ called {old}"));
}
let (from, to) = (
Path::new("dsp").join(slug(old)),
Path::new("dsp").join(slug(new)),
);
let base = config::config_dir();
let moved = from != to && base.join(&from).exists();
if moved {
if base.join(&to).exists() {
return Err(format!("{} is in the way", to.display()));
}
std::fs::rename(base.join(&from), base.join(&to)).map_err(|e| e.to_string())?;
}
persist(|cfg| {
for l in cfg
.dsp
.profiles
.iter_mut()
.flat_map(|p| p.layers.iter_mut())
{
if l.profile == old {
l.profile = new.to_string();
}
}
for t in &mut cfg.dsp.tunings {
if t.tuning == old {
t.tuning = new.to_string();
}
}
for p in &mut cfg.dsp.presets {
if p.preset == old {
p.preset = new.to_string();
}
}
if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == old) {
p.name = new.to_string();
if moved {
for path in &mut p.impulses {
if let Ok(rest) = path.strip_prefix(&from) {
*path = to.join(rest);
}
}
}
}
})
.map_err(|e| e.to_string())
}
fn persist(mutate: impl FnOnce(&mut crate::config::Config)) -> Result<(), String> {
Config::persist(mutate).map_err(|e| e.to_string())?;
crate::signal::engine_changed().bump();
crate::remote::dsp_sync::changed();
Ok(())
}
pub fn dir(name: &str) -> PathBuf {
config::config_dir().join("dsp").join(slug(name))
}
pub fn set_made_for(name: &str, made_for: Option<&str>) -> Result<(), String> {
use super::targets;
let cfg = Config::cached();
let p = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == name)
.ok_or_else(|| format!("No EQ called {name}"))?;
if p.measurement.is_some() {
return Err(format!(
"{name} is built from a measurement: choose the target it is corrected to instead"
));
}
let made = match made_for {
Some(id) => Some(targets::shipped(id).ok_or_else(|| format!("No target called {id}"))?),
None => None,
};
let chosen = p
.target
.as_ref()
.and_then(|t| t.chosen.clone())
.filter(|c| made.is_some_and(|m| targets::shipped(c).is_none_or(|t| t.ear == m.ear)));
persist(|cfg| {
if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
p.target = made.map(|m| crate::config::DspTarget {
made_for: m.id.to_owned(),
chosen,
});
}
})
}
fn headphone(p: &DspProfile) -> Option<(super::targets::Curve, super::targets::Curve)> {
use super::targets;
let folder = dir(&p.name);
if let Some(m) = &p.measurement {
let level = |c: targets::Curve| {
let k = targets::at(&c, 1000.0);
c.into_iter()
.map(|(hz, db)| (hz, db - k))
.collect::<targets::Curve>()
};
return Some((
level(targets::measurement(&folder)?),
level(targets::choice_curve(&m.target)?),
));
}
let (raw, made_for) = targets::autoeq_measurement(&folder)?;
let moved = p
.target
.as_ref()
.and_then(|t| Some((t.chosen.as_ref()?, &t.made_for)))
.and_then(|(to, from)| {
Some(targets::difference(
&targets::choice_curve(from)?,
&targets::choice_curve(to)?,
))
});
Some((
raw,
match moved {
Some(step) => targets::moved(&made_for, &step),
None => made_for,
},
))
}
#[derive(Debug, Clone, PartialEq)]
pub struct TargetChoices {
pub made_for: Option<&'static super::targets::Target>,
pub chosen: Option<String>,
pub choices: Vec<TargetChoice>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TargetChoice {
pub id: String,
pub name: String,
pub does: String,
pub character: String,
}
pub fn target_choices(name: &str) -> Option<TargetChoices> {
use super::targets;
let cfg = Config::cached();
let p = cfg.dsp.profiles.iter().find(|p| p.name == name)?;
let (made_for, chosen, ear) = match (&p.measurement, &p.target) {
(Some(m), _) => (
None,
Some(m.target.clone()),
match m.ear {
DspEar::In => targets::Ear::In,
DspEar::Over => targets::Ear::Over,
},
),
(None, Some(t)) => {
let made_for = targets::shipped(&t.made_for)?;
(Some(made_for), t.chosen.clone(), made_for.ear)
}
(None, None) => return None,
};
let mut choices: Vec<TargetChoice> = targets::TARGETS
.iter()
.filter(|c| c.ear == ear)
.map(|c| TargetChoice {
id: c.id.into(),
name: c.name.into(),
does: c.does.into(),
character: c.character.into(),
})
.collect();
choices.extend(targets::added().into_iter().map(|a| TargetChoice {
id: a.id,
name: a.name,
does: String::new(),
character: String::new(),
}));
Some(TargetChoices {
made_for,
chosen,
choices,
})
}
pub fn choose_target(name: &str, chosen: Option<&str>) -> Result<(), String> {
let choices =
target_choices(name).ok_or_else(|| format!("{name} has no target to move from"))?;
if let Some(c) = chosen
&& !choices.choices.iter().any(|x| x.id == c)
{
return Err(format!("No target {c} for {name}"));
}
persist(|cfg| {
let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) else {
return;
};
if let Some(m) = p.measurement.as_mut() {
if let Some(c) = chosen {
m.target = c.to_owned();
}
} else if let Some(t) = p.target.as_mut() {
t.chosen = chosen.filter(|c| *c != t.made_for).map(str::to_owned);
}
})
.map_err(|e| e.to_string())
}
pub fn assign(name: Option<&str>, device: &str) -> Result<(), String> {
if let Some(name) = name {
let cfg = Config::cached();
let Some(p) = cfg.dsp.profiles.iter().find(|p| p.name == name) else {
return Err(format!("No EQ called {name}"));
};
if p.preset {
return apply_preset(device, Some(name));
}
}
persist(|cfg| {
for p in &mut cfg.dsp.profiles {
p.devices.retain(|d| d != device);
if Some(p.name.as_str()) == name {
p.devices.push(device.to_string());
}
}
})
.map_err(|e| e.to_string())
}
const BAND_HZ: std::ops::RangeInclusive<f64> = 10.0..=22_000.0;
const BAND_DB: std::ops::RangeInclusive<f64> = -30.0..=30.0;
const BAND_Q: std::ops::RangeInclusive<f64> = 0.1..=20.0;
fn clamp(v: f64, r: &std::ops::RangeInclusive<f64>) -> Result<f64, String> {
if !v.is_finite() {
return Err("Not a number".into());
}
Ok(v.clamp(*r.start(), *r.end()))
}
fn may_edit(name: &str) -> Result<(), String> {
let cfg = Config::cached();
let p = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == name)
.ok_or_else(|| format!("No EQ called {name}"))?;
editable(p, &cfg.dsp.profiles)
}
pub fn set_band(
name: &str,
index: usize,
kind: &str,
freq: f64,
gain_db: f64,
q: f64,
) -> Result<(), String> {
use crate::config::{DspFilter, EqFilterKind};
let kind: EqFilterKind = serde_json::from_value(serde_json::Value::String(kind.into()))
.map_err(|_| format!("No band type called {kind}"))?;
may_edit(name)?;
let (freq, gain_db, q) = (
clamp(freq, &BAND_HZ)?,
clamp(gain_db, &BAND_DB)?,
clamp(q, &BAND_Q)?,
);
let mut found = Err(format!("{name} has no band {}", index + 1));
persist(|cfg| {
if let Some(DspFilter::Band(b)) = cfg
.dsp
.profiles
.iter_mut()
.find(|p| p.name == name)
.and_then(|p| p.filters.get_mut(index))
{
(b.kind, b.freq, b.gain_db, b.q) = (kind, freq, gain_db, q);
found = Ok(());
}
})
.map_err(|e| e.to_string())?;
found
}
pub fn add_band(name: &str) -> Result<usize, String> {
use crate::config::{DspFilter, EqFilter, EqFilterKind};
may_edit(name)?;
let mut index = 0;
persist(|cfg| {
let p = profile_mut(&mut cfg.dsp.profiles, name);
p.filters.push(DspFilter::Band(EqFilter {
kind: EqFilterKind::Peaking,
freq: 1000.0,
gain_db: 0.0,
q: 1.0,
channels: vec![],
}));
index = p.filters.len() - 1;
})
.map_err(|e| e.to_string())?;
Ok(index)
}
pub fn remove_filter(name: &str, index: usize) -> Result<(), String> {
may_edit(name)?;
let mut found = Err(format!("{name} has no filter {}", index + 1));
persist(|cfg| {
if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name)
&& index < p.filters.len()
{
p.filters.remove(index);
found = Ok(());
}
})
.map_err(|e| e.to_string())?;
found
}
#[derive(Debug, Clone, PartialEq)]
pub struct Response {
pub freqs: Vec<f64>,
pub total: Vec<f64>,
pub bands: Vec<Vec<f64>>,
pub layers: Vec<LayerResponse>,
pub measurement: Option<Vec<f64>>,
pub target: Option<Vec<f64>>,
pub predicted: Option<Vec<f64>>,
pub preamp_db: f64,
pub correction: Option<Vec<f64>>,
pub tuning: Option<Vec<f64>>,
pub original: Option<Vec<f64>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LayerResponse {
pub name: String,
pub on: bool,
pub db: Vec<f64>,
}
pub fn response(name: &str, rate: u32) -> Option<Response> {
let cfg = Config::cached();
let all = &cfg.dsp.profiles;
response_of(all.iter().find(|p| p.name == name)?, all, rate)
}
pub fn output_response(device: &str, rate: u32) -> Option<Response> {
let chain = super::output_chain(&Config::cached().dsp, device)?;
response_of(&chain.profile, &chain.all, rate)
}
fn response_of(profile: &DspProfile, all: &[DspProfile], rate: u32) -> Option<Response> {
use super::targets;
let freqs = targets::grid();
let curve = |filters: &[crate::config::DspFilter]| super::response(filters, &freqs, rate);
let setup = Setup::load(profile, all, &config::config_dir()).ok()?;
let total = setup
.as_ref()
.map_or_else(|| vec![0.0; freqs.len()], |s| s.response(&freqs, rate));
let bands = profile
.filters
.iter()
.filter(|f| matches!(f, crate::config::DspFilter::Band(_)))
.map(|f| curve(std::slice::from_ref(f)))
.collect();
let corrector = corrections_in(profile, all)
.first()
.and_then(|c| all.iter().find(|p| &p.name == c))
.filter(|c| c.name != profile.name || !profile.layers.is_empty());
let correction = corrector.and_then(|c| {
let alone = DspProfile {
layers: Vec::new(),
..c.clone()
};
Setup::load(&alone, all, &config::config_dir())
.ok()
.flatten()
.map(|s| s.response(&freqs, rate))
});
let tuning = correction
.as_ref()
.map(|c| {
total
.iter()
.zip(c)
.map(|(t, c)| t - c)
.collect::<Vec<f64>>()
})
.filter(|t| t.iter().any(|db| db.abs() > 0.05));
let correction = correction.filter(|_| tuning.is_some());
let layers = profile
.layers
.iter()
.filter_map(|l| {
let p = all.iter().find(|p| p.name == l.profile)?;
let filters = super::chain(p, all, &mut Vec::new()).ok()?;
Some(LayerResponse {
name: l
.profile
.rsplit('\u{1}')
.next()
.unwrap_or_default()
.to_owned(),
on: l.on,
db: curve(&filters),
})
})
.collect();
let measured = std::iter::once(profile)
.chain(
profile
.layers
.iter()
.filter(|l| l.on)
.filter_map(|l| all.iter().find(|p| p.name == l.profile)),
)
.find_map(headphone);
let (measurement, target, predicted) = match measured {
Some((raw, aim)) => {
let raw: Vec<f64> = freqs.iter().map(|&hz| targets::at(&raw, hz)).collect();
let aim: Vec<f64> = freqs.iter().map(|&hz| targets::at(&aim, hz)).collect();
let predicted = raw.iter().zip(&total).map(|(r, t)| r + t).collect();
(Some(raw), Some(aim), Some(predicted))
}
None => (None, None, None),
};
let preamp_db = setup.map_or(0.0, |s| s.preamp_db(s.output_rate(rate), 2));
Some(Response {
freqs,
total,
bands,
layers,
measurement,
target,
predicted,
preamp_db,
correction,
tuning,
original: None,
})
}
pub fn select(group: &str, member: &str) -> Result<(), String> {
let cfg = Config::cached();
let g = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == group)
.ok_or_else(|| format!("No EQ called {group}"))?;
if !g.group {
return Err(format!("{group} is not a group"));
}
if !g.layers.iter().any(|l| l.profile == member) {
return Err(format!("{member} is not in {group}"));
}
persist(|cfg| {
if let Some(g) = cfg.dsp.profiles.iter_mut().find(|p| p.name == group) {
for l in &mut g.layers {
l.on = l.profile == member;
}
}
})
.map_err(|e| e.to_string())
}
pub fn set_group(name: &str, group: bool) -> Result<(), String> {
persist(|cfg| {
if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
p.group = group;
if group {
let first = p.layers.iter().position(|l| l.on).unwrap_or(0);
for (i, l) in p.layers.iter_mut().enumerate() {
l.on = i == first;
}
}
}
})
.map_err(|e| e.to_string())
}
pub fn free_name(name: &str) -> String {
let cfg = Config::cached();
let taken = |n: &str| {
let folder = dir(n);
cfg.dsp
.profiles
.iter()
.any(|p| p.name == n || dir(&p.name) == folder)
|| folder.exists()
};
std::iter::once(name.to_owned())
.chain((2..).map(|n| format!("{name} {n}")))
.find(|n| !taken(n))
.expect("some number is free")
}
pub fn make_group(name: &str, members: &[String]) -> Result<(), String> {
let name = name.trim();
if name.is_empty() {
return Err("A group needs a name".into());
}
if Config::cached().dsp.profiles.iter().any(|p| p.name == name) {
return Err(format!("There is already an EQ called {name}"));
}
let group = DspProfile {
name: name.to_owned(),
layers: members
.iter()
.enumerate()
.map(|(i, m)| crate::config::DspLayer {
profile: m.clone(),
on: i == 0,
})
.collect(),
group: true,
..Default::default()
};
let mut all = Config::cached().dsp.profiles.clone();
all.push(group.clone());
for m in members {
let mut check = group.clone();
for l in &mut check.layers {
l.on = &l.profile == m;
}
super::chain(&check, &all, &mut Vec::new()).map_err(|e| e.to_string())?;
}
persist(|cfg| cfg.dsp.profiles.push(group)).map_err(|e| e.to_string())
}
pub fn scope(profile: &DspProfile, all: &[DspProfile]) -> DspScope {
scope_in(profile, all, &mut Vec::new())
}
fn scope_in(profile: &DspProfile, all: &[DspProfile], seen: &mut Vec<String>) -> DspScope {
if let Some(scope) = profile.scope {
return scope;
}
if !profile.impulses.is_empty() {
return DspScope::Device;
}
if profile.target.is_some() || profile.measurement.is_some() {
return DspScope::Everywhere;
}
if !profile.layers.is_empty() {
if seen.contains(&profile.name) {
return DspScope::Device;
}
seen.push(profile.name.clone());
let everywhere = profile.layers.iter().all(|l| {
all.iter()
.find(|p| p.name == l.profile)
.is_some_and(|p| scope_in(p, all, seen) == DspScope::Everywhere)
});
return if everywhere {
DspScope::Everywhere
} else {
DspScope::Device
};
}
if profile.devices.iter().any(|d| stays_put(d)) {
return DspScope::Device;
}
DspScope::Everywhere
}
fn stays_put(device: &str) -> bool {
device.starts_with("uuid:")
|| device == "Speaker"
|| crate::audio::list_output_devices().is_ok_and(|devices| {
devices
.iter()
.any(|d| d.name == device && d.kind == crate::audio::backend::OutputKind::BuiltIn)
})
}
fn local_layer<'a>(layers: &'a [crate::config::DspLayer], all: &[DspProfile]) -> Option<&'a str> {
layers
.iter()
.find(|l| {
all.iter()
.find(|p| p.name == l.profile)
.is_some_and(|p| scope(p, all) == DspScope::Device)
})
.map(|l| l.profile.as_str())
}
fn shared_stack<'a>(name: &str, all: &'a [DspProfile]) -> Option<&'a DspProfile> {
all.iter().find(|p| {
p.layers.iter().any(|l| l.profile == name) && scope(p, all) == DspScope::Everywhere
})
}
fn scope_locked(profile: &DspProfile, all: &[DspProfile]) -> Option<String> {
if scope(profile, all) == DspScope::Everywhere {
shared_stack(&profile.name, all).map(|s| format!("{} plays it on every device", s.name))
} else {
local_layer(&profile.layers, all).map(|l| format!("plays {l}, kept on this device"))
}
}
fn kept_here(stack: &str, layer: &str) -> String {
format!(
"{layer} is kept on this device, so {stack}, which is kept everywhere, \
could not play it on your other devices. Keep {layer} everywhere, or {stack} on this device"
)
}
pub fn set_scope(name: &str, to: DspScope) -> Result<(), String> {
let cfg = Config::cached();
let all = &cfg.dsp.profiles;
let profile = all
.iter()
.find(|p| p.name == name)
.ok_or_else(|| format!("No EQ called {name}"))?;
match to {
DspScope::Everywhere => {
if let Some(layer) = local_layer(&profile.layers, all) {
return Err(kept_here(name, layer));
}
}
DspScope::Device => {
if let Some(stack) = shared_stack(name, all) {
return Err(format!(
"{name} is played by {}, which is kept everywhere and would lose it on \
your other devices. Keep {} on this device first",
stack.name, stack.name
));
}
}
}
persist(|cfg| {
if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
p.scope = Some(to);
}
})
.map_err(|e| e.to_string())
}
pub fn role(profile: &DspProfile) -> DspRole {
profile.role.unwrap_or(
if profile.target.is_some() || profile.measurement.is_some() || !profile.impulses.is_empty()
{
DspRole::Correction
} else {
DspRole::Tuning
},
)
}
pub fn shown_role(profile: &DspProfile, all: &[DspProfile]) -> DspRole {
let own = !profile.filters.is_empty() || !profile.impulses.is_empty();
if profile.role.is_some() || own || profile.layers.is_empty() {
return role(profile);
}
corrections_in(profile, all)
.first()
.and_then(|c| all.iter().find(|p| &p.name == c))
.map_or(DspRole::Tuning, role)
}
pub fn aims_at(profile: &DspProfile, all: &[DspProfile]) -> Option<String> {
let first = corrections_in(profile, all).into_iter().next()?;
let c = all.iter().find(|p| p.name == first)?;
if role(c) != DspRole::Correction {
return None;
}
c.measurement
.as_ref()
.map(|m| m.target.clone())
.or_else(|| {
c.target
.as_ref()
.map(|t| t.chosen.clone().unwrap_or_else(|| t.made_for.clone()))
})
}
pub fn tuning_for(device: &str) -> Option<String> {
tunings_for(device).into_iter().next().map(|(name, _)| name)
}
pub fn tunings_for(device: &str) -> Vec<(String, bool)> {
Config::cached()
.dsp
.tunings
.iter()
.filter(|t| t.device == device)
.map(|t| (t.tuning.clone(), t.on))
.collect()
}
pub fn set_tuning(device: &str, tuning: Option<&str>) -> Result<(), String> {
let list: Vec<(String, bool)> = tuning.map(|t| (t.to_owned(), true)).into_iter().collect();
set_tunings(device, &list)
}
pub fn set_tunings(device: &str, tunings: &[(String, bool)]) -> Result<(), String> {
let cfg = Config::cached();
let all = &cfg.dsp.profiles;
for (i, (name, _)) in tunings.iter().enumerate() {
check_tuning(name, all)?;
if tunings[..i].iter().any(|(n, _)| n == name) {
return Err(format!("{name} is in the tuning twice"));
}
}
if tunings.iter().any(|(_, on)| *on)
&& let Some(c) = cfg.dsp.profile_for(device)
&& shown_role(c, all) == DspRole::Baked
{
return Err(baked_already(&c.name));
}
persist(|cfg| {
cfg.dsp.tunings.retain(|t| t.device != device);
cfg.dsp.tunings.extend(
tunings
.iter()
.map(|(name, on)| crate::config::DspOutputTuning {
device: device.to_owned(),
tuning: name.clone(),
on: *on,
}),
);
})
}
fn check_tuning(name: &str, all: &[DspProfile]) -> Result<(), String> {
let t = all
.iter()
.find(|p| p.name == name)
.ok_or_else(|| format!("No EQ called {name}"))?;
if !super::responses(t, all).is_empty() {
return Err(format!(
"{name} has impulse responses, which correct a room or speakers: choose it as the correction instead"
));
}
if shown_role(t, all) != DspRole::Tuning {
return Err(format!(
"{name} corrects headphones: choose it as the correction instead"
));
}
Ok(())
}
pub fn read_only(name: &str) -> String {
format!(
"{name} is a correction, which plays as made: add a tuning on top to change the sound, or make it a tuning on its page to edit it"
)
}
fn editable(profile: &DspProfile, all: &[DspProfile]) -> Result<(), String> {
if shown_role(profile, all).corrects() {
return Err(read_only(&profile.name));
}
Ok(())
}
pub fn edited(profile: &DspProfile) -> bool {
profile
.original
.as_ref()
.is_some_and(|o| o.filters != profile.filters || o.preamp_db != profile.preamp_db)
}
pub fn revert(name: &str) -> Result<(), String> {
let cfg = Config::cached();
let p = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == name)
.ok_or_else(|| format!("No EQ called {name}"))?;
let Some(original) = p.original.clone() else {
return Err(format!(
"{name} was not imported, so there is nothing to go back to"
));
};
persist(|cfg| {
if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
p.filters = original.filters;
p.preamp_db = original.preamp_db;
}
})
}
pub fn duplicate(name: &str, new: Option<&str>) -> Result<String, String> {
let cfg = Config::cached();
let p = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == name)
.ok_or_else(|| format!("No EQ called {name}"))?;
let new = match new.map(str::trim).filter(|n| !n.is_empty()) {
Some(n) if free_name(n) != n => {
return Err(format!("There is already an EQ called {n}"));
}
Some(n) => n.to_owned(),
None => free_name(&format!("{name} copy")),
};
let (from, to) = (dir(name), dir(&new));
if from.is_dir() {
copy_dir(&from, &to).map_err(|e| e.to_string())?;
}
let mut copy = p.clone();
let rel = |d: &Path| Path::new("dsp").join(d.file_name().unwrap_or_default());
copy.impulses = copy
.impulses
.iter()
.map(|i| match i.strip_prefix(rel(&from)) {
Ok(rest) => rel(&to).join(rest),
Err(_) => i.clone(),
})
.collect();
copy.name = new.clone();
copy.devices.clear();
copy.uid = None;
copy.origin = None;
copy.original = None;
copy.source.clear();
persist(|cfg| cfg.dsp.profiles.push(copy))?;
Ok(new)
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let target = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), target)?;
}
}
Ok(())
}
pub fn preset_for(device: &str) -> Option<(String, bool)> {
let cfg = Config::cached();
let name = cfg
.dsp
.presets
.iter()
.find(|p| p.device == device)?
.preset
.clone();
let preset = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == name && p.preset)?;
let now = chain_of(&cfg.dsp, device);
Some((name, now != preset.layers))
}
fn chain_of(dsp: &crate::config::DspConfig, device: &str) -> Vec<crate::config::DspLayer> {
let correction = dsp
.profiles
.iter()
.find(|p| !p.preset && p.devices.iter().any(|d| d == device));
correction
.map(|c| crate::config::DspLayer {
profile: c.name.clone(),
on: true,
})
.into_iter()
.chain(
dsp.tunings
.iter()
.filter(|t| t.device == device)
.map(|t| crate::config::DspLayer {
profile: t.tuning.clone(),
on: t.on,
}),
)
.collect()
}
pub fn save_preset(device: &str, name: &str) -> Result<String, String> {
let name = name.trim();
if name.is_empty() {
return Err("A preset needs a name".into());
}
let cfg = Config::cached();
if cfg.dsp.profiles.iter().any(|p| p.name == name && !p.preset) {
return Err(format!("There is already an EQ called {name}"));
}
let layers = chain_of(&cfg.dsp, device);
let unsaid = unsaid_correction(&cfg.dsp, device);
persist(|cfg| {
say_corrects(cfg, unsaid.as_deref());
match cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
Some(p) => p.layers = layers,
None => cfg.dsp.profiles.push(DspProfile {
name: name.to_owned(),
preset: true,
layers,
..Default::default()
}),
}
cfg.dsp.presets.retain(|p| p.device != device);
cfg.dsp.presets.push(crate::config::DspOutputPreset {
device: device.to_owned(),
preset: name.to_owned(),
});
})?;
Ok(name.to_owned())
}
fn unsaid_correction(dsp: &crate::config::DspConfig, device: &str) -> Option<String> {
dsp.profiles
.iter()
.find(|p| !p.preset && p.devices.iter().any(|d| d == device))
.filter(|p| p.role.is_none() && !shown_role(p, &dsp.profiles).corrects())
.map(|p| p.name.clone())
}
fn say_corrects(cfg: &mut Config, name: Option<&str>) {
if let Some(p) = cfg
.dsp
.profiles
.iter_mut()
.find(|p| Some(p.name.as_str()) == name)
{
p.role = Some(DspRole::Correction);
}
}
fn preset_parts(
layers: &[crate::config::DspLayer],
all: &[DspProfile],
) -> (Option<String>, Vec<(String, bool)>) {
let mut layers = layers.iter().peekable();
let correction = layers
.next_if(|l| {
all.iter()
.any(|q| q.name == l.profile && shown_role(q, all).corrects())
})
.filter(|l| l.on)
.map(|l| l.profile.clone());
(
correction,
layers.map(|l| (l.profile.clone(), l.on)).collect(),
)
}
fn set_output(
cfg: &mut Config,
device: &str,
correction: Option<&str>,
tunings: &[(String, bool)],
preset: Option<&str>,
) {
for p in &mut cfg.dsp.profiles {
p.devices.retain(|d| d != device);
if Some(p.name.as_str()) == correction {
p.devices.push(device.to_owned());
}
}
cfg.dsp.tunings.retain(|t| t.device != device);
cfg.dsp.tunings.extend(
tunings
.iter()
.map(|(t, on)| crate::config::DspOutputTuning {
device: device.to_owned(),
tuning: t.clone(),
on: *on,
}),
);
cfg.dsp.presets.retain(|p| p.device != device);
if let Some(preset) = preset {
cfg.dsp.presets.push(crate::config::DspOutputPreset {
device: device.to_owned(),
preset: preset.to_owned(),
});
}
}
pub fn apply_preset(device: &str, name: Option<&str>) -> Result<(), String> {
let cfg = Config::cached();
let all = &cfg.dsp.profiles;
let (correction, tunings) = match name {
None => (None, Vec::new()),
Some(name) => {
let p = all
.iter()
.find(|p| p.name == name && p.preset)
.ok_or_else(|| {
let presets: Vec<&str> = all
.iter()
.filter(|p| p.preset)
.map(|p| p.name.as_str())
.collect();
format!(
"No preset called {name}. Presets: {}",
if presets.is_empty() {
"none yet".to_owned()
} else {
presets.join(", ")
}
)
})?;
let parts = preset_parts(&p.layers, all);
for (t, _) in &parts.1 {
check_tuning(t, all)?;
}
parts
}
};
persist(|cfg| set_output(cfg, device, correction.as_deref(), &tunings, name))
}
type Setting = (String, Option<String>, Vec<(String, bool)>, String);
pub fn migrate() -> Result<(), String> {
migrate_presets()?;
flatten_off()
}
fn flatten_off() -> Result<(), String> {
let cfg = Config::cached();
if cfg.dsp.enabled {
return Ok(());
}
let mut devices: Vec<&str> = Vec::new();
for d in cfg
.dsp
.profiles
.iter()
.filter(|p| !p.preset)
.flat_map(|p| p.devices.iter())
.chain(cfg.dsp.tunings.iter().map(|t| &t.device))
{
if !devices.contains(&d.as_str()) {
devices.push(d);
}
}
let mut taken: Vec<String> = cfg.dsp.profiles.iter().map(|p| p.name.clone()).collect();
let mut kept: Vec<(String, Vec<crate::config::DspLayer>)> = Vec::new();
let mut unsaid: Vec<String> = Vec::new();
for device in &devices {
if preset_for(device).is_some_and(|(_, edited)| !edited) {
continue;
}
let layers = chain_of(&cfg.dsp, device);
if layers.is_empty() {
continue;
}
unsaid.extend(unsaid_correction(&cfg.dsp, device));
let name = (1..)
.map(|n| match n {
1 => (*device).to_owned(),
n => format!("{device} {n}"),
})
.find(|n| !taken.contains(n))
.expect("a name is free");
taken.push(name.clone());
kept.push((name, layers));
}
persist(|cfg| {
for name in &unsaid {
say_corrects(cfg, Some(name));
}
for (name, layers) in &kept {
cfg.dsp.profiles.push(DspProfile {
name: name.clone(),
preset: true,
layers: layers.clone(),
..Default::default()
});
}
for device in &devices {
set_output(cfg, device, None, &[], None);
}
cfg.dsp.enabled = true;
})
}
fn migrate_presets() -> Result<(), String> {
let cfg = Config::cached();
let all = &cfg.dsp.profiles;
let used_as_layer = |name: &str| {
all.iter()
.any(|p| p.layers.iter().any(|l| l.profile == name))
};
let corrects = |name: &str| {
all.iter()
.any(|q| q.name == name && shown_role(q, all).corrects())
};
let is_eq = |name: &str| check_tuning(name, all).is_ok();
let presets: Vec<(String, Vec<crate::config::DspLayer>, Vec<String>)> = all
.iter()
.filter(|p| {
(p.preset && !p.devices.is_empty())
|| (!p.preset
&& !p.group
&& p.filters.is_empty()
&& p.impulses.is_empty()
&& p.target.is_none()
&& p.measurement.is_none()
&& !used_as_layer(&p.name)
&& p.layers.first().is_some_and(|l| corrects(&l.profile))
&& p.layers.iter().skip(1).all(|l| is_eq(&l.profile)))
})
.map(|p| {
let mut layers = p.layers.clone();
if layers
.first()
.is_some_and(|l| !l.on && corrects(&l.profile))
{
layers.remove(0);
}
(p.name.clone(), layers, p.devices.clone())
})
.collect();
let mut set: Vec<Setting> = Vec::new();
for (name, layers, devices) in &presets {
let (correction, eqs) = preset_parts(layers, all);
for device in devices {
let mut tunings = eqs.clone();
for t in cfg.dsp.tunings.iter().filter(|t| &t.device == device) {
if !tunings.iter().any(|(n, _)| n == &t.tuning) {
tunings.push((t.tuning.clone(), t.on));
}
}
set.push((device.clone(), correction.clone(), tunings, name.clone()));
}
}
let playing = |p: &DspProfile| super::playing(p, all).map(|m| m.name.clone());
let groups: Vec<(String, String)> = all
.iter()
.filter(|p| p.group)
.filter_map(|g| playing(g).map(|m| (g.name.clone(), m)))
.collect();
let groups_in_use = groups.iter().any(|(g, _)| {
all.iter().any(|p| &p.name == g && !p.devices.is_empty())
|| cfg.dsp.tunings.iter().any(|t| &t.tuning == g)
});
if presets.is_empty() && !groups_in_use {
return Ok(());
}
persist(|cfg| {
for (device, correction, tunings, preset) in &set {
set_output(cfg, device, correction.as_deref(), tunings, Some(preset));
}
for (name, layers, _) in &presets {
if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| &p.name == name) {
p.preset = true;
p.layers = layers.clone();
p.devices.clear();
}
}
for (group, member) in &groups {
let devices: Vec<String> = cfg
.dsp
.profiles
.iter()
.find(|p| &p.name == group)
.map(|p| p.devices.clone())
.unwrap_or_default();
for p in &mut cfg.dsp.profiles {
if &p.name == group {
p.devices.clear();
} else if &p.name == member {
for d in &devices {
if !p.devices.contains(d) {
p.devices.push(d.clone());
}
}
}
}
for t in &mut cfg.dsp.tunings {
if &t.tuning == group {
t.tuning = member.clone();
}
}
}
})
}
pub fn baked_already(correction: &str) -> String {
format!(
"{correction} already includes a tuning. Split it into a correction and an EQ to change the tuning."
)
}
pub fn set_tuned_for(name: &str, target: Option<&str>) -> Result<(), String> {
if !Config::cached().dsp.profiles.iter().any(|p| p.name == name) {
return Err(format!("No EQ called {name}"));
}
if let Some(t) = target
&& super::targets::choice_curve(t).is_none()
{
return Err(format!("No target called {t}"));
}
persist(|cfg| {
if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
p.tuned_for = target.map(str::to_owned);
}
})
}
pub fn corrections_in(profile: &DspProfile, all: &[DspProfile]) -> Vec<String> {
fn walk(p: &DspProfile, all: &[DspProfile], seen: &mut Vec<String>, out: &mut Vec<String>) {
if seen.contains(&p.name) || seen.len() > 64 {
return;
}
seen.push(p.name.clone());
let played: Vec<&DspProfile> = if p.group {
super::playing(p, all).into_iter().collect()
} else {
p.layers
.iter()
.filter(|l| l.on)
.filter_map(|l| all.iter().find(|q| q.name == l.profile))
.collect()
};
for q in played {
walk(q, all, seen, out);
}
if role(p).corrects() && !out.contains(&p.name) {
out.push(p.name.clone());
}
}
let mut out = Vec::new();
walk(profile, all, &mut Vec::new(), &mut out);
out
}
fn correction_label(p: &DspProfile) -> String {
let kind = if role(p) == DspRole::Baked {
"includes a tuning"
} else if p.measurement.is_some() {
"measured"
} else if super::targets::autoeq_measurement(&dir(&p.name)).is_some() {
"AutoEQ"
} else if p.target.is_some() {
"ready-made"
} else {
"correction"
};
if p.name.to_lowercase().contains(&kind.to_lowercase()) {
p.name.clone()
} else {
format!("{} ({kind})", p.name)
}
}
pub fn corrects_twice(profile: &DspProfile, all: &[DspProfile]) -> Option<String> {
let c = corrections_in(profile, all);
if c.len() < 2 {
return None;
}
let labels: Vec<String> = c
.iter()
.filter_map(|n| all.iter().find(|p| &p.name == n))
.map(correction_label)
.collect();
let (last, rest) = labels.split_last()?;
Some(format!(
"This EQ corrects twice: {} and {last}. Keep one.",
rest.join(", ")
))
}
pub fn two_corrections(corrections: &[String]) -> String {
format!(
"This EQ already plays the correction {}. Take that out first, or add {} to a device's tuning instead.",
corrections[0],
corrections.get(1).map_or("this", String::as_str)
)
}
pub fn set_role(name: &str, to: DspRole) -> Result<(), String> {
if !Config::cached().dsp.profiles.iter().any(|p| p.name == name) {
return Err(format!("No EQ called {name}"));
}
persist(|cfg| {
if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
p.role = Some(to);
}
})
}
pub fn target_name(id: &str) -> String {
use super::targets;
targets::shipped(id).map_or_else(
|| {
targets::added()
.into_iter()
.find(|a| a.id == id)
.map_or_else(|| id.to_owned(), |a| a.name)
},
|t| t.name.to_owned(),
)
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ChainSummary {
pub correction: Option<String>,
pub baked: bool,
pub tunings: Vec<String>,
}
pub fn summary(name: &str) -> Option<ChainSummary> {
use super::targets;
let cfg = Config::cached();
let all = &cfg.dsp.profiles;
let profile = all.iter().find(|p| p.name == name)?;
let mut chain: Vec<&DspProfile> = profile
.layers
.iter()
.filter(|l| l.on)
.filter_map(|l| all.iter().find(|p| p.name == l.profile))
.collect();
chain.push(profile);
let mut out = ChainSummary::default();
for p in chain {
match role(p) {
DspRole::Tuning => {
if !p.filters.is_empty() || !p.impulses.is_empty() {
out.tunings.push(p.name.clone());
}
continue;
}
DspRole::Baked => {
if out.correction.is_none() {
out.correction = Some(format!("{} (correction + tuning in one)", p.name));
out.baked = true;
}
continue;
}
DspRole::Correction => {}
}
let line = if let Some(m) = &p.measurement {
format!("{} → {} (from measurement)", p.name, target_name(&m.target))
} else if let Some(t) = &p.target {
let autoeq = targets::autoeq_measurement(&dir(&p.name)).is_some();
match (&t.chosen, autoeq) {
(Some(c), true) => format!(
"{} → {} (rebuilt from AutoEQ's measurement)",
p.name,
target_name(c)
),
(Some(c), false) => format!("{} → {}", p.name, target_name(c)),
(None, true) => format!("{} → {}", p.name, target_name(&t.made_for)),
(None, false) => format!(
"{}: ready-made EQ (made for {})",
p.name,
target_name(&t.made_for)
),
}
} else {
format!("{}: ready-made EQ (made for an unknown target)", p.name)
};
out.correction.get_or_insert(line);
}
Some(out)
}
pub fn read_measurement(text: &str) -> Result<super::targets::Curve, String> {
super::targets::covering(text, "A measurement")
}
pub fn preview_measurement(text: &str, target: &str, rate: u32) -> Result<Response, String> {
use super::targets;
let measured = read_measurement(text)?;
let aim = targets::choice_curve(target).ok_or_else(|| format!("No target {target}"))?;
let freqs = targets::grid();
let filters = [config::DspFilter::Graphic(targets::correction(
&measured, &aim,
))];
let total = super::response(&filters, &freqs, rate);
let level = |c: &targets::Curve| {
let k = targets::at(c, 1000.0);
freqs
.iter()
.map(|&hz| targets::at(c, hz) - k)
.collect::<Vec<f64>>()
};
let raw = level(&measured);
let predicted = raw.iter().zip(&total).map(|(r, t)| r + t).collect();
Ok(Response {
bands: Vec::new(),
layers: Vec::new(),
measurement: Some(raw),
target: Some(level(&aim)),
predicted: Some(predicted),
preamp_db: 0.0,
correction: None,
tuning: None,
original: None,
total,
freqs,
})
}
struct Split {
taste: crate::config::GraphicEq,
freqs: Vec<f64>,
original: Vec<f64>,
correction: Vec<f64>,
tuning: Vec<f64>,
}
fn split(name: &str, text: &str, target: &str, rate: u32) -> Result<Split, String> {
use super::targets;
use crate::config::DspFilter;
let cfg = Config::cached();
let all = &cfg.dsp.profiles;
let p = all
.iter()
.find(|p| p.name == name)
.ok_or_else(|| format!("No EQ called {name}"))?;
if !super::responses(p, all).is_empty() {
return Err(format!(
"{name} has impulse responses: only an EQ splits into a correction and a tuning"
));
}
if !p.layers.is_empty() || p.group {
return Err(format!(
"{name} plays other EQs: split the one among them that includes a tuning instead"
));
}
if p.filters.iter().any(|f| match f {
DspFilter::Band(b) => !b.channels.is_empty(),
DspFilter::Graphic(g) => !g.channels.is_empty(),
DspFilter::Mix(_) | DspFilter::Delay(_) => true,
}) {
return Err(format!(
"{name} treats its channels differently: only an EQ the same on every channel splits"
));
}
let measured = read_measurement(text)?;
let aim = targets::choice_curve(target).ok_or_else(|| format!("No target {target}"))?;
let freqs = targets::grid();
let filters = super::chain(p, all, &mut Vec::new()).map_err(|e| e.to_string())?;
let original = super::response(&filters, &freqs, rate);
let correction = super::response(
&[DspFilter::Graphic(targets::correction(&measured, &aim))],
&freqs,
rate,
);
let curve =
|db: &[f64]| -> targets::Curve { freqs.iter().copied().zip(db.iter().copied()).collect() };
let neutral = super::response(
&[DspFilter::Graphic(targets::difference(&measured, &aim))],
&freqs,
rate,
);
let taste = targets::difference(&curve(&neutral), &curve(&original));
let tuning = super::response(&[DspFilter::Graphic(taste.clone())], &freqs, rate);
Ok(Split {
taste,
freqs,
original,
correction,
tuning,
})
}
pub fn preview_split(name: &str, text: &str, target: &str, rate: u32) -> Result<Response, String> {
let s = split(name, text, target, rate)?;
let total = s
.correction
.iter()
.zip(&s.tuning)
.map(|(c, t)| c + t)
.collect();
Ok(Response {
bands: Vec::new(),
layers: Vec::new(),
measurement: None,
target: None,
predicted: None,
preamp_db: 0.0,
correction: Some(s.correction),
tuning: Some(s.tuning),
original: Some(s.original),
total,
freqs: s.freqs,
})
}
pub fn split_baked(
name: &str,
text: &str,
ear: DspEar,
target: &str,
) -> Result<(String, String), String> {
let s = split(name, text, target, 48_000)?;
let devices = Config::cached()
.dsp
.profiles
.iter()
.find(|p| p.name == name)
.map(|p| p.devices.clone())
.unwrap_or_default();
let mut correction =
measured_profile(&free_name(&format!("{name} correction")), text, ear, target)?;
let tuning = free_name(&format!("{name} tuning"));
let made = (correction.name.clone(), tuning.clone());
correction.devices = devices.clone();
let written = persist(|cfg| {
for p in &mut cfg.dsp.profiles {
p.devices.retain(|d| !devices.contains(d));
}
cfg.dsp.tunings.retain(|t| !devices.contains(&t.device));
cfg.dsp
.tunings
.extend(devices.iter().map(|d| crate::config::DspOutputTuning {
device: d.clone(),
tuning: tuning.clone(),
on: true,
}));
cfg.dsp.profiles.push(correction);
cfg.dsp.profiles.push(DspProfile {
name: tuning.clone(),
filters: vec![crate::config::DspFilter::Graphic(s.taste)],
role: Some(DspRole::Tuning),
tuned_for: Some(target.to_owned()),
..Default::default()
});
});
if written.is_err() {
let _ = std::fs::remove_dir_all(dir(&made.0));
}
written.map(|()| made)
}
pub fn save_measured(name: &str, text: &str, ear: DspEar, target: &str) -> Result<String, String> {
save_measured_from(name, text, ear, target, None)
}
pub fn save_measured_from(
name: &str,
text: &str,
ear: DspEar,
target: &str,
source: Option<&str>,
) -> Result<String, String> {
let mut profile = measured_profile(name, text, ear, target)?;
profile.source = source.into_iter().map(str::to_owned).collect();
let name = profile.name.clone();
persist(|cfg| cfg.dsp.profiles.push(profile))?;
Ok(name)
}
fn measured_profile(
name: &str,
text: &str,
ear: DspEar,
target: &str,
) -> Result<DspProfile, String> {
use super::targets;
let name = name.trim();
if name.is_empty() {
return Err("It needs a name".into());
}
if Config::cached().dsp.profiles.iter().any(|p| p.name == name) {
return Err(format!("There is already an EQ called {name}"));
}
let measured = read_measurement(text)?;
if targets::choice_curve(target).is_none() {
return Err(format!("No target {target}"));
}
let folder = dir(name);
if folder.exists() {
return Err(format!("{} is in the way", folder.display()));
}
std::fs::create_dir_all(&folder).map_err(|e| e.to_string())?;
std::fs::write(
targets::measurement_path(&folder),
targets::on_grid(&measured),
)
.map_err(|e| e.to_string())?;
Ok(DspProfile {
name: name.to_owned(),
role: Some(DspRole::Correction),
measurement: Some(DspMeasurement {
ear,
target: target.to_owned(),
}),
..Default::default()
})
}
pub fn set_layers(name: &str, layers: Vec<crate::config::DspLayer>) -> Result<(), String> {
let name = name.trim();
if name.is_empty() {
return Err("It needs a name".into());
}
if let Some(twice) = layers
.iter()
.enumerate()
.find(|(i, l)| layers[..*i].iter().any(|e| e.profile == l.profile))
.map(|(_, l)| &l.profile)
{
return Err(format!("{twice} is in {name} twice; each plays once"));
}
let cfg = Config::cached();
let mut all = cfg.dsp.profiles.clone();
let probe = profile_mut(&mut all, name);
probe.layers = layers.clone();
let mut check = probe.clone();
if !check.group {
for l in &mut check.layers {
l.on = true;
}
}
let corrections = corrections_in(&check, &all);
let before = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == name)
.map(|p| {
let mut p = p.clone();
if !p.group {
for l in &mut p.layers {
l.on = true;
}
}
corrections_in(&p, &cfg.dsp.profiles)
})
.unwrap_or_default();
if corrections.len() > 1 && corrections.iter().any(|c| !before.contains(c)) {
let mut named: Vec<String> = before
.iter()
.filter(|c| corrections.contains(c))
.cloned()
.collect();
named.extend(corrections.iter().filter(|c| !before.contains(c)).cloned());
return Err(two_corrections(&named));
}
super::chain(&check, &all, &mut Vec::new()).map_err(|e| e.to_string())?;
if everywhere_by_choice(&all, name)
&& let Some(layer) = local_layer(&layers, &all)
{
return Err(kept_here(name, layer));
}
persist(|cfg| {
let p = profile_mut(&mut cfg.dsp.profiles, name);
p.layers = layers;
if p.group {
let first = p.layers.iter().position(|l| l.on).unwrap_or(0);
for (i, l) in p.layers.iter_mut().enumerate() {
l.on = i == first;
}
}
})
.map_err(|e| e.to_string())
}
pub fn remove(name: &str) -> Result<(), String> {
let cfg = Config::cached();
let playing: Vec<String> = cfg
.dsp
.profiles
.iter()
.filter(|p| !p.preset && !p.group && p.layers.iter().any(|l| l.profile == name))
.map(|p| p.name.clone())
.collect();
if !playing.is_empty() {
return Err(format!(
"{} plays {name}: take it out of {} first",
playing.join(", "),
if playing.len() == 1 { "it" } else { "them" }
));
}
let shared = Config::cached()
.dsp
.profiles
.iter()
.any(|p| p.name != name && slug(&p.name) == slug(name));
persist(|cfg| {
cfg.dsp.profiles.retain(|p| p.name != name);
for p in &mut cfg.dsp.profiles {
p.layers.retain(|l| l.profile != name);
if p.group
&& !p.layers.iter().any(|l| l.on)
&& let Some(first) = p.layers.first_mut()
{
first.on = true;
}
}
cfg.dsp.tunings.retain(|t| t.tuning != name);
cfg.dsp.presets.retain(|p| p.preset != name);
})
.map_err(|e| e.to_string())?;
if !shared {
let _ = std::fs::remove_dir_all(dir(name));
}
Ok(())
}
pub fn set_enabled(enabled: bool) -> Result<(), String> {
persist(|cfg| cfg.dsp.enabled = enabled).map_err(|e| e.to_string())
}
fn everywhere_by_choice(all: &[DspProfile], name: &str) -> bool {
all.iter()
.find(|p| p.name == name)
.is_some_and(|p| p.scope == Some(DspScope::Everywhere))
}
fn profile_mut<'a>(profiles: &'a mut Vec<DspProfile>, name: &str) -> &'a mut DspProfile {
let index = match profiles.iter().position(|p| p.name == name) {
Some(i) => i,
None => {
profiles.push(DspProfile {
name: name.into(),
..Default::default()
});
profiles.len() - 1
}
};
&mut profiles[index]
}
fn slug(name: &str) -> String {
let s: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let s = s
.split('-')
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join("-");
if s.is_empty() { "profile".into() } else { s }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audio::dsp::impulse::{Impulse, Route};
#[test]
fn an_import_lands_beside_the_config_and_merges_by_name() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
let eq = Imported {
name: "HD 600".into(),
source: vec![],
filters: vec![crate::config::DspFilter::Band(crate::config::EqFilter {
kind: crate::config::EqFilterKind::Peaking,
freq: 100.0,
gain_db: 3.0,
q: 1.0,
channels: vec![],
})],
impulses: vec![],
};
assert_eq!(save(eq, None).unwrap(), "HD 600");
assert_eq!(slug("HD 600 / Röom"), "hd-600-röom");
let crossfeed = Impulse {
rate: 48000,
channels: Some(2),
routes: vec![Route {
ir: vec![1.0],
inputs: vec![(0, 0.5), (1, 0.5)],
outputs: vec![(0, 1.0), (1, 1.0)],
}],
in_delays: vec![],
out_delays: vec![],
};
let room = Imported {
name: "whatever".into(),
source: vec!["whatever-source".into()],
filters: vec![],
impulses: vec![Impulse::from_channels(44100, vec![vec![1.0]]), crossfeed],
};
save(room, Some("HD 600")).unwrap();
assign(Some("HD 600"), "Topping E30").unwrap();
let o = overview();
let p = &o.profiles[0];
assert_eq!(o.profiles.len(), 1);
assert_eq!(p.bands, 1, "the EQ survived the second import");
assert_eq!(p.rates, vec![44100, 48000]);
assert_eq!(p.devices, vec!["Topping E30"]);
assert!(p.problem.is_none(), "{:?}", p.problem);
assert!(dir.path().join("dsp/hd-600/48000.cfg").exists());
let d = detail("HD 600").unwrap();
assert_eq!(d.impulses.len(), 2);
assert!(!d.impulses[0].mixes);
assert!(d.impulses[1].mixes, "the crossfeed route mixes");
assert_eq!(d.source, vec!["whatever-source"]);
rename("HD 600", "Desk").unwrap();
assert!(dir.path().join("dsp/desk/48000.cfg").exists());
let d = detail("Desk").unwrap();
assert!(d.problem.is_none(), "{:?}", d.problem);
assert_eq!(d.impulses.len(), 2);
assert!(rename("Desk", "").is_err());
remove("Desk").unwrap();
assert!(!dir.path().join("dsp/desk").exists());
assert!(overview().profiles.is_empty());
}
fn band(freq: f64) -> crate::config::DspFilter {
crate::config::DspFilter::Band(crate::config::EqFilter {
kind: crate::config::EqFilterKind::Peaking,
freq,
gain_db: 3.0,
q: 1.0,
channels: vec![],
})
}
fn layer(profile: &str, on: bool) -> crate::config::DspLayer {
crate::config::DspLayer {
profile: profile.into(),
on,
}
}
#[test]
fn a_stack_plays_its_layers_in_order() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
for (name, f) in [
("HD 650", 100.0),
("Bass +3", 60.0),
("Treble tilt", 8000.0),
] {
c.dsp.profiles.push(DspProfile {
name: name.into(),
filters: vec![band(f)],
..Default::default()
});
}
})
.unwrap();
set_layers(
"Desk",
vec![
layer("HD 650", true),
layer("Bass +3", true),
layer("Treble tilt", false),
],
)
.unwrap();
let played = || {
let cfg = Config::cached();
let p = cfg.dsp.profiles.iter().find(|p| p.name == "Desk").unwrap();
Setup::load(p, &cfg.dsp.profiles, dir.path())
.unwrap()
.unwrap()
.filters
};
assert_eq!(played(), vec![band(100.0), band(60.0)]);
set_layers(
"Desk",
vec![
layer("Bass +3", true),
layer("HD 650", true),
layer("Treble tilt", true),
],
)
.unwrap();
assert_eq!(played(), vec![band(60.0), band(100.0), band(8000.0)]);
let summary = overview_for(None);
assert_eq!(
summary
.profiles
.iter()
.find(|p| p.name == "Desk")
.unwrap()
.layers,
3
);
rename("Bass +3", "Bass").unwrap();
assert_eq!(detail("Desk").unwrap().layers[0].profile, "Bass");
assert_eq!(played(), vec![band(60.0), band(100.0), band(8000.0)]);
let refused = remove("Bass").unwrap_err();
assert!(refused.contains("Desk"), "{refused}");
}
#[test]
fn a_stack_that_could_not_play_is_refused() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "Room".into(),
impulses: vec!["dsp/room/48000.wav".into()],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "EQ".into(),
filters: vec![band(100.0)],
..Default::default()
});
})
.unwrap();
assert!(
set_layers("Desk", vec![layer("Nothing", true)]).is_err(),
"missing"
);
assert!(
set_layers("Desk", vec![layer("Room", true)]).is_err(),
"impulses"
);
assert!(
set_layers("Desk", vec![layer("Desk", true)]).is_err(),
"itself"
);
set_layers("Desk", vec![layer("EQ", true)]).unwrap();
assert!(
set_layers("EQ", vec![layer("Desk", false)]).is_err(),
"cycle"
);
}
#[test]
fn a_stack_cannot_grow_without_bound() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "D".into(),
filters: vec![band(100.0)],
..Default::default()
});
})
.unwrap();
set_layers("B", vec![layer("D", true)]).unwrap();
set_layers("C", vec![layer("D", true)]).unwrap();
set_layers("A", vec![layer("B", true), layer("C", true)]).unwrap();
{
let cfg = Config::cached();
let a = cfg.dsp.profiles.iter().find(|p| p.name == "A").unwrap();
let played = Setup::load(a, &cfg.dsp.profiles, dir.path())
.unwrap()
.unwrap();
assert_eq!(
played.filters,
vec![band(100.0), band(100.0)],
"a diamond resolves"
);
}
assert!(
set_layers("X", vec![layer("D", true), layer("D", true)]).is_err(),
"twice"
);
persist(|c| {
for i in 0..30 {
let below = |n: i32| {
if n < 0 {
"D".to_string()
} else {
format!("L{n}")
}
};
c.dsp.profiles.push(DspProfile {
name: format!("L{i}"),
layers: vec![layer(&below(i - 1), true), layer(&below(i - 2), true)],
..Default::default()
});
}
})
.unwrap();
let cfg = Config::cached();
let top = cfg.dsp.profiles.iter().find(|p| p.name == "L29").unwrap();
let err = Setup::load(top, &cfg.dsp.profiles, dir.path())
.err()
.unwrap();
assert!(err.to_string().contains("L29"), "{err}");
let summary = overview_for(None);
let l29 = summary.profiles.iter().find(|p| p.name == "L29").unwrap();
assert!(l29.problem.is_some(), "Settings shows why it does not play");
assert!(set_layers("L30", vec![layer("L29", true)]).is_err());
}
#[test]
fn a_response_is_the_filters_the_dsp_runs() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "Peak".into(),
filters: vec![band(1000.0)],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Shelf".into(),
filters: vec![crate::config::DspFilter::Band(crate::config::EqFilter {
kind: crate::config::EqFilterKind::LowShelf,
freq: 100.0,
gain_db: 6.0,
q: 0.7,
channels: vec![],
})],
..Default::default()
});
})
.unwrap();
let r = response("Peak", 48000).unwrap();
let at = |r: &Response, curve: &[f64], hz: f64| {
let i = r.freqs.iter().position(|f| *f >= hz).unwrap();
curve[i]
};
assert!(
(at(&r, &r.total, 1000.0) - 3.0).abs() < 0.2,
"{}",
at(&r, &r.total, 1000.0)
);
assert!(at(&r, &r.total, 20.0).abs() < 0.1);
assert_eq!(r.bands.len(), 1);
assert!(r.measurement.is_none());
assert!(
r.preamp_db < -2.5,
"the peak is taken off ahead: {}",
r.preamp_db
);
set_layers("Both", vec![layer("Shelf", true), layer("Peak", true)]).unwrap();
let r = response("Both", 48000).unwrap();
assert_eq!(r.layers.len(), 2);
assert!((at(&r, &r.total, 30.0) - 6.0).abs() < 0.5, "the shelf");
assert!((at(&r, &r.total, 1000.0) - 3.0).abs() < 0.5, "the peak");
assert!(r.bands.is_empty(), "no bands of its own");
set_layers("Both", vec![layer("Shelf", false), layer("Peak", true)]).unwrap();
let r = response("Both", 48000).unwrap();
assert!(at(&r, &r.total, 30.0).abs() < 0.3, "the shelf off");
assert!(!r.layers[0].on);
}
#[test]
fn bands_are_edited_in_place() {
use crate::config::{DspFilter, EqFilter, EqFilterKind};
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "Mine".into(),
filters: vec![DspFilter::Band(EqFilter {
kind: EqFilterKind::Peaking,
freq: 100.0,
gain_db: 3.0,
q: 1.0,
channels: vec![1],
})],
..Default::default()
})
})
.unwrap();
set_band("Mine", 0, "low_shelf", 80.0, 99.0, 0.0).unwrap();
let filters = || detail("Mine").unwrap().filters;
assert_eq!(
filters()[0],
DspFilter::Band(EqFilter {
kind: EqFilterKind::LowShelf,
freq: 80.0,
gain_db: 30.0,
q: 0.1,
channels: vec![1],
})
);
assert!(set_band("Mine", 0, "wobble", 80.0, 0.0, 1.0).is_err());
assert!(set_band("Mine", 0, "peaking", f64::NAN, 0.0, 1.0).is_err());
assert!(set_band("Mine", 5, "peaking", 80.0, 0.0, 1.0).is_err());
assert_eq!(add_band("Mine").unwrap(), 1);
assert_eq!(filters().len(), 2);
let r = response("Mine", 48000).unwrap();
assert_eq!(r.bands.len(), 2);
remove_filter("Mine", 0).unwrap();
assert_eq!(filters().len(), 1);
assert!(remove_filter("Mine", 3).is_err());
assert!(add_band("Nobody").is_err());
}
#[test]
fn a_group_plays_one_member() {
use crate::config::{DspFilter, DspLayer};
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
for (name, hz) in [
("Warm", 100.0),
("Bright", 8000.0),
("Flat", 1000.0),
("Bass", 60.0),
] {
c.dsp.profiles.push(DspProfile {
name: name.into(),
filters: vec![band(hz)],
..Default::default()
});
}
})
.unwrap();
let names = ["Warm", "Bright", "Flat"].map(String::from);
make_group("Presets", &names).unwrap();
assert!(make_group("Presets", &names).is_err());
let freqs = |name: &str| -> Vec<f64> {
let cfg = Config::cached();
let p = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == name)
.unwrap()
.clone();
super::super::chain(&p, &cfg.dsp.profiles, &mut Vec::new())
.unwrap()
.into_iter()
.map(|f| match f {
DspFilter::Band(b) => b.freq,
other => panic!("{other:?}"),
})
.collect()
};
assert_eq!(freqs("Presets"), [100.0], "the first plays");
select("Presets", "Bright").unwrap();
assert_eq!(freqs("Presets"), [8000.0]);
assert!(select("Presets", "Bass").is_err(), "not a member");
let layer = |p: &str, on: bool| DspLayer {
profile: p.into(),
on,
};
set_layers(
"Presets",
vec![
layer("Warm", false),
layer("Bright", true),
layer("Flat", true),
],
)
.unwrap();
assert_eq!(freqs("Presets"), [8000.0]);
assert_eq!(
detail("Presets")
.unwrap()
.layers
.iter()
.filter(|l| l.on)
.count(),
1
);
set_layers("Desk", vec![layer("Presets", true), layer("Bass", true)]).unwrap();
assert_eq!(freqs("Desk"), [8000.0, 60.0]);
set_group("Presets", false).unwrap();
set_layers(
"Presets",
vec![
layer("Warm", true),
layer("Bright", true),
layer("Flat", false),
],
)
.unwrap();
assert_eq!(freqs("Presets"), [100.0, 8000.0]);
set_group("Presets", true).unwrap();
assert_eq!(
freqs("Presets"),
[100.0],
"the first switched on keeps playing"
);
persist(|c| {
let p = c.dsp.profiles.iter_mut().find(|p| p.name == "Presets");
for l in &mut p.unwrap().layers {
l.on = false;
}
})
.unwrap();
assert_eq!(freqs("Presets"), [100.0]);
}
#[test]
fn a_group_plays_its_members_responses() {
use crate::config::DspLayer;
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
let mut paths = Vec::new();
for rate in [44100, 48000] {
let path = dir.path().join(format!("{rate}.wav"));
super::super::raw::write_wav(&path, rate, &[vec![1.0, 0.0]]).unwrap();
paths.push(path);
}
persist(|c| {
for (name, path) in [("Room 44", &paths[0]), ("Room 48", &paths[1])] {
c.dsp.profiles.push(DspProfile {
name: name.into(),
impulses: vec![path.clone()],
..Default::default()
});
}
})
.unwrap();
make_group("Room", &["Room 44".into(), "Room 48".into()]).unwrap();
let rates = || {
let cfg = Config::cached();
let p = cfg.dsp.profiles.iter().find(|p| p.name == "Room").unwrap();
super::super::Setup::load(p, &cfg.dsp.profiles, dir.path())
.unwrap()
.unwrap()
.rates()
};
assert_eq!(rates(), [44100]);
select("Room", "Room 48").unwrap();
assert_eq!(rates(), [48000]);
let refused = set_layers(
"Desk",
vec![DspLayer {
profile: "Room".into(),
on: true,
}],
)
.unwrap_err();
assert!(
refused.contains("only bands can be played by another EQ"),
"{refused}"
);
}
#[test]
fn a_free_name_takes_nothing() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
assert_eq!(free_name("Room"), "Room");
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "Room".into(),
filters: vec![band(100.0)],
..Default::default()
})
})
.unwrap();
assert_eq!(free_name("Room"), "Room 2");
assert_eq!(free_name("room!"), "room! 2", "the same folder as Room's");
}
#[test]
fn where_a_profile_is_kept() {
use crate::config::{DspLayer, DspTarget};
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "HD 650".into(),
filters: vec![band(1000.0)],
target: Some(DspTarget {
made_for: "harman-over-ear-2018".into(),
chosen: None,
}),
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Room".into(),
impulses: vec!["dsp/room/48000.wav".into()],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Bass".into(),
filters: vec![band(80.0)],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Amp".into(),
filters: vec![band(80.0)],
devices: vec!["uuid:5f9ec1b3-ed59-79bb-4530-745e2b1d1b10".into()],
..Default::default()
});
})
.unwrap();
let kept = |name: &str| {
let cfg = Config::cached();
let p = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == name)
.unwrap()
.clone();
scope(&p, &cfg.dsp.profiles)
};
assert_eq!(kept("HD 650"), DspScope::Everywhere);
assert_eq!(kept("Room"), DspScope::Device);
assert_eq!(kept("Bass"), DspScope::Everywhere);
assert_eq!(
kept("Amp"),
DspScope::Device,
"an amplifier stays where it is"
);
let layer = |p: &str| DspLayer {
profile: p.into(),
on: true,
};
set_layers("Desk", vec![layer("HD 650"), layer("Bass")]).unwrap();
assert_eq!(kept("Desk"), DspScope::Everywhere);
set_layers("Speakers", vec![layer("Amp"), layer("Bass")]).unwrap();
assert_eq!(
kept("Speakers"),
DspScope::Device,
"a stack here may use shared layers"
);
set_scope("Desk", DspScope::Everywhere).unwrap();
let refused = set_layers("Desk", vec![layer("HD 650"), layer("Amp")]).unwrap_err();
assert!(refused.contains("Amp is kept on this device"), "{refused}");
let refused = set_scope("Bass", DspScope::Device).unwrap_err();
assert!(refused.contains("Bass is played by Desk"), "{refused}");
let locked = |name: &str| {
let cfg = Config::cached();
let p = cfg.dsp.profiles.iter().find(|p| p.name == name).unwrap();
scope_locked(p, &cfg.dsp.profiles)
};
assert_eq!(
locked("Bass").as_deref(),
Some("Desk plays it on every device")
);
assert_eq!(
locked("Speakers").as_deref(),
Some("plays Amp, kept on this device")
);
assert_eq!(locked("Desk"), None);
set_scope("Speakers", DspScope::Everywhere).unwrap_err();
set_scope("Desk", DspScope::Device).unwrap();
set_scope("Bass", DspScope::Device).unwrap();
assert_eq!(kept("Bass"), DspScope::Device);
}
fn measured(bump: f64) -> String {
let mut text = String::from("frequency,raw\n");
for hz in super::super::targets::grid() {
let db = bump * (-((hz / 3000.0).log2() * 3.0).powi(2)).exp();
text.push_str(&format!("{hz:.2},{db:.2}\n"));
}
text
}
#[test]
fn a_measurement_is_corrected_to_its_target() {
use super::super::targets;
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
let text = measured(6.0);
assert!(
read_measurement("1000,0\n").is_err(),
"too little to correct from"
);
let preview = preview_measurement(&text, "harman-in-ear-2019", 48000).unwrap();
assert!(preview.predicted.is_some() && preview.measurement.is_some());
save_measured("AFUL Performer 8S", &text, DspEar::In, "harman-in-ear-2019").unwrap();
assert!(
save_measured("AFUL Performer 8S", &text, DspEar::In, "harman-in-ear-2019").is_err()
);
let cfg = Config::cached();
let p = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == "AFUL Performer 8S")
.unwrap()
.clone();
assert_eq!(role(&p), DspRole::Correction);
assert_eq!(scope(&p, &cfg.dsp.profiles), DspScope::Everywhere);
assert_eq!(
summary("AFUL Performer 8S").unwrap().correction.as_deref(),
Some("AFUL Performer 8S → Harman in-ear 2019 (from measurement)")
);
let r = response("AFUL Performer 8S", 48000).unwrap();
let at = |curve: &[f64], hz: f64| curve[r.freqs.iter().position(|f| *f >= hz).unwrap()];
let wanted = targets::correction(
&targets::parse(&text),
&targets::choice_curve("harman-in-ear-2019").unwrap(),
);
let expect = targets::at(&wanted.points, 3000.0);
assert!(
(at(&r.total, 3000.0) - expect).abs() < 1.0,
"{} vs {expect}",
at(&r.total, 3000.0)
);
assert!(
wanted
.points
.iter()
.filter(|(hz, _)| *hz >= 10_000.0)
.all(|(_, db)| db.abs() <= 3.0)
);
let choices = target_choices("AFUL Performer 8S").unwrap();
assert!(choices.made_for.is_none());
choose_target("AFUL Performer 8S", Some("harman-in-ear-2019-without-bass")).unwrap();
assert_eq!(
summary("AFUL Performer 8S").unwrap().correction.as_deref(),
Some("AFUL Performer 8S → Harman in-ear 2019, no bass shelf (from measurement)")
);
}
#[test]
fn a_chain_corrects_once() {
use crate::config::{DspLayer, DspTarget};
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
for (name, target) in [("HD 650", true), ("HD 600", true), ("Warm", false)] {
c.dsp.profiles.push(DspProfile {
name: name.into(),
filters: vec![band(100.0)],
target: target.then(|| DspTarget {
made_for: "harman-over-ear-2018".into(),
chosen: None,
}),
..Default::default()
});
}
})
.unwrap();
let layer = |p: &str| DspLayer {
profile: p.into(),
on: true,
};
set_layers("Desk", vec![layer("HD 650"), layer("Warm")]).unwrap();
let s = summary("Desk").unwrap();
assert_eq!(
s.correction.as_deref(),
Some("HD 650: ready-made EQ (made for Harman over-ear 2018)")
);
assert_eq!(s.tunings, vec!["Warm".to_string()]);
let refused = set_layers(
"Desk",
vec![layer("HD 650"), layer("Warm"), layer("HD 600")],
)
.unwrap_err();
assert_eq!(
refused,
"This EQ already plays the correction HD 650. Take that out first, or add HD 600 to a device's tuning instead."
);
set_role("Warm", DspRole::Correction).unwrap();
assert_eq!(
detail("Desk").unwrap().corrects_twice.as_deref(),
Some("This EQ corrects twice: HD 650 (ready-made) and Warm (correction). Keep one.")
);
set_role("Warm", DspRole::Tuning).unwrap();
set_role("HD 600", DspRole::Tuning).unwrap();
set_layers(
"Desk",
vec![layer("HD 650"), layer("Warm"), layer("HD 600")],
)
.unwrap();
}
#[test]
fn made_for_is_checked() {
use crate::config::{DspEar, DspMeasurement};
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "Preset".into(),
filters: vec![band(100.0)],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Measured".into(),
measurement: Some(DspMeasurement {
ear: DspEar::In,
target: "harman-in-ear-2019".into(),
}),
..Default::default()
});
})
.unwrap();
assert!(set_made_for("Nobody", Some("harman-in-ear-2019")).is_err());
assert!(set_made_for("Preset", Some("made-up")).is_err());
assert!(set_made_for("Measured", Some("harman-in-ear-2019")).is_err());
let target = || {
Config::cached()
.dsp
.profiles
.iter()
.find(|p| p.name == "Preset")
.unwrap()
.target
.clone()
};
assert_eq!(target(), None);
set_made_for("Preset", Some("harman-in-ear-2019")).unwrap();
choose_target("Preset", Some("diffuse-field-iso-11904-1")).unwrap();
set_made_for("Preset", Some("harman-over-ear-2018")).unwrap();
let t = target().unwrap();
assert_eq!(t.made_for, "harman-over-ear-2018");
assert_eq!(t.chosen, None, "an in-ear target is no move for over-ears");
set_made_for("Preset", None).unwrap();
assert_eq!(target(), None);
}
#[test]
fn the_target_difference_goes_before_the_tuning() {
use crate::config::{DspFilter, DspLayer, DspTarget};
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
let spread = |n: usize, from: f64| -> Vec<DspFilter> {
(0..n)
.map(|i| {
DspFilter::Band(crate::config::EqFilter {
kind: crate::config::EqFilterKind::Peaking,
freq: from + i as f64,
gain_db: 1.0,
q: 1.0,
channels: vec![(i % 4) as u16],
})
})
.collect()
};
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "Base".into(),
filters: spread(100, 1000.0),
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "HD 600".into(),
filters: vec![band(100.0)],
layers: vec![DspLayer {
profile: "Base".into(),
on: true,
}],
target: Some(DspTarget {
made_for: "harman-over-ear-2018".into(),
chosen: None,
}),
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Many".into(),
filters: spread(156, 100.0),
tuned_for: Some("diffuse-field-gras-kemar".into()),
..Default::default()
});
})
.unwrap();
let dac = "Desk DAC";
assign(Some("HD 600"), dac).unwrap();
set_tuning(dac, Some("Many")).unwrap();
let cfg = Config::cached();
let chain = super::super::output_chain(&cfg.dsp, dac).unwrap();
assert!(chain.tuning_plays);
let played = super::super::chain(&chain.profile, &chain.all, &mut Vec::new()).unwrap();
assert_eq!(
played.len(),
100 + 156 + 1,
"Base, the tuning, the correction's own"
);
assert!(
!played.iter().any(|f| matches!(f, DspFilter::Graphic(_))),
"no target difference"
);
assert!(
chain
.left_out
.as_deref()
.is_some_and(|l| l.starts_with("Many plays without the target difference")),
"{:?}",
chain.left_out
);
}
#[test]
fn a_baked_eq_splits_into_correction_and_tuning() {
use super::super::targets;
use crate::config::DspFilter;
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
let mut text = String::from("frequency,raw\n");
for hz in targets::grid() {
let peak = |at: f64, db: f64| db * (-(hz / at).log2().powi(2) / 0.1).exp();
text.push_str(&format!(
"{hz:.2},{:.2}\n",
90.0 + peak(3000.0, 6.0) + peak(8000.0, 5.0)
));
}
let target = "harman-in-ear-2019";
let measured = read_measurement(&text).unwrap();
let correction = targets::difference(&measured, &targets::choice_curve(target).unwrap());
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "Lush".into(),
filters: vec![DspFilter::Graphic(correction), band(80.0)],
role: Some(DspRole::Baked),
devices: vec!["Desk DAC".into()],
..Default::default()
})
})
.unwrap();
let r = preview_split("Lush", &text, target, 48_000).unwrap();
let at = |db: &[f64], hz: f64| {
let i = r.freqs.iter().position(|&f| f >= hz).unwrap();
db[i]
};
let tuning = r.tuning.as_ref().unwrap();
assert!((at(tuning, 80.0) - 3.0).abs() < 1.0, "{}", at(tuning, 80.0));
assert!(at(tuning, 3000.0).abs() < 0.7, "{}", at(tuning, 3000.0));
assert!(at(tuning, 8000.0).abs() < 1.0, "{}", at(tuning, 8000.0));
let original = r.original.as_ref().unwrap();
for hz in [50.0, 200.0, 1000.0, 4000.0] {
assert!((at(&r.total, hz) - at(original, hz)).abs() < 1.0, "{hz} Hz");
}
let (c, t) = split_baked("Lush", &text, DspEar::In, target).unwrap();
assert_eq!((c.as_str(), t.as_str()), ("Lush correction", "Lush tuning"));
let cfg = Config::cached();
assert_eq!(cfg.dsp.profile_for("Desk DAC").unwrap().name, c);
assert_eq!(tuning_for("Desk DAC").as_deref(), Some(t.as_str()));
let made = cfg.dsp.profiles.iter().find(|p| p.name == t).unwrap();
assert_eq!(made.tuned_for.as_deref(), Some(target));
assert_eq!(role(made), DspRole::Tuning);
assert!(cfg.dsp.profiles.iter().any(|p| p.name == "Lush"), "kept");
let chain = super::super::output_chain(&cfg.dsp, "Desk DAC").unwrap();
let played = super::super::chain(&chain.profile, &chain.all, &mut Vec::new()).unwrap();
assert_eq!(played.len(), 2);
assert!(split_baked("Lush", &text, DspEar::In, "made-up").is_err());
let stack = |c: &mut Config| {
c.dsp.profiles.push(DspProfile {
name: "Stack".into(),
layers: vec![crate::config::DspLayer {
profile: "Lush".into(),
on: true,
}],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Left".into(),
filters: vec![DspFilter::Band(crate::config::EqFilter {
kind: crate::config::EqFilterKind::Peaking,
freq: 100.0,
gain_db: 2.0,
q: 1.0,
channels: vec![0],
})],
..Default::default()
});
};
persist(stack).unwrap();
let refused = preview_split("Stack", &text, target, 48_000).unwrap_err();
assert!(refused.contains("plays other EQs"), "{refused}");
let refused = preview_split("Left", &text, target, 48_000).unwrap_err();
assert!(
refused.contains("treats its channels differently"),
"{refused}"
);
}
#[test]
fn a_tuning_is_eqs_in_order_and_a_preset_saves_it() {
use crate::config::{DspFilter, DspTarget};
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
let curve = |db: f64| {
DspFilter::Graphic(crate::config::GraphicEq {
points: vec![(20.0, 0.0), (1000.0, db), (20_000.0, 0.0)],
channels: vec![],
})
};
persist(|c| {
let harman = || {
Some(DspTarget {
made_for: "harman-over-ear-2018".into(),
chosen: None,
})
};
c.dsp.profiles.push(DspProfile {
name: "HD 600".into(),
filters: vec![band(100.0)],
target: harman(),
..Default::default()
});
for (name, freq, made) in [
("Warm", 60.0, Some("diffuse-field-gras-kemar")),
("Bright", 8000.0, Some("harman-over-ear-2018")),
("Spare", 200.0, None),
] {
c.dsp.profiles.push(DspProfile {
name: name.into(),
filters: vec![band(freq)],
tuned_for: made.map(str::to_owned),
..Default::default()
});
}
for (name, db) in [("Tilt", 1.0), ("Lift", 2.0)] {
c.dsp.profiles.push(DspProfile {
name: name.into(),
filters: vec![curve(db)],
..Default::default()
});
}
})
.unwrap();
let dac = "Desk DAC";
let plays = |device: &str| -> Vec<String> {
let cfg = Config::cached();
let Some(chain) = super::super::output_chain(&cfg.dsp, device) else {
return Vec::new();
};
super::super::chain(&chain.profile, &chain.all, &mut Vec::new())
.unwrap()
.into_iter()
.map(|f| match f {
DspFilter::Band(b) => format!("{}", b.freq),
DspFilter::Graphic(_) => "curve".into(),
other => panic!("{other:?}"),
})
.collect()
};
let list = |names: &[(&str, bool)]| -> Vec<(String, bool)> {
names.iter().map(|(n, on)| (n.to_string(), *on)).collect()
};
assign(Some("HD 600"), dac).unwrap();
set_tunings(
dac,
&list(&[("Warm", true), ("Bright", true), ("Spare", false)]),
)
.unwrap();
assert_eq!(plays(dac), ["curve", "60", "8000", "100"]);
assert_eq!(tuning_for(dac).as_deref(), Some("Warm"));
assert!(set_tunings(dac, &list(&[("Warm", true), ("Warm", false)])).is_err());
assert!(set_tunings(dac, &list(&[("HD 600", true)])).is_err());
set_tunings(
dac,
&list(&[("Tilt", true), ("Lift", true), ("Spare", true)]),
)
.unwrap();
assert_eq!(plays(dac), ["curve", "curve", "200", "100"]);
let o = overview_for(Some(dac.into()));
assert!(o.left_out.as_deref().is_none(), "{:?}", o.left_out);
persist(|c| {
let hd = c
.dsp
.profiles
.iter_mut()
.find(|p| p.name == "HD 600")
.unwrap();
hd.filters.push(curve(-1.0));
})
.unwrap();
assert_eq!(plays(dac), ["curve", "200", "100", "curve"]);
let o = overview_for(Some(dac.into()));
assert!(
o.left_out
.as_deref()
.is_some_and(|l| l.starts_with("Lift is left out")),
"{:?}",
o.left_out
);
set_tunings(dac, &list(&[("Warm", true), ("Spare", false)])).unwrap();
save_preset(dac, "Late night").unwrap();
assert_eq!(preset_for(dac), Some(("Late night".into(), false)));
let other = "Speakers";
apply_preset(other, Some("Late night")).unwrap();
assert_eq!(
Config::cached().dsp.profile_for(other).unwrap().name,
"HD 600"
);
assert_eq!(
tunings_for(other),
list(&[("Warm", true), ("Spare", false)])
);
assert_eq!(plays(other), plays(dac));
set_tunings(other, &list(&[("Warm", true), ("Spare", true)])).unwrap();
assert_eq!(
preset_for(other),
Some(("Late night".into(), true)),
"edited"
);
apply_preset(other, Some("Late night")).unwrap();
assert_eq!(preset_for(other), Some(("Late night".into(), false)));
assert_eq!(
tunings_for(other),
list(&[("Warm", true), ("Spare", false)])
);
assert_eq!(plays(other), plays(dac));
assert!(save_preset(other, "Warm").is_err(), "not over an EQ");
apply_preset(other, None).unwrap();
assert!(Config::cached().dsp.profile_for(other).is_none());
assert!(tunings_for(other).is_empty());
assert_eq!(preset_for(other), None);
assert!(plays(other).is_empty());
}
#[test]
fn corrections_are_read_only_and_imports_can_go_back() {
use crate::config::DspTarget;
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "HD 600".into(),
filters: vec![band(100.0)],
target: Some(DspTarget {
made_for: "harman-over-ear-2018".into(),
chosen: None,
}),
..Default::default()
})
})
.unwrap();
let refused = set_band("HD 600", 0, "peaking", 200.0, 1.0, 1.0).unwrap_err();
assert!(refused.contains("plays as made"), "{refused}");
assert!(add_band("HD 600").is_err());
assert!(remove_filter("HD 600", 0).is_err());
assert!(detail("HD 600").unwrap().read_only);
let lush = Imported {
name: "Lush".into(),
source: vec!["Lush.txt".into()],
filters: vec![band(80.0)],
impulses: vec![],
};
save(lush, None).unwrap();
let d = detail("Lush").unwrap();
assert!(!d.read_only && d.can_revert && !d.edited);
set_band("Lush", 0, "peaking", 80.0, 12.0, 1.0).unwrap();
assert!(detail("Lush").unwrap().edited);
let copy = duplicate("Lush", None).unwrap();
assert_eq!(copy, "Lush copy");
let c = detail(©).unwrap();
assert!(!c.can_revert && c.devices.is_empty());
assert_eq!(c.filters, detail("Lush").unwrap().filters, "as it is now");
revert("Lush").unwrap();
assert!(!detail("Lush").unwrap().edited);
assert!(revert(©).is_err(), "made here");
assert!(duplicate("Lush", Some("Lush copy")).is_err(), "taken");
}
#[test]
fn old_stacks_become_presets() {
use crate::config::{DspLayer, DspTarget};
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
let layer = |p: &str, on: bool| DspLayer {
profile: p.into(),
on,
};
persist(|c| {
for name in ["HD 600", "HD 650"] {
c.dsp.profiles.push(DspProfile {
name: name.into(),
filters: vec![band(100.0)],
target: Some(DspTarget {
made_for: "harman-over-ear-2018".into(),
chosen: None,
}),
..Default::default()
});
}
for (name, hz) in [("Warm", 60.0), ("Bright", 8000.0)] {
c.dsp.profiles.push(DspProfile {
name: name.into(),
filters: vec![band(hz)],
..Default::default()
});
}
c.dsp.tunings.push(crate::config::DspOutputTuning {
device: "DAC".into(),
tuning: "Bright".into(),
on: true,
});
c.dsp.profiles.push(DspProfile {
name: "Quiet".into(),
devices: vec!["Amp".into()],
layers: vec![layer("HD 650", false), layer("Warm", true)],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Saved".into(),
devices: vec!["Speaker".into()],
preset: true,
layers: vec![layer("HD 650", true), layer("Bright", true)],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Desk".into(),
devices: vec!["DAC".into()],
layers: vec![layer("HD 600", true), layer("Warm", false)],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Cans".into(),
devices: vec!["Phone".into()],
group: true,
layers: vec![layer("HD 600", false), layer("HD 650", true)],
..Default::default()
});
})
.unwrap();
migrate().unwrap();
let cfg = Config::cached();
let desk = cfg.dsp.profiles.iter().find(|p| p.name == "Desk").unwrap();
assert!(desk.preset && desk.devices.is_empty());
assert_eq!(cfg.dsp.profile_for("DAC").unwrap().name, "HD 600");
assert_eq!(
tunings_for("DAC"),
vec![("Warm".to_string(), false), ("Bright".to_string(), true)],
"the stack's EQs, then the tuning it had on top"
);
assert_eq!(preset_for("DAC"), Some(("Desk".into(), true)));
assert!(cfg.dsp.profile_for("Amp").is_none(), "switched off");
assert_eq!(tunings_for("Amp"), vec![("Warm".to_string(), true)]);
assert_eq!(preset_for("Amp"), Some(("Quiet".into(), false)));
assert_eq!(cfg.dsp.profile_for("Speaker").unwrap().name, "HD 650");
assert_eq!(tunings_for("Speaker"), vec![("Bright".to_string(), true)]);
assert_eq!(preset_for("Speaker"), Some(("Saved".into(), false)));
assert_eq!(cfg.dsp.profile_for("Phone").unwrap().name, "HD 650");
assign(Some("Saved"), "Laptop").unwrap();
assert_eq!(preset_for("Laptop"), Some(("Saved".into(), false)));
assert!(
Config::cached()
.dsp
.profiles
.iter()
.all(|p| !p.preset || p.devices.is_empty()),
"a preset sets an output, never is its correction"
);
migrate().unwrap();
assert_eq!(
Config::cached().dsp.profile_for("DAC").unwrap().name,
"HD 600",
"once"
);
}
#[test]
fn a_chain_is_set_whole_and_said() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "Wharfedale".into(),
filters: vec![band(100.0)],
role: Some(DspRole::Correction),
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Lush".into(),
filters: vec![band(60.0)],
role: Some(DspRole::Baked),
..Default::default()
});
for (name, hz) in [("Warm", 80.0), ("Air", 9000.0)] {
c.dsp.profiles.push(DspProfile {
name: name.into(),
filters: vec![band(hz)],
..Default::default()
});
}
})
.unwrap();
let dac = "Scarlett";
let eqs = ["Warm".to_string(), "Air".to_string()];
assert_eq!(
chain_view(dac).sentence,
"Scarlett is flat: the music plays untouched."
);
set_chain(dac, Some(Some("Wharfedale")), Some(&eqs)).unwrap();
let once = std::fs::read_to_string(dir.path().join("config.local.toml")).unwrap();
set_chain(dac, Some(Some("Wharfedale")), Some(&eqs)).unwrap();
let twice = std::fs::read_to_string(dir.path().join("config.local.toml")).unwrap();
assert_eq!(once, twice, "the same twice changes nothing");
let view = chain_view(dac);
assert_eq!(view.correction.as_deref(), Some("Wharfedale"));
assert_eq!(view.tuning.len(), 2);
assert_eq!(
view.sentence,
"Music to Scarlett, corrected by Wharfedale, then tuned with Warm and Air."
);
let refused = set_chain(dac, Some(Some("Nope")), None).unwrap_err();
assert_eq!(
refused,
"No correction called Nope. Corrections: Wharfedale, Lush"
);
let refused = set_chain(dac, None, Some(&["Nope".to_string()])).unwrap_err();
assert_eq!(refused, "No EQ called Nope. EQs: Warm, Air");
let twice = ["Warm".to_string(), "Warm".to_string()];
let refused = set_chain(dac, Some(None), Some(&twice)).unwrap_err();
assert_eq!(refused, "Warm is in the tuning twice");
assert_eq!(
chain_view(dac).correction.as_deref(),
Some("Wharfedale"),
"a refusal changes nothing"
);
set_chain(dac, Some(Some("Lush")), Some(&eqs)).unwrap_err();
assert_eq!(chain_view(dac).correction.as_deref(), Some("Wharfedale"));
set_chain(dac, Some(Some("Lush")), Some(&[])).unwrap();
assert_eq!(
chain_view(dac).sentence,
"Music to Scarlett, corrected by Lush."
);
set_chain(dac, Some(None), None).unwrap();
assert!(chain_view(dac).flat);
}
#[test]
fn a_deleted_eq_leaves_its_presets_and_groups() {
use crate::config::DspLayer;
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
let layer = |p: &str, on: bool| DspLayer {
profile: p.into(),
on,
};
persist(|c| {
for (name, hz) in [("Warm", 60.0), ("Air", 9000.0)] {
c.dsp.profiles.push(DspProfile {
name: name.into(),
filters: vec![band(hz)],
..Default::default()
});
}
c.dsp.profiles.push(DspProfile {
name: "Evening".into(),
preset: true,
layers: vec![layer("Warm", true), layer("Air", true)],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Tastes".into(),
group: true,
layers: vec![layer("Warm", true), layer("Air", false)],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Both".into(),
layers: vec![layer("Air", true)],
..Default::default()
});
})
.unwrap();
let held = overview()
.profiles
.into_iter()
.find(|p| p.name == "Warm")
.unwrap()
.held_by;
assert_eq!(held, ["Evening", "Tastes"]);
assert_eq!(
remove("Air").unwrap_err(),
"Both plays Air: take it out of it first"
);
remove("Warm").unwrap();
let cfg = Config::cached();
let of = |n: &str| {
cfg.dsp
.profiles
.iter()
.find(|p| p.name == n)
.unwrap()
.layers
.clone()
};
assert_eq!(of("Evening"), vec![layer("Air", true)]);
assert_eq!(
of("Tastes"),
vec![layer("Air", true)],
"the group plays what is left"
);
}
#[test]
fn processing_off_becomes_flat() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "HD 600".into(),
filters: vec![band(100.0)],
role: Some(DspRole::Correction),
devices: vec!["DAC".into(), "Amp".into()],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Warm".into(),
filters: vec![band(60.0)],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Room".into(),
impulses: vec!["room.wav".into()],
devices: vec!["Monitors".into()],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Plain".into(),
filters: vec![band(300.0)],
devices: vec!["Phones".into()],
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "DAC".into(),
filters: vec![band(80.0)],
role: Some(DspRole::Tuning),
..Default::default()
});
})
.unwrap();
set_tunings("DAC", &[("Warm".into(), true)]).unwrap();
save_preset("Amp", "Speakers").unwrap();
set_enabled(false).unwrap();
migrate().unwrap();
let cfg = Config::cached();
assert!(cfg.dsp.enabled);
for device in ["DAC", "Amp"] {
assert!(cfg.dsp.profile_for(device).is_none(), "{device} is flat");
assert!(tunings_for(device).is_empty());
assert_eq!(preset_for(device), None);
}
let kept = cfg.dsp.profiles.iter().find(|p| p.name == "DAC 2").unwrap();
assert!(kept.preset);
apply_preset("DAC", Some("DAC 2")).unwrap();
assert_eq!(
Config::cached().dsp.profile_for("DAC").unwrap().name,
"HD 600"
);
assert_eq!(tunings_for("DAC"), vec![("Warm".to_string(), true)]);
assert!(
!Config::cached()
.dsp
.profiles
.iter()
.any(|p| p.name == "Amp"),
"Amp was set from Speakers, unchanged"
);
for (device, correction) in [("Monitors", "Room"), ("Phones", "Plain")] {
assert!(Config::cached().dsp.profile_for(device).is_none());
apply_preset(device, Some(device)).unwrap();
assert_eq!(
Config::cached().dsp.profile_for(device).unwrap().name,
correction,
"back as {device}'s correction"
);
assert!(tunings_for(device).is_empty(), "{device}");
}
}
#[test]
fn an_output_plays_its_tuning_on_its_correction() {
use crate::config::{DspFilter, DspTarget};
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "HD 600".into(),
filters: vec![band(100.0)],
target: Some(DspTarget {
made_for: "harman-over-ear-2018".into(),
chosen: None,
}),
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Warm".into(),
filters: vec![band(60.0)],
tuned_for: Some("diffuse-field-gras-kemar".into()),
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Lush".into(),
filters: vec![band(200.0)],
role: Some(DspRole::Baked),
..Default::default()
});
let harman = || {
Some(DspTarget {
made_for: "harman-over-ear-2018".into(),
chosen: None,
})
};
c.dsp.profiles.push(DspProfile {
name: "HD 650".into(),
filters: vec![band(110.0)],
target: harman(),
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Bright".into(),
filters: vec![band(8000.0)],
tuned_for: Some("harman-over-ear-2018".into()),
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Room".into(),
impulses: vec!["dsp/room/48000.wav".into()],
..Default::default()
});
let curve = |db: f64| {
DspFilter::Graphic(crate::config::GraphicEq {
points: vec![(20.0, 0.0), (1000.0, db), (20_000.0, 0.0)],
channels: vec![],
})
};
c.dsp.profiles.push(DspProfile {
name: "Curved".into(),
filters: vec![curve(2.0)],
target: harman(),
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Tilt".into(),
filters: vec![curve(-1.0)],
tuned_for: Some("diffuse-field-gras-kemar".into()),
..Default::default()
});
})
.unwrap();
let dac = "Desk DAC";
let shown = || {
super::super::output_chain(&Config::cached().dsp, dac)
.unwrap()
.name
};
let plays = || -> Vec<String> {
let cfg = Config::cached();
let Some(chain) = super::super::output_chain(&cfg.dsp, dac) else {
return Vec::new();
};
super::super::chain(&chain.profile, &chain.all, &mut Vec::new())
.unwrap()
.into_iter()
.map(|f| match f {
DspFilter::Band(b) => format!("{}", b.freq),
DspFilter::Graphic(_) => "step".into(),
other => panic!("{other:?}"),
})
.collect()
};
assign(Some("HD 600"), dac).unwrap();
assert_eq!(plays(), ["100"]);
set_tuning(dac, Some("Warm")).unwrap();
assert_eq!(tuning_for(dac).as_deref(), Some("Warm"));
assert_eq!(plays(), ["step", "60", "100"]);
set_tuned_for("Warm", Some("harman-over-ear-2018")).unwrap();
assert_eq!(
detail("Warm").unwrap().tuned_for.as_deref(),
Some("harman-over-ear-2018")
);
assert_eq!(plays(), ["60", "100"]);
set_tuned_for("Warm", None).unwrap();
assert_eq!(plays(), ["60", "100"]);
set_tuned_for("Warm", Some("harman-in-ear-2019")).unwrap();
assert_eq!(plays(), ["60", "100"]);
assert!(set_tuned_for("Warm", Some("made-up")).is_err());
let refused = set_tuning(dac, Some("HD 600")).unwrap_err();
assert!(refused.contains("corrects headphones"), "{refused}");
assign(Some("Lush"), dac).unwrap();
assert_eq!(plays(), ["200"], "the tuning waits");
assert_eq!(
overview_for(Some(dac.into())).left_out.as_deref(),
Some("Warm is left out: Lush already includes a tuning."),
"and says why"
);
assert_eq!(
set_tuning(dac, Some("Warm")).unwrap_err(),
"Lush already includes a tuning. Split it into a correction and an EQ to change the tuning."
);
make_group("Cans", &["HD 600".into(), "HD 650".into()]).unwrap();
assign(Some("Cans"), dac).unwrap();
set_tuning(dac, Some("Warm")).unwrap();
set_tuned_for("Warm", Some("diffuse-field-gras-kemar")).unwrap();
assert_eq!(plays(), ["step", "60", "100"]);
select("Cans", "HD 650").unwrap();
assert_eq!(plays(), ["step", "60", "110"]);
assert_eq!(shown(), "Cans");
make_group("Tastes", &["Warm".into(), "Bright".into()]).unwrap();
set_tuning(dac, Some("Tastes")).unwrap();
assert_eq!(plays(), ["step", "60", "110"]);
select("Tastes", "Bright").unwrap();
assert_eq!(plays(), ["8000", "110"], "made against Harman, on Harman");
set_tuning(dac, Some("Warm")).unwrap();
remove("Tastes").unwrap();
let refused = set_tuning(dac, Some("Room")).unwrap_err();
assert!(refused.contains("impulse responses"), "{refused}");
assign(Some("Curved"), dac).unwrap();
set_tuning(dac, Some("Tilt")).unwrap();
let cfg = Config::cached();
let chain = super::super::output_chain(&cfg.dsp, dac).unwrap();
let played = super::super::chain(&chain.profile, &chain.all, &mut Vec::new()).unwrap();
let curved = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == "Curved")
.unwrap();
assert_eq!(played.len(), 2, "the tuning's curve and the correction's");
assert_eq!(played.last(), curved.filters.first());
let o = overview_for(Some(dac.into()));
assert!(o.tuning_plays);
assert!(
o.left_out
.as_deref()
.is_some_and(|l| l.starts_with("Tilt plays without the target difference")),
"{:?}",
o.left_out
);
set_tuning(dac, Some("Warm")).unwrap();
let spread = |n: usize, from: f64| -> Vec<DspFilter> {
(0..n)
.map(|i| {
DspFilter::Band(crate::config::EqFilter {
kind: crate::config::EqFilterKind::Peaking,
freq: from + i as f64,
gain_db: 1.0,
q: 1.0,
channels: vec![(i % 4) as u16],
})
})
.collect()
};
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "Busy".into(),
filters: spread(200, 100.0),
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "Base".into(),
filters: spread(100, 1000.0),
..Default::default()
});
let hd = c
.dsp
.profiles
.iter_mut()
.find(|p| p.name == "HD 600")
.unwrap();
hd.layers = vec![crate::config::DspLayer {
profile: "Base".into(),
on: true,
}];
})
.unwrap();
assign(Some("HD 600"), dac).unwrap();
set_tuning(dac, Some("Busy")).unwrap();
assert_eq!(plays().len(), 101, "the correction alone");
let o = overview_for(Some(dac.into()));
assert!(!o.tuning_plays);
assert!(
o.left_out
.as_deref()
.is_some_and(|l| l.starts_with("Busy is left out")),
"{:?}",
o.left_out
);
assign(Some("Curved"), dac).unwrap();
set_tuning(dac, Some("Warm")).unwrap();
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "Heavy".into(),
layers: ["Busy", "Base"]
.map(|p| crate::config::DspLayer {
profile: p.into(),
on: true,
})
.to_vec(),
..Default::default()
});
})
.unwrap();
assign(None, dac).unwrap();
set_tuning(dac, Some("Heavy")).unwrap();
assert!(plays().is_empty());
let o = overview_for(Some(dac.into()));
assert!(!o.tuning_plays);
assert!(
o.left_out
.as_deref()
.is_some_and(|l| l.starts_with("Heavy is left out")),
"{:?}",
o.left_out
);
set_tuning(dac, Some("Warm")).unwrap();
assign(None, dac).unwrap();
assert_eq!(plays(), ["60"]);
assert_eq!(shown(), "Warm");
rename("Warm", "Warm bass").unwrap();
assert_eq!(tuning_for(dac).as_deref(), Some("Warm bass"));
remove("Warm bass").unwrap();
assert_eq!(tuning_for(dac), None);
assert!(plays().is_empty());
}
#[test]
fn a_baked_preset_is_the_correction() {
use crate::config::{DspLayer, DspTarget};
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
let layer = |p: &str, on: bool| DspLayer {
profile: p.into(),
on,
};
persist(|c| {
let autoeq = || {
Some(DspTarget {
made_for: "harman-in-ear-2019".into(),
chosen: None,
})
};
c.dsp.profiles.push(DspProfile {
name: "Cantor".into(),
filters: vec![band(100.0)],
target: autoeq(),
..Default::default()
});
c.dsp.profiles.push(DspProfile {
name: "HD 600".into(),
filters: vec![band(100.0)],
target: autoeq(),
..Default::default()
});
for name in ["Performer 8S", "Warm"] {
c.dsp.profiles.push(DspProfile {
name: name.into(),
filters: vec![band(200.0)],
..Default::default()
});
}
c.dsp.profiles.push(DspProfile {
name: "Old".into(),
layers: vec![layer("Cantor", true), layer("Performer 8S", true)],
..Default::default()
});
})
.unwrap();
let folder = super::dir("Cantor");
std::fs::create_dir_all(&folder).unwrap();
let mut csv = String::from("frequency,raw,target\n");
for hz in super::super::targets::grid() {
csv.push_str(&format!("{hz:.2},0.0,0.0\n"));
}
std::fs::write(super::super::targets::result_path(&folder), csv).unwrap();
let cfg = Config::cached();
let named = |n: &str| cfg.dsp.profiles.iter().find(|p| p.name == n).unwrap();
assert_eq!(
role(named("Cantor")),
DspRole::Correction,
"an AutoEQ install"
);
assert_eq!(role(named("Warm")), DspRole::Tuning, "anything else");
assert_eq!(
shown_role(named("Old"), &cfg.dsp.profiles),
DspRole::Correction,
"a stack is what it corrects with"
);
assert_eq!(corrects_twice(named("Old"), &cfg.dsp.profiles), None);
set_role("Performer 8S", DspRole::Baked).unwrap();
let d = detail("Old").unwrap();
assert_eq!(
d.corrects_twice.as_deref(),
Some(
"This EQ corrects twice: Cantor (AutoEQ) and Performer 8S (includes a tuning). Keep one."
)
);
assert_eq!(
d.layer_roles,
vec![Some(DspRole::Correction), Some(DspRole::Baked)]
);
let cfg = Config::cached();
let old = cfg.dsp.profiles.iter().find(|p| p.name == "Old").unwrap();
assert!(super::super::chain(old, &cfg.dsp.profiles, &mut Vec::new()).is_ok());
set_layers(
"Old",
vec![layer("Cantor", true), layer("Performer 8S", false)],
)
.unwrap();
let refused = set_layers(
"Old",
vec![
layer("Cantor", true),
layer("Performer 8S", false),
layer("HD 600", true),
],
)
.unwrap_err();
assert!(
refused.starts_with("This EQ already plays the correction"),
"{refused}"
);
set_layers(
"Desk",
vec![layer("Performer 8S", true), layer("Warm", true)],
)
.unwrap();
let s = summary("Desk").unwrap();
assert_eq!(
s.correction.as_deref(),
Some("Performer 8S (correction + tuning in one)")
);
assert!(s.baked);
assert_eq!(s.tunings, vec!["Warm".to_string()]);
let refused = set_layers(
"Desk",
vec![
layer("Performer 8S", true),
layer("Warm", true),
layer("Cantor", true),
],
)
.unwrap_err();
assert_eq!(
refused,
"This EQ already plays the correction Performer 8S. Take that out first, or add Cantor to a device's tuning instead."
);
}
#[test]
fn autoeq_moved_to_another_target_is_rebuilt() {
use crate::config::{DspFilter, DspTarget};
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
config::set_config_dir(dir.path());
let folder = super::dir("HD 650");
std::fs::create_dir_all(&folder).unwrap();
let mut csv = String::from("frequency,raw,target\n");
for hz in super::super::targets::grid() {
csv.push_str(&format!("{hz:.2},0.0,0.0\n"));
}
std::fs::write(super::super::targets::result_path(&folder), csv).unwrap();
persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "HD 650".into(),
filters: vec![band(100.0), band(1000.0)],
target: Some(DspTarget {
made_for: "harman-over-ear-2018".into(),
chosen: None,
}),
..Default::default()
})
})
.unwrap();
let chain = || {
let cfg = Config::cached();
let p = cfg.dsp.profiles[0].clone();
super::super::chain(&p, &cfg.dsp.profiles, &mut Vec::new()).unwrap()
};
assert_eq!(chain().len(), 2, "AutoEQ's bands, as made");
choose_target("HD 650", Some("harman-over-ear-2018-without-bass")).unwrap();
let rebuilt = chain();
assert_eq!(rebuilt.len(), 1, "{rebuilt:?}");
assert!(matches!(rebuilt[0], DspFilter::Graphic(_)));
assert!(
summary("HD 650")
.unwrap()
.correction
.unwrap()
.contains("rebuilt from AutoEQ's measurement")
);
}
}