Skip to content

Commit 93114e2

Browse files
committed
Fix multiple issues with tcod.sdl.audio
Adds much needed testing of the tcod.sdl.audio module. Convert some asserts into real errors. Handle exceptions raised in audio callback as unraisable.
1 parent 98531c5 commit 93114e2

File tree

5 files changed

+186
-21
lines changed

5 files changed

+186
-21
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@
412412
"typestr",
413413
"undoc",
414414
"Unifont",
415+
"unraisable",
415416
"unraisablehook",
416417
"unraiseable",
417418
"upscaling",

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Changes relevant to the users of python-tcod are documented here.
44
This project adheres to [Semantic Versioning](https://semver.org/) since version `2.0.0`.
55

66
## [Unreleased]
7+
### Fixed
8+
- `AudioDevice.stopped` was inverted.
9+
- Fixed the audio mixer stop and fadeout methods.
10+
- Exceptions raised in the audio mixer callback no longer cause a messy crash, they now go to `sys.unraisablehook`.
711

812
## [16.0.0] - 2023-05-27
913
### Added

tcod/sdl/audio.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import sys
4848
import threading
4949
import time
50+
from dataclasses import dataclass
5051
from types import TracebackType
5152
from typing import Any, Callable, Hashable, Iterator
5253

@@ -65,7 +66,9 @@ def _get_format(format: DTypeLike) -> int:
6566
assert dt.fields is None
6667
bitsize = dt.itemsize * 8
6768
assert 0 < bitsize <= lib.SDL_AUDIO_MASK_BITSIZE
68-
assert dt.str[1] in "uif"
69+
if not dt.str[1] in "uif":
70+
msg = f"Unexpected dtype: {dt}"
71+
raise TypeError(msg)
6972
is_signed = dt.str[1] != "u"
7073
is_float = dt.str[1] == "f"
7174
byteorder = dt.byteorder
@@ -81,7 +84,21 @@ def _get_format(format: DTypeLike) -> int:
8184

8285

8386
def _dtype_from_format(format: int) -> np.dtype[Any]:
84-
"""Return a dtype from a SDL_AudioFormat."""
87+
"""Return a dtype from a SDL_AudioFormat.
88+
89+
>>> _dtype_from_format(tcod.lib.AUDIO_F32LSB)
90+
dtype('float32')
91+
>>> _dtype_from_format(tcod.lib.AUDIO_F32MSB)
92+
dtype('>f4')
93+
>>> _dtype_from_format(tcod.lib.AUDIO_S16LSB)
94+
dtype('int16')
95+
>>> _dtype_from_format(tcod.lib.AUDIO_S16MSB)
96+
dtype('>i2')
97+
>>> _dtype_from_format(tcod.lib.AUDIO_U16LSB)
98+
dtype('uint16')
99+
>>> _dtype_from_format(tcod.lib.AUDIO_U16MSB)
100+
dtype('>u2')
101+
"""
85102
bitsize = format & lib.SDL_AUDIO_MASK_BITSIZE
86103
assert bitsize % 8 == 0
87104
byte_size = bitsize // 8
@@ -203,6 +220,8 @@ def __init__(
203220

204221
def __repr__(self) -> str:
205222
"""Return a representation of this device."""
223+
if self.stopped:
224+
return f"<{self.__class__.__name__}() stopped=True>"
206225
items = [
207226
f"{self.__class__.__name__}(device_id={self.device_id})",
208227
f"frequency={self.frequency}",
@@ -211,7 +230,9 @@ def __repr__(self) -> str:
211230
f"channels={self.channels}",
212231
f"buffer_samples={self.buffer_samples}",
213232
f"buffer_bytes={self.buffer_bytes}",
233+
f"paused={self.paused}",
214234
]
235+
215236
if self.silence:
216237
items.append(f"silence={self.silence}")
217238
if self._handle is not None:
@@ -241,7 +262,9 @@ def _sample_size(self) -> int:
241262
@property
242263
def stopped(self) -> bool:
243264
"""Is True if the device has failed or was closed."""
244-
return bool(lib.SDL_GetAudioDeviceStatus(self.device_id) != lib.SDL_AUDIO_STOPPED)
265+
if not hasattr(self, "device_id"):
266+
return True
267+
return bool(lib.SDL_GetAudioDeviceStatus(self.device_id) == lib.SDL_AUDIO_STOPPED)
245268

246269
@property
247270
def paused(self) -> bool:
@@ -404,7 +427,9 @@ def play(
404427
def _verify_audio_sample(self, sample: ArrayLike) -> NDArray[Any]:
405428
"""Verify an audio sample is valid and return it as a Numpy array."""
406429
array: NDArray[Any] = np.asarray(sample)
407-
assert array.dtype == self.mixer.device.format
430+
if array.dtype != self.mixer.device.format:
431+
msg = f"Audio sample must be dtype={self.mixer.device.format}, input was dtype={array.dtype}"
432+
raise TypeError(msg)
408433
if len(array.shape) == 1:
409434
array = array[:, np.newaxis]
410435
return array
@@ -434,7 +459,7 @@ def fadeout(self, time: float) -> None:
434459
time_samples = round(time * self.mixer.device.frequency) + 1
435460
buffer: NDArray[np.float32] = np.zeros((time_samples, self.mixer.device.channels), np.float32)
436461
self._on_mix(buffer)
437-
buffer *= np.linspace(1.0, 0.0, time_samples + 1, endpoint=False)[1:]
462+
buffer *= np.linspace(1.0, 0.0, time_samples + 1, endpoint=False)[1:, np.newaxis]
438463
self.sound_queue[:] = [buffer]
439464

440465
def stop(self) -> None:
@@ -536,13 +561,41 @@ class _AudioCallbackUserdata:
536561
device: AudioDevice
537562

538563

564+
@dataclass
565+
class _UnraisableHookArgs:
566+
exc_type: type[BaseException]
567+
exc_value: BaseException | None
568+
exc_traceback: TracebackType | None
569+
err_msg: str | None
570+
object: object
571+
572+
573+
class _ProtectedContext:
574+
def __init__(self, obj: object = None) -> None:
575+
self.obj = obj
576+
577+
def __enter__(self) -> None:
578+
pass
579+
580+
def __exit__(
581+
self, exc_type: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None
582+
) -> bool:
583+
if exc_type is None:
584+
return False
585+
if sys.version_info < (3, 8):
586+
return False
587+
sys.unraisablehook(_UnraisableHookArgs(exc_type, value, traceback, None, self.obj)) # type: ignore[arg-type]
588+
return True
589+
590+
539591
@ffi.def_extern() # type: ignore
540-
def _sdl_audio_callback(userdata: Any, stream: Any, length: int) -> None:
592+
def _sdl_audio_callback(userdata: Any, stream: Any, length: int) -> None: # noqa: ANN401
541593
"""Handle audio device callbacks."""
542594
data: _AudioCallbackUserdata = ffi.from_handle(userdata)
543595
device = data.device
544596
buffer = np.frombuffer(ffi.buffer(stream, length), dtype=device.format).reshape(-1, device.channels)
545-
device._callback(device, buffer)
597+
with _ProtectedContext(device):
598+
device._callback(device, buffer)
546599

547600

548601
def _get_devices(capture: bool) -> Iterator[str]:

tests/test_sdl.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
"""Test SDL specific features."""
2-
import contextlib
32
import sys
43

54
import numpy as np
65
import pytest
76

8-
import tcod.sdl.audio
97
import tcod.sdl.render
108
import tcod.sdl.sys
119
import tcod.sdl.video
@@ -83,15 +81,3 @@ def test_sdl_render_bad_types() -> None:
8381
tcod.sdl.render.Renderer(tcod.ffi.cast("SDL_Renderer*", tcod.ffi.NULL))
8482
with pytest.raises(TypeError):
8583
tcod.sdl.render.Renderer(tcod.ffi.new("SDL_Rect*"))
86-
87-
88-
def test_sdl_audio_device() -> None:
89-
with contextlib.closing(tcod.sdl.audio.open(frequency=44100, format=np.float32, channels=2, paused=True)) as device:
90-
assert device.convert(np.zeros(4, dtype=np.float32), 22050).shape[0] == 8 # noqa: PLR2004
91-
assert device.convert(np.zeros((4, 4), dtype=np.float32)).shape == (4, 2)
92-
assert device.convert(np.zeros(4, dtype=np.int8)).shape[0] == 4 # noqa: PLR2004
93-
device.paused = False
94-
device.paused = True
95-
assert device.queued_samples == 0
96-
with contextlib.closing(tcod.sdl.audio.BasicMixer(device)) as mixer:
97-
assert mixer

tests/test_sdl_audio.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Test tcod.sdl.audio module."""
2+
import contextlib
3+
import sys
4+
import time
5+
from typing import Any
6+
7+
import numpy as np
8+
import pytest
9+
from numpy.typing import NDArray
10+
11+
import tcod.sdl.audio
12+
13+
# ruff: noqa: D103
14+
15+
16+
def test_devices() -> None:
17+
list(tcod.sdl.audio.get_devices())
18+
list(tcod.sdl.audio.get_capture_devices())
19+
20+
21+
def test_audio_device() -> None:
22+
with tcod.sdl.audio.open(frequency=44100, format=np.float32, channels=2, paused=True) as device:
23+
assert not device.stopped
24+
assert device.convert(np.zeros(4, dtype=np.float32), 22050).shape[0] == 8 # noqa: PLR2004
25+
assert device.convert(np.zeros((4, 4), dtype=np.float32)).shape == (4, 2)
26+
assert device.convert(np.zeros(4, dtype=np.int8)).shape[0] == 4 # noqa: PLR2004
27+
assert device.paused is True
28+
device.paused = False
29+
assert device.paused is False
30+
device.paused = True
31+
assert device.queued_samples == 0
32+
with pytest.raises(TypeError):
33+
device.callback # noqa: B018
34+
with pytest.raises(TypeError):
35+
device.callback = lambda _device, _stream: None
36+
with contextlib.closing(tcod.sdl.audio.BasicMixer(device)) as mixer:
37+
assert mixer.daemon
38+
assert mixer.play(np.zeros(4, np.float32)).busy
39+
mixer.play(np.zeros(0, np.float32))
40+
mixer.play(np.full(1, 0.01, np.float32), on_end=lambda _: None)
41+
mixer.play(np.full(1, 0.01, np.float32), loops=2, on_end=lambda _: None)
42+
mixer.play(np.full(4, 0.01, np.float32), loops=2).stop()
43+
mixer.play(np.full(100000, 0.01, np.float32))
44+
with pytest.raises(TypeError, match=r".*must be dtype=float32.*was dtype=int32"):
45+
mixer.play(np.zeros(1, np.int32))
46+
time.sleep(0.001)
47+
mixer.stop()
48+
49+
50+
def test_audio_capture() -> None:
51+
with tcod.sdl.audio.open(capture=True) as device:
52+
assert not device.stopped
53+
assert isinstance(device.dequeue_audio(), np.ndarray)
54+
55+
56+
def test_audio_device_repr() -> None:
57+
with tcod.sdl.audio.open(format=np.uint16, paused=True, callback=True) as device:
58+
assert "silence=" in repr(device)
59+
assert "callback=" in repr(device)
60+
assert "stopped=" in repr(device)
61+
62+
63+
def test_convert_bad_shape() -> None:
64+
with pytest.raises(TypeError):
65+
tcod.sdl.audio.convert_audio(
66+
np.zeros((1, 1, 1), np.float32), 8000, out_rate=8000, out_format=np.float32, out_channels=1
67+
)
68+
69+
70+
def test_convert_bad_type() -> None:
71+
with pytest.raises(TypeError, match=r".*bool"):
72+
tcod.sdl.audio.convert_audio(np.zeros(8, bool), 8000, out_rate=8000, out_format=np.float32, out_channels=1)
73+
with pytest.raises(RuntimeError, match=r"Invalid source format"):
74+
tcod.sdl.audio.convert_audio(np.zeros(8, np.int64), 8000, out_rate=8000, out_format=np.float32, out_channels=1)
75+
76+
77+
def test_convert_float64() -> None:
78+
np.testing.assert_array_equal(
79+
tcod.sdl.audio.convert_audio(
80+
np.ones(8, np.float64), 8000, out_rate=8000, out_format=np.float32, out_channels=1
81+
),
82+
np.ones((8, 1), np.float32),
83+
)
84+
85+
86+
def test_audio_callback() -> None:
87+
class CheckCalled:
88+
was_called: bool = False
89+
90+
def __call__(self, device: tcod.sdl.audio.AudioDevice, stream: NDArray[Any]) -> None:
91+
self.was_called = True
92+
assert isinstance(device, tcod.sdl.audio.AudioDevice)
93+
assert isinstance(stream, np.ndarray)
94+
assert len(stream.shape) == 2 # noqa: PLR2004
95+
96+
check_called = CheckCalled()
97+
with tcod.sdl.audio.open(callback=check_called, paused=False) as device:
98+
device.callback = device.callback
99+
while not check_called.was_called:
100+
time.sleep(0.001)
101+
102+
103+
@pytest.mark.skipif(sys.version_info < (3, 8), reason="Needs sys.unraisablehook support")
104+
@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
105+
def test_audio_callback_unraisable() -> None:
106+
"""Test unraisable error in audio callback.
107+
108+
This can't be checked with pytest very well, so at least make sure this doesn't crash.
109+
"""
110+
111+
class CheckCalled:
112+
was_called: bool = False
113+
114+
def __call__(self, device: tcod.sdl.audio.AudioDevice, stream: NDArray[Any]) -> None:
115+
self.was_called = True
116+
raise Exception("Test unraisable error") # noqa
117+
118+
check_called = CheckCalled()
119+
with tcod.sdl.audio.open(callback=check_called, paused=False):
120+
while not check_called.was_called:
121+
time.sleep(0.001)

0 commit comments

Comments
 (0)