You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
378 lines
13 KiB
378 lines
13 KiB
import Foundation
|
|
import MicrosoftCognitiveServicesSpeech
|
|
import AVFoundation
|
|
|
|
/// Azure TTS工具类,负责实现TTS服务接口
|
|
@available(iOS 13.0, *)
|
|
class AzureTtsHelper: NSObject {
|
|
// MARK: - 属性
|
|
|
|
/// 事件处理回调
|
|
private var eventHandler: (String, [String: Any]) -> Void
|
|
|
|
/// 语音配置信息
|
|
private var speechSubscriptionKey: String = ""
|
|
private var serviceRegion: String = ""
|
|
|
|
/// 语音合成配置
|
|
private var speechConfig: SPXSpeechConfiguration?
|
|
|
|
/// 语音合成器
|
|
private var synthesizer: SPXSpeechSynthesizer?
|
|
|
|
/// 是否初始化成功
|
|
private var isInitialized = false
|
|
|
|
/// 当前是否正在播放
|
|
private var _isSpeaking = false
|
|
|
|
// MARK: - 语音设置
|
|
|
|
/// 当前语音
|
|
private var currentVoice = "zh-CN-XiaoxiaoNeural"
|
|
|
|
/// 支持的语音映射
|
|
private var voiceMap: [String: String] = [
|
|
"zh-CN": "zh-CN-XiaoxiaoNeural",
|
|
"en-US": "en-US-JennyNeural",
|
|
"ja-JP": "ja-JP-NanamiNeural",
|
|
"ko-KR": "ko-KR-SunHiNeural",
|
|
"zh-TW": "zh-TW-HsiaoChenNeural",
|
|
"zh-HK": "zh-HK-HiuMaanNeural"
|
|
]
|
|
|
|
/// 当前语音合成参数
|
|
private var currentSpeechRate = "0%"
|
|
private var currentPitch = "0%"
|
|
private var currentVolume = "100%"
|
|
|
|
// MARK: - 初始化
|
|
|
|
init(eventHandler: @escaping (String, [String: Any]) -> Void) {
|
|
self.eventHandler = eventHandler
|
|
super.init()
|
|
}
|
|
|
|
deinit {
|
|
dispose()
|
|
}
|
|
|
|
// MARK: - TTS 接口实现
|
|
|
|
/// 初始化语音合成服务
|
|
/// - Parameters:
|
|
/// - speechSubscriptionKey: Azure 语音服务订阅密钥
|
|
/// - serviceRegion: Azure 服务区域 (如 eastasia)
|
|
/// - language: 语言代码 (默认 zh-CN)
|
|
/// - Returns: 初始化是否成功
|
|
func initialize(speechSubscriptionKey: String, serviceRegion: String, language: String = "zh-CN") -> Bool {
|
|
print("[AzureTtsHelper] 初始化语音合成服务")
|
|
|
|
// 检查配置是否为空
|
|
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty {
|
|
print("[AzureTtsHelper] 错误: Azure 配置信息不完整")
|
|
eventHandler("error", ["error": "Azure 配置信息不完整"])
|
|
return false
|
|
}
|
|
|
|
// 释放之前的资源
|
|
dispose()
|
|
|
|
// 记录配置信息
|
|
self.speechSubscriptionKey = speechSubscriptionKey
|
|
self.serviceRegion = serviceRegion
|
|
|
|
do {
|
|
// 创建语音配置
|
|
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion)
|
|
|
|
// 设置语音合成输出格式
|
|
// speechConfig?.setSpeechSynthesisOutputFormat(.audio24Khz48KBitRateMonoMp3)
|
|
|
|
// 设置默认语音
|
|
let defaultVoice = getDefaultVoiceForLanguage(language)
|
|
currentVoice = defaultVoice
|
|
speechConfig?.speechSynthesisVoiceName = defaultVoice
|
|
|
|
// 创建语音合成器
|
|
synthesizer = try SPXSpeechSynthesizer(speechConfig!)
|
|
|
|
// 设置事件处理器
|
|
setupSynthesizerEvents()
|
|
|
|
isInitialized = true
|
|
print("[AzureTtsHelper] TTS 引擎初始化成功")
|
|
|
|
return true
|
|
} catch {
|
|
print("[AzureTtsHelper] 错误: 初始化语音合成服务失败: \(error.localizedDescription)")
|
|
eventHandler("error", ["error": "初始化语音合成服务失败: \(error.localizedDescription)"])
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// 设置语音
|
|
/// - Parameter voiceName: 语音名称 (如 "zh-CN-XiaoxiaoNeural")
|
|
/// - Returns: 设置是否成功
|
|
func setVoice(voiceName: String) -> Bool {
|
|
if !isInitialized {
|
|
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化")
|
|
eventHandler("error", ["error": "TTS 引擎尚未初始化"])
|
|
return false
|
|
}
|
|
|
|
if voiceName.isEmpty {
|
|
print("[AzureTtsHelper] 错误: 声音名称为空")
|
|
eventHandler("error", ["error": "声音名称不能为空"])
|
|
return false
|
|
}
|
|
|
|
if voiceName == currentVoice {
|
|
print("[AzureTtsHelper] 已设置语音: \(voiceName)")
|
|
return true
|
|
}
|
|
|
|
print("[AzureTtsHelper] 设置声音: \(voiceName)")
|
|
currentVoice = voiceName
|
|
|
|
// 更新语音配置
|
|
if let speechConfig = speechConfig {
|
|
speechConfig.speechSynthesisVoiceName = voiceName
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/// 设置语音合成参数
|
|
/// - Parameters:
|
|
/// - rate: 语速,范围 -100 到 100,默认为 0
|
|
/// - pitch: 音调,范围 -100 到 100,默认为 0
|
|
/// - volume: 音量,范围 0 到 100,默认为 100
|
|
/// - Returns: 是否设置成功
|
|
func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool {
|
|
if !isInitialized {
|
|
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化")
|
|
eventHandler("error", ["error": "TTS 引擎尚未初始化"])
|
|
return false
|
|
}
|
|
|
|
// 转换参数格式
|
|
currentSpeechRate = formatRateParam(rate)
|
|
currentPitch = formatPitchParam(pitch)
|
|
currentVolume = formatVolumeParam(volume)
|
|
|
|
print("[AzureTtsHelper] 已设置语音参数: 语速=\(currentSpeechRate), 音调=\(currentPitch), 音量=\(currentVolume)")
|
|
return true
|
|
}
|
|
|
|
/// 合成文本为语音并播放
|
|
/// - Parameter text: 要合成的文本
|
|
/// - Returns: 操作是否成功启动
|
|
func speakText(text: String) -> Bool {
|
|
if !isInitialized {
|
|
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化")
|
|
eventHandler("error", ["error": "TTS 引擎尚未初始化"])
|
|
return false
|
|
}
|
|
|
|
if text.isEmpty {
|
|
print("[AzureTtsHelper] 警告: 要播放的文本为空")
|
|
return true
|
|
}
|
|
|
|
print("[AzureTtsHelper] 开始语音合成: \(text.prefix(50))...")
|
|
|
|
// 生成SSML
|
|
let ssml = generateSsml(text: text)
|
|
|
|
// 直接进行SSML合成
|
|
return speakSsmlInternal(text: ssml)
|
|
}
|
|
|
|
/// 内部SSML合成和播放
|
|
private func speakSsmlInternal(text: String) -> Bool {
|
|
guard let synthesizer = synthesizer else {
|
|
print("[AzureTtsHelper] 错误: 合成器未初始化")
|
|
eventHandler("error", ["error": "合成器未初始化"])
|
|
return false
|
|
}
|
|
|
|
_isSpeaking = true
|
|
eventHandler("started", [:])
|
|
|
|
Task {
|
|
do {
|
|
print("[AzureTtsHelper] 开始语音合成")
|
|
|
|
// 使用异步方法进行合成并直接播放
|
|
_ = try await synthesizer.startSpeakingSsml(text)
|
|
|
|
} catch {
|
|
print("[AzureTtsHelper] 错误: 语音合成失败: \(error.localizedDescription)")
|
|
DispatchQueue.main.async {
|
|
self._isSpeaking = false
|
|
self.eventHandler("error", ["error": "语音合成失败: \(error.localizedDescription)"])
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/// 停止当前语音合成
|
|
/// - Returns: 操作是否成功
|
|
func stopSpeaking() -> Bool {
|
|
if !isInitialized || !_isSpeaking {
|
|
return true
|
|
}
|
|
|
|
// 停止合成
|
|
do {
|
|
try synthesizer?.stopSpeaking()
|
|
_isSpeaking = false
|
|
eventHandler("canceled", [:])
|
|
print("[AzureTtsHelper] 已停止语音合成")
|
|
return true
|
|
} catch {
|
|
print("[AzureTtsHelper] 错误: 停止语音合成失败: \(error.localizedDescription)")
|
|
eventHandler("error", ["error": "停止语音合成失败: \(error.localizedDescription)"])
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// 检查是否正在播放
|
|
/// - Returns: 当前是否正在播放语音
|
|
func isSpeaking() -> Bool {
|
|
return _isSpeaking
|
|
}
|
|
|
|
/// 释放资源
|
|
func dispose() {
|
|
try? stopSpeaking()
|
|
|
|
// 释放合成器和配置
|
|
synthesizer = nil
|
|
speechConfig = nil
|
|
|
|
isInitialized = false
|
|
_isSpeaking = false
|
|
print("[AzureTtsHelper] TTS 引擎已释放")
|
|
}
|
|
|
|
// MARK: - 私有辅助方法
|
|
|
|
/// 设置合成器事件处理
|
|
private func setupSynthesizerEvents() {
|
|
guard let synthesizer = synthesizer else { return }
|
|
|
|
// 添加书签到达事件处理
|
|
synthesizer.addBookmarkReachedEventHandler { _, e in
|
|
print("[AzureTtsHelper] 书签事件: 音频偏移: \((e.audioOffset + 5000) / 10000)ms, 文本: \"\(e.text)\"")
|
|
}
|
|
|
|
// 合成完成事件
|
|
synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in
|
|
guard let self = self else { return }
|
|
print("[AzureTtsHelper] 语音合成完成: 音频持续时间: \(e.result.audioDuration)")
|
|
DispatchQueue.main.async {
|
|
self._isSpeaking = false
|
|
self.eventHandler("completed", [:])
|
|
}
|
|
}
|
|
|
|
// 合成取消事件
|
|
synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in
|
|
guard let self = self else { return }
|
|
|
|
let result = e.result
|
|
do {
|
|
let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: result)
|
|
print("[AzureTtsHelper] 语音合成取消: 原因: \(cancellationDetails.reason)")
|
|
|
|
if cancellationDetails.reason == SPXCancellationReason.error {
|
|
print("[AzureTtsHelper] 错误代码: \(cancellationDetails.errorCode)")
|
|
print("[AzureTtsHelper] 错误详情: \(cancellationDetails.errorDetails ?? "未知")")
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
self._isSpeaking = false
|
|
self.eventHandler("error", ["error": "语音合成取消: \(cancellationDetails.errorDetails ?? "未知错误")"])
|
|
}
|
|
} catch {
|
|
print("[AzureTtsHelper] 获取取消详情时出错: \(error)")
|
|
|
|
DispatchQueue.main.async {
|
|
self._isSpeaking = false
|
|
self.eventHandler("error", ["error": "语音合成被取消"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// 合成开始事件
|
|
synthesizer.addSynthesisStartedEventHandler { _, _ in
|
|
print("[AzureTtsHelper] 语音合成开始")
|
|
}
|
|
|
|
// 合成中事件
|
|
synthesizer.addSynthesizingEventHandler { _, _ in
|
|
print("[AzureTtsHelper] 语音合成中")
|
|
}
|
|
}
|
|
|
|
/// 生成 SSML 文本
|
|
private func generateSsml(text: String) -> String {
|
|
return """
|
|
<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='zh-CN'>
|
|
<voice name='\(currentVoice)'>
|
|
<prosody rate='\(currentSpeechRate)' pitch='\(currentPitch)' volume='\(currentVolume)'>
|
|
\(text)
|
|
</prosody>
|
|
</voice>
|
|
</speak>
|
|
"""
|
|
}
|
|
|
|
/// 格式化语速参数
|
|
private func formatRateParam(_ rate: Int) -> String {
|
|
let clampedRate = rate.clamp(min: -100, max: 100)
|
|
if clampedRate == 0 {
|
|
return "0%"
|
|
} else if clampedRate < 0 {
|
|
return "\(Int(Double(clampedRate) * 0.9))%"
|
|
} else {
|
|
return "+\(clampedRate)%"
|
|
}
|
|
}
|
|
|
|
/// 格式化音调参数
|
|
private func formatPitchParam(_ pitch: Int) -> String {
|
|
let clampedPitch = pitch.clamp(min: -100, max: 100)
|
|
if clampedPitch == 0 {
|
|
return "0%"
|
|
} else {
|
|
return "\(Int(Double(clampedPitch) * 0.5))%"
|
|
}
|
|
}
|
|
|
|
/// 格式化音量参数
|
|
private func formatVolumeParam(_ volume: Int) -> String {
|
|
let clampedVolume = volume.clamp(min: 0, max: 100)
|
|
return "\(clampedVolume)%"
|
|
}
|
|
|
|
/// 获取指定语言的默认语音
|
|
private func getDefaultVoiceForLanguage(_ language: String) -> String {
|
|
return voiceMap[language] ?? "zh-CN-XiaoxiaoNeural"
|
|
}
|
|
}
|
|
|
|
// MARK: - 扩展
|
|
|
|
extension Int {
|
|
func clamp(min: Int, max: Int) -> Int {
|
|
if self < min { return min }
|
|
if self > max { return max }
|
|
return self
|
|
}
|
|
}
|