Static audio manager for the soccer game. Builds SoundFile objects from packaged ogg assets and generated PCM, plays 2D and positional effects (kicks, whistle, goals, coach calls), and continually mixes adaptive crowd layers (ambience, chatter, tension, chants, goal tail, boos) from danger and reaction values.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Socer;
// Crowd presentation is mixed from independent recordings. Its clocks never affect match simulation.
public static class GameAudio
{
sealed class CrowdLayer
{
public readonly string Path;
public SoundFile File;
public SoundHandle Handle;
public float Level;
public CrowdLayer(string path) { Path=path; }
}
static SoundFile tap, goal, pass, shot, whistle, cheer, boo, chant, sustain;
static readonly CrowdLayer ambience=new("sounds/stadium_ambient.ogg");
static readonly CrowdLayer chatter=new("sounds/stadium_chatter.ogg");
static readonly CrowdLayer tension=new("sounds/stadium_tension.ogg");
static SoundHandle lastEffectHandle, chantHandle, sustainHandle, booHandle;
static float lastGoalAt=float.NegativeInfinity, lastCheerAt=float.NegativeInfinity;
static float goalAge=100, booAge=100, booDuration, chantAge=100, nextChantAt=-1;
static float shotEnergy, cheerEnergy, sustainLevel, booLevel, chantLevel;
static readonly HashSet<string> failedPaths=new();
static readonly Dictionary<string,SoundFile> coachVoices=new();
static SoundHandle coachVoice;
static float coachVoiceUntil;
static string pendingCoachClip,lastCoachClip;
static float pendingCoachAt;
public static int CoachCallsPlayed {get;private set;}
public static void CoachCall(string kind,int team)
{
if(UserGain<=0 || Time.Now<coachVoiceUntil)return;
// Only explicit instructions are voiced; goal/miss reactions are gestures.
string clip=kind switch {"cover"=>"hurry","hold"=>"hold","go"=>"gogogo",_=>null};
if(clip==null || team!=0)return;
pendingCoachClip=clip;pendingCoachAt=Time.Now;coachVoiceUntil=Time.Now+1.65f;
}
public static void UpdateCoachVoice(Scene stadium,Vector3 source,bool audible)
{
if(!audible || UserGain<=0){StopCoachVoice();return;}
if(Playing(coachVoice)){coachVoice.Position=source;coachVoice.Volume=.20f*UserGain;}
string clip=pendingCoachClip;pendingCoachClip=null;
if(clip==null || Time.Now-pendingCoachAt>.5f)return;
coachVoices.TryGetValue(clip,out var file);
var loaded=Load(ref file,"sounds/coach_"+clip+".ogg");coachVoices[clip]=file;
if(loaded==null)return;
Stop(ref coachVoice);
// The stadium is a ScenePanel scene. Play in that scene so its camera
// supplies the listener, instead of the unrelated outer UI scene.
using(stadium.Push())coachVoice=Sound.PlayFile(loaded,.20f*UserGain);
coachVoice.Position=source;coachVoice.SpacialBlend=1;coachVoice.DistanceAttenuation=true;
coachVoice.Distance=65*39.37f;coachVoice.Pitch=.98f;
coachVoice.OcclusionEnabled=false;coachVoice.ReverbEnabled=false;coachVoice.AirAbsorption=true;
lastCoachClip=clip;CoachCallsPlayed++;
}
public static void StopCoachVoice(){Stop(ref coachVoice);pendingCoachClip=null;coachVoiceUntil=0;}
public static object CoachVoiceReport()=>new {CoachCallsPlayed,Loaded=coachVoices.Count,Playing=Playing(coachVoice),
Spatial=Playing(coachVoice)?coachVoice.SpacialBlend:-1,Volume=Playing(coachVoice)?coachVoice.Volume:0,
DistanceAttenuation=Playing(coachVoice) && coachVoice.DistanceAttenuation,RangeMetres=Playing(coachVoice)?coachVoice.Distance/39.37f:0,
Source=Playing(coachVoice)?new {X=coachVoice.Position.x/39.37f,Y=-coachVoice.Position.y/39.37f,Z=coachVoice.Position.z/39.37f}:null,
Pending=pendingCoachClip,LastClip=lastCoachClip,
Files=coachVoices.Keys.ToArray()};
public static int EngineMasterPercent=>(int)Math.Round(Sound.MasterVolume*100);
static float UserGain=>GamePreferences.SoundVolume/100f;
static bool Ready(SoundFile file)
{
try { return file!=null && file.IsValid && file.IsValidForPlayback; }
catch { return false; } // Editor hotload can leave a stale native sound resource.
}
static bool Playing(SoundHandle handle)=>handle!=null && handle.IsValid && handle.IsPlaying;
static SoundFile Load(ref SoundFile file,string path)
{
bool valid=false;
try{valid=file!=null && file.IsValid;}
catch{} // Hotload can invalidate the native resource while retaining its C# wrapper.
if(!valid)
{
try
{
var bytes=FileSystem.Mounted.ReadAllBytes(path);
bool loop=path.Contains("stadium_ambient") || path.Contains("stadium_chatter") || path.Contains("stadium_tension");
file=SoundFile.FromOgg("socer-runtime-"+path.Replace('/','-')+"-v3",bytes,
new SoundFile.LoadOptions{Loop=loop});
file.Preload();
failedPaths.Remove(path);
}
catch(Exception error)
{
if(failedPaths.Add(path))Log.Warning($"Socer sound {path}: {error.Message}");
return null;
}
}
return Ready(file)?file:null;
}
public static void Prepare()
{
Load(ref goal,"sounds/stadium_goal.ogg");
Load(ref pass,"sounds/ball_pass.ogg");
Load(ref shot,"sounds/ball_shot.ogg");
Load(ref whistle,"sounds/referee_whistle.ogg");
Load(ref ambience.File,ambience.Path);
Load(ref chatter.File,chatter.Path);
Load(ref tension.File,tension.Path);
Load(ref cheer,"sounds/stadium_cheer.ogg");
Load(ref boo,"sounds/stadium_boo.ogg");
Load(ref chant,"sounds/stadium_chant.ogg");
Load(ref sustain,"sounds/stadium_sustain.ogg");
}
public static object AssetReport()=>new {Goal=Ready(goal),Pass=Ready(pass),Shot=Ready(shot),Whistle=Ready(whistle),
Crowd=Ready(ambience.File),Chatter=Ready(chatter.File),Tension=Ready(tension.File),
Cheer=Ready(cheer),Boo=Ready(boo),Chant=Ready(chant),Sustain=Ready(sustain)};
static SoundHandle Play2D(SoundFile file,float volume,float pitch=1)
{
if(file==null || volume<=0)return null;
var handle=Sound.PlayFile(file,volume);
handle.SpacialBlend=0;
handle.DistanceAttenuation=false;
handle.Pitch=pitch;
return handle;
}
static void Play(ref SoundFile file,string path,float volume,float pitch=1)
{
if(UserGain<=0)return;
lastEffectHandle=Play2D(Load(ref file,path),volume*UserGain,pitch);
}
static void Stop(ref SoundHandle handle)
{
if(handle!=null && handle.IsValid)handle.Stop();
handle=null;
}
static float Approach(float current,float target,float delta,float attack,float release)
{
float seconds=target>current?attack:release;
return current+(target-current)*(1-MathF.Exp(-delta/seconds));
}
static void MixLoop(CrowdLayer layer,float target,float delta,float attack,float release)
{
layer.Level=Approach(layer.Level,target,delta,attack,release);
if(!Playing(layer.Handle) && target>.002f)
{
var file=Load(ref layer.File,layer.Path);
layer.Handle=Play2D(file,Math.Max(.0001f,layer.Level));
}
if(Playing(layer.Handle))
{
layer.Handle.Volume=layer.Level;
if(target<=0 && layer.Level<.0005f)Stop(ref layer.Handle);
}
}
static void MixEvent(ref SoundHandle handle,ref float level,float target,float delta,float attack,float release)
{
level=Approach(level,target,delta,attack,release);
if(!Playing(handle)){handle=null;level=0;return;}
handle.Volume=level;
if(target<=0 && level<.0005f)Stop(ref handle);
}
static SoundFile TapFile()
{
const int rate=22050;
int frames=(int)(.065f*rate);
byte[] pcm=new byte[frames*2];
for(int i=0;i<frames;i++)
{
float progress=i/(float)frames;
short sample=(short)(Math.Sin(i*Math.PI*2*690/rate)*Math.Pow(1-progress,3)*6800);
pcm[i*2]=(byte)sample;pcm[i*2+1]=(byte)(sample>>8);
}
return SoundFile.FromPcm("socer-ui-click",pcm,new SoundFile.PcmOptions{Channels=1,Rate=rate,Bits=16});
}
public static void Tap()
{
if(UserGain<=0)return;
if(!Ready(tap))tap=TapFile();
lastEffectHandle=Play2D(tap,.24f*UserGain);
}
public static void Goal()
{
if(Time.Now-lastGoalAt<2)return;
lastGoalAt=Time.Now;
goalAge=0;
Play(ref goal,"sounds/stadium_goal.ogg",.5f);
Stop(ref sustainHandle);
sustainLevel=0;
if(UserGain>0)sustainHandle=Play2D(Load(ref sustain,"sounds/stadium_sustain.ogg"),.0001f,.97f);
}
public static void Kick(bool isShot,StrikeKind kind)
{
if(isShot)
{
shotEnergy=Math.Max(shotEnergy,.65f);
Play(ref shot,"sounds/ball_shot.ogg",kind==StrikeKind.Power?.52f:.42f,kind==StrikeKind.Power?.95f:1.05f);
}
else Play(ref pass,"sounds/ball_pass.ogg",(kind is StrikeKind.Cross or StrikeKind.Chip)?.13f:.095f,
(kind is StrikeKind.Cross or StrikeKind.Chip)?.94f:1.02f);
}
public static void Header()=>Play(ref pass,"sounds/ball_pass.ogg",.12f,.88f);
public static void Tackle()=>Play(ref shot,"sounds/ball_shot.ogg",.12f,.90f);
public static void Save()=>Play(ref shot,"sounds/ball_shot.ogg",.17f,.80f);
public static void Whistle()=>Play(ref whistle,"sounds/referee_whistle.ogg",.28f);
public static void Cheer()
{
if(Time.Now-lastGoalAt<1.8f || Time.Now-lastCheerAt<4f)return;
lastCheerAt=Time.Now;
cheerEnergy=Math.Max(cheerEnergy,.55f);
Play(ref cheer,"sounds/stadium_cheer.ogg",.33f);
}
public static void Card(bool red)
{
booAge=0;
booDuration=red?12f:5.5f;
Stop(ref booHandle);
booLevel=0;
if(UserGain>0)booHandle=Play2D(Load(ref boo,"sounds/stadium_boo.ogg"),.0001f);
}
public static void Boo()=>Card(false);
public static void UpdateCrowd(float danger,float reaction,bool audible,float delta)
{
delta=Math.Clamp(delta,0,.1f);
goalAge+=delta;booAge+=delta;chantAge+=delta;
shotEnergy=Math.Max(0,shotEnergy-delta*.32f);
cheerEnergy=Math.Max(0,cheerEnergy-delta*.18f);
float gain=audible?UserGain:0;
// Two low-level crowd textures run together. Tension follows field danger but releases slowly.
MixLoop(ambience,gain*.10f,delta,.8f,1.8f);
MixLoop(chatter,gain*(.065f-.012f*Math.Clamp(danger,0,1)),delta,2f,2.5f);
float energy=Math.Clamp(Math.Max(danger,Math.Max(shotEnergy,Math.Max(cheerEnergy,reaction*.55f))),0,1);
MixLoop(tension,gain*(.012f+.11f*energy),delta,1.2f,4f);
// Chants emerge between attacks, with varying spacing and pitch rather than a repeating playlist.
if(nextChantAt<0)nextChantAt=Time.Now+11;
if(audible && gain>0 && Time.Now>=nextChantAt && danger<.28f && goalAge>14 && booAge>10)
{
chantAge=0;
Stop(ref chantHandle);
chantLevel=0;
chantHandle=Play2D(Load(ref chant,"sounds/stadium_chant.ogg"),.0001f,.94f+Random.Shared.NextSingle()*.12f);
nextChantAt=Time.Now+27+Random.Shared.NextSingle()*18;
}
bool majorReaction=goalAge<13 || booAge<booDuration+2;
float chantTarget=chantAge<12 && danger<.55f && !majorReaction?gain*.105f:0;
MixEvent(ref chantHandle,ref chantLevel,chantTarget,delta,1.6f,majorReaction?.65f:2.8f);
// The long supporting cheer outlives the initial goal burst and returns to the normal hum.
float goalTarget=goalAge<20?gain*.20f*MathF.Exp(-Math.Max(0,goalAge-2)/5f):0;
MixEvent(ref sustainHandle,ref sustainLevel,goalTarget,delta,.7f,2.2f);
float cardTarget=booAge<booDuration?gain*(booDuration>10?.34f:.22f):0;
MixEvent(ref booHandle,ref booLevel,cardTarget,delta,.55f,2.2f);
}
public static void StopCrowd()
{
StopCoachVoice();
Stop(ref ambience.Handle);Stop(ref chatter.Handle);Stop(ref tension.Handle);
Stop(ref chantHandle);Stop(ref sustainHandle);Stop(ref booHandle);
ambience.Level=chatter.Level=tension.Level=chantLevel=sustainLevel=booLevel=0;
nextChantAt=-1;goalAge=booAge=chantAge=100;shotEnergy=cheerEnergy=0;
}
public static void RefreshAssets()
{
StopCrowd();
goal=pass=shot=whistle=cheer=boo=chant=sustain=null;
ambience.File=chatter.File=tension.File=null;
failedPaths.Clear();
Prepare();
}
public static object DebugState()=>new
{
Master=Sound.MasterVolume,Game=GamePreferences.SoundVolume,
CrowdFileValid=Ready(ambience.File),CrowdPlaying=Playing(ambience.Handle),
CrowdSpatial=Playing(ambience.Handle)?ambience.Handle.SpacialBlend:-1,
CrowdEffectiveVolume=ambience.Level,
ChatterPlaying=Playing(chatter.Handle),ChatterVolume=chatter.Level,
TensionPlaying=Playing(tension.Handle),TensionVolume=tension.Level,
ChantPlaying=Playing(chantHandle),ChantVolume=chantLevel,
GoalTailPlaying=Playing(sustainHandle),GoalTailVolume=sustainLevel,GoalAge=goalAge,
CardPlaying=Playing(booHandle),CardVolume=booLevel,CardAge=booAge,CardDuration=booDuration,
LastFileValid=Ready(lastEffectHandle!=null && lastEffectHandle.IsValid?lastEffectHandle.SoundFile:null),
LastPlaying=Playing(lastEffectHandle),
LastSpatial=lastEffectHandle!=null && lastEffectHandle.IsValid?lastEffectHandle.SpacialBlend:-1,
LastEffectiveVolume=lastEffectHandle!=null && lastEffectHandle.IsValid?lastEffectHandle.Volume:-1
};
}