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.

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

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", "_layout") 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] = [] 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. """ if self._layout is None: return -80.0 if self.mute else self.volume_db 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 bus = self._layout.get_bus(bus.send_to) if bus.send_to else 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. 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) 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 | None: """Get a bus by name, or None if not found.""" return self._buses.get(name)
[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).""" 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