Try to fix background glitching over long periods of time
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

This commit is contained in:
2026-09-24 13:31:16 +02:00
parent 186fe6dbb6
commit fb740bcfb2
7 changed files with 129 additions and 14 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ internal sealed class AppModel
internal void WillEnterForeground()
{
backgrounded = false;
IosAudioRouter.Shared.Recover("foreground");
IosAudioRouter.Shared.ResumeForeground();
}
internal void DidBecomeActive()
+17 -3
View File
@@ -40,7 +40,12 @@ internal sealed class IosAudioEngine
private bool inputProvided;
private bool tapInstalled;
private long captureCallbacks, capturedFrames, convertedFrames, rejectedFeeds, converterFailures, stereoFrames, stereoDifferentFrames;
private long renderCallbacks, lastRenderTimestamp;
internal bool IsConnected { get; private set; }
internal bool IsRunning => engine?.Running == true;
// The watchdog compares this across ticks: a graph that stops calling back while it still
// reports Running leaves the whole device-clocked pipeline frozen until it is rebuilt.
internal long RenderCallbacks => Interlocked.Read(ref renderCallbacks);
internal int BufferMilliseconds { get => playbackRing.BufferMilliseconds; set => playbackRing.BufferMilliseconds = value; }
internal void StartListening(VoiceCatClient owner)
@@ -201,15 +206,24 @@ internal sealed class IosAudioEngine
// 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.
Interlocked.Increment(ref renderCallbacks);
long now = System.Diagnostics.Stopwatch.GetTimestamp(), previous = lastRenderTimestamp;
lastRenderTimestamp = now;
VoiceCatClient? owner = Volatile.Read(ref client);
if (owner is null || !IsConnected) microphoneCredit = 0;
else
{
if (previous != 0 && (now - previous) * 1000.0 / System.Diagnostics.Stopwatch.Frequency > 100)
{
Volatile.Read(ref microphone)?.Ring.Resynchronize();
playbackRing.Resynchronize(); owner.Audio.ResynchronizeInputs(); microphoneCredit = 0;
}
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;
// below never starves and the buffer keeps its chosen buffering latency. Catch-up is
// capped at one extra cycle so a refill cannot overrun this callback's deadline.
int deficit = Math.Min(frames + playbackRing.TargetFrames - playbackRing.CountFrames, frames + 960);
for (int produced = 0; produced < deficit; produced += 960) owner.Audio.RunCycle();
}
Span<short> input = renderScratch.AsSpan(0, requested);
@@ -245,7 +259,7 @@ internal sealed class IosAudioEngine
if (tapInstalled) old.InputNode.RemoveTapOnBus(0);
old.Stop(); if (source is not null) old.DetachNode(source); old.Dispose();
}
tapInstalled = false; pendingInput = null; inputProvider = null;
tapInstalled = false; pendingInput = null; inputProvider = null; lastRenderTimestamp = 0; microphoneCredit = 0;
convertedMicrophone?.Dispose(); convertedMicrophone = null;
microphoneConverter?.Dispose(); microphoneConverter = null;
microphoneFormat?.Dispose(); microphoneFormat = null;
+52 -4
View File
@@ -18,6 +18,11 @@ internal sealed class IosAudioRouter
internal static IosAudioRouter Shared { get; } = new();
private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults;
private bool applying;
private System.Threading.Timer? watchdog;
private long lastRenderCallbacks = -1;
private int watchdogMisses;
private bool watchdogTicking;
private bool interrupted;
internal event Action? Changed;
internal IosAudioPreset Preset { get; private set; } = IosAudioPreset.VoiceChat;
internal IosBluetoothMode BluetoothMode { get; private set; } = IosBluetoothMode.HfpVoice;
@@ -117,6 +122,7 @@ internal sealed class IosAudioRouter
$"preferred={session.PreferredInput?.PortName ?? "default"} dataSource={session.InputDataSource?.DataSourceName ?? "default"} " +
$"pattern={session.InputDataSource?.SelectedPolarPattern.ToString() ?? "default"}");
session.OverrideOutputAudioPort(ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionPortOverride.Speaker : AVAudioSessionPortOverride.None, out _); RefreshRoutes();
ResetWatchdog(); EnsureWatchdog();
}
finally { applying = false; }
}
@@ -210,10 +216,14 @@ internal sealed class IosAudioRouter
}
private void Set(string key, string? value) { if (value is null) defaults.RemoveObject(key); else defaults.SetString(value, key); }
internal void Deactivate() => AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _);
internal void Deactivate()
{
watchdog?.Dispose(); watchdog = null; ResetWatchdog();
AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _);
}
internal void EnsureAudio(string reason)
{
if (!IosAudioEngine.Shared.IsConnected) return;
if (!IosAudioEngine.Shared.IsConnected || interrupted) return;
try { IosAudioEngine.Shared.EnsureRunning(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
}
@@ -221,13 +231,47 @@ internal sealed class IosAudioRouter
internal void Recover(string reason)
{
RefreshRoutes();
if (!IosAudioEngine.Shared.IsConnected) return;
if (!IosAudioEngine.Shared.IsConnected || interrupted) return;
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
try { IosAudioEngine.Shared.Reconfigure(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); }
});
}
// The render callback is the only clock for the device-clocked pipeline, so a graph that
// stops while the app is backgrounded freezes capture, mix and send with no notification to
// recover from. Poll for that and rebuild; a failed rebuild is retried on the next tick.
private void EnsureWatchdog()
{
watchdog ??= new System.Threading.Timer(_ => UIApplication.SharedApplication.BeginInvokeOnMainThread(TickWatchdog),
null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
private void ResetWatchdog() { lastRenderCallbacks = -1; watchdogMisses = 0; }
// An interruption that ends while the app is suspended never delivers its Ended notification.
internal void ResumeForeground() { interrupted = false; ResetWatchdog(); Recover("foreground"); }
private void TickWatchdog()
{
if (watchdogTicking) return;
watchdogTicking = true;
try
{
if (!IosAudioEngine.Shared.IsConnected || interrupted || applying) { ResetWatchdog(); return; }
long callbacks = IosAudioEngine.Shared.RenderCallbacks;
bool stalled = !IosAudioEngine.Shared.IsRunning || callbacks == lastRenderCallbacks;
lastRenderCallbacks = callbacks;
if (!stalled) { watchdogMisses = 0; return; }
// One missed tick can be a route change already rebuilding the graph.
if (++watchdogMisses < 2) return;
ResetWatchdog();
try { IosAudioEngine.Shared.Reconfigure(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio watchdog rebuild failed: {exception}"); }
}
finally { watchdogTicking = false; }
}
private void HandleRouteChange(NSNotification note)
{
NSNumber? value = note.UserInfo?[new NSString("AVAudioSessionRouteChangeReasonKey")] as NSNumber;
@@ -243,6 +287,10 @@ internal sealed class IosAudioRouter
{
NSNumber? type = note.UserInfo?[new NSString("AVAudioSessionInterruptionTypeKey")] as NSNumber;
AVAudioSessionInterruptionType interruption = (AVAudioSessionInterruptionType)(type?.UInt32Value ?? 0);
if (interruption == AVAudioSessionInterruptionType.Ended) Recover("interruption ended");
// The system stops the graph on Began and SetActive fails until the interruption clears,
// so suppress recovery until Ended and let the watchdog retry if that rebuild fails.
if (interruption == AVAudioSessionInterruptionType.Began) { interrupted = true; ResetWatchdog(); return; }
if (interruption != AVAudioSessionInterruptionType.Ended) return;
interrupted = false; ResetWatchdog(); Recover("interruption ended");
}
}