Commit 10829fde by PLN (Algolia)

feat(opal26): subtle reverb tail chosen — and the A/B and the release now share one chain

PLN picked SUBTLE from the three-way close A/B. Promoting it exposed two bugs
that would each have shipped something he never heard.

1. The reverb existed TWICE and the copies disagreed. render_release had a
   single 833ms slap (aecho=0.8:0.5:833:0.35) applied to the WHOLE track;
   build_release_joins had a three-tap preset applied to the tail only. Setting
   `reverb_tail_s` would therefore have washed all of REVOLUTION with an echo
   nobody auditioned. The preset table now lives in render_release and the A/B
   builder imports it, so what ships is bit-for-bit the chain he compared.

2. apply_boundaries forwarded edit keys through an ALLOW-list, which silently
   dropped `reverb_preset`; the renderer then saw no preset and fell back to no
   reverb at all. A missing edit is invisible in the output — you get a clean
   render of the wrong thing — so it now deny-lists the keys it consumes and
   forwards everything else.

Caught by inspecting the built filter graph before rendering rather than after,
which is the only reason this is a commit message and not a re-render.

The tail reverb is a split graph, not `-af`: head dry, last 5s through apad then
aecho, concatenated. The apad matters — without it ffmpeg chops the ring at the
final sample, reintroducing the exact hard stop the reverb exists to soften.
Verify accounts for the 2.5s ring, so REVOLUTION is expected at 207.4s not
204.9s rather than being flagged as a mismatch.
parent 40e04cb0
...@@ -97,8 +97,12 @@ def main() -> int: ...@@ -97,8 +97,12 @@ def main() -> int:
"duration": round(end - head_trim.get(t, starts[t]), 3), "duration": round(end - head_trim.get(t, starts[t]), 3),
"bpm": src.get("bpm"), "bpm": src.get("bpm"),
"source": "ear" if verified.get(t, {}).get("start") is not None else "nominal", "source": "ear" if verified.get(t, {}).get("start") is not None else "nominal",
**{k: ed[k] for k in ("fade_in_s", "fade_out_s", "fade_out_to", # Pass through every edit key EXCEPT the ones consumed above. An
"reverb_tail_s") if ed.get(k) is not None}, # allow-list silently dropped `reverb_preset` and the renderer fell
# back to no reverb — a missing edit is invisible in the output, so
# deny-list the four we handle and forward the rest.
**{k: v for k, v in ed.items()
if k not in ("start", "end", "why") and v is not None},
**({"edited": ed.get("why", True)} if ed else {}), **({"edited": ed.get("why", True)} if ed else {}),
}) })
......
...@@ -45,11 +45,7 @@ from build_judge_set import clean_title, ffprobe_dur, peaks # noqa: E402 ...@@ -45,11 +45,7 @@ from build_judge_set import clean_title, ffprobe_dur, peaks # noqa: E402
# is allowed to ring past the end of the record rather than being cut off, which # is allowed to ring past the end of the record rather than being cut off, which
# is the difference between an echo and a truncation. Everything before the tail # is the difference between an echo and a truncation. Everything before the tail
# stays bit-identical to the dry render. # stays bit-identical to the dry render.
CLOSE_AB = { from render_release import CLOSE_AB, RING # noqa: E402 — one table, not two
"subtle": "0.8:0.9:60|140|240:0.28|0.18|0.10",
"more": "0.8:0.9:120|260|420|650:0.38|0.28|0.20|0.13",
}
RING = 2.5 # seconds the reverb is allowed to ring past the last sample
TAIL = 20.0 # seconds of the outgoing track TAIL = 20.0 # seconds of the outgoing track
HEAD = 20.0 # seconds of the incoming track HEAD = 20.0 # seconds of the incoming track
......
...@@ -105,7 +105,9 @@ ...@@ -105,7 +105,9 @@
"start": 4566.47, "start": 4566.47,
"fade_in_s": 2.0, "fade_in_s": 2.0,
"end": 4771.37, "end": 4771.37,
"why": "PLN: 'start revolution at 0:38:9 on a fade of the drums to not be so sudden' -> clip 38.9 = 18.9s into REVOLUTION = master 4566.47. And 'end at 0:15' on the close clip = master 4771.37 (-15.0s). His 'maybe with reverb over last 5s so we hear it slightly echo as last sound' is NOT applied \u2014 offered as an A/B, see reverb_tail_s." "why": "PLN: 'start revolution at 0:38:9 on a fade of the drums to not be so sudden' -> clip 38.9 = 18.9s into REVOLUTION = master 4566.47. And 'end at 0:15' on the close clip = master 4771.37 (-15.0s). 'maybe with reverb over last 5s so we hear it slightly echo as last sound' -> A/B rendered (dry / subtle / more), PLN 2026-08-16: 'pick subtle'. The preset is the SAME table the A/B used, so what ships is what he heard.",
"reverb_tail_s": 5.0,
"reverb_preset": "subtle"
} }
}, },
"_edits_comment": [ "_edits_comment": [
......
...@@ -47,6 +47,17 @@ from build_judge_set import ffprobe_dur # noqa: E402 ...@@ -47,6 +47,17 @@ from build_judge_set import ffprobe_dur # noqa: E402
TOL = 0.05 # seconds a rendered track may differ from its segment TOL = 0.05 # seconds a rendered track may differ from its segment
# Reverb-tail presets. These are THE definition — build_release_joins imports
# them so the A/B PLN auditions is bit-for-bit the chain that ships. They lived
# in two places for one commit and already disagreed (a single 833ms slap here
# vs. a three-tap preset there, and this one washed the whole track instead of
# the tail). One concept, one table.
CLOSE_AB = {
"subtle": "0.8:0.9:60|140|240:0.28|0.18|0.10",
"more": "0.8:0.9:120|260|420|650:0.38|0.28|0.20|0.13",
}
RING = 2.5 # seconds the reverb may ring past the last sample
def sh(cmd, **kw): def sh(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, **kw) return subprocess.run(cmd, capture_output=True, text=True, **kw)
...@@ -73,11 +84,39 @@ def filters(seg: dict) -> list[str]: ...@@ -73,11 +84,39 @@ def filters(seg: dict) -> list[str]:
# to lower the sudden cut") is a gain ramp, so express it directly. # to lower the sudden cut") is a gain ramp, so express it directly.
af.append(f"volume='if(gte(t,{st:.4f}),1-{1-to:.4f}*(t-{st:.4f})/{d:.4f},1)'" af.append(f"volume='if(gte(t,{st:.4f}),1-{1-to:.4f}*(t-{st:.4f})/{d:.4f},1)'"
f":eval=frame") f":eval=frame")
if seg.get("reverb_tail_s"):
af.append(f"aecho=0.8:0.5:{int(float(seg['reverb_tail_s'])*1000/6)}:0.35")
return af return af
def has_reverb(seg: dict) -> bool:
return bool(seg.get("reverb_tail_s")) and seg.get("reverb_preset") in CLOSE_AB
def reverb_complex(seg: dict) -> str:
"""Filter graph applying the ear-chosen reverb to the segment's TAIL only.
A plain `-af aecho` would wet the ENTIRE track — PLN asked for the last few
seconds. So the stream is split: the head passes through dry, the tail gets
`apad` (so the ring has somewhere to go instead of being chopped at the last
sample, which would reintroduce exactly the hard stop the reverb is there to
soften) and then the echo. Pre-filters — fades — apply to both halves, so
they must run BEFORE the split.
"""
dur = seg["end"] - seg["start"]
split = max(0.0, dur - float(seg["reverb_tail_s"]))
pre = ",".join(filters(seg))
chain = f"[0:a]{pre + ',' if pre else ''}asplit=2[p][q];"
return (chain +
f"[p]atrim=0:{split:.4f},asetpts=N/SR/TB[dry];"
f"[q]atrim={split:.4f},asetpts=N/SR/TB,apad=pad_dur={RING},"
f"aecho={CLOSE_AB[seg['reverb_preset']]}[wet];"
f"[dry][wet]concat=n=2:v=0:a=1[out]")
def expected_dur(seg: dict) -> float:
"""A reverb tail makes the track LONGER by its ring — not a mismatch."""
return (seg["end"] - seg["start"]) + (RING if has_reverb(seg) else 0.0)
def sanitize(name: str) -> str: def sanitize(name: str) -> str:
return "".join("_" if c in '/\\:*?"<>|' else c for c in name).strip().strip(".") return "".join("_" if c in '/\\:*?"<>|' else c for c in name).strip().strip(".")
...@@ -124,9 +163,12 @@ def stage_split(spec, segs, seg_path: Path) -> int: ...@@ -124,9 +163,12 @@ def stage_split(spec, segs, seg_path: Path) -> int:
# STREAMINFO, so every track claims the whole master's length. The # STREAMINFO, so every track claims the whole master's length. The
# audio is right and only the header lies, which is worse than a # audio is right and only the header lies, which is worse than a
# loud failure — players show wrong durations and stores reject it. # loud failure — players show wrong durations and stores reject it.
af = filters(s) if has_reverb(s):
if af: cmd += ["-filter_complex", reverb_complex(s), "-map", "[out]"]
cmd += ["-af", ",".join(af)] else:
af = filters(s)
if af:
cmd += ["-af", ",".join(af)]
cmd += ["-c:a", "flac", "-compression_level", "5", str(dest)] cmd += ["-c:a", "flac", "-compression_level", "5", str(dest)]
if sh(cmd).returncode != 0: if sh(cmd).returncode != 0:
print(f" FAIL {dest.name}") print(f" FAIL {dest.name}")
...@@ -146,7 +188,7 @@ def stage_verify(spec, segs) -> int: ...@@ -146,7 +188,7 @@ def stage_verify(spec, segs) -> int:
continue continue
for s, f in zip(segs, files): for s, f in zip(segs, files):
got = ffprobe_dur(f) got = ffprobe_dur(f)
want = s["end"] - s["start"] want = expected_dur(s)
if abs(got - want) >= TOL: if abs(got - want) >= TOL:
print(f" {variant} #{s['track']:>2} {s['title'][:26]:<26} " print(f" {variant} #{s['track']:>2} {s['title'][:26]:<26} "
f"want {want:>8.2f} got {got:>8.2f} MISMATCH") f"want {want:>8.2f} got {got:>8.2f} MISMATCH")
...@@ -169,7 +211,7 @@ def stage_continuous(spec, segs) -> int: ...@@ -169,7 +211,7 @@ def stage_continuous(spec, segs) -> int:
drift. Gaps (trimmed audio, dropped tracks) fall out for free — they are drift. Gaps (trimmed audio, dropped tracks) fall out for free — they are
simply not in any segment. simply not in any segment.
""" """
total = sum(s["end"] - s["start"] for s in segs) total = sum(expected_dur(s) for s in segs)
gaps = [(x["end"], y["start"]) for x, y in zip(segs, segs[1:]) if y["start"] - x["end"] > 0.001] gaps = [(x["end"], y["start"]) for x, y in zip(segs, segs[1:]) if y["start"] - x["end"] > 0.001]
print(f"=== continuous: {len(segs)} segments, {total/60:.1f} min, {len(gaps)} gap(s)") print(f"=== continuous: {len(segs)} segments, {total/60:.1f} min, {len(gaps)} gap(s)")
for a, b in gaps: for a, b in gaps:
...@@ -184,9 +226,12 @@ def stage_continuous(spec, segs) -> int: ...@@ -184,9 +226,12 @@ def stage_continuous(spec, segs) -> int:
pth = Path(td) / f"{i:02d}.flac" pth = Path(td) / f"{i:02d}.flac"
cmd = ["ffmpeg", "-v", "error", "-y", "-ss", f"{seg['start']:.6f}", cmd = ["ffmpeg", "-v", "error", "-y", "-ss", f"{seg['start']:.6f}",
"-to", f"{seg['end']:.6f}", "-i", str(m)] "-to", f"{seg['end']:.6f}", "-i", str(m)]
af = filters(seg) if has_reverb(seg):
if af: cmd += ["-filter_complex", reverb_complex(seg), "-map", "[out]"]
cmd += ["-af", ",".join(af)] else:
af = filters(seg)
if af:
cmd += ["-af", ",".join(af)]
cmd += ["-c:a", "flac", "-compression_level", "3", str(pth)] cmd += ["-c:a", "flac", "-compression_level", "3", str(pth)]
r = sh(cmd) r = sh(cmd)
if r.returncode != 0: if r.returncode != 0:
......
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