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