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.pitchis 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
- On the player (or a dedicated child GameObject), add a
BeatSchedulercomponent - Drag the
Heartbeat_Thumpevent asset intoAudio Event - Set
Bpm= 60 (calm baseline) - 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
BeatSchedulercomponent,Audio Event= theMetronome_Tickevent asset- For an accented downbeat (every 4th tick louder), subscribe to
OnBeatand 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.