using System; using System.Diagnostics.CodeAnalysis; using System.Linq; using Robust.Shared.Audio.Components; using Robust.Shared.Configuration; using Robust.Shared.GameObjects; using Robust.Shared.GameStates; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Map; using Robust.Shared.Map.Components; using Robust.Shared.Network; using Robust.Shared.Physics.Components; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Serialization; using Robust.Shared.Spawners; using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Robust.Shared.Audio.Systems; /// /// Handles audio for robust toolbox inside of the sim. /// /// /// Interacts with AudioManager internally. /// public abstract partial class SharedAudioSystem : EntitySystem { [Dependency] protected IConfigurationManager CfgManager = default!; [Dependency] protected IGameTiming Timing = default!; [Dependency] private INetManager _netManager = default!; [Dependency] protected IRobustRandom RandMan = default!; [Dependency] protected MetaDataSystem MetadataSys = default!; [Dependency] protected SharedTransformSystem XformSystem = default!; public const float AudioDespawnBuffer = 1f; /// /// Default max range at which the sound can be heard. /// public const float DefaultSoundRange = 15; /// /// Used in the PAS to designate the physics collision mask of occluders. /// public int OcclusionCollisionMask { get; set; } public virtual float ZOffset { get; protected set; } public override void Initialize() { base.Initialize(); InitializeEffect(); ZOffset = CfgManager.GetCVar(CVars.AudioZOffset); Subs.CVar(CfgManager, CVars.AudioZOffset, SetZOffset); SubscribeLocalEvent(OnAudioGetStateAttempt); SubscribeLocalEvent(OnAudioUnpaused); SubscribeLocalEvent(OnTimedDespawn); } /// /// Sets the playback position of audio to the specified spot. /// public void SetPlaybackPosition(Entity? nullEntity, float position) { if (nullEntity == null) return; var entity = nullEntity.Value; if (!Resolve(entity.Owner, ref entity.Comp, false)) return; var audioLength = GetAudioLength(entity.Comp.FileName); position = CalculateAudioPosition(entity!, (float)audioLength.TotalSeconds, position); if (audioLength.TotalSeconds < position) { // Just stop it and return if (!_netManager.IsClient) QueueDel(nullEntity.Value); entity.Comp.StopPlaying(); return; } // If we're paused then the current position is , else it's var currentPos = (entity.Comp.PauseTime ?? Timing.CurTime) - entity.Comp.AudioStart; var timeOffset = TimeSpan.FromSeconds(position - currentPos.TotalSeconds); DebugTools.Assert(currentPos >= TimeSpan.Zero); // Rounding. if (Math.Abs(timeOffset.TotalSeconds) <= 0.01) { return; } if (entity.Comp.PauseTime != null) { entity.Comp.PauseTime = entity.Comp.PauseTime.Value + timeOffset; DirtyField(entity, nameof(AudioComponent.PauseTime)); // Paused audio doesn't have TimedDespawn so. } else { // Bump it back so the actual playback positions moves forward entity.Comp.AudioStart -= timeOffset; DirtyField(entity, nameof(AudioComponent.AudioStart)); // need to ensure it doesn't despawn too early. if (TryComp(entity.Owner, out TimedDespawnComponent? despawn)) { despawn.Lifetime -= (float) timeOffset.TotalSeconds; } } entity.Comp.PlaybackPosition = position; } /// /// Calculates playback position considering length paused. /// /// /// private float GetPlaybackPosition(AudioComponent component) { return (float) (Timing.CurTime - (component.PauseTime ?? TimeSpan.Zero) - component.AudioStart).TotalSeconds; } /// /// Marks this audio as being map-based. /// public virtual void SetMapAudio(Entity? audio) { if (audio == null) return; audio.Value.Comp.Global = true; MetadataSys.AddFlag(audio.Value.Owner, MetaDataFlags.Undetachable); } public virtual void SetGridAudio(Entity? entity) { if (entity == null) return; entity.Value.Comp.Flags |= AudioFlags.GridAudio; var gridUid = Transform(entity.Value).GridUid; if (TryComp(gridUid, out PhysicsComponent? gridPhysics)) { XformSystem.SetLocalPosition(entity.Value.Owner, gridPhysics.LocalCenter); } if (TryComp(gridUid, out MapGridComponent? mapGrid)) { var extents = mapGrid.LocalAABB.Extents; var minDistance = MathF.Max(extents.X, extents.Y); entity.Value.Comp.Params = entity.Value.Comp.Params .WithMaxDistance(minDistance + DefaultSoundRange) .WithReferenceDistance(minDistance); } entity.Value.Comp.Flags |= AudioFlags.NoOcclusion; Dirty(entity.Value); } /// /// Sets the shared state for an audio entity. /// public void SetState(EntityUid? entity, AudioState state, bool force = false, AudioComponent? component = null) { if (entity == null || !Resolve(entity.Value, ref component, false)) return; if (component.State == state && !force) return; // Unpause it if (component.State == AudioState.Paused && state == AudioState.Playing) { var pauseOffset = Timing.CurTime - component.PauseTime; component.AudioStart += pauseOffset ?? TimeSpan.Zero; component.PlaybackPosition = (float) (Timing.CurTime - component.AudioStart).TotalSeconds; DirtyField(entity.Value, component, nameof(AudioComponent.AudioStart)); } // If we were stopped then played then restart audiostart to now. if (component.State == AudioState.Stopped && state == AudioState.Playing) { component.AudioStart = Timing.CurTime; component.PauseTime = null; DirtyField(entity.Value, component, nameof(AudioComponent.AudioStart)); DirtyField(entity.Value, component, nameof(AudioComponent.PauseTime)); } switch (state) { case AudioState.Stopped: component.AudioStart = Timing.CurTime; component.PauseTime = null; DirtyField(entity.Value, component, nameof(AudioComponent.AudioStart)); DirtyField(entity.Value, component, nameof(AudioComponent.PauseTime)); component.StopPlaying(); RemComp(entity.Value); break; case AudioState.Paused: // Set it to current time so we can easily unpause it later. component.PauseTime = Timing.CurTime; DirtyField(entity.Value, component, nameof(AudioComponent.PauseTime)); component.Pause(); RemComp(entity.Value); break; case AudioState.Playing: component.PauseTime = null; DirtyField(entity.Value, component, nameof(AudioComponent.PauseTime)); component.StartPlaying(); // Reset TimedDespawn so the audio still gets cleaned up. if (!component.Looping) { var timed = EnsureComp(entity.Value); var audioLength = GetAudioLength(component.FileName); timed.Lifetime = (float) audioLength.TotalSeconds + AudioDespawnBuffer; } break; } component.State = state; DirtyField(entity.Value, component, nameof(AudioComponent.State)); } protected void SetZOffset(float value) { ZOffset = value; } protected virtual void OnAudioUnpaused(EntityUid uid, AudioComponent component, ref EntityUnpausedEvent args) { component.AudioStart += args.PausedTime; } private void OnAudioGetStateAttempt(EntityUid uid, AudioComponent component, ref ComponentGetStateAttemptEvent args) { var playerEnt = args.Player?.AttachedEntity; if (component.ExcludedEntity != null && playerEnt == component.ExcludedEntity) { args.Cancelled = true; return; } if (playerEnt != null && component.IncludedEntities != null && !component.IncludedEntities.Contains(playerEnt.Value)) { args.Cancelled = true; } } private void OnTimedDespawn(Entity ent, ref TimedDespawnEvent args) { var parent = Transform(ent).ParentUid; if (!Exists(parent)) return; var ev = new AttachedAudioDespawnedEvent(); RaiseLocalEvent(parent, ref ev); } /// /// Considers Z-offset for audio and gets the adjusted distance. /// /// /// Really it's just doing pythagoras for you. /// public float GetAudioDistance(float length) { return MathF.Sqrt(MathF.Pow(length, 2) + MathF.Pow(ZOffset, 2)); } /// /// Resolve a sound specifier so it can be consistently played back on all clients. /// public ResolvedSoundSpecifier ResolveSound(SoundSpecifier specifier) { switch (specifier) { case SoundPathSpecifier path: return new ResolvedPathSpecifier(path.Path == default ? string.Empty : path.Path.ToString()); case SoundCollectionSpecifier collection: { if (collection.Collection == null) return new ResolvedPathSpecifier(string.Empty); var soundCollection = ProtoMan.Index(collection.Collection); var index = RandMan.Next(soundCollection.PickFiles.Count); return new ResolvedCollectionSpecifier(collection.Collection, index); } } return new ResolvedPathSpecifier(string.Empty); } /// /// Resolves the filepath to a sound file. /// [Obsolete("Use ResolveSound() and pass around resolved sound specifiers instead.")] public string GetSound(SoundSpecifier specifier) { var resolved = ResolveSound(specifier); return GetAudioPath(resolved); } /// /// Calculates the current playback position of an audio entity /// and clamps it to the range from 0 to (length - 0.01f). /// /// The audio entity. /// /// The total length of the audio file. /// If null, the method retrieves the length using . /// /// /// A precomputed playback position. /// If provided, it will be added to the calculation. /// /// The playback position as a float. protected float CalculateAudioPosition(Entity ent, float? length = null, float? position = null) { position ??= (float) ((ent.Comp.PauseTime ?? Timing.CurTime) - ent.Comp.AudioStart).TotalSeconds; length ??= (float) GetAudioLength(ent.Comp.FileName).TotalSeconds; // Looped audio has no conceptual start or end. if (ent.Comp.Params.Loop) position %= length; // TODO clamp the offset inside of AudioSource.SetPlaybackPosition() itself. var maxOffset = Math.Max((float) length - 0.01f, 0f); position = Math.Clamp(position.Value, 0f, maxOffset); return position.Value; } #region AudioParams [return: NotNullIfNotNull(nameof(specifier))] public string? GetAudioPath(ResolvedSoundSpecifier? specifier) { return specifier switch { ResolvedPathSpecifier path => path.Path.ToString(), ResolvedCollectionSpecifier collection => collection.Collection is null ? string.Empty : ProtoMan.Index(collection.Collection).PickFiles[collection.Index].ToString(), null => null, _ => throw new ArgumentOutOfRangeException("specifier", specifier, "argument is not a ResolvedPathSpecifier or a ResolvedCollectionSpecifier"), }; } protected Entity SetupAudio(ResolvedSoundSpecifier? specifier, AudioParams? audioParams, bool initialize = true, TimeSpan? length = null) { var uid = EntityManager.CreateEntityUninitialized("Audio", MapCoordinates.Nullspace); var fileName = GetAudioPath(specifier); DebugTools.Assert(!string.IsNullOrEmpty(fileName) || length is not null); MetadataSys.SetEntityName(uid, $"Audio ({fileName})", raiseEvents: false); audioParams ??= AudioParams.Default; var comp = AddComp(uid); comp.FileName = fileName ?? string.Empty; comp.Params = audioParams.Value; comp.AudioStart = Timing.CurTime; if (!audioParams.Value.Loop) { length ??= GetAudioLength(fileName!); var despawn = AddComp(uid); // Don't want to clip audio too short due to imprecision. despawn.Lifetime = (float) length.Value.TotalSeconds + AudioDespawnBuffer; } if (comp.Params.Variation != null && comp.Params.Variation.Value != 0f) { comp.Params.Pitch *= (float) RandMan.NextGaussian(1, comp.Params.Variation.Value); } if (initialize) { EntityManager.InitializeAndStartEntity(uid); } return new Entity(uid, comp); } public static float GainToVolume(float value) { if (value < 0f) { value = 0f; } return 10f * MathF.Log10(value); } public static float VolumeToGain(float value) { var result = MathF.Pow(10, value / 10); if (result < 0f) { throw new InvalidOperationException($"Tried to get gain calculation that resulted in invalid value of {result}"); } return result; } /// /// Sets the audio params volume for an entity. /// public void SetGain(EntityUid? entity, float value, AudioComponent? component = null) { if (entity == null || !Resolve(entity.Value, ref component)) return; var volume = GainToVolume(value); SetVolume(entity, volume, component); } /// /// Sets the audio params volume for an entity. /// public void SetVolume(EntityUid? entity, float value, AudioComponent? component = null) { if (entity == null || !Resolve(entity.Value, ref component)) return; if (component.Params.Volume.Equals(value)) return; // Not a log error for now because if something has a negative infinity volume (i.e. 0 gain) then subtracting from it can // easily cause this and making callers deal with it everywhere is quite annoying. if (float.IsNaN(value)) { value = float.NegativeInfinity; } component.Params.Volume = value; component.Volume = value; DirtyField(entity.Value, component, nameof(AudioComponent.Params)); } #endregion /// /// Gets the timespan of the specified audio. /// public TimeSpan GetAudioLength(ResolvedSoundSpecifier specifier) => GetAudioLength(GetAudioPath(specifier)); /// /// Gets the timespan of the specified filename. /// protected TimeSpan GetAudioLength(string filename) { if (!filename.StartsWith('/')) throw new ArgumentException($"Path must be rooted. Path: {filename}"); return GetAudioLengthImpl(filename); } protected abstract TimeSpan GetAudioLengthImpl(string filename); /// /// Stops the specified audio entity from playing. /// /// /// Returns null so you can inline the call. /// public EntityUid? Stop(EntityUid? uid, Components.AudioComponent? component = null) { // One morbillion warnings for logging missing. if (uid == null || !Resolve(uid.Value, ref component, false)) return null; if (!Timing.IsFirstTimePredicted || (_netManager.IsClient && !IsClientSide(uid.Value))) return null; QueueDel(uid); return null; } /// /// Play an audio file globally, without position. /// /// The resource path to the OGG Vorbis file to play. /// The set of players that will hear the sound. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayGlobal(ResolvedSoundSpecifier? filename, Filter playerFilter, bool recordReplay, AudioParams? audioParams = null); /// /// Play an audio file globally, without position. /// /// The sound specifier that points the audio file(s) that should be played. /// The set of players that will hear the sound. public (EntityUid Entity, Components.AudioComponent Component)? PlayGlobal(SoundSpecifier? sound, Filter playerFilter, bool recordReplay, AudioParams? audioParams = null) { return sound == null ? null : PlayGlobal(ResolveSound(sound), playerFilter, recordReplay, audioParams ?? sound.Params); } /// /// Play an audio file globally, without position. /// /// The resource path to the OGG Vorbis file to play. /// The player that will hear the sound. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayGlobal(ResolvedSoundSpecifier? filename, ICommonSession recipient, AudioParams? audioParams = null); /// /// Play an audio file globally, without position. /// /// The sound specifier that points the audio file(s) that should be played. /// The player that will hear the sound. public (EntityUid Entity, Components.AudioComponent Component)? PlayGlobal(SoundSpecifier? sound, ICommonSession recipient, AudioParams? audioParams = null) { return sound == null ? null : PlayGlobal(ResolveSound(sound), recipient, audioParams ?? sound.Params); } public abstract void LoadStream(Entity entity, T stream); /// /// Play an audio file globally, without position. /// /// The resource path to the OGG Vorbis file to play. /// The player that will hear the sound. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayGlobal(ResolvedSoundSpecifier? filename, EntityUid recipient, AudioParams? audioParams = null); /// /// Play an audio file globally, without position. /// /// The sound specifier that points the audio file(s) that should be played. /// The player that will hear the sound. public (EntityUid Entity, Components.AudioComponent Component)? PlayGlobal(SoundSpecifier? sound, EntityUid recipient, AudioParams? audioParams = null) { return sound == null ? null : PlayGlobal(ResolveSound(sound), recipient, audioParams ?? sound.Params); } /// /// Play an audio file following an entity. /// /// The resource path to the OGG Vorbis file to play. /// The set of players that will hear the sound. /// The UID of the entity "emitting" the audio. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayEntity(ResolvedSoundSpecifier? filename, Filter playerFilter, EntityUid uid, bool recordReplay, AudioParams? audioParams = null); /// /// Play an audio file following an entity. /// /// The resource path to the OGG Vorbis file to play. /// The player that will hear the sound. /// The UID of the entity "emitting" the audio. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayEntity(ResolvedSoundSpecifier? filename, ICommonSession recipient, EntityUid uid, AudioParams? audioParams = null); /// /// Play an audio file following an entity. /// /// The resource path to the OGG Vorbis file to play. /// The player that will hear the sound. /// The UID of the entity "emitting" the audio. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayEntity(ResolvedSoundSpecifier? filename, EntityUid recipient, EntityUid uid, AudioParams? audioParams = null); /// /// Play an audio file following an entity. /// /// The sound specifier that points the audio file(s) that should be played. /// The set of players that will hear the sound. /// The UID of the entity "emitting" the audio. public (EntityUid Entity, Components.AudioComponent Component)? PlayEntity(SoundSpecifier? sound, Filter playerFilter, EntityUid uid, bool recordReplay, AudioParams? audioParams = null) { return sound == null ? null : PlayEntity(ResolveSound(sound), playerFilter, uid, recordReplay, audioParams ?? sound.Params); } /// /// Play an audio file following an entity. /// /// The sound specifier that points the audio file(s) that should be played. /// The player that will hear the sound. /// The UID of the entity "emitting" the audio. public (EntityUid Entity, Components.AudioComponent Component)? PlayEntity(SoundSpecifier? sound, ICommonSession recipient, EntityUid uid, AudioParams? audioParams = null) { return sound == null ? null : PlayEntity(ResolveSound(sound), recipient, uid, audioParams ?? sound.Params); } /// /// Play an audio file following an entity. /// /// The sound specifier that points the audio file(s) that should be played. /// The player that will hear the sound. /// The UID of the entity "emitting" the audio. public (EntityUid Entity, Components.AudioComponent Component)? PlayEntity(SoundSpecifier? sound, EntityUid recipient, EntityUid uid, AudioParams? audioParams = null) { return sound == null ? null : PlayEntity(ResolveSound(sound), recipient, uid, audioParams ?? sound.Params); } /// /// Play an audio file following an entity for every entity in PVS range. /// /// The sound specifier that points the audio file(s) that should be played. /// The UID of the entity "emitting" the audio. public (EntityUid Entity, Components.AudioComponent Component)? PlayPvs(SoundSpecifier? sound, EntityUid uid, AudioParams? audioParams = null) { return sound == null ? null : PlayPvs(ResolveSound(sound), uid, audioParams ?? sound.Params); } /// /// Play an audio file at the specified EntityCoordinates for every entity in PVS range. /// /// The sound specifier that points the audio file(s) that should be played. /// The EntityCoordinates to attach the audio source to. public (EntityUid Entity, Components.AudioComponent Component)? PlayPvs(SoundSpecifier? sound, EntityCoordinates coordinates, AudioParams? audioParams = null) { return sound == null ? null : PlayPvs(ResolveSound(sound), coordinates, audioParams ?? sound.Params); } /// /// Play an audio file at the specified EntityCoordinates for every entity in PVS range. /// /// The sound specifier that points the audio file(s) that should be played. /// The EntityCoordinates to attach the audio source to. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayPvs(ResolvedSoundSpecifier? filename, EntityCoordinates coordinates, AudioParams? audioParams = null); /// /// Play an audio file following an entity for every entity in PVS range. /// /// The resource path to the OGG Vorbis file to play. /// The UID of the entity "emitting" the audio. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayPvs(ResolvedSoundSpecifier? filename, EntityUid uid, AudioParams? audioParams = null); /// /// Plays a predicted sound following an entity for only one entity. The client-side system plays this sound as normal, server will do nothing. /// /// The sound specifier that points the audio file(s) that should be played. /// The UID of the entity "emitting" the audio. /// The UID of the user that initiated this sound. This is usually some player's controlled entity. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayLocal(SoundSpecifier? sound, EntityUid source, EntityUid? soundInitiator, AudioParams? audioParams = null); /// /// Plays a predicted sound following an entity. The server will send the sound to every player in PVS range, /// unless that player is attached to the "user" entity that initiated the sound. The client-side system plays /// this sound as normal /// /// The sound specifier that points the audio file(s) that should be played. /// The UID of the entity "emitting" the audio. /// The UID of the user that initiated this sound. This is usually some player's controlled entity. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayPredicted(SoundSpecifier? sound, EntityUid source, EntityUid? user, AudioParams? audioParams = null); /// /// Plays a predicted sound following an EntityCoordinates. The server will send the sound to every player in PVS range, /// unless that player is attached to the "user" entity that initiated the sound. The client-side system plays /// this sound as normal /// /// The sound specifier that points the audio file(s) that should be played. /// The entitycoordinates "emitting" the audio /// The UID of the user that initiated this sound. This is usually some player's controlled entity. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayPredicted(SoundSpecifier? sound, EntityCoordinates coordinates, EntityUid? user, AudioParams? audioParams = null); /// /// Play an audio file at a static position. /// /// The resource path to the OGG Vorbis file to play. /// The set of players that will hear the sound. /// The coordinates at which to play the audio. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayStatic(ResolvedSoundSpecifier? filename, Filter playerFilter, EntityCoordinates coordinates, bool recordReplay, AudioParams? audioParams = null); /// /// Play an audio file at a static position. /// /// The resource path to the OGG Vorbis file to play. /// The player that will hear the sound. /// The coordinates at which to play the audio. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayStatic(ResolvedSoundSpecifier? filename, ICommonSession recipient, EntityCoordinates coordinates, AudioParams? audioParams = null); /// /// Play an audio file at a static position. /// /// The resource path to the OGG Vorbis file to play. /// The player that will hear the sound. /// The coordinates at which to play the audio. public abstract (EntityUid Entity, Components.AudioComponent Component)? PlayStatic(ResolvedSoundSpecifier? filename, EntityUid recipient, EntityCoordinates coordinates, AudioParams? audioParams = null); /// /// Play an audio file at a static position. /// /// The sound specifier that points the audio file(s) that should be played. /// The set of players that will hear the sound. /// The coordinates at which to play the audio. public (EntityUid Entity, Components.AudioComponent Component)? PlayStatic(SoundSpecifier? sound, Filter playerFilter, EntityCoordinates coordinates, bool recordReplay, AudioParams? audioParams = null) { return sound == null ? null : PlayStatic(ResolveSound(sound), playerFilter, coordinates, recordReplay, audioParams ?? sound.Params); } /// /// Play an audio file at a static position. /// /// The sound specifier that points the audio file(s) that should be played. /// The player that will hear the sound. /// The coordinates at which to play the audio. public (EntityUid Entity, Components.AudioComponent Component)? PlayStatic(SoundSpecifier? sound, ICommonSession recipient, EntityCoordinates coordinates, AudioParams? audioParams = null) { return sound == null ? null : PlayStatic(ResolveSound(sound), recipient, coordinates, audioParams ?? sound.Params); } /// /// Play an audio file at a static position. /// /// The sound specifier that points the audio file(s) that should be played. /// The player that will hear the sound. /// The coordinates at which to play the audio. public (EntityUid Entity, Components.AudioComponent Component)? PlayStatic(SoundSpecifier? sound, EntityUid recipient, EntityCoordinates coordinates, AudioParams? audioParams = null) { return sound == null ? null : PlayStatic(ResolveSound(sound), recipient, coordinates, audioParams ?? sound.Params); } // These are just here for replays now. // We don't actually need them in shared, or netserializable, but this makes net serialization // and replays happy // TODO: This is quite bandwidth intensive. // Sending bus names and file names as strings is expensive and can be optimized. // Also there's redundant fields in AudioParams in most cases. [NetSerializable, Serializable] protected abstract class AudioMessage : EntityEventArgs { public ResolvedSoundSpecifier Specifier = new ResolvedPathSpecifier(string.Empty); public AudioParams AudioParams; } [NetSerializable, Serializable] protected sealed class PlayAudioGlobalMessage : AudioMessage { } [NetSerializable, Serializable] protected sealed class PlayAudioPositionalMessage : AudioMessage { public NetCoordinates Coordinates; } [NetSerializable, Serializable] protected sealed class PlayAudioEntityMessage : AudioMessage { public NetEntity NetEntity; } public bool IsPlaying(EntityUid? stream, AudioComponent? component = null) { if (stream == null || !Resolve(stream.Value, ref component, false)) return false; return component.State == AudioState.Playing; } }