diff --git a/PROGRESS.md b/PROGRESS.md index aee8cfe..74fad92 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 diff --git a/clients/apple/VoiceCat.iOS/IosAudioRouter.cs b/clients/apple/VoiceCat.iOS/IosAudioRouter.cs index 6200171..07c0042 100644 --- a/clients/apple/VoiceCat.iOS/IosAudioRouter.cs +++ b/clients/apple/VoiceCat.iOS/IosAudioRouter.cs @@ -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; } diff --git a/tests/VoiceCat.Tests/PublishServerScriptTests.cs b/tests/VoiceCat.Tests/PublishServerScriptTests.cs index 71a62b8..35b2e5f 100644 --- a/tests/VoiceCat.Tests/PublishServerScriptTests.cs +++ b/tests/VoiceCat.Tests/PublishServerScriptTests.cs @@ -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() {