From 03e6b7c67b034a366f90e230fa82a1f8f67e53e7 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:56:29 -0600 Subject: [PATCH 1/3] audioif: the GIGA renders its audio from an interrupt The DAC's pump ran on the interpreter, so a garbage collection, a write to /flash or a long Python callback emptied the 93 ms ring and the jack played silence. It now runs from an interrupt of the driver's own (SWPMI1's vector, priority 7, below the flash code's mask) on a 12 KB stack of its own, with the heap locked. The interpreter's side of the pump lock holds the interrupt off, and _audioif.render_mode() switches back to the interpreter. The interrupt needs an audiodsp with render_kick (AUDIODSP_PORT_IRQ_HOOKS); built against an older one the pump stays on the interpreter, as before. --- modules/audioif/CHANGELOG.md | 10 + modules/audioif/README.md | 29 ++- modules/audioif/src/audioif_stm32.c | 332 ++++++++++++++++++++++++++-- 3 files changed, 342 insertions(+), 29 deletions(-) diff --git a/modules/audioif/CHANGELOG.md b/modules/audioif/CHANGELOG.md index a53f9fcb..d870b581 100644 --- a/modules/audioif/CHANGELOG.md +++ b/modules/audioif/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- **The GIGA's audio no longer stops for the interpreter.** The pump renders + from an interrupt of its own, on its own stack, so a garbage collection, a + write to `/flash` or a Python callback that holds the interpreter longer + than the DAC's ring no longer plays silence. Synth Station played ten + minutes with a `gc.collect()` every 3 s and a 32 KB write every 30 s without + a starved frame, where the same run on the interpreter starved 3.5%. + `_audioif.render_mode()` switches between `"irq"` and `"interpreter"`, and + `_audioif.render_stats()` reports the longest render and its stack use. It + needs an audiodsp with the interrupt hooks; with an older one the pump + stays on the interpreter. - **A microphone and a speaker on two clocks.** `_audioif.Input(..., ring=ring)` on Windows reads the microphone on its own thread into an `audiopump.Ring`, so an `audiopump.DriftResampler` can play it at the diff --git a/modules/audioif/README.md b/modules/audioif/README.md index 39575e5b..4bd3b84a 100644 --- a/modules/audioif/README.md +++ b/modules/audioif/README.md @@ -132,15 +132,24 @@ and `while out.playing: pass` waits for either. `quiescent_value` takes only CircuitPython's default, 0x8000, which is where the DAC rests, and `sample_rate=` (ours) is the rate it opens at before the first `play()`. -The port has no threads, so the pump runs in the engine's service mode, on the -interpreter: a soft timer looks at the DMA every 4 ms and schedules the pump -whenever a block's worth of its ring has played. It runs between -bytecodes and while the interpreter sleeps. A C call that holds the interpreter -for longer than the ring underruns, and the DAC plays -silence until the pump catches up; `starved()` counts it. The ring holds about -93 ms; `buffer_ms=` (ours) asks for more or less, up to 12,288 frames (278 ms at -44.1 kHz). More rides out longer stalls and puts the sound further behind, so a -music player can ask for 250 and an instrument should leave it alone. +The port has no threads, so the pump runs in the engine's service mode: a soft +timer looks at the DMA every 4 ms and, whenever a block's worth of its ring has +played, raises an interrupt of the driver's own that runs the pump on its own +stack. A garbage collection, a write to `/flash` or a long Python callback +doesn't hold the audio up: Synth Station played ten minutes with a +`gc.collect()` every 3 s and 32 KB written to `/flash` every 30 s without a +starved frame. Run on the interpreter, the same test starved 3.5% of them. + +`_audioif.render_mode("interpreter")` runs the pump on the interpreter instead, +between bytecodes and while it sleeps, and `render_mode("irq")` goes back; +`_audioif.render_stats()` says how long the longest render took and how much of +its stack it used. With an audiodsp too old for the interrupt, the interpreter +is the only mode. There, a C call that holds the interpreter for longer than +the ring underruns, and the DAC plays silence until the pump catches up; +`starved()` counts it either way. The ring holds about 93 ms; `buffer_ms=` +(ours) asks for more or less, up to 12,288 frames (278 ms at 44.1 kHz). More +rides out longer stalls and puts the sound further behind, so a music player +can ask for 250 and an instrument should leave it alone. ## `audioi2sin.I2SIn` — recording from a microphone @@ -312,7 +321,7 @@ it and it has not been done. | **ESP32 family** | a FreeRTOS task pinned to the core the interpreter is not on, optionally with its stack in PSRAM | `xSemaphoreCreateRecursiveMutex`, which is priority-inheriting | `esp_timer_get_time` | the I2S channel, written from the pull loop | `Input`, single-port or two-port | | **unix** | `pthread` | recursive `pthread_mutex` | `clock_gettime` | a file the loop `write(2)`s each block to | — | | **Windows** | `_beginthreadex` | `CRITICAL_SECTION` | `QueryPerformanceCounter` | a file descriptor, or a WASAPI speaker written from the pull loop | `Input`, a WASAPI microphone read on the pump's thread | -| **STM32H7** (Arduino GIGA R1) | none: the engine's service mode, run by a soft timer that schedules the pump on the interpreter whenever the DAC has played a block | — | `mp_hal_ticks_us` | the DAC's two channels, through a circular DMA paced by TIM6 | a PDM microphone through DFSDM1, or an ADC1 pin paced by TIM15, into one ring by DMA; not an `Input` | +| **STM32H7** (Arduino GIGA R1) | none: the engine's service mode, run from an interrupt of the driver's own, on its own stack, whenever the DAC has played a block (or on the interpreter, by `render_mode()`) | the render interrupt, held off by the interpreter's side | `mp_hal_ticks_us` | the DAC's two channels, through a circular DMA paced by TIM6 | a PDM microphone through DFSDM1, or an ADC1 pin paced by TIM15, into one ring by DMA; not an `Input` | | **WebAssembly** | none — **and none is needed** | — | — | — | — | WebAssembly is not a gap. That build has no threads (no `-pthread`, no diff --git a/modules/audioif/src/audioif_stm32.c b/modules/audioif/src/audioif_stm32.c index 1ab04050..1a0ff3f9 100644 --- a/modules/audioif/src/audioif_stm32.c +++ b/modules/audioif/src/audioif_stm32.c @@ -12,14 +12,29 @@ // service mode and this driver is what calls service(). The DAC plays a ring // of 32-bit words, both channels at once through DHR12RD, fed by a circular // DMA and paced by TIM6. A soft timer looks at the DMA every few milliseconds -// from the PendSV interrupt; when a block's worth of the ring has been played -// it schedules a callback, and that callback, on the interpreter, runs the -// pump until the ring is full again. The pump therefore renders between -// bytecodes, and while the interpreter waits in sleep() or a blocking read, -// never inside an interrupt: a graph may allocate and may raise, and neither -// is allowed there. What that costs is that a C call that holds the -// interpreter longer than the ring (about 93 ms at any rate) underruns, and the -// ring plays silence until the pump catches up. +// from the PendSV interrupt, and when a block's worth of the ring has been +// played it has the pump run until the ring is full again. Where the pump +// runs is the render mode: +// +// - "irq", the default: in an interrupt of its own, on a stack of its own. +// Nothing the interpreter does can hold it up: not a garbage collection, +// not a Python callback, not a C call that keeps the interpreter for +// longer than the ring (about 93 ms at any rate). The interrupt sits at +// AUDIOIF_RENDER_PRI, below the priority the QSPI flash code masks while +// it holds the bus (IRQ_PRI_FLASH), so a render never reads a buffer in +// memory-mapped flash (ROMFS) while that flash is unmapped. The flash +// code masks one block operation at a time, so the render catches up +// between them: 32 KB written to /flash every 20 s, each write taking +// over a second, starved nothing. The engine treats the interrupt as its +// pump thread: a pull there doesn't raise or allocate, a file-backed +// source is refused, and the interpreter's side of the pump lock masks +// this one interrupt rather than waiting on a mutex. +// - "interpreter": a scheduled callback runs the pump between bytecodes, +// and while the interpreter waits in sleep() or a blocking read. A graph +// may allocate and may raise there, but anything that holds the +// interpreter longer than the ring underruns, and the ring plays silence +// until the pump catches up. _audioif.render_mode() switches between the +// two at any time. // // The hook table is in this file, with MP_REGISTER_MODULE, so that a static // archive can't let the engine's weak default win (audiodsp's @@ -30,6 +45,7 @@ #include #include +#include "py/gc.h" #include "py/mperrno.h" #include "py/mphal.h" #include "py/obj.h" @@ -38,6 +54,7 @@ #include "adc.h" #include "dma.h" +#include "irq.h" #include "pin.h" #include "timer.h" @@ -62,6 +79,24 @@ // DAC midscale, the silence of an unsigned 12-bit output, on both channels. #define AUDIOIF_SILENCE (0x08000800u) +// The render interrupt: a peripheral's vector this board doesn't use, raised +// by software. A board that uses SWPMI1 names another. +#ifndef AUDIOIF_RENDER_IRQN +#define AUDIOIF_RENDER_IRQN SWPMI1_IRQn +#define AUDIOIF_RENDER_IRQ_HANDLER SWPMI1_IRQHandler +#endif +// Its priority: below IRQ_PRI_FLASH, so the QSPI flash code masks it while +// the memory-mapped flash (ROMFS) is unavailable, and below USB, DMA and SPI, +// which preempt it; above I2C, the general timers, pin interrupts and PendSV. +#ifndef AUDIOIF_RENDER_PRI +#define AUDIOIF_RENDER_PRI NVIC_EncodePriority(NVIC_PRIORITYGROUP_4, 7, 0) +#endif +// Its stack, in bytes. The interpreter's is 16 KB and may be nearly full when +// the interrupt arrives, so the render doesn't stack on top of it. +#ifndef AUDIOIF_RENDER_STACK +#define AUDIOIF_RENDER_STACK (12288) +#endif + // In .bss, which the H7 boards' linker scripts put in AXI SRAM, where DMA1 // can read it; DMA1 can't reach the DTCM, and audiopump_i2s_open() refuses a // board that puts .bss there. The CPU writes the ring through the data cache, @@ -166,12 +201,24 @@ static uint32_t audioif_space(void) { return (uint32_t)(AUDIOIF_RING_FRAMES - (audioif_written - audioif_played)); } -// --- the kick: the pump runs here, on the interpreter --------------------- +// --- the kick: where the pump runs ---------------------------------------- + +enum { AUDIOIF_MODE_INTERPRETER, AUDIOIF_MODE_IRQ }; +// The interrupt renders only with an engine that can share the pump with it +// (AUDIODSP_PORT_IRQ_HOOKS): one that hands audiopump.service() to the +// interrupt, and holds it off while the interpreter changes the pump. +#ifdef AUDIODSP_PORT_IRQ_HOOKS +#define AUDIOIF_HAVE_IRQ (1) +static uint8_t audioif_mode = AUDIOIF_MODE_IRQ; +#else +#define AUDIOIF_HAVE_IRQ (0) +static uint8_t audioif_mode = AUDIOIF_MODE_INTERPRETER; +#endif // Run the pump only while the ring has a block's room, so the sink write // never waits for the DMA. Bounded, so a pump that stopped writing can't hold -// the interpreter. -void audiopump_i2s_service(void) { +// the interpreter, or the interrupt. +static void audioif_fill(void) { for (int i = 0; i < 32; i++) { audioif_poll(); if (audioif_space() < audioif_block) { @@ -185,6 +232,41 @@ void audiopump_i2s_service(void) { } } +// The render interrupt is armed while the DAC is open in "irq" mode. The +// interpreter's side of the pump lock disables it, counting, and the last +// release enables it again if it is still armed; a render asked for in +// between is left pending and runs then. +static bool audioif_render_armed; +static uint32_t audioif_lock_depth; + +static void audioif_render_arm(bool on) { + audioif_render_armed = on; + if (on) { + NVIC_SetPriority(AUDIOIF_RENDER_IRQN, AUDIOIF_RENDER_PRI); + if (audioif_lock_depth == 0) { + NVIC_EnableIRQ(AUDIOIF_RENDER_IRQN); + } + } else { + NVIC_DisableIRQ(AUDIOIF_RENDER_IRQN); + NVIC_ClearPendingIRQ(AUDIOIF_RENDER_IRQN); + } +} + +static inline bool audioif_in_render(void) { + return __get_IPSR() == (uint32_t)AUDIOIF_RENDER_IRQN + 16u; +} + +void audiopump_i2s_service(void) { + if (audioif_mode == AUDIOIF_MODE_IRQ) { + // From the interpreter (a `playing` read): the interrupt renders. + if (audioif_render_armed) { + NVIC_SetPendingIRQ(AUDIOIF_RENDER_IRQN); + } + return; + } + audioif_fill(); +} + static void audioif_kick_cb(mp_sched_node_t *node) { (void)node; nlr_buf_t nlr; @@ -200,10 +282,128 @@ static void audioif_kick_timer_cb(soft_timer_entry_t *self) { (void)self; audioif_poll(); if (audioif_space() >= audioif_block && audiopump_c_service_mode()) { - mp_sched_schedule_node(&audioif_kick_node, audioif_kick_cb); + if (audioif_mode == AUDIOIF_MODE_IRQ) { + if (audioif_render_armed) { + NVIC_SetPendingIRQ(AUDIOIF_RENDER_IRQN); + } + } else { + mp_sched_schedule_node(&audioif_kick_node, audioif_kick_cb); + } } } +// What the render interrupt has done, for _audioif.render_stats(). +static uint32_t audioif_renders; +static uint32_t audioif_render_us_max; + +// The render, on its own stack. The heap is locked, so a pull that tried to +// allocate gets a MemoryError rather than a heap the interpreter was in the +// middle of changing, and the error is caught here and counted. Nothing in the +// engine's pull raises; this is the net under it. +void audioif_render_irq(void); +void audioif_render_irq(void) { + if (!audioif_open || !audioif_render_armed || !audiopump_c_service_mode()) { + return; + } + const uint32_t t0 = mp_hal_ticks_us(); + gc_lock(); + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + audioif_fill(); + nlr_pop(); + } else { + audioif_service_errors++; + } + gc_unlock(); + const uint32_t dt = mp_hal_ticks_us() - t0; + if (dt > audioif_render_us_max) { + audioif_render_us_max = dt; + } + audioif_renders++; +} + +// The interrupt's stack, painted at boot so render_stats() can say how much +// of it the deepest render used. +#define AUDIOIF_STACK_WORDS (AUDIOIF_RENDER_STACK / 4) +#define AUDIOIF_STACK_PAINT (0x5a5aa5a5u) +uint32_t audioif_render_stack[AUDIOIF_STACK_WORDS] __attribute__((aligned(8))); +static bool audioif_stack_painted; + +static void audioif_stack_paint(void) { + if (!audioif_stack_painted) { + for (uint32_t i = 0; i < AUDIOIF_STACK_WORDS; i++) { + audioif_render_stack[i] = AUDIOIF_STACK_PAINT; + } + audioif_stack_painted = true; + } +} + +static uint32_t audioif_stack_used(void) { + uint32_t i = 0; + while (i < AUDIOIF_STACK_WORDS && audioif_render_stack[i] == AUDIOIF_STACK_PAINT) { + i++; + } + return (AUDIOIF_STACK_WORDS - i) * 4u; +} + +#define AUDIOIF_STR_(x) #x +#define AUDIOIF_STR(x) AUDIOIF_STR_(x) + +// Move to the render's stack, render, and move back. The exception frame stays +// on the stack the interrupt arrived on, which is where the return finds it; +// anything that preempts the render stacks on the render's own. +__attribute__((naked)) void AUDIOIF_RENDER_IRQ_HANDLER(void) { + __asm volatile ( + "mov r0, sp\n" + "movw r1, #:lower16:(audioif_render_stack + " AUDIOIF_STR(AUDIOIF_RENDER_STACK) ")\n" + "movt r1, #:upper16:(audioif_render_stack + " AUDIOIF_STR(AUDIOIF_RENDER_STACK) ")\n" + "mov sp, r1\n" + "push {r0, lr}\n" + "bl audioif_render_irq\n" + "pop {r0, lr}\n" + "mov sp, r0\n" + "bx lr\n" + ); +} + +// --- the engine's thread hooks, in "irq" mode ----------------------------- +// +// The interrupt is the pump's thread and the interpreter is the control side. +// The control side takes the lock by disabling the render interrupt, which +// can't be in the middle of a pull while the interpreter runs; the render +// itself, and any interrupt above it, never waits. + +static void audioif_lock_take(void) { + if (__get_IPSR() != 0) { + return; + } + if (audioif_lock_depth++ == 0) { + NVIC_DisableIRQ(AUDIOIF_RENDER_IRQN); + } +} + +static void audioif_lock_give(void) { + if (__get_IPSR() != 0 || audioif_lock_depth == 0) { + return; + } + if (--audioif_lock_depth == 0 && audioif_render_armed) { + NVIC_EnableIRQ(AUDIOIF_RENDER_IRQN); + } +} + +static uintptr_t audioif_self_id(void) { + return audioif_in_render() ? 2 : 1; +} + +#ifdef AUDIODSP_PORT_IRQ_HOOKS +// audiopump.service() on the interpreter: the interrupt renders instead. +static void audioif_render_kick(void) { + if (audioif_render_armed) { + NVIC_SetPendingIRQ(AUDIOIF_RENDER_IRQN); + } +} +#endif + // --- the hardware ---------------------------------------------------------- static const machine_pin_obj_t *audioif_dac_pin(int channel) { @@ -223,6 +423,7 @@ static int audioif_dac_channel(int id) { static void audioif_hw_stop(void) { soft_timer_remove(&audioif_kick_timer); + audioif_render_arm(false); AUDIOIF_DMA_STREAM->CR &= ~DMA_SxCR_EN; while (AUDIOIF_DMA_STREAM->CR & DMA_SxCR_EN) { } @@ -273,6 +474,13 @@ static void audioif_hw_start(int left, int right, uint32_t rate) { HAL_TIM_Base_Start(tim); audioif_open = true; + audioif_stack_paint(); + // A lock a raise left held would keep the render off for good; nothing + // holds it while a channel opens. + audioif_lock_depth = 0; + if (audioif_mode == AUDIOIF_MODE_IRQ) { + audioif_render_arm(true); + } soft_timer_static_init(&audioif_kick_timer, SOFT_TIMER_MODE_PERIODIC, AUDIOIF_KICK_MS, audioif_kick_timer_cb); soft_timer_insert(&audioif_kick_timer, AUDIOIF_KICK_MS); @@ -881,12 +1089,17 @@ uint32_t audiopump_i2s_rx_begin(const audiopump_i2s_cfg_t *cfg, uint32_t ring, i static uint64_t audioif_now_us(void) { static uint32_t last; static uint64_t high; + // Read from the interpreter and from the render interrupt, so the wrap + // is counted once whichever of them sees it. + mp_uint_t state = MICROPY_BEGIN_ATOMIC_SECTION(); uint32_t now = mp_hal_ticks_us(); if (now < last) { high += (uint64_t)1 << 32; } last = now; - return high | now; + uint64_t t = high | now; + MICROPY_END_ATOMIC_SECTION(state); + return t; } static void audioif_sleep_us(uint64_t us) { @@ -924,11 +1137,12 @@ static uint32_t audioif_sink_write(const uint8_t *buffer, uint32_t length, audioif_starved_pending = false; audioif_starved_counting = true; } - // What fits, at once. The pump runs on the interpreter here, so waiting - // for the DMA to make room would hold everything else up for as long as - // the block is longer than the ring: a one-shot RawSample is one block, - // however long. The engine keeps what isn't taken and offers it again on - // the next service, so nothing is lost and nothing waits. + // What fits, at once. The pump runs on the interpreter or in an + // interrupt here, and neither may wait for the DMA to make room: that + // would hold everything else up for as long as the block is longer than + // the ring, and a one-shot RawSample is one block, however long. The + // engine keeps what isn't taken and offers it again on the next service, + // so nothing is lost and nothing waits. (void)timeout_ms; uint32_t space = audioif_space(); if (frames > space) { @@ -968,18 +1182,52 @@ static void audioif_teardown(void) { if (audioif_open) { audioif_hw_stop(); } + audioif_lock_depth = 0; } -static const audiodsp_port_ops_t audioif_stm32_ops = { +// Not const: the thread hooks are there only in "irq" mode, and +// audioif_mode_set() takes them out for "interpreter". With them out, the +// pump runs on the interpreter exactly as it does on a port with no driver +// thread, and nothing is locked. +static audiodsp_port_ops_t audioif_stm32_ops = { .name = "stm32", + #if AUDIOIF_HAVE_IRQ + .lock_take = audioif_lock_take, + .lock_give = audioif_lock_give, + .self_id = audioif_self_id, + #endif .now_us = audioif_now_us, .sleep_us = audioif_sleep_us, .sink_ready = audioif_sink_ready, .sink_write = audioif_sink_write, .sink_dma_bytes = audioif_sink_dma_bytes, .teardown = audioif_teardown, + #ifdef AUDIODSP_PORT_IRQ_HOOKS + .render_kick = audioif_render_kick, + #endif }; +static void audioif_mode_set(uint8_t mode) { + if (mode == audioif_mode) { + return; + } + // From the interpreter, so no render is under way: the switch lands + // between two of them. + audioif_render_arm(false); + audioif_lock_depth = 0; + audioif_mode = mode; + const bool irq = mode == AUDIOIF_MODE_IRQ; + audioif_stm32_ops.lock_take = irq ? audioif_lock_take : NULL; + audioif_stm32_ops.lock_give = irq ? audioif_lock_give : NULL; + audioif_stm32_ops.self_id = irq ? audioif_self_id : NULL; + #ifdef AUDIODSP_PORT_IRQ_HOOKS + audioif_stm32_ops.render_kick = irq ? audioif_render_kick : NULL; + #endif + if (irq && audioif_open) { + audioif_render_arm(true); + } +} + const audiodsp_port_ops_t *audiodsp_port_driver(void) { return &audioif_stm32_ops; } @@ -1000,6 +1248,50 @@ static mp_obj_t audioif_stm32_stats(void) { } static MP_DEFINE_CONST_FUN_OBJ_0(audioif_stm32_stats_obj, audioif_stm32_stats); +// render_mode() -> "irq" or "interpreter"; render_mode(mode) switches, at any +// time, and the choice lasts until the board is reset. "interpreter" is the +// fallback: the pump renders on the interpreter, as on builds before the +// render interrupt. +static mp_obj_t audioif_stm32_render_mode(size_t n_args, const mp_obj_t *args) { + if (n_args == 1) { + const char *want = mp_obj_str_get_str(args[0]); + if (strcmp(want, "irq") == 0) { + if (!AUDIOIF_HAVE_IRQ) { + mp_raise_ValueError(MP_ERROR_TEXT("this audiodsp can't render from an interrupt")); + } + audioif_mode_set(AUDIOIF_MODE_IRQ); + } else if (strcmp(want, "interpreter") == 0) { + audioif_mode_set(AUDIOIF_MODE_INTERPRETER); + } else { + mp_raise_ValueError(MP_ERROR_TEXT("render mode is 'irq' or 'interpreter'")); + } + } + return MP_OBJ_NEW_QSTR(audioif_mode == AUDIOIF_MODE_IRQ ? MP_QSTR_irq : MP_QSTR_interpreter); +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audioif_stm32_render_mode_obj, 0, 1, + audioif_stm32_render_mode); + +// (renders, longest render in us, render stack used in bytes, render stack +// size, lock depth, priority). render_stats(True) zeroes the first two after +// reading them. +static mp_obj_t audioif_stm32_render_stats(size_t n_args, const mp_obj_t *args) { + mp_obj_t items[6] = { + mp_obj_new_int_from_uint(audioif_renders), + mp_obj_new_int_from_uint(audioif_render_us_max), + mp_obj_new_int_from_uint(audioif_stack_painted ? audioif_stack_used() : 0), + MP_OBJ_NEW_SMALL_INT(AUDIOIF_RENDER_STACK), + mp_obj_new_int_from_uint(audioif_lock_depth), + MP_OBJ_NEW_SMALL_INT(NVIC_GetPriority(AUDIOIF_RENDER_IRQN)), + }; + if (n_args == 1 && mp_obj_is_true(args[0])) { + audioif_renders = 0; + audioif_render_us_max = 0; + } + return mp_obj_new_tuple(6, items); +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(audioif_stm32_render_stats_obj, 0, 1, + audioif_stm32_render_stats); + static mp_obj_t audioif_stm32_driver(void) { return MP_OBJ_NEW_QSTR(qstr_from_str(audioif_stm32_ops.name)); } @@ -1097,6 +1389,8 @@ static const mp_rom_map_elem_t audioif_stm32_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR__audioif) }, { MP_ROM_QSTR(MP_QSTR_driver), MP_ROM_PTR(&audioif_stm32_driver_obj) }, { MP_ROM_QSTR(MP_QSTR_dac_stats), MP_ROM_PTR(&audioif_stm32_stats_obj) }, + { MP_ROM_QSTR(MP_QSTR_render_mode), MP_ROM_PTR(&audioif_stm32_render_mode_obj) }, + { MP_ROM_QSTR(MP_QSTR_render_stats), MP_ROM_PTR(&audioif_stm32_render_stats_obj) }, { MP_ROM_QSTR(MP_QSTR_pdm_open), MP_ROM_PTR(&audioif_stm32_pdm_open_obj) }, { MP_ROM_QSTR(MP_QSTR_adc_open), MP_ROM_PTR(&audioif_stm32_adc_open_obj) }, { MP_ROM_QSTR(MP_QSTR_rx_read), MP_ROM_PTR(&audioif_stm32_rx_read_obj) }, From 48f80762a9550c1e1cbcb51a5db141e3fd559426 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:39:59 -0600 Subject: [PATCH 2/3] audiodsp: interrupt rendering, 20 ms level ramps and type stubs Moves the audiodsp pin from 6d08b07 to 46e9224: the render_kick hook the GIGA driver here uses to render from its DAC interrupt (audiodsp#403), the 20 ms walk of a Mix or level move in the nodes (audiodsp#400), and type stubs for thirteen modules (audiodsp#401). --- modules.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules.lock b/modules.lock index ea3b0148..96980807 100644 --- a/modules.lock +++ b/modules.lock @@ -19,7 +19,7 @@ # sibling checkout is linked there instead) and compiles the port for its # interpreter. Move the commit here to move every LVGL build, MicroPython's and # CircuitPython's. -audiodsp https://github.com/PyDevices/audiodsp.git 6d08b0784939fa122ccb0dabeda5816bd8f70597 +audiodsp https://github.com/PyDevices/audiodsp.git 46e922490ae9110625819c9fc3afadb03dc957ad omnipython-lvgl https://github.com/PyDevices/omnipython-lvgl.git c34fbeeb0440e71acb62c92ddee1c3c65d089622 pygraphics https://github.com/PyDevices/pygraphics.git f97fd2e20e73f23c2138ab52a630cf04b0a7aea1 ulab https://github.com/v923z/micropython-ulab.git 1d3ddd8f5228e2c7335dac71f4846e6abb0ec9f9 From bd32362ca7b23cf53a9ff6f620cf38dced3c767a Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:23:59 -0600 Subject: [PATCH 3/3] audiodsp: level ramps build for CircuitPython (audiodsp#405) --- modules.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules.lock b/modules.lock index 96980807..d8e94806 100644 --- a/modules.lock +++ b/modules.lock @@ -19,7 +19,7 @@ # sibling checkout is linked there instead) and compiles the port for its # interpreter. Move the commit here to move every LVGL build, MicroPython's and # CircuitPython's. -audiodsp https://github.com/PyDevices/audiodsp.git 46e922490ae9110625819c9fc3afadb03dc957ad +audiodsp https://github.com/PyDevices/audiodsp.git 8d5c4a0ca69f12425c39c811ae190682655e5805 omnipython-lvgl https://github.com/PyDevices/omnipython-lvgl.git c34fbeeb0440e71acb62c92ddee1c3c65d089622 pygraphics https://github.com/PyDevices/pygraphics.git f97fd2e20e73f23c2138ab52a630cf04b0a7aea1 ulab https://github.com/v923z/micropython-ulab.git 1d3ddd8f5228e2c7335dac71f4846e6abb0ec9f9