A modern, network-enabled music player platform built on Rockbox technology. rockboxd.tsiry-sandratraina.com
rust deno navidrome airplay libadwaita zig mpris snapcast mpd rockbox audio subsonic
README.md

rockbox-ffi (TypeScript) #

npm TypeScript Bun Deno Node.js License

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

If 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 streams, or HLS / MPEG-DASH manifests (.m3u8/.mpd) — mix and match freely.
player.setQueue([
  "a.flac",
  "https://example.com/b.mp3",
  "http://radio.example/stream",
  "https://cdn.example.com/live/main.m3u8",
]);
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 scope

Everything else that crosses the FFI boundary (JSON strings, sample buffers) is freed inside the wrappers — you never call a *_free yourself.

Note: the Dsp wraps 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 check

Building & 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 automatically

build 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).