All in one audio toolkit for Cloudflare Workers.
Waveform peaks, duration, MP3 transcoding, native-container tagging, and ALAC to WAV streaming from inside a worker.
pnpm add wavekitNo Rust toolchain is needed as the analyzer wasm ships prebuilt.
analyze() |
any supported format → N peak buckets + exact duration |
transcodeToMp3() |
decode + LAME encode into R2 in one streaming pass |
tagStream() |
stream a file back with title/artist/album/cover written into its native container, no re-encode |
wavBridge() |
serve an ALAC/AAC m4a as a seekable WAV for <audio> |
sniffSource() |
detect the real container from magic bytes |
| Format | Peaks / duration | Memory | Transcode source | Tagging |
|---|---|---|---|---|
| WAV (int 8/16/24/32, float 32/64, extensible) | streamed | constant, any size | yes | RIFF INFO + id3 chunk |
| FLAC | streamed | constant, any size¹ | yes | Vorbis comment + PICTURE |
| M4A (AAC & ALAC) | streamed | constant, any size¹ | yes | iTunes ilst |
| MP3 | streamed | constant, any size¹ | no² | ID3v2.3 |
| AAC (ADTS) | buffered | ≤ 80 MB default cap | no² | pass-through |
| Ogg Vorbis | buffered | ≤ 80 MB default cap | no | pass-through³ |
¹ Structurally odd files (fragmented MP4s, lost sync) fall back to a buffered decode if they fit under the cap
² Browsers already play MP3 and AAC natively, and re-encoding them only loses quality, so transcodeToMp3() refuses
³ Ogg tagging would need lacing-aware page rewrites. Files pass through
untouched with tagged: false (can possibly implement in the future)
import { analyze, fromR2 } from "wavekit";
const { peaks, durationSeconds, format } = await analyze(
fromR2(env.BUCKET, "uploads/song.flac"),
{ peaks: 400 },
);
// peaks: Uint8Array(400), RMS per bucket, loudest = 255Everything takes a ByteSource, so the decoders don't care where the bytes
live:
fromR2(bucket, key) // R2 binding
fromBytes(uint8array) // request bodies, tests
fromUrl(url) // anything that honors Range headers (presigned URLs)There is an example worker with all of these functions in examples/worker.
import { transcodeToMp3, fromR2, R2Sink } from "wavekit";
const result = await transcodeToMp3(
fromR2(env.BUCKET, "uploads/master.wav"),
new R2Sink(env.BUCKET, "playback/song.mp3"),
{ bitrate: 320 },
);
// { bytes, durationSeconds, sampleRate, channels, format }One streaming pass, so a 500 MB WAV never sits in memory. The encoder yields to the event loop every half second of audio to avoid starving other requests on the isolate, and R2Sink deals with R2's multipart rule that every non-trailing part must be exactly the same size. Output is MP3 because no open AAC encoder compiles to WASM.
import { tagStream, fetchCover, fromR2 } from "wavekit";
const result = await tagStream(fromR2(env.BUCKET, "uploads/song.m4a"), {
tags: { title: "Song", artist: "Artist", album: "Album", track: 3, trackTotal: 12 },
cover: await fetchCover("https://example.com/cover-600.jpg"),
});
ctx.waitUntil(result.done);
return new Response(result.stream, {
headers: { "content-type": result.contentType, "content-length": `${result.byteLength}` },
});Tags get written into the container as the bytes stream out, so there's nothing stored and nothing to go stale when a track gets renamed or a cover swapped. The audio bytes are untouched.
Every browser except Safari does not support ALAC encoded M4A's, but ALAC is just lossless PCM so you can just stream it.
wavBridge() decodes on the fly and serves what looks like a static WAV file with real Content-Length and Range support.
A WAV byte offset maps straight to an audio frame, so seeking decodes from the seek point instead of from zero.
const bridge = await wavBridge(fromR2(env.BUCKET, key), {
rangeHeader: request.headers.get("range"),
});
ctx.waitUntil(bridge.done);
return new Response(bridge.body, { status: bridge.status, headers: bridge.headers });- WAV is PCM, so there's no decoder at all. Peaks fold in as the stream flows through.
- M4A keeps its sample tables (
moov) at the end of the file, which is what breaks naive streaming. wavekit range-reads just that box, then streams only the audio bytes. - FLAC and MP3 get re-framed on the fly and decoded frame by frame.
- Everything else is buffered into WASM memory once (via a shared input
buffer) and capped at
maxBufferedBytes, 80 MB by default.
Peaks are RMS per bucket rather than max amplitude. Modern masters are brick-wall limited, so max peaks render as a solid rectangle. Generating the peaks this way provides a better visual representation of the sound.
- Set
limits.cpu_mshigh in your wrangler config. Decoding big files takes real CPU, transcoding more so. - The LAME encoder binary is vendored at
vendor/mp3.wasm, vendor/README.md explains why. - One WASM instance per isolate. Buffered analyses queue behind an internal lock; the streamed paths are unaffected.
- Opus at 96kbps is much more efficient than mp3 at 320kbps without a quality trade-off, but it is not implemented yet because opus only takes 48 kHz input and most music is 44.1 kHz, so we need a proper streaming resampler which is not yet added. Feel free to make a PR if you have a good implementation
- I currently add features to this as I need them for my personal projects but if you have anything useful that is not currently added feel free to make an issue to request or PR if you want to implement it yoursel.
git clone https://github.com/elijahrode/wavekit && cd wavekit
pnpm install
pnpm run build:wasm # rustup + wasm32-unknown-unknown target + wasm-packbuild:wasmregeneratesanalyzer/pkg/which is normally shipped
MIT