esphome / esphome/issues

mixer: a queued COMMAND_START is discarded when the mixer task is reaped, leaving source speakers running with nothing draining them

Open Beginner friendly
#7,252 0 comments 0 reactions 0 assignees View on GitHub
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.