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