async def test_active_sounds(self, loop): tracker = SoundTracker() assert len(tracker.active_sounds) == 0 task = loop.create_task(asyncio.sleep(0.001)) tracker.register_sound(0, 1, task) assert len(tracker.active_sounds) == 1 assert tracker.active_sounds[0]
async def test_automatically_unregisters_sound_if_done(self, loop): tracker = SoundTracker() task = loop.create_task(asyncio.sleep(0.001)) tracker.register_sound(0, 1, task) key = tracker._get_sound_key(0, 1) assert tracker.sound_to_task[tracker._get_sound_key(0, 1)] == task await task assert key not in tracker.sound_to_task
async def test_cancel_sound_cancels_task(self, loop): tracker = SoundTracker() task = loop.create_task(asyncio.sleep(0.001)) key = tracker._get_sound_key(0, 1) tracker.register_sound(0, 1, task) assert key in tracker.sound_to_task await tracker.cancel_sound(0, 1) assert task.cancelled() assert key not in tracker.sound_to_task
class SoundManager: SLEEP_TIME = 0.01 def __init__(self, config: Dict, callback_fn: Callable = None): """ Initializes a `SoundManager` instance. The `config` parameter is expected to be a dictionary with the following keys: - "volume": a value between 0 and 1 where 1 is maximum volume and 0 is no volume - "directory": the default directory to use if no directory is further specified (Optional) - "sort": whether to sort the groups alphabetically (Optional, default=True) - "groups": a list of configs for `SoundGroup` instances. See `SoundGroup` class for more information The `callback_fn` is an async function that should accept the following optional keyword arguments: - "action": value of type `SoundActions` - "request": the request that caused the action - "sound_info": instance of type `SoundCallbackInfo` (`None` if action not related to particular sound) - "master_volume": float (0 to 1) :param config: `dict` :param callback_fn: function to call when the active sounds change """ pygame.mixer.init() self.volume = float(config["volume"]) self.directory = config["directory"] if "directory" in config else None groups = [SoundGroup(sound_group_config) for sound_group_config in config["groups"]] if "sort" not in config or ("sort" in config and config["sort"]): groups = sorted(groups, key=lambda x: x.name) self.groups = tuple(groups) self.callback_handler = SoundCallbackHandler(callback_fn=callback_fn) self.tracker = SoundTracker() self.players = {} SoundChecker(self.groups, self.directory).do_all_checks() def _get_player_key(self, group_index: int, sound_index: int): """ Returns the dictionary key for the dictionary of player instances at `self.players`. """ return f"{group_index}-{sound_index}" def _get_sound_from_player_key(self, player_key: str): """ Returns the sound instance for the player key. """ components = player_key.split("-") group_index = int(components[0]) sound_index = int(components[1]) return self.groups[group_index].sounds[sound_index] @property def currently_playing(self) -> List[SoundCallbackInfo]: """ Returns a list of the sounds that are currently being played. """ active_sounds = self.tracker.active_sounds sounds_being_played = [] for active_sound in active_sounds: sounds_being_played.append( self._get_sound_callback_info(active_sound.group_index, active_sound.sound_index) ) return sounds_being_played async def cancel_sound(self, group_index: int, sound_index: int): """ If the sound is currently being played, the replay will be cancelled. """ await self.tracker.cancel_sound(group_index, sound_index) async def play_sound(self, request: Request, group_index: int, sound_index: int): """ Creates an asynchronous task to play the sound from the given group at the given index. If the sound is already being played, it will be cancelled and restarted. """ await self.cancel_sound(group_index, sound_index) loop = asyncio.get_event_loop() task = loop.create_task(self._play_repeating_sound(request, group_index, sound_index)) self.tracker.register_sound(group_index, sound_index, task) await asyncio.sleep(self.SLEEP_TIME) # Return to the event loop that will start the task async def _play_repeating_sound(self, request: Request, group_index: int, sound_index: int): """ Plays the given sound. Repeats the sound if its `repeat_count` attribute is greater than one after waiting for `repeat_delay` ms. """ group = self.groups[group_index] sound = group.sounds[sound_index] sound_info = self._get_sound_callback_info(group_index, sound_index) try: await self.callback_handler(SoundActions.START, request, sound_info, self.volume) logger.info(f"Playing sound on repeat: {sound.name}") repeat_count = 0 while True: await self._play_sound(group_index, sound_index) repeat_count += 1 if sound.repeat_count != 0 and repeat_count >= sound.repeat_count: break delay = sound.repeat_delay if delay == 0: continue await asyncio.sleep(delay / 1000.0) await self.callback_handler(SoundActions.FINISH, request, sound_info, self.volume) logger.info(f"Finished sound on repeat: {sound.name}") except asyncio.CancelledError: await self.callback_handler(SoundActions.STOP, request, sound_info, self.volume) logger.info(f"Cancelled sound on repeat: {sound.name}") async def _play_sound(self, group_index: int, sound_index: int): """ Plays the given sound. """ group = self.groups[group_index] sound = group.sounds[sound_index] try: logger.info(f"Playing sound file for: {sound.name}") await self._play_sound_file(group_index, sound_index) logger.info(f"Finished sound file for: {sound.name}") except asyncio.CancelledError: logger.info(f"Cancelled sound file for: {sound.name}") raise async def _play_sound_file(self, group_index: int, sound_index: int): """ Plays a sound file from the given group and sound. """ group = self.groups[group_index] sound = group.sounds[sound_index] root_directory = utils.get_sound_root_directory(group, sound, default_dir=self.directory) sound_file = random.choice(sound.files) pygame_sound = None try: pygame_sound = pygame.mixer.Sound(os.path.join(root_directory, sound_file.file)) self.players[self._get_player_key(group_index, sound_index)] = pygame_sound pygame_sound.set_volume(self.volume * sound.volume) if sound_file.end_at is not None: pygame_sound.play(maxtime=sound_file.end_at) await asyncio.sleep(sound_file.end_at / 1000) else: pygame_sound.play() await asyncio.sleep(pygame_sound.get_length()) except asyncio.CancelledError: if pygame_sound is not None: pygame_sound.stop() del self.players[self._get_player_key(group_index, sound_index)] raise async def set_master_volume(self, request: Request, volume: float): """ Sets the master volume for the sounds. If sounds are currently being played, the volume of the players is updated. :param request: the request that caused this action :param volume: new volume, a value between 0 (mute) and 1 (max) """ self.volume = volume for player_key in self.players: sound = self._get_sound_from_player_key(player_key) self.players[player_key].set_volume(self.volume * sound.volume) logger.info(f"Changed sound master volume to {volume}") await self.callback_handler(SoundActions.MASTER_VOLUME, request, None, self.volume) async def set_sound_volume(self, request: Request, group_index: int, sound_index: int, volume: float): """ Sets the volume for a specific sound. If the sound is currently being played, the volume of the player is updated. :param request: the request that caused this action :param group_index: index of the group of the sound :param sound_index: index of the sound in the group :param volume: new volume, a value between 0 (mute) and 1 (max) """ group = self.groups[group_index] sound = group.sounds[sound_index] sound.volume = volume sound_info = self._get_sound_callback_info(group_index, sound_index) player_key = self._get_player_key(group_index, sound_index) if player_key in self.players: self.players[player_key].set_volume(self.volume * sound.volume) logger.info(f"Changed sound volume for group={group_index}, sound={sound_index} to {volume}") await self.callback_handler(SoundActions.VOLUME, request, sound_info, self.volume) async def set_sound_repeat_count(self, request: Request, group_index: int, sound_index: int, repeat_count: int): """ Sets the repeat count attribute for a specific sound. :param request: the request that caused this action :param group_index: index of the group of the sound :param sound_index: index of the sound in the group :param repeat_count: number of times to repeat the sound (includes initial play, 0 means infinity) """ group = self.groups[group_index] sound = group.sounds[sound_index] sound.repeat_count = repeat_count sound_info = self._get_sound_callback_info(group_index, sound_index) logger.info(f"Changed sound repeat count for group={group_index}, sound={sound_index} to {repeat_count}") await self.callback_handler(SoundActions.REPEAT_COUNT, request, sound_info, self.volume) async def set_sound_repeat_delay(self, request: Request, group_index: int, sound_index: int, repeat_delay: str): """ Sets the repeat delay for a specific sound. The `repeat_delay` argument is expected to be a string of either a single integer or an interval of the form `<int>-<int>`. :param request: the request that caused this action :param group_index: index of the group of the sound :param sound_index: index of the sound in the group :param repeat_delay: new value of the repeat delay """ group = self.groups[group_index] sound = group.sounds[sound_index] try: sound.repeat_delay = repeat_delay logger.info(f"Changed sound repeat delay for group={group_index}, sound={sound_index} to {repeat_delay}") except ValueError: logger.info( f"Failed to change sound repeat delay for group={group_index}, sound={sound_index} to " f"{repeat_delay}" ) sound_info = self._get_sound_callback_info(group_index, sound_index) await self.callback_handler(SoundActions.REPEAT_DELAY, request, sound_info, self.volume) def _get_sound_callback_info(self, group_index, sound_index): group = self.groups[group_index] sound = group.sounds[sound_index] return SoundCallbackInfo( group_index, group.name, sound_index, sound.name, sound.volume, sound.repeat_count, sound.repeat_delay_config, ) def __eq__(self, other): if isinstance(other, SoundManager): attrs_are_the_same = self.volume == other.volume and self.directory == other.directory if not attrs_are_the_same: return False if len(self.groups) != len(other.groups): return False for my_group, other_group in zip(self.groups, other.groups): if my_group != other_group: return False return True return False
async def test_unregister_sound_raises_if_task_not_done(self, loop): tracker = SoundTracker() task = loop.create_task(asyncio.sleep(0.001)) tracker.register_sound(0, 1, task) with pytest.raises(RuntimeError): tracker._unregister_sound(0, 1, None)
async def test_register_sound_raises_if_task_done(self, loop): tracker = SoundTracker() task = loop.create_task(asyncio.sleep(0.001)) await task with pytest.raises(RuntimeError): tracker.register_sound(0, 0, task)
async def test_register_sound(self, loop): tracker = SoundTracker() task = loop.create_task(asyncio.sleep(0.001)) tracker.register_sound(0, 1, task) assert tracker.sound_to_task[tracker._get_sound_key(0, 1)] == task