|
|
|
@ -415,14 +415,14 @@ public class AzureTtsHelper: NSObject, ITtsService, AVAudioPlayerDelegate { |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
public func setPhoneCallMode(mod: String) -> Bool { |
|
|
|
// os_log("[liwei] AzureTtsHelper: setPhoneCallMode: %{public}@", log: log, type: .debug, mod) |
|
|
|
public func setTtsMode(mod: String) -> Bool { |
|
|
|
os_log("[liwei] AzureTtsHelper: setTtsMode: %{public}@", log: log, type: .debug, mod) |
|
|
|
self.previousRecognitionMode = self.currentRecognitionMode |
|
|
|
self.currentRecognitionMode = mod |
|
|
|
self.isHeadphones = detectHeadphonesOutput() |
|
|
|
if isInitialized { |
|
|
|
safeConfigureAudioSessionForTTS() |
|
|
|
} |
|
|
|
// if isInitialized { |
|
|
|
// safeConfigureAudioSessionForTTS() |
|
|
|
// } |
|
|
|
return true |
|
|
|
} |
|
|
|
/** |
|
|
|
@ -438,6 +438,7 @@ public class AzureTtsHelper: NSObject, ITtsService, AVAudioPlayerDelegate { |
|
|
|
out.portType == .bluetoothA2DP || |
|
|
|
out.portType == .bluetoothHFP || |
|
|
|
out.portType == .bluetoothLE || |
|
|
|
out.portType == .headsetMic || |
|
|
|
out.portType == .headphones |
|
|
|
} |
|
|
|
} |
|
|
|
@ -1376,6 +1377,18 @@ private var audioSessionObserversAdded = false |
|
|
|
private var routeChangeDebounceWorkItem: DispatchWorkItem? |
|
|
|
private var isApplyingAudioSessionConfig = false |
|
|
|
|
|
|
|
private func isTtsBusy() -> Bool { |
|
|
|
if speaking { return true } |
|
|
|
if isPlaying { return true } |
|
|
|
if !playbackQueue.isEmpty { return true } |
|
|
|
if pendingTextCount > 0 { return true } |
|
|
|
if !pendingTasks.isEmpty { return true } |
|
|
|
if activeStreamSynthesisCount > 0 { return true } |
|
|
|
if !pcmPendingData.isEmpty { return true } |
|
|
|
if scheduledBufferCount > 0 { return true } |
|
|
|
return false |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 确保音频会话队列已设置 specific key(用于避免同队列 sync 死锁) |
|
|
|
* - Parameters: 无 |
|
|
|
@ -1388,6 +1401,12 @@ private func ensureAudioSessionQueueSpecificKeySet() { |
|
|
|
audioSessionQueue.setSpecific(key: audioSessionQueueKey, value: 1) |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 同步应用 TTS 音频会话配置(用于需要立即生效的播放/引擎初始化路径) |
|
|
|
* - Parameters: 无 |
|
|
|
* - Returns: 是否成功激活音频会话 |
|
|
|
* - Throws: 无(内部捕获 AVAudioSession 相关异常并记录日志) |
|
|
|
*/ |
|
|
|
/** |
|
|
|
* 同步应用 TTS 音频会话配置(用于需要立即生效的播放/引擎初始化路径) |
|
|
|
* - Parameters: 无 |
|
|
|
@ -1397,7 +1416,6 @@ private func ensureAudioSessionQueueSpecificKeySet() { |
|
|
|
private func applyAudioSessionForTTS() -> Bool { |
|
|
|
var success = true |
|
|
|
ensureAudioSessionQueueSpecificKeySet() |
|
|
|
|
|
|
|
let applyBody = { |
|
|
|
if self.isApplyingAudioSessionConfig { return } |
|
|
|
self.isApplyingAudioSessionConfig = true |
|
|
|
@ -1405,69 +1423,62 @@ private func applyAudioSessionForTTS() -> Bool { |
|
|
|
|
|
|
|
let audioSession = AVAudioSession.sharedInstance() |
|
|
|
let outputs: [AVAudioSessionPortDescription] = audioSession.currentRoute.outputs |
|
|
|
let hasBluetoothHFP = outputs.contains(where: { $0.portType == .bluetoothHFP }) |
|
|
|
let hasWiredHeadphones = outputs.contains(where: { $0.portType == .headphones || $0.portType == .headsetMic }) |
|
|
|
let hasBluetoothA2DP = outputs.contains(where: { $0.portType == .bluetoothA2DP }) |
|
|
|
let hasHeadphones = hasWiredHeadphones || hasBluetoothA2DP |
|
|
|
let hasBluetoothA2DP = outputs.contains(where: { $0.portType == .bluetoothA2DP || $0.portType == .bluetoothLE }) |
|
|
|
let hasBluetoothHFP = outputs.contains(where: { $0.portType == .bluetoothHFP }) |
|
|
|
let hasHeadphones = hasWiredHeadphones || hasBluetoothA2DP || hasBluetoothHFP |
|
|
|
let otherAudioPlaying = audioSession.isOtherAudioPlaying |
|
|
|
let isRecording = (self.micCapture?.isCapturing == true) |
|
|
|
self.isHeadphones = hasHeadphones |
|
|
|
|
|
|
|
let desiredCategory: AVAudioSession.Category |
|
|
|
let desiredMode: AVAudioSession.Mode |
|
|
|
var desiredOptions: AVAudioSession.CategoryOptions = [] |
|
|
|
|
|
|
|
let isHeadphonesConnected = outputs.contains(where: { $0.portType == .headphones || $0.portType == .headsetMic || $0.portType == .bluetoothA2DP }) |
|
|
|
os_log("[liwei] AzureTtsHelper: applyAudioSessionForTTS: %{public}@", log: self.log, type: .debug, self.currentRecognitionMode) |
|
|
|
if self.currentRecognitionMode == "phone_call" || self.currentRecognitionMode == "push_to_talk" { |
|
|
|
|
|
|
|
if self.currentRecognitionMode == "phone_call" { |
|
|
|
desiredCategory = .playAndRecord |
|
|
|
if isHeadphonesConnected { |
|
|
|
desiredMode = .voiceChat |
|
|
|
if #available(iOS 18.0, *) { |
|
|
|
desiredOptions = [.allowBluetooth, .duckOthers] |
|
|
|
} else { |
|
|
|
desiredOptions = [.allowBluetooth, .allowBluetoothA2DP] |
|
|
|
if hasHeadphones { |
|
|
|
desiredMode = .default |
|
|
|
desiredOptions = [.allowBluetoothA2DP] |
|
|
|
if let inputs = audioSession.availableInputs, let builtInMic = inputs.first(where: { $0.portType == .builtInMic }) { |
|
|
|
try? audioSession.setPreferredInput(builtInMic) |
|
|
|
} |
|
|
|
} else { |
|
|
|
desiredMode = .videoChat |
|
|
|
desiredMode = .voiceChat |
|
|
|
desiredOptions = [.allowBluetooth, .defaultToSpeaker] |
|
|
|
} |
|
|
|
} else if self.isTelephonyActive() { |
|
|
|
if otherAudioPlaying { |
|
|
|
desiredOptions.insert(.duckOthers) |
|
|
|
} |
|
|
|
} else if self.currentRecognitionMode == "push_to_talk" || self.currentRecognitionMode == "normal" || self.currentRecognitionMode == "ble_wakeup" { |
|
|
|
if isRecording { |
|
|
|
self.micCapture?.stopCapture() |
|
|
|
Thread.sleep(forTimeInterval: 0.05) |
|
|
|
} |
|
|
|
desiredCategory = .playback |
|
|
|
desiredMode = .default |
|
|
|
desiredMode = .spokenAudio |
|
|
|
if otherAudioPlaying { |
|
|
|
desiredOptions.insert(.duckOthers) |
|
|
|
} |
|
|
|
} else if isRecording { |
|
|
|
desiredCategory = .playAndRecord |
|
|
|
if self.isHeadphones { |
|
|
|
desiredMode = .default |
|
|
|
desiredOptions.insert(.allowBluetooth) |
|
|
|
if hasHeadphones { |
|
|
|
desiredOptions.insert(.allowBluetoothA2DP) |
|
|
|
if otherAudioPlaying { |
|
|
|
desiredOptions.insert(.duckOthers) |
|
|
|
if let inputs = audioSession.availableInputs, let builtInMic = inputs.first(where: { $0.portType == .builtInMic }) { |
|
|
|
try? audioSession.setPreferredInput(builtInMic) |
|
|
|
} |
|
|
|
} else { |
|
|
|
desiredMode = .videoChat |
|
|
|
desiredOptions.insert(.allowBluetooth) |
|
|
|
desiredOptions.insert(.mixWithOthers) |
|
|
|
desiredOptions.insert(.defaultToSpeaker) |
|
|
|
} |
|
|
|
} else if hasBluetoothHFP { |
|
|
|
desiredCategory = .playback |
|
|
|
desiredMode = .voicePrompt |
|
|
|
desiredOptions = [.interruptSpokenAudioAndMixWithOthers, .allowBluetooth] |
|
|
|
} else if hasHeadphones { |
|
|
|
desiredCategory = .playback |
|
|
|
desiredMode = .default |
|
|
|
if otherAudioPlaying { |
|
|
|
desiredOptions.insert(.duckOthers) |
|
|
|
} |
|
|
|
} else { |
|
|
|
desiredCategory = .playback |
|
|
|
desiredMode = .default |
|
|
|
if #available(iOS 18.0, *) { |
|
|
|
desiredOptions = [] |
|
|
|
} else { |
|
|
|
desiredOptions = [.defaultToSpeaker] |
|
|
|
} |
|
|
|
desiredMode = .spokenAudio |
|
|
|
if otherAudioPlaying { |
|
|
|
desiredOptions.insert(.duckOthers) |
|
|
|
} |
|
|
|
@ -1483,7 +1494,7 @@ private func applyAudioSessionForTTS() -> Bool { |
|
|
|
_ = try? audioSession.setPreferredIOBufferDuration(0.02) |
|
|
|
} |
|
|
|
try audioSession.setActive(true) |
|
|
|
if self.currentRecognitionMode == "phone_call", !self.isHeadphones { |
|
|
|
if self.currentRecognitionMode == "phone_call", !hasHeadphones { |
|
|
|
_ = try? audioSession.overrideOutputAudioPort(.speaker) |
|
|
|
} |
|
|
|
break |
|
|
|
@ -1551,16 +1562,6 @@ private func isTelephonyActive() -> Bool { |
|
|
|
return false |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 安全配置音频会话用于 TTS |
|
|
|
* - 播放优先走媒体声道(A2DP),避免切换到通话声道(HFP) |
|
|
|
* - 外部音乐播放时:不启用 mix,默认会打断外部音频;同时可 duck 确保可听见 |
|
|
|
* - 录音进行中:保持 playAndRecord + A2DP 输出 + 内置麦输入,避免切到 HFP |
|
|
|
* - 通话中:保持保守策略,避免触发系统断言或破坏通话链路 |
|
|
|
* - Parameters: 无 |
|
|
|
* - Returns: 无 |
|
|
|
* - Throws: 无(内部捕获 AVAudioSession 相关异常并记录日志) |
|
|
|
*/ |
|
|
|
|
|
|
|
/// 为 TTS 安全配置音频会话: |
|
|
|
/// - 使用异步队列,避免在同一串行队列上 sync 导致死锁 |
|
|
|
@ -1568,23 +1569,10 @@ private func isTelephonyActive() -> Bool { |
|
|
|
/// - 外部音频播放时启用 spoken audio 策略,确保能在 A2DP 上播放 |
|
|
|
/// - 仅当类别或模式发生变化时才 setCategory,降低 AVAudioSessionRouteChangeReason.categoryChange 的触发概率 |
|
|
|
private func safeConfigureAudioSessionForTTS() { |
|
|
|
os_log("[liwei] AzureTtsHelper: safeConfigureAudioSessionForTTS - recognitionMode: %{public}@", log: log, type: .debug, currentRecognitionMode) |
|
|
|
// 使用异步,避免串行队列重入自锁 |
|
|
|
if !isTtsBusy() { return } |
|
|
|
audioSessionQueue.async { [weak self] in |
|
|
|
guard let self = self else { return } |
|
|
|
|
|
|
|
let audioSession = AVAudioSession.sharedInstance() |
|
|
|
|
|
|
|
// 显式指定 outputs 的类型,确保闭包参数类型可推断 |
|
|
|
let outputs: [AVAudioSessionPortDescription] = audioSession.currentRoute.outputs |
|
|
|
|
|
|
|
// 使用 contains(where:) 明确谓词闭包。 |
|
|
|
// 保持使用 audioSessionQueue.async,防止在串行队列上自锁。 |
|
|
|
// 仅在类别或模式发生变化时调用 setCategory,减少 categoryChange 的回调风暴。 |
|
|
|
let hasBluetoothHFP = outputs.contains(where: { $0.portType == .bluetoothHFP }) |
|
|
|
_ = hasBluetoothHFP |
|
|
|
_ = audioSession |
|
|
|
|
|
|
|
if !self.isTtsBusy() { return } |
|
|
|
_ = self.applyAudioSessionForTTS() |
|
|
|
} |
|
|
|
} |
|
|
|
@ -1617,8 +1605,6 @@ private func deactivateAudioSessionAfterTTSIfIdle() { |
|
|
|
self.playerNode = nil |
|
|
|
self.pcmFormat = nil |
|
|
|
} |
|
|
|
let session = AVAudioSession.sharedInstance() |
|
|
|
_ = try? session.setActive(false, options: .notifyOthersOnDeactivation) |
|
|
|
} |
|
|
|
if DispatchQueue.getSpecific(key: audioSessionQueueKey) != nil { |
|
|
|
work() |
|
|
|
@ -1645,8 +1631,10 @@ private func deactivateAudioSessionAfterTTSIfIdle() { |
|
|
|
os_log("音频中断开始(可能为通话/Siri),停止TTS", log: log, type: .info) |
|
|
|
case .ended: |
|
|
|
audioInterrupted = false |
|
|
|
if isTtsBusy() { |
|
|
|
os_log("音频中断结束,重新配置会话", log: log, type: .info) |
|
|
|
safeConfigureAudioSessionForTTS() |
|
|
|
} |
|
|
|
@unknown default: |
|
|
|
break |
|
|
|
} |
|
|
|
@ -1660,42 +1648,55 @@ private func deactivateAudioSessionAfterTTSIfIdle() { |
|
|
|
* - 配置过程中(isApplyingAudioSessionConfig = true)直接忽略 |
|
|
|
*/ |
|
|
|
@objc private func handleRouteChange(_ notification: Notification) { |
|
|
|
guard let info = notification.userInfo, |
|
|
|
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt, |
|
|
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return } |
|
|
|
|
|
|
|
os_log("音频路由变化: %{public}@", log: log, type: .info, String(describing: reason)) |
|
|
|
|
|
|
|
// 配置中触发的路由变化,直接忽略,避免重入导致死循环 |
|
|
|
if isApplyingAudioSessionConfig { |
|
|
|
os_log("配置进行中,忽略本次路由变化", log: log, type: .debug) |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
// 忽略会造成循环的原因:categoryChange(你的日志 reason=3),以及 routeConfigurationChange 等 |
|
|
|
switch reason { |
|
|
|
case .categoryChange, .routeConfigurationChange, .wakeFromSleep, .noSuitableRouteForCategory: |
|
|
|
os_log("忽略路由变化原因: %{public}@", log: log, type: .debug, String(describing: reason)) |
|
|
|
return |
|
|
|
default: |
|
|
|
break |
|
|
|
} |
|
|
|
|
|
|
|
// 通话期间避免重配,避免与电话路由冲突 |
|
|
|
if isTelephonyActive() { |
|
|
|
os_log("通话期间检测到路由变化,跳过重配", log: log, type: .info) |
|
|
|
return |
|
|
|
} |
|
|
|
// guard let info = notification.userInfo, |
|
|
|
// let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt, |
|
|
|
// let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return } |
|
|
|
|
|
|
|
// if !isTtsBusy() { return } |
|
|
|
// os_log("音频路由变化: %{public}@", log: log, type: .info, String(describing: reason)) |
|
|
|
|
|
|
|
// // 配置中触发的路由变化,直接忽略,避免重入导致死循环 |
|
|
|
// if isApplyingAudioSessionConfig { |
|
|
|
// os_log("配置进行中,忽略本次路由变化", log: log, type: .debug) |
|
|
|
// return |
|
|
|
// } |
|
|
|
|
|
|
|
// // 忽略会造成循环的原因:categoryChange(你的日志 reason=3),以及 routeConfigurationChange 等 |
|
|
|
// switch reason { |
|
|
|
// case .categoryChange, .routeConfigurationChange, .wakeFromSleep, .noSuitableRouteForCategory: |
|
|
|
// os_log("忽略路由变化原因: %{public}@", log: log, type: .debug, String(describing: reason)) |
|
|
|
// return |
|
|
|
// default: |
|
|
|
// break |
|
|
|
// } |
|
|
|
|
|
|
|
// // 通话期间避免重配,避免与电话路由冲突 |
|
|
|
// if isTelephonyActive() { |
|
|
|
// os_log("通话期间检测到路由变化,跳过重配", log: log, type: .info) |
|
|
|
// return |
|
|
|
// } |
|
|
|
|
|
|
|
// let isActivelyPlayingNow = |
|
|
|
// speaking || |
|
|
|
// isPlaying || |
|
|
|
// (audioPlayer?.isPlaying == true) || |
|
|
|
// (playerNode?.isPlaying == true) |
|
|
|
// if isActivelyPlayingNow { |
|
|
|
// routeChangeDebounceWorkItem?.cancel() |
|
|
|
// _ = stop() |
|
|
|
// os_log("路由变化时正在播报,已停止以避免音源冲突", log: log, type: .info) |
|
|
|
// return |
|
|
|
// } |
|
|
|
|
|
|
|
// 去抖处理:300ms 内只执行最后一次重配 |
|
|
|
// 忽略 categoryChange 等 |
|
|
|
// 去抖:在 audioSessionQueue 上延迟调用 |
|
|
|
routeChangeDebounceWorkItem?.cancel() |
|
|
|
let work = DispatchWorkItem { [weak self] in |
|
|
|
self?.safeConfigureAudioSessionForTTS() |
|
|
|
} |
|
|
|
routeChangeDebounceWorkItem = work |
|
|
|
audioSessionQueue.asyncAfter(deadline: .now() + 0.3, execute: work) |
|
|
|
// routeChangeDebounceWorkItem?.cancel() |
|
|
|
// let work = DispatchWorkItem { [weak self] in |
|
|
|
// self?.safeConfigureAudioSessionForTTS() |
|
|
|
// } |
|
|
|
// routeChangeDebounceWorkItem = work |
|
|
|
// audioSessionQueue.asyncAfter(deadline: .now() + 0.3, execute: work) |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
|