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
@@ -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