Pepper.SFX
All Guidance pages

Guidance / Cookbook

States & RTPCs

Recipe: Underwater State

📋 What: Complete underwater audio effect

🎯 Use Case: Swimming, diving, underwater sections

Asset Setup

1. Create States:

Normal State:

  • Audio State: “State_Normal”
  • Save to: Assets/Resources/Audio/States/
  • Inspector:
    • State Name: “Normal”
    • State Group: “Location”
    • (Leave all properties empty/default)

Underwater State:

  • Audio State: “State_Underwater”
  • Save to: Assets/Resources/Audio/States/
  • Inspector:
    • State Name: “Underwater”
    • State Group: “Location”

Bus Volumes:

  • [0] Bus: SFX_Bus, Volume Db: -9
  • [1] Bus: Music_Bus, Volume Db: -12
  • [2] Bus: Dialogue_Bus, Volume Db: -6

Switch Values:

  • [0] Switch Group: “Location”, Value: “Underwater”

RTPC Values:

  • [0] Parameter: “Underwater”, Value: 1.0

A state sets the global value of a parameter, by name. To give it a range and a default, create a Parameter asset whose Parameter Name is “Underwater”: the state and the asset share one value. Underwater is the same for every sound, so a global value is right here; per-object values go on an Audio Emitter (Manual §3.4).

2. Create Underwater Ambience:

  • Routing Container: “Ambience_Underwater_RC”
  • Clip: Underwater bubbles/muffled sound
  • Loop: ✓
  • Volume: 0.8
  • Is3D: ☐ (2D)

3. Create Event:

  • Event: “Play_Ambience_Underwater”
  • Actions: Play Ambience_Underwater_RC → Ambience_Bus

Code

using UnityEngine;
using AudioSystem;

public class UnderwaterVolume : MonoBehaviour
{
    [Header("Audio")]
    [SerializeField] private AudioEvent underwaterAmbienceEvent;
    [SerializeField] private float transitionTime = 1.5f;

    [Header("Optional VFX")]
    [SerializeField] private GameObject underwaterVFX;

    private AudioHandle ambienceHandle;
    private bool isUnderwater = false;

    void OnTriggerEnter(Collider other)
    {
        if (!other.CompareTag("Player")) return;

        if (!isUnderwater)
        {
            EnterUnderwater();
        }
    }

    void OnTriggerExit(Collider other)
    {
        if (!other.CompareTag("Player")) return;

        if (isUnderwater)
        {
            ExitUnderwater();
        }
    }

    void EnterUnderwater()
    {
        isUnderwater = true;

        // Activate underwater state
        AudioManager.Instance.SetState("Underwater", transitionTime);

        // Set location switch
        AudioManager.Instance.SetSwitch("Location", "Underwater");

        // Play underwater ambience
        if (underwaterAmbienceEvent != null)
        {
            ambienceHandle = underwaterAmbienceEvent.Post();
        }

        // Enable VFX
        if (underwaterVFX != null)
        {
            underwaterVFX.SetActive(true);
        }

        Debug.Log("Entered underwater");
    }

    void ExitUnderwater()
    {
        isUnderwater = false;

        // Return to normal state
        AudioManager.Instance.SetState("Normal", transitionTime);

        // Reset location switch
        AudioManager.Instance.SetSwitch("Location", "Normal");

        // Stop underwater ambience
        if (ambienceHandle != null)
        {
            ambienceHandle.Stop(transitionTime);
            ambienceHandle = null;
        }

        // Disable VFX
        if (underwaterVFX != null)
        {
            underwaterVFX.SetActive(false);
        }

        Debug.Log("Exited underwater");
    }

    void OnDrawGizmos()
    {
        Gizmos.color = new Color(0, 0, 1, 0.3f);
        Gizmos.DrawCube(transform.position, transform.localScale);
    }
}

Setup:

  1. Create BoxCollider trigger volume for water
  2. Attach UnderwaterVolume script
  3. Assign underwater ambience event
  4. Tag player as “Player”

✅ Result: Smooth transition to muffled underwater audio!


Recipe: Vehicle Engine RPM

📋 What: Engine sound that changes with speed/RPM

🎯 Use Case: Cars, motorcycles, aircraft

Asset Setup

1. Create Layer Containers:

  • “Engine_Idle_RC” (idle loop)
  • “Engine_Load_RC” (medium RPM loop)
  • “Engine_Redline_RC” (high RPM loop)
  • Tick Loop on each of these, or set the Blend’s Loop Mode to Force Loop (step 3).
  • For a 3D engine, set Doppler to Pepper on the Blend (its 3D Settings; or Doppler Level 0 for none) and tick Uses Parent Positioning on each layer. Layers that go virtual then come back in step with the others; Unity’s Doppler is the one pitch change Pepper can’t follow (Manual §3.2, Voice Virtualization; §4.1 Doppler).

2. Create a Parameter:

  • Parameter: “EngineRPM” (Create > Audio System > Parameter)
  • Unit Label: “0-1”, Min: 0, Max: 1, Default: 0.2 (idle)
  • Smoothing: Ease, Rise 0.3 s, Fall 0.5 s. The engine follows the car’s speed gently, the same at any frame rate, so the script below doesn’t smooth anything itself.

3. Create Blend Container:

  • Blend Container: “Vehicle_Engine_BC”
  • Blend Parameter: the EngineRPM asset
  • Loop Mode: Force Loop
  • Silent Layers: Virtualize. Layers that are silent at the current RPM go virtual and free their real voices, coming back in step when the RPM reaches them (Manual §4.6).
  • 3D Settings: Is 3D ticked and Doppler Pepper (or Doppler Level 0). The layers take both through Uses Parent Positioning (step 1); without Is 3D on the Blend the engine plays 2D, in the listener’s head.

Each car has its own RPM. The car posts its engine on its own Audio Emitter and sets the value there, so two cars playing this Blend each follow their own RPM. A value set globally (SetRTPC("EngineRPM", ...) or AudioManager.SetParameter) reaches only engines whose emitter has no value of its own. See Manual §3.4.

Engine loops recorded at known RPMs? Use the parameter in real RPM (Min 0, Max 8000, Unit “RPM”), set each layer’s Root Value to the RPM it was recorded at, switch the Blend’s Layout to Regions, press Auto Layout, and set Pitch Follow Parameter to the same parameter. Every layer then crossfades at the right RPM and is pitched by RPM ÷ its recording, so the layers stay in tune with each other. In the script below, write the car’s real RPM instead of the 0–1 value (targetRPM from the engine model, idleRPM in RPM, say 900) and untick Modulate Pitch: Pitch Follow does the pitching. See Manual §4.6. The Engine Builder (Manual §10.8) builds this whole tree, with on-load and off-load sets, from the selected loops, and Detect Root Values (§10.7) measures each loop’s RPM.

Entry [0] - Idle:

  • Container: Engine_Idle_RC
  • Curve: (0.0, 1.0), (0.3, 0.5), (1.0, 0.0)

Entry [1] - Load:

  • Container: Engine_Load_RC
  • Curve: (0.0, 0.0), (0.5, 1.0), (1.0, 0.3)

Entry [2] - Redline:

  • Container: Engine_Redline_RC
  • Curve: (0.0, 0.0), (0.7, 0.2), (1.0, 1.0)

4. Create Event:

  • Event: “Start_Vehicle_Engine”
  • Actions: Play Vehicle_Engine_BC → SFX_Bus
  • Persistent: ticked. The engine is never stolen to make room for other sounds (Manual §5.3).

5. On the car: add an Audio Emitter component (Add Component > Audio System > Audio Emitter) next to the script below, and set its On Disable to Stop: when the car is destroyed or switched off, its engine fades out over Stop Fade Seconds where it is and its voices go back to the pool (Manual §3.4). Left at Keep, the engine’s voices sit under the car and are cut with it.

Code

using UnityEngine;
using AudioSystem;

[RequireComponent(typeof(Rigidbody), typeof(AudioEmitter))]
public class VehicleEngineAudio : MonoBehaviour
{
    [Header("Audio")]
    [SerializeField] private AudioEvent engineStartEvent;
    [SerializeField] private AudioParameter engineRpm;   // the EngineRPM asset

    [Header("RPM Settings")]
    [SerializeField] private float maxSpeed = 30f;
    [SerializeField] private float idleRPM = 0.2f;

    [Header("Pitch Modulation")]
    [SerializeField] private bool modulatePitch = true;
    [SerializeField] private float minPitch = 0.8f;
    [SerializeField] private float maxPitch = 1.4f;

    private Rigidbody rb;
    private AudioEmitter emitter;
    private AudioHandle engineHandle;

    void Start()
    {
        rb = GetComponent<Rigidbody>();
        emitter = GetComponent<AudioEmitter>();
        StartEngine();
    }

    // Call this when the car is reset or teleported, so the engine doesn't glide across it.
    public void OnCarReset() => emitter.SnapAllParameters();

    void StartEngine()
    {
        if (engineStartEvent != null)
        {
            // Posted on this car's emitter: the engine reads this car's RPM.
            engineHandle = emitter.Post(engineStartEvent);
        }
    }

    void Update()
    {
        UpdateEngineRPM();
    }

    void UpdateEngineRPM()
    {
        // Calculate target RPM from speed
        float speed = rb.linearVelocity.magnitude;
        float targetRPM = Mathf.Clamp01(speed / maxSpeed);

        // Keep minimum idle RPM when not moving
        if (targetRPM < idleRPM)
            targetRPM = idleRPM;

        // This car's RPM, raw. The parameter's Smoothing eases what the engine hears; the Blend
        // picks it up after LateUpdate, before the frame's audio.
        emitter.SetParameter(engineRpm, targetRPM);

        // Optional: Modulate pitch by the smoothed RPM the engine is playing at
        float currentRPM = emitter.GetEffectiveParameter(engineRpm);
        if (modulatePitch && engineHandle != null)
        {
            // SetPitchOffset takes semitones. A playback-rate ratio r is 12 * log2(r) semitones:
            // 0.8 -> -3.9 st, 1.4 -> +5.8 st. It shifts on top of the layers' own pitch
            // randomization, if any, rather than replacing it.
            float pitchSemitones = Mathf.Lerp(
                12f * Mathf.Log(minPitch, 2f),
                12f * Mathf.Log(maxPitch, 2f),
                currentRPM
            );
            engineHandle.SetPitchOffset(pitchSemitones);
        }
    }

    // Engine off while the car stays (a stall, the key turned). Removing the car needs nothing here: its Audio
    // Emitter, set to On Disable = Stop, fades the engine out where it is.
    public void StopEngine()
    {
        engineHandle?.Stop(0.5f);
        engineHandle = null;
    }
}

✅ Result: Realistic engine sound that responds to speed!

Why no OnDestroy: an earlier version posted a stop event there. A Stop action only stops its own event’s sounds, so a separate stop event never reached the engine, and a Stop(0.5f) from OnDestroy can’t fade voices that are destroyed with the car on the same frame. The emitter’s On Disable = Stop does both jobs.

Tuning by ear: open Window > Audio System > Audition with the engine Blend selected, press Play and Sweep the RPM (say 900 → 7000 over 4 s, back and forth) while you adjust regions and root values in the Inspector; the Layers table shows each band’s level and pitch (§10.5).

From the Pepper.SFX 3.1 guides (USER_GUIDE.md). Something wrong or unclear? Tell me.