diff --git a/ports/unix/variants/coverage/mpconfigvariant.mk b/ports/unix/variants/coverage/mpconfigvariant.mk index beab74832cb..b9a170b012a 100644 --- a/ports/unix/variants/coverage/mpconfigvariant.mk +++ b/ports/unix/variants/coverage/mpconfigvariant.mk @@ -43,6 +43,8 @@ SRC_BITMAP := \ shared-bindings/audiofilters/Filter.c \ shared-bindings/audiofilters/Phaser.c \ shared-bindings/audiofilters/__init__.c \ + shared-bindings/audiotap/Tap.c \ + shared-bindings/audiotap/__init__.c \ shared-bindings/audiofreeverb/Freeverb.c \ shared-bindings/audiofreeverb/__init__.c \ shared-bindings/audiomixer/__init__.c \ @@ -93,6 +95,8 @@ SRC_BITMAP := \ shared-module/audiofilters/Filter.c \ shared-module/audiofilters/Phaser.c \ shared-module/audiofilters/__init__.c \ + shared-module/audiotap/Tap.c \ + shared-module/audiotap/__init__.c \ shared-module/audiofreeverb/Freeverb.c \ shared-module/audiofreeverb/__init__.c \ shared-module/audiomixer/__init__.c \ @@ -159,6 +163,7 @@ CFLAGS += \ -DCIRCUITPY_AUDIODELAYS=1 \ -DCIRCUITPY_AUDIOFILTERS=1 \ -DCIRCUITPY_AUDIOMIXER=1 \ + -DCIRCUITPY_AUDIOTAP=1 \ -DCIRCUITPY_AUDIOMP3=1 \ -DCIRCUITPY_AUDIOCORE_DEBUG=1 \ -DCIRCUITPY_BITMAPTOOLS=1 \ diff --git a/py/circuitpy_defns.mk b/py/circuitpy_defns.mk index ec84d3ec381..ccfcaac96d2 100755 --- a/py/circuitpy_defns.mk +++ b/py/circuitpy_defns.mk @@ -152,6 +152,9 @@ endif ifeq ($(CIRCUITPY_AUDIOSPEED),1) SRC_PATTERNS += audiospeed/% endif +ifeq ($(CIRCUITPY_AUDIOTAP),1) +SRC_PATTERNS += audiotap/% +endif ifeq ($(CIRCUITPY_AUDIOFILEWRITER),1) SRC_PATTERNS += audiofilewriter/% endif @@ -758,6 +761,8 @@ SRC_SHARED_MODULE_ALL = \ audiospeed/Resampler.c \ audiospeed/SpeedChanger.c \ audiospeed/__init__.c \ + audiotap/Tap.c \ + audiotap/__init__.c \ audiodelays/Echo.c \ audiodelays/Chorus.c \ audiodelays/Flanger.c \ diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index 0fc82d03704..7c8a57fecc7 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -173,6 +173,8 @@ CIRCUITPY_AUDIOFILTERS ?= $(CIRCUITPY_AUDIOEFFECTS) CFLAGS += -DCIRCUITPY_AUDIOFILTERS=$(CIRCUITPY_AUDIOFILTERS) CIRCUITPY_AUDIOFREEVERB ?= $(CIRCUITPY_AUDIOEFFECTS) CFLAGS += -DCIRCUITPY_AUDIOFREEVERB=$(CIRCUITPY_AUDIOFREEVERB) +CIRCUITPY_AUDIOTAP ?= $(CIRCUITPY_AUDIOEFFECTS) +CFLAGS += -DCIRCUITPY_AUDIOTAP=$(CIRCUITPY_AUDIOTAP) CIRCUITPY_AUDIOFILEWRITER ?= 0 CFLAGS += -DCIRCUITPY_AUDIOFILEWRITER=$(CIRCUITPY_AUDIOFILEWRITER) diff --git a/shared-bindings/audiotap/Tap.c b/shared-bindings/audiotap/Tap.c new file mode 100644 index 00000000000..beb797638a1 --- /dev/null +++ b/shared-bindings/audiotap/Tap.c @@ -0,0 +1,356 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include + +#include "shared-bindings/audiotap/Tap.h" +#include "shared-bindings/audiocore/__init__.h" + +#include "shared/runtime/context_manager_helpers.h" +#include "py/binary.h" +#include "py/objproperty.h" +#include "py/runtime.h" +#include "shared-bindings/util.h" + +//| class Tap: +//| """Passes audio through unchanged and keeps a copy that the program can read""" +//| +//| def __init__(self, capacity_ms: int = 100) -> None: +//| """Create a Tap. It plays a sample like the effects in `audiofilters` +//| and `audiodelays` do, so it can go anywhere in an audio chain. Whatever +//| plays the tap gets the sample's audio unchanged. +//| +//| As the audio passes through, the tap keeps up to ``capacity_ms`` of +//| it in a buffer, which `readinto` copies out of without waiting, and +//| measures its `peak` and `rms` levels. The program can look at +//| the audio this way, at the point in the chain where the tap is, +//| while it plays. +//| +//| The tap takes its sample rate, channel count, bits per sample and +//| signedness from the first sample it plays, and allocates the buffer +//| then. So `play` a sample with the tap before anything plays the tap. +//| +//| The tap only sees audio that is played through it: something, such +//| as an `audiomixer.Mixer` voice playing to an audio output, must play +//| it or a chain that it is part of. To look at audio without hearing +//| it, play it at a ``level`` of 0. When its sample is stopped or ends, +//| the tap plays silence and keeps nothing. +//| +//| When the program reads too slowly, the buffer fills up, the tap keeps +//| the audio it has and drops the newer audio until there is room +//| again, and `overflow` turns True. The audio that passes through is +//| never affected. +//| +//| :param int capacity_ms: How many milliseconds of audio are kept for +//| `readinto`. 0 keeps none, for a tap that only measures `peak` and +//| `rms`. +//| +//| Showing the level of a USB microphone while it is heard through the +//| DVI output, and keeping the audio for a spectrum display. The +//| ``adafruit_usb_host_descriptors`` library finds the microphone and +//| its format, and `usb.core` starts its stream; see +//| `usb_host_audio.USBIn`. DVI audio plays at 32, 44.1 or 48 kHz, so an +//| `audiospeed.Resampler` converts other sample rates:: +//| +//| import array +//| import time +//| +//| import adafruit_usb_host_descriptors +//| import audiomixer +//| import audiospeed +//| import audiotap +//| import displayio +//| import supervisor +//| import usb_host_audio +//| import vectorio +//| +//| device = adafruit_usb_host_descriptors.find_microphone() +//| if device is None: +//| raise RuntimeError("Plug a USB microphone or webcam into the USB host port") +//| formats = adafruit_usb_host_descriptors.find_microphone_formats(device) +//| mic_format = adafruit_usb_host_descriptors.choose_microphone_format(formats, sample_rate=48000) +//| sample_rate = mic_format.nearest_rate(48000) +//| +//| device.set_configuration() +//| # SET_INTERFACE to the format's alternate setting starts the stream, then +//| # SET_CUR of the endpoint's sampling frequency picks the rate. +//| device.ctrl_transfer(0x01, 11, mic_format.alternate, mic_format.interface, None) +//| if mic_format.rate_control: +//| device.ctrl_transfer( +//| 0x22, 0x01, 0x0100, mic_format.endpoint, sample_rate.to_bytes(3, "little") +//| ) +//| +//| mic = usb_host_audio.USBIn( +//| device, +//| mic_format.endpoint, +//| sample_rate=sample_rate, +//| channel_count=mic_format.channel_count, +//| bit_depth=8 * mic_format.bytes_per_sample, +//| samples_signed=mic_format.samples_signed, +//| max_packet_size=mic_format.max_packet_size, +//| ) +//| # Keep a quarter second, more than arrives between reads of the loop below. +//| tap = audiotap.Tap(capacity_ms=250) +//| mixer = audiomixer.Mixer(voice_count=1, sample_rate=48000, channel_count=mic.channel_count) +//| supervisor.runtime.audio.play(mixer) +//| mixer.voice[0].play(audiospeed.Resampler(tap.play(mic))) +//| +//| # Bars for the peak (top) and RMS (bottom) levels. Showing them hides the +//| # console, whose scrolling stalls the microphone's stream. print() still +//| # goes to the serial console. +//| display = supervisor.runtime.display +//| palette = displayio.Palette(1) +//| palette[0] = 0x00FF00 +//| peak_bar = vectorio.Rectangle(pixel_shader=palette, width=1, height=40, x=0, y=60) +//| rms_bar = vectorio.Rectangle(pixel_shader=palette, width=1, height=40, x=0, y=140) +//| meter = displayio.Group() +//| meter.append(peak_bar) +//| meter.append(rms_bar) +//| display.root_group = meter +//| +//| samples = array.array("h", [0] * 1024) +//| while True: +//| # Read the audio that is waiting. A spectrum display would use these samples. +//| while tap.available >= len(samples): +//| tap.readinto(samples) +//| peak = tap.peak +//| rms = tap.rms +//| peak_bar.width = max(1, int(peak * display.width)) +//| rms_bar.width = max(1, int(rms * display.width)) +//| print(f"peak {peak:.3f} rms {rms:.3f}") +//| time.sleep(0.1)""" +//| ... +//| +static mp_obj_t audiotap_tap_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_capacity_ms, }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_capacity_ms, MP_ARG_INT, {.u_int = 100} }, + }; + + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + mp_int_t capacity_ms = mp_arg_validate_int_range(args[ARG_capacity_ms].u_int, 0, 60000, MP_QSTR_capacity_ms); + + audiotap_tap_obj_t *self = mp_obj_malloc_with_finaliser(audiotap_tap_obj_t, &audiotap_tap_type); + common_hal_audiotap_tap_construct(self, capacity_ms); + + return MP_OBJ_FROM_PTR(self); +} + +//| def deinit(self) -> None: +//| """Deinitialises the Tap.""" +//| ... +//| +static mp_obj_t audiotap_tap_deinit(mp_obj_t self_in) { + audiotap_tap_obj_t *self = MP_OBJ_TO_PTR(self_in); + common_hal_audiotap_tap_deinit(self); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiotap_tap_deinit_obj, audiotap_tap_deinit); + +static void check_for_deinit(audiotap_tap_obj_t *self) { + audiosample_check_for_deinit(&self->base); +} + +//| def __enter__(self) -> Tap: +//| """No-op used by Context Managers.""" +//| ... +//| +// Provided by context manager helper. + +//| def __exit__(self) -> None: +//| """Automatically deinitializes when exiting a context. See +//| :ref:`lifetime-and-contextmanagers` for more info.""" +//| ... +//| +// Provided by context manager helper. + +//| playing: bool +//| """True when the tap is playing a sample. (read-only)""" +//| +static mp_obj_t audiotap_tap_obj_get_playing(mp_obj_t self_in) { + audiotap_tap_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + return mp_obj_new_bool(common_hal_audiotap_tap_get_playing(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(audiotap_tap_get_playing_obj, audiotap_tap_obj_get_playing); + +MP_PROPERTY_GETTER(audiotap_tap_playing_obj, + (mp_obj_t)&audiotap_tap_get_playing_obj); + +//| available: int +//| """The number of samples waiting to be read with `readinto`. (read-only)""" +//| +static mp_obj_t audiotap_tap_obj_get_available(mp_obj_t self_in) { + audiotap_tap_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + return mp_obj_new_int_from_uint(common_hal_audiotap_tap_get_available(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(audiotap_tap_get_available_obj, audiotap_tap_obj_get_available); + +MP_PROPERTY_GETTER(audiotap_tap_available_obj, + (mp_obj_t)&audiotap_tap_get_available_obj); + +//| overflow: bool +//| """True if audio was dropped since the last time this was checked, +//| because `readinto` did not keep up. Reading this clears it. (read-only)""" +//| +static mp_obj_t audiotap_tap_obj_get_overflow(mp_obj_t self_in) { + audiotap_tap_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + return mp_obj_new_bool(common_hal_audiotap_tap_get_overflow(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(audiotap_tap_get_overflow_obj, audiotap_tap_obj_get_overflow); + +MP_PROPERTY_GETTER(audiotap_tap_overflow_obj, + (mp_obj_t)&audiotap_tap_get_overflow_obj); + +//| def readinto(self, buffer: WriteableBuffer) -> int: +//| """Copies the oldest audio waiting into ``buffer`` and returns the number +//| of samples copied, which is 0 when none is waiting. Does not wait for +//| audio. Copies whole frames only: a stereo frame is two samples, left +//| then right. +//| +//| The samples are copied as they are played, so ``buffer`` is +//| usually an ``array.array`` of typecode ``'h'`` for 16 bit signed +//| samples. A ``bytearray`` works for any format and holds half as +//| many 16 bit samples as its length. To read into part of a buffer, +//| pass a slice of a ``memoryview`` of it.""" +//| ... +//| +static mp_obj_t audiotap_tap_obj_readinto(mp_obj_t self_in, mp_obj_t buffer_in) { + audiotap_tap_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(buffer_in, &bufinfo, MP_BUFFER_WRITE); + uint32_t length = bufinfo.len / (self->base.bits_per_sample / 8); + return mp_obj_new_int_from_uint(common_hal_audiotap_tap_readinto(self, bufinfo.buf, length)); +} +MP_DEFINE_CONST_FUN_OBJ_2(audiotap_tap_readinto_obj, audiotap_tap_obj_readinto); + +//| def clear(self) -> None: +//| """Discards the audio waiting, so that `readinto` returns only audio +//| that passes through after this. Use it to skip ahead to the live +//| audio, for example after an `overflow`.""" +//| ... +//| +static mp_obj_t audiotap_tap_obj_clear(mp_obj_t self_in) { + audiotap_tap_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + common_hal_audiotap_tap_clear(self); + return mp_const_none; +} +MP_DEFINE_CONST_FUN_OBJ_1(audiotap_tap_clear_obj, audiotap_tap_obj_clear); + +//| peak: float +//| """The largest magnitude of the audio that passed through since this was +//| last read, from 0.0 to 1.0 of full scale. Reading this starts a new +//| measurement. 0.0 when no audio passed through. The channels of stereo +//| audio are measured together. (read-only)""" +//| +static mp_obj_t audiotap_tap_obj_get_peak(mp_obj_t self_in) { + audiotap_tap_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + return mp_obj_new_float(common_hal_audiotap_tap_get_peak(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(audiotap_tap_get_peak_obj, audiotap_tap_obj_get_peak); + +MP_PROPERTY_GETTER(audiotap_tap_peak_obj, + (mp_obj_t)&audiotap_tap_get_peak_obj); + +//| rms: float +//| """The RMS level of the audio that passed through since this was last +//| read, from 0.0 to 1.0 of full scale. Reading this starts a new +//| measurement, separate from `peak`'s. 0.0 when no audio passed through. +//| The channels of stereo audio are measured together. (read-only)""" +//| +static mp_obj_t audiotap_tap_obj_get_rms(mp_obj_t self_in) { + audiotap_tap_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + return mp_obj_new_float(common_hal_audiotap_tap_get_rms(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(audiotap_tap_get_rms_obj, audiotap_tap_obj_get_rms); + +MP_PROPERTY_GETTER(audiotap_tap_rms_obj, + (mp_obj_t)&audiotap_tap_get_rms_obj); + +//| def play(self, sample: circuitpython_typing.AudioSample, *, loop: bool = False) -> Tap: +//| """Plays the sample once when loop=False and continuously when loop=True. +//| Does not block. Use `playing` to block. +//| +//| The first sample sets the tap's format. Later samples must match it. +//| +//| :return: The tap itself. Can be used for chaining, ie: +//| ``audio.play(tap.play(sample))``. +//| :rtype: Tap""" +//| ... +//| +static mp_obj_t audiotap_tap_obj_play(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_sample, ARG_loop }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_sample, MP_ARG_OBJ | MP_ARG_REQUIRED, {} }, + { MP_QSTR_loop, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, + }; + audiotap_tap_obj_t *self = MP_OBJ_TO_PTR(pos_args[0]); + check_for_deinit(self); + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + common_hal_audiotap_tap_play(self, args[ARG_sample].u_obj, args[ARG_loop].u_bool); + + return MP_OBJ_FROM_PTR(self); +} +MP_DEFINE_CONST_FUN_OBJ_KW(audiotap_tap_play_obj, 1, audiotap_tap_obj_play); + +//| def stop(self) -> None: +//| """Stops playback of the sample. The audio already kept can still be read.""" +//| ... +//| +//| +static mp_obj_t audiotap_tap_obj_stop(mp_obj_t self_in) { + audiotap_tap_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + common_hal_audiotap_tap_stop(self); + return mp_const_none; +} +MP_DEFINE_CONST_FUN_OBJ_1(audiotap_tap_stop_obj, audiotap_tap_obj_stop); + +static const mp_rom_map_elem_t audiotap_tap_locals_dict_table[] = { + // Methods + { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&audiotap_tap_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR___del__), MP_ROM_PTR(&audiotap_tap_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR___enter__), MP_ROM_PTR(&default___enter___obj) }, + { MP_ROM_QSTR(MP_QSTR___exit__), MP_ROM_PTR(&default___exit___obj) }, + { MP_ROM_QSTR(MP_QSTR_play), MP_ROM_PTR(&audiotap_tap_play_obj) }, + { MP_ROM_QSTR(MP_QSTR_stop), MP_ROM_PTR(&audiotap_tap_stop_obj) }, + { MP_ROM_QSTR(MP_QSTR_readinto), MP_ROM_PTR(&audiotap_tap_readinto_obj) }, + { MP_ROM_QSTR(MP_QSTR_clear), MP_ROM_PTR(&audiotap_tap_clear_obj) }, + + // Properties + { MP_ROM_QSTR(MP_QSTR_playing), MP_ROM_PTR(&audiotap_tap_playing_obj) }, + { MP_ROM_QSTR(MP_QSTR_available), MP_ROM_PTR(&audiotap_tap_available_obj) }, + { MP_ROM_QSTR(MP_QSTR_overflow), MP_ROM_PTR(&audiotap_tap_overflow_obj) }, + { MP_ROM_QSTR(MP_QSTR_peak), MP_ROM_PTR(&audiotap_tap_peak_obj) }, + { MP_ROM_QSTR(MP_QSTR_rms), MP_ROM_PTR(&audiotap_tap_rms_obj) }, + AUDIOSAMPLE_FIELDS, +}; +static MP_DEFINE_CONST_DICT(audiotap_tap_locals_dict, audiotap_tap_locals_dict_table); + +static const audiosample_p_t audiotap_tap_proto = { + MP_PROTO_IMPLEMENT(MP_QSTR_protocol_audiosample) + .reset_buffer = (audiosample_reset_buffer_fun)audiotap_tap_reset_buffer, + .get_buffer = (audiosample_get_buffer_fun)audiotap_tap_get_buffer, +}; + +MP_DEFINE_CONST_OBJ_TYPE( + audiotap_tap_type, + MP_QSTR_Tap, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, audiotap_tap_make_new, + locals_dict, &audiotap_tap_locals_dict, + protocol, &audiotap_tap_proto + ); diff --git a/shared-bindings/audiotap/Tap.h b/shared-bindings/audiotap/Tap.h new file mode 100644 index 00000000000..892d4fd2fdd --- /dev/null +++ b/shared-bindings/audiotap/Tap.h @@ -0,0 +1,27 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "shared-module/audiotap/Tap.h" + +extern const mp_obj_type_t audiotap_tap_type; + +void common_hal_audiotap_tap_construct(audiotap_tap_obj_t *self, uint32_t capacity_ms); + +void common_hal_audiotap_tap_deinit(audiotap_tap_obj_t *self); + +bool common_hal_audiotap_tap_get_playing(audiotap_tap_obj_t *self); +void common_hal_audiotap_tap_play(audiotap_tap_obj_t *self, mp_obj_t sample, bool loop); +void common_hal_audiotap_tap_stop(audiotap_tap_obj_t *self); + +uint32_t common_hal_audiotap_tap_get_available(audiotap_tap_obj_t *self); +// Copies at most length samples, in whole frames. Returns the number copied. +uint32_t common_hal_audiotap_tap_readinto(audiotap_tap_obj_t *self, uint8_t *buffer, uint32_t length); +void common_hal_audiotap_tap_clear(audiotap_tap_obj_t *self); +bool common_hal_audiotap_tap_get_overflow(audiotap_tap_obj_t *self); +mp_float_t common_hal_audiotap_tap_get_peak(audiotap_tap_obj_t *self); +mp_float_t common_hal_audiotap_tap_get_rms(audiotap_tap_obj_t *self); diff --git a/shared-bindings/audiotap/__init__.c b/shared-bindings/audiotap/__init__.c new file mode 100644 index 00000000000..06278eaf2d7 --- /dev/null +++ b/shared-bindings/audiotap/__init__.c @@ -0,0 +1,33 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include + +#include "py/obj.h" +#include "py/runtime.h" + +#include "shared-bindings/audiotap/Tap.h" + +//| """Look at audio as it plays +//| +//| The `audiotap` module contains `Tap`, which goes into an audio chain like +//| the effects in `audiofilters` and `audiodelays`. Audio passes through it +//| unchanged, and the program can read a copy of it and its levels, for meters, +//| waveforms, spectrum analysis or recording, while it plays.""" + +static const mp_rom_map_elem_t audiotap_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_audiotap) }, + { MP_ROM_QSTR(MP_QSTR_Tap), MP_ROM_PTR(&audiotap_tap_type) }, +}; + +static MP_DEFINE_CONST_DICT(audiotap_module_globals, audiotap_module_globals_table); + +const mp_obj_module_t audiotap_module = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&audiotap_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_audiotap, audiotap_module); diff --git a/shared-module/audiotap/Tap.c b/shared-module/audiotap/Tap.c new file mode 100644 index 00000000000..ff1f34c14fe --- /dev/null +++ b/shared-module/audiotap/Tap.c @@ -0,0 +1,336 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT +#include "shared-bindings/audiotap/Tap.h" +#include "shared-bindings/audiocore/__init__.h" + +#include +#include +#include + +#include "py/runtime.h" + +void common_hal_audiotap_tap_construct(audiotap_tap_obj_t *self, uint32_t capacity_ms) { + // The format comes from the first sample played. Until then the tap is + // 16 bit mono at 0 Hz, which nothing plays: a channel count of 0 would + // mark it deinitialized. + self->base.bits_per_sample = 16; + self->base.samples_signed = true; + self->base.channel_count = 1; + self->base.sample_rate = 0; + self->base.single_buffer = false; + self->base.max_buffer_length = 0; + + self->silence = NULL; + self->silence_len = 0; + + self->sample = NULL; + self->sample_remaining_buffer = NULL; + self->sample_buffer_length = 0; + self->loop = false; + self->more_data = false; + + self->capacity_ms = capacity_ms; + self->ring = NULL; + self->capacity = 0; + self->write_pos = 0; + self->read_pos = 0; + self->overflow = false; + + self->peak = 0; + self->peak_reset = false; + self->sum_squares = 0; + self->level_count = 0; + self->rms_reset = false; +} + +void common_hal_audiotap_tap_deinit(audiotap_tap_obj_t *self) { + audiosample_mark_deinit(&self->base); + self->sample = NULL; + self->silence = NULL; + self->ring = NULL; + self->capacity = 0; +} + +void audiotap_tap_reset_buffer(audiotap_tap_obj_t *self, + bool single_channel_output, + uint8_t channel) { + (void)self; + (void)single_channel_output; + (void)channel; + // Nothing to reset: the tap passes on the source's buffers, and the ring + // belongs to the reader, so what it holds stays readable. +} + +bool common_hal_audiotap_tap_get_playing(audiotap_tap_obj_t *self) { + return self->sample != NULL; +} + +// Takes the format of the first sample played and allocates for it. +static void take_format(audiotap_tap_obj_t *self, mp_obj_t sample) { + const audiosample_base_t *source = audiosample_check(sample); + audiosample_check_for_deinit(source); + if (source->bits_per_sample != 8 && source->bits_per_sample != 16) { + mp_raise_ValueError(MP_ERROR_TEXT("bits_per_sample must be 8 or 16")); + } + const uint32_t sample_bytes = source->bits_per_sample / 8; + const uint32_t frame_bytes = sample_bytes * source->channel_count; + + // Silence for when the source has no audio, no longer than the source's + // buffers and in whole frames. + uint32_t silence_len = MIN(MAX(source->max_buffer_length, frame_bytes), 512); + silence_len -= silence_len % frame_bytes; + int8_t *silence = m_malloc_without_collect(silence_len); + if (source->samples_signed) { + memset(silence, 0, silence_len); + } else if (sample_bytes == 2) { + uint16_t *words = (uint16_t *)silence; + for (uint32_t i = 0; i < silence_len / 2; i++) { + words[i] = 0x8000; + } + } else { + memset(silence, 0x80, silence_len); + } + + uint32_t capacity = (uint32_t)((uint64_t)self->capacity_ms * source->sample_rate / 1000) * source->channel_count; + uint8_t *ring = NULL; + if (capacity > 0) { + ring = m_malloc_without_collect(capacity * sample_bytes); + } + + // Nothing plays the tap before it has a format, so the reader is the + // only one using these yet. + self->silence = silence; + self->silence_len = silence_len; + self->ring = ring; + self->capacity = capacity; + self->write_pos = 0; + self->read_pos = 0; + + self->base.bits_per_sample = source->bits_per_sample; + self->base.samples_signed = source->samples_signed; + self->base.channel_count = source->channel_count; + self->base.sample_rate = source->sample_rate; + self->base.max_buffer_length = MAX(source->max_buffer_length, silence_len); +} + +void common_hal_audiotap_tap_play(audiotap_tap_obj_t *self, mp_obj_t sample, bool loop) { + if (self->silence == NULL) { + take_format(self, sample); + } else { + audiosample_must_match(&self->base, sample, false); + } + + audiosample_reset_buffer(sample, false, 0); + self->loop = loop; + self->sample_buffer_length = 0; + // Ask the source for its first buffer. + self->more_data = true; + self->sample = sample; +} + +void common_hal_audiotap_tap_stop(audiotap_tap_obj_t *self) { + self->sample = NULL; +} + +// Positions run from 0 to 2 * capacity. +static uint32_t ring_used(uint32_t capacity, uint32_t write, uint32_t read) { + return write >= read ? write - read : write + 2 * capacity - read; +} + +static uint32_t ring_index(uint32_t capacity, uint32_t pos) { + return pos >= capacity ? pos - capacity : pos; +} + +static uint32_t ring_advance(uint32_t capacity, uint32_t pos, uint32_t count) { + pos += count; + return pos >= 2 * capacity ? pos - 2 * capacity : pos; +} + +// The sample at i, as a signed 16 bit value. +static int32_t sample_at(audiotap_tap_obj_t *self, const uint8_t *data, uint32_t i) { + if (MP_LIKELY(self->base.bits_per_sample == 16)) { + uint16_t raw = ((const uint16_t *)data)[i]; + return self->base.samples_signed ? (int16_t)raw : (int16_t)(raw ^ 0x8000); + } + uint8_t raw = data[i]; + return (self->base.samples_signed ? (int8_t)raw : (int8_t)(raw ^ 0x80)) * 256; +} + +// Called by whatever plays the tap, for each run of samples from the source. +// It may be an interrupt, so it never allocates and changes only write_pos and +// the levels. +static void tap_capture(audiotap_tap_obj_t *self, const uint8_t *data, uint32_t count) { + if (count == 0) { + return; + } + + if (self->peak_reset) { + self->peak = 0; + self->peak_reset = false; + } + if (self->rms_reset) { + self->sum_squares = 0; + self->level_count = 0; + self->rms_reset = false; + } + uint32_t peak = self->peak; + uint64_t sum = 0; + for (uint32_t i = 0; i < count; i++) { + int32_t value = sample_at(self, data, i); + uint32_t magnitude = value < 0 ? -value : value; + if (magnitude > peak) { + peak = magnitude; + } + sum += (uint64_t)(value * value); + } + self->peak = peak; + self->sum_squares += (float)sum / (32768.0f * 32768.0f); + self->level_count += count; + + if (self->ring == NULL) { + return; + } + const uint32_t capacity = self->capacity; + const uint32_t sample_bytes = self->base.bits_per_sample / 8; + uint32_t write = self->write_pos; + uint32_t free = capacity - ring_used(capacity, write, self->read_pos); + if (count > free) { + // The reader fell behind. Moving read_pos to keep the newest audio + // would race with it, so drop what doesn't fit. + count = free; + self->overflow = true; + } + while (count > 0) { + uint32_t index = ring_index(capacity, write); + uint32_t run = MIN(count, capacity - index); + memcpy(self->ring + index * sample_bytes, data, run * sample_bytes); + data += run * sample_bytes; + count -= run; + write = ring_advance(capacity, write, run); + } + // The samples must be in the ring before the reader can see them. + __sync_synchronize(); + self->write_pos = write; +} + +uint32_t common_hal_audiotap_tap_get_available(audiotap_tap_obj_t *self) { + if (self->ring == NULL) { + return 0; + } + return ring_used(self->capacity, self->write_pos, self->read_pos); +} + +uint32_t common_hal_audiotap_tap_readinto(audiotap_tap_obj_t *self, uint8_t *buffer, uint32_t length) { + if (self->ring == NULL) { + return 0; + } + const uint32_t capacity = self->capacity; + const uint32_t sample_bytes = self->base.bits_per_sample / 8; + uint32_t read = self->read_pos; + uint32_t write = self->write_pos; + __sync_synchronize(); + uint32_t count = MIN(length - length % self->base.channel_count, + ring_used(capacity, write, read)); + uint32_t done = 0; + while (done < count) { + uint32_t index = ring_index(capacity, read); + uint32_t run = MIN(count - done, capacity - index); + memcpy(buffer + done * sample_bytes, self->ring + index * sample_bytes, run * sample_bytes); + done += run; + read = ring_advance(capacity, read, run); + } + // Copy out before the writer may reuse the space. + __sync_synchronize(); + self->read_pos = read; + return count; +} + +void common_hal_audiotap_tap_clear(audiotap_tap_obj_t *self) { + self->read_pos = self->write_pos; +} + +bool common_hal_audiotap_tap_get_overflow(audiotap_tap_obj_t *self) { + bool overflow = self->overflow; + self->overflow = false; + return overflow; +} + +mp_float_t common_hal_audiotap_tap_get_peak(audiotap_tap_obj_t *self) { + if (self->peak_reset) { + // No audio passed since the last read. + return 0; + } + mp_float_t peak = (mp_float_t)self->peak / MICROPY_FLOAT_CONST(32768.0); + self->peak_reset = true; + return peak; +} + +mp_float_t common_hal_audiotap_tap_get_rms(audiotap_tap_obj_t *self) { + if (self->rms_reset) { + // No audio passed since the last read. + return 0; + } + uint32_t count = self->level_count; + float sum_squares = self->sum_squares; + self->rms_reset = true; + return count > 0 ? MICROPY_FLOAT_C_FUN(sqrt)((mp_float_t)sum_squares / count) : 0; +} + +audioio_get_buffer_result_t audiotap_tap_get_buffer(audiotap_tap_obj_t *self, bool single_channel_output, uint8_t channel, + uint8_t **buffer, uint32_t *buffer_length) { + (void)single_channel_output; + (void)channel; + + if (self->silence == NULL) { + // Deinitialized while something still plays it. + *buffer = NULL; + *buffer_length = 0; + return GET_BUFFER_DONE; + } + + const uint32_t sample_bytes = self->base.bits_per_sample / 8; + if (self->sample_buffer_length == 0 && self->sample != NULL) { + if (!self->more_data) { + if (self->loop) { + audiosample_reset_buffer(self->sample, false, 0); + } else { + self->sample = NULL; + } + } + if (self->sample != NULL) { + audioio_get_buffer_result_t result = audiosample_get_buffer(self->sample, false, 0, + (uint8_t **)&self->sample_remaining_buffer, &self->sample_buffer_length); + if (result == GET_BUFFER_ERROR) { + self->sample = NULL; + self->sample_buffer_length = 0; + self->more_data = false; + } else { + self->sample_buffer_length /= sample_bytes; + self->more_data = result == GET_BUFFER_MORE_DATA; + } + } + } + + if (self->sample == NULL || self->sample_buffer_length == 0) { + // Like the effects, a tap plays silence when it has no audio, and + // rather than ask a source that has nothing yet again in a loop. + *buffer = (uint8_t *)self->silence; + *buffer_length = self->silence_len; + return GET_BUFFER_MORE_DATA; + } + + // Pass on the source's own buffer, in whole frames no longer than what + // the player expects, which a later sample's buffers may be. + const uint32_t channel_count = self->base.channel_count; + const uint32_t max_samples = self->base.max_buffer_length / sample_bytes / channel_count * channel_count; + uint32_t n = MIN(self->sample_buffer_length, max_samples); + tap_capture(self, self->sample_remaining_buffer, n); + *buffer = self->sample_remaining_buffer; + *buffer_length = n * sample_bytes; + self->sample_remaining_buffer += n * sample_bytes; + self->sample_buffer_length -= n; + return GET_BUFFER_MORE_DATA; +} diff --git a/shared-module/audiotap/Tap.h b/shared-module/audiotap/Tap.h new file mode 100644 index 00000000000..6bc667ad294 --- /dev/null +++ b/shared-module/audiotap/Tap.h @@ -0,0 +1,61 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT +#pragma once + +#include "py/obj.h" + +#include "shared-module/audiocore/__init__.h" + +extern const mp_obj_type_t audiotap_tap_type; + +typedef struct { + audiosample_base_t base; + + // Played while there is no audio to pass through. Allocated, like the + // ring, when the first sample is played, which also sets the format. + int8_t *silence; + uint32_t silence_len; // in bytes + + // The part of the source's buffer not passed through yet. It stays the + // source's until the source is asked for its next buffer. + uint8_t *sample_remaining_buffer; + uint32_t sample_buffer_length; // in samples + + bool loop; + bool more_data; + + mp_obj_t sample; + + // A copy of the audio that passed through, for readinto(). Whatever plays + // the tap writes it, from a background task or an interrupt, and the VM + // reads it, so each side moves only its own position. Both positions run + // from 0 to 2 * capacity, which tells a full ring from an empty one. + uint32_t capacity_ms; + uint8_t *ring; + uint32_t capacity; // in samples, a whole number of frames + volatile uint32_t write_pos; + volatile uint32_t read_pos; + // Audio was dropped because the ring was full. Cleared when read. + volatile bool overflow; + + // Levels of the audio since each was last read. Reading one asks for its + // reset, and the writer does it, so that only the writer changes them. + volatile uint32_t peak; // largest magnitude, 0 to 32768 + volatile bool peak_reset; + volatile float sum_squares; // of samples scaled to -1.0 to 1.0 + volatile uint32_t level_count; // samples summed + volatile bool rms_reset; +} audiotap_tap_obj_t; + +void audiotap_tap_reset_buffer(audiotap_tap_obj_t *self, + bool single_channel_output, + uint8_t channel); + +audioio_get_buffer_result_t audiotap_tap_get_buffer(audiotap_tap_obj_t *self, + bool single_channel_output, + uint8_t channel, + uint8_t **buffer, + uint32_t *buffer_length); // length in bytes diff --git a/shared-module/audiotap/__init__.c b/shared-module/audiotap/__init__.c new file mode 100644 index 00000000000..4ab4bccdc69 --- /dev/null +++ b/shared-module/audiotap/__init__.c @@ -0,0 +1,7 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +// Nothing to do here. The module's only state is in its Tap objects. diff --git a/tests/circuitpython/audiotap_tap.py b/tests/circuitpython/audiotap_tap.py new file mode 100644 index 00000000000..aec3770448d --- /dev/null +++ b/tests/circuitpython/audiotap_tap.py @@ -0,0 +1,85 @@ +import array +from audiocore import get_buffer, RawSample +from audiofilters import Filter +from audiotap import Tap + +ramp = array.array("h", range(-300, 300, 3)) # 200 samples +source = RawSample(ramp, sample_rate=8000) + + +def pull(sample, times=1): + out = [] + for _ in range(times): + result, buffer = get_buffer(sample) + out.extend(buffer) + return out + + +# Audio passes through unchanged and a copy can be read. +tap = Tap(capacity=1000, buffer_size=128, sample_rate=8000) +print("idle", tap.playing, tap.available, tap.levels()) +print("idle silence", set(pull(tap)), tap.available) +tap.play(source, loop=True) +print("playing", tap.playing) +played = pull(tap, 4) +print("played", len(played), played[:3], played == list(ramp) + list(ramp)[:56]) +print("available", tap.available) +copy = array.array("h", [0] * 300) +print("read", tap.readinto(copy), list(copy[:256]) == played, tap.available) +print("read empty", tap.readinto(copy)) + +# Levels of the audio since the last call. +peak, rms = tap.levels() +print("levels", round(peak, 4), round(rms, 4)) +print("levels again", tap.levels()) + +# Overflow keeps the oldest audio and drops the newer. +small = Tap(capacity=100, buffer_size=128, sample_rate=8000) +small.play(source, loop=True) +played = pull(small, 2) +print("full", small.available, small.overflow, small.overflow) +copy = array.array("h", [0] * 100) +print("kept oldest", small.readinto(copy), list(copy) == played[:100]) +pull(small) +small.clear() +print("cleared", small.available) + +# A bytearray holds half as many 16 bit samples, and memoryview slices work. +pull(small) +raw = bytearray(10) +print("bytearray", small.readinto(raw), small.available) +part = array.array("h", [7] * 8) +print("slice", small.readinto(memoryview(part)[2:5]), list(part)) + +# Levels only. +meter = Tap(capacity=0, buffer_size=64, sample_rate=8000) +meter.play(RawSample(array.array("h", [16384, -32768] * 16), sample_rate=8000), loop=True) +pull(meter) +print("meter", meter.capacity, meter.readinto(copy), meter.levels()) + +# Stereo reads whole frames. +stereo = Tap(capacity=64, buffer_size=32, sample_rate=8000, channel_count=2) +stereo.play(RawSample(array.array("h", range(16)), sample_rate=8000, channel_count=2), loop=True) +pull(stereo) +odd = array.array("h", [0] * 5) +print("stereo", stereo.readinto(odd), list(odd)) + +# 8 bit unsigned levels: 0x80 is silence. +eight = Tap(capacity=16, buffer_size=16, sample_rate=8000, bits_per_sample=8, samples_signed=False) +eight.play(RawSample(array.array("B", [0x80, 0xC0] * 8), sample_rate=8000), loop=True) +pull(eight) +print("8 bit", eight.levels()) + +# In a chain, and the end of a sample that does not loop. +once = Tap(capacity=1000, buffer_size=128, sample_rate=8000) +chain = Filter(buffer_size=128, sample_rate=8000) +chain.play(once) +once.play(source) +pull(chain, 6) +print("ended", once.playing, once.available) + +once.deinit() +try: + once.readinto(copy) +except ValueError as error: + print("deinit", error) diff --git a/tests/circuitpython/audiotap_tap.py.exp b/tests/circuitpython/audiotap_tap.py.exp new file mode 100644 index 00000000000..e5ea328ed28 --- /dev/null +++ b/tests/circuitpython/audiotap_tap.py.exp @@ -0,0 +1,19 @@ +idle False 0 (0.0, 0.0) +idle silence {0} 0 +playing True +played 256 [-300, -297, -294] True +available 256 +read 256 True 0 +read empty 0 +levels 0.0092 0.0057 +levels again (0.0, 0.0) +full 100 True False +kept oldest 100 True +cleared 0 +bytearray 5 59 +slice 3 [7, 7, 291, 294, 297, 7, 7, 7] +meter 0 0 (1.0, 0.7905694150420949) +stereo 4 [0, 1, 2, 3, 0] +8 bit (0.5, 0.3535533905932738) +ended False 200 +deinit Object has been deinitialized and can no longer be used. Create a new object.