From 388f612435f7137f361d911be640a4191de07ac6 Mon Sep 17 00:00:00 2001 From: Ariel Tov Ben Date: Tue, 21 Jul 2026 23:51:31 +0400 Subject: [PATCH] fix(discord): drain pending voice input before disconnect --- plugins/platforms/discord/adapter.py | 27 +++++++++++++++++ tests/gateway/test_voice_command.py | 43 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 310061439cd..e5d75d793f9 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -744,6 +744,26 @@ class VoiceReceiver: return completed + def flush_pending(self) -> list: + """Return buffered utterances that have not yet reached silence.""" + completed = [] + + with self._lock: + ssrc_user_map = dict(self._ssrc_to_user) + for ssrc, buf in list(self._buffers.items()): + # 48kHz, 16-bit, stereo = 192000 bytes/sec + buf_duration = len(buf) / (self.SAMPLE_RATE * self.CHANNELS * 2) + if buf_duration >= self.MIN_SPEECH_DURATION: + user_id = ssrc_user_map.get(ssrc, 0) + if not user_id: + user_id = self._infer_user_for_ssrc(ssrc) + if user_id: + completed.append((user_id, bytes(buf))) + self._buffers.pop(ssrc, None) + self._last_packet_time.pop(ssrc, None) + + return completed + # ------------------------------------------------------------------ # PCM -> WAV conversion (for Whisper STT) # ------------------------------------------------------------------ @@ -3940,12 +3960,19 @@ class DiscordAdapter(BasePlatformAdapter): async with self._voice_locks.setdefault(guild_id, asyncio.Lock()): # Stop voice receiver first receiver = self._voice_receivers.pop(guild_id, None) + pending_inputs = [] if receiver: + pending_inputs = receiver.flush_pending() receiver.stop() listen_task = self._voice_listen_tasks.pop(guild_id, None) if listen_task: listen_task.cancel() + guild = self._client.get_guild(guild_id) if self._client is not None else None + for user_id, pcm_data in pending_inputs: + if self._is_allowed_user(str(user_id), guild=guild, is_dm=False): + await self._process_voice_input(guild_id, user_id, pcm_data) + # Tear down the mixer (stops the continuous outgoing stream). if getattr(self, "_voice_mixers", None) is not None: self._voice_mixers.pop(guild_id, None) diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index 6f5ca016414..8952a4d55ef 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -717,6 +717,18 @@ class TestVoiceReceiver: completed = receiver.check_silence() assert len(completed) == 0 + def test_flush_pending_returns_recent_utterance_before_silence(self): + """Disconnect drains a valid utterance even before silence is detected.""" + receiver = self._make_receiver() + receiver.map_ssrc(100, 42) + pcm_data = bytearray(b"\x00" * 96000) + receiver._buffers[100] = pcm_data + receiver._last_packet_time[100] = time.monotonic() + + assert receiver.flush_pending() == [(42, bytes(pcm_data))] + assert 100 not in receiver._buffers + assert 100 not in receiver._last_packet_time + def test_check_silence_unknown_user_discarded(self): receiver = self._make_receiver() # No SSRC mapping — user_id will be 0 @@ -1159,6 +1171,37 @@ class TestDiscordVoiceChannelMethods: assert 111 not in adapter._voice_sources assert 111 not in adapter._voice_receivers + @pytest.mark.asyncio + async def test_leave_voice_channel_processes_pending_audio_before_disconnect(self): + """Recent speech is transcribed before the voice connection is torn down.""" + adapter = self._make_adapter() + events = [] + mock_vc = MagicMock() + mock_vc.is_connected.return_value = True + + async def disconnect(): + events.append("disconnect") + + mock_vc.disconnect = disconnect + adapter._voice_clients[111] = mock_vc + + mock_receiver = MagicMock() + mock_receiver.flush_pending.side_effect = lambda: events.append("flush") or [(42, b"pcm")] + mock_receiver.stop.side_effect = lambda: events.append("stop") + adapter._voice_receivers[111] = mock_receiver + adapter._voice_listen_tasks[111] = MagicMock() + adapter._is_allowed_user = MagicMock(return_value=True) + + async def process(guild_id, user_id, pcm_data): + events.append("process") + + adapter._process_voice_input = process + + await adapter.leave_voice_channel(111) + + assert events == ["flush", "stop", "process", "disconnect"] + adapter._is_allowed_user.assert_called_once_with("42", guild=adapter._client.get_guild(111), is_dm=False) + @pytest.mark.asyncio async def test_leave_voice_channel_no_connection(self): """Leave when not connected — no crash."""