1158 lines
30 KiB
Python
1158 lines
30 KiB
Python
# Copyright 2004-2019 Tom Rothamel <pytom@bishoujo.us>
|
|
#
|
|
# 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()
|