Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Runtime/Scripts/RtcVideoSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public enum VideoStreamSource
private bool _muted = false;
public override bool Muted => _muted;

internal RtcVideoSource(VideoStreamSource sourceType, VideoBufferType bufferType)
protected RtcVideoSource(VideoStreamSource sourceType, VideoBufferType bufferType)
{
_sourceType = sourceType;
_bufferType = bufferType;
Expand Down
829 changes: 829 additions & 0 deletions Tests/PlayMode/LatencyTests.cs

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions Tests/PlayMode/LatencyTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Tests/PlayMode/LiveKit.PlayModeTests.asmdef
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
"nunit.framework.dll",
"Google.Protobuf.dll"
],
"autoReferenced": false,
"defineConstraints": [
Expand Down
89 changes: 89 additions & 0 deletions Tests/PlayMode/Utils/AudioPulseDetector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System;
using UnityEngine;

namespace LiveKit.PlayModeTests.Utils
{
/// <summary>
/// MonoBehaviour that identifies audio pulses by frequency using the Goertzel algorithm.
/// Attach to the same GameObject as an AudioSource after AudioStream has added its
/// AudioProbe, so this filter runs second and sees the filled audio data.
/// </summary>
public class AudioPulseDetector : MonoBehaviour
{
public int TotalPulses;
public double BaseFrequency;
public double FrequencyStep;
public double MagnitudeThreshold;

/// <summary>
/// Fired on the audio thread when a pulse is detected.
/// Parameters: (pulseIndex, magnitude).
/// </summary>
public event Action<int, double> PulseReceived;

private int _sampleRate;

void OnEnable()
{
_sampleRate = AudioSettings.outputSampleRate;
AudioSettings.OnAudioConfigurationChanged += OnAudioConfigChanged;
}

void OnDisable()
{
AudioSettings.OnAudioConfigurationChanged -= OnAudioConfigChanged;
}

void OnAudioConfigChanged(bool deviceWasChanged)
{
_sampleRate = AudioSettings.outputSampleRate;
}

void OnAudioFilterRead(float[] data, int channels)
{
int sampleRate = _sampleRate;
int samples = data.Length / channels;
if (samples == 0) return;

int bestPulse = -1;
double bestMag = 0;

for (int p = 0; p < TotalPulses; p++)
{
double freq = BaseFrequency + p * FrequencyStep;
double mag = Goertzel(data, channels, samples, sampleRate, freq);
if (mag > bestMag)
{
bestMag = mag;
bestPulse = p;
}
}

// Debug.Log($"bestPulse: {bestPulse} | bestMag: {bestMag}");
if (bestPulse >= 0 && bestMag > MagnitudeThreshold)
PulseReceived?.Invoke(bestPulse, bestMag);
}

/// <summary>
/// Goertzel algorithm — computes the magnitude of a single frequency bin.
/// O(N) per frequency, much cheaper than a full FFT.
/// </summary>
static double Goertzel(float[] data, int channels, int N, int sampleRate, double freq)
{
double k = 0.5 + (double)N * freq / sampleRate;
double w = 2.0 * Math.PI * k / N;
double coeff = 2.0 * Math.Cos(w);
double s0 = 0, s1 = 0, s2 = 0;

for (int i = 0; i < N; i++)
{
s0 = data[i * channels] + coeff * s1 - s2;
s2 = s1;
s1 = s0;
}

double power = s1 * s1 + s2 * s2 - coeff * s1 * s2;
return Math.Sqrt(Math.Abs(power)) / N;
}
}
}
11 changes: 11 additions & 0 deletions Tests/PlayMode/Utils/AudioPulseDetector.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions Tests/PlayMode/Utils/TestAudioSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;

namespace LiveKit.PlayModeTests.Utils
{
/// <summary>
/// A programmatic audio source for testing. Allows pushing audio frames
/// directly without requiring a Unity AudioSource or microphone.
/// </summary>
public class TestAudioSource : RtcAudioSource
{
public override event Action<float[], int, int> AudioRead;

public TestAudioSource(int channels = 1)
: base(channels, RtcAudioSourceType.AudioSourceCustom) { }

/// <summary>
/// Push an audio frame into the FFI capture pipeline.
/// Must call Start() first so the base class is subscribed to AudioRead.
/// </summary>
public void PushFrame(float[] data, int channels, int sampleRate)
{
AudioRead?.Invoke(data, channels, sampleRate);
}
}
}
11 changes: 11 additions & 0 deletions Tests/PlayMode/Utils/TestAudioSource.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions Tests/PlayMode/Utils/TestCoroutineRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Collections;
using UnityEngine;

namespace LiveKit.PlayModeTests.Utils
{
/// <summary>
/// Helper for running coroutines from non-MonoBehaviour test code.
/// Creates a temporary GameObject with a MonoBehaviour to host coroutines.
/// </summary>
public class TestCoroutineRunner : MonoBehaviour
{
private static TestCoroutineRunner _instance;

private static TestCoroutineRunner Instance
{
get
{
if (_instance == null)
{
var go = new GameObject("TestCoroutineRunner");
_instance = go.AddComponent<TestCoroutineRunner>();
}
return _instance;
}
}

public static Coroutine Start(IEnumerator routine)
{
return Instance.StartCoroutine(routine);
}

public static void Stop(Coroutine coroutine)
{
if (_instance != null && coroutine != null)
_instance.StopCoroutine(coroutine);
}

public static void Cleanup()
{
if (_instance != null)
{
Destroy(_instance.gameObject);
_instance = null;
}
}
}
}
11 changes: 11 additions & 0 deletions Tests/PlayMode/Utils/TestCoroutineRunner.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Tests/PlayMode/Utils/TestRoomContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ namespace LiveKit.PlayModeTests.Utils
{
public class TestRoomContext : IDisposable
{
public readonly string RoomName = $"unity-test-{Guid.NewGuid()}";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Todo: revert back to random room names.

// public readonly string RoomName = $"unity-test-{Guid.NewGuid()}";
public readonly string RoomName = $"unity-test-max";
public List<Room> Rooms { get; private set; } = new List<Room>();
public string? ConnectionError { get; private set; }

Expand Down
82 changes: 82 additions & 0 deletions Tests/PlayMode/Utils/TestVideoSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using LiveKit.Proto;
using Unity.Collections;

namespace LiveKit.PlayModeTests.Utils
{
/// <summary>
/// A programmatic video source for testing. Encodes a pulse index as a
/// spatial binary pattern: the frame is divided into 4 vertical strips,
/// each either black or white, representing 4 bits of the index.
/// This survives WebRTC lossy compression because detection only needs
/// to distinguish black vs white in large uniform regions.
/// </summary>
public class TestVideoSource : RtcVideoSource
{
private readonly int _width;
private readonly int _height;
private int _pulseIndex = -1;

/// <summary>
/// Number of vertical strips used for binary encoding.
/// 4 strips = 4 bits = values 0–15.
/// </summary>
public const int NumStrips = 4;

public TestVideoSource(int width = 64, int height = 64)
: base(VideoStreamSource.Texture, VideoBufferType.Rgba)
{
_width = width;
_height = height;
base.Init();
}

public override int GetWidth() => _width;
public override int GetHeight() => _height;
protected override VideoRotation GetVideoRotation() => VideoRotation._0;

/// <summary>
/// Set the pulse index to encode. -1 = all black (between pulses).
/// Index is encoded as 4-bit binary across vertical strips.
/// </summary>
public void SetPulseIndex(int index)
{
_pulseIndex = index;
}

protected override bool ReadBuffer()
{
int size = _width * _height * 4;
if (!_captureBuffer.IsCreated || _captureBuffer.Length != size)
{
if (_captureBuffer.IsCreated) _captureBuffer.Dispose();
_captureBuffer = new NativeArray<byte>(size, Allocator.Persistent);
}

int stripWidth = _width / NumStrips;

for (int y = 0; y < _height; y++)
{
for (int x = 0; x < _width; x++)
{
int strip = x / stripWidth;
bool bright = _pulseIndex >= 0 && ((_pulseIndex >> strip) & 1) == 1;
byte value = bright ? (byte)255 : (byte)0;

int offset = (y * _width + x) * 4;
_captureBuffer[offset] = value; // R
_captureBuffer[offset + 1] = value; // G
_captureBuffer[offset + 2] = value; // B
_captureBuffer[offset + 3] = 255; // A
}
}

_requestPending = true;
return false;
}

~TestVideoSource()
{
Dispose(false);
}
}
}
11 changes: 11 additions & 0 deletions Tests/PlayMode/Utils/TestVideoSource.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading