Commit 271e07c0 by PLN (Algolia)

feat(postprod): a rest is not a gap — pad_end_s, and the close goes to MORE

Two ear calls on the OPAL-26 record, one of which needed a new concept.

PLN, having heard the CRIME->REVOLUTION seam: "approved #13 seam, but lets add
after seam 2s silence in crime file, then seam is start of [the] revolution."
And on the closing echo, after living with it: "allez lets use the B version,
the more option" — so the ship-preset moves subtle -> more.

The silence is the interesting half. The pipeline knew two kinds of answer:
`verified` boundaries (move one, both neighbours shift) and `edits` trims (audio
is DROPPED, a gap opens, the continuous mix skips it). A rest is neither — it
ADDS time. So `pad_end_s` is a third kind, and where it lives is the whole
decision: the 2s belongs to CRIME, so it ships inside track 13's file AND at the
same place in the continuous mix, and REVOLUTION still begins on its first
sample in both forms. Had it been modelled as a gap between tracks it would have
existed only in the mix and track 13 would have ended on the hard cut.

Implemented with `apad` — the same filter whose one-second-per-second literalism
was a BUG two commits ago, where it was misused as reverb headroom and shipped
~2.3s of trailing zeros. Here that literalism is exactly the feature: 2.0 in,
2.0000s out. Silence as an accident of a misunderstood filter is a defect;
silence a listener asked for is a rest. Same filter, opposite verdict, and the
comment now says so where the next reader will hit it.

Ordering matters and is documented: the pad runs LAST, after the fades and after
any reverb ring. Padding first would hand the fade silence to act on, and the
fade's own start is measured from the un-padded duration. In the reverb graph it
attaches after the dry/wet concat, not inside either branch.

`expected_dur` now predicts pad + aecho's longest tap, so `verify` still compares
against INTENT rather than against whatever came out — the property that caught
the apad bug in the first place. `stage_continuous` prints rests separately from
gaps, because lumping them together is the exact confusion this commit exists to
prevent.

Validated, all measured not assumed:
  - verify ALL OK, 14 tracks x 2 variants, 48000/24
  - #13 271.050s = 269.05 + 2.00; #14 205.550s = 204.90 + 0.65 (more's 650ms
    top tap — subtle's was 240ms, so the duration alone proves which shipped)
  - sample-exact: the last 2.0000s of CRIME are all zeros and the last non-zero
    sample sits precisely on the 2.000s mark
  - continuous 4361.35s both variants, OK against intent
  - the rebuilt seam-13 audition clip carries 2.0000s of silence ending exactly
    at the 20.00s seam marker: the rest is CRIME's, the marker is REVOLUTION's
    first sample

Kept deliberately: the 1s duck to 50% stays alongside the new rest. They answer
different complaints — the duck softens the cut, the rest separates the encore.
parent 6c508e4d
......@@ -105,7 +105,19 @@ while still refusing overlaps.
Supported edit keys: `start`, `end` (trims), `fade_in_s`, `fade_out_s`,
`fade_out_to` (partial duck — `afade` can only reach silence, so a "100 to 50%"
ramp is expressed as a gain expression), `reverb_tail_s`.
ramp is expressed as a gain expression), `reverb_tail_s`, `reverb_preset`,
`pad_end_s`.
`pad_end_s` is the third kind of answer and it is neither of the two above: it
**adds** time rather than moving or removing it. A rest belongs to the track
before it, so it ships inside that track's file *and* at the same place in the
continuous mix — that is what makes a deliberate silence different from a gap,
which is audio the listener would otherwise have heard. `stage_continuous`
prints gaps and rests separately for exactly that reason. It is implemented with
`apad`, the same filter whose one-second-per-second behaviour was a *bug* as
reverb headroom; here that literal-mindedness is the feature (`pad_end_s: 2.0`
→ 2.000 s, no rounding). Both lengthen the render, so `expected_dur` predicts
the pad and aecho's longest tap and `verify` still compares against intent.
**Verify a fade by measurement, never by reading the command line.** Render the
same span with and without the filter chain and take the per-frame RMS ratio;
......
......@@ -99,15 +99,16 @@
"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."
"pad_end_s": 2.0,
"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. Then 2026-08-16, having heard it: 'approved #13 seam, but lets add after seam 2s silence in crime file, then seam is start of REVOLUTION' -> pad_end_s 2.0. This is a REST, not a trim artefact: the 2s belongs to CRIME (it ships inside track 13, and inside the continuous mix at the same place), so the last official track ends on its own silence and the encore starts clean. Note the ducked fade is kept AND the silence added — the duck softens the cut, the rest separates the encore; they answer different complaints."
},
"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). '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.",
"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 first said 'pick subtle', then after living with the record: 'allez lets use the B version, the more option' (2026-08-16) -> MORE. 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"
"reverb_preset": "more"
}
},
"_edits_comment": [
......
......@@ -56,15 +56,22 @@ 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",
}
# NO apad. The first version padded 2.5s "so the ring has somewhere to go",
# reasoning that ffmpeg would otherwise chop the echo at the last sample. That
# reasoning was wrong and the measurement settles it: aecho extends its own
# output by its longest tap, and every second of apad becomes trailing DIGITAL
# SILENCE one-for-one.
# NO apad as reverb headroom. The first version padded 2.5s "so the ring has
# somewhere to go", reasoning that ffmpeg would otherwise chop the echo at the
# last sample. That reasoning was wrong and the measurement settles it: aecho
# extends its own output by its longest tap, and every second of apad becomes
# trailing DIGITAL SILENCE one-for-one.
# no apad -> 5.240s wet, 0.000s trailing silence (full ring, nothing cut)
# apad=2.5 -> 7.740s wet, 2.500s trailing silence
# Shipped once: the record ended with ~2.3s of pure zeros — exactly the defect
# PLN flagged at the START of the record ("trim leading silence tho").
#
# `pad_end_s` is the SAME filter used deliberately, and the distinction is the
# whole lesson: silence as an accident of a misunderstood filter is a defect,
# silence a listener asked for is a rest. PLN on the CRIME->REVOLUTION seam:
# *"lets add after seam 2s silence in crime file, then seam is start of [the]
# revolution"*. One-for-one is exactly what makes it usable — pad_end_s=2.0
# yields 2.000s, no rounding, no guesswork.
def sh(cmd, **kw):
......@@ -99,25 +106,39 @@ def has_reverb(seg: dict) -> bool:
return bool(seg.get("reverb_tail_s")) and seg.get("reverb_preset") in CLOSE_AB
def tail_pad(seg: dict) -> str | None:
"""A REST the ear asked for, appended after everything else.
It must come last in the chain, after the fades and after any reverb ring:
padding first would put the silence where the fade still has audio to act
on, and the fade's own start is measured from the un-padded duration.
"""
if not seg.get("pad_end_s"):
return None
return f"apad=pad_dur={float(seg['pad_end_s']):.4f}"
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.
the echo, and the echo is allowed to ring PAST the segment's last sample
rather than being chopped there (which would reintroduce exactly the hard
stop the reverb exists to soften). Pre-filters — fades — apply to both
halves, so they must run BEFORE the split; a tail pad applies to neither, so
it runs after the join.
"""
dur = seg["end"] - seg["start"]
split = max(0.0, dur - float(seg["reverb_tail_s"]))
pre = ",".join(filters(seg))
pad = tail_pad(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,"
f"aecho={CLOSE_AB[seg['reverb_preset']]}[wet];"
f"[dry][wet]concat=n=2:v=0:a=1[out]")
f"[dry][wet]concat=n=2:v=0:a=1" + (f",{pad}" if pad else "") + "[out]")
def echo_extension(preset: str) -> float:
......@@ -135,10 +156,16 @@ def echo_extension(preset: str) -> float:
def expected_dur(seg: dict) -> float:
"""A reverb tail makes the track longer by exactly aecho's longest tap."""
if not has_reverb(seg):
return seg["end"] - seg["start"]
return (seg["end"] - seg["start"]) + echo_extension(seg["reverb_preset"])
"""Audio length + everything the effects ADD to it.
Both terms lengthen the output and both are exactly predictable: aecho's
longest tap, and apad one-for-one. Predicting them is what lets `verify`
compare against INTENT rather than against whatever came out.
"""
d = seg["end"] - seg["start"]
if has_reverb(seg):
d += echo_extension(seg["reverb_preset"])
return d + float(seg.get("pad_end_s") or 0.0)
def sanitize(name: str) -> str:
......@@ -190,7 +217,7 @@ def stage_split(spec, segs, seg_path: Path) -> int:
if has_reverb(s):
cmd += ["-filter_complex", reverb_complex(s), "-map", "[out]"]
else:
af = filters(s)
af = filters(s) + [p for p in (tail_pad(s),) if p]
if af:
cmd += ["-af", ",".join(af)]
cmd += ["-c:a", "flac", "-compression_level", "5", str(dest)]
......@@ -240,6 +267,13 @@ def stage_continuous(spec, segs) -> int:
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")
# A pad is the opposite of a gap and must not be silently lumped in with
# one: a gap removes audio the listener would have heard, a pad ADDS a rest
# the listener asked for. Both change the running time, so print both.
for s in segs:
if s.get("pad_end_s"):
print(f" rest after #{s['track']} {s['title'][:26]:<26} "
f"{float(s['pad_end_s']):6.2f}s")
bad = 0
for variant, master in spec["variants"].items():
m = Path(master)
......@@ -253,7 +287,7 @@ def stage_continuous(spec, segs) -> int:
if has_reverb(seg):
cmd += ["-filter_complex", reverb_complex(seg), "-map", "[out]"]
else:
af = filters(seg)
af = filters(seg) + [p for p in (tail_pad(seg),) if p]
if af:
cmd += ["-af", ",".join(af)]
cmd += ["-c:a", "flac", "-compression_level", "3", str(pth)]
......
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