Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/node.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions node/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
build/
native/
native-tests/
LICENSE
THIRD_PARTY_LICENSES/
*.tgz
test/*.log
test/fixtures/
63 changes: 63 additions & 0 deletions node/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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()
145 changes: 145 additions & 0 deletions node/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading