From c1763c9a5da96036c71c76ff91bb4d9a25ccf506 Mon Sep 17 00:00:00 2001 From: Talon Date: Sat, 26 Sep 2026 21:02:54 +0200 Subject: [PATCH] Activate iOS audio session before stereo input selection --- PROGRESS.md | 6 ++++++ clients/apple/VoiceCat.iOS/IosAudioRouter.cs | 10 +++++----- clients/apple/VoiceCat.iOS/SimulatorSmoke.cs | 2 ++ docs/ios-deploy.md | 2 ++ tests/VoiceCat.Tests/PublishServerScriptTests.cs | 7 +++++-- 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 997690a..ab6319b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -54,6 +54,12 @@ no longer held mapped through suspension. A TestFlight report from build 2026092 `0xdead10cc` shared-file-lock termination; physical-device validation of that mitigation remains open. +Stereo capture now selects the preferred input only after activating the audio session and no +longer calls `SetInputDataSource` with a source from a port that may not yet be current. A Debug +build installed on an iPhone 16 Pro Max connected with the StereoMicrophone preset and recorded +a two-channel input graph with distinct left/right PCM and encoded packets. The simulator's mono +input also completed stereo-preset login, voice join, disconnect, and reconnect. + 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 diff --git a/clients/apple/VoiceCat.iOS/IosAudioRouter.cs b/clients/apple/VoiceCat.iOS/IosAudioRouter.cs index 5aa7345..405db46 100644 --- a/clients/apple/VoiceCat.iOS/IosAudioRouter.cs +++ b/clients/apple/VoiceCat.iOS/IosAudioRouter.cs @@ -147,8 +147,9 @@ internal sealed class IosAudioRouter : BluetoothMode == IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionMode.VideoRecording : AVAudioSessionMode.VoiceChat; 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 ?? "Could not activate the iOS audio session."); + // Apple requires an active session before selecting a preferred input or data source. + if (configureInput) ApplyInputSelection(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"}"); @@ -172,6 +173,7 @@ internal sealed class IosAudioRouter AVAudioSessionPortDescription? port = session.AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId); if (CaptureChannels == 2) port ??= session.AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic); if (port is null) { if (CaptureChannels == 1 && stereoApplied) ClearStereo(session); return; } + if (!session.SetPreferredInput(port, out NSError? inputError)) throw new InvalidOperationException(inputError?.LocalizedDescription ?? "Could not select the microphone input."); if (CaptureChannels == 2) { // Polar-pattern discovery alone is insufficient on current iPhones: until a stereo @@ -195,13 +197,11 @@ internal sealed class IosAudioRouter !source.SetPreferredPolarPattern(SelectedPolarPattern, out NSError? patternError)) throw new InvalidOperationException(patternError?.LocalizedDescription ?? "Could not select the microphone polar pattern."); } - 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 ?? "Could not activate the stereo microphone source."); + // A port preference is enough. SetInputDataSource only accepts a member of the *current* + // port's InputDataSources, which can still be a different route after SetPreferredInput. 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()) ?? [])}"); diff --git a/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs b/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs index 9babc17..c8ed276 100644 --- a/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs +++ b/clients/apple/VoiceCat.iOS/SimulatorSmoke.cs @@ -32,6 +32,8 @@ internal static class SimulatorSmoke model.UpsertProfile(profile, parts[2]); } model.Load(); + if (Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE_MODE") == "stereo") + IosAudioRouter.Shared.SelectPreset(IosAudioPreset.StereoMicrophone); profile = model.Profiles.Single(value => value.Id == id); Console.Error.WriteLine("VC_SIM phase=connect"); await model.ConnectAsync(profile); diff --git a/docs/ios-deploy.md b/docs/ios-deploy.md index c1b3ea4..ca2109b 100644 --- a/docs/ios-deploy.md +++ b/docs/ios-deploy.md @@ -163,6 +163,8 @@ synthetic screen audio with the microphone off and during a voice call, voice jo 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 +gate also accepts mode `stereo` to select the StereoMicrophone preset before login and voice join; +the simulator exposes only a mono microphone, so check stereo capture on an iPhone. It also does not validate Bluetooth hardware, the iOS 27 ScreenCaptureKit picker, or suspension on a physical device. diff --git a/tests/VoiceCat.Tests/PublishServerScriptTests.cs b/tests/VoiceCat.Tests/PublishServerScriptTests.cs index 9b9375f..3ce0e46 100644 --- a/tests/VoiceCat.Tests/PublishServerScriptTests.cs +++ b/tests/VoiceCat.Tests/PublishServerScriptTests.cs @@ -207,8 +207,11 @@ public class PublishServerScriptTests string router = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "VoiceCat.iOS", "IosAudioRouter.cs")); string engine = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs")); - Assert.True(router.IndexOf("ApplyInputSelection(session)", StringComparison.Ordinal) < - router.IndexOf("SetActive(true", StringComparison.Ordinal)); + Assert.True(router.IndexOf("SetActive(true", StringComparison.Ordinal) < + router.IndexOf("ApplyInputSelection(session)", StringComparison.Ordinal)); + Assert.True(router.IndexOf("session.SetPreferredInput(port", StringComparison.Ordinal) < + router.IndexOf("port.SetPreferredDataSource(source", StringComparison.Ordinal)); + Assert.DoesNotContain("session.SetInputDataSource(source", router); Assert.DoesNotContain("session.SetPreferredInputNumberOfChannels", router); Assert.DoesNotContain("MaximumInputNumberOfChannels", router); Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.Portrait", router);