Skip to content

espressif: add audiopwmio.PWMAudioOut for the ESP32-S2 - #33

Open
tyeth wants to merge 2 commits into
base/upstream-main-cfe5624efrom
claude/audiopwmio-esp32s2
Open

tyeth wants to merge 2 commits into
base/upstream-main-cfe5624efrom
claude/audiopwmio-esp32s2

Conversation

@tyeth

@tyeth tyeth commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Adds audiopwmio.PWMAudioOut to the espressif port for the ESP32-S2, so boards whose speaker is not on a DAC pin can play samples. Motivating case: the Adafruit FunHouse piezo is on GPIO42, while the S2 DAC is hardwired to GPIO17/GPIO18 — and GPIO18 is the onboard light sensor — so audioio can never reach it.

Based on upstream/main @ cfe5624e47 (11.0.0-alpha.2). The base branch here is a snapshot of that commit, because this fork's main is 455 commits behind and a PR against it would show 456 commits instead of one.

Mechanism

LEDC has no DMA path to its duty registers, and the S2's I2S has no PDM transmitter (no SOC_I2S_SUPPORTS_PDM_TX; I2S HW v1). So samples are pushed one at a time from a GPTimer alarm ISR 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.

  • Carrier: APB_CLK / 2**10 = 78.125 kHz, 10-bit duty — the same 10-bit choice the RP2040 port makes, and it falls out of pwmio's own resolution picker for free at that frequency.
  • Sample clock: GPTimer at 40 MHz, the finest the timer-group divider allows (timer_ll_set_clock_prescale asserts divider >= 2). Rate error is under 0.01%: 8000 exact, 22050 -> 22050.7, 44100 -> 44101.4 Hz.
  • A single-buffer sample that loops wraps inside the ISR instead of round-tripping through the background task, so looping RawSamples don't click.

Everything the ISR touches lives in internal RAM

This is the part that took a hardware run to find. gptimer_register_event_callbacks() was returning ESP_ERR_INVALID_ARG, so the alarm callback was never installed: the timer ran, no alarm was ever delivered, nothing consumed samples, and play() left playing true forever with no error.

The driver is built with CONFIG_GPTIMER_ISR_CACHE_SAFE — selected by CONFIG_GPTIMER_ISR_HANDLER_IN_IRAM, which defaults on — and that enables two checks: the callback must be in IRAM, and its user context must be in internal RAM. The callback was fine. The user context 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 have been 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 or it faults at random. So this follows the pattern common-hal/frequencyio already uses: the ISR-reachable state lives in an audiopwmio_internal_data_t allocated with MALLOC_CAP_INTERNAL, and that is what gets passed as the user context. The LEDC channel numbers are copied in too, so the ISR never reaches into the pwmio objects. The audio buffers move to heap_caps_malloc(MALLOC_CAP_INTERNAL) for the same reason, capped so an oversized sample fails with ESP_ERR_NO_MEM rather than exhausting internal RAM. Buffers now hold one value per output channel rather than always a stereo pair, which halves what a mono output needs. The refill path still runs in task context and reaches the object as before.

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 rather than as a fault at a random later moment.

Why the refill is polled rather than queued from the ISR

An earlier revision had the ISR call background_callback_add(). That is not safe here: on this port CALLBACK_CRITICAL_BEGIN is taskENTER_CRITICAL(), and vPortEnterCriticalCompliance() prints port*_CRITICAL called from ISR context. Aborting!.

It was also a latent deadlock independent of that: the refill was queued only from the ISR's buffer-exhaustion path, so any time the ISR found a buffer not ready it returned early without queueing anything. Polling from port_background_task() — the same shape nordic and raspberrypi use — fixes both.

Failing loudly

Every gptimer_* call is return-checked and reports the underlying esp_err_t via raise_esp_error(), so a timer that cannot be configured raises from play() instead of leaving playing true forever. alarm_config is static because of CONFIG_GPTIMER_CTRL_FUNC_IN_IRAM, under which gptimer_set_alarm_action() rejects a config struct outside internal RAM.

Build plumbing

ledc_ll.h lives in the per-target esp_hal_ledc/$(IDF_TARGET)/include directory, which was not on the include path (esp_hal_i2s has both entries; esp_hal_ledc only had the generic one).

Enabled for esp32s2 only. The S3 has neither DAC nor audiopwmio and could reuse this later.

Size

Measured on adafruit_funhouse by building with CIRCUITPY_AUDIOPWMIO=0 and =1:

flash used internal SRAM
=0 1,474,656 216,608
=1 1,477,328 217,224
delta +2,672 B +616 B

The SRAM figure is static. While a sample is playing it also holds the control block (~48 B) plus the two device buffers from internal RAM — 512 B each for a 16-bit mono WaveFile, capped at 8 KB each. Boards that leave CIRCUITPY_AUDIOPWMIO off pay nothing.

Testing

Builds clean for adafruit_funhouse; timer_alarm_cb links into .iram0.text as required.

The internal-RAM fix is not yet verified on hardware. The previous revision was flashed and reproduced exactly the failure described above, which is what identified the cause. The piezo is also a resonant, capacitive element driven straight off the GPIO with no amplifier, so expect something thin and bass-free rather than hi-fi; and a 78 kHz carrier into a capacitive load draws noticeably more current than the 1-4 kHz tones pwmio normally produces. PWM_BITS 10 -> 11 halves that and gains a bit of resolution, at a 39 kHz carrier.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@tyeth

tyeth commented Oct 23, 2026

Copy link
Copy Markdown
Owner Author

Confirmed working on hardware. Flashed to an Adafruit FunHouse: the 440 Hz RawSample test tone plays, and a synthio MIDI track plays through PWMAudioOut on the onboard piezo (board.SPEAKER, GPIO42) to completion.

This is the run after the internal-RAM fix; the previous build failed at gptimer_register_event_callbacks with ESP_ERR_INVALID_ARG because user_data was a PSRAM-resident MicroPython object.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant