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 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)