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;
	}
}