Preserve iOS screen audio during stream startup
This commit is contained in:
+5
-2
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
file.Position = 24; file.Write(value);
|
{
|
||||||
|
BinaryPrimitives.WriteUInt64LittleEndian(value, position);
|
||||||
|
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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user