Skip to content

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

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

ports/stm: add audioio.AudioOut for STM32F405/F407#10976
ChrisNourse wants to merge 18 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>
Copy link
Copy Markdown
Member

@tannewt tannewt left a comment

Choose a reason for hiding this comment

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

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.

Comment thread locale/circuitpython.pot
Comment thread tests/circuitpython-manual/audioio/results.md Outdated
// 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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is TIM6 used by other modules? I know other ports do dynamic timer allocation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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:

  1. 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.
  2. 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How are you making sure two or more DAC uses don't clobber each other on TIM6?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Considered three options:

  1. Full integration into mcu_tim_banks[] (TIM6/7 across all chip periph.c + tim_clock_enable/disable + find_timer guard) — touches every STM board, can't validate F7/H7/L4 without hardware.
  2. Parallel stm_peripherals_basic_timer_* API — works but adds public surface for a case not reachable today.
  3. Rely on pin-claim — every reachable collision (2× AudioOut, AudioOut after AnalogOut, soft reset) is already caught by common_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>
@ChrisNourse
Copy link
Copy Markdown
Author

Thanks! On Zephyr — I'm interested but want to flag scope.

A Zephyr port of audioio would live in ports/zephyr-cp/, not as a refactor of ports/stm/, and it's a much bigger effort than the audioio module alone:

  • Zephyr DAC + DMA + devicetree APIs are completely different surface from the STM32 HAL — basically a from-scratch implementation, not a port.
  • ports/zephyr-cp doesn't currently list any STM32F4 board, so there's no hardware-tested path to validate against. Several foundational layers (pinctrl bindings for our boards, Zephyr DMA driver coverage on F405, board-level .overlay files) would need to land first.
  • The realistic test rig also changes — I'd need a Zephyr-supported board with a DAC pin exposed, which isn't what I have on the bench today.

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 ports/zephyr-cp/common-hal/audioio/ once the surrounding pieces exist. For now my goal is to get audio working on the existing ports/stm Feather F405 path that's in users' hands today.

Comment thread locale/circuitpython.pot
// 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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How are you making sure two or more DAC uses don't clobber each other on TIM6?

@tannewt
Copy link
Copy Markdown
Member

tannewt commented May 5, 2026

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>
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

2 participants