Audio: Sound Banks, Acoustic Zones and Baked Sound¶
The engine’s audio is described under Sound: audio managers, sounds and music, and positional 3D Audio. The modules on this page add three things on top of it: a cache of loaded sounds that plays them flat or in 3D, acoustic zones that crossfade between baked reverbs, and an offline kit that synthesises sound into WAV files once.
Playing sounds with SoundBank¶
acron.SoundBank loads each sound file once and plays it:
from direct.showbase.ShowBase import ShowBase
from acron import SoundBank
base = ShowBase()
sounds = SoundBank(base)
sounds.play("sfx/click.wav")
sounds.play("sfx/step.wav", volume=0.8, rate_jitter=0.2)
sounds.play_music("music/theme.ogg", loop=True, volume=0.6)
play(path, volume=1.0, rate_jitter=0.0)plays a sound without position.rate_jitter(0 to 1) varies the playback rate a little around 1.0 on each play, so a repeated sound does not sound identical every time. The bank keeps one sound per file: playing a file again restarts it.play_music(path, loop=True, volume=0.6)plays music through the music manager.play_3d(path, node, volume=1.0, voice=None)plays a positional sound attached tonode, through anAudio3DManageron the camera. Each call makes a new sound, so two explosions overlap. Passvoice– any object you own, such as the character making the sound – to have one sound per voice that restarts rather than piles up, like footsteps. Callrelease_voice(voice)when that object goes away.
Each method returns the AudioSound, or None when the file could not be
loaded; a file that fails to load is reported once and does not stop the
game.
A positional sound must be mono: OpenAL plays a stereo sound through the
speakers as it is, with no position at all. play_3d() therefore passes
every file through acron.audio.mono_source(), which averages a stereo (or
wider) file down to one channel once per run and keeps the copy in memory, in
a ramdisk mounted at /acron-mono. The files in your project are not
changed.
Several voices with SfxPlayers¶
acron.SfxPlayers is a fixed number of voices for one-shot sounds.
play() uses the first voice that is not playing and drops the sound when
all of them are busy, which keeps a burst of sounds from turning into noise.
Each voice has its own copy of each sound, so the same file can play on every
voice at once:
from acron import SfxPlayers
sfx = SfxPlayers(base, voices=5, master=0.75)
sfx.play("sfx/coin.wav", volume=1.0, pitch=1.2)
master scales every play; change it at any time for a volume setting. A
pitch of 0 or less plays at 1.0.
Acoustic zones with AudioSpace¶
A game that bakes its reverb into the samples cannot change the room at
runtime, but it can bake each looping sound once per zone – a dry stem, and a
wet stem for each zone – play them all in step, and change their volumes.
acron.audiospace.AudioSpace does the volumes:
from acron.audiospace import AudioSpace, DRY
def sink(voice, bank, gain):
stems[voice, bank].set_volume(gain) # bank is a zone id, or DRY
space = AudioSpace(fade_s=0.6, sink=sink)
space.add_zone("hall", wet=1.0, dry=0.7)
space.add_zone("cave", wet=0.9, dry=0.5)
space.add_voice("generator", wet_zones=("hall", "cave"))
space.set_zone("hall")
def update(task):
space.tick(base.clock.dt)
return task.cont
add_zone(zone_id, wet=1.0, dry=1.0)declares a zone with the level of its wet stems and of the dry stem.add_voice(voice_id, wet_zones=(), dry=True)declares a looping sound and the zones it has wet stems for.set_zone(zone_id, fade_s=None)fades every voice to that zone’s levels;Nonefades to silence. Calling it again during a fade starts the new fade from the volumes that are sounding, so nothing jumps.tick(dt)advances the fade and calls the sink for each volume that changed; it returns how many it wrote, which is 0 once the fade is over.gains(voice_id)returns a voice’s current volumes.
The fade keeps the total power of the outgoing and incoming stems constant
(acron.audiospace.equal_power()), so the sound does not dip in the middle
of a crossfade. Where the zones are and what the stems sound like is your
game’s.
One-shots can have a variant per zone too. route(uris) picks the current
zone’s file from a mapping of zone id to file, with DRY as the fallback,
and SpatialOneshots plays it positionally through a SoundBank:
from acron.audiospace import SpatialOneshots
shots = SpatialOneshots(sounds, space)
shots.play({"hall": "sfx/shot_hall.wav", DRY: "sfx/shot.wav"}, gun_np)
Baked sound with acron.dsp¶
acron.dsp synthesises sound offline, with numpy, so that it can be played
as ordinary WAV files. Given the same seed (an acron.noise.RNG), it
produces the same samples on every machine.
Filters:
biquad_h()is the response of a biquad filter (lowpass,highpass,bandpassorhighshelf; Q as in the Audio EQ Cookbook, 0.7071 the flattest), andfft_filter()applies one to a loop circularly, so the loop stays seamless.Sources:
brown_noise(), andimpulse(), a plate-reverb impulse response.Reverb: normalise an impulse response with
plate_norm()first, so a send level means the same wetness whatever the impulse; thenreverb_loop()for a loop orreverb_tail()for a one-shot and its tail.Mixing:
pan_gains()(equal-power pan),env_exp()(an exponential attack and decay),loop_bins()(a frequency moved onto a loop’s FFT grid, so it loops cleanly) andwrite_wav()(16-bit PCM, mono or stereo).
WavBakeCache keeps the results in a directory named after a version
number. Subclass it, write the sounds in bake_all(), and call
ensure() at startup: it bakes only when the directory does not exist yet,
and returns each sound’s playback scale. write() normalises each file to
use the full 16-bit range and records its true level in manifest.json, so
the volume to play it at is your mix level times that scale. Raise
version when the synthesis changes, and the old cache is no longer used.
from pathlib import Path
from acron import dsp
from acron.core import Filename
from acron.noise import RNG
class GameAudio(dsp.WavBakeCache):
def bake_all(self, report=None):
rate = 44100
rumble = dsp.brown_noise(RNG(7), 4.0, rate)
rumble = dsp.fft_filter(rumble, "lowpass", 180.0, rate)
self.write("rumble", rumble, rate)
cache = GameAudio(Path("cache"), name="audio", version=1)
scales = cache.ensure()
path = Filename.from_os_specific(str(cache.path("rumble")))
sound = base.loader.loadSfx(path)
sound.set_volume(0.5 * scales["rumble"])