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:
@@ -23,6 +23,7 @@ public sealed class AdaptivePcmBuffer
|
||||
|
||||
public int Channels => channels;
|
||||
public int CountFrames => unchecked(Volatile.Read(ref writtenFrame) - Volatile.Read(ref readFrame));
|
||||
public int TargetFrames => Volatile.Read(ref targetFrames);
|
||||
public int BufferMilliseconds
|
||||
{
|
||||
get => Volatile.Read(ref targetFrames) * 1000 / SampleRate;
|
||||
@@ -50,6 +51,19 @@ public sealed class AdaptivePcmBuffer
|
||||
finally { Volatile.Write(ref producer, 0); }
|
||||
}
|
||||
|
||||
// A consumer that stalls leaves its producer backlog queued at a fixed offset forever: feed
|
||||
// and drain rates are equal in steady state, so the correction above only sheds roughly half
|
||||
// a percent at a time and the queue ratchets toward its hard edge one stall at a time. The
|
||||
// consumer calls this after a detected stall so the backlog becomes one bounded gap instead
|
||||
// of accumulating; queued audio beyond the target is dropped and the next read re-primes.
|
||||
public void Resynchronize()
|
||||
{
|
||||
int written = Volatile.Read(ref writtenFrame), available = unchecked(written - Volatile.Read(ref readFrame));
|
||||
int target = Volatile.Read(ref targetFrames);
|
||||
if (available > target) Volatile.Write(ref readFrame, unchecked(written - target));
|
||||
primed = false; phase = 0;
|
||||
}
|
||||
|
||||
// Returns interleaved samples written. A zero return means the caller should treat the
|
||||
// already-cleared destination as silence. Once primed, short scheduling stalls re-prime
|
||||
// instead of repeatedly clicking at the ring edge.
|
||||
|
||||
@@ -166,6 +166,17 @@ public sealed class AudioEngine : IDisposable
|
||||
Volatile.Write(ref completedEpoch, current.Epoch);
|
||||
}
|
||||
|
||||
// Device-clocked pull entry for platforms whose audio callback is the only reliable cadence:
|
||||
// iOS keeps render callbacks running in the background while sleep-paced threads coalesce
|
||||
// there. The device callback owns this call, so failures are contained instead of thrown and
|
||||
// later cycles stay inert. Constructed with startWorker: false, the caller alone paces it.
|
||||
public void RunCycle()
|
||||
{
|
||||
if (Failure is not null || Volatile.Read(ref disposed) != 0) return;
|
||||
try { ProcessCycle(); }
|
||||
catch (Exception exception) { Failure = exception; stop.Cancel(); }
|
||||
}
|
||||
|
||||
private void Work()
|
||||
{
|
||||
long deadline = Stopwatch.GetTimestamp();
|
||||
@@ -177,7 +188,14 @@ public sealed class AudioEngine : IDisposable
|
||||
deadline += Stopwatch.Frequency / 50;
|
||||
WaitUntil(deadline);
|
||||
if ((deadline - Stopwatch.GetTimestamp()) * 1000.0 / Stopwatch.Frequency < -100)
|
||||
{
|
||||
// A stall this long cannot be caught up in place. Discarding only the schedule
|
||||
// deficit would leave the producer backlog queued at a fixed offset forever and
|
||||
// eventually overflow its ring, so drop back to the buffer targets instead: one
|
||||
// bounded gap per stall rather than growing latency and feed drops.
|
||||
deadline = Stopwatch.GetTimestamp();
|
||||
foreach (LocalStream stream in Volatile.Read(ref routes).Local) stream.Input.Resynchronize();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) { Failure = exception; stop.Cancel(); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user