fix(worklets): make TFMX playback work on Safari - #108
Merged
Conversation
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
marked this pull request as ready for review
July 10, 2026 21:13
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-workletWebAssembly.instantiate) never resolves insideAudioWorkletGlobalScopeon Safari. → Drop-sSINGLE_FILE; the main thread fetches +WebAssembly.compile()s the (now separate)libtfmx.worklet.wasmand hands theWebAssembly.Moduleto the processor viaprocessorOptions, which instantiates it synchronously through aninstantiateWasmhook.2. The MODULARIZE factory promise never resolves in the worklet
Even after the runtime fully initialises on Safari (
onRuntimeInitializedfires),createLibtfmx(...).then(...)never runs. → Key readiness off theonRuntimeInitializedcallback instead of the factory promise; the emscripten Module config object is augmented in place, so it is captured asM.3.
TextDecoder.decode()throws on a growable heaptfx_load'sfopen → openatreads the path string viaTextDecoder.decode()on a view of the WASM heap — Safari throws (decode@[native code]) when that view is over a growable/resizableArrayBuffer(from-sALLOW_MEMORY_GROWTH). emscripten 6.x removedTEXTDECODER=0, so the fix is on the memory side: dropALLOW_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 everyplay()(Safari re-suspends the context across the engine switch).onprocessorerrorhandler on the tfmx node (was missing — silent processor crashes now surface)._play()try/catch → surfaces a load-path throw as a propererrinstead of a silentprocessorerror.getBufferfetch-failure logging (previously silent).Verification
make verifygreen (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