namespace MeetingAssistant.Recording; public interface IAcousticEchoCanceller { void Reset(); AudioChunk CleanMicrophone(AudioChunk microphone, AudioChunk systemAudio); } public interface IAcousticEchoCancellerFactory { IAcousticEchoCanceller Create(); } public sealed class AdaptiveFilterAcousticEchoCancellerFactory : IAcousticEchoCancellerFactory { public IAcousticEchoCanceller Create() { return new AdaptiveFilterAcousticEchoCanceller(); } } public sealed class NoopAcousticEchoCanceller : IAcousticEchoCanceller { public void Reset() { } public AudioChunk CleanMicrophone(AudioChunk microphone, AudioChunk systemAudio) { return microphone; } } public sealed class AdaptiveFilterAcousticEchoCanceller : IAcousticEchoCanceller { private const int DefaultFilterLength = 256; private const double DefaultAdaptationStep = 0.25; private const double Epsilon = 1.0; private readonly double[] coefficients; private readonly double[] renderHistory; private int historyPosition; public AdaptiveFilterAcousticEchoCanceller() : this(DefaultFilterLength) { } public AdaptiveFilterAcousticEchoCanceller(int filterLength) { var effectiveFilterLength = Math.Max(16, filterLength); coefficients = new double[effectiveFilterLength]; renderHistory = new double[effectiveFilterLength]; } public void Reset() { Array.Clear(coefficients); Array.Clear(renderHistory); historyPosition = 0; } public AudioChunk CleanMicrophone(AudioChunk microphone, AudioChunk systemAudio) { if (microphone.Pcm.Length == 0 || systemAudio.Pcm.Length == 0 || microphone.SampleRate != systemAudio.SampleRate || microphone.Channels != 1 || systemAudio.Channels != 1) { return microphone; } var byteCount = Math.Min(microphone.Pcm.Length, systemAudio.Pcm.Length); byteCount -= byteCount % sizeof(short); if (byteCount <= 0) { return microphone; } var cleaned = new byte[microphone.Pcm.Length]; for (var byteIndex = 0; byteIndex < byteCount; byteIndex += sizeof(short)) { var render = BitConverter.ToInt16(systemAudio.Pcm, byteIndex) / 32768.0; var capture = BitConverter.ToInt16(microphone.Pcm, byteIndex) / 32768.0; renderHistory[historyPosition] = render; var estimatedEcho = 0.0; var energy = Epsilon; for (var tap = 0; tap < coefficients.Length; tap++) { var sample = renderHistory[HistoryIndex(tap)]; estimatedEcho += coefficients[tap] * sample; energy += sample * sample; } var error = capture - estimatedEcho; var updateScale = DefaultAdaptationStep * error / energy; for (var tap = 0; tap < coefficients.Length; tap++) { coefficients[tap] += updateScale * renderHistory[HistoryIndex(tap)]; } WriteSample(cleaned, byteIndex, error); historyPosition = historyPosition == 0 ? renderHistory.Length - 1 : historyPosition - 1; } if (byteCount < microphone.Pcm.Length) { Buffer.BlockCopy(microphone.Pcm, byteCount, cleaned, byteCount, microphone.Pcm.Length - byteCount); } return microphone with { Pcm = cleaned }; } private int HistoryIndex(int offset) { var index = historyPosition + offset; return index >= renderHistory.Length ? index - renderHistory.Length : index; } private static void WriteSample(byte[] target, int byteIndex, double sample) { var scaled = Math.Clamp( (int)Math.Round(sample * 32768.0), short.MinValue, short.MaxValue); BitConverter.GetBytes((short)scaled).CopyTo(target, byteIndex); } }