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.
This pr has now revised onto main as the synthio PRs merged.

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

tyeth commented Oct 24, 2026 •

Copy link
Copy Markdown
Member Author

Code example used:

# SPDX-FileCopyrightText: Copyright (c) 2026 Tyeth Gundry for Adafruit Industries
# SPDX-License-Identifier: MIT
"""FunHouse: 440 Hz test, then a polyphonic MIDI file via audiopwmio + synthio.

REQUIRES the custom firmware that adds audiopwmio.PWMAudioOut to the ESP32-S2.
Stock CircuitPython has no audiopwmio on espressif and this will raise
ImportError -- use the pwmio/adafruit_midi_parser version instead.

Unlike the piezo/pwmio version this is POLYPHONIC: synthio renders up to 12
voices (CIRCUITPY_SYNTHIO_MAX_CHANNELS on esp32s2) into a sample stream, which
audiopwmio pushes out of GPIO42 on a 78 kHz carrier.

The .mid must be prepared for synthio.from_file(), which is strict: (synthio fixed for last two)
  * format 0, exactly one track
  * no running status, and no meta events at all (a tempo event is read as
    end-of-track and silently stops playback)
  * tempo is fixed at 2*division ticks/sec, so the real tempo must be baked
    into the delta times
Use make_synthio_midi.py to convert; a normal .mid will not work.
"""

import array
import math
import time

import audiocore
import audiopwmio
import board
import synthio
import time
time.sleep(3)

MIDI_FILE = "/melody.mid"
SAMPLE_RATE = 11025

dac = audiopwmio.PWMAudioOut(board.SPEAKER)

# --- 1. 440 Hz sine --------------------------------------------------------
length = 8000 // 440
sine_wave = array.array("H", [0] * length)
for i in range(length):
    sine_wave[i] = int(math.sin(math.pi * 2 * i / length) * (2**15) + 2**15)

print("440 Hz test tone")
dac.play(audiocore.RawSample(sine_wave, sample_rate=8000), loop=True)
time.sleep(1)
dac.stop()
time.sleep(0.5)

# --- 2. MIDI through synthio ----------------------------------------------
# Single-cycle waveform for the voices. Must be signed 16-bit ('h').
# Delete the waveform= argument for synthio's default 50% square, which is
# closer to a chip-organ and arguably more fitting for a toccata.
WAVE_LENGTH = 128
waveform = array.array("h", [0] * WAVE_LENGTH)
for i in range(WAVE_LENGTH):
    waveform[i] = int(math.sin(math.pi * 2 * i / WAVE_LENGTH) * (2**15 - 1))

envelope = synthio.Envelope(
    attack_time=0.03,
    decay_time=0.05,
    release_time=0.2,
    attack_level=1.0,
    sustain_level=0.8,
)

try:
    midi_data = open(MIDI_FILE, "rb")
except OSError:
    print("no", MIDI_FILE, "- nothing to play")
else:
    midi = synthio.from_file(
        midi_data,
        sample_rate=SAMPLE_RATE,
        waveform=waveform,
        envelope=envelope,
    )
    print("playing", MIDI_FILE, "at", midi.tempo, "ticks/sec")
    dac.play(midi)
    while dac.playing:
        pass
    # -1 means the track decoded cleanly; anything else is the byte offset
    # where MidiTrack gave up (usually running status or a meta event).
    print("error_location:", midi.error_location)
    midi.deinit()

print("stopped")
dac.deinit()

tyeth and others added 2 commits October 24, 2026 18:50
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 force-pushed the espressif-audiopwmio-esp32s2 branch from 1f6a9dd to 65138d5 Compare October 24, 2026 17:51

@dhalbert dhalbert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks - seems fine in principle. I wonder if it's worth adding to all ESP32-S2, given that it exists to solve a FunHouse problem.

And it would be nice to add to ESP32-S3, but it needs to be tested. However, that could be a later PR.

Could there be glitches due to higher priority tasks like WiFi? Have you tested that?

Willing to merge for the upcoming project immediately and we can fix these later.

Comment on lines +57 to +60
// 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does the absence of the spinlock mean in practice?

Comment on lines +120 to +121
// Convert one source buffer into device buffer `buf_index`, scaling samples to
// PWM_BITS. Runs in task context, so it may touch the object freely.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean as opposed to ISR context?

}

// Publish last, so the ISR never sees a half-filled buffer.
__asm__ volatile ("" ::: "memory");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the asm here?

Comment on lines +406 to +412
// 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]) ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this guaranteed by how the routine and variables are declared and allocated? If not, could it be checked at compile-time? Why check at runtime?

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.

2 participants