Skip to content

Publishing an Audio Sample Pack

The sfx-sample tool mixes over a sample library and the music tool plays an instrument bank — the fixed audio palette each ships with, exactly as draw ships with its drawing logic. Because a run container is isolated and offline, that palette must be baked into the run-container image at build time; nothing is fetched at run time.

This guide covers turning a committed pack manifest into a published, pinned pack the image builds against. If you just need the commands, use the quickstart.

The audio files themselves are not committed to the repo. What lives in containers/sample-packs/ is a per-pack manifest (<pack>.toml) listing each entry’s name, tags, description, permissive license, source url, and sha256. The pack is a content-addressed tarball assembled from the manifest, published to object storage, and pinned by digest in the image build. So there are three moving pieces:

  1. The manifest — committed, human-readable, versioned. A source is fetched once, at curation time, by a developer.
  2. The tarball — the normalized pack (pack.toml + <name>.wav), living in a private Cloudflare R2 bucket. Fetched only at image-build time, never per-run.
  3. The pinpacks.lock.json, committed, mapping <name>@<version> to the tarball’s bucket/key/digest. This is the source of truth the image build reads.

The pack bucket is private and distinct from the backend’s public snapshot bucket. The snapshot bucket is public-read (the site fetches it anonymously at build time); the packs must stay private. Since R2’s public access is a per-bucket switch, they cannot share one bucket. They also have different writer identities (the backend writes the snapshot; a developer publishes packs) and different blast radius, so keeping them apart is deliberate. R2 has zero egress fees, so the per-build fetch costs nothing.

Each half uses its own bucket-scoped credential pair, so a read-only key can live on CI and dev machines while the writer stays local:

  • PUBLISH (read + write) — uploads a built tarball. Local developer only; CI never writes.
  • PRESIGN (read only) — mints the short-lived download URL the image build fetches the pack from. Lives on CI and dev machines.

A presigned URL moves the credential to build time (something has to sign the URL); it does not put a credential into the image. The URL itself is anonymous once minted, so Docker’s ADD fetches it with no secret in any layer.

  1. Create a private R2 bucket (e.g. test-cabinet-audio) with public access off.
  2. Create two R2 API tokens scoped to that bucket: one Object Read & Write (publish), one Object Read-only (presign). Cloudflare shows each token an Access Key ID and a Secret Access Key for the S3 API — the S3/SigV4 flow uses that pair, not the single opaque token value.

Read from repo-root .env locally, and from GitHub secrets/variables in CI. The image build needs only the read-only PRESIGN pair.

VariableRoleWhere
CLOUDFLARE_ACCOUNT_IDderives the S3 endpointpublish + presign
CLOUDFLARE_AUDIO_R2_BUCKETthe private bucket namepublish + presign
CLOUDFLARE_AUDIO_R2_PUBLISH_ACCESS_KEY_ID / _SECRET_ACCESS_KEYwritelocal publish only
CLOUDFLARE_AUDIO_R2_PRESIGN_ACCESS_KEY_ID / _SECRET_ACCESS_KEYreadlocal + CI image build

The Build containers workflow presigns a read-only download of the pack. Add, under the repository’s Settings → Secrets and variables → Actions:

  • Secrets: CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_AUDIO_R2_PRESIGN_ACCESS_KEY_ID, CLOUDFLARE_AUDIO_R2_PRESIGN_SECRET_ACCESS_KEY.
  • Variable: CLOUDFLARE_AUDIO_R2_BUCKET.

The publish (write) credentials are never given to CI.

A manifest lives at containers/sample-packs/<pack>.toml. Each [[sample]] (or [[instrument]]) entry needs a name, tags, description, a CC0 or otherwise permissive license (NC/ND is rejected), a source url, and the source’s sha256. The format and the on-disk layout the loader (crates/audio-core/src/sample.rs) expects are documented in containers/sample-packs/README.md.

Two rules matter:

  • Any content change is a new version. Packs are immutable and versioned with the image; never edit a published pack in place. The version is part of the pinned ref (combat-core@0.1.0) and the object key, so bumping it is what makes a new pack.
  • name / tags / description must be neutral and informational. The model browses the library by text alone (it cannot audition audio), so describe what a clip is (source, timbre, frequency, decay) — never how to use, layer, pitch, or combine it. That composition reasoning is exactly what an sfx-sample case measures. See the README’s rule.
Terminal window
node scripts/build-sample-pack.mjs combat-core --publish
git add containers/sample-packs/packs.lock.json

The script:

  1. Fetches each source, caching it by sha256 under dist/sample-packs/.cache/ (override with TCAB_SAMPLE_SRC_CACHE). A rebuild re-reads a cached clip instead of re-hitting Freesound; because the cache is keyed by content hash, it can never serve stale bytes. Delete the cache dir to force a clean re-fetch.
  2. Verifies each source against its declared sha256 (a mismatch aborts).
  3. Normalizes to PCM-16 WAV via ffmpeg (sample rate, channels, loudness, trim). With no ffmpeg it writes an un-normalized skeleton and says so loudly.
  4. Writes the loader-facing layout, tars it deterministically, and prints the digest.
  5. Uploads the tarball to R2 at a versioned key (<name>/<version>/<name>-<version>.tar) and records the pin in packs.lock.json.

Then commit packs.lock.json — that pin is what lets CI and other machines build the pack. (Omit --publish to build + print the digest without uploading, e.g. to pin by hand.)

./containers/build.sh (and the Build containers CI workflow) build the sfx-sample / music images by resolving the pack’s pin, minting a short-lived presigned R2 GET URL for it (via scripts/presign-sample-pack.mjs), and passing the pack ref, that URL, and the digest to the build. The Dockerfile’s ADD --checksum fetches and verifies the tarball and unpacks it to the path the loader reads. There are no build args to pass by hand.

An audio image whose pack is not pinned (or whose presign fails) is a build error, not a silent skip — a missing or broken pack surfaces immediately rather than shipping an image with an empty palette.

Updating a palette is therefore: new pack version → --publish → commit the pin → image rebuild.

An instrument bank (kind = "instrument-bank", entries under [[instrument]]) is built and published exactly like a sample pack, with two extra per-entry fields the music sequencer needs:

  • root_note — the MIDI note the sample was recorded at. The sequencer pitch-shifts the one recorded note across a track’s notes relative to this, so the sample may be at any pitch as long as root_note records it accurately (it need not be tuned to a fixed reference).
  • pitchedtrue for a melodic instrument (transposed per note); false for percussion (played at its native pitch, never transposed).

The shipped gm-lite bank was assembled by scripts/curate-instrument-bank.mjs, which searches Freesound (CC0 only) for a representative note per instrument, downloads its preview, and detects the recorded pitch by autocorrelation to fill root_note. Re-run it to refresh or extend the bank, then publish as above:

Terminal window
node scripts/curate-instrument-bank.mjs --dry-run # search + detect, print a table
node scripts/curate-instrument-bank.mjs # write containers/sample-packs/gm-lite.toml
node scripts/build-sample-pack.mjs gm-lite --publish

Unlike the deliberately elemental sfx library, a bank entry is named by its instrument (grand_piano, violin): a music case measures composition, not sample identification, so a real instrument name is correct here.

  • ffmpeg not found — the pack builds but is un-normalized (a raw skeleton copy). Install ffmpeg and rebuild for a real PCM-16 pack.
  • sha256 mismatch — the source changed at its URL, or the manifest’s hash is wrong. Re-curate the clip and update the manifest.
  • presign / upload fails — check the R2 credentials in .env (the right pair for the role) and that the bucket name and account id are correct. The first --publish is the real end-to-end test of the credentials.