diff --git a/engine/Sandbox.Engine/Systems/Audio/Mixer.cs b/engine/Sandbox.Engine/Systems/Audio/Mixer.cs index 94c2ff412..c189e6a94 100644 --- a/engine/Sandbox.Engine/Systems/Audio/Mixer.cs +++ b/engine/Sandbox.Engine/Systems/Audio/Mixer.cs @@ -354,6 +354,10 @@ void MixVoice( SoundHandle voice ) } var samples = voice.sampler.GetLastReadSamples(); + + // Apply per-voice audio event processors before any other processing + voice.ApplyEventProcessors( samples ); + var buffer = samples.Get( AudioChannel.Left ); // Store the levels on the sound for use later diff --git a/engine/Sandbox.Engine/Systems/Audio/Processors/AudioEventProcessor.cs b/engine/Sandbox.Engine/Systems/Audio/Processors/AudioEventProcessor.cs new file mode 100644 index 000000000..ade523d69 --- /dev/null +++ b/engine/Sandbox.Engine/Systems/Audio/Processors/AudioEventProcessor.cs @@ -0,0 +1,120 @@ +using System.Runtime.InteropServices; + +namespace Sandbox.Audio; + +/// +/// An audio processor that can be applied to individual sound events/handles. +/// Unlike AudioProcessor which works at the mixer level (processing all mixed sounds), +/// AudioEventProcessor processes samples for a single sound before mixing. +/// +[Expose] +public abstract class AudioEventProcessor +{ + /// + /// Is this processor active? + /// + [Group( "Processor Settings" )] + public bool Enabled { get; set; } = true; + + /// + /// Should we fade the influence of this processor in? + /// + [Range( 0, 1 )] + [Group( "Processor Settings" )] + public float Mix { get; set; } = 1; + + private MultiChannelBuffer scratch; + + /// + /// The sound handle this processor is attached to. + /// + [Hide] + protected SoundHandle Sound { get; private set; } + + /// + /// Called when the processor is attached to a sound handle. + /// + internal void SetSound( SoundHandle sound ) + { + Sound = sound; + OnAttached(); + } + + /// + /// Called when this processor is attached to a sound handle. + /// Override to initialize any state. + /// + protected virtual void OnAttached() + { + } + + /// + /// Should process input into output + /// + internal virtual void Process( MultiChannelBuffer input, MultiChannelBuffer output ) + { + Assert.True( input.ChannelCount <= output.ChannelCount ); + + output.CopyFrom( input ); + ProcessEachChannel( output ); + } + + /// + /// Will process the buffer, and copy it back to output + /// + internal void ProcessInPlace( MultiChannelBuffer inputoutput ) + { + if ( scratch is null || scratch.ChannelCount != inputoutput.ChannelCount ) + { + scratch?.Dispose(); + scratch = new MultiChannelBuffer( inputoutput.ChannelCount ); + } + + scratch.Silence(); + Process( inputoutput, scratch ); + + for ( int i = 0; i < inputoutput.ChannelCount; i++ ) + { + inputoutput.Get( i ).CopyFrom( scratch.Get( i ) ); + } + } + + /// + /// Called internally to process each channel in a buffer + /// + private unsafe void ProcessEachChannel( MultiChannelBuffer buffer ) + { + for ( int i = 0; i < buffer.ChannelCount; i++ ) + { + using ( buffer.Get( i ).DataPointer( out var ptr ) ) + { + Span memory = new Span( (float*)ptr, AudioEngine.MixBufferSize ); + ProcessSingleChannel( new AudioChannel( i ), memory ); + } + } + } + + /// + /// For implementations that process each channel individually. + /// Override this method for simple per-channel effects. + /// + protected virtual unsafe void ProcessSingleChannel( AudioChannel channel, Span samples ) + { + } + + /// + /// Called when the processor is being destroyed or removed. + /// + protected virtual void OnDestroy() + { + } + + internal void DestroyInternal() + { + OnDestroy(); + scratch?.Dispose(); + scratch = null; + } + + public override string ToString() => GetType().Name; +} diff --git a/engine/Sandbox.Engine/Systems/Audio/Processors/EventProcessors/HighPassEventProcessor.cs b/engine/Sandbox.Engine/Systems/Audio/Processors/EventProcessors/HighPassEventProcessor.cs new file mode 100644 index 000000000..84decdc0e --- /dev/null +++ b/engine/Sandbox.Engine/Systems/Audio/Processors/EventProcessors/HighPassEventProcessor.cs @@ -0,0 +1,40 @@ +namespace Sandbox.Audio; + +/// +/// A high-pass filter for individual sound events. +/// Allows frequencies above the cutoff to pass through while attenuating lower frequencies. +/// +[Expose] +public sealed class HighPassEventProcessor : AudioEventProcessor +{ + /// + /// Cutoff frequency of the high-pass filter (0 to 1, where 1 is Nyquist frequency). + /// Higher values = less bass, more treble. + /// + [Range( 0, 1 )] + public float Cutoff { get; set; } = 0.5f; + + private PerChannel _previousInput; + private PerChannel _previousOutput; + + protected override unsafe void ProcessSingleChannel( AudioChannel channel, Span samples ) + { + if ( samples.Length == 0 ) + return; + + float alpha = Cutoff; + float prevIn = _previousInput.Get( channel ); + float prevOut = _previousOutput.Get( channel ); + + for ( int i = 0; i < samples.Length; i++ ) + { + float current = samples[i]; + samples[i] = prevOut + alpha * (current - prevIn); + prevIn = current; + prevOut = samples[i]; + } + + _previousInput.Set( channel, prevIn ); + _previousOutput.Set( channel, prevOut ); + } +} diff --git a/engine/Sandbox.Engine/Systems/Audio/SoundHandle.Processors.cs b/engine/Sandbox.Engine/Systems/Audio/SoundHandle.Processors.cs new file mode 100644 index 000000000..2dfaeb498 --- /dev/null +++ b/engine/Sandbox.Engine/Systems/Audio/SoundHandle.Processors.cs @@ -0,0 +1,167 @@ +using Sandbox.Audio; + +namespace Sandbox; + +partial class SoundHandle +{ + /// + /// List of audio processors applied to this sound. + /// + private List _processors; + + /// + /// Scratch buffer for processor mixing. + /// + private MultiChannelBuffer _processorBuffer; + + /// + /// Add an audio processor to this sound handle. + /// Processors are applied in order before the sound is mixed. + /// + public void AddProcessor( AudioEventProcessor processor ) + { + if ( processor is null ) + return; + + lock ( this ) + { + _processors ??= new List(); + _processors.Add( processor ); + processor.SetSound( this ); + } + } + + /// + /// Remove an audio processor from this sound handle. + /// + public void RemoveProcessor( AudioEventProcessor processor ) + { + if ( processor is null ) + return; + + lock ( this ) + { + if ( _processors?.Remove( processor ) == true ) + { + processor.DestroyInternal(); + } + } + } + + /// + /// Remove all audio processors from this sound handle. + /// + public void ClearProcessors() + { + lock ( this ) + { + if ( _processors is null ) + return; + + foreach ( var processor in _processors ) + { + processor.DestroyInternal(); + } + + _processors.Clear(); + } + } + + /// + /// Get a copy of the current processor list. + /// + public AudioEventProcessor[] GetProcessors() + { + lock ( this ) + { + return _processors?.ToArray() ?? Array.Empty(); + } + } + + /// + /// Get the first processor of a specific type. + /// + public T GetProcessor() where T : AudioEventProcessor + { + lock ( this ) + { + return _processors?.OfType().FirstOrDefault(); + } + } + + /// + /// Check if this sound has any processors attached. + /// + internal bool HasProcessors => _processors is not null && _processors.Count > 0; + + /// + /// Apply all processors to the sample buffer. + /// Called from the mixing thread before spatialization. + /// + internal void ApplyEventProcessors( MultiChannelBuffer samples ) + { + if ( _processors is null || _processors.Count == 0 ) + return; + + lock ( this ) + { + foreach ( var processor in _processors ) + { + if ( !processor.Enabled ) + continue; + + if ( processor.Mix <= 0 ) + continue; + + try + { + if ( processor.Mix >= 1.0f ) + { + // Full wet - process in place + processor.ProcessInPlace( samples ); + } + else + { + // Mix dry/wet + if ( _processorBuffer is null || _processorBuffer.ChannelCount != samples.ChannelCount ) + { + _processorBuffer?.Dispose(); + _processorBuffer = new MultiChannelBuffer( samples.ChannelCount ); + } + + _processorBuffer.CopyFrom( samples ); + processor.ProcessInPlace( _processorBuffer ); + + // Blend: output = dry * (1 - mix) + wet * mix + samples.Scale( 1.0f - processor.Mix ); + samples.MixFrom( _processorBuffer, processor.Mix ); + } + } + catch ( Exception e ) + { + Log.Warning( e, $"Exception running event processor: {processor} - {e.Message}" ); + } + } + } + } + + /// + /// Clean up processor resources when sound is disposed. + /// + private void DisposeProcessors() + { + if ( _processors is not null ) + { + foreach ( var processor in _processors ) + { + processor.DestroyInternal(); + } + + _processors.Clear(); + _processors = null; + } + + _processorBuffer?.Dispose(); + _processorBuffer = null; + } +} diff --git a/engine/Sandbox.Engine/Systems/Audio/SoundHandle.cs b/engine/Sandbox.Engine/Systems/Audio/SoundHandle.cs index 323c3ddcc..0f83d7589 100644 --- a/engine/Sandbox.Engine/Systems/Audio/SoundHandle.cs +++ b/engine/Sandbox.Engine/Systems/Audio/SoundHandle.cs @@ -334,6 +334,7 @@ public void Dispose() _sfx = default; DisposeSources(); + DisposeProcessors(); MainThread.QueueDispose( sampler ); sampler = null;