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;