diff --git a/Runtime/Scripts/ApmReverseStream.cs b/Runtime/Scripts/ApmReverseStream.cs
new file mode 100644
index 00000000..4490729e
--- /dev/null
+++ b/Runtime/Scripts/ApmReverseStream.cs
@@ -0,0 +1,53 @@
+using LiveKit.Internal;
+using UnityEngine;
+
+namespace LiveKit
+{
+ ///
+ /// Captures and processes the reverse audio stream using an .
+ ///
+ ///
+ /// The reverse stream is captured from the scene's audio listener.
+ ///
+ internal class ApmReverseStream
+ {
+ private readonly AudioBuffer _captureBuffer = new AudioBuffer();
+ private readonly AudioProcessingModule _apm; // APM is thread safe
+ private AudioFilter _audioFilter;
+
+ internal ApmReverseStream(AudioProcessingModule apm)
+ {
+ _apm = apm;
+ }
+
+ internal void Start()
+ {
+ var audioListener = GameObject.FindObjectOfType();
+ if (audioListener == null)
+ {
+ Utils.Error("AudioListener not found in scene");
+ return;
+ }
+ _audioFilter = audioListener.gameObject.AddComponent();
+ _audioFilter.AudioRead += OnAudioRead;
+ }
+
+ internal void Stop()
+ {
+ if (_audioFilter != null)
+ Object.Destroy(_audioFilter);
+ }
+
+ private void OnAudioRead(float[] data, int channels, int sampleRate)
+ {
+ _captureBuffer.Write(data, (uint)channels, (uint)sampleRate);
+ while (true)
+ {
+ using var frame = _captureBuffer.ReadDuration(AudioProcessingModule.FRAME_DURATION_MS);
+ if (frame == null) break;
+
+ _apm.ProcessReverseStream(frame);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Runtime/Scripts/ApmReverseStream.cs.meta b/Runtime/Scripts/ApmReverseStream.cs.meta
new file mode 100644
index 00000000..0f388abd
--- /dev/null
+++ b/Runtime/Scripts/ApmReverseStream.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4075a37ec813b43249c214c09e5aba2a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/Scripts/AudioFrame.cs b/Runtime/Scripts/AudioFrame.cs
index 5b3441a5..7496d338 100644
--- a/Runtime/Scripts/AudioFrame.cs
+++ b/Runtime/Scripts/AudioFrame.cs
@@ -3,6 +3,8 @@
using LiveKit.Internal;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
+using System.Runtime.CompilerServices;
+[assembly: InternalsVisibleTo("Tests")]
namespace LiveKit
{
diff --git a/Runtime/Scripts/AudioProcessingModule.cs b/Runtime/Scripts/AudioProcessingModule.cs
new file mode 100644
index 00000000..d80c3c70
--- /dev/null
+++ b/Runtime/Scripts/AudioProcessingModule.cs
@@ -0,0 +1,135 @@
+using LiveKit.Proto;
+using LiveKit.Internal.FFIClients.Requests;
+using LiveKit.Internal;
+using System;
+using System.Runtime.CompilerServices;
+[assembly: InternalsVisibleTo("Tests")]
+
+namespace LiveKit
+{
+ ///
+ /// Provides WebRTC audio processing capabilities including echo cancellation, noise suppression,
+ /// high-pass filtering, and gain control.
+ ///
+ public sealed class AudioProcessingModule
+ {
+ internal readonly FfiHandle Handle;
+
+ ///
+ /// Initializes an instance with the specified audio processing features.
+ ///
+ /// Whether to enable echo cancellation.
+ /// Whether to enable noise suppression.
+ /// Whether to enable high-pass filtering.
+ /// Whether to enable gain control.
+ public AudioProcessingModule(
+ bool echoCancellationEnabled,
+ bool noiseSuppressionEnabled,
+ bool highPassFilterEnabled,
+ bool gainControllerEnabled)
+ {
+ using var request = FFIBridge.Instance.NewRequest();
+ var newApm = request.request;
+ newApm.EchoCancellerEnabled = echoCancellationEnabled;
+ newApm.NoiseSuppressionEnabled = noiseSuppressionEnabled;
+ newApm.HighPassFilterEnabled = highPassFilterEnabled;
+ newApm.GainControllerEnabled = gainControllerEnabled;
+
+ using var response = request.Send();
+ FfiResponse res = response;
+ Handle = FfiHandle.FromOwnedHandle(res.NewApm.Apm.Handle);
+ }
+
+ ///
+ /// Process the provided audio frame using the configured audio processing features.
+ ///
+ /// The audio frame to process.
+ ///
+ /// Important: Audio frames must be exactly 10 ms in duration.
+ ///
+ /// The input audio frame is modified in-place (if applicable) by the underlying audio
+ /// processing module (e.g., echo cancellation, noise suppression, etc.).
+ ///
+ public void ProcessStream(AudioFrame data)
+ {
+ using var request = FFIBridge.Instance.NewRequest();
+ var processStream = request.request;
+ processStream.ApmHandle = (ulong)Handle.DangerousGetHandle();
+ processStream.DataPtr = (ulong)data.Data;
+ processStream.Size = (uint)data.Length;
+ processStream.SampleRate = data.SampleRate;
+ processStream.NumChannels = data.NumChannels;
+
+ using var response = request.Send();
+ FfiResponse res = response;
+ if (res.ApmProcessStream.HasError)
+ {
+ throw new Exception(res.ApmProcessStream.Error);
+ }
+ }
+
+ ///
+ /// Process the reverse audio frame (typically used for echo cancellation in a full-duplex setup).
+ ///
+ /// The audio frame to process.
+ ///
+ /// Important: Audio frames must be exactly 10 ms in duration.
+ ///
+ /// In an echo cancellation scenario, this method is used to process the "far-end" audio
+ /// prior to mixing or feeding it into the echo canceller. Like , the
+ /// input audio frame is modified in-place by the underlying processing module.
+ ///
+ public void ProcessReverseStream(AudioFrame data)
+ {
+ using var request = FFIBridge.Instance.NewRequest();
+ var processReverseStream = request.request;
+ processReverseStream.ApmHandle = (ulong)Handle.DangerousGetHandle();
+ processReverseStream.DataPtr = (ulong)data.Data;
+ processReverseStream.Size = (uint)data.Length;
+ processReverseStream.SampleRate = data.SampleRate;
+ processReverseStream.NumChannels = data.NumChannels;
+
+ using var response = request.Send();
+ FfiResponse res = response;
+ if (res.ApmProcessReverseStream.HasError)
+ {
+ throw new Exception(res.ApmProcessReverseStream.Error);
+ }
+ }
+
+ ///
+ /// This must be called if and only if echo processing is enabled.
+ ///
+ ///
+ /// Sets the `delay` in milliseconds between receiving a far-end frame in
+ /// and receiving the corresponding echo in a near-end frame in .
+ ///
+ /// The delay can be calculated as: delay = (t_render - t_analyze) + (t_process - t_capture)
+ ///
+ /// Where:
+ /// - t_analyze: Time when frame is passed to
+ /// - t_render: Time when first sample of frame is rendered by audio hardware
+ /// - t_capture: Time when first sample of frame is captured by audio hardware
+ /// - t_process: Time when frame is passed to
+ ///
+ public void SetStreamDelayMs(int delayMs)
+ {
+ using var request = FFIBridge.Instance.NewRequest();
+ var setStreamDelay = request.request;
+ setStreamDelay.ApmHandle = (ulong)Handle.DangerousGetHandle();
+ setStreamDelay.DelayMs = delayMs;
+
+ using var response = request.Send();
+ FfiResponse res = response;
+ if (res.ApmSetStreamDelay.HasError)
+ {
+ throw new Exception(res.ApmSetStreamDelay.Error);
+ }
+ }
+
+ ///
+ /// The required duration for audio frames being processed.
+ ///
+ public const uint FRAME_DURATION_MS = 10;
+ }
+}
\ No newline at end of file
diff --git a/Runtime/Scripts/AudioProcessingModule.cs.meta b/Runtime/Scripts/AudioProcessingModule.cs.meta
new file mode 100644
index 00000000..25a82999
--- /dev/null
+++ b/Runtime/Scripts/AudioProcessingModule.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bf605ac440b124e0f80a3a695dc7008a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/Scripts/Internal/AudioBuffer.cs b/Runtime/Scripts/Internal/AudioBuffer.cs
new file mode 100644
index 00000000..9a9d1eeb
--- /dev/null
+++ b/Runtime/Scripts/Internal/AudioBuffer.cs
@@ -0,0 +1,94 @@
+using System;
+using LiveKit.Internal;
+
+namespace LiveKit
+{
+ ///
+ /// A ring buffer for audio samples.
+ ///
+ internal class AudioBuffer
+ {
+ private readonly uint _bufferDurationMs;
+ private RingBuffer _buffer;
+ private uint _channels;
+ private uint _sampleRate;
+
+ ///
+ /// Initializes a new audio sample buffer for holding samples for a given duration.
+ ///
+ internal AudioBuffer(uint bufferDurationMs = 200)
+ {
+ _bufferDurationMs = bufferDurationMs;
+ }
+
+ ///
+ /// Write audio samples.
+ ///
+ ///
+ /// The float data will be converted to short format before being written to the buffer.
+ /// If the number of channels or sample rate changes, the buffer will be recreated.
+ ///
+ /// The audio samples to write.
+ /// The number of channels in the audio data.
+ /// The sample rate of the audio data in Hz.
+ internal void Write(float[] data, uint channels, uint sampleRate)
+ {
+ static short FloatToS16(float v)
+ {
+ v *= 32768f;
+ v = Math.Min(v, 32767f);
+ v = Math.Max(v, -32768f);
+ return (short)(v + Math.Sign(v) * 0.5f);
+ }
+
+ var s16Data = new short[data.Length];
+ for (int i = 0; i < data.Length; i++)
+ {
+ s16Data[i] = FloatToS16(data[i]);
+ }
+ Capture(s16Data, channels, sampleRate);
+ }
+
+ private void Capture(short[] data, uint channels, uint sampleRate)
+ {
+ if (_buffer == null || channels != _channels || sampleRate != _sampleRate)
+ {
+ var size = (int)(channels * sampleRate * (_bufferDurationMs / 1000f));
+ _buffer?.Dispose();
+ _buffer = new RingBuffer(size * sizeof(short));
+ _channels = channels;
+ _sampleRate = sampleRate;
+ }
+ unsafe
+ {
+ fixed (short* pData = data)
+ {
+ var byteData = new ReadOnlySpan(pData, data.Length * sizeof(short));
+ _buffer.Write(byteData);
+ }
+ }
+ }
+
+ ///
+ /// Reads a frame that is the length of the given duration.
+ ///
+ /// The duration of the audio samples to read in milliseconds.
+ /// An AudioFrame containing the read audio samples or if there is not enough samples, null.
+ internal AudioFrame ReadDuration(uint durationMs)
+ {
+ if (_buffer == null) return null;
+
+ var samplesForDuration = (uint)(_sampleRate * (durationMs / 1000f));
+ var requiredLength = samplesForDuration * _channels * sizeof(short);
+ if (_buffer.AvailableRead() < requiredLength) return null;
+
+ var frame = new AudioFrame(_sampleRate, _channels, samplesForDuration);
+ unsafe
+ {
+ var frameData = new Span(frame.Data.ToPointer(), frame.Length);
+ _buffer.Read(frameData);
+ }
+ return frame;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Runtime/Scripts/Internal/AudioBuffer.cs.meta b/Runtime/Scripts/Internal/AudioBuffer.cs.meta
new file mode 100644
index 00000000..fff9d644
--- /dev/null
+++ b/Runtime/Scripts/Internal/AudioBuffer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0bf6612ef779c46b89010720549346d4
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/Scripts/Internal/FFIClient.cs b/Runtime/Scripts/Internal/FFIClient.cs
index 9e8a1065..d037a3bb 100644
--- a/Runtime/Scripts/Internal/FFIClient.cs
+++ b/Runtime/Scripts/Internal/FFIClient.cs
@@ -46,6 +46,7 @@ internal sealed class FfiClient : IFFIClient
// participant events are not allowed in the fii protocol public event ParticipantEventReceivedDelegate ParticipantEventReceived;
public event VideoStreamEventReceivedDelegate? VideoStreamEventReceived;
public event AudioStreamEventReceivedDelegate? AudioStreamEventReceived;
+ public event CaptureAudioFrameReceivedDelegate? CaptureAudioFrameReceived;
public event PerformRpcReceivedDelegate? PerformRpcReceived;
@@ -287,6 +288,7 @@ static unsafe void FFICallback(UIntPtr data, UIntPtr size)
Instance.AudioStreamEventReceived?.Invoke(r.AudioStreamEvent!);
break;
case FfiEvent.MessageOneofCase.CaptureAudioFrame:
+ Instance.CaptureAudioFrameReceived?.Invoke(r.CaptureAudioFrame!);
break;
case FfiEvent.MessageOneofCase.PerformRpc:
Instance.PerformRpcReceived?.Invoke(r.PerformRpc!);
diff --git a/Runtime/Scripts/Internal/FFIClients/FFIEvents.cs b/Runtime/Scripts/Internal/FFIClients/FFIEvents.cs
index eb9005aa..94f0b1da 100644
--- a/Runtime/Scripts/Internal/FFIClients/FFIEvents.cs
+++ b/Runtime/Scripts/Internal/FFIClients/FFIEvents.cs
@@ -48,6 +48,8 @@ namespace LiveKit.Internal
internal delegate void SendTextReceivedDelegate(StreamSendTextCallback e);
+ internal delegate void CaptureAudioFrameReceivedDelegate(CaptureAudioFrameCallback e);
+
// Events
internal delegate void RoomEventReceivedDelegate(RoomEvent e);
diff --git a/Runtime/Scripts/Internal/FFIClients/FfiRequestExtensions.cs b/Runtime/Scripts/Internal/FFIClients/FfiRequestExtensions.cs
index 65eb45d4..33898320 100644
--- a/Runtime/Scripts/Internal/FFIClients/FfiRequestExtensions.cs
+++ b/Runtime/Scripts/Internal/FFIClients/FfiRequestExtensions.cs
@@ -90,6 +90,19 @@ public static void Inject(this FfiRequest ffiRequest, T request)
case E2eeRequest e2EeRequest:
ffiRequest.E2Ee = e2EeRequest;
break;
+ // Apm
+ case NewApmRequest newApmRequest:
+ ffiRequest.NewApm = newApmRequest;
+ break;
+ case ApmProcessStreamRequest apmProcessStreamRequest:
+ ffiRequest.ApmProcessStream = apmProcessStreamRequest;
+ break;
+ case ApmProcessReverseStreamRequest apmProcessReverseStreamRequest:
+ ffiRequest.ApmProcessReverseStream = apmProcessReverseStreamRequest;
+ break;
+ case ApmSetStreamDelayRequest apmSetStreamDelayRequest:
+ ffiRequest.ApmSetStreamDelay = apmSetStreamDelayRequest;
+ break;
// Rpc
case RegisterRpcMethodRequest registerRpcMethodRequest:
ffiRequest.RegisterRpcMethod = registerRpcMethodRequest;
diff --git a/Runtime/Scripts/RtcAudioSource.cs b/Runtime/Scripts/RtcAudioSource.cs
index fcdc054e..601619c2 100644
--- a/Runtime/Scripts/RtcAudioSource.cs
+++ b/Runtime/Scripts/RtcAudioSource.cs
@@ -2,8 +2,8 @@
using System.Collections;
using LiveKit.Proto;
using LiveKit.Internal;
-using System.Threading;
using LiveKit.Internal.FFIClients.Requests;
+using UnityEngine;
namespace LiveKit
{
@@ -18,12 +18,12 @@ public enum RtcAudioSourceType
public abstract class RtcAudioSource : IRtcSource
{
public abstract event Action AudioRead;
- public virtual IEnumerator Prepare(float timeout = 0) { yield break; }
+ public virtual IEnumerator Prepare(float timeout = 0) { yield break; }
public abstract void Play();
#if UNITY_IOS
// iOS microphone sample rate is 24k,
- // please make sure when you using
+ // please make sure when you using
// sourceType is AudioSourceMicrophone
public static uint DefaultMirophoneSampleRate = 24000;
@@ -42,8 +42,9 @@ public abstract class RtcAudioSource : IRtcSource
protected AudioSourceInfo _info;
// Possibly used on the AudioThread
- private Thread _readAudioThread;
- private ThreadSafeQueue _frameQueue = new ThreadSafeQueue();
+ private AudioBuffer _captureBuffer = new AudioBuffer();
+ private readonly AudioProcessingModule _apm;
+ private readonly ApmReverseStream _apmReverseStream;
private bool _muted = false;
public override bool Muted => _muted;
@@ -52,22 +53,20 @@ protected RtcAudioSource(int channels = 2, RtcAudioSourceType audioSourceType =
{
_sourceType = audioSourceType;
+ var isMicrophone = audioSourceType == RtcAudioSourceType.AudioSourceMicrophone;
+ _apm = new AudioProcessingModule(isMicrophone, true, true, true);
+ if (isMicrophone)
+ {
+ _apmReverseStream = new ApmReverseStream(_apm);
+ _apm.SetStreamDelayMs(EstimateStreamDelayMs());
+ }
+
using var request = FFIBridge.Instance.NewRequest();
var newAudioSource = request.request;
newAudioSource.Type = AudioSourceType.AudioSourceNative;
newAudioSource.NumChannels = (uint)channels;
- if(_sourceType == RtcAudioSourceType.AudioSourceMicrophone)
- {
- newAudioSource.SampleRate = DefaultMirophoneSampleRate;
- }
- else
- {
- newAudioSource.SampleRate = DefaultSampleRate;
- }
- newAudioSource.Options = request.TempResource();
- newAudioSource.Options.EchoCancellation = true;
- newAudioSource.Options.AutoGainControl = true;
- newAudioSource.Options.NoiseSuppression = true;
+ newAudioSource.SampleRate = isMicrophone ? DefaultMirophoneSampleRate : DefaultSampleRate;
+
using var response = request.Send();
FfiResponse res = response;
_info = res.NewAudioSource.Source.Info;
@@ -83,90 +82,64 @@ public IEnumerator PrepareAndStart()
public void Start()
{
Stop();
- _readAudioThread = new Thread(Update);
- _readAudioThread.Start();
-
+ _apmReverseStream?.Start();
AudioRead += OnAudioRead;
Play();
}
public virtual void Stop()
{
- _readAudioThread?.Abort();
+ _apmReverseStream?.Stop();
AudioRead -= OnAudioRead;
}
- private void Update()
+ private void OnAudioRead(float[] data, int channels, int sampleRate)
{
+ _captureBuffer.Write(data, (uint)channels, (uint)sampleRate);
while (true)
{
- Thread.Sleep(Constants.TASK_DELAY);
- ReadAudio();
- }
- }
+ var frame = _captureBuffer.ReadDuration(AudioProcessingModule.FRAME_DURATION_MS);
+ if (_muted || frame == null) break;
- private void OnAudioRead(float[] data, int channels, int sampleRate)
- {
- var samplesPerChannel = data.Length / channels;
- var frame = new AudioFrame((uint)sampleRate, (uint)channels, (uint)samplesPerChannel);
-
- static short FloatToS16(float v)
- {
- v *= 32768f;
- v = Math.Min(v, 32767f);
- v = Math.Max(v, -32768f);
- return (short)(v + Math.Sign(v) * 0.5f);
+ if (_apm != null) _apm.ProcessStream(frame);
+ Capture(frame);
}
- unsafe
+ // Don't play the audio locally, to avoid echo.
+ if (_sourceType == RtcAudioSourceType.AudioSourceMicrophone)
{
- var frameData = new Span(frame.Data.ToPointer(), frame.Length / sizeof(short));
- for (int i = 0; i < data.Length; i++)
- {
- frameData[i] = FloatToS16(data[i]);
- }
- if (_sourceType == RtcAudioSourceType.AudioSourceMicrophone)
- {
- // Don't play the audio locally, to avoid echo.
- Array.Clear(data, 0, data.Length);
- }
+ Span span = data;
+ span.Clear();
}
- _frameQueue.Enqueue(frame);
}
- private void ReadAudio()
+ private void Capture(AudioFrame frame)
{
- while (_frameQueue.Count > 0)
+ using var request = FFIBridge.Instance.NewRequest();
+ using var audioFrameBufferInfo = request.TempResource();
+
+ var pushFrame = request.request;
+ pushFrame.SourceHandle = (ulong)Handle.DangerousGetHandle();
+
+ pushFrame.Buffer = audioFrameBufferInfo;
+ pushFrame.Buffer.DataPtr = (ulong)frame.Data;
+ pushFrame.Buffer.NumChannels = frame.NumChannels;
+ pushFrame.Buffer.SampleRate = frame.SampleRate;
+ pushFrame.Buffer.SamplesPerChannel = frame.SamplesPerChannel;
+
+ using var response = request.Send();
+ FfiResponse res = response;
+
+ // Frame needs to stay alive until receiving the async callback.
+ var asyncId = res.CaptureAudioFrame.AsyncId;
+ void Callback(CaptureAudioFrameCallback callback)
{
- try
- {
- AudioFrame frame = _frameQueue.Dequeue();
-
- if(_muted)
- {
- continue;
- }
- unsafe
- {
- using var request = FFIBridge.Instance.NewRequest();
- using var audioFrameBufferInfo = request.TempResource();
-
- var pushFrame = request.request;
- pushFrame.SourceHandle = (ulong)Handle.DangerousGetHandle();
-
- pushFrame.Buffer = audioFrameBufferInfo;
- pushFrame.Buffer.DataPtr = (ulong)frame.Data;
- pushFrame.Buffer.NumChannels = frame.NumChannels;
- pushFrame.Buffer.SampleRate = frame.SampleRate;
- pushFrame.Buffer.SamplesPerChannel = frame.SamplesPerChannel;
-
- using var response = request.Send();
- }
- }
- catch (Exception e)
- {
- Utils.Error("Audio Framedata error: " + e.Message);
- }
+ if (callback.AsyncId != asyncId) return;
+ if (callback.HasError)
+ Utils.Error($"Audio capture failed: {callback.Error}");
+ frame.Dispose();
+ FfiClient.Instance.CaptureAudioFrameReceived -= Callback;
}
+ FfiClient.Instance.CaptureAudioFrameReceived += Callback;
}
public override void SetMute(bool muted)
@@ -174,5 +147,13 @@ public override void SetMute(bool muted)
_muted = muted;
}
+ private int EstimateStreamDelayMs()
+ {
+ // TODO: estimate more accurately
+ int bufferLength, numBuffers;
+ int sampleRate = AudioSettings.outputSampleRate;
+ AudioSettings.GetDSPBufferSize(out bufferLength, out numBuffers);
+ return 2 * (int)(1000f * bufferLength * numBuffers / sampleRate);
+ }
}
-}
+}
\ No newline at end of file
diff --git a/Tests/AudioBuffer.cs b/Tests/AudioBuffer.cs
new file mode 100644
index 00000000..4c373ad1
--- /dev/null
+++ b/Tests/AudioBuffer.cs
@@ -0,0 +1,31 @@
+using System;
+using NUnit.Framework;
+
+namespace LiveKit.Tests
+{
+ public class AudioBufferTest
+ {
+ [Test]
+ [TestCase(24000u, 1u, 10u)]
+ [TestCase(48000u, 2u, 10u)]
+ public void TestWriteAndRead(uint sampleRate, uint channels, uint durationMs)
+ {
+ var buffer = new AudioBuffer();
+
+ Assert.IsNull(buffer.ReadDuration(durationMs), "Should not be able to read from empty buffer");
+
+ var samples = TestUtils.GenerateSineWave(channels, sampleRate, durationMs);
+ buffer.Write(samples, channels, sampleRate);
+
+ Assert.IsNull(buffer.ReadDuration(durationMs * 2), "Should not be enough samples for this read");
+
+ var frame = buffer.ReadDuration(durationMs);
+ Assert.IsNotNull(frame);
+ Assert.AreEqual(sampleRate, frame.SampleRate);
+ Assert.AreEqual(channels, frame.NumChannels);
+ Assert.AreEqual(samples.Length / channels, frame.SamplesPerChannel);
+
+ Assert.IsNull(buffer.ReadDuration(durationMs), "Should not be able to read again");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/AudioBuffer.cs.meta b/Tests/AudioBuffer.cs.meta
new file mode 100644
index 00000000..6114b64f
--- /dev/null
+++ b/Tests/AudioBuffer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 28f653860611048a3a9577a638d528f8
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/AudioProcessingModule.cs b/Tests/AudioProcessingModule.cs
new file mode 100644
index 00000000..b6cdf996
--- /dev/null
+++ b/Tests/AudioProcessingModule.cs
@@ -0,0 +1,44 @@
+using System;
+using NUnit.Framework;
+using System.Runtime.InteropServices;
+
+namespace LiveKit.Tests
+{
+ public class AudioProcessingModuleTest
+ {
+ [Test]
+ public void TestAudioProcessing()
+ {
+ var apm = new AudioProcessingModule(true, true, true, true);
+ apm.SetStreamDelayMs(100);
+ apm.ProcessStream(CreateTestFrame());
+ apm.ProcessReverseStream(CreateTestFrame());
+
+ Assert.Throws(() => apm.ProcessStream(CreateInvalidFrame()));
+ Assert.Throws(() => apm.ProcessReverseStream(CreateInvalidFrame()));
+ }
+
+ private AudioFrame CreateTestFrame()
+ {
+ const int SampleRate = 48000;
+ const int NumChannels = 1;
+ const int FramesPerChunk = SampleRate / 100;
+
+ var frame = new AudioFrame(SampleRate, NumChannels, FramesPerChunk);
+
+ var data = new short[frame.SamplesPerChannel * frame.NumChannels];
+ for (int i = 0; i < data.Length; i++)
+ {
+ // Generate a 440Hz sine wave
+ data[i] = (short)(short.MaxValue * Math.Sin(2 * Math.PI * 440 * i / frame.SampleRate));
+ }
+ Marshal.Copy(data, 0, frame.Data, data.Length);
+ return frame;
+ }
+
+ private AudioFrame CreateInvalidFrame()
+ {
+ return new AudioFrame(100, 1, 1);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/AudioProcessingModule.cs.meta b/Tests/AudioProcessingModule.cs.meta
new file mode 100644
index 00000000..a3bfd1b3
--- /dev/null
+++ b/Tests/AudioProcessingModule.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1d02b1160a42044c9a52fb5a1fffbd8d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/TestUtils.cs b/Tests/TestUtils.cs
new file mode 100644
index 00000000..4efac4c1
--- /dev/null
+++ b/Tests/TestUtils.cs
@@ -0,0 +1,29 @@
+using System;
+
+namespace LiveKit.Tests
+{
+ internal static class TestUtils
+ {
+ ///
+ /// Generates a sine wave with the specified parameters.
+ ///
+ /// Number of audio channels.
+ /// Sample rate in Hz.
+ /// Duration in milliseconds.
+ /// Frequency of the sine wave in Hz.
+ /// A float array containing the generated sine wave.
+ internal static float[] GenerateSineWave(uint channels, uint sampleRate, uint durationMs, uint frequency = 440)
+ {
+ var samplesPerChannel = sampleRate * durationMs / 1000;
+ var samples = new float[samplesPerChannel * channels];
+ for (int i = 0; i < samplesPerChannel; i++)
+ {
+ float sampleValue = (float)Math.Sin(2 * Math.PI * frequency * i / sampleRate);
+ for (int channel = 0; channel < channels; channel++)
+ samples[i * channels + channel] = sampleValue;
+ }
+ return samples;
+ }
+ }
+}
+
diff --git a/Tests/TestUtils.cs.meta b/Tests/TestUtils.cs.meta
new file mode 100644
index 00000000..c6089f80
--- /dev/null
+++ b/Tests/TestUtils.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 54e6ea638860b4ebabc52cfa4c96d7bb
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: