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

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

View File

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

View File

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

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