1616
1717import tcod .sdl .sys
1818from tcod .loader import ffi , lib
19- from tcod .sdl import _get_error
19+ from tcod .sdl import _check , _get_error
2020
2121
2222def _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+
58100class 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
198279class 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 )
0 commit comments