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:
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -31,6 +31,7 @@
|
||||
BBBB00000000000000000045 /* PermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002C /* PermissionsView.swift */; };
|
||||
BBBB00000000000000000046 /* AccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002D /* AccountsView.swift */; };
|
||||
BBBB00000000000000000047 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002E /* SettingsView.swift */; };
|
||||
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB0000000000000000002F /* IOSAudioRouter.swift */; };
|
||||
BBBB00000000000000000048 /* VoiceCatCore in Frameworks */ = {isa = PBXBuildFile; productRef = BBBB0000000000000000004A /* VoiceCatCore */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -62,6 +63,7 @@
|
||||
BBBB0000000000000000002C /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002D /* AccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
BBBB0000000000000000002F /* IOSAudioRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSAudioRouter.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -76,7 +78,7 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
BBBB00000000000000000002 /* mainGroup */ = {
|
||||
BBBB00000000000000000002 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BBBB00000000000000000003 /* VoiceCatiOS */,
|
||||
@@ -93,6 +95,7 @@
|
||||
BBBB00000000000000000018 /* AppState.swift */,
|
||||
BBBB00000000000000000019 /* SessionState.swift */,
|
||||
BBBB0000000000000000001A /* AudioSessionManager.swift */,
|
||||
BBBB0000000000000000002F /* IOSAudioRouter.swift */,
|
||||
BBBB0000000000000000001B /* ServerListStore.swift */,
|
||||
BBBB0000000000000000001C /* SavedServer.swift */,
|
||||
BBBB0000000000000000001D /* BroadcastCredentials.swift */,
|
||||
@@ -174,7 +177,7 @@
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = BBBB00000000000000000002 /* mainGroup */;
|
||||
mainGroup = BBBB00000000000000000002;
|
||||
packageReferences = (
|
||||
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */,
|
||||
);
|
||||
@@ -206,6 +209,7 @@
|
||||
BBBB00000000000000000031 /* AppState.swift in Sources */,
|
||||
BBBB00000000000000000032 /* SessionState.swift in Sources */,
|
||||
BBBB00000000000000000033 /* AudioSessionManager.swift in Sources */,
|
||||
BBBB0000000000000000004B /* IOSAudioRouter.swift in Sources */,
|
||||
BBBB00000000000000000034 /* ServerListStore.swift in Sources */,
|
||||
BBBB00000000000000000035 /* SavedServer.swift in Sources */,
|
||||
BBBB00000000000000000036 /* BroadcastCredentials.swift in Sources */,
|
||||
@@ -282,7 +286,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -336,7 +340,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -353,9 +357,11 @@
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = VoiceCat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -380,9 +386,11 @@
|
||||
CODE_SIGN_ENTITLEMENTS = VoiceCatiOS/VoiceCatiOS.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = FJV8L966W4;
|
||||
INFOPLIST_FILE = VoiceCatiOS/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = VoiceCat;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -426,7 +434,7 @@
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
BBBB00000000000000000049 /* XCLocalSwiftPackageReference "../" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "../";
|
||||
relativePath = ../;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -437,7 +445,6 @@
|
||||
productName = VoiceCatCore;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
|
||||
};
|
||||
rootObject = BBBB00000000000000000001 /* Project object */;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
306
clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift
Normal file
306
clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user