Skip to content

Quantized save: defer wasm + song swap to next beat divisible by N#128

Draft
petersalomonsen wants to merge 18 commits into
masterfrom
quantized-save
Draft

Quantized save: defer wasm + song swap to next beat divisible by N#128
petersalomonsen wants to merge 18 commits into
masterfrom
quantized-save

Conversation

@petersalomonsen

@petersalomonsen petersalomonsen commented May 15, 2026

Copy link
Copy Markdown
Owner

Status: not merging

Final shape works but isn't fully suited for live concerts — the audible glitch from WebAssembly.instantiate blocking the audio thread can't be hidden, only relocated. Likely follow-up is a multi-window app where another window with its own AudioContext signals takeover.

Summary

  • Adds a dropdown next to the save button (Now / 1 / 2 / 3 / 4 / 6 / 8 / 12 / 16 / 32 beats). Default = Now.
  • Now (N=0) preserves the existing suspend/instantiate/resume behavior — short audible pause, guaranteed live before save returns.
  • N>0 stashes the new wasm bytes (plus any new sequencedata / toggleSongPlay) in a pending slot on the worklet processor. At the next beat where currentBeat % N == 0, the worklet calls WebAssembly.instantiate(bytes, …) from inside tryQuantizedSwap via .then, and once the instance is ready the next process() tick applies the atomic swap.
  • Bundled save: wasm + sequencedata + toggleSongPlay all land in a single port message so a beat boundary can't fire a half-applied swap (new synth, old song).
  • Beat math uses playingBpm (whatever's currently audible), not pendingBpm — so swapping into a song with a different BPM still lands on the correct audible beat.
  • On swap: allNotesOff() on the outgoing instance, sequencer currentFrame reset to 0 so the new song plays from its own beat 0, then the receiver pointer is moved.
  • Last-save-wins: saving again before the trigger fires replaces the pending entries.

The unavoidable trade-off

WebAssembly.instantiate blocks the audio thread synchronously inside AudioWorkletGlobalScope on every browser we tested — V8/SpiderMonkey don't dispatch the compile to off-thread workers from a worklet. So we get a glitch somewhere. The choice was:

  • Compile on save click → glitch interrupts the currently playing music.
  • Compile at the beat boundary → glitch lands where the listener already expects a transition.

This PR picks the second. The save click is glitch-free; the audible pause moves to the swap moment.

Other approaches we tried and dropped:

  • Pre-compile on main thread + transfer WebAssembly.Module to the worklet: blocked by Chromium silently dropping Module-bearing messages to AudioWorkletNode.port. (Issue Audible glitch on quantized save (synth change): in-worklet wasm compile blocks audio thread; Module postMessage to AudioWorklet broken on Chromium #129, now closed — the bytes-only path here makes that workaround unnecessary.)
  • Per-save new AudioWorkletNode with processorOptions: glitched in a different place (sequencedata clone on the audio thread at startup), beat indicator froze, replacement landed at the wrong time. Reverted.
  • Quick fade-out before instantiate: softened the click but the swap was no longer sample-accurate. Reverted in favour of the simpler "instantiate at swap" path.
  • LRU instance cache (preload + reuse to avoid recompile on switch-back): dangling notes when switching back to a cached instance — stale voice state leaked across the swap. Reverted.

Test plan

  • synth1/audioworklet/quantized-save.spec.js exercises the deferred swap end-to-end (post-save click → expected swap time → tracing receiver confirms note events come from the new instance). Passes on Chromium and Firefox.
  • Existing midisynthaudioworklet.spec.js (Now/legacy path) — no regression in the immediate suspend/resume swap.

🤖 Generated with Claude Code

…ble by N

A dropdown next to the save button picks N. N=0 ("Now", the default)
preserves the existing suspend/instantiate/resume behavior — a brief
audible pause but the swap is guaranteed live before save returns.
N>0 instead stashes the freshly compiled wasm (and any new sequencedata
/ toggleSongPlay) in a pending slot on the worklet processor, acks
back to the caller without suspending, and performs an atomic swap
inside process() at the next beat where currentBeat % N == 0. Beats
are derived from the sequencer's currentFrame and the song's BPM
(read live from midisequencer/pattern.js's exported bpm binding).

Last-save-wins: saving again before the trigger fires replaces the
pending entries. On swap, allNotesOff() is called on the outgoing
instance to silence any tails before the receiver pointer is moved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 15, 2026

Copy link
Copy Markdown

Deploying webassemblymusic with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6ab3073
Status: ✅  Deploy successful!
Preview URL: https://0111c42d.webassemblymusic.pages.dev
Branch Preview URL: https://quantized-save.webassemblymusic.pages.dev

View logs

petersalomonsen and others added 11 commits May 15, 2026 10:33
Previously the worklet received raw bytes and called
WebAssembly.instantiate(bytes, imports) inside process()'s thread.
In audio worklets the bytes overload's compile step falls back to
synchronous on the render thread, blowing the ~2.9 ms render-quantum
deadline and producing an audible glitch on every save (which was the
whole reason the legacy Now path brackets the call with suspend/resume).

Move the compile to the main thread via WebAssembly.compile(bytes) and
ship the resulting Module across postMessage (Modules are structured
cloneable). The worklet now calls `new WebAssembly.Instance(module, …)`
which is synchronous but only links imports + sets up memory — small
enough to fit inside a render quantum, so saves no longer glitch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related bugs were making the swap land on unpredictable beats:

1. lastBeat starts at -1, so the first process() call after a save sees
   `currentBeat !== -1` and treats it as a beat *crossing*. If the save
   happened to land inside a beat where `beat % N === 0` already, the
   modulo check passed and the swap fired ~3 ms after save instead of
   waiting for the next N-aligned beat.

2. lastBeat was never reset after a swap finished, so the second save in
   a session re-entered with stale state and could misfire the same way.

Fix (1): when lastBeat is -1 (first call after pending state is set),
anchor it to the current beat and return without swapping. We only swap
once we've crossed *out of* that beat into a new one that satisfies the
quantize.

Fix (2): when there's no pending state, reset lastBeat to -1 so the next
save anchors fresh.

With N=16 a save now reliably fires at the next absolute beat divisible
by 16 (16, 32, 48, 64, …) regardless of where in the bar the save landed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the user switches to a song with a different BPM and clicks save:

- midiBpm (live binding from pattern.js) is now the new song's BPM,
  because compileSong just ran setBPM(...) for the new song.
- But the audible music is still the old song at the old BPM until the
  swap fires.
- Beat indicator in the UI uses the BPM captured at attachSeek time
  (the song that's actually playing), so it stays correct from the
  listener's perspective.
- The worklet was using pendingBpm (= midiBpm at save time = new song's
  BPM) to decide when `currentBeat % N == 0`, so its beats diverged
  from what the listener was hearing — the swap landed on the wrong
  audible beat, or never seemed to land at all if the worklet thought
  it had already passed.

Track playingBpm separately in the worklet:
- Initial `wasm` message and N=0 immediate replacements set playingBpm
  to the bpm that just took over.
- A quantized swap, once it fires, promotes pendingBpm to playingBpm.
- tryQuantizedSwap uses playingBpm for beat math (falls back to
  pendingBpm if playingBpm hasn't been seeded yet).

All three call sites (initial connect, N=0 path in updateSynth, and
the existing pending path) now pass bpm along with the wasm message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "have to press save twice" symptom was a race between two separate
worklet messages:

- updateSynth posted `pendingWasmModule` and awaited `pendingWasmReady`.
  WebAssembly.compile(bytes) on the main thread plus the postMessage
  round-trip takes ~50–300 ms.
- During that window, `process()` was already running with
  `pendingInstance` set and `lastBeat` anchored. If a beat boundary
  divisible by N happened to fall inside the window, tryQuantizedSwap
  fired the swap with only the new wasm — the new sequencedata hadn't
  arrived from updateSong yet. The listener kept hearing the old song's
  notes through the new synth, which looked like "the song didn't
  replace". The second save caught up because by then nothing was
  pending and both halves landed together.

Fix: when quantizeN > 0 and both new wasm and new sequencedata are
available, bundle them into a single `pendingWasmModule` message that
also carries `pendingSequencedata` and `pendingToggleSongPlay`. The
worklet's onmessage handler sets all three pending fields atomically
before acking, so any subsequent process() call sees either all pending
state or none — never a half-applied save.

The legacy quantizeN === 0 path and the WAM (synthsource) path are
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a WTR spec that traces noteons emitted by the worklet's wasm and
asserts they line up with the swap event the worklet announces. The
spec exposed three distinct bugs that explained the user-reported
"wrong beat" and "had to press save twice" symptoms:

1. WebAssembly.Module wasn't being delivered to the worklet on
   Chromium. The previous commit pre-compiled the bytes on the main
   thread and posted the resulting Module. Firefox's AudioWorklet
   structured-clone handles Modules; Chromium's silently drops them,
   so the bundled save message never arrived and the swap never
   fired. Switch to posting raw bytes and `await
   WebAssembly.instantiate(bytes, ...)` in the worklet — the async
   instantiate path is off-thread on both engines and doesn't
   audibly glitch the way a synchronous `new WebAssembly.Module`
   would.

2. The sequencer's `setSequenceData` "replace while playing" branch
   used `evt.time >= currentTime` to pick where to resume. The swap
   fires on the first render quantum that crosses a beat boundary,
   so currentTime is ~2.9 ms past the boundary's exact ms. The new
   song's event sitting on that boundary (e.g. at 4800 ms when the
   swap fires at 4801 ms) failed the `>=` check and was silently
   skipped — the listener heard up to one new-tempo beat of silence
   before the next note. Allow events from one render quantum back.

3. When a quantized swap fires with new sequencedata, reset the
   sequencer's `currentFrame` to 0 so the new song plays from its
   beat 0 at the swap moment. Otherwise a swap at currentTime T
   leaves the new song waiting for its next event at time > T — at
   120 BPM that's up to 500 ms of silence after the swap fires.

The worklet now also posts a structured `quantizedSwapApplied`
message back to the main thread when each swap fires, replacing the
ad-hoc `_debug` string the test was parsing during diagnosis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regressions in the previous commit:

1. Audible glitch on every save. Switching from the pre-compiled-Module
   path to the send-bytes-and-compile-in-worklet path put the
   WebAssembly compile back on the audio thread (Chromium runs
   WebAssembly.compile inside an AudioWorkletGlobalScope synchronously
   from the render thread's perspective, no matter that the API is
   async). Firefox had been glitch-free under the Module path.

2. Beat indicator in the bottom-right drifts after a song switch.
   attachSeek captures bpm in a closure at startup, so the indicator
   always uses the *first* song's tempo. Multiple swaps compound the
   drift.

Fix (1): probe at startup whether the engine can structure-clone a
WebAssembly.Module across AudioWorkletNode.port. Firefox passes; the
worklet posts back an ack, the main thread records that and posts
Module-only on subsequent saves. Chromium silently drops the probe
message, the ack never comes, the 500-ms timeout in the probe trips,
and the main thread falls back to sending raw bytes (with the
worklet awaiting `WebAssembly.instantiate(bytes, …)`). On engines
where Module transfer works we're back to glitch-free saves.

Fix (2): attachSeek now accepts either a static bpm or a getter. The
worklet announces each quantized swap with `{quantizedSwapApplied,
bpm}`; main thread maintains `activePlayingBpm` from those events and
from N=0 immediate replacements, and feeds attachSeek a getter that
reads it. The beat indicator's tempo now follows the audible song.

Test: quantized-save.spec.js now also asserts the beat indicator
reflects the swapped-in tempo after two swaps (would previously have
read at the original first-song bpm). Existing midisynthaudioworklet
spec still passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the sequencer and the synth processor hardcoded 128 in several
places (sample-buffer slicing, currentFrame increment, the
quantum-tolerance check in setSequenceData). Pull it into a single
named constant per module — RENDER_QUANTUM_FRAMES — and add a comment
flagging that the wasm synth also assumes this size, so a future
non-default `AudioContext({renderQuantumSize})` would need a
coordinated change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Module-vs-bytes branching plus a startup probe to pick between
them was carrying its own complexity (probe handler, timeout, state,
two code paths in updateSynth, two code paths in the worklet's
onmessage). Cross-browser support for WebAssembly.Module over
AudioWorkletNode.port is unreliable enough that the optimization
isn't worth the maintenance cost.

Keep one path: main thread posts raw bytes, the worklet does
`await WebAssembly.instantiate(bytes, …)` in its onmessage handler.
The bpm-following beat-indicator changes stay; this only removes the
Module-transfer machinery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Measures each step of the worklet's pendingWasmBytes handler:
- WebAssembly.instantiate (await — mostly off-thread compile, but the
  start function and Promise-resume code run on the audio thread)
- loadAudioIntoWasm (synchronous Float32Array copy into wasm memory)
- stashing pending state

Also counts process() calls before and after the instantiate await so
we can tell whether the audio thread actually missed any render quanta
(if actual < expected, we underran → audible glitch).

Emits a `_swapTiming` message per save. updateSynth's main-thread
listener logs it as `[swap-timing] …`. Temporary — to be removed once
we've identified where the time actually goes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first instrumentation pass only measured the window inside the
pendingWasmBytes onmessage handler and showed 0 missed quanta — but
the user reports the audible glitch is at click time and lasts ~250 ms,
far longer than the 5.8 ms instantiate window. The glitch must be
caused by something OUTSIDE that window.

Add:
- Continuous gap monitor in process() that fires a message whenever
  the inter-quantum gap exceeds 1.5x the expected ~2.9 ms, reporting
  the actual gap, missed quanta, and audio-context time of occurrence.
- Pre-post and post-ack logs in updateSynth so we can see when the
  main thread sent the bundled message and when the ack came back, in
  both perf.now() and audio-context-time units. Lets us correlate the
  audio-thread gap timestamp with the main-thread sequence of events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 89 ms wall-clock vs 5.8 ms audio-context-time during post→ack
shows the audio thread was starved for ~83 ms, but the 5.8 ms
instantiate alone can't account for that. The next suspect is
structured-clone deserialization of the bundled message (which
includes 7347 event objects) — that work runs on the audio thread
BEFORE onmessage fires.

Add an "onmessage entered" ping the worklet posts immediately at
handler entry. updateSynth's listener captures performance.now() when
that ping arrives, and the ack log now reports
"clone+delivery=Xms" alongside total wallElapsed. If clone is the
bottleneck the two numbers will be nearly equal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
petersalomonsen and others added 6 commits May 15, 2026 22:15
The temporary timing/process-gap probes added across the last several
commits served their purpose: they pinned the audible save-glitch on
the in-worklet `await WebAssembly.instantiate(bytes, …)` blocking the
audio thread for ~85 ms on Chromium, and ruled out structured-clone
of the sequencedata as the cause. Strip them now that the diagnosis
is documented in issue #129.

What stays:
- The `quantizedSwapApplied` swap-event message (used by the beat
  indicator to follow the audible tempo across swaps).
- `_installTracingReceiver` / `_trace_noteon` (used by
  quantized-save.spec to verify which notes actually played).
- The single-path bytes implementation, with a comment pointing at
  issue #129 for the trade-off and possible future fixes.

What goes:
- `_swapTiming` posts and the corresponding console.log.
- `_processGap` continuous render-quantum monitor + log.
- `_onMessageEntered` ping and the clone-time measurement on main.
- `_processCallsTotal`, `_lastProcessTime` counters.
- Pre-/post-postMessage `performance.now()` capture in updateSynth.

Net: -108 LoC across the two worklet files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ions

Workaround for chromium#40855462 ("Chrome fails to send WebAssembly.Module
over AudioWorkletNode.port"): pass the pre-compiled Module via
processorOptions on AudioWorkletNode construction instead. Module
structured-clones via processorOptions in current Chromium (and Firefox);
only port.postMessage drops it.

This eliminates the audible save glitch (~85 ms on Chrome) by moving the
wasm compile off the audio rendering thread:

- Main thread: WebAssembly.compile(bytes) — V8's off-thread compile
  worker pool, no audio impact.
- New AudioWorkletNode constructed with processorOptions: { wasmModule,
  audio, sequencedata, toggleSongPlay, bpm, swapAtCtxTime }.
- Processor constructor receives the Module and synchronously creates
  an Instance — microseconds of linking, fits inside one render quantum.
- New node is connected through its own GainNode (gain=0) so it renders
  silently until the quantize boundary.
- Sample-accurate handoff: at swapAtCtxTime, oldGain.setValueAtTime(0)
  and newGain.setValueAtTime(1). The old node is then told to freeze
  its sequencer (stopAtCtxTime), and main thread cleans it up 200 ms
  later.

Architecture changes:

- audioworkletprocessorsequencer.js exposes MidiSequencer as a class
  on AudioWorkletGlobalScope (MidiSequencerClass) in addition to the
  legacy singleton. The synth processor now creates a per-instance
  sequencer so multiple AudioWorkletNodes can coexist briefly during
  a swap without sharing state.

- midisynthaudioworkletprocessor.js: per-instance sequencer; supports
  both legacy `wasm`-message construction (for the initial load and
  N=0 path) and the new processorOptions-based construction;
  swapAtCtxTime/stopAtCtxTime gating in process().

- midisynthaudioworklet.js updateSynth N>0 path completely rewritten
  to spawn a new AudioWorkletNode + GainNode per save and crossfade.
  The legacy N=0 path (suspend/resume) is unchanged.

- quantizedSwapApplied + _trace_noteon are now re-broadcast as
  CustomEvents on `window` so listeners (beat indicator, tests) don't
  have to rebind on every save. activePlayingBpm is updated and
  audioStartCtxTime is reset to the new song's swap time so the next
  quantize boundary is computed relative to the current playback.

- quantized-save.spec.js rewritten to subscribe to the window
  CustomEvents and assert in audio-context-time rather than the
  worklet's currentFrame-based time (which now resets per node).

Tested: 51 passes / 51 passes on Chromium 148 + Firefox 150 in WTR.
Manual verification (no audible glitch on real Chrome) still pending.

Closes the workaround path tracked in #129.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three shapes — AS midi synth, WAM midi synth, and pattern-based —
share one save handler but route to completely different audio worklet
processors. The branching has grown enough that it's not obvious which
shape goes where, especially since quantize support only exists for
the AS midi synth path. Add a header comment listing the shapes and a
sharper inline comment on `useBundledSave` calling out that quantize
is a no-op for shapes (2) and (3).

No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous wording referred to the "original 'had to press save
twice' symptom" — but that symptom only existed during early
iterations of this branch's quantize work, never in master. Once this
branch merges, the comment would mislead a future reader into thinking
master ever had that bug. Reword to describe the concrete race the
bundling prevents, without implying any historical context.

No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WebAssembly.instantiate blocks the audio thread in AudioWorkletGlobalScope
regardless of bytes-vs-Module input (no off-thread compile dispatch from
the worklet). Compiling on save click made the *currently playing* wasm
glitch; moving the compile inside tryQuantizedSwap puts the glitch at the
swap moment instead — musically expected, since the listener already knows
something is changing.

Save click now only stashes bytes in pendingWasmBytesDeferred. At the next
beat divisible by quantizeN, tryQuantizedSwap kicks off instantiate via
.then (await isn't available inside process) and sets swapInstantiateInFlight;
the next process() tick sees pendingInstance and calls _applySwap.

An instance LRU cache was considered to avoid recompiling on switch-back,
but a switch back to a cached instance produced dangling notes from stale
voice state — reverted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@petersalomonsen petersalomonsen marked this pull request as draft May 16, 2026 14:39
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