fix(discord): drain pending voice input before disconnect

This commit is contained in:
Ariel Tov Ben 2026-07-21 23:51:31 +04:00 committed by Teknium
parent a5b32a721a
commit 388f612435
2 changed files with 70 additions and 0 deletions

View file

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

View file

@ -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."""