"""The opinionated part. Measurements are neutral; this module is where the taste lives. Each rule turns a number into a diagnosis with a *reason* and a *move*, scoped to what the material is supposed to be (a master behaves nothing like a solo'd sub). Targets are expressed as ratios between bands wherever possible. Absolute band fractions drift with arrangement density; ratios like "low mid against mid" survive it and are closer to how an engineer actually judges a balance. """ from __future__ import annotations from dataclasses import dataclass, asdict, field from . import dsp # -------------------------------------------------------------------------- # context profiles # -------------------------------------------------------------------------- @dataclass class Profile: name: str lufs: tuple[float, float] crest: tuple[float, float] sub_vs_bass: tuple[float, float] mud: tuple[float, float] harsh: tuple[float, float] air: tuple[float, float] tilt: tuple[float, float] width: tuple[float, float] bpm_hint: str voice: str GENRES: dict[str, Profile] = { "Dubstep / Riddim": Profile( "Dubstep / Riddim", lufs=(-9.0, -5.0), crest=(6.0, 11.0), sub_vs_bass=(-4.0, 2.0), mud=(-5.0, 0.0), harsh=(-6.0, -1.0), air=(-14.0, -6.0), tilt=(2.0, 14.0), width=(-14.0, -4.0), bpm_hint="140 / 150 (half-time feel at 70–75)", voice="Sub weight and mid aggression are the product. Protect the " "20–60 Hz shelf, keep it mono, and make room at 200–400 Hz so " "the growls read as bite instead of mud.", ), "Melodic Dubstep / Future Bass": Profile( "Melodic Dubstep / Future Bass", lufs=(-10.0, -6.0), crest=(7.0, 12.0), sub_vs_bass=(-5.0, 1.0), mud=(-6.0, -1.0), harsh=(-5.0, 0.0), air=(-11.0, -4.0), tilt=(-1.0, 9.0), width=(-11.0, -2.0), bpm_hint="140–150", voice="Wide, bright and emotional — but the chord stack is the usual " "mud source. Keep supersaws high-passed and let the sub own " "everything under 80 Hz alone.", ), "Drum & Bass": Profile( "Drum & Bass", lufs=(-9.0, -5.0), crest=(7.0, 12.0), sub_vs_bass=(-3.0, 3.0), mud=(-6.0, -1.0), harsh=(-5.0, 0.0), air=(-12.0, -5.0), tilt=(1.0, 12.0), width=(-13.0, -4.0), bpm_hint="172–176", voice="Breaks need transient survival. Watch crest — if it drops " "under 7 dB the drums stop moving air even though the meter " "says loud.", ), "House / Techno": Profile( "House / Techno", lufs=(-10.0, -6.0), crest=(7.0, 12.0), sub_vs_bass=(-6.0, 0.0), mud=(-6.0, -1.0), harsh=(-6.0, -1.0), air=(-12.0, -5.0), tilt=(0.0, 10.0), width=(-13.0, -4.0), bpm_hint="120–135", voice="The kick is the anchor. Everything else earns its place around " "it; sidechain depth matters more than EQ here.", ), "Trap / Hip-Hop": Profile( "Trap / Hip-Hop", lufs=(-10.0, -6.0), crest=(7.0, 13.0), sub_vs_bass=(-2.0, 5.0), mud=(-6.0, -1.0), harsh=(-6.0, -1.0), air=(-13.0, -5.0), tilt=(3.0, 15.0), width=(-16.0, -5.0), bpm_hint="130–150 (half-time 65–75)", voice="808 and vocal are the two things that must never fight. If " "they share 100–250 Hz, one of them has to move.", ), "Pop / Vocal-led": Profile( "Pop / Vocal-led", lufs=(-11.0, -7.0), crest=(8.0, 13.0), sub_vs_bass=(-8.0, -1.0), mud=(-7.0, -2.0), harsh=(-4.0, 1.0), air=(-10.0, -3.0), tilt=(-4.0, 6.0), width=(-12.0, -3.0), bpm_hint="90–130", voice="The vocal is the mix. Every decision is 'does this help the " "voice sit forward without getting harsh at 3 kHz'.", ), "Ambient / Cinematic": Profile( "Ambient / Cinematic", lufs=(-20.0, -13.0), crest=(11.0, 22.0), sub_vs_bass=(-8.0, 2.0), mud=(-6.0, 0.0), harsh=(-8.0, -2.0), air=(-12.0, -3.0), tilt=(-4.0, 8.0), width=(-9.0, 0.0), bpm_hint="free / rubato", voice="Dynamic range is the point. Loudness rules invert here — a " "high LRA is a feature, not a fault.", ), "Reference master (streaming)": Profile( "Reference master (streaming)", lufs=(-15.0, -12.0), crest=(8.0, 14.0), sub_vs_bass=(-7.0, 0.0), mud=(-6.0, -1.0), harsh=(-5.0, 0.0), air=(-12.0, -4.0), tilt=(-2.0, 8.0), width=(-12.0, -3.0), bpm_hint="—", voice="Targets Spotify/Apple normalisation. Anything louder than " "-12 LUFS just gets turned down with the transients already " "spent.", ), } # What is being listened to. Changes which rules are even allowed to fire. SOURCES = [ "Full mix / master", "Drum bus", "Bass / 808", "Lead / synth", "Vocal", "Pad / atmosphere", ] _SEVERITY_RANK = {"critical": 0, "warn": 1, "note": 2, "good": 3} @dataclass class Diagnosis: id: str severity: str # critical | warn | note | good headline: str evidence: str why: str move: str band: str = "" freq: float = 0.0 amount: float = 0.0 tags: list[str] = field(default_factory=list) def to_dict(self) -> dict: return asdict(self) def _below(value: float, window: tuple[float, float]) -> float: return window[0] - value def _above(value: float, window: tuple[float, float]) -> float: return value - window[1] # -------------------------------------------------------------------------- # rules # -------------------------------------------------------------------------- def diagnose(rep: dsp.Report, genre: str, source: str) -> list[Diagnosis]: p = GENRES.get(genre, GENRES["Dubstep / Riddim"]) out: list[Diagnosis] = [] is_master = source == "Full mix / master" r = rep.ratios st = rep.stereo # -- headroom ----------------------------------------------------------- if rep.true_peak > 0.0: out.append(Diagnosis( "clipping", "critical", f"Clipping at {rep.true_peak:+.1f} dBTP", f"True peak {rep.true_peak:+.2f} dBTP, sample peak {rep.sample_peak:+.2f} dBFS", "Inter-sample peaks above 0 dBTP distort in every lossy encoder even " "when the file itself looks clean. Spotify and YouTube both " "reconstruct those peaks and clip them.", "Pull the master output down until true peak lands at -1.0 dBTP. If " "that costs you loudness, take it out of the limiter's ceiling, not " "the mix gain.", tags=["headroom"], )) elif rep.true_peak > -0.3: out.append(Diagnosis( "no_headroom", "warn", f"Only {abs(rep.true_peak):.1f} dB of true-peak headroom", f"True peak {rep.true_peak:+.2f} dBTP", "Anything above -1 dBTP is a coin flip after MP3/AAC encoding.", "Set the limiter ceiling to -1.0 dBTP and leave it there.", tags=["headroom"], )) # -- loudness ----------------------------------------------------------- if rep.lufs_i > -60.0 and rep.duration > 3.0: over = _above(rep.lufs_i, p.lufs) under = _below(rep.lufs_i, p.lufs) if over > 1.5 and is_master: out.append(Diagnosis( "too_loud", "warn", f"Pushed {over:.1f} LU past the {p.name} window", f"Integrated {rep.lufs_i:.1f} LUFS vs target {p.lufs[0]:.0f}…{p.lufs[1]:.0f}", "Streaming platforms normalise down to roughly -14 LUFS. Loudness " "above the target does not get louder on playback — it only " "arrives with less transient left.", f"Back the limiter off by {over:.1f} dB and recover the " "perceived weight with saturation on the low mids instead.", amount=over, tags=["loudness"], )) elif under > 3.0 and is_master: out.append(Diagnosis( "too_quiet", "note", f"{under:.1f} LU under the {p.name} window", f"Integrated {rep.lufs_i:.1f} LUFS vs target {p.lufs[0]:.0f}…{p.lufs[1]:.0f}", "Not a problem in itself, but it will feel small next to " "references in a playlist.", "Add gain into the limiter in 1 dB steps and stop the moment " "crest factor drops below the genre floor.", amount=under, tags=["loudness"], )) # -- dynamics ----------------------------------------------------------- if rep.duration > 2.0: if rep.crest < p.crest[0] - 1.0: out.append(Diagnosis( "over_limited", "critical" if rep.crest < p.crest[0] - 3 else "warn", f"Crest factor {rep.crest:.1f} dB — the transients are gone", f"Peak-to-RMS {rep.crest:.1f} dB, PSR {rep.psr:.1f} dB, " f"target {p.crest[0]:.0f}…{p.crest[1]:.0f} dB", "Under roughly 6 dB of crest the drums stop reading as hits and " "start reading as level. This is the single most common way a " "loud master ends up sounding smaller than a quiet one.", "Take 2–3 dB off the limiter, then reclaim density with parallel " "compression on the drum bus rather than more ceiling.", amount=p.crest[0] - rep.crest, tags=["dynamics"], )) elif rep.crest > p.crest[1] + 3.0 and is_master: out.append(Diagnosis( "uncontrolled", "note", f"Crest factor {rep.crest:.1f} dB — peaks are running free", f"Peak-to-RMS {rep.crest:.1f} dB vs target {p.crest[0]:.0f}…{p.crest[1]:.0f}", "A few isolated peaks are eating all the headroom, so the body " "of the track sits far lower than it could.", "Clip or soft-limit the worst transients before the bus " "compressor so the compressor stops chasing them.", tags=["dynamics"], )) if rep.lra > 12.0 and is_master and genre != "Ambient / Cinematic": out.append(Diagnosis( "loud_range", "note", f"Loudness range {rep.lra:.1f} LU — sections are uneven", f"LRA {rep.lra:.1f} LU", "Large section-to-section swings make club or car playback " "feel like it keeps changing volume.", "Automate section gain before the bus, or set a slow 1.5:1 " "compressor across the master to glue the arrangement.", tags=["dynamics"], )) # -- low end ------------------------------------------------------------ sub_vs_bass = r.get("sub_vs_bass", 0.0) if _above(sub_vs_bass, p.sub_vs_bass) > 2.0: amt = _above(sub_vs_bass, p.sub_vs_bass) out.append(Diagnosis( "sub_heavy", "warn", f"Sub is {amt:.1f} dB hotter than it should be", f"20–60 Hz sits {sub_vs_bass:+.1f} dB against 60–120 Hz " f"(target {p.sub_vs_bass[0]:+.0f}…{p.sub_vs_bass[1]:+.0f})", "Energy this low is felt, not heard. On laptop and phone speakers " "it vanishes entirely, so the track reads thin there while eating " "all the limiter's work on a big system.", f"High-pass the sub at 28–30 Hz and shelve 20–60 Hz down {amt:.1f} dB. " "Check it on a phone speaker before trusting the change.", band="sub", freq=45.0, amount=amt, tags=["lowend"], )) elif _below(sub_vs_bass, p.sub_vs_bass) > 3.0 and is_master: out.append(Diagnosis( "no_sub", "warn", "No real sub under the track", f"20–60 Hz sits {sub_vs_bass:+.1f} dB against 60–120 Hz " f"(target {p.sub_vs_bass[0]:+.0f}…{p.sub_vs_bass[1]:+.0f})", "The weight you are hearing is all upper bass. It will feel " "adequate on monitors and completely gutless on a club rig.", "Layer a clean sine sub an octave under the bass, mono, and " "sidechain it hard to the kick.", band="sub", freq=45.0, tags=["lowend"], )) # -- mud / boxiness ----------------------------------------------------- mud = r.get("mud", 0.0) mud_over = _above(mud, p.mud) if mud_over > 1.0: sev = "critical" if mud_over > 4.0 else "warn" out.append(Diagnosis( "mud", sev, f"Low mids are {mud_over:.1f} dB thick", f"120–350 Hz sits {mud:+.1f} dB against 350 Hz–1.5 kHz " f"(target {p.mud[0]:+.0f}…{p.mud[1]:+.0f})", "This is the band every instrument has energy in and nobody needs. " "It builds up silently across a layered arrangement and eats the " "clarity of everything above it.", f"Narrow bell cut of {min(mud_over, 5.0):.1f} dB at 250 Hz " "(Q ≈ 1.4) on the offending bus. If the cut makes the track thin, " "the problem is the arrangement, not the EQ — mute layers until " "you find which one owns 250 Hz.", band="lowmid", freq=250.0, amount=min(mud_over, 5.0), tags=["mud", "clarity"], )) elif _below(mud, p.mud) > 3.0: out.append(Diagnosis( "hollow", "note", "Scooped low mids — sounds hi-fi, plays thin", f"120–350 Hz sits {mud:+.1f} dB against the mids " f"(target {p.mud[0]:+.0f}…{p.mud[1]:+.0f})", "Over-cutting 200–400 Hz is the classic overcorrection. It sounds " "clean in isolation and disappears in a mix or a playlist.", "Give 2 dB back with a wide bell at 220 Hz (Q ≈ 0.7).", band="lowmid", freq=220.0, tags=["clarity"], )) # -- harshness / dullness ---------------------------------------------- harsh = r.get("harsh", 0.0) if _above(harsh, p.harsh) > 1.5: amt = min(_above(harsh, p.harsh), 4.0) out.append(Diagnosis( "harsh", "warn", f"Upper mids {_above(harsh, p.harsh):.1f} dB hot — this will fatigue", f"1.5–4 kHz sits {harsh:+.1f} dB against the mids " f"(target {p.harsh[0]:+.0f}…{p.harsh[1]:+.0f})", "The ear is most sensitive right here. Excess reads as 'loud and " "exciting' for thirty seconds and as 'painful' for three minutes.", f"Dynamic EQ at 3 kHz, {amt:.1f} dB of downward movement, only when " "it crosses threshold — a static cut here kills presence.", band="himid", freq=3000.0, amount=amt, tags=["harsh", "tone"], )) air = r.get("air", 0.0) if _below(air, p.air) > 3.0: out.append(Diagnosis( "dull", "note", "Top end is closed in", f"8–16 kHz sits {air:+.1f} dB against the mids " f"(target {p.air[0]:+.0f}…{p.air[1]:+.0f})", "Nothing above 8 kHz means no sense of air or space, which usually " "reads to listeners as 'demo' rather than 'dark'.", "High shelf +2 dB at 10 kHz. If it turns harsh instead of open, " "the top end is distortion artefacts, not content — fix the source.", band="air", freq=10000.0, tags=["tone"], )) elif _above(air, p.air) > 3.0: out.append(Diagnosis( "brittle", "warn", "Top end is brittle", f"8–16 kHz sits {air:+.1f} dB against the mids " f"(target {p.air[0]:+.0f}…{p.air[1]:+.0f})", "Usually the fingerprint of an exciter or aggressive limiting " "rather than real content.", "High shelf -2 dB at 9 kHz and back off whatever is generating it.", band="air", freq=9000.0, tags=["tone"], )) # -- stereo ------------------------------------------------------------- corr = st.get("correlation", 1.0) sub_corr = st.get("sub_correlation", 1.0) if corr < -0.1: out.append(Diagnosis( "out_of_phase", "critical", f"Phase correlation {corr:+.2f} — this cancels in mono", f"L/R correlation {corr:+.2f}, mono fold loses " f"{abs(st.get('mono_loss_db', 0.0)):.1f} dB", "Negative correlation means the channels fight each other. Any mono " "playback — club sub, phone, most PA systems — loses that material.", "Find the widener or the inverted duplicate causing it. Check every " "stereo effect's phase before reaching for a corrector.", tags=["stereo", "phase"], )) if sub_corr < 0.85: out.append(Diagnosis( "stereo_sub", "critical" if sub_corr < 0.6 else "warn", f"Low end is not mono (correlation {sub_corr:+.2f} below 120 Hz)", f"20–120 Hz correlation {sub_corr:+.2f}", "Stereo information below 120 Hz cancels unpredictably on club " "systems and wastes cutter headroom on vinyl. There is no upside.", "Utility with Bass Mono engaged at 120 Hz, before the limiter.", band="sub", freq=120.0, tags=["stereo", "phase", "lowend"], )) width = st.get("width_db", -12.0) if _below(width, p.width) > 4.0 and is_master: out.append(Diagnosis( "narrow", "note", "Image is narrow", f"Side/mid ratio {width:+.1f} dB (target {p.width[0]:+.0f}…{p.width[1]:+.0f})", "Almost everything is centred, so the mix has no depth to move " "into when the drop hits.", "Widen the elements that can afford it — reverb returns, pads, hat " "layers — not the bass and not the kick.", tags=["stereo"], )) elif _above(width, p.width) > 4.0: out.append(Diagnosis( "over_wide", "warn", "Over-widened", f"Side/mid ratio {width:+.1f} dB (target {p.width[0]:+.0f}…{p.width[1]:+.0f})", "More side energy than mid means the centre is hollow and the mono " "fold will be a different mix entirely.", "Pull the widener back and check the mono fold before committing.", tags=["stereo"], )) # -- source-specific ---------------------------------------------------- if source == "Bass / 808" and rep.bands.get("air", -99.0) > -12.0: out.append(Diagnosis( "bass_top", "note", "Bass is carrying a lot of top end", f"8–16 kHz at {rep.bands['air']:.1f} dB relative", "Fine if it is intentional grit; a problem if it is aliasing from " "distortion or a resampled sample.", "Low-pass at 12 kHz and A/B. If nothing is lost, it was noise.", tags=["bass"], )) if source == "Vocal" and _above(r.get("harsh", 0.0), (-6.0, 0.0)) > 1.0: out.append(Diagnosis( "sibilance", "warn", "Sibilance range is hot", f"1.5–4 kHz {r.get('harsh', 0.0):+.1f} dB against the mids", "Reads as 'ess' and 'tuh' jumping out of the line.", "De-esser at 6–8 kHz, 3 dB of range, then a dynamic bell at 3 kHz " "for the hardness underneath it.", band="himid", freq=6500.0, tags=["vocal"], )) if source == "Drum bus" and rep.crest < 8.0 and rep.duration > 2.0: out.append(Diagnosis( "flat_drums", "warn", f"Drum bus crest is only {rep.crest:.1f} dB", f"Peak-to-RMS {rep.crest:.1f} dB", "Drums are the one bus where crest is the whole point.", "Slower attack on the bus compressor (30 ms) so the hits get " "through before gain reduction starts.", tags=["drums", "dynamics"], )) if not out: out.append(Diagnosis( "clean", "good", "Nothing is fighting you", f"Balance, dynamics and phase all inside the {p.name} window", "The measurable problems are absent, which means the remaining " "decisions are taste, not repair.", "Move on to arrangement and sound selection.", tags=["ok"], )) out.sort(key=lambda d: (_SEVERITY_RANK.get(d.severity, 9), -abs(d.amount))) return out def headline_verdict(rep: dsp.Report, diags: list[Diagnosis]) -> tuple[str, str]: """One-line status for the live meter strip.""" if not diags: return "listening", "ok" worst = diags[0] if worst.severity == "critical": return worst.headline, "critical" if worst.severity == "warn": return worst.headline, "warn" if worst.severity == "good": return "Balance is holding", "ok" return worst.headline, "note" def profile_for(genre: str) -> Profile: return GENRES.get(genre, GENRES["Dubstep / Riddim"]) def band_target_windows(genre: str) -> dict[str, tuple[float, float]]: """Approximate absolute band windows, for the ghost range behind each bar. Drawing only — the rules themselves never fire on these numbers. """ p = profile_for(genre) a = -6.5 # nominal mid-band anchor for a full-range balance return { "sub": (a + p.sub_vs_bass[0] - 1.0, a + p.sub_vs_bass[1] + 2.0), "bass": (a - 4.0, a + 2.0), "lowmid": (a + p.mud[0], a + p.mud[1]), "mid": (a - 2.5, a + 2.5), "himid": (a + p.harsh[0], a + p.harsh[1]), "presence": (a + p.harsh[0] - 4.0, a + p.harsh[1] - 2.0), "air": (a + p.air[0], a + p.air[1]), }