Audio/Audio.cs
using System.Collections.Generic;
using System.Runtime.CompilerServices;
namespace BlockParty;
/// <summary>SFX identifiers, ported verbatim from the original AudioManager.</summary>
public enum SfxType
{
PlayerWalk, PlayerWallClimb, PlayerCeilingClimb, PlayerLand, PlayerJump, PlayerSquish,
ImpostorJumpP1, ImpostorJumpP2, ImpostorWalkP1, ImpostorWalkP2,
BlockImpact, BlockSidePressed0, BlockSidePressed1,
Glitter0, Glitter1, BlockPhaseReach1, BlockPhaseReachMax, BlockExplosion,
FireballShoot, FireballBounce, FireballDissipate, FireballHitPlayer,
LaserTracer, Laser, LaserHitPlayer,
SirenPrepare, SirenSuck,
TearForm, TearSplash, TearHitPlayer,
TurnInvisible, TurnVisible, ImpostorPortalOpen, ImpostorPortalComplete,
SpikesSend, SpikesAdd, SpikesRetract,
StickyStick, StickyUnstuck, StickyBlocked,
EnterGame, LeaveGame, MenuBlip, MenuStart, LevelPreviewHover, Error,
AddScorePhase0, AddScoreFinishPhase0, AddScorePhase1, AddScoreFinishPhase1, AddScoreTimeBonus,
PlayerAirJump, BirdAirJump, SpringChargeStart, SpringChargeThreshold, SpringChargeLost, SpringChargeCancel, SwarmRepel, TwinSwitch,
SolarDeath, SpringDeath,
CoinPickup, CoinTally,
DashRecharge,
}
/// <summary>
/// Audio facade. Each <see cref="SfxType"/> maps to a <c>SoundEvent</c> (.sound) resource in
/// <c>Assets/sounds/</c> (generated by <c>tools/gen_sounds.py</c>); the SoundEvent carries the
/// variant list + pitch range, so playback is just <c>Sound.Play</c> — the engine
/// picks the random variant and pitch. Sounds are authored <c>UI = true</c> (2D / centred).
///
/// The looping song is decoded from its audio file and played through a <see cref="SoundHandle"/> on the
/// engine "Music" mixer (so the player's system music-volume setting applies). A SoundHandle is used
/// instead of <see cref="MusicPlayer"/> because its <see cref="SoundHandle.Pitch"/> can drive the
/// playback rate — used to slow the music to match the replay watch speed.
///
/// The positional PlaySfx overload places the handle at the game-space point, so any SoundEvent
/// re-authored with <c>UI = false</c> gets engine spatialization (distance falloff + pan from the
/// camera). Events left <c>UI = true</c> ignore the position and stay 2D/centred.
/// </summary>
public static class Audio
{
private static readonly Dictionary<SfxType, string> _events = new()
{
[SfxType.PlayerWalk] = "player_walk",
[SfxType.PlayerWallClimb] = "player_wall_climb",
[SfxType.PlayerCeilingClimb] = "player_ceiling_climb",
[SfxType.PlayerLand] = "player_land",
[SfxType.PlayerJump] = "player_jump",
[SfxType.ImpostorJumpP1] = "impostor_jump_p1",
[SfxType.ImpostorJumpP2] = "impostor_jump_p2",
[SfxType.ImpostorWalkP1] = "impostor_walk_p1",
[SfxType.ImpostorWalkP2] = "impostor_walk_p2",
[SfxType.PlayerAirJump] = "player_air_jump",
[SfxType.BirdAirJump] = "bird_air_jump",
[SfxType.SpringChargeStart] = "spring_charge_start",
[SfxType.SpringChargeThreshold] = "spring_charge_threshold",
[SfxType.SpringChargeLost] = "spring_charge_lost",
[SfxType.SpringChargeCancel] = "spring_charge_cancel",
[SfxType.SwarmRepel] = "swarm_repel",
[SfxType.TwinSwitch] = "twin_switch",
[SfxType.PlayerSquish] = "player_squish",
[SfxType.SolarDeath] = "solar_death",
[SfxType.SpringDeath] = "spring_death",
[SfxType.BlockImpact] = "block_impact",
[SfxType.BlockSidePressed0] = "block_side_pressed_0",
[SfxType.BlockSidePressed1] = "block_side_pressed_1",
[SfxType.Glitter0] = "glitter_0",
[SfxType.Glitter1] = "glitter_1",
[SfxType.BlockPhaseReach1] = "block_phase_1",
[SfxType.BlockPhaseReachMax] = "block_phase_max",
[SfxType.BlockExplosion] = "block_explosion",
[SfxType.FireballShoot] = "fireball_shoot",
[SfxType.FireballBounce] = "fireball_bounce",
[SfxType.FireballDissipate] = "fireball_dissipate",
[SfxType.FireballHitPlayer] = "fireball_hit_player",
[SfxType.LaserTracer] = "laser_tracer",
[SfxType.Laser] = "laser",
[SfxType.LaserHitPlayer] = "laser_hit_player",
[SfxType.SirenPrepare] = "siren_prepare",
[SfxType.SirenSuck] = "siren_suck",
[SfxType.TearForm] = "tear_form",
[SfxType.TearSplash] = "tear_splash",
[SfxType.TearHitPlayer] = "tear_hit_player",
[SfxType.TurnInvisible] = "turn_invisible",
[SfxType.TurnVisible] = "turn_visible",
[SfxType.ImpostorPortalOpen] = "impostor_portal_open",
[SfxType.ImpostorPortalComplete] = "impostor_portal_complete",
[SfxType.SpikesSend] = "spikes_send",
[SfxType.SpikesAdd] = "spikes_add",
[SfxType.SpikesRetract] = "spikes_retract",
[SfxType.StickyStick] = "sticky_stick",
[SfxType.StickyUnstuck] = "sticky_unstuck",
[SfxType.StickyBlocked] = "sticky_blocked",
[SfxType.EnterGame] = "enter_game",
[SfxType.LeaveGame] = "leave_game",
[SfxType.MenuBlip] = "menu_blip",
[SfxType.MenuStart] = "menu_start",
[SfxType.LevelPreviewHover] = "level_preview_hover",
[SfxType.Error] = "error",
[SfxType.AddScorePhase0] = "add_score_0",
[SfxType.AddScorePhase1] = "add_score_1",
[SfxType.AddScoreFinishPhase0] = "add_score_finish_0",
[SfxType.AddScoreFinishPhase1] = "add_score_finish_1",
[SfxType.AddScoreTimeBonus] = "add_time_bonus",
[SfxType.CoinPickup] = "coin_pickup",
[SfxType.CoinTally] = "coin_tally",
[SfxType.DashRecharge] = "dash_recharge",
};
private static readonly List<SoundHandle> _gameSfx = new();
private static readonly List<SoundHandle> _loopingSfx = new();
private static SoundFile _swapperHoverToneFile;
private const int SwapperHoverToneRate = 48000;
private const int SwapperHoverToneFrequency = 120;
private const int SwapperHoverWarbleFrequency = 4;
private const int SwapperHoverToneSampleCount = SwapperHoverToneRate / 2;
public const string DefaultMusic = "default";
private const string MusicDirectory = "music";
// The looping song plays through a SoundHandle (not MusicPlayer) so we can drive its playback
// rate: MusicPlayer exposes no pitch/rate, but SoundHandle.Pitch resamples (pitch AND tempo),
// giving the tape/vinyl slow-down used to match the replay watch speed. Decoded tracks are cached
// by path and reused when revisiting a level.
private static readonly Dictionary<string, SoundFile> _musicFiles = new();
private static string _musicPath;
private static SoundHandle _music;
// Per-level volume trim (see LevelDef.MusicVolume), so a hotter/quieter track can be balanced
// against the rest of the mix without re-mastering it. Folded in before the player's settings.
private static float _musicBase = 1f;
// Whether the game is currently paused (music ducked). Tracked so volume-setting changes
// re-apply on top of the correct base.
private static bool _musicPaused;
// While the escape menu is up we signal "paused" mostly via a pitch drop (below) and only a gentle
// volume duck — the music should stay clearly audible, just slowed.
private const float PausedMusicVolumeMult = 0.7f;
// Pitch the music drops to while the escape menu is up (a subtle tape slow-down). Composes on top
// of the base rate via EffectiveMusicPitch, so it works whether paused mid-run or mid-replay.
private const float PausedMusicPitchMult = 0.8f;
// Replay playback-rate band. SoundHandle.Pitch resamples, so it shifts pitch AND tempo together.
// The replay speed (0.1x..8x) is MAPPED onto this band by the caller (see GameManager); we clamp
// here only as a safety net so music never hits an extreme/engine-clamped pitch.
public const float MusicPitchMin = 0.5f;
public const float MusicPitchMax = 2f;
private static float _musicPitch = 1f;
// Run-progression pitch: the music starts at normal pitch and rises as the run advances — every time
// a block phases up (all four sides pressed in) the run gets one step closer to Target, so the song
// rises in tension across the run. Composes multiplicatively on top of the replay-speed / pause pitch
// via EffectiveMusicPitch. Reset to Start at the top of a run / on menu screens.
//
// Progress is PER LEVEL, not a fixed per-press step: the stage counts how many phase-ups the level
// actually has left after spawning (blocks can be authored partway through their phases) and calls
// ScaleMusicProgression, so a 1-press level and a 16-phase-up monster both end at Target. DefaultTotal
// only covers levels that never report a count (dev/debug spawn paths); Max is a safety clamp.
//
// The climb EASES IN via t^Ease: early presses barely lift the pitch and the last few carry most of
// it, so the run's tension ramps up toward the end instead of rising flatly from the first press.
private const float MusicProgressionStart = 1f;
private const float MusicProgressionTarget = 1.2f;
private const float MusicProgressionMax = 1.25f;
private const float MusicProgressionEase = 2.2f;
private const int MusicProgressionDefaultTotal = 10; // a standard 5-block level: 5 x 2 phase-ups
private static float _musicProgression = MusicProgressionStart;
private static int _musicProgressionTotal = MusicProgressionDefaultTotal;
private static int _musicProgressionDone;
// --- player volume settings (0..1 factors, pushed in from the options menu) -------------
// Defaults mirror GameSettings so audio is sane even before settings are loaded/applied.
private static float _masterVol = 0.8f;
private static float _sfxVol = 1f;
private static float _musicVol = 0.7f;
/// <summary>Push all three volume factors (0..1) at once and re-apply to live audio.</summary>
public static void SetVolumes( float master, float sfx, float music )
{
_masterVol = Math.Clamp( master, 0f, 1f );
_sfxVol = Math.Clamp( sfx, 0f, 1f );
_musicVol = Math.Clamp( music, 0f, 1f );
ApplyMusicVolume();
}
public static void SetMasterVolume( float v ) { _masterVol = Math.Clamp( v, 0f, 1f ); ApplyMusicVolume(); }
public static void SetSfxVolume( float v ) { _sfxVol = Math.Clamp( v, 0f, 1f ); }
public static void SetMusicVolume( float v ) { _musicVol = Math.Clamp( v, 0f, 1f ); ApplyMusicVolume(); }
// Global gain applied on top of the master slider, so its 0..100 maps to 0..this instead of 0..1.
// This is the one knob for "louder overall"; it never touches anyone's saved settings value.
public const float MasterVolumeScale = 2f;
// Global music gain, applied on top of the player's slider: the songs sit quieter in the mix than
// the sfx, so every music setting reads louder without touching the settings values. Tune here.
public const float MusicVolumeScale = 1.5f;
// Global fade-in multiplier (0..1), independent of which song is playing: a screen can fade the
// music up on entry and keep fading through song switches (the map swapping songs per node).
private static float _musicFade = 1f;
private static float _musicFadeRate;
/// <summary>Start (or restart) a global music fade-in from silence over <paramref name="seconds"/>.
/// Survives song changes — drive it with <see cref="TickMusicFade"/>.</summary>
public static void FadeInMusic( float seconds = 1f )
{
_musicFade = 0f;
_musicFadeRate = 1f / MathF.Max( seconds, 0.01f );
ApplyMusicVolume();
}
/// <summary>Cancel any fade and restore full music volume (call when leaving the fading screen).</summary>
public static void ClearMusicFade()
{
_musicFade = 1f;
_musicFadeRate = 0f;
ApplyMusicVolume();
}
/// <summary>Advance an in-progress fade-in. No-op once it reaches full volume.</summary>
public static void TickMusicFade( float dt )
{
if ( _musicFadeRate <= 0f ) return;
_musicFade = MathF.Min( 1f, _musicFade + _musicFadeRate * dt );
if ( _musicFade >= 1f ) _musicFadeRate = 0f;
ApplyMusicVolume();
}
// Effective music volume = level trim * master (x global master gain) * music slider * global music
// gain * fade-in * (pause dim).
private static float EffectiveMusicVolume()
=> _musicBase * _masterVol * MasterVolumeScale * _musicVol * MusicVolumeScale * _musicFade * (_musicPaused ? PausedMusicVolumeMult : 1f);
private static void ApplyMusicVolume()
{
if ( _music is not null && _music.IsValid )
_music.Volume = EffectiveMusicVolume();
}
// Effective music pitch = the base rate (1, or the replay-mapped rate) times the run-progression
// pitch, dropped a touch while the escape menu is up. Clamped as a safety net against an
// extreme/engine-clamped pitch.
private static float EffectiveMusicPitch()
=> Math.Clamp( _musicPitch * _musicProgression * (_musicPaused ? PausedMusicPitchMult : 1f), 0.25f, MusicPitchMax );
private static void ApplyMusicPitch()
{
if ( _music is not null && _music.IsValid )
_music.Pitch = EffectiveMusicPitch();
}
/// <summary>While true, <see cref="PlaySfx(SfxType, float, float)"/> is a no-op. Set around a replay scrub's silent re-sim
/// (a click-jump or a backward seek) so the hundreds of SFX those steps would fire don't all play
/// at once. Restored by the caller; a small forward drag deliberately leaves it false so scrubbing
/// keeps its per-step audio.</summary>
public static bool SuppressSfx { get; set; }
/// <summary>Extra multiplier applied to every <see cref="PlaySfx(SfxType, float, float)"/> volume
/// (1 = normal). Used to duck the per-step SFX a forward scrub fires so they sit under live play.
/// Restored by the caller.</summary>
public static float SfxVolumeScale { get; set; } = 1f;
// Several same-input bodies (Swarm copies, impostors) fire identical one-shot movement sounds
// within a frame or two of each other; stacked they read as one LOUD blast rather than "the group
// moved". These types coalesce: after one plays, re-triggers inside its window are dropped (first
// caller wins). ONLY discrete event sounds belong here, with windows no solo re-trigger can beat
// (a jump→walljump chain needs a release + re-press + wall traversal; re-landing needs an arc).
// Continuous distance-driven sounds (footsteps) must NOT be rate-limited here — a fast
// character's solo cadence (Solar walks a footstep every ~0.08s) outruns any window wide enough
// to coalesce a group. Walking is handled by a swarm-group-scoped gate at its call site instead
// (Player.TryPlayGroupFootstep): any group body may carry the track, the group shares one
// cadence, and non-grouped characters bypass the gate entirely. Impostor footsteps are the one
// exception: they have their own events (never shared with a fast solo character), so a summoned
// crowd's interleaved steps coalesce here alongside its jumps.
private static readonly Dictionary<SfxType, float> _coalesceWindow = new()
{
[SfxType.PlayerJump] = 0.05f,
[SfxType.ImpostorJumpP1] = 0.05f,
[SfxType.ImpostorJumpP2] = 0.05f,
[SfxType.ImpostorWalkP1] = 0.05f,
[SfxType.ImpostorWalkP2] = 0.05f,
[SfxType.ImpostorPortalOpen] = 0.05f,
[SfxType.ImpostorPortalComplete] = 0.05f,
[SfxType.PlayerAirJump] = 0.05f,
[SfxType.BirdAirJump] = 0.05f,
[SfxType.PlayerLand] = 0.09f,
[SfxType.StickyStick] = 0.09f,
[SfxType.StickyUnstuck] = 0.09f,
[SfxType.StickyBlocked] = 0.09f,
[SfxType.SwarmRepel] = 0.05f,
[SfxType.TearForm] = 0.03f,
[SfxType.TearSplash] = 0.03f,
[SfxType.CoinPickup] = 0.06f,
};
private static readonly Dictionary<SfxType, float> _lastCoalescedPlayTime = new();
public static void PlaySfx( SfxType type, float volume = 1f, float pitch = 1f, float pitchScale = 1f,
[CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
=> PlaySfxInternal( type, null, volume, pitch, pitchScale, caller, callerLine );
// SFX TRACE: toggle with `sfx_trace`, then play. Every PlaySfx call logs its event name, volume,
// pitch args, position and CALL SITE (caller member + line), including calls that were dropped
// (coalesce window / SuppressSfx), so a "what is that repeating sound" can be attributed.
static bool _sfxTrace;
[ConCmd( "sfx_trace" )]
static void ToggleSfxTrace()
{
if ( !Game.IsEditor ) return;
_sfxTrace = !_sfxTrace;
Log.Info( $"[sfx] trace {(_sfxTrace ? "ON" : "OFF")}" );
}
static void TraceSfx( SfxType type, string name, Vector2? pos, float volume, float pitch, float pitchScale, string caller, int callerLine, string dropped )
{
if ( !_sfxTrace ) return;
string at = pos is Vector2 p ? $" @({p.x:0},{p.y:0})" : "";
string drop = dropped is null ? "" : $" DROPPED({dropped})";
Log.Info( $"[sfx {RealTime.Now:0.000}] {type} ({name}) vol={volume:0.00} pitch={pitch:0.00} x{pitchScale:0.00}{at} <- {caller}:{callerLine}{drop}" );
}
private static float EffectiveSfxVolume( float volume )
=> volume * _masterVol * MasterVolumeScale * _sfxVol * SfxVolumeScale;
private static void PlaySfxInternal( SfxType type, Vector2? pos, float volume, float pitch, float pitchScale, string caller, int callerLine )
{
if ( !_events.TryGetValue( type, out var name ) ) return;
if ( SuppressSfx )
{
TraceSfx( type, name, pos, volume, pitch, pitchScale, caller, callerLine, "suppressed" );
return;
}
if ( _coalesceWindow.TryGetValue( type, out float window ) )
{
if ( _lastCoalescedPlayTime.TryGetValue( type, out float last ) && RealTime.Now - last < window )
{
TraceSfx( type, name, pos, volume, pitch, pitchScale, caller, callerLine, "coalesced" );
return;
}
_lastCoalescedPlayTime[type] = RealTime.Now;
}
TraceSfx( type, name, pos, volume, pitch, pitchScale, caller, callerLine, null );
// Game X/Y map straight to world X/Y (see Entity2D.UpdateWorldPosition); play AT the point so a
// non-UI SoundEvent's authored spatialization (distance falloff, pan) works. Note the listener is
// the camera, which sits ~2000 units out on Z (GameStage.UpdateCameraPosition) — that Z gap
// dominates the listener distance, so falloff curves need their action well inside x = 2000/Distance.
// UI events ignore the position entirely (2D / centred), so passing it is always safe.
var h = pos is Vector2 p
? Sound.Play( $"sounds/{name}.sound", new Vector3( p.x, p.y, 0f ) )
: Sound.Play( $"sounds/{name}.sound" );
if ( h is null ) return;
// Fold the SoundEvent's authored Volume IN rather than replacing it: Sound.Play has just
// assigned the rolled event volume to the handle, so read it back and scale. Keeps the .sound's
// volume a live design knob, exactly like its pitch range below.
h.Volume *= EffectiveSfxVolume( volume );
// Only override the SoundEvent's authored pitch range when the caller asks for it, so the
// per-variant random pitch baked into the .sound is preserved for normal playback.
if ( pitch != 1f ) h.Pitch = pitch;
if ( pitchScale != 1f ) h.Pitch *= pitchScale;
if ( IsGameSfx( type ) )
{
for ( int i = _gameSfx.Count - 1; i >= 0; i-- )
if ( _gameSfx[i] is null || !_gameSfx[i].IsValid )
_gameSfx.RemoveAt( i );
_gameSfx.Add( h );
}
}
private static bool IsGameSfx( SfxType type ) => type switch
{
SfxType.EnterGame or SfxType.LeaveGame or SfxType.MenuBlip or SfxType.MenuStart or SfxType.Error => false,
SfxType.AddScorePhase0 or SfxType.AddScoreFinishPhase0 or SfxType.AddScorePhase1 or
SfxType.AddScoreFinishPhase1 or SfxType.AddScoreTimeBonus or SfxType.CoinTally => false,
_ => true,
};
/// <summary>Stop sounds owned by the current run while preserving menu, transition, and tally cues.</summary>
public static void StopGameSfx()
{
foreach ( var handle in _gameSfx )
if ( handle is not null && handle.IsValid )
handle.Stop();
_gameSfx.Clear();
_loopingSfx.Clear();
}
/// <summary>Positional overload — the handle is placed at the game-space point, so a SoundEvent
/// authored with <c>UI = false</c> gets real spatialization (distance falloff + pan). UI events
/// still play 2D/centred exactly as before.</summary>
/// <param name="pitch">Absolute pitch override; 1 keeps the .sound's authored random range.</param>
/// <param name="pitchScale">Multiplier applied ON TOP of the rolled (or overridden) pitch, so a
/// per-play bend keeps the authored variation underneath it.</param>
public static void PlaySfx( SfxType type, Vector2 pos, float volume = 1f, float pitch = 1f, float pitchScale = 1f,
[CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
=> PlaySfxInternal( type, pos, volume, pitch, pitchScale, caller, callerLine );
/// <summary>Start Swapper's seamless low hover oscillator on the game SFX mixer (Mimic re-pitches
/// the same loop for its pre-swap rising tone).</summary>
public static SoundHandle PlaySwapperHoverTone( float volume )
{
if ( SuppressSfx ) return null;
_swapperHoverToneFile ??= CreateSwapperHoverTone();
if ( _swapperHoverToneFile is null || !_swapperHoverToneFile.IsValidForPlayback ) return null;
SoundHandle handle = Sound.PlayFile( _swapperHoverToneFile, EffectiveSfxVolume( volume ) );
if ( handle is null ) return null;
handle.TargetMixer = Sandbox.Audio.Mixer.FindMixerByName( "game" );
handle.ListenLocal = true;
handle.SpacialBlend = 0f;
handle.DistanceAttenuation = false;
for ( int i = _gameSfx.Count - 1; i >= 0; i-- )
if ( _gameSfx[i] is null || !_gameSfx[i].IsValid )
_gameSfx.RemoveAt( i );
_gameSfx.Add( handle );
_loopingSfx.Add( handle );
return handle;
}
public static void SetLoopingSfxProperties( SoundHandle handle, float volume, float pitch )
{
if ( handle is null || !handle.IsValid ) return;
handle.Volume = SuppressSfx ? 0f : EffectiveSfxVolume( volume );
handle.Pitch = pitch;
}
public static void StopLoopingSfx( SoundHandle handle )
{
if ( handle is null ) return;
if ( handle.IsValid )
{
handle.Volume = 0f;
handle.Stop();
}
_gameSfx.Remove( handle );
_loopingSfx.Remove( handle );
}
public static void SetLoopingSfxPaused( bool paused )
{
for ( int i = _loopingSfx.Count - 1; i >= 0; i-- )
{
SoundHandle handle = _loopingSfx[i];
if ( handle is null || !handle.IsValid )
{
_loopingSfx.RemoveAt( i );
continue;
}
handle.Paused = paused;
}
}
private static SoundFile CreateSwapperHoverTone()
{
const int channels = 2;
byte[] pcm = new byte[SwapperHoverToneSampleCount * channels * sizeof( short )];
// Every oscillator and modulator completes an integer number of cycles in this half-second
// buffer, so the richer stereo motion still wraps without a click.
for ( int i = 0; i < SwapperHoverToneSampleCount; i++ )
{
float time = i / (float)SwapperHoverToneRate;
float warblePhase = MathF.Tau * SwapperHoverWarbleFrequency * time;
float warble = MathF.Sin( warblePhase );
float sweep = MathF.Sin( MathF.Tau * 2f * time );
float carrierPhase = MathF.Tau * SwapperHoverToneFrequency * time + warble * 1.6f + sweep * 0.35f;
float energy = 0.78f + MathF.Sin( warblePhase - MathF.PI * 0.5f ) * 0.12f;
float center = MathF.Sin( carrierPhase ) * 0.42f;
float sub = MathF.Sin( MathF.Tau * 60f * time + warble * 0.3f ) * 0.13f;
float shimmerAmount = 0.06f + (sweep + 1f) * 0.025f;
float left = (center + MathF.Sin( MathF.Tau * 116f * time - warble * 0.75f ) * 0.24f) * energy
+ sub + MathF.Sin( MathF.Tau * 356f * time + warble * 1.1f ) * shimmerAmount;
float right = (center + MathF.Sin( MathF.Tau * 124f * time + warble * 0.75f ) * 0.24f) * energy
+ sub + MathF.Sin( MathF.Tau * 364f * time - warble * 0.9f ) * shimmerAmount;
left /= 1f + MathF.Abs( left ) * 0.3f;
right /= 1f + MathF.Abs( right ) * 0.3f;
short leftSample = (short)MathF.Round( Math.Clamp( left, -1f, 1f ) * short.MaxValue );
short rightSample = (short)MathF.Round( Math.Clamp( right, -1f, 1f ) * short.MaxValue );
int offset = i * channels * sizeof( short );
pcm[offset] = (byte)leftSample;
pcm[offset + 1] = (byte)(leftSample >> 8);
pcm[offset + 2] = (byte)rightSample;
pcm[offset + 3] = (byte)(rightSample >> 8);
}
return SoundFile.FromPcm( "swapper_hover_tone", pcm, new SoundFile.PcmOptions
{
Channels = channels,
Rate = SwapperHoverToneRate,
Bits = 16,
Loop = true,
LoopStart = 0,
LoopEnd = 0,
} );
}
/// <summary>Play + loop a file from <c>Assets/music</c> through a SoundHandle on the "Music"
/// mixer. A blank filename uses <see cref="DefaultMusic"/>; a name without an extension resolves
/// to OGG. <paramref name="volume"/> is the track's mix trim, not the player's setting.</summary>
public static void PlayMusic( float volume = 1f, string filename = null )
{
string path = MusicPath( filename );
_musicBase = volume;
if ( path == _musicPath && _music is not null && _music.IsValid )
{
ApplyMusicVolume();
return;
}
StopMusic();
_musicBase = volume;
if ( !_musicFiles.TryGetValue( path, out var file ) )
{
file = LoadMusic( path );
if ( file is null ) return;
_musicFiles[path] = file;
}
_musicPath = path;
_music = Sound.PlayFile( file, EffectiveMusicVolume(), EffectiveMusicPitch() );
if ( _music is null ) return;
// Route to the engine "Music" mixer so the player's s&box music-volume system setting applies
// on top of our in-game music slider. Play it 2D / full-volume (listen-local, no spatial blend
// or distance falloff) so it isn't attenuated against the camera-listener far above the arena.
_music.TargetMixer = Sandbox.Audio.Mixer.FindMixerByName( "Music" );
_music.ListenLocal = true;
_music.SpacialBlend = 0f;
_music.DistanceAttenuation = false;
}
/// <summary>Switch to a level's authored song, at that level's volume trim.</summary>
public static void PlayLevelMusic( LevelDef level ) => PlayMusic( level?.MusicVolume ?? 1f, level?.Music );
/// <summary>True when a song name resolves to a real file under <c>Assets/music</c> (used by the
/// editor's Music field to tint a name that actually plays).</summary>
public static bool MusicExists( string filename ) => FileSystem.Mounted.FileExists( MusicPath( filename ) );
/// <summary>The resolved <c>music/…</c> path for a song name: two names resolving to the same path
/// are the same track (with/without the .ogg extension, blank = the default song).</summary>
public static string ResolveMusicPath( string filename ) => MusicPath( filename );
/// <summary>Every playable song under <c>Assets/music</c>, as bare names (no extension) the editor's
/// Music field accepts — sorted, and .ogg preferred over a same-named .mp3 (both resolve to the same
/// entry). Used by the editor's randomize button.</summary>
public static List<string> AllMusicNames()
{
var names = new List<string>();
var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
foreach ( var pattern in new[] { "*.ogg", "*.mp3" } )
{
foreach ( var file in FileSystem.Mounted.FindFile( MusicDirectory, pattern ) )
{
string name = System.IO.Path.GetFileNameWithoutExtension( file );
if ( string.IsNullOrWhiteSpace( name ) || !seen.Add( name ) ) continue;
names.Add( name );
}
}
names.Sort( StringComparer.OrdinalIgnoreCase );
return names;
}
/// <summary>The songs a randomizer may pick — every playable song except <see cref="DefaultMusic"/>,
/// which is reserved as the menus' fallback track and never assigned to a level.</summary>
public static List<string> RandomizableMusicNames()
{
var names = AllMusicNames();
names.RemoveAll( n => string.Equals( n, DefaultMusic, StringComparison.OrdinalIgnoreCase ) );
return names;
}
private static string MusicPath( string filename )
{
filename = string.IsNullOrWhiteSpace( filename ) ? DefaultMusic : filename.Trim();
int lastSeparator = Math.Max( filename.LastIndexOf( '/' ), filename.LastIndexOf( '\\' ) );
bool hasExtension = filename.LastIndexOf( '.' ) > lastSeparator;
return hasExtension ? $"{MusicDirectory}/{filename}" : $"{MusicDirectory}/{filename}.ogg";
}
// Read + decode a supported song into a looping SoundFile. Returns null if the file is missing or
// uses an unsupported format. NOTE: when the asset has been compiled to a .vsnd the engine returns
// that cached resource and IGNORES these options — looping then comes from the file's .meta.
private static SoundFile LoadMusic( string path )
{
if ( !FileSystem.Mounted.FileExists( path ) ) return null;
var bytes = FileSystem.Mounted.ReadAllBytes( path );
var options = new SoundFile.LoadOptions { Loop = true };
if ( path.EndsWith( ".mp3", StringComparison.OrdinalIgnoreCase ) )
return SoundFile.FromMp3( path, bytes, options );
if ( path.EndsWith( ".ogg", StringComparison.OrdinalIgnoreCase ) )
return SoundFile.FromOgg( path, bytes, options );
return null;
}
/// <summary>Pause feedback for the looping music: a gentle volume duck plus a pitch drop.</summary>
public static void SetMusicPaused( bool paused )
{
_musicPaused = paused;
SetLoopingSfxPaused( paused );
ApplyMusicVolume();
ApplyMusicPitch();
}
/// <summary>
/// Set the music playback rate, 1 = normal. SoundHandle.Pitch resamples, so this shifts pitch AND
/// tempo together (a tape/vinyl slow-down). Clamped to a musical band. Callers map the replay watch
/// speed onto this band before calling.
/// </summary>
public static void SetMusicPitch( float pitch )
{
_musicPitch = Math.Clamp( pitch, MusicPitchMin, MusicPitchMax );
ApplyMusicPitch();
}
/// <summary>Reset the run progression back to the start (no phase-ups done, phase-up total back to
/// its default). Called at the top of a run and on the menu screens so the song always begins at
/// normal pitch; a GameStage then reports its phase-up count via
/// <see cref="ScaleMusicProgression"/>.</summary>
public static void ResetMusicProgression()
{
_musicProgressionDone = 0;
_musicProgressionTotal = MusicProgressionDefaultTotal;
ApplyMusicProgression();
}
/// <summary>Tell the music how many phase-ups this run has to give, so the eased climb lands on the
/// target end pitch no matter how many blocks the level has or how far along they spawn. Called once
/// by the stage after its blocks exist; a non-positive count (nothing left to press) keeps the
/// default.</summary>
public static void ScaleMusicProgression( int totalPhaseUps )
{
if ( totalPhaseUps <= 0 ) return;
_musicProgressionTotal = totalPhaseUps;
ApplyMusicProgression();
}
/// <summary>Count one phase-up (called when a block advances a phase) and re-derive the pitch, so the
/// music rises in tension across the run.</summary>
public static void AdvanceMusicProgression()
{
_musicProgressionDone++;
ApplyMusicProgression();
}
// Re-derive the progression pitch from how far through the run's phase-ups we are, eased so the rise
// is gentle early and steep near the end. Clamped to a safety max.
private static void ApplyMusicProgression()
{
float t = Math.Clamp( _musicProgressionDone / (float)Math.Max( 1, _musicProgressionTotal ), 0f, 1f );
float eased = MathF.Pow( t, MusicProgressionEase );
_musicProgression = Math.Min( MusicProgressionStart + (MusicProgressionTarget - MusicProgressionStart) * eased,
MusicProgressionMax );
ApplyMusicPitch();
}
/// <summary>Pause/resume the looping music outright (used while a replay is paused).</summary>
public static void SetMusicPlaying( bool playing )
{
if ( _music is not null && _music.IsValid )
_music.Paused = !playing;
SetLoopingSfxPaused( !playing );
}
public static void StopMusic()
{
_music?.Stop();
_music = null;
_musicPath = null;
_musicPaused = false;
_musicPitch = 1f;
_musicProgression = MusicProgressionStart;
_musicProgressionDone = 0;
_musicProgressionTotal = MusicProgressionDefaultTotal;
}
}