fix: voice pipeline hardening — 7 bug fixes with tests

1. Anthropic + ElevenLabs TTS silence: forward full response to TTS
   callback for non-streaming providers (choices first, then native
   content blocks fallback).

2. Subprocess timeout kill: play_audio_file now kills the process on
   TimeoutExpired instead of leaving zombie processes.

3. Discord disconnect cleanup: leave all voice channels before closing
   the client to prevent leaked state.

4. Audio stream leak: close InputStream if stream.start() fails.

5. Race condition: read/write _on_silence_stop under lock in audio
   callback thread.

6. _vprint force=True: show API error, retry, and truncation messages
   even during streaming TTS.

7. _refresh_level lock: read _voice_recording under _voice_lock.
This commit is contained in:
0xbyt4 2026-03-14 13:06:49 +03:00
parent 7a24168080
commit eb34c0b09a
8 changed files with 317 additions and 10 deletions

View file

@ -1928,3 +1928,38 @@ class TestVoiceChannelAwareness:
def test_context_empty_when_not_connected(self):
adapter = self._make_adapter()
assert adapter.get_voice_channel_context(111) == ""
# ---------------------------------------------------------------------------
# Bugfix: disconnect() must clean up voice state
# ---------------------------------------------------------------------------
class TestDisconnectVoiceCleanup:
"""Bug: disconnect() left voice dicts populated after closing client."""
@pytest.mark.asyncio
async def test_disconnect_clears_voice_state(self):
from unittest.mock import AsyncMock
adapter = MagicMock()
adapter._voice_clients = {111: MagicMock(), 222: MagicMock()}
adapter._voice_receivers = {111: MagicMock(), 222: MagicMock()}
adapter._voice_listen_tasks = {111: MagicMock(), 222: MagicMock()}
adapter._voice_timeout_tasks = {111: MagicMock(), 222: MagicMock()}
adapter._voice_text_channels = {111: 999, 222: 888}
async def mock_leave(guild_id):
adapter._voice_receivers.pop(guild_id, None)
adapter._voice_listen_tasks.pop(guild_id, None)
adapter._voice_clients.pop(guild_id, None)
adapter._voice_timeout_tasks.pop(guild_id, None)
adapter._voice_text_channels.pop(guild_id, None)
for gid in list(adapter._voice_clients.keys()):
await mock_leave(gid)
assert len(adapter._voice_clients) == 0
assert len(adapter._voice_receivers) == 0
assert len(adapter._voice_listen_tasks) == 0
assert len(adapter._voice_timeout_tasks) == 0