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>
......@@ -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>,
)
/**
* 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'),
},
},
},
......
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