-
Notifications
You must be signed in to change notification settings - Fork 68
AEC in Unity Audio #381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
AEC in Unity Audio #381
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
2e9eee0
The first version is already working in Meet on MacOS
MaxHeimbrock 935c8bb
Code moved to SDK
MaxHeimbrock abeb06d
Created subfolder for audio processing
MaxHeimbrock be5d9c4
Drop the HighPassFilter option and make AudioProcessingModule internal
MaxHeimbrock fd50848
Drop AudioProcessingStats
MaxHeimbrock 148d3e0
Drop remoteAudioGain from the Meet sample
MaxHeimbrock d77a8e6
Some renaming in PlayoutReference
MaxHeimbrock 7a9b621
Address review findings on the Unity-audio AEC path
MaxHeimbrock 0a23b8e
Add guard with fallback when Unity audio system is disabled in settings
MaxHeimbrock 6182d55
Refactor PlayoutReference to simplify the consumer logic
MaxHeimbrock 2438525
Exchanging new native array allocation for every frame for a reusable…
MaxHeimbrock File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
78 changes: 78 additions & 0 deletions
78
Runtime/Scripts/Audio/Processing/AudioProcessingDelayHint.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| using System; | ||
| using System.Runtime.InteropServices; | ||
| using UnityEngine; | ||
|
|
||
| namespace LiveKit | ||
| { | ||
| /// <summary> | ||
| /// Estimates the render-to-capture delay hint handed to | ||
| /// <see cref="AudioProcessingModule.SetStreamDelayMs"/>. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// AEC3 runs its own correlation-based delay estimator; the hint only sets the initial | ||
| /// alignment after a reset, so it has to be in the right ballpark rather than exact. The echo | ||
| /// path in Unity-audio mode is: <see cref="PlayoutReference"/> tap → Unity's output queue (a | ||
| /// few DSP blocks) → device output → air → device input → <c>Microphone</c> clip → the | ||
| /// AudioSource reading that clip → capture probe. Only the DSP block size and, on iOS, the | ||
| /// audio session latencies are readable; the remaining terms are constants. | ||
| /// | ||
| /// Main thread only: reads <see cref="AudioSettings"/>. | ||
| /// </remarks> | ||
| internal static class AudioProcessingDelayHint | ||
| { | ||
| #if UNITY_IOS && !UNITY_EDITOR | ||
| [DllImport("__Internal")] private static extern double LiveKit_AudioSessionOutputLatency(); | ||
| [DllImport("__Internal")] private static extern double LiveKit_AudioSessionInputLatency(); | ||
| [DllImport("__Internal")] private static extern double LiveKit_AudioSessionIOBufferDuration(); | ||
| #endif | ||
|
|
||
| internal const int MinDelayMs = 0; | ||
| internal const int MaxDelayMs = 500; | ||
|
|
||
| /// <summary>Output queue depth assumed between the listener tap and the device, in DSP blocks.</summary> | ||
| internal const int OutputQueueBlocks = 2; | ||
|
|
||
| /// <summary> | ||
| /// How far the AudioSource reading the microphone clip trails the clip's write head. | ||
| /// <see cref="MicrophoneSource"/> starts reading once <c>Microphone.GetPosition</c> first | ||
| /// reports data, polled at 50 ms, and that offset persists for the life of the clip. | ||
| /// </summary> | ||
| internal const int MicrophoneReadBehindMs = 50; | ||
|
|
||
| /// <summary>Device input plus output latency when the platform does not report it.</summary> | ||
| internal const int FallbackDeviceLatencyMs = 30; | ||
|
|
||
| public static int EstimateMs() | ||
| { | ||
| var config = AudioSettings.GetConfiguration(); | ||
| return EstimateMs(config.dspBufferSize, config.sampleRate, PlatformLatencyMs()); | ||
| } | ||
|
|
||
| internal static int EstimateMs(int dspBufferSize, int sampleRate, double deviceLatencyMs) | ||
| { | ||
| var blockMs = sampleRate > 0 ? dspBufferSize * 1000.0 / sampleRate : 0.0; | ||
|
xianshijing-lk marked this conversation as resolved.
|
||
| var estimate = blockMs * OutputQueueBlocks + deviceLatencyMs + MicrophoneReadBehindMs; | ||
| return (int)Math.Round(Math.Min(MaxDelayMs, Math.Max(MinDelayMs, estimate))); | ||
| } | ||
|
|
||
| internal static double PlatformLatencyMs() | ||
| { | ||
| #if UNITY_IOS && !UNITY_EDITOR | ||
| try | ||
| { | ||
| // AVAudioSession reports zero until the session is active, hence the fallback. | ||
| var output = LiveKit_AudioSessionOutputLatency(); | ||
| var input = LiveKit_AudioSessionInputLatency(); | ||
| var ioBuffer = LiveKit_AudioSessionIOBufferDuration(); | ||
| if (output > 0d || input > 0d) | ||
| return (output + input + ioBuffer) * 1000d; | ||
| } | ||
| catch (Exception) | ||
| { | ||
| // Fall through to the constant. | ||
| } | ||
| #endif | ||
| return FallbackDeviceLatencyMs; | ||
| } | ||
| } | ||
| } | ||
11 changes: 11 additions & 0 deletions
11
Runtime/Scripts/Audio/Processing/AudioProcessingDelayHint.cs.meta
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
167 changes: 167 additions & 0 deletions
167
Runtime/Scripts/Audio/Processing/AudioProcessingModule.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| using System; | ||
| using LiveKit.Internal.FFI; | ||
| using LiveKit.Internal.FFI.Requests; | ||
| using LiveKit.Proto; | ||
|
|
||
| namespace LiveKit | ||
| { | ||
| /// <summary> | ||
| /// libwebrtc's <c>AudioProcessingModule</c> (AEC3 echo cancellation, noise suppression, gain | ||
| /// control, high-pass filter), driven over the FFI. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Use this to run echo cancellation over a capture path that does not go through the | ||
| /// platform audio device module (e.g. Unity's <c>Microphone</c>): feed the audio that is | ||
| /// played out of the loudspeaker to <see cref="ProcessReverseStream"/> and the captured | ||
| /// microphone audio to <see cref="ProcessStream"/>, which processes it in place. | ||
| /// | ||
| /// Both accept exactly one 10 ms chunk of interleaved int16 PCM (<see cref="FrameSizeFor"/> | ||
| /// samples per channel) and nothing else. libwebrtc's own contract is a capture thread calling | ||
| /// <see cref="ProcessStream"/> and a render thread calling <see cref="ProcessReverseStream"/>; | ||
| /// the native module is internally synchronised for exactly that split, and the SDK's request | ||
| /// plumbing is safe to use from both. | ||
| /// </remarks> | ||
| internal sealed class AudioProcessingModule : IDisposable | ||
| { | ||
| /// <summary>libwebrtc's <c>kChunkSizeMs</c> — the APM accepts nothing else.</summary> | ||
| public const int ChunkSizeMs = 10; | ||
|
|
||
| /// <summary> | ||
| /// libwebrtc's internal processing rates. The APM resamples any other API rate onto one of | ||
| /// these itself, so this list is diagnostic information — NOT an admission requirement. See | ||
| /// <see cref="IsSupportedApiRate"/>. | ||
| /// </summary> | ||
| private static readonly int[] NativeSampleRates = { 8000, 16000, 32000, 48000 }; | ||
|
|
||
| private readonly FfiHandle _handle; | ||
| private bool _disposed; | ||
|
|
||
| /// <summary>The native handle id, for diagnostics.</summary> | ||
| public ulong Handle => (ulong)_handle.DangerousGetHandle(); | ||
|
|
||
| public AudioProcessingModule( | ||
|
xianshijing-lk marked this conversation as resolved.
|
||
| bool echoCancellerEnabled, | ||
| bool gainControllerEnabled, | ||
| bool highPassFilterEnabled, | ||
| bool noiseSuppressionEnabled) | ||
| { | ||
| using var request = FFIBridge.Instance.NewRequest<NewApmRequest>(); | ||
| var newApm = request.request; | ||
| newApm.EchoCancellerEnabled = echoCancellerEnabled; | ||
| newApm.GainControllerEnabled = gainControllerEnabled; | ||
| newApm.HighPassFilterEnabled = highPassFilterEnabled; | ||
| newApm.NoiseSuppressionEnabled = noiseSuppressionEnabled; | ||
|
|
||
| using var response = request.Send(); | ||
| FfiResponse res = response; | ||
| var owned = res.NewApm?.Apm; | ||
| if (owned?.Handle == null || owned.Handle.Id == 0) | ||
| throw new InvalidOperationException("FFI returned no APM handle"); | ||
|
|
||
| _handle = FfiHandle.FromOwnedHandle(owned.Handle); | ||
| } | ||
|
|
||
| public static bool IsNativeSampleRate(int sampleRate) | ||
| { | ||
| foreach (var rate in NativeSampleRates) | ||
| if (rate == sampleRate) return true; | ||
| return false; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Whether the APM accepts this rate on its API surface. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// The only hard requirement is that one 10 ms chunk is a whole number of samples: both | ||
| /// <see cref="FrameSizeFor"/> here and libwebrtc's own <c>StreamConfig::num_frames()</c> | ||
| /// derive the chunk with integer division, so a rate that is not a multiple of 100 Hz would | ||
| /// short every chunk and drift the two feeds apart. | ||
| /// | ||
| /// A non-native rate is NOT rejected — the rate goes straight into a <c>StreamConfig</c> | ||
| /// and libwebrtc resamples to a native processing rate internally. A 24 kHz output rate | ||
| /// (iPad) is cancelled just as well as 48 kHz. | ||
| /// </remarks> | ||
| public static bool IsSupportedApiRate(int sampleRate) => | ||
| sampleRate > 0 && sampleRate % (1000 / ChunkSizeMs) == 0; | ||
|
|
||
| /// <summary>Samples per channel in one APM chunk at the given rate.</summary> | ||
| public static int FrameSizeFor(int sampleRate) => sampleRate / (1000 / ChunkSizeMs); | ||
|
|
||
| /// <summary> | ||
| /// Processes the near-end (capture) stream in place. <paramref name="byteCount"/> is bytes, | ||
| /// not samples — the buffer is interleaved int16. Returns the FFI error, or null on success. | ||
| /// </summary> | ||
| public string ProcessStream(IntPtr dataPtr, int byteCount, int sampleRate, int channels) | ||
| { | ||
| if (_disposed) throw new ObjectDisposedException(nameof(AudioProcessingModule)); | ||
|
|
||
| using var request = FFIBridge.Instance.NewRequest<ApmProcessStreamRequest>(); | ||
|
xianshijing-lk marked this conversation as resolved.
|
||
| var process = request.request; | ||
| process.ApmHandle = Handle; | ||
| process.DataPtr = (ulong)dataPtr.ToInt64(); | ||
| process.Size = (uint)byteCount; | ||
| process.SampleRate = (uint)sampleRate; | ||
| process.NumChannels = (uint)channels; | ||
|
|
||
| using var response = request.Send(); | ||
| FfiResponse res = response; | ||
| return ErrorOrNull(res.ApmProcessStream?.Error); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Processes the far-end (render) reference stream in place. Same buffer contract as | ||
| /// <see cref="ProcessStream"/>. | ||
| /// </summary> | ||
| public string ProcessReverseStream(IntPtr dataPtr, int byteCount, int sampleRate, int channels) | ||
|
xianshijing-lk marked this conversation as resolved.
|
||
| { | ||
| if (_disposed) throw new ObjectDisposedException(nameof(AudioProcessingModule)); | ||
|
|
||
| using var request = FFIBridge.Instance.NewRequest<ApmProcessReverseStreamRequest>(); | ||
| var reverse = request.request; | ||
| reverse.ApmHandle = Handle; | ||
| reverse.DataPtr = (ulong)dataPtr.ToInt64(); | ||
| reverse.Size = (uint)byteCount; | ||
| reverse.SampleRate = (uint)sampleRate; | ||
| reverse.NumChannels = (uint)channels; | ||
|
|
||
| using var response = request.Send(); | ||
| FfiResponse res = response; | ||
| return ErrorOrNull(res.ApmProcessReverseStream?.Error); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Seeds the render/capture delay. AEC3 runs its own correlation estimator, so this is a | ||
| /// convergence hint rather than a hard alignment. Returns the FFI error, or null on success. | ||
| /// </summary> | ||
| public string SetStreamDelayMs(int delayMs) | ||
|
xianshijing-lk marked this conversation as resolved.
|
||
| { | ||
| if (_disposed) throw new ObjectDisposedException(nameof(AudioProcessingModule)); | ||
|
|
||
| using var request = FFIBridge.Instance.NewRequest<ApmSetStreamDelayRequest>(); | ||
| var delay = request.request; | ||
| delay.ApmHandle = Handle; | ||
| delay.DelayMs = delayMs; | ||
|
|
||
| using var response = request.Send(); | ||
| FfiResponse res = response; | ||
| return ErrorOrNull(res.ApmSetStreamDelay?.Error); | ||
| } | ||
|
|
||
| private static string ErrorOrNull(string error) => string.IsNullOrEmpty(error) ? null : error; | ||
|
|
||
| public void Dispose() | ||
|
xianshijing-lk marked this conversation as resolved.
|
||
| { | ||
| if (_disposed) return; | ||
| _disposed = true; | ||
| _handle.Dispose(); | ||
| GC.SuppressFinalize(this); | ||
| } | ||
|
|
||
| ~AudioProcessingModule() | ||
| { | ||
| if (_disposed) return; | ||
| _disposed = true; | ||
| _handle.Dispose(); | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.