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
|
- Works across container restarts (cold boot) and network reconnections
|
||||||
- The bot waits up to 60s for silence; if the channel stays active, it
|
- The bot waits up to 60s for silence; if the channel stays active, it
|
||||||
aborts and the saved state remains for manual `!resume`
|
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
|
- 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
|
### Voice Ducking
|
||||||
|
|
||||||
When other users speak in the Mumble channel, the music volume automatically
|
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:
|
if data is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
elapsed = data.get("elapsed", 0.0)
|
||||||
|
title = _truncate(data.get("title", data["url"]))
|
||||||
|
pos = _fmt_time(elapsed)
|
||||||
|
|
||||||
# Let pymumble fully stabilize after reconnect
|
# Let pymumble fully stabilize after reconnect
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
deadline = time.monotonic() + 60
|
deadline = time.monotonic() + 60
|
||||||
silence_needed = ps.get("duck_silence", 15)
|
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:
|
while time.monotonic() < deadline:
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
ts = getattr(bot, "_last_voice_ts", 0.0)
|
ts = getattr(bot, "_last_voice_ts", 0.0)
|
||||||
@@ -218,6 +231,8 @@ async def _auto_resume(bot) -> None:
|
|||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
log.info("music: auto-resume aborted, channel not silent after 60s")
|
log.info("music: auto-resume aborted, channel not silent after 60s")
|
||||||
|
await bot.send("0", f"Resume of '{title}' aborted -- "
|
||||||
|
"channel not silent")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Re-check after waiting -- someone may have started playback manually
|
# Re-check after waiting -- someone may have started playback manually
|
||||||
|
|||||||
@@ -463,6 +463,16 @@ class MumbleBot:
|
|||||||
|
|
||||||
# -- Voice streaming -----------------------------------------------------
|
# -- 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:
|
async def test_tone(self, duration: float = 3.0) -> None:
|
||||||
"""Send a 440Hz sine test tone for debugging voice output."""
|
"""Send a 440Hz sine test tone for debugging voice output."""
|
||||||
import math
|
import math
|
||||||
@@ -532,6 +542,7 @@ class MumbleBot:
|
|||||||
|
|
||||||
_max_step = 0.005 # max volume change per frame (~4s full ramp)
|
_max_step = 0.005 # max volume change per frame (~4s full ramp)
|
||||||
_cur_vol = _get_vol()
|
_cur_vol = _get_vol()
|
||||||
|
_was_feeding = True # track connected/disconnected transitions
|
||||||
|
|
||||||
frames = 0
|
frames = 0
|
||||||
try:
|
try:
|
||||||
@@ -542,6 +553,24 @@ class MumbleBot:
|
|||||||
if len(pcm) < _FRAME_BYTES:
|
if len(pcm) < _FRAME_BYTES:
|
||||||
pcm += b"\x00" * (_FRAME_BYTES - len(pcm))
|
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()
|
target = _get_vol()
|
||||||
if _cur_vol == target:
|
if _cur_vol == target:
|
||||||
# Fast path: flat scaling
|
# Fast path: flat scaling
|
||||||
@@ -559,25 +588,36 @@ class MumbleBot:
|
|||||||
pcm = _scale_pcm_ramp(pcm, _cur_vol, next_vol)
|
pcm = _scale_pcm_ramp(pcm, _cur_vol, next_vol)
|
||||||
_cur_vol = next_vol
|
_cur_vol = next_vol
|
||||||
|
|
||||||
self._mumble.sound_output.add_sound(pcm)
|
try:
|
||||||
frames += 1
|
self._mumble.sound_output.add_sound(pcm)
|
||||||
if progress is not None:
|
except (TypeError, AttributeError, OSError):
|
||||||
progress[0] = frames
|
# Disconnected mid-feed -- skip this frame
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
continue
|
||||||
|
|
||||||
if frames == 1:
|
if frames == 1:
|
||||||
log.info("stream_audio: first frame fed to pymumble")
|
log.info("stream_audio: first frame fed to pymumble")
|
||||||
|
|
||||||
# Keep buffer at most 1 second ahead
|
# Keep buffer at most 1 second ahead
|
||||||
while self._mumble.sound_output.get_buffer_size() > 1.0:
|
try:
|
||||||
await asyncio.sleep(0.05)
|
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
|
# Wait for buffer to drain
|
||||||
while self._mumble.sound_output.get_buffer_size() > 0:
|
try:
|
||||||
await asyncio.sleep(0.1)
|
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)
|
log.info("stream_audio: finished, %d frames", frames)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
try:
|
try:
|
||||||
self._mumble.sound_output.clear_buffer()
|
if self._is_audio_ready():
|
||||||
|
self._mumble.sound_output.clear_buffer()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
log.info("stream_audio: cancelled at frame %d", frames)
|
log.info("stream_audio: cancelled at frame %d", frames)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import struct
|
import struct
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
from derp.mumble import (
|
from derp.mumble import (
|
||||||
MumbleBot,
|
MumbleBot,
|
||||||
@@ -645,3 +645,95 @@ class TestPcmRamping:
|
|||||||
assert samples[1] == 7500
|
assert samples[1] == 7500
|
||||||
assert samples[2] == 5000
|
assert samples[2] == 5000
|
||||||
assert samples[3] == 2500
|
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