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)
```
!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
!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

View File

@@ -1565,7 +1565,7 @@ and voice transmission.
- `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
!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

View File

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

View File

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