1. 16 Aug, 2026 29 commits
    • 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
    • feat(bounds): reverb-tail A/B for the record's close · bc7d60c1
      PLN: 'maybe with reverb over last 5s so we hear it slightly echo as last sound'.
      Rendered as an A/B rather than baked in — an echo on the final sound of a record
      is a taste call, and 'maybe' is not a decision.
      
      Two flavours (subtle / more) plus the dry render, so the comparison is three
      clips in the same view. The wet part is the TAIL ONLY: a global reverb would
      wash the whole ending, and everything before the last 5s stays identical to dry.
      The tail gets apad before the echo so the ring has somewhere to go — without it
      ffmpeg chops at the last sample and the 'echo as last sound' becomes another
      hard cut, which is the thing being fixed.
      PLN (Algolia) authored
    • feat(postprod): per-track trims and fades — the seam pass, from PLN's ears · 7b9dd178
      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.
      PLN (Algolia) authored
    • feat(postprod): the set→record pipeline becomes reusable tooling · 30e62f45
      PLN: "this becomes our tooling, often a set will be postprocessed as we did —
      reusable scripts and views plz". So the per-gig finish.sh is replaced by
      spec-driven tools: a new gig is now a copy of judge_specs/<gig>.json, never a
      copy of a script. POSTPROD.md documents the whole flow and the trap each stage
      exists to avoid.
      
      render_release.py — split every variant, verify durations and format, and
      re-render the continuous mixes. Ran clean: 14 tracks x streaming+club, all
      durations within tolerance, 48000/24.
      
      Its `continuous` stage answers PLN's other catch: "imo the master club rec will
      also have desire removed". A set is released TWICE — as tracks, and as the
      unbroken mix — and cutting a track had only fixed the first; both continuous
      masters still played Desire in full. This matters past 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 and it has to match the tracklist. The excision derives its kept
      spans from the same segments the tracks came from, so mix and tracklist cannot
      disagree. Both variants now render to 4403.65s = 73.4 min, matching the sum of
      the 14 tracks exactly:
          [1.45 .. 4166.30] 69.4 min  +  [4547.57 .. 4786.37] 4.0 min
      
      build_release_joins.py — the confirmation pass PLN asked for ("i wanna confirm
      all tracks start/ends/transitions"). It deliberately does NOT use the master:
      once Desire is dropped, the seam a listener hears (CRIME straight into
      REVOLUTION) exists only in the rendered output, and a master-based window there
      would play six minutes of a track that does not ship. Building from the split
      FLACs also audits the files that actually ship, so a split error surfaces before
      upload rather than after. 15 clips: the record's opening, 13 seams, and its
      closing.
      
      apply_boundaries now emits the splitter's native shape — `track` is RELEASE
      position because tidal_ears.master split uses it for both filename and the
      `track=N/total` tag; performance order stays as `perf_track`. Emitting
      performance order would have named the last file "15 - REVOLUTION" in a
      14-track album and tagged it 15/14, which stores reject.
      
      Both UI sets pass bounds-smoke (11 assertions each). The test now derives its
      data file from the page's own ?set= — hardcoding it meant it could assert
      against a different set than the browser had loaded, passing or failing for
      reasons unrelated to the code.
      PLN (Algolia) authored
    • feat(opal26): every boundary settled — segments_v4 generated, 14 tracks · 6099df85
      PLN's last call: "00:31:3 is a decent end of crimewave to cut early on end of
      the crime sound". Read off the transport, which shows CLIP time, and #14's clip
      starts at 4135.0 — so master 4166.3. The audio agrees rather than merely
      permitting it: the crime sound gaps to -71 dB at 4164.5 and 4166.3 sits just
      past that on its decay, 13.7 s before the nominal 4180.0. Recorded with that
      mapping written out, because clip-vs-master is the one confusion that would
      shift a cut by 45 s and look plausible.
      
      apply_boundaries.py turns the ear file into segments_v4.json. Made a tool rather
      than a hand edit because this is the step that shipped a wrong album once, and
      its three rules are each silently destructive to get wrong:
      
        - starts come from the ear, ends from the neighbour (butt-joined), so editing
          one start moves two tracks;
        - a track CUT from the release still owns its boundary — Desire is dropped but
          the edge into Desire is where Vague de CRIME ends, and ignoring it would run
          the last official track into Desire's intro;
        - release position is not track number: dropping a track renumbers the album
          but must not renumber the provenance, so both `n` and `track` are emitted.
      
      It refuses to write on overlap, non-positive duration, out-of-master range, or
      an ear-verified start that failed to reach the output. All green:
      14 tracks, 73.4 min kept of a 79.8 min master, 6.4 min dropped (Desire).
      
      Remaining unverified edge, for the record: #2's start (531.35) is still the
      nominal, so #1's end rides on it. It was never flagged in the judge pass.
      
      Ear feedback archived in performance_notes.md, including the finding that #11
      and #12 disagree about which fraction is right — takeover on one, mid on the
      other, 8 s apart. Two adjacent cuts, opposite answers: where a cut belongs is a
      musical judgement about that handover, not a parameter to fit.
      PLN (Algolia) authored
    • feat(opal26): all four open cuts settled by ear — and the one nobody had counted · af60c81a
      PLN made the four calls in the boundary lab. Merged into `verified`, so the
      ear-boundary file now covers #1 and #3-#13 and #15; `unjudged` is empty. The raw
      export is committed next to it as provenance rather than being consumed and
      thrown away.
      
        #11  3557.33   takeover was +0.13s  -> the machine had it
        #12  3680.10   takeover was -8.00s  -> "here the right cut is mid :)"
        #13  3887.25   ear-only, no candidate could exist
        #15  4547.57   ear-only, no candidate could exist
      
      Worth recording that #11 and #12 disagree about WHICH fraction is right: on one
      the takeover was the cut, on the other the midpoint was, 8 s earlier. That kills
      any remaining hope of a single universal fraction and confirms the tool's shape
      was the right call — offer the candidates, let the ear pick, do not average.
      
      The unnoticed gap: #13's END. The set is butt-joined, so that edge is the
      boundary INTO Desire (nominal 4180.0), which was never verified because Desire
      itself is cut from the release — it looked like work that no longer mattered.
      It is the opposite: with Desire dropped, that edge is where the last official
      track ends, and if it is late then Desire's intro bleeds into the end of the
      record. Rendered it as a fifth boundary; the builder now reads
      `cut_from_release` and labels such an edge "END of #13 Vague de CRIME" with an
      on-screen explanation, so the next gig cannot lose the same edge the same way.
      
      Also made bounds-smoke pick its subject by content instead of position: it broke
      the moment #11 became settled and lost its candidate buttons, which is a data
      change, not a regression. It now prefers a boundary that still has a machine
      candidate and falls back to the nominal marker every boundary carries. 11/11
      against the production server.
      PLN (Algolia) authored
    • feat(bounds): the boundary lab — decide a cut by ear in seconds, not a full pass · 192c6e9e
      The set-judge answers "is this track a keeper". This answers the question that
      actually blocked the release: where exactly does one track become the next.
      After three failed detectors (cut_lens3 declared a dead end in 5f7db02a), #13 and
      #15 are ear-only by nature — the two tracks share their sounds across the switch,
      so no timbral lens can see the handover. The tool's job is therefore to make one
      ear call cheap, not to avoid asking.
      
      build_bounds_set.py renders a ±45 s FLAC clip per boundary instead of seeking the
      817 MB master. A boundary audition is a scrub — dozens of small seeks around one
      point — and seeking a long FLAC depends on a seektable the browser may not use
      well; local clips make every seek instant for a few MB total. Clips are
      re-encoded, never `-c copy`, because FLAC stream-copy snaps to frame boundaries
      and every marker inside the clip would inherit that offset. Clip time and master
      time are kept explicitly apart (`clipStart`), converted in exactly two functions.
      
      The UI shows each cut's candidates as labelled markers on the waveform, drawn as
      regions rather than positioned divs so they stay glued to the audio through zoom
      and scroll. `takeover` leads the list because it landed within 0.7 s on both
      boundaries that also had ear answers — with the caveat printed on screen, since
      that is n=2 on the cases that were already easy. Boundaries with NO candidate are
      shown with an explicit notice rather than omitted: those are precisely the ones
      needing a human, and hiding them would hide the work.
      
      Fixes a real bug in the SHARED player while here: `ws.zoom()` needs a decoded
      buffer, which the peaks path never has, so it threw "No audio loaded" from an
      effect and blanked the entire page. Same message as the peaks+url trap, entirely
      different cause. Now guarded and failed-soft — zoom is a convenience and must
      never take the tool down. The judge had this latent too.
      
      bounds-smoke.mjs, 11 assertions, all passing. Two of them exist because the first
      version of this test passed while proving nothing: querySelector('audio') is null
      by construction (wavesurfer owns the element and never puts it in the DOM), so
      the playback probe would have "passed" forever on a dead transport. It now reads
      the transport clock and the Pause button — what a human sees. A positional
      selector also silently picked the header's counter instead of the clock, hence
      the explicit data-testid.
      PLN (Algolia) authored
    • feat(opal26): #1's head trim is now a measured number, and candidates get their own room · d3dbe59a
      Two changes to the ear-boundary ground truth, kept deliberately distinct.
      
      #1 was the last GO with an unfinished instruction — PLN said "trim leading
      silence tho" without an amount. The amount is now measured rather than
      estimated: every sample is EXACTLY zero up to sample 64038 (1.452109 s @
      44.1 kHz), and the first audible frame peaks at -28 dBFS. That is a sample-exact
      scan, not a threshold and not a detector, which is why it is allowed into a file
      whose header forbids detector output. Recorded as start=1.45, keeping a ~2 ms
      guard so the first transient cannot be clipped, with the full measurement and
      method stored alongside it for audit.
      
      #11 and #12 gain machine PROPOSALS in a new `candidates` block, structurally
      separate from `verified` so nothing can be mistaken for a confirmed boundary.
      On the two boundaries that also have ear answers, the `takeover` estimate landed
      within 0.7 s (#8 2726.25 vs 2726.4, #10 3268.3 vs 3269.0) — which is a reason to
      audition takeover first, not a reason to trust it: n=2, on the cases that were
      already easy.
      
      #13 and #15 are listed with an explicit NO CANDIDATE and the reason, so a future
      reader does not go looking for numbers that cannot exist: zero voting orbits
      across three gates and two estimators, because those transitions share their
      sounds and a timbral lens is blind to them by construction.
      PLN (Algolia) authored
    • docs(tasks): rebuild the board as TASKS_DUMP.md — the Task API is gone · 522ad228
      The Claude-Code Task API is not reachable from this session (TaskList/TaskCreate/
      TaskUpdate/TaskGet all absent), so the live board — last seen at 112 tasks —
      could not be read or rewritten. Rebuilt the tree from durable sources instead:
      board-archive.md (ids exact, completed through 2026-07-29), the numbered
      achievement logs, the memory store, and git.
      
      Structured as six epics with the work nested underneath: OPAL-26 release (the
      only one blocking a shipment), cut/boundary detection, the floor problem, rig &
      hardware, open taste calls, completed. Each item carries the measurement or the
      quote that justifies it rather than a bare title, so it survives a cold read.
      
      Honest about provenance: ids in the #150+ range come from the lost board via
      session context and cannot be re-verified, so the file says to treat a #17x
      number as a name, not a key.
      
      This session's outcomes are slotted in place: #1's leading silence measured
      (1.452s), the timbral cut lens declared a dead end with its three-attempt table,
      and #13/#15 escalated to ear-only calls.
      PLN (Algolia) authored
    • fix(cut-lens): record run 2 — the distribution gate HALVED coverage; stop tuning · 5f7db02a
      The medoid + effect-size gate was meant to buy coverage after run 1 returned
      "no separating orbit" for six of fourteen boundaries. It did the opposite:
      boundaries with any voting orbit went 8/14 -> 4/14. Requiring cross-side
      distance to beat within-side spread is a stricter test, not a better-aimed one.
      
      The error table is seductive and is not evidence: u=0.85 scores median 0.4s,
      worst 0.7s — but n=2, and those two boundaries (#8, #10) already had ear
      answers. Four survivors cannot validate five candidate fractions, and a metric
      computed on the cases that were already easy measures nothing.
      
      The durable finding is the pattern across all three attempts: #13 Vague de
      CRIME and #15 REVOLUTION have produced ZERO voting orbits in v2, v3 run 1 and
      v3 run 2 — three gates, two estimators. That is not a tuning failure. Those
      transitions have no orbit whose timbre differs materially across the switch;
      the outgoing and incoming tracks share their sounds, so a timbral lens is
      structurally blind there just as an activity lens is blind to a crossfade. A
      fourth loosening can only admit noise, because there is no signal in this
      feature space.
      
      Declared a dead end in the docstring rather than tuned again. Two ways out are
      recorded: change the feature (harmonic/key change, or sample-bank identity from
      the score, which differs even when the spectrum does not), or accept #13/#15 as
      ear-only calls — two timestamps, seconds of listening each.
      
      Candidates that DID survive are kept in cut_candidates_v3run2.json for #8, #10,
      #11, #12 as ear-confirmation inputs, not as boundaries.
      PLN (Algolia) authored
    • wip(cut-lens): gate on distributions, not on two smeared means — UNVALIDATED · d11e6c9a
      v3 run 1 returned "no separating orbit" for six of fourteen boundaries,
      including #13 and #15, the two the run existed to answer. Diagnosis was that
      the gate compared ONE profile averaged over 40 s per side: a mean of band
      percentages is a sound that never occurred, and 40 s of a livecoded track
      averages toward the same grey for every orbit, so the orbits that actually
      change look uninformative.
      
      This replaces that with a distribution test:
        - reference windows are framed (3 s / 1.5 s hop) instead of averaged
        - each side is summarised by its MEDOID — an actual moment of the track —
          plus the median within-side distance, i.e. how varied that side is
        - an orbit votes only if it differs MORE across the switch than within
          either side (effect = sep - mean spread), not merely by a fixed distance
        - REF_MAX 40s -> 24s, so a reference stays one sound
      
      NOT YET VALIDATED. Syntax-checked and smoke-run on boundary #11 only, which
      still draws 3 voters and moves only its u0.15 estimate (3553.7 -> 3515.7);
      whether #13/#15 recover needs the full 14-boundary run (~10 min) that has not
      been done. Committed rather than parked so the reasoning is not lost — the
      docstring's RESULT block still describes run 1 and must be rewritten with run
      2's numbers before any boundary is taken from this.
      PLN (Algolia) authored
    • feat(cut-lens): v3 measures the crossfade as a span — and self-calibrates · dab8d38b
      v2 left a diagnosis, not a fix: every error was negative, and the accurate
      boundaries were exactly the ones PLN called short. The lens finds where the
      incoming track FIRST APPEARS; the ear marks where it TAKES OVER. Those differ
      by the crossfade length, which is the varying quantity (2-41s on this set), so
      a point estimator is the wrong shape of answer.
      
      v3 reports a span. The part worth keeping is that the "fraction through the
      fade" needs no tuning: evaluating the timbral series d(t) on the reference
      windows themselves gives d(ref_prev) = -sep and d(ref_next) = +sep, so
      u = (d + sep) / 2sep is calibrated by construction — u=0 is "identical to
      before the switch", u=1 "identical to after", comparable across orbits and
      boundaries. Every previous version thresholded a raw distance whose scale
      differed per orbit, which is why no threshold ever meant the same thing twice.
      
      The model behaves as predicted: u=0.15 sits 48s early (onset, confirming the
      v2 diagnosis), and the error finally changes sign at u=0.7 (+1.3s mean, median
      3.7s, vs v2's 16.6s and uniformly negative).
      
      But it does NOT ship a number. Six of fourteen boundaries returned "no
      separating orbit" — including #13 and #15, the two the run existed to answer —
      leaving n=2 truth points against five candidate fractions. Coverage, not
      accuracy, is now the bug: MIN_SEP gates on the distance between two 40s MEAN
      profiles, and averaging that long over a livecoded track washes out precisely
      the orbits that change. Next attempt gates on the reference windows' frame-level
      distributions instead. Full result and reasoning in the module docstring.
      
      Usable today as candidates only: #11 clusters inside 3.5s (short fade, nominal
      ~4s late), #12 spreads over 32s (long fade, nominal mid-span).
      PLN (Algolia) authored
    • docs(archive): the set-judge, and what it found on first use · 60fbec90
      Archive entry for the set-judge build. The entry worth reading later is not the
      tool but its first run: it was built to collect go/no-go verdicts and what it
      actually established is that the OPAL-26 masters are fine and every middle CUT
      is wrong — nine boundaries ear-verified in one sitting, with errors of 3 to 21
      seconds that two automated detectors had certified clean.
      
      Also records the four silent traps the build walked into (heads are intros; a
      green build with dead playback; three verification probes that tested nothing;
      two latent bugs surfaced by reusing existing components).
      PLN (Algolia) authored
    • feat(cuts): cut-lens v2 — better, still not trustworthy, and now we know why · d683d786
      Second attempt at locating crossfaded track boundaries without PLN's ears, with
      the three faults of v1 fixed: references taken ADJACENT to the boundary instead
      of from a track midpoint (a livecoded track is not stationary), a MEDIAN over
      every separating orbit instead of a weighted mean over 1-5 (one liar cannot move
      a median), and a CUSUM change-point instead of a threshold plus a 6s sustained-
      crossing rule that could not resolve a 4-cycle crossfade (~7.7s at 124 BPM).
      
      It got better: median error 24.0s -> 16.6s, voters 1-5 -> 4-8. It is still not
      good enough to ship, and I did not ship it.
      
      The useful part is the SHAPE of the failure. Every single error is negative, and
      the accurate boundaries are exactly the ones PLN described as short:
      
          #3 -21.0  #4 -15.2  #5 -18.0  #6 -18.5  #7 -41.2   (his: 'still the drops')
          #8  -4.4  #9  -2.0  #10 -2.4                        (his: 'perfect starts perfect')
      
      The lens finds where the incoming track FIRST APPEARS; the ear marks where it
      TAKES OVER. They differ by the length of the crossfade, which is precisely the
      quantity that varies. So the lens is measuring a real thing, just not the one we
      asked for — a much better position than 'it is noisy'.
      
      Two ways forward are written into the file, plus an explicit warning not to
      paper over it with a constant offset fitted to these eight points: the errors
      span 2.0-41.2s, so a fitted constant would be memorisation, not a method.
      
      Prior art checked at PLN's suggestion and worth recording: the OPAL gig log is
      useless here (2 track events in 87 minutes — the gig-log 'track' field only
      started recording properly in 97b22c0b, AFTER the gig), but
      Prod/Montreuil26_master/boundaries_ground_truth.json holds 14 user-confirmed
      boundaries from a RELEASED split. That is a second, independent labelled set the
      next attempt should validate against before touching OPAL.
      PLN (Algolia) authored
    • feat(judge): sticky header, and Desire is cut from the release · 73e9d798
      PLN, mid-pass: 'make the judge header fixed so decision/key/stats is easy to
      read always, and button to DL is always there'. Fair — the list is 15 tracks and
      the focused row expands to several hundred pixels, so the tally, the keymap and
      Export were all scrolled off exactly when they are being used.
      
      DECISION, confirmed: 'i confirm skip desire stronger set without'. #14 Desire is
      cut. #13 Vague de CRIME becomes the last OFFICIAL track and #15 REVOLUTION stays
      the Encore — which is already its section in tracks.json, so the set's shape
      holds. The release is 14 tracks.
      
      Two more boundaries from his relisten, now recorded as ground truth:
        #1  GO, but 'trim leading silence tho' — head trim needed, amount unmeasured
        #8  2726.4 — 'piment until 0:02, xfade starts, but unfortunate midi events
            make a decent eh ouais je funk single start only at 06.4'. He also floats
            xfading it ourselves from 0:10.9 in postprod, kept as alt_start.
      
      Nine of fourteen boundaries are now ear-verified; #11, #12, #13, #15 remain.
      PLN (Algolia) authored
    • docs(ear): the floor problem, measured — kick audible 59% of OPAL-26 · f1798e7b
      PLN relayed the gig's main criticism: 'destructure. les gens etaient un peu
      perdu' — individually good tracks, overall confusing. And his own read of it:
      'I DIDNT KEEP THE KICK LONG ENOUGH.'
      
      Measured against the stems, the floor and the data say the same thing. Kick (d1)
      audible 59% of the set; 13 of 15 tracks under 70%; a 164-second kickless stretch
      inside 'Am i Doing it Right' and 116s inside 'Piment Bresilien'. The set runs
      89-166 BPM, so anything built on this must count BARS, not seconds.
      
      Also logged, from the REVOLUTION listen: his first guess was wrong and his
      second was right. Re-ranking the orbits in the band a laptop actually
      reproduces (400Hz-8k) puts the_revolution (d5) on top by 13.7 dB, while
      like_sugar:21 (d10) — the one he wanted cut — sits 14.5 dB BELOW it and is
      quieter on a laptop than it is full-range. On a small speaker the loudest thing
      is the one that SURVIVES the band, not the one with the highest fader.
      
      And a defect nobody was looking for: d9 SUGAR CHOIR is digital silence for the
      whole of REVOLUTION though the score declares it, confirmed by two independent
      measurements. That one is why he now wants the HUD to shout about declared-but-
      never-played orbits until they sound.
      PLN (Algolia) authored
    • fix(judge): chain into the next track — and PLN's ear beats both cut detectors · 20b91515
      First real use of the set-judge, and it did its job: it found that the PROBLEM
      IS NOT THE MASTER, IT IS THE CUTS. PLN judged through #10 and stopped —
      "listened to 7, then errors seem to compound".
      
        #3  893.0   WAP bass bleeds in, real start 0:03, and the END misses a note
        #4  1118.4  4s too much at the head; Sunshine begins at 7:15 INSIDE it
        #5  1549.4  missing its own head (it is sitting inside #4); ends abrupt
        #6  1808.1  "still some sunshine bass here :(" — real start 0:10
        #7  2248.8  "this start is still the drops" — 21s of the previous track
        #9  2954.4  correct: "perfect starts perfect :lol:"
        #10 3269.0  "starts on a perfect sound lol", real Gimme Acid at 0:16.7
      
      Magnitude grows through the middle of the set and the direction flips, so it is
      not clock drift — it is per-boundary detection error. PLN named the mechanism
      himself: "check careful youll see the xfade". Every dN is a 4-cycle crossfade,
      so across a switch the ORBIT SET barely changes (d1 is a kick before and after)
      and every activity-based detector sees continuity. What changes is the SAMPLE.
      
      TWO INSTRUMENTS FAILED, AND BOTH REPORTED SUCCESS.
      
      `tidal-ears master cuts verify` said 13/15 PASS. It genuinely catches the
      stutter shape — on #3 and #4 it agreed with his ear to within a second — and it
      passed #5, #6 and #7 where the errors were 9, 10 and 21 seconds.
      
      So I wrote cut_lens.py to measure TIMBRE rather than activity, with a --truth
      harness to check it against the five boundaries his ear had already settled.
      It missed by a MEDIAN OF 24 SECONDS, worst 40 s, with only 1-5 orbits voting.
      Discarded. Committed anyway, with the negative result and three concrete
      hypotheses at the top of the file, because the harness is the valuable part and
      the next person to have this idea deserves to see the result first. The
      ear-verified boundaries are now DATA (judge_specs/opal26_boundaries_ear.json),
      marked do-not-overwrite-with-a-detector.
      
      Also fixed the first version of cut_lens spawning ffmpeg once per window —
      14 boundaries x 12 orbits x ~90 windows = 15k execve for four seconds of
      arithmetic. Decode the span once, slice in numpy.
      
      FEATURE, from "players need to autoplay next so we can see how it goes into ;)":
      the judge now chains into the next track by default. A per-track list otherwise
      makes transitions the one thing you cannot hear — which is exactly what this
      whole pass turned out to be about. Manual j/k navigation never auto-plays, so
      moving around stays quiet; the toggle is in the header.
      
      All his notes archived verbatim in performance_notes.md with the implied
      absolute boundary for each, per the archivist rule.
      
      Validated: 11/11 smoke green against the built dist; tsc clean.
      PLN (Algolia) authored
    • feat(judge): a set-judge — 15 tracks, a verdict each, one decisions.json · 9c6a24cb
      PLN: "make me a tiny spa to see the heads, soundwaves and metadata of what dX
      are running at that moment / what samples/synth they are, so i can go/nogo /
      comment each, then download decisions.json. its a reusable tool tbh we do this
      flow often."
      
      That flow was being run in a music player with the answers landing in a chat
      log, so three weeks later nobody could say why a track was cut. Ear-feedback is
      the scarcest input in this pipeline and it was the only one with no artifact.
      
      Built on the existing A/B Judge rather than beside it: OrbitRail now takes
      binS + orbits instead of a whole Take, so one rail serves both UIs. Shapes live
      in models.py and the TS is regenerated from them, as before.
      
      FIFTEEN 16-SECOND HEADS ARE FIFTEEN INTROS. The first build showed 1-4 orbits
      per track and I nearly shipped that as a finding. The heads are bit-exact with
      each track's first 16s (r=1.0000 by correlation against the full track), so the
      sparse lanes were TRUE and completely misleading — these tracks run 7-10 orbits
      once they open. A ship/cut call on an intro is a call on the wrong evidence.
      Activity now spans the whole track; the head stays as a running-order scan,
      with a banner saying exactly what it is.
      
      PEAKS ARE THE FEATURE, NOT AN OPTIMISATION. Without precomputed peaks
      wavesurfer downloads and decodes the entire file before drawing one pixel —
      102 MB per track here. Blank screen at the desk, unusable over wifi on a phone,
      which is where the mono-compat check happens. But passing `peaks` + `url`
      together makes wavesurfer treat the track as pre-decoded and never wire up
      playback: waveform draws, transport looks alive, play throws "No audio loaded".
      Both `tsc -b` and `vite build` were green through all of that. Fixed by owning
      the <audio> element via `media:`.
      
      THREE VERIFICATION PROBES IN A ROW TESTED NOTHING, each green or red for
      reasons unrelated to the app:
        1. fetch(document.querySelector('audio').src) asserting 206 — there was no
           <audio> in the light DOM, so it fetched the empty string, got THE PAGE,
           and read a 206 from somewhere else. It passed.
        2. querySelectorAll('li canvas') — wavesurfer renders into a SHADOW ROOT.
           Playwright's selectors pierce it; querySelector inside evaluate does not.
           It failed while the waveform drew perfectly.
        3. A tally assertion matching "2 judged" against text reading "2/15 judged".
      The probe that works asserts what PLN can see: the clock advances and the
      button flips to Pause. Prefer the assertion a human could make by looking.
      
      Two bugs fell out of reusing existing parts, which is the argument for reusing
      existing parts:
        - classify_family filed vec1_claps, drumtraks, realclaps, clubkick and 808bd
          under MELODIC — it matched perc names by exact-match or prefix, and in
          these scores the perc word is a suffix or an underscore token. A rail that
          shows claps as melody is worse than no rail. Widened to suffix + token
          matching; the test pins that cpluck, dropbass and snippet still must NOT
          match, since those collisions are why exact-matching existed.
        - mmss() padded a spurious zero onto every sub-10s time ("0:002.2"), off by
          one since it was written, in the shared helper. Caught by the smoke test
          reading the clock back.
      
      Titles carried markdown into the release path: tracks.json holds
      "There's **Something About Drums**" because backlog.md is prose, and those
      strings flow decisions.json -> upload metadata. Emphasis stripped; "<3" and the
      shouting caps are PLN's and stay.
      
      Spec-driven so the next gig is a copy of a JSON file, not a code change. The
      master->stem map is explicit numbers (keeps summing to 4786.370s, the master's
      duration to the millisecond) and the builder asserts it rather than trusting
      it. audio-mounts.json is read by BOTH vite's dev middleware and serve.py, so a
      URL that works in dev works on the phone over LAN.
      
      Validated: 11/11 smoke green in dev AND against serve.py on the built dist;
      22 classifier tests; path traversal out of an audio mount returns 404.
      PLN (Algolia) authored
    • feat(rig): parvagues-protect — keep scsynth un-killable across every restart · 5a97d0ed
      PLN got "memory shortage avoided — parvagues-sc terminated because the system is
      low on memory". Nothing in the system had decided to kill it: systemd-oomd is
      disabled and has NEVER run, earlyoom/nohang inactive, and the kernel OOM killer
      left no trace in `journalctl -k` or `-b -1`. The string "memory shortage" appears
      in no journal at all, so the notification came from an app — source still
      unidentified, and currently the only warning that exists.
      
      THE MECHANISM — a stale binding, this rig's most expensive recurring shape.
      perf.sh:545 protects the rig BY PID (chrt -f 90, oom_score_adj -1000). systemd's
      user manager ships DefaultOOMScoreAdjust=200, so EVERY restart of parvagues-sc —
      crash, watchdog, or manual — hands scsynth back at +200, the most attractive OOM
      victim on the machine, with no realtime priority either. perf.sh is correct
      exactly once. On 2026-08-15 SC restarted 6+ times (four SIGSEGVs, e65e3a87) and
      spent the rest of the session naked. The tell: live processes read -1000 while
      `systemctl show` still says 200 — that gap IS the bug.
      
      WHY A SYSTEM UNIT. Measured, because it is easy to assume wrongly:
      
          OOMScoreAdjust=-1000 -> 100     OOMScoreAdjust=0   -> 100
          OOMScoreAdjust=-500  -> 100     OOMScoreAdjust=200 -> 200
      
      A --user unit CANNOT go below 100 and it clamps SILENTLY — no error, no log.
      Writing -1000 into parvagues-sc.service would look right in the file, look right
      in `systemctl show`, and do nothing. Lowering past the manager's own value needs
      CAP_SYS_RESOURCE. PLN: "make it system if useful ... this device is
      parvagues-pilled" / "i can sudo any good shit".
      
      WHAT SHIPPED
        tools/parvagues-protect.sh       2s sweep; only ever lowers oom, only ever
                                         raises priority, so it is idempotent and
                                         composes with perf.sh instead of fighting it.
                                         Logs ONLY changes — at 2s, "still fine" would
                                         be 43k journal lines a day, i.e. no logging.
                                         --check is read-only and unprivileged.
        tools/parvagues-protect.service  root, hardened (three capabilities, no more),
                                         Nice=10 + IOSchedulingClass=idle so the
                                         protector is never itself a cost.
        tools/install-protect.sh         COPIES the script to /usr/local/bin root:root
                                         0755 rather than running it from the repo — a
                                         root unit with ExecStart under /home/pln is a
                                         privesc hole, unlike the user-owned watchdog.
        tools/gig-up.sh                  new SOFT check "SC un-killable". SOFT is a
                                         judgement, not an oversight: an unprotected
                                         rig still makes sound, and this gate's own
                                         rule is that HARD means "there is no gig".
      
      It never starts or stops anything — that stays the watchdog's job, and two
      supervisors with opinions about one process is a fight nobody wins.
      
      TWO BUGS FOUND WHILE BUILDING IT
      - `pgrep -f ardour` reported Ardour RUNNING when it was closed: it matched
        `bash .../tidal-ardour-autoroute.sh`, which has "ardour" in its path. I had
        already repeated that false positive to PLN as "Ardour up but engine not
        started". Now matches comm exactly, and --check PRINTS absent targets rather
        than skipping them silently.
      - `tools/check-audio-graph.sh` was +x on disk but git recorded 100644, failing
        the gate's own "tools executable" check. Fixed with update-index --chmod=+x
        (feedback_chmod_is_not_a_fix: chmod fixes your tree, not the repo).
      
      VERIFIED, both directions, because a check that cannot fail is decoration:
      --check returns 0 on the live protected rig, and against a decoy process at
      oom_score_adj=200/SCHED_OTHER it prints UNPROTECTED and exits 1 — that decoy
      signature is exactly what a freshly restarted scsynth looks like.
      
      NOT YET PROVEN: recovery after a real restart, which needs the unit installed
      (root). install-protect.sh ends by printing that verification.
      PLN (Algolia) authored
  2. 15 Aug, 2026 3 commits
    • feat(tools): button-column-audit — 174 tracks still speak the pre-remap layout · f92f4455
      PLN, mid-session in the cafe tracks: "some d1 had still 42 not 41 as button, did
      we not do all refact? doesnt our tools shout at such error snow?"
      
      Both halves were right, and the answers are different.
      
      DID WE NOT DO ALL REFACT — no. CC 41 used to be gMask, so while it was, an
      orbit's gate started a column late: d1 -> ^42, d2 -> ^43. Retiring gMask freed 41
      and REMAP PHASE 2 (5910aacc, 2026-07-29) column-aligned the board. That commit
      touched FOURTEEN FILES — the OPAL set, not the corpus. 174 of 696 tracks in live/
      still carry the old layout, cafe_glace / cafe_tiede / cafe_bouillant among them.
      Measured: every single shifted file is +1. Not 174 typos, one layout generation.
      
      DOESNT OUR TOOLS SHOUT — they structurally cannot. pvlint's button rule is PV008,
      "one physical button driving two orbits", and it fires on a COLLISION. A
      uniformly shifted file has no collision: every orbit is off by the same amount,
      so no two orbits ever meet on the same button. The file slides one column over
      and stays internally consistent. PV008 is blind to it by construction, and it is
      the rule that caught the perfect.tidal mis-migration — so this is a genuine gap,
      not a missing check someone forgot to run.
      
      WHY THIS IS NOT A NEW PVLINT RULE
      PV014 set the house bar: measure the convention before encoding it (it earned its
      place at 894/984 = 90.9%). The same measurement for buttons:
      
          button refs on orbits 1-8      2700
          on the orbit's OWN column       963   (35.7%)
          on ANOTHER column              1737   (64.3%)
      
      35.7% is a minority, not a convention. A strict "column N = orbit N" rule would
      emit 1737 findings and hold the pre-gig gate shut over the corpus's normal state.
      The crisp signal is the shift SIGNATURE — >=3 orbits sharing one non-zero offset
      — which is one finding per FILE (174) instead of one per reference (1737).
      
      So this ships as a standalone lens, not a gate rule: it cannot break gig-up.sh
      while PLN is away, and severity stays his call.
      
      Migration is migrate-columns.py's job and is POST-GIG on purpose: retuning 174
      tracks' muscle memory days before a set is how you reach for the wrong button on
      stage.
      PLN (Algolia) authored
    • fix(sc): swap PV_Freeze for PV_MagFreeze — JoshPVUGens took the server down mid-set · e65e3a87
      Sudden silence while changing track, an "audio server died" notification, then
      crackle and no sound while Ardour's faders still moved. Four scsynth SIGSEGVs in
      four minutes (14:01:58, 14:02:55, 14:03:52, 14:04:53), each ~57s apart, until the
      watchdog hit its bound: "GIVING UP: 3 restarts in 600s and scsynth will not stay
      up." sclang stayed alive throughout, so the systemd unit read active (running)
      the entire time — green unit, no sound, exactly the supervision trap.
      
      WHERE IT DIED
      Every coredump carries the same frame #1: JoshPVUGens.so, on the audio callback
      thread (libpipewire -> libspa -> libjack -> libscsynth -> JoshPVUGens). The only
      route into that library from this rig is SuperDirt's `spectral-freeze` module in
      default-effects-extra.scd, i.e. Tidal's `# freeze`. Eight live tracks carry it,
      including cafe_glace.tidal:44 — the track being played when it started.
      
      WHY IT DIED
      The server was starving before it fell over: `LocalBuf_allocBuffer: alloc failed`,
      18x `Buffer UGen: no buffer data`, and `late` values of 6-13 SECONDS. SuperDirt
      builds one spectral-freeze synth PER EVENT, each allocating FFT(LocalBuf(2048))
      per channel, so a dense pattern under `# freeze` drains the RT pool even at the
      configured memSize of 256 MB.
      
      The difference that matters, measured in NRT with 120 overlapping synths and a
      starved pool (both survive non-realtime, but their behaviour diverges):
      
          PV_Freeze     LocalBuf_allocBuffer: alloc failed        <- and then nothing
          PV_MagFreeze  PV_MagFreeze_next: alloc failed x6        <- detects it, bails
      
      The core UGen guards its buffer. Josh's does not — it proceeds with the failed
      allocation. Single-threaded NRT survives that; the realtime callback does not.
      
      THE FIX
      Re-`.add` "spectral-freeze2" after ~dirt.start, identical but for PV_MagFreeze —
      the core-SC sibling JoshUGens' own help file compares itself to, with the same
      trigger semantics (freeze > 0 freezes magnitudes). Slightly more static, since
      PV_Freeze also advances phase between frames; in exchange it cannot take the
      server down. Verified live: boot prints "[boot] spectral-freeze overridden",
      scsynth stable, 96 SC->Ardour links re-made by the autoroute unit.
      
      Pre-existing and NOT caused by this change: `SynthDef global_mi_verb2 not found`
      appears from 14:02:31, in a boot that predates the edit. Filed separately.
      PLN (Algolia) authored
    • feat(slop): three output shapes and a full-length cut, so YouTube is reachable · 113d9c57
      PLN: "i wanna floor youtube with individual clips for each good rec we have".
      The renderer could not do that: it had exactly ONE output shape, 1080x1920
      vertical, and no way to render a whole track.
      
      WHY IT WAS STUCK AT ONE SHAPE
      
      The geometry was hardcoded in three separate places -- the Playwright viewport,
      the CDP screencast cap, and the ffmpeg -vf. Adding a shape meant finding all
      three and keeping them consistent forever, so nobody did. They now derive from
      one SHAPES table: vertical 1080x1920, square 1080x1080, landscape 1920x1080.
      
      The viewport is the load-bearing one and the reason this is not just a crop.
      Hydra COMPOSES for the frame it is given, so the shape has to be chosen before
      the scene renders -- a 16:9 scene centre-cropped to 9:16 throws away most of the
      motion, and the reverse is equally true. The viewport is now output/dpr, which
      at the default dpr 2 is 540x960 for vertical: byte-identical to the old
      behaviour, so existing renders are unchanged.
      
      FULL-LENGTH
      
      `--dur full` (ad-hoc) and `--cut full` (idea path) render start-to-end, with the
      length measured by ffprobe rather than assumed. `--cut full` stays on the idea
      path deliberately so a YouTube cut still inherits that idea's playsets and fx
      instead of silently falling back to the ad-hoc defaults.
      
      THE BUG THIS WOULD HAVE CAUSED, AVOIDED
      
      The output filename now carries the shape. Without that, rendering square,
      vertical and landscape of one cut writes the same path three times and leaves
      only the last -- a batch that quietly produces a third of what it claims.
      
      An unknown --shape throws at startup, before a multi-minute realtime capture,
      not after it.
      
      VALIDATED END TO END
      
        node --check                                  clean
        vertical -> viewport 540x960                  identical to before
        --shape hexagon                               throws at line 80, pre-capture
        --dur full on a 446.777729s file              planned "+446.8s"
        15s square test render                        1080x1080 h264 30fps, aac 48k
      
      NOTE for anyone running this: the playwright browser cache on this box is EMPTY,
      so it needs SLOP_CHROME=/usr/bin/chromium (the script already supports it) plus
      the hexa dev server on :5173 under node 22. Capture is realtime, so a full
      render costs the track's duration PER SHAPE.
      PLN (Algolia) authored
  3. 14 Aug, 2026 8 commits
    • feat(gig-log): record what was PLAYED, not what was looked at · 97b22c0b
      #150, and the fix at the source of #148.
      
      gig-log recorded tab-focus events -- which file PLN was LOOKING at. Album
      track boundaries were then derived from those, and they were wrong, because a
      livecoder tabs around constantly while a pattern keeps running. Looking at a
      file is not playing it.
      
      An EVAL is the moment a pattern actually changes. That is the honest boundary.
      
      SIGNAL SOURCE
      
      `atom.commands.onDidDispatch` in the HUD package, filtered to `tidalcycles:eval*`.
      Confirmed ctrl+enter maps to `tidalcycles:eval-multi-line` in Pulsar's own
      keymap, and that MULTI_LINE sends exactly the blank-line-delimited paragraph
      around the cursor -- which is already this repo's block convention. onDidDispatch
      rides Atom's EXISTING command routing, so it adds no new per-keystroke listener:
      it cannot make the renderer burn worse.
      
      Rejected: GHCi stdout (no stable per-eval marker without changing the REPL
      protocol) and SuperDirt OSC sniffing (needs its own listener process -- closer
      to the observer-perturbs trap this rig has already been burned by).
      
      SHAPE
      
      New `k:"eval"` record {t, path, d}, where d is a best-effort list of dN streams
      found by a narrow regex: dN as the FIRST token of a line, inside the evaluated
      block only. Documented as best-effort rather than dressed up as complete.
      
      Transport is ~/.cache/parvagues/eval-events.jsonl, APPEND-only -- unlike
      current-track, which overwrites. An eval is an EVENT, in the same category as a
      MIDI note, never coalesced; current-track is STATE. Conflating the two is how
      the focus lens got mistaken for a play lens in the first place.
      
      `EvalTail` polls it once per tick with a byte-offset seek, buffers torn lines,
      and rebaselines if the file shrinks or is recreated -- the same pattern
      XrunReader already uses for node respawns. It never replays pre-session content.
      
      `track` events are UNTOUCHED. This adds a lens; it does not replace one.
      Boundary detection wants several (gap < orbit-flip < tempo), and focus is still
      the cheapest of them.
      
      BLAST RADIUS
      
      Folded into load()'s existing marks list exactly as `track` is, so cmd_report
      renders it for free and load()'s tuple signature does not change. The one real
      consumer, tools/take-segments.py, reads the JSONL itself and already ignores
      any `k` it does not recognise -- verified by reading it, not assumed.
      
      VALIDATION
      
        111/111 python tests pass (91 pre-existing + 20 new), covering the pure
        normalizer, the tail's offset / torn-line / rebaseline behaviour, and a full
        write -> load round trip.
      
      NOT VERIFIED: whether the running gig-log.service picks this up without a
      restart. It was deliberately left alone -- PLN was performing.
      PLN (Algolia) authored
    • fix(lcxl): the tap went deaf for six hours and every signal stayed green · e7961f57
      PLN, mid-session: "why atm i see no midi color on lcxl reacting to my touches?"
      
      The LED watcher was enabled, active, 6h uptime, re-parsing the loaded track
      into the journal every few minutes. `systemctl status` was green. And it had
      decoded exactly zero MIDI events the entire time, because its aseqdump child
      was bound to `20:0` while the LCXL had moved to `28:0`.
      
      WHY THE EXISTING DEFENCE DIDN'T FIRE
      
      cmd_watch already re-resolved the port by name on every respawn -- the comment
      on that line even says so, and it is true. It is also useless, because respawn
      is triggered by the child EXITING, and an aseqdump whose sequencer client has
      vanished does not exit. It blocks on a dead subscription indefinitely. The
      read loop never ends, so the re-resolution is never reached.
      
      A re-resolution that is never reached is indistinguishable from a correct one.
      Same shape as the two lenses that shipped with thresholds that could never
      fire: the code was right, the trigger was wrong.
      
      THE FIX
      
      Detect the drift from OUTSIDE the read loop. A 10s poll compares the live
      name resolution against what the current child is actually bound to; on a
      change it invalidates the send-side port cache and terminates the child, and
      the existing main loop respawns and re-resolves through the path it already
      had. No new resolution logic -- find_seq_port() was correct all along and
      returns 28:0 today. Only the re-check was missing.
      
      Deliberately NOT keyed on "no events for N seconds". A board nobody is
      touching is also silent, so that check would fire through every quiet passage
      and respawn the tap mid-set. Port identity changes when the binding is
      genuinely stale and at no other time.
      
      VALIDATION
      
        find_seq_port() -> 28:0                   (resolution was never the bug)
        before restart:  aseqdump -p 20:0         6h stale, 0 events decoded
        after restart:   aseqdump -p 28:0         re-parsed rose_rouge -> 26 controls
      
      The restart also reaped its own orphan. Two unrelated zombie taps remain
      (6.4h on the dead 20:0, 7.7h with no -p at all) -- that is #129/#155, logged
      separately, not addressed here.
      
      NOT PROVEN: the drift watchdog itself has not fired yet, because that needs
      the port to actually move. Unplug and replug the LCXL and the journal should
      show "LCXL moved 28:0 -> NN:0; respawning the tap". Until someone sees that
      line, this fix is reasoned, not demonstrated.
      PLN (Algolia) authored
    • fix(metadata): teach gig-metadata both segment shapes, and refuse to guess · f584a1c2
      The OPAL tracks.json on the website still carried the pre-#148 tab-focus
      boundaries, drifting up to 50 s from the measured cuts — and that file feeds
      both the site and Slopmotion, so the visualist would have been cutting to
      timecodes that no longer describe the audio.
      
      Regenerating it should have been one command, except the two sides of the
      pipeline describe a segment differently:
      
        {name, start_s, end_s}   what this tool was built for — keyed by .tidal stem
        {title, start, end}      what the mastering side produces (segments_v3.json)
      
      Rather than add a converter script beside it, the tool now accepts both. One
      parser per concept: "a segments file" is one idea and should have one reader,
      not a reader plus a shim that can disagree with it.
      
      The v3 shape is matched to the setlist BY TITLE, never by position. Position
      would work today and silently pair the wrong boundary with the wrong track the
      first time a soundcheck row or an unplayed track shifts the list — and this is
      exactly the class of bug #148 already was. An unmatched title is therefore a
      hard failure that prints both sides, not a warning.
      
      That strictness paid for itself on the first run. Two titles failed to match:
      
          "There's Something About Drums"  vs  "There's **Something About Drums** <3"
          "Perfect"                        vs  "Perfect <3"
      
      backlog.md is prose written for a human, so names carry markdown emphasis and
      hearts. The fold strips punctuation but KEEPS DIGITS — so `<3` survived as a
      trailing `3` and `Perfect` folded to "perfect3", matching nothing. Stripping
      `<3` before the alphanumeric fold is the fix, and the ordering of those two
      steps is the whole bug.
      
      Validated by diffing the regenerated file against the canonical one field by
      field: names, order, bpmRange, styleDistribution and all 60 samplePacks are
      byte-identical; only start/end/duration moved, on 13-15 of 15 tracks, plus
      totalDuration_s 4809 -> 4769.1. Worst drift Perfect +50 s, then Gimme Acid +33 s
      and Ghosts +32 s. venue and stage are passed through verbatim — omitting the
      flags drops them, which the diff caught before anything was written.
      PLN (Algolia) authored
    • docs(log 024): an address is a lease · bee4f240
      The evening the ALSA client numbers swapped and both of the morning's fixes
      became wrong at once — while the one tap I had written up as broken turned out
      to be the only correct one.
      
      Covers the name-resolved surface tap (#155), the preference-list output router
      (#41), the scene keys that could never fire and the one that muted d10 (#134),
      and the hush that threw a modal dialog over the editor on a dormant REPL.
      
      The learning is the title: every fix I shipped that morning wrote down a port
      number, which looked like a fact and was a lease. Also the smaller trap that
      cost twenty minutes — with the surface unplugged, the discovery commands list
      no hardware at all, so I nearly 'fixed' a tool that was working perfectly.
      PLN (Algolia) authored
    • feat(nujazz): rose_rouge grows a d6 counter-line and an Orleans break · 1a5fa811
      An evening's live editing on the track, committed so it is not left sitting in
      a buffer overnight. Net +38/-16 across the twelve orbits.
      
        d6   NEW — "Jorja too, she wants _you_": a second rose voice, masked to
             "<f f f t>" so it answers d5 rather than crowding it
        d7   the vocal-chop gate moves to ^35 (HANDS IN THE AIR) and the phrase end
             lands at 0.55, one notch past the "i want you to get" cut
        d8   the break gets loopAt 2 inside both midiOn branches, and the Orleans
             8/8 variant replaces the old n 22
      
      Recorded here because the sampling table in the comments is the score for this
      track — the `end` values ARE the lyrics, and re-deriving them by ear is an
      evening's work.
      
      Still standing, from earlier today (#156): d4 and d11 carry bare "^NN" inside a
      `#` (crushbus on ^35, delay on ^19). Those only emit because BootTidal's boot
      seed gives every CC a value — silent-eval with an EMPTY map still reports both
      orbits SILENT, which is the honest cold reading, not a regression.
      PLN (Algolia) authored
    • feat(rig): one supervised surface tap, and an output that follows a preference list · 560e0e95
      Two pieces of the cockpit (#155, #41), both written the same way: resolve by
      IDENTITY every time, never cache an address.
      
      tools/bridge/surface.py — THE control-surface tap
      -------------------------------------------------
      Four tools each spawned their own aseqdump. This morning three of them were
      bound to ALSA client 24 (an Arturia KeyStep) instead of 20 (the Launch Control
      XL) because a replug had renumbered the clients, and each resolves its port
      once, at start. I "fixed" them by moving them to 20:0.
      
      Then PLN replugged again this evening and the numbers SWAPPED BACK:
      
          client 20: 'Arturia KeyStep 32'     <- was the LCXL
          client 24: 'Launch Control XL'      <- was the KeyStep
      
      So the two I fixed are wrong again and the one I called broken is now right.
      An address is a lease, not an identity. This module resolves "Launch Control
      XL" by name on EVERY respawn, so a replug costs one backoff interval instead of
      a silent session. First run against the live rig, with zero configuration:
      
          starting   port=None
          ok         port=24:0
      
      It also keeps the last value of every CC, and — the part that matters — records
      `seen: false` distinctly from `value: 0`. An untouched control is what silences
      a Tidal stream (a bare "^NN" with no value yields NO EVENTS), so a cockpit that
      rendered untouched-as-zero would hide the exact failure it exists to show.
      
      And it does not litter. Three smoke runs of this file each orphaned an
      aseqdump to pid 1 — precisely the mess PLN complained about this morning
      ("i see 4 aseqdumps... you had one job"). try/finally is not enough, because
      SIGKILL, OOM and `timeout` all skip it. PR_SET_PDEATHSIG puts the guarantee in
      the kernel: the tap dies with its parent however the parent dies.
      
      tools/master-out.py — Ardour Master follows a preference list
      -------------------------------------------------------------
      PLN: "can we control this tedious routing... ideally theres a tie breaking list
      of choice, from uhc to headphones to speakers". Ardour's master does not follow
      the PipeWire default sink, so every rig move means re-patching in qjackctl.
      
      Not "route to X" but "route to the best X actually present": UMC -> Headphones
      -> Speakers, matched as substrings of the node name so serial numbers and
      profile suffixes do not matter.
      
      Two properties make it safe to run mid-set:
        * IDEMPOTENT — computes wanted links, diffs against existing, applies only
          the delta. Already correct = zero pw-link calls = cannot glitch the audio.
          The naive unlink-then-relink is a guaranteed dropout even when nothing
          changed.
        * EXCLUSIVE BUT NARROW — removes Master links to other sinks (or plugging the
          UMC back in gives you doubled output), and touches nothing else. The limit
          is a predicate, `_is_ours`, not a good intention: apply() refuses to unlink
          any source that is not a Master output.
      
      Verified against KNOWN-BAD states, not just against agreement with reality —
      the whole lesson of this session. Ten checks: preference order at each of the
      three tiers, the "master stranded on headphones when the UMC appears" case
      (2 adds + 2 removes), idempotency (0 and 0), and the guard refusing both a
      foreign link in plan() and a foreign source handed straight to apply().
      All pass. Live dry-run reports "already correct — 0 changes".
      PLN (Algolia) authored
    • fix(nujazz): one paren too many killed all twelve orbits of rose_rouge · 098d5712
        $ midiOn "^76" ((# delay 0.6) . (# delayt "e") . (# delayfb 0)))
                                                                      ^
      
      The `^59` block twenty lines down uses the same idiom correctly with a single
      outer paren, so this is a typo, not a misunderstanding.
      
      Worth recording how it was found, because it was not found by reading. Running
      tools/silent-eval.py over the track returned:
      
        rose_rouge: THE TRACK DOES NOT COMPILE — it will fail live too.
                    Every dN in its block is dead.
        rose_rouge.tidal:21:68: error: parse error on input ')'
      
      One parse error silences the WHOLE do-block (reference_tidal_silent_failures) —
      not just d4. Twelve orbits, no sound, no obvious cause at the desk, and the only
      signal is a GHC error in a console nobody is looking at mid-set.
      
      With the paren removed, the same query is honest about what is left:
      
        FAIL — 2 orbit(s) SILENT
          SILENT  d4  (line 19)  (nothing in 64 cycles)
          SILENT  d11 (line 71)  (nothing in 64 cycles)
      
      Both for the reason predicted before running it: d4 carries
      `# crushbus 41 (range 16 4 "^35")` and d11 carries `# delay (range 0 0.9 "^19")`
      — bare "^NN" inside a `#`, which yields NO EVENTS on an empty control map. And
      `silent-eval --seeded` returns `ok` for every orbit, which is the same fact from
      the other side.
      
      That is a cold query against an EMPTY map, so it describes a rig whose boot seed
      did not run. Whether this one's did is a separate question and not yet answered
      here — see #156, which I had wrong.
      PLN (Algolia) authored
    • feat(nujazz): rose_rouge MVP — St Germain's tour du jazz, twelve orbits deep · ca39a9e1
      First playable pass at Rose Rouge. 118 BPM, the `rose` bank carrying both the
      voice and the horns, and the d5 line walking the sample index by hand
      ("<5 6 7 8 9 10 11 11 12 13 14 13 15 16 17 18 20 21 22 23 ...>") because the
      phrase order IS the arrangement — LE TOUR DU JAZZ.
      
      The d7 vocal chops keep their own lookup table in the comments, mapping `end`
      to the words it lands on:
      
        0.2  i wan'
        0.3  i want you
        0.5  i want you to get to?
      
      That table is the score. Committing it now so the next session does not have to
      re-derive it by ear.
      
      Committed as an MVP, warts included, because three of its lines are sitting on
      the unseeded-control bomb found this afternoon (#156) and I want the BEFORE
      state in history:
      
        * d1  midiOn "^41"                      -> orDef 0, so the 4/4 floor variant
                                                   plays on 0% of cycles and the
                                                   syncopated one plays forever
        * d4  # crushbus 41 (range 16 4 "^35")  -> BARE ^35 in a `#`: no events at
                                                   all, the bass is structurally
                                                   silent until that knob is moved
        * d11 # delay (range 0 0.9 "^19")       -> same shape, same silence
      
      None of that is a bug in the track. It is the boot's control map starting empty,
      and the fix lands next commit. This file is the test case.
      PLN (Algolia) authored