Source code for simvx.core.audio_backend._null

"""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