Stabilize iOS reconnect and screen audio sessions
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-26 20:30:24 +02:00
parent 5a2dddba89
commit 4cc13a27a0
11 changed files with 327 additions and 36 deletions
+13
View File
@@ -45,6 +45,15 @@ because the voice-processing IO unit only stays up while the input is in the ren
changes no longer force a rebuild; media-services resets still do, and the stall watchdog now
backs off and stops after four failed attempts instead of rebuilding without end.
An explicit disconnect now pauses the prepared iOS graph and releases the audio session; the
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
`0xdead10cc` shared-file-lock termination; physical-device validation of that mitigation remains
open.
Voice-chat capture is not yet reliable on the first attempt: a graph with voice processing still
sometimes fails to start and is only recovered by a watchdog rebuild, which costs seconds before
audio appears. Speaker output is an explicit choice that no longer changes the preset. Every
@@ -75,6 +84,10 @@ cap the applied Opus hint at 30%, and feed it back over TLS.
- Find why an iOS graph with voice processing intermittently starts and then stops within a
second, so voice-chat capture comes up on the first attempt rather than after a watchdog rebuild.
- On physical iOS 27 hardware, repeat account login after app restart, explicit disconnect and
reconnect, microphone permission denial, ReplayKit and ScreenCaptureKit sharing with and
without a joined microphone, and background/foreground transitions. Confirm the graph keeps
rendering and that `0xdead10cc` does not recur.
- Run real multi-person calls on Windows, macOS, and physical iOS hardware, including adaptive
20/40/60 ms buffering, duration-aware DRED/FEC, automatic packet-loss feedback, and mismatched
input/output endpoints.
@@ -9,6 +9,9 @@ internal sealed class AppDelegate : UIApplicationDelegate
public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions)
{
AppModel.Shared.Load();
#if DEBUG
SimulatorSmoke.RunIfRequested();
#endif
return true;
}
+33 -11
View File
@@ -51,6 +51,9 @@ internal sealed class AppModel
internal bool IsConnecting { get; private set; }
internal bool VoiceJoined => microphoneStream != 0;
internal bool ScreenSharing => broadcast?.IsActive == true;
#if DEBUG
internal bool ScreenRingMapped => broadcast?.IsMappingOpen == true;
#endif
internal bool IsBackgrounded => backgrounded;
internal float MicrophoneLevel { get; private set; }
internal string Status { get; private set; } = "Not connected";
@@ -162,7 +165,7 @@ internal sealed class AppModel
}
catch (Exception exception)
{
System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}");
Console.Error.WriteLine($"VoiceCat connection setup failed: {exception}");
IsConnecting = false; Status = exception.Message;
if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(releaseAudio: !restoring); }
await next.DisposeAsync();
@@ -270,15 +273,35 @@ internal sealed class AppModel
if (microphoneStream != 0)
{
IosAudioEngine.Shared.StopMicrophone(); active.StopStream(microphoneStream); microphoneStream = 0;
await active.SubscribeVoiceAsync(false); MicrophoneLevel = 0; feedback.Play(SoundEvent.VoiceOff); Notify(); return;
if (!ScreenSharing) await active.SubscribeVoiceAsync(false);
MicrophoneLevel = 0; feedback.Play(SoundEvent.VoiceOff); Notify(); return;
}
if (AVFoundation.AVCaptureDevice.GetAuthorizationStatus(AVFoundation.AVAuthorizationMediaType.Audio) == AVFoundation.AVAuthorizationStatus.NotDetermined)
await AVFoundation.AVCaptureDevice.RequestAccessForMediaTypeAsync(AVFoundation.AVAuthorizationMediaType.Audio);
AVFoundation.AVAuthorizationStatus authorization = AVFoundation.AVCaptureDevice.GetAuthorizationStatus(AVFoundation.AVAuthorizationMediaType.Audio);
if (authorization == AVFoundation.AVAuthorizationStatus.NotDetermined)
authorization = await AVFoundation.AVCaptureDevice.RequestAccessForMediaTypeAsync(AVFoundation.AVAuthorizationMediaType.Audio)
? AVFoundation.AVAuthorizationStatus.Authorized : AVFoundation.AVAuthorizationStatus.Denied;
if (authorization != AVFoundation.AVAuthorizationStatus.Authorized)
throw new InvalidOperationException("Microphone access is required to join voice. Allow it in iOS Settings.");
VoiceSubscriptionResult subscribed = await active.SubscribeVoiceAsync();
if (!subscribed.Ok) throw new InvalidOperationException(subscribed.Error);
int channels = IosAudioRouter.Shared.CaptureChannels;
StreamInfo stream = await active.StartStreamAsync(StreamKind.StreamMic, "Microphone", channels);
microphoneStream = stream.StreamId; IosAudioEngine.Shared.StartMicrophone(stream.StreamId, channels); feedback.Play(SoundEvent.VoiceOn); Notify();
uint startedStream = 0;
try
{
int channels = IosAudioRouter.Shared.CaptureChannels;
StreamInfo stream = await active.StartStreamAsync(StreamKind.StreamMic, "Microphone", channels);
startedStream = stream.StreamId;
IosAudioEngine.Shared.StartMicrophone(stream.StreamId, channels);
microphoneStream = stream.StreamId; feedback.Play(SoundEvent.VoiceOn); Notify();
}
catch
{
IosAudioEngine.Shared.StopMicrophone();
if (startedStream != 0)
try { active.StopStream(startedStream); } catch (Exception exception) when (exception is IOException or InvalidOperationException or ObjectDisposedException) { }
if (!ScreenSharing)
try { await active.SubscribeVoiceAsync(false); } catch (Exception exception) when (exception is IOException or InvalidOperationException or ObjectDisposedException) { }
throw;
}
}
internal void ToggleScreenAudio()
@@ -390,10 +413,9 @@ internal sealed class AppModel
{
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
// Presenting either system picker can replace or interrupt the app's audio session.
// Rebuild after the producer becomes active so playback and an existing mic tap are
// attached to the session that will remain in use for the broadcast.
if (ScreenSharing) IosAudioRouter.Shared.Recover("screen sharing started");
// Presenting either picker can change the audio route. Keep a healthy graph; if the
// picker stopped it, Recover resumes the same graph or rebuilds for changed hardware.
IosAudioRouter.Shared.Recover(ScreenSharing ? "screen sharing started" : "screen sharing stopped", force: false);
Notify();
});
}
@@ -18,9 +18,15 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
private uint streamId;
private bool active;
private int generation;
private ulong observedWrite;
private ulong idleWrite;
private bool idleWriteSeen;
internal event Action? Changed;
internal bool IsActive => active;
#if DEBUG
internal bool IsMappingOpen => view is not null;
#endif
internal void Start(VoiceCatClient owner)
{
@@ -37,8 +43,17 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
while (!token.IsCancellationRequested)
{
try { DrainAsync(token).GetAwaiter().GetResult(); }
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or OperationCanceledException) { CloseMapping(); }
if (!token.IsCancellationRequested) Thread.Sleep(5);
catch (OperationCanceledException) when (token.IsCancellationRequested) { break; }
catch (Exception exception)
{
// A rejected screen stream is a session error, not an unhandled exception on
// this background Thread. Keep the app and microphone call alive.
Console.Error.WriteLine($"VoiceCat screen audio pump failed: {exception}");
CloseMapping(); StopStream(); SetActive(false);
if (!token.IsCancellationRequested) Thread.Sleep(1000);
}
// The shared file must not remain mapped while the app is idle or suspending.
if (!token.IsCancellationRequested) Thread.Sleep(view is null ? 100 : 5);
}
}
finally { CloseMapping(); }
@@ -49,11 +64,18 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
if (!OpenMapping()) return;
MemoryMappedViewAccessor pages = view!;
bool active = pages.ReadUInt32(16) != 0;
if (!active) { StopStream(); SetActive(false); return; }
if (!active) { StopStream(); SetActive(false); CloseMapping(); return; }
VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
if (streamId == 0)
{
// A ring left marked active by a killed extension must not start a phantom stream.
// Wait for a fresh producer write after this connection opened the mapping.
ulong currentWrite = pages.ReadUInt64(24);
if (currentWrite == observedWrite) return;
observedWrite = currentWrite;
int startGeneration = Volatile.Read(ref generation);
VoiceSubscriptionResult subscription = await owner.SubscribeVoiceAsync(cancellationToken: token).ConfigureAwait(false);
if (!subscription.Ok) throw new InvalidOperationException(subscription.Error);
StreamInfo stream = await owner.StartStreamAsync(StreamKind.StreamScreenAudio, "Screen audio", 2, token).ConfigureAwait(false);
if (startGeneration != Volatile.Read(ref generation) || pages.ReadUInt32(16) == 0 || !ReferenceEquals(client, owner))
{
@@ -77,11 +99,10 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
}
}
// The producer opens the ring with O_CREAT and never replaces it, so one mapping covers the
// pump's whole life. Creating and disposing a mapping (plus its container lookup and path
// strings) every 5 ms tick allocated steadily at 200 Hz and churned the GC under long calls;
// the mapping is now built on the ring's transitions and reused until the file disappears or
// a drain fails.
// Keep a mapping only while a broadcast is active. An inactive mapping holds an open handle
// into the shared App Group container through suspension, which iOS may terminate as a shared
// file lock (0xdead10cc). Poll the small header with a short-lived read when idle, then map
// once for the active broadcast's high-rate drain.
private bool OpenMapping()
{
path ??= ResolvePath();
@@ -92,7 +113,28 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
if (present) return true;
CloseMapping(); return false;
}
if (!present) return false;
if (!present) { idleWriteSeen = false; return false; }
using (var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
{
if (file.Length < Header) { idleWriteSeen = false; return false; }
Span<byte> header = stackalloc byte[32];
file.ReadExactly(header);
if (System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(header) != Magic ||
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(header[4..]) != Version ||
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(header[16..]) == 0)
{
idleWriteSeen = false;
return false;
}
ulong currentWrite = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(header[24..]);
if (!idleWriteSeen)
{
idleWrite = currentWrite;
idleWriteSeen = true;
return false;
}
if (currentWrite == idleWrite) return false;
}
MemoryMappedFile candidate = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
try
{
@@ -101,7 +143,7 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
{
pages.Dispose(); throw new InvalidDataException("Unsupported broadcast ring.");
}
map = candidate; view = pages; return true;
map = candidate; view = pages; observedWrite = idleWrite; return true;
}
catch { candidate.Dispose(); throw; }
}
@@ -116,12 +158,14 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
{
view?.Dispose(); view = null;
map?.Dispose(); map = null;
observedWrite = 0;
idleWriteSeen = false;
}
private void StopStream()
{
uint id = streamId; streamId = 0; if (id == 0 || client?.State != ClientConnectionState.Connected) return;
try { client.StopStream(id); } catch (Exception exception) when (exception is IOException or InvalidOperationException) { }
try { client.StopStream(id); } catch (Exception exception) when (exception is IOException or InvalidOperationException or ObjectDisposedException) { }
}
internal void RequestStop() { Interlocked.Increment(ref generation); SetActive(false); }
+30 -5
View File
@@ -88,6 +88,7 @@ internal sealed class IosAudioEngine
// right hardware. Rebuilding it there would release an HFP headset and pay a Bluetooth
// profile renegotiation for a transport blip that changed no audio configuration.
if (engine?.Running == true) { playbackRing.Resynchronize(); return; }
if (ResumeStoppedGraph()) return;
Volatile.Write(ref microphone, null); Rebuild();
}
@@ -134,6 +135,7 @@ internal sealed class IosAudioEngine
int channels = IosAudioRouter.Shared.CaptureChannels;
if (!force && engine?.Running == true && tapInstalled && builtCaptureChannels == channels
&& voiceProcessing == IosAudioRouter.Shared.UsesVoiceProcessing && !HardwareChanged()) return;
if (!force && ResumeStoppedGraph()) return;
if (current is not null)
{
// Stop the old tap before publishing a route with a different sample width. Otherwise
@@ -155,9 +157,30 @@ internal sealed class IosAudioEngine
// Still starting: report it as running rather than replacing it, since that is what it is
// about to be, and a rebuild here would start the cycle over.
if (Settling) return true;
if (ResumeStoppedGraph()) return true;
Rebuild(); return engine?.Running == true;
}
private bool ResumeStoppedGraph()
{
if (engine is not { } paused || !tapInstalled ||
builtCaptureChannels != IosAudioRouter.Shared.CaptureChannels ||
voiceProcessing != IosAudioRouter.Shared.UsesVoiceProcessing || HardwareChanged()) return false;
IosAudioRouter.Shared.Apply(true);
bool started = paused.StartAndReturnError(out NSError? error);
if (started)
{
playbackRing.Resynchronize();
// Voice-processing IO may still be starting when Start returns. Give the existing
// graph its settling window before a watchdog judges it, just as a fresh graph gets.
Volatile.Write(ref lastRebuildAt, Environment.TickCount64);
Console.Error.WriteLine($"VC_RESUME running={paused.Running}");
return true;
}
Console.Error.WriteLine($"VC_RESUME running={paused.Running} error={error?.LocalizedDescription ?? "none"}");
return false;
}
private void Rebuild([System.Runtime.CompilerServices.CallerMemberName] string cause = "")
{
Console.Error.WriteLine($"VC_REBUILD cause={cause} running={engine?.Running == true} callbacks={RenderCallbacks}");
@@ -182,7 +205,7 @@ internal sealed class IosAudioEngine
// formats are queried or nodes are connected so the graph is built from the final IO.
input = next.InputNode;
if (!input.SetVoiceProcessingEnabled(IosAudioRouter.Shared.UsesVoiceProcessing, out NSError? processingError))
throw new InvalidOperationException(processingError.LocalizedDescription);
throw new InvalidOperationException(processingError?.LocalizedDescription ?? "Could not configure voice processing.");
if (IosAudioRouter.Shared.UsesVoiceProcessing) input.VoiceProcessingAgcEnabled = IosAudioRouter.Shared.AutomaticGainControl;
}
outputFormat = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false);
@@ -195,6 +218,8 @@ internal sealed class IosAudioEngine
if (captures)
{
AVAudioFormat inputFormat = input!.GetBusOutputFormat(0);
if (inputFormat.SampleRate <= 0 || inputFormat.ChannelCount == 0)
throw new InvalidOperationException("No usable microphone input is available on the current audio route.");
Console.Error.WriteLine($"VC_GRAPH vpio={IosAudioRouter.Shared.UsesVoiceProcessing} requestedCh={captureChannels} " +
$"inputCh={inputFormat.ChannelCount} inputRate={inputFormat.SampleRate}");
builtInputRate = inputFormat.SampleRate; builtInputChannels = inputFormat.ChannelCount;
@@ -231,15 +256,15 @@ internal sealed class IosAudioEngine
captureSink.OutputVolume = 0f;
}
next.Prepare();
if (!next.StartAndReturnError(out NSError? error)) { next.Dispose(); throw new InvalidOperationException(error.LocalizedDescription); }
if (!next.StartAndReturnError(out NSError? error)) { next.Dispose(); throw new InvalidOperationException(error?.LocalizedDescription ?? "The iOS audio graph could not start."); }
engine = next;
{
AVAudioSession live = AVAudioSession.SharedInstance();
Console.Error.WriteLine($"VC_START running={next.Running} builtCh={builtInputChannels} sessionRate={live.SampleRate} " +
$"sessionInCh={live.InputNumberOfChannels} sessionOutCh={live.OutputNumberOfChannels} inputAvailable={live.InputAvailable} " +
$"other={live.OtherAudioPlaying} mode={live.Mode} options={live.CategoryOptions} io={live.IOBufferDuration:F4} " +
$"out={string.Join(',', live.CurrentRoute.Outputs.Select(value => value.PortType.ToString()))} " +
$"in={string.Join(',', live.CurrentRoute.Inputs.Select(value => value.PortType.ToString()))}");
$"out={string.Join(',', live.CurrentRoute?.Outputs?.Select(value => value.PortType.ToString()) ?? [])} " +
$"in={string.Join(',', live.CurrentRoute?.Inputs?.Select(value => value.PortType.ToString()) ?? [])}");
// A graph that starts and then stops on its own is the failure that matters, and it is
// invisible at start: check again once the IO has had time to come up.
AVAudioEngine started = next;
@@ -380,7 +405,7 @@ internal sealed class IosAudioEngine
{
IsConnected = false; Volatile.Write(ref microphone, null);
if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm;
DestroyGraph(); client = null; IosAudioRouter.Shared.Deactivate();
engine?.Pause(); client = null; IosAudioRouter.Shared.Deactivate();
}
private void DestroyGraph()
+10 -8
View File
@@ -127,7 +127,7 @@ internal sealed class IosAudioRouter
{
AVAudioSession session = AVAudioSession.SharedInstance();
Inputs = session.AvailableInputs?.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray() ?? [];
Outputs = session.CurrentRoute.Outputs.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray();
Outputs = session.CurrentRoute?.Outputs?.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray() ?? [];
// A route reported by iOS is not an explicit user input choice. Capturing it here
// can pin the built-in mic after a speaker fallback and displace a Bluetooth HFP route
// on the next graph rebuild.
@@ -145,10 +145,10 @@ internal sealed class IosAudioRouter
if (BluetoothMode == IosBluetoothMode.BuiltInMicSpeaker || ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp) options |= AVAudioSessionCategoryOptions.DefaultToSpeaker;
AVAudioSessionMode mode = CaptureChannels == 2 ? AVAudioSessionMode.Default : MicMode == IosMicMode.Raw ? AVAudioSessionMode.Measurement
: BluetoothMode == IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionMode.VideoRecording : AVAudioSessionMode.VoiceChat;
if (!session.SetCategory(AVAudioSessionCategory.PlayAndRecord, mode, options, out NSError? categoryError)) throw new InvalidOperationException(categoryError.LocalizedDescription);
if (!session.SetCategory(AVAudioSessionCategory.PlayAndRecord, mode, options, out NSError? categoryError)) throw new InvalidOperationException(categoryError?.LocalizedDescription ?? "Could not configure the iOS audio session.");
session.SetPreferredSampleRate(48_000, out _); session.SetPreferredIOBufferDuration(0.02, out _);
if (configureInput) ApplyInputSelection(session);
if (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError)) throw new InvalidOperationException(activeError.LocalizedDescription);
if (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError)) throw new InvalidOperationException(activeError?.LocalizedDescription ?? "Could not activate the iOS audio session.");
Console.Error.WriteLine($"VC_ROUTE preset={Preset} requestedCh={CaptureChannels} sessionCh={session.InputNumberOfChannels} " +
$"preferred={session.PreferredInput?.PortName ?? "default"} dataSource={session.InputDataSource?.DataSourceName ?? "default"} " +
$"pattern={session.InputDataSource?.SelectedPolarPattern.ToString() ?? "default"}");
@@ -189,19 +189,19 @@ internal sealed class IosAudioRouter
if (CaptureChannels == 1 && source is null && stereoApplied) ClearStereo(session);
if (source is not null)
{
if (!port.SetPreferredDataSource(source, out NSError? portError)) throw new InvalidOperationException(portError.LocalizedDescription);
if (!port.SetPreferredDataSource(source, out NSError? portError)) throw new InvalidOperationException(portError?.LocalizedDescription ?? "Could not select the microphone data source.");
if (CaptureChannels == 2) SetStereoPolarPattern(source);
else if (SelectedPolarPattern != AVAudioDataSourcePolarPattern.Unknown &&
!source.SetPreferredPolarPattern(SelectedPolarPattern, out NSError? patternError))
throw new InvalidOperationException(patternError.LocalizedDescription);
throw new InvalidOperationException(patternError?.LocalizedDescription ?? "Could not select the microphone polar pattern.");
}
if (!session.SetPreferredInput(port, out NSError? inputError)) throw new InvalidOperationException(inputError.LocalizedDescription);
if (!session.SetPreferredInput(port, out NSError? inputError)) throw new InvalidOperationException(inputError?.LocalizedDescription ?? "Could not select the microphone input.");
// The working Swift client deliberately does not call
// SetPreferredInputNumberOfChannels: doing so disrupts stereo + A2DP routing. The stereo
// capsule and polar pattern above cause the input node to expose its two-channel format.
if (CaptureChannels == 2 && source is not null &&
!session.SetInputDataSource(source, out NSError? sourceError))
throw new InvalidOperationException(sourceError.LocalizedDescription);
throw new InvalidOperationException(sourceError?.LocalizedDescription ?? "Could not activate the stereo microphone source.");
Console.Error.WriteLine($"VC_ROUTE_SELECT port={port.PortName} source={source?.DataSourceName ?? "none"} " +
$"stereoPattern={source is not null && SupportsStereoPolarPattern(source)} " +
$"patterns={string.Join(',', source?.SupportedPolarPatterns?.Select(value => value.ToString()) ?? [])}");
@@ -265,7 +265,9 @@ internal sealed class IosAudioRouter
{
// A released session has no route the next graph can be compared against.
watchdog?.Dispose(); watchdog = null; ResetWatchdog(); ResetWatchdogAttempts();
AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _);
if (!AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? error))
Console.Error.WriteLine($"VC_DEACTIVATE failed: {error?.LocalizedDescription ?? "no system error"}");
else Console.Error.WriteLine("VC_DEACTIVATE succeeded");
}
internal void EnsureAudio(string reason)
{
@@ -0,0 +1,144 @@
#if DEBUG
using System.Buffers.Binary;
using System.Runtime.InteropServices;
using Foundation;
using UIKit;
using VoiceCat.Core;
namespace VoiceCat.iOS;
// An opt-in simulator gate for the real UIKit model and audio graph. Local development only.
internal static class SimulatorSmoke
{
internal static void RunIfRequested()
{
string? endpoint = Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE");
if (endpoint is null || !RuntimeInformation.RuntimeIdentifier.StartsWith("iossimulator", StringComparison.Ordinal)) return;
UIApplication.SharedApplication.BeginInvokeOnMainThread(async () =>
{
try
{
string[] parts = endpoint.Split(':', 3);
Guid id = Guid.Parse("959da92f-f088-452d-a870-4c15007a6fd3");
ServerProfile guest = ServerProfile.Create(parts[0], ushort.Parse(parts[1]), ServerAuthentication.Guest, nickname: "smoke", id: id);
ServerProfile profile = ServerProfile.Create(parts[0], ushort.Parse(parts[1]), ServerAuthentication.Account, "smoke", id: id);
AppModel model = AppModel.Shared;
bool share = Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE_MODE") == "share";
string? ring = share ? PrepareRing() : null;
model.IdentityRequested += _ => model.ResolveIdentity(true);
if (Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE_MODE") != "resume")
{
model.UpsertProfile(guest, null);
model.UpsertProfile(profile, parts[2]);
}
model.Load();
profile = model.Profiles.Single(value => value.Id == id);
Console.Error.WriteLine("VC_SIM phase=connect");
await model.ConnectAsync(profile);
Console.Error.WriteLine($"VC_SIM phase=connected audioRunning={IosAudioEngine.Shared.IsRunning}");
Require(model.IsConnected, "Account login did not connect.");
if (ring is not null)
{
// Start a synthetic producer while the microphone is off. This exercises
// screen-only subscription and the real BroadcastChanged audio recovery.
await Task.Delay(300);
Require(!model.ScreenRingMapped && !model.ScreenSharing,
"An unchanged ring from a previous broadcast remained mapped.");
WriteRing(ring, active: true, write: 3840);
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.ScreenSharing, "Screen stream did not stop.");
Require(!model.ScreenRingMapped, "The stopped screen ring remained mapped.");
Console.Error.WriteLine("VC_SIM phase=screenSharePassed");
}
await model.ToggleVoiceAsync();
Console.Error.WriteLine($"VC_SIM phase=joined audioRunning={IosAudioEngine.Shared.IsRunning}");
await Task.Delay(2500);
Require(model.VoiceJoined && IosAudioEngine.Shared.IsRunning, "The first voice graph stopped.");
long beforeShare = IosAudioEngine.Shared.RenderCallbacks;
IosAudioRouter.Shared.Recover("simulated screen sharing start", force: false);
await Task.Delay(1000);
Require(IosAudioEngine.Shared.IsRunning && IosAudioEngine.Shared.RenderCallbacks > beforeShare,
"Screen sharing recovery stopped the live audio graph.");
if (ring is not null)
{
WriteRing(ring, active: true, write: 3840);
await Task.Delay(300);
WriteRing(ring, active: true, write: 5760);
await WaitUntil(() => model.ScreenSharing, "Screen stream did not start during a voice call.");
Require(IosAudioEngine.Shared.IsRunning, "Starting screen sharing stopped the voice graph.");
}
await model.ToggleVoiceAsync();
if (ring is not null)
{
Require(model.ScreenSharing, "Leaving the microphone call unsubscribed active screen audio.");
WriteRing(ring, active: false, write: 5760);
await WaitUntil(() => !model.ScreenSharing, "The second screen stream did not stop.");
}
await model.DisconnectAsync();
await Task.Delay(3000);
model.Load();
profile = model.Profiles.Single(value => value.Id == id);
Console.Error.WriteLine("VC_SIM phase=reconnect");
await model.ConnectAsync(profile);
Console.Error.WriteLine($"VC_SIM phase=reconnected audioRunning={IosAudioEngine.Shared.IsRunning}");
Require(model.IsConnected, "Saved account login did not reconnect.");
await Task.Delay(2500);
Console.Error.WriteLine($"VC_SIM phase=reconnectedSettled audioRunning={IosAudioEngine.Shared.IsRunning}");
Require(IosAudioEngine.Shared.IsRunning, "The reconnect audio graph stopped.");
await model.ToggleVoiceAsync();
Console.Error.WriteLine($"VC_SIM phase=rejoined audioRunning={IosAudioEngine.Shared.IsRunning}");
await Task.Delay(2500);
Console.Error.WriteLine($"VC_SIM phase=rejoinedSettled audioRunning={IosAudioEngine.Shared.IsRunning}");
Require(model.VoiceJoined && IosAudioEngine.Shared.IsRunning, "The rejoined voice graph stopped.");
await model.DisconnectAsync();
Console.Error.WriteLine("VC_SIM phase=passed");
}
catch (Exception exception) { Console.Error.WriteLine($"VC_SIM phase=failed exception={exception}"); }
});
}
private static void Require(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
private static string PrepareRing()
{
string root = NSFileManager.DefaultManager.GetContainerUrl(IosConstants.AppGroup)?.Path
?? throw new InvalidOperationException("Simulator App Group is unavailable.");
string path = Path.Combine(root, "voicecat", "broadcast_audio.ring");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var file = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
file.SetLength(64 + 96_000 * sizeof(short));
byte[] header = new byte[20];
BinaryPrimitives.WriteUInt32LittleEndian(header, 0x56434252);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(4), 1);
file.Write(header);
file.Dispose();
WriteRing(path, active: true, write: 1920);
return path;
}
private static void WriteRing(string path, bool active, ulong write)
{
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);
BinaryPrimitives.WriteUInt32LittleEndian(value, active ? 1u : 0u);
file.Position = 16; file.Write(value, 0, 4);
}
private static async Task WaitUntil(Func<bool> condition, string error)
{
for (int attempt = 0; attempt < 30; attempt++)
{
if (condition()) return;
await Task.Delay(100);
}
throw new InvalidOperationException(error);
}
}
#endif
+36
View File
@@ -133,6 +133,42 @@ CoreDevice can also install the app successfully and then reject only the launch
deployment; rebuilding is unnecessary. Occasional CoreDeviceService initialization timeouts while
listing devices or processes do not imply that an already-confirmed install or launch failed.
## Simulator connection and audio gate
The Debug app has an opt-in simulator smoke gate. Start a local VoiceCat server and create an
account named `smoke`, then build the `iossimulator-arm64` app. The simulator build needs an
App Group entitlement for the saved-password and screen-ring checks; sign the built app
ad hoc with `clients/apple/VoiceCat.iOS/Entitlements.plist` before installing it. A clean
build avoids stale simulator AOT modules after managed code changes.
```bash
dotnet restore clients/apple/VoiceCat.iOS/VoiceCat.iOS.csproj \
-p:RuntimeIdentifier=iossimulator-arm64 -p:VoiceCatIosStatic=true --force-evaluate
dotnet clean clients/apple/VoiceCat.iOS/VoiceCat.iOS.csproj -c Debug \
-p:RuntimeIdentifier=iossimulator-arm64
dotnet build clients/apple/VoiceCat.iOS/VoiceCat.iOS.csproj -c Debug --no-restore \
-p:RuntimeIdentifier=iossimulator-arm64
codesign --force --sign - --entitlements clients/apple/VoiceCat.iOS/Entitlements.plist \
clients/apple/VoiceCat.iOS/bin/Debug/net10.0-ios27.0/iossimulator-arm64/VoiceCat.iOS.app
xcrun simctl install booted \
clients/apple/VoiceCat.iOS/bin/Debug/net10.0-ios27.0/iossimulator-arm64/VoiceCat.iOS.app
```
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
`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
does not validate Bluetooth hardware, the iOS 27 ScreenCaptureKit picker, or suspension on
a physical device.
The simulator restore can rewrite `packages.ios.lock.json`; retain both checked-in iOS
runtime entries when reviewing the diff.
## Verified hardware result
On 2026-09-22, the Debug build completed with .NET 10.0.401 and Xcode 27.0. The host and
+1
View File
@@ -264,6 +264,7 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
}
// A liveness failure has already recorded the real cause and cancelled this read, so do
// not replace it with the cancellation it produced.
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
catch (Exception exception) { failure = exception; ConnectionFailure ??= exception; }
finally
{
@@ -39,6 +39,7 @@ public class ManagedClientTests
await Assert.ThrowsAsync<InvalidOperationException>(() => Connect(alice, fixture));
Assert.Equal(ClientConnectionState.Connected, alice.State);
await alice.DisconnectAsync();
Assert.Null(alice.ConnectionFailure);
await Event(bob, e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left);
await Connect(alice, fixture); Assert.True((await alice.AuthenticateGuestAsync("Returned")).Ok);
}
@@ -131,7 +131,7 @@ public class PublishServerScriptTests
Assert.Single(Regex.Matches(pump, "MemoryMappedFile.CreateFromFile"));
Assert.Contains("private bool OpenMapping()", pump);
Assert.Contains("private void CloseMapping()", pump);
Assert.Contains("Thread.Sleep(5)", pump);
Assert.Contains("Thread.Sleep(view is null ? 100 : 5)", pump);
Assert.Contains("File.Exists(path)", pump);
Assert.DoesNotContain("using MemoryMappedFile", pump);
Assert.DoesNotContain("using MemoryMappedViewAccessor", pump);