Files
voice-cat/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Talon 3e80af2f3f fix(ios): output muted with A2DP, session lifecycle, mic input issues
Three bugs causing no audio output and no mic input:

1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware
   AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS
   mutes the output because it can't set up the voice processing pipeline on
   an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC,
   but audio routes correctly). .voiceChat kept for HFP and speaker modes.
   Added info warning in Settings UI for A2DP no-AEC.

2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming()
   which deactivated the AVAudioSession — but the AudioEngine keeps running for
   remote audio playback, so leaving voice killed all remote audio. And the
   session was never activated when a remote user started talking (only on
   Join Voice), so you couldn't hear anyone before joining voice. Fix:
   - ensureSessionActive() replaces activateForStreaming() — idempotent, called
     on Join Voice AND on .streamStarted (remote user starts talking).
   - stopMicStream() no longer deactivates the session.
   - deactivateSession() called only on disconnect from server.
   - isSessionActive flag tracks state, updated by interruption handler.

3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is
   the default) and may put the session in a bad state on some devices. Fix:
   only call it when stereo is explicitly selected. Also handle empty input
   port ID (selecting 'Default' in the picker) correctly.

Added comprehensive route logging — after activation, logs the current output
and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00

233 lines
10 KiB
Swift

import SwiftUI
import AVKit
import VoiceCatCore
struct SettingsView: View {
@Environment(AppState.self) private var appState
@Bindable var session: SessionState
@StateObject private var router = IOSAudioRouter.shared
var body: some View {
NavigationStack {
Form {
// MARK: - Audio Input
Section("Audio Input") {
// Input port picker (AVAudioSession.availableInputs)
Picker("Input Port", selection: Binding(
get: { router.selectedInputPortId ?? "" },
set: { id in
if !id.isEmpty { router.selectInputPort(id) }
}
)) {
Text("Default").tag("")
ForEach(router.inputPorts) { port in
Text(port.name).tag(port.id)
}
}
.accessibilityLabel("Audio input port selection")
// Built-in mic sub-options: orientation (data source) + polar pattern
if router.selectedPortIsBuiltInMic,
let dataSources = router.selectedPortDataSources,
!dataSources.isEmpty {
Picker("Mic Orientation", selection: Binding(
get: { router.selectedDataSourceId ?? "" },
set: { id in
if !id.isEmpty { router.selectDataSource(id) }
}
)) {
Text("Default").tag("")
ForEach(dataSources) { ds in
Text(ds.name).tag(ds.id)
}
}
.accessibilityLabel("Microphone orientation")
// Polar pattern sub-picker (only if the data source supports patterns)
if let selectedDs = dataSources.first(where: { $0.id == router.selectedDataSourceId }),
let patterns = selectedDs.polarPatterns,
!patterns.isEmpty {
Picker("Polar Pattern", selection: Binding(
get: { router.selectedPolarPattern ?? "" },
set: { pattern in
if !pattern.isEmpty { router.selectPolarPattern(pattern) }
}
)) {
Text("Default").tag("")
ForEach(patterns, id: \.self) { pattern in
Text(polarPatternLabel(pattern)).tag(pattern)
}
}
.accessibilityLabel("Microphone polar pattern")
}
}
// Mic processing mode: Standard vs Raw/Studio
Picker("Mic Mode", selection: Binding(
get: { router.micMode },
set: { router.selectMicMode($0) }
)) {
ForEach(IOSAudioRouter.MicMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.accessibilityLabel("Microphone processing mode")
if router.showsRawModeSpeakerWarning {
Label(
"Raw mode on speaker — echo risk (no AEC)",
systemImage: "exclamationmark.triangle.fill"
)
.foregroundStyle(.orange)
.font(.caption)
.accessibilityLabel("Warning: Raw mode with speaker output may cause echo")
}
if router.showsA2dpNoAecWarning {
Label(
"A2DP mode — no echo cancellation (hardware AEC unavailable)",
systemImage: "info.circle.fill"
)
.foregroundStyle(.blue)
.font(.caption)
.accessibilityLabel("Info: A2DP output mode does not support hardware echo cancellation")
}
// Capture channels: Mono vs Stereo
Picker("Channels", selection: Binding(
get: { router.captureChannels },
set: { router.selectCaptureChannels($0) }
)) {
ForEach(IOSAudioRouter.CaptureChannels.allCases) { ch in
Text(ch.rawValue).tag(ch)
}
}
.accessibilityLabel("Capture channel count")
}
// MARK: - Audio Output
Section("Audio Output") {
// Bluetooth mode
Picker("Bluetooth Mode", selection: Binding(
get: { router.bluetoothMode },
set: { router.selectBluetoothMode($0) }
)) {
ForEach(IOSAudioRouter.BluetoothMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.accessibilityLabel("Bluetooth audio mode")
// Current output route (read-only)
if !router.outputRoutes.isEmpty {
ForEach(router.outputRoutes) { route in
HStack {
Text(route.name)
Spacer()
Text(route.portType)
.foregroundStyle(.secondary)
.font(.caption)
}
.accessibilityLabel("Current output: \(route.name)")
}
} else {
Text("No output route")
.foregroundStyle(.secondary)
}
// AirPlay button
HStack {
Text("AirPlay")
Spacer()
RoutePickerButton()
}
.accessibilityLabel("AirPlay output selector")
}
// MARK: - Voice
Section("Voice") {
Picker("Input Mode", selection: Binding(
get: { session.voiceState.inputMode },
set: { session.setInputMode($0) }
)) {
Text("Voice Activation").tag(VoiceCatInputMode.voiceActivation)
Text("Push to Talk").tag(VoiceCatInputMode.pushToTalk)
Text("Always On").tag(VoiceCatInputMode.alwaysOn)
}
.accessibilityLabel("Voice input mode")
if session.voiceState.inputMode == .voiceActivation {
VStack(alignment: .leading, spacing: 4) {
Text("VAD Threshold: \(String(format: "%.3f", session.voiceState.vadThreshold))")
.font(.caption)
Slider(
value: Binding(
get: { Double(session.voiceState.vadThreshold) },
set: { session.setVadThreshold(Float($0)) }
),
in: 0.001...0.1, step: 0.001
)
.accessibilityLabel("Voice activation threshold")
}
}
}
// MARK: - Admin
if session.permissions.canAdminAccounts || session.permissions.isAdmin {
Section("Administration") {
NavigationLink("Manage Accounts") {
AccountsView(session: session)
}
.accessibilityLabel("Manage server accounts")
}
}
// MARK: - Server
Section("Server") {
Button(role: .destructive) {
session.stopMicStream()
appState.disconnect()
} label: {
Label("Disconnect", systemImage: "phone.down")
.foregroundStyle(.red)
}
.accessibilityLabel("Disconnect from server")
}
// MARK: - About
Section("About") {
Text(VoiceCatClient.versionString)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("Version: \(VoiceCatClient.versionString)")
}
}
.navigationTitle("Settings")
.onAppear {
router.refreshRoutes()
}
}
}
/// Human-readable label for AVAudioSession.PolarPattern raw values.
private func polarPatternLabel(_ rawValue: String) -> String {
switch rawValue {
case AVAudioSession.PolarPattern.omnidirectional.rawValue: return "Omnidirectional"
case AVAudioSession.PolarPattern.cardioid.rawValue: return "Cardioid"
case AVAudioSession.PolarPattern.subcardioid.rawValue: return "Subcardioid"
default: return rawValue
}
}
}
/// SwiftUI wrapper for AVRoutePickerView (AVKit's UIView for AirPlay route selection).
private struct RoutePickerButton: UIViewRepresentable {
func makeUIView(context: Context) -> AVRoutePickerView {
let view = AVRoutePickerView()
view.tintColor = .systemBlue
return view
}
func updateUIView(_ uiView: AVRoutePickerView, context: Context) {}
}