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;
@@ -81,6 +81,58 @@ public class PublishServerScriptTests
Assert.Contains("<string>$(CURRENT_PROJECT_VERSION)</string>", extensionManifest);
}
[Fact]
public async Task IosScreenSharingDeclaresBackgroundCaptureAndKeepsReplayKitFallback()
{
string root = FindRoot();
string manifest = await File.ReadAllTextAsync(Path.Combine(
root, "clients", "apple", "VoiceCat.iOS", "Info.plist"));
string settings = await File.ReadAllTextAsync(Path.Combine(
root, "clients", "apple", "VoiceCat.iOS", "SettingsController.cs"));
Assert.Contains("<string>screen-capture</string>", manifest);
Assert.Contains("if (IosScreenCapture.IsAvailable)", settings);
Assert.Contains("new RPSystemBroadcastPickerView", settings);
Assert.DoesNotContain("if (OperatingSystem.IsIOSVersionAtLeast(27)) { model.ToggleScreenAudio();", settings);
}
[Fact]
public async Task IosCaptureDoesNotDependOnCoalescedManagedTimers()
{
string root = FindRoot();
string microphone = await File.ReadAllTextAsync(Path.Combine(
root, "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs"));
string screen = await File.ReadAllTextAsync(Path.Combine(
root, "clients", "apple", "VoiceCat.iOS", "BroadcastAudioPump.cs"));
Assert.DoesNotContain("PeriodicTimer", microphone);
Assert.Contains("owner.Audio.FeedPcm(route.StreamId", microphone);
Assert.Contains("new Thread(Run)", screen);
Assert.DoesNotContain("Task.Delay(10", screen);
Assert.Contains("MaximumCaptureCallbackFrames = 16_384", microphone);
Assert.DoesNotContain("Math.Ceiling(4_096 * 48_000", microphone);
}
[Fact]
public async Task IosAudioCommitsInputRouteBeforeChannelsAndVoiceProcessingBeforeConnections()
{
string root = FindRoot();
string router = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "VoiceCat.iOS", "IosAudioRouter.cs"));
string engine = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs"));
Assert.True(router.IndexOf("ApplyInputSelection(session)", StringComparison.Ordinal) <
router.IndexOf("SetActive(true", StringComparison.Ordinal));
Assert.DoesNotContain("session.SetPreferredInputNumberOfChannels", router);
Assert.DoesNotContain("MaximumInputNumberOfChannels", router);
Assert.Contains("SelectedDataSourceId = null; SelectedPolarPattern = AVAudioDataSourcePolarPattern.Unknown", router);
Assert.Contains("ClearStereoPolarPattern(session)", router);
Assert.Contains("CaptureChannels == 2\n ? port.DataSources?.FirstOrDefault", router);
Assert.True(engine.IndexOf("SetVoiceProcessingEnabled", StringComparison.Ordinal) <
engine.IndexOf("next.Connect(source", StringComparison.Ordinal));
Assert.Contains("AVAudioEngine.ConfigurationChangeNotification", engine);
Assert.Contains("ReferenceEquals(engine, next) && !next.Running", engine);
}
[Fact]
public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne()
{