@pith-hash/pith-flac 0.0.0-stage → 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/index.js +219 -0
- package/package.json +31 -4
- package/prebuilds/darwin-arm64/libpith_flac.dylib +0 -0
- package/prebuilds/linux-x64/libpith_flac.so +0 -0
- package/prebuilds/win32-x64/pith_flac.dll +0 -0
- package/reference.json +106 -0
- package/README.md +0 -3
package/index.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 pith-hash
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* pith-flac SDK: FLAC decoding through koffi.
|
|
7
|
+
*
|
|
8
|
+
* Decodes a native FLAC stream (STREAMINFO, constant/verbatim
|
|
9
|
+
* subframes, fixed predictors of orders 0-4, both Rice methods) into
|
|
10
|
+
* the canonical byte stream the `reference.json` vectors are defined
|
|
11
|
+
* over: a 64-byte big-endian header followed by the decoded PCM,
|
|
12
|
+
* interleaved `i32` little-endian.
|
|
13
|
+
*
|
|
14
|
+
* The cdylib is located through the suite's discovery chain:
|
|
15
|
+
*
|
|
16
|
+
* 1. `PITH_CDYLIB` — an explicit cdylib *file* path;
|
|
17
|
+
* 2. `PITH_CDYLIB_DIR` — a *directory* scanned for the cdylib names
|
|
18
|
+
* (the CD pipeline points this at `target/release`);
|
|
19
|
+
* 3. `prebuilds/` — the packaged npm layout the CD publish job
|
|
20
|
+
* assembles, flat and per `<os-arch>` (e.g. `linux-x64`);
|
|
21
|
+
* 4. `<repo root>/target/release` — the repository working-tree
|
|
22
|
+
* layout, so a source checkout runs against a local cargo build
|
|
23
|
+
* with no configuration.
|
|
24
|
+
*
|
|
25
|
+
* The FFI surface is one decode operation plus one free:
|
|
26
|
+
* `pith_flac_decode` decodes a whole FLAC stream into the canonical
|
|
27
|
+
* byte stream, and `pith_flac_free` releases the handed-out buffer.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const koffi = require("koffi");
|
|
31
|
+
const fs = require("node:fs");
|
|
32
|
+
const path = require("node:path");
|
|
33
|
+
|
|
34
|
+
const STATUS_OK = 0;
|
|
35
|
+
const STATUS_INVALID = -1;
|
|
36
|
+
const STATUS_REJECTED = -2;
|
|
37
|
+
|
|
38
|
+
/** Refusal kind "bad-value" (Error::BadValue). */
|
|
39
|
+
const ERR_BAD_VALUE = 1;
|
|
40
|
+
/** Refusal kind "invalid-magic" (Error::InvalidMagic). */
|
|
41
|
+
const ERR_INVALID_MAGIC = 2;
|
|
42
|
+
/** Refusal kind "too-large" (Error::TooLarge). */
|
|
43
|
+
const ERR_TOO_LARGE = 3;
|
|
44
|
+
/** Refusal kind "truncated" (Error::Truncated). */
|
|
45
|
+
const ERR_TRUNCATED = 4;
|
|
46
|
+
/** Refusal kind "unsupported" (Error::Unsupported). */
|
|
47
|
+
const ERR_UNSUPPORTED = 5;
|
|
48
|
+
|
|
49
|
+
/** The canonical stream's header length in bytes. */
|
|
50
|
+
const HEADER_LEN = 64;
|
|
51
|
+
|
|
52
|
+
/** Refusal-kind names by code — the stable names reference.json
|
|
53
|
+
* `error_vectors` record as strings. */
|
|
54
|
+
const ERR_KIND_NAMES = Object.freeze({
|
|
55
|
+
[ERR_BAD_VALUE]: "bad-value",
|
|
56
|
+
[ERR_INVALID_MAGIC]: "invalid-magic",
|
|
57
|
+
[ERR_TOO_LARGE]: "too-large",
|
|
58
|
+
[ERR_TRUNCATED]: "truncated",
|
|
59
|
+
[ERR_UNSUPPORTED]: "unsupported",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** Every cdylib file name cargo may drop into the build directory, per platform. */
|
|
63
|
+
const CDYLIB_NAMES = ["pith_flac.dll", "libpith_flac.so", "libpith_flac.dylib"];
|
|
64
|
+
|
|
65
|
+
const PKG_ROOT = path.join(__dirname);
|
|
66
|
+
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
67
|
+
|
|
68
|
+
/** FfiError: a non-zero status code came back from the cdylib. */
|
|
69
|
+
class FfiError extends Error {
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} op the FFI operation name
|
|
72
|
+
* @param {number} status the raw status code
|
|
73
|
+
* @param {number} kind the refusal kind code (0 unless rejected)
|
|
74
|
+
*/
|
|
75
|
+
constructor(op, status, kind = 0) {
|
|
76
|
+
let kinded = { [STATUS_INVALID]: "invalid argument", [STATUS_REJECTED]: "input rejected" }[status] ?? "unknown failure";
|
|
77
|
+
if (kind) kinded += ` (${ERR_KIND_NAMES[kind] ?? `kind ${kind}`})`;
|
|
78
|
+
super(`${op} failed: ${kinded} (status ${status})`);
|
|
79
|
+
this.name = "FfiError";
|
|
80
|
+
/** The raw status code the FFI returned. */
|
|
81
|
+
this.status = status;
|
|
82
|
+
/** The refusal kind code (0 unless status is STATUS_REJECTED). */
|
|
83
|
+
this.kind = kind;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Locates the cdylib through the suite's discovery chain.
|
|
89
|
+
* @returns {string} an absolute path to the cdylib file
|
|
90
|
+
* @throws {Error} when nothing is found
|
|
91
|
+
*/
|
|
92
|
+
function findCdylib() {
|
|
93
|
+
const explicit = process.env.PITH_CDYLIB;
|
|
94
|
+
if (explicit && fs.statSync(explicit, { throwIfNoEntry: false })?.isFile()) {
|
|
95
|
+
return path.resolve(explicit);
|
|
96
|
+
}
|
|
97
|
+
/** @type {string[]} */
|
|
98
|
+
const dirs = [];
|
|
99
|
+
const envDir = process.env.PITH_CDYLIB_DIR;
|
|
100
|
+
if (envDir) {
|
|
101
|
+
dirs.push(envDir);
|
|
102
|
+
if (!path.isAbsolute(envDir)) {
|
|
103
|
+
dirs.push(path.join(REPO_ROOT, envDir));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const osArch = `${process.platform}-${process.arch}`;
|
|
107
|
+
dirs.push(path.join(PKG_ROOT, "prebuilds", osArch));
|
|
108
|
+
dirs.push(path.join(PKG_ROOT, "prebuilds"));
|
|
109
|
+
dirs.push(path.join(REPO_ROOT, "target", "release"));
|
|
110
|
+
for (const dir of dirs) {
|
|
111
|
+
for (const name of CDYLIB_NAMES) {
|
|
112
|
+
const p = path.join(dir, name);
|
|
113
|
+
if (fs.statSync(p, { throwIfNoEntry: false })?.isFile()) return p;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw new Error(
|
|
117
|
+
"no pith-flac cdylib found (searched PITH_CDYLIB, PITH_CDYLIB_DIR, prebuilds/ and <repo>/target/release); " +
|
|
118
|
+
"run `cargo build --release` first",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let cached = undefined;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Loads the cdylib and binds the exported symbols (lazily, once).
|
|
126
|
+
* @returns {{decode: Function, free: Function}}
|
|
127
|
+
*/
|
|
128
|
+
function loadLibrary() {
|
|
129
|
+
if (cached) return cached;
|
|
130
|
+
const lib = koffi.load(findCdylib());
|
|
131
|
+
const decode = lib.func("pith_flac_decode", "int32_t", [
|
|
132
|
+
"const uint8_t *",
|
|
133
|
+
"size_t",
|
|
134
|
+
koffi.out(koffi.pointer("void *")),
|
|
135
|
+
koffi.out(koffi.pointer("size_t")),
|
|
136
|
+
koffi.out(koffi.pointer("int32_t")),
|
|
137
|
+
]);
|
|
138
|
+
const free = lib.func("void pith_flac_free(void *ptr, size_t len)");
|
|
139
|
+
cached = { decode, free };
|
|
140
|
+
return cached;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Decodes a complete FLAC stream into the canonical byte stream the
|
|
145
|
+
* `reference.json` vectors are defined over. The handed-out cdylib
|
|
146
|
+
* buffer is copied into a JS Buffer and released before returning.
|
|
147
|
+
*
|
|
148
|
+
* @param {Buffer} data the complete FLAC file bytes
|
|
149
|
+
* @returns {Buffer} the canonical stream (64-byte header + PCM)
|
|
150
|
+
* @throws {FfiError} with `status === -2` for any malformed input
|
|
151
|
+
*/
|
|
152
|
+
function decodeCanonical(data) {
|
|
153
|
+
if (!Buffer.isBuffer(data)) {
|
|
154
|
+
throw new TypeError("data must be a Buffer");
|
|
155
|
+
}
|
|
156
|
+
const { decode, free } = loadLibrary();
|
|
157
|
+
const out = [null];
|
|
158
|
+
const outLen = [0];
|
|
159
|
+
const err = [0];
|
|
160
|
+
const status = decode(data, data.length, out, outLen, err);
|
|
161
|
+
if (status !== STATUS_OK) {
|
|
162
|
+
throw new FfiError("pith_flac_decode", status, err[0]);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
// koffi.decode hands back a Uint8Array view over the external
|
|
166
|
+
// buffer; copy it into a Buffer before the cdylib buffer is freed.
|
|
167
|
+
return Buffer.from(koffi.decode(out[0], "uint8_t", Number(outLen[0])));
|
|
168
|
+
} finally {
|
|
169
|
+
free(out[0], Number(outLen[0]));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Re-expresses the canonical byte stream as a plain object.
|
|
175
|
+
*
|
|
176
|
+
* @param {Buffer} raw the canonical stream
|
|
177
|
+
* @returns {{siMinBlockSize: number, siMaxBlockSize: number, siSampleRate: number,
|
|
178
|
+
* siChannels: number, siBitsPerSample: number, siTotalSamples: number,
|
|
179
|
+
* sampleRate: number, channels: number, bitsPerSample: number,
|
|
180
|
+
* totalSamples: number, decodedSamples: number, pcm: Buffer, raw: Buffer}}
|
|
181
|
+
*/
|
|
182
|
+
function parseCanonical(raw) {
|
|
183
|
+
if (!Buffer.isBuffer(raw) || raw.length < HEADER_LEN) {
|
|
184
|
+
throw new TypeError("canonical stream is shorter than the 64-byte header");
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
siMinBlockSize: raw.readUInt32BE(0),
|
|
188
|
+
siMaxBlockSize: raw.readUInt32BE(4),
|
|
189
|
+
siSampleRate: raw.readUInt32BE(8),
|
|
190
|
+
siChannels: raw.readUInt32BE(12),
|
|
191
|
+
siBitsPerSample: raw.readUInt32BE(16),
|
|
192
|
+
siTotalSamples: Number(raw.readBigUInt64BE(20)),
|
|
193
|
+
sampleRate: raw.readUInt32BE(28),
|
|
194
|
+
channels: raw.readUInt32BE(32),
|
|
195
|
+
bitsPerSample: raw.readUInt32BE(36),
|
|
196
|
+
totalSamples: Number(raw.readBigUInt64BE(40)),
|
|
197
|
+
decodedSamples: Number(raw.readBigUInt64BE(48)),
|
|
198
|
+
pcm: raw.subarray(HEADER_LEN),
|
|
199
|
+
raw,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
STATUS_OK,
|
|
205
|
+
STATUS_INVALID,
|
|
206
|
+
STATUS_REJECTED,
|
|
207
|
+
ERR_BAD_VALUE,
|
|
208
|
+
ERR_INVALID_MAGIC,
|
|
209
|
+
ERR_TOO_LARGE,
|
|
210
|
+
ERR_TRUNCATED,
|
|
211
|
+
ERR_UNSUPPORTED,
|
|
212
|
+
HEADER_LEN,
|
|
213
|
+
ERR_KIND_NAMES,
|
|
214
|
+
CDYLIB_NAMES,
|
|
215
|
+
FfiError,
|
|
216
|
+
findCdylib,
|
|
217
|
+
decodeCanonical,
|
|
218
|
+
parseCanonical,
|
|
219
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pith-hash/pith-flac",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "FLAC decoding SDK: STREAMINFO, constant/verbatim/fixed subframes and both Rice methods via the pith-flac Rust cdylib (koffi, prebuilt)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"prebuilds/",
|
|
10
|
+
"reference.json"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"koffi": "^2.12.0"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/pith-hash/pith-flac.git",
|
|
24
|
+
"directory": "sdk/node"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"flac",
|
|
28
|
+
"audio",
|
|
29
|
+
"decoder",
|
|
30
|
+
"pcm",
|
|
31
|
+
"hashing"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/reference.json
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"suite": "pith",
|
|
3
|
+
"crate": "pith-flac",
|
|
4
|
+
"format_version": 1,
|
|
5
|
+
"generator": "cargo run --bin gen-reference -- gen",
|
|
6
|
+
"vectors": [
|
|
7
|
+
{
|
|
8
|
+
"name": "fixture-tone",
|
|
9
|
+
"input_kind": "fixture-file",
|
|
10
|
+
"input_path": "tests/fixtures/tone.flac",
|
|
11
|
+
"input_sha256": "f954b800b5476f06529b365ed429da96fbf9961067073e955c1966d5fa098d9e",
|
|
12
|
+
"streaminfo_min_block_size": 16,
|
|
13
|
+
"streaminfo_max_block_size": 65535,
|
|
14
|
+
"streaminfo_sample_rate": 44100,
|
|
15
|
+
"streaminfo_channels": 1,
|
|
16
|
+
"streaminfo_bits_per_sample": 16,
|
|
17
|
+
"streaminfo_total_samples": 44100,
|
|
18
|
+
"sample_rate": 44100,
|
|
19
|
+
"channels": 1,
|
|
20
|
+
"bits_per_sample": 16,
|
|
21
|
+
"total_samples": 44100,
|
|
22
|
+
"decoded_samples": 44100,
|
|
23
|
+
"pcm_i32_le_sha256": "2514eff527051953533d999b57bc3745386432e2a37dcc32ccab77fb67faee6f",
|
|
24
|
+
"pcm_i32_le_fnv1a64": "3a1160e77c61f2b9"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "constant-mono-16bit",
|
|
28
|
+
"input_kind": "inline-hex",
|
|
29
|
+
"input_hex": "664c61438000002200c012000000000000000ac440f00000000800000000000000000000000000000000fff860000007ff001234fd6f",
|
|
30
|
+
"sample_rate": 44100,
|
|
31
|
+
"channels": 1,
|
|
32
|
+
"bits_per_sample": 16,
|
|
33
|
+
"total_samples": 8,
|
|
34
|
+
"decoded_samples": 8,
|
|
35
|
+
"samples_i32": [4660, 4660, 4660, 4660, 4660, 4660, 4660, 4660],
|
|
36
|
+
"pcm_i32_le_sha256": "8949c8b20154a8ad94f90be09da09819eb3f6e4327b25df4ff0b28b0d8e9295c",
|
|
37
|
+
"pcm_i32_le_fnv1a64": "9e2315414d536a25"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "verbatim-mono-signs",
|
|
41
|
+
"input_kind": "inline-hex",
|
|
42
|
+
"input_hex": "664c61438000002200c012000000000000000ac440f00000000800000000000000000000000000000000fff860000007ff020001fffe0003fffc0005fffa0007fff8af0b",
|
|
43
|
+
"sample_rate": 44100,
|
|
44
|
+
"channels": 1,
|
|
45
|
+
"bits_per_sample": 16,
|
|
46
|
+
"total_samples": 8,
|
|
47
|
+
"decoded_samples": 8,
|
|
48
|
+
"samples_i32": [1, -2, 3, -4, 5, -6, 7, -8],
|
|
49
|
+
"pcm_i32_le_sha256": "b646e9e2e7d9d9cdb8963241f30ef5b14db614b51f24cc9bbfbb8d6773ae4e60",
|
|
50
|
+
"pcm_i32_le_fnv1a64": "209cbf97ac330015"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "fixed1-rice4-partitioned",
|
|
54
|
+
"input_kind": "inline-hex",
|
|
55
|
+
"input_hex": "664c61438000002200c012000000000000000ac440f00000000800000000000000000000000000000000fff860000007ff1201f40500000000000000000000000000000003e00000000000000000000000010300000000000100000000008000000000400000000020cd7a",
|
|
56
|
+
"sample_rate": 44100,
|
|
57
|
+
"channels": 1,
|
|
58
|
+
"bits_per_sample": 16,
|
|
59
|
+
"total_samples": 8,
|
|
60
|
+
"decoded_samples": 8,
|
|
61
|
+
"samples_i32": [500, -500, 300, 320, 340, 360, 380, 400],
|
|
62
|
+
"pcm_i32_le_sha256": "4224637b975f1bc8ecc4673122c35fa5c6b01976ac175627c1e1b37a0e68ea39",
|
|
63
|
+
"pcm_i32_le_fnv1a64": "57959265f7dfb14a"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "fixed0-rice-escape",
|
|
67
|
+
"input_kind": "inline-hex",
|
|
68
|
+
"input_hex": "664c61438000002200c012000000000000000ac440f00000000800000000000000000000000000000000fff860000003e31007cb17848023fd",
|
|
69
|
+
"sample_rate": 44100,
|
|
70
|
+
"channels": 1,
|
|
71
|
+
"bits_per_sample": 16,
|
|
72
|
+
"total_samples": 8,
|
|
73
|
+
"decoded_samples": 4,
|
|
74
|
+
"samples_i32": [-15, 15, 0, 1],
|
|
75
|
+
"pcm_i32_le_sha256": "edc39f894f69e2678320dfe2884634e7d59ebee9324ad4d5a3ad4889f14c80a2",
|
|
76
|
+
"pcm_i32_le_fnv1a64": "8f8d061ed1f929c9"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "stereo-left-side",
|
|
80
|
+
"input_kind": "inline-hex",
|
|
81
|
+
"input_hex": "664c61438000002200c012000000000000000ac442f00000000400000000000000000000000000000000fff860800003e8020064ff9c0064ff9c02001e7fdd00119ffc4038c7",
|
|
82
|
+
"sample_rate": 44100,
|
|
83
|
+
"channels": 2,
|
|
84
|
+
"bits_per_sample": 16,
|
|
85
|
+
"total_samples": 4,
|
|
86
|
+
"decoded_samples": 8,
|
|
87
|
+
"samples_i32": [100, 40, -100, 40, 100, -40, -100, -40],
|
|
88
|
+
"pcm_i32_le_sha256": "ad4f6b88c82175e563cf7b84e4b7ba62e0d3ab97bd8447011c2ea4817c085d9f",
|
|
89
|
+
"pcm_i32_le_fnv1a64": "ff5258122066d21d"
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
"error_vectors": [
|
|
93
|
+
{
|
|
94
|
+
"name": "not-a-flac-stream",
|
|
95
|
+
"input_kind": "inline-hex",
|
|
96
|
+
"input_hex": "6e6f7065",
|
|
97
|
+
"error": "invalid-magic"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "headerless-stream",
|
|
101
|
+
"input_kind": "inline-hex",
|
|
102
|
+
"input_hex": "664c6143",
|
|
103
|
+
"error": "truncated"
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
package/README.md
DELETED