Microphone capture and playback for in-browser Python (Pyodide / JupyterLite) via the Web Audio API.
Project description
browseraudio
Audio I/O for in-browser Python — record from the mic and play audio back in Pyodide, JupyterLite, and thebe, straight to and from a NumPy array.
In the browser, Python usually runs in a Web Worker with no access to
getUserMedia or the Web Audio API — so the usual sounddevice → PortAudio
stack can't run. browseraudio does the capture and playback on the page's main
thread (via a tiny anywidget frontend) and ferries the
float32 samples across, so it works even when the kernel runs in a worker.
Status: recording, playback, and a
sounddevice-compatible facade (browseraudio.sounddevice). See the roadmap for what's next.
Contents
- Requirements
- Install
- Live demo
- Quickstart
- API reference
- How it works
- Troubleshooting
- Development
- Roadmap
- License
Requirements
| Python | 3.9+ |
| Runtime | A browser Python kernel that supports Jupyter widgets — JupyterLite, thebe-lite, classic Jupyter, or marimo. (Also works on a native kernel, but native code should just use sounddevice.) |
| Browser | Any current Chromium, Firefox, or Safari (needs the Web Audio API + getUserMedia). |
| Context | A secure context — https:// or http://localhost. Browsers block microphone access on plain http://. |
| Permission | Recording needs the microphone-permission prompt (triggered by clicking Record). Playback needs no permission. |
Runtime dependencies: anywidget and numpy.
pyquist is optional (only for
Recorder.to_pyquist()).
Install
pip install browseraudio
In a browser kernel (JupyterLite / thebe), install at runtime with micropip:
import micropip
await micropip.install("browseraudio")
Live demo
Try it live → jiaweil6.github.io/browseraudio
— a static site that runs browseraudio's own frontend (record + playback) right in your
browser, no install. Or serve the demo/ folder locally:
cd demo && python -m http.server 8000 # then visit http://localhost:8000
Quickstart
Recording finishes after you click, so display the recorder in one cell and read the result in the next.
from browseraudio import record
rec = record(3.0) # shows "● Record 3s" — click it, allow the mic, speak
An inline player appears so you can hear the take. Then, in a new cell:
rec.samples # float32 ndarray, shape (n_frames, 1)
rec.sample_rate # e.g. 48000
rec.to_pyquist() # a pyquist.Audio, if pyquist is installed
Why two cells? A single-cell
await record()can't work in Jupyter/thebe: the kernel doesn't process the widget's reply while that same cell is still running, so the recording would never arrive.
Playback
Playback is fire-and-forget — nothing has to return to the kernel — so it works in a single cell:
from browseraudio import play
play(rec.samples, rec.sample_rate) # ▶ plays through the speakers
play(rec.to_pyquist()) # sample_rate read from the pyquist.Audio
Autoplay. Browsers block audio that starts without a user gesture, so playback may not start on its own — just click the ▶ Play button the widget shows.
API reference
record(duration=3.0)
Create a Recorder, display it, and return it. Convenience wrapper
for the common case. Click Record, then read the result (see
Recorder) in a later cell.
| Parameter | Type | Default | Description |
|---|---|---|---|
duration |
float |
3.0 |
Length to record, in seconds. |
Returns a Recorder.
play(samples, sample_rate=None, *, autoplay=True)
Create a Player, display it, and return it — playing samples
through the page's speakers on the browser's main thread. A drop-in shape for
sounddevice.play / pyquist.play.
| Parameter | Type | Default | Description |
|---|---|---|---|
samples |
array-like | — | Audio as (n_frames,) or (n_frames, n_channels), values in [-1, 1]. |
sample_rate |
int |
None |
Sample rate in Hz. If None, read from samples.sample_rate (e.g. a pyquist.Audio). |
autoplay |
bool |
True |
Try to start immediately; falls back to the ▶ Play button if the browser blocks autoplay. |
Returns a Player. Raises ValueError if samples isn't 1-D/2-D or no
sample rate can be determined.
Player
An anywidget that plays a NumPy audio buffer through
the browser's AudioContext — so playback works even when the kernel runs in a
Web Worker. Multi-channel buffers are sent channel-major; the AudioContext
resamples to its own rate, so any sample_rate is fine. Use
play() rather than constructing it
directly.
Recorder
An anywidget that records from the microphone. Display
it (or use record()), click Record, then read its
attributes in a separate cell once capture finishes.
from browseraudio import Recorder
rec = Recorder(duration=5.0)
rec # display it; click Record
Constructor
| Parameter | Type | Default | Description |
|---|---|---|---|
duration |
float |
3.0 |
Length to record, in seconds. |
Attributes
| Attribute | Type | Description |
|---|---|---|
samples |
numpy.ndarray | None |
The latest take as float32, shape (n_frames, 1); None before anything is recorded. |
sample_rate |
int |
The browser AudioContext rate (e.g. 48000); 0 before recording. |
duration |
float |
The requested recording length, in seconds. |
error |
str | None |
A message if the last attempt failed (permission denied, no input, …), else None. |
Methods
| Method | Returns | Description |
|---|---|---|
to_pyquist() |
pyquist.Audio |
The take as a pyquist.Audio. Raises RuntimeError if nothing has been recorded, and ImportError if pyquist isn't installed. |
on_result(cb) |
handler | Register cb(recorder) to run when a capture arrives — a public alternative to observing the internal _pcm_b64 trait. |
on_error(cb) |
handler | Register cb(message) to run when a capture attempt fails. |
await result() |
Recorder |
Awaitable that resolves with the recorder once capture completes (raising on failure). Main-thread kernels only — in a Web-Worker kernel it raises immediately rather than hanging; use the two-cell flow there. |
browseraudio.sounddevice
A sounddevice-shaped facade, so a
library can fall back to it in the browser without code changes:
try:
import sounddevice as sd
except (OSError, ModuleNotFoundError):
import browseraudio.sounddevice as sd
| Name | Status | Notes |
|---|---|---|
play(data, samplerate=None, **kw) |
drop-in | Delegates to play(); fire-and-forget, single cell. |
wait(ignore_errors=True) |
drop-in | No-op — browser playback doesn't block. |
query_devices(device=None, kind=None) |
drop-in | A synthetic two-device table (browser mic + speakers). |
default |
drop-in | Mimics sd.default; its device slot is indexable and assignable. |
rec(...) |
not supported | Raises with guidance toward the two-cell record(): the browser picks the sample rate and can't fill a buffer synchronously in one cell. |
How it works
A browser tab has two Python-relevant execution contexts, and browseraudio uses both:
- The page (main thread) has the Web Audio API and
getUserMedia, but not your Python kernel. An anywidget frontend lives here: for recording it captures and encodes the float32 samples; for playback it decodes a buffer into anAudioContextand plays it through the speakers. - The worker runs your Python kernel (this is how Pyodide/JupyterLite keep
the page responsive), but it can't reach those audio APIs.
record()receives the captured samples asrec.samples;play()ships a NumPy buffer the other way for the page to sound.
The two contexts talk over the standard Jupyter widget comm channel — the
same mechanism any ipywidgets widget uses — so browseraudio works wherever
widgets do: JupyterLite, thebe-lite, classic Jupyter, and marimo.
Under the hood, audio crosses the comm as base64-encoded float32. Recording uses a
ScriptProcessorNode to accumulate duration seconds before sending it up;
playback hands a buffer to a one-shot AudioBufferSourceNode. (Both are
deliberately simple — see the roadmap for the planned AudioWorklet
upgrade.)
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| No Record button appears | The frontend needs a Jupyter-widget-capable runtime. In a bare Pyodide page without the widget manager, widgets don't render — use JupyterLite, thebe-lite, or Jupyter. |
| Permission denied / no prompt | The mic needs a secure context (https:// or localhost) and a user gesture. Click the button; if you previously blocked the mic, re-allow it in the browser's site settings. |
rec.samples is None |
You haven't recorded yet, or you read it in the same cell that created the recorder. Click Record, then read it in a new cell. |
| Recorded, but silent | Check rec.error, and that the right input device is selected and unmuted at the OS/browser level. |
await record() hangs |
In a Web-Worker kernel (JupyterLite / thebe) the kernel can't process the widget reply mid-cell — use the two-cell flow. On the page's main thread, await rec.result() works in one cell. |
to_pyquist() raises ImportError |
Install pyquist (pip install pyquist), or use rec.samples / rec.sample_rate directly. |
Development
git clone https://github.com/jiaweil6/browseraudio
cd browseraudio
pip install -e ".[test,pyquist]" # editable install with test + optional extras
pytest # run the test suite
python -m build # build the wheel + sdist into dist/
python -m twine check dist/* # validate package metadata
Project layout:
| Path | Purpose |
|---|---|
browseraudio/__init__.py |
Public API (Recorder, record, Player, play) and version. |
browseraudio/_recorder.py |
The Recorder widget and record() (Python side). |
browseraudio/_player.py |
The Player widget and play() (Python side). |
browseraudio/static/recorder.js |
Recorder frontend — Web Audio capture. |
browseraudio/static/player.js |
Player frontend — Web Audio playback. |
tests/ |
The pytest suite (Python side, headless). |
demo/ |
Static demo site, published to GitHub Pages. |
pyproject.toml |
Packaging metadata; static/*.js is shipped as package data. |
The tests/ suite covers the Python side headlessly — the base64↔NumPy
transport, array shapes, validation, and version sync — and runs in CI on every
push and pull request across Python 3.9–3.13. (The browser frontend's Web Audio
path isn't unit-tested yet.) Contributions and issues are welcome.
Roadmap
- ✅ Playback — push a buffer to a main-thread
AudioContext. (0.2.0) - ✅
sounddevice-compatible facade —browseraudio.sounddeviceexposesplay/wait/query_devices/defaultso libraries like pyquist can fall back to it in the browser.playis genuinely drop-in; the catch issd.rec+sd.wait, which is blocking — a Web-Worker kernel can't honor that in one cell, sorecraises with guidance toward the two-cellrecord()flow (andRecorder.result()offers anawaitpath on the main thread). (Unreleased) - AudioWorklet backend — replace the deprecated
ScriptProcessorNode. - Binary comm transport — send raw buffers instead of base64.
- Streaming (stretch) — generator → ring buffer → AudioWorklet. Bounded by
the browser: Python can't run in the audio thread, and
SharedArrayBufferneeds cross-origin-isolation (COOP/COEP) headers.
License
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file browseraudio-0.3.0.tar.gz.
File metadata
- Download URL: browseraudio-0.3.0.tar.gz
- Upload date:
- Size: 24.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
af6bc87d53218b8e8344e152ff6d42c22d7ab0c8b33ae81f1c0168b28745bc85
|
|
| MD5 |
23e6b388240087ef02b15648282b6a31
|
|
| BLAKE2b-256 |
445044c4683136ae230708addbeb65173afcef2b04ff221a053145655aff8093
|
File details
Details for the file browseraudio-0.3.0-py3-none-any.whl.
File metadata
- Download URL: browseraudio-0.3.0-py3-none-any.whl
- Upload date:
- Size: 19.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f483668961020e0b8594f329df12fcb8e04e19a89aca73736ffea9632f9c3af6
|
|
| MD5 |
1da7f943cebf2735735a01ae1e2f3511
|
|
| BLAKE2b-256 |
e6c71a8c9b3e321267ea7aec1b8d5315b8256aef7bf5d580bcf7ecbd6c3e06ac
|