Architecture

How it works

Two puzzles: how browseraudio moves audio between Python and the browser, and how this page runs Python at all. Here's the whole stack.

The library · two worlds

The page and the worker

In a browser kernel, your Python runs in a Web Worker — a background thread with no DOM, no getUserMedia, and no Web Audio API. Those live only on the page's main thread. So Python can't reach the microphone or the speakers on its own.

Main thread The pageDOM · Web Audio · getUserMedia · the anywidget frontend (recorder.js / player.js)
↕ Jupyter Comm channel
Web Worker The kernelCPython on WebAssembly · NumPy · your Recorder / Player object

The library · the bridge

The bridge is an anywidget

browseraudio is built on anywidget. Each widget is two halves that talk over the Jupyter Comm channel:

recorder.js
player.js
An ES module on the main thread. It calls getUserMedia / builds an AudioContext, and reads or writes the samples.
Recorder
Player
A Python anywidget.AnyWidget subclass in the worker. Its traitlets mirror state to and from the frontend.
_pcm_b64
The audio itself: float32 PCM, base64-encoded, carried as a synced trait. Recording sends it worker-ward; playback sends it page-ward.

So record() captures on the page and pushes bytes up to Python; play() takes an array and pushes bytes down to an AudioContext. The kernel never touches an audio API — it only sets and reads traits.

The library · timing

Why recording needs two cells

The frontend's reply travels over the same Comm channel the kernel uses — but the kernel processes comm messages between cell executions, not during one. While the cell that calls record() is still running, the "here are your samples" message sits unread. Display the widget in one cell, then read rec.samples in the next, once the kernel is idle. Playback has no such constraint — nothing comes back — so play() is single-cell.

The library · drop-in

The sounddevice facade

Plenty of audio code already speaks sounddevice. So browseraudio ships browseraudio.sounddevice — a shim with the same shape (play, wait, query_devices, default), backed by the page/worker bridge above. A library can fall back to it in the browser and keep running unchanged:

try:
    import sounddevice as sd
except (OSError, ModuleNotFoundError):
    import browseraudio.sounddevice as sd   # same calls, Web Audio underneath

play is genuinely drop-in — it's fire-and-forget, so a single call works. rec is the catch: sd.rec() + sd.wait() is blocking, but the browser can't fill a buffer synchronously — the AudioContext picks the rate, and a worker kernel can't process the reply mid-cell (the two-cells problem). So the facade's rec raises with a pointer to the two-cell record() flow instead of hanging.

On the page's main thread — a bare Pyodide REPL, where the event loop can service the reply — you can also await rec.result() to get a take in one go. In a Web-Worker kernel that would hang, so result() detects the worker and raises immediately rather than leaving you stuck.

Running it · the notebook stack

How you'd run browseraudio for real

In day-to-day use you don't wire any of this up — you pip install browseraudio in a browser notebook and call record() / play(). That kernel is itself a stack, the Python sitting on the interpreter sitting on the browser:

your coderecord() / play()browseraudio's Python API, plus the anywidget halves above.
JupyterLiteIn-browser JupyterThe kernel protocol, the Comm channel, and piplite (the %pip behind installs).
PyodideCPython on WebAssemblyThe interpreter, NumPy, and micropip — running in a Web Worker.
the browserMain threadWhere the anywidget frontend reaches the mic and speakers.

JupyterLite and thebe are the usual hosts; Pyodide is the interpreter underneath both.

Running it · this demo

How this demo runs — on the real stack

Both interactive pages run the real stack above — thebe + thebe-lite + Pyodide, vendored under vendor/thebe-dist/ — rather than faking it. On the first Run, assets/live.js boots the kernel and micropip-installs the actual browseraudio package, so the cells run the genuine API: record() mounts the real Recorder anywidget, and play() the real Player — exactly what you'd get in a notebook.

// assets/live.js — on first Run
await loadScript("vendor/thebe-dist/lite/thebe-lite.min.js");   // + Pyodide
await kernel.requestExecute({ code: 'await micropip.install("browseraudio")' });

The runtime is a one-time ~25 MB download (cached after); reload the page to reset to a clean kernel. The cells are editable — change the code and re-run. (The CodeMirror editors are styled to match the static blocks via assets/live.css.)

Run it yourself

Serve the folder over localhost

Open it over localhost (not as a file:// URL — microphone access needs a secure context):

cd demo
python3 -m http.server 8000 --bind 127.0.0.1
# then visit http://127.0.0.1:8000/

Any static host works for deployment (GitHub Pages, Netlify, …) as long as it's served over https://.