import ctypes
import io
import logging
import time
from sdl2 import (
AUDIO_S16SYS, rw_from_object,
)
from sdl2.sdlmixer import (
# Errors, https://www.libsdl.org/projects/SDL_mixer/docs/SDL_mixer_7.html#SEC7
Mix_GetError,
# Support library loading https://www.libsdl.org/projects/SDL_mixer/docs/SDL_mixer_7.html#SEC7
Mix_Init, Mix_Quit, MIX_INIT_FLAC, MIX_INIT_MOD, MIX_INIT_MP3, MIX_INIT_OGG,
# Mixer init https://www.libsdl.org/projects/SDL_mixer/docs/SDL_mixer_7.html#SEC7
Mix_OpenAudio, Mix_CloseAudio, Mix_QuerySpec,
# Samples https://www.libsdl.org/projects/SDL_mixer/docs/SDL_mixer_16.html#SEC16
Mix_LoadWAV_RW, Mix_FreeChunk, Mix_VolumeChunk,
# Channels https://www.libsdl.org/projects/SDL_mixer/docs/SDL_mixer_25.html#SEC25
Mix_AllocateChannels, Mix_PlayChannel, Mix_ChannelFinished, channel_finished,
# Other
MIX_MAX_VOLUME,
)
from ppb import assetlib
from ppb.systems.sdl_utils import SdlSubSystem, mix_call, SdlMixerError
from ppb.utils import LoggingMixin
__all__ = ('SoundController', 'Sound')
logger = logging.getLogger(__name__)
def query_spec():
"""
Helpful wrapper around Mix_QuerySpec()
"""
frequency = ctypes.c_int()
format = ctypes.c_uint16()
channels = ctypes.c_int()
count = mix_call(
Mix_QuerySpec,
ctypes.byref(frequency),
ctypes.byref(format),
ctypes.byref(channels),
_check_error=lambda rv: rv == 0 and Mix_GetError(),
)
return count, frequency, format, channels
[docs]
class Sound(assetlib.Asset):
# This is wrapping a ctypes.POINTER(Mix_Chunk)
def background_parse(self, data):
# Band-aid over some synchronization issues
# https://github.com/ppb/pursuedpybear/issues/619
while not any(query_spec()):
time.sleep(0)
file = rw_from_object(io.BytesIO(data))
# ^^^^ is a pure-python emulation, does not need cleanup.
return mix_call(
Mix_LoadWAV_RW, file, False,
_check_error=lambda rv: not rv
)
def free(self, object, _Mix_FreeChunk=Mix_FreeChunk):
# ^^^ is a way to keep required functions during interpreter cleanup
# "It's a bad idea to free a chunk that is still being played..."
# This should only be called when all references are dropped.
# (This means that SoundController needs to keep a reference while playing.)
if object: # Check that the pointer isn't null
_Mix_FreeChunk(object) # Can't fail
# object.contents = None
# Can't actually nullify the pointer. Good thing this is __del__.
@property
def volume(self):
"""
The volume setting of this chunk, from 0.0 to 1.0
"""
return mix_call(Mix_VolumeChunk, self.load(), -1) / MIX_MAX_VOLUME
@volume.setter
def volume(self, value):
mix_call(Mix_VolumeChunk, self.load(), int(value * MIX_MAX_VOLUME))
@channel_finished
def _filler_channel_finished(channel):
pass
class SoundController(SdlSubSystem, LoggingMixin):
_finished_callback = None
def __init__(self, **kw):
super().__init__(**kw)
self._currently_playing = {} # Track sound assets so they don't get freed early
@property
def allocated_channels(self):
"""
The number of channels currently allocated by SDL_mixer.
Seems to default to 8.
"""
return mix_call(Mix_AllocateChannels, -1)
@allocated_channels.setter
def allocated_channels(self, value):
mix_call(Mix_AllocateChannels, value)
def __enter__(self):
super().__enter__()
mix_call(
Mix_OpenAudio,
44100, # Sample frequency, 44.1 kHz is CD quality
AUDIO_S16SYS, # Audio, 16-bit, system byte order. IDK is signed makes a difference
2, # Number of output channels, 2=stereo
4096, # Chunk size. TBH, this is a magic knob number.
# ^^^^ Smaller is more CPU, larger is less responsive.
# A lot of the performance-related recommendations are so dated I'm
# not sure how much difference it makes.
_check_error=lambda rv: rv == -1
)
mix_call(Mix_Init, MIX_INIT_FLAC | MIX_INIT_MOD | MIX_INIT_MP3 | MIX_INIT_OGG)
logger.debug("SoundController")
logger.debug(query_spec())
self.allocated_channels = 16
# Register callback, keeping reference for later cleanup
self._finished_callback = channel_finished(self._on_channel_finished)
mix_call(Mix_ChannelFinished, self._finished_callback)
def __exit__(self, *exc):
# Unregister callback and release reference
mix_call(Mix_ChannelFinished, _filler_channel_finished)
self._finished_callback = None
# Cleanup SDL_mixer
mix_call(Mix_CloseAudio)
mix_call(Mix_Quit)
super().__exit__(*exc)
def on_play_sound(self, event, signal):
sound = event.sound
chunk = event.sound.load()
try:
channel = mix_call(
Mix_PlayChannel,
-1, # Auto-pick channel
chunk,
0, # Do not repeat
_check_error=lambda rv: rv == -1
)
except SdlMixerError as e:
if not str(e).endswith("No free channels available"):
raise
self.logger.warn("Attempted to play sound, but there were no available channels.")
else:
self._currently_playing[channel] = sound # Keep reference of playing asset
def _on_channel_finished(self, channel_num):
# "NEVER call SDL_Mixer functions, nor SDL_LockAudio, from a callback function."
self._currently_playing[channel_num] = None # Release the asset that was playing