"""
Sound Library.
"""
from __future__ import annotations
import math
import os
from pathlib import Path
from typing import Optional, Union
from arcade.resources import resolve
import pyglet
if os.environ.get("ARCADE_SOUND_BACKENDS"):
pyglet.options["audio"] = tuple(
v.strip() for v in os.environ["ARCADE_SOUND_BACKENDS"].split(",")
)
else:
pyglet.options["audio"] = ("openal", "xaudio2", "directsound", "pulse", "silent")
import pyglet.media as media
__all__ = [
"Sound",
"load_sound",
"play_sound",
"stop_sound"
]
[docs]
class Sound:
"""This class represents a sound you can play."""
def __init__(self, file_name: Union[str, Path], streaming: bool = False):
self.file_name: str = ""
file_name = resolve(file_name)
if not Path(file_name).is_file():
raise FileNotFoundError(
f"The sound file '{file_name}' is not a file or can't be read."
)
self.file_name = str(file_name)
self.source: Union[media.StaticSource, media.StreamingSource] = media.load(
self.file_name, streaming=streaming
)
if self.source.duration is None:
raise ValueError("Audio duration must be known when loaded, but this audio source returned `None`")
self.min_distance = (
100000000 # setting the players to this allows for 2D panning with 3D audio
)
[docs]
def play(
self,
volume: float = 1.0,
pan: float = 0.0,
loop: bool = False,
speed: float = 1.0,
) -> media.Player:
"""
Play the sound.
:param volume: Volume, from 0=quiet to 1=loud
:param pan: Pan, from -1=left to 0=centered to 1=right
:param loop: Loop, false to play once, true to loop continuously
:param speed: Change the speed of the sound which also changes pitch, default 1.0
"""
if (
isinstance(self.source, media.StreamingSource)
and self.source.is_player_source
):
raise RuntimeError(
"Tried to play a streaming source more than once."
" Streaming sources should only be played in one instance."
" If you need more use a Static source."
)
player: media.Player = media.Player()
player.volume = volume
player.position = (
pan,
0.0,
math.sqrt(1 - math.pow(pan, 2)),
) # used to mimic panning with 3D audio
# Note that the underlying attribute is pitch but "speed" is used
# because it describes the behavior better (see #1198)
player.pitch = speed
player.loop = loop
player.queue(self.source)
player.play()
media.Source._players.append(player)
def _on_player_eos():
media.Source._players.remove(player)
# There is a closure on player. To get the refcount to 0,
# we need to delete this function.
player.on_player_eos = None # type: ignore # pending https://github.com/pyglet/pyglet/issues/845
player.on_player_eos = _on_player_eos
return player
[docs]
def stop(self, player: media.Player) -> None:
"""
Stop a currently playing sound.
"""
player.pause()
player.delete()
if player in media.Source._players:
media.Source._players.remove(player)
[docs]
def get_length(self) -> float:
"""Get length of audio in seconds"""
# We validate that duration is known when loading the source
return self.source.duration # type: ignore
[docs]
def is_complete(self, player: media.Player) -> bool:
"""Return true if the sound is done playing."""
# We validate that duration is known when loading the source
return player.time >= self.source.duration # type: ignore
[docs]
def is_playing(self, player: media.Player) -> bool:
"""
Return if the sound is currently playing or not
:param player: Player returned from :func:`play_sound`.
:returns: A boolean, ``True`` if the sound is playing.
"""
return player.playing
[docs]
def get_volume(self, player: media.Player) -> float:
"""
Get the current volume.
:param player: Player returned from :func:`play_sound`.
:returns: A float, 0 for volume off, 1 for full volume.
"""
return player.volume # type: ignore # pending https://github.com/pyglet/pyglet/issues/847
[docs]
def set_volume(self, volume, player: media.Player) -> None:
"""
Set the volume of a sound as it is playing.
:param volume: Floating point volume. 0 is silent, 1 is full.
:param player: Player returned from :func:`play_sound`.
"""
player.volume = volume
[docs]
def get_stream_position(self, player: media.Player) -> float:
"""
Return where we are in the stream. This will reset back to
zero when it is done playing.
:param player: Player returned from :func:`play_sound`.
"""
return player.time
[docs]
def load_sound(path: Union[str, Path], streaming: bool = False) -> Optional[Sound]:
"""
Load a sound.
:param path: Name of the sound file to load.
:param streaming: Boolean for determining if we stream the sound
or load it all into memory. Set to ``True`` for long sounds to save
memory, ``False`` for short sounds to speed playback.
:returns: Sound object which can be used by the :func:`play_sound` function.
"""
# Initialize the audio driver if it hasn't been already.
# This call is to avoid audio driver initialization
# the first time a sound is played.
# This call is inexpensive if the driver is already initialized.
media.get_audio_driver()
file_name = str(path)
try:
return Sound(file_name, streaming)
except Exception as ex:
raise FileNotFoundError(
f'Unable to load sound file: "{file_name}". Exception: {ex}'
) from ex
[docs]
def play_sound(
sound: Sound,
volume: float = 1.0,
pan: float = 0.0,
loop: bool = False,
speed: float = 1.0,
) -> Optional[media.Player]:
"""
Play a sound.
:param sound: Sound loaded by :func:`load_sound`. Do NOT use a string here for the filename.
:param volume: Volume, from 0=quiet to 1=loud
:param pan: Pan, from -1=left to 0=centered to 1=right
:param loop: Should we loop the sound over and over?
:param speed: Change the speed of the sound which also changes pitch, default 1.0
"""
if sound is None:
print("Unable to play sound, no data passed in.")
return None
elif not isinstance(sound, Sound):
raise TypeError(
f"Error, got {sound!r} instead of an arcade.Sound."
if not isinstance(sound, (str, Path, bytes)) else\
" Make sure to use load_sound first, then play the result with play_sound.")
try:
return sound.play(volume, pan, loop, speed)
except Exception as ex:
print("Error playing sound.", ex)
return None
[docs]
def stop_sound(player: media.Player):
"""
Stop a sound that is currently playing.
:param player: Player returned from :func:`play_sound`.
"""
if not isinstance(player, media.Player):
raise TypeError(
"stop_sound takes a media player object returned from the play_sound() command, not a "
"loaded Sound object." if isinstance(player, Sound) else f"{player!r}")
player.pause()
player.delete()
if player in media.Source._players:
media.Source._players.remove(player)