Skip to content

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

Open
tyeth wants to merge 2 commits into
mainfrom
espressif-audiopwmio-esp32s2
Open

tyeth wants to merge 2 commits into
mainfrom
espressif-audiopwmio-esp32s2

Conversation

@tyeth

@tyeth tyeth commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

The espressif port has no audiopwmio, so a board whose speaker is not wired to a DAC pin has no way to play samples at all. On the Adafruit FunHouse the piezo is on GPIO42, while the ESP32-S2 DAC is hardwired to GPIO17/GPIO18 — and GPIO18 is the ambient light sensor — so audioio cannot reach it.

Mechanism

LEDC plus a GPTimer alarm ISR running at the sample rate. The alternatives were checked against the in-tree IDF rather than assumed:

  • I2S PDM TX — not available. soc/esp32s2/include/soc/soc_caps.h has no SOC_I2S_SUPPORTS_PDM_TX; PDM TX arrives on the S3.
  • LEDC + DMA — LEDC has no DMA path to its duty registers on any ESP32, so something has to write duty per sample.
  • DAC — pin-locked to GPIO17/18, which is the reason this is needed.

The alarm callback writes the duty registers through ledc_ll_*, draining one of two buffers while a background callback refills the other from the audiosample protocol — the same shape the port's audioio uses for its DMA refills.

Carrier is APB_CLK / 2**10 = 78.125 kHz with 10 bits of duty, the same resolution choice the RP2040 port makes. The GPTimer runs at 40 MHz, the finest the timer-group divider allows, putting sample-rate error under 0.01% (8000 exact, 22050 -> 22050.7, 44100 -> 44101.4 Hz).

ISR state must be in internal RAM

Worth highlighting for review, because it is not obvious and it is why a first attempt failed on hardware: CircuitPython builds with CONFIG_GPTIMER_ISR_CACHE_SAFE, so gptimer_register_event_callbacks() requires the callback to be in IRAM and user_data to be in internal RAM. Passing self fails with ESP_ERR_INVALID_ARG, because port_malloc() takes MALLOC_CAP_SPIRAM first and the whole MicroPython heap is external on a PSRAM board.

So, following common-hal/frequencyio, everything the ISR dereferences lives in a MALLOC_CAP_INTERNAL control block passed as user_data — including the sample buffers, which would otherwise come from that same PSRAM heap and fault whenever the cache is disabled during a flash write. play() asserts esp_ptr_in_iram() on the callback and esp_ptr_internal() on the block and both buffers, so a regression surfaces there rather than as a random crash.

Verification

Confirmed on an Adafruit FunHouse: a 440 Hz RawSample test tone plays, and a synthio MIDI track plays through PWMAudioOut on the onboard piezo to completion. Tested on that one board only.

On a piezo specifically, expect a thin, bass-free sound — it is a resonant capacitive element driven straight off a GPIO with no amplifier. Note also that a 78 kHz carrier draws appreciably more current into that capacitance than the 1-4 kHz tones pwmio normally produces; raising PWM_BITS from 10 to 11 halves it at a 39 kHz carrier.

Size

adafruit_funhouse, CIRCUITPY_AUDIOPWMIO 0 vs 1:

flash internal SRAM
off 1,474,656 216,608
on 1,477,328 217,224
delta +2,672 B +616 B

Scoped to esp32s2; boards leaving it off pay nothing. The S3 has neither DAC nor audiopwmio and could reuse this.

🤖 Generated with Claude Code

-- Edit -- The uf2 with example installable 11-alpha2 + this PR #11663 on top of this PR stack #11662

funhouse_halloween_audiopwmio.zip
From tyeth#39 plus a melody.mid file.

Shows synthio playing a polyphonic midi file on the pwm "speaker" pin via audiopwmio, potentially allowing mixing and control too etc.

tyeth and others added 2 commits October 23, 2026 01:29
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>
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