Fix iOS voice capture and screen sharing
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

This commit is contained in:
2026-09-21 14:14:15 +02:00
parent 35617e9708
commit 0372baf589
13 changed files with 389 additions and 66 deletions
+83
View File
@@ -7,6 +7,27 @@ namespace VoiceCat.Tests;
public class AudioEngineTests
{
[Fact]
public void ManagedAudioWorkerUsesDeadlineSchedulingAtRealtimePriority()
{
string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Audio", "AudioEngine.cs"));
Assert.Contains("Priority = ThreadPriority.Highest", source);
Assert.Contains("WaitUntil(deadline);", source);
Assert.DoesNotContain("Thread.Sleep((int)Math.Ceiling(remaining))", source);
}
[Fact]
public void MediaSenderDoesNotDependOnCoalescedAsyncTimers()
{
string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Core", "ClientMediaTransport.cs"));
Assert.Contains("new Thread(Send)", source);
Assert.Contains("socket.Send(packet.AsSpan(0, size)", source);
Assert.DoesNotContain("Task.Delay(5", source);
Assert.DoesNotContain("SendAsync(packet.AsMemory", source);
}
[Theory]
[InlineData(-1000)]
[InlineData(1000)]
@@ -29,6 +50,58 @@ public class AudioEngineTests
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
}
[Fact]
public void AdaptivePcmBufferPreservesPartialFrameAcrossCaptureCallbacks()
{
var buffer = new AdaptivePcmBuffer(1, 20);
short[] half = Enumerable.Range(0, 480).Select(value => (short)value).ToArray();
short[] full = new short[960];
Assert.True(buffer.TryWrite(half));
Assert.Equal(0, buffer.Read(full));
Assert.Equal(480, buffer.CountFrames);
Assert.True(buffer.TryWrite(half));
Assert.Equal(960, buffer.Read(full));
Assert.Equal(0, buffer.CountFrames);
Assert.Equal(half, full.AsSpan(0, 480).ToArray());
Assert.Equal(half, full.AsSpan(480, 480).ToArray());
}
[Theory]
[InlineData(20)]
[InlineData(40)]
[InlineData(60)]
public void AdaptivePcmBufferSustainsIosSizedCaptureCallbacks(int bufferMilliseconds)
{
var buffer = new AdaptivePcmBuffer(1, bufferMilliseconds);
short[] callback = new short[1_024], encoded = new short[960];
long producedFrames = 0;
int lateStarvation = 0, fullReads = 0;
// AVAudioEngine commonly delivers 1,024 frames every 21 1/3 ms while the codec owner
// requests 960 frames every 20 ms. Replay those independent clocks for two minutes.
for (long consumerFrame = 0; consumerFrame < 48_000L * 120; consumerFrame += 960)
{
while (producedFrames <= consumerFrame)
{
for (int i = 0; i < callback.Length; i++) callback[i] = (short)(producedFrames + i);
Assert.True(buffer.TryWrite(callback));
producedFrames += callback.Length;
}
int read = buffer.Read(encoded);
if (read == encoded.Length) fullReads++;
// A 20 ms target initially contains one 1,024-frame callback, so the codec's
// second deadline precedes the next callback by 1.33 ms. Re-priming once during
// that first second is expected; recurring starvation is the audible defect.
else if (consumerFrame >= 48_000) lateStarvation++;
}
Assert.Equal(0, lateStarvation);
Assert.True(fullReads > 5_000);
}
[Fact]
public void OneCaptureMissDoesNotRestartTalkspurtButSustainedStarvationDoes()
{
@@ -40,6 +113,8 @@ public class AudioEngineTests
engine.ProcessCycle();
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
Assert.Equal(2, sent.Count); Assert.True((sent[0].Flags & VoiceFrameFlags.Marker) != 0); Assert.Equal(VoiceFrameFlags.None, sent[1].Flags & VoiceFrameFlags.Marker);
LocalAudioDiagnostics diagnostic = engine.GetLocalDiagnostics(1);
Assert.Equal(3, diagnostic.Cycles); Assert.Equal(1, diagnostic.StarvedCycles); Assert.Equal(2, diagnostic.EncodedPackets);
for (int i = 0; i < 10; i++) engine.ProcessCycle();
engine.FeedPcm(1, tone, 1); engine.ProcessCycle();
Assert.True((sent[^1].Flags & VoiceFrameFlags.Marker) != 0);
@@ -193,6 +268,14 @@ public class AudioEngineTests
Assert.True(energy > 100000);
}
private static string FindRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VoiceCat.slnx")))
directory = directory.Parent;
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
}
private sealed class ManualAudioClock : TimeProvider
{
private long milliseconds;