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
16 changes: 14 additions & 2 deletions .claude/skills/forge-ci/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fork> --ref <branch> -f packages="<recipe>:"`
— 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" <nanobind>/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.
Expand Down Expand Up @@ -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 <so> | 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
Expand Down
199 changes: 199 additions & 0 deletions recipes/soxr/README.md
Original file line number Diff line number Diff line change
@@ -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 <so> | 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.
7 changes: 7 additions & 0 deletions recipes/soxr/examples/resample-tone/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.venv/
.flet/
build/
__pycache__/
.pytest_cache/
.ruff_cache/
uv.lock
45 changes: 45 additions & 0 deletions recipes/soxr/examples/resample-tone/README.md
Original file line number Diff line number Diff line change
@@ -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
```
16 changes: 16 additions & 0 deletions recipes/soxr/examples/resample-tone/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Loading