second-ear / ear /knowledge.py
sevahu97's picture
Upload folder using huggingface_hub
5459c43 verified
Raw
History Blame Contribute Delete
21.9 kB
"""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]),
}