From 62adfdccd687231a2da6f066dd4ae84a5163baa7 Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sun, 5 Apr 2026 04:13:05 -0700 Subject: [PATCH 01/17] ports/stm: add DAC-based audioio.AudioOut for STM32F405/F407 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 --- ports/stm/common-hal/analogio/AnalogOut.h | 6 + ports/stm/common-hal/audioio/AudioOut.c | 524 ++++++++++++++++++++++ ports/stm/common-hal/audioio/AudioOut.h | 57 +++ ports/stm/common-hal/audioio/__init__.c | 7 + ports/stm/mpconfigport.mk | 5 +- ports/stm/supervisor/port.c | 6 + 6 files changed, 603 insertions(+), 2 deletions(-) create mode 100644 ports/stm/common-hal/audioio/AudioOut.c create mode 100644 ports/stm/common-hal/audioio/AudioOut.h create mode 100644 ports/stm/common-hal/audioio/__init__.c diff --git a/ports/stm/common-hal/analogio/AnalogOut.h b/ports/stm/common-hal/analogio/AnalogOut.h index 6a8fc2720bf31..33c8abc21f89e 100644 --- a/ports/stm/common-hal/analogio/AnalogOut.h +++ b/ports/stm/common-hal/analogio/AnalogOut.h @@ -28,3 +28,9 @@ typedef struct { } analogio_analogout_obj_t; void analogout_reset(void); + +// Shared DAC peripheral handle (defined in AnalogOut.c). +// AudioOut reuses this handle for DMA-triggered DAC operation on channel 1. +#if HAS_DAC +extern DAC_HandleTypeDef handle; +#endif diff --git a/ports/stm/common-hal/audioio/AudioOut.c b/ports/stm/common-hal/audioio/AudioOut.c new file mode 100644 index 0000000000000..34252cfe287a4 --- /dev/null +++ b/ports/stm/common-hal/audioio/AudioOut.c @@ -0,0 +1,524 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Adafruit Industries LLC +// +// SPDX-License-Identifier: MIT + +// DAC-based audio output for STM32F405 / STM32F407. +// +// 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. +// DMA1 Stream5 Channel7 operates in circular mode, feeding 16-bit samples +// from a double-buffer (512 samples = two 256-sample halves). +// The DMA half-complete and complete callbacks queue a background_callback +// to refill the idle half from the audio sample source. +// +// The shared DAC handle (declared in AnalogOut.c) is reused here so both +// modules share state consistently and avoid double-init conflicts. + +#include + +#include "py/mperrno.h" +#include "py/runtime.h" +#include "py/mphal.h" + +#include "common-hal/audioio/AudioOut.h" +#include "shared-bindings/audioio/AudioOut.h" +#include "shared-bindings/microcontroller/Pin.h" +#include "shared-module/audiocore/__init__.h" +#include "supervisor/background_callback.h" + +#include STM32_HAL_H + +// Shared DAC handle declared in common-hal/analogio/AnalogOut.h. +// AudioOut reconfigures channel 1 for DMA-triggered operation and restores +// it on deinit. +#include "common-hal/analogio/AnalogOut.h" + +// TIM6 handle: only one instance ever active, so file-scope is fine. +static TIM_HandleTypeDef tim6_handle; + +// Pointer to the currently active AudioOut object, used by IRQ handlers. +static audioio_audioout_obj_t *active_audioout = NULL; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Return the TIM6 input clock frequency in Hz. +// TIM6 sits on APB1. The clock is doubled when the APB1 prescaler != 1 +// (same logic as stm_peripherals_timer_get_source_freq in timers.c but +// applied directly to APB1 since TIM6 is not in mcu_tim_banks[]). +static uint32_t get_tim6_freq(void) { + uint32_t apb1 = HAL_RCC_GetPCLK1Freq(); + uint32_t ppre1 = (RCC->CFGR & RCC_CFGR_PPRE1) >> RCC_CFGR_PPRE1_Pos; + #if defined(RCC_DCKCFGR_TIMPRE) + uint32_t timpre = RCC->DCKCFGR & RCC_DCKCFGR_TIMPRE; + if (timpre == 0) { + return (ppre1 >= 0b100) ? apb1 * 2 : apb1; + } else { + return (ppre1 > 0b101) ? apb1 * 4 : HAL_RCC_GetHCLKFreq(); + } + #else + return (ppre1 >= 0b100) ? apb1 * 2 : apb1; + #endif +} + +// Gently ramp the DAC CH1 output from from_12 to to_12 (both 12-bit, 0-4095) +// to avoid audible clicks. 64 steps, ~100 µs per step = ~6.4 ms total. +static void dac_ramp(uint32_t from_12, uint32_t to_12) { + if (from_12 == to_12) { + return; + } + int32_t delta = (int32_t)to_12 - (int32_t)from_12; + int32_t step = delta / 64; + if (step == 0) { + step = (delta > 0) ? 1 : -1; + } + int32_t v = (int32_t)from_12; + while (true) { + v += step; + if (step > 0 && v >= (int32_t)to_12) { + v = (int32_t)to_12; + } else if (step < 0 && v <= (int32_t)to_12) { + v = (int32_t)to_12; + } + HAL_DAC_SetValue(&handle, DAC_CHANNEL_1, DAC_ALIGN_12B_R, (uint16_t)v); + HAL_DAC_Start(&handle, DAC_CHANNEL_1); + mp_hal_delay_us(100); + if (v == (int32_t)to_12) { + break; + } + } +} + +// Convert a buffer of audio samples into 12-bit unsigned DAC values and write +// them into dest[]. Returns the number of DAC samples written. +// +// src - raw sample bytes from audiosample_get_buffer +// src_len - byte length of src +// dest - output array of 12-bit DAC values (uint16_t) +// dest_count - capacity of dest in samples +// bytes_per_sample - 1 or 2 +// samples_signed - true if source samples are signed +// channel_count - 1 (mono) or 2 (stereo); only channel 0 (L) is output +// quiescent_12 - value to pad with if src runs short +static uint32_t convert_to_dac12( + const uint8_t *src, uint32_t src_len, + uint16_t *dest, uint32_t dest_count, + uint8_t bytes_per_sample, bool samples_signed, + uint8_t channel_count, uint16_t quiescent_12) { + + // src_stride: bytes between consecutive left-channel samples + uint32_t src_stride = (uint32_t)bytes_per_sample * channel_count; + uint32_t src_frames = src_len / src_stride; + uint32_t frames = (src_frames < dest_count) ? src_frames : dest_count; + + if (bytes_per_sample == 1) { + for (uint32_t i = 0; i < frames; i++) { + uint8_t u8 = src[i * src_stride]; + if (samples_signed) { + // signed 8-bit: -128..127 → 0..4080 + dest[i] = (uint16_t)((int16_t)(int8_t)u8 + 128) << 4; + } else { + // unsigned 8-bit: 0..255 → 0..4080 + dest[i] = (uint16_t)u8 << 4; + } + } + } else { + for (uint32_t i = 0; i < frames; i++) { + uint16_t u16; + memcpy(&u16, src + i * src_stride, 2); + if (samples_signed) { + // signed 16-bit: -32768..32767 → 0..4095 + dest[i] = (uint16_t)((int32_t)(int16_t)u16 + 0x8000) >> 4; + } else { + // unsigned 16-bit: 0..65535 → 0..4095 + dest[i] = u16 >> 4; + } + } + } + + // Pad remainder with quiescent value. + for (uint32_t i = frames; i < dest_count; i++) { + dest[i] = quiescent_12; + } + return frames; +} + +// Load one half of the DMA circular buffer from the audio sample source. +// half: 0 = lower half (indices 0..HALF-1), 1 = upper half (HALF..END-1). +// +// Loops get_buffer calls until the full AUDIOOUT_DMA_HALF_SAMPLES-sample slot +// is filled. This is necessary because the audio sample source (e.g. WaveFile) +// delivers data in fixed-byte chunks (256 bytes) that may be smaller than the +// half-buffer in samples (e.g. 128 samples for 16-bit audio vs 256 slots). +static void load_dma_buffer_half(audioio_audioout_obj_t *self, uint8_t half) { + uint16_t *dest = self->dma_buffer + ((uint32_t)half * AUDIOOUT_DMA_HALF_SAMPLES); + uint16_t quiescent_12 = self->quiescent_value >> 4; + uint32_t filled = 0; + + while (filled < AUDIOOUT_DMA_HALF_SAMPLES) { + uint8_t *src_buf; + uint32_t src_len; + audioio_get_buffer_result_t result = + audiosample_get_buffer(self->sample, false, 0, &src_buf, &src_len); + + if (result == GET_BUFFER_ERROR) { + for (uint32_t i = filled; i < AUDIOOUT_DMA_HALF_SAMPLES; i++) { + dest[i] = quiescent_12; + } + self->stopping = true; + return; + } + + uint32_t written = convert_to_dac12( + src_buf, src_len, + dest + filled, AUDIOOUT_DMA_HALF_SAMPLES - filled, + self->bytes_per_sample, self->samples_signed, + self->channel_count, quiescent_12); + + filled += written; + + if (result == GET_BUFFER_DONE) { + if (self->loop) { + audiosample_reset_buffer(self->sample, false, 0); + // Continue filling the remainder of this half from the reset buffer. + } else { + for (uint32_t i = filled; i < AUDIOOUT_DMA_HALF_SAMPLES; i++) { + dest[i] = quiescent_12; + } + self->stopping = true; + return; + } + } + } +} + +// Background callback: called from the main loop after a DMA half/full event. +static void audioout_fill_callback(void *arg) { + audioio_audioout_obj_t *self = (audioio_audioout_obj_t *)arg; + if (!self || active_audioout != self) { + return; + } + if (self->stopping) { + common_hal_audioio_audioout_stop(self); + return; + } + load_dma_buffer_half(self, self->buffer_half_to_fill); +} + +// --------------------------------------------------------------------------- +// IRQ handlers (must be defined at file scope, not inside functions) +// --------------------------------------------------------------------------- + +void DMA1_Stream5_IRQHandler(void) { + if (active_audioout) { + HAL_DMA_IRQHandler(&active_audioout->dma_handle); + } +} + +// HAL weak-symbol overrides: called from HAL_DMA_IRQHandler context. + +void HAL_DAC_ConvHalfCpltCallbackCh1(DAC_HandleTypeDef *hdac) { + if (active_audioout && !active_audioout->paused) { + active_audioout->buffer_half_to_fill = 0; + background_callback_add(&active_audioout->callback, + audioout_fill_callback, active_audioout); + } +} + +void HAL_DAC_ConvCpltCallbackCh1(DAC_HandleTypeDef *hdac) { + if (active_audioout && !active_audioout->paused) { + active_audioout->buffer_half_to_fill = 1; + background_callback_add(&active_audioout->callback, + audioout_fill_callback, active_audioout); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +void common_hal_audioio_audioout_construct(audioio_audioout_obj_t *self, + const mcu_pin_obj_t *left_channel, const mcu_pin_obj_t *right_channel, + uint16_t quiescent_value) { + + #if !HAS_DAC + mp_raise_ValueError(MP_ERROR_TEXT("No DAC on chip")); + #else + + // Right channel (stereo) support deferred to a future implementation. + if (right_channel != NULL) { + mp_raise_ValueError(MP_ERROR_TEXT("Stereo not supported on this board")); + } + + // Only PA04 (DAC_CH1) is supported. + if (left_channel != &pin_PA04) { + mp_raise_ValueError(MP_ERROR_TEXT("AudioOut requires pin A0 (PA04)")); + } + + self->left_channel = left_channel; + self->quiescent_value = quiescent_value; + self->sample = MP_OBJ_NULL; + self->dma_buffer = NULL; + memset(&self->dma_handle, 0, sizeof(self->dma_handle)); + memset(&self->callback, 0, sizeof(self->callback)); + self->stopping = false; + self->paused = false; + + // Configure PA04 for analog (DAC) mode. + GPIO_InitTypeDef gpio_init = {0}; + gpio_init.Pin = pin_mask(left_channel->number); + gpio_init.Mode = GPIO_MODE_ANALOG; + gpio_init.Pull = GPIO_NOPULL; + HAL_GPIO_Init(pin_port(left_channel->port), &gpio_init); + + // Initialise the shared DAC handle if it hasn't been set up yet + // (i.e. AnalogOut hasn't been used since last reset). + if (handle.Instance == NULL || handle.State == HAL_DAC_STATE_RESET) { + __HAL_RCC_DAC_CLK_ENABLE(); + handle.Instance = DAC; + if (HAL_DAC_Init(&handle) != HAL_OK) { + mp_raise_ValueError(MP_ERROR_TEXT("DAC init error")); + } + } + + // Configure DAC channel 1 with TIM6_TRGO trigger (set at play() time; + // for now configure with no trigger so the ramp works correctly). + DAC_ChannelConfTypeDef ch_cfg = {0}; + ch_cfg.DAC_Trigger = DAC_TRIGGER_NONE; + ch_cfg.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; + if (HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_1) != HAL_OK) { + mp_raise_ValueError(MP_ERROR_TEXT("DAC channel config error")); + } + + // Ramp DAC output up to quiescent value to prevent an audible pop. + HAL_DAC_SetValue(&handle, DAC_CHANNEL_1, DAC_ALIGN_12B_R, 0); + HAL_DAC_Start(&handle, DAC_CHANNEL_1); + dac_ramp(0, quiescent_value >> 4); + + // Claim the pin last so any error above doesn't leave it claimed. + common_hal_mcu_pin_claim(left_channel); + #endif +} + +bool common_hal_audioio_audioout_deinited(audioio_audioout_obj_t *self) { + return self->left_channel == NULL; +} + +void common_hal_audioio_audioout_deinit(audioio_audioout_obj_t *self) { + if (common_hal_audioio_audioout_deinited(self)) { + return; + } + + common_hal_audioio_audioout_stop(self); + + // Ramp DAC back to zero before disconnecting. + dac_ramp(self->quiescent_value >> 4, 0); + HAL_DAC_Stop(&handle, DAC_CHANNEL_1); + + // Restore channel 1 to no-trigger mode so AnalogOut can use it again. + DAC_ChannelConfTypeDef ch_cfg = {0}; + ch_cfg.DAC_Trigger = DAC_TRIGGER_NONE; + ch_cfg.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; + HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_1); + + // Release the pin. Let AnalogOut manage DAC clock disable. + reset_pin_number(self->left_channel->port, self->left_channel->number); + self->left_channel = NULL; +} + +void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, + mp_obj_t sample, bool loop) { + if (common_hal_audioio_audioout_deinited(self)) { + mp_raise_ValueError(MP_ERROR_TEXT("AudioOut is deinited")); + } + common_hal_audioio_audioout_stop(self); + + // Extract sample format metadata. + audiosample_base_t *base = audiosample_check(sample); + self->bytes_per_sample = base->bits_per_sample / 8; + self->samples_signed = base->samples_signed; + self->channel_count = base->channel_count; + uint32_t sample_rate = base->sample_rate; + if (sample_rate == 0) { + mp_raise_ValueError(MP_ERROR_TEXT("sample_rate must be > 0")); + } + + self->sample = sample; + self->loop = loop; + self->stopping = false; + self->paused = false; + + // Allocate the DMA circular buffer. + self->dma_buffer = (uint16_t *)m_malloc( + AUDIOOUT_DMA_BUFFER_SAMPLES * sizeof(uint16_t)); + if (!self->dma_buffer) { + mp_raise_msg(&mp_type_MemoryError, + MP_ERROR_TEXT("insufficient memory for audio buffer")); + } + + // Pre-fill both halves before starting DMA. + audiosample_reset_buffer(sample, false, 0); + load_dma_buffer_half(self, 0); + load_dma_buffer_half(self, 1); + + // --- TIM6 setup --- + // TIM6 is a basic timer on APB1. It is not managed by the common timer + // infrastructure (stm_peripherals_find_timer etc.) because it has no GPIO + // pins and is reserved for DAC use (mcu_tim_banks[5] == NULL). + __HAL_RCC_TIM6_CLK_ENABLE(); + + uint32_t tim6_clk = get_tim6_freq(); + uint32_t period = (tim6_clk / sample_rate); + if (period < 2) { + period = 2; + } + period -= 1; + + tim6_handle.Instance = TIM6; + tim6_handle.Init.Prescaler = 0; + tim6_handle.Init.CounterMode = TIM_COUNTERMODE_UP; + tim6_handle.Init.Period = period; + tim6_handle.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; + tim6_handle.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; + HAL_TIM_Base_Init(&tim6_handle); + + // TRGO = Update event → triggers DAC conversion each period. + TIM_MasterConfigTypeDef master_cfg = {0}; + master_cfg.MasterOutputTrigger = TIM_TRGO_UPDATE; + master_cfg.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; + HAL_TIMEx_MasterConfigSynchronization(&tim6_handle, &master_cfg); + + // --- DAC channel reconfiguration for DMA-triggered mode --- + DAC_ChannelConfTypeDef ch_cfg = {0}; + ch_cfg.DAC_Trigger = DAC_TRIGGER_T6_TRGO; + ch_cfg.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; + HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_1); + + // --- DMA1 Stream5 Channel7 setup --- + __HAL_RCC_DMA1_CLK_ENABLE(); + + DMA_HandleTypeDef *hdma = &self->dma_handle; + memset(hdma, 0, sizeof(*hdma)); + hdma->Instance = DMA1_Stream5; + hdma->Init.Channel = DMA_CHANNEL_7; + hdma->Init.Direction = DMA_MEMORY_TO_PERIPH; + hdma->Init.PeriphInc = DMA_PINC_DISABLE; + hdma->Init.MemInc = DMA_MINC_ENABLE; + hdma->Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; + hdma->Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; + hdma->Init.Mode = DMA_CIRCULAR; + hdma->Init.Priority = DMA_PRIORITY_HIGH; + hdma->Init.FIFOMode = DMA_FIFOMODE_DISABLE; + HAL_DMA_Init(hdma); + + // Link DMA stream to DAC channel 1. + __HAL_LINKDMA(&handle, DMA_Handle1, *hdma); + + HAL_NVIC_SetPriority(DMA1_Stream5_IRQn, 6, 0); + HAL_NVIC_EnableIRQ(DMA1_Stream5_IRQn); + + // --- Start DMA transfer then timer --- + active_audioout = self; + + if (HAL_DAC_Start_DMA(&handle, DAC_CHANNEL_1, + (uint32_t *)self->dma_buffer, + AUDIOOUT_DMA_BUFFER_SAMPLES, + DAC_ALIGN_12B_R) != HAL_OK) { + active_audioout = NULL; + HAL_NVIC_DisableIRQ(DMA1_Stream5_IRQn); + m_free(self->dma_buffer); + self->dma_buffer = NULL; + mp_raise_RuntimeError(MP_ERROR_TEXT("DAC DMA start failed")); + } + + HAL_TIM_Base_Start(&tim6_handle); +} + +void common_hal_audioio_audioout_stop(audioio_audioout_obj_t *self) { + if (active_audioout != self) { + return; + } + + // Stop the sample clock first so no more DMA requests are generated. + TIM6->CR1 &= ~TIM_CR1_CEN; + + // Stop DMA and DAC channel. + HAL_DAC_Stop_DMA(&handle, DAC_CHANNEL_1); + HAL_NVIC_DisableIRQ(DMA1_Stream5_IRQn); + + // Free the DMA buffer. + if (self->dma_buffer) { + m_free(self->dma_buffer); + self->dma_buffer = NULL; + } + + // Restore quiescent output (channel is still active from construct()). + DAC_ChannelConfTypeDef ch_cfg = {0}; + ch_cfg.DAC_Trigger = DAC_TRIGGER_NONE; + ch_cfg.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; + HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_1); + HAL_DAC_SetValue(&handle, DAC_CHANNEL_1, DAC_ALIGN_12B_R, + self->quiescent_value >> 4); + HAL_DAC_Start(&handle, DAC_CHANNEL_1); + + self->sample = MP_OBJ_NULL; + self->stopping = false; + self->paused = false; + active_audioout = NULL; + + __HAL_RCC_TIM6_CLK_DISABLE(); +} + +bool common_hal_audioio_audioout_get_playing(audioio_audioout_obj_t *self) { + return active_audioout == self; +} + +void common_hal_audioio_audioout_pause(audioio_audioout_obj_t *self) { + if (active_audioout != self) { + return; + } + self->paused = true; + // Pause the sample clock; DMA remains armed but no new triggers fire. + TIM6->CR1 &= ~TIM_CR1_CEN; +} + +void common_hal_audioio_audioout_resume(audioio_audioout_obj_t *self) { + if (active_audioout != self || !self->paused) { + return; + } + self->paused = false; + // Restart the sample clock. + TIM6->CR1 |= TIM_CR1_CEN; +} + +bool common_hal_audioio_audioout_get_paused(audioio_audioout_obj_t *self) { + return self->paused; +} + +// --------------------------------------------------------------------------- +// Reset hook +// --------------------------------------------------------------------------- + +void audioout_reset(void) { + if (active_audioout != NULL) { + // Emergency stop: halt timer and DMA without ramping. + TIM6->CR1 &= ~TIM_CR1_CEN; + HAL_DAC_Stop_DMA(&handle, DAC_CHANNEL_1); + HAL_NVIC_DisableIRQ(DMA1_Stream5_IRQn); + if (active_audioout->dma_buffer) { + m_free(active_audioout->dma_buffer); + active_audioout->dma_buffer = NULL; + } + active_audioout->sample = MP_OBJ_NULL; + active_audioout->stopping = false; + active_audioout->paused = false; + active_audioout->left_channel = NULL; // mark deinited + active_audioout = NULL; + } + __HAL_RCC_TIM6_CLK_DISABLE(); +} diff --git a/ports/stm/common-hal/audioio/AudioOut.h b/ports/stm/common-hal/audioio/AudioOut.h new file mode 100644 index 0000000000000..4b1cc388b853f --- /dev/null +++ b/ports/stm/common-hal/audioio/AudioOut.h @@ -0,0 +1,57 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Adafruit Industries LLC +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "py/obj.h" +#include "common-hal/microcontroller/Pin.h" +#include "supervisor/background_callback.h" +#include STM32_HAL_H + +// Total DMA circular buffer size in 16-bit samples. +// Split into two halves; one half plays while the other is refilled. +// 512 samples at 44100 Hz = ~5.8 ms per half-buffer interrupt. +#define AUDIOOUT_DMA_BUFFER_SAMPLES 512 +#define AUDIOOUT_DMA_HALF_SAMPLES 256 + +typedef struct { + mp_obj_base_t base; + + // Left channel pin (PA04 = DAC_CH1). NULL when deinited. + const mcu_pin_obj_t *left_channel; + // right_channel (PA05 = DAC_CH2) deferred to a future implementation. + + // DMA handle for DMA1 Stream5 Channel7 (DAC CH1). + // The DAC handle is the shared file-scope handle from AnalogOut.c. + DMA_HandleTypeDef dma_handle; + + // Circular DMA buffer: AUDIOOUT_DMA_BUFFER_SAMPLES uint16_t elements, + // allocated on play() and freed on stop(). + uint16_t *dma_buffer; + + // Current audio sample object being played. + mp_obj_t sample; + bool loop; + + // Set from ISR context to request a clean stop via background callback. + volatile bool stopping; + bool paused; + + // Sample format metadata, populated at play() time. + uint8_t bytes_per_sample; // 1 (8-bit) or 2 (16-bit) + bool samples_signed; + uint8_t channel_count; // 1 = mono, 2 = stereo (only L channel output) + uint16_t quiescent_value; // 16-bit resting value (default 0x8000) + + // Background callback queued from DMA ISR, processed in main loop. + background_callback_t callback; + + // Which half of dma_buffer to refill next: 0 = lower, 1 = upper. + volatile uint8_t buffer_half_to_fill; +} audioio_audioout_obj_t; + +// Called from reset_port() to stop any active playback on soft-reset. +void audioout_reset(void); diff --git a/ports/stm/common-hal/audioio/__init__.c b/ports/stm/common-hal/audioio/__init__.c new file mode 100644 index 0000000000000..ebf626832f68b --- /dev/null +++ b/ports/stm/common-hal/audioio/__init__.c @@ -0,0 +1,7 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Adafruit Industries LLC +// +// SPDX-License-Identifier: MIT + +// No audioio module functions. diff --git a/ports/stm/mpconfigport.mk b/ports/stm/mpconfigport.mk index 96724a5296090..9fd8cbfb2d788 100644 --- a/ports/stm/mpconfigport.mk +++ b/ports/stm/mpconfigport.mk @@ -3,6 +3,7 @@ INTERNAL_LIBM ?= 1 ifeq ($(MCU_VARIANT),$(filter $(MCU_VARIANT),STM32F405xx STM32F407xx)) CIRCUITPY_ALARM = 1 + CIRCUITPY_AUDIOIO = 1 CIRCUITPY_CANIO = 1 CIRCUITPY_FRAMEBUFFERIO ?= 1 CIRCUITPY_SDIOIO ?= 1 @@ -19,8 +20,8 @@ ifeq ($(MCU_VARIANT),STM32F407xx) endif ifeq ($(MCU_SERIES),F4) - # Audio via PWM - CIRCUITPY_AUDIOIO = 0 + # Audio via PWM (F405/F407 also supports DAC-based audioio; set above) + CIRCUITPY_AUDIOIO ?= 0 CIRCUITPY_AUDIOCORE ?= 1 CIRCUITPY_AUDIOPWMIO ?= 1 diff --git a/ports/stm/supervisor/port.c b/ports/stm/supervisor/port.c index 1ffc8656a888e..1da862cd88643 100644 --- a/ports/stm/supervisor/port.c +++ b/ports/stm/supervisor/port.c @@ -32,6 +32,9 @@ #if CIRCUITPY_RTC #include "shared-bindings/rtc/__init__.h" #endif +#if CIRCUITPY_AUDIOIO +#include "common-hal/audioio/AudioOut.h" +#endif #include "peripherals/clocks.h" #include "peripherals/gpio.h" @@ -315,6 +318,9 @@ void reset_port(void) { #if CIRCUITPY_ALARM exti_reset(); #endif + #if CIRCUITPY_AUDIOIO + audioout_reset(); + #endif } void reset_to_bootloader(void) { From 3f5fa6a07b0a797da86a27d03efa3c7de1e2b26c Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sun, 5 Apr 2026 04:13:48 -0700 Subject: [PATCH 02/17] tests: add audioio manual test suite for STM32F405/F407 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/circuitpython-manual/audioio/README.md | 255 ++++++++++++++++ tests/circuitpython-manual/audioio/TESTING.md | 255 ++++++++++++++++ .../audioio/run_serial_tests.py | 280 ++++++++++++++++++ .../audioio/single_buffer_loop.py | 58 ++++ .../audioio/wavefile_pause_resume.py | 53 ++++ .../audioio/wavefile_playback.py | 45 +++ 6 files changed, 946 insertions(+) create mode 100644 tests/circuitpython-manual/audioio/README.md create mode 100644 tests/circuitpython-manual/audioio/TESTING.md create mode 100644 tests/circuitpython-manual/audioio/run_serial_tests.py create mode 100644 tests/circuitpython-manual/audioio/single_buffer_loop.py create mode 100644 tests/circuitpython-manual/audioio/wavefile_pause_resume.py create mode 100644 tests/circuitpython-manual/audioio/wavefile_playback.py diff --git a/tests/circuitpython-manual/audioio/README.md b/tests/circuitpython-manual/audioio/README.md new file mode 100644 index 0000000000000..596baebbe90f5 --- /dev/null +++ b/tests/circuitpython-manual/audioio/README.md @@ -0,0 +1,255 @@ +# Testing: STM32F405 / STM32F407 DAC AudioOut + +These tests exercise the DAC-based `audioio.AudioOut` implementation added for STM32F405xx and STM32F407xx. + +## Automated vs Manual Tests + +| Test | Automated | Requires audio/scope | +|------|-----------|----------------------| +| 1 — WAV File Playback | Yes | Yes (audio check) | +| 2 — Pause / Resume | Yes | Yes (audio check) | +| 3 — Looping Sine Wave | Yes | Yes (audio check) | +| 4 — deinit and Re-init | Yes | No | +| 5 — Soft Reset Cleanup | No (manual Ctrl-C/D) | No | + +`run_serial_tests.py` automates Tests 1, 2, 3, and 4: it copies the necessary +files to the board and runs each script over the serial REPL, comparing the +printed output to the expected patterns. You still need to listen to the audio +(and optionally use an oscilloscope) for the audio-quality checks. + +### Quick start + +```bash +# Install the one dependency (if not already present) +pip install mpremote + +# Run all automated tests (board must be connected and CIRCUITPY mounted) +python3 tests/circuitpython-manual/audioio/run_serial_tests.py + +# Skip file copy if files are already on the board +python3 tests/circuitpython-manual/audioio/run_serial_tests.py --no-copy + +# Override the serial port or CIRCUITPY path +python3 tests/circuitpython-manual/audioio/run_serial_tests.py \ + --port /dev/cu.usbmodem1234 \ + --circuitpy /Volumes/CIRCUITPY + +# Run only specific tests +python3 tests/circuitpython-manual/audioio/run_serial_tests.py --tests 3,4 +``` + +The script exits 0 if all selected tests pass, 1 otherwise — suitable for CI. + +--- + +## Hardware Required + +- An STM32F405 or STM32F407 board running the freshly-built firmware (e.g. Feather STM32F405 Express). +- A passive speaker or audio amplifier connected to **pin A0 (PA04, DAC channel 1)** and GND. + - A simple test: 100 Ω resistor in series with a small speaker between A0 and GND. + - For better audio: connect A0 to a small amp module (e.g. PAM8403) then to a speaker. +- Optional: an oscilloscope or logic analyser probe on **D4** (used as a trigger output by the test scripts). +- A USB cable for REPL/filesystem access (CircuitPython storage must not be read-only). + +## Build + +Enable the feature by building for an F405 or F407 target: + +``` +make -C ports/stm BOARD=feather_stm32f405_express -j CROSS_COMPILE=~/arm-toolchain/arm-gnu-toolchain-14.3.rel1-darwin-arm64-arm-none-eabi/bin/arm-none-eabi- PYTHON=/opt/homebrew/bin/python3 +``` + +`CIRCUITPY_AUDIOIO` is now set to `1` automatically for those variants. Verify it is present in the build: + +``` +grep CIRCUITPY_AUDIOIO ports/stm/build-feather_stm32f405_express/mpconfigport.mk +# Expected output: CIRCUITPY_AUDIOIO = 1 +``` + +Flash the resulting `.bin` to the board using your preferred method (e.g. `dfu-util`). + +## File Setup + +> **If using `run_serial_tests.py`** this step is done automatically — skip ahead. + +Copy the WAV test samples onto the board's `CIRCUITPY` drive root: + +``` +cp \ + tests/circuitpython-manual/audiocore/jeplayer-splash-8000-8bit-mono-unsigned.wav \ + tests/circuitpython-manual/audiocore/jeplayer-splash-8000-16bit-mono-signed.wav \ + tests/circuitpython-manual/audiocore/jeplayer-splash-44100-16bit-mono-signed.wav \ + /Volumes/CIRCUITPY/ +``` + +These three files (~447 KB total) cover every exercised code path: +- `8000-8bit-mono-unsigned` — 8-bit unsigned decode path, audibly lo-fi +- `8000-16bit-mono-signed` — 16-bit signed decode path at lowest sample rate +- `44100-16bit-mono-signed` — 16-bit at highest sample rate (DMA reconfiguration, audible quality difference) + +Stereo, 24-bit, and the 16 kHz files are omitted: stereo and 24-bit will `OSError`; 16 kHz adds no new code paths over 8 kHz and 44.1 kHz. + +Copy the three test scripts to the board as well (or paste them into the REPL): + +``` +cp tests/circuitpython-manual/audioio/wavefile_playback.py /Volumes/CIRCUITPY/ +cp tests/circuitpython-manual/audioio/wavefile_pause_resume.py /Volumes/CIRCUITPY/ +cp tests/circuitpython-manual/audioio/single_buffer_loop.py /Volumes/CIRCUITPY/ +``` + +## Test 1 — WAV File Playback (`wavefile_playback.py`) *(automated)* + +Verifies that `AudioOut` can play WAV files at 8 kHz, 16 kHz, and 44.1 kHz in mono and stereo (only left channel output), with 8-bit unsigned and 16-bit signed encodings. + +**Note:** 24-bit WAV files and the stereo MP3 are intentionally excluded — `audiocore.WaveFile` does not support 24-bit, and those files will print an `OSError`. That is expected. + +**Run from the REPL:** + +```python +import os +os.chdir("/") +exec(open("wavefile_playback.py").read()) +``` + +**Expected output (order may vary by filename sort):** + +``` +playing jeplayer-splash-44100-16bit-mono-signed.wav +playing jeplayer-splash-8000-16bit-mono-signed.wav +playing jeplayer-splash-8000-8bit-mono-unsigned.wav +done +``` + +**What to listen for:** + +- Each supported WAV plays the "jeplayer splash" jingle to completion before the next starts. +- No loud pops at the start or end of each file (the DAC ramp-in / ramp-out should suppress them). +- Audio pitch should match the sample rate: the 44100 Hz file sounds the most natural; the 8000 Hz file sounds lower fidelity. + +## Test 2 — Pause and Resume (`wavefile_pause_resume.py`) *(automated)* + +Verifies `AudioOut.pause()` / `AudioOut.resume()` by toggling every 100 ms during playback. The audio will sound choppy — that is intentional. + +**Run from the REPL:** + +```python +exec(open("wavefile_pause_resume.py").read()) +``` + +**Expected output (repeating for each WAV):** + +``` +playing with pause/resume: jeplayer-splash-44100-16bit-mono-signed.wav + paused + resumed + ... +playing with pause/resume: jeplayer-splash-8000-16bit-mono-signed.wav + paused + resumed + ... +playing with pause/resume: jeplayer-splash-8000-8bit-mono-unsigned.wav + paused + resumed + ... +done +``` + +**What to verify:** + +- The REPL prints alternating "paused" / "resumed" lines. +- Audio cuts in and out in sync with the prints. +- Playback eventually completes (the `while dac.playing` loop exits normally). +- No hard fault or hang. + +## Test 3 — Looping Sine Wave (`single_buffer_loop.py`) *(automated)* + +Verifies `RawSample` with `loop=True` and tests all four sample formats: +`unsigned 8-bit`, `signed 8-bit`, `unsigned 16-bit`, `signed 16-bit`. + +Each sample generates one cycle of a 440 Hz sine wave and loops for 1 second. + +**Run from the REPL:** + +```python +exec(open("single_buffer_loop.py").read()) +``` + +**Expected output:** + +``` +unsigned 8 bit + +signed 8 bit + +unsigned 16 bit + +signed 16 bit + +done +``` + +**What to listen for:** + +- A 440 Hz tone (concert A) for approximately 1 second for each format. +- All four formats should sound essentially identical in pitch and volume. +- No pops or glitches during the loop. +- Clean silence between tones (quiescent DAC value holds between `stop()` calls). + +## Test 4 — `deinit` and Re-init *(automated)* + +Verifies that `AudioOut` can be deconstructed and reconstructed without rebooting, and that pin A0 is properly released. + +```python +import audioio, analogio, board + +# Construct and immediately deinit AudioOut +dac = audioio.AudioOut(board.A0) +dac.deinit() + +# PA04 should now be free for AnalogOut +aout = analogio.AnalogOut(board.A0) +aout.value = 32768 # mid-scale +aout.deinit() + +# Re-create AudioOut on the same pin +dac2 = audioio.AudioOut(board.A0) +dac2.deinit() +print("pass") +``` + +**Expected output:** `pass` with no exceptions. + +## Test 5 — Soft Reset Cleanup *(manual)* + +Verifies that `audioout_reset()` properly cleans up when the REPL soft-resets during active playback. + +1. Start a looping tone in the REPL: + +```python +import audiocore, audioio, board, array, math, time +length = 8000 // 440 +s16 = array.array("h", [int(math.sin(math.pi * 2 * i / length) * 32767) for i in range(length)]) +dac = audioio.AudioOut(board.A0) +dac.play(audiocore.RawSample(s16, sample_rate=8000), loop=True) +``` + +2. While the tone is playing, press **Ctrl-C** then **Ctrl-D** (soft reset). + +**Expected:** +- **Ctrl-C** interrupts the Python code but the tone *keeps playing* — this is normal. The DMA and TIM6 run in hardware independently of Python; a `KeyboardInterrupt` does not stop them. +- **Ctrl-D** triggers a soft reset which calls `audioout_reset()`. The tone stops immediately and the board returns to the `>>>` prompt with no crash or fault. +- Running any of the above tests again afterwards should work normally. + +## Oscilloscope Checks (Optional) + +Each test script drives `board.D4` (pin D4) low at the start of each playback and high when it ends. This provides a clean trigger edge for a scope. + +- **Test 1:** Probe A0 — should show a sampled waveform at the correct sample rate. Probe D4 for a gate signal that spans the file duration. +- **Test 3:** Probe A0 — should show a 440 Hz staircase-sine at the DAC output (12-bit steps visible at 44.1 kHz; fewer at 8 kHz). A simple RC low-pass filter (1 kΩ + 100 nF) on the A0 output will smooth the staircase significantly. + +## Known Limitations + +- **Right channel / stereo output** is not implemented. Passing `right_channel` to `AudioOut()` raises `ValueError: Stereo not supported on this board`. +- **Only pin A0 (PA04)** is supported as the left channel. Any other pin raises `ValueError: AudioOut requires pin A0 (PA04)`. +- **24-bit WAV files** are not supported by `audiocore.WaveFile` and will raise `OSError` when opened. +- Only one `AudioOut` instance can be active at a time (single DAC channel). diff --git a/tests/circuitpython-manual/audioio/TESTING.md b/tests/circuitpython-manual/audioio/TESTING.md new file mode 100644 index 0000000000000..d8362a31e9fbf --- /dev/null +++ b/tests/circuitpython-manual/audioio/TESTING.md @@ -0,0 +1,255 @@ +# Testing: STM32F405 / STM32F407 DAC AudioOut + +These tests exercise the DAC-based `audioio.AudioOut` implementation added for STM32F405xx and STM32F407xx. + +## Automated vs Manual Tests + +| Test | Automated | Requires audio/scope | +|------|-----------|----------------------| +| 1 — WAV File Playback | Yes | Yes (audio check) | +| 2 — Pause / Resume | Yes | Yes (audio check) | +| 3 — Looping Sine Wave | Yes | Yes (audio check) | +| 4 — Soft Reset Cleanup | No (manual Ctrl-C/D) | No | +| 5 — deinit and Re-init | Yes | No | + +`run_serial_tests.py` automates Tests 1, 2, 3, and 5: it copies the necessary +files to the board and runs each script over the serial REPL, comparing the +printed output to the expected patterns. You still need to listen to the audio +(and optionally use an oscilloscope) for the audio-quality checks. + +### Quick start + +```bash +# Install the one dependency (if not already present) +pip install pyserial + +# Run all automated tests (board must be connected and CIRCUITPY mounted) +python3 tests/circuitpython-manual/audioio/run_serial_tests.py + +# Skip file copy if files are already on the board +python3 tests/circuitpython-manual/audioio/run_serial_tests.py --no-copy + +# Override the serial port or CIRCUITPY path +python3 tests/circuitpython-manual/audioio/run_serial_tests.py \ + --port /dev/cu.usbmodem1234 \ + --circuitpy /Volumes/CIRCUITPY + +# Run only specific tests +python3 tests/circuitpython-manual/audioio/run_serial_tests.py --tests 3,5 +``` + +The script exits 0 if all selected tests pass, 1 otherwise — suitable for CI. + +--- + +## Hardware Required + +- An STM32F405 or STM32F407 board running the freshly-built firmware (e.g. Feather STM32F405 Express). +- A passive speaker or audio amplifier connected to **pin A0 (PA04, DAC channel 1)** and GND. + - A simple test: 100 Ω resistor in series with a small speaker between A0 and GND. + - For better audio: connect A0 to a small amp module (e.g. PAM8403) then to a speaker. +- Optional: an oscilloscope or logic analyser probe on **D4** (used as a trigger output by the test scripts). +- A USB cable for REPL/filesystem access (CircuitPython storage must not be read-only). + +## Build + +Enable the feature by building for an F405 or F407 target: + +``` +make -C ports/stm BOARD=feather_stm32f405_express -j CROSS_COMPILE=~/arm-toolchain/arm-gnu-toolchain-14.3.rel1-darwin-arm64-arm-none-eabi/bin/arm-none-eabi- +``` + +`CIRCUITPY_AUDIOIO` is now set to `1` automatically for those variants. Verify it is present in the build: + +``` +grep CIRCUITPY_AUDIOIO ports/stm/build-feather_stm32f405_express/mpconfigport.mk +# Expected output: CIRCUITPY_AUDIOIO = 1 +``` + +Flash the resulting `.bin` to the board using your preferred method (e.g. `dfu-util`). + +## File Setup + +> **If using `run_serial_tests.py`** this step is done automatically — skip ahead. + +Copy the WAV test samples onto the board's `CIRCUITPY` drive root: + +``` +cp \ + tests/circuitpython-manual/audiocore/jeplayer-splash-8000-8bit-mono-unsigned.wav \ + tests/circuitpython-manual/audiocore/jeplayer-splash-8000-16bit-mono-signed.wav \ + tests/circuitpython-manual/audiocore/jeplayer-splash-44100-16bit-mono-signed.wav \ + /Volumes/CIRCUITPY/ +``` + +These three files (~447 KB total) cover every exercised code path: +- `8000-8bit-mono-unsigned` — 8-bit unsigned decode path, audibly lo-fi +- `8000-16bit-mono-signed` — 16-bit signed decode path at lowest sample rate +- `44100-16bit-mono-signed` — 16-bit at highest sample rate (DMA reconfiguration, audible quality difference) + +Stereo, 24-bit, and the 16 kHz files are omitted: stereo and 24-bit will `OSError`; 16 kHz adds no new code paths over 8 kHz and 44.1 kHz. + +Copy the three test scripts to the board as well (or paste them into the REPL): + +``` +cp tests/circuitpython-manual/audioio/wavefile_playback.py /Volumes/CIRCUITPY/ +cp tests/circuitpython-manual/audioio/wavefile_pause_resume.py /Volumes/CIRCUITPY/ +cp tests/circuitpython-manual/audioio/single_buffer_loop.py /Volumes/CIRCUITPY/ +``` + +## Test 1 — WAV File Playback (`wavefile_playback.py`) *(automated)* + +Verifies that `AudioOut` can play WAV files at 8 kHz, 16 kHz, and 44.1 kHz in mono and stereo (only left channel output), with 8-bit unsigned and 16-bit signed encodings. + +**Note:** 24-bit WAV files and the stereo MP3 are intentionally excluded — `audiocore.WaveFile` does not support 24-bit, and those files will print an `OSError`. That is expected. + +**Run from the REPL:** + +```python +import os +os.chdir("/") +exec(open("wavefile_playback.py").read()) +``` + +**Expected output (order may vary by filename sort):** + +``` +playing jeplayer-splash-44100-16bit-mono-signed.wav +playing jeplayer-splash-8000-16bit-mono-signed.wav +playing jeplayer-splash-8000-8bit-mono-unsigned.wav +done +``` + +**What to listen for:** + +- Each supported WAV plays the "jeplayer splash" jingle to completion before the next starts. +- No loud pops at the start or end of each file (the DAC ramp-in / ramp-out should suppress them). +- Audio pitch should match the sample rate: the 44100 Hz file sounds the most natural; the 8000 Hz file sounds lower fidelity. + +## Test 2 — Pause and Resume (`wavefile_pause_resume.py`) *(automated)* + +Verifies `AudioOut.pause()` / `AudioOut.resume()` by toggling every 100 ms during playback. The audio will sound choppy — that is intentional. + +**Run from the REPL:** + +```python +exec(open("wavefile_pause_resume.py").read()) +``` + +**Expected output (repeating for each WAV):** + +``` +playing with pause/resume: jeplayer-splash-44100-16bit-mono-signed.wav + paused + resumed + ... +playing with pause/resume: jeplayer-splash-8000-16bit-mono-signed.wav + paused + resumed + ... +playing with pause/resume: jeplayer-splash-8000-8bit-mono-unsigned.wav + paused + resumed + ... +done +``` + +**What to verify:** + +- The REPL prints alternating "paused" / "resumed" lines. +- Audio cuts in and out in sync with the prints. +- Playback eventually completes (the `while dac.playing` loop exits normally). +- No hard fault or hang. + +## Test 3 — Looping Sine Wave (`single_buffer_loop.py`) *(automated)* + +Verifies `RawSample` with `loop=True` and tests all four sample formats: +`unsigned 8-bit`, `signed 8-bit`, `unsigned 16-bit`, `signed 16-bit`. + +Each sample generates one cycle of a 440 Hz sine wave and loops for 1 second. + +**Run from the REPL:** + +```python +exec(open("single_buffer_loop.py").read()) +``` + +**Expected output:** + +``` +unsigned 8 bit + +signed 8 bit + +unsigned 16 bit + +signed 16 bit + +done +``` + +**What to listen for:** + +- A 440 Hz tone (concert A) for approximately 1 second for each format. +- All four formats should sound essentially identical in pitch and volume. +- No pops or glitches during the loop. +- Clean silence between tones (quiescent DAC value holds between `stop()` calls). + +## Test 4 — Soft Reset Cleanup *(manual)* + +Verifies that `audioout_reset()` properly cleans up when the REPL soft-resets during active playback. + +1. Start a looping tone in the REPL: + +```python +import audiocore, audioio, board, array, math, time +length = 8000 // 440 +s16 = array.array("h", [int(math.sin(math.pi * 2 * i / length) * 32767) for i in range(length)]) +dac = audioio.AudioOut(board.A0) +dac.play(audiocore.RawSample(s16, sample_rate=8000), loop=True) +``` + +2. While the tone is playing, press **Ctrl-C** then **Ctrl-D** (soft reset). + +**Expected:** +- **Ctrl-C** interrupts the Python code but the tone *keeps playing* — this is normal. The DMA and TIM6 run in hardware independently of Python; a `KeyboardInterrupt` does not stop them. +- **Ctrl-D** triggers a soft reset which calls `audioout_reset()`. The tone stops immediately and the board returns to the `>>>` prompt with no crash or fault. +- Running any of the above tests again afterwards should work normally. + +## Test 5 — `deinit` and Re-init *(automated)* + +Verifies that `AudioOut` can be deconstructed and reconstructed without rebooting, and that pin A0 is properly released. + +```python +import audioio, analogio, board + +# Construct and immediately deinit AudioOut +dac = audioio.AudioOut(board.A0) +dac.deinit() + +# PA04 should now be free for AnalogOut +aout = analogio.AnalogOut(board.A0) +aout.value = 32768 # mid-scale +aout.deinit() + +# Re-create AudioOut on the same pin +dac2 = audioio.AudioOut(board.A0) +dac2.deinit() +print("pass") +``` + +**Expected output:** `pass` with no exceptions. + +## Oscilloscope Checks (Optional) + +Each test script drives `board.D4` (pin D4) low at the start of each playback and high when it ends. This provides a clean trigger edge for a scope. + +- **Test 1:** Probe A0 — should show a sampled waveform at the correct sample rate. Probe D4 for a gate signal that spans the file duration. +- **Test 3:** Probe A0 — should show a 440 Hz staircase-sine at the DAC output (12-bit steps visible at 44.1 kHz; fewer at 8 kHz). + +## Known Limitations + +- **Right channel / stereo output** is not implemented. Passing `right_channel` to `AudioOut()` raises `ValueError: Stereo not supported on this board`. +- **Only pin A0 (PA04)** is supported as the left channel. Any other pin raises `ValueError: AudioOut requires pin A0 (PA04)`. +- **24-bit WAV files** are not supported by `audiocore.WaveFile` and will raise `OSError` when opened. +- Only one `AudioOut` instance can be active at a time (single DAC channel). diff --git a/tests/circuitpython-manual/audioio/run_serial_tests.py b/tests/circuitpython-manual/audioio/run_serial_tests.py new file mode 100644 index 0000000000000..577be071c625a --- /dev/null +++ b/tests/circuitpython-manual/audioio/run_serial_tests.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +run_serial_tests.py — Automated REPL-based tests for STM32F405 audioio. + +Automates Tests 1, 2, 3, and 4 from README.md by: + 1. Copying WAV files and test scripts to the board via mpremote. + 2. Running each test on the device via the CircuitPython REPL. + 3. Comparing captured output to expected patterns and reporting PASS/FAIL. + +Test 5 (soft-reset cleanup) still requires manual interaction. + +Usage: + python3 run_serial_tests.py + python3 run_serial_tests.py --port /dev/cu.usbmodemXXX + python3 run_serial_tests.py --no-copy --tests 3,4 + +Requirements: + pip install mpremote +""" + +import argparse +import os +import subprocess +import sys + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "../../..")) +AUDIOCORE_DIR = os.path.join(REPO_ROOT, "tests", "circuitpython-manual", "audiocore") + +WAV_FILES = [ + "jeplayer-splash-8000-8bit-mono-unsigned.wav", + "jeplayer-splash-8000-16bit-mono-signed.wav", + "jeplayer-splash-44100-16bit-mono-signed.wav", +] + +TEST_SCRIPTS = [ + "wavefile_playback.py", + "wavefile_pause_resume.py", + "single_buffer_loop.py", +] + +DEINIT_TEST_CODE = ( + "import audioio, analogio, board\n" + "dac = audioio.AudioOut(board.A0)\n" + "dac.deinit()\n" + "aout = analogio.AnalogOut(board.A0)\n" + "aout.value = 32768\n" + "aout.deinit()\n" + "dac2 = audioio.AudioOut(board.A0)\n" + "dac2.deinit()\n" + 'print("pass")\n' +) + +# --------------------------------------------------------------------------- +# mpremote helpers +# --------------------------------------------------------------------------- + +def _mpremote(args: list, timeout: float = 30.0): + """Run an mpremote command, return (stdout, stderr). Raises on timeout.""" + try: + result = subprocess.run( + ["mpremote"] + args, + capture_output=True, + text=True, + timeout=timeout, + ) + return result.stdout, result.stderr + except subprocess.TimeoutExpired: + raise TimeoutError(f"mpremote timed out after {timeout}s") + except FileNotFoundError: + sys.exit("mpremote not found. Run: pip install mpremote") + + +def find_port() -> str: + """Return the port of the first Adafruit device found by mpremote devs.""" + stdout, _ = _mpremote(["devs"]) + for line in stdout.splitlines(): + parts = line.split() + if not parts: + continue + # mpremote devs output: + # Filter for Adafruit VID (239a) + if any("239a" in p for p in parts): + return parts[0] + # Fall back to any USB serial port that isn't Bluetooth/wlan + for line in stdout.splitlines(): + parts = line.split() + if parts and parts[0].startswith("/dev/") and "Bluetooth" not in line and "wlan" not in line: + return parts[0] + raise RuntimeError( + "No board detected. Connect the board and/or pass --port.\n" + f"mpremote devs output:\n{stdout}" + ) + + +def copy_files(port: str): + """Copy WAV samples and test scripts to the board.""" + print("Copying files to board ...") + files = ( + [(os.path.join(AUDIOCORE_DIR, w), w) for w in WAV_FILES] + + [(os.path.join(SCRIPT_DIR, s), s) for s in TEST_SCRIPTS] + ) + missing = [] + for src, dst in files: + if not os.path.exists(src): + missing.append(src) + continue + stdout, stderr = _mpremote( + ["connect", port, "fs", "cp", src, f":{dst}"], timeout=30 + ) + # mpremote prints "Up to date: " if unchanged, "cp ..." otherwise + status = "up to date" if "Up to date" in stdout else "copied" + print(f" {dst} ({status})") + if missing: + print("WARNING: source files not found:") + for m in missing: + print(f" {m}") + print() + + +# --------------------------------------------------------------------------- +# Test runner +# --------------------------------------------------------------------------- + +PASS_TAG = "PASS" +FAIL_TAG = "FAIL" + + +def _check(condition: bool, message: str) -> bool: + print(f" [{PASS_TAG if condition else FAIL_TAG}] {message}") + return condition + + +def _run_exec(port: str, code: str, label: str, timeout: float): + """Execute *code* on the device via mpremote exec and print the output.""" + print(f"\n{'=' * 60}") + print(f" {label}") + print("=" * 60) + try: + stdout, stderr = _mpremote(["connect", port, "exec", code], timeout=timeout) + except TimeoutError as exc: + print(f" [FAIL] {exc}") + return False, "", "" + print("Output:") + for line in stdout.splitlines(): + print(f" {line}") + if stderr: + print("Stderr:") + for line in stderr.splitlines(): + print(f" {line}") + return True, stdout, stderr + + +# --------------------------------------------------------------------------- +# Individual tests +# --------------------------------------------------------------------------- + +def test1_wavefile_playback(port: str) -> bool: + code = 'import os; os.chdir("/")\nexec(open("wavefile_playback.py").read())' + ok, stdout, stderr = _run_exec( + port, code, "Test 1 — WAV File Playback (wavefile_playback.py)", timeout=180 + ) + if not ok: + return False + passed = True + passed &= _check("playing jeplayer-splash-44100-16bit-mono-signed.wav" in stdout, "44100 Hz 16-bit mono WAV played") + passed &= _check("playing jeplayer-splash-8000-16bit-mono-signed.wav" in stdout, "8000 Hz 16-bit mono WAV played") + passed &= _check("playing jeplayer-splash-8000-8bit-mono-unsigned.wav" in stdout, "8000 Hz 8-bit unsigned WAV played") + passed &= _check("done" in stdout, "Script completed with 'done'") + passed &= _check(not stderr, f"No exceptions (stderr={stderr!r})") + return passed + + +def test2_pause_resume(port: str) -> bool: + code = 'exec(open("wavefile_pause_resume.py").read())' + ok, stdout, stderr = _run_exec( + port, code, "Test 2 — Pause / Resume (wavefile_pause_resume.py)", timeout=180 + ) + if not ok: + return False + passed = True + for wav in sorted(WAV_FILES): + passed &= _check(f"playing with pause/resume: {wav}" in stdout, f"pause/resume header for {wav}") + passed &= _check("paused" in stdout, "At least one 'paused' line printed") + passed &= _check("resumed" in stdout, "At least one 'resumed' line printed") + passed &= _check("done" in stdout, "Script completed with 'done'") + passed &= _check(not stderr, f"No exceptions (stderr={stderr!r})") + return passed + + +def test3_single_buffer_loop(port: str) -> bool: + code = 'exec(open("single_buffer_loop.py").read())' + ok, stdout, stderr = _run_exec( + port, code, "Test 3 — Looping Sine Wave (single_buffer_loop.py)", timeout=30 + ) + if not ok: + return False + passed = True + for label in ("unsigned 8 bit", "signed 8 bit", "unsigned 16 bit", "signed 16 bit"): + passed &= _check(label in stdout, f"'{label}' label printed") + passed &= _check("done" in stdout, "Script completed with 'done'") + passed &= _check(not stderr, f"No exceptions (stderr={stderr!r})") + return passed + + +def test4_deinit(port: str) -> bool: + ok, stdout, stderr = _run_exec( + port, DEINIT_TEST_CODE, "Test 4 — deinit and Re-init (inline)", timeout=10 + ) + if not ok: + return False + passed = True + passed &= _check("pass" in stdout, "Script printed 'pass'") + passed &= _check(not stderr, f"No exceptions (stderr={stderr!r})") + return passed + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--port", help="Serial port (auto-detected if omitted)") + parser.add_argument( + "--no-copy", + action="store_true", + help="Skip copying files to the board (assume they are already present)", + ) + parser.add_argument( + "--tests", + default="1,2,3,4", + help="Comma-separated list of test numbers to run (default: 1,2,3,4)", + ) + args = parser.parse_args() + + selected = set(args.tests.split(",")) + + port = args.port or find_port() + print(f"Using port: {port}\n") + + if not args.no_copy: + copy_files(port) + + results: dict[str, bool] = {} + if "1" in selected: + results["Test 1 — WAV Playback"] = test1_wavefile_playback(port) + if "2" in selected: + results["Test 2 — Pause/Resume"] = test2_pause_resume(port) + if "3" in selected: + results["Test 3 — Looping Sine"] = test3_single_buffer_loop(port) + if "4" in selected: + results["Test 4 — deinit/Re-init"] = test4_deinit(port) + + print(f"\n{'=' * 60}") + print("SUMMARY") + print("=" * 60) + all_passed = True + for name, passed in results.items(): + print(f" [{'PASS' if passed else 'FAIL'}] {name}") + all_passed = all_passed and passed + + print() + if all_passed: + print("All automated tests passed.") + print("Remaining manual step: Test 5 (soft-reset) and audio/oscilloscope verification.") + sys.exit(0) + else: + print("One or more tests FAILED — see details above.") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/circuitpython-manual/audioio/single_buffer_loop.py b/tests/circuitpython-manual/audioio/single_buffer_loop.py new file mode 100644 index 0000000000000..bce6a7a2cf300 --- /dev/null +++ b/tests/circuitpython-manual/audioio/single_buffer_loop.py @@ -0,0 +1,58 @@ +import audiocore +import audioio +import board +import digitalio +import array +import time +import math + +# Optional trigger pin for oscilloscope synchronisation. +try: + trigger = digitalio.DigitalInOut(board.D4) + trigger.switch_to_output(True) +except AttributeError: + trigger = None + +# Generate one period of a 440 Hz sine wave at each sample rate. +sample_rate = 8000 +length = sample_rate // 440 # samples per cycle + +sample_names = ["unsigned 8 bit", "signed 8 bit", "unsigned 16 bit", "signed 16 bit"] + +# unsigned 8 bit +u8 = array.array("B", [0] * length) +for i in range(length): + u8[i] = int(math.sin(math.pi * 2 * i / length) * 127 + 128) +samples = [audiocore.RawSample(u8, sample_rate=8000)] + +# signed 8 bit +s8 = array.array("b", [0] * length) +for i in range(length): + s8[i] = int(math.sin(math.pi * 2 * i / length) * 127) +samples.append(audiocore.RawSample(s8, sample_rate=16000)) + +# unsigned 16 bit +u16 = array.array("H", [0] * length) +for i in range(length): + u16[i] = int(math.sin(math.pi * 2 * i / length) * 32767 + 32768) +samples.append(audiocore.RawSample(u16, sample_rate=8000)) + +# signed 16 bit +s16 = array.array("h", [0] * length) +for i in range(length): + s16[i] = int(math.sin(math.pi * 2 * i / length) * 32767) +samples.append(audiocore.RawSample(s16, sample_rate=44100)) + +dac = audioio.AudioOut(board.A0) +for sample, name in zip(samples, sample_names): + print(name) + if trigger: trigger.value = False + dac.play(sample, loop=True) + time.sleep(1) + dac.stop() + time.sleep(0.1) + if trigger: trigger.value = True + print() + +dac.deinit() +print("done") diff --git a/tests/circuitpython-manual/audioio/wavefile_pause_resume.py b/tests/circuitpython-manual/audioio/wavefile_pause_resume.py new file mode 100644 index 0000000000000..13a227c027421 --- /dev/null +++ b/tests/circuitpython-manual/audioio/wavefile_pause_resume.py @@ -0,0 +1,53 @@ +import audiocore +import audioio +import board +import digitalio +import time +import os + +# Optional trigger pin for oscilloscope synchronisation. +try: + trigger = digitalio.DigitalInOut(board.D4) + trigger.switch_to_output(True) +except AttributeError: + trigger = None + +sample_prefix = "jeplayer-splash" + +samples = [] +for fn in os.listdir("/"): + if fn.startswith(sample_prefix): + samples.append(fn) + +if not samples: + print("No sample files found. Copy *.wav files from tests/circuitpython-manual/audiocore/ to the board.") + +dac = audioio.AudioOut(board.A0) +for filename in sorted(samples): + print("playing with pause/resume:", filename) + with open(filename, "rb") as sample_file: + try: + sample = audiocore.WaveFile(sample_file) + except OSError as e: + print(e) + continue + if trigger: trigger.value = False + dac.play(sample) + # Deliberately toggle pause/resume every 100 ms to stress-test the + # pause/resume cycle. Audio will sound choppy — that is expected. + while dac.playing: + time.sleep(0.1) + if not dac.playing: # may have finished during sleep + break + if not dac.paused: + dac.pause() + print(" paused") + else: + dac.resume() + print(" resumed") + if trigger: trigger.value = True + time.sleep(0.1) + print() + +dac.deinit() +print("done") diff --git a/tests/circuitpython-manual/audioio/wavefile_playback.py b/tests/circuitpython-manual/audioio/wavefile_playback.py new file mode 100644 index 0000000000000..fa1fa9713092d --- /dev/null +++ b/tests/circuitpython-manual/audioio/wavefile_playback.py @@ -0,0 +1,45 @@ +import audiocore +import audioio +import board +import digitalio +import time +import os + +# Optional trigger pin for oscilloscope synchronisation. +try: + trigger = digitalio.DigitalInOut(board.D4) + trigger.switch_to_output(True) +except AttributeError: + trigger = None + +# List WAV files from the audiocore test samples directory on the device. +# Copy tests/circuitpython-manual/audiocore/*.wav to the board's filesystem. +sample_prefix = "jeplayer-splash" + +samples = [] +for fn in os.listdir("/"): + if fn.startswith(sample_prefix): + samples.append(fn) + +if not samples: + print("No sample files found. Copy *.wav files from tests/circuitpython-manual/audiocore/ to the board.") + +dac = audioio.AudioOut(board.A0) +for filename in sorted(samples): + print("playing", filename) + with open(filename, "rb") as sample_file: + try: + sample = audiocore.WaveFile(sample_file) + except OSError as e: + print(e) + continue + if trigger: trigger.value = False + dac.play(sample) + while dac.playing: + time.sleep(0.1) + if trigger: trigger.value = True + time.sleep(0.1) + print() + +dac.deinit() +print("done") From ec21af4b39e9637e11fb048737effdc6e4be0d5f Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Mon, 6 Apr 2026 21:53:16 -0700 Subject: [PATCH 03/17] ports/stm: add stereo output via DAC CH2 (PA05/A1) for STM32F405 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) --- ports/stm/common-hal/audioio/AudioOut.c | 183 ++++++++++++++++++++---- ports/stm/common-hal/audioio/AudioOut.h | 14 +- 2 files changed, 161 insertions(+), 36 deletions(-) diff --git a/ports/stm/common-hal/audioio/AudioOut.c b/ports/stm/common-hal/audioio/AudioOut.c index 34252cfe287a4..e4c3a70e86080 100644 --- a/ports/stm/common-hal/audioio/AudioOut.c +++ b/ports/stm/common-hal/audioio/AudioOut.c @@ -97,28 +97,32 @@ static void dac_ramp(uint32_t from_12, uint32_t to_12) { // Convert a buffer of audio samples into 12-bit unsigned DAC values and write // them into dest[]. Returns the number of DAC samples written. // -// src - raw sample bytes from audiosample_get_buffer -// src_len - byte length of src -// dest - output array of 12-bit DAC values (uint16_t) -// dest_count - capacity of dest in samples +// src - raw sample bytes from audiosample_get_buffer +// src_len - byte length of src +// dest - output array of 12-bit DAC values (uint16_t) +// dest_count - capacity of dest in samples // bytes_per_sample - 1 or 2 // samples_signed - true if source samples are signed -// channel_count - 1 (mono) or 2 (stereo); only channel 0 (L) is output +// channel_count - 1 (mono) or 2 (stereo) +// channel_offset - 0 for left/mono, 1 for right channel of a stereo stream // quiescent_12 - value to pad with if src runs short static uint32_t convert_to_dac12( const uint8_t *src, uint32_t src_len, uint16_t *dest, uint32_t dest_count, uint8_t bytes_per_sample, bool samples_signed, - uint8_t channel_count, uint16_t quiescent_12) { + uint8_t channel_count, uint8_t channel_offset, + uint16_t quiescent_12) { - // src_stride: bytes between consecutive left-channel samples + // src_stride: bytes between consecutive samples of the same channel uint32_t src_stride = (uint32_t)bytes_per_sample * channel_count; + // src_offset: byte offset to the desired channel within each frame + uint32_t src_offset = (uint32_t)bytes_per_sample * channel_offset; uint32_t src_frames = src_len / src_stride; uint32_t frames = (src_frames < dest_count) ? src_frames : dest_count; if (bytes_per_sample == 1) { for (uint32_t i = 0; i < frames; i++) { - uint8_t u8 = src[i * src_stride]; + uint8_t u8 = src[i * src_stride + src_offset]; if (samples_signed) { // signed 8-bit: -128..127 → 0..4080 dest[i] = (uint16_t)((int16_t)(int8_t)u8 + 128) << 4; @@ -130,7 +134,7 @@ static uint32_t convert_to_dac12( } else { for (uint32_t i = 0; i < frames; i++) { uint16_t u16; - memcpy(&u16, src + i * src_stride, 2); + memcpy(&u16, src + i * src_stride + src_offset, 2); if (samples_signed) { // signed 16-bit: -32768..32767 → 0..4095 dest[i] = (uint16_t)((int32_t)(int16_t)u16 + 0x8000) >> 4; @@ -156,7 +160,10 @@ static uint32_t convert_to_dac12( // delivers data in fixed-byte chunks (256 bytes) that may be smaller than the // half-buffer in samples (e.g. 128 samples for 16-bit audio vs 256 slots). static void load_dma_buffer_half(audioio_audioout_obj_t *self, uint8_t half) { - uint16_t *dest = self->dma_buffer + ((uint32_t)half * AUDIOOUT_DMA_HALF_SAMPLES); + uint16_t *dest_l = self->dma_buffer + ((uint32_t)half * AUDIOOUT_DMA_HALF_SAMPLES); + uint16_t *dest_r = self->dma_buffer_r + ? self->dma_buffer_r + ((uint32_t)half * AUDIOOUT_DMA_HALF_SAMPLES) + : NULL; uint16_t quiescent_12 = self->quiescent_value >> 4; uint32_t filled = 0; @@ -168,7 +175,10 @@ static void load_dma_buffer_half(audioio_audioout_obj_t *self, uint8_t half) { if (result == GET_BUFFER_ERROR) { for (uint32_t i = filled; i < AUDIOOUT_DMA_HALF_SAMPLES; i++) { - dest[i] = quiescent_12; + dest_l[i] = quiescent_12; + if (dest_r) { + dest_r[i] = quiescent_12; + } } self->stopping = true; return; @@ -176,9 +186,20 @@ static void load_dma_buffer_half(audioio_audioout_obj_t *self, uint8_t half) { uint32_t written = convert_to_dac12( src_buf, src_len, - dest + filled, AUDIOOUT_DMA_HALF_SAMPLES - filled, + dest_l + filled, AUDIOOUT_DMA_HALF_SAMPLES - filled, self->bytes_per_sample, self->samples_signed, - self->channel_count, quiescent_12); + self->channel_count, 0, quiescent_12); + + if (dest_r) { + // Right channel: use channel_offset=1 for stereo, or 0 if the + // source is unexpectedly mono (duplicate left into right). + uint8_t r_offset = (self->channel_count >= 2) ? 1 : 0; + convert_to_dac12( + src_buf, src_len, + dest_r + filled, AUDIOOUT_DMA_HALF_SAMPLES - filled, + self->bytes_per_sample, self->samples_signed, + self->channel_count, r_offset, quiescent_12); + } filled += written; @@ -188,7 +209,10 @@ static void load_dma_buffer_half(audioio_audioout_obj_t *self, uint8_t half) { // Continue filling the remainder of this half from the reset buffer. } else { for (uint32_t i = filled; i < AUDIOOUT_DMA_HALF_SAMPLES; i++) { - dest[i] = quiescent_12; + dest_l[i] = quiescent_12; + if (dest_r) { + dest_r[i] = quiescent_12; + } } self->stopping = true; return; @@ -220,6 +244,12 @@ void DMA1_Stream5_IRQHandler(void) { } } +void DMA1_Stream6_IRQHandler(void) { + if (active_audioout && active_audioout->dma_buffer_r) { + HAL_DMA_IRQHandler(&active_audioout->dma_handle_r); + } +} + // HAL weak-symbol overrides: called from HAL_DMA_IRQHandler context. void HAL_DAC_ConvHalfCpltCallbackCh1(DAC_HandleTypeDef *hdac) { @@ -250,32 +280,40 @@ void common_hal_audioio_audioout_construct(audioio_audioout_obj_t *self, mp_raise_ValueError(MP_ERROR_TEXT("No DAC on chip")); #else - // Right channel (stereo) support deferred to a future implementation. - if (right_channel != NULL) { - mp_raise_ValueError(MP_ERROR_TEXT("Stereo not supported on this board")); - } - - // Only PA04 (DAC_CH1) is supported. + // Only PA04 (DAC_CH1) is supported as left channel. if (left_channel != &pin_PA04) { mp_raise_ValueError(MP_ERROR_TEXT("AudioOut requires pin A0 (PA04)")); } + // Right channel must be PA05 (DAC_CH2 / A1) if provided. + if (right_channel != NULL && right_channel != &pin_PA05) { + mp_raise_ValueError(MP_ERROR_TEXT("AudioOut right channel requires pin A1 (PA05)")); + } + self->left_channel = left_channel; + self->right_channel = right_channel; self->quiescent_value = quiescent_value; self->sample = MP_OBJ_NULL; self->dma_buffer = NULL; + self->dma_buffer_r = NULL; memset(&self->dma_handle, 0, sizeof(self->dma_handle)); + memset(&self->dma_handle_r, 0, sizeof(self->dma_handle_r)); memset(&self->callback, 0, sizeof(self->callback)); self->stopping = false; self->paused = false; - // Configure PA04 for analog (DAC) mode. + // Configure PA04 (and PA05 if stereo) for analog (DAC) mode. GPIO_InitTypeDef gpio_init = {0}; gpio_init.Pin = pin_mask(left_channel->number); gpio_init.Mode = GPIO_MODE_ANALOG; gpio_init.Pull = GPIO_NOPULL; HAL_GPIO_Init(pin_port(left_channel->port), &gpio_init); + if (right_channel != NULL) { + gpio_init.Pin = pin_mask(right_channel->number); + HAL_GPIO_Init(pin_port(right_channel->port), &gpio_init); + } + // Initialise the shared DAC handle if it hasn't been set up yet // (i.e. AnalogOut hasn't been used since last reset). if (handle.Instance == NULL || handle.State == HAL_DAC_STATE_RESET) { @@ -300,8 +338,11 @@ void common_hal_audioio_audioout_construct(audioio_audioout_obj_t *self, HAL_DAC_Start(&handle, DAC_CHANNEL_1); dac_ramp(0, quiescent_value >> 4); - // Claim the pin last so any error above doesn't leave it claimed. + // Claim pins last so any error above doesn't leave them claimed. common_hal_mcu_pin_claim(left_channel); + if (right_channel != NULL) { + common_hal_mcu_pin_claim(right_channel); + } #endif } @@ -320,13 +361,19 @@ void common_hal_audioio_audioout_deinit(audioio_audioout_obj_t *self) { dac_ramp(self->quiescent_value >> 4, 0); HAL_DAC_Stop(&handle, DAC_CHANNEL_1); - // Restore channel 1 to no-trigger mode so AnalogOut can use it again. + // Restore channels to no-trigger mode so AnalogOut can use them again. DAC_ChannelConfTypeDef ch_cfg = {0}; ch_cfg.DAC_Trigger = DAC_TRIGGER_NONE; ch_cfg.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_1); + if (self->right_channel != NULL) { + HAL_DAC_Stop(&handle, DAC_CHANNEL_2); + HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_2); + reset_pin_number(self->right_channel->port, self->right_channel->number); + self->right_channel = NULL; + } - // Release the pin. Let AnalogOut manage DAC clock disable. + // Release the left pin. Let AnalogOut manage DAC clock disable. reset_pin_number(self->left_channel->port, self->left_channel->number); self->left_channel = NULL; } @@ -353,13 +400,23 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, self->stopping = false; self->paused = false; - // Allocate the DMA circular buffer. + // Allocate DMA circular buffer(s). self->dma_buffer = (uint16_t *)m_malloc( AUDIOOUT_DMA_BUFFER_SAMPLES * sizeof(uint16_t)); if (!self->dma_buffer) { mp_raise_msg(&mp_type_MemoryError, MP_ERROR_TEXT("insufficient memory for audio buffer")); } + if (self->right_channel != NULL) { + self->dma_buffer_r = (uint16_t *)m_malloc( + AUDIOOUT_DMA_BUFFER_SAMPLES * sizeof(uint16_t)); + if (!self->dma_buffer_r) { + m_free(self->dma_buffer); + self->dma_buffer = NULL; + mp_raise_msg(&mp_type_MemoryError, + MP_ERROR_TEXT("insufficient memory for audio buffer")); + } + } // Pre-fill both halves before starting DMA. audiosample_reset_buffer(sample, false, 0); @@ -394,12 +451,19 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, HAL_TIMEx_MasterConfigSynchronization(&tim6_handle, &master_cfg); // --- DAC channel reconfiguration for DMA-triggered mode --- + // Stop single-mode DAC (started by stop() for quiescent output) before + // reconfiguring for DMA-triggered operation; otherwise the HAL state + // machine is left in BUSY and HAL_DAC_Start_DMA may reject the request. + HAL_DAC_Stop(&handle, DAC_CHANNEL_1); DAC_ChannelConfTypeDef ch_cfg = {0}; ch_cfg.DAC_Trigger = DAC_TRIGGER_T6_TRGO; ch_cfg.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_1); + if (self->right_channel != NULL) { + HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_2); + } - // --- DMA1 Stream5 Channel7 setup --- + // --- DMA1 Stream5 Channel7 setup (DAC CH1, left) --- __HAL_RCC_DMA1_CLK_ENABLE(); DMA_HandleTypeDef *hdma = &self->dma_handle; @@ -415,14 +479,35 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, hdma->Init.Priority = DMA_PRIORITY_HIGH; hdma->Init.FIFOMode = DMA_FIFOMODE_DISABLE; HAL_DMA_Init(hdma); - - // Link DMA stream to DAC channel 1. __HAL_LINKDMA(&handle, DMA_Handle1, *hdma); HAL_NVIC_SetPriority(DMA1_Stream5_IRQn, 6, 0); + NVIC_ClearPendingIRQ(DMA1_Stream5_IRQn); HAL_NVIC_EnableIRQ(DMA1_Stream5_IRQn); - // --- Start DMA transfer then timer --- + // --- DMA1 Stream6 Channel7 setup (DAC CH2, right) --- + if (self->right_channel != NULL) { + DMA_HandleTypeDef *hdma_r = &self->dma_handle_r; + memset(hdma_r, 0, sizeof(*hdma_r)); + hdma_r->Instance = DMA1_Stream6; + hdma_r->Init.Channel = DMA_CHANNEL_7; + hdma_r->Init.Direction = DMA_MEMORY_TO_PERIPH; + hdma_r->Init.PeriphInc = DMA_PINC_DISABLE; + hdma_r->Init.MemInc = DMA_MINC_ENABLE; + hdma_r->Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; + hdma_r->Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; + hdma_r->Init.Mode = DMA_CIRCULAR; + hdma_r->Init.Priority = DMA_PRIORITY_HIGH; + hdma_r->Init.FIFOMode = DMA_FIFOMODE_DISABLE; + HAL_DMA_Init(hdma_r); + __HAL_LINKDMA(&handle, DMA_Handle2, *hdma_r); + + HAL_NVIC_SetPriority(DMA1_Stream6_IRQn, 6, 0); + NVIC_ClearPendingIRQ(DMA1_Stream6_IRQn); + HAL_NVIC_EnableIRQ(DMA1_Stream6_IRQn); + } + + // --- Start DMA transfer(s) then timer --- active_audioout = self; if (HAL_DAC_Start_DMA(&handle, DAC_CHANNEL_1, @@ -431,11 +516,33 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, DAC_ALIGN_12B_R) != HAL_OK) { active_audioout = NULL; HAL_NVIC_DisableIRQ(DMA1_Stream5_IRQn); + if (self->right_channel != NULL) { + HAL_NVIC_DisableIRQ(DMA1_Stream6_IRQn); + m_free(self->dma_buffer_r); + self->dma_buffer_r = NULL; + } m_free(self->dma_buffer); self->dma_buffer = NULL; mp_raise_RuntimeError(MP_ERROR_TEXT("DAC DMA start failed")); } + if (self->right_channel != NULL) { + if (HAL_DAC_Start_DMA(&handle, DAC_CHANNEL_2, + (uint32_t *)self->dma_buffer_r, + AUDIOOUT_DMA_BUFFER_SAMPLES, + DAC_ALIGN_12B_R) != HAL_OK) { + active_audioout = NULL; + HAL_DAC_Stop_DMA(&handle, DAC_CHANNEL_1); + HAL_NVIC_DisableIRQ(DMA1_Stream5_IRQn); + HAL_NVIC_DisableIRQ(DMA1_Stream6_IRQn); + m_free(self->dma_buffer); + self->dma_buffer = NULL; + m_free(self->dma_buffer_r); + self->dma_buffer_r = NULL; + mp_raise_RuntimeError(MP_ERROR_TEXT("DAC DMA start failed (right channel)")); + } + } + HAL_TIM_Base_Start(&tim6_handle); } @@ -447,11 +554,19 @@ void common_hal_audioio_audioout_stop(audioio_audioout_obj_t *self) { // Stop the sample clock first so no more DMA requests are generated. TIM6->CR1 &= ~TIM_CR1_CEN; - // Stop DMA and DAC channel. + // Stop DMA and DAC channels. HAL_DAC_Stop_DMA(&handle, DAC_CHANNEL_1); HAL_NVIC_DisableIRQ(DMA1_Stream5_IRQn); + NVIC_ClearPendingIRQ(DMA1_Stream5_IRQn); + if (self->dma_buffer_r) { + HAL_DAC_Stop_DMA(&handle, DAC_CHANNEL_2); + HAL_NVIC_DisableIRQ(DMA1_Stream6_IRQn); + NVIC_ClearPendingIRQ(DMA1_Stream6_IRQn); + m_free(self->dma_buffer_r); + self->dma_buffer_r = NULL; + } - // Free the DMA buffer. + // Free the left DMA buffer. if (self->dma_buffer) { m_free(self->dma_buffer); self->dma_buffer = NULL; @@ -510,6 +625,12 @@ void audioout_reset(void) { TIM6->CR1 &= ~TIM_CR1_CEN; HAL_DAC_Stop_DMA(&handle, DAC_CHANNEL_1); HAL_NVIC_DisableIRQ(DMA1_Stream5_IRQn); + if (active_audioout->dma_buffer_r) { + HAL_DAC_Stop_DMA(&handle, DAC_CHANNEL_2); + HAL_NVIC_DisableIRQ(DMA1_Stream6_IRQn); + m_free(active_audioout->dma_buffer_r); + active_audioout->dma_buffer_r = NULL; + } if (active_audioout->dma_buffer) { m_free(active_audioout->dma_buffer); active_audioout->dma_buffer = NULL; diff --git a/ports/stm/common-hal/audioio/AudioOut.h b/ports/stm/common-hal/audioio/AudioOut.h index 4b1cc388b853f..4e0ffd70955ba 100644 --- a/ports/stm/common-hal/audioio/AudioOut.h +++ b/ports/stm/common-hal/audioio/AudioOut.h @@ -22,15 +22,19 @@ typedef struct { // Left channel pin (PA04 = DAC_CH1). NULL when deinited. const mcu_pin_obj_t *left_channel; - // right_channel (PA05 = DAC_CH2) deferred to a future implementation. + // Right channel pin (PA05 = DAC_CH2). NULL when mono. + const mcu_pin_obj_t *right_channel; - // DMA handle for DMA1 Stream5 Channel7 (DAC CH1). + // DMA handle for DMA1 Stream5 Channel7 (DAC CH1, left). + // DMA handle for DMA1 Stream6 Channel7 (DAC CH2, right). // The DAC handle is the shared file-scope handle from AnalogOut.c. DMA_HandleTypeDef dma_handle; + DMA_HandleTypeDef dma_handle_r; - // Circular DMA buffer: AUDIOOUT_DMA_BUFFER_SAMPLES uint16_t elements, + // Circular DMA buffers: AUDIOOUT_DMA_BUFFER_SAMPLES uint16_t elements each, // allocated on play() and freed on stop(). - uint16_t *dma_buffer; + uint16_t *dma_buffer; // left (CH1) + uint16_t *dma_buffer_r; // right (CH2), NULL when mono // Current audio sample object being played. mp_obj_t sample; @@ -43,7 +47,7 @@ typedef struct { // Sample format metadata, populated at play() time. uint8_t bytes_per_sample; // 1 (8-bit) or 2 (16-bit) bool samples_signed; - uint8_t channel_count; // 1 = mono, 2 = stereo (only L channel output) + uint8_t channel_count; // 1 = mono, 2 = stereo uint16_t quiescent_value; // 16-bit resting value (default 0x8000) // Background callback queued from DMA ISR, processed in main loop. From ff10e517adf32250349ff5b196e7c16748400f08 Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Mon, 6 Apr 2026 21:53:38 -0700 Subject: [PATCH 04/17] tests: add stereo test and cross-platform support to audioio suite 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) --- tests/circuitpython-manual/audioio/README.md | 72 +++++++++--- .../audioio/run_serial_tests.py | 108 +++++++++++++++--- .../audioio/stereo_playback.py | 50 ++++++++ 3 files changed, 201 insertions(+), 29 deletions(-) create mode 100644 tests/circuitpython-manual/audioio/stereo_playback.py diff --git a/tests/circuitpython-manual/audioio/README.md b/tests/circuitpython-manual/audioio/README.md index 596baebbe90f5..35c5f15518e7d 100644 --- a/tests/circuitpython-manual/audioio/README.md +++ b/tests/circuitpython-manual/audioio/README.md @@ -10,12 +10,13 @@ These tests exercise the DAC-based `audioio.AudioOut` implementation added for S | 2 — Pause / Resume | Yes | Yes (audio check) | | 3 — Looping Sine Wave | Yes | Yes (audio check) | | 4 — deinit and Re-init | Yes | No | -| 5 — Soft Reset Cleanup | No (manual Ctrl-C/D) | No | +| 5 — Stereo Playback | Yes | Yes (audio check) | +| 6 — Soft Reset Cleanup | No (manual Ctrl-C/D) | No | -`run_serial_tests.py` automates Tests 1, 2, 3, and 4: it copies the necessary -files to the board and runs each script over the serial REPL, comparing the -printed output to the expected patterns. You still need to listen to the audio -(and optionally use an oscilloscope) for the audio-quality checks. +`run_serial_tests.py` automates Tests 1–5: it copies the necessary files to the +board and runs each script over the serial REPL, comparing the printed output to +the expected patterns. You still need to listen to the audio (and optionally +use an oscilloscope) for the audio-quality checks. ### Quick start @@ -29,13 +30,19 @@ python3 tests/circuitpython-manual/audioio/run_serial_tests.py # Skip file copy if files are already on the board python3 tests/circuitpython-manual/audioio/run_serial_tests.py --no-copy -# Override the serial port or CIRCUITPY path +# Override the serial port or CIRCUITPY path (auto-detected on macOS/Linux/Windows) python3 tests/circuitpython-manual/audioio/run_serial_tests.py \ --port /dev/cu.usbmodem1234 \ - --circuitpy /Volumes/CIRCUITPY + --circuitpy /Volumes/CIRCUITPY # macOS (auto-detected) +python3 tests/circuitpython-manual/audioio/run_serial_tests.py \ + --port /dev/ttyACM0 \ + --circuitpy /media/user/CIRCUITPY # Linux (auto-detected) +python3 tests/circuitpython-manual/audioio/run_serial_tests.py \ + --port COM5 \ + --circuitpy D:\ # Windows (auto-detected) # Run only specific tests -python3 tests/circuitpython-manual/audioio/run_serial_tests.py --tests 3,4 +python3 tests/circuitpython-manual/audioio/run_serial_tests.py --tests 3,4,5 ``` The script exits 0 if all selected tests pass, 1 otherwise — suitable for CI. @@ -79,22 +86,27 @@ cp \ tests/circuitpython-manual/audiocore/jeplayer-splash-8000-8bit-mono-unsigned.wav \ tests/circuitpython-manual/audiocore/jeplayer-splash-8000-16bit-mono-signed.wav \ tests/circuitpython-manual/audiocore/jeplayer-splash-44100-16bit-mono-signed.wav \ + tests/circuitpython-manual/audiocore/jeplayer-splash-8000-16bit-stereo-signed.wav \ + tests/circuitpython-manual/audiocore/jeplayer-splash-44100-16bit-stereo-signed.wav \ /Volumes/CIRCUITPY/ ``` -These three files (~447 KB total) cover every exercised code path: +These five files cover every exercised code path: - `8000-8bit-mono-unsigned` — 8-bit unsigned decode path, audibly lo-fi - `8000-16bit-mono-signed` — 16-bit signed decode path at lowest sample rate - `44100-16bit-mono-signed` — 16-bit at highest sample rate (DMA reconfiguration, audible quality difference) +- `8000-16bit-stereo-signed` — stereo decode path, left→A0, right→A1 +- `44100-16bit-stereo-signed` — stereo at 44.1 kHz Stereo, 24-bit, and the 16 kHz files are omitted: stereo and 24-bit will `OSError`; 16 kHz adds no new code paths over 8 kHz and 44.1 kHz. -Copy the three test scripts to the board as well (or paste them into the REPL): +Copy the test scripts to the board as well (or paste them into the REPL): ``` cp tests/circuitpython-manual/audioio/wavefile_playback.py /Volumes/CIRCUITPY/ cp tests/circuitpython-manual/audioio/wavefile_pause_resume.py /Volumes/CIRCUITPY/ cp tests/circuitpython-manual/audioio/single_buffer_loop.py /Volumes/CIRCUITPY/ +cp tests/circuitpython-manual/audioio/stereo_playback.py /Volumes/CIRCUITPY/ ``` ## Test 1 — WAV File Playback (`wavefile_playback.py`) *(automated)* @@ -219,7 +231,39 @@ print("pass") **Expected output:** `pass` with no exceptions. -## Test 5 — Soft Reset Cleanup *(manual)* +## Test 5 — Stereo Playback (`stereo_playback.py`) *(automated)* + +Verifies that `AudioOut(board.A0, right_channel=board.A1)` correctly splits a +stereo WAV file across both DAC channels: left audio on **A0 (PA04, DAC_CH1)** +and right audio on **A1 (PA05, DAC_CH2)**, both clocked by TIM6. + +**Hardware required:** connect a speaker or amp to both A0 and A1 (two separate +channels), or use an oscilloscope to probe each pin independently. + +**Run from the REPL:** + +```python +import os +os.chdir("/") +exec(open("stereo_playback.py").read()) +``` + +**Expected output:** + +``` +playing stereo: jeplayer-splash-44100-16bit-stereo-signed.wav +playing stereo: jeplayer-splash-8000-16bit-stereo-signed.wav +done +``` + +**What to listen / look for:** + +- Left and right channels play the correct sides of the stereo jingle. +- No cross-contamination between channels. +- On a scope: probe A0 and A1 simultaneously — waveforms should differ + (the splash sample is not phase-identical left/right). + +## Test 6 — Soft Reset Cleanup *(manual)* Verifies that `audioout_reset()` properly cleans up when the REPL soft-resets during active playback. @@ -249,7 +293,7 @@ Each test script drives `board.D4` (pin D4) low at the start of each playback an ## Known Limitations -- **Right channel / stereo output** is not implemented. Passing `right_channel` to `AudioOut()` raises `ValueError: Stereo not supported on this board`. -- **Only pin A0 (PA04)** is supported as the left channel. Any other pin raises `ValueError: AudioOut requires pin A0 (PA04)`. +- **Left channel must be A0 (PA04)**. Any other pin raises `ValueError: AudioOut requires pin A0 (PA04)`. +- **Right channel must be A1 (PA05)** when used. Any other pin raises `ValueError: AudioOut right channel requires pin A1 (PA05)`. - **24-bit WAV files** are not supported by `audiocore.WaveFile` and will raise `OSError` when opened. -- Only one `AudioOut` instance can be active at a time (single DAC channel). +- Only one `AudioOut` instance can be active at a time. diff --git a/tests/circuitpython-manual/audioio/run_serial_tests.py b/tests/circuitpython-manual/audioio/run_serial_tests.py index 577be071c625a..35b2c4630ac2b 100644 --- a/tests/circuitpython-manual/audioio/run_serial_tests.py +++ b/tests/circuitpython-manual/audioio/run_serial_tests.py @@ -12,14 +12,20 @@ Usage: python3 run_serial_tests.py python3 run_serial_tests.py --port /dev/cu.usbmodemXXX + python3 run_serial_tests.py --circuitpy /Volumes/CIRCUITPY # macOS + python3 run_serial_tests.py --circuitpy /media/user/CIRCUITPY # Linux + python3 run_serial_tests.py --circuitpy D:\\ # Windows python3 run_serial_tests.py --no-copy --tests 3,4 Requirements: pip install mpremote """ +from __future__ import annotations + import argparse import os +import shutil import subprocess import sys @@ -34,12 +40,15 @@ "jeplayer-splash-8000-8bit-mono-unsigned.wav", "jeplayer-splash-8000-16bit-mono-signed.wav", "jeplayer-splash-44100-16bit-mono-signed.wav", + "jeplayer-splash-8000-16bit-stereo-signed.wav", + "jeplayer-splash-44100-16bit-stereo-signed.wav", ] TEST_SCRIPTS = [ "wavefile_playback.py", "wavefile_pause_resume.py", "single_buffer_loop.py", + "stereo_playback.py", ] DEINIT_TEST_CODE = ( @@ -96,9 +105,49 @@ def find_port() -> str: ) -def copy_files(port: str): +def find_circuitpy() -> str | None: + """Return the path to the mounted CIRCUITPY volume, or None if not found.""" + import platform + system = platform.system() + + if system == "Darwin": + candidates = ["/Volumes/CIRCUITPY"] + elif system == "Windows": + # Scan all drive letters for a CIRCUITPY volume label. + import string + import ctypes + candidates = [] + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + buf = ctypes.create_unicode_buffer(256) + for letter in string.ascii_uppercase: + root = f"{letter}:\\" + if kernel32.GetVolumeInformationW(root, buf, 256, None, None, None, None, 0): + if buf.value == "CIRCUITPY": + candidates.append(root) + else: + # Linux: common udev/udisks mount points + candidates = ["/media/CIRCUITPY", "/run/media/CIRCUITPY"] + try: + import pwd + user = pwd.getpwuid(os.getuid()).pw_name + candidates.insert(0, f"/run/media/{user}/CIRCUITPY") + candidates.insert(0, f"/media/{user}/CIRCUITPY") + except Exception: + pass + + for path in candidates: + if os.path.isdir(path): + return path + return None + + +def copy_files(port: str, circuitpy: str | None = None): """Copy WAV samples and test scripts to the board.""" - print("Copying files to board ...") + mount = circuitpy or find_circuitpy() + if mount: + print(f"Copying files to board via {mount} ...") + else: + print("Copying files to board via mpremote ...") files = ( [(os.path.join(AUDIOCORE_DIR, w), w) for w in WAV_FILES] + [(os.path.join(SCRIPT_DIR, s), s) for s in TEST_SCRIPTS] @@ -108,12 +157,19 @@ def copy_files(port: str): if not os.path.exists(src): missing.append(src) continue - stdout, stderr = _mpremote( - ["connect", port, "fs", "cp", src, f":{dst}"], timeout=30 - ) - # mpremote prints "Up to date: " if unchanged, "cp ..." otherwise - status = "up to date" if "Up to date" in stdout else "copied" - print(f" {dst} ({status})") + if mount: + dest_path = os.path.join(mount, dst) + shutil.copy2(src, dest_path) + print(f" {dst} (copied)") + else: + stdout, stderr = _mpremote( + ["connect", port, "fs", "cp", src, f":/{dst}"], timeout=30 + ) + if stderr and "Error" in stderr: + print(f" {dst} (FAILED: {stderr.strip()})") + else: + status = "up to date" if "Up to date" in stdout else "copied" + print(f" {dst} ({status})") if missing: print("WARNING: source files not found:") for m in missing: @@ -159,7 +215,7 @@ def _run_exec(port: str, code: str, label: str, timeout: float): # --------------------------------------------------------------------------- def test1_wavefile_playback(port: str) -> bool: - code = 'import os; os.chdir("/")\nexec(open("wavefile_playback.py").read())' + code = 'exec(open("/wavefile_playback.py").read())' ok, stdout, stderr = _run_exec( port, code, "Test 1 — WAV File Playback (wavefile_playback.py)", timeout=180 ) @@ -175,7 +231,7 @@ def test1_wavefile_playback(port: str) -> bool: def test2_pause_resume(port: str) -> bool: - code = 'exec(open("wavefile_pause_resume.py").read())' + code = 'exec(open("/wavefile_pause_resume.py").read())' ok, stdout, stderr = _run_exec( port, code, "Test 2 — Pause / Resume (wavefile_pause_resume.py)", timeout=180 ) @@ -192,7 +248,7 @@ def test2_pause_resume(port: str) -> bool: def test3_single_buffer_loop(port: str) -> bool: - code = 'exec(open("single_buffer_loop.py").read())' + code = 'exec(open("/single_buffer_loop.py").read())' ok, stdout, stderr = _run_exec( port, code, "Test 3 — Looping Sine Wave (single_buffer_loop.py)", timeout=30 ) @@ -206,6 +262,21 @@ def test3_single_buffer_loop(port: str) -> bool: return passed +def test5_stereo_playback(port: str) -> bool: + code = 'exec(open("/stereo_playback.py").read())' + ok, stdout, stderr = _run_exec( + port, code, "Test 5 — Stereo Playback (stereo_playback.py)", timeout=180 + ) + if not ok: + return False + passed = True + passed &= _check("playing stereo: jeplayer-splash-44100-16bit-stereo-signed.wav" in stdout, "44100 Hz 16-bit stereo WAV played") + passed &= _check("playing stereo: jeplayer-splash-8000-16bit-stereo-signed.wav" in stdout, "8000 Hz 16-bit stereo WAV played") + passed &= _check("done" in stdout, "Script completed with 'done'") + passed &= _check(not stderr, f"No exceptions (stderr={stderr!r})") + return passed + + def test4_deinit(port: str) -> bool: ok, stdout, stderr = _run_exec( port, DEINIT_TEST_CODE, "Test 4 — deinit and Re-init (inline)", timeout=10 @@ -228,6 +299,11 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--port", help="Serial port (auto-detected if omitted)") + parser.add_argument( + "--circuitpy", + metavar="PATH", + help="Path to mounted CIRCUITPY volume (auto-detected if omitted)", + ) parser.add_argument( "--no-copy", action="store_true", @@ -235,8 +311,8 @@ def main(): ) parser.add_argument( "--tests", - default="1,2,3,4", - help="Comma-separated list of test numbers to run (default: 1,2,3,4)", + default="1,2,3,4,5", + help="Comma-separated list of test numbers to run (default: 1,2,3,4,5)", ) args = parser.parse_args() @@ -246,7 +322,7 @@ def main(): print(f"Using port: {port}\n") if not args.no_copy: - copy_files(port) + copy_files(port, circuitpy=args.circuitpy) results: dict[str, bool] = {} if "1" in selected: @@ -257,6 +333,8 @@ def main(): results["Test 3 — Looping Sine"] = test3_single_buffer_loop(port) if "4" in selected: results["Test 4 — deinit/Re-init"] = test4_deinit(port) + if "5" in selected: + results["Test 5 — Stereo Playback"] = test5_stereo_playback(port) print(f"\n{'=' * 60}") print("SUMMARY") @@ -269,7 +347,7 @@ def main(): print() if all_passed: print("All automated tests passed.") - print("Remaining manual step: Test 5 (soft-reset) and audio/oscilloscope verification.") + print("Remaining manual step: Test 6 (soft-reset) and audio/oscilloscope verification.") sys.exit(0) else: print("One or more tests FAILED — see details above.") diff --git a/tests/circuitpython-manual/audioio/stereo_playback.py b/tests/circuitpython-manual/audioio/stereo_playback.py new file mode 100644 index 0000000000000..c82e4ba40dd1d --- /dev/null +++ b/tests/circuitpython-manual/audioio/stereo_playback.py @@ -0,0 +1,50 @@ +import audiocore +import audioio +import board +import digitalio +import time +import os + +# Optional trigger pin for oscilloscope synchronisation. +try: + trigger = digitalio.DigitalInOut(board.D4) + trigger.switch_to_output(True) +except AttributeError: + trigger = None + +sample_prefix = "jeplayer-splash" + +samples = [] +for fn in os.listdir("/"): + if fn.startswith(sample_prefix): + samples.append(fn) + +if not samples: + print("No sample files found. Copy *.wav files from tests/circuitpython-manual/audiocore/ to the board.") + +# Play each stereo WAV file through both DAC channels simultaneously. +# Left channel → A0 (PA04, DAC_CH1), Right channel → A1 (PA05, DAC_CH2). +dac = audioio.AudioOut(board.A0, right_channel=board.A1) +for filename in sorted(samples): + # Only play stereo files to exercise the right channel path. + if "stereo" not in filename: + continue + print("playing stereo:", filename) + with open(filename, "rb") as sample_file: + try: + sample = audiocore.WaveFile(sample_file) + except OSError as e: + print(e) + continue + if trigger: + trigger.value = False + dac.play(sample) + while dac.playing: + time.sleep(0.1) + if trigger: + trigger.value = True + time.sleep(0.1) + print() + +dac.deinit() +print("done") From 81467e58afc999cd5b328172d85bc4358b68a93d Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sun, 26 Apr 2026 17:52:50 -0700 Subject: [PATCH 05/17] ports/stm: track source position across DMA half-fills in audioio 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 --- ports/stm/common-hal/audioio/AudioOut.c | 85 ++++++++++++++----------- ports/stm/common-hal/audioio/AudioOut.h | 6 ++ 2 files changed, 55 insertions(+), 36 deletions(-) diff --git a/ports/stm/common-hal/audioio/AudioOut.c b/ports/stm/common-hal/audioio/AudioOut.c index e4c3a70e86080..a237ffbe10103 100644 --- a/ports/stm/common-hal/audioio/AudioOut.c +++ b/ports/stm/common-hal/audioio/AudioOut.c @@ -155,69 +155,79 @@ static uint32_t convert_to_dac12( // Load one half of the DMA circular buffer from the audio sample source. // half: 0 = lower half (indices 0..HALF-1), 1 = upper half (HALF..END-1). // -// Loops get_buffer calls until the full AUDIOOUT_DMA_HALF_SAMPLES-sample slot -// is filled. This is necessary because the audio sample source (e.g. WaveFile) -// delivers data in fixed-byte chunks (256 bytes) that may be smaller than the -// half-buffer in samples (e.g. 128 samples for 16-bit audio vs 256 slots). +// Tracks position within the source buffer (src_ptr / src_remaining_len) +// across calls so that large source buffers (e.g. a 1024-sample RawSample) +// are consumed incrementally rather than re-reading from the start each time. static void load_dma_buffer_half(audioio_audioout_obj_t *self, uint8_t half) { uint16_t *dest_l = self->dma_buffer + ((uint32_t)half * AUDIOOUT_DMA_HALF_SAMPLES); uint16_t *dest_r = self->dma_buffer_r ? self->dma_buffer_r + ((uint32_t)half * AUDIOOUT_DMA_HALF_SAMPLES) : NULL; uint16_t quiescent_12 = self->quiescent_value >> 4; + uint32_t src_stride = (uint32_t)self->bytes_per_sample * self->channel_count; uint32_t filled = 0; while (filled < AUDIOOUT_DMA_HALF_SAMPLES) { - uint8_t *src_buf; - uint32_t src_len; - audioio_get_buffer_result_t result = - audiosample_get_buffer(self->sample, false, 0, &src_buf, &src_len); - - if (result == GET_BUFFER_ERROR) { - for (uint32_t i = filled; i < AUDIOOUT_DMA_HALF_SAMPLES; i++) { - dest_l[i] = quiescent_12; - if (dest_r) { - dest_r[i] = quiescent_12; + // Fetch new source data when the previous buffer is exhausted. + if (self->src_remaining_len == 0) { + // Handle end-of-stream from previous get_buffer call. + if (self->src_done) { + if (self->loop) { + audiosample_reset_buffer(self->sample, false, 0); + self->src_done = false; + } else { + for (uint32_t i = filled; i < AUDIOOUT_DMA_HALF_SAMPLES; i++) { + dest_l[i] = quiescent_12; + if (dest_r) { + dest_r[i] = quiescent_12; + } + } + self->stopping = true; + return; } } - self->stopping = true; - return; + + uint8_t *buf; + uint32_t len; + audioio_get_buffer_result_t result = + audiosample_get_buffer(self->sample, false, 0, &buf, &len); + + if (result == GET_BUFFER_ERROR) { + for (uint32_t i = filled; i < AUDIOOUT_DMA_HALF_SAMPLES; i++) { + dest_l[i] = quiescent_12; + if (dest_r) { + dest_r[i] = quiescent_12; + } + } + self->stopping = true; + return; + } + + self->src_ptr = buf; + self->src_remaining_len = len; + self->src_done = (result == GET_BUFFER_DONE); } uint32_t written = convert_to_dac12( - src_buf, src_len, + self->src_ptr, self->src_remaining_len, dest_l + filled, AUDIOOUT_DMA_HALF_SAMPLES - filled, self->bytes_per_sample, self->samples_signed, self->channel_count, 0, quiescent_12); if (dest_r) { - // Right channel: use channel_offset=1 for stereo, or 0 if the - // source is unexpectedly mono (duplicate left into right). uint8_t r_offset = (self->channel_count >= 2) ? 1 : 0; convert_to_dac12( - src_buf, src_len, + self->src_ptr, self->src_remaining_len, dest_r + filled, AUDIOOUT_DMA_HALF_SAMPLES - filled, self->bytes_per_sample, self->samples_signed, self->channel_count, r_offset, quiescent_12); } + // Advance source position by the amount consumed. + uint32_t bytes_consumed = written * src_stride; + self->src_ptr += bytes_consumed; + self->src_remaining_len -= bytes_consumed; filled += written; - - if (result == GET_BUFFER_DONE) { - if (self->loop) { - audiosample_reset_buffer(self->sample, false, 0); - // Continue filling the remainder of this half from the reset buffer. - } else { - for (uint32_t i = filled; i < AUDIOOUT_DMA_HALF_SAMPLES; i++) { - dest_l[i] = quiescent_12; - if (dest_r) { - dest_r[i] = quiescent_12; - } - } - self->stopping = true; - return; - } - } } } @@ -399,6 +409,9 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, self->loop = loop; self->stopping = false; self->paused = false; + self->src_ptr = NULL; + self->src_remaining_len = 0; + self->src_done = false; // Allocate DMA circular buffer(s). self->dma_buffer = (uint16_t *)m_malloc( diff --git a/ports/stm/common-hal/audioio/AudioOut.h b/ports/stm/common-hal/audioio/AudioOut.h index 4e0ffd70955ba..5472ccb0c6e5f 100644 --- a/ports/stm/common-hal/audioio/AudioOut.h +++ b/ports/stm/common-hal/audioio/AudioOut.h @@ -55,6 +55,12 @@ typedef struct { // Which half of dma_buffer to refill next: 0 = lower, 1 = upper. volatile uint8_t buffer_half_to_fill; + + // Source buffer position tracking. Allows consuming large source buffers + // (e.g. RawSample > 256 samples) across multiple DMA half-fills. + const uint8_t *src_ptr; // current read position in source buffer + uint32_t src_remaining_len; // bytes remaining in current source buffer + bool src_done; // GET_BUFFER_DONE received for current buffer } audioio_audioout_obj_t; // Called from reset_port() to stop any active playback on soft-reset. From fcac2fc3f7c626356fed0d2570fb83d62cbf526d Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sun, 26 Apr 2026 17:53:05 -0700 Subject: [PATCH 06/17] ports/stm: enlarge audioio DMA buffer to 2048 samples 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 --- ports/stm/common-hal/audioio/AudioOut.h | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ports/stm/common-hal/audioio/AudioOut.h b/ports/stm/common-hal/audioio/AudioOut.h index 5472ccb0c6e5f..6f47b2b1eea6c 100644 --- a/ports/stm/common-hal/audioio/AudioOut.h +++ b/ports/stm/common-hal/audioio/AudioOut.h @@ -13,9 +13,11 @@ // Total DMA circular buffer size in 16-bit samples. // Split into two halves; one half plays while the other is refilled. -// 512 samples at 44100 Hz = ~5.8 ms per half-buffer interrupt. -#define AUDIOOUT_DMA_BUFFER_SAMPLES 512 -#define AUDIOOUT_DMA_HALF_SAMPLES 256 +// 2048 samples at 48000 Hz = ~21 ms per half-buffer interrupt, which +// gives the main loop plenty of headroom against USB / VFS stalls +// before an underrun occurs. +#define AUDIOOUT_DMA_BUFFER_SAMPLES 2048 +#define AUDIOOUT_DMA_HALF_SAMPLES 1024 typedef struct { mp_obj_base_t base; From d112d01ed5520e81ccb3bf93dbfc17f22ed35b70 Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sun, 26 Apr 2026 17:53:20 -0700 Subject: [PATCH 07/17] ports/stm: round-to-nearest TIM6 period in audioio 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 --- ports/stm/common-hal/audioio/AudioOut.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ports/stm/common-hal/audioio/AudioOut.c b/ports/stm/common-hal/audioio/AudioOut.c index a237ffbe10103..575ce72510de6 100644 --- a/ports/stm/common-hal/audioio/AudioOut.c +++ b/ports/stm/common-hal/audioio/AudioOut.c @@ -443,7 +443,9 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, __HAL_RCC_TIM6_CLK_ENABLE(); uint32_t tim6_clk = get_tim6_freq(); - uint32_t period = (tim6_clk / sample_rate); + // Round to nearest, not truncate, so the realised sample rate is the + // closest TIM6 division to the requested rate. + uint32_t period = (tim6_clk + sample_rate / 2) / sample_rate; if (period < 2) { period = 2; } From 53fef38ae39707b6ae72a9b8519be172889b075e Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sun, 26 Apr 2026 17:56:06 -0700 Subject: [PATCH 08/17] ports/stm: ramp into first sample on audioio play start 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 --- ports/stm/common-hal/audioio/AudioOut.c | 28 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/ports/stm/common-hal/audioio/AudioOut.c b/ports/stm/common-hal/audioio/AudioOut.c index 575ce72510de6..9f8799f258b16 100644 --- a/ports/stm/common-hal/audioio/AudioOut.c +++ b/ports/stm/common-hal/audioio/AudioOut.c @@ -66,9 +66,9 @@ static uint32_t get_tim6_freq(void) { #endif } -// Gently ramp the DAC CH1 output from from_12 to to_12 (both 12-bit, 0-4095) -// to avoid audible clicks. 64 steps, ~100 µs per step = ~6.4 ms total. -static void dac_ramp(uint32_t from_12, uint32_t to_12) { +// Gently ramp the given DAC channel output from from_12 to to_12 (both 12-bit, +// 0-4095) to avoid audible clicks. 64 steps, ~100 µs per step = ~6.4 ms total. +static void dac_ramp_channel(uint32_t channel, uint32_t from_12, uint32_t to_12) { if (from_12 == to_12) { return; } @@ -85,8 +85,8 @@ static void dac_ramp(uint32_t from_12, uint32_t to_12) { } else if (step < 0 && v <= (int32_t)to_12) { v = (int32_t)to_12; } - HAL_DAC_SetValue(&handle, DAC_CHANNEL_1, DAC_ALIGN_12B_R, (uint16_t)v); - HAL_DAC_Start(&handle, DAC_CHANNEL_1); + HAL_DAC_SetValue(&handle, channel, DAC_ALIGN_12B_R, (uint16_t)v); + HAL_DAC_Start(&handle, channel); mp_hal_delay_us(100); if (v == (int32_t)to_12) { break; @@ -94,6 +94,10 @@ static void dac_ramp(uint32_t from_12, uint32_t to_12) { } } +static inline void dac_ramp(uint32_t from_12, uint32_t to_12) { + dac_ramp_channel(DAC_CHANNEL_1, from_12, to_12); +} + // Convert a buffer of audio samples into 12-bit unsigned DAC values and write // them into dest[]. Returns the number of DAC samples written. // @@ -436,6 +440,20 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, load_dma_buffer_half(self, 0); load_dma_buffer_half(self, 1); + // Ramp from quiescent to the first sample so the transition into + // DMA-driven output is glitch-free. The DAC is still in single-mode + // (DAC_TRIGGER_NONE) at this point, set up by construct() / stop(), + // so HAL_DAC_SetValue takes effect immediately. After the ramp the + // pin is sitting at exactly dma_buffer[0], which is also the first + // value the timer-triggered DMA will latch. + uint16_t quiescent_12 = self->quiescent_value >> 4; + dac_ramp_channel(DAC_CHANNEL_1, quiescent_12, self->dma_buffer[0]); + if (self->right_channel != NULL) { + // CH2 was not started in construct(), so it has been outputting + // its reset value (0). Ramp from there. + dac_ramp_channel(DAC_CHANNEL_2, 0, self->dma_buffer_r[0]); + } + // --- TIM6 setup --- // TIM6 is a basic timer on APB1. It is not managed by the common timer // infrastructure (stm_peripherals_find_timer etc.) because it has no GPIO From dc6fb6b20c5404d34aaf149dc91755357056daf1 Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sat, 2 May 2026 01:49:38 -0700 Subject: [PATCH 09/17] ports/stm: queue DMA halves via bitmask + grow audioio buffer 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 --- ports/stm/common-hal/audioio/AudioOut.c | 90 +++++++---- ports/stm/common-hal/audioio/AudioOut.h | 17 ++- tests/circuitpython-manual/audioio/README.md | 37 +++-- tests/circuitpython-manual/audioio/TESTING.md | 141 +++++++++++++----- .../audioio/run_serial_tests.py | 74 ++++++++- .../audioio/stereo_playback.py | 107 +++++++++++-- 6 files changed, 356 insertions(+), 110 deletions(-) diff --git a/ports/stm/common-hal/audioio/AudioOut.c b/ports/stm/common-hal/audioio/AudioOut.c index 9f8799f258b16..31b10235176c3 100644 --- a/ports/stm/common-hal/audioio/AudioOut.c +++ b/ports/stm/common-hal/audioio/AudioOut.c @@ -127,25 +127,19 @@ static uint32_t convert_to_dac12( if (bytes_per_sample == 1) { for (uint32_t i = 0; i < frames; i++) { uint8_t u8 = src[i * src_stride + src_offset]; - if (samples_signed) { - // signed 8-bit: -128..127 → 0..4080 - dest[i] = (uint16_t)((int16_t)(int8_t)u8 + 128) << 4; - } else { - // unsigned 8-bit: 0..255 → 0..4080 - dest[i] = (uint16_t)u8 << 4; - } + int32_t s = samples_signed + ? (int32_t)(int8_t)u8 + : (int32_t)u8 - 128; + dest[i] = (uint16_t)((s + 128) & 0xFF) << 4; } } else { for (uint32_t i = 0; i < frames; i++) { uint16_t u16; memcpy(&u16, src + i * src_stride + src_offset, 2); - if (samples_signed) { - // signed 16-bit: -32768..32767 → 0..4095 - dest[i] = (uint16_t)((int32_t)(int16_t)u16 + 0x8000) >> 4; - } else { - // unsigned 16-bit: 0..65535 → 0..4095 - dest[i] = u16 >> 4; - } + int32_t s = samples_signed + ? (int32_t)(int16_t)u16 + : (int32_t)u16 - 0x8000; + dest[i] = (uint16_t)((s + 0x8000) & 0xFFFF) >> 4; } } @@ -245,7 +239,17 @@ static void audioout_fill_callback(void *arg) { common_hal_audioio_audioout_stop(self); return; } - load_dma_buffer_half(self, self->buffer_half_to_fill); + uint8_t mask; + __disable_irq(); + mask = self->halves_to_fill; + self->halves_to_fill = 0; + __enable_irq(); + if (mask & 0x1) { + load_dma_buffer_half(self, 0); + } + if (mask & 0x2) { + load_dma_buffer_half(self, 1); + } } // --------------------------------------------------------------------------- @@ -268,7 +272,7 @@ void DMA1_Stream6_IRQHandler(void) { void HAL_DAC_ConvHalfCpltCallbackCh1(DAC_HandleTypeDef *hdac) { if (active_audioout && !active_audioout->paused) { - active_audioout->buffer_half_to_fill = 0; + active_audioout->halves_to_fill |= 0x1; background_callback_add(&active_audioout->callback, audioout_fill_callback, active_audioout); } @@ -276,7 +280,7 @@ void HAL_DAC_ConvHalfCpltCallbackCh1(DAC_HandleTypeDef *hdac) { void HAL_DAC_ConvCpltCallbackCh1(DAC_HandleTypeDef *hdac) { if (active_audioout && !active_audioout->paused) { - active_audioout->buffer_half_to_fill = 1; + active_audioout->halves_to_fill |= 0x2; background_callback_add(&active_audioout->callback, audioout_fill_callback, active_audioout); } @@ -413,6 +417,7 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, self->loop = loop; self->stopping = false; self->paused = false; + self->halves_to_fill = 0; self->src_ptr = NULL; self->src_remaining_len = 0; self->src_done = false; @@ -484,10 +489,9 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, HAL_TIMEx_MasterConfigSynchronization(&tim6_handle, &master_cfg); // --- DAC channel reconfiguration for DMA-triggered mode --- - // Stop single-mode DAC (started by stop() for quiescent output) before - // reconfiguring for DMA-triggered operation; otherwise the HAL state - // machine is left in BUSY and HAL_DAC_Start_DMA may reject the request. - HAL_DAC_Stop(&handle, DAC_CHANNEL_1); + // Switch the trigger source to TIM6 *without* disabling the DAC channel. + // HAL_DAC_Stop would disable the channel and momentarily drop the output + // pin to 0 V — audible as a pop between samples. DAC_ChannelConfTypeDef ch_cfg = {0}; ch_cfg.DAC_Trigger = DAC_TRIGGER_T6_TRGO; ch_cfg.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; @@ -495,6 +499,9 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, if (self->right_channel != NULL) { HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_2); } + // Reset HAL state from BUSY (set by Start) back to READY so + // HAL_DAC_Start_DMA below doesn't reject the request. + handle.State = HAL_DAC_STATE_READY; // --- DMA1 Stream5 Channel7 setup (DAC CH1, left) --- __HAL_RCC_DMA1_CLK_ENABLE(); @@ -587,12 +594,30 @@ void common_hal_audioio_audioout_stop(audioio_audioout_obj_t *self) { // Stop the sample clock first so no more DMA requests are generated. TIM6->CR1 &= ~TIM_CR1_CEN; - // Stop DMA and DAC channels. - HAL_DAC_Stop_DMA(&handle, DAC_CHANNEL_1); + // Switch the DAC channels to no-trigger mode while leaving them enabled. + // This is the key to a click-free stop: HAL_DAC_Stop_DMA / HAL_DAC_Stop + // disable the channel, which briefly pulls the output pin to 0 V before + // the ramp can run. Reconfiguring the trigger only keeps the channel + // enabled; the DAC keeps holding its last DHR value while DMA is aborted. + DAC_ChannelConfTypeDef ch_cfg = {0}; + ch_cfg.DAC_Trigger = DAC_TRIGGER_NONE; + ch_cfg.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; + HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_1); + if (self->dma_buffer_r) { + HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_2); + } + + // Capture the last DAC output now that no further DMA writes can land. + uint16_t last_l = (uint16_t)(DAC->DOR1 & 0xFFF); + uint16_t last_r = self->dma_buffer_r ? (uint16_t)(DAC->DOR2 & 0xFFF) : 0; + uint16_t quiescent_12 = self->quiescent_value >> 4; + + // Abort DMA without disabling the DAC channels (HAL_DAC_Stop_DMA would). + HAL_DMA_Abort(&self->dma_handle); HAL_NVIC_DisableIRQ(DMA1_Stream5_IRQn); NVIC_ClearPendingIRQ(DMA1_Stream5_IRQn); if (self->dma_buffer_r) { - HAL_DAC_Stop_DMA(&handle, DAC_CHANNEL_2); + HAL_DMA_Abort(&self->dma_handle_r); HAL_NVIC_DisableIRQ(DMA1_Stream6_IRQn); NVIC_ClearPendingIRQ(DMA1_Stream6_IRQn); m_free(self->dma_buffer_r); @@ -605,14 +630,15 @@ void common_hal_audioio_audioout_stop(audioio_audioout_obj_t *self) { self->dma_buffer = NULL; } - // Restore quiescent output (channel is still active from construct()). - DAC_ChannelConfTypeDef ch_cfg = {0}; - ch_cfg.DAC_Trigger = DAC_TRIGGER_NONE; - ch_cfg.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; - HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_1); - HAL_DAC_SetValue(&handle, DAC_CHANNEL_1, DAC_ALIGN_12B_R, - self->quiescent_value >> 4); - HAL_DAC_Start(&handle, DAC_CHANNEL_1); + // Ramp left channel from last sample back to quiescent. + dac_ramp_channel(DAC_CHANNEL_1, last_l, quiescent_12); + + // Ramp right channel back to 0 (its reset value) before disabling it, + // so the next play() can ramp cleanly from 0 again. + if (self->right_channel != NULL) { + dac_ramp_channel(DAC_CHANNEL_2, last_r, 0); + HAL_DAC_Stop(&handle, DAC_CHANNEL_2); + } self->sample = MP_OBJ_NULL; self->stopping = false; diff --git a/ports/stm/common-hal/audioio/AudioOut.h b/ports/stm/common-hal/audioio/AudioOut.h index 6f47b2b1eea6c..ad8ff1c768064 100644 --- a/ports/stm/common-hal/audioio/AudioOut.h +++ b/ports/stm/common-hal/audioio/AudioOut.h @@ -13,11 +13,11 @@ // Total DMA circular buffer size in 16-bit samples. // Split into two halves; one half plays while the other is refilled. -// 2048 samples at 48000 Hz = ~21 ms per half-buffer interrupt, which -// gives the main loop plenty of headroom against USB / VFS stalls -// before an underrun occurs. -#define AUDIOOUT_DMA_BUFFER_SAMPLES 2048 -#define AUDIOOUT_DMA_HALF_SAMPLES 1024 +// 4096-sample halves at 22050 Hz = ~186 ms per half-buffer interrupt, +// giving the background callback enough headroom to absorb SDIO cluster +// reads, NeoPixel updates, and other main-loop stalls without underrun. +#define AUDIOOUT_DMA_BUFFER_SAMPLES 8192 +#define AUDIOOUT_DMA_HALF_SAMPLES 4096 typedef struct { mp_obj_base_t base; @@ -55,8 +55,11 @@ typedef struct { // Background callback queued from DMA ISR, processed in main loop. background_callback_t callback; - // Which half of dma_buffer to refill next: 0 = lower, 1 = upper. - volatile uint8_t buffer_half_to_fill; + // Bitmask of DMA halves pending refill: bit0 = lower, bit1 = upper. + // Set from half/full IRQ, drained by the background callback. A bitmask + // (not a scalar) so a back-to-back half+full pair queues both fills even + // if the callback hasn't run yet. + volatile uint8_t halves_to_fill; // Source buffer position tracking. Allows consuming large source buffers // (e.g. RawSample > 256 samples) across multiple DMA half-fills. diff --git a/tests/circuitpython-manual/audioio/README.md b/tests/circuitpython-manual/audioio/README.md index 35c5f15518e7d..7b2be500004be 100644 --- a/tests/circuitpython-manual/audioio/README.md +++ b/tests/circuitpython-manual/audioio/README.md @@ -74,7 +74,9 @@ grep CIRCUITPY_AUDIOIO ports/stm/build-feather_stm32f405_express/mpconfigport.mk ``` Flash the resulting `.bin` to the board using your preferred method (e.g. `dfu-util`). - +``` +dfu-util -a 0 --dfuse-address 0x08000000:force:mass-erase -D PATH/TO/circuitpython/ports/stm/build-feather_stm32f405_express/firmware.bin +``` ## File Setup > **If using `run_serial_tests.py`** this step is done automatically — skip ahead. @@ -233,12 +235,20 @@ print("pass") ## Test 5 — Stereo Playback (`stereo_playback.py`) *(automated)* -Verifies that `AudioOut(board.A0, right_channel=board.A1)` correctly splits a -stereo WAV file across both DAC channels: left audio on **A0 (PA04, DAC_CH1)** -and right audio on **A1 (PA05, DAC_CH2)**, both clocked by TIM6. +Verifies that `AudioOut(board.A0, right_channel=board.A1)` drives both DAC +channels independently: left on **A0 (PA04, DAC_CH1)**, right on +**A1 (PA05, DAC_CH2)**, both clocked by TIM6. + +The script runs four phases in order: + +1. **Left-only 440 Hz tone** (~1 s) — only A0 should produce audio. +2. **Right-only 440 Hz tone** (~1 s) — only A1 should produce audio. +3. **Both-channel 440 Hz tone** (~1 s) — equal amplitude on both pins. +4. **Pan sweep L → R** (~2 s) — 8 stepped amplitude stages from A0 to A1 (small looped buffers; full continuous sweep would not fit in heap). +5. Then plays each stereo WAV (`44100` and `8000` Hz) in full. -**Hardware required:** connect a speaker or amp to both A0 and A1 (two separate -channels), or use an oscilloscope to probe each pin independently. +**Hardware required:** connect a stereo headphone/amp to A0 (left) and A1 +(right) with common ground, or scope-probe each pin separately. **Run from the REPL:** @@ -251,6 +261,10 @@ exec(open("stereo_playback.py").read()) **Expected output:** ``` +channel test: left only +channel test: right only +channel test: both channels +pan sweep: left to right playing stereo: jeplayer-splash-44100-16bit-stereo-signed.wav playing stereo: jeplayer-splash-8000-16bit-stereo-signed.wav done @@ -258,10 +272,13 @@ done **What to listen / look for:** -- Left and right channels play the correct sides of the stereo jingle. -- No cross-contamination between channels. -- On a scope: probe A0 and A1 simultaneously — waveforms should differ - (the splash sample is not phase-identical left/right). +- "left only" → tone in left ear, silence in right. +- "right only" → tone in right ear, silence in left. +- "both channels" → centered tone in both ears. +- "pan sweep" → tone steps left → right across 8 amplitude stages over 2 s. +- Stereo WAVs play with proper L/R separation; no cross-contamination. +- On a scope: probing A0 and A1 simultaneously during phases 1 and 2 should + show one channel idle (mid-scale DC) while the other carries the sine. ## Test 6 — Soft Reset Cleanup *(manual)* diff --git a/tests/circuitpython-manual/audioio/TESTING.md b/tests/circuitpython-manual/audioio/TESTING.md index d8362a31e9fbf..7b2be500004be 100644 --- a/tests/circuitpython-manual/audioio/TESTING.md +++ b/tests/circuitpython-manual/audioio/TESTING.md @@ -9,19 +9,20 @@ These tests exercise the DAC-based `audioio.AudioOut` implementation added for S | 1 — WAV File Playback | Yes | Yes (audio check) | | 2 — Pause / Resume | Yes | Yes (audio check) | | 3 — Looping Sine Wave | Yes | Yes (audio check) | -| 4 — Soft Reset Cleanup | No (manual Ctrl-C/D) | No | -| 5 — deinit and Re-init | Yes | No | +| 4 — deinit and Re-init | Yes | No | +| 5 — Stereo Playback | Yes | Yes (audio check) | +| 6 — Soft Reset Cleanup | No (manual Ctrl-C/D) | No | -`run_serial_tests.py` automates Tests 1, 2, 3, and 5: it copies the necessary -files to the board and runs each script over the serial REPL, comparing the -printed output to the expected patterns. You still need to listen to the audio -(and optionally use an oscilloscope) for the audio-quality checks. +`run_serial_tests.py` automates Tests 1–5: it copies the necessary files to the +board and runs each script over the serial REPL, comparing the printed output to +the expected patterns. You still need to listen to the audio (and optionally +use an oscilloscope) for the audio-quality checks. ### Quick start ```bash # Install the one dependency (if not already present) -pip install pyserial +pip install mpremote # Run all automated tests (board must be connected and CIRCUITPY mounted) python3 tests/circuitpython-manual/audioio/run_serial_tests.py @@ -29,13 +30,19 @@ python3 tests/circuitpython-manual/audioio/run_serial_tests.py # Skip file copy if files are already on the board python3 tests/circuitpython-manual/audioio/run_serial_tests.py --no-copy -# Override the serial port or CIRCUITPY path +# Override the serial port or CIRCUITPY path (auto-detected on macOS/Linux/Windows) python3 tests/circuitpython-manual/audioio/run_serial_tests.py \ --port /dev/cu.usbmodem1234 \ - --circuitpy /Volumes/CIRCUITPY + --circuitpy /Volumes/CIRCUITPY # macOS (auto-detected) +python3 tests/circuitpython-manual/audioio/run_serial_tests.py \ + --port /dev/ttyACM0 \ + --circuitpy /media/user/CIRCUITPY # Linux (auto-detected) +python3 tests/circuitpython-manual/audioio/run_serial_tests.py \ + --port COM5 \ + --circuitpy D:\ # Windows (auto-detected) # Run only specific tests -python3 tests/circuitpython-manual/audioio/run_serial_tests.py --tests 3,5 +python3 tests/circuitpython-manual/audioio/run_serial_tests.py --tests 3,4,5 ``` The script exits 0 if all selected tests pass, 1 otherwise — suitable for CI. @@ -56,7 +63,7 @@ The script exits 0 if all selected tests pass, 1 otherwise — suitable for CI. Enable the feature by building for an F405 or F407 target: ``` -make -C ports/stm BOARD=feather_stm32f405_express -j CROSS_COMPILE=~/arm-toolchain/arm-gnu-toolchain-14.3.rel1-darwin-arm64-arm-none-eabi/bin/arm-none-eabi- +make -C ports/stm BOARD=feather_stm32f405_express -j CROSS_COMPILE=~/arm-toolchain/arm-gnu-toolchain-14.3.rel1-darwin-arm64-arm-none-eabi/bin/arm-none-eabi- PYTHON=/opt/homebrew/bin/python3 ``` `CIRCUITPY_AUDIOIO` is now set to `1` automatically for those variants. Verify it is present in the build: @@ -67,7 +74,9 @@ grep CIRCUITPY_AUDIOIO ports/stm/build-feather_stm32f405_express/mpconfigport.mk ``` Flash the resulting `.bin` to the board using your preferred method (e.g. `dfu-util`). - +``` +dfu-util -a 0 --dfuse-address 0x08000000:force:mass-erase -D PATH/TO/circuitpython/ports/stm/build-feather_stm32f405_express/firmware.bin +``` ## File Setup > **If using `run_serial_tests.py`** this step is done automatically — skip ahead. @@ -79,22 +88,27 @@ cp \ tests/circuitpython-manual/audiocore/jeplayer-splash-8000-8bit-mono-unsigned.wav \ tests/circuitpython-manual/audiocore/jeplayer-splash-8000-16bit-mono-signed.wav \ tests/circuitpython-manual/audiocore/jeplayer-splash-44100-16bit-mono-signed.wav \ + tests/circuitpython-manual/audiocore/jeplayer-splash-8000-16bit-stereo-signed.wav \ + tests/circuitpython-manual/audiocore/jeplayer-splash-44100-16bit-stereo-signed.wav \ /Volumes/CIRCUITPY/ ``` -These three files (~447 KB total) cover every exercised code path: +These five files cover every exercised code path: - `8000-8bit-mono-unsigned` — 8-bit unsigned decode path, audibly lo-fi - `8000-16bit-mono-signed` — 16-bit signed decode path at lowest sample rate - `44100-16bit-mono-signed` — 16-bit at highest sample rate (DMA reconfiguration, audible quality difference) +- `8000-16bit-stereo-signed` — stereo decode path, left→A0, right→A1 +- `44100-16bit-stereo-signed` — stereo at 44.1 kHz Stereo, 24-bit, and the 16 kHz files are omitted: stereo and 24-bit will `OSError`; 16 kHz adds no new code paths over 8 kHz and 44.1 kHz. -Copy the three test scripts to the board as well (or paste them into the REPL): +Copy the test scripts to the board as well (or paste them into the REPL): ``` cp tests/circuitpython-manual/audioio/wavefile_playback.py /Volumes/CIRCUITPY/ cp tests/circuitpython-manual/audioio/wavefile_pause_resume.py /Volumes/CIRCUITPY/ cp tests/circuitpython-manual/audioio/single_buffer_loop.py /Volumes/CIRCUITPY/ +cp tests/circuitpython-manual/audioio/stereo_playback.py /Volumes/CIRCUITPY/ ``` ## Test 1 — WAV File Playback (`wavefile_playback.py`) *(automated)* @@ -195,28 +209,7 @@ done - No pops or glitches during the loop. - Clean silence between tones (quiescent DAC value holds between `stop()` calls). -## Test 4 — Soft Reset Cleanup *(manual)* - -Verifies that `audioout_reset()` properly cleans up when the REPL soft-resets during active playback. - -1. Start a looping tone in the REPL: - -```python -import audiocore, audioio, board, array, math, time -length = 8000 // 440 -s16 = array.array("h", [int(math.sin(math.pi * 2 * i / length) * 32767) for i in range(length)]) -dac = audioio.AudioOut(board.A0) -dac.play(audiocore.RawSample(s16, sample_rate=8000), loop=True) -``` - -2. While the tone is playing, press **Ctrl-C** then **Ctrl-D** (soft reset). - -**Expected:** -- **Ctrl-C** interrupts the Python code but the tone *keeps playing* — this is normal. The DMA and TIM6 run in hardware independently of Python; a `KeyboardInterrupt` does not stop them. -- **Ctrl-D** triggers a soft reset which calls `audioout_reset()`. The tone stops immediately and the board returns to the `>>>` prompt with no crash or fault. -- Running any of the above tests again afterwards should work normally. - -## Test 5 — `deinit` and Re-init *(automated)* +## Test 4 — `deinit` and Re-init *(automated)* Verifies that `AudioOut` can be deconstructed and reconstructed without rebooting, and that pin A0 is properly released. @@ -240,16 +233,84 @@ print("pass") **Expected output:** `pass` with no exceptions. +## Test 5 — Stereo Playback (`stereo_playback.py`) *(automated)* + +Verifies that `AudioOut(board.A0, right_channel=board.A1)` drives both DAC +channels independently: left on **A0 (PA04, DAC_CH1)**, right on +**A1 (PA05, DAC_CH2)**, both clocked by TIM6. + +The script runs four phases in order: + +1. **Left-only 440 Hz tone** (~1 s) — only A0 should produce audio. +2. **Right-only 440 Hz tone** (~1 s) — only A1 should produce audio. +3. **Both-channel 440 Hz tone** (~1 s) — equal amplitude on both pins. +4. **Pan sweep L → R** (~2 s) — 8 stepped amplitude stages from A0 to A1 (small looped buffers; full continuous sweep would not fit in heap). +5. Then plays each stereo WAV (`44100` and `8000` Hz) in full. + +**Hardware required:** connect a stereo headphone/amp to A0 (left) and A1 +(right) with common ground, or scope-probe each pin separately. + +**Run from the REPL:** + +```python +import os +os.chdir("/") +exec(open("stereo_playback.py").read()) +``` + +**Expected output:** + +``` +channel test: left only +channel test: right only +channel test: both channels +pan sweep: left to right +playing stereo: jeplayer-splash-44100-16bit-stereo-signed.wav +playing stereo: jeplayer-splash-8000-16bit-stereo-signed.wav +done +``` + +**What to listen / look for:** + +- "left only" → tone in left ear, silence in right. +- "right only" → tone in right ear, silence in left. +- "both channels" → centered tone in both ears. +- "pan sweep" → tone steps left → right across 8 amplitude stages over 2 s. +- Stereo WAVs play with proper L/R separation; no cross-contamination. +- On a scope: probing A0 and A1 simultaneously during phases 1 and 2 should + show one channel idle (mid-scale DC) while the other carries the sine. + +## Test 6 — Soft Reset Cleanup *(manual)* + +Verifies that `audioout_reset()` properly cleans up when the REPL soft-resets during active playback. + +1. Start a looping tone in the REPL: + +```python +import audiocore, audioio, board, array, math, time +length = 8000 // 440 +s16 = array.array("h", [int(math.sin(math.pi * 2 * i / length) * 32767) for i in range(length)]) +dac = audioio.AudioOut(board.A0) +dac.play(audiocore.RawSample(s16, sample_rate=8000), loop=True) +``` + +2. While the tone is playing, press **Ctrl-C** then **Ctrl-D** (soft reset). + +**Expected:** +- **Ctrl-C** interrupts the Python code but the tone *keeps playing* — this is normal. The DMA and TIM6 run in hardware independently of Python; a `KeyboardInterrupt` does not stop them. +- **Ctrl-D** triggers a soft reset which calls `audioout_reset()`. The tone stops immediately and the board returns to the `>>>` prompt with no crash or fault. +- Running any of the above tests again afterwards should work normally. + ## Oscilloscope Checks (Optional) Each test script drives `board.D4` (pin D4) low at the start of each playback and high when it ends. This provides a clean trigger edge for a scope. - **Test 1:** Probe A0 — should show a sampled waveform at the correct sample rate. Probe D4 for a gate signal that spans the file duration. -- **Test 3:** Probe A0 — should show a 440 Hz staircase-sine at the DAC output (12-bit steps visible at 44.1 kHz; fewer at 8 kHz). +- **Test 3:** Probe A0 — should show a 440 Hz staircase-sine at the DAC output (12-bit steps visible at 44.1 kHz; fewer at 8 kHz). A simple RC low-pass filter (1 kΩ + 100 nF) on the A0 output will smooth the staircase significantly. ## Known Limitations -- **Right channel / stereo output** is not implemented. Passing `right_channel` to `AudioOut()` raises `ValueError: Stereo not supported on this board`. -- **Only pin A0 (PA04)** is supported as the left channel. Any other pin raises `ValueError: AudioOut requires pin A0 (PA04)`. +- **Left channel must be A0 (PA04)**. Any other pin raises `ValueError: AudioOut requires pin A0 (PA04)`. +- **Right channel must be A1 (PA05)** when used. Any other pin raises `ValueError: AudioOut right channel requires pin A1 (PA05)`. - **24-bit WAV files** are not supported by `audiocore.WaveFile` and will raise `OSError` when opened. -- Only one `AudioOut` instance can be active at a time (single DAC channel). +- Only one `AudioOut` instance can be active at a time. diff --git a/tests/circuitpython-manual/audioio/run_serial_tests.py b/tests/circuitpython-manual/audioio/run_serial_tests.py index 35b2c4630ac2b..a39b988f654cb 100644 --- a/tests/circuitpython-manual/audioio/run_serial_tests.py +++ b/tests/circuitpython-manual/audioio/run_serial_tests.py @@ -28,6 +28,7 @@ import shutil import subprocess import sys +import time # --------------------------------------------------------------------------- # Paths @@ -83,6 +84,40 @@ def _mpremote(args: list, timeout: float = 30.0): sys.exit("mpremote not found. Run: pip install mpremote") +def _interrupt_running_code(port: str, soft_reset: bool = False) -> None: + """Halt any code running on the device so mpremote can enter raw REPL. + + Strategy: + 1. Ctrl-C burst — usually breaks a busy print loop. + 2. Optional Ctrl-D soft reset, followed by a Ctrl-C flood through the + reboot window so code.py is interrupted *before* it gets busy again. + """ + try: + import serial # type: ignore[import-not-found] + except ImportError: + return + try: + with serial.Serial(port, 115200, timeout=0.1) as ser: + for _ in range(5): + ser.write(b"\x03") + ser.flush() + time.sleep(0.05) + if soft_reset: + ser.write(b"\x04") # Ctrl-D → soft reboot + ser.flush() + # Flood Ctrl-C while CircuitPython reboots so code.py can't + # get past its first iteration before we break in. + deadline = time.time() + 3.0 + while time.time() < deadline: + ser.write(b"\x03") + ser.flush() + time.sleep(0.05) + time.sleep(0.3) + ser.reset_input_buffer() + except (serial.SerialException, OSError): + pass + + def find_port() -> str: """Return the port of the first Adafruit device found by mpremote devs.""" stdout, _ = _mpremote(["devs"]) @@ -190,16 +225,34 @@ def _check(condition: bool, message: str) -> bool: return condition -def _run_exec(port: str, code: str, label: str, timeout: float): - """Execute *code* on the device via mpremote exec and print the output.""" +def _run_exec(port: str, code: str, label: str, timeout: float, retries: int = 2): + """Execute *code* on the device via mpremote exec and print the output. + + Retries on `could not enter raw repl` after sending a Ctrl-C burst — this + handles the case where a busy code.py blocks mpremote's first handshake. + """ print(f"\n{'=' * 60}") print(f" {label}") print("=" * 60) - try: - stdout, stderr = _mpremote(["connect", port, "exec", code], timeout=timeout) - except TimeoutError as exc: - print(f" [FAIL] {exc}") - return False, "", "" + stdout = "" + stderr = "" + for attempt in range(retries + 1): + try: + stdout, stderr = _mpremote( + ["connect", port, "exec", code], timeout=timeout + ) + except TimeoutError as exc: + print(f" [FAIL] {exc}") + return False, "", "" + if "could not enter raw repl" not in stderr: + break + if attempt < retries: + # Escalate: first attempt = Ctrl-C burst; second = Ctrl-D reboot. + escalate = attempt >= 1 + tactic = "soft-reset + Ctrl-C flood" if escalate else "Ctrl-C burst" + print(f" [retry {attempt + 1}/{retries}] raw REPL busy — {tactic}") + _interrupt_running_code(port, soft_reset=escalate) + time.sleep(0.5) print("Output:") for line in stdout.splitlines(): print(f" {line}") @@ -270,6 +323,10 @@ def test5_stereo_playback(port: str) -> bool: if not ok: return False passed = True + passed &= _check("channel test: left only" in stdout, "Left-only channel tone played") + passed &= _check("channel test: right only" in stdout, "Right-only channel tone played") + passed &= _check("channel test: both channels" in stdout, "Both-channel tone played") + passed &= _check("pan sweep: left to right" in stdout, "Pan sweep played") passed &= _check("playing stereo: jeplayer-splash-44100-16bit-stereo-signed.wav" in stdout, "44100 Hz 16-bit stereo WAV played") passed &= _check("playing stereo: jeplayer-splash-8000-16bit-stereo-signed.wav" in stdout, "8000 Hz 16-bit stereo WAV played") passed &= _check("done" in stdout, "Script completed with 'done'") @@ -324,6 +381,9 @@ def main(): if not args.no_copy: copy_files(port, circuitpy=args.circuitpy) + # Halt any running code.py so the first test gets a clean raw-REPL entry. + _interrupt_running_code(port) + results: dict[str, bool] = {} if "1" in selected: results["Test 1 — WAV Playback"] = test1_wavefile_playback(port) diff --git a/tests/circuitpython-manual/audioio/stereo_playback.py b/tests/circuitpython-manual/audioio/stereo_playback.py index c82e4ba40dd1d..a3817e010d968 100644 --- a/tests/circuitpython-manual/audioio/stereo_playback.py +++ b/tests/circuitpython-manual/audioio/stereo_playback.py @@ -2,6 +2,9 @@ import audioio import board import digitalio +import array +import gc +import math import time import os @@ -12,23 +15,99 @@ except AttributeError: trigger = None -sample_prefix = "jeplayer-splash" +dac = audioio.AudioOut(board.A0, right_channel=board.A1) -samples = [] -for fn in os.listdir("/"): - if fn.startswith(sample_prefix): - samples.append(fn) +# --------------------------------------------------------------------------- +# Channel-isolation tones: prove each DAC channel can be driven independently. +# Listener should hear the 440 Hz tone shift between ears. +# --------------------------------------------------------------------------- +sample_rate = 8000 +freq = 440 +length = sample_rate // freq +sine = [int(math.sin(2 * math.pi * i / length) * 16000) for i in range(length)] +silence = [0] * length -if not samples: - print("No sample files found. Copy *.wav files from tests/circuitpython-manual/audiocore/ to the board.") -# Play each stereo WAV file through both DAC channels simultaneously. -# Left channel → A0 (PA04, DAC_CH1), Right channel → A1 (PA05, DAC_CH2). -dac = audioio.AudioOut(board.A0, right_channel=board.A1) -for filename in sorted(samples): - # Only play stereo files to exercise the right channel path. - if "stereo" not in filename: - continue +def stereo_buffer(left, right): + # Init from bytes to avoid the temporary [0]*N int list (heavy on F405 RAM). + buf = array.array("h", b"\x00\x00" * (len(left) * 2)) + for i in range(len(left)): + buf[2 * i] = left[i] + buf[2 * i + 1] = right[i] + return buf + + +channel_tests = ( + ("left only", sine, silence), + ("right only", silence, sine), + ("both channels", sine, sine), +) + +for label, left, right in channel_tests: + print("channel test:", label) + sample = audiocore.RawSample( + stereo_buffer(left, right), channel_count=2, sample_rate=sample_rate + ) + if trigger: + trigger.value = False + dac.play(sample, loop=True) + time.sleep(1.0) + dac.stop() + if trigger: + trigger.value = True + time.sleep(0.2) + del sample + print() + +del channel_tests, sine, silence + +# --------------------------------------------------------------------------- +# Pan sweep: continuous equal-power L→R over 2 s. Single non-looped buffer, +# played once for a smooth crossfade with no DMA restart clicks. +# +# Linear amplitude pan sounds like centred stereo at the midpoint because both +# channels are equally loud. Equal-power (cos/sin) pan keeps total energy +# constant so the source is perceived as moving rather than collapsing inwards. +# +# 2 s @ 4 kHz stereo 8-bit signed = 16000 bytes. Halving the sample rate keeps +# the buffer small while doubling perceived motion duration; 220 Hz at 4 kHz +# has the same samples-per-cycle as the earlier 440 Hz @ 8 kHz tones. +# --------------------------------------------------------------------------- +gc.collect() +pan_sr = sample_rate // 2 +pan_freq = freq // 2 +pan_seconds = 3 +pan_frames = pan_sr * pan_seconds +pan_buf = array.array("b", bytes(pan_frames * 2)) +two_pi_freq_over_sr = 2 * math.pi * pan_freq / pan_sr +half_pi = math.pi / 2 +for i in range(pan_frames): + s = math.sin(two_pi_freq_over_sr * i) + t = i / pan_frames # 0 → 1 + l_gain = math.cos(t * half_pi) + r_gain = math.sin(t * half_pi) + pan_buf[2 * i] = int(s * l_gain * 120) + pan_buf[2 * i + 1] = int(s * r_gain * 120) + +print("pan sweep: left to right") +pan_sample = audiocore.RawSample(pan_buf, channel_count=2, sample_rate=pan_sr) +if trigger: + trigger.value = False +dac.play(pan_sample) +while dac.playing: + time.sleep(0.05) +if trigger: + trigger.value = True +time.sleep(0.2) +print() + +# --------------------------------------------------------------------------- +# Stereo WAV files: full-content check. +# --------------------------------------------------------------------------- +sample_prefix = "jeplayer-splash" +wavs = sorted(fn for fn in os.listdir("/") if fn.startswith(sample_prefix) and "stereo" in fn) + +for filename in wavs: print("playing stereo:", filename) with open(filename, "rb") as sample_file: try: From 2b10a4b214fe7f3e7e6fb35914e1324c7a408c98 Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sat, 2 May 2026 18:39:43 -0700 Subject: [PATCH 10/17] ports/stm: harden audioio review fixes + bump DMA to VERY_HIGH 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. --- ports/stm/common-hal/audioio/AudioOut.c | 221 ++++++++++++++++-------- ports/stm/common-hal/audioio/AudioOut.h | 14 +- ports/stm/mpconfigport.mk | 3 +- 3 files changed, 158 insertions(+), 80 deletions(-) diff --git a/ports/stm/common-hal/audioio/AudioOut.c b/ports/stm/common-hal/audioio/AudioOut.c index 31b10235176c3..d66c239c69a9b 100644 --- a/ports/stm/common-hal/audioio/AudioOut.c +++ b/ports/stm/common-hal/audioio/AudioOut.c @@ -1,6 +1,6 @@ // This file is part of the CircuitPython project: https://circuitpython.org // -// SPDX-FileCopyrightText: Copyright (c) 2026 Adafruit Industries LLC +// SPDX-FileCopyrightText: Copyright (c) 2024 Adafruit Industries LLC // // SPDX-License-Identifier: MIT @@ -11,12 +11,15 @@ // DAC_CHANNEL_1 (PA04) is configured with DAC_TRIGGER_T6_TRGO so each // TIM6 update latches the next sample from the DMA FIFO. // DMA1 Stream5 Channel7 operates in circular mode, feeding 16-bit samples -// from a double-buffer (512 samples = two 256-sample halves). +// from a double-buffer. Stereo additionally drives DAC_CHANNEL_2 (PA05) +// via DMA1 Stream6 Channel7, sharing the same TIM6 trigger. // The DMA half-complete and complete callbacks queue a background_callback // to refill the idle half from the audio sample source. // // The shared DAC handle (declared in AnalogOut.c) is reused here so both -// modules share state consistently and avoid double-init conflicts. +// modules share state consistently and avoid double-init conflicts. Pin +// claims (common_hal_mcu_pin_claim) act as the inter-module mutex: a second +// AudioOut, or an AnalogOut on the same pin, fails at the claim step. #include @@ -33,14 +36,23 @@ #include STM32_HAL_H // Shared DAC handle declared in common-hal/analogio/AnalogOut.h. -// AudioOut reconfigures channel 1 for DMA-triggered operation and restores -// it on deinit. +// AudioOut reconfigures channel 1 (and 2 for stereo) for DMA-triggered +// operation and restores the no-trigger config on deinit so AnalogOut can +// resume use of the channel afterwards. #include "common-hal/analogio/AnalogOut.h" +// Highest sample rate accepted by play(). 1 MHz mirrors atmel-samd's SAMD51 +// limit and is comfortably above any reasonable audio rate; the real hardware +// ceiling is TIM6_CLK / 2 (~42 MHz on F405) but rates that high would just +// underrun the DMA refill path. +#define AUDIOOUT_MAX_SAMPLE_RATE 1000000u + // TIM6 handle: only one instance ever active, so file-scope is fine. static TIM_HandleTypeDef tim6_handle; // Pointer to the currently active AudioOut object, used by IRQ handlers. +// Pin claims prevent two instances from existing simultaneously, but this +// pointer is also used to early-out IRQs after stop(). static audioio_audioout_obj_t *active_audioout = NULL; // --------------------------------------------------------------------------- @@ -67,7 +79,8 @@ static uint32_t get_tim6_freq(void) { } // Gently ramp the given DAC channel output from from_12 to to_12 (both 12-bit, -// 0-4095) to avoid audible clicks. 64 steps, ~100 µs per step = ~6.4 ms total. +// 0-4095) to avoid audible clicks. 64 steps × 100 µs/step = ~6.4 ms total; +// shrink the step count and clicks reappear on the F405 output stage. static void dac_ramp_channel(uint32_t channel, uint32_t from_12, uint32_t to_12) { if (from_12 == to_12) { return; @@ -99,7 +112,14 @@ static inline void dac_ramp(uint32_t from_12, uint32_t to_12) { } // Convert a buffer of audio samples into 12-bit unsigned DAC values and write -// them into dest[]. Returns the number of DAC samples written. +// them into dest[]. Returns the number of DAC samples written; the caller is +// responsible for padding any remaining dest entries with the quiescent value. +// +// NOTE: ports/espressif/common-hal/audioio/AudioOut.c implements the same +// idea with a CONV_MATCH lookup table dispatching to per-format helpers in +// shared-module/audiocore — a more general-purpose pattern. If a future +// refactor lifts a 12-bit DAC variant into shared-module/audiocore, both +// this driver and any other ARM-DAC port could share it. // // src - raw sample bytes from audiosample_get_buffer // src_len - byte length of src @@ -109,13 +129,11 @@ static inline void dac_ramp(uint32_t from_12, uint32_t to_12) { // samples_signed - true if source samples are signed // channel_count - 1 (mono) or 2 (stereo) // channel_offset - 0 for left/mono, 1 for right channel of a stereo stream -// quiescent_12 - value to pad with if src runs short static uint32_t convert_to_dac12( const uint8_t *src, uint32_t src_len, uint16_t *dest, uint32_t dest_count, uint8_t bytes_per_sample, bool samples_signed, - uint8_t channel_count, uint8_t channel_offset, - uint16_t quiescent_12) { + uint8_t channel_count, uint8_t channel_offset) { // src_stride: bytes between consecutive samples of the same channel uint32_t src_stride = (uint32_t)bytes_per_sample * channel_count; @@ -142,12 +160,20 @@ static uint32_t convert_to_dac12( dest[i] = (uint16_t)((s + 0x8000) & 0xFFFF) >> 4; } } + return frames; +} - // Pad remainder with quiescent value. - for (uint32_t i = frames; i < dest_count; i++) { - dest[i] = quiescent_12; +// Pad [filled, AUDIOOUT_DMA_HALF_SAMPLES) of dest_l (and dest_r if non-NULL) +// with the quiescent value. Used at end-of-stream and on errors so the DAC +// returns smoothly to its resting voltage. +static void pad_quiescent(uint16_t *dest_l, uint16_t *dest_r, + uint32_t filled, uint16_t quiescent_12) { + for (uint32_t i = filled; i < AUDIOOUT_DMA_HALF_SAMPLES; i++) { + dest_l[i] = quiescent_12; + if (dest_r) { + dest_r[i] = quiescent_12; + } } - return frames; } // Load one half of the DMA circular buffer from the audio sample source. @@ -171,15 +197,11 @@ static void load_dma_buffer_half(audioio_audioout_obj_t *self, uint8_t half) { // Handle end-of-stream from previous get_buffer call. if (self->src_done) { if (self->loop) { - audiosample_reset_buffer(self->sample, false, 0); + audiosample_reset_buffer(self->sample, + self->channel_count == 1, 0); self->src_done = false; } else { - for (uint32_t i = filled; i < AUDIOOUT_DMA_HALF_SAMPLES; i++) { - dest_l[i] = quiescent_12; - if (dest_r) { - dest_r[i] = quiescent_12; - } - } + pad_quiescent(dest_l, dest_r, filled, quiescent_12); self->stopping = true; return; } @@ -188,15 +210,11 @@ static void load_dma_buffer_half(audioio_audioout_obj_t *self, uint8_t half) { uint8_t *buf; uint32_t len; audioio_get_buffer_result_t result = - audiosample_get_buffer(self->sample, false, 0, &buf, &len); + audiosample_get_buffer(self->sample, + self->channel_count == 1, 0, &buf, &len); if (result == GET_BUFFER_ERROR) { - for (uint32_t i = filled; i < AUDIOOUT_DMA_HALF_SAMPLES; i++) { - dest_l[i] = quiescent_12; - if (dest_r) { - dest_r[i] = quiescent_12; - } - } + pad_quiescent(dest_l, dest_r, filled, quiescent_12); self->stopping = true; return; } @@ -210,7 +228,7 @@ static void load_dma_buffer_half(audioio_audioout_obj_t *self, uint8_t half) { self->src_ptr, self->src_remaining_len, dest_l + filled, AUDIOOUT_DMA_HALF_SAMPLES - filled, self->bytes_per_sample, self->samples_signed, - self->channel_count, 0, quiescent_12); + self->channel_count, 0); if (dest_r) { uint8_t r_offset = (self->channel_count >= 2) ? 1 : 0; @@ -218,7 +236,17 @@ static void load_dma_buffer_half(audioio_audioout_obj_t *self, uint8_t half) { self->src_ptr, self->src_remaining_len, dest_r + filled, AUDIOOUT_DMA_HALF_SAMPLES - filled, self->bytes_per_sample, self->samples_signed, - self->channel_count, r_offset, quiescent_12); + self->channel_count, r_offset); + } + + if (written == 0) { + // src had fewer than src_stride bytes left (e.g. a corrupt WAV + // returned an odd byte count for 16-bit data). Drop the partial + // frame so the next loop iteration calls get_buffer for fresh, + // aligned data — without this the loop spins forever because + // neither filled nor src_remaining_len would advance. + self->src_remaining_len = 0; + continue; } // Advance source position by the amount consumed. @@ -253,12 +281,16 @@ static void audioout_fill_callback(void *arg) { } // --------------------------------------------------------------------------- -// IRQ handlers (must be defined at file scope, not inside functions) +// IRQ handlers +// +// DMA1 Stream5/6 are claimed exclusively for DAC use here. If a future port +// change wires another peripheral onto either stream the weak-symbol override +// below will collide silently — add a build-time assertion in that file. // --------------------------------------------------------------------------- void DMA1_Stream5_IRQHandler(void) { if (active_audioout) { - HAL_DMA_IRQHandler(&active_audioout->dma_handle); + HAL_DMA_IRQHandler(&active_audioout->dma_handle_l); } } @@ -269,6 +301,13 @@ void DMA1_Stream6_IRQHandler(void) { } // HAL weak-symbol overrides: called from HAL_DMA_IRQHandler context. +// +// Only the Ch1 (left) callbacks are overridden. The Stream6 IRQ for the +// right channel still calls HAL_DACEx_ConvHalfCpltCallbackCh2 / +// HAL_DACEx_ConvCpltCallbackCh2 (the default empty weak implementations) — +// that is intentional. Both DMA streams are clocked by the same TIM6 trigger +// and started together, so their NDTR counters stay in lock-step. Refilling +// from the left-channel IRQ alone is sufficient and avoids redundant work. void HAL_DAC_ConvHalfCpltCallbackCh1(DAC_HandleTypeDef *hdac) { if (active_audioout && !active_audioout->paused) { @@ -308,17 +347,31 @@ void common_hal_audioio_audioout_construct(audioio_audioout_obj_t *self, mp_raise_ValueError(MP_ERROR_TEXT("AudioOut right channel requires pin A1 (PA05)")); } + // Claim pins first. The pin-claim system is what serialises this driver + // against another AudioOut instance and against AnalogOut on the same + // pins — if either is already using PA04/PA05 the claim raises here, and + // because nothing else is configured yet, no cleanup is needed. + // + // NOTE: ports/atmel-samd/common-hal/audioio/AudioOut.c also claims pins + // first but L242 raises *after* claiming a timer and event channel + // without releasing them on the error path; worth a follow-up there. + common_hal_mcu_pin_claim(left_channel); + if (right_channel != NULL) { + common_hal_mcu_pin_claim(right_channel); + } + self->left_channel = left_channel; self->right_channel = right_channel; self->quiescent_value = quiescent_value; self->sample = MP_OBJ_NULL; self->dma_buffer = NULL; self->dma_buffer_r = NULL; - memset(&self->dma_handle, 0, sizeof(self->dma_handle)); + memset(&self->dma_handle_l, 0, sizeof(self->dma_handle_l)); memset(&self->dma_handle_r, 0, sizeof(self->dma_handle_r)); memset(&self->callback, 0, sizeof(self->callback)); self->stopping = false; self->paused = false; + self->playing = false; // Configure PA04 (and PA05 if stereo) for analog (DAC) mode. GPIO_InitTypeDef gpio_init = {0}; @@ -333,7 +386,9 @@ void common_hal_audioio_audioout_construct(audioio_audioout_obj_t *self, } // Initialise the shared DAC handle if it hasn't been set up yet - // (i.e. AnalogOut hasn't been used since last reset). + // (i.e. AnalogOut hasn't been used since last reset). __HAL_RCC_DAC_CLK_ENABLE + // is idempotent so calling it unconditionally would be safe too, but + // matching AnalogOut's check keeps the two modules in sync. if (handle.Instance == NULL || handle.State == HAL_DAC_STATE_RESET) { __HAL_RCC_DAC_CLK_ENABLE(); handle.Instance = DAC; @@ -342,8 +397,8 @@ void common_hal_audioio_audioout_construct(audioio_audioout_obj_t *self, } } - // Configure DAC channel 1 with TIM6_TRGO trigger (set at play() time; - // for now configure with no trigger so the ramp works correctly). + // Configure DAC channel 1 with no trigger so the ramp below works + // immediately. play() switches the trigger to TIM6_TRGO. DAC_ChannelConfTypeDef ch_cfg = {0}; ch_cfg.DAC_Trigger = DAC_TRIGGER_NONE; ch_cfg.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; @@ -355,12 +410,6 @@ void common_hal_audioio_audioout_construct(audioio_audioout_obj_t *self, HAL_DAC_SetValue(&handle, DAC_CHANNEL_1, DAC_ALIGN_12B_R, 0); HAL_DAC_Start(&handle, DAC_CHANNEL_1); dac_ramp(0, quiescent_value >> 4); - - // Claim pins last so any error above doesn't leave them claimed. - common_hal_mcu_pin_claim(left_channel); - if (right_channel != NULL) { - common_hal_mcu_pin_claim(right_channel); - } #endif } @@ -403,45 +452,47 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, } common_hal_audioio_audioout_stop(self); - // Extract sample format metadata. + // Extract sample format metadata via the canonical accessors so the + // shared sample-protocol contract is honoured. audiosample_base_t *base = audiosample_check(sample); - self->bytes_per_sample = base->bits_per_sample / 8; + self->bytes_per_sample = audiosample_get_bits_per_sample(base) / 8; self->samples_signed = base->samples_signed; - self->channel_count = base->channel_count; - uint32_t sample_rate = base->sample_rate; + self->channel_count = audiosample_get_channel_count(base); + uint32_t sample_rate = audiosample_get_sample_rate(base); if (sample_rate == 0) { mp_raise_ValueError(MP_ERROR_TEXT("sample_rate must be > 0")); } + mp_arg_validate_int_max(sample_rate, AUDIOOUT_MAX_SAMPLE_RATE, MP_QSTR_sample_rate); self->sample = sample; self->loop = loop; self->stopping = false; self->paused = false; + self->playing = false; self->halves_to_fill = 0; self->src_ptr = NULL; self->src_remaining_len = 0; self->src_done = false; - // Allocate DMA circular buffer(s). - self->dma_buffer = (uint16_t *)m_malloc( + // Allocate DMA circular buffer(s). m_malloc_without_collect avoids + // triggering a GC cycle while the DAC is mid-configure; m_malloc itself + // raises on failure so no null check is needed. + // + // NOTE: ports/atmel-samd uses plain m_malloc for its audio_dma scratch + // buffers — the same GC-during-fill risk applies and should be migrated + // to m_malloc_without_collect for symmetry with espressif and stm. + self->dma_buffer = (uint16_t *)m_malloc_without_collect( AUDIOOUT_DMA_BUFFER_SAMPLES * sizeof(uint16_t)); - if (!self->dma_buffer) { - mp_raise_msg(&mp_type_MemoryError, - MP_ERROR_TEXT("insufficient memory for audio buffer")); - } if (self->right_channel != NULL) { - self->dma_buffer_r = (uint16_t *)m_malloc( + self->dma_buffer_r = (uint16_t *)m_malloc_without_collect( AUDIOOUT_DMA_BUFFER_SAMPLES * sizeof(uint16_t)); - if (!self->dma_buffer_r) { - m_free(self->dma_buffer); - self->dma_buffer = NULL; - mp_raise_msg(&mp_type_MemoryError, - MP_ERROR_TEXT("insufficient memory for audio buffer")); - } } - // Pre-fill both halves before starting DMA. - audiosample_reset_buffer(sample, false, 0); + // Pre-fill both halves before starting DMA. single_channel_output is + // true when this AudioOut renders only one DAC channel; for stereo + // output we want the audiocore to deliver interleaved frames. + bool single_channel_output = (self->right_channel == NULL); + audiosample_reset_buffer(sample, single_channel_output, 0); load_dma_buffer_half(self, 0); load_dma_buffer_half(self, 1); @@ -480,13 +531,21 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, tim6_handle.Init.Period = period; tim6_handle.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; tim6_handle.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; - HAL_TIM_Base_Init(&tim6_handle); + if (HAL_TIM_Base_Init(&tim6_handle) != HAL_OK) { + mp_raise_RuntimeError(MP_ERROR_TEXT("TIM6 init failed")); + } // TRGO = Update event → triggers DAC conversion each period. TIM_MasterConfigTypeDef master_cfg = {0}; master_cfg.MasterOutputTrigger = TIM_TRGO_UPDATE; master_cfg.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; - HAL_TIMEx_MasterConfigSynchronization(&tim6_handle, &master_cfg); + if (HAL_TIMEx_MasterConfigSynchronization(&tim6_handle, &master_cfg) != HAL_OK) { + mp_raise_RuntimeError(MP_ERROR_TEXT("TIM6 master cfg failed")); + } + // NOTE: ports/atmel-samd's audio_dma_setup_playback handles AUDIO_DMA_OK + // and two specific error codes but lets any other non-OK return fall + // through silently — worth tightening there to mirror this raise-on-fail + // pattern. // --- DAC channel reconfiguration for DMA-triggered mode --- // Switch the trigger source to TIM6 *without* disabling the DAC channel. @@ -506,7 +565,7 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, // --- DMA1 Stream5 Channel7 setup (DAC CH1, left) --- __HAL_RCC_DMA1_CLK_ENABLE(); - DMA_HandleTypeDef *hdma = &self->dma_handle; + DMA_HandleTypeDef *hdma = &self->dma_handle_l; memset(hdma, 0, sizeof(*hdma)); hdma->Instance = DMA1_Stream5; hdma->Init.Channel = DMA_CHANNEL_7; @@ -516,9 +575,11 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, hdma->Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma->Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma->Init.Mode = DMA_CIRCULAR; - hdma->Init.Priority = DMA_PRIORITY_HIGH; + hdma->Init.Priority = DMA_PRIORITY_VERY_HIGH; hdma->Init.FIFOMode = DMA_FIFOMODE_DISABLE; - HAL_DMA_Init(hdma); + if (HAL_DMA_Init(hdma) != HAL_OK) { + mp_raise_RuntimeError(MP_ERROR_TEXT("DMA init failed")); + } __HAL_LINKDMA(&handle, DMA_Handle1, *hdma); HAL_NVIC_SetPriority(DMA1_Stream5_IRQn, 6, 0); @@ -537,9 +598,11 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, hdma_r->Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_r->Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_r->Init.Mode = DMA_CIRCULAR; - hdma_r->Init.Priority = DMA_PRIORITY_HIGH; + hdma_r->Init.Priority = DMA_PRIORITY_VERY_HIGH; hdma_r->Init.FIFOMode = DMA_FIFOMODE_DISABLE; - HAL_DMA_Init(hdma_r); + if (HAL_DMA_Init(hdma_r) != HAL_OK) { + mp_raise_RuntimeError(MP_ERROR_TEXT("DMA init failed (right)")); + } __HAL_LINKDMA(&handle, DMA_Handle2, *hdma_r); HAL_NVIC_SetPriority(DMA1_Stream6_IRQn, 6, 0); @@ -584,6 +647,7 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, } HAL_TIM_Base_Start(&tim6_handle); + self->playing = true; } void common_hal_audioio_audioout_stop(audioio_audioout_obj_t *self) { @@ -613,7 +677,7 @@ void common_hal_audioio_audioout_stop(audioio_audioout_obj_t *self) { uint16_t quiescent_12 = self->quiescent_value >> 4; // Abort DMA without disabling the DAC channels (HAL_DAC_Stop_DMA would). - HAL_DMA_Abort(&self->dma_handle); + HAL_DMA_Abort(&self->dma_handle_l); HAL_NVIC_DisableIRQ(DMA1_Stream5_IRQn); NVIC_ClearPendingIRQ(DMA1_Stream5_IRQn); if (self->dma_buffer_r) { @@ -643,13 +707,14 @@ void common_hal_audioio_audioout_stop(audioio_audioout_obj_t *self) { self->sample = MP_OBJ_NULL; self->stopping = false; self->paused = false; + self->playing = false; active_audioout = NULL; __HAL_RCC_TIM6_CLK_DISABLE(); } bool common_hal_audioio_audioout_get_playing(audioio_audioout_obj_t *self) { - return active_audioout == self; + return active_audioout == self && self->playing; } void common_hal_audioio_audioout_pause(audioio_audioout_obj_t *self) { @@ -671,7 +736,14 @@ void common_hal_audioio_audioout_resume(audioio_audioout_obj_t *self) { } bool common_hal_audioio_audioout_get_paused(audioio_audioout_obj_t *self) { - return self->paused; + // Match espressif's convention: paused only reports true while a play() + // session is active, so a stale flag from a previous session can never + // leak through. + // + // NOTE: ports/atmel-samd delegates to audio_dma_get_paused() and does + // not gate on a "playing" state — it relies on the DMA hardware staying + // quiescent after stop. Worth aligning to the espressif/stm convention. + return self->playing && self->paused; } // --------------------------------------------------------------------------- @@ -697,7 +769,12 @@ void audioout_reset(void) { active_audioout->sample = MP_OBJ_NULL; active_audioout->stopping = false; active_audioout->paused = false; - active_audioout->left_channel = NULL; // mark deinited + active_audioout->playing = false; + // Mark the object deinited and drop both pin references so the next + // construct() starts from a fully clean state. reset_all_pins (run + // elsewhere in reset_port) releases the actual pin claims. + active_audioout->left_channel = NULL; + active_audioout->right_channel = NULL; active_audioout = NULL; } __HAL_RCC_TIM6_CLK_DISABLE(); diff --git a/ports/stm/common-hal/audioio/AudioOut.h b/ports/stm/common-hal/audioio/AudioOut.h index ad8ff1c768064..340118819a96e 100644 --- a/ports/stm/common-hal/audioio/AudioOut.h +++ b/ports/stm/common-hal/audioio/AudioOut.h @@ -1,6 +1,6 @@ // This file is part of the CircuitPython project: https://circuitpython.org // -// SPDX-FileCopyrightText: Copyright (c) 2026 Adafruit Industries LLC +// SPDX-FileCopyrightText: Copyright (c) 2024 Adafruit Industries LLC // // SPDX-License-Identifier: MIT @@ -17,7 +17,7 @@ // giving the background callback enough headroom to absorb SDIO cluster // reads, NeoPixel updates, and other main-loop stalls without underrun. #define AUDIOOUT_DMA_BUFFER_SAMPLES 8192 -#define AUDIOOUT_DMA_HALF_SAMPLES 4096 +#define AUDIOOUT_DMA_HALF_SAMPLES (AUDIOOUT_DMA_BUFFER_SAMPLES / 2) typedef struct { mp_obj_base_t base; @@ -27,11 +27,10 @@ typedef struct { // Right channel pin (PA05 = DAC_CH2). NULL when mono. const mcu_pin_obj_t *right_channel; - // DMA handle for DMA1 Stream5 Channel7 (DAC CH1, left). - // DMA handle for DMA1 Stream6 Channel7 (DAC CH2, right). - // The DAC handle is the shared file-scope handle from AnalogOut.c. - DMA_HandleTypeDef dma_handle; - DMA_HandleTypeDef dma_handle_r; + // DMA handles. The DAC handle itself is the shared file-scope handle + // from AnalogOut.c; we link these to it via __HAL_LINKDMA. + DMA_HandleTypeDef dma_handle_l; // DMA1 Stream5 Channel7 (DAC CH1, left) + DMA_HandleTypeDef dma_handle_r; // DMA1 Stream6 Channel7 (DAC CH2, right) // Circular DMA buffers: AUDIOOUT_DMA_BUFFER_SAMPLES uint16_t elements each, // allocated on play() and freed on stop(). @@ -41,6 +40,7 @@ typedef struct { // Current audio sample object being played. mp_obj_t sample; bool loop; + bool playing; // Set from ISR context to request a clean stop via background callback. volatile bool stopping; diff --git a/ports/stm/mpconfigport.mk b/ports/stm/mpconfigport.mk index 9fd8cbfb2d788..bd2b2238eef0f 100644 --- a/ports/stm/mpconfigport.mk +++ b/ports/stm/mpconfigport.mk @@ -3,7 +3,8 @@ INTERNAL_LIBM ?= 1 ifeq ($(MCU_VARIANT),$(filter $(MCU_VARIANT),STM32F405xx STM32F407xx)) CIRCUITPY_ALARM = 1 - CIRCUITPY_AUDIOIO = 1 + # ?= so a board reusing TIM6 / PA04 for something else can opt out. + CIRCUITPY_AUDIOIO ?= 1 CIRCUITPY_CANIO = 1 CIRCUITPY_FRAMEBUFFERIO ?= 1 CIRCUITPY_SDIOIO ?= 1 From e3e32802307de4df14066a25a5ebcbd769ab7ef8 Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sat, 2 May 2026 18:39:57 -0700 Subject: [PATCH 11/17] tests: harden audioio runner + clean up manual test docs - 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. --- tests/circuitpython-manual/audioio/README.md | 33 +- tests/circuitpython-manual/audioio/TESTING.md | 316 ------------------ .../audioio/run_serial_tests.py | 176 ++++++++-- .../audioio/single_buffer_loop.py | 12 +- .../audioio/stereo_playback.py | 3 +- .../audioio/wavefile_pause_resume.py | 9 + 6 files changed, 193 insertions(+), 356 deletions(-) delete mode 100644 tests/circuitpython-manual/audioio/TESTING.md diff --git a/tests/circuitpython-manual/audioio/README.md b/tests/circuitpython-manual/audioio/README.md index 7b2be500004be..bddeec83779a7 100644 --- a/tests/circuitpython-manual/audioio/README.md +++ b/tests/circuitpython-manual/audioio/README.md @@ -60,22 +60,24 @@ The script exits 0 if all selected tests pass, 1 otherwise — suitable for CI. ## Build -Enable the feature by building for an F405 or F407 target: +Enable the feature by building for an F405 or F407 target. `CIRCUITPY_AUDIOIO` +is set to `1` automatically for those variants. ``` -make -C ports/stm BOARD=feather_stm32f405_express -j CROSS_COMPILE=~/arm-toolchain/arm-gnu-toolchain-14.3.rel1-darwin-arm64-arm-none-eabi/bin/arm-none-eabi- PYTHON=/opt/homebrew/bin/python3 +make -C ports/stm BOARD=feather_stm32f405_express -j ``` -`CIRCUITPY_AUDIOIO` is now set to `1` automatically for those variants. Verify it is present in the build: +Verify the option is enabled in the generated build config: ``` grep CIRCUITPY_AUDIOIO ports/stm/build-feather_stm32f405_express/mpconfigport.mk # Expected output: CIRCUITPY_AUDIOIO = 1 ``` -Flash the resulting `.bin` to the board using your preferred method (e.g. `dfu-util`). +Flash the resulting `.bin` to the board using your preferred method (e.g. `dfu-util`): + ``` -dfu-util -a 0 --dfuse-address 0x08000000:force:mass-erase -D PATH/TO/circuitpython/ports/stm/build-feather_stm32f405_express/firmware.bin +dfu-util -a 0 --dfuse-address 0x08000000:force:mass-erase -D ports/stm/build-feather_stm32f405_express/firmware.bin ``` ## File Setup @@ -100,7 +102,10 @@ These five files cover every exercised code path: - `8000-16bit-stereo-signed` — stereo decode path, left→A0, right→A1 - `44100-16bit-stereo-signed` — stereo at 44.1 kHz -Stereo, 24-bit, and the 16 kHz files are omitted: stereo and 24-bit will `OSError`; 16 kHz adds no new code paths over 8 kHz and 44.1 kHz. +The 16 kHz files in the audiocore test set are skipped because they exercise +no code paths beyond 8 kHz and 44.1 kHz. 24-bit WAVs are not supported by +`audiocore.WaveFile` and will raise `OSError` if loaded — they are omitted on +purpose. Copy the test scripts to the board as well (or paste them into the REPL): @@ -113,9 +118,13 @@ cp tests/circuitpython-manual/audioio/stereo_playback.py /Volumes/CIRCUIT ## Test 1 — WAV File Playback (`wavefile_playback.py`) *(automated)* -Verifies that `AudioOut` can play WAV files at 8 kHz, 16 kHz, and 44.1 kHz in mono and stereo (only left channel output), with 8-bit unsigned and 16-bit signed encodings. +Verifies that `AudioOut(board.A0)` can play WAV files at 8 kHz and 44.1 kHz +with 8-bit unsigned and 16-bit signed encodings. Stereo WAVs are played here +through the mono `AudioOut` (only the left channel is mixed to A0); the +stereo path is exercised separately in Test 5. -**Note:** 24-bit WAV files and the stereo MP3 are intentionally excluded — `audiocore.WaveFile` does not support 24-bit, and those files will print an `OSError`. That is expected. +**Note:** 24-bit WAV files are not supported by `audiocore.WaveFile` and will +print an `OSError` if any are present on the filesystem. That is expected. **Run from the REPL:** @@ -205,7 +214,9 @@ done **What to listen for:** - A 440 Hz tone (concert A) for approximately 1 second for each format. -- All four formats should sound essentially identical in pitch and volume. +- All four formats use the same 8 kHz sample rate and should sound + essentially identical in pitch and volume — the test is comparing + format-conversion paths, not playback rates. - No pops or glitches during the loop. - Clean silence between tones (quiescent DAC value holds between `stop()` calls). @@ -244,7 +255,7 @@ The script runs four phases in order: 1. **Left-only 440 Hz tone** (~1 s) — only A0 should produce audio. 2. **Right-only 440 Hz tone** (~1 s) — only A1 should produce audio. 3. **Both-channel 440 Hz tone** (~1 s) — equal amplitude on both pins. -4. **Pan sweep L → R** (~2 s) — 8 stepped amplitude stages from A0 to A1 (small looped buffers; full continuous sweep would not fit in heap). +4. **Pan sweep L → R** (~3 s) — continuous equal-power (cos/sin) crossfade from A0 to A1 in a single non-looped buffer. 5. Then plays each stereo WAV (`44100` and `8000` Hz) in full. **Hardware required:** connect a stereo headphone/amp to A0 (left) and A1 @@ -275,7 +286,7 @@ done - "left only" → tone in left ear, silence in right. - "right only" → tone in right ear, silence in left. - "both channels" → centered tone in both ears. -- "pan sweep" → tone steps left → right across 8 amplitude stages over 2 s. +- "pan sweep" → tone smoothly travels left → right over ~3 s. - Stereo WAVs play with proper L/R separation; no cross-contamination. - On a scope: probing A0 and A1 simultaneously during phases 1 and 2 should show one channel idle (mid-scale DC) while the other carries the sine. diff --git a/tests/circuitpython-manual/audioio/TESTING.md b/tests/circuitpython-manual/audioio/TESTING.md deleted file mode 100644 index 7b2be500004be..0000000000000 --- a/tests/circuitpython-manual/audioio/TESTING.md +++ /dev/null @@ -1,316 +0,0 @@ -# Testing: STM32F405 / STM32F407 DAC AudioOut - -These tests exercise the DAC-based `audioio.AudioOut` implementation added for STM32F405xx and STM32F407xx. - -## Automated vs Manual Tests - -| Test | Automated | Requires audio/scope | -|------|-----------|----------------------| -| 1 — WAV File Playback | Yes | Yes (audio check) | -| 2 — Pause / Resume | Yes | Yes (audio check) | -| 3 — Looping Sine Wave | Yes | Yes (audio check) | -| 4 — deinit and Re-init | Yes | No | -| 5 — Stereo Playback | Yes | Yes (audio check) | -| 6 — Soft Reset Cleanup | No (manual Ctrl-C/D) | No | - -`run_serial_tests.py` automates Tests 1–5: it copies the necessary files to the -board and runs each script over the serial REPL, comparing the printed output to -the expected patterns. You still need to listen to the audio (and optionally -use an oscilloscope) for the audio-quality checks. - -### Quick start - -```bash -# Install the one dependency (if not already present) -pip install mpremote - -# Run all automated tests (board must be connected and CIRCUITPY mounted) -python3 tests/circuitpython-manual/audioio/run_serial_tests.py - -# Skip file copy if files are already on the board -python3 tests/circuitpython-manual/audioio/run_serial_tests.py --no-copy - -# Override the serial port or CIRCUITPY path (auto-detected on macOS/Linux/Windows) -python3 tests/circuitpython-manual/audioio/run_serial_tests.py \ - --port /dev/cu.usbmodem1234 \ - --circuitpy /Volumes/CIRCUITPY # macOS (auto-detected) -python3 tests/circuitpython-manual/audioio/run_serial_tests.py \ - --port /dev/ttyACM0 \ - --circuitpy /media/user/CIRCUITPY # Linux (auto-detected) -python3 tests/circuitpython-manual/audioio/run_serial_tests.py \ - --port COM5 \ - --circuitpy D:\ # Windows (auto-detected) - -# Run only specific tests -python3 tests/circuitpython-manual/audioio/run_serial_tests.py --tests 3,4,5 -``` - -The script exits 0 if all selected tests pass, 1 otherwise — suitable for CI. - ---- - -## Hardware Required - -- An STM32F405 or STM32F407 board running the freshly-built firmware (e.g. Feather STM32F405 Express). -- A passive speaker or audio amplifier connected to **pin A0 (PA04, DAC channel 1)** and GND. - - A simple test: 100 Ω resistor in series with a small speaker between A0 and GND. - - For better audio: connect A0 to a small amp module (e.g. PAM8403) then to a speaker. -- Optional: an oscilloscope or logic analyser probe on **D4** (used as a trigger output by the test scripts). -- A USB cable for REPL/filesystem access (CircuitPython storage must not be read-only). - -## Build - -Enable the feature by building for an F405 or F407 target: - -``` -make -C ports/stm BOARD=feather_stm32f405_express -j CROSS_COMPILE=~/arm-toolchain/arm-gnu-toolchain-14.3.rel1-darwin-arm64-arm-none-eabi/bin/arm-none-eabi- PYTHON=/opt/homebrew/bin/python3 -``` - -`CIRCUITPY_AUDIOIO` is now set to `1` automatically for those variants. Verify it is present in the build: - -``` -grep CIRCUITPY_AUDIOIO ports/stm/build-feather_stm32f405_express/mpconfigport.mk -# Expected output: CIRCUITPY_AUDIOIO = 1 -``` - -Flash the resulting `.bin` to the board using your preferred method (e.g. `dfu-util`). -``` -dfu-util -a 0 --dfuse-address 0x08000000:force:mass-erase -D PATH/TO/circuitpython/ports/stm/build-feather_stm32f405_express/firmware.bin -``` -## File Setup - -> **If using `run_serial_tests.py`** this step is done automatically — skip ahead. - -Copy the WAV test samples onto the board's `CIRCUITPY` drive root: - -``` -cp \ - tests/circuitpython-manual/audiocore/jeplayer-splash-8000-8bit-mono-unsigned.wav \ - tests/circuitpython-manual/audiocore/jeplayer-splash-8000-16bit-mono-signed.wav \ - tests/circuitpython-manual/audiocore/jeplayer-splash-44100-16bit-mono-signed.wav \ - tests/circuitpython-manual/audiocore/jeplayer-splash-8000-16bit-stereo-signed.wav \ - tests/circuitpython-manual/audiocore/jeplayer-splash-44100-16bit-stereo-signed.wav \ - /Volumes/CIRCUITPY/ -``` - -These five files cover every exercised code path: -- `8000-8bit-mono-unsigned` — 8-bit unsigned decode path, audibly lo-fi -- `8000-16bit-mono-signed` — 16-bit signed decode path at lowest sample rate -- `44100-16bit-mono-signed` — 16-bit at highest sample rate (DMA reconfiguration, audible quality difference) -- `8000-16bit-stereo-signed` — stereo decode path, left→A0, right→A1 -- `44100-16bit-stereo-signed` — stereo at 44.1 kHz - -Stereo, 24-bit, and the 16 kHz files are omitted: stereo and 24-bit will `OSError`; 16 kHz adds no new code paths over 8 kHz and 44.1 kHz. - -Copy the test scripts to the board as well (or paste them into the REPL): - -``` -cp tests/circuitpython-manual/audioio/wavefile_playback.py /Volumes/CIRCUITPY/ -cp tests/circuitpython-manual/audioio/wavefile_pause_resume.py /Volumes/CIRCUITPY/ -cp tests/circuitpython-manual/audioio/single_buffer_loop.py /Volumes/CIRCUITPY/ -cp tests/circuitpython-manual/audioio/stereo_playback.py /Volumes/CIRCUITPY/ -``` - -## Test 1 — WAV File Playback (`wavefile_playback.py`) *(automated)* - -Verifies that `AudioOut` can play WAV files at 8 kHz, 16 kHz, and 44.1 kHz in mono and stereo (only left channel output), with 8-bit unsigned and 16-bit signed encodings. - -**Note:** 24-bit WAV files and the stereo MP3 are intentionally excluded — `audiocore.WaveFile` does not support 24-bit, and those files will print an `OSError`. That is expected. - -**Run from the REPL:** - -```python -import os -os.chdir("/") -exec(open("wavefile_playback.py").read()) -``` - -**Expected output (order may vary by filename sort):** - -``` -playing jeplayer-splash-44100-16bit-mono-signed.wav -playing jeplayer-splash-8000-16bit-mono-signed.wav -playing jeplayer-splash-8000-8bit-mono-unsigned.wav -done -``` - -**What to listen for:** - -- Each supported WAV plays the "jeplayer splash" jingle to completion before the next starts. -- No loud pops at the start or end of each file (the DAC ramp-in / ramp-out should suppress them). -- Audio pitch should match the sample rate: the 44100 Hz file sounds the most natural; the 8000 Hz file sounds lower fidelity. - -## Test 2 — Pause and Resume (`wavefile_pause_resume.py`) *(automated)* - -Verifies `AudioOut.pause()` / `AudioOut.resume()` by toggling every 100 ms during playback. The audio will sound choppy — that is intentional. - -**Run from the REPL:** - -```python -exec(open("wavefile_pause_resume.py").read()) -``` - -**Expected output (repeating for each WAV):** - -``` -playing with pause/resume: jeplayer-splash-44100-16bit-mono-signed.wav - paused - resumed - ... -playing with pause/resume: jeplayer-splash-8000-16bit-mono-signed.wav - paused - resumed - ... -playing with pause/resume: jeplayer-splash-8000-8bit-mono-unsigned.wav - paused - resumed - ... -done -``` - -**What to verify:** - -- The REPL prints alternating "paused" / "resumed" lines. -- Audio cuts in and out in sync with the prints. -- Playback eventually completes (the `while dac.playing` loop exits normally). -- No hard fault or hang. - -## Test 3 — Looping Sine Wave (`single_buffer_loop.py`) *(automated)* - -Verifies `RawSample` with `loop=True` and tests all four sample formats: -`unsigned 8-bit`, `signed 8-bit`, `unsigned 16-bit`, `signed 16-bit`. - -Each sample generates one cycle of a 440 Hz sine wave and loops for 1 second. - -**Run from the REPL:** - -```python -exec(open("single_buffer_loop.py").read()) -``` - -**Expected output:** - -``` -unsigned 8 bit - -signed 8 bit - -unsigned 16 bit - -signed 16 bit - -done -``` - -**What to listen for:** - -- A 440 Hz tone (concert A) for approximately 1 second for each format. -- All four formats should sound essentially identical in pitch and volume. -- No pops or glitches during the loop. -- Clean silence between tones (quiescent DAC value holds between `stop()` calls). - -## Test 4 — `deinit` and Re-init *(automated)* - -Verifies that `AudioOut` can be deconstructed and reconstructed without rebooting, and that pin A0 is properly released. - -```python -import audioio, analogio, board - -# Construct and immediately deinit AudioOut -dac = audioio.AudioOut(board.A0) -dac.deinit() - -# PA04 should now be free for AnalogOut -aout = analogio.AnalogOut(board.A0) -aout.value = 32768 # mid-scale -aout.deinit() - -# Re-create AudioOut on the same pin -dac2 = audioio.AudioOut(board.A0) -dac2.deinit() -print("pass") -``` - -**Expected output:** `pass` with no exceptions. - -## Test 5 — Stereo Playback (`stereo_playback.py`) *(automated)* - -Verifies that `AudioOut(board.A0, right_channel=board.A1)` drives both DAC -channels independently: left on **A0 (PA04, DAC_CH1)**, right on -**A1 (PA05, DAC_CH2)**, both clocked by TIM6. - -The script runs four phases in order: - -1. **Left-only 440 Hz tone** (~1 s) — only A0 should produce audio. -2. **Right-only 440 Hz tone** (~1 s) — only A1 should produce audio. -3. **Both-channel 440 Hz tone** (~1 s) — equal amplitude on both pins. -4. **Pan sweep L → R** (~2 s) — 8 stepped amplitude stages from A0 to A1 (small looped buffers; full continuous sweep would not fit in heap). -5. Then plays each stereo WAV (`44100` and `8000` Hz) in full. - -**Hardware required:** connect a stereo headphone/amp to A0 (left) and A1 -(right) with common ground, or scope-probe each pin separately. - -**Run from the REPL:** - -```python -import os -os.chdir("/") -exec(open("stereo_playback.py").read()) -``` - -**Expected output:** - -``` -channel test: left only -channel test: right only -channel test: both channels -pan sweep: left to right -playing stereo: jeplayer-splash-44100-16bit-stereo-signed.wav -playing stereo: jeplayer-splash-8000-16bit-stereo-signed.wav -done -``` - -**What to listen / look for:** - -- "left only" → tone in left ear, silence in right. -- "right only" → tone in right ear, silence in left. -- "both channels" → centered tone in both ears. -- "pan sweep" → tone steps left → right across 8 amplitude stages over 2 s. -- Stereo WAVs play with proper L/R separation; no cross-contamination. -- On a scope: probing A0 and A1 simultaneously during phases 1 and 2 should - show one channel idle (mid-scale DC) while the other carries the sine. - -## Test 6 — Soft Reset Cleanup *(manual)* - -Verifies that `audioout_reset()` properly cleans up when the REPL soft-resets during active playback. - -1. Start a looping tone in the REPL: - -```python -import audiocore, audioio, board, array, math, time -length = 8000 // 440 -s16 = array.array("h", [int(math.sin(math.pi * 2 * i / length) * 32767) for i in range(length)]) -dac = audioio.AudioOut(board.A0) -dac.play(audiocore.RawSample(s16, sample_rate=8000), loop=True) -``` - -2. While the tone is playing, press **Ctrl-C** then **Ctrl-D** (soft reset). - -**Expected:** -- **Ctrl-C** interrupts the Python code but the tone *keeps playing* — this is normal. The DMA and TIM6 run in hardware independently of Python; a `KeyboardInterrupt` does not stop them. -- **Ctrl-D** triggers a soft reset which calls `audioout_reset()`. The tone stops immediately and the board returns to the `>>>` prompt with no crash or fault. -- Running any of the above tests again afterwards should work normally. - -## Oscilloscope Checks (Optional) - -Each test script drives `board.D4` (pin D4) low at the start of each playback and high when it ends. This provides a clean trigger edge for a scope. - -- **Test 1:** Probe A0 — should show a sampled waveform at the correct sample rate. Probe D4 for a gate signal that spans the file duration. -- **Test 3:** Probe A0 — should show a 440 Hz staircase-sine at the DAC output (12-bit steps visible at 44.1 kHz; fewer at 8 kHz). A simple RC low-pass filter (1 kΩ + 100 nF) on the A0 output will smooth the staircase significantly. - -## Known Limitations - -- **Left channel must be A0 (PA04)**. Any other pin raises `ValueError: AudioOut requires pin A0 (PA04)`. -- **Right channel must be A1 (PA05)** when used. Any other pin raises `ValueError: AudioOut right channel requires pin A1 (PA05)`. -- **24-bit WAV files** are not supported by `audiocore.WaveFile` and will raise `OSError` when opened. -- Only one `AudioOut` instance can be active at a time. diff --git a/tests/circuitpython-manual/audioio/run_serial_tests.py b/tests/circuitpython-manual/audioio/run_serial_tests.py index a39b988f654cb..20156213c8314 100644 --- a/tests/circuitpython-manual/audioio/run_serial_tests.py +++ b/tests/circuitpython-manual/audioio/run_serial_tests.py @@ -84,6 +84,90 @@ def _mpremote(args: list, timeout: float = 30.0): sys.exit("mpremote not found. Run: pip install mpremote") +# stderr substrings that indicate a transient host/USB issue worth retrying. +# Matched case-insensitively. CircuitPython USB-CDC briefly disappears during +# soft-reset and after some heavy DMA activity; mpremote's next invocation then +# either can't open the port or sees the kernel still holding the previous +# descriptor. +_RETRYABLE_STDERR = ( + "could not enter raw repl", + "failed to access", + "device not configured", + "errno 6", + "errno 16", + "resource busy", + "could not open", + "no such file or directory", + "serialexception", + "device disconnected", + "could not exclusively lock", +) + + +def _is_retryable(stderr: str) -> bool: + s = stderr.lower() + return any(needle in s for needle in _RETRYABLE_STDERR) + + +def _port_holders(port: str) -> list[str]: + """Return a human-readable list of processes holding *port* (Unix only). + + macOS / Linux only — uses `lsof`. Returns lines like "Code Helper (51298)". + Empty list when nothing holds the port or `lsof` is unavailable. + """ + if not port.startswith("/"): + return [] + holders: list[str] = [] + # Both /dev/cu.* (callout) and /dev/tty.* (dial-in) refer to the same UART + # on macOS — VS Code typically opens /dev/tty.* while we ask for /dev/cu.*, + # so check both. + candidates = {port} + if "/cu." in port: + candidates.add(port.replace("/cu.", "/tty.", 1)) + elif "/tty." in port: + candidates.add(port.replace("/tty.", "/cu.", 1)) + for path in candidates: + if not os.path.exists(path): + continue + try: + result = subprocess.run( + ["lsof", "-Fcp", path], + capture_output=True, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + pid = command = None + for line in result.stdout.splitlines(): + if line.startswith("p"): + pid = line[1:] + elif line.startswith("c"): + command = line[1:] + if pid: + holders.append(f"{command} (PID {pid}) on {path}") + pid = command = None + return holders + + +def _wait_for_port(port: str, timeout: float = 10.0) -> bool: + """Block until *port* exists in the filesystem, or *timeout* elapses. + + macOS / Linux expose serial ports as /dev nodes; Windows uses COMx which is + not a filesystem path, so on Windows we just sleep briefly and trust the + next mpremote call to surface the real error. + """ + if not port.startswith("/"): + time.sleep(0.5) + return True + deadline = time.time() + timeout + while time.time() < deadline: + if os.path.exists(port): + return True + time.sleep(0.1) + return False + + def _interrupt_running_code(port: str, soft_reset: bool = False) -> None: """Halt any code running on the device so mpremote can enter raw REPL. @@ -146,7 +230,9 @@ def find_circuitpy() -> str | None: system = platform.system() if system == "Darwin": - candidates = ["/Volumes/CIRCUITPY"] + # Glob so a second CIRCUITPY volume (mounted as "CIRCUITPY 1") is found. + import glob + candidates = sorted(glob.glob("/Volumes/CIRCUITPY*")) elif system == "Windows": # Scan all drive letters for a CIRCUITPY volume label. import string @@ -225,11 +311,15 @@ def _check(condition: bool, message: str) -> bool: return condition -def _run_exec(port: str, code: str, label: str, timeout: float, retries: int = 2): +def _run_exec(port: str, code: str, label: str, timeout: float, retries: int = 3): """Execute *code* on the device via mpremote exec and print the output. - Retries on `could not enter raw repl` after sending a Ctrl-C burst — this - handles the case where a busy code.py blocks mpremote's first handshake. + Retries cover three failure modes: + * raw-REPL handshake blocked by a running code.py → Ctrl-C burst. + * Persistent handshake failure → soft-reset + Ctrl-C flood. + * Host-side serial drop ("device in use", "Errno 6", etc.) → wait for the + port node to re-appear, then retry. CircuitPython's USB-CDC can briefly + vanish after heavy DMA traffic or soft-reset. """ print(f"\n{'=' * 60}") print(f" {label}") @@ -237,6 +327,9 @@ def _run_exec(port: str, code: str, label: str, timeout: float, retries: int = 2 stdout = "" stderr = "" for attempt in range(retries + 1): + if not _wait_for_port(port, timeout=10.0): + print(f" [retry {attempt}/{retries}] port {port} not present — waited 10s") + continue try: stdout, stderr = _mpremote( ["connect", port, "exec", code], timeout=timeout @@ -244,13 +337,19 @@ def _run_exec(port: str, code: str, label: str, timeout: float, retries: int = 2 except TimeoutError as exc: print(f" [FAIL] {exc}") return False, "", "" - if "could not enter raw repl" not in stderr: + if not _is_retryable(stderr): break if attempt < retries: - # Escalate: first attempt = Ctrl-C burst; second = Ctrl-D reboot. - escalate = attempt >= 1 - tactic = "soft-reset + Ctrl-C flood" if escalate else "Ctrl-C burst" - print(f" [retry {attempt + 1}/{retries}] raw REPL busy — {tactic}") + handshake = "could not enter raw repl" in stderr.lower() + # Soft-reset only on persistent raw-REPL failures, not on host drops + # (a soft-reset there just makes the disconnect window longer). + escalate = handshake and attempt >= 1 + if handshake: + tactic = "soft-reset + Ctrl-C flood" if escalate else "Ctrl-C burst" + else: + tactic = f"port-drop recovery ({stderr.strip().splitlines()[-1] if stderr.strip() else '?'})" + print(f" [retry {attempt + 1}/{retries}] {tactic}") + _wait_for_port(port, timeout=10.0) _interrupt_running_code(port, soft_reset=escalate) time.sleep(0.5) print("Output:") @@ -263,6 +362,19 @@ def _run_exec(port: str, code: str, label: str, timeout: float, retries: int = 2 return True, stdout, stderr +def _settle_between_tests(port: str) -> None: + """Drain any lingering REPL output and wait for the port to be ready. + + CircuitPython occasionally re-enumerates its USB-CDC after a heavy test; + next mpremote call then races the kernel re-binding the tty. Polling for + the port node and then sending a Ctrl-C burst gives the host a clean + starting state before the next exec. + """ + _wait_for_port(port, timeout=10.0) + _interrupt_running_code(port, soft_reset=False) + time.sleep(0.3) + + # --------------------------------------------------------------------------- # Individual tests # --------------------------------------------------------------------------- @@ -275,9 +387,9 @@ def test1_wavefile_playback(port: str) -> bool: if not ok: return False passed = True - passed &= _check("playing jeplayer-splash-44100-16bit-mono-signed.wav" in stdout, "44100 Hz 16-bit mono WAV played") - passed &= _check("playing jeplayer-splash-8000-16bit-mono-signed.wav" in stdout, "8000 Hz 16-bit mono WAV played") - passed &= _check("playing jeplayer-splash-8000-8bit-mono-unsigned.wav" in stdout, "8000 Hz 8-bit unsigned WAV played") + for wav in sorted(WAV_FILES): + passed &= _check(f"playing {wav}" in stdout, f"played {wav}") + passed &= _check("OSError" not in stdout, "No OSError reported during playback") passed &= _check("done" in stdout, "Script completed with 'done'") passed &= _check(not stderr, f"No exceptions (stderr={stderr!r})") return passed @@ -295,6 +407,8 @@ def test2_pause_resume(port: str) -> bool: passed &= _check(f"playing with pause/resume: {wav}" in stdout, f"pause/resume header for {wav}") passed &= _check("paused" in stdout, "At least one 'paused' line printed") passed &= _check("resumed" in stdout, "At least one 'resumed' line printed") + passed &= _check("TIMEOUT" not in stdout, "No pause/resume hang timeout") + passed &= _check("OSError" not in stdout, "No OSError reported during playback") passed &= _check("done" in stdout, "Script completed with 'done'") passed &= _check(not stderr, f"No exceptions (stderr={stderr!r})") return passed @@ -378,23 +492,41 @@ def main(): port = args.port or find_port() print(f"Using port: {port}\n") + # Surface the common "VS Code has the serial monitor open" pitfall up front + # — otherwise every test fails with the opaque "in use by another program". + holders = _port_holders(port) + if holders: + print("ERROR: port is held by another process:") + for h in holders: + print(f" {h}") + print( + "Close the offending app (e.g. VS Code Serial Monitor, screen, tio)\n" + "and re-run. mpremote needs exclusive access." + ) + sys.exit(2) + if not args.no_copy: copy_files(port, circuitpy=args.circuitpy) # Halt any running code.py so the first test gets a clean raw-REPL entry. _interrupt_running_code(port) + test_runners = [ + ("1", "Test 1 — WAV Playback", test1_wavefile_playback), + ("2", "Test 2 — Pause/Resume", test2_pause_resume), + ("3", "Test 3 — Looping Sine", test3_single_buffer_loop), + ("4", "Test 4 — deinit/Re-init", test4_deinit), + ("5", "Test 5 — Stereo Playback", test5_stereo_playback), + ] results: dict[str, bool] = {} - if "1" in selected: - results["Test 1 — WAV Playback"] = test1_wavefile_playback(port) - if "2" in selected: - results["Test 2 — Pause/Resume"] = test2_pause_resume(port) - if "3" in selected: - results["Test 3 — Looping Sine"] = test3_single_buffer_loop(port) - if "4" in selected: - results["Test 4 — deinit/Re-init"] = test4_deinit(port) - if "5" in selected: - results["Test 5 — Stereo Playback"] = test5_stereo_playback(port) + first = True + for key, name, runner in test_runners: + if key not in selected: + continue + if not first: + _settle_between_tests(port) + first = False + results[name] = runner(port) print(f"\n{'=' * 60}") print("SUMMARY") diff --git a/tests/circuitpython-manual/audioio/single_buffer_loop.py b/tests/circuitpython-manual/audioio/single_buffer_loop.py index bce6a7a2cf300..929e03e6d101e 100644 --- a/tests/circuitpython-manual/audioio/single_buffer_loop.py +++ b/tests/circuitpython-manual/audioio/single_buffer_loop.py @@ -13,7 +13,9 @@ except AttributeError: trigger = None -# Generate one period of a 440 Hz sine wave at each sample rate. +# Generate one period of a 440 Hz sine wave. All four samples use the same +# sample rate so each plays the same audible pitch — the test is comparing +# the four format-conversion paths, not playback rates. sample_rate = 8000 length = sample_rate // 440 # samples per cycle @@ -23,25 +25,25 @@ u8 = array.array("B", [0] * length) for i in range(length): u8[i] = int(math.sin(math.pi * 2 * i / length) * 127 + 128) -samples = [audiocore.RawSample(u8, sample_rate=8000)] +samples = [audiocore.RawSample(u8, sample_rate=sample_rate)] # signed 8 bit s8 = array.array("b", [0] * length) for i in range(length): s8[i] = int(math.sin(math.pi * 2 * i / length) * 127) -samples.append(audiocore.RawSample(s8, sample_rate=16000)) +samples.append(audiocore.RawSample(s8, sample_rate=sample_rate)) # unsigned 16 bit u16 = array.array("H", [0] * length) for i in range(length): u16[i] = int(math.sin(math.pi * 2 * i / length) * 32767 + 32768) -samples.append(audiocore.RawSample(u16, sample_rate=8000)) +samples.append(audiocore.RawSample(u16, sample_rate=sample_rate)) # signed 16 bit s16 = array.array("h", [0] * length) for i in range(length): s16[i] = int(math.sin(math.pi * 2 * i / length) * 32767) -samples.append(audiocore.RawSample(s16, sample_rate=44100)) +samples.append(audiocore.RawSample(s16, sample_rate=sample_rate)) dac = audioio.AudioOut(board.A0) for sample, name in zip(samples, sample_names): diff --git a/tests/circuitpython-manual/audioio/stereo_playback.py b/tests/circuitpython-manual/audioio/stereo_playback.py index a3817e010d968..cfdb4d395406a 100644 --- a/tests/circuitpython-manual/audioio/stereo_playback.py +++ b/tests/circuitpython-manual/audioio/stereo_playback.py @@ -29,8 +29,7 @@ def stereo_buffer(left, right): - # Init from bytes to avoid the temporary [0]*N int list (heavy on F405 RAM). - buf = array.array("h", b"\x00\x00" * (len(left) * 2)) + buf = array.array("h", [0] * (len(left) * 2)) for i in range(len(left)): buf[2 * i] = left[i] buf[2 * i + 1] = right[i] diff --git a/tests/circuitpython-manual/audioio/wavefile_pause_resume.py b/tests/circuitpython-manual/audioio/wavefile_pause_resume.py index 13a227c027421..c3002c6b5d1b4 100644 --- a/tests/circuitpython-manual/audioio/wavefile_pause_resume.py +++ b/tests/circuitpython-manual/audioio/wavefile_pause_resume.py @@ -35,7 +35,16 @@ dac.play(sample) # Deliberately toggle pause/resume every 100 ms to stress-test the # pause/resume cycle. Audio will sound choppy — that is expected. + # The wall-clock guard makes the test fail loudly instead of hanging + # if pause() ever leaves the driver in a state where playing never + # clears. 30 s is enough for the longest 44.1 kHz mono WAV in the + # set even with pause-doubling. + deadline = time.monotonic() + 30.0 while dac.playing: + if time.monotonic() > deadline: + print(" TIMEOUT waiting for playback to finish") + dac.stop() + break time.sleep(0.1) if not dac.playing: # may have finished during sleep break From dce5290c4158d12776187c0a1d1541e35d809c3e Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sat, 2 May 2026 21:19:48 -0700 Subject: [PATCH 12/17] ports/stm: note atmel-samd freq bias near TIM6 round-to-nearest 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 --- ports/stm/common-hal/audioio/AudioOut.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ports/stm/common-hal/audioio/AudioOut.c b/ports/stm/common-hal/audioio/AudioOut.c index d66c239c69a9b..b375da758fc0e 100644 --- a/ports/stm/common-hal/audioio/AudioOut.c +++ b/ports/stm/common-hal/audioio/AudioOut.c @@ -519,6 +519,10 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, uint32_t tim6_clk = get_tim6_freq(); // Round to nearest, not truncate, so the realised sample rate is the // closest TIM6 division to the requested rate. + // NOTE: a sweep audit on atmel-samd (Circuit Playground Express) shows a + // constant -3.4 cent frequency bias across all tones, consistent with a + // truncating period calculation there. The same round-to-nearest fix + // would likely tighten frequency accuracy on that port too. uint32_t period = (tim6_clk + sample_rate / 2) / sample_rate; if (period < 2) { period = 2; From c48300771a1859aa86081d52b2bda47d0fd0df93 Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sun, 3 May 2026 15:18:56 -0700 Subject: [PATCH 13/17] tests: pre-flight CIRCUITPY free-space check in audioio runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../audioio/run_serial_tests.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/circuitpython-manual/audioio/run_serial_tests.py b/tests/circuitpython-manual/audioio/run_serial_tests.py index 20156213c8314..03017c420f056 100644 --- a/tests/circuitpython-manual/audioio/run_serial_tests.py +++ b/tests/circuitpython-manual/audioio/run_serial_tests.py @@ -273,6 +273,31 @@ def copy_files(port: str, circuitpy: str | None = None): [(os.path.join(AUDIOCORE_DIR, w), w) for w in WAV_FILES] + [(os.path.join(SCRIPT_DIR, s), s) for s in TEST_SCRIPTS] ) + # STM32F4 CIRCUITPY is only ~2 MiB. Leftover files from prior runs + # (a sweep generator code.py, stale WAVs) routinely fill it; macOS + # then reports ENOSPC as a misleading "Operation not permitted", + # which sent the first contributor chasing a permission red herring. + # Sum what we're about to write and bail loudly if it won't fit, + # discounting space the dst already occupies. + if mount: + needed = 0 + for src, dst in files: + if not os.path.exists(src): + continue + needed += os.path.getsize(src) + dest_path = os.path.join(mount, dst) + if os.path.exists(dest_path): + needed -= os.path.getsize(dest_path) + free = shutil.disk_usage(mount).free + if needed > free: + short = needed - free + print(f"ERROR: not enough space on {mount}.") + print(f" need: {needed/1024:.1f} KiB") + print(f" free: {free/1024:.1f} KiB") + print(f" short: {short/1024:.1f} KiB") + print(f" Delete unrelated files (e.g. an old code.py or " + f"stale WAVs) and re-run.") + sys.exit(1) missing = [] for src, dst in files: if not os.path.exists(src): From 11b441fcc71e2d522b491a14b16f9f8670be6d68 Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sun, 3 May 2026 15:19:39 -0700 Subject: [PATCH 14/17] tests: capture audioio runner results from F405 (all 5 PASS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/circuitpython-manual/audioio/results.md | 496 ++++++++++++++++++ .../audioio/run_serial_tests.py | 7 +- 2 files changed, 497 insertions(+), 6 deletions(-) create mode 100644 tests/circuitpython-manual/audioio/results.md diff --git a/tests/circuitpython-manual/audioio/results.md b/tests/circuitpython-manual/audioio/results.md new file mode 100644 index 0000000000000..b994f84258f38 --- /dev/null +++ b/tests/circuitpython-manual/audioio/results.md @@ -0,0 +1,496 @@ +# audioio — `run_serial_tests.py` results + +- **Board:** Adafruit Feather STM32F405 Express +- **Suite:** Tests 1–5 automated (Test 6 soft-reset is manual) +- **Result:** all PASS + +``` +Using port: /dev/cu.usbmodem101 + +Copying files to board via /Volumes/CIRCUITPY ... + jeplayer-splash-8000-8bit-mono-unsigned.wav (copied) + jeplayer-splash-8000-16bit-mono-signed.wav (copied) + jeplayer-splash-44100-16bit-mono-signed.wav (copied) + jeplayer-splash-8000-16bit-stereo-signed.wav (copied) + jeplayer-splash-44100-16bit-stereo-signed.wav (copied) + wavefile_playback.py (copied) + wavefile_pause_resume.py (copied) + single_buffer_loop.py (copied) + stereo_playback.py (copied) + + +============================================================ + Test 1 — WAV File Playback (wavefile_playback.py) +============================================================ +Output: + playing jeplayer-splash-44100-16bit-mono-signed.wav + + playing jeplayer-splash-44100-16bit-stereo-signed.wav + + playing jeplayer-splash-8000-16bit-mono-signed.wav + + playing jeplayer-splash-8000-16bit-stereo-signed.wav + + playing jeplayer-splash-8000-8bit-mono-unsigned.wav + + done + [PASS] played jeplayer-splash-44100-16bit-mono-signed.wav + [PASS] played jeplayer-splash-44100-16bit-stereo-signed.wav + [PASS] played jeplayer-splash-8000-16bit-mono-signed.wav + [PASS] played jeplayer-splash-8000-16bit-stereo-signed.wav + [PASS] played jeplayer-splash-8000-8bit-mono-unsigned.wav + [PASS] No OSError reported during playback + [PASS] Script completed with 'done' + [PASS] No exceptions (stderr='') + +============================================================ + Test 2 — Pause / Resume (wavefile_pause_resume.py) +============================================================ +Output: + playing with pause/resume: jeplayer-splash-44100-16bit-mono-signed.wav + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + + playing with pause/resume: jeplayer-splash-44100-16bit-stereo-signed.wav + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + + playing with pause/resume: jeplayer-splash-8000-16bit-mono-signed.wav + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + + playing with pause/resume: jeplayer-splash-8000-16bit-stereo-signed.wav + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + + playing with pause/resume: jeplayer-splash-8000-8bit-mono-unsigned.wav + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + paused + resumed + + done + [PASS] pause/resume header for jeplayer-splash-44100-16bit-mono-signed.wav + [PASS] pause/resume header for jeplayer-splash-44100-16bit-stereo-signed.wav + [PASS] pause/resume header for jeplayer-splash-8000-16bit-mono-signed.wav + [PASS] pause/resume header for jeplayer-splash-8000-16bit-stereo-signed.wav + [PASS] pause/resume header for jeplayer-splash-8000-8bit-mono-unsigned.wav + [PASS] At least one 'paused' line printed + [PASS] At least one 'resumed' line printed + [PASS] No pause/resume hang timeout + [PASS] No OSError reported during playback + [PASS] Script completed with 'done' + [PASS] No exceptions (stderr='') + +============================================================ + Test 3 — Looping Sine Wave (single_buffer_loop.py) +============================================================ +Output: + unsigned 8 bit + + signed 8 bit + + unsigned 16 bit + + signed 16 bit + + done + [PASS] 'unsigned 8 bit' label printed + [PASS] 'signed 8 bit' label printed + [PASS] 'unsigned 16 bit' label printed + [PASS] 'signed 16 bit' label printed + [PASS] Script completed with 'done' + [PASS] No exceptions (stderr='') + +============================================================ + Test 4 — deinit and Re-init (inline) +============================================================ +Output: + pass + [PASS] Script printed 'pass' + [PASS] No exceptions (stderr='') + +============================================================ + Test 5 — Stereo Playback (stereo_playback.py) +============================================================ +Output: + channel test: left only + + channel test: right only + + channel test: both channels + + pan sweep: left to right + + playing stereo: jeplayer-splash-44100-16bit-stereo-signed.wav + + playing stereo: jeplayer-splash-8000-16bit-stereo-signed.wav + + done + [PASS] Left-only channel tone played + [PASS] Right-only channel tone played + [PASS] Both-channel tone played + [PASS] Pan sweep played + [PASS] 44100 Hz 16-bit stereo WAV played + [PASS] 8000 Hz 16-bit stereo WAV played + [PASS] Script completed with 'done' + [PASS] No exceptions (stderr='') + +============================================================ +SUMMARY +============================================================ + [PASS] Test 1 — WAV Playback + [PASS] Test 2 — Pause/Resume + [PASS] Test 3 — Looping Sine + [PASS] Test 4 — deinit/Re-init + [PASS] Test 5 — Stereo Playback + +All automated tests passed. +Remaining manual step: Test 6 (soft-reset) and audio/oscilloscope verification. +``` diff --git a/tests/circuitpython-manual/audioio/run_serial_tests.py b/tests/circuitpython-manual/audioio/run_serial_tests.py index 03017c420f056..a63a4bace4107 100644 --- a/tests/circuitpython-manual/audioio/run_serial_tests.py +++ b/tests/circuitpython-manual/audioio/run_serial_tests.py @@ -273,12 +273,7 @@ def copy_files(port: str, circuitpy: str | None = None): [(os.path.join(AUDIOCORE_DIR, w), w) for w in WAV_FILES] + [(os.path.join(SCRIPT_DIR, s), s) for s in TEST_SCRIPTS] ) - # STM32F4 CIRCUITPY is only ~2 MiB. Leftover files from prior runs - # (a sweep generator code.py, stale WAVs) routinely fill it; macOS - # then reports ENOSPC as a misleading "Operation not permitted", - # which sent the first contributor chasing a permission red herring. - # Sum what we're about to write and bail loudly if it won't fit, - # discounting space the dst already occupies. + # Check if device has enough space for test files if mount: needed = 0 for src, dst in files: From 2e97b2790d65d2f702a145f79e9e89c521c49edb Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sun, 3 May 2026 15:28:23 -0700 Subject: [PATCH 15/17] tests: drop manual soft-reset test from audioio suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/circuitpython-manual/audioio/README.md | 22 ------------------- tests/circuitpython-manual/audioio/results.md | 4 ++-- .../audioio/run_serial_tests.py | 6 ++--- 3 files changed, 4 insertions(+), 28 deletions(-) diff --git a/tests/circuitpython-manual/audioio/README.md b/tests/circuitpython-manual/audioio/README.md index bddeec83779a7..66377e75ffaba 100644 --- a/tests/circuitpython-manual/audioio/README.md +++ b/tests/circuitpython-manual/audioio/README.md @@ -11,7 +11,6 @@ These tests exercise the DAC-based `audioio.AudioOut` implementation added for S | 3 — Looping Sine Wave | Yes | Yes (audio check) | | 4 — deinit and Re-init | Yes | No | | 5 — Stereo Playback | Yes | Yes (audio check) | -| 6 — Soft Reset Cleanup | No (manual Ctrl-C/D) | No | `run_serial_tests.py` automates Tests 1–5: it copies the necessary files to the board and runs each script over the serial REPL, comparing the printed output to @@ -291,27 +290,6 @@ done - On a scope: probing A0 and A1 simultaneously during phases 1 and 2 should show one channel idle (mid-scale DC) while the other carries the sine. -## Test 6 — Soft Reset Cleanup *(manual)* - -Verifies that `audioout_reset()` properly cleans up when the REPL soft-resets during active playback. - -1. Start a looping tone in the REPL: - -```python -import audiocore, audioio, board, array, math, time -length = 8000 // 440 -s16 = array.array("h", [int(math.sin(math.pi * 2 * i / length) * 32767) for i in range(length)]) -dac = audioio.AudioOut(board.A0) -dac.play(audiocore.RawSample(s16, sample_rate=8000), loop=True) -``` - -2. While the tone is playing, press **Ctrl-C** then **Ctrl-D** (soft reset). - -**Expected:** -- **Ctrl-C** interrupts the Python code but the tone *keeps playing* — this is normal. The DMA and TIM6 run in hardware independently of Python; a `KeyboardInterrupt` does not stop them. -- **Ctrl-D** triggers a soft reset which calls `audioout_reset()`. The tone stops immediately and the board returns to the `>>>` prompt with no crash or fault. -- Running any of the above tests again afterwards should work normally. - ## Oscilloscope Checks (Optional) Each test script drives `board.D4` (pin D4) low at the start of each playback and high when it ends. This provides a clean trigger edge for a scope. diff --git a/tests/circuitpython-manual/audioio/results.md b/tests/circuitpython-manual/audioio/results.md index b994f84258f38..afb9c9ad8706e 100644 --- a/tests/circuitpython-manual/audioio/results.md +++ b/tests/circuitpython-manual/audioio/results.md @@ -1,7 +1,7 @@ # audioio — `run_serial_tests.py` results - **Board:** Adafruit Feather STM32F405 Express -- **Suite:** Tests 1–5 automated (Test 6 soft-reset is manual) +- **Suite:** Tests 1–5 automated - **Result:** all PASS ``` @@ -492,5 +492,5 @@ SUMMARY [PASS] Test 5 — Stereo Playback All automated tests passed. -Remaining manual step: Test 6 (soft-reset) and audio/oscilloscope verification. +Remaining manual step: audio/oscilloscope verification. ``` diff --git a/tests/circuitpython-manual/audioio/run_serial_tests.py b/tests/circuitpython-manual/audioio/run_serial_tests.py index a63a4bace4107..f6ec48cb847dc 100644 --- a/tests/circuitpython-manual/audioio/run_serial_tests.py +++ b/tests/circuitpython-manual/audioio/run_serial_tests.py @@ -2,13 +2,11 @@ """ run_serial_tests.py — Automated REPL-based tests for STM32F405 audioio. -Automates Tests 1, 2, 3, and 4 from README.md by: +Automates Tests 1–5 from README.md by: 1. Copying WAV files and test scripts to the board via mpremote. 2. Running each test on the device via the CircuitPython REPL. 3. Comparing captured output to expected patterns and reporting PASS/FAIL. -Test 5 (soft-reset cleanup) still requires manual interaction. - Usage: python3 run_serial_tests.py python3 run_serial_tests.py --port /dev/cu.usbmodemXXX @@ -559,7 +557,7 @@ def main(): print() if all_passed: print("All automated tests passed.") - print("Remaining manual step: Test 6 (soft-reset) and audio/oscilloscope verification.") + print("Remaining manual step: audio/oscilloscope verification.") sys.exit(0) else: print("One or more tests FAILED — see details above.") From 0f28c948a4cf228db7f81e227da28eda81a5d83f Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Sun, 3 May 2026 23:19:09 -0700 Subject: [PATCH 16/17] chore: pre-commit fixups (locale, ruff-format, trailing whitespace) 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 --- locale/circuitpython.pot | 51 +++++++++++++++++- tests/circuitpython-manual/audioio/results.md | 40 +++++++------- .../audioio/run_serial_tests.py | 52 ++++++++++++------- .../audioio/single_buffer_loop.py | 6 ++- .../audioio/wavefile_pause_resume.py | 10 ++-- .../audioio/wavefile_playback.py | 10 ++-- 6 files changed, 121 insertions(+), 48 deletions(-) diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index 833991ac476e0..d973a62cb5ce9 100644 --- a/locale/circuitpython.pot +++ b/locale/circuitpython.pot @@ -630,6 +630,18 @@ msgstr "" msgid "Audio source error" msgstr "" +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "AudioOut is deinited" +msgstr "" + +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "AudioOut requires pin A0 (PA04)" +msgstr "" + +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "AudioOut right channel requires pin A1 (PA05)" +msgstr "" + #: shared-bindings/wifi/Radio.c msgid "AuthMode.OPEN is not used with password" msgstr "" @@ -892,6 +904,14 @@ msgstr "" msgid "DAC Channel Init Error" msgstr "" +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "DAC DMA start failed" +msgstr "" + +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "DAC DMA start failed (right channel)" +msgstr "" + #: ports/stm/common-hal/analogio/AnalogOut.c msgid "DAC Device Init Error" msgstr "" @@ -900,6 +920,22 @@ msgstr "" msgid "DAC already in use" msgstr "" +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "DAC channel config error" +msgstr "" + +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "DAC init error" +msgstr "" + +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "DMA init failed" +msgstr "" + +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "DMA init failed (right)" +msgstr "" + #: ports/atmel-samd/common-hal/paralleldisplaybus/ParallelBus.c #: ports/nordic/common-hal/paralleldisplaybus/ParallelBus.c msgid "Data 0 pin must be byte aligned" @@ -1541,6 +1577,7 @@ msgstr "" #: ports/atmel-samd/common-hal/analogio/AnalogOut.c #: ports/stm/common-hal/analogio/AnalogOut.c +#: ports/stm/common-hal/audioio/AudioOut.c msgid "No DAC on chip" msgstr "" @@ -2109,6 +2146,14 @@ msgstr "" msgid "System entry must be gnss.SatelliteSystem" msgstr "" +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "TIM6 init failed" +msgstr "" + +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "TIM6 master cfg failed" +msgstr "" + #: ports/stm/common-hal/microcontroller/Processor.c msgid "Temperature read timed out" msgstr "" @@ -4049,7 +4094,7 @@ msgstr "" msgid "output array must be contiguous" msgstr "" -#: py/objint_mpz.c +#: py/objint_longlong.c py/objint_mpz.c msgid "overflow converting long int to machine word" msgstr "" @@ -4162,6 +4207,10 @@ msgstr "" msgid "rsplit(None,n)" msgstr "" +#: ports/stm/common-hal/audioio/AudioOut.c +msgid "sample_rate must be > 0" +msgstr "" + #: shared-bindings/audiofreeverb/Freeverb.c msgid "samples_signed must be true" msgstr "" diff --git a/tests/circuitpython-manual/audioio/results.md b/tests/circuitpython-manual/audioio/results.md index afb9c9ad8706e..4e3459e6f800d 100644 --- a/tests/circuitpython-manual/audioio/results.md +++ b/tests/circuitpython-manual/audioio/results.md @@ -24,15 +24,15 @@ Copying files to board via /Volumes/CIRCUITPY ... ============================================================ Output: playing jeplayer-splash-44100-16bit-mono-signed.wav - + playing jeplayer-splash-44100-16bit-stereo-signed.wav - + playing jeplayer-splash-8000-16bit-mono-signed.wav - + playing jeplayer-splash-8000-16bit-stereo-signed.wav - + playing jeplayer-splash-8000-8bit-mono-unsigned.wav - + done [PASS] played jeplayer-splash-44100-16bit-mono-signed.wav [PASS] played jeplayer-splash-44100-16bit-stereo-signed.wav @@ -124,7 +124,7 @@ Output: resumed paused resumed - + playing with pause/resume: jeplayer-splash-44100-16bit-stereo-signed.wav paused resumed @@ -198,7 +198,7 @@ Output: resumed paused resumed - + playing with pause/resume: jeplayer-splash-8000-16bit-mono-signed.wav paused resumed @@ -270,7 +270,7 @@ Output: resumed paused resumed - + playing with pause/resume: jeplayer-splash-8000-16bit-stereo-signed.wav paused resumed @@ -342,7 +342,7 @@ Output: resumed paused resumed - + playing with pause/resume: jeplayer-splash-8000-8bit-mono-unsigned.wav paused resumed @@ -414,7 +414,7 @@ Output: resumed paused resumed - + done [PASS] pause/resume header for jeplayer-splash-44100-16bit-mono-signed.wav [PASS] pause/resume header for jeplayer-splash-44100-16bit-stereo-signed.wav @@ -433,13 +433,13 @@ Output: ============================================================ Output: unsigned 8 bit - + signed 8 bit - + unsigned 16 bit - + signed 16 bit - + done [PASS] 'unsigned 8 bit' label printed [PASS] 'signed 8 bit' label printed @@ -461,17 +461,17 @@ Output: ============================================================ Output: channel test: left only - + channel test: right only - + channel test: both channels - + pan sweep: left to right - + playing stereo: jeplayer-splash-44100-16bit-stereo-signed.wav - + playing stereo: jeplayer-splash-8000-16bit-stereo-signed.wav - + done [PASS] Left-only channel tone played [PASS] Right-only channel tone played diff --git a/tests/circuitpython-manual/audioio/run_serial_tests.py b/tests/circuitpython-manual/audioio/run_serial_tests.py index f6ec48cb847dc..9fbbe55559292 100644 --- a/tests/circuitpython-manual/audioio/run_serial_tests.py +++ b/tests/circuitpython-manual/audioio/run_serial_tests.py @@ -66,6 +66,7 @@ # mpremote helpers # --------------------------------------------------------------------------- + def _mpremote(args: list, timeout: float = 30.0): """Run an mpremote command, return (stdout, stderr). Raises on timeout.""" try: @@ -214,7 +215,12 @@ def find_port() -> str: # Fall back to any USB serial port that isn't Bluetooth/wlan for line in stdout.splitlines(): parts = line.split() - if parts and parts[0].startswith("/dev/") and "Bluetooth" not in line and "wlan" not in line: + if ( + parts + and parts[0].startswith("/dev/") + and "Bluetooth" not in line + and "wlan" not in line + ): return parts[0] raise RuntimeError( "No board detected. Connect the board and/or pass --port.\n" @@ -225,16 +231,19 @@ def find_port() -> str: def find_circuitpy() -> str | None: """Return the path to the mounted CIRCUITPY volume, or None if not found.""" import platform + system = platform.system() if system == "Darwin": # Glob so a second CIRCUITPY volume (mounted as "CIRCUITPY 1") is found. import glob + candidates = sorted(glob.glob("/Volumes/CIRCUITPY*")) elif system == "Windows": # Scan all drive letters for a CIRCUITPY volume label. import string import ctypes + candidates = [] kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] buf = ctypes.create_unicode_buffer(256) @@ -248,6 +257,7 @@ def find_circuitpy() -> str | None: candidates = ["/media/CIRCUITPY", "/run/media/CIRCUITPY"] try: import pwd + user = pwd.getpwuid(os.getuid()).pw_name candidates.insert(0, f"/run/media/{user}/CIRCUITPY") candidates.insert(0, f"/media/{user}/CIRCUITPY") @@ -267,10 +277,9 @@ def copy_files(port: str, circuitpy: str | None = None): print(f"Copying files to board via {mount} ...") else: print("Copying files to board via mpremote ...") - files = ( - [(os.path.join(AUDIOCORE_DIR, w), w) for w in WAV_FILES] - + [(os.path.join(SCRIPT_DIR, s), s) for s in TEST_SCRIPTS] - ) + files = [(os.path.join(AUDIOCORE_DIR, w), w) for w in WAV_FILES] + [ + (os.path.join(SCRIPT_DIR, s), s) for s in TEST_SCRIPTS + ] # Check if device has enough space for test files if mount: needed = 0 @@ -285,11 +294,10 @@ def copy_files(port: str, circuitpy: str | None = None): if needed > free: short = needed - free print(f"ERROR: not enough space on {mount}.") - print(f" need: {needed/1024:.1f} KiB") - print(f" free: {free/1024:.1f} KiB") - print(f" short: {short/1024:.1f} KiB") - print(f" Delete unrelated files (e.g. an old code.py or " - f"stale WAVs) and re-run.") + print(f" need: {needed / 1024:.1f} KiB") + print(f" free: {free / 1024:.1f} KiB") + print(f" short: {short / 1024:.1f} KiB") + print(f" Delete unrelated files (e.g. an old code.py or stale WAVs) and re-run.") sys.exit(1) missing = [] for src, dst in files: @@ -301,9 +309,7 @@ def copy_files(port: str, circuitpy: str | None = None): shutil.copy2(src, dest_path) print(f" {dst} (copied)") else: - stdout, stderr = _mpremote( - ["connect", port, "fs", "cp", src, f":/{dst}"], timeout=30 - ) + stdout, stderr = _mpremote(["connect", port, "fs", "cp", src, f":/{dst}"], timeout=30) if stderr and "Error" in stderr: print(f" {dst} (FAILED: {stderr.strip()})") else: @@ -349,9 +355,7 @@ def _run_exec(port: str, code: str, label: str, timeout: float, retries: int = 3 print(f" [retry {attempt}/{retries}] port {port} not present — waited 10s") continue try: - stdout, stderr = _mpremote( - ["connect", port, "exec", code], timeout=timeout - ) + stdout, stderr = _mpremote(["connect", port, "exec", code], timeout=timeout) except TimeoutError as exc: print(f" [FAIL] {exc}") return False, "", "" @@ -397,6 +401,7 @@ def _settle_between_tests(port: str) -> None: # Individual tests # --------------------------------------------------------------------------- + def test1_wavefile_playback(port: str) -> bool: code = 'exec(open("/wavefile_playback.py").read())' ok, stdout, stderr = _run_exec( @@ -422,7 +427,9 @@ def test2_pause_resume(port: str) -> bool: return False passed = True for wav in sorted(WAV_FILES): - passed &= _check(f"playing with pause/resume: {wav}" in stdout, f"pause/resume header for {wav}") + passed &= _check( + f"playing with pause/resume: {wav}" in stdout, f"pause/resume header for {wav}" + ) passed &= _check("paused" in stdout, "At least one 'paused' line printed") passed &= _check("resumed" in stdout, "At least one 'resumed' line printed") passed &= _check("TIMEOUT" not in stdout, "No pause/resume hang timeout") @@ -459,8 +466,14 @@ def test5_stereo_playback(port: str) -> bool: passed &= _check("channel test: right only" in stdout, "Right-only channel tone played") passed &= _check("channel test: both channels" in stdout, "Both-channel tone played") passed &= _check("pan sweep: left to right" in stdout, "Pan sweep played") - passed &= _check("playing stereo: jeplayer-splash-44100-16bit-stereo-signed.wav" in stdout, "44100 Hz 16-bit stereo WAV played") - passed &= _check("playing stereo: jeplayer-splash-8000-16bit-stereo-signed.wav" in stdout, "8000 Hz 16-bit stereo WAV played") + passed &= _check( + "playing stereo: jeplayer-splash-44100-16bit-stereo-signed.wav" in stdout, + "44100 Hz 16-bit stereo WAV played", + ) + passed &= _check( + "playing stereo: jeplayer-splash-8000-16bit-stereo-signed.wav" in stdout, + "8000 Hz 16-bit stereo WAV played", + ) passed &= _check("done" in stdout, "Script completed with 'done'") passed &= _check(not stderr, f"No exceptions (stderr={stderr!r})") return passed @@ -482,6 +495,7 @@ def test4_deinit(port: str) -> bool: # Main # --------------------------------------------------------------------------- + def main(): parser = argparse.ArgumentParser( description=__doc__, diff --git a/tests/circuitpython-manual/audioio/single_buffer_loop.py b/tests/circuitpython-manual/audioio/single_buffer_loop.py index 929e03e6d101e..4a4c4304275b2 100644 --- a/tests/circuitpython-manual/audioio/single_buffer_loop.py +++ b/tests/circuitpython-manual/audioio/single_buffer_loop.py @@ -48,12 +48,14 @@ dac = audioio.AudioOut(board.A0) for sample, name in zip(samples, sample_names): print(name) - if trigger: trigger.value = False + if trigger: + trigger.value = False dac.play(sample, loop=True) time.sleep(1) dac.stop() time.sleep(0.1) - if trigger: trigger.value = True + if trigger: + trigger.value = True print() dac.deinit() diff --git a/tests/circuitpython-manual/audioio/wavefile_pause_resume.py b/tests/circuitpython-manual/audioio/wavefile_pause_resume.py index c3002c6b5d1b4..7661a828b2613 100644 --- a/tests/circuitpython-manual/audioio/wavefile_pause_resume.py +++ b/tests/circuitpython-manual/audioio/wavefile_pause_resume.py @@ -20,7 +20,9 @@ samples.append(fn) if not samples: - print("No sample files found. Copy *.wav files from tests/circuitpython-manual/audiocore/ to the board.") + print( + "No sample files found. Copy *.wav files from tests/circuitpython-manual/audiocore/ to the board." + ) dac = audioio.AudioOut(board.A0) for filename in sorted(samples): @@ -31,7 +33,8 @@ except OSError as e: print(e) continue - if trigger: trigger.value = False + if trigger: + trigger.value = False dac.play(sample) # Deliberately toggle pause/resume every 100 ms to stress-test the # pause/resume cycle. Audio will sound choppy — that is expected. @@ -54,7 +57,8 @@ else: dac.resume() print(" resumed") - if trigger: trigger.value = True + if trigger: + trigger.value = True time.sleep(0.1) print() diff --git a/tests/circuitpython-manual/audioio/wavefile_playback.py b/tests/circuitpython-manual/audioio/wavefile_playback.py index fa1fa9713092d..a7800c54ddc0f 100644 --- a/tests/circuitpython-manual/audioio/wavefile_playback.py +++ b/tests/circuitpython-manual/audioio/wavefile_playback.py @@ -22,7 +22,9 @@ samples.append(fn) if not samples: - print("No sample files found. Copy *.wav files from tests/circuitpython-manual/audiocore/ to the board.") + print( + "No sample files found. Copy *.wav files from tests/circuitpython-manual/audiocore/ to the board." + ) dac = audioio.AudioOut(board.A0) for filename in sorted(samples): @@ -33,11 +35,13 @@ except OSError as e: print(e) continue - if trigger: trigger.value = False + if trigger: + trigger.value = False dac.play(sample) while dac.playing: time.sleep(0.1) - if trigger: trigger.value = True + if trigger: + trigger.value = True time.sleep(0.1) print() From cd142d5ae7836baf9a6848d2709914f373895765 Mon Sep 17 00:00:00 2001 From: Chris Nourse Date: Mon, 4 May 2026 11:26:39 -0700 Subject: [PATCH 17/17] ports/stm: reuse existing translations and shared deinit guard in audioio Address review feedback on PR #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 --- locale/circuitpython.pot | 47 +- ports/stm/common-hal/audioio/AudioOut.c | 25 +- tests/circuitpython-manual/audioio/results.md | 496 ------------------ 3 files changed, 15 insertions(+), 553 deletions(-) delete mode 100644 tests/circuitpython-manual/audioio/results.md diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index d973a62cb5ce9..db36f74b45f39 100644 --- a/locale/circuitpython.pot +++ b/locale/circuitpython.pot @@ -140,6 +140,7 @@ msgid "%q indices must be integers, not %s" msgstr "" #: ports/analog/common-hal/busio/SPI.c ports/analog/common-hal/busio/UART.c +#: ports/stm/common-hal/audioio/AudioOut.c #: shared-bindings/digitalio/DigitalInOutProtocol.c #: shared-module/busdisplay/BusDisplay.c msgid "%q init failed" @@ -630,18 +631,6 @@ msgstr "" msgid "Audio source error" msgstr "" -#: ports/stm/common-hal/audioio/AudioOut.c -msgid "AudioOut is deinited" -msgstr "" - -#: ports/stm/common-hal/audioio/AudioOut.c -msgid "AudioOut requires pin A0 (PA04)" -msgstr "" - -#: ports/stm/common-hal/audioio/AudioOut.c -msgid "AudioOut right channel requires pin A1 (PA05)" -msgstr "" - #: shared-bindings/wifi/Radio.c msgid "AuthMode.OPEN is not used with password" msgstr "" @@ -901,18 +890,12 @@ msgid "Critical ROS failure during soft reboot, reset required: %d" msgstr "" #: ports/stm/common-hal/analogio/AnalogOut.c -msgid "DAC Channel Init Error" -msgstr "" - #: ports/stm/common-hal/audioio/AudioOut.c -msgid "DAC DMA start failed" -msgstr "" - -#: ports/stm/common-hal/audioio/AudioOut.c -msgid "DAC DMA start failed (right channel)" +msgid "DAC Channel Init Error" msgstr "" #: ports/stm/common-hal/analogio/AnalogOut.c +#: ports/stm/common-hal/audioio/AudioOut.c msgid "DAC Device Init Error" msgstr "" @@ -920,22 +903,6 @@ msgstr "" msgid "DAC already in use" msgstr "" -#: ports/stm/common-hal/audioio/AudioOut.c -msgid "DAC channel config error" -msgstr "" - -#: ports/stm/common-hal/audioio/AudioOut.c -msgid "DAC init error" -msgstr "" - -#: ports/stm/common-hal/audioio/AudioOut.c -msgid "DMA init failed" -msgstr "" - -#: ports/stm/common-hal/audioio/AudioOut.c -msgid "DMA init failed (right)" -msgstr "" - #: ports/atmel-samd/common-hal/paralleldisplaybus/ParallelBus.c #: ports/nordic/common-hal/paralleldisplaybus/ParallelBus.c msgid "Data 0 pin must be byte aligned" @@ -2146,14 +2113,6 @@ msgstr "" msgid "System entry must be gnss.SatelliteSystem" msgstr "" -#: ports/stm/common-hal/audioio/AudioOut.c -msgid "TIM6 init failed" -msgstr "" - -#: ports/stm/common-hal/audioio/AudioOut.c -msgid "TIM6 master cfg failed" -msgstr "" - #: ports/stm/common-hal/microcontroller/Processor.c msgid "Temperature read timed out" msgstr "" diff --git a/ports/stm/common-hal/audioio/AudioOut.c b/ports/stm/common-hal/audioio/AudioOut.c index b375da758fc0e..44f8b8e8d51b4 100644 --- a/ports/stm/common-hal/audioio/AudioOut.c +++ b/ports/stm/common-hal/audioio/AudioOut.c @@ -339,12 +339,12 @@ void common_hal_audioio_audioout_construct(audioio_audioout_obj_t *self, // Only PA04 (DAC_CH1) is supported as left channel. if (left_channel != &pin_PA04) { - mp_raise_ValueError(MP_ERROR_TEXT("AudioOut requires pin A0 (PA04)")); + raise_ValueError_invalid_pin_name(MP_QSTR_left_channel); } // Right channel must be PA05 (DAC_CH2 / A1) if provided. if (right_channel != NULL && right_channel != &pin_PA05) { - mp_raise_ValueError(MP_ERROR_TEXT("AudioOut right channel requires pin A1 (PA05)")); + raise_ValueError_invalid_pin_name(MP_QSTR_right_channel); } // Claim pins first. The pin-claim system is what serialises this driver @@ -393,7 +393,7 @@ void common_hal_audioio_audioout_construct(audioio_audioout_obj_t *self, __HAL_RCC_DAC_CLK_ENABLE(); handle.Instance = DAC; if (HAL_DAC_Init(&handle) != HAL_OK) { - mp_raise_ValueError(MP_ERROR_TEXT("DAC init error")); + mp_raise_ValueError(MP_ERROR_TEXT("DAC Device Init Error")); } } @@ -403,7 +403,7 @@ void common_hal_audioio_audioout_construct(audioio_audioout_obj_t *self, ch_cfg.DAC_Trigger = DAC_TRIGGER_NONE; ch_cfg.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; if (HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_1) != HAL_OK) { - mp_raise_ValueError(MP_ERROR_TEXT("DAC channel config error")); + mp_raise_ValueError(MP_ERROR_TEXT("DAC Channel Init Error")); } // Ramp DAC output up to quiescent value to prevent an audible pop. @@ -447,9 +447,8 @@ void common_hal_audioio_audioout_deinit(audioio_audioout_obj_t *self) { void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, mp_obj_t sample, bool loop) { - if (common_hal_audioio_audioout_deinited(self)) { - mp_raise_ValueError(MP_ERROR_TEXT("AudioOut is deinited")); - } + // The shared-bindings layer guards every entry point with + // check_for_deinit, so a deinited self can't reach this function. common_hal_audioio_audioout_stop(self); // Extract sample format metadata via the canonical accessors so the @@ -536,7 +535,7 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, tim6_handle.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; tim6_handle.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; if (HAL_TIM_Base_Init(&tim6_handle) != HAL_OK) { - mp_raise_RuntimeError(MP_ERROR_TEXT("TIM6 init failed")); + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q init failed"), MP_QSTR_TIM6); } // TRGO = Update event → triggers DAC conversion each period. @@ -544,7 +543,7 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, master_cfg.MasterOutputTrigger = TIM_TRGO_UPDATE; master_cfg.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; if (HAL_TIMEx_MasterConfigSynchronization(&tim6_handle, &master_cfg) != HAL_OK) { - mp_raise_RuntimeError(MP_ERROR_TEXT("TIM6 master cfg failed")); + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q init failed"), MP_QSTR_TIM6); } // NOTE: ports/atmel-samd's audio_dma_setup_playback handles AUDIO_DMA_OK // and two specific error codes but lets any other non-OK return fall @@ -582,7 +581,7 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, hdma->Init.Priority = DMA_PRIORITY_VERY_HIGH; hdma->Init.FIFOMode = DMA_FIFOMODE_DISABLE; if (HAL_DMA_Init(hdma) != HAL_OK) { - mp_raise_RuntimeError(MP_ERROR_TEXT("DMA init failed")); + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q init failed"), MP_QSTR_DMA); } __HAL_LINKDMA(&handle, DMA_Handle1, *hdma); @@ -605,7 +604,7 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, hdma_r->Init.Priority = DMA_PRIORITY_VERY_HIGH; hdma_r->Init.FIFOMode = DMA_FIFOMODE_DISABLE; if (HAL_DMA_Init(hdma_r) != HAL_OK) { - mp_raise_RuntimeError(MP_ERROR_TEXT("DMA init failed (right)")); + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q init failed"), MP_QSTR_DMA); } __HAL_LINKDMA(&handle, DMA_Handle2, *hdma_r); @@ -630,7 +629,7 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, } m_free(self->dma_buffer); self->dma_buffer = NULL; - mp_raise_RuntimeError(MP_ERROR_TEXT("DAC DMA start failed")); + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q init failed"), MP_QSTR_DAC); } if (self->right_channel != NULL) { @@ -646,7 +645,7 @@ void common_hal_audioio_audioout_play(audioio_audioout_obj_t *self, self->dma_buffer = NULL; m_free(self->dma_buffer_r); self->dma_buffer_r = NULL; - mp_raise_RuntimeError(MP_ERROR_TEXT("DAC DMA start failed (right channel)")); + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q init failed"), MP_QSTR_DAC); } } diff --git a/tests/circuitpython-manual/audioio/results.md b/tests/circuitpython-manual/audioio/results.md deleted file mode 100644 index 4e3459e6f800d..0000000000000 --- a/tests/circuitpython-manual/audioio/results.md +++ /dev/null @@ -1,496 +0,0 @@ -# audioio — `run_serial_tests.py` results - -- **Board:** Adafruit Feather STM32F405 Express -- **Suite:** Tests 1–5 automated -- **Result:** all PASS - -``` -Using port: /dev/cu.usbmodem101 - -Copying files to board via /Volumes/CIRCUITPY ... - jeplayer-splash-8000-8bit-mono-unsigned.wav (copied) - jeplayer-splash-8000-16bit-mono-signed.wav (copied) - jeplayer-splash-44100-16bit-mono-signed.wav (copied) - jeplayer-splash-8000-16bit-stereo-signed.wav (copied) - jeplayer-splash-44100-16bit-stereo-signed.wav (copied) - wavefile_playback.py (copied) - wavefile_pause_resume.py (copied) - single_buffer_loop.py (copied) - stereo_playback.py (copied) - - -============================================================ - Test 1 — WAV File Playback (wavefile_playback.py) -============================================================ -Output: - playing jeplayer-splash-44100-16bit-mono-signed.wav - - playing jeplayer-splash-44100-16bit-stereo-signed.wav - - playing jeplayer-splash-8000-16bit-mono-signed.wav - - playing jeplayer-splash-8000-16bit-stereo-signed.wav - - playing jeplayer-splash-8000-8bit-mono-unsigned.wav - - done - [PASS] played jeplayer-splash-44100-16bit-mono-signed.wav - [PASS] played jeplayer-splash-44100-16bit-stereo-signed.wav - [PASS] played jeplayer-splash-8000-16bit-mono-signed.wav - [PASS] played jeplayer-splash-8000-16bit-stereo-signed.wav - [PASS] played jeplayer-splash-8000-8bit-mono-unsigned.wav - [PASS] No OSError reported during playback - [PASS] Script completed with 'done' - [PASS] No exceptions (stderr='') - -============================================================ - Test 2 — Pause / Resume (wavefile_pause_resume.py) -============================================================ -Output: - playing with pause/resume: jeplayer-splash-44100-16bit-mono-signed.wav - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - - playing with pause/resume: jeplayer-splash-44100-16bit-stereo-signed.wav - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - - playing with pause/resume: jeplayer-splash-8000-16bit-mono-signed.wav - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - - playing with pause/resume: jeplayer-splash-8000-16bit-stereo-signed.wav - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - - playing with pause/resume: jeplayer-splash-8000-8bit-mono-unsigned.wav - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - paused - resumed - - done - [PASS] pause/resume header for jeplayer-splash-44100-16bit-mono-signed.wav - [PASS] pause/resume header for jeplayer-splash-44100-16bit-stereo-signed.wav - [PASS] pause/resume header for jeplayer-splash-8000-16bit-mono-signed.wav - [PASS] pause/resume header for jeplayer-splash-8000-16bit-stereo-signed.wav - [PASS] pause/resume header for jeplayer-splash-8000-8bit-mono-unsigned.wav - [PASS] At least one 'paused' line printed - [PASS] At least one 'resumed' line printed - [PASS] No pause/resume hang timeout - [PASS] No OSError reported during playback - [PASS] Script completed with 'done' - [PASS] No exceptions (stderr='') - -============================================================ - Test 3 — Looping Sine Wave (single_buffer_loop.py) -============================================================ -Output: - unsigned 8 bit - - signed 8 bit - - unsigned 16 bit - - signed 16 bit - - done - [PASS] 'unsigned 8 bit' label printed - [PASS] 'signed 8 bit' label printed - [PASS] 'unsigned 16 bit' label printed - [PASS] 'signed 16 bit' label printed - [PASS] Script completed with 'done' - [PASS] No exceptions (stderr='') - -============================================================ - Test 4 — deinit and Re-init (inline) -============================================================ -Output: - pass - [PASS] Script printed 'pass' - [PASS] No exceptions (stderr='') - -============================================================ - Test 5 — Stereo Playback (stereo_playback.py) -============================================================ -Output: - channel test: left only - - channel test: right only - - channel test: both channels - - pan sweep: left to right - - playing stereo: jeplayer-splash-44100-16bit-stereo-signed.wav - - playing stereo: jeplayer-splash-8000-16bit-stereo-signed.wav - - done - [PASS] Left-only channel tone played - [PASS] Right-only channel tone played - [PASS] Both-channel tone played - [PASS] Pan sweep played - [PASS] 44100 Hz 16-bit stereo WAV played - [PASS] 8000 Hz 16-bit stereo WAV played - [PASS] Script completed with 'done' - [PASS] No exceptions (stderr='') - -============================================================ -SUMMARY -============================================================ - [PASS] Test 1 — WAV Playback - [PASS] Test 2 — Pause/Resume - [PASS] Test 3 — Looping Sine - [PASS] Test 4 — deinit/Re-init - [PASS] Test 5 — Stereo Playback - -All automated tests passed. -Remaining manual step: audio/oscilloscope verification. -```