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:
@@ -1605,9 +1605,24 @@ automatically resumes playback -- but only after the channel is silent
|
||||
- Works across container restarts (cold boot) and network reconnections
|
||||
- The bot waits up to 60s for silence; if the channel stays active, it
|
||||
aborts and the saved state remains for manual `!resume`
|
||||
- No chat message is sent on auto-resume; playback resumes silently
|
||||
- Chat messages announce resume intentions and abort reasons
|
||||
- The reconnect watcher starts via the `on_connected` plugin lifecycle hook
|
||||
|
||||
### Disconnect-Resilient Streaming
|
||||
|
||||
During brief network disconnects (~5-15s), the audio stream stays alive.
|
||||
The ffmpeg pipeline keeps running; PCM frames are read at real-time pace
|
||||
but dropped while pymumble reconnects. Once the connection re-establishes
|
||||
and the codec is negotiated, audio feeding resumes automatically. The
|
||||
listener hears a brief silence instead of a 30+ second restart with URL
|
||||
re-resolution.
|
||||
|
||||
- The `_is_audio_ready()` guard checks: mumble connected, sound_output
|
||||
exists, Opus encoder initialized
|
||||
- Frames are counted even during disconnect, so position tracking remains
|
||||
accurate
|
||||
- State transitions (connected/disconnected) are logged for diagnostics
|
||||
|
||||
### Voice Ducking
|
||||
|
||||
When other users speak in the Mumble channel, the music volume automatically
|
||||
|
||||
@@ -204,11 +204,24 @@ async def _auto_resume(bot) -> None:
|
||||
if data is None:
|
||||
return
|
||||
|
||||
elapsed = data.get("elapsed", 0.0)
|
||||
title = _truncate(data.get("title", data["url"]))
|
||||
pos = _fmt_time(elapsed)
|
||||
|
||||
# Let pymumble fully stabilize after reconnect
|
||||
await asyncio.sleep(10)
|
||||
|
||||
deadline = time.monotonic() + 60
|
||||
silence_needed = ps.get("duck_silence", 15)
|
||||
|
||||
ts = getattr(bot, "_last_voice_ts", 0.0)
|
||||
if ts != 0.0 and time.monotonic() - ts < silence_needed:
|
||||
await bot.send("0",
|
||||
f"Resuming '{title}' at {pos} once silent for "
|
||||
f"{silence_needed}s")
|
||||
else:
|
||||
await bot.send("0", f"Resuming '{title}' at {pos} in a moment")
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(2)
|
||||
ts = getattr(bot, "_last_voice_ts", 0.0)
|
||||
@@ -218,6 +231,8 @@ async def _auto_resume(bot) -> None:
|
||||
break
|
||||
else:
|
||||
log.info("music: auto-resume aborted, channel not silent after 60s")
|
||||
await bot.send("0", f"Resume of '{title}' aborted -- "
|
||||
"channel not silent")
|
||||
return
|
||||
|
||||
# Re-check after waiting -- someone may have started playback manually
|
||||
|
||||
@@ -463,6 +463,16 @@ class MumbleBot:
|
||||
|
||||
# -- Voice streaming -----------------------------------------------------
|
||||
|
||||
def _is_audio_ready(self) -> bool:
|
||||
"""Check if pymumble can accept audio (connected + encoder ready)."""
|
||||
if self._mumble is None:
|
||||
return False
|
||||
try:
|
||||
so = self._mumble.sound_output
|
||||
return so is not None and so.encoder is not None
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
async def test_tone(self, duration: float = 3.0) -> None:
|
||||
"""Send a 440Hz sine test tone for debugging voice output."""
|
||||
import math
|
||||
@@ -532,6 +542,7 @@ class MumbleBot:
|
||||
|
||||
_max_step = 0.005 # max volume change per frame (~4s full ramp)
|
||||
_cur_vol = _get_vol()
|
||||
_was_feeding = True # track connected/disconnected transitions
|
||||
|
||||
frames = 0
|
||||
try:
|
||||
@@ -542,6 +553,24 @@ class MumbleBot:
|
||||
if len(pcm) < _FRAME_BYTES:
|
||||
pcm += b"\x00" * (_FRAME_BYTES - len(pcm))
|
||||
|
||||
frames += 1
|
||||
if progress is not None:
|
||||
progress[0] = frames
|
||||
|
||||
if not self._is_audio_ready():
|
||||
# Disconnected -- keep reading ffmpeg at real-time pace
|
||||
if _was_feeding:
|
||||
log.warning("stream_audio: connection lost, "
|
||||
"dropping frames at %d", frames)
|
||||
_was_feeding = False
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
|
||||
if not _was_feeding:
|
||||
log.info("stream_audio: connection restored, "
|
||||
"resuming feed at frame %d", frames)
|
||||
_was_feeding = True
|
||||
|
||||
target = _get_vol()
|
||||
if _cur_vol == target:
|
||||
# Fast path: flat scaling
|
||||
@@ -559,25 +588,36 @@ class MumbleBot:
|
||||
pcm = _scale_pcm_ramp(pcm, _cur_vol, next_vol)
|
||||
_cur_vol = next_vol
|
||||
|
||||
self._mumble.sound_output.add_sound(pcm)
|
||||
frames += 1
|
||||
if progress is not None:
|
||||
progress[0] = frames
|
||||
try:
|
||||
self._mumble.sound_output.add_sound(pcm)
|
||||
except (TypeError, AttributeError, OSError):
|
||||
# Disconnected mid-feed -- skip this frame
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
|
||||
if frames == 1:
|
||||
log.info("stream_audio: first frame fed to pymumble")
|
||||
|
||||
# Keep buffer at most 1 second ahead
|
||||
while self._mumble.sound_output.get_buffer_size() > 1.0:
|
||||
await asyncio.sleep(0.05)
|
||||
try:
|
||||
while (self._is_audio_ready()
|
||||
and self._mumble.sound_output.get_buffer_size() > 1.0):
|
||||
await asyncio.sleep(0.05)
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
# Wait for buffer to drain
|
||||
while self._mumble.sound_output.get_buffer_size() > 0:
|
||||
await asyncio.sleep(0.1)
|
||||
try:
|
||||
while (self._is_audio_ready()
|
||||
and self._mumble.sound_output.get_buffer_size() > 0):
|
||||
await asyncio.sleep(0.1)
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
log.info("stream_audio: finished, %d frames", frames)
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
self._mumble.sound_output.clear_buffer()
|
||||
if self._is_audio_ready():
|
||||
self._mumble.sound_output.clear_buffer()
|
||||
except Exception:
|
||||
pass
|
||||
log.info("stream_audio: cancelled at frame %d", frames)
|
||||
|
||||
@@ -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