Skip to main content

koan_core/audio/dsp/
mod.rs

1//! Equalisation and convolution, between the decoder and the ring buffer.
2//!
3//! The chain runs on the decode thread, so the render callback stays what it
4//! was: atomics and a ring buffer. With no profile for the output device there
5//! is no chain at all — samples reach the ring untouched, and bit-perfect stays
6//! something that can be checked.
7//!
8//! Order: preamp, resampling (only to reach an impulse response's rate), the
9//! profile's filters at the output rate in the order listed (`steps`), then
10//! convolution. Running the filters after resampling means their coefficients
11//! belong to the session rather than the track, so a gapless change of source
12//! rate keeps the filters' state.
13//!
14//! Stages that delay the audio — the resampler, and a linear-phase response's
15//! pre-ringing — have that delay trimmed from the front of the session and
16//! flushed at its end. Output frame `n` is then input frame `n` at the output
17//! rate, which is what the timeline counts and the playhead reads.
18
19pub mod apo;
20pub mod autoeq;
21pub mod autoeq_squig;
22pub mod camilla;
23pub mod convolver;
24pub mod import;
25pub mod impulse;
26pub mod klippel;
27#[cfg(test)]
28mod null;
29pub mod profiles;
30pub mod raw;
31pub mod squig;
32mod steps;
33pub mod targets;
34
35use std::collections::BTreeMap;
36use std::path::{Path, PathBuf};
37
38use realfft::RealFftPlanner;
39use rubato::audioadapter_buffers::direct::InterleavedSlice;
40use rubato::{Fft, FixedSync, Indexing, Resampler};
41use thiserror::Error;
42
43use impulse::{Convolve, read_audio};
44pub use impulse::{Impulse, Route};
45
46use steps::{Planned, Steps, gain_matrix, plan};
47
48use crate::config::{DspFilter, DspProfile};
49
50#[derive(Debug, Error)]
51pub enum DspError {
52    #[error("{}: {reason}", path.display())]
53    Impulse { path: PathBuf, reason: String },
54    /// A target the profile was made for or moved to that cannot be read.
55    #[error("no target called {0}")]
56    Target(String),
57    /// A layer that cannot be played: missing, a layer of itself, or one
58    /// with impulse responses.
59    #[error("{0}")]
60    Layer(String),
61    /// A correction built from a measurement whose file is not there.
62    #[error("{0}'s measurement is missing")]
63    Measurement(String),
64}
65
66/// How deep layers may nest: a stack of stacks of stacks, and so on.
67const MAX_LAYER_DEPTH: usize = 8;
68/// The most filters a stack may expand to. A layer played twice, or a
69/// diamond of stacks sharing layers, copies its filters each time; past this
70/// the stack is refused rather than grown without bound.
71const MAX_CHAIN_FILTERS: usize = 256;
72/// The most layers a stack may visit while resolving, however few filters
73/// they hold: a diamond of empty stacks costs time if not memory.
74const MAX_LAYER_VISITS: usize = 1024;
75
76/// The level `filters` leave channel 0 at, in dB, at each of `freqs`, as
77/// the DSP runs them at `rate`: the same plan, and the same gain the derived
78/// preamp is worked out from. A graphic curve counts as its design, which
79/// its minimum-phase FIR follows; delays change no level.
80pub fn response(filters: &[DspFilter], freqs: &[f64], rate: u32) -> Vec<f64> {
81    response_on(filters, freqs, rate, 0)
82}
83
84/// [`response`] on `channel` of two.
85pub fn response_on(filters: &[DspFilter], freqs: &[f64], rate: u32, channel: usize) -> Vec<f64> {
86    let plan = steps::plan(filters, rate, 2);
87    freqs
88        .iter()
89        .map(|&hz| {
90            let w = std::f64::consts::TAU * hz / rate as f64;
91            let gain: f64 = steps::gain_matrix(&plan, 2, w, rate)[channel].iter().sum();
92            20.0 * gain.max(1e-6).log10()
93        })
94        .collect()
95}
96
97/// What an output plays: the profile to load, the profiles to resolve it
98/// among, and what to call it.
99pub struct OutputChain {
100    pub profile: DspProfile,
101    pub all: Vec<DspProfile>,
102    /// The correction's name, or the tuning's where there is none: never the
103    /// name the tuning is resolved under.
104    pub name: String,
105    /// What of the output's choices does not play, and why, to show where
106    /// the EQ is: a tuning or a target difference left out.
107    pub left_out: Option<String>,
108    /// The tuning's EQs left out entirely, by name, for a front end to say
109    /// what plays without reading `left_out`.
110    pub left_out_eqs: Vec<String>,
111    /// `left_out` a sentence at a time, by the EQ each is about.
112    pub eq_notes: Vec<(String, String)>,
113    /// The output's tuning plays.
114    pub tuning_plays: bool,
115    /// The target, by id, a correction fitted to a measurement is fitted to
116    /// again for the tuning, and the tuning's EQ made against it.
117    pub refit: Option<(String, String)>,
118}
119
120/// What `device` plays, built from its two choices: the correction, the
121/// profile that lists it, and the tuning chosen for it on top. Where the
122/// tuning names the target it was made against and the correction aims at
123/// another, the difference between the two plays with the tuning, so it
124/// sounds as made whatever corrects the headphones: dynamic baking. A
125/// tuning is skipped on a correction with one baked in, and a profile with
126/// impulse responses is never one.
127///
128/// The correction keeps its own name, folder, filters and responses; a
129/// group corrects with the member that plays. The tuning joins it as a
130/// layer, so it plays first: for bands and curves on every channel alike,
131/// which commute, that is heard as correction then tuning, and ahead of a
132/// response or a mix it is where taste belongs. The correction's graphic
133/// curves come first in the chain's budget of them: the step is left out
134/// rather than push one out, and the tuning too where it alone would, or
135/// where the chain with it would be more than one may hold.
136///
137/// A correction fitted to a measurement is fitted again for a tuning made
138/// against another target only where the tuning that asks still plays and
139/// nothing more is left out than without the refit.
140pub fn output_chain(dsp: &crate::config::DspConfig, device: &str) -> Option<OutputChain> {
141    let refitted = output_chain_with(dsp, device, true)?;
142    let Some((_, by)) = &refitted.refit else {
143        return Some(refitted);
144    };
145    let plain = output_chain_with(dsp, device, false)?;
146    let worse = refitted.left_out_eqs.contains(by)
147        || refitted.left_out_eqs.len() > plain.left_out_eqs.len()
148        || refitted.eq_notes.len() > plain.eq_notes.len();
149    Some(if worse { plain } else { refitted })
150}
151
152fn output_chain_with(
153    dsp: &crate::config::DspConfig,
154    device: &str,
155    may_refit: bool,
156) -> Option<OutputChain> {
157    use crate::config::{DspLayer, DspRole, dsp_bounds};
158    let all = &dsp.profiles;
159    let chosen = dsp.profile_for(device);
160    let correction = chosen.map(|c| member_playing(c, all));
161    let baked = chosen.is_some_and(|c| profiles::shown_role(c, all) == DspRole::Baked);
162    let mut notes: Vec<(String, String)> = Vec::new();
163    let mut dropped: Vec<String> = Vec::new();
164    // The tunings switched on, in order, each one that cannot play said.
165    let mut tunings: Vec<&DspProfile> = Vec::new();
166    if dsp.enabled {
167        for entry in dsp.tunings.iter().filter(|t| t.device == device && t.on) {
168            let why = match all.iter().find(|p| p.name == entry.tuning) {
169                None => Some("there is no EQ by that name".to_owned()),
170                Some(t) if !responses(t, all).is_empty() => {
171                    Some("an EQ with impulse responses is never a tuning".to_owned())
172                }
173                Some(t) if profiles::shown_role(t, all) != DspRole::Tuning => {
174                    Some("it is no longer a tuning".to_owned())
175                }
176                Some(_) => match (chosen, correction) {
177                    (Some(c), _) if baked => Some(format!("{} already includes a tuning", c.name)),
178                    // A group without a member to play has nothing to put a
179                    // layer on.
180                    (Some(c), Some(m)) if m.group => {
181                        Some(format!("{} has no member playing", c.name))
182                    }
183                    _ => None,
184                },
185            };
186            match why {
187                Some(why) => {
188                    notes.push((
189                        entry.tuning.clone(),
190                        format!("{} is left out: {why}.", entry.tuning),
191                    ));
192                    dropped.push(entry.tuning.clone());
193                }
194                None => tunings.extend(all.iter().find(|p| p.name == entry.tuning)),
195            }
196        }
197    }
198    let name = match chosen.or(tunings.first().copied()) {
199        Some(p) => p.name.clone(),
200        // Nothing plays, but a tuning chosen and left out is still said.
201        None => dsp
202            .tunings
203            .iter()
204            .find(|t| t.device == device && t.on && !notes.is_empty())?
205            .tuning
206            .clone(),
207    };
208    let said = |notes: &[(String, String)]| {
209        (!notes.is_empty()).then(|| {
210            notes
211                .iter()
212                .map(|(_, n)| n.as_str())
213                .collect::<Vec<_>>()
214                .join(" ")
215        })
216    };
217    let alone = |notes: &[(String, String)], dropped: &[String]| -> Option<OutputChain> {
218        let left_out = said(notes);
219        Some(match correction {
220            Some(c) => OutputChain {
221                profile: c.clone(),
222                all: all.clone(),
223                name: name.clone(),
224                left_out,
225                left_out_eqs: dropped.to_vec(),
226                eq_notes: notes.to_vec(),
227                tuning_plays: false,
228                refit: None,
229            },
230            // Without a correction to fall back on, the output plays
231            // untouched, and still says why.
232            None if left_out.is_some() => OutputChain {
233                profile: DspProfile {
234                    name: name.clone(),
235                    ..Default::default()
236                },
237                all: all.clone(),
238                name: name.clone(),
239                left_out,
240                left_out_eqs: dropped.to_vec(),
241                eq_notes: notes.to_vec(),
242                tuning_plays: false,
243                refit: None,
244            },
245            None => return None,
246        })
247    };
248    if tunings.is_empty() {
249        return alone(&notes, &dropped);
250    }
251    // The correction's graphic curves come first in the chain's budget, then
252    // each tuning's in order while they fit.
253    let curves = |p: &DspProfile| {
254        chain(p, all, &mut Vec::new()).map_or(0, |f| {
255            f.iter()
256                .filter(|f| matches!(f, DspFilter::Graphic(_)))
257                .count()
258        })
259    };
260    let mut held = correction.map_or(0, curves);
261    let mut kept = Vec::new();
262    for t in tunings {
263        let c = curves(t);
264        if held + c > dsp_bounds::CHAIN_GRAPHICS {
265            notes.push((
266                t.name.clone(),
267                format!(
268                    "{} is left out: with what plays before it, it would play more graphic curves than a chain holds.",
269                    t.name
270                ),
271            ));
272            dropped.push(t.name.clone());
273            continue;
274        }
275        held += c;
276        kept.push(t);
277    }
278    // Each tuning's target difference, where it was made against another
279    // target than the correction's, while curves are left: a layer of its
280    // own ahead of the tuning, and of a group's member.
281    let aim = correction.and_then(|c| profiles::aims_at(c, all));
282    // A correction fitted to a measurement, under a tuning made against
283    // another target, is fitted to that target instead: one fit to the
284    // target the tuning expects is squig.link's result, where a fit to the
285    // correction's own target plus the difference is an approximation of
286    // it. The first tuning that asks decides; any after it compare with
287    // that target. Nothing is written back.
288    let refit = correction
289        .zip(aim.as_ref())
290        .filter(|_| may_refit)
291        .and_then(|(c, aim)| {
292            kept.iter()
293                .filter_map(|t| Some((made_against(t, all, 0)?, t.name.clone())))
294                .find(|(made, _)| made != aim && targets::same_ear(aim, made))
295                .and_then(|(made, by)| Some((profiles::fitted_to(c, &made)?, made, by)))
296        });
297    let (refit_filters, refit) = match refit {
298        Some((filters, made, by)) => (Some(filters), Some((made, by))),
299        None => (None, None),
300    };
301    let aim = refit.as_ref().map(|(made, _)| made.clone()).or(aim);
302    let mut layers: Vec<(Option<DspProfile>, DspProfile, String)> = Vec::new();
303    for (i, t) in kept.into_iter().enumerate() {
304        let mut on_top = t.clone();
305        // Control characters are taken out of every name, so none is this.
306        on_top.name = format!("\u{1}{i}\u{1}{}", t.name);
307        on_top.devices.clear();
308        let wanted = aim
309            .clone()
310            .zip(made_against(t, all, 0))
311            .filter(|(aim, made)| aim != made && targets::same_ear(aim, made));
312        let step = match wanted {
313            Some(_) if held >= dsp_bounds::CHAIN_GRAPHICS => {
314                notes.push((
315                    t.name.clone(),
316                    format!(
317                        "{} plays without the target difference: the chain's graphic curves are full, so it may not sound as made.",
318                        t.name
319                    ),
320                ));
321                None
322            }
323            Some((aim, made)) => {
324                let step = targets::choice_curve(&aim)
325                    .zip(targets::choice_curve(&made))
326                    .zip(correction)
327                    .map(|((from, to), c)| DspProfile {
328                        name: format!("\u{1}{i}\u{1}Target difference"),
329                        filters: vec![DspFilter::Graphic(profiles::target_step(
330                            c, all, &from, &to,
331                        ))],
332                        ..Default::default()
333                    });
334                held += usize::from(step.is_some());
335                step
336            }
337            None => None,
338        };
339        layers.push((step, on_top, t.name.clone()));
340    }
341    let build = |layers: &[(Option<DspProfile>, DspProfile, String)]| {
342        let mut among = all.clone();
343        let mut names = Vec::new();
344        for (step, on_top, _) in layers {
345            for p in step.iter().chain([on_top]) {
346                names.push(p.name.clone());
347                among.push(p.clone());
348            }
349        }
350        let profile = match correction {
351            Some(c) => {
352                let mut c = c.clone();
353                // The correction's preamp pays for its own boosts. With a
354                // tuning on top, the headroom comes from what the chain
355                // plays in sum, so a layered chain is as loud as one EQ
356                // with the same response.
357                if !names.is_empty() {
358                    c.preamp_db = None;
359                }
360                if let (Some(filters), Some((to, _))) = (&refit_filters, &refit) {
361                    c.filters = filters.clone();
362                    c.fitted = c.measured().map(|m| crate::config::DspMeasurement {
363                        target: to.clone(),
364                        ..m.clone()
365                    });
366                    c.measurement = None;
367                }
368                c.layers.extend(
369                    names
370                        .into_iter()
371                        .map(|profile| DspLayer { profile, on: true }),
372                );
373                c
374            }
375            // No correction: the tunings are the chain.
376            None => DspProfile {
377                name: format!("\u{1}{name}"),
378                layers: names
379                    .into_iter()
380                    .map(|profile| DspLayer { profile, on: true })
381                    .collect(),
382                ..Default::default()
383            },
384        };
385        chain(&profile, &among, &mut Vec::new()).map(|_| (profile, among))
386    };
387    // A chain past what one may hold loses target differences first, from
388    // the last, then tunings from the last, never the correction.
389    let (profile, among) = loop {
390        match build(&layers) {
391            Ok(built) => break built,
392            Err(e) => {
393                if let Some((step, _, tuning)) = layers.iter_mut().rev().find(|l| l.0.is_some()) {
394                    *step = None;
395                    notes.push((
396                        tuning.clone(),
397                        format!(
398                            "{tuning} plays without the target difference: with it, the chain is more than one can hold, so it may not sound as made."
399                        ),
400                    ));
401                } else if let Some((_, _, tuning)) = layers.pop() {
402                    notes.push((
403                        tuning.clone(),
404                        format!(
405                            "{tuning} is left out: with what plays before it, the chain is more than one can hold ({e})."
406                        ),
407                    ));
408                    dropped.push(tuning);
409                    if layers.is_empty() {
410                        return alone(&notes, &dropped);
411                    }
412                } else {
413                    return alone(&notes, &dropped);
414                }
415            }
416        }
417    };
418    let left_out = said(&notes);
419    Some(OutputChain {
420        profile,
421        all: among,
422        name,
423        left_out,
424        left_out_eqs: dropped,
425        eq_notes: notes,
426        tuning_plays: true,
427        refit,
428    })
429}
430
431/// The profile that plays for `profile`: itself, or for a group, the member
432/// playing, and so on down.
433pub(crate) fn member_playing<'a>(profile: &'a DspProfile, all: &'a [DspProfile]) -> &'a DspProfile {
434    let mut p = profile;
435    for _ in 0..MAX_LAYER_DEPTH {
436        match p.group.then(|| playing(p, all)).flatten() {
437            Some(m) => p = m,
438            None => break,
439        }
440    }
441    p
442}
443
444/// The target the tuning `profile` was made against: for a group, its
445/// playing member's, and for a stack, its own or its first layer's on.
446pub(crate) fn made_against(
447    profile: &DspProfile,
448    all: &[DspProfile],
449    depth: usize,
450) -> Option<String> {
451    if depth > MAX_LAYER_DEPTH {
452        return None;
453    }
454    if profile.group {
455        return playing(profile, all)
456            .and_then(|m| made_against(m, all, depth + 1))
457            .or_else(|| profile.tuned_for.clone());
458    }
459    profile.tuned_for.clone().or_else(|| {
460        profile
461            .layers
462            .iter()
463            .filter(|l| l.on)
464            .filter_map(|l| all.iter().find(|p| p.name == l.profile))
465            .find_map(|p| made_against(p, all, depth + 1))
466    })
467}
468
469/// The filters `profile` plays: each layer that is on, in order, as that
470/// layer plays it, then its own, then the step moving it to another target.
471/// `stack` holds the profiles being resolved, which a cycle would come back
472/// to. A layer is EQ alone: impulse responses belong to the profile that
473/// plays them, and two stacks of responses would make one profile's rates
474/// another's. Refused past `MAX_LAYER_DEPTH`, `MAX_CHAIN_FILTERS` or
475/// `MAX_LAYER_VISITS`, so no stack, however edited, can grow the chain
476/// without bound.
477pub fn chain(
478    profile: &DspProfile,
479    all: &[DspProfile],
480    stack: &mut Vec<String>,
481) -> Result<Vec<DspFilter>, DspError> {
482    Ok(chain_noted(profile, all, stack)?.0)
483}
484
485/// [`chain`], and what keeping it within `config::dsp_bounds`' budgets
486/// changed: each layer is within bounds alone, and together they may not be.
487pub fn chain_noted(
488    profile: &DspProfile,
489    all: &[DspProfile],
490    stack: &mut Vec<String>,
491) -> Result<(Vec<DspFilter>, Vec<String>), DspError> {
492    let mut visits = 0;
493    let mut filters = resolve(profile, all, stack, &mut visits)?;
494    let notes = crate::config::budget(&mut filters);
495    Ok((filters, notes))
496}
497
498fn resolve(
499    profile: &DspProfile,
500    all: &[DspProfile],
501    stack: &mut Vec<String>,
502    visits: &mut usize,
503) -> Result<Vec<DspFilter>, DspError> {
504    if stack.contains(&profile.name) {
505        return Err(DspError::Layer(format!(
506            "{} plays itself, through {}",
507            profile.name,
508            stack.join(" → ")
509        )));
510    }
511    if stack.len() >= MAX_LAYER_DEPTH {
512        return Err(DspError::Layer(format!(
513            "{} plays EQs that play others more than {MAX_LAYER_DEPTH} deep",
514            stack[0]
515        )));
516    }
517    *visits += 1;
518    if *visits > MAX_LAYER_VISITS {
519        return Err(DspError::Layer(format!(
520            "{} reaches the EQs it plays more than {MAX_LAYER_VISITS} times over",
521            stack.first().unwrap_or(&profile.name)
522        )));
523    }
524    stack.push(profile.name.clone());
525    let mut out = Vec::new();
526    let too_many = |stack: &[String]| {
527        DspError::Layer(format!(
528            "{} comes to more than {MAX_CHAIN_FILTERS} filters with the EQs it plays",
529            stack[0]
530        ))
531    };
532    // A group plays one member: the first switched on, or with none on, the
533    // first of all. A stack plays each layer switched on.
534    let playing: Vec<&crate::config::DspLayer> = if profile.group {
535        profile
536            .layers
537            .iter()
538            .find(|l| l.on)
539            .or(profile.layers.first())
540            .into_iter()
541            .collect()
542    } else {
543        profile.layers.iter().filter(|l| l.on).collect()
544    };
545    for layer in playing {
546        let p = all
547            .iter()
548            .find(|p| p.name == layer.profile)
549            .ok_or_else(|| {
550                DspError::Layer(format!(
551                    "{} plays {}, which is not there",
552                    profile.name, layer.profile
553                ))
554            })?;
555        // A stack's layers play together, and only one response can: a
556        // group's member plays alone, its responses as the group's own.
557        if !profile.group && !responses(p, all).is_empty() {
558            return Err(DspError::Layer(format!(
559                "{} has impulse responses: only bands can be played by another EQ",
560                p.name
561            )));
562        }
563        out.extend(resolve(p, all, stack, visits)?);
564        if out.len() > MAX_CHAIN_FILTERS {
565            return Err(too_many(stack));
566        }
567    }
568    let dir = profiles::dir(&profile.name);
569    let curve = |id: &str| targets::choice_curve(id).ok_or(DspError::Target(id.into()));
570    // Another target than the one an AutoEQ correction was made for. With
571    // the measurement AutoEQ kept, the correction is rebuilt from it to the
572    // target moved; without, the targets' difference plays after AutoEQ's.
573    let moved = profile.target.as_ref().and_then(|t| {
574        Some((
575            t.chosen.as_ref().filter(|c| **c != t.made_for)?,
576            &t.made_for,
577        ))
578    });
579    let rebuilt = moved.and_then(|_| targets::autoeq_measurement(&dir));
580    if rebuilt.is_none() {
581        // As played: within `config::dsp_bounds`, whatever the config says.
582        out.extend(profile.sanitized().filters);
583    }
584    if let Some((chosen, made_for)) = moved {
585        let step = targets::difference(&curve(made_for)?, &curve(chosen)?);
586        out.push(DspFilter::Graphic(match rebuilt {
587            Some((raw, target)) => targets::correction(&raw, &targets::moved(&target, &step)),
588            None => step,
589        }));
590    }
591    // A correction fitted to a measurement plays its own filters, above:
592    // the bands squig.link's auto-EQ fits. One saved before kōan fitted
593    // bands has them fitted here, from its measurement as kept. Nothing is
594    // written back, so no edit is made to sync. The fit leans on the
595    // platform's maths library, so two platforms can differ by a band here;
596    // a new target fits and keeps the bands, and then they are shared.
597    if let Some(m) = profile
598        .measurement
599        .as_ref()
600        .filter(|_| profile.fitted.is_none())
601    {
602        let measured = targets::measurement(&dir)
603            .ok_or_else(|| DspError::Measurement(profile.name.clone()))?;
604        out.extend(profiles::squig_fit(&measured, &curve(&m.target)?).0);
605    }
606    if out.len() > MAX_CHAIN_FILTERS && stack.len() > 1 {
607        return Err(too_many(stack));
608    }
609    stack.pop();
610    Ok(out)
611}
612
613/// The member of the group `profile` that plays: the first switched on, or
614/// with none on, the first.
615pub fn playing<'a>(profile: &DspProfile, all: &'a [DspProfile]) -> Option<&'a DspProfile> {
616    let layer = profile
617        .layers
618        .iter()
619        .find(|l| l.on)
620        .or(profile.layers.first())?;
621    all.iter().find(|p| p.name == layer.profile)
622}
623
624/// The impulse responses `profile` plays: its own, and for a group, those
625/// of the member playing.
626pub fn responses(profile: &DspProfile, all: &[DspProfile]) -> Vec<PathBuf> {
627    fn walk(p: &DspProfile, all: &[DspProfile], depth: usize, out: &mut Vec<PathBuf>) {
628        out.extend(p.impulses.iter().cloned());
629        if p.group
630            && depth < MAX_LAYER_DEPTH
631            && let Some(m) = playing(p, all)
632        {
633            walk(m, all, depth + 1, out);
634        }
635    }
636    let mut out = Vec::new();
637    walk(profile, all, 0, &mut out);
638    out
639}
640
641/// What is being done to the audio, for the format badge.
642#[derive(Debug, Clone, PartialEq, Eq)]
643pub struct DspStatus {
644    pub profile: String,
645    /// Filters ahead of convolution are running: bands, delays, mixes or
646    /// a graphic curve.
647    pub eq: bool,
648    /// The rate the impulse response in use was designed at, which is the
649    /// rate the output runs at. A source at another rate was resampled to
650    /// reach it.
651    pub convolution_rate: Option<u32>,
652}
653
654/// A profile ready to run, its impulse responses read from disk.
655#[derive(Debug, Clone, PartialEq)]
656pub struct Setup {
657    pub name: String,
658    preamp_db: Option<f64>,
659    filters: Vec<DspFilter>,
660    /// By rate. More than one at a rate where they are for different channel
661    /// counts.
662    impulses: BTreeMap<u32, Vec<Impulse>>,
663}
664
665impl Setup {
666    /// `None` for a profile that would leave the audio as it is.
667    /// `all` is every profile, which a stack's layers are found among.
668    pub fn load(
669        profile: &DspProfile,
670        all: &[DspProfile],
671        base: &Path,
672    ) -> Result<Option<Self>, DspError> {
673        let mut impulses: BTreeMap<u32, Vec<Impulse>> = BTreeMap::new();
674        for path in &responses(profile, all) {
675            for mut impulse in load_impulses(path, base)? {
676                // Past this a response is past any room's decay, and costs
677                // the decode thread more than it can keep up with.
678                for route in &mut impulse.routes {
679                    route.ir.truncate(crate::config::dsp_bounds::IMPULSE_TAPS);
680                }
681                impulses.entry(impulse.rate).or_default().push(impulse);
682            }
683        }
684        let filters = chain(profile, all, &mut Vec::new())?;
685        if filters.is_empty() && impulses.is_empty() && profile.preamp_db.unwrap_or(0.0) == 0.0 {
686            return Ok(None);
687        }
688        Ok(Some(Self {
689            name: profile.name.clone(),
690            preamp_db: profile.sanitized().preamp_db,
691            filters,
692            impulses,
693        }))
694    }
695
696    /// The flat gain among the filters on `channel`: a gain the profile's
697    /// file states as a filter rather than as its preamp.
698    pub fn gain_db_on(&self, channel: u16) -> f64 {
699        self.filters
700            .iter()
701            .filter_map(|f| match f {
702                DspFilter::Band(b)
703                    if b.kind == crate::config::EqFilterKind::Gain
704                        && (b.channels.is_empty() || b.channels.contains(&channel)) =>
705                {
706                    Some(b.gain_db)
707                }
708                _ => None,
709            })
710            .sum()
711    }
712
713    /// The preamp at `rate` for `channels`: the profile's own, or the one
714    /// derived from the peak gain of its bands and response there.
715    pub fn preamp_db(&self, rate: u32, channels: usize) -> f64 {
716        let impulse = self
717            .impulses
718            .get(&rate)
719            .and_then(|at_rate| at_rate.iter().find(|i| i.fits(channels)));
720        let plan = plan(&self.filters, rate, channels);
721        let headroom = -20.0 * peak_gain(&plan, impulse, channels, rate).log10().max(0.0);
722        // A preamp set by hand that leaves the peak above full scale is
723        // lowered to the headroom the filters need: no profile overloads.
724        self.preamp_db.map_or(headroom, |set| set.min(headroom))
725    }
726
727    /// How far the preamp set by hand was lowered for headroom at `rate`, in
728    /// dB, if it was.
729    pub fn headroom_cut(&self, rate: u32, channels: usize) -> Option<f64> {
730        let set = self.preamp_db?;
731        let used = self.preamp_db(rate, channels);
732        (used < set - 0.05).then_some(set - used)
733    }
734
735    /// What the profile does at each of `freqs`, in dB, on the first of two
736    /// channels, for a source at `rate`: its filters, then its impulse
737    /// response at the rate it plays at. Routes add by magnitude, as for the
738    /// derived preamp; the preamp itself is not included.
739    pub fn response(&self, freqs: &[f64], rate: u32) -> Vec<f64> {
740        let rate = self.output_rate(rate);
741        let Some(impulse) = self
742            .impulses
743            .get(&rate)
744            .and_then(|at_rate| at_rate.iter().find(|i| i.fits(2)))
745        else {
746            return response(&self.filters, freqs, rate);
747        };
748        let plan = plan(&self.filters, rate, 2);
749        let routes = impulse.routes_for(2);
750        let size = impulse.taps().next_power_of_two().max(8192);
751        let fft = RealFftPlanner::<f64>::new().plan_fft_forward(size);
752        let magnitudes: Vec<Vec<f64>> = routes
753            .iter()
754            .map(|r| {
755                let mut input = fft.make_input_vec();
756                for (d, &s) in input.iter_mut().zip(&r.ir) {
757                    *d = s as f64;
758                }
759                let mut spectrum = fft.make_output_vec();
760                if fft.process(&mut input, &mut spectrum).is_err() {
761                    return vec![0.0; spectrum.len()];
762                }
763                spectrum.iter().map(|b| b.norm()).collect()
764            })
765            .collect();
766        freqs
767            .iter()
768            .map(|&hz| {
769                let w = std::f64::consts::TAU * hz / rate as f64;
770                let gains: Vec<f64> = gain_matrix(&plan, 2, w, rate)
771                    .iter()
772                    .map(|row| row.iter().sum())
773                    .collect();
774                // Between the two bins either side of `hz`.
775                let k = hz * size as f64 / rate as f64;
776                let gain: f64 = routes
777                    .iter()
778                    .zip(&magnitudes)
779                    .map(|(r, m)| {
780                        let i = (k.floor() as usize).min(m.len() - 1);
781                        let next = m[(i + 1).min(m.len() - 1)];
782                        let at = m[i] + (next - m[i]) * (k - i as f64).clamp(0.0, 1.0);
783                        let fed: f64 = r
784                            .inputs
785                            .iter()
786                            .map(|&(c, g)| g.abs() as f64 * gains.get(c).copied().unwrap_or(1.0))
787                            .sum();
788                        r.outputs
789                            .iter()
790                            .filter(|&&(o, _)| o == 0)
791                            .map(|&(_, g)| at * fed * g.abs() as f64)
792                            .sum::<f64>()
793                    })
794                    .sum();
795                20.0 * gain.max(1e-6).log10()
796            })
797            .collect()
798    }
799
800    #[cfg(test)]
801    pub(crate) fn with_preamp(mut self, db: f64) -> Self {
802        self.preamp_db = Some(db);
803        self
804    }
805
806    #[cfg(test)]
807    pub(crate) fn new(filters: Vec<DspFilter>, impulses: Vec<Impulse>) -> Self {
808        Self {
809            name: "test".into(),
810            preamp_db: None,
811            filters,
812            impulses: impulses.into_iter().fold(BTreeMap::new(), |mut m, i| {
813                m.entry(i.rate).or_insert_with(Vec::new).push(i);
814                m
815            }),
816        }
817    }
818
819    /// The rate a source at `source` plays at: its own, unless convolution
820    /// needs another. A response exported at the source's rate is used as it
821    /// is; otherwise the nearest one, and the audio is resampled to it.
822    /// Resampling the response instead would low-pass hi-res material at the
823    /// response's Nyquist.
824    pub fn output_rate(&self, source: u32) -> u32 {
825        if self.impulses.is_empty() || self.impulses.contains_key(&source) {
826            return source;
827        }
828        *self
829            .impulses
830            .keys()
831            .min_by_key(|&&r| (r.abs_diff(source), std::cmp::Reverse(r)))
832            .expect("not empty")
833    }
834
835    /// Rates there are responses for.
836    pub fn rates(&self) -> Vec<u32> {
837        self.impulses.keys().copied().collect()
838    }
839
840    pub fn status(&self, source: u32) -> DspStatus {
841        DspStatus {
842            profile: self.name.clone(),
843            eq: !self.filters.is_empty(),
844            convolution_rate: (!self.impulses.is_empty()).then(|| self.output_rate(source)),
845        }
846    }
847}
848
849/// The responses one entry of a profile's `impulses` names: a WAV (or any
850/// audio file) at its own rate, or a Convolver `.cfg`.
851pub fn load_impulses(path: &Path, base: &Path) -> Result<Vec<Impulse>, DspError> {
852    let path = if path.is_absolute() {
853        path.to_path_buf()
854    } else {
855        base.join(path)
856    };
857    let read = if convolver::is_cfg(&path) {
858        convolver::read(&path)
859    } else {
860        read_audio(&path).map(|(rate, channels)| vec![Impulse::from_channels(rate, channels)])
861    };
862    read.map_err(|reason| DspError::Impulse { path, reason })
863}
864
865/// The processing for one session, built for the first track's format.
866///
867/// Samples become 64-bit floats on the way in and 32-bit on the way out, and
868/// nothing is rounded between: resampling, filters and convolution all run in
869/// 64-bit, as CamillaDSP and Roon do. `null_test_against_direct_convolution`
870/// measures what is left.
871pub struct Chain {
872    channels: usize,
873    out_rate: u32,
874    gain: f64,
875    resample: Option<Resample>,
876    steps: Steps,
877    convolve: Option<Convolve>,
878    input: Vec<f64>,
879    work: Vec<f64>,
880    out: Vec<f32>,
881}
882
883impl Chain {
884    pub fn new(setup: &Setup, source_rate: u32, channels: u16) -> Self {
885        let channels = channels as usize;
886        let out_rate = setup.output_rate(source_rate);
887        let impulse = setup.impulses.get(&out_rate).and_then(|at_rate| {
888            let fitting = at_rate.iter().find(|i| i.fits(channels));
889            if fitting.is_none() {
890                log::warn!(
891                    "dsp: no impulse at {out_rate}Hz for {channels} channels; convolution skipped"
892                );
893            }
894            fitting
895        });
896
897        let preamp_db = setup.preamp_db(out_rate, channels);
898        log::info!(
899            "dsp: '{}' at {out_rate}Hz — {} filters, preamp {preamp_db:.2} dB{}",
900            setup.name,
901            setup.filters.len(),
902            impulse.map_or(String::new(), |i| format!(
903                ", {} routes of {} taps, {} frames delay",
904                i.routes.len(),
905                i.taps(),
906                i.delay()
907            ))
908        );
909
910        Self {
911            channels,
912            out_rate,
913            gain: 10f64.powf(preamp_db / 20.0),
914            resample: Resample::new(source_rate, out_rate, channels),
915            steps: Steps::new(plan(&setup.filters, out_rate, channels), out_rate, channels),
916            convolve: impulse.map(|i| Convolve::new(i, channels)),
917            input: Vec::new(),
918            work: Vec::new(),
919            out: Vec::new(),
920        }
921    }
922
923    pub fn output_rate(&self) -> u32 {
924        self.out_rate
925    }
926
927    /// Carry the chain into a track at `source_rate`, the output rate
928    /// unchanged. Returns the outgoing resampler's tail, which belongs to the
929    /// track before and is still to be written.
930    pub fn set_source_rate(&mut self, source_rate: u32) -> &[f32] {
931        self.work.clear();
932        if self.resample.as_ref().map_or(self.out_rate, |r| r.in_rate) != source_rate {
933            if let Some(mut r) = self.resample.take() {
934                r.flush(&mut self.work);
935            }
936            self.resample = Resample::new(source_rate, self.out_rate, self.channels);
937            self.post();
938        }
939        self.emit()
940    }
941
942    /// Process interleaved `input`. Returns what is ready to be written, and
943    /// how many samples of output time `input` amounts to — the figure the
944    /// timeline counts, which with a resampler is not what came out this call.
945    pub fn process(&mut self, input: &[f32]) -> (&[f32], u64) {
946        self.input.clear();
947        self.input.extend(input.iter().map(|&s| s as f64));
948        self.work.clear();
949        let length = match self.resample.as_mut() {
950            Some(r) => {
951                r.run(&self.input, &mut self.work);
952                r.counted() * self.channels as u64
953            }
954            None => {
955                self.work.extend_from_slice(&self.input);
956                input.len() as u64
957            }
958        };
959        self.post();
960        (self.emit(), length)
961    }
962
963    /// What the chain still holds at the end of a session.
964    pub fn flush(&mut self) -> &[f32] {
965        self.work.clear();
966        if let Some(r) = self.resample.as_mut() {
967            r.flush(&mut self.work);
968        }
969        self.post();
970        if let Some(c) = self.convolve.as_mut() {
971            c.flush(&mut self.work);
972        }
973        self.emit()
974    }
975
976    /// Gain, filters and convolution over `self.work`, at the output rate.
977    fn post(&mut self) {
978        if self.gain != 1.0 {
979            for s in &mut self.work {
980                *s *= self.gain;
981            }
982        }
983        if !self.steps.is_empty() {
984            self.steps.run(&mut self.work);
985        }
986        if let Some(c) = self.convolve.as_mut() {
987            c.run(&mut self.work);
988        }
989    }
990
991    /// `self.work` as the ring buffer takes it.
992    /// The chain's output, as the ring buffer takes it. The last line of
993    /// defence: a sample that is not a number is silence and none passes
994    /// full scale, whatever a profile did, so nothing reaches the device as
995    /// noise or an overload.
996    fn emit(&mut self) -> &[f32] {
997        self.out.clear();
998        self.out.extend(self.work.iter().map(|&s| {
999            if s.is_finite() {
1000                s.clamp(-1.0, 1.0) as f32
1001            } else {
1002                0.0
1003            }
1004        }));
1005        &self.out
1006    }
1007}
1008
1009/// The largest gain, linear, the filters and the response apply to any channel
1010/// at any frequency. A preamp of its inverse keeps a full-scale sine at that
1011/// frequency at full scale, as AutoEQ's `Preamp` line does. Whatever sums into
1012/// one channel — a mix, routes into one output — is added by magnitude, which
1013/// bounds what it can reach.
1014fn peak_gain(plan: &[Planned], impulse: Option<&Impulse>, channels: usize, rate: u32) -> f64 {
1015    // Each channel's gain at `w` with every input at full scale, in phase.
1016    let channel_gains = |w: f64| -> Vec<f64> {
1017        gain_matrix(plan, channels.max(1), w, rate)
1018            .iter()
1019            .map(|row| row.iter().sum())
1020            .collect()
1021    };
1022    let Some(impulse) = impulse else {
1023        let (lo, hi) = (10f64.ln(), (rate as f64 * 0.499).ln());
1024        return (0..=4096)
1025            .flat_map(|i| {
1026                let f = (lo + (hi - lo) * i as f64 / 4096.0).exp();
1027                let w = std::f64::consts::TAU * f / rate as f64;
1028                channel_gains(w)
1029            })
1030            .fold(0.0, f64::max);
1031    };
1032    let routes = impulse.routes_for(channels);
1033    let size = impulse.taps().next_power_of_two().max(8192);
1034    let fft = RealFftPlanner::<f64>::new().plan_fft_forward(size);
1035    let mut spectrum = fft.make_output_vec();
1036    let mut per_output = vec![vec![0.0f64; spectrum.len()]; channels];
1037    let gains: Vec<Vec<f64>> = if plan.is_empty() {
1038        Vec::new()
1039    } else {
1040        (0..spectrum.len())
1041            .map(|k| channel_gains(std::f64::consts::TAU * k as f64 / size as f64))
1042            .collect()
1043    };
1044    for r in &routes {
1045        let mut input = fft.make_input_vec();
1046        for (d, &s) in input.iter_mut().zip(&r.ir) {
1047            *d = s as f64;
1048        }
1049        if fft.process(&mut input, &mut spectrum).is_err() {
1050            continue;
1051        }
1052        for (k, bin) in spectrum.iter().enumerate() {
1053            let fed: f64 = r
1054                .inputs
1055                .iter()
1056                .map(|&(c, g)| {
1057                    g.abs() as f64 * gains.get(k).and_then(|g| g.get(c)).map_or(1.0, |&g| g)
1058                })
1059                .sum();
1060            for &(o, g) in &r.outputs {
1061                if let Some(out) = per_output.get_mut(o) {
1062                    out[k] += bin.norm() * fed * g.abs() as f64;
1063                }
1064            }
1065        }
1066    }
1067    per_output.iter().flatten().copied().fold(0.0, f64::max)
1068}
1069
1070/// Sample-rate conversion to an impulse response's rate.
1071struct Resample {
1072    inner: Fft<f64>,
1073    in_rate: u32,
1074    out_rate: u32,
1075    channels: usize,
1076    /// Interleaved input not yet a whole chunk.
1077    pending: Vec<f64>,
1078    scratch: Vec<f64>,
1079    /// Output frames of the resampler's own delay still to drop.
1080    skip: usize,
1081    fed: u64,
1082    emitted: u64,
1083    counted: u64,
1084}
1085
1086impl Resample {
1087    fn new(in_rate: u32, out_rate: u32, channels: usize) -> Option<Self> {
1088        if in_rate == out_rate {
1089            return None;
1090        }
1091        let inner = Fft::<f64>::new(
1092            in_rate as usize,
1093            out_rate as usize,
1094            1024,
1095            channels,
1096            FixedSync::Input,
1097        )
1098        .inspect_err(|e| log::error!("dsp: no resampler {in_rate}→{out_rate}Hz: {e}"))
1099        .ok()?;
1100        Some(Self {
1101            skip: inner.output_delay(),
1102            scratch: vec![0.0; inner.output_frames_max() * channels],
1103            inner,
1104            in_rate,
1105            out_rate,
1106            channels,
1107            pending: Vec::new(),
1108            fed: 0,
1109            emitted: 0,
1110            counted: 0,
1111        })
1112    }
1113
1114    /// Output frames `fed` input frames amount to.
1115    fn target(&self) -> u64 {
1116        self.fed * self.out_rate as u64 / self.in_rate as u64
1117    }
1118
1119    /// Output frames of time fed since the last call.
1120    fn counted(&mut self) -> u64 {
1121        let target = self.target();
1122        let new = target - self.counted;
1123        self.counted = target;
1124        new
1125    }
1126
1127    fn run(&mut self, input: &[f64], dst: &mut Vec<f64>) {
1128        self.fed += (input.len() / self.channels) as u64;
1129        self.pending.extend_from_slice(input);
1130        loop {
1131            let need = self.inner.input_frames_next();
1132            if self.pending.len() / self.channels < need {
1133                break;
1134            }
1135            self.chunk(need, None, dst);
1136            self.pending.drain(..need * self.channels);
1137        }
1138    }
1139
1140    /// Everything still held, padded with silence until the output reaches
1141    /// the length of what was fed.
1142    fn flush(&mut self, dst: &mut Vec<f64>) {
1143        let start = dst.len();
1144        let target = self.target();
1145        let mut partial = self.pending.len() / self.channels;
1146        while self.emitted < target {
1147            let need = self.inner.input_frames_next();
1148            self.pending.resize(need * self.channels, 0.0);
1149            if self.chunk(need, Some(partial), dst) == 0 {
1150                break;
1151            }
1152            partial = 0;
1153        }
1154        self.pending.clear();
1155        let over = (self.emitted - target) as usize * self.channels;
1156        dst.truncate((dst.len() - over).max(start));
1157        self.emitted = target;
1158    }
1159
1160    /// Resample one chunk into `dst`. Returns the frames the resampler made,
1161    /// its delay included.
1162    fn chunk(&mut self, need: usize, partial: Option<usize>, dst: &mut Vec<f64>) -> usize {
1163        let ch = self.channels;
1164        let frames_out = self.scratch.len() / ch;
1165        let (Ok(input), Ok(mut output)) = (
1166            InterleavedSlice::new(&self.pending[..need * ch], ch, need),
1167            InterleavedSlice::new_mut(&mut self.scratch, ch, frames_out),
1168        ) else {
1169            return 0;
1170        };
1171        let indexing = partial.map(|p| Indexing::new().partial_len(p));
1172        match self
1173            .inner
1174            .process_into_buffer(&input, &mut output, indexing.as_ref())
1175        {
1176            Ok((_, produced)) => {
1177                let drop = self.skip.min(produced);
1178                self.skip -= drop;
1179                dst.extend_from_slice(&self.scratch[drop * ch..produced * ch]);
1180                self.emitted += (produced - drop) as u64;
1181                produced
1182            }
1183            Err(e) => {
1184                log::error!("dsp: resampling failed: {e}");
1185                0
1186            }
1187        }
1188    }
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194    use crate::config::{EqFilter, EqFilterKind};
1195
1196    fn band(kind: EqFilterKind, freq: f64, gain_db: f64, q: f64) -> EqFilter {
1197        EqFilter {
1198            kind,
1199            freq,
1200            gain_db,
1201            q,
1202            channels: vec![],
1203        }
1204    }
1205
1206    fn sine(rate: u32, freq: f64, frames: usize, channels: usize, amp: f32) -> Vec<f32> {
1207        (0..frames)
1208            .flat_map(|i| {
1209                let v = amp * (std::f64::consts::TAU * freq * i as f64 / rate as f64).sin() as f32;
1210                std::iter::repeat_n(v, channels)
1211            })
1212            .collect()
1213    }
1214
1215    fn rms(s: &[f32]) -> f64 {
1216        (s.iter().map(|&v| (v as f64).powi(2)).sum::<f64>() / s.len() as f64).sqrt()
1217    }
1218
1219    /// Run `input` through in packets, as the decoder hands it over.
1220    fn run_all(chain: &mut Chain, input: &[f32], packet: usize) -> (Vec<f32>, u64) {
1221        let mut out = Vec::new();
1222        let mut counted = 0;
1223        for p in input.chunks(packet) {
1224            let (o, n) = chain.process(p);
1225            out.extend_from_slice(o);
1226            counted += n;
1227        }
1228        out.extend_from_slice(chain.flush());
1229        (out, counted)
1230    }
1231
1232    /// Deterministic noise in [-1, 1).
1233    fn noise(seed: &mut u64) -> f64 {
1234        *seed = seed
1235            .wrapping_mul(6_364_136_223_846_793_005)
1236            .wrapping_add(1_442_695_040_888_963_407);
1237        ((*seed >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
1238    }
1239
1240    /// What is left after subtracting a textbook convolution — every output
1241    /// sample summed tap by tap in 64-bit — from the chain's. The partitioned
1242    /// FFT is the same sum reordered, so the residual is rounding: what the
1243    /// 64-bit chain adds, and the 32-bit floats it hands the ring buffer.
1244    #[test]
1245    fn null_test_against_direct_convolution() {
1246        let (rate, taps, frames, peak) = (48000, 4096, 8192, 200);
1247        let mut seed = 1;
1248        let mut ir: Vec<f32> = (0..taps)
1249            .map(|i| (noise(&mut seed) * 0.2 * (-(i as f64) / 600.0).exp()) as f32)
1250            .collect();
1251        ir[peak] = 1.0;
1252        let input: Vec<f32> = (0..frames * 2)
1253            .map(|_| (noise(&mut seed) * 0.25) as f32)
1254            .collect();
1255        let mut setup = Setup::new(vec![], vec![Impulse::from_channels(rate, vec![ir.clone()])]);
1256        setup.preamp_db = Some(0.0);
1257        // Lowered for the response's peak, which the reference applies too.
1258        let preamp = 10f64.powf(setup.preamp_db(rate, 2) / 20.0);
1259        let mut chain = Chain::new(&setup, rate, 2);
1260        let (out, _) = run_all(&mut chain, &input, 1152);
1261        assert_eq!(out.len(), input.len());
1262
1263        let (mut err, mut sig) = (0.0f64, 0.0f64);
1264        for c in 0..2 {
1265            for n in 0..frames {
1266                // Output frame n is input frame n: the peak's delay is trimmed.
1267                let m = n + peak;
1268                let y: f64 = (0..taps)
1269                    .filter(|&k| k <= m && m - k < frames)
1270                    .map(|k| ir[k] as f64 * input[(m - k) * 2 + c] as f64)
1271                    .sum::<f64>()
1272                    * preamp;
1273                err += (out[n * 2 + c] as f64 - y).powi(2);
1274                sig += y.powi(2);
1275            }
1276        }
1277        let db = 10.0 * (err / sig).log10();
1278        eprintln!("residual against direct convolution: {db:.1} dB");
1279        // 32-bit output rounding alone sits near -150 dB.
1280        assert!(db < -140.0, "residual {db:.1} dB");
1281    }
1282
1283    /// The heaviest response there is: 262,145 taps a channel at 192 kHz, as
1284    /// the Harman 780 Roon pack ships. Run it in release:
1285    /// `cargo test --release -p koan-core --lib bench_long_response -- --ignored --nocapture`
1286    #[test]
1287    #[ignore]
1288    fn bench_long_response() {
1289        let (rate, taps, secs) = (192000, 262_145, 10);
1290        let mut seed = 7;
1291        let ir: Vec<Vec<f32>> = (0..2)
1292            .map(|_| {
1293                (0..taps)
1294                    .map(|i| (noise(&mut seed) * (-(i as f64) / 20000.0).exp()) as f32)
1295                    .collect()
1296            })
1297            .collect();
1298        let input: Vec<f32> = (0..rate as usize * secs * 2)
1299            .map(|_| (noise(&mut seed) * 0.25) as f32)
1300            .collect();
1301        let setup = Setup::new(vec![], vec![Impulse::from_channels(rate, ir)]);
1302        let started = std::time::Instant::now();
1303        let mut chain = Chain::new(&setup, rate, 2);
1304        let built = started.elapsed();
1305        let started = std::time::Instant::now();
1306        for p in input.chunks(4096 * 2) {
1307            chain.process(p);
1308        }
1309        let took = started.elapsed();
1310        eprintln!(
1311            "{taps} taps at {rate} Hz, stereo: built in {built:?}; {secs} s of audio in {took:?}, {:.1}x real time, {:.1}% of one core",
1312            secs as f64 / took.as_secs_f64(),
1313            100.0 * took.as_secs_f64() / secs as f64
1314        );
1315    }
1316
1317    /// What a typical profile costs per second of audio, stage by stage and
1318    /// together: a ten-band AutoEQ correction, a target change (a graphic
1319    /// curve, so a minimum-phase FIR), and a room convolution of 65,536 taps,
1320    /// stereo at 48 kHz. Run it in release:
1321    /// `cargo test --release -p koan-core --lib bench_typical_profile -- --ignored --nocapture`
1322    #[test]
1323    #[ignore]
1324    fn bench_typical_profile() {
1325        use crate::config::{EqFilter, EqFilterKind, GraphicEq};
1326        let (rate, secs) = (48000u32, 20usize);
1327        let band = |kind, freq, gain_db, q| {
1328            DspFilter::Band(EqFilter {
1329                kind,
1330                freq,
1331                gain_db,
1332                q,
1333                channels: vec![],
1334            })
1335        };
1336        let bands: Vec<DspFilter> = [
1337            (EqFilterKind::LowShelf, 105.0, 5.5, 0.7),
1338            (EqFilterKind::Peaking, 180.0, -2.8, 0.9),
1339            (EqFilterKind::Peaking, 650.0, 1.2, 1.1),
1340            (EqFilterKind::Peaking, 1400.0, 2.1, 1.4),
1341            (EqFilterKind::Peaking, 2600.0, -1.6, 3.0),
1342            (EqFilterKind::Peaking, 3200.0, -4.0, 2.2),
1343            (EqFilterKind::Peaking, 4800.0, 2.4, 4.0),
1344            (EqFilterKind::Peaking, 6100.0, 3.4, 3.0),
1345            (EqFilterKind::Peaking, 9000.0, -3.1, 5.0),
1346            (EqFilterKind::HighShelf, 10000.0, -2.5, 0.7),
1347        ]
1348        .into_iter()
1349        .map(|(k, f, g, q)| band(k, f, g, q))
1350        .collect();
1351        let target = DspFilter::Graphic(GraphicEq {
1352            points: vec![
1353                (20.0, 4.0),
1354                (120.0, 3.0),
1355                (1000.0, 0.0),
1356                (3000.0, -1.0),
1357                (10000.0, -3.0),
1358                (20000.0, -6.0),
1359            ],
1360            channels: vec![],
1361        });
1362        let mut seed = 11;
1363        let room: Vec<Vec<f32>> = (0..2)
1364            .map(|_| {
1365                let mut ir: Vec<f32> = (0..65536)
1366                    .map(|i| (noise(&mut seed) * 0.2 * (-(i as f64) / 6000.0).exp()) as f32)
1367                    .collect();
1368                ir[0] = 1.0;
1369                ir
1370            })
1371            .collect();
1372        let input: Vec<f32> = (0..rate as usize * secs * 2)
1373            .map(|_| (noise(&mut seed) * 0.25) as f32)
1374            .collect();
1375
1376        let cases: [(&str, Vec<DspFilter>, Vec<Impulse>); 4] = [
1377            ("ten bands", bands.clone(), vec![]),
1378            ("target FIR", vec![target.clone()], vec![]),
1379            (
1380                "65,536-tap convolution",
1381                vec![],
1382                vec![Impulse::from_channels(rate, room.clone())],
1383            ),
1384            (
1385                "all three",
1386                [bands, vec![target]].concat(),
1387                vec![Impulse::from_channels(rate, room)],
1388            ),
1389        ];
1390        for (name, filters, impulses) in cases {
1391            let setup = Setup::new(filters, impulses);
1392            let started = std::time::Instant::now();
1393            let mut chain = Chain::new(&setup, rate, 2);
1394            let built = started.elapsed();
1395            let started = std::time::Instant::now();
1396            for p in input.chunks(4096 * 2) {
1397                chain.process(p);
1398            }
1399            let took = started.elapsed();
1400            eprintln!(
1401                "{name}: built in {built:.1?}; {:.2} ms a second of audio, {:.0}x real time, {:.2}% of one core",
1402                1000.0 * took.as_secs_f64() / secs as f64,
1403                secs as f64 / took.as_secs_f64(),
1404                100.0 * took.as_secs_f64() / secs as f64
1405            );
1406        }
1407    }
1408
1409    #[test]
1410    fn a_profile_that_changes_nothing_is_no_chain() {
1411        let profile = DspProfile {
1412            name: "flat".into(),
1413            ..Default::default()
1414        };
1415        assert!(
1416            Setup::load(&profile, &[], Path::new("/"))
1417                .unwrap()
1418                .is_none()
1419        );
1420    }
1421
1422    #[test]
1423    fn a_peaking_band_boosts_its_frequency_and_the_preamp_pays_for_it() {
1424        let setup = Setup::new(
1425            vec![band(EqFilterKind::Peaking, 1000.0, 6.0, 1.0).into()],
1426            vec![],
1427        );
1428        let mut chain = Chain::new(&setup, 48000, 2);
1429        let input = sine(48000, 1000.0, 48000, 2, 0.5);
1430        let (out, counted) = run_all(&mut chain, &input, 4096);
1431        assert_eq!(out.len(), input.len());
1432        assert_eq!(counted, input.len() as u64);
1433        // +6 dB at the centre, -6 dB of derived preamp: unity.
1434        let ratio = rms(&out[24000..]) / rms(&input[24000..]);
1435        assert!((ratio - 1.0).abs() < 0.02, "ratio {ratio}");
1436
1437        // Far from the band only the preamp is left.
1438        let mut chain = Chain::new(&setup, 48000, 2);
1439        let input = sine(48000, 100.0, 48000, 2, 0.5);
1440        let (out, _) = run_all(&mut chain, &input, 4096);
1441        let db = 20.0 * (rms(&out[24000..]) / rms(&input[24000..])).log10();
1442        assert!((db + 6.0).abs() < 0.2, "{db} dB");
1443    }
1444
1445    #[test]
1446    fn a_band_past_nyquist_is_skipped_not_fatal() {
1447        let setup = Setup::new(
1448            vec![band(EqFilterKind::Peaking, 30000.0, 6.0, 1.0).into()],
1449            vec![],
1450        );
1451        let mut chain = Chain::new(&setup, 44100, 2);
1452        let input = sine(44100, 1000.0, 4410, 2, 0.5);
1453        let (out, _) = run_all(&mut chain, &input, 1024);
1454        assert_eq!(out, input);
1455    }
1456
1457    fn delayed_impulse(rate: u32, delay: usize) -> Impulse {
1458        let mut ir = vec![0.0; delay * 2 + 1];
1459        ir[delay] = 1.0;
1460        Impulse::from_channels(rate, vec![ir])
1461    }
1462
1463    /// The drawn response carries the impulse response, after the filters.
1464    #[test]
1465    fn a_response_includes_the_impulse() {
1466        let half = Impulse::from_channels(48000, vec![vec![0.5]]);
1467        let peak = DspFilter::Band(crate::config::EqFilter {
1468            kind: crate::config::EqFilterKind::Peaking,
1469            freq: 1000.0,
1470            gain_db: 3.0,
1471            q: 1.0,
1472            channels: vec![],
1473        });
1474        let setup = Setup::new(vec![peak], vec![half]);
1475        let db = setup.response(&[20.0, 1000.0], 48000);
1476        assert!((db[0] + 6.02).abs() < 0.05, "{db:?}");
1477        assert!((db[1] + 3.02).abs() < 0.1, "{db:?}");
1478    }
1479
1480    fn loaded(profile: DspProfile, all: &[DspProfile]) -> Setup {
1481        Setup::load(&profile, all, Path::new("/nonexistent"))
1482            .unwrap()
1483            .unwrap()
1484    }
1485
1486    fn gain_band(db: f64) -> DspFilter {
1487        DspFilter::Band(crate::config::EqFilter {
1488            kind: crate::config::EqFilterKind::Gain,
1489            freq: 1000.0,
1490            gain_db: db,
1491            q: 1.0,
1492            channels: vec![],
1493        })
1494    }
1495
1496    /// 256 delays at their own limit add up to two seconds on a channel,
1497    /// not eight minutes of buffers.
1498    #[test]
1499    fn delays_add_up_to_two_seconds_at_most() {
1500        let delay = DspFilter::Delay(crate::config::Delay {
1501            ms: 2000.0,
1502            samples: 768_000.0,
1503            ..Default::default()
1504        });
1505        let profile = DspProfile {
1506            name: "Echo".into(),
1507            filters: vec![delay; 256],
1508            ..Default::default()
1509        };
1510        let setup = loaded(profile, &[]);
1511        let total: f64 = setup
1512            .filters
1513            .iter()
1514            .map(|f| match f {
1515                DspFilter::Delay(d) => d.ms + d.samples / 44.1,
1516                _ => 0.0,
1517            })
1518            .sum();
1519        assert!(total <= 2000.0 + 1e-6, "{total} ms");
1520        // What the chain holds for them: two seconds a channel at 384 kHz.
1521        let chain = Chain::new(&setup, 384_000, 2);
1522        drop(chain);
1523    }
1524
1525    /// 64 bands of +30 dB with the preamp set to 0 dB: the preamp is lowered
1526    /// for headroom, and what comes out is finite and within full scale.
1527    #[test]
1528    fn stacked_boost_never_overloads() {
1529        let profile = DspProfile {
1530            name: "Loud".into(),
1531            preamp_db: Some(0.0),
1532            filters: vec![gain_band(30.0); 64],
1533            ..Default::default()
1534        };
1535        let setup = loaded(profile, &[]);
1536        assert!(setup.headroom_cut(48000, 2).unwrap() > 1900.0);
1537        let mut chain = Chain::new(&setup, 48000, 2);
1538        let (out, _) = run_all(&mut chain, &sine(48000, 1000.0, 48000, 2, 1.0), 4096);
1539        assert!(out.iter().all(|s| s.is_finite() && s.abs() <= 1.0));
1540    }
1541
1542    /// Graphic curves and mixes are counted across the whole chain, layers
1543    /// and all: 32 layers of many curves each still play the budget's few.
1544    #[test]
1545    fn graphic_curves_are_budgeted_across_layers() {
1546        let curve = DspFilter::Graphic(crate::config::GraphicEq {
1547            points: (1..2048)
1548                .map(|i| (i as f64 * 10.0, if i % 2 == 0 { 30.0 } else { -30.0 }))
1549                .collect(),
1550            channels: vec![],
1551        });
1552        let mut all: Vec<DspProfile> = (0..32)
1553            .map(|i| DspProfile {
1554                name: format!("Layer {i}"),
1555                filters: vec![curve.clone(); 256],
1556                ..Default::default()
1557            })
1558            .collect();
1559        let stack = DspProfile {
1560            name: "Stack".into(),
1561            layers: all
1562                .iter()
1563                .map(|p| crate::config::DspLayer {
1564                    profile: p.name.clone(),
1565                    on: true,
1566                })
1567                .collect(),
1568            ..Default::default()
1569        };
1570        all.push(stack.clone());
1571        let (filters, notes) = chain_noted(&stack, &all, &mut Vec::new()).unwrap();
1572        let graphics = filters
1573            .iter()
1574            .filter(|f| matches!(f, DspFilter::Graphic(_)))
1575            .count();
1576        assert_eq!(graphics, crate::config::dsp_bounds::CHAIN_GRAPHICS);
1577        assert!(
1578            notes.iter().any(|n| n.starts_with("graphic EQs past")),
1579            "{notes:?}"
1580        );
1581    }
1582
1583    /// Whatever reaches the end of the chain, a sample that is not a number
1584    /// leaves as silence and none leaves past full scale.
1585    #[test]
1586    fn the_output_is_finite_and_within_full_scale() {
1587        let setup = Setup::new(vec![gain_band(0.0)], vec![]).with_preamp(0.0);
1588        let mut chain = Chain::new(&setup, 48000, 2);
1589        let (out, _) = run_all(&mut chain, &[1.5, -3.0, f32::NAN, f32::INFINITY], 4);
1590        assert_eq!(out, vec![1.0, -1.0, 0.0, 0.0]);
1591    }
1592
1593    /// How long the largest chain the budgets allow takes to run, against
1594    /// the audio it makes: `cargo test --release -- --ignored budget_runs
1595    /// --nocapture`.
1596    #[test]
1597    #[ignore]
1598    fn budget_runs_in_real_time() {
1599        let curve = DspFilter::Graphic(crate::config::GraphicEq {
1600            points: (1..2048)
1601                .map(|i| (i as f64 * 10.0, if i % 2 == 0 { 6.0 } else { -6.0 }))
1602                .collect(),
1603            channels: vec![],
1604        });
1605        let mut filters = vec![curve; crate::config::dsp_bounds::CHAIN_GRAPHICS];
1606        filters.extend((0..64).map(|i| {
1607            DspFilter::Band(crate::config::EqFilter {
1608                kind: crate::config::EqFilterKind::Peaking,
1609                freq: 20.0 * 1.1f64.powi(i),
1610                gain_db: 1.0,
1611                q: 2.0,
1612                channels: vec![],
1613            })
1614        }));
1615        filters.push(DspFilter::Delay(crate::config::Delay {
1616            ms: 2000.0,
1617            ..Default::default()
1618        }));
1619        let ir: Vec<f32> = (0..crate::config::dsp_bounds::IMPULSE_TAPS)
1620            .map(|i| (-(i as f32) / 20000.0).exp() * if i % 2 == 0 { 0.01 } else { -0.01 })
1621            .collect();
1622        for rate in [48_000u32, 192_000] {
1623            let impulse = Impulse::from_channels(rate, vec![ir.clone()]);
1624            let setup = Setup::new(filters.clone(), vec![impulse]);
1625            let mut chain = Chain::new(&setup, rate, 2);
1626            let seconds = 10;
1627            let input = sine(rate, 440.0, rate as usize * seconds, 2, 0.5);
1628            let start = std::time::Instant::now();
1629            run_all(&mut chain, &input, 4096);
1630            let took = start.elapsed().as_secs_f64();
1631            println!(
1632                "{rate} Hz: {seconds} s of stereo in {took:.2} s, {:.1}x real time",
1633                seconds as f64 / took
1634            );
1635        }
1636    }
1637
1638    #[test]
1639    fn convolution_delay_is_trimmed_and_the_tail_flushed() {
1640        let setup = Setup::new(vec![], vec![delayed_impulse(48000, 300)]);
1641        let mut chain = Chain::new(&setup, 48000, 2);
1642        let input = sine(48000, 440.0, 10000, 2, 0.5);
1643        let (out, counted) = run_all(&mut chain, &input, 1152);
1644        assert_eq!(out.len(), input.len());
1645        assert_eq!(counted, input.len() as u64);
1646        for (a, b) in out.iter().zip(&input) {
1647            assert!((a - b).abs() < 1e-4);
1648        }
1649    }
1650
1651    #[test]
1652    fn a_source_without_its_own_response_is_resampled_to_the_nearest() {
1653        let setup = Setup::new(
1654            vec![],
1655            vec![delayed_impulse(44100, 0), delayed_impulse(48000, 0)],
1656        );
1657        assert_eq!(setup.output_rate(44100), 44100);
1658        assert_eq!(setup.output_rate(96000), 48000);
1659        assert_eq!(setup.output_rate(88200), 48000);
1660        assert_eq!(setup.output_rate(22050), 44100);
1661        assert_eq!(
1662            setup.status(96000),
1663            DspStatus {
1664                profile: "test".into(),
1665                eq: false,
1666                convolution_rate: Some(48000)
1667            }
1668        );
1669
1670        let mut chain = Chain::new(&setup, 96000, 2);
1671        assert_eq!(chain.output_rate(), 48000);
1672        let input = sine(96000, 1000.0, 96000, 2, 0.5);
1673        let (out, counted) = run_all(&mut chain, &input, 4096);
1674        assert_eq!(out.len(), 48000 * 2);
1675        assert_eq!(counted, 48000 * 2);
1676        let ratio = rms(&out[4800..43200]) / rms(&input[9600..86400]);
1677        assert!((ratio - 1.0).abs() < 0.02, "ratio {ratio}");
1678        // Delay trimmed: the output starts in phase with the input.
1679        let expect = sine(48000, 1000.0, 2000, 2, 0.5);
1680        let err = out[2000..4000]
1681            .iter()
1682            .zip(&expect[2000..4000])
1683            .map(|(a, b)| (a - b).abs())
1684            .fold(0.0f32, f32::max);
1685        assert!(err < 0.02, "max error {err}");
1686    }
1687
1688    #[test]
1689    fn a_gapless_rate_change_keeps_the_output_rate_and_its_length() {
1690        let setup = Setup::new(vec![], vec![delayed_impulse(48000, 64)]);
1691        let mut chain = Chain::new(&setup, 44100, 2);
1692        let first = sine(44100, 500.0, 44100, 2, 0.5);
1693        let mut out = Vec::new();
1694        let mut counted = 0;
1695        for p in first.chunks(4096) {
1696            let (o, n) = chain.process(p);
1697            out.extend_from_slice(o);
1698            counted += n;
1699        }
1700        out.extend_from_slice(chain.set_source_rate(48000));
1701        let second = sine(48000, 500.0, 48000, 2, 0.5);
1702        for p in second.chunks(4096) {
1703            let (o, n) = chain.process(p);
1704            out.extend_from_slice(o);
1705            counted += n;
1706        }
1707        out.extend_from_slice(chain.flush());
1708        assert_eq!(counted, 96000 * 2);
1709        assert_eq!(out.len(), 96000 * 2);
1710    }
1711
1712    #[test]
1713    fn the_preamp_covers_the_response_gain() {
1714        let mut ir = vec![0.0; 64];
1715        ir[0] = 2.0;
1716        let setup = Setup::new(vec![], vec![Impulse::from_channels(48000, vec![ir])]);
1717        let mut chain = Chain::new(&setup, 48000, 1);
1718        let (out, _) = run_all(&mut chain, &[0.5; 4800], 480);
1719        assert!(out.iter().all(|&s| (s - 0.5).abs() < 1e-4));
1720    }
1721
1722    #[test]
1723    fn bands_for_one_channel_leave_the_other_alone() {
1724        let mut b = band(EqFilterKind::Gain, 1000.0, -6.0, 1.0);
1725        b.channels = vec![1];
1726        let mut setup = Setup::new(vec![b.into()], vec![]);
1727        setup.preamp_db = Some(0.0);
1728        let mut chain = Chain::new(&setup, 48000, 2);
1729        let (out, _) = run_all(&mut chain, &[0.5, 0.5, 0.5, 0.5], 4);
1730        assert_eq!(out[0], 0.5);
1731        assert!((out[1] - 0.25).abs() < 1e-3);
1732    }
1733
1734    #[test]
1735    fn a_route_can_feed_one_channel_into_the_other() {
1736        // Left passes; right is half left and half right, so no output can
1737        // pass full scale and the preamp stays at 0 dB.
1738        let impulse = Impulse {
1739            rate: 48000,
1740            channels: Some(2),
1741            routes: vec![
1742                Route {
1743                    ir: vec![1.0],
1744                    inputs: vec![(0, 1.0)],
1745                    outputs: vec![(0, 1.0)],
1746                },
1747                Route {
1748                    ir: vec![1.0],
1749                    inputs: vec![(0, 0.5), (1, 0.5)],
1750                    outputs: vec![(1, 1.0)],
1751                },
1752            ],
1753            in_delays: vec![],
1754            out_delays: vec![0, 1],
1755        };
1756        let mut setup = Setup::new(vec![], vec![impulse]);
1757        setup.preamp_db = Some(0.0);
1758        let mut chain = Chain::new(&setup, 48000, 2);
1759        let (out, _) = run_all(&mut chain, &[0.4, 0.2, 0.0, 0.0], 4);
1760        // The right output is a frame late, by its delay.
1761        assert_eq!(out, vec![0.4, 0.0, 0.0, 0.3]);
1762    }
1763
1764    #[test]
1765    fn an_impulse_is_read_from_a_wav_at_its_own_rate() {
1766        let dir = std::env::temp_dir().join(format!("koan-dsp-{}", std::process::id()));
1767        std::fs::create_dir_all(&dir).unwrap();
1768        let path = dir.join("ir.wav");
1769        let frames: [i16; 8] = [0, 0, 16384, 16384, 0, 0, 0, 0];
1770        let mut wav = Vec::new();
1771        let data_len = (frames.len() * 2) as u32;
1772        wav.extend_from_slice(b"RIFF");
1773        wav.extend_from_slice(&(36 + data_len).to_le_bytes());
1774        wav.extend_from_slice(b"WAVEfmt ");
1775        wav.extend_from_slice(&16u32.to_le_bytes());
1776        wav.extend_from_slice(&1u16.to_le_bytes());
1777        wav.extend_from_slice(&2u16.to_le_bytes());
1778        wav.extend_from_slice(&96000u32.to_le_bytes());
1779        wav.extend_from_slice(&(96000u32 * 4).to_le_bytes());
1780        wav.extend_from_slice(&4u16.to_le_bytes());
1781        wav.extend_from_slice(&16u16.to_le_bytes());
1782        wav.extend_from_slice(b"data");
1783        wav.extend_from_slice(&data_len.to_le_bytes());
1784        for f in frames {
1785            wav.extend_from_slice(&f.to_le_bytes());
1786        }
1787        std::fs::write(&path, wav).unwrap();
1788
1789        let profile = DspProfile {
1790            name: "room".into(),
1791            impulses: vec!["ir.wav".into()],
1792            ..Default::default()
1793        };
1794        let setup = Setup::load(&profile, &[], &dir).unwrap().unwrap();
1795        let impulse = &setup.impulses[&96000][0];
1796        assert_eq!(impulse.channels, Some(2));
1797        assert_eq!(impulse.routes[0].ir, vec![0.0, 0.5, 0.0, 0.0]);
1798        assert_eq!(impulse.delay(), 1);
1799
1800        let missing = DspProfile {
1801            impulses: vec!["nope.wav".into()],
1802            ..profile
1803        };
1804        assert!(Setup::load(&missing, &[], &dir).is_err());
1805    }
1806}