Three coordinated fixes for the bug where enabling stereo mic capture
caused all audio output (A2DP, speaker, wired) to go silent:
1. audio_engine.cpp — open playback before capture
On iOS, starting the stereo capture AudioUnit can trigger an audio
route reconfiguration that drops A2DP before the playback device has
a chance to claim the route. Opening and starting the playback device
first commits the output route (A2DP), so iOS is less likely to drop
it when stereo capture activates afterward.
2. client.cpp — decouple set_capture_channels from engine restart
Previously vc_set_capture_channels() stopped and restarted the engine
immediately, which opened capture first (old ordering) and raced
against the settling AVAudioSession route. Now it only stores the
channel count; the caller (Swift via vc_audio_restart) controls when
the engine restarts, after the route has settled.
3. IOSAudioRouter.swift — call audioRestart() after channel config
selectCaptureChannels() and applyPreset() now call audioRestart()
after applyConfiguration() + setCaptureChannels(). This is the
vc_audio_restart() path that was added to the ABI in fdcc84f but
never wired up in the Swift layer. The restart sees the stored
channel count and reopens devices in the correct order (playback
first, capture second).
The doStartMicStream path is unaffected: setCaptureChannels is called
before the server acknowledges the stream (engine not yet running), so
ensure_audio_running() picks up capture_channels=2 directly when the
stream is confirmed and opens with the right count from the start.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
314 lines
11 KiB
Swift
314 lines
11 KiB
Swift
import Foundation
|
|
import AVFoundation
|
|
import VoiceCatCore
|
|
|
|
// MARK: - Helper types
|
|
|
|
struct ChatMessage: Identifiable {
|
|
let id = UUID()
|
|
let timestamp: Date
|
|
let senderName: String
|
|
let text: String
|
|
let scope: VoiceCatTextScope
|
|
}
|
|
|
|
struct ActivityEntry: Identifiable {
|
|
let id = UUID()
|
|
let timestamp: Date
|
|
let text: String
|
|
}
|
|
|
|
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
|
|
var currentDeviceId: String?
|
|
var localStreamId: UInt32 = 0
|
|
}
|
|
|
|
// MARK: - SessionState
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class SessionState {
|
|
let client: VoiceCatClient
|
|
let selfUserId: UInt32
|
|
|
|
var channels: [Channel] = []
|
|
var users: [User] = []
|
|
var currentChannelId: UInt32 = 0
|
|
var messages: [ChatMessage] = []
|
|
var activityLog: [ActivityEntry] = []
|
|
var voiceState = VoiceState()
|
|
var permissions: Permissions
|
|
var accounts: [Account] = []
|
|
var devices: [Device] = []
|
|
|
|
init(client: VoiceCatClient, selfUserId: UInt32, permissions: Permissions) {
|
|
self.client = client
|
|
self.selfUserId = selfUserId
|
|
self.permissions = permissions
|
|
AudioSessionManager.shared.client = client
|
|
refreshChannels()
|
|
refreshUsers()
|
|
syncSelfChannel()
|
|
refreshDevices()
|
|
client.onEvent = { [weak self] ev in
|
|
Task { @MainActor [weak self] in self?.handleEvent(ev) }
|
|
}
|
|
client.onLevel = { [weak self] _, rms in
|
|
Task { @MainActor [weak self] in self?.voiceState.level = rms }
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
MainActor.assumeIsolated {
|
|
AudioSessionManager.shared.client = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Event dispatch
|
|
|
|
func handleEvent(_ ev: VoiceCatEvent) {
|
|
switch ev.type {
|
|
case .channelList:
|
|
refreshChannels()
|
|
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(
|
|
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
|
|
senderName: sender,
|
|
text: ev.text ?? "",
|
|
scope: ev.textScope))
|
|
case .talkState:
|
|
let talking = ev.u32a != 0
|
|
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
|
|
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
|
|
case .streamStarted:
|
|
// A remote user started a stream — ensure the audio session is active so we can
|
|
// hear them even if we haven't joined voice ourselves. Previously the session was
|
|
// only activated when the user pressed Join Voice, so remote audio was silent.
|
|
if ev.userId != selfUserId {
|
|
do {
|
|
try AudioSessionManager.shared.ensureSessionActive()
|
|
} catch {
|
|
addActivity("Audio session activate failed: \(error)")
|
|
}
|
|
}
|
|
addActivity("Stream started (user \(ev.userId))")
|
|
case .streamStopped:
|
|
addActivity("Stream stopped (user \(ev.userId))")
|
|
case .joinResult:
|
|
if ev.result == .ok {
|
|
currentChannelId = ev.channelId
|
|
addActivity("Joined channel")
|
|
refreshUsers()
|
|
} else {
|
|
addActivity("Join failed: \(ev.result.description)")
|
|
}
|
|
case .error:
|
|
addActivity("Error: \(ev.text ?? ev.result.description)")
|
|
case .genericResult:
|
|
if ev.result != .ok {
|
|
addActivity("Operation failed: \(ev.result.description)")
|
|
}
|
|
case .accountList:
|
|
accounts = client.listAccounts()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func addActivity(_ text: String) {
|
|
activityLog.append(ActivityEntry(timestamp: Date(), text: text))
|
|
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() }
|
|
func refreshUsers() { users = client.listUsers() }
|
|
func refreshDevices() { devices = client.listDevices(.input) }
|
|
|
|
// MARK: - Voice controls
|
|
|
|
func joinChannel(_ channelId: UInt32, password: String = "") {
|
|
client.joinChannel(channelId, password: password.isEmpty ? nil : password)
|
|
}
|
|
|
|
func leaveChannel() {
|
|
client.leaveChannel()
|
|
currentChannelId = 0
|
|
}
|
|
|
|
func startMicStream() {
|
|
AVAudioApplication.requestRecordPermission { [weak self] granted in
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
if granted {
|
|
self.doStartMicStream()
|
|
} else {
|
|
self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func doStartMicStream() {
|
|
do {
|
|
try AudioSessionManager.shared.ensureSessionActive()
|
|
} catch {
|
|
addActivity("AVAudioSession activate failed: \(error)")
|
|
return
|
|
}
|
|
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic")
|
|
let (result, streamId) = client.startStream(desc)
|
|
if result == .ok {
|
|
voiceState.micActive = true
|
|
voiceState.localStreamId = streamId
|
|
// Publish the active mic stream ID so IOSAudioRouter can reset the core's capture
|
|
// channel count when the user switches mono↔stereo (selectCaptureChannels /
|
|
// applyPreset). Without this, switching stereo→mono leaves the LocalStream's
|
|
// capture_channels field at 2 and the next engine start still opens stereo.
|
|
AudioSessionManager.shared.activeMicStreamId = streamId
|
|
// Store the user's capture channel selection before the server acknowledges
|
|
// the stream. The engine hasn't started yet at this point (it starts when
|
|
// handle_stream_announce_result fires), so vc_set_capture_channels just
|
|
// stores the value — no restart. ensure_audio_running() picks it up when
|
|
// the stream is confirmed and opens the device with the right channel count.
|
|
let channels = IOSAudioRouter.shared.captureChannels.channelCount
|
|
if channels != 1 {
|
|
client.setCaptureChannels(streamId: streamId, channels: channels)
|
|
}
|
|
} else {
|
|
addActivity("Failed to start mic: \(result.description)")
|
|
}
|
|
}
|
|
|
|
func stopMicStream() {
|
|
if voiceState.localStreamId != 0 {
|
|
client.stopStream(voiceState.localStreamId)
|
|
voiceState.localStreamId = 0
|
|
AudioSessionManager.shared.activeMicStreamId = nil
|
|
}
|
|
voiceState.micActive = false
|
|
voiceState.level = 0
|
|
// Do NOT deactivate the AVAudioSession here — the user may still want to hear
|
|
// remote audio (other people talking). The session is deactivated only when
|
|
// disconnecting from the server (see AppState.disconnect / .disconnected event).
|
|
}
|
|
|
|
func setMute(_ muted: Bool, deafened: Bool) {
|
|
client.setSelfMute(micMuted: muted, deafened: deafened)
|
|
voiceState.selfMuted = muted
|
|
voiceState.selfDeafened = deafened
|
|
}
|
|
|
|
func setInputMode(_ mode: VoiceCatInputMode) {
|
|
client.setInputMode(mode)
|
|
voiceState.inputMode = mode
|
|
}
|
|
|
|
func setVadThreshold(_ threshold: Float) {
|
|
client.setVadThreshold(threshold)
|
|
voiceState.vadThreshold = threshold
|
|
}
|
|
|
|
func setPushToTalk(_ active: Bool) {
|
|
client.setPushToTalk(active)
|
|
}
|
|
|
|
// MARK: - Text
|
|
|
|
func sendText(_ text: String, scope: VoiceCatTextScope, targetId: UInt32 = 0) {
|
|
client.sendText(scope: scope, targetId: targetId, text: text)
|
|
}
|
|
|
|
// MARK: - Admin
|
|
|
|
func kickUser(_ userId: UInt32, reason: String) {
|
|
client.kickUser(userId, reason: reason.isEmpty ? nil : reason)
|
|
}
|
|
|
|
func banUser(_ userId: UInt32, reason: String, expiresUnixMs: UInt64) {
|
|
client.banUser(userId, reason: reason.isEmpty ? nil : reason, expiresUnixMs: expiresUnixMs)
|
|
}
|
|
|
|
func moveUser(_ userId: UInt32, toChannel channelId: UInt32) {
|
|
client.moveUser(userId, toChannel: channelId)
|
|
}
|
|
|
|
func setPermissions(_ userId: UInt32, perms: Permissions) {
|
|
client.setPermission(userId, perms: perms)
|
|
}
|
|
|
|
func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) {
|
|
client.setServerMute(userId, muted: muted, deafened: deafened)
|
|
}
|
|
|
|
func createChannel(name: String, topic: String) {
|
|
let info = ChannelEdit(id: 0, parentId: 0, name: name, topic: topic,
|
|
passwordProtected: false, password: nil,
|
|
maxUsers: 0, sortOrder: 0, audio: AudioConfig())
|
|
client.createChannel(info)
|
|
}
|
|
|
|
func deleteChannel(_ channelId: UInt32) {
|
|
client.deleteChannel(channelId)
|
|
}
|
|
|
|
func fetchAccountList() {
|
|
client.requestAccountList()
|
|
}
|
|
|
|
func createAccount(username: String, password: String) {
|
|
client.createAccount(username, password: password)
|
|
}
|
|
|
|
func deleteAccount(username: String) {
|
|
client.deleteAccount(username)
|
|
}
|
|
|
|
func resetPassword(username: String, newPassword: String) {
|
|
client.resetPassword(username, newPassword: newPassword)
|
|
}
|
|
}
|