rockbox-ffi
v0.5.0
Published
TypeScript bindings (Bun + Deno + Node.js FFI) for the Rockbox DSP / metadata / playback engine
Readme
rockbox-ffi (TypeScript)
TypeScript bindings for the Rockbox audio engine —
metadata parsing (40+ formats), the DSP pipeline (EQ, tone, surround,
compressor, ReplayGain, resampler), a streaming codec decoder, and a
queue-based player with crossfade. Local files and http(s):// streams
alike. One typed API, three runtimes: Bun, Deno, and Node.js.
📖 Sound settings reference — the equalizer, tone, crossfeed, compressor and other DSP controls mirror Rockbox's own. See the official Rockbox manual — Sound Settings.
import { metadata, Dsp } from "rockbox-ffi/bun";
const meta = metadata.read("song.flac");
console.log(`${meta.artist} — ${meta.title} (${meta.duration_ms} ms)`);Install
npm install rockbox-ffi # Node.js (also pulls in koffi)
bun add rockbox-ffi # Bun
deno add npm:rockbox-ffi # Deno (or import npm:rockbox-ffi/deno directly)Then import the entry point for your runtime — all three expose the identical API:
| Runtime | Import | Notes |
| ------- | -------------------------------------- | ------------------------------------------------ |
| Bun | import … from "rockbox-ffi/bun" | uses built-in bun:ffi |
| Deno | import … from "npm:rockbox-ffi/deno" | run with --allow-ffi --allow-read --allow-env |
| Node.js | import … from "rockbox-ffi/node" | uses koffi (a dependency) |
Prefer a single import?
import { load } from "rockbox-ffi"returns a promise that resolves the correct backend at runtime (see Runtime auto-detection).
The native library
These bindings call into the Rockbox engine through a native shared library
(librockbox_ffi.dylib / .so). Point the package at it with the
ROCKBOX_FFI_LIB environment variable:
export ROCKBOX_FFI_LIB=/path/to/librockbox_ffi.dylibIf unset, the loader searches upward from the package for a
target/release/librockbox_ffi.{dylib,so} (handy when working inside the
Rockbox repo). See Building the native library.
Quick start
import {
metadata,
Dsp,
Decoder,
Player,
sineStereo,
DspReplayGainMode,
ReplayGainMode,
CrossfadeMode,
} from "rockbox-ffi/bun"; // or /node, or npm:rockbox-ffi/deno
// --- metadata --------------------------------------------------------
const meta = metadata.read("song.flac");
console.log(meta.artist, "—", meta.title, `${meta.duration_ms} ms`);
console.log(metadata.probe("track.opus")); // "Opus" (extension guess, no I/O)
// --- DSP: process interleaved stereo Int16 ---------------------------
const dsp = new Dsp(44_100);
dsp.eqEnable(true);
dsp.setEqBand(0, /*cutoffHz*/ 60, /*q*/ 0.7, /*gainDb*/ 3.0);
dsp.setReplaygain(DspReplayGainMode.TRACK, /*noclip*/ true, /*preampDb*/ 0.0);
dsp.setReplaygainGains(/*trackGainDb*/ -6.02); // −6 dB ≈ half amplitude
const input = sineStereo(1_000, 1.0, 44_100); // 1 s of a 1 kHz test tone
const output: Int16Array = dsp.process(input);
dsp.close();
// --- codecs: decode a file to PCM, one chunk at a time ---------------
const dec = new Decoder("song.flac"); // or an http(s):// URL
console.log(dec.metadata().title); // tags from the open file
for (let c = dec.nextChunk(); c !== null; c = dec.nextChunk()) {
// c.samples: interleaved-stereo Int16 PCM, c.sampleRate: Hz
}
console.log(dec.finished()); // { done: true, code: 0 } (0 = clean end)
dec.close();
// --- playback (needs an audio output device) -------------------------
const player = new Player({ volume: 0.8, crossfadeMode: CrossfadeMode.ALWAYS });
player.setReplaygain(ReplayGainMode.TRACK, 0.0, true);
// Queue entries may be local files, http(s):// URLs to remote media, or
// live-radio / streaming URLs — mix and match freely.
player.setQueue([
"a.flac",
"https://example.com/b.mp3",
"http://radio.example/stream",
]);
player.play();
console.log(player.status()); // { state: "playing", index: 0, ... }API
Everything is fully typed — the tables below are a map; your editor has the details.
metadata
| Function | Returns | Description |
| -------------------------- | ---------------- | -------------------------------------------------------- |
| metadata.read(path) | Metadata | Parse tags, duration, ReplayGain, album-art/cue offsets |
| metadata.probe(filename) | string \| null | Codec label from the extension, without opening the file |
Dsp
Interleaved-S16LE-stereo processor. Construct with a sample rate, feed it
Int16Arrays, and close() (or using) when done.
const dsp = new Dsp(sampleRate: number);| Method | Description |
| ---------------------------------------------------------------- | ------------------------------------------------------------ |
| process(samples: Int16Array): Int16Array | Run stereo S16 frames through the pipeline |
| setInputFrequency(hz) | Change input rate (engages the resampler) |
| eqEnable(on) / setEqBand(band, cutoffHz, q, gainDb) | 10-band EQ (band 0 low-shelf, 9 high-shelf) |
| setEqPrecut(db) | Negative pre-gain to avoid EQ clipping |
| setTone(bassDb, trebleDb) / setToneCutoffs(bHz, tHz) | Bass/treble shelves |
| setSurround(delayMs, balance, fx1, fx2) | Haas surround (delayMs > 0 enables) |
| setChannelConfig(mode) / setStereoWidth(pct) | Channel mode / custom width |
| setCompressor(threshold, makeup, ratio, knee, rel, atk) | Dynamic-range compressor (threshold 0 = off) |
| setReplaygain(mode, noclip, preampDb) | ReplayGain mode (see encodings) |
| setReplaygainGains(trackDb?, albumDb?, trackPeak?, albumPeak?) | Per-track gains in dB (omit = absent) |
| setReplaygainGainsRaw(tg, ag, tp, ap: bigint) | Native Q7.24 gains (the raw_* metadata fields) |
| flush() / close() | Drop buffered samples / free the handle |
Decoder
Streaming codec engine: decodes one audio source to interleaved-S16LE-stereo
PCM, one chunk at a time. The source may be a local file or an http(s)://
URL to remote media. The codec state is process-wide, so keep only one
Decoder alive at a time and close() (or using) when done.
const dec = new Decoder(pathOrUrl: string);| Method | Description |
| ---------------------------------------------- | ------------------------------------------------------------------ |
| metadata(): Metadata | Tags and stream properties (same shape as metadata.read) |
| nextChunk(): { samples, sampleRate } \| null | Next PCM buffer (interleaved stereo Int16); null at end of track |
| seekMs(ms) | Request a seek to ms milliseconds |
| elapsedMs(): number | Playback position last reported by the codec |
| finished(): { done, code } | done once ended; code is 0 (clean) or negative (codec error) |
| close() | Free the handle |
Player
Queue-based player backed by a live audio device and a background thread.
Queue entries may be local files, http(s):// URLs to remote media, or
live-radio / streaming URLs.
const player = new Player(config?: PlayerConfig);| Method | Description |
| -------------------------------------------------------------- | ---------------------------------------- |
| setQueue(paths) / enqueue(path) | Replace / append (files or http(s)://) |
| remove(index) / clearQueue() | Drop one track / empty the queue |
| play() pause() toggle() stop() | Transport |
| next() previous() skipTo(index) seekMs(ms) | Navigation |
| setVolume(v) / volume() | Volume, 0.0–1.0 |
| sampleRate() | Output rate everything resamples to |
| setCrossfade(mode, foDelay?, foDur?, fiDelay?, fiDur?, mix?) | Configure crossfade |
| setReplaygain(mode, preampDb, preventClipping) | ReplayGain (player encoding) |
| status(): PlayerStatus | Snapshot: state, index, position, queue… |
| close() | Stop playback and free the handle |
PlayerConfig (all optional): sampleRate (0 = device default),
bufferSeconds, volume, replaygainMode, replaygainPreampDb,
replaygainPreventClipping, crossfadeMode, fadeOutDelayMs,
fadeOutDurationMs, fadeInDelayMs, fadeInDurationMs, mixMode.
Enums & helpers
DspReplayGainMode, ReplayGainMode, CrossfadeMode, MixMode,
ChannelConfig, and sineStereo(freqHz, seconds, rate, amplitude?) for
generating test tones. abiVersion() returns the native ABI version.
Types
Metadata, PlayerStatus, ReplayGain, AlbumArt, Cuesheet,
PlayerConfig are exported for annotations. JSON field names are snake_case
(e.g. duration_ms, sample_rate, replaygain.raw_track_gain).
Resource management
Dsp, Decoder, and Player hold native handles. Free them with close(),
or let using do it automatically (all implement Symbol.dispose):
using dsp = new Dsp(44_100);
// dsp.close() runs at end of scopeEverything else that crosses the FFI boundary (JSON strings, sample buffers)
is freed inside the wrappers — you never call a *_free yourself.
Note: the
Dspwraps a process-wide singleton, so keep only one alive at a time and use it from a single thread.
Two ReplayGain encodings
The DSP and player take different mode integers (a quirk of the underlying C ABI) — use the named enums and you won't have to remember which:
Dsp.setReplaygain→DspReplayGainMode(TRACK=0, ALBUM=1, SHUFFLE=2, OFF=3)Player.setReplaygain→ReplayGainMode(OFF=0, TRACK=1, ALBUM=2)
Runtime auto-detection
If you don't want to hard-code the backend, import load from the package
root; it dynamically imports the right module (so the other runtimes' FFI code
is never parsed):
import { load } from "rockbox-ffi";
const { metadata, Dsp, Player } = await load();Developing
The rest is only relevant when hacking on the bindings themselves inside the Rockbox repo.
Building the native library
cargo build --release -p rockbox-ffi
# target/release/librockbox_ffi.dylib (macOS)
# target/release/librockbox_ffi.so (Linux)The loader finds this automatically when the package sits inside the repo;
elsewhere, set ROCKBOX_FFI_LIB.
Running the smoke tests
bun run examples/smoke.bun.ts
deno run --allow-ffi --allow-read --allow-env examples/smoke.deno.ts
npm install && npx tsx examples/smoke.node.ts
bunx tsc --noEmit # type checkBuilding & publishing
The package is authored in TypeScript and shipped as bundled ESM + .d.ts
under dist/:
bun install
bun run build # Bun.build + tsc -> dist/*.js + dist/*.d.ts
npm publish # prepublishOnly runs the build automaticallybuild bundles the four entry points (index / bun / deno / node),
keeping bun:ffi and koffi external, so consumers get the right backend via
the package exports map (., ./bun, ./deno, ./node).
