Skip to content

audiodev.worklet_audio: AudioWorklet output for Python in a Pyodide Web Worker - #209

Merged
bdbarnett merged 1 commit into
mainfrom
ostinato/audio
Oct 15, 2026
Merged

bdbarnett merged 1 commit into
mainfrom
ostinato/audio

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

If your Python runs in a Pyodide Web Worker, it can't touch an AudioContext, and web_audio can't help. audiodev.worklet_audio is the worker's half of an AudioWorklet output: the page owns the context and an AudioWorkletProcessor, hands your worker one end of a MessageChannel, and this module answers the worklet's requests for audio on it.

Two ways to feed it:

from audiodev import AudioFormat, worklet_audio

# Pull: an engine that renders on demand
out = worklet_audio.WorkletOutput(port, channels=2, sample_rate=48000)
out.source = render          # render(blocks) -> blocks * 256 * channels int16 samples

# Push: the usual PCMOutput, for an AudioOut pump or any write() caller
pcm = worklet_audio.pcm_out(AudioFormat(48000, 2, 16), port=port, latency="low")
pcm.write(chunk)

The protocol. Audio moves in blocks of 256 frames of int16, interleaved for stereo. The worklet sends {need, asked}; the worker replies once with {pcm: Int16Array, asked}, the array's buffer in the transfer list. The worklet may also send {offset} (rendered frame k is heard at context frame k + offset, exposed as out.offset so you can place events at a sample) and {underrun} (summed in out.underruns). Each reply is one bulk copy into JS and one postMessage, with no per-sample Python.

Push mode can't block. The worklet's requests arrive on the worker's own thread, so waiting inside write() would stop them. write() drops the oldest audio when the queue is full and counts it in dropped; try_write() takes only what fits; awrite() and adrain() wait by yielding to the event loop. Requests drain the queue and pad with silence when it runs short, counted in starved. latency and queue_ms size the queue (500 ms by default, 100 ms for "low", never under eight blocks).

audiodev.auto can't tell a worker from a page, so it never selects this; import it yourself. It ships in pydevices-desktop with the other browser backends, and the protocol is documented in lib/audiodev/README.md.

Tests. tests/test_worklet_audio.py runs it against a fake MessagePort and fake JS types: pull mode, short and long sources, a failing source still getting a reply, stereo interleave, offset and underrun tracking, the queue in order, starvation padding, latency sizing, try_write, drop-oldest, awrite/adrain waiting, and that each reply transfers its own buffer. It was also run in real Pyodide under node over a real MessageChannel: replies arrived as Int16Arrays of the right length, the sender's buffer was detached (transferred, not copied), and offset, underruns and starvation were tracked.

@bdbarnett
bdbarnett merged commit 4938b29 into main Oct 15, 2026
3 checks passed
@bdbarnett
bdbarnett deleted the ostinato/audio branch October 15, 2026 07:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant