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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user