Commit 9ab93ffb by PLN (Algolia)

fix(master): the first full render was 192 kHz — a bug this project already fixed once

The CosmicFest streaming master completed in spec on every number I had thought
to check: I = -14.0 LUFS, LRA 7.5 from a source 10.6, true peak -1.0, duration
matching the source to the microsecond. It was also 192 kHz and 1.32 GB, from a
44.1 kHz source. 4.3x the size it should be, at a rate no platform wants.

ffmpeg's `loudnorm` oversamples 4x for true-peak detection and leaves the graph at
192 kHz without resampling back. Any chain ending in loudnorm inherits that. So
every chain here now ends `aresample=<source rate>:resampler=soxr`, with the rate
PROBED from the source rather than assumed, and `-ar` set on the output too.
Validated on the 90 s slice: 44100 Hz / 2 ch, duration exact, loudness unchanged.

The part worth writing down is that this is not a new bug. topic_postprod_mastering
has carried a section titled "Every master was 192 kHz (fixed c652d16)" since the
tidal-ears chain hit it, describing this precise failure and its cause. I wrote a
brand-new mastering tool without re-reading the omnibus that exists to prevent
exactly this, and reproduced it. The memory now records the recurrence and two
structural fixes rather than a note-to-self:

Put the resample in the chain BUILDER, so no future chain can omit it. And make
the in-spec gate assert FORMAT, not just loudness — sample rate and channel count
now sit in the pass/fail beside I/TP/LRA, and the output line prints them. A gate
checking only loudness is what certified a 4.3x-oversized master as ✓, and I found
it by ffprobing for an unrelated reason. That is luck, and luck is not a check.

Also fixed an indentation error I introduced while threading the rate through with
a scripted replacement whose search string matched inside deeper indentation than
intended — the kind of mistake that is instant to catch with a parse check and
invisible without one.

Cleaned up: the 192 kHz master deleted, the master unit restarted on the fixed
chain, and cosmic-finish re-queued behind it so the split cannot run against a
file that no longer exists.
parent fdd1418b
......@@ -72,7 +72,8 @@ def tone_chain() -> str:
)
def filter_complex(loudnorm: str, pre_gain: float | None = None) -> str:
def filter_complex(loudnorm: str, pre_gain: float | None = None,
out_rate: int = 44100) -> str:
"""Tone -> M/S bass-mono -> glue -> [staged push] -> loudnorm.
`pre_gain` is what makes the club target reachable, and getting it wrong is
......@@ -103,15 +104,26 @@ def filter_complex(loudnorm: str, pre_gain: float | None = None) -> str:
"acompressor=threshold=-14dB:ratio=2:attack=10:release=150:makeup=1,"
"alimiter=limit=0.891:attack=5:release=100:level=disabled[push]")
last = "push"
parts.append(f"[{last}]{loudnorm}[out]")
# ffmpeg's loudnorm declares its output at 192 kHz, because true-peak
# detection oversamples 4x and the filter does not resample back. Left alone
# it silently produces a 192 kHz master: the first CosmicFest streaming
# render came out correct on loudness (I -14.0, LRA 7.5, peak -1.0) and
# 1.26 GB at 192 kHz from a 44.1 kHz source — 4.3x the size it should be,
# at a rate no platform wants. topic_postprod_mastering already lists this
# under "loudnorm traps"; I hit it anyway by not re-reading it.
#
# So every chain ends by resampling back to the SOURCE rate. soxr because
# the default resampler is not worth the pennies saved on a mastering pass.
parts.append(f"[{last}]{loudnorm},aresample={out_rate}:resampler=soxr[out]")
return ";".join(parts)
def measure(src: Path, pre_gain: float | None = None) -> dict:
def measure(src: Path, pre_gain: float | None = None,
out_rate: int = 44100) -> dict:
"""loudnorm pass 1. Two-pass is not optional: pass 2 needs measured_* to
apply ONE fixed offset (linear=true). A single-pass loudnorm rides the gain
and smears transients — the exact thing -14 LUFS is chosen to preserve."""
fc = filter_complex("loudnorm=print_format=json", pre_gain)
fc = filter_complex("loudnorm=print_format=json", pre_gain, out_rate)
p = subprocess.run(["ffmpeg", "-nostdin", "-hide_banner", "-i", str(src),
"-filter_complex", fc, "-map", "[out]", "-f", "null", "-"],
capture_output=True, text=True)
......@@ -123,14 +135,15 @@ def measure(src: Path, pre_gain: float | None = None) -> dict:
def render(src: Path, dest: Path, target: dict, m: dict,
pre_gain: float | None = None) -> None:
pre_gain: float | None = None, out_rate: int = 44100) -> None:
ln = (f"loudnorm=I={target['I']}:TP={target['TP']}:LRA={target['LRA']}:linear=true"
f":measured_I={m['input_i']}:measured_TP={m['input_tp']}"
f":measured_LRA={m['input_lra']}:measured_thresh={m['input_thresh']}"
f":offset={m['target_offset']}")
dest.parent.mkdir(parents=True, exist_ok=True)
cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-v", "error", "-y", "-i", str(src),
"-filter_complex", filter_complex(ln, pre_gain), "-map", "[out]",
"-filter_complex", filter_complex(ln, pre_gain, out_rate),
"-map", "[out]", "-ar", str(out_rate),
# 24-bit: the source is lossy, but the chain's gain and EQ produce
# values that deserve the headroom, and every downstream split
# re-encodes from this file.
......@@ -152,7 +165,13 @@ def verify(dest: Path) -> dict:
def grab(label):
m = re.search(rf"{label}:\s*(-?[\d.]+)", tail)
return float(m.group(1)) if m else None
return {"I": grab("I"), "LRA": grab("LRA"), "peak": grab("Peak")}
fmt = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "stream=sample_rate,channels",
"-of", "csv=p=0", str(dest)], capture_output=True, text=True).stdout.strip()
sr, ch = (fmt.split(",") + ["", ""])[:2]
return {"I": grab("I"), "LRA": grab("LRA"), "peak": grab("Peak"),
"sample_rate": int(sr) if sr.isdigit() else None,
"channels": int(ch) if ch.isdigit() else None}
def main() -> int:
......@@ -171,13 +190,18 @@ def main() -> int:
root = Path(a.out_dir) if a.out_dir else Path(spec["releaseRoot"])
root.mkdir(parents=True, exist_ok=True)
print(f"source {src.name}")
out_rate = int(subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "stream=sample_rate",
"-of", "csv=p=0", str(src)],
capture_output=True, text=True).stdout.strip().split("\n")[0])
print(f"source {src.name} ({out_rate} Hz — every render resamples back to this)")
print(f"tone {tone_chain()}")
print(f" bass-mono (mid/side) <{BASS_MONO_F} Hz · glue 1.5:1 @ -22 dB\n")
report = {}
# One tone-only measurement serves every variant: the chain up to the push
# is identical, so measuring it per variant would burn a full pass each.
base = measure(src)
base = measure(src, None, out_rate)
print(f"tone-only: I={base['input_i']} TP={base['input_tp']} LRA={base['input_lra']}\n")
for v in variants:
......@@ -201,11 +225,11 @@ def main() -> int:
# better-sounding render rather than clipping our way to a number.
attempts, got, m, best = [], None, base, None
for i in range(3):
m = measure(src, pre_gain) if staged else base
m = measure(src, pre_gain, out_rate) if staged else base
if staged:
print(f" attempt {i+1}: pre-gain {pre_gain:+.1f} dB -> "
f"I={m['input_i']} TP={m['input_tp']} LRA={m['input_lra']}")
render(src, dest, t, m, pre_gain)
render(src, dest, t, m, pre_gain, out_rate)
got = verify(dest)
lra_out = got["LRA"] if got["LRA"] is not None else 0.0
hit = got["I"] is not None and abs(got["I"] - t["I"]) <= 0.5
......@@ -224,9 +248,12 @@ def main() -> int:
pre_gain += (t["I"] - got["I"]) # the shortfall is the missing gain
ok = (got["I"] is not None and abs(got["I"] - t["I"]) <= 0.5
and got["peak"] is not None and got["peak"] <= t["TP"] + 0.3)
and got["peak"] is not None and got["peak"] <= t["TP"] + 0.3
# Loudness alone passed a 192 kHz master once. Format is spec too.
and got["sample_rate"] == out_rate and got["channels"] == 2)
squash = (float(base["input_lra"]) - got["LRA"]) if got["LRA"] is not None else None
print(f" out: I={got['I']} LRA={got['LRA']} peak={got['peak']} "
f"{got['sample_rate']}Hz/{got['channels']}ch "
f"{'✓' if ok else '✗ MISSED TARGET'}"
+ (f" · dynamics lost {squash:.1f} LU" if squash and squash > 1 else ""))
print(f" {dest}")
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment