Skip to content

Commit 81209e0

Browse files
authored
Allow explicit audio decoders when loading sounds (#2959)
Co-authored-by: snowyukitty <270071858+snowyukitty@users.noreply.github.com>
1 parent 98879bb commit 81209e0

4 files changed

Lines changed: 95 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
6363
- 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.
6464

6565
### New Features
66+
- 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)).
6667
- 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.
6768
- 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.
6869
- 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.

‎arcade/sound.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pyglet
99
from pyglet.media import Source
10+
from pyglet.media.codecs import MediaDecoder
1011

1112
from arcade.resources import resolve
1213
from arcade.utils import is_pyodide
@@ -63,17 +64,23 @@ class Sound:
6364
streaming:
6465
If ``True``, attempt to load data from ``file_path`` via
6566
via :ref:`streaming <sound-loading-modes>`.
67+
decoder:
68+
A specific :py:class:`pyglet.media.codecs.MediaDecoder` to use.
69+
If ``None``, pyglet selects a decoder automatically. When a decoder
70+
is provided, no other decoders are tried.
6671
"""
6772

68-
def __init__(self, file_name: str | Path, streaming: bool = False):
73+
def __init__(
74+
self, file_name: str | Path, streaming: bool = False, *, decoder: MediaDecoder | None = None
75+
):
6976
self.file_name: str = ""
7077
file_name = resolve(file_name)
7178

7279
if not Path(file_name).is_file():
7380
raise FileNotFoundError(f"The sound file '{file_name}' is not a file or can't be read.")
7481
self.file_name = str(file_name)
7582

76-
self.source: Source = media.load_audio(self.file_name, streaming=streaming)
83+
self.source: Source = media.load_audio(self.file_name, streaming=streaming, decoder=decoder)
7784
"""
7885
The :py:class:`pyglet.media.Source` object that holds the audio data.
7986
"""
@@ -219,7 +226,9 @@ def get_stream_position(self, player: media.AudioPlayer) -> float:
219226
return player.time
220227

221228

222-
def load_sound(path: str | Path, streaming: bool = False) -> Sound:
229+
def load_sound(
230+
path: str | Path, streaming: bool = False, *, decoder: MediaDecoder | None = None
231+
) -> Sound:
223232
"""Load a file as a :py:class:`Sound` data object.
224233
225234
.. important:: A :py:class:`Sound` with ``streaming=True`` loses features!
@@ -233,6 +242,9 @@ def load_sound(path: str | Path, streaming: bool = False) -> Sound:
233242
streaming: Boolean for determining if we stream the sound or
234243
load it all into memory. Set to ``True`` for long sounds to
235244
save memory, ``False`` for short sounds to speed playback.
245+
decoder: A specific :py:class:`pyglet.media.codecs.MediaDecoder` to use.
246+
If ``None``, pyglet selects a decoder automatically. When a decoder
247+
is provided, no other decoders are tried.
236248
237249
Returns:
238250
A :ref:`playable <sound-basics-playing>` instance of a
@@ -246,7 +258,7 @@ def load_sound(path: str | Path, streaming: bool = False) -> Sound:
246258

247259
file_name = str(path)
248260
try:
249-
return Sound(file_name, streaming)
261+
return Sound(file_name, streaming, decoder=decoder)
250262
except Exception as ex:
251263
raise FileNotFoundError(
252264
f'Unable to load sound file: "{file_name}". Exception: {ex}'

‎doc/programming_guide/sound.rst‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ Both provide a :py:class:`Sound` instance and accept the same arguments:
9999
- :py:class:`bool`
100100
- * ``True`` streams from disk
101101
* ``False`` loads the whole file
102+
* - ``decoder`` (keyword-only)
103+
- :py:class:`pyglet.media.codecs.MediaDecoder` **|** ``None``
104+
- A specific decoder, or ``None`` for automatic selection
102105

103106
The simplest option is to use :py:func:`arcade.load_sound`:
104107

@@ -124,6 +127,24 @@ directly:
124127
# For music files and ambiance tracks, streaming=True is usually best
125128
streaming_music_file = Sound(":resources:music/1918.mp3", streaming=True)
126129
130+
To select a decoder explicitly, pass a
131+
:py:class:`pyglet.media.codecs.MediaDecoder` instance as ``decoder`` to either
132+
loader. For example, pyglet includes a WAV decoder:
133+
134+
.. code-block:: python
135+
136+
import arcade
137+
from pyglet.media.codecs.wave import WaveDecoder
138+
139+
sound = arcade.load_sound(":resources:sounds/hurt1.wav", decoder=WaveDecoder())
140+
141+
You can also pass your own ``MediaDecoder`` subclass without subclassing
142+
:py:class:`Sound` or registering the decoder globally. The selected decoder
143+
receives the resolved file path and the ``streaming`` setting. If it fails,
144+
pyglet does not fall back to other decoders. Any dependencies required by the
145+
decoder must already be installed; passing a decoder does not add support for
146+
its formats automatically.
147+
127148
To learn more, please see the following:
128149

129150
#. :ref:`resources`

‎tests/unit/test_sound.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,70 @@
1+
import wave
12
from pathlib import Path
23

34
import pytest
5+
from pyglet.media.codecs.base import StaticSource, StreamingSource
6+
from pyglet.media.codecs.wave import WAVEDecodeException, WaveDecoder
47

58
import arcade
69

710
frame_count = 0
811
player = None
912

1013

14+
@pytest.mark.parametrize("load", [arcade.Sound, arcade.load_sound])
15+
@pytest.mark.parametrize("streaming", [False, True])
16+
@pytest.mark.parametrize("path_kind", ["path", "resource"])
17+
def test_sound_explicit_decoder(load, streaming, path_kind, tmp_path, mocker):
18+
if path_kind == "path":
19+
# An explicit decoder must work without a registered file extension.
20+
path = tmp_path / "sound.custom"
21+
with wave.open(str(path), "wb") as audio:
22+
audio.setparams((1, 1, 8000, 80, "NONE", "not compressed"))
23+
audio.writeframes(b"\x80" * 80)
24+
else:
25+
path = ":resources:sounds/laser1.wav"
26+
27+
decoder = WaveDecoder()
28+
decode = mocker.spy(decoder, "decode")
29+
sound = load(path, streaming, decoder=decoder)
30+
31+
decode.assert_called_once()
32+
assert decode.call_args.args == (sound.file_name, None)
33+
assert decode.call_args.kwargs["streaming"] is streaming
34+
assert Path(sound.file_name).is_file()
35+
assert sound.source.duration > 0
36+
assert sound.source.audio_format.channels > 0
37+
assert isinstance(sound.source, StreamingSource if streaming else StaticSource)
38+
39+
source = sound.source.get_queue_source()
40+
audio_data = source.get_audio_data(1024)
41+
assert audio_data is not None
42+
assert audio_data.length > 0
43+
if path_kind == "path":
44+
assert sound.source.duration == pytest.approx(0.01)
45+
assert sound.source.audio_format.sample_rate == 8000
46+
assert audio_data.data == b"\x80" * 80
47+
48+
49+
@pytest.mark.parametrize("load", [arcade.Sound, arcade.load_sound])
50+
@pytest.mark.parametrize("streaming", [False, True])
51+
def test_sound_explicit_decoder_failure(load, streaming, mocker):
52+
decoder = WaveDecoder()
53+
error = WAVEDecodeException("Explicit decoder failed")
54+
decode = mocker.patch.object(decoder, "decode", side_effect=error)
55+
56+
# This valid WAV would load successfully if automatic decoders were tried.
57+
expected_error = WAVEDecodeException if load is arcade.Sound else FileNotFoundError
58+
with pytest.raises(expected_error, match="Explicit decoder failed") as exc:
59+
load(":resources:sounds/laser1.wav", streaming, decoder=decoder)
60+
61+
decode.assert_called_once()
62+
if load is arcade.Sound:
63+
assert exc.value is error
64+
else:
65+
assert exc.value.__cause__ is error
66+
67+
1168
def test_sound_normal_load_and_playback(window):
1269
global frame_count, player
1370

0 commit comments

Comments
 (0)