From d4b1c76d50e29462f67ac93256a4494567db7294 Mon Sep 17 00:00:00 2001 From: stalengd Date: Sun, 8 Dec 2024 21:57:20 +0300 Subject: [PATCH] Audio mixers --- Robust.Client/Audio/AudioSystem.Mixers.cs | 97 ++++++++ Robust.Client/Audio/AudioSystem.cs | 21 +- Robust.Client/Audio/Midi/IMidiManager.cs | 6 + Robust.Client/Audio/Midi/IMidiRenderer.cs | 5 + Robust.Client/Audio/Midi/MidiManager.cs | 35 ++- Robust.Client/Audio/Midi/MidiRenderer.cs | 4 + Robust.Client/Audio/Mixers/AudioMixer.cs | 125 ++++++++++ .../Audio/Mixers/AudioMixersManager.cs | 77 ++++++ .../Audio/Mixers/IAudioMixersManager.cs | 14 ++ .../Audio/Sources/MixableAudioSource.cs | 172 +++++++++++++ Robust.Client/ClientIoC.cs | 2 + Robust.Server/Audio/AudioSystem.Mixers.cs | 22 ++ Robust.Server/Audio/AudioSystem.cs | 2 +- Robust.Shared/Audio/AudioParams.cs | 21 +- .../Audio/Components/AudioComponent.cs | 16 +- .../Audio/Components/AudioMixerComponent.cs | 81 ++++++ .../Audio/Mixers/AudioMixerPrototype.cs | 43 ++++ Robust.Shared/Audio/Mixers/DummyAudioMixer.cs | 36 +++ Robust.Shared/Audio/Mixers/IAudioMixer.cs | 50 ++++ .../Audio/Mixers/IAudioMixerSubscriber.cs | 12 + .../Audio/Sources/DummyMixableAudioSource.cs | 12 + .../Audio/Sources/IMixableAudioSource.cs | 11 + .../Audio/Systems/SharedAudioSystem.Mixers.cs | 234 ++++++++++++++++++ .../Audio/Systems/SharedAudioSystem.cs | 10 + 24 files changed, 1091 insertions(+), 17 deletions(-) create mode 100644 Robust.Client/Audio/AudioSystem.Mixers.cs create mode 100644 Robust.Client/Audio/Mixers/AudioMixer.cs create mode 100644 Robust.Client/Audio/Mixers/AudioMixersManager.cs create mode 100644 Robust.Client/Audio/Mixers/IAudioMixersManager.cs create mode 100644 Robust.Client/Audio/Sources/MixableAudioSource.cs create mode 100644 Robust.Server/Audio/AudioSystem.Mixers.cs create mode 100644 Robust.Shared/Audio/Components/AudioMixerComponent.cs create mode 100644 Robust.Shared/Audio/Mixers/AudioMixerPrototype.cs create mode 100644 Robust.Shared/Audio/Mixers/DummyAudioMixer.cs create mode 100644 Robust.Shared/Audio/Mixers/IAudioMixer.cs create mode 100644 Robust.Shared/Audio/Mixers/IAudioMixerSubscriber.cs create mode 100644 Robust.Shared/Audio/Sources/DummyMixableAudioSource.cs create mode 100644 Robust.Shared/Audio/Sources/IMixableAudioSource.cs create mode 100644 Robust.Shared/Audio/Systems/SharedAudioSystem.Mixers.cs diff --git a/Robust.Client/Audio/AudioSystem.Mixers.cs b/Robust.Client/Audio/AudioSystem.Mixers.cs new file mode 100644 index 00000000000..a94f58c9b61 --- /dev/null +++ b/Robust.Client/Audio/AudioSystem.Mixers.cs @@ -0,0 +1,97 @@ +using Robust.Client.Audio.Mixers; +using Robust.Shared.Audio.Components; +using Robust.Shared.Audio.Mixers; +using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; +using Robust.Shared.IoC; +using Robust.Shared.Prototypes; + +namespace Robust.Client.Audio; + +public sealed partial class AudioSystem +{ + [Dependency] private readonly IAudioMixersManager _audioMixersManager = default!; + + protected override void InitializeMixers() + { + base.InitializeMixers(); + + SubscribeLocalEvent(OnMixerAdd); + } + + public override Entity CreateMixer(Entity? outMixer) + { + var mixerEntity = base.CreateMixer(outMixer); + if (outMixer is { } outMixerValue) + { + mixerEntity.Comp.Mixer.SetOut(outMixerValue.Comp.Mixer); + } + return mixerEntity; + } + + public override void SetMixerGain(Entity mixer, float gain) + { + base.SetMixerGain(mixer, gain); + if (mixer.Comp.Mixer.GainCVar is { } cvar) + { + CfgManager.SetCVar(cvar, gain); + } + else + { + mixer.Comp.Mixer.SelfGain = gain; + } + } + + public override void SetMixerGainCVar(Entity mixer, string? name) + { + base.SetMixerGainCVar(mixer, name); + _audioMixersManager.SetMixerGainCVar(mixer.Comp.Mixer, name); + } + + protected override Entity SpawnMixerForPrototype(ProtoId mixerProtoId) + { + var mixer = base.SpawnMixerForPrototype(mixerProtoId); + mixer.Comp.Mixer = _audioMixersManager.GetMixer(mixerProtoId) ?? mixer.Comp.Mixer; + return mixer; + } + + private void OnMixerAdd(Entity mixer, ref ComponentAdd args) + { + mixer.Comp.Mixer = _audioMixersManager.CreateMixer(); + } + + protected override void OnMixerShutdown(Entity mixer, ref ComponentShutdown args) + { + base.OnMixerShutdown(mixer, ref args); + DisposeMixer(mixer.Comp.Mixer); + } + + protected override void OnHandleState(Entity mixer, ref ComponentHandleState args) + { + base.OnHandleState(mixer, ref args); + + if (mixer.Comp.ProtoId is { } protoId + && protoId != mixer.Comp.Mixer.ProtoId + && _audioMixersManager.GetMixer(protoId) is { } newMixer) + { + DisposeMixer(mixer.Comp.Mixer); + mixer.Comp.Mixer = newMixer; + } + SetMixerGainCVar(mixer, mixer.Comp.GainCVar); + if (mixer.Comp.ProtoId is null) + { + Entity? outMixer = mixer.Comp.OutEntity is { } outMixerOwner + && TryComp(outMixerOwner, out var outMixerComponent) + ? new Entity(outMixerOwner, outMixerComponent) : null; + SetMixerOut(mixer, outMixer); + } + } + + private void DisposeMixer(IAudioMixer mixer) + { + // We don't want to dispose mixers from prototypes cos they are supposed to be re-used. + if (mixer.ProtoId is { }) + return; + mixer.Dispose(); + } +} diff --git a/Robust.Client/Audio/AudioSystem.cs b/Robust.Client/Audio/AudioSystem.cs index 5dfd954bb4c..ed8582d8cbf 100644 --- a/Robust.Client/Audio/AudioSystem.cs +++ b/Robust.Client/Audio/AudioSystem.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Numerics; +using Robust.Client.Audio.Sources; using Robust.Client.GameObjects; using Robust.Client.Graphics; using Robust.Client.Player; @@ -134,6 +135,15 @@ private void OnAudioState(EntityUid uid, AudioComponent component, ref AfterAuto component.Source.SetAuxiliary(null); } + if (TryComp(component.Mixer, out var mixerComp)) + { + component.Source.SetMixer(mixerComp.Mixer); + } + else + { + ApplyAudioParamsMixer((uid, component), component.Params); + } + switch (component.State) { case AudioState.Playing: @@ -219,12 +229,20 @@ private void SetupSource(Entity entity, AudioResource audioResou } else { - component.Source = newSource; + component.Source = new MixableAudioSource(newSource); } } // Need to set all initial data for first frame. ApplyAudioParams(component.Params, component); + if (TryComp(component.Mixer, out var mixerComp)) + { + component.Source.SetMixer(mixerComp.Mixer); + } + else + { + ApplyAudioParamsMixer(entity, component.Params); + } component.Source.Global = component.Global; // Don't play until first frame so occlusion etc. are correct. @@ -245,6 +263,7 @@ private void OnAudioShutdown(EntityUid uid, AudioComponent component, ComponentS { // Breaks with prediction? component.Source.Dispose(); + ClearMixer((uid, component)); RemoveAudioLimit(component.FileName); } diff --git a/Robust.Client/Audio/Midi/IMidiManager.cs b/Robust.Client/Audio/Midi/IMidiManager.cs index a171564cc2a..89ca292a05d 100644 --- a/Robust.Client/Audio/Midi/IMidiManager.cs +++ b/Robust.Client/Audio/Midi/IMidiManager.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using NFluidsynth; using Robust.Shared.Audio.Midi; +using Robust.Shared.Audio.Mixers; namespace Robust.Client.Audio.Midi; @@ -21,6 +22,11 @@ public interface IMidiManager /// float Gain { get; set; } + /// + /// Audio mixer to play with. + /// + IAudioMixer? Mixer { get; set; } + /// /// This method tries to return a midi renderer ready to be used. /// You only need to set the afterwards. diff --git a/Robust.Client/Audio/Midi/IMidiRenderer.cs b/Robust.Client/Audio/Midi/IMidiRenderer.cs index 483694faa7c..287ff525a1b 100644 --- a/Robust.Client/Audio/Midi/IMidiRenderer.cs +++ b/Robust.Client/Audio/Midi/IMidiRenderer.cs @@ -23,6 +23,11 @@ public interface IMidiRenderer : IDisposable /// internal IBufferedAudioSource Source { get; } + /// + /// Mixable audio source reference to apply mixing. + /// + internal IMixableAudioSource MixableSource { get; } + /// /// Whether this renderer has been disposed or not. /// diff --git a/Robust.Client/Audio/Midi/MidiManager.cs b/Robust.Client/Audio/Midi/MidiManager.cs index 8aa9cafee69..8da825b32b5 100644 --- a/Robust.Client/Audio/Midi/MidiManager.cs +++ b/Robust.Client/Audio/Midi/MidiManager.cs @@ -9,6 +9,7 @@ using Robust.Shared; using Robust.Shared.Asynchronous; using Robust.Shared.Audio.Midi; +using Robust.Shared.Audio.Mixers; using Robust.Shared.Collections; using Robust.Shared.Configuration; using Robust.Shared.ContentPack; @@ -81,6 +82,7 @@ public bool IsAvailable private Thread? _midiThread; private ISawmill _midiSawmill = default!; private float _gain = 0f; + private IAudioMixer? _audioMixer; private bool _volumeDirty = true; // Not reliable until Fluidsynth is initialized! @@ -95,7 +97,21 @@ public float Gain if (MathHelper.CloseToPercent(_gain, clamped)) return; - _cfgMan.SetCVar(CVars.MidiVolume, clamped); + _gain = value; + _volumeDirty = true; + } + } + + [ViewVariables(VVAccess.ReadWrite)] + public IAudioMixer? Mixer + { + get => _audioMixer; + set + { + if (_audioMixer == value) + return; + + _audioMixer = value; _volumeDirty = true; } } @@ -145,12 +161,6 @@ private void InitializeFluidsynth() { if (FluidsynthInitialized || _failedInitialize) return; - _cfgMan.OnValueChanged(CVars.MidiVolume, value => - { - _gain = value; - _volumeDirty = true; - }, true); - _midiSawmill = _logger.GetSawmill("midi"); #if DEBUG _midiSawmill.Level = LogLevel.Debug; @@ -396,7 +406,8 @@ private void UpdateRenderer(IMidiRenderer renderer, MapCoordinates listener) if (_volumeDirty) { - renderer.Source.Gain = Gain; + renderer.MixableSource.Gain = Gain; + renderer.MixableSource.SetMixer(_audioMixer); } if (!renderer.Mono) @@ -429,14 +440,14 @@ private void UpdateRenderer(IMidiRenderer renderer, MapCoordinates listener) // If it's on a different map then just mute it, not pause. if (mapPos.MapId == MapId.Nullspace || mapPos.MapId != listener.MapId) { - renderer.Source.Gain = 0f; + renderer.MixableSource.Gain = 0f; return; } // Was previously muted maybe so try unmuting it? - if (renderer.Source.Gain == 0f) + if (renderer.MixableSource.Gain == 0f) { - renderer.Source.Gain = Gain; + renderer.MixableSource.Gain = Gain; } var worldPos = mapPos.Position; @@ -448,7 +459,7 @@ private void UpdateRenderer(IMidiRenderer renderer, MapCoordinates listener) if (distance > renderer.Source.MaxDistance) { // Still keeps the source playing, just with no volume. - renderer.Source.Gain = 0f; + renderer.MixableSource.Gain = 0f; return; } diff --git a/Robust.Client/Audio/Midi/MidiRenderer.cs b/Robust.Client/Audio/Midi/MidiRenderer.cs index bed5537c38d..f38143bace6 100644 --- a/Robust.Client/Audio/Midi/MidiRenderer.cs +++ b/Robust.Client/Audio/Midi/MidiRenderer.cs @@ -2,6 +2,8 @@ using System.Collections; using JetBrains.Annotations; using NFluidsynth; + +using Robust.Client.Audio.Sources; using Robust.Client.Graphics; using Robust.Shared.Asynchronous; using Robust.Shared.Audio; @@ -56,6 +58,7 @@ internal sealed class MidiRenderer : IMidiRenderer private IMidiRenderer? _master; public MidiRendererState RendererState => _rendererState; public IBufferedAudioSource Source { get; set; } + public IMixableAudioSource MixableSource { get; set; } IBufferedAudioSource IMidiRenderer.Source => Source; [ViewVariables] @@ -263,6 +266,7 @@ internal MidiRenderer(Settings settings, SoundFontLoader soundFontLoader, bool m _midiSawmill = midiSawmill; Source = clydeAudio.CreateBufferedAudioSource(Buffers, true) ?? DummyBufferedAudioSource.Instance; + MixableSource = new MixableAudioSource(Source); Source.SampleRate = SampleRate; _settings = settings; _soundFontLoader = soundFontLoader; diff --git a/Robust.Client/Audio/Mixers/AudioMixer.cs b/Robust.Client/Audio/Mixers/AudioMixer.cs new file mode 100644 index 00000000000..f7a9f7f9b7b --- /dev/null +++ b/Robust.Client/Audio/Mixers/AudioMixer.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.Audio.Mixers; +using Robust.Shared.Prototypes; + +namespace Robust.Client.Audio.Mixers; + +public sealed class AudioMixer : IAudioMixer +{ + public IAudioMixer? Out => _out; + + public float SelfGain + { + get => _selfGain; + set + { + if (_isDisposed) return; + _selfGain = value; + _selfGain = Math.Max(_selfGain, 0); + RecalculateGain(); + } + } + public float Gain { get; private set; } + + public ProtoId? ProtoId { get; } + string? IAudioMixer.GainCVar + { + get => _gainCVar; + set => _gainCVar = _isDisposed ? null : value; + } + + private readonly AudioMixersManager _manager; + private readonly HashSet _subscribers = new(); + private IAudioMixer? _out; + private float _selfGain = 1f; + private string? _gainCVar; + private bool _isDisposed = false; + private bool _isNotifyingSubscribers = false; + + internal AudioMixer(ProtoId? protoId, AudioMixersManager manager) + { + ProtoId = protoId; + _manager = manager; + Recalculate(); + } + + public void Dispose() + { + if (_isDisposed) return; + SetDefaults(); + SetOut(null); + _subscribers.Clear(); + _manager.SetMixerGainCVar(this, null); + _isDisposed = true; + } + + public void Subscribe(IAudioMixerSubscriber subscriber) + { + if (_isDisposed) return; + _subscribers.Add(subscriber); + } + + public void Unsubscribe(IAudioMixerSubscriber subscriber) + { + if (_isDisposed) return; + _subscribers.Remove(subscriber); + } + + public void SetOut(IAudioMixer? outMixer) + { + if (_out == outMixer || outMixer == this || _isDisposed) return; + if (_out is { }) + { + _out.Unsubscribe(this); + } + _out = outMixer; + if (outMixer is { }) + { + outMixer.Subscribe(this); + } + Recalculate(); + } + + void IAudioMixerSubscriber.OnMixerGainChanged(float mixerGain) + { + RecalculateGain(); + } + + void IAudioMixer.OnGainCVarChanged(float value) + { + SelfGain = value; + } + + private void Recalculate() + { + RecalculateGain(); + } + + private void RecalculateGain() + { + if (_isNotifyingSubscribers) + { + _manager.Sawmill.Error($"Audio mixer {ToString()} has a circular output."); + return; + } + var gain = (_out?.Gain ?? 1f) * _selfGain; + Gain = gain; + _isNotifyingSubscribers = true; + foreach (var subscriber in _subscribers) + { + subscriber.OnMixerGainChanged(gain); + } + _isNotifyingSubscribers = false; + } + + private void SetDefaults() + { + _selfGain = 1f; + } + + public override string ToString() + { + return $"{{ Proto: {ProtoId} GainCVar: {_gainCVar} }}"; + } +} diff --git a/Robust.Client/Audio/Mixers/AudioMixersManager.cs b/Robust.Client/Audio/Mixers/AudioMixersManager.cs new file mode 100644 index 00000000000..faf2af2b081 --- /dev/null +++ b/Robust.Client/Audio/Mixers/AudioMixersManager.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using Robust.Shared.Audio.Mixers; +using Robust.Shared.Configuration; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Prototypes; + +namespace Robust.Client.Audio.Mixers; + +public sealed class AudioMixersManager : IAudioMixersManager +{ + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly IConfigurationManager _configurationManager = default!; + [Dependency] private readonly ILogManager _logManager = default!; + + internal ISawmill Sawmill => _sawmill ??= _logManager.GetSawmill("audiomixers"); + + private readonly Dictionary, IAudioMixer> _audioMixers = new(); + private ISawmill? _sawmill; + + public IAudioMixer CreateMixer() + { + return CreateMixer(null); + } + + public IAudioMixer? GetMixer(ProtoId? mixerProtoIdMaybe) + { + return GetMixer(mixerProtoIdMaybe, mixerProtoIdMaybe); + } + + public void SetMixerGainCVar(IAudioMixer mixer, string? name) + { + if (mixer.GainCVar is { }) + { + _configurationManager.UnsubValueChanged(mixer.GainCVar, mixer.OnGainCVarChanged); + } + mixer.GainCVar = name; + if (mixer.GainCVar is { }) + { + _configurationManager.OnValueChanged(mixer.GainCVar, mixer.OnGainCVarChanged, true); + } + } + + private IAudioMixer CreateMixer(ProtoId? mixerProtoIdMaybe) + { + return new AudioMixer(mixerProtoIdMaybe, this); + } + + private IAudioMixer? GetMixer(ProtoId? mixerProtoIdMaybe, ProtoId? originProtoId) + { + if (mixerProtoIdMaybe is not { } protoId) + { + return null; + } + if (_audioMixers.TryGetValue(protoId, out var mixer)) + { + return mixer; + } + if (!_prototypeManager.TryIndex(protoId, out var proto)) + { + return null; + } + mixer = CreateMixer(mixerProtoIdMaybe); + _audioMixers[protoId] = mixer; + if (proto.Out == originProtoId) + { + Sawmill.Error($"Audio mixer prototype {originProtoId} has a circular output."); + } + else if (GetMixer(proto.Out, originProtoId) is { } outMixer) + { + mixer.SetOut(outMixer); + } + mixer.SelfGain = proto.Gain; + SetMixerGainCVar(mixer, proto.GainCVar); + return mixer; + } +} diff --git a/Robust.Client/Audio/Mixers/IAudioMixersManager.cs b/Robust.Client/Audio/Mixers/IAudioMixersManager.cs new file mode 100644 index 00000000000..721427a03d1 --- /dev/null +++ b/Robust.Client/Audio/Mixers/IAudioMixersManager.cs @@ -0,0 +1,14 @@ +using Robust.Shared.Audio.Mixers; +using Robust.Shared.Prototypes; + +namespace Robust.Client.Audio.Mixers; + +/// +/// Public API to manipulate on raw objects. +/// +public interface IAudioMixersManager +{ + IAudioMixer CreateMixer(); + IAudioMixer? GetMixer(ProtoId? mixerProtoIdMaybe); + void SetMixerGainCVar(IAudioMixer mixer, string? name); +} diff --git a/Robust.Client/Audio/Sources/MixableAudioSource.cs b/Robust.Client/Audio/Sources/MixableAudioSource.cs new file mode 100644 index 00000000000..ce98a20a747 --- /dev/null +++ b/Robust.Client/Audio/Sources/MixableAudioSource.cs @@ -0,0 +1,172 @@ +using System.Numerics; +using Robust.Shared.Audio.Effects; +using Robust.Shared.Audio.Mixers; +using Robust.Shared.Audio.Sources; +using Robust.Shared.Audio.Systems; +using Robust.Shared.ViewVariables; + +namespace Robust.Client.Audio.Sources; + +public sealed class MixableAudioSource : IMixableAudioSource, IAudioMixerSubscriber +{ + [ViewVariables(VVAccess.ReadOnly)] + private readonly IAudioSource _innerSource; + [ViewVariables(VVAccess.ReadOnly)] + private IAudioMixer? _mixer; + [ViewVariables] + private float _selfGain = 1f; + + private bool _isDisposed = false; + + public MixableAudioSource(IAudioSource innerSource) + { + _innerSource = innerSource; + _selfGain = innerSource.Gain; + } + + public bool Playing + { + get => _innerSource.Playing; + set => _innerSource.Playing = value; + } + + public bool Looping + { + get => _innerSource.Looping; + set => _innerSource.Looping = value; + } + + public bool Global + { + get => _innerSource.Global; + set => _innerSource.Global = value; + } + + public Vector2 Position + { + get => _innerSource.Position; + set => _innerSource.Position = value; + } + + public float Pitch + { + get => _innerSource.Pitch; + set => _innerSource.Pitch = value; + } + + public float Volume + { + get + { + var gain = Gain; + var volume = SharedAudioSystem.GainToVolume(gain); + return volume; + } + set => Gain = SharedAudioSystem.VolumeToGain(value); + } + + public float Gain + { + get => _selfGain; + set + { + _selfGain = value; + RecalculateGain(); + } + } + + public float MaxDistance + { + get => _innerSource.MaxDistance; + set => _innerSource.MaxDistance = value; + } + + public float RolloffFactor + { + get => _innerSource.RolloffFactor; + set => _innerSource.RolloffFactor = value; + } + + public float ReferenceDistance + { + get => _innerSource.ReferenceDistance; + set => _innerSource.ReferenceDistance = value; + } + + public float Occlusion + { + get => _innerSource.Occlusion; + set => _innerSource.Occlusion = value; + } + + public float PlaybackPosition + { + get => _innerSource.PlaybackPosition; + set => _innerSource.PlaybackPosition = value; + } + + public Vector2 Velocity + { + get => _innerSource.Velocity; + set => _innerSource.Velocity = value; + } + + public void Pause() + { + _innerSource.Pause(); + } + + public void StartPlaying() + { + _innerSource.StartPlaying(); + } + + public void StopPlaying() + { + _innerSource.StopPlaying(); + } + + public void Restart() + { + _innerSource.Restart(); + } + + public void Dispose() + { + _isDisposed = true; + _mixer?.Unsubscribe(this); + _innerSource.Dispose(); + } + + public void SetAuxiliary(IAuxiliaryAudio? audio) + { + _innerSource.SetAuxiliary(audio); + } + + public void SetMixer(IAudioMixer? mixer) + { + if (_mixer == mixer || _isDisposed) + { + return; + } + _mixer?.Unsubscribe(this); + _mixer = mixer; + _mixer?.Subscribe(this); + Recalculate(); + } + + public void OnMixerGainChanged(float mixerGain) + { + RecalculateGain(); + } + + private void Recalculate() + { + RecalculateGain(); + } + + private void RecalculateGain() + { + _innerSource.Gain = _selfGain * (_mixer?.Gain ?? 1f); + } +} diff --git a/Robust.Client/ClientIoC.cs b/Robust.Client/ClientIoC.cs index e197f433fb7..2526be40b21 100644 --- a/Robust.Client/ClientIoC.cs +++ b/Robust.Client/ClientIoC.cs @@ -1,6 +1,7 @@ using System; using Robust.Client.Audio; using Robust.Client.Audio.Midi; +using Robust.Client.Audio.Mixers; using Robust.Client.Configuration; using Robust.Client.Console; using Robust.Client.Debugging; @@ -102,6 +103,7 @@ public static void RegisterIoC(GameController.DisplayMode mode, IDependencyColle deps.Register(); deps.Register(); deps.Register(); + deps.Register(); switch (mode) { diff --git a/Robust.Server/Audio/AudioSystem.Mixers.cs b/Robust.Server/Audio/AudioSystem.Mixers.cs new file mode 100644 index 00000000000..6c464c00116 --- /dev/null +++ b/Robust.Server/Audio/AudioSystem.Mixers.cs @@ -0,0 +1,22 @@ +using Robust.Shared.Audio.Components; +using Robust.Shared.Audio.Mixers; +using Robust.Shared.GameObjects; + +namespace Robust.Server.Audio; + +public partial class AudioSystem +{ + public override Entity CreateMixerEntity(IAudioMixer mixer) + { + var mixerEntity = base.CreateMixerEntity(mixer); + _pvs.AddGlobalOverride(mixerEntity); + return mixerEntity; + } + + public override Entity CreateMixer(Entity? outMixer) + { + var mixerEntity = base.CreateMixer(outMixer); + _pvs.AddGlobalOverride(mixerEntity); + return mixerEntity; + } +} diff --git a/Robust.Server/Audio/AudioSystem.cs b/Robust.Server/Audio/AudioSystem.cs index 981c01e770e..1c955755cd6 100644 --- a/Robust.Server/Audio/AudioSystem.cs +++ b/Robust.Server/Audio/AudioSystem.cs @@ -39,7 +39,7 @@ public override void Shutdown() private void OnAudioStartup(EntityUid uid, AudioComponent component, ComponentStartup args) { - component.Source = new DummyAudioSource(); + component.Source = new DummyMixableAudioSource(); } public override void SetGridAudio(Entity? entity) diff --git a/Robust.Shared/Audio/AudioParams.cs b/Robust.Shared/Audio/AudioParams.cs index fa7557dece7..432271d66fb 100644 --- a/Robust.Shared/Audio/AudioParams.cs +++ b/Robust.Shared/Audio/AudioParams.cs @@ -1,10 +1,12 @@ -using Robust.Shared.Serialization; +using Robust.Shared.Serialization; using System; using System.Diagnostics.Contracts; +using Robust.Shared.Audio.Mixers; using Robust.Shared.Audio.Systems; using Robust.Shared.Serialization.Manager; using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.GameObjects; +using Robust.Shared.Prototypes; namespace Robust.Shared.Audio { @@ -78,6 +80,12 @@ public float Pitch [DataField] public float? Variation { get; set; } = null; + /// + /// Output mixer to use, if any. + /// + [DataField] + public ProtoId? MixerProto { get; set; } + // For the max distance value: it's 2000 in Godot, but I assume that's PIXELS due to the 2D positioning, // so that's divided by 32 (EyeManager.PIXELSPERMETER). /// @@ -214,5 +222,16 @@ public readonly AudioParams WithPlayOffset(float offset) me.PlayOffsetSeconds = offset; return me; } + + /// + /// Returns a copy of this instance with a mixer set, for easy chaining. + /// + [Pure] + public readonly AudioParams WithMixer(ProtoId? mixerProto) + { + var me = this; + me.MixerProto = mixerProto; + return me; + } } } diff --git a/Robust.Shared/Audio/Components/AudioComponent.cs b/Robust.Shared/Audio/Components/AudioComponent.cs index 374075c84fe..ee2c87cc0ad 100644 --- a/Robust.Shared/Audio/Components/AudioComponent.cs +++ b/Robust.Shared/Audio/Components/AudioComponent.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Numerics; using Robust.Shared.Audio.Effects; +using Robust.Shared.Audio.Mixers; using Robust.Shared.Audio.Sources; using Robust.Shared.Audio.Systems; using Robust.Shared.GameObjects; @@ -17,7 +18,7 @@ namespace Robust.Shared.Audio.Components; /// Stores the audio data for an audio entity. /// [RegisterComponent, NetworkedComponent, AutoGenerateComponentState(true), Access(typeof(SharedAudioSystem))] -public sealed partial class AudioComponent : Component, IAudioSource +public sealed partial class AudioComponent : Component, IMixableAudioSource { [ViewVariables(VVAccess.ReadWrite), AutoNetworkedField, DataField, Access(Other = AccessPermissions.ReadWriteExecute)] public AudioFlags Flags = AudioFlags.None; @@ -70,7 +71,7 @@ public sealed partial class AudioComponent : Component, IAudioSource /// Audio source that interacts with OpenAL. /// [ViewVariables(VVAccess.ReadOnly)] - internal IAudioSource Source = new DummyAudioSource(); + internal IMixableAudioSource Source = DummyMixableAudioSource.Instance; /// /// Auxiliary entity to pass audio to. @@ -78,6 +79,12 @@ public sealed partial class AudioComponent : Component, IAudioSource [DataField, AutoNetworkedField] public EntityUid? Auxiliary; + /// + /// Audio mixer entity to pass audio to. + /// + [DataField, AutoNetworkedField] + public EntityUid? Mixer; + /* * Values for IAudioSource stored on the component and sent to IAudioSource as applicable. * Most of these aren't networked as they double AudioParams data and these just interact with IAudioSource. @@ -247,6 +254,11 @@ void IAudioSource.SetAuxiliary(IAuxiliaryAudio? audio) Source.SetAuxiliary(audio); } + void IMixableAudioSource.SetMixer(IAudioMixer? mixer) + { + Source.SetMixer(mixer); + } + #endregion public void Dispose() diff --git a/Robust.Shared/Audio/Components/AudioMixerComponent.cs b/Robust.Shared/Audio/Components/AudioMixerComponent.cs new file mode 100644 index 00000000000..26fac0587ef --- /dev/null +++ b/Robust.Shared/Audio/Components/AudioMixerComponent.cs @@ -0,0 +1,81 @@ +using System; +using Robust.Shared.Audio.Mixers; +using Robust.Shared.Audio.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; +using Robust.Shared.ViewVariables; + +namespace Robust.Shared.Audio.Components; + +[RegisterComponent, NetworkedComponent, Access(typeof(SharedAudioSystem))] +public sealed partial class AudioMixerComponent : Component, IAudioMixer +{ + public IAudioMixer? Out => Mixer.Out; + + public EntityUid? OutEntity { get; set; } + + public ProtoId? ProtoId { get; set; } + + public float SelfGain + { + get => Mixer.SelfGain; + set => Mixer.SelfGain = value; + } + public float Gain => Mixer.Gain; + + /// + /// Set if you want to control gain of the mixer from the server side. + /// + [Access(Other = AccessPermissions.ReadWrite)] + public bool IsGainSynced { get; set; } = false; + + public string? GainCVar + { + get => Mixer.GainCVar; + set => Mixer.GainCVar = value; + } + + [ViewVariables] + internal IAudioMixer Mixer = new DummyAudioMixer(); + + internal bool IsInitiallySynced { get; set; } = false; + + public void Dispose() { } + + public void Subscribe(IAudioMixerSubscriber subscriber) + { + Mixer.Subscribe(subscriber); + } + + public void Unsubscribe(IAudioMixerSubscriber subscriber) + { + Mixer.Unsubscribe(subscriber); + } + + public void SetOut(IAudioMixer? outMixer) + { + Mixer.SetOut(outMixer); + } + + public void OnMixerGainChanged(float mixerGain) + { + Mixer.OnMixerGainChanged(mixerGain); + } + + void IAudioMixer.OnGainCVarChanged(float value) + { + Mixer.OnGainCVarChanged(value); + } +} + +[Serializable, NetSerializable] +public sealed class AudioMixerComponentState : IComponentState +{ + public NetEntity? OutEntity; + public ProtoId? ProtoId; + public float SelfGain; + public bool IsGainSynced; + public string? GainCVar; +} diff --git a/Robust.Shared/Audio/Mixers/AudioMixerPrototype.cs b/Robust.Shared/Audio/Mixers/AudioMixerPrototype.cs new file mode 100644 index 00000000000..46225500221 --- /dev/null +++ b/Robust.Shared/Audio/Mixers/AudioMixerPrototype.cs @@ -0,0 +1,43 @@ +using Robust.Shared.Audio.Systems; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager.Attributes; + +namespace Robust.Shared.Audio.Mixers; + +/// +/// Preset for creating s from. +/// +[Prototype("audioMixer")] +public sealed class AudioMixerPrototype : IPrototype +{ + [IdDataField] + public string ID { get; } = default!; + + /// + /// Name of the CVar bound to this mixer to store gain value. + /// + [DataField] + public string? GainCVar; + + /// + /// Mixer to pass signal to. + /// + [DataField] + public ProtoId? Out; + + /// + /// Default volume of the mixer, if no is specified. + /// + [DataField] + public float Volume + { + get => SharedAudioSystem.GainToVolume(Gain); + set => Gain = SharedAudioSystem.VolumeToGain(value); + } + + /// + /// Default gain of the mixer, if no is specified. + /// + [DataField] + public float Gain = 1f; +} diff --git a/Robust.Shared/Audio/Mixers/DummyAudioMixer.cs b/Robust.Shared/Audio/Mixers/DummyAudioMixer.cs new file mode 100644 index 00000000000..115cff23806 --- /dev/null +++ b/Robust.Shared/Audio/Mixers/DummyAudioMixer.cs @@ -0,0 +1,36 @@ +using Robust.Shared.Prototypes; + +namespace Robust.Shared.Audio.Mixers; + +internal sealed class DummyAudioMixer : IAudioMixer +{ + public float SelfGain { get; set; } = 1f; + public float Gain => SelfGain; + public IAudioMixer? Out { get; } + public ProtoId? ProtoId { get; } + string? IAudioMixer.GainCVar { get; set; } + + public void Subscribe(IAudioMixerSubscriber subscriber) + { + } + + public void Unsubscribe(IAudioMixerSubscriber subscriber) + { + } + + public void Dispose() + { + } + + public void SetOut(IAudioMixer? outMixer) + { + } + + public void OnMixerGainChanged(float mixerGain) + { + } + + void IAudioMixer.OnGainCVarChanged(float value) + { + } +} diff --git a/Robust.Shared/Audio/Mixers/IAudioMixer.cs b/Robust.Shared/Audio/Mixers/IAudioMixer.cs new file mode 100644 index 00000000000..187cc33670f --- /dev/null +++ b/Robust.Shared/Audio/Mixers/IAudioMixer.cs @@ -0,0 +1,50 @@ +using System; + +using Robust.Shared.Prototypes; + +namespace Robust.Shared.Audio.Mixers; + +/// +/// Controls the parameters of the audio sources to which this mixer is assigned. +/// Mixers can also output signal to other mixers, creating a hierarchy. +/// +public interface IAudioMixer : IAudioMixerSubscriber, IDisposable +{ + /// + /// Mixer to pass signal to. + /// + IAudioMixer? Out { get; } + /// + /// Audio mixer prototype id that is associated with this mixer. + /// + ProtoId? ProtoId { get; } + /// + /// Gain assigned to this mixer before passing to any output mixers. + /// + float SelfGain { get; set; } + /// + /// Gain of this mixer after passing through all output chain. + /// + float Gain { get; } + /// + /// Name of the CVar bound to this mixer to store gain value. + /// + internal string? GainCVar { get; set; } + + /// + /// Subscribes to this mixer instance. + /// + void Subscribe(IAudioMixerSubscriber subscriber); + /// + /// Unsubscribes from this mixer instance. + /// + void Unsubscribe(IAudioMixerSubscriber subscriber); + /// + /// Set specified mixer as an output for this mixer, pass to set as root mixer. + /// + void SetOut(IAudioMixer? outMixer); + /// + /// Called when the value of the gain CVar is changed. + /// + internal void OnGainCVarChanged(float value); +} diff --git a/Robust.Shared/Audio/Mixers/IAudioMixerSubscriber.cs b/Robust.Shared/Audio/Mixers/IAudioMixerSubscriber.cs new file mode 100644 index 00000000000..adcd5eeb16f --- /dev/null +++ b/Robust.Shared/Audio/Mixers/IAudioMixerSubscriber.cs @@ -0,0 +1,12 @@ +namespace Robust.Shared.Audio.Mixers; + +/// +/// Implement this to be able to subscribe to . +/// +public interface IAudioMixerSubscriber +{ + /// + /// This is called from subscribed mixer when its gain is changed. + /// + void OnMixerGainChanged(float mixerGain); +} diff --git a/Robust.Shared/Audio/Sources/DummyMixableAudioSource.cs b/Robust.Shared/Audio/Sources/DummyMixableAudioSource.cs new file mode 100644 index 00000000000..27d4c6312c2 --- /dev/null +++ b/Robust.Shared/Audio/Sources/DummyMixableAudioSource.cs @@ -0,0 +1,12 @@ +using Robust.Shared.Audio.Mixers; + +namespace Robust.Shared.Audio.Sources; + +internal sealed class DummyMixableAudioSource : DummyAudioSource, IMixableAudioSource +{ + public static new DummyMixableAudioSource Instance { get; } = new(); + + public void SetMixer(IAudioMixer? mixer) + { + } +} diff --git a/Robust.Shared/Audio/Sources/IMixableAudioSource.cs b/Robust.Shared/Audio/Sources/IMixableAudioSource.cs new file mode 100644 index 00000000000..8f5bae83312 --- /dev/null +++ b/Robust.Shared/Audio/Sources/IMixableAudioSource.cs @@ -0,0 +1,11 @@ +using Robust.Shared.Audio.Mixers; + +namespace Robust.Shared.Audio.Sources; + +/// +/// with support for . +/// +public interface IMixableAudioSource : IAudioSource +{ + void SetMixer(IAudioMixer? mixer); +} diff --git a/Robust.Shared/Audio/Systems/SharedAudioSystem.Mixers.cs b/Robust.Shared/Audio/Systems/SharedAudioSystem.Mixers.cs new file mode 100644 index 00000000000..e05387eafcd --- /dev/null +++ b/Robust.Shared/Audio/Systems/SharedAudioSystem.Mixers.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.Audio.Components; +using Robust.Shared.Audio.Mixers; +using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; + +namespace Robust.Shared.Audio.Systems; + +public abstract partial class SharedAudioSystem +{ + /// + /// Mixer prototype id that will be used on audio sources without specified mixer. + /// + public ProtoId? DefaultMixer { get; set; } + + private readonly Dictionary, Entity> _audioMixers = new(); + private bool _isMixersStarted; + + protected virtual void InitializeMixers() + { + SubscribeLocalEvent(OnMixerShutdown); + SubscribeLocalEvent(OnGetState); + SubscribeLocalEvent(OnHandleState); + EntityManager.AfterEntityFlush += LoadPrototypedMixers; + } + + protected virtual void UpdateMixers() + { + // Ahhhh, I find this the best way to know when game is ready to spawn entities on startup. + if (!_isMixersStarted) + { + StartMixers(); + } + } + + private void StartMixers() + { + _isMixersStarted = true; + LoadPrototypedMixers(); + } + + /// + /// Creates audio mixer entity wrapper from raw . + /// + public virtual Entity CreateMixerEntity(IAudioMixer mixer) + { + var ent = Spawn(null, MapCoordinates.Nullspace); + var comp = AddComp(ent); + comp.Mixer = mixer; + return (ent, comp); + } + + /// + /// Creates audio mixer. + /// + /// Mixer to set as out for created mixer. + public virtual Entity CreateMixer(Entity? outMixer) + { + var ent = Spawn(null, MapCoordinates.Nullspace); + var comp = AddComp(ent); + SetMixerOut((ent, comp), outMixer); + return (ent, comp); + } + + /// + /// Assigns audio mixer to specified audio source. + /// + public void SetMixer(Entity audio, Entity? mixerOrNone) + { + if (mixerOrNone is { } mixer) + { + SetMixer(audio, mixer); + } + else + { + ClearMixer(audio); + } + } + + /// + /// Assigns audio mixer to specified audio source. + /// + public virtual void SetMixer(Entity audio, Entity mixer) + { + audio.Comp.Mixer = mixer; + audio.Comp.Params.MixerProto = mixer.Comp.ProtoId; + audio.Comp.Source.SetMixer(mixer.Comp.Mixer); + Dirty(audio); + } + + /// + /// Clears audio mixer from specified audio source. + /// + public virtual void ClearMixer(Entity audio) + { + audio.Comp.Mixer = null; + audio.Comp.Params.MixerProto = null; + audio.Comp.Source.SetMixer(null); + Dirty(audio); + } + + /// + /// Returns audio mixer associated with provided mixer prototype id. + /// + public Entity? GetMixer(ProtoId? mixerProtoId) + { + return mixerProtoId.HasValue ? GetMixer(mixerProtoId.Value) : null; + } + + /// + /// Returns audio mixer associated with provided mixer prototype id. + /// + public virtual Entity? GetMixer(ProtoId mixerProtoId) + { + return _audioMixers.TryGetValue(mixerProtoId, out var mixer) ? mixer : null; + } + + /// + /// Set gain value to the audio mixer. + /// + public virtual void SetMixerGain(Entity mixer, float gain) + { + gain = Math.Max(gain, 0); + mixer.Comp.SelfGain = gain; + if (mixer.Comp.IsGainSynced) + Dirty(mixer); + } + + /// + /// Assigns CVar to store mixer gain value. Pass to clear. + /// + public virtual void SetMixerGainCVar(Entity mixer, string? name) + { + mixer.Comp.GainCVar = name; + Dirty(mixer); + } + + /// + /// Set specified mixer as an output for this mixer, pass to set as root mixer. + /// + public virtual void SetMixerOut(Entity mixer, Entity? outMixerOrNone) + { + if (outMixerOrNone is { } outMixer && !TerminatingOrDeleted(outMixer)) + { + mixer.Comp.OutEntity = outMixer; + mixer.Comp.Mixer.SetOut(outMixer.Comp.Mixer); + } + else + { + mixer.Comp.OutEntity = null; + mixer.Comp.Mixer.SetOut(null); + } + Dirty(mixer); + } + + protected virtual Entity SpawnMixerForPrototype(ProtoId mixerProtoId) + { + var mixer = CreateMixer(null); + mixer.Comp.ProtoId = mixerProtoId; + return mixer; + } + + protected void ApplyAudioParamsMixer(Entity audio, AudioParams audioParams) + { + SetMixer(audio, GetMixer(audioParams.MixerProto)); + } + + protected virtual void OnMixerShutdown(Entity mixer, ref ComponentShutdown args) + { + // It is too hard to store all the subscribers to unsubscribe here, so we do this + var query = AllEntityQuery(); + while (query.MoveNext(out var audio)) + { + if (audio.Mixer != mixer.Owner) + continue; + audio.Mixer = null; + audio.Params.MixerProto = null; + } + } + + private void OnGetState(Entity mixer, ref ComponentGetState args) + { + args.State = new AudioMixerComponentState + { + OutEntity = GetNetEntity(mixer.Comp.OutEntity), + ProtoId = mixer.Comp.ProtoId, + IsGainSynced = mixer.Comp.IsGainSynced, + SelfGain = mixer.Comp.SelfGain, + GainCVar = mixer.Comp.GainCVar, + }; + } + + protected virtual void OnHandleState(Entity mixer, ref ComponentHandleState args) + { + if (args.Current is not AudioMixerComponentState state) + return; + + mixer.Comp.OutEntity = EnsureEntity(state.OutEntity, mixer); + mixer.Comp.ProtoId = state.ProtoId; + mixer.Comp.IsGainSynced = state.IsGainSynced; + if (mixer.Comp.IsGainSynced || !mixer.Comp.IsInitiallySynced) + mixer.Comp.SelfGain = state.SelfGain; + mixer.Comp.GainCVar = state.GainCVar; + + mixer.Comp.IsInitiallySynced = true; + } + + private void LoadPrototypedMixers() + { + if (EntityManager.ShuttingDown) + { + return; + } + // Initialization + foreach (var proto in ProtoMan.EnumeratePrototypes()) + { + var mixer = SpawnMixerForPrototype(proto.ID); + _audioMixers[proto.ID] = mixer; + SetMixerGain(mixer, proto.Gain); + SetMixerGainCVar(mixer, proto.GainCVar); + } + // Out setup + foreach (var proto in ProtoMan.EnumeratePrototypes()) + { + if (proto.Out is { } outId) + { + SetMixerOut(_audioMixers[proto.ID], _audioMixers.TryGetValue(outId, out var mixer) ? mixer : null); + } + } + } +} diff --git a/Robust.Shared/Audio/Systems/SharedAudioSystem.cs b/Robust.Shared/Audio/Systems/SharedAudioSystem.cs index 4d79ff0e3d0..664dec8cfc9 100644 --- a/Robust.Shared/Audio/Systems/SharedAudioSystem.cs +++ b/Robust.Shared/Audio/Systems/SharedAudioSystem.cs @@ -53,12 +53,19 @@ public override void Initialize() { base.Initialize(); InitializeEffect(); + InitializeMixers(); ZOffset = CfgManager.GetCVar(CVars.AudioZOffset); Subs.CVar(CfgManager, CVars.AudioZOffset, SetZOffset); SubscribeLocalEvent(OnAudioGetStateAttempt); SubscribeLocalEvent(OnAudioUnpaused); } + public override void Update(float frameTime) + { + base.Update(frameTime); + UpdateMixers(); + } + /// /// Sets the playback position of audio to the specified spot. /// @@ -301,6 +308,7 @@ protected Entity SetupAudio(string? fileName, AudioParams? audio DebugTools.Assert(!string.IsNullOrEmpty(fileName) || length is not null); MetadataSys.SetEntityName(uid, $"Audio ({fileName})", raiseEvents: false); audioParams ??= AudioParams.Default; + audioParams = audioParams.Value.WithMixer(audioParams.Value.MixerProto ?? DefaultMixer); var comp = AddComp(uid); comp.FileName = fileName ?? string.Empty; comp.Params = audioParams.Value; @@ -320,6 +328,8 @@ protected Entity SetupAudio(string? fileName, AudioParams? audio comp.Params.Pitch *= (float) RandMan.NextGaussian(1, comp.Params.Variation.Value); } + ApplyAudioParamsMixer((uid, comp), audioParams.Value); + if (initialize) { EntityManager.InitializeAndStartEntity(uid);