From 3dd30804dcb7e5aa81a4050ab2daa3c72fe782a0 Mon Sep 17 00:00:00 2001 From: Marcin Macierzanka Date: Fri, 11 Sep 2026 01:46:16 +0200 Subject: [PATCH] feat: implement volume boost functionality with signal-based multiplier and audio processing override --- frontend/src/api/client.ts | 2 + frontend/src/components/PlayerPanel.vue | 94 +++++++++++++++++++ frontend/src/types/status.ts | 2 + tests/test_api_surface.py | 1 + tests/test_music_player.py | 32 +++++++ tests/test_ytdl_source.py | 70 ++++++++++++++ zyrarbot/audio/ytdl_source.py | 28 +++++- zyrarbot/music/guild_music_state.py | 8 ++ zyrarbot/music/music_player.py | 23 ++++- .../ui/api/requests/volume_boost_request.py | 7 ++ zyrarbot/ui/api/routers/playback_router.py | 16 ++++ zyrarbot/ui/api/status_service.py | 5 + zyrarbot/ui/bot/cogs/control_cog.py | 14 +++ 13 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 tests/test_ytdl_source.py create mode 100644 zyrarbot/ui/api/requests/volume_boost_request.py 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