diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml new file mode 100644 index 0000000..b9d91d5 --- /dev/null +++ b/.github/workflows/node.yml @@ -0,0 +1,60 @@ +name: Node.js + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + cpu: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + env: + CMAKE_BUILD_PARALLEL_LEVEL: 4 + defaults: + run: + working-directory: node + shell: bash + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: node/package-lock.json + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install numerical oracle dependency + run: python -m pip install numpy + - name: Build CPU bindings + run: npm ci + - name: Generate tiny models and test bindings + run: | + npm run test:fixtures + npm test + npm run test:types + npm run test:packaging + - name: Pack source distribution + run: | + mkdir -p artifacts + npm pack --pack-destination artifacts + - name: Install and test source distribution + run: | + npm install --prefix installed ./artifacts/*.tgz + CLAP_CPP_MODULE="$PWD/installed/node_modules/clap-cpp" npm test + cd installed/node_modules/clap-cpp + npm run test:fixtures + npm test + - uses: actions/upload-artifact@v4 + with: + name: node-${{ matrix.os }} + path: node/artifacts/*.tgz diff --git a/README.md b/README.md index 6c1fe10..38e851b 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,15 @@ intentionally not publishable until native-source vendoring is implemented. Run `cargo test --manifest-path rust/Cargo.toml -j 4` (Python 3 is needed only to generate tiny test fixtures). Inference requires no Python or GPU runtime. +## Node.js bindings (CPU) + +Install from a recursive checkout with `npm install ./node`. +The [`node/` package](node/README.md) provides native text/audio embeddings, +`Float32Array` waveform input and output, frame representations, audio +decoding/resampling, and TypeScript declarations. It builds a self-contained CPU +addon. The synchronous API blocks the calling thread; use a worker thread for +server applications. See the package README for API examples and tests. + ## Build and test ```sh diff --git a/node/.gitignore b/node/.gitignore new file mode 100644 index 0000000..edb40f2 --- /dev/null +++ b/node/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +build/ +native/ +native-tests/ +LICENSE +THIRD_PARTY_LICENSES/ +*.tgz +test/*.log +test/fixtures/ diff --git a/node/CMakeLists.txt b/node/CMakeLists.txt new file mode 100644 index 0000000..aba1ede --- /dev/null +++ b/node/CMakeLists.txt @@ -0,0 +1,63 @@ +cmake_minimum_required(VERSION 3.20) +project(clap_cpp_node VERSION 0.1.0 LANGUAGES C CXX) + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/../include/clap.h") + get_filename_component(CLAP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE) +else() + set(CLAP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/native") +endif() +foreach(source ggml/CMakeLists.txt thirdparty/json/CMakeLists.txt thirdparty/miniaudio/miniaudio.h) + if(NOT EXISTS "${CLAP_ROOT}/${source}") + message(FATAL_ERROR "Native sources missing: run git submodule update --init --recursive") + endif() +endforeach() + +# Build a self-contained CPU extension, never CLI executables, GPU backends, +# external OpenMP runtimes, or shared libraries requiring a loader search path. +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(GGML_NATIVE OFF CACHE BOOL "" FORCE) +set(GGML_CPU ON CACHE BOOL "" FORCE) +foreach(backend METAL CUDA HIP VULKAN SYCL OPENCL OPENVINO RPC MUSA WEBGPU ZDNN + VIRTGPU VIRTGPU_BACKEND ET HEXAGON ZENDNN BLAS ACCELERATE) + set(GGML_${backend} OFF CACHE BOOL "" FORCE) +endforeach() +foreach(feature BACKEND_DL OPENMP BUILD_TESTS BUILD_EXAMPLES) + set(GGML_${feature} OFF CACHE BOOL "" FORCE) +endforeach() +# Avoid host-specific x86 ISA requirements in redistributed wheels. +foreach(isa SSE42 AVX AVX2 AVX_VNNI BMI2 FMA F16C AVX512 AVX512_VBMI + AVX512_VNNI AVX512_BF16 AMX_TILE AMX_INT8 AMX_BF16) + set(GGML_${isa} OFF CACHE BOOL "" FORCE) +endforeach() +set(CLAP_USE_SYSTEM_ICU OFF CACHE BOOL "" FORCE) +add_subdirectory("${CLAP_ROOT}/ggml" ggml EXCLUDE_FROM_ALL) +add_subdirectory("${CLAP_ROOT}/thirdparty/json" json EXCLUDE_FROM_ALL) +include("${CLAP_ROOT}/cmake/icu.cmake") +add_subdirectory("${CLAP_ROOT}/src" core EXCLUDE_FROM_ALL) + +# cmake-js supplies Node headers and the import library on Windows. +execute_process(COMMAND node -p "require('node-addon-api').include" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + OUTPUT_VARIABLE NODE_ADDON_API_DIR OUTPUT_STRIP_TRAILING_WHITESPACE + COMMAND_ERROR_IS_FATAL ANY) +string(REPLACE "\"" "" NODE_ADDON_API_DIR "${NODE_ADDON_API_DIR}") +add_library(clap_node MODULE bindings.cpp ${CMAKE_JS_SRC}) +target_include_directories(clap_node PRIVATE ${CMAKE_JS_INC} "${NODE_ADDON_API_DIR}") +target_link_libraries(clap_node PRIVATE clap ${CMAKE_JS_LIB}) +if(APPLE) + target_link_options(clap_node PRIVATE "-undefined" "dynamic_lookup") +endif() +target_compile_features(clap_node PRIVATE cxx_std_17) +target_compile_definitions(clap_node PRIVATE NAPI_VERSION=8 NAPI_CPP_EXCEPTIONS) +set_target_properties(clap_node PROPERTIES PREFIX "" SUFFIX ".node" CXX_EXTENSIONS OFF) +if(MSVC) + target_compile_options(clap_node PRIVATE /EHsc) + # Import the Node-API stubs for Windows when requested by cmake-js. + if(CMAKE_JS_NODELIB_DEF AND CMAKE_JS_NODELIB_TARGET) + execute_process(COMMAND ${CMAKE_AR} /def:${CMAKE_JS_NODELIB_DEF} + /out:${CMAKE_JS_NODELIB_TARGET} ${CMAKE_STATIC_LINKER_FLAGS}) + endif() +else() + target_compile_options(clap_node PRIVATE -fexceptions) +endif() diff --git a/node/README.md b/node/README.md new file mode 100644 index 0000000..4d5065a --- /dev/null +++ b/node/README.md @@ -0,0 +1,145 @@ +# clap.cpp for Node.js + +Synchronous CPU bindings to the existing C++ `clap::Model` API, implemented with +node-addon-api and Node-API 8. No C API branch, Python runtime, GPU, OpenMP runtime, +or separately installed ICU is required for inference. TypeScript declarations +are included. The package is CommonJS; ESM can use its default export. + +## Requirements and installation + +- Node.js **22 or newer**, npm, CMake **3.20+**, and a C/C++17 compiler. +- Linux: Make or Ninja; macOS: Xcode command-line tools; Windows: Visual Studio + C++ build tools. Only Linux x64 has been exercised locally; the other platforms + use portable CMake/Node-API integration but are not locally verified. +- Python 3.9+ is needed **only to generate test fixtures**. NumPy is optional and + enables an independent numerical text oracle. + +From a checkout with its pinned submodules initialized: + +```sh +git submodule update --init --recursive +cd node +npm ci # builds the addon via cmake-js +npm run test:fixtures # deterministic tiny GGUF models + WAV, no downloads +npm test +npm run test:types +``` + +`npm install`/`npm ci` compiles from source; no prebuilt binaries are shipped. +cmake-js may download the matching Node headers on first use. CMake itself needs +no network access once the package and Node headers are available. The build +bundles static PIC CPU ggml, JSON, ICU Unicode/regex and miniaudio sources. Host- +specific x86 instruction extensions and GPU backends are disabled by default. + +To override generator or build parallelism explicitly: + +```sh +npm run build -- --generator 'Unix Makefiles' --parallel 2 +``` + +## Usage + +```js +const { Model, decodeAudio, resampleAudio } = require('clap-cpp'); + +const model = new Model('/path/to/model.gguf', 4); +try { + console.log(model.projectionDimension, model.audioSampleRate); + const text = model.text('a piano playing'); // owned Float32Array + const { samples, sampleRate } = decodeAudio('/path/to/audio.wav'); + const audio = model.audio(samples, sampleRate); // automatically resampled + // CLAP projections are unit-normalized, so their dot product is cosine. + const similarity = text.reduce((sum, x, i) => sum + x * audio[i], 0); + console.log(similarity); +} finally { + model.close(); // optional early release; garbage collection also frees weights +} +``` + +Every inference, decode and resample call **blocks the JavaScript thread**. Use +`worker_threads` for servers/UI workloads, creating a separate model inside each +worker. Models are not transferable or shared across workers. There is no async +API, batching, similarity helper or network/model downloader in this binding. + +## API + +| API | Result / defaults | +| --- | --- | +| `new Model(path, threads = 0)` | Load a GGUF; zero threads selects the native default. | +| `model.projectionDimension` | Read-only embedding width. | +| `model.audioSampleRate` | Read-only model-native sample rate. | +| `model.tokenize(text, maxLength = 512)` | Owned `Int32Array`. Native tokenizer/truncation behavior applies. | +| `model.text(textOrTokens)` | Owned `Float32Array`; accepts a string, `Int32Array` or `number[]` token IDs. | +| `model.audio(pcm, sampleRate = 48000, cropOffset = 0)` | Owned embedding `Float32Array`; mono PCM is automatically resampled. Crop offset is in samples, per the native model contract. | +| `model.audioFrames(pcm, sampleRate = 48000)` | `{ values: Float32Array, frames: number, dimension: number }`. | +| `model.clamp3Features(pcm, sampleRate = 24000)` | Same frame object, with MERT-v1 all-layer means for CLaMP3. | +| `model.close()` | Idempotent early release. Other methods and property reads throw after close. | +| `decodeAudio(path)` | `{ samples: Float32Array, sampleRate: number }`; WAV/MP3/FLAC detected by contents, channels averaged to mono, source rate preserved. | +| `resampleAudio(pcm, sourceRate, targetRate = 48000)` | Owned mono `Float32Array`; same-rate input is copied unchanged. | + +Frame values are flat **time-major**: `values[t * dimension + d]`, with exactly +`frames * dimension` elements. All returned typed arrays own their memory and +remain valid after subsequent inference and after `close()`. + +PCM must be a nonempty **Float32Array** with finite values and at most 1 GiB of +samples. No coercion from ordinary arrays or Float64Array is performed. Sample +rates must be integers in `[1, 2147483647]`; threads in `[0, 2147483647]`; +`maxLength` in `[1, 2147483647]`. Token IDs must be nonnegative signed 32-bit +integers. Crop offsets must be nonnegative safe integers representable by native +`size_t`. Paths must be nonempty strings without embedded NUL bytes. Invalid +arguments and native failures throw JavaScript exceptions; invalid-argument +errors generally use `TypeError`. `undefined` for an optional argument selects +its default; `null` does not. Resource limits/context lengths remain model- +dependent; an integer within the API range is not a promise that arbitrary huge +requests can be allocated or inferred. + +### Architecture behavior + +Architecture selection, preprocessing, pooling, context lengths and normalization +are the native C++ implementation's behavior, not reimplemented in JavaScript: + +- CLAP text/audio outputs are unit-normalized projections. +- CLaMP3 outputs are native **unnormalized** projections and require a bundled + MERT-v1 frontend. Do not treat their raw dot product as cosine similarity. +- MERT-v1, MERT-v2 and MuQ expose final-layer `audioFrames`; `audio` mean-pools + frames. `clamp3Features` is specifically MERT-v1's all-layer, five-second-window + representation, not its final-layer frames; it requires at least one second. +- MuQ-MuLan supports joint embeddings, not frame/feature APIs or crop offsets; + audio follows native per-clip normalization/averaging without final normalization. +- Unsupported modalities/methods throw. The tokenizer's default maximum is not a + universal model context size; use the model-specific supported length when + requesting tokens for inference. + +Resampling uses the native causal linear + anti-alias low-pass implementation, +not zero-phase sinc interpolation. Startup latency is expected. Output duration +is `ceil(input.length * targetRate / sourceRate)`. + +## Standalone npm tarballs + +```sh +mkdir -p artifacts +npm run test:packaging +npm pack --pack-destination artifacts +npm install --prefix installed ./artifacts/clap-cpp-0.1.0.tgz +CLAP_CPP_MODULE="$PWD/installed/node_modules/clap-cpp" npm test +``` + +The `prepack` hook copies the **pinned** native subset and third-party licenses +into ignored `native/`/`THIRD_PARTY_LICENSES/` directories. It refuses mismatched +submodule revisions. Checkout builds use the parent repository; tarball installs +build entirely from `native/`, without Git or a surrounding clap.cpp checkout. +The tarball carries a copy of the shared stdlib tiny-model generator, so even +its tests can run standalone: + +```sh +cd installed/node_modules/clap-cpp +npm run test:fixtures +npm test +``` + +Generated native copies, fixtures, binaries, dependencies and tarballs must not +be committed. `npm run test:types` needs the development dependencies and is +normally run from the source package. The runtime regression suite covers real +CLAP text/waveform inference, MERT frame/pooling/features, lifetime/ownership, +argument validation, WAV decoding and resampling. It uses tiny deterministic +models, not published-weight accuracy or performance benchmarks. diff --git a/node/bindings.cpp b/node/bindings.cpp new file mode 100644 index 0000000..f896dd5 --- /dev/null +++ b/node/bindings.cpp @@ -0,0 +1,193 @@ +#include +#include "clap.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +// No C++ exception may escape a Node-API callback, including allocation errors. +template +Napi::Value guarded(Napi::Env env, F &&fn) { + try { return fn(); } + catch (const Napi::Error &e) { e.ThrowAsJavaScriptException(); } + catch (const std::invalid_argument &e) { Napi::TypeError::New(env, e.what()).ThrowAsJavaScriptException(); } + catch (const std::exception &e) { Napi::Error::New(env, e.what()).ThrowAsJavaScriptException(); } + catch (...) { Napi::Error::New(env, "Unknown native exception").ThrowAsJavaScriptException(); } + return env.Undefined(); +} + +std::string string(const Napi::Value &v, const char *name) { + if (!v.IsString()) throw std::invalid_argument(std::string(name) + " must be a string"); + return v.As().Utf8Value(); +} +std::string path(const Napi::Value &v) { + auto p = string(v, "path"); + if (p.empty() || p.find('\0') != std::string::npos) + throw std::invalid_argument("path must be nonempty and contain no NUL bytes"); + return p; +} +std::size_t integer(const Napi::Value &v, const char *name, double min, double max) { + if (!v.IsNumber()) throw std::invalid_argument(std::string(name) + " must be an integer"); + const double n = v.As().DoubleValue(); + if (!std::isfinite(n) || std::floor(n) != n || n < min || n > max) + throw std::invalid_argument(std::string(name) + " is outside the supported integer range"); + return static_cast(n); +} +int rate(const Napi::Value &v) { + return static_cast(integer(v, "sampleRate", 1, std::numeric_limits::max())); +} +int optional_rate(const Napi::Value &v, int fallback) { return v.IsUndefined() ? fallback : rate(v); } +std::vector samples(const Napi::Value &v) { + if (!v.IsTypedArray() || v.As().TypedArrayType() != napi_float32_array) + throw std::invalid_argument("PCM must be a nonempty Float32Array of mono samples"); + auto a = v.As(); + if (a.ElementLength() == 0 || a.ElementLength() > (std::size_t(1) << 28) || !a.Data()) + throw std::invalid_argument("PCM must be nonempty and at most 1 GiB"); + std::vector result(a.Data(), a.Data() + a.ElementLength()); + for (float x : result) if (!std::isfinite(x)) throw std::invalid_argument("PCM samples must be finite"); + return result; +} +std::vector tokens(const Napi::Value &v) { + std::vector result; + if (v.IsTypedArray() && v.As().TypedArrayType() == napi_int32_array) { + auto a = v.As(); + if (a.ElementLength() && a.Data()) result.assign(a.Data(), a.Data() + a.ElementLength()); + for (int id : result) if (id < 0) throw std::invalid_argument("token IDs must be nonnegative"); + } else if (v.IsArray()) { + auto a = v.As(); + result.reserve(a.Length()); + for (uint32_t i = 0; i < a.Length(); ++i) + result.push_back(static_cast(integer(a.Get(i), "token ID", 0, std::numeric_limits::max()))); + } else { throw std::invalid_argument("text must be a string, Int32Array or number[]"); } + if (result.empty()) throw std::invalid_argument("tokens must be nonempty"); + return result; +} +Napi::Float32Array array(Napi::Env env, const std::vector &v) { + auto output = Napi::Float32Array::New(env, v.size()); + if (!v.empty()) std::memcpy(output.Data(), v.data(), v.size() * sizeof(float)); + return output; +} +Napi::Object frames(Napi::Env env, const clap::AudioFrames &f) { + auto output = Napi::Object::New(env); + output.Set("values", array(env, f.values)); + output.Set("frames", Napi::Number::New(env, f.frames)); + output.Set("dimension", Napi::Number::New(env, f.dimension)); + return output; +} + +class Model : public Napi::ObjectWrap { +public: + static Napi::Function define(Napi::Env env) { + return DefineClass(env, "Model", { + InstanceAccessor<&Model::projection_dimension>("projectionDimension"), + InstanceAccessor<&Model::audio_sample_rate>("audioSampleRate"), + InstanceMethod<&Model::tokenize>("tokenize"), + InstanceMethod<&Model::text>("text"), + InstanceMethod<&Model::audio>("audio"), + InstanceMethod<&Model::audio_frames>("audioFrames"), + InstanceMethod<&Model::clamp3_features>("clamp3Features"), + InstanceMethod<&Model::close>("close") + }); + } + explicit Model(const Napi::CallbackInfo &info) : Napi::ObjectWrap(info) { + guarded(info.Env(), [&]() -> Napi::Value { + auto p = path(info[0]); + int threads = info[1].IsUndefined() ? 0 : static_cast(integer(info[1], "threads", 0, std::numeric_limits::max())); + model_ = std::make_unique(p, threads); + return info.Env().Undefined(); + }); + } +private: + // Synchronous calls cannot overlap within one JS isolate; each worker owns its + // own ObjectWrap. There is no globally shared model handle or borrowed output. + std::unique_ptr model_; + clap::CLAP &get() { + if (!model_) throw std::runtime_error("Model is closed"); + return *model_; + } + Napi::Value projection_dimension(const Napi::CallbackInfo &info) { + return guarded(info.Env(), [&]() -> Napi::Value { return Napi::Number::New(info.Env(), get().projection_dimension()); }); + } + Napi::Value audio_sample_rate(const Napi::CallbackInfo &info) { + return guarded(info.Env(), [&]() -> Napi::Value { return Napi::Number::New(info.Env(), get().audio_sample_rate()); }); + } + Napi::Value tokenize(const Napi::CallbackInfo &info) { + return guarded(info.Env(), [&]() -> Napi::Value { + auto &m = get(); + auto s = string(info[0], "text"); + auto n = info[1].IsUndefined() ? 512 : integer(info[1], "maxLength", 1, std::numeric_limits::max()); + auto ids = m.tokenize(s, n); + auto out = Napi::Int32Array::New(info.Env(), ids.size()); + for (std::size_t i = 0; i < ids.size(); ++i) out[i] = ids[i]; + return out; + }); + } + Napi::Value text(const Napi::CallbackInfo &info) { + return guarded(info.Env(), [&]() -> Napi::Value { + // Array element getters can reenter JS and close this model. Parse + // completely before taking a native reference, then recheck ownership. + if (info[0].IsString()) { + auto s = string(info[0], "text"); + return array(info.Env(), get().encode_text(s)); + } + auto ids = tokens(info[0]); + return array(info.Env(), get().encode_text(ids)); + }); + } + Napi::Value audio(const Napi::CallbackInfo &info) { + return guarded(info.Env(), [&]() -> Napi::Value { + auto &m = get(); + auto pcm = samples(info[0]); + int sr = optional_rate(info[1], 48000); + auto crop = info[2].IsUndefined() ? 0 : integer(info[2], "cropOffset", 0, + std::min(9007199254740991.0, static_cast(std::numeric_limits::max()))); + return array(info.Env(), m.encode_audio(pcm, sr, crop)); + }); + } + Napi::Value audio_frames(const Napi::CallbackInfo &info) { + return guarded(info.Env(), [&]() -> Napi::Value { + auto &m = get(); + auto pcm = samples(info[0]); + return frames(info.Env(), m.audio_frames(pcm, optional_rate(info[1], 48000))); + }); + } + Napi::Value clamp3_features(const Napi::CallbackInfo &info) { + return guarded(info.Env(), [&]() -> Napi::Value { + auto &m = get(); + auto pcm = samples(info[0]); + return frames(info.Env(), m.clamp3_features(pcm, optional_rate(info[1], 24000))); + }); + } + Napi::Value close(const Napi::CallbackInfo &info) { + return guarded(info.Env(), [&]() -> Napi::Value { model_.reset(); return info.Env().Undefined(); }); + } +}; + +Napi::Object initialize(Napi::Env env, Napi::Object exports) { + exports.Set("Model", Model::define(env)); + exports.Set("decodeAudio", Napi::Function::New(env, [](const Napi::CallbackInfo &info) { + return guarded(info.Env(), [&]() -> Napi::Value { + auto decoded = clap::decode_audio(path(info[0])); + auto result = Napi::Object::New(info.Env()); + result.Set("samples", array(info.Env(), decoded.samples)); + result.Set("sampleRate", Napi::Number::New(info.Env(), decoded.sample_rate)); + return result; + }); + }, "decodeAudio")); + exports.Set("resampleAudio", Napi::Function::New(env, [](const Napi::CallbackInfo &info) { + return guarded(info.Env(), [&]() -> Napi::Value { + clap::Audio input{samples(info[0]), rate(info[1])}; + return array(info.Env(), clap::resample_audio(input, optional_rate(info[2], 48000)).samples); + }); + }, "resampleAudio")); + return exports; +} +} // namespace +NODE_API_MODULE(clap_node, initialize) diff --git a/node/index.d.ts b/node/index.d.ts new file mode 100644 index 0000000..5195bba --- /dev/null +++ b/node/index.d.ts @@ -0,0 +1,32 @@ +/** Owned, flat time-major values: values.length === frames * dimension. */ +export interface AudioFrames { + values: Float32Array; + frames: number; + dimension: number; +} +export interface Audio { + samples: Float32Array; + sampleRate: number; +} +/** Synchronous CPU inference. Each call blocks the calling JavaScript thread. */ +export class Model { + /** threads=0 selects the native default; negative/fractional values are rejected. */ + constructor(path: string, threads?: number); + readonly projectionDimension: number; + readonly audioSampleRate: number; + /** Native tokenizer; maxLength defaults to 512. Returns owned token IDs. */ + tokenize(text: string, maxLength?: number): Int32Array; + text(input: string | Int32Array | number[]): Float32Array; + /** Mono PCM. sampleRate defaults to 48000; cropOffset defaults to 0 samples. */ + audio(pcm: Float32Array, sampleRate?: number, cropOffset?: number): Float32Array; + /** Available only for architectures exposing frame encodings. Default: 48000 Hz. */ + audioFrames(pcm: Float32Array, sampleRate?: number): AudioFrames; + /** MERT-v1 all-layer mean features for CLaMP3. Default: 24000 Hz. */ + clamp3Features(pcm: Float32Array, sampleRate?: number): AudioFrames; + /** Idempotent. All other methods/properties throw after close. */ + close(): void; +} +/** WAV/MP3/FLAC decoded by contents, downmixed to mono, original sample rate. */ +export function decodeAudio(path: string): Audio; +/** Owned output. targetRate defaults to 48000; both rates must be positive integers. */ +export function resampleAudio(pcm: Float32Array, sourceRate: number, targetRate?: number): Float32Array; diff --git a/node/index.js b/node/index.js new file mode 100644 index 0000000..28746bc --- /dev/null +++ b/node/index.js @@ -0,0 +1,2 @@ +'use strict'; +module.exports = require('./build/Release/clap_node.node'); diff --git a/node/package-lock.json b/node/package-lock.json new file mode 100644 index 0000000..a7de0f0 --- /dev/null +++ b/node/package-lock.json @@ -0,0 +1,492 @@ +{ + "name": "clap-cpp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clap-cpp", + "version": "0.1.0", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "cmake-js": "^8.0.0", + "node-addon-api": "^8.9.2" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cmake-js": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-8.0.0.tgz", + "integrity": "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "fs-extra": "^11.3.3", + "node-api-headers": "^1.8.0", + "rc": "1.2.8", + "semver": "^7.7.3", + "tar": "^7.5.6", + "url-join": "^4.0.1", + "which": "^6.0.0", + "yargs": "^17.7.2" + }, + "bin": { + "cmake-js": "bin/cmake-js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-api-headers": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/node-api-headers/-/node-api-headers-1.9.0.tgz", + "integrity": "sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/node/package.json b/node/package.json new file mode 100644 index 0000000..2bc4ce3 --- /dev/null +++ b/node/package.json @@ -0,0 +1,22 @@ +{ + "name": "clap-cpp", + "version": "0.1.0", + "description": "Synchronous Node-API CPU bindings for clap.cpp audio and text embeddings", + "license": "Apache-2.0", + "main": "index.js", + "types": "index.d.ts", + "engines": { "node": ">=22.0.0" }, + "files": ["index.js", "index.d.ts", "bindings.cpp", "CMakeLists.txt", "scripts", "native", "native-tests", "test/addon.test.js", "test/packaging.test.js", "test/generate-fixtures.py", "test/types.ts", "test/tsconfig.json", "README.md", "LICENSE", "THIRD_PARTY_LICENSES"], + "scripts": { + "install": "cmake-js compile", + "build": "cmake-js compile", + "rebuild": "cmake-js rebuild", + "test:fixtures": "python3 test/generate-fixtures.py", + "test": "node --test test/addon.test.js", + "test:packaging": "node --test test/packaging.test.js", + "test:types": "tsc -p test/tsconfig.json", + "prepack": "node scripts/vendor.js" + }, + "dependencies": { "cmake-js": "^8.0.0", "node-addon-api": "^8.9.2" }, + "devDependencies": { "typescript": "^5.9.3", "@types/node": "^22.0.0" } +} diff --git a/node/scripts/vendor.js b/node/scripts/vendor.js new file mode 100644 index 0000000..ad7cbd7 --- /dev/null +++ b/node/scripts/vendor.js @@ -0,0 +1,43 @@ +'use strict'; +// npm pack must be independently buildable, without Git or sibling directories. +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const pkg = path.resolve(__dirname, '..'); +const root = path.resolve(pkg, '..'); +const native = path.join(pkg, 'native'); +const trees = ['include', 'src', 'ggml/CMakeLists.txt', 'ggml/cmake', 'ggml/include', + 'ggml/src', 'ggml/LICENSE', 'thirdparty/json/CMakeLists.txt', 'thirdparty/json/cmake', + 'thirdparty/json/include', 'thirdparty/json/nlohmann_json.natvis', 'thirdparty/json/LICENSE.MIT', + 'thirdparty/miniaudio/miniaudio.h', 'thirdparty/miniaudio/LICENSE', + 'thirdparty/icu/icu4c/source/common', 'thirdparty/icu/icu4c/source/i18n', + 'thirdparty/icu/icu4c/source/stubdata']; +const licenses = { ggml: 'ggml/LICENSE', json: 'thirdparty/json/LICENSE.MIT', + icu: 'thirdparty/icu/LICENSE', miniaudio: 'thirdparty/miniaudio/LICENSE' }; +function copy(src, dest) { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.cpSync(src, dest, { recursive: true, filter: p => !['.git', 'build', '__pycache__', 'node_modules'].includes(path.basename(p)) }); +} +// Repacking an installed tarball requires no repository or Git executable. +if (!fs.existsSync(path.join(root, 'include/clap.h'))) { + if (!fs.existsSync(path.join(native, 'include/clap.h'))) throw new Error('Native source tree missing'); + process.exit(0); +} +// Never accidentally publish source from a different submodule revision. +for (const dir of ['ggml', 'thirdparty/json', 'thirdparty/icu', 'thirdparty/miniaudio']) { + const entry = execFileSync('git', ['ls-tree', 'HEAD', dir], { cwd: root, encoding: 'utf8' }).trim(); + const expected = entry.split(/\s+/)[2]; + const actual = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: path.join(root, dir), encoding: 'utf8' }).trim(); + if (!expected || expected !== actual) throw new Error(`${dir}: initialize the pinned submodule revision before packing`); +} +for (const p of [...trees, 'cmake/icu.cmake', 'LICENSE', ...Object.values(licenses)]) { + if (!fs.existsSync(path.join(root, p))) throw new Error(`Missing ${p}; run git submodule update --init --recursive`); +} +fs.rmSync(native, { recursive: true, force: true }); +for (const dir of trees) copy(path.join(root, dir), path.join(native, dir)); +copy(path.join(root, 'cmake/icu.cmake'), path.join(native, 'cmake/icu.cmake')); +copy(path.join(root, 'thirdparty/icu/LICENSE'), path.join(native, 'thirdparty/icu/LICENSE')); +copy(path.join(root, 'LICENSE'), path.join(pkg, 'LICENSE')); +for (const [name, source] of Object.entries(licenses)) copy(path.join(root, source), path.join(pkg, 'THIRD_PARTY_LICENSES', `${name}.txt`)); +copy(path.join(root, 'python/tests/tiny_models.py'), path.join(pkg, 'native-tests/tiny_models.py')); +console.log('Vendored pinned CPU sources, licenses and stdlib test fixture generator'); diff --git a/node/test/addon.test.js b/node/test/addon.test.js new file mode 100644 index 0000000..b248e3c --- /dev/null +++ b/node/test/addon.test.js @@ -0,0 +1,147 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); +const { spawnSync } = require('node:child_process'); +const root = process.env.CLAP_CPP_MODULE || '..'; +const { Model, decodeAudio, resampleAudio } = require(root); +const fixtures = path.join(__dirname, 'fixtures'); +const modelPath = path.join(fixtures, 'clap.gguf'); +const mertPath = path.join(fixtures, 'mert.gguf'); +function vector(v, n) { + assert.ok(v instanceof Float32Array); + assert.equal(v.length, n); + assert.ok(v.every(Number.isFinite)); + assert.ok(v.some(x => x !== 0)); +} +function near(a, b, tolerance = 2e-5) { + assert.equal(a.length, b.length); + a.forEach((x, i) => assert.ok(Math.abs(x - b[i]) < tolerance, `${i}: ${x} != ${b[i]}`)); +} +const pcm = Float32Array.from({ length: 24000 }, (_, i) => 0.2 * Math.sin(i * 0.031)); +test('native exports and strict constructor validation', () => { + assert.equal(typeof Model, 'function'); + for (const p of [undefined, null, 1, '', 'a\0b']) assert.throws(() => new Model(p)); + for (const n of [-1, 0.5, NaN, Infinity, 2147483648, '1', null]) assert.throws(() => new Model(modelPath, n)); + assert.throws(() => Model(modelPath)); + assert.throws(() => new Model(path.join(fixtures, 'missing.gguf'))); +}); +test('real CLAP tokenization and text inference, with owned outputs', () => { + const m = new Model(modelPath, 1); + assert.equal(m.projectionDimension, 3); + assert.equal(m.audioSampleRate, 48000); + assert.throws(() => { m.projectionDimension = 5; }, TypeError); + const ids = m.tokenize('hello', 32); + assert.ok(ids instanceof Int32Array); + assert.ok(ids.length > 2); + assert.equal(ids[0], 0); + assert.equal(ids.at(-1), 2); + assert.ok(m.tokenize('你好 🎵', 32).length > 2); + const a = m.text('hello'); + vector(a, 3); + near(a, m.text(ids)); + near(a, m.text(Array.from(ids))); + assert.ok(Math.abs(Math.hypot(...a) - 1) < 1e-5); + const oracle = path.join(fixtures, 'reference.json'); + if (fs.existsSync(oracle)) { + const ref = JSON.parse(fs.readFileSync(oracle, 'utf8')); + near(m.text(ref.ids), ref.values); + } + const b = m.text('hello'); + a.fill(99); + near(b, m.text('hello')); + m.close(); m.close(); + vector(b, 3); + for (const f of [() => m.text('x'), () => m.tokenize('x'), () => m.audio(pcm), + () => m.audioFrames(pcm), () => m.clamp3Features(pcm), () => m.projectionDimension, + () => m.audioSampleRate]) assert.throws(f, /closed/i); +}); +test('text rejects a model closed by a reentrant token array getter', () => { + const child = spawnSync(process.execPath, ['-e', ` + const assert = require('node:assert/strict'); + const { Model } = require(${JSON.stringify(require.resolve(root))}); + const m = new Model(${JSON.stringify(modelPath)}, 1); + const ids = [0, 2]; + let calls = 0; + Object.defineProperty(ids, 0, { get() { ++calls; m.close(); return 0; } }); + assert.throws(() => m.text(ids), /closed/i); + assert.equal(calls, 1); + assert.throws(() => m.text('hello'), /closed/i); + m.close(); + console.log('reentrant close rejected safely'); + `], { encoding: 'utf8', timeout: 30000 }); + assert.equal(child.error, undefined); + assert.equal(child.signal, null, `child signal: ${child.signal}\n${child.stderr}`); + assert.equal(child.status, 0, child.stderr); + assert.match(child.stdout, /reentrant close rejected safely/); +}); +test('CLAP accepts actual waveforms and rejects unsupported frame APIs', () => { + const m = new Model(modelPath, 1); + vector(m.audio(pcm, 24000), 3); + vector(m.audio(pcm, 24000, 0), 3); + assert.throws(() => m.audioFrames(pcm, 24000)); + assert.throws(() => m.clamp3Features(pcm, 24000)); + m.close(); +}); +test('MERT actual frame, pooled and CLaMP3-feature inference', () => { + const m = new Model(mertPath, 1); + assert.equal(m.audioSampleRate, 24000); + assert.equal(m.projectionDimension, 4); + const f = m.audioFrames(pcm, 24000); + assert.deepEqual(Object.keys(f).sort(), ['dimension', 'frames', 'values']); + assert.equal(f.dimension, 4); + assert.ok(f.frames > 1); + vector(f.values, f.frames * f.dimension); + const pooled = m.audio(pcm, 24000); + vector(pooled, 4); + near(pooled, Array.from({ length: 4 }, (_, d) => { + let sum = 0; for (let t = 0; t < f.frames; ++t) sum += f.values[t * 4 + d]; + return sum / f.frames; + })); + const c = m.clamp3Features(pcm, 24000); + assert.equal(c.frames, 1); assert.equal(c.dimension, 4); vector(c.values, 4); + assert.throws(() => m.tokenize('hello')); + assert.throws(() => m.text('hello')); + m.close(); vector(f.values, f.frames * 4); +}); +test('strict method input validation and native exceptions', () => { + const m = new Model(modelPath, 1); + for (const x of [undefined, null, {}, [], new Uint8Array([1]), new Float32Array(), + new Float32Array([NaN]), new Float32Array([Infinity])]) { + assert.throws(() => m.audio(x)); assert.throws(() => m.audioFrames(x)); + assert.throws(() => m.clamp3Features(x)); + } + for (const rate of [0, -1, 0.5, NaN, Infinity, 2147483648, '24000', null]) { + assert.throws(() => m.audio(pcm, rate)); + assert.throws(() => m.audioFrames(pcm, rate)); + assert.throws(() => m.clamp3Features(pcm, rate)); + } + for (const offset of [-1, 0.5, Infinity, NaN, 2 ** 53, '0', null]) assert.throws(() => m.audio(pcm, 24000, offset)); + for (const x of [null, undefined, {}, 3, [], [-1], [0.5], [NaN], [Infinity], [2147483648], + ['1'], new Float32Array([1]), new Int32Array([-1])]) assert.throws(() => m.text(x)); + assert.throws(() => m.text([999999])); + for (const x of [null, 1, undefined]) assert.throws(() => m.tokenize(x)); + for (const n of [0, -1, 0.5, NaN, Infinity, 2 ** 53, '1', null]) assert.throws(() => m.tokenize('x', n)); + m.close(); +}); +test('decode mono/stereo WAV, resample and retain owned PCM', () => { + const a = decodeAudio(path.join(fixtures, 'stereo.wav')); + assert.equal(a.sampleRate, 24000); + vector(a.samples, 24000); + assert.ok(Math.abs(a.samples[0] - 0.25) < 1e-4); + const same = resampleAudio(a.samples, 24000, 24000); + assert.deepEqual(same, a.samples); + same[0] = 99; assert.notEqual(a.samples[0], 99); + const b = resampleAudio(pcm, 24000, 48000); + vector(b, 48000); + vector(resampleAudio(pcm, 24000), 48000); + vector(resampleAudio(pcm, 24000, 16000), 16000); + assert.equal(resampleAudio(new Float32Array([0.5]), 24000, 48000).length, 2); +}); +test('audio utility strict validation and decoder error translation', () => { + for (const p of [undefined, null, 1, '', 'a\0b', path.join(fixtures, 'missing.wav'), modelPath]) assert.throws(() => decodeAudio(p)); + for (const x of [null, [], new Float64Array([1]), new Float32Array(), new Float32Array([NaN])]) assert.throws(() => resampleAudio(x, 24000)); + for (const r of [undefined, null, 0, -1, 0.5, Infinity, NaN, 2147483648, '24000']) assert.throws(() => resampleAudio(pcm, r)); + for (const r of [null, 0, -1, 0.5, Infinity, NaN, 2147483648, '24000']) assert.throws(() => resampleAudio(pcm, 24000, r)); +}); diff --git a/node/test/generate-fixtures.py b/node/test/generate-fixtures.py new file mode 100644 index 0000000..5d620fb --- /dev/null +++ b/node/test/generate-fixtures.py @@ -0,0 +1,31 @@ +"""Generate deterministic real models/audio using the shared stdlib fixture.""" +import importlib.util +import json +from pathlib import Path +import struct +import wave + +root = Path(__file__).resolve().parents[1] +source = root.parent / 'python' / 'tests' / 'tiny_models.py' +if not source.exists(): + source = root / 'native-tests' / 'tiny_models.py' +spec = importlib.util.spec_from_file_location('tiny_models', source) +fixtures = importlib.util.module_from_spec(spec) +spec.loader.exec_module(fixtures) +out = root / 'test' / 'fixtures' +out.mkdir(parents=True, exist_ok=True) +fixtures.clap_fixture(out / 'clap.gguf') +fixtures.mert_fixture(out / 'mert.gguf') +with wave.open(str(out / 'stereo.wav'), 'wb') as wav: + wav.setparams((2, 2, 24000, 0, 'NONE', 'not compressed')) + wav.writeframes(struct.pack(' { + const r = spawnSync(process.execPath, ['scripts/vendor.js'], { cwd: root, encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + for (const p of ['native/include/clap.h', 'native/src/model.cpp', 'native/cmake/icu.cmake', + 'native/ggml/CMakeLists.txt', 'native/thirdparty/json/CMakeLists.txt', + 'native/thirdparty/icu/icu4c/source/common/unicode/uvernum.h', + 'native/thirdparty/miniaudio/miniaudio.h', 'native-tests/tiny_models.py', + 'LICENSE', 'THIRD_PARTY_LICENSES/ggml.txt', 'THIRD_PARTY_LICENSES/icu.txt', + 'THIRD_PARTY_LICENSES/miniaudio.txt', 'THIRD_PARTY_LICENSES/json.txt']) + assert.ok(fs.existsSync(path.join(root, p)), p); + assert.ok(!fs.existsSync(path.join(root, 'native/ggml/.git'))); + assert.ok(!fs.existsSync(path.join(root, 'native/thirdparty/icu/icu4c/source/data'))); + for (const p of ['native/thirdparty/miniaudio/data', 'native/thirdparty/miniaudio/tests', + 'native/thirdparty/json/docs', 'native/thirdparty/json/tests', 'native/ggml/tests']) + assert.ok(!fs.existsSync(path.join(root, p)), `not shipping ${p}`); +}); diff --git a/node/test/tsconfig.json b/node/test/tsconfig.json new file mode 100644 index 0000000..c6044e8 --- /dev/null +++ b/node/test/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"strict":true,"noEmit":true,"target":"ES2020","module":"commonjs","moduleResolution":"node","skipLibCheck":true},"files":["types.ts"]} diff --git a/node/test/types.ts b/node/test/types.ts new file mode 100644 index 0000000..08ffd2b --- /dev/null +++ b/node/test/types.ts @@ -0,0 +1,19 @@ +import { Model, decodeAudio, resampleAudio, AudioFrames } from '..'; +const model = new Model('model.gguf', 1); +const ids: Int32Array = model.tokenize('hello'); +const embedding: Float32Array = model.text(ids); +model.text('hello'); model.text([0, 2]); +const decoded = decodeAudio('clip.wav'); +const pcm: Float32Array = resampleAudio(decoded.samples, decoded.sampleRate); +const frames: AudioFrames = model.audioFrames(pcm); +model.audio(pcm, 48000, 0); model.clamp3Features(pcm, 24000); +const dimension: number = model.projectionDimension; +const sampleRate: number = model.audioSampleRate; +model.close(); +// @ts-expect-error read-only native property +model.projectionDimension = 2; +// @ts-expect-error PCM must have F32 storage +model.audio(new Float64Array(1)); +// @ts-expect-error tokens cannot be strings +model.text(['hello']); +void [embedding, frames, dimension, sampleRate];