ports/stm: add audioio.AudioOut for STM32F405/F407#10976
Open
ChrisNourse wants to merge 16 commits intoadafruit:mainfrom
Open
ports/stm: add audioio.AudioOut for STM32F405/F407#10976ChrisNourse wants to merge 16 commits intoadafruit:mainfrom
ChrisNourse wants to merge 16 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>
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.
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