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/AudioListener3Dnodes that work likeCamera2D/Camera3D: most recently entered is current; lazy fallback if none is added.Typed audio buses (
AudioBus) withvolume_db,mute,solo,send_torouting, 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, andpitch_modulatepropagate 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-levelFadeEffect). Every duration is in seconds.queue_free_on_endauto-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 |
|---|---|
|
Filesystem audio file. |
|
Asset shipped inside a Python package. |
|
Raw |
|
Procedural sine with 20 ms fade-in/out. |
|
Wrap a float32 ndarray (interleaved stereo or mono). |
|
Synthetic placeholder with no audio data. Replaces the legacy |
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, spatialmax_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 freshplay()call to take effect. Mutating one mid-playback raisesAudioMutationDuringPlaybackErrorin 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 |
|---|---|---|---|
|
|
|
live |
|
|
|
live (via |
|
|
enum |
next_play |
|
|
bool |
next_play |
|
|
bool |
next_play |
|
|
bool |
next_play |
|
|
|
next_play |
|
|
|
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 whenstream_mode="streaming"cannot open or parse the source.
Methods:
play(from_position=0.0): start (or resume from pause) playback.from_positionis 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 viaplay()(withoutfrom_position, which would re-seek).is_playing(): true while not paused.fade_in(duration): start (or continue) playback at silence and ramp up tovolume_dboverdurationseconds.fade_out(duration): ramp to silence overdurationseconds, thenstop().fade_to(gain, duration): ramp togain(a multiplier onvolume_db,1.0being that volume) overdurationseconds and hold there.crossfade(other, duration): startothersilently, fade self out while fading other in, both landingdurationseconds from now.pitch_modulate(scale): live pitch shift on an already-playing channel (clamped topitch_scalerange).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 theloopflag, 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 |
|---|---|---|---|
|
|
enum as above |
next_play |
|
|
|
live |
|
|
|
live |
|
|
float in |
live |
|
|
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 |
|---|---|---|---|
|
|
enum as above |
next_play |
|
|
|
live |
|
|
|
live |
|
|
|
live |
|
|
float in |
live |
|
|
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, defaultTrue): become the current listener onenter_tree. SetFalseif you want to manage active listeners explicitly vialistener.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 tobackend.set_listener_velocityso 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’sdoppler_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 |
|---|---|
|
Property, range |
|
Property (bool). Walks: any muted bus along the chain forces effective volume to |
|
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. |
|
The bus this one routes its signal into (empty for root). Validated in |
|
|
|
Sum along the chain, with mute/solo gating. |
|
|
|
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 |
|---|---|
|
|
|
none: the Python mixer can’t run effects at acceptable cost. |
|
|
|
none: silent. |
v1 effects:
Effect |
Parameters |
Native |
Web |
|---|---|---|---|
|
|
composed into output bus volume |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
notch effect node |
|
|
|
|
|
|
|
|
|
|
|
chain of peak/loshelf/hishelf nodes |
chain of |
|
|
tanh waveshaper |
|
|
|
feed-forward compressor ( |
|
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 |
|---|---|
|
Pure sine, phase-continuous across chunks. |
|
Pulse-width adjustable (clamped 0.01-0.99). |
|
Classic. |
|
Uniform-distribution white noise. |
|
Voss-McCartney pink noise (16 rows). |
|
All in seconds; sustain is a level. Release truncates if duration is short. |
|
Straight-line / exponential envelopes. |
|
First-order one-pole per-source filters (use |
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_chunkfor 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:
Native
MiniaudioBackend(~20 ms latency, GIL-immune mixing). Selected when the compiled_simvx_miniaudio_engineextension imports cleanly._LegacyMiniaudioBackend(~100 ms pure-Python mixer running on miniaudio’s audio thread). Selected when the native extension is unavailable andSIMVX_ALLOW_LEGACY_AUDIO != "0". Supports every playback / streaming / bus call: only latency differs. Advertises noeffect.*capabilities, so bus effects are skipped on this path.NullAudioBackend(silent). Selected when even legacy can’t start. Calls return valid channel ids; nothing is heard. Does not implementAudioStreamingBackend, soAudioSynth.attach_toand streaming player modes raiseAudioCapabilityErrorhere. A non-looping channel still finishes after the clip’s duration, soqueue_free_on_endplayers 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 |
|---|---|
|
Base class for |
|
Backend selection or initialisation failed (with |
|
A player references a bus that isn’t in the active layout. Message lists the available bus names. |
|
Stream source unrecognised or unsupported by the active backend; also raised for |
|
Backend doesn’t advertise the requested capability (e.g. streaming on |
|
A |
Two helpers gate strict vs lenient mode:
warn_once(key, msg, *args, exc_info=False): log a WARNING the first timekeyis seen, then suppress. Use for non-fatal failures insideon_updateticks (would otherwise flood the log).raise_or_warn(exc, *, key, message): re-raise wrapped inAudioErrorunder strict mode; otherwise warn once. Use at cleanup boundaries where the surrounding code can continue.
Performance Tips¶
Use OGG / FLAC for music: smaller file than WAV.
Use WAV for short SFX: fast decode, no codec overhead.
Limit active 3D sounds: each 3D player runs Doppler math and a backend update every
on_updatetick.Share decoded streams: construct
AudioClip(...)once and pass it to multiple players (backend_datacaches the PCM on the legacy path; the native path opens the file once per sound).Bake long synths once:
AudioSynth.bake()is pure numpy; the resultingAudioClipplays through any backend with no per-frame cost. Reserveattach_tofor synths whose parameters actually need to change mid-play.Push spatial properties through Properties, not retriggers: the
[live]Properties (volume_db,max_distance,pan_override, …) reach the backend without astop()/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 whenApp.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. |
|
install |
Skips the compile entirely. Quiet. |
|
install |
Fails the install if the compile fails (for CI). |
(default) |
runtime |
Native → legacy (WARNING) → null (WARNING). |
|
runtime |
Refuse the fallbacks: native or |
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 |
Rebuild: |
Silent, no exception raised |
|
|
Silent on a headless host |
No audio device (headless container, sandboxed CI), or ALSA / PulseAudio / PipeWire down: the device open fails, resolving to |
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 |
|
Active backend doesn’t implement |
Install the native extension, or use |
|
|
|
|
Player’s |
Fix the typo, or |
|
A |
Stop, change the property, |
A bus is silent |
Muted along the |
Walk |
3D panning stuck / silent |
No |
Add a listener (or confirm the auto-create warning fired); set |
|
|
Use |
|
Streaming a file needs a Python decoder and the browser runtime ships none, whatever the container. Procedural streaming ( |
Use |
Handled automatically¶
Exit and crash cleanup. Every live backend registers an
atexithook that joins miniaudio’s native audio thread, so the process never deadlocks at exit even when a caller bypassesApp.quit()withsys.exit. Preferself.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_headlessand unit tests open no device and emit no warning:make_backendreturns the native backend if built, elseNullAudioBackend, 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:AudioEffectand 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 noFadeEffect.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, unionAudioBackend,CapabilityStrEnum,CAPABILITIES_CORE,PlayMode.packages/core/src/simvx/core/audio_errors.py:AudioError,AudioBackendUnavailable,UnknownBusError,InvalidStreamError,AudioCapabilityError,AudioMutationDuringPlaybackError,warn_once,raise_or_warn,STRICTflag.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_engineextension.packages/core/src/simvx/core/_native/miniaudio_engine_build.py: CFFI build script invoked bysimvx 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.