灵符lingfu
L

ling-daw

↓ 0 downloads

Ling DAW engine: synth, physical-modeling drums, effects, mixer, sequencer, project I/O

lingfu install ling-daw
LicenseApache-2.0 OR MIT
AuthorsLing Lin <taellinglin@gmail.com>, Sanny Lin <SannyLing53@gmail.com>
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (91)

src/mixai.rs
//! AI Mix & Master: measure every track and the master (loudness, peaks, dynamics, spectral balance, stereo width), then plan the moves a mix
//! engineer would make: levels by role, panning of crowded instruments, low cut and mud cut, a little compression where a track is too
//! dynamic, and the master level for a loudness target. The numbers decide; the language model (Qwen) only names the roles and picks the
//! style, so the plan is the same on every machine and every move comes with its reason.
use std::f32::consts::PI;

pub const BANDS: usize = 6;
pub const BAND_EDGES: [f32; BANDS + 1] = [20.0, 60.0, 250.0, 800.0, 2500.0, 6000.0, 20000.0];
pub const BAND_NAMES: [&str; BANDS] = ["sub", "bass", "low-mid", "mid", "presence", "air"];

#[derive(Clone, Debug, Default)]
pub struct Stat {
    pub peak_db: f32,
    /// RMS over the whole signal (dBFS)
    pub rms_db: f32,
    /// RMS over the moments the track is playing (blocks above -55 dB), the level the track has when it is there
    pub active_rms_db: f32,
    /// integrated loudness (LUFS, BS.1770: K-weighted, gated)
    pub lufs: f32,
    pub crest_db: f32,
    pub centroid_hz: f32,
    /// side energy relative to mid in dB (negative = narrow / mono)
    pub width_db: f32,
    /// share of 100 ms blocks that are not silent
    pub active: f32,
    /// energy per band relative to the track's total, dB
    pub bands: [f32; BANDS],
    /// L/R correlation -1..1
    pub corr: f32,
    pub clipped: usize,
}

fn db(x: f32) -> f32 {
    20.0 * x.max(1e-9).log10()
}
fn pdb(x: f32) -> f32 {
    10.0 * x.max(1e-12).log10()
}

// ── K-weighting (ITU-R BS.1770) ────────────────────────────────────────────────

#[derive(Clone, Copy)]
struct Bq {
    b: [f32; 3],
    a: [f32; 2],
    z: [f32; 2],
}
impl Bq {
    fn run(&mut self, x: f32) -> f32 {
        let y = self.b[0] * x + self.z[0];
        self.z[0] = self.b[1] * x - self.a[0] * y + self.z[1];
        self.z[1] = self.b[2] * x - self.a[1] * y;
        y
    }
}
fn k_filters(sr: f32) -> [Bq; 2] {
    // stage 1: the head-related high shelf
    let (f0, g, q) = (1681.9745f32, 3.99984f32, 0.707175f32);
    let a = 10f32.powf(g / 40.0);
    let w0 = 2.0 * PI * f0 / sr;
    let (cw, sw) = (w0.cos(), w0.sin());
    let alpha = sw / (2.0 * q);
    let sa = a.sqrt();
    let b0 = a * ((a + 1.0) + (a - 1.0) * cw + 2.0 * sa * alpha);
    let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cw);
    let b2 = a * ((a + 1.0) + (a - 1.0) * cw - 2.0 * sa * alpha);
    let a0 = (a + 1.0) - (a - 1.0) * cw + 2.0 * sa * alpha;
    let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cw);
    let a2 = (a + 1.0) - (a - 1.0) * cw - 2.0 * sa * alpha;
    let s1 = Bq { b: [b0 / a0, b1 / a0, b2 / a0], a: [a1 / a0, a2 / a0], z: [0.0; 2] };
    // stage 2: the revised low-frequency B weighting (a 38 Hz high-pass)
    let (f0, q) = (38.13547f32, 0.500327f32);
    let w0 = 2.0 * PI * f0 / sr;
    let (cw, sw) = (w0.cos(), w0.sin());
    let alpha = sw / (2.0 * q);
    let a0 = 1.0 + alpha;
    let s2 = Bq { b: [(1.0 + cw) / 2.0 / a0, -(1.0 + cw) / a0, (1.0 + cw) / 2.0 / a0], a: [-2.0 * cw / a0, (1.0 - alpha) / a0], z: [0.0; 2] };
    [s1, s2]
}

/// integrated loudness in LUFS of interleaved stereo (absolute gate -70, relative gate -10)
pub fn lufs(stereo: &[f32], sr: f32) -> f32 {
    let n = stereo.len() / 2;
    if n < (sr * 0.4) as usize {
        return -70.0;
    }
    let mut kl = k_filters(sr);
    let mut kr = k_filters(sr);
    let mut e = Vec::with_capacity(n);
    for f in 0..n {
        let l0 = kl[0].run(stereo[f * 2]);
        let l = kl[1].run(l0);
        let r0 = kr[0].run(stereo[f * 2 + 1]);
        let r = kr[1].run(r0);
        e.push(l * l + r * r);
    }
    let block = (sr * 0.4) as usize;
    let step = (sr * 0.1) as usize;
    let mut cum = vec![0.0f64; n + 1];
    for i in 0..n {
        cum[i + 1] = cum[i] + e[i] as f64;
    }
    let mut blocks: Vec<f32> = vec![];
    let mut s = 0;
    while s + block <= n {
        blocks.push(((cum[s + block] - cum[s]) / block as f64) as f32);
        s += step;
    }
    let abs: Vec<f32> = blocks.iter().cloned().filter(|z| -0.691 + pdb(*z) > -70.0).collect();
    if abs.is_empty() {
        return -70.0;
    }
    let mean = abs.iter().sum::<f32>() / abs.len() as f32;
    let rel_gate = -0.691 + pdb(mean) - 10.0;
    let rel: Vec<f32> = abs.into_iter().filter(|z| -0.691 + pdb(*z) > rel_gate).collect();
    if rel.is_empty() {
        return -70.0;
    }
    -0.691 + pdb(rel.iter().sum::<f32>() / rel.len() as f32)
}

// ── spectrum ──────────────────────────────────────────────────────────────────

fn fft(re: &mut [f32], im: &mut [f32]) {
    let n = re.len();
    let mut j = 0;
    for i in 1..n {
        let mut bit = n >> 1;
        while j & bit != 0 {
            j ^= bit;
            bit >>= 1;
        }
        j ^= bit;
        if i < j {
            re.swap(i, j);
            im.swap(i, j);
        }
    }
    let mut len = 2;
    while len <= n {
        let ang = -2.0 * PI / len as f32;
        let (wr, wi) = (ang.cos(), ang.sin());
        let mut i = 0;
        while i < n {
            let (mut cr, mut ci) = (1.0f32, 0.0f32);
            for k in 0..len / 2 {
                let (ur, ui) = (re[i + k], im[i + k]);
                let (vr, vi) = (re[i + k + len / 2] * cr - im[i + k + len / 2] * ci, re[i + k + len / 2] * ci + im[i + k + len / 2] * cr);
                re[i + k] = ur + vr;
                im[i + k] = ui + vi;
                re[i + k + len / 2] = ur - vr;
                im[i + k + len / 2] = ui - vi;
                let t = cr * wr - ci * wi;
                ci = cr * wi + ci * wr;
                cr = t;
            }
            i += len;
        }
        len <<= 1;
    }
}

/// Measure interleaved stereo audio.
pub fn analyze(stereo: &[f32], sr: f32) -> Stat {
    let n = stereo.len() / 2;
    let mut st = Stat { peak_db: -120.0, rms_db: -120.0, active_rms_db: -120.0, lufs: -70.0, bands: [-120.0; BANDS], ..Stat::default() };
    if n < 2048 {
        return st;
    }
    let mut peak = 0f32;
    let (mut sq, mut sl, mut sr_, mut slr, mut mid, mut side) = (0f64, 0f64, 0f64, 0f64, 0f64, 0f64);
    for f in 0..n {
        let (l, r) = (stereo[f * 2], stereo[f * 2 + 1]);
        peak = peak.max(l.abs()).max(r.abs());
        if l.abs() >= 0.999 || r.abs() >= 0.999 {
            st.clipped += 1;
        }
        sq += (l * l + r * r) as f64 * 0.5;
        sl += (l * l) as f64;
        sr_ += (r * r) as f64;
        slr += (l * r) as f64;
        let (m, s) = ((l + r) * 0.5, (l - r) * 0.5);
        mid += (m * m) as f64;
        side += (s * s) as f64;
    }
    st.peak_db = db(peak);
    st.rms_db = pdb((sq / n as f64) as f32);
    st.crest_db = st.peak_db - st.rms_db;
    st.width_db = pdb(side as f32) - pdb(mid as f32);
    st.corr = (slr / ((sl * sr_).sqrt() + 1e-12)) as f32;
    st.lufs = lufs(stereo, sr);
    // active blocks (100 ms): the level of the track where it plays
    let blk = (sr * 0.1) as usize;
    let mut act = 0usize;
    let mut total_blocks = 0usize;
    let mut act_sq = 0f64;
    let mut act_n = 0usize;
    let mut b = 0;
    while b + blk <= n {
        let e: f64 = (b..b + blk).map(|f| ((stereo[f * 2].powi(2) + stereo[f * 2 + 1].powi(2)) * 0.5) as f64).sum::<f64>() / blk as f64;
        total_blocks += 1;
        if pdb(e as f32) > -55.0 {
            act += 1;
            act_sq += e * blk as f64;
            act_n += blk;
        }
        b += blk;
    }
    st.active = act as f32 / total_blocks.max(1) as f32;
    st.active_rms_db = if act_n > 0 { pdb((act_sq / act_n as f64) as f32) } else { -120.0 };
    // average power spectrum over windows that are playing
    const N: usize = 2048;
    let win: Vec<f32> = (0..N).map(|i| 0.5 - 0.5 * (2.0 * PI * i as f32 / N as f32).cos()).collect();
    let mut power = vec![0f64; N / 2];
    let hop = N;
    let mut count = 0;
    let mut p = 0;
    while p + N <= n {
        let mut re: Vec<f32> = (0..N).map(|i| (stereo[(p + i) * 2] + stereo[(p + i) * 2 + 1]) * 0.5 * win[i]).collect();
        let energy: f32 = re.iter().map(|x| x * x).sum::<f32>() / N as f32;
        if pdb(energy * 2.0) > -55.0 {
            let mut im = vec![0f32; N];
            fft(&mut re, &mut im);
            for k in 0..N / 2 {
                power[k] += (re[k] * re[k] + im[k] * im[k]) as f64;
            }
            count += 1;
        }
        p += hop;
    }
    if count > 0 {
        let binhz = sr / N as f32;
        let mut band = [0f64; BANDS];
        let (mut num, mut den) = (0f64, 0f64);
        for (k, pw) in power.iter().enumerate().skip(1) {
            let f = k as f32 * binhz;
            num += f as f64 * pw;
            den += pw;
            for bi in 0..BANDS {
                if f >= BAND_EDGES[bi] && f < BAND_EDGES[bi + 1] {
                    band[bi] += pw;
                }
            }
        }
        let tot: f64 = band.iter().sum::<f64>().max(1e-30);
        for bi in 0..BANDS {
            st.bands[bi] = pdb((band[bi] / tot) as f32);
        }
        st.centroid_hz = (num / den.max(1e-30)) as f32;
    }
    st
}

// ── roles and the plan ─────────────────────────────────────────────────────────

#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Role {
    Drums,
    Bass,
    Vocal,
    Lead,
    Chords,
    Pad,
    Fx,
    Other,
}
impl Role {
    pub const ALL: [Role; 8] = [Role::Drums, Role::Bass, Role::Vocal, Role::Lead, Role::Chords, Role::Pad, Role::Fx, Role::Other];
    pub fn name(self) -> &'static str {
        match self {
            Role::Drums => "drums",
            Role::Bass => "bass",
            Role::Vocal => "vocal",
            Role::Lead => "lead",
            Role::Chords => "chords",
            Role::Pad => "pad",
            Role::Fx => "fx",
            Role::Other => "other",
        }
    }
    pub fn parse(s: &str) -> Option<Role> {
        let s = s.trim().to_lowercase();
        Role::ALL.iter().copied().find(|r| r.name() == s).or(match s.as_str() {
            "drum" | "percussion" | "perc" | "kick" | "hats" => Some(Role::Drums),
            "vocals" | "voice" | "vox" | "choir" => Some(Role::Vocal),
            "melody" | "solo" => Some(Role::Lead),
            "chord" | "keys" | "rhythm" | "guitar" | "piano" => Some(Role::Chords),
            "strings" | "atmosphere" | "ambient" => Some(Role::Pad),
            "sfx" | "effects" | "noise" => Some(Role::Fx),
            _ => None,
        })
    }
    /// the level (dBFS RMS where the track plays) this role sits at in a balanced mix
    pub fn target_rms(self) -> f32 {
        match self {
            Role::Drums => -19.0,
            Role::Bass => -19.0,
            Role::Vocal => -18.0,
            Role::Lead => -20.0,
            Role::Chords => -24.0,
            Role::Pad => -27.0,
            Role::Fx => -30.0,
            Role::Other => -24.0,
        }
    }
}

/// A first guess of what a track is: its name and instrument say most, the spectrum the rest.
pub fn guess_role(name: &str, inst_kind: &str, st: &Stat) -> Role {
    let n = name.to_lowercase();
    let has = |ws: &[&str]| ws.iter().any(|w| n.contains(w));
    if inst_kind == "drums" || has(&["drum", "kick", "snare", "hat", "perc", "kit", "cymbal", "tom ", "clap"]) {
        return Role::Drums;
    }
    if has(&["bass", "sub ", "808", "contrabass"]) {
        return Role::Bass;
    }
    if inst_kind == "voice" || has(&["vox", "vocal", "voice", "sing", "choir", "lyric"]) {
        return Role::Vocal;
    }
    if has(&["fx", "riser", "noise", "sweep", "impact", "foley"]) {
        return Role::Fx;
    }
    if has(&["pizz", "stacc", "pluck", "spiccato"]) {
        return Role::Chords;
    }
    if has(&["pad", "string", "swell", "atmos", "ambient", "drone", "sustain", "choir"]) {
        return Role::Pad;
    }
    if has(&["lead", "melody", "solo", "flute", "violin", "trumpet", "sax", "oboe", "clarinet", "synth lead", "whistle"]) {
        return Role::Lead;
    }
    if has(&["chord", "piano", "keys", "guitar", "pluck", "harp", "organ", "stab", "arp", "pizz", "rhodes"]) {
        return Role::Chords;
    }
    if st.centroid_hz > 0.0 && st.centroid_hz < 260.0 {
        return Role::Bass;
    }
    if st.active > 0.0 && st.centroid_hz > 2500.0 {
        return Role::Lead;
    }
    if st.active < 0.35 {
        return Role::Fx;
    }
    Role::Chords
}

#[derive(Clone, Debug)]
pub struct Style {
    /// integrated loudness the master is aimed at (LUFS)
    pub loudness: f32,
    /// -1 darker .. +1 brighter
    pub brightness: f32,
    /// 0..1: how much dynamic control the drums, bass and vocals get
    pub punch: f32,
    /// 0..1: how far crowded instruments are spread
    pub width: f32,
}
impl Default for Style {
    fn default() -> Style {
        Style { loudness: -14.0, brightness: 0.0, punch: 0.5, width: 0.5 }
    }
}

#[derive(Clone, Debug)]
pub struct Change {
    /// track index, -1 = master
    pub track: i32,
    /// gain | pan | eq | comp | master
    pub what: String,
    /// gain: the new fader (linear). pan: the new pan. eq: [low, mid, high] in dB. comp: the threshold in dBFS then ratio. master: the new master volume
    pub values: Vec<f32>,
    pub reason: String,
}

pub struct TrackInfo {
    pub name: String,
    pub volume: f32,
    pub pan: f32,
    pub has_eq: bool,
    pub has_comp: bool,
    pub stat: Stat,
    pub role: Role,
}

/// the moves for this mix
pub fn plan(tracks: &[TrackInfo], master: &Stat, master_volume: f32, style: &Style) -> Vec<Change> {
    let mut out: Vec<Change> = vec![];
    let mut power_before = 0f32;
    let mut power_after = 0f32;
    let mut crowd: Vec<(usize, Role)> = vec![];
    for (i, t) in tracks.iter().enumerate() {
        let s = &t.stat;
        if s.active_rms_db < -80.0 || s.active < 0.02 {
            continue;
        }
        // 1. the level of the track where it plays against the level its role wants
        let mut target = t.role.target_rms();
        if style.punch > 0.6 && matches!(t.role, Role::Drums | Role::Bass) {
            target += 1.0;
        }
        let delta = ((target - s.active_rms_db) * 0.8).clamp(-9.0, 9.0);
        let p = 10f32.powf(s.rms_db / 10.0);
        power_before += p;
        if delta.abs() >= 0.8 {
            let vol = (t.volume * 10f32.powf(delta / 20.0)).clamp(0.02, 1.5);
            out.push(Change { track: i as i32, what: "gain".into(), values: vec![vol], reason: format!("{} sits at {:.0} dB where it plays, a {} wants about {:.0}: fader {:+.1} dB", t.name, s.active_rms_db, t.role.name(), target, delta) });
            power_after += p * 10f32.powf(delta / 10.0);
        } else {
            power_after += p;
        }
        // 2. EQ: a low cut where the track has weight it does not need, a mud cut, brightness
        let (mut low, mut mid, mut high) = (0f32, 0f32, 0f32);
        let mut why: Vec<String> = vec![];
        let wants_bottom = matches!(t.role, Role::Bass | Role::Drums);
        if !wants_bottom && (s.bands[0] > -20.0 || s.bands[1] > -8.0) {
            low = -6.0;
            why.push(format!("low end ({:.0} dB below 250 Hz) that the bass and kick own: low shelf -6 dB", s.bands[1].max(s.bands[0])));
        }
        if matches!(t.role, Role::Chords | Role::Pad | Role::Other) && s.bands[2] > -7.0 {
            mid = -3.0;
            why.push(format!("a build-up at 250-800 Hz ({:.0} dB of its energy): mid -3 dB", s.bands[2]));
        }
        if s.bands[5] > -9.0 && style.brightness <= 0.0 && !matches!(t.role, Role::Drums) {
            high = -3.0;
            why.push(format!("a lot of air above 6 kHz ({:.0} dB): high shelf -3 dB", s.bands[5]));
        } else if style.brightness > 0.3 && matches!(t.role, Role::Lead | Role::Vocal) && s.bands[4] < -14.0 {
            high = 2.0 * style.brightness.min(1.0) + 1.0;
            why.push("dull presence: high shelf up a little for the brighter style".into());
        } else if style.brightness < -0.3 && matches!(t.role, Role::Lead | Role::Chords | Role::Pad) {
            high -= 1.5;
        }
        if low != 0.0 || mid != 0.0 || high != 0.0 {
            out.push(Change { track: i as i32, what: "eq".into(), values: vec![low, mid, high], reason: format!("{}: {}", t.name, why.join("; ")) });
        }
        // 3. dynamics
        if matches!(t.role, Role::Drums | Role::Bass | Role::Vocal) && !t.has_comp && s.crest_db > 15.0 - style.punch * 3.0 {
            let thr = (s.active_rms_db + 3.0).clamp(-40.0, -6.0);
            out.push(Change { track: i as i32, what: "comp".into(), values: vec![thr, 2.0 + style.punch * 2.0], reason: format!("{}: peaks {:.0} dB above its average, a {:.1}:1 compressor keeps it steady", t.name, s.crest_db, 2.0 + style.punch * 2.0) });
        }
        if matches!(t.role, Role::Chords | Role::Pad | Role::Other | Role::Lead) && t.pan.abs() < 0.05 {
            crowd.push((i, t.role));
        }
    }
    // 4. width: the instruments that compete for the middle are spread out, alternately left and right
    if style.width > 0.05 && crowd.len() >= 2 {
        let spread = [0.35f32, -0.35, 0.6, -0.6, 0.2, -0.2, 0.8, -0.8];
        for (k, (i, role)) in crowd.iter().enumerate() {
            // a lone lead stays in the middle
            if *role == Role::Lead && crowd.iter().filter(|(_, r)| *r == Role::Lead).count() == 1 {
                continue;
            }
            let p = spread[k % spread.len()] * style.width.clamp(0.0, 1.0) * 1.6;
            let p = p.clamp(-0.85, 0.85);
            out.push(Change { track: *i as i32, what: "pan".into(), values: vec![p], reason: format!("{} shares the middle with {} other instruments: pan {:+.0}%", tracks[*i].name, crowd.len() - 1, p * 100.0) });
        }
    }
    // 5. the master: the loudness the style wants
    let moved = if power_before > 0.0 { 10.0 * (power_after / power_before).log10() } else { 0.0 };
    let est = master.lufs + moved;
    let want = style.loudness - est;
    if master.lufs > -69.0 && want.abs() >= 0.7 {
        let mv = (master_volume * 10f32.powf(want.clamp(-12.0, 12.0) / 20.0)).clamp(0.2, 1.8);
        out.push(Change { track: -1, what: "master".into(), values: vec![mv], reason: format!("the mix is at about {:.1} LUFS after these moves; the master goes {:+.1} dB toward {:.0} LUFS (the limiter stays on)", est, want.clamp(-12.0, 12.0), style.loudness) });
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sine(hz: f32, amp: f32, secs: f32, sr: f32) -> Vec<f32> {
        (0..(secs * sr) as usize).flat_map(|i| {
            let v = amp * (2.0 * PI * hz * i as f32 / sr).sin();
            [v, v]
        }).collect()
    }

    #[test]
    fn loudness_of_a_reference_sine() {
        // BS.1770: a 997 Hz sine at -20 dBFS peak on both channels reads about -20 LUFS... measured per the standard a full-scale
        // sine is -3.01 LUFS mono-equivalent per channel; stereo identical channels add 3 dB: -23.0 + ... compare relative steps instead
        let a = lufs(&sine(997.0, 0.1, 5.0, 48000.0), 48000.0);
        let b = lufs(&sine(997.0, 0.2, 5.0, 48000.0), 48000.0);
        assert!((b - a - 6.02).abs() < 0.1, "6 dB louder reads 6 dB louder: {a} {b}");
        assert!(a < -20.0 && a > -35.0, "{a}");
        // silence is silent
        assert!(lufs(&vec![0.0; 48000 * 2], 48000.0) <= -70.0);
    }

    #[test]
    fn bands_find_where_the_energy_is() {
        let sr = 44100.0;
        let low = analyze(&sine(100.0, 0.3, 3.0, sr), sr);
        assert!(low.bands[1] > -3.0, "100 Hz is in the bass band: {:?}", low.bands);
        assert!((low.centroid_hz - 100.0).abs() < 30.0, "{}", low.centroid_hz);
        let high = analyze(&sine(8000.0, 0.3, 3.0, sr), sr);
        assert!(high.bands[5] > -3.0, "{:?}", high.bands);
        assert!(low.width_db < -60.0, "identical channels are mono: {}", low.width_db);
        assert!(low.corr > 0.99);
    }

    #[test]
    fn plan_levels_by_role_and_leaves_a_balanced_mix_alone() {
        let quiet_pad = Stat { active_rms_db: -40.0, rms_db: -40.0, active: 0.9, crest_db: 8.0, bands: [-40.0, -20.0, -3.0, -10.0, -20.0, -30.0], lufs: -45.0, ..Stat::default() };
        let loud_bass = Stat { active_rms_db: -10.0, rms_db: -10.0, active: 0.9, crest_db: 18.0, bands: [-6.0, -1.0, -12.0, -25.0, -40.0, -60.0], lufs: -14.0, ..Stat::default() };
        let tracks = vec![
            TrackInfo { name: "Pad".into(), volume: 0.8, pan: 0.0, has_eq: false, has_comp: false, stat: quiet_pad, role: Role::Pad },
            TrackInfo { name: "Bass".into(), volume: 0.8, pan: 0.0, has_eq: false, has_comp: false, stat: loud_bass, role: Role::Bass },
        ];
        let master = Stat { lufs: -9.0, ..Stat::default() };
        let ch = plan(&tracks, &master, 1.0, &Style::default());
        let pad_gain = ch.iter().find(|c| c.track == 0 && c.what == "gain").expect("pad level");
        assert!(pad_gain.values[0] > 0.8, "the quiet pad comes up: {:?}", pad_gain);
        let bass_gain = ch.iter().find(|c| c.track == 1 && c.what == "gain").expect("bass level");
        assert!(bass_gain.values[0] < 0.8, "the loud bass comes down");
        assert!(ch.iter().any(|c| c.track == 0 && c.what == "eq" && c.values[1] < 0.0), "the pad mud is cut");
        assert!(ch.iter().any(|c| c.track == 1 && c.what == "comp"), "the dynamic bass is compressed");
        assert!(ch.iter().any(|c| c.track == -1 && c.what == "master"), "the master heads for the loudness target");
        // a mix already on target changes nothing
        let ok_stat = Stat { active_rms_db: -24.0, rms_db: -24.0, active: 0.9, crest_db: 10.0, bands: [-50.0, -30.0, -8.0, -12.0, -20.0, -30.0], lufs: -14.0, ..Stat::default() };
        let t2 = vec![TrackInfo { name: "Keys".into(), volume: 0.8, pan: 0.3, has_eq: false, has_comp: false, stat: ok_stat, role: Role::Chords }];
        assert!(plan(&t2, &Stat { lufs: -14.0, ..Stat::default() }, 1.0, &Style::default()).is_empty());
    }

    #[test]
    fn roles_come_from_names_and_kinds() {
        let s = Stat::default();
        assert_eq!(guess_role("Kick", "synth", &s), Role::Drums);
        assert_eq!(guess_role("Lead Violin", "synth", &s), Role::Lead);
        assert_eq!(guess_role("Sub Bass", "synth", &s), Role::Bass);
        assert_eq!(guess_role("x", "voice", &s), Role::Vocal);
        assert_eq!(Role::parse("Vocals"), Some(Role::Vocal));
    }
}

← Back to index