Activate iOS audio session before stereo input selection
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user