Skip to content

feat(bot): say so, during the meeting, when a speaker's audio has no level - #43

Merged
TheMeinerLP merged 2 commits into
mainfrom
feat/silence-detection
Aug 21, 2026
Merged

feat(bot): say so, during the meeting, when a speaker's audio has no level#43
TheMeinerLP merged 2 commits into
mainfrom
feat/silence-detection

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

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:

packets arrived for this speaker, decoded successfully, and every sample in them stayed at the noise floor, for a meaningful amount of received audio.

Three states exist and only the third is a fault:

Reported
No packets at all No — a participant not speaking
Packets that will not decode No — already EndReason.DECODE_FAILURE
Packets that decode into nothing audible Yes

The 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 existing Announcer port, and session_participant.silent_audio_detected_at (migration 0006, 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 domain meant no numpy (the architecture test forbids it); stdlib array with max/min is 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:

  1. The threshold test asserted against SILENCE_PEAK_AMPLITUDE itself — self-referential, so it passed for any value including 0. Rewritten against literals, pinning the constant from both sides.
  2. A _make_announcer seam 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. Not 0, 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.
  • The channel message is posted before the database write. The in-meeting half is the time-critical one; the opposite order is defensible.
  • announcer is a required constructor argument, which cost a _UnusedAnnouncer in recovery.py and four test fakes. A defaulted None would have been less churn and would let a future call site silently wire nothing.
  • The wording is a module constant, English, not per-guild configurable. It renders through the same sandboxed environment as the link announcement, so that can be added without a code change.

…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
TheMeinerLP merged commit 0ceb496 into main Aug 21, 2026
8 checks passed
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.
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