Fix iOS voice capture and screen sharing
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user