Add a "speaker output" override so users can route audio to the built-in speaker instead of the earpiece when no headphones/Bluetooth are connected. Previously the receiver was the only fallback on the default Voice Chat preset. The toggle inserts .defaultToSpeaker into the AVAudioSession category options (skipped for the A2DP mode, where it would break Bluetooth routing). It yields to connected BT/wired output and is orthogonal to preset matching. Exposed both as a call-bar button in VoiceControlsView and a persisted Settings toggle.
261 lines
12 KiB
Swift
261 lines
12 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
|
|
@State private var showAdvanced = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
// MARK: - Audio Preset
|
|
Section("Audio") {
|
|
Picker("Preset", selection: Binding(
|
|
get: { router.activePreset },
|
|
set: { preset in router.applyPreset(preset) }
|
|
)) {
|
|
ForEach(router.availablePresets) { preset in
|
|
Text(preset.rawValue).tag(preset)
|
|
}
|
|
}
|
|
.accessibilityLabel("Audio preset")
|
|
|
|
Toggle("Speaker output", isOn: Binding(
|
|
get: { router.forceSpeaker },
|
|
set: { router.setForceSpeaker($0) }
|
|
))
|
|
.accessibilityLabel("Speaker output")
|
|
.accessibilityHint("Routes audio to the speaker instead of the earpiece when no headphones are connected.")
|
|
|
|
if !router.hasBluetoothDevice && !router.hasWiredHeadset {
|
|
Text("Connect Bluetooth headphones or a wired headset for more presets.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityLabel("No external audio device connected")
|
|
}
|
|
}
|
|
|
|
// MARK: - Advanced Audio
|
|
Section {
|
|
DisclosureGroup("Advanced Audio", isExpanded: $showAdvanced) {
|
|
// 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
|
|
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")
|
|
|
|
// 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) {}
|
|
}
|