SimVX Audio System

Audio playback for background music, UI sounds, 2D positional effects, and 3D spatial sound. Three player nodes feed a typed bus graph with per-bus effect chains; an AudioSynth builds procedural audio with no asset files; a scene-tree AudioListener node receives the spatialization. Backends are pluggable: native MiniaudioBackend (CFFI over miniaudio’s ma_engine), pure-Python _LegacyMiniaudioBackend, silent NullAudioBackend, and WebAudioBackend for browser exports.

Features

  • Non-positional, 2D, and 3D player nodes with shared playback lifecycle.

  • Scene-tree AudioListener2D / AudioListener3D nodes that work like Camera2D / Camera3D: most recently entered is current; lazy fallback if none is added.

  • Typed audio buses (AudioBus) with volume_db, mute, solo, send_to routing, and per-bus effect chains.

  • Procedural synthesis (AudioSynth): oscillators, noise, envelopes, per-source filters, baked or live-streamed.

  • Live property pushes: volume_db, pan_override, and pitch_modulate propagate to active channels without retriggering.

  • Per-player fades: fade_in(seconds), fade_out(seconds), crossfade_to(other, seconds) (replaces the removed bus-level FadeEffect).

  • queue_free_on_end auto-removes one-shot players when playback ends.

  • Strict-mode error model: typed exceptions for unknown buses, unsupported capabilities, mid-playback property mutations, and missing backends.

Quick Start

1. Background music

from simvx.core import AudioStreamPlayer

music = AudioStreamPlayer(
    stream="music/theme.ogg",
    volume_db=-5.0,
    loop=True,
    autoplay=True,
    bus="Music",
)
root.add_child(music)

2. 2D sound effects

from simvx.core import AudioStreamPlayer2D, Resource, Vec2

explosion = AudioStreamPlayer2D(
    stream=Resource("game.assets", "explosion.wav"),
    position=Vec2(200, 300),
    max_distance=400.0,
    bus="SFX",
)
root.add_child(explosion)
explosion.play()

3. 3D spatial audio (with explicit listener)

from simvx.core import AudioListener3D, AudioStreamPlayer3D, Vec3
from simvx.core.nodes_3d.camera import Camera3D

class MyScene(Node3D):
    def on_ready(self):
        camera = self.add_child(Camera3D())
        # Parent the listener under the camera so its pose follows.
        camera.add_child(AudioListener3D())

        engine = AudioStreamPlayer3D(
            stream="sfx/engine.ogg",
            position=Vec3(10, 0, 5),
            max_distance=100.0,
            loop=True,
            doppler_scale=0.5,
            bus="SFX",
        )
        self.add_child(engine)
        engine.play()

If no AudioListener3D is in the tree when an AudioStreamPlayer3D needs one, the engine lazy-creates a fallback under the active Camera3D and logs a one-time warning. Add an explicit listener to silence it.

4. Synthetic tones (no file)

from simvx.core import AudioStream, AudioStreamPlayer

beep = AudioStreamPlayer(stream=AudioStream.tone(440))   # 440 Hz, 1 s
root.add_child(beep)
beep.play()

AudioStream: audio sources

AudioStream and the AudioStreamPlayer* constructors accept:

Source

Use case

str / pathlib.Path / any os.PathLike

Filesystem audio file.

Resource(package, name)

Asset shipped inside a Python package.

importlib.resources.Traversable

Raw importlib.resources.files(pkg) / name.

AudioStream.tone(freq_hz, duration=1.0, volume=0.3)

Procedural sine with 20 ms fade-in/out.

AudioStream.from_pcm(samples, sample_rate=..., channels=...)

Wrap a float32 ndarray (interleaved stereo or mono). sample_rate and channels are required: omitting them used to silently pitch-shift the buffer.

AudioStream.empty(name="empty")

Synthetic placeholder with no audio data. Replaces the legacy AudioStream("") sentinel, which now raises InvalidStreamError.

The container format is sniffed from the file header (wav / ogg / mp3 / flac / pcm for synthetic / unknown for failures); the streaming open path uses it to pick the right decoder rather than guessing from the extension.

Sharing a decoded stream between players:

from pathlib import Path
from simvx.core import AudioStream, AudioStreamPlayer

stream = AudioStream(Path("music/boss_fight.ogg"))
player1 = AudioStreamPlayer(stream=stream)
player2 = AudioStreamPlayer(stream=stream)

On the native backend, file-backed streams are opened directly via ma_sound_init_from_file; ndarray-backed streams (synthetic / AudioSynth.bake()) route through ma_audio_buffer. The legacy backend caches decoded PCM in stream.backend_data after the first play so a second player skips decode.

Supported formats: WAV (uncompressed; streaming path parses RIFF chunks itself), OGG, MP3, FLAC (decoded by miniaudio on desktop; by AudioContext.decodeAudioData in the browser for memory mode).

Audio Player Nodes

All three players share _AudioPlaybackMixin, which provides play, stop, pause, is_playing, is_paused, fade_in, fade_out, crossfade_to, pitch_modulate, and (on the non-positional player) get_playback_position.

Property mutation policy. Each Property’s hint string carries a [live] or [next_play] tag:

  • [live] Properties (volume_db, pitch_scale, pan_override, spatial max_distance / attenuation / doppler_scale) push to the active backend channel on change: no retrigger needed.

  • [next_play] Properties (bus, loop, autoplay, stream_mode, buffer_size, queue_free_on_end) require a fresh play() call to take effect. Mutating one mid-playback raises AudioMutationDuringPlaybackError in strict mode (dev default) or warns once and defers in non-strict mode.

AudioStreamPlayer

Non-positional player for background music and UI sounds.

Property

Default

Range / values

Policy

volume_db

0.0

-80.0 to 24.0

live

pitch_scale

1.0

0.5 to 2.0

live (via pitch_modulate for active channels)

bus

"Master"

enum Master / Music / SFX / Voice / UI

next_play

autoplay

False

bool

next_play

loop

False

bool

next_play

queue_free_on_end

False

bool

next_play

stream_mode

"memory"

"memory" | "streaming"

next_play

buffer_size

65536

4096 to 524288 (bytes)

next_play

Arbitrary bus strings outside the enum still work, but they must be present in the active AudioBusLayout or playback raises UnknownBusError.

Signals:

  • stream_open_failed(path: str): emitted when stream_mode="streaming" cannot open or parse the source.

Methods:

  • play(from_position=0.0): start (or resume from pause) playback. from_position is in seconds and seeks the source cursor before the first sample renders.

  • stop(): stop the channel and close any streaming file handle.

  • pause() / is_paused(): resumed via play() (without from_position, which would re-seek).

  • is_playing(): true while not paused.

  • fade_in(seconds): start (or continue) playback at silence and ramp up to volume_db over the duration.

  • fade_out(seconds): linear-dB ramp to silence, then stop().

  • crossfade_to(other, seconds): start other silently, fade self out while fading other in.

  • pitch_modulate(scale): live pitch shift on an already-playing channel (clamped to pitch_scale range).

  • get_playback_position() -> float: current cursor in seconds.

AudioStreamPlayer2D

2D positional player. Volume attenuates with distance from the current AudioListener2D; stereo pan derives from horizontal offset.

Inherits the playback Properties above. Defaults that differ:

Property

Default

Range

Policy

bus

"SFX"

enum as above

next_play

max_distance

2000.0

1.0 to 10000.0 (pixels)

live

attenuation

1.0

0.1 to 4.0

live

pan_override

None

float in [-1, 1] or None

live

gizmo_colour

(0.6, 0.4, 1.0, 0.5)

RGBA

:

set_pan_and_gain(pan, gain_db) writes both Properties as a single backend push for sample-accurate combined changes. get_gizmo_lines() returns a 32-segment circle showing max_distance for the editor.

AudioStreamPlayer3D

3D spatial player. Volume attenuates with distance from the current AudioListener3D; stereo pan is the dot product with the listener’s right vector (forward × up); pitch shifts with radial velocity when doppler_scale > 0.

Inherits the playback Properties above. Defaults that differ:

Property

Default

Range

Policy

bus

"SFX"

enum as above

next_play

max_distance

100.0

1.0 to 1000.0 (world units)

live

attenuation

1.0

0.1 to 4.0

live

doppler_scale

0.0

0.0 to 4.0

live

pan_override

None

float in [-1, 1] or None

live

gizmo_colour

(0.6, 0.4, 1.0, 0.5)

RGBA

:

get_gizmo_lines() returns three orthogonal circles forming a wireframe sphere at max_distance. set_pan_and_gain(pan, gain_db) mirrors the 2D variant.

AudioListener2D / AudioListener3D

Scene-tree nodes that receive spatial audio: the audio analogue of Camera2D / Camera3D. Place one in the scene; the most recently entered listener becomes the current one for its tree, and every positional audio player computes attenuation, pan, and (for 3D) Doppler relative to it.

from simvx.core import AudioListener3D, Vec3
from simvx.core.nodes_3d.camera import Camera3D

class MyScene(Node3D):
    def on_ready(self):
        cam = self.add_child(Camera3D())
        cam.add_child(AudioListener3D())     # follows camera pose

Properties:

  • auto_current (bool, default True): become the current listener on enter_tree. Set False if you want to manage active listeners explicitly via listener.make_current().

Methods:

  • make_current(): promote this listener to the active one for its scene tree; pushes the new pose to the backend immediately.

AudioListener3D extras:

  • velocity (Vec3, plain attribute): listener velocity in m/s for Doppler. Not a Property because Vec3 isn’t scalar-serialisable; assignments forward to backend.set_listener_velocity so the native spatializer sees the velocity every push. The engine does not compute it automatically: set it from a camera-follow controller, or leave it at zero to disable Doppler regardless of any source’s doppler_scale.

Orientation (forward, up, right) is inherited from Node3D, so the listener’s rotation directly drives its pose.

Lazy fallback. Audio players reach the current listener via tree.audio_listener_3d() / tree.audio_listener_2d(). If none has been added, the engine auto-creates one parented to the active camera and logs a one-time warning (audio.listener.autocreated_3d / audio.listener.autocreated_2d). Add an explicit listener to silence it. If there’s no camera either, the warning is audio.listener.no_camera and the audio degrades to listener-at-origin.

The legacy singleton AudioListener.get() has been removed; calling it raises AudioError with a migration pointer. The class survives only as a stub for clean import errors during migration.

Audio Buses

The default layout has five buses, all using TitleCase Godot-convention names (Master, Music, SFX, Voice, UI), each non-master bus routed to Master via send_to. Bus names are case-sensitive: "master" does not match "Master" and raises UnknownBusError with the available names listed in the error message.

from simvx.core.audio_bus import AudioBusLayout

layout = AudioBusLayout.get_default()
layout.get_bus("Music").volume_db = -6.0
layout.get_bus("SFX").mute = True

# Effective volume walks the send_to chain to Master:
effective = layout.get_bus("SFX").effective_volume

AudioBus exposes:

Member

Notes

volume_db

Property, range [-80, 24], clamped on every write.

mute

Property (bool). Walks: any muted bus along the chain forces effective volume to -80 dB.

solo

Property (bool). When any non-master bus is soloed, every non-solo, non-ancestor-of-solo, non-Master bus is gated to silence. Master is always exempt.

send_to

Parent bus name (empty for root). Validated in add_bus: referencing an unknown target raises UnknownBusError.

linear_volume

volume_db converted to linear.

effective_volume

Sum along the chain, with mute/solo gating.

effective_linear_volume

effective_volume in linear units.

add_effect(effect) / remove_effect(effect) / effects

Per-bus DSP chain (see below).

AudioBusLayout exposes add_bus, remove_bus (Master is protected), get_bus, has_bus, buses, bus_names, to_dict, from_dict. The singleton lives at AudioBusLayout.get_default(); reset() drops it (for tests).

Each tick, SceneTree calls backend.sync_bus_layout(layout). The native backend diffs (volume_db, mute, send_to) per bus and pushes only on change, plus reconciles the effect chain whenever its signature changes. The legacy backend re-snapshots gains per audio period during mixing. The web backend diffs the layout when its drain runs (so volume / mute updates land live); its sync_bus_layout method itself is a no-op since the JS bridge already drives off the per-drain diff.

Audio Effects

Buses route through chains of typed AudioEffect instances from simvx.core.audio_effect. The Python layer owns parameters and ordering; backends materialise the chain natively (ma_engine’s ma_node_graph on desktop; Web Audio AudioNode chains on the web bridge).

from simvx.core import AudioBusLayout
from simvx.core.audio_effect import (
    LowPassFilter, ReverbEffect, CompressorEffect, ParametricEQ, EQBand,
)

layout = AudioBusLayout.get_default()
music = layout.get_bus("Music")
music.add_effect(LowPassFilter(cutoff_hz=4000.0, q=1.2))
music.add_effect(ReverbEffect(room_size=0.7, wet=0.3, width=1.0, freeze=False))
sfx = layout.get_bus("SFX")
sfx.add_effect(CompressorEffect(threshold_db=-18.0, ratio=4.0, makeup_db=4.0))
sfx.add_effect(ParametricEQ(bands=[
    EQBand(type="lowshelf", freq=120.0, gain_db=-3.0),
    EQBand(type="peaking", freq=2500.0, q=1.4, gain_db=+2.0),
]))

Effects chain in declaration order (source effect[0] effect[N-1] send_to). Disable an effect without removing it via effect.enabled = False. The native backend rebuilds its ma_node chain only when an effect’s signature changes (rounded parameter tuple), so parameter sweeps are cheap.

Capability gating. Each effect class declares a required_capability (a Capability StrEnum member). Backends advertise their supported set via list_capabilities(); unsupported effects are skipped with a single warning per (effect, backend) pair.

Backend

Effect capabilities advertised

MiniaudioBackend (native)

gain, filter_biquad, parametric_eq, delay, reverb, softclip, compressor

_LegacyMiniaudioBackend

none: the Python mixer can’t run effects at acceptable cost.

WebAudioBackend

gain, filter_biquad, parametric_eq, delay, reverb, softclip, compressor

NullAudioBackend

none: silent.

v1 effects:

Effect

Parameters

Native

Web

GainEffect

volume_db

composed into output bus volume

GainNode

LowPassFilter

cutoff_hz, q

ma_lpf2 biquad (Q wired in Phase 2)

BiquadFilterNode (lowpass)

HighPassFilter

cutoff_hz, q

ma_hpf2 biquad (Q wired)

BiquadFilterNode (highpass)

BandPassFilter

cutoff_hz, q

ma_bpf2 biquad (Q wired)

BiquadFilterNode (bandpass)

NotchFilter

cutoff_hz, q

notch effect node

BiquadFilterNode (notch)

DelayEffect

time_seconds, feedback, wet, dry

DelayEffectNode

DelayNode + feedback GainNode

ReverbEffect

room_size, damping, wet, dry, width, freeze

FreeverbEffectNode (Schroeder; width + freeze wired)

ConvolverNode with generated IR

ParametricEQ

bands: list[EQBand] (peaking / lowshelf / highshelf)

chain of peak/loshelf/hishelf nodes

chain of BiquadFilterNode

SoftClipEffect

drive, output_gain

tanh waveshaper

WaveShaperNode with tanh curve

CompressorEffect

threshold_db, ratio, attack_ms, release_ms, knee_db, makeup_db

feed-forward compressor

DynamicsCompressorNode

Q on LP/HP/BP/notch is honoured on both web (true biquad) and native (Phase 2 wired ma_lpf2/hpf2/bpf2 cascades with proper resonance). ReverbEffect.freeze (infinite-sustain tail) and width (stereo width) were similarly wired in Phase 2.

There is no FadeEffect. Per-player fades on the player node (AudioStreamPlayer.fade_in / fade_out / crossfade_to) replace it: fades are a property of the playing source, not the bus.

Procedural Synthesis (AudioSynth)

simvx.core.audio_synth provides oscillators, noise, envelopes, per-source filters, and AudioSynth for runtime sound generation without audio assets.

from simvx.core import AudioSynth, Oscillator, ADSR

# Bake a one-shot sound.
synth = AudioSynth()
synth.add(
    Oscillator.sine(440.0),
    envelope=ADSR(attack=0.01, decay=0.1, sustain=0.6, release=0.2),
    gain=0.5,
)
pluck = synth.bake(duration=0.4)          # → AudioStream
player.stream = pluck
player.play()

# Multiple voices mix together.
chord = AudioSynth()
chord.add(Oscillator.sine(220.0), gain=0.5)
chord.add(Oscillator.sine(330.0), gain=0.3)              # fifth
chord.add(Oscillator.noise.white(seed=42), gain=0.1)     # hiss
player.stream = chord.bake(duration=1.0)

bake() returns an AudioStream whose backend_data is a float32 interleaved-stereo ndarray; both desktop backends and the web backend accept it directly. The stream carries the synth’s sample_rate and channels so the backend doesn’t pitch-shift it against its own rate.

Live procedural (mutable parameters):

synth = AudioSynth()
vid = synth.add(Oscillator.sine(220.0), gain=0.5)
driver = synth.attach_to(player, chunk_seconds=0.1)

# Later, mid-game:
synth.set_param(vid, "freq", 880.0)   # takes effect next chunk (<=100 ms)

attach_to(player, chunk_seconds=0.1, sample_rate=48000, channels=2) adds a small _AudioSynthDriver Node as a child of player. The driver calls backend.open_stream(bus=player.bus) and feeds chunk_seconds * sample_rate samples per on_process tick. Returns the driver so the caller can parent.remove_child(driver) to stop streaming.

The driver requires an AudioStreamingBackend; on NullAudioBackend (playback-only) it raises AudioCapabilityError with remediation pointing at the native install. The native path uses an ma_pcm_rb ring buffer (default 0.5 s, override via MiniaudioBackend.open_stream( buffer_seconds=)); the legacy path appends to a Python bytearray; the web path posts to an AudioWorkletNode. Underrun produces silence on all three.

Per-source filters (one-pole). Attach a simple LP/HP to a single voice via the filter= kwarg. Lighter than the bus-level LowPassFilter / HighPassFilter effects (which are 2nd-order biquads on web and on native); use these when you want the filter shape baked into the source’s audio:

from simvx.core import AudioSynth, Oscillator, ADSR
from simvx.core.audio_synth import LowPass

synth = AudioSynth()
synth.add(
    Oscillator.noise.white(),
    envelope=ADSR(attack=0.001, decay=0.0, sustain=1.0, release=0.05),
    filter=LowPass(800),         # warm filtered-noise impact
    gain=0.5,
)
impact = synth.bake(duration=0.1)

Building blocks (from simvx.core.audio_synth):

Class

Notes

Oscillator.sine(freq, phase=0)

Pure sine, phase-continuous across chunks.

Oscillator.square(freq, duty=0.5)

Pulse-width adjustable (clamped 0.01-0.99).

Oscillator.saw(freq), Oscillator.triangle(freq)

Classic.

Oscillator.noise.white(seed=None)

Uniform-distribution white noise.

Oscillator.noise.pink(seed=None)

Voss-McCartney pink noise (16 rows).

ADSR(attack, decay, sustain, release)

All in seconds; sustain is a level. Release truncates if duration is short.

Linear(start, end), Exponential(start, end, power)

Straight-line / exponential envelopes. Exponential requires positive endpoints.

LowPass(cutoff_hz), HighPass(cutoff_hz)

First-order one-pole per-source filters (use filter=).

AudioSynth.render_chunk(cursor_samples, n_samples, ...) is the streaming primitive used by attach_to; envelopes are not applied here (they belong to baked clips whose envelope spans the whole duration).

Backends + Protocol split

The audio system talks to three thin structural Protocol types (simvx.core.audio_protocol):

  • AudioPlaybackBackend: start / stop / pause / live-update sounds, plus listener pose endpoints (set_listener_position / _velocity / _direction / _world_up).

  • AudioStreamingBackend: open_stream / feed_audio_chunk for chunk-fed PCM (AudioSynth, AudioWorklet, compressed-container streaming).

  • AudioBusBackend: sync_bus_layout + list_capabilities.

AudioBackend is a union Protocol over all three: kept for callers that genuinely need every facet (e.g. MiniaudioBackend). Narrower callers should depend on the smallest Protocol they actually use and reach the backend via SceneTree.audio_playback, audio_streaming, or audio_buses so the type checker enforces the boundary:

from simvx.core.audio_protocol import AudioStreamingBackend

backend = self.tree.audio_streaming   # None or AudioStreamingBackend
if backend is None:
    raise AudioCapabilityError(
        "streaming",
        backend=type(self.tree.audio_backend).__name__,
        advertised=self.tree.audio_backend.list_capabilities(),
        remediation="Install the native extension or use stream_mode='memory'.",
    )

Capability is a StrEnum, so Capability.PLAY_BASIC in caps works against either a frozenset[Capability] or a legacy frozenset[str], and string literals in backend code are caught by the type checker. Members: PLAY_BASIC, PLAY_2D, PLAY_3D, SPATIAL_HRTF, SPATIAL_DOPPLER, STREAMING, STREAMING_WAV, STREAMING_OGG, STREAMING_MP3, STREAMING_FLAC, EFFECT_GAIN, EFFECT_FILTER_BIQUAD, EFFECT_PARAMETRIC_EQ, EFFECT_DELAY, EFFECT_REVERB, EFFECT_COMPRESSOR, EFFECT_SOFTCLIP.

Resolution. make_backend(sample_rate=48000, nchannels=2) resolves at runtime in this order: the runtime never invokes a C compiler; that’s the install-time build hook’s job (see below):

  1. Native MiniaudioBackend (~20 ms latency, GIL-immune mixing). Selected when the compiled _simvx_miniaudio_engine extension imports cleanly.

  2. _LegacyMiniaudioBackend (~100 ms pure-Python mixer running on miniaudio’s audio thread). Selected when the native extension is unavailable and SIMVX_ALLOW_LEGACY_AUDIO != "0". Supports every playback / streaming / bus call: only latency differs. Advertises no effect.* capabilities, so bus effects are skipped on this path.

  3. NullAudioBackend (silent). Selected when even legacy can’t start. Calls return valid channel ids; nothing is heard. Does not implement AudioStreamingBackend, so AudioSynth.attach_to and streaming player modes raise AudioCapabilityError here.

MiniaudioBackend (and the legacy + null backends) register an atexit hook so miniaudio’s audio thread is joined cleanly even when callers bypass App.quit() with sys.exit.

Note

Native HRTF spatializer is deferred. The native backend still computes 3D pan + attenuation in Python (via _compute_3d_state) and pushes the result per frame via update_audio_3d. The C-side ma_engine spatializer (true HRTF, hardware Doppler) is wired but not yet used. Tracked in BUGS.md as bug-audio-native-ma-engine-spatializer-dead-code (Phase 1c). Current 3D audio is correct but does not yet use the canonical native path.

Install + runtime modes

See Installation for the full install matrix. In summary, three install-time env vars control how the C extension is compiled:

Env var

Behaviour at install

(default)

Tries to compile. On failure prints a stderr WARNING; install succeeds.

SIMVX_SKIP_AUDIO_BUILD=1

Skips compile entirely. Quiet.

SIMVX_REQUIRE_AUDIO_NATIVE=1

Fails install if compile fails (for CI).

…and one runtime env var controls the fallback chain:

Env var

Effect

(default)

Native → legacy (with WARNING) → null (with WARNING).

SIMVX_ALLOW_LEGACY_AUDIO=0

Native or AudioBackendUnavailable. No silent degradation.

To rebuild the extension after install without re-running pip:

uv run --with setuptools simvx build-audio

Strict mode + error types

SIMVX_AUDIO_STRICT (default "1", dev) controls whether historically silent code paths raise or warn-once. Shipping games typically set SIMVX_AUDIO_STRICT=0 so a misconfigured asset doesn’t crash the player; development runs strict so bugs surface early.

All audio exceptions live in simvx.core.audio_errors and inherit from AudioError:

Exception

Raised when

AudioError

Base class for except clauses.

AudioBackendUnavailable

Backend selection or initialisation failed (with SIMVX_ALLOW_LEGACY_AUDIO=0 set).

UnknownBusError

A player references a bus that isn’t in the active layout. Message lists the available bus names.

InvalidStreamError

Stream source unrecognised or unsupported by the active backend; also raised for AudioStream("") (use AudioStream.empty()) and for invalid from_pcm rate/channels.

AudioCapabilityError

Backend doesn’t advertise the requested capability (e.g. streaming on NullAudioBackend). Carries capability, backend, advertised, optional remediation.

AudioMutationDuringPlaybackError

A next_play Property was written while a channel was active. Strict-mode only.

Two helpers gate strict vs lenient mode:

  • warn_once(key, msg, *args, exc_info=False): log a WARNING the first time key is seen, then suppress. Use for non-fatal failures inside on_process ticks (would otherwise flood the log).

  • raise_or_warn(exc, *, key, message): re-raise wrapped in AudioError under strict mode; otherwise warn once. Use at cleanup boundaries where the surrounding code can continue.

Performance Tips

  1. Use OGG / FLAC for music: smaller file than WAV.

  2. Use WAV for short SFX: fast decode, no codec overhead.

  3. Limit active 3D sounds: each 3D player runs Doppler math and a backend update every on_process tick.

  4. Share decoded streams: construct AudioStream(...) once and pass it to multiple players (backend_data caches the PCM on the legacy path; the native path opens the file once per sound).

  5. Bake long synths once: AudioSynth.bake() is pure numpy; the resulting AudioStream plays through any backend with no per-frame cost. Reserve attach_to for synths whose parameters actually need to change mid-play.

  6. Push spatial properties through Properties, not retriggers: the [live] Properties (volume_db, max_distance, pan_override, …) reach the backend without a stop() / play() cycle.

Troubleshooting

No sound at all. Check the resolved backend by enabling WARNING logs:

import logging; logging.basicConfig(level=logging.WARNING)

A “Native audio extension not available; using legacy mixer” or “using silent NullAudioBackend” line identifies which fallback is in use. To get the native mixer back, run uv run --with setuptools simvx build-audio.

UnknownBusError. Your player’s bus doesn’t match a bus name in the layout. Bus names are case-sensitive: "sfx" is not "SFX". The error message lists the available names; either fix the typo or call AudioBusLayout.get_default().add_bus("MyBus", send_to="Master") before referencing it.

AudioCapabilityError: streaming. The active backend doesn’t implement AudioStreamingBackend: usually because you’re on NullAudioBackend (no audio device or miniaudio package missing). Install the native extension, or switch to stream_mode="memory" / AudioSynth.bake() instead of attach_to.

AudioMutationDuringPlaybackError. You wrote a next_play Property (e.g. player.loop = True) while a channel was active. Stop the player, change the property, then play() again. Set SIMVX_AUDIO_STRICT=0 to defer the change to the next play() instead of raising.

Bus muted. Walk the layout: AudioBusLayout.get_default().get_bus("Music").effective_volume. Any muted bus along the send_to chain returns -80 dB. If you’ve used solo anywhere, remember solo on a non-master bus gates every non-solo non-ancestor non-Master bus to silence.

3D panning stuck / silent. Make sure you have an AudioListener3D in the scene (or that the auto-created fallback warning fired). For Doppler, set listener.velocity = Vec3(...) from a camera-follow controller: the engine doesn’t compute it for you.

Crackling / stuttering on the legacy backend. Known limitation of the Python mixer (audio thread runs under the GIL). Build the native extension: it moves mixing into C off-thread.

stream_open_failed signal fires. stream_mode="streaming" validates the container from the file header. WAV runs through the engine-side header parser; OGG / MP3 / FLAC hand off to the backend’s native decoder. Synthetic PCM streams (from_pcm / tone) can’t stream; use stream_mode="memory". Connect to the signal to fall back to a placeholder.

Example: Complete Game Audio

from simvx.core import (
    AudioListener3D,
    AudioStreamPlayer,
    AudioStreamPlayer3D,
    Input,
    InputMap,
    MouseButton,
    Node,
    Vec3,
)
from simvx.core.nodes_3d.camera import Camera3D
from simvx.graphics import App


class GameScene(Node):
    def __init__(self):
        super().__init__()

        # Background music on the Music bus.
        music = AudioStreamPlayer(
            stream="music/gameplay.ogg",
            volume_db=-8.0,
            loop=True,
            autoplay=True,
            bus="Music",
        )
        self.add_child(music)

        # UI click sound on the UI bus.
        self.ui_click = AudioStreamPlayer(
            stream="ui/button_click.wav",
            bus="UI",
        )
        self.add_child(self.ui_click)

        # 3D ambient (waterfall) on SFX.
        waterfall = AudioStreamPlayer3D(
            stream="ambient/waterfall.ogg",
            position=Vec3(20, 0, 10),
            max_distance=30.0,
            loop=True,
            autoplay=True,
            bus="SFX",
        )
        self.add_child(waterfall)

        # Camera + AudioListener3D parented to it so the listener pose
        # follows the camera every frame.
        camera = Camera3D(position=(0, 5, 10))
        self.add_child(camera)
        camera.add_child(AudioListener3D())

    def on_ready(self):
        # InputMap.add_action must live in on_ready (web exporter skips main()).
        InputMap.add_action("click", [MouseButton.LEFT])

    def on_process(self, dt):
        if Input.is_action_just_pressed("click"):
            self.ui_click.play()


if __name__ == "__main__":
    app = App(width=1280, height=720, title="Game")
    app.run(GameScene())

API Reference

  • packages/core/src/simvx/core/audio.py: AudioStream, AudioStreamPlayer, AudioStreamPlayer2D, AudioStreamPlayer3D.

  • packages/core/src/simvx/core/audio_listener.py: AudioListener2D, AudioListener3D, _autocreate_listener_*.

  • packages/core/src/simvx/core/audio_bus.py: AudioBus, AudioBusLayout.

  • packages/core/src/simvx/core/audio_effect.py: AudioEffect and v1 effect subclasses (GainEffect, LowPassFilter, HighPassFilter, BandPassFilter, NotchFilter, DelayEffect, ReverbEffect, ParametricEQ / EQBand, SoftClipEffect, CompressorEffect). Per-player fades live on the player node (fade_in, fade_out, crossfade_to); there is no FadeEffect.

  • packages/core/src/simvx/core/audio_synth.py: AudioSynth, Oscillator, WhiteNoise, PinkNoise, ADSR, Linear, Exponential, LowPass, HighPass, AudioSource, Envelope, Filter.

  • packages/core/src/simvx/core/audio_protocol.py: AudioPlaybackBackend, AudioStreamingBackend, AudioBusBackend, union AudioBackend, Capability StrEnum, CAPABILITIES_CORE, PlayMode.

  • packages/core/src/simvx/core/audio_errors.py: AudioError, AudioBackendUnavailable, UnknownBusError, InvalidStreamError, AudioCapabilityError, AudioMutationDuringPlaybackError, warn_once, raise_or_warn, STRICT flag.

  • packages/core/src/simvx/core/audio_backend.py: MiniaudioBackend, _LegacyMiniaudioBackend, NullAudioBackend, make_backend.

  • packages/core/src/simvx/core/_native/miniaudio_engine.py: CFFI wrapper around the compiled _simvx_miniaudio_engine extension.

  • packages/core/src/simvx/core/_native/miniaudio_engine_build.py: CFFI build script invoked by simvx build-audio.

  • packages/web/src/simvx/web/audio/web_backend.py: WebAudioBackend (Pyodide-side).

  • packages/web/src/simvx/web/runtime/js/audio_bridge.js: JS bridge that materialises the bus / effect graph in Web Audio.

  • packages/graphics/examples/audio_demo.py: interactive demo.

  • packages/core/tests/test_audio*.py: test coverage.