Skip to content

Sounds manifest

For WaveformSounds, write one manifest for a whole pack instead of a JSON per sound: every file’s URL, a title, its type, BPM and key (read from the file name), its length and low-resolution peaks — so a list of hundreds of previews loads from one request.

Terminal window
npx @arraypress/waveform-gen ./public/previews/ --recursive \
--manifest ./public/previews/sounds.json --base-url /previews/
<div data-waveform-sounds data-manifest="/previews/sounds.json"></div>
Flag Default Description
--manifest <file> — Where to write the manifest. Its folders are created as needed. Without --output, no per-file JSON is written — only the manifest.
--root <dir> the deepest folder containing every input The folder URLs and types are relative to. A directory argument counts as itself, a file argument (e.g. one a shell glob expanded) as its folder. A file outside --root fails with … is outside the manifest root ….
--base-url <url> / The public URL --root is served at. A path (/previews/) or an origin (https://cdn.example.com/packs); a trailing / is added if missing.
--type <name> the file’s folder name One type for every sound. By default a file’s type is the name of the folder it’s in — and files directly in --root get none.
--manifest-bars <n> 64 Bars per sound in peaks. An integer of 1 or more, or the run exits 1.
--waveform-base-url <url> --base-url The public URL of --output. With --output, each sound links its full per-file JSON as waveform (used by the strip layout’s docked player).

They combine with the usual flags: --recursive to walk sub-folders (which is what gives sounds their types), --bpm to detect a tempo for files whose name carries none, --samples for the resolution decoded at before downsampling to --manifest-bars, --quiet, and --output / --precision for the per-file JSON. Value flags take --flag value or --flag=value. --manifest with --format inline is a usage error (exit 2).

Every url is --base-url joined with the file’s path relative to --root, each segment URL-encoded (Drum Loops/kick 1.wav → Drum%20Loops/kick%201.wav). The default --base-url is / — the site root — so set it whenever the audio isn’t served from the root:

Files on disk Served at Command A url
public/previews/Bass/loop.wav /previews/ waveform-gen ./public/previews/ --recursive --manifest … --base-url /previews/ /previews/Bass/loop.wav
public/previews/Bass/loop.wav — the same, with no --base-url /Bass/loop.wav — wrong, a 404
packs/neon/Bass/loop.wav https://cdn.example.com/neon/ waveform-gen ./packs/neon/ --recursive --manifest … --base-url https://cdn.example.com/neon/ https://cdn.example.com/neon/Bass/loop.wav

The manifest file itself can live anywhere — its location doesn’t change the URLs. With --output, the waveform links are built the same way from --waveform-base-url and each JSON’s path under --output:

Terminal window
npx @arraypress/waveform-gen ./public/previews/ --recursive \
--manifest ./public/previews/sounds.json --base-url /previews/ \
--output ./public/waveforms/ --waveform-base-url /waveforms/
{
"version": 1,
"sounds": [
{
"url": "/previews/Bass/NW_Bass_Loop_04_128_Fmin.wav",
"title": "NW Bass Loop 04",
"type": "Bass",
"bpm": 128,
"key": "Fm",
"loop": true,
"duration": 8.02,
"peaks": "1f3a…"
}
]
}

Pretty-printed (2-space indent) with a trailing newline. Each sound has, in this order:

Field Always? Notes
url Yes --base-url + the path relative to --root, segment-encoded.
title Yes The file name with its extension dropped, separators (_, -, spaces) turned into single spaces, and the BPM and key words removed. If nothing would be left, the whole name.
type When there is one --type, else the name of the file’s folder. None for a file directly in --root.
bpm When known From the file name; with --bpm, a detected tempo fills in when the name has none. Left out otherwise — never guessed.
key When known From the file name, in the short form (Fm, C#, Bb). Left out otherwise.
loop Loops only true when a folder or the file name has the word “loop” in it (Loops from the path, 2.2.0+). Left out for everything else — a one-shot.
duration When decoded Seconds, to two decimals.
peaks Yes An 8-bit hex string, two lowercase characters per bar (00–ff).
waveform With --output The URL of the sound’s full per-file JSON.

Peaks. 64 bars by default (--manifest-bars), downsampled from the same normalised peaks the per-file JSON holds by keeping the loudest value in each bucket — so a short transient (a kick, a snare hit) still shows, and the loudest bar is ff. The encoding is byte-for-byte WaveformSounds’ own encodePeaks: each value clamped to 0..1, scaled by 255 and rounded. At 64 bars that’s 128 characters a sound. There is no peakScale in the output — it only applies to integer arrays, and hex needs none.

Order. Sounds are sorted by path, in natural order — Loop 2 before Loop 10, folder by folder, case-insensitively.

Failures. A file that fails to decode is left out of the manifest and still fails the run (exit 1); the manifest is written with every file that succeeded. With --output, a sound is only listed once its JSON has been written, so it never links a file that doesn’t exist.

Without --quiet (and without --output), each file’s line shows what was read from its name — ✅ NW_Bass_Loop_04_128_Fmin.wav (128 BPM, Fm) — and the run ends with a summary: 📋 ./public/previews/sounds.json: 300 sounds, 212 with BPM, 187 with key. Worth a glance — it’s how you spot a pack whose names the parser couldn’t read.

The parser errs towards leaving a value out: an unknown BPM just isn’t filterable, while a wrong one files the sound under the wrong filter. The words of a name are the parts between spaces, _ and -; brackets and trailing punctuation around a word are ignored, so (C# minor) and [140 BPM] read fine.

BPM — a whole word that is a number from 50 to 220:

  • Marked tempos win: 128bpm, bpm128, 128 BPM, BPM_174.
  • A bare number counts too (_128_) — but not one with a leading zero (090 is an index), and not when there are two different bare candidates with nothing to choose between them. Then the one sitting right beside a key wins; if neither is, there’s no BPM.
  • Numbers outside 50–220 (250, 04) are never a BPM.

Key — a whole word (or a root followed by a separate mode word, F_minor, C# Major):

  • Read anywhere: a mode word (Fmin, Cmaj, F#minor, Bbmin), a sharp (F#, A#m), a flat with m (Bbm), or an upper-case root with a lower-case m (Fm — but not FM, the synth, and not fm).
  • Read only right beside the BPM: the ambiguous spellings — a lone root (A, C), a lone flat (Eb), and Am (also an English word).
  • Written in the short form: Fmin → Fm, Cmaj → C, A♯ → A#.
File name title bpm key
NW_Bass_Loop_04_128_Fmin.wav NW Bass Loop 04 128 Fm
Lead 128bpm A#m.wav Lead 128 A#m
Vox (C# minor) [140 BPM].wav Vox 140 C#m
Bass_F_minor_124.wav Bass 124 Fm
Keys_Gbmaj_96.wav Keys 96 Gb
Drums_BPM_174.wav Drums 174 —
Chord_Loop_A_120.wav Chord Loop 120 A (beside the BPM)
Chord_Loop_A.wav Chord Loop A — — (a lone A, no BPM beside it)
Pluck_120_Am.wav Pluck 120 Am (beside the BPM)
Am_Pluck_120.wav Am Pluck 120 — (Am isn’t beside the BPM)
Loop_120_Eb.wav Loop 120 Eb
Synth FM 128.wav Synth FM 128 — (FM is the synth)
Pad_090_Cmaj.wav Pad 090 — (leading zero: an index) C
Loop_90_128.wav Loop 90 128 — (two bare numbers, no key beside either) —
Loop_100_140_Fm.wav Loop 100 140 (the one beside the key) Fm
Perc_250.wav Perc 250 — (out of range) —
Kick_01.wav Kick 01 — —

Names the parser can’t read are left with no BPM or key — fill them in afterwards if you know them, or rename the files. WaveformSounds normalises keys again when it reads the manifest, so a hand-written "F minor" works too.

2.2.0+. A sound gets "loop": true when a folder or the file name has the word loop or loops in it; everything else is left unmarked, which WaveformSounds reads as a one-shot. A whole word only, and camelCase is split first:

Path loop
Drum Loops/Groove_01.wav true (the folder)
Bass_Loop_01.wav true
DrumLoop_120.wav true (camelCase)
Loopmasters_Kick.wav — (inside another word)
Kick_128.wav — (a tempo doesn’t make a loop)

In code, isLoopPath(rel) gives the same answer.

The same thing as a library call, for a build script:

import { buildManifest } from '@arraypress/waveform-gen';
import { writeFile } from 'node:fs/promises';
const manifest = await buildManifest(['./public/previews/Bass/loop-01.wav', './public/previews/Drums/kick.wav'], {
root: './public/previews',
baseUrl: '/previews/',
});
await writeFile('./public/previews/sounds.json', JSON.stringify(manifest, null, 2) + '\n');

Unlike the CLI, buildManifest() takes a list of files (expand your folders yourself) and throws on the first file it can’t decode. See Library API for its options and the pieces it’s built from — parseFilename() among them.