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.

``send_to`` routes signal, not just gain. A bus's output, after its own
effect chain, flows into its ``send_to`` target and through that bus's
gain and effects on the way to the listener, so a reverb on ``Master``
reverberates every bus routed into ``Master``. Every backend that can
build a graph holds to that; the pure-Python fallback mixer runs no
effects at all, so it folds the chain into one gain per bus instead.

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 collections.abc import Iterator
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 everything routed into it produce no output. solo: If True, only this bus, everything routed into it and the buses carrying it onward to Master produce output. send_to: Name of the bus this one routes its signal into (empty for a bus that feeds the listener directly). """ # 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 bus that is not on a soloed bus's routing path is forced to the silence floor (-80 dB): mirroring the standard mixing-console "solo" behaviour. On the path means the soloed bus itself, the buses routed into it (which are the signal it isolates) and its ancestors (which carry that signal onward). 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, re-routing whatever fed it. Cannot remove Master. Every bus whose ``send_to`` named the removed bus is re-routed onto the removed bus's own target, falling back to ``"Master"``, so the layout never holds a bus pointing at a name that is not in it. An orphan is not a state the backends can agree on: one folds the chain into a gain and stops walking at the missing name, the other builds a graph and would leave the child wired to a dead node. The removed bus is detached from the layout, so its own :attr:`AudioBus.effective_volume` reports its own ``volume_db`` from then on. Removing a name that is not present does nothing. """ if name == "Master": log.warning("audio_bus: cannot remove Master bus") return removed = self._buses.pop(name, None) if removed is None: return removed._layout = None target = removed.send_to if target and not self.has_bus(target): target = "" if not target and self.has_bus("Master"): target = "Master" for bus in self._buses.values(): if bus.send_to == name: bus.send_to = target
[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 _send_chain(self, bus: AudioBus) -> Iterator[AudioBus]: """Yield ``bus`` then every bus it routes into, stopping on a cycle.""" cur: AudioBus | None = bus visited: set[str] = set() while cur is not None and cur.name not in visited: visited.add(cur.name) yield cur if cur.send_to and self.has_bus(cur.send_to): cur = self.get_bus(cur.send_to) else: cur = None def _routing_parent(self, bus: AudioBus) -> str: """The bus ``bus`` sends its signal into; ``""`` for the listener. Where a backend that builds a real graph attaches this bus's output. ``send_to`` is a plain attribute, so a routing cycle is representable and would become a cycle in that graph: a bus that routes back into itself is reported as feeding the listener directly instead. """ target = bus.send_to if not target or not self.has_bus(target): return "" for step in self._send_chain(self.get_bus(target)): if step.name == bus.name: log.warning( "audio_bus: circular routing at %s, routing it straight to the listener", bus.name, ) return "" return target 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 bus routed through a soloed bus (the soloed bus itself and everything feeding into it: that is the signal solo isolates), and for any ancestor of a soloed bus, so the isolated signal still reaches Master. """ if bus.name == "Master": return True # Upward from ``bus``: reaching a soloed bus means ``bus`` feeds it. for step in self._send_chain(bus): if step.solo and step.name != "Master": return True # Upward from each soloed bus: everything it passes through has to # stay open or the isolated signal is gated out before the listener. for soloed in self._buses.values(): if not soloed.solo or soloed.name == "Master": continue for step in self._send_chain(soloed): if step is bus: return True return False def _own_linear_volume(self, bus: AudioBus) -> float: """``bus``'s own linear gain, before any routing is applied. What a backend puts on the bus's own node when it models ``send_to`` as a real graph: the graph multiplies the routing chain itself, so the node carries only this bus's ``volume_db`` and mute plus the layout-wide solo gate. A backend that mixes flat wants :attr:`AudioBus.effective_linear_volume` instead, which folds the whole chain into one number. """ if self._any_solo() and not self._is_audible_under_solo(bus): return 0.0 return bus.linear_volume
[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