Skip to content

recipes: soundfile 0.14.0 + libsndfile and its codec chain - #121

Merged
ndonkoHenri merged 7 commits into
mainfrom
soundfile
Sep 15, 2026
Merged

ndonkoHenri merged 7 commits into
mainfrom
soundfile

Conversation

@ndonkoHenri

Copy link
Copy Markdown

Adds soundfile — the library nearly every scientific-Python audio pipeline opens with — plus the seven native recipes it needs. Answers flet-dev/flet#6834.

Why it needs a recipe at all

soundfile ships no compiled code: soundfile_build.py calls ffibuilder.set_source("_soundfile", None), so it is cffi ABI mode — a pure-Python wrapper that dlopens libsndfile. It still cannot work unpatched, because its loader branches on sys.platform over darwin/win32/linux and both fallbacks end in a bare raise. Confirmed against PyPI's own wheel on an emulator before touching anything:

OSError: no packaged library for this platform
OSError: sndfile library not found using ctypes.util.find_library

What's here

Eight recipes. flet-libogg 1.3.6, flet-libvorbis 1.3.7, flet-libflac 1.5.0, flet-libopus 1.5.2, flet-libmpg123 1.33.7 and flet-libmp3lame 3.100 are built as static PIC archives and declared requirements.host_build of flet-libsndfile 1.2.2, which absorbs them into a single shared library. They are deliberately not requirements.host: nothing of them is loaded at runtime, so promoting them would make every consuming app download six wheels whose contents already live inside libsndfile.so. Only that one library ships — 1.9–2.7 MB per Android ABI, 2.9–3.2 MB per iOS slice — and soundfile itself is a 21 KB pure-Python wheel.

The result is that FLAC, Ogg Vorbis, Opus and MP3 all work, so sf.available_formats() on a phone returns the same list as on a desktop.

The loader patch is the interesting part. ffi.dlopen is a raw dlopen(3) and, unlike ctypes.CDLL, cannot dereference the .fwork text pointer serious-python leaves when it relocates a library into an iOS framework — that dereference lives only in iOS CPython's patched ctypes/__init__.py. So patches/mobile.patch wraps ctypes.util.find_library, lets ctypes resolve the name, and hands cffi the absolute path ctypes settled on (CDLL(candidate)._name), trying the bare soname (Android jniLibs), opt/lib/libsndfile.fwork (iOS) and opt/lib/libsndfile.so in turn. Everything downstream of find_library is upstream's, untouched.

Three smaller decisions worth a reviewer's eye:

  • flet-libflac/patches/android-api-level.patch — FLAC reads the Android API level from CMAKE_SYSTEM_VERSION, which the NDK toolchain pins to 1. It therefore disables fseeko at every API level, and compat.h's #define fseeko fseek then collides with bionic's 64-bit-off_t declaration on armeabi-v7a only. Passing -DCMAKE_SYSTEM_VERSION does not help — the toolchain shadows it with a non-cache set().
  • flet-libsndfile/patches/security-backports.patch — 1.2.2 (August 2023) is still the only libsndfile release, and ~80 commits have landed since. Five heap-overflow and over-read fixes are cherry-picked from master, all clean. A mobile app hands libsndfile whatever file a user picked, so these are reachable.
  • iOS symbol hygiene — the shared image is hand-linked from the static build (CMake emits a versioned dylib triplet, which serious-python's first-dot framework naming collapses into one name), with -Wl,-exported_symbol,'_sf_*' so the absorbed codec symbols stay hidden, matching what libsndfile's own version script already does on Android. 41 exported symbols per iOS slice.

One platform limitation, documented not worked around

sf.read(io.BytesIO(...)) raises MemoryError on iOS. Virtual I/O hands libsndfile a ffi.callback(); cffi writes that trampoline at runtime and iOS refuses write+execute pages without the JIT entitlement. Only the file-object form is affected — paths and integer file descriptors (sf_open_fd) use no callbacks and pass. The README leads its reading section with "a real path is the only form that works everywhere" and gives the write-a-file replacement, and the tests assert the MemoryError on iOS rather than skipping, so a future cffi or OS change that lifts it does not go unnoticed.

Validation

CI run 349056776606/6 jobs green, both platforms × Python 3.12 / 3.13 / 3.14, with on-device tests on the 3.12 and 3.14 legs. Each of the four test legs: 17 passed, 1 skipped, EXIT 0 (the skip is the other platform's arm of the virtual-I/O split). 18 cases from 9 test functions, one parametrized across ten containers.

Wheel hygiene checked per slice: correct Machine per ABI, SONAME exactly libsndfile.so, DT_NEEDED limited to libc/libm/libdl, every Android LOAD segment aligned 0x4000; iOS filetype DYLIB, otool -L showing only libSystem, LC_BUILD_VERSION platform 2 on device and 7 on the simulators. soundfile's METADATA promotes flet-libsndfile (==1.2.2) and none of the six codec libraries.

Locally, the consumer example round-trips all seven containers on an Android emulator. The iOS half was never runnable on this machine (Xcode has no iOS platform component installed), so iOS rests on the CI run above.

Consumer notes

soundfile reads and writes audio files as numpy arrays and is the front door for librosa-, wfdb- and pywavelets-shaped pipelines. With this build a Flet app gets WAV, AIFF, AU, CAF/ALAC, W64, RF64, FLAC, Ogg Vorbis, Opus and MP3 on both platforms, from a single dependencies = ["soundfile"].

Details, the storage and threading guidance, the iOS file-object limitation and the traps worth knowing are in recipes/soundfile/README.md, with a runnable app in recipes/soundfile/examples/codec-roundtrip.

Also in this branch

.claude/skills/ picks up the three findings: the cffi-ABI-mode recipe shape in new-mobile-recipe, the cffi/.fwork and CMAKE_SYSTEM_VERSION entries in forge-error-catalogue, and a correction in local-recipe-testingflet build 0.86.5 bundles Python 3.14, and for a pure-Python recipe a cp312-only build produces no error at all, it just silently loses to PyPI's unpatched wheel.

soundfile is a pure-Python cffi wrapper over libsndfile, so the recipe is
Pattern H: a shared flet-libsndfile that the wrapper dlopens, plus a loader
patch. Upstream's loader branches on sys.platform over darwin/win32/linux only
and both fallbacks end in a bare `raise`, so `import soundfile` fails outright
on android and ios -- confirmed on an emulator against the PyPI wheel.

The patch wraps ctypes.util.find_library rather than rewriting the loader.
cffi's ffi.dlopen is a raw dlopen(3) and, unlike ctypes.CDLL, cannot dereference
the .fwork text pointer serious-python leaves when it relocates a library into
an iOS framework (that dereference lives in iOS CPython's patched ctypes). So
the shim lets ctypes resolve the name and hands cffi the path ctypes settled on,
trying the bare soname (Android jniLibs), opt/lib/libsndfile.fwork (iOS) and
opt/lib/libsndfile.so in turn. Everything downstream of find_library is
upstream's, unchanged.

flet-libsndfile is built with FLAC, Ogg Vorbis, Opus and MP3 compiled in, so the
container list on a phone matches a desktop's. The six codec libraries are
separate recipes built as static PIC archives and declared host_build, not host:
they are absorbed into the one shared library, so promoting them would make
every consuming app download six wheels whose contents it already has. iOS
hand-links the shared image from the static build (CMake would emit a versioned
dylib triplet, which serious-python's first-dot framework naming cannot
represent) and restricts the export list to sf_*, matching what libsndfile's own
version script already does on Android.

Three fixes upstream would want back:

- flet-libflac/patches/android-api-level.patch -- FLAC reads the Android API
  level from CMAKE_SYSTEM_VERSION, which the NDK toolchain pins to 1, so it
  disables fseeko at every level and armeabi-v7a then fails to compile against
  bionic's 64-bit-off_t declaration.
- flet-libsndfile/patches/security-backports.patch -- 1.2.2 is three years old
  and still the only release; five heap-overflow and over-read fixes are
  cherry-picked from master. A mobile app hands libsndfile whatever file a user
  picked, so these are reachable.
- FLAC's ENABLE_MULTITHREADING is off: it puts Threads::Threads in libFLAC's
  exported CMake target, which libsndfile cannot resolve.

Full matrix green on both platforms for 3.12 and 3.14, and the consumer example
round-trips all seven containers on an Android emulator.
…t's 3.14 default [skip ci]

Three findings from the soundfile chain, each one a wasted cycle if unrecorded.

new-mobile-recipe gains a cffi-ABI-mode row in the shape table and a deep-dive:
a package whose wrapper is `ffi.dlopen` rather than `ctypes.CDLL` still needs a
recipe even though nothing compiles, and its loader patch has to resolve the
iOS .fwork through ctypes because cffi's dlopen cannot. The deep-dive also
records the static-PIC-plus-host_build arrangement for a library with optional
codecs, and the -headerpad flag a hand-linked iOS image needs and CMake adds
for free.

forge-error-catalogue gains the matching runtime entry and a build-time one:
a CMake project reading the Android API level from CMAKE_SYSTEM_VERSION gets 1
under the NDK toolchain file, which silently disables fseeko and breaks only
the 32-bit slices.

local-recipe-testing's "match flet's python" gotcha was written when flet
bundled 3.12; 0.86.5 bundles 3.14, and for a pure-Python recipe the mismatch
produces no error at all -- pip just installs PyPI's unpatched wheel.
…lame mirror [skip ci]

CI run 34892127701 surfaced two things.

soundfile's file-object path is permanently unavailable on iOS. Virtual I/O hands
libsndfile a `ffi.callback()`, cffi writes that trampoline at runtime, and iOS
refuses write+execute pages to an app without the JIT entitlement:

    MemoryError: Cannot allocate write+execute memory for ffi.callback().

Only the file-object form is affected — paths and integer file descriptors
(`sf_open_fd`) use no callbacks and were fine in the same run, 15 of 16 tests
passing on the simulator and 16 of 16 on the emulator. So it is documented rather
than worked around: the README leads the reading section with "a real path is the
only form that works everywhere" and gives the write-a-file replacement, and the
tests assert the MemoryError on iOS instead of skipping, so a future cffi or OS
change that lifts it does not go unnoticed. The example app wrote every container
through io.BytesIO and would have failed outright on iOS; it now writes real files
under FLET_APP_STORAGE_TEMP.

flet-libmp3lame moves off downloads.sourceforge.net, which bounces through a
randomly chosen mirror and timed out its TLS handshake on two of six legs. Debian's
pool serves the same 1524133 bytes, sha256 ddfe36ca…1da1e.
…kip ci]

encode_all() took no arguments and closed over nothing its inner work() did not
already close over, so the nesting bought a level of indentation and nothing
else. Flatten it: the body becomes encode_all itself, and the two call sites say
page.run_thread(encode_all) — which also reads more honestly than a bare call,
since "this goes to a worker thread" is the thing a reader needs to know here.

Nesting of this shape is worth keeping only when the outer function takes a
parameter the inner one closes over, the way soxr's resample_to(rate) does.

Verified on the emulator: first sweep on load, then a tap on Re-encode re-runs
it with no output on console.log.
@ndonkoHenri
ndonkoHenri merged commit 1374ae3 into main Sep 15, 2026
16 of 28 checks passed
@ndonkoHenri
ndonkoHenri deleted the soundfile branch September 15, 2026 01:01
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