Skip to content

Commit efa027b

Browse files
committed
Add audio conversion, make mixer features public.
Add partial audio tests.
1 parent b69720a commit efa027b

File tree

4 files changed

+169
-17
lines changed

4 files changed

+169
-17
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@
192192
"msilib",
193193
"MSVC",
194194
"msvcr",
195+
"mult",
195196
"mulx",
196197
"muly",
197198
"mypy",

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+
### Added
8+
- `BasicMixer` and `Channel` classes added to `tcod.sdl.audio`. These handle simple audio mixing.
9+
- `AudioDevice.convert` added to handle simple conversions to the active devices format.
10+
- `tcod.sdl.audio.convert_audio` added to handle any other conversions needed.
711

812
## [13.5.0] - 2022-02-11
913
### Added

tcod/sdl/audio.py

Lines changed: 150 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import tcod.sdl.sys
1818
from tcod.loader import ffi, lib
19-
from tcod.sdl import _get_error
19+
from tcod.sdl import _check, _get_error
2020

2121

2222
def _get_format(format: DTypeLike) -> int:
@@ -55,10 +55,65 @@ def _dtype_from_format(format: int) -> np.dtype[Any]:
5555
return np.dtype(f"{byteorder}{kind}{bytesize}")
5656

5757

58+
def convert_audio(
59+
in_sound: ArrayLike, in_rate: int, *, out_rate: int, out_format: DTypeLike, out_channels: int
60+
) -> NDArray[Any]:
61+
"""Convert an audio sample into a format supported by this device.
62+
63+
Returns the converted array. This might be a reference to the input array if no conversion was needed.
64+
65+
Args:
66+
in_sound: The input ArrayLike sound sample. Input format and channels are derived from the array.
67+
in_rate: The samplerate of the input array.
68+
out_rate: The samplerate of the output array.
69+
out_format: The output format of the converted array.
70+
out_channels: The number of audio channels of the output array.
71+
72+
.. versionadded:: unreleased
73+
74+
.. seealso::
75+
:any:`AudioDevice.convert`
76+
"""
77+
in_array: NDArray[Any] = np.asarray(in_sound)
78+
if len(in_array.shape) == 1:
79+
in_array = in_array[:, np.newaxis]
80+
if not len(in_array.shape) == 2:
81+
raise TypeError(f"Expected a 1 or 2 ndim input, got {in_array.shape} instead.")
82+
cvt = ffi.new("SDL_AudioCVT*")
83+
in_channels = in_array.shape[1]
84+
in_format = _get_format(in_array.dtype)
85+
out_sdl_format = _get_format(out_format)
86+
if _check(lib.SDL_BuildAudioCVT(cvt, in_format, in_channels, in_rate, out_sdl_format, out_channels, out_rate)) == 0:
87+
return in_array # No conversion needed.
88+
# Upload to the SDL_AudioCVT buffer.
89+
cvt.len = in_array.itemsize * in_array.size
90+
out_buffer = cvt.buf = ffi.new("uint8_t[]", cvt.len * cvt.len_mult)
91+
np.frombuffer(ffi.buffer(out_buffer[0 : cvt.len]), dtype=in_array.dtype).reshape(in_array.shape)[:] = in_array
92+
93+
_check(lib.SDL_ConvertAudio(cvt))
94+
out_array: NDArray[Any] = (
95+
np.frombuffer(ffi.buffer(out_buffer[0 : cvt.len_cvt]), dtype=out_format).reshape(-1, out_channels).copy()
96+
)
97+
return out_array
98+
99+
58100
class AudioDevice:
59101
"""An SDL audio device.
60102
61103
Open new audio devices using :any:`tcod.sdl.audio.open`.
104+
105+
Example::
106+
107+
import soundfile # pip install soundfile
108+
import tcod.sdl.audio
109+
110+
device = tcod.sdl.audio.open()
111+
sound, samplerate = soundfile.read("example_sound.wav")
112+
converted = device.convert(sound, samplerate)
113+
device.queue_audio(converted) # Play the audio syncroniously.
114+
115+
When you use this object directly the audio passed to :any:`queue_audio` is always played syncroniously.
116+
For more typical asynchronous audio you should pass an AudioDevice to :any:`BasicMixer`.
62117
"""
63118

64119
def __init__(
@@ -136,6 +191,32 @@ def _convert_array(self, samples_: ArrayLike) -> NDArray[Any]:
136191
samples = samples[:, np.newaxis]
137192
return np.ascontiguousarray(np.broadcast_to(samples, (samples.shape[0], self.channels)), dtype=self.format)
138193

194+
def convert(self, sound: ArrayLike, rate: Optional[int] = None) -> NDArray[Any]:
195+
"""Convert an audio sample into a format supported by this device.
196+
197+
Returns the converted array. This might be a reference to the input array if no conversion was needed.
198+
199+
Args:
200+
sound: An ArrayLike sound sample.
201+
rate: The samplerate of the input array.
202+
If None is given then it's assumed to be the same as the device.
203+
204+
.. versionadded:: unreleased
205+
206+
.. seealso::
207+
:any:`convert_audio`
208+
"""
209+
in_array: NDArray[Any] = np.asarray(sound)
210+
if len(in_array.shape) == 1:
211+
in_array = in_array[:, np.newaxis]
212+
return convert_audio(
213+
in_sound=sound,
214+
in_rate=rate if rate is not None else self.frequency,
215+
out_channels=self.channels if in_array.shape[1] > 1 else 1,
216+
out_format=self.format,
217+
out_rate=self.frequency,
218+
)
219+
139220
@property
140221
def _queued_bytes(self) -> int:
141222
"""The current amount of bytes remaining in the audio queue."""
@@ -196,7 +277,13 @@ def __call__(self, channel: Channel) -> None:
196277

197278

198279
class Channel:
199-
mixer: Mixer
280+
"""An audio channel for :any:`BasicMixer`. Use :any:`BasicMixer.get_channel` to initialize this object.
281+
282+
.. versionadded:: unreleased
283+
"""
284+
285+
mixer: BasicMixer
286+
"""The :any:`BasicMixer` is channel belongs to."""
200287

201288
def __init__(self) -> None:
202289
self._lock = threading.RLock()
@@ -206,6 +293,7 @@ def __init__(self) -> None:
206293

207294
@property
208295
def busy(self) -> bool:
296+
"""Is True when this channel is playing audio."""
209297
return bool(self.sound_queue)
210298

211299
def play(
@@ -216,6 +304,10 @@ def play(
216304
loops: int = 0,
217305
on_end: Optional[Callable[[Channel], None]] = None,
218306
) -> None:
307+
"""Play an audio sample, stopping any audio currently playing on this channel.
308+
309+
Parameters are the same as :any:`BasicMixer.play`.
310+
"""
219311
sound = self._verify_audio_sample(sound)
220312
with self._lock:
221313
self.volume = volume
@@ -233,6 +325,7 @@ def _verify_audio_sample(self, sample: ArrayLike) -> NDArray[Any]:
233325
return array
234326

235327
def _on_mix(self, stream: NDArray[Any]) -> None:
328+
"""Mix the next part of this channels audio into an active audio stream."""
236329
with self._lock:
237330
while self.sound_queue and stream.size:
238331
buffer = self.sound_queue[0]
@@ -249,23 +342,47 @@ def _on_mix(self, stream: NDArray[Any]) -> None:
249342
self.on_end_callback(self)
250343

251344
def fadeout(self, time: float) -> None:
252-
assert time >= 0
345+
"""Fadeout this channel then stop playing."""
253346
with self._lock:
347+
if not self.sound_queue:
348+
return
254349
time_samples = round(time * self.mixer.device.frequency) + 1
255350
buffer: NDArray[np.float32] = np.zeros((time_samples, self.mixer.device.channels), np.float32)
256351
self._on_mix(buffer)
257352
buffer *= np.linspace(1.0, 0.0, time_samples + 1, endpoint=False)[1:]
258353
self.sound_queue[:] = [buffer]
259354

260355
def stop(self) -> None:
356+
"""Stop audio on this channel."""
261357
self.fadeout(0.0005)
262358

263359

264-
class Mixer(threading.Thread):
360+
class BasicMixer(threading.Thread):
361+
"""An SDL sound mixer implemented in Python and Numpy.
362+
363+
Example::
364+
365+
import time
366+
367+
import soundfile # pip install soundfile
368+
import tcod.sdl.audio
369+
370+
mixer = tcod.sdl.audio.BasicMixer(tcod.sdl.audio.open())
371+
sound, samplerate = soundfile.read("example_sound.wav")
372+
sound = mixer.device.convert(sound, samplerate) # Needed if dtype or samplerate differs.
373+
channel = mixer.play(sound)
374+
while channel.busy:
375+
time.sleep(0.001)
376+
377+
.. versionadded:: unreleased
378+
"""
379+
265380
def __init__(self, device: AudioDevice):
381+
self.channels: Dict[Hashable, Channel] = {}
266382
assert device.format == np.float32
267383
super().__init__(daemon=True)
268384
self.device = device
385+
"""The :any:`AudioDevice`"""
269386
self._lock = threading.RLock()
270387
self._running = True
271388
self.start()
@@ -278,30 +395,30 @@ def run(self) -> None:
278395
if self.device._queued_bytes > 0:
279396
time.sleep(0.001)
280397
continue
281-
self.on_stream(buffer)
398+
self._on_stream(buffer)
282399
self.device.queue_audio(buffer)
283400
buffer[:] = self.device.silence
284401

285402
def close(self) -> None:
403+
"""Shutdown this mixer, all playing audio will be abruptly stopped."""
286404
self._running = False
287405

288-
def on_stream(self, stream: NDArray[Any]) -> None:
289-
pass
290-
406+
def get_channel(self, key: Hashable) -> Channel:
407+
"""Return a channel tied to with the given key.
291408
292-
class BasicMixer(Mixer):
293-
def __init__(self, device: AudioDevice):
294-
self.channels: Dict[Hashable, Channel] = {}
295-
super().__init__(device)
409+
Channels are initialized as you access them with this function.
410+
:any:`int` channels starting from zero are used internally.
296411
297-
def get_channel(self, key: Hashable) -> Channel:
412+
This can be used to generate a ``"music"`` channel for example.
413+
"""
298414
with self._lock:
299415
if key not in self.channels:
300416
self.channels[key] = Channel()
301417
self.channels[key].mixer = self
302418
return self.channels[key]
303419

304-
def get_free_channel(self) -> Channel:
420+
def _get_next_channel(self) -> Channel:
421+
"""Return the next available channel for the play method."""
305422
with self._lock:
306423
i = 0
307424
while True:
@@ -317,12 +434,28 @@ def play(
317434
loops: int = 0,
318435
on_end: Optional[Callable[[Channel], None]] = None,
319436
) -> Channel:
320-
channel = self.get_free_channel()
437+
"""Play a sound, return the channel the sound is playing on.
438+
439+
Args:
440+
sound: The sound to play. This a Numpy array matching the format of the loaded audio device.
441+
volume: The volume to play the sound at.
442+
You can also pass a tuple of floats to set the volume for each channel/speaker.
443+
loops: How many times to play the sound, `-1` can be used to loop the sound forever.
444+
on_end: A function to call when this sound has ended.
445+
This is called with the :any:`Channel` which was playing the sound.
446+
"""
447+
channel = self._get_next_channel()
321448
channel.play(sound, volume=volume, loops=loops, on_end=on_end)
322449
return channel
323450

324-
def on_stream(self, stream: NDArray[Any]) -> None:
325-
super().on_stream(stream)
451+
def stop(self) -> None:
452+
"""Stop playback on all channels from this mixer."""
453+
with self._lock:
454+
for channel in self.channels.values():
455+
channel.stop()
456+
457+
def _on_stream(self, stream: NDArray[Any]) -> None:
458+
"""Called to fill the audio buffer."""
326459
with self._lock:
327460
for channel in list(self.channels.values()):
328461
channel._on_mix(stream)

tests/test_sdl.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import contextlib
12
import sys
23

34
import numpy as np
45
import pytest
56

7+
import tcod.sdl.audio
68
import tcod.sdl.render
79
import tcod.sdl.sys
810
import tcod.sdl.video
@@ -74,3 +76,15 @@ def test_sdl_render_bad_types() -> None:
7476
tcod.sdl.render.Renderer(tcod.ffi.cast("SDL_Renderer*", tcod.ffi.NULL))
7577
with pytest.raises(TypeError):
7678
tcod.sdl.render.Renderer(tcod.ffi.new("SDL_Rect*"))
79+
80+
81+
def test_sdl_audio_device() -> None:
82+
with contextlib.closing(tcod.sdl.audio.open(frequency=44100, format=np.float32, channels=2, paused=True)) as device:
83+
assert device.convert(np.zeros(4, dtype=np.float32), 22050).shape[0] == 8
84+
assert device.convert(np.zeros((4, 4), dtype=np.float32)).shape == (4, 2)
85+
assert device.convert(np.zeros(4, dtype=np.int8)).shape[0] == 4
86+
device.paused = False
87+
device.paused = True
88+
assert device.queued_samples == 0
89+
with contextlib.closing(tcod.sdl.audio.BasicMixer(device)) as mixer:
90+
assert mixer

0 commit comments

Comments
 (0)