Skip to content

fix(voice): decode Opus ourselves so one bad frame cannot end a recording - #37

Merged
TheMeinerLP merged 5 commits into
mainfrom
fix/voice-decode-resilience
Aug 20, 2026
Merged

fix(voice): decode Opus ourselves so one bad frame cannot end a recording#37
TheMeinerLP merged 5 commits into
mainfrom
fix/voice-decode-resilience

Conversation

@TheMeinerLP

@TheMeinerLP TheMeinerLP commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What was broken

Recording produced zero participants, for two independent reasons:

  1. A single bad Opus frame ended the entire recording. discord-ext-voice-recv catches decoder errors in PacketRouter.run(), sets reader.error, calls stop_listening() in its finally — and the thread exits. Not that speaker, not that packet: everything.
  2. Anyone already speaking when the bot joined was never mapped. Discord sends the SSRC→user mapping only via the speaking event (opcode 5), so a continuous speaker is never announced.

What this branch does

Rather than working around the library or papering over failures with reconnects, it uses the seam the library provides: wants_opus() = True. The library then never constructs a decoder at all (self._decoder = None if self.sink.wants_opus() else Decoder()) and never reaches _decode_packet. Decoding becomes ours, in infrastructure/discord/decoding.py, with one decoder per SSRC — so a failure costs exactly the frame it occurred in.

The processing is built as its own extension, not as patchwork inside the adapter:

  • decoding.py — per-SSRC decoder registry; errors stay local to a frame
  • sink.py — the sink itself, with no knowledge of sessions or the database
  • voice.py — the wiring, unchanged in its role as an adapter

Alongside that, late mapping: a stream whose SSRC is not yet known is buffered until the speaking event arrives, instead of being dropped.

Rejoin guard

The bot's own leave() triggers a voice-state update that makes it rejoin immediately. The guard against that now lapses through the existing tick loop (_tick_all) rather than through channel events — otherwise it stays stuck whenever nothing else happens after the bot leaves.

Reproduced rather than merely unit-tested: 16 simulated minutes with no events at all, after which recording resumed; and three simulated hours of persistently failing receive produced twelve attempts, not a hot loop.

Deliberately left alone

on_ready rebuilds every guild's pipeline, and Discord fires it on every gateway reconnect, not just the first start. A reconnect mid-session therefore discards the RecordingService holding the open session row, its data key and its writers. Nothing closes it, so the row stays open, and its audio is only collected by recovery on the next restart.

This is already the behaviour on main and is not a regression from this branch. What a reconnect should do with a live recording — resume it, close it cleanly, start a new one — is its own decision and belongs in its own PR.

In production a single Opus frame that failed to decode ended an entire
recording. `PacketDecoder._decode_packet` let `OpusError: corrupted stream`
escape into `PacketRouter.run()`, which logged it, set `reader.error` and
called `stop_listening()` in its `finally`. Capture stopped for every
speaker at once; the session stayed open, the bot stayed in the channel,
and it closed with no audio and no transcription job. A `sessions` row
with zero participants was the first anyone knew. Everyone in that channel
had been told they were being recorded.

The fix is structural rather than defensive: `RecordingSink.wants_opus()`
returns `True`, so the library constructs no `discord.opus.Decoder` and
never reaches `_decode_packet`. The crash site becomes unreachable through
the library's documented public API -- no monkey-patching, no internals,
and nothing a version bump can break quietly.

Sturnus now decodes, and owns what a failure means:

- `domain/stream_health.py` decides what an accumulating run of failures
  means. Pure stdlib, so the escalation rule can be exercised exhaustively
  without a voice connection. Escalation is edge-triggered: a stream
  failing at 50 fps produces one WARNING, not 3000 log lines a minute.
- `infrastructure/discord/decoding.py` holds one native decoder per SSRC,
  mirroring `PacketRouter.decoders`. Opus is stateful, so sharing a
  decoder between speakers would corrupt both streams. A frame that will
  not decode costs that frame; the decoder is kept, not reset (measured:
  it decodes the very next good frame). One rebuild per stream at
  UNUSABLE, then reporting only. Never a reconnect.
- `infrastructure/discord/sink.py` runs the per-frame pipeline on the
  router thread behind a `write()` that cannot raise -- the invariant that
  keeps the router thread alive.

A discarded frame is lossless with respect to time: `SpeakerWriter` places
audio by RTP-derived absolute time, so the gap becomes real silence in
exactly the right place and nothing after it shifts.

The consent gate keeps its strictness and gains ordering: the synchronous
role check runs on every frame, now *before* the decoder, so audio nobody
consented to is never turned into PCM and no decoder is created for that
speaker. The consent-record check stays on the loop, per frame, behind the
same cache.

Per-speaker degradation never ends a session. The one exception is total
failure: if no stream decodes anything, the session closes with the new
`EndReason.DECODE_FAILURE` rather than producing empty files while telling
everyone they are recorded -- the original incident in a new costume.

Also here, because the incident's real lesson was invisibility:

- Frames cross to the event loop as immutable messages through one bounded
  queue drained by a single task, replacing one `run_coroutine_threadsafe`
  future per packet. Per-speaker ordering is preserved and a stalled loop
  drops counted frames instead of accumulating futures.
- `listen(sink, after=...)` finally observes capture dying on its own: it
  is logged, counted, announced in the channel, and resumed once as a
  guarded last resort.
- `OpusNotLoaded` is probed once in `join()` before connecting, so a
  missing libopus refuses the channel instead of recording hours of
  silence.
- Audio that cannot be attributed to a member is never decoded or written
  -- no consent record can be checked for an unknown identity -- but it is
  counted, logged and reported once, with the pause-and-speak-again hint
  that is what makes Discord send the SSRC mapping.
- `/metrics` serves real counters, which is also what turns the deferred
  FEC decision into a measurement rather than an argument.
- CI installs libopus0: owning decoding makes it a test-time dependency,
  and constructing an `OpusError` at all requires the library loaded.

Docs record what production answered, including the spike's open question
about whether `source` is populated on a speaker's first packet: it is
not, for someone already talking when the bot connects.

Refs: docs/verification/voice-receive-spike.md, spec 6.1
Review of the decode work found defects around it, and the ones that
matter share the original incident's shape: a failure that happens and
nobody hears about it. Closing the decode path while leaving those would
have fixed the symptom and kept the disease.

**The alarm no longer shares fate with the audio.** `CaptureStopped`,
`DecodeTotalFailure` and `StreamStateChanged` travelled through the same
bounded queue as frames, so a loop far enough behind to be dropping audio
dropped the messages reporting that capture had died -- the alarm
discarded by exactly the load that raised it. `CaptureChannel` gives the
crossing two lanes: audio is bounded and dropped, control messages are
neither bounded nor queued behind it. They need no bound of their own
because they are rate-limited at the source, and a flood of them would be
a bug in one of those limits rather than a load condition.

The audio bound now counts frames submitted and not yet drained, rather
than frames sitting in a queue. `call_soon_threadsafe` hands the message
to the loop's callback queue, so while the loop is actually stalled --
the only situation the bound exists for -- a bound read off the
destination stays at zero while callbacks pile up without limit.

**Capture dying is now an end reason of its own.** Nothing armed a close
when capture could not be resumed: the session stayed open with nothing
arriving and eventually closed as `idle_timeout`, indistinguishable in
the database from a meeting where nobody spoke. That row is precisely
what let the production incident go unnoticed. `EndReason.CAPTURE_FAILURE`
says which one it was, and the guild then waits out `REJOIN_COOLDOWN`
before another session may start -- leaving the channel is itself a
voice-state update, so rejoining immediately reproduced the fault once
per empty session, each one announcing to everyone present that they
were being recorded.

**A dead stream can no longer report itself healthy.** Only `OpusError`
reached `StreamHealth`, so anything else the decoder raised left through
the outer guard untouched: the stream stayed HEALTHY with
`frames_seen == 0` while every frame of it was thrown away. Whatever the
failure, the frame is now counted against that stream, escalates, and
counts towards the total-failure verdict.

**One failing speaker is no longer read as total failure.** The verdict
excluded any stream with fewer than `never_decoded_after` frames, so a
single failing speaker while everyone else was briefly quiet ended the
recording for the whole channel. Every live stream counts now, including
the ones too young to judge: those are evidence something might still
work. Declaring total failure by mistake ends a real recording;
declining to costs empty files the per-stream errors already shout about.

**Nothing on the frame drain awaits the network or the database.** The
drain is a single consumer and everything behind it is somebody's audio,
so a rate-limited `channel.send` or a slow consent lookup stalled capture
for every speaker at once. Notices are posted from tasks of their own.
`ConsentCache.verdict` never awaits: a cached entry answers immediately
and refreshes beside the drain, and a frame arriving before that
speaker's verdict is known is counted and dropped -- audio we cannot
vouch for is not recorded, the same rule unattributed audio follows.
Serving a stale verdict is bounded by `stale_after` rather than by
whether the database happens to be up, because an hour-old verdict
authorising a recording is not something to be relaxed about.

Also here:

- `sturnus_voice_frames_decoded_total` was incremented in the sink and
  again in the adapter's drain and read about double. All three frame
  counters now live in the decoder, which is the one place that knows
  why a frame did not make it -- so `frames_discarded_total` finally
  carries the `code` label its documentation always claimed, and
  `code="-4"` is the production corrupted-stream case.
- `join()` connects before it builds anything that has to be torn down;
  a failed `connect` no longer leaves an orphaned drain task behind it.

Each fix has a test that fails without it, asserting the outcome rather
than the path: that the notification arrives while frames are being
dropped, that the session row says `capture_failure` and not
`idle_timeout`, that a stream failing on a `MemoryError` stops claiming
to be healthy.

Refs: spec 5.1, 6.1
The fix stays: the sink returns wants_opus() = True, so the library
builds no discord.opus.Decoder and never reaches _decode_packet -- the
line whose uncaught OpusError: corrupted stream killed the packet-router
thread and stopped capture for every speaker. Sturnus decodes per SSRC,
discards a frame that will not decode whatever the decoder raised, and
carries on; write() is total; capture that dies ends the session with a
reason that says "we could not hear" rather than "nobody spoke", and a
join() that fails now does the same instead of leaving a session open
with nothing behind it.

Everything the branch had grown around that comes out. The ConsentCache
rewrite is reverted to main's: it was unrelated to Opus decoding and it
turned a database outage into a silent stop -- every frame got a None
verdict, nothing recorded, no ERROR, and the session closed as
idle_timeout, which is strictly worse than the behaviour it replaced.
The two-lane CaptureChannel goes back to a plain asyncio.Queue: the
control lane let a message overtake the audio ahead of it, reordering
SpeakerStreamEnded in front of the frames it should follow. The rejoin
cooldown, the metrics registry and its labels, and StreamHealth's
four-state machine with its thresholds, transitions and decoder
recycling are gone; what the escalation actually needs is one counter
per stream and one threshold, which is what is left.

Each removed concern was real, so each is written down in
docs/verification/voice-receive-spike.md under Known limitations, with
what was observed and why the implementation was not yet worth its
surface: the blocking consent lookup on the frame drain, the unbounded
audio backlog, the rejoin loop, the absence of any notice to the people
in the channel, and the absence of voice metrics.

Every failure path that remains is visible -- a WARNING or ERROR someone
will read, and a distinguishable end reason on the session row. Tests
that only covered the removed machinery are deleted; the rest assert on
outcomes, including the incident itself replayed through real libopus.
The two new end reasons close a session and make the bot leave the
channel -- and leaving is itself a voice-state update. The handler sees
members still present, opens a fresh session row, rejoins with fresh
decoders, meets the same fault and closes again: new row, new decoders,
same failure, repeat, each cycle announcing to everyone in the channel
that they are being recorded. The reduction dropped the cooldown as
extra machinery around the decode fix; it was not extra, it was what
made those reasons safe to introduce.

A session that ends with `capture_failure` or `decode_failure` now holds
its guild out of that channel for `REJOIN_COOLDOWN`. While it is in
force `on_voice_state_update` returns without counting anyone, which is
the specific event the loop fed on.

The earlier version of this guard could only lapse inside
`on_voice_state_update`, so a guild whose membership did not change
again stayed blocked indefinitely -- a transient fault turned into an
outage, waiting on the very people the fault was not caused by. Here the
tick loop lifts it and then recounts the channel itself through
`_sync_participants`, the same path a voice-state update takes. Nobody
has to leave and come back for recording to resume, and no operator has
to clear anything.

`_GuildRecording` carries its `discord.Guild` for that: the tick has no
event to read the channel off, and resolving it per tick through the
client's cache would silently do nothing whenever the lookup missed.

Three log lines separate "waiting out a capture failure" from "nothing
is happening": an ERROR naming the reason and the deadline when the
guard is armed, an INFO for each voice-state update it suppresses, and
an INFO when it lifts. `docs/operations.md` gains the troubleshooting
entry that ties them to the `session` rows they explain, and the loop
comes out of the spike's *Known limitations*.

Both tests assert the outcome on `sessions.opened` rather than the
mechanism, and each fails without its half: without the handler guard a
second session row opens on the update the bot's own `leave()`
produced; without the tick-driven lapse none ever opens again, in a test
that dispatches no voice-state update at all after the failure.
Two restructurings of `SturnusClient` met here: main's reconfiguration
engine (#35) and this branch's capture pipeline. Main's structure is the
trunk; this branch's behaviour is re-expressed inside it.

The one decision worth naming: the rejoin guard moved out of
`on_voice_state_update` and into `_sync_participants`. On this branch the
event handler was the only way a session could start, so the guard was
correct there. Under main's engine it no longer is -- `_build`,
`_retarget` and `_apply_pending` reach `_sync_participants` too, so a
deferred channel change landing on the same tick as a capture failure
would have smuggled a session past a guard sitting in the handler. One
check at the funnel covers every path.

The cooldown is armed between `_return_to_idle` and `_apply_pending`, not
after, for the same reason. It lapses in `_tick_guild` after `_reconcile`,
so the recount uses the configuration the reconcile just settled on.

Also merged: `RecordingService.retarget()` now takes the channel name
alongside the id (#34 + #35), and the capture-pipeline fakes follow the
`open_session` signature that gained `channel_name`.
@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

Merged main in rather than rebasing — the branch's four commits would have gone through the same conflicts four times, and the PR is squash-merged anyway.

Two restructurings of SturnusClient met here: main's reconfiguration engine (#35) and this branch's capture pipeline. Main's structure is the trunk; this branch's behaviour is re-expressed inside it.

The rejoin guard moved out of on_voice_state_update and into _sync_participants. On this branch the event handler was the only way a session could start, so the guard was correct where it sat. Under main's engine it no longer is: _build, _retarget and _apply_pending all reach _sync_participants, so a deferred channel change landing on the same tick as a capture failure would have smuggled a session past a guard living in the handler. One check at the funnel covers every path. Behaviour through on_voice_state_update is unchanged.

Ordering follows from the same reasoning: the cooldown is armed between _return_to_idle and _apply_pending (armed after, a pending retarget would already have run _sync_participants unguarded), and it lapses in _tick_guild after _reconcile, so the recount uses the configuration the reconcile just settled on.

Also merged: RecordingService.retarget() now carries the channel name alongside the id (#34 + #35).

575 tests pass, mypy and ruff clean. Verified independently of the test count that no test function from either side was dropped in the merge. Both halves of the guard were mutation-checked: removing the guard fails test_a_capture_side_end_does_not_open_another_session_straight_after and nothing else; removing the lapse fails test_the_guard_lapses_on_the_tick_with_no_voice_state_update_at_all and nothing else.

Two gaps this merge makes visible, neither a regression

  • blocked_until is not surfaced by running_state()/ReconfigureResult, so /config show reports a guild sitting in a capture-failure cooldown as live and idle.
  • _teardown pops the _GuildRecording and with it the cooldown, so clearing and re-setting a guild's configuration is an operator escape hatch from the wait. That seems right, but it is an implicit consequence rather than a written decision.

@TheMeinerLP
TheMeinerLP merged commit 757828d into main Aug 20, 2026
6 checks passed
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.

1 participant