fix: survive mumble disconnects without restarting audio stream

Guard stream_audio with _is_audio_ready() so that PCM frames are
dropped (not crashed on) when pymumble recreates SoundOutput with
encoder=None during reconnect. The ffmpeg pipeline stays alive,
position tracking remains accurate, and audio feeding resumes once
the codec is negotiated. Listeners hear brief silence instead of
a 30+ second restart with URL re-resolution.

Also adds chat messages to _auto_resume so users see what the bot
intends ("Resuming 'X' at M:SS in a moment" / "...aborted").
This commit is contained in:
user
2026-02-22 02:41:44 +01:00
parent ec55c2aef1
commit ab924444de
4 changed files with 173 additions and 11 deletions

View File

@@ -2,7 +2,7 @@
import asyncio
import struct
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
from derp.mumble import (
MumbleBot,
@@ -645,3 +645,95 @@ class TestPcmRamping:
assert samples[1] == 7500
assert samples[2] == 5000
assert samples[3] == 2500
# ---------------------------------------------------------------------------
# TestIsAudioReady
# ---------------------------------------------------------------------------
class TestIsAudioReady:
def test_no_mumble_object(self):
bot = _make_bot()
bot._mumble = None
assert bot._is_audio_ready() is False
def test_no_sound_output(self):
bot = _make_bot()
bot._mumble = MagicMock()
bot._mumble.sound_output = None
assert bot._is_audio_ready() is False
def test_no_encoder(self):
bot = _make_bot()
bot._mumble = MagicMock()
bot._mumble.sound_output.encoder = None
assert bot._is_audio_ready() is False
def test_ready(self):
bot = _make_bot()
bot._mumble = MagicMock()
bot._mumble.sound_output.encoder = MagicMock()
assert bot._is_audio_ready() is True
def test_attribute_error_handled(self):
bot = _make_bot()
bot._mumble = MagicMock()
del bot._mumble.sound_output
assert bot._is_audio_ready() is False
# ---------------------------------------------------------------------------
# TestStreamAudioDisconnect
# ---------------------------------------------------------------------------
class TestStreamAudioDisconnect:
def test_stream_survives_disconnect(self):
"""stream_audio keeps ffmpeg alive when connection drops mid-stream."""
bot = _make_bot()
bot._mumble = MagicMock()
bot._mumble.sound_output.encoder = MagicMock()
bot._mumble.sound_output.get_buffer_size.return_value = 0.0
frame = b"\x00" * 1920
# Track which frame we're on; disconnect after frame 3
frame_count = [0]
connected = [True]
async def _fake_read(n):
if frame_count[0] < 5:
frame_count[0] += 1
# Disconnect after 3 frames are read
if frame_count[0] > 3:
connected[0] = False
return frame
return b""
def _ready():
return connected[0]
proc = MagicMock()
proc.stdout.read = _fake_read
proc.stderr.read = AsyncMock(return_value=b"")
proc.wait = AsyncMock(return_value=0)
proc.kill = MagicMock()
progress = [0]
async def _run():
with patch.object(bot, "_is_audio_ready", side_effect=_ready):
with patch("asyncio.create_subprocess_exec",
return_value=proc):
await bot.stream_audio(
"http://example.com/audio",
volume=0.5,
progress=progress,
)
asyncio.run(_run())
# All 5 frames were read (progress tracks all, connected or not)
assert progress[0] == 5
# Only 3 frames were fed to sound_output (the connected ones)
assert bot._mumble.sound_output.add_sound.call_count == 3