diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index b901b44..609727f 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -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 }),
diff --git a/frontend/src/components/PlayerPanel.vue b/frontend/src/components/PlayerPanel.vue
index 5900e94..08db93e 100644
--- a/frontend/src/components/PlayerPanel.vue
+++ b/frontend/src/components/PlayerPanel.vue
@@ -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() {
>
{{ volume }}%
+
+
+
+
@@ -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);
+}
diff --git a/frontend/src/types/status.ts b/frontend/src/types/status.ts
index e46c453..8dcd55a 100644
--- a/frontend/src/types/status.ts
+++ b/frontend/src/types/status.ts
@@ -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
diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py
index 9706868..15ef3ba 100644
--- a/tests/test_api_surface.py
+++ b/tests/test_api_surface.py
@@ -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"),
diff --git a/tests/test_music_player.py b/tests/test_music_player.py
index 8cd0da2..227adb4 100644
--- a/tests/test_music_player.py
+++ b/tests/test_music_player.py
@@ -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(
diff --git a/tests/test_ytdl_source.py b/tests/test_ytdl_source.py
new file mode 100644
index 0000000..1c988c1
--- /dev/null
+++ b/tests/test_ytdl_source.py
@@ -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(" clipped to 32767
+ pcm = struct.pack(" 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
+
diff --git a/zyrarbot/music/guild_music_state.py b/zyrarbot/music/guild_music_state.py
index 2a08d07..2b12773 100644
--- a/zyrarbot/music/guild_music_state.py
+++ b/zyrarbot/music/guild_music_state.py
@@ -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
diff --git a/zyrarbot/music/music_player.py b/zyrarbot/music/music_player.py
index 4f1bbf2..652d1ad 100644
--- a/zyrarbot/music/music_player.py
+++ b/zyrarbot/music/music_player.py
@@ -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:
diff --git a/zyrarbot/ui/api/requests/volume_boost_request.py b/zyrarbot/ui/api/requests/volume_boost_request.py
new file mode 100644
index 0000000..4276160
--- /dev/null
+++ b/zyrarbot/ui/api/requests/volume_boost_request.py
@@ -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
diff --git a/zyrarbot/ui/api/routers/playback_router.py b/zyrarbot/ui/api/routers/playback_router.py
index 318cbc5..e44a4c7 100644
--- a/zyrarbot/ui/api/routers/playback_router.py
+++ b/zyrarbot/ui/api/routers/playback_router.py
@@ -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,
diff --git a/zyrarbot/ui/api/status_service.py b/zyrarbot/ui/api/status_service.py
index f75640b..d6da36f 100644
--- a/zyrarbot/ui/api/status_service.py
+++ b/zyrarbot/ui/api/status_service.py
@@ -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,
diff --git a/zyrarbot/ui/bot/cogs/control_cog.py b/zyrarbot/ui/bot/cogs/control_cog.py
index 6699c22..c16ba03 100644
--- a/zyrarbot/ui/bot/cogs/control_cog.py
+++ b/zyrarbot/ui/bot/cogs/control_cog.py
@@ -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