diff --git a/clients/apple/iOS/VoiceCatiOS/AppState.swift b/clients/apple/iOS/VoiceCatiOS/AppState.swift index ab11080..2777173 100644 --- a/clients/apple/iOS/VoiceCatiOS/AppState.swift +++ b/clients/apple/iOS/VoiceCatiOS/AppState.swift @@ -89,7 +89,9 @@ final class AppState { } func disconnect() { + session?.stopMicStream() session?.client.disconnect() + AudioSessionManager.shared.deactivateSession() session = nil connectingClient?.disconnect() connectingClient = nil @@ -163,7 +165,10 @@ final class AppState { } case .disconnected: if session == nil { cancelConnect() } - else { session = nil; isConnecting = false } + else { + AudioSessionManager.shared.deactivateSession() + session = nil; isConnecting = false + } case .error: connectStatus = ev.text ?? "Unknown error" if session == nil { isConnecting = false } diff --git a/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift b/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift index 5317dde..2bcf7e9 100644 --- a/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift +++ b/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift @@ -10,6 +10,15 @@ final class AudioSessionManager { weak var client: VoiceCatClient? + /// Tracks whether WE activated the session. The session must be active whenever the + /// AudioEngine is running (for capture OR playback). Previously, the session was only + /// activated when the user joined voice (startMicStream), which meant: + /// - Remote audio was silent if the user hadn't joined voice yet. + /// - Leaving voice (stopMicStream) deactivated the session, killing remote audio. + /// Now the session is activated when any audio needs to play (remote stream started OR + /// user joins voice) and only deactivated when disconnecting from the server. + private var isSessionActive = false + func configure() { // Load stored audio routing preferences and apply them before any audio session // activation. IOSAudioRouter drives all iOS audio route selection via AVAudioSession; @@ -26,17 +35,34 @@ final class AudioSessionManager { name: AVAudioSession.routeChangeNotification, object: nil) } - func activateForStreaming() throws { - // Re-apply the routing configuration before activating, in case the user changed - // settings since the last apply. The core (miniaudio) will open whatever route - // AVAudioSession has established. + /// Activate the AVAudioSession if not already active. Call before any audio I/O: + /// when the user joins voice, or when a remote stream starts (so playback works even + /// before the user has joined voice). Idempotent — safe to call multiple times. + func ensureSessionActive() throws { + guard !isSessionActive else { + logger.debug("ensureSessionActive — already active, skipping") + return + } IOSAudioRouter.shared.applyConfiguration() try AVAudioSession.sharedInstance().setActive(true, options: []) + isSessionActive = true + let route = AVAudioSession.sharedInstance().currentRoute + let outputNames = route.outputs.map { $0.portName }.joined(separator: ", ") + let inputNames = route.inputs.map { $0.portName }.joined(separator: ", ") + logger.info("session activated — outputs: [\(outputNames)], inputs: [\(inputNames)]") } - func deactivateAfterStreaming() { + /// Deactivate the AVAudioSession. Call ONLY when disconnecting from the server — not + /// when leaving voice (the user may still want to hear remote audio). + func deactivateSession() { + guard isSessionActive else { + logger.debug("deactivateSession — not active, skipping") + return + } try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + isSessionActive = false + logger.info("session deactivated") } @objc private func handleInterruption(_ notification: Notification) { @@ -47,13 +73,21 @@ final class AudioSessionManager { switch type { case .began: + logger.info("interruption began — session suspended by system") + isSessionActive = false // system deactivated us client?.audioSuspend() case .ended: let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0 let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) { - try? AVAudioSession.sharedInstance().setActive(true) - client?.audioResume() + do { + try AVAudioSession.sharedInstance().setActive(true) + isSessionActive = true + logger.info("interruption ended — session reactivated") + client?.audioResume() + } catch { + logger.error("interruption ended — reactivation failed: \(error.localizedDescription)") + } } @unknown default: break } diff --git a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift index 626bb79..6a3e2a5 100644 --- a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift +++ b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift @@ -48,6 +48,7 @@ final class IOSAudioRouter: ObservableObject { @Published var selectedDataSourceId: String? @Published var selectedPolarPattern: String? @Published var showsRawModeSpeakerWarning: Bool = false + @Published var showsA2dpNoAecWarning: Bool = false enum BluetoothMode: String, CaseIterable, Identifiable { case btHfpVoice = "BT HFP Voice" @@ -134,7 +135,7 @@ final class IOSAudioRouter: ObservableObject { selectedPolarPattern = currentPolarPattern } - updateRawModeWarning() + updateWarnings() } // MARK: - Apply configuration @@ -167,24 +168,33 @@ final class IOSAudioRouter: ObservableObject { break } - // 2. Set category + mode based on mic processing mode. + // 2. Set category + mode based on mic processing mode AND bluetooth mode. + // .voiceChat mode uses hardware AEC/AGC/HPF, but requires HFP-compatible routes. + // A2DP output is NOT HFP — using .voiceChat with A2DP causes iOS to mute the output + // because it can't set up the voice processing pipeline on an A2DP route. So: + // - Standard + HFP or Speaker: .voiceChat (hardware AEC works) + // - Standard + A2DP: .default (no hardware AEC, but audio routes correctly — A2DP + // headphones are in-ear/over-ear so echo from built-in mic is minimal) + // - Raw + any: .measurement (all processing off, regardless of bluetooth mode) let mode: AVAudioSession.Mode - switch micMode { - case .standard: - mode = .voiceChat // AEC/AGC/HPF on - case .raw: + switch (micMode, bluetoothMode) { + case (.standard, .builtInMicBtA2dp): + mode = .default // A2DP + hardware AEC = incompatible + case (.standard, _): + mode = .voiceChat // HFP or speaker: hardware AEC works + case (.raw, _): mode = .measurement // all processing off } do { try session.setCategory(.playAndRecord, mode: mode, options: options) - logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue)") + logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue), options=\(self.optionsLabel(options))") } catch { logger.error("setCategory failed: \(error.localizedDescription)") } - // 3. Set preferred input port. - if let portId = selectedInputPortId, + // 3. Set preferred input port (skip if "Default" — empty/nil ID means use system default). + if let portId = selectedInputPortId, !portId.isEmpty, let port = session.availableInputs?.first(where: { $0.uid == portId }) { do { try session.setPreferredInput(port) @@ -194,7 +204,7 @@ final class IOSAudioRouter: ObservableObject { } // 4. Set preferred data source (orientation) on the selected input port. - if let dataSourceId = selectedDataSourceId, + if let dataSourceId = selectedDataSourceId, !dataSourceId.isEmpty, let dataSource = port.dataSources?.first(where: { String(describing: $0.dataSourceID) == dataSourceId }) { do { try port.setPreferredDataSource(dataSource) @@ -204,7 +214,7 @@ final class IOSAudioRouter: ObservableObject { } // 5. Set preferred polar pattern on the data source. - if let polarPattern = selectedPolarPattern { + if let polarPattern = selectedPolarPattern, !polarPattern.isEmpty { let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern) do { try dataSource.setPreferredPolarPattern(pattern) @@ -216,25 +226,39 @@ final class IOSAudioRouter: ObservableObject { } } - // 6. Set preferred input number of channels (stereo capture). - do { - try session.setPreferredInputNumberOfChannels(Int(captureChannels.channelCount)) - logger.info("setPreferredInputNumberOfChannels ok — \(self.captureChannels.rawValue)") - } catch { - logger.error("setPreferredInputNumberOfChannels failed: \(error.localizedDescription)") + // 6. Set preferred input number of channels — ONLY for stereo (non-default). + // Calling setPreferredInputNumberOfChannels(1) for mono is unnecessary (1 is the + // default) and may put the session in a bad state on some devices. + if captureChannels == .stereo { + do { + try session.setPreferredInputNumberOfChannels(2) + logger.info("setPreferredInputNumberOfChannels ok — 2 (stereo)") + } catch { + logger.error("setPreferredInputNumberOfChannels failed: \(error.localizedDescription)") + } } - updateRawModeWarning() + updateWarnings() } private func modeLabel(_ mode: AVAudioSession.Mode) -> String { switch mode { case .voiceChat: return "voiceChat" case .measurement: return "measurement" + case .default: return "default" default: return "other" } } + private func optionsLabel(_ opts: AVAudioSession.CategoryOptions) -> String { + var parts: [String] = [] + if opts.contains(.defaultToSpeaker) { parts.append("defaultToSpeaker") } + if opts.contains(.mixWithOthers) { parts.append("mixWithOthers") } + if opts.contains(.allowBluetooth) { parts.append("allowBluetooth") } + if opts.contains(.allowBluetoothA2DP) { parts.append("allowBluetoothA2DP") } + return parts.joined(separator: ",") + } + /// Apply stored preferences from UserDefaults. Called at app launch (before any /// audio session activation). func loadStoredPreferences() { @@ -302,7 +326,7 @@ final class IOSAudioRouter: ObservableObject { micMode = mode savePreferences() applyConfiguration() - updateRawModeWarning() + updateWarnings() } func selectCaptureChannels(_ channels: CaptureChannels) { @@ -313,12 +337,14 @@ final class IOSAudioRouter: ObservableObject { // MARK: - Helpers - /// Show a warning when Raw/Studio mode is active and the output route is the speaker - /// (echo risk since AEC is off in .measurement mode). - private func updateRawModeWarning() { + /// Update warning indicators for the Settings UI. + private func updateWarnings() { let session = AVAudioSession.sharedInstance() let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker } + // Raw/Studio mode + speaker = echo risk (no AEC in .measurement mode) showsRawModeSpeakerWarning = (micMode == .raw && outputIsSpeaker) + // Standard mode + A2DP = no hardware AEC (A2DP incompatible with .voiceChat mode) + showsA2dpNoAecWarning = (micMode == .standard && bluetoothMode == .builtInMicBtA2dp) } /// The selected input port object, if any. diff --git a/clients/apple/iOS/VoiceCatiOS/SessionState.swift b/clients/apple/iOS/VoiceCatiOS/SessionState.swift index 8ec2cfd..baaa55d 100644 --- a/clients/apple/iOS/VoiceCatiOS/SessionState.swift +++ b/clients/apple/iOS/VoiceCatiOS/SessionState.swift @@ -100,6 +100,16 @@ final class SessionState { 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))") @@ -184,7 +194,7 @@ final class SessionState { private func doStartMicStream() { do { - try AudioSessionManager.shared.activateForStreaming() + try AudioSessionManager.shared.ensureSessionActive() } catch { addActivity("AVAudioSession activate failed: \(error)") return @@ -213,7 +223,9 @@ final class SessionState { } voiceState.micActive = false voiceState.level = 0 - AudioSessionManager.shared.deactivateAfterStreaming() + // 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) { diff --git a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift index a71f9f6..23a4777 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift @@ -83,6 +83,16 @@ struct SettingsView: View { .accessibilityLabel("Warning: Raw mode with speaker output may cause echo") } + if router.showsA2dpNoAecWarning { + Label( + "A2DP mode — no echo cancellation (hardware AEC unavailable)", + systemImage: "info.circle.fill" + ) + .foregroundStyle(.blue) + .font(.caption) + .accessibilityLabel("Info: A2DP output mode does not support hardware echo cancellation") + } + // Capture channels: Mono vs Stereo Picker("Channels", selection: Binding( get: { router.captureChannels },