Pepper.SFX
All Guidance pages

Guidance / Cookbook

Rhythmic SFX

Use BeatScheduler (AudioSystem.BeatScheduler, MonoBehaviour in RunTime/Utilities/) for any SFX whose rate must vary at runtime without changing pitch: heartbeats, metronomes, clocks, sonar pings, reactor pulses. Instead of time-stretching one long clip (which shifts pitch), it re-posts a one-shot AudioEvent on a 60 / BPM interval, anchored to AudioSettings.dspTime for stable cadence.

Recipe: Stress-Driven Heartbeat

📋 What: Heartbeat whose BPM rises as the player sees an enemy or takes damage, with no pitch artefacts.

🎯 Use Case: Horror / survival / stealth — “I just saw something”. Same pattern works for clock tension, reactor meltdown, alert pulse.

Asset Setup

1. Create Container:

  • Random Container: “SFX_Heartbeat_Thump_RnC”
  • Audio Clips: 2–3 thump variations (heartbeat_01.wav, heartbeat_02.wav, …) — each a single short thump, not a looping heartbeat loop
  • Avoid Repeat Last: 1
  • Loop: ☐
  • Volume: 0.8
  • Is3D: ☐ (2D — it’s in the player’s head)

Why one-shots, not a loop? BPM is controlled by firing rate, not playback speed. If you use a loop, source.pitch is your only speed knob and it will rise in pitch alongside the tempo.

2. Create Event:

  • Event: “Heartbeat_Thump”
  • Priority: Medium-High (don’t let combat SFX starve it)
  • Max Instances: 4 (lets overlap at panic BPM without voice-stealing the ongoing thump)
  • Actions: Play SFX_Heartbeat_Thump_RnC → Player_SFX_Bus

Scene Setup

  1. On the player (or a dedicated child GameObject), add a BeatScheduler component
  2. Drag the Heartbeat_Thump event asset into Audio Event
  3. Set Bpm = 60 (calm baseline)
  4. Leave Play On Enable = ✓, Fire Immediately On Start = ✓

Code

using UnityEngine;
using AudioSystem;

public class StressHeartbeat : MonoBehaviour
{
    [Header("References")]
    [SerializeField] private BeatScheduler heartbeat;

    [Header("Tempo Range")]
    [SerializeField] private float calmBpm = 60f;
    [SerializeField] private float panicBpm = 160f;
    [SerializeField] private float smoothing = 2f; // BPM ramp rate

    [Header("Stress Inputs")]
    [SerializeField] private float sightRadius = 15f;
    [SerializeField] private LayerMask enemyMask;

    private float currentStress;  // 0..1, internal
    private float targetStress;

    private void Update()
    {
        // 1. Compute target stress from game state.
        targetStress = ComputeStress();

        // 2. Smooth it so BPM ramps instead of snapping.
        currentStress = Mathf.MoveTowards(currentStress, targetStress, smoothing * Time.deltaTime);

        // 3. Drive the scheduler. The NEXT beat uses this BPM; the one already queued fires at the old rate — this is musically natural.
        heartbeat.Bpm = Mathf.Lerp(calmBpm, panicBpm, currentStress);
    }

    private float ComputeStress()
    {
        // Example: closest enemy inside sight radius => stress 0..1.
        var hits = Physics.OverlapSphere(transform.position, sightRadius, enemyMask);
        if (hits.Length == 0) return 0f;

        float closest = sightRadius;
        foreach (var h in hits)
            closest = Mathf.Min(closest, Vector3.Distance(transform.position, h.transform.position));

        return 1f - Mathf.Clamp01(closest / sightRadius);
    }

    // Call these from your damage / sanity / stealth systems.
    public void Spike(float amount)    => targetStress = Mathf.Clamp01(targetStress + amount);
    public void ForceCalm()            => targetStress = 0f;
}

Variations

Volume swell with BPM. The thumps should also get a bit louder as you panic. BeatScheduler doesn’t own an AudioSource, so drive the volume from the same stress value through a parameter. Make a global parameter asset Stress (0–1), write it each frame, and give it a Volume curve on the thump’s container (§4.1) or on Player_SFX_Bus (bus Parameter Curves, §6.6), for example 0 → -6 dB, 1 → 0 dB:

[SerializeField] private AudioParameter stressParam;   // the Stress parameter asset

// In Update, after computing currentStress. A bus curve reads the global value.
AudioManager.Instance.SetParameter(stressParam, currentStress);

Stay at or below 0 dB: a voice can’t go louder than its authored level. A curve on the container affects only the thumps; one on the bus moves everything on that bus.

Pause the heartbeat cleanly. Disable the component (heartbeat.enabled = false) or heartbeat.StopScheduling(). Already-posted thumps finish naturally; no new ones fire.

React on-beat with gameplay. Subscribe to the scheduler’s event to pulse a vignette, camera shake, or UI indicator in perfect sync with the thump:

void OnEnable()  => heartbeat.OnBeat += PulseVignette;
void OnDisable() => heartbeat.OnBeat -= PulseVignette;
void PulseVignette() { /* flash post-processing, etc. */ }

✅ Result: Heartbeat cadence scales smoothly from 60 → 160 BPM. The thump itself always sounds like a thump — no chipmunk pitch-up, no CPU cost from real-time time-stretching.


Recipe: Metronome / Rhythm Tick

📋 What: Fixed or adjustable tempo tick for rhythm games, timing tutorials, or in-engine practice tools.

Setup

  • BeatScheduler component, Audio Event = the Metronome_Tick event asset
  • For an accented downbeat (every 4th tick louder), subscribe to OnBeat and post a second event every 4 beats:
public class Metronome : MonoBehaviour
{
    [SerializeField] private BeatScheduler beat;
    [SerializeField] private AudioEvent accentEvent; // e.g. Metronome_Accent
    [SerializeField, Range(1, 16)] private int beatsPerMeasure = 4;

    private int beatCount;

    private void OnEnable()  => beat.OnBeat += HandleBeat;
    private void OnDisable() => beat.OnBeat -= HandleBeat;

    private void HandleBeat()
    {
        if (beatCount % beatsPerMeasure == 0 && accentEvent != null)
            accentEvent.Post(gameObject, transform.position);
        beatCount++;
    }
}

✅ Result: Standard tick on every beat, accented thump on the 1.

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