feat: add Robot (ring modulation) and Dźwięk z tostera (8-bit bitcrusher) player audio effects
This commit is contained in:
7 files changed
+137
No files matched your search
@@ -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<string, any> = {
|
||||
sparkles: Sparkles,
|
||||
waves: Waves,
|
||||
activity: Activity,
|
||||
bot: Bot,
|
||||
radio: Radio,
|
||||
}
|
||||
|
||||
function effectIcon(iconName: string) {
|
||||
|
||||
@@ -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("<hh", 10000, 10000)) * 960
|
||||
out = fx.process(pcm)
|
||||
self.assertEqual(len(out), len(pcm))
|
||||
self.assertNotEqual(out, pcm)
|
||||
|
||||
def test_toaster_bitcrush_pcm(self):
|
||||
fx = ToasterEffect(bit_depth=5, downsample=6)
|
||||
pcm = struct.pack("<hh", 12345, -23456)
|
||||
out = fx.process(pcm)
|
||||
l, r = struct.unpack("<hh", out)
|
||||
# Low 11 bits are zeroed due to 5-bit quantization
|
||||
self.assertEqual(l & 0x07FF, 0)
|
||||
self.assertEqual(r & 0x07FF, 0)
|
||||
|
||||
|
||||
def test_pipeline_chains_multiple_effects_and_applies_volume(self):
|
||||
pipeline = AudioEffectsPipeline(create_default_effects())
|
||||
pcm = (struct.pack("<hh", 1000, -1000)) * 960
|
||||
|
||||
@@ -9,6 +9,8 @@ from zyrarbot.audio.effects.registry import (
|
||||
create_default_effects,
|
||||
get_available_effects_metadata,
|
||||
)
|
||||
from zyrarbot.audio.effects.robot import RobotEffect
|
||||
from zyrarbot.audio.effects.toaster import ToasterEffect
|
||||
from zyrarbot.audio.effects.tremolo import TremoloEffect
|
||||
|
||||
__all__ = [
|
||||
@@ -17,6 +19,8 @@ __all__ = [
|
||||
"DistortionEffect",
|
||||
"FourthDensityEffect",
|
||||
"MuffledEffect",
|
||||
"RobotEffect",
|
||||
"ToasterEffect",
|
||||
"TremoloEffect",
|
||||
"create_default_effects",
|
||||
"get_available_effects_metadata",
|
||||
|
||||
@@ -6,6 +6,8 @@ from zyrarbot.audio.effects.base import AudioEffect
|
||||
from zyrarbot.audio.effects.distortion import DistortionEffect
|
||||
from zyrarbot.audio.effects.fourth_density import FourthDensityEffect
|
||||
from zyrarbot.audio.effects.muffled import MuffledEffect
|
||||
from zyrarbot.audio.effects.robot import RobotEffect
|
||||
from zyrarbot.audio.effects.toaster import ToasterEffect
|
||||
from zyrarbot.audio.effects.tremolo import TremoloEffect
|
||||
|
||||
|
||||
@@ -14,6 +16,8 @@ def create_default_effects() -> list[AudioEffect]:
|
||||
return [
|
||||
MuffledEffect(),
|
||||
FourthDensityEffect(),
|
||||
RobotEffect(),
|
||||
ToasterEffect(),
|
||||
DistortionEffect(),
|
||||
TremoloEffect(),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user