"""
pygame-menu
https://github.com/ppizarror/pygame-menu
SOUND
Sound class.
"""
from __future__ import annotations
__all__ = [
# Main class
"Sound",
# Sound types
"SOUND_INITIALIZED",
"SOUND_TYPE_CLICK_MOUSE",
"SOUND_TYPE_CLICK_TOUCH",
"SOUND_TYPE_CLOSE_MENU",
"SOUND_TYPE_ERROR",
"SOUND_TYPE_EVENT",
"SOUND_TYPE_EVENT_ERROR",
"SOUND_TYPE_KEY_ADDITION",
"SOUND_TYPE_KEY_DELETION",
"SOUND_TYPE_OPEN_MENU",
"SOUND_TYPE_WIDGET_SELECTION",
"SOUND_TYPES",
# Sound example paths
"SOUND_EXAMPLE_CLICK_MOUSE",
"SOUND_EXAMPLE_CLICK_TOUCH",
"SOUND_EXAMPLE_CLOSE_MENU",
"SOUND_EXAMPLE_ERROR",
"SOUND_EXAMPLE_EVENT",
"SOUND_EXAMPLE_EVENT_ERROR",
"SOUND_EXAMPLE_KEY_ADD",
"SOUND_EXAMPLE_KEY_DELETE",
"SOUND_EXAMPLE_OPEN_MENU",
"SOUND_EXAMPLE_WIDGET_SELECTION",
"SOUND_EXAMPLES",
]
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pygame import error as pygame_error, mixer, vernum as pygame_version
from pygame_menu._base import Base
from pygame_menu._types import NumberInstance, NumberType
from pygame_menu.utils import warn
try: # pygame<2.0.0 compatibility
from pygame import AUDIO_ALLOW_CHANNELS_CHANGE, AUDIO_ALLOW_FREQUENCY_CHANGE
except ImportError:
AUDIO_ALLOW_CHANNELS_CHANGE, AUDIO_ALLOW_FREQUENCY_CHANGE = False, False
# Sound types
SOUND_TYPE_CLICK_MOUSE = "__pygame_menu_sound_click_mouse__"
SOUND_TYPE_CLICK_TOUCH = "__pygame_menu_sound_click_touch__"
SOUND_TYPE_CLOSE_MENU = "__pygame_menu_sound_close_menu__"
SOUND_TYPE_ERROR = "__pygame_menu_sound_error__"
SOUND_TYPE_EVENT = "__pygame_menu_sound_event__"
SOUND_TYPE_EVENT_ERROR = "__pygame_menu_sound_event_error__"
SOUND_TYPE_KEY_ADDITION = "__pygame_menu_sound_key_addition__"
SOUND_TYPE_KEY_DELETION = "__pygame_menu_sound_key_deletion__"
SOUND_TYPE_OPEN_MENU = "__pygame_menu_sound_open_menu__"
SOUND_TYPE_WIDGET_SELECTION = "__pygame_menu_sound_widget_selection__"
SOUND_TYPES = (
SOUND_TYPE_CLICK_MOUSE,
SOUND_TYPE_CLICK_TOUCH,
SOUND_TYPE_CLOSE_MENU,
SOUND_TYPE_ERROR,
SOUND_TYPE_EVENT,
SOUND_TYPE_EVENT_ERROR,
SOUND_TYPE_KEY_ADDITION,
SOUND_TYPE_KEY_DELETION,
SOUND_TYPE_OPEN_MENU,
SOUND_TYPE_WIDGET_SELECTION,
)
# Sound example paths
__sounds_path__ = (
Path(__file__).resolve().parent / "resources" / "sounds" / "{0}"
).as_posix()
SOUND_EXAMPLE_CLICK_MOUSE = __sounds_path__.format("click_mouse.ogg")
SOUND_EXAMPLE_CLICK_TOUCH = SOUND_EXAMPLE_CLICK_MOUSE
SOUND_EXAMPLE_CLOSE_MENU = __sounds_path__.format("close_menu.ogg")
SOUND_EXAMPLE_ERROR = __sounds_path__.format("error.ogg")
SOUND_EXAMPLE_EVENT = __sounds_path__.format("event.ogg")
SOUND_EXAMPLE_EVENT_ERROR = __sounds_path__.format("event_error.ogg")
SOUND_EXAMPLE_KEY_ADD = __sounds_path__.format("key_add.ogg")
SOUND_EXAMPLE_KEY_DELETE = __sounds_path__.format("key_delete.ogg")
SOUND_EXAMPLE_OPEN_MENU = __sounds_path__.format("open_menu.ogg")
SOUND_EXAMPLE_WIDGET_SELECTION = __sounds_path__.format("widget_selection.ogg")
SOUND_EXAMPLES = (
SOUND_EXAMPLE_CLICK_MOUSE,
SOUND_EXAMPLE_CLICK_TOUCH,
SOUND_EXAMPLE_CLOSE_MENU,
SOUND_EXAMPLE_ERROR,
SOUND_EXAMPLE_EVENT,
SOUND_EXAMPLE_EVENT_ERROR,
SOUND_EXAMPLE_KEY_ADD,
SOUND_EXAMPLE_KEY_DELETE,
SOUND_EXAMPLE_OPEN_MENU,
SOUND_EXAMPLE_WIDGET_SELECTION,
)
# Stores global reference that marks sounds as initialized
@dataclass
class SoundInitState:
"""
Global mixer initialization state.
attempted: True once mixer.init() has been called at least once.
available: False if pygame mixer module is missing.
"""
attempted: bool = False
available: bool = True
SOUND_INITIALIZED = SoundInitState()
[docs]
class Sound(Base):
"""
Sound engine class.
:param allowedchanges: Convert the samples at runtime, only in pygame>=2.0.0
:param buffer: Buffer size
:param channels: Number of channels
:param devicename: Device name
:param force_init: Force mixer init with new parameters
:param frequency: Frequency of sounds
:param size: Size of sample
:param sound_id: Sound ID
:param uniquechannel: Force the channel to be unique, this is set at the object creation moment
:param verbose: Enable/disable verbose mode (warnings/errors)
"""
_channel: mixer.Channel | None
_last_play: str
_last_time: float
_mixer_configs: dict[str, bool | int | str]
_sound: dict[str, dict[str, Any]]
_uniquechannel: bool
def __init__(
self,
allowedchanges: int = AUDIO_ALLOW_CHANNELS_CHANGE
| AUDIO_ALLOW_FREQUENCY_CHANGE,
buffer: int = 4096,
channels: int = 2,
devicename: str = "",
force_init: bool = False,
frequency: int = 22050,
size: int = -16,
sound_id: str = "",
uniquechannel: bool = True,
verbose: bool = True,
) -> None:
super().__init__(object_id=sound_id, verbose=verbose)
assert isinstance(allowedchanges, int)
assert isinstance(buffer, int)
assert isinstance(channels, int)
assert isinstance(devicename, str)
assert isinstance(force_init, bool)
assert isinstance(frequency, int)
assert isinstance(size, int)
assert isinstance(uniquechannel, bool)
assert buffer > 0, "buffer size must be greater than zero"
assert channels > 0, "channels must be greater than zero"
assert frequency > 0, "frequency must be greater than zero"
# Check if mixer is init
mixer_missing = "MissingModule" in str(type(mixer))
if mixer_missing:
if self._verbose:
warn(
"pygame mixer module could not be found, NotImplementedError"
"has been raised. Sound support is disabled"
)
SOUND_INITIALIZED.available = False
# Initialize sounds if not initialized
if not mixer_missing and (
(mixer.get_init() is None and not SOUND_INITIALIZED.attempted) or force_init
):
# Set sound as initialized globally
SOUND_INITIALIZED.attempted = True
try:
# pygame < 1.9.5
mixer_kwargs: dict[str, int | str] = {
"frequency": frequency,
"size": size,
"channels": channels,
"buffer": buffer,
}
if pygame_version >= (1, 9, 5):
mixer_kwargs["devicename"] = devicename
if pygame_version >= (2, 0, 0):
mixer_kwargs["allowedchanges"] = allowedchanges
# Call to mixer
mixer.init(**mixer_kwargs)
except Exception as e:
if self._verbose:
warn("sound error: " + str(e))
except pygame_error as e:
if self._verbose:
warn(
"sound engine could not be initialized, pygame error: " + str(e)
)
# Store mixer configs
self._mixer_configs = {
"allowedchanges": allowedchanges,
"buffer": buffer,
"channels": channels,
"devicename": devicename,
"frequency": frequency,
"size": size,
}
# Channel where a sound is played
self._channel = None
self._uniquechannel = uniquechannel
# Sound dict
self._sound = {sound_type: {} for sound_type in SOUND_TYPES}
# Last played song
self._last_play = ""
self._last_time = 0
[docs]
def copy(self) -> Sound:
"""
Return a copy of the object.
:return: Sound copied
"""
new_sound = Sound(uniquechannel=self._uniquechannel)
new_sound._channel = self._channel
new_sound._mixer_configs = dict(self._mixer_configs)
new_sound._last_play = self._last_play
new_sound._last_time = self._last_time
for sound_type in self._sound.keys():
s = self._sound[sound_type]
if s:
new_sound.set_sound(
sound_type=sound_type,
sound_file=s["path"],
volume=s["volume"],
loops=s["loops"],
maxtime=s["maxtime"],
fade_ms=s["fade_ms"],
)
return new_sound
def __copy__(self) -> Sound:
"""
Copy method.
:return: Return new sound
"""
return self.copy()
def __deepcopy__(self, memodict: dict) -> Sound:
"""
Deep-copy method.
:param memodict: Memo dict
:return: Return new sound
"""
return self.copy()
[docs]
def get_channel(self) -> mixer.Channel:
"""
Return the mixer channel used by this sound engine.
Note: ``mixer.find_channel()`` requires pygame 2.x for reliable behavior.
"""
channel = mixer.find_channel()
if self._uniquechannel:
if self._channel is None:
self._channel = channel
else:
self._channel = channel
return self._channel
[docs]
def set_sound(
self,
sound_type: str,
sound_file: str | Path | None,
volume: float = 0.5,
loops: int = 0,
maxtime: NumberType = 0,
fade_ms: NumberType = 0,
) -> bool:
"""
Link a sound file to a sound type.
:param sound_type: Sound type
:param sound_file: Sound file. If ``None`` disable the given sound type
:param volume: Volume of the sound, from ``0.0`` to ``1.0``
:param loops: Loops of the sound
:param maxtime: Max playing time of the sound
:param fade_ms: Fading ms
:return: The status of the sound load, ``True`` if the sound was loaded
"""
assert isinstance(sound_type, str)
assert isinstance(sound_file, (str, type(None), Path))
assert isinstance(volume, NumberInstance)
assert isinstance(loops, int)
assert isinstance(maxtime, NumberInstance)
assert isinstance(fade_ms, NumberInstance)
assert loops >= 0, "loops count must be equal or greater than zero"
assert maxtime >= 0, "maxtime must be equal or greater than zero"
assert fade_ms >= 0, "fade_ms must be equal or greater than zero"
assert 1 >= volume >= 0, "volume must be between 0 and 1"
# Check sound type is correct
if sound_type not in SOUND_TYPES:
raise ValueError("sound type not valid, check the manual")
# If file is none disable the sound
if sound_file is None or not SOUND_INITIALIZED.available:
self._sound[sound_type] = {}
return False
# Check the file exists
sound_path = Path(sound_file)
if not sound_path.is_file():
raise OSError(f'sound file "{sound_path}" does not exist')
# Load the sound
try:
sound_data = mixer.Sound(file=str(sound_path))
except pygame_error:
# Failed to load sound file; disable this sound type.
if self._verbose:
warn(
f'the sound file "{sound_file}" could not be loaded, it has been disabled'
)
self._sound[sound_type] = {}
return False
# Configure the sound
sound_data.set_volume(float(volume))
# Store the sound
self._sound[sound_type] = {
"fade_ms": fade_ms,
"file": sound_data,
"length": sound_data.get_length(),
"loops": loops,
"maxtime": maxtime,
"path": sound_file,
"type": sound_type,
"volume": volume,
}
return True
[docs]
def load_example_sounds(self, volume: float = 0.5) -> Sound:
"""
Load the example sounds provided by the package.
:param volume: Volume of the sound, from ``0`` to ``1``
:return: Self reference
"""
assert isinstance(volume, NumberInstance) and 0 <= volume <= 1
for sound_type, example in zip(SOUND_TYPES, SOUND_EXAMPLES):
self.set_sound(sound_type, example, volume=float(volume))
return self
def _play_sound(self, sound: dict[str, Any] | None) -> bool:
"""
Play a sound.
:param sound: Sound to be played
:return: ``True`` if the sound was played
"""
if not sound:
return False
# Find an available channel
channel = self.get_channel() # This will set the channel if it's None
if channel is None: # The sound can't be played because all channels are busy
return False
# Play the sound
sound_time = time.time()
# If the previous sound is the same and has not ended (max 10% overlap)
overlap_ratio: float = 0.1
overlap_allowed = (
sound_time - self._last_time >= overlap_ratio * sound["length"]
)
is_different_sound = sound["type"] != self._last_play
if is_different_sound or overlap_allowed or self._uniquechannel:
try:
if self._uniquechannel: # Stop the current channel if it's unique
channel.stop()
channel.play(
sound["file"],
loops=sound["loops"],
maxtime=sound["maxtime"],
fade_ms=sound["fade_ms"],
)
except pygame_error:
# Ignore playback errors; sound is optional.
pass
# Store last execution
self._last_play = sound["type"]
self._last_time = sound_time
return True
[docs]
def play_sound_type(self, sound_type: str) -> Sound:
"""
Play a sound based on its type.
:param sound_type: The type of sound to play
:return: Self reference
"""
if sound_type in self._sound:
self._play_sound(self._sound[sound_type])
return self
[docs]
def play_click_mouse(self) -> Sound:
"""
Play click mouse sound.
:return: Self reference
"""
return self.play_sound_type(SOUND_TYPE_CLICK_MOUSE)
[docs]
def play_click_touch(self) -> Sound:
"""
Play click touch sound.
:return: Self reference
"""
return self.play_sound_type(SOUND_TYPE_CLICK_TOUCH)
[docs]
def play_error(self) -> Sound:
"""
Play error sound.
:return: Self reference
"""
return self.play_sound_type(SOUND_TYPE_ERROR)
[docs]
def play_event(self) -> Sound:
"""
Play event sound.
:return: Self reference
"""
return self.play_sound_type(SOUND_TYPE_EVENT)
[docs]
def play_event_error(self) -> Sound:
"""
Play event error sound.
:return: Self reference
"""
return self.play_sound_type(SOUND_TYPE_EVENT_ERROR)
[docs]
def play_key_add(self) -> Sound:
"""
Play key addition sound.
:return: Self reference
"""
return self.play_sound_type(SOUND_TYPE_KEY_ADDITION)
[docs]
def play_key_del(self) -> Sound:
"""
Play key deletion sound.
:return: Self reference
"""
return self.play_sound_type(SOUND_TYPE_KEY_DELETION)
[docs]
def stop(self) -> Sound:
"""
Stop the channel.
:return: Self reference
"""
channel = self.get_channel()
if channel is None: # The sound can't be played because all channels are busy
return self
try:
channel.stop()
except pygame_error:
# Stopping may fail if the channel is invalid; safe to ignore.
pass
return self
[docs]
def pause(self) -> Sound:
"""
Pause the channel.
:return: Self reference
"""
channel = self.get_channel()
if channel is None: # The sound can't be played because all channels are busy
return self
try:
channel.pause()
except pygame_error:
# Pausing may fail if the channel is invalid; safe to ignore.
pass
return self
[docs]
def unpause(self) -> Sound:
"""
Unpause channel.
:return: Self reference
"""
channel = self.get_channel()
if channel is None: # The sound can't be played because all channels are busy
return self
try:
channel.unpause()
except pygame_error:
# Unpausing may fail if the channel is invalid; safe to ignore.
pass
return self
[docs]
def get_channel_info(self) -> dict[str, Any]:
"""
Return the channel information.
:return: Information dict e.g.: ``{'busy': 0, 'endevent': 0, 'queue': None, 'sound': None, 'volume': 1.0}``
"""
channel = self.get_channel()
data = {}
if channel is None: # The sound can't be played because all channels are busy
return {
"busy": None,
"endevent": None,
"queue": None,
"sound": None,
"volume": None,
}
data["busy"] = channel.get_busy()
data["endevent"] = channel.get_endevent()
data["queue"] = channel.get_queue()
data["sound"] = channel.get_sound()
data["volume"] = channel.get_volume()
return data
[docs]
def set_sound_volume(self, sound_type: str, volume: float) -> bool:
"""
Set the volume of a specific sound type.
:param sound_type: Sound type
:param volume: Volume of the sound, from ``0.0`` to ``1.0``
:return: ``True`` if the volume was set, ``False`` otherwise
"""
if not 0.0 <= volume <= 1.0:
return False
if sound_type not in SOUND_TYPES:
return False
sound_data = self._sound.get(sound_type)
if sound_data:
sound_data["file"].set_volume(volume)
sound_data["volume"] = volume
return True
return False