1. 28 Jun, 2026 1 commit
    • docs(douanier): capture the two erable deploy gotchas + go-live (#42) · fbe1742d
      First real deploy of douanier:latest to erable went green, but only after
      diagnosing two host-specific traps that DEPLOY.md now records so the next
      deploy is one shot:
      
      1. clone3 vs old seccomp — the container booted uvicorn then segfaulted
         (exit 139) / aborted with "OpenBLAS blas_thread_init: pthread_create failed
         … Operation not permitted". Root cause: Docker 19.03 on kernel 4.9's default
         seccomp profile rejects the clone3 syscall that python:3.12-slim's glibc 2.36
         uses for pthread_create. Fix: run with --security-opt seccomp=unconfined
         (safe — the container is loopback-only behind the gateway).
      2. BLAS thread pool on a small shared box — pinned OPENBLAS/OMP/NUMEXPR/MKL
         _NUM_THREADS=1 in the env file: belt-and-braces with the seccomp fix on the
         old kernel and right-sized for CPU-light work on 4 vCPU / ~2 GB.
      
      Also: data volume is /home/pln/srv/douanier/data (no sudo for /srv; it's pure
      transient cache so the path is immaterial). Verified end-to-end through the
      gateway: healthz/openapi/docs all 200, authed routes 401 without a token.
      PLN (Algolia) authored
  2. 25 Jun, 2026 18 commits
    • feat(douanier): make it callable — OpenAPI snapshot + zero-dep TS client (#32) · 11c8f9d6
      hexa can now call the audio API with types, not guesswork.
      
      - clients/openapi.json — checked-in OpenAPI 3.1 snapshot (servers pinned to the
        public https://api.nech.pl/audio/v1), covering all 13 routes. A snapshot-drift
        guard test asserts it stays in sync with the live app (add a route → refresh or CI fails).
      - clients/douanier.ts — a typed, ZERO-dependency client (global fetch/FormData/Blob;
        works in Node 18+, Vercel Functions, Edge). One method per engine (emotion,
        features, samples, grade, onsets, waveform, analyze, loudness, spectrum, naming),
        typed results, X-Douanier-Cache surfaced as result._cache, DouanierError on non-2xx.
        Typechecks clean under tsc --strict.
      - clients/README.md — base URL + bearer, a Vercel Function example, the endpoint/
        scope table, curl, and the openapi-generator one-liner for full codegen.
      
      60/60 tests (added the snapshot guard). The API is now self-describing
      (/audio/v1/docs + openapi.json) and has a drop-in client.
      PLN (Algolia) authored
    • feat(douanier): /loudness, /spectrum (FFT-as-a-service), /naming (#41) · f11bef94
      Three more torch-free, cached building blocks → 11 engines total.
      
      - POST /loudness → BS.1770 integrated LUFS (pyloudnorm) + sample/true-peak (4×
        oversample) + crest + the gain to hit each delivery target (-14 streaming, -9
        club, reference_postprod_master). The mastering numbers PLN + hexa gate on.
        engines/loudness.py; pyloudnorm added to requirements-deploy (pure-python, tiny).
      - POST /spectrum → FFT-AS-A-SERVICE (PLN's ask): a downsampled, render-ready
        spectrogram bands×frames, 0..1 normalized; mel (perceptual, for visuals) or
        log-linear; frames=1 collapses to a single averaged FFT spectrum. Bounded
        payload so it caches cheaply. signal.spectrum().
      - POST /naming → convention-compliant sample name (NN_role_character) from the
        MEASURED role + character, never the file name. naming.py vendored from the
        Foundry (drift-guarded); character_of() derives the adjective from features.
      
      Validated in the torch-free container (LUFS -19.1 w/ gain +5.1/+10.1; mel band
      centers; "07_melodic_warm" lint-clean). 58/58 tests (test_extra.py: engines +
      endpoints + naming drift guard + character_of). Image now serves emotion/features/
      samples/grade/onsets/waveform/analyze/loudness/spectrum/naming + separate-503.
      PLN (Algolia) authored
    • feat(douanier): low-hanging endpoints — /onsets, /waveform, /analyze (#40) · 8c2be606
      Three cheap, torch-free, cached building blocks — the no-model tier, plus a
      convenience composite. The image now serves 8 engines (emotion/features/samples/
      grade/onsets/waveform/analyze + separate-503).
      
      - POST /onsets   → onset hit times (s) + tempo + onset rate. Rhythmic hits for
        visual sync / slicing. scope `onsets`.
      - POST /waveform → render-ready waveform: per-bin [min,max] in [-1,1] + a 0..1
        RMS energy envelope (?bins= ≤4000). For hexa's audio-reactive visuals. scope `waveform`.
      - POST /analyze  → emotion + features + sample role in ONE cached call (fewer
        round-trips for hexa); each is the same engine the dedicated routes use. scope `analyze`.
      
      engines/signal.py is self-contained librosa (onsets uses feature.tempo, the
      0.11-correct path). Validated in the torch-free container (/onsets tempo 107.7
      n=41; /waveform bins honored, peaks in range; /analyze returns all three blocks).
      50/50 tests (added test_signal.py: engine + endpoint + cache + scope-gate + torch-free).
      PLN (Algolia) authored
    • feat(douanier): /grade endpoint — the Foundry loop grader as a building block (#39) · cd3caa7c
      Exposes the Foundry's mechanical loop-quality grader (the "katana") on
      /audio/v1/grade: upload a loop/one-shot → composite 0..1 + S/A/B/C/D tier,
      per-rule sub-scores (seam click, zero-crossing cleanliness, DC, bar
      self-consistency, level, bass mono-compat) + human-readable flags. scope `grade`,
      cached, torch-free. Directly serves "iterate on sampling quality".
      
      engines/grade.py is a BYTE-FOR-BYTE vendored copy of tools/foundry/engine/grade.py
      (self-contained — numpy/soundfile/pydantic/librosa, no Foundry/torch — because the
      container holds only armada/api/). It's kept identical on purpose, and
      tests/test_grade_endpoint.py is a DRIFT GUARD: it imports the Foundry canonical
      standalone and asserts the vendored copy grades identically (grade/tier/sub +
      WEIGHTS/THRESH) on a synth signal — a future Foundry tweak that isn't re-vendored
      fails CI here, not silently in prod (parsers-over-copy, applied to a vendored copy).
      
      Validated in the torch-free container (healthz engines now emotion/features/
      samples/grade/separate; a sine tone grades D with a correct seam-click flag) +
      43/43 tests. Note: this is the CPU-doable slice of #31's /loops /grade /correlate
      — the finder (/loops) and corpus correlation (/correlate) remain.
      PLN (Algolia) authored
    • feat(douanier): modular ears building blocks — /features + /analyze/samples (#38) · 9eb26236
      Two new CPU-native, torch-free, cached endpoints on the audio sub-API — the
      "interesting value points" for hexa beyond emotion, each the same shape/pattern
      as /analyze/emotion (upload → content-address → cache → compute).
      
      - POST /features  → the ~35-dim audio feature stack (spectral moments, MFCCs,
        chroma/key, envelope/attack-decay, + rhythm/tempo). scope `features`.
      - POST /analyze/samples → per-sample EDA + role (percs|bass|melodic|tops|atmos)
        decided by the MEASURED spectrum (centroid + band energy), never the name; an
        optional ?name= only disambiguates breaks/drums (feedback_mastering_eda). scope `samples`.
      
      Engine: engines/feats.py is SELF-CONTAINED (vendored DSP), like ears_light —
      the deployed container holds only armada/api/, not armada/tide-table/, so it can
      NOT import sample_features/audio_lens at runtime. It mirrors their algorithms and
      fixes the librosa-0.11 tempo bug (feature.tempo, not the removed
      feature.rhythm.tempo that silently dropped tempo in the tide-table original).
      
      Wiring: a shared _cached() helper now backs all three analyze routes (DRY);
      healthz advertises the engines map; both routes are scope-gated via the gateway
      X-Scopes. Validated in the torch-free container: /features 200 (41 features,
      tempo 107.7, key=Amaj), /analyze/samples role-by-measurement, 403 scope gate,
      X-Douanier-Cache hit on repeat. 40/40 tests (added test_feats.py + endpoint cases).
      PLN (Algolia) authored
    • feat(douanier): CPU deploy image + conform to the nech.pl platform contract (#37) · 722daded
      The SRE edge is live (api.nech.pl returns 503 warming_up); the only thing
      blocking a green healthz was a CPU-deployable image. This ships it, and folds
      in the SRE's platform reframe that landed in the same letter.
      
      Baseline image
      - Dockerfile: python:3.12-slim + ffmpeg/libsndfile, the LIGHT torch-free stack
        only (requirements-deploy.txt: fastapi/uvicorn/librosa/numpy/soundfile). Builds
        to ~1 GB, runs well under the 2 GB-RAM erable budget. Default engine = light.
      - Validated in-container end to end: healthz green, emotion miss->hit cache,
        401/403/200 auth gating, /metrics, /separate 503.
      
      Platform contract (SRE update: api.nech.pl is a multi-API gateway; we're the
      `audio` sub-API)
      - Public path is /audio/v1/...; the gateway strips the prefix and proxies to us
        at root. Routes moved off the /v1 router to root; root_path=/audio/v1 so
        OpenAPI/docs advertise the real public paths (verified servers=[{/audio/v1}]).
      - Central auth: dropped our own bearer verification in the request path. We now
        trust the gateway-injected X-Tenant / X-Scopes (loopback-only bind = only the
        gateway can reach us). Local-dev keeps a DOUANIER_DEV_TOKEN bearer fallback.
        Supersedes #23/#24 (the SQLite token store + CLI remain for dev only).
      - /metrics: Prometheus text (douanier_up, requests_total{path,status},
        cache_rows/hits{kind}) for the erable scraper.
      - /separate: deliberate 503 compute_unavailable (retriable) — no GPU path on
        erable; route exists so hexa can code against it now. GPU backend is env-selected later.
      
      Docs + tests
      - DEPLOY.md rewritten as the erable container contract (build, /data volume,
        env-file, loopback publish, cache cap, central-auth onboarding). README reframed
        to the platform shape. 32/32 tests pass (smoke retargeted to root paths +
        header auth; added /separate, /metrics, openapi-root-path coverage).
      PLN (Algolia) authored
    • docs(tasks): archive the Douanier birth + Foundry demos (2026-06-25 session) · 09dc0bbe
      Rich archive entries (blog/video source material) for the day's shipped work:
      the Douanier Audio-Intelligence API's first endpoints (#22/#36 scaffold+emotion
      walking skeleton, #23 SQLite auth+CLI, #26 content-addressed cache with the
      measured 5663x repeat speedup) and the Foundry sampling-classics demo sources.
      Each entry stands alone for a cold reader — goal, what shipped (commit hashes),
      non-obvious learnings + numbers.
      PLN (Algolia) authored
    • feat(foundry): auto-tune harness — search finder settings vs the grade rubric (#19) · 6f850128
      PLN's closed feedback loop: generate candidate loops with a settings profile →
      grade them on the full quality rubric → score the profile → search for the
      settings that maximise quality. Auto-tune the composite weights instead of
      hand-guessing them.
      
      WHY: the finder/grader/correlator exist; the composite weights W were "provisional,
      calibrate with #7". This builds the machine that calibrates them against measured
      grade — and measures the lift honestly rather than asserting a tuning is better.
      
      WHAT:
      - engine/autotune.py — evaluate(stems, settings) runs the finder with a profile,
        grades every produced candidate on the FULL rubric (grade.py adds dc / level /
        bass-mono / bar-consistency — features the finder's own score does NOT use, so
        tuning pulls in signal beyond seam/zc), aggregates objective = mean_grade ×
        coverage. search() sweeps random|grid and always includes the current defaults
        (loops.W) as a baseline row so the report shows lift, not just a number.
      - autotune.py — CLI (--stems glob --n --method --max-stems); PII-safe aggregated
        leaderboard (counts, never filenames).
      - engine/loops.py — analyze_stem now accepts an optional `weights` override
        (defaults unchanged); the grid is preloaded once per stem (weight-independent)
        so the sweep is cheap — a scarcity-minded speedup.
      
      VALIDATION:
      - 4/4 mocked harness unit tests (aggregation, empty-set, ranked+baseline-included,
        weights threaded through to the finder).
      - Real end-to-end run, 2 drums stems × 8 configs: baseline objective 0.690 → best
        0.763 (+10.5%); the defaults ranked LAST of 8 — the search found real lift.
      
      NOT DONE ON PURPOSE: the finder defaults (loops.W) are UNCHANGED. The winning
      profile (seam 0.4, zc 0.05, bars=(4,8)) is from a 2-stem sample AND seam/zc are in
      both the finder score and the grade (a confound) — so it's suggestive, not a
      mandate. Remaining for #19: recall-vs-provenance-GT as an anti-gaming 2nd
      objective, per-stem-role tuning (vocals=chops want different weights than drums),
      a full-corpus + demo-corpus campaign, an extract-once/score-many speedup, THEN
      adopt a validated profile as the default.
      PLN (Algolia) authored
    • feat(douanier): torch-free light V/A emotion engine + engine selector (#37 rapid slice) · 137fa8d6
      The SRE reply (armada/api/SRE.md) revealed the public host is erable — Debian 9,
      no GPU, ~2 GB RAM, ~3 GB disk free — where the ~4 GB CLAP/torch image can't fit.
      This unblocks deployment in principle: a CPU-native emotion path that needs no
      torch, behind a selector so the rich CLAP engine stays the dev/GPU default.
      
      WHY: the SRE is "blocking on a CPU-deployable image". The emotion read must work
      within ~2 GB RAM and sub-second, without torch — but the API response shape must
      not change, so hexa's integration and the cache are unaffected by the swap.
      
      WHAT:
      - engines/ears_light.py — heuristic valence/arousal from librosa features only
        (no torch): arousal from RMS energy + tempo + spectral brightness; valence from
        major/minor mode (Krumhansl key-profile correlation) + brightness. Mapped onto
        the SAME 12 emotion-ontology anchors by V/A distance → a CLAP-shaped {valence,
        arousal, top, dist, confidence} dict, tagged engine="light". Honest baseline;
        an Essentia/CLAP-grade precise tier lands later behind the same shape.
      - config.DOUANIER_EMOTION_ENGINE (clap|light); ears.emotion_read() dispatches;
        cache keys on the engine so clap/light reads don't collide; healthz reports the
        selected engine + availability.
      
      VALIDATION:
      - 27/27 tests green (+5): anchors drift-guarded == emotion_ontology.EMOTIONS;
        mode-valence major>minor; light read shape + V/A in range + dist sums to 1;
        arousal orders loud/bright/noisy above quiet/low; and the endpoint runs
        end-to-end via TestClient with engine=light — the torch-free erable path proven.
      
      REMAINING in #37 (queued, not rapid): Dockerfile (light image, no torch, fits
      ~3 GB / 2 GB RAM, bind 127.0.0.1:9780, --env-file); Essentia MusiCNN upgrade for
      the light tier; separation /v1/separate → 503 + env-selected GPU-runner dispatch
      seam (with #29/#30); /v1/metrics; 2 GB cache hard-cap. See SRE.md + memory
      project_douanier_api 'HOSTING REALITY'.
      PLN (Algolia) authored
    • feat(foundry): sampling-classics demo sources for loop-quality iteration · fd1fae82
      Add a curated list of 10 sampling classics as built-in demo sources — fuel for
      demoing the Foundry, precomputing a fixed validation corpus, and iterating on
      loop/chop quality (#19 autotune input).
      
      WHY: we need a stable, pedagogically-diverse set to validate the finder across
      material types instead of ad-hoc URLs. The set spans the axes that stress the
      finder differently: canonical drum BREAKS (Amen, Funky Drummer, Apache — the
      gold standards; if the rubric can't nail these it's wrong), a BASS-defining
      groove (Chic – Good Times), VOCAL/no-drums (Loituma), ORCHESTRAL/no-drums edge
      case (Mozart 40 — exercises the no-drums grid fallback), a full clean POP mix
      (Rickroll), hip-hop sample-collage (Grandmaster Flash Wheels of Steel, Humpty
      Dance), and a drum+vocal-stab combo (Lyn Collins – Think).
      
      WHAT:
      - demos.json — authored data (slug/title/year/source/why/expect/tags). Each
        entry flags drums-presence because the finder's grid-from-drums path depends
        on it (no-drums → bass/first-stem fallback, per the recall notes).
      - engine/demos.py — loader (load / by_slug / source_of).
      - foundry.py "demos" command — list, or precompute via --catch <slug> / --all
        (+ --sep to separate). This batch path is exactly what #19 autotune consumes.
      - server.py — GET /api/demos; /api/fetch now accepts "ytsearch1:" queries too.
      - ui/index.html — a 'sampling classics' quick-pick strip (click → fills the URL,
        tooltip shows why/expect).
      
      HONESTY ON LINKS: only Loituma (from the repo's own test fixture) and Rickroll
      (universally known id) ship as verified watch URLs (✓). The rest use
      "ytsearch1:" queries (≈) that resolve to the top hit at fetch time — so we never
      ship a guessed video id that silently breaks. yt-dlp accepts both forms.
      
      VALIDATION: foundry demos lists 10 (✓/≈ marked); /api/demos serves the JSON;
      UI strip renders; loader resolves source_of('amen-break').
      PLN (Algolia) authored
    • feat(douanier): content-addressed cache — the margin lever (#26) · 394bec04
      Identical audio is analyzed ONCE and served forever. This is the economic core
      that makes cheap-analysis-at-scale viable, and it makes the live emotion
      endpoint feel instant on repeats.
      
      WHY: per the design, repeat calls must cost ~nothing — that's the margin story
      vs cloud egress. A re-analyzed track shouldn't pay the CLAP compute twice.
      
      WHAT:
      - cache.py — content_id = sha256 of DECODED PCM (+ samplerate) so re-encodes /
        re-uploads of the same sound dedupe; raw-bytes fallback for formats we can't
        decode here. params_hash folds engine settings (model, bars…) so different
        params cache separately. get/put (JSON inline or a result_ref path for big
        artifacts), yt-id/url → content_id aliases (#29 will use them), and an
        LRU/size-cap gc() that unlinks evicted artifact files (cache is transient on
        the freebox — rebuildable).
      - db.py — cache + aliases tables (WAL already on), LRU index on (kind, last_access).
      - app.py — /v1/analyze/emotion now content-addresses the upload, serves cached
        reads with X-Douanier-Cache: hit, computes+stores on miss. Engine call still
        behind engines.ears (unchanged contract).
      - douanier.py — cache stats / gc admin commands.
      
      VALIDATION:
      - 22/22 tests green (+7 cache: stable/decode-invariant content id, raw fallback,
        miss→put→hit, params separate entries, hit counter, alias resolve, LRU gc
        spares the recently-touched entry).
      - Real-CLAP end-to-end: same clip twice → call 1 miss 10.6s, call 2 hit 0.002s
        = 5663x speedup, identical V/A. The headline product benefit, measured.
      PLN (Algolia) authored
    • feat(douanier): SQLite bearer-token auth + scopes + douanier CLI (#23) · fe9ca02e
      Replace the walking skeleton's hardcoded dev-token stub with a real,
      self-hosted token store — the customs gate's papers check — keeping the dev
      token as a documented local-only bootstrap.
      
      WHY: hexa needs a proper 31-day scoped token (#33), and every future engine
      endpoint needs per-engine authorization. Self-hosted SQLite (no vendor, same DB
      that'll hold jobs/cache/usage) matches the 'self-host all' decision.
      
      WHAT:
      - db.py — stdlib sqlite3, WAL mode so the API + worker daemon read/write
        concurrently. Tables: accounts, tokens (token_hash, prefix, scopes,
        quota_json, expires_at, revoked). The DB is the one thing worth backing up;
        the artifact cache is rebuildable (noted in SRE.md).
      - auth.py — mint() returns the plaintext ONCE and stores only sha256(token), so
        a DB leak isn't replayable. verify() is an indexed hash lookup → a Principal
        (account, scopes, expiry); checks revoked + expiry. has_scope() honors the '*'
        wildcard. Scope-gated dependency require_scope(scope) in app.py: 401
        unknown/expired/revoked, 403 scoped-out; scope=None = any valid token.
      - douanier.py — admin CLI (token mint/list/revoke, db init); the only way tokens
        are created (no public signup). list masks to a 6-char prefix.
      - /v1/me echoes the caller's identity (onboarding smoke test); /v1/analyze/emotion
        now requires the 'emotion' scope.
      
      VALIDATION:
      - 15/15 tests green (8 auth: mint→verify, wildcard, unknown→None, revoke,
        expiry, never-expires, only-hash-stored, dev-bootstrap; 7 smoke incl. /me
        guarded). No CLAP/torch needed; DB points at a temp file via conftest.
      - CLI smoke end-to-end: mint prints secret once + masks in list, expiry lands
        exactly 31 days out (2026-07-26), revoke flips state to REVOKED.
      PLN (Algolia) authored
    • feat(douanier): scaffold the Audio-Intelligence API + emotion walking skeleton (#22, #36) · 7f558a76
      Stand up Douanier — the ParVagues Audio-Intelligence API — as a new self-hosted
      service in armada/api/, and prove the lean MVP vertical end-to-end before
      investing in the auth/quota/jobs/cache machinery it'll later sit on.
      
      WHY: hexa (Shipow's hydra-live-hexa Studio) wants to call our engines (stems,
      loops, emotion, samples, features) live from his Vercel world. Rather than build
      six foundational tasks before the first useful call, we ship a walking skeleton:
      scaffold + ONE real synchronous endpoint + a stub token, to de-risk the three
      seams that actually matter — engines import cleanly, the service runs, a client
      can auth & call.
      
      WHAT:
      - FastAPI app (app.py) versioned at /v1: /v1/healthz (status + engine
        availability) and POST /v1/analyze/emotion (upload clip -> valence/arousal +
        top emotions), guarded by a hardcoded dev bearer that FAILS CLOSED (503 if no
        token configured, never accidentally unauthenticated).
      - engines/ adapter layer with lazy heavy imports — ears.emotion_of() wraps the
        real tide-table seam: sample_semantics.embed_audios -> emotion_ontology.score.
        The route never touches the engine directly, so #28 can wrap it with
        cache+async later without changing the public shape.
      - config.py (env-overridable paths/token/limits); requirements.txt documents the
        --system-site-packages venv trick (reuse the box's multi-GB torch/CLAP/demucs
        stack, add only FastAPI on top — Arch PEP-668 blocks system pip).
      - SRE.md: a context letter to whoever maps the public api.nech.pl path — the 10
        hosting decisions that are theirs (path vs host routing, systemd vs Docker,
        ports, TLS, GPU dispatch, freebox mount, secrets, observability, WAF, CORS).
      - README.md + smoke tests.
      
      VALIDATION:
      - 5/5 smoke tests green (healthz; auth fails-closed/opens; happy-path mocked;
        413 oversize) with no CLAP/torch needed.
      - Real engine seam confirmed importable, and the FULL pipeline run end-to-end on
        a synthetic clip: real CLAP read, valence 0.154 / arousal 0.141, V/A in range,
        34.4s cold (model load — which is precisely why #28 warms CLAP once in the
        worker rather than per request).
      PLN (Algolia) authored
    • docs(tasks): archive the Foundry phase-2 loops chain (overnight 2026-06-23) · d4c0ae6d
      Structured archive of the night's 11 completed tasks (#5-#13,#16-#18 + audio fix) —
      grader/tierlist/correlation/finder/naming/merge, with the load-bearing learnings and
      measured numbers (ρ=0.34, recall 0.27→0.34, the 56min→coarse-to-fine + silent-window
      ∞ + PLP-octave + HTTP/1.0-audio gotchas). Blog/video source material; the board now
      shows only live work (#19 auto-tune, #20 batch-explore).
      PLN (Algolia) authored
    • feat(foundry): timeline candidate navigation + usable chop floor (#18 follow-up) · 5ac5cd0f
      Two PLN UX fixes from auditioning the loops panel:
      
      1. Chops were way too short (0.06 s slices = clicks, not cuts). analyze_chops now
         floors at min_len_s=0.25 and extends to onset→skip-two for phrase-length options;
         vocal chops now land 0.29–1.07 s (verified) — actual word/phrase chops.
      
      2. A 10+ vertical list was impractical to navigate. Replaced it with a TIMELINE
         overview (the research §6 design): every candidate is placed on the track by its
         start/length, coloured by lead stem, height + opacity by score — so you SEE where
         the candidates are at a glance and click one to focus. Below sits a single detail
         panel (stems, waveforms, audition, keep-toggles, Forge) for the selected candidate,
         plus prev/next stepping and a "candidate N / M" counter. Scannable map + one
         focused editor instead of a scroll-wall.
      
      Verified in Chromium: 8 segments render, click-to-focus (candidate 1→4), prev/next,
      no console errors; stem durations now display (the HTTP/1.1 + preload audio fix shows
      2:43). Works for loops and chops modes (header reads "N-bar" or "chop · Ns").
      PLN (Algolia) authored
    • feat(foundry): merge-of-stems multimodel — Roformer vocals + demucs rest (#9) · 14e69fe5
      PLN's "merge of stems": the best vocal isolation cascaded into the best 4-way split.
      There is no 4-stem Roformer, so MultiModelBackend orchestrates a cascade —
        1. Roformer (audio-separator, py3.12 venv) isolates vocals + instrumental;
        2. demucs runs on the INSTRUMENTAL (vocals already gone → cleaner) for drums/bass/other;
        3. merge = Roformer vocals ⊕ demucs drums/bass/other in the standard 4-stem contract.
      
      Refactored separate.py for composite backends: a backend declares what it `produces`
      and (for composites) a `run_composite` hook; the subprocess runner is factored into
      _run_cmd so the cascade can drive several sub-separations. RoformerBackend is now wired
      (was a stub): audio-separator CLI, 2-stem (vocals/instrumental), normalize() globs the
      "(Vocals)"/"(Instrumental)" output names. The default normalizer maps by produces[].
      
      Verified end to end on the Loituma source: merge → vocals (Roformer) + drums/bass/other
      (demucs-on-instrumental), 4 clean PCM_16/44.1k stems. All three backends report
      available on this box. GUI renders whatever stems a backend emits (demucs/merge 4,
      roformer 2 incl. instrumental), canonical roles first. 53 tests (added: registry has the
      three engines, roformer produces 2 + builds its cmd, merge is composite/4-stem).
      
      Keeps the 2-stem Roformer door open as its own backend (vocal isolation), and the merge
      as the flagship. Foundry's engine registry now showcases the pluggable design PLN wants
      to grow into a product/API/service.
      PLN (Algolia) authored
    • fix(foundry,armada): sound audio streaming — HTTP/1.1, show duration, offer the original · 7c3154a4
      PLN couldn't hear the Loituma stems and wanted the original too. The bytes/format
      were fine (PCM_16/44.1k, audio/x-wav, 206 Range all verified) — the problems were
      presentation + protocol:
      
      - Both audio servers defaulted to HTTP/1.0, which closes the connection after each
        response. <audio> seeking fires many small Range requests, so HTTP/1.0 makes
        playback/seek flaky in real browsers. Set protocol_version="HTTP/1.1" on the
        Foundry Handler and armada RangeHandler (every response sets an exact
        Content-Length, so keep-alive is safe). Reliable streaming + seeking now.
      - The stem players used preload="none", so they showed a misleading "0:00 / 0:00"
        until play — easy to read as "broken". Now preload="metadata": durations load and
        display (verified 164s on all five).
      - The ORIGINAL is now offered on separated cards too (an "original" row), not just on
        raw catches — so PLN can A/B the source against each stem.
      
      Verified end to end in Chromium: original + all 4 stems load 164s duration and
      currentTime advances on play, no errors.
      PLN (Algolia) authored
  3. 23 Jun, 2026 20 commits
    • fix(armada): propagate Aria→Mafia Sans Serif through authored refs + retag masters (#22, #29) · e399a28f
      Follows 720e14d0 (canonical roots + catalog regen). Sweeps the rename through the
      authored layer that the catalog generators don't own, and corrects the release
      artifacts:
      
      Authored text (hand-fixed): backlog.md, release_priority.md, manifeste/calendar.md
      (setlist mentions); master_edl_take89.json (boundary name + bleed reason — Take89
      is the Montreuil set); boundaries_take89_validated.json (ear-validated cut-6
      label). Regenerated from those: boundary_bleed_take89.json. Found+fixed a
      hardcoded SETLIST in punkachien/build_stemmap_html.py (the name was baked into the
      generator, not read from data) and regenerated stemmap.html.
      
      Release masters (#29 + #22): retagged all 15 FLACs in
      Prod/Montreuil26_master/tracks_bandcamp/ — album → "Montreuil Algorave V3 — Mai
      Floral" (the CANONICAL tracks.json title; the old tag said "…V3 — Live@Les
      Nouveaux Sauvages", a venue string not the set title). Track 08 title →
      "Mafia Sans Serif" and file renamed 08-Aria_Sans_Serif.flac →
      08-Mafia_Sans_Serif.flac. (Prod is plain files, not git — FLACs are the artifact.)
      
      Deliberately NOT touched: sources/soundcloud.jsonl is an EXTERNAL MIRROR of the
      published SC upload (which itself still reads "Aria Sans Serif" + "Montreuil
      Algorave III") — editing it would desync it from reality; the real fix is
      publish-side (tracked as a follow-up). eda_report.json carries the name as a
      cosmetic label and is freebox-EDA-gated — it refreshes on the next EDA pass, not
      worth an expensive regen now. The hexa public-audio copy of the FLACs is a
      downstream sync (Shipow's repo) — flagged, not edited here.
      PLN (Algolia) authored
    • fix(tide-table): repair stale next/ catalog path + rename Aria→Mafia Sans Serif (#22) · 720e14d0
      Two findings while fixing the #22 track-name typo, both surfaced by tracing
      where the name actually lives rather than sed-ing the blast radius:
      
      PARSER BUG — the catalog generators were reading a dead path. Commit 6e51fff
      hoisted the site app out of next/ to the repo root, but five tide-table modules
      still pointed LIVES at /Work/Web/www/next/content/lives — which is now EMPTY.
      The committed catalog/map JSONs were stale pre-hoist artifacts; any regen would
      have silently produced an empty catalog. Fixed the path in
      build_track_recording_map, build_catalog_view, pattern_ngrams, tide_eda,
      boundary_bleed (+ 4 docs/README/conftest that documented the dead path). This is
      exactly the "a parser miss must never masquerade as data" footgun — the path was
      wrong at the source while the output looked plausible.
      
      RENAME — the track was renamed Aria→Mafia Sans Serif long ago in the score, but
      the canonical site tracks.json still carried name "Aria Sans Serif" AND a
      dangling file ref (aria_sans_serif.tidal, which no longer exists; the real score
      is mafia_sans_serif.tidal). Fixed at the canonical root (tracks.json, committed
      separately in the www repo) and REGENERATED the catalog via `tide.py build` —
      not hand-edited — so catalog.generated / catalog_view / pattern_registry /
      track_recording_map all pick up the rename from source.
      
      Validation: regen diff is clean — only the Mafia rename plus one legitimate
      side-effect (the catalog re-read the *current* mafia_sans_serif.tidal source,
      which had resetCycles commented out since the last build; the old cached source
      was from the pre-rename file). No other track's data moved (verified by grepping
      the diff for non-Mafia id/title/track changes — none). Neighbor-list reordering
      in pattern_registry is benign. Test suite: 80 pass, 1 fail — the failure
      (38c3-toilet recovers 0 backlog tracks) is PRE-EXISTING (reproduced with these
      changes stashed), a real data gap belonging to #27/#66, not this change.
      
      Interim n=172 emotion calibration JSON left uncommitted (regenerates at 534).
      PLN (Algolia) authored
    • feat(foundry): finder v2 pt.2 — onset-driven chop mode (#18) · 0a2921a0
      Adds a sub-bar "chops" mode alongside bar-aligned "loops": analyze_chops segments a
      stem by onset (librosa.onset_detect + backtrack) and offers onset→onset / onset→skip-one
      slices (0.05–2.0 s), scored on the seam/zc/level rules a short chop still must honour
      (bars=0 marks a chop). find_takes(mode=) selects it; /api/loops takes mode; the GUI gains
      a loops/chops dropdown per catch (header + waveform render chop durations). 9 finder tests.
      
      HONEST measurement (not a recall win): chop-mode recall on the GT is 0.13 vs the
      bar-loop 0.34 — WORSE. Two real reasons, both worth recording:
      - PLN selects a few chops by TASTE from many onset candidates, so "did top-N contain his
        exact picks" is a harsh yardstick for a palette-browsing task; precision (0.15) confirms
        most onset slices aren't ones he'd keep. recall is arguably the wrong metric for chops.
      - naive onset windows over-segment (clean seam on any short slice ≠ a musically useful
        chop). A better chop ranker would need transient saliency + pitch/word-boundary cues.
      So LOOPS stays the default; CHOPS is an opt-in capability for when PLN is word-chopping a
      vocal — it produces clean, auditioning-ready sub-bar slices (verified end to end in the
      GUI: 0.06–0.51 s slices, waveforms, Forge), it just doesn't predict his selections. The
      recall gap on chop-heavy sources is a genuine research problem, parked on #18 with this
      finding rather than papered over. finder_eval_chops.json carries the numbers.
      PLN (Algolia) authored
    • data(tide-table): finder v2 pt.1 measured — recall 0.27→0.34 from grid-from-drums (#18) · cdf2f132
      Re-ran build_finder_eval with the shared drums-derived beat grid. Recall 0.27→0.34
      (+26% rel), precision 0.24→0.31 (+29% rel) over the 8 most-cut stems. The lift is
      concentrated exactly where predicted — vocal stems: Xxplosive/vocals 0.27→0.50,
      Doors/other 0.27→0.37. Confirms the §7 diagnosis (weak per-stem PLP grids on
      sparse-onset stems). Still below the 0.70 target; the remaining gap is sub-bar
      chops the bar-aligned finder can't form — #18 pt.2 (onset-driven chop mode).
      PLN (Algolia) authored
    • feat(foundry): finder v2 pt.1 — one beat grid from the drums, shared across stems (#18) · de0cb113
      Directly addresses the #7 finding: per-stem PLP grids are weak on sparse-onset stems
      (Doors/vocals recall 0.06 vs Gil-Scott-Heron 0.56), because PLP needs onsets to lock a
      pulse and a vocal stem barely has them. Fix: find_takes now computes ONE beat grid from
      the most rhythmic stem (drums → bass → first available) and reuses it for every stem, so
      vocals/other are cut on the same musical bar lines as the drums instead of each guessing
      its own. analyze_stem gained an optional `grid=(peaks,times,bpm)` param (None ⇒ per-stem,
      the CLI case); find_takes and build_finder_eval both pass the shared grid.
      
      Verified on Loituma: "beat grid from drums" → all four stems analysed on it, including a
      cross-stem bass+vocals take at 137s. 8 finder tests (added: analyze_stem honours a shared
      grid and adopts its tempo). build_finder_eval re-run with per-track drums grid is in
      flight to quantify the recall lift over the 0.27 baseline; the correctness win (shared
      grid ≫ weak per-stem grid on sparse stems) holds regardless. Pt.2 (sub-bar chop mode)
      remains on #18.
      PLN (Algolia) authored
    • feat(tide-table): finder recall harness + honest result (#7) — 0.27, below target · 09bace6a
      build_finder_eval.py measures whether the loop finder surfaces the windows PLN
      actually cut, using the correlation engine's provenance map (#12) as ground truth
      at scale. Metric v2: dedup GT into distinct sampled windows (PLN cuts many
      overlapping loops per stem), give the finder N≥#distinct candidates so recall isn't
      candidate-capped, and also report precision.
      
      Result on the 8 most-cut stems: recall 0.27 (45/169 distinct windows), precision
      0.24 — BELOW the 0.70 v1 target. Honest and diagnostic, not a pass:
      - The eval picked the hardest stems by design (ranked by #cuts) — CHOP-HEAVY sources
        (14–42 distinct cuts each: Doors, Xxplosive, MC Fioti, Brubeck). PLN chops them into
        short, often sub-bar pieces; the finder targets 2/4/8-bar LOOPS, so it can't
        reproduce sub-bar chops.
      - Vocal stems worst (Doors/vocals 0.06) vs rhythmic best (Gil Scott-Heron 0.56): the
        per-stem PLP beat grid is weak on sparse-onset vocals → poor candidate windows.
      - Loop choice is taste-driven anyway (house rule: finder suggests, human picks).
      
      Next steps surfaced (new task): (1) borrow the beat grid from drums/full-mix for all
      stems; (2) add a sub-bar onset-driven "chop mode". The harness is the katana for both.
      PLN (Algolia) authored
    • feat(foundry): expose demucs model variants — instant 4-stem A/B (#9, easy win) · 853eeaf7
      Groundwork toward PLN's "merge-of-stems multimodel": the finding that reshaped #9
      is that there is NO 4-stem Roformer — all Roformer models are 2-stem; the only
      4-stem (drums/bass/other/vocals) separators are the Demucs family. So the immediate,
      zero-risk stem-quality A/B is between demucs variants, which the DemucsBackend already
      supports via its `model` arg.
      
      Backends now declare a `models` list (demucs: htdemucs / htdemucs_ft / hdemucs_mmi /
      htdemucs_6s). /api/backends exposes it; the Foundry GUI gains a model dropdown beside
      the engine picker (hidden when a backend has ≤1 model), passed through to /api/separate.
      So PLN can now separate with htdemucs_ft (fine-tuned: vocals SDR 10.8 vs 9.9, bass 12.0
      vs 11.6) or htdemucs_6s (adds guitar+piano stems) and compare — no new backend.
      
      Verified: build_cmd respects the chosen model (-n htdemucs_ft), /api/backends returns
      the list, the selector renders all four. The full multimodel engine2 (Roformer-vocals
      + demucs-rest merged, the door PLN asked to keep open) stays on #9 with the design.
      PLN (Algolia) authored
    • feat(emotion): paired whole-mix-vs-stem comparator — the katana for #92 · e0635928
      The whole-mix-vs-stem decision (#92) was about to be called on a rigged
      comparison. `fit` grades whole-mix on every mix-analyzed track (163 so far,
      heading to 534) and `fit --stem` on every stem-analyzed track (24) — different
      SETS and different SIZES. The unpaired numbers (calibrated LOO 0.418 whole-mix
      vs 0.618 stem) screamed "whole-mix decisively wins," but most of that gap was
      sample size: 163 points fit a stable affine (in-sample→LOO gap 0.005), 24 points
      overfit it (gap 0.063). That's a sample-size artifact masquerading as a
      mode-quality verdict — exactly the kind of premature conclusion the build-the-
      katana-first discipline exists to catch.
      
      Approach: extract the raw/in-sample/LOO scoring into a shared `_evaluate()` so
      every mode is graded by identical code, then add `paired` — whole-mix vs stem
      head-to-head restricted to the slugs analyzed BOTH ways (the only honest
      comparison). Reports both modes' raw + calibrated-LOO V/A-err, quad-hit,
      emo-hit, plus an in-sample→LOO conditioning gap with an overfit flag.
      
      The surprise (n=26 paired, validated this run): on equal footing the verdict
      nearly inverts. whole-mix cal·LOO 0.569 / quad 54% / emo 23% vs stem 0.605 /
      58% / 27% — whole-mix wins distance by Δ0.037, but stem wins BOTH quadrant and
      emotion-hit, and both overfit (n=26 too small). The honest #92 answer is now
      "inconclusive until more stems exist," not "whole-mix decisively wins." Caveat:
      the paired subset is the hard cases (first-fetched = aggressive techno/dnb/
      dubstep, the high-arousal region drift + the ontology critique both flagged).
      
      Regression: `fit` output unchanged after the refactor. Interim calibration JSON
      (n=163 fit) left uncommitted — it regenerates at the full 534.
      PLN (Algolia) authored
    • feat(foundry): GUI loop panel — the Audacity-replacement picker (#6) · a94bf65a
      The Foundry's payoff surface: per separated catch, a "Find loops" button runs the
      finder (#5) and lays out its ranked Takes for the human to audition, prune, name,
      and forge into a kit — closing the url→stems→loops loop the whole tool was for.
      
      server.py: /api/loops (async job → find_takes → Takes JSON) and /api/export (runs
      export_take with the kept stems → Samples/<kit>/, then publish.link_kit). Stems
      already serve Range-capable for <audio>.
      
      ui/index.html: a Loops panel. Each Take shows score, bar-count, time window, bpm,
      tempo-unstable flag, and a row per stem with: a keep checkbox, role tag, a
      dep-free CANVAS waveform of that loop slice (decoded client-side via Web Audio,
      cached per stem, drawn in the role colour), a region-audition play button (loops
      [start,end] on the full stem via timeupdate), and the candidate score. A kit-name
      field (defaults to the slug) + Forge button writes the kept stems and auto-links.
      
      Verified end to end in Chromium (Playwright): Find loops → 8 Takes render, all 11
      stem-slice canvases paint (role-coloured waveforms — vocals pink, other cyan, drums
      faint where quiet), no console errors. Caveat: first waveform per stem waits on the
      full-stem decode (~1s each, 4 stems ~5s); subsequent takes from a cached stem draw
      instantly. A future win is byte-range slice fetch instead of full-stem decode.
      PLN (Algolia) authored
    • feat(foundry): correlation engine — recover loop provenance at scale (#12) · 7a79dc83
      engine/correlate.py recovers where each hand-cut loop was cut from: a loop is a
      literal slice of a separated stem, so normalized cross-correlation of the waveform
      has a razor peak at the true offset (true slice ~1.0, foreign ~0.03). build_provenance.py
      runs it over the whole corpus → provenance.json.
      
      Result (honest): 482/1690 loops matched (29%) across 28 kits — only the recent
      demucs-derived kits have a source in separated/; the rest predate the workflow, and
      we quantify that rather than pretend. Recoveries are exact and name-confirming:
      bumbum←MC Fioti "Bum Bum Tam Tam", diams_dj←Diam's "DJ", like_sugar←Chaka Khan
      "Like Sugar", praise←Fatboy Slim. Cross-stem alignment 0.34 — the measured answer to
      §0's open question (loops share a window across stems about a third of the time).
      
      Two bugs caught and fixed during the build, both load-bearing:
      - NAIVE all-pairs full-res NCC took 56 MIN on 1690×120. Fix: coarse-to-fine —
        rank every stem by NCC on a frame-RMS *energy envelope* (phase-robust, ~40× cheaper),
        then run the precise sample-domain NCC only on the top-3 candidates. Minutes, not an hour.
      - SILENT-WINDOW BLOWUP: catastrophic cancellation in the sliding-variance cumsum
        drove the normaliser to ~0 in quiet stem regions, so silence scored ∞ and
        masqueraded as a perfect match (an early run reported a bogus 55%/0.69 — ~440 false
        positives). Fix: floor the window norm at 5% of the stem's global energy and clamp
        NCC to [-1,1]. Scores are now all ≤1.0 (max 1.000, median 0.969); the honest 29%
        is what survived. Regression test added.
      
      forkserver gotcha: py3.14's default mp start method re-imports the module per worker
      (empty globals), so the preloaded-stems COW trick silently fails — pinned mp fork
      context. 8 correlate tests (NCC offset/foreign/gain-invariance/silent-window, envelope
      prefilter, locate top-k). Feeds the tierlist cut-critique and the finder recall eval (#7).
      PLN (Algolia) authored
    • feat(foundry): loop naming convention — engine/naming.py, derived from the corpus (#13) · 146070b5
      Recovered the ParVagues loop naming scheme empirically from 1690 existing loops,
      rather than inventing one: NN_<role>[_<character>]. The corpus EDA that grounds it
      (reproducible via `naming.analyze_corpus`, parsers-over-copy): 46% carry a 2-digit
      NN_ index (the hand-cut-kit signature, vs keyed/tempo packs like 120g/80c), 74%
      underscore-joined, only 16% repeat the kit name (the folder already carries it), and
      the role tokens cluster on voice/vocals, keys, guitar, bass, brass, synth.
      
      naming.py provides:
      - suggest_name(index, role, character=, section=) → NN_role[_section][_character],
        slugged + zero-padded + de-duped against names already taken in the kit.
      - stem_of(role) → maps a role/character word back to its stem family, so a name
        stays honest to what was actually separated (never infer role FROM the name —
        the finder knows the stem; the name reflects it).
      - lint(name) → flags drift (no index, spaces, uppercase, odd chars).
      - analyze_corpus() → re-derives the stats above from the live Samples tree.
      
      Wired as export_take's default namer (replaces the inline f"{i:02d}_{name}"), so the
      GUI Forge button and CLI export both land convention-compliant names; the GUI's
      per-stem name field still overrides. CLI: python3 -m engine.naming --suggest 3 synth dark
      | --lint <name> | --analyze. 6 tests; full foundry suite green.
      PLN (Algolia) authored
    • feat(bridge): live MIDI dashboard panel — SSE wired end to end (#16) · c0fd5d42
      Completes the parked MIDI feature (midimon.py + midistream.py were done/tested in
      270a01d6; this wires them into the always-on Bridge). The "do better than a raw
      aseqdump in a terminal" monitor now lives in the web dashboard.
      
      server.py: a module-global MidiStream fan-out (lazy — opens aseqdump only while a
      tab is watching, closes on the last unsubscribe). Two GET routes:
        /api/midi/ports  → list ALSA seq ports
        /api/midi/stream → Server-Sent Events of parsed events, ?port=addr to pick one.
      ThreadingHTTPServer gives each SSE connection its own thread, so blocking on the
      subscriber queue is fine; a ': ping' every 15 s holds the pipe open, and
      BrokenPipe/ConnectionReset on tab-close is swallowed and triggers unsubscribe.
      
      ui/index.html: a MIDI panel — port picker + connect/disconnect (EventSource) +
      rescan, and a live log. Rows are colour-coded by event (note-on green, note-off
      faint, control-change amber, pitch-bend magenta), show source/event/channel/
      note-name and a velocity bar (val/127), newest on top, capped at 200. Never hue
      alone: the event word carries the meaning, colour reinforces (DESIGN principle 4).
      
      Verified end to end: systemd --user restart, /api/midi/ports lists the seq ports,
      /api/midi/stream emits ': connected' then live events, and the UI connects (green
      "live") and renders rows — no console errors. With no hardware controller attached
      the feed shows system/subscription events; note/CC rows appear once a controller
      port is selected and played.
      PLN (Algolia) authored
    • feat(tide-table): self-updating corpus essay — template Story 02 from EDA + freshness badge (#17) · 24606d49
      PLN flagged the "2024, everything at once" stats as stale. Root cause: the chart
      BARS were data-driven but the PROSE around them was frozen string literals
      ("334 sample folders", "tripled to 14", "2 → 4 → 14 → 14 → 3-so-far", the section
      title, the chart tooltips, the magenta-highlight year). They'd drift the moment
      the corpus moved. Now every one of them derives from EDA at render:
      
      - breakoutFacts() reads vocabulary_growth + cadence → peak import month/count, the
        breakout year (argmax gigs), the year-over-year multiple, and the gig sequence.
      - Story 02's title, dek, legend, note, the magenta band, and both chart tooltips
        are templated from it. parsers-over-copy: no number is typed by hand.
      - Honesty fix surfaced by the templating: 4→14 gigs is 3.5×, not "tripled" (the old
        hand-copy undersold) nor "quadrupled" (a naive round oversells). The multiple-word
        only fires when the ratio is within 0.15 of an integer; otherwise it states the
        exact "grew 3.5×". Trust the instrument (DESIGN principle 1).
      - New freshness badge in the hero reads EDA.as_of and shows days-since (green ≤14d,
        amber ≤30d) plus the one-line refresh command (python3 tide.py build). Staleness
        is now visible and curable instead of silent — the data is 17 days old today.
      
      Verified in Chromium via Playwright: title/dek/badge all render from data, no
      console errors. Follow-up (needs PLN's call): a one-click rebuild from the Bridge
      (/api/rebuild → tide.py build) vs the current command hint.
      PLN (Algolia) authored
    • docs(tasks): archive #85 (pack-level grouping) to completed-archive · 0ca81596
      Pre-compact Pass 4: structured long-term-learning entry. The keeper insight —
      each grouping grain answers a different question (folder=author's label,
      pack=origin, sample=timbre); the origin-vs-sound MISMATCH (15/22 packs span the
      family wheel by design) is the story, not an error.
      PLN (Algolia) authored
    • feat(foundry): the loop finder (engine/loops.py) — §1 pipeline, per-stem (#5) · c17dd0d5
      Generates ranked loop candidates from a separated stem, the phase-2 design made
      real (PHASE2_FINDINGS §1/§3/§4): drift-tolerant PLP beat grid → beat-sync SSM
      (MFCC⊕chroma → recurrence affinity) → Foote checkerboard novelty → candidate
      windows at {1,2,4,8} bars → §3 composite score → NMS dedup. Works PER STEM (§0),
      then loosely groups candidates across stems by shared time window into Takes the
      human auditions together. export_take honours the B-rulebook (B1 ZC-snap, B6 bass
      mono-sum, B8 24-bit/44.1k, B10 no-normalize) and calls publish.link_kit.
      
      DRY with the katana: the loopability score reuses engine.grade's seam_score /
      zc_score on each candidate slice, and adds the two terms only the finder has the
      SSM context for — structural (does the window recur off its own diagonal?) and
      boundary novelty (clean segment edges). Weights w_struct .4 / w_novel .2 / w_seam
      .3 / w_zc .1, minus a tempo-instability penalty (§3).
      
      Caught while validating on the real Loituma stems: PLP latched onto the eighth-
      note pulse and reported ~250 bpm (Loituma is ~125), so 2-bar windows came out
      half-length — the §7 octave hazard, live. Fixed by pinning PLP's tempo range to
      one musical octave (70–160) and enforcing a min inter-beat spacing in peak-pick.
      Now reads ~120 bpm and 2-bar windows land at ~3.9s, squarely in the A-profile
      band (3.7–8.0s). find_takes on the 4-stem catch correctly clusters time-aligned
      candidates (e.g. vocals+drums sharing a 2-bar window).
      
      CLI: python3 -m engine.loops <stem.wav> [--bars 2 4 8] [--top N] [--json].
      7 new tests (checkerboard signs, Foote peak at a block seam, structural recurrence,
      NMS dedup, ZC-snap, and the tempo-octave guard). Full foundry suite: 41 pass.
      PLN (Algolia) authored
    • feat(tide-table): Hall of Fame showcase viz — tierlist.html (#11) · 4c9a8312
      The Foundry Hall of Fame, built through /impeccable in the Ship's Bridge language
      (armada/DESIGN.md): dark instrument surface, Geist + Geist-Mono, brand magenta
      reserved for the one earned accent (here the S-tier champions, the brand eyebrow,
      and the now-playing pulse). Register = product: quiet, instrument-grade, the ear
      leads and the screen serves.
      
      What it shows:
      - A classic S→D tier board. Tier is encoded redundantly (letter badge + lane
        position + colour), never hue alone — colour-blind safe by construction (DESIGN
        principle 4). Each kit is a chip: median grade, usage (tracks that play it),
        loop count, and a row of flag dots (seam/clip/off-grid/dc).
      - Click a kit → a drawer of its loops, each with a grade bar, tier dot, and a
        PLAY button. The ear leads: every loop auditions in place (loop-on-repeat via a
        shared <audio>, _corpus/<kit>/<file>.wav over serve.py), with a now-playing
        footer. (DESIGN principle 2.)
      - The rubric-validation callout, shown HONESTLY (principle 1, trust the
        instrument): gradeusage ρ=+0.34, with a log-scale scatter that *labels its own
        confound* — risers and weird_dialogs sit low-right (used most, tier low) because
        they're FX, not loops. The footnote states the coverage caveat (34/97 kits carry
        a usage token) rather than implying full coverage.
      - Search, filter (all/played/flagged), sort (grade/usage/name); skeleton load,
        empty states, focus-visible, keyboard audition, and prefers-reduced-motion
        fallbacks for every animation.
      
      Verified in Chromium at 1280/390 widths via Playwright: no console errors, drawer +
      audition + scatter all render, copy carries no em-dashes (impeccable ban). Audio
      served through the local _corpus symlink (gitignored). Runs via
      `python3 serve.py --dir tide-table --port 8731` → /tierlist.html.
      PLN (Algolia) authored
    • docs(blog): 02 — scaling the feeling + cross-model drift (#93) · 91334e26
      The overnight story: 60→534-track corpus via a 24-subagent workflow (schema-
      enforced GT, 0 invalid); cross-model drift (Sonnet relabels all 534) as a
      conscience — 72%% quadrant agreement, 174 uncertain-GT tracks concentrated on
      circumplex-straddling genres (hardstyle, uplifting trance, neoclassical); and
      the unplanned corroboration — a separate ontology-critique panel converged on
      the SAME high-arousal gap the drift flagged (groovy/propulsive/transcendent/
      menacing). Plus the infra lesson: only a systemd --user unit survives sandbox
      teardown. Calibration verdict (whole-mix vs stem at scale) left as the
      cliffhanger pending the grind.
      PLN (Algolia) authored
    • data(emotion): Sonnet ontology-critique panel — 40 proposals, the convergence (#93) · 75dba15e
      A 5-lens Sonnet panel (electronic gaps / canon gaps / synonym enrichment /
      overlap-anchors skeptic / cross-cultural) critiqued the 12-emotion wheel:
      18 new-emotion, 10 anchor-fix, 10 synonym-add, 2 redundancy proposals.
      
      The signal is the CONVERGENCE:
      - 'groovy' proposed independently by 3+ lenses (electronic, canon, cross-cultural)
        — the in-the-pocket body-lock register the wheel has no home for (hypnotic too
        cerebral, playful too cartoonish, warm has no swagger). Clearest accept.
      - the high-arousal boundary-straddlers — propulsive (peak-time diesel momentum),
        transcendent (blissed-out rave plateau), menacing (rolling predatory dread) —
        fill EXACTLY the region the cross-model drift report independently flagged as
        uncertain GT (hardstyle's euphoric-aggression, uplifting trance). Two separate
        analyses, same hole: the (+/- valence, high arousal) corner is under-vocabularied.
      - bittersweet (melodic-techno's beautiful ache), psychedelic (hallucinatory
        disorientation), plus cross-cultural saudade/duende/ennui (mostly fold into
        existing). dark anchor-fix A+0.20→+0.35 to separate from cold.
      
      NOT folded in tonight: changing the wheel mid-grind would split the corpus across
      two ontologies and invalidate the #92 calibration. Expansion + re-analysis is a
      deliberate v2 pass (new task). Tonight's corpus stays on the consistent 12.
      PLN (Algolia) authored
    • feat(tide-table): Hall of Fame tierlist data — grade 1690 loops, validate the rubric (#11) · f88273b8
      build_tierlist.py grades EVERY hand-cut loop in the Samples corpus with the Foundry
      katana (engine/grade.py — DRY, one rubric) and joins each kit against how often PLN
      actually plays it (`s "kit"` track-counts from catalog_view.json). 1690 wavs across
      97 loop-kits graded in parallel (~98s, 6 workers). Tiers: 4 S, 3 A, 24 B, 51 C, 15 D.
      
      The point of #11 was never the ranking — it's VALIDATING the rubric against reality
      (PHASE2_FINDINGS §5b): do the kits PLN reaches for grade higher? Result:
      gradeusage Spearman ρ = +0.34 — positive, the katana points the right way.
      
      The confound is the interesting part. The single most-used kit, `risers` (16 tracks),
      tiers only C — and that's CORRECT: risers are FX sweeps, they go quiet→loud and never
      wrap cleanly, so they fail seam + bar-consistency by design. They're heavily used as
      one-shot transitions, not loops. Same for weird_dialogs. So loop-mechanical-grade and
      usage diverge exactly where they should — for non-loop material. Filtering FX out would
      lift ρ; the divergence is a feature (it tells loops from FX), not a rubric failure.
      Meanwhile PLN's custom `f*` palette validates the rubric from the other side: fsynth,
      fguitar, fmono all land A-tier; fpiano/forgan/fbreak* B. S-tier exemplars: simmons,
      electrn, samples-cello-plucked, ouais.
      
      Caveat logged for follow-up: only 34/97 kits matched a usage token (folder name vs
      `s`-token mismatch for the rest, and catalog_view covers 73 tracks) — the correlation
      is over those 34. The grader's v1 thresholds (noisy dc-offset flag at 1e-4, seam dB
      cutoffs) are the next calibration lever now that we can measure against the corpus.
      
      7 pure-logic tests (spearman incl. ties/inverse, usage = tracks-not-occurrences,
      tier thresholds mirror the grader). Next: tierlist.html showcase (Ship's Bridge + impeccable).
      PLN (Algolia) authored
    • data(emotion): cross-model GT drift report — opus vs sonnet on 534 (#93) · 1081c74b
      A second model (Sonnet) independently labeled all 534 tracks; emotion_drift.py
      measures where it disagrees with the opus GT. Result:
        mean V/A drift     0.322
        quadrant agreement 72%
        emotion Jaccard    0.53
        174 uncertain-GT tracks (top-quartile drift or quadrant mismatch)
      
      The disagreements aren't noise — they CONCENTRATE on genres that straddle the
      circumplex, which is a real finding:
        - hardstyle (The Prophet, Headhunterz): triumphant/euphoric (+,+) vs
          aggressive/tense (-,+) — the genre's 'euphoric aggression' lives on the
          quadrant line.
        - uplifting trance (Cosmic Gate, Ferry Corsten, Markus Schulz): euphoric vs
          driving/tense — same boundary.
        - neoclassical (Nils Frahm, Ólafur Arnalds, Max Richter): calm-positive vs
          melancholic-calm.
      
      So ~33% of the corpus carries genuinely uncertain emotional GT, and it's
      predictable WHICH third. Those tracks should be down-weighted (not trusted) in
      the upcoming scale calibration — single-model GT would have hidden this.
      PLN (Algolia) authored
  4. 22 Jun, 2026 1 commit
    • data(emotion): GT corpus 60 → 534 tracks (electronic-leaning, +474, #93) · cb52ad1d
      Overnight scale-up. A workflow fanned 24 opus subagents over genre cells —
      electronic-heavy (Detroit/Chicago, modern techno, house, trance, dnb/jungle,
      dubstep, garage, ambient, IDM, trip-hop, dub, synthwave, EDM, minimal, French
      touch, hardcore, deconstructed, lofi) anchored by canon + diversity (hip-hop,
      rock/metal, soul/funk, jazz, world, modern classical). 480 authored → 475 unique
      → 474 merged (1 dup, 0 invalid: schema enforcement on the 12-emotion vocab +
      V/A range held perfectly).
      
      Coverage: all 12 emotions, 289 genre tags, every V/A quadrant >> floor (thinnest
      --=47). Labels ring true — Cybotron 'Clear' cold/hypnotic, 'Strings of Life'
      euphoric, Jeff Mills 'The Bells' hypnotic/tense, de Witte dark/aggressive.
      
      PLN's 'dataset size is the lever' — 9x the corpus for the scale calibration the
      n=5/n=60 reads couldn't settle. Audio fetch/analyze runs overnight (disk-safe).
      PLN (Algolia) authored