diff --git a/PROGRESS.md b/PROGRESS.md index ab6319b..cb8cc21 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -49,8 +49,11 @@ An explicit disconnect now pauses the prepared iOS graph and releases the audio next connection resumes that graph before attempting a rebuild. A signed iOS 26 simulator gate verified guest-to-account editing, saved-password login after a process restart, two voice joins, and a screen-audio stream started before the microphone. Screen sharing no longer forces a graph -rebuild, and its pump subscribes before announcing the stream. An idle shared broadcast ring is -no longer held mapped through suspension. A TestFlight report from build 2026092504 was an iOS +rebuild, and its pump subscribes before announcing the stream. The pump retains up to 120 ms of +screen PCM captured during stream negotiation; the simulator gate now checks that this PCM +reaches the encoder and passed on iOS 26. Audible screen sharing was confirmed on an iPhone 16 +Pro Max after deployment. An idle shared broadcast ring is no longer held mapped through suspension. A +TestFlight report from build 2026092504 was an iOS `0xdead10cc` shared-file-lock termination; physical-device validation of that mitigation remains open. diff --git a/clients/apple/README.md b/clients/apple/README.md index 24db6ef..38ae65d 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -24,7 +24,10 @@ require `VOICECAT_CODESIGN_IDENTITY`; optional notarization uses `APPLE_ID`, `AP For a physical iOS device, use `build-ios-device.sh` and `deploy-ios-device.sh`. The host and ReplayKit extension require signing profiles with App Group `group.me.iamtalon.voicecat`. Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route -changes, Bluetooth, ReplayKit, and iOS 27 ScreenCaptureKit audio. +changes, Bluetooth, ReplayKit, and iOS 27 ScreenCaptureKit audio. The opt-in simulator share +smoke gate checks that ring PCM captured during stream startup reaches the encoder; audible +screen sharing was confirmed on an iPhone 16 Pro Max. The iOS 27 capture path still needs a +device check. For iOS voice stability, leave a call joined with the microphone active for at least 30 minutes and confirm speech stays clear and `VC_AUDIO` reports no growing `feedDrops`. While still joined, toggle Wi-Fi off and on, switch between Wi-Fi and cellular, and confirm the app stays open, diff --git a/clients/apple/VoiceCat.iOS/AppModel.cs b/clients/apple/VoiceCat.iOS/AppModel.cs index f8c85d4..223515a 100644 --- a/clients/apple/VoiceCat.iOS/AppModel.cs +++ b/clients/apple/VoiceCat.iOS/AppModel.cs @@ -53,6 +53,7 @@ internal sealed class AppModel internal bool ScreenSharing => broadcast?.IsActive == true; #if DEBUG internal bool ScreenRingMapped => broadcast?.IsMappingOpen == true; + internal long ScreenEncodedPackets => broadcast?.EncodedPackets ?? 0; #endif internal bool IsBackgrounded => backgrounded; internal float MicrophoneLevel { get; private set; } diff --git a/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs b/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs index 44dc437..f3639f7 100644 --- a/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs +++ b/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs @@ -26,6 +26,7 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable internal bool IsActive => active; #if DEBUG internal bool IsMappingOpen => view is not null; + internal long EncodedPackets => streamId == 0 ? 0 : client?.Audio.GetLocalDiagnostics(streamId).EncodedPackets ?? 0; #endif internal void Start(VoiceCatClient owner) @@ -82,7 +83,13 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable try { owner.StopStream(stream.StreamId); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { } return; } - streamId = stream.StreamId; pages.Write(32, pages.ReadUInt64(24)); SetActive(true); + // Keep the newest 120 ms captured during stream negotiation. This covers the + // largest codec frame plus the input buffer target; dropping through the latest + // write loses short sounds before the first drain can feed them. + ulong latestWrite = pages.ReadUInt64(24); + ulong earliest = latestWrite > (ulong)(Frame * 12) ? latestWrite - (ulong)(Frame * 12) : 0; + pages.Write(32, Math.Max(pages.ReadUInt64(32), earliest)); + streamId = stream.StreamId; SetActive(true); } ulong write = pages.ReadUInt64(24), read = pages.ReadUInt64(32); if (write - read > Capacity) read = write - Capacity; diff --git a/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs b/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs index c8ed276..b140af7 100644 --- a/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs +++ b/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs @@ -46,10 +46,20 @@ internal static class SimulatorSmoke await Task.Delay(300); Require(!model.ScreenRingMapped && !model.ScreenSharing, "An unchanged ring from a previous broadcast remained mapped."); - WriteRing(ring, active: true, write: 3840); + for (int frame = 0; frame < 6; frame++) WriteScreenPcm(ring); await WaitUntil(() => model.ScreenSharing, "Screen stream did not start."); Require(IosAudioEngine.Shared.IsRunning, "Screen sharing stopped the audio graph."); - WriteRing(ring, active: false, write: 3840); + await WaitUntil(() => model.ScreenEncodedPackets > 0, + "Screen PCM captured before stream negotiation was discarded."); + long encodedBefore = model.ScreenEncodedPackets; + for (int frame = 0; frame < 12; frame++) + { + WriteScreenPcm(ring); + await Task.Delay(20); + } + await WaitUntil(() => model.ScreenEncodedPackets > encodedBefore, + "Screen PCM did not reach the encoder while the microphone was off."); + WriteRing(ring, active: false); await WaitUntil(() => !model.ScreenSharing, "Screen stream did not stop."); Require(!model.ScreenRingMapped, "The stopped screen ring remained mapped."); Console.Error.WriteLine("VC_SIM phase=screenSharePassed"); @@ -65,9 +75,9 @@ internal static class SimulatorSmoke "Screen sharing recovery stopped the live audio graph."); if (ring is not null) { - WriteRing(ring, active: true, write: 3840); + WriteRing(ring, active: true); await Task.Delay(300); - WriteRing(ring, active: true, write: 5760); + WriteScreenPcm(ring); await WaitUntil(() => model.ScreenSharing, "Screen stream did not start during a voice call."); Require(IosAudioEngine.Shared.IsRunning, "Starting screen sharing stopped the voice graph."); } @@ -75,7 +85,7 @@ internal static class SimulatorSmoke if (ring is not null) { Require(model.ScreenSharing, "Leaving the microphone call unsubscribed active screen audio."); - WriteRing(ring, active: false, write: 5760); + WriteRing(ring, active: false); await WaitUntil(() => !model.ScreenSharing, "The second screen stream did not stop."); } await model.DisconnectAsync(); @@ -123,16 +133,41 @@ internal static class SimulatorSmoke return path; } - private static void WriteRing(string path, bool active, ulong write) + private static void WriteRing(string path, bool active, ulong? write = null) { using var file = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite); byte[] value = new byte[8]; - BinaryPrimitives.WriteUInt64LittleEndian(value, write); - file.Position = 24; file.Write(value); + if (write is { } position) + { + BinaryPrimitives.WriteUInt64LittleEndian(value, position); + file.Position = 24; file.Write(value); + } BinaryPrimitives.WriteUInt32LittleEndian(value, active ? 1u : 0u); file.Position = 16; file.Write(value, 0, 4); } + private static void WriteScreenPcm(string path) + { + using var file = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + Span index = stackalloc byte[8]; + file.Position = 24; file.ReadExactly(index); + ulong write = BinaryPrimitives.ReadUInt64LittleEndian(index); + byte[] pcm = new byte[960 * 2 * sizeof(short)]; + for (int sample = 0; sample < pcm.Length / sizeof(short); sample++) + BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan(sample * sizeof(short)), + (short)(sample % 64 < 32 ? 6000 : -6000)); + int start = (int)(write % 96_000), first = Math.Min(pcm.Length / sizeof(short), 96_000 - start); + file.Position = 64 + start * sizeof(short); + file.Write(pcm.AsSpan(0, first * sizeof(short))); + if (first < pcm.Length / sizeof(short)) + { + file.Position = 64; + file.Write(pcm.AsSpan(first * sizeof(short))); + } + BinaryPrimitives.WriteUInt64LittleEndian(index, write + (ulong)(pcm.Length / sizeof(short))); + file.Position = 24; file.Write(index); + } + private static async Task WaitUntil(Func condition, string error) { for (int attempt = 0; attempt < 30; attempt++) diff --git a/docs/ios-deploy.md b/docs/ios-deploy.md index ca2109b..dd2a2ef 100644 --- a/docs/ios-deploy.md +++ b/docs/ios-deploy.md @@ -158,9 +158,10 @@ Grant microphone access with `xcrun simctl privacy booted grant microphone me.iamtalon.voicecat`. Launch with `SIMCTL_CHILD_VOICECAT_SIM_SMOKE` set to `127.0.0.1::` and `SIMCTL_CHILD_VOICECAT_SIM_SMOKE_MODE=share` using `xcrun simctl launch --console-pty booted -me.iamtalon.voicecat`. The `share` mode checks that a stale ring stays unmapped, then exercises -synthetic screen audio with the microphone off and during a voice call, voice join, disconnect, -and reconnect. Look for +me.iamtalon.voicecat`. The `share` mode checks that a stale ring stays unmapped, then verifies +that screen PCM captured before stream negotiation reaches the encoder. It also exercises +continuous synthetic screen audio with the microphone off and during a voice call, voice join, +disconnect, and reconnect. Look for `VC_SIM phase=screenSharePassed` and `VC_SIM phase=passed`. Terminate the app, then launch again with mode `resume` to verify the saved account after a process restart. The simulator gate also accepts mode `stereo` to select the StereoMicrophone preset before login and voice join;