Guidance / Manual
Core Components
3.1 AudioManager
Location: Core/AudioManager.cs (partial class across multiple files)
Purpose: Central singleton managing all audio operations.
Responsibilities
-
Voice Lifecycle
- Create voice pool on startup
- Allocate voices on demand
- Track active/virtual voices
- Return voices to pool when finished
-
Bus Management
- Hierarchical volume control
- Ducking automation
- Mixer integration
-
Event Processing
- Load events from the Audio Event Registry (falls back to Resources when there is none)
- Dispatch event actions
- Instance limiting
- Cooldown tracking
-
State Management
- Load states the same way (registry first, then Resources)
- Track active state per group
- Apply state transitions
-
RTPC System
- Store parameter values
- Notify listeners on changes
- Drive blend containers
-
Performance Systems
- Voice virtualization (only when more than Max Real Voices are live)
- Voice stealing
- Occlusion raycasting
Singleton Pattern
public partial class AudioManager : MonoBehaviour
{
// Set in Awake, cleared in OnDestroy. There is no lazy lookup and no auto-creation:
// if no AudioManager is in a loaded scene, Instance is null.
public static AudioManager Instance { get; private set; }
private void Awake()
{
// A second manager: its whole GameObject with Persist Across Scenes on, only this component with it off.
if (Instance != null && Instance != this) { RemoveDuplicate(); return; }
Instance = this;
if (persistAcrossScenes) DontDestroyOnLoad(gameObject);
if (Inert) { /* a server: load the event registry, build nothing else */ return; }
InitializeCore(); // the voice pool, under Pepper's own "AudioVoices" root
// ...
}
}
Key Points:
- Only one AudioManager exists; a second one destroys itself
- Persists between scene loads
- Accessible globally via
AudioManager.Instance, once itsAwakehas run. Put it in your first (bootstrap) scene.
Holding a reference instead of Instance
Your game never needs AudioManager.Instance. Everything a game calls is on the manager itself or on an asset (events, buses, parameters, emitters), so you can hand the manager to the scripts that use it, the way you’d wire any other dependency:
public class CarAudio : MonoBehaviour
{
[SerializeField] private AudioManager audioManager; // assigned in the inspector, or by your bootstrap
[SerializeField] private AudioEvent engine;
[SerializeField] private AudioParameter rpm;
[SerializeField] private AudioEmitter emitter;
[SerializeField] private Transform cameraTransform;
public float CurrentRpm { get; set; } // your car code writes this
private void Start()
{
audioManager.AddListeningPoint(cameraTransform); // instead of ListenerUtil, or a scene search
audioManager.Post(engine, emitter); // instead of AudioManager.Instance.Post
}
private void Update() => emitter.SetParameter(rpm, CurrentRpm);
}
The samples in this guide write AudioManager.Instance.X(...) to stay short; every one of them works the same as audioManager.X(...) on a reference you hold. Pepper itself still uses its one manager internally (containers and events reach it), which is why there can only be one. Register a listening point (§9.3, Listening Points) and neither Pepper nor its ambient system (§13) ever searches the scene or uses Camera.main. To leave the multiplayer code out of a game that doesn’t use it, see §17.2.
Inspector Configuration
| Property | Description | Default | When to Change |
|---|---|---|---|
masterVolume |
Global volume multiplier (0-1) | 1.0 | User settings menu |
muteAll |
Mute all audio | false | Debug/testing |
maxRealVoices |
Max physically playing voices | 32 | Mobile (16-24), High-end (48-64) |
maxVirtualVoices |
Max virtualized voices | 64 | Complex scenes with many sounds |
voiceUpdateInterval |
Voice management update rate (seconds) | 0.1 | Performance tuning |
occlusionMask |
Layers that block sound | Everything | Ignore player, small objects |
occlusionUpdateInterval |
Occlusion raycast rate (seconds) | 0.2 | Performance tuning |
enableLOD |
Virtualize the least important voices once more than maxRealVoices are live |
true | Always leave enabled |
lodDistances |
Not used in this version (hidden in the inspector) | [10, 25, 50, 100] | — |
Public API Overview
Events:
AudioHandle PostEvent(string eventName)
AudioHandle PostEvent(string eventName, GameObject parent, Vector3 position)
Parameters (global scope; per-emitter and per-play values are in §3.4):
void SetParameter(AudioParameter parameter, float value)
float GetParameter(AudioParameter parameter)
void SetRTPC(string parameter, float value) // the same global values, by name
float GetRTPC(string parameter)
Switches:
void SetSwitch(string group, string value)
string GetSwitch(string group)
States:
void SetState(string stateName, float transitionTime = 0.5f)
AudioState GetActiveState(string stateGroup)
Buses:
void SetBusVolume(string busName, float volumeDb, float transitionTime = 0f)
AudioBus GetBus(string busName)
Crossfading:
void CrossFade(AudioContainer from, AudioContainer to, float duration,
AudioEvent.CrossfadeType type = AudioEvent.CrossfadeType.EqualPower)
3.2 AudioVoice & AudioVoiceEnhanced
Location: Events/AudioVoiceEnhanced.cs
Purpose: Represents a single playing audio instance with advanced features.
Class Hierarchy
public class AudioVoice
{
public AudioSource source;
public GameObject parent;
public bool isPlaying;
public float volumeMultiplier;
// Basic voice functionality
}
public class AudioVoiceEnhanced : AudioVoice
{
public GainStack GainStack; // Layered volume control
public AudioEvent SourceEvent; // Originating event
public AudioContainer Container; // Source container
public AudioBus Bus; // Assigned bus
public int Priority; // Voice stealing priority
public bool IsVirtual; // Virtualization state
public float VirtualTime; // Where a virtual voice is in its clip, in seconds
public double VirtualSamples; // The same, in the clip's samples
public double ScheduledStartTime; // DSP scheduled start
public double ScheduledEndTime; // DSP scheduled end
public OcclusionSlot OcclusionSlot; // Mixer-side occlusion slot (null when not held)
}
The GainStack System
Concept: Multiple volume layers multiply together for final volume.
public class GainStack
{
public float BaseGain = 1f; // Container/Event base volume
public float BusGain = 1f; // Bus hierarchy contribution
public float OcclusionGain = 1f; // Raycast-based attenuation
public float RtpcGain = 1f; // Blend curves. RESERVED: sole writer is the Blend's
// per-frame step (the product of every enclosing Blend's
// layer curve). No generic parameter->volume driver writes it.
public float CurveGain = 1f; // Volume curves (Parameter Curves, §4.1) on the containers
// above the voice, summed in dB. Sole writer: the same step.
public float SchedulerGain = 1f; // Crossfade/transition gain
public float FadeGain = 1f; // Stop(fadeTime) ramp, 1 → 0. RESERVED: sole writer is the
// voice's fade-out — see note below.
public float MultiplierGain = 1f; // Per-voice multiplicative — volume randomization,
// Switch/Blend container outer Volume, PlayWithVolume scale.
public float UserGain = 1f; // AudioHandle.SetVolumeScale — idempotent runtime scale.
public float SwitchGain = 1f; // A playing Switch's crossfade between its choices (§4.5, When Switch Changes).
public float EmitterGain = 1f; // The emitter's and the handle's volume offsets (dB), kept on later voices.
public float SpatialGain = 1f; // Spatial Mode = Pepper: the level from the listening points; 1 in Unity mode.
public float RealizeGain = 1f; // The short fade around a voice going virtual and coming back.
public float DuckingGain = 1f; // (Deprecated) ducking is applied at the bus, not the voice.
// Settable for back-compat but NOT in GetFinalGain.
public float GetFinalGain() => GetGainWithoutRealize() * RealizeGain;
// How loud the voice is meant to be, real or virtual: what importance and stealing read.
public float GetGainWithoutRealize()
{
return BaseGain * BusGain * OcclusionGain * RtpcGain * CurveGain * SchedulerGain * FadeGain * MultiplierGain * UserGain *
SwitchGain * EmitterGain * SpatialGain;
}
public void ApplyToSource(AudioSource source) { ... }
public void Reset() { ... }
}
Why Layers? Each system can independently control volume without knowing about others:
- Container sets BaseGain
- Bus hierarchy sets BusGain
- Occlusion system sets OcclusionGain
- RtpcGain is reserved for Blend curves: each frame, a Blend layer’s voice gets the product of the curves of every Blend it plays under (§4.6). It is the slot’s only writer — there is no generic parameter→volume driver, and a future one must not write this slot for blend voices (it would fight the blend frame-by-frame). See the reservation note on
GainStack.RtpcGaininGainStack.cs. - Volume curves (Parameter Curves) set CurveGain, separately from the Blend crossfade in RtpcGain
- Crossfade/scheduler sets SchedulerGain
Stop(fadeTime)ramps FadeGain from 1 to 0. Because the fade is a slot like any other, a recompute mid-fade (the occlusion LateUpdate, a bus or master volume change, a duck) keeps the ramp instead of snapping the voice back to full level. It is separate from SchedulerGain so a crossfade and a stop fade on the same voice don’t fight.- Volume randomization, SwitchContainer/BlendContainer outer Volume, and
PlayWithVolumewrite MultiplierGain AudioHandle.SetVolumeScalewrites UserGain- Ducking is applied at the bus (the voice-level
DuckingGainfield is deprecated and not part of the final gain calculation)
Example:
BaseGain: 1.0 (container volume)
BusGain: 0.707 (bus at -3dB)
OcclusionGain: 0.5 (partially occluded)
RtpcGain: 0.8 (RTPC lowering volume)
SchedulerGain: 0.9 (fading in)
MultiplierGain: 0.9 (volume randomization -1 dB)
FinalGain = 1.0 * 0.707 * 0.5 * 0.8 * 0.9 * 0.9 = 0.229
The PitchStack System
Concept: Pitch works like the gain stack. Each thing that changes a voice’s playback rate owns one slot, the slots multiply, and only AudioVoice.UpdateFinalPitch() writes AudioSource.pitch. A handle’s pitch offset therefore no longer wipes out the container’s pitch randomization.
| Slot | Written by |
|---|---|
Randomization |
Container pitch randomization, when the voice starts |
Decorrelation |
The Decorrelated multi-position mode’s per-emitter nudge |
User |
AudioHandle.SetPitchOffset / AudioMultiHandle.SetPitchOffset and SetVoicePitchOffset |
Emitter |
The Audio Emitter’s Pitch Offset (§3.4) |
Follow |
A Blend’s Pitch Follow (§4.6): the parameter ÷ the layer’s Root Value, every frame |
Curve |
Pitch curves (§4.1 Parameter Curves) on the containers above the voice, every frame |
Doppler |
Pepper’s Doppler (§4.1, a container set to Doppler: Pepper), every frame |
TimeScale |
Reserved for slow motion (planned) |
Every slot is a ratio: 1 = unchanged, 2 = an octave up. The result is clamped to Unity’s maximum of 3, and the slots other than Doppler to the container’s Pitch Limit when one is set. Doppler comes after the limit, which is for the authored pitch; the container’s Doppler Max Cents clamps it instead.
SetPitchAbsolute (and the obsolete SetPitch, which always did this) sets an override that ignores every slot, so the voice plays at exactly that pitch, randomization (and Doppler) included.
Example:
Randomization: +1 st (container randomization)
User: +2 st (handle.SetPitchOffset(2f))
Final pitch = 2^(1/12) × 2^(2/12) = 2^(3/12): 3 semitones up
Don’t write voice.source.pitch yourself: the next pitch-stack update overwrites it. Use the handle methods, or a slot.
Voice Priority System
Enum Values:
public enum VoicePriority
{
Low = 0, // Background ambience, least important
Medium = 64, // Standard gameplay sounds
High = 128, // Important feedback (damage, pickups)
Critical = 255 // UI, dialogue: the strongest weight
}
What a priority weighs. A sound’s importance is how loud it is where it is heard (distance × volume) times its priority’s weight:
| Priority | Weight |
|---|---|
| Low | 1/8 |
| Medium | 1/4 |
| High | 1/2 |
| Critical | 1 |
Each step doubles, which is worth about 6 dB of loudness: a Medium sound counts as much as a Low one twice as loud. Low used to weigh 0, so a Low sound right beside you went before a Medium one you could hardly hear; since 3.1 it weighs 1/8, and the other three are as before.
Voice Stealing: A steal ends a whole play, never one voice of it. A play is everything one start of a container produced: all of a Blend’s layers, all of a Routing container’s clips, a footstep’s one voice. So an engine never loses a layer mid-race; it plays on whole or ends whole.
Before an event plays, it makes room for every voice the play will need (AudioContainer.EstimateVoiceCount(): a Blend adds up its layers, a Switch takes its largest choice, a Routing container counts its clips). When the pool (Max Real + Max Virtual) is too full, it ends whole plays until there is room:
- A play that is entirely virtual and silent first. Ending it frees its voices at once.
- Otherwise the least important play. A play’s importance is its loudest voice’s (see the weights above).
If even that can’t make enough room (too many sounds started in the same frame, or only persistent sounds are left), nothing is stolen and the new sound is skipped, with a warning. A sound from an event marked Persistent (§5.3) is never stolen.
A stolen sound fades out. A stolen sound that can be heard fades out over the Audio Manager’s Steal Fade Seconds (0.025 by default, and never fewer than three frames: 50 ms at 60 fps), then its voice goes back to the pool. The same fade ends a play an event’s Max Instances takes (§5.3) and a Switch set to Restart’s old choice. Its handle hears at once that the sound is gone (OnFinished). A sound that can’t be heard yet (one posted this frame, which starts a buffer ahead) ends at once and frees its voice the same frame. Steal Fade Seconds 0 brings back the old cut, a few ms ahead on the audio clock (Steal Declick Seconds), which can click. While it fades, a stolen sound keeps its AudioSource for three or four frames; the Steal Headroom covers that. A handle’s Stop(0) is still a cut: you asked for it.
VoiceStealBehavior (Oldest, Quietest, Furthest, LowestPriority) is not used here. It decides which of an event’s own plays to end when that event reaches its Max Instances (§5.3).
Critical Voices: Critical is the strongest priority: it weighs most in a voice’s importance, so a Critical sound is among the last to go virtual or be stolen. It can still be stolen when the pool has nothing less important left; “never stolen” would let the pool jam when many sounds are Critical. For a sound that must never drop out (an engine), tick Persistent on its event (§5.3): that is the one exemption.
Voice Virtualization
What Is It? A virtual voice is still tracked, and keeps its place in the clip, but isn’t heard: its AudioSource is paused, so it doesn’t use one of Unity’s real voices.
When Does It Happen?
Only when more than maxRealVoices voices would be heard (and enableLOD is on), or more than a bus’s own Max Real Voices (§6.9). It happens a whole play at a time: a play’s audible voices are all real, or all virtual. Importance is distance × volume × the priority’s weight, taken from a play’s loudest voice, so far, quiet, low-priority sounds go before near, loud, important ones (for a sound with an Attenuation asset, “distance” is how loud its curve says it is there, §4.1; with Spatial Mode = Pepper, how loud it is heard from the listening points, §9.3). Nothing is virtualized for distance alone while you are under maxRealVoices, except a sound past its Attenuation asset’s Max Distance.
- When a sound starts, once all its voices are set up, the least important plays go virtual until the audible count fits, the new sound included. So a Low sound posted while the real voices are full can start virtual instead of pushing out a more important one.
- About 10 times a second (
voiceUpdateInterval), every play is ranked by importance and given real voices while they fit; the rest are virtual. A real play counts as 1.25 times as important, and a play stays at least half a second in a state, so a sound that only matters slightly more doesn’t swap in and out. That is also what brings an important virtual sound back while the real voices are all taken. - A single play with more audible voices than
maxRealVoiceskeeps its most important ones real. - A Blend with Silent Layers = Virtualize (§4.6) also makes its near-silent layers virtual while the rest of its play stays real.
Keeping its place.
While a voice is virtual, Pepper keeps the position it would have reached had it kept playing, in the clip’s samples (AudioVoice.VirtualSamples; VirtualTime is the same in seconds). Every frame, in Pepper’s per-frame step, the position moves on by the time Unity’s audio clock has advanced × the voice’s pitch, and wraps for a loop. That clock counts the audio Unity has actually mixed, so it is exactly what real voices move by: it keeps running through a frame-rate hitch and stops when audio stops. So a voice at pitch 1.5 virtual for 2 s moves on 3 s of clip. When it becomes real it starts from that position, within one audio buffer of a twin that stayed real.
- A paused voice (
Pause,PauseAll) holds its position, so a pause menu can’t make a virtual one-shot “finish” while the game is paused. - A virtual one-shot ends when its position passes the end of its clip.
- A start still scheduled ahead doesn’t advance until that time.
AudioHandle.SeekToon a virtual voice moves the position it comes back at.
No clicks.
Unity’s Pause and UnPause are hard cuts. So a voice that can be heard fades out over about 25 ms (in at least three steps, at most one per audio buffer) before it pauses, and a voice that becomes real fades back in the same way. While it fades out, Unity is still playing it, so for those few frames one more voice than maxRealVoices can be heard. A voice that is already silent, paused or not yet started pauses at once. The fade has its own gain slot, GainStack.RealizeGain, which isn’t counted in a voice’s importance, so a virtual voice competes at the level it would play at.
Unity’s Doppler. Unity’s own Doppler effect changes a moving 3D voice’s speed without Pepper seeing it, so a virtual voice can’t follow it. For looping layers that must stay in step with each other (engine layers), set the container’s Doppler to Pepper (§4.1): Pepper’s Doppler is in the pitch a virtual voice keeps its place at. Or set Doppler Level to 0 for no Doppler at all (in its 3D Settings; a layer with Uses Parent Positioning takes its Blend’s). The default, Unity at Doppler Level 1, is Unity’s.
DSP Scheduling
Purpose: Sample-accurate synchronization for multi-position audio.
double dspTime = AudioSettings.dspTime;
ScheduledStartTime = dspTime + 0.1; // Start in 0.1 seconds
source.PlayScheduled(ScheduledStartTime);
Use Cases:
- Multi-position audio (all emitters start at exact same sample)
- Music layer synchronization
- Rhythmic audio sequences
3.3 AudioHandle & AudioMultiHandle
Purpose: Provide playback control after posting an event.
AudioHandle (Single Voice)
public class AudioHandle
{
// Properties
public string EventName { get; }
public AudioEvent SourceEvent { get; }
public bool IsPaused { get; }
public bool isPlaying { get; }
public float time { get; } // Current playback time
public float duration { get; } // Total clip length
// Events
public event System.Action OnStarted;
public event System.Action OnLoop; // declared, but not raised in this version
public event System.Action OnStopping; // a fade-out has begun
public event System.Action OnFinished;
// Methods
public void SetVolumeScale(float scale) // relative to the authored volume
public void SetVolumeOffset(float db) // the same, in dB
public void SetVolumeAbsolute(float linear) // replaces the authored volume
public void SetPitchOffset(float semitones) // on top of the authored pitch (randomization kept)
public void SetPitchAbsolute(float semitones) // replaces the authored pitch
public void SetPitch(float semitones) // obsolete: same as SetPitchAbsolute
public void SetLocalOffset(Vector3 offset) // move it on the object it was posted on (§3.4)
public void SetWorldPosition(Vector3 position)
public void Stop(float fadeTime = 0.1f)
public void Pause()
public void Resume()
public void Dispose() // Clean up event handlers
}
Usage:
AudioHandle handle = musicEvent.Post();
handle.OnStarted += () => Debug.Log("Music started");
handle.OnFinished += () => Debug.Log("Music finished");
// Later...
handle.SetVolumeScale(0.5f); // Half the authored volume
handle.SetVolumeOffset(-3f); // Or 3 dB down (the same slot: the last call wins)
handle.SetPitchOffset(2f); // 2 semitones up, on top of any pitch randomization
handle.Stop(1f); // Fade out over 1 second
The offsets stick. SetVolumeScale, SetVolumeOffset and SetPitchOffset reach the voices playing now and the ones the sound starts later: a Switch’s new choice, a Sequence’s next clip, a Play action still waiting out its Delay. A post can set them before the first sample (PostValues.WithVolumeOffset, WithPitchOffset, §3.4). The absolute setters (SetVolumeAbsolute, SetPitchAbsolute) still reach only the voices playing now. (Before 3.1.0-dev.7 the offsets didn’t reach later voices either.)
AudioMultiHandle (Multiple Voices)
Purpose: Control multiple voices from multi-position playback.
public class AudioMultiHandle
{
// Properties
public int VoiceCount { get; }
public bool HasVoices { get; }
public bool isPlaying { get; }
public MultiPositionType PositionType { get; }
// Global control (all voices)
public void SetVolumeScale(float scale) // relative to the authored volume
public void SetVolumeAbsolute(float linear) // replaces the authored volume
public void SetPitchOffset(float semitones) // on top of the authored pitch
public void SetPitchAbsolute(float semitones) // replaces the authored pitch (SetPitch is obsolete)
public void Stop(float fadeTime = 0.1f)
public void Pause()
public void Resume()
// Per-emitter control (index = the position's index in the post's array / emitter list)
public void SetVoiceVolumeScale(int emitterIndex, float scale)
public void SetVoicePitchOffset(int emitterIndex, float semitones)
public void SetVoicePitchAbsolute(int emitterIndex, float semitones) // SetVoicePitch is obsolete
public void StopVoice(int emitterIndex, float fadeTime = 0.1f)
public void PauseVoice(int emitterIndex)
public void ResumeVoice(int emitterIndex)
// Position management (one entry per emitter)
public void UpdatePositions(Vector3[] newPositions)
public void UpdatePositions(Transform[] newTransforms)
public void RefreshPositions()
public Vector3? GetVoicePosition(int emitterIndex)
public Vector3[] GetAllPositions() // one entry per voice
}
The per-emitter methods act on every voice that emitter started. If the event’s container starts several voices per play (a multi-clip Routing container, a Blend), all of an emitter’s layers are controlled together. The index is stable: it does not shift when other emitters’ voices end. VoiceCount counts voices, so it equals emitters × voices per play.
Usage:
AudioMultiHandle multiHandle = myEvent.PostMultiPosition(emitterParent);
Debug.Log($"Playing {multiHandle.VoiceCount} voices");
multiHandle.SetVolumeScale(0.75f); // All speakers to 75% of authored volume
multiHandle.SetVoiceVolumeScale(0, 0.5f); // First speaker to 50%
multiHandle.UpdatePositions(newTransforms); // Update positions dynamically
3.4 Parameters & Scopes
A parameter is a number the game sets and sounds read: an engine’s RPM, a car’s speed, combat intensity. Blend Containers follow one (§4.6).
Parameter assets
Menu: Create > Audio System > Parameter
| Field | Meaning |
|---|---|
| Parameter Name | The name it answers to in the string API (SetRTPC, GetRTPC, state and event RTPC actions) and in Blends that name a parameter. Empty = the asset’s name. |
| Unit Label | Shown next to the values: RPM, m/s, 0-1. Display only. |
| Min, Max | The range. A value outside it is read clamped; what was written is kept, so widening the range while tuning brings it back. Writing outside the range logs one warning per parameter. |
| Default | What sounds read when nothing has been set. |
| Smoothing | How fast sounds follow a new value: None, Rate or Ease (below). |
| Bypass While | Optional on/off parameter that turns the smoothing off while it reads 0.5 or more. |
A name and an asset with that Parameter Name are one parameter: SetRTPC("EngineRPM", v) and SetParameter(engineRpmAsset, v) write the same global value.
Three scopes
A value can be set in three places. A sound reads the first one that has a value:
- Play: one sound only, set on its handle. For values that differ between sounds on one object, such as each axle’s tyre slide.
- Emitter: every sound posted on one Audio Emitter (one car, one machine).
- Global: every sound with no play or emitter value. This is what
SetRTPChas always set. - Otherwise, the parameter’s Default (0 for a name with no asset).
// Global: every sound without a closer value.
AudioManager.Instance.SetParameter(intensity, 0.7f);
AudioManager.Instance.SetRTPC("CombatIntensity", 0.7f); // the same thing, by name
// Emitter: every sound posted on this car.
AudioEmitter car = GetComponent<AudioEmitter>();
AudioHandle engine = car.Post(engineEvent); // or engineEvent.Post(car)
car.SetParameter(rpm, 4200f);
// Play: this sound only, here at the car's front axle (a named point, below).
AudioHandle frontAxle = car.Post(tyreEvent, "FrontAxle");
frontAxle.SetParameter(slideSpeed, 3.5f);
// Fall back to the next scope.
car.ClearParameter(rpm);
// What a sound actually reads, after clamping.
float value = engine.GetEffectiveParameter(rpm);
Audio Emitter (Add Component > Audio System > Audio Emitter) holds the emitter scope. Posting on it places and attaches 3D sounds exactly as posting with its GameObject does. A post on a plain GameObject has no emitter values, as before. In Play mode its inspector lists the values set on it.
Switch values use the same three scopes: car.SetSwitch(surface, "Kerb") for one car, handle.SetSwitch(...) for one sound (§4.5).
Values at post
A value set on a handle after Post reaches a sound that has already started. A one-shot that must start right (a heavy impact at a heavy impact’s level, a footstep on this foot’s surface) carries its values with the post: they go on the sound’s own scope before any of its voices start, so the first sample hears them.
car.Post(impactEvent, PostValues.Of(strength, 0.8f));
car.Post(footstepEvent, "RearAxle", PostValues.Of(surface, "Grass").With(strength, 0.3f));
impactEvent.Post(debrisObject, debrisObject.transform.position, PostValues.Of(strength, 1f));
- Up to 4 parameters and 2 switch values per post;
PostValuesis a struct, so building one allocates nothing. - They are the sound’s own values (the play scope, like
handle.SetParameter), first in line before its emitter’s and the global ones, and the handle keeps them for later changes. - A post can also carry the sound’s own volume and pitch offsets:
PostValues.VolumeOffset(-3f), or.WithVolumeOffset(dB)and.WithPitchOffset(semitones)on any values. They are the handle’sSetVolumeOffsetandSetPitchOffset, set before the first sample:car.Post(tyreEvent, "RearAxle", PostValues.Of(surface, "Asphalt").WithVolumeOffset(profile.TyresDb)).
Built-in values: Distance, Approach Speed, Angle
A Parameter asset’s Value From can be a value Pepper works out for each sound instead of one your code sets:
| Value From | Unit | What it is |
|---|---|---|
| Distance | m | From the sound to the listening point. |
| Approach Speed | m/s | How fast the sound and the listening point close in on each other: positive coming closer, negative moving apart. From the emitter’s velocity (measured, or SetVelocity), else the sound’s own measured motion, minus the listening point’s; eased over 50 ms. |
| Angle | ° | Around the listening point, on the horizontal plane: 0 ahead, +90 right, -90 left, ±180 behind. |
// A pass-by boost: a Parameter asset "ApproachSpeed" (Value From = Approach Speed, -80 to 80 m/s), and on the
// car's engine a volume curve on it rising to +3 dB at 50 m/s.
// A near/far engine: a Blend on a "Distance" asset (0-300 m) crossfading a close and a distant recording.
- Use them wherever a parameter goes: Parameter Curves, a Blend’s parameter, Pitch Follow, a Switch Group’s Driven By (a near/far switch).
- Each sound has its own value, from where its first voice is (before it has one, where it was posted). The asset’s range clamps it; its Smoothing doesn’t apply.
- Your code can’t set them:
SetParameteron one is ignored, with one warning.handle.GetEffectiveParameter(distance)reads a sound’s;emitter.GetEffectiveParameter(distance)the emitter’s own place.
Named points
One object often has several sound sources: a car’s exhaust at the tailpipe, its intake at the front, a tyre emitter per axle. Give the Audio Emitter Points: each a name and an offset in metres in the object’s own space (x right, y up, z forward; the object’s scale applies). Drag them into place in the Scene view while the object is selected.
car.Post(engineExhaustEvent, "Exhaust"); // attached at the exhaust's offset
car.Post(intakeEvent, "Intake");
car.Post(tyreRollEvent, "RearAxle");
car.SetParameter(rpm, 4200f); // one RPM, read by every sound on the car
- A sound posted at a point is attached to the object at that offset, so it moves and turns with it, a Sequence’s later clips too. A 2D sound ignores the place.
- Every point shares the emitter’s parameter values (and its velocity, below). Values that differ per point, such as each axle’s slide speed, go on that sound’s handle.
- An unknown name warns once and plays at the object’s origin.
- By number is a little faster for sounds posted very often:
int rear = car.FindPoint("RearAxle"); car.Post(skidEvent, rear); car.SetPoint("Exhaust", offset)adds or moves a point from code; sounds already playing stay where they were attached.
Posting at an offset, and moving a sound
An impact happens where the car hit, not at a named point. Post it at any offset in the object’s own space (metres): it is like a point, with the emitter’s values, its Max Instances Per Emitter and Cooldown Per Emitter, and its random numbers.
Vector3 local = car.transform.InverseTransformPoint(contact.point);
car.Post(impactEvent, local, PostValues.Of(strength, impactSpeed / 25f)); // or impactEvent.PostAtOffset(car, local, ...)
// A scrape loop that follows the contact along a wall: move it every frame it lasts.
scrape = car.Post(scrapeEvent, local);
// ... each frame:
scrape.SetLocalOffset(car.transform.InverseTransformPoint(contact.point));
handle.SetLocalOffset(offset)moves a playing sound to that offset from the object it was posted on (an emitter, or a GameObject); it keeps moving and turning with the object. It takes effect at once, whether the sound is attached (On Disable = Keep) or follows the object (Stop, Pause).handle.SetWorldPosition(position)puts it at a world position. On a sound posted on an object, that becomes an offset from the object, so it moves with it from there; a sound posted at a world position (no object) just moves there.- Voices the sound starts later (a Sequence’s next clip, a Switch’s new choice, a Play action waiting out its Delay) start where it was moved to.
SetPointstill doesn’t move sounds already playing at a named point; move them with their handle.
Level and pitch per emitter
An Audio Emitter’s Volume Offset (dB) and Pitch Offset (cents) apply to every sound posted on it, the ones playing now and the ones posted or started later: a car’s level against the others. They compose with a sound’s own offset (SetVolumeOffset, SetPitchOffset, or at the post).
car.VolumeOffsetDb = profile.CarDb; // this car 2 dB up in the mix
car.Post(engineEvent, PostValues.VolumeOffset(profile.EngineDb)); // and its engine against its tyres
- They have gain and pitch slots of their own (
GainStack.EmitterGain,PitchStack.Emitter), set in the inspector or from code, also in Play mode. - A raise can’t take a sound past full volume: an AudioSource’s volume stops at 1. Author car sounds a few dB below full to leave room for trims up.
Velocity. The emitter measures its velocity in Pepper’s per-frame step (car.Velocity), or you give it with car.SetVelocity(rigidbody.linearVelocity) every frame, which is exact for a physics car. Approach Speed (above) and Pepper’s Doppler (§4.1) read it.
Teleports. When the object jumps (a reset to the track, a respawn, a remote car placed), call car.Teleport(): Pepper’s next step takes the new position and measures nothing from the jump, so it isn’t read as a huge speed (a Doppler shriek). The last velocity stays until a new one is measured, so a car moving on at the same speed has no step in its pitch. A velocity given with SetVelocity is kept. Call SnapAllParameters() too if its values jump.
Switched off or destroyed (On Disable)
What an emitter’s sounds do when its GameObject is switched off or destroyed is its On Disable setting (WhenDisabled from code):
| On Disable | Switched off | Destroyed |
|---|---|---|
| Keep (default, as before 3.1) | Its 3D sounds are attached to it, so Unity stops them with it; a loop is never reclaimed. | Its 3D sounds are cut, with a warning, and Pepper rebuilds their voices. |
| Stop | Every sound on it fades out over Stop Fade Seconds (default 0.1 s) where it is, and goes back to the pool. Delayed parts of its sounds (a Delay action, a Sequence’s next clip) are cancelled. | The same: the fade finishes after the object is gone. |
| Pause | Its sounds pause (fading out over about 25 ms) and hold their place, keeping no real voice. Switched on again, they carry on from there, every layer of an engine together, in phase. | They stop with the fade. |
- With Stop or Pause, the sounds aren’t attached to the object. They stay under Pepper’s “AudioVoices” root and follow it: Pepper moves each one to its object (and its point) every frame, after every LateUpdate and before Unity mixes, so it sits exactly where an attached one would. That is what lets a fade finish after the object is gone. Unity won’t let anything move a child out of an object while it is being switched off.
- While a Stop or Pause emitter is off, posts on it play nothing (they return a finished handle, silently): a script on the same object posting in its own
OnDisableruns either before or after the emitter, so it must not matter. - Set On Disable before posting. It is read when a sound is posted; sounds already playing keep the way they were attached.
- Use Stop for objects created and removed all the time (cars on show, cars that leave), and Pause for objects you switch off to bring back.
// A car prefab: its sounds fade out over 0.2 s when the car is removed, instead of being cut.
car.WhenDisabled = EmitterDisableMode.Stop;
car.StopFadeSeconds = 0.2f;
Smoothing
The game usually writes a value every frame (or faster, from physics), and it jumps around. A parameter’s Smoothing makes what sounds hear follow it gently, the same at 40 fps as at 144 fps:
| Mode | Fields | What it does |
|---|---|---|
| None | Sounds hear the written value at once. | |
| Ease | Rise, Fall (seconds) | Closes 95% of the gap to a new value in that time: a fast start that slows as it arrives. Separate times for rising and falling, for example a quick throttle attack and a slower release. |
| Rate | Rise speed, Fall speed (units per second) | Moves at most that fast, then stops on the value. |
0 in any field means “at once” for that direction.
- Each scope smooths its own value. Two cars ease their own RPM; a value on a handle eases separately from its emitter’s.
- The first value a scope gets is heard at once, so a new engine doesn’t glide up from the default.
- Values set by name are never smoothed.
SetRTPCandTransitionRTPCbehave exactly as before. Smoothing applies to writes through the asset (SetParameter,RampParameter). - Snap skips the smoothing: sounds hear the written value now, and a running ramp jumps to its end. Use it on a reset or a teleport so nothing glides across it.
- Per car: an emitter or a handle can override the smoothing for its own value, for example a different throttle attack per car.
- Bypass While: point it at an on/off parameter (a rev limiter). While that reads 0.5 or more in the same scope, the value is heard at once, so the limiter’s cuts stay sharp.
- Clearing a smoothed value eases it over to the next scope’s value before it goes, instead of jumping.
// On the asset: Smoothing = Ease, Rise 0.05 s, Fall 0.2 s. Then write the raw value every frame:
car.SetParameter(load, throttle);
// This car only: a sharper attack.
car.SetSlewOverride(load, ParameterSlew.Ease(0.02f, 0.2f));
// The car was reset to the start line: nothing should glide.
car.SnapAllParameters();
GetEffectiveParameter returns the smoothed value sounds hear now; TryGetParameter and GetRTPC return what was written. RTPC listeners are told written values, not the smoothed ones.
Ramps
RampParameter(parameter, target, seconds) exists on the manager (global), an emitter and a handle. It moves the written value in a straight line from what was written before (or, with nothing written, what that scope hears now), on unscaled time; smoothing applies on top. A new ramp, a Set, a Snap or a Clear on the same parameter in the same scope replaces it. TransitionRTPC(name, value, seconds) is a global ramp by name: a second call now replaces the first instead of the two fighting.
When values are heard
Pepper updates parameters (ramps and smoothing) once a frame, after every LateUpdate and just before Unity mixes the audio, so a value set anywhere in this frame’s game code (Update, LateUpdate, a camera script) is heard this frame. No Script Execution Order setup is needed.
SetRTPCstill tellsIRTPCListeners at once, inside the call. Global writes through an asset tell them too, by the asset’s name. Emitter and play values don’t.- A Blend layer picks a change up in that per-frame update, not inside
SetRTPCas it used to. It is still the same frame. - Manual update: set the Audio Manager’s Update Mode to Manual and call
AudioManager.Instance.Tick()once a frame after your last write, if your game runs its own loop. - The update starts with the frame after the Audio Manager is created. A Blend posted in that very first frame still starts at the right level.
Cost
Each parameter gets a small number the first time it is used. After that, a write is an array write: no string lookups and no garbage. One hundred writes and an update allocate nothing.
From the Pepper.SFX 3.1 guides (USER_GUIDE.md). Something wrong or unclear? Tell me.