use std::path::{Path, PathBuf};
use crate::config::{self, GraphicEq};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ear {
Over,
In,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Target {
pub id: &'static str,
pub name: &'static str,
pub does: &'static str,
pub character: &'static str,
pub ear: Ear,
data: &'static str,
}
pub const TARGETS: &[Target] = &[
Target {
id: "diffuse-field-gras-kemar",
name: "Neutral (diffuse field)",
does: "flat, no preference",
character: "Neutral: sound arriving evenly from every direction, as a room without reflections would give, with no bass or treble preference. Brighter than Harman.",
ear: Ear::Over,
data: include_str!("targets/diffuse-field-gras-kemar.csv"),
},
Target {
id: "harman-over-ear-2018",
name: "Harman over-ear 2018",
does: "neutral plus Harman's bass shelf and brighter treble",
character: "What most listeners in Harman's research preferred: a warm bass shelf, a forward upper midrange and a soft top end.",
ear: Ear::Over,
data: include_str!("targets/harman-over-ear-2018.csv"),
},
Target {
id: "harman-over-ear-2018-without-bass",
name: "Harman over-ear 2018, no bass shelf",
does: "Harman's treble, no extra bass",
character: "Harman's curve with a flat low end: leaner bass, the same mids and treble.",
ear: Ear::Over,
data: include_str!("targets/harman-over-ear-2018-without-bass.csv"),
},
Target {
id: "oratory1990-over-ear",
name: "oratory1990 over-ear",
does: "oratory1990's preference, close to Harman's",
character: "oratory1990's target for over-ears, close to Harman's.",
ear: Ear::Over,
data: include_str!("targets/oratory1990-over-ear.csv"),
},
Target {
id: "diffuse-field-iso-11904-1",
name: "Neutral (diffuse field)",
does: "flat, no preference",
character: "Neutral: the ear's response to sound arriving evenly from every direction (ISO 11904-1), with no bass or treble preference.",
ear: Ear::In,
data: include_str!("targets/diffuse-field-iso-11904-1.csv"),
},
Target {
id: "harman-in-ear-2019",
name: "Harman in-ear 2019",
does: "neutral plus Harman's bass shelf and brighter treble",
character: "Harman's in-ear preference target: a bigger bass shelf than over-ear, and more treble.",
ear: Ear::In,
data: include_str!("targets/harman-in-ear-2019.csv"),
},
Target {
id: "harman-in-ear-2019-without-bass",
name: "Harman in-ear 2019, no bass shelf",
does: "Harman's treble, no extra bass",
character: "Harman's in-ear curve with a flat low end.",
ear: Ear::In,
data: include_str!("targets/harman-in-ear-2019-without-bass.csv"),
},
Target {
id: "autoeq-in-ear",
name: "AutoEQ in-ear",
does: "AutoEQ's own in-ear neutral",
character: "AutoEQ's own in-ear target.",
ear: Ear::In,
data: include_str!("targets/autoeq-in-ear.csv"),
},
Target {
id: "oratory1990-in-ear",
name: "oratory1990 in-ear",
does: "oratory1990's preference",
character: "oratory1990's target for in-ears.",
ear: Ear::In,
data: include_str!("targets/oratory1990-in-ear.csv"),
},
];
const SAME_TARGET_DB: f64 = 1.5;
const MAX_DB: f64 = 12.0;
const MARGIN_DB: f64 = 0.3;
const LEVEL_LIMIT_DB: f64 = 40.0;
const FILE_CAP: u64 = 1 << 20;
impl Ear {
pub fn of_result_path(path: &str) -> Option<Self> {
let lower = path.to_ascii_lowercase();
if lower.contains("in-ear") || lower.contains("earbud") {
Some(Ear::In)
} else if lower.contains("over-ear") || lower.contains("on-ear") {
Some(Ear::Over)
} else {
None
}
}
}
pub type Curve = Vec<(f64, f64)>;
pub fn parse(text: &str) -> Curve {
let mut curve = points(text);
curve.retain(|(_, db)| db.abs() <= LEVEL_LIMIT_DB);
curve
}
pub(crate) fn points(text: &str) -> Curve {
let mut curve: Curve = text
.lines()
.filter_map(|line| {
let mut fields = line
.split(|c: char| c == ',' || c == ';' || c.is_whitespace())
.filter(|f| !f.is_empty());
let hz: f64 = fields.next()?.parse().ok()?;
let db: f64 = fields.next()?.parse().ok()?;
(hz > 0.0 && hz.is_finite() && db.is_finite()).then_some((hz, db))
})
.collect();
curve.sort_by(|a, b| a.0.total_cmp(&b.0));
curve.dedup_by(|a, b| a.0 == b.0);
curve
}
impl Target {
pub fn curve(&self) -> Curve {
parse(self.data)
}
}
pub fn shipped(id: &str) -> Option<&'static Target> {
TARGETS.iter().find(|t| t.id == id)
}
pub(crate) fn at(curve: &[(f64, f64)], hz: f64) -> f64 {
let (Some(first), Some(last)) = (curve.first(), curve.last()) else {
return 0.0;
};
if hz <= first.0 {
return first.1;
}
if hz >= last.0 {
return last.1;
}
let i = curve.partition_point(|p| p.0 <= hz);
let ((f0, g0), (f1, g1)) = (curve[i - 1], curve[i]);
g0 + (g1 - g0) * (hz / f0).ln() / (f1 / f0).ln()
}
pub fn grid() -> Vec<f64> {
TARGETS[0].curve().into_iter().map(|(hz, _)| hz).collect()
}
fn levelled(curve: &[(f64, f64)], grid: &[f64]) -> Vec<f64> {
let k = at(curve, 1000.0);
grid.iter().map(|&hz| at(curve, hz) - k).collect()
}
pub fn identify(result_target: &[(f64, f64)], ear: Ear) -> Option<&'static Target> {
if result_target.len() < 2
|| result_target
.iter()
.any(|(f, d)| !f.is_finite() || !d.is_finite())
{
return None;
}
let grid = grid();
let theirs = levelled(result_target, &grid);
let rms = |t: &Target| {
let ours = levelled(&t.curve(), &grid);
let sum: f64 = theirs.iter().zip(&ours).map(|(a, b)| (a - b).powi(2)).sum();
(sum / grid.len() as f64).sqrt()
};
let mut near: Vec<(f64, &'static Target)> = TARGETS
.iter()
.filter(|t| t.ear == ear)
.map(|t| (rms(t), t))
.collect();
near.sort_by(|a, b| a.0.total_cmp(&b.0));
match near.as_slice() {
[(best, t), rest @ ..]
if *best <= SAME_TARGET_DB
&& rest
.first()
.is_none_or(|(next, _)| next - best >= MARGIN_DB) =>
{
Some(t)
}
_ => None,
}
}
pub fn difference(from: &[(f64, f64)], to: &[(f64, f64)]) -> GraphicEq {
let grid = grid();
let (a, b) = (levelled(from, &grid), levelled(to, &grid));
let delta: Vec<f64> = b
.iter()
.zip(&a)
.map(|(t, f)| t - f)
.map(|d| if d.is_finite() { d } else { 0.0 })
.collect();
let smoothed: Vec<f64> = grid
.iter()
.map(|&hz| {
let (lo, hi) = (hz / 2f64.powf(1.0 / 24.0), hz * 2f64.powf(1.0 / 24.0));
let near: Vec<f64> = grid
.iter()
.zip(&delta)
.filter(|(f, _)| (lo..=hi).contains(*f))
.map(|(_, d)| *d)
.collect();
near.iter().sum::<f64>() / near.len().max(1) as f64
})
.collect();
let mut points = Vec::new();
let mut next = 20.0;
for (&hz, &db) in grid.iter().zip(&smoothed) {
if hz >= next || Some(&hz) == grid.last() {
let db = if db.is_nan() { 0.0 } else { db };
points.push((hz, db.clamp(-MAX_DB, MAX_DB)));
next = hz * 2f64.powf(1.0 / 12.0);
}
}
GraphicEq {
points,
channels: Vec::new(),
}
}
fn added_dir() -> PathBuf {
config::config_dir().join("dsp").join("targets")
}
const ADDED: &str = "added:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Added {
pub id: String,
pub name: String,
}
pub fn added() -> Vec<Added> {
let Ok(dir) = std::fs::read_dir(added_dir()) else {
return Vec::new();
};
let mut out: Vec<Added> = dir
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.path().file_stem()?.to_string_lossy().into_owned();
(e.path().extension()? == "csv").then(|| Added {
id: format!("{ADDED}{name}"),
name,
})
})
.collect();
out.sort_by_key(|a| a.name.to_lowercase());
out
}
pub fn add(path: &Path) -> Result<Added, String> {
use std::io::Read as _;
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut bytes = Vec::new();
file.take(FILE_CAP + 1)
.read_to_end(&mut bytes)
.map_err(|e| format!("{}: {e}", path.display()))?;
if bytes.len() as u64 > FILE_CAP {
return Err("A target file is a few kilobytes; this one is over a megabyte".into());
}
let text = String::from_utf8_lossy(&bytes);
let curve = covering(&text, "A target")?;
let name: String = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "Target".into())
.chars()
.filter(|c| !matches!(c, '/' | '\\' | ':'))
.collect();
let name = name.trim().to_owned();
if name.is_empty() || name.starts_with('.') {
return Err("The file needs a name to call the target by".into());
}
let dir = added_dir();
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
std::fs::write(dir.join(format!("{name}.csv")), on_grid(&curve)).map_err(|e| e.to_string())?;
Ok(Added {
id: format!("{ADDED}{name}"),
name,
})
}
pub fn covering(text: &str, what: &str) -> Result<Curve, String> {
if text.len() as u64 > FILE_CAP {
return Err(format!(
"{what} is a few kilobytes; this is over a megabyte"
));
}
let mut curve = points(text);
let k = at(&curve, 1000.0);
for (_, db) in &mut curve {
*db -= k;
}
curve.retain(|(_, db)| db.abs() <= LEVEL_LIMIT_DB);
let (Some(first), Some(last)) = (curve.first(), curve.last()) else {
return Err("No frequency and level pairs in it".into());
};
if curve.len() < 20 || first.0 > 100.0 || last.0 < 10_000.0 {
return Err(format!(
"{what} needs points from below 100 Hz to above 10 kHz, at least twenty of them"
));
}
Ok(curve)
}
pub fn on_grid(curve: &[(f64, f64)]) -> String {
let mut out = String::from("frequency,raw\n");
for hz in grid() {
out.push_str(&format!("{hz:.2},{:.2}\n", at(curve, hz)));
}
out
}
pub fn measurement_path(dsp_dir: &Path) -> PathBuf {
dsp_dir.join("measurement.csv")
}
pub fn measurement(dsp_dir: &Path) -> Option<Curve> {
let text = std::fs::read_to_string(measurement_path(dsp_dir)).ok()?;
Some(parse(&text)).filter(|c| !c.is_empty())
}
const TREBLE_FROM: f64 = 6_000.0;
const TREBLE_AT: f64 = 10_000.0;
const TREBLE_DB: f64 = 3.0;
pub fn correction(measurement: &[(f64, f64)], target: &[(f64, f64)]) -> GraphicEq {
let mut g = difference(measurement, target);
for (hz, db) in &mut g.points {
let limit = if *hz <= TREBLE_FROM {
MAX_DB
} else if *hz >= TREBLE_AT {
TREBLE_DB
} else {
let t = (*hz / TREBLE_FROM).ln() / (TREBLE_AT / TREBLE_FROM).ln();
MAX_DB + (TREBLE_DB - MAX_DB) * t
};
*db = db.clamp(-limit, limit);
}
g
}
pub fn autoeq_measurement(dsp_dir: &Path) -> Option<(Curve, Curve)> {
let text = std::fs::read_to_string(result_path(dsp_dir)).ok()?;
let (raw, target) = (result_column(&text, "raw"), result_column(&text, "target"));
(!raw.is_empty() && !target.is_empty()).then_some((raw, target))
}
pub fn moved(target: &[(f64, f64)], step: &GraphicEq) -> Curve {
target
.iter()
.map(|&(hz, db)| (hz, db + at(&step.points, hz)))
.collect()
}
pub fn same_ear(a: &str, b: &str) -> bool {
match (shipped(a), shipped(b)) {
(Some(a), Some(b)) => a.ear == b.ear,
_ => true,
}
}
pub fn choice_curve(id: &str) -> Option<Curve> {
if let Some(t) = shipped(id) {
return Some(t.curve());
}
let name = id.strip_prefix(ADDED)?;
if name.contains(['/', '\\']) || name.starts_with('.') {
return None;
}
let text = std::fs::read_to_string(added_dir().join(format!("{name}.csv"))).ok()?;
Some(parse(&text)).filter(|c| !c.is_empty())
}
pub fn result_path(dsp_dir: &Path) -> PathBuf {
dsp_dir.join("autoeq.csv")
}
pub fn result_column(text: &str, column: &str) -> Curve {
let mut lines = text.lines();
let Some(header) = lines.next() else {
return Vec::new();
};
let names: Vec<&str> = header.split(',').map(str::trim).collect();
let (Some(f), Some(c)) = (
names.iter().position(|n| *n == "frequency"),
names.iter().position(|n| *n == column),
) else {
return Vec::new();
};
lines
.filter_map(|line| {
let fields: Vec<&str> = line.split(',').collect();
let hz: f64 = fields.get(f)?.trim().parse().ok()?;
let db: f64 = fields.get(c)?.trim().parse().ok()?;
Some((hz, db))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_target_is_on_autoeqs_grid() {
let grid = grid();
assert_eq!(grid.len(), 695);
assert_eq!(grid[0], 20.0);
assert!(*grid.last().unwrap() > 19_900.0);
for t in TARGETS {
let c = t.curve();
assert_eq!(c.len(), 695, "{}", t.id);
assert!(c.iter().zip(&grid).all(|((f, _), g)| f == g), "{}", t.id);
}
}
#[test]
fn a_measurement_in_db_spl_is_read() {
let mut text = String::from("Frequency(Hz), SPL(dB)\n");
for hz in grid() {
let db = 92.0 + if hz < 100.0 { 6.0 } else { 0.0 };
text.push_str(&format!("{hz:.2}, {db:.1}\n"));
}
let curve = covering(&text, "A measurement").unwrap();
assert_eq!(curve.len(), 695);
assert!(at(&curve, 1000.0).abs() < 1e-9);
assert!((at(&curve, 50.0) - 6.0).abs() < 1e-9);
assert!(
covering("20, 92.4\n21, 92.6\n", "A measurement").is_err(),
"too few"
);
}
#[test]
fn a_result_is_known_by_its_target_and_its_rigs_offset_is_tolerated() {
let harman = shipped("harman-over-ear-2018").unwrap();
assert_eq!(
identify(&harman.curve(), Ear::Over).map(|t| t.id),
Some(harman.id)
);
let rig: Curve = harman
.curve()
.into_iter()
.map(|(hz, db)| {
let x = (hz / 6000.0).log2();
(hz, db - 3.0 * (-x * x * 4.0).exp())
})
.collect();
assert_eq!(identify(&rig, Ear::Over).map(|t| t.id), Some(harman.id));
let no_bass = shipped("harman-over-ear-2018-without-bass").unwrap();
assert_eq!(
identify(&no_bass.curve(), Ear::Over).map(|t| t.id),
Some(no_bass.id)
);
let tilted: Curve = harman
.curve()
.into_iter()
.map(|(hz, db)| (hz, db + 6.0 * (hz / 1000.0).log2()))
.collect();
assert_eq!(identify(&tilted, Ear::Over), None);
}
#[test]
fn identification_keeps_to_the_ear_and_refuses_to_guess() {
let in_ear = shipped("harman-in-ear-2019").unwrap();
assert_eq!(
identify(&in_ear.curve(), Ear::In).map(|t| t.id),
Some(in_ear.id)
);
let autoeq = shipped("autoeq-in-ear").unwrap();
assert_eq!(
identify(&autoeq.curve(), Ear::In).map(|t| t.id),
Some(autoeq.id)
);
let with = shipped("harman-over-ear-2018").unwrap().curve();
let without = shipped("harman-over-ear-2018-without-bass")
.unwrap()
.curve();
let between: Curve = with
.iter()
.zip(&without)
.map(|((f, a), (_, b))| (*f, (a + b) / 2.0))
.collect();
assert_eq!(identify(&between, Ear::Over), None);
assert_eq!(
Ear::of_result_path("crinacle/711 in-ear/1Custom SA02"),
Some(Ear::In)
);
assert_eq!(
Ear::of_result_path("oratory1990/over-ear/Sennheiser HD 650"),
Some(Ear::Over)
);
assert_eq!(
Ear::of_result_path("Rtings/HMS II.3 over-ear/X"),
Some(Ear::Over)
);
assert_eq!(Ear::of_result_path("someone/elsewhere/X"), None);
}
#[test]
fn a_hostile_curve_cannot_make_a_filter_that_is_not_a_number() {
let hostile = "20000,-3\n20,nan\n100,inf\n200,-inf\n300,1.7e308\n400,-1.7e308\n500,-50\n1000,0\n50,2\n";
let curve = parse(hostile);
assert_eq!(curve, vec![(50.0, 2.0), (1000.0, 0.0), (20000.0, -3.0)]);
let from = shipped("harman-over-ear-2018").unwrap().curve();
let g = difference(
&from,
&[(20.0, f64::MAX), (21.0, -f64::MAX), (20000.0, 0.0)],
);
assert!(g.points.iter().all(|(f, d)| f.is_finite() && d.is_finite()));
}
#[test]
fn the_difference_is_the_targets_apart_and_keeps_the_rigs_part() {
let from = shipped("harman-over-ear-2018").unwrap().curve();
let to = shipped("harman-over-ear-2018-without-bass")
.unwrap()
.curve();
let g = difference(&from, &to);
let level = |hz: f64| at(&g.points, hz);
assert!(level(1000.0).abs() < 0.2, "level at 1 kHz");
assert!(
level(30.0) < -3.0,
"the bass shelf comes off: {}",
level(30.0)
);
assert!(level(4000.0).abs() < 0.5, "the rest is alike");
assert!((110..=125).contains(&g.points.len()), "{}", g.points.len());
assert!(g.points.iter().all(|(_, db)| db.abs() <= MAX_DB));
assert!(
difference(&from, &from)
.points
.iter()
.all(|(_, db)| db.abs() < 1e-9)
);
}
#[test]
fn curves_are_read_from_csv_or_squiglink_text() {
let csv = "frequency,raw\n20,1.5\n1000,0\n20000,-3\n";
assert_eq!(
parse(csv),
vec![(20.0, 1.5), (1000.0, 0.0), (20000.0, -3.0)]
);
let squig = "* squig.link target\n20\t1.5\n1000\t0\n20000 -3\n";
assert_eq!(parse(squig), parse(csv));
let result = "frequency,raw,target\n20,-6,3.3\n1000,0,0\n";
assert_eq!(
result_column(result, "target"),
vec![(20.0, 3.3), (1000.0, 0.0)]
);
}
#[test]
fn an_added_target_is_kept_on_the_grid_and_read_back() {
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 file = dir.path().join("Super Warm.txt");
let mut text = String::from("Frequency\tdB\n");
for i in 0..40 {
let hz = 20.0 * 2f64.powf(i as f64 / 4.0);
text.push_str(&format!("{hz}\t{}\n", 10.0 - i as f64 / 4.0));
}
std::fs::write(&file, text).unwrap();
let added = add(&file).unwrap();
assert_eq!(added.name, "Super Warm");
assert_eq!(super::added(), vec![added.clone()]);
let curve = choice_curve(&added.id).unwrap();
assert_eq!(curve.len(), 695);
assert!(choice_curve("added:../escape").is_none());
let short = dir.path().join("short.csv");
std::fs::write(&short, "100,0\n1000,0\n").unwrap();
assert!(add(&short).is_err());
}
#[test]
fn a_chosen_target_adds_one_curve_to_the_chain() {
use crate::audio::dsp::profiles;
use crate::config::{Config, DspFilter, DspProfile, DspTarget, 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());
let band = DspFilter::Band(EqFilter {
kind: EqFilterKind::Peaking,
freq: 100.0,
gain_db: 3.0,
q: 1.0,
channels: vec![],
});
Config::persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "HD 650 (AutoEQ, oratory1990)".into(),
filters: vec![band.clone()],
target: Some(DspTarget {
made_for: "harman-over-ear-2018".into(),
chosen: None,
}),
..Default::default()
})
})
.unwrap();
let name = "HD 650 (AutoEQ, oratory1990)";
let loaded = || {
let cfg = Config::cached();
let p = cfg
.dsp
.profiles
.iter()
.find(|p| p.name == name)
.unwrap()
.clone();
super::super::Setup::load(&p, &cfg.dsp.profiles, dir.path())
.unwrap()
.unwrap()
.filters
};
assert_eq!(loaded(), vec![band.clone()]);
let choices = profiles::target_choices(name).unwrap();
assert_eq!(choices.made_for.unwrap().id, "harman-over-ear-2018");
assert!(
choices
.choices
.iter()
.all(|c| shipped(&c.id).is_none_or(|t| t.ear == Ear::Over))
);
assert!(
profiles::choose_target(name, Some("harman-in-ear-2019")).is_err(),
"in-ear"
);
profiles::choose_target(name, Some("harman-over-ear-2018-without-bass")).unwrap();
let filters = loaded();
assert_eq!(filters.len(), 2);
assert_eq!(filters[0], band);
assert!(matches!(filters[1], DspFilter::Graphic(_)));
profiles::choose_target(name, Some("harman-over-ear-2018")).unwrap();
assert_eq!(loaded(), vec![band.clone()]);
profiles::choose_target(name, Some("diffuse-field-gras-kemar")).unwrap();
profiles::choose_target(name, None).unwrap();
assert_eq!(loaded(), vec![band]);
}
}