Visitar URL original
Add setBufferReleaseCallback to Speaker and Mic by ainyan03 · Pull Request #356 · m5stack/M5Unified · GitHub
Skip to content

Add setBufferReleaseCallback to Speaker and Mic - #356

Merged
lovyan03 merged 2 commits into
m5stack:developfrom
ainyan03:buffer_release_callback
Sep 13, 2026
Merged

lovyan03 merged 2 commits into
m5stack:developfrom
ainyan03:buffer_release_callback

Conversation

@ainyan03

Copy link
Copy Markdown
Contributor

What

A callback that the playback / capture task calls once it has finished with a buffer given to playRaw() / playWav() / tone() or record(). From that point the caller may overwrite (Speaker) or read (Mic) the buffer.

M5.Speaker.setBufferReleaseCallback(args, [](void* args, const void* data, uint8_t channel) { ... });
M5.Mic.setBufferReleaseCallback(args, [](void* args, void* data, size_t length) { ... });

With it, two buffers used alternately are enough for runtime-generated audio. The playRaw() notes used to say three were needed; that was conservative even before (waiting for isPlaying(channel) < 2 gives the same guarantee for a single producer), and the notes now say so.

Contract (documented in the headers)

  • The notice is per request, not per memory region. For playWav() the pointer is the PCM chunk inside the wav.
  • Requests discarded unplayed do not call back (queued ones replaced by stop() / stop_current_sound, everything dropped by end()); the one being played when stop() cuts it does. Delivery follows the slot release, so it can arrive after isPlaying() / isRecording() already dropped: track buffers by pointer.
  • Task context (never an ISR); keep it short, never block, never call begin() / end() from it. The sound may still be in the I2S queue.
  • play*() / record() may be called from within: they never wait there and return false if no slot is free.
  • Set or clear only before the first request or after end() returned.

Supporting changes

  • play*() returns true only when the request was queued (it used to return true for a speaker that could not start and for an empty request).
  • Speaker: task handle is atomic; end() is serialized with begin() under the same lock.
  • _slot_occupied loads with acquire (ordering for producers that poll isPlaying()).

Examples

  • Speak_with_AquesTalk / Speaker_SD_wav_file / Bluetooth_with_ESP32A2DP: two buffers with atomic busy flags.
  • WebRadio / MP3 (ESP8266Audio wrappers): three buffers in rotation with the callback (the third keeps one buffer of slack against decoder / network jitter). Registered from an explicit setup() after M5.begin().
  • Basic/Microphone: draws the block the callback reports as filled.

Fixes found while verifying the examples on hardware (same commit):

  • Bluetooth: the phone's AVRCP volume now drives the speaker and local button changes are reported back; builds on the current ESP32-A2DP (1.8.x) as well as 1.7.x.
  • WebRadio: WiFi modem sleep off (with it on, the stream buffer drained at ~2 KB/s while the link could deliver far more), 32 KB stream buffer with pre-buffering and refill, stations that cannot be opened or stop early are skipped, the station list is refreshed (the first entry returned 404), WIFI_SSID / WIFI_PASS can be passed from the build.
  • Speaker_SD_wav_file plays every *.wav in the root of the card; MP3_with_ESP8266Audio plays every *.mp3 in /mp3.

Verification

  • ESP32-S3 (StickS3): two-buffer ping-pong driven from the callback, and refilling from inside the callback, 0 overlaps with the task reading the buffer (checked by inspecting the task state during refills); three-buffer and isPlaying-wait forms unchanged. Basic/Microphone record / playback on hardware.
  • CoreS3 SE: Speaker_SD_wav_file, MP3_with_ESP8266Audio (ESP8266Audio 2.4.1), WebRadio_with_ESP8266Audio (long play without dropouts, station switching).
  • Core Basic: Bluetooth_with_ESP32A2DP with ESP32-A2DP 1.7.x on Arduino core 2.x and 1.8.11 on core 3.x, including volume from the phone.
  • Not verified: Speak_with_AquesTalk (licensed library not available here); compiles with the same wrapper pattern as the others but was not built.

Breaking changes

  • playRaw() / playWav() / tone() now return false when the speaker could not be started or the request is empty (previously true).

Fork CI (Arduino / ESP-IDF builds) passed on ainyan03/M5Unified before this PR.

Register a function that the playback/capture task calls once it has
finished with a buffer given to playRaw()/playWav()/tone() or record():
from then on the caller may overwrite (Speaker) or read (Mic) it. With
it, two buffers used alternately are enough for runtime-generated
audio; the playRaw notes said three were needed, which was conservative
even before (waiting for isPlaying(channel) < 2 gives the same
guarantee for a single producer), so the notes now say so.

Contract (see the header):
- Per request, not per memory region: a queued request or a re-triggered
  tone using the same data keeps it in use. For playWav() the pointer is
  the PCM chunk inside the wav.
- Requests discarded unplayed do not call back: queued ones replaced by
  stop()/stop_current_sound, and everything dropped by end(). The one
  being played when stop() cuts it does. Delivery follows the slot
  release, so it can arrive after isPlaying()/isRecording() dropped:
  track buffers by pointer, not by counting.
- Task context, never an ISR; the sound may still be in the I2S queue.
- play*()/record() may be called from within: they never wait there and
  never start the lifecycle (an end() may be tearing the task down), so
  they return false if no slot is free. A Mic rate change from within is
  refused before it is committed.
- Set or clear only before the first request or after end() returned.

Supporting changes:
- play*() answers true only when the request was queued (it used to say
  true for a speaker that could not start and for an empty request; a
  producer marking a buffer busy on true would then wait forever).
- Speaker: the task handle is atomic (read from every play*() caller),
  and end() is serialized with begin() under the same lock so a teardown
  cannot slip in between task creation and handle publication.
- _slot_occupied loads with acquire, so a producer polling isPlaying()
  is ordered after the task's last read of the buffer.

Verified on ESP32-S3: two-buffer ping-pong driven from the callback and
refilling from inside the callback, 0 overlaps with the task reading the
buffer; three-buffer and isPlaying-wait forms unchanged.
…, and fixes found while verifying

Speaker examples that generate or stream audio at runtime used three
buffers in rotation so that the one being refilled could not be the one
the speaker task was still reading. They now refill a buffer only after
the release callback has reported it free, tracking each buffer by
pointer with an atomic busy flag (release on the callback side, acquire
on the waiting side):
- Speak_with_AquesTalk, Speaker_SD_wav_file, Bluetooth_with_ESP32A2DP:
  two buffers. Bluetooth uses fixed-size storage instead of per-packet
  heap blocks (the callback and the display compare against addresses
  that must not change), truncates an oversized packet, and shows a zero
  waveform after a stop instead of clearing buffers the task may read.
- WebRadio/MP3 (ESP8266Audio wrappers): three buffers in rotation - two
  would satisfy the handshake, the third keeps one buffer of slack
  against decoder and network jitter. The callback is registered from an
  explicit setup() after M5.begin(), before anything plays. stop() lets
  the queued buffers play out rather than cutting them, so every buffer
  comes back through the callback.
- Zero-length requests are skipped before marking a buffer busy.

Basic/Microphone draws the block the callback reports as filled (atomic
mailbox holding the newest block) instead of a block assumed to be two
behind the record position, and clears the mailbox across a Mic restart.

Fixes found while verifying the examples on hardware:
- Bluetooth: the phone's AVRCP volume now drives the speaker (the library
  applied it inside the audio_data_callback this example overrides) and
  a local button change is reported back with set_volume(). The example
  builds on the current ESP32-A2DP (set_stream_reader(nullptr, false)
  replaces the removed is_i2s_output; works on 1.7.x too).
- WebRadio: WiFi modem sleep off - with it on the stream buffer drained
  at about 2 KB/s while the link could deliver far more. Buffer 5 KB ->
  32 KB, filled before decoding starts (triggering the library's own
  first read, which replaces rather than adds) and refilled below 1/8.
  Stations that cannot be opened or stop before two seconds of audio
  came out are skipped with a note; a stream that ends after that moves
  on to the next station; when every station failed in a row the task
  waits before going round again. The decode task owns the station
  number and the UI requests absolute numbers, so a selection is never
  lost to an auto-skip. Station list refreshed (the first one returned
  404, several others no longer answer). WIFI_SSID/WIFI_PASS may be
  passed from the build.
- Speaker_SD_wav_file plays every *.wav in the root of the card;
  MP3_with_ESP8266Audio plays every *.mp3 in /mp3, one after another.
@ainyan03
ainyan03 force-pushed the buffer_release_callback branch from 1d8ae2b to 5f01745 Compare September 13, 2026 07:11
@lovyan03
lovyan03 merged commit d31cd73 into m5stack:develop Sep 13, 2026
27 checks passed
@ainyan03
ainyan03 deleted the buffer_release_callback branch September 13, 2026 07:21
@ainyan03 ainyan03 mentioned this pull request Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants