Repository navigation
Conversation
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>
Owner
Author
|
Confirmed working on hardware. Flashed to an Adafruit FunHouse: the 440 Hz This is the run after the internal-RAM fix; the previous build failed at |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
audiopwmio.PWMAudioOutto 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 — soaudioiocan 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'smainis 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 fromport_background_task(), refills the other from the audiosample protocol.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.timer_ll_set_clock_prescaleasserts divider >= 2). Rate error is under 0.01%: 8000 exact, 22050 -> 22050.7, 44100 -> 44101.4 Hz.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 returningESP_ERR_INVALID_ARG, so the alarm callback was never installed: the timer ran, no alarm was ever delivered, nothing consumed samples, andplay()leftplayingtrue forever with no error.The driver is built with
CONFIG_GPTIMER_ISR_CACHE_SAFE— selected byCONFIG_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 wasself, a MicroPython heap object, andport_malloc()takesMALLOC_CAP_SPIRAMfirst, 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 patterncommon-hal/frequencyioalready uses: the ISR-reachable state lives in anaudiopwmio_internal_data_tallocated withMALLOC_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 toheap_caps_malloc(MALLOC_CAP_INTERNAL)for the same reason, capped so an oversized sample fails withESP_ERR_NO_MEMrather 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()assertsesp_ptr_in_iram()for the callback andesp_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 portCALLBACK_CRITICAL_BEGINistaskENTER_CRITICAL(), andvPortEnterCriticalCompliance()printsport*_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 underlyingesp_err_tviaraise_esp_error(), so a timer that cannot be configured raises fromplay()instead of leavingplayingtrue forever.alarm_configisstaticbecause ofCONFIG_GPTIMER_CTRL_FUNC_IN_IRAM, under whichgptimer_set_alarm_action()rejects a config struct outside internal RAM.Build plumbing
ledc_ll.hlives in the per-targetesp_hal_ledc/$(IDF_TARGET)/includedirectory, which was not on the include path (esp_hal_i2shas both entries;esp_hal_ledconly had the generic one).Enabled for esp32s2 only. The S3 has neither DAC nor audiopwmio and could reuse this later.
Size
Measured on
adafruit_funhouseby building withCIRCUITPY_AUDIOPWMIO=0and=1:=0=1The 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 leaveCIRCUITPY_AUDIOPWMIOoff pay nothing.Testing
Builds clean for
adafruit_funhouse;timer_alarm_cblinks into.iram0.textas 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
pwmionormally produces.PWM_BITS10 -> 11 halves that and gains a bit of resolution, at a 39 kHz carrier.🤖 Generated with Claude Code