diff --git a/CHANGELOG.md b/CHANGELOG.md index a35c5a96c..3a27ebaf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Fixed the `sprite_rotate_around_tank` example's corrected barrel, which sat 90 degrees off from the mouse, and updated `turn_and_move` for the new angles. `turn_and_move`'s sprite also now stops when moving straight along an axis, which it never did. ### New Features +- Added a keyword-only `decoder` argument to `arcade.Sound` and `arcade.load_sound`, allowing a specific pyglet `MediaDecoder` to load audio without subclassing `Sound` or registering the decoder globally ([#2049](https://github.com/pythonarcade/arcade/issues/2049)). - Added `arcade.sweep_sprite(sprite, dx, dy, sprite_list)`, which checks the whole path of a moving sprite and returns a `SweepInfo` for the first sprite it would hit (the `sprite`, how far along the move as a `fraction` and `distance`, and the surface `normal`), or `None`. Fast sprites can't pass through thin walls this way. A sprite that already overlaps one is an immediate hit. Added the `sprite_bullets_sweep` example comparing it with a plain collision check. - Added the `sprite_pixel_demolition` example, a stress test where every pixel of the Arcade logo is a sprite (28,000, or 71,000 with smaller pixels) and exploding bullets knock them loose as debris. Debris can move as Python sprites or on the GPU with a shader (press G), which keeps 60 FPS with 15,000 pieces in flight. - Added `arcade.sweep_line(start, end, sprite_list)`, which returns a `SweepInfo` for the first sprite a line hits (its `fraction`, `distance`, and surface `normal`), or `None`. It's `sweep_sprite` for a point: for lasers, hitscan weapons, or seeing what's in the way. Added the `sprite_laser_mirrors` example, where a laser bounces off rotating mirrors using the normal. diff --git a/arcade/sound.py b/arcade/sound.py index 09c70ce32..3e30f3140 100644 --- a/arcade/sound.py +++ b/arcade/sound.py @@ -7,6 +7,7 @@ import pyglet from pyglet.media import Source +from pyglet.media.codecs import MediaDecoder from arcade.resources import resolve from arcade.utils import is_pyodide @@ -63,9 +64,15 @@ class Sound: streaming: If ``True``, attempt to load data from ``file_path`` via via :ref:`streaming `. + decoder: + A specific :py:class:`pyglet.media.codecs.MediaDecoder` to use. + If ``None``, pyglet selects a decoder automatically. When a decoder + is provided, no other decoders are tried. """ - def __init__(self, file_name: str | Path, streaming: bool = False): + def __init__( + self, file_name: str | Path, streaming: bool = False, *, decoder: MediaDecoder | None = None + ): self.file_name: str = "" file_name = resolve(file_name) @@ -73,7 +80,7 @@ def __init__(self, file_name: str | Path, streaming: bool = False): 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: Source = media.load_audio(self.file_name, streaming=streaming) + self.source: Source = media.load_audio(self.file_name, streaming=streaming, decoder=decoder) """ The :py:class:`pyglet.media.Source` object that holds the audio data. """ @@ -219,7 +226,9 @@ def get_stream_position(self, player: media.AudioPlayer) -> float: return player.time -def load_sound(path: str | Path, streaming: bool = False) -> Sound: +def load_sound( + path: str | Path, streaming: bool = False, *, decoder: MediaDecoder | None = None +) -> Sound: """Load a file as a :py:class:`Sound` data object. .. important:: A :py:class:`Sound` with ``streaming=True`` loses features! @@ -233,6 +242,9 @@ def load_sound(path: str | Path, streaming: bool = False) -> Sound: 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. + decoder: A specific :py:class:`pyglet.media.codecs.MediaDecoder` to use. + If ``None``, pyglet selects a decoder automatically. When a decoder + is provided, no other decoders are tried. Returns: A :ref:`playable ` instance of a @@ -246,7 +258,7 @@ def load_sound(path: str | Path, streaming: bool = False) -> Sound: file_name = str(path) try: - return Sound(file_name, streaming) + return Sound(file_name, streaming, decoder=decoder) except Exception as ex: raise FileNotFoundError( f'Unable to load sound file: "{file_name}". Exception: {ex}' diff --git a/doc/programming_guide/sound.rst b/doc/programming_guide/sound.rst index 6ae28095d..5d1edb851 100644 --- a/doc/programming_guide/sound.rst +++ b/doc/programming_guide/sound.rst @@ -99,6 +99,9 @@ Both provide a :py:class:`Sound` instance and accept the same arguments: - :py:class:`bool` - * ``True`` streams from disk * ``False`` loads the whole file + * - ``decoder`` (keyword-only) + - :py:class:`pyglet.media.codecs.MediaDecoder` **|** ``None`` + - A specific decoder, or ``None`` for automatic selection The simplest option is to use :py:func:`arcade.load_sound`: @@ -124,6 +127,24 @@ directly: # For music files and ambiance tracks, streaming=True is usually best streaming_music_file = Sound(":resources:music/1918.mp3", streaming=True) +To select a decoder explicitly, pass a +:py:class:`pyglet.media.codecs.MediaDecoder` instance as ``decoder`` to either +loader. For example, pyglet includes a WAV decoder: + +.. code-block:: python + + import arcade + from pyglet.media.codecs.wave import WaveDecoder + + sound = arcade.load_sound(":resources:sounds/hurt1.wav", decoder=WaveDecoder()) + +You can also pass your own ``MediaDecoder`` subclass without subclassing +:py:class:`Sound` or registering the decoder globally. The selected decoder +receives the resolved file path and the ``streaming`` setting. If it fails, +pyglet does not fall back to other decoders. Any dependencies required by the +decoder must already be installed; passing a decoder does not add support for +its formats automatically. + To learn more, please see the following: #. :ref:`resources` diff --git a/tests/unit/test_sound.py b/tests/unit/test_sound.py index a5a3a6ca9..cbaefa19d 100644 --- a/tests/unit/test_sound.py +++ b/tests/unit/test_sound.py @@ -1,6 +1,9 @@ +import wave from pathlib import Path import pytest +from pyglet.media.codecs.base import StaticSource, StreamingSource +from pyglet.media.codecs.wave import WAVEDecodeException, WaveDecoder import arcade @@ -8,6 +11,60 @@ player = None +@pytest.mark.parametrize("load", [arcade.Sound, arcade.load_sound]) +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("path_kind", ["path", "resource"]) +def test_sound_explicit_decoder(load, streaming, path_kind, tmp_path, mocker): + if path_kind == "path": + # An explicit decoder must work without a registered file extension. + path = tmp_path / "sound.custom" + with wave.open(str(path), "wb") as audio: + audio.setparams((1, 1, 8000, 80, "NONE", "not compressed")) + audio.writeframes(b"\x80" * 80) + else: + path = ":resources:sounds/laser1.wav" + + decoder = WaveDecoder() + decode = mocker.spy(decoder, "decode") + sound = load(path, streaming, decoder=decoder) + + decode.assert_called_once() + assert decode.call_args.args == (sound.file_name, None) + assert decode.call_args.kwargs["streaming"] is streaming + assert Path(sound.file_name).is_file() + assert sound.source.duration > 0 + assert sound.source.audio_format.channels > 0 + assert isinstance(sound.source, StreamingSource if streaming else StaticSource) + + source = sound.source.get_queue_source() + audio_data = source.get_audio_data(1024) + assert audio_data is not None + assert audio_data.length > 0 + if path_kind == "path": + assert sound.source.duration == pytest.approx(0.01) + assert sound.source.audio_format.sample_rate == 8000 + assert audio_data.data == b"\x80" * 80 + + +@pytest.mark.parametrize("load", [arcade.Sound, arcade.load_sound]) +@pytest.mark.parametrize("streaming", [False, True]) +def test_sound_explicit_decoder_failure(load, streaming, mocker): + decoder = WaveDecoder() + error = WAVEDecodeException("Explicit decoder failed") + decode = mocker.patch.object(decoder, "decode", side_effect=error) + + # This valid WAV would load successfully if automatic decoders were tried. + expected_error = WAVEDecodeException if load is arcade.Sound else FileNotFoundError + with pytest.raises(expected_error, match="Explicit decoder failed") as exc: + load(":resources:sounds/laser1.wav", streaming, decoder=decoder) + + decode.assert_called_once() + if load is arcade.Sound: + assert exc.value is error + else: + assert exc.value.__cause__ is error + + def test_sound_normal_load_and_playback(window): global frame_count, player