Presentationcom.zoa.audio · v0.1.0

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.

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.

The AudioEventPlayer inspector with its sectioned layout.

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.

A SoundProfileDefinition with state-filtered and surface-tagged entries.

Setup

Workflow

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

The simple call, and the full onecsharp
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);
    }
}
Play returns false when the resolver missed, the entry was on cooldown, or the polyphony cap was reached. Treat that as information, not an error.
Hierarchical fallback in practicecsharp
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);
Dispatch is the shortcut for Play with a default state and no surface tag, useful from handlers that do not build a tag set.
Starting and stopping a loopcsharp
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);
}
Loops get their own AudioSource so a burst of one-shots on the same bus cannot truncate them. StopLoop re-resolves the entry to honour its authored fade time.
Swapping a profile at runtimecsharp
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