diff --git a/docs/USAGE.md b/docs/USAGE.md index 0ece7d5..4e6ebc7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 diff --git a/plugins/music.py b/plugins/music.py index edd6c52..8cef425 100644 --- a/plugins/music.py +++ b/plugins/music.py @@ -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 diff --git a/src/derp/mumble.py b/src/derp/mumble.py index fcde821..481a9df 100644 --- a/src/derp/mumble.py +++ b/src/derp/mumble.py @@ -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) diff --git a/tests/test_mumble.py b/tests/test_mumble.py index 3cdb569..2a52771 100644 --- a/tests/test_mumble.py +++ b/tests/test_mumble.py @@ -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