iOS "voice chat" had echo and no noise suppression: real iOS AEC/NS/AGC come only from Apple's Voice-Processing I/O unit (VPIO), but the core plays/captures via miniaudio's plain RemoteIO units, so .voiceChat mode alone never engaged AEC. Core (ABI PATCH 1->2): - vc_set_mixed_output_sink + vc_set_external_playback. In external mode the AudioEngine opens no hardware playback device; a mixer-timer thread drives on_playback (decode+mix) on a ~20ms cadence and ships the final mix to the sink. start() also skips the hardware capture device when the MIC stream is external_feed (AudioParams.external_capture). - New white-box test test_external_playback (drives the timer with no hw). iOS/Swift: - StreamDescriptor.externalFeed; VoiceCatClient.setMixedOutputSink / setExternalPlayback wrappers. - IOSVoiceProcessingEngine: AVAudioEngine + setVoiceProcessingEnabled; mic tap -> feedPcm, mixed-sink lock-free ring -> AVAudioSourceNode (both share the VPIO unit so AEC has its reference signal). - IOSAudioRouter.currentConfigUsesVoiceProcessing scopes VPIO to the AEC presets; SessionState join/leave + reconcileVoicePath() switch paths; Voice Chat defaults to speaker; Settings surfaces AEC/NS state. Known: pending on-device verification; a few bugs to fix afterward.
278 lines
13 KiB
Swift
278 lines
13 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.")
|
|
|
|
// Surface the voice-processing state. On the AEC presets the native iOS
|
|
// Voice-Processing unit (VPIO) does echo cancellation, noise suppression and
|
|
// automatic gain control; the other presets (stereo/studio/A2DP) can't use it.
|
|
if router.currentConfigUsesVoiceProcessing {
|
|
Label("Echo cancellation & noise suppression on (iOS voice processing)",
|
|
systemImage: "waveform.badge.mic")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityLabel("Echo cancellation and noise suppression are on")
|
|
} else {
|
|
Label("No echo cancellation in this preset (stereo / studio / A2DP)",
|
|
systemImage: "waveform.slash")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityLabel("Echo cancellation is off in this preset")
|
|
}
|
|
|
|
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) {}
|
|
}
|