"""
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.
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_effective_volume("SFX")
"""
from __future__ import annotations
from __future__ import annotations
import logging
from typing import Any
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").
volume_db: Volume in decibels (-80 to 24). 0 = full volume.
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).
"""
__slots__ = ("name", "volume_db", "mute", "solo", "send_to", "_effects")
def __init__(self, name: str, volume_db: float = 0.0, send_to: str = ""):
self.name = name
self.volume_db = max(-80.0, min(24.0, volume_db))
self.mute = False
self.solo = False
self.send_to = send_to
self._effects: list[Any] = []
[docs]
def get_linear_volume(self) -> float:
"""Convert volume_db 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]
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)
@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.
volume_db: Initial volume in dB.
send_to: Name of parent bus to route to.
Returns:
The created AudioBus.
"""
if name in self._buses:
log.warning("audio_bus: bus %r already exists, returning existing", name)
return self._buses[name]
bus = AudioBus(name, volume_db, send_to)
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 | None:
"""Get a bus by name, or None if not found."""
return self._buses.get(name)
@property
def buses(self) -> list[AudioBus]:
"""All buses in the layout."""
return list(self._buses.values())
@property
def bus_names(self) -> list[str]:
"""Names of all buses."""
return list(self._buses)
[docs]
def get_effective_volume(self, bus_name: str) -> float:
"""Compute effective volume in dB by walking the bus chain to Master.
The effective volume is the sum of volume_db values along the chain.
If any bus in the chain is muted, returns -80 (silent).
"""
total_db = 0.0
visited: set[str] = set()
name = bus_name
while name:
if name in visited:
log.warning("audio_bus: circular routing detected at %s", name)
break
visited.add(name)
bus = self._buses.get(name)
if bus is None:
break
if bus.mute:
return -80.0
total_db += bus.volume_db
name = bus.send_to
return max(-80.0, min(24.0, total_db))
[docs]
def get_effective_linear(self, bus_name: str) -> float:
"""Compute effective volume as linear multiplier (0.0 to ~15.85)."""
db = self.get_effective_volume(bus_name)
if db <= -80.0:
return 0.0
return 10.0 ** (db / 20.0)
[docs]
@classmethod
def get_default(cls) -> AudioBusLayout:
"""Return the default bus layout (Master, Music, SFX, Voice)."""
if cls._default is None:
cls._default = cls.create_default()
return cls._default
[docs]
@classmethod
def create_default(cls) -> AudioBusLayout:
"""Create the standard four-bus layout."""
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")
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."""
return [
{
"name": bus.name,
"volume_db": bus.volume_db,
"mute": bus.mute,
"solo": bus.solo,
"send_to": bus.send_to,
}
for bus in self._buses.values()
]
[docs]
@classmethod
def from_dict(cls, data: list[dict]) -> AudioBusLayout:
"""Deserialize from JSON format."""
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)
return layout