diff --git a/.claude/skills/forge-ci/SKILL.md b/.claude/skills/forge-ci/SKILL.md index b6c55135..345fc166 100644 --- a/.claude/skills/forge-ci/SKILL.md +++ b/.claude/skills/forge-ci/SKILL.md @@ -40,9 +40,21 @@ build-wheels.yml (push / workflow_dispatch / workflow_call) Key structural facts: -- **On push**, `detect` diffs `recipes/**` against the base and builds every - changed recipe. The detected package list is ordered like `git diff` output +- **On push**, `detect` diffs `recipes/**` for **that push** (before..after) and builds + every changed recipe. The detected package list is ordered like `git diff` output (pathname-sorted) — do not rely on that ordering for dependency chains. +- **FOOTGUN — a follow-up push that touches no recipe silently downgrades the branch's + CI and kills the real run.** The diff is per-push, *not* branch-vs-`main`, so a second + commit containing only `.claude/skills/**`, `README.rst`, `src/forge/**` or + `recipes/*/README.md` detects **zero** changed recipes and falls back to + `SMOKE_TEST_PACKAGES` (`lru-dict`, `pydantic-core`, `numpy`) — and branch concurrency + then **cancels the still-running recipe build** it superseded. Net effect: the branch's + newest run is green, and it never built your recipe. Tell: the job list names the smoke + packages and not yours; confirm in the `detect` log + (`pkgs="${INPUT_PACKAGES:-$SMOKE_TEST_PACKAGES}"`). Either land such commits **before** + the recipe commit, or recover with an explicit dispatch — + `gh workflow run build-wheels.yml --repo --ref -f packages=":"` + — after cancelling the smoke run. Cost when missed: a full recipe matrix. soxr 1.1.0. - **Each matrix job builds ONLY its own package(s).** Its `dist/` (and the `dist-test/` find-links dir the mobile test resolves from) contains that job's wheels plus whatever `prebuild_recipes` added — nothing from sibling diff --git a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md index f24c75d9..ef80ccf3 100644 --- a/.claude/skills/forge-error-catalogue/references/failure-catalogue.md +++ b/.claude/skills/forge-error-catalogue/references/failure-catalogue.md @@ -925,6 +925,15 @@ libpython explicitly. But the libpython flags are multi-token and forge's `CMAKE_ARGS` is shlex-split, so `-DCMAKE_MODULE_LINKER_FLAGS=… -L… -lpython…` can't be passed as separate tokens. +**Also fires with no upstream involvement at all:** the NDK toolchain adds +`-Wl,--no-undefined` itself, so **any** `add_library(… MODULE …)` hits this on Android even +when upstream sets no such flag. `nanobind_add_module` builds a MODULE target, which makes +every nanobind recipe a candidate (soxr 1.1.0: ~20 `undefined symbol: PyExc_…` / +`_Py_Dealloc` / `PyMem_Malloc` lines, all three ABIs, iOS unaffected). Confirm the target +type with `grep -n "add_library" /cmake/nanobind-config.cmake` before reaching for +`CMAKE_SHARED_LINKER_FLAGS` — for a MODULE it is `CMAKE_MODULE_LINKER_FLAGS` that applies, +and setting the wrong one changes nothing. + **Fix:** fold everything into ONE comma-joined `-Wl` token: `-DCMAKE_MODULE_LINKER_FLAGS=-Wl,-z,max-page-size=16384,-L{HOST_PYTHON_HOME}/lib,-lpython{py_version_short}`. The linker splits `-Wl` args on commas, dodging the single-token constraint. onnx. @@ -1092,6 +1101,35 @@ right arch from the NDK toolchain, so it never needs this. opencv-python 5.0.0.9 --- +### iOS: a GREEN wheel that silently lost its SIMD/optimised code path (no `CMAKE_SYSTEM_PROCESSOR`) + +**Cause:** the mirror image of the entry above, and far nastier because **nothing fails**. +CMake leaves `CMAKE_SYSTEM_PROCESSOR` **empty** on the iOS lane (only the NDK toolchain +presets it), so a vendored library that runs its own CPU probe falls through to its *x86* +branch, fails to compile the SSE test on arm64, and drops its SIMD sources from the archive. +The build exits 0, the wheel loads, functional tests pass — it is just slower. libsoxr is the +worked example: `SetSystemProcessor.cmake` probes only `__x86_64__` / `__i386__` / `__arm__`, +and iOS arm64 defines `__aarch64__`, matching none of the three; `FindSIMD32` then takes its +`xmmintrin.h` branch, `WITH_CR32S` becomes 0, and `cr32s`/`pffft32s`/`util32s` vanish. +Measured cost of the resulting scalar build (`SOXR_USE_SIMD32=0`, 10 s mono 48k→16k, `HQ`, +macOS arm64): **2.8x**, 0.70 ms → 1.98 ms. + +**Fix:** name the processor on the iOS lane, `-DCMAKE_SYSTEM_PROCESSOR={{ arch }}`, and +**never** on Android. The exact token is library-specific — match it to the vendored +project's own matcher rather than to a convention: libsoxr's `FindSIMD32` accepts `^arm` OR +`^aarch64` (so `arm64` works), while opencv needs `aarch64` (entry above). Read the matcher +before copying either recipe. + +**Tell it apart from a healthy build:** grep the configure log for the probe's own success +line (`-- Found SIMD32:`) — its *absence* is the whole signal, and absence is easy to miss. +Then confirm in the artifact: `strings | grep -x cr32s`. The durable defence for this +class is a test asserting the fast path is **compiled in**, not merely that the module +imports — soxr's `test_simd_engine_compiled_in` asserts `engine() == "cr32s"` on device. +Generalises to any recipe whose vendored dependency does its own +`check_c_source_compiles`-style CPU detection. soxr 1.1.0. + +--- + ### iOS: Rust `error[E0432]: unresolved import 'internal'` / `cannot find module 'os'` (a crate with no `target_os="ios"` backend) **Cause:** a platform-specific Rust crate (here `mac_address` 1.x, pulled in for v1/v6 diff --git a/recipes/soxr/README.md b/recipes/soxr/README.md new file mode 100644 index 00000000..2f291476 --- /dev/null +++ b/recipes/soxr/README.md @@ -0,0 +1,199 @@ +# soxr + +[`soxr`](https://github.com/dofuuz/python-soxr) is the SoX Resampler bound to Python — one +job, done well: change the sample rate of audio. It wraps +[libsoxr](https://sourceforge.net/projects/soxr/), the resampler behind SoX, and compiles it +into the wheel. + +Sample-rate conversion is the step almost every on-device audio pipeline needs and almost no +model provides. A recorder hands you 44.1 or 48 kHz; speech models want 16 kHz. soxr does +that conversion in a few hundred KB of compiled code, with no FFT library, no BLAS and no +model runtime behind it. + +## Install + +```toml +dependencies = [ + "flet", + "soxr", +] +``` + +The API takes and returns [numpy](https://numpy.org/doc/stable/) arrays, so what you pass in +decides what you get back: pass `float32` and the result is `float32`, pass `int16` and it +stays `int16`. `float64` and `int32` work too, and anything else raises. + +## Examples + +See runnable Flet apps in [`examples/`](examples): + +- [`resample-tone`](examples/resample-tone) — converts a generated tone between rates on a + worker thread and reports the rate, the timing and the engine in use. + +## Usage in a Flet app + +Two entry points cover everything. +[`soxr.resample`](https://python-soxr.readthedocs.io/en/stable/soxr.html#soxr.resample) +converts an array you already hold: + +```python +import numpy as np +import soxr + +y = soxr.resample(x, 48000, 16000) # x: float32 array at 48 kHz +``` + +[`soxr.ResampleStream`](https://python-soxr.readthedocs.io/en/stable/soxr.html#soxr.ResampleStream) +keeps filter state across calls, which is what a microphone feed or a file too large to hold +at once needs: + +```python +stream = soxr.ResampleStream(48000, 16000, 1, dtype="float32", quality="HQ") +out = stream.resample_chunk(chunk, last=is_final_chunk) +``` + +Set `last=True` exactly once, on the final chunk, to flush the filter tail — otherwise the +last few milliseconds never come out. Chunked output then concatenates to the same result as +one `resample` call over the whole signal. + +Quality defaults to `HQ`, which is also the best choice on a phone: it is the highest +setting that still runs on libsoxr's SIMD engine. `VHQ` is a worse trade than it looks — +see **Things to know**. + +In an app, run the conversion off the UI thread and put the result into a control: + +```python +status = ft.Text() + +def work(): + y = soxr.resample(x, 48000, 16000) + status.value = f"{len(x):,} frames @48k → {len(y):,} @16k" + page.update() # a background thread needs this explicitly + +page.add(status, ft.Button("Resample", on_click=lambda _: page.run_thread(work))) +``` + +### Storage + +soxr reads and writes nothing: an array goes in, an array comes out, with no config +directory, no cache and no network. Audio files you keep belong in +[`FLET_APP_STORAGE_DATA`](https://flet.dev/docs/reference/environment-variables/#flet_app_storage_data), +but that is your file handling, not soxr's. + +soxr does not decode or encode audio files. Getting samples out of a `.wav` is the standard +library's [`wave`](https://docs.python.org/3/library/wave.html) module; any other container +or codec needs its own package. + +### Threading + +The compiled resampler releases the GIL around every conversion, so +[`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) above +buys real concurrency rather than only a responsive UI — two conversions on two threads +genuinely overlap. + +Catch exceptions inside the worker, and finish with an explicit +[`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update): a background +thread does not get the automatic one. A `ResampleStream` carries filter state, so calling +`resample_chunk` on one stream from two threads at once corrupts it — give each thread its +own stream, or serialise the calls behind a lock. `run_thread` uses a pool, so two quick +taps can overlap. + +### App size + +Roughly 155–225 KB compressed and 300–530 KB unpacked per slice, almost all of it the one +compiled extension; `armeabi_v7a` is the smallest and `x86_64` the largest. There are no +data files. That is small enough that the decision worth making is about numpy, which the +package needs and which is many times its size — most apps reaching for a resampler already +carry it for other reasons. + +On Android, use an app bundle, split APKs, or narrow +[`target_arch`](https://flet.dev/docs/publish/android/#supported-target-architectures) when +the app does not need every ABI. + +### Other considerations + +A desktop `flet run` uses PyPI's own wheel rather than this one. The API is identical, but +the compiled engine is not always: PyPI's macOS and Linux x86_64 builds carry libsoxr's AVX +engine, which no ARM build has, so a `VHQ` timing measured at your desk does not transfer to +a phone. Time the quality setting you actually ship on a device or emulator/simulator. + +## Things to know + +- **`VHQ` is slower on a phone than its name suggests.** `QQ`, `LQ`, `MQ` and `HQ` all run + on libsoxr's 32-bit SIMD engine. `VHQ` raises precision to 28 bits, which crosses into the + double-precision engine — and that engine's SIMD variant is AVX, so on every ARM device it + falls back to a scalar core. Prefer `HQ` unless you have measured that you need more. + +- **`resample_chunk` type-checks exactly.** It tests `type(x) != np.ndarray`, so a numpy + *subclass* is rejected with a `TypeError` naming the dtype even when the dtype is right. + Pass `np.asarray(x)`. The dtype must also match the one given to the constructor; it is not + converted for you. + +- **Multi-channel work runs on one core.** libsoxr can split channels across threads, but + that path is compiled out here, as it is in upstream's own wheels. The GIL is released + during conversion, so arrange parallelism yourself with `run_thread` if you need it. + +- **soxr is LGPL-2.1-or-later, and this wheel links libsoxr statically.** The licence texts + ship inside the wheel under `dist-info/licenses/`. For an open-source app that is the end + of it. If you are shipping a closed-source app, LGPL section 6 asks that a user be able to + relink your app against a modified libsoxr, which a statically linked store binary does not + offer on its own; section 6a (shipping your object files) is the usual answer where it + matters. This is a flag, not legal advice. + +## Build notes (maintainers) + +### Recipe shape + +scikit-build-core + CMake over a self-contained sdist that vendors libsoxr — the +[`duckdb`](../duckdb) / [`rapidfuzz`](../rapidfuzz) archetype, with no patches. Upstream +already does the things a cross build usually has to be patched into: the nanobind stub step +is guarded behind `NOT CMAKE_CROSSCOMPILING`, `vr-coefs.h` ships pre-generated so no host +code generator runs, nothing anywhere uses `try_run`, and OpenMP, the LSR bindings and shared +libraries are all turned off before `add_subdirectory(libsoxr)`. + +A separate `flet-libsoxr` was considered and rejected: upstream supports +`USE_SYSTEM_LIBSOXR=ON`, but a shared libsoxr bundled inside a signed APK or IPA is no more +relinkable by the user than a static one, so it would add a recipe and a load-time dependency +without changing the licensing position that motivates it. + +This is the repo's first **nanobind** recipe, which is the reason for both `meta.yaml` +settings that are not boilerplate; each is explained in a comment beside it. + +### Upgrade hazards + +- **A lost SIMD engine does not fail the build.** libsoxr compiles its `cr32s` core only when + CMake can identify the target CPU, and the wheel is perfectly functional without it — just + measurably slower. `test_simd_engine_compiled_in` is the guard; if a bump moves libsoxr's + CMake modules, check that test before anything else. +- **`cmake/versioning.cmake` runs `git describe` against `VCS_REPO_DIR`.** Inside forge's + build tree that walks up into mobile-forge's own repository and stamps *its* commit into + `soxr.__libsoxr_version__`, overwriting the value the sdist ships. Cosmetic, and the inner + `cmake -P` is a fresh cacheless invocation so `-DGIT_EXECUTABLE=` cannot reach it. **Do not + write a test asserting `__libsoxr_version__`.** +- **`CMAKE_INSTALL_PREFIX ../install`** is set before `add_subdirectory(libsoxr)`, so + libsoxr's own install rules resolve beside the wheel staging directory. They land outside + the wheel today, but a layout change upstream could start leaking `lib/libsoxr.a` and the + docs into the payload. +- **`STABLE_ABI` resolves on Apple but not under the NDK**, so the platforms ship + structurally different modules — `soxr_ext.abi3.so` against `soxr_ext.cpython-3XX-*.so`. + forge's `fix_wheel` rewrites the tag and accepts both, so this needs no handling; it is + listed because it looks like a defect when diffing two wheels. + +### Re-verification checklist + +- **SIMD engine per slice:** `strings | grep -x cr32s` on every wheel, plus the + on-device test. `cr64s` is expected on the x86_64 slices only. +- **Wheel hygiene:** correct `Machine` per ABI, every Android `LOAD` segment aligned + `0x4000`, `DT_NEEDED` limited to bionic plus `libc++_shared` and `libpython`, iOS + `LC_BUILD_VERSION` platform 2 on device and 7 on the simulators. +- **METADATA:** `Requires-Dist: numpy` present, `flet-libcpp-shared` promoted on Android only. +- **Sizes:** re-measure from the wheels rather than scaling the figures above. + +### Coverage gaps + +The device tests cover a float32 round trip, all four dtypes in two channels, streaming +against one-shot, and the SIMD engine. They do not cover variable-rate mode (`vr=True`, which +upstream marks experimental), `num_clips()` on integer overflow, `delay()`, `clear()`, +`set_io_ratio()`, or any real audio file. `test_simd_engine_compiled_in` skips on 32-bit ARM, +where NEON is a runtime property rather than a build one, so that slice's engine is only ever +checked by inspecting the binary. diff --git a/recipes/soxr/examples/resample-tone/.gitignore b/recipes/soxr/examples/resample-tone/.gitignore new file mode 100644 index 00000000..429a8307 --- /dev/null +++ b/recipes/soxr/examples/resample-tone/.gitignore @@ -0,0 +1,7 @@ +.venv/ +.flet/ +build/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +uv.lock diff --git a/recipes/soxr/examples/resample-tone/README.md b/recipes/soxr/examples/resample-tone/README.md new file mode 100644 index 00000000..321496ab --- /dev/null +++ b/recipes/soxr/examples/resample-tone/README.md @@ -0,0 +1,45 @@ +# soxr resample tone + +Ten seconds of a 440 Hz sine at 48 kHz, generated when the app starts. Tap a target rate and +soxr converts it, reporting how many frames went in and came out, how long the conversion +took, and how far ahead of realtime that is. The footer shows which libsoxr engine each +quality setting selects on this device. + +What it demonstrates: + +- **The conversion every on-device audio pipeline needs.** Recorders hand you 44.1 or + 48 kHz; speech models want 16 kHz. + [`soxr.resample`](https://python-soxr.readthedocs.io/en/stable/soxr.html#soxr.resample) + is the whole of that step — an array in, an array out, no file and no model runtime. +- **How fast that actually is on a phone.** The timing is printed as a realtime multiple, so + the number means something: a value well above 1x is the headroom you have for doing the + conversion inside a live capture loop rather than as a batch step. +- **Which engine you got.** libsoxr compiles several resampling cores and picks one per + quality setting; `cr32s` is the SIMD core. The footer reads it back through + `stream._csoxr.engine()` — a private attribute, used here because it is the only way to + see the choice, and because seeing it is the point. Note `VHQ` selects a *double-precision* + core, which has no ARM SIMD implementation, so on a phone it reads `cr64` and is the slow + option rather than the good one. +- **Compute off the UI thread.** Each conversion runs in + [`page.run_thread(...)`](https://flet.dev/docs/controls/page/#flet.Page.run_thread) with a + spinner up, ending in the explicit + [`page.update()`](https://flet.dev/docs/controls/page/#flet.Page.update) a background + thread needs. soxr releases the GIL while resampling, so this is real parallelism, not + just a responsive handler loop. + +The tone is generated rather than bundled, so the example ships no audio asset. + +## Try it + +[Build](https://flet.dev/docs/publish/) the app, then install it on a device or emulator/simulator: + +```bash +# Android +uv run flet build apk + +# iOS +uv run flet build ipa + +# iOS-Simulator +uv run flet build ios-simulator +``` diff --git a/recipes/soxr/examples/resample-tone/pyproject.toml b/recipes/soxr/examples/resample-tone/pyproject.toml new file mode 100644 index 00000000..dbdc5115 --- /dev/null +++ b/recipes/soxr/examples/resample-tone/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "soxr-resample-tone" +version = "1.0.0" +description = "Resamples a generated tone on a worker thread and reports rate, timing and engine." +requires-python = ">=3.12" + +dependencies = [ + "flet==0.86.5", + "soxr==1.1.0", +] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.app] +path = "src" diff --git a/recipes/soxr/examples/resample-tone/src/main.py b/recipes/soxr/examples/resample-tone/src/main.py new file mode 100644 index 00000000..533f9886 --- /dev/null +++ b/recipes/soxr/examples/resample-tone/src/main.py @@ -0,0 +1,73 @@ +import flet as ft +from resampling import ( + QUALITIES, + SECONDS, + SOURCE_RATE, + TARGET_RATES, + convert, + engine_for, + tone, +) + + +def main(page: ft.Page): + """Wire the rate buttons to a background resample and report the result.""" + source = tone() + + def resample_to(rate): + """Run one conversion off the UI thread, with the spinner up.""" + + def work(): + """Resample, then refill the caption from the worker thread.""" + spinner.visible = True + page.update() + + frames, elapsed = convert(source, rate) + headline.value = f"{SOURCE_RATE:,} Hz → {rate:,} Hz" + detail.value = ( + f"{len(source):,} frames in, {frames:,} out\n" + f"{elapsed * 1e3:.1f} ms for {SECONDS} s of audio " + f"({SECONDS / elapsed:,.0f}x realtime)" + ) + spinner.visible = False + page.update() # auto-update does not reach background threads + + # soxr releases the GIL while resampling, so this genuinely runs in parallel. + page.run_thread(work) + + spinner = ft.ProgressRing(visible=False, width=18, height=18) + headline = ft.Text("Pick a target rate", size=18, weight=ft.FontWeight.BOLD) + detail = ft.Text("") + + page.appbar = ft.AppBar(title=ft.Text("Resample a tone"), center_title=True) + page.add( + ft.SafeArea( + expand=True, + content=ft.Column( + controls=[ + ft.Row( + controls=[ + ft.Button( + f"{rate // 1000}k", + on_click=lambda _, r=rate: resample_to(r), + ) + for rate in TARGET_RATES + ], + wrap=True, + ), + ft.Row(controls=[headline, spinner]), + detail, + ft.Divider(), + ft.Text("libsoxr core per quality setting", size=11), + ft.Text( + " ".join(f"{q}={engine_for(q)}" for q in QUALITIES), + size=11, + font_family="monospace", + ), + ], + ), + ) + ) + + +ft.run(main) diff --git a/recipes/soxr/examples/resample-tone/src/resampling.py b/recipes/soxr/examples/resample-tone/src/resampling.py new file mode 100644 index 00000000..260a06fc --- /dev/null +++ b/recipes/soxr/examples/resample-tone/src/resampling.py @@ -0,0 +1,43 @@ +import time + +import numpy as np + +import soxr + +SOURCE_RATE = 48000 +SECONDS = 10 +TARGET_RATES = [8000, 16000, 22050, 44100] +QUALITIES = ["QQ", "LQ", "MQ", "HQ", "VHQ"] + + +def tone(sample_rate=SOURCE_RATE, seconds=SECONDS, freq=440.0): + """Build a float32 sine of the given length, standing in for recorded audio. + + Generated rather than bundled so the example ships no asset, and float32 because + that is what a capture API hands you and what soxr resamples fastest. + """ + t = np.arange(int(sample_rate * seconds)) / sample_rate + return np.sin(2.0 * np.pi * freq * t).astype(np.float32) + + +def convert(source, target_rate, quality="HQ"): + """Resample `source` to `target_rate`, returning (frames_out, seconds_elapsed). + + Timed here rather than in the UI so the measurement covers only soxr's work. + """ + started = time.perf_counter() + out = soxr.resample(source, SOURCE_RATE, target_rate, quality=quality) + return len(out), time.perf_counter() - started + + +def engine_for(quality): + """Name the libsoxr core a quality setting selects on this device. + + 'cr32s' is the SIMD core; 'cr32' and 'cr64' are scalar. VHQ reads 'cr64' on ARM + because the double-precision core's SIMD variant is AVX-only. Reaching through the + private `_csoxr` is the only way to see the choice, and seeing it is the point. + """ + stream = soxr.ResampleStream( + SOURCE_RATE, 16000, 1, dtype="float32", quality=quality + ) + return stream._csoxr.engine() diff --git a/recipes/soxr/meta.yaml b/recipes/soxr/meta.yaml new file mode 100644 index 00000000..60b4ed67 --- /dev/null +++ b/recipes/soxr/meta.yaml @@ -0,0 +1,58 @@ +package: + name: soxr + version: "1.1.0" + +build: + number: 1 + script_env: +# {% if sdk == 'android' %} + # nanobind_add_module builds a MODULE library, and the NDK links those with + # -Wl,--no-undefined -- so the usual leave-Python-symbols-unresolved convention + # fails and libpython must be named explicitly. scikit-build-core shlex-splits + # CMAKE_ARGS, so the -L/-l pair can't be separate tokens; comma-joining them + # into the one -Wl argument gets them past the split (the linker re-splits). + CMAKE_ARGS: >- + -DCMAKE_TOOLCHAIN_FILE={NDK_ROOT}/build/cmake/android.toolchain.cmake + -DANDROID_ABI={ANDROID_ABI} + -DANDROID_NATIVE_API_LEVEL={ANDROID_API_LEVEL} + -DANDROID_STL=c++_shared + -DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384 + -DCMAKE_MODULE_LINKER_FLAGS=-Wl,-z,max-page-size=16384,-L{HOST_PYTHON_HOME}/lib,-lpython{py_version_short} + -DPython_EXECUTABLE={CROSS_VENV_PYTHON} + -DPython_INCLUDE_DIR={HOST_PYTHON_HOME}/include/python{py_version_short} + -DPython_LIBRARY={HOST_PYTHON_HOME}/lib/libpython{py_version_short}.so +# {% else %} + # The `ninja` pip package crashes importing on the iOS crossenv python + # (sysconfig.get_preferred_scheme('user') -> 'posix_user' invalid on iOS), + # so use the Makefiles generator on iOS; scikit-build-core then never imports + # ninja. (Android keeps Ninja, which works there.) + CMAKE_GENERATOR: Unix Makefiles + # CMAKE_SYSTEM_PROCESSOR is empty on iOS (only the NDK toolchain sets it), and + # the vendored libsoxr's fallback probe knows only __arm__/__i386__/__x86_64__, + # so arm64 falls into FindSIMD32's SSE branch, fails, and silently drops + # cr32s/pffft32s/util32s -- a green wheel with only the scalar resampler. + # `arm64` satisfies FindSIMD32's `^arm` match; Android must not see this flag. + CMAKE_ARGS: >- + -DCMAKE_SYSTEM_NAME=iOS + -DCMAKE_SYSTEM_PROCESSOR={{ arch }} + -DCMAKE_OSX_SYSROOT={{ sdk }} + -DCMAKE_OSX_DEPLOYMENT_TARGET={{ sdk_version }} + -DCMAKE_OSX_ARCHITECTURES={{ arch }} + -DPython_EXECUTABLE={CROSS_VENV_PYTHON} + -DPython_INCLUDE_DIR={HOST_PYTHON_HOME}/include/python{py_version_short} + -DPython_LIBRARY={HOST_PYTHON_HOME}/lib/libpython{py_version_short}.dylib +# {% endif %} + +requirements: + build: + # soxr builds via scikit-build-core + CMake; with forge's `--no-isolation` + # these must live in the build venv. scikit-build-core, nanobind and + # setuptools_scm come from the package's own build-system.requires. + - cmake + - ninja +# {% if sdk == 'android' %} + host: + # soxr_ext is a nanobind C++17 extension; on Android it links libc++_shared.so, + # which the device runtime doesn't provide unless bundled. + - flet-libcpp-shared >=27.2.12479018 +# {% endif %} diff --git a/recipes/soxr/tests/test_soxr.py b/recipes/soxr/tests/test_soxr.py new file mode 100644 index 00000000..2074f40d --- /dev/null +++ b/recipes/soxr/tests/test_soxr.py @@ -0,0 +1,86 @@ +import numpy as np +import pytest + +SR_IN = 48000 +SR_OUT = 16000 + + +def _tone(sample_rate, seconds=0.5, freq=440.0): + """Deterministic mono float32 sine — no RNG, no assets, no network.""" + t = np.arange(int(sample_rate * seconds)) / sample_rate + return np.sin(2.0 * np.pi * freq * t).astype(np.float32) + + +def test_resample_roundtrip(): + """48k->16k->48k reproduces the waveform -> the nanobind extension and the vendored + libsoxr actually resample, not just load.""" + import soxr + + x = _tone(SR_IN) + down = soxr.resample(x, SR_IN, SR_OUT, quality="VHQ") + assert down.shape == (len(x) * SR_OUT // SR_IN,) + assert down.dtype == np.float32 + + up = soxr.resample(down, SR_OUT, SR_IN, quality="VHQ") + assert up.shape == x.shape + edge = SR_IN // 20 # drop the filter transients at both ends + assert np.max(np.abs(up[edge:-edge] - x[edge:-edge])) < 1e-2 + + +def test_multichannel_dtypes(): + """Every supported dtype survives a 2-D (frames, channels) resample -> covers all + four csoxr_divide_proc_* template instantiations.""" + import soxr + + mono = _tone(SR_IN) + for dtype in (np.float32, np.float64, np.int16, np.int32): + x = np.stack([mono, 0.5 * mono], axis=1) + if np.issubdtype(dtype, np.integer): + x = (x * 20000).astype(dtype) + else: + x = x.astype(dtype) + + y = soxr.resample(x, SR_IN, SR_OUT) + assert y.dtype == x.dtype + assert y.shape == (len(mono) * SR_OUT // SR_IN, 2) + + +def test_stream_matches_oneshot(): + """Chunked ResampleStream output equals the one-shot resample -> exercises the + stateful CSoxr object and its final flush.""" + import soxr + + x = _tone(SR_IN) + stream = soxr.ResampleStream(SR_IN, SR_OUT, 1, dtype="float32", quality="HQ") + chunk = 1024 + streamed = np.concatenate( + [ + stream.resample_chunk(x[i : i + chunk], last=i + chunk >= len(x)) + for i in range(0, len(x), chunk) + ] + ) + + oneshot = soxr.resample(x, SR_IN, SR_OUT, quality="HQ") + assert streamed.shape == oneshot.shape + assert np.max(np.abs(streamed - oneshot)) < 1e-6 + + +def test_simd_engine_compiled_in(): + """The wheel carries libsoxr's SIMD resampling engine, not just the scalar fallback. + + libsoxr only compiles cr32s when CMake knows the target CPU, and iOS leaves + CMAKE_SYSTEM_PROCESSOR unset unless the recipe names it -- the build stays green + either way, so only the engine name catches the loss. + """ + import platform + + import soxr + + if platform.machine().lower().startswith("armv"): + # 32-bit ARM asks HWCAP for NEON at runtime, so a "cr32" here would mean + # the device lacks NEON, not that the recipe dropped the engine. + pytest.skip("32-bit ARM picks the engine at runtime, not at build time") + + # HQ keeps precision <= 20, which is the branch that reaches cr32s at all. + stream = soxr.ResampleStream(SR_IN, SR_OUT, 1, dtype="float32", quality="HQ") + assert stream._csoxr.engine() == "cr32s"