1. 02 Sep, 2026 2 commits
    • fix(finish): the global-stem gate compares against the master now, not a magic number · 696ba12a
      A threshold of 3700 seconds against an expected 3779 accepted exactly the state
      the pass sits in for minutes: it writes in 120 s chunks, so 31 chunks is 3720 s —
      one short — and "basically done" sails through a loose comparison. My own monitor
      fired a premature COMPLETE on that very number, which is how I noticed: the check
      and the bug had the same blind spot because I wrote both.
      
      It now reads the expected duration from the master itself and allows two seconds,
      so the check cannot drift from the thing it checks.
      
      The fallback message says what a short global file would actually cost, which is
      the part worth naming: it does not FAIL the comparison, because compare_global
      takes min(len(a), len(b)). It compares fewer seconds per track and reports thinner
      evidence under identical column headings. Silent degradation in a summary nobody
      watches being written is worse than an error.
      
      Second attempt at this commit. The first edit silently no-op'd — a scripted
      replacement whose search string carried escaped line-continuations that did not
      match the file, while the script printed "gate tightened" unconditionally, and I
      reported success on the strength of that print. Caught by grepping the file. This
      version asserts the anchor exists before replacing and asserts the content changed
      after, then proves it by grep rather than by message. That is the third instance
      tonight of the same failure shape — a check whose failure mode is indistinguishable
      from success — so the assertions are the actual fix, not the gate.
      
      Held until now deliberately: finish_cosmic.sh was mid-execution, and bash reads
      scripts incrementally, so editing a running script can corrupt the run.
      PLN (Algolia) authored
    • docs(log 038): the results — two masters, 14 tracks, and a clean negative on the global pass · 5885da5f
      Everything landed. Two full masters differing only in air shelf, both at -14.0 LUFS
      / -0.9 dBTP / LRA 7.5-7.6 / 44100 Hz 24-bit, and 14 split tracks verifying ALL OK.
      Duration is 3779.317551 s against a source of 3779.317550 — the load-bearing number,
      because it means every one of PLN's playhead calls stays valid and the split lands
      exactly where he put the cuts rather than approximately there.
      
      Q1: the missing air is CYMBALS. Drums hold 0.367% of their own energy above 8 kHz
      against 0.084% for the runner-up, a 4.4x concentration where evenly-spread energy
      would have meant codec hiss. So opening the shelf is licensed by measurement, and
      how far is not — hence an A/B rather than a number I liked. The framing that
      matters: both candidates are still BELOW typical brightness for electronic masters
      (8-16 kHz at 0.40% and 0.63% against a 1-3% norm), so the choice is "quite dark vs
      slightly less dark", not "safe vs aggressive". The A/B is also confound-free:
      identical loudness, identical true peak, range within 0.1 LU, only the tilt differs.
      
      Q2: the floor is worse here than OPAL. Kick active time averages 44.8% against the
      59% project_floor_problem measured for OPAL-26 from real orbit stems, worst at
      #2 22.8%, #3 33.4%, #8 36.9%. A stage mic in a room explains it on its own, so this
      is a lead rather than a verdict — but it is the first time this gig's floor has been
      measurable at all.
      
      Q3, PLN's own question, and the answer is a clean negative: the global pass was not
      worth running. Correlations 0.943-0.977 against the per-section stems with level
      deltas of at most 0.21 dB. The two separations are the same stems. The prediction
      held exactly — htdemucs' 7.8 s receptive field means a global pass cannot give the
      model more musical context, so only normalisation differs, and it barely moved. It
      cost ~2.5 h of CPU competing with the master render to establish a quarter of a dB.
      Sections only next time; the question was worth asking once, not twice.
      
      Side finding: bass is the least stably separated of the four sources (0.9432,
      lowest), consistent with the same run putting PunkAChien's bass into `other` — bass
      stem at -81.0 dBFS while `other` was the loudest stem in the set.
      
      The log also records three self-inflicted errors that share one shape: checks whose
      failure mode was indistinguishable from success. A dropped rtk grep plus a
      block-buffered logfile that made a working job look dead, so two renders raced on
      the same paths for forty minutes. A speed claim built on counting directories that
      appear when writers open. And a scripted edit that silently no-op'd while printing
      success unconditionally. Plus the 192 kHz master, which was not a new bug at all —
      topic_postprod_mastering has carried that trap since tidal-ears hit it, and I wrote
      a new mastering chain without re-reading the omnibus that exists to prevent it.
      PLN (Algolia) authored
  2. 01 Sep, 2026 13 commits
    • feat(armada): compare_masters — put an A/B in numbers, so the ear judges taste and not arithmetic · 0a123d3b
      PLN has to choose between two masters that differ only in a high-shelf setting.
      The ear is the authority on which one is better and this does not try to replace
      it — but "which do you prefer" is a much easier question when you also know WHAT
      differs and by how much, because a shelf change does not stay in the band it
      names. It moves true peak, it changes how hard the limiter works, and it shifts
      where the loudness normaliser lands.
      
      So: integrated loudness, true peak, loudness range, L/R correlation and the
      band-energy distribution for each file, then a delta table against the first,
      because two absolute tables side by side is exactly how you miss a change that
      matters. Band deltas are reported as RATIOS rather than differences in
      percentage points: a band going from 0.12% to 0.24% is a doubling, and "+0.12
      points" hides that entirely.
      
      Measured on the RENDERED files, never predicted from the filter settings. A shelf
      set to +3 dB does not necessarily put +3 dB in the master, since the limiter
      downstream has opinions — feedback_verify_own_renders, the machine's job is the
      objective facts so PLN's ears are spent on taste.
      
      The smoke test proved the point immediately. Comparing the trial streaming master
      against the harder-pushed club one, the loud version shows 0.92x the 60-120 Hz
      energy and 1.08x the 300-1000 Hz: that is the limiter eating bass transients and
      relatively lifting the mids, at a cost of 1.4 LU of range for 3.5 dB of loudness.
      A measurable signature of what loudness costs, rather than a vague sense that the
      loud one sounds smaller.
      
      Wired into the finisher, so the summary PLN reads over coffee has the two masters
      with their differences quantified next to the files themselves.
      PLN (Algolia) authored
    • chore(spec): defer the club variant instead of letting it fail loudly at 3am · 19957cf5
      The spec still listed a -9 LUFS club variant that the master unit no longer
      renders, so render_release would have printed MISSING into the summary PLN reads
      over coffee — an error that looks like a broken pipeline but is actually a
      decision nobody wrote down. So it is written down now.
      
      `club` moves to `variants_deferred`, carrying the reasoning rather than just the
      path. It is not a failure on two counts. First, the axes were conflated: PLN's
      "one live club mix" means the CONTINUOUS set as opposed to the album split, which
      is a FORM, while this entry is a LOUDNESS target of our own convention. No
      CosmicFest track is cut, so the continuous mix simply IS the master file, and both
      of his forms are already covered by the streaming master plus tracks_v1_streaming/.
      Second, it would have missed anyway: reaching -9 from a -25.6 LUFS source with
      4.7 dB of linear headroom tripped the LRA floor at -10.5 on the trial, meaning the
      target costs more dynamics than it is worth on this particular tape. Moving the key
      back into `variants` renders it, so the decision is reversible in one edit.
      
      Added `abVariants` alongside, recording what the freed CPU bought and why: the stem
      EDA proved the 8-16 kHz deficit is cymbals rather than codec noise — drums hold
      0.367% of their own energy above 8 kHz against 0.084% for the runner-up, a 4.4x
      concentration — which licenses opening the air shelf but says nothing about how
      far. Two full masters at +5 and +8, identical otherwise, judged in place rather
      than on a clip, per the lesson OPAL's reverb A/B taught.
      
      The finisher now iterates only the variant that exists, so its split, verify and
      on-disk census all line up with what was actually rendered.
      PLN (Algolia) authored
    • fix(finish): gate the global EDA on stem LENGTH, not existence — and report the A/B · 565db062
      The finisher decided whether to run the local-vs-global comparison by testing
      whether the global drums stem exists. A soundfile writer creates its file the
      instant it opens, so that test is true from the first second of an hour-long pass.
      This is the same trap that had me reading "4 of 14 sections done" off a directory
      count earlier tonight, and it would have been worse here than a wrong progress
      number: the comparison seeks into the global stems by ABSOLUTE TIME, so a
      truncated global file does not fail, it silently mis-aligns every later track and
      returns plausible correlations for the wrong audio.
      
      Now it probes the duration and requires ~3700 of 3779 seconds before attempting
      the global half, falling back to the section-only EDA with the shortfall printed.
      In practice the unit is ordered After= the demucs producer so the stems should be
      complete anyway — but "should be" is exactly the assumption that a demucs failure
      mid-global would have quietly violated, and the finisher runs unattended while PLN
      sleeps.
      
      Also made the summary report the air-shelf A/B explicitly, since that pair is the
      thing he has to judge: both masters listed with size, sample rate, channels and
      duration, and a loud MISSING line if either did not render. The EDA settled that
      the 8-16 kHz deficit is cymbals rather than codec noise (drums hold 0.367% of
      their own energy up there against 0.084% for the runner-up, a 4.4x concentration),
      which licenses opening the shelf but says nothing about how far — so the summary
      puts the two candidates side by side rather than announcing a winner.
      PLN (Algolia) authored
    • feat(cosmicfest): the EDA answered — the missing air is CYMBALS, so render an A/B · 6411504d
      The stem EDA ran on all 14 sections and settled the one parameter that was an
      admitted guess. The air shelf sat at +5 dB against a measured 8-16 kHz share of
      0.12%, held back because a 320 kbps MP3 puts codec hiss in exactly that band and a
      broadband boost cannot tell hiss from hats.
      
      Per stem, mean share of each stem's OWN energy above 8 kHz: drums 0.367%, vocals
      0.084%, other 0.033%, bass 0.0%. A 4.4x concentration in drums. Evenly-spread
      energy would have meant noise and a shut shelf; concentration means cymbals the
      room ate, and opening up is justified by measurement.
      
      HOW FAR is not justified by measurement — that is taste. So --air-gain exists and
      the master unit now renders TWO full streaming masters, identical but for the
      shelf (+5 and +8), because OPAL already taught us that an effect judged on a clip
      is not the same effect judged in place. PLN compares two records tomorrow instead
      of ratifying a number I happened to like.
      
      The -9 LUFS "club" target is deliberately dropped, and the reasoning is a
      correction to my own earlier conflation of two axes. PLN's "one live club mix"
      means the CONTINUOUS set as opposed to the album split — a FORM. Our club variant
      is a LOUDNESS. Since no CosmicFest track is cut, the continuous mix simply IS the
      master file, so both of his forms were already covered and the -9 render was
      optional polish that would have missed anyway (the LRA floor tripped at -10.5 on
      the trial). That CPU now buys the A/B, which answers a question he actually has.
      
      Q2, unasked for and worth having: kick presence per track, 40-120 Hz active time
      against each track's own p95. Mean 43.4%, against the 59% project_floor_problem
      measured for OPAL-26 from real orbit stems. Worst real offenders are #2 There's
      Something About Drums at 22.8% and #3 Am i Doing it Right at 33.4%. A stage mic in
      a room is a plausible cause on its own, so this is a lead rather than a verdict —
      but it is the first time this gig's floor has been measurable at all.
      
      Two things the lens caught that no label would have, both vindicating
      feedback_label_and_lens applied to the separator's own names. #14's drums stem is
      -84.9 dBFS RMS, essentially empty, which confirms PLN's note that the outro is
      "pure dubsiren noise and fun" — so its kick percentage was measuring a noise floor
      and reading as a real number. kick_activity is now gated on presence and returns
      None with a reason instead, because a percentage of nothing is not a percentage
      (presence_is_a_precondition). And #10 PunkAChien's bass stem reads -81.0 dBFS
      while its `other` stem is the loudest in the whole table at -26.8: demucs put the
      bass INTO other. Anyone reading stem filenames as roles would have concluded
      PunkAChien has no bass.
      
      Also fixed the JSON writer, which died on numpy's bool_ after printing every
      useful number, and a headline ratio that divided by an epsilon guard when one stem
      measured a legitimate 0.000% and announced a 367000x concentration. It now reports
      against the runner-up.
      PLN (Algolia) authored
    • fix(master): the first full render was 192 kHz — a bug this project already fixed once · 9ab93ffb
      The CosmicFest streaming master completed in spec on every number I had thought
      to check: I = -14.0 LUFS, LRA 7.5 from a source 10.6, true peak -1.0, duration
      matching the source to the microsecond. It was also 192 kHz and 1.32 GB, from a
      44.1 kHz source. 4.3x the size it should be, at a rate no platform wants.
      
      ffmpeg's `loudnorm` oversamples 4x for true-peak detection and leaves the graph at
      192 kHz without resampling back. Any chain ending in loudnorm inherits that. So
      every chain here now ends `aresample=<source rate>:resampler=soxr`, with the rate
      PROBED from the source rather than assumed, and `-ar` set on the output too.
      Validated on the 90 s slice: 44100 Hz / 2 ch, duration exact, loudness unchanged.
      
      The part worth writing down is that this is not a new bug. topic_postprod_mastering
      has carried a section titled "Every master was 192 kHz (fixed c652d16)" since the
      tidal-ears chain hit it, describing this precise failure and its cause. I wrote a
      brand-new mastering tool without re-reading the omnibus that exists to prevent
      exactly this, and reproduced it. The memory now records the recurrence and two
      structural fixes rather than a note-to-self:
      
      Put the resample in the chain BUILDER, so no future chain can omit it. And make
      the in-spec gate assert FORMAT, not just loudness — sample rate and channel count
      now sit in the pass/fail beside I/TP/LRA, and the output line prints them. A gate
      checking only loudness is what certified a 4.3x-oversized master as ✓, and I found
      it by ffprobing for an unrelated reason. That is luck, and luck is not a check.
      
      Also fixed an indentation error I introduced while threading the rate through with
      a scripted replacement whose search string matched inside deeper indentation than
      intended — the kind of mistake that is instant to catch with a parse check and
      invisible without one.
      
      Cleaned up: the 192 kHz master deleted, the master unit restarted on the fixed
      chain, and cosmic-finish re-queued behind it so the split cannot run against a
      file that no longer exists.
      PLN (Algolia) authored
    • fix(log 038): retract the nohup diagnosis — two false negatives, and two renders racing · fdd1418b
      I wrote in 038 that `nohup … &` inside a tool call does not survive, on the
      evidence of an empty logfile and no matching process. Both were artefacts, the
      diagnosis was wrong, and acting on it cost considerably more than the dead job
      I thought I was recovering from.
      
      The log was empty because Python block-buffers stdout to a file. No TTY means a
      ~8 KB buffer, and PYTHONUNBUFFERED=1 was set in the systemd unit but not on the
      nohup line, so forty minutes of genuine work produced a zero-byte log. The process
      appeared missing because rtk silently dropped the grep; `rtk proxy ps` showed it
      immediately, reparented to PID 1553 — the user systemd manager — and well into its
      render. RTK.md says to fall back to `rtk proxy` whenever output looks suspiciously
      empty, and I skipped that on the one check the entire conclusion rested on.
      
      So I started the same 63-minute render a second time. For roughly forty minutes two
      processes wrote the same output paths, racing on Cosmic26_v1_streaming.flac and
      master_report.json. Nothing warned me; it surfaced only because two ffmpeg
      processes turned up at different pipeline stages, which is luck rather than a
      designed tell. Killed the unsupervised duplicate by PID, removed its 102 MB partial
      FLAC so cosmic-finish could not split a truncated file, and let the journaled unit
      carry on. The unit has since reported attempt 1 cleanly: pre-gain +11.6 dB giving
      I=-15.94, TP=-1.00, LRA 8.30 down from 10.60 — dynamics substantially intact.
      
      Two rules earned the hard way, both recorded in
      reference_durable_background_jobs alongside the retraction. An empty logfile is
      not evidence of no progress: use `python -u` or PYTHONUNBUFFERED on anything whose
      log you intend to read. And before starting a replacement for a job you believe is
      gone, PROVE it is gone — via `rtk proxy ps` or the unit's own cgroup.procs —
      because a duplicate writing the same paths is a corruption risk, not merely wasted
      CPU. feedback_check_the_instrument_first and feedback_absence_needs_proof both
      applied here and I honoured neither.
      
      What the episode does not undermine is the house rule, which stands on its own
      evidence from the same night: systemd --user units outlive the session. Every
      harness background waiter and monitor I had armed was killed at a between-turn
      teardown, while all three cosmic-* units kept running untouched. That is the reason
      to use them. The nohup story was never the reason.
      PLN (Algolia) authored
    • docs(board): a resume point for an unattended run — and two progress metrics that lie · f87c8ead
      PLN went to bed with the CosmicFest pipeline in flight. This is the block a cold
      session needs to pick it up without reconstructing anything, written because the
      run outlives the session that started it.
      
      The teardown test came back the way the design hoped: every harness background
      waiter in that session was killed between turns, while all three systemd --user
      units kept running untouched. So the resume instructions lead with systemctl and
      journalctl rather than with anything session-local, and the expected end state is
      listed in full — masters, both split directories, eda_stems.json,
      master_report.json, FINISH_SUMMARY.txt, and the section plus global stems — because
      cosmic-finish.service is ordered After= both producers and gets there on its own.
      
      Two progress metrics are recorded as WRONG, since I used both and both fooled me.
      Counting directories under stems_demucs/sections/ counts sections STARTED, not
      finished: the directory appears the instant chunked_separate opens its four
      writers. And libsndfile buffers, so a stem file sits at 44 bytes — a bare WAV
      header — through minutes of genuine work. I read "4 of 14 done" off the first and
      put a speed claim in commit 37eb401f on the strength of it. Judge by mtime and
      flushed bytes, or simply by whether the unit is still activating.
      
      The board now also names the first question to ask of the EDA, because it decides
      a parameter that is currently an admitted guess. The air shelf sits at +5 dB
      against a measured 8-16 kHz share of 0.12%, which on its face justifies far more;
      it is held back because a 320 kbps MP3 puts codec hiss in exactly that band. The
      EDA prints the verdict: HF concentrated in drums by 2x or more means cymbals and
      the shelf can open, HF spread evenly across all four stems means noise and it
      stays shut. Re-rendering on that basis costs nothing, since PLN has not yet heard
      any of it — which is the one moment such a change is free.
      
      Blockers are ordered by what actually gates a shipment. First is the canonical www
      gig page, because build_release_plan refuses without it and every upload adapter
      reads that plan; the derivable half is already drafted, so PLN owes four
      frontmatter fields and the set's movements. Then the tone, which no measurement
      settles. Then three smaller calls, and the still-unreviewed merge 2f947c2c.
      
      Recorded as ready-but-deliberately-undone: OPAL's SoundCloud is 7 of 15 tracks
      short and the re-run is idempotent, but PLN was sharing OPAL links that evening
      and mutating the account under him was the wrong risk to take unattended. And a
      standing warning against uploading CosmicFest's split before he approves the tone —
      SoundCloud is idempotent by permalink, so a v2 re-render is silently skipped
      rather than replaced, which is the take-five-drops defect waiting to happen
      fourteen more times.
      PLN (Algolia) authored
    • docs+feat(cosmicfest): log 038, the stem EDA, and a finisher that does not need my session · b40c7fb5
      Log 038 — "A record from one microphone" — is the night CosmicFest went from
      "educated guess game", PLN's phrase, to a pipeline. It carries the measurements
      that reshaped the job and a correction to my own previous commit.
      
      eda_stems.py is the mastering EDA a stemless gig could not otherwise have, and it
      exists to answer three specific questions rather than to print statistics.
      
      Is the 8-16 kHz deficit cymbals or codec noise? The master measured 0.12% of
      total energy up there and the air shelf was deliberately held to +5 dB instead of
      the +12 the deficit suggests, because a 320 kbps MP3 puts hiss in that band
      alongside hats. A broadband boost cannot tell the two apart; a per-stem view can.
      If the HF lives in `drums` it is cymbals and the shelf can open, and if it is
      spread evenly across all four it is noise and the shelf stays shut.
      
      Where is the kick? project_floor_problem measured the kick audible in only 59% of
      OPAL-26, from real orbit stems. This asks the same question of a gig that has
      none, via 40-120 Hz activity over time, thresholded relative to each track's own
      p95 rather than an absolute dBFS — a fixed threshold on a quiet room-coloured
      source measures the recording level, not the kick. Reported per track so a floor
      that drops out gets named instead of averaged away.
      
      And how much does local-versus-global separation actually differ? It compares
      each section's stem against the same span cut out of the global stem:
      correlation says whether the model made the same decision, the RMS delta says
      whether normalisation changed the level. That comparison is the reason the
      chunked writer had to be length-exact.
      
      Presence is gated before any stem is compared to another, and demucs' own source
      names are treated as hypotheses — feedback_label_and_lens applies to a
      separator's labels as much as to a sample's filename.
      
      finish_cosmic.sh + cosmic-finish.service exist because of a structural mistake I
      nearly shipped. The master and demucs runs are systemd units so they outlive the
      session, but every step AFTER them — split, verify, EDA — was sitting in my
      session. PLN said "your autonomous now going to bed tell me results tomo", so a
      teardown would have left him with rendered masters and nothing else. The unit is
      ordered After= both producers, so systemd does the waiting instead of a polling
      loop, and it leaves FINISH_SUMMARY.txt on disk that stands on its own. Wants=
      rather than Requires=, deliberately: a club-target miss or a demucs failure must
      not cancel the split of a perfectly good streaming master.
      
      The log also corrects commit 37eb401f, which claimed the chunked demucs path was
      about twice as fast. It is not, and the claim came from a broken metric: I counted
      directories under stems_demucs/sections/, but a directory appears the moment
      chunked_separate opens its writers, so the count measures sections STARTED, not
      finished. libsndfile buffers on top of that, so three of four in-flight sections
      sat at 44 bytes — a bare WAV header — after nine minutes of genuine work.
      Measured properly by mtime and flushed bytes, a worker runs at about 4.3x
      realtime under four-way contention plus the master render. Chunking fixed the
      OOM kill; it did not demonstrably make anything faster.
      PLN (Algolia) authored
    • feat(armada): draft a gig's tracks.json — derive what is derivable, refuse to guess the rest · 6ae2b820
      build_release_plan.py refuses to emit anything without the canonical www gig
      page, with the right message: "Gig metadata is never invented here — create it
      there first." Every upload adapter reads that plan, so one missing file blocks
      SoundCloud, YouTube and Bandcamp at once. CosmicFest 2026 has no page, which
      makes that gate the single thing standing between a finished master and PLN
      hearing it on the platform he asked to hear it on.
      
      The gate is correct and this does not weaken it. It splits the file along the
      line the gate cares about instead of blurring it:
      
        derivable   name, file, bpm, start/end/duration, style, samples
        PLN only    the gig's title, venue, stage, description, and each track's
                    `section` (the movements of the set)
      
      Everything in the second group is emitted as null with a `_needs_pln` list, never
      as a plausible guess. The output is a DRAFT at whatever path you pass; promoting
      it into the www repo stays PLN's call. So tomorrow is four fields and a review
      rather than an afternoon of reconstruction.
      
      Derivations reuse what already parses the corpus rather than re-reading it:
      `style` comes from the score's own directory, which is how OPAL's published
      styles line up with the tree, and `samples` comes from catalog_view.json's
      `score_sounds`. A score absent from that view is REPORTED, not handed an empty
      list — an empty sample list is invisible once it is on the shelf, which is the
      whole lesson of feedback_parsers_over_copy. Seven of CosmicFest's fourteen are
      currently absent because the view is stale; re-running build_catalog_view fills
      them.
      
      The derived timecodes then validated themselves against PLN's own ear notes,
      which is the reassuring part. Piment Bresilien lands at 0:35:46 against his
      playhead comment "starting at 35:45.6 i hear proper only piment start sound",
      and LiveCode Parade at 0:54:58 against the 54:59 note where crossmatch had
      guessed gimme_acid and the declared 130 BPM broke the tie. 14 tracks, 63.0 min,
      30 sample packs, 89-170 BPM.
      PLN (Algolia) authored
    • fix(demucs): the OOM killer ended it after 2 of 14 sections — one bounded path for both modes · 37eb401f
      The first run died at 8.2 GB peak, killed by the kernel, having finished two
      sections in 32 minutes of wall clock and nearly five hours of CPU. The arithmetic
      was there to be done beforehand and I did not do it: a 532-second section is
      188 MB of input, 750 MB of output across four sources, and apply_model's overlap
      accumulator wants that again — call it 2 GB, times four workers, on a machine
      already holding 18 GB of someone else's Gradle builds.
      
      The fix was already written, in the other half of the same file. `global_pass`
      chunked its work specifically because a 63-minute tensor would need ~12 GB, and
      sections needed exactly the same treatment for exactly the same reason. So the
      two modes now share one applier and differ only in which normalisation statistics
      they are handed — which is the entire local/global distinction anyway, since
      htdemucs' receptive field is 7.8 s and neither mode can give the model more
      musical context than that. Sections pass their own stats; the global pass passes
      the whole recording's.
      
      Chunking turned out to be faster as well as smaller: four sections landed in the
      time the unchunked version took to finish two, presumably from allocating a few
      hundred MB repeatedly instead of gigabytes once. Memory peak is now 5.45 GB.
      
      The unit also grew MemoryMax=10G / MemoryHigh=8G. A bounded algorithm should not
      need it, but a cap turns any future regression into a failed unit rather than the
      kernel choosing a victim on PLN's desktop while he sleeps.
      
      Two details kept from the global implementation because they are load-bearing:
      the chunk seam is joined with an equal-power ramp, since it sums two estimates of
      the same audio and a linear fade would dip 3 dB right at the join; and the output
      is length-exact, verified at 3969000 frames on all four stems for a 90 s input,
      zero drift. That second one matters beyond tidiness — the EDA pass seeks into the
      global stems by absolute time to compare them against each section, so any
      accumulated drift would silently misalign every later track.
      
      Also discarded the two sections the old code had produced. They were valid
      separations, but they were made by a different code path, and a local-versus-
      global comparison built on a mixed dataset would have a confound in it that no
      amount of care downstream could remove.
      PLN (Algolia) authored
    • feat(cosmicfest): two tools for a gig with no stems — separation as a lens, and a bus-only master · b8059f81
      Both exist because CosmicFest is one stage-mic MP3, and the house pipeline
      assumes twelve Ardour orbit-stems.
      
      demucs_sections.py — per-track stems plus a global pass, PLN's ask: "to see how
      both are interpreted locally and globally". One measured fact decides what that
      comparison can show. htdemucs' receptive field is 7.8 s (model.segment = 39/5)
      and it always splits internally, so a 63-minute file gives the model no more
      musical context than a 4-minute one. The local/global difference is therefore
      NOT context — it is input normalisation, since demucs normalises by the mean/std
      of whatever you hand it. On this gig that is a real lever rather than a nitpick:
      the set sits at -25.2 LUFS with 11.1 LU of range, so quiet sections get far less
      gain under whole-set statistics than under their own.
      
      So global mode computes the normalisation over the entire recording and then
      applies the model in bounded chunks with those statistics. That isolates the one
      variable that can actually differ, in about 1 GB instead of the ~12 GB a single
      63-minute tensor needs (four sources x 1.33 GB of output, plus apply_model's
      overlap accumulator, against 12 GB free). Chunks overlap and are crossfaded with
      an equal-power ramp — the seam is two estimates of the same audio summed, so a
      linear fade would dip 3 dB in the middle of it.
      
      Written against demucs' Python API rather than its CLI, because the CLI cannot
      save here at all: torchaudio 2.10 routes writes through TorchCodec, which is not
      installed, so `python -m demucs` separates for 87 seconds and then dies on
      ImportError. Going through apply_model and soundfile also avoids installing
      anything into PLN's venv to work around it.
      
      Validated before launching a six-hour job: 30 s slice, four stems written, all
      populated, and sum-minus-original sits 27.4 dB below the signal. Vocals came
      back at -50.6 dBFS RMS on an instrumental passage — correctly near-empty, which
      is the kind of agreement that makes later disagreements worth trusting. Runs as
      a systemd --user unit per reference_durable_background_jobs, niced with idle IO
      so it can never be why the rig stutters.
      
      master_stemless.py — MASTERING.md's chain with the per-stem half removed and one
      stage added, because the measurement demanded it. Full-file analysis first:
      L/R correlation 0.833 (genuinely stereo, so bass-mono is safe), sub-30 Hz is
      0.07% of total energy (the HPF is free headroom), and then the finding that
      reshapes the job — 300-1000 Hz carries 34.75% while 4-8 kHz has 1.75% and
      8-16 kHz has 0.12%. That is a microphone in a room, not a desk feed: the PA's
      treble never reaches the mic, the room absorbs what does, mid-bass piles up. The
      tape is dark and boxy, not merely quiet, so the dominant move is a broadband
      tilt rather than gain.
      
      Restraint where the numbers cannot justify enthusiasm: the air shelf is +5, not
      the +12 the deficit suggests, because a 320 k MP3 puts codec residue in that band
      alongside cymbals and a big boost lifts hiss into the master. The demucs stems
      are the lens that can tell those apart; until they answer, the shelf stays modest.
      
      Bass-mono runs in mid/side, not by splitting and re-summing bands: a lowpass(120)
      summed with a highpass(120) leaves a phase notch at the crossover, whereas
      high-passing the side channel alone never filters the mid path.
      
      Two failures worth keeping. First, the club target with the limiter placed before
      any gain: the signal reaching it still peaked at -9.5 dBTP, so a -1 dBFS ceiling
      never engaged, and loudnorm's linear=true — correctly refusing to breach TP —
      capped the master at -11.2 LUFS against a -9 target. The gain has to come first
      so the limiter has something to catch. Second, that fix alone still undershot,
      because every dB the limiter absorbs is a dB the final loudnorm cannot add; the
      shortfall IS the missing gain, so feeding it back converges. Bounded at three
      attempts and guarded by an LRA floor, since a loudness target reached by
      flattening the music is not reached — presence_is_a_precondition, applied to
      dynamics.
      
      On the 90 s trial the streaming target lands at -14.0 LUFS, peak -1.0, with
      1.8 LU of range lost. The club target stops at -10.5 with the floor tripped, and
      reports the miss instead of clipping its way to a number. Whether -9 is
      reachable on the full set, whose LRA is 11.1 rather than the slice's 4.6, is a
      question the full render answers.
      PLN (Algolia) authored
    • feat(cosmicfest): the first stemless gig reaches segments — 14 tracks, 63.0 min · d97dda3f
      R-0 and C-0, the two moves the release board put ahead of everything else.
      
      R-0. The CosmicFest master existed in exactly one place: ~/Downloads. It is now
      in Prod/Cosmic26_master/ and mirrored to the freebox, sha256 7a197589…d671b
      identical on both, and the Downloads original was removed only after both copies
      were verified — a move, not a hopeful mv. feedback_freebox_ssot names the freebox
      as the SSOT for audio, and an irreplaceable 63-minute gig in a scratch directory
      was the cheapest large risk on the board. The spec records the naming trap that
      nearly cost an hour: Prod/cosmicfest/ and Prod/cosmicfestv0.live1.* are the 2025
      edition, files dated 2025-06-27/28, and nothing in them belongs to this release.
      
      C-0. POSTPROD.md's rule is that a new gig is a copy of judge_specs/<gig>.json and
      never a copy of a script — so the question was whether a gig with NO stems can
      use the pipeline at all. Answered by reading the code, not by trying it and
      hoping: apply_boundaries and render_release touch no stem key whatsoever, only
      segments/master/variants/releaseRoot/releaseTag. The single tool that genuinely
      needs stems is build_judge_set, which derives nominal segments from per-orbit
      activity. That is the one hole, and setlist_to_segments.py fills it.
      
      The converter turns judge_specs/<gig>_setlist_ear.json into the two inputs
      apply_boundaries wants, and its real job is keeping two grades of truth apart.
      The setlist silently mixes them: some starts are playhead calls PLN made while
      listening, others were reconstructed from his notes on boundaries he SKIPPED.
      Only the former go into `verified`, so apply_boundaries prints ear or nominal per
      row and asserts every playhead call reached the output. An inferred edge can
      therefore never be mistaken later for one he actually heard.
      
      That distinction immediately corrected the board. It had named #10 and #12 as the
      soft edges, taken from the recovered tracklist's unresolved list. The setlist ear
      file supersedes that: ten starts (#2-#11) are playhead calls, #1 is the origin,
      and the inferred ones are #12 LiveCode Parade, #13 Vague de CRIME and #14
      Outro: La Dub Sirène. #10 PunkAChien was ear-called all along — "perfect cut at
      48:27.4 into PunkAChien!". Three tracks to audition, not two, and a different
      three. The board also claimed 11 decided boundaries; there are 10.
      
      Titles resolve authored -> OPAL-release -> catalog -> NEEDS_PLN, and never by
      title-casing a slug. The authoritative source for a shared track is what actually
      SHIPPED: OPAL-26's ear-signed segments_v4.json joined to that gig's tracks.json
      on performance order, since neither file alone holds both the score path and the
      released title. Four tracks were in no shipped release and no catalog entry, so
      the first run emitted them as NEEDS_PLN rather than inventing anything; PLN then
      gave all four, and they live in judge_specs/cosmicfest-2026_titles.json with
      value+source+locator+date per feedback_metadata_provenance. The same file carries
      a `score` alias, because the gig slug "mafia" is mafia_sans_serif.tidal — and
      that alias then cross-checked itself, yielding a declared 160 BPM that matches
      OPAL's Mafia exactly. Outro: La Dub Sirène has no .tidal at all, being a played
      outro rather than a composed track, which is also why the catalog can never hold
      its title: the catalog is keyed by score path.
      
      `album` is deliberately absent from the spec. CosmicFest 2026 has no page under
      content/lives/2026/, so there is no canonical set title to copy, and inventing
      one would be exactly what feedback_metadata_vs_mastering forbids.
      
      The source measures better than "raw stage mic with FOH effects" suggests:
      I = -25.2 LUFS, true peak -5.1 dBFS, LRA 11.1 LU over the full 62:59. Not
      clipped, not crushed, just quiet — reaching -14 LUFS needs about +11 dB and
      therefore limiting, but that is a gain problem rather than a repair job. Recorded
      in the spec's sourceProvenance so the next pass starts from the number.
      
      apply_boundaries output: 14 tracks, 63.0 min kept of a 63.0 min master, zero
      gaps, all assertions green. variants still point at mastered files that do not
      exist; the mix is the next step and render_release will say MISSING until it runs.
      PLN (Algolia) authored
    • docs(board): the two-set release board — and CosmicFest is a better tape than we thought · 39ca4def
      PLN reframed the north star: not one release but four deliverables — {OPAL,
      COSMIC} x {continuous club mix, per-track album split}. The board only tracked
      OPAL, so this amendment measures both sets against the same scorecard instead
      of recalling their state.
      
      The four questions he asked, answered from the filesystem rather than memory:
      boundaries are DONE for BOTH sets (OPAL ear-signed 2026-08-16, CosmicFest
      2026-08-29 — 11 playhead calls plus 8 skip notes). The mix is done and
      ear-reviewed for OPAL only. CosmicFest has had zero mastering: its ear pass
      settled WHERE the cuts are, never HOW it sounds — there is no mix to review.
      
      Two findings worth more than the status table.
      
      The CosmicFest master has exactly ONE copy, and it is in ~/Downloads. 151 MB,
      not on the freebox, not in Prod/. feedback_freebox_ssot says the freebox is the
      SSOT for audio; an irreplaceable gig recording in a scratch directory is the
      highest-risk, cheapest-to-fix item on the whole board. Filed as R-0, ahead of
      all processing. Adjacent trap recorded too: Prod/cosmicfest/ and
      Prod/cosmicfestv0.live1.* are the 2025 edition (files dated 2025-06-27/28) —
      nothing there belongs to this release, and the names invite exactly that error.
      
      And the tape is far better than "raw stage mic with FOH effects" implies.
      Measured with ebur128 over the full 62:59: I = -25.2 LUFS, true peak -5.1 dBFS,
      LRA 11.1 LU. The peak says it was recorded conservatively and is NOT clipped;
      the 11.1 LU range says the desk did NOT crush the dynamics (a mangled feed lands
      under 6). It is simply quiet — reaching -14 LUFS needs ~+11 dB and therefore
      limiting, but that is a gain problem, not a repair job. PLN's "80/20 likely" is
      reachable; what is genuinely impossible is a premix, because one stereo file has
      no stems and none of OPAL's per-orbit surgery has an analogue.
      
      The keystone follows from POSTPROD.md's own rule — "a new gig is a copy of
      judge_specs/<gig>.json, never a copy of a script". That file does not exist for
      CosmicFest, and BOTH of its deliverables sit downstream of it. One wrinkle to
      settle while writing it: OPAL's spec assumes stems (stemsDir, stemmap, keeps,
      clipsDir) and CosmicFest has none, so the stemless path must be verified rather
      than assumed.
      
      Six decisions are logged as PLN's, not the tooling's: the still-unreviewed merge
      2f947c2c; whether the A2 REVOLUTION fix is worth invalidating the A1.7 signoff it
      would force a re-render of; whether "Outro Dub Siren" is an album track or
      mix-only; the two soft CosmicFest edges (#10, #12) inferred from skip notes
      rather than called on the playhead; the rights ledger re-measured at 103 banks
      (43 third_party, 31 unknown, 19 dirt_samples, 10 cleared), which gates the split
      but not the mix; and artwork, still unchosen for both.
      
      Also traced the collab thread: piment_bresilien is in BOTH sets — OPAL track 07,
      already on SoundCloud, and CosmicFest #7 at 2145.6-2525.4 on a playhead call.
      Its score carries one uncommitted edit, line 33, |+ note 12 becoming |- note 12.
      PLN (Algolia) authored
  3. 29 Aug, 2026 25 commits
    • merge: the LCXL3 surface becomes the rig's default state · 2f947c2c
      Brings the v3 control surface work onto master, as PLN called it: "this is the
      new tooling/state". The arc is one idea repeated — the driver owns the truths
      the hardware refuses to keep.
      
      - it plays: four defects between the driver and a live SuperCollider, all of
        them identity bugs (a port's NAME is not the port)
      - it speaks a language: role hue x value lightness, the DJF's diverging
        blue -> breathing-green-zero -> red, glowing at the track's tempo
      - it holds state the v3 board dropped: offset-64 relative values, row-E
        latches, paint reasserted every two seconds
      - it says what each control DOES, in PLN's own words parsed from his .tidal
        ("Le Delay rose!!!", "HANDS IN THE AIR"), across all 34 display targets
      - it follows the track: the editor already published it, the driver only had
        to read — remap, relabel, repaint and reset the lap clock on a change, and
        re-read on save so mapping a control lights the board within a second
      - the tempo can be a knob, with landmark detents replacing the notch the v3
        encoders lack, and a bar countdown for the mute-drop transition
      - and the rig around it: Ardour hears the surface again, RIG UP owns the
        ordering SuperDirt needs, midiviz replaced the transcript with a picture
      
      Also aboard: a display-semantics probe that asks the device instead of us
      guessing, realtime.tidal for composing without the crossfade, and the corpus
      marked for quantise at all 14 tempo-knob sites.
      
      Backward compatibility is the standing constraint and it holds: the corpus
      speaks v2 forever, everything v3 lives in the driver, and a classic Launch
      Control XL still works with nothing running.
      PLN (Algolia) authored
    • feat(compose): realtime.tidal — the crossfade off switch, three shapes · 2e3c8000
      PLN wanted `$ realtime` as a prefix. That shape cannot work: `realtime $ d1 $
      pat` parses as `realtime (d1 pat)`, and d1 has already applied its transition
      by then, so nothing in that position can undo it. What does work ships here:
      `rt 1 $ ...` for a single transition-free launch, a one-line `let` that shadows
      d1-d12 for the session, and the exact restore. No BootTidal edit, so tomorrow's
      boot cannot break on tonight's untested Haskell — every line is copied from
      BootTidal's own definitions, and the file says plainly that it has not been
      through GHCi yet.
      
      Also written down where he will find it again: the retrigger he blamed on the
      crossfade is `resetCycles` sharing a block with the patterns, and the real fix
      for the mode (an IORef compose/perform toggle, promoted into BootTidal) is
      tomorrow's C-1.
      PLN (Algolia) authored
    • chore(corpus): mark the tempo knob for quantise — 14 sites, none of them touched live · ea52a32c
      PLN, after seeing the OLED refuse to claim a round number: "quantise would make
      sense mark a todo on the tracks sept1 and this commented line replace it with
      quantize commented too. where its possible".
      
      The measurement behind it: the house idiom `# cps ((range 60 180 "^29")/60/4)`
      spreads 120 BPM across 128 CC steps at 0.94 BPM per step, so exactly 120 is
      unreachable — step 63 is 119.5 and step 64 is 120.4. Wrapping the range in
      `quantise 1` makes round tempos land exactly AND gives the knob a real two-step
      detent at each one, which is the notch the v3 encoder lost.
      
      Two treatments, deliberately different. A LIVE cps line is not edited — the
      tempo is playing and this is his set material; it gets a TODO on its own line
      above. An already-COMMENTED one is rewritten in place to the quantise form,
      still commented, so uncommenting it gives round BPMs from the first click.
      
      The TODO goes on its own line and never as a trailing comment, because the
      driver's parse_context uses a line's trailing comment as that control's OLED
      label — a trailing TODO would have renamed the tempo knob "TODO(tempo): wrap
      the range..." on the screen. The first draft of this change also hard-coded
      "60-180" into files whose ranges are 75-165, 100-166, 99+-20 and 60-124; it was
      reverted and rewritten range-agnostic before landing.
      
      Verified all 14 matches are genuinely tempo knobs (including one `cpsbus 4`
      variant in ragga_la_hierarchie), not incidental cps-and-range coincidences.
      PLN (Algolia) authored
    • docs(armada): log 037 — the surface follows the track · 9903e221
      The documentary entry for the night the driver stopped being a one-track
      instrument: the follow bus that already existed and nobody had read, PLN's
      carry policy in his own words, the tempo knob that became the clock, and the
      transition move the screen now serves. Plus the three findings that cost only
      reading (an innocent v2 service, MIDIIn.connectAll, a disproven off-by-one) and
      the two traps worth retelling (the detent that welded itself to the first notch
      twice, and zsh eating the backticks out of a commit message).
      PLN (Algolia) authored
    • feat(lcxl3): a probe that asks the device instead of us guessing · ac864787
      Three rounds of screen fixes were hypotheses dressed as patches: the lap clock
      is frozen at 0:00 on glass while our ghost ticks, and the DJF Hz readout
      "refresh maybe every 1s, not smooth at all". Both point at one unproven rule —
      that a field write to an ALREADY-VISIBLE display target does not repaint
      without a bring-up — and the protocol offers no display readback, so the only
      instrument that can settle it is PLN's eyes, asked precise questions in order.
      
      So: a guided probe. Each step prints an instruction, sends a minimal
      distinguishing sequence, and records what he saw; the run ends with a table and
      a verdict naming the surviving hypothesis, plus JSON in the scratchpad so the
      answers outlive the session. --dry-run prints every byte with the hypothesis it
      tests, so the whole thing is reviewable without hardware.
      
      It distinguishes: bring-up NONE/AFTER/BEFORE/RECONFIG on the stationary target;
      the same on the temporary target plus a 20/10/5-per-second rate ladder (which
      directly decides the DJF smoothness fix); whether per-control targets can be
      driven while untouched; which auto bit actually summons; and an arrangement
      census with a SENTINEL, so an arrangement the firmware IGNORES reads as "still
      the sentinel" instead of masquerading as a real layout — without that, "ignored"
      and "renders like the previous one" are the same observation and the census
      would invent arrangements that do not exist.
      
      Two things it found by reading our code, before touching the device. The
      targets showing both symptoms are on arrangement 2, not the 4 we describe in
      our own notes (4 is only label_control's default). And CFG_BRING_UP = 0x7F is
      arithmetically ARR_TRIGGER | AUTO_TOUCH | AUTO_CHANGE, so every bring-up may
      RE-ARM the auto-display bits we deliberately clear on the three DJF knobs —
      which would explain, mechanically, why the firmware's number keeps reappearing
      over "HPF 3.4kHz !!". That became its own probe step.
      
      Safety, since the rig is live: display and config SysEx only, enforced by an AST
      check that there are zero cc/note/rgb/feature call sites plus raisers installed
      over those methods at open time; it refuses to run while the driver is up
      (printing the pid, the kill, and the restart line); and it restores the
      arrangement the driver expects from a finally block.
      
      NOT YET RUN ON HARDWARE — committed so the work is not parked in a scratchpad.
      PLN (Algolia) authored
    • docs+fix(lcxl3): the surface register, and a control PLN commented out no longer glows · 15c2c86a
      PLN: "ok lets list my demands not handled yet and ensure we track all tasks and
      ergonomic issues to improve". So the register: every open ask with his own words
      attached, stable IDs, grouped by what unblocks what — and the honest note that
      ONE probe run at the desk unblocks five screen items at once.
      
      The fix came out of writing it. nouvelle_couleur.tidal carries a parked tempo
      knob, a commented `# cps ((range 60 180 "^29")/60/4)`, and the mapped-set
      scanner read commented lines — so B1 glowed on a board whose oldest rule is
      "dark = unmapped". parse_context already skipped comments; parse_track now does
      too. Live: 12 controls -> 11, 18 lit cells -> 17, B1 dark. Uncomment the line
      and the next follow tick (under a second) lights it and the tempo readout
      appears with it.
      
      Also recorded: the v2 lcxl-leds-watch service has been logging "no LCXL
      sequencer port" every 30 s since 22:39 — its name regex cannot match "LCXL3 1",
      so it has never touched the v3 board. It was my prime suspect for the
      paint-reset mystery and it is innocent; that mystery is reopened with no
      suspect. The Midi-Through to SuperCollider re-add is explained though:
      MIDIIn.connectAll, re-running on sample reload, exactly as
      midi-autoconnect.sh's own header warned it would.
      PLN (Algolia) authored
    • feat(lcxl3): the surface follows the track — and the tempo knob grew notches · 7c297a61
      The driver parsed its .tidal once, at launch, and never looked again. PLN said
      it three ways in one session: "i see still ROSE_ROUGE in title", "why dont i
      see the lcxl colors evolve as i map buttons on the new track", "probably this
      will be an opp to confirm you read current file well". One bug, three faces.
      
      FOLLOW. The editor has published the loaded track for a while
      (~/.cache/parvagues/current-track, written by the HUD) and logs every
      ctrl+enter (eval-events.jsonl). The driver needed no new channel — it needed to
      read. It now polls both once a second: a different path is a track change
      (remap, relabel all 32 targets, repaint, reset the lap clock), and a changed
      mtime on the SAME path is a re-read, so mapping a button and saving lights the
      board within a second. --no-follow pins the old behaviour.
      
      CARRY POLICY, PLN's spec verbatim: "faders are real positions, but could we
      reset the knobs to 'defaults' on all effects? and maybe teh DJFs stay across
      tracks so i can do djf-and-fader-crossfaded-transitions". So DJFs carry (they
      ARE the transition), effect knobs go to 0, faders are physical and untouched.
      Two cells carry that he did not name, because the sound would have taught us
      the hard way: the TEMPO knob (resetting it means the range floor — a 60->120
      transition would slam back to 60 exactly when the new track lands) and every
      button (emitting to a gate is the mutebomb; latches reset in the driver so the
      LEDs stop lying, but nothing is sent).
      
      LIVE TEMPO. sept1's idiom `# cps ((range 60 180 "^29")/60/4)` makes B1 the
      clock — and it steered the tempo all last session while the OLED said "B1
      unmapped", which is the whole argument for following the track: the sound was
      right and every label lied. The BPM is now derived from the knob, and bar phase
      is INTEGRATED rather than read off the wall clock, because `time.time() * bpm /
      60` re-scales all of history whenever the tempo moves — the countdown would
      have jumped precisely mid-transition, the one moment it is read.
      
      THE MOVE. PLN: "start holding kick mute, move kick to the 120bpm position (i
      used the center notch, now ill rely on OLED), drop kick mute at end of a bar".
      So the screen becomes the notch: BPM not wire units, an arrow to the nearest
      landmark that vanishes when he is on it, and — while a mute is held — the bar
      countdown ("DROP kick / ~120 BPM >2"), with the ring breathing at the beat to
      count it in. The bar is real, not guessed: `resetCycles` shares a block with
      the patterns, so every ctrl+enter IS cycle 0 and the eval log is a phase anchor.
      
      Plus software detents, since the v3 encoder has no ridge: a deliberate single
      click that would cross a musical tempo lands ON it and holds for two more
      clicks; fast sweeps pass through untouched. Two versions of that welded the
      knob to the first notch it met (the closest STEP to a landmark sits just below
      it, so stepping up re-crossed it and snapped back — measured, 24 consecutive
      clicks stuck at 114.8 BPM); the fix tracks WHICH landmark holds us by identity
      and releases only once the value has actually left it.
      
      And the mute overlay finally reports the whole held SET ("MUTE x2 /
      kick+percs", "all") instead of the last finger down.
      
      Honesty notes: 60-180 over 128 steps is 0.94 BPM/step, so exactly 120 is not
      reachable (step 63 -> 119.5, step 64 -> 120.4). The readout prints "~120" for
      the closest step and a bare "120" only when it is exact. A track wanting true
      round tempos can quantise in the pattern:
      `# cps ((quantise 1 (range 60 180 "^29"))/60/4)` — then two steps both land on
      120 and the detent is the music's, not the driver's.
      
      Also closed by evidence rather than code: A1's label is correct ("d9 level
      [ard]" — the grid puts d9-d12 on row A, faders carry d1-d8), so the suspected
      off-by-one that sat on the board was never real.
      PLN (Algolia) authored
    • docs: board — session plan for the Opus shift: keystone is the v2 ghost still painting · fab4512a
      Ground-truth pass found lcxl-leds-watch.service (the v2 LED watcher) still
      running beside the v3 driver — the likely author of the paint-reset mystery —
      and midi-autoconnect as the probable Midi-Thru→SC re-adder. Plan: kill the
      tug-of-war first, then the A1 label off-by-one, then the generalize thread
      PLN named (track-follow + live BPM from the cps knob), then the display
      probe with his eyes on the device.
      PLN (Algolia) authored
    • docs: board — 2nd pre-compact; generalize-the-driver is the thread · c462ce7e
      sept1.tidal proved the architecture mid-set: translation is track-agnostic
      (the cps knob on ^29 changed tempo with the driver still mapped for
      rose_rouge), so playability generalizes for free and only paint/labels/BPM
      are per-track — exactly the next thread. Fresh eye-batch bugs boarded with
      the discipline note: READ label_control before believing the off-by-one.
      PLN (Algolia) authored
    • feat(rose_rouge): une rose qui respire — looped bloom backdrop, four treatments · 41c53c27
      The Pinterest bloom gif is one-way (bud → full rose on black), so a
      plain loop snaps. Four loop treatments now sit in visuals/scenes/,
      auditionable live by swapping the header directive (ctrl+s reapplies):
      
        rose_bloom          ping-pong at half speed, mci-interpolated —
                            the rose breathes open/closed over ~13.5s (default twin)
        rose_bloom_fast     ping-pong at native speed (~6.8s cycle)
        rose_bloom_rebloom  seam-crossfade: blooms again every ~2.5s
        rose_bloom_noir     fade through black, cinematic gap
      
      Recipes (ffmpeg): ping-pong = split+reverse+concat with boundary frames
      trimmed; slow = setpts 2x + minterpolate mci; rebloom = xfade against
      itself, trim [fade, offset+fade]; seams verified by first/last-frame
      RMSE (0.028 = codec noise for the ping-pongs).
      
      Source renamed to ~/Downloads/rose_bloom.gif (stable stem for the
      twin lookup).
      PLN (Algolia) authored
    • fix(lcxl3): the lap clock could not survive its first tick — and values echo home · 09aaf055
      Two things, one restart.
      
      The 10-second home-refresh ticker read last_home, which was never initialised:
      my init patch targeted the pre-merge line and the merged code had renamed it
      (last_labels joined the chain), so the replace silently no-opped — I had
      asserted every replacement in that patch EXCEPT this one. The driver died at
      its first tick, 10 s after start, while the surface kept showing the labels
      the startup had already written: a dead translator behind a live-looking
      display. Fixed, and verified past the tick this time.
      
      And the stuck "0" PLN saw on row 3: arrangement 4's numeric value is drawn by
      the FIRMWARE from its own internal control value, which never moves for a
      relative encoder — the driver owns those values now. So the driver echoes each
      integrated value back to the device on the encoder's own CC (plain DAW-style
      feedback). --no-echo exists in case the echo ever fights the RGB paint; the
      2 s paint reassert heals any skirmish regardless.
      
      Next display experiment, deliberately not built blind: arrangement 1
      (host-supplied TEXT value) on per-control targets, which would let
      probability-style constructs (sometimesBy / someCyclesBy / midiOn ranges)
      read as percentages instead of 0-127.
      PLN (Algolia) authored
    • feat(lcxl3): row order is ergonomics — and the lap clock · e7e441a9
      PLN, from the chair: seated, OLED row 2 reads clearly and row 3 hides behind
      the bezel. So the information hierarchy now follows the sightline, not the
      protocol's field order:
      
      - HOME: track name / "118BPM 4:32" / ParVagues. The drip moves to the bezel
        row; tempo and TIME PLAYED take the prime row. The elapsed counter is the
        overstretch check PLN asked for ("maybe time played that track eventually
        when in a set recording to help know when overstretching") — refreshed every
        10 s by a field write on the stationary target, no re-summon. Lap reset
        belongs to the future Track </> setlist cursor; today, track load = lap 0.
      - DJF overlay: "DJF percs" / "LPF 2.4kHz !!" / (blank). The cell name is gone
        entirely — "C1 C2 C3 are noise i know the button positions ahah".
      - Gates: "d1 gate" / "ON" / context. Value rides row 2 everywhere.
      - The zero-mark heart animation moved to row 2 with everything else.
      
      Display byte-pinning tests still pass; the ghost mirrors the new layout.
      PLN (Algolia) authored
    • docs: board state + log 036 — the night the surface learned to speak · acdaacf1
      Pre-compact ritual: 11 threads closed tonight, 8 resume points with exact
      next commands. The theme of the night was identity bugs — a port's name is
      not the port, a device's saved identity is not the device — written up in
      log 036 and banked to memory (ardour9 generic midi, worktree roots,
      34-display-targets protocol update).
      PLN (Algolia) authored
    • docs(armada): log 035 — there were never two screens, there were thirty-four · ac8ed14d
      The LCXL3 OLED fix as a story: three rounds tuning a keep-alive for a race that
      was unwinnable by construction, and the partial symptom (buttons were always
      fine) that pointed at addressing rather than timing the whole time.
      PLN (Algolia) authored
    • fix(lcxl3): we were fighting for the wrong screen — every knob owns its own · 78ba7fca
      PLN, three rounds in: the OLED "still shows values". Move a fader and you get
      the firmware's bare 0-127; move a relative encoder and you get "0", because the
      wire value of a relative encoder IS 0-ish. "No info at all." Meanwhile our own
      overlay — track name, what the control does, a readout — was being re-summoned
      every 0.9 s under a 1.2 s firmware timeout, field-diffed, stamped only on real
      bring-ups. Three increasingly clever versions of the same losing move.
      
      The programmer's reference says why, in the display chapter's first list:
      
          "• 05h (5) - 24h (36): Temporary display for Analogue controls (same as CC
             indices, 05h (5) - 0Ch (12): Faders, 0Dh (13) - 24h (36): Encoders)
           • 35h (53): Permanent/Stationary display
           • 36h (54): Overlay/Temporary display"
      
      There are not two display targets. There are thirty-four. Every fader and every
      encoder owns a display target of its own, addressed by its own DAW-mode CC
      index, and THAT is the one the firmware auto-triggers when you touch the
      control — not 36h, which is where we had been writing all evening. Config bit 6
      is its on-switch and it defaults to on:
      
          "• Bit 6: Allow Launch Control XL 3 to generate temporary display
             automatically on Change (default: Set).
           • Bit 5: Allow Launch Control XL 3 to generate temporary display
             automatically on Touch (default: Set; this is the Shift + rotate)."
      
      So a fader sweep fired target 05h thirty times a second while we re-summoned
      36h once a second. Unwinnable by construction, and no amount of keep-alive
      tuning was ever going to change that. It also explains the one part of the
      symptom that never fit a timeout story — buttons were fine. Buttons live at
      25h-34h, outside the analogue range, own no per-control target, and so were
      never contested at all.
      
      And the fix is not to win the fight. Arrangement 4 is the firmware's default:
      
          "4 | 2 lines: Parameter Name and Numeric Parameter Value (default) | Yes | 1 | Name"
      
      One field, Name, and a value the firmware draws itself. The Name field was
      empty because nobody had ever written it. Write it, and the display we were
      trying to suppress becomes the display we wanted: it already tracks the value at
      wire speed and already times its own dismissal. So the driver now labels all 32
      analogue controls on their own targets at startup — "d4 # crushbus",
      "d5 # octerbus", "d1 level [ard]" — and stands down from the overlay for them.
      
      Faders and A1-A4 carry no ^NN in any .tidal, because they are Ardour-learned
      levels; their label comes from the grid's role table instead, with an explicit
      [ard] mark. Mid-set, "this fader is Ardour's, not Tidal's" is the most useful
      sixteen characters the panel can hold. Unmapped controls say "unmapped", because
      dark-means-unmapped is the surface's oldest rule and the screen must not
      contradict the LED beside it.
      
      Three controls keep our overlay: the DJF knobs. "HPF 3.4kHz !!" and the heart
      frames at the zero detent are prose, and arrangement 4's value field is a
      number. For exactly those three targets we clear bits 5+6 — which is the
      documented, per-control way to make the firmware stand down — and take the
      screen back uncontested. Restored on exit, since a next session finding three
      knobs that silently refuse to show a value would be a worse bug than this one.
      
      Along the way, three things the per-control screen made visible by being the
      first thing to render our labels at full width:
      
      - set_text filtered to `0x20 <= c <= 0x7E` and so dropped the four reassigned
        control codes its own docstring advertised. The language module's heart frames
        had been arriving as plain spaces this whole time.
      - parse_context said "an inline trailing comment IS the best label" and did not
        do it: the only `--` strip was anchored at the start of the body, where a
        trailing comment never is, so the Haskell always won. C8 of piment_bresilien
        read `d8 # n "23")) --` on a line ending in PLN's own `-- Raise COMEON!`.
        The comment is split off first now and preferred.
      - With no stage note, the body fallback was raw combinator soup. It now reads
        the sample name off the first quoted string and the effect off the first
        identifier — "break:18", "n 6" — which is what PLN asked the line to say
        ("sample name or effect like 'rose:4'"). Across live/: 158 syntax-noisy
        labels out of 22368 became 3.
      
      Verification I can actually do, given a write-only display and no readback:
      --show-oled dry-runs the whole label table offline, opening no port, and fails
      on any name over 16 characters; the new test pins the target numbers against the
      guide's table, the exact configure/set_text bytes, that a button raises instead
      of writing into the void, that the hearts survive the filter, and the three
      parse_context cases above. What no test can prove is a pixel. That needs eyes.
      
      Fallbacks kept in case the name field turns out not to render: --oled-overlay
      suppresses every native display and restores the uncontested-overlay behaviour
      across the whole surface, and --oled-native-all hands even the DJFs to the
      firmware, for the eye test that compares the two side by side.
      PLN (Algolia) authored
    • feat(lcxl3): Hz-true DJF readout, the OLED ghost, and the edge-button census · ca173ae1
      Round 4 of the live design session, plus the nine-press census.
      
      DJF readout speaks Hz, not percent — and the numbers come from BootTidal's own
      gDJF ranges (lpf: range 180 20000 over 2v; hpf: range 20 8000 over 2(v-.5)),
      so what the OLED says is what the filter DOES. The zero mark animates: heart
      frames at ~3 fps while a DJF rests in the detent (charset 0x1E is a heart).
      Home screen line 3 is now "118 BPM" — "36 live" was a control count nobody
      asked for, and PLN rightly mocked it.
      
      The OLED GHOST (/tmp/lcxl3-oled-ghost.txt): the protocol has no display
      readback, so nothing can pull the screen — instead the driver mirrors every
      frame IT sends into a box-drawing the shell can read. What the ghost cannot
      show is the firmware's own native value overlay, and that is the point: when
      PLN's eyes see content the ghost does not have, the discrepancy itself is the
      diagnosis. (That discrepancy is live: the native overlay still outdraws our
      temporary text on every control move — an Opus worker is in the programmer's
      reference hunting per-control display targets, the likely correct fix.)
      
      Keep-alive: the round-3 overlay stamped _oled_up_at on every push, so after
      the firmware's 1.2 s timeout our overlay was never re-summoned — one good
      overlay per touch, then raw values forever. Now the bring-up re-fires every
      0.9 s of continuous activity and stamps only when actually sent.
      
      THE EDGE-BUTTON CENSUS (PLN pressed, the log answered): Solo/Arm is ONE key
      (ch1 CC65, two press cycles for his presses 1+2), Mute/Select is ONE key
      (CC66), the Novation logo is alive (CC104), Track </> are CC103/102, Page
      up/down CC106/107. Authored as EDGE_BUTTONS in lcxl3.py; the driver now names
      them in the log instead of counting them unmapped. No functions assigned yet —
      the settled direction is Track = setlist cursor, Page = OLED pages, the two
      verb keys = record-arm and panic, logo in reserve.
      PLN (Algolia) authored
    • feat(mafia): the Afia oil ad rides again — scene header points at its GLITCHWAVE source · 89c7d967
      The glitch loop cut live from the Afia ad (afia_flow.gif) is now this
      track's backdrop, through the HUD's new standard: the header names the
      source, scene-ingest derives the playable twin into visuals/scenes/.
      PLN (Algolia) authored
    • feat(bridge): midiviz — the MIDI stream as a picture, not a transcript · 30a704ec
      PLN, 2026-08-29: "midiwatch still naively verbose [`14:0 Control change ch0,
      controller 29, value 15`] improve it massively. no more console, tiny borderless
      window that exits on q/exit/ctrl+c/alt+f4, that renders the stream in a nicer,
      cyberpunk matrix dense cryptic purple-on-dark, style. no word like 'control
      change' pure viz"
      
      The complaint is not about verbosity, it is about REGISTER. One line per event is
      the right shape for a debugger and the wrong shape for a performer: a fader sweep
      emits ~400 events a second, so midimon's prose scrolls past faster than an eye
      can land on it, and the one question you actually have mid-set — "which orbit did
      I just touch" — is the one thing a sentence makes you decode. So this is a lens,
      not a log.
      
      The picture
      -----------
      Zero English words render. An event's identity is its POSITION, its type is a
      GLYPH CLASS, its value is a BAR plus two hex digits, and its recency is
      BRIGHTNESS. The layout is the authored surface itself — lcxl_grid's rows A..F in
      PHYSICAL_ORDER down, columns 1..8 across, column N *is* orbit N — so the answer
      is spatial and needs no decoding. A per-orbit charge glow behind each column says
      "this one is live"; the column digit brightens with it. Buttons render as latches
      (filled above half, hollow below) because rows E/F are gates, not levels — a bar
      would have lied about what they do. Channel shifts the hue of the digits, not of
      the cell, so the cell keeps meaning "which control" while the tint carries "from
      where". Only what the grid CANNOT place — foreign CCs, notes, bend, program —
      falls as rain in the right-hand gutter, which makes "that came from somewhere
      else" a visible fact rather than a thing you read. A ribbon along the bottom
      scrolls the raw order of arrival, brightest at the right.
      
      Every hue stays inside the violet→magenta arc, so eight roles stay
      distinguishable while the window still reads as one colour.
      
      Choices worth keeping
      ---------------------
      * **Mapped-but-idle must not be black.** All 48 grid cells carry a floor tint
        even when nothing has ever arrived on them. "Dark = not mapped" is the reading
        a cockpit trains you into, and a dark cell that IS mapped reads as a hardware
        fault — the same trap the LED work settled.
      * **Toolkit: PySide6 (Qt6), already installed (6.11.2).** PyQt6 and pygame are
        not present; GTK4 + pycairo are, but Qt gave frameless + always-on-top hints +
        startSystemMove (the only way a Wayland client may reposition itself) without
        hand-rolling a drag.
      * **Two frame rates, not a busy loop.** This machine performs live audio. ~25 fps
        while events arrive, dropping to 5 fps two seconds after the last one, where
        the only motion is a slow dim pulse. Colours are a precomputed LUT (8 families
        x 16 decay levels), paint uses the int overloads, the CRT scanline texture is
        one cached pixmap blitted once, and the ribbon is bounded to exactly what fits
        so a frame copies nothing.
      * **Profiled rather than guessed.** The first version cost 30.6% of a core under
        a sustained 400 ev/s sweep. Stage-by-stage timing put the cost in the sheer
        NUMBER of drawText calls, not in pixels: gutter rain alone was 3.1 ms of an
        8.9 ms frame (a 96-drop cap with a trail glyph each = up to 192 calls). Capping
        rain at 32 heads with a trail only on the brightest, batching the ribbon into
        same-colour RUNS, and 33→40 ms took the frame to 5.5 ms and the sweep to 17.7%.
        Idle is 1.4%. The ribbon batches only hex glyphs, because those are guaranteed
        to come from the monospaced face at exactly one advance — a class glyph (◆ ≈ ≡)
        may be served by a fallback face and would drift the ribbon off its grid.
      
      Port choice: delegated, not re-derived
      --------------------------------------
      `midimon.resolve_port()` owns "which port", and midiviz delegates to it. Two
      things about it are load-bearing and were nearly re-derived wrong here: the
      preference order is authored, and it matches the whole `aseqdump -l` ROW rather
      than the CLIENT column — python-rtmidi registers the lcxl3 driver's client as
      'RtMidiOut Client' and puts 'ParVagues LCXL3' on the PORT. A client-name match
      (which is what surface.resolve_port does, correctly, for its own purpose) falls
      straight through to the raw hardware: the monitor would work and show the wrong
      stream. Confirmed live on this rig, which has both up — the driver is 133:0 under
      'RtMidiOut Client'. The local fallback copy exists only for trees whose midimon
      predates the helper, and a test asserts it cannot drift from the original.
      
      Launcher
      --------
      The `midimon` KEY is kept, so the Bridge hub's and the tray's existing button open
      the new thing with no change on either face; midimon.py is untouched and remains
      the terminal fallback. `terminal: True` is dropped — midiviz draws its own
      window, and wrapping a GUI in konsole would have parked an empty black terminal
      behind it for the length of the set. That also re-enables the already-running
      guard, so the button is now idempotent instead of spawning a window per click.
      
      Verified
      --------
      * `ast.parse` clean; full bridge suite 90 passed (was 78 + 12 new).
      * `midiviz.py --selftest`: 3 s of synthetic events — every one of the 48 authored
        grid cells, four channels, un-gridded CCs, notes, bend, program, aftertouch,
        sysex — pushed through the REAL parse_line + enrich + ingest + paint path, 140
        paints, 68 grabs, 139 distinct sampled colours. It grabs the widget each tick,
        so the drawing code is exercised for real and offscreen, not merely constructed.
      * Exit paths run, not assumed: SIGINT and SIGTERM each exit 0 from a live process
        (q/Esc/close share that path).
      * Plumbing, not just pure functions: a real Reader subscribed to the resolved
        driver port with no error, and a second one pinned to Midi Through received 8
        genuine events played in with aplaymidi — aseqdump → parse_line → enrich →
        drain, end to end against real ALSA.
      * Rendered and looked at, at 420x260 and at 2x, before and after the
        optimizations: identical output, which is the point of the run-length batching.
      
      Found on the way: data-less realtime messages (Start/Stop/Continue/Clock) print
      with no data column, and midimon's row regex requires one, so they never parse —
      midimon's console silently omits transport too, and midiviz has four glyphs that
      cannot light up. Left in midimon (one parser per concept) and pinned by a test
      that says what to delete when it is fixed.
      PLN (Algolia) authored
    • fix(rig): Ardour hears the surface again — two stale identities, one webcam · 63f53205
      PLN's report: "no impact of lcxl in ardour... why would i need to remap midi
      controls there across lcxl gens?" He is right: he never needs to remap. The
      session file has the learned bindings (v2 CC 77-84 + 13-16, channel 1) and the
      lcxl3-driver emits exactly that. The path was broken in TWO places, both by
      name-vs-identity rot:
      
      1. midi-autoconnect resolved its source by NAME and connected "name:0" — but
         aconnect resolves CLIENT names only, and the driver's virtual port carries
         its name on the PORT line (client = python-rtmidi's 'RtMidiOut Client').
         The connect failed into 2>/dev/null for exactly the source the candidate
         list most prefers, so Midi Through carried NOTHING in DAW mode. Now the
         resolver returns a numeric client:port address (awk over aconnect -i,
         matching client AND port lines). Fourth name-identity bug of the day.
      
      2. Ardour's Generic-MIDI input connection was saved by device identity, and
         that identity was "ALSA;;C922 Pro Stream Webcam" — the webcam had taken
         over the slot the surface once owned. The purest stale-binding specimen the
         rig has produced. tidal-ardour-autoroute now asserts
         Midi-Bridge Midi Through -> ardour:MIDI Control In on its 2 s cadence,
         no-op until Ardour registers the port, permanent once it does.
      
      Midi Through stays the rendezvous ON PURPOSE: v2 raw or v3-translated, both
      feed it, which is what keeps the classic LCXL plug-and-play (the BC
      constraint). Ardour 9.7 note for the archives: the Control Surfaces page is
      now a vendor tree built from the .map files' manufacturer field — the old
      "Generic MIDI" lives under vendor "Any" (GenericMIDI.map, manufacturer="Any"),
      NOT under "Other", and Novation's "Launch Control XL (generic)" entry is the
      factory map that would OVERRIDE the learned bindings — do not enable it.
      PLN (Algolia) authored
    • feat(lcxl3): the v3 visual language — driver-owned values, latches, and a · 857de5b2
      surface that breathes at the track's tempo
      
      The design session with PLN, hands on the device, four AskUserQuestion answers
      and three live iterations. Everything here is v3-only BY CONSTRUCTION: the
      corpus, BootTidal, SC and Ardour still speak v2 absolutes, so the classic LCXL
      (PLN's fallback, and his friend's surface) keeps working with zero changes —
      "we need to be BC always" is the standing constraint.
      
      THE LANGUAGE (lcxl3_language.py, new — parallel to lcxl-leds.py, replacing
      nothing):
      - rings: hue = ROLE (per-orbit family anchors, warm rhythm bloc / purple bass /
        cool melodics), lightness = VALUE. This is the exact trade v2 could not make
        with three LED hues — the settled 2026-07-29 language spent all hue on value
        because role had nowhere else to live. RGB dissolves the trade.
      - dark = unmapped survives unchanged: it outranks everything on both surfaces.
      - DJF row C, PLN's spec after the first blue<->green attempt proved "too
        subtle": blue = lows kept, DEEP GREEN BREATHING SLOWLY = the zero mark
        (there is no pot ridge on an endless encoder — the 0 must be VISIBLE),
        then green -> yellow -> red as the HPF cut grows. Near-silence extremes
        breathe to the BEAT — sine, not blink ("a bit aggressive atm"), rate from
        the track's setcps (118 BPM for rose_rouge). Wall-clock phase: it breathes
        AT tempo without claiming to know Tidal's cycle position — true phase sync
        is a feed from SC, later, not a silent assumption now.
      
      DRIVER-OWNED TRUTH (three kinds, one principle):
      - encoder VALUES: rows B/C relative, offset-64 decode — MEASURED off PLN's
        slow clicks arriving as 63/65/66; the first decoder assumed two's complement
        and read +1 as -63, direction inverted, every click a full-range jump. Row A
        stays absolute deliberately: A1-A4 are Ardour-learned levels, and integrating
        them from a blind seed would yank a live fader toward silence.
      - row-E LATCHES: the latch never lived in Tidal (midiOn/midiOff read the last
        value) — it lived in the v2 hardware's toggle buttons. v3 DAW buttons are
        momentary, so the driver flips state on press, swallows the release, and the
        LED paints driver state — which IS what Tidal hears. Row F stays momentary:
        gestures are held, and the panic chord needs four simultaneous 127s.
      - PAINT as a binding: the device repaints its own defaults on events we cannot
        see, so the driver re-asserts the full board every 2 s — same doctrine as
        every aconnect link on this rig. Faders are OUT of the paint path: they have
        no LEDs, and painting them was a no-op lie this commit stops telling.
      
      OLED:
      - touched-control context in CORPUS terms, line 2 parsed from the .tidal:
        "# crushbus", "mask <f f f t>", and — best of all — PLN's own inline stage
        notes: ^35 shows "HANDS IN THE AIR", ^59 "Le Delay rose!!!", ^36 "Savoy 8/8
        Break". His words, on his hardware, at the moment his finger lands.
      - anti-blink: fields are diffed and the configure/bring-up pair fires only when
        the overlay has likely expired — re-summoning a live overlay every event was
        the blink PLN saw.
      - gates read ON/off, DJFs read "LPF 43% / BYPASS / HPF 61% !!".
      
      Validated on the live rig across three rounds of PLN's eyes and hands:
      translation + paint + OLED coexist in one process; 27 live cells / 13 dark on
      rose_rouge; 18/18 controls context-labelled; latch verified against E1.
      PLN (Algolia) authored
    • fix(bridge): RIG UP now shows its work in a second, and midimon opens wired · b30715b7
      Three gaps PLN hit launching the rig on 2026-08-29, reported as "had to launch
      ardour myself / had to open midi watch myself / once opened had to wire it to
      midi through myself". All three were real, and one of them nearly got "fixed"
      the wrong way.
      
      GAP 1 — the button looked dead for over a minute. _run() ran gig-up --converge
      to completion (~100 s on a cold boot while SuperDirt warms 51 banks, up to the
      300 s timeout) and only THEN launched the apps. So RIG UP showed a spinner and
      no window, PLN concluded it had failed, and launched Ardour by hand.
      
      My first attempt was to launch the apps first. That was wrong, and PLN caught
      it: "superdirt froze before loading midi when in my launcher, when ardour was
      midi connected, had to untick ardour midi, then launch". Ardour holding the
      surface makes sclang hang inside MIDIClient.init / MIDIIn.connectAll — the same
      rawmidi contention that stops `amidi` writing LEDs while SC is up. The original
      ordering was not an accident, it was load-bearing, and reordering it would have
      traded a cosmetic complaint for a reliable boot freeze. Reverted.
      
      The correct shape is a SPLIT, not a reorder:
      
          PRE_APPS  = ["pulsar"]              touch no MIDI -> open immediately
          POST_APPS = ["ardour", "midimon"]   contend for the surface -> wait
      
      Pulsar's GHCi reaches Tidal over TCP 6010 and never touches ALSA, so it can
      open at once and give the button a visible effect within a second, while its own
      multi-second startup overlaps converge instead of queuing behind it. Ardour
      still waits for SuperDirt to own MIDI first. Converge output now appends to the
      launch lines rather than overwriting them, so the log shows both halves.
      
      GAP 2 — midimon was in NEITHER list. RIG UP never had it to open, so "had to
      open midi watch myself" was exactly correct. Added to POST_APPS.
      
      GAP 3 — midimon opened blind. `aseqdump` with no -p subscribes to nothing and
      prints "Waiting for data at port", so every launch needed hand-wiring before it
      could be used. Added resolve_port(), which picks the most interesting source
      present, in a deliberate order: the lcxl3-driver's TRANSLATED stream first
      (the numbers the corpus speaks — what you want when a knob does the wrong
      thing), then the raw v3 DAW port (the numbers the hardware speaks — for "is it
      even sending"), then the custom-mode port, then Midi Through as a catch-all.
      
      It matches CLIENT and PORT names both, because python-rtmidi registers the
      driver's client as 'RtMidiOut Client' and puts 'ParVagues LCXL3' on the port —
      a client-only match misses precisely the stream most worth watching. That is
      the third bug today caused by treating a MIDI endpoint's name as its identity.
      
      Verified with the driver running: resolve_port() -> ('133:0', 'ParVagues LCXL3'),
      i.e. it selects the translated stream over the two raw ports and Midi Through.
      PLN (Algolia) authored
    • fix(lcxl3): identity is the ALSA port id, not the name — and the surface now plays · 9b482463
      Follow-up to fa22bb85, which fixed the direction trap but still could not run.
      Two more defects, then the first verified end-to-end translation.
      
      DEFECT 3 — the virtual port could never be found. _clients() returned
      (port_id, CLIENT_name) and every lookup matched the client name. python-rtmidi
      registers the client as 'RtMidiOut Client' and puts OUR chosen name on the
      PORT:
      
          client 133: 'RtMidiOut Client' [type=user,pid=...]
              0 'ParVagues LCXL3 '
      
      So `VIRTUAL_PORT_NAME in client_name` was false forever. The driver printed
      "reconcile: src=None dst=130:0 — nothing to do yet" on every cycle while
      `aconnect -i` listed the port plainly two lines below. _clients() now returns
      (port_id, client_name, port_name) and _find_port() matches either — both names
      are real and each is load-bearing somewhere.
      
      DEFECT 4 — prune() would have cut the driver's own link. It skipped "our" port
      by testing VIRTUAL_PORT_NAME against the name from `aconnect -l`, which is the
      CLIENT name — 'RtMidiOut Client'. That never matches, so the very first prune
      after a successful connect would have severed the connection it had just made,
      every cycle, forever. prune() now takes an explicit set of protected PORT IDS.
      Identity here is the ALSA port id; the names are decoration. Any place that
      matches a MIDI endpoint by name on this rig is a latent version of this bug.
      
      Also: reconcile() prunes only AFTER a confirmed connect. Cutting the raw
      surface while the replacement stream is not actually delivering would leave a
      dead surface, which is worse than a wrong one.
      
      VERIFIED END TO END on the live rig, PLN's hands on the hardware:
      
          fader 1      v3 #5   -> v2 #77   (38 events, full sweep 37..0)
          button E1    v3 #37  -> v2 #41   (127 then 0)
      
      ^77 is lcxl_grid's row D cell 1 and ^41 is the kick gate — the exact control
      the #94 migration aligned all 169 tracks onto, and the one v3 renumbering would
      have moved to button E5. 0 unmapped, 0 dropped. The graph after prune:
      
          133:0  RtMidiOut Client  -> 130:0    (ours, the only surface path)
          20:0 / 20:1 LCXL3 1                  GONE from SuperCollider
          14:0 Midi Through -> 130:2           re-cut every cycle
      
      Open, and deliberately not guessed at: something re-adds Midi Through -> SC
      within 5 s. midi-autoconnect only ever creates the OTHER direction, so the
      re-adder is unidentified. prune contains it each cycle; the window is real but
      small, and naming a culprit without evidence is how the last three "rig is
      broken" findings turned out to be the measuring tool.
      
      Resume point 1 is closed: the driver has met a live SuperCollider, and it took
      four defects to get one fader through. Every one of them was invisible to
      reading — three reported success while doing nothing.
      PLN (Algolia) authored
    • fix(lcxl3): the driver had never connected anything to SuperCollider · fa22bb85
      Resume point 1 was "run lcxl3-driver against a live SuperCollider, because it
      never has been". It has now, and the run found two defects that no amount of
      reading could have found — both invisible precisely because the code reported
      success.
      
      DEFECT 1 — the aconnect direction trap. reconcile() resolved its destination
      with `_clients("-i")`:
      
          dst = next((p for p, n in _clients("-i") if "SuperCollider" in n), None)
      
      `aconnect -i` lists ports you can read FROM. SuperCollider appears in both
      directions: in0..in4 are destinations, out0..out6 are sources. So `-i` returned
      SC's *output* port — 130:5 on this rig — and the driver ran
      
          aconnect ParVagues LCXL3:0  130:5
      
      which writes into an output-only port and fails. The CompletedProcess was
      discarded, so the failure had no way to be seen, and the next line printed
      "reconcile: src -> dst" as though the link existed. Every previous reasoning
      step about this driver rested on a connection that was never made.
      
      Fixed by resolving destinations from `-o` via a new _sc_input_ports(), and by
      checking the return code and PRINTING the failure. A reconciler that cannot say
      "I failed" is decoration — the rig has been bitten by this shape before
      (feedback_verify_the_plumbing, reference_check_the_instrument_first).
      
      DEFECT 2 — reconcile only ever ADDED links, so it could not be correct here.
      `MIDIIn.connectAll` subscribes SC to every source present at boot. Measured on
      the live rig:
      
          14:0  Midi Through  -> 130:2
          20:0  LCXL3 1 MIDI  -> 130:3
          20:1  LCXL3 1 DAW   -> 130:4
      
      So the moment the driver published a correct, translated stream, SC was hearing
      it ALONGSIDE two raw ones. This is worse than duplication: v3's DAW map
      collides with the corpus's v2 numbers on 16 indices with different meanings, so
      row C knob 4 would drive ^52 (right) and ^32 (row B's cell) at once — two
      orbits moving when one knob turns. And a doubled CC is fatal on a latch: the
      toggle fires twice and cancels, which reads from the stage as "the button does
      nothing".
      
      Added prune(), which cuts every path into SC that is not our virtual port,
      re-asserted on the same timer as the connect (the binding must be re-asserted,
      never assumed — feedback_stale_binding_pattern). Midi Through is cut by default
      because midi-autoconnect's rule 1 pushes the surface into it, i.e. it is a
      second copy of the same stream; --keep-thru opts out.
      
      Added `--graph`, which prints what actually feeds SC and what prune would do.
      That is the diagnostic whose absence let defect 1 hide: the driver's own view
      of the graph was never printable, so it could never be checked against reality.
      
      Verified on the live rig (sclang 845344 / scsynth 845718, LCXL3 on client 20):
      --graph now resolves 130:0-130:4 and correctly classifies 3 WOULD CUT paths and
      PipeWire-RT-Event's monitor bridge as left alone. Hardware also confirms the
      translation table's row D: PLN reports fader 1 = CC5, which is V3_ROWS["D"][0].
      PLN (Algolia) authored
    • docs(board): end-of-session state, with the six resume points written for a cold reader · 0a7475b2
      Eleven threads closed, six resume points with exact commands. The first one is
      load-bearing and gets said plainly: tools/lcxl3-driver.py has never run against a
      live SuperCollider, because sclang, scsynth and Ardour were all at zero when it
      was written. Every claim about PLN playing his corpus on the new surface rests on
      that one untested step, so it is resume point 1 rather than a footnote.
      
      Also records what is deliberately left running (the audition server, plus the
      firewall holes its LAN access needs and the ?set= key that cost PLN a listening
      session), and what is parked because it is not mine to commit — four uncommitted
      www files from a prior session's CosmicFest page work.
      PLN (Algolia) authored
    • docs(armada): logs 032, 033, 034 — the surface, the microphone, the recorder · 588d0a41
      Three threads worth their own entries in the documentary trail.
      
      032 — the LCXL3. Closes the board's twelve-day-old OLED question (arbitrary
      128x64 bitmaps, ACKed per frame for animation), records the keystone result that
      paint is DAW-mode-only, and the sixteen-index CC collision whose decisive case is
      v3 #41 = button E5 against v2 #41 = the kick gate log 031 had just aligned 169
      files onto. Plus two instruments that lied: aseqsend -s takes a FILENAME and exits
      0 on 32 silent no-ops, and pgrep -c -f matched its own command line.
      
      033 — fourteen tracks from one microphone. 12 of 14 named correctly off a
      handheld stage mic with no stems and no log. Includes the three lenses, the three
      failed tempo estimators, the same reference-size bias appearing in three separate
      costumes, and PLN's ears beating my reporting twice because I printed 4 of 86
      detections.
      
      034 — the recorder nobody enumerated. Why CosmicFest has no session log: the unit
      was installed 2026-07-29, never enabled, and absent from the authored inventory,
      so nothing could assert it. Paid in three places, with a gate proved capable of
      failing.
      PLN (Algolia) authored