|
|
|
@ -4,88 +4,93 @@ import MicrosoftCognitiveServicesSpeech |
|
|
|
import os.log |
|
|
|
// 自定义语音处理组件,提供音频流处理等功能 |
|
|
|
import speech |
|
|
|
|
|
|
|
public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
private let tag = "AzureTtsHelper" |
|
|
|
private let log = OSLog(subsystem: "com.azure.speech", category: "AzureTtsHelper") |
|
|
|
|
|
|
|
|
|
|
|
private static let DEFAULT_LANGUAGE = "zh-CN" |
|
|
|
private static let DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural" |
|
|
|
|
|
|
|
|
|
|
|
// Azure语音服务配置 |
|
|
|
private var speechConfig: SPXSpeechConfiguration? |
|
|
|
private var synthesizer: SPXSpeechSynthesizer? |
|
|
|
private var isInitialized = false |
|
|
|
private var speaking = false |
|
|
|
private var sessionid = "" //语音播报任务idsetAudioOutputRoute |
|
|
|
|
|
|
|
private var sessionid = "" //语音播报任务id |
|
|
|
|
|
|
|
// 当前配置 |
|
|
|
private var currentVoice = DEFAULT_VOICE |
|
|
|
private var currentLanguage = DEFAULT_LANGUAGE |
|
|
|
private var currentRate = "+0%" |
|
|
|
private var currentPitch = "+0%" |
|
|
|
private var currentVolume = "100%" |
|
|
|
|
|
|
|
|
|
|
|
// 事件监听器列表 |
|
|
|
private var eventListeners = NSHashTable<AnyObject>.weakObjects() |
|
|
|
private var audioDataListeners = NSHashTable<AnyObject>.weakObjects() |
|
|
|
|
|
|
|
|
|
|
|
// 流式文本处理的缓冲区 |
|
|
|
private var streamBuffer = "" |
|
|
|
private var lastSpeakTime: TimeInterval = 0 |
|
|
|
|
|
|
|
|
|
|
|
// 添加文本计数统计变量 |
|
|
|
private var totalTextCount = 0 |
|
|
|
private var currentSessionTextCount = 0 |
|
|
|
private var pendingTextCount = 0 |
|
|
|
|
|
|
|
|
|
|
|
// 内部异步处理队列 |
|
|
|
private let synthesisQueue = DispatchQueue(label: "com.azure.tts.synthesis", qos: .userInitiated) |
|
|
|
private var isProcessing = false // 添加处理状态标志 |
|
|
|
private let synthesisGroup = DispatchGroup() |
|
|
|
private var pendingTasks: [() -> Void] = [] |
|
|
|
private let taskLock = NSLock() |
|
|
|
|
|
|
|
|
|
|
|
// 自定义音频输出流 |
|
|
|
private var customAudioOutputStream: SPXPushAudioOutputStream? |
|
|
|
private var useInternalPlayer = true |
|
|
|
private var micCapture: MicrophoneCapture! |
|
|
|
// 记录最后播放的文本 |
|
|
|
private var lastSpokenText: String? |
|
|
|
|
|
|
|
/** |
|
|
|
* 初始化语音合成服务 |
|
|
|
*/ |
|
|
|
public func initialize(ttsAppId: String, ttsAppToken: String, ttsResource: String, language: String) -> Bool { |
|
|
|
do { |
|
|
|
micCapture = MicrophoneCapture.shared |
|
|
|
micCapture = MicrophoneCapture.shared |
|
|
|
// 创建语音配置 |
|
|
|
speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: ttsResource) |
|
|
|
speechConfig?.speechSynthesisLanguage = language |
|
|
|
currentLanguage = language |
|
|
|
|
|
|
|
|
|
|
|
// 设置音频输出格式 - 与Android一致 |
|
|
|
speechConfig?.setPropertyTo("riff-16khz-16bit-mono-pcm", |
|
|
|
speechConfig?.setPropertyTo("riff-16khz-16bit-mono-pcm", |
|
|
|
byName: "SpeechServiceConnection_SynthOutputFormat") |
|
|
|
|
|
|
|
|
|
|
|
// 设置低延迟属性 |
|
|
|
speechConfig?.setPropertyTo("300", byName: "SpeechServiceConnection_InitialSilenceTimeoutMs") |
|
|
|
speechConfig?.setPropertyTo("300", byName: "SpeechServiceConnection_EndSilenceTimeoutMs") |
|
|
|
|
|
|
|
|
|
|
|
// 创建合成器 |
|
|
|
recreateSynthesizer() |
|
|
|
|
|
|
|
|
|
|
|
// 设置初始化完成 |
|
|
|
isInitialized = true |
|
|
|
|
|
|
|
|
|
|
|
// 新增:监听音频会话中断和路由变化,避免通话时崩溃 |
|
|
|
setupAudioSessionObservers() |
|
|
|
|
|
|
|
// 预热TTS引擎 |
|
|
|
warmupSynthesizer() |
|
|
|
|
|
|
|
|
|
|
|
return true |
|
|
|
} catch { |
|
|
|
os_log("语音合成服务初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|
|
|
return false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 设置自定义音频输出流 |
|
|
|
*/ |
|
|
|
@ -95,13 +100,13 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
recreateSynthesizer() |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 设置语音角色 |
|
|
|
*/ |
|
|
|
public func setVoice(_ voiceName: String) -> Bool { |
|
|
|
if !isInitialized { return false } |
|
|
|
|
|
|
|
|
|
|
|
do { |
|
|
|
currentVoice = voiceName |
|
|
|
speechConfig?.speechSynthesisVoiceName = voiceName |
|
|
|
@ -116,48 +121,29 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
return false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 开始语音合成会话 |
|
|
|
* @param sessionid 会话ID |
|
|
|
* @return 是否成功启动 |
|
|
|
*/ |
|
|
|
public func startspeak(sessionid sessionid: String)-> Bool { |
|
|
|
public func startspeak(sessionid sessionid: String) -> Bool { |
|
|
|
self.sessionid = sessionid |
|
|
|
print("startspeak sessionid \(sessionid)") |
|
|
|
// 重置当前会话的文本计数 |
|
|
|
self.currentSessionTextCount = 0 |
|
|
|
self.pendingTextCount = 0 |
|
|
|
|
|
|
|
do { |
|
|
|
// 新增:使用 AVAudioSession.sharedInstance 配置为“通话模式 + 扬声器外放” |
|
|
|
// - 使用 .playAndRecord 以保证通话上下行链路 |
|
|
|
// - 使用 .voiceChat 获得通话处理(AEC 等) |
|
|
|
// - 使用 .defaultToSpeaker 默认走外放 |
|
|
|
// - 通过 overrideOutputAudioPort(.speaker) 强制切到扬声器 |
|
|
|
let audioSession = AVAudioSession.sharedInstance() |
|
|
|
|
|
|
|
//try audioSession.setActive(false) |
|
|
|
try audioSession.setCategory(.playAndRecord, |
|
|
|
mode: .voiceChat, |
|
|
|
options: [.defaultToSpeaker, |
|
|
|
.mixWithOthers, |
|
|
|
.defaultToSpeaker, |
|
|
|
.allowBluetoothA2DP, |
|
|
|
.allowBluetooth]) |
|
|
|
try audioSession.overrideOutputAudioPort(.none) |
|
|
|
try audioSession.setActive(true) |
|
|
|
} catch { |
|
|
|
print("音频会话配置失败: \(error.localizedDescription)") |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 使用安全的音频会话配置,兼容通话场景 |
|
|
|
safeConfigureAudioSessionForTTS() |
|
|
|
|
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 单次合成并播放 |
|
|
|
*/ |
|
|
|
public func speakOnce(sessionid sessionid: String,_ text: String) -> Bool { |
|
|
|
public func speakOnce(sessionid sessionid: String, _ text: String) -> Bool { |
|
|
|
if !isInitialized { |
|
|
|
os_log("语音合成未初始化", log: log, type: .error) |
|
|
|
notifyEvent(eventType: .error, params: [ |
|
|
|
@ -166,30 +152,29 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
]) |
|
|
|
return false |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (self.sessionid != sessionid){ |
|
|
|
return false |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 清理文本 |
|
|
|
let cleanedText = cleanTextForTTS(text) |
|
|
|
if cleanedText.isEmpty { |
|
|
|
return false |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 异步处理 |
|
|
|
enqueueSynthesisTask { |
|
|
|
self.performSynthesis(sessionid: sessionid, text: cleanedText) |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 流式合成文本 |
|
|
|
*/ |
|
|
|
public func speakStream(sessionid sessionid: String,_ text: String) -> Bool { |
|
|
|
public func speakStream(sessionid sessionid: String, _ text: String) -> Bool { |
|
|
|
if !isInitialized { |
|
|
|
notifyEvent(eventType: .error, params: [ |
|
|
|
"errorCode": "NOT_INITIALIZED", |
|
|
|
@ -201,33 +186,31 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
return false |
|
|
|
} |
|
|
|
self.sessionid = sessionid |
|
|
|
|
|
|
|
|
|
|
|
if text.isEmpty { |
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 增加文本计数统计 |
|
|
|
totalTextCount += text.count |
|
|
|
currentSessionTextCount += text.count |
|
|
|
|
|
|
|
//print("流式合成文本\(text.count),总计文本数量:\(totalTextCount),当前会话:\(currentSessionTextCount)") |
|
|
|
|
|
|
|
|
|
|
|
// 清理文本并添加到缓冲区 |
|
|
|
let cleanedText = cleanTextForTTS(text) |
|
|
|
streamBuffer.append(cleanedText) |
|
|
|
|
|
|
|
|
|
|
|
// 防抖逻辑 (150ms) |
|
|
|
let currentTime = Date().timeIntervalSince1970 |
|
|
|
if currentTime - lastSpeakTime < 0.15 { |
|
|
|
return true |
|
|
|
} |
|
|
|
lastSpeakTime = currentTime |
|
|
|
|
|
|
|
|
|
|
|
let currentText = streamBuffer |
|
|
|
|
|
|
|
|
|
|
|
// 标点符号列表 (与Android一致) |
|
|
|
let punctuationMarks: [Character] = [".", "。", "!", "!","*", "?", "?", ";", ";", ",", ",", ":", ":", "\n"] |
|
|
|
|
|
|
|
let punctuationMarks: [Character] = [".", "。", "!", "!", "*", "?", "?", ";", ";", ",", ",", ":", ":", "\n"] |
|
|
|
|
|
|
|
// 从后往前查找最后一个标点符号 |
|
|
|
var lastPunctuationIndex = -1 |
|
|
|
for (i, char) in currentText.enumerated().reversed() { |
|
|
|
@ -236,21 +219,21 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
break |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 播放到找到的标点符号 |
|
|
|
if lastPunctuationIndex >= 0 { |
|
|
|
let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1)) |
|
|
|
let startIndex = currentText.index(currentText.startIndex, offsetBy: lastPunctuationIndex + 1) |
|
|
|
streamBuffer = String(currentText[startIndex...]) |
|
|
|
|
|
|
|
|
|
|
|
if !textToSpeak.isEmpty { |
|
|
|
return speakOnce(sessionid: sessionid, textToSpeak) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 刷新并播放流式文本 |
|
|
|
*/ |
|
|
|
@ -262,17 +245,17 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
]) |
|
|
|
return false |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let remainingText = streamBuffer |
|
|
|
streamBuffer = "" |
|
|
|
|
|
|
|
|
|
|
|
if remainingText.isEmpty { |
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
return speakOnce(sessionid:sessionid,remainingText) |
|
|
|
|
|
|
|
return speakOnce(sessionid: sessionid, remainingText) |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 停止语音合成 |
|
|
|
*/ |
|
|
|
@ -286,12 +269,12 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
taskLock.lock() |
|
|
|
pendingTasks.removeAll() |
|
|
|
taskLock.unlock() |
|
|
|
|
|
|
|
|
|
|
|
do { |
|
|
|
print("liwei-------------停止语音合成播放") |
|
|
|
try self.synthesizer?.stopSpeaking() |
|
|
|
self.notifyEvent(eventType: .synthesisCanceled) |
|
|
|
|
|
|
|
|
|
|
|
if !micCapture.isCapturing{ |
|
|
|
// 停止时设置音频会话为非活跃状态 |
|
|
|
// try AVAudioSession.sharedInstance().setActive(false) |
|
|
|
@ -305,7 +288,7 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
"errorMessage": error.localizedDescription |
|
|
|
]) |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
synthesisQueue.async { |
|
|
|
do { |
|
|
|
self.synthesizer = nil |
|
|
|
@ -314,28 +297,10 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
os_log("重置语音合成失败: %{public}@", log: self.log, type: .error, error.localizedDescription) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return true |
|
|
|
} |
|
|
|
/** |
|
|
|
* 释放资源 |
|
|
|
*/ |
|
|
|
public func dispose() { |
|
|
|
_ = stop() |
|
|
|
synthesisGroup.wait() |
|
|
|
|
|
|
|
synthesisQueue.sync { |
|
|
|
synthesizer = nil |
|
|
|
speechConfig = nil |
|
|
|
customAudioOutputStream = nil |
|
|
|
isInitialized = false |
|
|
|
speaking = false |
|
|
|
|
|
|
|
taskLock.lock() |
|
|
|
pendingTasks.removeAll() |
|
|
|
taskLock.unlock() |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 添加事件监听器 |
|
|
|
@ -343,28 +308,28 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
public func addListener(_ listener: TtsEventListener) { |
|
|
|
eventListeners.add(listener as AnyObject) |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 移除事件监听器 |
|
|
|
*/ |
|
|
|
public func removeListener(_ listener: TtsEventListener) { |
|
|
|
eventListeners.remove(listener as AnyObject) |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 添加音频数据监听器 |
|
|
|
*/ |
|
|
|
public func addAudioDataListener(_ listener: AudioDataListener) { |
|
|
|
audioDataListeners.add(listener as AnyObject) |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 移除音频数据监听器 |
|
|
|
*/ |
|
|
|
public func removeAudioDataListener(_ listener: AudioDataListener) { |
|
|
|
audioDataListeners.remove(listener as AnyObject) |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 设置是否使用内部播放器 |
|
|
|
*/ |
|
|
|
@ -374,6 +339,7 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
recreateSynthesizer() |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 设置音频输出设备 |
|
|
|
*/ |
|
|
|
@ -383,6 +349,12 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
if wasSpeaking { |
|
|
|
_ = stop() |
|
|
|
} |
|
|
|
|
|
|
|
// 新增:在通话期间不进行音频设备切换,避免崩溃 |
|
|
|
if isTelephonyActive() { |
|
|
|
os_log("检测到通话进行中,跳过音频设备切换以避免崩溃", log: log, type: .info) |
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
// 重新创建合成器以应用新设备 |
|
|
|
if isInitialized { |
|
|
|
@ -397,7 +369,7 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
|
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// MARK: - 私有方法 |
|
|
|
|
|
|
|
/** |
|
|
|
@ -411,6 +383,7 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
// 确保任务被处理 |
|
|
|
processTasksIfNeeded() |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 处理任务队列 |
|
|
|
*/ |
|
|
|
@ -460,7 +433,7 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
self.processNextTask() |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 执行语音合成 |
|
|
|
*/ |
|
|
|
@ -672,7 +645,7 @@ public class AzureTtsHelper: NSObject, ITtsService { |
|
|
|
currentVolume = "\(min(max(volume, 0), 100))%" |
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 是否正在播放 |
|
|
|
*/ |
|
|
|
@ -704,5 +677,215 @@ public func resetTextCount() { |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 音频会话并发防护和状态 |
|
|
|
*/ |
|
|
|
private let audioSessionQueue = DispatchQueue(label: "com.azure.tts.audioSession") |
|
|
|
private var audioInterrupted = false |
|
|
|
private var audioSessionObserversAdded = false |
|
|
|
// 新增:路由变化的去抖与配置重入保护标记 |
|
|
|
private var routeChangeDebounceWorkItem: DispatchWorkItem? |
|
|
|
private var isApplyingAudioSessionConfig = false |
|
|
|
|
|
|
|
/** |
|
|
|
* 监听并处理音频中断与路由变化(通话/Siri/蓝牙切换等) |
|
|
|
* - 目的:在通话期间或路由变化时安全地停止/重配,避免 AVAudioSession 断言导致崩溃 |
|
|
|
*/ |
|
|
|
private func setupAudioSessionObservers() { |
|
|
|
if audioSessionObserversAdded { return } |
|
|
|
audioSessionObserversAdded = true |
|
|
|
|
|
|
|
NotificationCenter.default.addObserver(self, |
|
|
|
selector: #selector(handleAudioInterruption(_:)), |
|
|
|
name: AVAudioSession.interruptionNotification, |
|
|
|
object: AVAudioSession.sharedInstance()) |
|
|
|
NotificationCenter.default.addObserver(self, |
|
|
|
selector: #selector(handleRouteChange(_:)), |
|
|
|
name: AVAudioSession.routeChangeNotification, |
|
|
|
object: AVAudioSession.sharedInstance()) |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 判断当前是否处于通话相关音频占用(电话或蓝牙 HFP) |
|
|
|
* - 使用 secondaryAudioShouldBeSilencedHint 与蓝牙 HFP 路由作为判断依据 |
|
|
|
*/ |
|
|
|
private func isTelephonyActive() -> Bool { |
|
|
|
let session = AVAudioSession.sharedInstance() |
|
|
|
if session.secondaryAudioShouldBeSilencedHint { return true } |
|
|
|
// 检查是否使用蓝牙 HFP(通话用的 Hands-Free Profile) |
|
|
|
for out in session.currentRoute.outputs { |
|
|
|
if out.portType == .bluetoothHFP { |
|
|
|
return true |
|
|
|
} |
|
|
|
} |
|
|
|
return false |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 安全配置音频会话用于 TTS,兼容通话场景 |
|
|
|
* - 通话中:使用 .playback + .duckOthers,避免 .voiceChat/.allowBluetoothA2DP 导致断言 |
|
|
|
* - 非通话:使用 .playAndRecord + .voiceChat + .allowBluetooth(不使用 A2DP) |
|
|
|
*/ |
|
|
|
|
|
|
|
/// 为 TTS 安全配置音频会话: |
|
|
|
/// - 使用异步队列,避免在同一串行队列上 sync 导致死锁 |
|
|
|
/// - 根据当前音频路由(蓝牙 A2DP / 蓝牙 HFP / 有线耳机)选择更合适的类别与模式 |
|
|
|
/// - 仅当类别或模式发生变化时才 setCategory,降低 AVAudioSessionRouteChangeReason.categoryChange 的触发概率 |
|
|
|
private func safeConfigureAudioSessionForTTS() { |
|
|
|
// 使用异步,避免串行队列重入自锁 |
|
|
|
audioSessionQueue.async { [weak self] in |
|
|
|
guard let self = self else { return } |
|
|
|
|
|
|
|
// 防重入:配置中不重复进入 |
|
|
|
if self.isApplyingAudioSessionConfig { return } |
|
|
|
self.isApplyingAudioSessionConfig = true |
|
|
|
defer { self.isApplyingAudioSessionConfig = false } |
|
|
|
|
|
|
|
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 }) |
|
|
|
let hasBluetoothA2DP = outputs.contains(where: { $0.portType == .bluetoothA2DP }) |
|
|
|
let hasWiredHeadphones = outputs.contains(where: { $0.portType == .headphones || $0.portType == .headsetMic }) |
|
|
|
|
|
|
|
// 计算目标配置 |
|
|
|
let desiredCategory: AVAudioSession.Category |
|
|
|
let desiredMode: AVAudioSession.Mode |
|
|
|
var desiredOptions: AVAudioSession.CategoryOptions = [.mixWithOthers] |
|
|
|
|
|
|
|
if self.isTelephonyActive() || hasBluetoothHFP { |
|
|
|
// 通话活跃或存在 HFP(语音)链路:使用语音聊天模式 |
|
|
|
desiredCategory = .playAndRecord |
|
|
|
desiredMode = .voiceChat |
|
|
|
desiredOptions.insert(.allowBluetooth) // HFP 需 allowBluetooth |
|
|
|
} else if hasBluetoothA2DP || hasWiredHeadphones { |
|
|
|
// 纯播放链路耳机(A2DP/有线耳机):避免强制切语音链路 |
|
|
|
desiredCategory = .playback |
|
|
|
desiredMode = .default |
|
|
|
desiredOptions.insert(.allowBluetoothA2DP) |
|
|
|
} else { |
|
|
|
// 默认:本机扬声器的语音链路 |
|
|
|
desiredCategory = .playAndRecord |
|
|
|
desiredMode = .voiceChat |
|
|
|
desiredOptions.insert(.defaultToSpeaker) |
|
|
|
desiredOptions.insert(.allowBluetooth) // 允许外接麦/耳机(非 HFP 也不冲突) |
|
|
|
} |
|
|
|
|
|
|
|
do { |
|
|
|
// 仅当类别或模式变化时设置,减少无谓的 categoryChange |
|
|
|
if audioSession.category != desiredCategory || audioSession.mode != desiredMode { |
|
|
|
try audioSession.setCategory(desiredCategory, mode: desiredMode, options: desiredOptions) |
|
|
|
} |
|
|
|
// 激活会话(通常不会引发死锁),失败时仅记录错误 |
|
|
|
try audioSession.setActive(true) |
|
|
|
} catch { |
|
|
|
os_log("TTS 音频会话配置失败: %{public}@", log: self.log, type: .error, error.localizedDescription) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
* 处理系统音频中断(来电/通话开始、Siri等) |
|
|
|
* - began:标记中断并停止当前合成播放 |
|
|
|
* - ended:清除标记并按场景重配音频会话 |
|
|
|
*/ |
|
|
|
@objc private func handleAudioInterruption(_ notification: Notification) { |
|
|
|
guard let info = notification.userInfo, |
|
|
|
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, |
|
|
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } |
|
|
|
|
|
|
|
switch type { |
|
|
|
case .began: |
|
|
|
audioInterrupted = true |
|
|
|
_ = stop() |
|
|
|
os_log("音频中断开始(可能为通话/Siri),停止TTS", log: log, type: .info) |
|
|
|
case .ended: |
|
|
|
audioInterrupted = false |
|
|
|
os_log("音频中断结束,重新配置会话", log: log, type: .info) |
|
|
|
safeConfigureAudioSessionForTTS() |
|
|
|
@unknown default: |
|
|
|
break |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 处理音频路由变化(设备插拔、蓝牙切换、扬声器/听筒切换等) |
|
|
|
* - 忽略 .categoryChange/.routeConfigurationChange 等由我们设置类别引发的变化,避免死循环 |
|
|
|
* - 配置进行中(isApplyingAudioSessionConfig)直接忽略,防止重入 |
|
|
|
* - 加入 300ms 去抖,汇聚瞬时多次变化后再重配 |
|
|
|
* - 配置过程中(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 |
|
|
|
} |
|
|
|
|
|
|
|
// 去抖处理:300ms 内只执行最后一次重配 |
|
|
|
// 忽略 categoryChange 等 |
|
|
|
// 去抖:在 audioSessionQueue 上延迟调用 |
|
|
|
routeChangeDebounceWorkItem?.cancel() |
|
|
|
let work = DispatchWorkItem { [weak self] in |
|
|
|
self?.safeConfigureAudioSessionForTTS() |
|
|
|
} |
|
|
|
routeChangeDebounceWorkItem = work |
|
|
|
audioSessionQueue.asyncAfter(deadline: .now() + 0.3, execute: work) |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 释放资源 |
|
|
|
*/ |
|
|
|
public func dispose() { |
|
|
|
_ = stop() |
|
|
|
synthesisGroup.wait() |
|
|
|
|
|
|
|
synthesisQueue.sync { |
|
|
|
synthesizer = nil |
|
|
|
speechConfig = nil |
|
|
|
customAudioOutputStream = nil |
|
|
|
isInitialized = false |
|
|
|
speaking = false |
|
|
|
|
|
|
|
taskLock.lock() |
|
|
|
pendingTasks.removeAll() |
|
|
|
taskLock.unlock() |
|
|
|
} |
|
|
|
|
|
|
|
// 新增:移除音频会话观察者,防止泄漏 |
|
|
|
if audioSessionObserversAdded { |
|
|
|
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) |
|
|
|
NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance()) |
|
|
|
audioSessionObserversAdded = false |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|