From 179885e0856596590458745e187f3bcd8276e489 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 20 Aug 2026 19:30:54 +0200 Subject: [PATCH 1/2] feat(bot): say so, during the meeting, when a speaker's audio has no 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. --- .../versions/0006_participant_silent_audio.py | 46 ++++ src/sturnus/application/publishing.py | 36 +++ src/sturnus/application/recording.py | 102 +++++++- src/sturnus/application/recovery.py | 18 ++ src/sturnus/domain/silence.py | 157 +++++++++++++ src/sturnus/entrypoints/bot.py | 24 +- src/sturnus/infrastructure/db/models.py | 10 + src/sturnus/infrastructure/db/repositories.py | 30 +++ .../infrastructure/discord/announcer.py | 38 +++ src/sturnus/infrastructure/discord/client.py | 6 + tests/application/test_publishing.py | 32 ++- tests/application/test_recording.py | 221 ++++++++++++++++++ tests/application/test_recovery.py | 5 + tests/domain/test_silence.py | 165 +++++++++++++ .../infrastructure/discord/test_announcer.py | 75 ++++++ .../discord/test_capture_pipeline.py | 17 ++ tests/infrastructure/discord/test_client.py | 77 ++++++ .../discord/test_voice_adapter.py | 4 + tests/infrastructure/test_repositories.py | 77 +++++- 19 files changed, 1113 insertions(+), 27 deletions(-) create mode 100644 migrations/versions/0006_participant_silent_audio.py create mode 100644 src/sturnus/domain/silence.py create mode 100644 src/sturnus/infrastructure/discord/announcer.py create mode 100644 tests/domain/test_silence.py create mode 100644 tests/infrastructure/discord/test_announcer.py diff --git a/migrations/versions/0006_participant_silent_audio.py b/migrations/versions/0006_participant_silent_audio.py new file mode 100644 index 0000000..df7db86 --- /dev/null +++ b/migrations/versions/0006_participant_silent_audio.py @@ -0,0 +1,46 @@ +"""participant silent audio + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-08-20 00:00:00.000000 + +Records when a speaker's audio was first seen arriving with no audible +level in it -- packets received and decoded, every sample at the noise +floor. The bot writes it during the session, at the same moment it says so +in the channel, because that message is gone by the next meeting and this +column is what an operator can still read afterwards. + +Two live sessions produced empty transcripts from full-length recordings, +and nothing in the database distinguished "we could not hear them" from +"they said nothing" -- both leave exactly the same participant row. This is +the column that answers it. + +Nullable, and deliberately not backfilled: being quiet is normal and null +is what nearly every participant will always carry, so there is nothing to +fill in for the sessions that predate this column -- their audio is gone, +and no value could be honestly inferred from what is left. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0006" +down_revision: Union[str, Sequence[str], None] = "0005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "session_participant", + sa.Column("silent_audio_detected_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("session_participant", "silent_audio_detected_at") diff --git a/src/sturnus/application/publishing.py b/src/sturnus/application/publishing.py index 2a87819..554b201 100644 --- a/src/sturnus/application/publishing.py +++ b/src/sturnus/application/publishing.py @@ -87,6 +87,42 @@ def render_announcement( return template.render(document_url=document_url) +#: Wording for the in-meeting warning that a speaker's audio is arriving +#: with nothing audible in it (`sturnus.domain.silence`). Posted publicly +#: into the recording channel and naming the person, which is a deliberate +#: choice over a direct message: whoever is muted at system level is +#: usually the last to notice, and somebody else in the room can help. +#: +#: Every word of it is load-bearing. It states what was observed rather +#: than what somebody did wrong, it names the one cause that explains +#: audio arriving at zero level, and it says the recording continues -- +#: without that last sentence the message reads as "you are not being +#: recorded", which is false and would send people out of the meeting to +#: fix something that is not broken. +#: +#: Rendered through the same sandboxed engine as the announcement above, +#: for the same reason (Spec 8.2: one engine for every Discord message +#: this system posts), so the wording can move to a per-guild template +#: later without a code change. +SILENT_AUDIO_WARNING_TEMPLATE = ( + "Audio is arriving from <@{{ discord_user_id }}> but at no audible level. " + "The microphone is most likely muted at system level. Recording continues." +) + + +def render_silent_audio_warning( + discord_user_id: int, template_source: str = SILENT_AUDIO_WARNING_TEMPLATE +) -> str: + """Renders the public warning for one speaker whose audio carries no level. + + `<@id>` is Discord's mention syntax; the id is an integer taken from + the voice packet itself, so nothing user-controlled reaches the + template even though this environment does not autoescape. + """ + template = _build_environment().from_string(template_source) + return template.render(discord_user_id=discord_user_id) + + class SessionReader(Protocol): """Where announcement candidates are read from and `announced_at` is stamped.""" diff --git a/src/sturnus/application/recording.py b/src/sturnus/application/recording.py index bb0e7c8..2d57979 100644 --- a/src/sturnus/application/recording.py +++ b/src/sturnus/application/recording.py @@ -11,6 +11,7 @@ from __future__ import annotations import contextlib +import logging from datetime import datetime, timedelta from pathlib import Path from typing import Protocol @@ -22,9 +23,13 @@ Encryptor, SessionKey, ) +from sturnus.application.publishing import Announcer, render_silent_audio_warning from sturnus.domain.session import EndReason, SessionMachine, SessionState, SessionTimeouts +from sturnus.domain.silence import SILENCE_EVIDENCE_SECONDS, SilentAudioWatch from sturnus.domain.timeline import SpeakerClock +log = logging.getLogger(__name__) + def audio_key(session_id: int, discord_user_id: int) -> str: """Object key for one speaker's recording within a session. @@ -53,6 +58,19 @@ async def set_audio_epoch( self, session_id: int, discord_user_id: int, at: datetime ) -> None: ... + async def record_silent_audio( + self, session_id: int, discord_user_id: int, at: datetime + ) -> None: + """Records that this speaker's audio arrived carrying no audible level. + + Written once, at the moment the case is established, so that an + operator reading the session afterwards can tell a broken capture + path from a room in which nobody said anything -- the question two + empty transcripts left unanswerable. The warning posted into the + channel is gone by the next meeting; this is what survives it. + """ + ... + async def close_session(self, session_id: int, ended_at: datetime, reason: str) -> None: ... async def record_session_key( @@ -113,9 +131,14 @@ class RecordingService: packets on an absolute timeline, and one `AudioWriter` per speaker who has actually spoken. Every collaborator that reaches outward -- the session and job repositories, the object store, the audio writers, the - encryptor -- is reached through a port or a narrow protocol, so this - class never touches Discord, SQL, S3, the filesystem's audio format, or - a crypto library directly. + encryptor, the announcer -- is reached through a port or a narrow + protocol, so this class never touches Discord, SQL, S3, the + filesystem's audio format, or a crypto library directly. + + It speaks into the channel exactly once, and only about a fault it can + see and the room cannot: audio arriving from a speaker with no audible + level in it (`_report_silent_audio`). Everything else it has to say + about a session it says by writing it down. """ def __init__( @@ -128,6 +151,7 @@ def __init__( store: AudioStore, writers: AudioWriterFactory, encryptor: Encryptor, + announcer: Announcer, retention_days: int, channel_name: str | None = None, ) -> None: @@ -144,8 +168,18 @@ def __init__( self._store = store self._writer_factory = writers self._encryptor = encryptor + #: The only way this service says anything to the people it is + #: recording. Reached through the same `Announcer` port the link + #: publisher already posts through, rather than a second route out + #: to Discord, so this layer still has no idea Discord exists. + self._announcer = announcer self._retention_days = retention_days + #: Watches each speaker's amplitude for audio that arrives and + #: decodes but carries nothing (`sturnus.domain.silence`). Rebuilt + #: per session in `reset()`, like `_clock`, so "once per speaker" + #: means once per session. + self._silence = SilentAudioWatch() self._session_id: int | None = None self._data_key: SessionKey | None = None self._writers: dict[int, AudioWriter] = {} @@ -272,6 +306,67 @@ async def voice_packet( writer.write(at, pcm) self._machine.audio_received(now) + # Last, and only after the audio itself is safely written: this is + # a report about the recording, never a step in making it. The + # watch reads amplitude and nothing else -- no sample is kept, + # logged or passed on -- and answers `True` exactly once per + # speaker per session, on the packet that completes the case. + if self._silence.observe(discord_user_id, pcm): + await self._report_silent_audio(discord_user_id, display_name, at) + + async def _report_silent_audio( + self, discord_user_id: int, display_name: str, at: datetime + ) -> None: + """Says, three ways, that this speaker's audio is arriving empty. + + Three, because each survives something the others do not. The log + line reaches the operator watching the pod and is the one thing + that cannot itself fail. The channel message reaches the meeting + while it can still act -- at the end it would be worthless, since + the recording is already lost. The participant row outlives both + and is what turns "the transcript is empty" into an answerable + question weeks later. + + Each of the two that can fail is guarded on its own rather than + together: a Discord rate limit must not swallow the durable + record, and a database hiccup must not swallow the message that + could still get somebody's microphone fixed. Neither may reach the + caller at all -- `voice_packet` is the capture path, and a warning + that took the recording down with it would be worse than no + warning. + """ + assert self._session_id is not None + log.warning( + "Audio from %s (id %d) in session %d has been arriving for %ds with no " + "audible level: packets are being received and decoded, and every sample in " + "them is at the noise floor. This is what a microphone muted at system level " + "produces, and it transcribes to nothing. Recording continues.", + display_name, + discord_user_id, + self._session_id, + SILENCE_EVIDENCE_SECONDS, + ) + try: + await self._announcer.post( + self._channel_id, render_silent_audio_warning(discord_user_id) + ) + except Exception as exc: + log.warning( + "Could not post the silent-audio warning for id %d into channel %d: %s", + discord_user_id, + self._channel_id, + exc, + ) + try: + await self._sessions.record_silent_audio(self._session_id, discord_user_id, at) + except Exception as exc: + log.warning( + "Could not record silent audio for id %d on session %d: %s", + discord_user_id, + self._session_id, + exc, + ) + def request_close(self, reason: EndReason) -> None: """Arms an out-of-band close that the next `tick()` acts on. @@ -413,6 +508,7 @@ def reset(self) -> None: assert self._closed, "reset() must only follow close()" self._machine.reset() self._clock = SpeakerClock() + self._silence = SilentAudioWatch() self._session_id = None self._data_key = None self._writers = {} diff --git a/src/sturnus/application/recovery.py b/src/sturnus/application/recovery.py index 969a366..43b8379 100644 --- a/src/sturnus/application/recovery.py +++ b/src/sturnus/application/recovery.py @@ -134,6 +134,23 @@ def open(self, session_id: int, discord_user_id: int, epoch: datetime) -> _Alrea ) +class _UnusedAnnouncer: + """Satisfies `RecordingService`'s constructor without ever posting anything. + + Recovery runs at boot, before the Discord client is even constructed, + and only ever calls `close` -- the one method that says nothing to + anybody. The service posts from `voice_packet` alone, which recovery + never reaches, so being asked to post here means the wiring above has + gone wrong and the process would otherwise be quietly announcing into + a channel it has no connection to. + """ + + async def post(self, channel_id: int, text: str) -> None: + raise RuntimeError( + f"recovery must not announce anything (channel {channel_id}, {len(text)} chars)" + ) + + def _service_for_recovery( sessions: SessionRecorder, jobs: JobQueue, @@ -155,6 +172,7 @@ def _service_for_recovery( store=store, writers=_UnusedWriterFactory(), encryptor=encryptor, + announcer=_UnusedAnnouncer(), retention_days=retention_days, ) diff --git a/src/sturnus/domain/silence.py b/src/sturnus/domain/silence.py new file mode 100644 index 0000000..0bfa91a --- /dev/null +++ b/src/sturnus/domain/silence.py @@ -0,0 +1,157 @@ +"""Telling a quiet meeting apart from a microphone that produces nothing. + +Two live sessions produced transcripts of one hallucinated "Thank you." and +of nothing at all, from WAV files that were exactly as long as the meeting. +Bytes had flowed in the right volume the whole time; 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. + +**Silence on its own is never the signal.** People are quiet for most of a +meeting, and Discord sends no packets while they are. The narrower +condition this module recognises is the one that means something: + + packets arrived for this speaker, decoded successfully, and every + sample in them stayed at or near zero, for a meaningful amount of + *received* audio. + +Three states exist and only the third is a fault. No packets at all is a +participant not speaking (nothing to report). Packets that will not decode +is already `EndReason.DECODE_FAILURE`, handled in the capture layer. This +module covers the third: packets that decode into nothing audible. + +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 feature outside the consent model entirely: it is exactly as true of +somebody who never consented as of somebody who did. + +Pure arithmetic over bytes and per-speaker counters, so it lives in the +domain beside `SessionMachine` and `SpeakerClock` and is driven from +`sturnus.application.recording.RecordingService.voice_packet`. +""" + +from __future__ import annotations + +import sys +from array import array + +#: Discord's voice wire format, which is fixed by the platform rather than +#: chosen by us: 48 kHz, 16-bit, two channels. Restated here rather than +#: imported from `sturnus.infrastructure.audio.SOURCE_RATE`, which names +#: the same rate for the resampler, because the domain must not import +#: outward (tests/test_architecture.py). The two cannot drift in practice: +#: neither is a setting, and if Discord ever changed the wire format both +#: would have to change together anyway. +SOURCE_SAMPLE_RATE_HZ = 48_000 +BYTES_PER_SAMPLE_FRAME = 4 + +#: The largest peak sample still counted as silence, on the 16-bit scale +#: whose full deflection is 32767 -- about -60 dBFS. +#: +#: Not `0`, because Opus is lossy: a muted microphone feeds the encoder +#: digital silence and the decoder does not always hand back exact zeros, +#: so an equality test would find nothing on the very recordings this was +#: written for. Not much higher either, because everything above this is +#: sound somebody could have meant: -60 dBFS is far below any spoken word, +#: below a whisper, and below what Whisper can transcribe. The cost of the +#: threshold sitting a little too high is one factual message to somebody +#: whose recording continues regardless; the cost of it sitting too low is +#: the silence this whole feature exists to break. +SILENCE_PEAK_AMPLITUDE = 32 + +#: How much *received* audio must stay at the noise floor before it counts +#: as evidence. Thirty seconds: long enough that nothing a working +#: microphone does -- a long pause, a held breath, a codec artefact -- +#: reaches it, and short enough that the warning still arrives while the +#: meeting can act on it. At the end of the session the information would +#: be worthless, because the recording is already lost. +#: +#: Measured in bytes of PCM rather than wall-clock seconds, and that is the +#: whole point: a speaker who transmits nothing for half an hour has +#: produced no evidence about their microphone at all, and warning them +#: would be the false positive that makes this unusable. +SILENCE_EVIDENCE_SECONDS = 30 +SILENCE_EVIDENCE_BYTES = SILENCE_EVIDENCE_SECONDS * SOURCE_SAMPLE_RATE_HZ * BYTES_PER_SAMPLE_FRAME + + +def peak_amplitude(pcm: bytes) -> int: + """The loudest sample in one packet, as a distance from zero. + + Distance, not the signed value: a waveform is symmetric around zero, so + reading the maximum alone would report a loud negative half-cycle as + quieter than digital silence and warn somebody in mid-sentence. + + `array` reads the samples in one C-level pass rather than a Python loop + over ~1920 of them fifty times a second per speaker; `max`/`min` over + it are C-level too. `numpy` would be the obvious tool and is already a + dependency of the audio adapter, but the domain may not import it + (tests/test_architecture.py), and at this size the standard library is + not the bottleneck. + + Total by construction. A packet length that is not a whole number of + samples costs the trailing half-sample rather than raising: this runs + on every frame of every speaker, and the length comes from outside this + process. + """ + usable = len(pcm) - len(pcm) % 2 + if usable == 0: + return 0 + samples = array("h") + samples.frombytes(pcm[:usable]) + if sys.byteorder != "little": + # `array("h")` is native-endian while the wire format is not. + # No supported deployment target is big-endian, so this branch is + # never taken in practice -- but a silently byte-swapped peak would + # be the kind of wrong that still looks plausible. + samples.byteswap() + # `-min(...)` can reach 32768, one past the positive range. It is only + # ever compared against a threshold, never stored as a sample. + return max(max(samples), -min(samples)) + + +class SilentAudioWatch: + """Accumulates, per speaker, how much received audio has stayed inaudible. + + One instance belongs to one recording session -- `RecordingService` + replaces it in `reset()`, the same way it replaces its `SpeakerClock` + -- so "once per speaker" means once per session, and the next meeting + starts with a clean slate for everybody. + """ + + def __init__( + self, + evidence_bytes: int = SILENCE_EVIDENCE_BYTES, + peak_threshold: int = SILENCE_PEAK_AMPLITUDE, + ) -> None: + self._evidence_bytes = evidence_bytes + self._peak_threshold = peak_threshold + #: Bytes of *consecutive* inaudible audio received per speaker. + self._silent_bytes: dict[int, int] = {} + #: Speakers already reported. Never cleared while the session runs. + self._reported: set[int] = set() + + def observe(self, discord_user_id: int, pcm: bytes) -> bool: + """Records one decoded packet; `True` on the packet that completes the case. + + Returns `True` exactly once per speaker per session, on the packet + that pushes the accumulated evidence over the threshold, so the + caller can act on the return value alone and needs no state of its + own. Every later packet from that speaker returns `False`, + inaudible or not -- repeating the message would only put the same + person on the spot again. + + Any audible packet clears that speaker's evidence: the case has to + be continuous, because somebody who spoke twenty seconds ago has a + microphone that demonstrably works. + """ + if peak_amplitude(pcm) > self._peak_threshold: + self._silent_bytes[discord_user_id] = 0 + return False + if discord_user_id in self._reported: + return False + collected = self._silent_bytes.get(discord_user_id, 0) + len(pcm) + self._silent_bytes[discord_user_id] = collected + if collected < self._evidence_bytes: + return False + self._reported.add(discord_user_id) + return True diff --git a/src/sturnus/entrypoints/bot.py b/src/sturnus/entrypoints/bot.py index 36c3374..1943f9c 100644 --- a/src/sturnus/entrypoints/bot.py +++ b/src/sturnus/entrypoints/bot.py @@ -36,6 +36,7 @@ JobRepository, SessionRepository, ) +from sturnus.infrastructure.discord.announcer import DiscordAnnouncer from sturnus.infrastructure.discord.client import SturnusClient from sturnus.infrastructure.discord.link_cog import PROVIDER as OUTLINE_PROVIDER from sturnus.infrastructure.documents.outline_oauth import OutlineOAuth @@ -64,27 +65,6 @@ def now(self) -> datetime: return datetime.now(UTC) -class _DiscordAnnouncer: - """Satisfies `sturnus.application.publishing.Announcer` over the gateway. - - Posts into the session's own `channel_id` -- the recording channel - (Spec 8.5) -- which discord.py's `VoiceChannel` supports directly via - `.send()`, the same way `sturnus.infrastructure.discord.voice. - VoiceReceiveAdapter` already resolves that same id with `get_channel`. - """ - - def __init__(self, client: discord.Client) -> None: - self._client = client - - async def post(self, channel_id: int, text: str) -> None: - channel = self._client.get_channel(channel_id) or await self._client.fetch_channel( - channel_id - ) - if not isinstance(channel, discord.abc.Messageable): - raise ValueError(f"channel {channel_id} cannot receive messages") - await channel.send(text) - - async def _publish_loop( client: discord.Client, sessions: SessionReader, @@ -115,7 +95,7 @@ async def _publish_loop( not stop the other, or kill this loop outright. """ await client.wait_until_ready() - announcer = _DiscordAnnouncer(client) + announcer = DiscordAnnouncer(client) while not stop.is_set(): now = datetime.now(UTC) try: diff --git a/src/sturnus/infrastructure/db/models.py b/src/sturnus/infrastructure/db/models.py index c34d5c0..b281c36 100644 --- a/src/sturnus/infrastructure/db/models.py +++ b/src/sturnus/infrastructure/db/models.py @@ -106,6 +106,16 @@ class SessionParticipant(Base): detected_language: Mapped[str | None] = mapped_column(Text) first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) audio_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + #: When this speaker's audio was first seen to be arriving with no + #: audible level in it -- packets received and decoded, every sample at + #: the noise floor (`sturnus.domain.silence`). The bot writes it during + #: the session, at the same moment it says so in the channel, because + #: the message is gone by the next meeting and this is what an operator + #: can still read afterwards: it is what separates "we could not hear + #: them" from "they said nothing", which two empty transcripts left + #: unanswerable. Nullable, and null for nearly everybody: being quiet + #: is normal, so a value here means something on its own. + silent_audio_detected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) __table_args__ = ( UniqueConstraint("session_id", "discord_user_id", name="uq_participant_per_session"), diff --git a/src/sturnus/infrastructure/db/repositories.py b/src/sturnus/infrastructure/db/repositories.py index 241e922..7e69b87 100644 --- a/src/sturnus/infrastructure/db/repositories.py +++ b/src/sturnus/infrastructure/db/repositories.py @@ -133,6 +133,7 @@ async def add_participant( detected_language=None, first_seen_at=now, audio_started_at=None, + silent_audio_detected_at=None, ) await session.execute( statement.on_conflict_do_nothing( @@ -158,6 +159,35 @@ async def set_audio_epoch(self, session_id: int, discord_user_id: int, now: date ) await session.commit() + async def record_silent_audio( + self, session_id: int, discord_user_id: int, at: datetime + ) -> None: + """Stamps that this speaker's audio arrived carrying no audible level. + + Writes only while `silent_audio_detected_at` is still null, the + same "first write wins" guard `set_audio_epoch` uses just above and + for the same reason: the column answers *from when* this speaker's + audio was empty, and a later write would keep moving that answer + forward to whenever somebody last looked. + + Nothing in the running system reads it back. It exists for the + person who asks, weeks later, why a transcript was empty -- the + question two live sessions left unanswerable, because a recording + at the noise floor and a meeting in which nobody spoke leave + exactly the same rows behind. + """ + async with self._session_factory() as session: + await session.execute( + update(SessionParticipant) + .where( + SessionParticipant.session_id == session_id, + SessionParticipant.discord_user_id == discord_user_id, + SessionParticipant.silent_audio_detected_at.is_(None), + ) + .values(silent_audio_detected_at=at) + ) + await session.commit() + async def audio_epoch(self, session_id: int, discord_user_id: int) -> datetime | None: async with self._session_factory() as session: return await session.scalar( diff --git a/src/sturnus/infrastructure/discord/announcer.py b/src/sturnus/infrastructure/discord/announcer.py new file mode 100644 index 0000000..e8303ef --- /dev/null +++ b/src/sturnus/infrastructure/discord/announcer.py @@ -0,0 +1,38 @@ +"""The one adapter that turns `Announcer` into an actual Discord message. + +Two callers post through it, and both post into the session's own +`channel_id` -- the recording voice channel, which discord.py's +`VoiceChannel` supports directly via `.send()`, the same way +`sturnus.infrastructure.discord.voice.VoiceReceiveAdapter` already resolves +that id with `get_channel`. `sturnus.entrypoints.bot._publish_loop` posts a +finished session's document link (Spec 8.5); `sturnus.application.recording. +RecordingService` posts the one in-meeting warning it has to give, that a +speaker's audio is arriving with no audible level. + +It lives here rather than in `sturnus.entrypoints.bot`, where it began, for +exactly that second caller: `SturnusClient` builds the recording pipeline +and would otherwise have to import from an entrypoint, which is the +dependency direction backwards. One adapter also means one place where a +message can go out, so a change to how this system addresses a channel -- +permissions, threading, failure handling -- cannot land in one path and +miss the other. +""" + +from __future__ import annotations + +import discord + + +class DiscordAnnouncer: + """Satisfies `sturnus.application.publishing.Announcer` over the gateway.""" + + def __init__(self, client: discord.Client) -> None: + self._client = client + + async def post(self, channel_id: int, text: str) -> None: + channel = self._client.get_channel(channel_id) or await self._client.fetch_channel( + channel_id + ) + if not isinstance(channel, discord.abc.Messageable): + raise ValueError(f"channel {channel_id} cannot receive messages") + await channel.send(text) diff --git a/src/sturnus/infrastructure/discord/client.py b/src/sturnus/infrastructure/discord/client.py index 96c8373..43ece80 100644 --- a/src/sturnus/infrastructure/discord/client.py +++ b/src/sturnus/infrastructure/discord/client.py @@ -61,6 +61,7 @@ SessionRepository, ) from sturnus.infrastructure.discord.about_cog import AboutCog +from sturnus.infrastructure.discord.announcer import DiscordAnnouncer from sturnus.infrastructure.discord.audio_cog import AudioCog from sturnus.infrastructure.discord.config_cog import ConfigCog from sturnus.infrastructure.discord.consent_cog import ConsentCog @@ -547,6 +548,11 @@ async def _build(self, guild_id: int, desired: GuildRuntimeConfig) -> None: store=self._audio_store, writers=self._writer_factory, encryptor=self._encryptor, + # The real adapter, not a seam: unlike `_make_voice` below, + # nothing here needs a gateway connection to *construct*, so a + # test can drive this build path and still see what the + # pipeline said by standing in for the channel it resolves. + announcer=DiscordAnnouncer(self), retention_days=desired.retention_days, ) self._guilds[guild_id] = _GuildRecording( diff --git a/tests/application/test_publishing.py b/tests/application/test_publishing.py index 3f4d960..96c88fe 100644 --- a/tests/application/test_publishing.py +++ b/tests/application/test_publishing.py @@ -1,6 +1,10 @@ from datetime import UTC, datetime -from sturnus.application.publishing import announce_ready_sessions, sessions_to_announce +from sturnus.application.publishing import ( + announce_ready_sessions, + render_silent_audio_warning, + sessions_to_announce, +) T0 = datetime(2026, 8, 19, 20, 0, 0, tzinfo=UTC) @@ -119,3 +123,29 @@ async def test_announce_ready_sessions_survives_one_sessions_failure() -> None: await announce_ready_sessions(sessions, announcer, T0) # must not raise assert [channel_id for channel_id, _ in announcer.posted] == [43] assert sessions.announced == [2] + + +# --------------------------------------------------------------------------- +# The silent-audio warning: the other message this system posts into a +# recording channel, rendered through the same engine as the link above. +# --------------------------------------------------------------------------- + + +def test_the_silent_audio_warning_mentions_the_speaker() -> None: + """`<@id>` is Discord's mention syntax, and the point of posting publicly. + + The person whose microphone is dead is frequently the last one to + notice, and a message nobody is addressed by scrolls past unread. + """ + assert "<@100>" in render_silent_audio_warning(100) + + +def test_the_silent_audio_warning_says_the_recording_continues() -> None: + """Naming somebody in the channel is already uncomfortable enough. + + Without this, the obvious reading of the message is "you are not being + recorded, do something now" -- which is false: everything after the + warning is still captured, and a fixed microphone simply starts + arriving audibly. + """ + assert "Recording continues." in render_silent_audio_warning(100) diff --git a/tests/application/test_recording.py b/tests/application/test_recording.py index 308480e..18c42db 100644 --- a/tests/application/test_recording.py +++ b/tests/application/test_recording.py @@ -23,12 +23,24 @@ def pcm(frames: int) -> bytes: return b"\x10\x27" * 2 * frames +def silent_pcm(seconds: float) -> bytes: + """`seconds` of the same format, with every sample at zero. + + What a microphone muted at system level produces: packets arrive and + decode, and there is nothing in them. Sized in seconds rather than + frames because the threshold it has to reach is measured in seconds of + received audio (`sturnus.domain.silence.SILENCE_EVIDENCE_SECONDS`). + """ + return b"\x00" * (int(seconds * 48_000) * 4) + + class FakeSessions: def __init__(self) -> None: self.opened: list[int] = [] self.channel_names: list[str | None] = [] self.participants: dict[int, str] = {} self.epochs: dict[int, datetime] = {} + self.silent_audio: dict[int, datetime] = {} self.closed: list[tuple[int, str]] = [] self.keys: dict[int, tuple[str, bytes]] = {} self.status: dict[int, str] = {} @@ -60,6 +72,9 @@ async def add_participant( async def set_audio_epoch(self, _session_id: int, user_id: int, at: datetime) -> None: self.epochs.setdefault(user_id, at) + async def record_silent_audio(self, _session_id: int, user_id: int, at: datetime) -> None: + self.silent_audio.setdefault(user_id, at) + async def close_session(self, session_id: int, _ended_at: datetime, reason: str) -> None: self.closed.append((session_id, reason)) self.status[session_id] = "closed" @@ -71,6 +86,24 @@ async def find_open_session(self, _guild_id: int) -> int | None: return None +class FakeAnnouncer: + """Stands in for the Discord gateway on the `Announcer` port. + + `fails` makes `post` raise the way an unreachable channel or a rate + limit would, so the tests can pin that a failed message costs the + message and nothing else. + """ + + def __init__(self, fails: bool = False) -> None: + self.posted: list[tuple[int, str]] = [] + self._fails = fails + + async def post(self, channel_id: int, text: str) -> None: + if self._fails: + raise RuntimeError("Discord API is briefly unreachable") + self.posted.append((channel_id, text)) + + class FakeJobs: def __init__(self) -> None: self.enqueued: list[dict[str, object]] = [] @@ -174,6 +207,7 @@ def service( writers: FakeAudioWriterFactory | None = None, encryptor: FakeEncryptor | None = None, channel_name: str | None = None, + announcer: FakeAnnouncer | None = None, ) -> RecordingService: return RecordingService( guild_id=GUILD, @@ -186,6 +220,7 @@ def service( store=store or FakeStore(), writers=writers or FakeAudioWriterFactory(tmp_path), encryptor=encryptor or FakeEncryptor(), + announcer=announcer or FakeAnnouncer(), retention_days=30, channel_name=channel_name, ) @@ -613,3 +648,189 @@ async def test_speaker_stream_ended_retires_that_ssrcs_reference_point(tmp_path: # Without the reset, an RTP timestamp of 0 against a reference of # 48_000 would place this packet a second *before* the epoch. assert writer.placed_at[-1] == later + + +# --------------------------------------------------------------------------- +# Audio that arrives and decodes but carries no level. Not "somebody is +# quiet" -- that is most of a meeting and must never trigger anything -- but +# "packets are flowing from this speaker and every sample in them is zero", +# which is a microphone muted at system level and produces a recording that +# transcribes to nothing. See `sturnus.domain.silence`. +# --------------------------------------------------------------------------- + + +async def test_a_speaker_whose_audio_carries_no_level_is_named_in_the_channel( + tmp_path: Path, +) -> None: + """The whole point of the feature: say so while the meeting can still act. + + Posted publicly into the recording channel and mentioning the person, + because whoever is muted at system level is usually the last to notice + and somebody else in the room can help. + """ + announcer = FakeAnnouncer() + svc = service(tmp_path, announcer=announcer) + await svc.participants_changed(1, T0) + + for second in range(30): + await svc.voice_packet( + ANNA, "anna", 1, RTP * (second + 1), silent_pcm(1.0), T0 + timedelta(seconds=second) + ) + + assert len(announcer.posted) == 1 + channel_id, text = announcer.posted[0] + assert channel_id == CHANNEL, "into the recording channel itself, not somewhere else" + assert "<@100>" in text + assert "Recording continues." in text + + +async def test_the_speaker_is_named_only_once_in_a_session(tmp_path: Path) -> None: + """A repeat costs the same person the same embarrassment for no new information.""" + announcer = FakeAnnouncer() + svc = service(tmp_path, announcer=announcer) + await svc.participants_changed(1, T0) + + for second in range(120): + await svc.voice_packet( + ANNA, "anna", 1, RTP * (second + 1), silent_pcm(1.0), T0 + timedelta(seconds=second) + ) + + assert len(announcer.posted) == 1 + + +async def test_a_participant_who_simply_does_not_speak_is_never_warned(tmp_path: Path) -> None: + """The distinction the whole feature rests on. + + No packets means the person is not speaking or not transmitting, which + is normal for most of a meeting and is nobody's fault. Only audio that + actually arrived and decoded into nothing is evidence of a fault. + """ + announcer = FakeAnnouncer() + sessions = FakeSessions() + svc = service(tmp_path, sessions=sessions, announcer=announcer) + await svc.participants_changed(1, T0) + + await svc.tick(T0 + timedelta(minutes=10)) + + assert announcer.posted == [] + assert sessions.silent_audio == {} + + +async def test_audible_audio_is_never_warned_about(tmp_path: Path) -> None: + announcer = FakeAnnouncer() + svc = service(tmp_path, announcer=announcer) + await svc.participants_changed(1, T0) + + for second in range(120): + await svc.voice_packet( + ANNA, "anna", 1, RTP * (second + 1), pcm(48_000), T0 + timedelta(seconds=second) + ) + + assert announcer.posted == [] + + +async def test_the_finding_is_recorded_on_the_participant_row(tmp_path: Path) -> None: + """A chat message is gone by the next meeting; the row is what an + operator can still read afterwards to tell a broken pipeline from a + room full of people who said nothing. + + Stamped with the packet's own place on the timeline rather than the + arrival time, matching `audio_started_at` -- both answer "when in this + recording", and a reader comparing the two must not be comparing two + different clocks. + + The last packet is handed in three quarters of a second late, which is + what the hop from the capture thread through the queue onto the event + loop does under load. That lag is what separates the two candidate + timestamps here at all: without it they coincide and the test would + pass whichever one the code picked. + """ + sessions = FakeSessions() + svc = service(tmp_path, sessions=sessions) + await svc.participants_changed(1, T0) + + for second in range(29): + await svc.voice_packet( + ANNA, "anna", 1, RTP * (second + 1), silent_pcm(1.0), T0 + timedelta(seconds=second) + ) + await svc.voice_packet( + ANNA, "anna", 1, RTP * 30, silent_pcm(1.0), T0 + timedelta(seconds=29, milliseconds=750) + ) + + assert sessions.silent_audio == {ANNA: T0 + timedelta(seconds=29)} + + +async def test_a_failing_announcer_costs_the_message_and_nothing_else(tmp_path: Path) -> None: + """An unreachable channel must never cost the recording it is reporting on. + + The audio still reaches the writer, the finding still reaches the + participant row, and the session keeps running -- a warning that could + take a meeting down with it would be worse than no warning at all. + """ + sessions = FakeSessions() + jobs = FakeJobs() + svc = service(tmp_path, sessions=sessions, jobs=jobs, announcer=FakeAnnouncer(fails=True)) + await svc.participants_changed(1, T0) + + for second in range(30): + await svc.voice_packet( + ANNA, "anna", 1, RTP * (second + 1), silent_pcm(1.0), T0 + timedelta(seconds=second) + ) + + assert svc.is_recording is True + assert sessions.silent_audio == {ANNA: T0 + timedelta(seconds=29)} + await svc.close(EndReason.EMPTY, T0 + timedelta(minutes=1)) + assert [job["discord_user_id"] for job in jobs.enqueued] == [ANNA] + + +async def test_a_failing_participant_row_still_gets_the_message_out(tmp_path: Path) -> None: + """The two halves are independent, and the in-meeting half is the urgent one. + + A database hiccup while stamping the row must not swallow the message + that could still get somebody's microphone fixed before the meeting + ends. + """ + + class BrokenSessions(FakeSessions): + async def record_silent_audio(self, _session_id: int, _user_id: int, _at: datetime) -> None: + raise RuntimeError("the database is briefly unreachable") + + announcer = FakeAnnouncer() + svc = service(tmp_path, sessions=BrokenSessions(), announcer=announcer) + await svc.participants_changed(1, T0) + + for second in range(30): + await svc.voice_packet( + ANNA, "anna", 1, RTP * (second + 1), silent_pcm(1.0), T0 + timedelta(seconds=second) + ) + + assert len(announcer.posted) == 1 + assert svc.is_recording is True + + +async def test_the_next_session_may_warn_the_same_speaker_again(tmp_path: Path) -> None: + """ "Once per speaker" is scoped to the session, not to the process. + + Somebody who arrived muted last week and again today has a problem + today too, and a bot that stayed quiet about it because it had + mentioned it once, hours ago, in a meeting that is already over would + be useless. + """ + announcer = FakeAnnouncer() + svc = service(tmp_path, announcer=announcer) + + for session_start in (T0, T0 + timedelta(hours=1)): + await svc.participants_changed(1, session_start) + for second in range(30): + await svc.voice_packet( + ANNA, + "anna", + 1, + RTP * (second + 1), + silent_pcm(1.0), + session_start + timedelta(seconds=second), + ) + await svc.end_now(EndReason.SHUTDOWN, session_start + timedelta(minutes=1)) + svc.reset() + + assert len(announcer.posted) == 2 diff --git a/tests/application/test_recovery.py b/tests/application/test_recovery.py index f8fd044..830af03 100644 --- a/tests/application/test_recovery.py +++ b/tests/application/test_recovery.py @@ -49,6 +49,11 @@ async def add_participant( async def set_audio_epoch(self, _session_id: int, _user_id: int, _at: datetime) -> None: raise AssertionError("recovery must never set an audio epoch") + async def record_silent_audio(self, _session_id: int, _user_id: int, _at: datetime) -> None: + # Only `voice_packet` ever detects silent audio, and recovery never + # reaches it -- there are no packets left to observe, only files. + raise AssertionError("recovery must never record silent audio") + async def close_session(self, session_id: int, _ended_at: datetime, reason: str) -> None: self.closed.append((session_id, reason)) self._status[session_id] = "closed" diff --git a/tests/domain/test_silence.py b/tests/domain/test_silence.py new file mode 100644 index 0000000..f57671e --- /dev/null +++ b/tests/domain/test_silence.py @@ -0,0 +1,165 @@ +"""Tests for the amplitude watch that tells a quiet meeting from a dead microphone. + +Every case here is about the distinction the feature exists to make: +audio that *arrived and decoded* but carries nothing audible is a fault, +while audio that carries something -- however quiet -- is a meeting. +Nothing in this module touches Discord, a database or a file; the watch is +pure arithmetic over bytes, which is exactly why it lives in the domain. +""" + +from sturnus.domain.silence import ( + BYTES_PER_SAMPLE_FRAME, + SILENCE_EVIDENCE_BYTES, + SILENCE_PEAK_AMPLITUDE, + SOURCE_SAMPLE_RATE_HZ, + SilentAudioWatch, + peak_amplitude, +) + +ANNA, BEN = 100, 200 + + +def _pcm(seconds: float, sample: int) -> bytes: + """`seconds` of 48 kHz 16-bit stereo PCM in which every sample is `sample`.""" + frames = int(seconds * SOURCE_SAMPLE_RATE_HZ) + return int(sample & 0xFFFF).to_bytes(2, "little") * 2 * frames + + +def silent(seconds: float) -> bytes: + return _pcm(seconds, 0) + + +def audible(seconds: float) -> bytes: + return _pcm(seconds, 10_000) + + +def test_digital_silence_peaks_at_zero() -> None: + assert peak_amplitude(silent(0.02)) == 0 + + +def test_the_peak_is_the_loudest_sample_in_the_packet() -> None: + assert peak_amplitude(b"\x00\x00" + b"\x10\x27" + b"\x00\x00") == 10_000 + + +def test_a_negative_sample_counts_by_its_magnitude() -> None: + """A waveform is symmetric around zero; only distance from it is loudness. + + Reading the peak off the signed values alone would report a loud + negative half-cycle as quieter than digital silence, and a speaker + whose packet happened to land on one would be warned about mid- + sentence. + """ + assert peak_amplitude((-10_000 & 0xFFFF).to_bytes(2, "little")) == 10_000 + + +def test_an_empty_packet_has_no_peak() -> None: + """The decoder hands back `b""` for a frame it could not conceal.""" + assert peak_amplitude(b"") == 0 + + +def test_a_trailing_half_sample_is_ignored_rather_than_raising() -> None: + """`peak_amplitude` is called on every frame of every speaker, and the + packet length comes from outside this process. A byte count that is not + a whole number of samples must cost the last half-sample, never the + session. + """ + assert peak_amplitude(b"\x10\x27\x00") == 10_000 + + +def test_nothing_is_reported_before_the_evidence_threshold() -> None: + """People are silent for most of a meeting. That is never news.""" + watch = SilentAudioWatch() + fired = [watch.observe(ANNA, silent(1.0)) for _ in range(29)] + assert not any(fired) + + +def test_the_threshold_reports_exactly_once_and_then_never_again() -> None: + """One message per speaker per session -- the second one only annoys.""" + watch = SilentAudioWatch() + for _ in range(29): + assert watch.observe(ANNA, silent(1.0)) is False + assert watch.observe(ANNA, silent(1.0)) is True + for _ in range(60): + assert watch.observe(ANNA, silent(1.0)) is False + + +def test_audible_audio_discards_the_evidence_collected_so_far() -> None: + """A single audible frame means the microphone works, whatever came before. + + The evidence has to be *continuous*: someone who spoke twenty seconds + ago and has been quiet since is a person in a meeting, not a fault. + """ + watch = SilentAudioWatch() + for _ in range(29): + watch.observe(ANNA, silent(1.0)) + assert watch.observe(ANNA, audible(0.02)) is False + for _ in range(29): + assert watch.observe(ANNA, silent(1.0)) is False + assert watch.observe(ANNA, silent(1.0)) is True + + +def test_each_speaker_is_watched_separately() -> None: + """Ben talking says nothing about whether Anna's microphone works.""" + watch = SilentAudioWatch() + for _ in range(29): + watch.observe(ANNA, silent(1.0)) + watch.observe(BEN, audible(1.0)) + assert watch.observe(BEN, audible(1.0)) is False + assert watch.observe(ANNA, silent(1.0)) is True + + +def test_the_noise_floor_counts_as_silence() -> None: + """Opus does not always decode digital silence back to exact zeros. + + A hard `== 0` test would therefore report nothing at all on the very + recordings this feature was written for -- the threshold is what makes + it detect a muted microphone rather than only a zeroed buffer. + + The amplitude here is a literal, deliberately not + `SILENCE_PEAK_AMPLITUDE` itself: a test written against the constant + passes for *any* value of it, including the `0` this case exists to + rule out. `16` is about -66 dBFS, the order of magnitude a lossy codec + leaves behind on a silent input. + """ + watch = SilentAudioWatch() + for _ in range(29): + assert watch.observe(ANNA, _pcm(1.0, 16)) is False + assert watch.observe(ANNA, _pcm(1.0, 16)) is True + + +def test_quiet_speech_is_audio_and_is_never_warned_about() -> None: + """The other side of the same boundary, pinned with a literal for the + same reason: a threshold raised far enough to swallow a softly spoken + word would put somebody who *is* being recorded on the spot in front of + the room, which is the failure this feature must never produce. + + `1000` is about -30 dBFS -- quiet, distant, unmistakably sound. + """ + watch = SilentAudioWatch() + for _ in range(29): + watch.observe(ANNA, silent(1.0)) + assert watch.observe(ANNA, _pcm(0.02, 1_000)) is False + assert watch.observe(ANNA, silent(1.0)) is False, "and the evidence started over" + + +def test_one_step_above_the_threshold_is_audio() -> None: + """`SILENCE_PEAK_AMPLITUDE` is the loudest peak still counted as + silence, not the quietest counted as audio -- the boundary is inclusive + on the silent side, and a change of that direction would shift every + detection by one sample value without any other test noticing. + """ + watch = SilentAudioWatch() + for _ in range(29): + watch.observe(ANNA, silent(1.0)) + assert watch.observe(ANNA, _pcm(0.02, SILENCE_PEAK_AMPLITUDE + 1)) is False + + +def test_the_evidence_threshold_is_thirty_seconds_of_received_audio() -> None: + """Pins the constant itself, since the whole design rests on its unit. + + It is a byte count of *received* PCM, not wall-clock time: a speaker + who transmits nothing for half an hour has produced no evidence of + anything, and warning them would be the false positive that makes the + feature unusable. + """ + assert SILENCE_EVIDENCE_BYTES == 30 * SOURCE_SAMPLE_RATE_HZ * BYTES_PER_SAMPLE_FRAME diff --git a/tests/infrastructure/discord/test_announcer.py b/tests/infrastructure/discord/test_announcer.py new file mode 100644 index 0000000..c3e58d5 --- /dev/null +++ b/tests/infrastructure/discord/test_announcer.py @@ -0,0 +1,75 @@ +"""Tests for the one adapter that turns `Announcer` into a Discord message. + +Both things this system says out loud go through it -- a finished session's +document link and the in-meeting warning that a speaker's audio carries no +level -- so the two branches it owns are worth pinning here rather than +only where they happen to be exercised: the cache miss, and the channel +that cannot be sent to at all. +""" + +from unittest.mock import AsyncMock, MagicMock + +import discord +import pytest + +from sturnus.infrastructure.discord.announcer import DiscordAnnouncer + +CHANNEL_ID = 4242 + + +def _messageable() -> MagicMock: + """A `VoiceChannel` stand-in that really does satisfy `isinstance`. + + `MagicMock(spec=...)` reports the spec class as its own, so the + adapter's `isinstance(channel, discord.abc.Messageable)` check runs for + real against it -- and `send`, being a coroutine function on the spec, + comes back as an `AsyncMock`. + """ + return MagicMock(spec=discord.VoiceChannel) + + +async def test_it_sends_into_the_channel_it_was_given() -> None: + client = MagicMock(spec=discord.Client) + channel = _messageable() + client.get_channel = MagicMock(return_value=channel) + + await DiscordAnnouncer(client).post(CHANNEL_ID, "hello") + + client.get_channel.assert_called_once_with(CHANNEL_ID) + channel.send.assert_awaited_once_with("hello") + + +async def test_a_channel_missing_from_the_cache_is_fetched() -> None: + """`get_channel` reads a cache the gateway fills, and it can be cold. + + A bot restarted mid-meeting, or one whose cache never covered this + guild, would otherwise silently drop the message into a `None` -- the + two things posted through here are a finished session's only link and + the one warning that can still save a recording, so neither may depend + on a cache being warm. + """ + channel = _messageable() + client = MagicMock(spec=discord.Client) + client.get_channel = MagicMock(return_value=None) + client.fetch_channel = AsyncMock(return_value=channel) + + await DiscordAnnouncer(client).post(CHANNEL_ID, "hello") + + client.fetch_channel.assert_awaited_once_with(CHANNEL_ID) + channel.send.assert_awaited_once_with("hello") + + +async def test_a_channel_that_cannot_receive_messages_is_refused_loudly() -> None: + """Raising beats calling `send` on something that does not have one. + + Both callers already survive a failed post -- `announce_ready_sessions` + retries on its next sweep and `RecordingService` logs and carries on -- + so an error here costs one message and reaches somebody's log, whereas + an `AttributeError` from inside a `send` that was never there would say + nothing about which channel was misconfigured. + """ + client = MagicMock(spec=discord.Client) + client.get_channel = MagicMock(return_value=MagicMock(spec=discord.CategoryChannel)) + + with pytest.raises(ValueError, match=str(CHANNEL_ID)): + await DiscordAnnouncer(client).post(CHANNEL_ID, "hello") diff --git a/tests/infrastructure/discord/test_capture_pipeline.py b/tests/infrastructure/discord/test_capture_pipeline.py index de8d0ac..4431f7a 100644 --- a/tests/infrastructure/discord/test_capture_pipeline.py +++ b/tests/infrastructure/discord/test_capture_pipeline.py @@ -85,6 +85,10 @@ async def set_audio_epoch( self, session_id: int, discord_user_id: int, at: datetime ) -> None: ... + async def record_silent_audio( + self, session_id: int, discord_user_id: int, at: datetime + ) -> None: ... + async def close_session(self, session_id: int, ended_at: datetime, reason: str) -> None: ... async def record_session_key( @@ -103,6 +107,18 @@ async def enqueue(self, **kwargs: object) -> int: # noqa: ARG002 return 1 +class FakeAnnouncer: + """Stands in for the gateway on the `Announcer` port. + + Silent, because nothing in this module feeds the pipeline the thirty + seconds of level-less audio that would make it speak -- every packet + here is either real audio or a decode failure. + """ + + async def post(self, channel_id: int, text: str) -> None: # noqa: ARG002 + return None + + class FakeStore: async def put(self, key: str, source: Path) -> None: ... @@ -171,6 +187,7 @@ def build_service(tmp_path: Path) -> RecordingService: store=FakeStore(), writers=FileAudioWriterFactory(tmp_path), encryptor=FakeEncryptor(), + announcer=FakeAnnouncer(), retention_days=30, ) diff --git a/tests/infrastructure/discord/test_client.py b/tests/infrastructure/discord/test_client.py index 06ee3cf..cea6616 100644 --- a/tests/infrastructure/discord/test_client.py +++ b/tests/infrastructure/discord/test_client.py @@ -110,6 +110,7 @@ def __init__(self) -> None: self.opened: list[int] = [] self.keys: dict[int, tuple[str, bytes]] = {} self.closed: list[tuple[int, str]] = [] + self.silent_audio: list[tuple[int, int, datetime]] = [] self._participants: dict[int, set[int]] = {} self._next = 1 @@ -138,6 +139,11 @@ async def add_participant( async def set_audio_epoch(self, _session_id: int, _discord_user_id: int, _at: datetime) -> None: pass + async def record_silent_audio( + self, session_id: int, discord_user_id: int, at: datetime + ) -> None: + self.silent_audio.append((session_id, discord_user_id, at)) + async def close_session(self, session_id: int, _ended_at: datetime, reason: str) -> None: self.closed.append((session_id, reason)) @@ -221,6 +227,18 @@ async def snapshot(self, guild_id: int) -> dict[str, str]: return {**settings.DEFAULTS, **self.values.get(guild_id, {})} +class FakeAnnouncer: + """Satisfies the `Announcer` port for the pipelines these tests build by hand. + + The pipelines the *client* builds get the real `DiscordAnnouncer` + instead -- see `test_a_pipeline_the_client_builds_can_warn_its_own_ + channel`, which stands in for the channel rather than for the adapter. + """ + + async def post(self, channel_id: int, text: str) -> None: # noqa: ARG002 + return None + + class FakeVoiceReceiver: """Satisfies the `VoiceReceiver` port without a real gateway connection.""" @@ -384,6 +402,7 @@ async def test_two_consecutive_sessions_through_the_client(tmp_path: Path) -> No store=FakeStore(), writers=FakeAudioWriterFactory(tmp_path), encryptor=FakeEncryptor(), + announcer=FakeAnnouncer(), retention_days=30, ) voice = FakeVoiceReceiver() @@ -1445,6 +1464,7 @@ def _service(sessions: FakeSessions, jobs: FakeJobs, root: Path) -> RecordingSer store=FakeStore(), writers=FakeAudioWriterFactory(root), encryptor=FakeEncryptor(), + announcer=FakeAnnouncer(), retention_days=30, ) @@ -1597,3 +1617,60 @@ async def test_the_guard_lapses_on_the_tick_with_no_voice_state_update_at_all( assert voice.joined == [CHANNEL_ID, CHANNEL_ID] assert client._guilds[GUILD_ID].service.is_recording is True assert client._guilds[GUILD_ID].blocked_until is None + + +async def test_a_pipeline_the_client_builds_can_warn_its_own_channel(tmp_path: Path) -> None: + """The wiring, end to end through the real build path and the real adapter. + + `RecordingService` can only say anything if `_build` handed it an + announcer, and it can only say it in the right place if that announcer + resolves the session's own voice channel and sends there. None of that + is visible to `tests/application/test_recording.py`, which constructs + the service itself around a fake -- exactly the kind of gap that let a + `sessions_to_announce` with no caller anywhere sit in this codebase + with passing tests (Defect 3). + + So nothing between `reconcile_guild` and `VoiceChannel.send` is + substituted here: the real `DiscordAnnouncer` runs, and only the + channel it resolves is a stand-in. `_voice_channel` produces a + `MagicMock(spec=discord.VoiceChannel)`, which really does satisfy the + adapter's `isinstance(..., discord.abc.Messageable)` check, so even + the "can this channel receive messages" branch is the live one. + + Thirty seconds of level-less audio is what a microphone muted at + system level produces: packets arrive, decode, and contain nothing. + """ + clock = FakeClock(T0) + sessions, jobs = FakeSessions(), FakeJobs() + store = _configured_store() + client = _client( + clock, config_store=store, sessions=sessions, jobs=jobs, recording_dir=tmp_path + ) + guild = _guild(GUILD_ID, _voice_channel(CHANNEL_ID, members=[])) + _in_guild(client, guild) + await client._tick_all(clock.now()) + + anna = _member(ANNA, guild, role_ids=[ROLE_ID]) + await _start_session(client, guild, anna) + + # What the gateway's channel cache would hand back. Assigned onto the + # instance rather than reaching into `_connection`, because the only + # thing under test here is what the announcer does with the id it is + # given. + channel = _voice_channel(CHANNEL_ID, members=[anna]) + client.get_channel = MagicMock(return_value=channel) # type: ignore[method-assign] + + service = client._guilds[GUILD_ID].service + silence = b"\x00" * (48_000 * 4) # one second of 48 kHz stereo 16-bit zeroes + for second in range(30): + await service.voice_packet( + ANNA, "anna", 1, 48_000 * (second + 1), silence, T0 + timedelta(seconds=second) + ) + + channel.send.assert_awaited_once_with( # type: ignore[attr-defined] + "Audio is arriving from <@100> but at no audible level. The microphone is " + "most likely muted at system level. Recording continues." + ) + assert [(user_id, at) for _, user_id, at in sessions.silent_audio] == [ + (ANNA, T0 + timedelta(seconds=29)) + ] diff --git a/tests/infrastructure/discord/test_voice_adapter.py b/tests/infrastructure/discord/test_voice_adapter.py index a01cdd2..4dbd043 100644 --- a/tests/infrastructure/discord/test_voice_adapter.py +++ b/tests/infrastructure/discord/test_voice_adapter.py @@ -83,6 +83,9 @@ async def add_participant( async def set_audio_epoch(self, _sid: int, _user: int, _at: datetime) -> None: return None + async def record_silent_audio(self, _sid: int, _user: int, _at: datetime) -> None: + return None + async def close_session(self, session_id: int, _ended_at: datetime, reason: str) -> None: self.closed.append((session_id, reason)) @@ -113,6 +116,7 @@ def recording_service(sessions: FakeSessions) -> RecordingService: store=AsyncMock(), writers=MagicMock(), encryptor=FakeEncryptor(), + announcer=AsyncMock(), retention_days=30, ) diff --git a/tests/infrastructure/test_repositories.py b/tests/infrastructure/test_repositories.py index adabc7b..439b681 100644 --- a/tests/infrastructure/test_repositories.py +++ b/tests/infrastructure/test_repositories.py @@ -1,12 +1,13 @@ from datetime import UTC, datetime, timedelta import pytest +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sturnus.application.assembly import serialize_transcript from sturnus.application.transcription import TranscribedSegment, TranscriptionResult from sturnus.entrypoints.worker import _WorkerSessionStore -from sturnus.infrastructure.db.models import AccountLink, Base, Session +from sturnus.infrastructure.db.models import AccountLink, Base, Session, SessionParticipant from sturnus.infrastructure.db.queue import JobQueue from sturnus.infrastructure.db.repositories import ( AccountLinkRepository, @@ -159,6 +160,80 @@ async def test_audio_epoch_is_written_once(factory: async_sessionmaker[AsyncSess assert await repo.audio_epoch(session_id, ANNA) == T0 + timedelta(seconds=3) +async def _silent_audio_at( + factory: async_sessionmaker[AsyncSession], session_id: int, user_id: int +) -> datetime | None: + """Reads the column back directly: nothing in the running system reads it. + + The bot writes it so that an operator investigating an empty transcript + weeks later can tell "we could not hear them" from "they said nothing", + and that reader is a person with a SQL prompt. Adding a repository + method purely so this test could call one would be production code with + no production caller. + """ + async with factory() as session: + return await session.scalar( + select(SessionParticipant.silent_audio_detected_at).where( + SessionParticipant.session_id == session_id, + SessionParticipant.discord_user_id == user_id, + ) + ) + + +async def test_silent_audio_is_recorded_on_the_participant( + factory: async_sessionmaker[AsyncSession], +) -> None: + """The durable half of the silent-audio warning (`sturnus.domain.silence`). + + The message posted into the channel is gone by the next meeting; this + row is what is still there when somebody asks why a transcript was + empty. + """ + repo = SessionRepository(factory) + session_id = await repo.open_session(GUILD, CHANNEL, "meeting-raum", T0) + await repo.add_participant(session_id, ANNA, "anna", T0) + + await repo.record_silent_audio(session_id, ANNA, T0 + timedelta(seconds=30)) + + assert await _silent_audio_at(factory, session_id, ANNA) == T0 + timedelta(seconds=30) + + +async def test_silent_audio_keeps_the_first_detection( + factory: async_sessionmaker[AsyncSession], +) -> None: + """First detection wins, the same way `set_audio_epoch` does. + + The column answers "from when was this speaker's audio empty", and a + later write would move that answer forward every time somebody looked + -- turning the one fact worth keeping into the time of the most recent + observation. + """ + repo = SessionRepository(factory) + session_id = await repo.open_session(GUILD, CHANNEL, "meeting-raum", T0) + await repo.add_participant(session_id, ANNA, "anna", T0) + + await repo.record_silent_audio(session_id, ANNA, T0 + timedelta(seconds=30)) + await repo.record_silent_audio(session_id, ANNA, T0 + timedelta(minutes=10)) + + assert await _silent_audio_at(factory, session_id, ANNA) == T0 + timedelta(seconds=30) + + +async def test_a_participant_with_audible_audio_has_no_silence_stamp( + factory: async_sessionmaker[AsyncSession], +) -> None: + """Null is the normal case and must stay the default. + + Everyone in every meeting who was simply quiet shares this row shape; + only a speaker whose audio actually arrived empty gets a timestamp, so + a non-null value means something on its own. + """ + repo = SessionRepository(factory) + session_id = await repo.open_session(GUILD, CHANNEL, "meeting-raum", T0) + await repo.add_participant(session_id, ANNA, "anna", T0) + + assert await _silent_audio_at(factory, session_id, ANNA) is None + + async def test_job_enqueue(factory: async_sessionmaker[AsyncSession]) -> None: sessions = SessionRepository(factory) jobs = JobRepository(factory) From f5f3efb71d1fdad96f75c270164c9230f7d1e6a0 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 20 Aug 2026 19:34:31 +0200 Subject: [PATCH 2/2] docs: record what a silent-audio warning means and does not mean 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". --- docs/operations.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/operations.md b/docs/operations.md index 6a15964..da9d2ab 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -441,6 +441,51 @@ stored is actually what is running. ## 5. Troubleshooting +**A speaker's audio arrives with no level.** During a meeting the bot may +post, into the recording channel and naming the person: + +> Audio is arriving from @Name but at no audible level. The microphone is +> most likely muted at system level. Recording continues. + +That message means something narrower than "it is quiet", and the +distinction is the whole point. Three states exist and only the third is +reported: + +| What is happening | Reported? | +|---|---| +| No packets at all from that speaker | No — they are not speaking, which is normal | +| Packets arrive but will not decode | No — that is `EndReason.DECODE_FAILURE`, section 5 below | +| Packets arrive, decode, and every sample stays at the noise floor | **Yes** | + +It fires after 30 seconds of *received* audio at the noise floor +(`SILENCE_EVIDENCE_SECONDS` in `sturnus/domain/silence.py`), counted in +bytes of PCM rather than wall-clock — somebody who transmits nothing for +half an hour has produced no evidence about their microphone and is never +warned. Any audible packet discards that speaker's evidence and starts +over. Once per speaker per session, never repeated. + +The usual cause is a microphone muted below Discord: in the operating +system's mixer, on the device itself, or a hardware mute switch. Discord's +own mute sends no packets at all and therefore never triggers this. + +It is also recorded, so it can be seen after the fact: + +```sql +SELECT discord_display_name, silent_audio_detected_at +FROM session_participant WHERE session_id = ; +``` + +A row with `silent_audio_detected_at` set will have contributed an empty +or near-empty transcript. That is the signal to look at before concluding +that Whisper failed: a transcript of one short hallucinated phrase over a +long recording is what silence looks like after transcription, not what a +broken model looks like. + +Only amplitude is ever measured. No sample is buffered, logged or sent +anywhere, which is why this applies equally to people who have not +consented — a peak is a number about loudness, not about content. + + **A job is `dead`.** `transcription_job.status` becomes `dead` once `attempts` reaches the worker's configured retry limit (`JobQueue.fail`). A dead job is deliberately excluded from the