"""Desktop audio backends for SimVX.
``make_backend()`` picks one, and warns loudly at every fallback (never silent
degradation):
1. **An accelerator's backend.** A distribution offers one through the
``simvx.audio.backends`` entry point group, whose ``register()`` passes a
factory to :func:`register_backend`. ``simvx-audio-miniaudio`` offers
``MiniaudioBackend``, which mixes, spatializes and runs the bus effects in
native C through miniaudio's ``ma_engine``, at about 20 ms of latency.
2. ``_LegacyMiniaudioBackend``: the fallback. Mixes in a numpy generator
callback driven by ``miniaudio.PlaybackDevice`` from the ``miniaudio``
distribution, which wraps the same C library but exposes only its device
and decoder layer, not its engine. Mixing therefore happens in Python under
the GIL on the audio thread, which is what forces the 100 ms buffer: 20 ms
would underrun on any heavy main-thread frame. 3D players still work: the
mixer applies the attenuation, pan and doppler pitch the nodes compute. Bus
routing and gain work; bus effects are not applied.
3. ``NullAudioBackend``: silent no-op backend used when no audio device is
available (sandboxed CI, headless containers).
Set ``SIMVX_ALLOW_LEGACY_AUDIO=0`` to disable the legacy/null fallbacks and
raise :class:`AudioBackendUnavailable` instead.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from importlib.metadata import entry_points
from ..audio_errors import AudioBackendUnavailable
from ..audio_protocol import AudioPlaybackBackend
from ._legacy import _LegacyMiniaudioBackend
from ._null import NullAudioBackend
from ._shared import _DEFAULT_CHANNELS, _DEFAULT_SAMPLE_RATE, _legacy_allowed
log = logging.getLogger(__name__)
__all__ = [
"BACKEND_ENTRY_POINT_GROUP",
"_LegacyMiniaudioBackend",
"NullAudioBackend",
"make_backend",
"register_backend",
]
#: The entry point group ``make_backend()`` scans for accelerators' backends.
BACKEND_ENTRY_POINT_GROUP = "simvx.audio.backends"
# The fallback warning is one line, because a warning nobody finishes reading is
# a warning nobody acts on. It names what is lost and the page that explains it.
_HELP_URL = "https://simvx.com/docs/install.html#accelerators"
_INSTALL_HINT = "`pip install simvx-audio-miniaudio`"
_registered: dict[str, Callable[..., AudioPlaybackBackend]] = {}
_fallback_warned = False
[docs]
def register_backend(name: str, factory: Callable[..., AudioPlaybackBackend]) -> None:
"""Offer an audio backend to :func:`make_backend` under ``name``.
Called by the ``register()`` a ``simvx.audio.backends`` entry point names.
``factory`` is called with ``sample_rate`` and ``nchannels`` keywords and
returns a started backend, or raises when it cannot start. Registering a
name again replaces its factory.
"""
_registered[name] = factory
def _installed_backends() -> tuple[dict[str, Callable[..., AudioPlaybackBackend]], list[str]]:
"""Run every ``simvx.audio.backends`` entry point.
Returns the registered factories, and a description of each entry point
that failed to load: an accelerator whose native library cannot be opened
raises from its ``register()``.
"""
failures = []
for ep in entry_points(group=BACKEND_ENTRY_POINT_GROUP):
try:
ep.load()()
except Exception as exc:
source = f" ({ep.dist.name})" if ep.dist is not None else ""
failures.append(f"the {ep.name!r} audio backend{source} failed to load: {exc}")
return dict(_registered), failures
[docs]
def make_backend(
sample_rate: int = _DEFAULT_SAMPLE_RATE,
nchannels: int = _DEFAULT_CHANNELS,
) -> AudioPlaybackBackend:
"""Pick the best available audio backend, falling back loudly on failure.
Resolution order:
1. **An accelerator's backend**, registered through the
``simvx.audio.backends`` entry point group (``MiniaudioBackend``, ~20 ms
latency), when one is installed and starts.
2. **Legacy** ``_LegacyMiniaudioBackend`` (~100 ms latency, no 3D
positional audio), unless ``SIMVX_ALLOW_LEGACY_AUDIO`` is ``"0"``. Emits
a one-time WARNING naming what is lost, with install instructions only
when no accelerator is installed: one that is installed but could not
open the device is not fixed by installing it again.
3. **Null** ``NullAudioBackend`` (silent), when the legacy mixer cannot
start either: typically a sandboxed CI without an audio device. Emits
another one-time WARNING.
With ``SIMVX_ALLOW_LEGACY_AUDIO=0``, a missing, unloadable or failing
accelerator raises :class:`AudioBackendUnavailable` instead.
"""
global _fallback_warned
# --- 1. accelerator -----------------------------------------------------
factories, failures = _installed_backends()
for name, factory in factories.items():
try:
return factory(sample_rate=sample_rate, nchannels=nchannels)
except Exception as exc:
if not _legacy_allowed():
raise AudioBackendUnavailable(
f"The {name!r} audio backend failed to initialise: {exc}. "
"SIMVX_ALLOW_LEGACY_AUDIO=0 is set so the legacy fallback is disabled."
) from exc
log.warning("The %r audio backend failed to initialise (%s); trying the legacy mixer.", name, exc)
if not factories and not _legacy_allowed():
cause = "; ".join(failures) or "no native audio backend is installed"
raise AudioBackendUnavailable(
f"{cause[0].upper()}{cause[1:]}, and SIMVX_ALLOW_LEGACY_AUDIO=0 is set. "
f"Install one with {_INSTALL_HINT}, on a platform it is built for."
)
# --- 2. legacy ----------------------------------------------------------
if not _fallback_warned:
_fallback_warned = True
if factories:
# The backend is there and could not start, which reinstalling does
# not address: the device is what refused. Pointing at the install
# instructions here sends the reader after the wrong problem.
log.warning(
"The native audio backend is installed but could not start; using the legacy mixer: "
"100 ms latency, no bus effects. Check the audio device: whether one is "
"present, whether the sound server is running, and whether another process holds it."
)
elif failures:
log.warning(
"%s; using the legacy mixer: 100 ms latency, no bus effects. Reinstall the accelerator. %s",
"; ".join(failures),
_HELP_URL,
)
else:
log.warning(
"No native audio backend is installed; using the legacy mixer: 100 ms latency, "
"no bus effects. %s adds one. %s",
_INSTALL_HINT,
_HELP_URL,
)
try:
return _LegacyMiniaudioBackend(sample_rate=sample_rate, nchannels=nchannels)
except Exception as exc:
log.warning(
"_LegacyMiniaudioBackend init failed (%s); using silent NullAudioBackend. "
"Audio will be inaudible but the engine will run normally.",
exc,
)
# --- 3. null ------------------------------------------------------------
return NullAudioBackend(sample_rate=sample_rate, nchannels=nchannels)