From a2338c97edeebce27c0656e89f133ba549fa5004 Mon Sep 17 00:00:00 2001 From: tyeth Date: Fri, 23 Oct 2026 01:29:50 +0100 Subject: [PATCH 1/2] espressif: add audiopwmio.PWMAudioOut for the ESP32-S2 The espressif port had no audiopwmio at all, so boards whose speaker is not wired to a DAC pin had no way to play samples. On the Adafruit FunHouse the piezo is on GPIO42, while the ESP32-S2 DAC is hardwired to GPIO17/GPIO18 (and GPIO18 is the light sensor), so audioio cannot reach it. LEDC has no DMA path for its duty registers, and the S2's I2S has no PDM transmitter (no SOC_I2S_SUPPORTS_PDM_TX), so samples are pushed from a GPTimer alarm ISR running at the sample rate. The ISR drains one of two device buffers; audiopwmout_background(), called from port_background_task(), refills the other from the audiosample protocol. The ISR deliberately touches nothing but its own object's volatile state. It must not queue a background callback: on this port that macro is taskENTER_CRITICAL(), which aborts when called from ISR context. Driving the refill by polling instead also means a buffer the ISR found empty is always picked up again, rather than relying on the ISR to have queued the refill before it stalled. Every gptimer call is checked, so a timer that cannot be configured raises instead of leaving `playing` true forever. alarm_config is static because CircuitPython builds with CONFIG_GPTIMER_CTRL_FUNC_IN_IRAM, under which gptimer_set_alarm_action() rejects a config outside internal RAM. The carrier is APB_CLK / 2**10 = 78.125 kHz, which keeps it out of the audio band while leaving 10 bits of amplitude resolution. The GPTimer runs at 40 MHz (the finest the timer group divider allows), putting the sample-rate error below 0.01% at 8k/11k/22k/44.1k. A single-buffer sample that loops wraps inside the ISR rather than round-tripping through the background task, so looping RawSamples do not click. ledc_ll.h lives in the per-target esp_hal_ledc directory, which was not on the include path, so add it. Enabled for esp32s2 only; the S3 has neither DAC nor audiopwmio and could reuse this later. Costs 2336 bytes of flash and 648 bytes of internal SRAM on adafruit_funhouse, and nothing on boards that leave it off. Co-Authored-By: Claude Opus 5 --- ports/espressif/Makefile | 1 + ports/espressif/background.c | 6 + .../common-hal/audiopwmio/PWMAudioOut.c | 442 ++++++++++++++++++ .../common-hal/audiopwmio/PWMAudioOut.h | 61 +++ .../common-hal/audiopwmio/__init__.c | 7 + ports/espressif/mpconfigport.mk | 3 + ports/espressif/supervisor/port.c | 7 + 7 files changed, 527 insertions(+) create mode 100644 ports/espressif/common-hal/audiopwmio/PWMAudioOut.c create mode 100644 ports/espressif/common-hal/audiopwmio/PWMAudioOut.h create mode 100644 ports/espressif/common-hal/audiopwmio/__init__.c diff --git a/ports/espressif/Makefile b/ports/espressif/Makefile index 978a3b0e7c0..485aa81a56f 100644 --- a/ports/espressif/Makefile +++ b/ports/espressif/Makefile @@ -123,6 +123,7 @@ INC += \ -isystem esp-idf/components/esp_hal_i2s/include \ -isystem esp-idf/components/esp_hal_i2s/$(IDF_TARGET)/include \ -isystem esp-idf/components/esp_hal_ledc/include \ + -isystem esp-idf/components/esp_hal_ledc/$(IDF_TARGET)/include \ -isystem esp-idf/components/esp_hal_pcnt/include \ -isystem esp-idf/components/esp_hal_rmt/include \ -isystem esp-idf/components/esp_hal_rmt/$(IDF_TARGET)/include \ diff --git a/ports/espressif/background.c b/ports/espressif/background.c index d28b1bb3765..df63aeb3dd2 100644 --- a/ports/espressif/background.c +++ b/ports/espressif/background.c @@ -8,6 +8,9 @@ #include "supervisor/filesystem.h" #include "supervisor/port.h" #include "supervisor/shared/stack.h" +#if CIRCUITPY_AUDIOPWMIO +#include "common-hal/audiopwmio/PWMAudioOut.h" +#endif #if CIRCUITPY_USB_HOST #include "supervisor/usb.h" #include "tusb.h" @@ -28,6 +31,9 @@ void port_background_tick(void) { } void port_background_task(void) { + #if CIRCUITPY_AUDIOPWMIO + audiopwmout_background(); + #endif } void port_start_background_tick(void) { diff --git a/ports/espressif/common-hal/audiopwmio/PWMAudioOut.c b/ports/espressif/common-hal/audiopwmio/PWMAudioOut.c new file mode 100644 index 00000000000..798a9232245 --- /dev/null +++ b/ports/espressif/common-hal/audiopwmio/PWMAudioOut.c @@ -0,0 +1,442 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tyeth Gundry +// +// SPDX-License-Identifier: MIT + +// PWM audio for the ESP32 family. +// +// LEDC has no DMA path for its duty registers, and the ESP32-S2's I2S has no +// PDM transmitter, so samples are pushed one at a time from a GPTimer alarm +// ISR running at the sample rate. Two device buffers are used: the ISR drains +// one while audiopwmout_background() refills the other. +// +// The ISR deliberately touches nothing but this object's own volatile state. +// In particular it must not queue a background callback: that takes a FreeRTOS +// critical section, which aborts when called from ISR context on this port. + +#include "common-hal/audiopwmio/PWMAudioOut.h" + +#include +#include + +#include "py/gc.h" +#include "py/runtime.h" + +#include "shared-bindings/audiopwmio/PWMAudioOut.h" +#include "shared-bindings/microcontroller/Pin.h" +#include "shared-bindings/pwmio/PWMOut.h" +#include "shared-module/audiocore/__init__.h" + +#include "driver/gptimer.h" +#include "esp_attr.h" +#include "hal/ledc_ll.h" +#include "soc/soc.h" + +// Carrier frequency: 80 MHz / 1024 = 78125 Hz. pwmio derives PWM_BITS back out +// of this when it picks a duty resolution, so the two stay in step. +#define PWM_FREQUENCY (APB_CLK_FREQ >> PWM_BITS) + +// GPTimer tick rate. The timer group divider must be at least 2, so 40 MHz is +// the finest available, and it keeps the sample-rate error below a cent +// (44100 Hz -> 907 ticks -> 44101.4 Hz). +#define TIMER_RESOLUTION_HZ (APB_CLK_FREQ / 2) + +// Each object needs its own LEDC timer, so that bounds how many can play. +static audiopwmio_pwmaudioout_obj_t *active_audio[SOC_LEDC_TIMER_NUM]; + +// Write one duty value to a LEDC channel. This mirrors what ledc_update_duty() +// does, minus the argument checks and the spinlock, so it is cheap enough to +// run at the sample rate. The fade parameters it relies on (scale 0, so the +// duty is latched rather than ramped) are left set up by pwmio's construct. +static inline void IRAM_ATTR set_duty(uint8_t channel, uint32_t duty) { + ledc_ll_set_duty_int_part(&LEDC, LEDC_LOW_SPEED_MODE, channel, duty); + ledc_ll_set_sig_out_en(&LEDC, LEDC_LOW_SPEED_MODE, channel, true); + ledc_ll_set_duty_start(&LEDC, LEDC_LOW_SPEED_MODE, channel); + ledc_ll_ls_channel_update(&LEDC, LEDC_LOW_SPEED_MODE, channel); +} + +static bool IRAM_ATTR timer_alarm_cb(gptimer_handle_t timer, + const gptimer_alarm_event_data_t *edata, void *user_ctx) { + audiopwmio_pwmaudioout_obj_t *self = (audiopwmio_pwmaudioout_obj_t *)user_ctx; + (void)timer; + (void)edata; + + uint8_t buf_index = self->playing_buffer; + if (!self->buffer_ready[buf_index]) { + // Underrun, or the sample ended. Hold the quiescent level rather than + // leaving the last sample on the pin, which would be a DC offset. + set_duty(self->left_pwm.chan_handle.channel, self->quiescent_value); + if (self->stereo) { + set_duty(self->right_pwm.chan_handle.channel, self->quiescent_value); + } + return false; + } + + size_t frame = self->playing_frame; + const uint16_t *buf = self->buffers[buf_index]; + + set_duty(self->left_pwm.chan_handle.channel, buf[2 * frame]); + if (self->stereo) { + set_duty(self->right_pwm.chan_handle.channel, buf[2 * frame + 1]); + } + + frame++; + if (frame >= self->buffer_frames[buf_index]) { + if (self->single_buffer && self->loop) { + // The whole sample fits in one buffer and repeats forever, so wrap + // in place. This keeps looping RawSamples glitch-free: no refill, + // and no dependency on how promptly the background task runs. + self->playing_frame = 0; + return false; + } + // This buffer is spent. Flag it; the background task does the refill. + self->buffer_ready[buf_index] = false; + if (self->last_buffer[buf_index]) { + self->stop_queued = true; + } + self->playing_frame = 0; + if (!self->single_buffer) { + self->playing_buffer = buf_index ^ 1; + } + } else { + self->playing_frame = frame; + } + + return false; +} + +// Convert one source buffer into device buffer `buf_index`, scaling samples to +// PWM_BITS and always writing interleaved (left, right) pairs. +static void fill_buffer(audiopwmio_pwmaudioout_obj_t *self, uint8_t buf_index) { + uint8_t *source; + uint32_t source_length; + audioio_get_buffer_result_t result = + audiosample_get_buffer(self->sample, false, 0, &source, &source_length); + + if (result == GET_BUFFER_ERROR) { + self->stop_queued = true; + return; + } + + size_t frames = source_length / self->bytes_per_sample / self->sample_channel_count; + if (frames * 2 * sizeof(uint16_t) > self->buffer_size[buf_index]) { + // Should not happen: play() sized the buffers from max_buffer_length. + frames = self->buffer_size[buf_index] / (2 * sizeof(uint16_t)); + } + + uint16_t *dest = self->buffers[buf_index]; + if (self->bytes_per_sample == 1) { + uint8_t xor_mask = self->signed_to_unsigned ? 0x80 : 0; + for (size_t i = 0; i < frames; i++) { + uint16_t left = (uint16_t)((source[i * self->sample_channel_count] ^ xor_mask) << (PWM_BITS - 8)); + uint16_t right = left; + if (self->sample_channel_count == 2) { + right = (uint16_t)((source[i * 2 + 1] ^ xor_mask) << (PWM_BITS - 8)); + } + *dest++ = left; + *dest++ = right; + } + } else { + uint16_t xor_mask = self->signed_to_unsigned ? 0x8000 : 0; + // Cast via void * : the sample buffer is 16-bit aligned for 16-bit + // samples, but the protocol hands it over as uint8_t *. + const uint16_t *source16 = (const uint16_t *)(const void *)source; + for (size_t i = 0; i < frames; i++) { + uint16_t left = (uint16_t)((source16[i * self->sample_channel_count] ^ xor_mask) >> SAMPLE_BITS_TO_DISCARD); + uint16_t right = left; + if (self->sample_channel_count == 2) { + right = (uint16_t)((source16[i * 2 + 1] ^ xor_mask) >> SAMPLE_BITS_TO_DISCARD); + } + *dest++ = left; + *dest++ = right; + } + } + + self->buffer_frames[buf_index] = frames; + self->last_buffer[buf_index] = false; + + if (result == GET_BUFFER_DONE) { + if (self->loop) { + audiosample_reset_buffer(self->sample, false, 0); + } else { + self->last_buffer[buf_index] = true; + } + } + + // Publish last, so the ISR never sees a half-filled buffer. + __asm__ volatile ("" ::: "memory"); + self->buffer_ready[buf_index] = (frames > 0); + if (frames == 0 && result == GET_BUFFER_DONE && !self->loop) { + self->stop_queued = true; + } +} + +// Tear down the timer without touching the heap, so this is also safe to call +// while the VM is being reset. +static void stop_timer(audiopwmio_pwmaudioout_obj_t *self) { + if (self->timer != NULL) { + gptimer_stop(self->timer); + gptimer_disable(self->timer); + gptimer_del_timer(self->timer); + self->timer = NULL; + } + for (size_t i = 0; i < MP_ARRAY_SIZE(active_audio); i++) { + if (active_audio[i] == self) { + active_audio[i] = NULL; + } + } +} + +static void free_buffers(audiopwmio_pwmaudioout_obj_t *self) { + for (uint8_t i = 0; i < 2; i++) { + if (self->buffers[i] != NULL) { + #if MICROPY_MALLOC_USES_ALLOCATED_SIZE + m_free(self->buffers[i], self->buffer_size[i]); + #else + m_free(self->buffers[i]); + #endif + self->buffers[i] = NULL; + } + self->buffer_size[i] = 0; + self->buffer_frames[i] = 0; + self->buffer_ready[i] = false; + self->last_buffer[i] = false; + } +} + +void audiopwmout_background(void) { + for (size_t i = 0; i < MP_ARRAY_SIZE(active_audio); i++) { + audiopwmio_pwmaudioout_obj_t *self = active_audio[i]; + if (self == NULL || !self->playing) { + continue; + } + if (self->stop_queued) { + common_hal_audiopwmio_pwmaudioout_stop(self); + continue; + } + if (self->paused) { + continue; + } + for (uint8_t buf = 0; buf < 2; buf++) { + if (!self->buffer_ready[buf] && !self->stop_queued) { + fill_buffer(self, buf); + } + if (self->single_buffer) { + // A single-buffer sample hands back the same memory every + // time, so there is nothing to gain from a second buffer. + break; + } + } + } +} + +void audiopwmout_reset(void) { + for (size_t i = 0; i < MP_ARRAY_SIZE(active_audio); i++) { + if (active_audio[i] != NULL) { + active_audio[i]->playing = false; + stop_timer(active_audio[i]); + } + } +} + +// Caller validates that pins are free. +void common_hal_audiopwmio_pwmaudioout_construct(audiopwmio_pwmaudioout_obj_t *self, + const mcu_pin_obj_t *left_channel, const mcu_pin_obj_t *right_channel, uint16_t quiescent_value) { + + self->stereo = right_channel != NULL; + self->left_pwm.deinited = true; + self->right_pwm.deinited = true; + self->timer = NULL; + self->sample = NULL; + self->playing = false; + self->paused = false; + + // variable_frequency is false so that the two channels of a stereo pair can + // share one LEDC timer. At PWM_FREQUENCY pwmio picks a PWM_BITS-wide duty. + pwmout_result_t result = common_hal_pwmio_pwmout_construct( + &self->left_pwm, left_channel, 0, PWM_FREQUENCY, false); + if (result == PWMOUT_OK && self->stereo) { + result = common_hal_pwmio_pwmout_construct( + &self->right_pwm, right_channel, 0, PWM_FREQUENCY, false); + if (result != PWMOUT_OK) { + common_hal_pwmio_pwmout_deinit(&self->left_pwm); + } + } + if (result != PWMOUT_OK) { + common_hal_pwmio_pwmout_raise_error(result); + } + + self->quiescent_value = quiescent_value >> SAMPLE_BITS_TO_DISCARD; + common_hal_pwmio_pwmout_set_duty_cycle(&self->left_pwm, quiescent_value); + if (self->stereo) { + common_hal_pwmio_pwmout_set_duty_cycle(&self->right_pwm, quiescent_value); + } +} + +bool common_hal_audiopwmio_pwmaudioout_deinited(audiopwmio_pwmaudioout_obj_t *self) { + return common_hal_pwmio_pwmout_deinited(&self->left_pwm); +} + +void common_hal_audiopwmio_pwmaudioout_deinit(audiopwmio_pwmaudioout_obj_t *self) { + if (common_hal_audiopwmio_pwmaudioout_deinited(self)) { + return; + } + + common_hal_audiopwmio_pwmaudioout_stop(self); + + common_hal_pwmio_pwmout_deinit(&self->left_pwm); + if (self->stereo) { + common_hal_pwmio_pwmout_deinit(&self->right_pwm); + } +} + +void common_hal_audiopwmio_pwmaudioout_play(audiopwmio_pwmaudioout_obj_t *self, + mp_obj_t sample, bool loop) { + + if (common_hal_audiopwmio_pwmaudioout_get_playing(self)) { + common_hal_audiopwmio_pwmaudioout_stop(self); + } + + audiosample_base_t *base = audiosample_check(sample); + + uint32_t sample_rate = audiosample_get_sample_rate(base); + if (sample_rate == 0 || sample_rate > TIMER_RESOLUTION_HZ) { + mp_arg_error_invalid(MP_QSTR_sample_rate); + } + + self->sample = sample; + self->loop = loop; + self->bytes_per_sample = audiosample_get_bits_per_sample(base) / 8; + self->sample_channel_count = audiosample_get_channel_count(base); + + uint32_t max_buffer_length; + uint8_t spacing; + audiosample_get_buffer_structure(base, false, &self->single_buffer, + &self->signed_to_unsigned, &max_buffer_length, &spacing); + + // Two device buffers of interleaved 16-bit pairs. + size_t frames = max_buffer_length / self->bytes_per_sample / self->sample_channel_count; + size_t buffer_size = frames * 2 * sizeof(uint16_t); + if (buffer_size == 0) { + mp_arg_error_invalid(MP_QSTR_sample); + } + + // Claim a slot before allocating, so a failure here frees nothing. + size_t slot = MP_ARRAY_SIZE(active_audio); + for (size_t i = 0; i < MP_ARRAY_SIZE(active_audio); i++) { + if (active_audio[i] == NULL) { + slot = i; + break; + } + } + if (slot == MP_ARRAY_SIZE(active_audio)) { + mp_raise_RuntimeError(MP_ERROR_TEXT("Failed to start async audio")); + } + + free_buffers(self); + for (uint8_t i = 0; i < 2; i++) { + self->buffers[i] = m_malloc_without_collect(buffer_size); + self->buffer_size[i] = buffer_size; + if (self->single_buffer) { + break; + } + } + + audiosample_reset_buffer(sample, false, 0); + + self->playing_buffer = 0; + self->playing_frame = 0; + self->stop_queued = false; + self->paused = false; + self->playing = true; + + // Prime both buffers before the timer starts. + fill_buffer(self, 0); + if (!self->single_buffer && !self->stop_queued) { + fill_buffer(self, 1); + } + if (self->stop_queued) { + // Nothing to play (empty or broken sample). Don't start the timer, or + // there would be nothing to shut it down again. + common_hal_audiopwmio_pwmaudioout_stop(self); + return; + } + + gptimer_config_t timer_config = { + .clk_src = GPTIMER_CLK_SRC_DEFAULT, + .direction = GPTIMER_COUNT_UP, + .resolution_hz = TIMER_RESOLUTION_HZ, + .intr_priority = 0, + }; + gptimer_event_callbacks_t callbacks = { + .on_alarm = timer_alarm_cb, + }; + // CircuitPython builds with CONFIG_GPTIMER_CTRL_FUNC_IN_IRAM, under which + // gptimer_set_alarm_action() rejects a config that is not in internal RAM. + // The IDF docs call for a static instance here, so give it one. + static gptimer_alarm_config_t alarm_config; + alarm_config.alarm_count = TIMER_RESOLUTION_HZ / sample_rate; + alarm_config.reload_count = 0; + alarm_config.flags.auto_reload_on_alarm = true; + + active_audio[slot] = self; + if (gptimer_new_timer(&timer_config, &self->timer) != ESP_OK || + gptimer_register_event_callbacks(self->timer, &callbacks, self) != ESP_OK || + gptimer_set_alarm_action(self->timer, &alarm_config) != ESP_OK || + gptimer_enable(self->timer) != ESP_OK || + gptimer_start(self->timer) != ESP_OK) { + // Leave nothing half-started: a stuck `playing` would hang any + // `while playing:` loop forever. + self->playing = false; + stop_timer(self); + free_buffers(self); + mp_raise_RuntimeError(MP_ERROR_TEXT("Failed to start async audio")); + } +} + +void common_hal_audiopwmio_pwmaudioout_stop(audiopwmio_pwmaudioout_obj_t *self) { + stop_timer(self); + + self->playing = false; + self->paused = false; + self->stop_queued = false; + self->sample = NULL; + + // Park the output at the quiescent level so the speaker does not sit at a + // DC offset between samples. + if (!common_hal_pwmio_pwmout_deinited(&self->left_pwm)) { + common_hal_pwmio_pwmout_set_duty_cycle(&self->left_pwm, + self->quiescent_value << SAMPLE_BITS_TO_DISCARD); + } + if (self->stereo && !common_hal_pwmio_pwmout_deinited(&self->right_pwm)) { + common_hal_pwmio_pwmout_set_duty_cycle(&self->right_pwm, + self->quiescent_value << SAMPLE_BITS_TO_DISCARD); + } + + free_buffers(self); +} + +bool common_hal_audiopwmio_pwmaudioout_get_playing(audiopwmio_pwmaudioout_obj_t *self) { + return self->playing; +} + +void common_hal_audiopwmio_pwmaudioout_pause(audiopwmio_pwmaudioout_obj_t *self) { + if (!self->playing || self->paused || self->timer == NULL) { + return; + } + gptimer_stop(self->timer); + self->paused = true; +} + +void common_hal_audiopwmio_pwmaudioout_resume(audiopwmio_pwmaudioout_obj_t *self) { + if (!self->paused || self->timer == NULL) { + return; + } + self->paused = false; + gptimer_start(self->timer); +} + +bool common_hal_audiopwmio_pwmaudioout_get_paused(audiopwmio_pwmaudioout_obj_t *self) { + return self->paused; +} diff --git a/ports/espressif/common-hal/audiopwmio/PWMAudioOut.h b/ports/espressif/common-hal/audiopwmio/PWMAudioOut.h new file mode 100644 index 00000000000..80f636573ba --- /dev/null +++ b/ports/espressif/common-hal/audiopwmio/PWMAudioOut.h @@ -0,0 +1,61 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tyeth Gundry +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "common-hal/microcontroller/Pin.h" +#include "common-hal/pwmio/PWMOut.h" + +#include "py/obj.h" + +#include "driver/gptimer.h" + +// The LEDC carrier is APB_CLK (80 MHz) / 2**PWM_BITS. 10 bits puts the carrier +// at 78.125 kHz: comfortably above hearing, while leaving 10 bits of amplitude +// resolution. Going finer drags the carrier down into the audible band. +#define PWM_BITS (10) +#define SAMPLE_BITS_TO_DISCARD (16 - PWM_BITS) + +typedef struct { + mp_obj_base_t base; + + pwmio_pwmout_obj_t left_pwm; + pwmio_pwmout_obj_t right_pwm; + gptimer_handle_t timer; + + mp_obj_t sample; + + // Two device buffers of interleaved (left, right) duty values, already + // scaled to PWM_BITS. The ISR drains one while audiopwmout_background() + // refills the other. + uint16_t *buffers[2]; + size_t buffer_size[2]; // allocated size in bytes + size_t buffer_frames[2]; // valid frames currently in the buffer + + volatile size_t playing_frame; // read cursor within the active buffer + volatile uint8_t playing_buffer; // which buffer the ISR is draining + volatile bool buffer_ready[2]; // buffer holds data the ISR has not used + volatile bool last_buffer[2]; // this buffer ends the sample + volatile bool stop_queued; // the ISR reached the end of the sample + + uint16_t quiescent_value; // scaled to PWM_BITS + + uint8_t sample_channel_count; + uint8_t bytes_per_sample; + + bool stereo; // right channel in use + bool signed_to_unsigned; + bool single_buffer; + bool playing; + bool paused; + bool loop; +} audiopwmio_pwmaudioout_obj_t; + +// Called from port_background_task(): refills buffers the ISR has drained. +void audiopwmout_background(void); + +// Called from reset_port(): stops any playback left running by the VM. +void audiopwmout_reset(void); diff --git a/ports/espressif/common-hal/audiopwmio/__init__.c b/ports/espressif/common-hal/audiopwmio/__init__.c new file mode 100644 index 00000000000..98db6232125 --- /dev/null +++ b/ports/espressif/common-hal/audiopwmio/__init__.c @@ -0,0 +1,7 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tyeth Gundry +// +// SPDX-License-Identifier: MIT + +// No audiopwmio module functions. diff --git a/ports/espressif/mpconfigport.mk b/ports/espressif/mpconfigport.mk index b635f193e1c..4dc09f6d85b 100644 --- a/ports/espressif/mpconfigport.mk +++ b/ports/espressif/mpconfigport.mk @@ -458,6 +458,9 @@ CIRCUITPY_MII = 0 CIRCUITPY_AUDIOIO ?= 1 CIRCUITPY_AUDIOFILEWRITER ?= 1 +# LEDC + GPTimer PWM audio, for boards whose speaker is not on a DAC pin. +CIRCUITPY_AUDIOPWMIO ?= 1 + # No I2S peripheral PDM-to-PCM hardware support CIRCUITPY_AUDIOBUSIO_PDMIN = 0 diff --git a/ports/espressif/supervisor/port.c b/ports/espressif/supervisor/port.c index 91fe8cf5a2e..0eff744c83e 100644 --- a/ports/espressif/supervisor/port.c +++ b/ports/espressif/supervisor/port.c @@ -29,6 +29,9 @@ #include "bindings/espulp/__init__.h" #include "common-hal/microcontroller/Pin.h" #include "common-hal/analogio/AnalogOut.h" +#if CIRCUITPY_AUDIOPWMIO +#include "common-hal/audiopwmio/PWMAudioOut.h" +#endif #include "common-hal/busio/SPI.h" #include "common-hal/busio/UART.h" #include "common-hal/dualbank/__init__.h" @@ -456,6 +459,10 @@ void reset_port(void) { analogout_reset(); #endif + #if CIRCUITPY_AUDIOPWMIO + audiopwmout_reset(); + #endif + #if CIRCUITPY_BUSIO uart_reset(); #endif From 65138d5004923e2a220261e0783cb37c36a35d14 Mon Sep 17 00:00:00 2001 From: tyeth Date: Fri, 23 Oct 2026 01:59:37 +0100 Subject: [PATCH 2/2] espressif/audiopwmio: keep all ISR state in internal RAM gptimer_register_event_callbacks() returned ESP_ERR_INVALID_ARG on the FunHouse, so the alarm callback was never installed: the timer ran, no alarm was ever delivered, nothing consumed samples, and play() left `playing` true forever. The driver is built with CONFIG_GPTIMER_ISR_CACHE_SAFE (selected by CONFIG_GPTIMER_ISR_HANDLER_IN_IRAM, which defaults on), so gptimer_register_event_callbacks() requires the callback in IRAM and its user context in internal RAM. The callback was fine; the user context was not. It was `self`, a MicroPython heap object, and port_malloc() takes MALLOC_CAP_SPIRAM first, so on any board with PSRAM the whole object is external. Clearing that check alone would not be enough. A cache-safe ISR can run while the flash cache is disabled, which happens during any flash write, including the user saving code.py, so everything it dereferences has to be in internal RAM. Follow the pattern common-hal/frequencyio already uses: put the ISR-reachable state in an audiopwmio_internal_data_t allocated with MALLOC_CAP_INTERNAL and pass that as the user context. The LEDC channel numbers are copied in too, so the ISR no longer reaches into the pwmio objects. The audio buffers move to heap_caps_malloc(MALLOC_CAP_ INTERNAL) for the same reason, capped so a large sample fails with ESP_ERR_NO_MEM rather than exhausting internal RAM. The refill path still runs in task context and reaches the object as before. Buffers now hold one value per output channel rather than always a stereo pair, which halves the internal RAM a mono output needs. play() asserts esp_ptr_in_iram() for the callback and esp_ptr_internal() for the control block and both buffers, so a regression surfaces there instead of as a fault at a random later moment. Error paths report the underlying esp_err_t through raise_esp_error() instead of a generic message. Co-Authored-By: Claude Opus 5 --- .../common-hal/audiopwmio/PWMAudioOut.c | 245 +++++++++++------- .../common-hal/audiopwmio/PWMAudioOut.h | 41 +-- 2 files changed, 177 insertions(+), 109 deletions(-) diff --git a/ports/espressif/common-hal/audiopwmio/PWMAudioOut.c b/ports/espressif/common-hal/audiopwmio/PWMAudioOut.c index 798a9232245..21373bd134a 100644 --- a/ports/espressif/common-hal/audiopwmio/PWMAudioOut.c +++ b/ports/espressif/common-hal/audiopwmio/PWMAudioOut.c @@ -11,9 +11,10 @@ // ISR running at the sample rate. Two device buffers are used: the ISR drains // one while audiopwmout_background() refills the other. // -// The ISR deliberately touches nothing but this object's own volatile state. -// In particular it must not queue a background callback: that takes a FreeRTOS -// critical section, which aborts when called from ISR context on this port. +// The ISR reads and writes only the internal-RAM control block, for the two +// reasons set out in PWMAudioOut.h and below. In particular it must not queue +// a background callback: on this port that macro is taskENTER_CRITICAL(), +// which aborts when called from ISR context. #include "common-hal/audiopwmio/PWMAudioOut.h" @@ -23,6 +24,7 @@ #include "py/gc.h" #include "py/runtime.h" +#include "bindings/espidf/__init__.h" #include "shared-bindings/audiopwmio/PWMAudioOut.h" #include "shared-bindings/microcontroller/Pin.h" #include "shared-bindings/pwmio/PWMOut.h" @@ -30,6 +32,8 @@ #include "driver/gptimer.h" #include "esp_attr.h" +#include "esp_heap_caps.h" +#include "esp_memory_utils.h" #include "hal/ledc_ll.h" #include "soc/soc.h" @@ -42,6 +46,11 @@ // (44100 Hz -> 907 ticks -> 44101.4 Hz). #define TIMER_RESOLUTION_HZ (APB_CLK_FREQ / 2) +// Internal RAM is scarce, and both buffers come out of it. This is generous +// for the buffer sizes the audiosample protocol actually hands over (a +// WaveFile uses 512 bytes) while still failing clearly on something silly. +#define MAX_BUFFER_BYTES (8192) + // Each object needs its own LEDC timer, so that bounds how many can play. static audiopwmio_pwmaudioout_obj_t *active_audio[SOC_LEDC_TIMER_NUM]; @@ -57,85 +66,89 @@ static inline void IRAM_ATTR set_duty(uint8_t channel, uint32_t duty) { } static bool IRAM_ATTR timer_alarm_cb(gptimer_handle_t timer, - const gptimer_alarm_event_data_t *edata, void *user_ctx) { - audiopwmio_pwmaudioout_obj_t *self = (audiopwmio_pwmaudioout_obj_t *)user_ctx; + const gptimer_alarm_event_data_t *edata, void *internal_data_ptr) { + audiopwmio_internal_data_t *internal_data = + (audiopwmio_internal_data_t *)internal_data_ptr; (void)timer; (void)edata; - uint8_t buf_index = self->playing_buffer; - if (!self->buffer_ready[buf_index]) { + uint8_t buf_index = internal_data->playing_buffer; + if (!internal_data->buffer_ready[buf_index]) { // Underrun, or the sample ended. Hold the quiescent level rather than // leaving the last sample on the pin, which would be a DC offset. - set_duty(self->left_pwm.chan_handle.channel, self->quiescent_value); - if (self->stereo) { - set_duty(self->right_pwm.chan_handle.channel, self->quiescent_value); + set_duty(internal_data->left_channel, internal_data->quiescent_value); + if (internal_data->stereo) { + set_duty(internal_data->right_channel, internal_data->quiescent_value); } return false; } - size_t frame = self->playing_frame; - const uint16_t *buf = self->buffers[buf_index]; + size_t frame = internal_data->playing_frame; + const uint16_t *sample = + &internal_data->buffers[buf_index][frame * internal_data->out_channels]; - set_duty(self->left_pwm.chan_handle.channel, buf[2 * frame]); - if (self->stereo) { - set_duty(self->right_pwm.chan_handle.channel, buf[2 * frame + 1]); + set_duty(internal_data->left_channel, sample[0]); + if (internal_data->stereo) { + set_duty(internal_data->right_channel, sample[1]); } frame++; - if (frame >= self->buffer_frames[buf_index]) { - if (self->single_buffer && self->loop) { + if (frame >= internal_data->buffer_frames[buf_index]) { + if (internal_data->single_buffer && internal_data->loop) { // The whole sample fits in one buffer and repeats forever, so wrap // in place. This keeps looping RawSamples glitch-free: no refill, // and no dependency on how promptly the background task runs. - self->playing_frame = 0; + internal_data->playing_frame = 0; return false; } // This buffer is spent. Flag it; the background task does the refill. - self->buffer_ready[buf_index] = false; - if (self->last_buffer[buf_index]) { - self->stop_queued = true; + internal_data->buffer_ready[buf_index] = false; + if (internal_data->last_buffer[buf_index]) { + internal_data->stop_queued = true; } - self->playing_frame = 0; - if (!self->single_buffer) { - self->playing_buffer = buf_index ^ 1; + internal_data->playing_frame = 0; + if (!internal_data->single_buffer) { + internal_data->playing_buffer = buf_index ^ 1; } } else { - self->playing_frame = frame; + internal_data->playing_frame = frame; } return false; } // Convert one source buffer into device buffer `buf_index`, scaling samples to -// PWM_BITS and always writing interleaved (left, right) pairs. +// PWM_BITS. Runs in task context, so it may touch the object freely. static void fill_buffer(audiopwmio_pwmaudioout_obj_t *self, uint8_t buf_index) { + audiopwmio_internal_data_t *internal_data = self->internal_data; uint8_t *source; uint32_t source_length; audioio_get_buffer_result_t result = audiosample_get_buffer(self->sample, false, 0, &source, &source_length); if (result == GET_BUFFER_ERROR) { - self->stop_queued = true; + internal_data->stop_queued = true; return; } + uint8_t out_channels = internal_data->out_channels; size_t frames = source_length / self->bytes_per_sample / self->sample_channel_count; - if (frames * 2 * sizeof(uint16_t) > self->buffer_size[buf_index]) { + if (frames * out_channels * sizeof(uint16_t) > self->buffer_size[buf_index]) { // Should not happen: play() sized the buffers from max_buffer_length. - frames = self->buffer_size[buf_index] / (2 * sizeof(uint16_t)); + frames = self->buffer_size[buf_index] / (out_channels * sizeof(uint16_t)); } - uint16_t *dest = self->buffers[buf_index]; + uint16_t *dest = internal_data->buffers[buf_index]; if (self->bytes_per_sample == 1) { uint8_t xor_mask = self->signed_to_unsigned ? 0x80 : 0; for (size_t i = 0; i < frames; i++) { uint16_t left = (uint16_t)((source[i * self->sample_channel_count] ^ xor_mask) << (PWM_BITS - 8)); - uint16_t right = left; - if (self->sample_channel_count == 2) { - right = (uint16_t)((source[i * 2 + 1] ^ xor_mask) << (PWM_BITS - 8)); - } *dest++ = left; - *dest++ = right; + if (out_channels == 2) { + *dest++ = (self->sample_channel_count == 2) + ? (uint16_t)((source[i * 2 + 1] ^ xor_mask) << (PWM_BITS - 8)) + : left; + } } } else { uint16_t xor_mask = self->signed_to_unsigned ? 0x8000 : 0; @@ -144,31 +157,31 @@ static void fill_buffer(audiopwmio_pwmaudioout_obj_t *self, uint8_t buf_index) { const uint16_t *source16 = (const uint16_t *)(const void *)source; for (size_t i = 0; i < frames; i++) { uint16_t left = (uint16_t)((source16[i * self->sample_channel_count] ^ xor_mask) >> SAMPLE_BITS_TO_DISCARD); - uint16_t right = left; - if (self->sample_channel_count == 2) { - right = (uint16_t)((source16[i * 2 + 1] ^ xor_mask) >> SAMPLE_BITS_TO_DISCARD); - } *dest++ = left; - *dest++ = right; + if (out_channels == 2) { + *dest++ = (self->sample_channel_count == 2) + ? (uint16_t)((source16[i * 2 + 1] ^ xor_mask) >> SAMPLE_BITS_TO_DISCARD) + : left; + } } } - self->buffer_frames[buf_index] = frames; - self->last_buffer[buf_index] = false; + internal_data->buffer_frames[buf_index] = frames; + internal_data->last_buffer[buf_index] = false; if (result == GET_BUFFER_DONE) { - if (self->loop) { + if (internal_data->loop) { audiosample_reset_buffer(self->sample, false, 0); } else { - self->last_buffer[buf_index] = true; + internal_data->last_buffer[buf_index] = true; } } // Publish last, so the ISR never sees a half-filled buffer. __asm__ volatile ("" ::: "memory"); - self->buffer_ready[buf_index] = (frames > 0); - if (frames == 0 && result == GET_BUFFER_DONE && !self->loop) { - self->stop_queued = true; + internal_data->buffer_ready[buf_index] = (frames > 0); + if (frames == 0 && result == GET_BUFFER_DONE && !internal_data->loop) { + internal_data->stop_queued = true; } } @@ -190,18 +203,14 @@ static void stop_timer(audiopwmio_pwmaudioout_obj_t *self) { static void free_buffers(audiopwmio_pwmaudioout_obj_t *self) { for (uint8_t i = 0; i < 2; i++) { - if (self->buffers[i] != NULL) { - #if MICROPY_MALLOC_USES_ALLOCATED_SIZE - m_free(self->buffers[i], self->buffer_size[i]); - #else - m_free(self->buffers[i]); - #endif - self->buffers[i] = NULL; + if (self->internal_data->buffers[i] != NULL) { + heap_caps_free(self->internal_data->buffers[i]); + self->internal_data->buffers[i] = NULL; } self->buffer_size[i] = 0; - self->buffer_frames[i] = 0; - self->buffer_ready[i] = false; - self->last_buffer[i] = false; + self->internal_data->buffer_frames[i] = 0; + self->internal_data->buffer_ready[i] = false; + self->internal_data->last_buffer[i] = false; } } @@ -211,7 +220,7 @@ void audiopwmout_background(void) { if (self == NULL || !self->playing) { continue; } - if (self->stop_queued) { + if (self->internal_data->stop_queued) { common_hal_audiopwmio_pwmaudioout_stop(self); continue; } @@ -219,10 +228,10 @@ void audiopwmout_background(void) { continue; } for (uint8_t buf = 0; buf < 2; buf++) { - if (!self->buffer_ready[buf] && !self->stop_queued) { + if (!self->internal_data->buffer_ready[buf] && !self->internal_data->stop_queued) { fill_buffer(self, buf); } - if (self->single_buffer) { + if (self->internal_data->single_buffer) { // A single-buffer sample hands back the same memory every // time, so there is nothing to gain from a second buffer. break; @@ -244,19 +253,27 @@ void audiopwmout_reset(void) { void common_hal_audiopwmio_pwmaudioout_construct(audiopwmio_pwmaudioout_obj_t *self, const mcu_pin_obj_t *left_channel, const mcu_pin_obj_t *right_channel, uint16_t quiescent_value) { - self->stereo = right_channel != NULL; - self->left_pwm.deinited = true; - self->right_pwm.deinited = true; self->timer = NULL; self->sample = NULL; self->playing = false; self->paused = false; + self->internal_data = heap_caps_calloc(1, sizeof(audiopwmio_internal_data_t), + MALLOC_CAP_8BIT | MALLOC_CAP_INTERNAL); + if (self->internal_data == NULL) { + raise_esp_error(ESP_ERR_NO_MEM); + } + self->internal_data->stereo = right_channel != NULL; + self->internal_data->out_channels = right_channel != NULL ? 2 : 1; + + self->left_pwm.deinited = true; + self->right_pwm.deinited = true; + // variable_frequency is false so that the two channels of a stereo pair can // share one LEDC timer. At PWM_FREQUENCY pwmio picks a PWM_BITS-wide duty. pwmout_result_t result = common_hal_pwmio_pwmout_construct( &self->left_pwm, left_channel, 0, PWM_FREQUENCY, false); - if (result == PWMOUT_OK && self->stereo) { + if (result == PWMOUT_OK && self->internal_data->stereo) { result = common_hal_pwmio_pwmout_construct( &self->right_pwm, right_channel, 0, PWM_FREQUENCY, false); if (result != PWMOUT_OK) { @@ -264,18 +281,27 @@ void common_hal_audiopwmio_pwmaudioout_construct(audiopwmio_pwmaudioout_obj_t *s } } if (result != PWMOUT_OK) { + heap_caps_free(self->internal_data); + self->internal_data = NULL; common_hal_pwmio_pwmout_raise_error(result); } - self->quiescent_value = quiescent_value >> SAMPLE_BITS_TO_DISCARD; + // Copy the channel numbers in: the ISR must not reach into the pwmio + // objects, which live on the (possibly PSRAM) MicroPython heap. + self->internal_data->left_channel = self->left_pwm.chan_handle.channel; + if (self->internal_data->stereo) { + self->internal_data->right_channel = self->right_pwm.chan_handle.channel; + } + + self->internal_data->quiescent_value = quiescent_value >> SAMPLE_BITS_TO_DISCARD; common_hal_pwmio_pwmout_set_duty_cycle(&self->left_pwm, quiescent_value); - if (self->stereo) { + if (self->internal_data->stereo) { common_hal_pwmio_pwmout_set_duty_cycle(&self->right_pwm, quiescent_value); } } bool common_hal_audiopwmio_pwmaudioout_deinited(audiopwmio_pwmaudioout_obj_t *self) { - return common_hal_pwmio_pwmout_deinited(&self->left_pwm); + return self->internal_data == NULL; } void common_hal_audiopwmio_pwmaudioout_deinit(audiopwmio_pwmaudioout_obj_t *self) { @@ -286,9 +312,12 @@ void common_hal_audiopwmio_pwmaudioout_deinit(audiopwmio_pwmaudioout_obj_t *self common_hal_audiopwmio_pwmaudioout_stop(self); common_hal_pwmio_pwmout_deinit(&self->left_pwm); - if (self->stereo) { + if (self->internal_data->stereo) { common_hal_pwmio_pwmout_deinit(&self->right_pwm); } + + heap_caps_free(self->internal_data); + self->internal_data = NULL; } void common_hal_audiopwmio_pwmaudioout_play(audiopwmio_pwmaudioout_obj_t *self, @@ -298,6 +327,7 @@ void common_hal_audiopwmio_pwmaudioout_play(audiopwmio_pwmaudioout_obj_t *self, common_hal_audiopwmio_pwmaudioout_stop(self); } + audiopwmio_internal_data_t *internal_data = self->internal_data; audiosample_base_t *base = audiosample_check(sample); uint32_t sample_rate = audiosample_get_sample_rate(base); @@ -306,21 +336,24 @@ void common_hal_audiopwmio_pwmaudioout_play(audiopwmio_pwmaudioout_obj_t *self, } self->sample = sample; - self->loop = loop; + internal_data->loop = loop; self->bytes_per_sample = audiosample_get_bits_per_sample(base) / 8; self->sample_channel_count = audiosample_get_channel_count(base); uint32_t max_buffer_length; uint8_t spacing; - audiosample_get_buffer_structure(base, false, &self->single_buffer, + audiosample_get_buffer_structure(base, false, &internal_data->single_buffer, &self->signed_to_unsigned, &max_buffer_length, &spacing); - // Two device buffers of interleaved 16-bit pairs. size_t frames = max_buffer_length / self->bytes_per_sample / self->sample_channel_count; - size_t buffer_size = frames * 2 * sizeof(uint16_t); + size_t buffer_size = frames * internal_data->out_channels * sizeof(uint16_t); if (buffer_size == 0) { mp_arg_error_invalid(MP_QSTR_sample); } + if (buffer_size > MAX_BUFFER_BYTES) { + // These come out of internal RAM, so refuse rather than exhaust it. + raise_esp_error(ESP_ERR_NO_MEM); + } // Claim a slot before allocating, so a failure here frees nothing. size_t slot = MP_ARRAY_SIZE(active_audio); @@ -331,38 +364,58 @@ void common_hal_audiopwmio_pwmaudioout_play(audiopwmio_pwmaudioout_obj_t *self, } } if (slot == MP_ARRAY_SIZE(active_audio)) { - mp_raise_RuntimeError(MP_ERROR_TEXT("Failed to start async audio")); + raise_esp_error(ESP_ERR_NOT_FOUND); } free_buffers(self); for (uint8_t i = 0; i < 2; i++) { - self->buffers[i] = m_malloc_without_collect(buffer_size); + // The ISR reads these with the cache possibly disabled, so they have + // to be internal RAM too, not the MicroPython heap. + internal_data->buffers[i] = heap_caps_malloc(buffer_size, + MALLOC_CAP_8BIT | MALLOC_CAP_INTERNAL); + if (internal_data->buffers[i] == NULL) { + free_buffers(self); + raise_esp_error(ESP_ERR_NO_MEM); + } self->buffer_size[i] = buffer_size; - if (self->single_buffer) { + if (internal_data->single_buffer) { break; } } audiosample_reset_buffer(sample, false, 0); - self->playing_buffer = 0; - self->playing_frame = 0; - self->stop_queued = false; + internal_data->playing_buffer = 0; + internal_data->playing_frame = 0; + internal_data->stop_queued = false; self->paused = false; self->playing = true; // Prime both buffers before the timer starts. fill_buffer(self, 0); - if (!self->single_buffer && !self->stop_queued) { + if (!internal_data->single_buffer && !internal_data->stop_queued) { fill_buffer(self, 1); } - if (self->stop_queued) { + if (internal_data->stop_queued) { // Nothing to play (empty or broken sample). Don't start the timer, or // there would be nothing to shut it down again. common_hal_audiopwmio_pwmaudioout_stop(self); return; } + // The GPTimer driver is built cache-safe here, so it requires the callback + // in IRAM and its user context in internal RAM, and it only checks the + // latter two. Check all of it, so a regression shows up as an error from + // play() rather than as a fault at a random later moment. + if (!esp_ptr_in_iram((const void *)timer_alarm_cb) || + !esp_ptr_internal(internal_data) || + !esp_ptr_internal(internal_data->buffers[0]) || + (internal_data->buffers[1] != NULL && !esp_ptr_internal(internal_data->buffers[1]))) { + self->playing = false; + free_buffers(self); + raise_esp_error(ESP_ERR_INVALID_ARG); + } + gptimer_config_t timer_config = { .clk_src = GPTIMER_CLK_SRC_DEFAULT, .direction = GPTIMER_COUNT_UP, @@ -381,17 +434,26 @@ void common_hal_audiopwmio_pwmaudioout_play(audiopwmio_pwmaudioout_obj_t *self, alarm_config.flags.auto_reload_on_alarm = true; active_audio[slot] = self; - if (gptimer_new_timer(&timer_config, &self->timer) != ESP_OK || - gptimer_register_event_callbacks(self->timer, &callbacks, self) != ESP_OK || - gptimer_set_alarm_action(self->timer, &alarm_config) != ESP_OK || - gptimer_enable(self->timer) != ESP_OK || - gptimer_start(self->timer) != ESP_OK) { + esp_err_t err = gptimer_new_timer(&timer_config, &self->timer); + if (err == ESP_OK) { + err = gptimer_register_event_callbacks(self->timer, &callbacks, internal_data); + } + if (err == ESP_OK) { + err = gptimer_set_alarm_action(self->timer, &alarm_config); + } + if (err == ESP_OK) { + err = gptimer_enable(self->timer); + } + if (err == ESP_OK) { + err = gptimer_start(self->timer); + } + if (err != ESP_OK) { // Leave nothing half-started: a stuck `playing` would hang any // `while playing:` loop forever. self->playing = false; stop_timer(self); free_buffers(self); - mp_raise_RuntimeError(MP_ERROR_TEXT("Failed to start async audio")); + raise_esp_error(err); } } @@ -400,18 +462,17 @@ void common_hal_audiopwmio_pwmaudioout_stop(audiopwmio_pwmaudioout_obj_t *self) self->playing = false; self->paused = false; - self->stop_queued = false; + self->internal_data->stop_queued = false; self->sample = NULL; // Park the output at the quiescent level so the speaker does not sit at a // DC offset between samples. + uint16_t quiescent = self->internal_data->quiescent_value << SAMPLE_BITS_TO_DISCARD; if (!common_hal_pwmio_pwmout_deinited(&self->left_pwm)) { - common_hal_pwmio_pwmout_set_duty_cycle(&self->left_pwm, - self->quiescent_value << SAMPLE_BITS_TO_DISCARD); + common_hal_pwmio_pwmout_set_duty_cycle(&self->left_pwm, quiescent); } - if (self->stereo && !common_hal_pwmio_pwmout_deinited(&self->right_pwm)) { - common_hal_pwmio_pwmout_set_duty_cycle(&self->right_pwm, - self->quiescent_value << SAMPLE_BITS_TO_DISCARD); + if (self->internal_data->stereo && !common_hal_pwmio_pwmout_deinited(&self->right_pwm)) { + common_hal_pwmio_pwmout_set_duty_cycle(&self->right_pwm, quiescent); } free_buffers(self); diff --git a/ports/espressif/common-hal/audiopwmio/PWMAudioOut.h b/ports/espressif/common-hal/audiopwmio/PWMAudioOut.h index 80f636573ba..98e90474cd9 100644 --- a/ports/espressif/common-hal/audiopwmio/PWMAudioOut.h +++ b/ports/espressif/common-hal/audiopwmio/PWMAudioOut.h @@ -19,6 +19,28 @@ #define PWM_BITS (10) #define SAMPLE_BITS_TO_DISCARD (16 - PWM_BITS) +// Everything the alarm ISR touches. CircuitPython builds the GPTimer driver +// with CONFIG_GPTIMER_ISR_CACHE_SAFE, so the ISR can run while the flash cache +// is disabled - during any flash write, including the user saving code.py. +// Anything it dereferences must therefore live in internal RAM, which the +// MicroPython heap is not: port_malloc() prefers PSRAM when a board has it. +typedef struct { + uint16_t *buffers[2]; // interleaved duty values, internal RAM + size_t buffer_frames[2]; // valid frames currently in each buffer + volatile size_t playing_frame; // read cursor within the active buffer + volatile uint8_t playing_buffer; // which buffer the ISR is draining + volatile bool buffer_ready[2]; // buffer holds data the ISR has not used + volatile bool last_buffer[2]; // this buffer ends the sample + volatile bool stop_queued; // the ISR reached the end of the sample + uint16_t quiescent_value; // scaled to PWM_BITS + uint8_t left_channel; // LEDC channel numbers, copied in so the + uint8_t right_channel; // ISR never reaches into the pwmio objects + uint8_t out_channels; // 1 or 2; the stride of buffers[] + bool stereo; + bool single_buffer; + bool loop; +} audiopwmio_internal_data_t; + typedef struct { mp_obj_base_t base; @@ -27,31 +49,16 @@ typedef struct { gptimer_handle_t timer; mp_obj_t sample; + audiopwmio_internal_data_t *internal_data; - // Two device buffers of interleaved (left, right) duty values, already - // scaled to PWM_BITS. The ISR drains one while audiopwmout_background() - // refills the other. - uint16_t *buffers[2]; - size_t buffer_size[2]; // allocated size in bytes - size_t buffer_frames[2]; // valid frames currently in the buffer - - volatile size_t playing_frame; // read cursor within the active buffer - volatile uint8_t playing_buffer; // which buffer the ISR is draining - volatile bool buffer_ready[2]; // buffer holds data the ISR has not used - volatile bool last_buffer[2]; // this buffer ends the sample - volatile bool stop_queued; // the ISR reached the end of the sample - - uint16_t quiescent_value; // scaled to PWM_BITS + size_t buffer_size[2]; // allocated bytes, for the free path uint8_t sample_channel_count; uint8_t bytes_per_sample; - bool stereo; // right channel in use bool signed_to_unsigned; - bool single_buffer; bool playing; bool paused; - bool loop; } audiopwmio_pwmaudioout_obj_t; // Called from port_background_task(): refills buffers the ISR has drained.