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.
- browseraudio-0.1.0/LICENSE +21 -0
- browseraudio-0.1.0/PKG-INFO +125 -0
- browseraudio-0.1.0/README.md +99 -0
- browseraudio-0.1.0/browseraudio/__init__.py +16 -0
- browseraudio-0.1.0/browseraudio/_recorder.py +93 -0
- browseraudio-0.1.0/browseraudio/static/recorder.js +131 -0
- browseraudio-0.1.0/browseraudio.egg-info/PKG-INFO +125 -0
- browseraudio-0.1.0/browseraudio.egg-info/SOURCES.txt +11 -0
- browseraudio-0.1.0/browseraudio.egg-info/dependency_links.txt +1 -0
- browseraudio-0.1.0/browseraudio.egg-info/requires.txt +5 -0
- browseraudio-0.1.0/browseraudio.egg-info/top_level.txt +1 -0
- browseraudio-0.1.0/pyproject.toml +40 -0
- browseraudio-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|