diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index 833991ac476e0..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" @@ -889,10 +890,12 @@ msgid "Critical ROS failure during soft reboot, reset required: %d" msgstr "" #: ports/stm/common-hal/analogio/AnalogOut.c +#: ports/stm/common-hal/audioio/AudioOut.c 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 "" @@ -1541,6 +1544,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 "" @@ -4049,7 +4053,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 +4166,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/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..44f8b8e8d51b4 --- /dev/null +++ b/ports/stm/common-hal/audioio/AudioOut.c @@ -0,0 +1,784 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2024 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. 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. 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 + +#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 (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; + +// --------------------------------------------------------------------------- +// 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 given DAC channel output from from_12 to to_12 (both 12-bit, +// 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; + } + 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, 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; + } + } +} + +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; 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 +// 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) +// channel_offset - 0 for left/mono, 1 for right channel of a stereo stream +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) { + + // 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 + src_offset]; + 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); + int32_t s = samples_signed + ? (int32_t)(int16_t)u16 + : (int32_t)u16 - 0x8000; + dest[i] = (uint16_t)((s + 0x8000) & 0xFFFF) >> 4; + } + } + return frames; +} + +// 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; + } + } +} + +// 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). +// +// 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) { + // 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, + self->channel_count == 1, 0); + self->src_done = false; + } else { + pad_quiescent(dest_l, dest_r, filled, quiescent_12); + self->stopping = true; + return; + } + } + + uint8_t *buf; + uint32_t len; + audioio_get_buffer_result_t result = + audiosample_get_buffer(self->sample, + self->channel_count == 1, 0, &buf, &len); + + if (result == GET_BUFFER_ERROR) { + pad_quiescent(dest_l, dest_r, filled, 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( + 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); + + if (dest_r) { + uint8_t r_offset = (self->channel_count >= 2) ? 1 : 0; + convert_to_dac12( + 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); + } + + 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. + uint32_t bytes_consumed = written * src_stride; + self->src_ptr += bytes_consumed; + self->src_remaining_len -= bytes_consumed; + filled += written; + } +} + +// 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; + } + 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); + } +} + +// --------------------------------------------------------------------------- +// 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_l); + } +} + +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. +// +// 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) { + active_audioout->halves_to_fill |= 0x1; + 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->halves_to_fill |= 0x2; + 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 + + // Only PA04 (DAC_CH1) is supported as left channel. + if (left_channel != &pin_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) { + raise_ValueError_invalid_pin_name(MP_QSTR_right_channel); + } + + // 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_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}; + 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). __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; + if (HAL_DAC_Init(&handle) != HAL_OK) { + mp_raise_ValueError(MP_ERROR_TEXT("DAC Device Init Error")); + } + } + + // 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; + if (HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_1) != HAL_OK) { + mp_raise_ValueError(MP_ERROR_TEXT("DAC Channel Init 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); + #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 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 left 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) { + // 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 + // shared sample-protocol contract is honoured. + audiosample_base_t *base = audiosample_check(sample); + self->bytes_per_sample = audiosample_get_bits_per_sample(base) / 8; + self->samples_signed = base->samples_signed; + 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). 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->right_channel != NULL) { + self->dma_buffer_r = (uint16_t *)m_malloc_without_collect( + AUDIOOUT_DMA_BUFFER_SAMPLES * sizeof(uint16_t)); + } + + // 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); + + // 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 + // pins and is reserved for DAC use (mcu_tim_banks[5] == NULL). + __HAL_RCC_TIM6_CLK_ENABLE(); + + 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; + } + 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; + if (HAL_TIM_Base_Init(&tim6_handle) != HAL_OK) { + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q init failed"), MP_QSTR_TIM6); + } + + // 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; + if (HAL_TIMEx_MasterConfigSynchronization(&tim6_handle, &master_cfg) != HAL_OK) { + 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 + // 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. + // 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; + HAL_DAC_ConfigChannel(&handle, &ch_cfg, DAC_CHANNEL_1); + 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(); + + DMA_HandleTypeDef *hdma = &self->dma_handle_l; + 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_VERY_HIGH; + hdma->Init.FIFOMode = DMA_FIFOMODE_DISABLE; + if (HAL_DMA_Init(hdma) != HAL_OK) { + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q init failed"), MP_QSTR_DMA); + } + __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); + + // --- 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_VERY_HIGH; + hdma_r->Init.FIFOMode = DMA_FIFOMODE_DISABLE; + if (HAL_DMA_Init(hdma_r) != HAL_OK) { + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q init failed"), MP_QSTR_DMA); + } + __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, + (uint32_t *)self->dma_buffer, + AUDIOOUT_DMA_BUFFER_SAMPLES, + 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_varg(MP_ERROR_TEXT("%q init failed"), MP_QSTR_DAC); + } + + 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_varg(MP_ERROR_TEXT("%q init failed"), MP_QSTR_DAC); + } + } + + HAL_TIM_Base_Start(&tim6_handle); + self->playing = true; +} + +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; + + // 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_l); + HAL_NVIC_DisableIRQ(DMA1_Stream5_IRQn); + NVIC_ClearPendingIRQ(DMA1_Stream5_IRQn); + if (self->dma_buffer_r) { + HAL_DMA_Abort(&self->dma_handle_r); + HAL_NVIC_DisableIRQ(DMA1_Stream6_IRQn); + NVIC_ClearPendingIRQ(DMA1_Stream6_IRQn); + m_free(self->dma_buffer_r); + self->dma_buffer_r = NULL; + } + + // Free the left DMA buffer. + if (self->dma_buffer) { + m_free(self->dma_buffer); + self->dma_buffer = NULL; + } + + // 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; + 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 && self->playing; +} + +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) { + // 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; +} + +// --------------------------------------------------------------------------- +// 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_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; + } + active_audioout->sample = MP_OBJ_NULL; + active_audioout->stopping = false; + active_audioout->paused = false; + 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 new file mode 100644 index 0000000000000..340118819a96e --- /dev/null +++ b/ports/stm/common-hal/audioio/AudioOut.h @@ -0,0 +1,72 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2024 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. +// 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 (AUDIOOUT_DMA_BUFFER_SAMPLES / 2) + +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 pin (PA05 = DAC_CH2). NULL when mono. + const mcu_pin_obj_t *right_channel; + + // 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(). + 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; + bool loop; + bool playing; + + // 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 + uint16_t quiescent_value; // 16-bit resting value (default 0x8000) + + // Background callback queued from DMA ISR, processed in main loop. + background_callback_t callback; + + // 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. + 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. +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..bd2b2238eef0f 100644 --- a/ports/stm/mpconfigport.mk +++ b/ports/stm/mpconfigport.mk @@ -3,6 +3,8 @@ INTERNAL_LIBM ?= 1 ifeq ($(MCU_VARIANT),$(filter $(MCU_VARIANT),STM32F405xx STM32F407xx)) CIRCUITPY_ALARM = 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 @@ -19,8 +21,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) { diff --git a/tests/circuitpython-manual/audioio/README.md b/tests/circuitpython-manual/audioio/README.md new file mode 100644 index 0000000000000..66377e75ffaba --- /dev/null +++ b/tests/circuitpython-manual/audioio/README.md @@ -0,0 +1,305 @@ +# 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) | + +`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. `CIRCUITPY_AUDIOIO` +is set to `1` automatically for those variants. + +``` +make -C ports/stm BOARD=feather_stm32f405_express -j +``` + +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`): + +``` +dfu-util -a 0 --dfuse-address 0x08000000:force:mass-erase -D 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 + +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): + +``` +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(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 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:** + +```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 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). + +## 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** (~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 +(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 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. + +## 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 new file mode 100644 index 0000000000000..9fbbe55559292 --- /dev/null +++ b/tests/circuitpython-manual/audioio/run_serial_tests.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +""" +run_serial_tests.py — Automated REPL-based tests for STM32F405 audioio. + +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. + +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 +import time + +# --------------------------------------------------------------------------- +# 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", + "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 = ( + "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") + + +# 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. + + 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"]) + 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 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) + 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.""" + 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 + ] + # Check if device has enough space for test files + 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 stale WAVs) and re-run.") + sys.exit(1) + missing = [] + for src, dst in files: + if not os.path.exists(src): + missing.append(src) + continue + 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: + 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, retries: int = 3): + """Execute *code* on the device via mpremote exec and print the output. + + 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}") + print("=" * 60) + 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) + except TimeoutError as exc: + print(f" [FAIL] {exc}") + return False, "", "" + if not _is_retryable(stderr): + break + if attempt < retries: + 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:") + for line in stdout.splitlines(): + print(f" {line}") + if stderr: + print("Stderr:") + for line in stderr.splitlines(): + print(f" {line}") + 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 +# --------------------------------------------------------------------------- + + +def test1_wavefile_playback(port: str) -> bool: + code = 'exec(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 + 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 + + +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("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 + + +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 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("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'") + 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( + "--circuitpy", + metavar="PATH", + help="Path to mounted CIRCUITPY volume (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,5", + help="Comma-separated list of test numbers to run (default: 1,2,3,4,5)", + ) + args = parser.parse_args() + + selected = set(args.tests.split(",")) + + 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] = {} + 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") + 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: 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..4a4c4304275b2 --- /dev/null +++ b/tests/circuitpython-manual/audioio/single_buffer_loop.py @@ -0,0 +1,62 @@ +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. 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 + +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=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=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=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=sample_rate)) + +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/stereo_playback.py b/tests/circuitpython-manual/audioio/stereo_playback.py new file mode 100644 index 0000000000000..cfdb4d395406a --- /dev/null +++ b/tests/circuitpython-manual/audioio/stereo_playback.py @@ -0,0 +1,128 @@ +import audiocore +import audioio +import board +import digitalio +import array +import gc +import math +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 + +dac = audioio.AudioOut(board.A0, right_channel=board.A1) + +# --------------------------------------------------------------------------- +# 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 + + +def stereo_buffer(left, right): + 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] + 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: + 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") 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..7661a828b2613 --- /dev/null +++ b/tests/circuitpython-manual/audioio/wavefile_pause_resume.py @@ -0,0 +1,66 @@ +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. + # 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 + 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..a7800c54ddc0f --- /dev/null +++ b/tests/circuitpython-manual/audioio/wavefile_playback.py @@ -0,0 +1,49 @@ +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")