Files
voice-cat/clients/apple/iOS/VoiceCatiOS/SessionState.swift
Talon f1e1ef59ed fix(ios): stop miniaudio from clobbering AVAudioSession (stereo->A2DP output death)
The real root cause of "selecting Stereo Mic kills headphone/A2DP output on Join
Voice." Every prior fix worked on the Swift IOSAudioRouter under the false premise
that "miniaudio does NOT touch AVAudioSession on iOS." It does: the core opened
devices via ma_device_init(nullptr, ...), and with a NULL context miniaudio 0.11.25
runs an iOS "hack" that sets the session category by device type, then
ma_context_init__coreaudio calls setCategory()+setActive() on every device open --
capture -> AVAudioSessionCategoryRecord with zero options. That wipes the
.playAndRecord category, the mode, and .allowBluetoothA2DP / .mixWithOthers /
.allowAirPlay that IOSAudioRouter had just configured, killing headphone/A2DP (and
even wired) output. Stereo presets break worst because they rely on the A2DP output
route the wipe removes. TeamTalk avoids this by opening RemoteIO/VPIO AudioUnits
directly and leaving the session entirely to the app.

Fix (core, cross-platform safe): AudioEngine now owns a ma_context built by
make_context_config() with coreaudio.sessionCategory = ma_ios_session_category_none
and noAudioSessionActivate/Deactivate = MA_TRUE, and routes all ma_device_init calls
(playback, capture, loopback) plus enumerate_devices through it. miniaudio no longer
touches AVAudioSession; IOSAudioRouter is the sole owner (the session is already
activated on connect in AppState before any device opens). Context is lazily inited
in start(), reused across restarts, uninited in ~AudioEngine.

Adds TEMP AudioSessionManager.logSessionState() diagnostics (after activate, on route
change, on .streamStarted) to verify on-device that the category stays
PlayAndRecord+allowBluetoothA2DP instead of flipping to Record. Remove once confirmed.

Windows: cmake --build --preset dev clean; ctest --preset dev 21/21.
iOS build + on-device verification pending on Mac.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:34:07 +02:00

317 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)")
}
}
// TEMP DIAGNOSTIC: the core opens its miniaudio devices around now log the
// session state to confirm miniaudio is no longer resetting the category to Record.
AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))")
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 monostereo (selectCaptureChannels /
// applyPreset). Without this, switching stereomono 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)
}
}