fix(ios): keep voice chat on Bluetooth headset
This commit is contained in:
+5
-2
@@ -1,6 +1,6 @@
|
||||
# VoiceCat status
|
||||
|
||||
Updated: 2026-09-22
|
||||
Updated: 2026-09-25
|
||||
|
||||
## 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
|
||||
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
|
||||
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
|
||||
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.micMode"), true, out IosMicMode mic)) MicMode = mic;
|
||||
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");
|
||||
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;
|
||||
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;
|
||||
RefreshRoutes();
|
||||
}
|
||||
@@ -73,12 +77,14 @@ internal sealed class IosAudioRouter
|
||||
// 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.
|
||||
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();
|
||||
}
|
||||
|
||||
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 SetAutomaticGainControl(bool value) { AutomaticGainControl = value; 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();
|
||||
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();
|
||||
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)
|
||||
@@ -121,7 +130,10 @@ internal sealed class IosAudioRouter
|
||||
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"}");
|
||||
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();
|
||||
}
|
||||
finally { applying = false; }
|
||||
|
||||
@@ -221,6 +221,20 @@ public class PublishServerScriptTests
|
||||
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]
|
||||
public async Task DefaultPublishTargetsWindowsAndLinuxWhileRuntimeCanSelectOne()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user