Skip to content

elijahrode/wavekit

Repository files navigation

wavekit

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.

Installation

pnpm add wavekit

No Rust toolchain is needed as the analyzer wasm ships prebuilt.

What it does

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 support

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 = 255

Everything 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.

Transcode into R2

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.

Tag at download time

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.

Serve ALAC to browsers

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 });

How it stays inside 128 MB

  • 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.

Setup notes

  • Set limits.cpu_ms high 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.

To do

  • 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

Feature requests

  • 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.

Development

git clone https://github.com/elijahrode/wavekit && cd wavekit
pnpm install
pnpm run build:wasm   # rustup + wasm32-unknown-unknown target + wasm-pack
  • build:wasm regenerates analyzer/pkg/ which is normally shipped

License

MIT

About

All in one audio toolkit for Cloudflare Workers

Resources

License

Stars

2 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors