fix(ios): keep voice chat on Bluetooth headset
Build and test / test (macos-latest) (push) Waiting to run
Build and test / test (ubuntu-24.04) (push) Waiting to run
Build and test / test (windows-latest) (push) Waiting to run
Build and test / apple-client (push) Waiting to run

This commit is contained in:
2026-09-25 12:27:27 +02:00
parent f3ac779bf4
commit 724f7e912d
3 changed files with 35 additions and 6 deletions
+5 -2
View File
@@ -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
+16 -4
View File
@@ -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()
{