fix(ios): keep voice chat on Bluetooth headset
This commit is contained in:
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
# VoiceCat status
|
# VoiceCat status
|
||||||
|
|
||||||
Updated: 2026-09-22
|
Updated: 2026-09-25
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
@@ -23,7 +23,10 @@ The physical-device iOS voice path now supports ReplayKit fallback, stable Apple
|
|||||||
capture using a paced 20 ms handoff, and true built-in stereo microphone capture. Stereo was
|
capture using a paced 20 ms handoff, and true built-in stereo microphone capture. Stereo was
|
||||||
verified on an iPhone 16 Pro Max with a two-channel AVAudioEngine input and distinct left/right
|
verified on an iPhone 16 Pro Max with a two-channel AVAudioEngine input and distinct left/right
|
||||||
samples; the managed Apple binding requires native use of its otherwise-unmapped stereo polar
|
samples; the managed Apple binding requires native use of its otherwise-unmapped stereo polar
|
||||||
pattern constant.
|
pattern constant. The voice-chat preset now leaves speaker routing off so a connected Bluetooth
|
||||||
|
headset can supply both input and output; speaker routing remains an explicit Advanced setting.
|
||||||
|
Device-selected inputs are not saved as preferences during route refresh. Bluetooth switching
|
||||||
|
still needs device validation.
|
||||||
|
|
||||||
The iOS user list now opens a remote-user detail view with independent tuning for each active
|
The iOS user list now opens a remote-user detail view with independent tuning for each active
|
||||||
audio stream. Private messages are grouped into per-user conversations with direct access to the
|
audio stream. Private messages are grouped into per-user conversations with direct access to the
|
||||||
|
|||||||
@@ -52,10 +52,14 @@ internal sealed class IosAudioRouter
|
|||||||
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.bluetoothMode"), true, out IosBluetoothMode bluetooth)) BluetoothMode = bluetooth;
|
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.bluetoothMode"), true, out IosBluetoothMode bluetooth)) BluetoothMode = bluetooth;
|
||||||
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.micMode"), true, out IosMicMode mic)) MicMode = mic;
|
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.micMode"), true, out IosMicMode mic)) MicMode = mic;
|
||||||
ForceSpeaker = defaults.BoolForKey("cat.voice.audio.forceSpeaker");
|
ForceSpeaker = defaults.BoolForKey("cat.voice.audio.forceSpeaker");
|
||||||
|
// Older voice-chat selections saved an automatic speaker override. Drop that
|
||||||
|
// preference for the named preset so an existing install can follow HFP again.
|
||||||
|
if (Preset == IosAudioPreset.VoiceChat) ForceSpeaker = false;
|
||||||
VoiceProcessing = defaults.ValueForKey(new NSString("cat.voice.audio.voiceProcessing")) is null || defaults.BoolForKey("cat.voice.audio.voiceProcessing");
|
VoiceProcessing = defaults.ValueForKey(new NSString("cat.voice.audio.voiceProcessing")) is null || defaults.BoolForKey("cat.voice.audio.voiceProcessing");
|
||||||
AutomaticGainControl = defaults.ValueForKey(new NSString("cat.voice.audio.agc")) is null || defaults.BoolForKey("cat.voice.audio.agc");
|
AutomaticGainControl = defaults.ValueForKey(new NSString("cat.voice.audio.agc")) is null || defaults.BoolForKey("cat.voice.audio.agc");
|
||||||
CaptureChannels = defaults.IntForKey("cat.voice.audio.captureChannels") == 2 ? 2 : Preset == IosAudioPreset.StereoMicrophone ? 2 : 1;
|
CaptureChannels = defaults.IntForKey("cat.voice.audio.captureChannels") == 2 ? 2 : Preset == IosAudioPreset.StereoMicrophone ? 2 : 1;
|
||||||
SelectedInputId = defaults.StringForKey("cat.voice.audio.inputPortId"); SelectedDataSourceId = defaults.StringForKey("cat.voice.audio.dataSourceId");
|
SelectedInputId = defaults.StringForKey("cat.voice.audio.inputPortId"); SelectedDataSourceId = defaults.StringForKey("cat.voice.audio.dataSourceId");
|
||||||
|
if (Preset == IosAudioPreset.VoiceChat) { SelectedInputId = null; SelectedDataSourceId = null; }
|
||||||
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.polarPattern"), true, out AVAudioDataSourcePolarPattern pattern)) SelectedPolarPattern = pattern;
|
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.polarPattern"), true, out AVAudioDataSourcePolarPattern pattern)) SelectedPolarPattern = pattern;
|
||||||
RefreshRoutes();
|
RefreshRoutes();
|
||||||
}
|
}
|
||||||
@@ -73,12 +77,14 @@ internal sealed class IosAudioRouter
|
|||||||
// Named presets never carry an Advanced capsule selection across transitions.
|
// Named presets never carry an Advanced capsule selection across transitions.
|
||||||
// Stereo derives its data source below; mono/voice chat must clear a stale stereo one.
|
// Stereo derives its data source below; mono/voice chat must clear a stale stereo one.
|
||||||
SelectedDataSourceId = null; SelectedPolarPattern = AVAudioDataSourcePolarPattern.Unknown;
|
SelectedDataSourceId = null; SelectedPolarPattern = AVAudioDataSourcePolarPattern.Unknown;
|
||||||
if (preset == IosAudioPreset.VoiceChat) ForceSpeaker = true;
|
// HFP needs to remain free to follow a connected headset. A speaker route can
|
||||||
|
// still be requested explicitly with the Speaker output control.
|
||||||
|
if (preset == IosAudioPreset.VoiceChat) ForceSpeaker = false;
|
||||||
}
|
}
|
||||||
SaveAndReconfigure();
|
SaveAndReconfigure();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void SetForceSpeaker(bool value) { ForceSpeaker = value; SaveAndReconfigure(); }
|
internal void SetForceSpeaker(bool value) { ForceSpeaker = value; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
|
||||||
internal void SetVoiceProcessing(bool value) { VoiceProcessing = value; SaveAndReconfigure(); }
|
internal void SetVoiceProcessing(bool value) { VoiceProcessing = value; SaveAndReconfigure(); }
|
||||||
internal void SetAutomaticGainControl(bool value) { AutomaticGainControl = value; SaveAndReconfigure(); }
|
internal void SetAutomaticGainControl(bool value) { AutomaticGainControl = value; SaveAndReconfigure(); }
|
||||||
internal void SetCaptureChannels(int value) { CaptureChannels = value == 2 ? 2 : 1; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
|
internal void SetCaptureChannels(int value) { CaptureChannels = value == 2 ? 2 : 1; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
|
||||||
@@ -100,7 +106,10 @@ internal sealed class IosAudioRouter
|
|||||||
AVAudioSession session = AVAudioSession.SharedInstance();
|
AVAudioSession session = AVAudioSession.SharedInstance();
|
||||||
Inputs = session.AvailableInputs?.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray() ?? [];
|
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();
|
||||||
SelectedInputId ??= session.PreferredInput?.UID; Changed?.Invoke();
|
// 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.
|
||||||
|
Changed?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void Apply(bool configureInput)
|
internal void Apply(bool configureInput)
|
||||||
@@ -121,7 +130,10 @@ internal sealed class IosAudioRouter
|
|||||||
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"}");
|
||||||
session.OverrideOutputAudioPort(ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionPortOverride.Speaker : AVAudioSessionPortOverride.None, out _); RefreshRoutes();
|
// The speaker port override forces both input and output to built-in hardware,
|
||||||
|
// even with a headset connected. DefaultToSpeaker is an explicit speaker choice
|
||||||
|
// only; the voice-chat preset leaves it off so HFP can follow the headset.
|
||||||
|
RefreshRoutes();
|
||||||
ResetWatchdog(); EnsureWatchdog();
|
ResetWatchdog(); EnsureWatchdog();
|
||||||
}
|
}
|
||||||
finally { applying = false; }
|
finally { applying = false; }
|
||||||
|
|||||||
@@ -221,6 +221,20 @@ public class PublishServerScriptTests
|
|||||||
Assert.Contains("ReferenceEquals(engine, next) && !next.Running", engine);
|
Assert.Contains("ReferenceEquals(engine, next) && !next.Running", engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IosVoiceChatLeavesBluetoothHeadsetRouteToSystem()
|
||||||
|
{
|
||||||
|
string router = await File.ReadAllTextAsync(Path.Combine(
|
||||||
|
FindRoot(), "clients", "apple", "VoiceCat.iOS", "IosAudioRouter.cs"));
|
||||||
|
|
||||||
|
Assert.Contains("if (preset == IosAudioPreset.VoiceChat) ForceSpeaker = false", router);
|
||||||
|
Assert.Contains("if (Preset == IosAudioPreset.VoiceChat) ForceSpeaker = false", router);
|
||||||
|
Assert.Contains("if (Preset == IosAudioPreset.VoiceChat) { SelectedInputId = null; SelectedDataSourceId = null; }", router);
|
||||||
|
Assert.Contains("ForceSpeaker = value; Preset = IosAudioPreset.Advanced", router);
|
||||||
|
Assert.DoesNotContain("OverrideOutputAudioPort(", router);
|
||||||
|
Assert.DoesNotContain("SelectedInputId ??= session.PreferredInput", router);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne()
|
public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user