Skip to main content

koan_core/audio/dsp/
profiles.rs

1//! The profiles in `config.local.toml`, as every front end changes them.
2//!
3//! Imported responses are kept under `dsp/<profile>/` beside the config, one
4//! 32-bit float WAV per rate, or a `.cfg` with it where routes mix or delay
5//! channels. Whatever they were imported from — a Roon zip, a CamillaDSP
6//! setup, a share from another app — is read once and not needed again.
7
8use 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    /// Profiles it plays first, for a stack.
20    pub layers: usize,
21    /// Rates there are responses for.
22    pub rates: Vec<u32>,
23    /// Why the profile would not load, if it would not.
24    pub problem: Option<String>,
25    pub role: DspRole,
26    /// Built from a measurement.
27    pub measured: bool,
28    /// For a group: its members, in order, and the one playing.
29    pub members: Vec<String>,
30    pub playing: Option<String>,
31    /// A preset: a correction and its tuning saved together.
32    pub preset: bool,
33    /// Changed since it was imported, so it can go back.
34    pub edited: bool,
35    /// The devices it is chosen for: as their correction, in their tuning,
36    /// or as the preset they were set from.
37    pub used_on: Vec<String>,
38    /// Kept on every device of the account.
39    pub everywhere: bool,
40    /// The presets and groups that hold it.
41    pub held_by: Vec<String>,
42    /// Why it cannot move to the other scope, while it cannot.
43    pub scope_locked: Option<String>,
44    /// The graphic curves among `bands`, and their points in all.
45    pub graphics: usize,
46    pub points: usize,
47    /// The target it was made against, by name.
48    pub made_for: Option<String>,
49    /// How it would meet the overview device's correction, as `joins` says
50    /// of an EQ in that device's tuning.
51    pub join: Option<Join>,
52}
53
54/// What a device plays, as the apps' EQ page shows it: for a front end
55/// without one, such as the CLI and an assistant driving it.
56#[derive(Debug, Clone, PartialEq, serde::Serialize)]
57pub struct ChainView {
58    pub device: String,
59    pub correction: Option<String>,
60    /// The target the correction aims at, by name.
61    pub target: Option<String>,
62    pub tuning: Vec<TuningView>,
63    pub preset: Option<String>,
64    /// Changed since it was set from `preset`.
65    pub edited: bool,
66    /// The tuning's EQs that do not play, by name; `notes` says why.
67    pub left_out: Vec<String>,
68    pub notes: Option<String>,
69    /// No correction and no tuning: it plays untouched.
70    pub flat: bool,
71    /// The chain in words, as the EQ page says it.
72    pub sentence: String,
73}
74
75#[derive(Debug, Clone, PartialEq, serde::Serialize)]
76pub struct TuningView {
77    pub name: String,
78    pub on: bool,
79    /// The target it was made against, by name.
80    pub made_for: Option<String>,
81    /// Made against the target the correction aims at. False where it was
82    /// made against another, or where that is not known; none where there
83    /// is nothing to compare.
84    pub matched: Option<bool>,
85    pub join: Option<Join>,
86    /// What of it does not play as chosen, and why.
87    pub note: Option<String>,
88}
89
90/// How an EQ of the tuning meets the correction ahead of it.
91#[derive(Debug, Clone, PartialEq, serde::Serialize)]
92#[serde(rename_all = "snake_case", tag = "state")]
93pub enum Join {
94    /// Made against the target the correction aims at: nothing to convert.
95    Matched,
96    /// Made against another target: the difference from the correction's
97    /// target to that one plays first. Names, not ids.
98    Converted { from: String, to: String },
99    /// Made against another target, under a correction fitted to a
100    /// measurement: the correction is fitted to that target in place of
101    /// `from`, so nothing is converted.
102    Refitted { from: String, to: String },
103    /// Made against a target not said, so nothing converts: a target the EQ
104    /// already includes may be applied twice.
105    Unknown,
106}
107
108/// One EQ of a device's tuning, as it meets the correction.
109#[derive(Debug, Clone, Default, PartialEq)]
110pub struct EqJoin {
111    /// The target it was made against, by name.
112    pub made_for: Option<String>,
113    /// None where nothing is compared: no correction, one whose target is
114    /// not known, a headphone of another kind, or an EQ that does not play.
115    pub join: Option<Join>,
116    pub note: Option<String>,
117    /// For a converted join, the target difference that plays, in dB on
118    /// `targets::grid()`.
119    pub step: Vec<f64>,
120    /// For an unknown join, the target it looks made against, by id and
121    /// name: see [`suggest_made_against`].
122    pub suggestion: Option<(String, String)>,
123}
124
125/// How each EQ of `device`'s tuning meets its correction, in order. `notes`
126/// are the chain's, by EQ, and `dropped` the EQs it leaves out.
127fn 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
184/// How an EQ made against `made` meets a correction aiming at `aim`, both
185/// target ids. None where nothing compares: no correction target, or one
186/// for another kind of headphone.
187fn 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
199/// What `device` plays.
200pub 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            // The tuning stays the device's under a correction that
242            // includes one, and plays again once that correction goes.
243            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
284/// What the tuning `name` looks made against, as an id and a name, played on
285/// a correction aiming at `aim`: see [`targets::guess_made_against`].
286fn 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    // A group's Made against is every member's that does not say, and a
290    // guess from the member playing is no guess at the others.
291    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
298/// What the tuning `name` looks made against, on `device`, where it does not
299/// say: worked out from its curve against the target `device`'s correction
300/// aims at, for the person to accept or not. Never set without asking.
301pub 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
312/// What the tuning `name` adds on `device`'s correction for each target it
313/// could say it was made against, in dB on `targets::grid()`: itself, with
314/// the target difference ahead of it where the target is another of the
315/// same kind of headphone. `None` is not saying, which plays it as it is.
316/// A wrong choice shows as a preference applied twice or taken out. Empty
317/// where the correction's target is not known.
318pub 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    // A correction built from a measurement is fitted again to the target
344    // chosen, which moves it from the bands it plays, its own and for one
345    // saved before kōan fitted bands the fit made as it plays, to the refit.
346    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
386/// How `eq` meets a correction aiming at `aim`, as a sentence: what the EQ
387/// page's chain reads to VoiceOver after the chain itself.
388pub 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
403/// "A", "A and B", "A, B and C".
404fn 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
412/// Set `device`'s whole chain: its correction (`Some(None)` for none), its
413/// tuning's EQs in order, all on. What is not given is left as it is, and
414/// giving the same twice changes nothing. Refused, naming the choices, for
415/// a correction or EQ that is not one.
416pub 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        // Checked before anything is set, so a refusal changes nothing.
464        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    // One write, so the two never half-apply. What is not given stays, and
476    // so does the preset the device was set from, which then reads as edited.
477    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/// What a device is set to, for a menu of presets.
498#[derive(Debug, Clone, PartialEq)]
499pub struct OutputState {
500    pub device: String,
501    /// The preset it was set from, and whether it was changed since.
502    pub preset: Option<(String, bool)>,
503    /// No correction and no tuning: it plays untouched.
504    pub flat: bool,
505}
506
507#[derive(Debug, Clone, PartialEq)]
508pub struct Overview {
509    pub enabled: bool,
510    /// The output device playback goes to.
511    pub device: Option<String>,
512    /// The profile for that device: its correction.
513    pub active: Option<String>,
514    /// The tuning it plays on top.
515    pub tuning: Option<String>,
516    /// Whether that tuning plays: not on a baked correction, nor where the
517    /// chain cannot hold it.
518    pub tuning_plays: bool,
519    /// What of that device's choices does not play, and why.
520    pub left_out: Option<String>,
521    /// The EQs of its tuning left out entirely.
522    pub left_out_eqs: Vec<String>,
523    /// Every output's tuning, by device: its first EQ.
524    pub tunings: Vec<(String, String)>,
525    /// That device's tuning: its EQs in order, each on or off.
526    pub chain: Vec<(String, bool)>,
527    /// How each EQ of `chain` meets the correction, in the same order.
528    pub joins: Vec<EqJoin>,
529    /// The target that device's correction aims at, by name.
530    pub aim: Option<String>,
531    /// Whether that target is for in-ears; none where it is not known.
532    pub in_ear: Option<bool>,
533    /// The preset that device was set from, and whether it was changed
534    /// since.
535    pub preset: Option<(String, bool)>,
536    pub profiles: Vec<Summary>,
537    /// Every device the EQ names, and what each is set to.
538    pub outputs: Vec<OutputState>,
539    /// That device plays filters alone, with no correction.
540    pub filters_only: bool,
541}
542
543/// The device the player resolves to: the configured one if it is there, or
544/// the system's.
545pub 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
560/// The profiles, and which `device` plays through. A front end that knows
561/// a renderer is the output names it by its UDN here.
562pub 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
678/// Every device the EQ names, in the order first named, and what each is
679/// set to.
680fn 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
704/// The devices `name` is chosen for: as their correction, in their tuning,
705/// or as the preset they were set from.
706fn 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
731/// Save an import as the profile `name`, or the name it came with. An existing
732/// profile of that name keeps its devices, and whichever of its filters and
733/// responses the import does not replace — so a room's responses and a
734/// headphone EQ can be imported into one profile in turn.
735pub 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            // Derived from the filters, which now include these.
758            profile.preamp_db = None;
759            // Kept, so an edit can be undone.
760            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/// One impulse-response file in a profile, as the detail page shows it.
808#[derive(Debug, Clone, PartialEq)]
809pub struct ImpulseDetail {
810    pub file: String,
811    pub rate: u32,
812    /// `None` for one response applied to every channel.
813    pub channels: Option<usize>,
814    pub taps: usize,
815    pub routes: usize,
816    /// Routes mix channels into others, as crossfeed does, rather than each
817    /// channel being convolved on its own.
818    pub mixes: bool,
819    /// Channels delayed against each other.
820    pub delayed: bool,
821    /// Where the response peaks: the delay trimmed from playback.
822    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    /// The gain applied ahead of the filters, at `preamp_rate`.
833    pub preamp_db: f64,
834    pub preamp_rate: u32,
835    /// Set by hand rather than derived.
836    pub preamp_set: bool,
837    pub problem: Option<String>,
838    /// Profiles it plays first, for a stack.
839    pub layers: Vec<crate::config::DspLayer>,
840    /// A group: one of `layers` plays, the one switched on.
841    pub group: bool,
842    /// Kept on every device of the account, rather than this one alone.
843    pub everywhere: bool,
844    /// Where it is kept was chosen, rather than following from what it is.
845    pub scope_set: bool,
846    pub role: DspRole,
847    pub role_set: bool,
848    /// A chain correcting twice, made before that was refused: which, and
849    /// what to do.
850    pub corrects_twice: Option<String>,
851    /// The chain in a line each: see [`summary`].
852    pub corrects: Option<String>,
853    pub corrects_baked: bool,
854    pub tunings: Vec<String>,
855    /// For each of `layers`, what it is for; `None` where it is missing.
856    pub layer_roles: Vec<Option<DspRole>>,
857    pub measured: bool,
858    pub made_for: Option<String>,
859    /// For a tuning: the target it was made against.
860    pub tuned_for: Option<String>,
861    /// A preset: a correction and its tuning saved together.
862    pub preset: bool,
863    /// Changed since it was imported.
864    pub edited: bool,
865    /// Imported, so it can go back to how it was.
866    pub can_revert: bool,
867    /// A correction, which plays as made: its bands are not edited.
868    pub read_only: bool,
869    /// For an EQ of bands alone: one list for both channels, or one each.
870    pub channels: Option<ChannelLayout>,
871}
872
873/// Everything in the profile `name`.
874pub 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    // What it plays is adjusted to stay within bounds: say how.
908    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
977/// Rename a profile, moving the responses koan keeps for it.
978pub 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        // The stacks that play it follow the new name.
1007        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        // So do the outputs that play it as their tuning, or were set from it.
1018        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
1042/// Write the profiles, and say so: what each output plays through is shown
1043/// on every device that can choose it, here and on the devices controlling
1044/// this one.
1045fn 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
1052/// Where `name` keeps its files: responses, and an AutoEQ result's CSV.
1053pub fn dir(name: &str) -> PathBuf {
1054    config::config_dir().join("dsp").join(slug(name))
1055}
1056
1057/// Record the target `name`'s correction was made for: one that ships,
1058/// since a move is worked out against it. A target chosen in its place is
1059/// kept where it is for the same kind of headphone. Refused for a
1060/// correction built from a measurement, which is made for the target it is
1061/// corrected to.
1062pub 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
1095/// The headphone `p` corrects, as measured, and the target it is corrected
1096/// to, for drawing: an AutoEQ result's own two curves, its target moved as a
1097/// chosen target moves it; or a measurement and its target, levelled at
1098/// 1 kHz so they are drawn against each other.
1099fn 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/// What `name` offers to move its correction to.
1135#[derive(Debug, Clone, PartialEq)]
1136pub struct TargetChoices {
1137    /// The target an AutoEQ correction or a ready-made EQ was made for;
1138    /// none for one built from a measurement, which is made for `chosen`.
1139    pub made_for: Option<&'static super::targets::Target>,
1140    pub chosen: Option<String>,
1141    /// The shipped targets for the same kind of headphone, then those added.
1142    pub choices: Vec<TargetChoice>,
1143}
1144
1145#[derive(Debug, Clone, PartialEq)]
1146pub struct TargetChoice {
1147    pub id: String,
1148    pub name: String,
1149    /// What it does, in a few plain words; empty for one added.
1150    pub does: String,
1151    /// What it sounds like; empty for one added.
1152    pub character: String,
1153}
1154
1155/// The targets `name` can move to, for a correction installed from AutoEQ
1156/// whose target is known.
1157pub 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
1200/// Move `name`'s correction to `chosen`, or with `None` back to the target it
1201/// was made for.
1202pub 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    // Built from a measurement: its bands are fitted again to the target
1211    // chosen.
1212    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            // Built from a measurement: it is made for the target chosen,
1232            // and fitted to it, one saved before kōan fitted bands too.
1233            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
1249/// Play `device` through `name`, or untouched with `None`.
1250pub 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        // A preset is never an output's correction: it sets one.
1257        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        // A correction chosen is the output correcting again.
1269        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
1280/// The ranges a band edited by hand is held to: the audible band, what a
1281/// correction plausibly asks, and Q from very broad to very narrow.
1282const 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
1293/// Refused unless `name` is there and can be edited: not a correction.
1294fn 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
1305/// A band's type by its config name, and its figures held within the
1306/// ranges a band may have.
1307fn 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
1323/// A curve's points held within a band's ranges, in order of frequency.
1324fn 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
1340/// What `name` would draw with filter `index` changed by `edit`, nothing
1341/// saved: the graph follows a drag with this, and the edit is saved when
1342/// the drag settles. None where the filter is not of the kind `edit` takes,
1343/// or `name` is a correction, which is not edited.
1344fn 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
1360/// What `name` would draw with band `index` set so, at `rate`.
1361pub 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
1380/// What `name` would draw with curve `index` set to `points`, at `rate`.
1381pub 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
1397/// Set filter `index` of `name`, a parametric band, to `kind` at `freq`,
1398/// `gain_db` and `q`, held within the ranges a band may have. Its channels
1399/// are kept. Delays, mixes and graphic curves are not edited this way.
1400/// Refused for a correction, which plays as made.
1401pub 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
1429/// Set the points of filter `index` of `name`, a graphic curve, each held
1430/// within the ranges a band may have and put in order of frequency. Its
1431/// channels are kept. Refused for a correction, which plays as made.
1432pub 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
1453/// Add a flat peaking band at 1 kHz to the end of `name`'s filters, on
1454/// `channel` alone or, with none, on every channel. Returns its index.
1455pub 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/// Where a new EQ of bands starts: flat, or tilted the way most people
1475/// first reach for.
1476#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1477pub enum Starter {
1478    Flat,
1479    BassBoost,
1480    Warm,
1481    Bright,
1482}
1483
1484/// Eight bands spread across the range, as a parametric EQ opens: a low
1485/// shelf, six peaks an octave apart and a high shelf, at `starter`'s gains.
1486pub 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
1522/// Make the EQ `name`: `starter`'s bands, on every channel, to edit.
1523pub 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/// How an EQ of bands lays them over the two channels: one list for both,
1543/// or one for each, and whether the two lists say different things.
1544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1545pub struct ChannelLayout {
1546    pub stereo: bool,
1547    pub differ: bool,
1548}
1549
1550/// `filters`' layout, where they are bands alone, each on every channel or
1551/// on the left or right alone. `None` for anything else: delays, mixes,
1552/// curves, or bands over more channels than two.
1553pub 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
1581/// Give `name` a list of bands for each channel, each starting as the one
1582/// list it had, or one list for both: the bands of channel `keep`, the
1583/// other's dropped.
1584pub 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
1629/// Take filter `index` out of `name`.
1630pub 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/// What a profile does to the sound, for drawing: every curve on AutoEQ's
1646/// grid, in dB.
1647#[derive(Debug, Clone, PartialEq)]
1648pub struct Response {
1649    pub freqs: Vec<f64>,
1650    /// Everything it plays, layers, target step and impulse responses
1651    /// included, on the first channel. The preamp is left out, so the
1652    /// curve lines up with the bands' own gains; it is `preamp_db`.
1653    pub total: Vec<f64>,
1654    /// Each of its own parametric bands alone, in order; a flat gain is
1655    /// `gain_db`.
1656    pub bands: Vec<Vec<f64>>,
1657    /// Each layer as it plays alone, for a stack.
1658    pub layers: Vec<LayerResponse>,
1659    /// For a correction from AutoEQ (or a stack with one among its layers):
1660    /// the headphone as measured, the target it plays to, and the measurement
1661    /// with everything here applied.
1662    pub measurement: Option<Vec<f64>>,
1663    pub target: Option<Vec<f64>>,
1664    pub predicted: Option<Vec<f64>>,
1665    /// The gain ahead of it all at `rate`.
1666    pub preamp_db: f64,
1667    /// The flat gain among its filters on the first channel, which `total`
1668    /// includes. With `preamp_db`, the level the curve sits at, however the
1669    /// profile states its gain.
1670    pub gain_db: f64,
1671    /// For a chain with both a correction and tuning: what the correction
1672    /// does, and what the tuning on top does, so each can be drawn as its
1673    /// own. `total` is the two together.
1674    pub correction: Option<Vec<f64>>,
1675    pub tuning: Option<Vec<f64>>,
1676    /// For a split's preview: the baked EQ it comes from, which `total`
1677    /// plays the same as.
1678    pub original: Option<Vec<f64>>,
1679    /// For an EQ with a list of bands for each channel: the right channel,
1680    /// where `total` is the left.
1681    pub right: Option<Vec<f64>>,
1682    /// `gain_db` on the right channel, which `right` includes.
1683    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
1693/// What `name` does to the sound at `rate`, from the filters the DSP runs.
1694/// `None` for a profile that is not there or would not play.
1695pub 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
1701/// What the output `device` plays at `rate`: its correction and the tuning
1702/// on top, as the player builds them.
1703pub 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    // The correction alone, where the chain has tuning besides.
1723    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    // An output's chain is its correction with the tuning as layers, and
1728    // the correction as it plays there, fitted again for the tuning.
1729    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                // An output's tuning is resolved under a name of its own.
1759                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    // The headphone this chain corrects, as measured, and the target it is
1772    // corrected to: this profile's, or the first of its layers that has one.
1773    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
1815/// The right channel of what `profile` plays, where it differs from the
1816/// left: one or more of its EQs has a list of bands for each channel. Not
1817/// drawn for impulse responses, whose channels are their own.
1818fn 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
1839/// Play `member` of the group `group`, and none of the others.
1840pub 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
1864/// Make `name` a group, where one of its layers plays, or a stack, where
1865/// each is switched on or off. Made a group, the first layer switched on
1866/// is the one that plays.
1867pub 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
1882/// `name`, or numbered where it or the folder its files would go in is
1883/// taken, so nothing already here is written over.
1884pub 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
1900/// Make `name` a group of `members`, in order, the first playing. Refused
1901/// over a profile of that name.
1902pub 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    // Each member as it would play, so a group that cannot is never kept.
1924    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
1936/// Where `profile` is kept: as set, or as follows from what it is. A room
1937/// or speaker correction, with impulse responses, or one for an output that
1938/// stays put (built in, or an amplifier on the network) is this device's; a
1939/// headphone correction from AutoEQ is the account's, since headphones move
1940/// between devices; a stack is the account's when every layer is.
1941pub 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
1977/// An output that does not travel: built in, an amplifier on the network
1978/// (named by its UDN), or a phone's own speaker.
1979fn 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
1989/// The first of `layers` kept on this device alone, which a stack kept
1990/// everywhere cannot play on the account's other devices.
1991fn 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
2002/// The stack kept everywhere that plays `name`, if one does.
2003fn 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
2009/// Why `profile` cannot move to the scope it is not in, in a few words.
2010fn 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
2025/// Keep `name` everywhere or on this device alone. A stack kept everywhere
2026/// cannot have a layer kept here: refused either way round.
2027pub 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
2058/// What `profile` is for: as set, or a correction where it was installed
2059/// from AutoEQ, built from a measurement or said to be made for a target,
2060/// and a tuning otherwise.
2061pub fn role(profile: &DspProfile) -> DspRole {
2062    // Impulse responses correct a room or a device: never a tuning.
2063    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
2073/// What `profile` is for, as shown: a stack with nothing of its own is what
2074/// it plays, a correction where it holds one, as that correction is.
2075pub 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
2086/// The target the chain `profile` corrects to: its correction's chosen
2087/// target, or the one it was made for or is built from a measurement to.
2088/// None for a correction with a tuning baked in, whose target is not only a
2089/// target, or one whose target is not known.
2090pub 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
2103/// The target difference a tuning made against `made` plays on the
2104/// correction of `profile`'s chain, which aims at `aim`. Tapered where the
2105/// correction is built from a measurement, as that correction is, so the two
2106/// sum to the tuning's target, tapered, minus the measurement.
2107pub 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
2132/// What `device`'s chain plays, in dB on `targets::grid()` at `rate`: its
2133/// correction, any target difference and its tuning, as one curve, preamp
2134/// aside. With `correction` or `tuning` given, those stand in for the
2135/// device's own, in this call only; nothing is saved. What an independent
2136/// check compares with another tool's curves.
2137pub 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
2183/// The first EQ of `device`'s tuning: what an app showing one shows.
2184pub fn tuning_for(device: &str) -> Option<String> {
2185    tunings_for(device).into_iter().next().map(|(name, _)| name)
2186}
2187
2188/// `device`'s tuning: its EQs in the order they play, each on or off.
2189pub 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
2199/// Play `tuning` alone on top of `device`'s correction, or none.
2200pub 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
2205/// Make `device`'s tuning `tunings`, EQs in the order they play, each on or
2206/// off. Only EQs, each once, none with impulse responses; and none on over
2207/// a correction with a tuning baked in, which would add taste twice.
2208pub 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
2237/// Whether `name` can be an EQ of a tuning, and why not.
2238fn 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
2256/// Why a correction's bands are not edited.
2257pub 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
2263/// Refused for a correction, which plays as made, and for anything fitted
2264/// to a measurement whatever it is said to be: its bands are fitted again to
2265/// each new target, and an edit would be lost.
2266fn 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
2279/// Whether `profile` was changed since it was imported.
2280pub 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
2287/// Put `name` back as it was imported.
2288pub 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
2309/// A copy of `name` as it is now, under `new` or "<name> copy": its filters,
2310/// files, role, targets and where it is kept, used by no output. Its own,
2311/// with nothing to revert to. The copy's name.
2312pub 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
2351/// `name`'s filters and headroom as they are, to put back with
2352/// `save_as_copy`. Opaque to callers.
2353pub 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/// A copy saved by `save_as_copy`: its name, and whether it took the
2364/// original's place in the device's tuning, which it does only where the
2365/// tuning names the original itself.
2366#[derive(Debug, Clone, PartialEq)]
2367pub struct SavedCopy {
2368    pub name: String,
2369    pub placed: bool,
2370}
2371
2372/// Keep `name` as it is now as a copy, under `new` or "<name> copy", and put
2373/// `name` back: to `before`, a `snapshot` taken before the edit, where it
2374/// differs, or else to its file, where it was imported and has been edited.
2375/// On `device`, the copy takes `name`'s place in the tuning. Refused for a
2376/// correction, which is not edited.
2377pub 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    // Whatever would refuse the swap is found before anything is written:
2390    // the copy is the original under another name, so it may be a tuning's
2391    // EQ exactly where the original may.
2392    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
2445/// The preset `device` was last set from, and whether its correction or
2446/// tuning has been changed since.
2447pub 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
2466/// Whether `device` plays filters alone, with no correction.
2467pub fn filters_only(device: &str) -> bool {
2468    Config::cached()
2469        .dsp
2470        .filters_only
2471        .iter()
2472        .any(|d| d == device)
2473}
2474
2475/// Have `device` play filters alone, or correct for a headphone again. Alone,
2476/// a correction it had is taken off it, and kept; an EQ chosen for it that
2477/// does not correct stays.
2478pub 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
2498/// `device`'s correction and tuning as a preset's layers: the correction,
2499/// then the tuning's EQs.
2500fn 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
2523/// Save `device`'s correction and tuning as the preset `name`, over one of
2524/// that name, and say the output is set from it.
2525pub 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
2561/// `device`'s correction where nothing says it is one: a profile with no
2562/// role that would read as a tuning, chosen as a correction before roles
2563/// were asked. A preset saved from it must still put it back as one.
2564fn 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
2572/// Say that `name` corrects, as the output it is chosen for uses it.
2573fn 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
2584/// What a preset's layers set an output to: its correction, where the first
2585/// layer is one and on, and its tuning's EQs in order. A correction switched
2586/// off is no correction, as it played in a stack.
2587fn 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
2605/// Set `device` to play `correction` and `tunings`, set from `preset`.
2606fn 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
2638/// Set `device` from the preset `name`: its correction, and its tuning's
2639/// EQs in order, on and off as saved. With `None`, flat: no correction and
2640/// no tuning, which plays untouched.
2641pub 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            // Filters alone: a correction the preset somehow holds is not
2672            // set.
2673            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
2691/// An output set from a preset by `migrate`: the device, its correction,
2692/// its tuning and the preset.
2693type Setting = (String, Option<String>, Vec<(String, bool)>, String);
2694
2695/// Bring configs from before presets and tunings of several EQs up to
2696/// them, once at start, and again harmlessly after:
2697/// - a stack that is a correction with EQs on top is a preset, and an output
2698///   that played it plays its correction and EQs, set from it, then the
2699///   tuning it had on top; a correction switched off in the stack is none;
2700/// - an output a preset is assigned to as its correction, as the apps
2701///   before this could and a synced preset can, is set from it the same way;
2702/// - an output whose correction is a group, of which one member plays, has
2703///   that member; a group in a tuning becomes its member playing;
2704/// - with processing switched off, which is no longer a thing, every output
2705///   is flat, its correction and tuning kept as a preset to go back to.
2706pub fn migrate() -> Result<(), String> {
2707    migrate_presets()?;
2708    flatten_off()
2709}
2710
2711/// Processing switched off becomes every output flat. What an output played
2712/// is kept as a preset named for it, unless it was set from one and not
2713/// changed since.
2714fn 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    // Each stack becoming a preset, or preset assigned to an output: its
2786    // name, the layers it keeps as a preset, and the outputs it set.
2787    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    // Each output those set: its correction, and its tuning, the preset's
2813    // EQs then those it had on top.
2814    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
2879/// Why no tuning goes on top of `correction`.
2880pub 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
2886/// Say which target the tuning `name` was made against, or that it is not
2887/// known: a target that ships or one added.
2888pub 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
2904/// The corrections a chain plays, `profile` and its layers switched on, in
2905/// the order they play.
2906pub 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        // A group plays one member, so only it counts.
2913        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
2934/// A correction by name and kind, as a warning names it: "Cantor (AutoEQ)".
2935fn 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
2954/// What a chain correcting more than once says: each undoes the same
2955/// headphone, so it plays twice the correction. Stacks made before that was
2956/// refused still play, and say so.
2957pub 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
2974/// Why a chain with more than one correction is refused.
2975pub 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
2983/// Say what `name` is for. Always taken, since it is what the profile is:
2984/// a stack it makes correct twice says so on its page (`corrects_twice`),
2985/// and gains no further correction.
2986pub 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
2997/// A target's name, by id: one that ships, one added, or the id itself.
2998pub 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/// What a chain does, in a line each: the headphone it corrects and how,
3012/// and the tunings on top.
3013#[derive(Debug, Clone, Default, PartialEq)]
3014pub struct ChainSummary {
3015    pub correction: Option<String>,
3016    /// The correction has a tuning baked in.
3017    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
3077/// A measurement, checked: frequency and level pairs covering the audible
3078/// band, as squig.link and REW export them, or a speaker's Klippel export.
3079pub fn read_measurement(text: &str) -> Result<super::targets::Curve, String> {
3080    reading(text).map(|(curve, _)| curve)
3081}
3082
3083/// What a measurement is read as.
3084#[derive(Debug, Clone, PartialEq)]
3085pub struct MeasurementReading {
3086    /// Said to the person before they go on: for a speaker's Klippel export,
3087    /// which of its curves.
3088    pub note: String,
3089    /// A speaker's, corrected to Flat; otherwise a headphone's.
3090    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
3112/// Whether a measurement for `ear` may be corrected to `target`: a speaker
3113/// to the speaker target, a headphone to a headphone's. One added by hand
3114/// is taken at its word.
3115fn 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
3141/// What a measurement corrected to a target would do, before anything is
3142/// saved: the curves the Headphone view draws, and the EQ's own.
3143pub 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
3180/// A baked EQ taken apart, given the headphones' measurement and the target
3181/// that counts as neutral: the correction, target − measurement, and what
3182/// the EQ does beyond it, its taste, as a curve. The reverse of what
3183/// squig.link does to make one.
3184struct 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    // What is split is one EQ, the same on every channel: a stack or group
3207    // would lose its layers, and a mix, a delay or one side's bands would be
3208    // folded into a curve for both.
3209    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    // The taste is what the EQ does beyond the correction it was baked from.
3232    // That is target minus measurement whole when the EQ is a graphic
3233    // export, and the fitted bands, whose treble is held back, when it is a
3234    // squig.link preset. Taken from the wrong one, the taste carries this
3235    // measurement's treble peaks and notches, inverted, to every headphone it
3236    // plays on. A taste is broad, so the right one is the one leaving the
3237    // least in the treble.
3238    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
3269/// What splitting the baked EQ `name` would give, before anything is saved:
3270/// the correction and the tuning, their sum as `total`, and the EQ itself as
3271/// `original`, which the sum follows but for the treble a correction holds
3272/// back.
3273pub 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
3299/// Split the baked EQ `name` into a correction built from the headphones'
3300/// measurement, to `target`, and a tuning holding the rest, made against
3301/// `target` so it carries to other headphones. Every output that played
3302/// `name` plays the two instead; `name` itself is kept. The new profiles'
3303/// names, correction then tuning.
3304pub 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    // One write: both profiles, and every output moved to them, or nothing.
3323    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
3351/// Save a correction built from a measurement: the headphone `name`,
3352/// measured as `text`, corrected to `target`. A correction, kept everywhere
3353/// like any headphone's.
3354pub 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
3358/// [`save_measured`], saying where the measurement came from, as an import
3359/// says its files: one found on squig.link credits the site it was
3360/// measured for.
3361pub 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
3375/// The profile `save_measured` keeps, its measurement written to its folder
3376/// and the profile itself not yet.
3377fn 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    // Kept at its own points and in full, and fitted as kept: a new target
3407    // fits again from the same curve, here or on another device.
3408    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
3431/// The bands `correction` plays fitted to `target` instead of its own
3432/// target, from the measurement it was built from. None for a correction
3433/// not built from a measurement, or whose measurement is missing.
3434pub(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
3441/// The bands squig.link's auto-EQ fits to bring `measured` to `target`, and
3442/// its preamp to the hundredth of a decibel. A fit takes a good part of a
3443/// second, so recent ones are kept, by measurement and target, least
3444/// recently used first out. Those the player asked for while loading its
3445/// chain are kept apart, so pages fitting previews never push out what is
3446/// playing and the next load of it does not stall on a refit.
3447pub(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
3488/// Fits most recently used last, at most `cap` of them.
3489struct 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    /// What the player's chain was fitted with: a device or two, each with
3518    /// its target and maybe a tuning's.
3519    playing: Lru,
3520    /// Everything else: previews, imports, pages.
3521    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
3543/// Runs `f` as the player loading its chain: the fits it makes or uses are
3544/// kept apart from the ones previews make.
3545pub 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
3556/// Make `name` a stack of `layers`, in order, creating it if there is none.
3557/// Refused where it could not play: a layer missing, a layer of itself, one
3558/// with impulse responses, one kept on this device under a stack kept
3559/// everywhere.
3560pub 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    // Every layer, on or off: one switched off now is one switched on later.
3578    // A group's members are alternatives, one playing whichever is picked.
3579    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    // Every correction it had, on or off: a stack that already corrects
3587    // twice can still be edited, but gains no other.
3588    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        // The one it had first, then the one being added.
3605        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        // A group plays one: the first switched on, or the first of all.
3623        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
3633/// Delete `name`, and the responses kōan keeps for it. The presets and groups that hold it go on without it; an
3634/// EQ that plays it refuses, since what it plays is that EQ's to say.
3635pub 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        // For sync to delete it on the account's other devices too.
3658        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            // A group whose member playing went plays its first.
3672            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    // Another profile's name may map to the same folder: its files stay.
3684    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
3694/// Whether `name` is kept everywhere by choice: a stack kept so only because
3695/// its layers are follows them instead.
3696fn 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
3716/// A directory name for a profile name.
3717fn 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    /// A stack plays its layers that are on, in order, then its own filters;
3829    /// it follows a layer's rename, and keeps a layer it plays from being
3830    /// deleted.
3831    #[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        // A cycle through another, even with the way back switched off.
3932        assert!(
3933            set_layers("EQ", vec![layer("Desk", false)]).is_err(),
3934            "cycle"
3935        );
3936    }
3937
3938    /// Shared layers resolve, each where it is played; a ladder of them that
3939    /// would copy its filters exponentially is refused when set, and as a
3940    /// stack edited by hand it reports the problem and does not load, rather
3941    /// than growing the chain until the process aborts.
3942    #[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        // A diamond: B and C both play D.
3958        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        // A ladder, as a hand edit could leave it: each rung plays the two
3979        // below, 2^30 copies of D at the top.
3980        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    /// The response is what the DSP plays: a 3 dB peak shows as 3 dB at its
4010    /// frequency and nothing far from it, a stack is its layers summed, and a
4011    /// band's own curve is drawn alone.
4012    #[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    /// A new EQ is eight bands, a shelf at each end and six peaks, flat or
4079    /// at its starter's gains, and a tuning; a name taken is refused.
4080    #[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    /// Stereo gives each channel the one list it had; the right channel is
4116    /// drawn of its own; mono keeps the channel asked for.
4117    #[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        // The right channel's bass shelf up.
4143        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    /// An output playing filters alone has no correction; a preset saved
4176    /// from it says so and sets it again; choosing a correction ends it.
4177    #[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        // A preset for filters alone that holds a correction anyway, as a
4214        // hand edit or another device can leave one, sets none; nor does
4215        // one the output came to name some other way play.
4216        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        // An EQ that does not correct, chosen for the output, neither ends
4251        // filters alone nor is taken off by it.
4252        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    /// Only the left or right channel can be kept, and only an EQ of bands
4265    /// alone has channels to set.
4266    #[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    /// A band edited by hand is held to its ranges, keeps its channels, and
4286    /// changes what plays; one added starts flat; one taken out is gone.
4287    #[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    /// A preview draws an edit without saving it; saving as a copy keeps the
4336    /// edit apart, puts the original back and swaps the copy into the
4337    /// device's tuning.
4338    #[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(&copy).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        // A correction is neither previewed nor copied this way.
4393        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        // Nor is one fitted to a measurement, even said to be a tuning.
4407        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    /// A graphic curve's points are set whole, clamped and in order of
4428    /// frequency; a correction's are not.
4429    #[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    /// A group plays one member, chosen like a radio button; a stack plays
4477    /// each layer switched on. A group can itself be a stack's layer.
4478    #[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        // Switching two on in a group leaves the first of them on.
4528        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        // A group as a stack's layer: its chosen member, then the tuning.
4553        set_layers("Desk", vec![layer("Presets", true), layer("Bass", true)]).unwrap();
4554        assert_eq!(freqs("Desk"), [8000.0, 60.0]);
4555
4556        // A stack again: each layer switched on plays.
4557        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        // None on, as a hand edit can leave it: the first plays.
4576        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    /// A group of room corrections plays the chosen one's responses, since
4587    /// only one plays; a stack cannot layer such a group, as it cannot a
4588    /// response.
4589    #[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    /// A name already taken, or one whose files would go in a folder that is,
4640    /// is numbered: nothing is written over.
4641    #[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    /// A headphone correction travels and a room correction stays; a stack
4662    /// follows its layers unless set; a stack kept everywhere cannot have a
4663    /// layer kept here, either way round.
4664    #[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    /// A measurement as text: flat, with `bump` dB at 3 kHz.
4759    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    /// A file the importer cannot read says what it expected and what it
4769    /// found, so pressing Next never does nothing.
4770    #[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    /// An Audio Science Review export is a speaker's: on-axis from one
4785    /// plane, the listening window from both, said which, and corrected to
4786    /// Flat only.
4787    #[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    /// A speaker's listening window corrected to Flat: the fitted bands
4818    /// bring it toward flat across the band they act in.
4819    #[test]
4820    fn a_speakers_listening_window_is_fitted_toward_flat() {
4821        use super::super::klippel::tests::export;
4822        // A bass hump, a presence dip and a lift, the same at every angle.
4823        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        // Spread about the mean, 30 Hz to 6 kHz: the preamp only shifts it.
4830        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    /// A headphone measured and corrected to a target: a correction, kept
4856    /// everywhere, playing target minus measurement, said in one line.
4857    #[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        // What plays is squig.link's bands, fitted to the measurement and
4893        // the target, and nothing after them: no graphic curve, no taper.
4894        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        // At 3 kHz against 1 kHz it does what target minus measurement does,
4905        // within a decibel and a half.
4906        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        // Another target: the same measurement, corrected to it.
4917        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    /// A correction saved from a measurement before the bands were kept
4935    /// holds the measurement alone: it plays the bands fitted to it now,
4936    /// and its config is not touched.
4937    #[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        // A new target fits it, and keeps it as a fitted correction.
4979        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    /// A tuning's target difference on a fitted correction is tapered above
4992    /// 6 kHz, as on any correction built from a measurement: the bands stop
4993    /// there.
4994    #[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    /// A correction fitted to a measurement plays its stored bands, exactly,
5015    /// through the device's chain, and they are squig.link's Harman preset.
5016    #[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    /// A tuning on a correction takes its headroom from the chain's summed
5067    /// response, not the correction's own preamp: boosts that cancel leave
5068    /// the chain at unity.
5069    #[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    /// A gain the file states as a filter and one stated as the preamp put
5112    /// the curve, and the level it sits at, in the same place: the graph
5113    /// does not move between them.
5114    #[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    /// A correction fitted to one target under a tuning made against another
5160    /// is fitted to the tuning's target instead: the chain plays what the
5161    /// correction fitted to that target plays with the same tuning, which is
5162    /// squig.link's result, rather than a target difference on top.
5163    #[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        // Saying Lush was made against ISO is matched: no refit.
5221        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        // Against Harman, the preview adds the refit's move to the tuning.
5234        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        // A third target compares with the refit's: the difference runs from
5244        // Harman to it.
5245        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    /// A correction saved before kōan fitted bands, holding the measurement
5273    /// alone, is fitted again for its tuning as a fitted one is, and its
5274    /// previews move from the fit it plays.
5275    #[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    /// The refit follows the tuning that asks for it: where the chain cannot
5332    /// hold that tuning and leaves it out, the correction plays its own fit.
5333    #[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    /// One correction to a chain: a second layer is refused, naming the one
5384    /// already there, and one made so by its role is warned of; tuning on
5385    /// top is fine.
5386    #[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        // Said to be a correction, it is one: the stack says so.
5429        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    /// What a ready-made EQ was made for is a target that ships, said of a
5444    /// profile that is there and not built from a measurement; a target
5445    /// chosen for another kind of headphone goes with the old one.
5446    #[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    /// Where the target difference is what tips the chain past the filters
5495    /// it may hold, it is the step that goes: the tuning plays without it,
5496    /// and says so.
5497    #[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    /// A baked EQ, a correction and a bass band in one, splits back into the
5572    /// two: the correction from the measurement, the band as the tuning,
5573    /// made against the target, and the output that played it plays them.
5574    #[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        // As a squig.link export bakes it: target minus measurement whole.
5594        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        // The headphones' treble is the correction's, not the taste's.
5615        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        // On its own target, no difference to play: the correction's bands
5631        // and the tuning's curve.
5632        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        // One EQ the same on every channel splits; nothing else does.
5654        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    /// A squig.link preset is bands fitted to the target and the taste, its
5686    /// treble held back. Split, its tuning is the taste alone: none of the
5687    /// measurement's treble, inverted, which the fit never corrected.
5688    #[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        // Correction and tuning together play what the preset played.
5744        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    /// A tuning is EQs in order, each on or off, each with its own target
5755    /// difference; a preset saves a correction and its tuning and sets an
5756    /// output from them, and says when the output was changed since.
5757    #[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        // Warm's difference ahead of it, Bright on its own target, Spare off;
5831        // then the correction's own band.
5832        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        // The correction's curves first, then each EQ's while they fit.
5838        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        // A preset saves the correction and the tuning, on and off.
5867        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        // Reverting is setting it from the preset again: the tuning, on and
5888        // off, as saved.
5889        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        // Flat: nothing, and untouched.
5898        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    /// A correction plays as made: its bands are not edited. An imported EQ
5906    /// keeps what it was imported as, says when it was changed, and goes
5907    /// back; a copy is its own.
5908    #[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(&copy).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(&copy).is_err(), "made here");
5953        assert!(duplicate("Lush", Some("Lush copy")).is_err(), "taken");
5954    }
5955
5956    /// Configs from before: a stack of a correction and EQs becomes a
5957    /// preset, its outputs set from it with the tuning they had on top and
5958    /// without a correction switched off; an output a preset was assigned to
5959    /// is set from it; an output whose correction was a group plays the
5960    /// member that played.
5961    #[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    /// A whole chain set in one go, the same twice changing nothing; a name
6062    /// that is not a correction or EQ refused with the choices, before
6063    /// anything is set; and the chain said as the EQ page says it.
6064    #[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        // A correction that includes a tuning takes none: nothing is set.
6128        set_chain(dac, Some(Some("Lush")), Some(&eqs)).unwrap_err();
6129        assert_eq!(chain_view(dac).correction.as_deref(), Some("Wharfedale"));
6130        // Changing only the correction keeps the tuning, which is said
6131        // skipped rather than hidden.
6132        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    /// Deleting an EQ takes it out of the presets and groups that hold it;
6149    /// an EQ that plays it refuses, naming itself.
6150    #[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    /// Processing switched off is every output flat, what each played kept
6220    /// as a preset named for it, unless a preset already holds it as it was.
6221    #[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            // Chosen as corrections before roles were asked: a room's
6242            // impulse response, and bands with no role.
6243            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            // Taken: the preset is named apart from it.
6256            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    /// A tuning that does not say what it was made against is offered the
6304    /// target its curve fits: the same preset split against Harman and
6305    /// against neutral, as a person brought them in.
6306    #[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        // The same on a correction to neutral: the guess is of the tuning.
6348        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        // Nothing to judge by: a flat tuning, or one small band, is offered
6354        // nothing, rather than Harman for the shelf it lacks.
6355        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        // Nor a group, whose Made against would be every member's.
6385        make_group("Both", &["Lush".into(), "Lush N".into()]).unwrap();
6386        assert_eq!(suggested("Both"), None);
6387
6388        // At the join, while it is unknown.
6389        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        // Said wrongly, Harman's bass shelf plays twice: the preview shows it.
6398        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        // Said rightly, the conversion plays, and the join carries its curve:
6415        // Harman's shelf taken out ahead of a tuning that puts it back.
6416        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    /// An output plays its tuning on top of its correction, with the
6430    /// difference between the correction's target and the one the tuning was
6431    /// made against: dynamic baking. Never on a correction with a tuning
6432    /// baked in, and never across kinds of headphone.
6433    #[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        // Made against neutral, played on a correction to Harman: Harman's
6531        // preference comes out first.
6532        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        // Made against the correction's own target, or nothing said: as it is.
6546        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        // Switched off, it meets nothing.
6572        set_tunings(dac, &[("Warm".into(), false)]).unwrap();
6573        assert_eq!(join().join, None);
6574        set_tunings(dac, &[("Warm".into(), true)]).unwrap();
6575        // An in-ear target says nothing of over-ears.
6576        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        // A correction is not a tuning, and a baked one takes none.
6582        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        // A group corrects with its member playing, the tuning on top, and is
6607        // called by its own name.
6608        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        // A group of tunings is made against its playing member's target.
6618        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        // Impulse responses correct a room: never a tuning.
6627        let refused = set_tuning(dac, Some("Room")).unwrap_err();
6628        assert!(refused.contains("impulse responses"), "{refused}");
6629
6630        // The correction's curves come first in the chain's budget: the step
6631        // is left out rather than push one out.
6632        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        // A chain past the filters one may hold loses the tuning, never the
6657        // correction, and says so. Each is within bounds, a channel's bands
6658        // spread over four; the layers together are not.
6659        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        // Nor without a correction: nothing plays, and it says why.
6711        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        // No correction: the tuning alone, called by its name.
6739        assign(None, dac).unwrap();
6740        assert_eq!(plays(), ["60"]);
6741        assert_eq!(shown(), "Warm");
6742
6743        // It follows the profile's name, and goes with it.
6744        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    /// A correction with a tuning baked in is the chain's correction. A stack
6752    /// made before a second correction was refused still plays, says so,
6753    /// and can be edited, but gains no other correction.
6754    #[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            // As 0.59 allowed: two corrections in one stack.
6793            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        // AutoEQ's measurement, kept beside Cantor as an install keeps it.
6801        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        // It still plays, and can be edited towards one correction.
6836        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        // A baked preset is a stack's correction: another is refused.
6859        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    /// An AutoEQ correction moved to another target is rebuilt from the
6887    /// measurement AutoEQ kept, rather than played as its bands and a step.
6888    #[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}