Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sound Managers #537

Draft
wants to merge 6 commits into
base: canon
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ppb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,16 @@
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

__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',
)
Expand Down
1 change: 1 addition & 0 deletions ppb/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions ppb/systems/__init__.py
Original file line number Diff line number Diff line change
@@ -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',
)
100 changes: 98 additions & 2 deletions ppb/systems/sound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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.
Expand All @@ -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(
Expand All @@ -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
34 changes: 34 additions & 0 deletions viztests/sound_manager.py
Original file line number Diff line number Diff line change
@@ -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)