Source code for miney.sound

"""
Noise: a click, a chime, a thunderclap, or music that plays until you stop it.
"""
from itertools import count
from typing import TYPE_CHECKING, Any, Optional, Union

# ponytail: the same two checks particles.py already does - a number with a range and a
# Point turned into a Lua table. One definition, imported rather than copied.
from .particles import _number, _vector

if TYPE_CHECKING:
    from .luanti import Luanti
    from .player import Player
    from .point import Point

#: What every named parameter of :meth:`Sound.play` is called once it reaches Luanti, so
#: that ``extra`` can say which parameter to use instead of a field it collides with.
_OWNED = {
    "gain": "gain", "pitch": "pitch", "fade": "fade", "loop": "loop",
    "pos": "point", "to_player": "player", "object": "follow",
    "start_time": "start", "max_hear_distance": "distance",
}


def _sound_name(name: Any) -> str:
    """
    :param name: What :meth:`Sound.play` was given as a sound.
    :return: The sound group name Luanti plays, without a file extension.
    :raises TypeError: If it is not a name.
    :raises ValueError: If it is empty, which Luanti reads as "play nothing".
    """
    if not isinstance(name, str):
        raise TypeError(
            f"A sound is named, as in \"default_dig_stone\", not {name!r}. "
            f"lt.assets.sounds has every name this server knows."
        )
    # A sound is played by its group name, so "x.ogg" and "x.3.ogg" are both "x". Nobody
    # should have to know that to pass the name lt.assets.upload() just handed back.
    if name.lower().endswith(".ogg"):
        name = name[:-4]
    if not name:
        raise ValueError(
            "An empty name plays nothing at all. lt.assets.sounds has every name this "
            "server knows."
        )
    return name


[docs] class PlayingSound: """ A sound that is playing, and the way to end it. You never make one of these - :meth:`Sound.play` hands it back:: >>> music = lt.sound.play("miney_low_random", loop=True) >>> music.fade_out(3) It is also a context manager, which is the tidier way to say the same thing:: >>> with lt.sound.play("miney_low_random", loop=True): ... build_the_thing() ... # silent again on the way out, come what may """ def __init__(self, sound: "Sound", key: str, gain: float): """ :param sound: The :class:`Sound` this one came from. :param key: How the server knows this sound. :param gain: How loud it was started, which is what a fade counts down from. """ self._sound = sound #: How the server knows this sound. self.key = key #: How loud it was started. self.gain = gain def __repr__(self) -> str: return f'<Luanti PlayingSound "{self.key}">' def __enter__(self) -> "PlayingSound": return self def __exit__(self, *_) -> None: self.stop()
[docs] def stop(self) -> None: """ Stop it now. Stopping one twice is not an error, and neither is stopping one that finished on its own - only a sound made with ``loop=True`` really needs this. .. code-block:: python music = lt.sound.play("miney_low_random", loop=True) # ... a while later ... music.stop() """ self._sound._end(self.key)
[docs] def fade_out(self, seconds: float = 1.0) -> None: """ Turn it down to nothing over this many seconds, then stop it. Kinder than :meth:`stop` for anything a player has been listening to:: >>> music = lt.sound.play("miney_low_random", loop=True, gain=0.5) >>> music.fade_out(3) :param seconds: How long the fade takes. Must be more than 0 - to end it at once use :meth:`stop`. :raises ValueError: If ``seconds`` is 0 or less. """ seconds = _number(seconds, "seconds") if not seconds: raise ValueError( "A fade over 0 seconds is not a fade. Use stop() to end it at once." ) # Luanti fades by gain per second, not for a number of seconds, so how loud this # one started decides how fast it has to go down to arrive at nothing on time. self._sound._end(self.key, step=self.gain / seconds)
[docs] class Sound: """ Noise: a click, a chime, or music that plays until you stop it. Reached through :attr:`lt.sound <miney.Luanti.sound>` and never created directly:: >>> lt.sound.play("miney_power_up_1") <Luanti PlayingSound "sound-1"> A sound is named, not filed: ``"default_dig_stone"`` is a name the game answers to, and :attr:`lt.assets.sounds <miney.Assets.sounds>` is where to find one with TAB. Given a :class:`~miney.Point` it comes from that place and fades with distance; without one it is equally loud everywhere, which is what music wants. Every name beginning with ``miney_`` comes with Miney's own mod and works in any game - 47 CC0 effects by `Kenney Vleugels <https://kenney.nl/assets/digital-audio>`_, so an example has something to play before you have gone looking for a name. .. important:: A sound with ``loop=True`` keeps playing after your script has ended. Somebody has to stop it - :meth:`PlayingSound.stop`, :meth:`stop_all`, or your session ending, because Miney's mod clears out what a connection left behind. """ def __init__(self, luanti: "Luanti"): """ :param luanti: The parent :class:`~miney.Luanti` object. """ self.lt = luanti self._keys = count(1) def __repr__(self) -> str: return "<Luanti Sound>" def _end(self, key: Optional[str], step: float = 0.0) -> None: """ :param key: The sound to end, or ``None`` for every one of them. :param step: How much gain to lose per second, or ``0`` to cut it off. """ # A target gain of 0 makes the server drop the sound as well, so a fade really # ends it rather than leaving something inaudible running (server.cpp, fadeSound). if step: # Rounded because 0.6 / 3 writes itself as 0.19999999999999998, and this # line ends up in a server log somebody may have to read. call = f"minetest.sound_fade(id, {round(step, 6)}, 0)" else: call = "minetest.sound_stop(id)" if key is None: self.lt.lua.run( f"if miney_sounds then for _, id in pairs(miney_sounds) do " f"{call} end miney_sounds = {{}} end", wait=False, ) return name = self.lt.lua.dumps(key) self.lt.lua.run( f"if miney_sounds and miney_sounds[{name}] then " f"local id = miney_sounds[{name}] {call} " f"miney_sounds[{name}] = nil end", wait=False, )
[docs] def play( self, name: str, *, gain: float = 1.0, pitch: float = 1.0, loop: bool = False, point: Optional["Point"] = None, player: Optional[Union["Player", str]] = None, follow: Optional[Union["Player", str]] = None, fade: float = 0.0, start: float = 0.0, distance: Optional[float] = None, **extra: Any, ) -> PlayingSound: """ Play a sound. 1. Everywhere, once:: >>> lt.sound.play("miney_power_up_1") 2. At a place, so it gets quieter the further away you are:: >>> lt.sound.play("miney_power_up_1", point=Point(10, 20, 30)) 3. Music, until you stop it:: >>> music = lt.sound.play("miney_low_random", loop=True, gain=0.4) >>> music.fade_out(3) 4. In one player's ears only, half as deep:: >>> lt.sound.play("miney_power_up_1", player=lt.players.Steve, pitch=0.5) 5. Travelling with a player, for everybody to hear:: >>> lt.sound.play("miney_power_up_1", follow=lt.players.Steve) 6. Your own:: >>> lt.sound.play(lt.assets.upload(Path("fanfare.ogg"))) ``player`` and ``follow`` sound alike and are not: ``player`` is *who hears it*, ``follow`` is *where it comes from*. They combine, so one player can hear a sound that another player carries. :param name: The sound, from :attr:`lt.assets.sounds <miney.Assets.sounds>` or from :meth:`lt.assets.upload() <miney.Assets.upload>`. A trailing ``.ogg`` is dropped for you. :param gain: How loud, where ``1`` is the sound's own level. Above ``1`` a placed sound does not get louder, it gets *further* - the volume is measured three blocks away. :param pitch: How deep. ``0.5`` is an octave down, ``2`` an octave up. :param loop: Start again at the end, for ever. Needs :meth:`PlayingSound.stop`. :param point: Where it comes from. Without one it is equally loud everywhere and follows nobody, which is what music wants. :param player: Only this player hears it. A :class:`~miney.Player` or a name. :param follow: The sound travels with this player. A :class:`~miney.Player` or a name. Cannot be combined with ``point``. :param fade: Fade *in* over this long, in gain per second. ``0.5`` takes two seconds to reach ``gain``. :param start: Start this many seconds into the sound instead of at the beginning. :param distance: How far away it can still be heard, in blocks. Luanti's own answer is 32. Needs ``point`` or ``follow``. :param extra: Anything else Luanti's sound parameter table takes, passed straight through - ``exclude_player``, for example. A name the engine does not know is **ignored without a word**, so check it against Luanti's `lua_api.md` if nothing happens. :return: A :class:`PlayingSound`, to stop it with. :raises TypeError: If a value is of the wrong kind. :raises ValueError: If a number is out of range, if ``point`` and ``follow`` are given together, if ``distance`` has no place to measure from, or if ``extra`` repeats something a parameter above already says. """ if point is not None and follow is not None: raise ValueError( "A sound is either at a place or on a player, not both. Drop point= to " "let it travel with the player, or drop follow= to nail it down." ) params: dict = { "gain": _number(gain, "gain"), "pitch": _number(pitch, "pitch"), } if not params["pitch"]: raise ValueError("pitch is a factor, so it has to be more than 0.") if loop: params["loop"] = True if fade: params["fade"] = _number(fade, "fade") if start: params["start_time"] = _number(start, "start") if point is not None: params["pos"] = _vector(point) if player is not None: params["to_player"] = _player_name(player, "player") if distance is not None: if point is None and follow is None: raise ValueError( "distance is how far away a sound can still be heard, and a sound " "with no place is heard everywhere. Give it a point= or a follow=." ) params["max_hear_distance"] = _number(distance, "distance") for field, value in extra.items(): if field in _OWNED: raise ValueError( f'"{field}" is what Luanti calls it, and Miney already sends it: ' f"use the {_OWNED[field]} parameter instead." ) params[field] = value key = f"sound-{next(self._keys)}" dumps = self.lt.lua.dumps spec = dumps(_sound_name(name)) # The engine hands back a number, and asking for it would cost a server step per # sound. So the key is made here and Lua remembers which number it stands for - # in this session's own table, which the mod empties when the session ends. store = f"miney_sounds = miney_sounds or {{}} miney_sounds[{dumps(key)}] = " if follow is None: code = f"{store}minetest.sound_play({spec}, {dumps(params)})" else: # An ObjectRef cannot travel through dumps(), so the player is looked up on # the other side. One who left in the meantime simply makes no sound, which # beats an error from inside the engine. code = ( f"local who = minetest.get_player_by_name(" f"{dumps(_player_name(follow, 'follow'))}) " f"if who then local p = {dumps(params)} p.object = who " f"{store}minetest.sound_play({spec}, p) end" ) self.lt.lua.run(code, wait=False) return PlayingSound(self, key, params["gain"])
[docs] def stop_all(self) -> None: """ Stop every sound this session started. The one line to put at the end of a show, so nothing keeps playing after it. .. code-block:: python lt.sound.stop_all() """ self._end(None)
def _player_name(player: Any, parameter: str) -> str: """ :param player: A :class:`~miney.Player` or a player's name. :param parameter: Which parameter it came from, for the error message. :return: The name. :raises TypeError: For anything else. """ name = getattr(player, "name", player) if not isinstance(name, str): raise TypeError( f"{parameter} is one player or their name, as in lt.players.Steve, " f"not {player!r}." ) return name