browseraudio 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 browseraudio contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: browseraudio
3
+ Version: 0.1.0
4
+ Summary: Microphone capture for in-browser Python (Pyodide / JupyterLite) via the Web Audio API.
5
+ Author: jiaweil6
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jiaweil6/browseraudio
8
+ Project-URL: Repository, https://github.com/jiaweil6/browseraudio
9
+ Project-URL: Issues, https://github.com/jiaweil6/browseraudio/issues
10
+ Keywords: pyodide,jupyterlite,web audio,microphone,anywidget,wasm
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Multimedia :: Sound/Audio
17
+ Classifier: Framework :: Jupyter
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: anywidget>=0.9
22
+ Requires-Dist: numpy
23
+ Provides-Extra: pyquist
24
+ Requires-Dist: pyquist; extra == "pyquist"
25
+ Dynamic: license-file
26
+
27
+ # browseraudio
28
+
29
+ Microphone capture for **in-browser Python** — Pyodide, JupyterLite, thebe-lite —
30
+ via the browser's Web Audio API.
31
+
32
+ When Python runs in the browser it usually lives in a **Web Worker**, where the
33
+ Web Audio API and `getUserMedia` don't exist and the DOM is out of reach. So
34
+ the classic audio stack (`sounddevice` → PortAudio) can't run, and libraries
35
+ that depend on it fall back to "not available" stubs. `browseraudio` bridges
36
+ that gap: it captures audio on the browser's **main thread** (through a tiny
37
+ [`anywidget`](https://anywidget.dev) frontend) and hands the samples back to the
38
+ Python kernel over the widget comm channel.
39
+
40
+ > **Status: v0 — recording only, and a foundation to build on.** The full
41
+ > round-trip is proven end-to-end under thebe-lite (Pyodide 0.27.7): a 1-second
42
+ > capture returned a float32 array, shape `(48000, 1)` at 48 kHz, with real
43
+ > signal. See the roadmap for what's next.
44
+
45
+ ## Install
46
+
47
+ ```sh
48
+ pip install browseraudio # in a normal environment
49
+ ```
50
+
51
+ In a browser kernel, install at runtime with micropip:
52
+
53
+ ```python
54
+ import micropip
55
+ await micropip.install("browseraudio")
56
+ ```
57
+
58
+ ## Use
59
+
60
+ The recording finishes *after* you click the button, so read the result in a
61
+ **separate cell** from the one that shows the widget:
62
+
63
+ ```python
64
+ from browseraudio import Recorder
65
+
66
+ rec = Recorder(duration=3.0)
67
+ rec # shows a "● Record 3s" button — click it, then speak
68
+ ```
69
+
70
+ After it captures, an inline player appears so you can hear the take. Then,
71
+ **in the next cell**, use the audio:
72
+
73
+ ```python
74
+ rec.samples # float32 ndarray, shape (n_frames, 1)
75
+ rec.sample_rate # e.g. 48000
76
+ ```
77
+
78
+ > Why two cells? A single-cell `await record()` can't work in Jupyter/thebe —
79
+ > the kernel doesn't process the widget's reply while that same cell is still
80
+ > running, so the recording would never arrive. Display in one cell, use it in
81
+ > the next.
82
+
83
+ With [pyquist](https://github.com/gclef-cmu/pyquist) installed, get an `Audio`
84
+ object directly:
85
+
86
+ ```python
87
+ clip = rec.to_pyquist() # pyquist.Audio
88
+ ```
89
+
90
+ ## How it works
91
+
92
+ ```
93
+ main thread (browser) worker (Python kernel)
94
+ ───────────────────── ──────────────────────
95
+ getUserMedia → AudioContext ──comm (base64 float32)──► numpy float32
96
+ (anywidget frontend) Recorder.samples
97
+ ```
98
+
99
+ The kernel→page direction (displaying the widget) and the page→kernel direction
100
+ (sending samples back) both ride the standard Jupyter widget comm, which works
101
+ in JupyterLite and thebe-lite.
102
+
103
+ ## Roadmap
104
+
105
+ - **Playback** — push a buffer to a main-thread `AudioContext` (`play`).
106
+ - **AudioWorklet backend** — replace the deprecated `ScriptProcessorNode` used
107
+ in v0.
108
+ - **sounddevice-compatible facade** — expose `play` / `rec` / `wait` /
109
+ `query_devices` so a library like pyquist works in the browser **unchanged**
110
+ (a real `sounddevice` replacement, not a stub).
111
+ - **Streaming** (stretch) — generator → ring buffer → AudioWorklet. Bounded by
112
+ the browser: Python can't run in the audio thread, and `SharedArrayBuffer`
113
+ needs cross-origin-isolation (COOP/COEP) headers.
114
+
115
+ ## Caveats
116
+
117
+ - Recording needs **HTTPS** (or `localhost`), a **user gesture** (the button),
118
+ and the browser's microphone-permission prompt.
119
+ - v0 transfers samples as base64 over the comm — simple and robust; binary comm
120
+ buffers would be more efficient.
121
+ - v0 uses `ScriptProcessorNode` (deprecated but universally supported).
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,99 @@
1
+ # browseraudio
2
+
3
+ Microphone capture for **in-browser Python** — Pyodide, JupyterLite, thebe-lite —
4
+ via the browser's Web Audio API.
5
+
6
+ When Python runs in the browser it usually lives in a **Web Worker**, where the
7
+ Web Audio API and `getUserMedia` don't exist and the DOM is out of reach. So
8
+ the classic audio stack (`sounddevice` → PortAudio) can't run, and libraries
9
+ that depend on it fall back to "not available" stubs. `browseraudio` bridges
10
+ that gap: it captures audio on the browser's **main thread** (through a tiny
11
+ [`anywidget`](https://anywidget.dev) frontend) and hands the samples back to the
12
+ Python kernel over the widget comm channel.
13
+
14
+ > **Status: v0 — recording only, and a foundation to build on.** The full
15
+ > round-trip is proven end-to-end under thebe-lite (Pyodide 0.27.7): a 1-second
16
+ > capture returned a float32 array, shape `(48000, 1)` at 48 kHz, with real
17
+ > signal. See the roadmap for what's next.
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ pip install browseraudio # in a normal environment
23
+ ```
24
+
25
+ In a browser kernel, install at runtime with micropip:
26
+
27
+ ```python
28
+ import micropip
29
+ await micropip.install("browseraudio")
30
+ ```
31
+
32
+ ## Use
33
+
34
+ The recording finishes *after* you click the button, so read the result in a
35
+ **separate cell** from the one that shows the widget:
36
+
37
+ ```python
38
+ from browseraudio import Recorder
39
+
40
+ rec = Recorder(duration=3.0)
41
+ rec # shows a "● Record 3s" button — click it, then speak
42
+ ```
43
+
44
+ After it captures, an inline player appears so you can hear the take. Then,
45
+ **in the next cell**, use the audio:
46
+
47
+ ```python
48
+ rec.samples # float32 ndarray, shape (n_frames, 1)
49
+ rec.sample_rate # e.g. 48000
50
+ ```
51
+
52
+ > Why two cells? A single-cell `await record()` can't work in Jupyter/thebe —
53
+ > the kernel doesn't process the widget's reply while that same cell is still
54
+ > running, so the recording would never arrive. Display in one cell, use it in
55
+ > the next.
56
+
57
+ With [pyquist](https://github.com/gclef-cmu/pyquist) installed, get an `Audio`
58
+ object directly:
59
+
60
+ ```python
61
+ clip = rec.to_pyquist() # pyquist.Audio
62
+ ```
63
+
64
+ ## How it works
65
+
66
+ ```
67
+ main thread (browser) worker (Python kernel)
68
+ ───────────────────── ──────────────────────
69
+ getUserMedia → AudioContext ──comm (base64 float32)──► numpy float32
70
+ (anywidget frontend) Recorder.samples
71
+ ```
72
+
73
+ The kernel→page direction (displaying the widget) and the page→kernel direction
74
+ (sending samples back) both ride the standard Jupyter widget comm, which works
75
+ in JupyterLite and thebe-lite.
76
+
77
+ ## Roadmap
78
+
79
+ - **Playback** — push a buffer to a main-thread `AudioContext` (`play`).
80
+ - **AudioWorklet backend** — replace the deprecated `ScriptProcessorNode` used
81
+ in v0.
82
+ - **sounddevice-compatible facade** — expose `play` / `rec` / `wait` /
83
+ `query_devices` so a library like pyquist works in the browser **unchanged**
84
+ (a real `sounddevice` replacement, not a stub).
85
+ - **Streaming** (stretch) — generator → ring buffer → AudioWorklet. Bounded by
86
+ the browser: Python can't run in the audio thread, and `SharedArrayBuffer`
87
+ needs cross-origin-isolation (COOP/COEP) headers.
88
+
89
+ ## Caveats
90
+
91
+ - Recording needs **HTTPS** (or `localhost`), a **user gesture** (the button),
92
+ and the browser's microphone-permission prompt.
93
+ - v0 transfers samples as base64 over the comm — simple and robust; binary comm
94
+ buffers would be more efficient.
95
+ - v0 uses `ScriptProcessorNode` (deprecated but universally supported).
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,16 @@
1
+ """browseraudio — microphone capture for in-browser Python (Pyodide / JupyterLite).
2
+
3
+ A small, framework-agnostic bridge from the browser's Web Audio API to Python
4
+ running in a WebAssembly kernel. The kernel often lives in a Web Worker, where
5
+ Web Audio and ``getUserMedia`` are unavailable; this library does the capture
6
+ on the main thread (via an ``anywidget`` frontend) and ships the samples back.
7
+
8
+ v0: recording only. See the README for the roadmap (playback, an AudioWorklet
9
+ backend, an async ``await record()`` API, and a sounddevice-compatible facade
10
+ so libraries like pyquist work unchanged in the browser).
11
+ """
12
+
13
+ from ._recorder import Recorder, record
14
+
15
+ __all__ = ["Recorder", "record"]
16
+ __version__ = "0.1.0"
@@ -0,0 +1,93 @@
1
+ """The microphone Recorder widget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import pathlib
7
+ from typing import Optional
8
+
9
+ import anywidget
10
+ import numpy as np
11
+ import traitlets
12
+
13
+ _STATIC = pathlib.Path(__file__).parent / "static"
14
+
15
+
16
+ class Recorder(anywidget.AnyWidget):
17
+ """A microphone recorder for in-browser Python (Pyodide / JupyterLite).
18
+
19
+ The audio is captured on the browser's main thread (where the Web Audio
20
+ API and ``getUserMedia`` live) and handed back to the Python kernel — so
21
+ it works even when the kernel runs in a Web Worker, as it does under
22
+ JupyterLite and thebe-lite.
23
+
24
+ Because the recording finishes *after* you click the button, read the
25
+ result in a separate cell from the one that displays the widget::
26
+
27
+ from browseraudio import Recorder
28
+ rec = Recorder(duration=3.0)
29
+ rec # shows a "Record" button — click it
30
+
31
+ # ...then, in a later cell:
32
+ rec.samples # float32 ndarray, shape (n_frames, 1)
33
+ rec.sample_rate # e.g. 48000
34
+
35
+ Attributes:
36
+ duration: Length to record, in seconds (default 3.0).
37
+ sample_rate: The browser AudioContext's rate; set after recording.
38
+ error: A message if the last attempt failed, else ``None``.
39
+ """
40
+
41
+ _esm = _STATIC / "recorder.js"
42
+
43
+ duration = traitlets.Float(3.0).tag(sync=True)
44
+ sample_rate = traitlets.Int(0).tag(sync=True)
45
+ # Internal sync state (the transport is base64 float32 over the comm).
46
+ _pcm_b64 = traitlets.Unicode("").tag(sync=True)
47
+ _error = traitlets.Unicode("").tag(sync=True)
48
+
49
+ def __init__(self, duration: float = 3.0, **kwargs):
50
+ super().__init__(duration=duration, **kwargs)
51
+ self._samples: Optional[np.ndarray] = None
52
+ self.error: Optional[str] = None
53
+ self.observe(self._on_pcm, names="_pcm_b64")
54
+ self.observe(self._on_error, names="_error")
55
+
56
+ def _on_pcm(self, change) -> None:
57
+ if not change["new"]:
58
+ return
59
+ raw = base64.b64decode(change["new"])
60
+ # `astype` makes a writable copy (frombuffer is read-only).
61
+ self._samples = np.frombuffer(raw, dtype="<f4").astype(np.float32).reshape(-1, 1)
62
+ self.error = None
63
+
64
+ def _on_error(self, change) -> None:
65
+ self.error = change["new"] or None
66
+
67
+ @property
68
+ def samples(self) -> Optional[np.ndarray]:
69
+ """The most recent recording as float32 ``(n_frames, 1)``, or ``None``."""
70
+ return self._samples
71
+
72
+ def to_pyquist(self):
73
+ """Return the recording as a :class:`pyquist.Audio` (requires pyquist)."""
74
+ if self._samples is None:
75
+ raise RuntimeError("Nothing recorded yet — click Record first.")
76
+ import pyquist as pq
77
+
78
+ return pq.Audio(self._samples, sample_rate=self.sample_rate)
79
+
80
+
81
+ def record(duration: float = 3.0) -> Recorder:
82
+ """Create, display, and return a :class:`Recorder`.
83
+
84
+ Click **Record**, then read ``.samples`` (or ``.to_pyquist()``) in a
85
+ *later* cell. (A single-cell ``await`` flow can't work in Jupyter/thebe:
86
+ the kernel doesn't process the widget-comm reply while the same cell is
87
+ still running, so the recording would never arrive — hence two cells.)
88
+ """
89
+ from IPython.display import display
90
+
91
+ rec = Recorder(duration=duration)
92
+ display(rec)
93
+ return rec
@@ -0,0 +1,131 @@
1
+ // recorder.js — anywidget frontend for browseraudio.Recorder.
2
+ //
3
+ // Runs on the MAIN thread, where the Web Audio API and getUserMedia live.
4
+ // The Python kernel that drives this widget may be in a Web Worker
5
+ // (JupyterLite / thebe-lite), which has no access to those APIs — so the
6
+ // capture happens here and the samples are handed back to Python over the
7
+ // widget comm channel (base64-encoded float32 PCM). After capturing, an
8
+ // inline <audio> player is shown so the user can hear the take immediately.
9
+ //
10
+ // v0 uses ScriptProcessorNode: deprecated but universally supported and
11
+ // dependency-free. A future version should move to an AudioWorklet.
12
+
13
+ function toBase64(buffer) {
14
+ const bytes = new Uint8Array(buffer);
15
+ let binary = "";
16
+ const CHUNK = 0x8000; // chunk to stay under the argument-count limit
17
+ for (let i = 0; i < bytes.length; i += CHUNK) {
18
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
19
+ }
20
+ return btoa(binary);
21
+ }
22
+
23
+ // Encode float32 [-1, 1] mono samples as a 16-bit PCM WAV blob (for the
24
+ // inline preview player only; Python receives the full-precision float32).
25
+ function wavBlob(samples, sampleRate) {
26
+ const n = samples.length;
27
+ const buffer = new ArrayBuffer(44 + n * 2);
28
+ const view = new DataView(buffer);
29
+ const writeStr = (offset, str) => {
30
+ for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
31
+ };
32
+ writeStr(0, "RIFF");
33
+ view.setUint32(4, 36 + n * 2, true);
34
+ writeStr(8, "WAVE");
35
+ writeStr(12, "fmt ");
36
+ view.setUint32(16, 16, true);
37
+ view.setUint16(20, 1, true); // PCM
38
+ view.setUint16(22, 1, true); // mono
39
+ view.setUint32(24, sampleRate, true);
40
+ view.setUint32(28, sampleRate * 2, true);
41
+ view.setUint16(32, 2, true);
42
+ view.setUint16(34, 16, true);
43
+ writeStr(36, "data");
44
+ view.setUint32(40, n * 2, true);
45
+ let offset = 44;
46
+ for (let i = 0; i < n; i++) {
47
+ const s = Math.max(-1, Math.min(1, samples[i]));
48
+ view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
49
+ offset += 2;
50
+ }
51
+ return new Blob([buffer], { type: "audio/wav" });
52
+ }
53
+
54
+ async function render({ model, el }) {
55
+ const button = document.createElement("button");
56
+ button.className = "jupyter-button";
57
+ const status = document.createElement("span");
58
+ status.style.marginLeft = "0.5em";
59
+ const player = document.createElement("div"); // holds the inline preview
60
+ player.style.marginTop = "0.5em";
61
+
62
+ const label = () => "● Record " + model.get("duration") + "s";
63
+ button.textContent = label();
64
+ model.on("change:duration", () => (button.textContent = label()));
65
+ el.append(button, status, player);
66
+
67
+ button.addEventListener("click", async () => {
68
+ button.disabled = true;
69
+ status.textContent = "recording…";
70
+ player.replaceChildren();
71
+ let stream;
72
+ try {
73
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
74
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
75
+ const ctx = new AudioCtx();
76
+ await ctx.resume();
77
+ const sampleRate = ctx.sampleRate;
78
+ const duration = model.get("duration");
79
+
80
+ const source = ctx.createMediaStreamSource(stream);
81
+ const processor = ctx.createScriptProcessor(4096, 1, 1);
82
+ const chunks = [];
83
+ let total = 0;
84
+
85
+ await new Promise((resolve) => {
86
+ processor.onaudioprocess = (event) => {
87
+ const input = event.inputBuffer.getChannelData(0);
88
+ chunks.push(new Float32Array(input));
89
+ total += input.length;
90
+ if (total >= duration * sampleRate) resolve();
91
+ };
92
+ source.connect(processor);
93
+ processor.connect(ctx.destination);
94
+ setTimeout(resolve, (duration + 1.5) * 1000); // safety: never hang
95
+ });
96
+
97
+ processor.disconnect();
98
+ source.disconnect();
99
+ ctx.close();
100
+
101
+ const pcm = new Float32Array(total);
102
+ let offset = 0;
103
+ for (const chunk of chunks) {
104
+ pcm.set(chunk, offset);
105
+ offset += chunk.length;
106
+ }
107
+
108
+ model.set("sample_rate", sampleRate);
109
+ model.set("_pcm_b64", total ? toBase64(pcm.buffer) : "");
110
+ if (!total) model.set("_error", "no audio was captured");
111
+ model.save_changes();
112
+ status.textContent = "recorded " + (total / sampleRate).toFixed(2) + "s";
113
+
114
+ if (total) {
115
+ const audio = document.createElement("audio");
116
+ audio.controls = true;
117
+ audio.src = URL.createObjectURL(wavBlob(pcm, sampleRate));
118
+ player.append(audio);
119
+ }
120
+ } catch (err) {
121
+ model.set("_error", String(err));
122
+ model.save_changes();
123
+ status.textContent = "error: " + err;
124
+ } finally {
125
+ if (stream) stream.getTracks().forEach((t) => t.stop());
126
+ button.disabled = false;
127
+ }
128
+ });
129
+ }
130
+
131
+ export default { render };
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: browseraudio
3
+ Version: 0.1.0
4
+ Summary: Microphone capture for in-browser Python (Pyodide / JupyterLite) via the Web Audio API.
5
+ Author: jiaweil6
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jiaweil6/browseraudio
8
+ Project-URL: Repository, https://github.com/jiaweil6/browseraudio
9
+ Project-URL: Issues, https://github.com/jiaweil6/browseraudio/issues
10
+ Keywords: pyodide,jupyterlite,web audio,microphone,anywidget,wasm
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Multimedia :: Sound/Audio
17
+ Classifier: Framework :: Jupyter
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: anywidget>=0.9
22
+ Requires-Dist: numpy
23
+ Provides-Extra: pyquist
24
+ Requires-Dist: pyquist; extra == "pyquist"
25
+ Dynamic: license-file
26
+
27
+ # browseraudio
28
+
29
+ Microphone capture for **in-browser Python** — Pyodide, JupyterLite, thebe-lite —
30
+ via the browser's Web Audio API.
31
+
32
+ When Python runs in the browser it usually lives in a **Web Worker**, where the
33
+ Web Audio API and `getUserMedia` don't exist and the DOM is out of reach. So
34
+ the classic audio stack (`sounddevice` → PortAudio) can't run, and libraries
35
+ that depend on it fall back to "not available" stubs. `browseraudio` bridges
36
+ that gap: it captures audio on the browser's **main thread** (through a tiny
37
+ [`anywidget`](https://anywidget.dev) frontend) and hands the samples back to the
38
+ Python kernel over the widget comm channel.
39
+
40
+ > **Status: v0 — recording only, and a foundation to build on.** The full
41
+ > round-trip is proven end-to-end under thebe-lite (Pyodide 0.27.7): a 1-second
42
+ > capture returned a float32 array, shape `(48000, 1)` at 48 kHz, with real
43
+ > signal. See the roadmap for what's next.
44
+
45
+ ## Install
46
+
47
+ ```sh
48
+ pip install browseraudio # in a normal environment
49
+ ```
50
+
51
+ In a browser kernel, install at runtime with micropip:
52
+
53
+ ```python
54
+ import micropip
55
+ await micropip.install("browseraudio")
56
+ ```
57
+
58
+ ## Use
59
+
60
+ The recording finishes *after* you click the button, so read the result in a
61
+ **separate cell** from the one that shows the widget:
62
+
63
+ ```python
64
+ from browseraudio import Recorder
65
+
66
+ rec = Recorder(duration=3.0)
67
+ rec # shows a "● Record 3s" button — click it, then speak
68
+ ```
69
+
70
+ After it captures, an inline player appears so you can hear the take. Then,
71
+ **in the next cell**, use the audio:
72
+
73
+ ```python
74
+ rec.samples # float32 ndarray, shape (n_frames, 1)
75
+ rec.sample_rate # e.g. 48000
76
+ ```
77
+
78
+ > Why two cells? A single-cell `await record()` can't work in Jupyter/thebe —
79
+ > the kernel doesn't process the widget's reply while that same cell is still
80
+ > running, so the recording would never arrive. Display in one cell, use it in
81
+ > the next.
82
+
83
+ With [pyquist](https://github.com/gclef-cmu/pyquist) installed, get an `Audio`
84
+ object directly:
85
+
86
+ ```python
87
+ clip = rec.to_pyquist() # pyquist.Audio
88
+ ```
89
+
90
+ ## How it works
91
+
92
+ ```
93
+ main thread (browser) worker (Python kernel)
94
+ ───────────────────── ──────────────────────
95
+ getUserMedia → AudioContext ──comm (base64 float32)──► numpy float32
96
+ (anywidget frontend) Recorder.samples
97
+ ```
98
+
99
+ The kernel→page direction (displaying the widget) and the page→kernel direction
100
+ (sending samples back) both ride the standard Jupyter widget comm, which works
101
+ in JupyterLite and thebe-lite.
102
+
103
+ ## Roadmap
104
+
105
+ - **Playback** — push a buffer to a main-thread `AudioContext` (`play`).
106
+ - **AudioWorklet backend** — replace the deprecated `ScriptProcessorNode` used
107
+ in v0.
108
+ - **sounddevice-compatible facade** — expose `play` / `rec` / `wait` /
109
+ `query_devices` so a library like pyquist works in the browser **unchanged**
110
+ (a real `sounddevice` replacement, not a stub).
111
+ - **Streaming** (stretch) — generator → ring buffer → AudioWorklet. Bounded by
112
+ the browser: Python can't run in the audio thread, and `SharedArrayBuffer`
113
+ needs cross-origin-isolation (COOP/COEP) headers.
114
+
115
+ ## Caveats
116
+
117
+ - Recording needs **HTTPS** (or `localhost`), a **user gesture** (the button),
118
+ and the browser's microphone-permission prompt.
119
+ - v0 transfers samples as base64 over the comm — simple and robust; binary comm
120
+ buffers would be more efficient.
121
+ - v0 uses `ScriptProcessorNode` (deprecated but universally supported).
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ browseraudio/__init__.py
5
+ browseraudio/_recorder.py
6
+ browseraudio.egg-info/PKG-INFO
7
+ browseraudio.egg-info/SOURCES.txt
8
+ browseraudio.egg-info/dependency_links.txt
9
+ browseraudio.egg-info/requires.txt
10
+ browseraudio.egg-info/top_level.txt
11
+ browseraudio/static/recorder.js
@@ -0,0 +1,5 @@
1
+ anywidget>=0.9
2
+ numpy
3
+
4
+ [pyquist]
5
+ pyquist
@@ -0,0 +1 @@
1
+ browseraudio
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "browseraudio"
7
+ version = "0.1.0"
8
+ description = "Microphone capture for in-browser Python (Pyodide / JupyterLite) via the Web Audio API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "jiaweil6" }]
13
+ keywords = ["pyodide", "jupyterlite", "web audio", "microphone", "anywidget", "wasm"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Education",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Multimedia :: Sound/Audio",
21
+ "Framework :: Jupyter",
22
+ ]
23
+ dependencies = [
24
+ "anywidget>=0.9",
25
+ "numpy",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/jiaweil6/browseraudio"
30
+ Repository = "https://github.com/jiaweil6/browseraudio"
31
+ Issues = "https://github.com/jiaweil6/browseraudio/issues"
32
+
33
+ [project.optional-dependencies]
34
+ pyquist = ["pyquist"]
35
+
36
+ [tool.setuptools]
37
+ packages = ["browseraudio"]
38
+
39
+ [tool.setuptools.package-data]
40
+ browseraudio = ["static/*.js"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+