Skip to content

fix(worklets): make TFMX playback work on Safari - #108

Merged
indigo423 merged 8 commits into
mainfrom
fix/safari-tfmx-audiocontext-resume
Jul 10, 2026
Merged

fix(worklets): make TFMX playback work on Safari#108
indigo423 merged 8 commits into
mainfrom
fix/safari-tfmx-audiocontext-resume

Conversation

@indigo423

@indigo423 indigo423 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes TFMX playback on Safari/WebKit, where every TFMX track failed silently ("Loading…" forever or "Couldn't play this track") while Chromium and Firefox worked. Confirmed working on real Safari; Chromium + headless WebKit both regression-verified.

It turned out to be three stacked Safari-specific AudioWorklet + emscripten incompatibilities, each hiding the next:

1. Async WASM instantiation hangs in Safari's AudioWorklet

createLibtfmx() (emscripten's in-worklet WebAssembly.instantiate) never resolves inside AudioWorkletGlobalScope on Safari. → Drop -sSINGLE_FILE; the main thread fetches + WebAssembly.compile()s the (now separate) libtfmx.worklet.wasm and hands the WebAssembly.Module to the processor via processorOptions, which instantiates it synchronously through an instantiateWasm hook.

2. The MODULARIZE factory promise never resolves in the worklet

Even after the runtime fully initialises on Safari (onRuntimeInitialized fires), createLibtfmx(...).then(...) never runs. → Key readiness off the onRuntimeInitialized callback instead of the factory promise; the emscripten Module config object is augmented in place, so it is captured as M.

3. TextDecoder.decode() throws on a growable heap

tfx_load's fopen → openat reads the path string via TextDecoder.decode() on a view of the WASM heap — Safari throws (decode@[native code]) when that view is over a growable/resizable ArrayBuffer (from -sALLOW_MEMORY_GROWTH). emscripten 6.x removed TEXTDECODER=0, so the fix is on the memory side: drop ALLOW_MEMORY_GROWTH, use a fixed -sINITIAL_MEMORY=64MB (far above the >16 MB large TFMX sample banks need).

Also included (genuine hardening surfaced during the hunt)

  • context.resume() on every play() (Safari re-suspends the context across the engine switch).
  • onprocessorerror handler on the tfmx node (was missing — silent processor crashes now surface).
  • _play() try/catch → surfaces a load-path throw as a proper err instead of a silent processorerror.
  • Worklet init-error forwarding + getBuffer fetch-failure logging (previously silent).

Verification

  • Real Safari: plays (user-confirmed).
  • Chromium + headless WebKit: position advances, no error; headless WebKit reproduced each failure mode during the hunt and now reaches "meta received".
  • make verify green (lint + typecheck + 252 tests + audit + build).

Files

scripts/build-tfmx-wasm.sh (build flags) · public/libtfmx.worklet.js + public/libtfmx.worklet.wasm (rebuilt, un-embedded) · public/tfmx.worklet.js (main-thread-module init) · lib/audio-player.ts (compile + pass module, resume, processorerror) · components/sources/index.ts (fetch logging) · vendor/.../VENDORING.md.

Refs #103

CANDIDATE FIX — needs verification on real Safari before merge.

TFMX playback stalls silently on Safari/WebKit: the track's metadata
loads (title/duration, posted by the worklet's play handler on the main
thread) but process() is never clocked, so audio never renders and the
position stays at 0:00 ("Loading..."). Chromium and Firefox/Floorp play
the identical file+code correctly — verified by downloading the exact
failing file from prod and driving it headless (Chromium advances
0:01->0:05; WebKit freezes).

Root cause (hypothesis): the AudioContext is resumed only once, at
prewarm time (audio-prewarm.js). Safari re-suspends it aggressively —
notably across the cross-engine libopenmpt->TFMX worklet switch — and
nothing re-resumes it, so the TFMX worklet's process() sits idle.

Fix: re-assert context.resume() (a) at the top of play(), in the
user-gesture stack Safari requires, and (b) just before the TFMX worklet
starts rendering, to catch a re-suspend during ensureTfmx()'s async gap.
Both are guarded on state === "suspended", so they are no-ops on
Chromium/Firefox (regression-checked: Chromium TFMX still plays).

Not merging until confirmed on real Safari — the headless WebKit harness
can't validate audio (no audio sink).

Refs #103

Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Ronny Trommer <ronny@no42.org>
On WebKit/Safari the TFMX worklet's render loop can throw a WASM trap
(e.g. a HEAP view detaching after memory growth) that dies as a SILENT,
unhandled AudioWorklet processorerror — no console output anywhere, the
player just freezes then reports "Couldn't play". This wraps process()
in try/catch and posts the real error (once) to the main thread, so the
failure is diagnosable and the facade can surface a proper error.

Part of the combined Safari TFMX fix (with the AudioContext resume in
this branch). Still needs real-Safari verification.

Refs #103

Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Ronny Trommer <ronny@no42.org>
On Safari a TFMX track fails instantly with a bare "Couldn't play this
track" and an empty console: getBuffer's two /api/library/file fetches
reject (Safari's opaque "TypeError: Load failed") and the error bubbles
straight to the onError handler with nothing logged. Wrap the pair fetch
so the actual error + the two URLs are surfaced, to pinpoint whether the
fetch itself, a status code, or the encoded path is the culprit.

Diagnostic step for the combined Safari TFMX fix.

Refs #103

Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Ronny Trommer <ronny@no42.org>
ROOT CAUSE of TFMX playback failing on Safari/WebKit: emscripten's
default async WASM compilation (WebAssembly.instantiate) never resolves
inside Safari's AudioWorkletGlobalScope, so createLibtfmx() hangs — the
processor node is created but M is never set, _play() defers forever, and
the user gets a silent "Couldn't play this track" (metadata never loads,
console empty). Chromium and Firefox resolve the async path fine.

Proven via a window.__tfmxDebug trace read from headless WebKit: the trace
stops at "tfmx node created" and never reaches "worklet meta received"
(nor an init error), i.e. the WASM factory neither resolves nor rejects.

Fix: build libtfmx.worklet.js with -sWASM_ASYNC_COMPILATION=0 (synchronous
instantiation). The factory resolves immediately; the one-time compile of
the ~270 KB module on the audio thread is negligible. Chromium regression-
checked (still decodes).

Also hardens the silent-failure paths that hid this for so long:
- worklet: forward the real createLibtfmx() init error in the err `detail`
  (was posted detail-less, so a WASM init failure logged nothing);
- audio-player: add the missing AudioWorkletNode onprocessorerror handler;
  surface tfmx init/worklet errors via a debug trace.

Part of the combined Safari TFMX fix (with context.resume() on play).
NOTE: still contains temporary window.__tfmxDebug tracing to be removed
once verified on real Safari. Refs #103

Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Ronny Trommer <ronny@no42.org>
The architectural fix for TFMX playback hanging on Safari. Root cause:
emscripten's in-worklet async WASM instantiation (createLibtfmx() inside
the AudioWorkletProcessor) never resolves in Safari's
AudioWorkletGlobalScope — the processor loads but M is never set, _play()
defers forever, and the user gets a silent "Couldn't play" / stuck
"Loading". Chromium and Firefox resolve it fine; build-flag tweaks
(WASM_ASYNC_COMPILATION=0, ENVIRONMENT=web) did not help.

Fix — the portable AudioWorklet+WASM pattern:
- Build: drop -sSINGLE_FILE, so the wasm is a separate libtfmx.worklet.wasm
  the main thread can fetch (the worklet no longer needs fetch()).
- Main thread (ensureTfmx): fetch + WebAssembly.compile() the wasm, hand the
  compiled WebAssembly.Module to the processor via processorOptions
  (structured-cloned, works on Safari).
- Worklet: instantiate synchronously from that module via emscripten's
  instantiateWasm hook — no in-worklet async instantiation at all.

Chromium regression-verified end-to-end (plays, position advances, "worklet
meta received"). Needs confirmation on real Safari — headless WebKit can't
validate (no audio device → suspended context masks the result).

Combined Safari TFMX fix (with context.resume() + error surfacing). Still
carries temporary window.__tfmxDebug tracing, to strip once confirmed.
Refs #103

Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Ronny Trommer <ronny@no42.org>
The last piece of the Safari TFMX fix. With the WASM now compiled on the
main thread and instantiated synchronously in the worklet, the emscripten
runtime fully initialises on Safari (onRuntimeInitialized fires) — but the
MODULARIZE factory PROMISE still never resolves inside an AudioWorklet on
Safari, so `createLibtfmx(...).then(mod => M = mod)` hung forever and _play
deferred indefinitely (silent "Couldn't play"/"Loading").

Fix: don't await the factory promise. The Emscripten Module config object is
augmented in place and becomes the Module, so capture it and set M from the
onRuntimeInitialized callback (which DOES fire on Safari); onAbort covers the
failure path. Chromium regression-verified end-to-end (plays; trace reaches
"worklet meta received").

Refs #103

Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Ronny Trommer <ronny@no42.org>
…x_load

The final Safari-specific crash. With the runtime now initialising and
_play running on Safari, tfx_load's fopen → ___syscall_openat → string
read hit `TextDecoder.decode()` on a view of the WASM heap and threw
("decode@[native code]") — a known Safari bug where TextDecoder.decode
rejects views over GROWABLE/resizable ArrayBuffers (which
ALLOW_MEMORY_GROWTH produces). Chromium/Firefox accept them.

Emscripten 6.x removed TEXTDECODER=0 (pure-JS decoder), so the fix is on
the memory side: drop -sALLOW_MEMORY_GROWTH and use a fixed
-sINITIAL_MEMORY=64MB (far above the >16 MB large TFMX sample banks need).
The heap is then a plain ArrayBuffer that TextDecoder.decode accepts.

Verified in headless WebKit (which — now that createLibtfmx resolves —
reproduces the real-Safari path): tfx_load returns 1 and "worklet meta
received" fires with no throw. Chromium regression-clean.

Completes the Safari TFMX fix chain (main-thread compile +
onRuntimeInitialized readiness + fixed heap). Refs #103

Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Ronny Trommer <ronny@no42.org>
Remove the temporary window.__tfmxDebug tracing, the build/lifecycle
markers, and the `dbg` message channel used to root-cause the Safari
failure. Keeps the genuine hardening that fell out of it:
- onprocessorerror handler on the tfmx node (was missing);
- _play() try/catch that surfaces an unexpected load-path throw as an
  err instead of a silent processorerror;
- worklet init-error forwarding (onAbort → err detail).

Also: normalise the committed .wasm file mode (drop the exec bit) and
update build-tfmx-wasm.sh / VENDORING.md to describe the two-file output
(separate .wasm, main-thread compile, fixed INITIAL_MEMORY) instead of
the old single-file/embedded build.

Verified: `make verify` green; Chromium + WebKit both play (position
advances, no error); confirmed by hand on real Safari.
Refs #103

Assisted-by: ClaudeCode:claude-opus-4-8
Signed-off-by: Ronny Trommer <ronny@no42.org>
@indigo423 indigo423 changed the title fix(player): resume AudioContext on play to unstick TFMX on Safari [needs Safari verification] fix(worklets): make TFMX playback work on Safari Jul 10, 2026
@indigo423
indigo423 marked this pull request as ready for review July 10, 2026 21:13
@indigo423
indigo423 merged commit c44f7a3 into main Jul 10, 2026
3 checks passed
@indigo423
indigo423 deleted the fix/safari-tfmx-audiocontext-resume branch July 10, 2026 21:18
indigo423 added a commit that referenced this pull request Jul 10, 2026
Patch release: TFMX playback now works on Safari/WebKit (#108).

Assisted-by: ClaudeCode:claude-opus-4-8

Signed-off-by: Ronny Trommer <ronny@no42.org>
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