feat: smooth volume ramping over 200ms in audio streaming
Some checks failed
CI / test (3.11) (push) Failing after 22s
CI / test (3.12) (push) Failing after 22s
CI / test (3.13) (push) Failing after 22s

Volume changes now ramp linearly per-sample via _scale_pcm_ramp instead
of jumping abruptly. Each frame steps _cur_vol toward target by at most
0.1, giving ~200ms for a full 0-to-1 sweep. Fast path unchanged when
volume is stable.
This commit is contained in:
user
2026-02-21 23:32:22 +01:00
parent c5c61e63cc
commit 6b7d733650
4 changed files with 115 additions and 9 deletions

View File

@@ -546,7 +546,8 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops.
## Music (Mumble only) ## Music (Mumble only)
``` ```
!play <url> # Play audio (YouTube, SoundCloud, etc.) !play <url|playlist> # Play audio (YouTube, SoundCloud, etc.)
!play <playlist-url> # Playlist tracks expanded into queue
!stop # Stop playback, clear queue !stop # Stop playback, clear queue
!skip # Skip current track !skip # Skip current track
!queue # Show queue !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. 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. Mumble-only: `!play` replies with error on other adapters, others silently no-op.
## Plugin Template ## Plugin Template

View File

@@ -1565,7 +1565,7 @@ and voice transmission.
- `libopus` -- Opus codec (used by pymumble/opuslib) - `libopus` -- Opus codec (used by pymumble/opuslib)
``` ```
!play <url> Play audio or add to queue !play <url|playlist> Play audio or add to queue (playlists expanded)
!stop Stop playback, clear queue !stop Stop playback, clear queue
!skip Skip current track !skip Skip current track
!queue Show queue !queue Show queue
@@ -1576,8 +1576,11 @@ and voice transmission.
``` ```
- Queue holds up to 50 tracks - Queue holds up to 50 tracks
- Volume takes effect immediately during playback (default: 50%) - Playlists are expanded into individual tracks; excess tracks are
- Title resolved via `yt-dlp --get-title` before playback 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 - Audio pipeline: `yt-dlp | ffmpeg` subprocess, PCM fed to pymumble
- Commands are Mumble-only; `!play` on other adapters replies with an error, - Commands are Mumble-only; `!play` on other adapters replies with an error,
other music commands silently no-op other music commands silently no-op

View File

@@ -66,6 +66,29 @@ def _scale_pcm(data: bytes, volume: float) -> bytes:
return samples.tobytes() 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 ----------------------------------------------------------- # -- MumbleMessage -----------------------------------------------------------
@@ -452,6 +475,9 @@ class MumbleBot:
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
_max_step = 0.1 # max volume change per frame (~200ms full ramp)
_cur_vol = _get_vol()
frames = 0 frames = 0
try: try:
while True: while True:
@@ -461,9 +487,22 @@ 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))
vol = _get_vol() target = _get_vol()
if vol != 1.0: if _cur_vol == target:
pcm = _scale_pcm(pcm, vol) # 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) self._mumble.sound_output.add_sound(pcm)
frames += 1 frames += 1

View File

@@ -2,13 +2,14 @@
import asyncio import asyncio
import struct import struct
from unittest.mock import patch, MagicMock from unittest.mock import patch
from derp.mumble import ( from derp.mumble import (
MumbleBot, MumbleBot,
MumbleMessage, MumbleMessage,
_escape_html, _escape_html,
_scale_pcm, _scale_pcm,
_scale_pcm_ramp,
_shell_quote, _shell_quote,
_strip_html, _strip_html,
) )
@@ -583,3 +584,64 @@ class TestShellQuote:
quoted = _shell_quote(url) quoted = _shell_quote(url)
assert quoted.startswith("'") assert quoted.startswith("'")
assert quoted.endswith("'") assert quoted.endswith("'")
# ---------------------------------------------------------------------------
# TestPcmRamping
# ---------------------------------------------------------------------------
class TestPcmRamping:
def test_flat_when_equal(self):
"""When vol_start == vol_end, behaves like _scale_pcm."""
pcm = struct.pack("<hh", 1000, -1000)
result = _scale_pcm_ramp(pcm, 0.5, 0.5)
expected = _scale_pcm(pcm, 0.5)
assert result == expected
def test_linear_interpolation(self):
"""Volume ramps linearly from start to end across samples."""
pcm = struct.pack("<hhhh", 10000, 10000, 10000, 10000)
result = _scale_pcm_ramp(pcm, 0.0, 1.0)
samples = struct.unpack("<hhhh", result)
# At i=0: vol=0.0, i=1: vol=0.25, i=2: vol=0.5, i=3: vol=0.75
assert samples[0] == 0
assert samples[1] == 2500
assert samples[2] == 5000
assert samples[3] == 7500
def test_clamp_positive(self):
"""Ramping up with loud samples clamps to 32767."""
pcm = struct.pack("<h", 32767)
result = _scale_pcm_ramp(pcm, 2.0, 2.0)
samples = struct.unpack("<h", result)
assert samples[0] == 32767
def test_clamp_negative(self):
"""Ramping up with negative samples clamps to -32768."""
pcm = struct.pack("<h", -32768)
result = _scale_pcm_ramp(pcm, 2.0, 2.0)
samples = struct.unpack("<h", result)
assert samples[0] == -32768
def test_preserves_length(self):
"""Output length equals input length."""
pcm = b"\x00" * 1920
result = _scale_pcm_ramp(pcm, 0.0, 1.0)
assert len(result) == 1920
def test_empty_data(self):
"""Empty input returns empty output."""
result = _scale_pcm_ramp(b"", 0.0, 1.0)
assert result == b""
def test_reverse_direction(self):
"""Volume ramps down from start to end."""
pcm = struct.pack("<hhhh", 10000, 10000, 10000, 10000)
result = _scale_pcm_ramp(pcm, 1.0, 0.0)
samples = struct.unpack("<hhhh", result)
# At i=0: vol=1.0, i=1: vol=0.75, i=2: vol=0.5, i=3: vol=0.25
assert samples[0] == 10000
assert samples[1] == 7500
assert samples[2] == 5000
assert samples[3] == 2500