# Set → record: the post-production pipeline

Every gig gets postprocessed the same way, so this is tooling, not a one-off.
**A new gig is a copy of `judge_specs/<gig>.json` — never a copy of a script.**
That spec holds every path; the tools hold the reasoning.

Prior art this replaced: `Opal26_master/v4_provenance/finish.sh`, which hardcoded
one gig's paths, one segments file, and one variant list. It is kept only as a
historical artefact.

## The flow

```
      stems + master                    the ear                    the record
           │                               │                            │
  ┌────────▼─────────┐        ┌────────────▼───────────┐     ┌──────────▼─────────┐
  │ build_judge_set  │──judge─▶ <gig>_boundaries_ear   ◀─────│ build_bounds_set   │
  │  → judge.html    │  .json │  GROUND TRUTH          │bound│  → bounds.html     │
  └──────────────────┘        └────────────┬───────────┘ lab └────────────────────┘
      is it a keeper?                      │                    where is the cut?
                                 ┌─────────▼──────────┐
                                 │ apply_boundaries   │  refuses to write on
                                 │  → segments_v N    │  overlap / bad duration
                                 └─────────┬──────────┘
                                 ┌─────────▼──────────┐
                                 │ render_release     │  split · verify · continuous
                                 └─────────┬──────────┘
                                 ┌─────────▼──────────┐
                                 │ build_release_joins│  → bounds.html?set=…-release
                                 └────────────────────┘  does it PLAY as a record?
```

## Commands, in order

```sh
cd armada/tide-table
S=judge_specs/opal26.json          # the only thing that changes per gig
E=judge_specs/opal26_boundaries_ear.json

# 1 — go/no-go each track            → judge.html?set=<gig>
python3 build_judge_set.py $S
# 2 — settle each cut                → bounds.html?set=<gig>
python3 build_bounds_set.py $S --ear $E
#     …make the calls, then merge the export's `decided` into $E's `verified`
# 3 — segments from the ear (asserts, then writes)
python3 apply_boundaries.py $S --ear $E --out <releaseRoot>/segments_v4.json
# 4 — split every variant, verify, and re-render the drop-free continuous mixes
python3 render_release.py $S
# 5 — confirm the record end-to-end   → bounds.html?set=<gig>-release
python3 build_release_joins.py $S --tracks <releaseRoot>/tracks_v4_streaming

cd ../ui && npx vite build && python3 ../serve.py --dir dist --port 8799
node scripts/bounds-smoke.mjs                      # 11 assertions
```

## The traps each stage exists to avoid

**`apply_boundaries`** — the set is butt-joined, so a track's end is the next
track's *start*: editing one start silently moves two tracks. And **a track cut
from the release still owns its boundary** — dropping Desire without honouring
its start would have run the last official track straight into Desire's intro.
Release position ≠ performance number: the splitter uses `track` for both the
filename and the `track=N/total` tag, so emitting performance order would name
the last file `15 - …` in a 14-track album and tag it `15/14`, which stores
reject. Both are emitted (`track` = release, `perf_track` = performance).

**`render_release`** — never `-c copy` on FLAC: a stream copy carries the SOURCE
STREAMINFO, so every sliced track claims the whole master's length. The audio is
correct and only the header lies, which is worse than a loud failure.

The `continuous` stage exists because **a set is released twice**: as tracks and
as the unbroken mix. Cutting a track fixed only the first — the mix still played
it. It matters beyond tidiness: the continuous upload is the lower-risk rights
path (an isolated track was flagged on SoundCloud where the 87-minute mix
passed), so the mix is the artefact most likely to ship. Its kept spans are
derived from the same segments the tracks came from, so mix and tracklist cannot
disagree.

**`build_release_joins`** — the confirmation pass cannot run on the master. Once
a track is dropped, the seam a listener hears (CRIME straight into REVOLUTION)
**exists only in the rendered output**; a master-based window there plays six
minutes of a track that does not ship. Building the clips from the split FLACs
also audits the files that actually ship, so a split error surfaces here rather
than after upload.

**The UI** is one page for both jobs: `bounds.html?set=<gig>` walks master
boundaries, `?set=<gig>-release` walks the finished record. Same component, same
smoke test — which derives its data file from the page's own `?set=`, because a
hardcoded path let the test assert against a different set than the browser had
loaded.

## Boundaries vs edits — the distinction that matters most

The ear file holds two different kinds of answer and they must not be mixed:

| block | meaning | effect |
|---|---|---|
| `verified` | a **boundary** | the set is butt-joined, so moving it shifts BOTH neighbours |
| `edits` | a per-track **trim / fade** | the audio is DROPPED — a gap opens, and the continuous mix skips it too |

Confusing them silently hands a neighbour a second of someone else's silence,
which is precisely the artefact the trims exist to remove. `apply_boundaries`
asserts an edit stays inside its own track's span, and treats gaps as legal
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`, `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;
that is what proved the OPAL fades (CRIME flat 1.0 then ramping to 0.546 over
its last second, REVOLUTION 0.022 → 1.0 across 2 s) and what showed an 80 ms
fade was *hiding* WAP's truncation burst at −26.8 dB rather than removing it.

## Ground truth rules

- `judge_specs/<gig>_boundaries_ear.json` is **ground truth**. Detector output
  never overwrites it; machine proposals live in a separate `candidates` block.
  A *measurement* may fill a gap (e.g. a sample-exact silence trim) if it records
  its method.
- Automated cut detection is a **dead end** for crossfaded sets — see
  `cut_lens3.py`. Where two tracks share their sounds, no timbral lens can see
  the handover. Offer candidates, let the ear pick, never average.
- Gig metadata (venue, set title, lineup) is canonical in
  `../../Web/www/next/content/lives/{year}/{slug}.md` + `tracks.json`. Mastering
  references takes by **Take-ID + date** only. Never invent it here.
