Transposer/AudioManager.cs
namespace Sandbox.Transposer;
/// <summary>
/// Sound effect identifiers for <see cref="AudioManager"/>.
/// </summary>
public enum Sfx
{
BloodSplat,
Footstep,
CollectCoin,
CoinTwinkle,
EnemyTouchPlayer,
}
/// <summary>
/// Manages all audio playback for Transposer: SFX with per-clip pitch/volume
/// randomisation and looping music with crossfade.
///
/// Audio files are expected in <c>sounds/transposer/</c>:
/// SFX: drip.sound, step.sound, cha-ching.sound, twinkle.sound, laugh.sound
/// Music: music/song1.sound, music/song2.sound, music/song3.sound
///
/// Each <c>.sound</c> file is a sound event asset that references the actual
/// audio file (.wav/.ogg). Create them via the editor's sound event creator.
///
/// Music pitch increases as coins are collected — the <see cref="MusicPitch"/>
/// property is applied to the active music handle every frame via <see cref="Tick"/>.
/// </summary>
public static class AudioManager
{
private static bool _isMuted;
public static bool IsMuted => _isMuted;
private const float MAX_MUSIC_VOLUME = 0.15f;
private static bool _gamePaused;
public static float PausePitchMin { get; set; } = 0.75f;
public static float PausePitchMax { get; set; } = 0.95f;
private static float _pausePitchMult = 0.8f;
public static float PausePitchMult { get => _pausePitchMult; set => _pausePitchMult = value; }
private const float PAUSE_VOLUME_MULT = 0.5f;
private static float _currentMusicVolume; // base volume ignoring pause
// ── SFX path mappings ────────────────────────────────────────────────
// Sound event names matching the .sound files under Assets/sounds/transposer/.
private const string SFX_DRIP = "drip";
private const string SFX_STEP = "step";
private const string SFX_COLLECT = "cha-ching";
private const string SFX_TWINKLE = "twinkle";
private const string SFX_LAUGH = "laugh";
// ── Music tracks ───────────────────────────────────────────────────
private static readonly string[] MusicTracks = new[]
{
"song1",
"song2",
"song3",
};
private static int _currentTrackIndex = -1;
// ── Music state ──────────────────────────────────────────────────────
private static SoundHandle _musicHandle;
private static SoundHandle _fadingOutHandle;
private static float _fadeOutTimer;
private static float _fadeOutDuration;
private static float _fadeInTimer;
private static float _fadeInDuration;
private static bool _isFadingIn;
private static bool _isFadingOut;
private static float _musicPitch = 1.0f;
public static float MusicPitch
{
get => _musicPitch;
set => _musicPitch = value;
}
// ── Coin collect pitch wobble ────────────────────────────────────────
// Keeps a handle to the most recent coin collect SFX and randomly
// nudges its pitch while it's still playing, creating a warble effect.
private static SoundHandle _collectHandle;
private static float _collectBasePitch;
private static float _collectWobbleTimer;
private const float WOBBLE_CHECK_INTERVAL = 0.1f; // check every 100ms
private const float WOBBLE_CHANCE = 0.10f; // 10% chance each check
private const float WOBBLE_RANGE_LOW = -0.13f; // max downward pitch nudge
private const float WOBBLE_RANGE_HIGH = 0.15f; // max upward pitch nudge
// ── SFX playback ─────────────────────────────────────────────────────
/// <summary>
/// Play a sound effect with the original Transposer pitch/volume settings.
/// Each SFX uses randomised pitch within a range for variety.
/// </summary>
public static void PlaySfx( Sfx sfx )
{
switch ( sfx )
{
case Sfx.BloodSplat:
PlaySound( SFX_DRIP, Game.Random.Float( 0.9f, 1.4f ), 0.8f );
break;
case Sfx.Footstep:
PlaySound( SFX_STEP, Game.Random.Float( 0.9f, 1.4f ), 0.45f );
break;
case Sfx.CollectCoin:
_collectBasePitch = Game.Random.Float( 0.4f, 1.1f );
_collectHandle = PlaySound( SFX_COLLECT, _collectBasePitch, 0.5f );
_collectWobbleTimer = 0f;
break;
case Sfx.CoinTwinkle:
PlaySound( SFX_TWINKLE, 1.0f, 0.15f );
break;
case Sfx.EnemyTouchPlayer:
PlaySound( SFX_LAUGH, Game.Random.Float( 0.8f, 1.0f ), 0.25f );
break;
}
}
/// <summary>
/// Fire-and-forget a 2D (non-positional) sound with the given pitch and volume.
/// </summary>
private static SoundHandle PlaySound( string name, float pitch, float volume )
{
SoundHandle handle = Sound.Play( name );
if ( !handle.IsValid() ) return handle;
handle.Pitch = pitch;
handle.Volume = volume;
return handle;
}
// ── Music playback ───────────────────────────────────────────────────
/// <summary>
/// Start music from silence, fading in over <paramref name="time"/> seconds.
/// Picks a random track if none is currently selected.
/// </summary>
public static void FadeInMusic( float time )
{
StopMusic();
int trackIndex = PickRandomTrack();
_currentTrackIndex = trackIndex;
_gamePaused = false;
_musicHandle = Sound.Play( MusicTracks[trackIndex] );
_currentMusicVolume = 0f;
if ( _musicHandle.IsValid() )
{
_musicHandle.Volume = 0f;
_musicHandle.Pitch = _musicPitch;
}
_isFadingIn = true;
_fadeInTimer = time;
_fadeInDuration = time;
}
/// <summary>
/// Crossfade from the current track to a different random track
/// over <paramref name="time"/> seconds.
/// </summary>
public static void SwitchMusic( float time )
{
// Move the current track to the fade-out slot.
if ( _musicHandle.IsValid() && _musicHandle.IsPlaying )
{
_fadingOutHandle = _musicHandle;
_isFadingOut = true;
_fadeOutTimer = time;
_fadeOutDuration = time;
}
// Start a new track fading in.
int trackIndex = PickRandomTrack();
_currentTrackIndex = trackIndex;
_musicHandle = Sound.Play( MusicTracks[trackIndex] );
if ( _musicHandle.IsValid() )
{
_musicHandle.Volume = 0f;
_musicHandle.Pitch = _musicPitch;
}
_isFadingIn = true;
_fadeInTimer = time;
_fadeInDuration = time;
}
/// <summary>
/// Stop all music immediately.
/// </summary>
public static void StopMusic()
{
if ( _musicHandle.IsValid() )
_musicHandle.Stop();
if ( _fadingOutHandle.IsValid() )
_fadingOutHandle.Stop();
_isFadingIn = false;
_isFadingOut = false;
}
/// <summary>
/// Must be called once per frame (from the game entry point) to drive
/// music fades, pitch updates, and coin collect wobble.
/// </summary>
public static void Tick( float deltaTime )
{
// ── Coin collect pitch wobble ─────────────────────────────────
if ( _collectHandle.IsValid() && _collectHandle.IsPlaying )
{
_collectWobbleTimer -= deltaTime;
if ( _collectWobbleTimer <= 0f )
{
_collectWobbleTimer = WOBBLE_CHECK_INTERVAL;
if ( Game.Random.Float( 0f, 1f ) < WOBBLE_CHANCE )
{
float nudge = Game.Random.Float( WOBBLE_RANGE_LOW, WOBBLE_RANGE_HIGH );
_collectHandle.Pitch = _collectBasePitch + nudge;
}
}
}
// Apply current pitch and volume (with pause modifiers) to the active music.
float pitchMult = _gamePaused ? _pausePitchMult : 1f;
float volumeMult = _isMuted ? 0f : (_gamePaused ? PAUSE_VOLUME_MULT : 1f);
if ( _musicHandle.IsValid() && _musicHandle.IsPlaying )
_musicHandle.Pitch = _musicPitch * pitchMult;
// Fade in.
if ( _isFadingIn && _fadeInDuration > 0f )
{
_fadeInTimer -= deltaTime;
float t = 1f - Math.Clamp( _fadeInTimer / _fadeInDuration, 0f, 1f );
_currentMusicVolume = t * MAX_MUSIC_VOLUME;
if ( _musicHandle.IsValid() )
_musicHandle.Volume = _currentMusicVolume * volumeMult;
if ( _fadeInTimer <= 0f )
{
_isFadingIn = false;
_currentMusicVolume = MAX_MUSIC_VOLUME;
}
}
else if ( _musicHandle.IsValid() && _musicHandle.IsPlaying )
{
// Steady state — keep volume correct if pause state changed.
_musicHandle.Volume = _currentMusicVolume * volumeMult;
}
// Fade out the old track.
if ( _isFadingOut && _fadeOutDuration > 0f )
{
_fadeOutTimer -= deltaTime;
float t = Math.Clamp( _fadeOutTimer / _fadeOutDuration, 0f, 1f );
float volume = t * MAX_MUSIC_VOLUME;
if ( _fadingOutHandle.IsValid() )
_fadingOutHandle.Volume = volume;
if ( _fadeOutTimer <= 0f )
{
if ( _fadingOutHandle.IsValid() )
_fadingOutHandle.Stop();
_isFadingOut = false;
}
}
}
// ── Pause ────────────────────────────────────────────────────────────
public static void SetGamePaused( bool paused )
{
_gamePaused = paused;
}
// ── Toggle ───────────────────────────────────────────────────────────
public static void ToggleMute()
{
_isMuted = !_isMuted;
// Volume is applied every Tick — no need to touch the handle here.
}
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>
/// Pick a random track index different from the currently playing one.
/// </summary>
private static int PickRandomTrack()
{
if ( MusicTracks.Length <= 1 )
return 0;
int index;
do
{
index = Game.Random.Next( 0, MusicTracks.Length );
}
while ( index == _currentTrackIndex );
return index;
}
}