import asyncio import pkgutil from asyncio import Task from pathlib import Path from typing import cast from kivy import Config from kivy.core.audio import Sound, SoundLoader from typing_extensions import Buffer from CommonClient import logger from .. import game from .item_quality import ItemQuality from .utils import make_data_directory ITEM_JINGLES = { ItemQuality.PROGUSEFUL: "8bit ProgUseful.ogg", ItemQuality.PROGRESSION: "8bit Progression.ogg", ItemQuality.USEFUL: "8bit Useful.ogg", ItemQuality.TRAP: "8bit Trap.ogg", ItemQuality.FILLER: "8bit Filler.ogg", } CONFETTI_CANNON = "APQuest Confetti Cannon.ogg" MATH_PROBLEM_STARTED_JINGLE = "APQuest Math Problem Starter Jingle.ogg" MATH_PROBLEM_SOLVED_JINGLE = "APQuest Math Problem Solved Jingle.ogg" VICTORY_JINGLE = "8bit Victory.ogg" ALL_JINGLES = [ MATH_PROBLEM_SOLVED_JINGLE, MATH_PROBLEM_STARTED_JINGLE, CONFETTI_CANNON, VICTORY_JINGLE, *ITEM_JINGLES.values(), ] BACKGROUND_MUSIC_INTRO = "APQuest Intro.ogg" BACKGROUND_MUSIC = "APQuest BGM.ogg" MATH_TIME_BACKGROUND_MUSIC = "APQuest Math BGM.ogg" ALL_BGM = [ BACKGROUND_MUSIC_INTRO, BACKGROUND_MUSIC, MATH_TIME_BACKGROUND_MUSIC, ] ALL_SOUNDS = [ *ALL_JINGLES, *ALL_BGM, ] class SoundManager: sound_paths: dict[str, Path] jingles: dict[str, Sound] bgm_songs: dict[str, Sound] active_bgm_song: str = BACKGROUND_MUSIC_INTRO current_background_music_volume: float = 1.0 background_music_target_volume: float = 0.0 background_music_task: Task[None] | None = None background_music_last_position: int = 0 volume_percentage: int = 0 game_started: bool math_trap_active: bool allow_intro_to_play: bool def __init__(self) -> None: self.extract_sounds() self.populate_sounds() self.game_started = False self.allow_intro_to_play = False self.math_trap_active = False self.ensure_config() self.background_music_task = asyncio.create_task(self.sound_manager_loop()) def ensure_config(self) -> None: Config.adddefaultsection("APQuest") Config.setdefault("APQuest", "volume", 30) self.set_volume_percentage(Config.getint("APQuest", "volume")) async def sound_manager_loop(self) -> None: while True: self.update_background_music() self.do_fade() await asyncio.sleep(0.02) def extract_sounds(self) -> None: # Kivy appears to have no good way of loading audio from bytes. # So, we have to extract it out of the .apworld first sound_paths = {} sound_directory = make_data_directory("sounds") for sound in ALL_SOUNDS: sound_file_location = sound_directory / sound sound_paths[sound] = sound_file_location if sound_file_location.exists(): continue with open(sound_file_location, "wb") as sound_file: data = pkgutil.get_data(game.__name__, f"audio/{sound}") if data is None: logger.exception(f"Unable to extract sound {sound} to Archipelago/data") continue sound_file.write(cast(Buffer, data)) self.sound_paths = sound_paths def load_audio(self, sound_filename: str) -> Sound: audio_path = self.sound_paths[sound_filename] sound_object = SoundLoader.load(str(audio_path.absolute())) sound_object.seek(0) return sound_object def populate_sounds(self) -> None: try: self.jingles = {sound_filename: self.load_audio(sound_filename) for sound_filename in ALL_JINGLES} except Exception as e: logger.exception(e) try: self.bgm_songs = {sound_filename: self.load_audio(sound_filename) for sound_filename in ALL_BGM} for bgm_song in self.bgm_songs.values(): bgm_song.loop = True bgm_song.seek(0) except Exception as e: logger.exception(e) def play_jingle(self, audio_filename: str) -> None: higher_priority_sound_is_playing = False for sound_name, sound in self.jingles.items(): if higher_priority_sound_is_playing: # jingles are ordered by priority, lower priority gets eaten sound.stop() continue if sound_name == audio_filename: sound.volume = self.volume_percentage / 100 sound.play() self.update_background_music() higher_priority_sound_is_playing = True elif sound.state == "play": higher_priority_sound_is_playing = True def update_background_music(self) -> None: self.update_active_song() if any(sound.state == "play" for sound in self.jingles.values()): self.play_background_music(False) else: if self.math_trap_active: # Don't fade math trap song, it ends up feeling better self.play_background_music(True) else: self.fade_background_music(True) def play_background_music(self, play: bool = True) -> None: if play: self.background_music_target_volume = 1 self.set_background_music_volume(1) else: self.background_music_target_volume = 0 self.set_background_music_volume(0) def set_background_music_volume(self, volume: float) -> None: self.current_background_music_volume = volume for song_filename, song in self.bgm_songs.items(): if song_filename != self.active_bgm_song: song.volume = 0 continue song.volume = volume * self.volume_percentage / 100 def fade_background_music(self, fade_in: bool = True) -> None: if fade_in: self.background_music_target_volume = 1 else: self.background_music_target_volume = 0 def set_volume_percentage(self, volume_percentage: float) -> None: volume_percentage_int = int(volume_percentage) if self.volume_percentage != volume_percentage: self.volume_percentage = volume_percentage_int Config.set("APQuest", "volume", volume_percentage_int) Config.write() self.set_background_music_volume(self.current_background_music_volume) for jingle in self.jingles.values(): jingle.volume = self.volume_percentage / 100 def do_fade(self) -> None: if self.current_background_music_volume > self.background_music_target_volume: self.set_background_music_volume(max(0.0, self.current_background_music_volume - 0.02)) if self.current_background_music_volume < self.background_music_target_volume: self.set_background_music_volume(min(1.0, self.current_background_music_volume + 0.02)) for song_filename, song in self.bgm_songs.items(): if song_filename != self.active_bgm_song: if song_filename == BACKGROUND_MUSIC: # It ends up feeling better if this just always continues playing quietly after being started. # Even "fading in at a random spot" is better than restarting the song after a jingle / math trap. if self.game_started and song.state == "stop": song.volume = self.current_background_music_volume * self.volume_percentage / 100 song.play() song.seek(0) continue song.stop() song.seek(0) continue if self.active_bgm_song == BACKGROUND_MUSIC_INTRO and not self.allow_intro_to_play: song.stop() song.seek(0) continue if self.current_background_music_volume != 0: if song.state == "stop": song.volume = self.current_background_music_volume * self.volume_percentage / 100 song.play() song.seek(0) def update_active_song(self) -> None: new_active_song = self.determine_correct_song() if new_active_song == self.active_bgm_song: return self.active_bgm_song = new_active_song # reevaluate song volumes self.set_background_music_volume(self.current_background_music_volume) def determine_correct_song(self) -> str: if not self.game_started: return BACKGROUND_MUSIC_INTRO if self.math_trap_active: return MATH_TIME_BACKGROUND_MUSIC return BACKGROUND_MUSIC