Source code for simvx.core.audio_errors

"""Typed audio errors and the strict-mode flag.

The audio system raises typed exceptions instead of returning ``None`` or
silently falling back. Callers can ``except AudioError`` (or any subclass)
at well-defined boundaries; deep code paths never swallow.

Strict mode (``SIMVX_AUDIO_STRICT``, default ``"1"``) controls whether
historically-silent code paths raise (strict, dev default) or warn-once
(non-strict, release builds). Shipping games typically set
``SIMVX_AUDIO_STRICT=0`` so a misconfigured asset doesn't crash the
player; development runs at strict to surface bugs early.
"""

from __future__ import annotations

import logging
import os

__all__ = [
    "STRICT",
    "AudioError",
    "AudioBackendUnavailable",
    "UnknownBusError",
    "InvalidStreamError",
    "AudioCapabilityError",
    "AudioMutationDuringPlaybackError",
    "warn_once",
    "raise_or_warn",
]

STRICT: bool = os.environ.get("SIMVX_AUDIO_STRICT", "1") != "0"

_log = logging.getLogger("simvx.audio")
_warned_keys: set[str] = set()


[docs] def warn_once(key: str, msg: str, *args: object, exc_info: bool = False) -> None: """Emit ``msg`` at WARNING level the first time ``key`` is seen, then suppress. Use for non-fatal audio failures where flooding the log would be worse than missing a few subsequent occurrences (e.g. a backend call that fails on every ``on_process`` tick). """ if key in _warned_keys: return _warned_keys.add(key) _log.warning(msg, *args, exc_info=exc_info)
[docs] def raise_or_warn( exc: BaseException, *, key: str, message: str, ) -> None: """In strict mode, re-raise ``exc`` wrapped in :class:`AudioError`. Otherwise warn-once. Use at boundaries where the surrounding code can continue without the operation that just failed (e.g. cleanup paths, best-effort releases). Hot-path code that fundamentally needs the operation to succeed should raise directly rather than route through this helper. """ if STRICT: raise AudioError(message) from exc warn_once(key, "%s: %s", message, exc, exc_info=True)
[docs] class AudioError(Exception): """Base class for every audio-system exception."""
[docs] class AudioBackendUnavailable(AudioError): """The requested audio backend is missing or failed to initialise. Raised at backend-selection time (``make_backend``) or at first audio operation when the native extension is absent and the user hasn't opted into a fallback. """
[docs] class UnknownBusError(AudioError): """Bus name not found in the active layout. Raised by ``AudioBusLayout.get_bus`` and every backend's bus resolver when a player references a bus that hasn't been declared. The error message lists the available bus names to help the caller fix the typo or register the missing bus. """ def __init__(self, name: str, *, available: list[str]) -> None: super().__init__( f"Unknown audio bus: {name!r}. Available: {sorted(available)}. " f"Register the bus via AudioBusLayout.get_default().add_bus(...) " f"before referencing it from a player." ) self.name = name self.available = list(available)
[docs] class InvalidStreamError(AudioError): """Stream source is unrecognised or unsupported by the active backend."""
[docs] class AudioCapabilityError(AudioError): """The active backend doesn't support the requested capability. Use when a player asks for something the backend can't deliver (e.g. ``set_pitch`` on a streaming sound on a backend that can't pitch-shift streams). Includes the capability name + the backend's advertised capability set so the caller knows what's available. An optional ``remediation`` string is appended verbatim to point the caller at the fix. Accepts ``capability`` and the ``advertised`` set as either ``str`` or :class:`simvx.core.audio_protocol.Capability`. Members are stored as plain strings so the error remains hashable/serialisable. """ def __init__( self, capability: str | object, *, backend: str, advertised: set[str] | set[object] | frozenset[object], remediation: str | None = None, ) -> None: cap_str = str(capability) advertised_strs = {str(c) for c in advertised} message = ( f"Audio backend {backend!r} does not support capability {cap_str!r}. " f"Advertised capabilities: {sorted(advertised_strs)}." ) if remediation: message = f"{message} {remediation}" super().__init__(message) self.capability = cap_str self.backend = backend self.advertised = advertised_strs self.remediation = remediation
[docs] class AudioMutationDuringPlaybackError(AudioError): """A next-play-only Property was mutated while a channel was active. Raised in strict mode when a Property declared with ``mutation_policy="next_play"`` (e.g. ``loop``, ``stream_mode``) is written while the player has an active channel. Non-strict mode logs a one-time warning and silently defers the change to the next ``play()`` call. """ def __init__(self, prop_name: str, *, player: str) -> None: super().__init__( f"Cannot mutate {prop_name!r} on {player!r} mid-playback. " f"Stop the player first, change the property, then call play() again. " f"Set SIMVX_AUDIO_STRICT=0 to defer instead of raising." ) self.prop_name = prop_name self.player = player