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 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. 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 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 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 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 - 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. 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 - 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 20/40/60 ms buffering, duration-aware DRED/FEC, automatic packet-loss feedback, and mismatched
input/output endpoints. input/output endpoints.
@@ -9,6 +9,9 @@ internal sealed class AppDelegate : UIApplicationDelegate
public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions) public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions)
{ {
AppModel.Shared.Load(); AppModel.Shared.Load();
#if DEBUG
SimulatorSmoke.RunIfRequested();
#endif
return true; return true;
} }
+31 -9
View File
@@ -51,6 +51,9 @@ internal sealed class AppModel
internal bool IsConnecting { get; private set; } internal bool IsConnecting { get; private set; }
internal bool VoiceJoined => microphoneStream != 0; internal bool VoiceJoined => microphoneStream != 0;
internal bool ScreenSharing => broadcast?.IsActive == true; internal bool ScreenSharing => broadcast?.IsActive == true;
#if DEBUG
internal bool ScreenRingMapped => broadcast?.IsMappingOpen == true;
#endif
internal bool IsBackgrounded => backgrounded; internal bool IsBackgrounded => backgrounded;
internal float MicrophoneLevel { get; private set; } internal float MicrophoneLevel { get; private set; }
internal string Status { get; private set; } = "Not connected"; internal string Status { get; private set; } = "Not connected";
@@ -162,7 +165,7 @@ internal sealed class AppModel
} }
catch (Exception exception) catch (Exception exception)
{ {
System.Diagnostics.Debug.WriteLine($"Connection failed: {exception}"); Console.Error.WriteLine($"VoiceCat connection setup failed: {exception}");
IsConnecting = false; Status = exception.Message; IsConnecting = false; Status = exception.Message;
if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(releaseAudio: !restoring); } if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(releaseAudio: !restoring); }
await next.DisposeAsync(); await next.DisposeAsync();
@@ -270,15 +273,35 @@ internal sealed class AppModel
if (microphoneStream != 0) if (microphoneStream != 0)
{ {
IosAudioEngine.Shared.StopMicrophone(); active.StopStream(microphoneStream); 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) AVFoundation.AVAuthorizationStatus authorization = AVFoundation.AVCaptureDevice.GetAuthorizationStatus(AVFoundation.AVAuthorizationMediaType.Audio);
await AVFoundation.AVCaptureDevice.RequestAccessForMediaTypeAsync(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(); VoiceSubscriptionResult subscribed = await active.SubscribeVoiceAsync();
if (!subscribed.Ok) throw new InvalidOperationException(subscribed.Error); if (!subscribed.Ok) throw new InvalidOperationException(subscribed.Error);
uint startedStream = 0;
try
{
int channels = IosAudioRouter.Shared.CaptureChannels; int channels = IosAudioRouter.Shared.CaptureChannels;
StreamInfo stream = await active.StartStreamAsync(StreamKind.StreamMic, "Microphone", channels); StreamInfo stream = await active.StartStreamAsync(StreamKind.StreamMic, "Microphone", channels);
microphoneStream = stream.StreamId; IosAudioEngine.Shared.StartMicrophone(stream.StreamId, channels); feedback.Play(SoundEvent.VoiceOn); Notify(); 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() internal void ToggleScreenAudio()
@@ -390,10 +413,9 @@ internal sealed class AppModel
{ {
UIApplication.SharedApplication.BeginInvokeOnMainThread(() => UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{ {
// Presenting either system picker can replace or interrupt the app's audio session. // Presenting either picker can change the audio route. Keep a healthy graph; if the
// Rebuild after the producer becomes active so playback and an existing mic tap are // picker stopped it, Recover resumes the same graph or rebuilds for changed hardware.
// attached to the session that will remain in use for the broadcast. IosAudioRouter.Shared.Recover(ScreenSharing ? "screen sharing started" : "screen sharing stopped", force: false);
if (ScreenSharing) IosAudioRouter.Shared.Recover("screen sharing started");
Notify(); Notify();
}); });
} }
@@ -18,9 +18,15 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
private uint streamId; private uint streamId;
private bool active; private bool active;
private int generation; private int generation;
private ulong observedWrite;
private ulong idleWrite;
private bool idleWriteSeen;
internal event Action? Changed; internal event Action? Changed;
internal bool IsActive => active; internal bool IsActive => active;
#if DEBUG
internal bool IsMappingOpen => view is not null;
#endif
internal void Start(VoiceCatClient owner) internal void Start(VoiceCatClient owner)
{ {
@@ -37,8 +43,17 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
while (!token.IsCancellationRequested) while (!token.IsCancellationRequested)
{ {
try { DrainAsync(token).GetAwaiter().GetResult(); } try { DrainAsync(token).GetAwaiter().GetResult(); }
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or OperationCanceledException) { CloseMapping(); } catch (OperationCanceledException) when (token.IsCancellationRequested) { break; }
if (!token.IsCancellationRequested) Thread.Sleep(5); 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(); } finally { CloseMapping(); }
@@ -49,11 +64,18 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
if (!OpenMapping()) return; if (!OpenMapping()) return;
MemoryMappedViewAccessor pages = view!; MemoryMappedViewAccessor pages = view!;
bool active = pages.ReadUInt32(16) != 0; 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."); VoiceCatClient owner = client ?? throw new IOException("Client disconnected.");
if (streamId == 0) 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); 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); 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)) 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 // Keep a mapping only while a broadcast is active. An inactive mapping holds an open handle
// pump's whole life. Creating and disposing a mapping (plus its container lookup and path // into the shared App Group container through suspension, which iOS may terminate as a shared
// strings) every 5 ms tick allocated steadily at 200 Hz and churned the GC under long calls; // file lock (0xdead10cc). Poll the small header with a short-lived read when idle, then map
// the mapping is now built on the ring's transitions and reused until the file disappears or // once for the active broadcast's high-rate drain.
// a drain fails.
private bool OpenMapping() private bool OpenMapping()
{ {
path ??= ResolvePath(); path ??= ResolvePath();
@@ -92,7 +113,28 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
if (present) return true; if (present) return true;
CloseMapping(); return false; 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); MemoryMappedFile candidate = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, Header + Capacity * sizeof(short), MemoryMappedFileAccess.ReadWrite);
try try
{ {
@@ -101,7 +143,7 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
{ {
pages.Dispose(); throw new InvalidDataException("Unsupported broadcast ring."); 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; } catch { candidate.Dispose(); throw; }
} }
@@ -116,12 +158,14 @@ internal sealed class BroadcastAudioPump : IAsyncDisposable
{ {
view?.Dispose(); view = null; view?.Dispose(); view = null;
map?.Dispose(); map = null; map?.Dispose(); map = null;
observedWrite = 0;
idleWriteSeen = false;
} }
private void StopStream() private void StopStream()
{ {
uint id = streamId; streamId = 0; if (id == 0 || client?.State != ClientConnectionState.Connected) return; 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); } 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 // 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. // profile renegotiation for a transport blip that changed no audio configuration.
if (engine?.Running == true) { playbackRing.Resynchronize(); return; } if (engine?.Running == true) { playbackRing.Resynchronize(); return; }
if (ResumeStoppedGraph()) return;
Volatile.Write(ref microphone, null); Rebuild(); Volatile.Write(ref microphone, null); Rebuild();
} }
@@ -134,6 +135,7 @@ internal sealed class IosAudioEngine
int channels = IosAudioRouter.Shared.CaptureChannels; int channels = IosAudioRouter.Shared.CaptureChannels;
if (!force && engine?.Running == true && tapInstalled && builtCaptureChannels == channels if (!force && engine?.Running == true && tapInstalled && builtCaptureChannels == channels
&& voiceProcessing == IosAudioRouter.Shared.UsesVoiceProcessing && !HardwareChanged()) return; && voiceProcessing == IosAudioRouter.Shared.UsesVoiceProcessing && !HardwareChanged()) return;
if (!force && ResumeStoppedGraph()) return;
if (current is not null) if (current is not null)
{ {
// Stop the old tap before publishing a route with a different sample width. Otherwise // 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 // 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. // about to be, and a rebuild here would start the cycle over.
if (Settling) return true; if (Settling) return true;
if (ResumeStoppedGraph()) return true;
Rebuild(); return engine?.Running == 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 = "") private void Rebuild([System.Runtime.CompilerServices.CallerMemberName] string cause = "")
{ {
Console.Error.WriteLine($"VC_REBUILD cause={cause} running={engine?.Running == true} callbacks={RenderCallbacks}"); 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. // formats are queried or nodes are connected so the graph is built from the final IO.
input = next.InputNode; input = next.InputNode;
if (!input.SetVoiceProcessingEnabled(IosAudioRouter.Shared.UsesVoiceProcessing, out NSError? processingError)) 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; if (IosAudioRouter.Shared.UsesVoiceProcessing) input.VoiceProcessingAgcEnabled = IosAudioRouter.Shared.AutomaticGainControl;
} }
outputFormat = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false); outputFormat = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false);
@@ -195,6 +218,8 @@ internal sealed class IosAudioEngine
if (captures) if (captures)
{ {
AVAudioFormat inputFormat = input!.GetBusOutputFormat(0); 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} " + Console.Error.WriteLine($"VC_GRAPH vpio={IosAudioRouter.Shared.UsesVoiceProcessing} requestedCh={captureChannels} " +
$"inputCh={inputFormat.ChannelCount} inputRate={inputFormat.SampleRate}"); $"inputCh={inputFormat.ChannelCount} inputRate={inputFormat.SampleRate}");
builtInputRate = inputFormat.SampleRate; builtInputChannels = inputFormat.ChannelCount; builtInputRate = inputFormat.SampleRate; builtInputChannels = inputFormat.ChannelCount;
@@ -231,15 +256,15 @@ internal sealed class IosAudioEngine
captureSink.OutputVolume = 0f; captureSink.OutputVolume = 0f;
} }
next.Prepare(); 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; engine = next;
{ {
AVAudioSession live = AVAudioSession.SharedInstance(); AVAudioSession live = AVAudioSession.SharedInstance();
Console.Error.WriteLine($"VC_START running={next.Running} builtCh={builtInputChannels} sessionRate={live.SampleRate} " + Console.Error.WriteLine($"VC_START running={next.Running} builtCh={builtInputChannels} sessionRate={live.SampleRate} " +
$"sessionInCh={live.InputNumberOfChannels} sessionOutCh={live.OutputNumberOfChannels} inputAvailable={live.InputAvailable} " + $"sessionInCh={live.InputNumberOfChannels} sessionOutCh={live.OutputNumberOfChannels} inputAvailable={live.InputAvailable} " +
$"other={live.OtherAudioPlaying} mode={live.Mode} options={live.CategoryOptions} io={live.IOBufferDuration:F4} " + $"other={live.OtherAudioPlaying} mode={live.Mode} options={live.CategoryOptions} io={live.IOBufferDuration:F4} " +
$"out={string.Join(',', live.CurrentRoute.Outputs.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()))}"); $"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 // 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. // invisible at start: check again once the IO has had time to come up.
AVAudioEngine started = next; AVAudioEngine started = next;
@@ -380,7 +405,7 @@ internal sealed class IosAudioEngine
{ {
IsConnected = false; Volatile.Write(ref microphone, null); IsConnected = false; Volatile.Write(ref microphone, null);
if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm; if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm;
DestroyGraph(); client = null; IosAudioRouter.Shared.Deactivate(); engine?.Pause(); client = null; IosAudioRouter.Shared.Deactivate();
} }
private void DestroyGraph() private void DestroyGraph()
+10 -8
View File
@@ -127,7 +127,7 @@ internal sealed class IosAudioRouter
{ {
AVAudioSession session = AVAudioSession.SharedInstance(); AVAudioSession session = AVAudioSession.SharedInstance();
Inputs = session.AvailableInputs?.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray() ?? []; 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 // 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 // can pin the built-in mic after a speaker fallback and displace a Bluetooth HFP route
// on the next graph rebuild. // on the next graph rebuild.
@@ -145,10 +145,10 @@ internal sealed class IosAudioRouter
if (BluetoothMode == IosBluetoothMode.BuiltInMicSpeaker || ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp) options |= AVAudioSessionCategoryOptions.DefaultToSpeaker; if (BluetoothMode == IosBluetoothMode.BuiltInMicSpeaker || ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp) options |= AVAudioSessionCategoryOptions.DefaultToSpeaker;
AVAudioSessionMode mode = CaptureChannels == 2 ? AVAudioSessionMode.Default : MicMode == IosMicMode.Raw ? AVAudioSessionMode.Measurement AVAudioSessionMode mode = CaptureChannels == 2 ? AVAudioSessionMode.Default : MicMode == IosMicMode.Raw ? AVAudioSessionMode.Measurement
: BluetoothMode == IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionMode.VideoRecording : AVAudioSessionMode.VoiceChat; : 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 _); session.SetPreferredSampleRate(48_000, out _); session.SetPreferredIOBufferDuration(0.02, out _);
if (configureInput) ApplyInputSelection(session); 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} " + Console.Error.WriteLine($"VC_ROUTE preset={Preset} requestedCh={CaptureChannels} sessionCh={session.InputNumberOfChannels} " +
$"preferred={session.PreferredInput?.PortName ?? "default"} dataSource={session.InputDataSource?.DataSourceName ?? "default"} " + $"preferred={session.PreferredInput?.PortName ?? "default"} dataSource={session.InputDataSource?.DataSourceName ?? "default"} " +
$"pattern={session.InputDataSource?.SelectedPolarPattern.ToString() ?? "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 (CaptureChannels == 1 && source is null && stereoApplied) ClearStereo(session);
if (source is not null) 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); if (CaptureChannels == 2) SetStereoPolarPattern(source);
else if (SelectedPolarPattern != AVAudioDataSourcePolarPattern.Unknown && else if (SelectedPolarPattern != AVAudioDataSourcePolarPattern.Unknown &&
!source.SetPreferredPolarPattern(SelectedPolarPattern, out NSError? patternError)) !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 // The working Swift client deliberately does not call
// SetPreferredInputNumberOfChannels: doing so disrupts stereo + A2DP routing. The stereo // SetPreferredInputNumberOfChannels: doing so disrupts stereo + A2DP routing. The stereo
// capsule and polar pattern above cause the input node to expose its two-channel format. // capsule and polar pattern above cause the input node to expose its two-channel format.
if (CaptureChannels == 2 && source is not null && if (CaptureChannels == 2 && source is not null &&
!session.SetInputDataSource(source, out NSError? sourceError)) !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"} " + Console.Error.WriteLine($"VC_ROUTE_SELECT port={port.PortName} source={source?.DataSourceName ?? "none"} " +
$"stereoPattern={source is not null && SupportsStereoPolarPattern(source)} " + $"stereoPattern={source is not null && SupportsStereoPolarPattern(source)} " +
$"patterns={string.Join(',', source?.SupportedPolarPatterns?.Select(value => value.ToString()) ?? [])}"); $"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. // A released session has no route the next graph can be compared against.
watchdog?.Dispose(); watchdog = null; ResetWatchdog(); ResetWatchdogAttempts(); 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) 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 deployment; rebuilding is unnecessary. Occasional CoreDeviceService initialization timeouts while
listing devices or processes do not imply that an already-confirmed install or launch failed. 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 ## Verified hardware result
On 2026-09-22, the Debug build completed with .NET 10.0.401 and Xcode 27.0. The host and 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 // A liveness failure has already recorded the real cause and cancelled this read, so do
// not replace it with the cancellation it produced. // not replace it with the cancellation it produced.
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
catch (Exception exception) { failure = exception; ConnectionFailure ??= exception; } catch (Exception exception) { failure = exception; ConnectionFailure ??= exception; }
finally finally
{ {
@@ -39,6 +39,7 @@ public class ManagedClientTests
await Assert.ThrowsAsync<InvalidOperationException>(() => Connect(alice, fixture)); await Assert.ThrowsAsync<InvalidOperationException>(() => Connect(alice, fixture));
Assert.Equal(ClientConnectionState.Connected, alice.State); Assert.Equal(ClientConnectionState.Connected, alice.State);
await alice.DisconnectAsync(); await alice.DisconnectAsync();
Assert.Null(alice.ConnectionFailure);
await Event(bob, e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left); await Event(bob, e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left);
await Connect(alice, fixture); Assert.True((await alice.AuthenticateGuestAsync("Returned")).Ok); 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.Single(Regex.Matches(pump, "MemoryMappedFile.CreateFromFile"));
Assert.Contains("private bool OpenMapping()", pump); Assert.Contains("private bool OpenMapping()", pump);
Assert.Contains("private void CloseMapping()", 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.Contains("File.Exists(path)", pump);
Assert.DoesNotContain("using MemoryMappedFile", pump); Assert.DoesNotContain("using MemoryMappedFile", pump);
Assert.DoesNotContain("using MemoryMappedViewAccessor", pump); Assert.DoesNotContain("using MemoryMappedViewAccessor", pump);