Preserve iOS screen audio during stream startup
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

This commit is contained in:
2026-09-27 17:56:18 +02:00
parent 3dbe763598
commit 4bad264605
6 changed files with 65 additions and 15 deletions
+5 -2
View File
@@ -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.
+4 -1
View File
@@ -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,
+1
View File
@@ -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; }
@@ -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;
+42 -7
View File
@@ -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);
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<byte> 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<bool> condition, string error)
{
for (int attempt = 0; attempt < 30; attempt++)
+4 -3
View File
@@ -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:<port>:<smoke-account-password>` 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;