Skip to content

ports/stm: add audioio.AudioOut for STM32F405/F407#10976

Open
ChrisNourse wants to merge 16 commits intoadafruit:mainfrom
ChrisNourse:feature/add_audioio_to_stm32
Open

ports/stm: add audioio.AudioOut for STM32F405/F407#10976
ChrisNourse wants to merge 16 commits intoadafruit:mainfrom
ChrisNourse:feature/add_audioio_to_stm32

Conversation

@ChrisNourse
Copy link
Copy Markdown

@ChrisNourse ChrisNourse commented May 4, 2026

Summary

  • First audioio.AudioOut for the STM32 port. STM32F4 boards (Feather STM32F405 Express, etc.) previously had no audioio support.
  • Drives the on-chip 12-bit DAC via TIM6 + DMA1 — true analog out, like the atmel-samd port, instead of the PWM-based approach used by raspberrypi and nrf. Mono on PA04/A0 (DAC1); stereo via PA05/A1 (DAC2), with both DAC channels triggered off the same timer so L/R stay sample-aligned.
  • Cooperates with the existing analogio.AnalogOut on 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.
  • Built directly on the STM32 HAL primitives this port already uses (TIM / DMA / RCC).

Closes #2864.

Verification

Automated test suite

run_serial_tests.py on a Feather STM32F405 Express — all 5 automated tests PASS. Captured output: tests/circuitpython-manual/audioio/results.md.

# Test Status
1 WAV file playback (5 formats) PASS
2 Pause / resume PASS
3 Looping sine wave (signed/unsigned, 8/16-bit) PASS
4 deinit and re-init (audioioanalogio handover) PASS
5 Stereo playback (L-only / R-only / both / pan / WAV) PASS

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:

Metric CPX (SAMD21, 10-bit) STM32F405 (12-bit) Δ
Avg SNR 31.5 dB 48.7 dB +17.2 dB
Min SNR 13.9 dB 30.1 dB +16.2 dB
Flatness (max−min) 5.96 dB 3.65 dB -2.3 dB
Max freq error 7.05 cents 3.23 cents -3.8 cents

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.py automated suite passes on F405
  • Mono output on A0 produces a clean sine on a scope
  • Stereo output (A0 + A1) produces independent L/R channels
  • audiomixer.Mixer per-voice scaling path produces clean audio post-mult16signed fix
  • Compile-tested on all 5 F405/F407 boards in ports/stm/: feather_stm32f405_express (hardware-validated), pyboard_v11, sparkfun_stm32_thing_plus, sparkfun_stm32f405_micromod, stm32f4_discovery
  • Hardware-validated only on Feather STM32F405 Express; F407 hardware not available — would appreciate a second pair of eyes

🤖 Generated with Claude Code

Chris Nourse and others added 16 commits May 2, 2026 01:52
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>
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.

STM32: AudioIO support

1 participant