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 |
|---|---|
|
an audio file |
|
an asset shipped inside a Python package |
|
|
|
a sine with a 20 ms fade in and out |
|
a float32 ndarray, interleaved stereo or mono; rate and channels are required |
|
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 |
|---|---|
|
start, or resume from pause; |
|
stop and release any streaming decoder |
|
pause; |
|
start at silence and ramp up to |
|
ramp to silence, then |
|
ramp to |
|
fade this one out while |
|
shift pitch on the playing channel, within |
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 |
|
|---|---|---|---|
|
|
|
live |
|
|
|
live |
|
|
|
next play |
|
|
next play |
|
|
|
next play |
|
|
|
remove the node when playback ends |
next play |
|
|
|
next play |
|
|
|
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 |
|---|---|---|
|
|
as above |
|
|
|
|
|
|
|
|
|
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 |
|---|---|---|
|
|
as above |
|
|
|
|
|
|
|
|
|
|
|
|
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(defaultTrue): become current on entering the tree. SetFalseand callmake_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
Node3Drotation.
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")
|
Meaning |
|---|---|
|
|
|
a muted bus silences everything routed through it |
|
when any non-master bus is soloed, every bus off its routing path is silenced |
|
the bus this one feeds; an unknown name raises |
|
|
|
the volume along the whole chain, with mute and solo applied |
|
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 ( |
Notes |
|---|---|
|
phase-continuous across chunks |
|
duty clamped to 0.01-0.99 |
|
|
|
|
|
times in seconds, |
|
|
|
one-pole, for |
Backends¶
App.run() picks the audio backend once, in this order, logging a WARNING at each fallback:
Backend |
When |
Latency |
Bus effects |
Streaming |
|---|---|---|---|---|
Accelerator ( |
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 |
|---|---|
|
no backend could start and |
|
a player names a bus that is not in the layout |
|
a source is unrecognised, or |
|
the backend cannot do what was asked (streaming on the silent backend); carries |
|
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) |
|
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 |
|
the silent backend |
install the accelerator, or use |
|
a misspelt or missing bus (names are case-sensitive) |
fix the name, or |
|
a next-play property written while playing |
stop, change it, play again |
A bus is silent |
a muted bus on its |
check |
No 3D panning |
no |
add one under the camera |
No Doppler |
|
set it from the camera controller |
|
a missing file, an unrecognised container, a synthetic clip, or any file in a web export |
use |
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
AudioCliponce and share it between players.Bake synths that do not change; keep
attach_tofor parameters that move while playing.Change
[live]properties rather than stopping and replaying.Each playing
AudioPlayer3Dupdates 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.