Skip to main content

Microphone capture and playback for in-browser Python (Pyodide / JupyterLite) via the Web Audio API.

Project description

browseraudio

PyPI Python License: MIT

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

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 contexthttps:// 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:

  1. 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 an AudioContext and plays it through the speakers.
  2. 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 as rec.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 facadebrowseraudio.sounddevice exposes play / wait / query_devices / default so libraries like pyquist can fall back to it in the browser. play is genuinely drop-in; the catch is sd.rec + sd.wait, which is blocking — a Web-Worker kernel can't honor that in one cell, so rec raises with guidance toward the two-cell record() flow (and Recorder.result() offers an await path 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 SharedArrayBuffer needs cross-origin-isolation (COOP/COEP) headers.

License

MIT

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

browseraudio-0.3.0.tar.gz (24.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

browseraudio-0.3.0-py3-none-any.whl (19.6 kB view details)

Uploaded Python 3

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

Hashes for browseraudio-0.3.0.tar.gz
Algorithm Hash digest
SHA256 af6bc87d53218b8e8344e152ff6d42c22d7ab0c8b33ae81f1c0168b28745bc85
MD5 23e6b388240087ef02b15648282b6a31
BLAKE2b-256 445044c4683136ae230708addbeb65173afcef2b04ff221a053145655aff8093

See more details on using hashes here.

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

Hashes for browseraudio-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f483668961020e0b8594f329df12fcb8e04e19a89aca73736ffea9632f9c3af6
MD5 1da7f943cebf2735735a01ae1e2f3511
BLAKE2b-256 e6c71a8c9b3e321267ea7aec1b8d5315b8256aef7bf5d580bcf7ecbd6c3e06ac

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page