mixer: a queued COMMAND_START is discarded when the mixer task is reaped, leaving source speakers running with nothing draining them
- Dominant language
- No language data
- Stars
- 313
- Forks
- 40
- PR merge metrics
- No merged PRs in 30d
Description
## The bug
`MixerSpeaker::loop()` snapshots the event group **once**:
```cpp
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
```
then, on the stopped branch, clears **every** bit:
```cpp
if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
this->task_.deallocate();
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); // <-- here
this->all_stopped_since_ms_ = 0;
}
```
`MIXER_TASK_ALL_BITS` is `0x00FFFFFF` and `MIXER_TASK_COMMAND_START` is
`1 << 0`, so that clear also eats a start request. There are **two** ways in,
and only the first is a race:
**1. Set-after-snapshot (a race).** `MixerSpeaker::start()` runs from another
context between the `xEventGroupGetBits` at the top of `loop()` and the clear
on the stopped branch. It sets `COMMAND_START` and wakes the loop. The clear
then wipes a bit that was never examined.
**2. Both bits in one snapshot (deterministic).** If `COMMAND_START` and
`STATE_STOPPED` are both set when `loop()` samples, the start branch runs
first — and does nothing, because it is gated on `!this->task_.is_created()`
and `deallocate()` has not happened yet:
```cpp
if (event_group_bits & MIXER_TASK_COMMAND_START) {
if (!this->status_has_error() && !this->task_.is_created()) { // false here
...
}
}
```
Control falls through to the stopped branch, which deallocates and then clears
the still-unserviced `COMMAND_START`. No timing window is required for this
one; it happens whenever a start arrives while the previous task is shutting
down.
Nothing re-arms the bit afterwards. `start()` is edge-triggered —
```cpp
if (!(event_bits & MIXER_TASK_COMMAND_START)) {
xEventGroupSetBits(this->event_group_, MIXER_TASK_COMMAND_START);
App.wake_loop_threadsafe();
}
```
— and it has already returned `ESP_OK` to its caller. The mixer task is never
created.
## Why it is not merely a missed start
`SourceSpeaker::start()` sets its own `SOURCE_SPEAKER_COMMAND_START` on a
separate path, so the **source** speaker starts normally and accepts audio.
With no mixer task to drain it, that audio goes nowhere. Everything upstream
looks healthy: the media source reports playing, the stream is fully consumed,
and the media player's position advances in real time. The track simply plays
silently, start to finish.
On a Home Assistant Voice PE this also wedges playback entirely. The
`speaker_source` media player's `try_execute_play_uri_` returns `false` while
`!ps.speaker->is_stopped()`, and `process_control_queue_()` peeks and dequeues
only on success — so the undrained speaker leaves a `PLAY_CURRENT` at the head
of a command queue **shared by both pipelines**. Announcements queue behind a
media command that can never complete, and the device goes completely deaf,
wake word included, until something else clears it.
## Evidence
Continuous DEBUG capture, 1,245 plays. Three carry the fingerprint, all with
the start arriving in a ~40 ms window after the previous track went idle:
```
[mixer_speaker:387] Stopped <- clear of ALL bits
(start request issued 400-499 ms earlier, in the shutdown window)
... no "Starting", no task creation, ever
```
The absent `ESP_LOGD(TAG, "Starting")` is the tell: it is the only line that
distinguishes "the task was asked to start and did" from "the task was asked
and the request was thrown away".
## Suggested fix
Do not reap a command that has not been serviced. Mask the start bit out of
the clear:
```diff
if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
this->task_.deallocate();
ESP_LOGD(TAG, "Stopped");
- xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
+ // Reap the finished task's own bits, but NOT a start request that
+ // arrived while it was shutting down: the start branch above skips it
+ // while task_.is_created() is still true, so clearing it here loses it
+ // permanently -- start() is edge-triggered and will not set it again.
+ xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS & ~MIXER_TASK_COMMAND_START);
this->all_stopped_since_ms_ = 0;
}
```
This is what we run in production and it has held. It is deliberately minimal
and fail-closed: if no start is pending the bit is clear anyway and behaviour
is identical; if one is pending it survives to the next `loop()`, where
`task_.is_created()` is now false and the task starts normally.
Two alternatives, both larger: re-read the event group before the clear
(closes path 1 only, leaves path 2 — so it is not sufficient on its own), or
move `deallocate()` ahead of the start branch so a same-pass start can be
serviced immediately.
## Also worth a look, separately
`speaker_source`'s single `media_control_command_queue_` is shared across
pipelines while every other piece of `PipelineContext` — playlist, speaker,
active source — is per-pipeline. That is what turns one unsatisfiable media
command into total device deafness rather than one failed track. A
per-pipeline queue would contain the blast radius without needing a timeout,
which would have to be a guess about legitimate drain time.
## Environment
- ESPHome 2026.6.0, ESP-IDF 5.5.4
- Home Assistant Voice PE (ESP32-S3), stock `mixer` + `speaker_source`
- Reproduced across 1,245 plays; three occurrences, all in the ~40 ms window
after the previous track went idle
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with MixerSpeaker::loop() and MixerSpeaker::start(), tracing the event-group bits through task shutdown and restart. Confirm that a pending COMMAND_START survives reaping and leads to task creation, while normal shutdown behavior remains unchanged; the reported 1,245-play reproduction can validate the result.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- audio-video-rtc, embedded-iot
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100