Audio

Audio covers background music, UI sounds, 2D positional effects and 3D spatial sound. Three player nodes feed a bus graph with per-bus effect chains, AudioSynth makes procedural sound with no asset files, and an AudioListener2D / AudioListener3D node is where the player’s ears are.

Quick start

Background music

from simvx.core import AudioPlayer

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

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,
)
self.add_child(explosion)
explosion.play()

3D spatial audio

from simvx.core import AudioListener3D, AudioPlayer3D, Camera3D, Node3D, Vec3

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

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

A tone with no file

from simvx.core import AudioClip, AudioPlayer

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

Audio sources: AudioClip

AudioClip and the players’ stream accept:

Source

Use

str, pathlib.Path or any os.PathLike

an audio file

Resource(package, name)

an asset shipped inside a Python package

importlib.resources.Traversable

importlib.resources.files(pkg) / name

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

a sine with a 20 ms fade in and out

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

a float32 ndarray, interleaved stereo or mono; rate and channels are required

AudioClip.empty()

a placeholder with no audio

Formats: WAV, OGG, MP3 and FLAC, recognised from the file header rather than the extension. miniaudio decodes them on desktop; in the browser, AudioContext.decodeAudioData does.

clip.duration is the length in seconds, read from the file’s metadata without opening a device. It is None when the length is unknown: an empty clip, a missing or truncated file, or a codec this build lacks.

To share one decoded sound between players, build the AudioClip once and pass it to each:

stream = AudioClip(Path("music/boss_fight.ogg"))
player1 = AudioPlayer(stream=stream)
player2 = AudioPlayer(stream=stream)

Player nodes

AudioPlayer, AudioPlayer2D and AudioPlayer3D share the playback API:

Method

Does

play(from_position=0.0)

start, or resume from pause; from_position seeks, in seconds

stop()

stop and release any streaming decoder

pause(), is_paused(), is_playing()

pause; play() without a position resumes

fade_in(duration)

start at silence and ramp up to volume_db

fade_out(duration)

ramp to silence, then stop()

fade_to(gain, duration)

ramp to gain (a multiplier, 1.0 being volume_db) and hold

crossfade(other, duration)

fade this one out while other fades in

pitch_modulate(scale)

shift pitch on the playing channel, within pitch_scale’s range

Every duration is in seconds. AudioPlayer also has get_playback_position().

Live and next-play properties. A [live] property in a property’s hint (volume_db, pitch_scale, pan_override, max_distance, attenuation, doppler_scale) reaches the playing channel at once. A next-play property (bus, loop, autoplay, stream_mode, buffer_size, queue_free_on_end) takes effect at the next play(). Writing one while playing raises AudioMutationDuringPlaybackError in strict mode, or warns once and defers otherwise.

AudioPlayer

Non-positional: music and UI sounds.

Property

Default

Values

volume_db

0.0

-80.0 to 24.0

live

pitch_scale

1.0

0.5 to 2.0

live

bus

"Master"

Master, Music, SFX, Voice, UI, or any bus in the layout

next play

autoplay

False

next play

loop

False

next play

queue_free_on_end

False

remove the node when playback ends

next play

stream_mode

"memory"

"memory" or "streaming"

next play

buffer_size

65536

4096 to 524288 bytes written per frame at most

next play

A bus name not in the active AudioBusLayout raises UnknownBusError.

Streaming. stream_mode="streaming" plays a file without loading it whole. The desktop accelerator decodes OGG, MP3 and FLAC itself; otherwise the player decodes and feeds the backend. Any sample format, rate or channel count plays as itself. stream_open_failed(path) fires when the source cannot be opened.

Streaming a file is not available in a web export, in any container: the browser runtime has no Python decoder. A streaming file warns, emits stream_open_failed and plays nothing. Use stream_mode="memory", where the browser decodes the file off the main thread. Procedural streaming (AudioSynth.attach_to) works on the web.

AudioPlayer2D

Volume falls off with distance from the current AudioListener2D, and pan follows the horizontal offset.

Property

Default

Values

bus

"SFX"

as above

max_distance

2000.0

1.0 to 10000.0 pixels

attenuation

1.0

0.1 to 4.0

pan_override

None

-1 to 1, or None to follow position

volume_db, pitch_scale, autoplay, loop and queue_free_on_end are as on AudioPlayer. set_pan_and_gain(pan, gain_db) writes both in one backend update.

AudioPlayer3D

Volume falls off with distance from the current AudioListener3D, pan follows the listener’s right vector, and pitch shifts with radial velocity when doppler_scale > 0.

Property

Default

Values

bus

"SFX"

as above

max_distance

100.0

1.0 to 1000.0 world units

attenuation

1.0

0.1 to 4.0

doppler_scale

0.0

0.0 to 4.0

pan_override

None

-1 to 1, or None

Spatial audio is computed the same way on every backend (distance attenuation and stereo pan), so a 3D game sounds the same on desktop and in the browser.

Listeners

AudioListener2D and AudioListener3D are where the player hears from, and work like cameras: the most recently entered listener is current for its tree. Parent one under the camera so it follows.

  • auto_current (default True): become current on entering the tree. Set False and call make_current() to choose yourself.

  • AudioListener3D.velocity (Vec3): the listener’s velocity for Doppler. The engine does not compute it; set it from your camera controller, or leave it at zero for no Doppler.

  • The listener’s orientation is its Node3D rotation.

With no listener in the tree, the engine creates one under the active camera and logs a one-time warning; add an explicit listener to silence it. With no camera either, the listener is at the origin. tree.audio_listener_3d() and tree.audio_listener_2d() return the current one.

Buses

The default layout has Master, Music, SFX, Voice and UI, each routed to Master. Names are case-sensitive: "sfx" raises UnknownBusError, which lists the names that exist.

from simvx.core import AudioBusLayout

layout = AudioBusLayout.get_default()
layout.get_bus("Music").volume_db = -6.0
layout.get_bus("SFX").mute = True
layout.add_bus("Ambience", send_to="Master")

AudioBus member

Meaning

volume_db

-80 to 24 dB

mute

a muted bus silences everything routed through it

solo

when any non-master bus is soloed, every bus off its routing path is silenced

send_to

the bus this one feeds; an unknown name raises UnknownBusError

linear_volume

volume_db as a linear gain

effective_volume, effective_linear_volume

the volume along the whole chain, with mute and solo applied

add_effect(effect), remove_effect(effect), effects

the bus’s effect chain

AudioBusLayout has add_bus, remove_bus (not Master), get_bus, has_bus, buses, bus_names, to_dict and from_dict. Removing a bus reroutes what fed it to its own target.

send_to routes signal, not just gain: a bus’s output, after its effects, goes through its target’s gain and effects, so a reverb on Master reverberates everything. Changes to the layout reach the backend every frame.

Effects

A bus runs a chain of AudioEffects, in the order added (source -> effect[0] -> ... -> send_to). effect.enabled = False bypasses one without removing it, and changing parameters is cheap.

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

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),
]))

Effect

Parameters

GainEffect

volume_db

LowPassFilter, HighPassFilter, BandPassFilter, NotchFilter

cutoff_hz, q (resonant biquads)

DelayEffect

time_seconds, feedback, wet, dry

ReverbEffect

room_size, damping, wet, dry, width, freeze (freeze sustains the tail)

ParametricEQ

bands: a list of EQBand (peaking, lowshelf, highshelf)

SoftClipEffect

drive, output_gain

CompressorEffect

threshold_db, ratio, attack, release, knee_db, makeup_db (times in seconds)

Effects run on the desktop accelerator and in the browser. Without the accelerator they are skipped, with one warning per effect (see Without the accelerator). Each effect names the backend capability it needs, and a backend that lacks it skips the effect the same way.

Fades are per player (fade_in, fade_out, fade_to, crossfade), not a bus effect: a fade belongs to the sound that is playing.

Procedural synthesis: AudioSynth

AudioSynth mixes oscillators, noise, envelopes and filters into sound with no asset files.

from simvx.core import ADSR, AudioSynth, Oscillator

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)
player.stream = synth.bake(duration=0.4)       # an AudioClip
player.play()

chord = AudioSynth()
chord.add(Oscillator.sine(220.0), gain=0.5)
chord.add(Oscillator.sine(330.0), gain=0.3)
chord.add(Oscillator.noise.white(seed=42), gain=0.1)
player.stream = chord.bake(duration=1.0)

bake(duration, sample_rate=48000, channels=2) returns an AudioClip that plays on every backend with no per-frame cost.

Live synthesis. For parameters that change while it plays, attach the synth to a player:

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

synth.set_param(voice, "freq", 880.0)   # heard from the next chunk

attach_to(player, chunk_seconds=0.1, sample_rate=48000, channels=2) adds a child node to the player that feeds it one chunk per frame; remove that node to stop. It needs a backend that can stream, and raises AudioCapabilityError on the silent backend. Envelopes apply to baked clips, not to live streams.

Per-voice filters. filter= bakes a one-pole low- or high-pass into one voice, lighter than a bus filter:

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), gain=0.5)
impact = synth.bake(duration=0.1)

Building block (simvx.core.audio_synth)

Notes

Oscillator.sine(freq, phase=0)

phase-continuous across chunks

Oscillator.square(freq, duty=0.5)

duty clamped to 0.01-0.99

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

Oscillator.noise.white(seed=None), Oscillator.noise.pink(seed=None)

ADSR(attack, decay, sustain, release)

times in seconds, sustain a level

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

Exponential needs positive endpoints

LowPass(cutoff_hz), HighPass(cutoff_hz)

one-pole, for filter=

Backends

App.run() picks the audio backend once, in this order, logging a WARNING at each fallback:

Backend

When

Latency

Bus effects

Streaming

Accelerator (simvx-audio-miniaudio)

installed and starts

about 20 ms

yes

yes

Python mixer

accelerator missing or failed

about 100 ms

no

yes

Silent

no audio device

no

no

Web Audio

browser exports

yes

procedural only

The accelerator is included with pip install simvx where a wheel exists (not macOS); see Installation.

Without the accelerator

The Python mixer runs on the audio thread under the GIL, so its buffer is large enough to survive a heavy frame on the main thread, which is where its 100 ms latency comes from. It plays every player, bus volume and spatial pan, and skips bus effects. A heavy main thread can still cause crackling; the accelerator moves mixing off the GIL.

SIMVX_ALLOW_LEGACY_AUDIO=0 refuses both fallbacks: the accelerator or AudioBackendUnavailable, which suits a CI job that requires the fast path.

The silent backend still finishes a non-looping sound after its duration, timed by the scene clock, so queue_free_on_end players are removed on a machine with no audio and a headless run behaves as it would with a device. Headless runs and tests never open a device.

Strict mode and errors

SIMVX_AUDIO_STRICT (default 1) makes mistakes raise; set SIMVX_AUDIO_STRICT=0 in a shipped game so a bad asset warns once instead of crashing. Every audio exception derives from AudioError in simvx.core.audio_errors:

Exception

Raised when

AudioBackendUnavailable

no backend could start and SIMVX_ALLOW_LEGACY_AUDIO=0

UnknownBusError

a player names a bus that is not in the layout

InvalidStreamError

a source is unrecognised, or from_pcm has an invalid rate or channel count

AudioCapabilityError

the backend cannot do what was asked (streaming on the silent backend); carries remediation

AudioMutationDuringPlaybackError

a next-play property was written while playing (strict mode)

Troubleshooting

Turn on WARNING logging to see which backend was chosen and why:

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

Symptom

Cause

Remedy

A WARNING names the Python mixer

the accelerator is not installed, has no wheel for this platform, or failed to load (the WARNING quotes the error)

pip install --force-reinstall simvx-audio-miniaudio

Silent, with a WARNING

no audio device, or the audio server is down

attach a device or start the server; the game runs normally

Crackling

the Python mixer under a heavy main thread

install the accelerator

AudioCapabilityError: streaming

the silent backend

install the accelerator, or use stream_mode="memory" / bake()

UnknownBusError

a misspelt or missing bus (names are case-sensitive)

fix the name, or add_bus it first

AudioMutationDuringPlaybackError

a next-play property written while playing

stop, change it, play again

A bus is silent

a muted bus on its send_to chain, or another bus soloed

check get_bus(name).effective_volume

No 3D panning

no AudioListener3D in the scene

add one under the camera

No Doppler

listener.velocity never set

set it from the camera controller

stream_open_failed

a missing file, an unrecognised container, a synthetic clip, or any file in a web export

use stream_mode="memory"

Quit with self.app.quit(); every backend also joins its audio thread at exit, so the process never hangs there.

Performance tips

  • OGG or FLAC for music, WAV for short effects.

  • Build an AudioClip once and share it between players.

  • Bake synths that do not change; keep attach_to for parameters that move while playing.

  • Change [live] properties rather than stopping and replaying.

  • Each playing AudioPlayer3D updates its spatial state every frame; stop distant ones.

Example: a scene’s audio

from simvx.core import AudioListener3D, AudioPlayer, AudioPlayer3D, Camera3D, Input, Node, Pointer, Vec3
from simvx.graphics import App


class GameScene(Node):
    input_context = {"click": [Pointer.PRIMARY]}

    def on_ready(self):
        self.add_child(AudioPlayer(stream="music/gameplay.ogg", volume_db=-8.0,
                                   loop=True, autoplay=True, bus="Music"))
        self.ui_click = self.add_child(AudioPlayer(stream="ui/button_click.wav", bus="UI"))
        self.add_child(AudioPlayer3D(stream="ambient/waterfall.ogg", position=Vec3(20, 0, 10),
                                     max_distance=30.0, loop=True, autoplay=True))
        camera = self.add_child(Camera3D(position=Vec3(0, 5, 10)))
        camera.add_child(AudioListener3D())

    def on_update(self, dt):
        if Input.just_pressed("click"):
            self.ui_click.play()


if __name__ == "__main__":
    App(title="Game").run(GameScene())

examples/features/audio/audio.py and examples/features/audio/spatial.py are runnable versions.

API reference

See simvx.core.audio, simvx.core.audio_bus, simvx.core.audio_effect, simvx.core.audio_synth and simvx.core.audio_errors.