Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
that don't show.

Start with the [root README](../README.md) for the template itself.
- [workers.md](workers.md): run your Python in Web Workers when the main thread has to stay free (Web MIDI, live audio)
78 changes: 78 additions & 0 deletions docs/workers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Python in Web Workers

By default the template runs your Python on the page's main thread, which
is simple and fine for most apps. When your app redraws a lot *and* has to
react on time, to Web MIDI, live audio or fast input, a long redraw
on the main thread delays everything else. The fix is to run your Python in
Web Workers and keep the page thin. `workers/` is a small kit for that:

| File | Runs on | Does |
|---|---|---|
| `workers/host.js` | the page | starts workers, hands a canvas to one of them and relays its input, relays Web MIDI, owns the `AudioContext` |
| `workers/python_worker.js` | each worker | loads Pyodide, installs your wheels, copies your files in, runs your module |
| `workers/audio_worklet.js` | the audio thread | plays the audio a worker sends, with a queue that adapts to the machine |

## A page that runs its app in a worker

```html
<canvas id="display_canvas"></canvas>
<script type="module">
import { startWorker } from "./workers/host.js";
const canvas = document.getElementById("display_canvas");
canvas.width = 800; canvas.height = 480;
startWorker({
name: "app", main: "main", canvas,
packages: ["vendor/wheels/pydevices-<version>-py3-none-any.whl"],
files: { "main.py": "main.py" },
});
</script>
```

In `main.py`, use the worker board config and everything else works as on
the page, including LVGL's `display_driver`:

```python
import board_config # pydevices' board_configs/workerdisplay, copied in as board_config.py
import appdev
app = appdev.App(board_config)
```

`files` maps a URL (relative to the site root) to a path in the worker's
file system; that's how `board_config.py` and `board_peripherals.py` from
`board_configs/workerdisplay` get in. Pointer, wheel and key events on the
canvas reach `appdev` exactly as `PSDisplay` delivers them.

## Sound

```js
import { createAudio, resumeOnGesture } from "./workers/host.js";
const audio = await createAudio({ channels: 1 });
startWorker({ ..., ports: { audio: audio.workerPort } });
resumeOnGesture(audio.context); // browsers start audio on the first click or key
```

In the worker, `from js import ports` and hand `ports.audio` to
`audiodev.worklet_audio` (pydevices): set `WorkletOutput(ports.audio).source`
to a function that renders blocks, or use its `pcm_out(port=ports.audio)` like
any other audio output. The worklet keeps about 32 ms queued at first and
works down to about 16 ms when the machine keeps up, backing off after any
dropout.

## Web MIDI

```js
import { createMidiRelay } from "./workers/host.js";
const midi = createMidiRelay();
startWorker({ ..., ports: { midi: midi.port() } });
```

In the worker, `midiports.webmidi.relay_ports(ports.midi)` gives
`adafruit_midi.MIDI` its ports. Every input is relayed with its timestamp,
and what you write goes to the page's outputs. Call `midi.port()` once per
worker that should hear MIDI.

## Serving it

Workers load as modules and Pyodide fetches its files, so serve the site
over HTTP (`python -m http.server`), as for the rest of the template. The
service worker caches the kit like any other file once it is listed.
158 changes: 158 additions & 0 deletions workers/audio_worklet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// The AudioWorklet end of a Python worker's audio (audiodev.worklet_audio).
//
// It plays 256-frame blocks of int16 PCM the worker sends and asks for more
// over a MessagePort, keeping a queue of `target` frames:
//
// worklet -> worker {need: blocks, asked: frame} send that many blocks
// worker -> worklet {pcm: Int16Array, asked} (transferred, interleaved)
// worklet -> worker {offset} rendered frame k plays at
// context frame k + offset
// worklet -> worker {underrun: count} underruns since the last one
//
// The queue adapts: it starts at `startFrames`, steps down 256 frames at a
// time towards `minFrames` while its low point stays above 512 frames for two
// seconds, and steps up 512 frames (to at most `maxFrames`) on any underrun,
// then holds for ten seconds. The browser's audio thread asks for audio about
// every 10 ms, so a queue much under 768 frames can't keep up.
//
// The node's own port (to the page) answers {type: "stats"} and, when a test
// asks with {type: "tap", on: true}, posts a copy of every frame played.

const BLOCK = 256;

class PythonWorkerOutput extends AudioWorkletProcessor {
constructor(options) {
super();
const o = options.processorOptions || {};
this.channels = o.channels || 1;
this.minFrames = o.minFrames || 768;
this.maxFrames = o.maxFrames || 4096;
this.target = o.startFrames || 1536;
this.queue = [];
this.head = 0; // sample index into queue[0]
this.queued = 0; // frames queued
this.requested = 0; // frames asked for, not yet here
this.played = 0; // queued frames played so far
this.offset = null;
this.worker = null;
this.started = false;
this.underruns = 0;
this.windowLow = Infinity;
this.windowStart = 0;
this.holdUntil = 0;
this.low = Infinity; // low point since the last stats request
this.tap = null;
this.tapAt = 0;
this.tapStart = 0;
this.port.onmessage = (e) => this.onPage(e);
}

onPage(e) {
const m = e.data;
if (m.type === "worker") {
this.worker = e.ports[0];
this.worker.onmessage = (ev) => {
const pcm = ev.data.pcm;
if (!pcm) return;
const frames = pcm.length / this.channels;
this.queue.push(pcm);
this.queued += frames;
this.requested = Math.max(0, this.requested - frames);
};
this.ask();
} else if (m.type === "stats") {
this.port.postMessage({ type: "stats", underruns: this.underruns, target: this.target,
queued: this.queued, low: this.low, frame: currentFrame });
this.low = Infinity;
} else if (m.type === "tap") {
this.tap = m.on ? new Float32Array(Math.floor(sampleRate / 2) * this.channels) : null;
this.tapAt = 0;
}
}

ask() {
const short = this.target - this.queued - this.requested;
if (short > 0 && this.worker) {
const blocks = Math.ceil(short / BLOCK);
this.requested += blocks * BLOCK;
this.worker.postMessage({ need: blocks, asked: currentFrame });
}
}

adapt(underran) {
const now = currentFrame;
if (underran) {
this.target = Math.min(this.maxFrames, this.target + 512);
this.holdUntil = now + 10 * sampleRate;
this.windowStart = now;
this.windowLow = Infinity;
return;
}
if (this.queued < this.windowLow) this.windowLow = this.queued;
if (now - this.windowStart >= 2 * sampleRate) {
if (now >= this.holdUntil && this.windowLow > 512 && this.target > this.minFrames) {
this.target = Math.max(this.minFrames, this.target - BLOCK);
}
this.windowStart = now;
this.windowLow = Infinity;
}
}

process(inputs, outputs) {
const out = outputs[0];
const ch = this.channels;
const frames = out[0].length;
let i = 0;
while (i < frames && this.queue.length) {
const blk = this.queue[0];
const n = Math.min(frames - i, (blk.length - this.head) / ch);
for (let k = 0; k < n; k++) {
for (let c = 0; c < ch; c++) out[c][i + k] = blk[this.head + k * ch + c] / 32768;
}
i += n;
this.head += n * ch;
this.queued -= n;
if (this.head >= blk.length) { this.queue.shift(); this.head = 0; }
}
let underran = false;
if (i < frames) {
for (let c = 0; c < out.length; c++) out[c].fill(0, i);
if (this.started) {
underran = true;
this.underruns++;
if (this.worker) this.worker.postMessage({ underrun: 1 });
}
} else {
this.started = true;
}
this.played += i;
if (this.started) {
const offset = currentFrame + frames - this.played;
if (offset !== this.offset && this.worker) {
this.offset = offset;
this.worker.postMessage({ offset });
}
if (this.queued < this.low) this.low = this.queued;
this.adapt(underran);
}
if (this.tap) this.record(out, frames);
this.ask();
return true;
}

record(out, frames) {
if (this.tapAt === 0) this.tapStart = currentFrame;
const ch = this.channels;
for (let k = 0; k < frames; k++) {
for (let c = 0; c < ch; c++) this.tap[(this.tapAt + k) * ch + c] = out[c][k];
}
this.tapAt += frames;
if ((this.tapAt + frames) * ch > this.tap.length) {
const chunk = this.tap.slice(0, this.tapAt * ch);
this.port.postMessage({ type: "tap", start: this.tapStart, channels: ch, pcm: chunk }, [chunk.buffer]);
this.tapAt = 0;
}
}
}

registerProcessor("python-worker-output", PythonWorkerOutput);
Loading
Loading