rockbox-ffi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ffi.ts ADDED
@@ -0,0 +1,144 @@
1
+ // Runtime-agnostic FFI description: one symbol table with abstract type
2
+ // tokens that each backend (bun.ts / deno.ts) maps to its own FFI types.
3
+
4
+ /** Abstract argument/return type tokens used by the symbol spec. */
5
+ export type Tok =
6
+ | "void"
7
+ | "ptr" // opaque handle / returned pointer
8
+ | "u32"
9
+ | "i32"
10
+ | "u64"
11
+ | "usize"
12
+ | "f32"
13
+ | "bool"
14
+ | "strbuf" // input: a JS string marshalled as `const char *`
15
+ | "i16in" // input: an Int16Array marshalled as `const int16_t *`
16
+ | "outsize"; // input: a `size_t *` out-parameter
17
+
18
+ export interface SymSpec {
19
+ args: Tok[];
20
+ ret: Tok;
21
+ }
22
+
23
+ // Mirrors include/rockbox_ffi.h. Keep in sync with the C ABI.
24
+ export const SPEC: Record<string, SymSpec> = {
25
+ rb_ffi_abi_version: { args: [], ret: "u32" },
26
+ rb_string_free: { args: ["ptr"], ret: "void" },
27
+ rb_buffer_free: { args: ["ptr", "usize"], ret: "void" },
28
+
29
+ rb_dsp_new: { args: ["u32"], ret: "ptr" },
30
+ rb_dsp_free: { args: ["ptr"], ret: "void" },
31
+ rb_dsp_set_input_frequency: { args: ["ptr", "u32"], ret: "void" },
32
+ rb_dsp_flush: { args: ["ptr"], ret: "void" },
33
+ rb_dsp_eq_enable: { args: ["ptr", "bool"], ret: "void" },
34
+ rb_dsp_set_tone: { args: ["ptr", "i32", "i32"], ret: "void" },
35
+ rb_dsp_set_tone_cutoffs: { args: ["ptr", "i32", "i32"], ret: "void" },
36
+ rb_dsp_set_surround: { args: ["ptr", "i32", "i32", "i32", "i32"], ret: "void" },
37
+ rb_dsp_set_channel_config: { args: ["ptr", "i32"], ret: "void" },
38
+ rb_dsp_set_stereo_width: { args: ["ptr", "i32"], ret: "void" },
39
+ rb_dsp_set_compressor: {
40
+ args: ["ptr", "i32", "i32", "i32", "i32", "i32", "i32"],
41
+ ret: "void",
42
+ },
43
+ rb_dsp_set_replaygain: { args: ["ptr", "i32", "bool", "f32"], ret: "void" },
44
+ rb_dsp_set_replaygain_gains: {
45
+ args: ["ptr", "f32", "f32", "f32", "f32"],
46
+ ret: "void",
47
+ },
48
+ rb_dsp_set_replaygain_gains_raw: {
49
+ args: ["ptr", "u64", "u64", "u64", "u64"],
50
+ ret: "void",
51
+ },
52
+ rb_dsp_set_eq_band: { args: ["ptr", "usize", "i32", "f32", "f32"], ret: "void" },
53
+ rb_dsp_set_eq_precut: { args: ["ptr", "f32"], ret: "void" },
54
+ rb_dsp_process: { args: ["ptr", "i16in", "usize", "outsize"], ret: "ptr" },
55
+
56
+ rb_meta_read_json: { args: ["strbuf"], ret: "ptr" },
57
+ rb_meta_probe: { args: ["strbuf"], ret: "ptr" },
58
+
59
+ rb_player_new: { args: [], ret: "ptr" },
60
+ rb_player_new_with_config: {
61
+ args: [
62
+ "u32", "f32", "f32", "i32", "f32", "bool", "i32", "u32", "u32", "u32",
63
+ "u32", "i32",
64
+ ],
65
+ ret: "ptr",
66
+ },
67
+ rb_player_free: { args: ["ptr"], ret: "void" },
68
+ rb_player_set_queue_json: { args: ["ptr", "strbuf"], ret: "void" },
69
+ rb_player_enqueue: { args: ["ptr", "strbuf"], ret: "void" },
70
+ rb_player_play: { args: ["ptr"], ret: "void" },
71
+ rb_player_pause: { args: ["ptr"], ret: "void" },
72
+ rb_player_toggle: { args: ["ptr"], ret: "void" },
73
+ rb_player_stop: { args: ["ptr"], ret: "void" },
74
+ rb_player_next: { args: ["ptr"], ret: "void" },
75
+ rb_player_previous: { args: ["ptr"], ret: "void" },
76
+ rb_player_skip_to: { args: ["ptr", "usize"], ret: "void" },
77
+ rb_player_seek_ms: { args: ["ptr", "u64"], ret: "void" },
78
+ rb_player_set_volume: { args: ["ptr", "f32"], ret: "void" },
79
+ rb_player_set_crossfade: {
80
+ args: ["ptr", "i32", "u32", "u32", "u32", "u32", "i32"],
81
+ ret: "void",
82
+ },
83
+ rb_player_set_replaygain: { args: ["ptr", "i32", "f32", "bool"], ret: "void" },
84
+ rb_player_volume: { args: ["ptr"], ret: "f32" },
85
+ rb_player_sample_rate: { args: ["ptr"], ret: "u32" },
86
+ rb_player_status_json: { args: ["ptr"], ret: "ptr" },
87
+ };
88
+
89
+ /**
90
+ * The low-level backend a runtime loader must provide. High-level classes in
91
+ * api.ts are written once against this interface.
92
+ */
93
+ export interface Raw {
94
+ /** The dlopen'd symbol table; call e.g. `sym.rb_dsp_new(44100)`. */
95
+ sym: Record<string, (...a: any[]) => any>;
96
+ /** Marshal a JS string as a `const char *` argument. */
97
+ cstr(s: string): unknown;
98
+ /** Marshal an Int16Array as a `const int16_t *` argument. */
99
+ i16in(a: Int16Array): unknown;
100
+ /** Allocate a `size_t` out-parameter; `value()` reads it after the call. */
101
+ sizeOut(): { arg: unknown; value(): number };
102
+ /** Read a returned `char *` into a string and free it (null => null). */
103
+ takeString(p: unknown): string | null;
104
+ /** Read a returned `int16_t *` of `len` samples into a copy and free it. */
105
+ takeI16(p: unknown, len: number): Int16Array;
106
+ /** True if a returned handle/pointer is NULL. */
107
+ isNull(p: unknown): boolean;
108
+ }
109
+
110
+ const LIB_NAMES = ["librockbox_ffi.dylib", "librockbox_ffi.so", "rockbox_ffi.dll"];
111
+
112
+ /**
113
+ * Locate the shared library: `ROCKBOX_FFI_LIB` env var first, then by walking
114
+ * up from `startDir` to a `target/release` directory. `exists` and the path
115
+ * ops are injected so this works under both Bun and Deno.
116
+ */
117
+ export function resolveLibPath(
118
+ startDir: string,
119
+ env: (k: string) => string | undefined,
120
+ exists: (p: string) => boolean,
121
+ join: (...p: string[]) => string,
122
+ dirname: (p: string) => string,
123
+ ): string {
124
+ const override = env("ROCKBOX_FFI_LIB");
125
+ if (override) return override;
126
+
127
+ let dir = startDir;
128
+ const tried: string[] = [];
129
+ for (let i = 0; i < 40; i++) {
130
+ const rel = join(dir, "target", "release");
131
+ for (const name of LIB_NAMES) {
132
+ const p = join(rel, name);
133
+ tried.push(p);
134
+ if (exists(p)) return p;
135
+ }
136
+ const parent = dirname(dir);
137
+ if (parent === dir) break;
138
+ dir = parent;
139
+ }
140
+ throw new Error(
141
+ "could not locate librockbox_ffi. Set ROCKBOX_FFI_LIB or run " +
142
+ "`cargo build --release -p rockbox-ffi`. Tried:\n " + tried.join("\n "),
143
+ );
144
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Runtime auto-detecting entry point.
2
+ //
3
+ // Prefer importing the runtime-specific module directly (`rockbox-ffi/bun` or
4
+ // `./src/deno.ts`) — those load synchronously. This helper exists for code
5
+ // that wants a single import and can await initialization.
6
+
7
+ import type { makeApi } from "./api.ts";
8
+
9
+ export * from "./enums.ts";
10
+ export { sineStereo } from "./api.ts";
11
+ export type { Metadata, PlayerConfig, PlayerStatus } from "./types.ts";
12
+
13
+ type Api = ReturnType<typeof makeApi>;
14
+
15
+ let cached: Api | undefined;
16
+
17
+ /**
18
+ * Load the binding for the current runtime (Bun or Deno). Uses a dynamic
19
+ * import so the other runtime's FFI module is never parsed.
20
+ */
21
+ export async function load(): Promise<Api> {
22
+ if (cached) return cached;
23
+ // deno-lint-ignore no-explicit-any
24
+ const g = globalThis as any;
25
+ let mod: { abiVersion: Api["abiVersion"]; metadata: Api["metadata"]; Dsp: Api["Dsp"]; Player: Api["Player"] };
26
+ if (typeof g.Bun !== "undefined") {
27
+ mod = await import("./bun.ts");
28
+ } else if (typeof g.Deno !== "undefined") {
29
+ mod = await import("./deno.ts");
30
+ } else if (typeof g.process !== "undefined" && g.process.versions?.node) {
31
+ mod = await import("./node.ts");
32
+ } else {
33
+ throw new Error("unsupported runtime: need Bun, Deno, or Node.js");
34
+ }
35
+ cached = { abiVersion: mod.abiVersion, metadata: mod.metadata, Dsp: mod.Dsp, Player: mod.Player };
36
+ return cached;
37
+ }
package/src/node.ts ADDED
@@ -0,0 +1,87 @@
1
+ // Node.js backend + full API, using koffi for FFI. Import under Node:
2
+ // import { Dsp, Player, metadata } from "rockbox-ffi/node";
3
+ //
4
+ // Node has no built-in FFI, so this backend depends on `koffi`
5
+ // (https://koffi.dev) — install it with `npm install koffi`.
6
+
7
+ import { existsSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { createRequire } from "node:module";
11
+
12
+ import { makeApi, sineStereo } from "./api.ts";
13
+ import { resolveLibPath, SPEC, type Raw, type Tok } from "./ffi.ts";
14
+
15
+ // koffi ships as CommonJS; load it via require to keep ESM interop simple.
16
+ const require = createRequire(import.meta.url);
17
+ // deno-lint-ignore no-explicit-any
18
+ const koffi: any = require("koffi");
19
+
20
+ const TOK: Record<Tok, string> = {
21
+ void: "void",
22
+ ptr: "void *",
23
+ u32: "uint32",
24
+ i32: "int32",
25
+ u64: "uint64",
26
+ usize: "size_t",
27
+ f32: "float",
28
+ bool: "bool",
29
+ strbuf: "str", // koffi marshals a JS string to a NUL-terminated char*
30
+ i16in: "void *", // we pass a Buffer view of the Int16Array
31
+ outsize: "void *", // we pass a Buffer.alloc(8) and read it back
32
+ };
33
+
34
+ function makeRaw(): Raw {
35
+ const here = dirname(fileURLToPath(import.meta.url));
36
+ const libPath = resolveLibPath(
37
+ here,
38
+ (k) => process.env[k],
39
+ existsSync,
40
+ join,
41
+ dirname,
42
+ );
43
+
44
+ const lib = koffi.load(libPath);
45
+ const sym: Record<string, (...a: any[]) => any> = {};
46
+ for (const [name, spec] of Object.entries(SPEC)) {
47
+ sym[name] = lib.func(name, TOK[spec.ret], spec.args.map((t) => TOK[t]));
48
+ }
49
+
50
+ return {
51
+ sym,
52
+ cstr(s: string) {
53
+ // The `strbuf` params are typed "str"; koffi marshals the JS string.
54
+ return s;
55
+ },
56
+ i16in(a: Int16Array) {
57
+ return Buffer.from(a.buffer, a.byteOffset, a.byteLength);
58
+ },
59
+ sizeOut() {
60
+ const buf = Buffer.alloc(8);
61
+ return { arg: buf, value: () => Number(buf.readBigUInt64LE(0)) };
62
+ },
63
+ takeString(p: unknown) {
64
+ if (p === null || p === undefined) return null;
65
+ // Read a NUL-terminated char* of unknown length. (koffi's "str" type
66
+ // is unreliable for longer strings in 2.16 — this form is robust.)
67
+ const str = koffi.decode(p, "char", -1) as string;
68
+ sym.rb_string_free(p);
69
+ return str;
70
+ },
71
+ takeI16(p: unknown, len: number) {
72
+ const arr = koffi.decode(p, koffi.array("int16_t", len)) as number[];
73
+ const copy = Int16Array.from(arr);
74
+ sym.rb_buffer_free(p, len);
75
+ return copy;
76
+ },
77
+ isNull(p: unknown) {
78
+ return p === null || p === undefined;
79
+ },
80
+ };
81
+ }
82
+
83
+ const api = makeApi(makeRaw());
84
+ export const { abiVersion, metadata, Dsp, Player } = api;
85
+ export { sineStereo };
86
+ export * from "./enums.ts";
87
+ export type { Metadata, PlayerConfig, PlayerStatus } from "./types.ts";
package/src/types.ts ADDED
@@ -0,0 +1,80 @@
1
+ // Types mirroring the JSON shapes emitted by crates/rockbox-ffi (snake_case).
2
+
3
+ export interface ReplayGain {
4
+ track_gain_db: number | null;
5
+ album_gain_db: number | null;
6
+ track_peak: number | null;
7
+ album_peak: number | null;
8
+ raw_track_gain: number;
9
+ raw_album_gain: number;
10
+ raw_track_peak: number;
11
+ raw_album_peak: number;
12
+ }
13
+
14
+ export interface AlbumArt {
15
+ kind: "bmp" | "png" | "jpeg" | "unknown";
16
+ offset: number;
17
+ size: number;
18
+ id3_unsync: boolean;
19
+ vorbis_base64: boolean;
20
+ }
21
+
22
+ export interface Cuesheet {
23
+ offset: number;
24
+ size: number;
25
+ encoding: "latin1" | "utf8" | "utf16le" | "utf16be" | "unknown";
26
+ }
27
+
28
+ export interface Metadata {
29
+ codec: string;
30
+ codec_id: number;
31
+ title: string;
32
+ artist: string;
33
+ album: string;
34
+ albumartist: string;
35
+ composer: string;
36
+ grouping: string;
37
+ comment: string;
38
+ genre: string;
39
+ year_string: string;
40
+ track_string: string;
41
+ disc_string: string;
42
+ mb_track_id: string;
43
+ track_number: number | null;
44
+ disc_number: number | null;
45
+ year: number | null;
46
+ duration_ms: number;
47
+ bitrate: number;
48
+ sample_rate: number;
49
+ filesize: number;
50
+ samples: number;
51
+ vbr: boolean;
52
+ first_frame_offset: number;
53
+ replaygain: ReplayGain;
54
+ album_art: AlbumArt | null;
55
+ cuesheet: Cuesheet | null;
56
+ }
57
+
58
+ export interface PlayerStatus {
59
+ state: "stopped" | "playing" | "paused";
60
+ index: number | null;
61
+ position_ms: number;
62
+ duration_ms: number;
63
+ queue_len: number;
64
+ metadata: Metadata | null;
65
+ }
66
+
67
+ export interface PlayerConfig {
68
+ sampleRate?: number; // 0 / undefined => device default
69
+ bufferSeconds?: number;
70
+ volume?: number;
71
+ replaygainMode?: number; // ReplayGainMode
72
+ replaygainPreampDb?: number;
73
+ replaygainPreventClipping?: boolean;
74
+ crossfadeMode?: number; // CrossfadeMode
75
+ fadeOutDelayMs?: number;
76
+ fadeOutDurationMs?: number;
77
+ fadeInDelayMs?: number;
78
+ fadeInDurationMs?: number;
79
+ mixMode?: number; // MixMode
80
+ }