feat(bot): say so, during the meeting, when a speaker's audio has no level - #43
Merged
Conversation
…level Two live sessions produced transcripts of one hallucinated "Thank you." and of nothing at all. Both WAVs were exactly as long as the meeting -- 16 kHz mono, ~32000 bytes a second, the full duration -- so bytes had flowed in the right volume the whole time and the amplitude in them was at the noise floor. Nobody could tell whether that was a broken decode path or somebody who simply never spoke, and by the time anyone looked, the meeting was over and the recording was worthless. This settles that question at the one moment it can still be acted on. Silence on its own is deliberately *not* the signal. People are quiet for most of a meeting and Discord sends no packets while they are; warning on that would make the bot unusable within one session. Three states exist and the code keeps them apart. No packets at all is a participant not speaking, and nothing reports it. Packets that will not decode is already `EndReason.DECODE_FAILURE` and `request_close`, untouched here. The third -- packets arrived, decoded successfully, and every sample in them stayed at or near zero -- is the new case, and it is the only one that means a microphone is muted at system level. `sturnus.domain.silence` is where the arithmetic lives, next to `SessionMachine` and `SpeakerClock`, because that is all it is: a peak absolute sample per packet and a per-speaker byte counter. Only amplitude is ever read; no sample is buffered, logged or handed on. That is what keeps the feature clear of the consent model -- a peak is a number about loudness, not about content, and it is exactly as true of somebody who never consented as of somebody who did. Two constants carry the judgement, both commented where they are defined: `SILENCE_PEAK_AMPLITUDE = 32` (about -60 dBFS -- not `0`, because Opus does not always decode digital silence back to exact zeros, and not higher, because everything above it is sound somebody could have meant), and 30 seconds of *received* audio as the evidence threshold, counted in bytes of PCM rather than wall-clock time, so a speaker who transmits nothing for half an hour is never warned about anything. Any audible packet clears that speaker's evidence: it has to be continuous, because somebody who spoke twenty seconds ago has a microphone that demonstrably works. Measured in the bot, in `RecordingService.voice_packet`, for two reasons the worker cannot offer: the decoded PCM is already in hand there, with the user id resolved and no download or decryption in the way, and only this process holds a Discord connection to say anything at all. It says it three ways, each surviving something the others do not. A `log.warning` reaches the operator watching the pod and is the one part that cannot itself fail -- this process logs almost nothing about a running session, so it is written to be read. A public message in the recording channel, naming the person, reaches the meeting while it can still act; a DM would be gentler, but whoever is muted at system level is usually the last to notice, and somebody else in the room can help. It states what was observed rather than what anyone did wrong, and it says the recording continues, without which it reads as "you are not being recorded" and sends people out of the meeting to fix something that is not broken. And `session_participant.silent_audio_detected_at` outlives both, because a chat message is gone by the next meeting and this is the column that turns "the transcript was empty" into an answerable question weeks later. Once per speaker per session, never at the end -- at the end the information is worth nothing, the recording is already lost. The message goes out through the existing `Announcer` port rather than a second route to Discord, which is why `_DiscordAnnouncer` moves from `sturnus.entrypoints.bot` to `sturnus.infrastructure.discord.announcer` as `DiscordAnnouncer`: `SturnusClient` builds the recording pipeline and would otherwise have to import from an entrypoint, the dependency direction backwards. One adapter also means one place where a message can leave this system, so a change to how it addresses a channel cannot land in the link publisher and miss this. The application layer still imports no `discord`. Both halves of the report are guarded independently: a Discord rate limit must not swallow the durable record, a database hiccup must not swallow the message that could still get a microphone fixed, and neither may reach `voice_packet`'s caller -- a warning that took the recording down with it would be worse than no warning. The check runs last in `voice_packet`, after the audio is written and the machine has been told, because it is a report about the recording and never a step in making it. Migration 0006 adds the column nullable and does not backfill: being quiet is normal, null is what nearly every participant will always carry, and the audio of the sessions that predate it is gone -- no value could be honestly inferred from what is left.
The behaviour is visible to participants (a message in the recording channel) and to operators (a new column), so both belong in the troubleshooting section rather than only in the code that produces them. Worth writing down chiefly for the negative cases: no packets at all is never reported, and Discord's own mute sends no packets, so neither reaches this. The table exists so nobody reads the message as "it is quiet in here".
TheMeinerLP
added a commit
that referenced
this pull request
Aug 21, 2026
#43 (silent-audio detection) and #48 (transcribe the speech, not the padded track) landed while this waited. Two conflicts, both additive: `tests/infrastructure/test_repositories.py` needed `select` and `update` from sqlalchemy rather than one or the other -- #43's repository test reads a column back, this branch's compare-and-set writes one. `docs/operations.md` section 5 gained two independent troubleshooting entries at the same anchor: this branch's `/queue` walkthrough and #43's 'a speaker's audio arrives with no level'. Both stay; they answer different questions and neither supersedes the other.
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.
Two live sessions produced transcripts of one hallucinated "Thank you." and of nothing at all, from WAV files exactly as long as the meetings. Bytes flowed in the right volume; the amplitude in them was at the noise floor. Nobody could tell whether that was a broken decode path or somebody who never spoke — and by the time anyone looked, the meeting was over and the recording was worthless.
Silence on its own is never the signal
People are quiet for most of a meeting, and Discord sends no packets while they are. Warning on "silence" would fire constantly and be ignored within a week. The condition here is narrower:
Three states exist and only the third is a fault:
EndReason.DECODE_FAILUREThe evidence threshold is counted in bytes of PCM, not wall-clock, and that is the point: somebody who transmits nothing for half an hour has produced no evidence about their microphone, and warning them would be the false positive that makes the feature unusable. Any audible packet discards that speaker's evidence and starts over.
Where, and when
In the bot at capture time — the decoded PCM is already there, and it is the only process that can say anything.
RecordingService.voice_packet, last, after the write and the state machine: a report about the recording, never a step in making it.During the meeting, once per speaker, never repeated. At the end the information is worthless because the recording is already lost.
Three outputs, each guarded separately so one failure cannot swallow another: a
log.warning, a public message through the existingAnnouncerport, andsession_participant.silent_audio_detected_at(migration0006, nullable, not backfilled).Only amplitude is ever read
No sample is buffered, logged or handed on. A peak is a number about loudness, not about content — which is what keeps this outside the consent model entirely: it is exactly as true of somebody who has not consented as of somebody who has.
Living in
domainmeant no numpy (the architecture test forbids it); stdlibarraywithmax/minis a C-level pass over ~1920 samples and not the bottleneck.Verification
610 tests pass (was 580; nothing weakened or removed), mypy and ruff clean. Twenty mutations were applied one at a time, each caught by the test claiming that behaviour.
Two mutants initially survived and both were real test defects, which is the part worth reading:
SILENCE_PEAK_AMPLITUDEitself — self-referential, so it passed for any value including0. Rewritten against literals, pinning the constant from both sides._make_announcerseam existed only so a test could override it, so the production line never ran. The seam was deleted rather than tested; the test now stands in for the channel.Independently re-checked here on two mutations not in that list: raising the threshold to 20000 and dropping the evidence window to zero each fail the right tests and only those.
Worth second-guessing
SILENCE_PEAK_AMPLITUDE = 32(~-60 dBFS) cannot be validated without real recordings. Not0, because Opus does not always decode digital silence back to exact zeros — an equality test would find nothing on the very recordings this was written for. If a working microphone's room tone stays below that for 30 continuous seconds, that speaker gets one factual sentence and the recording continues.announceris a required constructor argument, which cost a_UnusedAnnouncerinrecovery.pyand four test fakes. A defaultedNonewould have been less churn and would let a future call site silently wire nothing.