Source code for sonolink.models.filters

"""
MIT License

Copyright (c) 2026-present SonoLink Development Team

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""

from __future__ import annotations

import copy
from typing import TYPE_CHECKING, Any, Self

import msgspec

from ..rest.schemas import filters
from .base import BaseFilter, BaseModel

if TYPE_CHECKING:
    from ..gateway.client import Client

type FilterModelTypes = (
    Equalizer
    | Karaoke
    | Timescale
    | Tremolo
    | Vibrato
    | Rotation
    | Distortion
    | ChannelMix
    | LowPass
)

_SINGLE_FILTER_ATTRS: tuple[str, ...] = (
    "karaoke",
    "timescale",
    "tremolo",
    "vibrato",
    "rotation",
    "distortion",
    "channel_mix",
    "low_pass",
)


__all__ = (
    "Equalizer",
    "Karaoke",
    "Timescale",
    "Tremolo",
    "Vibrato",
    "Rotation",
    "Distortion",
    "ChannelMix",
    "LowPass",
    "Filters",
)


[docs] class Equalizer(BaseFilter[filters.EqualizerFilter]): """ Represents a single Lavalink equalizer band. Parameters ---------- band: :class:`int` The target band index (0 to 14). gain: :class:`float` The band gain multiplier (-0.25 to 1.0). """ __slots__ = () _schema_cls = filters.EqualizerFilter def __init__( self, *, band: int, gain: float, ) -> None: super().__init__( band=band, gain=gain, ) @property def band(self) -> int: """The target band index (0 to 14).""" return self._data.band @band.setter def band(self, value: int) -> None: self._data.band = value @property def gain(self) -> float: """The band gain multiplier (-0.25 to 1.0).""" return self._data.gain @gain.setter def gain(self, value: float) -> None: self._data.gain = value
[docs] class Karaoke(BaseFilter[filters.KaraokeFilter]): """ Filter that reduces vocal levels in a track, useful for karaoke. Parameters ---------- level: :class:`float` | :data:`None` Overall effect intensity (0.0 to 1.0). mono_level: :class:`float` | :data:`None` Mono signal amount (0.0 to 1.0). filter_band: :class:`float` | :data:`None` Center frequency in Hz for the target region. filter_width: :class:`float` | :data:`None` Bandwidth around the filter band in Hz. """ __slots__ = () _schema_cls = filters.KaraokeFilter def __init__( self, *, level: float | None = None, mono_level: float | None = None, filter_band: float | None = None, filter_width: float | None = None, ) -> None: super().__init__( level=level, mono_level=mono_level, filter_band=filter_band, filter_width=filter_width, ) @property def level(self) -> float | None: """Overall effect intensity (0.0 to 1.0).""" return self._get(self._data.level) @level.setter def level(self, value: float | None) -> None: self._data.level = self._set(value) @property def mono_level(self) -> float | None: """Mono signal amount (0.0 to 1.0).""" return self._get(self._data.mono_level) @mono_level.setter def mono_level(self, value: float | None) -> None: self._data.mono_level = self._set(value) @property def filter_band(self) -> float | None: """Center frequency in Hz for the target region.""" return self._get(self._data.filter_band) @filter_band.setter def filter_band(self, value: float | None) -> None: self._data.filter_band = self._set(value) @property def filter_width(self) -> float | None: """Bandwidth around the filter band in Hz.""" return self._get(self._data.filter_width) @filter_width.setter def filter_width(self, value: float | None) -> None: self._data.filter_width = self._set(value)
[docs] class Timescale(BaseFilter[filters.TimescaleFilter]): """ Adjusts the speed, pitch, and rate of audio playback. Parameters ---------- speed: :class:`float` | :data:`None` Playback speed multiplier (``0.0 <= x``). pitch: :class:`float` | :data:`None` Pitch multiplier (``0.0 <= x``). rate: :class:`float` | :data:`None` Internal rate multiplier (``0.0 <= x``). """ __slots__ = () _schema_cls = filters.TimescaleFilter def __init__( self, *, speed: float | None = None, pitch: float | None = None, rate: float | None = None, ) -> None: super().__init__( speed=speed, pitch=pitch, rate=rate, ) @property def speed(self) -> float | None: """Playback speed multiplier (>= 0.0).""" return self._get(self._data.speed) @speed.setter def speed(self, value: float | None) -> None: self._data.speed = self._set(value) @property def pitch(self) -> float | None: """Pitch multiplier (>= 0.0).""" return self._get(self._data.pitch) @pitch.setter def pitch(self, value: float | None) -> None: self._data.pitch = self._set(value) @property def rate(self) -> float | None: """Internal rate multiplier (>= 0.0).""" return self._get(self._data.rate) @rate.setter def rate(self, value: float | None) -> None: self._data.rate = self._set(value)
[docs] class Tremolo(BaseFilter[filters.TremoloFilter]): """ Rapidly oscillates the volume of the audio. Parameters ---------- frequency: :class:`float` | :data:`None` Oscillation frequency in Hz (``0.0 < x``). depth: :class:`float` | :data:`None` Effect depth (``0.0 < x <= 1.0``). """ __slots__ = () _schema_cls = filters.TremoloFilter def __init__( self, *, frequency: float | None = None, depth: float | None = None, ) -> None: super().__init__( frequency=frequency, depth=depth, ) @property def frequency(self) -> float | None: """Oscillation frequency in Hz (> 0.0).""" return self._get(self._data.frequency) @frequency.setter def frequency(self, value: float | None) -> None: self._data.frequency = self._set(value) @property def depth(self) -> float | None: """Effect depth (0.0 < x <= 1.0).""" return self._get(self._data.depth) @depth.setter def depth(self, value: float | None) -> None: self._data.depth = self._set(value)
[docs] class Vibrato(BaseFilter[filters.VibratoFilter]): """ Rapidly oscillates the pitch of the audio. Parameters ---------- frequency: :class:`float` | :data:`None` Oscillation frequency in Hz (``0.0 < x <= 14.0``). depth: :class:`float` | :data:`None` Effect depth (``0.0 < x <= 1.0``). """ __slots__ = () _schema_cls = filters.VibratoFilter def __init__( self, *, frequency: float | None = None, depth: float | None = None, ) -> None: super().__init__( frequency=frequency, depth=depth, ) @property def frequency(self) -> float | None: """Oscillation frequency in Hz (0.0 < x <= 14.0).""" return self._get(self._data.frequency) @frequency.setter def frequency(self, value: float | None) -> None: self._data.frequency = self._set(value) @property def depth(self) -> float | None: """Effect depth (0.0 < x <= 1.0).""" return self._get(self._data.depth) @depth.setter def depth(self, value: float | None) -> None: self._data.depth = self._set(value)
[docs] class Rotation(BaseFilter[filters.RotationFilter]): """ Rotates the audio across the stereo channels (panning effect). Parameters ---------- rotation_hz: :class:`float` | :data:`None` Rotation frequency in Hz. """ __slots__ = () _schema_cls = filters.RotationFilter def __init__( self, *, rotation_hz: float | None = None, ) -> None: super().__init__(rotation_hz=rotation_hz) @property def rotation_hz(self) -> float | None: """Rotation frequency in Hz.""" return self._get(self._data.rotation_hz) @rotation_hz.setter def rotation_hz(self, value: float | None) -> None: self._data.rotation_hz = self._set(value)
[docs] class Distortion(BaseFilter[filters.DistortionFilter]): """ Applies distortion effects using sine, cosine, and tangent transforms. Parameters ---------- sin_offset: :class:`float` | :data:`None` The sine input offset component. sin_scale: :class:`float` | :data:`None` The sine scaling component. cos_offset: :class:`float` | :data:`None` The cosine input offset component. cos_scale: :class:`float` | :data:`None` The cosine scaling component. tan_offset: :class:`float` | :data:`None` The tangent input offset component. tan_scale: :class:`float` | :data:`None` The tangent scaling component. offset: :class:`float` | :data:`None` The input offset component. scale: :class:`float` | :data:`None` The scaling component. """ __slots__ = () _schema_cls = filters.DistortionFilter def __init__( self, *, sin_offset: float | None = None, sin_scale: float | None = None, cos_offset: float | None = None, cos_scale: float | None = None, tan_offset: float | None = None, tan_scale: float | None = None, offset: float | None = None, scale: float | None = None, ) -> None: super().__init__( sin_offset=sin_offset, sin_scale=sin_scale, cos_offset=cos_offset, cos_scale=cos_scale, tan_offset=tan_offset, tan_scale=tan_scale, offset=offset, scale=scale, ) @property def sin_offset(self) -> float | None: """The sine input offset component.""" return self._get(self._data.sin_offset) @sin_offset.setter def sin_offset(self, value: float | None) -> None: self._data.sin_offset = self._set(value) @property def sin_scale(self) -> float | None: """The sine scaling component.""" return self._get(self._data.sin_scale) @sin_scale.setter def sin_scale(self, value: float | None) -> None: self._data.sin_scale = self._set(value) @property def cos_offset(self) -> float | None: """The cosine input offset component.""" return self._get(self._data.cos_offset) @cos_offset.setter def cos_offset(self, value: float | None) -> None: self._data.cos_offset = self._set(value) @property def cos_scale(self) -> float | None: """The cosine scaling component.""" return self._get(self._data.cos_scale) @cos_scale.setter def cos_scale(self, value: float | None) -> None: self._data.cos_scale = self._set(value) @property def tan_offset(self) -> float | None: """The tangent input offset component.""" return self._get(self._data.tan_offset) @tan_offset.setter def tan_offset(self, value: float | None) -> None: self._data.tan_offset = self._set(value) @property def tan_scale(self) -> float | None: """The tangent scaling component.""" return self._get(self._data.tan_scale) @tan_scale.setter def tan_scale(self, value: float | None) -> None: self._data.tan_scale = self._set(value) @property def offset(self) -> float | None: """The input offset component.""" return self._get(self._data.offset) @offset.setter def offset(self, value: float | None) -> None: self._data.offset = self._set(value) @property def scale(self) -> float | None: """The scaling component.""" return self._get(self._data.scale) @scale.setter def scale(self, value: float | None) -> None: self._data.scale = self._set(value)
[docs] class ChannelMix(BaseFilter[filters.ChannelMixFilter]): """ Mixes left and right audio channels to manipulate stereo separation. Parameters ---------- left_to_left: :class:`float` | :data:`None` The left to left channel mix factor (``0.0 <= x <= 1.0``). left_to_right: :class:`float` | :data:`None` The left to right channel mix factor (``0.0 <= x <= 1.0``). right_to_left: :class:`float` | :data:`None` The right to left channel mix factor (``0.0 <= x <= 1.0``). right_to_right: :class:`float` | :data:`None` The right to right channel mix factor (``0.0 <= x <= 1.0``). """ __slots__ = () _schema_cls = filters.ChannelMixFilter def __init__( self, *, left_to_left: float | None = None, left_to_right: float | None = None, right_to_left: float | None = None, right_to_right: float | None = None, ) -> None: super().__init__( left_to_left=left_to_left, left_to_right=left_to_right, right_to_left=right_to_left, right_to_right=right_to_right, ) @property def left_to_left(self) -> float | None: """The contribution of the left input channel to the left output channel.""" return self._get(self._data.left_to_left) @left_to_left.setter def left_to_left(self, value: float | None) -> None: self._data.left_to_left = self._set(value) @property def left_to_right(self) -> float | None: """The contribution of the left input channel to the right output channel.""" return self._get(self._data.left_to_right) @left_to_right.setter def left_to_right(self, value: float | None) -> None: self._data.left_to_right = self._set(value) @property def right_to_left(self) -> float | None: """The contribution of the right input channel to the left output channel.""" return self._get(self._data.right_to_left) @right_to_left.setter def right_to_left(self, value: float | None) -> None: self._data.right_to_left = self._set(value) @property def right_to_right(self) -> float | None: """The contribution of the right input channel to the right output channel.""" return self._get(self._data.right_to_right) @right_to_right.setter def right_to_right(self, value: float | None) -> None: self._data.right_to_right = self._set(value)
[docs] class LowPass(BaseFilter[filters.LowPassFilter]): """ Suppresses higher frequencies in the audio signal. Parameters ---------- smoothing: :class:`float` | :data:`None` The smoothing factor (``x > 1.0``). """ __slots__ = () _schema_cls = filters.LowPassFilter def __init__( self, *, smoothing: float | None = None, ) -> None: super().__init__(smoothing=smoothing) @property def smoothing(self) -> float | None: """Smoothing factor (x > 1.0).""" return self._get(self._data.smoothing) @smoothing.setter def smoothing(self, value: float | None) -> None: self._data.smoothing = self._set(value)
[docs] class Filters(BaseModel[filters.PlayerFilters]): """ The main container for all active player filters. This class provides a Pythonic interface to the underlying Lavalink filter state. Active filters are sent to Lavalink via the :meth:`payload` property. Parameters ---------- volume: float The linear volume multiplier. Defaults to 1.0. equalizer: list[Equalizer] | None A list of active equalizer bands. karaoke: :class:`Karaoke` | :data:`None` The active karaoke filter settings. timescale: :class:`Timescale` | :data:`None` The active timescale (speed/pitch) filter settings. tremolo: :class:`Tremolo` | :data:`None` The active tremolo (volume oscillation) filter settings. vibrato: :class:`Vibrato` | :data:`None` The active vibrato (pitch oscillation) filter settings. rotation: :class:`Rotation` | :data:`None` The active rotation (panning) filter settings. distortion: :class:`Distortion` | :data:`None` The active distortion filter settings. channel_mix: :class:`ChannelMix` | :data:`None` The active channel mix filter settings. low_pass: :class:`LowPass` | :data:`None` The active low pass filter settings. plugin_filters: :class:`dict` | :data:`None` A dictionary of raw plugin-defined filter payloads. """ __slots__ = ( "equalizer", "karaoke", "timescale", "tremolo", "vibrato", "rotation", "distortion", "channel_mix", "low_pass", "volume", "plugin_filters", ) def __init__( self, *, equalizer: list[Equalizer] | None = None, karaoke: Karaoke | None = None, timescale: Timescale | None = None, tremolo: Tremolo | None = None, vibrato: Vibrato | None = None, rotation: Rotation | None = None, distortion: Distortion | None = None, channel_mix: ChannelMix | None = None, low_pass: LowPass | None = None, volume: float = 1.0, plugin_filters: dict[str, Any] | None = None, ) -> None: self.equalizer: list[Equalizer] = equalizer or [] self.karaoke: Karaoke | None = karaoke self.timescale: Timescale | None = timescale self.tremolo: Tremolo | None = tremolo self.vibrato: Vibrato | None = vibrato self.rotation: Rotation | None = rotation self.distortion: Distortion | None = distortion self.channel_mix: ChannelMix | None = channel_mix self.low_pass: LowPass | None = low_pass self.volume: float = volume self.plugin_filters: dict[str, Any] = plugin_filters or {} @classmethod def _from_data(cls, client: Client[Any], data: filters.PlayerFilters) -> Self: eq_raw = data.equalizer if data.equalizer is not msgspec.UNSET else [] instance = cls( equalizer=[Equalizer._from_data(client, e) for e in eq_raw], karaoke=cls._wrap(Karaoke, client, data.karaoke), timescale=cls._wrap(Timescale, client, data.timescale), tremolo=cls._wrap(Tremolo, client, data.tremolo), vibrato=cls._wrap(Vibrato, client, data.vibrato), rotation=cls._wrap(Rotation, client, data.rotation), distortion=cls._wrap(Distortion, client, data.distortion), channel_mix=cls._wrap(ChannelMix, client, data.channel_mix), low_pass=cls._wrap(LowPass, client, data.low_pass), volume=data.volume if data.volume is not msgspec.UNSET else 1.0, plugin_filters=data.plugin_filters if data.plugin_filters is not msgspec.UNSET else None, ) instance._client = client return instance @classmethod def _wrap[C: FilterModelTypes]( cls, model: type[C], client: Client[Any], data: Any ) -> C | None: return ( model._from_data(client=client, data=data) if data is not msgspec.UNSET else None ) def __or__(self, other: object) -> Self: if not isinstance(other, type(self)): return NotImplemented return self.combine(other) def __ior__(self, other: object) -> Self: if not isinstance(other, type(self)): return NotImplemented return self.merge(other) @property def payload(self) -> filters.PlayerFilters: """Returns the underlying msgspec schema for API requests.""" return filters.PlayerFilters( volume=self.volume, equalizer=[e._data for e in self.equalizer] or msgspec.UNSET, karaoke=self.karaoke._data if self.karaoke else msgspec.UNSET, timescale=self.timescale._data if self.timescale else msgspec.UNSET, tremolo=self.tremolo._data if self.tremolo else msgspec.UNSET, vibrato=self.vibrato._data if self.vibrato else msgspec.UNSET, rotation=self.rotation._data if self.rotation else msgspec.UNSET, distortion=self.distortion._data if self.distortion else msgspec.UNSET, channel_mix=self.channel_mix._data if self.channel_mix else msgspec.UNSET, low_pass=self.low_pass._data if self.low_pass else msgspec.UNSET, plugin_filters=self.plugin_filters or msgspec.UNSET, )
[docs] def merge(self, other: Filters) -> Self: """ Merge another filter set into this instance in place. This mutates ``self``. Use :meth:`combine` when you need a new merged :class:`Filters` instance without changing either input. Parameters ---------- other: :class:`Filters` The other filter to merge with. Returns ------- :class:`Filters` This filter instance after merging. """ if not isinstance(other, type(self)): raise TypeError( f"Can only merge filters of the same type, got {type(other).__name__}" ) for attr in _SINGLE_FILTER_ATTRS: self_filter = getattr(self, attr) other_filter = getattr(other, attr) if self_filter and other_filter: self_filter.merge(other_filter) elif other_filter: setattr(self, attr, other_filter) if other.equalizer: if self.equalizer: self.equalizer.extend(other.equalizer) else: self.equalizer = other.equalizer self.equalizer.sort(key=lambda f: f.band) if other.plugin_filters: if self.plugin_filters: self.plugin_filters.update(other.plugin_filters) else: self.plugin_filters = other.plugin_filters if other.volume != 1.0 and other.volume != self.volume: self.volume = other.volume return self
[docs] def combine(self, other: Filters) -> Self: """ Return a new filter set with ``other`` merged into a copy of this one. This does not mutate either input. Use :meth:`merge` when you want in-place mutation of the current instance. Parameters ---------- other: :class:`Filters` The other filter to combine with. Returns ------- :class:`Filters` This filter instance after combining. """ if not isinstance(other, type(self)): raise TypeError( f"Can only combine filters of the same type, got {type(other).__name__}" ) return copy.deepcopy(self).merge(other)