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