Skip to content

Fix request handoff and lazy begin() races in Speaker_Class - #295

Merged
lovyan03 merged 2 commits into
m5stack:developfrom
ainyan03:spk_publish
Aug 1, 2026
Merged

Fix request handoff and lazy begin() races in Speaker_Class#295
lovyan03 merged 2 commits into
m5stack:developfrom
ainyan03:spk_publish

Conversation

@ainyan03

@ainyan03 ainyan03 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Problem

Requests reach the playback task by whole-struct assignment into a shared slot. That compiles to memcpy, which publishes the fields in no particular order, so the task can act on a half-written request. The volatile members give some accidental ordering on Xtensa, but on RISC-V volatile emits no barriers at all, and the ESP32-P4 is dual-core. On top of that, stop() and stop_current requests overwrite slots in place while the task may be reading them, and a writer that reads flip while the task moves it can publish into a slot the task no longer watches.

Separately, begin() runs lazily from whichever task plays first, and the setup starts by uninstalling the port: two first-use calls racing each other destroy the winner's live channel. Reproduced on hardware — i2s_channel_write gets a NULL handle and the board goes into a reboot loop.

Fix

First commit — each slot carries an atomic state byte (empty / writing / published / playing). A writer claims a slot by CAS before touching its payload (a preempting request may also take a queued one's place), and the task claims a published slot by CAS and plays from its own copy, so no one reads or writes a slot someone else holds. The flags that must be readable without the payload ride in the same byte: cutting the current sound, infinite repeat, pure-stop marker. Every handoff is a release/acquire on that byte.

Second commitbegin() is serialized, and its lock-free early return keys on a flag that is set only once the port and task are fully up. The microphone gets the same lock (its sample-rate rebuild runs under it too), and now records the rate the port was first built for, so a second record() at the same rate no longer tears the port down and rebuilds it.

Verified

  • Builds across ESP32 (IDF 4.4 / 5.x), S3, C6, P4, and the SDL simulator.
  • Disassembly on RISC-V shows payload writes, then fence rw,w, then the state store on publish; acquiring claims; nothing on the per-sample path got heavier (the playback loop now reads a plain local copy where it used to re-read volatile fields).
  • On CoreS3 and Tab5 hardware: a timing regression check (single 250 ms / queued 500 ms plays, all trials in range), a stress run (3000 preempting tones against 3000 stop() calls from another core, then 40 queued requests all accepted, clean convergence, no reboot with concurrent first-use begin()), and listening checks (single tones, gapless chaining, instant preempt cut, chord, stop).

Notes

A request used to reach the speaker task as a whole-struct assignment,
which compiles to memcpy and publishes the fields in no particular
order, so the task could act on a half-written slot. Stops and
preempting requests also overwrote a slot the task might be reading,
and could land in the wrong slot when flip moved between the read and
the write.

Each slot now carries an atomic state byte and every handoff is a
claim on it: writers take empty slots (a preempting request may also
take a queued one's place), the task takes published ones and plays
from its own copy, so no one touches a slot someone else holds. The
flags that must be readable without the payload ride in the same byte:
cutting the current sound, refusing to queue behind an endless
request, counting isPlaying().
Playback and recording both call begin() lazily from whichever task
touches them first, and setup starts by tearing the port down: two of
these racing rip the live channel out from under the running task,
which then feeds i2s a null handle and takes the whole board down.
One caller now goes through at a time; the rest wait and find the
work already done. The microphone gets the same lock around its
sample-rate rebuild, and now remembers the rate the port was built
for, so a second recording at the same rate no longer rebuilds it.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the audio subsystem against multi-core races by changing the speaker request handoff to a slot-ownership protocol (atomic state + release/acquire publication) and by serializing lazy begin() initialization so concurrent first-use calls can’t tear down each other’s I2S channel setup.

Changes:

  • Reworks Speaker_Class request queueing so writers/task claim slots via CAS and the task plays from a private copy, avoiding torn whole-struct publishes.
  • Serializes lazy begin() for both speaker and microphone using an atomic lock plus a “fully begun” flag for safe lock-free fast paths.
  • Adjusts stop/preempt behavior to route through the new _set_next_wav() protocol.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/utility/Speaker_Class.hpp Introduces slot/state protocol types and updates isPlaying() to use slot occupancy instead of volatile fields.
src/utility/Speaker_Class.cpp Implements slot claiming/publication in the playback task and writers; adds serialized begin() and updates stop paths.
src/utility/Mic_Class.hpp Adds serialized begin() state (_begin_lock/_begun) for microphone initialization.
src/utility/Mic_Class.cpp Implements serialized mic begin() and factors setup into _begin_locked() with a safe fast path.
Suppressed comments (1)

src/utility/Speaker_Class.cpp:1064

  • stop(uint8_t) now becomes a no-op unless _play_channel_bits already has the channel bit set. Similar to stop(), this can miss a channel that has a request in-flight (e.g., wav_phase_writing) but hasn’t set the bit yet, leaving the just-queued request to play despite a stop(channel) call.
  void Speaker_Class::stop(uint8_t ch)
  {
    if ((size_t)ch >= sound_channel_max)
    {
      stop();
    }
    else if (_play_channel_bits.load() & (1 << ch))
    {
      wav_info_t tmp;
      tmp.stop_current = 1;
      _set_next_wav(ch, tmp);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/utility/Speaker_Class.cpp
@lovyan03
lovyan03 merged commit 43ce161 into m5stack:develop Aug 1, 2026
23 checks passed
@ainyan03
ainyan03 deleted the spk_publish branch August 1, 2026 21:28
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.

3 participants