using Sandbox.Audio; using System.Collections.Concurrent; using System.IO; namespace Sandbox; /// /// A handle to a sound that is currently playing. You can use this to control the sound's position, volume, pitch etc. /// [Expose] public partial class SoundHandle : IValid, IDisposable { static ConcurrentQueue removalQueue = new(); static ConcurrentQueue addQueue = new(); static HashSet active = new(); CSfxTable _sfx; internal AudioSampler sampler; int _ticks; Transform _transform = Transform.Zero; static SoundHandle _empty; /// /// RealTime that this sound was created /// internal float _CreatedTime; /// /// An empty, do nothing sound, that we can return to avoid NREs /// internal static SoundHandle Empty { get { if ( _empty is null ) { _empty = new SoundHandle(); } return _empty; } } /// /// Position of the sound. /// public Vector3 Position { get => _transform.Position; set => _transform.Position = value; } /// /// The direction the sound is facing /// public Rotation Rotation { get => _transform.Rotation; set => _transform.Rotation = value; } /// /// This sound's transform /// public Transform Transform { get => _transform; set => _transform = value; } /// /// Volume of the sound. /// public float Volume { get; set; } = 1.0f; /// /// A debug name to help identify the sound /// public string Name { get; set; } /// /// How 3d the sound should be. 0 means no 3d, 1 means fully /// [Range( 0, 1 )] public float SpacialBlend { get; set; } = 1.0f; /// /// How many units the sound can be heard from. /// public float Distance { get; set; } = 15_000f; /// /// The falloff curve for the sound. /// public Curve Falloff { get; set; } = new Curve( new( 0, 1, 0, -1.8f ), new( 0.05f, 0.22f, 3.5f, -3.5f ), new( 0.2f, 0.04f, 0.16f, -0.16f ), new( 1, 0 ) ); /// /// The fadeout curve for when the sound stops. /// public Curve Fadeout { get; set; } = new Curve( new( 0, 1 ), new( 1, 0 ) ); [Obsolete( "This is not used anymore" )] public float Decibels { get; set; } = 70.0f; /// /// Pitch of the sound. /// public float Pitch { get; set; } = 1.0f; /// /// Whether the sound is currently playing or not. /// public bool IsPlaying => !IsStopped; /// /// Whether the sound is currently paused or not. /// public bool Paused { get; set; } = false; /// /// Sound is done /// public bool Finished { get; set; } /// /// Enable the sound reflecting off surfaces /// [System.Obsolete] public bool Reflections { get; set; } /// /// Allow this sound to be occluded by geometry etc /// public bool Occlusion { get; set; } = true; /// /// The radius of this sound's occlusion, allow for partial occlusion /// public float OcclusionRadius { get; set; } = 32.0f; /// /// Should the sound fade out over distance /// public bool DistanceAttenuation { get; set; } = true; /// /// Should the sound get absorbed by air, so it sounds different at distance /// public bool AirAbsorption { get; set; } = true; /// /// Should the sound transmit through walls, doors etc /// public bool Transmission { get; set; } = true; /// /// Which mixer do we want to write to /// public Mixer TargetMixer { get; set; } /// /// How many samples per second? /// public int SampleRate { get; private init; } /// /// Keep playing silently for a second or two, to finish reverb effect /// internal RealTimeUntil TimeUntilFinished { get; set; } /// /// Keep playing until faded out /// internal RealTimeUntil TimeUntilFaded { get; set; } /// /// Have we started fading out? /// internal bool IsFading { get; set; } /// /// True if the sound has been stopped /// public bool IsStopped { get { if ( !IsValid ) return true; return false; } } [Obsolete( "Use Time instead" )] public float ElapsedTime => Time; /// /// The current time of the playing sound in seconds. /// Note: for some formats seeking may be expensive, and some may not support it at all. /// public float Time { get { if ( IsStopped ) return 0.0f; if ( sampler is null ) return 0.0f; return SampleRate > 0 ? sampler.SamplePosition / (float)SampleRate : 0.0f; } set { if ( sampler is null ) return; sampler.SamplePosition = (int)(value * SampleRate); } } public void Stop( float fadeTime = 0.0f ) { if ( Finished || IsFading ) return; if ( fadeTime > 0.0f ) { TimeUntilFaded = fadeTime; IsFading = true; return; } Finished = true; } /// /// Place the listener at 0,0,0 facing 1,0,0. /// public bool ListenLocal { get; set; } /// /// If true, then this sound won't be played unless voice_loopback is 1. The assumption is that it's the /// local user's voice. Amplitude and visme data will still be available! /// public bool Loopback { get; set; } /// /// Measure of audio loudness. /// public float Amplitude { get; set; } bool _destroyed = false; internal readonly Scene Scene; internal SoundHandle( CSfxTable soundHandle ) { _sfx = soundHandle; Scene = Game.ActiveScene; SampleRate = _sfx.GetSound().m_rate(); TryCreateMixer(); addQueue.Enqueue( this ); _CreatedTime = RealTime.Now; } // an empty soundhandle internal SoundHandle() { SampleRate = 48000; _destroyed = true; _CreatedTime = RealTime.Now; } ~SoundHandle() { Dispose(); } /// /// Return true if this has no mixer specified, so will use the default mixer /// /// internal bool WantsDefaultMixer() => TargetMixer is null; /// /// Return true if we want to play on this mixer. Will return true if we have no /// mixer specified, and the provided mixer is the default. /// internal bool IsTargettingMixer( Mixer mixer ) { if ( _destroyed ) return false; if ( WantsDefaultMixer() && Mixer.Default == mixer ) return true; if ( TargetMixer is null ) return false; if ( string.IsNullOrEmpty( mixer.Name ) ) return false; // Compare names instead of mixers, because they may have deserialized etc return TargetMixer.Name == mixer.Name; } public bool IsValid => !_destroyed; void TryCreateMixer() { if ( sampler is not null ) return; var ptr = _sfx.CreateMixer(); if ( ptr.IsNull ) { // Did we fail because the resource failed to load? Just mark complete or get stuck in hell (feedback is provide on load failure) if ( _sfx.FailedResourceLoad() ) { Finished = true; } return; } sampler = new AudioSampler( ptr ); } public void Dispose() { lock ( this ) { if ( _destroyed ) return; GC.SuppressFinalize( this ); _destroyed = true; _sfx = default; DisposeSources(); MainThread.QueueDispose( sampler ); sampler = null; removalQueue.Enqueue( this ); if ( LipSync.Enabled ) LipSync.DisableLipSync(); } } /// /// This is called on the main thread for all active voices /// void TickInternal() { if ( _destroyed ) return; if ( Finished ) { Dispose(); return; } UpdateFollower(); TryCreateMixer(); UpdateSources(); _ticks++; } /// /// Called to push changes to a sound immediately, rather than waiting for the next tick. /// You should call this if you make changes to a sound. /// [System.Obsolete( "This no longer needs to exist" )] public void Update() { } /// /// Before we're added to the active list, we need to get some stuff straight /// void OnActive() { } static void TickQueues() { while ( addQueue.TryDequeue( out var h ) ) { h.OnActive(); active.Add( h ); } while ( removalQueue.TryDequeue( out var h ) ) { active.Remove( h ); } } static void TickVoices() { foreach ( var handle in active ) { if ( !handle.IsValid ) continue; handle.TickInternal(); } } internal static void TickAll() { lock ( active ) { TickQueues(); TickVoices(); } } internal static void StopAll( float fade, Mixer mixer = null ) { lock ( active ) { TickQueues(); var handles = mixer is null ? active : active.Where( x => x.TargetMixer == mixer ); foreach ( var handle in handles ) { if ( !handle.IsValid() ) continue; handle.Stop( fade ); } } } internal static void Shutdown() { lock ( active ) { foreach ( var handle in active ) { if ( !handle.IsValid() ) continue; handle.Dispose(); } } } internal static void StopAllWithParent( GameObject parent, float fade ) { lock ( active ) { TickQueues(); foreach ( var handle in active.Where( x => x.Parent == parent ) ) { if ( !handle.IsValid() ) continue; handle.Stop( fade ); } } } internal static void StopAll( CSfxTable sfx ) { lock ( active ) { foreach ( var handle in active.Where( x => x._sfx == sfx ) ) { if ( !handle.IsValid() ) continue; handle.Stop(); } } } internal static void FlushCreatedSounds() { lock ( active ) { TickQueues(); } } public static void GetActive( List handles ) { lock ( active ) { foreach ( var handle in active ) { if ( !handle.IsValid() ) continue; if ( handle._ticks == 0 ) continue; if ( handle.Paused ) continue; handles.Add( handle ); } } } [ConCmd( "list_sound_handles", Help = "Prints a summary of active sound handles to the console. Use \"list_sound_handles 2\" to see more info." )] private static void LogActiveHandles( int level = 1 ) { var handles = new List(); GetActive( handles ); var writer = new StringWriter(); var mixerGroups = handles .GroupBy( x => x.TargetMixer ) .OrderBy( x => x.Key?.Name ); writer.WriteLine( $"Total active sound handles: {handles.Count}" ); writer.WriteLine(); foreach ( var mixerGroup in mixerGroups ) { writer.WriteLine( $"Mixer \"{mixerGroup.Key?.Name ?? "Default"}\": {mixerGroup.Count()}" ); var soundGroups = mixerGroup.GroupBy( x => x.Name ); foreach ( var soundGroup in soundGroups ) { writer.WriteLine( $" Sound \"{soundGroup.Key}\": {soundGroup.Count()}" ); if ( level < 2 ) continue; foreach ( var soundHandle in soundGroup ) { writer.WriteLine( $" Handle {soundHandle._sfx.self:x}:" ); writer.WriteLine( $" {nameof( IsPlaying )}: {soundHandle.IsPlaying}" ); writer.WriteLine( $" {nameof( Time )}: {soundHandle.Time}" ); writer.WriteLine( $" {nameof( Volume )}: {soundHandle.Volume}" ); writer.WriteLine( $" {nameof( Distance )}: {soundHandle.Distance}" ); writer.WriteLine( $" {nameof( ListenLocal )}: {soundHandle.ListenLocal}" ); writer.WriteLine( $" {nameof( Position )}: {soundHandle.Position}" ); } } writer.WriteLine(); } Log.Info( writer.ToString() ); } }