[Backend] Drop all-silence tracks before transcription #348

Closed
opened 2026-08-25 20:39:04 +00:00 by claude-bot · 1 comment
Contributor

Severity: MEDIUM. Found in the August 2026 session lifecycle review (#319). New failure mode created by the capture fix.

The defect

Once capture fills gaps with silence, a user who joins voice but never actually speaks — or whose client emits only DTX comfort-noise frames — produces a full-length, entirely silent track. That track is then sent to Whisper, which is well documented to hallucinate repeated phantom text on silence. Because attribution is per-track, those phantom lines are attributed to that user.

That is an independent misattribution vector, arriving as a direct consequence of fixing the first one.

There is a related trap already in the code: _peak_amplitude (recording.py:243-254) reads only the first 0.5 s of a file, which is guaranteed silence for every padded speaker. Any check built on it reports zero for every non-first speaker and would mislead exactly this investigation.

Proposed fix

At close(), scan the whole file (not the first half second) for peak amplitude and total non-silent duration. Drop tracks with no real speech before upload, and record them in the existing uncaptured_member_ids field so the GM sees "present, never captured" rather than a phantom transcript.

Add a backend-side guard too, since the bot cannot be the only line of defence: a track whose speech duration is effectively zero should not be sent for transcription.

Acceptance criteria

  • _peak_amplitude scans the full file, not the first 0.5 s
  • All-silence tracks are dropped before upload and reported as uncaptured
  • The backend independently skips tracks with no detectable speech
  • The GM can see which participants were present but never captured
  • Tests cover a silence-only track and a track with one brief utterance
**Severity: MEDIUM.** Found in the August 2026 session lifecycle review (#319). New failure mode created by the capture fix. ## The defect Once capture fills gaps with silence, a user who joins voice but never actually speaks — or whose client emits only DTX comfort-noise frames — produces a **full-length, entirely silent track**. That track is then sent to Whisper, which is well documented to hallucinate repeated phantom text on silence. Because attribution is per-track, those phantom lines are attributed to that user. That is an independent misattribution vector, arriving as a direct consequence of fixing the first one. There is a related trap already in the code: `_peak_amplitude` (`recording.py:243-254`) reads only the first 0.5 s of a file, which is guaranteed silence for every padded speaker. Any check built on it reports zero for every non-first speaker and would mislead exactly this investigation. ## Proposed fix At `close()`, scan the **whole** file (not the first half second) for peak amplitude and total non-silent duration. Drop tracks with no real speech before upload, and record them in the existing `uncaptured_member_ids` field so the GM sees "present, never captured" rather than a phantom transcript. Add a backend-side guard too, since the bot cannot be the only line of defence: a track whose speech duration is effectively zero should not be sent for transcription. ## Acceptance criteria - [ ] `_peak_amplitude` scans the full file, not the first 0.5 s - [ ] All-silence tracks are dropped before upload and reported as uncaptured - [ ] The backend independently skips tracks with no detectable speech - [ ] The GM can see which participants were present but never captured - [ ] Tests cover a silence-only track and a track with one brief utterance
Author
Contributor

Verified against acceptance criteria. Four met, one unmet-as-worded but superseded. The gap that mattered was the fourth, and it was fixed in 540b0a2 under #425.

Criteria

  • _peak_amplitude scans the full file, not the first 0.5 sunmet as worded, and deliberately so. It still samples five 64 KB windows (recording.py:374-400).

    But the criterion was written when _peak_amplitude was going to be the drop decision. It isn't. The decision now uses speech_bytes_written() (recording.py:887) — a running counter of decoded audio accumulated during capture, with no sampling blind spot at all. _peak_amplitude survives only as a log diagnostic, and its docstring says so and explains why it samples spread through the file rather than at the head.

    Making it scan exhaustively now would add cost to a log line. The intent behind the criterion — "do not decide this from a head-only read" — is satisfied by something stronger than what was asked for. Recording it as superseded rather than ticking it.

  • All-silence tracks are dropped before upload and reported as uncapturedrecording.py:969-978, merged into uncaptured_member_ids and sent via api_client.post_audio_tracks.

  • The backend independently skips tracks with no detectable speechdrop_silent_tracks, called unconditionally in process_audio. Its detection was rewritten under #425 after the original sampling version turned out to drop quiet players from long sessions.

  • → [x] The GM can see which participants were present but never capturedwas met for bot-dropped tracks only. The backend's own drops went to a logger.info and never reached uncaptured_member_ids, which was built solely from presence.json. Since that file carries only what the bot declined to upload, a track dropped by the backend guard was invisible to the GM. Fixed in 540b0a2; full reasoning on #425.

  • Tests cover a silence-only track and a track with one brief utterancetest_silent_tracks.py, and the brief-utterance case is now tested at realistic session length (4,639 s) with speech scattered, not contiguous. I confirmed those fail against the pre-#425 sampler rather than trusting their docstrings.

Worth noting

This issue's own body contains the warning that caught #425: "_peak_amplitude reads only the first 0.5 s of a file... Any check built on it reports zero for every non-first speaker and would mislead exactly this investigation." The backend guard then shipped with a different version of the same defect — sampling that degrades with file length instead of position — and the test written to prove otherwise used a 60-second fixture.

Closing.

**Verified against acceptance criteria.** Four met, one unmet-as-worded but superseded. The gap that mattered was the fourth, and it was fixed in `540b0a2` under #425. ## Criteria - [ ] **`_peak_amplitude` scans the full file, not the first 0.5 s** — **unmet as worded, and deliberately so.** It still samples five 64 KB windows ([recording.py:374-400](bot/questboard_bot/cogs/recording.py#L374-L400)). But the criterion was written when `_peak_amplitude` was going to *be* the drop decision. It isn't. The decision now uses `speech_bytes_written()` ([recording.py:887](bot/questboard_bot/cogs/recording.py#L887)) — a running counter of decoded audio accumulated during capture, with no sampling blind spot at all. `_peak_amplitude` survives only as a log diagnostic, and its docstring says so and explains why it samples *spread through* the file rather than at the head. Making it scan exhaustively now would add cost to a log line. The intent behind the criterion — "do not decide this from a head-only read" — is satisfied by something stronger than what was asked for. Recording it as superseded rather than ticking it. - [x] **All-silence tracks are dropped before upload and reported as uncaptured** — [recording.py:969-978](bot/questboard_bot/cogs/recording.py#L969-L978), merged into `uncaptured_member_ids` and sent via `api_client.post_audio_tracks`. - [x] **The backend independently skips tracks with no detectable speech** — [`drop_silent_tracks`](webapp/backend/app/services/audio_service.py#L1312), called unconditionally in `process_audio`. Its detection was rewritten under #425 after the original sampling version turned out to drop quiet players from long sessions. - [ ] → [x] **The GM can see which participants were present but never captured** — **was met for bot-dropped tracks only.** The backend's own drops went to a `logger.info` and never reached `uncaptured_member_ids`, which was built solely from `presence.json`. Since that file carries only what the *bot* declined to upload, a track dropped by the backend guard was invisible to the GM. Fixed in `540b0a2`; full reasoning on #425. - [x] **Tests cover a silence-only track and a track with one brief utterance** — [test_silent_tracks.py](webapp/backend/tests/test_silent_tracks.py), and the brief-utterance case is now tested at realistic session length (4,639 s) with speech *scattered*, not contiguous. I confirmed those fail against the pre-#425 sampler rather than trusting their docstrings. ## Worth noting This issue's own body contains the warning that caught #425: *"`_peak_amplitude` reads only the first 0.5 s of a file... Any check built on it reports zero for every non-first speaker and would mislead exactly this investigation."* The backend guard then shipped with a different version of the same defect — sampling that degrades with file length instead of position — and the test written to prove otherwise used a 60-second fixture. Closing.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#348
No description provided.