diff --git a/frontend/src/components/PlayerPanel.vue b/frontend/src/components/PlayerPanel.vue index 17d3922..e78f249 100644 --- a/frontend/src/components/PlayerPanel.vue +++ b/frontend/src/components/PlayerPanel.vue @@ -2,10 +2,12 @@ import { computed, ref, toRef, watch } from 'vue' import { Activity, + Bot, ExternalLink, Music4, Pause, Play, + Radio, Repeat, Repeat1, SkipBack, @@ -98,6 +100,8 @@ const EFFECT_ICONS: Record = { sparkles: Sparkles, waves: Waves, activity: Activity, + bot: Bot, + radio: Radio, } function effectIcon(iconName: string) { diff --git a/tests/test_effects.py b/tests/test_effects.py index d0205fd..da89bcd 100644 --- a/tests/test_effects.py +++ b/tests/test_effects.py @@ -8,6 +8,8 @@ from zyrarbot.audio.effects import ( DistortionEffect, FourthDensityEffect, MuffledEffect, + RobotEffect, + ToasterEffect, TremoloEffect, create_default_effects, ) @@ -47,6 +49,23 @@ class TestAudioEffects(unittest.TestCase): out = fx.process(pcm) self.assertEqual(len(out), len(pcm)) + def test_robot_modulates_ring(self): + fx = RobotEffect(freq_hz=65.0, mix=0.85) + pcm = (struct.pack(" list[AudioEffect]: return [ MuffledEffect(), FourthDensityEffect(), + RobotEffect(), + ToasterEffect(), DistortionEffect(), TremoloEffect(), ] diff --git a/zyrarbot/audio/effects/robot.py b/zyrarbot/audio/effects/robot.py new file mode 100644 index 0000000..328dd57 --- /dev/null +++ b/zyrarbot/audio/effects/robot.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import array +import math + +from zyrarbot.audio.effects.base import AudioEffect + + +class RobotEffect(AudioEffect): + """Efekt metalicznego, robotycznego głosu oparty na ring modulation (modulacja pierścieniowa ~65Hz).""" + + id = "robot" + name = "Robot" + description = "Metaliczny, robotyczny głos cyborga (ring modulation ~65Hz)" + badge = "CYBORG" + icon = "bot" + + def __init__(self, freq_hz: float = 65.0, mix: float = 0.85) -> None: + self.freq_hz = freq_hz + self.mix = mix + self._phase = 0.0 + self._phase_inc = 2.0 * math.pi * freq_hz / 48000.0 + + def process(self, pcm_data: bytes, sample_rate: int = 48000) -> bytes: + if not pcm_data: + return pcm_data + + samples = array.array("h", pcm_data) + phase = self._phase + phase_inc = self._phase_inc + mix = self.mix + dry = 1.0 - mix + + for i in range(0, len(samples), 2): + carrier = math.sin(phase) + phase += phase_inc + if phase >= 2.0 * math.pi: + phase -= 2.0 * math.pi + + mod_factor = dry + mix * carrier + samples[i] = max(-32768, min(32767, int(samples[i] * mod_factor))) + samples[i + 1] = max(-32768, min(32767, int(samples[i + 1] * mod_factor))) + + self._phase = phase + return samples.tobytes() + + def reset(self) -> None: + self._phase = 0.0 diff --git a/zyrarbot/audio/effects/toaster.py b/zyrarbot/audio/effects/toaster.py new file mode 100644 index 0000000..1317bf5 --- /dev/null +++ b/zyrarbot/audio/effects/toaster.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import array + +from zyrarbot.audio.effects.base import AudioEffect + + +class ToasterEffect(AudioEffect): + """Efekt 'dźwięku z tostera / mikrofalówki' – memiczny lo-fi bitcrusher (5-bit quant + downsampling).""" + + id = "toaster" + name = "Dźwięk z tostera" + description = "Memiczny dźwięk z mikrofalówki / kalkulatora (bitcrusher 5-bit, aliasing)" + badge = "8-BIT" + icon = "radio" + + def __init__(self, bit_depth: int = 5, downsample: int = 6) -> None: + self.bit_depth = bit_depth + self.downsample = downsample + self.shift = 16 - bit_depth + self._counter = 0 + self._hold_l = 0 + self._hold_r = 0 + + def process(self, pcm_data: bytes, sample_rate: int = 48000) -> bytes: + if not pcm_data: + return pcm_data + + samples = array.array("h", pcm_data) + shift = self.shift + down = self.downsample + hold_l = self._hold_l + hold_r = self._hold_r + cnt = self._counter + + for i in range(0, len(samples), 2): + if cnt == 0: + # Obniżenie rozdzielczości bitowej (bitcrush / kwantyzacja do 5 bitów) + sl = samples[i] + sr = samples[i + 1] + hold_l = (sl >> shift) << shift + hold_r = (sr >> shift) << shift + + samples[i] = hold_l + samples[i + 1] = hold_r + cnt = (cnt + 1) % down + + self._counter = cnt + self._hold_l = hold_l + self._hold_r = hold_r + return samples.tobytes() + + def reset(self) -> None: + self._counter = 0 + self._hold_l = 0 + self._hold_r = 0 diff --git a/zyrarbot/ui/bot/cogs/control_cog.py b/zyrarbot/ui/bot/cogs/control_cog.py index df07834..f55c2f2 100644 --- a/zyrarbot/ui/bot/cogs/control_cog.py +++ b/zyrarbot/ui/bot/cogs/control_cog.py @@ -117,6 +117,8 @@ class ControlCog(MusicCogBase, name="Control"): nazwa=[ app_commands.Choice(name="Przester (1000%)", value="distortion"), app_commands.Choice(name="Czwarta gęstość (~1s pitch)", value="fourth_density"), + app_commands.Choice(name="Robot (cyborg voice)", value="robot"), + app_commands.Choice(name="Dźwięk z tostera (8-bit lo-fi)", value="toaster"), app_commands.Choice(name="Pod wodą (lowpass)", value="muffled"), app_commands.Choice(name="Tremolo (pulsowanie)", value="tremolo"), ]