Guidance / Manual
Audio Containers
Containers define how audio clips are organized and played. All containers inherit from AudioContainer base class.
4.1 Container Base Class
Location: Containers/audio-container-base.cs
Common Properties
Every container has these properties:
Identity:
string ContainerName { get; } // Unique identifier
string Description { get; } // Designer notes
List<string> Tags { get; } // Searchable organization tags
Mixer Integration:
AudioMixerGroup MixerGroup { get; set; } // Optional mixer routing
Randomization:
bool enableVolumeRandomization; // Add volume variation
float volumeRandomMin; // Min offset in dB (-12 to 12)
float volumeRandomMax; // Max offset in dB (-12 to 12)
bool enablePitchRandomization; // Add pitch variation
float pitchRandomMin; // Min offset in cents (-1200 to 1200)
float pitchRandomMax; // Max offset in cents (-1200 to 1200)
3D Spatialization:
bool Is3D { get; } // Enable 3D positioning
float MinDistance { get; } // Full volume distance
float MaxDistance { get; set; } // Zero volume distance (setting it changes the running game only)
AudioRolloffMode RolloffMode { get; } // Logarithmic/Linear/Custom
AudioAttenuation Attenuation { get; } // A shared distance asset; replaces the three above when set
float DopplerLevel { get; set; } // Unity's Doppler strength (Doppler = Unity)
DopplerMode Doppler { get; set; } // Unity (default) or Pepper
float DopplerExaggeration { get; set; } // Pepper: ×1 to ×3
float DopplerMaxCents { get; set; } // Pepper: the largest shift either way, in cents
Doppler
A container’s Doppler (3D Settings) says who shifts the pitch of its 3D sounds as they move relative to the listener:
| Doppler | What it does |
|---|---|
| Unity (default, as before 3.1) | Unity’s Doppler, by Doppler Level (0 = none, 1 = Unity’s default). Pepper can’t see it, so a voice that goes virtual comes back out of step with it. |
| Pepper | Pepper’s own, in the voice’s pitch stack: ratio = c ÷ (c − v), c = 343 m/s, v the sound’s closing speed on the listening point (positive coming closer). Then times Doppler Exaggeration (×1 physical, up to ×3 for an arcade pass) and kept within ± Doppler Max Cents (100 cents = a semitone). Unity’s Doppler is off on these voices. |
- A car passing at 50 m/s swings about 5 semitones at ×1 (+2.7 coming, −2.4 going) and about 10 at ×2.
- The closing speed is the sound’s Approach Speed (§3.4): its emitter’s velocity (measured, or
SetVelocity), else how the sound moves, against the listening point’s, eased over 50 ms. One value per sound (from its first voice), as for the built-in values. - Teleports: a car reset or placed elsewhere calls
emitter.Teleport(), so the jump isn’t read as speed; a camera cut callspoint.ClearVelocity(). Without them, Max Cents keeps the shriek bounded. - It is outside the container’s Pitch Limit (which is for the authored pitch); a virtual voice keeps its place at the pitch with Doppler in, so layers come back in step.
- Uses Parent Positioning carries it down with the other 3D settings: set it once on an engine’s top Blend.
- When Pepper pans (the Audio Manager’s Spatial Mode = Pepper, §9.3), every 3D sound plays 2D for Unity, which then has no Doppler to give it, so every one gets Pepper’s: a container left at Doppler = Unity gets Pepper’s at its Doppler Level (0 = none) as the exaggeration, within its Max Cents. The closing speed is measured from the sound’s own listening point, the one that hears it loudest.
Attenuation assets
Create > Audio System > Attenuation makes a distance asset you can share between sounds: how they change with distance from the listening point, as curves over metres.
| Field | Unit | What it does |
|---|---|---|
| Max Distance | m | Where the curves end. Past it the sound plays virtually (silent, keeping its place, no real voice), and it comes back inside 95% of it. |
| Volume | dB | The level at each distance (0 = as authored, -6 = half). The default: 0 dB to 2 m, 6 dB less per doubling, nearly silent at 100 m. |
| Low Pass (optional) | Hz | Dull the sound with distance (22000 = open). |
| High Pass (optional) | Hz | Thin it with distance (10 = open). |
| Spread (optional) | 0–1 | How wide it sounds: 0 a point (fully panned), 1 fully wide. Close cars wide, far cars narrowing to a point. |
- Set it in a container’s 3D Settings (Attenuation). It replaces that container’s Min Distance, Max Distance and Rolloff, which grey out. A container inside a Blend or Switch takes its parent’s with Uses Parent Positioning, so set it once on the top of an engine. Containers without one work exactly as before.
- Volume and Spread are applied by Unity while it mixes (Pepper hands the curves over as Unity’s custom curves), so even a fast pass-by fades smoothly at any frame rate. Unity measures from its AudioListener: keep it on the listening point (
AudioManager.DriveUnityListener, §9.3). - Low Pass and High Pass are set by Pepper from each voice’s distance every frame, eased over about 20 ms, and combine with Parameter Curves: the lowest low-pass and the highest high-pass win.
- Past Max Distance, a sound starts or goes virtual while the Audio Manager manages voices (LOD on); a persistent one too. With LOD off it keeps playing at the curve’s last level.
- Far sounds lose to near ones: a voice’s importance (§3.2) comes from how loud its curve says it is at its distance.
handle.SetAttenuation(otherAsset)plays one sound with another asset (a hero car heard further away), andClearAttenuation()goes back. Neither changes an asset.- Filter curves go in a straight line in octaves between keys, as Parameter Curves do.
- Editing the asset in Play mode: with Spatial Mode = Pepper (§9.3), the volume, filter and spread curves are read every frame, so a change is heard at once. With Spatial Mode = Unity, the volume and spread curves and Max Distance are handed to Unity when a sound starts, so a sound already playing (a persistent engine) keeps the old ones until it is posted again, while Pepper’s own Max Distance check reads the new one at once. Re-post the sound after changing them.
- The curves’ last key holds past it: with the default volume curve (nearly silent at 100 m) and a Max Distance of 150 m, a sound is nearly silent from 100 m but stays a live voice to 150 m. Move the last keys to Max Distance.
Parent Settings (for a container played inside a Blend or Switch):
bool UsesParentPositioning { get; set; } // Take the parent's 3D settings instead of its own
bool UsesParentOutput { get; set; } // Play through the parent's Mixer Group instead of its own
Both are off by default, so a child keeps its own settings, as before. A Blend’s or Switch’s own 3D settings and Mixer Group never reach its children unless a child asks. If the parent itself asks its own parent, the question goes up until a container answers it. Set the 3D settings once on the top Blend and tick Uses Parent Positioning on the layers.
Parameter Curves:
List<ParameterCurve> ParameterCurves { get; } // properties driven by parameters
Each curve maps one parameter (X, in the parameter’s unit) to one property (Y):
| Property | Y unit | Several curves on one container |
|---|---|---|
| Volume | dB (0 = as authored, -6 = half) | add |
| Pitch | cents (100 = one semitone) | add |
| Low Pass | Hz (22000 = open) | the lowest wins |
| High Pass | Hz (10 = open) | the highest wins |
- The parameter is read in each play’s scopes (the handle’s value, then the emitter’s, then global, §3.4), after smoothing, every frame. Curves apply to every sound below the container in the play’s tree, and compose down it: volumes and pitches add, the lowest low-pass and highest high-pass win.
- Filter curves go in a straight line in octaves between keys (1 kHz to 4 kHz passes 2 kHz half way), and ignore tangents.
- A Low Pass or High Pass curve switches on the filter every pooled voice has had, switched off, since the pool was built (so nothing is added mid-game); it eases over about 20 ms, is set before the voice’s first sample, and switches off when fully open. These are separate from occlusion, which keeps filtering through mixer slots (§14.1).
- A volume curve can’t make a sound louder than full scale (an AudioSource’s volume tops out at 1). The inspector warns when a curve goes above 0 dB.
- A container with curves is updated every frame; one without costs nothing.
Example: the hero car's engine pulls back as speed rises (a racing game).
Engine Blend → Parameter Curves → Speed (m/s): Volume, (0, 0 dB) to (60, -6 dB)
Wind loop → Parameter Curves → Speed (m/s): Low Pass, (0, 800 Hz) to (60, 12000 Hz)
Common Methods
public abstract AudioVoice Play(Vector3 position = default, GameObject parent = null);
public abstract void Stop();
public abstract void StopImmediate();
Randomization Implementation
protected void ApplyRandomization(AudioVoice voice)
{
if (enableVolumeRandomization)
{
float dbOffset = AudioRandom.Range(volumeRandomMin, volumeRandomMax); // Pepper's random numbers, see below
// MultiplierGain is the gain-stack slot for per-voice variation; it composes with
// the bus, occlusion, blend and handle slots.
voice.GainStack.MultiplierGain *= Mathf.Pow(10f, dbOffset / 20f);
}
if (enablePitchRandomization)
{
float centsOffset = AudioRandom.Range(pitchRandomMin, pitchRandomMax);
// The pitch stack's Randomization slot, so a handle's SetPitchOffset composes with it.
voice.PitchStack.Randomization *= Mathf.Pow(2f, centsOffset / 1200f);
voice.UpdateFinalPitch();
}
}
The volume value is drawn before the pitch value, and both after the clip pick. Networked posts rely on that order to play the same variation on every machine.
Random numbers
Every random choice a sound makes (which clip a Random container plays, a Sequence’s random order, volume and pitch randomization, multi-position offsets) comes from Pepper’s own random numbers, not UnityEngine.Random, so audio never moves your gameplay’s random sequence:
- A sound posted on an Audio Emitter draws from that emitter’s own sequence. Set the emitter’s Random Seed (or call
emitter.SetRandomSeed(seed)) and the same seed replays the same picks, whatever other cars play; 0 (the default) gives a different sequence every run. - Sounds without an emitter share one Pepper sequence.
- Networked posts (§17) still seed
UnityEngine.Randomfor the post, so every machine picks the same variation, and put its state back afterwards. - The Audio Manager’s Random Numbers setting switches back to
UnityEngine.Random(Unity), as before 3.1.
Pitch Limit (semitones): keeps the container’s voices within that many semitones of their recorded pitch, whatever randomization and other pitch changes add up to. 0 (the default) means no limit. SetPitchAbsolute ignores it.
Why Randomization Matters: Prevents “machine gun effect” where repeated sounds become obviously repetitive.
Recommended Values:
- Subtle variation: Volume ±1-2 dB, Pitch ±50-100 cents
- Dramatic variation: Volume ±4-6 dB, Pitch ±200-300 cents
4.2 RoutingContainer
Menu: Create > Audio System > Routing Container
Purpose: Simplest container. Plays all assigned clips simultaneously.
Properties
List<AudioClip> AudioClips { get; } // Clips to play
float Volume { get; } // Base volume (0-1)
bool Loop { get; } // Loop all clips
When to Use
Single One-Shot Sounds:
Use Case: Button click
Setup: 1 AudioClip, no loop
Multi-Layer Sounds:
Use Case: Explosion
Setup: 3 AudioClips (blast + debris + shockwave)
Result: All play simultaneously for rich sound
Background Loops:
Use Case: Generator hum
Setup: 1-2 AudioClips, loop enabled
Implementation Details
public override AudioVoice Play(Vector3 position = default, GameObject parent = null)
{
if (AudioClips.Count == 0) return null;
AudioVoice primaryVoice = null;
// Play all clips
foreach (var clip in AudioClips)
{
if (clip == null) continue;
var voice = audioManager.GetVoice();
if (voice == null) continue;
voice.source.clip = clip;
voice.source.loop = Loop;
voice.source.volume = Volume;
ConfigureAudioSource(voice.source, position, parent);
ApplyRandomization(voice);
voice.source.Play();
activeVoices.Add(voice);
if (primaryVoice == null) primaryVoice = voice;
}
return primaryVoice;
}
Key Insight: Returns first voice as “primary” for handle control, but all clips play.
4.3 RandomContainer
Menu: Create > Audio System > Random Container
Purpose: Picks one random clip per trigger, with weighting and repeat avoidance.
Properties
List<WeightedAudioClip> AudioClips { get; } // Clips with weights
int AvoidRepeatLast { get; } // Don't repeat last N clips (0-10)
bool UseWeighting { get; } // Enable weighted selection
float Volume { get; } // Base volume
bool Loop { get; } // Loop selected clip
Avoid Repeat Last remembers recent clips per Audio Emitter: a car never repeats its own last clips, whatever other cars just played. Sounds posted without an emitter share one memory. ClearHistory() forgets them all. A networked post ignores and doesn’t change the memory, so the seed alone decides its clip. Keep it below the clip count: at or above it the setting does nothing (any clip can play next), and a container of two or more clips warns about it.
WeightedAudioClip Structure
[Serializable]
public class WeightedAudioClip
{
public AudioClip clip;
public float weight = 1f; // Selection probability (0-10)
public float volumeMultiplier = 1f; // Per-clip volume (0-1)
}
Weighted Selection Algorithm
private AudioClip SelectWeightedClip()
{
// Calculate total weight of available clips
float totalWeight = 0f;
foreach (var entry in availableClips)
totalWeight += entry.weight;
// Random value in range [0, totalWeight]
float random = AudioRandom.Range(0f, totalWeight);
// Find clip at this weighted position
float cumulative = 0f;
foreach (var entry in availableClips)
{
cumulative += entry.weight;
if (random <= cumulative)
return entry.clip;
}
return availableClips[0].clip; // Fallback
}
Example Weighting:
Clip A: weight = 5.0 → 50% chance
Clip B: weight = 3.0 → 30% chance
Clip C: weight = 2.0 → 20% chance
Total: 10.0
Repeat Avoidance
private List<AudioClip> recentlyPlayed = new List<AudioClip>();
private void AvoidRepeats()
{
// Remove recently played clips from selection pool
var available = AudioClips
.Where(clip => !recentlyPlayed.Contains(clip.clip))
.ToList();
// If too many excluded, reset
if (available.Count == 0)
{
recentlyPlayed.Clear();
available = AudioClips.ToList();
}
return available;
}
private void TrackPlayed(AudioClip clip)
{
recentlyPlayed.Add(clip);
// Keep only last N
while (recentlyPlayed.Count > AvoidRepeatLast)
recentlyPlayed.RemoveAt(0);
}
When to Use
Footsteps:
Setup: 6-8 clips, avoidRepeatLast = 2-3, equal weights
Result: Natural variation, never obvious repetition
Weapon Fire:
Setup: 3-5 clips, avoidRepeatLast = 1, equal weights
Result: Each shot sounds different
Voice Lines:
Setup: 10+ clips, avoidRepeatLast = 3, custom weights
Example: Common phrases weight = 5, rare phrases weight = 1
Best Practice: Always set avoidRepeatLast to at least 1-2 for any repeated sound.
4.4 SequenceContainer
Menu: Create > Audio System > Sequence Container
Purpose: Plays clips in a defined order with various playback modes.
Auto Advance and the handle. With Auto Advance on, one post plays the whole sequence, and every clip it plays belongs to that post’s handle:
- the handle stays alive through Delay After gaps;
OnFinishedfires after the last clip;handle.Stop()ends the sequence, even in the middle of a gap.A sequence with Loop Sequence off stops after its final entry, and its next post starts from the top again. If a clip is stopped by anything (a Stop, a steal, a crossfade,
StopAllSounds), the sequence ends; only a clip that finishes on its own moves it on. Pausing a clip holds the sequence.
⚠️ Hard limit — one logical sequence per asset. The sequence position and the auto-advance run live on the container asset, so every emitter that plays the same SequenceContainer shares one position. Starting it from a second emitter takes the run over: the first emitter keeps its current clip, and its handle finishes when that clip ends.
container.Stop()stops every play of the asset, on any container type; to stop one play, stop its handle. If you need two independent sequences running at once (two alarm panels, two radios), duplicate the asset — one SequenceContainer per logical sequence. This is by design for 3.0.
Properties
List<SequenceEntry> Entries { get; } // Ordered clip list
SequenceContainer.PlaybackMode SequencePlaybackMode { get; } // Forward/Reverse/PingPong/Random
bool LoopSequence { get; } // Loop entire sequence
bool AutoAdvance { get; set; } // Auto-advance after clip finishes
float Volume { get; } // Base volume
SequenceEntry Structure
[Serializable]
public class SequenceEntry
{
public AudioClip clip;
public float volumeMultiplier = 1f; // Per-clip volume
public bool loop; // Loop this specific clip
public float delayAfter = 0f; // Delay before next clip (0-5s)
}
Playback Modes
Forward:
Sequence: [A, B, C, D]
Playback: A → B → C → D → (loop to A)
Reverse:
Sequence: [A, B, C, D]
Playback: D → C → B → A → (loop to D)
PingPong:
Sequence: [A, B, C, D]
Playback: A → B → C → D → C → B → A → B → ...
Random:
Sequence: [A, B, C, D]
Playback: B → A → D → C → B → ... (random each time)
Implementation
private int currentIndex = 0;
private bool pingPongDirection = true; // true = forward
public override AudioVoice Play(Vector3 position = default, GameObject parent = null)
{
if (Entries.Count == 0) return null;
var entry = Entries[currentIndex];
var voice = PlayEntry(entry, position, parent);
AdvanceIndex();
return voice;
}
private void AdvanceIndex()
{
switch (Mode)
{
case PlaybackMode.Forward:
currentIndex = (currentIndex + 1) % Entries.Count;
break;
case PlaybackMode.Reverse:
currentIndex--;
if (currentIndex < 0) currentIndex = Entries.Count - 1;
break;
case PlaybackMode.PingPong:
if (pingPongDirection)
{
currentIndex++;
if (currentIndex >= Entries.Count - 1)
{
pingPongDirection = false;
currentIndex = Entries.Count - 1;
}
}
else
{
currentIndex--;
if (currentIndex <= 0)
{
pingPongDirection = true;
currentIndex = 0;
}
}
break;
case PlaybackMode.Random:
currentIndex = AudioRandom.Range(0, Entries.Count);
break;
}
}
Public Methods
public void ResetSequence()
{
currentIndex = 0;
pingPongDirection = true;
}
public void SetIndex(int index)
{
currentIndex = Mathf.Clamp(index, 0, Entries.Count - 1);
}
public AudioVoice PlayNext(Vector3 position = default, GameObject parent = null)
{
// Advances the sequence and plays the next entry
}
When to Use
Alarm Sequences:
Setup: [Beep, Silence, Beep, Silence]
Mode: Forward, Loop
delayAfter: 0.5s on each
Musical Sequences:
Setup: [Note1, Note2, Note3, Note4]
Mode: PingPong or Forward
Use for melodic patterns
Tutorial Steps:
Setup: [Step1VO, Step2VO, Step3VO]
Mode: Forward, No Loop
Called manually for each step
4.5 SwitchContainer
Menu: Create > Audio System > Switch Container
Purpose: Selects child container based on game state switch value.
Properties
AudioSwitchGroup SwitchGroup { get; } // The Switch Group asset it reads (or null)
string SwitchGroupName { get; } // Which switch to monitor (the asset's name, or the name field)
List<SwitchEntry> SwitchEntries { get; } // Value→Container mappings
AudioContainer DefaultContainer { get; } // Fallback if no match
SwitchChangeMode WhenSwitchChanges { get; } // Next Play Only, Crossfade or Restart
float CrossfadeTime { get; } // Crossfade only, seconds
float Volume { get; } // Volume multiplier
SwitchEntry Structure
[Serializable]
public class SwitchEntry
{
public string switchValue; // Switch value to match (e.g., "Metal")
public AudioContainer container; // Container to play
}
How It Works
- When the Switch plays, it reads its switch group’s value in that sound’s scopes, the same three as parameters (§3.4): the sound’s own (
handle.SetSwitch), else its emitter’s (emitter.SetSwitch), else the global one (AudioManager.SetSwitch), else the group’s Default Value. It picks the matching entry’s container, or the Default Container when none matches. - What happens when the value changes while the sound plays is the Switch’s When Switch Changes (below). The default, Next Play Only, is how Switches always worked: the playing sound keeps its choice and the next play picks the new value.
- The chosen container plays as part of the same play, under its own place in the play’s tree. So a Blend under a Switch reads its parameter in that play (the handle’s or emitter’s value), and a Sequence under it reports the clips it starts later to the same handle.
- Every voice the chosen container starts, later clips included, gets the Switch’s Volume on top of its own.
- The Switch’s own 3D settings and Mixer Group reach the chosen container only if it ticks Uses Parent Positioning / Uses Parent Output (§4.1).
// The tyre tree from a racing game: a Switch on surface over Blends on slide speed.
car.SetSwitch(surface, "Asphalt"); // this car's surface (surface: a Switch Group asset)
AudioHandle frontAxle = car.Post(tyreEvent, "FrontAxle");
frontAxle.SetParameter(slideSpeed, 3.5f); // the asphalt Blend under the Switch reads it
When Switch Changes
| Setting | What a playing sound does when its value changes | For |
|---|---|---|
| Next Play Only (default) | Nothing; the next play picks the new value. | One-shots: footsteps, impacts. |
| Crossfade | The new choice starts from its beginning and fades in while the old one fades out, over Crossfade Time (seconds, default 0.15), along equal-power curves so the loudness holds steady. | Loops: a tyre’s surface bed, an ambience. |
| Restart | The old choice fades out as a stolen sound does, over the Audio Manager’s Steal Fade Seconds (25 ms by default, at least three frames; 0 cuts it in a few ms), and the new one starts at once, at full level. | Loops that must not overlap. |
- The sound keeps its handle: no second
OnStarted, andOnFinishedonly when it really ends. - If the value flips back while the old choice is still fading out (a car clipping a kerb), the old one fades back up; no second copy starts.
- When the old choice has faded to silence, its voices stop. A Sequence in it starts no more clips.
- During a crossfade the sound plays both choices, so it uses their voices together for that moment. If the voice pool is too full for that, the old choice is cut at once and the new one takes its voices.
- A sound that is virtual (far away, or less important than the ones playing) changes virtually: the new choice starts silent and takes no other sound’s real voice.
- A paused sound doesn’t follow; it catches up when resumed.
- A value with no entry and no Default Container fades the sound out to silence. It stays alive, and comes back when the value has an entry again. That holds once the sound plays: when the value has no entry at the post itself, nothing starts and the post ends at once (its handle is finished). A loop posted once and kept (a tyre bed on a car that may start in the air) needs a Default Container.
// Each car's rear tyre bed follows the car's surface; the front axle has its own.
car.SetSwitch(surface, "Kerb"); // the rear bed (and everything else on the car) crossfades to kerb
frontAxle.SetSwitch(surface, "Asphalt"); // the front axle stays on asphalt until it crosses
Switch Group assets
Create > Audio System > Switch Group makes a group asset: its Group Name (empty = the asset’s name), its Values (Asphalt, Kerb, Grass) and a Default Value read when nothing is set. Give it to the Switch’s Switch Group field (the Switch inspector’s Create Switch Group Asset From Name button makes one from the name and the entries’ values), and each entry’s value becomes a dropdown.
- A group name and an asset with that name are one switch:
AudioManager.SetSwitch("Surface", "Grass"), state and event SetSwitch actions, and a Switch that only names “Surface” all read and write the asset’s switch. - Setting a value that isn’t in the asset’s Values warns once per value (a typo would otherwise just play the Default Container).
- The emitter and handle calls take the asset:
car.SetSwitch(surface, "Kerb"),handle.SetSwitch(surface, "Kerb"),ClearSwitch,TryGetSwitch, andGetEffectiveSwitchfor what a sound reads. - A Switch that only names its group reads the values set (by name or through an asset) but not an asset’s Default Value: with nothing set, it plays its Default Container, as before.
Driven by a parameter
A group can take its value from a parameter: set Driven By (a Parameter asset) and its Ranges, each a value and where it starts, running up to the next one’s start. For example a tyre’s slide speed picks Grip from 0, Slide from 0.3 and Skid from 0.7.
- The parameter is read in each sound’s scopes, so each axle can drive its own (set the slide speed on that axle’s handle).
- Hysteresis (in the parameter’s unit) is how far past a boundary the parameter must go before the value changes, so a value sitting on 0.3 doesn’t flicker between Grip and Slide. Each playing sound remembers its own range.
- A driven group follows only its parameter: a value set directly on it (
SetSwitchon the manager, an emitter or a handle) is ignored, with one warning.
Switch Groups Concept
What: Organizational concept for related switches.
Example:
Switch Group: "Surface_Type"
Values: "Grass", "Metal", "Wood", "Water", "Dirt"
Switch Group: "Weather"
Values: "Clear", "Rain", "Snow", "Fog"
Switch Group: "CharacterType"
Values: "PlayerMale", "PlayerFemale", "EnemyA", "EnemyB"
Setting Switches:
// Set before posting event
AudioManager.Instance.SetSwitch("Surface_Type", "Metal");
footstepEvent.Post(gameObject, transform.position);
// → Plays metal footsteps automatically
// Per character: on its Audio Emitter, with the group asset
playerEmitter.SetSwitch(surfaceType, "Metal");
playerEmitter.Post(footstepEvent);
When to Use
Surface-Dependent Footsteps:
Switch Group: "Surface_Type"
Entries:
- "Grass" → GrassFootstepsContainer (RandomContainer with 6 grass clips)
- "Metal" → MetalFootstepsContainer (RandomContainer with 5 metal clips)
- "Wood" → WoodFootstepsContainer (RandomContainer with 6 wood clips)
Default: GenericFootstepsContainer
Weather-Dependent Ambience:
Switch Group: "Weather"
Entries:
- "Clear" → ClearAmbienceContainer
- "Rain" → RainAmbienceContainer
- "Snow" → SnowAmbienceContainer
Character-Specific Voice:
Switch Group: "CharacterType"
Entries:
- "PlayerMale" → MaleVoiceContainer
- "PlayerFemale" → FemaleVoiceContainer
Best Practice: Always provide a DefaultContainer as fallback for unknown switch values.
4.6 BlendContainer
Menu: Create > Audio System > Blend Container
Purpose: Plays multiple containers simultaneously, crossfading them by a parameter.
Every post is a new copy. Posting a Blend event again starts another copy of the blend alongside the one already playing, like any other container (two engines using the same blend). Each copy reads its own value (§3.4): posted on two Audio Emitters, two engines follow two RPMs. Copies with no emitter or handle value follow the global value together. To restart instead, set the event’s Max Instances to 1; the inspector offers a one-click button. The old copy then stops instantly, without a fade.
handle.Stop()stops one copy;blend.Stop()stops them all. Before 3.0, a second post faded the first copy out, so a Blend asset could only play once at a time.
Properties
List<BlendEntry> BlendEntries { get; } // Layers to blend
AudioParameter BlendParameter { get; } // The parameter asset, or null
string BlendParameterName { get; } // The parameter's name (the asset's, when set)
float Volume { get; } // Master volume
BlendLoopMode LoopMode { get; set; } // FromLayers (default), ForceLoop or ForceOneShot
SilentLayerBehaviour SilentLayers { get; set; } // KeepPlaying (default) or Virtualize
float SilentThresholdDb { get; set; } // Virtualize: below this a layer goes virtual (-60)
bool Loop { get; } // Obsolete and not read: see LoopMode
BlendLayout Layout { get; set; } // VolumeCurves (default) or Regions
CrossfadeShape Crossfade { get; set; } // Regions: Linear, EqualPower (default) or SCurve
AudioParameter PitchFollowParameter { get; set; } // Pitch follow: each layer plays at value ÷ its Root Value
IReadOnlyList<AudioVoice> LayerVoices { get; } // Layer voices of every live play of this asset
float GetCurrentBlendValue() // The value a copy with no emitter or handle value reads
void AutoLayout() // Regions from the layers' Root Values
Choosing the parameter. Set Blend Parameter to a Parameter asset: the curves’ X axis is then in its unit (RPM, m/s) and values are clamped to its range. Or leave it empty and type a Blend Parameter Name: values are clamped to 0–1, as Blends always read them. The inspector’s Create Parameter Asset From Name button turns a name into an asset with a 0–1 range, so existing curves read the same; code calling SetRTPC with that name keeps working.
The Blend’s other settings inherited from the container base (3D, distances, mixer group, occlusion, randomization) are not applied to its layers; each layer uses its own. Only Volume and the curves always reach the layers. A layer can take the Blend’s 3D settings (Uses Parent Positioning) or Mixer Group (Uses Parent Output), §4.1: set them once on the Blend and tick the box on each layer.
BlendEntry Structure
[Serializable]
public class BlendEntry
{
public AudioContainer container; // Layer container
public AnimationCurve volumeCurve; // Volume Curves layout: parameter value → layer level (0-1)
public float rootValue; // The value it was recorded at (an engine loop's RPM)
public float regionMin, regionMax; // Regions layout: where it plays
public float fadeInWidth, fadeOutWidth; // Regions layout: how far in from each edge it reaches full level
public bool followPitch = true; // Pitch follow on for this layer
public float followRangeSemitones = 12f; // Pitch follow never moves it further than this
public List<ParameterCurve> curves; // Extra curves for this layer only (§4.1)
}
Two Layouts: Volume Curves or Regions
Volume Curves (the default, and every Blend made before 3.1): each layer’s level is its own drawn curve against the Blend’s parameter, 0 to 1.
Regions: each layer plays in a region of the parameter and crossfades with its neighbours, so nothing has to be drawn:
- Give each layer its Root Value: for an engine loop, the RPM it was recorded at.
- Press Auto Layout in the inspector. It sorts the layers by root and gives each one a region from half way to the lower neighbour’s root to half way to the upper one’s; each crossfade is centred on that half-way point and covers Auto Layout Fade of the gap (0.5 = half). The lowest layer reaches down to the parameter’s Min and the highest up to its Max. Edit the numbers afterwards if you like.
- Crossfade Shape: Equal Power (the default) keeps the loudness steady when the layers are separate recordings, like engine loops at different RPMs: each is at -3 dB at the middle. Linear suits layers recorded together (-6 dB at the middle). S-Curve eases in and out.
The inspector lists every layer’s region.
Pitch Follow
Set Pitch Follow Parameter (usually the same RPM parameter) and each layer plays at the parameter’s value ÷ its Root Value: a loop recorded at 4000 RPM plays at 1.25× at 5000 RPM. Every layer stays in tune with the engine, and so with the others, with no pitch curve to draw.
- Follow Range (±12 semitones by default) caps how far a layer is pushed; Follow Pitch off leaves a layer alone (a whine that shouldn’t move, say).
- Pitch follow goes into the pitch stack’s
Followslot, so a handle’s pitch offset and randomization still apply on top (§3.2). - Tuning check: the inspector shows how far each layer is pitched across the part of the range where it can be heard, and warns past Tuning Warning semitones (5 by default). Recordings stretched much further start to sound wrong; add a layer in between or narrow the regions.
- Unity changes pitch once per frame, in a step, so a very fast sweep at a low frame rate steps slightly; parameter smoothing (§3.4) keeps each step small.
- Use pitch follow instead of
SetPitchOffseton the whole handle for engines: one handle pitch can’t keep layers recorded at different RPMs in tune.
How It Works
Starting a play:
- Every entry’s container is played, all with the same position and parent. A layer can start more than one voice (a Routing layer with three clips starts three), and every one of them joins the post’s handle.
- Each layer voice gets the Blend’s Volume multiplied into its
GainStack.MultiplierGain. - The play reads its parameter (handle, then emitter, then global, then default) and sets each layer’s level, so the layers start at the right level.
- Every layer starts on one DSP time, the play’s: two DSP buffers ahead (one buffer later than a single voice, so a walk through many layers can’t straddle a buffer), or the post’s explicit DSP time. The layers are sample-aligned and stay so.
Every frame:
- Each live play reads its parameter again (after smoothing, §3.4) and, for each layer voice, works out the level of the entry that started that voice (its curve, or its region), writing it into
GainStack.RtpcGain. Pitch follow and any parameter curves are updated in the same pass. This runs after every LateUpdate, before the frame’s audio is mixed (§3.4). - Paused and virtualized layers are updated too, so the level is right when they resume.
Nesting. Any container can be a layer, including another Blend or a Switch. Each play keeps a tree of the containers it reached, so every nested Blend reads its own parameter in that play’s scopes (the handle’s value, then the emitter’s, then global), and a layer under several Blends gets the product of their curves. (Before 3.1, whichever value had changed last won.) For example, an engine: a Blend on load over two Blends on RPM (the on-load and off-load sets); load crossfades the sets while RPM moves inside both, per car.
Things to know:
- Voices that a layer starts later (an auto-advancing Sequence’s next clip) start at their place in the tree, so they get the Blend’s Volume and curve like the first clip. (Before 3.1 they started at full level.)
- Only plays with a Blend somewhere in their tree are updated every frame; a play without one costs nothing per frame.
- A steal takes a whole Blend play or none of it, never one layer (§3.2, Voice Stealing). For a sound that must never be stolen, tick Persistent on its event (§5.3).
UpdateBlend(value)is obsolete: it sets the parameter’s global value. Set the parameter instead.
Loop Mode. Whether the layers loop. From Layers (the default): each layer loops as its own container’s Loop setting says, as Blends always did. Force Loop: every layer loops. Force One Shot: none does. A Blend inside another one that leaves its Loop Mode at From Layers takes the outer one’s. The old Loop checkbox was never read and is gone from the inspector; Sound Builder (Simple) now sets Loop Mode (its Loop ticked = Force Loop).
Silent Layers. Keep Playing (the default): a layer near silence keeps its real voice, sample-locked with the others, which music stems need. Virtualize: a layer whose own level is below Silent Threshold (−60 dB by default) goes virtual, freeing its real voice, and comes back once it is 6 dB above it. “Its own level” is its volume, the Blend’s crossfade and its curves; the bus, occlusion, a stop fade or a crossfade don’t count. A virtual layer keeps its place in the clip (§3.2), so it comes back in step with the others, about 54 dB down, where its short fade-in can’t be heard on a slow sweep. The fade-in takes at least three frames (75 ms at 40 fps), so a layer that must be back within a few frames is late at low frame rates: an engine’s off-load set, parked at full throttle, against a 50 ms rev-limiter cut. Give such a Blend Keep Playing. A layer that is already silent when the sound starts starts virtual and is never heard starting. A silent layer is never stolen on its own; it lives and ends with its sound. For an engine of 14 layers of which 2–4 are heard at a time, this keeps it to 2–4 real voices. A Blend inside another one that leaves this at Keep Playing takes the outer one’s.
Designing Volume Curves
Concept: AnimationCurve maps the parameter’s value (X: 0–1 for a named parameter, the asset’s unit for a Parameter asset) to layer level (Y: 0–1).
Example: Combat Intensity Music (3 layers)
Layer 0 - Ambient:
Curve:
(0.0, 1.0) → Full volume at calm
(1.0, 0.0) → Silent at combat
Layer 1 - Tension:
Curve:
(0.0, 0.0) → Silent at calm
(0.5, 1.0) → Full volume at medium intensity
(1.0, 0.0) → Silent at full combat
Layer 2 - Combat:
Curve:
(0.0, 0.0) → Silent at calm
(1.0, 1.0) → Full volume at combat
Result:
RTPC = 0.0: Ambient: 100%, Tension: 0%, Combat: 0% (calm)
RTPC = 0.25: Ambient: 75%, Tension: 50%, Combat: 0% (alert)
RTPC = 0.5: Ambient: 50%, Tension: 100%, Combat: 50% (tense)
RTPC = 0.75: Ambient: 25%, Tension: 50%, Combat: 75% (fighting)
RTPC = 1.0: Ambient: 0%, Tension: 0%, Combat: 100% (intense combat)
When to Use
Dynamic Music:
Layers: Ambient, Tension, Combat
RTPC: "CombatIntensity" (driven by enemy count/proximity)
Vehicle Engine:
Layers: Idle, Load, Redline
RTPC: "EngineRPM" (driven by speed/throttle)
Weather Intensity:
Layers: Calm, Moderate, Storm
RTPC: "WeatherIntensity"
Proximity Blending:
Layers: Distant, Medium, Close
RTPC: "DistanceToPlayer"
Best Practice: Design curves in Unity’s Animation Curve editor for visual feedback.
4.7 Generated Engines (Unity 6.3 and later)
An engine can be played from sweeps instead of loops: the Granular Engine cuts a recorded or synthesised sweep into
engine cycles and plays them at the car’s RPM, so a fast rev glides through every RPM with no band crossfade. The
Additive Engine adds the engine’s firing orders as sine tones, for weight. Both are containers whose sound is
computed while they play (Unity 6.3’s audio generators), on Pepper’s voices like any other: buses, panning, Doppler,
filters, pause, virtual voices and Blend nesting all apply. They are in the SFXSystem.Generators assemblies, which
compile only on Unity 6000.3 and later; on earlier versions they don’t exist, and the rest of Pepper is unchanged.
Which engine when:
- A loop blend (the engine recipe, §3; the Engine Builder, §10.8): steady character, predictable, every behaviour a layer you control. Its weakness is fast revs, where each loop is stretched until its neighbour takes over.
- A granular engine: the best response and transitions, from two sweeps per car. Its weakness is a held RPM, which can sound frozen; it varies the grains to avoid that.
- Both, or either with an additive layer: a Blend whose layers are the engines, each at full level. This is how most current racing games build theirs.
1. Make the sweeps. An on-load sweep is the engine climbing at full throttle, idle to redline; an off-load sweep is it falling with the throttle closed. Mono (or folded to mono), steady and slow enough to give at least 10 grains per 1000 RPM: 10 s from 1000 to 9000 RPM is plenty. A synthesised sweep is ideal, because its RPM is known exactly: note its range and whether it moves in a straight line or evenly in pitch (most synthesisers). Set each clip to Decompress On Load: the engine reads its samples.
2. Cut them into grains with Window > Audio System > Engine Grains (§10.10). Each sweep becomes a Grain Table: one grain per engine cycle (two turns), tagged with its RPM.
3. Make the engine: Create > Audio System > Granular Engine (Unity 6.3+).
- Rpm and Load: the same parameters a loop engine reads (EngineRpm, EngineLoad01). Without Load it plays on load only.
- On Load and Off Load: the two Grain Tables, blended by load at equal power.
- Idle and Idle Rpm (optional): an idle loop and the RPM it was recorded at. Below Idle Rpm the idle plays alone, pitched to the RPM; between it and the sweeps’ lowest RPM the two crossfade.
- Crossfade Ms (3): how long each grain fades into the next. The grains are cut in phase, so the fade’s gains sum to 1 and a join isn’t heard.
- Variation (2): at each grain, a random pick among up to this many neighbours within half a semitone of the RPM, so a held RPM never repeats one grain.
Each grain is played by the RPM the sweep had at every point of it, not by its average: the sweep climbed while the
grain was recorded, and played at one speed a held RPM would warble once a cycle (fixed after 3.1.0-dev.11).
4. Play it like any engine: an event with Persistent on and a Target Bus, posted on the car’s Audio Emitter (§3.4). The game writes RPM and load on the emitter as it does for a loop engine; nothing in the game’s code changes. Each car’s voice reads its own values. A generated engine moves to each frame’s values over the time that frame took, so a rev climbs at an even pace whatever the frame rate; it hears them about a frame and two audio buffers late (25–75 ms).
5. Tune it by ear in the Audition (§10.5): the Rpm and Load parameters get sliders, and a sweep on Rpm plays a rev. Generators play outside Play mode.
An additive layer (optional): Create > Audio System > Additive Engine (Unity 6.3+). Order 1 is the firing frequency, RPM × Cylinders ÷ 120 (Cylinders is a scale, as in Loop Analysis: 6 under loops or sweeps tuned to notes at RPM ÷ 20 Hz). Each order (0.5, 1, 1.5, 2… up to 16 of them) has a level Off Load and On Load, and the load moves between them; a little noise, low-passed at Noise Brightness times the firing frequency, takes the edge off. It isn’t a whole engine: on its own it sounds synthetic. Put it under an engine in a Blend:
Car_Engine (Blend, 3D, no blend parameter; each layer at full level)
├── Car_Granular (Granular Engine) or the Engine Builder's loop tree
└── Car_Weight (Additive Engine, Volume 0.3)
Leave Follow Pitch off for a generator layer: it already plays at its RPM (the Container Validator says so).
What it costs. Measured on the development PC, without Burst: a granular engine with both sweeps playing takes about 0.4% of a CPU core, and an additive layer of 16 orders about the same. To measure on the target (a Steam Deck), switch the measurement on in a build, drive for a while, and log it:
using AudioSystem.Generators;
GeneratorCost.Measuring = true; // starts a new measurement
// ... drive for 10 seconds ...
Debug.Log(GeneratorCost.Report(enginesPlaying)); // buffers, µs per buffer, share of a core per instance
GeneratorCost.Measuring = false;
Good to know:
- A Grain Table keeps a reference to its sweep, not a copy: re-analyse after editing the sweep, and the next play reads it again.
- Below the sweeps’ lowest RPM with no idle loop, and across a gap in the sweep, the nearest grain is stretched.
- A Sequence container can’t hold a generator (its entries are clips).
- The Container Validator checks generator containers: nothing to play, no RPM parameter, a table with no grains, a sweep that can’t be read, an off-load sweep with no Load parameter, a silent additive layer.
From the Pepper.SFX 3.1 guides (USER_GUIDE.md). Something wrong or unclear? Tell me.