ZOA Audio
One runtime audio player per rig: state-tag matching, hierarchical event-id fallback, and pooled sources per mixer bus.
Audio is a single component and a single call. AudioEventPlayer sits on any rig that makes noise, holds a SoundProfileDefinition, and answers Play requests. It replaces the split that used to exist between a player-side dispatcher and an AI-side bridge, and it is system-agnostic: given a profile and a request, it resolves an entry, picks a variant, allocates a source, applies spatial configuration, and stamps the mixer group.
Per-system binders live in the packages that own the gameplay events they translate. A player binder, an AI binder, and a UI binder each subscribe to their own domain's events and turn them into Play calls. The audio package never learns what a weapon or an enemy is, so one player component serves all of them.
The interesting behaviour is in resolution. A request carries an event id, the rig's active state-tag set, and an optional surface tag, and the resolver picks the most specific authored entry that the current state satisfies. When nothing matches, it strips the trailing segment of the event id and tries again, so a profile that authored the general case covers the specific one until someone authors it.
Depends on (3)
Depended on by (3)
How it works
Concepts
State-tag matching picks the most specific entry
A SoundEventEntry can require locomotion, posture, combat, reaction, and mood tags. Those become a StateTagSet, six 64-bit masks with one per orthogonal dimension, and the match is a subset test: every bit the entry asserts must also be asserted on the rig's active state. An entry that asserts nothing matches everything, which makes it the natural fallback row.
Among the entries that match, the resolver keeps the most specific. Specificity counts the asserted bits across all six dimensions, plus one when the entry also names a surface tag. So an entry requiring Sprinting and Crouched beats one requiring only Sprinting, and a footstep row that names "metal" beats the untagged one when the surface matches.
Surface tags filter rather than rank on their own. An entry with a non-empty SurfaceTag that does not equal the request's surface is discarded outright; an entry with an empty surface tag stays in the running for any surface.
Hierarchical event-id fallback
Event ids are dot-separated from broad to narrow, and the resolver walks them from full to root. A request for "weapon.reload.insert" is tried whole; on a miss the trailing segment is stripped and "weapon.reload" is tried; on another miss "weapon" is tried. The walk stops when a level matches or the id is exhausted.
The tie-break rule is that id specificity wins over state-tag specificity. A more specific id with a broad state filter beats a more general id with a tight one, because the resolver only considers state specificity within a single id level.
A profile is useful before it is finished. Authoring one "player.footstep" entry covers every generated "player.footstep.run.concrete" dispatch until the surface- and activity-specific rows exist, and adding those rows later needs no change at any call site.
Profiles flatten through their parent chain
SoundProfileDefinition can name a Parent, and FlattenChain walks that chain into a single ordered list. Child entries win: an entry is skipped when one with the same event id, tag filter, and surface tag triple has already been collected, and the child is collected first. The walk is depth-limited so a cyclic parent chain cannot hang the flatten.
That gives real inheritance: a HumanFemale profile inherits Human and overrides only its vocal rows, while every footstep, weapon, and foley entry keeps resolving from the parent.
AudioEventPlayer caches the flatten and rebuilds it when the profile changes. Setting the Profile property clears the cache, the cooldown table, and the polyphony counters, but does not stop active loops, because loops are keyed by event id rather than by entry index.
Pooled sources per bus, dedicated sources per loop
One-shots share one AudioSource per AudioBusId, allocated lazily and cached. That works because PlayOneShot mixes concurrent plays on a single source without truncating, so several overlapping SFX hits do not need several sources. The mixer group comes from IAudioBusService.GetGroup for the entry's resolved bus; when no bus service is registered, the source still plays through Unity's default routing.
Loops get a dedicated AudioSource each, keyed by event id, so a burst of one-shots on the same bus cannot cut a loop short. StartLoop allocates the source at volume zero and fades it in over the entry's fade time; StopLoop re-resolves the entry to read the authored fade time, fades out, and destroys the source. StopAllLoops runs automatically on disable so a scene change never orphans looping audio.
Spatial configuration is applied per play rather than once per source, because the same pooled source serves entries with different spatial settings on the same bus. Position resolution has a clear priority: an explicit WorldPosition on the request, then the request's Parent, then the player's emitter transform, then the component's own transform.
Cooldown and polyphony protect the mix
Two guards run before any source is touched, and both apply to one-shots only. Cooldown is a per-entry debounce: when the entry authors a positive Cooldown and it has played inside that window, the call returns false and nothing sounds. PolyphonyCap is a concurrent-voice limit, checked against a per-entry counter that a coroutine decrements after the clip's length divided by its pitch.
Both exist for the same reason. High-frequency events such as footsteps at a sprint or a weapon on full automatic will stack voices, and stacked voices crackle. Capping them is cheaper and sounds better than mixing them.
Loops manage their own lifecycle and are exempt from both, since a loop plays once and stops when told to.
Variant selection is weighted and deterministic to test
Each entry holds an array of AudioVariant, and PickVariantIndex chooses one by weighted random over their Weight values, taking the normalized random value as a parameter rather than sampling it internally. That keeps the resolver pure and lets tests pin a specific variant.
Per-variant Volume and PitchRange compose multiplicatively with the request's own scalars. Both have sensible zero handling: a variant Volume of zero is treated as one, and an all-zero PitchRange means no pitch shift, so a freshly authored variant sounds correct before anyone touches its numbers. A min below max samples uniformly in between.
In the editor
Screens
Screenshot pending
/screenshots/audio-event-player-inspector.png
The component inspector on a player rig showing the Profile, Emitter, and Diagnostics sections from the Foundry section attributes, with a SoundProfileDefinition assigned and the emitter pointing at a head socket transform.
Screenshot pending
/screenshots/audio-sound-profile-entries.png
The profile inspector showing several event rows: a broad player.footstep row with no filters, two surface-tagged footstep rows, and a weapon.fire row with a visible variant array, cooldown, and polyphony cap, so the specificity story reads from the asset itself.
Setup
Workflow
- 01
Author or generate a sound profile
Create a SoundProfileDefinition and add one row per event id you want to answer. Start broad: a single player.footstep row and a single weapon.fire row already cover every hierarchical dispatch beneath them. The bundled installer materialises baseline player, AI, and UI profiles with one row per canonical event id if you want a skeleton to fill in.
- 02
Put a player on the rig
Add AudioEventPlayer to the rig root and assign the profile. Set the Emitter to a head or chest socket rather than leaving it on the root, which sits at the feet and spatialises less believably. Turn on the verbose log while diagnosing a silent event and off before shipping.
- 03
Register a bus service, or accept default routing
The player resolves IAudioBusService once in Awake and stamps the mixer group for each entry's resolved bus. With no service registered, sources still play through Unity's default routing, so a missing bus service costs you the mixer, not the audio.
- 04
Write a binder per system
Subscribe to your own package's gameplay events and translate them into Play calls, building the request with the rig's current state-tag set and the surface tag where one applies. Binders live in the consuming package so audio stays system-agnostic.
- 05
Land animation-driven beats on frames
Add AnimationEventReceiver to the Animator's GameObject or an ancestor, then put AnimationEvents on the clip with one of the canonical function names. Use AUDIO_CUSTOM with a string parameter for one-off ids rather than adding new methods.
- 06
Tune with cooldown and polyphony
Once a rig sounds right, set Cooldown on the entries that can retrigger faster than they should and PolyphonyCap on the ones that stack. Footsteps at a sprint and full-auto weapon fire are the two that almost always need it.
Surface
Key types
AudioEventPlayer
component
The unified runtime player. One per audio-emitting rig, holding a profile and exposing the whole Play, Dispatch, StartLoop, StopLoop surface. Execution order -500 so binders can resolve it in their own Awake.
- ZOA/Audio/Audio Event Player (F70)
- bool Play(in AudioEventRequest request)
- bool Dispatch(string eventId)
- bool StartLoop(in AudioEventRequest request)
- bool StopLoop(string eventId)
- void StopAllLoops()
- SoundProfileDefinition Profile { get; set; }
- Transform Emitter { get; set; }
- int ActiveLoopCount { get; } int PooledSourceCount { get; }
AudioEventRequest
struct
The one call shape. Carries the event id, the rig's state-tag set, an optional surface tag, an optional world position or parent, and per-call volume and pitch scalars. A struct so binders pass it by in without allocating.
- string EventId; StateTagSet State; string SurfaceTag;
- Vector3? WorldPosition; Transform Parent;
- float VolumeScale; float PitchScale;
- float ResolvedVolumeScale { get; } float ResolvedPitchScale { get; }
- static implicit operator AudioEventRequest(string eventId)
ZOAAudioEventIds
class
The canonical event-id constants, so a rename ripples through every binder at compile time rather than silently breaking a string. Covers player movement and parkour, player feedback, weapon, AI, and UI.
- const string PlayerFootstep = "player.footstep"
- const string PlayerJump = "player.jump" const string PlayerLand = "player.land"
- const string WeaponFire = "weapon.fire" const string WeaponFireDryClick = "weapon.fire.dryclick"
- const string WeaponReload = "weapon.reload" const string WeaponReloadInsert = "weapon.reload.insert"
- const string AiAttack = "ai.attack" const string AiFootstep = "ai.footstep"
- const string UiButtonClick = "ui.button.click" const string UiNotification = "ui.notification"
AnimationEventReceiver
component
Routes Unity AnimationEvents into the player. Method names are the canonical ALL_CAPS idiom so existing AnimationClip event references resolve unchanged, and the player is discovered by walking up the hierarchy on first use.
- ZOA/Audio/Animation Event Receiver (F70)
- void FOOTSTEP_LEFT() void FOOTSTEP_RIGHT()
- void FIRE() void RELOAD_INSERT() void RELOAD_RACK() void RELOAD_DROP()
- void GRUNT() void EXERTION()
- void AUDIO_CUSTOM(string eventId)
SoundProfileResolver
class
The stateless resolver the player calls. Flattens a profile chain, picks the best entry for an event id plus state plus surface with optional hierarchical fallback, and selects a weighted variant from a caller-supplied random value.
- const int NoMatch = -1
- static int ResolveBestMatch(IReadOnlyList<SoundEventEntry> flattened, string eventId, StateTagSet activeState, string surfaceTag, bool allowHierarchicalFallback)
- static List<SoundEventEntry> FlattenChain(SoundProfileDefinition profile)
- static int PickVariantIndex(AudioVariant[] variants, float randomNormalized)
SoundEventEntry
struct
One row in a profile: the event id, five tag filters, an optional surface tag, the variant array, and the routing, lifecycle, and spatial fields. The Resolved accessors apply sensible defaults for fields left at their struct zero.
- string EventId; string SurfaceTag; AudioVariant[] Variants;
- LocomotionTag LocomotionFilter; PostureTag PostureFilter; CombatTag CombatFilter;
- AudioBusId Bus; bool Spatial; float MaxDistance; AudioRolloffMode Rolloff;
- bool Loop; float FadeTime; float Cooldown; int PolyphonyCap;
- AudioBusId ResolvedBus { get; } float ResolvedMaxDistance { get; } float ResolvedFadeTime { get; }
AudioVariant
struct
One clip alternative inside an entry, with a selection Weight, a Volume scale, and a random PitchRange, so footsteps and vocals do not repeat the same waveform.
- AudioClip Clip; float Weight; float Volume; Vector2 PitchRange;
StateTagSet
struct
The rig's active state as six 64-bit masks, one per orthogonal dimension. Matching is a subset test and specificity is a bit count, both allocation-free. Declared in com.zoa.animation and used here as the filter currency.
- static StateTagSet Empty { get; }
- LocomotionTag Locomotion { get; }
- PostureTag Posture { get; }
IAudioBusService
interface
Resolves an AudioBusId to a mixer group and carries per-bus volume. Declared in com.zoa.messaging so no package owns it; the player resolves it once in Awake and tolerates its absence.
- AudioMixerGroup GetGroup(AudioBusId bus)
- void SetVolume(AudioBusId bus, float linear01)
- float GetVolume(AudioBusId bus)
Surface
Authoring assets
SoundProfileDefinition
asset
The per-entity event table: a list of SoundEventEntry rows plus an optional parent profile for inheritance. One profile per rig archetype, and a variant profile overrides only the rows it cares about.
- ZOA/Foundry/Animation/Sound Profile
- IReadOnlyList<SoundEventEntry> Entries { get; }
- SoundProfileDefinition Parent { get; }
Usage
Examples
using UnityEngine;
using ZOA.Animation.Core.State;
using ZOA.Audio;
public sealed class PlayerAudioBinder : MonoBehaviour
{
[SerializeField] private AudioEventPlayer _player;
// The implicit string conversion covers the trivial case.
public void OnDryFire() => _player.Play(ZOAAudioEventIds.WeaponFireDryClick);
// State and surface matter for footsteps, so build the request.
public void OnFootstep(StateTagSet state, string surface)
{
var request = new AudioEventRequest(ZOAAudioEventIds.PlayerFootstep)
{
State = state,
SurfaceTag = surface,
VolumeScale = 0.9f,
};
_player.Play(in request);
}
}using ZOA.Audio;
// A profile that authored only "weapon.reload" answers all of these,
// because the resolver strips the trailing segment on a miss:
//
// weapon.reload.insert -> miss -> weapon.reload -> hit
// weapon.reload.rack -> miss -> weapon.reload -> hit
// weapon.reload.drop -> miss -> weapon.reload -> hit
//
// Authoring the specific rows later needs no call-site change.
player.Dispatch(ZOAAudioEventIds.WeaponReloadInsert);
player.Dispatch(ZOAAudioEventIds.WeaponReloadRack);
player.Dispatch(ZOAAudioEventIds.WeaponReloadDrop);using UnityEngine;
using ZOA.Audio;
public sealed class VehicleEngineAudio : MonoBehaviour
{
[SerializeField] private AudioEventPlayer _player;
[SerializeField] private Transform _engineMount;
private const string EngineLoop = "vehicle.engine.idle";
private void OnEnable()
{
var request = new AudioEventRequest(EngineLoop)
{
Parent = _engineMount,
};
// No-op unless the resolved entry is flagged as a loop.
_player.StartLoop(in request);
}
private void OnDisable() => _player.StopLoop(EngineLoop);
}using ZOA.Animation.Unity.Definitions;
using ZOA.Audio;
public void ApplyVoiceSet(AudioEventPlayer player, SoundProfileDefinition profile)
{
// Setting Profile flushes the flatten cache, the cooldown table,
// and the polyphony counters. Active loops keep running: they are
// keyed by event id, not by entry index.
player.Profile = profile;
}Tooling
Editor tools
Regenerate Bundled Audio Profiles
Tools > ZOA > Advanced > Generate > Project > Regenerate Bundled Audio Profiles
Authors three baseline SoundProfileDefinition assets under Assets/ZOA/Bundled/Audio, one each for player, AI, and UI, with a row per canonical event id. Clips are bound from the bundled sound folders by file-name prefix, so dropping a new variant into a folder and re-running picks it up without a code edit.
BundledFootstepScanner
Editor utility that walks a foley pack laid out as Footsteps_<Surface>/Footsteps_<Surface>_<Activity>/clip.wav and returns one row per clip with its surface, activity, modifier, and variant tokens parsed. The bundled installer groups those rows into surface-tagged footstep entries. ParseTokens is public so new packs can be validated against the parser directly.
Read this
Notes and caveats
See also