Fix request handoff and lazy begin() races in Speaker_Class - #295
Merged
Conversation
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.
There was a problem hiding this comment.
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_Classrequest 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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()andstop_currentrequests overwrite slots in place while the task may be reading them, and a writer that readsflipwhile 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_writegets 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 commit —
begin()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 secondrecord()at the same rate no longer tears the port down and rebuilds it.Verified
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).stop()calls from another core, then 40 queued requests all accepted, clean convergence, no reboot with concurrent first-usebegin()), and listening checks (single tones, gapless chaining, instant preempt cut, chord, stop).Notes
begin()/end()concurrent with playback or recording calls, and concurrentrecord()at different sample rates, are not supported.