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.
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 anAudioContext, and reads or writes the samples. - Recorder
Player - A Python
anywidget.AnyWidgetsubclass 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:
%pip behind installs).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://.