Source code for simvx.core.audio_backend

"""Desktop audio backends for SimVX.

Three backends ship in this module:

- ``MiniaudioBackend``: the production path. Mixes audio natively via
  ``ma_engine`` (CFFI wrapper at ``simvx.core._native.miniaudio_engine``).
  The native extension is built at ``uv pip install`` time by the PEP 517
  build hook in ``packages/core/build_audio_ext_hook.py``. If that build
  is skipped or fails, the runtime falls back to legacy.
- ``_LegacyMiniaudioBackend``: the pure-Python fallback. Mixes in a
  numpy generator callback driven by ``miniaudio.PlaybackDevice``. Slower
  (target latency 100 ms vs 20 ms for the native path) and runs on the
  Python audio thread under the GIL, so it's vulnerable to underruns
  from heavy main-thread frames.
- ``NullAudioBackend``: silent no-op backend used when no audio device
  is available (sandboxed CI, headless containers).

``make_backend()`` resolution order, with **loud** warnings at every
fallback (never silent degradation):

1. Native ``MiniaudioBackend``: picked when the extension is importable.
2. Legacy ``_LegacyMiniaudioBackend``: picked when native is unavailable
   AND a real audio device opens. Emits a one-time WARNING explaining the
   latency hit and the rebuild command.
3. Null ``NullAudioBackend``: picked when neither native nor legacy can
   start (no audio device). Emits a one-time WARNING.

Set ``SIMVX_ALLOW_LEGACY_AUDIO=0`` to disable the legacy/null fallbacks
and raise :class:`AudioBackendUnavailable` instead.

The runtime never invokes a C compiler. Use ``simvx build-audio`` (or
``uv pip install --reinstall -e packages/core``) to (re)build the
extension manually.
"""

from __future__ import annotations

import logging

from .._native import miniaudio_engine as _me
from ..audio_errors import AudioBackendUnavailable
from ._legacy import _LegacyMiniaudioBackend
from ._miniaudio import MiniaudioBackend
from ._null import NullAudioBackend
from ._shared import (
    _DEFAULT_CHANNELS,
    _DEFAULT_SAMPLE_RATE,
    _NATIVE_REBUILD_HINT,
    _legacy_allowed,
)

log = logging.getLogger(__name__)

__all__ = [
    "MiniaudioBackend",
    "_LegacyMiniaudioBackend",
    "NullAudioBackend",
    "make_backend",
]


_fallback_warned = False


[docs] def make_backend( sample_rate: int = _DEFAULT_SAMPLE_RATE, nchannels: int = _DEFAULT_CHANNELS, ) -> MiniaudioBackend | _LegacyMiniaudioBackend | NullAudioBackend: """Pick the best available audio backend, falling back loudly on failure. Resolution order (the runtime **never** invokes a C compiler: that's the install-time build hook's job): 1. **Native** ``MiniaudioBackend`` (~20 ms latency). Selected when the compiled ``_simvx_miniaudio_engine`` extension imports cleanly. 2. **Legacy** ``_LegacyMiniaudioBackend`` (~100 ms latency). Selected when native is unavailable AND ``SIMVX_ALLOW_LEGACY_AUDIO`` is not set to ``"0"``. Emits a one-time WARNING with rebuild instructions. 3. **Null** ``NullAudioBackend`` (silent). Selected when neither native nor legacy can start: typically a sandboxed CI without an audio device. Emits another one-time WARNING. Set ``SIMVX_ALLOW_LEGACY_AUDIO=0`` to refuse the fallback chain and raise :class:`AudioBackendUnavailable` if the native extension is missing or fails to initialise. """ global _fallback_warned # --- 1. native ---------------------------------------------------------- if _me.is_available(): try: return MiniaudioBackend(sample_rate=sample_rate, nchannels=nchannels) except Exception as exc: if not _legacy_allowed(): raise AudioBackendUnavailable( f"Native audio backend failed to initialise: {exc}. " f"SIMVX_ALLOW_LEGACY_AUDIO=0 is set so the legacy fallback is " f"disabled. {_NATIVE_REBUILD_HINT}" ) from exc log.warning( "MiniaudioBackend init failed (%s); trying legacy mixer.", exc ) elif not _legacy_allowed(): raise AudioBackendUnavailable( "Native audio extension is not built and SIMVX_ALLOW_LEGACY_AUDIO=0 " f"is set. {_NATIVE_REBUILD_HINT}" ) # --- 2. legacy ---------------------------------------------------------- if not _fallback_warned: _fallback_warned = True log.warning( "Native audio extension not available; using legacy mixer " "(100 ms latency). %s Or set SIMVX_ALLOW_LEGACY_AUDIO=0 to fail " "loudly instead.", _NATIVE_REBUILD_HINT, ) 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)