feat(ios): audio overhaul, Join/Leave Voice, channel-id sync fix, stereo mic capture

Three iOS client problems fixed plus a new core stereo-mic capture ABI:

1. Channel-id sync bug (mic button permanently dimmed): SessionState never
   synced currentChannelId from the self user's channelId on connect, so the
   mic button (gated on currentChannelId == 0) stayed dimmed. Added
   syncSelfChannel() (mirrors macOS MainWindowController.swift:461,491,522);
   called from init/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult.
   Added applyServerMuteState() + serverMuted/serverDeafened to VoiceState.

2. Join/Leave Voice button: replaced icon-only mic toggle with explicit
   text button (parity with macOS). Mute/deafen disable when not in voice.

3. IOSAudioRouter.swift (new): full AVAudioSession routing layer — input
   port selection, built-in mic orientation/polar patterns, Bluetooth
   HFP/A2DP/Off modes, Standard/Raw mic processing, stereo capture, AirPlay,
   UserDefaults persistence. AudioSessionManager delegates to it.

4. Core stereo-mic capture (append-only ABI): vc_set_capture_channels()
   lets the core open the mic device in stereo (2-ch interleaved). LocalStream
   gains capture_channels; ensure_audio_running reads it; audio_engine.cpp
   capture_accum_ + on_capture updated to channel-aware accumulation. Test
   test_stereo_mic_capture (headless, L!=R stereo round-trip). Swift wrapper
   VoiceCatClient.setCaptureChannels.

5. Settings UI rework: AVAudioSession-derived input/output tree replaces
   miniaudio device picker.

6. iOS deployment target raised to 18.0 (Package.swift + project.pbxproj).
   swift-tools-version 6.0 with swiftLanguageModes .v5.

Docs: tech-stack.md, architecture.md, voice.md, roadmap.md, building.md
updated; stale 'vc_audio_suspend/resume deferred' claims corrected.

Verified: ctest --preset dev 21/21 green; swift test 6/6 green;
xcodebuild -target VoiceCatiOS -sdk iphonesimulator BUILD SUCCEEDED.
This commit is contained in:
2026-06-19 13:17:52 +02:00
parent a10a18aebe
commit 9fc51cffc4
23 changed files with 861 additions and 83 deletions

View File

@@ -8,14 +8,12 @@ final class AudioSessionManager {
weak var client: VoiceCatClient?
func configure() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playAndRecord, mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothA2DP,
.defaultToSpeaker, .mixWithOthers])
} catch {
print("[AudioSession] setCategory failed: \(error)")
}
// Load stored audio routing preferences and apply them before any audio session
// activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession;
// miniaudio (the core) does NOT touch AVAudioSession on iOS.
IOSAudioRouter.shared.loadStoredPreferences()
IOSAudioRouter.shared.applyConfiguration()
IOSAudioRouter.shared.refreshRoutes()
NotificationCenter.default.addObserver(
self, selector: #selector(handleInterruption),
@@ -26,6 +24,10 @@ final class AudioSessionManager {
}
func activateForStreaming() throws {
// Re-apply the routing configuration before activating, in case the user changed
// settings since the last apply. The core (miniaudio) will open whatever route
// AVAudioSession has established.
IOSAudioRouter.shared.applyConfiguration()
try AVAudioSession.sharedInstance().setActive(true, options: [])
}
@@ -55,6 +57,10 @@ final class AudioSessionManager {
}
@objc private func handleRouteChange(_ notification: Notification) {
// Refresh the router's published state so the Settings UI updates, and re-apply
// the stored preferences (the new route may need the preferred input re-set).
IOSAudioRouter.shared.refreshRoutes()
IOSAudioRouter.shared.applyConfiguration()
NotificationCenter.default.post(name: .voiceCatDeviceListChanged, object: nil)
}
}

View File

@@ -0,0 +1,306 @@
import AVFoundation
import VoiceCatCore
/// iOS audio routing layer drives all iOS audio route selection via `AVAudioSession`
/// *before* the core (miniaudio) opens its device. miniaudio does NOT touch
/// `AVAudioSession` on iOS; it opens the current default route via CoreAudio and that's
/// it. All iOS audio routing (input port selection, mic orientation/polar patterns,
/// HFP vs A2DP, measurement/raw mode, stereo capture) must be driven from here.
///
/// The three user-facing choices:
/// 1. **Input port** which physical input (built-in mic, Bluetooth HFP, headset,
/// USB, AirPlay). For the built-in mic, a sub-selection of **data source**
/// (orientation: front/back/top/bottom) and **polar pattern**
/// (omni/cardioid/subcardioid/bidirectional).
/// 2. **Bluetooth mode** how Bluetooth headsets are handled:
/// - "BT HFP voice" (`.allowBluetooth`): mono 8/16 kHz + heavy processing, BT mic.
/// - "Built-in Mic + BT A2DP stereo" (`.allowBluetoothA2DP` only): stereo output,
/// built-in mic, no HFP processing.
/// - "Built-in Mic + Speaker" (neither): no Bluetooth at all.
/// 3. **Mic processing mode** Standard (`.voiceChat`: AEC/AGC/HPF on) or
/// Raw/Studio (`.measurement`: all processing off). Raw mode is allowed always
/// but shows a warning when the output route is the speaker (echo risk, no AEC).
///
/// Additionally, **stereo capture** (2-channel built-in mic) can be enabled via
/// `setPreferredInputNumberOfChannels(2)` the core is then told via
/// `vc_set_capture_channels(streamId, 2)`.
///
/// Voice Isolation / Wide Spectrum (iOS 17+/18+) are user-toggleable in Control Center
/// for `.voiceChat` apps surfaced as a hint, not a programmatic toggle.
///
/// All choices are persisted in `UserDefaults` and re-applied on route changes.
@MainActor
final class IOSAudioRouter: ObservableObject {
static let shared = IOSAudioRouter()
// MARK: - Published state (drives SettingsView)
@Published var inputPorts: [IOSAudioInputPort] = []
@Published var outputRoutes: [IOSAudioOutputRoute] = []
@Published var bluetoothMode: BluetoothMode = .btHfpVoice
@Published var micMode: MicMode = .standard
@Published var captureChannels: CaptureChannels = .mono
@Published var selectedInputPortId: String?
@Published var selectedDataSourceId: String?
@Published var selectedPolarPattern: String?
@Published var showsRawModeSpeakerWarning: Bool = false
enum BluetoothMode: String, CaseIterable, Identifiable {
case btHfpVoice = "BT HFP Voice"
case builtInMicBtA2dp = "Built-in Mic + BT A2DP"
case builtInMicSpeaker = "Built-in Mic + Speaker"
var id: String { rawValue }
}
enum MicMode: String, CaseIterable, Identifiable {
case standard = "Standard"
case raw = "Raw / Studio"
var id: String { rawValue }
}
enum CaptureChannels: String, CaseIterable, Identifiable {
case mono = "Mono"
case stereo = "Stereo"
var id: String { rawValue }
var channelCount: UInt32 { self == .stereo ? 2 : 1 }
}
// MARK: - UserDefaults keys
private let kBluetoothMode = "cat.voice.audio.bluetoothMode"
private let kMicMode = "cat.voice.audio.micMode"
private let kCaptureChannels = "cat.voice.audio.captureChannels"
private let kInputPortId = "cat.voice.audio.inputPortId"
private let kDataSourceId = "cat.voice.audio.dataSourceId"
private let kPolarPattern = "cat.voice.audio.polarPattern"
private init() {}
// MARK: - Load / refresh from AVAudioSession
/// Refresh the published input port list and output route list from the current
/// AVAudioSession state. Call after any route change or when the settings view appears.
func refreshRoutes() {
let session = AVAudioSession.sharedInstance()
let currentInput = session.preferredInput
let currentDataSource = currentInput?.preferredDataSource?.dataSourceID ?? nil
let currentPolarPattern = currentInput?.preferredDataSource?.preferredPolarPattern?.rawValue
inputPorts = (session.availableInputs ?? []).map { port in
let dataSources = port.dataSources?.map { ds in
IOSAudioDataSource(
id: String(describing: ds.dataSourceID),
name: ds.dataSourceName,
polarPatterns: ds.supportedPolarPatterns?.map { $0.rawValue },
isSelected: currentDataSource == ds.dataSourceID,
selectedPolarPattern: currentPolarPattern
)
}
return IOSAudioInputPort(
id: port.uid,
name: port.portName,
portType: port.portType.rawValue,
dataSources: dataSources,
isSelected: currentInput?.uid == port.uid
)
}
outputRoutes = session.currentRoute.outputs.map { port in
IOSAudioOutputRoute(
id: port.uid,
name: port.portName,
portType: port.portType.rawValue
)
}
if selectedInputPortId == nil {
selectedInputPortId = currentInput?.uid ?? inputPorts.first?.id
}
if selectedDataSourceId == nil {
selectedDataSourceId = currentDataSource.map { String(describing: $0) }
}
if selectedPolarPattern == nil {
selectedPolarPattern = currentPolarPattern
}
updateRawModeWarning()
}
// MARK: - Apply configuration
/// Apply the full audio configuration to AVAudioSession. Call this before the core
/// opens its capture device (i.e. before `startMicStream` `activateForStreaming`).
func applyConfiguration() {
let session = AVAudioSession.sharedInstance()
// 1. Build category options from bluetooth mode.
var options: AVAudioSession.CategoryOptions = [.defaultToSpeaker, .mixWithOthers]
switch bluetoothMode {
case .btHfpVoice:
options.insert(.allowBluetooth)
// Note: .allowBluetoothA2DP is NOT inserted forces HFP for the mic path.
case .builtInMicBtA2dp:
options.insert(.allowBluetoothA2DP)
// Note: .allowBluetooth is NOT inserted no HFP, stereo A2DP output only.
case .builtInMicSpeaker:
// Neither Bluetooth option built-in mic + speaker/wired output only.
break
}
// 2. Set category + mode based on mic processing mode.
let mode: AVAudioSession.Mode
switch micMode {
case .standard:
mode = .voiceChat // AEC/AGC/HPF on
case .raw:
mode = .measurement // all processing off
}
do {
try session.setCategory(.playAndRecord, mode: mode, options: options)
} catch {
print("[IOSAudioRouter] setCategory failed: \(error)")
}
// 3. Set preferred input port.
if let portId = selectedInputPortId,
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
do {
try session.setPreferredInput(port)
} catch {
print("[IOSAudioRouter] setPreferredInput failed: \(error)")
}
// 4. Set preferred data source (orientation) on the selected input port.
if let dataSourceId = selectedDataSourceId,
let dataSource = port.dataSources?.first(where: { String(describing: $0.dataSourceID) == dataSourceId }) {
do {
try port.setPreferredDataSource(dataSource)
} catch {
print("[IOSAudioRouter] setPreferredDataSource failed: \(error)")
}
// 5. Set preferred polar pattern on the data source.
if let polarPattern = selectedPolarPattern {
let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
do {
try dataSource.setPreferredPolarPattern(pattern)
} catch {
print("[IOSAudioRouter] setPreferredPolarPattern failed: \(error)")
}
}
}
}
// 6. Set preferred input number of channels (stereo capture).
do {
try session.setPreferredInputNumberOfChannels(Int(captureChannels.channelCount))
} catch {
print("[IOSAudioRouter] setPreferredInputNumberOfChannels failed: \(error)")
}
updateRawModeWarning()
}
/// Apply stored preferences from UserDefaults. Called at app launch (before any
/// audio session activation).
func loadStoredPreferences() {
if let raw = UserDefaults.standard.string(forKey: kBluetoothMode),
let mode = BluetoothMode(rawValue: raw) {
bluetoothMode = mode
}
if let raw = UserDefaults.standard.string(forKey: kMicMode),
let mode = MicMode(rawValue: raw) {
micMode = mode
}
if let raw = UserDefaults.standard.string(forKey: kCaptureChannels),
let ch = CaptureChannels(rawValue: raw) {
captureChannels = ch
}
selectedInputPortId = UserDefaults.standard.string(forKey: kInputPortId)
selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
}
/// Persist current selections to UserDefaults.
func savePreferences() {
UserDefaults.standard.set(bluetoothMode.rawValue, forKey: kBluetoothMode)
UserDefaults.standard.set(micMode.rawValue, forKey: kMicMode)
UserDefaults.standard.set(captureChannels.rawValue, forKey: kCaptureChannels)
UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId)
UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
}
// MARK: - Selection setters (called from SettingsView pickers)
func selectInputPort(_ portId: String) {
selectedInputPortId = portId
selectedDataSourceId = nil
selectedPolarPattern = nil
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectDataSource(_ dataSourceId: String) {
selectedDataSourceId = dataSourceId
selectedPolarPattern = nil
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectPolarPattern(_ pattern: String) {
selectedPolarPattern = pattern
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectBluetoothMode(_ mode: BluetoothMode) {
bluetoothMode = mode
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectMicMode(_ mode: MicMode) {
micMode = mode
savePreferences()
applyConfiguration()
updateRawModeWarning()
}
func selectCaptureChannels(_ channels: CaptureChannels) {
captureChannels = channels
savePreferences()
applyConfiguration()
}
// MARK: - Helpers
/// Show a warning when Raw/Studio mode is active and the output route is the speaker
/// (echo risk since AEC is off in .measurement mode).
private func updateRawModeWarning() {
let session = AVAudioSession.sharedInstance()
let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
showsRawModeSpeakerWarning = (micMode == .raw && outputIsSpeaker)
}
/// The selected input port object, if any.
var selectedPort: IOSAudioInputPort? {
inputPorts.first(where: { $0.id == selectedInputPortId })
}
/// The data sources of the selected input port, if it's the built-in mic.
var selectedPortDataSources: [IOSAudioDataSource]? {
selectedPort?.dataSources
}
/// Whether the selected input port is the built-in mic (has data sources / orientation).
var selectedPortIsBuiltInMic: Bool {
selectedPort?.portType == AVAudioSession.Port.builtInMic.rawValue
}
}

View File

@@ -22,6 +22,8 @@ struct VoiceState {
var micActive = false
var selfMuted = false
var selfDeafened = false
var serverMuted = false
var serverDeafened = false
var inputMode: VoiceCatInputMode = .voiceActivation
var vadThreshold: Float = 0.025
var level: Float = 0.0
@@ -54,6 +56,7 @@ final class SessionState {
AudioSessionManager.shared.client = client
refreshChannels()
refreshUsers()
syncSelfChannel()
refreshDevices()
client.onEvent = { [weak self] ev in
Task { @MainActor [weak self] in self?.handleEvent(ev) }
@@ -75,8 +78,16 @@ final class SessionState {
switch ev.type {
case .channelList:
refreshChannels()
case .userJoined, .userLeft, .userUpdated:
syncSelfChannel()
case .userJoined, .userLeft:
refreshUsers()
syncSelfChannel()
case .userUpdated:
refreshUsers()
syncSelfChannel()
if let me = users.first(where: { $0.id == selfUserId }) {
applyServerMuteState(muted: me.serverMuted, deafened: me.serverDeafened)
}
case .textMessage:
let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown"
messages.append(ChatMessage(
@@ -118,6 +129,29 @@ final class SessionState {
if activityLog.count > 500 { activityLog.removeFirst() }
}
// MARK: - Self-channel / server-mute sync
/// Sync currentChannelId from the self user's channelId in the user list. Mirrors macOS
/// MainWindowController.swift:461,491,522. The server auto-places every authed user into
/// the Lobby (channel 1) on connect, but without this sync currentChannelId stays 0 and
/// the mic button (gated on currentChannelId == 0) stays permanently dimmed.
private func syncSelfChannel() {
if let me = users.first(where: { $0.id == selfUserId }) {
currentChannelId = me.channelId
}
}
/// Apply server-side mute/deafen state mirrors macOS MainWindowController.swift:693-700.
/// iOS was previously ignoring server mute/deafen entirely.
private func applyServerMuteState(muted: Bool, deafened: Bool) {
if muted && !voiceState.serverMuted { addActivity("You have been server-muted") }
if deafened && !voiceState.serverDeafened { addActivity("You have been server-deafened") }
if !muted && voiceState.serverMuted { addActivity("Server mute cleared") }
if !deafened && voiceState.serverDeafened { addActivity("Server deafen cleared") }
voiceState.serverMuted = muted
voiceState.serverDeafened = deafened
}
// MARK: - Data refresh
func refreshChannels() { channels = client.listChannels() }
@@ -160,6 +194,13 @@ final class SessionState {
if result == .ok {
voiceState.micActive = true
voiceState.localStreamId = streamId
// Apply the user's capture channel selection (mono/stereo) from IOSAudioRouter.
// The core opens the capture device via miniaudio on the next engine start;
// vc_set_capture_channels tells it to open in stereo (2) or mono (1).
let channels = IOSAudioRouter.shared.captureChannels.channelCount
if channels != 1 {
client.setCaptureChannels(streamId: streamId, channels: channels)
}
} else {
addActivity("Failed to start mic: \(result.description)")
}

View File

@@ -1,15 +1,141 @@
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 {
// Voice input section
Section("Voice Input") {
// 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")
}
// 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) }
@@ -36,30 +162,7 @@ struct SettingsView: View {
}
}
// Audio device section
if !session.devices.isEmpty {
Section("Input Device") {
Picker("Microphone", selection: Binding(
get: { session.voiceState.currentDeviceId ?? "" },
set: { id in
session.voiceState.currentDeviceId = id.isEmpty ? nil : id
if session.voiceState.localStreamId != 0 {
session.client.setInputDevice(
streamId: session.voiceState.localStreamId,
deviceId: id.isEmpty ? nil : id)
}
}
)) {
Text("Default").tag("")
ForEach(session.devices) { dev in
Text(dev.name).tag(dev.id)
}
}
.accessibilityLabel("Microphone selection")
}
}
// Admin section
// MARK: - Admin
if session.permissions.canAdminAccounts || session.permissions.isAdmin {
Section("Administration") {
NavigationLink("Manage Accounts") {
@@ -69,7 +172,7 @@ struct SettingsView: View {
}
}
// Server info
// MARK: - Server
Section("Server") {
Button(role: .destructive) {
session.stopMicStream()
@@ -81,6 +184,7 @@ struct SettingsView: View {
.accessibilityLabel("Disconnect from server")
}
// MARK: - About
Section("About") {
Text(VoiceCatClient.versionString)
.font(.caption)
@@ -89,6 +193,30 @@ struct SettingsView: View {
}
}
.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) {}
}

View File

@@ -6,7 +6,7 @@ struct VoiceControlsView: View {
var body: some View {
HStack(spacing: 20) {
// Mic toggle / PTT button
// Join/Leave Voice button (mirrors macOS micToggleButton)
if session.voiceState.inputMode == .pushToTalk {
PTTButton(session: session)
} else {
@@ -17,14 +17,16 @@ struct VoiceControlsView: View {
session.startMicStream()
}
} label: {
Image(systemName: session.voiceState.micActive ? "mic.fill" : "mic.slash.fill")
.font(.title2)
.frame(width: 44, height: 44)
.background(session.voiceState.micActive ? Color.green : Color(.systemGray4), in: Circle())
.foregroundStyle(session.voiceState.micActive ? .white : .primary)
Text(session.voiceState.micActive ? "Leave Voice" : "Join Voice")
.font(.body.weight(.semibold))
.frame(minWidth: 110)
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(session.voiceState.micActive ? Color.green.opacity(0.2) : Color.accentColor.opacity(0.15), in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(session.voiceState.micActive ? .green : .accentColor)
}
.disabled(session.currentChannelId == 0)
.accessibilityLabel(session.voiceState.micActive ? "Stop microphone" : "Start microphone")
.accessibilityLabel(session.voiceState.micActive ? "Leave Voice — stop sending microphone audio" : "Join Voice — start sending microphone audio")
}
// Level meter
@@ -34,7 +36,7 @@ struct VoiceControlsView: View {
Spacer()
// Self mute
// Self mute (disabled when not in voice)
Button {
session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened)
} label: {
@@ -42,9 +44,10 @@ struct VoiceControlsView: View {
.font(.title3)
.foregroundStyle(session.voiceState.selfMuted ? .red : .primary)
}
.disabled(!session.voiceState.micActive)
.accessibilityLabel(session.voiceState.selfMuted ? "Unmute microphone" : "Mute microphone")
// Self deafen
// Self deafen (disabled when not in voice)
Button {
session.setMute(session.voiceState.selfMuted, deafened: !session.voiceState.selfDeafened)
} label: {
@@ -52,6 +55,7 @@ struct VoiceControlsView: View {
.font(.title3)
.foregroundStyle(session.voiceState.selfDeafened ? .red : .primary)
}
.disabled(!session.voiceState.micActive)
.accessibilityLabel(session.voiceState.selfDeafened ? "Undeafen" : "Deafen")
// Disconnect