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 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, 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 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 rebuild, and its pump subscribes before announcing the stream. The pump retains up to 120 ms of
no longer held mapped through suspension. A TestFlight report from build 2026092504 was an iOS 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 `0xdead10cc` shared-file-lock termination; physical-device validation of that mitigation remains
open. 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 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`. ReplayKit extension require signing profiles with App Group `group.me.iamtalon.voicecat`.
Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route 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 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, 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, 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; internal bool ScreenSharing => broadcast?.IsActive == true;
#if DEBUG #if DEBUG
internal bool ScreenRingMapped => broadcast?.IsMappingOpen == true; internal bool ScreenRingMapped => broadcast?.IsMappingOpen == true;
internal long ScreenEncodedPackets => broadcast?.EncodedPackets ?? 0;
#endif #endif
internal bool IsBackgrounded => backgrounded; internal bool IsBackgrounded => backgrounded;
internal float MicrophoneLevel { get; private set; } internal float MicrophoneLevel { get; private set; }
@@ -26,6 +26,7 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
internal bool IsActive => active; internal bool IsActive => active;
#if DEBUG #if DEBUG
internal bool IsMappingOpen => view is not null; internal bool IsMappingOpen => view is not null;
internal long EncodedPackets => streamId == 0 ? 0 : client?.Audio.GetLocalDiagnostics(streamId).EncodedPackets ?? 0;
#endif #endif
internal void Start(VoiceCatClient owner) 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) { } try { owner.StopStream(stream.StreamId); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
return; 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); ulong write = pages.ReadUInt64(24), read = pages.ReadUInt64(32);
if (write - read > Capacity) read = write - Capacity; if (write - read > Capacity) read = write - Capacity;
+42 -7
View File
@@ -46,10 +46,20 @@ internal static class SimulatorSmoke
await Task.Delay(300); await Task.Delay(300);
Require(!model.ScreenRingMapped && !model.ScreenSharing, Require(!model.ScreenRingMapped && !model.ScreenSharing,
"An unchanged ring from a previous broadcast remained mapped."); "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."); await WaitUntil(() => model.ScreenSharing, "Screen stream did not start.");
Require(IosAudioEngine.Shared.IsRunning, "Screen sharing stopped the audio graph."); 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."); await WaitUntil(() => !model.ScreenSharing, "Screen stream did not stop.");
Require(!model.ScreenRingMapped, "The stopped screen ring remained mapped."); Require(!model.ScreenRingMapped, "The stopped screen ring remained mapped.");
Console.Error.WriteLine("VC_SIM phase=screenSharePassed"); Console.Error.WriteLine("VC_SIM phase=screenSharePassed");
@@ -65,9 +75,9 @@ internal static class SimulatorSmoke
"Screen sharing recovery stopped the live audio graph."); "Screen sharing recovery stopped the live audio graph.");
if (ring is not null) if (ring is not null)
{ {
WriteRing(ring, active: true, write: 3840); WriteRing(ring, active: true);
await Task.Delay(300); 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."); await WaitUntil(() => model.ScreenSharing, "Screen stream did not start during a voice call.");
Require(IosAudioEngine.Shared.IsRunning, "Starting screen sharing stopped the voice graph."); Require(IosAudioEngine.Shared.IsRunning, "Starting screen sharing stopped the voice graph.");
} }
@@ -75,7 +85,7 @@ internal static class SimulatorSmoke
if (ring is not null) if (ring is not null)
{ {
Require(model.ScreenSharing, "Leaving the microphone call unsubscribed active screen audio."); 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 WaitUntil(() => !model.ScreenSharing, "The second screen stream did not stop.");
} }
await model.DisconnectAsync(); await model.DisconnectAsync();
@@ -123,16 +133,41 @@ internal static class SimulatorSmoke
return path; 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); using var file = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
byte[] value = new byte[8]; byte[] value = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(value, write); if (write is { } position)
{
BinaryPrimitives.WriteUInt64LittleEndian(value, position);
file.Position = 24; file.Write(value); file.Position = 24; file.Write(value);
}
BinaryPrimitives.WriteUInt32LittleEndian(value, active ? 1u : 0u); BinaryPrimitives.WriteUInt32LittleEndian(value, active ? 1u : 0u);
file.Position = 16; file.Write(value, 0, 4); 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) private static async Task WaitUntil(Func<bool> condition, string error)
{ {
for (int attempt = 0; attempt < 30; attempt++) 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 me.iamtalon.voicecat`. Launch with `SIMCTL_CHILD_VOICECAT_SIM_SMOKE` set to
`127.0.0.1:<port>:<smoke-account-password>` and `127.0.0.1:<port>:<smoke-account-password>` and
`SIMCTL_CHILD_VOICECAT_SIM_SMOKE_MODE=share` using `xcrun simctl launch --console-pty booted `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 me.iamtalon.voicecat`. The `share` mode checks that a stale ring stays unmapped, then verifies
synthetic screen audio with the microphone off and during a voice call, voice join, disconnect, that screen PCM captured before stream negotiation reaches the encoder. It also exercises
and reconnect. Look for 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 `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 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; gate also accepts mode `stereo` to select the StereoMicrophone preset before login and voice join;