Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 16 additions & 4 deletions arcade/sound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,17 +64,23 @@ class Sound:
streaming:
If ``True``, attempt to load data from ``file_path`` via
via :ref:`streaming <sound-loading-modes>`.
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)

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: 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.
"""
Expand Down Expand Up @@ -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!
Expand All @@ -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 <sound-basics-playing>` instance of a
Expand All @@ -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}'
Expand Down
21 changes: 21 additions & 0 deletions doc/programming_guide/sound.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand All @@ -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`
Expand Down
57 changes: 57 additions & 0 deletions tests/unit/test_sound.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,70 @@
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

frame_count = 0
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

Expand Down
Loading