Stabilize iOS reconnect and screen audio sessions
This commit is contained in:
+13
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
int channels = IosAudioRouter.Shared.CaptureChannels;
|
uint startedStream = 0;
|
||||||
StreamInfo stream = await active.StartStreamAsync(StreamKind.StreamMic, "Microphone", channels);
|
try
|
||||||
microphoneStream = stream.StreamId; IosAudioEngine.Shared.StartMicrophone(stream.StreamId, channels); feedback.Play(SoundEvent.VoiceOn); Notify();
|
{
|
||||||
|
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()
|
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); }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user