Fix iOS background audio pacing
Build and test / test (macos-latest) (push) Waiting to run
Build and test / test (ubuntu-24.04) (push) Waiting to run
Build and test / test (windows-latest) (push) Waiting to run
Build and test / apple-client (push) Waiting to run

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
+4 -1
View File
@@ -99,7 +99,10 @@ internal sealed class AppModel
explicitDisconnect = false; IsConnecting = true; connectedProfile = profile;
Status = restoring ? "Reconnecting…" : "Connecting…"; Notify();
lifetime?.Cancel(); lifetime?.Dispose(); lifetime = new();
VoiceCatClient next = new("VoiceCat-iOS", "0.0.1", storage.TofuPath);
// The AVAudioSourceNode render callback drives Audio.RunCycle and the 20 ms capture
// handoff; a sleep-paced audio worker stalls when iOS coalesces backgrounded wakeups and
// the call glitches after several minutes in the background.
VoiceCatClient next = new("VoiceCat-iOS", "0.0.1", storage.TofuPath, deviceClockedAudio: true);
next.ConnectionStateChanged += state =>
{
if (state == ClientConnectionState.Disconnected && next.ConnectionFailure is { } failure)
+27 -32
View File
@@ -31,7 +31,7 @@ internal sealed class IosAudioEngine
}
private MicrophoneRoute? microphone;
private readonly short[] microphoneFrame = new short[960 * 2];
private readonly Thread microphonePump;
private int microphoneCredit;
private AVAudioFormat? microphoneFormat;
private AVAudioConverter? microphoneConverter;
private AVAudioPcmBuffer? convertedMicrophone;
@@ -43,12 +43,6 @@ internal sealed class IosAudioEngine
internal bool IsConnected { get; private set; }
internal int BufferMilliseconds { get => playbackRing.BufferMilliseconds; set => playbackRing.BufferMilliseconds = value; }
private IosAudioEngine()
{
microphonePump = new Thread(PumpMicrophone) { IsBackground = true, Name = "VoiceCat iOS microphone pacer", Priority = ThreadPriority.Highest };
microphonePump.Start();
}
internal void StartListening(VoiceCatClient owner)
{
Stop(); client = owner; IsConnected = true; owner.Audio.MixedPcm += ReceiveMixedPcm; Rebuild();
@@ -183,32 +177,19 @@ internal sealed class IosAudioEngine
status = AVAudioConverterInputStatus.NoDataNow; return null!;
}
private void PumpMicrophone()
// One paced 20 ms handoff from the capture ring into the encoder. The render callback calls
// this at its demand rate: the ring absorbs RemoteIO's burst pattern and the small capture vs
// output clock difference, so the sender sees a steady 20 ms feed without a sleep-paced pacer
// thread to stall when iOS coalesces a backgrounded app's wakeups.
private void PumpMicrophoneChunk(VoiceCatClient owner)
{
long deadline = System.Diagnostics.Stopwatch.GetTimestamp();
while (true)
{
MicrophoneRoute? route = Volatile.Read(ref microphone);
VoiceCatClient? owner = client;
if (route is not null && owner is not null)
{
int required = 960 * route.Channels;
if (route.Ring.Read(microphoneFrame.AsSpan(0, required)) == required &&
ReferenceEquals(route, Volatile.Read(ref microphone)) &&
!owner.Audio.FeedPcm(route.StreamId, microphoneFrame.AsSpan(0, required), route.Channels))
Interlocked.Increment(ref rejectedFeeds);
}
deadline += System.Diagnostics.Stopwatch.Frequency / 50;
while (true)
{
long remaining = deadline - System.Diagnostics.Stopwatch.GetTimestamp();
if (remaining <= 0) break;
double milliseconds = remaining * 1000.0 / System.Diagnostics.Stopwatch.Frequency;
if (milliseconds > 2) Thread.Sleep(Math.Max(1, (int)milliseconds - 1)); else Thread.SpinWait(64);
}
if ((deadline - System.Diagnostics.Stopwatch.GetTimestamp()) * 1000.0 / System.Diagnostics.Stopwatch.Frequency < -100)
deadline = System.Diagnostics.Stopwatch.GetTimestamp();
}
MicrophoneRoute? route = Volatile.Read(ref microphone);
if (route is null) return;
int required = 960 * route.Channels;
if (route.Ring.Read(microphoneFrame.AsSpan(0, required)) == required &&
ReferenceEquals(route, Volatile.Read(ref microphone)) &&
!owner.Audio.FeedPcm(route.StreamId, microphoneFrame.AsSpan(0, required), route.Channels))
Interlocked.Increment(ref rejectedFeeds);
}
private void ReceiveMixedPcm(ReadOnlySpan<short> pcm) => playbackRing.TryWrite(pcm);
@@ -217,6 +198,20 @@ internal sealed class IosAudioEngine
{
int frames = checked((int)frameCount), requested = checked(frames * 2);
if (requested > renderScratch.Length) return -1;
// This callback is the cadence iOS keeps exact while the app is backgrounded or the device
// is locked, so it owns both managed 20 ms hands-offs: capture into the sender and one mix
// cycle per 20 ms of render demand. Both run allocation-free and without locks or I/O.
VoiceCatClient? owner = Volatile.Read(ref client);
if (owner is null || !IsConnected) microphoneCredit = 0;
else
{
microphoneCredit += frames;
while (microphoneCredit >= 960) { microphoneCredit -= 960; PumpMicrophoneChunk(owner); }
// Top the mix ring up to cover this callback plus its configured target so the read
// below never starves and the buffer keeps its chosen buffering latency.
int deficit = frames + playbackRing.TargetFrames - playbackRing.CountFrames;
for (int produced = 0; produced < deficit; produced += 960) owner.Audio.RunCycle();
}
Span<short> input = renderScratch.AsSpan(0, requested);
int read = playbackRing.Read(input); input[read..].Clear();
int count = Marshal.ReadInt32(outputData), first = IntPtr.Size == 8 ? 8 : 4, stride = IntPtr.Size == 8 ? 16 : 12;