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
|
||||
backs off and stops after four failed attempts instead of rebuilding without end.
|
||||
|
||||
An explicit disconnect now pauses the prepared iOS graph and releases the audio session; the
|
||||
next connection resumes that graph before attempting a rebuild. A signed iOS 26 simulator gate
|
||||
verified guest-to-account editing, saved-password login after a process restart, two voice joins,
|
||||
and a screen-audio stream started before the microphone. Screen sharing no longer forces a graph
|
||||
rebuild, and its pump subscribes before announcing the stream. An idle shared broadcast ring is
|
||||
no longer held mapped through suspension. A TestFlight report from build 2026092504 was an iOS
|
||||
`0xdead10cc` shared-file-lock termination; physical-device validation of that mitigation remains
|
||||
open.
|
||||
|
||||
Voice-chat capture is not yet reliable on the first attempt: a graph with voice processing still
|
||||
sometimes fails to start and is only recovered by a watchdog rebuild, which costs seconds before
|
||||
audio appears. Speaker output is an explicit choice that no longer changes the preset. Every
|
||||
@@ -75,6 +84,10 @@ cap the applied Opus hint at 30%, and feed it back over TLS.
|
||||
|
||||
- Find why an iOS graph with voice processing intermittently starts and then stops within a
|
||||
second, so voice-chat capture comes up on the first attempt rather than after a watchdog rebuild.
|
||||
- On physical iOS 27 hardware, repeat account login after app restart, explicit disconnect and
|
||||
reconnect, microphone permission denial, ReplayKit and ScreenCaptureKit sharing with and
|
||||
without a joined microphone, and background/foreground transitions. Confirm the graph keeps
|
||||
rendering and that `0xdead10cc` does not recur.
|
||||
- Run real multi-person calls on Windows, macOS, and physical iOS hardware, including adaptive
|
||||
20/40/60 ms buffering, duration-aware DRED/FEC, automatic packet-loss feedback, and mismatched
|
||||
input/output endpoints.
|
||||
|
||||
@@ -9,6 +9,9 @@ internal sealed class AppDelegate : UIApplicationDelegate
|
||||
public override bool FinishedLaunching(UIApplication application, NSDictionary? launchOptions)
|
||||
{
|
||||
AppModel.Shared.Load();
|
||||
#if DEBUG
|
||||
SimulatorSmoke.RunIfRequested();
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -133,6 +133,42 @@ CoreDevice can also install the app successfully and then reject only the launch
|
||||
deployment; rebuilding is unnecessary. Occasional CoreDeviceService initialization timeouts while
|
||||
listing devices or processes do not imply that an already-confirmed install or launch failed.
|
||||
|
||||
## Simulator connection and audio gate
|
||||
|
||||
The Debug app has an opt-in simulator smoke gate. Start a local VoiceCat server and create an
|
||||
account named `smoke`, then build the `iossimulator-arm64` app. The simulator build needs an
|
||||
App Group entitlement for the saved-password and screen-ring checks; sign the built app
|
||||
ad hoc with `clients/apple/VoiceCat.iOS/Entitlements.plist` before installing it. A clean
|
||||
build avoids stale simulator AOT modules after managed code changes.
|
||||
|
||||
```bash
|
||||
dotnet restore clients/apple/VoiceCat.iOS/VoiceCat.iOS.csproj \
|
||||
-p:RuntimeIdentifier=iossimulator-arm64 -p:VoiceCatIosStatic=true --force-evaluate
|
||||
dotnet clean clients/apple/VoiceCat.iOS/VoiceCat.iOS.csproj -c Debug \
|
||||
-p:RuntimeIdentifier=iossimulator-arm64
|
||||
dotnet build clients/apple/VoiceCat.iOS/VoiceCat.iOS.csproj -c Debug --no-restore \
|
||||
-p:RuntimeIdentifier=iossimulator-arm64
|
||||
codesign --force --sign - --entitlements clients/apple/VoiceCat.iOS/Entitlements.plist \
|
||||
clients/apple/VoiceCat.iOS/bin/Debug/net10.0-ios27.0/iossimulator-arm64/VoiceCat.iOS.app
|
||||
xcrun simctl install booted \
|
||||
clients/apple/VoiceCat.iOS/bin/Debug/net10.0-ios27.0/iossimulator-arm64/VoiceCat.iOS.app
|
||||
```
|
||||
|
||||
Grant microphone access with `xcrun simctl privacy booted grant microphone
|
||||
me.iamtalon.voicecat`. Launch with `SIMCTL_CHILD_VOICECAT_SIM_SMOKE` set to
|
||||
`127.0.0.1:<port>:<smoke-account-password>` and
|
||||
`SIMCTL_CHILD_VOICECAT_SIM_SMOKE_MODE=share` using `xcrun simctl launch --console-pty booted
|
||||
me.iamtalon.voicecat`. The `share` mode checks that a stale ring stays unmapped, then exercises
|
||||
synthetic screen audio with the microphone off and during a voice call, voice join, disconnect,
|
||||
and reconnect. Look for
|
||||
`VC_SIM phase=screenSharePassed` and `VC_SIM phase=passed`. Terminate the app, then launch
|
||||
again with mode `resume` to verify the saved account after a process restart. The simulator
|
||||
does not validate Bluetooth hardware, the iOS 27 ScreenCaptureKit picker, or suspension on
|
||||
a physical device.
|
||||
|
||||
The simulator restore can rewrite `packages.ios.lock.json`; retain both checked-in iOS
|
||||
runtime entries when reviewing the diff.
|
||||
|
||||
## Verified hardware result
|
||||
|
||||
On 2026-09-22, the Debug build completed with .NET 10.0.401 and Xcode 27.0. The host and
|
||||
|
||||
@@ -264,6 +264,7 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
|
||||
}
|
||||
// A liveness failure has already recorded the real cause and cancelled this read, so do
|
||||
// not replace it with the cancellation it produced.
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
||||
catch (Exception exception) { failure = exception; ConnectionFailure ??= exception; }
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ public class ManagedClientTests
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => Connect(alice, fixture));
|
||||
Assert.Equal(ClientConnectionState.Connected, alice.State);
|
||||
await alice.DisconnectAsync();
|
||||
Assert.Null(alice.ConnectionFailure);
|
||||
await Event(bob, e => e.UserEvent?.Kind == UserEvent.Types.Kind.Left);
|
||||
await Connect(alice, fixture); Assert.True((await alice.AuthenticateGuestAsync("Returned")).Ok);
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ public class PublishServerScriptTests
|
||||
Assert.Single(Regex.Matches(pump, "MemoryMappedFile.CreateFromFile"));
|
||||
Assert.Contains("private bool OpenMapping()", pump);
|
||||
Assert.Contains("private void CloseMapping()", pump);
|
||||
Assert.Contains("Thread.Sleep(5)", pump);
|
||||
Assert.Contains("Thread.Sleep(view is null ? 100 : 5)", pump);
|
||||
Assert.Contains("File.Exists(path)", pump);
|
||||
Assert.DoesNotContain("using MemoryMappedFile", pump);
|
||||
Assert.DoesNotContain("using MemoryMappedViewAccessor", pump);
|
||||
|
||||
Reference in New Issue
Block a user