# Copyright 2004-2026 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 division, absolute_import, with_statement, print_function, unicode_literals
from renpy.compat import PY2, basestring, bchr, bord, chr, open, pystr, range, round, str, tobytes, unicode  # *

from typing import Literal

import time
import os
import re
import threading
import sys
import io

import renpy

# Import the appropriate modules, or set them to None if we cannot.

import renpy.audio.renpysound as renpysound

# This is True if we were able to successfully enable the pcm audio.
pcm_ok = None

unique = time.time()
serial = 0

# The number of channels passed to the last call of set_channel_count
old_channel_count = 0


def get_serial():
    """
    Gets a globally unique serial number for each music change.
    """

    global serial
    serial += 1
    return (unique, serial)


AudioNotReady = renpy.object.Sentinel("AudioNotReady")


def load(fn):
    """
    Returns a file-like object for the given filename.
    """

    try:
        rv = renpy.loader.load(fn, directory="audio")
    except renpy.webloader.DownloadNeeded as exception:
        if exception.rtype == "music":
            renpy.webloader.enqueue(exception.relpath, "music", None)
            return AudioNotReady  # Try again later
        elif exception.rtype == "voice":
            renpy.webloader.enqueue(exception.relpath, "voice", None)
            return AudioNotReady  # Try again later
        elif exception.rtype == "video":
            # Video files are downloaded by the browser, so return
            # the file name instead of a file-like object
            return "url:" + renpy.config.web_video_base + "/" + exception.relpath

        # temporary 1s placeholder, will retry loading when looping:
        rv = open(os.path.join(renpy.config.commondir, "_dl_silence.ogg"), "rb")  # type: ignore
    return rv


class AudioData(str):
    """
    :doc: audio

    This class wraps a bytes object containing audio or video data, so it can be
    passed to the audio/video playback system. The audio or video data should be contained
    in some format Ren'Py supports. (For examples RIFF WAV format headers,
    not unadorned samples.)

    Despite the name, this can be used to represent video data as well as audio data.

    `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.

        If this starts with angle bracket, it can supply properties to
        the audio, like from and to times.

    Once created, this can be used wherever an audio filename is allowed. For
    example::

        define audio.easteregg = AudioData(b'...', 'sample.wav')
        play sound easteregg
    """

    data: bytes

    def __new__(cls, data: bytes, filename: str):
        rv = str.__new__(cls, filename)
        rv.data = data
        return rv

    def __init__(self, data, filename):
        pass

    def __reduce__(self):
        return (AudioData, (self.data, str(self)))


class QueueEntry(object):
    """
    A queue entry object.
    """

    audio_filter = None

    def __init__(self, filename, fadein, tight, loop, relative_volume, audio_filter):
        self.filename = filename
        self.fadein = fadein
        self.tight = tight
        self.loop = loop
        self.relative_volume = relative_volume
        self.audio_filter = audio_filter


class MusicContext(renpy.revertable.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
    last_relative_volume = 1.0
    audio_filter = None
    raw_audio_filter = None

    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 = []

        # The relative volume of the last files played.
        self.last_relative_volume = 1.0

        # Should we force stop this channel?
        self.force_stop = False

        # Should we pause this channel?
        self.pause = False

        # The audio filter that should be applied to things queued to
        # this channel.
        self.audio_filter = None

        # The audio filter that was given to set_audio_filter. (This is usually
        # wrapped int a Crossfade for use.)
        self.raw_audio_filter = None

    def copy(self):
        """
        Returns a shallow copy of this context.
        """

        rv = MusicContext()
        rv.__dict__.update(self.__dict__)

        self.audio_filter = self.raw_audio_filter

        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()


NotSet = renpy.object.Sentinel("NotSet")


class Channel(object):
    """
    This stores information about the currently-playing music.
    """

    # The audio filter to use.
    audio_filter = None
    raw_audio_filter = None

    def __init__(
        self,
        name,
        default_loop,
        stop_on_mute,
        tight,
        file_prefix,
        file_suffix,
        buffer_queue,
        movie,
        framedrop,
        synchro_start,
    ):
        # 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, then this channel will participate in a synchro-start
        # once all channels are ready.
        self.synchro_start = False

        # Does this participate in synchro start by default.
        self.default_synchro_start: bool|renpy.object.Sentinel = synchro_start

        # 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

        self.default_loop: bool
        self.default_loop_set: bool

        if default_loop is NotSet:
            # 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

            if renpysound.is_webaudio:
                renpysound.set_movie_channel(self.number, True)  # type: ignore
        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 copy_context(self):
        """
        Copies the MusicContext associated with this channel, updates the
        ExecutionContext to point to the copy, and returns the copy.
        """

        context = renpy.game.context()
        mcd = dict(context.music)
        context.music = mcd

        ctx = self.get_context().copy()
        mcd[self.name] = ctx

        context.movie = dict(context.movie)
        return ctx

    def split_filename(self, filename: str | AudioData, looped: bool) -> tuple[str | AudioData, float, float, float]:
        """
        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 Exception:
                raise exception("expected float, got {!r}.".format(v))

        def expect_channel():
            if not spec:
                raise exception("expected channel at end.")

            v = spec.pop(0)

            try:
                return renpy.audio.audio.get_channel(v)
            except Exception:
                raise exception("expected channel, got {!r}.".format(v))

        original_filename = filename

        m = re.match(r"<(.*)>(.*)", filename)
        if not m:
            return self.file_prefix + filename + self.file_suffix, 0, -1, 1.0

        fn = m.group(2)
        fn = self.file_prefix + fn + self.file_suffix

        spec = m.group(1).split()

        start = 0
        loop = None
        end = -1
        volume = 1.0

        while spec:
            clause = spec.pop(0)

            if clause == "from":
                start = expect_float()
            elif clause == "to":
                end = expect_float()
            elif clause == "sync":
                sync_channel = expect_channel()
                t = sync_channel.get_pos()
                if not t or t < 0:
                    pass
                else:
                    start = t
            elif clause == "loop":
                loop = expect_float()
            elif clause == "silence":
                end = expect_float()
                fn = "_silence.ogg"
            elif clause == "volume":
                volume = expect_float()

            else:
                raise exception("expected keyword, got {!r}.".format(clause))

        if (loop is not None) and looped:
            start = loop

        if isinstance(original_filename, AudioData):
            fn = AudioData(original_filename.data, fn)

        return fn, start, end, volume

    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.

        if self.mixer:
            mixer_volume = renpy.game.preferences.volumes.get(self.mixer, 1.0)
        else:
            mixer_volume = 1.0

        main_volume = renpy.game.preferences.volumes.get("main", 1.0)

        if renpy.game.preferences.self_voicing:
            if self.mixer not in renpy.config.voice_mixers:
                mixer_volume = mixer_volume * renpy.game.preferences.self_voicing_volume_drop

        vol = self.chan_volume * mixer_volume * main_volume

        if renpy.game.preferences.mute.get(self.mixer, False):
            vol = 0.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:
            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:
            if self._number is not None:
                depth = renpysound.queue_depth(self.number)
            else:
                depth = 0

            if depth == 0:
                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

            # 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, filename_volume = self.split_filename(topq.filename, topq.loop)

                if renpy.config.audio_filename_callback is not None:
                    filename = renpy.config.audio_filename_callback(filename)

                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(filename)
                    if topf is AudioNotReady:
                        # File is not ready, try again on the next periodic pass.
                        self.queue.insert(0, topq)
                        break

                if self.movie != renpy.audio.renpysound.NO_VIDEO:
                    # Let the browser handle the video loop if any
                    renpysound.set_video(self.number, self.movie, loop=(len(self.loop) == 1))
                else:
                    renpysound.set_video(self.number, self.movie, loop=False)

                if depth == 0:
                    renpysound.play(
                        self.number,
                        topf,
                        topq.filename,
                        synchro_start=self.synchro_start,
                        fadein=topq.fadein,
                        tight=topq.tight,
                        start=start,
                        end=end,
                        relative_volume=topq.relative_volume * filename_volume * renpy.config.adjust_audio_amplitude,
                        audio_filter=topq.audio_filter,
                    )  # type:ignore
                else:
                    renpysound.queue(
                        self.number,
                        topf,
                        topq.filename,
                        synchro_start=self.synchro_start,
                        fadein=topq.fadein,
                        tight=topq.tight,
                        start=start,
                        end=end,
                        relative_volume=topq.relative_volume * filename_volume * renpy.config.adjust_audio_amplitude,
                        audio_filter=topq.audio_filter,
                    )  # type:ignore

                self.playing = True
                self.synchro_start = False

            except Exception:
                # 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, topq.relative_volume, self.context.audio_filter)
                    else:
                        newq = QueueEntry(i, 0, False, True, 1.0, self.context.audio_filter)

                    self.queue.append(newq)
            # Try callback:
            elif self.callback:
                self.callback()  # E1102

        want_pause = self.context.pause

        if self.paused != want_pause:
            if want_pause:
                self.pause()
            else:
                self.unpause()

            self.paused = want_pause

        # Ensure the music won't be unloaded while playing.
        for i in self.queue:
            renpy.webloader.extend(self.split_filename(i.filename, i.loop)[0])

        for i in self.loop:
            renpy.webloader.extend(self.split_filename(i, True)[0])

    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._number is None:
                return

            if self.keep_queue == 0:
                renpysound.dequeue(self.number, even_tight)
                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 self._number is None:
                return

            if secs == 0:
                renpysound.stop(self.number)
            else:
                renpysound.fadeout(self.number, secs)

            # Mark the movie as having ended.

            context_movie = renpy.game.context().movie

            if self.name in context_movie:
                context_movie = dict(context_movie)
                del context_movie[self.name]
                renpy.game.context().movie = context_movie

            last_channel_movie = renpy.display.video.last_channel_movie

            if self.name in last_channel_movie:
                last_channel_movie = dict(last_channel_movie)
                del last_channel_movie[self.name]
                renpy.display.video.last_channel_movie = last_channel_movie

    def reload(self):
        """
        Causes this channel to be stopped in a way that looped audio will be
        reloaded and restarted.
        """

        with lock:
            renpysound.dequeue(self.number, True)
            renpysound.stop(self.number)

    def set_audio_filter(self, audio_filter, replace=False, duration=0.016):
        """
        Sets the audio filter being applied to this channel to `audio_filter`.
        """

        with lock:
            old_raw_audio_filter = self.context.raw_audio_filter
            self.context.raw_audio_filter = audio_filter
            self.raw_audio_filter = audio_filter

            if old_raw_audio_filter is None and audio_filter is None:
                new_audio_filter = None
            else:
                new_audio_filter = renpy.audio.filter.Crossfade(
                    old_raw_audio_filter or renpy.audio.filter.Null(),
                    audio_filter or renpy.audio.filter.Null(),
                    duration,
                )

            self.context.audio_filter = new_audio_filter

            for q in self.queue:
                q.audio_filter = new_audio_filter

            if replace:
                renpysound.replace_audio_filter(self.number, new_audio_filter, 1)
            else:
                renpysound.replace_audio_filter(self.number, new_audio_filter, 0)

    def enqueue(
        self, filenames, loop=True, synchro_start=None, fadein=0, tight=None, loop_only=False, relative_volume=1.0
    ):
        if synchro_start is None:
            synchro_start = self.default_synchro_start

            # This case triggers when the default loop not being set causes the default synchro start to be
            # NotSet.
            if synchro_start is NotSet:
                synchro_start = self.default_loop

        with lock:
            for filename in filenames:
                if renpy.exports.is_seen_allowed():
                    filename, _, _, _ = self.split_filename(filename, False)
                    renpy.game.persistent._seen_audio[str(filename)] = True  # type: ignore

            if not loop_only:
                if tight is None:
                    tight = self.tight

                self.keep_queue += 1

                for filename in filenames:
                    qe = QueueEntry(filename, fadein, tight, False, relative_volume, self.context.audio_filter)
                    self.queue.append(qe)

                    # Only fade the first thing in.
                    fadein = 0

                if synchro_start:
                    self.synchro_start = True

            if loop:
                self.loop = list(filenames)
            else:
                self.loop = []

        with periodic_condition:
            periodic_condition.notify()

    def get_playing(self):
        if not pcm_ok:
            return None

        rv = None

        with lock:
            if self._number is not None:
                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

        if self._number is None:
            return -1

        return renpysound.get_pos(self.number)

    def get_duration(self):
        if not pcm_ok:
            return 0.0

        if self._number is None:
            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):
        if self._number is None:
            return

        with lock:
            renpysound.unpause(self.number)

    def read_video(self):
        if not pcm_ok:
            return None

        return renpysound.read_video(self.number)

    def video_ready(self):
        if not pcm_ok:
            return 1

        return renpysound.video_ready(self.number)


# 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=NotSet,
    stop_on_mute=True,
    tight=False,
    file_prefix="",
    file_suffix="",
    buffer_queue=True,
    movie=False,
    framedrop=True,
    force=False,
    synchro_start=None,
):
    """
    :doc: audio
    :args: (name, mixer, loop=None, stop_on_mute=True, tight=False, file_prefix="", file_suffix="", buffer_queue=True, movie=False, framedrop=True)

    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.

    `name`
        The name of the channel. It should not contain spaces, as this is reserved
        for Ren'Py's internal use, and should be a
        `valid identifier <https://docs.python.org/reference/lexical_analysis.html#identifiers>`__
        for the syntax of the :ref:`play-statement` to be usable.

    `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,
        and will create the mixer if it doesn't already exist, but making new
        mixers reachable by the player requires changing the preferences screens.

    `loop`
        If True or None, sounds on this channel loop by default. If False, sounds on
        this channel do not 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.

    `synchro_start`
        Does this channel participate in synchro start? Synchro start determines if
        the channel will start playing at the same time as other channels. If None,
        this defaults to `loop` if `movie` is False, and False otherwise.
    """

    if name == "movie":
        movie = True

    if synchro_start is None:
        if movie:
            synchro_start = False
        else:
            synchro_start = loop

    if not force and not renpy.game.context().init_phase and (" " not in name):
        raise Exception("Can't register channel outside of init phase.")

    if name in channels:
        all_channels.remove(channels[name])
        del channels[name]

    c = Channel(
        name,
        loop,
        stop_on_mute,
        tight,
        file_prefix,
        file_suffix,
        buffer_queue,
        movie=movie,
        framedrop=framedrop,
        synchro_start=synchro_start,
    )

    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 or ("RENPY_DISABLE_SOUND" in os.environ):
        pcm_ok = False
        mix_ok = False
        return

    if renpy.emscripten and renpy.config.webaudio:
        # Importing webaudio hooks all public functions of renpysound
        import renpy.audio.webaudio as webaudio

        renpysound.is_webaudio = True

        if webaudio.can_play_types(renpy.config.webaudio_required_types):
            webaudio.video_only = False
        else:
            # Audio files are decoded by Ren'Py (wasm), video files by browser
            webaudio.video_only = True

        # Some channels are created before init() is called, so flag them now
        for c in all_channels:
            webaudio.set_movie_channel(c.number, c.movie != renpy.audio.renpysound.NO_VIDEO)

    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.config.sound_buffer_size is not None:
            bufsize = renpy.config.sound_buffer_size

        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, renpy.config.linear_fades
            )
            pcm_ok = True
        except Exception:
            renpy.display.log.write("Audio and video init failed. Proceeding anyway.")
            renpy.display.log.exception()

            os.environ["SDL_AUDIODRIVER"] = "dummy"

            try:
                renpysound.init(renpy.config.sound_sample_rate, 2, bufsize, False, renpy.config.equal_mono)
                pcm_ok = True
            except Exception:
                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

        if not renpy.emscripten:
            periodic_thread = threading.Thread(target=periodic_thread_main)
            periodic_thread.daemon = True
            periodic_thread.start()
        else:
            periodic_thread = None


def fadeout_all():
    """
    Called to fade out all playing audio as the game quits or restarts.
    """

    for c in all_channels:
        c.dequeue()
        c.fadeout(renpy.config.fadeout_audio)

    periodic()


def quit():
    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.unpause()

        c.queue = []
        c.loop = []
        c.playing = False
        c.playing_midi = 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 old_emphasized
    global old_channel_count

    if not pcm_ok:
        return False

    try:
        if renpy.game.preferences.mono_audio:
            channel_count = 1
        else:
            channel_count = 2

        if old_channel_count != channel_count:
            renpysound.set_channel_count(channel_count)
            old_channel_count = channel_count

        # 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()

    except Exception:
        if renpy.config.debug_sound:
            raise


# The exception that's been thrown by the periodic thread.
periodic_exc: Exception | None = 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(0.05)

            if periodic_thread_quit:
                return

            if not run_periodic:
                continue

            run_periodic = False

        with lock:
            try:
                periodic_pass()
            except Exception as e:
                periodic_exc = e


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

            raise exc

        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()

                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 ctx.raw_audio_filter != c.raw_audio_filter:
                    c.set_audio_filter(ctx.raw_audio_filter, True)

                if not c.name in renpy.display.video.last_channel_movie:
                    if c.loop != filenames:
                        c.fadeout(max(renpy.config.context_fadeout_music, renpy.config.fadeout_audio))

                    if filenames:
                        c.enqueue(
                            filenames,
                            loop=True,
                            synchro_start=c.default_synchro_start,
                            tight=tight,
                            fadein=renpy.config.context_fadein_music,
                            relative_volume=ctx.last_relative_volume,
                        )

                c.last_changed = ctx.last_changed

        except Exception:
            if renpy.config.debug_sound:
                raise

    periodic()


def pump():
    """
    Used to implement renpy.music.pump.
    """

    interact()

    with lock:
        periodic_pass()


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(renpy.config.fadeout_audio)


def autoreload(_fn):
    """
    After a sound file has been changed, stop all sound (and let Ren'Py restart
    the channels, as needed.)
    """

    for c in all_channels:
        c.reload()

    renpy.exports.restart_interaction()


def pause_all():
    """
    Pause all playback channels.
    """

    if pcm_ok:
        renpysound.global_pause(1)


def unpause_all():
    """
    Unpause all playback channels.
    """

    if pcm_ok:
        renpysound.global_pause(0)


def sample_surfaces(rgb, rgba):
    if not renpysound:
        return

    renpysound.sample_surfaces(rgb, rgba)


def advance_time():
    if not renpysound:
        return

    renpysound.advance_time()
