ports/stm: add audioio.AudioOut for STM32F405/F407#10976
ports/stm: add audioio.AudioOut for STM32F405/F407#10976ChrisNourse wants to merge 18 commits intoadafruit:mainfrom
Conversation
Implements audioio.AudioOut using the STM32F405/F407 12-bit DAC on pin A0 (PA04, DAC channel 1). TIM6 clocks the sample rate; DMA1 Stream5 feeds samples in circular double-buffer mode so the CPU is free between half-transfer callbacks. Supported formats: 8-bit unsigned, 8-bit signed, 16-bit unsigned, 16-bit signed, mono and stereo (left channel only). play(), stop(), pause(), resume(), and deinit() are all implemented. A soft ramp on construct/deinit suppresses audible pops. Build changes: - mpconfigport.mk: set CIRCUITPY_AUDIOIO = 1 for STM32F405xx/F407xx; change the F4-series default to CIRCUITPY_AUDIOIO ?= 0 so the F405/F407 override takes effect. - AnalogOut.h: expose the shared DAC_HandleTypeDef handle so AudioOut can reuse it without double-initialising the peripheral. - port.c: call audioout_reset() from reset_port() so an in-progress playback is cleanly stopped on soft reset. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds tests/circuitpython-manual/audioio/ with:
- wavefile_playback.py — plays three WAV files (8-bit unsigned,
16-bit signed at 8 kHz and 44.1 kHz)
- wavefile_pause_resume.py — exercises pause()/resume() during playback
- single_buffer_loop.py — loops a 440 Hz RawSample in all four
sample formats (u8, s8, u16, s16)
- run_serial_tests.py — automates Tests 1–4 via mpremote: copies
files to the board and checks serial output
against expected patterns; exits 0/1 for CI
- README.md — full test procedure including hardware setup,
build instructions, expected output for each
test, oscilloscope tips, and known limitations
Tests 1–4 are fully automated (requires: pip install mpremote).
Test 5 (soft-reset cleanup) remains a guided manual step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enable right-channel audio output on PA05 (DAC_CH2) using DMA1 Stream6 Channel7, triggered by the same TIM6 as the left channel. Mono sources are duplicated to both channels when right_channel is provided. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add Test 5 (stereo_playback.py) for verifying dual-DAC output. Update run_serial_tests.py with cross-platform CIRCUITPY volume detection (macOS/Linux/Windows), direct filesystem copy via shutil, and Python 3.7+ compatibility via `from __future__ import annotations`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
load_dma_buffer_half() previously called audiosample_get_buffer() on every half-fill and discarded any unconsumed bytes. Sources that return buffers larger than AUDIOOUT_DMA_HALF_SAMPLES (e.g. a 4410-sample RawSample) had everything past the first half-buffer worth of samples silently dropped, producing the wrong waveform. Track src_ptr / src_remaining_len / src_done on the object so a single source buffer is consumed across as many half-fills as needed before the next get_buffer call. End-of-stream (GET_BUFFER_DONE) is handled on the next fill rather than mid-fill so any trailing data in the current buffer is played first. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
256-sample halves left only ~5 ms of headroom before underrun at 44.1 kHz. USB enumeration, VFS sync and other main-loop work can exceed that, producing audible glitches. Bumping to 1024-sample halves (21 ms at 48 kHz) gives comfortable margin while still keeping total buffer memory at 4 KB per channel. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Truncating the divisor biased the realised sample rate slightly fast (e.g. 84 MHz / 44100 = 1904.76 truncated to 1904 yields 44117.6 Hz, ~0.7 cents sharp). Round to nearest so the rate is always the closest achievable, not the next one above. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
play() previously stopped the single-mode quiescent DAC output and went straight into DMA-driven mode. If dma_buffer[0] sat far from the quiescent value, the resulting jump produced an audible click at the start of every clip. Generalise dac_ramp() to either DAC channel and ramp from quiescent into dma_buffer[0] (and from 0 into dma_buffer_r[0] for the right channel) before reconfiguring for T6_TRGO. The pin already sits at the first DMA sample by the time the timer is started, so the transition into DMA output is seamless. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the scalar buffer_half_to_fill with a halves_to_fill bitmask so a back-to-back half/full IRQ pair queues both fills even if the background callback hasn't run yet. Grow the DMA circular buffer to 8192 samples (4096-sample halves) so each half-fill window covers ~186 ms at 22050 Hz, giving the background callback enough slack to absorb SDIO cluster reads, NeoPixel updates, and other main-loop stalls without underrun. Also expand the audioio manual test suite (stereo_playback, serial runner, README/TESTING docs) to cover the new behaviour. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Apply code-review fixes to the F405/F407 audioio implementation: - Fix infinite loop on partial-frame source data in load_dma_buffer_half (was spinning when convert returned 0 with leftover bytes). - Use canonical audiosample_get_* accessors for sample format. - Validate sample_rate via mp_arg_validate_int_max (1 MHz ceiling). - Replace m_malloc with m_malloc_without_collect to avoid GC during DAC configure. - Raise on HAL_TIM_Base_Init / HAL_TIMEx_MasterConfigSynchronization / HAL_DMA_Init failure rather than silently continuing. - Clear left/right pin refs and playing flag in audioout_reset so the next construct starts from a clean state. - Gate paused on playing in get_paused, matching espressif convention. - Claim pins first before any other allocation so the error path needs no rollback. - Bump DMA priority HIGH -> VERY_HIGH on both streams (sweep analysis shows this is safe and gives more refill headroom). - Make CIRCUITPY_AUDIOIO opt-out via ?= so boards reusing TIM6 / PA04 can disable it.
- run_serial_tests.py: pre-flight detects port held by other process (e.g. VS Code Serial Monitor) and reports the holder up front rather than spinning through opaque retries; add port-reappear wait, wider retry net (Errno 6/16, SerialException, "device in use"), and inter-test settle so a CDC drop in one test no longer cascades through the rest of the suite. - README: drop hardcoded toolchain paths, fix contradictory stereo-WAV description, correct pan-sweep description (continuous equal-power crossfade, not stepped amplitude), align Test 3 sample-rate notes. - Delete TESTING.md (was a near-duplicate of README.md). - single_buffer_loop.py: use the same sample_rate for all four format variants so the test isolates format conversion, not playback rate. - stereo_playback.py: use array initialiser instead of bytes literal for the stereo interleave buffer. - wavefile_pause_resume.py: 30s wall-clock guard prints TIMEOUT rather than hanging the runner.
Sweep audits across boards showed atmel-samd has a constant -3.4 cent bias on every tone, consistent with truncating its TIM period. Flag that next to our round-to-nearest so the fix is portable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
STM32F4 CIRCUITPY is only ~2 MiB. With a leftover code.py + stale WAVs the runner can't fit the 5 test WAVs (~1.3 MiB), and macOS surfaces the resulting ENOSPC as a misleading "Operation not permitted" — the first attempt to run this suite on a fresh dev machine wasted real time chasing it as a permission issue. Sum the bytes we're about to write up front and bail with a concrete "need / free / short" summary so the next contributor knows immediately to clear unrelated files. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
run_serial_tests.py output from a Feather STM32F405 Express, captured via tee. Tests 1–5 (WAV playback, pause/resume, looping sine, deinit/re-init, stereo) all pass; manual Test 6 (soft-reset cleanup) is documented separately. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Test 6 (soft-reset during active playback) is covered in practice by the external frequency-sweep rig, which exercises start / stop / re- play across 30 tones in series — the same lifecycle paths the manual Ctrl-C/Ctrl-D test was checking. Drops the README section, the table row, the results-file note, and the trailing "remaining manual step" print so the docs/runner consistently say "Tests 1–5". Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Auto-applied by pre-commit ahead of upstream PR:
- locale/circuitpython.pot regenerated to include the new audioio
MP_ERROR_TEXT strings ("DAC init error", "TIM6 init failed", etc.)
- ruff-format reflow on the manual audioio test scripts
- Trailing whitespace stripped from results.md
No source-of-record changes; behaviour and APIs are identical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
tannewt
left a comment
There was a problem hiding this comment.
Here is a first pass. Thanks for looking into this! I'd love to see Zephyr support for this too because I'd love to deprecate this STM port in favor of Zephyr. I know the Zephyr STM support is good.
| // Architecture: | ||
| // TIM6 (basic timer) generates an update event at the sample rate. | ||
| // DAC_CHANNEL_1 (PA04) is configured with DAC_TRIGGER_T6_TRGO so each | ||
| // TIM6 update latches the next sample from the DMA FIFO. |
There was a problem hiding this comment.
Is TIM6 used by other modules? I know other ports do dynamic timer allocation.
There was a problem hiding this comment.
Good question — surveyed the other ports. Quick summary of what each does:
| Port | Audio timer | Allocation |
|---|---|---|
| atmel-samd | TC instance from pool | runtime search via find_free_timer() |
| raspberrypi | 1 of 4 DMA pacing timers | linear search of dma_hw->timer[] |
| espressif | DAC continuous-mode driver | vendor IDF manages internally |
| nordic | PWM peripheral's own sequencer | peripheral-bound |
| mimxrt10xx | SAI / I2S frame clock | vendor HAL manages |
| stm (this PR) | TIM6 hard-coded | none |
So you're right that this PR is the outlier. Here's the rationale and what I'd like to do about it:
Why TIM6 is fixed in ports/stm
TIM6 is already reserved port-wide for DAC use by existing convention — every chip-family periph.c carries the comment // TIM6 and TIM7 are basic timers that are only used by DAC, and don't have pins. Because TIM6 has no GPIO outputs, it's deliberately not in mcu_tim_banks[], which is the array stm_peripherals_timer_reserve() manages. That allocator is the one audiopwmio, pwmio, pulseio, and rgbmatrix use, and it only knows about pin-driving timers. There's currently no module in ports/stm that uses TIM6 outside of DAC, so a TIM6 collision can't happen today.
Plan I'd like to propose for this PR
I'd rather not do a full dynamic-allocation refactor here — extending the allocator to model pinless basic timers is a port-wide change, and the fallback path (e.g. TIM7 driving DAC) isn't exercisable today because no other module competes for TIM6. We'd be testing allocator plumbing rather than a real conflict.
Middle ground: add TIM6 (and TIM7) to the existing reservation system as claim-only — the allocator records the reservation so any future feature that wants TIM6 fails fast at construct-time, but AudioOut still picks TIM6 unconditionally. This is ~30 LOC, no new search/fallback logic, and the conflict path is testable (just have a future feature call stm_peripherals_timer_reserve(TIM6) and watch it raise).
Two options:
- Claim-only: TIM6 enters the reservation table at AudioOut construct, releases at deinit. Conflict surfaces at construct-time. Doesn't add a fallback timer. Small, easy to test, makes the "TIM6 is reserved for DAC" convention enforced by code rather than comment.
- Full dynamic with fallback: AudioOut searches TIM6/TIM7, picks the first free one, configures the matching
DAC_TRIGGER_Tx_TRGO. ~200 LOC, fallback path can't be hardware-validated today.
I'd suggest (1) for this PR and would do it as an additional commit before merge if you're OK with that. Happy to do (2) as a follow-up PR once there's actually another module wanting one of the basic timers. Let me know which you prefer.
There was a problem hiding this comment.
How are you making sure two or more DAC uses don't clobber each other on TIM6?
There was a problem hiding this comment.
Considered three options:
- Full integration into
mcu_tim_banks[](TIM6/7 across all chipperiph.c+tim_clock_enable/disable+find_timerguard) — touches every STM board, can't validate F7/H7/L4 without hardware. - Parallel
stm_peripherals_basic_timer_*API — works but adds public surface for a case not reachable today. - Rely on pin-claim — every reachable collision (2×
AudioOut,AudioOutafterAnalogOut, soft reset) is already caught bycommon_hal_mcu_pin_claim(PA04). Unreachable case is a future C driver grabbing TIM6 directly, which doesn't exist.
Going with (3). STM is heading toward the Zephyr port long-term, where peripheral ownership is devicetree-managed — port-specific reservation infrastructure now would be throwaway.
…ioio Address review feedback on PR adafruit#10976: - Replace 11 audioio-specific MP_ERROR_TEXT strings with existing pot entries via %q substitution (Invalid %q pin, %q init failed) or by reusing analogio/AnalogOut's "DAC Device/Channel Init Error". - Drop the redundant in-driver deinit check in common_hal_audioio_audioout_play(); the shared-bindings layer already guards every entry point with check_for_deinit -> raise_deinited_error. - Net result: zero new entries in locale/circuitpython.pot. - Also remove tests/circuitpython-manual/audioio/results.md (committed in a prior commit; the test scripts stay, the captured run output shouldn't live in the repo). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks! On Zephyr — I'm interested but want to flag scope. A Zephyr port of
So I'd treat this as a separate, multi-PR effort once the foundation is further along. Happy to port the algorithmic parts of this work over (sample-rate timer math, half-fill DMA queue, ramp-in/ramp-out, mixer interaction) into a |
| // Architecture: | ||
| // TIM6 (basic timer) generates an update event at the sample rate. | ||
| // DAC_CHANNEL_1 (PA04) is configured with DAC_TRIGGER_T6_TRGO so each | ||
| // TIM6 update latches the next sample from the DMA FIFO. |
There was a problem hiding this comment.
How are you making sure two or more DAC uses don't clobber each other on TIM6?
|
Totally fine to get this in before moving STM Zephyr. You can test with this zephyr board def: https://docs.zephyrproject.org/latest/boards/adafruit/feather_stm32f405/doc/index.html if you want to try it in a follow up. |
Replace the custom "sample_rate must be > 0" raise with the standard mp_arg_validate_int_min helper, matching the existing _int_max call on the next line. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
audioio.AudioOutfor the STM32 port. STM32F4 boards (Feather STM32F405 Express, etc.) previously had noaudioiosupport.PA04/A0 (DAC1); stereo viaPA05/A1 (DAC2), with both DAC channels triggered off the same timer so L/R stay sample-aligned.analogio.AnalogOuton this port by sharing the underlying DAC handle, so a board can mix static DAC writes (AnalogOut.value = ...) and streaming audio without an explicit re-init.Closes #2864.
Verification
Automated test suite
run_serial_tests.pyon a Feather STM32F405 Express — all 5 automated tests PASS. Captured output:tests/circuitpython-manual/audioio/results.md.deinitand re-init (audioio↔analogiohandover)Independent frequency-sweep test
External rig that records the DAC output and computes per-tone SNR / flatness / frequency-error metrics. Also exercises the start/stop/re-play lifecycle in series across 30 tones.
Cross-port comparison vs atmel-samd (Circuit Playground Express)
Same rig, same recording chain, 30 sine tones 100 Hz → 20 kHz:
The +17 dB SNR headroom is consistent with the chip class (12-bit vs 10-bit DAC, faster bus, dedicated DMA streams). Side observation: CPX shows a roughly constant +4 cent bias across the entire band, which looks like a truncating period calculation in the atmel-samd timer setup — this port uses round-to-nearest for the TIM6 period (commit d112d01) to avoid that systematic error.
Full per-tone deltas + plots: https://github.com/ChrisNourse/circuit-python-audioio-sweep-analysis/blob/main/comparisons/cpx_vs_stm32f405/comparison.md
Known board-specific note
On the Feather STM32F405 Express specifically, DAC2 (A1 / PA05) measures ~15 dB worse SNR than DAC1 (A0 / PA04) — same firmware, same buffer contents, same wiring, only the board pin moved between runs. A baseline mono A0 capture with DAC2 idle matches the dual-DAC A0 capture within 0.2 dB, ruling out our dual-DMA-stream approach as the cause; the remaining suspects are PCB trace pickup on PA05 and per-chip variation in DAC2. Documented in the test rig README under Known board notes, with both recordings committed for reviewers to listen to. This is a hardware observation, not a firmware regression. By ear, I can't tell the difference.
Test plan
run_serial_tests.pyautomated suite passes on F405audiomixer.Mixerper-voice scaling path produces clean audio post-mult16signedfixports/stm/:feather_stm32f405_express(hardware-validated),pyboard_v11,sparkfun_stm32_thing_plus,sparkfun_stm32f405_micromod,stm32f4_discovery🤖 Generated with Claude Code