feat: smooth volume ramping over 200ms in audio streaming
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user