Guidance / Manual
Events System
5.1 AudioEvent Overview
Location: Events/AudioEvent.cs
Purpose: Triggers audio with complex behaviors and multiple actions.
Core Properties
string EventName { get; } // Unique identifier
List<EventAction> Actions { get; } // Actions to execute
VoicePriority Priority { get; } // Voice stealing priority
int MaxInstances { get; } // Concurrent instance limit (0 = unlimited)
VoiceStealBehavior StealBehavior { get; } // How to steal when max reached
float Cooldown { get; } // Minimum time between triggers
Timed posts: a moment of the sound at a DSP time
A pass-by whoosh must have its peak land on the moment the cars pass. Post it saying which point of the sound (seconds into its clips, at their recorded pitch) is heard at which DSP time (AudioSettings.dspTime):
// The whoosh peaks 0.5 s into its clip; the pass is predicted 0.3 s from now. Start 0.2 s in, now.
double pass = AudioSettings.dspTime + 0.3;
float startAt = 0.2f;
otherCar.Post(whooshEvent, pass - (0.5 - startAt), startAt, PostValues.Of(closing, speed));
// Also: whooshEvent.Post(emitter, startDsp, startOffset, values), AudioManager.Post(evt, emitter, ...),
// and whooshEvent.Post(source, position, startDsp, startOffset, values) without an emitter.
- If that time has passed (or is nearer than the next DSP buffer, as “now” always is), the sound starts at the next buffer, that much further into the clip at its pitch, so the moment still lands on time. A one-shot already past its end plays nothing (its handle finishes at once); a loop wraps round.
- Every voice the event’s Play actions start with takes the offset, so a Blend’s layers stay together. A Sequence’s later clips start from their beginning. A Play action with a Delay keeps its own timing and starts from the beginning.
- The older
Post(..., scheduledDspTime)overloads are unchanged: they start the clip from its beginning, at that time or the next buffer.
How long will it play? evt.EstimateDuration(values) (or EstimateDuration(emitter, values)) says before posting, as an AudioDuration: Shortest and Longest seconds (at the recorded pitch), Loops, and IsKnown (false when nothing would play). A Random container gives its shortest and longest clip; a Switch the choice the values pick (the post’s values, then the emitter’s, then global); a Blend or Routing container its longest layer; a Sequence the entry it plays next, or with Auto Advance the rest of the run; each Play action counts with its Delay added. It draws no random numbers, so it can’t change what a networked post picks.
var length = whooshEvent.EstimateDuration(PostValues.Of(closing, speed));
float preRoll = 0.5f; // where the whoosh peaks, from your own authoring
float startAt = Mathf.Max(0f, preRoll - (float)(pass - AudioSettings.dspTime)); // late into the clip when the warning is short
if (length.IsKnown && startAt < length.Shortest) otherCar.Post(whooshEvent, pass - (preRoll - startAt), startAt);
5.2 Event Actions
Concept: One event can execute multiple actions sequentially or with delays.
Available Action Types
public enum ActionType
{
Play, // Play a container
Stop, // Stop a container (only plays this event started)
Pause, // Pause this event's voices
Resume, // Resume this event's paused voices
SetSwitch, // Set a switch value
SetRTPC, // Set RTPC parameter
SetState, // Activate a state
TriggerDucking, // Duck target buses
CrossFade // Crossfade between containers
}
Stop, Pause and Resume only affect sounds started by the same event. They can’t reach a sound another event started, such as music. To stop that, keep the handle Post returned and call Stop on it.
Scope. A Stop, Pause or Resume action acts on All of the event’s sounds that are playing, on every object (the default, as before 3.1), or, with This Emitter, only on those posted on the Audio Emitter the event is posted on. With This Emitter, posting a car’s “engine restart” event (Stop, then Play) on one car no longer stops every other car’s engine. Posted without an Audio Emitter, This Emitter acts on all of them.
EventAction Structure
[Serializable]
public class EventAction
{
public ActionType actionType;
public ActionScope scope; // Stop/Pause/Resume: All (default) or ThisEmitter
public AudioContainer container; // For Play/Stop/CrossFade
public AudioBus targetBus; // Play/CrossFade routing; TriggerDucking's trigger bus
public float delay; // Delay before executing (seconds)
public float fadeDuration; // Stop fade-out; CrossFade/SetRTPC/SetState transition;
// TriggerDucking hold time. Play ignores it.
// For SetSwitch
public string switchGroup;
public string switchValue;
// For SetRTPC
public string rtpcParameter;
public float rtpcValue;
// For SetState
public string stateName;
public float stateTransitionTime;
// For CrossFade
public AudioContainer fromContainer;
public AudioContainer toContainer;
public CrossfadeType crossfadeType;
}
Multi-Action Example
Use Case: Music with intro and loop
Event: "Start_MainMusic"
Action [0]:
- Type: Play
- Container: Music_Intro_RC
- Target Bus: Music_Bus
- Delay: 0s
Action [1]:
- Type: Play
- Container: Music_Loop_RC
- Target Bus: Music_Bus
- Delay: 8.5s ← Length of intro
Result: Intro plays immediately, and the loop starts within about a frame of the intro’s end (a delayed action waits on a frame timer). For a sample-accurate join, see the Music Systems recipe in the Cookbook.
5.3 Instance Limiting
Purpose: Prevent event spam (e.g., 100 footsteps playing simultaneously).
Configuration
[SerializeField] private int maxInstances = 0; // 0 = unlimited
[SerializeField] private VoiceStealBehavior stealBehavior = VoiceStealBehavior.Oldest;
What counts as one instance
An instance is one play: everything a single Play action starts. Some containers start several voices at once: a Routing container with three clips, a Blend with two layers, a Switch that selects either of those. That is still one instance, and it stays live until its last voice ends. A steal ends the whole play at once, with every one of its voices stopping together. An event with two Play actions counts two instances per post. A multi-position post counts one instance per emitter, and its limit is maxInstances × emitter count.
Steal Behaviors
Oldest:
Behavior: Stop the play that started first, play the new one
Use For: Most sounds (footsteps, weapons)
Judged by start time, not playback position. A loop that has run for minutes is the oldest even when it has just wrapped back to the start of its clip. Plays posted in the same frame are tied, and the tie goes to the one posted first.
Quietest:
Behavior: Stop the quietest play (judged by its loudest voice), play the new one
Use For: Sounds where loudness matters (explosions)
Furthest:
Behavior: Stop the most distant play (judged by its nearest voice), play the new one
Use For: Spatial sounds where proximity matters (ambience, 3D SFX)
LowestPriority:
Behavior: Stop the lowest-priority play
Use For: Mixed priority scenarios
The steal is checked when a post arrives and before its actions run: if the event is already at maxInstances, one play is stopped immediately to make room.
Per Audio Emitter
Max Instances Per Emitter limits the plays of the event on one Audio Emitter at once, on top of Max Instances for the whole game: every car can crackle, but at most 3 crackles per car. When a car is at its limit, Steal Behavior picks which of that car’s plays ends; other cars are untouched. The play that ends fades out over the Audio Manager’s Steal Fade Seconds, like any steal (§3.2), so a burst of pops doesn’t click; it used to stop on the spot. 0 = no limit per emitter. Posts without an Audio Emitter (a GameObject, multi-position) aren’t counted per emitter.
Event: Car_Afterfire
Max Instances: 0 (no game-wide limit)
Max Instances Per Emitter: 3
Cooldown Per Emitter: 0.08 s
30 posts on one car in one frame → 3 crackles on that car; another car's crackle still plays.
Persistent
Tick Persistent on an event whose sounds must never drop out to make room for others, such as a car’s engine. The voice pool never steals its plays (§3.2, Voice Stealing). They can still go virtual when other sounds matter more, keeping their place in the clip, so they come back in step. The event’s own Max Instances can still end one of its persistent plays: that is the event’s rule.
If the pool is full and only persistent sounds are left in it, a new sound is skipped with a warning (“the voice pool is full of persistent sounds”). Raise Max Virtual Voices on the Audio Manager so the persistent sounds fit with room to spare: 8 cars of 14 layers need 112 voices.
Persistent sounds also get Unity’s highest AudioSource.priority, so if more voices than Unity’s own limit ever play, Unity silences other sounds first (Pepper warns at startup when its counts exceed Unity’s, §9.1).
5.4 Cooldown System
Purpose: Prevent rapid re-triggering of the same event.
Each event asset keeps the time it was last posted. Every public post entry point (Post and the PostMultiPosition overloads) checks it first:
// On AudioEvent. lastPlayTime starts at -infinity, so the first post is always allowed.
// Now is Time.time, or Time.unscaledTime when the event's Time is Unscaled.
private bool IsOnCooldown() => Cooldown > 0f && Now - lastPlayTime < Cooldown;
- A post rejected by cooldown returns a handle that has already finished, so
OnFinishedstill fires. lastPlayTimeis stamped after the post runs.- The cooldown runs on the event’s Time (Voice Settings). Scaled (the default) is game time, so it freezes while the game is paused (
timeScale0): right for gameplay sounds. Unscaled is real time: use it for UI and pause-menu sounds, or a button with a cooldown plays once per pause. The same setting covers the event’s Delay actions, its sounds’ Sequence gaps and its crossfades (§10.3). - The cooldown belongs to the event, so it applies across every object that posts it, not per object.
- Cooldown Per Emitter is a second one per Audio Emitter: one car’s posts stay that far apart while another car’s still play. Both apply when both are set; same clock.
Use Case:
Event: UI_ButtonClick
Cooldown: 0.05s
User clicks rapidly → Only one sound per 50ms
Prevents audio spam and performance issues
5.5 Multi-Position Mode
What: Play the same event from multiple emitters simultaneously, perfectly synchronized.
Configuration
There is nothing to switch on in the event. Any event can be posted at several positions by calling one of the PostMultiPosition overloads instead of Post:
public AudioMultiHandle PostMultiPosition(Vector3[] positions, GameObject source = null)
public AudioMultiHandle PostMultiPosition(Transform[] transforms, GameObject source = null)
public AudioMultiHandle PostMultiPosition(AudioMultiPositionEmitterParent emitterParent, GameObject source = null)
The array overloads play in synchronized mode. The AudioMultiPositionEmitterParent overload uses the mode set on that component instead, SingleEmitter or Decorrelated (see §8).
How It Works
- At most 16 positions are used; extra ones are ignored with a warning.
- The cooldown is checked once for the whole post.
- Synchronized mode: every Play action starts once per position. All of those voices, and all the layers of a multi-voice container, are rescheduled to one shared start time,
AudioSettings.dspTime + 0.1, so they begin on the same sample. - Max Instances is multiplied by the number of positions.
source(optional): on a 3D container, each voice still plays at its own position, attached tosource. The voices move with it, and stop if it is destroyed. Without a source they stay where they were posted. To follow moving transforms, callRefreshPositionson the handle.- The returned
AudioMultiHandlecontrols every voice at once, or one emitter at a time by its index (SetVoiceVolumeScale,StopVoiceand the others).
Key Insight: All voices use PlayScheduled with one shared DSP time for sample-accurate sync. That adds 100 ms of latency.
From the Pepper.SFX 3.1 guides (USER_GUIDE.md). Something wrong or unclear? Tell me.