# Copyright 2004-2019 Tom Rothamel # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, # including without limitation the rights to use, copy, modify, merge, # publish, distribute, sublicense, and/or sell copies of the Software, # and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # The latest and greatest Ren'Py audio system. # Invariants: The periodic callback assumes pcm_ok. If we don't have # at least pcm_ok, we have no sound whatsoever. from __future__ import print_function import renpy.audio # @UnusedImport import renpy.display # @UnusedImport from renpy import six import time import pygame_sdl2 # @UnusedImport import os import re import threading import sys import io # Import the appropriate modules, or set them to None if we cannot. disable = os.environ.get("RENPY_DISABLE_SOUND", "") if not disable: import renpy.audio.renpysound as renpysound else: renpysound = None # This is True if we were able to sucessfully enable the pcm audio. pcm_ok = None unique = time.time() serial = 0 def get_serial(): """ Gets a globally unique serial number for each music change. """ global serial serial += 1 return (unique, serial) def load(fn): """ Returns a file-like object for the given filename. """ rv = renpy.loader.load(fn) return rv class AudioData(unicode): """ :doc: audio This class wraps a bytes object containing audio data, so it can be passed to the audio playback system. The audio data should be contained in some format Ren'Py supports. (For examples RIFF WAV format headers, not unadorned samples.) `data` A bytes object containing the audio file data. `filename` A synthetic filename associated with this data. It can be used to suggest the format `data` is in, and is reported as part of error messages. Once created, this can be used wherever an audio filename is allowed. For example:: define audio.easteregg = AudioData(b'...', 'sample.wav') play sound easteregg """ def __new__(cls, data, filename): rv = unicode.__new__(cls, filename) rv.data = data return rv def __init__(self, data, filename): pass def __reduce__(self): return(AudioData, (self.data, unicode(self))) class QueueEntry(object): """ A queue entry object. """ def __init__(self, filename, fadein, tight, loop): self.filename = filename self.fadein = fadein self.tight = tight self.loop = loop class MusicContext(renpy.python.RevertableObject): """ This stores information about the music in a game. This object participates in rollback, so when the user goes back in time, all the values get reverted as well. """ __version__ = 0 pause = False def __init__(self): super(MusicContext, self).__init__() # The time this channel was last ordered panned. self.pan_time = None # The pan this channel was ordered to. self.pan = 0 # The time the secondary volume was last ordered changed. self.secondary_volume_time = None # The secondary volume. self.secondary_volume = 1.0 # The time the channel was ordered last changed. self.last_changed = 0 # Was the last change tight? self.last_tight = False # What were the filenames we were ordered to loop last? self.last_filenames = [ ] # Should we force stop this channel? self.force_stop = False # Should we pause this channel? self.pause = False def copy(self): """ Returns a shallow copy of this context. """ rv = MusicContext() rv.__dict__.update(self.__dict__) return rv # The next channel number to be assigned. next_channel_number = 0 # the lock that mediates between the periodic and main threads. lock = threading.RLock() class Channel(object): """ This stores information about the currently-playing music. """ def __init__(self, name, default_loop, stop_on_mute, tight, file_prefix, file_suffix, buffer_queue, movie, framedrop): # The name assigned to this channel. This is used to look up # information about the channel in the MusicContext object. self.name = name # The number this channel has been assigned, or None if we've yet # to assign a number to the channel. We only assign a channel # number when there's an operation on the channel other than # setting the mixer. self._number = None # The name of the mixer this channel uses. Set below, as there's # no good default. self.mixer = None # The volume imparted to this channel, as a fraction of the # mixer volume. self.chan_volume = 1.0 # The actual volume we imparted onto this channel. self.actual_volume = 1.0 # The QueueEntries queued for playback on this channel. self.queue = [ ] # If true, we loop the music. This entails adding everything in this # variable to the end of the queue. self.loop = [ ] # Are we playing anything at all? self.playing = False # If True, we'll wait for this channel to stop before # loading in more music from the queue. (This is necessary to # do a synchro-start.) self.wait_stop = False # If True, then this channel will participate in a synchro-start # once all channels are ready. self.synchro_start = False # The time the music in this channel was last changed. self.last_changed = 0 # The callback that is called if the queue becomes empty. self.callback = None # The time this channel was last panned. self.pan_time = None # The time the secondary volume of this channel was last set. self.secondary_volume_time = None # Should we stop playing on mute? self.stop_on_mute = stop_on_mute # Is this channel tight? self.tight = tight # The number of items in the queue that should be kept # on queue clear. self.keep_queue = 0 # A prefix and suffix that are used to create the full filenames. self.file_prefix = file_prefix self.file_suffix = file_suffix # Should we buffer upcoming music/video in the queue? self.buffer_queue = buffer_queue # Are we paused? self.paused = None if default_loop is None: # By default, should we loop the music? self.default_loop = True # Was this set explicitly? self.default_loop_set = False else: self.default_loop = default_loop self.default_loop_set = True # Is this a movie channel? if movie: if framedrop: self.movie = renpy.audio.renpysound.DROP_VIDEO else: self.movie = renpy.audio.renpysound.NODROP_VIDEO else: self.movie = renpy.audio.renpysound.NO_VIDEO def get_number(self): """ Returns the number of this channel, allocating a number if that proves necessary. """ global next_channel_number rv = self._number if rv is None: rv = self._number = next_channel_number next_channel_number += 1 return rv number = property(get_number) def get_context(self): """ Returns the MusicContext corresponding to this channel, taken from the context object. Allocates a MusicContext if none exists. """ mcd = renpy.game.context().music rv = mcd.get(self.name) if rv is None: rv = mcd[self.name] = MusicContext() return rv context = property(get_context) def split_filename(self, filename, looped): """ Splits a filename into a filename, start time, and end time. """ def exception(msg): return Exception("Parsing audio spec {!r}: {}.".format(filename, msg)) def expect_float(): if not spec: raise exception("expected float at end.") v = spec.pop(0) try: return float(v) except: raise exception("expected float, got {!r}.".format(v)) m = re.match(r'<(.*)>(.*)', filename) if not m: return filename, 0, -1 spec = m.group(1) fn = m.group(2) spec = spec.split() start = 0 loop = None end = -1 while spec: clause = spec.pop(0) if clause == "from": start = expect_float() elif clause == "to": end = expect_float() elif clause == "loop": loop = expect_float() elif clause == "silence": end = expect_float() fn = "_silence.ogg" else: raise exception("expected keyword, got {!r}.".format(clause)) if (loop is not None) and looped: start = loop return fn, start, end def periodic(self): """ This is the periodic call that causes this channel to load new stuff into its queues, if necessary. """ # Update the channel volume. vol = self.chan_volume * renpy.game.preferences.volumes.get(self.mixer, 1.0) if vol != self.actual_volume: renpysound.set_volume(self.number, vol) self.actual_volume = vol # This should be set from something that checks to see if our # mixer is muted. force_stop = self.context.force_stop or (renpy.game.preferences.mute.get(self.mixer, False) and self.stop_on_mute) if self.playing and force_stop: renpysound.stop(self.number) self.playing = False if force_stop: self.wait_stop = False if self.loop: self.queue = self.queue[-len(self.loop):] else: self.queue = [ ] return topq = None # This has been modified so we only queue a single sound file # per call, to prevent memory leaks with really short sound # files. So this loop will only execute once, in practice. while True: depth = renpysound.queue_depth(self.number) if depth == 0: self.wait_stop = False self.playing = False # Need to check this, so we don't do pointless work. if not self.queue: break # If the pcm_queue is full, then we can't queue # anything, regardless of if it is midi or pcm. if depth >= 2: break # If we can't buffer things, and we're playing something # give up here. if not self.buffer_queue and depth >= 1: break # We can't queue anything if the depth is > 0 and we're # waiting for a synchro_start. if self.synchro_start and depth: break # If the queue is full, return. if renpysound.queue_depth(self.number) >= 2: break # Otherwise, we might be able to enqueue something. topq = self.queue.pop(0) # Blacklist of old file formats we used to support, but we now # ignore. lfn = topq.filename.lower() + self.file_suffix.lower() for i in (".mod", ".xm", ".mid", ".midi"): if lfn.endswith(i): topq = None if not topq: continue try: filename, start, end = self.split_filename(topq.filename, topq.loop) if (end >= 0) and ((end - start) <= 0) and self.queue: continue if isinstance(topq.filename, AudioData): topf = io.BytesIO(topq.filename.data) else: topf = load(self.file_prefix + filename + self.file_suffix) renpysound.set_video(self.number, self.movie) if depth == 0: renpysound.play(self.number, topf, topq.filename, paused=self.synchro_start, fadein=topq.fadein, tight=topq.tight, start=start, end=end) else: renpysound.queue(self.number, topf, topq.filename, fadein=topq.fadein, tight=topq.tight, start=start, end=end) self.playing = True except: # If playing failed, remove topq.filename from self.loop # so we don't keep trying. while topq.filename in self.loop: self.loop.remove(topq.filename) if renpy.config.debug_sound and not renpy.game.after_rollback: raise else: return break # Empty queue? if not self.queue: # Re-loop: if self.loop: for i in self.loop: if topq is not None: newq = QueueEntry(i, 0, topq.tight, True) else: newq = QueueEntry(i, 0, False, True) self.queue.append(newq) # Try callback: elif self.callback: self.callback() # E1102 want_pause = self.context.pause or global_pause if self.paused != want_pause: if want_pause: self.pause() else: self.unpause() self.paused = want_pause def dequeue(self, even_tight=False): """ Clears the queued music. If the first item in the queue has not been started, then it is left in the queue unless all is given. """ with lock: self.queue = self.queue[:self.keep_queue] self.loop = [ ] if not pcm_ok: return if self.keep_queue == 0: renpysound.dequeue(self.number, even_tight) self.wait_stop = False self.synchro_start = False def interact(self): """ Called (mostly) once per interaction. """ self.keep_queue = 0 if pcm_ok: if self.pan_time != self.context.pan_time: self.pan_time = self.context.pan_time renpysound.set_pan(self.number, self.context.pan, 0) if self.secondary_volume_time != self.context.secondary_volume_time: self.secondary_volume_time = self.context.secondary_volume_time renpysound.set_secondary_volume(self.number, self.context.secondary_volume, 0) if not self.queue and self.callback: self.callback() # E1102 def fadeout(self, secs): """ Causes the playing music to be faded out for the given number of seconds. Also clears any queued music. """ with lock: self.keep_queue = 0 self.dequeue() if not pcm_ok: return if secs == 0: renpysound.stop(self.number) else: renpysound.fadeout(self.number, secs) def enqueue(self, filenames, loop=True, synchro_start=False, fadein=0, tight=None, loop_only=False): with lock: for filename in filenames: filename, _, _ = self.split_filename(filename, False) renpy.game.persistent._seen_audio[filename] = True # @UndefinedVariable if not pcm_ok: return if not loop_only: if tight is None: tight = self.tight self.keep_queue += 1 for filename in filenames: qe = QueueEntry(filename, int(fadein * 1000), tight, False) self.queue.append(qe) # Only fade the first thing in. fadein = 0 self.wait_stop = synchro_start self.synchro_start = synchro_start if loop: self.loop = list(filenames) else: self.loop = [ ] def get_playing(self): if not pcm_ok: return None rv = renpysound.playing_name(self.number) with lock: rv = renpysound.playing_name(self.number) if rv is None and self.queue: rv = self.queue[0].filename if rv is None and self.loop: rv = self.loop[0] return rv def set_volume(self, volume): self.chan_volume = volume def get_pos(self): if not pcm_ok: return -1 return renpysound.get_pos(self.number) def get_duration(self): if not pcm_ok: return 0.0 return renpysound.get_duration(self.number) def set_pan(self, pan, delay): with lock: now = get_serial() self.context.pan_time = now self.context.pan = pan if pcm_ok: self.pan_time = self.context.pan_time renpysound.set_pan(self.number, self.context.pan, delay) def set_secondary_volume(self, volume, delay): with lock: now = get_serial() self.context.secondary_volume_time = now self.context.secondary_volume = volume if pcm_ok: self.secondary_volume_time = self.context.secondary_volume_time renpysound.set_secondary_volume(self.number, self.context.secondary_volume, delay) def pause(self): with lock: renpysound.pause(self.number) def unpause(self): with lock: renpysound.unpause(self.number) def read_video(self): if pcm_ok: return renpysound.read_video(self.number) return None def video_ready(self): if not pcm_ok: return 1 return renpysound.video_ready(self.number) # Use unconditional imports so these files get compiled during the build # process. try: from renpy.audio.androidhw import AndroidVideoChannel except: pass try: from renpy.audio.ioshw import IOSVideoChannel except: pass # A list of channels we know about. all_channels = [ ] # A map from channel name to Channel object. channels = { } def register_channel(name, mixer=None, loop=None, stop_on_mute=True, tight=False, file_prefix="", file_suffix="", buffer_queue=True, movie=False, framedrop=True): """ :doc: audio This registers a new audio channel named `name`. Audio can then be played on the channel by supplying the channel name to the play or queue statements. `mixer` The name of the mixer the channel uses. By default, Ren'Py knows about the "music", "sfx", and "voice" mixers. Using other names is possible, but may require changing the preferences screens. `loop` If true, sounds on this channel loop by default. `stop_on_mute` If true, music on the channel is stopped when the channel is muted. `tight` If true, sounds will loop even when fadeout is occurring. This should be set to True for a sound effects or seamless music channel, and False if the music fades out on its own. `file_prefix` A prefix that is prepended to the filenames of the sound files being played on this channel. `file_suffix` A suffix that is appended to the filenames of the sound files being played on this channel. `buffer_queue` Should we buffer the first second or so of a queued file? This should be True for audio, and False for movie playback. `movie` If true, this channel will be set up to play back videos. `framedrop` This controls what a video does when lagging. If true, frames will be dropped to keep up with realtime and the soundtrack. If false, Ren'Py will display frames late rather than dropping them. """ if name == "movie": movie = True if not renpy.game.context().init_phase and (" " not in name): raise Exception("Can't register channel outside of init phase.") if renpy.android and renpy.config.hw_video and name == "movie": c = AndroidVideoChannel(name, default_loop=loop, file_prefix=file_prefix, file_suffix=file_suffix) elif renpy.ios and renpy.config.hw_video and name == "movie": c = IOSVideoChannel(name, default_loop=loop, file_prefix=file_prefix, file_suffix=file_suffix) else: c = Channel(name, loop, stop_on_mute, tight, file_prefix, file_suffix, buffer_queue, movie=movie, framedrop=framedrop) c.mixer = mixer all_channels.append(c) channels[name] = c def alias_channel(name, newname): if not renpy.game.context().init_phase: raise Exception("Can't alias channel outside of init phase.") c = get_channel(name) channels[newname] = c def get_channel(name): rv = channels.get(name, None) if rv is None: # Do we want to auto-define a new channel? if name in renpy.config.auto_channels: i = 0 while True: c = get_channel("{} {}".format(name, i)) if not c.get_playing(): return c # Limit to one channel while skipping, to prevent sounds from # piling up. if renpy.config.skipping: return c i += 1 # One of the channels that was just defined. elif " " in name: base = name.split()[0] mixer, file_prefix, file_suffix = renpy.config.auto_channels[base] register_channel( name, loop=False, mixer=mixer, file_prefix=file_prefix, file_suffix=file_suffix, ) return channels[name] else: raise Exception("Audio channel %r is unknown." % name) return rv def set_force_stop(name, value): get_channel(name).context.force_stop = value # The thread that call periodic. periodic_thread = None # True if we need it to quit. periodic_thread_quit = True def init(): global periodic_thread global periodic_thread_quit global pcm_ok global mix_ok if not renpy.config.sound: pcm_ok = False mix_ok = False return if pcm_ok is None and renpysound: bufsize = 2048 if renpy.emscripten: # Large buffer (and latency) as compromise to avoid sound jittering bufsize = 8192 # works for me #bufsize = 16384 # jitter/silence right after starting a sound if 'RENPY_SOUND_BUFSIZE' in os.environ: bufsize = int(os.environ['RENPY_SOUND_BUFSIZE']) try: renpysound.init(renpy.config.sound_sample_rate, 2, bufsize, False, renpy.config.equal_mono) pcm_ok = True except: if renpy.config.debug_sound: raise pcm_ok = False # Find all of the mixers in the game. mixers = [ ] for c in all_channels: if c.mixer not in mixers: mixers.append(c.mixer) default_volume = 1.0 for m in mixers: renpy.game.preferences.volumes.setdefault(m, default_volume) renpy.game.preferences.mute.setdefault(m, False) with periodic_condition: periodic_thread_quit = False periodic_thread = threading.Thread(target=periodic_thread_main) periodic_thread.daemon = True periodic_thread.start() def quit(): # @ReservedAssignment global periodic_thread global periodic_thread_quit global pcm_ok global mix_ok if periodic_thread is not None: with periodic_condition: periodic_thread_quit = True periodic_condition.notify() periodic_thread.join() if not pcm_ok: return for c in all_channels: c.dequeue() c.fadeout(0) c.queue = [ ] c.loop = [ ] c.playing = False c.playing_midi = False c.wait_stop = False c.synchro_start = False renpysound.quit() pcm_ok = None mix_ok = None # The last-set pcm volume. pcm_volume = None old_emphasized = False def periodic_pass(): """ The periodic sound callback. This is called at around 20hz, and is responsible for adjusting the volume of the playing music if necessary, and also for calling the periodic functions of midi and the various channels, which then may play music. """ global pcm_volume global old_emphasized if not pcm_ok: return False try: # A list of emphasized channels. emphasize_channels = [ ] emphasized = False for i in renpy.config.emphasize_audio_channels: c = get_channel(i) emphasize_channels.append(c) if c.get_playing(): emphasized = True if not renpy.game.preferences.emphasize_audio: emphasized = False if emphasized and not old_emphasized: vol = renpy.config.emphasize_audio_volume elif not emphasized and old_emphasized: vol = 1.0 else: vol = None old_emphasized = emphasized if vol is not None: for c in all_channels: if c in emphasize_channels: continue c.set_secondary_volume(vol, renpy.config.emphasize_audio_time) for c in all_channels: c.periodic() renpysound.periodic() # Perform a synchro-start if necessary. need_ss = False for c in all_channels: if c.synchro_start and c.wait_stop: need_ss = False break if c.synchro_start and not c.wait_stop: need_ss = True if need_ss: renpysound.unpause_all() for c in all_channels: c.synchro_start = False except: if renpy.config.debug_sound: raise # The exception that's been thrown by the periodic thread. periodic_exc = None # Should we run the periodic thread now? run_periodic = False # The condition the perodic thread runs on. periodic_condition = threading.Condition() def periodic_thread_main(): global periodic_exc global run_periodic while True: with periodic_condition: if not run_periodic: periodic_condition.wait(.05) if periodic_thread_quit: return if not run_periodic: continue run_periodic = False with lock: try: periodic_pass() except Exception: periodic_exc = sys.exc_info() def periodic(): global periodic_exc global run_periodic if not renpy.config.audio_periodic_thread: periodic_pass() return with periodic_condition: for c in all_channels: c.get_context() if periodic_exc is not None: exc = periodic_exc periodic_exc = None six.reraise(exc[0], exc[1], exc[2]) run_periodic = True periodic_condition.notify() def interact(): """ Called at least once per interaction. """ if not pcm_ok: return with lock: try: for c in all_channels: c.interact() # if _music_volumes.get(i, 1.0) != c.chan_volume: # c.set_volume(_music_volumes.get(i, 1.0)) ctx = c.context # If we're in the same music change, then do nothing with the # music. if c.last_changed == ctx.last_changed: continue filenames = ctx.last_filenames tight = ctx.last_tight if c.loop: if not filenames or c.get_playing() not in filenames: c.fadeout(renpy.config.fade_music) if filenames: c.enqueue(filenames, loop=True, synchro_start=True, tight=tight) c.last_changed = ctx.last_changed except: if renpy.config.debug_sound: raise periodic() def rollback(): """ On rollback, we want to stop all the channels with non-empty sounds. """ with lock: for c in all_channels: if not c.loop: c.fadeout(0) global_pause = False def pause_all(): """ Pause all playback channels. """ global global_pause global_pause = True periodic() def unpause_all(): """ Unpause all playback channels. """ global global_pause global_pause = False periodic() def sample_surfaces(rgb, rgba): if not renpysound: return renpysound.sample_surfaces(rgb, rgba) def advance_time(): if not renpysound: return renpysound.advance_time()