On iOS the remote end heard the mic twice and crackly (BT headset + internal mic in Voice Chat; mono + stereo copies of the internal mic in Stereo Mic). Root cause is an io-thread ordering race. `ensure_audio_running()` only set `external_capture` when a MIC stream already existed, but it also runs from `sync_remote_streams` on the post-auth `ServerStateSnapshot` — before the user joins voice. With `external_playback_` still false and no MIC stream, `AudioEngine::start()` opened a real miniaudio capture device that stayed open all session (later calls early-return on running()), racing the AVAudioEngine input tap fed via `vc_stream_feed_pcm`. `on_capture_frame` then encoded+sent both paths — the mic transmitted twice, the two unsynchronized capture clocks producing the crackle. - core (ensure_audio_running): force `external_capture = true` whenever `external_playback_` is set, so iOS unified mode never opens a hardware capture device. No-op on desktop. - ios (AppState): move `setExternalPlayback(true)` before `connect()`, so the flag is set before the io thread processes any message — closing the race. Verified on device: remote end hears the iOS mic once and clean in both Voice Chat (+ BT) and Stereo Mic.
202 lines
7.7 KiB
Swift
202 lines
7.7 KiB
Swift
import Foundation
|
|
import VoiceCatCore
|
|
|
|
struct PendingIdentity: Identifiable {
|
|
let id = UUID()
|
|
let displayText: String
|
|
let tofuStatus: VoiceCatTofuStatus
|
|
}
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class AppState {
|
|
var servers: [SavedServer] = ServerListStore.shared.load()
|
|
var session: SessionState?
|
|
|
|
// Connect-flow state
|
|
var isConnecting = false
|
|
var connectStatus = ""
|
|
var showAddServer = false
|
|
var editingServer: SavedServer?
|
|
var showPasswordPrompt = false
|
|
var pendingIdentity: PendingIdentity?
|
|
|
|
private var connectingClient: VoiceCatClient?
|
|
private(set) var connectingServer: SavedServer?
|
|
private var identityHandled = false
|
|
|
|
// MARK: - Server list management
|
|
|
|
func addServer(_ server: SavedServer, password: String?) {
|
|
if let pw = password, !pw.isEmpty {
|
|
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
|
}
|
|
servers.append(server)
|
|
ServerListStore.shared.save(servers)
|
|
}
|
|
|
|
func updateServer(_ server: SavedServer, password: String?) {
|
|
if let pw = password, !pw.isEmpty {
|
|
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
|
}
|
|
if let idx = servers.firstIndex(where: { $0.id == server.id }) {
|
|
servers[idx] = server
|
|
}
|
|
ServerListStore.shared.save(servers)
|
|
}
|
|
|
|
func removeServer(_ server: SavedServer) {
|
|
ServerListStore.shared.deletePassword(tag: server.keychainTag)
|
|
servers.removeAll(where: { $0.id == server.id })
|
|
ServerListStore.shared.save(servers)
|
|
}
|
|
|
|
// MARK: - Connect flow
|
|
|
|
func connectTo(_ server: SavedServer) {
|
|
guard !isConnecting else { return }
|
|
isConnecting = true
|
|
connectStatus = "Connecting…"
|
|
connectingServer = server
|
|
identityHandled = false
|
|
|
|
let config = VoiceCatConfig(
|
|
clientName: "VoiceCat-iOS",
|
|
clientVersion: "0.0.1",
|
|
logLevel: .info,
|
|
tofuStorePath: ServerListStore.shared.tofuStorePath)
|
|
let client = VoiceCatClient(config: config)
|
|
connectingClient = client
|
|
|
|
client.onEvent = { [weak self] ev in
|
|
Task { @MainActor [weak self] in self?.handleConnectEvent(ev, server: server) }
|
|
}
|
|
// Put the core into external-playback mode BEFORE connect, so the flag is set on the
|
|
// io thread before any message is processed. The server sends AuthResult immediately
|
|
// followed by ServerStateSnapshot; handle_server_state runs ensure_audio_running() on
|
|
// the io thread, and if external_playback_ were still false at that point the core
|
|
// would open a hardware miniaudio playback+capture device (see the matching fix in
|
|
// vc_client::ensure_audio_running). Setting it here — before connect — guarantees the
|
|
// unified external path is in effect from the first frame. setExternalPlayback only
|
|
// flips an atomic + forwards to the engine's setter; both are safe pre-connect.
|
|
client.setExternalPlayback(true)
|
|
client.connect(host: server.host, port: server.port)
|
|
|
|
// Auth is queued immediately — the core serialises it behind TLS + TOFU.
|
|
switch server.authMode {
|
|
case .guest:
|
|
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
|
|
client.authenticateGuest(nick)
|
|
case .password:
|
|
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
|
|
if let pw = savedPw, !pw.isEmpty {
|
|
client.authenticateUser(server.savedUsername, password: pw)
|
|
} else {
|
|
showPasswordPrompt = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func disconnect() {
|
|
session?.stopMicStream()
|
|
session?.client.disconnect()
|
|
IOSAudioEngine.shared.stop()
|
|
AudioSessionManager.shared.deactivateSession()
|
|
session = nil
|
|
connectingClient?.disconnect()
|
|
connectingClient = nil
|
|
connectingServer = nil
|
|
isConnecting = false
|
|
connectStatus = ""
|
|
showPasswordPrompt = false
|
|
pendingIdentity = nil
|
|
}
|
|
|
|
// MARK: - Auth actions (called from prompt sheets)
|
|
|
|
func authenticateUser(username: String, password: String) {
|
|
connectingClient?.authenticateUser(username, password: password)
|
|
showPasswordPrompt = false
|
|
}
|
|
|
|
func confirmServerIdentity(accept: Bool) {
|
|
connectingClient?.confirmServerIdentity(accept: accept)
|
|
pendingIdentity = nil
|
|
if !accept { cancelConnect() }
|
|
}
|
|
|
|
func cancelConnect() {
|
|
connectingClient?.disconnect()
|
|
connectingClient = nil
|
|
connectingServer = nil
|
|
isConnecting = false
|
|
connectStatus = ""
|
|
showPasswordPrompt = false
|
|
pendingIdentity = nil
|
|
}
|
|
|
|
// MARK: - Connect event handler
|
|
|
|
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer) {
|
|
switch ev.type {
|
|
case .connectionState:
|
|
switch ev.connectionState {
|
|
case .connecting: connectStatus = "Connecting…"
|
|
case .tlsHandshake: connectStatus = "TLS handshake…"
|
|
case .authenticating: connectStatus = "Authenticating…"
|
|
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
|
case .connected: connectStatus = "Connected"
|
|
default: break
|
|
}
|
|
case .serverIdentity:
|
|
guard !identityHandled else { break }
|
|
let tofuStatus = ev.tofuStatus ?? .firstConnect
|
|
if tofuStatus == .matched {
|
|
connectingClient?.confirmServerIdentity(accept: true)
|
|
} else {
|
|
identityHandled = true
|
|
let displayText = connectingClient?.getServerIdentityDisplay() ?? ""
|
|
pendingIdentity = PendingIdentity(displayText: displayText, tofuStatus: tofuStatus)
|
|
}
|
|
case .authResult:
|
|
if ev.result == .ok {
|
|
guard let client = connectingClient else { break }
|
|
let perms = client.getPermissions()
|
|
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
|
|
connectingClient = nil
|
|
isConnecting = false
|
|
connectStatus = ""
|
|
showPasswordPrompt = false
|
|
self.session = newSession
|
|
EventFeedback.shared.play(.login)
|
|
EventFeedback.shared.speak("Connected")
|
|
// External-playback mode was enabled before connect() so the core never opens a
|
|
// miniaudio device on iOS (the single ordering rule of the unified audio path).
|
|
// Now activate the session and start the engine in listening mode so remote audio
|
|
// plays the moment someone talks, even before we join voice (no "can't hear anyone").
|
|
do {
|
|
try AudioSessionManager.shared.ensureSessionActive()
|
|
} catch {
|
|
print("Audio session activate on connect failed: \(error)")
|
|
}
|
|
IOSAudioEngine.shared.startListening(client: client)
|
|
} else {
|
|
connectStatus = "Auth failed: \(ev.result.description)"
|
|
showPasswordPrompt = true
|
|
}
|
|
case .disconnected:
|
|
if session == nil { cancelConnect() }
|
|
else {
|
|
IOSAudioEngine.shared.stop()
|
|
AudioSessionManager.shared.deactivateSession()
|
|
session = nil; isConnecting = false
|
|
}
|
|
case .error:
|
|
connectStatus = ev.text ?? "Unknown error"
|
|
if session == nil { isConnecting = false }
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|