Commit bc7d60c1 by PLN (Algolia)

feat(bounds): reverb-tail A/B for the record's close

PLN: 'maybe with reverb over last 5s so we hear it slightly echo as last sound'.
Rendered as an A/B rather than baked in — an echo on the final sound of a record
is a taste call, and 'maybe' is not a decision.

Two flavours (subtle / more) plus the dry render, so the comparison is three
clips in the same view. The wet part is the TAIL ONLY: a global reverb would
wash the whole ending, and everything before the last 5s stays identical to dry.
The tail gets apad before the echo so the ring has somewhere to go — without it
ffmpeg chops at the last sample and the 'echo as last sound' becomes another
hard cut, which is the thing being fixed.
parent 9863a727
...@@ -40,6 +40,17 @@ HERE = Path(__file__).parent ...@@ -40,6 +40,17 @@ HERE = Path(__file__).parent
sys.path.insert(0, str(HERE)) sys.path.insert(0, str(HERE))
from build_judge_set import clean_title, ffprobe_dur, peaks # noqa: E402 from build_judge_set import clean_title, ffprobe_dur, peaks # noqa: E402
# Reverb flavours for the closing A/B. PLN asked for "reverb over last 5s so we
# hear it slightly echo as last sound" — so the wet part is the TAIL ONLY, and it
# 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
# stays bit-identical to the dry render.
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 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
PEAKS_N = 900 PEAKS_N = 900
...@@ -53,6 +64,35 @@ def cut(src: Path, out: Path, ss: float, dur: float) -> bool: ...@@ -53,6 +64,35 @@ def cut(src: Path, out: Path, ss: float, dur: float) -> bool:
return r.returncode == 0 return r.returncode == 0
def close_variant(src: Path, out: Path, ss: float, dur: float,
wet_s: float, echo: str | None) -> bool:
"""The record's last `dur` seconds, optionally with a reverb tail.
Split / process / concat rather than one global filter: a reverb applied to
the whole clip would wash the entire ending, and PLN asked for the tail. The
dry head is passed through untouched, the last `wet_s` seconds get the echo,
and `apad` gives the ring somewhere to go — without it the tail is chopped at
the last sample and the "echo as last sound" becomes another hard cut.
"""
if echo is None:
return cut(src, out, ss, dur)
split = max(0.0, dur - wet_s)
fc = (
f"[0:a]atrim=0:{split:.4f},asetpts=N/SR/TB[dry];"
f"[0:a]atrim={split:.4f},asetpts=N/SR/TB,apad=pad_dur={RING},"
f"aecho={echo}[wet];"
f"[dry][wet]concat=n=2:v=0:a=1[out]"
)
r = subprocess.run(
["ffmpeg", "-v", "error", "-y", "-ss", f"{ss:.4f}", "-t", f"{dur:.4f}",
"-i", str(src), "-filter_complex", fc, "-map", "[out]",
"-ac", "2", "-c:a", "flac", "-compression_level", "3", str(out)],
capture_output=True)
if r.returncode != 0:
print(f" ! {out.name}: {r.stderr.decode()[:200]}")
return r.returncode == 0
def concat(parts: list[Path], out: Path) -> bool: def concat(parts: list[Path], out: Path) -> bool:
"""Join the pieces with ffmpeg's concat DEMUXER, not the filter. """Join the pieces with ffmpeg's concat DEMUXER, not the filter.
...@@ -77,6 +117,9 @@ def main() -> int: ...@@ -77,6 +117,9 @@ def main() -> int:
ap.add_argument("--tracks", required=True) ap.add_argument("--tracks", required=True)
ap.add_argument("--tail", type=float, default=TAIL) ap.add_argument("--tail", type=float, default=TAIL)
ap.add_argument("--head", type=float, default=HEAD) ap.add_argument("--head", type=float, default=HEAD)
ap.add_argument("--close-ab", action="store_true",
help="also render reverb-tail variants of the closing clip")
ap.add_argument("--wet", type=float, default=5.0, help="seconds of reverb tail")
ap.add_argument("--out") ap.add_argument("--out")
a = ap.parse_args() a = ap.parse_args()
...@@ -136,18 +179,28 @@ def main() -> int: ...@@ -136,18 +179,28 @@ def main() -> int:
}) })
# --- closing: does the record END, or merely stop # --- closing: does the record END, or merely stop
cl = joins_dir / "99-close.flac"
tail_len = min(a.tail + 10, durs[-1]) tail_len = min(a.tail + 10, durs[-1])
if cut(files[-1], cl, max(0.0, durs[-1] - tail_len), tail_len): ss = max(0.0, durs[-1] - tail_len)
variants = [("dry", None)] + ([(k, CLOSE_AB[k]) for k in CLOSE_AB] if a.close_ab else [])
for i, (name, echo) in enumerate(variants):
cl = joins_dir / f"99{i}-close-{name}.flac"
if not close_variant(files[-1], cl, ss, tail_len, a.wet, echo):
continue
rows.append({ rows.append({
"track": 99, "title": f"CLOSE · {title_of(files[-1])}", "track": 99 + i,
"title": f"CLOSE · {name.upper()}" + (f" · {a.wet:.0f}s reverb tail"
if echo else " (as rendered)"),
"fromTitle": title_of(files[-1]), "nominal": tail_len, "fromTitle": title_of(files[-1]), "nominal": tail_len,
"clipStart": 0.0, "clipEnd": ffprobe_dur(cl), "clipStart": 0.0, "clipEnd": ffprobe_dur(cl),
"url": f"/audio/joins/{cl.name}", "url": f"/audio/joins/{cl.name}",
"peaks": [round(x, 4) for x in peaks(cl, PEAKS_N)], "peaks": [round(x, 4) for x in peaks(cl, PEAKS_N)],
"marks": [{"key": "join", "label": "record ends", "t": tail_len}], "marks": [{"key": "join", "label": "record ends", "t": tail_len}],
"settled": False, "noCandidate": False, "settled": False, "noCandidate": False,
"note": "Last sound of the album.", "note": ("Last sound of the album, dry — what ships today."
if echo is None else
f"A/B option '{name}': the last {a.wet:.0f}s get an echo that "
f"rings {RING}s past the final sample. Everything before the "
f"tail is identical to DRY."),
}) })
doc = {"gig": f"{spec['gig']}-release", "title": f"{spec.get('title','')} — release check", doc = {"gig": f"{spec['gig']}-release", "title": f"{spec.get('title','')} — release check",
......
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