Commit ec200263 by PLN (Algolia)

feat(cale): a page for hearing whether a kit is any good

PLN asked for an SPA to audition the cut samples, "click-based looping not just
strudel coding". La Cale — `armada/ui/kits.html`, the hold, where the cargo gets
checked before it goes on stage.

The reason it needs to exist is that a grade in a manifest is a claim, and this
week the claim has been wrong twice in ways no report caught: a loop that fades to
silence grades S, and 24 loops of pure digital silence graded A. Both are obvious
in two seconds of listening and invisible in a table.

**Auditioning is two different tests, so it is two buttons.** Play-once gives you
the file untouched — attack, tail, what it actually is. Loop plays it seamlessly at
rate 1, which is the only way to hear the seam, and the seam is the thing that
decides whether a loop is usable because it is the sound the audience gets every
bar. One AudioBufferSourceNode with `loop = true`, so drift cannot exist and
nothing is resampled between the file and the speaker.

**The rack is where a kit is actually judged**, because the questions that matter —
do these two loops sit together, does this vocal survive on that break, is the bass
really at this tempo — cannot be answered one file at a time. It schedules every
voice from a look-ahead clock against an absolute grid, at

    rate = buffer.duration / (bars * 4 * 60 / bpm)

which is exactly `loopAt bars`. So the layering you hear is the layering the rig
plays, and the exported block is not an approximation of the session — it is the
same two numbers in Tidal's syntax. Scheduling per bar rather than letting nodes
free-run matters more than it sounds: the cutter's bar tolerance is 1 ms, which is
60 ms of skew after a minute.

The grid means what Tidal means. A bar-loop row has cells only on multiples of its
own length — a 4-bar loop in an 8-bar phrase has two entries, not eight, because
firing it on bar 3 plays its bar 1 under the phrase's bar 4 — and those cells
become `mask "t f"`. A chop row is 16 steps in a bar and becomes `s "x ~ ~ x …"`.

Three things the export gets right that are easy to get wrong, all pinned by tests:
the fader is linear but Tidal's gain is quartic, so 0.5 exports as `gain 0.84` and
copying it across would be a 12 dB error; families land on their own `dN` with no
two voices sharing an orbit; and the mask samples the grid on the loop's stride,
not every bar, which otherwise silences an entry that is switched on.

Deliberately not wavesurfer, though the project already depends on it: a kit page
shows a hundred rows, and a hundred instances means a hundred fetches and decodes
to draw something 200 px wide. `kitindex.py` precomputes a 128-point envelope per
sample, so a row costs one `<svg>` and no network — audio is fetched only when
something is played. Same ship-the-index instinct as the tide-table.

`kitindex.py` merges two sources on purpose. The FOLDER is the truth about what
exists (via `pvbanks.playable`, so it agrees with kitgate and the watcher), which
is why the 111 kits that predate the Foundry are browsable too. A cut MANIFEST is
the truth about what a file MEANS — tier, family, bars, bpm, CLAP tags — and none
of it is re-derived here, so the page and the gate cannot disagree. `n` comes from
the folder listing, never the manifest order, because `n` is what SuperDirt plays.

Two staleness traps closed while building it: the envelope cache is keyed on
path+size+mtime, so a restaged kit misses it (a stale envelope over fresh audio is
a lie that looks like data), and the index is written to `dist/` as well as
`public/`, because `npm run build` copies public/ once and the LAN page would
otherwise show an index from before the last cut.

Also: the dev server hardcoded `Content-Type: audio/flac` for everything under
/audio. The masters are FLAC; the kits are WAV. Now by extension, matching
serve.py's `guess_type`.

13 vitest cases on the export; `npm test`. Verified end to end through the real
path — `serve.py --dir ui/dist` returns 200 on /kits.html and 206 `audio/x-wav`
on a Range request through the new `samples` mount.
parent f9a9ce53
......@@ -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>
......@@ -10,6 +10,7 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@fontsource-variable/geist-mono": "^5.2.8",
"@strudel/web": "^1.3.0",
"@wavesurfer/react": "^1.0.12",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
......@@ -32,7 +33,8 @@
"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"
}
},
"node_modules/@babel/code-frame": {
......@@ -227,6 +229,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
......@@ -571,6 +582,31 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kabelsalat/core": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@kabelsalat/core/-/core-0.4.0.tgz",
"integrity": "sha512-5zV8nh8HffW8aexObXs5pE0xgL0jb1cHc3o8UN6AvK0mBt87fjZvW65E4rMslHyq+OuCscoBJW7B3MbYfWrwMQ==",
"license": "AGPL-3.0-or-later"
},
"node_modules/@kabelsalat/lib": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@kabelsalat/lib/-/lib-0.4.1.tgz",
"integrity": "sha512-gBCjrZKD9huTKNJuBC6BXM4PMQSg8otL8A/vp8j98P9v6yWTX1TuyaqdLg/1PrIAMV4hlWoav9ZD3YMFlrvouw==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@kabelsalat/core": "0.4.0"
}
},
"node_modules/@kabelsalat/web": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@kabelsalat/web/-/web-0.4.1.tgz",
"integrity": "sha512-ASkFhePJLx3GjadYOueI7sHKXBbRPk/a1pfslFeQNI9gU7yZq/KrnkmOmLrgrtixAsy7gyi1vWqkmRRvH3Ki6w==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@kabelsalat/core": "0.4.0",
"@kabelsalat/lib": "0.4.1"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
......@@ -864,6 +900,112 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@strudel/core": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@strudel/core/-/core-1.2.6.tgz",
"integrity": "sha512-FA5Kxv3U+UhNRLIeuEpXck2o2J5AjHZbkAdSAK9Q2N3Az1G9Vnc+bXXfgDFyY3yRH9mXXQvTerz8jr876pGGng==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@kabelsalat/web": "^0.4.1",
"fraction.js": "^5.2.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@strudel/draw": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@strudel/draw/-/draw-1.2.6.tgz",
"integrity": "sha512-7UPJ4xgDi2bt12KWSVAEt4rZjcOajxhHBJikvNA3LQT5IA/FTfD+TRSNu0CSB7zhmFM0Njgq6t7ttgsC8fdKCg==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel/core": "1.2.6"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@strudel/mini": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@strudel/mini/-/mini-1.2.6.tgz",
"integrity": "sha512-I5GFPLHI3TPcJuD2RdDcurA+lTogaSQ0TB+EimKTgEtSAbU6ZMEkSUOXZIFO5eaAcJcMucgKaBcmTKpD4oZjPA==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel/core": "1.2.6"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@strudel/tonal": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@strudel/tonal/-/tonal-1.2.6.tgz",
"integrity": "sha512-I2ev0IJFmqvDt/vFkL8vIDXNanK5EZF+MNhlio3nHt1d6ngZZJyCNzC1OxoaiQKxZNN7+mpGIC3Cx/zcBC3weQ==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel/core": "1.2.6",
"@tonaljs/tonal": "^4.10.0",
"chord-voicings": "^0.0.1",
"webmidi": "^3.1.12"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@strudel/transpiler": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@strudel/transpiler/-/transpiler-1.2.6.tgz",
"integrity": "sha512-NzmyQDS4BqjafOPzHGFowxILDx3ZRAPD47YszxNaPB7PggMA2rMo9t3rLz80G0GxA0ZAyMbVMR3px9jDWnJ5hg==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel/core": "1.2.6",
"@strudel/mini": "1.2.6",
"acorn": "^8.14.0",
"escodegen": "^2.1.0",
"estree-walker": "^3.0.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@strudel/web": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@strudel/web/-/web-1.3.0.tgz",
"integrity": "sha512-iob9+OrZMM5oMQmDNQAXc6+CYnzKaOUeARs8fcRRxDDSvCxewmZf/ancwyqVSCJsVzgCgw+I9PkpFyhSpLS3FQ==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel/core": "1.2.6",
"@strudel/mini": "1.2.6",
"@strudel/tonal": "1.2.6",
"@strudel/transpiler": "1.2.6",
"@strudel/webaudio": "1.3.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@strudel/webaudio": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@strudel/webaudio/-/webaudio-1.3.0.tgz",
"integrity": "sha512-H23KJfxA5l2woG0TwNf+VMvjbj9SwR1y1qgzJW26Z35/9c5pbVs2IHPUpFwP0dWoA/4dmG2cmTMwI1e/TL8kGA==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@strudel/core": "1.2.6",
"@strudel/draw": "1.2.6",
"superdough": "1.3.0",
"supradough": "1.2.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
......@@ -1136,6 +1278,462 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tonaljs/abc-notation": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz",
"integrity": "sha512-2fDUdPsFDdgZgyIiZCTYGKy30QiwIQxSXCSN2thGrSMXbQKCp8iTC8haStYcrA+25MPWhWlmvC/pz3tGcTNAqQ==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/array": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/@tonaljs/array/-/array-4.8.4.tgz",
"integrity": "sha512-97HVdpZy82PqNBDMM9PRSbO2DUrnxNg3++N4xqLpfby70fKHAHhTWrMXWZK+Dzs76HDPQLd+qhd4cq28eBZzjw==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/chord": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/@tonaljs/chord/-/chord-4.10.2.tgz",
"integrity": "sha512-Zlqtq6c6T4y+EqvwHRQp9icEjHqyniCpnIwwCFg5amgAQwXmWzarouOOSmcdJ1Is92eEvcPVuCoLiVUtajS30A==",
"license": "MIT",
"dependencies": {
"@tonaljs/chord-detect": "^4.8.1",
"@tonaljs/chord-type": "^4.8.2",
"@tonaljs/collection": "^4.8.0",
"@tonaljs/core": "^4.10.0",
"@tonaljs/pcset": "^4.8.2",
"@tonaljs/scale-type": "^4.8.2"
}
},
"node_modules/@tonaljs/chord-detect": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@tonaljs/chord-detect/-/chord-detect-4.9.1.tgz",
"integrity": "sha512-rV/9+R7aZ9cQorQ3jdNMMMh63onosglYZM71Q0n7KKcWMAGrxF66MzxBG82xy+w1QDMJQslB3iHfDHUiS6wRjA==",
"license": "MIT",
"dependencies": {
"@tonaljs/chord-type": "5.1.1",
"@tonaljs/pcset": "4.10.1",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/chord-detect/node_modules/@tonaljs/chord-type": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@tonaljs/chord-type/-/chord-type-5.1.1.tgz",
"integrity": "sha512-ti4WzRYvvjH7to0G3zlJFq7WsjHqmcqbk8Jv98aZSR5YumLdY/ua2yOPPyoPq82n6vfgjZsacnAZ3v8/SodOcw==",
"license": "MIT",
"dependencies": {
"@tonaljs/pcset": "4.10.1"
}
},
"node_modules/@tonaljs/chord-type": {
"version": "4.8.2",
"resolved": "https://registry.npmjs.org/@tonaljs/chord-type/-/chord-type-4.8.2.tgz",
"integrity": "sha512-GzSTjQmZjkUdPhyesFDeJbxpW8R7L/bAE34E8ApGAh9FMcKYpRdX3Tn+gkluRsXOiRMfW07p3E0Adx4bjtyN+Q==",
"license": "MIT",
"dependencies": {
"@tonaljs/core": "^4.8.0",
"@tonaljs/pcset": "^4.8.2"
}
},
"node_modules/@tonaljs/collection": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@tonaljs/collection/-/collection-4.9.0.tgz",
"integrity": "sha512-Mk0h7O54nT6PgNVcUYauzxa5KOB23+0AOKudWzRH7JhJIN9vhVIC7PtwZXE+/G051UTbHSFIcN/afkgF4nB/8A==",
"license": "MIT"
},
"node_modules/@tonaljs/core": {
"version": "4.10.4",
"resolved": "https://registry.npmjs.org/@tonaljs/core/-/core-4.10.4.tgz",
"integrity": "sha512-PhGlQ2Js6rFQ6CufkSiBpEJoPS8QIIhbXSXhT4U+5ffqckli+YBZRN24vjZ4AEKuX8xoYDmCCbl+r6agauvzmw==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.1",
"@tonaljs/pitch-distance": "5.0.2",
"@tonaljs/pitch-interval": "5.0.2",
"@tonaljs/pitch-note": "5.0.3"
}
},
"node_modules/@tonaljs/core/node_modules/@tonaljs/pitch-distance": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-distance/-/pitch-distance-5.0.2.tgz",
"integrity": "sha512-DPxfGJCf4BvfIVRxl2v142tuCKIziM6PomOBT0/s7U5o+Z5qFAIW0tNx/MKyL83guV74p+XIf5VI0w1flmXxDA==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.1",
"@tonaljs/pitch-interval": "5.0.2",
"@tonaljs/pitch-note": "5.0.3"
}
},
"node_modules/@tonaljs/core/node_modules/@tonaljs/pitch-note": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-note/-/pitch-note-5.0.3.tgz",
"integrity": "sha512-JsPgqPa8VZdZtfwvgoHJz996BZqzvlnRxUF6c/Q9q8E0iq30UHBEid0Y6XldK+h6tuIENsG3a9jyvNNxRqIeYw==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.1"
}
},
"node_modules/@tonaljs/duration-value": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@tonaljs/duration-value/-/duration-value-4.9.0.tgz",
"integrity": "sha512-Muz54HyIe0nMYKWx6wyTa4y17ma29DtpJF4/oqJphy6A124rAVDe/SKit8JGOvDYAQj71FUXqs17sXBxO/ExVw==",
"license": "MIT"
},
"node_modules/@tonaljs/interval": {
"version": "4.8.2",
"resolved": "https://registry.npmjs.org/@tonaljs/interval/-/interval-4.8.2.tgz",
"integrity": "sha512-9SEuJuqFdmu9lnvMmJtxbReQcQvZ1YHXhCcxaF6Re23/w+BqiJvvkvNZhfWH+n1+g0uaSdTsuvMfamp7hAvPMw==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "^5.0.1",
"@tonaljs/pitch-distance": "^5.0.2",
"@tonaljs/pitch-interval": "^5.0.2"
}
},
"node_modules/@tonaljs/key": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@tonaljs/key/-/key-4.11.2.tgz",
"integrity": "sha512-fc1y5+NwS4S3amirzYLLB324PxJDO00oIBbLJclAddc46UBeYnu4FNz+1nZboHCXRQ/06/ftuaWD/l7HrAoulw==",
"license": "MIT",
"dependencies": {
"@tonaljs/note": "4.12.1",
"@tonaljs/pitch-note": "6.1.0",
"@tonaljs/roman-numeral": "4.9.1"
}
},
"node_modules/@tonaljs/midi": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/@tonaljs/midi/-/midi-4.10.2.tgz",
"integrity": "sha512-MPamXhEwPL7L1udLfYMm3Ft8mLYtHr62Zi2w5zYHM2P7YwIvNoiX0+dvAN5is1Wvq4iVRa8AjFHerjOW/SZhGg==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/mode": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/@tonaljs/mode/-/mode-4.9.2.tgz",
"integrity": "sha512-Il48fWX9SnGMzwNFVbizkWkr/SJ+aCIIHhjWfSf1agrQAoq81536qBXEyEyvia6aqEXC4OaXAQ5rSoBcmQRj1Q==",
"license": "MIT",
"dependencies": {
"@tonaljs/collection": "4.9.0",
"@tonaljs/interval": "5.1.0",
"@tonaljs/pcset": "4.10.1",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-note": "6.1.0",
"@tonaljs/scale-type": "4.9.2"
}
},
"node_modules/@tonaljs/mode/node_modules/@tonaljs/interval": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/interval/-/interval-5.1.0.tgz",
"integrity": "sha512-GR9dUjn0j7yhjwjRh8HQxZYXOiVl05WfY3AFyMB9rfg807K4dSJmWfPTULPQXyHJ6NiZOPXcwRs8MxMLDxgdbg==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "^5.0.2",
"@tonaljs/pitch-distance": "^5.0.4",
"@tonaljs/pitch-interval": "^6.0.0"
}
},
"node_modules/@tonaljs/mode/node_modules/@tonaljs/pitch": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
"integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
"license": "MIT"
},
"node_modules/@tonaljs/mode/node_modules/@tonaljs/pitch-interval": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
"integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2"
}
},
"node_modules/@tonaljs/note": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@tonaljs/note/-/note-4.12.1.tgz",
"integrity": "sha512-yh6cnhu21bUb0VuIQWxObSbWBVp2cHIkJj4zZ4qsNOW4jSqn74+wHpSbOTDF8q+2hl6upz7TtBRavtqwPtLUCQ==",
"license": "MIT",
"dependencies": {
"@tonaljs/midi": "4.10.2",
"@tonaljs/pitch": "5.0.2",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-interval": "6.1.0",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/note/node_modules/@tonaljs/pitch": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
"integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
"license": "MIT"
},
"node_modules/@tonaljs/note/node_modules/@tonaljs/pitch-interval": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
"integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2"
}
},
"node_modules/@tonaljs/pcset": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/@tonaljs/pcset/-/pcset-4.10.1.tgz",
"integrity": "sha512-CZG1rpKc38yMfpEJsbDTvsTmQqsek9xxcuMgK64Tr6sP1lEiDuZJbQeKVWWRnreBF2FQ1cEGLmfOpvSO+40csA==",
"license": "MIT",
"dependencies": {
"@tonaljs/collection": "4.9.0",
"@tonaljs/pitch": "5.0.2",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-interval": "6.1.0",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/pcset/node_modules/@tonaljs/pitch": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
"integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
"license": "MIT"
},
"node_modules/@tonaljs/pcset/node_modules/@tonaljs/pitch-interval": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
"integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2"
}
},
"node_modules/@tonaljs/pitch": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.1.tgz",
"integrity": "sha512-5HYkF4hGY0jS2y5V3Hf5gNFXX46kT4cAcI7JLEn+qQb9N1dU9Gz9koI9di0mD1Tbam+kviceiCsJl4WX/FqYjA==",
"license": "MIT"
},
"node_modules/@tonaljs/pitch-distance": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-distance/-/pitch-distance-5.0.5.tgz",
"integrity": "sha512-dTfjsU0zyrj5YmiFio5prPaD5w7sBmHp4nnmlEg70nHY+SerAH0KiO9HM9usttVgRFUaXl0Gc7OI8YMGfSFmug==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2",
"@tonaljs/pitch-interval": "6.1.0",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/pitch-distance/node_modules/@tonaljs/pitch": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
"integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
"license": "MIT"
},
"node_modules/@tonaljs/pitch-distance/node_modules/@tonaljs/pitch-interval": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
"integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2"
}
},
"node_modules/@tonaljs/pitch-interval": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-5.0.2.tgz",
"integrity": "sha512-bQmxeenRFNuuABs9mDc+bSUp3ySGw8I49pHMoGDdCcro9n3MDN8IsSwhvKoGOYErFMx76rNn1t+7WL8ukpvX5w==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.1"
}
},
"node_modules/@tonaljs/pitch-note": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-note/-/pitch-note-6.1.0.tgz",
"integrity": "sha512-A4OSLo8DjM38u73862LnDmL4YInDDRBmg0fojXcvu4cyU3oOlqndyeHOra1OVoH/WW46uNIxNs1wJDZNPWL5KQ==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2"
}
},
"node_modules/@tonaljs/pitch-note/node_modules/@tonaljs/pitch": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
"integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
"license": "MIT"
},
"node_modules/@tonaljs/progression": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/@tonaljs/progression/-/progression-4.9.2.tgz",
"integrity": "sha512-1Pmau5tKoWmY2H/fp9WVJ5Qm4c+xlu7IEZb3R3uLbc26kJq97DbuSnfArIAQoZTum2V6lOEN/Dt34E9OrGKnoA==",
"license": "MIT",
"dependencies": {
"@tonaljs/chord": "6.1.2",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-interval": "6.1.0",
"@tonaljs/pitch-note": "6.1.0",
"@tonaljs/roman-numeral": "4.9.1"
}
},
"node_modules/@tonaljs/progression/node_modules/@tonaljs/chord": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@tonaljs/chord/-/chord-6.1.2.tgz",
"integrity": "sha512-39crsPVyBJxdGJ1DGl+4diM+6+mZ/ws6crkQMlqX4zTYHMUVfJOQrko4mPF1CD89AFhHJe1zCT9Js+nNp3Gffg==",
"license": "MIT",
"dependencies": {
"@tonaljs/chord-detect": "4.9.1",
"@tonaljs/chord-type": "5.1.1",
"@tonaljs/collection": "4.9.0",
"@tonaljs/interval": "^5.1.0",
"@tonaljs/pcset": "4.10.1",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-note": "6.1.0",
"@tonaljs/scale-type": "4.9.2"
}
},
"node_modules/@tonaljs/progression/node_modules/@tonaljs/chord-type": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@tonaljs/chord-type/-/chord-type-5.1.1.tgz",
"integrity": "sha512-ti4WzRYvvjH7to0G3zlJFq7WsjHqmcqbk8Jv98aZSR5YumLdY/ua2yOPPyoPq82n6vfgjZsacnAZ3v8/SodOcw==",
"license": "MIT",
"dependencies": {
"@tonaljs/pcset": "4.10.1"
}
},
"node_modules/@tonaljs/progression/node_modules/@tonaljs/interval": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/interval/-/interval-5.1.0.tgz",
"integrity": "sha512-GR9dUjn0j7yhjwjRh8HQxZYXOiVl05WfY3AFyMB9rfg807K4dSJmWfPTULPQXyHJ6NiZOPXcwRs8MxMLDxgdbg==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "^5.0.2",
"@tonaljs/pitch-distance": "^5.0.4",
"@tonaljs/pitch-interval": "^6.0.0"
}
},
"node_modules/@tonaljs/progression/node_modules/@tonaljs/pitch": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
"integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
"license": "MIT"
},
"node_modules/@tonaljs/progression/node_modules/@tonaljs/pitch-interval": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
"integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2"
}
},
"node_modules/@tonaljs/range": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/@tonaljs/range/-/range-4.9.2.tgz",
"integrity": "sha512-XhFbCJCrEEIVz3MNmdVp9QUyiJA6eqnNnQeNqto8AuT5BYuffHDoMkBmvvjPuV5Lh8zfF9D0aqglvqPbZ+neKQ==",
"license": "MIT",
"dependencies": {
"@tonaljs/collection": "4.9.0",
"@tonaljs/midi": "4.10.2"
}
},
"node_modules/@tonaljs/roman-numeral": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@tonaljs/roman-numeral/-/roman-numeral-4.9.1.tgz",
"integrity": "sha512-dJGKBNHdPrNTE97ZDk4t6wpNmgYcpHyLkAvWkRP9I/HsDkeTFFQqNXosYIvspPWrFySlRpeqqFwcqV74MYoOag==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2",
"@tonaljs/pitch-interval": "6.1.0",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/roman-numeral/node_modules/@tonaljs/pitch": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
"integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
"license": "MIT"
},
"node_modules/@tonaljs/roman-numeral/node_modules/@tonaljs/pitch-interval": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
"integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2"
}
},
"node_modules/@tonaljs/scale": {
"version": "4.13.4",
"resolved": "https://registry.npmjs.org/@tonaljs/scale/-/scale-4.13.4.tgz",
"integrity": "sha512-hyilnmsCmoGQiRhYWKM1QPrqaNH3KGEfrSfxeXcicPnEh12yvrVRm4djKmTVw7Rc/5P4GniPAA51+CS0C5dbLw==",
"license": "MIT",
"dependencies": {
"@tonaljs/chord-type": "5.1.1",
"@tonaljs/collection": "4.9.0",
"@tonaljs/note": "4.12.1",
"@tonaljs/pcset": "4.10.1",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-note": "6.1.0",
"@tonaljs/scale-type": "4.9.2"
}
},
"node_modules/@tonaljs/scale-type": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/@tonaljs/scale-type/-/scale-type-4.9.2.tgz",
"integrity": "sha512-XDRPySg0X6owJ6AVzVRpS4ZpgleAUQakuq5VtfQ0JitnJKGRYo5Y0w7Fl/wEz/X9aEMk2lxcdF8VGGMIsQFo0g==",
"license": "MIT",
"dependencies": {
"@tonaljs/pcset": "4.10.1"
}
},
"node_modules/@tonaljs/scale/node_modules/@tonaljs/chord-type": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@tonaljs/chord-type/-/chord-type-5.1.1.tgz",
"integrity": "sha512-ti4WzRYvvjH7to0G3zlJFq7WsjHqmcqbk8Jv98aZSR5YumLdY/ua2yOPPyoPq82n6vfgjZsacnAZ3v8/SodOcw==",
"license": "MIT",
"dependencies": {
"@tonaljs/pcset": "4.10.1"
}
},
"node_modules/@tonaljs/time-signature": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@tonaljs/time-signature/-/time-signature-4.9.0.tgz",
"integrity": "sha512-zRo8CBqg/2guzTlF2vxyVIuB5gmwWQaxlknJPUSDII8CTdQ/x3a1LlNoMKkvTUnqwCihe3WrNPZ5XUfOOTthYA==",
"license": "MIT"
},
"node_modules/@tonaljs/tonal": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/@tonaljs/tonal/-/tonal-4.10.0.tgz",
"integrity": "sha512-F2T9Fiy+j0MVped89kCrX1XF68mQOLUnny4pDOHrIf+OkrjtLQOzzaSOrX1tx0o72WUwQm1knz6D1d57b9X1HA==",
"license": "MIT",
"dependencies": {
"@tonaljs/abc-notation": "^4.8.0",
"@tonaljs/array": "^4.8.0",
"@tonaljs/chord": "^4.8.0",
"@tonaljs/chord-type": "^4.8.0",
"@tonaljs/collection": "^4.8.0",
"@tonaljs/core": "^4.8.0",
"@tonaljs/duration-value": "^4.8.0",
"@tonaljs/interval": "^4.8.0",
"@tonaljs/key": "^4.9.0",
"@tonaljs/midi": "^4.8.0",
"@tonaljs/mode": "^4.8.0",
"@tonaljs/note": "^4.9.0",
"@tonaljs/pcset": "^4.8.0",
"@tonaljs/progression": "^4.8.0",
"@tonaljs/range": "^4.8.0",
"@tonaljs/roman-numeral": "^4.8.0",
"@tonaljs/scale": "^4.9.0",
"@tonaljs/scale-type": "^4.8.0",
"@tonaljs/time-signature": "^4.8.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
......@@ -1147,6 +1745,24 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
......@@ -1158,7 +1774,6 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
......@@ -1467,6 +2082,119 @@
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@wavesurfer/react": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@wavesurfer/react/-/react-1.0.12.tgz",
......@@ -1481,7 +2209,6 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
......@@ -1517,6 +2244,16 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
......@@ -1608,6 +2345,25 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chord-voicings": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/chord-voicings/-/chord-voicings-0.0.1.tgz",
"integrity": "sha512-SutgB/4ynkkuiK6qdQ/k3QvCFcH0Vj8Ch4t6LbRyRQbVzP/TOztiCk3kvXd516UZ6fqk7ijDRELEFcKN+6V8sA==",
"license": "ISC",
"dependencies": {
"@tonaljs/tonal": "^4.6.5"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
......@@ -1681,6 +2437,15 @@
"node": ">=8"
}
},
"node_modules/djipevents": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/djipevents/-/djipevents-2.0.7.tgz",
"integrity": "sha512-KNFYaU85imxOCKOUsIR70Iz9E19r96/X7LSH+u0tSoZdpWcBdzoqtTsU+wuLhc6GMpSFob+KInkZAbfKi01Bjg==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.20.6"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.368",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
......@@ -1702,6 +2467,13 @@
"node": ">=10.13.0"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
......@@ -1725,6 +2497,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/escodegen": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"license": "BSD-2-Clause",
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^5.2.0",
"esutils": "^2.0.2"
},
"bin": {
"escodegen": "bin/escodegen.js",
"esgenerate": "bin/esgenerate.js"
},
"engines": {
"node": ">=6.0"
},
"optionalDependencies": {
"source-map": "~0.6.1"
}
},
"node_modules/eslint": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz",
......@@ -1861,6 +2654,19 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
......@@ -1891,22 +2697,39 @@
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
......@@ -1997,6 +2820,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
......@@ -2122,6 +2958,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/jazz-midi": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/jazz-midi/-/jazz-midi-1.7.9.tgz",
"integrity": "sha512-c8c4BBgwxdsIr1iVm53nadCrtH7BUlnX3V95ciK/gbvXN/ndE5+POskBalXgqlc/r9p2XUbdLTrgrC6fou5p9w==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
......@@ -2186,6 +3032,16 @@
"node": ">=6"
}
},
"node_modules/jzz": {
"version": "1.9.6",
"resolved": "https://registry.npmjs.org/jzz/-/jzz-1.9.6.tgz",
"integrity": "sha512-J7ENLhXwfm2BNDKRUrL8eKtPhUS/CtMBpiafxQHDBcOWSocLhearDKEdh+ylnZFcr5OXWTed0gj6l/txeQA9vg==",
"license": "MIT",
"optional": true,
"dependencies": {
"jazz-midi": "^1.7.9"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
......@@ -2558,6 +3414,21 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nanostores": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.11.4.tgz",
"integrity": "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
......@@ -2575,6 +3446,20 @@
"node": ">=18"
}
},
"node_modules/obug": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
......@@ -2645,6 +3530,13 @@
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
......@@ -2855,6 +3747,23 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
......@@ -2865,6 +3774,39 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/superdough": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/superdough/-/superdough-1.3.0.tgz",
"integrity": "sha512-LhmsrErcv9EMFNb79+3tvn6h7WgzKp4yRe1XtT2+eRY9oEpV4Jz0nb9Dxil3OLaNhQ6V2FuwPWBLQJb2aVtwuQ==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@kabelsalat/lib": "^0.4.1",
"nanostores": "^0.11.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/supradough": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/supradough/-/supradough-1.2.4.tgz",
"integrity": "sha512-/v3cL9fq05QCQVwbttLHNZcjyyFFz5wdKvg3j6V6moRXpDlQG89l8dQtUIpQvHsxYI5sXNod9rh5bf+Y4NMGeA==",
"license": "AGPL-3.0-or-later"
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
......@@ -2886,6 +3828,23 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
......@@ -2903,6 +3862,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
......@@ -3101,12 +4070,117 @@
}
}
},
"node_modules/vitest": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/wavesurfer.js": {
"version": "7.12.7",
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.7.tgz",
"integrity": "sha512-TIe7hB6OCZysNOZ2cn2NR8Qpko22POWel6rauNcqOammFoH65NYQUM35unNLLMIlUMVYvjJ6w/TTl/G/m+w0nA==",
"license": "BSD-3-Clause"
},
"node_modules/webmidi": {
"version": "3.1.16",
"resolved": "https://registry.npmjs.org/webmidi/-/webmidi-3.1.16.tgz",
"integrity": "sha512-I6NnCfBUXNbV8q6EU+DPtr1u1EgjzDrFKHQQS2OgaHzcuQRFhRHgogfKpKSpxD0IgXhZHz8+k/OpGfZoWbvWAQ==",
"license": "Apache-2.0",
"dependencies": {
"djipevents": "^2.0.7"
},
"engines": {
"node": ">=8.5"
},
"optionalDependencies": {
"jzz": "^1.8.5"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
......@@ -3123,6 +4197,23 @@
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
......
......@@ -7,11 +7,13 @@
"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",
"@fontsource-variable/geist-mono": "^5.2.8",
"@strudel/web": "^1.3.0",
"@wavesurfer/react": "^1.0.12",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
......@@ -34,6 +36,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>,
)
/**
* La Cale — where the cargo gets checked before it goes on stage.
*
* The Foundry cuts sample kits; nothing until now could tell you whether they were any
* good. A grade in a manifest is a claim, and the claim has been wrong in interesting
* ways (a fade-out graded S, and then 24 loops of pure silence graded A) — so the
* kit has to be *heard*, at tempo, against other loops, in a place where a bad one can
* be noticed in two seconds rather than mid-set.
*
* Three things, in the order you need them:
* 1. **browse** — every kit under the sample root, filtered by family, tier, mode
* 2. **audition** — one sample, untouched or looped (see `SampleRow`)
* 3. **rack** — several at once on one bar grid, exported as a `.tidal` block
*
* The index is generated by `tools/foundry/kitindex.py`; the header shows when, because
* a generated file that is never compared against its source is how the stale-preload
* crackle got to a venue.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Play, Square, Anchor, Search, RefreshCw } from 'lucide-react'
import { SampleRow } from './SampleRow'
import { Rack, type RackRow } from './Rack'
import { familyColor, type Kit, type KitIndex, type Sample, type Tier } from './types'
import { Deck, PHRASE_BARS, CHOP_STEPS, audioCtx, audition, loadBuffer, type Audition, type Voice } from './engine'
/** Drop one key. `const { [id]: _unused, ...rest }` reads worse and lints worse. */
function without<T>(o: Record<string, T>, id: string): Record<string, T> {
const next = { ...o }
delete next[id]
return next
}
const FAMILIES = ['drums', 'hits', 'bass', 'tonal', 'vox', 'fx'] as const
const TIERS: Tier[] = ['S', 'A', 'B', 'C', 'D']
const KEY = 'pv_cale_rack'
/** `?kits=fred_*` narrows the rail on load, so a link can point at one pack. */
const kitGlob = new URLSearchParams(location.search).get('kits') ?? ''
const globMatch = (name: string, glob: string) =>
!glob || new RegExp('^' + glob.split('*').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$').test(name)
export default function KitAuditioner() {
const [idx, setIdx] = useState<KitIndex | null>(null)
const [err, setErr] = useState<string | null>(null)
const [kitSel, setKitSel] = useState<string | null>(null)
const [q, setQ] = useState('')
const [fam, setFam] = useState<Set<string>>(new Set())
const [minTier, setMinTier] = useState<Tier>('D')
const [mode, setMode] = useState<'all' | 'loop' | 'chop'>('all')
const [flaggedOnly, setFlaggedOnly] = useState(false)
const [focus, setFocus] = useState(0)
// audition state — one at a time, deliberately: two samples at once is what the rack is
const [live, setLive] = useState<{ id: string; mode: 'once' | 'loop' } | null>(null)
const auditionRef = useRef<Audition | null>(null)
const [head, setHead] = useState(0)
// the rack
const deckRef = useRef<Deck | null>(null)
// Hydrated at construction, not in an effect: the rack must never render empty for
// a frame and then fill in, or a play pressed early plays nothing.
const saved = useMemo(() => {
try { return JSON.parse(localStorage.getItem(KEY) || '{}') } catch { return {} }
}, [])
const [voices, setVoices] = useState<Record<string, Voice>>(saved.voices ?? {})
const [order, setOrder] = useState<string[]>(saved.order ?? [])
const [bpm, setBpm] = useState<number>(saved.bpm ?? 123)
const [playing, setPlaying] = useState(false)
const [pos, setPos] = useState(-1)
const [master, setMaster] = useState(0.8)
/** One action, so the focused row and the tempo can never lag the selection. */
const selectKit = useCallback((name: string, kitBpm?: number | null) => {
setKitSel(name)
setFocus(0)
if (kitBpm) setBpm(Math.round(kitBpm))
}, [])
useEffect(() => {
fetch('/kits.json')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status} ${r.statusText} — run tools/foundry/kitindex.py`))))
.then((d: KitIndex) => {
setIdx(d)
const first = d.kits.find((k) => globMatch(k.kit, kitGlob)) ?? d.kits[0]
if (first) selectKit(first.kit, first.bpm)
})
.catch((e) => setErr(String(e.message ?? e)))
}, [selectKit])
// ── the kit rail, grouped by track ─────────────────────────────────────────
const kits = useMemo(
() => (idx?.kits ?? []).filter((k) => globMatch(k.kit, kitGlob)
&& (!q || k.kit.toLowerCase().includes(q.toLowerCase()))),
[idx, q],
)
const groups = useMemo(() => {
const g = new Map<string, Kit[]>()
for (const k of kits) g.set(k.track ?? '—', [...(g.get(k.track ?? '—') ?? []), k])
return [...g.entries()]
}, [kits])
const kit = useMemo(() => idx?.kits.find((k) => k.kit === kitSel) ?? null, [idx, kitSel])
const samples = useMemo(() => {
const floor = TIERS.indexOf(minTier)
return (kit?.samples ?? []).filter((s) => {
if (fam.size && !fam.has(s.family ?? '')) return false
if (s.tier && TIERS.indexOf(s.tier) > floor) return false
if (mode !== 'all' && (s.mode ?? (s.bars ? 'loop' : 'chop')) !== mode) return false
if (flaggedOnly && !s.flags.length) return false
return true
})
}, [kit, fam, minTier, mode, flaggedOnly])
// ── audition ───────────────────────────────────────────────────────────────
const stopAudition = useCallback(() => {
auditionRef.current?.stop()
auditionRef.current = null
setLive(null)
setHead(0)
}, [])
const play = useCallback((s: Sample, m: 'once' | 'loop') => {
stopAudition()
void loadBuffer(s.url).then((buf) => {
auditionRef.current = audition(buf, { loop: m === 'loop' })
setLive({ id: s.name, mode: m })
}).catch((e) => setErr(String(e)))
}, [stopAudition])
// playhead: read the clock, don't count frames — a dropped frame must not move it
useEffect(() => {
if (!live) return
let raf = 0
const step = () => {
const a = auditionRef.current
if (a) {
const el = audioCtx().currentTime - a.startedAt
if (live.mode === 'once' && el > a.durS) stopAudition()
else setHead((el % a.durS) / a.durS)
}
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [live, stopAudition])
// ── the rack ───────────────────────────────────────────────────────────────
const deck = () => (deckRef.current ??= new Deck())
const addToRack = useCallback((s: Sample, kitName: string) => {
const id = `${kitName}/${s.name}`
setVoices((vs) => {
if (vs[id]) return without(vs, id)
const bars = s.bars ?? 0
return {
...vs,
[id]: {
id, url: s.url, bars, durS: s.dur_s ?? 0, gain: 1, muted: false,
// a new row arrives PLAYING, on every entry it has. Adding a loop and
// then having to find its cells before hearing anything is friction in
// the one interaction this page exists for.
cells: bars > 0
? Array.from({ length: PHRASE_BARS }, (_, i) => i % bars === 0)
: Array.from({ length: CHOP_STEPS }, (_, i) => i % 4 === 0),
},
}
})
setOrder((o) => (o.includes(id) ? o.filter((x) => x !== id) : [...o, id]))
}, [])
// Every voice change is pushed to the deck, buffers included. Decoding happens once
// per URL in `loadBuffer`, so re-running this on a fader move costs nothing.
useEffect(() => {
const d = deck()
d.bpm = bpm
const ids = new Set(Object.keys(voices))
for (const v of Object.values(voices)) void loadBuffer(v.url).then((b) => d.setVoice(v, b))
for (const id of order) if (!ids.has(id)) d.removeVoice(id)
}, [voices, bpm, order])
useEffect(() => { deck().setMaster(master) }, [master])
useEffect(() => {
if (!playing) return
const t = setInterval(() => setPos(deck().position()), 50)
return () => clearInterval(t)
}, [playing])
const togglePlay = useCallback(() => {
const d = deck()
if (d.playing) { d.stop(); setPlaying(false); setPos(-1) } else { d.start(); setPlaying(true) }
}, [])
// The rack survives a refresh. An arrangement you found by ear and then lost to a
// reload is the same mistake the set-judge already learned not to make.
useEffect(() => {
try { localStorage.setItem(KEY, JSON.stringify({ voices, order, bpm })) } catch { /* full or blocked */ }
}, [voices, order, bpm])
const rackRows: RackRow[] = useMemo(() => order.flatMap((id) => {
const v = voices[id]
if (!v) return []
const [kitName, name] = [id.slice(0, id.indexOf('/')), id.slice(id.indexOf('/') + 1)]
const s = idx?.kits.find((k) => k.kit === kitName)?.samples.find((x) => x.name === name)
return [{ voice: v, kit: kitName, n: s?.n ?? 0, family: s?.family ?? null, color: familyColor(s?.family) }]
}), [order, voices, idx])
// ── keys ───────────────────────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
const s = samples[focus]
if (e.key === 'j' || e.key === 'ArrowDown') { setFocus((f) => Math.min(f + 1, samples.length - 1)); e.preventDefault() }
else if (e.key === 'k' || e.key === 'ArrowUp') { setFocus((f) => Math.max(f - 1, 0)); e.preventDefault() }
else if (e.key === 'Enter' && s) play(s, 'once')
else if (e.key === 'l' && s) play(s, 'loop')
else if (e.key === 'a' && s && kit) addToRack(s, kit.kit)
else if (e.key === ' ') { togglePlay(); e.preventDefault() }
else if (e.key === 'Escape') stopAudition()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [samples, focus, kit, play, addToRack, togglePlay, stopAudition])
if (err) return <Fail msg={err} />
if (!idx) return <div className="p-6 text-[13px] text-ink-faint">reading the index…</div>
const total = idx.kits.reduce((a, k) => a + k.n_samples, 0)
const flagged = kit?.samples.filter((s) => s.flags.length).length ?? 0
return (
<div className="h-screen flex flex-col bg-surface text-ink text-[13px]">
{/* ── bridge header: identity, then the transport ───────────────────── */}
<header className="flex items-center gap-3 px-3 py-2 border-b border-hairline shrink-0">
<Anchor size={15} className="text-magenta" />
<div>
<div className="font-semibold leading-tight">La Cale</div>
<div className="text-[11px] text-ink-faint leading-tight">
{idx.kits.length} kits · {total} samples · indexed {idx.generated.slice(0, 16).replace('T', ' ')}
</div>
</div>
<div className="flex-1" />
<button onClick={togglePlay}
className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-[12px] font-medium
${playing ? 'bg-magenta text-surface' : 'bg-overlay text-ink hover:bg-overlay/70'}`}>
{playing ? <Square size={13} /> : <Play size={13} />}
{playing ? 'stop' : 'play rack'}
</button>
<label className="flex items-center gap-1.5 text-[11px] text-ink-faint">
bpm
<input type="number" min={40} max={220} step={0.5} value={bpm}
onChange={(e) => setBpm(Number(e.target.value) || 120)}
className="tnum w-16 bg-raised border border-hairline rounded-sm px-1.5 py-0.5 text-[12px] text-ink" />
</label>
{kit?.bpm && Math.round(kit.bpm) !== Math.round(bpm) && (
<button onClick={() => setBpm(Math.round(kit.bpm!))}
className="flex items-center gap-1 text-[11px] text-wip hover:text-ink" title="match this kit's tempo">
<RefreshCw size={11} /> {kit.bpm.toFixed(1)}
</button>
)}
<label className="flex items-center gap-1.5 text-[11px] text-ink-faint" title="master">
out
<input type="range" min={0} max={1} step={0.01} value={master}
onChange={(e) => setMaster(Number(e.target.value))}
className="sx-range w-20" style={{ ['--fill' as string]: `${master * 100}%` }} />
</label>
<div className="text-[10px] text-ink-faint/70 font-mono pl-1">
j/k move · ⏎ once · l loop · a rack · ␣ play
</div>
</header>
<div className="flex flex-1 min-h-0">
{/* ── the rail ─────────────────────────────────────────────────────── */}
<aside className="w-60 shrink-0 border-r border-hairline flex flex-col">
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-hairline">
<Search size={12} className="text-ink-faint" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="filter kits"
className="flex-1 bg-transparent text-[12px] outline-none placeholder:text-ink-faint/60" />
</div>
<div className="flex-1 overflow-y-auto">
{groups.map(([track, ks]) => (
<div key={track}>
<div className="px-2 pt-2 pb-0.5 text-[10px] uppercase tracking-wide text-ink-faint/80">{track}</div>
{ks.map((k) => (
<button key={k.kit} onClick={() => selectKit(k.kit, k.bpm)}
className={`w-full flex items-center gap-2 px-2 py-1 text-left
${k.kit === kitSel ? 'bg-overlay' : 'hover:bg-raised'}`}>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: familyColor(k.family) }} />
<span className="flex-1 truncate text-[12px]">{k.family ?? k.kit}</span>
<span className="tnum text-[11px] text-ink-faint">{k.n_samples}</span>
</button>
))}
</div>
))}
</div>
</aside>
{/* ── the kit ──────────────────────────────────────────────────────── */}
<main className="flex-1 min-w-0 flex flex-col">
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-hairline shrink-0">
<div className="font-mono text-[12px] text-ink">{kit?.kit}</div>
<div className="flex gap-1">
{FAMILIES.filter((f) => kit?.samples.some((s) => s.family === f)).map((f) => (
<button key={f} onClick={() => setFam((p) => {
const n = new Set(p)
if (n.has(f)) n.delete(f); else n.add(f)
return n
})}
className={`px-1.5 py-0.5 text-[11px] rounded-sm border
${fam.has(f) ? 'border-transparent' : 'border-hairline text-ink-faint'}`}
style={fam.has(f) ? { background: familyColor(f), color: 'var(--color-surface)' } : undefined}>
{f}
</button>
))}
</div>
<div className="flex-1" />
<select value={mode} onChange={(e) => setMode(e.target.value as typeof mode)}
className="bg-raised border border-hairline rounded-sm px-1 py-0.5 text-[11px]">
<option value="all">loops + chops</option>
<option value="loop">loops</option>
<option value="chop">chops</option>
</select>
<select value={minTier} onChange={(e) => setMinTier(e.target.value as Tier)}
className="bg-raised border border-hairline rounded-sm px-1 py-0.5 text-[11px]">
{TIERS.map((t) => <option key={t} value={t}>{t}+</option>)}
</select>
<button onClick={() => setFlaggedOnly((v) => !v)} disabled={!flagged}
className={`px-1.5 py-0.5 text-[11px] rounded-sm border disabled:opacity-40
${flaggedOnly ? 'border-wip text-wip' : 'border-hairline text-ink-faint'}`}
title="samples the grader flagged — worth a second listen">
! {flagged}
</button>
<div className="tnum text-[11px] text-ink-faint w-14 text-right">{samples.length} shown</div>
</div>
<div className="flex-1 overflow-y-auto">
{samples.map((s, i) => (
<SampleRow key={s.name} s={s} kit={kit!.kit}
playing={live?.id === s.name ? live.mode : null}
head={live?.id === s.name ? head : null}
inRack={!!voices[`${kit!.kit}/${s.name}`]}
focused={i === focus}
onFocus={() => setFocus(i)}
onPlay={(m) => play(s, m)}
onStop={stopAudition}
onRack={() => addToRack(s, kit!.kit)} />
))}
{!samples.length && <div className="p-4 text-[12px] text-ink-faint">nothing matches these filters.</div>}
</div>
</main>
</div>
{/* ── the rack ───────────────────────────────────────────────────────── */}
<section className="h-[17rem] shrink-0 border-t border-hairline flex flex-col bg-raised/40">
<div className="flex items-center gap-2 px-2 py-1 border-b border-hairline shrink-0">
<div className="text-[11px] uppercase tracking-wide text-ink-faint">rack</div>
<div className="text-[11px] text-ink-faint">
{rackRows.length} voice{rackRows.length === 1 ? '' : 's'} · {PHRASE_BARS}-bar phrase @ {bpm} bpm
</div>
{playing && pos >= 0 && (
<div className="tnum font-mono text-[11px] text-magenta">bar {pos + 1}/{PHRASE_BARS}</div>
)}
</div>
<Rack rows={rackRows} bpm={bpm} playing={playing} position={pos}
onChange={(id, patch) => setVoices((vs) => ({ ...vs, [id]: { ...vs[id], ...patch } }))}
onRemove={(id) => { setVoices((vs) => without(vs, id)); setOrder((o) => o.filter((x) => x !== id)); deck().removeVoice(id) }}
onClear={() => { setVoices({}); setOrder([]); deck().clear() }} />
</section>
</div>
)
}
function Fail({ msg }: { msg: string }) {
return (
<div className="p-6 max-w-xl">
<div className="text-blocked text-[13px] mb-2">{msg}</div>
<pre className="text-[11px] text-ink-faint font-mono">
{`cd tools/foundry
python3 kitindex.py --cuts packs/fred/fred_kits.json --all`}
</pre>
</div>
)
}
/**
* 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">
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, 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
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_11rem_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">
<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?.toFixed(1)} bpm
{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`}</>}
</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>
<div className="relative">
<Waveform env={s.env} color={col} head={head} bars={bars} height={30} />
{tags.length > 0 && (
<div className="absolute -bottom-0.5 left-0 flex gap-1 pointer-events-none">
{tags.map(([t]) => (
<span key={t} className="text-[10px] text-ink-faint/70 bg-surface/70 px-1 rounded-sm">{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 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 */ } },
}
}
// ── 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) {
const ctx = audioCtx()
const bar = barSeconds(this.bpm)
const slot = ((barIndex % PHRASE_BARS) + PHRASE_BARS) % PHRASE_BARS
for (const { v, buf } of this.voices.values()) {
if (v.muted || v.gain <= 0) continue
if (v.bars > 0) {
// A loop only starts on a bar that is a multiple of its own length, so a
// 4-bar loop in an 8-bar phrase has two possible entries, not eight. Firing
// it on bar 3 would play its bar 1 over the phrase's bar 4 — audible as the
// groove turning around in the wrong place, which is not a thing you want to
// have to reason about while performing.
if (slot % v.bars !== 0) continue
if (!v.cells[slot]) continue
this.fire(buf, at, v, loopAtRate(v.durS, v.bars, this.bpm), v.bars * bar)
} else {
const step = bar / CHOP_STEPS
for (let i = 0; i < CHOP_STEPS; i++) {
if (!v.cells[i]) continue
const sw = i % 2 === 1 ? this.swing * step : 0
this.fire(buf, at + i * step + sw, v, 1, Math.min(buf.duration, step * 4))
}
}
}
if (at < ctx.currentTime) return
}
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
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)'
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'),
},
},
},
......
#!/usr/bin/env python3
"""kitindex — build the sample-kit index the auditioner SPA browses.
One JSON describing every kit under the sample root: what is in it, how to loop it,
and a peak envelope small enough to draw without downloading the audio. Written to
`armada/ui/public/kits.json`, fetched by `kits.html`, and served by both
`vite.config.ts` and `armada/serve.py` through the `samples` mount — so the URLs in
here work identically in dev and on the LAN.
Two sources, merged, and the merge is the point:
* **The folder is the truth about what exists.** SuperDirt enumerates the directory,
so the index has to as well, via `pvbanks.playable` (the same listing kitgate and
bank-check use). A kit with no manifest — 163 of PLN's kits predate the Foundry —
still has to be browsable, or the tool only works on what it built itself.
* **A Foundry manifest is the truth about what a file MEANS.** tier, grade, family,
producer role, bars, bpm and CLAP tags all come from `fred_kits.json`-style cuts;
nothing here re-derives them, so the SPA and the gate cannot disagree.
`n` comes from the folder listing, never from the manifest order, because `n` is
what SuperDirt will play. (kitgate proves the two agree; this file does not assume it.)
Kit dirs do not all sit at the same depth — a pack can be staged in its own subtree
(`Samples/Fred/output/fred_marea_drums/`) while the older kits sit directly under
`Samples/`. So `--root` is repeatable and says *where to look for kit folders*, while
`--mount-root` is the one directory the SPA's `samples` mount points at and the only
thing URLs are computed from. Keeping those two ideas apart is what lets a pack move
without every URL in the index changing.
python3 kitindex.py --root ~/Work/Sound/Samples/Fred/output --cuts <cuts.json>
python3 kitindex.py --all # every kit under the sample root
"""
from __future__ import annotations
import argparse
import fnmatch
import json
import sys
import time
from pathlib import Path
import numpy as np
import soundfile as sf
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent)) # tools/, for pvbanks
PEAKS = 128 # envelope columns per sample — enough for a 200 px row
OUT = HERE.parent.parent / "armada/ui/public/kits.json"
MOUNT = "samples" # the /audio/<prefix> mount in armada/ui/audio-mounts.json
CACHE = HERE / ".kitindex-cache.json" # envelope cache, keyed on size+mtime
def envelope(path: Path, cols: int = PEAKS) -> tuple[list[int], float, float, int, int]:
"""A tiny peak envelope, plus the facts the player needs before decoding.
Read as one block: these files are seconds long, and a per-column seek would be
slower than the read. Mono-summed — the SPA draws one lane per sample, and a
two-lane waveform at 200 px wide is noise.
"""
y, sr = sf.read(str(path), dtype="float32", always_2d=True)
n, ch = y.shape
mono = y.mean(axis=1)
if n == 0:
return [0] * cols, 0.0, 0.0, sr, ch
# pad to a whole number of columns so the last one is not a short-window artefact
per = max(1, int(np.ceil(n / cols)))
pad = per * cols - n
blocks = np.abs(np.pad(mono, (0, pad))).reshape(cols, per)
peak = float(np.max(np.abs(mono)))
env = blocks.max(axis=1)
scaled = (env / (peak + 1e-12) * 100.0).round().astype(int).tolist()
rms = float(np.sqrt(np.mean(mono ** 2)))
return scaled, peak, rms, sr, ch
def db(x: float) -> float:
return round(float(20 * np.log10(max(x, 1e-12))), 1)
def load_cuts(paths: list[Path]) -> dict[str, dict]:
"""Manifest rows keyed by absolute audio path."""
by_path: dict[str, dict] = {}
for p in paths:
for c in json.loads(p.read_text()):
by_path[str(Path(c["path"]).resolve())] = c
return by_path
def _load_cache() -> dict:
"""Envelopes keyed on `path|size|mtime_ns`.
Reading 1884 files to draw 1884 little waveforms takes 3.5 minutes, and the whole
point of re-running this is that ONE pack changed. The key includes size and mtime
so a restaged kit — same path, new bytes — misses the cache: a stale envelope drawn
over fresh audio would be a lie that looks like data.
"""
try:
return json.loads(CACHE.read_text())
except Exception:
return {}
def build(roots: list[Path], mount_root: Path, cuts: dict[str, dict],
only: list[str], *, use_cache: bool = True, progress=None) -> dict:
from pvbanks import playable
cache = _load_cache() if use_cache else {}
fresh: dict[str, list] = {}
hits = 0
def url_for(f: Path) -> str:
"""`/audio/samples/<path under the mount>`. A kit outside the mount would be
unplayable in the browser, so say so here rather than serve a 404."""
try:
return f"/audio/{MOUNT}/{f.resolve().relative_to(mount_root.resolve())}"
except ValueError:
return ""
kits = []
seen: set[str] = set()
dirs = [x for r in roots if r.is_dir() for x in sorted(r.iterdir()) if x.is_dir()]
for d in dirs:
if only and not any(fnmatch.fnmatch(d.name, pat) for pat in only):
continue
files = playable(d)
if not files:
continue
if d.name in seen:
# Two dirs claiming one bank name is exactly the shadowing SuperDirt does
# silently; the index must not pick a winner behind PLN's back.
if progress:
progress(f" ! {d.name}: a second directory claims this bank name — skipped")
continue
seen.add(d.name)
samples = []
for n, f in enumerate(files): # n IS the folder index SuperDirt uses
c = cuts.get(str(f.resolve()), {})
st = f.stat()
key = f"{f}|{st.st_size}|{st.st_mtime_ns}"
cached = cache.get(key)
if cached:
env, peak, rms, sr, ch = cached
hits += 1
else:
try:
env, peak, rms, sr, ch = envelope(f)
except Exception as e: # a kit with one unreadable file stays usable
if progress:
progress(f" ! {f.name}: {e}")
continue
fresh[key] = [env, peak, rms, sr, ch]
samples.append({
"n": n, "name": f.stem, "file": f.name,
"url": url_for(f),
"sr": sr, "ch": ch, "peak_dbfs": db(peak), "rms_dbfs": db(rms),
"env": env,
# from the manifest when the Foundry cut it, absent otherwise
"bars": c.get("bars"), "bpm": c.get("bpm"),
"dur_s": c.get("dur_s"), "tier": c.get("tier"),
"grade": c.get("grade"), "family": c.get("family"),
"stem_role": c.get("stem_role"), "track": c.get("track"),
"mode": c.get("mode"), "flags": c.get("flags") or [],
"tags": c.get("tags") or {},
})
if not samples:
continue
known = [s for s in samples if s["bpm"]]
kits.append({
"kit": d.name, "n_samples": len(samples),
"source": "foundry" if known else "legacy",
"track": next((s["track"] for s in samples if s["track"]), None),
"family": next((s["family"] for s in samples if s["family"]), None),
"bpm": known[0]["bpm"] if known else None,
"samples": samples,
})
if progress:
progress(f" {d.name:38.38} {len(samples):3} samples")
if use_cache:
try:
CACHE.write_text(json.dumps(fresh, separators=(",", ":")))
except OSError:
pass
if progress and hits:
progress(f" ({hits} envelopes from cache)")
return {"generated": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"roots": [str(r) for r in roots], "mount_root": str(mount_root),
"peaks": PEAKS, "kits": kits}
def main(argv=None) -> int:
ap = argparse.ArgumentParser(prog="kitindex", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--cuts", type=Path, action="append", default=[],
help="a stempack cuts manifest; repeatable")
ap.add_argument("--only", action="append", default=[],
help="glob on kit name; repeatable (default: every kit)")
ap.add_argument("--all", action="store_true", help="explicit form of no --only")
ap.add_argument("--root", type=Path, action="append", default=[],
help="a directory holding kit folders; repeatable "
"(default: the sample root itself)")
ap.add_argument("--mount-root", type=Path, default=None,
help="what /audio/samples points at (default: the sample root)")
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--no-cache", action="store_true",
help="re-read every file instead of reusing cached envelopes")
a = ap.parse_args(argv)
from engine import publish
mount_root = a.mount_root or publish.samples_root()
roots = a.root or [publish.samples_root()]
cuts = load_cuts(a.cuts)
print(f"kitindex — {', '.join(str(r) for r in roots)}"
+ (f", {len(cuts)} manifest rows from {len(a.cuts)} file(s)" if cuts else ""))
idx = build(roots, mount_root, cuts, [] if a.all else a.only,
use_cache=not a.no_cache, progress=print)
a.out.parent.mkdir(parents=True, exist_ok=True)
body = json.dumps(idx, separators=(",", ":"))
a.out.write_text(body)
# `npm run build` copies public/ into dist/, so a regenerated index would otherwise
# be invisible to the built page that `serve.py` hands the LAN — the same shape as
# the stale preload that debuted at a venue. Write both, or neither.
dist = a.out.parent.parent / "dist" / a.out.name
if dist.parent.is_dir():
dist.write_text(body)
print(f" (also updated {dist})")
n = sum(k["n_samples"] for k in idx["kits"])
legacy = sum(1 for k in idx["kits"] if k["source"] == "legacy")
unreachable = [k["kit"] for k in idx["kits"]
if any(not s["url"] for s in k["samples"])]
if unreachable:
print(f" ! {len(unreachable)} kits are outside {mount_root} and cannot be "
f"served: {', '.join(unreachable[:4])}")
print(f"\n{len(idx['kits'])} kits ({legacy} without a manifest), {n} samples "
f"→ {a.out} ({a.out.stat().st_size / 1024:.0f} KB)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
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