1use std::path::{Path, PathBuf};
9
10use super::import::Imported;
11use super::{Setup, convolver, raw};
12use crate::config::{self, Config, DspEar, DspMeasurement, DspProfile, DspRole, DspScope};
13
14#[derive(Debug, Clone, PartialEq)]
15pub struct Summary {
16 pub name: String,
17 pub devices: Vec<String>,
18 pub bands: usize,
19 pub layers: usize,
21 pub rates: Vec<u32>,
23 pub problem: Option<String>,
25 pub role: DspRole,
26 pub measured: bool,
28 pub members: Vec<String>,
30 pub playing: Option<String>,
31 pub preset: bool,
33 pub edited: bool,
35 pub used_on: Vec<String>,
38 pub everywhere: bool,
40 pub held_by: Vec<String>,
42 pub scope_locked: Option<String>,
44 pub graphics: usize,
46 pub points: usize,
47 pub made_for: Option<String>,
49 pub join: Option<Join>,
52}
53
54#[derive(Debug, Clone, PartialEq, serde::Serialize)]
57pub struct ChainView {
58 pub device: String,
59 pub correction: Option<String>,
60 pub target: Option<String>,
62 pub tuning: Vec<TuningView>,
63 pub preset: Option<String>,
64 pub edited: bool,
66 pub left_out: Vec<String>,
68 pub notes: Option<String>,
69 pub flat: bool,
71 pub sentence: String,
73}
74
75#[derive(Debug, Clone, PartialEq, serde::Serialize)]
76pub struct TuningView {
77 pub name: String,
78 pub on: bool,
79 pub made_for: Option<String>,
81 pub matched: Option<bool>,
85 pub join: Option<Join>,
86 pub note: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, serde::Serialize)]
92#[serde(rename_all = "snake_case", tag = "state")]
93pub enum Join {
94 Matched,
96 Converted { from: String, to: String },
99 Refitted { from: String, to: String },
103 Unknown,
106}
107
108#[derive(Debug, Clone, Default, PartialEq)]
110pub struct EqJoin {
111 pub made_for: Option<String>,
113 pub join: Option<Join>,
116 pub note: Option<String>,
117 pub step: Vec<f64>,
120 pub suggestion: Option<(String, String)>,
123}
124
125fn joins(
128 dsp: &crate::config::DspConfig,
129 device: &str,
130 notes: &[(String, String)],
131 dropped: &[String],
132 refit: Option<&str>,
133) -> Vec<EqJoin> {
134 use super::targets;
135 let all = &dsp.profiles;
136 let own = dsp.profile_for(device).and_then(|c| aims_at(c, all));
137 let aim = refit.map(str::to_owned).or_else(|| own.clone());
138 tunings_for(device)
139 .into_iter()
140 .map(|(name, on)| {
141 let made = all
142 .iter()
143 .find(|p| p.name == name)
144 .and_then(|p| super::made_against(p, all, 0));
145 let plays = dsp.enabled && on && !dropped.contains(&name);
146 let join = plays
147 .then(|| match (refit, made.as_deref(), own.as_deref()) {
148 (Some(r), Some(m), Some(o)) if r == m => Some(Join::Refitted {
149 from: target_name(o),
150 to: target_name(r),
151 }),
152 _ => meets(aim.as_deref(), made.as_deref()),
153 })
154 .flatten();
155 let said: Vec<&str> = notes
156 .iter()
157 .filter(|(eq, _)| *eq == name)
158 .map(|(_, n)| n.as_str())
159 .collect();
160 let step = match (&join, &aim, &made, dsp.profile_for(device)) {
161 (Some(Join::Converted { .. }), Some(a), Some(m), Some(c)) => {
162 targets::choice_curve(a)
163 .zip(targets::choice_curve(m))
164 .map(|(from, to)| targets::on_grid_db(&target_step(c, all, &from, &to)))
165 .unwrap_or_default()
166 }
167 _ => Vec::new(),
168 };
169 let suggestion = match (&join, &aim) {
170 (Some(Join::Unknown), Some(a)) => guess(&name, all, a),
171 _ => None,
172 };
173 EqJoin {
174 made_for: made.as_deref().map(target_name),
175 join,
176 note: (!said.is_empty()).then(|| said.join(" ")),
177 step,
178 suggestion,
179 }
180 })
181 .collect()
182}
183
184fn meets(aim: Option<&str>, made: Option<&str>) -> Option<Join> {
188 match (aim, made) {
189 (Some(a), Some(m)) if a == m => Some(Join::Matched),
190 (Some(a), Some(m)) if super::targets::same_ear(a, m) => Some(Join::Converted {
191 from: target_name(a),
192 to: target_name(m),
193 }),
194 (Some(_), None) => Some(Join::Unknown),
195 _ => None,
196 }
197}
198
199pub fn chain_view(device: &str) -> ChainView {
201 let o = overview_for(Some(device.to_owned()));
202 let cfg = Config::cached();
203 let all = &cfg.dsp.profiles;
204 let chosen = cfg.dsp.profile_for(device);
205 let target = o.aim.clone();
206 let includes = chosen.is_some_and(|c| shown_role(c, all) == DspRole::Baked);
207 let playing: Vec<&str> = if includes {
208 Vec::new()
209 } else {
210 o.chain
211 .iter()
212 .filter(|(name, on)| *on && !o.left_out_eqs.contains(name))
213 .map(|(name, _)| name.as_str())
214 .collect()
215 };
216 let meets = o
217 .chain
218 .iter()
219 .zip(&o.joins)
220 .filter(|((name, _), _)| playing.contains(&name.as_str()))
221 .filter_map(|((name, _), j)| Some(joined(name, j.join.as_ref()?, target.as_deref()?)))
222 .collect::<Vec<_>>();
223 let sentence = match (&o.active, playing.is_empty()) {
224 (None, true) => format!("{device} is flat: the music plays untouched."),
225 (active, _) => {
226 let mut parts = Vec::new();
227 if let Some(c) = active {
228 parts.push(match &target {
229 Some(t) => format!("corrected by {c} to {t}"),
230 None => format!("corrected by {c}"),
231 });
232 }
233 if !playing.is_empty() {
234 parts.push(format!("tuned with {}", and_list(&playing)));
235 }
236 let mut said = format!("Music to {device}, {}.", parts.join(", then "));
237 for j in meets {
238 said.push(' ');
239 said.push_str(&j);
240 }
241 let skipped: Vec<&str> = o
244 .chain
245 .iter()
246 .filter(|(_, on)| includes && *on)
247 .map(|(name, _)| name.as_str())
248 .collect();
249 if let (Some(c), false) = (active, skipped.is_empty()) {
250 said.push_str(&format!(
251 " Its tuning, {}, is skipped: {c} already includes one.",
252 and_list(&skipped)
253 ));
254 }
255 said
256 }
257 };
258 ChainView {
259 device: device.to_owned(),
260 flat: o.active.is_none() && o.chain.is_empty(),
261 correction: o.active,
262 target,
263 tuning: o
264 .chain
265 .into_iter()
266 .zip(o.joins)
267 .map(|((name, on), j)| TuningView {
268 name,
269 on,
270 matched: j.join.as_ref().map(|j| *j == Join::Matched),
271 made_for: j.made_for,
272 join: j.join,
273 note: j.note,
274 })
275 .collect(),
276 edited: o.preset.as_ref().is_some_and(|(_, e)| *e),
277 preset: o.preset.map(|(name, _)| name),
278 left_out: o.left_out_eqs,
279 notes: o.left_out,
280 sentence,
281 }
282}
283
284fn guess(name: &str, all: &[DspProfile], aim: &str) -> Option<(String, String)> {
287 let ear = super::targets::shipped(aim)?.ear;
288 let p = all.iter().find(|p| p.name == name)?;
289 if p.group {
292 return None;
293 }
294 let eq = response_of(p, all, 48_000)?.total;
295 super::targets::guess_made_against(&eq, ear, aim).map(|t| (t.id.to_owned(), t.name.to_owned()))
296}
297
298pub fn suggest_made_against(name: &str, device: &str) -> Option<(String, String)> {
302 let cfg = Config::cached();
303 let all = &cfg.dsp.profiles;
304 let p = all.iter().find(|p| p.name == name)?;
305 if shown_role(p, all) != DspRole::Tuning || super::made_against(p, all, 0).is_some() {
306 return None;
307 }
308 let aim = cfg.dsp.profile_for(device).and_then(|c| aims_at(c, all))?;
309 guess(name, all, &aim)
310}
311
312pub fn made_against_previews(name: &str, device: &str) -> Vec<(Option<String>, Vec<f64>)> {
319 use super::targets;
320 let cfg = Config::cached();
321 let all = &cfg.dsp.profiles;
322 let Some(correction) = cfg.dsp.profile_for(device) else {
323 return Vec::new();
324 };
325 let Some(aim) = aims_at(correction, all) else {
326 return Vec::new();
327 };
328 let Some(eq) = all
329 .iter()
330 .find(|p| p.name == name)
331 .and_then(|p| response_of(p, all, 48_000))
332 .map(|r| r.total)
333 else {
334 return Vec::new();
335 };
336 let Some(from) = targets::choice_curve(&aim) else {
337 return Vec::new();
338 };
339 let ids = targets::TARGETS
340 .iter()
341 .map(|t| t.id.to_owned())
342 .chain(targets::added().into_iter().map(|a| a.id));
343 let member = super::member_playing(correction, all);
347 let fits = member.measured().map(|m| {
348 let mut own = member.sanitized().filters;
349 if member.fitted.is_none() {
350 own.extend(fitted_to(member, &m.target).unwrap_or_default());
351 }
352 own
353 });
354 let grid = targets::grid();
355 std::iter::once((None, eq.clone()))
356 .chain(ids.filter_map(|id| {
357 let refit = fits
358 .as_ref()
359 .filter(|_| id != aim && targets::same_ear(&aim, &id))
360 .and_then(|own| Some((own, fitted_to(member, &id)?)));
361 let plays = if let Some((own, refit)) = refit {
362 let (own, refit) = (
363 super::response(own, &grid, 48_000),
364 super::response(&refit, &grid, 48_000),
365 );
366 eq.iter()
367 .zip(own.iter().zip(&refit))
368 .map(|(e, (o, r))| e + r - o)
369 .collect()
370 } else if id == aim || !targets::same_ear(&aim, &id) {
371 eq.clone()
372 } else {
373 let step = targets::on_grid_db(&target_step(
374 correction,
375 all,
376 &from,
377 &targets::choice_curve(&id)?,
378 ));
379 eq.iter().zip(&step).map(|(e, s)| e + s).collect()
380 };
381 Some((Some(id), plays))
382 }))
383 .collect()
384}
385
386pub fn joined(eq: &str, join: &Join, aim: &str) -> String {
389 match join {
390 Join::Matched => format!("{eq} was made for {aim}: matched."),
391 Join::Converted { from, to } => {
392 format!("{eq} was made for {to}, so the difference from {from} to {to} plays first.")
393 }
394 Join::Refitted { to, .. } => {
395 format!("{eq} was made for {to}, so the correction is fitted to {to} for it.")
396 }
397 Join::Unknown => {
398 format!("What {eq} was made against is not set.")
399 }
400 }
401}
402
403fn and_list(items: &[&str]) -> String {
405 match items {
406 [] => String::new(),
407 [one] => (*one).to_owned(),
408 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
409 }
410}
411
412pub fn set_chain(
417 device: &str,
418 correction: Option<Option<&str>>,
419 tuning: Option<&[String]>,
420) -> Result<(), String> {
421 let cfg = Config::cached();
422 let all = &cfg.dsp.profiles;
423 let choices = |names: Vec<&str>| {
424 if names.is_empty() {
425 "none yet".to_owned()
426 } else {
427 names.join(", ")
428 }
429 };
430 if let Some(Some(name)) = correction {
431 let corrections: Vec<&str> = all
432 .iter()
433 .filter(|p| !p.preset && shown_role(p, all).corrects())
434 .map(|p| p.name.as_str())
435 .collect();
436 if all.iter().any(|p| p.name == name && p.preset) {
437 return Err(format!(
438 "{name} is a preset, not a correction: set it with `koan dsp preset use {name:?}`"
439 ));
440 }
441 if !corrections.contains(&name) {
442 return Err(format!(
443 "No correction called {name}. Corrections: {}",
444 choices(corrections)
445 ));
446 }
447 }
448 if let Some(eqs) = tuning {
449 let valid: Vec<&str> = all
450 .iter()
451 .filter(|p| !p.preset && check_tuning(&p.name, all).is_ok())
452 .map(|p| p.name.as_str())
453 .collect();
454 for (i, eq) in eqs.iter().enumerate() {
455 if !all.iter().any(|p| &p.name == eq) {
456 return Err(format!("No EQ called {eq}. EQs: {}", choices(valid)));
457 }
458 check_tuning(eq, all)?;
459 if eqs[..i].contains(eq) {
460 return Err(format!("{eq} is in the tuning twice"));
461 }
462 }
463 let then = match correction {
465 Some(c) => c.and_then(|c| all.iter().find(|p| p.name == c)),
466 None => cfg.dsp.profile_for(device),
467 };
468 if let Some(c) = then
469 && !eqs.is_empty()
470 && shown_role(c, all) == DspRole::Baked
471 {
472 return Err(baked_already(&c.name));
473 }
474 }
475 let correction: Option<String> = match correction {
478 Some(c) => c.map(str::to_owned),
479 None => cfg.dsp.profile_for(device).map(|p| p.name.clone()),
480 };
481 let entries: Vec<(String, bool)> = match tuning {
482 Some(eqs) => eqs.iter().map(|e| (e.clone(), true)).collect(),
483 None => tunings_for(device),
484 };
485 let preset = preset_for(device).map(|(name, _)| name);
486 persist(|cfg| {
487 set_output(
488 cfg,
489 device,
490 correction.as_deref(),
491 &entries,
492 preset.as_deref(),
493 )
494 })
495}
496
497#[derive(Debug, Clone, PartialEq)]
499pub struct OutputState {
500 pub device: String,
501 pub preset: Option<(String, bool)>,
503 pub flat: bool,
505}
506
507#[derive(Debug, Clone, PartialEq)]
508pub struct Overview {
509 pub enabled: bool,
510 pub device: Option<String>,
512 pub active: Option<String>,
514 pub tuning: Option<String>,
516 pub tuning_plays: bool,
519 pub left_out: Option<String>,
521 pub left_out_eqs: Vec<String>,
523 pub tunings: Vec<(String, String)>,
525 pub chain: Vec<(String, bool)>,
527 pub joins: Vec<EqJoin>,
529 pub aim: Option<String>,
531 pub in_ear: Option<bool>,
533 pub preset: Option<(String, bool)>,
536 pub profiles: Vec<Summary>,
537 pub outputs: Vec<OutputState>,
539 pub filters_only: bool,
541}
542
543pub fn current_device() -> Option<String> {
546 let cfg = Config::cached();
547 if let Some(name) = &cfg.playback.output_device
548 && crate::audio::list_output_devices()
549 .is_ok_and(|devices| devices.iter().any(|d| &d.name == name))
550 {
551 return Some(name.clone());
552 }
553 crate::audio::default_output_device().ok().map(|d| d.name)
554}
555
556pub fn overview() -> Overview {
557 overview_for(current_device())
558}
559
560pub fn overview_for(device: Option<String>) -> Overview {
563 let cfg = Config::cached();
564 let base = config::config_dir();
565 let chain = device
566 .as_deref()
567 .and_then(|d| super::output_chain(&cfg.dsp, d));
568 let aim = device
569 .as_deref()
570 .and_then(|d| cfg.dsp.profile_for(d))
571 .and_then(|c| aims_at(c, &cfg.dsp.profiles));
572 Overview {
573 enabled: cfg.dsp.enabled,
574 filters_only: device
575 .as_deref()
576 .is_some_and(|d| cfg.dsp.filters_only.iter().any(|f| f == d)),
577 active: device
578 .as_deref()
579 .and_then(|d| cfg.dsp.profile_for(d))
580 .map(|p| p.name.clone()),
581 tuning_plays: chain.as_ref().is_some_and(|c| c.tuning_plays),
582 left_out_eqs: chain
583 .as_ref()
584 .map(|c| c.left_out_eqs.clone())
585 .unwrap_or_default(),
586 joins: match (&device, &chain) {
587 (Some(d), Some(c)) => joins(
588 &cfg.dsp,
589 d,
590 &c.eq_notes,
591 &c.left_out_eqs,
592 c.refit.as_ref().map(|(to, _)| to.as_str()),
593 ),
594 (Some(d), None) => joins(&cfg.dsp, d, &[], &[], None),
595 (None, _) => Vec::new(),
596 },
597 in_ear: aim
598 .as_deref()
599 .and_then(super::targets::shipped)
600 .and_then(|t| match t.ear {
601 super::targets::Ear::In => Some(true),
602 super::targets::Ear::Over => Some(false),
603 super::targets::Ear::Speaker => None,
604 }),
605 aim: aim.as_deref().map(target_name),
606 left_out: chain.and_then(|c| c.left_out),
607 outputs: outputs(&cfg.dsp),
608 tunings: cfg.dsp.tunings.iter().fold(Vec::new(), |mut first, t| {
609 if !first.iter().any(|(d, _): &(String, String)| d == &t.device) {
610 first.push((t.device.clone(), t.tuning.clone()));
611 }
612 first
613 }),
614 tuning: device.as_deref().and_then(tuning_for),
615 chain: device.as_deref().map(tunings_for).unwrap_or_default(),
616 preset: device.as_deref().and_then(preset_for),
617 device,
618 profiles: cfg
619 .dsp
620 .profiles
621 .iter()
622 .map(|p| {
623 let graphics: Vec<_> = p
624 .filters
625 .iter()
626 .filter_map(|f| match f {
627 config::DspFilter::Graphic(g) => Some(g),
628 _ => None,
629 })
630 .collect();
631 let made = super::made_against(p, &cfg.dsp.profiles, 0);
632 let (rates, problem) = match Setup::load(p, &cfg.dsp.profiles, &base) {
633 Ok(setup) => (setup.map(|s| s.rates()).unwrap_or_default(), None),
634 Err(e) => (Vec::new(), Some(e.to_string())),
635 };
636 Summary {
637 name: p.name.clone(),
638 devices: p.devices.clone(),
639 bands: p.filters.len(),
640 layers: p.layers.len(),
641 rates,
642 problem,
643 role: shown_role(p, &cfg.dsp.profiles),
644 measured: p.measured().is_some(),
645 members: if p.group {
646 p.layers.iter().map(|l| l.profile.clone()).collect()
647 } else {
648 Vec::new()
649 },
650 playing: p
651 .group
652 .then(|| p.layers.iter().find(|l| l.on).map(|l| l.profile.clone()))
653 .flatten(),
654 preset: p.preset,
655 edited: edited(p),
656 used_on: used_on(&cfg.dsp, &p.name),
657 held_by: cfg
658 .dsp
659 .profiles
660 .iter()
661 .filter(|q| {
662 (q.preset || q.group) && q.layers.iter().any(|l| l.profile == p.name)
663 })
664 .map(|q| q.name.clone())
665 .collect(),
666 everywhere: scope(p, &cfg.dsp.profiles) == DspScope::Everywhere,
667 scope_locked: scope_locked(p, &cfg.dsp.profiles),
668 graphics: graphics.len(),
669 points: graphics.iter().map(|g| g.points.len()).sum(),
670 made_for: made.as_deref().map(target_name),
671 join: meets(aim.as_deref(), made.as_deref()),
672 }
673 })
674 .collect(),
675 }
676}
677
678fn outputs(dsp: &crate::config::DspConfig) -> Vec<OutputState> {
681 let mut devices: Vec<&str> = Vec::new();
682 for d in dsp
683 .profiles
684 .iter()
685 .filter(|p| !p.preset)
686 .flat_map(|p| p.devices.iter())
687 .chain(dsp.tunings.iter().map(|t| &t.device))
688 .chain(dsp.presets.iter().map(|p| &p.device))
689 {
690 if !devices.contains(&d.as_str()) {
691 devices.push(d);
692 }
693 }
694 devices
695 .into_iter()
696 .map(|d| OutputState {
697 device: d.to_owned(),
698 preset: preset_for(d),
699 flat: dsp.profile_for(d).is_none() && !dsp.tunings.iter().any(|t| t.device == d),
700 })
701 .collect()
702}
703
704fn used_on(dsp: &crate::config::DspConfig, name: &str) -> Vec<String> {
707 let mut on: Vec<String> = Vec::new();
708 let correcting = dsp
709 .profiles
710 .iter()
711 .filter(|p| p.name == name && !p.preset)
712 .flat_map(|p| p.devices.iter());
713 let tuning = dsp
714 .tunings
715 .iter()
716 .filter(|t| t.tuning == name)
717 .map(|t| &t.device);
718 let preset = dsp
719 .presets
720 .iter()
721 .filter(|p| p.preset == name)
722 .map(|p| &p.device);
723 for d in correcting.chain(tuning).chain(preset) {
724 if !on.contains(d) {
725 on.push(d.clone());
726 }
727 }
728 on
729}
730
731pub fn save(imported: Imported, name: Option<&str>) -> Result<String, String> {
736 let name = name
737 .map(str::trim)
738 .filter(|n| !n.is_empty())
739 .unwrap_or(&imported.name)
740 .to_string();
741 let impulses = if imported.impulses.is_empty() {
742 None
743 } else {
744 Some(write_impulses(&name, &imported)?)
745 };
746 let filters = (!imported.filters.is_empty()).then_some(imported.filters);
747 let source = imported.source;
748 persist(|cfg| {
749 let profile = profile_mut(&mut cfg.dsp.profiles, &name);
750 for s in source {
751 if !profile.source.contains(&s) {
752 profile.source.push(s);
753 }
754 }
755 if let Some(filters) = filters {
756 profile.filters = filters;
757 profile.preamp_db = None;
759 profile.original = Some(crate::config::DspOriginal {
761 filters: profile.filters.clone(),
762 preamp_db: None,
763 });
764 }
765 if let Some(impulses) = impulses {
766 profile.impulses = impulses;
767 }
768 })
769 .map_err(|e| e.to_string())?;
770 Ok(name)
771}
772
773fn write_impulses(name: &str, imported: &Imported) -> Result<Vec<PathBuf>, String> {
774 let rel = Path::new("dsp").join(slug(name));
775 let dir = config::config_dir().join(&rel);
776 let _ = std::fs::remove_dir_all(&dir);
777 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
778 let mut paths = Vec::new();
779 for ir in &imported.impulses {
780 let shared_rate = imported
781 .impulses
782 .iter()
783 .filter(|i| i.rate == ir.rate)
784 .count()
785 > 1;
786 let stem = match (shared_rate, ir.channels) {
787 (true, Some(n)) => format!("{}-{n}ch", ir.rate),
788 _ => ir.rate.to_string(),
789 };
790 let file = match ir.as_channels() {
791 Some(channels) => {
792 let channels: Vec<Vec<f32>> = channels.into_iter().map(<[f32]>::to_vec).collect();
793 let file = format!("{stem}.wav");
794 raw::write_wav(&dir.join(&file), ir.rate, &channels).map_err(|e| e.to_string())?;
795 file
796 }
797 None => convolver::write(&dir, &stem, ir)?
798 .file_name()
799 .map(|f| f.to_string_lossy().into_owned())
800 .unwrap_or_default(),
801 };
802 paths.push(rel.join(file));
803 }
804 Ok(paths)
805}
806
807#[derive(Debug, Clone, PartialEq)]
809pub struct ImpulseDetail {
810 pub file: String,
811 pub rate: u32,
812 pub channels: Option<usize>,
814 pub taps: usize,
815 pub routes: usize,
816 pub mixes: bool,
819 pub delayed: bool,
821 pub peak_ms: f64,
823}
824
825#[derive(Debug, Clone, PartialEq)]
826pub struct Detail {
827 pub name: String,
828 pub devices: Vec<String>,
829 pub source: Vec<String>,
830 pub filters: Vec<crate::config::DspFilter>,
831 pub impulses: Vec<ImpulseDetail>,
832 pub preamp_db: f64,
834 pub preamp_rate: u32,
835 pub preamp_set: bool,
837 pub problem: Option<String>,
838 pub layers: Vec<crate::config::DspLayer>,
840 pub group: bool,
842 pub everywhere: bool,
844 pub scope_set: bool,
846 pub role: DspRole,
847 pub role_set: bool,
848 pub corrects_twice: Option<String>,
851 pub corrects: Option<String>,
853 pub corrects_baked: bool,
854 pub tunings: Vec<String>,
855 pub layer_roles: Vec<Option<DspRole>>,
857 pub measured: bool,
858 pub made_for: Option<String>,
859 pub tuned_for: Option<String>,
861 pub preset: bool,
863 pub edited: bool,
865 pub can_revert: bool,
867 pub read_only: bool,
869 pub channels: Option<ChannelLayout>,
871}
872
873pub fn detail(name: &str) -> Option<Detail> {
875 let cfg = Config::cached();
876 let profile = cfg.dsp.profiles.iter().find(|p| p.name == name)?;
877 let base = config::config_dir();
878 let mut problem = None;
879 let mut impulses = Vec::new();
880 for path in &profile.impulses {
881 match super::load_impulses(path, &base) {
882 Ok(irs) => impulses.extend(irs.into_iter().map(|ir| {
883 ImpulseDetail {
884 file: path
885 .file_name()
886 .map(|f| f.to_string_lossy().into_owned())
887 .unwrap_or_default(),
888 rate: ir.rate,
889 channels: ir.channels,
890 taps: ir.taps(),
891 routes: ir.routes.len(),
892 mixes: ir.mixes(),
893 delayed: ir.in_delays.iter().chain(&ir.out_delays).any(|&d| d > 0),
894 peak_ms: ir.delay() as f64 * 1000.0 / ir.rate as f64,
895 }
896 })),
897 Err(e) => problem = Some(e.to_string()),
898 }
899 }
900 impulses.sort_by_key(|i| i.rate);
901 let preamp_rate = impulses
902 .iter()
903 .map(|i| i.rate)
904 .find(|&r| r == 48000)
905 .or(impulses.first().map(|i| i.rate))
906 .unwrap_or(48000);
907 let mut adjusted = profile.clone().sanitize();
909 match super::chain_noted(profile, &cfg.dsp.profiles, &mut Vec::new()) {
910 Ok((_, notes)) => {
911 for n in notes {
912 if !adjusted.contains(&n) {
913 adjusted.push(n);
914 }
915 }
916 }
917 Err(e) => problem = Some(e.to_string()),
918 }
919 let setup = Setup::load(profile, &cfg.dsp.profiles, &base)
920 .ok()
921 .flatten();
922 if let Some(cut) = setup.as_ref().and_then(|s| s.headroom_cut(preamp_rate, 2)) {
923 adjusted.push(format!("preamp −{cut:.1} dB for headroom"));
924 }
925 if !adjusted.is_empty() {
926 let note = format!("Adjusted: {}", adjusted.join("; "));
927 problem = Some(problem.map_or(note.clone(), |p| format!("{p}. {note}")));
928 }
929 let preamp_db = setup.map_or(0.0, |s| s.preamp_db(preamp_rate, 2));
930 let chain_summary = summary(name).unwrap_or_default();
931 Some(Detail {
932 name: profile.name.clone(),
933 devices: profile.devices.clone(),
934 source: profile.source.clone(),
935 filters: profile.filters.clone(),
936 impulses,
937 preamp_db,
938 preamp_rate,
939 preamp_set: profile.preamp_db.is_some(),
940 layers: profile.layers.clone(),
941 group: profile.group,
942 problem,
943 everywhere: scope(profile, &cfg.dsp.profiles) == DspScope::Everywhere,
944 scope_set: profile.scope.is_some(),
945 role: shown_role(profile, &cfg.dsp.profiles),
946 role_set: profile.role.is_some(),
947 corrects_twice: corrects_twice(profile, &cfg.dsp.profiles),
948 corrects: chain_summary.correction,
949 corrects_baked: chain_summary.baked,
950 tunings: chain_summary.tunings,
951 layer_roles: profile
952 .layers
953 .iter()
954 .map(|l| {
955 cfg.dsp
956 .profiles
957 .iter()
958 .find(|p| p.name == l.profile)
959 .map(role)
960 })
961 .collect(),
962 measured: profile.measured().is_some(),
963 made_for: profile.target.as_ref().map(|t| t.made_for.clone()),
964 tuned_for: profile.tuned_for.clone(),
965 preset: profile.preset,
966 edited: edited(profile),
967 can_revert: profile.original.is_some(),
968 read_only: editable(profile, &cfg.dsp.profiles).is_err(),
969 channels: if profile.layers.is_empty() && profile.impulses.is_empty() {
970 channel_layout(&profile.filters)
971 } else {
972 None
973 },
974 })
975}
976
977pub fn rename(old: &str, new: &str) -> Result<(), String> {
979 let new = new.trim();
980 if new.is_empty() {
981 return Err("It needs a name".into());
982 }
983 if new == old {
984 return Ok(());
985 }
986 let cfg = Config::cached();
987 if cfg.dsp.profiles.iter().any(|p| p.name == new) {
988 return Err(format!("There is already an EQ called {new}"));
989 }
990 if !cfg.dsp.profiles.iter().any(|p| p.name == old) {
991 return Err(format!("No EQ called {old}"));
992 }
993 let (from, to) = (
994 Path::new("dsp").join(slug(old)),
995 Path::new("dsp").join(slug(new)),
996 );
997 let base = config::config_dir();
998 let moved = from != to && base.join(&from).exists();
999 if moved {
1000 if base.join(&to).exists() {
1001 return Err(format!("{} is in the way", to.display()));
1002 }
1003 std::fs::rename(base.join(&from), base.join(&to)).map_err(|e| e.to_string())?;
1004 }
1005 persist(|cfg| {
1006 for l in cfg
1008 .dsp
1009 .profiles
1010 .iter_mut()
1011 .flat_map(|p| p.layers.iter_mut())
1012 {
1013 if l.profile == old {
1014 l.profile = new.to_string();
1015 }
1016 }
1017 for t in &mut cfg.dsp.tunings {
1019 if t.tuning == old {
1020 t.tuning = new.to_string();
1021 }
1022 }
1023 for p in &mut cfg.dsp.presets {
1024 if p.preset == old {
1025 p.preset = new.to_string();
1026 }
1027 }
1028 if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == old) {
1029 p.name = new.to_string();
1030 if moved {
1031 for path in &mut p.impulses {
1032 if let Ok(rest) = path.strip_prefix(&from) {
1033 *path = to.join(rest);
1034 }
1035 }
1036 }
1037 }
1038 })
1039 .map_err(|e| e.to_string())
1040}
1041
1042fn persist(mutate: impl FnOnce(&mut crate::config::Config)) -> Result<(), String> {
1046 Config::persist(mutate).map_err(|e| e.to_string())?;
1047 crate::signal::engine_changed().bump();
1048 crate::remote::dsp_sync::changed();
1049 Ok(())
1050}
1051
1052pub fn dir(name: &str) -> PathBuf {
1054 config::config_dir().join("dsp").join(slug(name))
1055}
1056
1057pub fn set_made_for(name: &str, made_for: Option<&str>) -> Result<(), String> {
1063 use super::targets;
1064 let cfg = Config::cached();
1065 let p = cfg
1066 .dsp
1067 .profiles
1068 .iter()
1069 .find(|p| p.name == name)
1070 .ok_or_else(|| format!("No EQ called {name}"))?;
1071 if p.measured().is_some() {
1072 return Err(format!(
1073 "{name} is built from a measurement: choose the target it is corrected to instead"
1074 ));
1075 }
1076 let made = match made_for {
1077 Some(id) => Some(targets::shipped(id).ok_or_else(|| format!("No target called {id}"))?),
1078 None => None,
1079 };
1080 let chosen = p
1081 .target
1082 .as_ref()
1083 .and_then(|t| t.chosen.clone())
1084 .filter(|c| made.is_some_and(|m| targets::shipped(c).is_none_or(|t| t.ear == m.ear)));
1085 persist(|cfg| {
1086 if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
1087 p.target = made.map(|m| crate::config::DspTarget {
1088 made_for: m.id.to_owned(),
1089 chosen,
1090 });
1091 }
1092 })
1093}
1094
1095fn headphone(p: &DspProfile) -> Option<(super::targets::Curve, super::targets::Curve)> {
1100 use super::targets;
1101 let folder = dir(&p.name);
1102 if let Some(m) = p.measured() {
1103 let level = |c: targets::Curve| {
1104 let k = targets::at(&c, 1000.0);
1105 c.into_iter()
1106 .map(|(hz, db)| (hz, db - k))
1107 .collect::<targets::Curve>()
1108 };
1109 return Some((
1110 level(targets::measurement(&folder)?),
1111 level(targets::choice_curve(&m.target)?),
1112 ));
1113 }
1114 let (raw, made_for) = targets::autoeq_measurement(&folder)?;
1115 let moved = p
1116 .target
1117 .as_ref()
1118 .and_then(|t| Some((t.chosen.as_ref()?, &t.made_for)))
1119 .and_then(|(to, from)| {
1120 Some(targets::difference(
1121 &targets::choice_curve(from)?,
1122 &targets::choice_curve(to)?,
1123 ))
1124 });
1125 Some((
1126 raw,
1127 match moved {
1128 Some(step) => targets::moved(&made_for, &step),
1129 None => made_for,
1130 },
1131 ))
1132}
1133
1134#[derive(Debug, Clone, PartialEq)]
1136pub struct TargetChoices {
1137 pub made_for: Option<&'static super::targets::Target>,
1140 pub chosen: Option<String>,
1141 pub choices: Vec<TargetChoice>,
1143}
1144
1145#[derive(Debug, Clone, PartialEq)]
1146pub struct TargetChoice {
1147 pub id: String,
1148 pub name: String,
1149 pub does: String,
1151 pub character: String,
1153}
1154
1155pub fn target_choices(name: &str) -> Option<TargetChoices> {
1158 use super::targets;
1159 let cfg = Config::cached();
1160 let p = cfg.dsp.profiles.iter().find(|p| p.name == name)?;
1161 let (made_for, chosen, ear) = match (p.measured(), &p.target) {
1162 (Some(m), _) => (
1163 None,
1164 Some(m.target.clone()),
1165 match m.ear {
1166 DspEar::In => targets::Ear::In,
1167 DspEar::Over => targets::Ear::Over,
1168 DspEar::Speaker => targets::Ear::Speaker,
1169 },
1170 ),
1171 (None, Some(t)) => {
1172 let made_for = targets::shipped(&t.made_for)?;
1173 (Some(made_for), t.chosen.clone(), made_for.ear)
1174 }
1175 (None, None) => return None,
1176 };
1177 let mut choices: Vec<TargetChoice> = targets::TARGETS
1178 .iter()
1179 .filter(|c| c.ear == ear)
1180 .map(|c| TargetChoice {
1181 id: c.id.into(),
1182 name: c.name.into(),
1183 does: c.does.into(),
1184 character: c.character.into(),
1185 })
1186 .collect();
1187 choices.extend(targets::added().into_iter().map(|a| TargetChoice {
1188 id: a.id,
1189 name: a.name,
1190 does: String::new(),
1191 character: String::new(),
1192 }));
1193 Some(TargetChoices {
1194 made_for,
1195 chosen,
1196 choices,
1197 })
1198}
1199
1200pub fn choose_target(name: &str, chosen: Option<&str>) -> Result<(), String> {
1203 let choices =
1204 target_choices(name).ok_or_else(|| format!("{name} has no target to move from"))?;
1205 if let Some(c) = chosen
1206 && !choices.choices.iter().any(|x| x.id == c)
1207 {
1208 return Err(format!("No target {c} for {name}"));
1209 }
1210 let measured = Config::cached()
1213 .dsp
1214 .profiles
1215 .iter()
1216 .any(|p| p.name == name && p.measured().is_some());
1217 let refit = match chosen {
1218 Some(c) if measured => {
1219 let curve = super::targets::measurement(&dir(name))
1220 .ok_or_else(|| format!("{name}'s measurement is missing"))?;
1221 let aim = super::targets::choice_curve(c).ok_or_else(|| format!("No target {c}"))?;
1222 Some(squig_fit(&curve, &aim))
1223 }
1224 _ => None,
1225 };
1226 persist(|cfg| {
1227 let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) else {
1228 return;
1229 };
1230 if let Some(m) = p.fitted.as_mut().or(p.measurement.as_mut()) {
1231 if let Some(c) = chosen {
1234 m.target = c.to_owned();
1235 }
1236 if let Some((filters, preamp)) = refit {
1237 let legacy = p.measurement.take();
1238 p.fitted = p.fitted.take().or(legacy);
1239 p.filters = filters;
1240 p.preamp_db = Some(preamp);
1241 }
1242 } else if let Some(t) = p.target.as_mut() {
1243 t.chosen = chosen.filter(|c| *c != t.made_for).map(str::to_owned);
1244 }
1245 })
1246 .map_err(|e| e.to_string())
1247}
1248
1249pub fn assign(name: Option<&str>, device: &str) -> Result<(), String> {
1251 if let Some(name) = name {
1252 let cfg = Config::cached();
1253 let Some(p) = cfg.dsp.profiles.iter().find(|p| p.name == name) else {
1254 return Err(format!("No EQ called {name}"));
1255 };
1256 if p.preset {
1258 return apply_preset(device, Some(name));
1259 }
1260 }
1261 persist(|cfg| {
1262 for p in &mut cfg.dsp.profiles {
1263 p.devices.retain(|d| d != device);
1264 if Some(p.name.as_str()) == name {
1265 p.devices.push(device.to_string());
1266 }
1267 }
1268 let all = cfg.dsp.profiles.clone();
1270 if all
1271 .iter()
1272 .any(|p| Some(p.name.as_str()) == name && shown_role(p, &all).corrects())
1273 {
1274 cfg.dsp.filters_only.retain(|d| d != device);
1275 }
1276 })
1277 .map_err(|e| e.to_string())
1278}
1279
1280const BAND_HZ: std::ops::RangeInclusive<f64> = 10.0..=22_000.0;
1283const BAND_DB: std::ops::RangeInclusive<f64> = -30.0..=30.0;
1284const BAND_Q: std::ops::RangeInclusive<f64> = 0.1..=20.0;
1285
1286fn clamp(v: f64, r: &std::ops::RangeInclusive<f64>) -> Result<f64, String> {
1287 if !v.is_finite() {
1288 return Err("Not a number".into());
1289 }
1290 Ok(v.clamp(*r.start(), *r.end()))
1291}
1292
1293fn may_edit(name: &str) -> Result<(), String> {
1295 let cfg = Config::cached();
1296 let p = cfg
1297 .dsp
1298 .profiles
1299 .iter()
1300 .find(|p| p.name == name)
1301 .ok_or_else(|| format!("No EQ called {name}"))?;
1302 editable(p, &cfg.dsp.profiles)
1303}
1304
1305fn band_values(
1308 kind: &str,
1309 freq: f64,
1310 gain_db: f64,
1311 q: f64,
1312) -> Result<(crate::config::EqFilterKind, f64, f64, f64), String> {
1313 let kind = serde_json::from_value(serde_json::Value::String(kind.into()))
1314 .map_err(|_| format!("No band type called {kind}"))?;
1315 Ok((
1316 kind,
1317 clamp(freq, &BAND_HZ)?,
1318 clamp(gain_db, &BAND_DB)?,
1319 clamp(q, &BAND_Q)?,
1320 ))
1321}
1322
1323fn curve_points(points: &[(f64, f64)]) -> Result<Vec<(f64, f64)>, String> {
1325 if points.is_empty() {
1326 return Err("A curve needs a point".into());
1327 }
1328 let most = crate::config::dsp_bounds::GRAPHIC_POINTS;
1329 if points.len() > most {
1330 return Err(format!("A curve has at most {most} points"));
1331 }
1332 let mut points = points
1333 .iter()
1334 .map(|&(hz, db)| Ok((clamp(hz, &BAND_HZ)?, clamp(db, &BAND_DB)?)))
1335 .collect::<Result<Vec<_>, String>>()?;
1336 points.sort_by(|a, b| a.0.total_cmp(&b.0));
1337 Ok(points)
1338}
1339
1340fn preview(
1345 name: &str,
1346 index: usize,
1347 rate: u32,
1348 edit: impl FnOnce(&mut crate::config::DspFilter) -> bool,
1349) -> Option<Response> {
1350 let cfg = Config::cached();
1351 let all = &cfg.dsp.profiles;
1352 let mut p = all.iter().find(|p| p.name == name)?.clone();
1353 editable(&p, all).ok()?;
1354 if !edit(p.filters.get_mut(index)?) {
1355 return None;
1356 }
1357 response_of(&p, all, rate)
1358}
1359
1360pub fn preview_band(
1362 name: &str,
1363 index: usize,
1364 kind: &str,
1365 freq: f64,
1366 gain_db: f64,
1367 q: f64,
1368 rate: u32,
1369) -> Option<Response> {
1370 let (kind, freq, gain_db, q) = band_values(kind, freq, gain_db, q).ok()?;
1371 preview(name, index, rate, |f| match f {
1372 crate::config::DspFilter::Band(b) => {
1373 (b.kind, b.freq, b.gain_db, b.q) = (kind, freq, gain_db, q);
1374 true
1375 }
1376 _ => false,
1377 })
1378}
1379
1380pub fn preview_curve(
1382 name: &str,
1383 index: usize,
1384 points: &[(f64, f64)],
1385 rate: u32,
1386) -> Option<Response> {
1387 let points = curve_points(points).ok()?;
1388 preview(name, index, rate, |f| match f {
1389 crate::config::DspFilter::Graphic(g) => {
1390 g.points = points;
1391 true
1392 }
1393 _ => false,
1394 })
1395}
1396
1397pub fn set_band(
1402 name: &str,
1403 index: usize,
1404 kind: &str,
1405 freq: f64,
1406 gain_db: f64,
1407 q: f64,
1408) -> Result<(), String> {
1409 use crate::config::DspFilter;
1410 let (kind, freq, gain_db, q) = band_values(kind, freq, gain_db, q)?;
1411 may_edit(name)?;
1412 let mut found = Err(format!("{name} has no band {}", index + 1));
1413 persist(|cfg| {
1414 if let Some(DspFilter::Band(b)) = cfg
1415 .dsp
1416 .profiles
1417 .iter_mut()
1418 .find(|p| p.name == name)
1419 .and_then(|p| p.filters.get_mut(index))
1420 {
1421 (b.kind, b.freq, b.gain_db, b.q) = (kind, freq, gain_db, q);
1422 found = Ok(());
1423 }
1424 })
1425 .map_err(|e| e.to_string())?;
1426 found
1427}
1428
1429pub fn set_curve(name: &str, index: usize, points: &[(f64, f64)]) -> Result<(), String> {
1433 use crate::config::DspFilter;
1434 let points = curve_points(points)?;
1435 may_edit(name)?;
1436 let mut found = Err(format!("{name} has no curve {}", index + 1));
1437 persist(|cfg| {
1438 if let Some(DspFilter::Graphic(g)) = cfg
1439 .dsp
1440 .profiles
1441 .iter_mut()
1442 .find(|p| p.name == name)
1443 .and_then(|p| p.filters.get_mut(index))
1444 {
1445 g.points = points;
1446 found = Ok(());
1447 }
1448 })
1449 .map_err(|e| e.to_string())?;
1450 found
1451}
1452
1453pub fn add_band(name: &str, channel: Option<u16>) -> Result<usize, String> {
1456 use crate::config::{DspFilter, EqFilter, EqFilterKind};
1457 may_edit(name)?;
1458 let mut index = 0;
1459 persist(|cfg| {
1460 let p = profile_mut(&mut cfg.dsp.profiles, name);
1461 p.filters.push(DspFilter::Band(EqFilter {
1462 kind: EqFilterKind::Peaking,
1463 freq: 1000.0,
1464 gain_db: 0.0,
1465 q: 1.0,
1466 channels: channel.into_iter().collect(),
1467 }));
1468 index = p.filters.len() - 1;
1469 })
1470 .map_err(|e| e.to_string())?;
1471 Ok(index)
1472}
1473
1474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1477pub enum Starter {
1478 Flat,
1479 BassBoost,
1480 Warm,
1481 Bright,
1482}
1483
1484pub fn starter_bands(starter: Starter) -> Vec<crate::config::DspFilter> {
1487 use crate::config::{DspFilter, EqFilter, EqFilterKind};
1488 let (low, high, presence) = match starter {
1489 Starter::Flat => (0.0, 0.0, 0.0),
1490 Starter::BassBoost => (4.0, 0.0, 0.0),
1491 Starter::Warm => (2.0, -2.0, 0.0),
1492 Starter::Bright => (0.0, 2.5, 1.0),
1493 };
1494 let band = |kind, freq, gain_db, q| {
1495 DspFilter::Band(EqFilter {
1496 kind,
1497 freq,
1498 gain_db,
1499 q,
1500 channels: vec![],
1501 })
1502 };
1503 let mut bands = vec![band(
1504 EqFilterKind::LowShelf,
1505 100.0,
1506 low,
1507 std::f64::consts::FRAC_1_SQRT_2,
1508 )];
1509 for freq in [200.0, 400.0, 800.0, 1600.0, 3200.0, 6400.0] {
1510 let gain = if freq == 3200.0 { presence } else { 0.0 };
1511 bands.push(band(EqFilterKind::Peaking, freq, gain, 1.0));
1512 }
1513 bands.push(band(
1514 EqFilterKind::HighShelf,
1515 10_000.0,
1516 high,
1517 std::f64::consts::FRAC_1_SQRT_2,
1518 ));
1519 bands
1520}
1521
1522pub fn create_eq(name: &str, starter: Starter) -> Result<(), String> {
1524 let name = name.trim();
1525 if name.is_empty() {
1526 return Err("It needs a name".into());
1527 }
1528 if Config::cached().dsp.profiles.iter().any(|p| p.name == name) {
1529 return Err(format!("There is already an EQ called {name}"));
1530 }
1531 persist(|cfg| {
1532 cfg.dsp.profiles.push(DspProfile {
1533 name: name.to_owned(),
1534 filters: starter_bands(starter),
1535 role: Some(DspRole::Tuning),
1536 ..Default::default()
1537 });
1538 })
1539 .map_err(|e| e.to_string())
1540}
1541
1542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1545pub struct ChannelLayout {
1546 pub stereo: bool,
1547 pub differ: bool,
1548}
1549
1550pub fn channel_layout(filters: &[crate::config::DspFilter]) -> Option<ChannelLayout> {
1554 use crate::config::DspFilter;
1555 let mut stereo = false;
1556 for f in filters {
1557 let DspFilter::Band(b) = f else { return None };
1558 match b.channels.as_slice() {
1559 [] => {}
1560 [0] | [1] => stereo = true,
1561 _ => return None,
1562 }
1563 }
1564 let on = |c: u16| -> Vec<(crate::config::EqFilterKind, u64, u64, u64)> {
1565 filters
1566 .iter()
1567 .filter_map(|f| match f {
1568 DspFilter::Band(b) if b.channels.is_empty() || b.channels.contains(&c) => {
1569 Some((b.kind, b.freq.to_bits(), b.gain_db.to_bits(), b.q.to_bits()))
1570 }
1571 _ => None,
1572 })
1573 .collect()
1574 };
1575 Some(ChannelLayout {
1576 stereo,
1577 differ: stereo && on(0) != on(1),
1578 })
1579}
1580
1581pub fn set_stereo(name: &str, stereo: bool, keep: u16) -> Result<(), String> {
1585 use crate::config::DspFilter;
1586 may_edit(name)?;
1587 if keep > 1 {
1588 return Err("Keep the left channel (0) or the right (1)".into());
1589 }
1590 let cfg = Config::cached();
1591 let p = cfg
1592 .dsp
1593 .profiles
1594 .iter()
1595 .find(|p| p.name == name)
1596 .ok_or_else(|| format!("No EQ called {name}"))?;
1597 if !p.layers.is_empty() || !p.impulses.is_empty() || channel_layout(&p.filters).is_none() {
1598 return Err(format!(
1599 "{name} is not bands alone, so it has no channels to set"
1600 ));
1601 }
1602 let on = |c: u16| -> Vec<DspFilter> {
1603 p.filters
1604 .iter()
1605 .filter(|f| {
1606 let ch = f.channels();
1607 ch.is_empty() || ch.contains(&c)
1608 })
1609 .cloned()
1610 .map(|mut f| {
1611 if let Some(ch) = f.channels_mut() {
1612 *ch = if stereo { vec![c] } else { vec![] };
1613 }
1614 f
1615 })
1616 .collect()
1617 };
1618 let filters = if stereo {
1619 let mut both = on(0);
1620 both.extend(on(1));
1621 both
1622 } else {
1623 on(keep)
1624 };
1625 persist(|cfg| profile_mut(&mut cfg.dsp.profiles, name).filters = filters)
1626 .map_err(|e| e.to_string())
1627}
1628
1629pub fn remove_filter(name: &str, index: usize) -> Result<(), String> {
1631 may_edit(name)?;
1632 let mut found = Err(format!("{name} has no filter {}", index + 1));
1633 persist(|cfg| {
1634 if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name)
1635 && index < p.filters.len()
1636 {
1637 p.filters.remove(index);
1638 found = Ok(());
1639 }
1640 })
1641 .map_err(|e| e.to_string())?;
1642 found
1643}
1644
1645#[derive(Debug, Clone, PartialEq)]
1648pub struct Response {
1649 pub freqs: Vec<f64>,
1650 pub total: Vec<f64>,
1654 pub bands: Vec<Vec<f64>>,
1657 pub layers: Vec<LayerResponse>,
1659 pub measurement: Option<Vec<f64>>,
1663 pub target: Option<Vec<f64>>,
1664 pub predicted: Option<Vec<f64>>,
1665 pub preamp_db: f64,
1667 pub gain_db: f64,
1671 pub correction: Option<Vec<f64>>,
1675 pub tuning: Option<Vec<f64>>,
1676 pub original: Option<Vec<f64>>,
1679 pub right: Option<Vec<f64>>,
1682 pub right_gain_db: f64,
1684}
1685
1686#[derive(Debug, Clone, PartialEq)]
1687pub struct LayerResponse {
1688 pub name: String,
1689 pub on: bool,
1690 pub db: Vec<f64>,
1691}
1692
1693pub fn response(name: &str, rate: u32) -> Option<Response> {
1696 let cfg = Config::cached();
1697 let all = &cfg.dsp.profiles;
1698 response_of(all.iter().find(|p| p.name == name)?, all, rate)
1699}
1700
1701pub fn output_response(device: &str, rate: u32) -> Option<Response> {
1704 let chain = super::output_chain(&Config::cached().dsp, device)?;
1705 response_of(&chain.profile, &chain.all, rate)
1706}
1707
1708fn response_of(profile: &DspProfile, all: &[DspProfile], rate: u32) -> Option<Response> {
1709 use super::targets;
1710 let freqs = targets::grid();
1711 let curve = |filters: &[crate::config::DspFilter]| super::response(filters, &freqs, rate);
1712 let setup = Setup::load(profile, all, &config::config_dir()).ok()?;
1713 let total = setup
1714 .as_ref()
1715 .map_or_else(|| vec![0.0; freqs.len()], |s| s.response(&freqs, rate));
1716 let bands = profile
1717 .filters
1718 .iter()
1719 .filter(|f| matches!(f, crate::config::DspFilter::Band(b) if b.kind != crate::config::EqFilterKind::Gain))
1720 .map(|f| curve(std::slice::from_ref(f)))
1721 .collect();
1722 let corrector = corrections_in(profile, all)
1724 .first()
1725 .and_then(|c| all.iter().find(|p| &p.name == c))
1726 .filter(|c| c.name != profile.name || !profile.layers.is_empty());
1727 let correction = corrector.and_then(|c| {
1730 let c = if c.name == profile.name { profile } else { c };
1731 let alone = DspProfile {
1732 layers: Vec::new(),
1733 ..c.clone()
1734 };
1735 Setup::load(&alone, all, &config::config_dir())
1736 .ok()
1737 .flatten()
1738 .map(|s| s.response(&freqs, rate))
1739 });
1740 let tuning = correction
1741 .as_ref()
1742 .map(|c| {
1743 total
1744 .iter()
1745 .zip(c)
1746 .map(|(t, c)| t - c)
1747 .collect::<Vec<f64>>()
1748 })
1749 .filter(|t| t.iter().any(|db| db.abs() > 0.05));
1750 let correction = correction.filter(|_| tuning.is_some());
1751 let layers = profile
1752 .layers
1753 .iter()
1754 .filter_map(|l| {
1755 let p = all.iter().find(|p| p.name == l.profile)?;
1756 let filters = super::chain(p, all, &mut Vec::new()).ok()?;
1757 Some(LayerResponse {
1758 name: l
1760 .profile
1761 .rsplit('\u{1}')
1762 .next()
1763 .unwrap_or_default()
1764 .to_owned(),
1765 on: l.on,
1766 db: curve(&filters),
1767 })
1768 })
1769 .collect();
1770
1771 let measured = std::iter::once(profile)
1774 .chain(
1775 profile
1776 .layers
1777 .iter()
1778 .filter(|l| l.on)
1779 .filter_map(|l| all.iter().find(|p| p.name == l.profile)),
1780 )
1781 .find_map(headphone);
1782 let (measurement, target, predicted) = match measured {
1783 Some((raw, aim)) => {
1784 let raw: Vec<f64> = freqs.iter().map(|&hz| targets::at(&raw, hz)).collect();
1785 let aim: Vec<f64> = freqs.iter().map(|&hz| targets::at(&aim, hz)).collect();
1786 let predicted = raw.iter().zip(&total).map(|(r, t)| r + t).collect();
1787 (Some(raw), Some(aim), Some(predicted))
1788 }
1789 None => (None, None, None),
1790 };
1791 let preamp_db = setup
1792 .as_ref()
1793 .map_or(0.0, |s| s.preamp_db(s.output_rate(rate), 2));
1794 let gain_db = setup.as_ref().map_or(0.0, |s| s.gain_db_on(0));
1795 let right_gain_db = setup.as_ref().map_or(0.0, |s| s.gain_db_on(1));
1796 let right = right_channel(profile, all, &freqs, rate);
1797 Some(Response {
1798 freqs,
1799 total,
1800 bands,
1801 layers,
1802 measurement,
1803 target,
1804 predicted,
1805 preamp_db,
1806 gain_db,
1807 correction,
1808 tuning,
1809 original: None,
1810 right,
1811 right_gain_db,
1812 })
1813}
1814
1815fn right_channel(
1819 profile: &DspProfile,
1820 all: &[DspProfile],
1821 freqs: &[f64],
1822 rate: u32,
1823) -> Option<Vec<f64>> {
1824 if !super::responses(profile, all).is_empty() {
1825 return None;
1826 }
1827 let filters = super::chain(profile, all, &mut Vec::new()).ok()?;
1828 if filters.iter().all(|f| f.channels().is_empty()) {
1829 return None;
1830 }
1831 let left = super::response_on(&filters, freqs, rate, 0);
1832 let right = super::response_on(&filters, freqs, rate, 1);
1833 left.iter()
1834 .zip(&right)
1835 .any(|(l, r)| (l - r).abs() > 0.01)
1836 .then_some(right)
1837}
1838
1839pub fn select(group: &str, member: &str) -> Result<(), String> {
1841 let cfg = Config::cached();
1842 let g = cfg
1843 .dsp
1844 .profiles
1845 .iter()
1846 .find(|p| p.name == group)
1847 .ok_or_else(|| format!("No EQ called {group}"))?;
1848 if !g.group {
1849 return Err(format!("{group} is not a group"));
1850 }
1851 if !g.layers.iter().any(|l| l.profile == member) {
1852 return Err(format!("{member} is not in {group}"));
1853 }
1854 persist(|cfg| {
1855 if let Some(g) = cfg.dsp.profiles.iter_mut().find(|p| p.name == group) {
1856 for l in &mut g.layers {
1857 l.on = l.profile == member;
1858 }
1859 }
1860 })
1861 .map_err(|e| e.to_string())
1862}
1863
1864pub fn set_group(name: &str, group: bool) -> Result<(), String> {
1868 persist(|cfg| {
1869 if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
1870 p.group = group;
1871 if group {
1872 let first = p.layers.iter().position(|l| l.on).unwrap_or(0);
1873 for (i, l) in p.layers.iter_mut().enumerate() {
1874 l.on = i == first;
1875 }
1876 }
1877 }
1878 })
1879 .map_err(|e| e.to_string())
1880}
1881
1882pub fn free_name(name: &str) -> String {
1885 let cfg = Config::cached();
1886 let taken = |n: &str| {
1887 let folder = dir(n);
1888 cfg.dsp
1889 .profiles
1890 .iter()
1891 .any(|p| p.name == n || dir(&p.name) == folder)
1892 || folder.exists()
1893 };
1894 std::iter::once(name.to_owned())
1895 .chain((2..).map(|n| format!("{name} {n}")))
1896 .find(|n| !taken(n))
1897 .expect("some number is free")
1898}
1899
1900pub fn make_group(name: &str, members: &[String]) -> Result<(), String> {
1903 let name = name.trim();
1904 if name.is_empty() {
1905 return Err("A group needs a name".into());
1906 }
1907 if Config::cached().dsp.profiles.iter().any(|p| p.name == name) {
1908 return Err(format!("There is already an EQ called {name}"));
1909 }
1910 let group = DspProfile {
1911 name: name.to_owned(),
1912 layers: members
1913 .iter()
1914 .enumerate()
1915 .map(|(i, m)| crate::config::DspLayer {
1916 profile: m.clone(),
1917 on: i == 0,
1918 })
1919 .collect(),
1920 group: true,
1921 ..Default::default()
1922 };
1923 let mut all = Config::cached().dsp.profiles.clone();
1925 all.push(group.clone());
1926 for m in members {
1927 let mut check = group.clone();
1928 for l in &mut check.layers {
1929 l.on = &l.profile == m;
1930 }
1931 super::chain(&check, &all, &mut Vec::new()).map_err(|e| e.to_string())?;
1932 }
1933 persist(|cfg| cfg.dsp.profiles.push(group)).map_err(|e| e.to_string())
1934}
1935
1936pub fn scope(profile: &DspProfile, all: &[DspProfile]) -> DspScope {
1942 scope_in(profile, all, &mut Vec::new())
1943}
1944
1945fn scope_in(profile: &DspProfile, all: &[DspProfile], seen: &mut Vec<String>) -> DspScope {
1946 if let Some(scope) = profile.scope {
1947 return scope;
1948 }
1949 if !profile.impulses.is_empty() {
1950 return DspScope::Device;
1951 }
1952 if profile.target.is_some() || profile.measured().is_some() {
1953 return DspScope::Everywhere;
1954 }
1955 if !profile.layers.is_empty() {
1956 if seen.contains(&profile.name) {
1957 return DspScope::Device;
1958 }
1959 seen.push(profile.name.clone());
1960 let everywhere = profile.layers.iter().all(|l| {
1961 all.iter()
1962 .find(|p| p.name == l.profile)
1963 .is_some_and(|p| scope_in(p, all, seen) == DspScope::Everywhere)
1964 });
1965 return if everywhere {
1966 DspScope::Everywhere
1967 } else {
1968 DspScope::Device
1969 };
1970 }
1971 if profile.devices.iter().any(|d| stays_put(d)) {
1972 return DspScope::Device;
1973 }
1974 DspScope::Everywhere
1975}
1976
1977fn stays_put(device: &str) -> bool {
1980 device.starts_with("uuid:")
1981 || device == "Speaker"
1982 || crate::audio::list_output_devices().is_ok_and(|devices| {
1983 devices
1984 .iter()
1985 .any(|d| d.name == device && d.kind == crate::audio::backend::OutputKind::BuiltIn)
1986 })
1987}
1988
1989fn local_layer<'a>(layers: &'a [crate::config::DspLayer], all: &[DspProfile]) -> Option<&'a str> {
1992 layers
1993 .iter()
1994 .find(|l| {
1995 all.iter()
1996 .find(|p| p.name == l.profile)
1997 .is_some_and(|p| scope(p, all) == DspScope::Device)
1998 })
1999 .map(|l| l.profile.as_str())
2000}
2001
2002fn shared_stack<'a>(name: &str, all: &'a [DspProfile]) -> Option<&'a DspProfile> {
2004 all.iter().find(|p| {
2005 p.layers.iter().any(|l| l.profile == name) && scope(p, all) == DspScope::Everywhere
2006 })
2007}
2008
2009fn scope_locked(profile: &DspProfile, all: &[DspProfile]) -> Option<String> {
2011 if scope(profile, all) == DspScope::Everywhere {
2012 shared_stack(&profile.name, all).map(|s| format!("{} plays it on every device", s.name))
2013 } else {
2014 local_layer(&profile.layers, all).map(|l| format!("plays {l}, kept on this device"))
2015 }
2016}
2017
2018fn kept_here(stack: &str, layer: &str) -> String {
2019 format!(
2020 "{layer} is kept on this device, so {stack}, which is kept everywhere, \
2021 could not play it on your other devices. Keep {layer} everywhere, or {stack} on this device"
2022 )
2023}
2024
2025pub fn set_scope(name: &str, to: DspScope) -> Result<(), String> {
2028 let cfg = Config::cached();
2029 let all = &cfg.dsp.profiles;
2030 let profile = all
2031 .iter()
2032 .find(|p| p.name == name)
2033 .ok_or_else(|| format!("No EQ called {name}"))?;
2034 match to {
2035 DspScope::Everywhere => {
2036 if let Some(layer) = local_layer(&profile.layers, all) {
2037 return Err(kept_here(name, layer));
2038 }
2039 }
2040 DspScope::Device => {
2041 if let Some(stack) = shared_stack(name, all) {
2042 return Err(format!(
2043 "{name} is played by {}, which is kept everywhere and would lose it on \
2044 your other devices. Keep {} on this device first",
2045 stack.name, stack.name
2046 ));
2047 }
2048 }
2049 }
2050 persist(|cfg| {
2051 if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
2052 p.scope = Some(to);
2053 }
2054 })
2055 .map_err(|e| e.to_string())
2056}
2057
2058pub fn role(profile: &DspProfile) -> DspRole {
2062 profile.role.unwrap_or(
2064 if profile.target.is_some() || profile.measured().is_some() || !profile.impulses.is_empty()
2065 {
2066 DspRole::Correction
2067 } else {
2068 DspRole::Tuning
2069 },
2070 )
2071}
2072
2073pub fn shown_role(profile: &DspProfile, all: &[DspProfile]) -> DspRole {
2076 let own = !profile.filters.is_empty() || !profile.impulses.is_empty();
2077 if profile.role.is_some() || own || profile.layers.is_empty() {
2078 return role(profile);
2079 }
2080 corrections_in(profile, all)
2081 .first()
2082 .and_then(|c| all.iter().find(|p| &p.name == c))
2083 .map_or(DspRole::Tuning, role)
2084}
2085
2086pub fn aims_at(profile: &DspProfile, all: &[DspProfile]) -> Option<String> {
2091 let first = corrections_in(profile, all).into_iter().next()?;
2092 let c = all.iter().find(|p| p.name == first)?;
2093 if role(c) != DspRole::Correction {
2094 return None;
2095 }
2096 c.measured().map(|m| m.target.clone()).or_else(|| {
2097 c.target
2098 .as_ref()
2099 .map(|t| t.chosen.clone().unwrap_or_else(|| t.made_for.clone()))
2100 })
2101}
2102
2103pub fn target_step(
2108 profile: &DspProfile,
2109 all: &[DspProfile],
2110 aim: &[(f64, f64)],
2111 made: &[(f64, f64)],
2112) -> crate::config::GraphicEq {
2113 use super::targets;
2114 let measured = corrections_in(profile, all)
2115 .into_iter()
2116 .next()
2117 .and_then(|first| all.iter().find(|p| p.name == first))
2118 .is_some_and(|c| {
2119 c.measured().is_some()
2120 || c.target
2121 .as_ref()
2122 .is_some_and(|t| t.chosen.as_ref().is_some_and(|to| *to != t.made_for))
2123 && targets::autoeq_measurement(&dir(&c.name)).is_some()
2124 });
2125 if measured {
2126 targets::tapered_difference(aim, made)
2127 } else {
2128 targets::difference(aim, made)
2129 }
2130}
2131
2132pub fn chain_response(
2138 device: &str,
2139 correction: Option<&str>,
2140 tuning: Option<&[String]>,
2141 rate: u32,
2142) -> Result<Vec<(f64, f64)>, String> {
2143 let mut dsp = Config::cached().dsp.clone();
2144 let named = |dsp: &crate::config::DspConfig, name: &str| -> Result<(), String> {
2145 dsp.profiles
2146 .iter()
2147 .any(|p| p.name == name)
2148 .then_some(())
2149 .ok_or_else(|| format!("No EQ called {name}"))
2150 };
2151 if let Some(c) = correction {
2152 named(&dsp, c)?;
2153 for p in &mut dsp.profiles {
2154 p.devices.retain(|d| d != device);
2155 if p.name == c {
2156 p.devices.push(device.to_owned());
2157 }
2158 }
2159 }
2160 if let Some(t) = tuning {
2161 for name in t {
2162 named(&dsp, name)?;
2163 }
2164 dsp.tunings.retain(|t| t.device != device);
2165 dsp.tunings
2166 .extend(t.iter().map(|name| crate::config::DspOutputTuning {
2167 device: device.to_owned(),
2168 tuning: name.clone(),
2169 on: true,
2170 }));
2171 }
2172 let freqs = super::targets::grid();
2173 let filters = match super::output_chain(&dsp, device) {
2174 Some(chain) => {
2175 super::chain(&chain.profile, &chain.all, &mut Vec::new()).map_err(|e| e.to_string())?
2176 }
2177 None => Vec::new(),
2178 };
2179 let total = super::response(&filters, &freqs, rate);
2180 Ok(freqs.into_iter().zip(total).collect())
2181}
2182
2183pub fn tuning_for(device: &str) -> Option<String> {
2185 tunings_for(device).into_iter().next().map(|(name, _)| name)
2186}
2187
2188pub fn tunings_for(device: &str) -> Vec<(String, bool)> {
2190 Config::cached()
2191 .dsp
2192 .tunings
2193 .iter()
2194 .filter(|t| t.device == device)
2195 .map(|t| (t.tuning.clone(), t.on))
2196 .collect()
2197}
2198
2199pub fn set_tuning(device: &str, tuning: Option<&str>) -> Result<(), String> {
2201 let list: Vec<(String, bool)> = tuning.map(|t| (t.to_owned(), true)).into_iter().collect();
2202 set_tunings(device, &list)
2203}
2204
2205pub fn set_tunings(device: &str, tunings: &[(String, bool)]) -> Result<(), String> {
2209 let cfg = Config::cached();
2210 let all = &cfg.dsp.profiles;
2211 for (i, (name, _)) in tunings.iter().enumerate() {
2212 check_tuning(name, all)?;
2213 if tunings[..i].iter().any(|(n, _)| n == name) {
2214 return Err(format!("{name} is in the tuning twice"));
2215 }
2216 }
2217 if tunings.iter().any(|(_, on)| *on)
2218 && let Some(c) = cfg.dsp.profile_for(device)
2219 && shown_role(c, all) == DspRole::Baked
2220 {
2221 return Err(baked_already(&c.name));
2222 }
2223 persist(|cfg| {
2224 cfg.dsp.tunings.retain(|t| t.device != device);
2225 cfg.dsp.tunings.extend(
2226 tunings
2227 .iter()
2228 .map(|(name, on)| crate::config::DspOutputTuning {
2229 device: device.to_owned(),
2230 tuning: name.clone(),
2231 on: *on,
2232 }),
2233 );
2234 })
2235}
2236
2237fn check_tuning(name: &str, all: &[DspProfile]) -> Result<(), String> {
2239 let t = all
2240 .iter()
2241 .find(|p| p.name == name)
2242 .ok_or_else(|| format!("No EQ called {name}"))?;
2243 if !super::responses(t, all).is_empty() {
2244 return Err(format!(
2245 "{name} has impulse responses, which correct a room or speakers: choose it as the correction instead"
2246 ));
2247 }
2248 if shown_role(t, all) != DspRole::Tuning {
2249 return Err(format!(
2250 "{name} corrects headphones: choose it as the correction instead"
2251 ));
2252 }
2253 Ok(())
2254}
2255
2256pub fn read_only(name: &str) -> String {
2258 format!(
2259 "{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"
2260 )
2261}
2262
2263fn editable(profile: &DspProfile, all: &[DspProfile]) -> Result<(), String> {
2267 if profile.fitted.is_some() {
2268 return Err(format!(
2269 "{} is fitted to a measurement, and fitted again for each target, so an edit would be lost: add a tuning on top to change the sound",
2270 profile.name
2271 ));
2272 }
2273 if shown_role(profile, all).corrects() {
2274 return Err(read_only(&profile.name));
2275 }
2276 Ok(())
2277}
2278
2279pub fn edited(profile: &DspProfile) -> bool {
2281 profile
2282 .original
2283 .as_ref()
2284 .is_some_and(|o| o.filters != profile.filters || o.preamp_db != profile.preamp_db)
2285}
2286
2287pub fn revert(name: &str) -> Result<(), String> {
2289 let cfg = Config::cached();
2290 let p = cfg
2291 .dsp
2292 .profiles
2293 .iter()
2294 .find(|p| p.name == name)
2295 .ok_or_else(|| format!("No EQ called {name}"))?;
2296 let Some(original) = p.original.clone() else {
2297 return Err(format!(
2298 "{name} was not imported, so there is nothing to go back to"
2299 ));
2300 };
2301 persist(|cfg| {
2302 if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
2303 p.filters = original.filters;
2304 p.preamp_db = original.preamp_db;
2305 }
2306 })
2307}
2308
2309pub fn duplicate(name: &str, new: Option<&str>) -> Result<String, String> {
2313 let cfg = Config::cached();
2314 let p = cfg
2315 .dsp
2316 .profiles
2317 .iter()
2318 .find(|p| p.name == name)
2319 .ok_or_else(|| format!("No EQ called {name}"))?;
2320 let new = match new.map(str::trim).filter(|n| !n.is_empty()) {
2321 Some(n) if free_name(n) != n => {
2322 return Err(format!("There is already an EQ called {n}"));
2323 }
2324 Some(n) => n.to_owned(),
2325 None => free_name(&format!("{name} copy")),
2326 };
2327 let (from, to) = (dir(name), dir(&new));
2328 if from.is_dir() {
2329 copy_dir(&from, &to).map_err(|e| e.to_string())?;
2330 }
2331 let mut copy = p.clone();
2332 let rel = |d: &Path| Path::new("dsp").join(d.file_name().unwrap_or_default());
2333 copy.impulses = copy
2334 .impulses
2335 .iter()
2336 .map(|i| match i.strip_prefix(rel(&from)) {
2337 Ok(rest) => rel(&to).join(rest),
2338 Err(_) => i.clone(),
2339 })
2340 .collect();
2341 copy.name = new.clone();
2342 copy.devices.clear();
2343 copy.uid = None;
2344 copy.origin = None;
2345 copy.original = None;
2346 copy.source.clear();
2347 persist(|cfg| cfg.dsp.profiles.push(copy))?;
2348 Ok(new)
2349}
2350
2351pub fn snapshot(name: &str) -> Option<String> {
2354 let cfg = Config::cached();
2355 let p = cfg.dsp.profiles.iter().find(|p| p.name == name)?;
2356 serde_json::to_string(&crate::config::DspOriginal {
2357 filters: p.filters.clone(),
2358 preamp_db: p.preamp_db,
2359 })
2360 .ok()
2361}
2362
2363#[derive(Debug, Clone, PartialEq)]
2367pub struct SavedCopy {
2368 pub name: String,
2369 pub placed: bool,
2370}
2371
2372pub fn save_as_copy(
2378 name: &str,
2379 new: Option<&str>,
2380 before: Option<&str>,
2381 device: Option<&str>,
2382) -> Result<SavedCopy, String> {
2383 use crate::config::DspOriginal;
2384 may_edit(name)?;
2385 let before: Option<DspOriginal> = before
2386 .map(serde_json::from_str)
2387 .transpose()
2388 .map_err(|e| format!("Not a snapshot: {e}"))?;
2389 let tunings = device.map(tunings_for).unwrap_or_default();
2393 let placed = tunings.iter().any(|(n, _)| n == name);
2394 if placed {
2395 check_tuning(name, &Config::cached().dsp.profiles)?;
2396 }
2397 let copy = duplicate(name, new)?;
2398 let cfg = Config::cached();
2399 let p = cfg
2400 .dsp
2401 .profiles
2402 .iter()
2403 .find(|p| p.name == name)
2404 .ok_or_else(|| format!("No EQ called {name}"))?;
2405 match before {
2406 Some(b) if b.filters != p.filters || b.preamp_db != p.preamp_db => persist(|cfg| {
2407 if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
2408 p.filters = b.filters;
2409 p.preamp_db = b.preamp_db;
2410 }
2411 })?,
2412 _ if edited(p) => revert(name)?,
2413 _ => {}
2414 }
2415 if let Some(device) = device.filter(|_| placed) {
2416 let swapped: Vec<(String, bool)> = tunings
2417 .into_iter()
2418 .map(|(n, on)| {
2419 if n == name {
2420 (copy.clone(), on)
2421 } else {
2422 (n, on)
2423 }
2424 })
2425 .collect();
2426 set_tunings(device, &swapped)?;
2427 }
2428 Ok(SavedCopy { name: copy, placed })
2429}
2430
2431fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
2432 std::fs::create_dir_all(to)?;
2433 for entry in std::fs::read_dir(from)? {
2434 let entry = entry?;
2435 let target = to.join(entry.file_name());
2436 if entry.file_type()?.is_dir() {
2437 copy_dir(&entry.path(), &target)?;
2438 } else {
2439 std::fs::copy(entry.path(), target)?;
2440 }
2441 }
2442 Ok(())
2443}
2444
2445pub fn preset_for(device: &str) -> Option<(String, bool)> {
2448 let cfg = Config::cached();
2449 let name = cfg
2450 .dsp
2451 .presets
2452 .iter()
2453 .find(|p| p.device == device)?
2454 .preset
2455 .clone();
2456 let preset = cfg
2457 .dsp
2458 .profiles
2459 .iter()
2460 .find(|p| p.name == name && p.preset)?;
2461 let now = chain_of(&cfg.dsp, device);
2462 let only = cfg.dsp.filters_only.iter().any(|d| d == device);
2463 Some((name, now != preset.layers || only != preset.filters_only))
2464}
2465
2466pub fn filters_only(device: &str) -> bool {
2468 Config::cached()
2469 .dsp
2470 .filters_only
2471 .iter()
2472 .any(|d| d == device)
2473}
2474
2475pub fn set_filters_only(device: &str, only: bool) -> Result<(), String> {
2479 persist(|cfg| {
2480 cfg.dsp.filters_only.retain(|d| d != device);
2481 if only {
2482 cfg.dsp.filters_only.push(device.to_owned());
2483 let all = cfg.dsp.profiles.clone();
2484 for p in cfg.dsp.profiles.iter_mut() {
2485 let corrects = all
2486 .iter()
2487 .find(|q| q.name == p.name)
2488 .is_some_and(|q| !q.preset && shown_role(q, &all).corrects());
2489 if corrects {
2490 p.devices.retain(|d| d != device);
2491 }
2492 }
2493 }
2494 })
2495 .map_err(|e| e.to_string())
2496}
2497
2498fn chain_of(dsp: &crate::config::DspConfig, device: &str) -> Vec<crate::config::DspLayer> {
2501 let correction = dsp
2502 .profiles
2503 .iter()
2504 .find(|p| !p.preset && p.devices.iter().any(|d| d == device));
2505 correction
2506 .map(|c| crate::config::DspLayer {
2507 profile: c.name.clone(),
2508 on: true,
2509 })
2510 .into_iter()
2511 .chain(
2512 dsp.tunings
2513 .iter()
2514 .filter(|t| t.device == device)
2515 .map(|t| crate::config::DspLayer {
2516 profile: t.tuning.clone(),
2517 on: t.on,
2518 }),
2519 )
2520 .collect()
2521}
2522
2523pub fn save_preset(device: &str, name: &str) -> Result<String, String> {
2526 let name = name.trim();
2527 if name.is_empty() {
2528 return Err("A preset needs a name".into());
2529 }
2530 let cfg = Config::cached();
2531 if cfg.dsp.profiles.iter().any(|p| p.name == name && !p.preset) {
2532 return Err(format!("There is already an EQ called {name}"));
2533 }
2534 let layers = chain_of(&cfg.dsp, device);
2535 let unsaid = unsaid_correction(&cfg.dsp, device);
2536 let only = cfg.dsp.filters_only.iter().any(|d| d == device);
2537 persist(|cfg| {
2538 say_corrects(cfg, unsaid.as_deref());
2539 match cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
2540 Some(p) => {
2541 p.layers = layers;
2542 p.filters_only = only;
2543 }
2544 None => cfg.dsp.profiles.push(DspProfile {
2545 name: name.to_owned(),
2546 preset: true,
2547 layers,
2548 filters_only: only,
2549 ..Default::default()
2550 }),
2551 }
2552 cfg.dsp.presets.retain(|p| p.device != device);
2553 cfg.dsp.presets.push(crate::config::DspOutputPreset {
2554 device: device.to_owned(),
2555 preset: name.to_owned(),
2556 });
2557 })?;
2558 Ok(name.to_owned())
2559}
2560
2561fn unsaid_correction(dsp: &crate::config::DspConfig, device: &str) -> Option<String> {
2565 dsp.profiles
2566 .iter()
2567 .find(|p| !p.preset && p.devices.iter().any(|d| d == device))
2568 .filter(|p| p.role.is_none() && !shown_role(p, &dsp.profiles).corrects())
2569 .map(|p| p.name.clone())
2570}
2571
2572fn say_corrects(cfg: &mut Config, name: Option<&str>) {
2574 if let Some(p) = cfg
2575 .dsp
2576 .profiles
2577 .iter_mut()
2578 .find(|p| Some(p.name.as_str()) == name)
2579 {
2580 p.role = Some(DspRole::Correction);
2581 }
2582}
2583
2584fn preset_parts(
2588 layers: &[crate::config::DspLayer],
2589 all: &[DspProfile],
2590) -> (Option<String>, Vec<(String, bool)>) {
2591 let mut layers = layers.iter().peekable();
2592 let correction = layers
2593 .next_if(|l| {
2594 all.iter()
2595 .any(|q| q.name == l.profile && shown_role(q, all).corrects())
2596 })
2597 .filter(|l| l.on)
2598 .map(|l| l.profile.clone());
2599 (
2600 correction,
2601 layers.map(|l| (l.profile.clone(), l.on)).collect(),
2602 )
2603}
2604
2605fn set_output(
2607 cfg: &mut Config,
2608 device: &str,
2609 correction: Option<&str>,
2610 tunings: &[(String, bool)],
2611 preset: Option<&str>,
2612) {
2613 for p in &mut cfg.dsp.profiles {
2614 p.devices.retain(|d| d != device);
2615 if Some(p.name.as_str()) == correction {
2616 p.devices.push(device.to_owned());
2617 }
2618 }
2619 cfg.dsp.tunings.retain(|t| t.device != device);
2620 cfg.dsp.tunings.extend(
2621 tunings
2622 .iter()
2623 .map(|(t, on)| crate::config::DspOutputTuning {
2624 device: device.to_owned(),
2625 tuning: t.clone(),
2626 on: *on,
2627 }),
2628 );
2629 cfg.dsp.presets.retain(|p| p.device != device);
2630 if let Some(preset) = preset {
2631 cfg.dsp.presets.push(crate::config::DspOutputPreset {
2632 device: device.to_owned(),
2633 preset: preset.to_owned(),
2634 });
2635 }
2636}
2637
2638pub fn apply_preset(device: &str, name: Option<&str>) -> Result<(), String> {
2642 let cfg = Config::cached();
2643 let all = &cfg.dsp.profiles;
2644 let mut only = None;
2645 let (correction, tunings) = match name {
2646 None => (None, Vec::new()),
2647 Some(name) => {
2648 let p = all
2649 .iter()
2650 .find(|p| p.name == name && p.preset)
2651 .ok_or_else(|| {
2652 let presets: Vec<&str> = all
2653 .iter()
2654 .filter(|p| p.preset)
2655 .map(|p| p.name.as_str())
2656 .collect();
2657 format!(
2658 "No preset called {name}. Presets: {}",
2659 if presets.is_empty() {
2660 "none yet".to_owned()
2661 } else {
2662 presets.join(", ")
2663 }
2664 )
2665 })?;
2666 let parts = preset_parts(&p.layers, all);
2667 for (t, _) in &parts.1 {
2668 check_tuning(t, all)?;
2669 }
2670 only = Some(p.filters_only);
2671 if p.filters_only {
2674 (None, parts.1)
2675 } else {
2676 parts
2677 }
2678 }
2679 };
2680 persist(|cfg| {
2681 set_output(cfg, device, correction.as_deref(), &tunings, name);
2682 if let Some(only) = only {
2683 cfg.dsp.filters_only.retain(|d| d != device);
2684 if only {
2685 cfg.dsp.filters_only.push(device.to_owned());
2686 }
2687 }
2688 })
2689}
2690
2691type Setting = (String, Option<String>, Vec<(String, bool)>, String);
2694
2695pub fn migrate() -> Result<(), String> {
2707 migrate_presets()?;
2708 flatten_off()
2709}
2710
2711fn flatten_off() -> Result<(), String> {
2715 let cfg = Config::cached();
2716 if cfg.dsp.enabled {
2717 return Ok(());
2718 }
2719 let mut devices: Vec<&str> = Vec::new();
2720 for d in cfg
2721 .dsp
2722 .profiles
2723 .iter()
2724 .filter(|p| !p.preset)
2725 .flat_map(|p| p.devices.iter())
2726 .chain(cfg.dsp.tunings.iter().map(|t| &t.device))
2727 {
2728 if !devices.contains(&d.as_str()) {
2729 devices.push(d);
2730 }
2731 }
2732 let mut taken: Vec<String> = cfg.dsp.profiles.iter().map(|p| p.name.clone()).collect();
2733 let mut kept: Vec<(String, Vec<crate::config::DspLayer>)> = Vec::new();
2734 let mut unsaid: Vec<String> = Vec::new();
2735 for device in &devices {
2736 if preset_for(device).is_some_and(|(_, edited)| !edited) {
2737 continue;
2738 }
2739 let layers = chain_of(&cfg.dsp, device);
2740 if layers.is_empty() {
2741 continue;
2742 }
2743 unsaid.extend(unsaid_correction(&cfg.dsp, device));
2744 let name = (1..)
2745 .map(|n| match n {
2746 1 => (*device).to_owned(),
2747 n => format!("{device} {n}"),
2748 })
2749 .find(|n| !taken.contains(n))
2750 .expect("a name is free");
2751 taken.push(name.clone());
2752 kept.push((name, layers));
2753 }
2754 persist(|cfg| {
2755 for name in &unsaid {
2756 say_corrects(cfg, Some(name));
2757 }
2758 for (name, layers) in &kept {
2759 cfg.dsp.profiles.push(DspProfile {
2760 name: name.clone(),
2761 preset: true,
2762 layers: layers.clone(),
2763 ..Default::default()
2764 });
2765 }
2766 for device in &devices {
2767 set_output(cfg, device, None, &[], None);
2768 }
2769 cfg.dsp.enabled = true;
2770 })
2771}
2772
2773fn migrate_presets() -> Result<(), String> {
2774 let cfg = Config::cached();
2775 let all = &cfg.dsp.profiles;
2776 let used_as_layer = |name: &str| {
2777 all.iter()
2778 .any(|p| p.layers.iter().any(|l| l.profile == name))
2779 };
2780 let corrects = |name: &str| {
2781 all.iter()
2782 .any(|q| q.name == name && shown_role(q, all).corrects())
2783 };
2784 let is_eq = |name: &str| check_tuning(name, all).is_ok();
2785 let presets: Vec<(String, Vec<crate::config::DspLayer>, Vec<String>)> = all
2788 .iter()
2789 .filter(|p| {
2790 (p.preset && !p.devices.is_empty())
2791 || (!p.preset
2792 && !p.group
2793 && p.filters.is_empty()
2794 && p.impulses.is_empty()
2795 && p.target.is_none()
2796 && p.measured().is_none()
2797 && !used_as_layer(&p.name)
2798 && p.layers.first().is_some_and(|l| corrects(&l.profile))
2799 && p.layers.iter().skip(1).all(|l| is_eq(&l.profile)))
2800 })
2801 .map(|p| {
2802 let mut layers = p.layers.clone();
2803 if layers
2804 .first()
2805 .is_some_and(|l| !l.on && corrects(&l.profile))
2806 {
2807 layers.remove(0);
2808 }
2809 (p.name.clone(), layers, p.devices.clone())
2810 })
2811 .collect();
2812 let mut set: Vec<Setting> = Vec::new();
2815 for (name, layers, devices) in &presets {
2816 let (correction, eqs) = preset_parts(layers, all);
2817 for device in devices {
2818 let mut tunings = eqs.clone();
2819 for t in cfg.dsp.tunings.iter().filter(|t| &t.device == device) {
2820 if !tunings.iter().any(|(n, _)| n == &t.tuning) {
2821 tunings.push((t.tuning.clone(), t.on));
2822 }
2823 }
2824 set.push((device.clone(), correction.clone(), tunings, name.clone()));
2825 }
2826 }
2827 let playing = |p: &DspProfile| super::playing(p, all).map(|m| m.name.clone());
2828 let groups: Vec<(String, String)> = all
2829 .iter()
2830 .filter(|p| p.group)
2831 .filter_map(|g| playing(g).map(|m| (g.name.clone(), m)))
2832 .collect();
2833 let groups_in_use = groups.iter().any(|(g, _)| {
2834 all.iter().any(|p| &p.name == g && !p.devices.is_empty())
2835 || cfg.dsp.tunings.iter().any(|t| &t.tuning == g)
2836 });
2837 if presets.is_empty() && !groups_in_use {
2838 return Ok(());
2839 }
2840 persist(|cfg| {
2841 for (device, correction, tunings, preset) in &set {
2842 set_output(cfg, device, correction.as_deref(), tunings, Some(preset));
2843 }
2844 for (name, layers, _) in &presets {
2845 if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| &p.name == name) {
2846 p.preset = true;
2847 p.layers = layers.clone();
2848 p.devices.clear();
2849 }
2850 }
2851 for (group, member) in &groups {
2852 let devices: Vec<String> = cfg
2853 .dsp
2854 .profiles
2855 .iter()
2856 .find(|p| &p.name == group)
2857 .map(|p| p.devices.clone())
2858 .unwrap_or_default();
2859 for p in &mut cfg.dsp.profiles {
2860 if &p.name == group {
2861 p.devices.clear();
2862 } else if &p.name == member {
2863 for d in &devices {
2864 if !p.devices.contains(d) {
2865 p.devices.push(d.clone());
2866 }
2867 }
2868 }
2869 }
2870 for t in &mut cfg.dsp.tunings {
2871 if &t.tuning == group {
2872 t.tuning = member.clone();
2873 }
2874 }
2875 }
2876 })
2877}
2878
2879pub fn baked_already(correction: &str) -> String {
2881 format!(
2882 "{correction} already includes a tuning. Split it into a correction and an EQ to change the tuning."
2883 )
2884}
2885
2886pub fn set_tuned_for(name: &str, target: Option<&str>) -> Result<(), String> {
2889 if !Config::cached().dsp.profiles.iter().any(|p| p.name == name) {
2890 return Err(format!("No EQ called {name}"));
2891 }
2892 if let Some(t) = target
2893 && super::targets::choice_curve(t).is_none()
2894 {
2895 return Err(format!("No target called {t}"));
2896 }
2897 persist(|cfg| {
2898 if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
2899 p.tuned_for = target.map(str::to_owned);
2900 }
2901 })
2902}
2903
2904pub fn corrections_in(profile: &DspProfile, all: &[DspProfile]) -> Vec<String> {
2907 fn walk(p: &DspProfile, all: &[DspProfile], seen: &mut Vec<String>, out: &mut Vec<String>) {
2908 if seen.contains(&p.name) || seen.len() > 64 {
2909 return;
2910 }
2911 seen.push(p.name.clone());
2912 let played: Vec<&DspProfile> = if p.group {
2914 super::playing(p, all).into_iter().collect()
2915 } else {
2916 p.layers
2917 .iter()
2918 .filter(|l| l.on)
2919 .filter_map(|l| all.iter().find(|q| q.name == l.profile))
2920 .collect()
2921 };
2922 for q in played {
2923 walk(q, all, seen, out);
2924 }
2925 if role(p).corrects() && !out.contains(&p.name) {
2926 out.push(p.name.clone());
2927 }
2928 }
2929 let mut out = Vec::new();
2930 walk(profile, all, &mut Vec::new(), &mut out);
2931 out
2932}
2933
2934fn correction_label(p: &DspProfile) -> String {
2936 let kind = if role(p) == DspRole::Baked {
2937 "includes a tuning"
2938 } else if p.measured().is_some() {
2939 "measured"
2940 } else if super::targets::autoeq_measurement(&dir(&p.name)).is_some() {
2941 "AutoEQ"
2942 } else if p.target.is_some() {
2943 "ready-made"
2944 } else {
2945 "correction"
2946 };
2947 if p.name.to_lowercase().contains(&kind.to_lowercase()) {
2948 p.name.clone()
2949 } else {
2950 format!("{} ({kind})", p.name)
2951 }
2952}
2953
2954pub fn corrects_twice(profile: &DspProfile, all: &[DspProfile]) -> Option<String> {
2958 let c = corrections_in(profile, all);
2959 if c.len() < 2 {
2960 return None;
2961 }
2962 let labels: Vec<String> = c
2963 .iter()
2964 .filter_map(|n| all.iter().find(|p| &p.name == n))
2965 .map(correction_label)
2966 .collect();
2967 let (last, rest) = labels.split_last()?;
2968 Some(format!(
2969 "This EQ corrects twice: {} and {last}. Keep one.",
2970 rest.join(", ")
2971 ))
2972}
2973
2974pub fn two_corrections(corrections: &[String]) -> String {
2976 format!(
2977 "This EQ already plays the correction {}. Take that out first, or add {} to a device's tuning instead.",
2978 corrections[0],
2979 corrections.get(1).map_or("this", String::as_str)
2980 )
2981}
2982
2983pub fn set_role(name: &str, to: DspRole) -> Result<(), String> {
2987 if !Config::cached().dsp.profiles.iter().any(|p| p.name == name) {
2988 return Err(format!("No EQ called {name}"));
2989 }
2990 persist(|cfg| {
2991 if let Some(p) = cfg.dsp.profiles.iter_mut().find(|p| p.name == name) {
2992 p.role = Some(to);
2993 }
2994 })
2995}
2996
2997pub fn target_name(id: &str) -> String {
2999 use super::targets;
3000 targets::shipped(id).map_or_else(
3001 || {
3002 targets::added()
3003 .into_iter()
3004 .find(|a| a.id == id)
3005 .map_or_else(|| id.to_owned(), |a| a.name)
3006 },
3007 |t| t.name.to_owned(),
3008 )
3009}
3010
3011#[derive(Debug, Clone, Default, PartialEq)]
3014pub struct ChainSummary {
3015 pub correction: Option<String>,
3016 pub baked: bool,
3018 pub tunings: Vec<String>,
3019}
3020
3021pub fn summary(name: &str) -> Option<ChainSummary> {
3022 use super::targets;
3023 let cfg = Config::cached();
3024 let all = &cfg.dsp.profiles;
3025 let profile = all.iter().find(|p| p.name == name)?;
3026 let mut chain: Vec<&DspProfile> = profile
3027 .layers
3028 .iter()
3029 .filter(|l| l.on)
3030 .filter_map(|l| all.iter().find(|p| p.name == l.profile))
3031 .collect();
3032 chain.push(profile);
3033 let mut out = ChainSummary::default();
3034 for p in chain {
3035 match role(p) {
3036 DspRole::Tuning => {
3037 if !p.filters.is_empty() || !p.impulses.is_empty() {
3038 out.tunings.push(p.name.clone());
3039 }
3040 continue;
3041 }
3042 DspRole::Baked => {
3043 if out.correction.is_none() {
3044 out.correction = Some(format!("{} (correction + tuning in one)", p.name));
3045 out.baked = true;
3046 }
3047 continue;
3048 }
3049 DspRole::Correction => {}
3050 }
3051 let line = if let Some(m) = p.measured() {
3052 format!("{} → {} (from measurement)", p.name, target_name(&m.target))
3053 } else if let Some(t) = &p.target {
3054 let autoeq = targets::autoeq_measurement(&dir(&p.name)).is_some();
3055 match (&t.chosen, autoeq) {
3056 (Some(c), true) => format!(
3057 "{} → {} (rebuilt from AutoEQ's measurement)",
3058 p.name,
3059 target_name(c)
3060 ),
3061 (Some(c), false) => format!("{} → {}", p.name, target_name(c)),
3062 (None, true) => format!("{} → {}", p.name, target_name(&t.made_for)),
3063 (None, false) => format!(
3064 "{}: ready-made EQ (made for {})",
3065 p.name,
3066 target_name(&t.made_for)
3067 ),
3068 }
3069 } else {
3070 format!("{}: ready-made EQ (made for an unknown target)", p.name)
3071 };
3072 out.correction.get_or_insert(line);
3073 }
3074 Some(out)
3075}
3076
3077pub fn read_measurement(text: &str) -> Result<super::targets::Curve, String> {
3080 reading(text).map(|(curve, _)| curve)
3081}
3082
3083#[derive(Debug, Clone, PartialEq)]
3085pub struct MeasurementReading {
3086 pub note: String,
3089 pub speaker: bool,
3091}
3092
3093pub fn describe_measurement(text: &str) -> Result<MeasurementReading, String> {
3094 let (curve, used) = reading(text)?;
3095 Ok(match used {
3096 Some(used) => MeasurementReading {
3097 note: used.describe(),
3098 speaker: true,
3099 },
3100 None => MeasurementReading {
3101 note: format!(
3102 "{} points, from {:.0} Hz to {:.1} kHz.",
3103 curve.len(),
3104 curve.first().map_or(0.0, |p| p.0),
3105 curve.last().map_or(0.0, |p| p.0) / 1000.0
3106 ),
3107 speaker: false,
3108 },
3109 })
3110}
3111
3112fn suits(ear: super::targets::Ear, target: &str) -> Result<(), String> {
3116 use super::targets::{Ear, shipped};
3117 match shipped(target) {
3118 Some(t) if t.ear == Ear::Speaker && ear != Ear::Speaker => Err(format!(
3119 "{} is a speaker target; a headphone is corrected to a headphone target",
3120 t.name
3121 )),
3122 Some(t) if ear == Ear::Speaker && t.ear != Ear::Speaker => Err(format!(
3123 "{} is a headphone target; a speaker is corrected to Flat",
3124 t.name
3125 )),
3126 _ => Ok(()),
3127 }
3128}
3129
3130fn reading(text: &str) -> Result<(super::targets::Curve, Option<super::klippel::Used>), String> {
3131 use super::{klippel, targets};
3132 if !klippel::is_export(text) {
3133 return targets::covering(text, "A measurement").map(|c| (c, None));
3134 }
3135 let (curve, used) = klippel::read(text)?;
3136 let curve = targets::covers(curve, "A measurement")
3137 .map_err(|why| format!("{why}, in its Klippel export"))?;
3138 Ok((curve, Some(used)))
3139}
3140
3141pub fn preview_measurement(text: &str, target: &str, rate: u32) -> Result<Response, String> {
3144 use super::targets;
3145 let (measured, used) = reading(text)?;
3146 if used.is_some() {
3147 suits(targets::Ear::Speaker, target)?;
3148 }
3149 let aim = targets::choice_curve(target).ok_or_else(|| format!("No target {target}"))?;
3150 let freqs = targets::grid();
3151 let (filters, preamp_db) = squig_fit(&measured, &aim);
3152 let total = super::response(&filters, &freqs, rate);
3153 let level = |c: &targets::Curve| {
3154 let k = targets::at(c, 1000.0);
3155 freqs
3156 .iter()
3157 .map(|&hz| targets::at(c, hz) - k)
3158 .collect::<Vec<f64>>()
3159 };
3160 let raw = level(&measured);
3161 let predicted = raw.iter().zip(&total).map(|(r, t)| r + t).collect();
3162 Ok(Response {
3163 bands: Vec::new(),
3164 layers: Vec::new(),
3165 measurement: Some(raw),
3166 target: Some(level(&aim)),
3167 predicted: Some(predicted),
3168 preamp_db,
3169 gain_db: 0.0,
3170 correction: None,
3171 tuning: None,
3172 original: None,
3173 right: None,
3174 right_gain_db: 0.0,
3175 total,
3176 freqs,
3177 })
3178}
3179
3180struct Split {
3185 taste: crate::config::GraphicEq,
3186 freqs: Vec<f64>,
3187 original: Vec<f64>,
3188 correction: Vec<f64>,
3189 tuning: Vec<f64>,
3190}
3191
3192fn split(name: &str, text: &str, target: &str, rate: u32) -> Result<Split, String> {
3193 use super::targets;
3194 use crate::config::DspFilter;
3195 let cfg = Config::cached();
3196 let all = &cfg.dsp.profiles;
3197 let p = all
3198 .iter()
3199 .find(|p| p.name == name)
3200 .ok_or_else(|| format!("No EQ called {name}"))?;
3201 if !super::responses(p, all).is_empty() {
3202 return Err(format!(
3203 "{name} has impulse responses: only an EQ splits into a correction and a tuning"
3204 ));
3205 }
3206 if !p.layers.is_empty() || p.group {
3210 return Err(format!(
3211 "{name} plays other EQs: split the one among them that includes a tuning instead"
3212 ));
3213 }
3214 if p.filters.iter().any(|f| match f {
3215 DspFilter::Band(b) => !b.channels.is_empty(),
3216 DspFilter::Graphic(g) => !g.channels.is_empty(),
3217 DspFilter::Mix(_) | DspFilter::Delay(_) => true,
3218 }) {
3219 return Err(format!(
3220 "{name} treats its channels differently: only an EQ the same on every channel splits"
3221 ));
3222 }
3223 let measured = read_measurement(text)?;
3224 let aim = targets::choice_curve(target).ok_or_else(|| format!("No target {target}"))?;
3225 let freqs = targets::grid();
3226 let filters = super::chain(p, all, &mut Vec::new()).map_err(|e| e.to_string())?;
3227 let original = super::response(&filters, &freqs, rate);
3228 let correction = super::response(&squig_fit(&measured, &aim).0, &freqs, rate);
3229 let curve =
3230 |db: &[f64]| -> targets::Curve { freqs.iter().copied().zip(db.iter().copied()).collect() };
3231 let neutral = super::response(
3239 &[DspFilter::Graphic(targets::difference(&measured, &aim))],
3240 &freqs,
3241 rate,
3242 );
3243 let treble = |base: &[f64]| -> f64 {
3244 let (sum, n) = freqs
3245 .iter()
3246 .zip(original.iter().zip(base))
3247 .filter(|(hz, _)| **hz >= 2000.0)
3248 .fold((0.0, 0), |(sum, n), (_, (o, b))| {
3249 (sum + (o - b).powi(2), n + 1)
3250 });
3251 sum / n.max(1) as f64
3252 };
3253 let base = if treble(&correction) < treble(&neutral) {
3254 &correction
3255 } else {
3256 &neutral
3257 };
3258 let taste = targets::difference(&curve(base), &curve(&original));
3259 let tuning = super::response(&[DspFilter::Graphic(taste.clone())], &freqs, rate);
3260 Ok(Split {
3261 taste,
3262 freqs,
3263 original,
3264 correction,
3265 tuning,
3266 })
3267}
3268
3269pub fn preview_split(name: &str, text: &str, target: &str, rate: u32) -> Result<Response, String> {
3274 let s = split(name, text, target, rate)?;
3275 let total = s
3276 .correction
3277 .iter()
3278 .zip(&s.tuning)
3279 .map(|(c, t)| c + t)
3280 .collect();
3281 Ok(Response {
3282 bands: Vec::new(),
3283 layers: Vec::new(),
3284 measurement: None,
3285 target: None,
3286 predicted: None,
3287 preamp_db: 0.0,
3288 gain_db: 0.0,
3289 correction: Some(s.correction),
3290 tuning: Some(s.tuning),
3291 original: Some(s.original),
3292 right: None,
3293 right_gain_db: 0.0,
3294 total,
3295 freqs: s.freqs,
3296 })
3297}
3298
3299pub fn split_baked(
3305 name: &str,
3306 text: &str,
3307 ear: DspEar,
3308 target: &str,
3309) -> Result<(String, String), String> {
3310 let s = split(name, text, target, 48_000)?;
3311 let devices = Config::cached()
3312 .dsp
3313 .profiles
3314 .iter()
3315 .find(|p| p.name == name)
3316 .map(|p| p.devices.clone())
3317 .unwrap_or_default();
3318 let mut correction =
3319 measured_profile(&free_name(&format!("{name} correction")), text, ear, target)?;
3320 let tuning = free_name(&format!("{name} tuning"));
3321 let made = (correction.name.clone(), tuning.clone());
3322 correction.devices = devices.clone();
3324 let written = persist(|cfg| {
3325 for p in &mut cfg.dsp.profiles {
3326 p.devices.retain(|d| !devices.contains(d));
3327 }
3328 cfg.dsp.tunings.retain(|t| !devices.contains(&t.device));
3329 cfg.dsp
3330 .tunings
3331 .extend(devices.iter().map(|d| crate::config::DspOutputTuning {
3332 device: d.clone(),
3333 tuning: tuning.clone(),
3334 on: true,
3335 }));
3336 cfg.dsp.profiles.push(correction);
3337 cfg.dsp.profiles.push(DspProfile {
3338 name: tuning.clone(),
3339 filters: vec![crate::config::DspFilter::Graphic(s.taste)],
3340 role: Some(DspRole::Tuning),
3341 tuned_for: Some(target.to_owned()),
3342 ..Default::default()
3343 });
3344 });
3345 if written.is_err() {
3346 let _ = std::fs::remove_dir_all(dir(&made.0));
3347 }
3348 written.map(|()| made)
3349}
3350
3351pub fn save_measured(name: &str, text: &str, ear: DspEar, target: &str) -> Result<String, String> {
3355 save_measured_from(name, text, ear, target, None)
3356}
3357
3358pub fn save_measured_from(
3362 name: &str,
3363 text: &str,
3364 ear: DspEar,
3365 target: &str,
3366 source: Option<&str>,
3367) -> Result<String, String> {
3368 let mut profile = measured_profile(name, text, ear, target)?;
3369 profile.source = source.into_iter().map(str::to_owned).collect();
3370 let name = profile.name.clone();
3371 persist(|cfg| cfg.dsp.profiles.push(profile))?;
3372 Ok(name)
3373}
3374
3375fn measured_profile(
3378 name: &str,
3379 text: &str,
3380 ear: DspEar,
3381 target: &str,
3382) -> Result<DspProfile, String> {
3383 use super::targets;
3384 let name = name.trim();
3385 if name.is_empty() {
3386 return Err("It needs a name".into());
3387 }
3388 if Config::cached().dsp.profiles.iter().any(|p| p.name == name) {
3389 return Err(format!("There is already an EQ called {name}"));
3390 }
3391 let (measured, used) = reading(text)?;
3392 if used.is_some() && ear != DspEar::Speaker {
3393 return Err(
3394 "This is a speaker's measurement: it is corrected as a speaker, to Flat".into(),
3395 );
3396 }
3397 suits(
3398 match ear {
3399 DspEar::In => targets::Ear::In,
3400 DspEar::Over => targets::Ear::Over,
3401 DspEar::Speaker => targets::Ear::Speaker,
3402 },
3403 target,
3404 )?;
3405 let aim = targets::choice_curve(target).ok_or_else(|| format!("No target {target}"))?;
3406 let kept: String = std::iter::once("frequency,raw\n".to_owned())
3409 .chain(measured.iter().map(|(hz, db)| format!("{hz},{db}\n")))
3410 .collect();
3411 let (filters, preamp_db) = squig_fit(&targets::parse(&kept), &aim);
3412 let folder = dir(name);
3413 if folder.exists() {
3414 return Err(format!("{} is in the way", folder.display()));
3415 }
3416 std::fs::create_dir_all(&folder).map_err(|e| e.to_string())?;
3417 std::fs::write(targets::measurement_path(&folder), &kept).map_err(|e| e.to_string())?;
3418 Ok(DspProfile {
3419 name: name.to_owned(),
3420 role: Some(DspRole::Correction),
3421 fitted: Some(DspMeasurement {
3422 ear,
3423 target: target.to_owned(),
3424 }),
3425 filters,
3426 preamp_db: Some(preamp_db),
3427 ..Default::default()
3428 })
3429}
3430
3431pub(super) fn fitted_to(correction: &DspProfile, target: &str) -> Option<Vec<config::DspFilter>> {
3435 use super::targets;
3436 correction.measured()?;
3437 let measured = targets::measurement(&dir(&correction.name))?;
3438 Some(squig_fit(&measured, &targets::choice_curve(target)?).0)
3439}
3440
3441pub(super) fn squig_fit(
3448 measured: &[(f64, f64)],
3449 target: &[(f64, f64)],
3450) -> (Vec<config::DspFilter>, f64) {
3451 let key = fit_key(measured, target);
3452 let playing = PLAYING_SCOPE.get();
3453 {
3454 let mut fits = FITS.lock();
3455 let hit = match fits.playing.take(key) {
3456 Some(hit) => Some((hit, true)),
3457 None => fits.recent.take(key).map(|hit| (hit, playing)),
3458 };
3459 if let Some((hit, playing)) = hit {
3460 fits.keep(key, hit.clone(), playing);
3461 return hit;
3462 }
3463 }
3464 let fit = super::autoeq_squig::fit(measured, target);
3465 let made = (
3466 fit.filters
3467 .into_iter()
3468 .map(config::DspFilter::Band)
3469 .collect::<Vec<_>>(),
3470 (fit.preamp_db * 100.0).round() / 100.0,
3471 );
3472 FITS.lock().keep(key, made.clone(), playing);
3473 made
3474}
3475
3476fn fit_key(measured: &[(f64, f64)], target: &[(f64, f64)]) -> u64 {
3477 use std::hash::{Hash, Hasher};
3478 let mut h = std::collections::hash_map::DefaultHasher::new();
3479 for (hz, db) in measured.iter().chain([(0.0, 0.0)].iter()).chain(target) {
3480 hz.to_bits().hash(&mut h);
3481 db.to_bits().hash(&mut h);
3482 }
3483 h.finish()
3484}
3485
3486type Fitted = (Vec<config::DspFilter>, f64);
3487
3488struct Lru {
3490 cap: usize,
3491 entries: Vec<(u64, Fitted)>,
3492}
3493
3494impl Lru {
3495 const fn new(cap: usize) -> Self {
3496 Self {
3497 cap,
3498 entries: Vec::new(),
3499 }
3500 }
3501
3502 fn take(&mut self, key: u64) -> Option<Fitted> {
3503 let at = self.entries.iter().position(|(k, _)| *k == key)?;
3504 Some(self.entries.remove(at).1)
3505 }
3506
3507 fn put(&mut self, key: u64, fit: Fitted) {
3508 self.take(key);
3509 if self.entries.len() >= self.cap {
3510 self.entries.remove(0);
3511 }
3512 self.entries.push((key, fit));
3513 }
3514}
3515
3516struct Fits {
3517 playing: Lru,
3520 recent: Lru,
3522}
3523
3524impl Fits {
3525 fn keep(&mut self, key: u64, fit: Fitted, playing: bool) {
3526 if playing {
3527 self.playing.put(key, fit);
3528 } else {
3529 self.recent.put(key, fit);
3530 }
3531 }
3532}
3533
3534static FITS: parking_lot::Mutex<Fits> = parking_lot::Mutex::new(Fits {
3535 playing: Lru::new(4),
3536 recent: Lru::new(32),
3537});
3538
3539thread_local! {
3540 static PLAYING_SCOPE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
3541}
3542
3543pub fn for_playing<T>(f: impl FnOnce() -> T) -> T {
3546 struct Restore(bool);
3547 impl Drop for Restore {
3548 fn drop(&mut self) {
3549 PLAYING_SCOPE.set(self.0);
3550 }
3551 }
3552 let _restore = Restore(PLAYING_SCOPE.replace(true));
3553 f()
3554}
3555
3556pub fn set_layers(name: &str, layers: Vec<crate::config::DspLayer>) -> Result<(), String> {
3561 let name = name.trim();
3562 if name.is_empty() {
3563 return Err("It needs a name".into());
3564 }
3565 if let Some(twice) = layers
3566 .iter()
3567 .enumerate()
3568 .find(|(i, l)| layers[..*i].iter().any(|e| e.profile == l.profile))
3569 .map(|(_, l)| &l.profile)
3570 {
3571 return Err(format!("{twice} is in {name} twice; each plays once"));
3572 }
3573 let cfg = Config::cached();
3574 let mut all = cfg.dsp.profiles.clone();
3575 let probe = profile_mut(&mut all, name);
3576 probe.layers = layers.clone();
3577 let mut check = probe.clone();
3580 if !check.group {
3581 for l in &mut check.layers {
3582 l.on = true;
3583 }
3584 }
3585 let corrections = corrections_in(&check, &all);
3586 let before = cfg
3589 .dsp
3590 .profiles
3591 .iter()
3592 .find(|p| p.name == name)
3593 .map(|p| {
3594 let mut p = p.clone();
3595 if !p.group {
3596 for l in &mut p.layers {
3597 l.on = true;
3598 }
3599 }
3600 corrections_in(&p, &cfg.dsp.profiles)
3601 })
3602 .unwrap_or_default();
3603 if corrections.len() > 1 && corrections.iter().any(|c| !before.contains(c)) {
3604 let mut named: Vec<String> = before
3606 .iter()
3607 .filter(|c| corrections.contains(c))
3608 .cloned()
3609 .collect();
3610 named.extend(corrections.iter().filter(|c| !before.contains(c)).cloned());
3611 return Err(two_corrections(&named));
3612 }
3613 super::chain(&check, &all, &mut Vec::new()).map_err(|e| e.to_string())?;
3614 if everywhere_by_choice(&all, name)
3615 && let Some(layer) = local_layer(&layers, &all)
3616 {
3617 return Err(kept_here(name, layer));
3618 }
3619 persist(|cfg| {
3620 let p = profile_mut(&mut cfg.dsp.profiles, name);
3621 p.layers = layers;
3622 if p.group {
3624 let first = p.layers.iter().position(|l| l.on).unwrap_or(0);
3625 for (i, l) in p.layers.iter_mut().enumerate() {
3626 l.on = i == first;
3627 }
3628 }
3629 })
3630 .map_err(|e| e.to_string())
3631}
3632
3633pub fn delete(name: &str) -> Result<(), String> {
3636 let cfg = Config::cached();
3637 let playing: Vec<String> = cfg
3638 .dsp
3639 .profiles
3640 .iter()
3641 .filter(|p| !p.preset && !p.group && p.layers.iter().any(|l| l.profile == name))
3642 .map(|p| p.name.clone())
3643 .collect();
3644 if !playing.is_empty() {
3645 return Err(format!(
3646 "{} plays {name}: take it out of {} first",
3647 playing.join(", "),
3648 if playing.len() == 1 { "it" } else { "them" }
3649 ));
3650 }
3651 let shared = Config::cached()
3652 .dsp
3653 .profiles
3654 .iter()
3655 .any(|p| p.name != name && slug(&p.name) == slug(name));
3656 persist(|cfg| {
3657 if let Some(uid) = cfg
3659 .dsp
3660 .profiles
3661 .iter()
3662 .find(|p| p.name == name)
3663 .and_then(|p| p.uid.clone())
3664 && !cfg.dsp.removed.contains(&uid)
3665 {
3666 cfg.dsp.removed.push(uid);
3667 }
3668 cfg.dsp.profiles.retain(|p| p.name != name);
3669 for p in &mut cfg.dsp.profiles {
3670 p.layers.retain(|l| l.profile != name);
3671 if p.group
3673 && !p.layers.iter().any(|l| l.on)
3674 && let Some(first) = p.layers.first_mut()
3675 {
3676 first.on = true;
3677 }
3678 }
3679 cfg.dsp.tunings.retain(|t| t.tuning != name);
3680 cfg.dsp.presets.retain(|p| p.preset != name);
3681 })
3682 .map_err(|e| e.to_string())?;
3683 if !shared {
3685 let _ = std::fs::remove_dir_all(dir(name));
3686 }
3687 Ok(())
3688}
3689
3690pub fn set_enabled(enabled: bool) -> Result<(), String> {
3691 persist(|cfg| cfg.dsp.enabled = enabled).map_err(|e| e.to_string())
3692}
3693
3694fn everywhere_by_choice(all: &[DspProfile], name: &str) -> bool {
3697 all.iter()
3698 .find(|p| p.name == name)
3699 .is_some_and(|p| p.scope == Some(DspScope::Everywhere))
3700}
3701
3702fn profile_mut<'a>(profiles: &'a mut Vec<DspProfile>, name: &str) -> &'a mut DspProfile {
3703 let index = match profiles.iter().position(|p| p.name == name) {
3704 Some(i) => i,
3705 None => {
3706 profiles.push(DspProfile {
3707 name: name.into(),
3708 ..Default::default()
3709 });
3710 profiles.len() - 1
3711 }
3712 };
3713 &mut profiles[index]
3714}
3715
3716fn slug(name: &str) -> String {
3718 let s: String = name
3719 .chars()
3720 .map(|c| {
3721 if c.is_alphanumeric() {
3722 c.to_ascii_lowercase()
3723 } else {
3724 '-'
3725 }
3726 })
3727 .collect();
3728 let s = s
3729 .split('-')
3730 .filter(|p| !p.is_empty())
3731 .collect::<Vec<_>>()
3732 .join("-");
3733 if s.is_empty() { "profile".into() } else { s }
3734}
3735
3736#[cfg(test)]
3737mod tests {
3738 use super::*;
3739 use crate::audio::dsp::impulse::{Impulse, Route};
3740
3741 #[test]
3742 fn an_import_lands_beside_the_config_and_merges_by_name() {
3743 let _guard = crate::config::tests::PERSIST_LOCK
3744 .lock()
3745 .unwrap_or_else(|e| e.into_inner());
3746 let dir = tempfile::tempdir().unwrap();
3747 config::set_config_dir(dir.path());
3748
3749 let eq = Imported {
3750 name: "HD 600".into(),
3751 source: vec![],
3752 filters: vec![crate::config::DspFilter::Band(crate::config::EqFilter {
3753 kind: crate::config::EqFilterKind::Peaking,
3754 freq: 100.0,
3755 gain_db: 3.0,
3756 q: 1.0,
3757 channels: vec![],
3758 })],
3759 impulses: vec![],
3760 };
3761 assert_eq!(save(eq, None).unwrap(), "HD 600");
3762 assert_eq!(slug("HD 600 / Röom"), "hd-600-röom");
3763
3764 let crossfeed = Impulse {
3765 rate: 48000,
3766 channels: Some(2),
3767 routes: vec![Route {
3768 ir: vec![1.0],
3769 inputs: vec![(0, 0.5), (1, 0.5)],
3770 outputs: vec![(0, 1.0), (1, 1.0)],
3771 }],
3772 in_delays: vec![],
3773 out_delays: vec![],
3774 };
3775 let room = Imported {
3776 name: "whatever".into(),
3777 source: vec!["whatever-source".into()],
3778 filters: vec![],
3779 impulses: vec![Impulse::from_channels(44100, vec![vec![1.0]]), crossfeed],
3780 };
3781 save(room, Some("HD 600")).unwrap();
3782 assign(Some("HD 600"), "Topping E30").unwrap();
3783
3784 let o = overview();
3785 let p = &o.profiles[0];
3786 assert_eq!(o.profiles.len(), 1);
3787 assert_eq!(p.bands, 1, "the EQ survived the second import");
3788 assert_eq!(p.rates, vec![44100, 48000]);
3789 assert_eq!(p.devices, vec!["Topping E30"]);
3790 assert!(p.problem.is_none(), "{:?}", p.problem);
3791 assert!(dir.path().join("dsp/hd-600/48000.cfg").exists());
3792
3793 let d = detail("HD 600").unwrap();
3794 assert_eq!(d.impulses.len(), 2);
3795 assert!(!d.impulses[0].mixes);
3796 assert!(d.impulses[1].mixes, "the crossfeed route mixes");
3797 assert_eq!(d.source, vec!["whatever-source"]);
3798
3799 rename("HD 600", "Desk").unwrap();
3800 assert!(dir.path().join("dsp/desk/48000.cfg").exists());
3801 let d = detail("Desk").unwrap();
3802 assert!(d.problem.is_none(), "{:?}", d.problem);
3803 assert_eq!(d.impulses.len(), 2);
3804 assert!(rename("Desk", "").is_err());
3805
3806 delete("Desk").unwrap();
3807 assert!(!dir.path().join("dsp/desk").exists());
3808 assert!(overview().profiles.is_empty());
3809 }
3810
3811 fn band(freq: f64) -> crate::config::DspFilter {
3812 crate::config::DspFilter::Band(crate::config::EqFilter {
3813 kind: crate::config::EqFilterKind::Peaking,
3814 freq,
3815 gain_db: 3.0,
3816 q: 1.0,
3817 channels: vec![],
3818 })
3819 }
3820
3821 fn layer(profile: &str, on: bool) -> crate::config::DspLayer {
3822 crate::config::DspLayer {
3823 profile: profile.into(),
3824 on,
3825 }
3826 }
3827
3828 #[test]
3832 fn a_stack_plays_its_layers_in_order() {
3833 let _guard = crate::config::tests::PERSIST_LOCK
3834 .lock()
3835 .unwrap_or_else(|e| e.into_inner());
3836 let dir = tempfile::tempdir().unwrap();
3837 config::set_config_dir(dir.path());
3838 persist(|c| {
3839 for (name, f) in [
3840 ("HD 650", 100.0),
3841 ("Bass +3", 60.0),
3842 ("Treble tilt", 8000.0),
3843 ] {
3844 c.dsp.profiles.push(DspProfile {
3845 name: name.into(),
3846 filters: vec![band(f)],
3847 ..Default::default()
3848 });
3849 }
3850 })
3851 .unwrap();
3852 set_layers(
3853 "Desk",
3854 vec![
3855 layer("HD 650", true),
3856 layer("Bass +3", true),
3857 layer("Treble tilt", false),
3858 ],
3859 )
3860 .unwrap();
3861 let played = || {
3862 let cfg = Config::cached();
3863 let p = cfg.dsp.profiles.iter().find(|p| p.name == "Desk").unwrap();
3864 Setup::load(p, &cfg.dsp.profiles, dir.path())
3865 .unwrap()
3866 .unwrap()
3867 .filters
3868 };
3869 assert_eq!(played(), vec![band(100.0), band(60.0)]);
3870 set_layers(
3871 "Desk",
3872 vec![
3873 layer("Bass +3", true),
3874 layer("HD 650", true),
3875 layer("Treble tilt", true),
3876 ],
3877 )
3878 .unwrap();
3879 assert_eq!(played(), vec![band(60.0), band(100.0), band(8000.0)]);
3880 let summary = overview_for(None);
3881 assert_eq!(
3882 summary
3883 .profiles
3884 .iter()
3885 .find(|p| p.name == "Desk")
3886 .unwrap()
3887 .layers,
3888 3
3889 );
3890
3891 rename("Bass +3", "Bass").unwrap();
3892 assert_eq!(detail("Desk").unwrap().layers[0].profile, "Bass");
3893 assert_eq!(played(), vec![band(60.0), band(100.0), band(8000.0)]);
3894 let refused = delete("Bass").unwrap_err();
3895 assert!(refused.contains("Desk"), "{refused}");
3896 }
3897
3898 #[test]
3899 fn a_stack_that_could_not_play_is_refused() {
3900 let _guard = crate::config::tests::PERSIST_LOCK
3901 .lock()
3902 .unwrap_or_else(|e| e.into_inner());
3903 let dir = tempfile::tempdir().unwrap();
3904 config::set_config_dir(dir.path());
3905 persist(|c| {
3906 c.dsp.profiles.push(DspProfile {
3907 name: "Room".into(),
3908 impulses: vec!["dsp/room/48000.wav".into()],
3909 ..Default::default()
3910 });
3911 c.dsp.profiles.push(DspProfile {
3912 name: "EQ".into(),
3913 filters: vec![band(100.0)],
3914 ..Default::default()
3915 });
3916 })
3917 .unwrap();
3918 assert!(
3919 set_layers("Desk", vec![layer("Nothing", true)]).is_err(),
3920 "missing"
3921 );
3922 assert!(
3923 set_layers("Desk", vec![layer("Room", true)]).is_err(),
3924 "impulses"
3925 );
3926 assert!(
3927 set_layers("Desk", vec![layer("Desk", true)]).is_err(),
3928 "itself"
3929 );
3930 set_layers("Desk", vec![layer("EQ", true)]).unwrap();
3931 assert!(
3933 set_layers("EQ", vec![layer("Desk", false)]).is_err(),
3934 "cycle"
3935 );
3936 }
3937
3938 #[test]
3943 fn a_stack_cannot_grow_without_bound() {
3944 let _guard = crate::config::tests::PERSIST_LOCK
3945 .lock()
3946 .unwrap_or_else(|e| e.into_inner());
3947 let dir = tempfile::tempdir().unwrap();
3948 config::set_config_dir(dir.path());
3949 persist(|c| {
3950 c.dsp.profiles.push(DspProfile {
3951 name: "D".into(),
3952 filters: vec![band(100.0)],
3953 ..Default::default()
3954 });
3955 })
3956 .unwrap();
3957 set_layers("B", vec![layer("D", true)]).unwrap();
3959 set_layers("C", vec![layer("D", true)]).unwrap();
3960 set_layers("A", vec![layer("B", true), layer("C", true)]).unwrap();
3961 {
3962 let cfg = Config::cached();
3963 let a = cfg.dsp.profiles.iter().find(|p| p.name == "A").unwrap();
3964 let played = Setup::load(a, &cfg.dsp.profiles, dir.path())
3965 .unwrap()
3966 .unwrap();
3967 assert_eq!(
3968 played.filters,
3969 vec![band(100.0), band(100.0)],
3970 "a diamond resolves"
3971 );
3972 }
3973 assert!(
3974 set_layers("X", vec![layer("D", true), layer("D", true)]).is_err(),
3975 "twice"
3976 );
3977
3978 persist(|c| {
3981 for i in 0..30 {
3982 let below = |n: i32| {
3983 if n < 0 {
3984 "D".to_string()
3985 } else {
3986 format!("L{n}")
3987 }
3988 };
3989 c.dsp.profiles.push(DspProfile {
3990 name: format!("L{i}"),
3991 layers: vec![layer(&below(i - 1), true), layer(&below(i - 2), true)],
3992 ..Default::default()
3993 });
3994 }
3995 })
3996 .unwrap();
3997 let cfg = Config::cached();
3998 let top = cfg.dsp.profiles.iter().find(|p| p.name == "L29").unwrap();
3999 let err = Setup::load(top, &cfg.dsp.profiles, dir.path())
4000 .err()
4001 .unwrap();
4002 assert!(err.to_string().contains("L29"), "{err}");
4003 let summary = overview_for(None);
4004 let l29 = summary.profiles.iter().find(|p| p.name == "L29").unwrap();
4005 assert!(l29.problem.is_some(), "Settings shows why it does not play");
4006 assert!(set_layers("L30", vec![layer("L29", true)]).is_err());
4007 }
4008
4009 #[test]
4013 fn a_response_is_the_filters_the_dsp_runs() {
4014 let _guard = crate::config::tests::PERSIST_LOCK
4015 .lock()
4016 .unwrap_or_else(|e| e.into_inner());
4017 let dir = tempfile::tempdir().unwrap();
4018 config::set_config_dir(dir.path());
4019 persist(|c| {
4020 c.dsp.profiles.push(DspProfile {
4021 name: "Peak".into(),
4022 filters: vec![band(1000.0)],
4023 ..Default::default()
4024 });
4025 c.dsp.profiles.push(DspProfile {
4026 name: "Shelf".into(),
4027 filters: vec![crate::config::DspFilter::Band(crate::config::EqFilter {
4028 kind: crate::config::EqFilterKind::LowShelf,
4029 freq: 100.0,
4030 gain_db: 6.0,
4031 q: 0.7,
4032 channels: vec![],
4033 })],
4034 ..Default::default()
4035 });
4036 })
4037 .unwrap();
4038 let r = response("Peak", 48000).unwrap();
4039 let at = |r: &Response, curve: &[f64], hz: f64| {
4040 let i = r.freqs.iter().position(|f| *f >= hz).unwrap();
4041 curve[i]
4042 };
4043 assert!(
4044 (at(&r, &r.total, 1000.0) - 3.0).abs() < 0.2,
4045 "{}",
4046 at(&r, &r.total, 1000.0)
4047 );
4048 assert!(at(&r, &r.total, 20.0).abs() < 0.1);
4049 assert_eq!(r.bands.len(), 1);
4050 assert!(r.measurement.is_none());
4051 assert!(
4052 r.preamp_db < -2.5,
4053 "the peak is taken off ahead: {}",
4054 r.preamp_db
4055 );
4056
4057 set_layers("Both", vec![layer("Shelf", true), layer("Peak", true)]).unwrap();
4058 let r = response("Both", 48000).unwrap();
4059 assert_eq!(r.layers.len(), 2);
4060 assert!((at(&r, &r.total, 30.0) - 6.0).abs() < 0.5, "the shelf");
4061 assert!((at(&r, &r.total, 1000.0) - 3.0).abs() < 0.5, "the peak");
4062 assert!(r.bands.is_empty(), "no bands of its own");
4063 set_layers("Both", vec![layer("Shelf", false), layer("Peak", true)]).unwrap();
4064 let r = response("Both", 48000).unwrap();
4065 assert!(at(&r, &r.total, 30.0).abs() < 0.3, "the shelf off");
4066 assert!(!r.layers[0].on);
4067 }
4068
4069 fn fresh_config() -> (std::sync::MutexGuard<'static, ()>, tempfile::TempDir) {
4070 let guard = crate::config::tests::PERSIST_LOCK
4071 .lock()
4072 .unwrap_or_else(|e| e.into_inner());
4073 let dir = tempfile::tempdir().unwrap();
4074 config::set_config_dir(dir.path());
4075 (guard, dir)
4076 }
4077
4078 #[test]
4081 fn a_new_eq_starts_as_eight_bands() {
4082 use crate::config::{DspFilter, EqFilterKind};
4083 let (_guard, _dir) = fresh_config();
4084 create_eq("Desk", Starter::Flat).unwrap();
4085 let d = detail("Desk").unwrap();
4086 assert_eq!(d.filters.len(), 8);
4087 assert_eq!(d.role, DspRole::Tuning);
4088 let kinds: Vec<EqFilterKind> = d
4089 .filters
4090 .iter()
4091 .map(|f| match f {
4092 DspFilter::Band(b) => {
4093 assert_eq!(b.gain_db, 0.0);
4094 b.kind
4095 }
4096 _ => panic!("bands alone"),
4097 })
4098 .collect();
4099 assert_eq!(kinds[0], EqFilterKind::LowShelf);
4100 assert_eq!(kinds[7], EqFilterKind::HighShelf);
4101 assert!(kinds[1..7].iter().all(|k| *k == EqFilterKind::Peaking));
4102 let r = response("Desk", 48000).unwrap();
4103 assert!(
4104 r.total.iter().all(|db| db.abs() < 1e-9),
4105 "flat plays untouched"
4106 );
4107
4108 create_eq("Thump", Starter::BassBoost).unwrap();
4109 let r = response("Thump", 48000).unwrap();
4110 assert!(r.total[0] > 3.0 && r.total.last().unwrap().abs() < 0.5);
4111 assert!(create_eq("Desk", Starter::Warm).is_err());
4112 assert!(create_eq(" ", Starter::Flat).is_err());
4113 }
4114
4115 #[test]
4118 fn an_eq_switches_between_one_list_and_one_per_channel() {
4119 use crate::config::DspFilter;
4120 let (_guard, _dir) = fresh_config();
4121 create_eq("Desk", Starter::Flat).unwrap();
4122 let layout = || detail("Desk").unwrap().channels.unwrap();
4123 assert_eq!(
4124 layout(),
4125 ChannelLayout {
4126 stereo: false,
4127 differ: false
4128 }
4129 );
4130 assert!(response("Desk", 48000).unwrap().right.is_none());
4131
4132 set_stereo("Desk", true, 0).unwrap();
4133 assert_eq!(
4134 layout(),
4135 ChannelLayout {
4136 stereo: true,
4137 differ: false
4138 }
4139 );
4140 assert_eq!(detail("Desk").unwrap().filters.len(), 16);
4141
4142 set_band("Desk", 8, "low_shelf", 100.0, 6.0, 0.7).unwrap();
4144 assert_eq!(
4145 layout(),
4146 ChannelLayout {
4147 stereo: true,
4148 differ: true
4149 }
4150 );
4151 let r = response("Desk", 48000).unwrap();
4152 assert!(r.total[0].abs() < 1e-9, "left untouched");
4153 assert!(r.right.unwrap()[0] > 5.0, "right drawn of its own");
4154 let i = add_band("Desk", Some(1)).unwrap();
4155 assert_eq!(detail("Desk").unwrap().filters[i].channels(), &[1]);
4156 remove_filter("Desk", i).unwrap();
4157
4158 set_stereo("Desk", false, 1).unwrap();
4159 assert_eq!(
4160 layout(),
4161 ChannelLayout {
4162 stereo: false,
4163 differ: false
4164 }
4165 );
4166 let filters = detail("Desk").unwrap().filters;
4167 assert_eq!(filters.len(), 8);
4168 assert!(filters.iter().all(|f| f.channels().is_empty()));
4169 assert!(
4170 matches!(&filters[0], DspFilter::Band(b) if b.gain_db == 6.0),
4171 "kept the right"
4172 );
4173 }
4174
4175 #[test]
4178 fn an_output_plays_filters_alone() {
4179 let (_guard, _dir) = fresh_config();
4180 persist(|c| {
4181 c.dsp.profiles.push(DspProfile {
4182 name: "HD 650".into(),
4183 role: Some(DspRole::Correction),
4184 ..Default::default()
4185 })
4186 })
4187 .unwrap();
4188 create_eq("Room", Starter::Warm).unwrap();
4189 assign(Some("HD 650"), "Desk").unwrap();
4190 set_tunings("Desk", &[("Room".into(), true)]).unwrap();
4191
4192 set_filters_only("Desk", true).unwrap();
4193 assert!(filters_only("Desk"));
4194 assert!(
4195 Config::cached().dsp.profile_for("Desk").is_none(),
4196 "no correction"
4197 );
4198 save_preset("Desk", "Speakers").unwrap();
4199 assert_eq!(preset_for("Desk"), Some(("Speakers".into(), false)));
4200
4201 set_filters_only("Desk", false).unwrap();
4202 assert_eq!(
4203 preset_for("Desk"),
4204 Some(("Speakers".into(), true)),
4205 "changed since"
4206 );
4207 apply_preset("Desk", Some("Speakers")).unwrap();
4208 assert!(filters_only("Desk"));
4209
4210 assign(Some("HD 650"), "Desk").unwrap();
4211 assert!(!filters_only("Desk"));
4212
4213 persist(|c| {
4217 let p = c
4218 .dsp
4219 .profiles
4220 .iter_mut()
4221 .find(|p| p.name == "Speakers")
4222 .unwrap();
4223 p.layers.insert(
4224 0,
4225 crate::config::DspLayer {
4226 profile: "HD 650".into(),
4227 on: true,
4228 },
4229 );
4230 })
4231 .unwrap();
4232 apply_preset("Desk", Some("Speakers")).unwrap();
4233 assert!(filters_only("Desk"));
4234 assert!(Config::cached().dsp.profile_for("Desk").is_none());
4235 persist(|c| {
4236 let p = c
4237 .dsp
4238 .profiles
4239 .iter_mut()
4240 .find(|p| p.name == "HD 650")
4241 .unwrap();
4242 p.devices.push("Desk".into());
4243 })
4244 .unwrap();
4245 assert!(
4246 Config::cached().dsp.profile_for("Desk").is_none(),
4247 "never plays"
4248 );
4249
4250 assign(Some("Room"), "Desk").unwrap();
4253 assert!(filters_only("Desk"));
4254 set_filters_only("Desk", true).unwrap();
4255 assert_eq!(
4256 Config::cached()
4257 .dsp
4258 .profile_for("Desk")
4259 .map(|p| p.name.clone()),
4260 Some("Room".into())
4261 );
4262 }
4263
4264 #[test]
4267 fn stereo_is_set_only_on_bands_alone() {
4268 let (_guard, _dir) = fresh_config();
4269 create_eq("Desk", Starter::Flat).unwrap();
4270 assert!(set_stereo("Desk", false, 2).is_err());
4271 persist(|c| {
4272 c.dsp.profiles.push(DspProfile {
4273 name: "Stack".into(),
4274 layers: vec![crate::config::DspLayer {
4275 profile: "Desk".into(),
4276 on: true,
4277 }],
4278 ..Default::default()
4279 })
4280 })
4281 .unwrap();
4282 assert!(set_stereo("Stack", true, 0).is_err());
4283 }
4284
4285 #[test]
4288 fn bands_are_edited_in_place() {
4289 use crate::config::{DspFilter, EqFilter, EqFilterKind};
4290 let _guard = crate::config::tests::PERSIST_LOCK
4291 .lock()
4292 .unwrap_or_else(|e| e.into_inner());
4293 let dir = tempfile::tempdir().unwrap();
4294 config::set_config_dir(dir.path());
4295 persist(|c| {
4296 c.dsp.profiles.push(DspProfile {
4297 name: "Mine".into(),
4298 filters: vec![DspFilter::Band(EqFilter {
4299 kind: EqFilterKind::Peaking,
4300 freq: 100.0,
4301 gain_db: 3.0,
4302 q: 1.0,
4303 channels: vec![1],
4304 })],
4305 ..Default::default()
4306 })
4307 })
4308 .unwrap();
4309 set_band("Mine", 0, "low_shelf", 80.0, 99.0, 0.0).unwrap();
4310 let filters = || detail("Mine").unwrap().filters;
4311 assert_eq!(
4312 filters()[0],
4313 DspFilter::Band(EqFilter {
4314 kind: EqFilterKind::LowShelf,
4315 freq: 80.0,
4316 gain_db: 30.0,
4317 q: 0.1,
4318 channels: vec![1],
4319 })
4320 );
4321 assert!(set_band("Mine", 0, "wobble", 80.0, 0.0, 1.0).is_err());
4322 assert!(set_band("Mine", 0, "peaking", f64::NAN, 0.0, 1.0).is_err());
4323 assert!(set_band("Mine", 5, "peaking", 80.0, 0.0, 1.0).is_err());
4324 assert_eq!(add_band("Mine", None).unwrap(), 1);
4325 assert_eq!(filters().len(), 2);
4326 let r = response("Mine", 48000).unwrap();
4327 assert_eq!(r.bands.len(), 2);
4328 remove_filter("Mine", 0).unwrap();
4329 assert_eq!(filters().len(), 1);
4330 assert!(remove_filter("Mine", 3).is_err());
4331 assert!(add_band("Nobody", None).is_err());
4332 assert!(set_curve("Mine", 0, &[(100.0, 1.0)]).is_err(), "a band");
4333 }
4334
4335 #[test]
4339 fn previews_and_copies_leave_the_original() {
4340 use crate::config::{DspFilter, DspProfile, EqFilter, EqFilterKind, GraphicEq};
4341 let _guard = crate::config::tests::PERSIST_LOCK
4342 .lock()
4343 .unwrap_or_else(|e| e.into_inner());
4344 let dir = tempfile::tempdir().unwrap();
4345 config::set_config_dir(dir.path());
4346 let flat = DspFilter::Band(EqFilter {
4347 kind: EqFilterKind::Peaking,
4348 freq: 1000.0,
4349 gain_db: 0.0,
4350 q: 1.0,
4351 channels: vec![],
4352 });
4353 let curve = DspFilter::Graphic(GraphicEq {
4354 points: vec![(20.0, 0.0), (20000.0, 0.0)],
4355 channels: vec![],
4356 });
4357 persist(|c| {
4358 c.dsp.profiles.push(DspProfile {
4359 name: "Warm".into(),
4360 filters: vec![flat.clone(), curve],
4361 ..Default::default()
4362 })
4363 })
4364 .unwrap();
4365 let peak = |r: &Response| r.total.iter().cloned().fold(f64::MIN, f64::max);
4366 let r = preview_band("Warm", 0, "peaking", 1000.0, 6.0, 1.0, 48000).unwrap();
4367 assert!((peak(&r) - 6.0).abs() < 0.5, "{}", peak(&r));
4368 let r = preview_curve("Warm", 1, &[(20.0, 4.0), (20000.0, 4.0)], 48000).unwrap();
4369 assert!((peak(&r) - 4.0).abs() < 0.5, "{}", peak(&r));
4370 assert!(preview_band("Warm", 1, "peaking", 1000.0, 6.0, 1.0, 48000).is_none());
4371 assert!(preview_curve("Warm", 0, &[(20.0, 4.0)], 48000).is_none());
4372 assert_eq!(detail("Warm").unwrap().filters[0], flat, "nothing saved");
4373
4374 let dac = "DAC";
4375 set_tunings(dac, &[("Warm".into(), true)]).unwrap();
4376 let before = snapshot("Warm").unwrap();
4377 set_band("Warm", 0, "peaking", 1000.0, 6.0, 1.0).unwrap();
4378 let saved = save_as_copy("Warm", None, Some(&before), Some(dac)).unwrap();
4379 assert!(saved.placed);
4380 let copy = saved.name;
4381 assert_eq!(copy, "Warm copy");
4382 assert_eq!(detail("Warm").unwrap().filters[0], flat, "put back");
4383 assert!(matches!(
4384 detail(©).unwrap().filters[0],
4385 DspFilter::Band(EqFilter { gain_db: 6.0, .. })
4386 ));
4387 assert_eq!(tunings_for(dac), vec![(copy, true)]);
4388 assert!(save_as_copy("Warm", None, Some("nonsense"), None).is_err());
4389 let elsewhere = save_as_copy("Warm", Some("Spare"), None, Some("Speakers")).unwrap();
4390 assert!(!elsewhere.placed, "not in that tuning");
4391
4392 persist(|c| {
4394 c.dsp.profiles.push(DspProfile {
4395 name: "HD 600".into(),
4396 role: Some(crate::config::DspRole::Correction),
4397 filters: vec![flat.clone()],
4398 ..Default::default()
4399 })
4400 })
4401 .unwrap();
4402 assert!(preview_band("HD 600", 0, "peaking", 1000.0, 6.0, 1.0, 48000).is_none());
4403 assert!(save_as_copy("HD 600", None, None, None).is_err());
4404 assert!(detail("HD 600 copy").is_none());
4405
4406 persist(|c| {
4408 c.dsp.profiles.push(DspProfile {
4409 name: "Fitted".into(),
4410 role: Some(crate::config::DspRole::Tuning),
4411 filters: vec![flat.clone()],
4412 fitted: Some(crate::config::DspMeasurement {
4413 ear: crate::config::DspEar::Over,
4414 target: "harman-over-ear-2018".into(),
4415 }),
4416 ..Default::default()
4417 })
4418 })
4419 .unwrap();
4420 assert!(detail("Fitted").unwrap().read_only);
4421 assert!(preview_band("Fitted", 0, "peaking", 1000.0, 6.0, 1.0, 48000).is_none());
4422 let refused = set_band("Fitted", 0, "peaking", 1000.0, 6.0, 1.0).unwrap_err();
4423 assert!(refused.contains("fitted to a measurement"), "{refused}");
4424 assert!(save_as_copy("Fitted", None, None, None).is_err());
4425 }
4426
4427 #[test]
4430 fn curves_are_edited_in_place() {
4431 use crate::config::{DspFilter, DspProfile, GraphicEq};
4432 let _guard = crate::config::tests::PERSIST_LOCK
4433 .lock()
4434 .unwrap_or_else(|e| e.into_inner());
4435 let dir = tempfile::tempdir().unwrap();
4436 config::set_config_dir(dir.path());
4437 let curve = DspFilter::Graphic(GraphicEq {
4438 points: vec![(20.0, 0.0), (1000.0, 2.0)],
4439 channels: vec![0],
4440 });
4441 persist(|c| {
4442 c.dsp.profiles.push(DspProfile {
4443 name: "Mine".into(),
4444 filters: vec![curve.clone()],
4445 ..Default::default()
4446 });
4447 c.dsp.profiles.push(DspProfile {
4448 name: "Fixed".into(),
4449 role: Some(crate::config::DspRole::Correction),
4450 filters: vec![curve],
4451 ..Default::default()
4452 });
4453 })
4454 .unwrap();
4455 set_curve("Mine", 0, &[(5000.0, 99.0), (2.0, -1.0)]).unwrap();
4456 assert_eq!(
4457 detail("Mine").unwrap().filters[0],
4458 DspFilter::Graphic(GraphicEq {
4459 points: vec![(10.0, -1.0), (5000.0, 30.0)],
4460 channels: vec![0],
4461 })
4462 );
4463 assert!(set_curve("Mine", 0, &[]).is_err());
4464 let most = crate::config::dsp_bounds::GRAPHIC_POINTS;
4465 let many: Vec<(f64, f64)> = (0..=most).map(|i| (20.0 + i as f64, 0.0)).collect();
4466 assert!(set_curve("Mine", 0, &many).is_err(), "too many points");
4467 assert!(set_curve("Mine", 0, &many[..most]).is_ok());
4468 assert!(set_curve("Mine", 0, &[(f64::NAN, 0.0)]).is_err());
4469 assert!(set_curve("Mine", 1, &[(100.0, 0.0)]).is_err());
4470 assert!(
4471 set_curve("Fixed", 0, &[(100.0, 0.0)]).is_err(),
4472 "a correction"
4473 );
4474 }
4475
4476 #[test]
4479 fn a_group_plays_one_member() {
4480 use crate::config::{DspFilter, DspLayer};
4481 let _guard = crate::config::tests::PERSIST_LOCK
4482 .lock()
4483 .unwrap_or_else(|e| e.into_inner());
4484 let dir = tempfile::tempdir().unwrap();
4485 config::set_config_dir(dir.path());
4486 persist(|c| {
4487 for (name, hz) in [
4488 ("Warm", 100.0),
4489 ("Bright", 8000.0),
4490 ("Flat", 1000.0),
4491 ("Bass", 60.0),
4492 ] {
4493 c.dsp.profiles.push(DspProfile {
4494 name: name.into(),
4495 filters: vec![band(hz)],
4496 ..Default::default()
4497 });
4498 }
4499 })
4500 .unwrap();
4501 let names = ["Warm", "Bright", "Flat"].map(String::from);
4502 make_group("Presets", &names).unwrap();
4503 assert!(make_group("Presets", &names).is_err());
4504 let freqs = |name: &str| -> Vec<f64> {
4505 let cfg = Config::cached();
4506 let p = cfg
4507 .dsp
4508 .profiles
4509 .iter()
4510 .find(|p| p.name == name)
4511 .unwrap()
4512 .clone();
4513 super::super::chain(&p, &cfg.dsp.profiles, &mut Vec::new())
4514 .unwrap()
4515 .into_iter()
4516 .map(|f| match f {
4517 DspFilter::Band(b) => b.freq,
4518 other => panic!("{other:?}"),
4519 })
4520 .collect()
4521 };
4522 assert_eq!(freqs("Presets"), [100.0], "the first plays");
4523 select("Presets", "Bright").unwrap();
4524 assert_eq!(freqs("Presets"), [8000.0]);
4525 assert!(select("Presets", "Bass").is_err(), "not a member");
4526
4527 let layer = |p: &str, on: bool| DspLayer {
4529 profile: p.into(),
4530 on,
4531 };
4532 set_layers(
4533 "Presets",
4534 vec![
4535 layer("Warm", false),
4536 layer("Bright", true),
4537 layer("Flat", true),
4538 ],
4539 )
4540 .unwrap();
4541 assert_eq!(freqs("Presets"), [8000.0]);
4542 assert_eq!(
4543 detail("Presets")
4544 .unwrap()
4545 .layers
4546 .iter()
4547 .filter(|l| l.on)
4548 .count(),
4549 1
4550 );
4551
4552 set_layers("Desk", vec![layer("Presets", true), layer("Bass", true)]).unwrap();
4554 assert_eq!(freqs("Desk"), [8000.0, 60.0]);
4555
4556 set_group("Presets", false).unwrap();
4558 set_layers(
4559 "Presets",
4560 vec![
4561 layer("Warm", true),
4562 layer("Bright", true),
4563 layer("Flat", false),
4564 ],
4565 )
4566 .unwrap();
4567 assert_eq!(freqs("Presets"), [100.0, 8000.0]);
4568 set_group("Presets", true).unwrap();
4569 assert_eq!(
4570 freqs("Presets"),
4571 [100.0],
4572 "the first switched on keeps playing"
4573 );
4574
4575 persist(|c| {
4577 let p = c.dsp.profiles.iter_mut().find(|p| p.name == "Presets");
4578 for l in &mut p.unwrap().layers {
4579 l.on = false;
4580 }
4581 })
4582 .unwrap();
4583 assert_eq!(freqs("Presets"), [100.0]);
4584 }
4585
4586 #[test]
4590 fn a_group_plays_its_members_responses() {
4591 use crate::config::DspLayer;
4592 let _guard = crate::config::tests::PERSIST_LOCK
4593 .lock()
4594 .unwrap_or_else(|e| e.into_inner());
4595 let dir = tempfile::tempdir().unwrap();
4596 config::set_config_dir(dir.path());
4597 let mut paths = Vec::new();
4598 for rate in [44100, 48000] {
4599 let path = dir.path().join(format!("{rate}.wav"));
4600 super::super::raw::write_wav(&path, rate, &[vec![1.0, 0.0]]).unwrap();
4601 paths.push(path);
4602 }
4603 persist(|c| {
4604 for (name, path) in [("Room 44", &paths[0]), ("Room 48", &paths[1])] {
4605 c.dsp.profiles.push(DspProfile {
4606 name: name.into(),
4607 impulses: vec![path.clone()],
4608 ..Default::default()
4609 });
4610 }
4611 })
4612 .unwrap();
4613 make_group("Room", &["Room 44".into(), "Room 48".into()]).unwrap();
4614 let rates = || {
4615 let cfg = Config::cached();
4616 let p = cfg.dsp.profiles.iter().find(|p| p.name == "Room").unwrap();
4617 super::super::Setup::load(p, &cfg.dsp.profiles, dir.path())
4618 .unwrap()
4619 .unwrap()
4620 .rates()
4621 };
4622 assert_eq!(rates(), [44100]);
4623 select("Room", "Room 48").unwrap();
4624 assert_eq!(rates(), [48000]);
4625 let refused = set_layers(
4626 "Desk",
4627 vec![DspLayer {
4628 profile: "Room".into(),
4629 on: true,
4630 }],
4631 )
4632 .unwrap_err();
4633 assert!(
4634 refused.contains("only bands can be played by another EQ"),
4635 "{refused}"
4636 );
4637 }
4638
4639 #[test]
4642 fn a_free_name_takes_nothing() {
4643 let _guard = crate::config::tests::PERSIST_LOCK
4644 .lock()
4645 .unwrap_or_else(|e| e.into_inner());
4646 let dir = tempfile::tempdir().unwrap();
4647 config::set_config_dir(dir.path());
4648 assert_eq!(free_name("Room"), "Room");
4649 persist(|c| {
4650 c.dsp.profiles.push(DspProfile {
4651 name: "Room".into(),
4652 filters: vec![band(100.0)],
4653 ..Default::default()
4654 })
4655 })
4656 .unwrap();
4657 assert_eq!(free_name("Room"), "Room 2");
4658 assert_eq!(free_name("room!"), "room! 2", "the same folder as Room's");
4659 }
4660
4661 #[test]
4665 fn where_a_profile_is_kept() {
4666 use crate::config::{DspLayer, DspTarget};
4667 let _guard = crate::config::tests::PERSIST_LOCK
4668 .lock()
4669 .unwrap_or_else(|e| e.into_inner());
4670 let dir = tempfile::tempdir().unwrap();
4671 config::set_config_dir(dir.path());
4672 persist(|c| {
4673 c.dsp.profiles.push(DspProfile {
4674 name: "HD 650".into(),
4675 filters: vec![band(1000.0)],
4676 target: Some(DspTarget {
4677 made_for: "harman-over-ear-2018".into(),
4678 chosen: None,
4679 }),
4680 ..Default::default()
4681 });
4682 c.dsp.profiles.push(DspProfile {
4683 name: "Room".into(),
4684 impulses: vec!["dsp/room/48000.wav".into()],
4685 ..Default::default()
4686 });
4687 c.dsp.profiles.push(DspProfile {
4688 name: "Bass".into(),
4689 filters: vec![band(80.0)],
4690 ..Default::default()
4691 });
4692 c.dsp.profiles.push(DspProfile {
4693 name: "Amp".into(),
4694 filters: vec![band(80.0)],
4695 devices: vec!["uuid:5f9ec1b3-ed59-79bb-4530-745e2b1d1b10".into()],
4696 ..Default::default()
4697 });
4698 })
4699 .unwrap();
4700 let kept = |name: &str| {
4701 let cfg = Config::cached();
4702 let p = cfg
4703 .dsp
4704 .profiles
4705 .iter()
4706 .find(|p| p.name == name)
4707 .unwrap()
4708 .clone();
4709 scope(&p, &cfg.dsp.profiles)
4710 };
4711 assert_eq!(kept("HD 650"), DspScope::Everywhere);
4712 assert_eq!(kept("Room"), DspScope::Device);
4713 assert_eq!(kept("Bass"), DspScope::Everywhere);
4714 assert_eq!(
4715 kept("Amp"),
4716 DspScope::Device,
4717 "an amplifier stays where it is"
4718 );
4719
4720 let layer = |p: &str| DspLayer {
4721 profile: p.into(),
4722 on: true,
4723 };
4724 set_layers("Desk", vec![layer("HD 650"), layer("Bass")]).unwrap();
4725 assert_eq!(kept("Desk"), DspScope::Everywhere);
4726 set_layers("Speakers", vec![layer("Amp"), layer("Bass")]).unwrap();
4727 assert_eq!(
4728 kept("Speakers"),
4729 DspScope::Device,
4730 "a stack here may use shared layers"
4731 );
4732
4733 set_scope("Desk", DspScope::Everywhere).unwrap();
4734 let refused = set_layers("Desk", vec![layer("HD 650"), layer("Amp")]).unwrap_err();
4735 assert!(refused.contains("Amp is kept on this device"), "{refused}");
4736 let refused = set_scope("Bass", DspScope::Device).unwrap_err();
4737 assert!(refused.contains("Bass is played by Desk"), "{refused}");
4738 let locked = |name: &str| {
4739 let cfg = Config::cached();
4740 let p = cfg.dsp.profiles.iter().find(|p| p.name == name).unwrap();
4741 scope_locked(p, &cfg.dsp.profiles)
4742 };
4743 assert_eq!(
4744 locked("Bass").as_deref(),
4745 Some("Desk plays it on every device")
4746 );
4747 assert_eq!(
4748 locked("Speakers").as_deref(),
4749 Some("plays Amp, kept on this device")
4750 );
4751 assert_eq!(locked("Desk"), None);
4752 set_scope("Speakers", DspScope::Everywhere).unwrap_err();
4753 set_scope("Desk", DspScope::Device).unwrap();
4754 set_scope("Bass", DspScope::Device).unwrap();
4755 assert_eq!(kept("Bass"), DspScope::Device);
4756 }
4757
4758 fn measured(bump: f64) -> String {
4760 let mut text = String::from("frequency,raw\n");
4761 for hz in super::super::targets::grid() {
4762 let db = bump * (-((hz / 3000.0).log2() * 3.0).powi(2)).exp();
4763 text.push_str(&format!("{hz:.2},{db:.2}\n"));
4764 }
4765 text
4766 }
4767
4768 #[test]
4771 fn an_unreadable_measurement_says_what_it_expected_and_found() {
4772 let path = read_measurement("~/Downloads/Speaker/SPL Vertical.txt").unwrap_err();
4773 assert!(
4774 path.contains("two-column CSV or a squig.link export"),
4775 "{path}"
4776 );
4777 assert!(path.contains("file's path"), "{path}");
4778 let short = read_measurement("20, 92.4\n21, 92.6\n").unwrap_err();
4779 assert!(short.contains("this has 2 from 20 Hz to 21 Hz"), "{short}");
4780 let prose = read_measurement("Hello there\nnothing numeric").unwrap_err();
4781 assert!(prose.contains("“Hello there”"), "{prose}");
4782 }
4783
4784 #[test]
4788 fn a_klippel_export_is_a_speaker_measurement() {
4789 use super::super::klippel::tests::export;
4790 let tilt = |_: f64, hz: f64| 88.0 - (hz / 1000.0).log2();
4791 let vertical = export("SPL Vertical", tilt);
4792 let one = describe_measurement(&vertical).unwrap();
4793 assert!(one.speaker);
4794 assert!(
4795 one.note.starts_with("A speaker's on-axis response"),
4796 "{}",
4797 one.note
4798 );
4799 assert!(one.note.contains("SPL Horizontal"), "{}", one.note);
4800 let both = export("SPL Horizontal", tilt) + &vertical;
4801 assert!(
4802 describe_measurement(&both)
4803 .unwrap()
4804 .note
4805 .contains("listening window (CTA-2034)")
4806 );
4807 let curve = read_measurement(&both).unwrap();
4808 assert!(curve.last().unwrap().0 > 19_000.0);
4809 assert!((super::super::targets::at(&curve, 4000.0) + 2.0).abs() < 0.01);
4810 assert!(preview_measurement(&both, "harman-over-ear-2018", 48000).is_err());
4811 assert!(!describe_measurement(&measured(6.0)).unwrap().speaker);
4812 assert!(measured_profile("X", &both, DspEar::Over, "harman-over-ear-2018").is_err());
4813 assert!(measured_profile("X", &both, DspEar::Speaker, "harman-over-ear-2018").is_err());
4814 assert!(measured_profile("X", &measured(6.0), DspEar::In, "flat").is_err());
4815 }
4816
4817 #[test]
4820 fn a_speakers_listening_window_is_fitted_toward_flat() {
4821 use super::super::klippel::tests::export;
4822 let level = |_: f64, hz: f64| {
4824 let bump = |f: f64, db: f64| db * (-((hz / f).log2() * 2.0).powi(2)).exp();
4825 85.0 + bump(120.0, 5.0) + bump(2500.0, -4.0) + bump(600.0, 2.5)
4826 };
4827 let both = export("SPL Horizontal", level) + &export("SPL Vertical", level);
4828 let preview = preview_measurement(&both, "flat", 48000).unwrap();
4829 let spread = |curve: &[f64]| {
4831 let band: Vec<f64> = preview
4832 .freqs
4833 .iter()
4834 .zip(curve)
4835 .filter(|(hz, _)| (30.0..=6000.0).contains(*hz))
4836 .map(|(_, db)| *db)
4837 .collect();
4838 let mean = band.iter().sum::<f64>() / band.len() as f64;
4839 (band.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / band.len() as f64).sqrt()
4840 };
4841 let before = spread(preview.measurement.as_ref().unwrap());
4842 let after = spread(preview.predicted.as_ref().unwrap());
4843 assert!(
4844 preview
4845 .target
4846 .as_ref()
4847 .unwrap()
4848 .iter()
4849 .all(|d| d.abs() < 1e-9)
4850 );
4851 assert!(before > 1.5, "{before}");
4852 assert!(after < before / 3.0, "{before} → {after}");
4853 }
4854
4855 #[test]
4858 fn a_measurement_is_corrected_to_its_target() {
4859 use super::super::targets;
4860 let _guard = crate::config::tests::PERSIST_LOCK
4861 .lock()
4862 .unwrap_or_else(|e| e.into_inner());
4863 let dir = tempfile::tempdir().unwrap();
4864 config::set_config_dir(dir.path());
4865 let text = measured(6.0);
4866 assert!(
4867 read_measurement("1000,0\n").is_err(),
4868 "too little to correct from"
4869 );
4870 let preview = preview_measurement(&text, "harman-in-ear-2019", 48000).unwrap();
4871 assert!(preview.predicted.is_some() && preview.measurement.is_some());
4872
4873 save_measured("AFUL Performer 8S", &text, DspEar::In, "harman-in-ear-2019").unwrap();
4874 assert!(
4875 save_measured("AFUL Performer 8S", &text, DspEar::In, "harman-in-ear-2019").is_err()
4876 );
4877 let cfg = Config::cached();
4878 let p = cfg
4879 .dsp
4880 .profiles
4881 .iter()
4882 .find(|p| p.name == "AFUL Performer 8S")
4883 .unwrap()
4884 .clone();
4885 assert_eq!(role(&p), DspRole::Correction);
4886 assert_eq!(scope(&p, &cfg.dsp.profiles), DspScope::Everywhere);
4887 assert_eq!(
4888 summary("AFUL Performer 8S").unwrap().correction.as_deref(),
4889 Some("AFUL Performer 8S → Harman in-ear 2019 (from measurement)")
4890 );
4891
4892 use crate::config::{DspFilter, EqFilterKind};
4895 assert!(!p.filters.is_empty());
4896 assert!(p.filters.iter().all(|f| matches!(
4897 f,
4898 DspFilter::Band(b) if b.kind == EqFilterKind::Peaking && b.freq <= 6000.0
4899 )));
4900 assert!(p.preamp_db.is_some_and(|db| db <= 0.0));
4901 assert!(p.measurement.is_none() && p.fitted.is_some());
4902 let played = super::super::chain(&p, &cfg.dsp.profiles, &mut Vec::new()).unwrap();
4903 assert_eq!(played, p.filters);
4904 let r = response("AFUL Performer 8S", 48000).unwrap();
4907 let at = |hz: f64| r.total[r.freqs.iter().position(|f| *f >= hz).unwrap()];
4908 let wanted = targets::difference(
4909 &read_measurement(&text).unwrap(),
4910 &targets::choice_curve("harman-in-ear-2019").unwrap(),
4911 );
4912 let g = |hz| targets::at(&wanted.points, hz);
4913 let (ours, theirs) = (at(3000.0) - at(1000.0), g(3000.0) - g(1000.0));
4914 assert!((ours - theirs).abs() < 1.5, "{ours} against {theirs}");
4915
4916 let choices = target_choices("AFUL Performer 8S").unwrap();
4918 assert!(choices.made_for.is_none());
4919 choose_target("AFUL Performer 8S", Some("harman-in-ear-2019-without-bass")).unwrap();
4920 assert_eq!(
4921 summary("AFUL Performer 8S").unwrap().correction.as_deref(),
4922 Some("AFUL Performer 8S → Harman in-ear 2019, no bass shelf (from measurement)")
4923 );
4924 let moved = Config::cached()
4925 .dsp
4926 .profiles
4927 .iter()
4928 .find(|p| p.name == "AFUL Performer 8S")
4929 .unwrap()
4930 .clone();
4931 assert_ne!(moved.filters, p.filters, "fitted again to the new target");
4932 }
4933
4934 #[test]
4938 fn an_older_measured_correction_plays_the_fit() {
4939 use super::super::targets;
4940 let _guard = crate::config::tests::PERSIST_LOCK
4941 .lock()
4942 .unwrap_or_else(|e| e.into_inner());
4943 let dir = tempfile::tempdir().unwrap();
4944 config::set_config_dir(dir.path());
4945 let text = measured(6.0);
4946 let folder = super::dir("Older");
4947 std::fs::create_dir_all(&folder).unwrap();
4948 let curve = read_measurement(&text).unwrap();
4949 std::fs::write(targets::measurement_path(&folder), targets::on_grid(&curve)).unwrap();
4950 persist(|c| {
4951 c.dsp.profiles.push(DspProfile {
4952 name: "Older".into(),
4953 role: Some(DspRole::Correction),
4954 measurement: Some(DspMeasurement {
4955 ear: DspEar::In,
4956 target: "harman-in-ear-2019".into(),
4957 }),
4958 ..Default::default()
4959 });
4960 })
4961 .unwrap();
4962 let cfg = Config::cached();
4963 let p = cfg.dsp.profiles.iter().find(|p| p.name == "Older").unwrap();
4964 let played = super::super::chain(p, &cfg.dsp.profiles, &mut Vec::new()).unwrap();
4965 let aim = targets::choice_curve("harman-in-ear-2019").unwrap();
4966 let kept = targets::measurement(&folder).unwrap();
4967 assert_eq!(played, squig_fit(&kept, &aim).0);
4968 assert!(!played.is_empty());
4969 let after = Config::cached();
4970 let p = after
4971 .dsp
4972 .profiles
4973 .iter()
4974 .find(|p| p.name == "Older")
4975 .unwrap();
4976 assert!(p.measurement.is_some() && p.fitted.is_none() && p.filters.is_empty());
4977
4978 choose_target("Older", Some("diffuse-field-iso-11904-1")).unwrap();
4980 let cfg = Config::cached();
4981 let p = cfg.dsp.profiles.iter().find(|p| p.name == "Older").unwrap();
4982 assert!(p.measurement.is_none());
4983 assert_eq!(
4984 p.fitted.as_ref().unwrap().target,
4985 "diffuse-field-iso-11904-1"
4986 );
4987 let diffuse = targets::choice_curve("diffuse-field-iso-11904-1").unwrap();
4988 assert_eq!(p.filters, squig_fit(&kept, &diffuse).0);
4989 }
4990
4991 #[test]
4995 fn a_target_step_on_a_fitted_correction_is_tapered() {
4996 use super::super::targets;
4997 let neutral = targets::choice_curve("diffuse-field-iso-11904-1").unwrap();
4998 let harman = targets::choice_curve("harman-in-ear-2019").unwrap();
4999 let fitted = DspProfile {
5000 name: "Fitted".into(),
5001 role: Some(DspRole::Correction),
5002 fitted: Some(DspMeasurement {
5003 ear: DspEar::In,
5004 target: "diffuse-field-iso-11904-1".into(),
5005 }),
5006 ..Default::default()
5007 };
5008 let all = vec![fitted.clone()];
5009 let step = target_step(&fitted, &all, &neutral, &harman);
5010 assert_eq!(step, targets::tapered_difference(&neutral, &harman));
5011 assert_ne!(step, targets::difference(&neutral, &harman));
5012 }
5013
5014 #[test]
5017 fn a_fitted_correction_plays_its_bands() {
5018 use crate::config::{DspFilter, EqFilter, EqFilterKind};
5019 let _guard = crate::config::tests::PERSIST_LOCK
5020 .lock()
5021 .unwrap_or_else(|e| e.into_inner());
5022 let dir = tempfile::tempdir().unwrap();
5023 config::set_config_dir(dir.path());
5024 let bands: Vec<DspFilter> = [
5025 (190.0, -2.2, 0.7),
5026 (740.0, -1.8, 2.4),
5027 (1700.0, -2.7, 1.5),
5028 (2100.0, -1.0, 5.6),
5029 (2800.0, 1.6, 4.2),
5030 (4200.0, 5.6, 0.7),
5031 (4600.0, -1.8, 5.3),
5032 ]
5033 .into_iter()
5034 .map(|(freq, gain_db, q)| {
5035 DspFilter::Band(EqFilter {
5036 kind: EqFilterKind::Peaking,
5037 freq,
5038 gain_db,
5039 q,
5040 channels: Vec::new(),
5041 })
5042 })
5043 .collect();
5044 persist(|c| {
5045 c.dsp.profiles.push(DspProfile {
5046 name: "Performer 8S".into(),
5047 role: Some(DspRole::Correction),
5048 fitted: Some(DspMeasurement {
5049 ear: DspEar::In,
5050 target: "harman-in-ear-2019".into(),
5051 }),
5052 filters: bands.clone(),
5053 preamp_db: Some(-4.99),
5054 ..Default::default()
5055 });
5056 })
5057 .unwrap();
5058 let played = chain_response("IEMs", Some("Performer 8S"), None, 48_000).unwrap();
5059 let freqs: Vec<f64> = played.iter().map(|(hz, _)| *hz).collect();
5060 let stored = super::super::response(&bands, &freqs, 48_000);
5061 for ((hz, db), want) in played.iter().zip(&stored) {
5062 assert!((db - want).abs() < 0.01, "{hz} Hz: {db} against {want}");
5063 }
5064 }
5065
5066 #[test]
5070 fn a_layered_chain_has_the_headroom_of_its_sum() {
5071 use crate::config::{DspFilter, EqFilter, EqFilterKind};
5072 let _guard = crate::config::tests::PERSIST_LOCK
5073 .lock()
5074 .unwrap_or_else(|e| e.into_inner());
5075 let dir = tempfile::tempdir().unwrap();
5076 config::set_config_dir(dir.path());
5077 let peak = |gain_db| {
5078 DspFilter::Band(EqFilter {
5079 kind: EqFilterKind::Peaking,
5080 freq: 1000.0,
5081 gain_db,
5082 q: 1.0,
5083 channels: Vec::new(),
5084 })
5085 };
5086 persist(|c| {
5087 c.dsp.profiles.push(DspProfile {
5088 name: "Boost".into(),
5089 role: Some(DspRole::Correction),
5090 filters: vec![peak(6.0)],
5091 preamp_db: Some(-6.0),
5092 ..Default::default()
5093 });
5094 c.dsp.profiles.push(DspProfile {
5095 name: "Cut".into(),
5096 role: Some(DspRole::Tuning),
5097 filters: vec![peak(-6.0)],
5098 ..Default::default()
5099 });
5100 })
5101 .unwrap();
5102 set_chain("DAC", Some(Some("Boost")), Some(&[])).unwrap();
5103 let alone = output_response("DAC", 48_000).unwrap();
5104 assert!((alone.preamp_db + 6.0).abs() < 0.01, "{}", alone.preamp_db);
5105 set_chain("DAC", None, Some(&["Cut".to_string()])).unwrap();
5106 let both = output_response("DAC", 48_000).unwrap();
5107 assert!(both.total.iter().all(|db| db.abs() < 0.01));
5108 assert!(both.preamp_db.abs() < 0.01, "{}", both.preamp_db);
5109 }
5110
5111 #[test]
5115 fn a_gain_filter_and_a_preamp_draw_alike() {
5116 use crate::config::{DspFilter, EqFilter, EqFilterKind};
5117 let _guard = crate::config::tests::PERSIST_LOCK
5118 .lock()
5119 .unwrap_or_else(|e| e.into_inner());
5120 let dir = tempfile::tempdir().unwrap();
5121 config::set_config_dir(dir.path());
5122 let band = |kind, gain_db| {
5123 DspFilter::Band(EqFilter {
5124 kind,
5125 freq: 1000.0,
5126 gain_db,
5127 q: 1.0,
5128 channels: Vec::new(),
5129 })
5130 };
5131 persist(|c| {
5132 c.dsp.profiles.push(DspProfile {
5133 name: "As a filter".into(),
5134 filters: vec![
5135 band(EqFilterKind::Gain, -5.0),
5136 band(EqFilterKind::Peaking, 3.0),
5137 ],
5138 ..Default::default()
5139 });
5140 c.dsp.profiles.push(DspProfile {
5141 name: "As the preamp".into(),
5142 filters: vec![band(EqFilterKind::Peaking, 3.0)],
5143 preamp_db: Some(-5.0),
5144 ..Default::default()
5145 });
5146 })
5147 .unwrap();
5148 let filter = response("As a filter", 48_000).unwrap();
5149 let preamp = response("As the preamp", 48_000).unwrap();
5150 let level = |r: &Response| r.preamp_db + r.gain_db;
5151 assert!((level(&filter) + 5.0).abs() < 0.01, "{}", level(&filter));
5152 assert!((level(&preamp) + 5.0).abs() < 0.01, "{}", level(&preamp));
5153 for (a, b) in filter.total.iter().zip(&preamp.total) {
5154 assert!((a + filter.preamp_db - b - preamp.preamp_db).abs() < 0.01);
5155 }
5156 assert_eq!(filter.bands.len(), 1, "the gain is not drawn as a band");
5157 }
5158
5159 #[test]
5164 fn a_fitted_correction_is_fitted_again_for_its_tuning() {
5165 let _guard = crate::config::tests::PERSIST_LOCK
5166 .lock()
5167 .unwrap_or_else(|e| e.into_inner());
5168 let dir = tempfile::tempdir().unwrap();
5169 config::set_config_dir(dir.path());
5170 let text = measured(6.0);
5171 save_measured("To ISO", &text, DspEar::In, "diffuse-field-iso-11904-1").unwrap();
5172 save_measured("To Harman", &text, DspEar::In, "harman-in-ear-2019").unwrap();
5173 persist(|c| {
5174 c.dsp.profiles.push(DspProfile {
5175 name: "Lush".into(),
5176 role: Some(DspRole::Tuning),
5177 tuned_for: Some("harman-in-ear-2019".into()),
5178 filters: vec![band(9000.0)],
5179 ..Default::default()
5180 });
5181 })
5182 .unwrap();
5183 let lush = ["Lush".to_string()];
5184 set_chain("Via ISO", Some(Some("To ISO")), Some(&lush)).unwrap();
5185 set_chain("Direct", Some(Some("To Harman")), Some(&lush)).unwrap();
5186 let via = chain_response("Via ISO", None, None, 48_000).unwrap();
5187 let direct = chain_response("Direct", None, None, 48_000).unwrap();
5188 for ((hz, a), (_, b)) in via.iter().zip(&direct) {
5189 assert!((a - b).abs() < 0.01, "{hz} Hz: {a} against {b}");
5190 }
5191
5192 let view = chain_view("Via ISO");
5193 assert!(matches!(
5194 &view.tuning[0].join,
5195 Some(Join::Refitted { to, .. }) if to == "Harman in-ear 2019"
5196 ));
5197 assert!(
5198 view.sentence
5199 .ends_with("Lush was made for Harman in-ear 2019, so the correction is fitted to Harman in-ear 2019 for it."),
5200 "{}",
5201 view.sentence
5202 );
5203 let graph = output_response("Via ISO", 48_000).unwrap();
5204 let tuning = graph.tuning.unwrap();
5205 let alone = super::super::response(&[band(9000.0)], &graph.freqs, 48_000);
5206 for (t, l) in tuning.iter().zip(&alone) {
5207 assert!(
5208 (t - l).abs() < 0.01,
5209 "the legend's tuning is the tuning alone"
5210 );
5211 }
5212 let direct_alone = output_response("Direct", 48_000)
5213 .unwrap()
5214 .correction
5215 .unwrap();
5216 for (a, b) in graph.correction.unwrap().iter().zip(&direct_alone) {
5217 assert!((a - b).abs() < 0.01, "the legend's correction is the refit");
5218 }
5219
5220 let previews = made_against_previews("Lush", "Via ISO");
5222 let of = |id: &str| {
5223 &previews
5224 .iter()
5225 .find(|(t, _)| t.as_deref() == Some(id))
5226 .unwrap()
5227 .1
5228 };
5229 let iso = of("diffuse-field-iso-11904-1");
5230 for (p, l) in iso.iter().zip(&alone) {
5231 assert!((p - l).abs() < 0.01);
5232 }
5233 let harman = of("harman-in-ear-2019");
5235 let moved: Vec<f64> = direct
5236 .iter()
5237 .zip(&chain_response("Via ISO", None, Some(&[]), 48_000).unwrap())
5238 .map(|((_, d), (_, own))| d - own)
5239 .collect();
5240 for (p, m) in harman.iter().zip(&moved) {
5241 assert!((p - m).abs() < 0.01, "{p} against {m}");
5242 }
5243 persist(|c| {
5246 c.dsp.profiles.push(DspProfile {
5247 name: "Other".into(),
5248 role: Some(DspRole::Tuning),
5249 tuned_for: Some("diffuse-field-iso-11904-1".into()),
5250 filters: vec![band(200.0)],
5251 ..Default::default()
5252 });
5253 })
5254 .unwrap();
5255 let both = ["Lush".to_string(), "Other".to_string()];
5256 set_chain("Via ISO", None, Some(&both)).unwrap();
5257 let iso_name = target_name("diffuse-field-iso-11904-1");
5258 let view = chain_view("Via ISO");
5259 assert!(matches!(
5260 &view.tuning[1].join,
5261 Some(Join::Converted { from, to }) if from == "Harman in-ear 2019" && *to == iso_name
5262 ));
5263 assert!(
5264 view.sentence.ends_with(&format!(
5265 "Other was made for {iso_name}, so the difference from Harman in-ear 2019 to {iso_name} plays first."
5266 )),
5267 "{}",
5268 view.sentence
5269 );
5270 }
5271
5272 #[test]
5276 fn an_older_measured_correction_is_fitted_again_for_its_tuning() {
5277 use super::super::targets;
5278 let _guard = crate::config::tests::PERSIST_LOCK
5279 .lock()
5280 .unwrap_or_else(|e| e.into_inner());
5281 let dir = tempfile::tempdir().unwrap();
5282 config::set_config_dir(dir.path());
5283 let text = measured(6.0);
5284 let folder = super::dir("Older");
5285 std::fs::create_dir_all(&folder).unwrap();
5286 let curve = read_measurement(&text).unwrap();
5287 std::fs::write(targets::measurement_path(&folder), targets::on_grid(&curve)).unwrap();
5288 persist(|c| {
5289 c.dsp.profiles.push(DspProfile {
5290 name: "Older".into(),
5291 role: Some(DspRole::Correction),
5292 measurement: Some(DspMeasurement {
5293 ear: DspEar::In,
5294 target: "diffuse-field-iso-11904-1".into(),
5295 }),
5296 ..Default::default()
5297 });
5298 c.dsp.profiles.push(DspProfile {
5299 name: "Lush".into(),
5300 role: Some(DspRole::Tuning),
5301 tuned_for: Some("harman-in-ear-2019".into()),
5302 filters: vec![band(9000.0)],
5303 ..Default::default()
5304 });
5305 })
5306 .unwrap();
5307 let lush = ["Lush".to_string()];
5308 set_chain("Older DAC", Some(Some("Older")), Some(&lush)).unwrap();
5309 let kept = targets::measurement(&folder).unwrap();
5310 let harman = squig_fit(&kept, &targets::choice_curve("harman-in-ear-2019").unwrap()).0;
5311 let mut wanted = harman.clone();
5312 wanted.push(band(9000.0));
5313 let freqs = targets::grid();
5314 let want = super::super::response(&wanted, &freqs, 48_000);
5315 let played = chain_response("Older DAC", None, None, 48_000).unwrap();
5316 for ((hz, a), b) in played.iter().zip(&want) {
5317 assert!((a - b).abs() < 0.01, "{hz} Hz: {a} against {b}");
5318 }
5319 let previews = made_against_previews("Lush", "Older DAC");
5320 let moved = &previews
5321 .iter()
5322 .find(|(t, _)| t.as_deref() == Some("harman-in-ear-2019"))
5323 .unwrap()
5324 .1;
5325 let own = chain_response("Older DAC", None, Some(&[]), 48_000).unwrap();
5326 for ((p, w), (_, o)) in moved.iter().zip(&want).zip(&own) {
5327 assert!((p - (w - o)).abs() < 0.01, "{p} against {}", w - o);
5328 }
5329 }
5330
5331 #[test]
5334 fn a_refit_goes_with_the_tuning_that_asked() {
5335 let _guard = crate::config::tests::PERSIST_LOCK
5336 .lock()
5337 .unwrap_or_else(|e| e.into_inner());
5338 let dir = tempfile::tempdir().unwrap();
5339 config::set_config_dir(dir.path());
5340 save_measured(
5341 "To ISO",
5342 &measured(6.0),
5343 DspEar::In,
5344 "diffuse-field-iso-11904-1",
5345 )
5346 .unwrap();
5347 persist(|c| {
5348 for i in 0..4 {
5349 c.dsp.profiles.push(DspProfile {
5350 name: format!("Big {i}"),
5351 role: Some(DspRole::Tuning),
5352 filters: vec![band(1000.0 + i as f64); 64],
5353 ..Default::default()
5354 });
5355 }
5356 c.dsp.profiles.push(DspProfile {
5357 name: "Lush".into(),
5358 role: Some(DspRole::Tuning),
5359 tuned_for: Some("harman-in-ear-2019".into()),
5360 layers: (0..4).map(|i| layer(&format!("Big {i}"), true)).collect(),
5361 ..Default::default()
5362 });
5363 c.dsp.profiles.push(DspProfile {
5364 name: "Plain".into(),
5365 role: Some(DspRole::Tuning),
5366 tuned_for: Some("diffuse-field-iso-11904-1".into()),
5367 filters: vec![band(200.0)],
5368 ..Default::default()
5369 });
5370 })
5371 .unwrap();
5372 let eqs = ["Plain".to_string(), "Lush".to_string()];
5373 set_chain("DAC", Some(Some("To ISO")), Some(&eqs)).unwrap();
5374 let chain = super::super::output_chain(&Config::cached().dsp, "DAC").unwrap();
5375 assert_eq!(chain.left_out_eqs, ["Lush"]);
5376 assert_eq!(chain.refit, None);
5377 assert!(matches!(
5378 chain_view("DAC").tuning[0].join,
5379 Some(Join::Matched)
5380 ));
5381 }
5382
5383 #[test]
5387 fn a_chain_corrects_once() {
5388 use crate::config::{DspLayer, DspTarget};
5389 let _guard = crate::config::tests::PERSIST_LOCK
5390 .lock()
5391 .unwrap_or_else(|e| e.into_inner());
5392 let dir = tempfile::tempdir().unwrap();
5393 config::set_config_dir(dir.path());
5394 persist(|c| {
5395 for (name, target) in [("HD 650", true), ("HD 600", true), ("Warm", false)] {
5396 c.dsp.profiles.push(DspProfile {
5397 name: name.into(),
5398 filters: vec![band(100.0)],
5399 target: target.then(|| DspTarget {
5400 made_for: "harman-over-ear-2018".into(),
5401 chosen: None,
5402 }),
5403 ..Default::default()
5404 });
5405 }
5406 })
5407 .unwrap();
5408 let layer = |p: &str| DspLayer {
5409 profile: p.into(),
5410 on: true,
5411 };
5412 set_layers("Desk", vec![layer("HD 650"), layer("Warm")]).unwrap();
5413 let s = summary("Desk").unwrap();
5414 assert_eq!(
5415 s.correction.as_deref(),
5416 Some("HD 650: ready-made EQ (made for Harman over-ear 2018)")
5417 );
5418 assert_eq!(s.tunings, vec!["Warm".to_string()]);
5419 let refused = set_layers(
5420 "Desk",
5421 vec![layer("HD 650"), layer("Warm"), layer("HD 600")],
5422 )
5423 .unwrap_err();
5424 assert_eq!(
5425 refused,
5426 "This EQ already plays the correction HD 650. Take that out first, or add HD 600 to a device's tuning instead."
5427 );
5428 set_role("Warm", DspRole::Correction).unwrap();
5430 assert_eq!(
5431 detail("Desk").unwrap().corrects_twice.as_deref(),
5432 Some("This EQ corrects twice: HD 650 (ready-made) and Warm (correction). Keep one.")
5433 );
5434 set_role("Warm", DspRole::Tuning).unwrap();
5435 set_role("HD 600", DspRole::Tuning).unwrap();
5436 set_layers(
5437 "Desk",
5438 vec![layer("HD 650"), layer("Warm"), layer("HD 600")],
5439 )
5440 .unwrap();
5441 }
5442
5443 #[test]
5447 fn made_for_is_checked() {
5448 use crate::config::{DspEar, DspMeasurement};
5449 let _guard = crate::config::tests::PERSIST_LOCK
5450 .lock()
5451 .unwrap_or_else(|e| e.into_inner());
5452 let dir = tempfile::tempdir().unwrap();
5453 config::set_config_dir(dir.path());
5454 persist(|c| {
5455 c.dsp.profiles.push(DspProfile {
5456 name: "Preset".into(),
5457 filters: vec![band(100.0)],
5458 ..Default::default()
5459 });
5460 c.dsp.profiles.push(DspProfile {
5461 name: "Measured".into(),
5462 measurement: Some(DspMeasurement {
5463 ear: DspEar::In,
5464 target: "harman-in-ear-2019".into(),
5465 }),
5466 ..Default::default()
5467 });
5468 })
5469 .unwrap();
5470 assert!(set_made_for("Nobody", Some("harman-in-ear-2019")).is_err());
5471 assert!(set_made_for("Preset", Some("made-up")).is_err());
5472 assert!(set_made_for("Measured", Some("harman-in-ear-2019")).is_err());
5473 let target = || {
5474 Config::cached()
5475 .dsp
5476 .profiles
5477 .iter()
5478 .find(|p| p.name == "Preset")
5479 .unwrap()
5480 .target
5481 .clone()
5482 };
5483 assert_eq!(target(), None);
5484 set_made_for("Preset", Some("harman-in-ear-2019")).unwrap();
5485 choose_target("Preset", Some("diffuse-field-iso-11904-1")).unwrap();
5486 set_made_for("Preset", Some("harman-over-ear-2018")).unwrap();
5487 let t = target().unwrap();
5488 assert_eq!(t.made_for, "harman-over-ear-2018");
5489 assert_eq!(t.chosen, None, "an in-ear target is no move for over-ears");
5490 set_made_for("Preset", None).unwrap();
5491 assert_eq!(target(), None);
5492 }
5493
5494 #[test]
5498 fn the_target_difference_goes_before_the_tuning() {
5499 use crate::config::{DspFilter, DspLayer, DspTarget};
5500 let _guard = crate::config::tests::PERSIST_LOCK
5501 .lock()
5502 .unwrap_or_else(|e| e.into_inner());
5503 let dir = tempfile::tempdir().unwrap();
5504 config::set_config_dir(dir.path());
5505 let spread = |n: usize, from: f64| -> Vec<DspFilter> {
5506 (0..n)
5507 .map(|i| {
5508 DspFilter::Band(crate::config::EqFilter {
5509 kind: crate::config::EqFilterKind::Peaking,
5510 freq: from + i as f64,
5511 gain_db: 1.0,
5512 q: 1.0,
5513 channels: vec![(i % 4) as u16],
5514 })
5515 })
5516 .collect()
5517 };
5518 persist(|c| {
5519 c.dsp.profiles.push(DspProfile {
5520 name: "Base".into(),
5521 filters: spread(100, 1000.0),
5522 ..Default::default()
5523 });
5524 c.dsp.profiles.push(DspProfile {
5525 name: "HD 600".into(),
5526 filters: vec![band(100.0)],
5527 layers: vec![DspLayer {
5528 profile: "Base".into(),
5529 on: true,
5530 }],
5531 target: Some(DspTarget {
5532 made_for: "harman-over-ear-2018".into(),
5533 chosen: None,
5534 }),
5535 ..Default::default()
5536 });
5537 c.dsp.profiles.push(DspProfile {
5538 name: "Many".into(),
5539 filters: spread(156, 100.0),
5540 tuned_for: Some("diffuse-field-gras-kemar".into()),
5541 ..Default::default()
5542 });
5543 })
5544 .unwrap();
5545 let dac = "Desk DAC";
5546 assign(Some("HD 600"), dac).unwrap();
5547 set_tuning(dac, Some("Many")).unwrap();
5548 let cfg = Config::cached();
5549 let chain = super::super::output_chain(&cfg.dsp, dac).unwrap();
5550 assert!(chain.tuning_plays);
5551 let played = super::super::chain(&chain.profile, &chain.all, &mut Vec::new()).unwrap();
5552 assert_eq!(
5553 played.len(),
5554 100 + 156 + 1,
5555 "Base, the tuning, the correction's own"
5556 );
5557 assert!(
5558 !played.iter().any(|f| matches!(f, DspFilter::Graphic(_))),
5559 "no target difference"
5560 );
5561 assert!(
5562 chain
5563 .left_out
5564 .as_deref()
5565 .is_some_and(|l| l.starts_with("Many plays without the target difference")),
5566 "{:?}",
5567 chain.left_out
5568 );
5569 }
5570
5571 #[test]
5575 fn a_baked_eq_splits_into_correction_and_tuning() {
5576 use super::super::targets;
5577 use crate::config::DspFilter;
5578 let _guard = crate::config::tests::PERSIST_LOCK
5579 .lock()
5580 .unwrap_or_else(|e| e.into_inner());
5581 let dir = tempfile::tempdir().unwrap();
5582 config::set_config_dir(dir.path());
5583 let mut text = String::from("frequency,raw\n");
5584 for hz in targets::grid() {
5585 let peak = |at: f64, db: f64| db * (-(hz / at).log2().powi(2) / 0.1).exp();
5586 text.push_str(&format!(
5587 "{hz:.2},{:.2}\n",
5588 90.0 + peak(3000.0, 6.0) + peak(8000.0, 5.0)
5589 ));
5590 }
5591 let target = "harman-in-ear-2019";
5592 let measured = read_measurement(&text).unwrap();
5593 let correction = targets::difference(&measured, &targets::choice_curve(target).unwrap());
5595 persist(|c| {
5596 c.dsp.profiles.push(DspProfile {
5597 name: "Lush".into(),
5598 filters: vec![DspFilter::Graphic(correction), band(80.0)],
5599 role: Some(DspRole::Baked),
5600 devices: vec!["Desk DAC".into()],
5601 ..Default::default()
5602 })
5603 })
5604 .unwrap();
5605
5606 let r = preview_split("Lush", &text, target, 48_000).unwrap();
5607 let at = |db: &[f64], hz: f64| {
5608 let i = r.freqs.iter().position(|&f| f >= hz).unwrap();
5609 db[i]
5610 };
5611 let tuning = r.tuning.as_ref().unwrap();
5612 assert!((at(tuning, 80.0) - 3.0).abs() < 1.0, "{}", at(tuning, 80.0));
5613 assert!(at(tuning, 3000.0).abs() < 0.7, "{}", at(tuning, 3000.0));
5614 assert!(at(tuning, 8000.0).abs() < 1.0, "{}", at(tuning, 8000.0));
5616 let original = r.original.as_ref().unwrap();
5617 for hz in [50.0, 200.0, 1000.0, 4000.0] {
5618 assert!((at(&r.total, hz) - at(original, hz)).abs() < 1.0, "{hz} Hz");
5619 }
5620
5621 let (c, t) = split_baked("Lush", &text, DspEar::In, target).unwrap();
5622 assert_eq!((c.as_str(), t.as_str()), ("Lush correction", "Lush tuning"));
5623 let cfg = Config::cached();
5624 assert_eq!(cfg.dsp.profile_for("Desk DAC").unwrap().name, c);
5625 assert_eq!(tuning_for("Desk DAC").as_deref(), Some(t.as_str()));
5626 let made = cfg.dsp.profiles.iter().find(|p| p.name == t).unwrap();
5627 assert_eq!(made.tuned_for.as_deref(), Some(target));
5628 assert_eq!(role(made), DspRole::Tuning);
5629 assert!(cfg.dsp.profiles.iter().any(|p| p.name == "Lush"), "kept");
5630 let chain = super::super::output_chain(&cfg.dsp, "Desk DAC").unwrap();
5633 let played = super::super::chain(&chain.profile, &chain.all, &mut Vec::new()).unwrap();
5634 let bands = &cfg
5635 .dsp
5636 .profiles
5637 .iter()
5638 .find(|p| p.name == c)
5639 .unwrap()
5640 .filters;
5641 assert!(!bands.is_empty());
5642 assert_eq!(played.len(), bands.len() + 1);
5643 assert!(played.windows(bands.len()).any(|w| w == &bands[..]));
5644 assert_eq!(
5645 played
5646 .iter()
5647 .filter(|f| matches!(f, DspFilter::Graphic(_)))
5648 .count(),
5649 1
5650 );
5651 assert!(split_baked("Lush", &text, DspEar::In, "made-up").is_err());
5652
5653 let stack = |c: &mut Config| {
5655 c.dsp.profiles.push(DspProfile {
5656 name: "Stack".into(),
5657 layers: vec![crate::config::DspLayer {
5658 profile: "Lush".into(),
5659 on: true,
5660 }],
5661 ..Default::default()
5662 });
5663 c.dsp.profiles.push(DspProfile {
5664 name: "Left".into(),
5665 filters: vec![DspFilter::Band(crate::config::EqFilter {
5666 kind: crate::config::EqFilterKind::Peaking,
5667 freq: 100.0,
5668 gain_db: 2.0,
5669 q: 1.0,
5670 channels: vec![0],
5671 })],
5672 ..Default::default()
5673 });
5674 };
5675 persist(stack).unwrap();
5676 let refused = preview_split("Stack", &text, target, 48_000).unwrap_err();
5677 assert!(refused.contains("plays other EQs"), "{refused}");
5678 let refused = preview_split("Left", &text, target, 48_000).unwrap_err();
5679 assert!(
5680 refused.contains("treats its channels differently"),
5681 "{refused}"
5682 );
5683 }
5684
5685 #[test]
5689 fn a_fitted_preset_splits_without_the_measurements_treble() {
5690 use super::super::targets;
5691 let _guard = crate::config::tests::PERSIST_LOCK
5692 .lock()
5693 .unwrap_or_else(|e| e.into_inner());
5694 let dir = tempfile::tempdir().unwrap();
5695 config::set_config_dir(dir.path());
5696 let peak =
5697 |hz: f64, at: f64, db: f64, width: f64| db * (-(hz / at).log2().powi(2) / width).exp();
5698 let mut text = String::from("frequency,raw\n");
5699 for hz in targets::grid() {
5700 text.push_str(&format!(
5701 "{hz:.2},{:.2}\n",
5702 90.0 + peak(hz, 3000.0, 6.0, 0.1)
5703 + peak(hz, 6000.0, -9.0, 0.02)
5704 + peak(hz, 9000.0, 7.0, 0.02)
5705 + peak(hz, 13000.0, -8.0, 0.03)
5706 ));
5707 }
5708 let target = "harman-in-ear-2019";
5709 let measured = read_measurement(&text).unwrap();
5710 let shelf = |hz: f64| 3.0 / (1.0 + (hz / 80.0).powi(2));
5711 let tasted: targets::Curve = targets::choice_curve(target)
5712 .unwrap()
5713 .into_iter()
5714 .map(|(hz, db)| (hz, db + shelf(hz)))
5715 .collect();
5716 let (bands, _) = squig_fit(&measured, &tasted);
5717 persist(|c| {
5718 c.dsp.profiles.push(DspProfile {
5719 name: "Super".into(),
5720 filters: bands,
5721 role: Some(DspRole::Baked),
5722 ..Default::default()
5723 })
5724 })
5725 .unwrap();
5726
5727 let r = preview_split("Super", &text, target, 48_000).unwrap();
5728 let tuning = r.tuning.as_ref().unwrap();
5729 let worst = r
5730 .freqs
5731 .iter()
5732 .zip(tuning)
5733 .filter(|(hz, _)| **hz >= 2000.0)
5734 .map(|(_, db)| db.abs())
5735 .fold(0.0, f64::max);
5736 assert!(worst < 1.5, "the tuning's treble reaches {worst:.1} dB");
5737 let i = r.freqs.iter().position(|&f| f >= 40.0).unwrap();
5738 assert!(
5739 (tuning[i] - shelf(40.0)).abs() < 1.0,
5740 "{} at 40 Hz",
5741 tuning[i]
5742 );
5743 let original = r.original.as_ref().unwrap();
5745 let apart = r
5746 .total
5747 .iter()
5748 .zip(original)
5749 .map(|(t, o)| (t - o).abs())
5750 .fold(0.0, f64::max);
5751 assert!(apart < 0.5, "{apart:.2} dB from the preset");
5752 }
5753
5754 #[test]
5758 fn a_tuning_is_eqs_in_order_and_a_preset_saves_it() {
5759 use crate::config::{DspFilter, DspTarget};
5760 let _guard = crate::config::tests::PERSIST_LOCK
5761 .lock()
5762 .unwrap_or_else(|e| e.into_inner());
5763 let dir = tempfile::tempdir().unwrap();
5764 config::set_config_dir(dir.path());
5765 let curve = |db: f64| {
5766 DspFilter::Graphic(crate::config::GraphicEq {
5767 points: vec![(20.0, 0.0), (1000.0, db), (20_000.0, 0.0)],
5768 channels: vec![],
5769 })
5770 };
5771 persist(|c| {
5772 let harman = || {
5773 Some(DspTarget {
5774 made_for: "harman-over-ear-2018".into(),
5775 chosen: None,
5776 })
5777 };
5778 c.dsp.profiles.push(DspProfile {
5779 name: "HD 600".into(),
5780 filters: vec![band(100.0)],
5781 target: harman(),
5782 ..Default::default()
5783 });
5784 for (name, freq, made) in [
5785 ("Warm", 60.0, Some("diffuse-field-gras-kemar")),
5786 ("Bright", 8000.0, Some("harman-over-ear-2018")),
5787 ("Spare", 200.0, None),
5788 ] {
5789 c.dsp.profiles.push(DspProfile {
5790 name: name.into(),
5791 filters: vec![band(freq)],
5792 tuned_for: made.map(str::to_owned),
5793 ..Default::default()
5794 });
5795 }
5796 for (name, db) in [("Tilt", 1.0), ("Lift", 2.0)] {
5797 c.dsp.profiles.push(DspProfile {
5798 name: name.into(),
5799 filters: vec![curve(db)],
5800 ..Default::default()
5801 });
5802 }
5803 })
5804 .unwrap();
5805 let dac = "Desk DAC";
5806 let plays = |device: &str| -> Vec<String> {
5807 let cfg = Config::cached();
5808 let Some(chain) = super::super::output_chain(&cfg.dsp, device) else {
5809 return Vec::new();
5810 };
5811 super::super::chain(&chain.profile, &chain.all, &mut Vec::new())
5812 .unwrap()
5813 .into_iter()
5814 .map(|f| match f {
5815 DspFilter::Band(b) => format!("{}", b.freq),
5816 DspFilter::Graphic(_) => "curve".into(),
5817 other => panic!("{other:?}"),
5818 })
5819 .collect()
5820 };
5821 let list = |names: &[(&str, bool)]| -> Vec<(String, bool)> {
5822 names.iter().map(|(n, on)| (n.to_string(), *on)).collect()
5823 };
5824 assign(Some("HD 600"), dac).unwrap();
5825 set_tunings(
5826 dac,
5827 &list(&[("Warm", true), ("Bright", true), ("Spare", false)]),
5828 )
5829 .unwrap();
5830 assert_eq!(plays(dac), ["curve", "60", "8000", "100"]);
5833 assert_eq!(tuning_for(dac).as_deref(), Some("Warm"));
5834 assert!(set_tunings(dac, &list(&[("Warm", true), ("Warm", false)])).is_err());
5835 assert!(set_tunings(dac, &list(&[("HD 600", true)])).is_err());
5836
5837 set_tunings(
5839 dac,
5840 &list(&[("Tilt", true), ("Lift", true), ("Spare", true)]),
5841 )
5842 .unwrap();
5843 assert_eq!(plays(dac), ["curve", "curve", "200", "100"]);
5844 let o = overview_for(Some(dac.into()));
5845 assert!(o.left_out.as_deref().is_none(), "{:?}", o.left_out);
5846 persist(|c| {
5847 let hd = c
5848 .dsp
5849 .profiles
5850 .iter_mut()
5851 .find(|p| p.name == "HD 600")
5852 .unwrap();
5853 hd.filters.push(curve(-1.0));
5854 })
5855 .unwrap();
5856 assert_eq!(plays(dac), ["curve", "200", "100", "curve"]);
5857 let o = overview_for(Some(dac.into()));
5858 assert!(
5859 o.left_out
5860 .as_deref()
5861 .is_some_and(|l| l.starts_with("Lift is left out")),
5862 "{:?}",
5863 o.left_out
5864 );
5865
5866 set_tunings(dac, &list(&[("Warm", true), ("Spare", false)])).unwrap();
5868 save_preset(dac, "Late night").unwrap();
5869 assert_eq!(preset_for(dac), Some(("Late night".into(), false)));
5870 let other = "Speakers";
5871 apply_preset(other, Some("Late night")).unwrap();
5872 assert_eq!(
5873 Config::cached().dsp.profile_for(other).unwrap().name,
5874 "HD 600"
5875 );
5876 assert_eq!(
5877 tunings_for(other),
5878 list(&[("Warm", true), ("Spare", false)])
5879 );
5880 assert_eq!(plays(other), plays(dac));
5881 set_tunings(other, &list(&[("Warm", true), ("Spare", true)])).unwrap();
5882 assert_eq!(
5883 preset_for(other),
5884 Some(("Late night".into(), true)),
5885 "edited"
5886 );
5887 apply_preset(other, Some("Late night")).unwrap();
5890 assert_eq!(preset_for(other), Some(("Late night".into(), false)));
5891 assert_eq!(
5892 tunings_for(other),
5893 list(&[("Warm", true), ("Spare", false)])
5894 );
5895 assert_eq!(plays(other), plays(dac));
5896 assert!(save_preset(other, "Warm").is_err(), "not over an EQ");
5897 apply_preset(other, None).unwrap();
5899 assert!(Config::cached().dsp.profile_for(other).is_none());
5900 assert!(tunings_for(other).is_empty());
5901 assert_eq!(preset_for(other), None);
5902 assert!(plays(other).is_empty());
5903 }
5904
5905 #[test]
5909 fn corrections_are_read_only_and_imports_can_go_back() {
5910 use crate::config::DspTarget;
5911 let _guard = crate::config::tests::PERSIST_LOCK
5912 .lock()
5913 .unwrap_or_else(|e| e.into_inner());
5914 let dir = tempfile::tempdir().unwrap();
5915 config::set_config_dir(dir.path());
5916 persist(|c| {
5917 c.dsp.profiles.push(DspProfile {
5918 name: "HD 600".into(),
5919 filters: vec![band(100.0)],
5920 target: Some(DspTarget {
5921 made_for: "harman-over-ear-2018".into(),
5922 chosen: None,
5923 }),
5924 ..Default::default()
5925 })
5926 })
5927 .unwrap();
5928 let refused = set_band("HD 600", 0, "peaking", 200.0, 1.0, 1.0).unwrap_err();
5929 assert!(refused.contains("plays as made"), "{refused}");
5930 assert!(add_band("HD 600", None).is_err());
5931 assert!(remove_filter("HD 600", 0).is_err());
5932 assert!(detail("HD 600").unwrap().read_only);
5933
5934 let lush = Imported {
5935 name: "Lush".into(),
5936 source: vec!["Lush.txt".into()],
5937 filters: vec![band(80.0)],
5938 impulses: vec![],
5939 };
5940 save(lush, None).unwrap();
5941 let d = detail("Lush").unwrap();
5942 assert!(!d.read_only && d.can_revert && !d.edited);
5943 set_band("Lush", 0, "peaking", 80.0, 12.0, 1.0).unwrap();
5944 assert!(detail("Lush").unwrap().edited);
5945 let copy = duplicate("Lush", None).unwrap();
5946 assert_eq!(copy, "Lush copy");
5947 let c = detail(©).unwrap();
5948 assert!(!c.can_revert && c.devices.is_empty());
5949 assert_eq!(c.filters, detail("Lush").unwrap().filters, "as it is now");
5950 revert("Lush").unwrap();
5951 assert!(!detail("Lush").unwrap().edited);
5952 assert!(revert(©).is_err(), "made here");
5953 assert!(duplicate("Lush", Some("Lush copy")).is_err(), "taken");
5954 }
5955
5956 #[test]
5962 fn old_stacks_become_presets() {
5963 use crate::config::{DspLayer, DspTarget};
5964 let _guard = crate::config::tests::PERSIST_LOCK
5965 .lock()
5966 .unwrap_or_else(|e| e.into_inner());
5967 let dir = tempfile::tempdir().unwrap();
5968 config::set_config_dir(dir.path());
5969 let layer = |p: &str, on: bool| DspLayer {
5970 profile: p.into(),
5971 on,
5972 };
5973 persist(|c| {
5974 for name in ["HD 600", "HD 650"] {
5975 c.dsp.profiles.push(DspProfile {
5976 name: name.into(),
5977 filters: vec![band(100.0)],
5978 target: Some(DspTarget {
5979 made_for: "harman-over-ear-2018".into(),
5980 chosen: None,
5981 }),
5982 ..Default::default()
5983 });
5984 }
5985 for (name, hz) in [("Warm", 60.0), ("Bright", 8000.0)] {
5986 c.dsp.profiles.push(DspProfile {
5987 name: name.into(),
5988 filters: vec![band(hz)],
5989 ..Default::default()
5990 });
5991 }
5992 c.dsp.tunings.push(crate::config::DspOutputTuning {
5993 device: "DAC".into(),
5994 tuning: "Bright".into(),
5995 on: true,
5996 });
5997 c.dsp.profiles.push(DspProfile {
5998 name: "Quiet".into(),
5999 devices: vec!["Amp".into()],
6000 layers: vec![layer("HD 650", false), layer("Warm", true)],
6001 ..Default::default()
6002 });
6003 c.dsp.profiles.push(DspProfile {
6004 name: "Saved".into(),
6005 devices: vec!["Speaker".into()],
6006 preset: true,
6007 layers: vec![layer("HD 650", true), layer("Bright", true)],
6008 ..Default::default()
6009 });
6010 c.dsp.profiles.push(DspProfile {
6011 name: "Desk".into(),
6012 devices: vec!["DAC".into()],
6013 layers: vec![layer("HD 600", true), layer("Warm", false)],
6014 ..Default::default()
6015 });
6016 c.dsp.profiles.push(DspProfile {
6017 name: "Cans".into(),
6018 devices: vec!["Phone".into()],
6019 group: true,
6020 layers: vec![layer("HD 600", false), layer("HD 650", true)],
6021 ..Default::default()
6022 });
6023 })
6024 .unwrap();
6025 migrate().unwrap();
6026 let cfg = Config::cached();
6027 let desk = cfg.dsp.profiles.iter().find(|p| p.name == "Desk").unwrap();
6028 assert!(desk.preset && desk.devices.is_empty());
6029 assert_eq!(cfg.dsp.profile_for("DAC").unwrap().name, "HD 600");
6030 assert_eq!(
6031 tunings_for("DAC"),
6032 vec![("Warm".to_string(), false), ("Bright".to_string(), true)],
6033 "the stack's EQs, then the tuning it had on top"
6034 );
6035 assert_eq!(preset_for("DAC"), Some(("Desk".into(), true)));
6036 assert!(cfg.dsp.profile_for("Amp").is_none(), "switched off");
6037 assert_eq!(tunings_for("Amp"), vec![("Warm".to_string(), true)]);
6038 assert_eq!(preset_for("Amp"), Some(("Quiet".into(), false)));
6039 assert_eq!(cfg.dsp.profile_for("Speaker").unwrap().name, "HD 650");
6040 assert_eq!(tunings_for("Speaker"), vec![("Bright".to_string(), true)]);
6041 assert_eq!(preset_for("Speaker"), Some(("Saved".into(), false)));
6042 assert_eq!(cfg.dsp.profile_for("Phone").unwrap().name, "HD 650");
6043 assign(Some("Saved"), "Laptop").unwrap();
6044 assert_eq!(preset_for("Laptop"), Some(("Saved".into(), false)));
6045 assert!(
6046 Config::cached()
6047 .dsp
6048 .profiles
6049 .iter()
6050 .all(|p| !p.preset || p.devices.is_empty()),
6051 "a preset sets an output, never is its correction"
6052 );
6053 migrate().unwrap();
6054 assert_eq!(
6055 Config::cached().dsp.profile_for("DAC").unwrap().name,
6056 "HD 600",
6057 "once"
6058 );
6059 }
6060
6061 #[test]
6065 fn a_chain_is_set_whole_and_said() {
6066 let _guard = crate::config::tests::PERSIST_LOCK
6067 .lock()
6068 .unwrap_or_else(|e| e.into_inner());
6069 let dir = tempfile::tempdir().unwrap();
6070 config::set_config_dir(dir.path());
6071 persist(|c| {
6072 c.dsp.profiles.push(DspProfile {
6073 name: "Wharfedale".into(),
6074 filters: vec![band(100.0)],
6075 role: Some(DspRole::Correction),
6076 ..Default::default()
6077 });
6078 c.dsp.profiles.push(DspProfile {
6079 name: "Lush".into(),
6080 filters: vec![band(60.0)],
6081 role: Some(DspRole::Baked),
6082 ..Default::default()
6083 });
6084 for (name, hz) in [("Warm", 80.0), ("Air", 9000.0)] {
6085 c.dsp.profiles.push(DspProfile {
6086 name: name.into(),
6087 filters: vec![band(hz)],
6088 ..Default::default()
6089 });
6090 }
6091 })
6092 .unwrap();
6093 let dac = "Scarlett";
6094 let eqs = ["Warm".to_string(), "Air".to_string()];
6095 assert_eq!(
6096 chain_view(dac).sentence,
6097 "Scarlett is flat: the music plays untouched."
6098 );
6099 set_chain(dac, Some(Some("Wharfedale")), Some(&eqs)).unwrap();
6100 let once = std::fs::read_to_string(dir.path().join("config.local.toml")).unwrap();
6101 set_chain(dac, Some(Some("Wharfedale")), Some(&eqs)).unwrap();
6102 let twice = std::fs::read_to_string(dir.path().join("config.local.toml")).unwrap();
6103 assert_eq!(once, twice, "the same twice changes nothing");
6104 let view = chain_view(dac);
6105 assert_eq!(view.correction.as_deref(), Some("Wharfedale"));
6106 assert_eq!(view.tuning.len(), 2);
6107 assert_eq!(
6108 view.sentence,
6109 "Music to Scarlett, corrected by Wharfedale, then tuned with Warm and Air."
6110 );
6111
6112 let refused = set_chain(dac, Some(Some("Nope")), None).unwrap_err();
6113 assert_eq!(
6114 refused,
6115 "No correction called Nope. Corrections: Wharfedale, Lush"
6116 );
6117 let refused = set_chain(dac, None, Some(&["Nope".to_string()])).unwrap_err();
6118 assert_eq!(refused, "No EQ called Nope. EQs: Warm, Air");
6119 let twice = ["Warm".to_string(), "Warm".to_string()];
6120 let refused = set_chain(dac, Some(None), Some(&twice)).unwrap_err();
6121 assert_eq!(refused, "Warm is in the tuning twice");
6122 assert_eq!(
6123 chain_view(dac).correction.as_deref(),
6124 Some("Wharfedale"),
6125 "a refusal changes nothing"
6126 );
6127 set_chain(dac, Some(Some("Lush")), Some(&eqs)).unwrap_err();
6129 assert_eq!(chain_view(dac).correction.as_deref(), Some("Wharfedale"));
6130 set_chain(dac, Some(Some("Lush")), None).unwrap();
6133 let view = chain_view(dac);
6134 assert_eq!(view.tuning.len(), 2);
6135 assert_eq!(
6136 view.sentence,
6137 "Music to Scarlett, corrected by Lush. Its tuning, Warm and Air, is skipped: Lush already includes one."
6138 );
6139 set_chain(dac, Some(Some("Lush")), Some(&[])).unwrap();
6140 assert_eq!(
6141 chain_view(dac).sentence,
6142 "Music to Scarlett, corrected by Lush."
6143 );
6144 set_chain(dac, Some(None), None).unwrap();
6145 assert!(chain_view(dac).flat);
6146 }
6147
6148 #[test]
6151 fn a_deleted_eq_leaves_its_presets_and_groups() {
6152 use crate::config::DspLayer;
6153 let _guard = crate::config::tests::PERSIST_LOCK
6154 .lock()
6155 .unwrap_or_else(|e| e.into_inner());
6156 let dir = tempfile::tempdir().unwrap();
6157 config::set_config_dir(dir.path());
6158 let layer = |p: &str, on: bool| DspLayer {
6159 profile: p.into(),
6160 on,
6161 };
6162 persist(|c| {
6163 for (name, hz) in [("Warm", 60.0), ("Air", 9000.0)] {
6164 c.dsp.profiles.push(DspProfile {
6165 name: name.into(),
6166 filters: vec![band(hz)],
6167 ..Default::default()
6168 });
6169 }
6170 c.dsp.profiles.push(DspProfile {
6171 name: "Evening".into(),
6172 preset: true,
6173 layers: vec![layer("Warm", true), layer("Air", true)],
6174 ..Default::default()
6175 });
6176 c.dsp.profiles.push(DspProfile {
6177 name: "Tastes".into(),
6178 group: true,
6179 layers: vec![layer("Warm", true), layer("Air", false)],
6180 ..Default::default()
6181 });
6182 c.dsp.profiles.push(DspProfile {
6183 name: "Both".into(),
6184 layers: vec![layer("Air", true)],
6185 ..Default::default()
6186 });
6187 })
6188 .unwrap();
6189 let held = overview()
6190 .profiles
6191 .into_iter()
6192 .find(|p| p.name == "Warm")
6193 .unwrap()
6194 .held_by;
6195 assert_eq!(held, ["Evening", "Tastes"]);
6196 assert_eq!(
6197 delete("Air").unwrap_err(),
6198 "Both plays Air: take it out of it first"
6199 );
6200 delete("Warm").unwrap();
6201 let cfg = Config::cached();
6202 let of = |n: &str| {
6203 cfg.dsp
6204 .profiles
6205 .iter()
6206 .find(|p| p.name == n)
6207 .unwrap()
6208 .layers
6209 .clone()
6210 };
6211 assert_eq!(of("Evening"), vec![layer("Air", true)]);
6212 assert_eq!(
6213 of("Tastes"),
6214 vec![layer("Air", true)],
6215 "the group plays what is left"
6216 );
6217 }
6218
6219 #[test]
6222 fn processing_off_becomes_flat() {
6223 let _guard = crate::config::tests::PERSIST_LOCK
6224 .lock()
6225 .unwrap_or_else(|e| e.into_inner());
6226 let dir = tempfile::tempdir().unwrap();
6227 config::set_config_dir(dir.path());
6228 persist(|c| {
6229 c.dsp.profiles.push(DspProfile {
6230 name: "HD 600".into(),
6231 filters: vec![band(100.0)],
6232 role: Some(DspRole::Correction),
6233 devices: vec!["DAC".into(), "Amp".into()],
6234 ..Default::default()
6235 });
6236 c.dsp.profiles.push(DspProfile {
6237 name: "Warm".into(),
6238 filters: vec![band(60.0)],
6239 ..Default::default()
6240 });
6241 c.dsp.profiles.push(DspProfile {
6244 name: "Room".into(),
6245 impulses: vec!["room.wav".into()],
6246 devices: vec!["Monitors".into()],
6247 ..Default::default()
6248 });
6249 c.dsp.profiles.push(DspProfile {
6250 name: "Plain".into(),
6251 filters: vec![band(300.0)],
6252 devices: vec!["Phones".into()],
6253 ..Default::default()
6254 });
6255 c.dsp.profiles.push(DspProfile {
6257 name: "DAC".into(),
6258 filters: vec![band(80.0)],
6259 role: Some(DspRole::Tuning),
6260 ..Default::default()
6261 });
6262 })
6263 .unwrap();
6264 set_tunings("DAC", &[("Warm".into(), true)]).unwrap();
6265 save_preset("Amp", "Speakers").unwrap();
6266 set_enabled(false).unwrap();
6267 migrate().unwrap();
6268 let cfg = Config::cached();
6269 assert!(cfg.dsp.enabled);
6270 for device in ["DAC", "Amp"] {
6271 assert!(cfg.dsp.profile_for(device).is_none(), "{device} is flat");
6272 assert!(tunings_for(device).is_empty());
6273 assert_eq!(preset_for(device), None);
6274 }
6275 let kept = cfg.dsp.profiles.iter().find(|p| p.name == "DAC 2").unwrap();
6276 assert!(kept.preset);
6277 apply_preset("DAC", Some("DAC 2")).unwrap();
6278 assert_eq!(
6279 Config::cached().dsp.profile_for("DAC").unwrap().name,
6280 "HD 600"
6281 );
6282 assert_eq!(tunings_for("DAC"), vec![("Warm".to_string(), true)]);
6283 assert!(
6284 !Config::cached()
6285 .dsp
6286 .profiles
6287 .iter()
6288 .any(|p| p.name == "Amp"),
6289 "Amp was set from Speakers, unchanged"
6290 );
6291 for (device, correction) in [("Monitors", "Room"), ("Phones", "Plain")] {
6292 assert!(Config::cached().dsp.profile_for(device).is_none());
6293 apply_preset(device, Some(device)).unwrap();
6294 assert_eq!(
6295 Config::cached().dsp.profile_for(device).unwrap().name,
6296 correction,
6297 "back as {device}'s correction"
6298 );
6299 assert!(tunings_for(device).is_empty(), "{device}");
6300 }
6301 }
6302
6303 #[test]
6307 fn a_tuning_is_offered_the_target_it_looks_made_against() {
6308 use super::super::targets;
6309 use crate::config::DspTarget;
6310 let _guard = crate::config::tests::PERSIST_LOCK
6311 .lock()
6312 .unwrap_or_else(|e| e.into_inner());
6313 let dir = tempfile::tempdir().unwrap();
6314 config::set_config_dir(dir.path());
6315 let eq = |name: &str, text: &str| DspProfile {
6316 name: name.into(),
6317 filters: super::super::apo::parse(text).unwrap().filters,
6318 role: Some(DspRole::Tuning),
6319 ..Default::default()
6320 };
6321 persist(|c| {
6322 c.dsp.profiles.push(DspProfile {
6323 name: "Performer 8S".into(),
6324 filters: vec![band(100.0)],
6325 target: Some(DspTarget {
6326 made_for: "harman-in-ear-2019".into(),
6327 chosen: None,
6328 }),
6329 ..Default::default()
6330 });
6331 c.dsp
6332 .profiles
6333 .push(eq("Lush", include_str!("testdata/lush-tuning-harman.txt")));
6334 c.dsp.profiles.push(eq(
6335 "Lush N",
6336 include_str!("testdata/lush-tuning-neutral.txt"),
6337 ));
6338 })
6339 .unwrap();
6340 let dac = "Q5K";
6341 assign(Some("Performer 8S"), dac).unwrap();
6342 let harman = "harman-in-ear-2019";
6343 let neutral = "diffuse-field-iso-11904-1";
6344 let suggested = |name: &str| suggest_made_against(name, dac).map(|(id, _)| id);
6345 assert_eq!(suggested("Lush").as_deref(), Some(harman));
6346 assert_eq!(suggested("Lush N").as_deref(), Some(neutral));
6347 choose_target("Performer 8S", Some(neutral)).unwrap();
6349 assert_eq!(suggested("Lush").as_deref(), Some(harman));
6350 assert_eq!(suggested("Lush N").as_deref(), Some(neutral));
6351 choose_target("Performer 8S", None).unwrap();
6352
6353 persist(|c| {
6356 c.dsp.profiles.push(DspProfile {
6357 name: "Flat".into(),
6358 filters: vec![crate::config::DspFilter::Band(crate::config::EqFilter {
6359 kind: crate::config::EqFilterKind::Peaking,
6360 freq: 1000.0,
6361 gain_db: 0.0,
6362 q: 1.0,
6363 channels: vec![],
6364 })],
6365 role: Some(DspRole::Tuning),
6366 ..Default::default()
6367 });
6368 c.dsp.profiles.push(DspProfile {
6369 name: "Air".into(),
6370 filters: vec![crate::config::DspFilter::Band(crate::config::EqFilter {
6371 kind: crate::config::EqFilterKind::Peaking,
6372 freq: 8000.0,
6373 gain_db: 2.0,
6374 q: 1.0,
6375 channels: vec![],
6376 })],
6377 role: Some(DspRole::Tuning),
6378 ..Default::default()
6379 });
6380 })
6381 .unwrap();
6382 assert_eq!(suggested("Flat"), None);
6383 assert_eq!(suggested("Air"), None);
6384 make_group("Both", &["Lush".into(), "Lush N".into()]).unwrap();
6386 assert_eq!(suggested("Both"), None);
6387
6388 set_tunings(dac, &[("Lush N".into(), true)]).unwrap();
6390 let join = || overview_for(Some(dac.into())).joins[0].clone();
6391 assert_eq!(join().join, Some(Join::Unknown));
6392 assert_eq!(
6393 join().suggestion.map(|(id, _)| id).as_deref(),
6394 Some(neutral)
6395 );
6396
6397 let low = targets::grid().iter().position(|&hz| hz >= 30.0).unwrap();
6399 let previews = made_against_previews("Lush N", dac);
6400 let bass = |id: &str| {
6401 previews
6402 .iter()
6403 .find(|(t, _)| t.as_deref() == Some(id))
6404 .unwrap()
6405 .1[low]
6406 };
6407 assert!(
6408 bass(harman) - bass(neutral) > 6.0,
6409 "{} {}",
6410 bass(harman),
6411 bass(neutral)
6412 );
6413
6414 set_tuned_for("Lush N", Some(neutral)).unwrap();
6417 assert_eq!(suggested("Lush N"), None, "nothing to suggest once said");
6418 let j = join();
6419 assert!(
6420 matches!(j.join, Some(Join::Converted { .. })),
6421 "{:?}",
6422 j.join
6423 );
6424 assert_eq!(j.step.len(), targets::grid().len());
6425 assert!(j.step[low] < -3.0, "{}", j.step[low]);
6426 assert_eq!(j.suggestion, None);
6427 }
6428
6429 #[test]
6434 fn an_output_plays_its_tuning_on_its_correction() {
6435 use crate::config::{DspFilter, DspTarget};
6436 let _guard = crate::config::tests::PERSIST_LOCK
6437 .lock()
6438 .unwrap_or_else(|e| e.into_inner());
6439 let dir = tempfile::tempdir().unwrap();
6440 config::set_config_dir(dir.path());
6441 persist(|c| {
6442 c.dsp.profiles.push(DspProfile {
6443 name: "HD 600".into(),
6444 filters: vec![band(100.0)],
6445 target: Some(DspTarget {
6446 made_for: "harman-over-ear-2018".into(),
6447 chosen: None,
6448 }),
6449 ..Default::default()
6450 });
6451 c.dsp.profiles.push(DspProfile {
6452 name: "Warm".into(),
6453 filters: vec![band(60.0)],
6454 tuned_for: Some("diffuse-field-gras-kemar".into()),
6455 ..Default::default()
6456 });
6457 c.dsp.profiles.push(DspProfile {
6458 name: "Lush".into(),
6459 filters: vec![band(200.0)],
6460 role: Some(DspRole::Baked),
6461 ..Default::default()
6462 });
6463 let harman = || {
6464 Some(DspTarget {
6465 made_for: "harman-over-ear-2018".into(),
6466 chosen: None,
6467 })
6468 };
6469 c.dsp.profiles.push(DspProfile {
6470 name: "HD 650".into(),
6471 filters: vec![band(110.0)],
6472 target: harman(),
6473 ..Default::default()
6474 });
6475 c.dsp.profiles.push(DspProfile {
6476 name: "Bright".into(),
6477 filters: vec![band(8000.0)],
6478 tuned_for: Some("harman-over-ear-2018".into()),
6479 ..Default::default()
6480 });
6481 c.dsp.profiles.push(DspProfile {
6482 name: "Room".into(),
6483 impulses: vec!["dsp/room/48000.wav".into()],
6484 ..Default::default()
6485 });
6486 let curve = |db: f64| {
6487 DspFilter::Graphic(crate::config::GraphicEq {
6488 points: vec![(20.0, 0.0), (1000.0, db), (20_000.0, 0.0)],
6489 channels: vec![],
6490 })
6491 };
6492 c.dsp.profiles.push(DspProfile {
6493 name: "Curved".into(),
6494 filters: vec![curve(2.0)],
6495 target: harman(),
6496 ..Default::default()
6497 });
6498 c.dsp.profiles.push(DspProfile {
6499 name: "Tilt".into(),
6500 filters: vec![curve(-1.0)],
6501 tuned_for: Some("diffuse-field-gras-kemar".into()),
6502 ..Default::default()
6503 });
6504 })
6505 .unwrap();
6506 let dac = "Desk DAC";
6507 let shown = || {
6508 super::super::output_chain(&Config::cached().dsp, dac)
6509 .unwrap()
6510 .name
6511 };
6512 let plays = || -> Vec<String> {
6513 let cfg = Config::cached();
6514 let Some(chain) = super::super::output_chain(&cfg.dsp, dac) else {
6515 return Vec::new();
6516 };
6517 super::super::chain(&chain.profile, &chain.all, &mut Vec::new())
6518 .unwrap()
6519 .into_iter()
6520 .map(|f| match f {
6521 DspFilter::Band(b) => format!("{}", b.freq),
6522 DspFilter::Graphic(_) => "step".into(),
6523 other => panic!("{other:?}"),
6524 })
6525 .collect()
6526 };
6527 assign(Some("HD 600"), dac).unwrap();
6528 assert_eq!(plays(), ["100"]);
6529
6530 set_tuning(dac, Some("Warm")).unwrap();
6533 assert_eq!(tuning_for(dac).as_deref(), Some("Warm"));
6534 assert_eq!(plays(), ["step", "60", "100"]);
6535 let join = || overview_for(Some(dac.into())).joins[0].clone();
6536 let harman = target_name("harman-over-ear-2018");
6537 assert_eq!(
6538 join().join,
6539 Some(Join::Converted {
6540 from: harman.clone(),
6541 to: target_name("diffuse-field-gras-kemar"),
6542 })
6543 );
6544 assert_eq!(chain_view(dac).tuning[0].matched, Some(false));
6545 set_tuned_for("Warm", Some("harman-over-ear-2018")).unwrap();
6547 assert_eq!(
6548 detail("Warm").unwrap().tuned_for.as_deref(),
6549 Some("harman-over-ear-2018")
6550 );
6551 assert_eq!(plays(), ["60", "100"]);
6552 assert_eq!(join().join, Some(Join::Matched));
6553 assert_eq!(join().made_for, Some(harman.clone()));
6554 let view = chain_view(dac);
6555 assert_eq!(view.tuning[0].matched, Some(true));
6556 assert!(
6557 view.sentence
6558 .ends_with(&format!("Warm was made for {harman}: matched.")),
6559 "{}",
6560 view.sentence
6561 );
6562 set_tuned_for("Warm", None).unwrap();
6563 assert_eq!(plays(), ["60", "100"]);
6564 assert_eq!(join().join, Some(Join::Unknown), "nothing converts");
6565 assert_eq!(join().made_for, None);
6566 assert!(
6567 chain_view(dac)
6568 .sentence
6569 .contains("What Warm was made against is not set.")
6570 );
6571 set_tunings(dac, &[("Warm".into(), false)]).unwrap();
6573 assert_eq!(join().join, None);
6574 set_tunings(dac, &[("Warm".into(), true)]).unwrap();
6575 set_tuned_for("Warm", Some("harman-in-ear-2019")).unwrap();
6577 assert_eq!(plays(), ["60", "100"]);
6578 assert_eq!(join().join, None);
6579 assert!(set_tuned_for("Warm", Some("made-up")).is_err());
6580
6581 let refused = set_tuning(dac, Some("HD 600")).unwrap_err();
6583 assert!(refused.contains("corrects headphones"), "{refused}");
6584 assign(Some("Lush"), dac).unwrap();
6585 assert_eq!(plays(), ["200"], "the tuning waits");
6586 assert_eq!(
6587 overview_for(Some(dac.into())).left_out.as_deref(),
6588 Some("Warm is left out: Lush already includes a tuning."),
6589 "and says why"
6590 );
6591 assert_eq!(
6592 join(),
6593 EqJoin {
6594 made_for: Some(target_name("harman-in-ear-2019")),
6595 join: None,
6596 note: Some("Warm is left out: Lush already includes a tuning.".into()),
6597 ..Default::default()
6598 },
6599 "at the EQ it is about"
6600 );
6601 assert_eq!(
6602 set_tuning(dac, Some("Warm")).unwrap_err(),
6603 "Lush already includes a tuning. Split it into a correction and an EQ to change the tuning."
6604 );
6605
6606 make_group("Cans", &["HD 600".into(), "HD 650".into()]).unwrap();
6609 assign(Some("Cans"), dac).unwrap();
6610 set_tuning(dac, Some("Warm")).unwrap();
6611 set_tuned_for("Warm", Some("diffuse-field-gras-kemar")).unwrap();
6612 assert_eq!(plays(), ["step", "60", "100"]);
6613 select("Cans", "HD 650").unwrap();
6614 assert_eq!(plays(), ["step", "60", "110"]);
6615 assert_eq!(shown(), "Cans");
6616
6617 make_group("Tastes", &["Warm".into(), "Bright".into()]).unwrap();
6619 set_tuning(dac, Some("Tastes")).unwrap();
6620 assert_eq!(plays(), ["step", "60", "110"]);
6621 select("Tastes", "Bright").unwrap();
6622 assert_eq!(plays(), ["8000", "110"], "made against Harman, on Harman");
6623 set_tuning(dac, Some("Warm")).unwrap();
6624 delete("Tastes").unwrap();
6625
6626 let refused = set_tuning(dac, Some("Room")).unwrap_err();
6628 assert!(refused.contains("impulse responses"), "{refused}");
6629
6630 assign(Some("Curved"), dac).unwrap();
6633 set_tuning(dac, Some("Tilt")).unwrap();
6634 let cfg = Config::cached();
6635 let chain = super::super::output_chain(&cfg.dsp, dac).unwrap();
6636 let played = super::super::chain(&chain.profile, &chain.all, &mut Vec::new()).unwrap();
6637 let curved = cfg
6638 .dsp
6639 .profiles
6640 .iter()
6641 .find(|p| p.name == "Curved")
6642 .unwrap();
6643 assert_eq!(played.len(), 2, "the tuning's curve and the correction's");
6644 assert_eq!(played.last(), curved.filters.first());
6645 let o = overview_for(Some(dac.into()));
6646 assert!(o.tuning_plays);
6647 assert!(
6648 o.left_out
6649 .as_deref()
6650 .is_some_and(|l| l.starts_with("Tilt plays without the target difference")),
6651 "{:?}",
6652 o.left_out
6653 );
6654 set_tuning(dac, Some("Warm")).unwrap();
6655
6656 let spread = |n: usize, from: f64| -> Vec<DspFilter> {
6660 (0..n)
6661 .map(|i| {
6662 DspFilter::Band(crate::config::EqFilter {
6663 kind: crate::config::EqFilterKind::Peaking,
6664 freq: from + i as f64,
6665 gain_db: 1.0,
6666 q: 1.0,
6667 channels: vec![(i % 4) as u16],
6668 })
6669 })
6670 .collect()
6671 };
6672 persist(|c| {
6673 c.dsp.profiles.push(DspProfile {
6674 name: "Busy".into(),
6675 filters: spread(200, 100.0),
6676 ..Default::default()
6677 });
6678 c.dsp.profiles.push(DspProfile {
6679 name: "Base".into(),
6680 filters: spread(100, 1000.0),
6681 ..Default::default()
6682 });
6683 let hd = c
6684 .dsp
6685 .profiles
6686 .iter_mut()
6687 .find(|p| p.name == "HD 600")
6688 .unwrap();
6689 hd.layers = vec![crate::config::DspLayer {
6690 profile: "Base".into(),
6691 on: true,
6692 }];
6693 })
6694 .unwrap();
6695 assign(Some("HD 600"), dac).unwrap();
6696 set_tuning(dac, Some("Busy")).unwrap();
6697 assert_eq!(plays().len(), 101, "the correction alone");
6698 let o = overview_for(Some(dac.into()));
6699 assert!(!o.tuning_plays);
6700 assert!(
6701 o.left_out
6702 .as_deref()
6703 .is_some_and(|l| l.starts_with("Busy is left out")),
6704 "{:?}",
6705 o.left_out
6706 );
6707 assign(Some("Curved"), dac).unwrap();
6708 set_tuning(dac, Some("Warm")).unwrap();
6709
6710 persist(|c| {
6712 c.dsp.profiles.push(DspProfile {
6713 name: "Heavy".into(),
6714 layers: ["Busy", "Base"]
6715 .map(|p| crate::config::DspLayer {
6716 profile: p.into(),
6717 on: true,
6718 })
6719 .to_vec(),
6720 ..Default::default()
6721 });
6722 })
6723 .unwrap();
6724 assign(None, dac).unwrap();
6725 set_tuning(dac, Some("Heavy")).unwrap();
6726 assert!(plays().is_empty());
6727 let o = overview_for(Some(dac.into()));
6728 assert!(!o.tuning_plays);
6729 assert!(
6730 o.left_out
6731 .as_deref()
6732 .is_some_and(|l| l.starts_with("Heavy is left out")),
6733 "{:?}",
6734 o.left_out
6735 );
6736 set_tuning(dac, Some("Warm")).unwrap();
6737
6738 assign(None, dac).unwrap();
6740 assert_eq!(plays(), ["60"]);
6741 assert_eq!(shown(), "Warm");
6742
6743 rename("Warm", "Warm bass").unwrap();
6745 assert_eq!(tuning_for(dac).as_deref(), Some("Warm bass"));
6746 delete("Warm bass").unwrap();
6747 assert_eq!(tuning_for(dac), None);
6748 assert!(plays().is_empty());
6749 }
6750
6751 #[test]
6755 fn a_baked_preset_is_the_correction() {
6756 use crate::config::{DspLayer, DspTarget};
6757 let _guard = crate::config::tests::PERSIST_LOCK
6758 .lock()
6759 .unwrap_or_else(|e| e.into_inner());
6760 let dir = tempfile::tempdir().unwrap();
6761 config::set_config_dir(dir.path());
6762 let layer = |p: &str, on: bool| DspLayer {
6763 profile: p.into(),
6764 on,
6765 };
6766 persist(|c| {
6767 let autoeq = || {
6768 Some(DspTarget {
6769 made_for: "harman-in-ear-2019".into(),
6770 chosen: None,
6771 })
6772 };
6773 c.dsp.profiles.push(DspProfile {
6774 name: "Cantor".into(),
6775 filters: vec![band(100.0)],
6776 target: autoeq(),
6777 ..Default::default()
6778 });
6779 c.dsp.profiles.push(DspProfile {
6780 name: "HD 600".into(),
6781 filters: vec![band(100.0)],
6782 target: autoeq(),
6783 ..Default::default()
6784 });
6785 for name in ["Performer 8S", "Warm"] {
6786 c.dsp.profiles.push(DspProfile {
6787 name: name.into(),
6788 filters: vec![band(200.0)],
6789 ..Default::default()
6790 });
6791 }
6792 c.dsp.profiles.push(DspProfile {
6794 name: "Old".into(),
6795 layers: vec![layer("Cantor", true), layer("Performer 8S", true)],
6796 ..Default::default()
6797 });
6798 })
6799 .unwrap();
6800 let folder = super::dir("Cantor");
6802 std::fs::create_dir_all(&folder).unwrap();
6803 let mut csv = String::from("frequency,raw,target\n");
6804 for hz in super::super::targets::grid() {
6805 csv.push_str(&format!("{hz:.2},0.0,0.0\n"));
6806 }
6807 std::fs::write(super::super::targets::result_path(&folder), csv).unwrap();
6808 let cfg = Config::cached();
6809 let named = |n: &str| cfg.dsp.profiles.iter().find(|p| p.name == n).unwrap();
6810 assert_eq!(
6811 role(named("Cantor")),
6812 DspRole::Correction,
6813 "an AutoEQ install"
6814 );
6815 assert_eq!(role(named("Warm")), DspRole::Tuning, "anything else");
6816 assert_eq!(
6817 shown_role(named("Old"), &cfg.dsp.profiles),
6818 DspRole::Correction,
6819 "a stack is what it corrects with"
6820 );
6821 assert_eq!(corrects_twice(named("Old"), &cfg.dsp.profiles), None);
6822
6823 set_role("Performer 8S", DspRole::Baked).unwrap();
6824 let d = detail("Old").unwrap();
6825 assert_eq!(
6826 d.corrects_twice.as_deref(),
6827 Some(
6828 "This EQ corrects twice: Cantor (AutoEQ) and Performer 8S (includes a tuning). Keep one."
6829 )
6830 );
6831 assert_eq!(
6832 d.layer_roles,
6833 vec![Some(DspRole::Correction), Some(DspRole::Baked)]
6834 );
6835 let cfg = Config::cached();
6837 let old = cfg.dsp.profiles.iter().find(|p| p.name == "Old").unwrap();
6838 assert!(super::super::chain(old, &cfg.dsp.profiles, &mut Vec::new()).is_ok());
6839 set_layers(
6840 "Old",
6841 vec![layer("Cantor", true), layer("Performer 8S", false)],
6842 )
6843 .unwrap();
6844 let refused = set_layers(
6845 "Old",
6846 vec![
6847 layer("Cantor", true),
6848 layer("Performer 8S", false),
6849 layer("HD 600", true),
6850 ],
6851 )
6852 .unwrap_err();
6853 assert!(
6854 refused.starts_with("This EQ already plays the correction"),
6855 "{refused}"
6856 );
6857
6858 set_layers(
6860 "Desk",
6861 vec![layer("Performer 8S", true), layer("Warm", true)],
6862 )
6863 .unwrap();
6864 let s = summary("Desk").unwrap();
6865 assert_eq!(
6866 s.correction.as_deref(),
6867 Some("Performer 8S (correction + tuning in one)")
6868 );
6869 assert!(s.baked);
6870 assert_eq!(s.tunings, vec!["Warm".to_string()]);
6871 let refused = set_layers(
6872 "Desk",
6873 vec![
6874 layer("Performer 8S", true),
6875 layer("Warm", true),
6876 layer("Cantor", true),
6877 ],
6878 )
6879 .unwrap_err();
6880 assert_eq!(
6881 refused,
6882 "This EQ already plays the correction Performer 8S. Take that out first, or add Cantor to a device's tuning instead."
6883 );
6884 }
6885
6886 #[test]
6889 fn autoeq_moved_to_another_target_is_rebuilt() {
6890 use crate::config::{DspFilter, DspTarget};
6891 let _guard = crate::config::tests::PERSIST_LOCK
6892 .lock()
6893 .unwrap_or_else(|e| e.into_inner());
6894 let dir = tempfile::tempdir().unwrap();
6895 config::set_config_dir(dir.path());
6896 let folder = super::dir("HD 650");
6897 std::fs::create_dir_all(&folder).unwrap();
6898 let mut csv = String::from("frequency,raw,target\n");
6899 for hz in super::super::targets::grid() {
6900 csv.push_str(&format!("{hz:.2},0.0,0.0\n"));
6901 }
6902 std::fs::write(super::super::targets::result_path(&folder), csv).unwrap();
6903 persist(|c| {
6904 c.dsp.profiles.push(DspProfile {
6905 name: "HD 650".into(),
6906 filters: vec![band(100.0), band(1000.0)],
6907 target: Some(DspTarget {
6908 made_for: "harman-over-ear-2018".into(),
6909 chosen: None,
6910 }),
6911 ..Default::default()
6912 })
6913 })
6914 .unwrap();
6915 let chain = || {
6916 let cfg = Config::cached();
6917 let p = cfg.dsp.profiles[0].clone();
6918 super::super::chain(&p, &cfg.dsp.profiles, &mut Vec::new()).unwrap()
6919 };
6920 assert_eq!(chain().len(), 2, "AutoEQ's bands, as made");
6921 choose_target("HD 650", Some("harman-over-ear-2018-without-bass")).unwrap();
6922 let rebuilt = chain();
6923 assert_eq!(rebuilt.len(), 1, "{rebuilt:?}");
6924 assert!(matches!(rebuilt[0], DspFilter::Graphic(_)));
6925 assert!(
6926 summary("HD 650")
6927 .unwrap()
6928 .correction
6929 .unwrap()
6930 .contains("rebuilt from AutoEQ's measurement")
6931 );
6932 }
6933
6934 #[test]
6935 fn previews_never_push_out_the_playing_fit() {
6936 use super::super::targets;
6937 let measured = read_measurement(&measured(6.0)).unwrap();
6938 let aim = targets::choice_curve("harman-in-ear-2019").unwrap();
6939 let fit = for_playing(|| squig_fit(&measured, &aim));
6940 let key = fit_key(&measured, &aim);
6941 let kept = |key| FITS.lock().playing.entries.iter().any(|(k, _)| *k == key);
6942 assert!(kept(key));
6943 for preview in 0..100u64 {
6944 FITS.lock().keep(preview ^ 0x5eed, fit.clone(), false);
6945 }
6946 assert!(kept(key), "the playing fit is still kept");
6947 assert_eq!(squig_fit(&measured, &aim), fit);
6948 assert!(
6949 kept(key),
6950 "used again by a page, it stays with the player's"
6951 );
6952 }
6953
6954 #[test]
6955 fn a_full_lru_lets_go_of_the_least_recently_used_alone() {
6956 let fit = (Vec::new(), 0.0);
6957 let mut lru = Lru::new(3);
6958 for k in 0..3 {
6959 lru.put(k, fit.clone());
6960 }
6961 let used = lru.take(0).unwrap();
6962 lru.put(0, used);
6963 lru.put(3, fit);
6964 let keys: Vec<_> = lru.entries.iter().map(|(k, _)| *k).collect();
6965 assert_eq!(keys, [2, 0, 3]);
6966 }
6967}