diff --git a/PROGRESS.md b/PROGRESS.md index 5cf28ae..997690a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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. diff --git a/clients/apple/VoiceCat.iOS/AppDelegate.cs b/clients/apple/VoiceCat.iOS/AppDelegate.cs index ce1c2d8..f494811 100644 --- a/clients/apple/VoiceCat.iOS/AppDelegate.cs +++ b/clients/apple/VoiceCat.iOS/AppDelegate.cs @@ -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; } diff --git a/clients/apple/VoiceCat.iOS/AppModel.cs b/clients/apple/VoiceCat.iOS/AppModel.cs index a76160c..f8c85d4 100644 --- a/clients/apple/VoiceCat.iOS/AppModel.cs +++ b/clients/apple/VoiceCat.iOS/AppModel.cs @@ -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(); }); } diff --git a/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs b/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs index 5e1a135..44dc437 100644 --- a/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs +++ b/clients/apple/VoiceCat.iOS/BroadcastAudioPump.cs @@ -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 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); } diff --git a/clients/apple/VoiceCat.iOS/IosAudioEngine.cs b/clients/apple/VoiceCat.iOS/IosAudioEngine.cs index 8728e19..bde13d3 100644 --- a/clients/apple/VoiceCat.iOS/IosAudioEngine.cs +++ b/clients/apple/VoiceCat.iOS/IosAudioEngine.cs @@ -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() diff --git a/clients/apple/VoiceCat.iOS/IosAudioRouter.cs b/clients/apple/VoiceCat.iOS/IosAudioRouter.cs index 40476cf..5aa7345 100644 --- a/clients/apple/VoiceCat.iOS/IosAudioRouter.cs +++ b/clients/apple/VoiceCat.iOS/IosAudioRouter.cs @@ -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) { diff --git a/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs b/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs new file mode 100644 index 0000000..9babc17 --- /dev/null +++ b/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs @@ -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 condition, string error) + { + for (int attempt = 0; attempt < 30; attempt++) + { + if (condition()) return; + await Task.Delay(100); + } + throw new InvalidOperationException(error); + } +} +#endif diff --git a/docs/ios-deploy.md b/docs/ios-deploy.md index 31e42f7..c1b3ea4 100644 --- a/docs/ios-deploy.md +++ b/docs/ios-deploy.md @@ -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::` 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 diff --git a/src/VoiceCat.Core/VoiceCatClient.cs b/src/VoiceCat.Core/VoiceCatClient.cs index 074332f..2a5edaf 100644 --- a/src/VoiceCat.Core/VoiceCatClient.cs +++ b/src/VoiceCat.Core/VoiceCatClient.cs @@ -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 { diff --git a/tests/VoiceCat.Tests/ManagedClientTests.cs b/tests/VoiceCat.Tests/ManagedClientTests.cs index 0a4b45a..adbe2d7 100644 --- a/tests/VoiceCat.Tests/ManagedClientTests.cs +++ b/tests/VoiceCat.Tests/ManagedClientTests.cs @@ -39,6 +39,7 @@ public class ManagedClientTests await Assert.ThrowsAsync(() => 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); } diff --git a/tests/VoiceCat.Tests/PublishServerScriptTests.cs b/tests/VoiceCat.Tests/PublishServerScriptTests.cs index d3bb8be..9b9375f 100644 --- a/tests/VoiceCat.Tests/PublishServerScriptTests.cs +++ b/tests/VoiceCat.Tests/PublishServerScriptTests.cs @@ -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);