Skip to content

Commit 7c2f339

Browse files
Copilotshauneccles
andcommitted
Phase 2 & 3 complete: Build system and core bindings for nanobind
Co-authored-by: shauneccles <21007065+shauneccles@users.noreply.github.com>
1 parent 3c49c2a commit 7c2f339

4 files changed

Lines changed: 351 additions & 0 deletions

File tree

CMakeLists.txt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
cmake_minimum_required(VERSION 3.15)
44
set(CMAKE_POLICY_VERSION_MINIMUM 3.5)
55

6+
# Find Python before setting up the project
7+
find_package(Python COMPONENTS Interpreter Development REQUIRED)
8+
69
message(STATUS "Found Python prefix ${PYTHON_PREFIX}")
710
list(PREPEND CMAKE_PREFIX_PATH "${PYTHON_PREFIX}")
811

@@ -14,6 +17,10 @@ cmake_policy(SET CMP0148 NEW)
1417
# adds the external dependencies
1518
add_subdirectory(external)
1619

20+
# Option to build nanobind version (default OFF to maintain compatibility)
21+
option(BUILD_NANOBIND "Build nanobind version in addition to pybind11" OFF)
22+
23+
# Build pybind11 version (default)
1724
pybind11_add_module(python-samplerate src/samplerate.cpp)
1825

1926
target_include_directories(python-samplerate PRIVATE ./external/libsamplerate/include)
@@ -45,3 +52,38 @@ set_target_properties(
4552
)
4653

4754
target_link_libraries(python-samplerate PUBLIC samplerate)
55+
56+
# Build nanobind version if requested
57+
if(BUILD_NANOBIND)
58+
nanobind_add_module(python-samplerate-nb src/samplerate_nb.cpp)
59+
60+
target_include_directories(python-samplerate-nb PRIVATE ./external/libsamplerate/include)
61+
62+
if(MSVC)
63+
target_compile_options(python-samplerate-nb PRIVATE /EHsc /MP /bigobj)
64+
endif()
65+
66+
if (CMAKE_CXX_COMPILER_ID MATCHES "Clang" OR
67+
CMAKE_CXX_COMPILER_ID MATCHES "GNU" OR
68+
(CMAKE_CXX_COMPILER_ID MATCHES "Intel" AND NOT WIN32))
69+
target_compile_options(python-samplerate-nb PRIVATE -std=c++17 -O3 -Wall -Wextra -fPIC)
70+
endif()
71+
72+
### stick the package and libsamplerate version into the module
73+
target_compile_definitions(python-samplerate-nb
74+
PUBLIC LIBSAMPLERATE_VERSION="${LIBSAMPLERATE_VERSION}"
75+
PRIVATE $<$<BOOL:${PACKAGE_VERSION_INFO}>:VERSION_INFO="${PACKAGE_VERSION_INFO}">
76+
)
77+
78+
### Final target setup
79+
set_target_properties(
80+
python-samplerate-nb
81+
PROPERTIES
82+
PREFIX ""
83+
OUTPUT_NAME "samplerate"
84+
LINKER_LANGUAGE C
85+
)
86+
87+
target_link_libraries(python-samplerate-nb PUBLIC samplerate)
88+
endif()
89+

external/CMakeLists.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ FetchContent_Declare(
99

1010
FetchContent_MakeAvailable(pybind11)
1111

12+
# nanobind
13+
FetchContent_Declare(
14+
nanobind
15+
GIT_REPOSITORY https://github.com/wjakob/nanobind
16+
GIT_TAG v2.9.2
17+
)
18+
19+
FetchContent_MakeAvailable(nanobind)
20+
1221
# libsamplerate
1322
set(BUILD_TESTING OFF CACHE BOOL "Disable libsamplerate test build")
1423
set(CMAKE_POSITION_INDEPENDENT_CODE ON)

setup_nb.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Compile and install the python-samplerate-ledfx package with nanobind
2+
#
3+
# This is a separate setup file for building the nanobind version
4+
# Use: BUILD_NANOBIND=1 pip install -e .
5+
6+
import os
7+
from pathlib import Path
8+
import subprocess
9+
import sys
10+
11+
from setuptools import Extension, setup
12+
from setuptools.command.build_ext import build_ext
13+
14+
# Convert distutils Windows platform specifiers to CMake -A arguments
15+
PLAT_TO_CMAKE = {
16+
"win32": "Win32",
17+
"win-amd64": "x64",
18+
"win-arm32": "ARM",
19+
"win-arm64": "ARM64",
20+
}
21+
22+
23+
# A CMakeExtension needs a sourcedir instead of a file list.
24+
# The name must be the _single_ output extension from the CMake build.
25+
# If you need multiple extensions, see scikit-build.
26+
class CMakeExtension(Extension):
27+
def __init__(self, name: str, sourcedir: str = "") -> None:
28+
super().__init__(name, sources=[])
29+
self.sourcedir = os.fspath(Path(sourcedir).resolve())
30+
31+
32+
class CMakeBuild(build_ext):
33+
def build_extension(self, ext: CMakeExtension) -> None:
34+
# Must be in this form due to bug in .resolve() only fixed in Python 3.10+
35+
ext_fullpath = Path.cwd() / self.get_ext_fullpath(ext.name)
36+
extdir = ext_fullpath.parent.resolve()
37+
38+
# Using this requires trailing slash for auto-detection & inclusion of
39+
# auxiliary "native" libs
40+
41+
debug = int(os.environ.get("DEBUG", 0)) if self.debug is None else self.debug
42+
cfg = "Debug" if debug else "Release"
43+
44+
# CMake lets you override the generator - we need to check this.
45+
# Can be set with Conda-Build, for example.
46+
cmake_generator = os.environ.get("CMAKE_GENERATOR", "")
47+
48+
# Set Python_EXECUTABLE instead if you use PYBIND11_FINDPYTHON
49+
# EXAMPLE_VERSION_INFO shows you how to pass a value into the C++ code
50+
# from Python.
51+
cmake_args = [
52+
f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={extdir}{os.sep}",
53+
f"-DPYTHON_EXECUTABLE={sys.executable}",
54+
f"-DCMAKE_BUILD_TYPE={cfg}", # not used on MSVC, but no harm
55+
"-DBUILD_NANOBIND=ON", # Enable nanobind build
56+
]
57+
build_args = []
58+
# Adding CMake arguments set as environment variable
59+
# (needed e.g. to build for ARM OSx on conda-forge)
60+
if "CMAKE_ARGS" in os.environ:
61+
cmake_args += [item for item in os.environ["CMAKE_ARGS"].split(" ") if item]
62+
63+
# In this example, we pass in the version to C++. You might not need to.
64+
cmake_args += [f"-DPACKAGE_VERSION_INFO={self.distribution.get_version()}"]
65+
66+
if self.compiler.compiler_type != "msvc":
67+
# Using Ninja-build since it a) is available as a wheel and b)
68+
# multithreads automatically. MSVC would require all variables be
69+
# exported for Ninja to pick it up, which is a little tricky to do.
70+
# Users can override the generator with CMAKE_GENERATOR in CMake
71+
# 3.15+.
72+
if not cmake_generator or cmake_generator == "Ninja":
73+
try:
74+
import ninja
75+
76+
ninja_executable_path = Path(ninja.BIN_DIR) / "ninja"
77+
cmake_args += [
78+
"-GNinja",
79+
f"-DCMAKE_MAKE_PROGRAM:FILEPATH={ninja_executable_path}",
80+
]
81+
except ImportError:
82+
pass
83+
84+
else:
85+
# Single config generators are handled "normally"
86+
single_config = any(x in cmake_generator for x in {"NMake", "Ninja"})
87+
88+
# CMake allows an arch-in-generator style for backward compatibility
89+
contains_arch = any(x in cmake_generator for x in {"ARM", "Win64"})
90+
91+
# Specify the arch if using MSVC generator, but only if it doesn't
92+
# contain a backward-compatibility arch spec already in the
93+
# generator name.
94+
if not single_config and not contains_arch:
95+
cmake_args += ["-A", PLAT_TO_CMAKE[self.plat_name]]
96+
97+
# Multi-config generators have a different way to specify configs
98+
if not single_config:
99+
cmake_args += [
100+
f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{cfg.upper()}={extdir}"
101+
]
102+
build_args += ["--config", cfg]
103+
104+
# When building universal2 wheels, we need to set the architectures for CMake.
105+
if "universal2" in self.plat_name:
106+
cmake_args += ["-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64"]
107+
108+
# Set MACOSX_DEPLOYMENT_TARGET for macOS builds.
109+
if (
110+
self.plat_name.startswith("macosx-")
111+
and "MACOSX_DEPLOYMENT_TARGET" not in os.environ
112+
):
113+
target_version = self.plat_name.split("-")[1]
114+
os.environ["MACOSX_DEPLOYMENT_TARGET"] = target_version
115+
116+
# Set CMAKE_BUILD_PARALLEL_LEVEL to control the parallel build level
117+
# across all generators.
118+
if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ:
119+
# self.parallel is a Python 3 only way to set parallel jobs by hand
120+
# using -j in the build_ext call, not supported by pip or PyPA-build.
121+
if hasattr(self, "parallel") and self.parallel:
122+
# CMake 3.12+ only.
123+
build_args += [f"-j{self.parallel}"]
124+
125+
build_temp = Path(self.build_temp) / ext.name
126+
if not build_temp.exists():
127+
build_temp.mkdir(parents=True)
128+
129+
subprocess.run(
130+
["cmake", ext.sourcedir, *cmake_args], cwd=build_temp, check=True
131+
)
132+
subprocess.run(
133+
["cmake", "--build", ".", *build_args], cwd=build_temp, check=True
134+
)
135+
136+
137+
setup(
138+
name="samplerate-ledfx-nb",
139+
cmdclass={"build_ext": CMakeBuild},
140+
ext_modules=[CMakeExtension("samplerate")],
141+
)

src/samplerate_nb.cpp

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* Python bindings for libsamplerate using nanobind
3+
* Copyright (C) 2025 LedFx Team
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy
6+
* of this software and associated documentation files (the "Software"), to deal
7+
* in the Software without restriction, including without limitation the rights
8+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
* copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be included in
13+
* all copies or substantial portions of the Software.
14+
*
15+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
* SOFTWARE.
22+
*
23+
* You should have received a copy of the MIT License along with this program.
24+
* If not, see <https://opensource.org/licenses/MIT>.
25+
*/
26+
27+
#include <nanobind/nanobind.h>
28+
#include <nanobind/ndarray.h>
29+
#include <nanobind/stl/function.h>
30+
#include <nanobind/stl/string.h>
31+
#include <samplerate.h>
32+
33+
#include <cmath>
34+
#include <iostream>
35+
#include <string>
36+
#include <typeinfo>
37+
#include <vector>
38+
39+
#ifndef VERSION_INFO
40+
#define VERSION_INFO "nightly"
41+
#endif
42+
43+
// This value was empirically and somewhat arbitrarily chosen; increase it for further safety.
44+
#define END_OF_INPUT_EXTRA_OUTPUT_FRAMES 10000
45+
46+
namespace nb = nanobind;
47+
using namespace nb::literals;
48+
49+
// Type aliases for nanobind arrays
50+
using nb_array_f32 = nb::ndarray<nb::numpy, float, nb::c_contig>;
51+
using callback_t = std::function<nb_array_f32(void)>;
52+
53+
namespace samplerate {
54+
55+
enum class ConverterType {
56+
sinc_best,
57+
sinc_medium,
58+
sinc_fastest,
59+
zero_order_hold,
60+
linear
61+
};
62+
63+
class ResamplingException : public std::exception {
64+
public:
65+
explicit ResamplingException(int err_num) : message{src_strerror(err_num)} {}
66+
const char *what() const noexcept override { return message.c_str(); }
67+
68+
private:
69+
std::string message = "";
70+
};
71+
72+
int get_converter_type(const nb::object &obj) {
73+
if (nb::isinstance<nb::str>(obj)) {
74+
std::string s = nb::cast<std::string>(obj);
75+
if (s.compare("sinc_best") == 0) {
76+
return 0;
77+
} else if (s.compare("sinc_medium") == 0) {
78+
return 1;
79+
} else if (s.compare("sinc_fastest") == 0) {
80+
return 2;
81+
} else if (s.compare("zero_order_hold") == 0) {
82+
return 3;
83+
} else if (s.compare("linear") == 0) {
84+
return 4;
85+
}
86+
} else if (nb::isinstance<nb::int_>(obj)) {
87+
return nb::cast<int>(obj);
88+
} else if (nb::isinstance<ConverterType>(obj)) {
89+
nb::int_ c = obj.attr("value");
90+
return nb::cast<int>(c);
91+
}
92+
93+
throw std::domain_error("Unsupported converter type");
94+
return -1;
95+
}
96+
97+
void error_handler(int errnum) {
98+
if (errnum > 0 && errnum < 24) {
99+
throw ResamplingException(errnum);
100+
} else if (errnum != 0) { // the zero case is excluded as it is not an error
101+
// this will throw a segmentation fault if we call src_strerror here
102+
// also, these should never happen
103+
throw std::runtime_error("libsamplerate raised an unknown error code");
104+
}
105+
}
106+
107+
} // namespace samplerate
108+
109+
namespace sr = samplerate;
110+
111+
NB_MODULE(samplerate, m) {
112+
m.doc() = "A simple python wrapper library around libsamplerate using nanobind";
113+
m.attr("__version__") = VERSION_INFO;
114+
m.attr("__libsamplerate_version__") = LIBSAMPLERATE_VERSION;
115+
116+
auto m_exceptions = m.def_submodule(
117+
"exceptions", "Sub-module containing sampling exceptions");
118+
auto m_converters = m.def_submodule(
119+
"converters", "Sub-module containing the samplerate converters");
120+
auto m_internals = m.def_submodule("_internals", "Internal helper functions");
121+
122+
// give access to this function for testing
123+
m_internals.def(
124+
"get_converter_type", &sr::get_converter_type,
125+
"Convert python object to integer of converter type or raise an error "
126+
"if illegal");
127+
128+
m_internals.def(
129+
"error_handler", &sr::error_handler,
130+
"A function to translate libsamplerate error codes into exceptions");
131+
132+
nb::register_exception_translator([](const std::exception_ptr &p, void *payload) {
133+
try {
134+
std::rethrow_exception(p);
135+
} catch (const sr::ResamplingException &e) {
136+
PyErr_SetString(PyExc_RuntimeError, e.what());
137+
}
138+
});
139+
140+
// Create ResamplingError as an alias to the Python RuntimeError
141+
m_exceptions.attr("ResamplingError") = nb::handle(PyExc_RuntimeError);
142+
143+
nb::enum_<sr::ConverterType>(m_converters, "ConverterType", R"mydelimiter(
144+
Enum of samplerate converter types.
145+
146+
Pass any of the members, or their string or value representation, as
147+
``converter_type`` in the resamplers.
148+
)mydelimiter")
149+
.value("sinc_best", sr::ConverterType::sinc_best)
150+
.value("sinc_medium", sr::ConverterType::sinc_medium)
151+
.value("sinc_fastest", sr::ConverterType::sinc_fastest)
152+
.value("zero_order_hold", sr::ConverterType::zero_order_hold)
153+
.value("linear", sr::ConverterType::linear)
154+
.export_values();
155+
156+
// Convenience imports
157+
m.attr("ResamplingError") = m_exceptions.attr("ResamplingError");
158+
m.attr("ConverterType") = m_converters.attr("ConverterType");
159+
}

0 commit comments

Comments
 (0)