Skip to content

Library API

The package’s one entry (@arraypress/waveform-gen, ESM) exports generatePeaks and roundPeaks, plus — since 2.1.0 — the sounds-manifest functions: buildManifest, manifestEntry, isLoopPath (2.2.0+), createManifest, parseFilename, encodePeaks, resamplePeaks, fileUrl, naturalCompare, commonRoot, relativeTo, and the constants MANIFEST_VERSION (1) and DEFAULT_MANIFEST_BARS (64). No typings ship with it.

import { generatePeaks } from '@arraypress/waveform-gen';
const { peaks, bpm, duration } = await generatePeaks('./song.mp3', {
samples: 1800, // optional — library now defaults to 1800 (matches the CLI)
precision: 2,
detectBPM: true,
});
// peaks → number[] normalized 0..1 (loudest = 1.0)
// bpm → number | null
// duration → number (seconds, 2.1.0+)

(filePath: string, options?: { samples?: number; precision?: number; detectBPM?: boolean }) => Promise<{ peaks: number[]; bpm: number | null; duration: number }>

Reads and decodes the file, extracts max-amplitude peaks per bin across all channels, normalizes so the loudest peak is 1.0, and rounds to precision.

Option Default Type Description
samples 1800 number Number of peaks. Must be a positive integer; numeric strings ('400') are accepted. Omitted / null → 1800. 0, negatives and non-integers throw (2.0.0+).
precision 2 number Decimal places. >= 0 rounds; < 0 returns unrounded normalized peaks.
detectBPM false boolean When true, also run BPM detection and return bpm (number | null).

Returns a Promise resolving to { peaks, bpm, duration }. bpm is null unless detectBPM is true and a stable tempo was found. duration is the decoded length in seconds (2.1.0+).

roundPeaks(peaks, precision) is the rounding step on its own — a negative precision returns the peaks unchanged.

Rejects on an invalid samples, a file that can’t be read, or one that can’t be decoded — a corrupt mp3/wav/flac/ogg is reported as appears to be corrupt or unreadable, m4a/aac get a convert-first hint, and a file that decodes to no audio throws instead of returning empty peaks.

import { generatePeaks } from '@arraypress/waveform-gen';
import { writeFile } from 'node:fs/promises';
const result = await generatePeaks('./track.wav', { samples: 1800 });
// Build the same JSON shape the CLI writes
const output = { peaks: result.peaks };
if (result.bpm != null) output.bpm = result.bpm;
await writeFile('./waveforms/track.json', JSON.stringify(output, null, 2) + '\n');

BPM is energy/onset based (window 2048, hop 1024): it detects transients, buckets the intervals into tempos 60 < bpm < 200, picks the most common, applies octave-doubling/halving correction, and subtracts a -1 calibration offset. It returns:

  • an integer when a stable tempo is found,
  • null when there are fewer than 2 onsets or no bucket matched.

It’s a heuristic, not exact metadata — verify critical values. When null, the CLI omits bpm from the JSON entirely.

The functions behind --manifest (2.1.0+). Everything but buildManifest is pure — no decoding, no file system.

(files: string[], options?) => Promise<{ version: 1, sounds: object[] }>

Decodes each file and returns a WaveformSounds manifest, sorted by path in natural order. files are audio file paths — expand folders yourself. It rejects on the first file it can’t decode (the CLI instead leaves that file out and exits 1), on a file outside root, and on a bars that isn’t a positive integer.

Option Default Description
root the deepest folder containing every file The folder URLs and types are relative to.
baseUrl '/' The public URL of root.
bars 64 Bars per sound in the hex peaks.
type the file’s folder name One type for every sound.
detectBPM false Detect a tempo for files whose name carries none.
samples 1800 Resolution decoded at before downsampling to bars.
waveform — (file, rel) => string | undefined — the URL of a file’s full peaks JSON, written as that sound’s waveform.
import { buildManifest } from '@arraypress/waveform-gen';
const manifest = await buildManifest(files, { root: './public/previews', baseUrl: '/previews/' });
Function Description
parseFilename(name) { title, bpm, key } read from a file name or path — the rules. bpm and key are null when not found. parseFilename('NW_Bass_Loop_04_128_Fmin.wav') → { title: 'NW Bass Loop 04', bpm: 128, key: 'Fm' }.
manifestEntry(analysis, options?) One sound from { rel, peaks, duration?, bpm? } (bpm is a detected tempo, used only when the name has none) and { baseUrl, bars, type, waveform }. Adds loop: true when the path names a loop (2.2.0+).
isLoopPath(rel): boolean Whether a folder or the file name has the word “loop” / “loops” in it (camelCase split first; a whole word only). 2.2.0+.
createManifest(items) { version: 1, sounds } from { rel, entry }[], sorted by rel in natural order.
encodePeaks(peaks) Peaks 0..1 → 8-bit hex, byte-for-byte WaveformSounds’ encodePeaks.
resamplePeaks(peaks, count) Downsample to count bars, keeping the loudest value in each bucket.
fileUrl(relPath, baseUrl = '/') baseUrl + the path, each segment URL-encoded (a trailing / is added to baseUrl).
naturalCompare(a, b) The path order: numeric, case-insensitive, folder by folder.
commonRoot(entries) The deepest folder containing every { path, isDir } entry (a file counts as its folder); null when there is none.
relativeTo(root, file) A file’s path under root with / separators; throws when it’s outside.

The generated JSON is consumed by the player and bar via attributes (no WaveformGen-specific attributes exist — these belong to the consuming libraries):

<div data-waveform-player
data-url="song.mp3"
data-waveform="waveforms/song.json"></div>

Or via JS — see Options:

new WaveformPlayer(el, {
url: 'song.mp3',
waveform: 'waveforms/song.json',
});

When a player is given pre-generated peaks, it skips client-side decoding entirely and renders the waveform immediately. If the JSON also carries bpm and markers, those flow through to the player’s tempo display and marker track.

Batch a folder into a waveforms/ directory

Section titled “Batch a folder into a waveforms/ directory”
Terminal window
waveform-gen ./public/audio/ --recursive --output ./public/waveforms/ --samples 1800 --bpm

Every supported file under ./public/audio/ becomes a JSON at the same relative path under ./public/waveforms/ — audio/albums/intro.mp3 → waveforms/albums/intro.json — with peaks, detected bpm, and any sidecar markers (2.0.0+; earlier versions flattened nested files into one folder).

package.json
{
"scripts": {
"waveforms": "waveform-gen ./src/audio/ --recursive --output ./public/waveforms/ --samples 1800 --bpm --quiet",
"prebuild": "npm run waveforms"
}
}

--quiet keeps CI logs clean — it hides progress only, so errors still reach stderr. Wiring it to prebuild regenerates peaks before every production build, and since a failed file exits 1 (2.0.0+), a corrupt file stops the build instead of shipping a missing JSON.

When you need more control — your own index file, custom file names, or merged metadata — drive the library directly. (For a WaveformSounds list, use --manifest or buildManifest() instead: they write the format it reads.)

import { generatePeaks } from '@arraypress/waveform-gen';
import { readdir, writeFile, mkdir } from 'node:fs/promises';
import { join, basename, extname } from 'node:path';
const SRC = './src/audio';
const OUT = './public/waveforms';
const SAMPLES = 1800; // match the CLI default explicitly
await mkdir(OUT, { recursive: true });
const manifest = {};
for (const file of await readdir(SRC)) {
if (!/\.(mp3|wav|flac|ogg)$/i.test(file)) continue;
const name = basename(file, extname(file));
try {
const { peaks, bpm } = await generatePeaks(join(SRC, file), {
samples: SAMPLES,
precision: 2,
detectBPM: true,
});
const output = { peaks };
if (bpm != null) output.bpm = bpm;
await writeFile(join(OUT, `${name}.json`), JSON.stringify(output, null, 2) + '\n');
manifest[file] = { waveform: `waveforms/${name}.json`, bpm };
} catch (err) {
console.error(`[build] ${file}: ${err.message}`); // don't abort the batch
}
}
await writeFile(join(OUT, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');