diff --git a/ppb/__init__.py b/ppb/__init__.py index a62c83ea..965a4df6 100644 --- a/ppb/__init__.py +++ b/ppb/__init__.py @@ -45,6 +45,7 @@ from ppb.sprites import Sprite from ppb.systems import Image from ppb.systems import Sound +from ppb.systems import SoundManager from ppb.systems import Font from ppb.systems import Text from ppb.utils import get_time @@ -52,7 +53,8 @@ __all__ = ( # Shortcuts 'Vector', 'BaseScene', 'Circle', 'Image', 'Sprite', 'RectangleSprite', - 'Square', 'Sound', 'Triangle', 'events', 'Font', 'Text', 'directions', + 'Square', 'Sound', 'SoundManager', 'Triangle', 'events', 'Font', 'Text', + 'directions', # Local stuff 'run', 'make_engine', ) diff --git a/ppb/events.py b/ppb/events.py index 40a91d2f..fa15fffa 100644 --- a/ppb/events.py +++ b/ppb/events.py @@ -348,6 +348,7 @@ class PlaySound: signal(PlaySound(my_sound)) """ sound: 'ppb.assetlib.Asset' #: A :class:`~ppb.systems.sound.Sound` asset. + manager: 'ppb.SoundManager' = None @dataclass diff --git a/ppb/systems/__init__.py b/ppb/systems/__init__.py index 17c1384a..e485b5a2 100644 --- a/ppb/systems/__init__.py +++ b/ppb/systems/__init__.py @@ -1,10 +1,10 @@ from ppb.systems.inputs import EventPoller from ppb.systems.renderer import Renderer, Image from ppb.systems.clocks import Updater -from ppb.systems.sound import SoundController, Sound +from ppb.systems.sound import SoundController, Sound, SoundManager from ppb.systems.text import Font, Text __all__ = ( 'EventPoller', 'Renderer', 'Image', 'Updater', 'SoundController', 'Sound', - 'Font', 'Text', + 'SoundManager', 'Font', 'Text', ) diff --git a/ppb/systems/sound.py b/ppb/systems/sound.py index 7c541cba..68a04e3c 100644 --- a/ppb/systems/sound.py +++ b/ppb/systems/sound.py @@ -13,15 +13,17 @@ 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, + Mix_Volume, Mix_HaltChannel, Mix_Pause, Mix_Resume, # Other MIX_MAX_VOLUME, ) from ppb import assetlib +from ppb.gomlib import GameObject from ppb.systems.sdl_utils import SdlSubSystem, mix_call, SdlMixerError from ppb.utils import LoggingMixin -__all__ = ('SoundController', 'Sound') +__all__ = ('SoundController', 'Sound', 'SoundManager') class Sound(assetlib.Asset): @@ -63,12 +65,86 @@ def _filler_channel_finished(channel): pass +class SoundManager(GameObject): + """ + Allows dynamic manipulation of a sound + """ + __channel = None + + def _set_channel(self, channel_num): + """ + Called by SoundController to give the manager the SDL mixer channel + number, or None if this manager is no longer managing a channel + """ + if channel_num is None: + del self.__channel + else: + self.__channel = channel_num + + @property + def volume(self): + """ + How load to play the sound, from 0 to 1 + """ + if self.__channel is not None: + raw_value = mix_call(Mix_Volume, self.__channel, -1) + return raw_value / MIX_MAX_VOLUME + + @volume.setter + def volume(self, value): + if self.__channel is not None: + raw_value = int(value * MIX_MAX_VOLUME) + mix_call(Mix_Volume, self.__channel, raw_value) + + def stop(self): + """ + Stop playback completely, as if the sound had ended. + + Does nothing if already stopped. + """ + if self.__channel is not None: + mix_call(Mix_HaltChannel, self.__channel) + + def pause(self): + """ + Pause playback to be continued later. + + If already paused, does nothing. + """ + if self.__channel is not None: + mix_call(Mix_Pause, self.__channel) + + def resume(self): + """ + Continue playback. + + If already playing, does nothing. + """ + if self.__channel is not None: + mix_call(Mix_Pause, self.__channel) + + def on_finished(self, event, signal): + """ + Called when this sound has finished playing. The event is empty. + + Override me. + """ + + +class Finished: + """ + An empty event bag + """ + + 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 + self._managers = {} # The managers for the various tracks + self._managers_to_evict = [] # Keeps managers around @property def allocated_channels(self): @@ -88,7 +164,7 @@ def __enter__(self): 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 + AUDIO_S16SYS, # Audio, 16-bit, system byte order. IDK if 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. @@ -115,6 +191,7 @@ def __exit__(self, *exc): def on_play_sound(self, event, signal): sound = event.sound chunk = event.sound.load() + manager = event.manager try: channel = mix_call( @@ -130,7 +207,26 @@ def on_play_sound(self, event, signal): self.logger.warn("Attempted to play sound, but there were no available channels.") else: self._currently_playing[channel] = sound # Keep reference of playing asset + self._managers[channel] = manager + self.children.add(manager) + if manager is not None: + manager._set_channel(channel) 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 + if self._managers[channel_num]: + manager = self._managers[channel_num] + if manager is not None: + manager._set_channel(None) + self._managers[channel_num] = None + self._managers_to_evict.append(manager) + self.children.remove(manager) + self.engine.signal(Finished(), targets=[manager]) + + def on_idle(self, event, signal): + # Any previously triggered Finished events should have been dispatched, + # so we're free to discard these managers + if self._managers_to_evict: + self._managers_to_evict = [] + # Well, that was easy diff --git a/viztests/sound_manager.py b/viztests/sound_manager.py new file mode 100644 index 00000000..c4f85a5c --- /dev/null +++ b/viztests/sound_manager.py @@ -0,0 +1,34 @@ +""" +Tests that sound managers work. + +NOTE: Does not open a window. +""" +import ppb + + +class ExitOnFinished(ppb.SoundManager): + time_passed = 0 + + # def on_update(self, event, signal): + # self.time_passed += event.time_delta + # if self.time_passed > 1: + # self.resume() + # elif self.time_passed > 0.5: + # self.pause() + + def on_finished(self, event, signal): + print(f"Sound finished {event=} {signal}") + signal(ppb.events.Quit()) + + +class Scene(ppb.BaseScene): + sound = ppb.Sound("laser1.ogg") + running = 0 + lifespan = 2 + + def on_scene_started(self, event, signal): + print("Scene start") + signal(ppb.events.PlaySound(sound=self.sound, manager=ExitOnFinished())) + + +ppb.run(starting_scene=Scene)