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
7 changes: 7 additions & 0 deletions .github/workflows/clean-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,13 @@ jobs:
done
run pump_probe.py
run route_probe.py
# CircuitPython's audiopump.Ring is checked on a CircuitPython build
# (tests/circuitpython/). The same file runs here against MicroPython's
# Ring, so a producer written for one is known to run on the other.
- name: The Ring through audiomixer, the same producer as on CircuitPython
run: |
timeout 300 ./micropython/ports/unix/build-standard/micropython \
audiodsp/tests/circuitpython/ring_through_mixer.py
# Make recompiles nothing for a changed -D, so the revision once stayed
# that of a build directory's first build (audiodsp#156). Move HEAD,
# build again in the same directory, and the firmware must follow.
Expand Down
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,50 @@ effects modules stay CircuitPython's. `audiometer` (band levels for a spectrum
meter, below) is ours as well, and is the one CircuitPython doesn't get: its
sources are a board's pump and usbif's sound card, and CircuitPython has
neither. `audiovorbis` (below) plays Ogg Vorbis files, which CircuitPython
has no module for.
has no module for. And `audiopump.Ring` (below) lets CircuitPython play audio
your own code computes.

### `audiopump.Ring` on CircuitPython — audio your code computes

CircuitPython's audio outputs play samples, and a sample is filled from C, so
until now there was no way to play a stream your Python code or a graph of
nodes renders as it goes. A `Ring` is a sample you write into. The output
pulls it in C, block by block, while your loop keeps it topped up:

```python
import audiobusio, board
from array import array
from audiopump import Ring

ring = Ring(sample_rate=22050, channel_count=1, frames=256, capacity=16)
block = array("h", bytes(512)) # 256 mono frames
while ring.space() >= len(block) * 2: # fill it before play()
ring.write(render(block))
out = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WCLK, board.I2S_DOUT)
out.play(ring)
while True:
while ring.space() >= len(block) * 2:
ring.write(render(block))
```

It plays through `audiobusio.I2SOut`, `audioio.AudioOut`,
`audiopwmio.PWMAudioOut` and an `audiomixer` voice like any other sample.
When your code falls behind, the output plays a block of silence rather than
stopping, and `ring.stats()[3]` counts those underruns; a write that doesn't
fit is short and returns the bytes it took. `capacity` is how many blocks the
ring holds, which is also its latency.

Size the ring for what your output takes at once, and fill it before
`play()`. On ESP32 boards `I2SOut` takes about 3,000 frames the moment it
starts and about 1,000 at a time after that, so a ring smaller than about
4,000 frames underruns at the start however fast you write. 256-frame blocks
with `capacity=16` (186 ms at 22,050 Hz) play a tone written from Python for
30 s on an ESP32-S3 with no underruns.

It is the same `audiopump.Ring` MicroPython's pump pulls, with the same
methods (`write`, `space`, `level`, `stats`, `clear`, `deinit`), so one
producer runs on both. On CircuitPython the module carries only `Ring`: the
board's output is the pump.

### `audiovorbis.VorbisDecoder` — Ogg Vorbis files

Expand Down Expand Up @@ -771,9 +814,10 @@ while playing: # from your app's timer, say every 20 ms
their own tick. What CPython does not have is `pull()` under a heap lock,
because it has no heap lock, and audiocore's pull-depth cap: a graph that
leads back into itself is caught at Python's recursion limit instead, and
reported as the same fault. **CircuitPython does not carry it**: its playback
reported as the same fault. **CircuitPython carries only `Ring`**: its playback
layer already pulls natively from its own audio thread, and a second pull loop
would be a second owner of the same graph. It still takes the lock and the
would be a second owner of the same graph, so there a board's audio output
pulls the `Ring` itself ([above](#audiopumpring-on-circuitpython--audio-your-code-computes)). It still takes the lock and the
hook table, on default hooks, which cost a load and a branch.

The hardware — the channel a board writes into, and a live microphone as a
Expand Down
2 changes: 1 addition & 1 deletion circuitpython.mk
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ CFLAGS_USERMOD += -I$(AUDIODSP_CP_SPIKE) -I$(AUDIODSP_CP_SRC)

AUDIODSP_CP_MODULES := audiodynamics audioroute audiomath audioecho audioshaper \
audioladder audiooscillator audioconvolve audiobiquad audioverb audiomodal \
audiosampler audiovorbis
audiosampler audiovorbis audiopump

AUDIODSP_CP_SOURCES := \
$(foreach m,$(AUDIODSP_CP_MODULES),$(sort $(wildcard $(AUDIODSP_CP_SPIKE)/shared-bindings/$(m)/*.c))) \
Expand Down
202 changes: 202 additions & 0 deletions src/circuitpython_spike/shared-bindings/audiopump/Ring.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// audiopump.Ring bindings for CircuitPython.
//
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (c) 2026 PyDevices

#include <stdint.h>

#include "shared-bindings/audiopump/Ring.h"
#include "shared-bindings/audiocore/__init__.h"

#include "py/runtime.h"

//| class Ring:
//| """A stream your code writes PCM into, played by CircuitPython's own
//| audio outputs.
//|
//| A Ring is an audio sample: hand it to `audiobusio.I2SOut.play()`,
//| `audioio.AudioOut.play()` or an `audiomixer.MixerVoice`, and the output
//| pulls blocks from it in C while your code keeps it filled. Signed
//| 16-bit, interleaved. A pull that finds less than a block plays a
//| block of silence, keeps what it has, and counts an underrun.
//|
//| The same class as MicroPython's ``audiopump.Ring``, so a producer
//| written against one runs on the other::
//|
//| import audiobusio, board
//| from audiopump import Ring
//|
//| ring = Ring(sample_rate=22050, channel_count=2, frames=256, capacity=16)
//| while ring.space() >= len(block): # fill it before play()
//| ring.write(render(block))
//| out = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WCLK, board.I2S_DOUT)
//| out.play(ring)
//| while True:
//| while ring.space() >= len(block):
//| ring.write(render(block))
//|
//| ``play()`` pulls at once, and an output may take several blocks at a
//| time: on ESP32 boards `audiobusio.I2SOut` takes about 3,000 frames when
//| it starts and about 1,000 at a time after. Make the ring larger than
//| that and fill it before ``play()``, or the first pulls count as
//| underruns."""
//|
//| def __init__(
//| self,
//| *,
//| sample_rate: int = 48000,
//| channel_count: int = 2,
//| frames: int = 256,
//| capacity: int = 4,
//| ) -> None:
//| """Create a ring of ``capacity`` blocks of ``frames`` frames each.
//|
//| ``frames`` is the block the output pulls; ``capacity`` is how many
//| of them the ring holds, which is also its latency. ``channel_count``
//| is 1 or 2, and ``capacity`` is at least 2."""
//| ...
static mp_obj_t audiopump_ring_make_new(const mp_obj_type_t *type,
size_t n_args, size_t n_kw, const mp_obj_t *all_args) {
enum { ARG_rate, ARG_channels, ARG_frames, ARG_capacity };
static const mp_arg_t allowed[] = {
{ MP_QSTR_sample_rate, MP_ARG_INT | MP_ARG_KW_ONLY, { .u_int = 48000 } },
{ MP_QSTR_channel_count, MP_ARG_INT | MP_ARG_KW_ONLY, { .u_int = 2 } },
{ MP_QSTR_frames, MP_ARG_INT | MP_ARG_KW_ONLY, { .u_int = 256 } },
{ MP_QSTR_capacity, MP_ARG_INT | MP_ARG_KW_ONLY, { .u_int = 4 } },
};
mp_arg_val_t args[MP_ARRAY_SIZE(allowed)];
mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed),
allowed, args);

const mp_int_t rate = args[ARG_rate].u_int;
const mp_int_t channels = args[ARG_channels].u_int;
const mp_int_t frames = args[ARG_frames].u_int;
const mp_int_t capacity = args[ARG_capacity].u_int;
if (rate < 1 || channels < 1 || channels > 2 || frames < 1 || capacity < 2) {
mp_raise_ValueError(
MP_ERROR_TEXT("bad channel_count, frames or capacity"));
}
// 2^31 is where `w - r` stops being the level; this only has to be far
// below it.
if ((uint64_t)frames * (uint64_t)channels * 2ULL * (uint64_t)capacity > (1ULL << 24)) {
mp_raise_ValueError(MP_ERROR_TEXT("ring too large"));
}

audiopump_ring_obj_t *self = mp_obj_malloc(audiopump_ring_obj_t, &audiopump_ring_type);
audiopump_ring_construct(self, (uint32_t)rate, (uint32_t)channels,
(uint32_t)frames, (uint32_t)capacity);
return MP_OBJ_FROM_PTR(self);
}

//| def write(self, buf: circuitpython_typing.ReadableBuffer) -> int:
//| """Copy in as many whole frames of ``buf`` as fit and return the
//| number of bytes taken. Never blocks: a write that can't take it all
//| is short, and counts an overrun."""
//| ...
static mp_obj_t audiopump_ring_obj_write(mp_obj_t self_in, mp_obj_t buf_in) {
audiopump_ring_obj_t *self = MP_OBJ_TO_PTR(self_in);
audiosample_check_for_deinit(&self->base);
mp_buffer_info_t info;
mp_get_buffer_raise(buf_in, &info, MP_BUFFER_READ);
return MP_OBJ_NEW_SMALL_INT(audiopump_ring_write(self, info.buf, (uint32_t)info.len));
}
static MP_DEFINE_CONST_FUN_OBJ_2(audiopump_ring_write_obj, audiopump_ring_obj_write);

//| def space(self) -> int:
//| """Bytes a `write()` would take now, in whole frames."""
//| ...
static mp_obj_t audiopump_ring_obj_space(mp_obj_t self_in) {
audiopump_ring_obj_t *self = MP_OBJ_TO_PTR(self_in);
audiosample_check_for_deinit(&self->base);
return MP_OBJ_NEW_SMALL_INT(audiopump_ring_space(self));
}
static MP_DEFINE_CONST_FUN_OBJ_1(audiopump_ring_space_obj, audiopump_ring_obj_space);

//| def level(self) -> int:
//| """Bytes queued and not yet pulled."""
//| ...
static mp_obj_t audiopump_ring_obj_level(mp_obj_t self_in) {
audiopump_ring_obj_t *self = MP_OBJ_TO_PTR(self_in);
audiosample_check_for_deinit(&self->base);
return MP_OBJ_NEW_SMALL_INT(audiopump_ring_level(self));
}
static MP_DEFINE_CONST_FUN_OBJ_1(audiopump_ring_level_obj, audiopump_ring_obj_level);

//| def stats(self) -> tuple[int, int, int, int, int, int, int, int, int, int]:
//| """``(wrote, read, starved, underruns, overruns, pulls, in_digest,
//| out_digest, capacity, block_size)``.
//|
//| Byte counts, then the underrun, overrun and pull counts, then an
//| FNV-1a 64 digest of every byte written and of every real byte
//| pulled (silence is left out), then the ring's capacity and block in
//| bytes. After the ring is drained, ``wrote == read`` and the two
//| digests match exactly when the output played what was written, in
//| order."""
//| ...
static mp_obj_t audiopump_ring_obj_stats(mp_obj_t self_in) {
audiopump_ring_obj_t *self = MP_OBJ_TO_PTR(self_in);
mp_obj_t items[10] = {
mp_obj_new_int_from_ull(self->wrote),
mp_obj_new_int_from_ull(self->read),
mp_obj_new_int_from_ull(self->starved),
mp_obj_new_int_from_uint(self->underruns),
mp_obj_new_int_from_uint(self->overruns),
mp_obj_new_int_from_uint(self->pulls),
mp_obj_new_int_from_ull(self->in_digest),
mp_obj_new_int_from_ull(self->out_digest),
mp_obj_new_int_from_uint(self->cap),
mp_obj_new_int_from_uint(self->block),
};
return mp_obj_new_tuple(10, items);
}
static MP_DEFINE_CONST_FUN_OBJ_1(audiopump_ring_stats_obj, audiopump_ring_obj_stats);

//| def clear(self) -> None:
//| """Drop what is queued. The output stops hearing it from its next
//| pull, and `space()` counts the room at once."""
//| ...
static mp_obj_t audiopump_ring_obj_clear(mp_obj_t self_in) {
audiopump_ring_obj_t *self = MP_OBJ_TO_PTR(self_in);
audiosample_check_for_deinit(&self->base);
audiopump_ring_clear(self);
return mp_const_none;
}
static MP_DEFINE_CONST_FUN_OBJ_1(audiopump_ring_clear_obj, audiopump_ring_obj_clear);

//| def deinit(self) -> None:
//| """Mark the ring deinitialised. An output still playing it stops at
//| its next pull."""
//| ...
//|
static mp_obj_t audiopump_ring_obj_deinit(mp_obj_t self_in) {
audiopump_ring_obj_t *self = MP_OBJ_TO_PTR(self_in);
audiosample_mark_deinit(&self->base);
return mp_const_none;
}
static MP_DEFINE_CONST_FUN_OBJ_1(audiopump_ring_deinit_obj, audiopump_ring_obj_deinit);

static const mp_rom_map_elem_t audiopump_ring_locals_dict_table[] = {
{ MP_ROM_QSTR(MP_QSTR_write), MP_ROM_PTR(&audiopump_ring_write_obj) },
{ MP_ROM_QSTR(MP_QSTR_space), MP_ROM_PTR(&audiopump_ring_space_obj) },
{ MP_ROM_QSTR(MP_QSTR_level), MP_ROM_PTR(&audiopump_ring_level_obj) },
{ MP_ROM_QSTR(MP_QSTR_stats), MP_ROM_PTR(&audiopump_ring_stats_obj) },
{ MP_ROM_QSTR(MP_QSTR_clear), MP_ROM_PTR(&audiopump_ring_clear_obj) },
{ MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&audiopump_ring_deinit_obj) },
AUDIOSAMPLE_FIELDS,
};
static MP_DEFINE_CONST_DICT(audiopump_ring_locals_dict, audiopump_ring_locals_dict_table);

static const audiosample_p_t audiopump_ring_proto = {
MP_PROTO_IMPLEMENT(MP_QSTR_protocol_audiosample)
.reset_buffer = (audiosample_reset_buffer_fun)audiopump_ring_reset_buffer,
.get_buffer = (audiosample_get_buffer_fun)audiopump_ring_get_buffer,
};

MP_DEFINE_CONST_OBJ_TYPE(
audiopump_ring_type,
MP_QSTR_Ring,
MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS,
make_new, audiopump_ring_make_new,
locals_dict, &audiopump_ring_locals_dict,
protocol, &audiopump_ring_proto
);
7 changes: 7 additions & 0 deletions src/circuitpython_spike/shared-bindings/audiopump/Ring.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: MIT

#pragma once

#include "shared-module/audiopump/Ring.h"

extern const mp_obj_type_t audiopump_ring_type;
36 changes: 36 additions & 0 deletions src/circuitpython_spike/shared-bindings/audiopump/__init__.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// audiopump module table for CircuitPython.
//
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (c) 2026 PyDevices

#include "py/obj.h"
#include "py/runtime.h"

#include "shared-bindings/audiopump/__init__.h"
#include "shared-bindings/audiopump/Ring.h"

//| """A stream sample your code fills, played by CircuitPython's audio outputs
//|
//| The `audiopump` module has `Ring`: an audio sample you write PCM into from
//| Python, which `audiobusio`, `audioio`, `audiopwmio` and `audiomixer` pull
//| in C as they would any other sample. It lets audio computed in Python, or
//| rendered by a graph of audio nodes, play without a gap. It is not part of
//| CircuitPython upstream; it comes from PyDevices' audiodsp.
//|
//| On MicroPython, audiodsp's `audiopump` also runs the pull loop itself.
//| CircuitPython's outputs already pull, so here the module carries only
//| `Ring`, and code written against `audiopump.Ring` runs on both.
//| """

static const mp_rom_map_elem_t audiopump_module_globals_table[] = {
{ MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_audiopump) },
{ MP_ROM_QSTR(MP_QSTR_Ring), MP_ROM_PTR(&audiopump_ring_type) },
};
static MP_DEFINE_CONST_DICT(audiopump_module_globals, audiopump_module_globals_table);

const mp_obj_module_t audiopump_module = {
.base = { &mp_type_module },
.globals = (mp_obj_dict_t *)&audiopump_module_globals,
};

MP_REGISTER_MODULE(MP_QSTR_audiopump, audiopump_module);
3 changes: 3 additions & 0 deletions src/circuitpython_spike/shared-bindings/audiopump/__init__.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: MIT

#pragma once
Loading
Loading