Source code for simvx.core.audio_bus

"""
Audio bus system -- named buses with volume, mute, and routing.

Provides a mixing hierarchy similar to Godot's audio bus layout. Buses can
route to parent buses (e.g., SFX -> Master, Music -> Master), and the
final volume is computed by walking the chain.

Bus names are case-sensitive and use the Godot convention:
``"Master"``, ``"Music"``, ``"SFX"``, ``"Voice"``, ``"UI"``. Looking up a
bus that isn't in the layout raises :class:`UnknownBusError` with the
available names listed: no lazy creation, no fallback to Master.

Public API:
    from simvx.core.audio_bus import AudioBusLayout, AudioBus

    layout = AudioBusLayout.get_default()
    layout.get_bus("Music").volume_db = -6.0
    layout.get_bus("SFX").mute = True

    # Effective volume considers the full chain:
    effective = layout.get_bus("SFX").effective_volume
"""

from __future__ import annotations

import logging
from typing import Any

from .audio_errors import UnknownBusError
from .descriptors import Property

log = logging.getLogger(__name__)

__all__ = ["AudioBus", "AudioBusLayout"]


[docs] class AudioBus: """A single audio bus with volume, mute, and parent routing. Attributes: name: Bus display name (e.g., "Master", "SFX"). Case-sensitive. volume_db: Volume in decibels (-80 to 24). 0 = full volume. Clamped to the declared range on every write. mute: If True, this bus and all children produce no output. solo: If True, only this bus (and its children) produce output. send_to: Name of the parent bus this routes to (empty for Master). """ # NOTE: __slots__ is intentionally absent: :class:`Property` descriptors # store their backing values via ``setattr(obj, "_<name>", v)`` on the # instance __dict__. Adding the slot names manually is fragile (any new # Property would need a matching slot entry). volume_db = Property(0.0, range=(-80.0, 24.0), hint="Volume in decibels", group="Bus") mute = Property(False, hint="Silence this bus and its children", group="Bus") solo = Property(False, hint="Solo this bus (mute non-solo buses)", group="Bus") def __init__(self, name: str, volume_db: float = 0.0, send_to: str = ""): self.name = name self.volume_db = volume_db # Property descriptor clamps to (-80, 24) self.send_to = send_to self._effects: list[Any] = [] self._layout: AudioBusLayout | None = None # set by AudioBusLayout.add_bus
[docs] @property def linear_volume(self) -> float: """volume_db converted to linear scale (0.0 to ~15.85).""" if self.mute: return 0.0 if self.volume_db <= -80.0: return 0.0 return 10.0 ** (self.volume_db / 20.0)
[docs] @property def effective_volume(self) -> float: """Effective dB after walking the send_to chain to Master. Sum of volume_db along the chain. If any bus in the chain is muted, returns -80 (silent). Returns own volume_db when not in a layout. Solo gating: when any bus in the layout has ``solo=True``, every non-master, non-solo bus that is not an ancestor of a soloed bus is forced to the silence floor (-80 dB): mirroring the standard mixing-console "solo" behaviour. Master is always exempt so the listener still hears the surviving signal. """ if self._layout is None: return -80.0 if self.mute else self.volume_db # Solo gate: if any bus is soloed, mute non-solo non-master buses # (and non-ancestors of soloed buses). The walk below would still # compute the per-chain volume correctly; this just short-circuits # buses whose audio shouldn't pass at all under solo. if self._layout._any_solo() and not self._layout._is_audible_under_solo(self): return -80.0 total_db = 0.0 visited: set[str] = set() bus: AudioBus | None = self while bus is not None: if bus.name in visited: log.warning("audio_bus: circular routing detected at %s", bus.name) break visited.add(bus.name) if bus.mute: return -80.0 total_db += bus.volume_db # ``send_to`` validation in ``add_bus`` guarantees the target # exists; check ``has_bus`` to keep callers tolerant of layouts # mutated after construction without raising mid-walk. if bus.send_to and self._layout.has_bus(bus.send_to): bus = self._layout.get_bus(bus.send_to) else: bus = None return max(-80.0, min(24.0, total_db))
[docs] @property def effective_linear_volume(self) -> float: """effective_volume converted to linear multiplier (0.0 to ~15.85).""" db = self.effective_volume if db <= -80.0: return 0.0 return 10.0 ** (db / 20.0)
[docs] def add_effect(self, effect: Any) -> None: """Add an audio effect to this bus's processing chain.""" self._effects.append(effect)
[docs] def remove_effect(self, effect: Any) -> None: """Remove an audio effect from this bus.""" if effect in self._effects: self._effects.remove(effect)
[docs] @property def effects(self) -> list[Any]: """Read-only view of effects on this bus.""" return list(self._effects)
[docs] def __repr__(self) -> str: send = f" -> {self.send_to}" if self.send_to else "" muted = " [MUTED]" if self.mute else "" return f"AudioBus({self.name!r}, {self.volume_db:.1f}dB{send}{muted})"
[docs] class AudioBusLayout: """Collection of audio buses with routing and volume computation. Default layout creates four buses: Master (root) <- Music, SFX, Voice """ _default: AudioBusLayout | None = None def __init__(self): self._buses: dict[str, AudioBus] = {}
[docs] def add_bus(self, name: str, volume_db: float = 0.0, send_to: str = "") -> AudioBus: """Add a new audio bus. Args: name: Unique bus name (case-sensitive). volume_db: Initial volume in dB (clamped to [-80, 24]). send_to: Name of parent bus to route to. Must already exist in this layout (other than the empty string for root buses). ``"Master"`` is permitted regardless because it's the conventional root and is always present in default layouts. Returns: The created AudioBus. Raises: UnknownBusError: ``send_to`` references a bus that does not exist in this layout (and isn't ``"Master"``). """ if name in self._buses: log.warning("audio_bus: bus %r already exists, returning existing", name) return self._buses[name] if send_to and send_to != "Master" and send_to not in self._buses: raise UnknownBusError(send_to, available=list(self._buses.keys())) bus = AudioBus(name, volume_db, send_to) bus._layout = self self._buses[name] = bus return bus
[docs] def remove_bus(self, name: str) -> None: """Remove a bus by name. Cannot remove Master.""" if name == "Master": log.warning("audio_bus: cannot remove Master bus") return self._buses.pop(name, None)
[docs] def get_bus(self, name: str) -> AudioBus: """Return the bus named ``name``. Raises: UnknownBusError: ``name`` is not present in this layout. The error message lists the available names. Bus names are case-sensitive: ``"master"`` does not match ``"Master"``. """ bus = self._buses.get(name) if bus is None: raise UnknownBusError(name, available=list(self._buses.keys())) return bus
[docs] def has_bus(self, name: str) -> bool: """Return True iff a bus named ``name`` exists. Cheap, no raise.""" return name in self._buses
def _any_solo(self) -> bool: """True iff at least one non-master bus is soloed. Master's solo flag is intentionally ignored: soloing the master bus is a no-op (it's already the root mix) and would otherwise gate every other bus to silence trivially. """ return any(b.solo for b in self._buses.values() if b.name != "Master") def _is_audible_under_solo(self, bus: AudioBus) -> bool: """Whether ``bus`` should pass audio under the current solo gate. Returns True for Master, for any soloed bus, and for any ancestor of a soloed bus (walking ``send_to``): the chain must stay open so the soloed signal reaches Master. """ if bus.name == "Master" or bus.solo: return True soloed = [b for b in self._buses.values() if b.solo and b.name != "Master"] for s in soloed: cur: AudioBus | None = s visited: set[str] = set() while cur is not None and cur.name not in visited: visited.add(cur.name) if cur.send_to and self.has_bus(cur.send_to): cur = self.get_bus(cur.send_to) if cur is bus: return True else: cur = None return False
[docs] @property def buses(self) -> list[AudioBus]: """All buses in the layout.""" return list(self._buses.values())
[docs] @property def bus_names(self) -> list[str]: """Names of all buses.""" return list(self._buses)
[docs] @classmethod def get_default(cls) -> AudioBusLayout: """Return the default bus layout (Master, Music, SFX, Voice, UI).""" if cls._default is None: cls._default = cls.create_default() return cls._default
[docs] @classmethod def create_default(cls) -> AudioBusLayout: """Create the standard five-bus layout (Godot convention). Buses: ``Master`` (root), ``Music``, ``SFX``, ``Voice``, ``UI``, each child routed to ``Master``. Names are case-sensitive; the engine and every shipped player Property default uses TitleCase. """ layout = cls() layout.add_bus("Master") layout.add_bus("Music", send_to="Master") layout.add_bus("SFX", send_to="Master") layout.add_bus("Voice", send_to="Master") layout.add_bus("UI", send_to="Master") return layout
[docs] @classmethod def reset(cls): """Reset the default layout singleton (for tests).""" cls._default = None
[docs] def to_dict(self) -> list[dict]: """Serialize to JSON-compatible format. Each bus emits its effect chain via :meth:`AudioEffect.to_dict`; :meth:`from_dict` rebuilds the effects symmetrically. """ return [ { "name": bus.name, "volume_db": bus.volume_db, "mute": bus.mute, "solo": bus.solo, "send_to": bus.send_to, "effects": [effect.to_dict() for effect in bus._effects], } for bus in self._buses.values() ]
[docs] @classmethod def from_dict(cls, data: list[dict]) -> AudioBusLayout: """Deserialize from JSON format. Buses are added in the order they appear, so children must follow their parent. Layouts produced by :meth:`to_dict` already obey this ordering. ``send_to`` validation in :meth:`add_bus` will raise :class:`UnknownBusError` if a child precedes its parent. Each bus's ``effects`` list is rebuilt via :meth:`AudioEffect.from_dict`. """ # Local import avoids a circular dependency at module load time # (audio_effect imports nothing from audio_bus, but the inverse # would create a cycle if hoisted to the top). from .audio_effect import AudioEffect layout = cls() for item in data: bus = layout.add_bus( item["name"], volume_db=item.get("volume_db", 0.0), send_to=item.get("send_to", ""), ) bus.mute = item.get("mute", False) bus.solo = item.get("solo", False) for effect_data in item.get("effects", []): bus._effects.append(AudioEffect.from_dict(effect_data)) return layout