Audio: Sound Banks, Acoustic Zones and Baked Sound

The acron framework modules on this page (acron.audio, acron.audiospace and acron.dsp) are Python; they ship in the acron wheel.

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 to node, through an Audio3DManager on the camera. Each call makes a new sound, so two explosions overlap. Pass voice – 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. Call release_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; None fades 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, bandpass or highshelf; Q as in the Audio EQ Cookbook, 0.7071 the flattest), and fft_filter() applies one to a loop circularly, so the loop stays seamless.

  • Sources: brown_noise(), and impulse(), 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; then reverb_loop() for a loop or reverb_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) and write_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"])