From 74c0b0595d643815ddc0cfb6063f08fe4b01b7b2 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:09:09 -0500 Subject: [PATCH 1/3] audiopump.Ring on CircuitPython: a stream sample Python fills and the board's outputs pull --- circuitpython.mk | 2 +- .../shared-bindings/audiopump/Ring.c | 198 +++++++++++++ .../shared-bindings/audiopump/Ring.h | 7 + .../shared-bindings/audiopump/__init__.c | 36 +++ .../shared-bindings/audiopump/__init__.h | 3 + .../shared-module/audiopump/Ring.c | 239 ++++++++++++++++ .../shared-module/audiopump/Ring.h | 100 +++++++ tests/circuitpython/ring_through_mixer.py | 262 ++++++++++++++++++ 8 files changed, 846 insertions(+), 1 deletion(-) create mode 100644 src/circuitpython_spike/shared-bindings/audiopump/Ring.c create mode 100644 src/circuitpython_spike/shared-bindings/audiopump/Ring.h create mode 100644 src/circuitpython_spike/shared-bindings/audiopump/__init__.c create mode 100644 src/circuitpython_spike/shared-bindings/audiopump/__init__.h create mode 100644 src/circuitpython_spike/shared-module/audiopump/Ring.c create mode 100644 src/circuitpython_spike/shared-module/audiopump/Ring.h create mode 100644 tests/circuitpython/ring_through_mixer.py diff --git a/circuitpython.mk b/circuitpython.mk index b41fb94f..2020854e 100644 --- a/circuitpython.mk +++ b/circuitpython.mk @@ -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))) \ diff --git a/src/circuitpython_spike/shared-bindings/audiopump/Ring.c b/src/circuitpython_spike/shared-bindings/audiopump/Ring.c new file mode 100644 index 00000000..2697d282 --- /dev/null +++ b/src/circuitpython_spike/shared-bindings/audiopump/Ring.c @@ -0,0 +1,198 @@ +// audiopump.Ring bindings for CircuitPython. +// +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2026 PyDevices + +#include + +#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=8) +//| ring.write(first_blocks) # fill before play() +//| out = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WCLK, board.I2S_DOUT) +//| out.play(ring) +//| while True: +//| if ring.space() >= len(block): +//| ring.write(render(block)) +//| +//| ``play()`` pulls its first block at once, so write at least one block +//| before it, or that first pull counts as an underrun.""" +//| +//| 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 + ); diff --git a/src/circuitpython_spike/shared-bindings/audiopump/Ring.h b/src/circuitpython_spike/shared-bindings/audiopump/Ring.h new file mode 100644 index 00000000..92ddf5a8 --- /dev/null +++ b/src/circuitpython_spike/shared-bindings/audiopump/Ring.h @@ -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; diff --git a/src/circuitpython_spike/shared-bindings/audiopump/__init__.c b/src/circuitpython_spike/shared-bindings/audiopump/__init__.c new file mode 100644 index 00000000..429456f1 --- /dev/null +++ b/src/circuitpython_spike/shared-bindings/audiopump/__init__.c @@ -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); diff --git a/src/circuitpython_spike/shared-bindings/audiopump/__init__.h b/src/circuitpython_spike/shared-bindings/audiopump/__init__.h new file mode 100644 index 00000000..05e767e3 --- /dev/null +++ b/src/circuitpython_spike/shared-bindings/audiopump/__init__.h @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MIT + +#pragma once diff --git a/src/circuitpython_spike/shared-module/audiopump/Ring.c b/src/circuitpython_spike/shared-module/audiopump/Ring.c new file mode 100644 index 00000000..5f047317 --- /dev/null +++ b/src/circuitpython_spike/shared-module/audiopump/Ring.c @@ -0,0 +1,239 @@ +// audiopump.Ring for CircuitPython: the ring and its pull. See Ring.h. +// +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2026 PyDevices + +#include +#include + +#include "py/runtime.h" + +#include "shared-module/audiopump/Ring.h" + +#define RING_FNV_OFFSET (0xcbf29ce484222325ULL) +#define RING_FNV_PRIME (0x100000001b3ULL) + +// Acquire and release rather than bare `volatile`: `volatile` orders the +// compiler and not the machine. The producer's release of `w` publishes the +// bytes it copied in; the consumer's release of `r` gives back the room only +// after the bytes are copied out. +#if defined(__GNUC__) || defined(__clang__) +#define RING_LOAD_ACQ(p) __atomic_load_n((p), __ATOMIC_ACQUIRE) +#define RING_STORE_REL(p, v) __atomic_store_n((p), (v), __ATOMIC_RELEASE) +#else +#define RING_LOAD_ACQ(p) (*(volatile uint32_t *)(p)) +#define RING_STORE_REL(p, v) (*(volatile uint32_t *)(p) = (v)) +#endif + +void audiopump_ring_construct(audiopump_ring_obj_t *self, + uint32_t sample_rate, uint32_t channel_count, uint32_t frames, + uint32_t capacity) { + self->frame = channel_count * 2; + self->block = frames * self->frame; + self->cap = self->block * capacity; + self->ring = m_new(uint8_t, self->cap); + self->out[0] = m_new(uint8_t, self->block); + self->out[1] = m_new(uint8_t, self->block); + memset(self->ring, 0, self->cap); + memset(self->out[0], 0, self->block); + memset(self->out[1], 0, self->block); + self->which = 0; + self->last = 0; + self->w = 0; + self->r = 0; + self->wpos = 0; + self->rpos = 0; + self->clear_w = 0; + self->clear_seq = 0; + self->clear_done = 0; + self->left_reads = 0; + self->right_reads = 0; + self->underruns = 0; + self->overruns = 0; + self->pulls = 0; + self->wrote = 0; + self->read = 0; + self->starved = 0; + self->in_digest = RING_FNV_OFFSET; + self->out_digest = RING_FNV_OFFSET; + + self->base.sample_rate = sample_rate; + self->base.max_buffer_length = self->block; + self->base.bits_per_sample = 16; + self->base.channel_count = (uint8_t)channel_count; + self->base.samples_signed = true; + self->base.single_buffer = false; +} + +// --- the producer side: the interpreter ------------------------------------ + +// Where the producer may count the consumer as having read to. A clear the +// consumer has not carried out yet has already dropped everything up to +// clear_w, so the room is counted from there. Read in this order, both +// answers can only be behind the consumer, never ahead of it, so the room is +// never overstated. +static uint32_t ring_producer_r(audiopump_ring_obj_t *self) { + const uint32_t r = RING_LOAD_ACQ(&self->r); + if (RING_LOAD_ACQ(&self->clear_done) != self->clear_seq) { + return self->clear_w; + } + return r; +} + +uint32_t audiopump_ring_write(audiopump_ring_obj_t *self, + const uint8_t *src, uint32_t len) { + const uint32_t w = self->w; + const uint32_t room = self->cap - (w - ring_producer_r(self)); + uint32_t n = len; + if (n > room) { + n = room; + self->overruns++; + } + n -= n % self->frame; + if (n == 0) { + return 0; + } + const uint32_t at = self->wpos; + uint32_t first = self->cap - at; + if (first > n) { + first = n; + } + memcpy(self->ring + at, src, first); + if (n > first) { + memcpy(self->ring, src + first, n - first); + } + uint64_t digest = self->in_digest; + for (uint32_t i = 0; i < n; i++) { + digest ^= src[i]; + digest *= RING_FNV_PRIME; + } + self->in_digest = digest; + self->wrote += n; + self->wpos = (at + n) % self->cap; + // Release, and last: the bytes are in before they are claimed to be. + RING_STORE_REL(&self->w, w + n); + return n; +} + +uint32_t audiopump_ring_space(audiopump_ring_obj_t *self) { + const uint32_t room = self->cap - (self->w - ring_producer_r(self)); + return room - (room % self->frame); +} + +uint32_t audiopump_ring_level(audiopump_ring_obj_t *self) { + return self->w - ring_producer_r(self); +} + +// Drops what is queued, by asking the consumer to. One word says where to +// drop to, so a pull that lands in the middle of a second clear() still finds +// a whole one: any value clear_w has held is a point the producer has written +// to. +void audiopump_ring_clear(audiopump_ring_obj_t *self) { + self->clear_w = self->w; + RING_STORE_REL(&self->clear_seq, self->clear_seq + 1); +} + +// --- the consumer side: the output's fill ----------------------------------- +// +// Nothing in here allocates or raises: it runs from a background callback or +// an interrupt. + +static void ring_take_clear(audiopump_ring_obj_t *self) { + const uint32_t seq = RING_LOAD_ACQ(&self->clear_seq); + if (seq == self->clear_done) { + return; + } + const uint32_t r = self->r; + const uint32_t to = self->clear_w; + const uint32_t w = RING_LOAD_ACQ(&self->w); + // A drop point is between r and w; anything else is a value from before + // this pull's r moved on, and dropping to it would go backwards. + if (to - r <= w - r) { + self->rpos = (self->rpos + (to - r)) % self->cap; + RING_STORE_REL(&self->r, to); + } + RING_STORE_REL(&self->clear_done, seq); +} + +static uint8_t *ring_pull_block(audiopump_ring_obj_t *self) { + ring_take_clear(self); + + const uint8_t index = self->which; + uint8_t *dst = self->out[index]; + self->which ^= 1; + self->last = index; + + const uint32_t block = self->block; + const uint32_t r = self->r; + const uint32_t level = RING_LOAD_ACQ(&self->w) - r; + + if (level < block) { + // A whole block of silence, and what is in the ring stays there: + // playing part of a block early only moves the hole. + memset(dst, 0, block); + self->underruns++; + self->starved += block; + } else { + const uint32_t at = self->rpos; + uint32_t first = self->cap - at; + if (first > block) { + first = block; + } + memcpy(dst, self->ring + at, first); + if (block > first) { + memcpy(dst + first, self->ring, block - first); + } + uint64_t digest = self->out_digest; + for (uint32_t i = 0; i < block; i++) { + digest ^= dst[i]; + digest *= RING_FNV_PRIME; + } + self->out_digest = digest; + self->read += block; + self->rpos = (at + block) % self->cap; + RING_STORE_REL(&self->r, r + block); + } + self->pulls++; + return dst; +} + +void audiopump_ring_reset_buffer(audiopump_ring_obj_t *self, + bool single_channel_output, uint8_t channel) { + if (single_channel_output && channel == 1) { + return; + } + // Deliberately does NOT drop what is queued: `MixerVoice.play()` and every + // output's play() call this, and a ring filled before play() would lose + // its head start. + self->which = 0; + self->left_reads = self->pulls; + self->right_reads = self->pulls; +} + +audioio_get_buffer_result_t audiopump_ring_get_buffer( + audiopump_ring_obj_t *self, bool single_channel_output, uint8_t channel, + uint8_t **buffer, uint32_t *buffer_length) { + if (!single_channel_output) { + channel = 0; + } + const uint32_t reads = channel == 1 ? self->right_reads : self->left_reads; + uint8_t *dst; + if (reads == self->pulls) { + dst = ring_pull_block(self); + } else { + dst = self->out[self->last]; + } + if (channel == 1) { + self->right_reads++; + if (self->frame == 4) { + // The right channel of interleaved stereo starts one sample in; + // the output reads every other sample from there. + dst += 2; + } + } else { + self->left_reads++; + } + *buffer = dst; + *buffer_length = self->block; + return GET_BUFFER_MORE_DATA; +} diff --git a/src/circuitpython_spike/shared-module/audiopump/Ring.h b/src/circuitpython_spike/shared-module/audiopump/Ring.h new file mode 100644 index 00000000..223de391 --- /dev/null +++ b/src/circuitpython_spike/shared-module/audiopump/Ring.h @@ -0,0 +1,100 @@ +// audiopump.Ring for CircuitPython: the stream sample. +// +// A ring Python writes PCM into that is also an audiosample, so +// CircuitPython's own outputs (audiobusio.I2SOut, audioio.AudioOut, +// audiopwmio.PWMAudioOut) and its audiomixer.Mixer pull it in C, with no +// Python in the pull path. It is the same node as audiodsp's MicroPython +// `audiopump.Ring` (src/audiopump/audiopump_ring.c), with the same Python +// surface, so one producer runs on both. On MicroPython the pump pulls it; +// here the board's audio output is the pump. +// +// One producer (the interpreter), one consumer (the output's fill, which +// runs from a background callback on most ports and from an interrupt on a +// few, stm's PWMAudioOut among them). `w` and `r` are 32-bit wrapping byte +// counters, each written by one side only, and `w - r` is the level even +// across the wrap. `clear()` is the one producer call that would have to +// write the consumer's state, so it does not: it posts a request the next +// pull carries out, which keeps it safe against a consumer that interrupts +// the producer halfway through. +// +// Underrun is a whole block of silence and a count, and what is in the ring +// stays there. Overrun is a short write and a count. +// +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) 2026 PyDevices + +#pragma once + +#include +#include + +#include "py/obj.h" +#include "shared-module/audiocore/__init__.h" + +typedef struct { + audiosample_base_t base; + + uint8_t *ring; // cap bytes, a whole number of blocks + uint32_t cap; // capacity in bytes + uint32_t block; // bytes handed out per pull + uint32_t frame; // bytes per frame; cap and block are multiples + + // The protocol. 32-bit, wrapping, one owner each. + uint32_t w; // producer writes, consumer reads + uint32_t r; // consumer writes, producer reads + // Where in the buffer those point. Carried, not recomputed as `w % cap`: + // a capacity that does not divide 2^32 would make the position jump when + // the counters wrap. + uint32_t wpos; // producer only + uint32_t rpos; // consumer only + + // clear(): the producer fills in clear_w and clear_wpos, then bumps + // clear_seq; the consumer moves r and rpos there at its next pull and + // copies clear_seq into clear_done. + uint32_t clear_w; // producer + uint32_t clear_wpos; // producer + uint32_t clear_seq; // producer + uint32_t clear_done; // consumer + + // Handed to the output. Two, so a consumer may still hold the previous + // block when the next pull comes. + uint8_t *out[2]; + uint8_t which; // the next of out[] to fill + uint8_t last; // the one handed out most recently + + // An output that plays the two channels from separate pulls (two DACs + // on one sample) asks once per channel per block. Whichever asks first + // takes a new block; the other is handed the same one. + uint32_t left_reads; + uint32_t right_reads; + + // Statistics, each written by one side. + uint32_t underruns; // consumer: pulls that found less than a block + uint32_t overruns; // producer: writes that could not take it all + uint32_t pulls; // consumer: blocks handed out, silence included + uint64_t wrote; // producer: bytes accepted + uint64_t read; // consumer: bytes handed out as real audio + uint64_t starved; // consumer: bytes handed out as silence + uint64_t in_digest; // producer: FNV-1a 64 over every byte accepted + uint64_t out_digest; // consumer: over every real byte handed out +} audiopump_ring_obj_t; + +// Sets up a ring of `capacity` blocks of `frames` frames. The caller has +// checked the arguments. +void audiopump_ring_construct(audiopump_ring_obj_t *self, + uint32_t sample_rate, uint32_t channel_count, uint32_t frames, + uint32_t capacity); + +// Takes whole frames, as many as fit, and returns the bytes taken. Never +// blocks, allocates or raises. +uint32_t audiopump_ring_write(audiopump_ring_obj_t *self, + const uint8_t *src, uint32_t len); +uint32_t audiopump_ring_space(audiopump_ring_obj_t *self); +uint32_t audiopump_ring_level(audiopump_ring_obj_t *self); +void audiopump_ring_clear(audiopump_ring_obj_t *self); + +void audiopump_ring_reset_buffer(audiopump_ring_obj_t *self, + bool single_channel_output, uint8_t channel); +audioio_get_buffer_result_t audiopump_ring_get_buffer( + audiopump_ring_obj_t *self, bool single_channel_output, uint8_t channel, + uint8_t **buffer, uint32_t *buffer_length); diff --git a/tests/circuitpython/ring_through_mixer.py b/tests/circuitpython/ring_through_mixer.py new file mode 100644 index 00000000..b8361cdd --- /dev/null +++ b/tests/circuitpython/ring_through_mixer.py @@ -0,0 +1,262 @@ +# audiopump.Ring pulled through audiomixer, checked sample by sample. +# +# Run it with a CircuitPython unix build that has audiodsp in it: +# +# circuitpython tests/circuitpython/ring_through_mixer.py +# +# It prints one line per check and "ALL PASS" last, and exits non-zero on the +# first failure. The same file runs on audiodsp's MicroPython unix build, +# whose Mixer and audiocore are CircuitPython's, so one producer is checked on +# both. +# +# What it would catch: channels swapped, a block dropped or repeated, an +# underrun that played silence without counting it (or counted it without +# playing silence), a short write that took a partial frame, a clear() that +# left old audio playing, and a position that jumps when the ring wraps. + +import sys + +import audiocore +import audiomixer +from array import array +from audiopump import Ring + +checks = 0 + + +def check(ok, what): + global checks + checks += 1 + if not ok: + print("FAIL", what) + sys.exit(1) + + +def left(f): + # Never zero, so silence can't pass for audio; never the right channel's + # value, so a swap can't pass either. + return 1000 + (f * 37) % 20000 + + +def right(f): + return -left(f) - 7 + + +def frames_of(start, count, channels): + a = array("h", bytes(count * channels * 2)) + for i in range(count): + f = start + i + if channels == 2: + a[2 * i] = left(f) + a[2 * i + 1] = right(f) + else: + a[i] = left(f) + return a + + +def get(sample): + """One pull, as signed 16-bit samples. CircuitPython's audiocore hands back + a memoryview of samples and MicroPython's one of bytes, so go by bytes.""" + result, buf = audiocore.get_buffer(sample) + check(result != 2, "get_buffer returned an error") + return array("h", bytes(buf)) + + +def pull(sample, frames, channels): + """Pull at least `frames` frames out of `sample` and return them.""" + got = array("h") + while len(got) < frames * channels: + got.extend(get(sample)) + return got + + +def near(a, b): + # A stereo Mixer at centre pan scales by 32767/32768, so a sample can come + # out one below what went in. Nothing this file catches is that small. + return a - b <= 1 and b - a <= 1 + + +def split(got, channels, out_channels): + """(non-silent frames as (l, r) tuples, number of silent frames).""" + audio = [] + silent = 0 + for i in range(0, len(got), out_channels): + frame = tuple(got[i:i + out_channels]) + if all(s == 0 for s in frame): + silent += 1 + else: + audio.append(frame) + return audio, silent + + +def matches(audio, start, channels, out_channels): + for i, frame in enumerate(audio): + f = start + i + want_l = left(f) + want_r = right(f) if channels == 2 else want_l + if not near(frame[0], want_l): + return "frame %d: left %d, want %d" % (f, frame[0], want_l) + if out_channels == 2 and not near(frame[1], want_r): + return "frame %d: right %d, want %d" % (f, frame[1], want_r) + return None + + +RATE = 22050 + + +# --- 1. the ring by itself: bit-exact, digests agree ------------------------ + +ring = Ring(sample_rate=RATE, channel_count=2, frames=64, capacity=4) +check(ring.sample_rate == RATE and ring.channel_count == 2 and ring.bits_per_sample == 16, + "audiosample fields") +check(ring.space() == 64 * 4 * 4 and ring.level() == 0, "empty ring's space and level") +data = frames_of(0, 64 * 3, 2) +check(ring.write(data) == len(data) * 2, "a write that fits takes it all") +check(ring.level() == 64 * 3 * 4, "level after write") +got = pull(ring, 64 * 3, 2) +check(got == data, "the ring hands out exactly what was written") +s = ring.stats() +check(s[0] == s[1] == 64 * 3 * 4 and s[6] == s[7], "wrote == read and the digests agree") +check(s[3] == 0 and s[5] == 3, "no underruns in three pulls") +print("ok ring alone: bit-exact, digests agree") + +# Underrun: a whole block of silence, counted, and the partial block kept. +ring.write(frames_of(1000, 40, 2)) +buf = get(ring) +check(len(buf) == 64 * 2 and all(v == 0 for v in buf), "underrun plays a block of silence") +check(ring.stats()[3] == 1 and ring.stats()[2] == 64 * 4, "underrun is counted, and so is its silence") +check(ring.level() == 40 * 4, "underrun keeps the partial block") +ring.write(frames_of(1040, 24, 2)) +check(pull(ring, 64, 2) == frames_of(1000, 64, 2), "the kept frames play once the block is whole") +print("ok underrun: silence, counted, nothing lost") + +# Overrun: whole frames only, short, counted. +ring = Ring(sample_rate=RATE, channel_count=2, frames=16, capacity=2) +took = ring.write(bytes(16 * 2 * 4 + 6)) +check(took == 16 * 2 * 4, "a write past capacity takes what fits") +check(ring.stats()[4] == 1, "an overrun is counted") +ring.clear() +check(ring.space() == 16 * 2 * 4, "clear() frees the room at once") +check(ring.write(bytes(6)) == 4, "a write takes whole frames only") +print("ok overrun and clear: short write, counted, whole frames") + +# clear() drops what was queued: the next pull is silence, not old audio. +ring = Ring(sample_rate=RATE, channel_count=2, frames=16, capacity=4) +ring.write(frames_of(0, 48, 2)) +ring.clear() +buf = get(ring) +check(all(v == 0 for v in buf), "clear() dropped the queued audio") +ring.write(frames_of(500, 16, 2)) +check(pull(ring, 16, 2) == frames_of(500, 16, 2), "audio written after clear() plays") +print("ok clear: old audio gone, new audio plays") + +# Wrap: a capacity that does not divide the block counts, many times round. +ring = Ring(sample_rate=RATE, channel_count=2, frames=48, capacity=3) +f = 0 +out = 0 +for _ in range(200): + n = ring.space() // 4 + if n: + n = min(n, 37) + ring.write(frames_of(f, n, 2)) + f += n + if ring.level() >= 48 * 4: + got = pull(ring, 48, 2) + check(got == frames_of(out, 48, 2), "wrap: block at frame %d" % out) + out += 48 +check(ring.stats()[3] == 0 and out > 48 * 3 * 20, "wrap: round the ring many times, no underruns") +print("ok wrap: %d frames round a %d-frame ring" % (out, 48 * 3)) + + +# --- 2. through audiomixer --------------------------------------------------- + +def through_mixer(channels, out_channels, block, mixer_frames, blocks, starve_at): + """Feed `blocks` blocks through a Mixer, keeping the ring topped up, except + that at block `starve_at` it is left to run dry. Returns the ring, what + came out, the frames written, and the underruns before the tail.""" + ring = Ring(sample_rate=RATE, channel_count=channels, frames=block, capacity=4) + mixer = audiomixer.Mixer(voice_count=1, sample_rate=RATE, channel_count=out_channels, + bits_per_sample=16, samples_signed=True, + buffer_size=mixer_frames * out_channels * 2) + block_bytes = block * channels * 2 + # A voice starts at level 0 and comes up to its level at the first zero + # crossing, or at the end of the Mixer's first buffer if there is none, so + # a stream that never crosses zero would lose that buffer. Audio starts + # from silence: one silent frame leads in, as a real signal's would. + ring.write(bytes(channels * 2)) + f = 0 + ring.write(frames_of(f, block * 2 - 1, channels)) + f += block * 2 - 1 + written = 2 + mixer.voice[0].play(ring) + got = array("h") + while written < blocks: + if written == starve_at: + starve_at = -1 + while ring.level(): + got.extend(pull(mixer, mixer_frames, out_channels)) + got.extend(pull(mixer, block * 2, out_channels)) + while written < blocks and ring.space() >= block_bytes: + ring.write(frames_of(f, block, channels)) + f += block + written += 1 + got.extend(pull(mixer, mixer_frames, out_channels)) + underruns = ring.stats()[3] + # The tail: everything still queued, and what the voice still holds. + while ring.level(): + got.extend(pull(mixer, mixer_frames, out_channels)) + got.extend(pull(mixer, block * 2, out_channels)) + return ring, got, f, underruns + + +for channels, out_channels, block, mixer_frames in ( + (2, 2, 64, 64), # the same block on both sides + (2, 2, 96, 40), # a mixer buffer that splits ring blocks + (2, 2, 32, 100), # a mixer buffer that spans several + (1, 1, 64, 50), # mono + (1, 2, 64, 64)): # mono ring into a stereo mixer + label = "%dch ring, %dch mixer, block %d, mixer %d" % (channels, out_channels, block, mixer_frames) + ring, got, written, underruns = through_mixer(channels, out_channels, block, mixer_frames, 40, 20) + audio, silent = split(got, channels, out_channels) + s = ring.stats() + check(len(audio) == written, "%s: %d frames came out, %d went in" % (label, len(audio), written)) + bad = matches(audio, 0, channels, out_channels) + check(bad is None, "%s: %s" % (label, bad)) + check(s[0] == s[1] and s[6] == s[7], "%s: every byte written was played, in order" % label) + check(underruns >= 1, "%s: the starved stretch counted underruns" % label) + check(s[2] == s[3] * block * channels * 2, "%s: silence counted as whole blocks" % label) + # The voice never stops (a ring never says it is done), so every frame the + # Mixer handed out came from the ring: the lead-in, the ring's silence, and + # the audio. The ring's silence is all out but for what the voice still + # holds of its last block, which is less than a block. + ring_silent = s[2] // (channels * 2) + check(ring_silent - block < silent - 1 <= ring_silent, + "%s: %d silent frames out, the ring counted %d" % (label, silent - 1, ring_silent)) + print("ok mixer: %s, %d frames, %d underruns while starved" % (label, written, underruns)) + +# An unbroken stream, kept ahead of the mixer, never underruns. +ring, got, written, underruns = through_mixer(2, 2, 64, 48, 60, -1) +check(underruns == 0, "a ring kept ahead of the mixer never underruns (%d)" % underruns) +audio, silent = split(got, 2, 2) +check(len(audio) == written and matches(audio, 0, 2, 2) is None, "unbroken stream plays in order") +print("ok mixer: unbroken stream, 0 underruns") + +# deinit(): the mixer's voice stops once it asks for another block. +ring = Ring(sample_rate=RATE, channel_count=2, frames=32, capacity=4) +mixer = audiomixer.Mixer(voice_count=1, sample_rate=RATE, channel_count=2, + bits_per_sample=16, samples_signed=True, buffer_size=32 * 4) +ring.write(frames_of(0, 96, 2)) +mixer.voice[0].play(ring) +audiocore.get_buffer(mixer) +ring.deinit() +for _ in range(4): + audiocore.get_buffer(mixer) +check(not mixer.voice[0].playing, "a deinitialised ring stops the voice") +try: + ring.write(bytes(4)) + check(False, "write() after deinit() raises") +except ValueError: + pass +print("ok deinit: the voice stops within a few pulls, write() raises") + +print("ALL PASS", checks, "checks on", sys.implementation.name) From 4fc34885b52fa72ac79fe6ff2b6b8e2fa85d405e Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:12:03 -0500 Subject: [PATCH 2/3] audiopump.Ring on CircuitPython: the README, a parity check against the pump's Ring, and the mixer test on MicroPython in CI --- .github/workflows/clean-build.yml | 7 +++++ README.md | 42 +++++++++++++++++++++++++++--- tests/test_binding_parity.py | 43 +++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/.github/workflows/clean-build.yml b/.github/workflows/clean-build.yml index 0f29a371..fabf9ef5 100644 --- a/.github/workflows/clean-build.yml +++ b/.github/workflows/clean-build.yml @@ -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. diff --git a/README.md b/README.md index ab8b65d6..90792492 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,42 @@ 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=8) +block = array("h", bytes(512)) # 256 mono frames +ring.write(render(block)) # fill a little before play() +out = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WCLK, board.I2S_DOUT) +out.play(ring) +while True: + if 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. + +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 @@ -771,9 +806,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 diff --git a/tests/test_binding_parity.py b/tests/test_binding_parity.py index 558f2feb..ada3162e 100644 --- a/tests/test_binding_parity.py +++ b/tests/test_binding_parity.py @@ -156,6 +156,49 @@ def test_every_node_type_has_deinit_on_both_sides(self): self.assertIn("__exit__", keys) + +class TheCircuitPythonRingIsThePumpsRing(unittest.TestCase): + """`audiopump.Ring` is bound twice too: `src/audiopump/audiopump_ring.c` + for MicroPython, where the pump pulls it, and + `src/circuitpython_spike/shared-bindings/audiopump/Ring.c` for + CircuitPython, where the board's audio output does. The point of the + CircuitPython one is that a producer written against either runs on both, + so the two must expose the same names. + + It is held here by name rather than through `MODULES`, because the rest of + MicroPython's `audiopump` (the pump itself, `Events`, `Tap`) has no + CircuitPython counterpart on purpose. `from_usb` is MicroPython's only: + it needs a USB device stack that streams into a C sink, which + CircuitPython's does not.""" + + MICROPYTHON_ONLY = {"from_usb"} + + def test_the_two_rings_expose_the_same_names(self): + micropython = surfaces([str(ROOT / "src" / "audiopump" / "audiopump_ring.c")], "") + circuitpython = circuitpython_surfaces("audiopump") + # The control: both tables are found, so agreement means something. + self.assertEqual(sorted(micropython), ["audiopump_ring"]) + self.assertEqual(sorted(circuitpython), ["audiopump_ring"]) + self.assertIn("write", circuitpython["audiopump_ring"]) + self.assertEqual( + micropython["audiopump_ring"] - self.MICROPYTHON_ONLY, + circuitpython["audiopump_ring"]) + + def test_the_circuitpython_module_carries_only_the_ring(self): + source = (ROOT / "src" / "circuitpython_spike" / "shared-bindings" + / "audiopump" / "__init__.c").read_text() + table = re.search(r"globals_table\[\]\s*=\s*\{(.*?)\n\};", source, re.DOTALL) + self.assertIsNotNone(table) + # The key of each entry, not its value: `__name__`'s value is a qstr too. + keys = re.findall(r"\{\s*MP_ROM_QSTR\(MP_QSTR_(\w+)\)", table.group(1)) + self.assertEqual(sorted(keys), ["Ring", "__name__"]) + + def test_the_circuitpython_build_compiles_it(self): + glue = (ROOT / "circuitpython.mk").read_text() + modules = re.search(r"AUDIODSP_CP_MODULES :=(.*?)\n\n", glue, re.DOTALL) + self.assertIsNotNone(modules) + self.assertIn("audiopump", modules.group(1).split()) + class EveryModuleSaysWhichAudiodspItIs(unittest.TestCase): """audiodsp#55: a firmware has to be able to name the audiodsp it was built from, and `os.uname().version` answers for MicroPython only. From 2ff7ac59eeba3fdad828f44678618e198fe0de8b Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:26:38 -0500 Subject: [PATCH 3/3] audiopump.Ring on CircuitPython: size the ring for ESP32's I2SOut, and the board check --- README.md | 14 ++++-- .../shared-bindings/audiopump/Ring.c | 14 ++++-- tests/circuitpython/ring_tone_board.py | 48 +++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 tests/circuitpython/ring_tone_board.py diff --git a/README.md b/README.md index 90792492..bd0285cb 100644 --- a/README.md +++ b/README.md @@ -189,13 +189,14 @@ import audiobusio, board from array import array from audiopump import Ring -ring = Ring(sample_rate=22050, channel_count=1, frames=256, capacity=8) +ring = Ring(sample_rate=22050, channel_count=1, frames=256, capacity=16) block = array("h", bytes(512)) # 256 mono frames -ring.write(render(block)) # fill a little before play() +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: - if ring.space() >= len(block) * 2: + while ring.space() >= len(block) * 2: ring.write(render(block)) ``` @@ -206,6 +207,13 @@ 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 diff --git a/src/circuitpython_spike/shared-bindings/audiopump/Ring.c b/src/circuitpython_spike/shared-bindings/audiopump/Ring.c index 2697d282..04a7b348 100644 --- a/src/circuitpython_spike/shared-bindings/audiopump/Ring.c +++ b/src/circuitpython_spike/shared-bindings/audiopump/Ring.c @@ -26,16 +26,20 @@ //| import audiobusio, board //| from audiopump import Ring //| -//| ring = Ring(sample_rate=22050, channel_count=2, frames=256, capacity=8) -//| ring.write(first_blocks) # fill before play() +//| 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: -//| if ring.space() >= len(block): +//| while ring.space() >= len(block): //| ring.write(render(block)) //| -//| ``play()`` pulls its first block at once, so write at least one block -//| before it, or that first pull counts as an underrun.""" +//| ``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, diff --git a/tests/circuitpython/ring_tone_board.py b/tests/circuitpython/ring_tone_board.py new file mode 100644 index 00000000..d9fa8547 --- /dev/null +++ b/tests/circuitpython/ring_tone_board.py @@ -0,0 +1,48 @@ +# A 441 Hz tone written from Python into audiopump.Ring and played by the +# board's audiobusio.I2SOut for 30 s, then the ring's counts. Copy it to a +# CircuitPython board with an I2S amplifier as code.py, or paste it at the +# REPL. A pass is `underruns=0`. +# +# Written for the LilyGO T-Embed (ESP32-S3, MAX98357A): its amplifier's rail +# is behind IO46. On another board, change the pins and drop the rail. + +import array +import math +import time + +import audiobusio +import board +import digitalio +from audiopump import Ring + +rail = digitalio.DigitalInOut(board.IO46) +rail.switch_to_output(value=True) + +RATE = 22050 +FRAMES = 256 +PERIOD = 50 # 22050 / 441 +table = array.array("h", [int(9830 * math.sin(2 * math.pi * i / PERIOD)) for i in range(PERIOD * 16)]) +table.extend(table[:FRAMES]) # so any block-long slice is contiguous +mv = memoryview(table) +pos = 0 + +# ESP32's I2SOut takes about 3,000 frames when it starts and about 1,000 at a +# time after: 16 blocks of 256, filled before play(), keep ahead of it. +ring = Ring(sample_rate=RATE, channel_count=1, frames=FRAMES, capacity=16) +while ring.space() >= FRAMES * 2: + ring.write(mv[pos:pos + FRAMES]) + pos = (pos + FRAMES) % (PERIOD * 16) + +out = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WCLK, board.I2S_DOUT) +out.play(ring) +start = time.monotonic() +while time.monotonic() - start < 30: + while ring.space() >= FRAMES * 2: + ring.write(mv[pos:pos + FRAMES]) + pos = (pos + FRAMES) % (PERIOD * 16) + time.sleep(0.002) +s = ring.stats() +out.stop() +out.deinit() +rail.deinit() +print("wrote=%d read=%d starved=%d underruns=%d overruns=%d pulls=%d" % s[:6])