Spaces:
Sleeping
Sleeping
File size: 21,921 Bytes
5459c43 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 | """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]),
}
|