diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index b5dbf7d6091..32f6e4ef631 100644 --- a/locale/circuitpython.pot +++ b/locale/circuitpython.pot @@ -1246,6 +1246,7 @@ msgid "Not connected" msgstr "" #: ports/espressif/common-hal/_bleio/__init__.c +#: shared-module/audiofilewriter/AudioFileWriter.c msgid "Already in progress" msgstr "" @@ -4252,6 +4253,14 @@ msgstr "" msgid "%q in %q must be of type %q or %q, not %q" msgstr "" +#: shared-module/audiofilewriter/AudioFileWriter.c +msgid "Only 8/16-bit mono/stereo is supported" +msgstr "" + +#: shared-module/audiofilewriter/AudioFileWriter.c +msgid "buffer_size too small for source" +msgstr "" + #: shared-module/audiomp3/MP3Decoder.c msgid "Couldn't allocate decoder" msgstr "" diff --git a/main.c b/main.c index 3238bd9a08a..8f19b782840 100644 --- a/main.c +++ b/main.c @@ -84,6 +84,10 @@ #include "shared-module/keypad/__init__.h" #endif +#if CIRCUITPY_AUDIOFILEWRITER +#include "shared-module/audiofilewriter/AudioFileWriter.h" +#endif + #if CIRCUITPY_MEMORYMONITOR #include "shared-module/memorymonitor/__init__.h" #endif @@ -399,6 +403,10 @@ static void cleanup_after_vm(mp_obj_t exception) { keypad_reset(); #endif + #if CIRCUITPY_AUDIOFILEWRITER + audiofilewriter_reset(); + #endif + // Close user-initiated sockets. #if CIRCUITPY_SOCKETPOOL socketpool_user_reset(); diff --git a/ports/espressif/mpconfigport.mk b/ports/espressif/mpconfigport.mk index 0b027333b97..bbe815af468 100644 --- a/ports/espressif/mpconfigport.mk +++ b/ports/espressif/mpconfigport.mk @@ -336,6 +336,7 @@ CIRCUITPY_MIPIDSI = 1 else ifeq ($(IDF_TARGET),esp32s2) # Modules CIRCUITPY_AUDIOIO ?= 1 +CIRCUITPY_AUDIOFILEWRITER ?= 1 # No I2S peripheral PDM-to-PCM hardware support CIRCUITPY_AUDIOBUSIO_PDMIN = 0 diff --git a/ports/raspberrypi/boards/pimoroni_pico_dv_base_w/mpconfigboard.mk b/ports/raspberrypi/boards/pimoroni_pico_dv_base_w/mpconfigboard.mk index 9218192d083..d380732afc1 100644 --- a/ports/raspberrypi/boards/pimoroni_pico_dv_base_w/mpconfigboard.mk +++ b/ports/raspberrypi/boards/pimoroni_pico_dv_base_w/mpconfigboard.mk @@ -19,6 +19,7 @@ CIRCUITPY_SOCKETPOOL = 1 CIRCUITPY_WIFI = 1 CIRCUITPY_PICODVI = 1 +CIRCUITPY_AUDIOFILEWRITER = 0 CFLAGS += \ -DCYW43_PIN_WL_DYNAMIC=0 \ diff --git a/ports/raspberrypi/mpconfigport.mk b/ports/raspberrypi/mpconfigport.mk index b4d1ed99696..f4c6e809ba0 100644 --- a/ports/raspberrypi/mpconfigport.mk +++ b/ports/raspberrypi/mpconfigport.mk @@ -13,6 +13,7 @@ CIRCUITPY_FULL_BUILD ?= 1 CIRCUITPY_AUDIOMP3 ?= 1 CIRCUITPY_AUDIOSPEED ?= 1 CIRCUITPY_AUDIOEFFECTS ?= 1 +CIRCUITPY_AUDIOFILEWRITER ?= 1 CIRCUITPY_BITOPS ?= 1 CIRCUITPY_HASHLIB ?= 1 CIRCUITPY_HASHLIB_MBEDTLS ?= 1 diff --git a/py/circuitpy_defns.mk b/py/circuitpy_defns.mk index 4c91ed102c2..63a874b094e 100644 --- a/py/circuitpy_defns.mk +++ b/py/circuitpy_defns.mk @@ -152,6 +152,9 @@ endif ifeq ($(CIRCUITPY_AUDIOSPEED),1) SRC_PATTERNS += audiospeed/% endif +ifeq ($(CIRCUITPY_AUDIOFILEWRITER),1) +SRC_PATTERNS += audiofilewriter/% +endif ifeq ($(CIRCUITPY_AURORA_EPAPER),1) SRC_PATTERNS += aurora_epaper/% endif @@ -718,6 +721,8 @@ SRC_SHARED_MODULE_ALL = \ audiofilters/__init__.c \ audiofreeverb/__init__.c \ audiofreeverb/Freeverb.c \ + audiofilewriter/AudioFileWriter.c \ + audiofilewriter/__init__.c \ audioio/__init__.c \ audiomixer/Mixer.c \ audiomixer/MixerVoice.c \ diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index ed0f5e5f1f2..62943cff3ad 100644 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -174,6 +174,9 @@ CFLAGS += -DCIRCUITPY_AUDIOFILTERS=$(CIRCUITPY_AUDIOFILTERS) CIRCUITPY_AUDIOFREEVERB ?= $(CIRCUITPY_AUDIOEFFECTS) CFLAGS += -DCIRCUITPY_AUDIOFREEVERB=$(CIRCUITPY_AUDIOFREEVERB) +CIRCUITPY_AUDIOFILEWRITER ?= 0 +CFLAGS += -DCIRCUITPY_AUDIOFILEWRITER=$(CIRCUITPY_AUDIOFILEWRITER) + CIRCUITPY_AURORA_EPAPER ?= 0 CFLAGS += -DCIRCUITPY_AURORA_EPAPER=$(CIRCUITPY_AURORA_EPAPER) diff --git a/shared-bindings/audiofilewriter/AudioFileWriter.c b/shared-bindings/audiofilewriter/AudioFileWriter.c new file mode 100644 index 00000000000..51b63d834aa --- /dev/null +++ b/shared-bindings/audiofilewriter/AudioFileWriter.c @@ -0,0 +1,181 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include + +#include "shared/runtime/context_manager_helpers.h" +#include "py/objproperty.h" +#include "py/runtime.h" +#include "shared-bindings/audiofilewriter/AudioFileWriter.h" +#include "shared-bindings/util.h" + +// ~1 s of 16 kHz mono 16-bit PCM. Sized to absorb a worst-case SD-write stall. +#define AUDIOFILEWRITER_DEFAULT_BUFFER_SIZE (32 * 1024) + +//| class AudioFileWriter: +//| """Streams an audio source to a ``.wav`` file in the background. +//| +//| ``AudioFileWriter`` is the inverse of `audiocore.WaveFile`: rather than being +//| an audio *source* played by an `audioio.AudioOut`, it is a *sink* that +//| drives an audio source (a microphone, ``synthio``, or an ``audiofilters``/ +//| ``audiodelays``/``audiofreeverb``/``audiospeed`` effect chain) and writes +//| the resulting PCM to a file as a WAV. +//| +//| Recording runs on a background pump paced to the source's real-time rate, +//| so it does not block and does not require a Python read loop.""" +//| +//| def __init__(self, file: typing.BinaryIO, *, buffer_size: int = 32768) -> None: +//| """Create an ``AudioFileWriter`` that writes to ``file``. +//| +//| :param typing.BinaryIO file: An already-open writable binary stream +//| (a file opened in ``"wb"`` mode, or an `io.BytesIO`). The stream must +//| support seeking so the WAV header sizes can be patched when recording +//| stops. ``AudioFileWriter`` does not close it; the caller owns it. +//| :param int buffer_size: Size in bytes of the internal RAM ring that +//| decouples file-write latency from the source. Larger values tolerate +//| longer write stalls (e.g. a slow SD card) at the cost of RAM. +//| +//| The audio format (sample rate, channel count, bit depth) is taken from +//| the source at `play()` time, so there are no format arguments here. +//| +//| Recording synthio to SD:: +//| +//| import time +//| import synthio +//| from audiofilewriter import AudioFileWriter +//| import storage +//| +//| SAMPLE_RATE = 16000 +//| OUTPUT_PATH = "/sd/demo_file.wav" +//| +//| C_major_scale = [60, 62, 64, 65, 67, 69, 71, 72, 71, 69, 67, 65, 64, 62, 60] +//| synth = synthio.Synthesizer(sample_rate=SAMPLE_RATE) +//| +//| with open(OUTPUT_PATH, "wb") as f: +//| writer = AudioFileWriter(f) +//| writer.play(synth) +//| for note in C_major_scale: +//| synth.press(note) +//| time.sleep(0.1) +//| synth.release(note) +//| time.sleep(0.10) +//| writer.stop() +//| print("Done ->", OUTPUT_PATH) +//| """ +//| ... +//| +static mp_obj_t audiofilewriter_audiofilewriter_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_file, ARG_buffer_size }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_file, MP_ARG_OBJ | MP_ARG_REQUIRED, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_buffer_size, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = AUDIOFILEWRITER_DEFAULT_BUFFER_SIZE} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + // A buffer smaller than one source buffer is useless; require a sane floor. + mp_int_t buffer_size = mp_arg_validate_int_min(args[ARG_buffer_size].u_int, 512, MP_QSTR_buffer_size); + + audiofilewriter_audiofilewriter_obj_t *self = mp_obj_malloc(audiofilewriter_audiofilewriter_obj_t, &audiofilewriter_audiofilewriter_type); + common_hal_audiofilewriter_audiofilewriter_construct(self, args[ARG_file].u_obj, (uint32_t)buffer_size); + + return MP_OBJ_FROM_PTR(self); +} + +static void check_for_deinit(audiofilewriter_audiofilewriter_obj_t *self) { + if (common_hal_audiofilewriter_audiofilewriter_deinited(self)) { + raise_deinited_error(); + } +} + +//| def deinit(self) -> None: +//| """Stops recording (patching the WAV header) and releases resources.""" +//| ... +//| +static mp_obj_t audiofilewriter_audiofilewriter_deinit(mp_obj_t self_in) { + audiofilewriter_audiofilewriter_obj_t *self = MP_OBJ_TO_PTR(self_in); + common_hal_audiofilewriter_audiofilewriter_deinit(self); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiofilewriter_audiofilewriter_deinit_obj, audiofilewriter_audiofilewriter_deinit); + +//| def __enter__(self) -> AudioFileWriter: +//| """No-op used by Context Managers.""" +//| ... +//| +// Provided by context manager helper. + +//| def __exit__(self) -> None: +//| """Automatically deinitializes when exiting a context. See +//| :ref:`lifetime-and-contextmanagers` for more info.""" +//| ... +//| +// Provided by context manager helper. + +//| def play(self, sample: circuitpython_typing.AudioSample) -> None: +//| """Begin recording ``sample`` to the file. Does not block. +//| +//| Writes a WAV header (in the format of ``sample``) and starts the +//| background pump. ``sample`` must be an 8-bit or 16-bit mono or stereo +//| audio source. Use `playing` to tell when a finite source has finished, +//| or call `stop()` to end recording of a continuous source (e.g. a mic).""" +//| ... +//| +static mp_obj_t audiofilewriter_audiofilewriter_obj_play(mp_obj_t self_in, mp_obj_t sample_in) { + audiofilewriter_audiofilewriter_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + common_hal_audiofilewriter_audiofilewriter_play(self, sample_in); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(audiofilewriter_audiofilewriter_play_obj, audiofilewriter_audiofilewriter_obj_play); + +//| def stop(self) -> None: +//| """Stop recording, flush the RAM ring to the file, and patch the WAV +//| header sizes. The file is left open for the caller to close.""" +//| ... +//| +static mp_obj_t audiofilewriter_audiofilewriter_obj_stop(mp_obj_t self_in) { + audiofilewriter_audiofilewriter_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + common_hal_audiofilewriter_audiofilewriter_stop(self); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiofilewriter_audiofilewriter_stop_obj, audiofilewriter_audiofilewriter_obj_stop); + +//| playing: bool +//| """True while recording is in progress. Becomes False on its own when a +//| finite source finishes, or after `stop()`. (read-only)""" +//| +static mp_obj_t audiofilewriter_audiofilewriter_obj_get_playing(mp_obj_t self_in) { + audiofilewriter_audiofilewriter_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + return mp_obj_new_bool(common_hal_audiofilewriter_audiofilewriter_get_playing(self)); +} +static MP_DEFINE_CONST_FUN_OBJ_1(audiofilewriter_audiofilewriter_get_playing_obj, audiofilewriter_audiofilewriter_obj_get_playing); + +MP_PROPERTY_GETTER(audiofilewriter_audiofilewriter_playing_obj, + (mp_obj_t)&audiofilewriter_audiofilewriter_get_playing_obj); + +static const mp_rom_map_elem_t audiofilewriter_audiofilewriter_locals_dict_table[] = { + // Methods + { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&audiofilewriter_audiofilewriter_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR___enter__), MP_ROM_PTR(&default___enter___obj) }, + { MP_ROM_QSTR(MP_QSTR___exit__), MP_ROM_PTR(&default___exit___obj) }, + { MP_ROM_QSTR(MP_QSTR_play), MP_ROM_PTR(&audiofilewriter_audiofilewriter_play_obj) }, + { MP_ROM_QSTR(MP_QSTR_stop), MP_ROM_PTR(&audiofilewriter_audiofilewriter_stop_obj) }, + + // Properties + { MP_ROM_QSTR(MP_QSTR_playing), MP_ROM_PTR(&audiofilewriter_audiofilewriter_playing_obj) }, +}; +static MP_DEFINE_CONST_DICT(audiofilewriter_audiofilewriter_locals_dict, audiofilewriter_audiofilewriter_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + audiofilewriter_audiofilewriter_type, + MP_QSTR_AudioFileWriter, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, audiofilewriter_audiofilewriter_make_new, + locals_dict, &audiofilewriter_audiofilewriter_locals_dict + ); diff --git a/shared-bindings/audiofilewriter/AudioFileWriter.h b/shared-bindings/audiofilewriter/AudioFileWriter.h new file mode 100644 index 00000000000..22d456979b9 --- /dev/null +++ b/shared-bindings/audiofilewriter/AudioFileWriter.h @@ -0,0 +1,23 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "py/obj.h" + +#include "shared-module/audiofilewriter/AudioFileWriter.h" + +extern const mp_obj_type_t audiofilewriter_audiofilewriter_type; + +void common_hal_audiofilewriter_audiofilewriter_construct(audiofilewriter_audiofilewriter_obj_t *self, + mp_obj_t file, uint32_t buffer_size); + +void common_hal_audiofilewriter_audiofilewriter_deinit(audiofilewriter_audiofilewriter_obj_t *self); +bool common_hal_audiofilewriter_audiofilewriter_deinited(audiofilewriter_audiofilewriter_obj_t *self); + +void common_hal_audiofilewriter_audiofilewriter_play(audiofilewriter_audiofilewriter_obj_t *self, mp_obj_t sample); +void common_hal_audiofilewriter_audiofilewriter_stop(audiofilewriter_audiofilewriter_obj_t *self); +bool common_hal_audiofilewriter_audiofilewriter_get_playing(audiofilewriter_audiofilewriter_obj_t *self); diff --git a/shared-bindings/audiofilewriter/__init__.c b/shared-bindings/audiofilewriter/__init__.c new file mode 100644 index 00000000000..e67a74b9451 --- /dev/null +++ b/shared-bindings/audiofilewriter/__init__.c @@ -0,0 +1,29 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include + +#include "py/obj.h" +#include "py/runtime.h" + +#include "shared-bindings/audiofilewriter/__init__.h" +#include "shared-bindings/audiofilewriter/AudioFileWriter.h" + +//| """Support for streaming audio to a WAV file""" + +static const mp_rom_map_elem_t audiofilewriter_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_audiofilewriter) }, + { MP_ROM_QSTR(MP_QSTR_AudioFileWriter), MP_ROM_PTR(&audiofilewriter_audiofilewriter_type) }, +}; + +static MP_DEFINE_CONST_DICT(audiofilewriter_module_globals, audiofilewriter_module_globals_table); + +const mp_obj_module_t audiofilewriter_module = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&audiofilewriter_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_audiofilewriter, audiofilewriter_module); diff --git a/shared-bindings/audiofilewriter/__init__.h b/shared-bindings/audiofilewriter/__init__.h new file mode 100644 index 00000000000..779b49ffd8d --- /dev/null +++ b/shared-bindings/audiofilewriter/__init__.h @@ -0,0 +1,7 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once diff --git a/shared-module/audiofilewriter/AudioFileWriter.c b/shared-module/audiofilewriter/AudioFileWriter.c new file mode 100644 index 00000000000..099345888a0 --- /dev/null +++ b/shared-module/audiofilewriter/AudioFileWriter.c @@ -0,0 +1,403 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include "shared-bindings/audiofilewriter/AudioFileWriter.h" +#include "shared-bindings/audiocore/__init__.h" +#include "shared-module/audiocore/__init__.h" + +#include + +#include "py/mperrno.h" +#include "py/runtime.h" +#include "py/stream.h" + +#include "supervisor/background_callback.h" +#include "supervisor/shared/tick.h" + +// --------------------------------------------------------------------------- +// Little-endian header helpers +// --------------------------------------------------------------------------- + +static void put_u16le(uint8_t *p, uint16_t v) { + p[0] = (uint8_t)v; + p[1] = (uint8_t)(v >> 8); +} + +static void put_u32le(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)v; + p[1] = (uint8_t)(v >> 8); + p[2] = (uint8_t)(v >> 16); + p[3] = (uint8_t)(v >> 24); +} + +// --------------------------------------------------------------------------- +// Active-writer registry, walked once per supervisor tick. +// +// The list head lives in MP_STATE_VM (see MP_REGISTER_ROOT_POINTER below) so +// that (a) active writers and their reg_next chain are GC-rooted while +// recording, and (b) the list is wiped automatically when the VM heap is +// recreated on soft reset. +// --------------------------------------------------------------------------- + +#define REGISTRY_HEAD ((audiofilewriter_audiofilewriter_obj_t *)MP_STATE_VM(audiofilewriter_linked_list)) + +// Add self to the registry. Called from Python (play()) context, so it must +// guard against a background tick walking the list mid-mutation. +static void audiofilewriter_register(audiofilewriter_audiofilewriter_obj_t *self) { + background_callback_prevent(); + // Avoid double-linking if already present. + bool present = false; + for (audiofilewriter_audiofilewriter_obj_t *w = REGISTRY_HEAD; w != NULL; w = w->reg_next) { + if (w == self) { + present = true; + break; + } + } + if (!present) { + self->reg_next = REGISTRY_HEAD; + MP_STATE_VM(audiofilewriter_linked_list) = self; + } + background_callback_allow(); +} + +// Remove self from the registry. Callers must ensure no background tick is +// walking the list concurrently: either they are the background tick itself +// (single-threaded, so safe), or they wrap the call in prevent/allow. +static void audiofilewriter_unregister(audiofilewriter_audiofilewriter_obj_t *self) { + audiofilewriter_audiofilewriter_obj_t **pp = (audiofilewriter_audiofilewriter_obj_t **)&MP_STATE_VM(audiofilewriter_linked_list); + while (*pp != NULL) { + if (*pp == self) { + *pp = self->reg_next; + self->reg_next = NULL; + return; + } + pp = &(*pp)->reg_next; + } +} + +// --------------------------------------------------------------------------- +// RAM ring +// --------------------------------------------------------------------------- + +// Copy len bytes from src into the ring. The caller guarantees there is room. +// 8-bit signed PCM is flipped to unsigned to match the WAV convention. +static void audiofilewriter_ring_write(audiofilewriter_audiofilewriter_obj_t *self, const uint8_t *src, uint32_t len) { + bool flip = (self->bits_per_sample == 8 && self->samples_signed); + uint32_t i = 0; + while (i < len) { + uint32_t span = self->ring_size - self->ring_head; + if (span > (len - i)) { + span = len - i; + } + if (flip) { + for (uint32_t j = 0; j < span; j++) { + self->ring[self->ring_head + j] = src[i + j] ^ 0x80; + } + } else { + memcpy(self->ring + self->ring_head, src + i, span); + } + self->ring_head += span; + if (self->ring_head == self->ring_size) { + self->ring_head = 0; + } + i += span; + } + self->ring_count += len; +} + +// Drain the ring to the file. Returns false on a write error. Non-raising: +// safe to call from background-task context. +static bool audiofilewriter_flush(audiofilewriter_audiofilewriter_obj_t *self) { + while (self->ring_count > 0) { + uint32_t span = self->ring_size - self->ring_tail; + if (span > self->ring_count) { + span = self->ring_count; + } + int err = 0; + mp_uint_t wrote = mp_stream_write_exactly(self->file, self->ring + self->ring_tail, span, &err); + if (err != 0 || wrote != span) { + return false; + } + self->ring_tail += span; + if (self->ring_tail == self->ring_size) { + self->ring_tail = 0; + } + self->ring_count -= span; + self->data_bytes += span; + } + return true; +} + +// --------------------------------------------------------------------------- +// Header patching + finalize +// --------------------------------------------------------------------------- + +static void audiofilewriter_patch_header(audiofilewriter_audiofilewriter_obj_t *self) { + uint8_t sz[4]; + int err = 0; + + // RIFF chunk size lives at header_offset + 4. + put_u32le(sz, 36 + self->data_bytes); + if (mp_stream_seek(self->file, self->header_offset + 4, MP_SEEK_SET, &err) == (mp_off_t)-1) { + return; + } + mp_stream_write_exactly(self->file, sz, 4, &err); + + // data chunk size lives at header_offset + 40. + put_u32le(sz, self->data_bytes); + if (mp_stream_seek(self->file, self->header_offset + 40, MP_SEEK_SET, &err) == (mp_off_t)-1) { + return; + } + mp_stream_write_exactly(self->file, sz, 4, &err); + + // Leave the cursor at the end of the PCM so the caller can keep appending + // or simply close the file. + mp_stream_seek(self->file, self->header_offset + 44 + self->data_bytes, MP_SEEK_SET, &err); +} + +// Stop pumping, drain, patch the header, and release the source. Idempotent: +// only the first call (while playing) does work. Non-raising. +static void audiofilewriter_finalize(audiofilewriter_audiofilewriter_obj_t *self) { + if (!self->playing) { + return; + } + // Stop the pump first so a background tick can't re-enter us. + self->playing = false; + + audiofilewriter_flush(self); + audiofilewriter_patch_header(self); + + supervisor_disable_tick(); + audiofilewriter_unregister(self); + self->sample = MP_OBJ_NULL; +} + +// --------------------------------------------------------------------------- +// The pump: one real-time-paced step per supervisor tick +// --------------------------------------------------------------------------- + +static void audiofilewriter_pump(audiofilewriter_audiofilewriter_obj_t *self) { + if (!self->playing) { + return; + } + + // The pull granularity through the chain is one source buffer, so we pace + // in whole-buffer units. + int64_t frames_per_pull = (int64_t)(self->source_max_buffer / self->bytes_per_frame); + if (frames_per_pull < 1) { + frames_per_pull = 1; + } + + // Accrue a real-time sample budget from elapsed ticks. + uint64_t now = supervisor_ticks_ms64(); + uint64_t elapsed = now - self->last_tick_ms; + self->last_tick_ms = now; + // Ignore long gaps (GC pause, SD stall) so we don't try to catch up an + // unbounded backlog all at once. + if (elapsed > 100) { + elapsed = 100; + } + self->budget_frames += (int64_t)((elapsed * self->sample_rate) / 1000); + // Never bank more than a single buffer of catch-up. For a LIVE source the + // captured audio sits in the source's own bounded ring; once we fall more + // than that behind, the surplus is already gone, so a large banked budget + // cannot recover it -- it would only burst-drain the source on consecutive + // ticks and then silence-pad, stretching one stall into a long glitch. + // Capping at one buffer yields at most one brief discontinuity per stall. + if (self->budget_frames > frames_per_pull) { + self->budget_frames = frames_per_pull; + } + + // Pull one buffer only once a full buffer of real time has elapsed: strict + // real-time pacing so we never pull ahead of a live source (which would + // only hand back silence). Also require room for a full source buffer. + if (!self->source_done && self->budget_frames >= frames_per_pull && + (self->ring_size - self->ring_count) >= self->source_max_buffer) { + uint8_t *buf = NULL; + uint32_t len = 0; + audioio_get_buffer_result_t res = + audiosample_get_buffer(self->sample, false, 0, &buf, &len); + if (res == GET_BUFFER_ERROR) { + self->source_done = true; + } else { + if (len > 0 && buf != NULL) { + // Defend against a source that hands back more than it advertised. + if (len > self->source_max_buffer) { + len = self->source_max_buffer; + } + audiofilewriter_ring_write(self, buf, len); + self->budget_frames -= (int64_t)(len / self->bytes_per_frame); + } + if (res == GET_BUFFER_DONE) { + self->source_done = true; + } + } + } + + if (!audiofilewriter_flush(self)) { + // File write failed; give up gracefully rather than spin. + self->source_done = true; + } + + if (self->source_done && self->ring_count == 0) { + audiofilewriter_finalize(self); + } +} + +void audiofilewriter_background(void) { + audiofilewriter_audiofilewriter_obj_t *self = REGISTRY_HEAD; + while (self != NULL) { + // Capture next before pumping: pump() may finalize self, which unlinks + // it from the registry (but leaves our saved next pointer valid). + audiofilewriter_audiofilewriter_obj_t *next = self->reg_next; + audiofilewriter_pump(self); + self = next; + } +} + +// Called during soft reset (VM teardown). Any writer still active is abandoned: +// we don't try to touch its file (it may already be gone), we just balance the +// tick-enable count and drop it from the list. +void audiofilewriter_reset(void) { + audiofilewriter_audiofilewriter_obj_t *self = REGISTRY_HEAD; + while (self != NULL) { + audiofilewriter_audiofilewriter_obj_t *next = self->reg_next; + if (self->playing) { + self->playing = false; + supervisor_disable_tick(); + } + self->reg_next = NULL; + self = next; + } + MP_STATE_VM(audiofilewriter_linked_list) = NULL; +} + +// --------------------------------------------------------------------------- +// common-hal surface +// --------------------------------------------------------------------------- + +void common_hal_audiofilewriter_audiofilewriter_construct(audiofilewriter_audiofilewriter_obj_t *self, + mp_obj_t file, uint32_t buffer_size) { + // The file must be a writable, seekable binary stream (a file or BytesIO). + mp_get_stream_raise(file, MP_STREAM_OP_WRITE | MP_STREAM_OP_IOCTL); + + self->file = file; + self->sample = MP_OBJ_NULL; + self->ring_size = buffer_size; + self->ring = m_malloc(buffer_size); + self->ring_head = 0; + self->ring_tail = 0; + self->ring_count = 0; + self->playing = false; + self->source_done = false; + self->reg_next = NULL; +} + +bool common_hal_audiofilewriter_audiofilewriter_deinited(audiofilewriter_audiofilewriter_obj_t *self) { + return self->ring == NULL; +} + +void common_hal_audiofilewriter_audiofilewriter_deinit(audiofilewriter_audiofilewriter_obj_t *self) { + if (self->playing) { + common_hal_audiofilewriter_audiofilewriter_stop(self); + } + self->ring = NULL; + self->file = MP_OBJ_NULL; + self->sample = MP_OBJ_NULL; +} + +void common_hal_audiofilewriter_audiofilewriter_play(audiofilewriter_audiofilewriter_obj_t *self, mp_obj_t sample_obj) { + if (self->playing) { + mp_raise_RuntimeError(MP_ERROR_TEXT("Already in progress")); + } + + audiosample_base_t *sample = audiosample_check(sample_obj); + uint32_t rate = audiosample_get_sample_rate(sample); + uint8_t channels = audiosample_get_channel_count(sample); + uint8_t bits = audiosample_get_bits_per_sample(sample); + bool single_buffer, samples_signed; + uint32_t max_buffer_length; + uint8_t spacing; + audiosample_get_buffer_structure(sample, false, &single_buffer, &samples_signed, + &max_buffer_length, &spacing); + + if ((bits != 8 && bits != 16) || channels < 1 || channels > 2) { + mp_raise_ValueError(MP_ERROR_TEXT("Only 8/16-bit mono/stereo is supported")); + } + if (max_buffer_length == 0 || self->ring_size < max_buffer_length) { + mp_raise_ValueError(MP_ERROR_TEXT("buffer_size too small for source")); + } + + self->sample_rate = rate; + self->channel_count = channels; + self->bits_per_sample = bits; + self->samples_signed = samples_signed; + self->bytes_per_frame = (uint8_t)(channels * (bits / 8)); + self->source_max_buffer = max_buffer_length; + + // Remember where the header starts so stop() can patch its size fields, + // then write a placeholder header with zeroed sizes. + int err = 0; + mp_off_t off = mp_stream_seek(self->file, 0, MP_SEEK_CUR, &err); + if (off == (mp_off_t)-1) { + mp_raise_OSError(err ? err : MP_EIO); + } + self->header_offset = (uint32_t)off; + + uint32_t block_align = self->bytes_per_frame; + uint32_t byte_rate = rate * block_align; + uint8_t hdr[44]; + memcpy(hdr + 0, "RIFF", 4); + put_u32le(hdr + 4, 36); // RIFF size (patched at stop) + memcpy(hdr + 8, "WAVE", 4); + memcpy(hdr + 12, "fmt ", 4); + put_u32le(hdr + 16, 16); // fmt chunk size + put_u16le(hdr + 20, 1); // PCM + put_u16le(hdr + 22, channels); + put_u32le(hdr + 24, rate); + put_u32le(hdr + 28, byte_rate); + put_u16le(hdr + 32, (uint16_t)block_align); + put_u16le(hdr + 34, bits); + memcpy(hdr + 36, "data", 4); + put_u32le(hdr + 40, 0); // data size (patched at stop) + + err = 0; + mp_uint_t wrote = mp_stream_write_exactly(self->file, hdr, sizeof(hdr), &err); + if (err != 0 || wrote != sizeof(hdr)) { + mp_raise_OSError(err ? err : MP_EIO); + } + + audiosample_reset_buffer(sample_obj, false, 0); + + self->sample = sample_obj; + self->ring_head = 0; + self->ring_tail = 0; + self->ring_count = 0; + self->budget_frames = 0; + self->data_bytes = 0; + self->source_done = false; + self->last_tick_ms = supervisor_ticks_ms64(); + self->playing = true; + + audiofilewriter_register(self); + supervisor_enable_tick(); +} + +void common_hal_audiofilewriter_audiofilewriter_stop(audiofilewriter_audiofilewriter_obj_t *self) { + if (!self->playing) { + return; + } + // Keep the background pump out while we finalize from Python context. + background_callback_prevent(); + audiofilewriter_finalize(self); + background_callback_allow(); +} + +bool common_hal_audiofilewriter_audiofilewriter_get_playing(audiofilewriter_audiofilewriter_obj_t *self) { + return self->playing; +} + +MP_REGISTER_ROOT_POINTER(mp_obj_t audiofilewriter_linked_list); diff --git a/shared-module/audiofilewriter/AudioFileWriter.h b/shared-module/audiofilewriter/AudioFileWriter.h new file mode 100644 index 00000000000..0e99e90e915 --- /dev/null +++ b/shared-module/audiofilewriter/AudioFileWriter.h @@ -0,0 +1,67 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +#include "py/obj.h" + +// A streaming WAV *sink*: it consumes an audiosample source (a mic, synthio, +// or an effect chain) and writes the resulting PCM to a file. Unlike WaveFile +// it is NOT an audiosample, it is the thing that drives a source, playing the +// role an AudioOut would. +typedef struct _audiofilewriter_audiofilewriter_obj_t { + mp_obj_base_t base; + + // Output stream (anything with write + MP_STREAM_SEEK ioctl: a file or a + // BytesIO). Held so it stays referenced while recording. + mp_obj_t file; + // The source being recorded. Only valid (and referenced) while playing. + mp_obj_t sample; + + // Format, captured from the source at play() time. AudioFileWriter is the + // format authority for the WAV header. + uint32_t sample_rate; + uint8_t channel_count; + uint8_t bits_per_sample; + bool samples_signed; + uint8_t bytes_per_frame; // channel_count * bits_per_sample / 8 + uint32_t source_max_buffer; // largest buffer the source can hand back, bytes + + // RAM ring that decouples SD-write latency from the source. Written by the + // pump, drained to the file by the pump. Only touched from background-task + // context (never an ISR), so no locking is needed. + uint8_t *ring; + uint32_t ring_size; // capacity in bytes + uint32_t ring_head; // write cursor + uint32_t ring_tail; // read cursor + uint32_t ring_count; // bytes currently buffered + + // Real-time pacing: budget accrues sample_rate frames per second of elapsed + // supervisor ticks; a buffer is pulled only when budget is positive. + int64_t budget_frames; + uint64_t last_tick_ms; + + // Absolute file offset of the RIFF header start, so stop() can seek back and + // patch the two size fields once the final length is known. + uint32_t header_offset; + uint32_t data_bytes; // total PCM bytes handed to the file + + bool playing; + bool source_done; // source returned DONE/ERROR; drain then finalize + + // Intrusive linked list of active writers, walked once per supervisor tick. + struct _audiofilewriter_audiofilewriter_obj_t *reg_next; +} audiofilewriter_audiofilewriter_obj_t; + +// Called once per supervisor tick (from supervisor_background_tick), in +// background-task context. Pumps every active writer. +void audiofilewriter_background(void); + +// Called during soft reset to abandon any writer left recording. +void audiofilewriter_reset(void); diff --git a/shared-module/audiofilewriter/__init__.c b/shared-module/audiofilewriter/__init__.c new file mode 100644 index 00000000000..584c821b996 --- /dev/null +++ b/shared-module/audiofilewriter/__init__.c @@ -0,0 +1,5 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT diff --git a/supervisor/shared/tick.c b/supervisor/shared/tick.c index 346ef9a93c4..81815c0813b 100644 --- a/supervisor/shared/tick.c +++ b/supervisor/shared/tick.c @@ -27,6 +27,10 @@ #include "shared-module/keypad/__init__.h" #endif +#if CIRCUITPY_AUDIOFILEWRITER +#include "shared-module/audiofilewriter/AudioFileWriter.h" +#endif + #include "shared-bindings/microcontroller/__init__.h" #if CIRCUITPY_WATCHDOG @@ -59,6 +63,10 @@ static void supervisor_background_tick(void *unused) { filesystem_background(); + #if CIRCUITPY_AUDIOFILEWRITER + audiofilewriter_background(); + #endif + port_background_tick(); assert_heap_ok();