diff --git a/docs/environment.rst b/docs/environment.rst index 26823650491..2899a75aff8 100644 --- a/docs/environment.rst +++ b/docs/environment.rst @@ -101,9 +101,9 @@ Used to avoid "Pystack exhausted" errors when the code can't be reworked to avoi CIRCUITPY_USB_HOST_ISO_BUFFER_SIZE (integer) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Allocates a buffer for incoming isochronous USB data (USB camera, USB audio) -on boards with a ``usb_host.Port``. Without it, isochronous IN transfers return at -most one packet per USB frame, so data arriving while code is busy is lost. +Allocates a buffer for incoming isochronous USB data, such as from a USB camera +read with ``usb.core``, on boards with a ``usb_host.Port``. Without it, isochronous IN +transfers return at most one packet per USB frame, so data arriving while code is busy is lost. The value is rounded up to the next power of two and limited to the range 2048 to 65536. A typical value for a USB camera is 16384. The buffer is allocated when the USB host port is created and is never freed. diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index 4969538a2ac..c395839d3e7 100644 --- a/locale/circuitpython.pot +++ b/locale/circuitpython.pot @@ -1067,7 +1067,7 @@ msgstr "" msgid "No pull up found on SDA or SCL; check your wiring" msgstr "" -#: ports/atmel-samd/common-hal/busio/UART.c shared-bindings/usb/core/Device.c +#: ports/atmel-samd/common-hal/busio/UART.c #: shared-bindings/usb_host_bulk/InStream.c msgid "%q must be power of 2" msgstr "" @@ -1346,6 +1346,7 @@ msgstr "" #: ports/espressif/common-hal/_bleio/Characteristic.c #: ports/espressif/common-hal/_bleio/Service.c #: ports/raspberrypi/common-hal/alarm/pin/PinAlarm.c +#: ports/raspberrypi/common-hal/usb_host_bulk/InStream.c msgid "Too many %q" msgstr "" @@ -1476,7 +1477,7 @@ msgid "%q must be array of type 'H'" msgstr "" #: ports/espressif/common-hal/audiobusio/PDMIn.c -#: shared-bindings/audioi2sin/I2SIn.c +#: shared-bindings/audioi2sin/I2SIn.c shared-bindings/usb_host_audio/USBIn.c msgid "%q must be 8, 16, 24, or 32" msgstr "" @@ -1675,6 +1676,7 @@ msgstr "" #: shared-bindings/digitalio/DigitalInOut.c shared-bindings/emmcio/EMMC.c #: shared-bindings/i2cioexpander/IOPin.c shared-bindings/microcontroller/Pin.c #: shared-module/max3421e/Max3421E.c shared-module/usb/core/Device.c +#: shared-module/usb_host_audio/USBIn.c msgid "%q in use" msgstr "" @@ -3922,10 +3924,12 @@ msgid "%q must be multiple of 8." msgstr "" #: shared-bindings/audiobusio/PDMIn.c shared-bindings/audioi2sin/I2SIn.c +#: shared-bindings/usb_host_audio/USBIn.c msgid "Cannot record to a file" msgstr "" #: shared-bindings/audiobusio/PDMIn.c shared-bindings/audioi2sin/I2SIn.c +#: shared-bindings/usb_host_audio/USBIn.c msgid "Destination capacity is smaller than destination_length." msgstr "" @@ -3965,7 +3969,7 @@ msgstr "" msgid "bits_per_sample must be 16" msgstr "" -#: shared-bindings/audioi2sin/I2SIn.c +#: shared-bindings/audioi2sin/I2SIn.c shared-bindings/usb_host_audio/USBIn.c #, c-format msgid "invalid destination buffer, must be an array of type: %c" msgstr "" diff --git a/main.c b/main.c index 054c7c8ed16..d7a0d31a941 100644 --- a/main.c +++ b/main.c @@ -97,6 +97,10 @@ #include "shared-module/audiofilewriter/AudioFileWriter.h" #endif +#if CIRCUITPY_USB_HOST_AUDIO +#include "shared-module/usb_host_audio/USBIn.h" +#endif + #if CIRCUITPY_MEMORYMONITOR #include "shared-module/memorymonitor/__init__.h" #endif @@ -420,6 +424,10 @@ static void cleanup_after_vm(mp_obj_t exception) { audiofilewriter_reset(); #endif + #if CIRCUITPY_USB_HOST_AUDIO + usb_host_audio_reset(); + #endif + // Close user-initiated sockets. #if CIRCUITPY_SOCKETPOOL socketpool_user_reset(); diff --git a/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk b/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk index f105f7436eb..49333599816 100644 --- a/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk +++ b/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk @@ -27,3 +27,6 @@ CFLAGS += -DPIO_USB_HOST_ISOCHRONOUS=1 # USB host bulk input, for SDR devices CIRCUITPY_USB_HOST_BULK = 1 + +# USB microphones as audio sources. Needs the isochronous ring above. +CIRCUITPY_USB_HOST_AUDIO = 1 diff --git a/ports/raspberrypi/common-hal/usb_host/Port.c b/ports/raspberrypi/common-hal/usb_host/Port.c index 22360320434..1ed56f667aa 100644 --- a/ports/raspberrypi/common-hal/usb_host/Port.c +++ b/ports/raspberrypi/common-hal/usb_host/Port.c @@ -36,6 +36,32 @@ usb_host_port_obj_t usb_host_instance; volatile bool _core1_ready = false; +#if PIO_USB_HOST_ISOCHRONOUS +// The isochronous ring from settings.toml. Like the port, it is never freed. +static uint8_t *_iso_settings_buffer = NULL; +static uint32_t _iso_settings_size = 0; +// A ring claimed for the length of one stream, which replaces the one above +// until it is released. +static uint8_t *_iso_claimed_buffer = NULL; + +// The ring size the host controller accepts: a power of two in [2048, 65536]. +static uint32_t _iso_ring_size(size_t size) { + uint32_t rounded = 2048; + while (rounded < size && rounded < 65536) { + rounded <<= 1; + } + return rounded; +} + +// Hand the host controller another ring. Core 1 may still be in the frame +// where the endpoint stopped streaming into the old ring, so let it finish +// first. False while an endpoint streams into the ring. +static bool _iso_set_ring(uint8_t *buffer, uint32_t size) { + busy_wait_us(2000); + return pio_usb_host_set_iso_ring(buffer, size); +} +#endif + // Core1 posts TinyUSB events while core0 may hold the queue FIFO mutex. The // contended wait path calls best_effort_wfe_or_timeout() and time_us_64(), // which normally live in flash, but core1 must not touch flash (see the MPU @@ -183,11 +209,7 @@ usb_host_port_obj_t *common_hal_usb_host_port_construct(const mcu_pin_obj_t *dp, mp_int_t iso_size = 0; if (settings_get_int("CIRCUITPY_USB_HOST_ISO_BUFFER_SIZE", &iso_size) == SETTINGS_OK && iso_size > 0) { - // Round up to a power of two and clamp to [2048, 65536] - uint32_t rounded = 2048; - while (rounded < (uint32_t)iso_size && rounded < 65536) { - rounded <<= 1; - } + uint32_t rounded = _iso_ring_size(iso_size); // dma_capable=true forces the internal-SRAM heap. Never PSRAM: PSRAM // is inside the XIP region that core 1's MPU blocks below, and the // ring is written from core 1. Like the port itself, the buffer is @@ -195,6 +217,8 @@ usb_host_port_obj_t *common_hal_usb_host_port_construct(const mcu_pin_obj_t *dp, uint8_t *iso_buf = port_malloc(rounded, true); if (iso_buf != NULL) { pio_usb_host_set_iso_ring(iso_buf, rounded); + _iso_settings_buffer = iso_buf; + _iso_settings_size = rounded; } else { mp_raise_RuntimeError_varg(MP_ERROR_TEXT("Failed to allocate %q buffer"), MP_QSTR_USB_HOST_ISO_BUFFER); @@ -214,6 +238,45 @@ usb_host_port_obj_t *common_hal_usb_host_port_construct(const mcu_pin_obj_t *dp, return self; } +bool common_hal_usb_host_port_claim_iso_buffer(size_t size) { + #if PIO_USB_HOST_ISOCHRONOUS + // A ring left claimed because it was still streaming may be free by now. + common_hal_usb_host_port_release_iso_buffer(); + if (_iso_claimed_buffer != NULL) { + return false; + } + uint32_t rounded = _iso_ring_size(size); + // Internal SRAM only, for the same reason as the settings.toml ring above. + uint8_t *buffer = port_malloc(rounded, true); + if (buffer == NULL) { + m_malloc_fail(rounded); + } + if (!_iso_set_ring(buffer, rounded)) { + port_free(buffer); + return false; + } + _iso_claimed_buffer = buffer; + return true; + #else + (void)size; + return false; + #endif +} + +void common_hal_usb_host_port_release_iso_buffer(void) { + #if PIO_USB_HOST_ISOCHRONOUS + if (_iso_claimed_buffer == NULL) { + return; + } + // When an endpoint still streams into the ring, core 1 keeps writing it, so + // it can't be freed. It stays claimed and the next claim tries again. + if (_iso_set_ring(_iso_settings_buffer, _iso_settings_size)) { + port_free(_iso_claimed_buffer); + _iso_claimed_buffer = NULL; + } + #endif +} + // Not used, but we must define to put this hook into SRAM void __not_in_flash_func(tuh_event_hook_cb)(uint8_t rhport, uint32_t eventid, bool in_isr) { (void)rhport; diff --git a/py/circuitpy_defns.mk b/py/circuitpy_defns.mk index 85322ce61ee..75c3e6ca1f8 100755 --- a/py/circuitpy_defns.mk +++ b/py/circuitpy_defns.mk @@ -471,6 +471,9 @@ endif ifeq ($(CIRCUITPY_USB_HOST),1) SRC_PATTERNS += usb_host/% endif +ifeq ($(CIRCUITPY_USB_HOST_AUDIO),1) +SRC_PATTERNS += usb_host_audio/% +endif ifeq ($(CIRCUITPY_USB_HOST_BULK),1) SRC_PATTERNS += usb_host_bulk/% endif @@ -877,6 +880,8 @@ SRC_SHARED_MODULE_ALL = \ usb/core/__init__.c \ usb/core/Device.c \ usb/util/__init__.c \ + usb_host_audio/USBIn.c \ + usb_host_audio/__init__.c \ ustack/__init__.c \ vectorio/Circle.c \ vectorio/Polygon.c \ diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index 702fceb5794..91b7c820eaf 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -788,6 +788,11 @@ CFLAGS += -DCIRCUITPY_USB_HOST=$(CIRCUITPY_USB_HOST) CIRCUITPY_PYUSB ?= $(call enable-if-any,$(CIRCUITPY_USB_HOST) $(CIRCUITPY_MAX3421E)) CFLAGS += -DCIRCUITPY_PYUSB=$(CIRCUITPY_PYUSB) +# Needs a host controller that buffers isochronous IN packets, such as +# Pico-PIO-USB with PIO_USB_HOST_ISOCHRONOUS set. +CIRCUITPY_USB_HOST_AUDIO ?= 0 +CFLAGS += -DCIRCUITPY_USB_HOST_AUDIO=$(CIRCUITPY_USB_HOST_AUDIO) + # Continuous bulk transfers on PIO USB host ports. Opt-in per board. CIRCUITPY_USB_HOST_BULK ?= 0 CFLAGS += -DCIRCUITPY_USB_HOST_BULK=$(CIRCUITPY_USB_HOST_BULK) diff --git a/shared-bindings/usb_host/Port.h b/shared-bindings/usb_host/Port.h index 803eae8b59f..3958bf3514b 100644 --- a/shared-bindings/usb_host/Port.h +++ b/shared-bindings/usb_host/Port.h @@ -19,3 +19,13 @@ extern const mp_obj_type_t usb_host_port_type; // method to check the internals of the global object against the given arguments // to determine whether to return the singleton or raise an exception. usb_host_port_obj_t *common_hal_usb_host_port_construct(const mcu_pin_obj_t *dp, const mcu_pin_obj_t *dm); + +// Gives the host controller a buffer of at least size bytes for isochronous IN +// packets, which it then keeps between transfers and hands over as +// length-prefixed records, as usb_host_audio needs. Until it is released it +// replaces the buffer set in settings.toml. False when another isochronous +// stream holds the buffer; raises when out of memory. +bool common_hal_usb_host_port_claim_iso_buffer(size_t size); +// Frees the claimed buffer and puts back the one from settings.toml. Call after +// the stream ended. Never raises. +void common_hal_usb_host_port_release_iso_buffer(void); diff --git a/shared-bindings/usb_host_audio/USBIn.c b/shared-bindings/usb_host_audio/USBIn.c new file mode 100644 index 00000000000..caa315c3495 --- /dev/null +++ b/shared-bindings/usb_host_audio/USBIn.c @@ -0,0 +1,345 @@ +// 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 "extmod/vfs_fat.h" +#include "shared/runtime/context_manager_helpers.h" +#include "py/binary.h" +#include "py/objproperty.h" +#include "py/runtime.h" +#include "shared-bindings/audiocore/__init__.h" +#include "shared-bindings/usb/core/Device.h" +#include "shared-bindings/usb_host_audio/USBIn.h" +#include "shared-bindings/util.h" +//| import usb.core +//| +//| +//| class USBIn: +//| """Stream or record the audio of a USB microphone on a USB host port.""" +//| +//| def __init__( +//| self, +//| device: usb.core.Device, +//| endpoint: int, +//| *, +//| sample_rate: int, +//| channel_count: int = 1, +//| bit_depth: int = 16, +//| samples_signed: bool = True, +//| max_packet_size: int = 0, +//| buffer_frames: int = 256, +//| iso_buffer_size: int = 16384, +//| ) -> None: +//| """Receive the PCM audio that a USB Audio Class microphone sends on an +//| isochronous IN endpoint. The object can be played like any other audio +//| sample, which makes the microphone the source of an audio effects +//| chain, and it can `record` to a buffer. +//| +//| The constructor does no control transfers. Before calling it, select +//| the alternate setting of the streaming interface that has ``endpoint`` +//| and set the sample rate; the arguments describe the format chosen +//| that way. Do not create a `USBIn` for an interface that is in +//| alternate setting 0. `deinit` selects alternate setting 0 again, so +//| the microphone stops also when a ``with`` block is left by an +//| exception, or when the program ends. +//| +//| The host controller buffers the stream between reads, in a buffer of +//| ``iso_buffer_size`` bytes that `USBIn` allocates and `deinit` frees. +//| +//| Only one isochronous IN endpoint is received at a time, so only one +//| `USBIn` can stream, and a USB camera cannot stream video meanwhile. +//| Creating a second one raises ``RuntimeError`` while the first is +//| `streaming`, and deinitializes the first when its stream has ended. +//| +//| The microphone's clock is independent of the clock of the audio +//| output. While playing, the drift between them is taken up by +//| stretching a buffer by one frame now and then, which cannot be heard. +//| Only when playback stalls is audio dropped, which sets `overflow`, or a +//| moment of silence played. About ``3 * buffer_frames`` frames of +//| latency are added. +//| +//| :param ~usb.core.Device device: The microphone. Its configuration must be set. +//| :param int endpoint: The address of the isochronous IN endpoint +//| :param int sample_rate: The sample rate that the microphone was set to, in Hz +//| :param int channel_count: The number of channels in the stream +//| :param int bit_depth: The bits per sample in the stream: 8, 16, 24 or 32. 24 +//| and 32 bit samples are narrowed to 16 bit; see `bits_per_sample`. For +//| samples narrower than their bytes, such as 24 bits in 4 bytes, give the +//| size of the bytes. +//| :param bool samples_signed: Samples are signed (True) or unsigned (False). USB +//| audio is signed, except for 8 bit PCM. +//| :param int max_packet_size: The endpoint's ``wMaxPacketSize`` +//| :param int buffer_frames: The number of frames handed to the audio pipeline at a time +//| :param int iso_buffer_size: The size in bytes of the host controller's buffer for +//| the stream, from 2048 to 65536, rounded up to a power of two. It holds the +//| packets that arrive while background tasks don't run, each with 2 bytes of +//| overhead: 16384 is about half a second of 16 kHz mono audio or 80 ms of 48 kHz +//| stereo. +//| +//| Example, recording a second from the microphone of a Logitech C270 +//| webcam. It streams 16 bit mono on interface 3, alternate setting 1, +//| endpoint 0x86. For another microphone, read these from its +//| configuration descriptor:: +//| +//| import array +//| import usb.core +//| import usb_host_audio +//| +//| device = usb.core.find(idVendor=0x046D, idProduct=0x0825) +//| device.set_configuration() +//| # SET_INTERFACE to alternate setting 1 starts the stream, then +//| # SET_CUR of the endpoint's sampling frequency picks 16 kHz. +//| device.ctrl_transfer(0x01, 11, 1, 3, None) +//| device.ctrl_transfer(0x22, 0x01, 0x0100, 0x86, (16000).to_bytes(3, "little")) +//| +//| samples = array.array("h", [0] * 16000) +//| # Leaving the with block stops the stream. +//| with usb_host_audio.USBIn(device, 0x86, sample_rate=16000, max_packet_size=68) as mic: +//| count = mic.record(samples, len(samples)) +//| print("recorded", count, "samples, peak", max(abs(s) for s in samples)) +//| +//| """ +//| ... +//| +static mp_obj_t usb_host_audio_usbin_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_device, ARG_endpoint, ARG_sample_rate, ARG_channel_count, ARG_bit_depth, + ARG_samples_signed, ARG_max_packet_size, ARG_buffer_frames, ARG_iso_buffer_size }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_device, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_endpoint, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_sample_rate, MP_ARG_KW_ONLY | MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_channel_count, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 1} }, + { MP_QSTR_bit_depth, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 16} }, + { MP_QSTR_samples_signed, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = true} }, + { MP_QSTR_max_packet_size, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_buffer_frames, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 256} }, + { MP_QSTR_iso_buffer_size, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 16384} }, + }; + 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); + + usb_core_device_obj_t *device = mp_arg_validate_type(args[ARG_device].u_obj, &usb_core_device_type, MP_QSTR_device); + if (common_hal_usb_core_device_deinited(device)) { + raise_deinited_error(); + } + mp_int_t endpoint = args[ARG_endpoint].u_int; + if ((endpoint & 0x80) == 0 || (endpoint & 0x7f) == 0 || endpoint > 0x8f) { + mp_arg_error_invalid(MP_QSTR_endpoint); + } + mp_int_t sample_rate = mp_arg_validate_int_min(args[ARG_sample_rate].u_int, 1, MP_QSTR_sample_rate); + mp_int_t channel_count = mp_arg_validate_int_range(args[ARG_channel_count].u_int, 1, 2, MP_QSTR_channel_count); + mp_int_t bit_depth = args[ARG_bit_depth].u_int; + if (bit_depth != 8 && bit_depth != 16 && bit_depth != 24 && bit_depth != 32) { + mp_raise_ValueError_varg(MP_ERROR_TEXT("%q must be 8, 16, 24, or 32"), MP_QSTR_bit_depth); + } + mp_int_t max_packet_size = mp_arg_validate_int_range(args[ARG_max_packet_size].u_int, 0, 1024, MP_QSTR_max_packet_size); + mp_int_t buffer_frames = mp_arg_validate_int_range(args[ARG_buffer_frames].u_int, 32, 4096, MP_QSTR_buffer_frames); + mp_int_t iso_buffer_size = mp_arg_validate_int_range(args[ARG_iso_buffer_size].u_int, 2048, 65536, MP_QSTR_iso_buffer_size); + + usb_host_audio_usbin_obj_t *self = mp_obj_malloc_with_finaliser(usb_host_audio_usbin_obj_t, &usb_host_audio_usbin_type); + common_hal_usb_host_audio_usbin_construct(self, device, endpoint, sample_rate, channel_count, + bit_depth, args[ARG_samples_signed].u_bool, max_packet_size, buffer_frames, iso_buffer_size); + + return MP_OBJ_FROM_PTR(self); +} + +//| def deinit(self) -> None: +//| """Stops the stream and releases the buffers. While the stream is still +//| `streaming`, this selects alternate setting 0 of the endpoint's +//| interface first, which stops the microphone sending.""" +//| ... +//| +static mp_obj_t usb_host_audio_usbin_deinit(mp_obj_t self_in) { + usb_host_audio_usbin_obj_t *self = MP_OBJ_TO_PTR(self_in); + common_hal_usb_host_audio_usbin_deinit(self); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(usb_host_audio_usbin_deinit_obj, usb_host_audio_usbin_deinit); + +static void check_for_deinit(usb_host_audio_usbin_obj_t *self) { + if (common_hal_usb_host_audio_usbin_deinited(self)) { + raise_deinited_error(); + } +} + +//| def __enter__(self) -> USBIn: +//| """No-op used by Context Managers.""" +//| ... +//| +// Provided by context manager helper. + +//| def __exit__(self) -> None: +//| """Automatically deinitializes the hardware when exiting a context. See +//| :ref:`lifetime-and-contextmanagers` for more info.""" +//| ... +//| +// Provided by context manager helper. + +//| def record(self, destination: WriteableBuffer, destination_length: int) -> int: +//| """Records destination_length samples to destination. This is blocking. +//| A stereo frame is two samples, left then right. +//| +//| ``destination`` is an ``array.array`` of typecode ``'h'`` for +//| `bits_per_sample` 16 (``'H'`` when unsigned), and of typecode ``'b'`` +//| (``'B'`` when unsigned) or a ``bytearray`` for 8. +//| +//| Audio is received between calls too, so that consecutive recordings +//| join up. When more arrived than is buffered, the recording starts +//| with the audio that arrives after the call. +//| +//| :return: The number of samples recorded. If this is less than +//| ``destination_length``, the microphone stopped sending audio.""" +//| ... +//| +static mp_obj_t usb_host_audio_usbin_obj_record(mp_obj_t self_obj, mp_obj_t destination, mp_obj_t destination_length) { + usb_host_audio_usbin_obj_t *self = MP_OBJ_TO_PTR(self_obj); + check_for_deinit(self); + uint32_t length = mp_arg_validate_type_int(destination_length, MP_QSTR_length); + mp_arg_validate_length_min(length, 0, MP_QSTR_length); + + mp_buffer_info_t bufinfo; + if (mp_obj_is_type(destination, &mp_type_fileio)) { + mp_raise_NotImplementedError(MP_ERROR_TEXT("Cannot record to a file")); + } + mp_get_buffer_raise(destination, &bufinfo, MP_BUFFER_WRITE); + if (bufinfo.len / mp_binary_get_size('@', bufinfo.typecode, NULL) < length) { + mp_raise_ValueError(MP_ERROR_TEXT("Destination capacity is smaller than destination_length.")); + } + char error_type = ' '; + if (self->base.bits_per_sample == 16) { + char typecode = self->base.samples_signed ? 'h' : 'H'; + if (bufinfo.typecode != typecode) { + error_type = typecode; + } + } else { + char typecode = self->base.samples_signed ? 'b' : 'B'; + if (bufinfo.typecode != typecode && bufinfo.typecode != BYTEARRAY_TYPECODE) { + error_type = typecode; // NOTE: Not identifying as bytearray + } + } + if (error_type != ' ') { + mp_raise_TypeError_varg( + MP_ERROR_TEXT("invalid destination buffer, must be an array of type: %c"), + error_type + ); + } + uint32_t length_written = + common_hal_usb_host_audio_usbin_record_to_buffer(self, bufinfo.buf, length); + return MP_OBJ_NEW_SMALL_INT(length_written); +} +MP_DEFINE_CONST_FUN_OBJ_3(usb_host_audio_usbin_record_obj, usb_host_audio_usbin_obj_record); + +//| sample_rate: int +//| """The sample rate given to the constructor, in Hz. This is the rate +//| reported to the audio pipeline. The microphone's own clock decides the +//| true rate, which is usually a fraction of a percent off.""" +//| +//| bits_per_sample: int +//| """The number of bits per sample as it is streamed through the audio pipeline +//| and recorded: 8 or 16. (read-only)""" +//| +//| channel_count: int +//| """The number of channels (1 for mono, 2 for stereo). (read-only)""" +//| + +//| bit_depth: int +//| """The number of bits per sample that the microphone sends. (read-only)""" +//| +static mp_obj_t usb_host_audio_usbin_obj_get_bit_depth(mp_obj_t self_in) { + usb_host_audio_usbin_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + return MP_OBJ_NEW_SMALL_INT(common_hal_usb_host_audio_usbin_get_bit_depth(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(usb_host_audio_usbin_get_bit_depth_obj, usb_host_audio_usbin_obj_get_bit_depth); + +MP_PROPERTY_GETTER(usb_host_audio_usbin_bit_depth_obj, + (mp_obj_t)&usb_host_audio_usbin_get_bit_depth_obj); + +//| samples_signed: bool +//| """True if the samples are signed PCM, False for unsigned. (read-only)""" +//| +static mp_obj_t usb_host_audio_usbin_obj_get_samples_signed(mp_obj_t self_in) { + usb_host_audio_usbin_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + return mp_obj_new_bool(common_hal_usb_host_audio_usbin_get_samples_signed(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(usb_host_audio_usbin_get_samples_signed_obj, usb_host_audio_usbin_obj_get_samples_signed); + +MP_PROPERTY_GETTER(usb_host_audio_usbin_samples_signed_obj, + (mp_obj_t)&usb_host_audio_usbin_get_samples_signed_obj); + +//| streaming: bool +//| """True while the stream is received. It turns False for good when the +//| microphone stops sending: an alternate setting of the device was +//| selected, its configuration was set again, it was unplugged, or another +//| isochronous stream was started. The object then plays silence and records +//| only what it received before; +//| make a new one after starting the stream again. (read-only) +//| +//| Receiving is not retried, because some devices stop answering control +//| requests when an endpoint of an idle interface is polled.""" +//| +static mp_obj_t usb_host_audio_usbin_obj_get_streaming(mp_obj_t self_in) { + usb_host_audio_usbin_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + return mp_obj_new_bool(common_hal_usb_host_audio_usbin_get_streaming(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(usb_host_audio_usbin_get_streaming_obj, usb_host_audio_usbin_obj_get_streaming); + +MP_PROPERTY_GETTER(usb_host_audio_usbin_streaming_obj, + (mp_obj_t)&usb_host_audio_usbin_get_streaming_obj); + +//| overflow: bool +//| """True if audio was dropped since the last time this was checked: it +//| was not played or recorded fast enough, or the host controller lost +//| packets. (read-only) +//| +//| Reading this clears the flag. Audio that arrives while nothing plays or +//| records also overflows, except that starting playback discards it without +//| setting the flag.""" +//| +//| +static mp_obj_t usb_host_audio_usbin_obj_get_overflow(mp_obj_t self_in) { + usb_host_audio_usbin_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + return mp_obj_new_bool(common_hal_usb_host_audio_usbin_get_overflow(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(usb_host_audio_usbin_get_overflow_obj, usb_host_audio_usbin_obj_get_overflow); + +MP_PROPERTY_GETTER(usb_host_audio_usbin_overflow_obj, + (mp_obj_t)&usb_host_audio_usbin_get_overflow_obj); + +static const mp_rom_map_elem_t usb_host_audio_usbin_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR___del__), MP_ROM_PTR(&usb_host_audio_usbin_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&usb_host_audio_usbin_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_record), MP_ROM_PTR(&usb_host_audio_usbin_record_obj) }, + // sample_rate / bits_per_sample / channel_count come from the audiosample + // protocol's shared property getters. + AUDIOSAMPLE_FIELDS, + { MP_ROM_QSTR(MP_QSTR_bit_depth), MP_ROM_PTR(&usb_host_audio_usbin_bit_depth_obj) }, + { MP_ROM_QSTR(MP_QSTR_samples_signed), MP_ROM_PTR(&usb_host_audio_usbin_samples_signed_obj) }, + { MP_ROM_QSTR(MP_QSTR_streaming), MP_ROM_PTR(&usb_host_audio_usbin_streaming_obj) }, + { MP_ROM_QSTR(MP_QSTR_overflow), MP_ROM_PTR(&usb_host_audio_usbin_overflow_obj) }, +}; +static MP_DEFINE_CONST_DICT(usb_host_audio_usbin_locals_dict, usb_host_audio_usbin_locals_dict_table); + +static const audiosample_p_t usb_host_audio_usbin_proto = { + MP_PROTO_IMPLEMENT(MP_QSTR_protocol_audiosample) + .reset_buffer = (audiosample_reset_buffer_fun)common_hal_usb_host_audio_usbin_reset_buffer, + .get_buffer = (audiosample_get_buffer_fun)common_hal_usb_host_audio_usbin_get_buffer, +}; + +MP_DEFINE_CONST_OBJ_TYPE( + usb_host_audio_usbin_type, + MP_QSTR_USBIn, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, usb_host_audio_usbin_make_new, + locals_dict, &usb_host_audio_usbin_locals_dict, + protocol, &usb_host_audio_usbin_proto + ); diff --git a/shared-bindings/usb_host_audio/USBIn.h b/shared-bindings/usb_host_audio/USBIn.h new file mode 100644 index 00000000000..1477b505d3e --- /dev/null +++ b/shared-bindings/usb_host_audio/USBIn.h @@ -0,0 +1,31 @@ +// 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/usb_host_audio/USBIn.h" + +extern const mp_obj_type_t usb_host_audio_usbin_type; + +void common_hal_usb_host_audio_usbin_construct(usb_host_audio_usbin_obj_t *self, + usb_core_device_obj_t *device, uint8_t endpoint, uint32_t sample_rate, + uint8_t channel_count, uint8_t bit_depth, bool samples_signed, + uint16_t max_packet_size, uint32_t buffer_frames, uint32_t iso_buffer_size); +void common_hal_usb_host_audio_usbin_deinit(usb_host_audio_usbin_obj_t *self); +bool common_hal_usb_host_audio_usbin_deinited(usb_host_audio_usbin_obj_t *self); +// length and the return value count samples; a stereo frame is two samples. +uint32_t common_hal_usb_host_audio_usbin_record_to_buffer(usb_host_audio_usbin_obj_t *self, + uint8_t *buffer, uint32_t length); +uint8_t common_hal_usb_host_audio_usbin_get_bit_depth(usb_host_audio_usbin_obj_t *self); +bool common_hal_usb_host_audio_usbin_get_samples_signed(usb_host_audio_usbin_obj_t *self); +bool common_hal_usb_host_audio_usbin_get_streaming(usb_host_audio_usbin_obj_t *self); +bool common_hal_usb_host_audio_usbin_get_overflow(usb_host_audio_usbin_obj_t *self); + +void common_hal_usb_host_audio_usbin_reset_buffer(usb_host_audio_usbin_obj_t *self, + bool single_channel_output, uint8_t channel); +audioio_get_buffer_result_t common_hal_usb_host_audio_usbin_get_buffer( + usb_host_audio_usbin_obj_t *self, bool single_channel_output, uint8_t channel, + uint8_t **buffer, uint32_t *buffer_length); diff --git a/shared-bindings/usb_host_audio/__init__.c b/shared-bindings/usb_host_audio/__init__.c new file mode 100644 index 00000000000..d055a07a301 --- /dev/null +++ b/shared-bindings/usb_host_audio/__init__.c @@ -0,0 +1,40 @@ +// 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/usb_host_audio/__init__.h" +#include "shared-bindings/usb_host_audio/USBIn.h" + +//| """Audio from USB devices connected to a USB host port. +//| +//| The `usb_host_audio` module contains the `USBIn` class, which streams the +//| audio of a USB microphone into the audio pipeline or records it. +//| +//| This module only moves the audio. Finding the microphone, picking one of its +//| formats and starting its stream is done with `usb.core`, for example by the +//| ``adafruit_usb_host_microphone`` library. +//| +//| All classes change hardware state and should be deinitialized when they +//| are no longer needed. To do so, either call :py:meth:`!deinit` or use a +//| context manager.""" + +static const mp_rom_map_elem_t usb_host_audio_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_usb_host_audio) }, + { MP_ROM_QSTR(MP_QSTR_USBIn), MP_ROM_PTR(&usb_host_audio_usbin_type) }, +}; + +static MP_DEFINE_CONST_DICT(usb_host_audio_module_globals, usb_host_audio_module_globals_table); + +const mp_obj_module_t usb_host_audio_module = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&usb_host_audio_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_usb_host_audio, usb_host_audio_module); diff --git a/shared-bindings/usb_host_audio/__init__.h b/shared-bindings/usb_host_audio/__init__.h new file mode 100644 index 00000000000..779b49ffd8d --- /dev/null +++ b/shared-bindings/usb_host_audio/__init__.h @@ -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 + +#pragma once diff --git a/shared-module/usb/core/Device.c b/shared-module/usb/core/Device.c index 4a1547446e1..aa3da1217a5 100644 --- a/shared-module/usb/core/Device.c +++ b/shared-module/usb/core/Device.c @@ -39,6 +39,14 @@ static inline void usb_host_bulk_device_deinit(usb_core_device_obj_t *device) { } #endif +#if CIRCUITPY_USB_HOST_AUDIO +#include "shared-module/usb_host_audio/USBIn.h" +#else +static inline void usb_host_audio_stop_device(uint8_t device_address) { + (void)device_address; +} +#endif + // Track what device numbers are mounted. We can't use tuh_ready() because it is // true before enumeration completes and TinyUSB drivers are started. static size_t _mounted_devices = 0; @@ -404,6 +412,7 @@ void common_hal_usb_core_device_set_configuration(usb_core_device_obj_t *self, m // We assume that the config index is one less than the value. uint8_t config_index = configuration - 1; usb_host_bulk_stop_device(self->device_address); + usb_host_audio_stop_device(self->device_address); // Get the configuration descriptor and cache it. We'll use it later to open // endpoints. @@ -572,6 +581,7 @@ mp_int_t common_hal_usb_core_device_ctrl_transfer(usb_core_device_obj_t *self, if (len == 0 && ((bmRequestType == 0x01 && bRequest == TUSB_REQ_SET_INTERFACE) || (bmRequestType == 0x00 && bRequest == TUSB_REQ_SET_CONFIGURATION))) { usb_host_bulk_stop_device(self->device_address); + usb_host_audio_stop_device(self->device_address); } // Timeout is in ms. diff --git a/shared-module/usb_host_audio/USBIn.c b/shared-module/usb_host_audio/USBIn.c new file mode 100644 index 00000000000..243a31fcf3e --- /dev/null +++ b/shared-module/usb_host_audio/USBIn.c @@ -0,0 +1,501 @@ +// 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/usb_host_audio/USBIn.h" + +#include + +#include "tusb.h" +#include "host/usbh_pvt.h" + +#include "py/runtime.h" +#include "shared/runtime/interrupt_char.h" +#include "shared-bindings/usb/core/__init__.h" +#include "shared-bindings/usb/core/Device.h" +#include "shared-bindings/usb_host/Port.h" +#include "supervisor/port.h" +#include "supervisor/port_heap.h" +#include "supervisor/shared/tick.h" + +// Room for a good number of records per transfer, for when the ring in the +// host controller has a backlog. +#define USBIN_MIN_XFER_BUFFER (1024) +// The host controller marks lost packets with a record of this length. +#define USBIN_LOST_RECORD (0xffff) +// get_buffer() averages the frames waiting over about 2^this calls. +#define USBIN_LEVEL_SHIFT (6) +// record() gives up when the microphone sends nothing for this long. +#define USBIN_RECORD_TIMEOUT_MS (1000) +// deinit() waits this long for the device to stop streaming. A request without +// a data stage takes at most 50 ms. +#define USBIN_STOP_TIMEOUT_MS (100) + +// The host controller receives one isochronous IN stream at a time, so there is +// one streaming object at most. TinyUSB copies the transfer and calls back with +// only user_data, which carries _seq so that a completion which was already on +// its way when its stream ended is recognised and dropped. +static usb_host_audio_usbin_obj_t *_active; +static uintptr_t _seq; + +static void _ring_append(usb_host_audio_usbin_obj_t *self, const uint8_t *data, uint32_t frames) { + if (frames > self->ring_frames) { + data += (frames - self->ring_frames) * self->wire_frame_bytes; + frames = self->ring_frames; + } + if (self->ring_count + frames > self->ring_frames) { + // Nothing is reading fast enough. Keep the newest audio. + uint32_t excess = self->ring_count + frames - self->ring_frames; + self->ring_head = (self->ring_head + excess) % self->ring_frames; + self->ring_count -= excess; + self->overflowed = true; + self->overflow = true; + } + uint32_t tail = (self->ring_head + self->ring_count) % self->ring_frames; + self->ring_count += frames; + while (frames > 0) { + uint32_t run = MIN(frames, self->ring_frames - tail); + uint8_t *dest = self->ring + tail * self->frame_bytes; + if (self->bit_depth > 16) { + // Keep the top two bytes of each little endian 3 or 4 byte sample. + const uint32_t sample_bytes = self->bit_depth / 8; + for (uint32_t i = 0; i < run * self->base.channel_count; i++) { + *dest++ = data[sample_bytes - 2]; + *dest++ = data[sample_bytes - 1]; + data += sample_bytes; + } + } else { + memcpy(dest, data, run * self->frame_bytes); + data += run * self->frame_bytes; + } + tail = (tail + run) % self->ring_frames; + frames -= run; + } +} + +static void _ring_take(usb_host_audio_usbin_obj_t *self, uint8_t *dest, uint32_t frames) { + uint32_t first = MIN(frames, self->ring_frames - self->ring_head); + if (dest != NULL) { + memcpy(dest, self->ring + self->ring_head * self->frame_bytes, first * self->frame_bytes); + memcpy(dest + first * self->frame_bytes, self->ring, (frames - first) * self->frame_bytes); + } + self->ring_head = (self->ring_head + frames) % self->ring_frames; + self->ring_count -= frames; +} + +static void _transfer_done_cb(tuh_xfer_t *xfer); + +static bool _queue_transfer(usb_host_audio_usbin_obj_t *self) { + tuh_xfer_t xfer = { + .daddr = self->device_address, + .ep_addr = self->endpoint, + .buffer = self->xfer_buffer, + .buflen = self->xfer_buffer_len, + .complete_cb = _transfer_done_cb, + .user_data = _seq, + }; + self->streaming = tuh_edpt_xfer(&xfer); + return self->streaming; +} + +// Runs inside tuh_task(), so as a background callback on core 0. +static void _transfer_done_cb(tuh_xfer_t *xfer) { + usb_host_audio_usbin_obj_t *self = _active; + if (self == NULL || xfer->user_data != _seq) { + // Late completion of a stream that already ended. + return; + } + if (xfer->result != XFER_RESULT_SUCCESS) { + self->streaming = false; + return; + } + // The transfer holds whole records: a little endian uint16_t length, then + // one packet's payload. + const uint8_t *buf = self->xfer_buffer; + uint32_t len = MIN(xfer->actual_len, self->xfer_buffer_len); + uint32_t pos = 0; + while (pos + 2 <= len) { + uint32_t record_len = buf[pos] | (buf[pos + 1] << 8); + pos += 2; + if (record_len == USBIN_LOST_RECORD) { + self->overflow = true; + continue; + } + record_len = MIN(record_len, len - pos); + _ring_append(self, buf + pos, record_len / self->wire_frame_bytes); + pos += record_len; + } + _queue_transfer(self); +} + +// The number of the interface whose alternate setting has endpoint. +static uint8_t _endpoint_interface(usb_core_device_obj_t *device, uint8_t endpoint) { + const uint8_t *desc = device->configuration_descriptor; + if (desc == NULL) { + return USBIN_NO_INTERFACE; + } + const uint8_t *end = desc + tu_le16toh(((const tusb_desc_configuration_t *)desc)->wTotalLength); + uint8_t interface = USBIN_NO_INTERFACE; + for (const uint8_t *p = tu_desc_next(desc); p < end; p = tu_desc_next(p)) { + if (tu_desc_type(p) == TUSB_DESC_INTERFACE) { + interface = ((const tusb_desc_interface_t *)p)->bInterfaceNumber; + } else if (tu_desc_type(p) == TUSB_DESC_ENDPOINT && + ((const tusb_desc_endpoint_t *)p)->bEndpointAddress == endpoint) { + return interface; + } + } + return USBIN_NO_INTERFACE; +} + +// Select alternate setting 0, which stops the microphone sending. usb.core +// ends the transfer first, as it does for the same request from Python. This +// runs when unwinding from an exception and in the finaliser, so it neither +// raises nor waits long. +static void _stop_device(usb_host_audio_usbin_obj_t *self) { + if (self->interface == USBIN_NO_INTERFACE || common_hal_usb_core_device_deinited(self->device)) { + return; + } + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + common_hal_usb_core_device_ctrl_transfer(self->device, + TUSB_DIR_OUT | (TUSB_REQ_TYPE_STANDARD << 5) | TUSB_REQ_RCPT_INTERFACE, + TUSB_REQ_SET_INTERFACE, 0, self->interface, NULL, 0, USBIN_STOP_TIMEOUT_MS); + nlr_pop(); + } +} + +void common_hal_usb_host_audio_usbin_construct(usb_host_audio_usbin_obj_t *self, + usb_core_device_obj_t *device, uint8_t endpoint, uint32_t sample_rate, + uint8_t channel_count, uint8_t bit_depth, bool samples_signed, + uint16_t max_packet_size, uint32_t buffer_frames, uint32_t iso_buffer_size) { + // The finaliser may run on an object that a raise below left half made. + self->xfer_buffer = NULL; + if (_active != NULL) { + if (common_hal_usb_host_audio_usbin_get_streaming(_active)) { + // The host controller would hand the one stream it receives to the + // newer endpoint and fail the older one. + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q in use"), MP_QSTR_USBIn); + } + // Its stream ended, so it is of no more use. Don't make the caller + // find and deinit an object that it may not even have any more. + common_hal_usb_host_audio_usbin_deinit(_active); + } + + self->device = device; + self->device_address = device->device_address; + self->endpoint = endpoint; + self->bit_depth = bit_depth; + self->wire_frame_bytes = (bit_depth / 8) * channel_count; + // The audio pipeline carries 8 and 16 bit samples, so 24 and 32 bit ones are narrowed. + self->base.bits_per_sample = bit_depth == 8 ? 8 : 16; + self->base.channel_count = channel_count; + self->base.sample_rate = sample_rate; + self->base.samples_signed = samples_signed; + self->base.single_buffer = false; + self->frame_bytes = (self->base.bits_per_sample / 8) * channel_count; + self->buffer_frames = buffer_frames; + self->output_half_bytes = buffer_frames * self->frame_bytes; + self->base.max_buffer_length = self->output_half_bytes * 2; + self->output_buffer = NULL; + self->output_index = 0; + self->ring_frames = buffer_frames * USBIN_RING_BUFFERS; + self->ring_head = 0; + self->ring_count = 0; + self->primed = false; + self->overflowed = false; + self->overflow = false; + self->streaming = false; + + if (!common_hal_usb_core_device_open_endpoint(device, endpoint)) { + mp_raise_usb_core_USBError(NULL); + } + self->interface = _endpoint_interface(device, endpoint); + + // The host controller keeps the packets that arrive between transfers in a + // ring of its own. Only one isochronous stream at a time can use it. + if (!common_hal_usb_host_port_claim_iso_buffer(iso_buffer_size)) { + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q in use"), MP_QSTR_usb_host); + } + + // A record that doesn't fit in the transfer buffer is dropped by the host + // controller, so leave room for the largest packet and its length. + self->xfer_buffer_len = MAX(USBIN_MIN_XFER_BUFFER, max_packet_size + 2); + size_t ring_bytes = self->ring_frames * self->frame_bytes; + self->ring = port_malloc(ring_bytes, false); + // The host controller writes the transfer buffer. + self->xfer_buffer = port_malloc(self->xfer_buffer_len, true); + if (self->ring == NULL || self->xfer_buffer == NULL) { + common_hal_usb_host_port_release_iso_buffer(); + port_free(self->ring); + port_free(self->xfer_buffer); + self->ring = NULL; + self->xfer_buffer = NULL; + m_malloc_fail(ring_bytes + self->xfer_buffer_len); + } + + _seq++; + _active = self; + if (!_queue_transfer(self)) { + common_hal_usb_host_audio_usbin_deinit(self); + mp_raise_usb_core_USBError(NULL); + } +} + +bool common_hal_usb_host_audio_usbin_deinited(usb_host_audio_usbin_obj_t *self) { + return self->xfer_buffer == NULL; +} + +void common_hal_usb_host_audio_usbin_deinit(usb_host_audio_usbin_obj_t *self) { + if (common_hal_usb_host_audio_usbin_deinited(self)) { + return; + } + if (common_hal_usb_host_audio_usbin_get_streaming(self)) { + // Don't leave the microphone sending to an endpoint that is no longer + // polled. A stream that already ended was stopped by whoever ended it, + // and its interface may be streaming for someone else by now. + _stop_device(self); + } + if (_active == self) { + if (self->streaming) { + // Once this returns the host controller no longer writes the + // transfer buffer. It fails when the device is gone, and then its + // endpoints are closed already. + tuh_edpt_abort_xfer(self->device_address, self->endpoint); + } + // Drop a completion that is queued already. + _seq++; + _active = NULL; + // Whatever ended the stream also stopped the host controller writing + // to its ring. + common_hal_usb_host_port_release_iso_buffer(); + } + self->streaming = false; + port_free(self->xfer_buffer); + port_free(self->ring); + port_free(self->output_buffer); + self->xfer_buffer = NULL; + self->ring = NULL; + self->output_buffer = NULL; + self->device = NULL; + self->ring_count = 0; +} + +void usb_host_audio_reset(void) { + if (_active != NULL) { + common_hal_usb_host_audio_usbin_deinit(_active); + } +} + +void usb_host_audio_stop_device(uint8_t device_address) { + usb_host_audio_usbin_obj_t *self = _active; + if (self == NULL || self->device_address != device_address) { + return; + } + if (self->streaming) { + // The host controller stops polling the endpoint when it sends the + // request. A transfer queued after that would start polling it again, + // which some devices answer by no longer answering control requests. + tuh_edpt_abort_xfer(self->device_address, self->endpoint); + self->streaming = false; + } + // Drop a completion that is queued already, so it queues nothing. + _seq++; +} + +uint32_t common_hal_usb_host_audio_usbin_record_to_buffer(usb_host_audio_usbin_obj_t *self, + uint8_t *buffer, uint32_t length) { + uint32_t frames = length / self->base.channel_count; + uint32_t done = 0; + if (self->overflowed) { + // There is a gap behind the buffered audio. Record what comes after it. + _ring_take(self, NULL, self->ring_count); + self->overflowed = false; + } + uint32_t last_audio = supervisor_ticks_ms32(); + while (done < frames) { + uint32_t take = MIN(frames - done, self->ring_count); + if (take > 0) { + _ring_take(self, buffer + done * self->frame_bytes, take); + done += take; + last_audio = supervisor_ticks_ms32(); + continue; + } + if (!common_hal_usb_host_audio_usbin_get_streaming(self) || mp_hal_is_interrupted() || + supervisor_ticks_ms32() - last_audio > USBIN_RECORD_TIMEOUT_MS) { + break; + } + // The background tasks include TinyUSB, which fills the ring. + RUN_BACKGROUND_TASKS; + } + return done * self->base.channel_count; +} + +uint8_t common_hal_usb_host_audio_usbin_get_bit_depth(usb_host_audio_usbin_obj_t *self) { + return self->bit_depth; +} + +bool common_hal_usb_host_audio_usbin_get_samples_signed(usb_host_audio_usbin_obj_t *self) { + return self->base.samples_signed; +} + +bool common_hal_usb_host_audio_usbin_get_streaming(usb_host_audio_usbin_obj_t *self) { + // Closing the endpoint, as usb.core.Device.set_configuration() does, ends + // the transfer without a completion. + if (self->streaming && !usbh_edpt_busy(self->device_address, self->endpoint)) { + self->streaming = false; + } + return self->streaming; +} + +bool common_hal_usb_host_audio_usbin_get_overflow(usb_host_audio_usbin_obj_t *self) { + bool overflow = self->overflow; + self->overflow = false; + return overflow; +} + +void common_hal_usb_host_audio_usbin_reset_buffer(usb_host_audio_usbin_obj_t *self, + bool single_channel_output, uint8_t channel) { + (void)single_channel_output; + (void)channel; + if (common_hal_usb_host_audio_usbin_deinited(self)) { + return; + } + if (self->output_buffer == NULL) { + // The output backend's DMA may read this buffer directly. After the + // two halves there is room for the frames that _stretch() works from. + size_t size = self->base.max_buffer_length + (self->buffer_frames + 1) * self->frame_bytes; + self->output_buffer = port_malloc(size, true); + if (self->output_buffer == NULL) { + m_malloc_fail(size); + } + } + self->output_index = 0; + // Start on live audio rather than on what piled up while nothing played. + // Dropping that is not an overflow. + _ring_take(self, NULL, self->ring_count); + self->primed = false; + self->overflowed = false; + self->overflow = false; +} + +static void _fill_silence(usb_host_audio_usbin_obj_t *self, uint8_t *buffer, uint32_t bytes) { + if (self->base.samples_signed) { + memset(buffer, 0, bytes); + } else if (self->base.bits_per_sample == 8) { + memset(buffer, 0x80, bytes); + } else { + for (uint32_t i = 0; i < bytes; i += 2) { + buffer[i] = 0; + buffer[i + 1] = 0x80; + } + } +} + +// Make buffer_frames frames in out from the in_frames frames in in, by linear +// interpolation. The first and last frames are kept as they are, so that +// consecutive buffers still join up. +static void _stretch(usb_host_audio_usbin_obj_t *self, uint8_t *out, const uint8_t *in, uint32_t in_frames) { + const uint32_t channels = self->base.channel_count; + const uint32_t out_frames = self->buffer_frames; + // 16.16 fixed point position in the input + const uint32_t step = ((in_frames - 1) << 16) / (out_frames - 1); + const bool is_16 = self->base.bits_per_sample == 16; + const bool is_signed = self->base.samples_signed; + uint32_t pos = 0; + for (uint32_t i = 0; i < out_frames; i++, pos += step) { + uint32_t index = pos >> 16; + // 15 bits, so that its product with a difference of samples fits. + int32_t frac = (pos & 0xffff) >> 1; + if (i == out_frames - 1 || index >= in_frames - 1) { + index = in_frames - 1; + frac = 0; + } + for (uint32_t c = 0; c < channels; c++) { + uint32_t a_at = index * channels + c; + uint32_t b_at = frac ? a_at + channels : a_at; + uint32_t out_at = i * channels + c; + int32_t a, b; + if (is_16) { + a = is_signed ? ((const int16_t *)in)[a_at] : ((const uint16_t *)in)[a_at]; + b = is_signed ? ((const int16_t *)in)[b_at] : ((const uint16_t *)in)[b_at]; + ((uint16_t *)out)[out_at] = a + (((b - a) * frac) >> 15); + } else { + a = is_signed ? ((const int8_t *)in)[a_at] : in[a_at]; + b = is_signed ? ((const int8_t *)in)[b_at] : in[b_at]; + out[out_at] = a + (((b - a) * frac) >> 15); + } + } + } +} + +// Called by the output backend's refill, so it must never block. +audioio_get_buffer_result_t common_hal_usb_host_audio_usbin_get_buffer( + usb_host_audio_usbin_obj_t *self, bool single_channel_output, uint8_t channel, + uint8_t **buffer, uint32_t *buffer_length) { + if (common_hal_usb_host_audio_usbin_deinited(self) || self->output_buffer == NULL) { + *buffer = NULL; + *buffer_length = 0; + return GET_BUFFER_DONE; + } + uint8_t *out = self->output_buffer + self->output_half_bytes * self->output_index; + self->output_index = 1 - self->output_index; + + uint32_t target = self->buffer_frames * USBIN_TARGET_BUFFERS; + if (self->overflowed) { + // The consumer stalled or runs slower than the microphone. Skip ahead + // so that the latency stays bounded. + self->overflowed = false; + if (self->ring_count > target) { + _ring_take(self, NULL, self->ring_count - target); + } + } + if (!self->primed && self->ring_count >= target) { + self->primed = true; + self->level = self->ring_count << USBIN_LEVEL_SHIFT; + self->drift = 0; + } + if (self->primed && self->ring_count >= self->buffer_frames) { + // The microphone's clock and the consumer's drift apart, which shows + // as a slow change of how much audio is waiting. The count at any one + // call depends on when both sides were last serviced, so follow its + // average. Make a buffer from one frame more or less than it holds, the + // more often the further that average is from the target: the sum of + // the differences says when. That spreads the corrections evenly, + // where correcting only beyond a threshold would bunch them up into + // an audible wobble of the pitch. + self->level += (int32_t)self->ring_count - (int32_t)(self->level >> USBIN_LEVEL_SHIFT); + const int32_t every = self->buffer_frames; + self->drift += (int32_t)(self->level >> USBIN_LEVEL_SHIFT) - (int32_t)target; + self->drift = MAX(-2 * every, MIN(2 * every, self->drift)); + uint32_t take = self->buffer_frames; + if (self->drift >= every && self->ring_count > take) { + take++; + self->drift -= every; + } else if (self->drift <= -every) { + take--; + self->drift += every; + } + if (take == self->buffer_frames) { + _ring_take(self, out, take); + } else { + uint8_t *stretch = self->output_buffer + self->output_half_bytes * 2; + _ring_take(self, stretch, take); + _stretch(self, out, stretch, take); + } + } else { + // Play silence until enough audio is waiting again to ride out the + // jitter in how both sides are serviced. + self->primed = false; + _fill_silence(self, out, self->output_half_bytes); + } + + if (single_channel_output) { + out += (channel % self->base.channel_count) * (self->base.bits_per_sample / 8); + } + *buffer = out; + *buffer_length = self->output_half_bytes; + // A live microphone is an infinite stream; never report DONE or the backend stops. + return GET_BUFFER_MORE_DATA; +} diff --git a/shared-module/usb_host_audio/USBIn.h b/shared-module/usb_host_audio/USBIn.h new file mode 100644 index 00000000000..258e1a4e02b --- /dev/null +++ b/shared-module/usb_host_audio/USBIn.h @@ -0,0 +1,69 @@ +// 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" +#include "shared-module/usb/core/Device.h" + +// The PCM ring holds this many get_buffer() buffers. Playback starts, and +// restarts after an underrun, once USBIN_TARGET_BUFFERS of them are waiting. +#define USBIN_RING_BUFFERS (8) +#define USBIN_TARGET_BUFFERS (2) +#define USBIN_NO_INTERFACE (0xff) + +typedef struct { + // so USBIn can be used directly as an audiosample source. + audiosample_base_t base; + // Keeps the device object, which owns the open endpoint, alive. + usb_core_device_obj_t *device; + uint8_t device_address; + uint8_t endpoint; + // The streaming interface that has endpoint, or USBIN_NO_INTERFACE. + uint8_t interface; + uint8_t bit_depth; // on the wire; base.bits_per_sample is what we produce + uint8_t wire_frame_bytes; + uint8_t frame_bytes; + // A transfer is queued. False once the stream stopped + bool streaming; + // Both buffers below are outside the VM heap; NULL xfer_buffer means deinited. + uint8_t *xfer_buffer; + uint16_t xfer_buffer_len; + // Converted PCM frames. Filled by the transfer's completion callback and + // drained by get_buffer() and record(). All of them run on core 0 outside + // interrupts (TinyUSB's task and the audio refill are background callbacks). + uint8_t *ring; + uint32_t ring_frames; + uint32_t ring_head; + uint32_t ring_count; + uint32_t buffer_frames; + bool primed; + // The ring dropped audio; the next reader skips ahead. + bool overflowed; + // Audio was dropped since the user last read the overflow property. + bool overflow; + // Average of ring_count as get_buffer() finds it, scaled by 2^USBIN_LEVEL_SHIFT. + uint32_t level; + // Sum of how far level was from the target, less what was corrected. + int32_t drift; + // Owned double-buffer returned to the output backend by get_buffer(), + // followed by scratch space for drift correction. Allocated lazily on the + // first reset_buffer() so record()-only use pays no extra RAM. + // base.max_buffer_length is the two halves; get_buffer() returns + // output_half_bytes of it. + uint8_t *output_buffer; + uint32_t output_half_bytes; + uint8_t output_index; +} usb_host_audio_usbin_obj_t; + +// Stops a stream left running by the VM that just finished. +void usb_host_audio_reset(void); +// Stops receiving from the device before a control request changes or +// disables its endpoints. The object keeps the audio received so far. +// Never raises or allocates. +void usb_host_audio_stop_device(uint8_t device_address); diff --git a/shared-module/usb_host_audio/__init__.c b/shared-module/usb_host_audio/__init__.c new file mode 100644 index 00000000000..ae8f2b17d90 --- /dev/null +++ b/shared-module/usb_host_audio/__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 here. The module's code is in USBIn.c. diff --git a/tests/circuitpython-manual/usb_host_audio/usbin_play.py b/tests/circuitpython-manual/usb_host_audio/usbin_play.py new file mode 100644 index 00000000000..6a60fe5ace1 --- /dev/null +++ b/tests/circuitpython-manual/usb_host_audio/usbin_play.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +# Play a USB microphone live on the Fruit Jam's headphone jack: USBIn is the +# root of the audio chain, resampled from its 16 kHz to the DAC's 48 kHz. +# Reports once a second whether audio was dropped. Needs the adafruit_tlv320 library. +# +# See usbin_record.py for the microphone set up. + +import time + +import adafruit_tlv320 +import audiobusio +import audiomixer +import audiospeed +import board +import digitalio +import usb.core +import usb_host_audio + +INTERFACE = 3 +ALTERNATE = 1 +ENDPOINT = 0x86 +SAMPLE_RATE = 16000 + +reset_pin = digitalio.DigitalInOut(board.PERIPH_RESET) +reset_pin.switch_to_output(False) +time.sleep(0.1) +reset_pin.value = True +dac = adafruit_tlv320.TLV320DAC3100(board.I2C()) +dac.configure_clocks(sample_rate=48000, bit_depth=16) +dac.headphone_output = True +dac.dac_volume = -10 +audio = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WS, board.I2S_DIN) +mixer = audiomixer.Mixer( + voice_count=1, sample_rate=48000, channel_count=1, bits_per_sample=16, buffer_size=2048 +) +audio.play(mixer) + +device = usb.core.find(idVendor=0x046D, idProduct=0x0825) +device.set_configuration() +device.ctrl_transfer(0x01, 11, ALTERNATE, INTERFACE, None) +device.ctrl_transfer(0x22, 0x01, 0x0100, ENDPOINT, SAMPLE_RATE.to_bytes(3, "little")) + +mic = usb_host_audio.USBIn(device, ENDPOINT, sample_rate=SAMPLE_RATE, max_packet_size=68) +mixer.voice[0].play(audiospeed.Resampler(mic)) +try: + while mic.streaming: + print("overflow", mic.overflow) + time.sleep(1) +finally: + mixer.voice[0].stop() + # Stops the stream too. + mic.deinit() diff --git a/tests/circuitpython-manual/usb_host_audio/usbin_record.py b/tests/circuitpython-manual/usb_host_audio/usbin_record.py new file mode 100644 index 00000000000..57a99d1d221 --- /dev/null +++ b/tests/circuitpython-manual/usb_host_audio/usbin_record.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +# Record from a USB Audio Class 1.0 microphone with usb_host_audio.USBIn and +# print the level of each tenth of a second. +# +# usb_host_audio only moves the audio, so this sets the microphone up by hand +# with the values of a Logitech C270 webcam: streaming interface 3, alternate +# setting 1, isochronous IN endpoint 0x86, 16 bit mono at 16 kHz. The +# adafruit_usb_host_microphone library finds these in the descriptors. + +import array +import math + +import usb.core +import usb_host_audio + +INTERFACE = 3 +ALTERNATE = 1 +ENDPOINT = 0x86 +SAMPLE_RATE = 16000 + +device = usb.core.find(idVendor=0x046D, idProduct=0x0825) +device.set_configuration() +# SET_INTERFACE starts the stream, then SET_CUR of the sampling frequency. +device.ctrl_transfer(0x01, 11, ALTERNATE, INTERFACE, None) +device.ctrl_transfer(0x22, 0x01, 0x0100, ENDPOINT, SAMPLE_RATE.to_bytes(3, "little")) + +samples = array.array("h", bytes(2 * SAMPLE_RATE // 10)) +with usb_host_audio.USBIn(device, ENDPOINT, sample_rate=SAMPLE_RATE, max_packet_size=68) as mic: + for _ in range(50): + count = mic.record(samples, len(samples)) + if count == 0: + break + mean = sum(samples) / count + rms = math.sqrt(sum((s - mean) ** 2 for s in samples) / count) + print("#" * int(rms / 100), "overflow:", mic.overflow) +# Leaving the with block stops the stream, also on an exception.