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
39 changes: 39 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Python

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
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install build and test dependencies
run: python -m pip install build pytest numpy
# build's default path builds the wheel FROM the sdist, exercising both.
- name: Build source distribution and CPU wheel
run: python -m build python --outdir dist
- name: Install wheel
run: python -m pip install --no-deps --no-index --find-links dist clap-cpp
- name: Test installed CPU bindings
run: python -m pytest python/tests -v
- uses: actions/upload-artifact@v4
with:
name: python-${{ matrix.os }}
path: dist/*
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ Audio decoding requires no
FFmpeg executable or system codec libraries to build or run the library and
executables. Only the test fixture generation step uses FFmpeg.

## Python bindings (CPU)

Install from a recursive checkout with `python -m pip install ./python`.
The [`python/` package](python/README.md) provides native text/audio embeddings,
NumPy waveform input and output, audio decoding/resampling, and model lifetime
management. It builds a self-contained CPU extension; no PyTorch or GPU runtime
is needed for inference. See the package README for API examples and tests.

## Build and test

```sh
Expand Down
50 changes: 50 additions & 0 deletions python/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
cmake_minimum_required(VERSION 3.20)
project(clap_cpp_python VERSION 0.1.0 LANGUAGES C CXX)

if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/native/include/clap.h")
set(CLAP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/native")
else()
get_filename_component(CLAP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE)
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)

find_package(Python REQUIRED COMPONENTS Interpreter Development.Module)
find_package(pybind11 CONFIG REQUIRED)
pybind11_add_module(_clap_cpp MODULE bindings.cpp)
target_link_libraries(_clap_cpp PRIVATE clap)
target_compile_features(_clap_cpp PRIVATE cxx_std_17)
set_target_properties(_clap_cpp PROPERTIES CXX_EXTENSIONS OFF)
install(TARGETS _clap_cpp LIBRARY DESTINATION clap_cpp COMPONENT python
RUNTIME DESTINATION clap_cpp COMPONENT python)
install(FILES "${CLAP_ROOT}/ggml/LICENSE" DESTINATION clap_cpp/licenses RENAME ggml.txt COMPONENT python)
install(FILES "${CLAP_ROOT}/thirdparty/miniaudio/LICENSE" DESTINATION clap_cpp/licenses RENAME miniaudio.txt COMPONENT python)
install(FILES "${CLAP_ROOT}/thirdparty/icu/LICENSE" DESTINATION clap_cpp/licenses RENAME icu.txt COMPONENT python)
install(FILES "${CLAP_ROOT}/thirdparty/json/LICENSE.MIT" DESTINATION clap_cpp/licenses RENAME json.txt COMPONENT python)
85 changes: 85 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# clap.cpp Python bindings (CPU)

Build and install from a recursive checkout:

```sh
git clone --recursive https://github.com/gorse-io/clap.cpp.git
cd clap.cpp
python -m pip install ./python
```

Requires Python 3.9+, a C++17 compiler and CMake 3.20+. The build backend installs
CMake/Ninja if necessary. NumPy is the only runtime Python dependency; PyTorch,
FFmpeg, a separate `clap` executable and GPU drivers are not required.
The extension statically links the CPU runtime and bundled ICU. GPU backends and
OpenMP are disabled. x86 wheels use baseline instructions rather than the build
machine's instruction set. No pretrained weights are downloaded automatically.

## Usage

Convert a supported checkpoint to GGUF with the repository's conversion tools,
then load it once and reuse it:

```python
from clap_cpp import Model, decode_audio

with Model("model.gguf", threads=4) as model:
text_embedding = model.text("A dog barking")
pcm, sample_rate = decode_audio("dog.wav")
audio_embedding = model.audio(pcm, sample_rate=sample_rate)
print(model.projection_dimension, model.audio_sample_rate)
```

`Model` uses the same GGUF architecture dispatch as the native library (CLAP,
CLaMP3, MERT-v1, MERT-v2, MuQ and MuQ-MuLan). An operation unsupported by a
particular architecture raises a native error; for example audio-only encoders
cannot embed text. CLaMP3 requires a GGUF with its bundled MERT frontend.

## API

- `Model(path, threads=0)`: `str`/`pathlib.Path`; zero selects the native thread
default. `close()` is idempotent and the context manager closes on exit.
- `model.text(text_or_tokens)`: one UTF-8 string or token-ID sequence to a
one-dimensional float32 NumPy embedding.
- `model.tokenize(text, max_length=512)`: native tokenizer returning a list of
integer IDs. Its limit must be compatible with the loaded model; strings may
be truncated by the tokenizer. Prefer `text(str)` for normal use.
- `model.audio(pcm, sample_rate=48000, crop_offset=0)`: mono waveform to a float32
embedding. Automatic model-rate resampling; crop offset uses the native
model-rate sample units and is not supported by every architecture.
- `model.audio_frames(pcm, sample_rate=48000)`: native frame representations as a
`[frames, dimension]` float32 array, for architectures exposing frames.
- `model.clamp3_features(pcm, sample_rate=24000)`: MERT-v1 CLaMP3 preprocessing
features as a `[windows, dimension]` float32 array (at least one second).
- `model.projection_dimension`, `model.audio_sample_rate`: read-only integers.
- `decode_audio(path)`: `(pcm, sample_rate)`; decodes WAV, MP3 or FLAC, averages
channels to mono, preserves sample rate.
- `resample_audio(pcm, source_rate, target_rate=48000)`: mono float32 array using
the native resampler.

PCM accepts one-dimensional numeric arrays or sequences. Noncontiguous arrays
and other numeric dtypes are converted to contiguous float32; the binding copies
inputs before releasing the GIL, so inference never retains a Python buffer.
Empty, nonfinite or multidimensional PCM and nonpositive sample rates are
rejected. Returned arrays own their storage and survive model closure.
Native invalid arguments map to `ValueError`, other native runtime errors to
`RuntimeError`. Calls after closing raise `RuntimeError`.

The binding releases the GIL for inference/loading/decoding/resampling and
serializes all operations on each model (including closing). Different model
instances can execute concurrently. Embedding normalization is architecture
specific and identical to the C++ API; the binding does not normalize outputs.

## Development and source distributions

```sh
python -m pip install './python[test]' build
python -m pytest python/tests
python -m build python
```

The source distribution includes the pinned native sources and third-party
licenses; a wheel can be rebuilt from that archive without a Git checkout or
CMake-time downloads. Build dependencies must still be available from the Python
package index or a local wheelhouse. Wheel publishing and GPU support are out of
scope for this initial binding.
145 changes: 145 additions & 0 deletions python/bindings.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#include "clap.h"

#include <pybind11/numpy.h>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <pybind11/stl/filesystem.h>

#include <cmath>
#include <cstring>
#include <filesystem>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <vector>

namespace py = pybind11;
using PCM = py::array_t<float, py::array::c_style | py::array::forcecast>;

namespace {

std::vector<float> samples(const PCM &input) {
if (input.ndim() != 1 || input.size() == 0) {
throw std::invalid_argument("PCM must be a nonempty one-dimensional mono array");
}
std::vector<float> result(input.data(), input.data() + input.size());
for (float value : result) {
if (!std::isfinite(value)) {
throw std::invalid_argument("PCM samples must be finite");
}
}
return result;
}

void validate_rate(int rate) {
if (rate <= 0) throw std::invalid_argument("sample rate must be positive");
}

py::array_t<float> array(const std::vector<float> &values) {
py::array_t<float> output(values.size());
if (!values.empty()) std::memcpy(output.mutable_data(), values.data(), values.size() * sizeof(float));
return output;
}

py::array_t<float> matrix(const clap::AudioFrames &frames) {
py::array_t<float> output({frames.frames, frames.dimension});
if (!frames.values.empty()) {
std::memcpy(output.mutable_data(), frames.values.data(), frames.values.size() * sizeof(float));
}
return output;
}

// Always release the GIL BEFORE taking the model mutex; release the mutex
// BEFORE reacquiring the GIL. close(), properties and inference share this order.
class Model {
public:
Model(const std::filesystem::path &path, int threads) {
if (threads < 0) throw std::invalid_argument("threads must be nonnegative");
py::gil_scoped_release release;
model_ = std::make_unique<clap::Model>(path.u8string(), threads);
}

template <typename F>
auto call(F &&function) {
py::gil_scoped_release release;
std::lock_guard<std::mutex> lock(mutex_);
if (!model_) throw std::runtime_error("Model is closed");
return function(*model_);
}

void close() {
py::gil_scoped_release release;
std::lock_guard<std::mutex> lock(mutex_);
model_.reset();
}

private:
std::unique_ptr<clap::Model> model_;
std::mutex mutex_;
};

} // namespace

PYBIND11_MODULE(_clap_cpp, module) {
module.doc() = "Native CPU inference for clap.cpp";
py::class_<Model>(module, "Model")
.def(py::init<const std::filesystem::path &, int>(), py::arg("path"), py::arg("threads") = 0)
.def_property_readonly("projection_dimension", [](Model &self) {
return self.call([](clap::Model &model) { return model.projection_dimension(); });
})
.def_property_readonly("audio_sample_rate", [](Model &self) {
return self.call([](clap::Model &model) { return model.audio_sample_rate(); });
})
.def("tokenize", [](Model &self, const std::string &text, py::ssize_t max_length) {
if (max_length <= 0) throw std::invalid_argument("max_length must be positive");
return self.call([&](clap::Model &model) { return model.tokenize(text, max_length); });
}, py::arg("text"), py::arg("max_length") = 512)
.def("text", [](Model &self, const std::string &text) {
return array(self.call([&](clap::Model &model) { return model.text(text); }));
}, py::arg("text"))
.def("text", [](Model &self, const std::vector<int> &tokens) {
return array(self.call([&](clap::Model &model) { return model.text(tokens); }));
}, py::arg("tokens"))
.def("audio", [](Model &self, const PCM &pcm, int sample_rate, py::ssize_t crop_offset) {
validate_rate(sample_rate);
if (crop_offset < 0) throw std::invalid_argument("crop_offset must be nonnegative");
auto input = samples(pcm);
return array(self.call([&](clap::Model &model) { return model.audio(input, sample_rate, crop_offset); }));
}, py::arg("pcm"), py::arg("sample_rate") = 48000, py::arg("crop_offset") = 0)
.def("audio_frames", [](Model &self, const PCM &pcm, int sample_rate) {
validate_rate(sample_rate);
auto input = samples(pcm);
return matrix(self.call([&](clap::Model &model) { return model.audio_frames(input, sample_rate); }));
}, py::arg("pcm"), py::arg("sample_rate") = 48000)
.def("clamp3_features", [](Model &self, const PCM &pcm, int sample_rate) {
validate_rate(sample_rate);
auto input = samples(pcm);
return matrix(self.call([&](clap::Model &model) { return model.clamp3_features(input, sample_rate); }));
}, py::arg("pcm"), py::arg("sample_rate") = 24000)
.def("close", &Model::close)
.def("__enter__", [](Model &self) -> Model & {
self.call([](clap::Model &) {});
return self;
}, py::return_value_policy::reference_internal)
.def("__exit__", [](Model &self, py::object, py::object, py::object) { self.close(); });

module.def("decode_audio", [](const std::filesystem::path &path) {
clap::Audio decoded;
{
py::gil_scoped_release release;
decoded = clap::decode_audio(path.u8string());
}
return py::make_tuple(array(decoded.samples), decoded.sample_rate);
}, py::arg("path"));
module.def("resample_audio", [](const PCM &pcm, int source_rate, int target_rate) {
validate_rate(source_rate);
validate_rate(target_rate);
clap::Audio input{samples(pcm), source_rate};
clap::Audio output;
{
py::gil_scoped_release release;
output = clap::resample_audio(input, target_rate);
}
return array(output.samples);
}, py::arg("pcm"), py::arg("source_rate"), py::arg("target_rate") = 48000);
}
5 changes: 5 additions & 0 deletions python/clap_cpp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""CPU audio/text embeddings backed by the native clap.cpp runtime."""

from ._clap_cpp import Model, decode_audio, resample_audio

__all__ = ["Model", "decode_audio", "resample_audio"]
40 changes: 40 additions & 0 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[build-system]
requires = ["scikit-build-core>=1.0", "pybind11>=2.13"]
build-backend = "scikit_build_core.build"

[project]
name = "clap-cpp"
version = "0.1.0"
description = "CPU Python bindings for clap.cpp audio and text embeddings"
readme = "README.md"
requires-python = ">=3.9"
license = "MIT"
dependencies = ["numpy>=1.23"]

[project.optional-dependencies]
test = ["pytest>=7"]

[tool.scikit-build]
minimum-version = "build-system.requires"
wheel.packages = ["clap_cpp"]
install.components = ["python"]
build.targets = ["_clap_cpp"]
sdist.exclude = ["**/.git", "**/__pycache__/**", "dist/**", "build/**"]

# The sdist carries the pinned native sources, so it builds without a Git
# checkout, submodule initialization or network fetches during CMake configure.
[tool.scikit-build.sdist.force-include]
"../LICENSE" = "LICENSE"
"../include" = "native/include"
"../src" = "native/src"
"../cmake" = "native/cmake"
"../ggml" = "native/ggml"
"../thirdparty/json" = "native/thirdparty/json"
"../thirdparty/miniaudio" = "native/thirdparty/miniaudio"
"../thirdparty/icu/LICENSE" = "native/thirdparty/icu/LICENSE"
"../thirdparty/icu/icu4c/source/common" = "native/thirdparty/icu/icu4c/source/common"
"../thirdparty/icu/icu4c/source/i18n" = "native/thirdparty/icu/icu4c/source/i18n"
"../thirdparty/icu/icu4c/source/stubdata" = "native/thirdparty/icu/icu4c/source/stubdata"

[tool.pytest.ini_options]
testpaths = ["tests"]
Loading
Loading