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