1. 21 Aug, 2026 22 commits
    • feat(cale): a page for hearing whether a kit is any good · ec200263
      PLN asked for an SPA to audition the cut samples, "click-based looping not just
      strudel coding". La Cale — `armada/ui/kits.html`, the hold, where the cargo gets
      checked before it goes on stage.
      
      The reason it needs to exist is that a grade in a manifest is a claim, and this
      week the claim has been wrong twice in ways no report caught: a loop that fades to
      silence grades S, and 24 loops of pure digital silence graded A. Both are obvious
      in two seconds of listening and invisible in a table.
      
      **Auditioning is two different tests, so it is two buttons.** Play-once gives you
      the file untouched — attack, tail, what it actually is. Loop plays it seamlessly at
      rate 1, which is the only way to hear the seam, and the seam is the thing that
      decides whether a loop is usable because it is the sound the audience gets every
      bar. One AudioBufferSourceNode with `loop = true`, so drift cannot exist and
      nothing is resampled between the file and the speaker.
      
      **The rack is where a kit is actually judged**, because the questions that matter —
      do these two loops sit together, does this vocal survive on that break, is the bass
      really at this tempo — cannot be answered one file at a time. It schedules every
      voice from a look-ahead clock against an absolute grid, at
      
          rate = buffer.duration / (bars * 4 * 60 / bpm)
      
      which is exactly `loopAt bars`. So the layering you hear is the layering the rig
      plays, and the exported block is not an approximation of the session — it is the
      same two numbers in Tidal's syntax. Scheduling per bar rather than letting nodes
      free-run matters more than it sounds: the cutter's bar tolerance is 1 ms, which is
      60 ms of skew after a minute.
      
      The grid means what Tidal means. A bar-loop row has cells only on multiples of its
      own length — a 4-bar loop in an 8-bar phrase has two entries, not eight, because
      firing it on bar 3 plays its bar 1 under the phrase's bar 4 — and those cells
      become `mask "t f"`. A chop row is 16 steps in a bar and becomes `s "x ~ ~ x …"`.
      
      Three things the export gets right that are easy to get wrong, all pinned by tests:
      the fader is linear but Tidal's gain is quartic, so 0.5 exports as `gain 0.84` and
      copying it across would be a 12 dB error; families land on their own `dN` with no
      two voices sharing an orbit; and the mask samples the grid on the loop's stride,
      not every bar, which otherwise silences an entry that is switched on.
      
      Deliberately not wavesurfer, though the project already depends on it: a kit page
      shows a hundred rows, and a hundred instances means a hundred fetches and decodes
      to draw something 200 px wide. `kitindex.py` precomputes a 128-point envelope per
      sample, so a row costs one `<svg>` and no network — audio is fetched only when
      something is played. Same ship-the-index instinct as the tide-table.
      
      `kitindex.py` merges two sources on purpose. The FOLDER is the truth about what
      exists (via `pvbanks.playable`, so it agrees with kitgate and the watcher), which
      is why the 111 kits that predate the Foundry are browsable too. A cut MANIFEST is
      the truth about what a file MEANS — tier, family, bars, bpm, CLAP tags — and none
      of it is re-derived here, so the page and the gate cannot disagree. `n` comes from
      the folder listing, never the manifest order, because `n` is what SuperDirt plays.
      
      Two staleness traps closed while building it: the envelope cache is keyed on
      path+size+mtime, so a restaged kit misses it (a stale envelope over fresh audio is
      a lie that looks like data), and the index is written to `dist/` as well as
      `public/`, because `npm run build` copies public/ once and the LAN page would
      otherwise show an index from before the last cut.
      
      Also: the dev server hardcoded `Content-Type: audio/flac` for everything under
      /audio. The masters are FLAC; the kits are WAV. Now by extension, matching
      serve.py's `guess_type`.
      
      13 vitest cases on the export; `npm test`. Verified end to end through the real
      path — `serve.py --dir ui/dist` returns 200 on /kits.html and 206 `audio/x-wav`
      on a Range request through the new `samples` mount.
      PLN (Algolia) authored
    • fix(foundry): silence was the grader's optimum — 24 of 153 loops were empty · f9a9ce53
      kitgate blocked the Fred pack and the reason turned out to be much worse than the
      flag it raised. Measuring the flagged files: 24 of 153 shipped loops have a peak
      between **-104 and -75 dBFS**. They are not quiet. They are digital silence. And
      they graded **0.707 to 0.896 — tier A and B**.
      
      The mechanism is that every sub-score in the loop rubric is vacuously perfect on
      nothing. The seam between two silences is immaculate. The DC of silence is exactly
      zero. Its zero crossings are trivially fine. Its bar length is whatever you cut it
      to, so it is bar-exact by construction. Level carries 10% of the composite, so a
      silent window scores 0.90 × perfect + 0.10 × nothing ≈ 0.90 and comes out tier A.
      The composite had a **degenerate optimum at "no audio at all"**, and the finder,
      doing exactly its job, walked into it: `fred_marea_bass` shipped three loops and
      all three were silence, from a stem measuring -22 dBFS RMS at 54% activity.
      
      Nothing downstream could catch it either. `_has_dead_bars` compares bars to EACH
      OTHER, so it finds a loop that fades out and is blind to one that was never there —
      a uniformly silent clip has a perfectly flat bar profile. This is the same trap as
      the fade-out loops grading S, one level deeper.
      
      So presence stops being a weighted term and becomes a **precondition**: peak below
      `empty_dbfs` ⇒ flag `empty`, grade 0.0, tier D, and the existing MIN_TIER filter
      drops it at export. The floor is -60 dBFS, and it is placed rather than tuned: the
      pack's peaks run -104…-75 and then jump to -47.1, a **28.4 dB empty gap**, so -60
      sits in clean air with nothing near it.
      
      Three details the measurements forced:
      
      * **Presence is measured on the CHANNELS, not the mono sum.** An anti-phase stereo
        pair sums to exact zero; calling that "empty" would both mis-report it and
        suppress the mono-incompatible flag, which is the one useful thing to say about
        it. (Caught by an existing test, which is why it is an existing test.)
      * **`near-silent` (RMS < -45) is advisory and now excludes `empty`.** RMS on sparse
        material measures how much silence it contains, not whether it is silent — a hat
        loop peaking at -14.7 reads -48.8 RMS. Two questions, two lenses.
      * **`mono-incompatible` no longer fires on silence.** Two independent noise floors
        are uncorrelated, so silence reads as maximally mono-incompatible; four of
        MAREA's flagged vox loops were simply empty.
      
      `kitcheck` asks the presence question too, from its own code path, sharing the one
      constant by import so the two lenses cannot drift apart.
      
      Also: restage was not idempotent. Export stages in float and writes PCM_24, so the
      peak read back off disk misses the ceiling by a quantization step, measures a hair
      hot, and asks to be staged again — by -0.00003 dB, every run, requantizing every
      file in the kit each time. `DEAD_BAND_DB = 0.05` (inaudible, four orders of
      magnitude above the quantization floor). The dry run now reports 0 of 42 kits,
      which is what it was supposed to say in the first place.
      
      7 tests; suite 115 → 119.
      PLN (Algolia) authored
    • feat(foundry): the gate now checks that `n 3` plays what the cheat sheet says · bfc17e73
      The cheat sheet numbers `n` by enumerating the manifest sorted by filename.
      SuperDirt numbers it by enumerating the *folder* sorted by filename. Those two
      agree only for as long as the folder holds exactly the manifest's files — and
      `export_track` writes into a kit dir it never empties.
      
      So re-cutting a pack is quietly unsafe. If a loop that was `00_kick_2b.wav` last
      run comes out as `00_kick_4b.wav` this run, both files are on disk, the folder
      listing gains an entry at position 0, and every index from there on addresses the
      wrong sound. Nothing else notices: the manifest is right, the grades are right,
      every file is a good loop, and the cheat sheet you paste from looks correct. You
      find out on stage.
      
      Noticed while looking at the run-3 kits and asking whether runs 1 and 2 had left
      anything behind (they hadn't — all 58 files carried run-3 mtimes). The absence of
      the bug today is not the absence of the bug.
      
      check_indices compares the two listings **index for index**, not as sets, so it
      distinguishes the two severities the report needs to separate: an orphan that
      sorts last pollutes the bank but shifts nothing, while one that sorts early
      breaks addressing from that point on — and it names the index where it breaks.
      `--prune` deletes what the manifest does not list, scoped to manifest kit dirs
      and to audio files only. Listing comes from `pvbanks.playable`, so "what
      SuperDirt will load" keeps one definition across kitgate, bank-check and the
      watcher — a checker and a loader that disagree here is the whole failure mode.
      
      3 tests (clean / shifting orphan / trailing orphan); suite 112 → 115.
      PLN (Algolia) authored
    • docs: board + archive for the stem-pack batch work · 7232003f
      #20 lands; #21 (auditioner SPA), #22 (acapellas + demucs catches), #23 (audit the existing
      163 kits) opened with enough detail to start cold. The Fred pack's finish sequence is
      written out step by step because the final run is still in flight.
      PLN (Algolia) authored
    • docs(armada): log 027 — the four findings that came from measuring the output · 3ec21fc9
      Periodicity measuring length then role, dead bars grading S, no headroom against amp=gain^4,
      and 1 loop in 6 opening on a weak beat. Plus the two bugs the checks caught in my own work,
      which is the argument for having them.
      PLN (Algolia) authored
    • fix(foundry): one rotation pass leaves 1 loop in 6 starting on a weak beat · 12fd5bb7
      Chasing "does a shipped loop actually start on its downbeat" and nearly filing a false
      alarm on the way. First measurement said 17% — but it scored 16 slots with no full-band
      term while the rotator scores bars*4 slots WITH one, so the disagreement was the
      instrument's. Factored the scorer out (_downbeat_slot_scores) so the rotation and its
      verification cannot use lookalike measures, and re-measured with the real one.
      
      The bug is real and smaller than the first number: over 60 shipped bar-loops, slot 0 was
      the best slot in 29 and in the WORSE HALF of slots in 10. Median miss was 0.032 — i.e.
      usually a near-tie — but one loop in six genuinely opens on a weak beat, which is exactly
      the "timed ok but cut BCDA/DABC" complaint from 2026-07-11 that this function was written
      to fix.
      
      Mechanism: the roll point is zero-crossing snapped by up to ~10 ms against a 20 ms attack
      window, and between two near-tied slots that is enough to flip which one wins. On clean
      synthetic material rotation is idempotent (pass 1 finds slot 4, pass 2 finds 0); on real
      music the tie-breaking is fragile.
      
      So it now checks its own result and repeats, up to 3 passes, keeping a pass only if the
      measured miss improves. Safe because rotation is free: two rolls compose into one and the
      guard cannot oscillate. Best slot 29 -> 46 of 60, worse half 10 -> 1, median miss
      0.032 -> 0.000.
      
      Default is 3 rather than opt-in: it is strictly better and applies to every caller,
      including export_take, so the GUI's forge gets it too. 112 tests.
      PLN (Algolia) authored
    • docs(foundry): packs/ holds the manifest, not the audio · 3e26a735
      Provenance for a cut that shipped: which stem each loop came from, at what tempo, graded
      how. The audio stays in Samples/ — this is the record, not a copy.
      PLN (Algolia) authored
    • fix(foundry): the report section I added never rendered, and hits vs drums was a false positive · b2c90154
      Two bugs in one small feature, both mine.
      
      The 'worth a second listen' table was appended by a str.replace whose anchor did not
      match, and I had not asserted on that one — so the code was present, the function
      computed 39 rows, and the report silently shipped without them. Every other patch in this
      file asserts its anchor; this is why.
      
      And the comparison was at the wrong level. A `hits` kit IS percussion, so CLAP hearing
      'a kick drum' in one is agreement, not contradiction — comparing family names instead of
      their underlying stems reported every kick and snare in the pack as suspicious. Same for
      fx/tonal/vox, which all map to `other` and genuinely cannot contradict each other.
      
      Also adds --report-only, which rebuilds the report and cheatsheet from the manifest. The
      manifest is the record and those are views of it, so anything that legitimately changes
      the audio after a cut (restage staging a kit and re-grading it) leaves them describing
      numbers that are no longer true — and re-cutting a whole pack to refresh a markdown table
      would be absurd.
      PLN (Algolia) authored
    • fix(foundry): the manifest described audio that was never written · 34e01673
      kitgate re-grades the shipped files and compares against what the finder recorded, on
      the principle that two code paths reaching the same verdict is worth more than one path
      asserting it. It immediately caught my own bug: staging applied the kit gain at write
      time, AFTER grading, so the recorded grade described the pre-staging clip. Several
      sub-scores have a level term, so the two differ — 0.875 recorded against 0.925 on disk —
      and the gate correctly called that a defect.
      
      Staged clips are now re-graded before the manifest is written, so the number stored is
      the number you get by grading the file. Selection still runs on the pre-staging grades,
      which is fine and worth stating: the gain is uniform across a kit, so it cannot reorder
      its members.
      
      This is the second time the cross-check has paid for itself in a day — it also caught the
      stale manifest after restage. A checker that only ever agrees with the thing it checks is
      not earning anything.
      
      Also: the report now lists cuts whose CLAP instrument tag contradicts the kit they landed
      in. Nothing is re-filed on that basis. For a stem whose filename token is unrecognised
      there is no producer label to fall back on, so it lands wherever the coarse measurement
      pointed — MAREA's `marea` stem went into the drums kit while CLAP hears a spoken voice.
      CLAP is the one signal here that can separate a sung line from a keyboard, so it is used
      to say what to audition first, not to decide.
      
      24 tests.
      PLN (Algolia) authored
    • docs(foundry): kitgate/restage in the quickstart, and the two periodicity caveats · 8cf1e841
      Anyone reading a periodicity number needs to know it was length-biased before the fix
      and that its floor is per-role — otherwise the vox column looks like a disaster.
      PLN (Algolia) authored
    • feat(foundry): stage a kit's level, because amp = gain^4 · 75e4ec16
      These are stems from a loud commercial master — MAREA's ALL DRUMS and KIT both peak at
      +0.2 dBFS — so a loop cut from them arrives at full scale. grade already calls that
      clipping, and SuperDirt cannot use it: amp = gain^4, so `# gain 1.2` is +7.6 dB and the
      orbit clips the moment you touch it. The house rule is peaks at -6 dBFS at the source and
      let the amplification happen downstream, so that is where kits land now.
      
      Two decisions inside kit_headroom_gain that matter more than the ceiling:
      
      NOT per file. One scalar for the whole kit. Normalising each file on its own would make a
      sparse hat loop exactly as loud as a full drum bus, throwing away the balance that two
      loops from one kit otherwise have for free — the reason to cut a kit from one track in
      the first place.
      
      NEVER boosts. A kit already below the ceiling is left exactly where it is. A quiet stem
      is quiet on purpose; lifting it amplifies its noise floor and discards real information
      about the arrangement.
      
      restage.py applies the same function to kits already on disk, so a kit restaged after the
      fact is bit-identical to one cut with it in place. Useful beyond this pack: hand-cut kits
      carry whatever level their source had, and there are 163 kits in Samples/.
      
      It also re-grades the manifest for anything it changed. Whoever moves the audio owns the
      record of it — every grade has a level term, so a stale manifest describes bytes that no
      longer exist, and kitgate compares the two precisely to catch that drift. Verified on the
      synthetic pack: clipping cleared, two drum loops A -> S, and the gate then caught the
      stale grades until restage started writing them back.
      
      23 tests in the file.
      PLN (Algolia) authored
    • feat(foundry): kitgate — one gate between cutting a pack and loading it · e6e0ab78
      Four checks, run before anything is linked into Dirt-Samples.
      
      grade is deliberately re-run over the WRITTEN FILES rather than trusted from the
      finder's in-memory scoring: same verdict by a different code path, so a disagreement
      means one of them is wrong, which is the entire reason to run it twice.
      
      Bank shadowing is the one that would hurt silently. loadSoundFiles defaults to
      appendToExisting = false, so a new folder whose basename matches an existing bank frees
      that bank's buffers and takes the name — no error anywhere, and the only symptom is that
      an old track now plays the wrong sound. Checked BEFORE linking, through tools/pvbanks.py
      so this and bank-check.py cannot disagree about what a bank is.
      
      Rights is advisory and never blocks the load, because loading a kit locally is not
      releasing a track — but it does say out loud when a kit cut from someone else's stems
      is not yet ledgered.
      
      Self-test on the synthetic pack immediately blocked on clipping in the fixture, which
      is the fixture's fault and the gate's point.
      PLN (Algolia) authored
    • fix(foundry): the periodicity check was measuring loop length, then role · 0c140b17
      Two errors stacked in one metric, both found by looking at its distribution instead
      of its verdicts.
      
      **It penalised short loops for being short.** Autocorrelation at lag k has only N-k
      overlapping frames, but the score was normalised by ac[0], which sums all N — so the
      number was partly a function of how much of the loop the lag consumed, and a 1-bar lag
      consumes half a 2-bar loop against an eighth of an 8-bar one. On a perfectly periodic
      click track the biased estimator reports 0.418 / 0.623 / 0.754 at 2 / 4 / 8 bars for
      IDENTICAL material. Dividing by the per-lag overlap count first: 0.834 / 0.830 / 0.862,
      flat as it must be. Corpus medians moved 0.12 -> 0.24 (2 bar), 0.31 -> 0.41, 0.43 -> 0.49.
      
      **And then it asked every role the same question.** Split by family across 81 bar-loops:
      drums 0.754, fx 0.549, tonal 0.214, bass 0.173, vox 0.087 — an 8.7x spread end to end.
      A drum loop that does not repeat at the bar is broken. A sung phrase that does not repeat
      within itself is a sung phrase, and a held chord is a perfectly good loop with no onset
      period at all. One threshold across all of them condemned 12 of 20 vocal loops for the
      crime of being vocal. Floors are now per family, and vox is measured-but-never-flagged.
      
      Also: dead bars are now rejected at EXPORT rather than reported afterwards. A 4-bar loop
      whose last two bars are a fade grades S — silent-to-silent is a perfect seam and the
      length is still an exact bar multiple — and 10 of the first 161 cuts were exactly that.
      An objective defect with an objective test belongs in the gate, not in a report someone
      has to read.
      
      First full run for the record: 161 samples, 43 kits, ZERO bar-length errors and 161 of
      161 distinct sounds, so the bar-multiple guarantee and the export dedup both hold at
      scale. 21 tests in this file, 104 in the suite.
      PLN (Algolia) authored
    • docs(foundry): log 027 — the label and the lens · ee41a29b
      Two ways to be wrong about a stem, pulling opposite directions. Plus the README
      section for stempack/kitcheck and the thresholds, all picked from measured data.
      PLN (Algolia) authored
    • fix(foundry): gate onset density on envelope crest — a steady sine read as drums · b8854723
      librosa's peak-picker finds peaks in whatever onset envelope it is handed, flat ones
      included: a steady 50 Hz sine yields 84 onsets over six seconds (14/s) and was being
      filed as drums, which made the bass branch unreachable for exactly the signal it was
      written for. Onset count is now gated on the frame-RMS crest — ~1.0 for a sustained
      tone against 3.1-9.5 for anything struck.
      
      Measured across MAREA/ANGIE/ME to pick the threshold rather than guess it. Worth
      recording that crest is a SPARSITY measure, not a percussion one — MAREA's single
      vocal phrase reads 17.98 — so it only ever gates onset density and is never used
      alone. HPSS was tried first and rejected: a kick-heavy drum bus reads 0.094
      percussive, because HPSS hears a modern kick as harmonic.
      
      18 tests.
      PLN (Algolia) authored
    • fix(foundry): the probe was looking in the wrong place, with the wrong lens · 2c5b68fd
      Three separate errors, each of which quietly threw away good material.
      
      **Where it looked.** Evenly-spaced probe windows sample the gaps. A stem is not
      stationary — measured on ANGIE, even windows read HIHATS at -60.9 dBFS / 1.4%
      active where the full file says -48.8 / 9.7%, and missed PAD STUFF's material
      entirely (0.0% vs 6.9%) because that stem only plays in two sections. Windows are
      now ranked by energy and chosen where the stem is actually playing.
      
      **What it measured.** Usability was gated on overall RMS, which on a sparse stem
      measures how much silence it contains, not whether it has material in it. ANGIE's
      HIHATS stem is -48.8 dBFS RMS and a perfectly good hi-hat stem peaking at -14.7.
      Level and activity now come from the whole file (it is only a frame-RMS pass) and
      the gate is peak plus activity relative to the stem's own peak. Three ANGIE stems
      and two MAREA stems come back from the dead.
      
      **What it concluded.** "Mostly low energy, low centroid ⇒ bass" fires on a drum bus,
      because a modern kick carries most of a drum mix's energy: ALL DRUMS reads lf 0.86,
      centroid 216 Hz, indistinguishable from BASS on those features. So ALL DRUMS was
      being filed into the bass kit AND mono-summed, and KICK with it. Onset density is
      asked first now (ALL DRUMS 5.5/s vs BASS 1.1/s), and a `hits` claim survives a bass
      measurement, because a kick IS low and slow and only the label says it is one hit.
      
      And the authority question underneath all of it. The house rule is that a sound's
      role is never INFERRED from its name; it is not that a producer's label about their
      own stem is worthless. Those have very different evidential weight. Presence and
      bass are decided by measurement — a stem under -40 dBFS peak has nothing in it, and
      "all energy under 150 Hz, nothing above 2 kHz" is unambiguous. Everything else keeps
      the label, because onset density cannot separate a rhythmic rhodes from a drum bus:
      MAREA's KEYS2 reads 5.1 onsets/s and its sparse CLAP reads 1.0, so measurement alone
      files both in the wrong kit, silently. Disagreements are reported, not resolved.
      
      Also: register_fred_rights.py records a freshly cut third-party kit in the rights
      ledger as third_party WITH its provenance, at the moment of cutting. Left alone it
      would land as `unknown` at the next --init, which blocks correctly but blocks with
      no reason attached.
      PLN (Algolia) authored
    • fix(foundry): three biases that would have shipped bad kits · 0bd4a75e
      All three found by running the thing, not by reading it.
      
      **Chops beat loops mechanically, every time.** grade classes anything under 0.75 s
      a one-shot and scores it 0.5*level + 0.3*dc + 0.2*zc -- no seam term, no bar term,
      because a one-shot is never looped. Export already removes DC and snaps zero
      crossings, so a chop lands near 1.0 by construction, while a loop pays 0.35 on seam
      and 0.25 on bar consistency and rarely maxes both. Measured on ME: every chop
      0.993-0.999 (S), every loop 0.841-0.881 (A). Ranked head to head the vocal kit came
      out as three stabs and no loops -- exactly backwards from what was asked for. Modes
      now rank in separate pools with their own slots.
      
      **Every loop got its own tempo.** analyze_stem reports a candidate's bpm as the mean
      local BPM inside its own window, which is the right number for detecting drift and
      the wrong one to derive a bar length from. ME's track grid read 129.2 bpm while both
      shipped loops were cut at 150.5 -- so two loops from one track do not layer, and
      loopAt lies. Bar length now comes from a single robust track tempo: the MODE of the
      local-BPM histogram, not the mean, because a few dropped or doubled beats drag a mean
      and leave a mode alone. Validated on a synthetic 120 bpm pack: 120.2, 0.2% off.
      
      Confidence comes with it -- the share of beats within 5% of that tempo. Below 35%
      there is no pulse worth cutting bars against and the track ships chops only. ME is
      the case that motivated it: no drum stem at all, so the grid was derived from a
      texture stem that merely measured percussive.
      
      **Near-duplicates were shipping.** A producer pack overlaps stems on purpose; ALL
      DRUMS, KIT, MAIN DRUM LOOP and DRUM BREAKS are four views of one groove and the
      finder picks the same bar out of all four. Eight slots holding three sounds is a
      worse kit than three. Dedup now runs at export, reusing kitcheck's mel fingerprint
      and its threshold so the auditor and the exporter cannot disagree about what "the
      same sound" means. Smoke test: 12 cuts -> 7, 6 of them distinct.
      
      Also: the merged report regenerates from the merged cuts instead of concatenating
      the children's fragments, which froze the tables before CLAP tagging ran and lost
      every tag; and kit_multiples_check no longer runs over chops, which are sub-bar by
      definition and were producing an off-grid warning that meant nothing.
      
      101 tests green.
      PLN (Algolia) authored
    • feat(foundry): batch producer stem packs — and 96 kHz was breaking the finder · b01e9c41
      The Foundry could make stems (yt-dlp → demucs) but not eat stems somebody else
      already made. Fred again's dropbox is 9 tracks × 6-13 producer-labelled stems at
      96 kHz/24-bit — better source material than demucs can produce, and completely
      outside what find_takes accepts: it keys stems by drums/bass/other/vocals, a dict
      that collides on KEYS1+KEYS2 and an export keep= filter that would have silently
      dropped every stem in the pack.
      
      engine/stempack.py is the missing batch driver (TODO #20), and building it turned
      up a real bug. MAREA ships its tempo in the filename (…123BPM…), so it is free
      ground truth. At native 96 kHz the finder returned 123/123/124/119/128.1/128.5 bpm
      and a 0.661 top score; resampled to 44.1 kHz first it returned 123.0-123.1 on every
      candidate and 0.859, in a third of the time. librosa's hop/window defaults are
      sample-rate-relative, so at 96 k every analysis frame spans half the musical time
      and beat tracking wanders. Rate is a correctness input, not a performance knob.
      
      engine/roles.py maps the pack's ~40 role tokens (39/40 hit directly) to a family,
      then verifies the label by measurement before anything trusts it — the house rule
      that a sound's role is never read off its name. The `hits` family
      (KICK, SNARE alone) is the one place the label carries information the cheap features cannot: an isolated kick
      and a full groove measure identically.
      
      engine/kitcheck.py audits what grade.py cannot see by construction. A four-bar loop
      whose last two bars are a fade grades S — silent-to-silent is a perfect seam and the
      length is still exact. So: per-bar RMS for dead bars, onset-envelope autocorrelation
      for whether a bar-aligned window is actually a repeating unit, and mel-fingerprint
      cross-correlation for near-duplicates, because a pack shipping ALL DRUMS + KIT +
      MAIN DRUM LOOP + DRUM BREAKS will otherwise ship one groove four times.
      
      --jobs fans out as subprocesses, not a ProcessPoolExecutor: the pool's forkserver is
      broken under this Python (BrokenProcessPool on a trivial task), and one process per
      track also means a track that blows up loses only itself.
      
      15 new tests.
      PLN (Algolia) authored
    • docs(log): 026 — the door was never locked · 2ef09af6
      Captain's log for the SoundCloud uploader landing. Written for the documentary
      rather than the board: the story beat is that I spent a session and a half
      picking a window on `POST /tracks` — killing three hypotheses one at a time,
      each death feeling like progress — while a real, logged-in, past-the-wall
      browser sat open with the actual upload form in it.
      
      Also records the two catches that were PLN's, not mine: the chip-input tags, and
      the MUI Select whose Private option my four probes all reported as absent.
      PLN (Algolia) authored
    • docs(board): the release is one re-run from done, plus a pre/post-gig setup plan · 67b14a31
      Session of 2026-08-17/18 written back to the board for a cold reader, since the
      Task API is still unreachable from this session and the scratchpad — which held
      the release plan and every log — was cleaned mid-session. (The plan regenerates
      from canonical sources, which is exactly why it is a generated artifact; it now
      lands in Prod/Opal26_master/release/ rather than /tmp.)
      
      A3 rewritten: the SoundCloud uploader WORKS. `POST /tracks` never needed solving
      — it was the wrong door. Recorded the account's verified state (8 of 15 up, all
      private, including the 72:41 mix), the two DOM facts that cost a session each,
      and two defects a re-run will NOT fix by itself: take-five-drops uploaded 24 s
      short, and ghosts-in-the-toilets carries one malformed tag from before the
      chip-input fix. Also recorded that api-v2 is a dead end for BOTH create and
      update, so nobody re-opens it.
      
      A5: the YouTube adapter is built, with the finding that decides its architecture
      — the Data API cannot publish at all from an unaudited project.
      
      New EPIC G (Slopmotion release videos) carries the renderer's two landed fixes
      with their measurements, the one modal still blocking, and the 50-commit pull
      that inverted the app's mode semantics under us.
      
      New EPIC H is what PLN asked for: the long-term setup plan split into PRE-GIG
      (what must be true before playing again — the 59%-audible kick, orphan orbits,
      preload staleness, SC supervision, the LCXL v3) and POST-GIG (the release
      pipeline). Plus H3, the standing lesson: three confident conclusions this session
      were wrong the same way — a blind instrument reported absence — and PLN's own
      eyes beat the probes every time.
      PLN (Algolia) authored
    • fix(slop): refuse to capture a scrim — PARTIAL, a second modal is still in the way · 8be26fb8
      The 19 bake-off clips all shipped with the photosensitivity warning sitting on
      top of the visuals. PLN: "lol all clips have the epilepsy warning on top of the
      viz xD and all seem to have very low fps, like maybe 5 ?"
      
      Extracting a frame showed one cause behind all three symptoms: the warning modal
      itself; the app's full UI chrome still visible (FX rail, top bar, shortcut strip)
      because the modal swallowed the "minimal performance shell" click and the `h`
      keypress; and the apparent ~5 fps, because ~90% of every frame was static chrome
      behind a dark scrim while 17.6 frames/s were captured faithfully. The frame rate
      was never the problem — I had measured it correctly and drawn the wrong picture
      of what was being measured.
      
      Seeding localStorage does NOT suppress it: hexa installs a dev-only bridge that
      persists localStorage to disk through the dev server and calls
      `hydrateLocalStorageFromDisk()` at boot, overwriting anything an init script
      seeded. Verified by trapping `Storage.prototype.setItem` and reading the stack —
      the write came from `localStorageBridge.ts:150`. So use the app's own control:
      uncheck "Show this warning at startup", click OK, which writes through the bridge
      and flushes to disk.
      
      PARTIAL, deliberately. The new guard now throws rather than capturing, and on the
      next run it caught a SECOND modal behind the first: "START A SESSION". So the
      bake-off clips were not slow — the scene had never started. Committing the guard
      alone because it is strictly better than silently shipping 19 more scrims, and
      refusing loudly is the behaviour worth having even before the fix is complete.
      
      Still to do (see the task board): a `git pull` brought 50 commits that also
      inverted the mode semantics — `?studio=1` is now stripped and Studio is the
      DEFAULT, with `?live=1` opting into the full-bleed performance shell; Studio
      freezes shader time when `studioTransportActive === false`, which is the other
      half of "no motion"; and the "Minimal performance shell" button this script
      clicks no longer exists at all.
      PLN (Algolia) authored
    • feat(probe): five probes that found what four guesses could not · 05c8ab30
      Every SoundCloud blocker this week fell to looking at the page instead of
      reasoning about it, so the probes are kept rather than thrown away — each one
      encodes a question worth re-asking when SoundCloud changes its uploader again.
      
        * `probe_upload_form.py` — enumerate every input/select/textarea/button in the
          upload frame with its label. This produced the whole selector table the
          uploader is written against: #title, #trackPermalink, #artist, #primaryGenre,
          #tags, textarea#description, input#fileInput, and the Upload button.
        * `probe_upload_privacy.py` — read the radio groups properly. Answer: they are
          geo (worldwide/exclusiveRegions/blockedRegions) and licensing
          (all-rights-reserved/commons). NOT privacy.
        * `probe_upload_legacy.py` — does an older, non-v2 uploader still exist with a
          Public/Private choice? Written when I wrongly believed the v2 form had none.
        * `probe_upload_watch.py` — open the real form and poll for state changes, so
          PLN can click a control and have the script report exactly which element it
          is. Built after concluding, twice, that a control was absent.
        * `probe_upload_sharing.py` — drive the MUI Select and VERIFY the trigger
          changed. Confirms Public -> Private end to end.
      
      The lesson the set exists to encode: I reported "the v2 uploader is public-only"
      after sweeping every input, select and radio in the frame and finding nothing.
      The control was a MUI Select, whose options are portal-rendered only once the
      trigger is clicked — so an unmounted menu and an absent feature are
      indistinguishable to a DOM sweep. PLN pasted the real markup
      (`li[role=option][data-value="Private"]`) and it drove fine first try. A
      component library's Select is not `<select>`, and "I searched and found nothing"
      is a statement about the search.
      PLN (Algolia) authored
  2. 17 Aug, 2026 5 commits
    • fix(slop): headless DOES reach the GPU — `--use-angle=gl-egl`, not `--use-gl=egl` · 32e7a008
      Correcting my own previous commit, which concluded hardware GL required headful
      and made `--gl hw` open a real window per clip. PLN, mid-batch: "stop opening
      chrome windows lol cant we do this headless". Yes — and the conclusion was wrong
      because I tested three launch modes and stopped at the first that worked.
      
      Five headless modes plus headful, each asked what WebGL actually resolved to:
      
          --use-angle=swiftshader                       -> SwiftShader (Subzero)
          --use-gl=egl                                  -> SwiftShader (Subzero)
          --use-angle=vulkan                            -> SwiftShader (Subzero)
          --ozone-platform=headless --use-angle=vulkan  -> SwiftShader (Subzero)
          --use-gl=angle --use-angle=gl-egl             -> Mesa Intel UHD (CML GT2)
          HEADFUL                                       -> Mesa Intel UHD (CML GT2)
      
      `--use-gl=angle --use-angle=gl-egl` reaches the real GPU with no window at all.
      It is ONE FLAG WORD from `--use-gl=egl`, which silently degrades to software —
      which is exactly why the original code read as correct and was not, and why the
      comment claiming the hw path "falls back by simply measuring worse" survived so
      long. It never fell back: 3.5 fps and 3.2 fps were the same software backend
      measured twice, and two numbers that looked like measurement noise were one wrong
      assumption.
      
      Measured, landscape 1920x1080, same 30 s window each time:
      
          swiftshader (headless)   3.5 fps captured
          headful + real GPU      11.4 fps captured, page rendered 17
          headless + gl-egl       17.8 fps captured, page rendered 542 of 542
      
      Headless is FASTER than headful — no compositor in the path — and the capture
      bottleneck disappears with it: captured frames now equal rendered frames, where
      headful drained 346 of 516. So the earlier "the next lever is the capture path"
      note was also an artefact of the wrong launch mode. 5x the software baseline,
      comfortably past the under-5-fps guard, and no windows.
      
      Lesson for the file: an early success is not a search. Two of the five modes
      reach the GPU and three do not, and the three failures are indistinguishable
      from each other by frame rate alone.
      PLN (Algolia) authored
    • fix(slop): headless never reached the GPU — `--gl hw` was software all along · 1fdfb85f
      Every bake-off render failed, for two independent reasons, and the second one had
      been sitting in this file as a comment asserting the opposite of the truth.
      
      ## 1. The playwright browser cache is empty now
      
      `chromeBinary()` scans `~/.cache/ms-playwright` for a full chromium build and
      returns `undefined` if it finds none, on the documented theory that this "lets
      playwright pick". It does not. Playwright reaches for the exact build it shipped
      against — `chromium_headless_shell-1234` — which was never downloaded, so every
      render died with "Executable doesn't exist" before rendering a frame. The cache
      directory is now absent entirely (it held build 1217 back in July). Falls back to
      `/usr/bin/chromium`: a full build, already driven by the SoundCloud transport, and
      no ~150 MB pull into PLN's shared cache behind his back.
      
      ## 2. `--gl hw` never got hardware GL
      
      The old comment claimed swiftshader manages ~7 fps and that `--gl hw` "asks for
      the real integrated GPU via EGL. Falls back by simply measuring worse." It was
      not falling back — it never left software. Instead of inferring the backend from
      frame rate on a machine whose load is a variable, I asked WebGL what it resolved
      to:
      
          headless + --use-angle=swiftshader -> SwiftShader (Subzero)
          headless + --use-gl=egl            -> SwiftShader (Subzero)   <- identical
          HEADFUL                            -> Mesa Intel UHD (CML GT2)
      
      Headless cannot reach this box's GPU whatever the flag. Which explains the
      symptom exactly: landscape 1920x1080 captured 3.5 fps on swiftshader and 3.2 fps
      on "hw", tripping the renderer's own under-5-fps guard both times — two numbers
      that look like noise and were actually the same backend twice.
      
      `--gl hw` now means HEADFUL. Measured after: **11.4 fps captured, page rendering
      17** — 3.3x, comfortably past the guard. Note the bottleneck has moved: the page
      now renders faster than the CDP screencast drains, so the next lever is the
      capture path (frame encoding), not the GPU. Cost is a visible window for the
      duration of each capture.
      
      The July note warned against trusting those fps follow-ups because they were
      measured at load average 12.5, and it was right to. But the fix was not a quieter
      benchmark — it was asking a question that load cannot corrupt. Third time this
      month a "the rig is broken" finding turned out to be the instrument.
      PLN (Algolia) authored
    • feat(slop): 19-preset bake-off — grade the looks before spending 2h25m · c8e1aa64
      PLN: "dont render 2h for now lol render 20x different presets 30s clips then let
      me watch/listen/grade through. try to do sth gtreat soundreactive". Right call —
      19 clips of 30 s is ~10 minutes of realtime capture against 2h25m for the record,
      so the taste decision gets made on cheap evidence.
      
      Also recorded: "slopmotion gave me rights to all packs, we can use any, lets be
      free and test stuf". Every local pack is fair game, which is worth writing down
      precisely because it is the opposite of the sample-bank situation next door.
      
      ## Experiment design
      
      Every clip renders the SAME 30 s window. With the audio fixed, the only variable
      is the look, so the grades mean something; vary two things and a favourite tells
      you nothing about why. 18 packs get one clip each at one reactive baseline, and
      clip 19 is `parvagues` again with reactivity OFF. That control earns its slot —
      "do something great soundreactive" is unanswerable from 19 all-reactive clips,
      because nothing shows what the reactivity contributes. parvagues sorts first as
      PLN's own imagery; the control sorts last so it is graded next to its twin.
      
      The reactive baseline is four FX chosen to read differently on a transient —
      glitch and rgbDelay snap, liquix flows, slowmo pulls time — since under
      `--triggerHeavy` each becomes kick-synced. Four subtle washes would hide the
      exact thing being evaluated.
      
      ## The window, and a metric that was measuring the wrong thing
      
      Reactivity feeds on onsets, so I scored 30 s windows by summed positive spectral
      flux normalised by level. It picked 69:18 at -23.3 dB — the near-silent seam
      between Vague de CRIME and REVOLUTION, i.e. PLN's 2 s rest plus a reverb tail.
      Dividing by the window mean rewards silence: a quiet window with any activity at
      all beats a loud groove. Using the level-gated answer instead — 1:34, -10.1 dB,
      inside the techno opener, four-on-the-floor — which is the honest test for
      kick-synced triggers. Same shape as ranking orbits by level instead of punch.
      
      Two preflight checks that exist because of what they would have cost: a pack
      needs BOTH local loops and a `videoLibrary.json` row (the renderer picks its
      backdrop from the library, so a pack with 30 files and no row fails at clip 12,
      after eleven realtime renders), and dead symlinks into Kevin's own machine are
      skipped rather than attempted.
      
      The grading page follows judge.html and bounds.html: local, not an Artifact,
      because 20 clips is hundreds of megabytes and the viewer sandbox blocks a
      page-initiated download — and the grades have to come back as a file.
      PLN (Algolia) authored
    • feat(release): Slopmotion renders the release videos, not a still I found lying around · e794a441
      I had `master yt render` wrap each track in a still image, and picked that still
      from `output/*.jpg`. PLN: "i dont like these covers, wait where are tehse genai
      images from? these are not great sources! we wanna use slopmotion for videos
      remember?" Both objections land, and the first is the one worth recording:
      
        * Those nine illustrations are UNTRACKED and have no recorded provenance.
          `6c3272c9 refactor: Remove covers from git` had deliberately taken covers out
          of this repo, so "sitting in output/" was evidence against using them, not
          for it. I built a per-bank rights ledger for the audio this same session —
          one that refuses a sample bank precisely because its origin is unrecorded —
          and then reached for pictures off the floor. The discipline has to apply to
          every asset in a release or it is not a discipline.
        * Slopmotion already does this properly, already supports `--shape landscape`
          at 1920x1080 explicitly "for YouTube", and has already produced a real
          7m26s landscape render. Nothing needed inventing.
      
      So this bridges the release plan to `visuals/slop/render_slop_clip.mjs`, naming
      its output exactly what `tidal_ears.yt.video_path` produces — imported from the
      adapter rather than reimplemented, because two copies of a filename rule is how
      a renderer and an uploader come to disagree about which file is track 7.
      
      ## Realtime is the whole scheduling story
      
      Slopmotion captures in realtime by design: Hydra's clock is wall time, so
      frame-stepping runs motion ~3x fast and drifts off the audio it reacts to. A
      full-length render therefore costs the track's own duration. OPAL-26 measures
      **2h25m** of headless Chromium for the landscape shape — 1h12m of continuous mix
      plus 1h13m of tracks. Too long for a shell job that dies with its terminal, so
      `--unit` writes a `systemd --user` service with `TimeoutStartSec=0`. Renders are
      resumable, so an interruption costs one clip.
      
      Preflight refuses before spending any of that: node >= 22 (system node is 16 and
      fails), the dev server actually answering on :5173, every audio file present,
      the playset known to the 24-pack catalog.
      
      ## The join that had to stay explicit
      
      `clip_ideas.json` agrees with the release plan on neither axis. Its titles are
      shorter or French ("Sunshine", "La Revolution Sera Samplee") and its
      `setlist_pos` follows an EARLIER running order — its 4 is Take five Drops where
      the plan's performance 4 is Am i Doing it Right. So position is not a join and
      title equality only catches 4 of 6 live ideas. Fuzzy matching would be worse
      than nothing: it puts a look on the wrong track silently. A two-entry alias
      table takes it to 6 of 15; the other 9 report no playset and the tool refuses to
      guess, because which visual goes with which track is PLN's call.
      PLN (Algolia) authored
    • feat(probe): look at the page instead of guessing — the form was in an iframe · 85123f6a
      Two probes for the SoundCloud upload flow. `probe_upload_dom.py` is the one that
      settled it: rather than hypothesise about why /upload showed no file input, it
      dumps overlays and z-indexes, enumerates every frame, walks shadow roots, prints
      the main region's text, and tries clicking whatever looks like a drop zone. The
      answer arrived in one run — 0 file inputs in the top document, 1 in a nested
      same-origin iframe at /n/upload.
      
      `probe_upload_ui.py` now attaches in that frame and listens on the CONTEXT
      rather than the page, because a frame's fetches never surface on the parent's
      request event — the same blind spot in a second guise. It reached the real form:
      inputs named title / trackPermalink / artist, plus an Upload button.
      
      It captured zero finalize requests, and that is correct: it deliberately stops
      short of clicking Upload, and the finalize call only fires on that click. Which
      raises the better question — with a real form and a real Upload button in front
      of us, reconstructing `POST /tracks` may be work we never have to do.
      PLN (Algolia) authored
  3. 16 Aug, 2026 13 commits
    • docs(board): resume points for the uploader, the ledger, and the gig list · 6dda5f75
      Writing for a cold reader, because the valuable part of this session is the
      DEAD hypotheses and those live only in context.
      
      POST /tracks -> 400 {"errors":[]} has now survived three explanations and each
      one cost a real experiment: it is not fingerprinting (identical from curl_cffi
      and from the page's own fetch), not the envelope ({"track":{…}} is the only
      shape that parses at all — flat, track[…] form and asset_data are rejected with
      "Failed to parse track create request"), and not the missing web session
      (retested after PLN logged in for real; session_ok() True, still 400). Anyone
      resuming would try those three first, so the board says not to.
      
      The one lead left is the consent dialog covering the upload form — session_ok()
      is True while input[type=file] count is 0 — so probe_upload_ui.py moves out of
      the scratchpad and into the repo. It attaches a 1s probe to the REAL form and
      logs every non-GET api-v2 request, which is how we stop reconstructing a shape
      from publish.py's older API and start copying one that demonstrably works.
      
      Also recorded: the multi-platform architecture PLN asked for (one manifest, many
      adapters, one state file), with the research behind it — Bandcamp has no upload
      API or CLI at all, RouteNote has no open API but ships a desktop tool, YouTube
      has a real one and gets chapters free from segments_v4. And the gig-list audit:
      38 gigs published, 2026 has four, opal-festival-2026.md still has empty
      audio/video/archive fields waiting on the upload.
      PLN (Algolia) authored
    • feat(rights): PLN clears the five keystone banks — 29 of 60 now answered · 15ed1664
      His verdicts, verbatim: "jungle breaks is a open domain cd, h2o is hydrogen oss
      drum machine, kick risers are from the tidalcycles officials or samples-extra
      cc0 packs, and vec1/2 come from offered free packs".
      
      Recorded against 10 banks rather than 5, because the same source answers more
      than the bank that prompted the question: org_jungle_breaks shares its CD,
      h2ogmcp and h2ogmcy are the same Hydrogen GM kit as h2ogmhh, and vec1_acid and
      vec1_snare are the same VEC pack as vec1_claps. Each carries the source in its
      note, so the reasoning survives the session that produced it.
      
      This is what ranking by leverage was for: five answers moved 60 banks from 19
      clear to 29, and the four heaviest gates in the record (14, 9, 9 and 8 tracks)
      are now open. 31 remain, and the next one down is `snare` at 4 tracks — likely
      the same CC0 family as kick and risers, but that is a guess and a guess is
      exactly what this ledger exists to not record.
      PLN (Algolia) authored
    • feat(rights): enumerate the banks a release contains, and rank the questions · a00066b1
      PLN: "cant push the single tracks when samples hit, but full mix might well
      pass!" — which matches the measurement (an isolated track flagged where the
      87-minute mix passed). So the gate on a per-track release is per BANK, and this
      tool answers "what is in here and where did it come from". The verdict stays
      his, recorded once in rights_ledger.json so it is never re-litigated.
      
      THE DISCRIMINATOR IS THE WHOLE COMMIT. "Lives in the Dirt-Samples folder" is not
      "is a Dirt-Samples bank": 56 of OPAL-26's 60 banks resolve to that directory,
      including the_revolution, like_sugar, desire, wap and crimewave — because local
      banks were dropped in beside the quark's own. Classifying by location would have
      returned a confident, comprehensively wrong LOW-RISK verdict on the most
      obviously sampled material in the set. That is the same shape as reading a role
      off a sample's name.
      
      The quark is a git checkout, so `git ls-files` settles it exactly:
      217 upstream banks, 509 added locally. Tracked = ships with the quark under its
      license, auto-verdict `dirt_samples`. Untracked = provenance unknown to the
      tool, so `unknown`, which blocks by design. A bank in the score but not on disk
      reports UNRESOLVED rather than passing as safe.
      
      RANKED BY LEVERAGE, not alphabetically. 41 open banks reads as 41 equal chores;
      in fact five gate almost the whole record — jungle_breaks (14 tracks), h2ogmhh
      (9), kick (9), risers (8), vec1_claps (6). Answering those five changes the
      shape of the release; answering `take5` changes one track. Keystone first.
      
      First run on OPAL-26: 60 distinct banks, 19 upstream and clear, 41 open, so all
      15 tracks currently block. That is the honest starting position, not a failure —
      and it is per-track only. The continuous mix remains the lower-risk artefact and
      is a separate question.
      PLN (Algolia) authored
    • feat(release): publish the mix as an artefact, with a real tracklist · 56a402ed
      PLN: "cant push the single tracks when samples hit, but full mix might well
      pass!" — which matches the measurement (an isolated track was flagged where the
      87-minute mix passed). So the continuous mix is not a by-product of the release,
      it is the artefact most likely to survive, and --include-mix makes it a first-
      class entry in the upload plan.
      
      It is listed FIRST on purpose: if an upload run dies halfway, it dies having
      landed the important thing.
      
      The description carries the tracklist with timecodes, and those come from the
      CUMULATIVE RENDERED durations rather than from segment arithmetic. The mix is
      literally these files concatenated, so summing what came out is exact — and it
      stays exact through effects that lengthen a track, which is not hypothetical
      here: CRIME carries a 2s rest and REVOLUTION a 650ms reverb tail, so segment
      maths would have every timecode after track 13 drifting.
      
      Timecodes switch to h:mm:ss past the hour. The last four tracks of a 73-minute
      set are all past 60 minutes, and "69:15" is a number nobody can place.
      
      The mix title is composed from canonical FIELDS (title · venue · year), never
      from a remembered string: "Sunset Forest — Opal Festival 2026 (full set)". The
      format lives in the code, the facts stay in Web/www.
      PLN (Algolia) authored
    • feat(release): generate the upload plan from canonical sources, never by hand · 719f0764
      `sc album --plan` wants titles, files and tags as JSON. Typing that by hand is
      exactly how a release ships with the wrong album name — split_bandcamp.py once
      carried an ALBUM string that was simply wrong and nothing downstream could tell.
      
      So build_release_plan.py generates it, and every field is copied from the one
      place that owns it: gig metadata from Web/www content/lives/{year}/{slug}.md,
      per-track section/style/sample-banks from that gig's tracks.json, release order
      and durations from the rendered segments, approval from the ear file's
      release_signoff. `_provenance` records each source, so a wrong string is traced
      rather than argued about.
      
      TWO REFUSALS, both because the failure they prevent is invisible after upload.
      
      1. NO SIGNOFF, NO PLAN. The ear file's release_signoff is pinned to the segments
         PLN approved; if it is missing, or the rendered FLACs are NEWER than it, the
         record on disk is not the record he cleared. An upload is hard to take back,
         so the check belongs before it and not in a checklist. --force overrides and
         says so in the log.
      
      2. NO ROW, NO PLAN. The first run joined the tracklist on the raw title and
         silently missed 4 of 14 — because the canonical names are written in PLN's
         blog voice ("There's **Something About Drums** <3", "Am i _Doing it Right_",
         "GHOSTS IN THE TOILETS") while the segments carry the plain form. The plan
         looked complete: 14 tracks, right durations, right files. Four of them just
         had empty genre, empty section and NO sample banks — and the sample-bank list
         is the input to the rights question. A parser miss must never become a quiet
         gap in the catalog, so an unmatched title now refuses the whole plan and
         prints both name lists side by side.
      
      The fix for the join itself is match_key(): strip emphasis, hearts, case and
      punctuation for MATCHING ONLY, while every value that ships still comes from the
      canonical record verbatim. Which surfaced a real question rather than hiding it
      — the two sources disagree about three names, so --titles chooses, defaulting to
      canonical, and the diff is printed:
      
          "There's Something About Drums" -> "There's Something About Drums <3"
          'Perfect'                       -> 'Perfect <3'
          'Ghosts in the Toilets'         -> 'GHOSTS IN THE TOILETS'
      
      Those decorations are his voice, not markup, and clean_title already knew to
      keep them. Verified against the shipped audio: 14 tracks, durations matching the
      rendered files including CRIME's 2s rest (271.1s) and REVOLUTION's reverb tail
      (205.6s). Every track carries `_rights_checked: false` — the banks are listed,
      nobody has cleared them.
      PLN (Algolia) authored
    • docs(opal26): the record clears by ear — release signoff recorded · c7751920
      PLN on the rebuilt seams: "listened to 13... perfect end for crime. revolution
      starts good. listened to 99... all good perfect ending!"
      
      That listen is the one that counted, and it is worth saying why: both clips were
      audio BUILT FROM his approvals rather than audio he had approved. The 2s rest and
      the MORE reverb tail existed only as instructions until this pass — a chain of
      individually-approved edits can still add up to an ending nobody has heard.
      
      Recorded in three places, each for a different reader:
        - `release_signoff` in the ear-boundary file — machine-checkable provenance,
          pinned to segments_v4 as rendered today. Any later re-render invalidates it;
          the note says so, so a future uploader can refuse rather than guess.
        - performance_notes.md — the ear-feedback corpus, per the archivist rule.
        - TASKS_DUMP.md — A1 closed, the last gate marked CLEARED.
      
      Two taste calls banked for future sets:
      
      SILENCE IS A MUSICAL ELEMENT, AND IT BELONGS TO THE OUTGOING TRACK. His fix for
      the CRIME->REVOLUTION seam was not a longer fade or a different cut point — the
      cut was already approved. It was "add after seam 2s silence in crime file". He
      wanted the last official track to end on its OWN silence and the encore to start
      clean. Future set-ending seams should offer a rest as a candidate, not only
      fades.
      
      HE REVISED THE REVERB AFTER LIVING WITH IT. Auditioned in isolation: "pick
      subtle". Heard as the record's ending: "allez lets use the B version, the more
      option" — roughly 2.7x the ring (650ms top tap vs 240ms). An effect judged on a
      bare clip and the same effect judged as an ending are different judgements, so
      render the A/B IN PLACE next time.
      
      Both were a second listen changing the answer, which is the whole argument for
      keeping the audition loop cheap: neither correction cost more than a re-render.
      PLN (Algolia) authored
    • fix(joins): the close clip was labelled "dry" while shipping a reverb tail · 57745a7c
      build_release_joins hardcoded its first closing clip as CLOSE · DRY, "what ships
      today". That was true exactly once — before any reverb was chosen. The clip is
      cut from the RENDERED last track, so the moment PLN picked a preset the file he
      auditioned had the echo baked in and the label said it did not.
      
      An audition UI that misdescribes what ships is the one bug this tool cannot
      have: its entire job is to let the ear check the actual artefact, and a wrong
      label turns a passed listen into a false clearance. Same family as the earlier
      allow-list that silently dropped reverb_preset — the output looked clean and was
      clean, of the wrong thing.
      
      Now the first entry is CLOSE · SHIPPED and reads the preset out of the release
      segments, so it renders as "SHIPPED · MORE tail baked in". Names the artefact
      from the data that produced it rather than from an assumption frozen at the time
      the code was written. Wrapped in try/except: a missing segments file drops the
      suffix rather than blocking the audition.
      
      The A/B variants are unchanged and still opt-in behind --close-ab.
      PLN (Algolia) authored
    • feat(postprod): a rest is not a gap — pad_end_s, and the close goes to MORE · 271e07c0
      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.
      PLN (Algolia) authored
    • docs(tasks): archive #177 and #178 — the re-cut, and why the detector was closed · 6c508e4d
      Written as documentary source material: three detectors' numbers, why the
      best-looking one was the least trustworthy, and the reframing (onset vs
      takeover) that a failed lens produced.
      PLN (Algolia) authored
    • fix(postprod): drop the apad — it was ending the record in 2.3s of digital silence · db6529dc
      The reverb tail shipped with `apad=pad_dur=2.5` on the reasoning that ffmpeg
      would otherwise chop the echo at the last sample. That reasoning was wrong, and
      the verify stage is what exposed it: REVOLUTION came out 207.64s against a
      predicted 207.40, and chasing the 0.24s led to the actual behaviour.
      
      Measured, four ways:
      
          no apad   -> 5.240s wet, 0.000s trailing silence   (full ring, nothing cut)
          apad=0.05 -> 5.290s wet, 0.050s trailing silence
          apad=0.35 -> 5.590s wet, 0.350s trailing silence
          apad=2.5  -> 7.740s wet, 2.500s trailing silence
      
      aecho extends its OWN output by its longest tap (240ms for subtle), so the ring
      was never at risk. Every second of apad simply became trailing digital silence,
      one for one. The rendered record therefore ended with ~2.3s of pure zeros —
      which is precisely the defect PLN flagged at the other end of the record
      ("trim leading silence tho"), reintroduced by me at the close.
      
      Two things worth keeping from how this surfaced. The 0.24s mismatch was real
      information, and the tempting fix — `atrim` the output back to the predicted
      length — would have clipped the end of the ring while making the check go green:
      the prediction was wrong, not the audio. And the fix only became findable
      because verify compares against a number derived from intent rather than from
      whatever the renderer happened to produce.
      
      expected_dur is now duration + aecho's longest tap, exactly. REVOLUTION: 205.14s.
      PLN (Algolia) authored
    • feat(opal26): subtle reverb tail chosen — and the A/B and the release now share one chain · 10829fde
      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.
      PLN (Algolia) authored
    • feat(opal26): re-render complete — seam edits applied, close A/B ready · 40e04cb0
      Full pipeline run on the edited segments. All green:
      
        split      14 tracks x streaming + club
        verify     every duration exact, 48000/24
        continuous 14 segments, 72.6 min, 3 gaps -> 4358.70s both variants
      
      The three gaps are the trims, and seeing them enumerated is the point:
        [ 530.35 ..  531.35]    1.00s   the silence PLN wanted cut to 1s
        [ 892.96 ..  893.00]    0.04s   WAP's truncation burst
        [4156.30 .. 4566.47]  410.17s   Desire, plus his CRIME/REVOLUTION trims
      
      Verified the WAP fix by measurement rather than by assuming the trim landed:
      peak in the track's last 100ms is now -55.0 dBFS, down from -15.1, and it decays
      to -70 instead of being chopped mid-transient. What remains near the end is
      ordinary music ringing out into the 30ms guard fade, which is the intended
      result — the truncated sound is gone, not merely quieter.
      
      Release-check set rebuilt: 17 clips (open + 13 seams + 3 closes). Smoke passes
      against the production server.
      PLN (Algolia) authored