Fix iOS background audio pacing
Sleep-paced threads stall for hundreds of milliseconds when iOS coalesces a backgrounded app's wakeups, and the deadline resets that discarded the deficit left the capture backlog queued until its ring overflowed: regular dropouts that worsen the longer the app stays backgrounded. Pace both 20 ms hands-offs from the AVAudioSourceNode render callback instead (mix via Audio.RunCycle with deviceClockedAudio, capture handoff from the ring), wake the UDP sender on a queue signal instead of a 1 ms poll, and let stalled consumers drop their backlog to the buffer target instead of ratcheting it.
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using VoiceCat.Audio;
|
||||
using VoiceCat.Codec;
|
||||
using VoiceCat.Core;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
using VoiceCat.Transport;
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
@@ -28,6 +33,63 @@ public class AudioEngineTests
|
||||
Assert.DoesNotContain("SendAsync(packet.AsMemory", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MediaSenderWakesOnQueueSignalInsteadOfTimedPolling()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Core", "ClientMediaTransport.cs"));
|
||||
|
||||
Assert.Contains("sendReady.WaitOne(", source);
|
||||
Assert.Contains("senderNapping", source);
|
||||
Assert.DoesNotContain("Thread.Sleep(1);", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MediaSenderDeliversQueuedVoicePromptlyWithoutTimedPolling()
|
||||
{
|
||||
// A loopback "relay" answers the binding so the sender's own keepalive pass falls back to
|
||||
// its five second schedule: only the queue signal can then deliver queued voice promptly,
|
||||
// so a lost wake or a polling loop shows up here as a multi-second delay.
|
||||
using var relay = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
relay.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
byte[] key = new byte[32]; Random.Shared.NextBytes(key);
|
||||
using var crypto = new MediaSessionCrypto(new MediaEncryptor(key), new MediaDecryptor(key));
|
||||
await using var transport = new ClientMediaTransport((IPEndPoint)relay.LocalEndPoint!, new byte[16], crypto, CancellationToken.None);
|
||||
|
||||
byte[] datagram = new byte[512];
|
||||
using (var startup = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
SocketReceiveFromResult probe = await relay.ReceiveFromAsync(datagram, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), startup.Token);
|
||||
Assert.Equal(VoiceFrameHeader.Size + 16, probe.ReceivedBytes);
|
||||
byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||
await relay.SendToAsync(keepalive, SocketFlags.None, probe.RemoteEndPoint);
|
||||
}
|
||||
await transport.Bound.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
Assert.True(transport.TrySend(new(MediaFrameType.Voice, VoiceFrameFlags.None, 0, 7, 0, 0), new byte[80]));
|
||||
await ReceiveVoiceAsync(relay, datagram, 1, TimeSpan.FromSeconds(2));
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2));
|
||||
|
||||
// A burst queued while the sender naps must drain in one wake, not one keepalive pass.
|
||||
stopwatch.Restart();
|
||||
for (uint i = 1; i <= 30; i++)
|
||||
Assert.True(transport.TrySend(new(MediaFrameType.Voice, VoiceFrameFlags.None, 0, 7, 0, i * 960), new byte[80]));
|
||||
await ReceiveVoiceAsync(relay, datagram, 30, TimeSpan.FromSeconds(2));
|
||||
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
private static async Task ReceiveVoiceAsync(Socket relay, byte[] datagram, int count, TimeSpan timeout)
|
||||
{
|
||||
int voice = VoiceFrameHeader.Size + 80 + MediaEncryptor.TagSize;
|
||||
using var deadline = new CancellationTokenSource(timeout);
|
||||
while (count > 0)
|
||||
{
|
||||
SocketReceiveFromResult packet = await relay.ReceiveFromAsync(datagram, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), deadline.Token);
|
||||
if (packet.ReceivedBytes == voice) count--;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1000)]
|
||||
[InlineData(1000)]
|
||||
@@ -124,6 +186,31 @@ public class AudioEngineTests
|
||||
Assert.Equal(0, lateStarvation);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(20)]
|
||||
[InlineData(40)]
|
||||
[InlineData(120)]
|
||||
public void AdaptivePcmBufferResynchronizeDropsStallBacklogToTheTarget(int bufferMilliseconds)
|
||||
{
|
||||
var buffer = new AdaptivePcmBuffer(1, bufferMilliseconds, 65_536);
|
||||
short[] chunk = new short[960], output = new short[960];
|
||||
chunk.AsSpan().Fill(1234);
|
||||
|
||||
// A stalled consumer leaves every chunk queued: feed and drain rates are equal in steady
|
||||
// state, so this backlog would otherwise sit at a fixed offset until overflow drops begin.
|
||||
for (int i = 0; i < 50; i++) Assert.True(buffer.TryWrite(chunk));
|
||||
Assert.Equal(50 * 960, buffer.CountFrames);
|
||||
|
||||
buffer.Resynchronize();
|
||||
Assert.Equal(bufferMilliseconds * 48, buffer.CountFrames);
|
||||
Assert.Equal(960, buffer.Read(output));
|
||||
Assert.All(output, value => Assert.Equal(1234, value));
|
||||
|
||||
// At or below the target the resynchronization drops nothing.
|
||||
buffer.Resynchronize();
|
||||
Assert.Equal(bufferMilliseconds * 48 - 960, buffer.CountFrames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneCaptureMissDoesNotRestartTalkspurtButSustainedStarvationDoes()
|
||||
{
|
||||
@@ -282,6 +369,83 @@ public class AudioEngineTests
|
||||
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceClockedRunCyclePacesMixAndCaptureWithNoWorkerThread()
|
||||
{
|
||||
var sent = new List<(uint Timestamp, VoiceFrameFlags Flags)>(512);
|
||||
StreamInfo stream = Stream();
|
||||
using var receive = new AudioEngine((_, _, _, _) => true, false);
|
||||
receive.SetRemoteStreams([new() { Id = 2, ChannelId = 1, Streams = { stream } }], 1, 1);
|
||||
using var send = new AudioEngine((ssrc, timestamp, payload, flags) =>
|
||||
{
|
||||
sent.Add((timestamp, flags));
|
||||
receive.Receive(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload);
|
||||
return true;
|
||||
}, false) { InputMode = AudioInputMode.AlwaysOn, DeviceBufferMilliseconds = 20 };
|
||||
send.AddLocalStream(stream);
|
||||
short[] tone = Tone(); long energy = 0;
|
||||
receive.MixedPcm += pcm => { foreach (short sample in pcm) energy += Math.Abs((int)sample); };
|
||||
|
||||
// Each iteration is one 20 ms render demand quantum: feed the capture handoff, then pull
|
||||
// both mixes, exactly as the iOS render callback drives its device-clocked engine.
|
||||
for (int i = 0; i < 30; i++) { Assert.True(send.FeedPcm(1, tone, 1)); send.RunCycle(); receive.RunCycle(); }
|
||||
Assert.Equal(30, send.GetLocalDiagnostics(1).Cycles);
|
||||
Assert.Equal(0, send.GetLocalDiagnostics(1).StarvedCycles);
|
||||
Assert.Equal(30, sent.Count);
|
||||
Assert.True(energy > 100000);
|
||||
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int i = 0; i < 100; i++) { send.FeedPcm(1, tone, 1); send.RunCycle(); receive.RunCycle(); }
|
||||
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunCycleContainsFailuresInsteadOfThrowingIntoRealtimeCallbacks()
|
||||
{
|
||||
using var engine = new AudioEngine((_, _, _, _) => throw new InvalidOperationException("boom"), false)
|
||||
{ InputMode = AudioInputMode.AlwaysOn, DeviceBufferMilliseconds = 20 };
|
||||
engine.AddLocalStream(Stream());
|
||||
Assert.True(engine.FeedPcm(1, Tone(), 1));
|
||||
engine.RunCycle();
|
||||
Assert.IsType<InvalidOperationException>(engine.Failure);
|
||||
engine.RunCycle();
|
||||
Assert.Equal(1, engine.GetLocalDiagnostics(1).Cycles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeviceClockedClientLeavesMixCadenceToTheAudioCallback()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), $"voicecat-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
await using var paced = new VoiceCatClient(tofuStorePath: Path.Combine(directory, "paced.txt"));
|
||||
await using var pulled = new VoiceCatClient(tofuStorePath: Path.Combine(directory, "pulled.txt"), deviceClockedAudio: true);
|
||||
paced.Audio.AddLocalStream(Stream());
|
||||
pulled.Audio.AddLocalStream(Stream());
|
||||
await Task.Delay(250);
|
||||
Assert.True(paced.Audio.GetLocalDiagnostics(1).Cycles > 0);
|
||||
Assert.Equal(0, pulled.Audio.GetLocalDiagnostics(1).Cycles);
|
||||
pulled.Audio.RunCycle();
|
||||
Assert.Equal(1, pulled.Audio.GetLocalDiagnostics(1).Cycles);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(directory, true); }
|
||||
catch (IOException) { } catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManagedAudioWorkerResynchronizesProducerBacklogAfterStalls()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Audio", "AudioEngine.cs"));
|
||||
string buffer = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Audio", "AdaptivePcmBuffer.cs"));
|
||||
|
||||
Assert.Contains("stream.Input.Resynchronize()", source);
|
||||
Assert.Contains("public void Resynchronize()", buffer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemotePlaybackSettingsAreIndependentForEachUserStream()
|
||||
{
|
||||
|
||||
@@ -132,6 +132,16 @@ public class PublishServerScriptTests
|
||||
Assert.DoesNotContain("Task.Delay(10", screen);
|
||||
Assert.Contains("MaximumCaptureCallbackFrames = 16_384", microphone);
|
||||
Assert.DoesNotContain("Math.Ceiling(4_096 * 48_000", microphone);
|
||||
|
||||
// Both 20 ms hands-offs run from the AVAudioSourceNode render callback: sleep-paced
|
||||
// threads coalesce when the app is backgrounded and glitch the call after several minutes.
|
||||
string model = await File.ReadAllTextAsync(Path.Combine(
|
||||
root, "clients", "apple", "VoiceCat.iOS", "AppModel.cs"));
|
||||
Assert.DoesNotContain("new Thread(", microphone);
|
||||
Assert.DoesNotContain("Thread.Sleep", microphone);
|
||||
Assert.Contains("owner.Audio.RunCycle()", microphone);
|
||||
Assert.Contains("while (microphoneCredit >= 960)", microphone);
|
||||
Assert.Contains("deviceClockedAudio: true", model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user