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 fallback. Mixes in a numpy generator
  callback driven by ``miniaudio.PlaybackDevice`` from the ``miniaudio``
  distribution, which wraps the same C library as the native path 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. It has no spatializer, so 3D positional audio is
  inactive on this path; bus routing, gain and effects still work.
- ``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 when the extension is the thing
   that is missing rather than the device.
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_HELP_URL,
    _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 naming what is lost, with rebuild instructions only when the extension is absent: a built extension that failed to open a device is not fixed by rebuilding. 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 ---------------------------------------------------------- native_built = _me.is_available() if native_built: 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 if native_built: # The extension is there and started failing, which a rebuild does # not address: the device is what refused. Pointing at the build # instructions here sends the reader after the wrong problem. log.warning( "Native audio backend is built but could not start; using the legacy mixer: " "100 ms latency, no 3D positional audio. Check the audio device: whether one is " "present, whether the sound server is running, and whether another process holds it." ) else: log.warning( "Native audio extension not built; using the legacy mixer: 100 ms latency, no 3D positional audio. %s", _NATIVE_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)