diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 66cc4a9..c72ba92 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -546,7 +546,8 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops. ## Music (Mumble only) ``` -!play # Play audio (YouTube, SoundCloud, etc.) +!play # Play audio (YouTube, SoundCloud, etc.) +!play # Playlist tracks expanded into queue !stop # Stop playback, clear queue !skip # Skip current track !queue # Show queue @@ -557,7 +558,8 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops. ``` Requires: `yt-dlp`, `ffmpeg`, `libopus` on the host. -Max 50 tracks in queue. Volume changes take effect immediately. +Max 50 tracks in queue. Playlists auto-expand; excess truncated at limit. +Volume ramps smoothly over ~200ms (no abrupt jumps mid-playback). Mumble-only: `!play` replies with error on other adapters, others silently no-op. ## Plugin Template diff --git a/docs/USAGE.md b/docs/USAGE.md index ebbac03..38da56e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1565,7 +1565,7 @@ and voice transmission. - `libopus` -- Opus codec (used by pymumble/opuslib) ``` -!play Play audio or add to queue +!play Play audio or add to queue (playlists expanded) !stop Stop playback, clear queue !skip Skip current track !queue Show queue @@ -1576,8 +1576,11 @@ and voice transmission. ``` - Queue holds up to 50 tracks -- Volume takes effect immediately during playback (default: 50%) -- Title resolved via `yt-dlp --get-title` before playback +- Playlists are expanded into individual tracks; excess tracks are + truncated at the queue limit +- Volume changes ramp smoothly over ~200ms (no abrupt jumps) +- Default volume: 50% +- Titles resolved via `yt-dlp --flat-playlist` before playback - Audio pipeline: `yt-dlp | ffmpeg` subprocess, PCM fed to pymumble - Commands are Mumble-only; `!play` on other adapters replies with an error, other music commands silently no-op diff --git a/src/derp/mumble.py b/src/derp/mumble.py index fefdfc8..fe686a1 100644 --- a/src/derp/mumble.py +++ b/src/derp/mumble.py @@ -66,6 +66,29 @@ def _scale_pcm(data: bytes, volume: float) -> bytes: return samples.tobytes() +def _scale_pcm_ramp(data: bytes, vol_start: float, vol_end: float) -> bytes: + """Scale s16le PCM with linear volume interpolation across the frame. + + Each sample is scaled by a linearly interpolated volume between + ``vol_start`` and ``vol_end``. Degenerates to flat scaling when + both values are equal. + """ + samples = array.array("h") + samples.frombytes(data) + n = len(samples) + if n == 0: + return data + for i in range(n): + vol = vol_start + (vol_end - vol_start) * (i / n) + val = int(samples[i] * vol) + if val > 32767: + val = 32767 + elif val < -32768: + val = -32768 + samples[i] = val + return samples.tobytes() + + # -- MumbleMessage ----------------------------------------------------------- @@ -452,6 +475,9 @@ class MumbleBot: stderr=asyncio.subprocess.PIPE, ) + _max_step = 0.1 # max volume change per frame (~200ms full ramp) + _cur_vol = _get_vol() + frames = 0 try: while True: @@ -461,9 +487,22 @@ class MumbleBot: if len(pcm) < _FRAME_BYTES: pcm += b"\x00" * (_FRAME_BYTES - len(pcm)) - vol = _get_vol() - if vol != 1.0: - pcm = _scale_pcm(pcm, vol) + target = _get_vol() + if _cur_vol == target: + # Fast path: flat scaling + if target != 1.0: + pcm = _scale_pcm(pcm, target) + else: + # Ramp toward target, clamped to _max_step per frame + diff = target - _cur_vol + if abs(diff) <= _max_step: + next_vol = target + elif diff > 0: + next_vol = _cur_vol + _max_step + else: + next_vol = _cur_vol - _max_step + pcm = _scale_pcm_ramp(pcm, _cur_vol, next_vol) + _cur_vol = next_vol self._mumble.sound_output.add_sound(pcm) frames += 1 diff --git a/tests/test_mumble.py b/tests/test_mumble.py index aa8f70a..3cdb569 100644 --- a/tests/test_mumble.py +++ b/tests/test_mumble.py @@ -2,13 +2,14 @@ import asyncio import struct -from unittest.mock import patch, MagicMock +from unittest.mock import patch from derp.mumble import ( MumbleBot, MumbleMessage, _escape_html, _scale_pcm, + _scale_pcm_ramp, _shell_quote, _strip_html, ) @@ -583,3 +584,64 @@ class TestShellQuote: quoted = _shell_quote(url) assert quoted.startswith("'") assert quoted.endswith("'") + + +# --------------------------------------------------------------------------- +# TestPcmRamping +# --------------------------------------------------------------------------- + + +class TestPcmRamping: + def test_flat_when_equal(self): + """When vol_start == vol_end, behaves like _scale_pcm.""" + pcm = struct.pack("