62 changed files with 8775 additions and 1439 deletions
Binary file not shown.
@ -0,0 +1,21 @@ |
|||||
|
MIT License |
||||
|
|
||||
|
Copyright (c) 2024 Your Company |
||||
|
|
||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
|
of this software and associated documentation files (the "Software"), to deal |
||||
|
in the Software without restriction, including without limitation the rights |
||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
|
copies of the Software, and to permit persons to whom the Software is |
||||
|
furnished to do so, subject to the following conditions: |
||||
|
|
||||
|
The above copyright notice and this permission notice shall be included in all |
||||
|
copies or substantial portions of the Software. |
||||
|
|
||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
|
SOFTWARE. |
||||
@ -0,0 +1,66 @@ |
|||||
|
# Azure Speech Recognition |
||||
|
|
||||
|
A Flutter plugin for Microsoft Azure Speech services, providing both speech recognition (ASR) and text-to-speech (TTS) capabilities. |
||||
|
|
||||
|
## Features |
||||
|
|
||||
|
- Speech-to-text (Azure Speech Recognition) |
||||
|
- Text-to-speech (Azure Speech Synthesis) |
||||
|
- Support for multiple languages |
||||
|
- Language detection |
||||
|
- Continuous recognition |
||||
|
- Streaming synthesis |
||||
|
|
||||
|
## Getting Started |
||||
|
|
||||
|
### Prerequisites |
||||
|
|
||||
|
- Azure Speech service subscription key |
||||
|
- Azure Speech service region |
||||
|
|
||||
|
### Installation |
||||
|
|
||||
|
Add this to your package's `pubspec.yaml` file: |
||||
|
|
||||
|
```yaml |
||||
|
dependencies: |
||||
|
azure_speech_recognition: |
||||
|
path: ./azure |
||||
|
``` |
||||
|
|
||||
|
### Usage |
||||
|
|
||||
|
```dart |
||||
|
import 'package:azure_speech_recognition/azure_speech_recognition.dart'; |
||||
|
|
||||
|
// Initialize the service |
||||
|
await AzureSpeechRecognition.initialize( |
||||
|
subscriptionKey: 'your_subscription_key', |
||||
|
region: 'your_region', |
||||
|
supportedLanguages: ['zh-CN', 'en-US'], |
||||
|
); |
||||
|
|
||||
|
// Start continuous recognition |
||||
|
await AzureSpeechRecognition.startContinuousRecognition(); |
||||
|
|
||||
|
// Listen for recognition events |
||||
|
AzureSpeechRecognition.onRecognitionEvent.listen((event) { |
||||
|
if (event['type'] == 'result') { |
||||
|
print('Recognized: ${event['text']}'); |
||||
|
print('Detected language: ${event['detectedLanguage']}'); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Stop recognition when done |
||||
|
await AzureSpeechRecognition.stopContinuousRecognition(); |
||||
|
|
||||
|
// Speak text |
||||
|
await AzureSpeechRecognition.speakText('Hello, world!'); |
||||
|
|
||||
|
// Clean up |
||||
|
await AzureSpeechRecognition.dispose(); |
||||
|
``` |
||||
|
|
||||
|
## License |
||||
|
|
||||
|
This project is licensed under the MIT License - see the LICENSE file for details. |
||||
@ -0,0 +1,757 @@ |
|||||
|
import Foundation |
||||
|
import MicrosoftCognitiveServicesSpeech |
||||
|
import AVFoundation |
||||
|
import AudioToolbox |
||||
|
|
||||
|
/// Azure ASR工具类,负责实现语音识别服务接口 |
||||
|
@available(iOS 13.0, *) |
||||
|
class AzureAsrHelper: NSObject { |
||||
|
// MARK: - 属性 |
||||
|
|
||||
|
/// 事件处理回调 |
||||
|
private var eventHandler: (String, [String: Any]) -> Void |
||||
|
|
||||
|
/// 语音配置信息 |
||||
|
private var speechSubscriptionKey: String = "" |
||||
|
private var serviceRegion: String = "" |
||||
|
|
||||
|
/// 语音识别相关 |
||||
|
private var speechConfig: SPXSpeechConfiguration? |
||||
|
private var recognizer: SPXSpeechRecognizer? |
||||
|
private var audioConfig: SPXAudioConfiguration? |
||||
|
private var pushStream: SPXPushAudioInputStream? |
||||
|
|
||||
|
/// 音频处理相关 |
||||
|
private var audioProcessor: CustomAudioProcessor? |
||||
|
private var isProcessingAudio = false |
||||
|
private var audioProcessingTimer: Timer? |
||||
|
|
||||
|
/// 状态标志 |
||||
|
private var isInitialized = false |
||||
|
private var _isContinuousRecognitionActive = false |
||||
|
|
||||
|
/// 当前语言和支持的语言 |
||||
|
private var currentLanguage = "zh-CN" |
||||
|
private var supportedLanguages: [String] = ["zh-CN", "en-US"] |
||||
|
private var isAutoDetectLanguage = false |
||||
|
|
||||
|
// MARK: - 初始化 |
||||
|
|
||||
|
init(eventHandler: @escaping (String, [String: Any]) -> Void) { |
||||
|
self.eventHandler = eventHandler |
||||
|
super.init() |
||||
|
} |
||||
|
|
||||
|
deinit { |
||||
|
dispose() |
||||
|
} |
||||
|
|
||||
|
// MARK: - ASR Service 接口实现 |
||||
|
|
||||
|
/// 初始化语音识别服务 |
||||
|
/// - Parameters: |
||||
|
/// - speechSubscriptionKey: Azure 语音服务订阅密钥 |
||||
|
/// - serviceRegion: Azure 服务区域 (如 eastasia) |
||||
|
/// - supportedLanguages: 支持的语言代码数组 (可选) |
||||
|
/// - Returns: 初始化是否成功 |
||||
|
func initialize(speechSubscriptionKey: String, serviceRegion: String, supportedLanguages: [String]? = nil) -> Bool { |
||||
|
print("[AzureAsrHelper] 初始化 Azure 语音服务") |
||||
|
|
||||
|
// 检查配置是否为空 |
||||
|
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
||||
|
print("[AzureAsrHelper] 错误: Azure 配置信息不完整") |
||||
|
eventHandler("error", ["message": "Azure 配置信息不完整"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 释放之前的资源 |
||||
|
dispose() |
||||
|
|
||||
|
// 记录配置信息 |
||||
|
self.speechSubscriptionKey = speechSubscriptionKey |
||||
|
self.serviceRegion = serviceRegion |
||||
|
|
||||
|
// 设置语言 |
||||
|
if let languages = supportedLanguages, !languages.isEmpty { |
||||
|
self.supportedLanguages = languages |
||||
|
} |
||||
|
|
||||
|
// 根据支持的语言数量决定是否启用自动语言检测 |
||||
|
isAutoDetectLanguage = self.supportedLanguages.count >= 2 |
||||
|
|
||||
|
// 如果只有一种语言,设置为当前语言 |
||||
|
if !isAutoDetectLanguage && !self.supportedLanguages.isEmpty { |
||||
|
currentLanguage = self.supportedLanguages[0] |
||||
|
} |
||||
|
|
||||
|
// 创建识别器和设置回调 |
||||
|
if !createRecognizerAndSetupCallbacks() { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
print("[AzureAsrHelper] Azure 语音服务初始化成功") |
||||
|
isInitialized = true |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
/// 创建识别器并设置回调 |
||||
|
private func createRecognizerAndSetupCallbacks() -> Bool { |
||||
|
// 释放之前的 recognizer |
||||
|
recognizer = nil |
||||
|
audioConfig = nil |
||||
|
|
||||
|
do { |
||||
|
// 创建语音配置 |
||||
|
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
||||
|
|
||||
|
// 设置音频输入参数 |
||||
|
try setupAudioSession() |
||||
|
|
||||
|
// 创建自定义推送流,替代默认的麦克风输入 |
||||
|
pushStream = try SPXPushAudioInputStream() |
||||
|
audioConfig = try SPXAudioConfiguration(streamInput: pushStream!) |
||||
|
|
||||
|
// 初始化自定义音频处理器 |
||||
|
audioProcessor = CustomAudioProcessor() |
||||
|
|
||||
|
// 设置语言配置 |
||||
|
if isAutoDetectLanguage { |
||||
|
// 设置自动语言检测 |
||||
|
speechConfig?.setPropertyTo("Continuous", by: SPXPropertyId.speechServiceConnectionLanguageIdMode) |
||||
|
|
||||
|
// 创建自动语言检测配置 |
||||
|
let autoDetectSourceLanguageConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) |
||||
|
|
||||
|
// 创建识别器 |
||||
|
recognizer = try SPXSpeechRecognizer( |
||||
|
speechConfiguration: speechConfig!, |
||||
|
autoDetectSourceLanguageConfiguration: autoDetectSourceLanguageConfig, |
||||
|
audioConfiguration: audioConfig! |
||||
|
) |
||||
|
} else { |
||||
|
// 设置指定的识别语言 |
||||
|
speechConfig?.speechRecognitionLanguage = currentLanguage |
||||
|
|
||||
|
// 创建识别器 |
||||
|
recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) |
||||
|
} |
||||
|
|
||||
|
// 设置所有回调 |
||||
|
setupAllCallbacks() |
||||
|
|
||||
|
return true |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 错误: 创建识别器失败: \(error.localizedDescription)") |
||||
|
eventHandler("error", ["message": "创建识别器失败: \(error.localizedDescription)"]) |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置音频会话 |
||||
|
private func setupAudioSession() throws { |
||||
|
let audioSession = AVAudioSession.sharedInstance() |
||||
|
|
||||
|
// 使用playAndRecord类别允许同时录音和播放 |
||||
|
try audioSession.setCategory(.playAndRecord, |
||||
|
mode: .voiceChat, // 使用voiceChat模式能够更好地支持回音消除 |
||||
|
options: [.allowBluetooth, .defaultToSpeaker, .allowAirPlay, .mixWithOthers]) |
||||
|
|
||||
|
// 设置首选的输入和输出 |
||||
|
let currentRoute = audioSession.currentRoute |
||||
|
|
||||
|
// 获取当前是否连接了耳机或外部麦克风 |
||||
|
let hasHeadphones = currentRoute.outputs.contains { |
||||
|
$0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP |
||||
|
} |
||||
|
|
||||
|
// 如果没有耳机,明确启用内置麦克风和扬声器的回音消除 |
||||
|
if !hasHeadphones { |
||||
|
try audioSession.setMode(.voiceChat) // 语音聊天模式有更强的回音消除 |
||||
|
|
||||
|
// 启用回音消除和噪声抑制 |
||||
|
try audioSession.setInputGain(0.8) // 适当降低输入增益以减少扬声器音频被麦克风捕获的可能性 |
||||
|
} else { |
||||
|
// 耳机模式,可以使用不同的设置 |
||||
|
try audioSession.setMode(.voiceChat) |
||||
|
try audioSession.setInputGain(1.0) |
||||
|
} |
||||
|
|
||||
|
// 设置合适的采样率 |
||||
|
try audioSession.setPreferredSampleRate(16000.0) // Azure语音识别推荐的采样率 |
||||
|
try audioSession.setPreferredIOBufferDuration(0.01) // 较小的缓冲区大小以减少延迟 |
||||
|
|
||||
|
// 激活音频会话 |
||||
|
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) |
||||
|
|
||||
|
print("[AzureAsrHelper] 音频会话配置成功,已启用回音消除") |
||||
|
} |
||||
|
|
||||
|
/// 设置所有回调 |
||||
|
private func setupAllCallbacks() { |
||||
|
guard let recognizer = recognizer else { return } |
||||
|
|
||||
|
// 最终识别结果 |
||||
|
recognizer.addRecognizedEventHandler { [weak self] _, event in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
if event.result.reason == SPXResultReason.recognizedSpeech { |
||||
|
let detectedLanguage = self.getDetectedLanguage(from: event.result) |
||||
|
print("[AzureAsrHelper] 识别结果: \(event.result.text ?? ""), 语言: \(detectedLanguage)") |
||||
|
self.eventHandler("result", [ |
||||
|
"text": event.result.text ?? "", |
||||
|
"detectedLanguage": detectedLanguage |
||||
|
]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 识别中事件 |
||||
|
recognizer.addRecognizingEventHandler { [weak self] _, event in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
if event.result.reason == SPXResultReason.recognizingSpeech { |
||||
|
let detectedLanguage = self.getDetectedLanguage(from: event.result) |
||||
|
// print("[AzureAsrHelper] 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") |
||||
|
self.eventHandler("recognizing", [ |
||||
|
"text": event.result.text ?? "", |
||||
|
"detectedLanguage": detectedLanguage |
||||
|
]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 会话事件 |
||||
|
recognizer.addSessionStartedEventHandler { [weak self] _, _ in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
print("[AzureAsrHelper] 识别会话已开始") |
||||
|
self._isContinuousRecognitionActive = true |
||||
|
self.eventHandler("sessionStarted", [:]) |
||||
|
} |
||||
|
|
||||
|
recognizer.addSessionStoppedEventHandler { [weak self] _, _ in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
print("[AzureAsrHelper] 识别会话已结束") |
||||
|
self._isContinuousRecognitionActive = false |
||||
|
self.eventHandler("sessionStopped", [:]) |
||||
|
} |
||||
|
|
||||
|
// 取消事件 |
||||
|
recognizer.addCanceledEventHandler { [weak self] _, event in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
let reason = event.reason.rawValue |
||||
|
let errorDetails = event.errorDetails ?? "未知错误" |
||||
|
|
||||
|
print("[AzureAsrHelper] 识别取消: \(errorDetails)") |
||||
|
|
||||
|
self.eventHandler("canceled", [ |
||||
|
"reason": reason, |
||||
|
"errorDetails": errorDetails |
||||
|
]) |
||||
|
|
||||
|
self._isContinuousRecognitionActive = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 执行一次性语音识别 |
||||
|
/// - Returns: 是否成功启动识别 |
||||
|
func recognizeOnce() -> Bool { |
||||
|
if !isInitialized { |
||||
|
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
||||
|
eventHandler("error", ["message": "语音服务未初始化"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 如果正在连续识别,先停止 |
||||
|
if _isContinuousRecognitionActive { |
||||
|
stopContinuousRecognition() |
||||
|
} |
||||
|
|
||||
|
// 确保识别器已创建 |
||||
|
if recognizer == nil && !createRecognizerAndSetupCallbacks() { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
// 启动音频处理 |
||||
|
startAudioProcessing() |
||||
|
|
||||
|
// 通知会话开始 |
||||
|
eventHandler("sessionStarted", [:]) |
||||
|
|
||||
|
// 执行识别 |
||||
|
try recognizer?.recognizeOnceAsync { [weak self] result in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
// 停止音频处理 |
||||
|
self.stopAudioProcessing() |
||||
|
|
||||
|
if result.reason == SPXResultReason.recognizedSpeech { |
||||
|
let detectedLanguage = self.getDetectedLanguage(from: result) |
||||
|
self.eventHandler("result", [ |
||||
|
"text": result.text ?? "", |
||||
|
"detectedLanguage": detectedLanguage |
||||
|
]) |
||||
|
} else if result.reason == SPXResultReason.noMatch { |
||||
|
print("[AzureAsrHelper] 无匹配结果") |
||||
|
self.eventHandler("noMatch", [:]) |
||||
|
} else if result.reason == SPXResultReason.canceled { |
||||
|
do { |
||||
|
let details = try SPXCancellationDetails(fromCanceledRecognitionResult: result) |
||||
|
let errorDetails = details.errorDetails ?? "未知错误" |
||||
|
self.eventHandler("error", ["message": "识别取消: \(errorDetails)"]) |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 错误: 获取取消详情失败: \(error.localizedDescription)") |
||||
|
self.eventHandler("error", ["message": "识别取消,无法获取详细原因"]) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return true |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 错误: 识别异常: \(error.localizedDescription)") |
||||
|
eventHandler("error", ["message": "识别异常: \(error.localizedDescription)"]) |
||||
|
stopAudioProcessing() |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 开始连续语音识别 |
||||
|
/// - Returns: 是否成功启动识别 |
||||
|
func startContinuousRecognition() -> Bool { |
||||
|
if !isInitialized { |
||||
|
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
||||
|
eventHandler("error", ["message": "语音服务未初始化"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 如果已经在进行连续识别,先停止 |
||||
|
if _isContinuousRecognitionActive { |
||||
|
stopContinuousRecognition() |
||||
|
} |
||||
|
|
||||
|
// 确保识别器已创建 |
||||
|
if recognizer == nil && !createRecognizerAndSetupCallbacks() { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 重新确保音频设置正确 |
||||
|
do { |
||||
|
try setupAudioSession() |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 警告: 设置音频会话失败: \(error.localizedDescription)") |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
// 启动音频处理 |
||||
|
startAudioProcessing() |
||||
|
|
||||
|
// 启动连续识别 |
||||
|
try recognizer?.startContinuousRecognition() |
||||
|
_isContinuousRecognitionActive = true |
||||
|
|
||||
|
print("[AzureAsrHelper] 连续识别开始") |
||||
|
return true |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") |
||||
|
eventHandler("error", ["message": "开始连续识别失败: \(error.localizedDescription)"]) |
||||
|
_isContinuousRecognitionActive = false |
||||
|
stopAudioProcessing() |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止连续语音识别 |
||||
|
/// - Returns: 是否成功停止识别 |
||||
|
func stopContinuousRecognition() -> Bool { |
||||
|
// 停止音频处理 |
||||
|
stopAudioProcessing() |
||||
|
|
||||
|
if !_isContinuousRecognitionActive || recognizer == nil { |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
try recognizer?.stopContinuousRecognition() |
||||
|
_isContinuousRecognitionActive = false |
||||
|
print("[AzureAsrHelper] 连续识别已停止") |
||||
|
return true |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") |
||||
|
eventHandler("error", ["message": "停止连续识别失败: \(error.localizedDescription)"]) |
||||
|
_isContinuousRecognitionActive = false |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查连续识别是否活跃 |
||||
|
/// - Returns: 连续识别是否处于活跃状态 |
||||
|
func isContinuousRecognitionActive() -> Bool { |
||||
|
return _isContinuousRecognitionActive |
||||
|
} |
||||
|
|
||||
|
/// 释放资源 |
||||
|
func dispose() { |
||||
|
print("[AzureAsrHelper] 释放资源") |
||||
|
|
||||
|
// 停止音频处理 |
||||
|
stopAudioProcessing() |
||||
|
|
||||
|
// 停止连续识别 |
||||
|
if _isContinuousRecognitionActive { |
||||
|
stopContinuousRecognition() |
||||
|
} |
||||
|
|
||||
|
// 释放音频会话 |
||||
|
do { |
||||
|
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 警告: 释放音频会话失败: \(error.localizedDescription)") |
||||
|
} |
||||
|
|
||||
|
// 释放资源 |
||||
|
recognizer = nil |
||||
|
speechConfig = nil |
||||
|
audioConfig = nil |
||||
|
pushStream = nil |
||||
|
audioProcessor = nil |
||||
|
|
||||
|
// 重置状态 |
||||
|
_isContinuousRecognitionActive = false |
||||
|
isInitialized = false |
||||
|
} |
||||
|
|
||||
|
/// 从结果中获取检测到的语言 |
||||
|
private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { |
||||
|
if isAutoDetectLanguage { |
||||
|
do { |
||||
|
let langResult = try SPXAutoDetectSourceLanguageResult(result) |
||||
|
return langResult.language ?? currentLanguage |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 错误: 获取检测到的语言失败: \(error.localizedDescription)") |
||||
|
return currentLanguage |
||||
|
} |
||||
|
} else { |
||||
|
return currentLanguage |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 音频处理 |
||||
|
|
||||
|
/// 开始音频处理 |
||||
|
private func startAudioProcessing() { |
||||
|
guard !isProcessingAudio, let audioProcessor = audioProcessor else { return } |
||||
|
|
||||
|
isProcessingAudio = true |
||||
|
|
||||
|
// 启动音频处理器 |
||||
|
if !audioProcessor.startRecord() { |
||||
|
print("[AzureAsrHelper] 错误: 启动音频处理器失败") |
||||
|
eventHandler("error", ["message": "启动音频处理器失败"]) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 启动音频处理定时器 |
||||
|
audioProcessingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in |
||||
|
guard let self = self, self.isProcessingAudio, let processor = self.audioProcessor, let stream = self.pushStream else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 读取处理后的音频数据 |
||||
|
var bytes = [UInt8](repeating: 0, count: 2560) |
||||
|
let bytesRead = processor.read(bytes: &bytes) |
||||
|
|
||||
|
if bytesRead > 0 { |
||||
|
// 推送数据到Azure语音服务 |
||||
|
let data = Data(bytes: bytes, count: bytesRead) |
||||
|
stream.write(data) |
||||
|
|
||||
|
// 通知音频数据可用 |
||||
|
self.eventHandler("audioData", ["data": bytes]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
print("[AzureAsrHelper] 音频处理已启动") |
||||
|
} |
||||
|
|
||||
|
/// 停止音频处理 |
||||
|
private func stopAudioProcessing() { |
||||
|
// 停止定时器 |
||||
|
audioProcessingTimer?.invalidate() |
||||
|
audioProcessingTimer = nil |
||||
|
|
||||
|
// 停止音频处理器 |
||||
|
audioProcessor?.stopRecord() |
||||
|
|
||||
|
isProcessingAudio = false |
||||
|
print("[AzureAsrHelper] 音频处理已停止") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 自定义音频处理器 |
||||
|
|
||||
|
@available(iOS 13.0, *) |
||||
|
class CustomAudioProcessor: NSObject { |
||||
|
// 音频单元 |
||||
|
private var ioUnit: AudioUnit? |
||||
|
|
||||
|
// 音频格式 |
||||
|
private var audioFormat: AudioStreamBasicDescription |
||||
|
|
||||
|
// 音频缓冲 |
||||
|
private var audioBufferList: AudioBufferList |
||||
|
private var audioList: [Float] = [] |
||||
|
private let audioListQueue = DispatchQueue(label: "audioListQueue") |
||||
|
|
||||
|
// 回音消除状态 |
||||
|
private var isEchoCancellationEnabled = true |
||||
|
|
||||
|
override init() { |
||||
|
// 设置音频格式 - 16kHz, 16位, 单声道 |
||||
|
audioFormat = AudioStreamBasicDescription( |
||||
|
mSampleRate: 16000.0, |
||||
|
mFormatID: kAudioFormatLinearPCM, |
||||
|
mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked, |
||||
|
mBytesPerPacket: 2, |
||||
|
mFramesPerPacket: 1, |
||||
|
mBytesPerFrame: 2, |
||||
|
mChannelsPerFrame: 1, |
||||
|
mBitsPerChannel: 16, |
||||
|
mReserved: 0 |
||||
|
) |
||||
|
|
||||
|
// 初始化音频缓冲 |
||||
|
audioBufferList = AudioBufferList( |
||||
|
mNumberBuffers: 1, |
||||
|
mBuffers: AudioBuffer( |
||||
|
mNumberChannels: 1, |
||||
|
mDataByteSize: 4096, |
||||
|
mData: malloc(4096) |
||||
|
) |
||||
|
) |
||||
|
|
||||
|
super.init() |
||||
|
} |
||||
|
|
||||
|
deinit { |
||||
|
stopRecord() |
||||
|
free(audioBufferList.mBuffers.mData) |
||||
|
} |
||||
|
|
||||
|
/// 启动音频处理 |
||||
|
/// - Returns: 是否成功启动 |
||||
|
func startRecord() -> Bool { |
||||
|
print("[CustomAudioProcessor] 配置音频单元") |
||||
|
|
||||
|
// 创建音频组件描述 - 使用VoiceProcessingIO类型获取回音消除 |
||||
|
var ioUnitDescription = AudioComponentDescription( |
||||
|
componentType: kAudioUnitType_Output, |
||||
|
componentSubType: kAudioUnitSubType_VoiceProcessingIO, |
||||
|
componentManufacturer: kAudioUnitManufacturer_Apple, |
||||
|
componentFlags: 0, |
||||
|
componentFlagsMask: 0 |
||||
|
) |
||||
|
|
||||
|
// 查找音频组件 |
||||
|
guard let ioUnitRef = AudioComponentFindNext(nil, &ioUnitDescription) else { |
||||
|
print("[CustomAudioProcessor] 错误: 未找到音频组件") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 创建音频单元实例 |
||||
|
if checkError(AudioComponentInstanceNew(ioUnitRef, &ioUnit), "创建音频单元") { |
||||
|
ioUnit = nil |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 启用输入端口 |
||||
|
var enableInput: UInt32 = 1 |
||||
|
let kInputBus: AudioUnitElement = 1 |
||||
|
let kOutputBus: AudioUnitElement = 0 |
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
||||
|
kAudioUnitScope_Input, kInputBus, &enableInput, |
||||
|
UInt32(MemoryLayout<UInt32>.size)), "启用输入端口") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 禁用输出端口 (我们只需要输入) |
||||
|
var enableOutput: UInt32 = 0 |
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
||||
|
kAudioUnitScope_Output, kOutputBus, |
||||
|
&enableOutput, UInt32(MemoryLayout<UInt32>.size)), "禁用输出端口") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 设置缓冲区分配标志 |
||||
|
var flag: UInt32 = 0 |
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_ShouldAllocateBuffer, |
||||
|
kAudioUnitScope_Output, kInputBus, &flag, UInt32(MemoryLayout<UInt32>.size)), "设置缓冲区分配标志") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 设置音频格式 |
||||
|
let size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size) |
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
||||
|
kAudioUnitScope_Output, kInputBus, &audioFormat, size), "设置输入总线输出范围的流格式") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
||||
|
kAudioUnitScope_Input, kOutputBus, &audioFormat, size), "设置输出总线输入范围的流格式") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 启用回音消除 |
||||
|
if isEchoCancellationEnabled { |
||||
|
var echoCancellation: UInt32 = 1 |
||||
|
AudioUnitSetProperty(ioUnit!, kAUVoiceIOProperty_BypassVoiceProcessing, |
||||
|
kAudioUnitScope_Global, 0, &echoCancellation, UInt32(MemoryLayout<UInt32>.size)) |
||||
|
} |
||||
|
|
||||
|
// 设置输入回调 - 当有新音频数据时调用 |
||||
|
var inputCallback = AURenderCallbackStruct( |
||||
|
inputProc: CustomAudioProcessor.onAudioDataAvailable, |
||||
|
inputProcRefCon: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) |
||||
|
) |
||||
|
|
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, |
||||
|
kAudioOutputUnitProperty_SetInputCallback, |
||||
|
kAudioUnitScope_Global, kInputBus, |
||||
|
&inputCallback, UInt32(MemoryLayout<AURenderCallbackStruct>.size)), "设置输入回调") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 初始化音频单元 |
||||
|
var hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") |
||||
|
while hasError { |
||||
|
Thread.sleep(forTimeInterval: 0.1) |
||||
|
hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") |
||||
|
} |
||||
|
|
||||
|
// 启动音频单元 |
||||
|
hasError = checkError(AudioOutputUnitStart(ioUnit!), "启动音频单元") |
||||
|
|
||||
|
print("[CustomAudioProcessor] 音频处理器已启动,回音消除\(isEchoCancellationEnabled ? "已启用" : "已禁用")") |
||||
|
return !hasError |
||||
|
} |
||||
|
|
||||
|
/// 停止音频处理 |
||||
|
func stopRecord() { |
||||
|
print("[CustomAudioProcessor] 停止音频处理器") |
||||
|
|
||||
|
if let ioUnit = ioUnit { |
||||
|
// 停止音频单元 |
||||
|
_ = checkError(AudioOutputUnitStop(ioUnit), "停止音频单元") |
||||
|
|
||||
|
// 关闭音频单元 |
||||
|
_ = checkError(AudioUnitUninitialize(ioUnit), "反初始化音频单元") |
||||
|
_ = checkError(AudioComponentInstanceDispose(ioUnit), "释放音频单元") |
||||
|
|
||||
|
self.ioUnit = nil |
||||
|
} |
||||
|
|
||||
|
// 清空音频数据缓冲 |
||||
|
audioListQueue.sync { |
||||
|
audioList.removeAll() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 音频数据回调 - 当有新的音频数据可用时调用 |
||||
|
private static let onAudioDataAvailable: AURenderCallback = { inRefCon, ioActionFlags, inTimeStamp, inBusNumber, inNumberFrames, ioData in |
||||
|
// 获取实例 |
||||
|
let processor = Unmanaged<CustomAudioProcessor>.fromOpaque(inRefCon).takeUnretainedValue() |
||||
|
|
||||
|
// 计算预期数据大小 |
||||
|
let expectedDataByteSize = inNumberFrames * processor.audioFormat.mBytesPerFrame |
||||
|
|
||||
|
// 确保缓冲区足够大 |
||||
|
if processor.audioBufferList.mBuffers.mDataByteSize < expectedDataByteSize { |
||||
|
processor.audioBufferList.mBuffers.mData = realloc(processor.audioBufferList.mBuffers.mData, Int(expectedDataByteSize)) |
||||
|
processor.audioBufferList.mBuffers.mDataByteSize = expectedDataByteSize |
||||
|
} |
||||
|
|
||||
|
// 渲染音频数据 |
||||
|
let status = processor.checkOSStatus(AudioUnitRender(processor.ioUnit!, ioActionFlags, inTimeStamp, |
||||
|
inBusNumber, inNumberFrames, &processor.audioBufferList), |
||||
|
"渲染音频数据") |
||||
|
|
||||
|
// 将Int16数据转换为浮点数据进行处理 |
||||
|
var audioDataFloat = [Float](repeating: 0.0, count: Int(inNumberFrames)) |
||||
|
let buffer = processor.audioBufferList.mBuffers |
||||
|
let bufferData = buffer.mData!.assumingMemoryBound(to: Int16.self) |
||||
|
|
||||
|
for j in 0..<Int(buffer.mDataByteSize / UInt32(MemoryLayout<Int16>.size)) { |
||||
|
// 归一化到[-1.0, 1.0]范围 |
||||
|
audioDataFloat[j] = Float(bufferData[j]) / 32768.0 |
||||
|
} |
||||
|
|
||||
|
// 应用附加处理 (如有需要) |
||||
|
// processor.applyAdditionalProcessing(&audioDataFloat) |
||||
|
|
||||
|
// 保存处理后的数据 |
||||
|
if status == noErr { |
||||
|
processor.audioListQueue.async { |
||||
|
processor.audioList.append(contentsOf: audioDataFloat) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return status |
||||
|
} |
||||
|
|
||||
|
/// 读取处理后的音频数据 |
||||
|
/// - Parameter bytes: 输出字节数组 |
||||
|
/// - Returns: 读取的字节数 |
||||
|
func read(bytes: inout [UInt8]) -> Int { |
||||
|
return audioListQueue.sync { |
||||
|
// 如果没有数据,返回0 |
||||
|
if audioList.isEmpty { |
||||
|
return 0 |
||||
|
} |
||||
|
|
||||
|
// 确保有足够的数据 (至少1280个样本) |
||||
|
if audioList.count < 1280 { |
||||
|
return 0 |
||||
|
} |
||||
|
|
||||
|
// 读取一帧数据 (1280个样本) |
||||
|
let frameLength = 1280 |
||||
|
let buffer = Array(audioList.prefix(frameLength)) |
||||
|
audioList.removeFirst(frameLength) |
||||
|
|
||||
|
// 将浮点数据转回Int16格式 |
||||
|
var int16Data = buffer.map { Int16($0 * 32767) } |
||||
|
|
||||
|
// 转换为字节数组 |
||||
|
let data = Data(buffer: UnsafeBufferPointer(start: &int16Data, count: int16Data.count)) |
||||
|
bytes = [UInt8](data) |
||||
|
|
||||
|
// 每个样本2字节 (16位PCM) |
||||
|
return frameLength * 2 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查错误并打印日志 |
||||
|
/// - Parameters: |
||||
|
/// - status: 操作状态 |
||||
|
/// - operation: 操作描述 |
||||
|
/// - Returns: 是否发生错误 |
||||
|
private func checkError(_ status: OSStatus, _ operation: String) -> Bool { |
||||
|
if status != noErr { |
||||
|
print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") |
||||
|
return true |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
/// 检查OSStatus并返回状态 |
||||
|
/// - Parameters: |
||||
|
/// - status: 操作状态 |
||||
|
/// - operation: 操作描述 |
||||
|
/// - Returns: 原始状态 |
||||
|
private func checkOSStatus(_ status: OSStatus, _ operation: String) -> OSStatus { |
||||
|
if status != noErr { |
||||
|
print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") |
||||
|
} |
||||
|
return status |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
import Flutter |
||||
|
import UIKit |
||||
|
|
||||
|
public class AzureSpeechRecognitionPlugin: NSObject, FlutterPlugin { |
||||
|
public static func register(with registrar: FlutterPluginRegistrar) { |
||||
|
if #available(iOS 13.0, *) { |
||||
|
SwiftAzureSpeechRecognitionPlugin.register(with: registrar) |
||||
|
} else { |
||||
|
// 如果低于iOS 13.0,返回不支持的错误 |
||||
|
let channel = FlutterMethodChannel(name: "com.deep_voice.azure_asr", binaryMessenger: registrar.messenger()) |
||||
|
channel.setMethodCallHandler { (call, result) in |
||||
|
result(FlutterError(code: "UNSUPPORTED", |
||||
|
message: "需要iOS 13.0及以上系统", |
||||
|
details: nil)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,427 @@ |
|||||
|
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 |
||||
|
|
||||
|
/// 音频会话配置 |
||||
|
private var isAudioSessionConfigured = 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 |
||||
|
|
||||
|
// 配置音频会话 |
||||
|
if !configureAudioSession() { |
||||
|
print("[AzureTtsHelper] 警告: 音频会话配置失败,将尝试继续初始化") |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
// 创建语音配置 |
||||
|
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
||||
|
|
||||
|
// 设置默认语音 |
||||
|
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 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 配置音频会话 |
||||
|
private func configureAudioSession() -> Bool { |
||||
|
let audioSession = AVAudioSession.sharedInstance() |
||||
|
do { |
||||
|
// 使用playback类别,但支持混合和空中播放 |
||||
|
try audioSession.setCategory(.playback, |
||||
|
mode: .spokenAudio, |
||||
|
options: [.mixWithOthers, .allowAirPlay, .duckOthers]) |
||||
|
|
||||
|
// 根据设备类型选择最佳配置 |
||||
|
let currentRoute = audioSession.currentRoute |
||||
|
let hasHeadphones = currentRoute.outputs.contains { |
||||
|
$0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP |
||||
|
} |
||||
|
|
||||
|
// 优化音频路由 |
||||
|
if hasHeadphones { |
||||
|
// 耳机模式,使用默认设置 |
||||
|
try audioSession.setPreferredIOBufferDuration(0.005) // 较小的缓冲区大小以减少延迟 |
||||
|
} else { |
||||
|
// 扬声器模式 |
||||
|
try audioSession.setPreferredIOBufferDuration(0.005) |
||||
|
} |
||||
|
|
||||
|
// 避免完全激活音频会话,因为ASR可能已经激活 |
||||
|
// 这里使用setActive(false)是为了不与ASR冲突 |
||||
|
if !audioSession.isOtherAudioPlaying { |
||||
|
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) |
||||
|
} |
||||
|
|
||||
|
isAudioSessionConfigured = true |
||||
|
print("[AzureTtsHelper] 音频会话配置成功") |
||||
|
return true |
||||
|
} catch { |
||||
|
print("[AzureTtsHelper] 警告: 音频会话配置失败: \(error.localizedDescription)") |
||||
|
isAudioSessionConfigured = false |
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
// 确保音频会话已配置 |
||||
|
if !isAudioSessionConfigured { |
||||
|
_ = configureAudioSession() |
||||
|
} |
||||
|
|
||||
|
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 { |
||||
|
// 使用异步方法进行合成并直接播放 |
||||
|
_ = 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 |
||||
|
isAudioSessionConfigured = 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 |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,259 @@ |
|||||
|
import Flutter |
||||
|
import UIKit |
||||
|
import MicrosoftCognitiveServicesSpeech |
||||
|
import AVFoundation |
||||
|
|
||||
|
@available(iOS 13.0, *) |
||||
|
public class SwiftAzureSpeechRecognitionPlugin: NSObject, FlutterPlugin { |
||||
|
private var azureChannel: FlutterMethodChannel |
||||
|
private var ttsChannel: FlutterMethodChannel |
||||
|
private var asrHelper: AzureAsrHelper |
||||
|
private var ttsHelper: AzureTtsHelper |
||||
|
private static var eventStreamHandler: AzureEventStreamHandler? |
||||
|
|
||||
|
// 创建方法到通道的映射 |
||||
|
private static var ttsMethodHandlers = [String: FlutterMethodCallHandler]() |
||||
|
private static var asrMethodHandlers = [String: FlutterMethodCallHandler]() |
||||
|
|
||||
|
public static func register(with registrar: FlutterPluginRegistrar) { |
||||
|
// ASR通道 |
||||
|
let channel = FlutterMethodChannel(name: "com.deep_voice.azure_asr", binaryMessenger: registrar.messenger()) |
||||
|
|
||||
|
// TTS通道 |
||||
|
let ttsChannel = FlutterMethodChannel(name: "com.deep_voice.azure_tts", binaryMessenger: registrar.messenger()) |
||||
|
|
||||
|
// 设置ASR事件通道 |
||||
|
let eventChannel = FlutterEventChannel(name: "com.deep_voice.azure_asr_events", binaryMessenger: registrar.messenger()) |
||||
|
eventStreamHandler = AzureEventStreamHandler() |
||||
|
eventChannel.setStreamHandler(eventStreamHandler) |
||||
|
|
||||
|
let instance = SwiftAzureSpeechRecognitionPlugin( |
||||
|
azureChannel: channel, |
||||
|
ttsChannel: ttsChannel, |
||||
|
eventStreamHandler: eventStreamHandler! |
||||
|
) |
||||
|
|
||||
|
// 直接设置各自通道的处理器 |
||||
|
channel.setMethodCallHandler(instance.handleAsrMethodCalls) |
||||
|
ttsChannel.setMethodCallHandler(instance.handleTtsMethodCalls) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
|
||||
|
// 新增直接处理方法调用的函数 |
||||
|
private func handleTtsMethodCalls(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
||||
|
handleTtsMethod(call, result) |
||||
|
} |
||||
|
|
||||
|
private func handleAsrMethodCalls(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
||||
|
handleAsrMethod(call, result) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
init(azureChannel: FlutterMethodChannel, ttsChannel: FlutterMethodChannel, eventStreamHandler: AzureEventStreamHandler) { |
||||
|
self.azureChannel = azureChannel |
||||
|
self.ttsChannel = ttsChannel |
||||
|
|
||||
|
// 创建辅助类实例,使用自定义事件回调处理器 |
||||
|
let eventHandler: (String, [String: Any]) -> Void = { eventName, arguments in |
||||
|
DispatchQueue.main.async { |
||||
|
if let eventSink = SwiftAzureSpeechRecognitionPlugin.eventStreamHandler?.eventSink { |
||||
|
var eventData = arguments |
||||
|
eventData["type"] = eventName |
||||
|
eventSink(eventData) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
asrHelper = AzureAsrHelper(eventHandler: eventHandler) |
||||
|
ttsHelper = AzureTtsHelper(eventHandler: eventHandler) |
||||
|
|
||||
|
super.init() |
||||
|
} |
||||
|
|
||||
|
private func handleAsrMethod(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
||||
|
|
||||
|
let args = call.arguments as? Dictionary<String, Any> |
||||
|
|
||||
|
switch call.method { |
||||
|
case "initialize": |
||||
|
// 仅在初始化时读取必要参数 |
||||
|
guard let speechSubscriptionKey = args?["subscriptionKey"] as? String, !speechSubscriptionKey.isEmpty else { |
||||
|
let errorMsg = "语音订阅密钥不能为空" |
||||
|
print("[AzurePlugin] 错误: \(errorMsg)") |
||||
|
result(FlutterError(code: "INVALID_SUBSCRIPTION_KEY", message: errorMsg, details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
guard let serviceRegion = args?["region"] as? String, !serviceRegion.isEmpty else { |
||||
|
let errorMsg = "服务区域不能为空" |
||||
|
print("[AzurePlugin] 错误: \(errorMsg)") |
||||
|
result(FlutterError(code: "INVALID_REGION", message: errorMsg, details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let supportedLanguages = args?["supportedLanguages"] as? [String] ?? [] |
||||
|
|
||||
|
let success = asrHelper.initialize( |
||||
|
speechSubscriptionKey: speechSubscriptionKey, |
||||
|
serviceRegion: serviceRegion, |
||||
|
supportedLanguages: supportedLanguages.isEmpty ? nil : supportedLanguages |
||||
|
) |
||||
|
result(success) |
||||
|
|
||||
|
case "startContinuousRecognition": |
||||
|
// 只有使用参数时才验证 |
||||
|
let success = asrHelper.startContinuousRecognition() |
||||
|
result(success) |
||||
|
|
||||
|
case "stopContinuousRecognition": |
||||
|
// 不需要额外参数 |
||||
|
let success = asrHelper.stopContinuousRecognition() |
||||
|
result(success) |
||||
|
|
||||
|
case "recognizeOnce": |
||||
|
// 只有使用参数时才验证 |
||||
|
let success = asrHelper.recognizeOnce() |
||||
|
result(success) |
||||
|
|
||||
|
case "isContinuousRecognitionActive": |
||||
|
// 不需要额外参数 |
||||
|
result(asrHelper.isContinuousRecognitionActive()) |
||||
|
|
||||
|
case "dispose": |
||||
|
// 不需要额外参数 |
||||
|
print("[AzurePlugin] 释放ASR资源") |
||||
|
asrHelper.dispose() |
||||
|
result(true) |
||||
|
|
||||
|
default: |
||||
|
print("[AzurePlugin] 错误: 未知ASR方法: \(call.method)") |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func handleTtsMethod(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
||||
|
|
||||
|
let args = call.arguments as? Dictionary<String, Any> |
||||
|
|
||||
|
switch call.method { |
||||
|
case "initialize": |
||||
|
// 仅在初始化时验证参数 |
||||
|
guard let speechSubscriptionKey = args?["subscriptionKey"] as? String, !speechSubscriptionKey.isEmpty else { |
||||
|
let errorMsg = "语音订阅密钥不能为空" |
||||
|
print("[AzurePlugin] 错误: \(errorMsg)") |
||||
|
result(FlutterError(code: "INVALID_SUBSCRIPTION_KEY", message: errorMsg, details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
guard let serviceRegion = args?["region"] as? String, !serviceRegion.isEmpty else { |
||||
|
let errorMsg = "服务区域不能为空" |
||||
|
print("[AzurePlugin] 错误: \(errorMsg)") |
||||
|
result(FlutterError(code: "INVALID_REGION", message: errorMsg, details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let language = args?["language"] as? String ?? "zh-CN" |
||||
|
|
||||
|
print("[AzurePlugin] 初始化TTS,语言: \(language)") |
||||
|
|
||||
|
let success = ttsHelper.initialize(speechSubscriptionKey: speechSubscriptionKey, serviceRegion: serviceRegion, language: language) |
||||
|
result(success) |
||||
|
|
||||
|
case "setVoice": |
||||
|
// 仅获取voice参数 |
||||
|
guard let voiceName = args?["voiceName"] as? String, !voiceName.isEmpty else { |
||||
|
let errorMsg = "声音名称不能为空" |
||||
|
print("[AzurePlugin] 错误: \(errorMsg)") |
||||
|
result(FlutterError(code: "INVALID_VOICE", message: errorMsg, details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
print("[AzurePlugin] 设置声音: \(voiceName)") |
||||
|
|
||||
|
let success = ttsHelper.setVoice(voiceName: voiceName) |
||||
|
result(success) |
||||
|
|
||||
|
case "speakText": |
||||
|
// 仅获取text参数 |
||||
|
let text = args?["text"] as? String ?? "" |
||||
|
|
||||
|
if text.isEmpty { |
||||
|
print("[AzurePlugin] 警告: 要播放的文本为空") |
||||
|
result("OK") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
print("[AzurePlugin] 播放文本: \(text.prefix(50))...") |
||||
|
|
||||
|
let success = ttsHelper.speakText(text: text) |
||||
|
result(success ? "OK" : "ERROR") |
||||
|
|
||||
|
case "speakSsml": |
||||
|
// 仅获取ssml参数 |
||||
|
guard let ssml = args?["ssml"] as? String, !ssml.isEmpty else { |
||||
|
let errorMsg = "SSML内容不能为空" |
||||
|
print("[AzurePlugin] 错误: \(errorMsg)") |
||||
|
result(FlutterError(code: "INVALID_SSML", message: errorMsg, details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
print("[AzurePlugin] 播放SSML: \(ssml.prefix(100))...") |
||||
|
|
||||
|
// 由于我们移除了speakSsml方法,这里改用speakText方法 |
||||
|
// Azure SDK内部会自动检测是普通文本还是SSML |
||||
|
let success = ttsHelper.speakText(text: ssml) |
||||
|
result(success) |
||||
|
|
||||
|
case "stopSpeaking": |
||||
|
// 不需要参数 |
||||
|
print("[AzurePlugin] 停止播放") |
||||
|
let success = ttsHelper.stopSpeaking() |
||||
|
result(success) |
||||
|
|
||||
|
case "isSpeaking": |
||||
|
// 不需要参数 |
||||
|
result(ttsHelper.isSpeaking()) |
||||
|
|
||||
|
case "setSpeechParams": |
||||
|
// 仅获取语音参数 |
||||
|
let rate = args?["rate"] as? Int ?? 0 |
||||
|
let pitch = args?["pitch"] as? Int ?? 0 |
||||
|
let volume = args?["volume"] as? Int ?? 100 |
||||
|
|
||||
|
print("[AzurePlugin] 设置语音参数: rate=\(rate), pitch=\(pitch), volume=\(volume)") |
||||
|
let success = ttsHelper.setSpeechParams(rate: rate, pitch: pitch, volume: volume) |
||||
|
result(success) |
||||
|
|
||||
|
case "dispose": |
||||
|
// 释放TTS资源 |
||||
|
print("[AzurePlugin] 释放TTS资源") |
||||
|
ttsHelper.dispose() |
||||
|
result(true) |
||||
|
|
||||
|
default: |
||||
|
print("[AzurePlugin] 错误: 未知TTS方法: \(call.method)") |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 用于处理事件流的辅助类 |
||||
|
@available(iOS 13.0, *) |
||||
|
class AzureEventStreamHandler: NSObject, FlutterStreamHandler { |
||||
|
var eventSink: FlutterEventSink? |
||||
|
|
||||
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
||||
|
self.eventSink = events |
||||
|
// 通知Flutter端事件通道已准备好 |
||||
|
DispatchQueue.main.async { |
||||
|
events(["type": "channelReady"]) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
||||
|
self.eventSink = nil |
||||
|
return nil |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
# |
||||
|
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. |
||||
|
# Run `pod lib lint azure_speech_recognition.podspec` to validate before publishing. |
||||
|
# |
||||
|
Pod::Spec.new do |s| |
||||
|
s.name = 'azure_speech_recognition' |
||||
|
s.version = '0.1.0' |
||||
|
s.summary = 'Azure Speech Recognition plugin for Flutter' |
||||
|
s.description = <<-DESC |
||||
|
A Flutter plugin for Microsoft Azure Speech services, providing both speech recognition (ASR) and text-to-speech (TTS) capabilities. |
||||
|
DESC |
||||
|
s.homepage = 'https://github.com/yourusername/azure_speech_recognition' |
||||
|
s.license = { :type => 'MIT', :file => '../LICENSE' } |
||||
|
s.author = { 'Your Company' => 'your-email@example.com' } |
||||
|
s.source = { :path => '.' } |
||||
|
s.source_files = 'Classes/**/*' |
||||
|
s.dependency 'Flutter' |
||||
|
s.dependency 'MicrosoftCognitiveServicesSpeech-iOS', '~> 1.34.0' |
||||
|
s.platform = :ios, '12.0' |
||||
|
|
||||
|
# Flutter.framework does not contain a i386 slice. |
||||
|
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } |
||||
|
s.swift_version = '5.0' |
||||
|
end |
||||
@ -0,0 +1,6 @@ |
|||||
|
// This is a placeholder file that exports nothing. |
||||
|
// The actual implementation is in the app's services folder. |
||||
|
// This file exists just to satisfy the Flutter plugin structure requirements. |
||||
|
|
||||
|
// Empty library to satisfy plugin structure |
||||
|
library azure_speech_recognition; |
||||
@ -0,0 +1,23 @@ |
|||||
|
name: azure_speech_recognition |
||||
|
description: Azure Speech Recognition and Text-to-Speech services Flutter plugin |
||||
|
version: 0.1.0 |
||||
|
homepage: https://github.com/yourusername/azure_speech_recognition |
||||
|
|
||||
|
environment: |
||||
|
sdk: '>=2.12.0 <3.0.0' |
||||
|
flutter: ">=2.0.0" |
||||
|
|
||||
|
dependencies: |
||||
|
flutter: |
||||
|
sdk: flutter |
||||
|
|
||||
|
dev_dependencies: |
||||
|
flutter_test: |
||||
|
sdk: flutter |
||||
|
flutter_lints: ^1.0.0 |
||||
|
|
||||
|
flutter: |
||||
|
plugin: |
||||
|
platforms: |
||||
|
ios: |
||||
|
pluginClass: AzureSpeechRecognitionPlugin |
||||
@ -1,13 +1,743 @@ |
|||||
import Flutter |
import Flutter |
||||
import UIKit |
import UIKit |
||||
|
import AVFoundation |
||||
|
|
||||
@main |
@main |
||||
@objc class AppDelegate: FlutterAppDelegate { |
@objc class AppDelegate: FlutterAppDelegate { |
||||
|
private var classicBluetoothHelper: ClassicBluetoothHelper? |
||||
|
private var mediaButtonHelper: Any? |
||||
|
// 添加Azure语音服务辅助类 |
||||
|
private var azureAsrHelper: Any? |
||||
|
private var azureTtsHelper: Any? |
||||
|
// 添加火山AI服务 |
||||
|
private var volcanoAIService: VolcanoAIService? |
||||
|
// 语音交互服务 |
||||
|
private var voiceInteractionService: VoiceInteractionService? |
||||
|
|
||||
|
private var audioSessionManager: AudioSessionManager? |
||||
|
|
||||
override func application( |
override func application( |
||||
_ application: UIApplication, |
_ application: UIApplication, |
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? |
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? |
||||
) -> Bool { |
) -> Bool { |
||||
|
|
||||
|
// 获取flutter控制器 |
||||
|
guard let controller = window?.rootViewController as? FlutterViewController else { |
||||
|
NSLog("AppDelegate: Flutter控制器不可用") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 初始化音频会话管理器 |
||||
|
audioSessionManager = AudioSessionManager.shared |
||||
|
|
||||
|
|
||||
|
// 统一注册所有方法通道 |
||||
|
registerMethodChannel() |
||||
|
|
||||
GeneratedPluginRegistrant.register(with: self) |
GeneratedPluginRegistrant.register(with: self) |
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions) |
return super.application(application, didFinishLaunchingWithOptions: launchOptions) |
||||
} |
} |
||||
|
|
||||
|
private func registerMethodChannel() { |
||||
|
let controller = window?.rootViewController as? FlutterViewController |
||||
|
guard let controller = controller else { |
||||
|
NSLog("AppDelegate: FlutterViewController 不可用") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 注册经典蓝牙方法通道 |
||||
|
registerClassicBluetoothChannels(controller: controller) |
||||
|
|
||||
|
// 仅在iOS 13.0及以上版本注册媒体按钮通道和Azure语音服务通道 |
||||
|
if #available(iOS 13.0, *) { |
||||
|
// 注册媒体按钮方法通道 |
||||
|
registerMediaButtonChannels(controller: controller) |
||||
|
|
||||
|
// 注册Azure ASR方法通道 |
||||
|
registerAzureAsrChannels(controller: controller) |
||||
|
|
||||
|
// 注册Azure TTS方法通道 |
||||
|
registerAzureTtsChannels(controller: controller) |
||||
|
|
||||
|
// 注册火山AI服务方法通道 |
||||
|
registerVolcanoAIServiceChannels(controller: controller) |
||||
|
|
||||
|
// 注册语音交互服务方法通道 |
||||
|
registerVoiceInteractionChannels(controller: controller) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 经典蓝牙通道 |
||||
|
|
||||
|
private func registerClassicBluetoothChannels(controller: FlutterViewController) { |
||||
|
// 初始化蓝牙辅助类 |
||||
|
if classicBluetoothHelper == nil { |
||||
|
classicBluetoothHelper = ClassicBluetoothHelper() |
||||
|
} |
||||
|
|
||||
|
guard let classicBluetoothHelper = self.classicBluetoothHelper else { |
||||
|
NSLog("AppDelegate: 经典蓝牙辅助实例不可用") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 注册经典蓝牙方法通道 |
||||
|
let classicBluetoothChannel = FlutterMethodChannel( |
||||
|
name: "com.deep_voice.classic_bluetooth", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
classicBluetoothChannel.setMethodCallHandler { [weak self, weak classicBluetoothHelper] (call, result) in |
||||
|
guard let _ = self, let classicBluetoothHelper = classicBluetoothHelper else { |
||||
|
result(FlutterError(code: "UNAVAILABLE", message: "经典蓝牙辅助实例不可用", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
switch call.method { |
||||
|
case "isBluetoothEnabled": |
||||
|
result(classicBluetoothHelper.isBluetoothEnabled()) |
||||
|
|
||||
|
case "getConnectedHeadsetDevices": |
||||
|
// 获取已连接的耳机设备 |
||||
|
classicBluetoothHelper.getConnectedHeadsetDevices { devices, error in |
||||
|
if let error = error { |
||||
|
result(FlutterError(code: "GET_DEVICES_FAILED", message: error, details: nil)) |
||||
|
} else { |
||||
|
result(devices) |
||||
|
} |
||||
|
} |
||||
|
case "initialize": |
||||
|
classicBluetoothHelper.initialize() |
||||
|
result(true) |
||||
|
|
||||
|
default: |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 注册蓝牙流通道 |
||||
|
let classicBluetoothStreamChannel = FlutterEventChannel( |
||||
|
name: "com.deep_voice.classic_bluetooth_events", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
// 创建蓝牙流处理器(作为局部变量) |
||||
|
let bluetoothStreamHandler = BluetoothStreamHandler(bluetoothHelper: classicBluetoothHelper) |
||||
|
classicBluetoothStreamChannel.setStreamHandler(bluetoothStreamHandler) |
||||
|
} |
||||
|
|
||||
|
// MARK: - 消息通道设置 |
||||
|
|
||||
|
// MARK: - 蓝牙媒体按键通道 |
||||
|
|
||||
|
@available(iOS 13.0, *) |
||||
|
private func registerMediaButtonChannels(controller: FlutterViewController) { |
||||
|
// 初始化媒体按钮辅助类 |
||||
|
if mediaButtonHelper == nil { |
||||
|
mediaButtonHelper = BluetoothMediaButtonHelper() |
||||
|
} |
||||
|
// guard let mediaButtonHelper = self.mediaButtonHelper as? BluetoothMediaButtonHelper else { |
||||
|
// NSLog("AppDelegate: 媒体按钮辅助实例不可用") |
||||
|
// return |
||||
|
// } |
||||
|
// mediaButtonHelper.startButtonListening() |
||||
|
// 注册媒体按钮方法通道 |
||||
|
let mediaButtonChannel = FlutterMethodChannel( |
||||
|
name: "com.deep_voice.bluetooth_media_button", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
mediaButtonChannel.setMethodCallHandler { [weak self] (call, result) in |
||||
|
guard let _ = self, let mediaButtonHelper = self?.mediaButtonHelper as? BluetoothMediaButtonHelper else { |
||||
|
result(FlutterError(code: "UNAVAILABLE", message: "媒体按钮辅助实例不可用", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
switch call.method { |
||||
|
case "startButtonListening": |
||||
|
let success = mediaButtonHelper.startButtonListening() |
||||
|
result(success) |
||||
|
|
||||
|
case "stopButtonListening": |
||||
|
let success = mediaButtonHelper.stopButtonListening() |
||||
|
result(success) |
||||
|
|
||||
|
default: |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 注册媒体按钮流通道 |
||||
|
let mediaButtonStreamChannel = FlutterEventChannel( |
||||
|
name: "com.deep_voice.bluetooth_media_button_events", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
// 创建媒体按钮流处理器 |
||||
|
let mediaButtonStreamHandler = MediaButtonStreamHandler(mediaButtonHelper: mediaButtonHelper as! BluetoothMediaButtonHelper) |
||||
|
mediaButtonStreamChannel.setStreamHandler(mediaButtonStreamHandler) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
// 注册Azure ASR通道 |
||||
|
@available(iOS 13.0, *) |
||||
|
private func registerAzureAsrChannels(controller: FlutterViewController) { |
||||
|
// 注册Azure ASR方法通道 |
||||
|
let azureAsrChannel = FlutterMethodChannel( |
||||
|
name: "com.deep_voice.azure_asr", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
// 初始化Azure ASR辅助类 |
||||
|
azureAsrHelper = AzureAsrHelper() |
||||
|
|
||||
|
// 设置ASR方法处理器 |
||||
|
azureAsrChannel.setMethodCallHandler { [weak self] (call, result) in |
||||
|
guard let self = self, |
||||
|
let asrHelper = self.azureAsrHelper as? AzureAsrHelper else { |
||||
|
result(FlutterError(code: "UNAVAILABLE", message: "Azure ASR辅助实例不可用", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
switch call.method { |
||||
|
case "initialize": |
||||
|
guard let args = call.arguments as? [String: Any], |
||||
|
let key = args["subscriptionKey"] as? String, |
||||
|
let region = args["region"] as? String else { |
||||
|
result(FlutterError(code: "INVALID_ARGS", message: "初始化参数无效", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let languages = args["supportedLanguages"] as? [String] |
||||
|
let success = asrHelper.initialize(speechSubscriptionKey: key, serviceRegion: region, supportedLanguages: languages) |
||||
|
result(success) |
||||
|
|
||||
|
case "recognizeOnce": |
||||
|
let success = asrHelper.recognizeOnce() |
||||
|
result(success) |
||||
|
|
||||
|
case "startContinuousRecognition": |
||||
|
let success = asrHelper.startContinuousRecognition() |
||||
|
result(success) |
||||
|
|
||||
|
case "stopContinuousRecognition": |
||||
|
let success = asrHelper.stopContinuousRecognition() |
||||
|
result(success) |
||||
|
|
||||
|
case "isContinuousRecognitionActive": |
||||
|
let isActive = asrHelper.isContinuousRecognitionActive() |
||||
|
result(isActive) |
||||
|
|
||||
|
case "dispose": |
||||
|
asrHelper.dispose() |
||||
|
result(true) |
||||
|
|
||||
|
default: |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 注册Azure ASR流通道 |
||||
|
let azureAsrStreamChannel = FlutterEventChannel( |
||||
|
name: "com.deep_voice.azure_asr_events", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
// 创建ASR流处理器 |
||||
|
let asrStreamHandler = AsrStreamHandler(azureAsrHelper: azureAsrHelper as! AzureAsrHelper) |
||||
|
azureAsrStreamChannel.setStreamHandler(asrStreamHandler) |
||||
|
} |
||||
|
|
||||
|
// 注册Azure TTS通道 |
||||
|
@available(iOS 13.0, *) |
||||
|
private func registerAzureTtsChannels(controller: FlutterViewController) { |
||||
|
// 注册Azure TTS方法通道 |
||||
|
let azureTtsChannel = FlutterMethodChannel( |
||||
|
name: "com.deep_voice.azure_tts", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
// 初始化Azure TTS辅助类 |
||||
|
azureTtsHelper = AzureTtsHelper() |
||||
|
|
||||
|
// 设置TTS方法处理器 |
||||
|
azureTtsChannel.setMethodCallHandler { [weak self] (call, result) in |
||||
|
guard let self = self, |
||||
|
let ttsHelper = self.azureTtsHelper as? AzureTtsHelper else { |
||||
|
result(FlutterError(code: "UNAVAILABLE", message: "Azure TTS辅助实例不可用", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
switch call.method { |
||||
|
case "initialize": |
||||
|
guard let args = call.arguments as? [String: Any], |
||||
|
let key = args["subscriptionKey"] as? String, |
||||
|
let region = args["region"] as? String else { |
||||
|
result(FlutterError(code: "INVALID_ARGS", message: "初始化参数无效", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let language = args["language"] as? String ?? "zh-CN" |
||||
|
let success = ttsHelper.initialize(speechSubscriptionKey: key, serviceRegion: region, language: language) |
||||
|
result(success) |
||||
|
|
||||
|
case "setVoice": |
||||
|
guard let args = call.arguments as? [String: Any], |
||||
|
let voiceName = args["voiceName"] as? String else { |
||||
|
result(FlutterError(code: "INVALID_ARGS", message: "语音名称参数无效", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let success = ttsHelper.setVoice(voiceName: voiceName) |
||||
|
result(success) |
||||
|
|
||||
|
case "setSpeechParams": |
||||
|
guard let args = call.arguments as? [String: Any] else { |
||||
|
result(FlutterError(code: "INVALID_ARGS", message: "语音参数无效", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let rate = args["rate"] as? Int ?? 0 |
||||
|
let pitch = args["pitch"] as? Int ?? 0 |
||||
|
let volume = args["volume"] as? Int ?? 100 |
||||
|
|
||||
|
let success = ttsHelper.setSpeechParams(rate: rate, pitch: pitch, volume: volume) |
||||
|
result(success) |
||||
|
|
||||
|
case "speakText": |
||||
|
guard let args = call.arguments as? [String: Any], |
||||
|
let text = args["text"] as? String else { |
||||
|
result(FlutterError(code: "INVALID_ARGS", message: "文本参数无效", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let success = ttsHelper.speakText(text: text) |
||||
|
result(success) |
||||
|
|
||||
|
case "stopSpeaking": |
||||
|
let success = ttsHelper.stopSpeaking() |
||||
|
result(success) |
||||
|
|
||||
|
case "isSpeaking": |
||||
|
let isSpeaking = ttsHelper.isSpeaking() |
||||
|
result(isSpeaking) |
||||
|
|
||||
|
case "dispose": |
||||
|
ttsHelper.dispose() |
||||
|
result(true) |
||||
|
|
||||
|
default: |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 注册Azure TTS流通道 |
||||
|
let azureTtsStreamChannel = FlutterEventChannel( |
||||
|
name: "com.deep_voice.azure_tts_events", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
// 创建TTS流处理器 |
||||
|
let ttsStreamHandler = TtsStreamHandler(azureTtsHelper: azureTtsHelper as! AzureTtsHelper) |
||||
|
azureTtsStreamChannel.setStreamHandler(ttsStreamHandler) |
||||
|
} |
||||
|
|
||||
|
// 注册火山AI服务方法通道 |
||||
|
@available(iOS 13.0, *) |
||||
|
private func registerVolcanoAIServiceChannels(controller: FlutterViewController) { |
||||
|
guard let volcanoAIService = volcanoAIService else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 注册火山AI方法通道 |
||||
|
let volcanoAIChannel = FlutterMethodChannel( |
||||
|
name: "com.deep_voice.volcano_ai", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
volcanoAIChannel.setMethodCallHandler { [weak self, weak volcanoAIService] (call, result) in |
||||
|
guard let _ = self, let volcanoAIService = volcanoAIService else { |
||||
|
result(FlutterError(code: "UNAVAILABLE", message: "火山AI服务实例不可用", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
switch call.method { |
||||
|
case "initialize": |
||||
|
let apiKey = call.arguments as? String ?? "" |
||||
|
let success = volcanoAIService.initialize(apiKey: apiKey) |
||||
|
result(success) |
||||
|
|
||||
|
case "sendMessage": |
||||
|
guard let arguments = call.arguments as? [String: Any], |
||||
|
let message = arguments["message"] as? String, |
||||
|
let systemPrompt = arguments["systemPrompt"] as? String else { |
||||
|
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if message.isEmpty { |
||||
|
result(FlutterError(code: "EMPTY_MESSAGE", message: "消息不能为空", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 异步发送消息 |
||||
|
Task { |
||||
|
do { |
||||
|
// 创建用户消息 |
||||
|
let userMessage = volcanoAIService.createUserMessage(content: message) |
||||
|
|
||||
|
// 发送消息并等待响应 |
||||
|
let response = try await volcanoAIService.sendMessage( |
||||
|
messages: [userMessage], |
||||
|
systemPrompt: systemPrompt |
||||
|
) |
||||
|
|
||||
|
// 返回响应 |
||||
|
DispatchQueue.main.async { |
||||
|
result(response) |
||||
|
} |
||||
|
} catch { |
||||
|
// 处理错误 |
||||
|
DispatchQueue.main.async { |
||||
|
result(FlutterError( |
||||
|
code: "AI_ERROR", |
||||
|
message: "AI请求失败: \(error.localizedDescription)", |
||||
|
details: nil |
||||
|
)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
default: |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 注册火山AI流通道 |
||||
|
let volcanoAIStreamChannel = FlutterEventChannel( |
||||
|
name: "com.deep_voice.volcano_ai_stream", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
// 创建火山AI流处理器(作为局部变量) |
||||
|
let volcanoAIStreamHandler = VolcanoAIStreamHandler(volcanoAIService: volcanoAIService) |
||||
|
volcanoAIStreamChannel.setStreamHandler(volcanoAIStreamHandler) |
||||
|
} |
||||
|
|
||||
|
// 注册语音交互服务方法通道 |
||||
|
@available(iOS 13.0, *) |
||||
|
private func registerVoiceInteractionChannels(controller: FlutterViewController) { |
||||
|
// 初始化语音交互服务 |
||||
|
voiceInteractionService = VoiceInteractionService.shared |
||||
|
|
||||
|
// 注册语音交互服务方法通道 |
||||
|
let voiceInteractionChannel = FlutterMethodChannel( |
||||
|
name: "com.deep_voice.voice_interaction", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
voiceInteractionChannel.setMethodCallHandler { [weak self, weak voiceInteractionService] (call, result) in |
||||
|
guard let _ = self, let voiceInteractionService = voiceInteractionService else { |
||||
|
result(FlutterError(code: "UNAVAILABLE", message: "语音交互服务实例不可用", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
switch call.method { |
||||
|
case "startService": |
||||
|
guard let args = call.arguments as? [String: Any], |
||||
|
let azureKey = args["azure_speech_key"] as? String, |
||||
|
let azureRegion = args["azure_speech_region"] as? String, |
||||
|
let volcanoKey = args["volcano_ai_api_key"] as? String else { |
||||
|
result(FlutterError(code: "INVALID_ARGS", message: "启动服务参数无效", details: nil)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let success = voiceInteractionService.start(azureKey: azureKey, azureRegion: azureRegion, volcanoKey: volcanoKey) |
||||
|
result(success) |
||||
|
|
||||
|
case "stopService": |
||||
|
voiceInteractionService.stop() |
||||
|
result(true) |
||||
|
|
||||
|
case "isServiceRunning": |
||||
|
let isRunning = voiceInteractionService.isServiceRunning() |
||||
|
result(isRunning) |
||||
|
|
||||
|
case "pauseVoiceInteraction": |
||||
|
voiceInteractionService.pauseVoiceInteraction() |
||||
|
result(true) |
||||
|
|
||||
|
default: |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 注册语音交互流通道 |
||||
|
let voiceInteractionStreamChannel = FlutterEventChannel( |
||||
|
name: "com.deep_voice.voice_interaction_events", |
||||
|
binaryMessenger: controller.binaryMessenger |
||||
|
) |
||||
|
|
||||
|
// 创建语音交互流处理器(作为局部变量) |
||||
|
let voiceInteractionStreamHandler = VoiceInteractionStreamHandler(voiceInteractionService: voiceInteractionService!) |
||||
|
voiceInteractionStreamChannel.setStreamHandler(voiceInteractionStreamHandler) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 蓝牙事件流处理器 |
||||
|
class BluetoothStreamHandler: NSObject, FlutterStreamHandler, BluetoothEventHandler { |
||||
|
private let bluetoothHelper: ClassicBluetoothHelper |
||||
|
private var eventSink: FlutterEventSink? |
||||
|
|
||||
|
init(bluetoothHelper: ClassicBluetoothHelper) { |
||||
|
self.bluetoothHelper = bluetoothHelper |
||||
|
super.init() |
||||
|
|
||||
|
// 设置事件处理器 |
||||
|
bluetoothHelper.setEventHandler(self) |
||||
|
} |
||||
|
|
||||
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
||||
|
// NSLog("BluetoothStreamHandler: 开始监听蓝牙事件") |
||||
|
self.eventSink = events |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
||||
|
// NSLog("BluetoothStreamHandler: 取消监听蓝牙事件") |
||||
|
eventSink = nil |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// 实现BluetoothEventHandler协议 |
||||
|
func sendEvent(_ event: [String: Any]) { |
||||
|
NSLog("BluetoothStreamHandler: 接收到蓝牙事件: \(event)") |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.eventSink?(event) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 媒体按钮事件流处理器 |
||||
|
@available(iOS 13.0, *) |
||||
|
class MediaButtonStreamHandler: NSObject, FlutterStreamHandler, MediaButtonEventHandler { |
||||
|
private let mediaButtonHelper: BluetoothMediaButtonHelper |
||||
|
private var eventSink: FlutterEventSink? |
||||
|
|
||||
|
init(mediaButtonHelper: BluetoothMediaButtonHelper) { |
||||
|
self.mediaButtonHelper = mediaButtonHelper |
||||
|
super.init() |
||||
|
|
||||
|
// 设置事件处理器 |
||||
|
mediaButtonHelper.setEventHandler(self) |
||||
|
} |
||||
|
|
||||
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
||||
|
NSLog("MediaButtonStreamHandler: 开始监听媒体按钮事件") |
||||
|
self.eventSink = events |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
||||
|
NSLog("MediaButtonStreamHandler: 取消监听媒体按钮事件") |
||||
|
eventSink = nil |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// 实现MediaButtonEventHandler协议 |
||||
|
func sendEvent(_ event: [String: Any]) { |
||||
|
NSLog("MediaButtonStreamHandler: 收到按钮事件 \(event)") |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.eventSink?(event) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ASR事件处理器协议 |
||||
|
@available(iOS 13.0, *) |
||||
|
protocol AsrEventHandler: AnyObject { |
||||
|
func sendEvent(_ event: [String: Any]) |
||||
|
} |
||||
|
|
||||
|
// ASR事件流处理器 |
||||
|
@available(iOS 13.0, *) |
||||
|
class AsrStreamHandler: NSObject, FlutterStreamHandler, AsrEventHandler { |
||||
|
private let azureAsrHelper: AzureAsrHelper |
||||
|
private var eventSink: FlutterEventSink? |
||||
|
|
||||
|
init(azureAsrHelper: AzureAsrHelper) { |
||||
|
self.azureAsrHelper = azureAsrHelper |
||||
|
super.init() |
||||
|
|
||||
|
// 设置事件处理器 |
||||
|
azureAsrHelper.setEventHandler { [weak self] event in |
||||
|
self?.sendEvent(event) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
||||
|
NSLog("AsrStreamHandler: 开始监听ASR事件") |
||||
|
self.eventSink = events |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
||||
|
NSLog("AsrStreamHandler: 取消监听ASR事件") |
||||
|
eventSink = nil |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// 实现AsrEventHandler协议 |
||||
|
func sendEvent(_ event: [String: Any]) { |
||||
|
// NSLog("AsrStreamHandler: 收到事件 \(event)") |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.eventSink?(event) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// TTS事件处理器协议 |
||||
|
@available(iOS 13.0, *) |
||||
|
protocol TtsEventHandler: AnyObject { |
||||
|
func sendEvent(_ event: [String: Any]) |
||||
|
} |
||||
|
|
||||
|
// TTS事件流处理器 |
||||
|
@available(iOS 13.0, *) |
||||
|
class TtsStreamHandler: NSObject, FlutterStreamHandler, TtsEventHandler { |
||||
|
private let azureTtsHelper: AzureTtsHelper |
||||
|
private var eventSink: FlutterEventSink? |
||||
|
|
||||
|
init(azureTtsHelper: AzureTtsHelper) { |
||||
|
self.azureTtsHelper = azureTtsHelper |
||||
|
super.init() |
||||
|
|
||||
|
// 设置事件处理器 |
||||
|
azureTtsHelper.setEventHandler { [weak self] event in |
||||
|
self?.sendEvent(event) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
||||
|
NSLog("TtsStreamHandler: 开始监听TTS事件") |
||||
|
self.eventSink = events |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
||||
|
NSLog("TtsStreamHandler: 取消监听TTS事件") |
||||
|
eventSink = nil |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// 实现TtsEventHandler协议 |
||||
|
func sendEvent(_ event: [String: Any]) { |
||||
|
NSLog("TtsStreamHandler: 收到事件 \(event)") |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.eventSink?(event) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 火山AI事件流处理器 |
||||
|
@available(iOS 13.0, *) |
||||
|
class VolcanoAIStreamHandler: NSObject, FlutterStreamHandler { |
||||
|
private let volcanoAIService: VolcanoAIService |
||||
|
private var eventSink: FlutterEventSink? |
||||
|
|
||||
|
init(volcanoAIService: VolcanoAIService) { |
||||
|
self.volcanoAIService = volcanoAIService |
||||
|
super.init() |
||||
|
} |
||||
|
|
||||
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
||||
|
self.eventSink = events |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
||||
|
eventSink = nil |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// 处理来自Flutter的流式请求 |
||||
|
func handleStreamRequest(messages: [[String: Any]], systemPrompt: String) { |
||||
|
volcanoAIService.sendMessageStream(messages: messages, systemPrompt: systemPrompt, streamCallback: VolcanoAIService.StreamCallback( |
||||
|
onToken: { [weak self] token in |
||||
|
guard let self = self, let eventSink = self.eventSink else { return } |
||||
|
|
||||
|
let event: [String: Any] = [ |
||||
|
"type": "token", |
||||
|
"token": token |
||||
|
] |
||||
|
|
||||
|
DispatchQueue.main.async { |
||||
|
eventSink(event) |
||||
|
} |
||||
|
}, |
||||
|
onComplete: { [weak self] in |
||||
|
guard let self = self, let eventSink = self.eventSink else { return } |
||||
|
|
||||
|
let event: [String: Any] = [ |
||||
|
"type": "complete" |
||||
|
] |
||||
|
|
||||
|
DispatchQueue.main.async { |
||||
|
eventSink(event) |
||||
|
} |
||||
|
}, |
||||
|
onError: { [weak self] error in |
||||
|
guard let self = self, let eventSink = self.eventSink else { return } |
||||
|
|
||||
|
let event: [String: Any] = [ |
||||
|
"type": "error", |
||||
|
"error": error.localizedDescription |
||||
|
] |
||||
|
|
||||
|
DispatchQueue.main.async { |
||||
|
eventSink(event) |
||||
|
} |
||||
|
} |
||||
|
)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 语音交互事件处理器协议 |
||||
|
@available(iOS 13.0, *) |
||||
|
protocol VoiceInteractionEventHandler: AnyObject { |
||||
|
func sendEvent(_ event: [String: Any]) |
||||
|
} |
||||
|
|
||||
|
// 语音交互流处理器 |
||||
|
@available(iOS 13.0, *) |
||||
|
class VoiceInteractionStreamHandler: NSObject, FlutterStreamHandler, VoiceInteractionEventHandler { |
||||
|
private let voiceInteractionService: VoiceInteractionService |
||||
|
private var eventSink: FlutterEventSink? |
||||
|
|
||||
|
init(voiceInteractionService: VoiceInteractionService) { |
||||
|
self.voiceInteractionService = voiceInteractionService |
||||
|
super.init() |
||||
|
|
||||
|
// 设置事件处理器 |
||||
|
voiceInteractionService.setEventHandler(self) |
||||
|
} |
||||
|
|
||||
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
||||
|
NSLog("VoiceInteractionStreamHandler: 开始监听语音交互事件") |
||||
|
self.eventSink = events |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
||||
|
NSLog("VoiceInteractionStreamHandler: 取消监听语音交互事件") |
||||
|
eventSink = nil |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// 实现VoiceInteractionEventHandler协议 |
||||
|
func sendEvent(_ event: [String: Any]) { |
||||
|
NSLog("VoiceInteractionStreamHandler: 收到事件 \(event)") |
||||
|
DispatchQueue.main.async { [weak self] in |
||||
|
self?.eventSink?(event) |
||||
|
} |
||||
|
} |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,585 @@ |
|||||
|
import Foundation |
||||
|
import AVFoundation |
||||
|
import UIKit |
||||
|
|
||||
|
/// 统一音频会话管理类 |
||||
|
/// 用于统一管理所有组件的音频会话配置 |
||||
|
@objc class AudioSessionManager: NSObject { |
||||
|
// MARK: - 单例实现 |
||||
|
|
||||
|
@objc static let shared = AudioSessionManager() |
||||
|
|
||||
|
// 音频会话 |
||||
|
private let audioSession = AVAudioSession.sharedInstance() |
||||
|
|
||||
|
// 音频会话状态 |
||||
|
private var isConfigured = false |
||||
|
private var isActive = false |
||||
|
|
||||
|
// 当前配置类型 |
||||
|
private var currentCategory: AVAudioSession.Category? |
||||
|
private var currentMode: AVAudioSession.Mode? |
||||
|
private var currentOptions: AVAudioSession.CategoryOptions? |
||||
|
|
||||
|
// 中断监听器 |
||||
|
private var interruptionObserver: NSObjectProtocol? |
||||
|
|
||||
|
// 路由变化监听器 |
||||
|
private var routeChangeObserver: NSObjectProtocol? |
||||
|
|
||||
|
// 私有初始化方法,确保只能通过shared访问 |
||||
|
private override init() { |
||||
|
super.init() |
||||
|
NSLog("[AudioSessionManager] 音频会话管理器已初始化") |
||||
|
setupNotifications() |
||||
|
|
||||
|
} |
||||
|
|
||||
|
deinit { |
||||
|
removeNotifications() |
||||
|
} |
||||
|
|
||||
|
|
||||
|
// MARK: - 通知设置 |
||||
|
|
||||
|
private func setupNotifications() { |
||||
|
// 监听中断事件 |
||||
|
interruptionObserver = NotificationCenter.default.addObserver( |
||||
|
forName: AVAudioSession.interruptionNotification, |
||||
|
object: nil, |
||||
|
queue: .main) { [weak self] notification in |
||||
|
self?.handleInterruption(notification) |
||||
|
} |
||||
|
|
||||
|
// 监听路由变化事件 |
||||
|
routeChangeObserver = NotificationCenter.default.addObserver( |
||||
|
forName: AVAudioSession.routeChangeNotification, |
||||
|
object: nil, |
||||
|
queue: .main) { [weak self] notification in |
||||
|
self?.handleRouteChange(notification) |
||||
|
} |
||||
|
|
||||
|
// 监听应用进入后台/前台 |
||||
|
NotificationCenter.default.addObserver( |
||||
|
self, |
||||
|
selector: #selector(handleAppDidEnterBackground), |
||||
|
name: UIApplication.didEnterBackgroundNotification, |
||||
|
object: nil) |
||||
|
|
||||
|
NotificationCenter.default.addObserver( |
||||
|
self, |
||||
|
selector: #selector(handleAppWillEnterForeground), |
||||
|
name: UIApplication.willEnterForegroundNotification, |
||||
|
object: nil) |
||||
|
} |
||||
|
|
||||
|
private func removeNotifications() { |
||||
|
if let interruptionObserver = interruptionObserver { |
||||
|
NotificationCenter.default.removeObserver(interruptionObserver) |
||||
|
} |
||||
|
|
||||
|
if let routeChangeObserver = routeChangeObserver { |
||||
|
NotificationCenter.default.removeObserver(routeChangeObserver) |
||||
|
} |
||||
|
|
||||
|
NotificationCenter.default.removeObserver(self) |
||||
|
} |
||||
|
|
||||
|
// MARK: - 通知处理 |
||||
|
|
||||
|
private func handleInterruption(_ notification: Notification) { |
||||
|
guard let userInfo = notification.userInfo, |
||||
|
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, |
||||
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
switch type { |
||||
|
case .began: |
||||
|
// 中断开始 |
||||
|
NSLog("[AudioSessionManager] 音频会话被中断") |
||||
|
isActive = false |
||||
|
|
||||
|
case .ended: |
||||
|
// 中断结束,尝试恢复 |
||||
|
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { |
||||
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) |
||||
|
if options.contains(.shouldResume) { |
||||
|
NSLog("[AudioSessionManager] 尝试恢复被中断的音频会话") |
||||
|
try? activateSession() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@unknown default: |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func handleRouteChange(_ notification: Notification) { |
||||
|
guard let userInfo = notification.userInfo, |
||||
|
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, |
||||
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
switch reason { |
||||
|
case .newDeviceAvailable: |
||||
|
// 新设备连接 |
||||
|
let currentRoute = audioSession.currentRoute |
||||
|
for output in currentRoute.outputs where output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP { |
||||
|
NSLog("[AudioSessionManager] 检测到蓝牙音频设备连接: \(output.portName)") |
||||
|
// 蓝牙设备连接,可能需要更新配置 |
||||
|
if isActive && currentOptions?.contains(.allowBluetooth) == true { |
||||
|
try? forceReconfigure() |
||||
|
} |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
case .oldDeviceUnavailable: |
||||
|
// 设备断开连接 |
||||
|
if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { |
||||
|
for output in previousRoute.outputs where output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP { |
||||
|
NSLog("[AudioSessionManager] 蓝牙音频设备断开连接: \(output.portName)") |
||||
|
// 蓝牙设备断开,重新配置到内置扬声器 |
||||
|
if isActive { |
||||
|
try? forceReconfigure() |
||||
|
} |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
default: |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@objc private func handleAppDidEnterBackground() { |
||||
|
NSLog("[AudioSessionManager] 应用进入后台") |
||||
|
// 应用进入后台时可能需要调整音频会话 |
||||
|
|
||||
|
} |
||||
|
|
||||
|
@objc private func handleAppWillEnterForeground() { |
||||
|
NSLog("[AudioSessionManager] 应用即将进入前台") |
||||
|
// 应用返回前台,检查并恢复音频会话 |
||||
|
if isConfigured && !isActive { |
||||
|
try? activateSession() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 音频会话配置 |
||||
|
|
||||
|
/// 统一配置方法 - 所有音频配置都通过此方法进行 |
||||
|
@objc func configureSession(category: AVAudioSession.Category, |
||||
|
mode: AVAudioSession.Mode, |
||||
|
options: AVAudioSession.CategoryOptions, |
||||
|
force: Bool = false) throws { |
||||
|
|
||||
|
NSLog("[AudioSessionManager] 配置音频会话: 类别=\(category), 模式=\(mode), 选项=\(options), 强制=\(force)") |
||||
|
|
||||
|
// 如果已配置且不强制重新配置,则直接返回 |
||||
|
if isConfigured && !force && |
||||
|
currentCategory == category && |
||||
|
currentMode == mode && |
||||
|
currentOptions == options { |
||||
|
NSLog("[AudioSessionManager] 音频会话已配置,跳过配置") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
// NSLog("[AudioSessionManager] 配置音频会话: 类别=\(category), 模式=\(mode)") |
||||
|
|
||||
|
// 暂时停用当前会话(如果正在活动) |
||||
|
if isActive { |
||||
|
try audioSession.setActive(false, options: .notifyOthersOnDeactivation) |
||||
|
} |
||||
|
|
||||
|
// 设置新的分类、模式和选项 |
||||
|
try audioSession.setCategory(category, mode: mode, options: options) |
||||
|
|
||||
|
// 设置采样率和缓冲区大小(对于语音识别优化) |
||||
|
// if mode == .spokenAudio { |
||||
|
// try audioSession.setPreferredSampleRate(16000) |
||||
|
// try audioSession.setPreferredIOBufferDuration(0.01) // 10ms |
||||
|
// } |
||||
|
|
||||
|
// 重新激活会话 |
||||
|
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) |
||||
|
isActive = true |
||||
|
|
||||
|
// 记录当前配置 |
||||
|
currentCategory = category |
||||
|
currentMode = mode |
||||
|
currentOptions = options |
||||
|
isConfigured = true |
||||
|
|
||||
|
// 检查并设置首选的输入设备(如果需要) |
||||
|
if category == .playAndRecord || category == .record { |
||||
|
try configurePreferredInput() |
||||
|
} |
||||
|
return |
||||
|
} catch { |
||||
|
NSLog("[AudioSessionManager] 配置音频会话失败: \(error.localizedDescription)") |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 配置首选输入设备 |
||||
|
private func configurePreferredInput() throws { |
||||
|
// 获取可用输入设备 |
||||
|
guard let availableInputs = audioSession.availableInputs else { |
||||
|
NSLog("[AudioSessionManager] 无法获取可用输入设备") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 首选顺序:有线耳机 > 蓝牙耳机 > 内置麦克风 |
||||
|
var preferredInput: AVAudioSessionPortDescription? = nil |
||||
|
|
||||
|
// 先尝试找到有线耳机 |
||||
|
if let wiredHeadset = availableInputs.first(where: { $0.portType == .headsetMic }) { |
||||
|
preferredInput = wiredHeadset |
||||
|
NSLog("[AudioSessionManager] 设置首选输入设备: 有线耳机") |
||||
|
} |
||||
|
// 如果没有有线耳机,尝试使用蓝牙 |
||||
|
else if let bluetooth = availableInputs.first(where: { $0.portType == .bluetoothHFP }) { |
||||
|
preferredInput = bluetooth |
||||
|
NSLog("[AudioSessionManager] 设置首选输入设备: 蓝牙耳机") |
||||
|
} |
||||
|
// 如果没有外部设备,使用内置麦克风 |
||||
|
else if let builtIn = availableInputs.first(where: { $0.portType == .builtInMic }) { |
||||
|
preferredInput = builtIn |
||||
|
NSLog("[AudioSessionManager] 设置首选输入设备: 内置麦克风") |
||||
|
} |
||||
|
|
||||
|
// 设置首选输入设备 |
||||
|
if let input = preferredInput { |
||||
|
try audioSession.setPreferredInput(input) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
|
||||
|
/// 激活音频会话(如果已配置) |
||||
|
@objc func activateSession() throws { |
||||
|
if !isConfigured { |
||||
|
NSLog("[AudioSessionManager] 警告: 尝试激活未配置的音频会话,应用默认配置") |
||||
|
// 未配置,应用默认配置 |
||||
|
// try configureSession( |
||||
|
// category: .playAndRecord, |
||||
|
// mode: .spokenAudio, |
||||
|
// options: [.allowBluetooth, .allowBluetoothA2DP, .duckOthers, .defaultToSpeaker], |
||||
|
// force: true |
||||
|
// ) |
||||
|
|
||||
|
try configureSession( |
||||
|
category: .playAndRecord, // 允许同时录音和播放 |
||||
|
mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
||||
|
options: [ |
||||
|
.allowBluetooth, // 允许蓝牙设备 |
||||
|
.defaultToSpeaker, // 默认使用扬声器 |
||||
|
.mixWithOthers // 允许与其他应用混音 |
||||
|
], |
||||
|
force: false // 是否强制重新配置 |
||||
|
) |
||||
|
|
||||
|
|
||||
|
} else if !isActive { |
||||
|
NSLog("[AudioSessionManager] 激活已配置的音频会话") |
||||
|
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) |
||||
|
isActive = true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停用音频会话(保留配置) |
||||
|
@objc func deactivateSession() throws { |
||||
|
if isActive { |
||||
|
NSLog("[AudioSessionManager] 停用音频会话") |
||||
|
try audioSession.setActive(false, options: .notifyOthersOnDeactivation) |
||||
|
isActive = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 重新配置当前会话 |
||||
|
@objc func forceReconfigure() throws { |
||||
|
NSLog("[AudioSessionManager] 强制重新配置音频会话") |
||||
|
// try configureSession( |
||||
|
// category: currentCategory ?? .playAndRecord, |
||||
|
// mode: currentMode ?? .default, |
||||
|
// options: currentOptions ?? [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker], |
||||
|
// force: true |
||||
|
// ) |
||||
|
|
||||
|
try configureSession( |
||||
|
category: .playAndRecord, // 允许同时录音和播放 |
||||
|
mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
||||
|
options: [ |
||||
|
.allowBluetooth, // 允许蓝牙设备 |
||||
|
.defaultToSpeaker, // 默认使用扬声器 |
||||
|
.mixWithOthers // 允许与其他应用混音 |
||||
|
], |
||||
|
force: false // 是否强制重新配置 |
||||
|
) |
||||
|
|
||||
|
} |
||||
|
|
||||
|
// MARK: - 状态检查 |
||||
|
|
||||
|
/// 检查麦克风权限 |
||||
|
@objc func checkMicrophonePermission(completion: @escaping (Bool) -> Void) { |
||||
|
switch AVAudioSession.sharedInstance().recordPermission { |
||||
|
case .granted: |
||||
|
completion(true) |
||||
|
case .denied: |
||||
|
NSLog("[AudioSessionManager] 麦克风权限被拒绝") |
||||
|
completion(false) |
||||
|
case .undetermined: |
||||
|
NSLog("[AudioSessionManager] 请求麦克风权限") |
||||
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in |
||||
|
DispatchQueue.main.async { |
||||
|
completion(granted) |
||||
|
} |
||||
|
} |
||||
|
@unknown default: |
||||
|
completion(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 获取当前音频会话的配置信息 |
||||
|
@objc func getCurrentConfiguration() -> [String: Any] { |
||||
|
return [ |
||||
|
"isConfigured": isConfigured, |
||||
|
"isActive": isActive, |
||||
|
"category": String(describing: currentCategory), |
||||
|
"mode": String(describing: currentMode), |
||||
|
"bluetoothEnabled": currentOptions?.contains(.allowBluetooth) == true, |
||||
|
"currentInputs": audioSession.currentRoute.inputs.map { [$0.portType.rawValue: $0.portName] }, |
||||
|
"currentOutputs": audioSession.currentRoute.outputs.map { [$0.portType.rawValue: $0.portName] } |
||||
|
] |
||||
|
} |
||||
|
|
||||
|
/// 检查当前是否有蓝牙耳机连接 |
||||
|
@objc func hasBluetoothHeadsetConnected() -> Bool { |
||||
|
let outputs = audioSession.currentRoute.outputs |
||||
|
return outputs.contains { $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP || $0.portType == .bluetoothLE } |
||||
|
} |
||||
|
|
||||
|
/// 检查蓝牙是否启用 |
||||
|
@objc func isBluetoothEnabled() -> Bool { |
||||
|
// 在iOS中无法直接检查蓝牙是否启用,我们可以通过检查音频会话配置来推断 |
||||
|
|
||||
|
// 检查音频会话是否配置了允许蓝牙 |
||||
|
if let options = currentOptions { |
||||
|
return options.contains(.allowBluetooth) || options.contains(.allowBluetoothA2DP) |
||||
|
} |
||||
|
|
||||
|
// 如果尚未配置音频会话,尝试检查当前路由中是否有蓝牙设备 |
||||
|
let outputs = audioSession.currentRoute.outputs |
||||
|
let hasBluetooth = outputs.contains { |
||||
|
$0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP || $0.portType == .bluetoothLE |
||||
|
} |
||||
|
|
||||
|
return hasBluetooth |
||||
|
} |
||||
|
|
||||
|
/// 获取当前音频路由 |
||||
|
@objc func getCurrentAudioRoute() -> String { |
||||
|
let outputs = audioSession.currentRoute.outputs |
||||
|
if let output = outputs.first { |
||||
|
return "\(output.portType.rawValue) (\(output.portName))" |
||||
|
} |
||||
|
return "unknown" |
||||
|
} |
||||
|
|
||||
|
/// 获取当前输入设备 |
||||
|
@objc func getCurrentInputDevice() -> String { |
||||
|
let inputs = audioSession.currentRoute.inputs |
||||
|
if let input = inputs.first { |
||||
|
return "\(input.portType.rawValue) (\(input.portName))" |
||||
|
} |
||||
|
return "unknown" |
||||
|
} |
||||
|
|
||||
|
// MARK: - 路由管理和监听 |
||||
|
|
||||
|
/// 获取当前路由信息 |
||||
|
@objc func getCurrentRoute() -> AVAudioSessionRouteDescription { |
||||
|
return audioSession.currentRoute |
||||
|
} |
||||
|
|
||||
|
/// 添加路由变更监听器 |
||||
|
/// - Parameters: |
||||
|
/// - observer: 监听对象 |
||||
|
/// - selector: 监听方法选择器 |
||||
|
@objc func addRouteChangeListener(_ observer: Any, selector: Selector) { |
||||
|
NotificationCenter.default.addObserver( |
||||
|
observer, |
||||
|
selector: selector, |
||||
|
name: AVAudioSession.routeChangeNotification, |
||||
|
object: nil |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/// 移除路由变更监听器 |
||||
|
/// - Parameter observer: 要移除的监听对象 |
||||
|
@objc func removeRouteChangeListener(_ observer: Any) { |
||||
|
NotificationCenter.default.removeObserver( |
||||
|
observer, |
||||
|
name: AVAudioSession.routeChangeNotification, |
||||
|
object: nil |
||||
|
) |
||||
|
} |
||||
|
/// 获取设备类型描述 |
||||
|
private func getDeviceType(portType: AVAudioSession.Port) -> String { |
||||
|
switch portType { |
||||
|
case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE: |
||||
|
return "bluetooth" |
||||
|
case .headphones, .headsetMic: |
||||
|
return "wired" |
||||
|
case .airPlay: |
||||
|
return "airplay" |
||||
|
default: |
||||
|
return "unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
// /// 为Azure语音识别专门配置的音频会话 |
||||
|
// /// 此函数优化Azure语音识别的音频处理配置,提供更强的回音消除能力 |
||||
|
// /// - Parameters: |
||||
|
// /// - force: 是否强制重新配置 |
||||
|
// /// - configureAdditionalSettings: 配置完成后进行额外的音频设置 |
||||
|
// /// - Returns: 配置是否成功 |
||||
|
// @objc func configureForAzureSpeechRecognition(force: Bool = false, configureAdditionalSettings: Bool = true) -> Bool { |
||||
|
// NSLog("[AudioSessionManager] 配置Azure ASR语音识别的音频会话") |
||||
|
// do { |
||||
|
// // 配置音频会话以优化语音识别 |
||||
|
// try configureSession( |
||||
|
// category: .playAndRecord, // 允许同时录音和播放 |
||||
|
// mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
||||
|
// options: [ |
||||
|
// .allowBluetooth, // 允许蓝牙设备 |
||||
|
// .defaultToSpeaker, // 默认使用扬声器 |
||||
|
// .mixWithOthers // 允许与其他应用混音 |
||||
|
// ], |
||||
|
// force: force // 是否强制重新配置 |
||||
|
// ) |
||||
|
|
||||
|
// // 额外的优化配置 |
||||
|
// if configureAdditionalSettings { |
||||
|
// // 获取当前是否使用耳机 |
||||
|
// let currentRoute = audioSession.currentRoute |
||||
|
// let hasHeadphones = currentRoute.outputs.contains { |
||||
|
// $0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP |
||||
|
// } |
||||
|
|
||||
|
// // 设置采样率为16kHz(Azure Speech API推荐) |
||||
|
// try audioSession.setPreferredSampleRate(16000.0) |
||||
|
|
||||
|
// // 设置较小的缓冲区大小以减少延迟 |
||||
|
// try audioSession.setPreferredIOBufferDuration(0.01) |
||||
|
|
||||
|
// // 根据是否有耳机连接调整输入增益 |
||||
|
// if !hasHeadphones { |
||||
|
// // 无耳机时降低输入增益以减少回音 |
||||
|
// try audioSession.setInputGain(0.8) |
||||
|
// NSLog("[AudioSessionManager] 启用扬声器回音消除优化") |
||||
|
// } else { |
||||
|
// // 使用耳机时可以使用较高增益 |
||||
|
// try audioSession.setInputGain(1.0) |
||||
|
// NSLog("[AudioSessionManager] 检测到耳机连接,应用耳机模式") |
||||
|
// } |
||||
|
// } |
||||
|
|
||||
|
// NSLog("[AudioSessionManager] 已成功配置Azure语音识别的音频会话") |
||||
|
// return true |
||||
|
// } catch { |
||||
|
// NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话失败: \(error.localizedDescription)") |
||||
|
// return false |
||||
|
// } |
||||
|
// } |
||||
|
|
||||
|
|
||||
|
@objc func configureForVoiceInteraction(force: Bool = false, configureAdditionalSettings: Bool = true) -> Bool { |
||||
|
NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话") |
||||
|
do { |
||||
|
// 配置音频会话以优化语音识别 |
||||
|
try configureSession( |
||||
|
category: .playAndRecord, // 允许同时录音和播放 |
||||
|
mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
||||
|
options: [ |
||||
|
.allowBluetooth, // 允许蓝牙设备 |
||||
|
.defaultToSpeaker, // 默认使用扬声器 |
||||
|
.mixWithOthers // 允许与其他应用混音 |
||||
|
], |
||||
|
force: force // 是否强制重新配置 |
||||
|
) |
||||
|
|
||||
|
// 额外的优化配置 |
||||
|
if configureAdditionalSettings { |
||||
|
// 获取当前是否使用耳机 |
||||
|
let currentRoute = audioSession.currentRoute |
||||
|
let hasHeadphones = currentRoute.outputs.contains { |
||||
|
$0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP |
||||
|
} |
||||
|
|
||||
|
// 设置采样率为16kHz(Azure Speech API推荐) |
||||
|
try audioSession.setPreferredSampleRate(16000.0) |
||||
|
|
||||
|
// 设置较小的缓冲区大小以减少延迟 |
||||
|
try audioSession.setPreferredIOBufferDuration(0.01) |
||||
|
|
||||
|
// 根据是否有耳机连接调整输入增益 |
||||
|
if !hasHeadphones { |
||||
|
// 无耳机时降低输入增益以减少回音 |
||||
|
try audioSession.setInputGain(0.8) |
||||
|
NSLog("[AudioSessionManager] 启用扬声器回音消除优化") |
||||
|
} else { |
||||
|
// 使用耳机时可以使用较高增益 |
||||
|
try audioSession.setInputGain(1.0) |
||||
|
NSLog("[AudioSessionManager] 检测到耳机连接,应用耳机模式") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
NSLog("[AudioSessionManager] 已成功配置Azure语音识别的音频会话") |
||||
|
return true |
||||
|
} catch { |
||||
|
NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话失败: \(error.localizedDescription)") |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
/// 配置用于语音交互(同时支持ASR和TTS)的音频会话 |
||||
|
/// 此函数综合优化语音识别和语音合成,适用于需要双向交互的场景 |
||||
|
/// - Returns: 配置是否成功 |
||||
|
// @objc func configureForVoiceInteraction(force: Bool = false) -> Bool { |
||||
|
// do { |
||||
|
// // 配置音频会话以支持语音交互 |
||||
|
// // 使用playAndRecord类别允许同时录音和播放 |
||||
|
// // 使用spokenAudio模式优化语音交互 |
||||
|
// try configureSession( |
||||
|
// category: .playAndRecord, |
||||
|
// mode: .voiceChat, |
||||
|
// options: [ |
||||
|
// // .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接进行高质量音频输出 |
||||
|
// .allowBluetooth, // 允许通过蓝牙SCO连接进行输入和输出 |
||||
|
// .defaultToSpeaker, // 默认使用扬声器输出 |
||||
|
// .mixWithOthers |
||||
|
// ], |
||||
|
// force: force // 强制重新配置,确保设置生效 |
||||
|
// ) |
||||
|
|
||||
|
// // 设置首选输入设备 |
||||
|
// try configurePreferredInput() |
||||
|
|
||||
|
// NSLog("[AudioSessionManager] 已配置语音交互(ASR+TTS)的音频会话") |
||||
|
// return true |
||||
|
// } catch { |
||||
|
// NSLog("[AudioSessionManager] 配置语音交互(ASR+TTS)的音频会话失败: \(error.localizedDescription)") |
||||
|
// return false |
||||
|
// } |
||||
|
// } |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,813 @@ |
|||||
|
import Foundation |
||||
|
import MicrosoftCognitiveServicesSpeech |
||||
|
import AVFoundation |
||||
|
import AudioToolbox |
||||
|
|
||||
|
/// Azure ASR工具类,负责实现语音识别服务接口 |
||||
|
@available(iOS 13.0, *) |
||||
|
class AzureAsrHelper: NSObject { |
||||
|
// MARK: - 属性 |
||||
|
|
||||
|
/// 是否使用自定义音频处理器 - 设置为false时将使用系统默认麦克风输入 |
||||
|
private let useCustomAudioProcessor = true |
||||
|
|
||||
|
/// 事件处理回调 |
||||
|
private var eventHandler: (([String: Any]) -> Void)? |
||||
|
|
||||
|
/// 语音配置信息 |
||||
|
private var speechSubscriptionKey: String = "" |
||||
|
private var serviceRegion: String = "" |
||||
|
|
||||
|
/// 语音识别相关 |
||||
|
private var speechConfig: SPXSpeechConfiguration? |
||||
|
private var recognizer: SPXSpeechRecognizer? |
||||
|
private var audioConfig: SPXAudioConfiguration? |
||||
|
|
||||
|
/// 添加自定义音频处理相关 |
||||
|
private var pushStream: SPXPushAudioInputStream? |
||||
|
private var audioProcessor: CustomAudioProcessor? |
||||
|
private var isProcessingAudio = false |
||||
|
private var audioProcessingTimer: Timer? |
||||
|
|
||||
|
/// 状态标志 |
||||
|
private var isInitialized = false |
||||
|
private var _isContinuousRecognitionActive = false |
||||
|
|
||||
|
/// 当前语言和支持的语言 |
||||
|
private var currentLanguage = "zh-CN" |
||||
|
private var supportedLanguages: [String] = ["zh-CN", "en-US"] |
||||
|
private var isAutoDetectLanguage = false |
||||
|
|
||||
|
/// 音频会话管理器 |
||||
|
private let audioSessionManager = AudioSessionManager.shared |
||||
|
|
||||
|
// MARK: - 初始化 |
||||
|
|
||||
|
override init() { |
||||
|
super.init() |
||||
|
} |
||||
|
|
||||
|
deinit { |
||||
|
dispose() |
||||
|
} |
||||
|
|
||||
|
|
||||
|
// MARK: - ASR Service 接口实现 |
||||
|
|
||||
|
/// 初始化语音识别服务 |
||||
|
/// - Parameters: |
||||
|
/// - speechSubscriptionKey: Azure 语音服务订阅密钥 |
||||
|
/// - serviceRegion: Azure 服务区域 (如 eastasia) |
||||
|
/// - supportedLanguages: 支持的语言代码数组 (可选) |
||||
|
/// - Returns: 初始化是否成功 |
||||
|
func initialize(speechSubscriptionKey: String, serviceRegion: String, supportedLanguages: [String]? = nil) -> Bool { |
||||
|
print("[AzureAsrHelper] 初始化 Azure 语音服务") |
||||
|
|
||||
|
// 检查配置是否为空 |
||||
|
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
||||
|
print("[AzureAsrHelper] 错误: Azure 配置信息不完整") |
||||
|
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 检查语言参数 |
||||
|
if let languages = supportedLanguages, languages.isEmpty { |
||||
|
print("[AzureAsrHelper] 警告: 传入的支持语言列表为空,将使用默认语言") |
||||
|
} |
||||
|
|
||||
|
// 释放之前的资源 |
||||
|
dispose() |
||||
|
|
||||
|
// 记录配置信息 |
||||
|
self.speechSubscriptionKey = speechSubscriptionKey |
||||
|
self.serviceRegion = serviceRegion |
||||
|
|
||||
|
// 设置语言 |
||||
|
if let languages = supportedLanguages, !languages.isEmpty { |
||||
|
self.supportedLanguages = languages |
||||
|
} |
||||
|
|
||||
|
// 根据支持的语言数量决定是否启用自动语言检测 |
||||
|
isAutoDetectLanguage = self.supportedLanguages.count >= 2 |
||||
|
|
||||
|
// 如果只有一种语言,设置为当前语言 |
||||
|
if !isAutoDetectLanguage && !self.supportedLanguages.isEmpty { |
||||
|
currentLanguage = self.supportedLanguages[0] |
||||
|
} |
||||
|
|
||||
|
// 创建识别器和设置回调 |
||||
|
if !createRecognizerAndSetupCallbacks() { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
print("[AzureAsrHelper] Azure 语音服务初始化成功") |
||||
|
isInitialized = true |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
/// 创建识别器并设置回调 |
||||
|
private func createRecognizerAndSetupCallbacks() -> Bool { |
||||
|
// 释放之前的 recognizer |
||||
|
recognizer = nil |
||||
|
audioConfig = nil |
||||
|
|
||||
|
do { |
||||
|
// 创建语音配置 |
||||
|
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
||||
|
|
||||
|
// 设置音频输入参数 |
||||
|
try setupAudioSession() |
||||
|
|
||||
|
// 根据设置决定是否使用自定义音频处理器 |
||||
|
if useCustomAudioProcessor { |
||||
|
// 创建自定义音频流和处理器,替代默认的麦克风输入 |
||||
|
pushStream = try SPXPushAudioInputStream() |
||||
|
audioConfig = try SPXAudioConfiguration(streamInput: pushStream!) |
||||
|
|
||||
|
// 初始化自定义音频处理器 |
||||
|
audioProcessor = CustomAudioProcessor() |
||||
|
} else { |
||||
|
// 使用默认麦克风输入 |
||||
|
audioConfig = try SPXAudioConfiguration() |
||||
|
} |
||||
|
|
||||
|
// 设置语言配置 |
||||
|
if isAutoDetectLanguage { |
||||
|
// 设置自动语言检测 |
||||
|
speechConfig?.setPropertyTo("Continuous", by: SPXPropertyId.speechServiceConnectionLanguageIdMode) |
||||
|
|
||||
|
// 创建自动语言检测配置 |
||||
|
let autoDetectSourceLanguageConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) |
||||
|
|
||||
|
// 创建识别器 |
||||
|
recognizer = try SPXSpeechRecognizer( |
||||
|
speechConfiguration: speechConfig!, |
||||
|
autoDetectSourceLanguageConfiguration: autoDetectSourceLanguageConfig, |
||||
|
audioConfiguration: audioConfig! |
||||
|
) |
||||
|
} else { |
||||
|
// 设置指定的识别语言 |
||||
|
speechConfig?.speechRecognitionLanguage = currentLanguage |
||||
|
|
||||
|
// 创建识别器 |
||||
|
recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) |
||||
|
} |
||||
|
|
||||
|
// 设置所有回调 |
||||
|
setupAllCallbacks() |
||||
|
|
||||
|
return true |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 错误: 创建识别器失败: \(error.localizedDescription)") |
||||
|
eventHandler?(["type": "error", "message": "创建识别器失败: \(error.localizedDescription)"]) |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置音频会话 |
||||
|
private func setupAudioSession() throws { |
||||
|
print("[AzureAsrHelper] 开始配置音频会话...") |
||||
|
|
||||
|
// 使用AudioSessionManager配置音频会话,使用专门为Azure ASR优化的配置 |
||||
|
let success = audioSessionManager.configureForVoiceInteraction() |
||||
|
if !success { |
||||
|
print("[AzureAsrHelper] 警告: 通过AudioSessionManager配置音频会话失败") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
/// 设置所有回调 |
||||
|
private func setupAllCallbacks() { |
||||
|
guard let recognizer = recognizer else { return } |
||||
|
|
||||
|
// 最终识别结果 |
||||
|
recognizer.addRecognizedEventHandler { [weak self] _, event in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
if event.result.reason == SPXResultReason.recognizedSpeech { |
||||
|
let detectedLanguage = self.getDetectedLanguage(from: event.result) |
||||
|
print("[AzureAsrHelper] 识别结果: \(event.result.text ?? ""), 语言: \(detectedLanguage)") |
||||
|
self.eventHandler?(["type": "result", |
||||
|
"text": event.result.text ?? "", |
||||
|
"detectedLanguage": detectedLanguage |
||||
|
]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 识别中事件 |
||||
|
recognizer.addRecognizingEventHandler { [weak self] _, event in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
if event.result.reason == SPXResultReason.recognizingSpeech { |
||||
|
let detectedLanguage = self.getDetectedLanguage(from: event.result) |
||||
|
// print("[AzureAsrHelper] 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") |
||||
|
self.eventHandler?(["type": "recognizing", |
||||
|
"text": event.result.text ?? "", |
||||
|
"detectedLanguage": detectedLanguage |
||||
|
]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 会话事件 |
||||
|
recognizer.addSessionStartedEventHandler { [weak self] _, _ in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
print("[AzureAsrHelper] 识别会话已开始") |
||||
|
self._isContinuousRecognitionActive = true |
||||
|
self.eventHandler?(["type": "sessionStarted"]) |
||||
|
} |
||||
|
|
||||
|
recognizer.addSessionStoppedEventHandler { [weak self] _, _ in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
print("[AzureAsrHelper] 识别会话已结束") |
||||
|
self._isContinuousRecognitionActive = false |
||||
|
self.eventHandler?(["type": "sessionStopped"]) |
||||
|
} |
||||
|
|
||||
|
// 取消事件 |
||||
|
recognizer.addCanceledEventHandler { [weak self] _, event in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
let reason = event.reason.rawValue |
||||
|
let errorDetails = event.errorDetails ?? "未知错误" |
||||
|
|
||||
|
print("[AzureAsrHelper] 识别取消: \(errorDetails)") |
||||
|
|
||||
|
self.eventHandler?(["type": "canceled", "reason": reason, "errorDetails": errorDetails]) |
||||
|
|
||||
|
self._isContinuousRecognitionActive = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 执行一次性语音识别 |
||||
|
/// - Returns: 是否成功启动识别 |
||||
|
func recognizeOnce() -> Bool { |
||||
|
// 检查是否初始化 |
||||
|
if !isInitialized { |
||||
|
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
||||
|
eventHandler?(["type": "error", "message": "语音服务未初始化"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 检查参数有效性 |
||||
|
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
||||
|
print("[AzureAsrHelper] 错误: Azure 配置信息不完整") |
||||
|
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 如果正在连续识别,先停止 |
||||
|
if _isContinuousRecognitionActive { |
||||
|
stopContinuousRecognition() |
||||
|
} |
||||
|
|
||||
|
// 确保识别器已创建 |
||||
|
if recognizer == nil && !createRecognizerAndSetupCallbacks() { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
// 启动音频处理 |
||||
|
startAudioProcessing() |
||||
|
|
||||
|
// 通知会话开始 |
||||
|
eventHandler?(["type": "sessionStarted"]) |
||||
|
|
||||
|
// 执行识别 |
||||
|
try recognizer?.recognizeOnceAsync { [weak self] result in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
// 停止音频处理 |
||||
|
self.stopAudioProcessing() |
||||
|
|
||||
|
if result.reason == SPXResultReason.recognizedSpeech { |
||||
|
let detectedLanguage = self.getDetectedLanguage(from: result) |
||||
|
self.eventHandler?(["type": "result", |
||||
|
"text": result.text ?? "", |
||||
|
"detectedLanguage": detectedLanguage |
||||
|
]) |
||||
|
} else if result.reason == SPXResultReason.noMatch { |
||||
|
print("[AzureAsrHelper] 无匹配结果") |
||||
|
self.eventHandler?(["type": "noMatch"]) |
||||
|
} else if result.reason == SPXResultReason.canceled { |
||||
|
do { |
||||
|
let details = try SPXCancellationDetails(fromCanceledRecognitionResult: result) |
||||
|
let errorDetails = details.errorDetails ?? "未知错误" |
||||
|
self.eventHandler?(["type": "error", "message": "识别取消: \(errorDetails)"]) |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 错误: 获取取消详情失败: \(error.localizedDescription)") |
||||
|
self.eventHandler?(["type": "error", "message": "识别取消,无法获取详细原因"]) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return true |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 错误: 识别异常: \(error.localizedDescription)") |
||||
|
eventHandler?(["type": "error", "message": "识别异常: \(error.localizedDescription)"]) |
||||
|
stopAudioProcessing() |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 开始连续语音识别 |
||||
|
/// - Returns: 是否成功启动识别 |
||||
|
func startContinuousRecognition() -> Bool { |
||||
|
// 检查是否初始化 |
||||
|
if !isInitialized { |
||||
|
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
||||
|
eventHandler?(["type": "error", "message": "语音服务未初始化"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 检查参数有效性 |
||||
|
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
||||
|
print("[AzureAsrHelper] 错误: Azure 配置信息不完整") |
||||
|
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 检查是否已经在识别 |
||||
|
if _isContinuousRecognitionActive { |
||||
|
print("[AzureAsrHelper] 已经在进行连续识别中,忽略请求") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
// 确保识别器已创建 |
||||
|
if recognizer == nil { |
||||
|
print("[AzureAsrHelper] 尝试重新创建识别器...") |
||||
|
if !createRecognizerAndSetupCallbacks() { |
||||
|
eventHandler?(["type": "error", "message": "无法创建语音识别器"]) |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 启动音频处理 |
||||
|
startAudioProcessing() |
||||
|
|
||||
|
// 尝试启动连续识别 |
||||
|
do { |
||||
|
// 启动连续识别 |
||||
|
try recognizer?.startContinuousRecognition() |
||||
|
_isContinuousRecognitionActive = true |
||||
|
|
||||
|
print("[AzureAsrHelper] 连续识别已启动") |
||||
|
return true |
||||
|
} catch { |
||||
|
_isContinuousRecognitionActive = false |
||||
|
print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") |
||||
|
|
||||
|
// 停止音频处理 |
||||
|
stopAudioProcessing() |
||||
|
|
||||
|
// 发送错误通知 |
||||
|
eventHandler?(["type": "error", "message": "开始连续识别失败: \(error.localizedDescription)"]) |
||||
|
|
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
/// 停止连续语音识别 |
||||
|
/// - Returns: 是否成功停止识别 |
||||
|
func stopContinuousRecognition() -> Bool { |
||||
|
// 停止音频处理 |
||||
|
stopAudioProcessing() |
||||
|
|
||||
|
// 检查是否初始化 |
||||
|
if !isInitialized { |
||||
|
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
||||
|
eventHandler?(["type": "error", "message": "语音服务未初始化"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
if !_isContinuousRecognitionActive { |
||||
|
print("[AzureAsrHelper] 未进行连续识别,忽略停止请求") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
print("[AzureAsrHelper] 停止连续语音识别") |
||||
|
|
||||
|
// 防止空指针异常 |
||||
|
guard let recognizer = recognizer else { |
||||
|
print("[AzureAsrHelper] 警告: 识别器为空,但状态显示活跃") |
||||
|
_isContinuousRecognitionActive = false |
||||
|
eventHandler?(["type": "sessionStopped"]) |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
// 先标记为非活跃状态,防止重复调用 |
||||
|
_isContinuousRecognitionActive = false |
||||
|
|
||||
|
// 异步执行停止操作,避免阻塞主线程 |
||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
do { |
||||
|
// 在后台线程执行停止操作 |
||||
|
try recognizer.stopContinuousRecognition() |
||||
|
|
||||
|
// 主线程回调通知结果 |
||||
|
DispatchQueue.main.async { |
||||
|
print("[AzureAsrHelper] 连续识别已停止") |
||||
|
self.eventHandler?(["type": "sessionStopped"]) |
||||
|
} |
||||
|
} catch { |
||||
|
// 主线程回调通知错误 |
||||
|
DispatchQueue.main.async { |
||||
|
print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") |
||||
|
// 停止失败,恢复状态 |
||||
|
self._isContinuousRecognitionActive = true |
||||
|
self.eventHandler?(["type": "error", "message": "停止识别失败: \(error.localizedDescription)"]) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 立即返回,不阻塞调用方 |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
/// 检查连续识别是否活跃 |
||||
|
/// - Returns: 连续识别是否处于活跃状态 |
||||
|
func isContinuousRecognitionActive() -> Bool { |
||||
|
return _isContinuousRecognitionActive |
||||
|
} |
||||
|
|
||||
|
/// 释放资源 |
||||
|
func dispose() { |
||||
|
// 停止音频处理 |
||||
|
stopAudioProcessing() |
||||
|
|
||||
|
// 尝试停止所有识别操作 |
||||
|
if _isContinuousRecognitionActive { |
||||
|
do { |
||||
|
if let recognizer = recognizer { |
||||
|
try recognizer.stopContinuousRecognition() |
||||
|
print("[AzureAsrHelper] 连续识别已停止") |
||||
|
} |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 警告: 停止连续识别失败: \(error.localizedDescription)") |
||||
|
} |
||||
|
_isContinuousRecognitionActive = false |
||||
|
} |
||||
|
|
||||
|
// 释放识别器 |
||||
|
recognizer = nil |
||||
|
audioConfig = nil |
||||
|
speechConfig = nil |
||||
|
pushStream = nil |
||||
|
audioProcessor = nil |
||||
|
|
||||
|
// 重置状态 |
||||
|
isInitialized = false |
||||
|
|
||||
|
print("[AzureAsrHelper] 资源已释放") |
||||
|
} |
||||
|
|
||||
|
/// 从结果中获取检测到的语言 |
||||
|
private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { |
||||
|
if isAutoDetectLanguage { |
||||
|
do { |
||||
|
let langResult = try SPXAutoDetectSourceLanguageResult(result) |
||||
|
return langResult.language ?? currentLanguage |
||||
|
} catch { |
||||
|
print("[AzureAsrHelper] 错误: 获取检测到的语言失败: \(error.localizedDescription)") |
||||
|
return currentLanguage |
||||
|
} |
||||
|
} else { |
||||
|
return currentLanguage |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置事件处理器 |
||||
|
@objc func setEventHandler(_ handler: @escaping ([String: Any]) -> Void) { |
||||
|
self.eventHandler = handler |
||||
|
} |
||||
|
|
||||
|
// MARK: - 音频处理 |
||||
|
|
||||
|
/// 开始音频处理 |
||||
|
private func startAudioProcessing() { |
||||
|
// 如果未使用自定义音频处理器,跳过 |
||||
|
if !useCustomAudioProcessor { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
guard !isProcessingAudio, let audioProcessor = audioProcessor else { return } |
||||
|
|
||||
|
isProcessingAudio = true |
||||
|
|
||||
|
// 启动音频处理器 |
||||
|
if !audioProcessor.startRecord() { |
||||
|
print("[AzureAsrHelper] 错误: 启动音频处理器失败") |
||||
|
eventHandler?(["type": "error", "message": "启动音频处理器失败"]) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 启动音频处理定时器 |
||||
|
audioProcessingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in |
||||
|
guard let self = self, self.isProcessingAudio, let processor = self.audioProcessor, let stream = self.pushStream else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 读取处理后的音频数据 |
||||
|
var bytes = [UInt8](repeating: 0, count: 2560) |
||||
|
let bytesRead = processor.read(bytes: &bytes) |
||||
|
|
||||
|
if bytesRead > 0 { |
||||
|
// 推送数据到Azure语音服务 |
||||
|
let data = Data(bytes: bytes, count: bytesRead) |
||||
|
stream.write(data) |
||||
|
|
||||
|
// 通知音频数据可用(可选) |
||||
|
// self.eventHandler?(["type": "audioData", "data": bytes]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
print("[AzureAsrHelper] 音频处理已启动") |
||||
|
} |
||||
|
|
||||
|
/// 停止音频处理 |
||||
|
private func stopAudioProcessing() { |
||||
|
// 如果未使用自定义音频处理器,跳过 |
||||
|
if !useCustomAudioProcessor { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 停止定时器 |
||||
|
audioProcessingTimer?.invalidate() |
||||
|
audioProcessingTimer = nil |
||||
|
|
||||
|
// 停止音频处理器 |
||||
|
audioProcessor?.stopRecord() |
||||
|
|
||||
|
isProcessingAudio = false |
||||
|
print("[AzureAsrHelper] 音频处理已停止") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// MARK: - 自定义音频处理器 |
||||
|
|
||||
|
@available(iOS 13.0, *) |
||||
|
class CustomAudioProcessor: NSObject { |
||||
|
// 音频单元 |
||||
|
private var ioUnit: AudioUnit? |
||||
|
|
||||
|
// 音频格式 |
||||
|
private var audioFormat: AudioStreamBasicDescription |
||||
|
|
||||
|
// 音频缓冲 |
||||
|
private var audioBufferList: AudioBufferList |
||||
|
private var audioList: [Float] = [] |
||||
|
private let audioListQueue = DispatchQueue(label: "audioListQueue") |
||||
|
|
||||
|
// 回音消除状态 |
||||
|
private var isEchoCancellationEnabled = true |
||||
|
|
||||
|
override init() { |
||||
|
// 设置音频格式 - 16kHz, 16位, 单声道 |
||||
|
audioFormat = AudioStreamBasicDescription( |
||||
|
mSampleRate: 16000.0, |
||||
|
mFormatID: kAudioFormatLinearPCM, |
||||
|
mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked, |
||||
|
mBytesPerPacket: 2, |
||||
|
mFramesPerPacket: 1, |
||||
|
mBytesPerFrame: 2, |
||||
|
mChannelsPerFrame: 1, |
||||
|
mBitsPerChannel: 16, |
||||
|
mReserved: 0 |
||||
|
) |
||||
|
|
||||
|
// 初始化音频缓冲 |
||||
|
audioBufferList = AudioBufferList( |
||||
|
mNumberBuffers: 1, |
||||
|
mBuffers: AudioBuffer( |
||||
|
mNumberChannels: 1, |
||||
|
mDataByteSize: 4096, |
||||
|
mData: malloc(4096) |
||||
|
) |
||||
|
) |
||||
|
|
||||
|
super.init() |
||||
|
} |
||||
|
|
||||
|
deinit { |
||||
|
stopRecord() |
||||
|
free(audioBufferList.mBuffers.mData) |
||||
|
} |
||||
|
|
||||
|
/// 启动音频处理 |
||||
|
/// - Returns: 是否成功启动 |
||||
|
func startRecord() -> Bool { |
||||
|
print("[CustomAudioProcessor] 配置音频单元") |
||||
|
|
||||
|
// 创建音频组件描述 - 使用VoiceProcessingIO类型获取回音消除 |
||||
|
var ioUnitDescription = AudioComponentDescription( |
||||
|
componentType: kAudioUnitType_Output, |
||||
|
componentSubType: kAudioUnitSubType_VoiceProcessingIO, |
||||
|
componentManufacturer: kAudioUnitManufacturer_Apple, |
||||
|
componentFlags: 0, |
||||
|
componentFlagsMask: 0 |
||||
|
) |
||||
|
|
||||
|
// 查找音频组件 |
||||
|
guard let ioUnitRef = AudioComponentFindNext(nil, &ioUnitDescription) else { |
||||
|
print("[CustomAudioProcessor] 错误: 未找到音频组件") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 创建音频单元实例 |
||||
|
if checkError(AudioComponentInstanceNew(ioUnitRef, &ioUnit), "创建音频单元") { |
||||
|
ioUnit = nil |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 启用输入端口 |
||||
|
var enableInput: UInt32 = 1 |
||||
|
let kInputBus: AudioUnitElement = 1 |
||||
|
let kOutputBus: AudioUnitElement = 0 |
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
||||
|
kAudioUnitScope_Input, kInputBus, &enableInput, |
||||
|
UInt32(MemoryLayout<UInt32>.size)), "启用输入端口") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 禁用输出端口 (我们只需要输入) |
||||
|
var enableOutput: UInt32 = 0 |
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
||||
|
kAudioUnitScope_Output, kOutputBus, |
||||
|
&enableOutput, UInt32(MemoryLayout<UInt32>.size)), "禁用输出端口") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 设置缓冲区分配标志 |
||||
|
var flag: UInt32 = 0 |
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_ShouldAllocateBuffer, |
||||
|
kAudioUnitScope_Output, kInputBus, &flag, UInt32(MemoryLayout<UInt32>.size)), "设置缓冲区分配标志") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 设置音频格式 |
||||
|
let size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size) |
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
||||
|
kAudioUnitScope_Output, kInputBus, &audioFormat, size), "设置输入总线输出范围的流格式") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
||||
|
kAudioUnitScope_Input, kOutputBus, &audioFormat, size), "设置输出总线输入范围的流格式") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 启用回音消除 - 注意: kAUVoiceIOProperty_BypassVoiceProcessing值为1时表示绕过处理,值为0表示启用处理 |
||||
|
if isEchoCancellationEnabled { |
||||
|
var echoCancellation: UInt32 = 0 // 0表示不绕过,即启用回音消除 |
||||
|
AudioUnitSetProperty(ioUnit!, kAUVoiceIOProperty_BypassVoiceProcessing, |
||||
|
kAudioUnitScope_Global, 0, &echoCancellation, UInt32(MemoryLayout<UInt32>.size)) |
||||
|
} |
||||
|
|
||||
|
// 设置输入回调 - 当有新音频数据时调用 |
||||
|
var inputCallback = AURenderCallbackStruct( |
||||
|
inputProc: CustomAudioProcessor.onAudioDataAvailable, |
||||
|
inputProcRefCon: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) |
||||
|
) |
||||
|
|
||||
|
if checkError(AudioUnitSetProperty(ioUnit!, |
||||
|
kAudioOutputUnitProperty_SetInputCallback, |
||||
|
kAudioUnitScope_Global, kInputBus, |
||||
|
&inputCallback, UInt32(MemoryLayout<AURenderCallbackStruct>.size)), "设置输入回调") { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 初始化音频单元 |
||||
|
var hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") |
||||
|
while hasError { |
||||
|
Thread.sleep(forTimeInterval: 0.1) |
||||
|
hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") |
||||
|
} |
||||
|
|
||||
|
// 启动音频单元 |
||||
|
hasError = checkError(AudioOutputUnitStart(ioUnit!), "启动音频单元") |
||||
|
|
||||
|
print("[CustomAudioProcessor] 音频处理器已启动,回音消除\(isEchoCancellationEnabled ? "已启用" : "已禁用")") |
||||
|
return !hasError |
||||
|
} |
||||
|
|
||||
|
/// 停止音频处理 |
||||
|
func stopRecord() { |
||||
|
print("[CustomAudioProcessor] 停止音频处理器") |
||||
|
|
||||
|
if let ioUnit = ioUnit { |
||||
|
// 停止音频单元 |
||||
|
_ = checkError(AudioOutputUnitStop(ioUnit), "停止音频单元") |
||||
|
|
||||
|
// 关闭音频单元 |
||||
|
_ = checkError(AudioUnitUninitialize(ioUnit), "反初始化音频单元") |
||||
|
_ = checkError(AudioComponentInstanceDispose(ioUnit), "释放音频单元") |
||||
|
|
||||
|
self.ioUnit = nil |
||||
|
} |
||||
|
|
||||
|
// 清空音频数据缓冲 |
||||
|
audioListQueue.sync { |
||||
|
audioList.removeAll() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 音频数据回调 - 当有新的音频数据可用时调用 |
||||
|
private static let onAudioDataAvailable: AURenderCallback = { inRefCon, ioActionFlags, inTimeStamp, inBusNumber, inNumberFrames, ioData in |
||||
|
// 获取实例 |
||||
|
let processor = Unmanaged<CustomAudioProcessor>.fromOpaque(inRefCon).takeUnretainedValue() |
||||
|
|
||||
|
// 计算预期数据大小 |
||||
|
let expectedDataByteSize = inNumberFrames * processor.audioFormat.mBytesPerFrame |
||||
|
|
||||
|
// 确保缓冲区足够大 |
||||
|
if processor.audioBufferList.mBuffers.mDataByteSize < expectedDataByteSize { |
||||
|
processor.audioBufferList.mBuffers.mData = realloc(processor.audioBufferList.mBuffers.mData, Int(expectedDataByteSize)) |
||||
|
processor.audioBufferList.mBuffers.mDataByteSize = expectedDataByteSize |
||||
|
} |
||||
|
|
||||
|
// 渲染音频数据 |
||||
|
let status = processor.checkOSStatus(AudioUnitRender(processor.ioUnit!, ioActionFlags, inTimeStamp, |
||||
|
inBusNumber, inNumberFrames, &processor.audioBufferList), |
||||
|
"渲染音频数据") |
||||
|
|
||||
|
// 将Int16数据转换为浮点数据进行处理 |
||||
|
var audioDataFloat = [Float](repeating: 0.0, count: Int(inNumberFrames)) |
||||
|
let buffer = processor.audioBufferList.mBuffers |
||||
|
let bufferData = buffer.mData!.assumingMemoryBound(to: Int16.self) |
||||
|
|
||||
|
for j in 0..<Int(buffer.mDataByteSize / UInt32(MemoryLayout<Int16>.size)) { |
||||
|
// 归一化到[-1.0, 1.0]范围 |
||||
|
audioDataFloat[j] = Float(bufferData[j]) / 32768.0 |
||||
|
} |
||||
|
|
||||
|
// 保存处理后的数据 |
||||
|
if status == noErr { |
||||
|
processor.audioListQueue.async { |
||||
|
processor.audioList.append(contentsOf: audioDataFloat) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return status |
||||
|
} |
||||
|
|
||||
|
/// 读取处理后的音频数据 |
||||
|
/// - Parameter bytes: 输出字节数组 |
||||
|
/// - Returns: 读取的字节数 |
||||
|
func read(bytes: inout [UInt8]) -> Int { |
||||
|
return audioListQueue.sync { |
||||
|
// 如果没有数据,返回0 |
||||
|
if audioList.isEmpty { |
||||
|
return 0 |
||||
|
} |
||||
|
|
||||
|
// 确保有足够的数据 (至少1280个样本) |
||||
|
if audioList.count < 1280 { |
||||
|
return 0 |
||||
|
} |
||||
|
|
||||
|
// 读取一帧数据 (1280个样本) |
||||
|
let frameLength = 1280 |
||||
|
let buffer = Array(audioList.prefix(frameLength)) |
||||
|
audioList.removeFirst(frameLength) |
||||
|
|
||||
|
// 将浮点数据转回Int16格式 |
||||
|
var int16Data = buffer.map { Int16($0 * 32767) } |
||||
|
|
||||
|
// 转换为字节数组 |
||||
|
let data = Data(buffer: UnsafeBufferPointer(start: &int16Data, count: int16Data.count)) |
||||
|
bytes = [UInt8](data) |
||||
|
|
||||
|
// 每个样本2字节 (16位PCM) |
||||
|
return frameLength * 2 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查错误并打印日志 |
||||
|
/// - Parameters: |
||||
|
/// - status: 操作状态 |
||||
|
/// - operation: 操作描述 |
||||
|
/// - Returns: 是否发生错误 |
||||
|
private func checkError(_ status: OSStatus, _ operation: String) -> Bool { |
||||
|
if status != noErr { |
||||
|
print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") |
||||
|
return true |
||||
|
} |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
/// 检查OSStatus并返回状态 |
||||
|
/// - Parameters: |
||||
|
/// - status: 操作状态 |
||||
|
/// - operation: 操作描述 |
||||
|
/// - Returns: 原始状态 |
||||
|
private func checkOSStatus(_ status: OSStatus, _ operation: String) -> OSStatus { |
||||
|
if status != noErr { |
||||
|
print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") |
||||
|
} |
||||
|
return status |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,454 @@ |
|||||
|
import Foundation |
||||
|
import MicrosoftCognitiveServicesSpeech |
||||
|
import AVFoundation |
||||
|
|
||||
|
/// Azure TTS工具类,负责实现TTS服务接口 |
||||
|
@available(iOS 13.0, *) |
||||
|
class AzureTtsHelper: NSObject { |
||||
|
// MARK: - 属性 |
||||
|
|
||||
|
/// 事件处理回调 |
||||
|
private var eventHandler: (([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 |
||||
|
|
||||
|
/// 音频会话配置 |
||||
|
private var isAudioSessionConfigured = false |
||||
|
|
||||
|
/// 音频会话管理器 |
||||
|
private let audioSessionManager = AudioSessionManager.shared |
||||
|
|
||||
|
// 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: - 初始化 |
||||
|
|
||||
|
override init() { |
||||
|
super.init() |
||||
|
} |
||||
|
|
||||
|
deinit { |
||||
|
dispose() |
||||
|
} |
||||
|
|
||||
|
/// 设置事件处理器 |
||||
|
@objc func setEventHandler(_ handler: @escaping ([String: Any]) -> Void) { |
||||
|
self.eventHandler = handler |
||||
|
} |
||||
|
|
||||
|
// 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?(["type": "error", "message": "Azure 配置信息不完整"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 释放之前的资源 |
||||
|
dispose() |
||||
|
|
||||
|
// 记录配置信息 |
||||
|
self.speechSubscriptionKey = speechSubscriptionKey |
||||
|
self.serviceRegion = serviceRegion |
||||
|
|
||||
|
// 配置音频会话 |
||||
|
if !configureAudioSession() { |
||||
|
print("[AzureTtsHelper] 警告: 音频会话配置失败,将尝试继续初始化") |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
// 创建语音配置 |
||||
|
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
||||
|
|
||||
|
// 设置默认语音 |
||||
|
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?(["type": "error", "message": "初始化语音合成服务失败: \(error.localizedDescription)"]) |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 配置音频会话 |
||||
|
private func configureAudioSession() -> Bool { |
||||
|
do { |
||||
|
// 使用音频会话管理器配置媒体播放模式 |
||||
|
try audioSessionManager.configureForVoiceInteraction() |
||||
|
|
||||
|
isAudioSessionConfigured = true |
||||
|
print("[AzureTtsHelper] 音频会话配置成功") |
||||
|
return true |
||||
|
} catch { |
||||
|
print("[AzureTtsHelper] 警告: 音频会话配置失败: \(error.localizedDescription)") |
||||
|
isAudioSessionConfigured = false |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置语音 |
||||
|
/// - Parameter voiceName: 语音名称 (如 "zh-CN-XiaoxiaoNeural") |
||||
|
/// - Returns: 设置是否成功 |
||||
|
func setVoice(voiceName: String) -> Bool { |
||||
|
// 检查是否初始化 |
||||
|
if !isInitialized { |
||||
|
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
||||
|
eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 检查参数有效性 |
||||
|
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
||||
|
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
||||
|
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
if voiceName.isEmpty { |
||||
|
print("[AzureTtsHelper] 错误: 声音名称为空") |
||||
|
eventHandler?(["type": "error", "message": "声音名称不能为空"]) |
||||
|
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?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 检查参数有效性 |
||||
|
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
||||
|
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
||||
|
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
||||
|
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?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 检查参数有效性 |
||||
|
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
||||
|
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
||||
|
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
if text.isEmpty { |
||||
|
print("[AzureTtsHelper] 警告: 要播放的文本为空") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
// 确保音频会话已配置 |
||||
|
if !isAudioSessionConfigured { |
||||
|
_ = configureAudioSession() |
||||
|
} |
||||
|
|
||||
|
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?(["type": "error", "message": "合成器未初始化"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
_isSpeaking = true |
||||
|
eventHandler?(["type": "started"]) |
||||
|
|
||||
|
Task { |
||||
|
do { |
||||
|
// 使用异步方法进行合成并直接播放 |
||||
|
_ = try synthesizer.startSpeakingSsml(text) |
||||
|
|
||||
|
} catch { |
||||
|
print("[AzureTtsHelper] 错误: 语音合成失败: \(error.localizedDescription)") |
||||
|
DispatchQueue.main.async { |
||||
|
self._isSpeaking = false |
||||
|
self.eventHandler?(["type": "error", "message": "语音合成失败: \(error.localizedDescription)"]) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
/// 停止当前语音合成 |
||||
|
/// - Returns: 操作是否成功 |
||||
|
func stopSpeaking() -> Bool { |
||||
|
// 检查是否初始化 |
||||
|
if !isInitialized { |
||||
|
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
||||
|
eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
if !_isSpeaking { |
||||
|
print("[AzureTtsHelper] 未进行语音播放,忽略停止请求") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
// 停止合成 |
||||
|
do { |
||||
|
try synthesizer?.stopSpeaking() |
||||
|
_isSpeaking = false |
||||
|
eventHandler?(["type": "canceled"]) |
||||
|
print("[AzureTtsHelper] 已停止语音合成") |
||||
|
return true |
||||
|
} catch { |
||||
|
print("[AzureTtsHelper] 错误: 停止语音合成失败: \(error.localizedDescription)") |
||||
|
eventHandler?(["type": "error", "message": "停止语音合成失败: \(error.localizedDescription)"]) |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查是否正在播放 |
||||
|
/// - Returns: 当前是否正在播放语音 |
||||
|
func isSpeaking() -> Bool { |
||||
|
return _isSpeaking |
||||
|
} |
||||
|
|
||||
|
/// 释放资源 |
||||
|
func dispose() { |
||||
|
// 尝试停止所有语音合成 |
||||
|
if _isSpeaking { |
||||
|
do { |
||||
|
if let synthesizer = synthesizer { |
||||
|
try synthesizer.stopSpeaking() |
||||
|
print("[AzureTtsHelper] 语音合成已停止") |
||||
|
} |
||||
|
} catch { |
||||
|
print("[AzureTtsHelper] 警告: 停止语音合成失败: \(error.localizedDescription)") |
||||
|
} |
||||
|
_isSpeaking = false |
||||
|
} |
||||
|
|
||||
|
// 释放合成器 |
||||
|
synthesizer = nil |
||||
|
speechConfig = nil |
||||
|
|
||||
|
// 重置状态 |
||||
|
isInitialized = false |
||||
|
isAudioSessionConfigured = false |
||||
|
|
||||
|
print("[AzureTtsHelper] 资源已释放") |
||||
|
} |
||||
|
|
||||
|
// 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?(["type": "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?(["type": "error", "message": "语音合成取消: \(cancellationDetails.errorDetails ?? "未知错误")"]) |
||||
|
} |
||||
|
} catch { |
||||
|
print("[AzureTtsHelper] 获取取消详情时出错: \(error)") |
||||
|
|
||||
|
DispatchQueue.main.async { |
||||
|
self._isSpeaking = false |
||||
|
self.eventHandler?(["type": "error", "message": "语音合成被取消"]) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 合成开始事件 |
||||
|
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 |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,464 @@ |
|||||
|
import Foundation |
||||
|
import MediaPlayer |
||||
|
import AVFoundation |
||||
|
import UIKit |
||||
|
|
||||
|
/// 媒体按钮事件处理协议 |
||||
|
@available(iOS 13.0, *) |
||||
|
@objc protocol MediaButtonEventHandler: AnyObject { |
||||
|
func sendEvent(_ event: [String: Any]) |
||||
|
} |
||||
|
|
||||
|
/// 蓝牙媒体按键事件处理类 |
||||
|
/// 该类仅在iOS 13.0及以上版本可用 |
||||
|
@available(iOS 13.0, *) |
||||
|
class BluetoothMediaButtonHelper: NSObject, AVAudioPlayerDelegate { |
||||
|
// MARK: - 属性 |
||||
|
|
||||
|
@objc static let shared = BluetoothMediaButtonHelper() |
||||
|
|
||||
|
/// 日志标签 |
||||
|
private let TAG = "BluetoothMediaButtonHelper" |
||||
|
|
||||
|
/// 远程控制中心 |
||||
|
private var remoteCommandCenter: MPRemoteCommandCenter |
||||
|
|
||||
|
/// 事件处理器 - 用于发送事件到Flutter |
||||
|
private weak var eventHandler: MediaButtonEventHandler? |
||||
|
|
||||
|
/// 是否已启用监听 |
||||
|
private var isListening = false |
||||
|
|
||||
|
/// 静音音频播放器 - 用于保持音频会话活跃 |
||||
|
private var silentAudioPlayer: AVAudioPlayer? |
||||
|
|
||||
|
/// 播放计时器 - 用于定期更新播放信息 |
||||
|
private var playbackTimer: Timer? |
||||
|
|
||||
|
/// 当前播放时间 - 模拟播放进度 |
||||
|
private var currentPlaybackTime: TimeInterval = 0 |
||||
|
|
||||
|
/// 总时长 - 模拟 |
||||
|
private let totalDuration: TimeInterval = 300.0 |
||||
|
|
||||
|
/// 音频会话管理器 |
||||
|
private let audioSessionManager = AudioSessionManager.shared |
||||
|
|
||||
|
// MARK: - 初始化 |
||||
|
|
||||
|
override init() { |
||||
|
// 获取远程控制中心单例 |
||||
|
self.remoteCommandCenter = MPRemoteCommandCenter.shared() |
||||
|
|
||||
|
super.init() |
||||
|
|
||||
|
NSLog("\(TAG): 初始化媒体按钮处理器") |
||||
|
|
||||
|
// 初始化静音音频播放器 |
||||
|
initSilentAudioPlayer() |
||||
|
} |
||||
|
|
||||
|
deinit { |
||||
|
stopButtonListening() |
||||
|
NSLog("\(TAG): 媒体按钮处理器已释放") |
||||
|
} |
||||
|
|
||||
|
// MARK: - 公共方法 |
||||
|
|
||||
|
/// 设置事件处理器 |
||||
|
@objc func setEventHandler(_ handler: MediaButtonEventHandler) { |
||||
|
eventHandler = handler |
||||
|
NSLog("\(TAG): 已设置媒体按钮事件处理器") |
||||
|
} |
||||
|
|
||||
|
/// 开始监听蓝牙媒体按钮事件 |
||||
|
@objc func startButtonListening() -> Bool { |
||||
|
if isListening { |
||||
|
NSLog("\(TAG): 已经在监听蓝牙媒体按钮事件") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
NSLog("\(TAG): 开始监听蓝牙媒体按钮事件") |
||||
|
|
||||
|
// 立即注册远程命令 - 这不需要音频会话 |
||||
|
registerRemoteCommands() |
||||
|
|
||||
|
// 标记为已在监听 |
||||
|
isListening = true |
||||
|
|
||||
|
// 确保NowPlaying信息可用 - 这也不依赖于活跃的音频会话 |
||||
|
setupNowPlaying(isPlaying: true) |
||||
|
|
||||
|
// 尝试启用静音音频播放(但即使失败,远程命令仍然可用) |
||||
|
do { |
||||
|
// try audioSessionManager.configureForBluetoothMediaButtons() |
||||
|
startSilentAudio() |
||||
|
|
||||
|
// 延时1秒 |
||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { |
||||
|
self.setupNowPlaying(isPlaying: false) |
||||
|
self.stopSilentAudio() |
||||
|
} |
||||
|
|
||||
|
// startUpdatingPlaybackInfo() |
||||
|
} catch { |
||||
|
NSLog("\(TAG): 音频设置或播放失败: \(error.localizedDescription)") |
||||
|
// 继续执行,因为远程命令在某些设备上不需要播放音频也能工作 |
||||
|
} |
||||
|
|
||||
|
NSLog("\(TAG): 蓝牙媒体按钮监听已启动") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
/// 停止监听蓝牙媒体按钮事件 |
||||
|
@objc func stopButtonListening() -> Bool { |
||||
|
if !isListening { |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
NSLog("\(TAG): 停止监听蓝牙媒体按钮事件") |
||||
|
|
||||
|
// 停止播放静音音频 |
||||
|
stopSilentAudio() |
||||
|
|
||||
|
// 停止更新播放信息 |
||||
|
stopUpdatingPlaybackInfo() |
||||
|
|
||||
|
// 取消注册远程命令 |
||||
|
unregisterRemoteCommands() |
||||
|
|
||||
|
// 清除NowPlaying信息 |
||||
|
DispatchQueue.main.async { |
||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil |
||||
|
} |
||||
|
|
||||
|
isListening = false |
||||
|
NSLog("\(TAG): 蓝牙媒体按钮监听已停止") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
// MARK: - 私有方法 |
||||
|
|
||||
|
/// 初始化静音音频播放器 |
||||
|
private func initSilentAudioPlayer() { |
||||
|
do { |
||||
|
// 尝试加载sample.mp3文件 |
||||
|
if let audioPath = Bundle.main.path(forResource: "sample", ofType: "mp3") { |
||||
|
let audioUrl = URL(fileURLWithPath: audioPath) |
||||
|
|
||||
|
silentAudioPlayer = try AVAudioPlayer(contentsOf: audioUrl) |
||||
|
silentAudioPlayer?.numberOfLoops = -1 // 无限循环播放 |
||||
|
silentAudioPlayer?.volume = 0.0 // 设置为全音量 |
||||
|
silentAudioPlayer?.prepareToPlay() // 预加载 |
||||
|
silentAudioPlayer?.delegate = self |
||||
|
|
||||
|
NSLog("\(TAG): 音频播放器使用sample.mp3初始化成功") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
NSLog("\(TAG): 未找到sample.mp3文件,使用生成的静音数据") |
||||
|
|
||||
|
} catch { |
||||
|
NSLog("\(TAG): 初始化静音音频播放器失败: \(error.localizedDescription)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 开始播放静音音频 |
||||
|
private func startSilentAudio() { |
||||
|
guard let player = silentAudioPlayer else { |
||||
|
NSLog("\(TAG): 无法开始播放,播放器未初始化") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if !player.isPlaying { |
||||
|
let success = player.play() |
||||
|
if success { |
||||
|
NSLog("\(TAG): 开始播放静音音频") |
||||
|
} else { |
||||
|
NSLog("\(TAG): 播放启动失败") |
||||
|
} |
||||
|
} else { |
||||
|
NSLog("\(TAG): 静音音频已在播放中") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止播放静音音频 |
||||
|
private func stopSilentAudio() { |
||||
|
silentAudioPlayer?.stop() |
||||
|
NSLog("\(TAG): 停止播放静音音频") |
||||
|
} |
||||
|
|
||||
|
/// 开始更新播放信息 |
||||
|
private func startUpdatingPlaybackInfo() { |
||||
|
// 初始设置 |
||||
|
setupNowPlaying(isPlaying: true) |
||||
|
|
||||
|
// 定期更新播放信息 - 恢复定时器,确保持续更新播放状态 |
||||
|
playbackTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in |
||||
|
guard let self = self else { return } |
||||
|
|
||||
|
// 更新播放时间(模拟) |
||||
|
self.currentPlaybackTime += 1.0 |
||||
|
if self.currentPlaybackTime >= self.totalDuration { |
||||
|
self.currentPlaybackTime = 0.0 |
||||
|
} |
||||
|
|
||||
|
// 更新NowPlaying信息 |
||||
|
self.setupNowPlaying(isPlaying: true) |
||||
|
} |
||||
|
|
||||
|
NSLog("\(TAG): 开始更新播放信息") |
||||
|
} |
||||
|
|
||||
|
/// 停止更新播放信息 |
||||
|
private func stopUpdatingPlaybackInfo() { |
||||
|
playbackTimer?.invalidate() |
||||
|
playbackTimer = nil |
||||
|
|
||||
|
// 清空NowPlaying信息 |
||||
|
DispatchQueue.main.async { |
||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil |
||||
|
} |
||||
|
|
||||
|
NSLog("\(TAG): 停止更新播放信息") |
||||
|
} |
||||
|
|
||||
|
/// 设置NowPlaying信息 |
||||
|
private func setupNowPlaying(isPlaying: Bool) { |
||||
|
NSLog("\(TAG): 设置NowPlaying信息, isPlaying=\(isPlaying)") |
||||
|
|
||||
|
// 创建音频专辑图片 |
||||
|
let albumArt = UIImage(named: "AppIcon") // 使用应用图标作为专辑图片 |
||||
|
var nowPlayingInfo: [String: Any] = [ |
||||
|
MPMediaItemPropertyTitle: "DeepVoice 语音助手", |
||||
|
MPMediaItemPropertyArtist: "DeepVoice", |
||||
|
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0, // 1.0表示正在播放 |
||||
|
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentPlaybackTime, |
||||
|
MPMediaItemPropertyPlaybackDuration: totalDuration |
||||
|
] |
||||
|
|
||||
|
// 如果有专辑图片,添加到NowPlaying信息中 |
||||
|
if let albumArt = albumArt { |
||||
|
let artwork = MPMediaItemArtwork(boundsSize: albumArt.size) { size in |
||||
|
return albumArt |
||||
|
} |
||||
|
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork |
||||
|
} |
||||
|
|
||||
|
// 设置NowPlaying信息 (在主线程执行) |
||||
|
DispatchQueue.main.async { |
||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo |
||||
|
NSLog("\(self.TAG): NowPlaying信息设置完成") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 注册远程命令 |
||||
|
private func registerRemoteCommands() { |
||||
|
let commandCenter = MPRemoteCommandCenter.shared() |
||||
|
|
||||
|
// 播放/暂停命令 |
||||
|
commandCenter.togglePlayPauseCommand.isEnabled = true |
||||
|
commandCenter.togglePlayPauseCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) |
||||
|
commandCenter.playCommand.isEnabled = true |
||||
|
commandCenter.playCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) |
||||
|
commandCenter.pauseCommand.isEnabled = true |
||||
|
commandCenter.pauseCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) |
||||
|
|
||||
|
// 下一曲/上一曲命令 |
||||
|
commandCenter.nextTrackCommand.isEnabled = true |
||||
|
commandCenter.nextTrackCommand.addTarget(self, action: #selector(handleNextTrackCommand(_:))) |
||||
|
commandCenter.previousTrackCommand.isEnabled = true |
||||
|
commandCenter.previousTrackCommand.addTarget(self, action: #selector(handlePreviousTrackCommand(_:))) |
||||
|
|
||||
|
// 快进/快退命令 |
||||
|
commandCenter.seekForwardCommand.isEnabled = true |
||||
|
commandCenter.seekForwardCommand.addTarget(self, action: #selector(handleSeekForwardCommand(_:))) |
||||
|
commandCenter.seekBackwardCommand.isEnabled = true |
||||
|
commandCenter.seekBackwardCommand.addTarget(self, action: #selector(handleSeekBackwardCommand(_:))) |
||||
|
|
||||
|
// 停止命令 |
||||
|
commandCenter.stopCommand.isEnabled = true |
||||
|
commandCenter.stopCommand.addTarget(self, action: #selector(handleStopCommand(_:))) |
||||
|
|
||||
|
|
||||
|
|
||||
|
NSLog("\(TAG): 远程命令已注册") |
||||
|
} |
||||
|
|
||||
|
/// 取消注册远程命令 |
||||
|
private func unregisterRemoteCommands() { |
||||
|
NSLog("\(TAG): 取消注册远程命令") |
||||
|
|
||||
|
// 基本控制命令 |
||||
|
remoteCommandCenter.playCommand.isEnabled = false |
||||
|
remoteCommandCenter.playCommand.removeTarget(nil) |
||||
|
|
||||
|
remoteCommandCenter.pauseCommand.isEnabled = false |
||||
|
remoteCommandCenter.pauseCommand.removeTarget(nil) |
||||
|
|
||||
|
remoteCommandCenter.togglePlayPauseCommand.isEnabled = false |
||||
|
remoteCommandCenter.togglePlayPauseCommand.removeTarget(nil) |
||||
|
|
||||
|
remoteCommandCenter.stopCommand.isEnabled = false |
||||
|
remoteCommandCenter.stopCommand.removeTarget(nil) |
||||
|
|
||||
|
|
||||
|
} |
||||
|
|
||||
|
/// 发送按钮事件到Flutter |
||||
|
private func sendButtonEvent(type: String) { |
||||
|
// 创建事件数据 |
||||
|
let eventData: [String: Any] = [ |
||||
|
"type": "mediaButtonEvent", |
||||
|
"buttonType": type, |
||||
|
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
||||
|
] |
||||
|
|
||||
|
// 发送事件到处理器 |
||||
|
eventHandler?.sendEvent(eventData) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
// MARK: - 命令处理方法 |
||||
|
|
||||
|
/// 处理播放/暂停命令 |
||||
|
@objc private func handlePlayPauseCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
||||
|
NSLog("\(TAG): 收到播放/暂停命令") |
||||
|
|
||||
|
// 发送事件到Flutter |
||||
|
sendButtonEvent(type: "play_pause") |
||||
|
|
||||
|
// 调用VoiceInteractionService处理媒体按钮事件 |
||||
|
if VoiceInteractionService.shared.isServiceRunning() { |
||||
|
NSLog("\(TAG): 转发到VoiceInteractionService处理") |
||||
|
VoiceInteractionService.shared.handleMediaButtonAction() |
||||
|
} |
||||
|
|
||||
|
return .success |
||||
|
} |
||||
|
|
||||
|
/// 处理下一曲命令 |
||||
|
@objc private func handleNextTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
||||
|
NSLog("\(TAG): 收到下一曲命令") |
||||
|
|
||||
|
// 发送事件到Flutter |
||||
|
sendButtonEvent(type: "next_track") |
||||
|
|
||||
|
// 也可以转发到VoiceInteractionService |
||||
|
if VoiceInteractionService.shared.isServiceRunning() { |
||||
|
VoiceInteractionService.shared.handleMediaButtonAction() |
||||
|
} |
||||
|
|
||||
|
return .success |
||||
|
} |
||||
|
|
||||
|
/// 处理上一曲命令 |
||||
|
@objc private func handlePreviousTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
||||
|
NSLog("\(TAG): 收到上一曲命令") |
||||
|
|
||||
|
// 发送事件到Flutter |
||||
|
sendButtonEvent(type: "previous_track") |
||||
|
|
||||
|
// 也可以转发到VoiceInteractionService |
||||
|
if VoiceInteractionService.shared.isServiceRunning() { |
||||
|
VoiceInteractionService.shared.handleMediaButtonAction() |
||||
|
} |
||||
|
|
||||
|
return .success |
||||
|
} |
||||
|
|
||||
|
/// 处理快进命令 |
||||
|
@objc private func handleSeekForwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
||||
|
NSLog("\(TAG): 收到快进命令") |
||||
|
|
||||
|
// 发送事件到Flutter |
||||
|
sendButtonEvent(type: "seek_forward") |
||||
|
|
||||
|
return .success |
||||
|
} |
||||
|
|
||||
|
/// 处理快退命令 |
||||
|
@objc private func handleSeekBackwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
||||
|
NSLog("\(TAG): 收到快退命令") |
||||
|
|
||||
|
// 发送事件到Flutter |
||||
|
sendButtonEvent(type: "seek_backward") |
||||
|
|
||||
|
return .success |
||||
|
} |
||||
|
|
||||
|
/// 处理停止命令 |
||||
|
@objc private func handleStopCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
||||
|
NSLog("\(TAG): 收到停止命令") |
||||
|
|
||||
|
// 发送事件到Flutter |
||||
|
sendButtonEvent(type: "stop") |
||||
|
|
||||
|
// 调用VoiceInteractionService处理媒体按钮事件 |
||||
|
if VoiceInteractionService.shared.isServiceRunning() { |
||||
|
VoiceInteractionService.shared.handleMediaButtonAction() |
||||
|
} |
||||
|
|
||||
|
return .success |
||||
|
} |
||||
|
|
||||
|
// MARK: - AVAudioPlayerDelegate方法 |
||||
|
|
||||
|
/// 音频播放结束回调 |
||||
|
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { |
||||
|
NSLog("\(TAG): 音频播放结束, 成功: \(flag)") |
||||
|
|
||||
|
// 如果仍在监听状态,重新开始播放 |
||||
|
if isListening && player == silentAudioPlayer { |
||||
|
player.play() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 音频播放错误回调 |
||||
|
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { |
||||
|
if let error = error { |
||||
|
NSLog("\(TAG): 音频解码错误: \(error.localizedDescription)") |
||||
|
} else { |
||||
|
NSLog("\(TAG): 音频解码发生未知错误") |
||||
|
} |
||||
|
|
||||
|
// 重新初始化播放器 |
||||
|
if player == silentAudioPlayer && isListening { |
||||
|
initSilentAudioPlayer() |
||||
|
startSilentAudio() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 清理和释放资源 |
||||
|
func dispose() { |
||||
|
NSLog("\(TAG): 释放BluetoothMediaButtonHelper资源") |
||||
|
// 停止监听 |
||||
|
stopButtonListening() |
||||
|
|
||||
|
// 释放播放器和计时器资源 |
||||
|
silentAudioPlayer = nil |
||||
|
if let timer = playbackTimer { |
||||
|
timer.invalidate() |
||||
|
playbackTimer = nil |
||||
|
} |
||||
|
|
||||
|
// 清除引用 |
||||
|
eventHandler = nil |
||||
|
|
||||
|
NSLog("\(TAG): 资源已释放") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
extension UInt32 { |
||||
|
var data: Data { |
||||
|
var int = self |
||||
|
return Data(bytes: &int, count: MemoryLayout<UInt32>.size) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
extension UInt16 { |
||||
|
var data: Data { |
||||
|
var int = self |
||||
|
return Data(bytes: &int, count: MemoryLayout<UInt16>.size) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,243 @@ |
|||||
|
import Foundation |
||||
|
import CoreBluetooth |
||||
|
import AVFoundation |
||||
|
import UIKit |
||||
|
|
||||
|
/// 蓝牙事件处理协议 |
||||
|
@objc protocol BluetoothEventHandler: AnyObject { |
||||
|
func sendEvent(_ event: [String: Any]) |
||||
|
} |
||||
|
|
||||
|
@objc class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { |
||||
|
// MARK: - 属性 |
||||
|
|
||||
|
// 音频会话管理器 |
||||
|
private let audioSessionManager = AudioSessionManager.shared |
||||
|
|
||||
|
// 蓝牙管理器 |
||||
|
private var centralManager: CBCentralManager? |
||||
|
|
||||
|
// 事件处理器 |
||||
|
private weak var eventHandler: BluetoothEventHandler? |
||||
|
|
||||
|
// MARK: - 初始化与设置 |
||||
|
|
||||
|
override init() { |
||||
|
super.init() |
||||
|
} |
||||
|
|
||||
|
// 设置事件处理器 |
||||
|
@objc func setEventHandler(_ handler: BluetoothEventHandler) { |
||||
|
eventHandler = handler |
||||
|
} |
||||
|
|
||||
|
|
||||
|
// MARK: - CBCentralManagerDelegate |
||||
|
|
||||
|
func centralManagerDidUpdateState(_ central: CBCentralManager) { |
||||
|
let stateString = getBluetoothStateString(state: central.state) |
||||
|
sendBluetoothStateEvent(state: stateString) |
||||
|
} |
||||
|
|
||||
|
// MARK: - 设备监控 |
||||
|
|
||||
|
// 获取蓝牙状态字符串 |
||||
|
private func getBluetoothStateString(state: CBManagerState) -> String { |
||||
|
switch state { |
||||
|
case .poweredOn: |
||||
|
return "on" |
||||
|
case .poweredOff: |
||||
|
return "off" |
||||
|
default: |
||||
|
return "unknown" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 发送蓝牙状态事件 |
||||
|
private func sendBluetoothStateEvent(state: String) { |
||||
|
let event: [String: Any] = [ |
||||
|
"type": "bluetoothStateChanged", |
||||
|
"state": state, |
||||
|
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
||||
|
] |
||||
|
eventHandler?.sendEvent(event) |
||||
|
} |
||||
|
|
||||
|
// 启动蓝牙设备监控 |
||||
|
@objc func initialize() { |
||||
|
if centralManager == nil { |
||||
|
centralManager = CBCentralManager(delegate: self, queue: nil) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
|
||||
|
// 发送当前蓝牙状态 |
||||
|
if let manager = centralManager { |
||||
|
let stateString = getBluetoothStateString(state: manager.state) |
||||
|
sendBluetoothStateEvent(state: stateString) |
||||
|
} |
||||
|
// 移除之前的监听器 |
||||
|
NotificationCenter.default.removeObserver(self) |
||||
|
// 添加新的监听器 |
||||
|
audioSessionManager.addRouteChangeListener(self, selector: #selector(handleRouteChange(_:))) |
||||
|
// BluetoothMediaButtonHelper.shared.startButtonListening() |
||||
|
} |
||||
|
|
||||
|
// 处理音频路由变化 |
||||
|
@objc private func handleRouteChange(_ notification: Notification) { |
||||
|
guard let userInfo = notification.userInfo, |
||||
|
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, |
||||
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 获取当前设备 |
||||
|
let currentDevices = getConnectedAudioDevices() |
||||
|
|
||||
|
// 检查原因 |
||||
|
switch reason { |
||||
|
case .newDeviceAvailable: |
||||
|
// 新设备已连接 |
||||
|
for device in currentDevices { |
||||
|
// 发送设备连接事件 |
||||
|
NSLog("发送设备连接事件 - 设备: \(device["name"] ?? "未知设备") [\(device["address"] ?? "未知地址")]") |
||||
|
sendDeviceConnectedEvent(device: device) |
||||
|
} |
||||
|
|
||||
|
case .oldDeviceUnavailable: |
||||
|
// 获取断开之前的路由 |
||||
|
if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { |
||||
|
// 过滤蓝牙相关设备 |
||||
|
for output in previousRoute.outputs where isBluetoothOutput(output.portType) { |
||||
|
let device: [String: String] = [ |
||||
|
"name": output.portName, |
||||
|
"address": cleanDeviceAddress(output.uid) |
||||
|
] |
||||
|
// 发送设备断开事件 |
||||
|
sendDeviceDisconnectedEvent(device: device) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
default: |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 检查是否为蓝牙输出 |
||||
|
private func isBluetoothOutput(_ portType: AVAudioSession.Port) -> Bool { |
||||
|
return portType == .bluetoothA2DP || portType == .bluetoothHFP || portType == .bluetoothLE |
||||
|
} |
||||
|
|
||||
|
// MARK: - 设备事件 |
||||
|
|
||||
|
// 发送设备连接事件 |
||||
|
private func sendDeviceConnectedEvent(device: [String: String]) { |
||||
|
guard let name = device["name"], let address = device["address"] else { return } |
||||
|
|
||||
|
let deviceMap: [String: Any] = [ |
||||
|
"name": name, |
||||
|
"address": address, |
||||
|
"type": "headset" |
||||
|
] |
||||
|
|
||||
|
let event: [String: Any] = [ |
||||
|
"type": "deviceConnected", |
||||
|
"device": deviceMap, |
||||
|
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
||||
|
] |
||||
|
|
||||
|
eventHandler?.sendEvent(event) |
||||
|
} |
||||
|
|
||||
|
// 发送设备断开事件 |
||||
|
private func sendDeviceDisconnectedEvent(device: [String: String]) { |
||||
|
guard let name = device["name"], let address = device["address"] else { return } |
||||
|
|
||||
|
let deviceMap: [String: Any] = [ |
||||
|
"name": name, |
||||
|
"address": address, |
||||
|
"type": "headset" |
||||
|
] |
||||
|
|
||||
|
let event: [String: Any] = [ |
||||
|
"type": "deviceDisconnected", |
||||
|
"device": deviceMap, |
||||
|
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
||||
|
] |
||||
|
|
||||
|
eventHandler?.sendEvent(event) |
||||
|
} |
||||
|
|
||||
|
// MARK: - 公共API |
||||
|
|
||||
|
// 获取当前连接的耳机设备 |
||||
|
@objc func getConnectedHeadsetDevices(_ completion: @escaping ([[String: String]]?, String?) -> Void) { |
||||
|
// 检查蓝牙状态 |
||||
|
if centralManager?.state != .poweredOn { |
||||
|
completion(nil, "蓝牙未启用") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
// 获取设备 |
||||
|
let devices = getConnectedAudioDevices() |
||||
|
completion(devices, nil) |
||||
|
} catch { |
||||
|
completion(nil, "获取设备失败: \(error.localizedDescription)") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取当前连接的音频设备 |
||||
|
private func getConnectedAudioDevices() -> [[String: String]] { |
||||
|
var result: [[String: String]] = [] |
||||
|
|
||||
|
// 获取当前音频路由 |
||||
|
let outputs = audioSessionManager.getCurrentRoute().outputs |
||||
|
|
||||
|
// 过滤蓝牙相关设备 |
||||
|
for output in outputs where isBluetoothOutput(output.portType) { |
||||
|
let device: [String: String] = [ |
||||
|
"name": output.portName, |
||||
|
"address": cleanDeviceAddress(output.uid) // 处理设备地址,移除后缀 |
||||
|
] |
||||
|
result.append(device) |
||||
|
} |
||||
|
|
||||
|
return result |
||||
|
} |
||||
|
|
||||
|
// 释放资源 |
||||
|
@objc func dispose() { |
||||
|
audioSessionManager.removeRouteChangeListener(self) |
||||
|
eventHandler = nil |
||||
|
} |
||||
|
|
||||
|
|
||||
|
|
||||
|
// 检查蓝牙是否启用 |
||||
|
@objc func isBluetoothEnabled() -> Bool { |
||||
|
return centralManager?.state == .poweredOn |
||||
|
} |
||||
|
|
||||
|
// 清理设备地址,移除后缀 |
||||
|
private func cleanDeviceAddress(_ address: String) -> String { |
||||
|
// 检查是否包含常见分隔符 |
||||
|
if let range = address.range(of: "-") ?? address.range(of: ":") ?? address.range(of: "_") { |
||||
|
// 只保留分隔符之前的部分 |
||||
|
return String(address[..<range.lowerBound]) |
||||
|
} |
||||
|
|
||||
|
// iOS设备UID有时会包含额外信息,尝试只保留标准MAC地址格式部分 |
||||
|
// MAC地址通常是12个十六进制字符,可能带有分隔符 |
||||
|
let hexPattern = "[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}|[0-9A-Fa-f]{12}" |
||||
|
if let regex = try? NSRegularExpression(pattern: hexPattern, options: []), |
||||
|
let match = regex.firstMatch(in: address, options: [], range: NSRange(location: 0, length: address.utf16.count)) { |
||||
|
let matchRange = match.range |
||||
|
if let range = Range(matchRange, in: address) { |
||||
|
return String(address[range]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return address |
||||
|
} |
||||
|
} |
||||
Binary file not shown.
Binary file not shown.
@ -1 +1,7 @@ |
|||||
|
#ifndef Runner_Bridging_Header_h |
||||
|
#define Runner_Bridging_Header_h |
||||
|
|
||||
#import "GeneratedPluginRegistrant.h" |
#import "GeneratedPluginRegistrant.h" |
||||
|
#import <MicrosoftCognitiveServicesSpeech/SPXSpeechApi.h> |
||||
|
|
||||
|
#endif /* Runner_Bridging_Header_h */ |
||||
|
|||||
@ -0,0 +1,565 @@ |
|||||
|
import Foundation |
||||
|
import UIKit |
||||
|
import AVFoundation |
||||
|
|
||||
|
/** |
||||
|
* 语音交互服务,iOS原生实现 |
||||
|
* |
||||
|
* 负责: |
||||
|
* 1) 监听蓝牙耳机按键 |
||||
|
* 2) 处理语音识别 |
||||
|
* 3) 与火山AI服务交互 |
||||
|
* 4) 文本转语音播放 |
||||
|
*/ |
||||
|
@available(iOS 13.0, *) |
||||
|
class VoiceInteractionService: NSObject { |
||||
|
// 常量 |
||||
|
private let TAG = "VoiceInteractionService" |
||||
|
private let RECOGNITION_TIMEOUT: TimeInterval = 8.0 |
||||
|
|
||||
|
// 单例 |
||||
|
static let shared = VoiceInteractionService() |
||||
|
|
||||
|
// 服务状态 |
||||
|
private var isRunning = false |
||||
|
private var isActive = false |
||||
|
private var isRecognitionActive = false |
||||
|
private var isTimeoutPaused = false |
||||
|
private var hasSpeechDetected = false |
||||
|
private var isTtsSpeaking = false |
||||
|
|
||||
|
// 事件处理器 |
||||
|
private weak var eventHandler: VoiceInteractionEventHandler? |
||||
|
|
||||
|
// 按键处理 |
||||
|
private var lastKeyEventTime: TimeInterval = 0 |
||||
|
private var keyEventCount = 0 |
||||
|
|
||||
|
// 活动时间 |
||||
|
private var lastActivityTime: TimeInterval = 0 |
||||
|
|
||||
|
// 当前用户输入 |
||||
|
private var currentUserInput = "" |
||||
|
private var lastUserMessage = "" |
||||
|
private var lastAssistantMessage = "" |
||||
|
|
||||
|
// 服务组件 |
||||
|
private var azureAsrHelper: AzureAsrHelper? |
||||
|
private var azureTtsHelper: AzureTtsHelper? |
||||
|
private var volcanoAIService: VolcanoAIService? |
||||
|
|
||||
|
|
||||
|
private var silenceTimer: Timer? |
||||
|
|
||||
|
// 系统提示词 |
||||
|
private let systemPrompt = """ |
||||
|
你是一个智能语音助手,能够简洁明了地回答用户的问题。 |
||||
|
请保持回答简短、准确,避免过长的解释。 |
||||
|
如果用户的问题不清楚,请礼貌地请求澄清。 |
||||
|
不要使用复杂的术语,除非用户明确要求。 |
||||
|
用户用语音和你交互。 |
||||
|
""" |
||||
|
|
||||
|
// 初始化方法 |
||||
|
private override init() { |
||||
|
super.init() |
||||
|
NSLog("%@: VoiceInteractionService 初始化中", TAG) |
||||
|
|
||||
|
|
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 启动服务 |
||||
|
* |
||||
|
* @param apiKey Azure语音服务API密钥 |
||||
|
* @param region Azure语音服务区域 |
||||
|
* @param volcanoKey 火山AI服务API密钥 |
||||
|
* @return 启动是否成功 |
||||
|
*/ |
||||
|
func start(azureKey: String, azureRegion: String, volcanoKey: String) -> Bool { |
||||
|
guard !isRunning else { |
||||
|
NSLog("%@: 服务已经在运行", TAG) |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
NSLog("%@: 启动服务中", TAG) |
||||
|
|
||||
|
// 重置状态 |
||||
|
resetState() |
||||
|
|
||||
|
// 初始化各个服务组件 |
||||
|
initServices(azureKey: azureKey, azureRegion: azureRegion, volcanoKey: volcanoKey) |
||||
|
|
||||
|
// 启动monitorService定时器 |
||||
|
silenceTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(monitorService), userInfo: nil, repeats: true) |
||||
|
NSLog("%@: monitorService定时器已启动", TAG) |
||||
|
|
||||
|
// 标记服务为运行状态 |
||||
|
isRunning = true |
||||
|
|
||||
|
NSLog("%@: 服务启动完成,等待蓝牙按键事件", TAG) |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 停止服务 |
||||
|
*/ |
||||
|
func stop() { |
||||
|
guard isRunning else { |
||||
|
NSLog("%@: 服务未运行", TAG) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
NSLog("%@: 停止服务中", TAG) |
||||
|
|
||||
|
// 停止monitorService定时器 |
||||
|
silenceTimer?.invalidate() |
||||
|
silenceTimer = nil |
||||
|
|
||||
|
// 停止语音识别 |
||||
|
if isRecognitionActive { |
||||
|
stopVoiceRecognition() |
||||
|
} |
||||
|
|
||||
|
// 停止TTS |
||||
|
stopCurrentTTS() |
||||
|
|
||||
|
// 释放服务实例 |
||||
|
azureAsrHelper?.dispose() |
||||
|
azureTtsHelper?.dispose() |
||||
|
|
||||
|
// 更新状态 |
||||
|
resetState() |
||||
|
isRunning = false |
||||
|
|
||||
|
NSLog("%@: 服务已停止", TAG) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取服务运行状态 |
||||
|
*/ |
||||
|
func isServiceRunning() -> Bool { |
||||
|
return isRunning |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 重置状态 |
||||
|
*/ |
||||
|
private func resetState() { |
||||
|
isActive = false |
||||
|
isRecognitionActive = false |
||||
|
isTimeoutPaused = false |
||||
|
hasSpeechDetected = false |
||||
|
isTtsSpeaking = false |
||||
|
lastUserMessage = "" |
||||
|
lastAssistantMessage = "" |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 初始化服务 |
||||
|
*/ |
||||
|
private func initServices(azureKey: String, azureRegion: String, volcanoKey: String) { |
||||
|
NSLog("%@: 初始化服务组件", TAG) |
||||
|
|
||||
|
// 初始化Azure ASR |
||||
|
azureAsrHelper = AzureAsrHelper() |
||||
|
if let helper = azureAsrHelper { |
||||
|
helper.setEventHandler({ [weak self] eventData in |
||||
|
// 处理ASR事件 |
||||
|
self?.handleAsrEvent(eventData: eventData) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 初始化Azure TTS |
||||
|
azureTtsHelper = AzureTtsHelper() |
||||
|
if let helper = azureTtsHelper { |
||||
|
helper.setEventHandler({ [weak self] eventData in |
||||
|
// 处理TTS事件 |
||||
|
self?.handleTtsEvent(eventData: eventData) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 初始化火山AI服务 |
||||
|
volcanoAIService = VolcanoAIService() |
||||
|
|
||||
|
// 配置Azure ASR |
||||
|
if !azureKey.isEmpty && !azureRegion.isEmpty { |
||||
|
azureAsrHelper?.initialize(speechSubscriptionKey: azureKey, serviceRegion: azureRegion, supportedLanguages: ["zh-CN"]) |
||||
|
NSLog("%@: Azure ASR初始化完成", TAG) |
||||
|
} else { |
||||
|
NSLog("%@: Azure配置信息不完整,无法初始化Azure ASR", TAG) |
||||
|
} |
||||
|
|
||||
|
// 配置Azure TTS |
||||
|
if !azureKey.isEmpty && !azureRegion.isEmpty { |
||||
|
azureTtsHelper?.initialize(speechSubscriptionKey: azureKey, serviceRegion: azureRegion, language: "zh-CN") |
||||
|
NSLog("%@: Azure TTS初始化完成", TAG) |
||||
|
} else { |
||||
|
NSLog("%@: Azure配置信息不完整,无法初始化Azure TTS", TAG) |
||||
|
} |
||||
|
|
||||
|
// 配置火山AI |
||||
|
if !volcanoKey.isEmpty { |
||||
|
volcanoAIService?.initialize(apiKey: volcanoKey) |
||||
|
NSLog("%@: 火山AI服务初始化完成", TAG) |
||||
|
} else { |
||||
|
NSLog("%@: 火山AI配置信息不完整,无法初始化火山AI服务", TAG) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 监控服务状态 |
||||
|
*/ |
||||
|
@objc private func monitorService() { |
||||
|
// 确保服务保持活跃状态 |
||||
|
if !isActive { |
||||
|
isActive = true |
||||
|
} |
||||
|
|
||||
|
// 检查语音识别状态 |
||||
|
if isRecognitionActive { |
||||
|
let currentTime = Date().timeIntervalSince1970 |
||||
|
let elapsedTime = currentTime - lastActivityTime |
||||
|
|
||||
|
// NSLog("%@: 已过去时间: %@, 是否检测到语音: %@, 是否TTS播放: %@", TAG, String(elapsedTime), String(hasSpeechDetected), String(isTtsSpeaking), String(elapsedTime)) |
||||
|
|
||||
|
// 如果超过超时时间没有检测到语音,且不在TTS播放中,暂停语音识别 |
||||
|
if !hasSpeechDetected && !isTtsSpeaking && elapsedTime >= RECOGNITION_TIMEOUT { |
||||
|
// NSLog("%@: 超过%@秒未检测到语音,停止识别", TAG, String(RECOGNITION_TIMEOUT)) |
||||
|
isTimeoutPaused = true |
||||
|
playNotification("没有听到您说话,已暂停对话。双击耳机按钮可重新开始。") |
||||
|
|
||||
|
// 停止语音识别并确保资源完全释放 |
||||
|
stopVoiceRecognition() |
||||
|
|
||||
|
// 清理识别状态 |
||||
|
isRecognitionActive = false |
||||
|
hasSpeechDetected = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 处理ASR事件 |
||||
|
*/ |
||||
|
private func handleAsrEvent(eventData: [String: Any]) { |
||||
|
if let type = eventData["type"] as? String { |
||||
|
// NSLog("%@: 处理ASR事件: %@", TAG, type) |
||||
|
|
||||
|
switch type { |
||||
|
case "recognizing": |
||||
|
if let text = eventData["text"] as? String, !text.isEmpty { |
||||
|
hasSpeechDetected = true |
||||
|
stopCurrentTTS() |
||||
|
updateLastActivityTime() |
||||
|
} else if let data = eventData["data"] as? [String: Any], |
||||
|
let text = data["text"] as? String, |
||||
|
!text.isEmpty { |
||||
|
hasSpeechDetected = true |
||||
|
stopCurrentTTS() |
||||
|
updateLastActivityTime() |
||||
|
} |
||||
|
|
||||
|
case "result": |
||||
|
var text: String? |
||||
|
|
||||
|
// 直接读取text或从data中读取 |
||||
|
if let directText = eventData["text"] as? String, !directText.isEmpty { |
||||
|
text = directText |
||||
|
} else if let data = eventData["data"] as? [String: Any], |
||||
|
let dataText = data["text"] as? String, |
||||
|
!dataText.isEmpty { |
||||
|
text = dataText |
||||
|
} |
||||
|
|
||||
|
if let finalText = text { |
||||
|
updateLastActivityTime() |
||||
|
|
||||
|
// 保存用户消息 |
||||
|
lastUserMessage = finalText |
||||
|
|
||||
|
processWithVolcanoAI(finalText) |
||||
|
} |
||||
|
|
||||
|
// 重置状态,继续识别 |
||||
|
hasSpeechDetected = false |
||||
|
|
||||
|
case "sessionStarted": |
||||
|
updateLastActivityTime() |
||||
|
|
||||
|
case "sessionStopped": |
||||
|
isRecognitionActive = false |
||||
|
|
||||
|
case "canceled": |
||||
|
isRecognitionActive = false |
||||
|
|
||||
|
case "error": |
||||
|
isRecognitionActive = false |
||||
|
if let message = eventData["message"] as? String { |
||||
|
NSLog("%@: ASR错误: %@", TAG, message) |
||||
|
} |
||||
|
playNotification("语音识别出错") |
||||
|
|
||||
|
default: |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 处理TTS事件 |
||||
|
*/ |
||||
|
private func handleTtsEvent(eventData: [String: Any]) { |
||||
|
if let type = eventData["type"] as? String { |
||||
|
// NSLog("%@: 处理TTS事件: %@", TAG, type) |
||||
|
|
||||
|
switch type { |
||||
|
case "started": |
||||
|
isTtsSpeaking = true |
||||
|
|
||||
|
case "completed": |
||||
|
isTtsSpeaking = false |
||||
|
updateLastActivityTime() |
||||
|
|
||||
|
case "canceled": |
||||
|
isTtsSpeaking = false |
||||
|
|
||||
|
case "error": |
||||
|
isTtsSpeaking = false |
||||
|
if let message = eventData["message"] as? String { |
||||
|
NSLog("%@: TTS错误: %@", TAG, message) |
||||
|
} |
||||
|
|
||||
|
default: |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 处理双击事件 |
||||
|
*/ |
||||
|
func handleMediaButtonAction() { |
||||
|
NSLog("%@: 处理媒体按钮事件", TAG) |
||||
|
|
||||
|
// 屏蔽短时间重复响应的问题 |
||||
|
let currentTime = Date().timeIntervalSince1970 |
||||
|
let timeDiff = currentTime - lastKeyEventTime |
||||
|
if timeDiff < 0.5 { |
||||
|
NSLog("%@: 短时间重复响应,忽略", TAG) |
||||
|
return |
||||
|
} |
||||
|
lastKeyEventTime = currentTime |
||||
|
|
||||
|
// 停止当前TTS播放 |
||||
|
stopCurrentTTS() |
||||
|
|
||||
|
// 检查应用是否在后台 |
||||
|
if UIApplication.shared.applicationState == .background { |
||||
|
NSLog("%@: 应用在后台,无法开始语音识别", TAG) |
||||
|
playNotification("请打开应用后重试!") |
||||
|
return |
||||
|
} |
||||
|
// 播放提示音 |
||||
|
playPrompt("我在!") |
||||
|
|
||||
|
// 重置超时暂停标志 |
||||
|
isTimeoutPaused = false |
||||
|
|
||||
|
// 启动或重置语音识别 |
||||
|
if !isRecognitionActive { |
||||
|
NSLog("%@: 语音识别未激活,开始启动", TAG) |
||||
|
|
||||
|
startVoiceRecognition() |
||||
|
} else { |
||||
|
NSLog("%@: 语音识别已激活,更新活动时间", TAG) |
||||
|
updateLastActivityTime() |
||||
|
hasSpeechDetected = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 开始语音识别 |
||||
|
*/ |
||||
|
private func startVoiceRecognition() { |
||||
|
if isRecognitionActive { return } |
||||
|
|
||||
|
|
||||
|
|
||||
|
// 通知Flutter语音识别已启动 |
||||
|
notifyVoiceRecognitionStarted() |
||||
|
|
||||
|
isActive = true |
||||
|
isRecognitionActive = true |
||||
|
hasSpeechDetected = false |
||||
|
updateLastActivityTime() |
||||
|
|
||||
|
if let asrHelper = azureAsrHelper { |
||||
|
let success = asrHelper.startContinuousRecognition() |
||||
|
if !success { |
||||
|
isRecognitionActive = false |
||||
|
NSLog("%@: 启动语音识别失败", TAG) |
||||
|
playNotification("启动语音识别失败") |
||||
|
} |
||||
|
} else { |
||||
|
isRecognitionActive = false |
||||
|
NSLog("%@: Azure ASR服务未初始化", TAG) |
||||
|
playNotification("语音识别服务未初始化") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 停止语音识别 |
||||
|
*/ |
||||
|
private func stopVoiceRecognition() { |
||||
|
if !isRecognitionActive { return } |
||||
|
|
||||
|
NSLog("%@: 停止语音识别", TAG) |
||||
|
|
||||
|
if let asrHelper = azureAsrHelper { |
||||
|
let success = asrHelper.stopContinuousRecognition() |
||||
|
if !success { |
||||
|
NSLog("%@: 停止语音识别失败", TAG) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
isRecognitionActive = false |
||||
|
hasSpeechDetected = false |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 使用火山AI处理语音识别结果 |
||||
|
*/ |
||||
|
private func processWithVolcanoAI(_ text: String) { |
||||
|
// 保存当前用户输入 |
||||
|
currentUserInput = text |
||||
|
|
||||
|
// 在后台线程处理 |
||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
||||
|
guard let self = self, let volcanoAIService = self.volcanoAIService else { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
// 创建消息 |
||||
|
let message = volcanoAIService.createUserMessage(content: text) |
||||
|
let messages = [message] |
||||
|
|
||||
|
// 发送请求 |
||||
|
let response = try volcanoAIService.sendMessage(messages: messages, systemPrompt: self.systemPrompt) |
||||
|
|
||||
|
// 保存助手回复 |
||||
|
self.lastAssistantMessage = response |
||||
|
|
||||
|
// 播放AI回复 |
||||
|
self.speakAIResponse(response) |
||||
|
|
||||
|
// 同步聊天记录到Flutter端 |
||||
|
self.notifyChatHistoryUpdated(userMessage: text, assistantMessage: response) |
||||
|
} catch { |
||||
|
NSLog("%@: AI处理出错: %@", self.TAG, error.localizedDescription) |
||||
|
self.playNotification("AI处理出错") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 播放AI回复 |
||||
|
*/ |
||||
|
private func speakAIResponse(_ text: String) { |
||||
|
isTtsSpeaking = true |
||||
|
azureTtsHelper?.speakText(text: text) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 播放提示音 |
||||
|
*/ |
||||
|
private func playPrompt(_ message: String) { |
||||
|
isTtsSpeaking = true |
||||
|
azureTtsHelper?.speakText(text: message) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 播放通知提示音 |
||||
|
*/ |
||||
|
private func playNotification(_ message: String) { |
||||
|
isTtsSpeaking = true |
||||
|
azureTtsHelper?.speakText(text: message) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 停止当前TTS播放 |
||||
|
*/ |
||||
|
private func stopCurrentTTS() { |
||||
|
if isTtsSpeaking { |
||||
|
azureTtsHelper?.stopSpeaking() |
||||
|
isTtsSpeaking = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新最后活动时间 |
||||
|
*/ |
||||
|
private func updateLastActivityTime() { |
||||
|
lastActivityTime = Date().timeIntervalSince1970 |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 暂停语音交互 |
||||
|
*/ |
||||
|
func pauseVoiceInteraction() { |
||||
|
NSLog("%@: 暂停语音交互", TAG) |
||||
|
|
||||
|
// 停止当前语音播放 |
||||
|
stopCurrentTTS() |
||||
|
|
||||
|
// 停止语音识别 |
||||
|
if isRecognitionActive { |
||||
|
stopVoiceRecognition() |
||||
|
} |
||||
|
|
||||
|
// 设置状态为暂停,但服务保持运行 |
||||
|
isTimeoutPaused = true |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 通知语音识别已开始 |
||||
|
*/ |
||||
|
private func notifyVoiceRecognitionStarted() { |
||||
|
// 发送识别开始事件 |
||||
|
sendEvent(type: "recognition_started", data: [:]) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 通知聊天历史更新 |
||||
|
*/ |
||||
|
private func notifyChatHistoryUpdated(userMessage: String, assistantMessage: String) { |
||||
|
// 发送聊天历史更新事件 |
||||
|
sendEvent(type: "chat_history_updated", data: [ |
||||
|
"agentId": "personal_assistant", |
||||
|
"userMessage": userMessage, |
||||
|
"assistantMessage": assistantMessage |
||||
|
]) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置事件处理器 |
||||
|
*/ |
||||
|
func setEventHandler(_ handler: VoiceInteractionEventHandler) { |
||||
|
self.eventHandler = handler |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发送事件到Flutter端 |
||||
|
*/ |
||||
|
private func sendEvent(type: String, data: [String: Any] = [:]) { |
||||
|
var eventData = data |
||||
|
eventData["type"] = type |
||||
|
eventData["timestamp"] = Int(Date().timeIntervalSince1970 * 1000) |
||||
|
|
||||
|
// 使用事件处理器发送事件 |
||||
|
eventHandler?.sendEvent(eventData) |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,391 @@ |
|||||
|
import Foundation |
||||
|
|
||||
|
/** |
||||
|
* 火山AI服务的iOS原生实现 |
||||
|
* |
||||
|
* 参考Android端的VolcanoAIService实现,提供同步和异步的API调用方式 |
||||
|
*/ |
||||
|
class VolcanoAIService { |
||||
|
private let TAG = "VolcanoAIService" |
||||
|
private let baseUrl = "https://ark.cn-beijing.volces.com/api/v3" |
||||
|
private let chatEndpoint = "/chat/completions" |
||||
|
private let session: URLSession |
||||
|
|
||||
|
private var apiKey: String = "" |
||||
|
private var isInitialized = false |
||||
|
|
||||
|
init() { |
||||
|
let config = URLSessionConfiguration.default |
||||
|
config.timeoutIntervalForRequest = 30.0 |
||||
|
config.timeoutIntervalForResource = 30.0 |
||||
|
self.session = URLSession(configuration: config) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 初始化火山AI服务 |
||||
|
* |
||||
|
* @param apiKey 火山AI API密钥 |
||||
|
* @return 初始化是否成功 |
||||
|
*/ |
||||
|
func initialize(apiKey: String) -> Bool { |
||||
|
self.apiKey = apiKey |
||||
|
isInitialized = !apiKey.isEmpty |
||||
|
|
||||
|
if !isInitialized { |
||||
|
NSLog("%@: 初始化失败:API key 不能为空", TAG) |
||||
|
} else { |
||||
|
NSLog("%@: 火山AI服务初始化成功", TAG) |
||||
|
} |
||||
|
|
||||
|
return isInitialized |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 生成个性化问候语 |
||||
|
* |
||||
|
* @param agentName 代理名称 |
||||
|
* @param systemPrompt 系统提示词 |
||||
|
* @param callback 回调函数,返回生成的问候语 |
||||
|
*/ |
||||
|
func generateGreeting(agentName: String, systemPrompt: String, callback: @escaping (String?, Error?) -> Void) { |
||||
|
let messages: [[String: Any]] = [ |
||||
|
["role": "system", "content": systemPrompt], |
||||
|
["role": "user", "content": "请用一句简短的话向我打个招呼,要符合你的身份和性格特点,不要超过18个字。"] |
||||
|
] |
||||
|
|
||||
|
let messagesData = try? JSONSerialization.data(withJSONObject: messages, options: []) |
||||
|
let messagesArray = try? JSONSerialization.jsonObject(with: messagesData!, options: []) as? [[String: Any]] |
||||
|
|
||||
|
var result = "" |
||||
|
|
||||
|
sendMessageStream(messages: messagesArray!, systemPrompt: systemPrompt, streamCallback: StreamCallback( |
||||
|
onToken: { token in |
||||
|
result.append(token) |
||||
|
}, |
||||
|
onComplete: { |
||||
|
callback(result, nil) |
||||
|
}, |
||||
|
onError: { error in |
||||
|
callback(nil, error) |
||||
|
} |
||||
|
)) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发送消息(非流式输出) |
||||
|
* |
||||
|
* @param messages 消息列表 |
||||
|
* @param systemPrompt 系统提示词 |
||||
|
* @return 返回AI的回复 |
||||
|
* @throws VolcanoAIError 如果API调用失败 |
||||
|
*/ |
||||
|
func sendMessage(messages: [[String: Any]], systemPrompt: String) throws -> String { |
||||
|
// 检查是否已初始化 |
||||
|
if !isInitialized || apiKey.isEmpty { |
||||
|
throw VolcanoAIError.serviceNotInitialized |
||||
|
} |
||||
|
|
||||
|
var fullMessages: [[String: Any]] = [ |
||||
|
["role": "system", "content": systemPrompt] |
||||
|
] |
||||
|
|
||||
|
fullMessages.append(contentsOf: messages) |
||||
|
|
||||
|
let requestBody: [String: Any] = [ |
||||
|
"model": "doubao-1-5-lite-32k-250115", |
||||
|
"messages": fullMessages, |
||||
|
"temperature": 0.7, |
||||
|
"max_tokens": 2000, |
||||
|
"stream": false |
||||
|
] |
||||
|
|
||||
|
guard let url = URL(string: "\(baseUrl)\(chatEndpoint)") else { |
||||
|
throw VolcanoAIError.invalidURL |
||||
|
} |
||||
|
|
||||
|
var request = URLRequest(url: url) |
||||
|
request.httpMethod = "POST" |
||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type") |
||||
|
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") |
||||
|
|
||||
|
do { |
||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: []) |
||||
|
} catch { |
||||
|
throw VolcanoAIError.invalidRequestBody |
||||
|
} |
||||
|
|
||||
|
let semaphore = DispatchSemaphore(value: 0) |
||||
|
var responseData: Data? |
||||
|
var responseError: Error? |
||||
|
|
||||
|
let task = session.dataTask(with: request) { data, response, error in |
||||
|
if let error = error { |
||||
|
responseError = VolcanoAIError.networkError(error.localizedDescription) |
||||
|
semaphore.signal() |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
guard let httpResponse = response as? HTTPURLResponse else { |
||||
|
responseError = VolcanoAIError.invalidResponse |
||||
|
semaphore.signal() |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if !(200...299).contains(httpResponse.statusCode) { |
||||
|
var errorMessage = "Unknown error occurred" |
||||
|
if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
||||
|
let error = json["error"] as? [String: Any], |
||||
|
let message = error["message"] as? String { |
||||
|
errorMessage = message |
||||
|
} |
||||
|
responseError = VolcanoAIError.apiError(errorMessage) |
||||
|
semaphore.signal() |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
responseData = data |
||||
|
semaphore.signal() |
||||
|
} |
||||
|
|
||||
|
task.resume() |
||||
|
_ = semaphore.wait(timeout: .distantFuture) |
||||
|
|
||||
|
if let error = responseError { |
||||
|
throw error |
||||
|
} |
||||
|
|
||||
|
guard let data = responseData else { |
||||
|
throw VolcanoAIError.emptyResponse |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], |
||||
|
let choices = json["choices"] as? [[String: Any]], |
||||
|
let firstChoice = choices.first, |
||||
|
let message = firstChoice["message"] as? [String: Any], |
||||
|
let content = message["content"] as? String else { |
||||
|
throw VolcanoAIError.invalidResponseFormat |
||||
|
} |
||||
|
|
||||
|
return content |
||||
|
} catch { |
||||
|
throw VolcanoAIError.invalidResponseFormat |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发送消息(流式输出) |
||||
|
* |
||||
|
* @param messages 消息列表 |
||||
|
* @param systemPrompt 系统提示词 |
||||
|
* @param streamCallback 回调函数,用于接收流式输出的结果 |
||||
|
*/ |
||||
|
func sendMessageStream(messages: [[String: Any]], systemPrompt: String, streamCallback: StreamCallback) { |
||||
|
// 检查是否已初始化 |
||||
|
if !isInitialized || apiKey.isEmpty { |
||||
|
streamCallback.onError(VolcanoAIError.serviceNotInitialized) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
var fullMessages: [[String: Any]] = [ |
||||
|
["role": "system", "content": systemPrompt] |
||||
|
] |
||||
|
|
||||
|
fullMessages.append(contentsOf: messages) |
||||
|
|
||||
|
let requestBody: [String: Any] = [ |
||||
|
"model": "doubao-1-5-lite-32k-250115", |
||||
|
"messages": fullMessages, |
||||
|
"temperature": 0.7, |
||||
|
"max_tokens": 2000, |
||||
|
"stream": true |
||||
|
] |
||||
|
|
||||
|
guard let url = URL(string: "\(baseUrl)\(chatEndpoint)") else { |
||||
|
streamCallback.onError(VolcanoAIError.invalidURL) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
var request = URLRequest(url: url) |
||||
|
request.httpMethod = "POST" |
||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type") |
||||
|
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") |
||||
|
request.addValue("text/event-stream", forHTTPHeaderField: "Accept") |
||||
|
|
||||
|
do { |
||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: []) |
||||
|
} catch { |
||||
|
streamCallback.onError(VolcanoAIError.invalidRequestBody) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let task = session.dataTask(with: request) { data, response, error in |
||||
|
if let error = error { |
||||
|
streamCallback.onError(VolcanoAIError.networkError(error.localizedDescription)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
guard let httpResponse = response as? HTTPURLResponse else { |
||||
|
streamCallback.onError(VolcanoAIError.invalidResponse) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if !(200...299).contains(httpResponse.statusCode) { |
||||
|
var errorMessage = "Unknown error occurred" |
||||
|
if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
||||
|
let error = json["error"] as? [String: Any], |
||||
|
let message = error["message"] as? String { |
||||
|
errorMessage = message |
||||
|
} |
||||
|
streamCallback.onError(VolcanoAIError.apiError(errorMessage)) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
guard let data = data else { |
||||
|
streamCallback.onError(VolcanoAIError.emptyResponse) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 处理SSE流数据 |
||||
|
let responseString = String(data: data, encoding: .utf8) ?? "" |
||||
|
let lines = responseString.components(separatedBy: "\n") |
||||
|
|
||||
|
for line in lines { |
||||
|
if line.isEmpty { continue } |
||||
|
|
||||
|
if line.hasPrefix("data: ") { |
||||
|
let data = String(line.dropFirst(6)) |
||||
|
if data == "[DONE]" { |
||||
|
streamCallback.onComplete() |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
do { |
||||
|
if let jsonData = data.data(using: .utf8), |
||||
|
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any], |
||||
|
let choices = json["choices"] as? [[String: Any]], |
||||
|
let firstChoice = choices.first, |
||||
|
let delta = firstChoice["delta"] as? [String: Any], |
||||
|
let content = delta["content"] as? String { |
||||
|
streamCallback.onToken(content) |
||||
|
} |
||||
|
} catch { |
||||
|
// 忽略无效的JSON数据 |
||||
|
continue |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
task.resume() |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 同步方式发送消息(流式输出) |
||||
|
* |
||||
|
* 注意:此方法会阻塞当前线程,请在后台线程中调用 |
||||
|
* |
||||
|
* @param messages 消息列表 |
||||
|
* @param systemPrompt 系统提示词 |
||||
|
* @return 返回完整的AI回复 |
||||
|
* @throws VolcanoAIError 如果API调用失败 |
||||
|
*/ |
||||
|
func sendMessageStreamSync(messages: [[String: Any]], systemPrompt: String) throws -> String { |
||||
|
var result = "" |
||||
|
let semaphore = DispatchSemaphore(value: 0) |
||||
|
var responseError: Error? |
||||
|
|
||||
|
sendMessageStream(messages: messages, systemPrompt: systemPrompt, streamCallback: StreamCallback( |
||||
|
onToken: { token in |
||||
|
result.append(token) |
||||
|
}, |
||||
|
onComplete: { |
||||
|
semaphore.signal() |
||||
|
}, |
||||
|
onError: { error in |
||||
|
responseError = error |
||||
|
semaphore.signal() |
||||
|
} |
||||
|
)) |
||||
|
|
||||
|
// 等待流式输出完成或出错 |
||||
|
_ = semaphore.wait(timeout: .now() + 60) |
||||
|
|
||||
|
if let error = responseError { |
||||
|
throw error |
||||
|
} |
||||
|
|
||||
|
return result |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建用户消息 |
||||
|
*/ |
||||
|
func createUserMessage(content: String) -> [String: Any] { |
||||
|
return ["role": "user", "content": content] |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建系统消息 |
||||
|
*/ |
||||
|
func createSystemMessage(content: String) -> [String: Any] { |
||||
|
return ["role": "system", "content": content] |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建助手消息 |
||||
|
*/ |
||||
|
func createAssistantMessage(content: String) -> [String: Any] { |
||||
|
return ["role": "assistant", "content": content] |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 流式输出回调类 |
||||
|
*/ |
||||
|
class StreamCallback { |
||||
|
let onToken: (String) -> Void |
||||
|
let onComplete: () -> Void |
||||
|
let onError: (Error) -> Void |
||||
|
|
||||
|
init(onToken: @escaping (String) -> Void, onComplete: @escaping () -> Void, onError: @escaping (Error) -> Void) { |
||||
|
self.onToken = onToken |
||||
|
self.onComplete = onComplete |
||||
|
self.onError = onError |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 火山AI错误枚举 |
||||
|
*/ |
||||
|
enum VolcanoAIError: Error { |
||||
|
case serviceNotInitialized |
||||
|
case invalidURL |
||||
|
case invalidRequestBody |
||||
|
case networkError(String) |
||||
|
case invalidResponse |
||||
|
case emptyResponse |
||||
|
case invalidResponseFormat |
||||
|
case apiError(String) |
||||
|
|
||||
|
var localizedDescription: String { |
||||
|
switch self { |
||||
|
case .serviceNotInitialized: |
||||
|
return "火山AI服务未初始化或API key为空,请先调用initialize方法" |
||||
|
case .invalidURL: |
||||
|
return "无效的URL" |
||||
|
case .invalidRequestBody: |
||||
|
return "无效的请求体" |
||||
|
case .networkError(let message): |
||||
|
return "网络错误: \(message)" |
||||
|
case .invalidResponse: |
||||
|
return "无效的响应" |
||||
|
case .emptyResponse: |
||||
|
return "空响应" |
||||
|
case .invalidResponseFormat: |
||||
|
return "无效的响应格式" |
||||
|
case .apiError(let message): |
||||
|
return "API错误: \(message)" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,29 @@ |
|||||
|
import 'package:meta/meta.dart'; |
||||
|
|
||||
|
/// 语音交互事件基类 |
||||
|
abstract class VoiceInteractionEvent { |
||||
|
final int timestamp; |
||||
|
|
||||
|
VoiceInteractionEvent({required this.timestamp}); |
||||
|
} |
||||
|
|
||||
|
/// 聊天历史事件 |
||||
|
class ChatHistoryEvent extends VoiceInteractionEvent { |
||||
|
final String agentId; |
||||
|
final String userMessage; |
||||
|
final String assistantMessage; |
||||
|
|
||||
|
ChatHistoryEvent({ |
||||
|
required this.agentId, |
||||
|
required this.userMessage, |
||||
|
required this.assistantMessage, |
||||
|
required int timestamp, |
||||
|
}) : super(timestamp: timestamp); |
||||
|
} |
||||
|
|
||||
|
/// 语音识别开始事件 |
||||
|
class RecognitionStartedEvent extends VoiceInteractionEvent { |
||||
|
RecognitionStartedEvent({ |
||||
|
required int timestamp, |
||||
|
}) : super(timestamp: timestamp); |
||||
|
} |
||||
@ -0,0 +1,58 @@ |
|||||
|
class Message { |
||||
|
final String role; // 'user' or 'assistant' |
||||
|
final String content; |
||||
|
final DateTime timestamp; |
||||
|
final bool isLoading; |
||||
|
|
||||
|
Message({ |
||||
|
required this.role, |
||||
|
required this.content, |
||||
|
required this.timestamp, |
||||
|
this.isLoading = false, |
||||
|
}); |
||||
|
|
||||
|
// 从JSON构造函数 |
||||
|
factory Message.fromJson(Map<String, dynamic> json) { |
||||
|
return Message( |
||||
|
role: json['role'] as String, |
||||
|
content: json['content'] as String, |
||||
|
timestamp: DateTime.parse(json['timestamp'] as String), |
||||
|
isLoading: json['isLoading'] as bool? ?? false, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 转换为JSON |
||||
|
Map<String, dynamic> toJson() { |
||||
|
return { |
||||
|
'role': role, |
||||
|
'content': content, |
||||
|
'timestamp': timestamp.toIso8601String(), |
||||
|
'isLoading': isLoading, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// 创建一个加载中的消息 |
||||
|
factory Message.loading() { |
||||
|
return Message( |
||||
|
role: 'assistant', |
||||
|
content: '', |
||||
|
timestamp: DateTime.now(), |
||||
|
isLoading: true, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 复制并修改 |
||||
|
Message copyWith({ |
||||
|
String? role, |
||||
|
String? content, |
||||
|
DateTime? timestamp, |
||||
|
bool? isLoading, |
||||
|
}) { |
||||
|
return Message( |
||||
|
role: role ?? this.role, |
||||
|
content: content ?? this.content, |
||||
|
timestamp: timestamp ?? this.timestamp, |
||||
|
isLoading: isLoading ?? this.isLoading, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,310 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:io' show Platform; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../core/utils/logger.dart'; |
||||
|
import 'voice_interaction_service.dart'; |
||||
|
/// 蓝牙媒体按钮事件类型 |
||||
|
enum MediaButtonType { |
||||
|
/// 播放/暂停按钮 |
||||
|
playPause, |
||||
|
|
||||
|
/// 未知按钮 |
||||
|
unknown |
||||
|
} |
||||
|
|
||||
|
/// 蓝牙媒体按钮事件 |
||||
|
class MediaButtonEvent { |
||||
|
/// 按钮类型 |
||||
|
final MediaButtonType buttonType; |
||||
|
|
||||
|
/// 事件发生时间戳 |
||||
|
final int timestamp; |
||||
|
|
||||
|
MediaButtonEvent({ |
||||
|
required this.buttonType, |
||||
|
required this.timestamp, |
||||
|
}); |
||||
|
|
||||
|
factory MediaButtonEvent.fromMap(Map<dynamic, dynamic> map) { |
||||
|
MediaButtonType buttonType; |
||||
|
|
||||
|
switch (map['buttonType']) { |
||||
|
case 'playPause': |
||||
|
buttonType = MediaButtonType.playPause; |
||||
|
break; |
||||
|
default: |
||||
|
// 将所有其他事件类型映射为 playPause |
||||
|
if (map['buttonType'] == 'play' || |
||||
|
map['buttonType'] == 'pause' || |
||||
|
map['buttonType'] == 'togglePlayPause') { |
||||
|
buttonType = MediaButtonType.playPause; |
||||
|
} else { |
||||
|
// 忽略其他类型(next, previous, seekForward, seekBackward) |
||||
|
buttonType = MediaButtonType.unknown; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return MediaButtonEvent( |
||||
|
buttonType: buttonType, |
||||
|
timestamp: map['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
String toString() { |
||||
|
switch (buttonType) { |
||||
|
case MediaButtonType.playPause: |
||||
|
return '蓝牙媒体按钮: 播放/暂停'; |
||||
|
case MediaButtonType.unknown: |
||||
|
return '蓝牙媒体按钮: 未知'; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 蓝牙媒体按钮服务 |
||||
|
class BluetoothMediaButtonService extends GetxService { |
||||
|
static BluetoothMediaButtonService get to => Get.find<BluetoothMediaButtonService>(); |
||||
|
|
||||
|
// 方法通道名称 |
||||
|
final MethodChannel _channel = const MethodChannel('com.deep_voice.bluetooth_media_button'); |
||||
|
|
||||
|
// 事件通道名称 |
||||
|
final EventChannel _eventChannel = const EventChannel('com.deep_voice.bluetooth_media_button_events'); |
||||
|
|
||||
|
/// 按钮事件流控制器 |
||||
|
final _buttonEventController = StreamController<MediaButtonEvent>.broadcast(); |
||||
|
|
||||
|
/// 按钮事件流 |
||||
|
Stream<MediaButtonEvent> get buttonEvents => _buttonEventController.stream; |
||||
|
|
||||
|
/// 是否已启用监听 |
||||
|
final _isListening = false.obs; |
||||
|
bool get isListening => _isListening.value; |
||||
|
|
||||
|
/// 是否正在加载 |
||||
|
final isLoading = false.obs; |
||||
|
|
||||
|
/// 错误信息 |
||||
|
final errorMessage = ''.obs; |
||||
|
|
||||
|
/// 按钮事件流订阅 |
||||
|
StreamSubscription? _buttonEventSubscription; |
||||
|
|
||||
|
/// 服务是否已初始化 |
||||
|
bool _isInitialized = false; |
||||
|
|
||||
|
/// 是否支持媒体按钮服务(iOS 13.0及以上) |
||||
|
bool get isSupported { |
||||
|
if (!Platform.isIOS) return true; // 非iOS平台默认支持 |
||||
|
|
||||
|
// 获取平台版本信息 |
||||
|
final String version = Platform.operatingSystemVersion; |
||||
|
|
||||
|
// 尝试提取iOS版本号 (格式如: "Version 14.5 (Build 18E182)") |
||||
|
try { |
||||
|
// 提取版本号 |
||||
|
final RegExp versionRegex = RegExp(r'Version\s+(\d+)\.(\d+)'); |
||||
|
final match = versionRegex.firstMatch(version); |
||||
|
|
||||
|
if (match != null) { |
||||
|
final int majorVersion = int.parse(match.group(1) ?? '0'); |
||||
|
return majorVersion >= 13; // iOS 13及以上支持 |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('解析iOS版本失败: $e'); |
||||
|
} |
||||
|
|
||||
|
// 无法确定版本时,假设不支持 |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/// 初始化服务 |
||||
|
BluetoothMediaButtonService() { |
||||
|
Logger.info('创建 BluetoothMediaButtonService 实例'); |
||||
|
} |
||||
|
|
||||
|
/// 初始化媒体按钮服务 |
||||
|
Future<bool> initialize() async { |
||||
|
if (_isInitialized) { |
||||
|
Logger.info('BluetoothMediaButtonService 已经初始化'); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
// 检查系统版本是否支持 |
||||
|
if (!isSupported) { |
||||
|
Logger.info('当前iOS版本不支持蓝牙媒体按钮功能(需要iOS 13.0+)'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
Logger.info('正在初始化 BluetoothMediaButtonService...'); |
||||
|
|
||||
|
// 设置事件监听器 |
||||
|
_setupButtonEventListener(); |
||||
|
|
||||
|
_isInitialized = true; |
||||
|
Logger.info('BluetoothMediaButtonService 初始化成功'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('初始化 BluetoothMediaButtonService 失败: $e'); |
||||
|
errorMessage.value = '初始化蓝牙媒体按钮服务失败'; |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置按钮事件监听器 |
||||
|
void _setupButtonEventListener() { |
||||
|
_buttonEventSubscription?.cancel(); |
||||
|
|
||||
|
// 设置事件通道监听器 |
||||
|
_buttonEventSubscription = _eventChannel |
||||
|
.receiveBroadcastStream() |
||||
|
.listen(_handleButtonEvent, onError: _handleEventStreamError); |
||||
|
|
||||
|
Logger.info('已设置蓝牙媒体按钮事件监听器'); |
||||
|
} |
||||
|
|
||||
|
/// 处理按钮事件 |
||||
|
void _handleButtonEvent(dynamic event) { |
||||
|
Logger.info('BluetoothMediaButtonService 收到原始事件: $event'); |
||||
|
|
||||
|
if (event is! Map) { |
||||
|
Logger.error('收到无效的蓝牙媒体按钮事件 (非Map类型): $event'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
final Map<dynamic, dynamic> eventMap = event; |
||||
|
|
||||
|
try { |
||||
|
if (eventMap['type'] == 'mediaButtonEvent') { |
||||
|
// 获取按钮类型 |
||||
|
final String buttonType = eventMap['buttonType'] as String? ?? 'unknown'; |
||||
|
|
||||
|
// 判断是否为playPause相关事件 |
||||
|
if (buttonType == 'playPause' || |
||||
|
buttonType == 'play' || |
||||
|
buttonType == 'pause' || |
||||
|
buttonType == 'togglePlayPause') { |
||||
|
|
||||
|
// 创建playPause事件并广播 |
||||
|
final buttonEvent = MediaButtonEvent( |
||||
|
buttonType: MediaButtonType.playPause, |
||||
|
timestamp: eventMap['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, |
||||
|
); |
||||
|
|
||||
|
|
||||
|
|
||||
|
// 广播事件 |
||||
|
_buttonEventController.add(buttonEvent); |
||||
|
|
||||
|
// 广播事件 |
||||
|
_buttonEventController.add(buttonEvent); |
||||
|
Logger.info('发送playPause按钮事件: $buttonEvent'); |
||||
|
} else { |
||||
|
// 忽略其他类型的按钮事件 |
||||
|
Logger.info('忽略非playPause按钮事件: $buttonType'); |
||||
|
} |
||||
|
} else { |
||||
|
Logger.warning('收到未知类型的事件: ${eventMap['type']}'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('处理蓝牙媒体按钮事件时出错: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理事件流错误 |
||||
|
void _handleEventStreamError(dynamic error) { |
||||
|
Logger.error('蓝牙媒体按钮事件流错误: $error'); |
||||
|
errorMessage.value = '蓝牙媒体按钮监听出错'; |
||||
|
} |
||||
|
|
||||
|
/// 开始监听蓝牙媒体按钮事件 |
||||
|
Future<bool> startButtonListening() async { |
||||
|
// 检查系统版本是否支持 |
||||
|
if (!isSupported) { |
||||
|
Logger.info('当前iOS版本不支持蓝牙媒体按钮功能(需要iOS 13.0+)'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (!_isInitialized && !(await initialize())) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (_isListening.value) { |
||||
|
Logger.info('已经在监听蓝牙媒体按钮事件'); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
isLoading.value = true; |
||||
|
|
||||
|
try { |
||||
|
final result = await _channel.invokeMethod<bool>('startButtonListening') ?? false; |
||||
|
|
||||
|
if (result) { |
||||
|
_isListening.value = true; |
||||
|
Logger.info('开始监听蓝牙媒体按钮事件成功'); |
||||
|
} else { |
||||
|
errorMessage.value = '开始监听蓝牙媒体按钮事件失败'; |
||||
|
Logger.error('开始监听蓝牙媒体按钮事件失败'); |
||||
|
} |
||||
|
|
||||
|
isLoading.value = false; |
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
isLoading.value = false; |
||||
|
errorMessage.value = '开始监听蓝牙媒体按钮事件出错: $e'; |
||||
|
Logger.error('开始监听蓝牙媒体按钮事件出错: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止监听蓝牙媒体按钮事件 |
||||
|
Future<bool> stopButtonListening() async { |
||||
|
if (!_isInitialized) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (!_isListening.value) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
isLoading.value = true; |
||||
|
|
||||
|
try { |
||||
|
final result = await _channel.invokeMethod<bool>('stopButtonListening') ?? false; |
||||
|
|
||||
|
if (result) { |
||||
|
_isListening.value = false; |
||||
|
Logger.info('停止监听蓝牙媒体按钮事件成功'); |
||||
|
} else { |
||||
|
errorMessage.value = '停止监听蓝牙媒体按钮事件失败'; |
||||
|
Logger.error('停止监听蓝牙媒体按钮事件失败'); |
||||
|
} |
||||
|
|
||||
|
isLoading.value = false; |
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
isLoading.value = false; |
||||
|
errorMessage.value = '停止监听蓝牙媒体按钮事件出错: $e'; |
||||
|
Logger.error('停止监听蓝牙媒体按钮事件出错: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 释放资源 |
||||
|
void dispose() { |
||||
|
_buttonEventSubscription?.cancel(); |
||||
|
_buttonEventSubscription = null; |
||||
|
_buttonEventController.close(); |
||||
|
_isInitialized = false; |
||||
|
_isListening.value = false; |
||||
|
Logger.info('BluetoothMediaButtonService 已释放'); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
dispose(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
} |
||||
@ -1,3 +0,0 @@ |
|||||
// 重新导出FlutterTtsService类 |
|
||||
// 这个文件作为兼容层,将speech_impl/flutter_tts_service.dart中的服务导出 |
|
||||
export 'speech_impl/flutter_tts_service.dart'; |
|
||||
@ -0,0 +1,340 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
import '../../../modules/chat/models/message_model.dart'; |
||||
|
import '../../models/events/voice_interaction_event.dart'; |
||||
|
import '../chat_history_service.dart'; |
||||
|
|
||||
|
/// Android语音交互服务 |
||||
|
/// |
||||
|
/// 该服务提供了与Android端的VoiceInteractionService.kt通信的接口, |
||||
|
/// 用于管理后台语音交互服务的生命周期和接收语音交互事件 |
||||
|
class AndroidVoiceInteractionService extends GetxService { |
||||
|
static AndroidVoiceInteractionService get to => Get.find(); |
||||
|
// 方法通道和事件通道 |
||||
|
static const MethodChannel _channel = MethodChannel('com.deep_voice.voice_interaction'); |
||||
|
static const EventChannel _eventChannel = EventChannel('com.deep_voice.voice_interaction_events'); |
||||
|
|
||||
|
// 服务状态 |
||||
|
final _isServiceRunning = false.obs; |
||||
|
bool get isServiceRunning => _isServiceRunning.value; |
||||
|
|
||||
|
// 事件流控制器 |
||||
|
StreamController<VoiceInteractionEvent>? _eventStreamController; |
||||
|
Stream<VoiceInteractionEvent>? _eventStream; |
||||
|
@override |
||||
|
Stream<VoiceInteractionEvent> get eventStream => _eventStream ?? Stream.empty(); |
||||
|
|
||||
|
// 事件通道状态 |
||||
|
StreamSubscription? _eventSubscription; |
||||
|
|
||||
|
// 配置信息 |
||||
|
late String _azureSpeechKey; |
||||
|
late String _azureSpeechRegion; |
||||
|
late String _volcanoAiApiKey; |
||||
|
|
||||
|
// 初始化状态标志 |
||||
|
static bool _isInitialized = false; |
||||
|
|
||||
|
// 当前会话历史 |
||||
|
final List<Map<String, String>> _messageHistory = []; |
||||
|
|
||||
|
// 服务ID |
||||
|
static const String _serviceId = 'android_assistant'; |
||||
|
|
||||
|
/// 构造函数 |
||||
|
AndroidVoiceInteractionService() { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
/// 从环境变量加载配置 |
||||
|
void _loadConfig() { |
||||
|
_azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
||||
|
_azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; |
||||
|
_volcanoAiApiKey = dotenv.env['VOLCANO_AI_API_KEY'] ?? ''; |
||||
|
|
||||
|
if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { |
||||
|
Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); |
||||
|
} |
||||
|
|
||||
|
if (_volcanoAiApiKey.isEmpty) { |
||||
|
Logger.warning('未找到火山 AI API 密钥。请在 .env 文件中设置 VOLCANO_AI_API_KEY'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置事件通道 |
||||
|
void _setupEventChannel() { |
||||
|
_eventSubscription = _eventChannel |
||||
|
.receiveBroadcastStream() |
||||
|
.listen((event) { |
||||
|
if (event is Map) { |
||||
|
final String eventType = event['type'] as String? ?? ''; |
||||
|
|
||||
|
// 直接处理事件,不再等待channelReady |
||||
|
_handleVoiceInteractionEvent(event); |
||||
|
} |
||||
|
}, onError: (error) { |
||||
|
Logger.error('语音交互事件通道错误: $error'); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 创建事件流 |
||||
|
void _createEventStream() { |
||||
|
_eventStreamController = StreamController<VoiceInteractionEvent>.broadcast(); |
||||
|
_eventStream = _eventStreamController?.stream; |
||||
|
} |
||||
|
|
||||
|
/// 初始化服务 |
||||
|
@override |
||||
|
Future<AndroidVoiceInteractionService> initialize() async { |
||||
|
try { |
||||
|
if (_isInitialized) { |
||||
|
return this; |
||||
|
} |
||||
|
_isInitialized = true; |
||||
|
|
||||
|
_loadConfig(); |
||||
|
_setupEventChannel(); |
||||
|
_createEventStream(); |
||||
|
// 检查服务是否正在运行 |
||||
|
await _checkServiceStatus(); |
||||
|
|
||||
|
await _startService(); |
||||
|
|
||||
|
Logger.info('语音交互服务初始化完成'); |
||||
|
return this; |
||||
|
} catch (e) { |
||||
|
Logger.error('语音交互服务初始化失败: $e'); |
||||
|
return this; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onWakeup() { |
||||
|
Logger.info('语音交互服务唤醒'); |
||||
|
} |
||||
|
|
||||
|
/// 检查服务状态 |
||||
|
Future<void> _checkServiceStatus() async { |
||||
|
try { |
||||
|
final bool isRunning = await _channel.invokeMethod('isVoiceInteractionServiceRunning') ?? false; |
||||
|
_isServiceRunning.value = isRunning; |
||||
|
Logger.info('语音交互服务状态: ${isRunning ? "运行中" : "未运行"}'); |
||||
|
} catch (e) { |
||||
|
Logger.error('检查语音交互服务状态失败: $e'); |
||||
|
_isServiceRunning.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 启动语音交互服务 |
||||
|
Future<bool> _startService() async { |
||||
|
if (_isServiceRunning.value) { |
||||
|
Logger.info('语音交互服务已经在运行'); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
final bool result = await _channel.invokeMethod('startVoiceInteractionService', { |
||||
|
'azure_speech_key': _azureSpeechKey, |
||||
|
'azure_speech_region': _azureSpeechRegion, |
||||
|
'volcano_ai_api_key': _volcanoAiApiKey, |
||||
|
}) ?? false; |
||||
|
|
||||
|
if (result) { |
||||
|
_isServiceRunning.value = true; |
||||
|
Logger.info('语音交互服务启动成功'); |
||||
|
} else { |
||||
|
Logger.error('语音交互服务启动失败'); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
Logger.error('启动语音交互服务失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止语音交互服务 |
||||
|
Future<bool> _stopService() async { |
||||
|
if (!_isServiceRunning.value) { |
||||
|
Logger.info('语音交互服务未运行'); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
final bool result = await _channel.invokeMethod('stopVoiceInteractionService') ?? false; |
||||
|
|
||||
|
if (result) { |
||||
|
_isServiceRunning.value = false; |
||||
|
Logger.info('语音交互服务停止成功'); |
||||
|
} else { |
||||
|
Logger.error('语音交互服务停止失败'); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
Logger.error('停止语音交互服务失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 暂停语音交互(停止TTS和语音识别,但保持服务运行) |
||||
|
@override |
||||
|
Future<bool> pauseVoiceInteraction() async { |
||||
|
if (!_isServiceRunning.value) { |
||||
|
Logger.info('语音交互服务未运行,无法暂停'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
final bool result = await _channel.invokeMethod('pauseVoiceInteraction') ?? false; |
||||
|
|
||||
|
if (result) { |
||||
|
Logger.info('语音交互暂停成功'); |
||||
|
} else { |
||||
|
Logger.error('语音交互暂停失败'); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
Logger.error('暂停语音交互失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理来自原生端的语音交互事件 |
||||
|
void _handleVoiceInteractionEvent(dynamic event) { |
||||
|
if (event is! Map || _eventStreamController == null) return; |
||||
|
|
||||
|
final Map<dynamic, dynamic> eventMap = event; |
||||
|
final String eventType = eventMap['type'] as String? ?? ''; |
||||
|
final int timestamp = eventMap['timestamp'] as int? ?? 0; |
||||
|
|
||||
|
// 添加时间戳日志,帮助调试 |
||||
|
Logger.info('收到原生端事件: $eventType, 时间戳: $timestamp, 当前时间: ${DateTime.now().millisecondsSinceEpoch}'); |
||||
|
|
||||
|
switch (eventType) { |
||||
|
case 'chatHistory': |
||||
|
final String agentId = eventMap['agentId'] as String? ?? ''; |
||||
|
final String userMessage = eventMap['userMessage'] as String? ?? ''; |
||||
|
final String assistantMessage = eventMap['assistantMessage'] as String? ?? ''; |
||||
|
|
||||
|
// 添加到消息历史 |
||||
|
if (userMessage.isNotEmpty) { |
||||
|
_messageHistory.add({'role': 'user', 'content': userMessage}); |
||||
|
_messageHistory.add({'role': 'assistant', 'content': assistantMessage}); |
||||
|
|
||||
|
// 保持历史记录在一定长度 |
||||
|
while (_messageHistory.length > 10) { |
||||
|
_messageHistory.removeAt(0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
_eventStreamController?.add(ChatHistoryEvent( |
||||
|
agentId: agentId, |
||||
|
userMessage: userMessage, |
||||
|
assistantMessage: assistantMessage, |
||||
|
timestamp: timestamp, |
||||
|
)); |
||||
|
|
||||
|
// 保存聊天记录 |
||||
|
_saveChatHistory(agentId, userMessage, assistantMessage, timestamp); |
||||
|
|
||||
|
Logger.info('收到聊天历史事件: agentId=$agentId'); |
||||
|
break; |
||||
|
|
||||
|
case 'recognitionStarted': |
||||
|
_eventStreamController?.add(RecognitionStartedEvent( |
||||
|
timestamp: timestamp, |
||||
|
)); |
||||
|
break; |
||||
|
|
||||
|
default: |
||||
|
Logger.warning('收到未知类型的语音交互事件: $eventType'); |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
// 清理资源 |
||||
|
_stopService(); |
||||
|
_eventSubscription?.cancel(); |
||||
|
_eventStreamController?.close(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
/// 保存聊天记录 |
||||
|
void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { |
||||
|
try { |
||||
|
// 获取ChatHistoryService实例 |
||||
|
final chatHistoryService = Get.find<ChatHistoryService>(); |
||||
|
|
||||
|
// 创建用户消息和助手消息 |
||||
|
final userMsg = Message( |
||||
|
role: 'user', |
||||
|
content: userMessage, |
||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), |
||||
|
); |
||||
|
|
||||
|
final assistantMsg = Message( |
||||
|
role: 'assistant', |
||||
|
content: assistantMessage, |
||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 |
||||
|
); |
||||
|
|
||||
|
// 加载现有历史记录 |
||||
|
final existingMessages = chatHistoryService.loadHistory(agentId); |
||||
|
|
||||
|
// 添加新消息 |
||||
|
existingMessages.addAll([userMsg, assistantMsg]); |
||||
|
|
||||
|
// 保存更新后的历史记录 |
||||
|
chatHistoryService.saveHistory(agentId, existingMessages); |
||||
|
} catch (e) { |
||||
|
Logger.error('保存聊天记录失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 是否处于活跃状态 |
||||
|
@override |
||||
|
bool isActive() { |
||||
|
return _isServiceRunning.value; |
||||
|
} |
||||
|
|
||||
|
/// 获取当前对话历史 |
||||
|
@override |
||||
|
List<Map<String, String>> getMessageHistory() { |
||||
|
return List<Map<String, String>>.from(_messageHistory); |
||||
|
} |
||||
|
|
||||
|
/// 获取当前服务ID |
||||
|
@override |
||||
|
String getServiceId() { |
||||
|
return _serviceId; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
/// 聊天历史事件 |
||||
|
class ChatHistoryEvent extends VoiceInteractionEvent { |
||||
|
final String agentId; |
||||
|
final String userMessage; |
||||
|
final String assistantMessage; |
||||
|
|
||||
|
ChatHistoryEvent({ |
||||
|
required this.agentId, |
||||
|
required this.userMessage, |
||||
|
required this.assistantMessage, |
||||
|
required int timestamp, |
||||
|
}) : super(timestamp: timestamp); |
||||
|
} |
||||
|
|
||||
|
/// 语音识别开始事件 |
||||
|
class RecognitionStartedEvent extends VoiceInteractionEvent { |
||||
|
RecognitionStartedEvent({ |
||||
|
required int timestamp, |
||||
|
}) : super(timestamp: timestamp); |
||||
|
} |
||||
@ -0,0 +1,467 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../asr_service.dart'; |
||||
|
import '../tts_service.dart'; |
||||
|
import '../volcano_ai_service.dart'; |
||||
|
import '../chat_history_service.dart'; |
||||
|
import '../../../modules/chat/models/message_model.dart'; |
||||
|
import '../../models/events/voice_interaction_event.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
|
||||
|
enum VoiceInteractionState { |
||||
|
idle, // 等待唤醒 |
||||
|
active, // 活跃状态 - 可以同时识别和播放 |
||||
|
} |
||||
|
|
||||
|
class IosVoiceInteractionService extends GetxService { |
||||
|
final AsrService _asrService = Get.find<AsrService>(); |
||||
|
final TtsService _ttsService = Get.find<TtsService>(); |
||||
|
final VolcanoAIService _aiService = Get.find<VolcanoAIService>(); |
||||
|
final ChatHistoryService _chatHistoryService = Get.find<ChatHistoryService>(); |
||||
|
|
||||
|
// 当前状态 |
||||
|
final Rx<VoiceInteractionState> state = VoiceInteractionState.idle.obs; |
||||
|
|
||||
|
// 是否识别到用户语音 |
||||
|
final RxBool isSpeechDetected = false.obs; |
||||
|
|
||||
|
// 是否正在处理AI响应 |
||||
|
final RxBool isProcessingAI = false.obs; |
||||
|
|
||||
|
// 是否正在播放TTS |
||||
|
final RxBool isSpeaking = false.obs; |
||||
|
|
||||
|
// 对话历史记录 |
||||
|
final List<Map<String, String>> _messageHistory = []; |
||||
|
|
||||
|
// 最大历史记录数 |
||||
|
static const int _maxHistorySize = 10; |
||||
|
|
||||
|
// 超时计时器 |
||||
|
Timer? _inactivityTimer; |
||||
|
|
||||
|
// 流订阅 |
||||
|
StreamSubscription? _recognitionSubscription; |
||||
|
|
||||
|
// 当前识别的文本 |
||||
|
String _currentRecognizedText = ''; |
||||
|
|
||||
|
// 事件流控制器 |
||||
|
final StreamController<VoiceInteractionEvent> _eventStreamController = |
||||
|
StreamController<VoiceInteractionEvent>.broadcast(); |
||||
|
|
||||
|
// 获取事件流 |
||||
|
@override |
||||
|
Stream<VoiceInteractionEvent> get eventStream => _eventStreamController.stream; |
||||
|
|
||||
|
// 标识符,用于存储聊天历史 |
||||
|
final String _agentId = 'ios_assistant'; |
||||
|
|
||||
|
/// 初始化服务 |
||||
|
@override |
||||
|
Future<IosVoiceInteractionService> initialize() async { |
||||
|
try { |
||||
|
// 保持idle状态,不自动启动语音识别 |
||||
|
Logger.info('iOS语音交互服务初始化完成,处于idle状态'); |
||||
|
} catch (e) { |
||||
|
Logger.error('iOS语音交互服务初始化失败: $e'); |
||||
|
} |
||||
|
|
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
// 处理唤醒事件 |
||||
|
void onWakeup() { |
||||
|
_ttsService.speakOnce('我在呢!'); |
||||
|
isSpeaking.value = true; |
||||
|
|
||||
|
if (state.value == VoiceInteractionState.idle) { |
||||
|
_activateInteraction(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 激活交互 |
||||
|
void _activateInteraction() { |
||||
|
// 设置状态为活跃 |
||||
|
state.value = VoiceInteractionState.active; |
||||
|
|
||||
|
// 开始连续监听 |
||||
|
_startContinuousListening(); |
||||
|
|
||||
|
// 启动不活动计时器 |
||||
|
_startInactivityTimer(); |
||||
|
|
||||
|
// 发送识别开始事件 |
||||
|
_sendRecognitionStartedEvent(); |
||||
|
} |
||||
|
|
||||
|
// 开始连续监听 |
||||
|
Future<void> _startContinuousListening() async { |
||||
|
// 停止之前的监听 |
||||
|
_recognitionSubscription?.cancel(); |
||||
|
|
||||
|
try { |
||||
|
// 开始连续识别 |
||||
|
final recognitionStream = await _asrService.startContinuousRecognition(); |
||||
|
|
||||
|
// 监听识别结果 |
||||
|
_recognitionSubscription = recognitionStream.listen( |
||||
|
_handleRecognitionEvent, |
||||
|
onError: (error) { |
||||
|
Logger.error('语音识别错误: $error'); |
||||
|
_startContinuousListening(); // 尝试重新启动 |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
Logger.info('开始连续语音识别'); |
||||
|
} catch (e) { |
||||
|
Logger.error('启动语音识别失败: $e'); |
||||
|
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 处理识别事件 |
||||
|
void _handleRecognitionEvent(RecognitionEvent event) { |
||||
|
// 更新活动时间 |
||||
|
_updateActivityTime(); |
||||
|
|
||||
|
switch (event.type) { |
||||
|
case RecognitionEventType.intermediateResult: |
||||
|
// 检测用户是否开始说话 |
||||
|
if (event.text.isNotEmpty && !isSpeechDetected.value) { |
||||
|
isSpeechDetected.value = true; |
||||
|
|
||||
|
// 用户开始讲话时,立即中断当前响应 |
||||
|
_interruptCurrentResponse("检测到用户开始讲话,中断当前响应"); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case RecognitionEventType.finalResult: |
||||
|
if (event.text.isNotEmpty) { |
||||
|
// 最终结果,处理用户输入 |
||||
|
_processUserInput(event.text); |
||||
|
} |
||||
|
// 重置语音检测状态 |
||||
|
isSpeechDetected.value = false; |
||||
|
break; |
||||
|
|
||||
|
case RecognitionEventType.sessionStarted: |
||||
|
Logger.info('语音识别会话开始'); |
||||
|
break; |
||||
|
|
||||
|
case RecognitionEventType.sessionStopped: |
||||
|
Logger.info('语音识别会话结束'); |
||||
|
|
||||
|
break; |
||||
|
|
||||
|
case RecognitionEventType.error: |
||||
|
case RecognitionEventType.canceled: |
||||
|
Logger.error('语音识别错误: ${event.error}'); |
||||
|
|
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 中断当前响应 |
||||
|
void _interruptCurrentResponse(String reason) { |
||||
|
Logger.info(reason); |
||||
|
|
||||
|
// 如果正在活跃状态,需要中断当前操作 |
||||
|
if (state.value == VoiceInteractionState.active) { |
||||
|
// 停止TTS播放 |
||||
|
_ttsService.stop(); |
||||
|
isSpeaking.value = false; |
||||
|
isProcessingAI.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 处理用户输入 |
||||
|
Future<void> _processUserInput(String text) async { |
||||
|
Logger.info('处理用户输入: $text'); |
||||
|
|
||||
|
// 设置为处理状态 |
||||
|
isProcessingAI.value = true; |
||||
|
|
||||
|
// 添加用户消息到历史记录 |
||||
|
final userMessage = {'role': 'user', 'content': text}; |
||||
|
_addToHistory(userMessage); |
||||
|
|
||||
|
// 创建用户消息对象 |
||||
|
final userMsg = Message( |
||||
|
role: 'user', |
||||
|
content: text, |
||||
|
timestamp: DateTime.now(), |
||||
|
); |
||||
|
|
||||
|
// AI响应处理标志 |
||||
|
bool isProcessingCancelled = false; |
||||
|
|
||||
|
try { |
||||
|
// 构建消息历史 |
||||
|
final messageHistory = _buildMessageHistory(); |
||||
|
|
||||
|
// 调用AI服务获取响应 |
||||
|
final responseStream = _aiService.sendMessageStream( |
||||
|
messages: messageHistory, |
||||
|
systemPrompt: "你是一个智能助手,请简明扼要地回答问题。", |
||||
|
); |
||||
|
|
||||
|
String aiResponse = ''; |
||||
|
|
||||
|
// 等待AI响应 |
||||
|
await for (final chunk in responseStream) { |
||||
|
// 检查是否被用户打断 |
||||
|
if (isSpeechDetected.value) { |
||||
|
// 用户开始说话,标记处理被取消 |
||||
|
isProcessingCancelled = true; |
||||
|
Logger.info('AI响应生成过程中被用户打断'); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
// 如果状态已改变(可能由其他原因导致),停止处理 |
||||
|
if (state.value != VoiceInteractionState.active) { |
||||
|
isProcessingCancelled = true; |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
aiResponse += chunk; |
||||
|
|
||||
|
// 立即播放当前文本块,实现边生成边播放 |
||||
|
_speakStreamResponse(chunk); |
||||
|
} |
||||
|
|
||||
|
// 如果处理被取消,不继续后续操作 |
||||
|
if (isProcessingCancelled) { |
||||
|
isProcessingAI.value = false; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 确保完整响应被处理 |
||||
|
if (aiResponse.isNotEmpty) { |
||||
|
// 将AI响应添加到历史记录 |
||||
|
final assistantMessage = {'role': 'assistant', 'content': aiResponse}; |
||||
|
_addToHistory(assistantMessage); |
||||
|
|
||||
|
// 创建助手消息对象 |
||||
|
final assistantMsg = Message( |
||||
|
role: 'assistant', |
||||
|
content: aiResponse, |
||||
|
timestamp: DateTime.now(), |
||||
|
); |
||||
|
|
||||
|
// 保存聊天历史 |
||||
|
_saveChatHistory(userMsg, assistantMsg); |
||||
|
|
||||
|
// 发送聊天历史事件 |
||||
|
_sendChatHistoryEvent(userMsg.content, assistantMsg.content); |
||||
|
|
||||
|
// 刷新TTS流,确保所有文本都被播放 |
||||
|
await _ttsService.flushStream(); |
||||
|
isSpeaking.value = false; |
||||
|
} |
||||
|
|
||||
|
// 处理完成 |
||||
|
isProcessingAI.value = false; |
||||
|
} catch (e) { |
||||
|
Logger.error('AI响应处理失败: $e'); |
||||
|
// 错误恢复 |
||||
|
isProcessingAI.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 构建用于AI服务的消息历史 |
||||
|
List<Map<String, String>> _buildMessageHistory() { |
||||
|
return List<Map<String, String>>.from(_messageHistory); |
||||
|
} |
||||
|
|
||||
|
// 添加消息到历史记录 |
||||
|
void _addToHistory(Map<String, String> message) { |
||||
|
_messageHistory.add(message); |
||||
|
|
||||
|
// 限制历史记录大小 |
||||
|
while (_messageHistory.length > _maxHistorySize) { |
||||
|
_messageHistory.removeAt(0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 播放流式响应 |
||||
|
void _speakStreamResponse(String chunk) { |
||||
|
if (chunk.isEmpty) return; |
||||
|
|
||||
|
// 更新播放状态 |
||||
|
isSpeaking.value = true; |
||||
|
|
||||
|
// 使用流式TTS播放 |
||||
|
_ttsService.speakStream(chunk); |
||||
|
|
||||
|
// 更新活动时间 |
||||
|
_updateActivityTime(); |
||||
|
} |
||||
|
|
||||
|
// 停止TTS |
||||
|
void _stopTts() { |
||||
|
_ttsService.stop(); |
||||
|
isSpeaking.value = false; |
||||
|
} |
||||
|
|
||||
|
// 更新最后活动时间 |
||||
|
DateTime _lastActivityTime = DateTime.now(); |
||||
|
void _updateActivityTime() { |
||||
|
_lastActivityTime = DateTime.now(); |
||||
|
} |
||||
|
|
||||
|
// 启动不活动计时器(长时间无交互会切换到空闲状态) |
||||
|
void _startInactivityTimer() { |
||||
|
_cancelInactivityTimer(); |
||||
|
|
||||
|
_inactivityTimer = Timer.periodic(Duration(seconds: 5), (timer) { |
||||
|
// 计算空闲时间 |
||||
|
final idleTime = DateTime.now().difference(_lastActivityTime).inSeconds; |
||||
|
|
||||
|
// 如果空闲超过30秒,且不在播放或检测到语音,切换到空闲 |
||||
|
if (idleTime > 8 && |
||||
|
!isSpeaking.value && |
||||
|
!isProcessingAI.value && |
||||
|
!isSpeechDetected.value) { |
||||
|
_resetToIdle(); |
||||
|
timer.cancel(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 取消不活动计时器 |
||||
|
void _cancelInactivityTimer() { |
||||
|
_inactivityTimer?.cancel(); |
||||
|
_inactivityTimer = null; |
||||
|
} |
||||
|
|
||||
|
// 重置到空闲状态 |
||||
|
void _resetToIdle() { |
||||
|
_ttsService.speakOnce('没有听到声音,暂停对话,双击耳机唤醒!'); |
||||
|
_cancelInactivityTimer(); |
||||
|
|
||||
|
// 停止TTS |
||||
|
_stopTts(); |
||||
|
|
||||
|
// 停止语音识别 |
||||
|
_recognitionSubscription?.cancel(); |
||||
|
_asrService.stopContinuousRecognition(); |
||||
|
|
||||
|
// 重置状态 |
||||
|
state.value = VoiceInteractionState.idle; |
||||
|
isSpeechDetected.value = false; |
||||
|
isProcessingAI.value = false; |
||||
|
isSpeaking.value = false; |
||||
|
|
||||
|
// 清空历史记录 |
||||
|
_messageHistory.clear(); |
||||
|
|
||||
|
Logger.info('语音交互已重置为空闲状态'); |
||||
|
} |
||||
|
|
||||
|
// 暂停语音交互 |
||||
|
@override |
||||
|
void pauseVoiceInteraction() { |
||||
|
if (state.value != VoiceInteractionState.idle) { |
||||
|
_stopTts(); |
||||
|
_recognitionSubscription?.cancel(); |
||||
|
_asrService.stopContinuousRecognition(); |
||||
|
|
||||
|
// 重置所有状态 |
||||
|
state.value = VoiceInteractionState.idle; |
||||
|
isSpeechDetected.value = false; |
||||
|
isProcessingAI.value = false; |
||||
|
isSpeaking.value = false; |
||||
|
|
||||
|
// 清空历史记录 |
||||
|
_messageHistory.clear(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 恢复语音交互 |
||||
|
void resumeVoiceInteraction() { |
||||
|
if (state.value == VoiceInteractionState.idle) { |
||||
|
_activateInteraction(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 手动停止交互 |
||||
|
void stopInteraction() { |
||||
|
_resetToIdle(); |
||||
|
} |
||||
|
|
||||
|
// 获取当前状态 |
||||
|
VoiceInteractionState getCurrentState() { |
||||
|
return state.value; |
||||
|
} |
||||
|
|
||||
|
// 是否处于活跃状态 |
||||
|
@override |
||||
|
bool isActive() { |
||||
|
return state.value != VoiceInteractionState.idle; |
||||
|
} |
||||
|
|
||||
|
// 获取当前对话历史 |
||||
|
@override |
||||
|
List<Map<String, String>> getMessageHistory() { |
||||
|
return List<Map<String, String>>.from(_messageHistory); |
||||
|
} |
||||
|
|
||||
|
// 获取当前服务ID |
||||
|
@override |
||||
|
String getServiceId() { |
||||
|
return _agentId; |
||||
|
} |
||||
|
|
||||
|
// 释放资源 |
||||
|
@override |
||||
|
void onClose() { |
||||
|
_cancelInactivityTimer(); |
||||
|
_recognitionSubscription?.cancel(); |
||||
|
_resetToIdle(); |
||||
|
_eventStreamController.close(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
// 保存聊天历史 |
||||
|
void _saveChatHistory(Message userMsg, Message assistantMsg) { |
||||
|
try { |
||||
|
// 加载现有历史记录 |
||||
|
final existingMessages = _chatHistoryService.loadHistory(_agentId); |
||||
|
|
||||
|
// 添加新消息 |
||||
|
existingMessages.addAll([userMsg, assistantMsg]); |
||||
|
|
||||
|
// 保存更新后的历史记录 |
||||
|
_chatHistoryService.saveHistory(_agentId, existingMessages); |
||||
|
|
||||
|
Logger.info('保存聊天历史记录成功'); |
||||
|
} catch (e) { |
||||
|
Logger.error('保存聊天历史记录失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 发送聊天历史事件 |
||||
|
void _sendChatHistoryEvent(String userMessage, String assistantMessage) { |
||||
|
final event = ChatHistoryEvent( |
||||
|
agentId: _agentId, |
||||
|
userMessage: userMessage, |
||||
|
assistantMessage: assistantMessage, |
||||
|
timestamp: DateTime.now().millisecondsSinceEpoch, |
||||
|
); |
||||
|
|
||||
|
_eventStreamController.add(event); |
||||
|
Logger.info('已发送聊天历史事件'); |
||||
|
} |
||||
|
|
||||
|
// 发送识别开始事件 |
||||
|
void _sendRecognitionStartedEvent() { |
||||
|
final event = RecognitionStartedEvent( |
||||
|
timestamp: DateTime.now().millisecondsSinceEpoch, |
||||
|
); |
||||
|
|
||||
|
_eventStreamController.add(event); |
||||
|
Logger.info('已发送识别开始事件'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -1,331 +1,275 @@ |
|||||
import 'dart:async'; |
import 'dart:async'; |
||||
import 'package:flutter/services.dart'; |
import 'package:flutter/services.dart'; |
||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import '../../core/utils/logger.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
import '../models/events/voice_interaction_event.dart'; |
||||
|
import '../../core/utils/logger.dart'; |
||||
import '../../modules/chat/models/message_model.dart'; |
import '../../modules/chat/models/message_model.dart'; |
||||
import 'chat_history_service.dart'; |
import 'chat_history_service.dart'; |
||||
import 'speech_impl/azure_asr_service.dart'; |
|
||||
import 'speech_impl/azure_tts_service.dart'; |
|
||||
import 'asr_service.dart'; |
|
||||
import 'tts_service.dart'; |
|
||||
|
|
||||
/// 语音交互服务 |
/// 语音交互服务接口 |
||||
/// |
/// |
||||
/// 该服务提供了与Android端的VoiceInteractionService.kt通信的接口, |
/// 管理与平台原生语音交互服务的通信,提供统一的接口供应用使用 |
||||
/// 用于管理后台语音交互服务的生命周期和接收语音交互事件 |
|
||||
class VoiceInteractionService extends GetxService { |
class VoiceInteractionService extends GetxService { |
||||
static VoiceInteractionService get to => Get.find(); |
static VoiceInteractionService get to => Get.find<VoiceInteractionService>(); |
||||
// 方法通道和事件通道 |
|
||||
|
// 方法通道 |
||||
static const MethodChannel _channel = MethodChannel('com.deep_voice.voice_interaction'); |
static const MethodChannel _channel = MethodChannel('com.deep_voice.voice_interaction'); |
||||
|
|
||||
|
// 事件通道 |
||||
static const EventChannel _eventChannel = EventChannel('com.deep_voice.voice_interaction_events'); |
static const EventChannel _eventChannel = EventChannel('com.deep_voice.voice_interaction_events'); |
||||
|
|
||||
// 服务状态 |
// 服务状态 |
||||
final _isServiceRunning = false.obs; |
final _isServiceRunning = false.obs; |
||||
bool get isServiceRunning => _isServiceRunning.value; |
bool get isServiceRunning => _isServiceRunning.value; |
||||
|
|
||||
// 事件流控制器 |
// 流控制器 |
||||
StreamController<VoiceInteractionEvent>? _eventStreamController; |
final _eventStreamController = StreamController<VoiceInteractionEvent>.broadcast(); |
||||
Stream<VoiceInteractionEvent>? _eventStream; |
|
||||
Stream<VoiceInteractionEvent>? get eventStream => _eventStream; |
// 事件流 |
||||
|
Stream<VoiceInteractionEvent> get eventStream => _eventStreamController.stream; |
||||
|
|
||||
// 事件通道状态 |
// 事件通道订阅 |
||||
bool _isEventChannelReady = false; |
|
||||
Completer<void>? _eventChannelReadyCompleter; |
|
||||
StreamSubscription? _eventSubscription; |
StreamSubscription? _eventSubscription; |
||||
|
|
||||
|
// 标记是否初始化 |
||||
|
bool _isInitialized = false; |
||||
|
|
||||
// 配置信息 |
// 配置信息 |
||||
late String _azureSpeechKey; |
late String _azureSpeechKey; |
||||
late String _azureSpeechRegion; |
late String _azureSpeechRegion; |
||||
late String _volcanoAiApiKey; |
late String _volcanoAiKey; |
||||
|
|
||||
// 初始化状态标志 |
// 聊天历史服务 |
||||
static bool _isInitialized = false; |
late final ChatHistoryService _chatHistoryService; |
||||
|
|
||||
/// 构造函数 |
|
||||
VoiceInteractionService() { |
|
||||
|
|
||||
|
/// 设置事件通道 |
||||
|
void _setupEventChannel() { |
||||
|
_eventSubscription = _eventChannel |
||||
|
.receiveBroadcastStream() |
||||
|
.listen(_handleVoiceInteractionEvent, onError: (error) { |
||||
|
Logger.error('语音交互事件通道错误: $error'); |
||||
|
}); |
||||
} |
} |
||||
|
|
||||
/// 从环境变量加载配置 |
/// 从环境变量加载配置 |
||||
void _loadConfig() { |
void _loadConfig() { |
||||
_azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
_azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
||||
_azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; |
_azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; |
||||
_volcanoAiApiKey = dotenv.env['VOLCANO_AI_API_KEY'] ?? ''; |
_volcanoAiKey = dotenv.env['VOLCANO_AI_API_KEY'] ?? ''; |
||||
|
|
||||
if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { |
if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { |
||||
Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); |
Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); |
||||
} |
} |
||||
|
|
||||
if (_volcanoAiApiKey.isEmpty) { |
if (_volcanoAiKey.isEmpty) { |
||||
Logger.warning('未找到火山 AI API 密钥。请在 .env 文件中设置 VOLCANO_AI_API_KEY'); |
Logger.warning('未找到火山 AI API 密钥。请在 .env 文件中设置 VOLCANO_AI_API_KEY'); |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 设置事件通道 |
/// 处理来自原生层的事件 |
||||
void _setupEventChannel() { |
void _handleVoiceInteractionEvent(dynamic event) { |
||||
_eventChannelReadyCompleter = Completer<void>(); |
if (event is! Map) return; |
||||
|
|
||||
_eventSubscription = _eventChannel |
final eventMap = event as Map<dynamic, dynamic>; |
||||
.receiveBroadcastStream() |
final String eventType = eventMap['type'] as String? ?? ''; |
||||
.listen((event) { |
// final int timestamp = eventMap['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch; |
||||
if (event is Map) { |
|
||||
final String eventType = event['type'] as String? ?? ''; |
|
||||
|
|
||||
// 处理通道准备好的事件 |
|
||||
if (eventType == 'channelReady') { |
|
||||
_isEventChannelReady = true; |
|
||||
if (!_eventChannelReadyCompleter!.isCompleted) { |
|
||||
_eventChannelReadyCompleter!.complete(); |
|
||||
} |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
_handleVoiceInteractionEvent(event); |
|
||||
} |
|
||||
}, onError: (error) { |
|
||||
Logger.error('语音交互事件通道错误: $error'); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/// 创建事件流 |
Logger.info('收到语音交互事件: $eventType'); |
||||
void _createEventStream() { |
|
||||
_eventStreamController = StreamController<VoiceInteractionEvent>.broadcast(); |
|
||||
_eventStream = _eventStreamController?.stream; |
|
||||
} |
|
||||
|
|
||||
/// 等待事件通道准备好 |
switch (eventType) { |
||||
Future<bool> _waitForEventChannel({Duration timeout = const Duration(seconds: 5)}) async { |
case 'recognition_started': |
||||
if (_isEventChannelReady) return true; |
// 语音识别开始事件 |
||||
|
final recognitionEvent = RecognitionStartedEvent( |
||||
|
timestamp: DateTime.now().millisecondsSinceEpoch, |
||||
|
); |
||||
|
_eventStreamController.add(recognitionEvent); |
||||
|
break; |
||||
|
|
||||
try { |
case 'chat_history_updated': |
||||
await _eventChannelReadyCompleter!.future.timeout(timeout); |
// 聊天历史更新事件 |
||||
return true; |
final String agentId = eventMap['agentId'] as String? ?? ''; |
||||
} on TimeoutException { |
final String userMessage = eventMap['userMessage'] as String? ?? ''; |
||||
Logger.error('等待语音交互事件通道准备好超时'); |
final String assistantMessage = eventMap['assistantMessage'] as String? ?? ''; |
||||
return false; |
|
||||
|
final chatHistoryEvent = ChatHistoryEvent( |
||||
|
agentId: agentId, |
||||
|
userMessage: userMessage, |
||||
|
assistantMessage: assistantMessage, |
||||
|
timestamp: DateTime.now().millisecondsSinceEpoch, |
||||
|
); |
||||
|
_eventStreamController.add(chatHistoryEvent); |
||||
|
|
||||
|
// 保存聊天历史到ChatHistoryService |
||||
|
_saveChatHistory(agentId, userMessage, assistantMessage, DateTime.now().millisecondsSinceEpoch); |
||||
|
break; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 初始化服务 |
/// 保存聊天历史 |
||||
Future<VoiceInteractionService> initialize() async { |
void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { |
||||
try { |
try { |
||||
if (_isInitialized) { |
// 检查参数有效性 |
||||
return this; |
if (userMessage.isEmpty) { |
||||
|
return; |
||||
} |
} |
||||
_isInitialized = true; |
|
||||
|
|
||||
_loadConfig(); |
// 创建用户消息和助手消息 |
||||
_setupEventChannel(); |
final userMsg = Message( |
||||
_createEventStream(); |
role: 'user', |
||||
// 检查服务是否正在运行 |
content: userMessage, |
||||
await _checkServiceStatus(); |
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), |
||||
|
); |
||||
|
|
||||
// 等待事件通道准备好 |
final assistantMsg = Message( |
||||
await _waitForEventChannel(); |
role: 'assistant', |
||||
|
content: assistantMessage, |
||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 |
||||
|
); |
||||
|
|
||||
Logger.info('语音交互服务初始化完成'); |
// 加载现有历史记录 |
||||
return this; |
final existingMessages = _chatHistoryService.loadHistory(agentId); |
||||
|
|
||||
|
// 添加新消息 |
||||
|
existingMessages.addAll([userMsg, assistantMsg]); |
||||
|
|
||||
|
// 保存更新后的历史记录 |
||||
|
_chatHistoryService.saveHistory(agentId, existingMessages); |
||||
|
|
||||
|
Logger.info('已保存聊天历史: agentId=$agentId'); |
||||
} catch (e) { |
} catch (e) { |
||||
Logger.error('语音交互服务初始化失败: $e'); |
Logger.error('保存聊天历史失败: $e'); |
||||
return this; |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 检查服务状态 |
/// 初始化服务 |
||||
Future<void> _checkServiceStatus() async { |
Future<bool> initialize() async { |
||||
|
if (_isInitialized) return true; |
||||
|
|
||||
try { |
try { |
||||
final bool isRunning = await _channel.invokeMethod('isVoiceInteractionServiceRunning') ?? false; |
Logger.info('正在初始化语音交互服务...'); |
||||
_isServiceRunning.value = isRunning; |
|
||||
Logger.info('语音交互服务状态: ${isRunning ? "运行中" : "未运行"}'); |
// 获取聊天历史服务 |
||||
|
try { |
||||
|
_chatHistoryService = Get.find<ChatHistoryService>(); |
||||
|
} catch (e) { |
||||
|
Logger.warning('获取ChatHistoryService失败,将创建新实例'); |
||||
|
_chatHistoryService = Get.put(ChatHistoryService()); |
||||
|
} |
||||
|
|
||||
|
// 设置事件通道 |
||||
|
_setupEventChannel(); |
||||
|
|
||||
|
// 加载配置 |
||||
|
_loadConfig(); |
||||
|
|
||||
|
// 检查服务是否已在运行 |
||||
|
final bool running = await checkServiceStatus(); |
||||
|
|
||||
|
// 如果服务未运行,启动服务 |
||||
|
if (!running) { |
||||
|
// 启动语音交互服务 |
||||
|
final success = await startService(); |
||||
|
if (!success) { |
||||
|
Logger.error('语音交互服务启动失败'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
_isInitialized = true; |
||||
|
Logger.info('语音交互服务初始化完成'); |
||||
|
return true; |
||||
} catch (e) { |
} catch (e) { |
||||
Logger.error('检查语音交互服务状态失败: $e'); |
Logger.error('语音交互服务初始化失败: $e'); |
||||
_isServiceRunning.value = false; |
return false; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 启动语音交互服务 |
/// 启动语音交互服务 |
||||
Future<bool> startService() async { |
Future<bool> startService() async { |
||||
if (_isServiceRunning.value) { |
|
||||
Logger.info('语音交互服务已经在运行'); |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
try { |
try { |
||||
final bool result = await _channel.invokeMethod('startVoiceInteractionService', { |
Logger.info('启动语音交互服务...'); |
||||
|
|
||||
|
// 使用已加载的配置信息 |
||||
|
final result = await _channel.invokeMethod<bool>('startService', { |
||||
'azure_speech_key': _azureSpeechKey, |
'azure_speech_key': _azureSpeechKey, |
||||
'azure_speech_region': _azureSpeechRegion, |
'azure_speech_region': _azureSpeechRegion, |
||||
'volcano_ai_api_key': _volcanoAiApiKey, |
'volcano_ai_api_key': _volcanoAiKey, |
||||
}) ?? false; |
}) ?? false; |
||||
|
|
||||
if (result) { |
if (result) { |
||||
_isServiceRunning.value = true; |
_isServiceRunning.value = true; |
||||
Logger.info('语音交互服务启动成功'); |
Logger.info('语音交互服务已启动'); |
||||
} else { |
} else { |
||||
Logger.error('语音交互服务启动失败'); |
Logger.error('启动语音交互服务失败'); |
||||
} |
} |
||||
|
|
||||
return result; |
return result; |
||||
} catch (e) { |
} catch (e) { |
||||
Logger.error('启动语音交互服务失败: $e'); |
Logger.error('启动语音交互服务时发生错误: $e'); |
||||
return false; |
return false; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 停止语音交互服务 |
/// 停止语音交互服务 |
||||
Future<bool> stopService() async { |
Future<bool> stopService() async { |
||||
if (!_isServiceRunning.value) { |
|
||||
Logger.info('语音交互服务未运行'); |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
try { |
try { |
||||
final bool result = await _channel.invokeMethod('stopVoiceInteractionService') ?? false; |
Logger.info('停止语音交互服务...'); |
||||
|
|
||||
|
final result = await _channel.invokeMethod<bool>('stopService') ?? false; |
||||
|
|
||||
if (result) { |
if (result) { |
||||
_isServiceRunning.value = false; |
_isServiceRunning.value = false; |
||||
Logger.info('语音交互服务停止成功'); |
Logger.info('语音交互服务已停止'); |
||||
} else { |
} else { |
||||
Logger.error('语音交互服务停止失败'); |
Logger.error('停止语音交互服务失败'); |
||||
} |
} |
||||
|
|
||||
return result; |
return result; |
||||
} catch (e) { |
} catch (e) { |
||||
Logger.error('停止语音交互服务失败: $e'); |
Logger.error('停止语音交互服务时发生错误: $e'); |
||||
return false; |
return false; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 暂停语音交互(停止TTS和语音识别,但保持服务运行) |
/// 检查服务是否运行 |
||||
Future<bool> pauseVoiceInteraction() async { |
Future<bool> checkServiceStatus() async { |
||||
if (!_isServiceRunning.value) { |
try { |
||||
Logger.info('语音交互服务未运行,无法暂停'); |
final bool result = await _channel.invokeMethod<bool>('isServiceRunning') ?? false; |
||||
|
_isServiceRunning.value = result; |
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
Logger.error('检查服务状态时发生错误: $e'); |
||||
return false; |
return false; |
||||
} |
} |
||||
|
} |
||||
|
|
||||
|
/// 暂停语音交互 |
||||
|
Future<bool> pauseVoiceInteraction() async { |
||||
try { |
try { |
||||
final bool result = await _channel.invokeMethod('pauseVoiceInteraction') ?? false; |
Logger.info('暂停后台语音交互...'); |
||||
|
|
||||
|
final result = await _channel.invokeMethod<bool>('pauseVoiceInteraction') ?? false; |
||||
|
|
||||
if (result) { |
if (result) { |
||||
Logger.info('语音交互暂停成功'); |
Logger.info('语音交互已暂停'); |
||||
} else { |
} else { |
||||
Logger.error('语音交互暂停失败'); |
Logger.error('暂停语音交互失败'); |
||||
} |
} |
||||
|
|
||||
return result; |
return result; |
||||
} catch (e) { |
} catch (e) { |
||||
Logger.error('暂停语音交互失败: $e'); |
Logger.error('暂停语音交互时发生错误: $e'); |
||||
return false; |
return false; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 处理来自原生端的语音交互事件 |
/// 资源释放 |
||||
void _handleVoiceInteractionEvent(dynamic event) { |
|
||||
if (event is! Map || _eventStreamController == null) return; |
|
||||
|
|
||||
final Map<dynamic, dynamic> eventMap = event; |
|
||||
final String eventType = eventMap['type'] as String? ?? ''; |
|
||||
final int timestamp = eventMap['timestamp'] as int? ?? 0; |
|
||||
|
|
||||
// 添加时间戳日志,帮助调试 |
|
||||
Logger.info('收到原生端事件: $eventType, 时间戳: $timestamp, 当前时间: ${DateTime.now().millisecondsSinceEpoch}'); |
|
||||
|
|
||||
switch (eventType) { |
|
||||
case 'chatHistory': |
|
||||
final String agentId = eventMap['agentId'] as String? ?? ''; |
|
||||
final String userMessage = eventMap['userMessage'] as String? ?? ''; |
|
||||
final String assistantMessage = eventMap['assistantMessage'] as String? ?? ''; |
|
||||
|
|
||||
_eventStreamController?.add(ChatHistoryEvent( |
|
||||
agentId: agentId, |
|
||||
userMessage: userMessage, |
|
||||
assistantMessage: assistantMessage, |
|
||||
timestamp: timestamp, |
|
||||
)); |
|
||||
|
|
||||
// 保存聊天记录 |
|
||||
_saveChatHistory(agentId, userMessage, assistantMessage, timestamp); |
|
||||
|
|
||||
Logger.info('收到聊天历史事件: agentId=$agentId'); |
|
||||
break; |
|
||||
|
|
||||
case 'recognitionStarted': |
|
||||
_eventStreamController?.add(RecognitionStartedEvent( |
|
||||
timestamp: timestamp, |
|
||||
)); |
|
||||
break; |
|
||||
|
|
||||
default: |
|
||||
Logger.warning('收到未知类型的语音交互事件: $eventType'); |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
@override |
||||
void onClose() { |
void onClose() { |
||||
// 清理资源 |
|
||||
_eventSubscription?.cancel(); |
_eventSubscription?.cancel(); |
||||
_eventStreamController?.close(); |
_eventStreamController.close(); |
||||
super.onClose(); |
super.onClose(); |
||||
} |
} |
||||
|
|
||||
/// 保存聊天记录 |
|
||||
void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { |
|
||||
try { |
|
||||
// 获取ChatHistoryService实例 |
|
||||
final chatHistoryService = Get.find<ChatHistoryService>(); |
|
||||
|
|
||||
// 创建用户消息和助手消息 |
|
||||
final userMsg = Message( |
|
||||
role: 'user', |
|
||||
content: userMessage, |
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), |
|
||||
); |
|
||||
|
|
||||
final assistantMsg = Message( |
|
||||
role: 'assistant', |
|
||||
content: assistantMessage, |
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 |
|
||||
); |
|
||||
|
|
||||
// 加载现有历史记录 |
|
||||
final existingMessages = chatHistoryService.loadHistory(agentId); |
|
||||
|
|
||||
// 添加新消息 |
|
||||
existingMessages.addAll([userMsg, assistantMsg]); |
|
||||
|
|
||||
// 保存更新后的历史记录 |
|
||||
chatHistoryService.saveHistory(agentId, existingMessages); |
|
||||
} catch (e) { |
|
||||
Logger.error('保存聊天记录失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 语音交互事件基类 |
|
||||
abstract class VoiceInteractionEvent { |
|
||||
final int timestamp; |
|
||||
|
|
||||
VoiceInteractionEvent({required this.timestamp}); |
|
||||
} |
|
||||
|
|
||||
/// 聊天历史事件 |
|
||||
class ChatHistoryEvent extends VoiceInteractionEvent { |
|
||||
final String agentId; |
|
||||
final String userMessage; |
|
||||
final String assistantMessage; |
|
||||
|
|
||||
ChatHistoryEvent({ |
|
||||
required this.agentId, |
|
||||
required this.userMessage, |
|
||||
required this.assistantMessage, |
|
||||
required int timestamp, |
|
||||
}) : super(timestamp: timestamp); |
|
||||
} |
|
||||
|
|
||||
/// 语音识别开始事件 |
|
||||
class RecognitionStartedEvent extends VoiceInteractionEvent { |
|
||||
RecognitionStartedEvent({ |
|
||||
required int timestamp, |
|
||||
}) : super(timestamp: timestamp); |
|
||||
} |
} |
||||
@ -1,19 +1,10 @@ |
|||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import '../controllers/translation_controller.dart'; |
import '../controllers/translation_controller.dart'; |
||||
import '../../../data/services/speech_impl/azure_asr_service.dart'; |
|
||||
import '../../../data/services/speech_impl/azure_tts_service.dart'; |
|
||||
import '../../../data/services/asr_service.dart'; |
|
||||
import '../../../data/services/tts_service.dart'; |
|
||||
import '../../../data/services/volcano_translation_service.dart'; |
|
||||
import '../../../data/services/language_manager.dart'; |
|
||||
|
|
||||
class TranslationBinding implements Bindings { |
class TranslationBinding implements Bindings { |
||||
@override |
@override |
||||
void dependencies() { |
void dependencies() { |
||||
Get.put(LanguageManager(), permanent: true); |
|
||||
Get.lazyPut<AsrService>(() => AzureAsrService(), fenix: true); |
|
||||
Get.lazyPut<TtsService>(() => AzureTtsService(), fenix: true); |
|
||||
Get.put(VolcanoTranslationService(), permanent: true); |
|
||||
Get.put(TranslationController()); |
Get.put(TranslationController()); |
||||
} |
} |
||||
} |
} |
||||
@ -1,30 +0,0 @@ |
|||||
// This is a basic Flutter widget test. |
|
||||
// |
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester |
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll |
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget |
|
||||
// tree, read text, and verify that the values of widget properties are correct. |
|
||||
|
|
||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:flutter_test/flutter_test.dart'; |
|
||||
|
|
||||
import 'package:deep_voice/main.dart'; |
|
||||
|
|
||||
void main() { |
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async { |
|
||||
// Build our app and trigger a frame. |
|
||||
await tester.pumpWidget(const MainApp()); |
|
||||
|
|
||||
// Verify that our counter starts at 0. |
|
||||
expect(find.text('0'), findsOneWidget); |
|
||||
expect(find.text('1'), findsNothing); |
|
||||
|
|
||||
// Tap the '+' icon and trigger a frame. |
|
||||
await tester.tap(find.byIcon(Icons.add)); |
|
||||
await tester.pump(); |
|
||||
|
|
||||
// Verify that our counter has incremented. |
|
||||
expect(find.text('0'), findsNothing); |
|
||||
expect(find.text('1'), findsOneWidget); |
|
||||
}); |
|
||||
} |
|
||||
Loading…
Reference in new issue