"""The silent (no-op) audio backend.
``NullAudioBackend`` is selected when no audio device is available (sandboxed
CI, headless containers). Calls return valid channel IDs and behave
consistently, but nothing is rendered to a device.
"""
from __future__ import annotations
import threading
from typing import TYPE_CHECKING, Any
from ..audio_protocol import Capability
from ._shared import (
_DEFAULT_CHANNELS,
_DEFAULT_SAMPLE_RATE,
_alloc_channel_id,
_register_atexit_shutdown,
)
if TYPE_CHECKING:
from ..audio import AudioClip
from ..audio_bus import AudioBusLayout
[docs]
class NullAudioBackend:
"""Silent backend implementing :class:`AudioPlaybackBackend` + :class:`AudioBusBackend`.
Selected automatically by :func:`make_backend` when neither the native
extension nor the legacy ``miniaudio`` path can start (e.g. no audio
device on the host, ALSA/Pulse not running, the ``miniaudio`` Python
package not installed, or a sandboxed environment with no compiler).
The engine, scene tree, and :class:`AudioPlayer` nodes all
operate normally: calls return valid channel IDs and
:meth:`is_channel_active` behaves consistently, but nothing is
rendered to a device.
NullBackend does NOT implement :class:`AudioStreamingBackend`. Code
paths that need streaming (procedural synth via
:class:`AudioSynth.attach_to`, AudioWorklet feeds, etc.) must check
``isinstance(backend, AudioStreamingBackend)`` and raise / warn-once
when it's absent. Advertised capabilities are narrowed to
``{Capability.PLAY_BASIC}`` so effect modules don't try to
instantiate native nodes on the null path.
"""
def __init__(
self,
sample_rate: int = _DEFAULT_SAMPLE_RATE,
nchannels: int = _DEFAULT_CHANNELS,
):
self._sample_rate = sample_rate
self._nchannels = nchannels
self._lock = threading.Lock()
self._channels: set[int] = set()
self._paused: set[int] = set()
# Symmetric atexit registration: no audio thread to leak here, but
# keeps shutdown semantics consistent across all three backends so
# tests can iterate ``_atexit_backends`` and assert every active
# backend gets joined at interpreter exit.
_register_atexit_shutdown(self)
def _alloc(self) -> int:
cid = _alloc_channel_id()
with self._lock:
self._channels.add(cid)
return cid
[docs]
def play_audio(
self,
stream: AudioClip,
*,
mode: str = "non_positional",
position: Any = None,
volume_db: float = 0.0,
pitch: float = 1.0,
loop: bool = False,
bus: str = "Master",
max_distance: float = 100.0,
from_position: float = 0.0,
pan: float = 0.0,
gain_db: float = 0.0,
) -> int | None:
# NullBackend is silent regardless of any kwarg; accept the full
# signature so AudioPlaybackBackend Protocol parity holds.
return self._alloc()
[docs]
def stop_audio(self, channel_id: int) -> None:
with self._lock:
self._channels.discard(channel_id)
self._paused.discard(channel_id)
[docs]
def pause_audio(self, channel_id: int) -> None:
with self._lock:
if channel_id in self._channels:
self._paused.add(channel_id)
[docs]
def resume_audio(self, channel_id: int) -> None:
with self._lock:
self._paused.discard(channel_id)
[docs]
def update_audio_2d(self, channel_id: int, volume_db: float, pan: float) -> None:
return
[docs]
def update_audio_3d(
self, channel_id: int, volume_db: float, pan: float, pitch: float
) -> None:
return
[docs]
def set_pitch(self, channel_id: int, pitch: float) -> None:
return
# Listener pose endpoints: Null backend has no spatializer.
[docs]
def set_listener_position(self, x: float, y: float, z: float) -> None: return
[docs]
def set_listener_velocity(self, x: float, y: float, z: float) -> None: return
[docs]
def set_listener_direction(self, x: float, y: float, z: float) -> None: return
[docs]
def set_listener_world_up(self, x: float, y: float, z: float) -> None: return
[docs]
def get_playback_position(self, channel_id: int) -> float:
return 0.0
[docs]
def is_channel_active(self, channel_id: int) -> bool:
with self._lock:
return channel_id in self._channels
[docs]
def shutdown(self) -> None:
with self._lock:
self._channels.clear()
self._paused.clear()
[docs]
def list_capabilities(self) -> frozenset[Capability]:
# The null backend supports only the unconditional "play.basic"
# advertisement: every call succeeds (silently) and returns a
# valid channel id. Spatial / streaming / effect capabilities are
# deliberately omitted so callers that gate on them raise
# AudioCapabilityError or skip work rather than producing the
# illusion of a working pipeline.
return frozenset({Capability.PLAY_BASIC})
[docs]
def sync_bus_layout(self, layout: AudioBusLayout) -> None:
return