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(duration), fade_out(duration), fade_to(gain, duration), crossfade(other, duration) (replaces the removed bus-level FadeEffect). Every duration is in seconds.

  • 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 AudioPlayer

music = AudioPlayer(
    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 AudioPlayer2D, Resource, Vec2

explosion = AudioPlayer2D(
    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, AudioPlayer3D, 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 = AudioPlayer3D(
            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 AudioPlayer3D 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 AudioClip, AudioPlayer

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

AudioClip: audio sources

AudioClip and the AudioPlayer* 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.

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

Procedural sine with 20 ms fade-in/out.

AudioClip.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.

AudioClip.empty(name="empty")

Synthetic placeholder with no audio data. Replaces the legacy AudioClip("") 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.

clip.duration is how long it plays for in seconds. A synthetic clip is measured from the buffer it carries; a file-backed one from its container’s metadata, read through the same decoder that plays it, so no audio device is opened and asking twice costs one stat. It is float | None, and None means the length is unknown rather than zero: AudioClip.empty(), a file that is missing or truncated, a codec this build of miniaudio lacks, or a raw buffer assigned to backend_data by hand, which declares no rate or channel count to divide by.

Sharing a decoded stream between players:

from pathlib import Path
from simvx.core import AudioClip, AudioPlayer

stream = AudioClip(Path("music/boss_fight.ogg"))
player1 = AudioPlayer(stream=stream)
player2 = AudioPlayer(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), 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, fade_to, crossfade, 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.

AudioPlayer

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, per-frame ceiling)

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 release any streaming decoder.

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

  • is_playing(): true while not paused.

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

  • fade_out(duration): ramp to silence over duration seconds, then stop().

  • fade_to(gain, duration): ramp to gain (a multiplier on volume_db, 1.0 being that volume) over duration seconds and hold there.

  • crossfade(other, duration): start other silently, fade self out while fading other in, both landing duration seconds from now.

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

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

Streaming mode. stream_mode="streaming" plays a file without loading it whole. Two things can decode it, and the player picks between them from what the backend advertises:

  • A backend with its own streaming decoder for the container (Capability.STREAMING_OGG / _MP3 / _FLAC, which the native desktop backend advertises) is handed the clip and the loop flag, and does the work itself.

  • Anything else is opened as a plain PCM ring and fed by the player, which decodes the file to the backend’s own sample rate and channel count. This is how a WAV always plays, and how a compressed container plays on the legacy backend.

Either way the source’s own format is irrelevant: a 24-bit, float, mono or 22.05 kHz file plays as itself. The feed is sized to the room the backend reports, so it runs at the speed of the sound rather than the speed of the frame loop, and buffer_size caps how much a single frame may write. play(from_position=...) seeks the decoder before the first sample.

Streaming a file on web. A web export cannot do it, in any container. Both routes above need a decoder in the Python process: the first needs one in the backend, and the browser’s decoders belong to the browser rather than to WebAudioBackend; the second needs one in the player, and the browser runtime ships no Python decoder at all. stream_mode="streaming" on a file therefore warns, emits stream_open_failed, and plays nothing. Use stream_mode="memory", which is what the browser is good at: the file’s bytes are handed to AudioContext.decodeAudioData, which decodes every container the page’s browser supports, off the main thread.

What does work on web is streaming that never decodes a file: AudioSynth and anything else that renders its own PCM and feeds it, which reaches the browser through an AudioWorkletNode unchanged. This is the one place a web export is narrower than the desktop by construction rather than by omission, and it is why Capability.STREAMING on the web backend means “can play a chunk-fed PCM channel” and never “can open that file for you”.

AudioPlayer2D

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

n/a

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.

AudioPlayer3D

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

n/a

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.

There is no AudioListener singleton: reach the active listener through the tree (tree.audio_listener_3d() / tree.audio_listener_2d()).

Audio Buses

The default layout has five buses, all using TitleCase 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 bus off the soloed bus’s routing path is gated to silence: the soloed bus, the buses routed into it and its ancestors stay audible. Master is always exempt.

send_to

The bus this one routes its signal into (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). remove_bus re-routes every bus that fed the removed one onto the removed bus’s own target (Master when it had none), so a layout never holds a send_to naming a bus that is not in it.

send_to routes signal, not just gain. A bus’s output, after its own effects, goes into its target’s gain and effects, so a reverb on Master reverberates every bus routed into Master. The native and web backends both build that graph (parented ma_sound_groups, parented GainNodes), which is why each bus’s node carries only its own volume: the graph multiplies the chain. The pure-Python fallback mixer runs no effects at all, so it folds the chain into one gain per bus instead.

Each tick, SceneTree calls backend.sync_bus_layout(layout). The native backend diffs each bus’s own gain and its routing target 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

BiquadFilterNode (lowpass)

HighPassFilter

cutoff_hz, q

ma_hpf2 biquad

BiquadFilterNode (highpass)

BandPassFilter

cutoff_hz, q

ma_bpf2 biquad

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, release, knee_db, makeup_db

feed-forward compressor (attack / release in seconds)

DynamicsCompressorNode

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

There is no FadeEffect. Per-player fades on the player node (AudioPlayer.fade_in / fade_out / fade_to / crossfade) 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)          # → AudioClip
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 AudioClip 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_update 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:

  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. A non-looping channel still finishes after the clip’s duration, so queue_free_on_end players reap on a machine with no audio; the elapsed time is read from the tree the backend was installed on (tree.now), not from a wall clock, so a headless run that ticks faster than real time finishes its sounds after the same number of ticks and a paused tree holds them where they are. Only that tree’s ticks count, so another tree built in the same process (an editor’s edited scene, which nothing ticks) changes nothing. That is the one place the silent backend deliberately differs from a device, whose playback cursor is driven by the hardware.

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

3D audio uses one shared spatializer across every backend. Pan + attenuation are computed Python-side (via _compute_3d_state) and pushed per frame through update_audio_3d, so native, web (StereoPannerNode) and legacy all sound identical. This is deliberate: a single stereo-pan + distance model keeps 3D games consistent across desktop and web. The native ma_engine HRTF path is an alternative, not used by default (HRTF would diverge from the web output and isn’t needed for typical game 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 AudioClip("") (use AudioClip.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_update 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_update tick.

  4. Share decoded streams: construct AudioClip(...) 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 AudioClip 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.

Failure handling and troubleshooting

Audio is best-effort: the engine runs (silently if it must) on any host. Two moments matter, and they behave differently:

  • Build time is when the C extension is compiled (simvx build-audio, or the install hook). Missing build tools or dependencies fail here with an actionable message; importing the audio modules afterwards still succeeds.

  • Runtime is make_backend(), called once when App.run() attaches a backend to the scene tree. It never invokes a compiler; it walks the fallback chain (see Resolution above) and degrades loudly, a one-time WARNING per fallback, never silently.

Environment variables

Three install-time vars control how the C extension is compiled; one runtime var controls the fallback chain. See Installation for the full install matrix.

Env var

When

Effect

(default)

install

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

SIMVX_SKIP_AUDIO_BUILD=1

install

Skips the compile entirely. Quiet.

SIMVX_REQUIRE_AUDIO_NATIVE=1

install

Fails the install if the compile fails (for CI).

(default)

runtime

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

SIMVX_ALLOW_LEGACY_AUDIO=0

runtime

Refuse the fallbacks: native or AudioBackendUnavailable, no silent degradation (useful for CI that requires the fast path).

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

uv run --with setuptools simvx build-audio

Diagnosing no sound

Enable WARNING logging to see which backend tier resolved:

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

A line like “Native audio extension not available; using legacy mixer (100 ms latency)” or “using silent NullAudioBackend” names the resolved tier and how to recover.

Symptom guide

Symptom

Cause

Remedy

Silent, WARNING names the legacy mixer

Native .so not built (no compiler at install), or stale / ABI-mismatched .so (renamed Python, corrupt extension): the wrapper catches the ImportError, module import still succeeds, is_available() reports False, backend falls back to legacy

Rebuild: uv run --with setuptools simvx build-audio

Silent, no exception raised

miniaudio PyPI package missing: the legacy constructor’s import miniaudio fails, resolving to NullAudioBackend; no exception escapes make_backend

uv pip install miniaudio, then rebuild the native extension

Silent on a headless host

No audio device (headless container, sandboxed CI), or ALSA / PulseAudio / PipeWire down: the device open fails, resolving to NullAudioBackend with a one-time WARNING; engine and nodes run normally

Expected; attach a device, start the audio server, or accept silence

Crackling / stuttering

Running on the legacy Python mixer (audio thread under the GIL)

Build the native extension: it moves mixing into C off-thread

AudioCapabilityError: streaming

Active backend doesn’t implement AudioStreamingBackend, usually NullAudioBackend (no device or miniaudio missing)

Install the native extension, or use stream_mode="memory" / AudioSynth.bake() instead of attach_to

simvx build-audio raises SystemExit

cffi / setuptools missing at build time (checked lazily inside build(), never at import, so module import is unaffected)

uv pip install cffi setuptools

UnknownBusError

Player’s bus doesn’t match a layout bus name; names are case-sensitive ("sfx" is not "SFX")

Fix the typo, or AudioBusLayout.get_default().add_bus("MyBus", send_to="Master") before referencing it; the error message lists the available names

AudioMutationDuringPlaybackError

A next_play Property (e.g. player.loop = True) written while a channel was active

Stop, change the property, play() again; or set SIMVX_AUDIO_STRICT=0 to defer the change to the next play()

A bus is silent

Muted along the send_to chain, or solo gating: solo on a non-master bus silences every bus off the soloed bus’s routing path

Walk AudioBusLayout.get_default().get_bus("Music").effective_volume; any muted bus in the chain returns -80 dB

3D panning stuck / silent

No AudioListener3D in the scene, or listener.velocity never set (for Doppler)

Add a listener (or confirm the auto-create warning fired); set listener.velocity = Vec3(...) from a camera-follow controller (the engine doesn’t compute it)

stream_open_failed signal fires

stream_mode="streaming" can’t reach or decode the source: a missing file, a container the header probe doesn’t recognise, or a synthetic PCM stream (from_pcm / tone), which has no file to stream

Use stream_mode="memory"; connect to the signal to fall back to a placeholder

stream_open_failed on a web export only

Streaming a file needs a Python decoder and the browser runtime ships none, whatever the container. Procedural streaming (AudioSynth.attach_to) is unaffected

Use stream_mode="memory" on web; the browser decodes the file itself

Handled automatically

  • Exit and crash cleanup. Every live backend registers an atexit hook that joins miniaudio’s native audio thread, so the process never deadlocks at exit even when a caller bypasses App.quit() with sys.exit. Prefer self.app.quit() regardless.

  • Web / Pyodide exports use WebAudioBackend (Web Audio API); the desktop backend, native wrapper, and C build script are never imported on the browser boot path.

  • run_headless and unit tests open no device and emit no warning: make_backend returns the native backend if built, else NullAudioBackend, so tests stay silent by design.

Example: Complete Game Audio

from simvx.core import (
    AudioListener3D,
    AudioPlayer,
    AudioPlayer3D,
    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 = AudioPlayer(
            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 = AudioPlayer(
            stream="ui/button_click.wav",
            bus="UI",
        )
        self.add_child(self.ui_click)

        # 3D ambient (waterfall) on SFX.
        waterfall = AudioPlayer3D(
            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_update(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: AudioClip, AudioPlayer, AudioPlayer2D, AudioPlayer3D.

  • 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, fade_to, crossfade); 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/demos/audio_demo.py: interactive demo.

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