diff --git a/PROGRESS.md b/PROGRESS.md index a1013d0..226fe95 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -42,9 +42,11 @@ cap the applied Opus hint at 30%, and feed it back over TLS. - Verify iOS remote-user tuning and private-conversation navigation with VoiceOver, including multiple streams, users without active streams, and users who disconnect while a view is open. - Exercise iOS background/lock, interruption, Bluetooth, route-change, ReplayKit, and iOS 27 - ScreenCaptureKit paths on devices. Complete a 30-minute iOS call and Wi-Fi/cellular switching - with voice restoration, plus extended mono/stereo/voice-chat switching while joined. Verify - Windows desktop/per-app stereo sharing. + ScreenCaptureKit paths on devices. The background/lock gate keeps a call active for 15+ minutes + backgrounded and screen-locked with no periodic glitches and flat `VC_AUDIO` `feedDrops`/`starved` + counters (the render callback now paces the mix and the 20 ms capture handoff). Complete a + 30-minute iOS call and Wi-Fi/cellular switching with voice restoration, plus extended + mono/stereo/voice-chat switching while joined. Verify Windows desktop/per-app stereo sharing. - Complete Developer ID signing/notarization. The iOS host and ReplayKit extension have been distribution-signed and packaged locally; upload the IPA for Apple's server-side validation. - Run the published Linux container and a 30-minute-or-longer server soak. diff --git a/clients/apple/VoiceCat.iOS/AppModel.cs b/clients/apple/VoiceCat.iOS/AppModel.cs index ee96bb7..d04cea6 100644 --- a/clients/apple/VoiceCat.iOS/AppModel.cs +++ b/clients/apple/VoiceCat.iOS/AppModel.cs @@ -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) diff --git a/clients/apple/VoiceCat.iOS/IosAudioEngine.cs b/clients/apple/VoiceCat.iOS/IosAudioEngine.cs index cd73a49..5c64cff 100644 --- a/clients/apple/VoiceCat.iOS/IosAudioEngine.cs +++ b/clients/apple/VoiceCat.iOS/IosAudioEngine.cs @@ -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 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 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; diff --git a/src/VoiceCat.Audio/AdaptivePcmBuffer.cs b/src/VoiceCat.Audio/AdaptivePcmBuffer.cs index 9328272..b487325 100644 --- a/src/VoiceCat.Audio/AdaptivePcmBuffer.cs +++ b/src/VoiceCat.Audio/AdaptivePcmBuffer.cs @@ -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. diff --git a/src/VoiceCat.Audio/AudioEngine.cs b/src/VoiceCat.Audio/AudioEngine.cs index 41fb525..e9e424c 100644 --- a/src/VoiceCat.Audio/AudioEngine.cs +++ b/src/VoiceCat.Audio/AudioEngine.cs @@ -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(); } diff --git a/src/VoiceCat.Core/ClientMediaTransport.cs b/src/VoiceCat.Core/ClientMediaTransport.cs index bd0cbb1..462b235 100644 --- a/src/VoiceCat.Core/ClientMediaTransport.cs +++ b/src/VoiceCat.Core/ClientMediaTransport.cs @@ -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 payload) => packets.TryWrite(header, payload); + internal bool TrySend(VoiceFrameHeader header, ReadOnlySpan 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 payload) { if (payload.Length is < 1 or > 1275 || Interlocked.CompareExchange(ref producer, 1, 0) != 0) return false; diff --git a/src/VoiceCat.Core/VoiceCatClient.cs b/src/VoiceCat.Core/VoiceCatClient.cs index 3c8b5a0..b756e6e 100644 --- a/src/VoiceCat.Core/VoiceCatClient.cs +++ b/src/VoiceCat.Core/VoiceCatClient.cs @@ -55,12 +55,15 @@ public sealed partial class VoiceCatClient : IAsyncDisposable public bool TryReadEvent(out Envelope? envelope) => events.Reader.TryRead(out envelope); public IAsyncEnumerable 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; } diff --git a/tests/VoiceCat.Tests/AudioEngineTests.cs b/tests/VoiceCat.Tests/AudioEngineTests.cs index 696ac32..f9459d9 100644 --- a/tests/VoiceCat.Tests/AudioEngineTests.cs +++ b/tests/VoiceCat.Tests/AudioEngineTests.cs @@ -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(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() { diff --git a/tests/VoiceCat.Tests/PublishServerScriptTests.cs b/tests/VoiceCat.Tests/PublishServerScriptTests.cs index ad28248..be44233 100644 --- a/tests/VoiceCat.Tests/PublishServerScriptTests.cs +++ b/tests/VoiceCat.Tests/PublishServerScriptTests.cs @@ -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]