Commit 0f271843 by PLN (Algolia)

merge: the Fred pack and La Cale

150 samples in 43 kits from Fred again..'s stem pack, staged at
Samples/Fred/output/ and linked into Dirt-Samples, plus the page for deciding
whether any of it is good.

The pack took six cuts. Each discarded run was discarded because looking at its
output found something: dead bars grading S, one rotation pass leaving 1 loop in 6
on a weak beat, then the big one — 24 of 153 loops were digital silence at -104…-75
dBFS, graded A and B, because every term in the loop rubric is vacuously perfect on
nothing. Presence is a precondition now, at the grader and before the finder ranks.

La Cale (armada/ui/kits.html) plays rate = dur / (bars x barlen), which is literally
loopAt, so what you hear is what the rig does and the exported .tidal block is the
same two numbers in Tidal's syntax.

Python suite 112 -> 128; new vitest suite 24 cases.
parents 1e2bc3d9 ee8d4ac2
---
log: 027
title: "The label and the lens"
date: 2026-08-21
task: "Foundry #20 batch-explore many sources"
tags: [tooling, sampling, foundry, analysis]
shareable: true
---
## Cap (what & why)
Fred again.. dropped his stems. 36 GB of them — 9 tracks split by the producer himself
into 6–13 named stems each (`MAREA MIX10 123BPM KIT STEM.wav`), plus 13 acapella /
instrumental pairs. Cutting that into TidalCycles kits by hand in Audacity is exactly
the drudgery the Foundry's loop finder was built to kill. So: point the finder at
somebody else's stems and find out whether it actually works.
It mostly did. What it got wrong is the interesting part.
## Manœuvre (how)
The finder existed and was good — beat grid, Foote novelty, verify-rerank on the true
post-snap seam, downbeat rotation, an export path that guarantees bar multiples. What
did not exist was a way to feed it a folder somebody else had already separated:
`find_takes` keys stems by demucs' four names, a dict that collides on KEYS1+KEYS2 and
an export filter that would have silently dropped every stem in the pack.
So `engine/stempack.py` (the batch driver), `engine/roles.py` (the pack's ~40 role
tokens → a family, then verified by measurement), and `engine/kitcheck.py` (the audit
of what `grade` structurally cannot see). One kit per track × role family.
Then: run it, look at what came out, fix what was wrong, run it again.
## Prise (findings / artifacts)
- `tools/foundry/engine/{stempack,roles,kitcheck}.py` + `kitgate.py` + `restage.py`,
26 new tests, suite 86 → 112.
- MAREA declares its tempo in its filenames, so it is free ground truth. Final grid:
**123.0 bpm against a declared 123 — 0.0 % off.**
- **96 kHz was breaking the finder.** At native rate it returned 123/123/124/**119**/
**128.1**/**128.5** bpm with a 0.661 top score; resampled to 44.1 kHz first, 123.0–123.1
on every candidate at 0.859, in a third of the time.
- Every loop was being cut at its own local tempo — ME's grid read 129.2 while both its
shipped loops were cut at 150.5. Bar length now comes from one robust track tempo
(the mode of the local-BPM histogram) with a confidence, and a track with no usable
pulse ships chops only.
- Chops beat loops mechanically every time (0.993–0.999 vs 0.841–0.881), so the vocal
kit came out as three stabs and no loops. Modes now rank in separate pools.
- The probe was looking in the wrong place with the wrong lens, and threw away good
material both ways — see below.
### Then four more, found by measuring the output instead of reading it
- **The periodicity check was measuring loop length.** Autocorrelation at lag *k* has
only *N−k* overlapping frames; normalised by `ac[0]` it scored a *perfectly periodic*
click track 0.418 / 0.623 / 0.754 at 2 / 4 / 8 bars. Then the floor had to go per role:
medians drums 0.754, fx 0.549, tonal 0.214, bass 0.173, **vox 0.087**. One threshold
condemned 12 of 20 vocal loops for the crime of being vocal.
- **Dead bars grade S.** A 4-bar loop whose last two bars are a fade is silent-to-silent
— a perfect seam — at an exact bar length. Ten of the first 161 cuts were that. Now
rejected at export, not reported afterwards.
- **Kits had no headroom.** MAREA's ALL DRUMS and KIT both peak at **+0.2 dBFS**, and
`amp = gain^4`, so `# gain 1.2` is +7.6 dB and the orbit clips the moment you touch it.
Kits now land at −6 dBFS as *one gain per kit* (per-file would flatten a sparse hat
against a full drum bus) and never boosting.
- **1 loop in 6 opened on a weak beat.** The exact "timed ok but cut BCDA/DABC" complaint
from July. The roll point is zero-crossing snapped by up to ~10 ms against a 20 ms
attack window — enough to flip two near-tied slots. Rotation now checks its own result
and repeats: best slot 29 → 46 of 60, worse half 10 → **1**.
And two bugs the *checks* caught in my own work, which is the argument for having them:
`kitgate` re-grades the shipped files instead of trusting the manifest, and immediately
found that staging applied its gain after grading, so the recorded number described audio
that was never written. Separately, a report section I added never rendered — a
`str.replace` whose anchor did not match, and the one patch in that file where I had not
asserted on the anchor.
## Sel (the shareable learning)
**Two ways to be wrong about a stem, and they pull in opposite directions.**
The house rule here is *never infer a sound's role from its name — validate by
analysis*. It is a good rule, learned the hard way. But applied without thinking it
becomes *the measurement always wins*, and that is a different claim, and it is false.
Measurement said MAREA's ALL DRUMS stem was **bass** — lf_frac 0.86, centroid 216 Hz,
identical to a real bass stem on those features, because a modern kick carries most of
a drum mix's energy. It said the sparse CLAP stem was **tonal**. It said the rhythmic
KEYS2 was **drums** at 5.1 onsets/s. Trust it blindly and three stems go into the wrong
kit, silently, with a confident measurement behind each one.
The resolution is not to pick a side but to notice they are decisive about *different
things*. Presence is measured — a stem under −40 dBFS peak has nothing in it, full
stop. Bass is measured — "all energy under 150 Hz, nothing above 2 kHz" is unambiguous.
Everything else keeps the producer's label, because the cheap features genuinely cannot
separate a rhythmic rhodes from a drum bus. Disagreements get reported, not resolved.
The rule was never "don't trust the name"; it was "don't *infer* from the name" — and a
human's statement about their own stem is evidence, not inference.
**And the lens has to fit the thing.** Usability was gated on overall RMS. ANGIE's
HIHATS stem reads −48.8 dBFS RMS and got binned as near-silent — but it peaks at −14.7
and is a perfectly good hi-hat stem. RMS on sparse percussion measures how much silence
it contains. Worse, the probe sampled evenly-spaced windows to save time and landed in
the gaps: it read that stem at −60.9 / 1.4 % active where the full file says −48.8 /
9.7 %, and missed PAD STUFF's material entirely, 0.0 % against 6.9 %. Look in the wrong
place with the wrong instrument and you will get a number, and it will be wrong twice.
**Bonus, same shape:** onset *count* is not a percussiveness measure. librosa's
peak-picker finds peaks in a flat envelope — a steady 50 Hz sine yields 84 onsets in
six seconds. HPSS was the obvious fix and is worse: it hears a modern kick as harmonic
and calls a drum bus 9 % percussive.
**And check the instrument before you accuse the rig.** The first measurement of "do
loops start on their downbeat" said 17 %, which looked like a broken feature. It scored
16 slots with no full-band term while the rotator scores `bars×4` slots *with* one — the
disagreement was mine. Re-measured with the rotator's own scorer, factored out so the two
cannot drift apart, the real number was 48 % best-slot with a median miss of 0.032: mostly
near-ties, but a genuine 17 % starting on a weak beat. The bug was real *and* the first
number was wrong, which is the annoying case.
## Hameçon (hook)
"The machine told me the drum stem was a bass. It was right about every number and
wrong about the sound." — three ways to measure a stem and be confidently mistaken,
and the one rule that sorts them out.
## Sillage (what it unlocks)
Foundry #20 lands: any folder of already-separated stems is now a batch away from being
a playable kit. Next: the 13 acapellas (chops mode, already 44.1 k), and the same driver
pointed at the 27 demucs catches sitting in `~/Downloads/separated`. `kitcheck`'s dead-bar
and periodicity tests are general — they belong in the tierlist pass over the whole
Samples corpus, where "grades S, useless" is presumably not rare.
---
log: 028
title: "Silence graded A: the day the grader's best loop was nothing at all"
date: 2026-08-21
task: "#20 finish the Fred pack · #21 La Cale"
tags: [tooling, sampling, foundry, ui, measurement]
shareable: true
---
## Cap (what & why)
Finish the Fred again.. stem pack into loadable Tidal kits, and build the page PLN
asked for to hear whether they are any good — *"click-based looping not just strudel
coding"*. The pack was "done" three times before it was done.
## Manœuvre (how)
The gate blocked the pack, so I measured the files it flagged instead of adjusting the
flag. 24 of 153 shipped loops had a peak between **-104 and -75 dBFS**. Not quiet.
Digital silence. Graded **0.707-0.896, tier A and B**.
Every sub-score in the loop rubric is vacuously perfect on nothing. The seam between
two silences is immaculate. The DC of silence is exactly zero. Its zero crossings are
trivially fine. Its length is a bar multiple by construction. Level carries 10% of the
composite, so silence scores 0.90 × perfect + 0.10 × nothing and comes out **tier A**.
The composite had a degenerate optimum at *no audio at all*, and the finder, doing
exactly its job, walked into it: `fred_marea_bass` shipped three loops and all three
were silence — from a stem measuring -22 dBFS RMS at 54% activity.
Nothing downstream could catch it. The dead-bar check compares bars to **each other**,
so it finds a loop that fades out and is blind to one that was never there: a uniformly
silent clip has a perfectly flat bar profile.
So presence stopped being a weighted term and became a **precondition**. The floor was
placed, not tuned: the pack's peaks run -104…-75 and then jump to -47.1, a **28.4 dB
empty gap**, so -60 dBFS sits in clean air.
Then the second half of the same bug. Filtering at export was the right fix in the wrong
place: BIGHEN's strings stem plays in two sections and rests between them, and all six
windows the finder ranked highest sat inside a rest — so `fred_bighen_tonal` went from
four silent loops to **one** real one when it should have gone to four. The finder's
composite is degenerate on silence too (a rest is maximally self-similar in the SSM), so
presence is now checked **before** ranking, sharing one constant with the grader by
import.
Meanwhile: **La Cale**, `armada/ui/kits.html`. The rack schedules every voice at
`rate = dur / (bars × barlen)`, which is literally `loopAt`, so what you hear is what
the rig plays and the exported `.tidal` block is the same two numbers in Tidal's syntax.
## Prise (findings / artifacts)
- `engine/grade.py``empty_dbfs = -60`, presence measured on the **channels**, not the
mono sum (an anti-phase pair sums to zero and is not empty).
- `engine/loops.py` — presence gate before ranking, via a per-beat peak table so it is
free; `engine/kitcheck.py` asks the same question from its own code path.
- `kitgate.py` — 5th check: does `n 3` on disk address the file the cheat sheet names.
- `armada/ui/kits.html` + `src/kits/` + `tools/foundry/kitindex.py`.
- Python suite 112 → 125; new vitest suite 24 cases.
## Sel (the shareable learning)
**A composite score is a specification of what you want, and every term you weight
lightly is a loophole.** Nobody wrote "prefer silence". It fell out of five reasonable
measures, each correctly implemented, none of which had an opinion about whether there
was any audio. Presence is not a quality you trade against seam cleanliness — it is a
precondition, and preconditions belong outside the weighted sum.
**Three tests today passed while proving nothing**, and finding that out was most of the
value. The finder test used the stem's own beat grid, which finds no beats inside
silence, so no window was ever generated there and it passed with the gate disabled —
the bug needs a grid shared from *another* stem, which is exactly how the real caller
works. The niceness test looked its hook up in the wrong scope, got `None`, fell back to
a no-op lambda, and went green. Now the habit is explicit: **break the fix, watch the
test fail, put the fix back.**
**And a background job that takes the desktop down is not a background job.** Nine
children at 88% of a core each put the load average at 15.6 on 16 cores, on the machine
PLN performs on. `nice 19` + `SCHED_IDLE` and half the cores by default — the work is
genuinely background, nothing waits on it but me.
## Hameçon (hook)
"My sample cutter's favourite loop was four bars of nothing — and it scored an A. Here
is why every one of its five measurements agreed."
## Sillage (what it unlocks)
The presence precondition is general, so #23 (auditing the 111 kits that predate the
Foundry) will find the same thing in hand-cut material. La Cale is the missing feedback
loop for every future cut: PLN can now hear a kit in seconds and the manifest's claims
get checked by ear instead of accumulating.
......@@ -1628,3 +1628,78 @@ cannot validate a stem-based lens — that gap should be closed before attempt f
**Deps.** Superseded in practice by the boundary lab (#B2), which made the ear call cheap
enough that the detector stopped being necessary.
## #20 — Batch the Foundry over producer stem packs
**Description.** Fred again.. dropped 36 GB of stems — 9 tracks split by the producer
himself into 6–13 named stems each (`MAREA MIX10 123BPM KIT STEM.wav`), plus 13
acapella/instrumental pairs. Cutting that into TidalCycles kits by hand in Audacity is the
drudgery the loop finder was built to kill. Goal: `fred_XXX` kit dirs, one per track, each
holding loops cut from that track's stems — and find out whether the finder actually works
on someone else's stems.
**Done.** Branch `claude/fred-kits`, 14 commits. `engine/stempack.py` (batch driver),
`engine/roles.py` (~40 role tokens → family, verified by measurement), `engine/kitcheck.py`
(the audit `grade` structurally cannot do), `kitgate.py` (the one gate before loading),
`restage.py` (gain-staging for kits already on disk), `--report-only` (rebuild views from
the manifest), `packs/` (provenance, not audio). Suite 86 → 112. Write-up:
`armada/tasks/027-the-label-and-the-lens.md`.
**Learnings.** The finder was good; everything that was wrong was found by RUNNING it and
measuring the output, never by reading code.
- **96 kHz silently broke it.** librosa's hop/window defaults are sample-rate-relative, so
at 96 k every analysis frame spans half the musical time. MAREA declares `123BPM` in its
filenames = free ground truth: native gave 123/123/124/**119**/**128.1**/**128.5** bpm at
a 0.661 top score; resampled to 44.1 k first, 123.0–123.1 on every candidate at 0.859, in
a third of the time. Rate is a correctness input, not a performance knob.
- **Bar length must come from the TRACK, not the window.** Candidate bpm is a per-window
mean — right for drift, wrong for a bar length: ME's grid read 129.2 while both its loops
were cut at 150.5, so no two loops in a kit layered. Use the MODE of the local-BPM
histogram plus a confidence (share of beats within ±5 %). The 35 % gate landed in a clean
empty gap: every track ≥46 % produced good loops, the one at 20 % correctly produced none.
- **Chops beat loops mechanically, always.** `grade` scores a sub-0.75 s clip as a one-shot
on `0.5·level + 0.3·dc + 0.2·zc` — no seam, no bar term — and export already removes DC
and snaps zero crossings, so chops land near 1.0 while loops pay 0.35 on seam. Measured:
chops 0.993–0.999, loops 0.841–0.881, and the vocal kit came out as three stabs with no
loops. Never rank two modes against one rubric.
- **The periodicity check measured loop LENGTH, then ROLE.** Autocorrelation at lag *k* has
only *N−k* overlapping frames; normalised by `ac[0]` it scored a *perfectly periodic*
click track 0.418/0.623/0.754 at 2/4/8 bars. Divide by the per-lag overlap count → flat.
Then the floor had to go per family (drums 0.754, fx 0.549, tonal 0.214, bass 0.173, vox
0.087): one threshold condemned 12 of 20 vocal loops for being vocal.
- **Dead bars grade S.** A 4-bar loop ending in a fade is silent-to-silent (perfect seam) at
an exact bar length. 10 of the first 161 cuts. An objective defect with an objective test
belongs in the gate, not a report.
- **`amp = gain^4` means kits must be staged.** MAREA's ALL DRUMS and KIT both peak at
**+0.2 dBFS**, so `# gain 1.2` is +7.6 dB and the orbit clips on contact. −6 dBFS ceiling,
as ONE gain per kit (per-file would flatten a sparse hat against a full drum bus) and
never boosting (a quiet stem is quiet on purpose).
- **One rotation pass leaves 1 loop in 6 opening on a weak beat** — the "timed ok but cut
BCDA/DABC" complaint from 2026-07-11. The roll point is zc-snapped up to ~10 ms against a
20 ms attack window, enough to flip near-tied slots. Check-and-repeat: best slot 29 → 46
of 60, worse half 10 → 1.
- **Dead end: HPSS for percussiveness.** It hears a modern kick as harmonic and calls a drum
bus 9 % percussive. Also slow. Frame-RMS crest gating onset density is the cheap win — but
crest is a *sparsity* measure (a single vocal phrase reads 17.98), so it may only gate.
- **The checks caught two bugs in my own work**, which is the whole argument for them:
`kitgate` re-grades the shipped files rather than trusting the manifest, and found that
staging applied its gain AFTER grading, so the record described audio never written. And a
report section never rendered — a `str.replace` whose anchor didn't match, in the one patch
where I hadn't asserted on it.
- **Nearly filed a false alarm:** the first "do loops start on the downbeat" measurement said
17 % and looked like a broken feature. It scored 16 slots with no full-band term while the
rotator scores `bars×4` WITH one — the disagreement was my instrument's. Factored the
scorer out so the two cannot drift, re-measured: 48 % best-slot, median miss 0.032. The bug
was real *and* the first number was wrong.
- **The deepest one is about authority.** "Never infer role from the name" is not "the
measurement always wins." Measurement called `ALL DRUMS` a bass (a modern kick carries the
mix's energy: lf 0.86, centroid 216 Hz), rhythmic `KEYS2` drums (5.1 onsets/s), sparse
`CLAP` tonal. Presence and bass are decided by measurement; everything else keeps the
producer's label, because a human's statement about their own stem is evidence, not
inference. A label WE invented (an unrecognised token) loses to measurement. Disagreements
get reported, never silently resolved. Banked as `feedback_label_and_lens`.
**Deps.** Unblocked #21 (the auditioner SPA PLN asked for), #22 (same driver at the
acapellas + the 27 demucs catches), #23 (kitcheck/restage over the 163 existing kits).
Runs 1 and 2 were discarded — each predated a fix; run 3 is the deliverable.
#!/usr/bin/env python3
"""Register freshly cut third-party sample kits in the rights ledger, blocked by default.
A kit cut from somebody else's stems is not releasable per-track until a human says so
(`reference_sample_rights_audit`). Left alone, a new `fred_*` bank would land in the
ledger as `unknown` the next time `rights_audit.py --init` ran — which does block, but
blocks with no reason attached, so whoever reads it later has to re-derive where the
audio came from. Recording the provenance at the moment of cutting is the whole point
of `feedback_metadata_provenance`: value, source, locator, date.
python3 register_fred_rights.py <fred_kits.json> [--source "..."]
"""
from __future__ import annotations
import argparse
import json
from datetime import date
from pathlib import Path
HERE = Path(__file__).resolve().parent
LEDGER = HERE / "rights_ledger.json"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("cuts_json", type=Path)
ap.add_argument("--source", default="Fred again.. producer stem pack (fan dropbox)")
ap.add_argument("--status", default="third_party",
choices=["third_party", "unknown", "cleared", "original"])
a = ap.parse_args()
cuts = json.loads(a.cuts_json.read_text())
led = json.loads(LEDGER.read_text())
banks = led.setdefault("banks", {})
counts: dict[str, int] = {}
tracks: dict[str, set] = {}
for c in cuts:
counts[c["kit"]] = counts.get(c["kit"], 0) + 1
tracks.setdefault(c["kit"], set()).add(c["track"])
added = updated = 0
for kit, n in sorted(counts.items()):
note = f"cut by engine.stempack from {a.source} — source track(s): " \
f"{', '.join(sorted(tracks[kit]))}"
if kit in banks and banks[kit].get("status") not in (None, "unknown"):
updated += 1 # never overwrite a human verdict
banks[kit]["files"] = n
continue
banks[kit] = {"status": a.status, "origin": "local", "files": n,
"note": note, "decided": date.today().isoformat()}
added += 1
LEDGER.write_text(json.dumps(led, indent=1) + "\n")
print(f"{added} bank(s) registered as {a.status}; {updated} already had a verdict and were left alone")
print(f"ledger: {LEDGER}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
node_modules
dist
*.log
# Generated by tools/foundry/kitindex.py from the sample root — a 1.2 MB snapshot of
# audio that lives outside the repo. Regenerate, don't commit.
public/kits.json
# React + TypeScript + Vite
# armada/ui — the bridge
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
The instruments PLN actually looks at. One Vite project, several pages, one design
language (**the Ship's Bridge**`../PRODUCT.md` for the register, `../DESIGN.md` for
the visuals, managed with the `impeccable` skill from `armada/`).
Currently, two official plugins are available:
Stack: Vite + React 19 + TS + Tailwind 4 + shadcn conventions, Geist / Geist Mono.
Node ≥ 18 — `nvm use 22`.
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## The pages
## React Compiler
| URL | Name | What it answers |
|---|---|---|
| `/` | Take Judge | A/B two masters of the same take — which one ships |
| `/judge.html` | Set Judge | one pass down a whole gig, a verdict + comment per track → `decisions.json` |
| `/bounds.html` | Boundary Lab | where exactly does one track become the next |
| `/sextant.html` | Le Sextant | distribution cost/revenue simulation |
| `/kits.html` | **La Cale** | is this sample kit any good — audition, loop, rack up, export Tidal |
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
Each page is its own Rollup input in `vite.config.ts` and its own `src/<page>.tsx`
entry; shared bits live in `src/components/` and `src/lib/`.
## Expanding the ESLint configuration
## Running it
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
```bash
nvm use 22
npm ci
npm run dev # http://localhost:5173 — also on the LAN (server.host)
npm run build # tsc -b && vite build → dist/
npm test # vitest
npm run lint
```
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
For phone audition and anything that has to work outside dev, serve the build:
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```bash
python3 ../serve.py --dir dist --port 8792
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
`serve.py` is Range-capable, which stdlib `http.server` is not — without it, seeking a
90 MB master in `<audio>` does not work at all.
## Audio, and why it is mounted rather than bundled
The audio these pages play is gigabytes and lives outside the repo. `audio-mounts.json`
maps `/audio/<prefix>/...` to real directories, and it is read by **both**
`vite.config.ts` (dev) and `../serve.py` (built), so a URL that works in one works in
the other. A UI that only works under `npm run dev` is a demo, not a tool.
Longest prefix wins; `""` is the fallback mount. Add a prefix rather than a hardcoded
path — the masters move between gigs and the URLs should not.
## Data
Pages fetch generated JSON from `public/`. Some of it is committed (the gig-scoped
`judge-*.json`, `bounds-*.json`); `kits.json` is not — it is a 1.2 MB index of audio
that lives outside the repo, regenerated by
```bash
cd ../../tools/foundry && python3 kitindex.py --all
```
which writes `public/` **and** `dist/` if a build is present, because `npm run build`
copies `public/` exactly once and a page served from `dist/` would otherwise show an
index from before the last cut.
......@@ -7,7 +7,11 @@
"Masters live outside the repo (they are gigabytes). Mounting them by prefix",
"keeps the URLs stable while the paths move between gigs, and keeps `heads`",
"and `full` apart \u2014 `master split` names both '01 - Title.flac', so a single",
"flat mount would serve the 98 MB track when the UI asked for the 16 s head."
"flat mount would serve the 98 MB track when the UI asked for the 16 s head.",
"",
"'samples' is the whole custom sample root, not just one pack, so the kit",
"auditioner works on every kit PLN owns \u2014 the 163 already there and every",
"one the Foundry cuts next \u2014 without a config change per pack."
],
"mounts": {
"bounds": "/home/pln/Work/Sound/Prod/Opal26_master/bounds_v4",
......@@ -15,6 +19,7 @@
"full-club": "/home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_club",
"heads": "/home/pln/Work/Sound/Prod/Opal26_master/heads_v4",
"joins": "/home/pln/Work/Sound/Prod/Opal26_master/joins_v4",
"samples": "/home/pln/Work/Sound/Samples",
"": "/home/pln/Work/Sound/Tidal/armada/tide-table/punkachien"
}
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>La Cale · L'Armada</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/kits.tsx"></script>
</body>
</html>
......@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.9",
......@@ -34,6 +35,7 @@
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
"vite": "^8.0.12",
"vitest": "^4.1.11"
}
}
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@fontsource-variable/geist'
import '@fontsource-variable/geist-mono'
import './index.css'
import KitAuditioner from './kits/KitAuditioner.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<KitAuditioner />
</StrictMode>,
)
/**
* The rack — click cells, hear a groove, paste it into a `.tidal` file.
*
* A kit is not validated one sample at a time. The questions that decide whether a cut
* was worth making are "do these two loops sit together", "does this vocal survive on
* top of that break", "is this bass the same tempo as it claims" — and none of them can
* be answered in a file browser. So the rack is a small step sequencer whose grid means
* exactly what Tidal means:
*
* - A **bar-loop** row has one cell per bar of the phrase, but only on multiples of its
* own length: a 4-bar loop in an 8-bar phrase has two entries, not eight, because
* starting it on bar 3 would play its bar 1 under the phrase's bar 4. The cells
* translate to `mask "t f"`.
* - A **chop** row has 16 steps in one bar, repeated. The cells translate to the
* structure string `s "x ~ ~ x …"`.
*
* Everything plays at the deck's tempo via `loopAt`, so the layering you hear is the
* layering you get, and the export is not an approximation of the session — it is the
* same two numbers written in Tidal's syntax.
*/
import { useEffect, useState } from 'react'
import { Trash2, Copy, Volume2, VolumeX, Check } from 'lucide-react'
import { CHOP_STEPS, PHRASE_BARS, type Voice } from './engine'
import { familyColor } from './types'
import { toTidal, toStrudel, type ExportRow } from './tidal'
export type RackRow = ExportRow & { color: string }
type Props = {
rows: RackRow[]
bpm: number
playing: boolean
position: number // bar index within the phrase, -1 when stopped
onChange: (id: string, patch: Partial<Voice>) => void
onRemove: (id: string) => void
onClear: () => void
}
export function Rack({ rows, bpm, playing, position, onChange, onRemove, onClear }: Props) {
const [tab, setTab] = useState<'tidal' | 'strudel'>('tidal')
const [copied, setCopied] = useState(false)
useEffect(() => { if (copied) { const t = setTimeout(() => setCopied(false), 1200); return () => clearTimeout(t) } }, [copied])
const code = tab === 'tidal' ? toTidal(rows, bpm) : toStrudel(rows, bpm)
if (!rows.length) {
return (
<div className="p-4 text-[12px] text-ink-faint max-w-2xl">
The rack is empty. Add samples with <span className="font-mono">+</span> — or press
<span className="font-mono"> a </span>on a focused row — then hit play. Loops are
stretched to the deck tempo with <span className="font-mono">loopAt</span>, which is
what Tidal will do, so what you hear here is what the rig plays.
</div>
)
}
return (
<div className="flex min-h-0 flex-1">
<div className="flex-1 overflow-y-auto">
{rows.map(({ voice: v, kit, n, family }) => {
const isChop = v.bars === 0
const cells = isChop ? CHOP_STEPS : PHRASE_BARS
const stride = isChop ? 1 : v.bars
const col = familyColor(family)
return (
<div key={v.id} className="flex items-center gap-2 px-2 py-1 border-b border-hairline/60">
<button onClick={() => onChange(v.id, { muted: !v.muted })}
className={`p-1 rounded-sm hover:bg-overlay ${v.muted ? 'text-ink-faint' : 'text-ink-muted'}`}
title={v.muted ? 'unmute' : 'mute'}>
{v.muted ? <VolumeX size={13} /> : <Volume2 size={13} />}
</button>
<div className="w-40 min-w-0">
<div className="truncate text-[12px] leading-tight" style={{ color: v.muted ? 'var(--color-ink-faint)' : col }}>
{v.id.split('/')[1]}
</div>
<div className="truncate text-[10px] text-ink-faint leading-tight font-mono">
{kit} · n {n} · {isChop ? 'chop' : `loopAt ${v.bars}`}
</div>
</div>
{/* the grid. A cell is a bar (or a 16th, for a chop) of the phrase. */}
<div className="flex flex-1 gap-[3px]">
{Array.from({ length: cells }, (_, i) => {
const on = !!v.cells[i]
const usable = i % stride === 0
// where the playhead is, in this row's own units
const live = playing && (isChop
? false // a 16th playhead would strobe; the bar rule is enough
: position >= 0 && Math.floor(position / stride) * stride === i)
if (!usable) return <div key={i} className="flex-1" />
return (
<button key={i}
onClick={() => {
const next = [...v.cells]
next[i] = !on
onChange(v.id, { cells: next })
}}
style={{
flex: stride,
background: on ? col : 'var(--color-raised)',
opacity: v.muted ? 0.35 : 1,
boxShadow: live ? 'inset 0 0 0 1.5px var(--color-ink)' : undefined,
}}
className="h-6 rounded-sm border border-hairline/60 hover:brightness-125" />
)
})}
</div>
<input type="range" min={0} max={1.5} step={0.01} value={v.gain}
onChange={(e) => onChange(v.id, { gain: Number(e.target.value) })}
className="sx-range w-20" style={{ ['--fill' as string]: `${(v.gain / 1.5) * 100}%` }}
title={`fader ${v.gain.toFixed(2)} (linear)`} />
<button onClick={() => onRemove(v.id)} className="p-1 rounded-sm text-ink-faint hover:bg-overlay hover:text-blocked">
<Trash2 size={13} />
</button>
</div>
)
})}
</div>
{/* the payoff: the same groove as source you can paste into a live file */}
<div className="w-[27rem] shrink-0 border-l border-hairline flex flex-col">
<div className="flex items-center gap-1 px-2 py-1 border-b border-hairline">
{(['tidal', 'strudel'] as const).map((t) => (
<button key={t} onClick={() => setTab(t)}
className={`px-2 py-0.5 text-[11px] rounded-sm font-mono
${tab === t ? 'bg-overlay text-ink' : 'text-ink-faint hover:text-ink-muted'}`}>
{t}
</button>
))}
<div className="flex-1" />
<button onClick={() => { void navigator.clipboard.writeText(code); setCopied(true) }}
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded-sm text-ink-muted hover:bg-overlay">
{copied ? <Check size={12} className="text-ready" /> : <Copy size={12} />}
{copied ? 'copied' : 'copy'}
</button>
<button onClick={onClear} className="px-2 py-0.5 text-[11px] rounded-sm text-ink-faint hover:bg-overlay hover:text-blocked">
clear
</button>
</div>
<pre className="flex-1 overflow-auto p-2 text-[11px] leading-[1.45] font-mono text-ink-muted whitespace-pre">
{code}
</pre>
</div>
</div>
)
}
/**
* One sample: what it is, what it sounds like, and two ways to hear it.
*
* The two buttons are not the same test and the difference is the point of the page.
* **Once** plays the file untouched, which is how you hear its attack and its tail.
* **Loop** loops it seamlessly at rate 1, which is the only way to hear the seam the
* cutter actually produced — and the seam is the single thing that decides whether a
* loop is usable, because it is the sound the audience hears every bar.
*
* **+** puts it in the rack, where it stops being a file and becomes a voice in a
* groove at the deck's tempo.
*/
import { Play, Repeat, Square, Plus, Check } from 'lucide-react'
import { Waveform } from './Waveform'
import { familyColor, fmtBpm, TIER_COLOR, type Sample } from './types'
import { barErrorMs } from './engine'
type Props = {
s: Sample
kit: string
playing: 'once' | 'loop' | null
head: number | null
inRack: boolean
focused: boolean
onPlay: (mode: 'once' | 'loop') => void
onStop: () => void
onRack: () => void
onFocus: () => void
}
const TAG_AXES = ['instrument', 'texture', 'mood'] as const
/** Where in the source track, so two loops off the same stem are tellable apart. */
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
export function SampleRow({ s, playing, head, inRack, focused, onPlay, onStop, onRack, onFocus }: Props) {
const col = familyColor(s.family)
const bars = s.bars ?? 0
const err = s.bars && s.bpm && s.dur_s ? barErrorMs(s.dur_s, s.bars, s.bpm) : 0
// top tag per axis, at all — a wall of CLAP labels is not information
const tags = TAG_AXES.map((a) => s.tags?.[a]?.[0]).filter(Boolean) as [string, number][]
return (
<div
onMouseDown={onFocus}
className={`grid grid-cols-[2.2rem_14rem_3.2rem_1fr_5.5rem_4.6rem] items-center gap-2
px-2 py-1 border-b border-hairline/60 cursor-default
${focused ? 'bg-overlay' : 'hover:bg-raised'}`}
>
{/* the index you type. `# n 3` is the whole reason the kit is addressable */}
<div className="tnum font-mono text-[13px] text-ink-muted text-right pr-1">{s.n}</div>
<div className="min-w-0" title={s.file}>
<div className="truncate text-[13px] leading-tight" style={{ color: col }}>
{s.stem_role ?? s.name}
</div>
<div className="truncate text-[11px] text-ink-faint leading-tight">
{bars > 0
? <>{bars} bar{bars > 1 ? 's' : ''} · {s.bpm != null ? fmtBpm(s.bpm) : '?'} bpm
{s.start_s != null && <> · @{mmss(s.start_s)}</>}
{Math.abs(err) > 1 && <span className="text-wip"> · {err > 0 ? '+' : ''}{err.toFixed(0)}ms off</span>}</>
: <>{s.dur_s ? `${s.dur_s.toFixed(2)}s chop` : `${s.ch}ch ${(s.sr / 1000).toFixed(1)}k`}
{s.start_s != null && <> · @{mmss(s.start_s)}</>}</>}
</div>
</div>
<div className="flex items-center gap-1">
{s.tier && (
<span className="tnum font-mono text-[11px] px-1 rounded-sm"
style={{ color: TIER_COLOR[s.tier], border: `1px solid ${TIER_COLOR[s.tier]}55` }}
title={s.grade ? `grade ${s.grade.toFixed(3)}` : undefined}>
{s.tier}
</span>
)}
{s.flags.length > 0 && (
<span className="text-[11px] text-wip" title={s.flags.join(', ')}>!</span>
)}
</div>
{/* Tags UNDER the waveform, not over it. Absolutely positioned they covered the
first ~15% of every envelope — which is the downbeat, the one part of a loop
you actually need to see. */}
<div className="min-w-0">
<Waveform env={s.env} color={col} head={head} bars={bars} height={26} />
<div className="flex gap-1.5 h-[11px] overflow-hidden">
{tags.map(([t, p]) => (
<span key={t} className="text-[10px] leading-[11px] text-ink-faint/70 truncate"
title={`${t} (${(p * 100).toFixed(0)}%)`}>{t}</span>
))}
</div>
</div>
<div className="tnum font-mono text-[11px] text-ink-faint text-right">
{s.peak_dbfs.toFixed(1)} / {s.rms_dbfs.toFixed(0)}
</div>
<div className="flex items-center justify-end gap-0.5">
<button title="play once, untouched"
onClick={() => (playing === 'once' ? onStop() : onPlay('once'))}
className={`p-1 rounded-sm hover:bg-overlay ${playing === 'once' ? 'text-magenta' : 'text-ink-muted'}`}>
{playing === 'once' ? <Square size={13} /> : <Play size={13} />}
</button>
<button title="loop it — the only way to hear the seam"
onClick={() => (playing === 'loop' ? onStop() : onPlay('loop'))}
className={`p-1 rounded-sm hover:bg-overlay ${playing === 'loop' ? 'text-magenta' : 'text-ink-muted'}`}>
<Repeat size={13} />
</button>
<button title={inRack ? 'in the rack' : 'add to the rack'} onClick={onRack}
className={`p-1 rounded-sm hover:bg-overlay ${inRack ? 'text-ready' : 'text-ink-muted'}`}>
{inRack ? <Check size={13} /> : <Plus size={13} />}
</button>
</div>
</div>
)
}
/**
* The envelope of a sample, drawn from the 128 numbers `kitindex.py` precomputed.
*
* Deliberately not wavesurfer. A kit page shows a hundred-odd rows; a hundred
* wavesurfer instances means a hundred fetches and a hundred decodes to draw
* something 200 px wide, so the list would take a minute to appear and the audio
* thread would be fighting the render. The index already carries the envelope, so a
* row costs one <svg> and no network at all — audio is fetched only when a sample is
* actually played. (Same instinct as the tide-table's ship-the-index rule.)
*
* The playhead is a CSS transform driven by the deck's own clock, so it cannot drift
* away from what you are hearing even when React is busy.
*/
import { memo } from 'react'
type Props = {
env: number[]
color: string
/** 0..1, or null when this sample is not sounding */
head?: number | null
/** bar boundaries to rule, as fractions of the width */
bars?: number
height?: number
className?: string
}
export const Waveform = memo(function Waveform(
{ env, color, head = null, bars = 0, height = 34, className }: Props,
) {
const n = env.length || 1
const mid = height / 2
// one polygon, mirrored: cheaper than 128 rects and it reads as a waveform rather
// than as a bar chart, which matters when you are scanning for where the hit is
const top = env.map((v, i) => `${(i / n) * 100},${mid - (v / 100) * (mid - 1)}`)
const bot = env.map((v, i) => `${(i / n) * 100},${mid + (v / 100) * (mid - 1)}`).reverse()
return (
<svg className={className} viewBox={`0 0 100 ${height}`} height={height}
preserveAspectRatio="none" style={{ width: '100%', display: 'block' }}>
<polygon points={[...top, ...bot].join(' ')} fill={color} fillOpacity={0.75} />
{bars > 1 && Array.from({ length: bars - 1 }, (_, i) => (
<line key={i} x1={((i + 1) / bars) * 100} x2={((i + 1) / bars) * 100}
y1={0} y2={height} stroke="var(--hairline)" strokeWidth={0.3} />
))}
{head != null && (
<line x1={head * 100} x2={head * 100} y1={0} y2={height}
stroke="var(--ink)" strokeWidth={0.5} />
)}
</svg>
)
})
/**
* The scheduler's one musical rule, tested away from the audio graph.
*
* `hitsInBar` decides what plays and when. Get it wrong and the rack still makes
* sound — it just makes the wrong sound, with a groove that turns around in the wrong
* place, which is exactly the class of bug you discover mid-set.
*/
import { describe, expect, it } from 'vitest'
import { hitsInBar, barSeconds, CHOP_STEPS, PHRASE_BARS, type Voice } from '../engine'
const loop = (o: Partial<Voice> = {}): Voice => ({
id: 'k/x', url: '/x.wav', bars: 4, durS: 7.805, gain: 1, muted: false,
cells: Array.from({ length: PHRASE_BARS }, () => true),
...o,
})
describe('hitsInBar — bar loops', () => {
it('starts a 4-bar loop only on bars 0 and 4 of an 8-bar phrase', () => {
const v = loop({ bars: 4 })
const fires = Array.from({ length: PHRASE_BARS }, (_, b) => hitsInBar(v, b, 123).length)
expect(fires).toEqual([1, 0, 0, 0, 1, 0, 0, 0])
})
it('starts a 2-bar loop on every even bar, and a 1-bar loop on every bar', () => {
expect(Array.from({ length: 8 }, (_, b) => hitsInBar(loop({ bars: 2 }), b, 123).length))
.toEqual([1, 0, 1, 0, 1, 0, 1, 0])
expect(Array.from({ length: 8 }, (_, b) => hitsInBar(loop({ bars: 1 }), b, 123).length))
.toEqual([1, 1, 1, 1, 1, 1, 1, 1])
})
it('holds the voice for exactly its own bar count', () => {
const [h] = hitsInBar(loop({ bars: 4 }), 0, 123)
expect(h.holdS).toBeCloseTo(4 * barSeconds(123), 6)
expect(h.offsetS).toBe(0)
})
it('respects a cleared cell at the entry it would otherwise use', () => {
const cells = Array.from({ length: PHRASE_BARS }, () => true)
cells[4] = false
expect(hitsInBar(loop({ bars: 4, cells }), 0, 123)).toHaveLength(1)
expect(hitsInBar(loop({ bars: 4, cells }), 4, 123)).toHaveLength(0)
})
it('keeps the phrase when the transport runs past it, and when it runs backwards', () => {
// bar 8 is bar 0 of the next phrase; a negative index must not fall off the array
expect(hitsInBar(loop({ bars: 4 }), 8, 123)).toHaveLength(1)
expect(hitsInBar(loop({ bars: 4 }), 12, 123)).toHaveLength(1)
expect(() => hitsInBar(loop({ bars: 4 }), -3, 123)).not.toThrow()
})
it('plays nothing when muted or at zero gain', () => {
expect(hitsInBar(loop({ muted: true }), 0, 123)).toHaveLength(0)
expect(hitsInBar(loop({ gain: 0 }), 0, 123)).toHaveLength(0)
})
it('stretches to the DECK tempo, not the file\'s — that is what layering means', () => {
const v = loop({ bars: 4, durS: 7.805 }) // 4 bars at 123 bpm
expect(hitsInBar(v, 0, 123)[0].rate).toBeCloseTo(1, 3)
expect(hitsInBar(v, 0, 150)[0].rate).toBeCloseTo(150 / 123, 3)
})
})
describe('hitsInBar — chops', () => {
const chop = (cells: boolean[]): Voice =>
({ ...loop(), bars: 0, durS: 0.4, cells })
it('fires on every armed 16th of every bar', () => {
const cells = Array.from({ length: CHOP_STEPS }, (_, i) => i % 4 === 0)
const hits = hitsInBar(chop(cells), 0, 120)
expect(hits).toHaveLength(4)
const step = barSeconds(120) / CHOP_STEPS
expect(hits.map((h) => h.offsetS / step)).toEqual([0, 4, 8, 12])
// unlike a bar-loop, a chop is not constrained to a stride — bar 3 plays too
expect(hitsInBar(chop(cells), 3, 120)).toHaveLength(4)
})
it('never stretches a chop: a stab is a stab at any tempo', () => {
const cells = Array.from({ length: CHOP_STEPS }, (_, i) => i === 0)
expect(hitsInBar(chop(cells), 0, 90)[0].rate).toBe(1)
expect(hitsInBar(chop(cells), 0, 174)[0].rate).toBe(1)
})
it('pushes odd 16ths late by the swing fraction, and leaves even ones alone', () => {
const cells = Array.from({ length: CHOP_STEPS }, () => true)
const step = barSeconds(120) / CHOP_STEPS
const hits = hitsInBar(chop(cells), 0, 120, 0.25)
expect(hits[0].offsetS).toBeCloseTo(0, 9)
expect(hits[1].offsetS).toBeCloseTo(step * 1.25, 9)
expect(hits[2].offsetS).toBeCloseTo(step * 2, 9)
})
it('never holds a chop past the end of its buffer', () => {
const cells = Array.from({ length: CHOP_STEPS }, (_, i) => i === 0)
expect(hitsInBar(chop(cells), 0, 120, 0, 0.12)[0].holdS).toBe(0.12)
})
})
/**
* The export is the whole payoff, so it gets the tests.
*
* Everything else on this page fails loudly — a broken fetch shows an error, a bad
* decode throws. The export fails *silently*: it produces plausible Tidal that plays
* the wrong sample, at the wrong length, on an orbit that is already in use, and you
* find out on stage. So the rules that are easy to get backwards get pinned here.
*/
import { describe, expect, it } from 'vitest'
import { maskOf, stepsOf, toTidal, toStrudel, FAMILY_ORBIT } from '../tidal'
import { loopAtRate, barErrorMs, PHRASE_BARS, CHOP_STEPS, type Voice } from '../engine'
const voice = (o: Partial<Voice> = {}): Voice => ({
id: 'fred_marea_drums/03_kit_4b', url: '/x.wav', bars: 4, durS: 7.805,
gain: 1, muted: false,
cells: Array.from({ length: PHRASE_BARS }, (_, i) => i % 4 === 0),
...o,
})
describe('loopAt', () => {
it('is the ratio the deck plays and the number Tidal is told', () => {
// 4 bars at 123 bpm is 7.8049 s; a file already that long plays at rate 1
expect(loopAtRate(7.8049, 4, 123)).toBeCloseTo(1, 3)
// the same file asked to be 4 bars of 150 bpm has to run faster
expect(loopAtRate(7.8049, 4, 150)).toBeCloseTo(150 / 123, 3)
})
it('never divides by zero on a chop', () => {
expect(loopAtRate(0.4, 0, 123)).toBe(1)
})
it('reports how far off a whole bar a file is, in ms', () => {
expect(barErrorMs(7.8049, 4, 123)).toBeCloseTo(0, 1)
expect(barErrorMs(7.9049, 4, 123)).toBeCloseTo(100, 0)
})
})
describe('mask', () => {
it('is omitted when every entry is on — an all-t mask is noise in the source', () => {
expect(maskOf([true, false, false, false, true, false, false, false], 4, 8)).toBeNull()
})
it('samples the grid on the loop\'s own stride, not every bar', () => {
// a 4-bar loop in an 8-bar phrase has TWO entries. Reading all eight cells would
// emit `mask "t f f f f f f f"` and silence the second entry that is actually on.
const cells = [true, false, false, false, false, false, false, false]
expect(maskOf(cells, 4, 8)).toBe('t f')
expect(maskOf(cells, 1, 8)).toBe('t f f f f f f f')
})
it('renders a chop row as a structure string of the right length', () => {
const c = Array.from({ length: CHOP_STEPS }, (_, i) => i % 4 === 0)
expect(stepsOf(c).split(' ')).toHaveLength(CHOP_STEPS)
expect(stepsOf(c)).toBe('x ~ ~ ~ x ~ ~ ~ x ~ ~ ~ x ~ ~ ~')
})
})
describe('toTidal', () => {
const row = (o: Partial<Voice> = {}, family = 'drums', n = 3) =>
({ voice: voice(o), kit: 'fred_marea_drums', n, family })
it('emits the index and the bar count together, because both must be right', () => {
const out = toTidal([row()], 123)
expect(out).toContain('loopAt 4 $ s "fred_marea_drums" # n 3')
expect(out).toContain('setcps (0.5125)') // 123 bpm / 60 / 4
})
it('converts the linear fader to Tidal\'s quartic gain', () => {
// amp = gain^4, so a fader at half amplitude is gain 0.5^(1/4) = 0.84, not 0.5.
// Copying the fader straight across would be a 12 dB error.
expect(toTidal([row({ gain: 0.5 })], 123)).toContain('# gain 0.84')
// and unity is left off entirely rather than written as `# gain 1.00`
expect(toTidal([row({ gain: 1 })], 123)).not.toContain('# gain')
})
it('puts each family on its own orbit and never doubles up', () => {
const rows = [row({ id: 'a/1' }, 'drums', 0), row({ id: 'b/2' }, 'drums', 1)]
const lines = toTidal(rows, 123).split('\n').filter((l) => l.startsWith('d'))
const orbits = lines.map((l) => l.slice(0, 2))
expect(new Set(orbits).size).toBe(2) // two voices, two orbits
expect(orbits[0]).toBe(`d${FAMILY_ORBIT.drums}`)
})
it('skips muted rows and always ends on hush', () => {
const out = toTidal([row({ muted: true })], 123)
expect(out).not.toContain('loopAt')
expect(out.trimEnd().endsWith('hush')).toBe(true)
})
it('emits a chop as a structure, not as a loopAt', () => {
const out = toTidal([row({ bars: 0, cells: [true, false, false, false] })], 123)
expect(out).toContain('# n 3')
expect(out).not.toContain('loopAt')
})
})
describe('toStrudel', () => {
it('is silence when nothing is armed, never an empty stack', () => {
expect(toStrudel([], 123)).toBe('silence')
expect(toStrudel([{ voice: voice({ muted: true }), kit: 'k', n: 0, family: 'drums' }], 123))
.toBe('silence')
})
it('carries the same kit, index and bar count as the Tidal form', () => {
const out = toStrudel([{ voice: voice(), kit: 'fred_marea_drums', n: 3, family: 'drums' }], 123)
expect(out).toContain('s("fred_marea_drums").n(3).loopAt(4)')
})
})
/**
* The deck — a Web Audio transport that loops sample kits the way Tidal will.
*
* The point of this file is that auditioning a loop in a normal audio player tells
* you almost nothing about whether it is a good loop. A player stops at the end, so
* you never hear the seam; it plays at the file's own rate, so you never hear it
* against another loop; and it has no bar grid, so you never find out that the
* "4-bar" loop is 4.02 bars and walks away from the beat after a minute.
*
* So the rack schedules every voice explicitly on one grid, and sets
*
* rate = buffer.duration / (bars * 4 * 60 / bpm)
*
* which is exactly `loopAt bars` in Tidal: squeeze the file into that many bars of
* the current tempo. What you hear here is what `d1 $ loopAt 4 $ s "kit" # n 3`
* will do on the rig — the SPA is a test of the kit, not a preview of it.
*
* Two playback paths on purpose:
*
* - `audition` uses a single looping AudioBufferSourceNode at rate 1. One node, so
* drift cannot exist, and the file is heard UNTOUCHED — which is the only way to
* judge the seam the cut actually produced.
* - `Deck` schedules one node per bar per voice from a look-ahead clock. Zero drift
* by construction even when a loop is a millisecond off a bar, because every
* iteration is placed against the grid rather than against the previous iteration.
* That millisecond is real: the cutter's tolerance is 1 ms, which is 60 ms of skew
* after a minute if you let loops free-run.
*/
export type Voice = {
id: string // stable key: `${kit}/${name}`
url: string
bars: number // 0 = a chop: sub-bar, fired on the 16th grid, never stretched
durS: number
gain: number // 0..1.5, linear (this is a mixer fader, not Tidal's gain^4)
muted: boolean
cells: boolean[] // bar-loops: one per bar of the phrase. chops: 16 per bar.
}
export const PHRASE_BARS = 8
export const CHOP_STEPS = 16
const ctxRef: { ctx?: AudioContext } = {}
export function audioCtx(): AudioContext {
if (!ctxRef.ctx) ctxRef.ctx = new AudioContext({ latencyHint: 'playback' })
if (ctxRef.ctx.state === 'suspended') void ctxRef.ctx.resume()
return ctxRef.ctx
}
const buffers = new Map<string, Promise<AudioBuffer>>()
/** Decode once per URL. The rack re-triggers the same file every bar; re-fetching it
* would put a network request on the audio path. */
export function loadBuffer(url: string): Promise<AudioBuffer> {
let p = buffers.get(url)
if (!p) {
p = fetch(url)
.then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(new Error(`${r.status} ${url}`))))
.then((b) => audioCtx().decodeAudioData(b))
buffers.set(url, p)
p.catch(() => buffers.delete(url)) // a failed decode must not be cached as a result
}
return p
}
export const isLoaded = (url: string) => buffers.has(url)
export const barSeconds = (bpm: number) => (4 * 60) / bpm
/** `loopAt bars` — the rate that makes this file exactly `bars` long at `bpm`. */
export function loopAtRate(durS: number, bars: number, bpm: number): number {
if (!bars || durS <= 0) return 1
return durS / (bars * barSeconds(bpm))
}
/** How far off a whole bar the file is, in ms, at its own declared tempo. The number
* the cutter promises is under 1 ms; showing it is how you notice when it isn't. */
export function barErrorMs(durS: number, bars: number, bpm: number): number {
if (!bars || !bpm) return 0
const want = bars * barSeconds(bpm)
return (durS - want) * 1000
}
// ── auditioning one sample, untouched ────────────────────────────────────────
export type Audition = { stop: () => void; startedAt: number; durS: number }
export function audition(buf: AudioBuffer, opts: { loop: boolean; gain?: number }): Audition {
const ctx = audioCtx()
const src = ctx.createBufferSource()
src.buffer = buf
src.loop = opts.loop
const g = ctx.createGain()
g.gain.value = opts.gain ?? 1
src.connect(g).connect(ctx.destination)
const t0 = ctx.currentTime
src.start(t0)
return {
startedAt: t0,
durS: buf.duration,
stop: () => { try { src.stop() } catch { /* already ended */ } },
}
}
export type Hit = { offsetS: number; rate: number; holdS: number }
/**
* What this voice plays during bar `barIndex`, as offsets from the top of that bar.
*
* Extracted from the scheduler because it is the only part with a musical rule in it,
* and a rule you cannot test is a rule you find out about on stage. The rule: a
* bar-loop only starts on a bar that is a multiple of ITS OWN length. A 4-bar loop in
* an 8-bar phrase therefore has two possible entries, not eight — firing it on bar 3
* would play its bar 1 underneath the phrase's bar 4, which is the groove turning
* around in the wrong place, and not something anyone should have to reason about while
* performing. A chop has no such constraint: it is sub-bar, so it fires on any of the
* 16 steps, with odd steps optionally pushed late by `swing`.
*/
export function hitsInBar(v: Voice, barIndex: number, bpm: number, swing = 0,
bufDurS = Infinity): Hit[] {
if (v.muted || v.gain <= 0) return []
const bar = barSeconds(bpm)
const slot = ((barIndex % PHRASE_BARS) + PHRASE_BARS) % PHRASE_BARS
if (v.bars > 0) {
if (slot % v.bars !== 0) return []
if (!v.cells[slot]) return []
return [{ offsetS: 0, rate: loopAtRate(v.durS, v.bars, bpm), holdS: v.bars * bar }]
}
const step = bar / CHOP_STEPS
const hits: Hit[] = []
for (let i = 0; i < CHOP_STEPS; i++) {
if (!v.cells[i]) continue
hits.push({
offsetS: i * step + (i % 2 === 1 ? swing * step : 0),
rate: 1,
holdS: Math.min(bufDurS, step * 4),
})
}
return hits
}
// ── the rack ─────────────────────────────────────────────────────────────────
type Scheduled = { src: AudioBufferSourceNode; at: number }
/**
* A look-ahead scheduler. `tick` runs on a timer, but nothing it decides depends on
* WHEN it ran: every event is placed at an absolute AudioContext time derived from
* the transport origin, so a late timer produces the same sound as an early one (or
* drops the event, if it is already too late — better a hole than a flam).
*/
export class Deck {
bpm = 123
swing = 0 // 0..0.5 — delay of odd 16ths, as a fraction of a 16th
private voices = new Map<string, { v: Voice; buf: AudioBuffer }>()
private timer?: number
private t0 = 0 // ctx time of bar 0 of the phrase
private nextBar = 0 // next bar index not yet scheduled
private live: Scheduled[] = []
private master: GainNode
onBar?: (barInPhrase: number) => void
static LOOKAHEAD = 0.35 // s of audio scheduled in advance
static TICK_MS = 60
constructor() {
const ctx = audioCtx()
this.master = ctx.createGain()
this.master.gain.value = 0.8
this.master.connect(ctx.destination)
}
get playing() { return this.timer !== undefined }
get masterGain() { return this.master.gain.value }
setMaster(v: number) { this.master.gain.value = v }
setVoice(v: Voice, buf: AudioBuffer) { this.voices.set(v.id, { v, buf }) }
removeVoice(id: string) { this.voices.delete(id) }
clear() { this.voices.clear() }
/** Where the playhead is, as a bar index within the phrase, or -1 when stopped. */
position(): number {
if (!this.playing) return -1
const el = audioCtx().currentTime - this.t0
return Math.floor(el / barSeconds(this.bpm)) % PHRASE_BARS
}
start() {
if (this.playing) return
const ctx = audioCtx()
this.t0 = ctx.currentTime + 0.12 // a beat of slack so bar 0 is not already late
this.nextBar = 0
this.timer = window.setInterval(() => this.tick(), Deck.TICK_MS)
this.tick()
}
stop() {
if (this.timer !== undefined) window.clearInterval(this.timer)
this.timer = undefined
for (const s of this.live) { try { s.src.stop() } catch { /* ended */ } }
this.live = []
}
private tick() {
const ctx = audioCtx()
const bar = barSeconds(this.bpm)
const until = ctx.currentTime + Deck.LOOKAHEAD
while (this.t0 + this.nextBar * bar < until) {
this.scheduleBar(this.nextBar, this.t0 + this.nextBar * bar)
this.nextBar++
}
this.live = this.live.filter((s) => s.at > ctx.currentTime - 30)
this.onBar?.(((this.nextBar - 1) % PHRASE_BARS + PHRASE_BARS) % PHRASE_BARS)
}
private scheduleBar(barIndex: number, at: number) {
for (const { v, buf } of this.voices.values()) {
for (const h of hitsInBar(v, barIndex, this.bpm, this.swing, buf.duration)) {
this.fire(buf, at + h.offsetS, v, h.rate, h.holdS)
}
}
}
private fire(buf: AudioBuffer, at: number, v: Voice, rate: number, holdS: number) {
const ctx = audioCtx()
if (at < ctx.currentTime) return // too late: a hole beats a flam
const src = ctx.createBufferSource()
src.buffer = buf
src.playbackRate.value = rate
const g = ctx.createGain()
g.gain.value = v.gain
// A 3 ms release on the tail. Cutting a voice at a hard sample boundary is a
// click, and a rack of eight clicking voices sounds like the kit is broken when
// it is the player that is.
const end = at + Math.min(holdS, buf.duration / rate)
g.gain.setValueAtTime(v.gain, Math.max(at, end - 0.003))
g.gain.linearRampToValueAtTime(0, end)
src.connect(g).connect(this.master)
src.start(at)
src.stop(end + 0.005)
this.live.push({ src, at })
}
}
/**
* The rack, written out as a `.tidal` block you can paste into a live file.
*
* This is the only reason the sequencer earns its place: clicking cells is a fast way
* to find out whether four loops belong together, but the answer is worthless unless
* it survives the trip to the rig. So the export is not a pretty-printer — it emits
* ParVagues' own idioms, on the orbits the kit's family maps to, with the mask
* pattern the cells describe, so the block plays the same thing the browser did.
*
* `loopAt` is the hinge: the deck plays every bar-loop at `dur / (bars * barlen)`,
* which is what `loopAt bars` does, so the same two numbers appear in both places.
*/
import { PHRASE_BARS, CHOP_STEPS, type Voice } from './engine'
/** Which `dN` a family belongs on, following the channel convention in `live/`. */
export const FAMILY_ORBIT: Record<string, number> = {
drums: 1, hits: 2, bass: 3, tonal: 5, vox: 6, fx: 8,
}
/** `mask "t f t t"` for a bar-loop row; `"t ~ f ~"`-style for a chop row.
* Returns null when every cell is on — a mask of all `t` is noise in the source. */
export function maskOf(cells: boolean[], stride: number, len: number): string | null {
const used: boolean[] = []
for (let i = 0; i < len; i += stride) used.push(!!cells[i])
if (used.every(Boolean)) return null
return used.map((b) => (b ? 't' : 'f')).join(' ')
}
/** A chop row's 16 steps as a Tidal structure string: `"x ~ ~ x ~ ~ x ~"`. */
export function stepsOf(cells: boolean[]): string {
return Array.from({ length: CHOP_STEPS }, (_, i) => (cells[i] ? 'x' : '~')).join(' ')
}
export type ExportRow = { voice: Voice; kit: string; n: number; family?: string | null }
export function toTidal(rows: ExportRow[], bpm: number, opts?: { swing?: number }): string {
const cps = bpm / 60 / 4
const out: string[] = [
`-- from the kit auditioner · ${bpm} bpm`,
`setcps (${cps.toFixed(4)}) -- ${bpm} bpm`,
'',
]
const usedOrbits = new Set<number>()
const nextOrbit = (family?: string | null) => {
let d = FAMILY_ORBIT[family ?? ''] ?? 4
while (usedOrbits.has(d)) d = (d % 12) + 1
usedOrbits.add(d)
return d
}
for (const r of rows) {
if (r.voice.muted) continue
const d = nextOrbit(r.family)
// The mixer fader is linear; Tidal's `gain` is quartic (amp = gain^4), so a fader
// at 0.5 is `gain (0.5 ** 0.25)` = 0.84, not `gain 0.5`. Getting this backwards is
// a 12 dB error and the reason the rack's fader is not just copied across.
const gain = r.voice.gain === 1 ? '' : ` # gain ${Math.pow(r.voice.gain, 0.25).toFixed(2)}`
if (r.voice.bars > 0) {
const m = maskOf(r.voice.cells, r.voice.bars, PHRASE_BARS)
const mask = m ? `mask "${m}" $ ` : ''
out.push(`d${d} $ ${mask}loopAt ${r.voice.bars} $ s "${r.kit}" # n ${r.n}${gain}`
+ ` -- ${r.voice.id.split('/')[1]}`)
} else {
const st = stepsOf(r.voice.cells)
out.push(`d${d} $ s "${st}" # s "${r.kit}" # n ${r.n}${gain}`
+ ` -- chop, ${r.voice.durS.toFixed(2)}s`)
}
}
if (opts?.swing) out.push('', `-- swing ${(opts.swing * 100).toFixed(0)}% — nudge by ear, `
+ `Tidal has no direct equivalent (try \`(# nudge 0.01)\` on the odd steps)`)
out.push('', 'hush')
return out.join('\n')
}
/** The same rack as a Strudel one-liner, for the browser playground (strudel.cc) or
* a `@strudel/web` pane. Emitted as text: the deck below already plays this rack, so
* a second audio graph would only add a way for the two to disagree. */
export function toStrudel(rows: ExportRow[], bpm: number): string {
const live = rows.filter((r) => !r.voice.muted)
if (!live.length) return 'silence'
const parts = live.map((r) => {
const g = r.voice.gain === 1 ? '' : `.gain(${r.voice.gain.toFixed(2)})`
if (r.voice.bars > 0) {
const m = maskOf(r.voice.cells, r.voice.bars, PHRASE_BARS)
const mask = m ? `.mask("<${m.replace(/ /g, ' ')}>")` : ''
return `s("${r.kit}").n(${r.n}).loopAt(${r.voice.bars})${mask}${g}`
}
return `s("${stepsOf(r.voice.cells).replace(/x/g, r.kit)}").n(${r.n})${g}`
})
return `setcps(${(bpm / 60 / 4).toFixed(4)})\nstack(\n ${parts.join(',\n ')}\n)`
}
/** The shape `tools/foundry/kitindex.py` writes to `public/kits.json`. */
export type Tier = 'S' | 'A' | 'B' | 'C' | 'D'
export type Family = 'drums' | 'hits' | 'bass' | 'tonal' | 'vox' | 'fx'
export type Sample = {
n: number // the index SuperDirt will answer to: `# n <n>`
name: string
file: string
url: string
sr: number
ch: number
peak_dbfs: number
rms_dbfs: number
env: number[]
// present only for kits the Foundry cut — a hand-made kit has none of this
bars: number | null
bpm: number | null
start_s: number | null // where in the source track it was cut from
dur_s: number | null
tier: Tier | null
grade: number | null
family: Family | null
stem_role: string | null
track: string | null
mode: 'loop' | 'chop' | null
flags: string[]
tags: Record<string, [string, number][]>
}
export type Kit = {
kit: string
n_samples: number
source: 'foundry' | 'legacy'
track: string | null
family: Family | null
bpm: number | null
samples: Sample[]
}
export type KitIndex = {
generated: string
root: string
peaks: number
kits: Kit[]
}
/** Family → the role colour it shares with the orbit rail elsewhere on the bridge. */
export const FAMILY_COLOR: Record<string, string> = {
drums: 'var(--color-percs)',
hits: 'var(--color-blocked)',
bass: 'var(--color-bass)',
tonal: 'var(--color-melodic)',
vox: 'var(--color-vox)',
fx: 'var(--color-atmos)',
}
export const familyColor = (f?: string | null) =>
FAMILY_COLOR[f ?? ''] ?? 'var(--color-ink-faint)'
/** Tempo, to two decimals, trailing zeros dropped: 123.05, 132.5, 120.
* One formatter so the transport, the rack header and every row agree — the same tempo
* printed two ways reads as a disagreement in the data. */
export const fmtBpm = (b: number) => String(Math.round(b * 100) / 100)
export const TIER_COLOR: Record<string, string> = {
S: 'var(--color-ready)', A: 'var(--color-tops)', B: 'var(--color-wip)',
C: 'var(--color-ink-faint)', D: 'var(--color-blocked)',
}
......@@ -26,6 +26,12 @@ function loadMounts(): [string, string][] {
* seek the big FLACs without copying them into the bundle. In production,
* `serve.py --dir dist` reads the same mounts, keeping /audio stable everywhere.
*/
const AUDIO_MIME: Record<string, string> = {
'.flac': 'audio/flac', '.wav': 'audio/wav', '.aif': 'audio/aiff',
'.aiff': 'audio/aiff', '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg',
'.opus': 'audio/ogg', '.m4a': 'audio/mp4',
}
function audioDevServer(): Plugin {
const mounts = loadMounts()
return {
......@@ -43,7 +49,11 @@ function audioDevServer(): Plugin {
const size = fs.statSync(file).size
const range = req.headers.range
res.setHeader('Accept-Ranges', 'bytes')
res.setHeader('Content-Type', 'audio/flac')
// by extension, not a constant: the masters are FLAC but the sample kits are
// WAV, and a wrong Content-Type is the kind of thing that works in one browser
// and silently refuses to decode in the next. serve.py uses guess_type for the
// same reason.
res.setHeader('Content-Type', AUDIO_MIME[path.extname(file).toLowerCase()] || 'application/octet-stream')
if (range) {
const m = /bytes=(\d*)-(\d*)/.exec(range)
const start = m && m[1] ? parseInt(m[1], 10) : 0
......@@ -76,6 +86,8 @@ export default defineConfig({
judge: path.resolve(__dirname, 'judge.html'),
// and the boundary lab — where exactly does one track become the next
bounds: path.resolve(__dirname, 'bounds.html'),
// La Cale — audition the sample kits the Foundry cuts, and rack them up
kits: path.resolve(__dirname, 'kits.html'),
},
},
},
......
__pycache__/
*.pyc
.pytest_cache/
# kitindex envelope cache — derived, keyed on file size+mtime
.kitindex-cache.json
......@@ -137,6 +137,102 @@ GET /media/<slug>/<path> → audio (HTTP Range / 206 — seek
---
## Producer stem packs (`engine.stempack`)
Not every source needs separating. A producer stem pack — Fred again..'s dropbox is the
worked example — arrives already split, by the human who mixed it, into stems named
`MAREA MIX10 123BPM KIT STEM.wav`. That is better material than demucs can produce and
completely outside what `find_takes` accepts, so `engine/stempack.py` is the batch path
for it (TODO #20).
```bash
cd tools/foundry
python3 -m engine.stempack "<pack dir>" --jobs 9 --no-link --out /tmp/cut # cut
python3 kitgate.py /tmp/cut/fred_kits.json # gate
python3 kitgate.py /tmp/cut/fred_kits.json --link # …then link
```
Cut with `--no-link` and let `kitgate.py` decide. It re-runs `grade` over the written
files (same verdict by a different code path — a disagreement means one of them is
wrong), runs the `kitcheck` audit, and checks for **bank shadowing before anything is
linked**: `loadSoundFiles` defaults to `appendToExisting = false`, so a folder whose
basename matches an existing bank frees that bank's buffers and takes the name, with no
error and no symptom except an old track playing the wrong sound.
`restage.py` applies the same kit-wide gain staging to kits already on disk — including
hand-cut ones, which carry whatever level their source had:
```bash
python3 restage.py --all-under ~/Work/Sound/Samples --dry-run
```
One kit per **track × role family** (`fred_marea_drums`, `fred_marea_vox`), files named
`NN_<producer role>_<bars>b.wav`, linked into Dirt-Samples so `s "fred_marea_drums"`
resolves. `fred_kits.tidal` is a paste-ready cheat sheet with the `n` index of every
sample, because a kit you cannot address is a kit you will not use.
### Four things this had to get right
**Resample before analysing.** Packs like this ship at 96 kHz, and librosa's hop/window
defaults are sample-rate-relative — at 96 k every analysis frame spans half the musical
time and beat tracking wanders. MAREA declares its tempo in the filename, so it is free
ground truth: at 96 kHz the finder returned 123/123/124/**119**/**128.1**/**128.5** bpm
with a 0.661 top score; at 44.1 kHz it returned 123.0–123.1 on every candidate with
0.859, in a third of the time. Rate is a correctness input, not a performance knob.
**One tempo per track.** `analyze_stem` reports each candidate's bpm as the mean local
BPM inside its own window — right for detecting drift, wrong for deriving a bar length,
because then every loop gets its own tempo and two loops from one track do not layer.
Bar length comes from the *mode* of the local-BPM histogram, with a confidence (the
share of beats within 5 % of it). Below 35 % there is no pulse worth cutting bars
against and the track ships chops only — ME, which has no drum stem at all, is the case
that motivated it.
**Modes rank in separate pools.** `grade` scores anything under 0.75 s as a one-shot on
`0.5·level + 0.3·dc + 0.2·zc` — no seam, no bar term. Export already removes DC and snaps
zero crossings, so chops land near 1.0 while loops pay 0.35 on seam and 0.25 on bar
consistency. Head to head, chops take every slot: measured on ME, chops 0.993–0.999,
loops 0.841–0.881, and the vocal kit came out as three stabs with no loops in it.
**Stage the level, because `amp = gain^4`.** Producer stems come off a loud master —
MAREA's ALL DRUMS and KIT both peak at +0.2 dBFS — so a loop cut from them arrives at full
scale, which `grade` calls clipping and SuperDirt cannot use: `# gain 1.2` is +7.6 dB and
the orbit clips the moment you touch it. Kits land with their hottest file at −6 dBFS, as
**one gain for the whole kit** (per-file normalisation would make a sparse hat loop as loud
as a full drum bus and throw away the balance two loops from one kit otherwise have for
free) and **never boosting** (a quiet stem is quiet on purpose).
**The label is evidence, not truth — and neither is the measurement.** `roles.py` maps
the pack's ~40 role tokens to a family and then measures the stem. Presence and bass are
decided by measurement (a stem under −40 dBFS peak has nothing in it; "all energy under
150 Hz, nothing above 2 kHz" is unambiguous). Everything else keeps the producer's label,
because onset density cannot separate a rhythmic rhodes from a drum bus — MAREA's KEYS2
reads 5.1 onsets/s and its sparse CLAP reads 1.0, so measurement alone would file both
in the wrong kit, silently. Disagreements are reported rather than resolved.
### What `kitcheck` catches that `grade` cannot
`grade` answers *is this mechanically a clean loop*. All of these pass it:
- **dead bars** — a 4-bar loop whose last two bars are a fade has a silent-to-silent
seam and an exact length, so it grades S. Per-bar RMS finds it.
- **not actually a loop** — the finder cuts on a beat grid, so its windows are always
bar-*aligned*; nothing checked they were a bar-length *unit*. Onset-envelope
autocorrelation at the bar lag does.
- **near-duplicates** — ALL DRUMS, KIT, MAIN DRUM LOOP and DRUM BREAKS are four views of
one groove, and the finder picks the same bar out of all four. Eight slots holding
three sounds is a worse kit than three. Dedup runs at export; `kitcheck` re-checks with
the same fingerprint and threshold so the two cannot disagree.
Two things about the periodicity test are worth knowing before reading its numbers.
Autocorrelation at lag *k* has only *N−k* overlapping frames, so normalising by `ac[0]`
scores identical material lower the shorter the loop — measured 0.418 / 0.623 / 0.754 at
2 / 4 / 8 bars on a perfect click track, which is length, not rhythm. And the floor is
**per family**, because the medians split hard by role (drums 0.754, fx 0.549, tonal 0.214,
bass 0.173, vox 0.087): a drum loop that does not repeat at the bar is broken, while a sung
phrase that does not repeat within itself is a sung phrase. One threshold for both condemned
12 of 20 vocal loops for being vocal.
## Engine roadmap — engine1 → engine2
The separation backend is a **registry** (`engine/separate.py`); the CLI/GUI ask
......
# The Foundry — TODO
Resume state for the URL→audio→stems→loops sampling tool. Snapshot **2026-06-29**.
Resume state for the URL→audio→stems→loops sampling tool. Snapshot **2026-08-21**.
Task IDs map to the session task board. Design: memory `project_foundry`.
## 🔵 In progress
......@@ -43,8 +43,90 @@ Task IDs map to the session task board. Design: memory `project_foundry`.
other top-3 BEFORE B/B/B → AFTER **S/S/S** (bad favorite @56.74 demoted out)
+2 rerank regression tests + 1 sparse-percussion test; suite 57→60 green.
## 🟢 Landed (2026-08-21) — #20 batch, via producer stem packs
`engine/stempack.py` + `engine/roles.py` + `engine/kitcheck.py` + `kitgate.py` +
`restage.py`, branch `claude/fred-kits`, 14 commits, suite 86 → 112.
Full write-up: `armada/tasks/027-the-label-and-the-lens.md`, memory
`reference_stempack` + `feedback_label_and_lens`.
```bash
cd tools/foundry
python3 -m engine.stempack "<pack>" --jobs 9 --no-link --out /tmp/cut
python3 kitgate.py /tmp/cut/fred_kits.json # then --link
```
## 🟡 In progress — finish the Fred pack (RESUME HERE)
**Staging root is `~/Work/Sound/Samples/Fred/output/<kit>/`** (PLN asked for it there,
2026-08-21), passed as `--samples-root`; Dirt-Samples symlinks point into it.
Runs 1-5 were all discarded, each superseded by a fix found by looking at its output:
161 samples (pre-dead-bar-fix), 153 (pre-rotation-fix), 153 (**24 of them digital
silence graded A/B**), aborted (wrong staging root), 129 (silence filtered at export
only, so BIGHEN's tonal kit lost 3 loops instead of gaining 3 real ones). **Run 6** is
the first with the finder itself refusing silent windows.
```bash
cd tools/foundry
python3 -u -m engine.stempack "$HOME/Work/Sound/Samples/Fred/fred again stems" \
--no-link --samples-root "$HOME/Work/Sound/Samples/Fred/output" --out <fresh dir>
```
`--jobs` now defaults to half the cores (children run at nice 19 / SCHED_IDLE — PLN
works on this machine). Takes ~20-30 min, fully deterministic.
**Exact steps to finish, in order:**
1. `python3 restage.py --cuts <out>/fred_kits.json --dry-run` — expect **0 kits**
changed (export stages, and restage has a 0.05 dB dead band so it is idempotent).
2. `python3 kitgate.py <out>/fred_kits.json` — five checks, must be green on grade /
kitcheck / shadowing / `n` indices. `rights` is advisory and WILL warn until step 3.
3. `python3 ../../armada/tide-table/register_fred_rights.py <out>/fred_kits.json`
— every `fred_*` bank as `third_party` **with provenance**, so per-track release
blocks by design. Before linking.
4. `python3 kitgate.py <out>/fred_kits.json --link` (takes each kit's root from the
manifest), then `python3 ../bank-check.py --since 1` to confirm 0 shadowed.
5. `python3 kitindex.py --root "$HOME/Work/Sound/Samples/Fred/output" --root \
"$HOME/Work/Sound/Samples" --cuts <out>/fred_kits.json` — the index La Cale reads.
6. Copy `fred_kits.{json,md,tidal}` + `kitcheck.md` into `packs/fred/` and commit
(`packs/README.md` — manifest, not audio).
7. Report: per-kit tier histogram, distinct-sound count, the "worth a second listen"
table, and the paste-ready `n` indices.
**Ground truth:** MAREA declares `123BPM` in its filenames; the grid must read
**123.0**. PLEASEMAKEITBETTER reads 20% tempo confidence and correctly ships
chops only.
## 🟢 Landed (2026-08-21) — #21 La Cale, the kit auditioner
`armada/ui/kits.html` + `src/kits/` + `tools/foundry/kitindex.py`. Browse every kit
under the sample root (the 111 that predate the Foundry included), audition a sample
untouched or looped, rack several on one bar grid at a chosen tempo, export a
paste-ready `.tidal` block. The rack plays `rate = dur / (bars * barlen)` — literally
`loopAt` — so what you hear is what the rig does. 13 vitest cases on the export.
```bash
cd armada/ui && nvm use 22 && npm ci && npm run dev # /kits.html
python3 ../serve.py --dir dist --port 8792 # LAN / phone
cd ../../tools/foundry && python3 kitindex.py --all # regenerate the index
```
Open follow-ups, none blocking: the Strudel tab emits text only (no `@strudel/web` —
the deck already plays the rack and two audio graphs could disagree); the rack has no
swing control wired to the UI though the engine supports it; and `?kits=fred_*` narrows
the rail but there is no way to save a rack as a named preset.
## 🔴 Open
- [ ] **#20 — Batch-explore many sources** (Foundry over the full corpus + merge).
- [ ] **#22 — Point stempack at the rest.** Same driver, no new code:
the 13 ACAPELLAS + 13 INSTRUMENTALS in `~/Work/Sound/Samples/Fred/` (already
44.1 kHz, so no resample; acapellas want chops mode), and the ~27 demucs catches in
`~/Downloads/separated/htdemucs/`. Each needs a `--prefix`.
- [ ] **#23 — Run `kitcheck`/`restage` over the whole existing corpus.** The dead-bar and
periodicity tests are general, and `Samples/` has 163 kits that have never been
audited; hand-cut kits also carry whatever level their source had.
`python3 restage.py --all-under ~/Work/Sound/Samples --dry-run` is the first look.
Expect "grades S, useless" to be non-rare.
- [ ] **#20b — Batch-explore the merged corpus** (the original #20 framing).
Workflow is one-catch-at-a-time; build batch exploration:
- Batch driver: `find_takes` over MANY source stems (the ~30 in
`~/Downloads/separated/htdemucs` + new fetches) → grade → surface the best
......
......@@ -40,6 +40,14 @@ THRESH = {
"rms_lo_dbfs": -28.0, # A-profile moderate-level band …
"rms_hi_dbfs": -12.0, # … RMS in here is ideal
"clip_dbfs": -0.1, # peak at/above this ⇒ clipping
# Peak below this ⇒ there is no audio here. NOT a taste threshold: every other
# sub-score is undefined on silence (the seam between two silences is perfect, the
# DC of silence is zero, the zero crossings are trivially fine), so a silent window
# scores 0.90 × perfect + 0.10 × nothing and comes out tier A. Measured on Fred
# again..'s pack: 24 of 153 cut loops were digital silence at -104…-75 dBFS peak,
# graded 0.707-0.896, and the next file up was -47.1 — a 28 dB empty gap, so the
# floor is placed inside it rather than tuned.
"empty_dbfs": -60.0,
"bars": (1, 2, 4, 8), # B-rule bar interpretations to test
"beats_per_bar": 4,
"tempo_lo": 60.0,
......@@ -256,6 +264,19 @@ def grade_array(y: np.ndarray, sr: int, *, path: str = "<array>",
grade = sum(w[k] * sub[k] for k in w)
flags: list[str] = []
# Presence is a PRECONDITION, not a weighted term. Everything above is a measure of
# how cleanly this audio loops, and all of it is vacuously true of silence — so the
# composite has a degenerate optimum at "nothing", which the finder duly walked into.
# A loop nobody can hear is not an A-grade loop whatever its seam looks like.
# Measured on the CHANNELS, not the mono sum: a deliberately wide stereo file can
# have almost no mono content — an anti-phase pair sums to exact zero — and calling
# that "empty" would both mis-report it and suppress the mono-incompatible flag that
# is the actually useful thing to say about it.
file_peak_db = float(20 * np.log10(float(np.max(np.abs(y))) + _EPS))
empty = file_peak_db < THRESH["empty_dbfs"]
if empty:
flags.append("empty")
grade = 0.0
if one_shot:
flags.append("one-shot")
if abs(dc) > THRESH["dc_ok"]:
......@@ -266,9 +287,15 @@ def grade_array(y: np.ndarray, sr: int, *, path: str = "<array>",
flags.append("off-grid")
if clipped:
flags.append("clipping")
if rms_db < -45:
if rms_db < -45 and not empty:
# RMS this low with a real peak means SPARSE, not silent — a hat loop with peaks
# at -14.7 reads -48.8 RMS because RMS measures how much silence it contains
# (`feedback_right_lens_per_control`). Advisory; `empty` is the blocking one.
flags.append("near-silent")
if (role == "bass" or y.ndim > 1) and corr < 0.2:
if (role == "bass" or y.ndim > 1) and corr < 0.2 and not empty:
# Interchannel correlation of two independent noise floors is ~0, so silence
# reads as maximally mono-incompatible; 4 of MAREA's flagged vox loops were
# just empty.
flags.append("mono-incompatible")
metrics = {
......
......@@ -196,6 +196,28 @@ def _true_seam(y: np.ndarray, s_smp: int, e_smp: int, sr: int, n_beats: int = 0)
return seam
# Amplitude below which a window has nothing in it. One number with `grade`, by import,
# so the finder and the grader cannot disagree about what silence is.
_EMPTY_AMP = 10.0 ** (G.THRESH["empty_dbfs"] / 20.0)
def _beat_peaks(y: np.ndarray, sr: int, times: np.ndarray) -> np.ndarray:
"""Peak amplitude in each inter-beat segment, so the presence gate is free.
A window's peak is the max over the beats it spans, and the segments partition
`[int(times[i]*sr), int(times[e]*sr))` exactly — the same samples the naive
`max(abs(slice))` would visit — so this is not an approximation. It just does the
O(samples) work once for the whole stem instead of once per candidate: at 8 bars a
window is ~700 k samples and there are thousands of windows, which turned a free
check into the most expensive line in the loop.
"""
mono = y if y.ndim == 1 else np.abs(y).max(axis=1)
a = np.abs(mono)
edges = [int(t * sr) for t in times]
return np.array([float(a[s:e].max()) if e > s else 0.0
for s, e in zip(edges, edges[1:])] + [0.0], dtype=np.float64)
def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8,
grid=None, weights=None, verify=True) -> list[LoopCandidate]:
"""Rank loop candidates within one stem.
......@@ -223,6 +245,7 @@ def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8,
ssm = beat_sync_ssm(y, sr, peaks) # peaks are onset-envelope frames
n = ssm.shape[0]
nov = np.maximum(foote_novelty(ssm, 2), foote_novelty(ssm, 8)) if n else np.zeros(nb)
beat_peak = _beat_peaks(y, sr, times) # for the presence gate, computed once
cands: list[LoopCandidate] = []
for B in bars:
......@@ -237,6 +260,15 @@ def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8,
sl = y[s_smp:e_smp]
if sl.size < 8:
continue
# Presence, BEFORE ranking. Every term below is vacuously perfect on silence
# — a seam between two silences is immaculate, its zero crossings are trivial,
# a flat SSM block is maximally self-similar — so the composite's optimum is
# "no audio" and a stem with long gaps spends all its candidate slots there.
# Rejecting at export (via `grade`) is not enough: BIGHEN's strings stem had
# all six of its top windows inside a rest, so the kit went from four silent
# loops to one real one instead of to four real ones.
if float(np.max(beat_peak[i:e])) < _EMPTY_AMP:
continue
seam, _ = G.seam_score(sl)
zc, _, _ = G.zc_score(sl, sr)
struct = _structural(ssm, i, e)
......@@ -473,8 +505,44 @@ ROTATE_ATTACK_WIN_S = 0.030 # attack window each side of a beat slot
ROTATE_LOWBAND_HZ = 200.0 # downbeat = strongest LOW-band attack (kick-weighted)
def _downbeat_slot_scores(clip: np.ndarray, sr: int, n_beats: int) -> np.ndarray:
"""Per-slot downbeat score: low-band circular attack + a small full-band term.
Factored out of `_rotate_to_downbeat` so the rotation can CHECK ITS OWN RESULT with
the identical scorer instead of a lookalike — measuring a rotation with a slightly
different window or weighting produces a disagreement that is the measurement's fault
(`feedback_check_the_instrument_first`).
"""
from scipy.signal import butter, sosfiltfilt
c2 = clip if clip.ndim == 2 else clip[:, None]
n = c2.shape[0]
mono = c2.mean(axis=1).astype(np.float64)
sos = butter(4, ROTATE_LOWBAND_HZ / (sr / 2), btype="low", output="sos")
low = sosfiltfilt(sos, mono)
w = max(8, int(ROTATE_ATTACK_WIN_S * sr))
def _rms(x: np.ndarray, a: int, b: int) -> float: # circular slice RMS
idx = np.arange(a, b) % n
return float(np.sqrt(np.mean(x[idx] ** 2) + 1e-12))
out = np.empty(n_beats)
for k in range(n_beats):
p = int(round(k * n / n_beats))
out[k] = ((_rms(low, p, p + w) - _rms(low, p - w, p))
+ 0.25 * (_rms(mono, p, p + w) - _rms(mono, p - w, p)))
return out
def _downbeat_miss(clip: np.ndarray, sr: int, n_beats: int) -> float:
"""How far slot 0 is from being the best slot: 0.0 = it IS the downbeat, 1.0 = worst."""
s = _downbeat_slot_scores(clip, sr, n_beats)
rng = float(s.max() - s.min())
return 0.0 if rng <= 0 else float((s.max() - s[0]) / rng)
def _rotate_to_downbeat(clip: np.ndarray, sr: int, n_beats: int,
force_slot: Optional[int] = None) -> tuple[np.ndarray, int, int]:
force_slot: Optional[int] = None,
passes: int = 3) -> tuple[np.ndarray, int, int]:
"""Rotate a bar-exact, clean-seam loop so the musical downbeat sits at position 0.
PLN's ear-finding (2026-07-11): loops came out 'timed ok but cut BCDA/DABC' — the PLP
......@@ -498,7 +566,22 @@ def _rotate_to_downbeat(clip: np.ndarray, sr: int, n_beats: int,
from the most rhythmic stem of the window and applied to all of them; independent
per-stem rotation could shift their relative groove by whole beats).
`passes` (default 3): rotate, then CHECK, and rotate again if slot 0 still is not the
strongest attack. One pass is not enough on real music, because the roll point is
zero-crossing snapped (up to ~10 ms, against a 20 ms attack window) and that is enough
to flip the winner between two near-tied slots. Measured over 60 shipped bar-loops from
the Fred pack: after one pass, slot 0 was the best slot in 29 and in the WORSE HALF of
slots in 10 — one loop in six starting on a weak beat, which is the "timed ok but cut
BCDA/DABC" complaint this function exists to fix. With up to three passes and a
strictly-improving guard: best slot in 46, worse half in 1, median miss 0.032 → 0.000.
Iterating is safe because rotation is free (see above): two rolls compose into one, and
the guard means a pass that does not improve the measured miss is discarded, so this
cannot oscillate.
`clip` is (n,) or (n, ch). Returns (rotated, best_slot, roll_samples); slot 0 ⇒ no-op.
`best_slot`/`roll_samples` describe the FIRST pass, so a caller re-deriving a shared
rotation with `force_slot` sees what it saw before.
"""
c2 = clip if clip.ndim == 2 else clip[:, None]
n = c2.shape[0]
......@@ -506,23 +589,7 @@ def _rotate_to_downbeat(clip: np.ndarray, sr: int, n_beats: int,
return clip, 0, 0
mono = c2.mean(axis=1).astype(np.float64)
if force_slot is None:
from scipy.signal import butter, sosfiltfilt
sos = butter(4, ROTATE_LOWBAND_HZ / (sr / 2), btype="low", output="sos")
low = sosfiltfilt(sos, mono)
w = max(8, int(ROTATE_ATTACK_WIN_S * sr))
def _rms(x: np.ndarray, a: int, b: int) -> float: # circular slice RMS
idx = np.arange(a, b) % n
return float(np.sqrt(np.mean(x[idx] ** 2) + 1e-12))
best_k, best_score = 0, -np.inf
for k in range(n_beats):
p = int(round(k * n / n_beats))
att_low = _rms(low, p, p + w) - _rms(low, p - w, p)
att_full = _rms(mono, p, p + w) - _rms(mono, p - w, p)
score = att_low + 0.25 * att_full
if score > best_score:
best_score, best_k = score, k
best_k = int(np.argmax(_downbeat_slot_scores(clip, sr, n_beats)))
else:
best_k = int(force_slot) % n_beats
if best_k == 0:
......@@ -531,6 +598,22 @@ def _rotate_to_downbeat(clip: np.ndarray, sr: int, n_beats: int,
rotated = np.roll(c2, -r, axis=0)
return (rotated if clip.ndim == 2 else rotated[:, 0]), best_k, r
# ── the check-and-repeat pass (see `passes` above) ────────────────────────
if force_slot is None and passes > 1:
cur, miss = out, _downbeat_miss(out, sr, n_beats)
for _ in range(passes - 1):
if miss <= 0.02: # slot 0 already is the downbeat
break
nxt, slot, _roll = _rotate_to_downbeat(cur, sr, n_beats, passes=1)
if slot == 0:
break
m = _downbeat_miss(nxt, sr, n_beats)
if m >= miss: # no improvement: keep what we have
break
cur, miss = nxt, m
out = cur
return out, best_k, r
def _pocket_zc(mono: np.ndarray, idx: int, sr: int, *,
back_s: float = 0.025, fwd_s: float = 0.002) -> int:
......
# packs/ — the record of what was cut
One directory per source pack, holding the **manifest and its views** for a cut that
actually shipped:
```
packs/<pack>/
fred_kits.json # the manifest — every sample, where it came from, its grade + tags
fred_kits.md # the report: per-track probe verdicts, tempo, per-kit tables
fred_kits.tidal # the cheat sheet — paste-ready, `n` index of every sample
kitcheck.md # the audit
```
The audio itself lives under a **staging root**, symlinked into Dirt-Samples, never
here — this is provenance, not a copy. Where that root is, is per-pack: kits cut from
someone else's stems are staged beside their source (`Samples/Fred/output/<kit>/`, via
`--samples-root`) so the pack stays one movable, deletable thing, while ParVagues' own
kits sit directly under `Samples/<kit>/`. Either way the bank name is the directory
name, so `s "<kit>"` does not care which. The manifest is the record; `.md` and
`.tidal` are views of it and can be rebuilt at any time with:
```bash
python3 -m engine.stempack --report-only --out packs/<pack>
```
Keeping the manifest is what makes a cut auditable months later: which stem a loop came
from, at what tempo, graded how, and what CLAP heard in it.
This source diff could not be displayed because it is too large. You can view the blob instead.
# kit check — 150 samples, 144 clean, 6 flagged
## `fred_angie_tonal`
-`03_padstuff_2b` — weak bar periodicity (0.00 < 0.05 for tonal) — bar-aligned but not a repeating unit
## `fred_angieinterlude_bass`
-`00_pad_2b` — weak bar periodicity (0.03 < 0.05 for bass) — bar-aligned but not a repeating unit
## `fred_bighen_tonal`
-`01_synth_2b` — weak bar periodicity (0.02 < 0.05 for tonal) — bar-aligned but not a repeating unit
## `fred_marea_drums`
-`10_marea_4b` — weak bar periodicity (0.12 < 0.30 for drums) — bar-aligned but not a repeating unit
-`11_marea_4b` — weak bar periodicity (0.08 < 0.30 for drums) — bar-aligned but not a repeating unit
## `fred_yasminah_tonal`
-`00_keys_4b` — weak bar periodicity (0.02 < 0.05 for tonal) — bar-aligned but not a repeating unit
**Distinct sounds:** 150 of 150 (0 near-duplicates)
\ No newline at end of file
#!/usr/bin/env python3
"""restage — apply kit-wide gain staging to sample kits that are already on disk.
`engine.stempack` stages a kit's level as it exports, but kits cut before that (or by
hand, in Audacity) carry whatever level their source had. Producer stems come off a loud
commercial master and land at full scale, which SuperDirt cannot use: amp = gain^4, so
`# gain 1.2` is +7.6 dB and the orbit clips the moment you touch it.
This applies the SAME function `stempack` uses at export (`kit_headroom_gain` — one gain
per kit, attenuate only), so a kit restaged here is bit-identical to one cut with the
fix in place. Reads and rewrites in place, 24-bit, and reports what it changed.
Idempotent: a kit already within `DEAD_BAND_DB` of the ceiling is left untouched,
so re-running never requantizes audio for a gain change nobody can hear.
python3 restage.py <kit dir> [<kit dir> …] # explicit kits
python3 restage.py --cuts fred_kits.json # every kit named in a cut manifest
python3 restage.py --all-under ~/Work/Sound/Samples --dry-run
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
import soundfile as sf
sys.path.insert(0, str(Path(__file__).resolve().parent))
from engine.stempack import HEADROOM_CEILING_DB, kit_headroom_gain # noqa: E402
# An attenuation smaller than this is not applied. Export stages in float and writes
# PCM_24, so the peak that comes back off disk differs from the one it aimed at by up to
# a quantization step — enough that a freshly-staged kit measures a hair ABOVE the
# ceiling and asks to be staged again, by -0.00003 dB, forever. Each of those rounds is a
# real read-modify-write and a fresh 24-bit requantization of every file in the kit, so
# the non-idempotence is not merely untidy. 0.05 dB is inaudible and four orders of
# magnitude above the quantization floor.
DEAD_BAND_DB = 0.05
def _peak_db(path: Path) -> float:
y, _ = sf.read(str(path), always_2d=True, dtype="float32")
return float(20 * np.log10(np.max(np.abs(y)) + 1e-12))
def restage_kit(kit_dir: Path, *, ceiling_db: float = HEADROOM_CEILING_DB,
dry_run: bool = False) -> dict:
"""Bring one kit's hottest file to the ceiling, moving every file by the same gain."""
wavs = sorted(p for p in kit_dir.glob("*.wav") if not p.name.startswith("."))
if not wavs:
return {"kit": kit_dir.name, "files": 0, "gain_db": 0.0, "changed": False}
peaks = [_peak_db(p) for p in wavs]
g = kit_headroom_gain(peaks, ceiling_db)
gain_db = 20 * np.log10(g) if g > 0 else 0.0
if g >= 1.0 or -gain_db < DEAD_BAND_DB:
return {"kit": kit_dir.name, "files": len(wavs), "gain_db": 0.0,
"changed": False, "peak_before": max(peaks)}
if not dry_run:
for p in wavs:
y, sr = sf.read(str(p), always_2d=True, dtype="float32")
sf.write(str(p), y * g, sr, subtype="PCM_24")
return {"kit": kit_dir.name, "files": len(wavs), "gain_db": round(float(gain_db), 2),
"changed": True, "peak_before": round(max(peaks), 2),
"peak_after": round(max(peaks) + float(gain_db), 2)}
def _regrade_manifest(cuts_path: Path, kits: set[str]) -> None:
"""Re-grade the restaged files and write the new grades back into the manifest."""
from engine import grade as G
cuts = json.loads(cuts_path.read_text())
n = 0
for c in cuts:
if c["kit"] not in kits or not Path(c["path"]).exists():
continue
g = G.grade(c["path"], role=c.get("family"))
c["grade"], c["tier"] = g.grade, g.tier
c["flags"] = list(g.flags)
n += 1
cuts_path.write_text(json.dumps(cuts, indent=2))
print(f" re-graded {n} entries in {cuts_path.name}")
def main(argv=None) -> int:
ap = argparse.ArgumentParser(prog="restage", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("kits", nargs="*", type=Path)
ap.add_argument("--cuts", type=Path, help="a stempack cuts JSON; restage every kit in it")
ap.add_argument("--all-under", type=Path, help="every immediate subdirectory of this path")
ap.add_argument("--ceiling", type=float, default=HEADROOM_CEILING_DB)
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args(argv)
dirs: list[Path] = list(a.kits)
if a.cuts:
cuts = json.loads(a.cuts.read_text())
dirs += sorted({Path(c["path"]).parent for c in cuts})
if a.all_under:
dirs += sorted(d for d in a.all_under.iterdir() if d.is_dir())
dirs = sorted({d.resolve() for d in dirs if d.is_dir()})
if not dirs:
ap.error("no kit directories given")
rows = [restage_kit(d, ceiling_db=a.ceiling, dry_run=a.dry_run) for d in dirs]
changed = [r for r in rows if r["changed"]]
# Whoever changes the audio owns the record of it. Restaging moves every grade that
# has a level term in it, so a manifest left alone would describe bytes that no longer
# exist — and `kitgate` compares the two precisely to catch that kind of drift.
if a.cuts and changed and not a.dry_run:
_regrade_manifest(a.cuts, {r["kit"] for r in changed})
for r in changed:
print(f" {r['kit']:34.34} {r['files']:3} files "
f"{r['peak_before']:+6.1f} → {r['peak_after']:+6.1f} dBFS "
f"({r['gain_db']:+.1f} dB)")
verb = "would restage" if a.dry_run else "restaged"
print(f"{verb} {len(changed)} of {len(rows)} kits "
f"(ceiling {a.ceiling:+.1f} dBFS; kits already below it are left alone)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
......@@ -64,9 +64,42 @@ def test_clipping_flagged():
assert "clipping" in G.grade_array(y, sr).flags
def test_near_silent_flagged():
def test_silence_is_empty_not_merely_near_silent():
"""-62 dBFS peak is not quiet material, it is nothing — and nothing used to grade A.
Every other sub-score is vacuously perfect on silence (a seam between two silences,
zero DC, trivial zero crossings), so the composite's optimum was "no audio at all"
and the finder walked straight into it: 24 of 153 loops cut from the Fred pack were
digital silence at -104…-75 dBFS, graded 0.707-0.896."""
y, sr = _sine(amp=0.0008) # ~ -62 dBFS
assert "near-silent" in G.grade_array(y, sr).flags
g = G.grade_array(y, sr)
assert "empty" in g.flags
assert g.grade == 0.0 and g.tier == "D"
def test_a_sparse_loop_is_near_silent_but_not_empty():
"""RMS and peak answer different questions. A hat loop with peaks at -14.7 dBFS reads
-48.8 RMS because RMS measures how much SILENCE a sparse signal contains — that is
advisory, not disqualifying, and it must not be confused with an empty file."""
sr, rng = 44100, np.random.RandomState(0)
y = np.zeros(sr * 4)
for i in range(4): # four very short ticks in four seconds
j = i * sr
y[j:j + 32] = 0.3 * rng.randn(32) * np.hanning(32)
g = G.grade_array(y, sr)
assert g.metrics["peak_dbfs"] - g.metrics["rms_dbfs"] > 30 # a real sparse crest
g = G.grade_array(y, sr)
assert "near-silent" in g.flags # RMS is way down …
assert "empty" not in g.flags # … but there is clearly audio here
assert g.grade > 0.0
def test_an_anti_phase_pair_is_mono_incompatible_not_empty():
"""The presence test has to look at the channels, not the mono sum: L/-R sums to
exact zero, and calling that "empty" would suppress the one flag worth raising."""
mono, sr = _sine(periods=200)
g = G.grade_array(np.stack([mono, -mono]), sr)
assert "empty" not in g.flags
assert "mono-incompatible" in g.flags
def test_mono_compatible_vs_anti_phase():
......
......@@ -357,3 +357,108 @@ def test_beat_grid_recovers_tempo_not_its_octave():
assert len(times) >= 8
med = float(np.median(bpm))
assert 100 <= med <= 140 # not the 240 octave
def test_rotation_checks_its_own_work_and_repeats():
"""One rotation pass is not enough on real music. The roll point is zero-crossing
snapped by up to ~10 ms against a 20 ms attack window, which is enough to flip the
winner between two near-tied slots — so the loop can come out starting on a weak
beat, the "timed ok but cut BCDA/DABC" failure this function exists to prevent.
Measured over 60 shipped bar-loops: slot 0 was the best slot in 29 and in the worse
half in 10 after one pass; 46 and 1 with the check-and-repeat.
"""
import numpy as np
from engine import loops as L
sr, bpm, bars = 44100, 120.0, 4
nb = bars * 4
n = int(round(bars * 4 * 60.0 / bpm * sr))
beat = n // nb
y = np.zeros(n)
for b in range(nb): # strongest kick deliberately on slot 4
i = b * beat
env = np.exp(-np.arange(beat) / (sr * 0.04))
y[i:i + beat] += (np.sin(2 * np.pi * 55 * np.arange(beat) / sr) * env
* (1.0 if b == 4 else 0.35))
clip = y.astype(np.float32)[:, None]
rot, slot, _ = L._rotate_to_downbeat(clip, sr, nb)
assert slot == 4 # found the real downbeat
assert L._downbeat_miss(rot, sr, nb) <= 0.02 # and it now sits at 0
# rotation is free: a bar-exact loop keeps its length and its wrap
assert rot.shape == clip.shape
# idempotent — a rotated loop is already on its downbeat
_, slot2, _ = L._rotate_to_downbeat(rot, sr, nb)
assert slot2 == 0
def test_the_downbeat_scorer_is_shared_so_a_check_cannot_disagree():
"""The rotation and its verification must use the same scorer; a lookalike with a
different window produces disagreements that are the measurement's fault."""
import numpy as np
from engine import loops as L
sr, nb = 44100, 8
rs = np.random.RandomState(0)
y = (rs.randn(sr * 2) * 0.1).astype(np.float32)
s = L._downbeat_slot_scores(y, sr, nb)
assert s.shape == (nb,)
assert 0.0 <= L._downbeat_miss(y, sr, nb) <= 1.0
def test_the_finder_will_not_spend_its_candidate_slots_on_silence():
"""A stem that plays for a few bars and then rests has a degenerate optimum: every
term in the composite — structural self-similarity, seam, zero crossings — is
vacuously perfect across the rest. Measured on BIGHEN's strings stem, all six top
windows sat inside a gap, so filtering at export left the kit with one loop where it
should have had four.
The grid has to come from ANOTHER stem for this to reproduce, which is also how
`stempack` calls it: a per-stem grid finds no beats in the silence, so no window is
ever generated there and the bug cannot appear. Share a rhythmic track's grid — the
whole point of doing so — and every stem gets windows across the full duration,
including the parts where it is not playing.
"""
import numpy as np
from engine import loops as L
sr = 22050
bpm, bars = 120.0, 4
bar_s = 4 * 60.0 / bpm
def ticks(n_bars, seed):
r = np.random.RandomState(seed)
out = np.zeros(int(round(n_bars * bar_s * sr)), dtype=np.float32)
step = int(round(bar_s / 4 * sr))
env = np.exp(-np.arange(step) / (sr * 0.03))
for i in range(0, len(out) - step, step):
out[i:i + step] += (r.randn(step) * env * 0.4).astype(np.float32)
return out
drums = ticks(32, 1) # plays throughout: the grid source
strings = np.concatenate([ticks(8, 2), # plays for 8 bars …
np.zeros(len(drums) - int(round(8 * bar_s * sr)),
dtype=np.float32)]) # … then rests
assert len(strings) == len(drums)
grid = L.beat_grid(drums, sr)
cands = L.analyze_stem(strings, sr, bars=(bars,), top_n=6, grid=grid)
assert cands, "the played section should still yield candidates"
played_s = 8 * bar_s
assert all(c.start_s < played_s for c in cands), \
[(c.start_s, c.score) for c in cands]
def test_the_beat_peak_table_is_exact_not_an_approximation():
"""The presence gate reads a per-beat peak table instead of scanning each candidate
window, which is only legitimate if it returns the same number. The inter-beat
segments partition the window exactly, so it must — and if that ever stops being
true the gate would start rejecting real material with no visible symptom."""
import numpy as np
from engine import loops as L
sr = 8000
rng = np.random.RandomState(3)
y = (rng.randn(sr * 6) * 0.2).astype(np.float32)
times = np.arange(0, 6.0, 0.5)
bp = L._beat_peaks(y, sr, times)
for i in range(len(times) - 4):
e = i + 4
naive = float(np.max(np.abs(y[int(times[i] * sr):int(times[e] * sr)])))
assert float(np.max(bp[i:e])) == naive, (i, e)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment