Fix iOS background audio pacing
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

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:
2026-09-23 21:37:24 +02:00
parent 1487f2673b
commit f348574bc1
9 changed files with 273 additions and 42 deletions
+26 -4
View File
@@ -19,6 +19,8 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
private readonly Thread sending;
private readonly Task receiving;
private readonly TaskCompletionSource bound = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AutoResetEvent sendReady = new(false);
private int senderNapping;
internal event EncodedVoiceHandler? Received;
internal Task Bound => bound.Task;
@@ -38,7 +40,14 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
sending.Start();
}
internal bool TrySend(VoiceFrameHeader header, ReadOnlySpan<byte> payload) => packets.TryWrite(header, payload);
internal bool TrySend(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
{
if (!packets.TryWrite(header, payload)) return false;
// Wake the sender only when it is actually waiting. The flag is raised before its final
// queue check, so a write can never slip between that check and the wait unnoticed.
if (Volatile.Read(ref senderNapping) != 0) sendReady.Set();
return true;
}
private void Send()
{
@@ -48,6 +57,7 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
{
while (!stop.IsCancellationRequested)
{
bool drained = false;
try
{
if (Environment.TickCount64 >= nextKeepalive)
@@ -58,6 +68,7 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
}
while (packets.TryRead(plain, out VoiceFrameHeader header, out int length))
{
drained = true;
int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet);
socket.Send(packet.AsSpan(0, size), SocketFlags.None);
}
@@ -69,7 +80,17 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
nextKeepalive = 0;
Thread.Sleep(100);
}
Thread.Sleep(1);
if (drained) continue;
// Wait on the queue's signal instead of polling every millisecond. A backgrounded
// iOS app coalesces timed sleeps, and a polling sender then flushes queued voice in
// bursts; a signalled wake is immediate. The keepalive deadline bounds the wait.
Volatile.Write(ref senderNapping, 1);
try
{
if (packets.IsEmpty && Environment.TickCount64 < nextKeepalive)
sendReady.WaitOne(checked((int)Math.Clamp(nextKeepalive - Environment.TickCount64, 1, 5000)));
}
finally { Volatile.Write(ref senderNapping, 0); }
}
}
catch (Exception exception) when (exception is SocketException or ObjectDisposedException)
@@ -107,9 +128,9 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
public async ValueTask DisposeAsync()
{
stop.Cancel(); socket.Dispose();
stop.Cancel(); socket.Dispose(); sendReady.Set();
try { sending.Join(); await receiving.ConfigureAwait(false); }
finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding); stop.Dispose(); }
finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding); stop.Dispose(); sendReady.Dispose(); }
}
// A bounded, allocation-free packet handoff. A contending producer drops instead of
@@ -120,6 +141,7 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
private readonly VoiceFrameHeader[] headers = new VoiceFrameHeader[64];
private readonly int[] lengths = new int[64];
private int read, written, producer;
internal bool IsEmpty => read == Volatile.Read(ref written);
internal bool TryWrite(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
{
if (payload.Length is < 1 or > 1275 || Interlocked.CompareExchange(ref producer, 1, 0) != 0) return false;
+5 -2
View File
@@ -55,12 +55,15 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
public bool TryReadEvent(out Envelope? envelope) => events.Reader.TryRead(out envelope);
public IAsyncEnumerable<Envelope> ReadEventsAsync(CancellationToken cancellationToken = default) => events.Reader.ReadAllAsync(cancellationToken);
public VoiceCatClient(string clientName = "VoiceCat .NET", string clientVersion = "0.1.0", string? tofuStorePath = null)
// deviceClockedAudio: the platform's audio callback drives Audio.RunCycle() itself (iOS
// keeps render callbacks running in the background while sleep-paced threads coalesce there),
// so the audio engine must not start its own pacing worker.
public VoiceCatClient(string clientName = "VoiceCat .NET", string clientVersion = "0.1.0", string? tofuStorePath = null, bool deviceClockedAudio = false)
{
this.clientName = clientName;
this.clientVersion = clientVersion;
pins = new(tofuStorePath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VoiceCat", "tofu.txt"));
Audio = new(TrySendEncodedVoice);
Audio = new(TrySendEncodedVoice, startWorker: !deviceClockedAudio);
VoiceReceived += Audio.Receive;
}