Files
voice-cat/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift
Talon 6fe7bf0158
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
feat: fix voice join/leave, channel edit defaults, channel-update stream restart
Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS):

1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane.
   Previously the button only toggled the local mic — receiving was always on
   (gated by channel membership alone). Added a protocol-level voice subscription
   concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult
   proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI
   functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked
   by the SFU relay recipient filter, and core-client gating of remote-stream
   decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe
   on Leave. Text chat works regardless of voice subscription.

2. Channel edit dialog now shows the channel's actual current settings. The read
   struct vc_channel was missing sort_order and audio fields — only the write
   struct vc_channel_info had them. Extended vc_channel with both (additive, no
   ABI break), updated the session model and list_channels marshaling to populate
   them, and updated all three clients' edit callers to use actual channel info
   instead of hardcoded defaults.

3. Channel parameter updates now automatically restart everyone's streams.
   Previously editing a channel's audio config persisted and broadcast a
   ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are
   frozen at announce time. handle_channel_event now detects audio-config changes
   on the user's current channel and stop->starts each active local stream. The
   server reads the updated config on re-announce; peers wire up fresh decoders
   at the new ssrc.

All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not
yet compile-verified (Windows environment).
2026-06-24 14:29:39 +02:00

342 lines
16 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
// Notification feedback prefs keys shared with VoiceCatCore's FeedbackSettings, so the
// EventFeedback player reads the same values these toggles write.
@AppStorage("feedback.sounds") private var soundsEnabled = true
@AppStorage("feedback.speech") private var speechEnabled = false
@AppStorage("feedback.volume") private var soundsVolume = 1.0
@AppStorage("feedback.selfTalk") private var selfTalkEnabled = false
@AppStorage("feedback.ptt") private var pttSoundEnabled = 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 Voice Chat the native iOS
// Voice-Processing unit (VPIO) does echo cancellation, noise suppression and
// automatic gain control; the stereo / mono-mic / A2DP configs 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 configuration (stereo / A2DP / off)",
systemImage: "waveform.slash")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("Echo cancellation is off in this configuration")
}
}
// 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")
// Voice-processing (VPIO) controls only meaningful on a VPIO-capable
// config (mono + standard + non-A2DP). iOS bundles echo cancellation and
// noise suppression into one master switch (no per-stage toggle); AGC is
// the one sub-stage it lets us control independently.
if router.voiceProcessingAvailable {
Toggle("Voice Processing (AEC + noise suppression)", isOn: Binding(
get: { router.voiceProcessingEnabled },
set: { router.setVoiceProcessingEnabled($0) }
))
.accessibilityLabel("Voice processing")
.accessibilityHint("Echo cancellation and noise suppression, bundled together by iOS.")
if router.voiceProcessingEnabled {
Toggle("Automatic Gain Control", isOn: Binding(
get: { router.agcEnabled },
set: { router.setAgcEnabled($0) }
))
.accessibilityLabel("Automatic gain control")
}
}
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")
}
}
VStack(alignment: .leading, spacing: 4) {
Text("Mic Volume: \(Int((session.voiceState.inputGain * 100).rounded()))%")
.font(.caption)
Slider(
value: Binding(
get: { Double(session.voiceState.inputGain) },
set: { session.setInputGain(Float($0)) }
),
in: 0...4, step: 0.05
)
.accessibilityLabel("Microphone volume")
.accessibilityValue("\(Int((session.voiceState.inputGain * 100).rounded())) percent")
}
Toggle("Noise Reduction (RNNoise)", isOn: Binding(
get: { session.voiceState.inputNoiseReduction },
set: { session.setInputNoiseReduction($0) }
))
.accessibilityLabel("Microphone noise reduction")
.accessibilityHint("Denoises your microphone signal for everyone listening.")
}
// MARK: - Notifications
Section("Notifications") {
Toggle("Event sounds", isOn: $soundsEnabled)
.accessibilityLabel("Play event sounds")
if soundsEnabled {
VStack(alignment: .leading, spacing: 4) {
Text("Sound volume")
.font(.caption)
Slider(value: $soundsVolume, in: 0...1)
.accessibilityLabel("Sound volume")
}
}
Toggle("Speak events (text-to-speech)", isOn: $speechEnabled)
.accessibilityLabel("Speak events")
.accessibilityHint("Announces joins and leaves and reads message text aloud.")
Toggle("Your own voice-activity sounds", isOn: $selfTalkEnabled)
.accessibilityLabel("Voice activity sounds")
Toggle("Push-to-talk cue", isOn: $pttSoundEnabled)
.accessibilityLabel("Push to talk cue")
}
// 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.leaveVoice()
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) {}
}