Commit 7b9dd178 by PLN (Algolia)

feat(postprod): per-track trims and fades — the seam pass, from PLN's ears

The release check turned up four fixes and one boundary move. The pipeline could
express none of them, because it only knew about boundaries. It now separates
two things that look alike and are not:

  `verified`  a BOUNDARY: moving it shifts both neighbours (butt-joined set)
  `edits`     a per-track TRIM/FADE: the audio is DROPPED, a gap opens, and the
              continuous mix must skip it too

Confusing them silently hands a neighbour a second of someone else's silence —
which is the exact artefact these edits exist to remove.

Applied, all verified by ratio against the unfiltered master rather than by
reading back the command line:

  #1  Bombe      end -1.0s   PLN: "trim silence to 1s silence from 00:17".
                             Measured: audio ends 527.90, then 2.0s of -60..-100.
  #2  WAP        end -45ms   PLN heard "a sound just at seam ... is an error".
                 +30ms fade  The error was at WAP's END, not Drums' head: the
                             last 40ms jump -57 -> -15 dBFS, a sound truncated
                             mid-decay. An 80ms fade only reached -26.8 dB —
                             hidden, not gone — so the burst is now dropped
                             outright and the fade only guards the new edge.
  #12 Mafia      +1.66s      boundary MOVE: heard as a seam, it wants more Ghosts
  #13 CRIME      end -10.0s  PLN: "cut at 0:10 its better. have fade from 0:09 to
                 duck to 50% 0:10 from 100 to 50% to lower the sudden cut"
  #15 REVOLUTION start +18.9s PLN: "start revolution at 0:38:9 on a fade of the
                 2s fade-in   drums to not be so sudden"
                 end -15.0s   PLN: "end at 0:15"

Measured gain curves confirm each: CRIME flat 1.0 then ramping to 0.546 over the
last second; REVOLUTION 0.022 -> 1.0 across 2s; WAP 1.0 until the fade. Every
duration exact.

`continuous` was rewritten as a consequence. It used to cut merged "kept spans"
from the master, which was right for boundaries and would have been silently
WRONG here: the mix would have played un-faded audio while the tracks were
faded, with nothing to flag it. It now cuts the same segments through the same
filter chain and concatenates them, so the mix is literally the tracklist and
the two cannot drift. Gaps fall out for free.

PLN's raw release-check export is committed as provenance. Still open: his
"maybe with reverb over last 5s so we hear it slightly echo as last sound" —
supported as `reverb_tail_s`, deliberately left off pending an A/B.
parent 30e62f45
......@@ -15,7 +15,14 @@ because getting it wrong is silent:
Desire's intro. The cut track's verified start becomes the previous track's
end, and only then is it removed.
3. **Release position is not track number.** The originals are numbered by the
3. **A trim is not a boundary move.** `verified` holds boundaries: moving one
shifts BOTH neighbouring tracks, because the set is butt-joined. `edits`
holds per-track trims and fades: an `end` there DROPS audio instead of
handing it to the next track, opening a gap the continuous mix must also
skip. Confusing the two silently gives a neighbour a second of someone
else's silence, which is exactly the artefact these edits exist to remove.
4. **Release position is not track number.** The originals are numbered by the
PERFORMANCE order; removing one track renumbers the album but must not
renumber the provenance. Both are emitted: `track` (as performed, the key
everything else joins on) and `n` (position on the release).
......@@ -51,6 +58,7 @@ def main() -> int:
ear = json.loads(Path(a.ear).read_text())
segs = json.loads(Path(spec["segments"]).read_text())
verified = {int(k): v for k, v in ear.get("verified", {}).items()}
edits = {int(k): v for k, v in ear.get("edits", {}).items()}
cut = {int(k) for k in ear.get("cut_from_release", {})}
mdur = ffprobe_dur(Path(spec["master"]))
......@@ -60,6 +68,9 @@ def main() -> int:
t = s["track"]
v = verified.get(t, {})
starts[t] = float(v["start"]) if v.get("start") is not None else float(s["start"])
# a per-track `start` edit trims the HEAD: the previous track keeps its own
# end, so this too opens a gap rather than moving a shared boundary
head_trim = {t: float(e["start"]) for t, e in edits.items() if e.get("start") is not None}
order = sorted(starts)
rows, n = [], 0
......@@ -68,6 +79,9 @@ def main() -> int:
continue # rule 2: its start was already used below
nxt = order[i + 1] if i + 1 < len(order) else None
end = starts[nxt] if nxt is not None else mdur # incl. a cut track's start
ed = edits.get(t, {})
if ed.get("end") is not None:
end = float(ed["end"]) # a TRIM: the dropped audio goes nowhere
n += 1
src = next(s for s in segs if s["track"] == t)
rows.append({
......@@ -78,11 +92,14 @@ def main() -> int:
"track": n,
"perf_track": t, # performance order — the join key
"title": src["title"],
"start": round(starts[t], 3),
"start": round(head_trim.get(t, starts[t]), 3),
"end": round(end, 3),
"duration": round(end - starts[t], 3),
"duration": round(end - head_trim.get(t, starts[t]), 3),
"bpm": src.get("bpm"),
"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",
"reverb_tail_s") if ed.get(k) is not None},
**({"edited": ed.get("why", True)} if ed else {}),
})
# 2 — assertions. Each one has cost us a re-render at some point.
......@@ -92,14 +109,23 @@ def main() -> int:
problems.append(f"#{r['perf_track']} {r['title']}: non-positive duration {r['duration']}")
if r["start"] < 0 or r["end"] > mdur + 0.01:
problems.append(f"#{r['perf_track']}: [{r['start']}, {r['end']}] outside master (0, {mdur:.2f})")
for t, e in edits.items():
if t not in starts:
problems.append(f"edit for #{t} but no such track")
continue
lo, hi = starts[t], next((starts[o] for o in order if o > t), mdur)
for key in ("start", "end"):
if e.get(key) is not None and not (lo - 0.001 <= float(e[key]) <= hi + 0.001):
problems.append(f"edit #{t} {key}={e[key]} outside its own span [{lo}, {hi}]")
for x, y in zip(rows, rows[1:]):
if y["start"] < x["end"] - 0.001:
if y["start"] < x["end"] - 0.001: # gaps are legal (trims); overlaps are not
problems.append(f"overlap: #{x['perf_track']} ends {x['end']} but #{y['perf_track']} starts {y['start']}")
for t, v in verified.items():
if v.get("start") is None or t in cut:
continue
if not any(abs(r["start"] - float(v["start"])) < 0.001 for r in rows):
problems.append(f"ear-verified #{t} start {v['start']} did not reach the output")
want = head_trim.get(t, float(v["start"]))
if not any(abs(r["start"] - want) < 0.001 for r in rows):
problems.append(f"ear-verified #{t} start {want} did not reach the output")
if problems:
print("REFUSING TO WRITE — " + f"{len(problems)} problem(s):")
for p in problems:
......
......@@ -61,8 +61,8 @@
"note": "takeover is the right cut time here. \u2014 ear call via boundary lab 2026-08-16 (source: playhead); machine takeover 3557.2 was +0.13s"
},
"12": {
"start": 3680.1,
"note": "here the right cut is mid :) \u2014 ear call via boundary lab 2026-08-16 (source: mid); machine takeover 3688.1 was -8.00s"
"start": 3681.76,
"note": "release-check 2026-08-16: PLN marked the seam 1.66s late and confirmed 'move the cut 1.66s later'. Was 3680.10 (his earlier call of `mid`); heard again in context as a seam, it wants 1.66s more of Ghosts."
},
"13": {
"start": 3887.25,
......@@ -84,5 +84,35 @@
"title": "Desire",
"note": "PLN 2026-08-16, CONFIRMED: 'i confirm skip desire stronger set without'. #13 Vague de CRIME becomes the last OFFICIAL track; #15 REVOLUTION remains the Encore (its section in tracks.json). Release is 14 tracks."
}
}
},
"edits": {
"1": {
"end": 530.35,
"why": "PLN: 'can trim silence to 1s silence from 00:17 to the seam'. Measured: audible material ends 527.90 (track time), then 2.0s of -60..-100 dBFS. Ending at 528.90 leaves exactly 1s."
},
"2": {
"end": 892.955,
"fade_out_s": 0.03,
"why": "PLN: 'sound just at seam ... is an error can we fade-in to hide it?'. Measured at 10ms resolution: WAP's last 40ms jump -57 -> -15 dBFS \u2014 a sound truncated mid-decay. An 80ms fade only got it to -26.8 dB (verified by ratio against the unfiltered master), i.e. hidden, not gone. Dropping the burst outright (end 45ms early) removes it; the 30ms fade then keeps the new edge from clicking in turn. Drums still starts at 893.00, so the 45ms is dropped from the record rather than handed to the next track."
},
"13": {
"end": 4156.3,
"fade_out_s": 1.0,
"fade_out_to": 0.5,
"why": "PLN on the CRIME->REVOLUTION seam: 'cut at 0:10 its better. have fade from 0:09 to 0:10 from 100 to 50% to lower the sudden cut'. Clip 0:10 = master 4156.30, i.e. 10.0s earlier than the old end."
},
"15": {
"start": 4566.47,
"fade_in_s": 2.0,
"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."
}
},
"_edits_comment": [
"`edits` are per-track and DESTRUCTIVE to the timeline: an `end` here drops",
"audio rather than handing it to the next track, so a gap opens and the",
"continuous mix must skip it too. A boundary MOVE belongs in `verified`.",
"Keeping the two apart is the whole point \u2014 confusing them silently gives a",
"neighbour a second of someone else's silence."
]
}
{
"gig": "opal-festival-2026-release",
"_comment": [
"Ear calls from the boundary lab. Times are absolute MASTER seconds.",
"These are PLN's answers — merge them into the `verified` block of",
"judge_specs/<gig>_boundaries_ear.json, which is the ground truth."
],
"decided": {
"0": {
"start": 0,
"source": "join",
"note": ""
},
"1": {
"start": 20,
"source": "join",
"note": "can trim silence to 1s silence from 00:17 to the seam nominal"
},
"2": {
"start": 20,
"source": "join",
"note": "sound just at seam between silence and seam is an error can we fade-in to hide it? otherwise good"
},
"3": {
"start": 20,
"source": "join",
"note": ""
},
"4": {
"start": 20,
"source": "join",
"note": ""
},
"5": {
"start": 20,
"source": "join",
"note": ""
},
"6": {
"start": 20,
"source": "join",
"note": ""
},
"7": {
"start": 20,
"source": "join",
"note": ""
},
"8": {
"start": 20,
"source": "join",
"note": ""
},
"9": {
"start": 20,
"source": "join",
"note": ""
},
"10": {
"start": 20,
"source": "join",
"note": ""
},
"11": {
"start": 21.66,
"source": "playhead",
"note": ""
},
"12": {
"start": 20,
"source": "nominal",
"note": "perfect"
},
"99": {
"start": 14.8,
"source": "playhead",
"note": "end at 0:15 maybe with reverb over last 5s so we hear it slightly echo as last sound."
}
},
"skipped": [
13
]
}
\ No newline at end of file
......@@ -52,6 +52,32 @@ def sh(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, **kw)
def filters(seg: dict) -> list[str]:
"""The ffmpeg -af chain for one segment's ear-requested fades.
Kept in ONE place so a track and the continuous mix cannot disagree about
what a fade sounds like.
"""
dur = seg["end"] - seg["start"]
af = []
if seg.get("fade_in_s"):
af.append(f"afade=t=in:st=0:d={float(seg['fade_in_s']):.4f}")
if seg.get("fade_out_s"):
d = float(seg["fade_out_s"])
to = float(seg.get("fade_out_to", 0.0))
st = max(0.0, dur - d)
if to <= 0.0:
af.append(f"afade=t=out:st={st:.4f}:d={d:.4f}")
else:
# afade only goes to silence. A partial duck (PLN: "from 100 to 50%
# 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)'"
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
def sanitize(name: str) -> str:
return "".join("_" if c in '/\\:*?"<>|' else c for c in name).strip().strip(".")
......@@ -98,6 +124,9 @@ def stage_split(spec, segs, seg_path: Path) -> int:
# STREAMINFO, so every track claims the whole master's length. The
# audio is right and only the header lies, which is worse than a
# loud failure — players show wrong durations and stores reject it.
af = filters(s)
if af:
cmd += ["-af", ",".join(af)]
cmd += ["-c:a", "flac", "-compression_level", "5", str(dest)]
if sh(cmd).returncode != 0:
print(f" FAIL {dest.name}")
......@@ -130,28 +159,40 @@ def stage_verify(spec, segs) -> int:
def stage_continuous(spec, segs) -> int:
spans = kept_spans(segs)
total = sum(b - a for a, b in spans)
print(f"=== continuous: {len(spans)} kept span(s), {total/60:.1f} min")
for a, b in spans:
print(f" [{a:>9.2f} .. {b:>9.2f}] {(b-a)/60:5.1f} min")
if len(spans) == 1:
print(" (nothing excised — the mix already matches the tracklist)")
"""The continuous mix = the RELEASE TRACKS, concatenated.
The first version cut merged "kept spans" straight from the master. That was
correct for boundaries but wrong the moment per-track fades appeared: the mix
would have played the un-faded audio while the tracks were faded, and nothing
would have flagged it. Cutting the same segments with the same filter chain
and joining them makes the mix literally the tracklist, so the two cannot
drift. Gaps (trimmed audio, dropped tracks) fall out for free — they are
simply not in any segment.
"""
total = sum(s["end"] - s["start"] for s in segs)
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)")
for a, b in gaps:
print(f" dropped [{a:>9.2f} .. {b:>9.2f}] {b-a:6.2f}s")
bad = 0
for variant, master in spec["variants"].items():
m = Path(master)
dest = m.with_name(m.stem + "_nodrop" + m.suffix)
with tempfile.TemporaryDirectory() as td:
parts = []
for i, (a, b) in enumerate(spans):
p = Path(td) / f"{i:02d}.flac"
r = sh(["ffmpeg", "-v", "error", "-y", "-ss", f"{a:.6f}",
"-to", f"{b:.6f}", "-i", str(m), "-c:a", "flac",
"-compression_level", "3", str(p)])
for i, seg in enumerate(segs):
pth = Path(td) / f"{i:02d}.flac"
cmd = ["ffmpeg", "-v", "error", "-y", "-ss", f"{seg['start']:.6f}",
"-to", f"{seg['end']:.6f}", "-i", str(m)]
af = filters(seg)
if af:
cmd += ["-af", ",".join(af)]
cmd += ["-c:a", "flac", "-compression_level", "3", str(pth)]
r = sh(cmd)
if r.returncode != 0:
print(f" FAIL cutting {variant} span {i}: {r.stderr[:150]}")
print(f" FAIL cutting {variant} #{seg['track']}: {r.stderr[:150]}")
bad += 1
parts.append(p)
parts.append(pth)
lst = Path(td) / "list.txt"
lst.write_text("".join(f"file '{p}'\n" for p in parts))
r = sh(["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "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