Skip to content

recipe: soxr 1.1.0 - #115

Merged
ndonkoHenri merged 3 commits into
mainfrom
soxr
Aug 21, 2026
Merged

recipe: soxr 1.1.0#115
ndonkoHenri merged 3 commits into
mainfrom
soxr

Conversation

@ndonkoHenri

Copy link
Copy Markdown

Adds a recipe for soxr 1.1.0 — the SoX Resampler (libsoxr) behind a Python API: one-dimensional sample-rate conversion, mono or multi-channel, float32/float64/int16/int32, one-shot or streaming. Requested in flet#6774.

Resampling is the step almost every on-device audio pipeline needs and almost no model provides — recorders hand you 44.1/48 kHz, speech models want 16 kHz — and soxr does it in a few hundred KB with numpy as its only dependency.

Recipe shape

scikit-build-core + CMake over a self-contained sdist that vendors libsoxr — the duckdb/rapidfuzz archetype, zero patches. Upstream is unusually well-behaved for a cross build: it already guards nanobind's stub generation behind NOT CMAKE_CROSSCOMPILING, ships vr-coefs.h pre-generated so no host code generator runs, uses no try_run anywhere, and turns OpenMP, the LSR bindings and shared libraries off before add_subdirectory(libsoxr).

First nanobind recipe in the repo. It needs exactly two non-obvious settings, both commented in place:

  • -DCMAKE_SYSTEM_PROCESSOR={{ arch }}, iOS lane only. libsoxr's SetSystemProcessor.cmake probes __x86_64__/__i386__/__arm__; iOS arm64 defines __aarch64__ and matches none, so CMAKE_SYSTEM_PROCESSOR stays empty, FindSIMD32 falls through to its SSE branch, fails, and cr32s/pffft32s/util32s silently vanish from the archive. The build stays green and a naive import-and-resample test still passes — you just get the scalar resampler. Forcing the scalar engine on an otherwise identical build (SOXR_USE_SIMD32=0, 10 s mono 48k→16k at HQ, macOS arm64) measured 2.8x: 0.70 → 1.98 ms. The NDK presets the variable so Android must not get this flag; iOS-sim x86_64 self-detects. test_simd_engine_compiled_in asserts engine() == "cr32s" on device so a bump can't lose it quietly.
  • libpython named explicitly in CMAKE_MODULE_LINKER_FLAGS, Android lane. 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 with a wall of undefined symbol: PyExc_…. scikit-build-core shlex-splits CMAKE_ARGS, so the -L/-l pair is comma-joined into the single -Wl argument.

STABLE_ABI resolves on Apple but not under the NDK, so the platforms ship structurally different modules (soxr_ext.abi3.so vs soxr_ext.cpython-3XX-*.so). forge's fix_wheel rewrites the tag and accepts both names — no handling needed, noted only because it is surprising when diffing two wheels.

Adds a recipe for [soxr](https://github.com/dofuuz/python-soxr) 1.1.0 — the SoX Resampler
([libsoxr](https://sourceforge.net/projects/soxr/)) bound to Python: one-dimensional
sample-rate conversion, mono or multi-channel, `float32`/`float64`/`int16`/`int32`, one-shot
or streaming. Requested in [flet#6774](flet-dev/flet#6774).

- [Docs](recipes/soxr/README.md)
- [Example](recipes/soxr/examples/resample-tone)

Resampling is the step almost every on-device audio pipeline needs and almost no model
provides — recorders hand you 44.1/48 kHz, speech models want 16 kHz — and soxr does it in
a few hundred KB with numpy as its only dependency.

## Recipe shape

scikit-build-core + CMake over a self-contained sdist that vendors libsoxr — the
`duckdb`/`rapidfuzz` archetype, **zero patches**. Upstream is unusually well-behaved for a
cross build: it already guards nanobind's stub generation behind `NOT CMAKE_CROSSCOMPILING`,
ships `vr-coefs.h` pre-generated so no host code generator runs, uses no `try_run` anywhere,
and turns OpenMP, the LSR bindings and shared libraries off before `add_subdirectory(libsoxr)`.

First **nanobind** recipe in the repo. It needs exactly two non-obvious settings, both
commented in place:

- **`-DCMAKE_SYSTEM_PROCESSOR={{ arch }}`, iOS lane only.** libsoxr's
  `SetSystemProcessor.cmake` probes `__x86_64__`/`__i386__`/`__arm__`; iOS arm64 defines
  `__aarch64__` and matches none, so `CMAKE_SYSTEM_PROCESSOR` stays empty, `FindSIMD32` falls
  through to its SSE branch, fails, and `cr32s`/`pffft32s`/`util32s` silently vanish from the
  archive. **The build stays green and a naive import-and-resample test still passes** — you
  just get the scalar resampler. Forcing the scalar engine on an otherwise identical build
  (`SOXR_USE_SIMD32=0`, 10 s mono 48k→16k at `HQ`, macOS arm64) measured 2.8x: 0.70 → 1.98 ms.
  The NDK presets the variable so Android must not get this flag; iOS-sim x86_64 self-detects.
  `test_simd_engine_compiled_in` asserts `engine() == "cr32s"` on device so a bump can't lose
  it quietly.
- **libpython named explicitly in `CMAKE_MODULE_LINKER_FLAGS`, Android lane.**
  `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 with a
  wall of `undefined symbol: PyExc_…`. scikit-build-core shlex-splits `CMAKE_ARGS`, so the
  `-L`/`-l` pair is comma-joined into the single `-Wl` argument.

`STABLE_ABI` resolves on Apple but not under the NDK, so the platforms ship structurally
different modules (`soxr_ext.abi3.so` vs `soxr_ext.cpython-312.so`). forge's `fix_wheel`
rewrites the tag and accepts both names — no handling needed, noted only because it is
surprising when diffing two wheels.

## Verification

All six slices built locally (NDK r27d-era 27.0, iOS SDK 26.2, Python 3.12). Every wheel
carries `cr32s`; the x86_64 slices additionally carry `cr64s` (libsoxr's AVX engine — there
is no ARM equivalent, so its absence on ARM is expected). Correct `Machine` per ABI, every
Android `LOAD` segment aligned `0x4000`, `DT_NEEDED` limited to bionic + `libc++_shared` +
`libpython3.12`, iOS `LC_BUILD_VERSION` platform 2 on device and 7 on the simulators,
`Requires-Dist: numpy` with `flet-libcpp-shared` promoted on Android only, 10 entries per
wheel with no stray install artifacts.

CI green across the full matrix: 18/18 wheels, 3.12/3.13/3.14 x 6 slices.

On-device the suite was run on **every** Python rather than only the 3.12 default
(`mobile_test_pythons=ALL`): 4 collected / 4 passed / EXIT 0 on all six legs — 3.12, 3.13
and 3.14 x android + ios — with `test_simd_engine_compiled_in` PASSED (not skipped) on each,
so the SIMD engine is confirmed present and selected, not merely compiled. Run also verified
locally on an arm64 emulator and arm64 simulator; CI's devices are x86_64, so between the two
every arch that ships is covered on-device.

## Consumer notes (usage & recommendations)

`soxr` converts audio sample rates — `soxr.resample(x, 48000, 16000)` for an array you hold,
`soxr.ResampleStream(...).resample_chunk(chunk, last=...)` for a microphone feed or a file
too big to hold at once. Input is mono `(frames,)` or `(frames, channels)`; the output dtype
always matches the input.

### Install

```toml
[project]
dependencies = [
  "flet",
  "soxr",
]
```

`numpy` comes with it, and is the only dependency. Wheels cover all Android ABIs and iOS
slices; no `target_arch` restriction and no `source_packages` opt-in.

### Things worth knowing

- **`HQ` (the default) is the right setting on a phone.** `QQ`/`LQ`/`MQ`/`HQ` all run on the
  SIMD engine; `VHQ` crosses into libsoxr's *double-precision* engine, whose SIMD variant is
  AVX-only — so on both ARM slices `VHQ` falls back to scalar `cr64` and is the slow option
  rather than the good one.
- **Set `last=True` exactly once**, on the final chunk, or the filter tail never comes out.
  Chunked output then matches a single whole-signal `resample` to within 1e-6 (asserted).
- **`resample_chunk` type-checks exactly** — `type(x) != np.ndarray`, so a numpy *subclass* is
  rejected even with the right dtype. Pass `np.asarray(x)`.
- **It releases the GIL**, so `page.run_thread(...)` buys real parallelism. A `ResampleStream`
  carries filter state, so don't drive one from two threads.
- **soxr does not decode or encode audio files** — it resamples arrays. `.wav` is the stdlib
  `wave` module; anything else needs its own package.
- **Licensing:** soxr is **LGPL-2.1-or-later** and this wheel links libsoxr statically (the
  licence texts ship in the wheel's `dist-info/licenses/`). Not a first for the repo —
  `pymupdf` (AGPL), `pymssql`, `psycopg2` and `opaque` are already copyleft — but worth a
  deliberate decision for closed-source store apps, and the repo has no stated policy on it.
  Flagging, not advising.
- **iOS green-but-degraded wheels.** New entry for the inverse of the existing
  hardcoded-`CMAKE_SYSTEM_PROCESSOR` case: leaving it *unset* on the iOS lane makes a
  vendored library's own CPU probe fall through to its x86 branch and silently drop its
  SIMD sources. Nothing fails — the wheel just gets slower (measured 2.8x on libsoxr).
  Records the tell (the absent `-- Found SIMD32:` line), the artifact check, and the
  general defence: assert the fast path is compiled in, not merely that the module imports.
  Also warns that the right processor token is library-specific — libsoxr accepts
  `arm64`, opencv needs `aarch64` — so neither recipe should be copied blind.

- **`--no-undefined` is not always upstream's doing.** Extends the existing entry: the NDK
  adds the flag itself, so any CMake `MODULE` target hits it on Android with no upstream
  involvement, which makes every nanobind recipe a candidate. Notes that
  `CMAKE_SHARED_LINKER_FLAGS` is the wrong knob for a MODULE target and how to check.

Separable from the recipe commit — drop it if you'd rather keep skills out of a recipe PR.

[skip ci] — touches no recipe, so a CI run here would only smoke-test and cancel the live soxr build.
Review feedback: the block before `### Storage` was partly upstream API reference, which
belongs behind the link it already carries rather than in a recipe README somebody has to
keep in sync.

Applied test: **does being on a phone, in a Flet app, change the answer?**

- Dropped the frames-first / mono-vs-multichannel paragraph. True identically on a desktop,
  and python-soxr's own docs say it.
- Dropped the quality-value enumeration, kept the half that is mobile-specific: `HQ` is the
  highest setting still on the SIMD engine, and `VHQ` drops to a scalar core on ARM.

And fixed the actual defect the review exposed: a section titled "Usage in a Flet app"
contained no Flet. The lead now lands the result in a control via `page.run_thread`, which
is what README.rst asks the section to open with. `### Threading` keeps the reasoning (GIL
released, `ResampleStream` not shareable, `run_thread` pools) and no longer repeats the
snippet.

Net two lines longer, materially more Flet-specific, 199 lines.

Also verified while here, which had been claimed but not tested: the example builds and
runs as-is. `flet build ios-simulator` produces a working app that resamples on device —
480,000 frames @48k -> 160,000 @16k in 6.7 ms (1,490x realtime), with `soxr_ext.fwork` and
numpy 2.4.6 confirmed inside the bundle. Its footer reads
`QQ=cr32s LQ=cr32s MQ=cr32s HQ=cr32s VHQ=cr64` computed on the device, which independently
confirms the SIMD claim this README makes.

[skip ci] — docs only; recipe READMEs are in the changed-files ignore list, so a run here
would only smoke-test.
@ndonkoHenri
ndonkoHenri merged commit 39207ab into main Aug 21, 2026
1 check passed
@ndonkoHenri
ndonkoHenri deleted the soxr branch August 21, 2026 21:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant