feat: implement volume boost functionality with signal-based multiplier and audio processing override
This commit is contained in:
13 files changed
+294
-8
No files matched your search
@@ -55,6 +55,8 @@ export const api = {
|
||||
skip: () => post('/api/skip'),
|
||||
stop: () => post('/api/stop'),
|
||||
setVolume: (volume: number) => post('/api/volume', { volume }),
|
||||
setVolumeBoost: (enabled?: boolean) =>
|
||||
post<{ status: string; volume_boost: boolean }>('/api/volume_boost', { enabled }),
|
||||
cycleLoop: () => post<{ mode: 'off' | 'playlist' | 'single' }>('/api/loop_cycle'),
|
||||
|
||||
jump: (index: number) => post('/api/jump', { index }),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Volume1,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Zap,
|
||||
} from 'lucide-vue-next'
|
||||
import { api } from '@/api/client'
|
||||
import { useToasts } from '@/composables/useToasts'
|
||||
@@ -87,6 +88,19 @@ async function commitVolume() {
|
||||
draggedVolume.value = null
|
||||
}
|
||||
|
||||
const volumeBoost = computed(() => props.status?.volume_boost ?? false)
|
||||
const volumeBoostMultiplier = computed(() => props.status?.volume_boost_multiplier ?? 10.0)
|
||||
const volumeBoostPercent = computed(() => Math.round(volumeBoostMultiplier.value * 100))
|
||||
|
||||
async function toggleVolumeBoost() {
|
||||
const next = !volumeBoost.value
|
||||
await run(
|
||||
() => api.setVolumeBoost(next),
|
||||
next ? `Włączono tryb ${volumeBoostPercent.value}% głośności ⚡` : 'Wyłączono podbicie głośności',
|
||||
'Błąd przełączania trybu podbicia głośności',
|
||||
)
|
||||
}
|
||||
|
||||
async function togglePlayback() {
|
||||
if (!current.value) {
|
||||
notify('Nic nie jest obecnie odtwarzane', 'info')
|
||||
@@ -312,6 +326,22 @@ async function cycleLoop() {
|
||||
>
|
||||
<span class="volume-value">{{ volume }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="volume-boost-row">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-boost"
|
||||
:class="{ active: volumeBoost }"
|
||||
:title="volumeBoost ? `Wyłącz tryb ${volumeBoostPercent}% głośności` : `Włącz tryb ${volumeBoostPercent}% głośności`"
|
||||
@click="toggleVolumeBoost"
|
||||
>
|
||||
<Zap :size="14" class="boost-icon" />
|
||||
<span class="boost-label">Tryb {{ volumeBoostPercent }}% głośności</span>
|
||||
<span class="boost-badge" :class="{ active: volumeBoost }">
|
||||
{{ volumeBoost ? 'AKTYWNY' : 'WYŁ' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -326,4 +356,68 @@ async function cycleLoop() {
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.volume-boost-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-boost {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.btn-boost:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-boost.active {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.15) 0%, rgba(239, 68, 68, 0.15) 100%);
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
color: #fbbf24;
|
||||
box-shadow: 0 0 14px rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.btn-boost.active:hover {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.22) 0%, rgba(239, 68, 68, 0.22) 100%);
|
||||
border-color: rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
|
||||
.boost-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-boost.active .boost-icon {
|
||||
transform: scale(1.15);
|
||||
filter: drop-shadow(0 0 6px rgba(245, 158, 11, 0.6));
|
||||
}
|
||||
|
||||
.boost-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.boost-badge.active {
|
||||
background: #f59e0b;
|
||||
color: #0f172a;
|
||||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -56,6 +56,8 @@ export interface BotStatus {
|
||||
loop_single: boolean
|
||||
loop_playlist: boolean
|
||||
volume: number
|
||||
volume_boost?: boolean
|
||||
volume_boost_multiplier?: number
|
||||
current: CurrentTrack | null
|
||||
queue: QueueTrack[]
|
||||
queue_index: number
|
||||
|
||||
@@ -20,6 +20,7 @@ EXPECTED_ENDPOINTS = {
|
||||
("post", "/api/skip"),
|
||||
("post", "/api/stop"),
|
||||
("post", "/api/volume"),
|
||||
("post", "/api/volume_boost"),
|
||||
("post", "/api/loop"),
|
||||
("post", "/api/loop_playlist"),
|
||||
("post", "/api/loop_cycle"),
|
||||
|
||||
@@ -7,6 +7,7 @@ import tempfile
|
||||
import unittest
|
||||
|
||||
from tests.fakes import FakeTrack, build_player
|
||||
from zyrarbot.music.guild_music_state import VOLUME_BOOST_MULTIPLIER
|
||||
from zyrarbot.music.loop_mode import LoopMode
|
||||
from zyrarbot.storage.queue_repository import QueueRepository
|
||||
|
||||
@@ -235,6 +236,37 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertEqual(track.volume, 0.25)
|
||||
|
||||
async def test_volume_boost_multiplies_effective_volume_by_multiplier(self):
|
||||
player, _ = build_player()
|
||||
player.set_volume(GUILD, 0.2)
|
||||
player.set_volume_boost(GUILD, True)
|
||||
|
||||
self.assertEqual(player.state(GUILD).volume, 0.2)
|
||||
self.assertAlmostEqual(
|
||||
player.state(GUILD).effective_volume, 0.2 * VOLUME_BOOST_MULTIPLIER
|
||||
)
|
||||
|
||||
track = FakeTrack("boosted")
|
||||
await player.enqueue(GUILD, track)
|
||||
self.assertAlmostEqual(track.volume, 0.2 * VOLUME_BOOST_MULTIPLIER)
|
||||
|
||||
player.set_volume(GUILD, 1.0)
|
||||
self.assertAlmostEqual(
|
||||
player.state(GUILD).effective_volume, 1.0 * VOLUME_BOOST_MULTIPLIER
|
||||
)
|
||||
|
||||
async def test_toggle_volume_boost(self):
|
||||
player, _ = build_player()
|
||||
self.assertFalse(player.state(GUILD).volume_boost)
|
||||
|
||||
res = player.toggle_volume_boost(GUILD)
|
||||
self.assertTrue(res)
|
||||
self.assertTrue(player.state(GUILD).volume_boost)
|
||||
|
||||
res2 = player.toggle_volume_boost(GUILD)
|
||||
self.assertFalse(res2)
|
||||
self.assertFalse(player.state(GUILD).volume_boost)
|
||||
|
||||
async def test_default_plays_are_applied_per_guild(self):
|
||||
player, _ = build_player()
|
||||
player._config.get_default_plays.side_effect = lambda guild_id: {1: 2, 2: 7}.get(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import discord
|
||||
|
||||
from zyrarbot.audio.ytdl_source import FRAME_LENGTH_SECONDS, YTDLSource
|
||||
|
||||
|
||||
class DummyPCM(discord.AudioSource):
|
||||
def __init__(self, data: bytes):
|
||||
self._data = data
|
||||
self.cleanup_called = False
|
||||
|
||||
def read(self) -> bytes:
|
||||
data = self._data
|
||||
self._data = b""
|
||||
return data
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.cleanup_called = True
|
||||
|
||||
|
||||
class TestYTDLSource(unittest.TestCase):
|
||||
def test_read_advances_elapsed_and_returns_data(self):
|
||||
# 1 sample of 16-bit stereo = 4 bytes
|
||||
pcm = struct.pack("<hh", 1000, 2000)
|
||||
source = DummyPCM(pcm)
|
||||
ytdl = YTDLSource(source, data={"title": "test"}, volume=1.0)
|
||||
|
||||
self.assertEqual(ytdl.elapsed, 0.0)
|
||||
data = ytdl.read()
|
||||
self.assertEqual(data, pcm)
|
||||
self.assertAlmostEqual(ytdl.elapsed, FRAME_LENGTH_SECONDS)
|
||||
|
||||
# Subsequent read returns empty and does not advance elapsed further
|
||||
empty = ytdl.read()
|
||||
self.assertEqual(empty, b"")
|
||||
self.assertAlmostEqual(ytdl.elapsed, FRAME_LENGTH_SECONDS)
|
||||
|
||||
def test_read_with_volume_boost_over_2_does_not_clamp_to_2(self):
|
||||
# Sample value 5000:
|
||||
# At volume 2.0 (discord limit): 5000 * 2 = 10000
|
||||
# At volume 5.0: 5000 * 5 = 25000
|
||||
# At volume 10.0: 5000 * 10 = 50000 -> clipped to 32767
|
||||
pcm = struct.pack("<hh", 5000, -5000)
|
||||
source = DummyPCM(pcm)
|
||||
ytdl = YTDLSource(source, data={"title": "distortion test"}, volume=5.0)
|
||||
|
||||
data = ytdl.read()
|
||||
left, right = struct.unpack("<hh", data)
|
||||
self.assertEqual(left, 25000)
|
||||
self.assertEqual(right, -25000)
|
||||
|
||||
def test_read_distortion_clips_at_pcm_limits(self):
|
||||
# Overdriving large amplitude samples causes clipping / przester at [-32768, 32767]
|
||||
pcm = struct.pack("<hh", 10000, -10000)
|
||||
source = DummyPCM(pcm)
|
||||
ytdl = YTDLSource(source, data={"title": "przester"}, volume=10.0)
|
||||
|
||||
data = ytdl.read()
|
||||
left, right = struct.unpack("<hh", data)
|
||||
self.assertEqual(left, 32767)
|
||||
self.assertEqual(right, -32768)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,15 @@ import discord
|
||||
FRAME_LENGTH_SECONDS = 0.02
|
||||
|
||||
|
||||
try:
|
||||
import audioop
|
||||
except ImportError: # pragma: no cover
|
||||
try:
|
||||
import audioop_lts as audioop
|
||||
except ImportError:
|
||||
audioop = None
|
||||
|
||||
|
||||
class YTDLSource(discord.PCMVolumeTransformer):
|
||||
"""Źródło audio (strumień YouTube lub plik lokalny) z metadanymi utworu."""
|
||||
|
||||
@@ -40,7 +49,20 @@ class YTDLSource(discord.PCMVolumeTransformer):
|
||||
return bool(url and str(url).startswith("http"))
|
||||
|
||||
def read(self) -> bytes:
|
||||
data = super().read()
|
||||
if data:
|
||||
self.elapsed += FRAME_LENGTH_SECONDS
|
||||
# discord.PCMVolumeTransformer clamps volume to min(self._volume, 2.0).
|
||||
# We read self.original directly and use audioop.mul without the 2.0 cap
|
||||
# so that boosted volume overdrive produces authentic PCM clipping / distortion (przester).
|
||||
if audioop is None:
|
||||
data = super().read()
|
||||
if data:
|
||||
self.elapsed += FRAME_LENGTH_SECONDS
|
||||
return data
|
||||
|
||||
data = self.original.read()
|
||||
if not data:
|
||||
return b""
|
||||
self.elapsed += FRAME_LENGTH_SECONDS
|
||||
if self._volume != 1.0:
|
||||
return audioop.mul(data, 2, self._volume)
|
||||
return data
|
||||
|
||||
@@ -7,6 +7,8 @@ import discord
|
||||
from zyrarbot.audio.ytdl_source import YTDLSource
|
||||
from zyrarbot.music.loop_mode import LoopMode
|
||||
|
||||
VOLUME_BOOST_MULTIPLIER: float = 10.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuildMusicState:
|
||||
@@ -21,10 +23,16 @@ class GuildMusicState:
|
||||
current: YTDLSource | None = None
|
||||
index: int = -1
|
||||
volume: float = 1.0
|
||||
volume_boost: bool = False
|
||||
loop_mode: LoopMode = LoopMode.OFF
|
||||
jump_pending: bool = False
|
||||
notification_channel: discord.abc.Messageable | None = None
|
||||
|
||||
@property
|
||||
def effective_volume(self) -> float:
|
||||
"""Zwraca głośność z uwzględnieniem trybu podbicia (mnożnik VOLUME_BOOST_MULTIPLIER)."""
|
||||
return self.volume * VOLUME_BOOST_MULTIPLIER if self.volume_boost else self.volume
|
||||
|
||||
def reset(self) -> None:
|
||||
self.queue.clear()
|
||||
self.current = None
|
||||
|
||||
@@ -207,11 +207,24 @@ class MusicPlayer:
|
||||
"filepath": track.filepath,
|
||||
}
|
||||
|
||||
def set_volume(self, guild_id: int, volume: float) -> None:
|
||||
self.state(guild_id).volume = volume
|
||||
def _apply_volume(self, guild_id: int) -> None:
|
||||
state = self.state(guild_id)
|
||||
voice_client = self.voice_client(guild_id)
|
||||
if voice_client and voice_client.source:
|
||||
voice_client.source.volume = volume
|
||||
voice_client.source.volume = state.effective_volume
|
||||
|
||||
def set_volume(self, guild_id: int, volume: float) -> None:
|
||||
self.state(guild_id).volume = volume
|
||||
self._apply_volume(guild_id)
|
||||
|
||||
def set_volume_boost(self, guild_id: int, boost: bool) -> bool:
|
||||
self.state(guild_id).volume_boost = boost
|
||||
self._apply_volume(guild_id)
|
||||
return boost
|
||||
|
||||
def toggle_volume_boost(self, guild_id: int) -> bool:
|
||||
state = self.state(guild_id)
|
||||
return self.set_volume_boost(guild_id, not state.volume_boost)
|
||||
|
||||
def set_loop_mode(self, guild_id: int, mode: LoopMode) -> None:
|
||||
self.state(guild_id).loop_mode = mode
|
||||
@@ -320,7 +333,7 @@ class MusicPlayer:
|
||||
|
||||
track = await self._tracks.recreate(current, loop=self._bot.loop, start_at=position)
|
||||
track.played = True
|
||||
track.volume = state.volume
|
||||
track.volume = state.effective_volume
|
||||
track.plays_left = self.plays_left(current, guild_id)
|
||||
track.elapsed = position
|
||||
state.queue[state.index] = track
|
||||
@@ -426,7 +439,7 @@ class MusicPlayer:
|
||||
|
||||
track.played = True
|
||||
track.plays_left = self.plays_left(track, guild_id)
|
||||
track.volume = state.volume
|
||||
track.volume = state.effective_volume
|
||||
|
||||
state.queue[next_index] = track
|
||||
if track.plays_left <= 0 and state.loop_mode.loops_track:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class VolumeBoostRequest(BaseModel):
|
||||
"""Włączenie, wyłączenie lub przełączenie trybu 500% głośności."""
|
||||
|
||||
enabled: bool | None = None
|
||||
@@ -21,6 +21,7 @@ from zyrarbot.ui.api.requests.loop_request import LoopRequest
|
||||
from zyrarbot.ui.api.requests.play_file_request import PlayFileRequest
|
||||
from zyrarbot.ui.api.requests.play_request import PlayRequest
|
||||
from zyrarbot.ui.api.requests.seek_request import SeekRequest
|
||||
from zyrarbot.ui.api.requests.volume_boost_request import VolumeBoostRequest
|
||||
from zyrarbot.ui.api.requests.volume_request import VolumeRequest
|
||||
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
|
||||
|
||||
@@ -173,6 +174,21 @@ async def set_volume(
|
||||
return {"status": "success", "volume": request.volume}
|
||||
|
||||
|
||||
@router.post("/volume_boost")
|
||||
async def set_volume_boost(
|
||||
request: VolumeBoostRequest | None = None,
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, Any]:
|
||||
if request is not None and request.enabled is not None:
|
||||
boost = player.set_volume_boost(guild.id, request.enabled)
|
||||
else:
|
||||
boost = player.toggle_volume_boost(guild.id)
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "success", "volume_boost": boost}
|
||||
|
||||
|
||||
@router.post("/loop")
|
||||
async def set_loop(
|
||||
request: LoopRequest,
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
import discord
|
||||
|
||||
from zyrarbot.audio.ytdl_source import YTDLSource
|
||||
from zyrarbot.music.guild_music_state import VOLUME_BOOST_MULTIPLIER
|
||||
from zyrarbot.music.music_player import MusicPlayer
|
||||
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
|
||||
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
||||
@@ -49,6 +50,8 @@ class StatusService:
|
||||
"loop_single": False,
|
||||
"loop_playlist": False,
|
||||
"volume": 100,
|
||||
"volume_boost": False,
|
||||
"volume_boost_multiplier": VOLUME_BOOST_MULTIPLIER,
|
||||
"current": None,
|
||||
"queue": [],
|
||||
"queue_index": -1,
|
||||
@@ -70,6 +73,8 @@ class StatusService:
|
||||
"loop_single": state.loop_mode.loops_track,
|
||||
"loop_playlist": state.loop_mode.loops_queue,
|
||||
"volume": int(state.volume * 100),
|
||||
"volume_boost": state.volume_boost,
|
||||
"volume_boost_multiplier": VOLUME_BOOST_MULTIPLIER,
|
||||
"current": self._current_payload(state.current, guild.id),
|
||||
"queue": [self._track_payload(track, guild.id) for track in state.queue],
|
||||
"queue_index": state.index,
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from zyrarbot.music.guild_music_state import VOLUME_BOOST_MULTIPLIER
|
||||
from zyrarbot.music.loop_mode import LoopMode
|
||||
from zyrarbot.ui.bot.cogs.common import MusicCogBase
|
||||
from zyrarbot.ui.bot.discord_theme import message_embed
|
||||
@@ -111,6 +112,19 @@ class ControlCog(MusicCogBase, name="Control"):
|
||||
embed=message_embed("Głośność", f"Ustawiono głośność na {volume}%.")
|
||||
)
|
||||
|
||||
@app_commands.command(name="volumeboost", description="Przełącz tryb podbicia głośności")
|
||||
async def volume_boost(self, interaction: discord.Interaction) -> None:
|
||||
boost = self.player.toggle_volume_boost(interaction.guild.id)
|
||||
await self.player.trigger_state_change()
|
||||
boost_percent = int(VOLUME_BOOST_MULTIPLIER * 100)
|
||||
status_text = f"włączony ({boost_percent}%)" if boost else "wyłączony (100%)"
|
||||
await interaction.response.send_message(
|
||||
embed=message_embed(
|
||||
"Podbicie głośności",
|
||||
f"⚡ Tryb podbicia głośności ({boost_percent}%) jest teraz **{status_text}**.",
|
||||
)
|
||||
)
|
||||
|
||||
@app_commands.command(name="nowplaying", description="Pokaż aktualnie odtwarzany utwór")
|
||||
async def now_playing(self, interaction: discord.Interaction) -> None:
|
||||
current = self.player.state(interaction.guild.id).current
|
||||
|
||||
Reference in new issue
Block a user