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)
|
## 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user