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:
@@ -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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user