Activate iOS audio session before stereo input selection
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

This commit is contained in:
2026-09-26 21:02:54 +02:00
parent 4cc13a27a0
commit c1763c9a5d
5 changed files with 20 additions and 7 deletions
+6
View File
@@ -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 `0xdead10cc` shared-file-lock termination; physical-device validation of that mitigation remains
open. 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 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
+5 -5
View File
@@ -147,8 +147,9 @@ internal sealed class IosAudioRouter
: 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 ?? "Could not configure the iOS audio session."); 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 (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError)) throw new InvalidOperationException(activeError?.LocalizedDescription ?? "Could not activate the iOS audio 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} " + 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"}");
@@ -172,6 +173,7 @@ internal sealed class IosAudioRouter
AVAudioSessionPortDescription? port = session.AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId); AVAudioSessionPortDescription? port = session.AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId);
if (CaptureChannels == 2) port ??= session.AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic); if (CaptureChannels == 2) port ??= session.AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic);
if (port is null) { if (CaptureChannels == 1 && stereoApplied) ClearStereo(session); return; } 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) if (CaptureChannels == 2)
{ {
// Polar-pattern discovery alone is insufficient on current iPhones: until a stereo // 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)) !source.SetPreferredPolarPattern(SelectedPolarPattern, out NSError? patternError))
throw new InvalidOperationException(patternError?.LocalizedDescription ?? "Could not select the microphone polar pattern."); 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 // 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 && // A port preference is enough. SetInputDataSource only accepts a member of the *current*
!session.SetInputDataSource(source, out NSError? sourceError)) // port's InputDataSources, which can still be a different route after SetPreferredInput.
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()) ?? [])}");
@@ -32,6 +32,8 @@ internal static class SimulatorSmoke
model.UpsertProfile(profile, parts[2]); model.UpsertProfile(profile, parts[2]);
} }
model.Load(); model.Load();
if (Environment.GetEnvironmentVariable("VOICECAT_SIM_SMOKE_MODE") == "stereo")
IosAudioRouter.Shared.SelectPreset(IosAudioPreset.StereoMicrophone);
profile = model.Profiles.Single(value => value.Id == id); profile = model.Profiles.Single(value => value.Id == id);
Console.Error.WriteLine("VC_SIM phase=connect"); Console.Error.WriteLine("VC_SIM phase=connect");
await model.ConnectAsync(profile); await model.ConnectAsync(profile);
+2
View File
@@ -163,6 +163,8 @@ synthetic screen audio with the microphone off and during a voice call, voice jo
and reconnect. Look for and reconnect. Look for
`VC_SIM phase=screenSharePassed` and `VC_SIM phase=passed`. Terminate the app, then launch `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 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 does not validate Bluetooth hardware, the iOS 27 ScreenCaptureKit picker, or suspension on
a physical device. a physical device.
@@ -207,8 +207,11 @@ public class PublishServerScriptTests
string router = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "VoiceCat.iOS", "IosAudioRouter.cs")); 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")); string engine = await File.ReadAllTextAsync(Path.Combine(root, "clients", "apple", "VoiceCat.iOS", "IosAudioEngine.cs"));
Assert.True(router.IndexOf("ApplyInputSelection(session)", StringComparison.Ordinal) < Assert.True(router.IndexOf("SetActive(true", StringComparison.Ordinal) <
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("session.SetPreferredInputNumberOfChannels", router);
Assert.DoesNotContain("MaximumInputNumberOfChannels", router); Assert.DoesNotContain("MaximumInputNumberOfChannels", router);
Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.Portrait", router); Assert.Contains("SetPreferredInputOrientation(AVAudioStereoOrientation.Portrait", router);