30 changed files with 2533 additions and 336 deletions
@ -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,710 @@ |
|||
import Foundation |
|||
import MicrosoftCognitiveServicesSpeech |
|||
import AVFoundation |
|||
import AudioToolbox |
|||
|
|||
/// 自定义麦克风流实现,优化ASR语音输入捕获 |
|||
class AzureMicrophoneStream: NSObject { |
|||
var ioUnit: AudioUnit? |
|||
var audioFormat: AudioStreamBasicDescription |
|||
var audioBufferList: AudioBufferList |
|||
var audioList: [Float] = [] |
|||
let audioListQueue = DispatchQueue(label: "azureAudioListQueue") |
|||
private var isActive = false |
|||
|
|||
override init() { |
|||
// 音频会话配置 |
|||
let audioSession = AVAudioSession.sharedInstance() |
|||
do { |
|||
try audioSession.setCategory(.playAndRecord, |
|||
mode: .voiceChat, |
|||
options: [.allowBluetooth, .defaultToSpeaker, .mixWithOthers]) |
|||
try audioSession.setActive(true) |
|||
print("[AzureMicrophoneStream] 音频会话配置成功") |
|||
} catch { |
|||
print("[AzureMicrophoneStream] 配置音频会话失败: \(error.localizedDescription)") |
|||
} |
|||
|
|||
// 设置音频格式为16KHz 16位单声道PCM |
|||
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: audioFormat.mChannelsPerFrame, |
|||
mDataByteSize: 4096, |
|||
mData: malloc(4096) |
|||
) |
|||
) |
|||
|
|||
super.init() |
|||
} |
|||
|
|||
func start() -> Bool { |
|||
if isActive { |
|||
return true // 已经在运行 |
|||
} |
|||
|
|||
if setupAudioUnit() { |
|||
isActive = startAudioUnit() |
|||
return isActive |
|||
} |
|||
return false |
|||
} |
|||
|
|||
private func setupAudioUnit() -> Bool { |
|||
print("[AzureMicrophoneStream] 设置音频单元") |
|||
|
|||
var ioUnitDescription = AudioComponentDescription( |
|||
componentType: kAudioUnitType_Output, |
|||
componentSubType: kAudioUnitSubType_VoiceProcessingIO, |
|||
componentManufacturer: kAudioUnitManufacturer_Apple, |
|||
componentFlags: 0, |
|||
componentFlagsMask: 0 |
|||
) |
|||
|
|||
guard let ioUnitRef = AudioComponentFindNext(nil, &ioUnitDescription) else { |
|||
print("[AzureMicrophoneStream] 无法找到音频组件") |
|||
return false |
|||
} |
|||
|
|||
if CheckHasError(AudioComponentInstanceNew(ioUnitRef, &ioUnit), "创建IO单元") { |
|||
ioUnit = nil |
|||
return false |
|||
} |
|||
|
|||
var enableInput: UInt32 = 1 |
|||
let kInputBus: AudioUnitElement = 1 |
|||
let kOutputBus: AudioUnitElement = 0 |
|||
|
|||
if CheckHasError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
|||
kAudioUnitScope_Input, kInputBus, &enableInput, |
|||
UInt32(MemoryLayout<UInt32>.size)), "设置输入总线的EnableIO属性") { |
|||
return false |
|||
} |
|||
|
|||
var enableOutput: UInt32 = 0 |
|||
if CheckHasError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
|||
kAudioUnitScope_Output, kOutputBus, |
|||
&enableOutput, UInt32(MemoryLayout<UInt32>.size)), "设置输出总线的EnableIO属性") { |
|||
return false |
|||
} |
|||
|
|||
var flag: UInt32 = 0 |
|||
if CheckHasError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_ShouldAllocateBuffer, |
|||
kAudioUnitScope_Output, kInputBus, &flag, UInt32(MemoryLayout<UInt32>.size)), "设置ShouldAllocateBuffer属性") { |
|||
return false |
|||
} |
|||
|
|||
let size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size) |
|||
if CheckHasError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
|||
kAudioUnitScope_Output, kInputBus, &audioFormat, size), "设置输入总线的StreamFormat属性") { |
|||
return false |
|||
} |
|||
|
|||
if CheckHasError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
|||
kAudioUnitScope_Input, kOutputBus, &audioFormat, size), "设置输出总线的StreamFormat属性") { |
|||
return false |
|||
} |
|||
|
|||
var inputCallback = AURenderCallbackStruct( |
|||
inputProc: AzureMicrophoneStream.OnRecordedDataIsAvailable, |
|||
inputProcRefCon: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) |
|||
) |
|||
|
|||
if CheckHasError(AudioUnitSetProperty(ioUnit!, |
|||
kAudioOutputUnitProperty_SetInputCallback, |
|||
kAudioUnitScope_Global, kInputBus, |
|||
&inputCallback, UInt32(MemoryLayout<AURenderCallbackStruct>.size)), "设置输入回调") { |
|||
return false |
|||
} |
|||
|
|||
var hasError = CheckHasError(AudioUnitInitialize(ioUnit!), "初始化IO单元") |
|||
if hasError { |
|||
// 如果初始化失败,重试一次 |
|||
Thread.sleep(forTimeInterval: 0.5) |
|||
hasError = CheckHasError(AudioUnitInitialize(ioUnit!), "重试初始化IO单元") |
|||
} |
|||
|
|||
print("[AzureMicrophoneStream] 音频单元设置\(hasError ? "失败" : "成功")") |
|||
return !hasError |
|||
} |
|||
|
|||
private func startAudioUnit() -> Bool { |
|||
print("[AzureMicrophoneStream] 启动音频单元") |
|||
guard let ioUnit = ioUnit else { |
|||
print("[AzureMicrophoneStream] IO单元未初始化") |
|||
return false |
|||
} |
|||
return !CheckHasError(AudioOutputUnitStart(ioUnit), "启动IO单元") |
|||
} |
|||
|
|||
func stop() { |
|||
print("[AzureMicrophoneStream] 停止音频单元") |
|||
guard isActive, let ioUnit = ioUnit else { return } |
|||
|
|||
_ = CheckHasError(AudioOutputUnitStop(ioUnit), "停止IO单元") |
|||
isActive = false |
|||
} |
|||
|
|||
func dispose() { |
|||
stop() |
|||
|
|||
if let ioUnit = ioUnit { |
|||
_ = CheckHasError(AudioUnitUninitialize(ioUnit), "反初始化IO单元") |
|||
_ = CheckHasError(AudioComponentInstanceDispose(ioUnit), "释放IO单元") |
|||
} |
|||
|
|||
// 释放缓冲区 |
|||
if let buffer = audioBufferList.mBuffers.mData { |
|||
free(buffer) |
|||
} |
|||
|
|||
self.ioUnit = nil |
|||
|
|||
// 清空音频数据 |
|||
audioListQueue.sync { |
|||
audioList.removeAll() |
|||
} |
|||
} |
|||
|
|||
static let OnRecordedDataIsAvailable: AURenderCallback = { inRefCon, ioActionFlags, inTimeStamp, inBusNumber, inNumberFrames, ioData in |
|||
let wrapper = Unmanaged<AzureMicrophoneStream>.fromOpaque(inRefCon).takeUnretainedValue() |
|||
let expectedDataByteSize = inNumberFrames * wrapper.audioFormat.mBytesPerFrame |
|||
|
|||
if wrapper.audioBufferList.mBuffers.mDataByteSize < expectedDataByteSize { |
|||
wrapper.audioBufferList.mBuffers.mData = realloc(wrapper.audioBufferList.mBuffers.mData, Int(expectedDataByteSize)) |
|||
wrapper.audioBufferList.mBuffers.mDataByteSize = expectedDataByteSize |
|||
} |
|||
|
|||
let status = wrapper.CheckErrorStatus(AudioUnitRender(wrapper.ioUnit!, ioActionFlags, inTimeStamp, |
|||
inBusNumber, inNumberFrames, &wrapper.audioBufferList), |
|||
"AudioUnitRender调用") |
|||
|
|||
var audioDataFloat = [Float](repeating: 0.0, count: Int(inNumberFrames)) |
|||
let buffer = wrapper.audioBufferList.mBuffers |
|||
let bufferData = buffer.mData!.assumingMemoryBound(to: Int16.self) |
|||
|
|||
for j in 0..<Int(buffer.mDataByteSize / UInt32(MemoryLayout<Int16>.size)) { |
|||
audioDataFloat[j] = Float(bufferData[j]) / 32768.0 // 归一化到[-1.0, 1.0]范围 |
|||
} |
|||
|
|||
if status == noErr { |
|||
wrapper.audioListQueue.async { |
|||
wrapper.audioList.append(contentsOf: audioDataFloat) |
|||
} |
|||
} |
|||
return status |
|||
} |
|||
|
|||
private func CheckHasError(_ status: OSStatus, _ operation: String) -> Bool { |
|||
if status != noErr { |
|||
print("[AzureMicrophoneStream] \(operation)失败: \(status)") |
|||
return true |
|||
} |
|||
return false |
|||
} |
|||
|
|||
private func CheckErrorStatus(_ status: OSStatus, _ operation: String) -> OSStatus { |
|||
if status != noErr { |
|||
print("[AzureMicrophoneStream] \(operation)失败: \(status)") |
|||
} |
|||
return status |
|||
} |
|||
|
|||
// 读取音频数据,适配Azure SDK |
|||
func read(bytes: inout [UInt8]) -> Int { |
|||
return audioListQueue.sync { |
|||
if audioList.isEmpty { |
|||
return 0 |
|||
} |
|||
|
|||
// 确保有足够的数据 |
|||
let minFrames = 1280 |
|||
if audioList.count < minFrames { |
|||
return 0 |
|||
} |
|||
|
|||
let frameLength = minFrames |
|||
|
|||
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) |
|||
|
|||
return frameLength * 2 // 每个样本2字节(16位PCM) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 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 microphoneStream: AzureMicrophoneStream? |
|||
private var pushStreamConfig: SPXPushAudioInputStream? |
|||
|
|||
/// 状态标志 |
|||
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] |
|||
} |
|||
|
|||
// 创建麦克风流 |
|||
microphoneStream = AzureMicrophoneStream() |
|||
|
|||
print("[AzureAsrHelper] Azure 语音服务初始化成功") |
|||
isInitialized = true |
|||
return true |
|||
} |
|||
|
|||
/// 重置 recognizer |
|||
private func resetRecognizer() -> Bool { |
|||
// 释放之前的 recognizer |
|||
recognizer = nil |
|||
audioConfig = nil |
|||
|
|||
do { |
|||
// 创建语音配置 |
|||
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
|||
|
|||
// 创建推送流 |
|||
pushStreamConfig = try SPXPushAudioInputStream() |
|||
|
|||
// 创建音频配置,使用推送流 |
|||
audioConfig = try SPXAudioConfiguration(streamInput: pushStreamConfig!) |
|||
|
|||
// 设置语言配置 |
|||
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!) |
|||
} |
|||
|
|||
return true |
|||
} catch { |
|||
print("[AzureAsrHelper] 错误: 重置识别器失败: \(error.localizedDescription)") |
|||
eventHandler("error", ["message": "重置识别器失败: \(error.localizedDescription)"]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/// 启动音频捕获和数据推送 |
|||
private func startAudioStream() -> Bool { |
|||
guard let micStream = microphoneStream else { |
|||
print("[AzureAsrHelper] 错误: 麦克风流未初始化") |
|||
return false |
|||
} |
|||
|
|||
// 启动麦克风 |
|||
if !micStream.start() { |
|||
print("[AzureAsrHelper] 错误: 启动麦克风流失败") |
|||
return false |
|||
} |
|||
|
|||
// 创建并启动音频推送线程 |
|||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
|||
guard let self = self, let pushStream = self.pushStreamConfig else { return } |
|||
|
|||
var isRunning = true |
|||
var audioBuffer = [UInt8](repeating: 0, count: 16000) |
|||
|
|||
while isRunning { |
|||
autoreleasepool { |
|||
// 读取麦克风数据 |
|||
let bytesRead = micStream.read(bytes: &audioBuffer) |
|||
|
|||
if bytesRead > 0 { |
|||
do { |
|||
// 推送音频数据到Azure识别流 |
|||
let data = Data(bytes: audioBuffer, count: bytesRead) |
|||
try pushStream.write(data) |
|||
} catch { |
|||
print("[AzureAsrHelper] 推送音频数据失败: \(error.localizedDescription)") |
|||
isRunning = false |
|||
} |
|||
} |
|||
|
|||
// 检查是否应该继续捕获 |
|||
if !self._isContinuousRecognitionActive { |
|||
isRunning = false |
|||
} |
|||
|
|||
// 添加适当的休眠以避免过度消耗CPU |
|||
if bytesRead == 0 { |
|||
Thread.sleep(forTimeInterval: 0.01) |
|||
} |
|||
} |
|||
} |
|||
print("[AzureAsrHelper] 音频推送线程已停止") |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
/// 执行一次性语音识别 |
|||
/// - Returns: 是否成功启动识别 |
|||
func recognizeOnce() -> Bool { |
|||
if !isInitialized { |
|||
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
|||
eventHandler("error", ["message": "语音服务未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
// 如果正在连续识别,先停止 |
|||
if _isContinuousRecognitionActive { |
|||
stopContinuousRecognition() |
|||
} |
|||
|
|||
// 重置 recognizer |
|||
if !resetRecognizer() { |
|||
return false |
|||
} |
|||
|
|||
// 启动音频流 |
|||
if !startAudioStream() { |
|||
return false |
|||
} |
|||
|
|||
do { |
|||
// 设置回调 |
|||
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) |
|||
self.eventHandler("result", [ |
|||
"text": event.result.text ?? "", |
|||
"detectedLanguage": detectedLanguage |
|||
]) |
|||
} |
|||
} |
|||
|
|||
recognizer?.addCanceledEventHandler { [weak self] _, event in |
|||
guard let self = self else { return } |
|||
|
|||
let errorDetails = event.errorDetails ?? "未知错误" |
|||
self.eventHandler("error", ["message": "识别异常: \(errorDetails)"]) |
|||
} |
|||
|
|||
// 通知会话开始 |
|||
eventHandler("sessionStarted", [:]) |
|||
|
|||
// 执行识别 |
|||
try recognizer?.recognizeOnceAsync { [weak self] result in |
|||
guard let self = self else { return } |
|||
|
|||
// 停止麦克风流 |
|||
self.microphoneStream?.stop() |
|||
|
|||
if result.reason == SPXResultReason.recognizedSpeech { |
|||
let detectedLanguage = self.getDetectedLanguage(from: result) |
|||
self.eventHandler("result", [ |
|||
"text": result.text ?? "", |
|||
"detectedLanguage": detectedLanguage |
|||
]) |
|||
} 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)"]) |
|||
microphoneStream?.stop() |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/// 开始连续语音识别 |
|||
/// - Returns: 是否成功启动识别 |
|||
func startContinuousRecognition() -> Bool { |
|||
if !isInitialized { |
|||
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
|||
eventHandler("error", ["message": "语音服务未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
// 如果已经在进行连续识别,先停止 |
|||
if _isContinuousRecognitionActive { |
|||
stopContinuousRecognition() |
|||
} |
|||
|
|||
// 重置 recognizer |
|||
if !resetRecognizer() { |
|||
return false |
|||
} |
|||
|
|||
do { |
|||
// 设置识别事件处理 |
|||
setupContinuousRecognitionCallbacks() |
|||
|
|||
// 启动连续识别 |
|||
try recognizer?.startContinuousRecognition() |
|||
_isContinuousRecognitionActive = true |
|||
|
|||
// 启动音频流 |
|||
if !startAudioStream() { |
|||
try recognizer?.stopContinuousRecognition() |
|||
_isContinuousRecognitionActive = false |
|||
return false |
|||
} |
|||
|
|||
// 通知会话开始 |
|||
eventHandler("sessionStarted", [:]) |
|||
|
|||
print("[AzureAsrHelper] 连续识别开始") |
|||
return true |
|||
} catch { |
|||
print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") |
|||
eventHandler("error", ["message": "开始连续识别失败: \(error.localizedDescription)"]) |
|||
_isContinuousRecognitionActive = false |
|||
microphoneStream?.stop() |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/// 停止连续语音识别 |
|||
/// - Returns: 是否成功停止识别 |
|||
func stopContinuousRecognition() -> Bool { |
|||
if !_isContinuousRecognitionActive || recognizer == nil { |
|||
return true |
|||
} |
|||
|
|||
// 停止麦克风流 |
|||
microphoneStream?.stop() |
|||
|
|||
do { |
|||
try recognizer?.stopContinuousRecognition() |
|||
_isContinuousRecognitionActive = false |
|||
eventHandler("sessionStopped", [:]) |
|||
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] 释放资源") |
|||
|
|||
// 停止连续识别 |
|||
if _isContinuousRecognitionActive { |
|||
stopContinuousRecognition() |
|||
} |
|||
|
|||
// 关闭麦克风流 |
|||
microphoneStream?.dispose() |
|||
microphoneStream = nil |
|||
|
|||
// 关闭推送流 |
|||
if let pushStream = pushStreamConfig { |
|||
do { |
|||
try pushStream.close() |
|||
} catch { |
|||
print("[AzureAsrHelper] 关闭推送流失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
|
|||
// 释放资源 |
|||
recognizer = nil |
|||
speechConfig = nil |
|||
audioConfig = nil |
|||
pushStreamConfig = nil |
|||
|
|||
// 重置状态 |
|||
_isContinuousRecognitionActive = false |
|||
isInitialized = false |
|||
} |
|||
|
|||
// MARK: - 私有辅助方法 |
|||
|
|||
/// 设置连续识别回调 |
|||
private func setupContinuousRecognitionCallbacks() { |
|||
// 最终识别结果 |
|||
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 } |
|||
|
|||
self._isContinuousRecognitionActive = true |
|||
self.eventHandler("sessionStarted", [:]) |
|||
} |
|||
|
|||
recognizer?.addSessionStoppedEventHandler { [weak self] _, _ in |
|||
guard let self = self else { return } |
|||
|
|||
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 ?? "" |
|||
|
|||
self.eventHandler("canceled", [ |
|||
"reason": reason, |
|||
"errorDetails": errorDetails |
|||
]) |
|||
|
|||
self._isContinuousRecognitionActive = false |
|||
self.microphoneStream?.stop() |
|||
} |
|||
} |
|||
|
|||
/// 从结果中获取检测到的语言 |
|||
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 |
|||
} |
|||
} |
|||
} |
|||
@ -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,378 @@ |
|||
import Foundation |
|||
import MicrosoftCognitiveServicesSpeech |
|||
import AVFoundation |
|||
|
|||
/// Azure TTS工具类,负责实现TTS服务接口 |
|||
@available(iOS 13.0, *) |
|||
class AzureTtsHelper: NSObject { |
|||
// MARK: - 属性 |
|||
|
|||
/// 事件处理回调 |
|||
private var eventHandler: (String, [String: Any]) -> Void |
|||
|
|||
/// 语音配置信息 |
|||
private var speechSubscriptionKey: String = "" |
|||
private var serviceRegion: String = "" |
|||
|
|||
/// 语音合成配置 |
|||
private var speechConfig: SPXSpeechConfiguration? |
|||
|
|||
/// 语音合成器 |
|||
private var synthesizer: SPXSpeechSynthesizer? |
|||
|
|||
/// 是否初始化成功 |
|||
private var isInitialized = false |
|||
|
|||
/// 当前是否正在播放 |
|||
private var _isSpeaking = false |
|||
|
|||
// MARK: - 语音设置 |
|||
|
|||
/// 当前语音 |
|||
private var currentVoice = "zh-CN-XiaoxiaoNeural" |
|||
|
|||
/// 支持的语音映射 |
|||
private var voiceMap: [String: String] = [ |
|||
"zh-CN": "zh-CN-XiaoxiaoNeural", |
|||
"en-US": "en-US-JennyNeural", |
|||
"ja-JP": "ja-JP-NanamiNeural", |
|||
"ko-KR": "ko-KR-SunHiNeural", |
|||
"zh-TW": "zh-TW-HsiaoChenNeural", |
|||
"zh-HK": "zh-HK-HiuMaanNeural" |
|||
] |
|||
|
|||
/// 当前语音合成参数 |
|||
private var currentSpeechRate = "0%" |
|||
private var currentPitch = "0%" |
|||
private var currentVolume = "100%" |
|||
|
|||
// MARK: - 初始化 |
|||
|
|||
init(eventHandler: @escaping (String, [String: Any]) -> Void) { |
|||
self.eventHandler = eventHandler |
|||
super.init() |
|||
} |
|||
|
|||
deinit { |
|||
dispose() |
|||
} |
|||
|
|||
// MARK: - TTS 接口实现 |
|||
|
|||
/// 初始化语音合成服务 |
|||
/// - Parameters: |
|||
/// - speechSubscriptionKey: Azure 语音服务订阅密钥 |
|||
/// - serviceRegion: Azure 服务区域 (如 eastasia) |
|||
/// - language: 语言代码 (默认 zh-CN) |
|||
/// - Returns: 初始化是否成功 |
|||
func initialize(speechSubscriptionKey: String, serviceRegion: String, language: String = "zh-CN") -> Bool { |
|||
print("[AzureTtsHelper] 初始化语音合成服务") |
|||
|
|||
// 检查配置是否为空 |
|||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|||
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
|||
eventHandler("error", ["error": "Azure 配置信息不完整"]) |
|||
return false |
|||
} |
|||
|
|||
// 释放之前的资源 |
|||
dispose() |
|||
|
|||
// 记录配置信息 |
|||
self.speechSubscriptionKey = speechSubscriptionKey |
|||
self.serviceRegion = serviceRegion |
|||
|
|||
do { |
|||
// 创建语音配置 |
|||
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
|||
|
|||
// 设置语音合成输出格式 |
|||
// speechConfig?.setSpeechSynthesisOutputFormat(.audio24Khz48KBitRateMonoMp3) |
|||
|
|||
// 设置默认语音 |
|||
let defaultVoice = getDefaultVoiceForLanguage(language) |
|||
currentVoice = defaultVoice |
|||
speechConfig?.speechSynthesisVoiceName = defaultVoice |
|||
|
|||
// 创建语音合成器 |
|||
synthesizer = try SPXSpeechSynthesizer(speechConfig!) |
|||
|
|||
// 设置事件处理器 |
|||
setupSynthesizerEvents() |
|||
|
|||
isInitialized = true |
|||
print("[AzureTtsHelper] TTS 引擎初始化成功") |
|||
|
|||
return true |
|||
} catch { |
|||
print("[AzureTtsHelper] 错误: 初始化语音合成服务失败: \(error.localizedDescription)") |
|||
eventHandler("error", ["error": "初始化语音合成服务失败: \(error.localizedDescription)"]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/// 设置语音 |
|||
/// - Parameter voiceName: 语音名称 (如 "zh-CN-XiaoxiaoNeural") |
|||
/// - Returns: 设置是否成功 |
|||
func setVoice(voiceName: String) -> Bool { |
|||
if !isInitialized { |
|||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|||
eventHandler("error", ["error": "TTS 引擎尚未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
if voiceName.isEmpty { |
|||
print("[AzureTtsHelper] 错误: 声音名称为空") |
|||
eventHandler("error", ["error": "声音名称不能为空"]) |
|||
return false |
|||
} |
|||
|
|||
if voiceName == currentVoice { |
|||
print("[AzureTtsHelper] 已设置语音: \(voiceName)") |
|||
return true |
|||
} |
|||
|
|||
print("[AzureTtsHelper] 设置声音: \(voiceName)") |
|||
currentVoice = voiceName |
|||
|
|||
// 更新语音配置 |
|||
if let speechConfig = speechConfig { |
|||
speechConfig.speechSynthesisVoiceName = voiceName |
|||
return true |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
/// 设置语音合成参数 |
|||
/// - Parameters: |
|||
/// - rate: 语速,范围 -100 到 100,默认为 0 |
|||
/// - pitch: 音调,范围 -100 到 100,默认为 0 |
|||
/// - volume: 音量,范围 0 到 100,默认为 100 |
|||
/// - Returns: 是否设置成功 |
|||
func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { |
|||
if !isInitialized { |
|||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|||
eventHandler("error", ["error": "TTS 引擎尚未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
// 转换参数格式 |
|||
currentSpeechRate = formatRateParam(rate) |
|||
currentPitch = formatPitchParam(pitch) |
|||
currentVolume = formatVolumeParam(volume) |
|||
|
|||
print("[AzureTtsHelper] 已设置语音参数: 语速=\(currentSpeechRate), 音调=\(currentPitch), 音量=\(currentVolume)") |
|||
return true |
|||
} |
|||
|
|||
/// 合成文本为语音并播放 |
|||
/// - Parameter text: 要合成的文本 |
|||
/// - Returns: 操作是否成功启动 |
|||
func speakText(text: String) -> Bool { |
|||
if !isInitialized { |
|||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|||
eventHandler("error", ["error": "TTS 引擎尚未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
if text.isEmpty { |
|||
print("[AzureTtsHelper] 警告: 要播放的文本为空") |
|||
return true |
|||
} |
|||
|
|||
print("[AzureTtsHelper] 开始语音合成: \(text.prefix(50))...") |
|||
|
|||
// 生成SSML |
|||
let ssml = generateSsml(text: text) |
|||
|
|||
// 直接进行SSML合成 |
|||
return speakSsmlInternal(text: ssml) |
|||
} |
|||
|
|||
/// 内部SSML合成和播放 |
|||
private func speakSsmlInternal(text: String) -> Bool { |
|||
guard let synthesizer = synthesizer else { |
|||
print("[AzureTtsHelper] 错误: 合成器未初始化") |
|||
eventHandler("error", ["error": "合成器未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
_isSpeaking = true |
|||
eventHandler("started", [:]) |
|||
|
|||
Task { |
|||
do { |
|||
print("[AzureTtsHelper] 开始语音合成") |
|||
|
|||
// 使用异步方法进行合成并直接播放 |
|||
_ = try await synthesizer.startSpeakingSsml(text) |
|||
|
|||
} catch { |
|||
print("[AzureTtsHelper] 错误: 语音合成失败: \(error.localizedDescription)") |
|||
DispatchQueue.main.async { |
|||
self._isSpeaking = false |
|||
self.eventHandler("error", ["error": "语音合成失败: \(error.localizedDescription)"]) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
/// 停止当前语音合成 |
|||
/// - Returns: 操作是否成功 |
|||
func stopSpeaking() -> Bool { |
|||
if !isInitialized || !_isSpeaking { |
|||
return true |
|||
} |
|||
|
|||
// 停止合成 |
|||
do { |
|||
try synthesizer?.stopSpeaking() |
|||
_isSpeaking = false |
|||
eventHandler("canceled", [:]) |
|||
print("[AzureTtsHelper] 已停止语音合成") |
|||
return true |
|||
} catch { |
|||
print("[AzureTtsHelper] 错误: 停止语音合成失败: \(error.localizedDescription)") |
|||
eventHandler("error", ["error": "停止语音合成失败: \(error.localizedDescription)"]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/// 检查是否正在播放 |
|||
/// - Returns: 当前是否正在播放语音 |
|||
func isSpeaking() -> Bool { |
|||
return _isSpeaking |
|||
} |
|||
|
|||
/// 释放资源 |
|||
func dispose() { |
|||
try? stopSpeaking() |
|||
|
|||
// 释放合成器和配置 |
|||
synthesizer = nil |
|||
speechConfig = nil |
|||
|
|||
isInitialized = false |
|||
_isSpeaking = false |
|||
print("[AzureTtsHelper] TTS 引擎已释放") |
|||
} |
|||
|
|||
// MARK: - 私有辅助方法 |
|||
|
|||
/// 设置合成器事件处理 |
|||
private func setupSynthesizerEvents() { |
|||
guard let synthesizer = synthesizer else { return } |
|||
|
|||
// 添加书签到达事件处理 |
|||
synthesizer.addBookmarkReachedEventHandler { _, e in |
|||
print("[AzureTtsHelper] 书签事件: 音频偏移: \((e.audioOffset + 5000) / 10000)ms, 文本: \"\(e.text)\"") |
|||
} |
|||
|
|||
// 合成完成事件 |
|||
synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in |
|||
guard let self = self else { return } |
|||
print("[AzureTtsHelper] 语音合成完成: 音频持续时间: \(e.result.audioDuration)") |
|||
DispatchQueue.main.async { |
|||
self._isSpeaking = false |
|||
self.eventHandler("completed", [:]) |
|||
} |
|||
} |
|||
|
|||
// 合成取消事件 |
|||
synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in |
|||
guard let self = self else { return } |
|||
|
|||
let result = e.result |
|||
do { |
|||
let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: result) |
|||
print("[AzureTtsHelper] 语音合成取消: 原因: \(cancellationDetails.reason)") |
|||
|
|||
if cancellationDetails.reason == SPXCancellationReason.error { |
|||
print("[AzureTtsHelper] 错误代码: \(cancellationDetails.errorCode)") |
|||
print("[AzureTtsHelper] 错误详情: \(cancellationDetails.errorDetails ?? "未知")") |
|||
} |
|||
|
|||
DispatchQueue.main.async { |
|||
self._isSpeaking = false |
|||
self.eventHandler("error", ["error": "语音合成取消: \(cancellationDetails.errorDetails ?? "未知错误")"]) |
|||
} |
|||
} catch { |
|||
print("[AzureTtsHelper] 获取取消详情时出错: \(error)") |
|||
|
|||
DispatchQueue.main.async { |
|||
self._isSpeaking = false |
|||
self.eventHandler("error", ["error": "语音合成被取消"]) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 合成开始事件 |
|||
synthesizer.addSynthesisStartedEventHandler { _, _ in |
|||
print("[AzureTtsHelper] 语音合成开始") |
|||
} |
|||
|
|||
// 合成中事件 |
|||
synthesizer.addSynthesizingEventHandler { _, _ in |
|||
print("[AzureTtsHelper] 语音合成中") |
|||
} |
|||
} |
|||
|
|||
/// 生成 SSML 文本 |
|||
private func generateSsml(text: String) -> String { |
|||
return """ |
|||
<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='zh-CN'> |
|||
<voice name='\(currentVoice)'> |
|||
<prosody rate='\(currentSpeechRate)' pitch='\(currentPitch)' volume='\(currentVolume)'> |
|||
\(text) |
|||
</prosody> |
|||
</voice> |
|||
</speak> |
|||
""" |
|||
} |
|||
|
|||
/// 格式化语速参数 |
|||
private func formatRateParam(_ rate: Int) -> String { |
|||
let clampedRate = rate.clamp(min: -100, max: 100) |
|||
if clampedRate == 0 { |
|||
return "0%" |
|||
} else if clampedRate < 0 { |
|||
return "\(Int(Double(clampedRate) * 0.9))%" |
|||
} else { |
|||
return "+\(clampedRate)%" |
|||
} |
|||
} |
|||
|
|||
/// 格式化音调参数 |
|||
private func formatPitchParam(_ pitch: Int) -> String { |
|||
let clampedPitch = pitch.clamp(min: -100, max: 100) |
|||
if clampedPitch == 0 { |
|||
return "0%" |
|||
} else { |
|||
return "\(Int(Double(clampedPitch) * 0.5))%" |
|||
} |
|||
} |
|||
|
|||
/// 格式化音量参数 |
|||
private func formatVolumeParam(_ volume: Int) -> String { |
|||
let clampedVolume = volume.clamp(min: 0, max: 100) |
|||
return "\(clampedVolume)%" |
|||
} |
|||
|
|||
/// 获取指定语言的默认语音 |
|||
private func getDefaultVoiceForLanguage(_ language: String) -> String { |
|||
return voiceMap[language] ?? "zh-CN-XiaoxiaoNeural" |
|||
} |
|||
} |
|||
|
|||
// MARK: - 扩展 |
|||
|
|||
extension Int { |
|||
func clamp(min: Int, max: Int) -> Int { |
|||
if self < min { return min } |
|||
if self > max { return max } |
|||
return self |
|||
} |
|||
} |
|||
@ -0,0 +1,263 @@ |
|||
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) { |
|||
print("[AzurePlugin] 处理TTS方法调用: \(call.method)") |
|||
handleTtsMethod(call, result) |
|||
} |
|||
|
|||
private func handleAsrMethodCalls(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|||
print("[AzurePlugin] 处理ASR方法调用: \(call.method)") |
|||
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) { |
|||
print("[AzurePlugin] 处理ASR方法: \(call.method)") |
|||
|
|||
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) { |
|||
print("[AzurePlugin] 处理TTS方法: \(call.method)") |
|||
|
|||
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 |
|||
@ -0,0 +1,523 @@ |
|||
import Foundation |
|||
import CoreBluetooth |
|||
import AVFoundation |
|||
import UIKit |
|||
|
|||
@objc class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate, CBPeripheralManagerDelegate { |
|||
private let TAG = "ClassicBluetoothHelper" |
|||
|
|||
// AVAudioSession 用于获取已连接的蓝牙设备 |
|||
private let audioSession = AVAudioSession.sharedInstance() |
|||
|
|||
// 蓝牙管理器 |
|||
private var centralManager: CBCentralManager? |
|||
private var peripheralManager: CBPeripheralManager? |
|||
|
|||
// 设备列表缓存 |
|||
private var bluetoothDevices: [[String: String]] = [] |
|||
|
|||
// 设备连接和断开回调 |
|||
private var deviceEventCallback: (([String: Any]) -> Void)? |
|||
|
|||
// 初始化 |
|||
override init() { |
|||
super.init() |
|||
|
|||
// 初始化中央管理器来检查蓝牙状态,并将自己设置为代理 |
|||
centralManager = CBCentralManager(delegate: self, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: true]) |
|||
|
|||
// 初始化外设管理器 |
|||
peripheralManager = CBPeripheralManager(delegate: self, queue: nil) |
|||
|
|||
// 请求权限 |
|||
requestBluetoothPermissions() |
|||
} |
|||
|
|||
// 请求蓝牙权限 |
|||
private func requestBluetoothPermissions() { |
|||
// 在 iOS 13 及更高版本中,需要主动请求蓝牙权限 |
|||
if #available(iOS 13.0, *) { |
|||
// 激活音频会话将触发系统蓝牙权限请求 |
|||
do { |
|||
try audioSession.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .allowBluetoothA2DP]) |
|||
try audioSession.setActive(true) |
|||
NSLog("\(TAG): 已请求音频会话蓝牙权限") |
|||
} catch { |
|||
NSLog("\(TAG): 激活音频会话失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// CBCentralManagerDelegate 方法 |
|||
func centralManagerDidUpdateState(_ central: CBCentralManager) { |
|||
var stateString = "unknown" |
|||
|
|||
switch central.state { |
|||
case .poweredOn: |
|||
stateString = "on" |
|||
NSLog("\(TAG): centralManagerDidUpdateState: 蓝牙已启用") |
|||
// 蓝牙已打开,可以开始扫描或其他操作 |
|||
// 初始化设备列表 - 主要是为了触发 iOS 的权限请求 |
|||
_ = getConnectedAudioDevices() |
|||
case .poweredOff: |
|||
stateString = "off" |
|||
NSLog("\(TAG): centralManagerDidUpdateState: 蓝牙已关闭") |
|||
// 清空设备列表 |
|||
bluetoothDevices.removeAll() |
|||
case .resetting: |
|||
stateString = "resetting" |
|||
NSLog("\(TAG): centralManagerDidUpdateState: 蓝牙正在重置") |
|||
case .unauthorized: |
|||
stateString = "unauthorized" |
|||
NSLog("\(TAG): centralManagerDidUpdateState: 蓝牙使用未授权") |
|||
case .unsupported: |
|||
stateString = "unsupported" |
|||
NSLog("\(TAG): centralManagerDidUpdateState: 设备不支持蓝牙") |
|||
case .unknown: |
|||
stateString = "unknown" |
|||
NSLog("\(TAG): centralManagerDidUpdateState: 蓝牙状态未知") |
|||
@unknown default: |
|||
stateString = "unknown" |
|||
NSLog("\(TAG): centralManagerDidUpdateState: 蓝牙状态未知(default)") |
|||
} |
|||
|
|||
NSLog("\(TAG): centralManagerDidUpdateState: 发送蓝牙状态变化事件: \(stateString)") |
|||
|
|||
// 发送蓝牙状态变化事件 |
|||
if let callback = deviceEventCallback { |
|||
let event: [String: Any] = [ |
|||
"type": "bluetoothStateChanged", |
|||
"state": stateString, |
|||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|||
] |
|||
callback(event) |
|||
} else { |
|||
NSLog("\(TAG): centralManagerDidUpdateState: 没有回调注册,无法发送事件") |
|||
} |
|||
} |
|||
|
|||
// CBPeripheralManagerDelegate 方法 |
|||
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { |
|||
NSLog("\(TAG): 外设管理器状态变化: \(peripheral.state.rawValue)") |
|||
} |
|||
|
|||
// 注册设备事件回调 |
|||
@objc func registerDeviceEventCallback(_ callback: @escaping ([String: Any]) -> Void) { |
|||
deviceEventCallback = callback |
|||
} |
|||
|
|||
// 检查蓝牙是否启用 |
|||
@objc func isBluetoothEnabled() -> Bool { |
|||
guard let manager = centralManager else { return false } |
|||
|
|||
return manager.state == .poweredOn |
|||
} |
|||
|
|||
// 获取已连接的音频设备 (A2DP 设备) |
|||
@objc func getConnectedA2dpDevices(_ completion: @escaping ([[String: String]]?, String?) -> Void) { |
|||
// 检查蓝牙状态和权限 |
|||
let (enabled, authorized) = checkBluetoothPermission() |
|||
if !enabled { |
|||
NSLog("\(TAG): 蓝牙未启用") |
|||
completion(nil, "蓝牙未启用,请在设置中开启蓝牙") |
|||
return |
|||
} |
|||
|
|||
if !authorized { |
|||
NSLog("\(TAG): 蓝牙权限被拒绝") |
|||
completion(nil, "蓝牙权限被拒绝,请在设置中允许蓝牙访问") |
|||
return |
|||
} |
|||
|
|||
// 尝试激活音频会话以获取设备信息 |
|||
do { |
|||
try audioSession.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .allowBluetoothA2DP]) |
|||
try audioSession.setActive(true) |
|||
|
|||
// 获取输出设备 |
|||
let devices = self.getConnectedAudioDevices() |
|||
|
|||
// 记录找到的设备数量 |
|||
NSLog("\(TAG): 找到 \(devices.count) 个A2DP设备") |
|||
|
|||
completion(devices, nil) |
|||
} catch { |
|||
NSLog("\(TAG): 获取A2DP设备失败: \(error.localizedDescription)") |
|||
completion(nil, "获取A2DP设备失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
|
|||
// 获取已连接的耳机设备 |
|||
@objc func getConnectedHeadsetDevices(_ completion: @escaping ([[String: String]]?, String?) -> Void) { |
|||
// 检查蓝牙状态和权限 |
|||
let (enabled, authorized) = checkBluetoothPermission() |
|||
if !enabled { |
|||
NSLog("\(TAG): 蓝牙未启用") |
|||
completion(nil, "蓝牙未启用,请在设置中开启蓝牙") |
|||
return |
|||
} |
|||
|
|||
if !authorized { |
|||
NSLog("\(TAG): 蓝牙权限被拒绝") |
|||
completion(nil, "蓝牙权限被拒绝,请在设置中允许蓝牙访问") |
|||
return |
|||
} |
|||
|
|||
// 尝试激活音频会话以获取设备信息 |
|||
do { |
|||
try audioSession.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth]) |
|||
try audioSession.setActive(true) |
|||
|
|||
// 获取输出设备 |
|||
let devices = self.getConnectedAudioDevices() |
|||
|
|||
// 记录找到的设备数量 |
|||
NSLog("\(TAG): 找到 \(devices.count) 个耳机设备") |
|||
|
|||
completion(devices, nil) |
|||
} catch { |
|||
NSLog("\(TAG): 获取耳机设备失败: \(error.localizedDescription)") |
|||
completion(nil, "获取耳机设备失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
|
|||
// 使用原生方式检查蓝牙权限 |
|||
private func checkBluetoothPermission() -> (enabled: Bool, authorized: Bool) { |
|||
// 检查蓝牙是否启用 |
|||
let isEnabled = centralManager?.state == .poweredOn |
|||
|
|||
// 检查蓝牙权限 |
|||
var isAuthorized = true |
|||
var authDescription = "unknown" |
|||
|
|||
if #available(iOS 13.0, *) { |
|||
let authStatus = centralManager?.authorization |
|||
|
|||
switch authStatus { |
|||
case .allowedAlways: |
|||
authDescription = "allowedAlways" |
|||
isAuthorized = true |
|||
case .denied: |
|||
authDescription = "denied" |
|||
isAuthorized = false |
|||
case .restricted: |
|||
authDescription = "restricted" |
|||
isAuthorized = false |
|||
case .notDetermined: |
|||
authDescription = "notDetermined" |
|||
// 未决定状态仍然当作授权, 因为系统会在实际使用时弹出请求 |
|||
isAuthorized = true |
|||
default: |
|||
authDescription = "unknown" |
|||
isAuthorized = true |
|||
} |
|||
|
|||
NSLog("\(TAG): 原生蓝牙权限状态: \(authDescription)") |
|||
} else { |
|||
// iOS 13 以下版本没有细粒度的权限控制 |
|||
authDescription = "legacy_version" |
|||
NSLog("\(TAG): iOS版本低于13,使用旧版权限模型") |
|||
} |
|||
|
|||
NSLog("\(TAG): 蓝牙状态: 启用=\(isEnabled), 已授权=\(isAuthorized), 权限描述=\(authDescription)") |
|||
return (isEnabled, isAuthorized) |
|||
} |
|||
|
|||
// 原生方式检查蓝牙权限并返回给 Flutter |
|||
@objc func checkAndRequestNativePermission(_ completion: @escaping ([String: Any]) -> Void) { |
|||
// 确保 centralManager 已初始化 |
|||
if centralManager == nil { |
|||
centralManager = CBCentralManager(delegate: self, queue: nil) |
|||
NSLog("\(self.TAG): 创建新的蓝牙管理器用于权限检查") |
|||
} |
|||
|
|||
// 延迟执行检查,确保 centralManager 状态已更新 |
|||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { |
|||
// 再次检查蓝牙权限,以获取最新状态 |
|||
let (enabled, authorized) = self.checkBluetoothPermission() |
|||
|
|||
// 尝试主动请求蓝牙权限(如果尚未决定) |
|||
if #available(iOS 13.0, *) { |
|||
if self.centralManager?.authorization == .notDetermined { |
|||
NSLog("\(self.TAG): 蓝牙权限状态为未决定,尝试主动请求权限") |
|||
// 启动扫描会触发系统权限请求 |
|||
self.centralManager?.scanForPeripherals(withServices: nil, options: nil) |
|||
// 立即停止扫描 |
|||
self.centralManager?.stopScan() |
|||
} |
|||
} |
|||
|
|||
let result: [String: Any] = [ |
|||
"enabled": enabled, |
|||
"authorized": authorized, |
|||
"status": self.getPermissionStatusText(enabled: enabled, authorized: authorized) |
|||
] |
|||
|
|||
NSLog("\(self.TAG): 返回权限检查结果: \(result)") |
|||
completion(result) |
|||
} |
|||
} |
|||
|
|||
// 获取权限状态文本 |
|||
private func getPermissionStatusText(enabled: Bool, authorized: Bool) -> String { |
|||
if !enabled { |
|||
return "disabled" // 蓝牙已禁用 |
|||
} |
|||
|
|||
if !authorized { |
|||
return "denied" // 权限被拒绝 |
|||
} |
|||
|
|||
return "granted" // 已授权 |
|||
} |
|||
|
|||
// 获取当前连接的音频设备 |
|||
private func getConnectedAudioDevices() -> [[String: String]] { |
|||
var result: [[String: String]] = [] |
|||
|
|||
// 尝试初始化设备列表 |
|||
if bluetoothDevices.isEmpty { |
|||
// 只在第一次使用时初始化 |
|||
NSLog("\(TAG): 第一次获取蓝牙设备,初始化设备列表") |
|||
} |
|||
|
|||
// 获取可用的音频输出设备 |
|||
guard let outputs = audioSession.currentRoute.outputs as? [AVAudioSessionPortDescription] else { |
|||
NSLog("\(TAG): 无法获取当前音频输出设备") |
|||
return result |
|||
} |
|||
|
|||
NSLog("\(TAG): 当前音频路由包含 \(outputs.count) 个输出设备") |
|||
|
|||
// 过滤蓝牙相关设备 |
|||
for output in outputs { |
|||
NSLog("\(TAG): 检查音频输出设备: \(output.portName), 类型: \(output.portType.rawValue)") |
|||
|
|||
if output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP || output.portType == .bluetoothLE { |
|||
let device: [String: String] = [ |
|||
"name": output.portName, |
|||
"address": output.uid // iOS 使用 UID 作为设备标识符 |
|||
] |
|||
result.append(device) |
|||
NSLog("\(TAG): 添加蓝牙设备: \(output.portName)") |
|||
} |
|||
} |
|||
|
|||
return result |
|||
} |
|||
|
|||
// 监听蓝牙设备连接/断开 |
|||
@objc func startBluetoothDeviceMonitoring() { |
|||
// 注册音频会话通知 |
|||
NotificationCenter.default.addObserver( |
|||
self, |
|||
selector: #selector(handleRouteChange(_:)), |
|||
name: AVAudioSession.routeChangeNotification, |
|||
object: nil |
|||
) |
|||
|
|||
// 注册蓝牙状态变化通知 |
|||
NotificationCenter.default.addObserver( |
|||
self, |
|||
selector: #selector(handleBluetoothStateChange(_:)), |
|||
name: NSNotification.Name(rawValue: "CBCentralManagerDidUpdateStateNotification"), |
|||
object: nil |
|||
) |
|||
} |
|||
|
|||
// 处理音频路由变化 |
|||
@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 { |
|||
if !deviceExistsInCache(device: device) { |
|||
// 找到新设备 |
|||
NSLog("\(TAG): 新的音频设备已连接: \(device["name"] ?? "未知设备")") |
|||
|
|||
// 添加到缓存 |
|||
bluetoothDevices.append(device) |
|||
|
|||
// 发送设备连接事件 |
|||
sendDeviceConnectedEvent(device: device) |
|||
} |
|||
} |
|||
|
|||
case .oldDeviceUnavailable: |
|||
// 设备已断开 - 查找从缓存中移除的设备 |
|||
var devicesToRemove: [[String: String]] = [] |
|||
|
|||
for cachedDevice in bluetoothDevices { |
|||
if !deviceExistsInList(device: cachedDevice, list: currentDevices) { |
|||
// 设备已断开 |
|||
NSLog("\(TAG): 音频设备已断开: \(cachedDevice["name"] ?? "未知设备")") |
|||
devicesToRemove.append(cachedDevice) |
|||
|
|||
// 发送设备断开事件 |
|||
sendDeviceDisconnectedEvent(device: cachedDevice) |
|||
} |
|||
} |
|||
|
|||
// 从缓存中移除断开的设备 |
|||
for device in devicesToRemove { |
|||
if let index = bluetoothDevices.firstIndex(where: { $0["address"] == device["address"] }) { |
|||
bluetoothDevices.remove(at: index) |
|||
} |
|||
} |
|||
|
|||
default: |
|||
break |
|||
} |
|||
} |
|||
|
|||
// 处理蓝牙状态变化 |
|||
@objc private func handleBluetoothStateChange(_ notification: Notification) { |
|||
guard let manager = centralManager else { return } |
|||
|
|||
var stateString = "unknown" |
|||
|
|||
switch manager.state { |
|||
case .poweredOn: |
|||
stateString = "on" |
|||
NSLog("\(TAG): 蓝牙状态变化: 已启用") |
|||
case .poweredOff: |
|||
stateString = "off" |
|||
NSLog("\(TAG): 蓝牙状态变化: 已关闭") |
|||
// 清空设备列表 |
|||
bluetoothDevices.removeAll() |
|||
case .resetting: |
|||
stateString = "resetting" |
|||
NSLog("\(TAG): 蓝牙状态变化: 正在重置") |
|||
case .unauthorized: |
|||
stateString = "unauthorized" |
|||
NSLog("\(TAG): 蓝牙状态变化: 未授权") |
|||
case .unsupported: |
|||
stateString = "unsupported" |
|||
NSLog("\(TAG): 蓝牙状态变化: 不支持") |
|||
case .unknown: |
|||
stateString = "unknown" |
|||
NSLog("\(TAG): 蓝牙状态变化: 未知") |
|||
@unknown default: |
|||
stateString = "unknown" |
|||
NSLog("\(TAG): 蓝牙状态变化: 未知(default)") |
|||
} |
|||
|
|||
NSLog("\(TAG): 发送蓝牙状态变化事件: \(stateString)") |
|||
|
|||
// 发送蓝牙状态变化事件 |
|||
if let callback = deviceEventCallback { |
|||
let event: [String: Any] = [ |
|||
"type": "bluetoothStateChanged", |
|||
"state": stateString, |
|||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|||
] |
|||
callback(event) |
|||
} |
|||
} |
|||
|
|||
// 发送设备连接事件 |
|||
private func sendDeviceConnectedEvent(device: [String: String]) { |
|||
if let callback = deviceEventCallback, |
|||
let name = device["name"], |
|||
let address = device["address"] { |
|||
|
|||
let deviceMap: [String: Any] = [ |
|||
"name": name, |
|||
"address": address, |
|||
"type": "a2dp" // iOS 默认认为是 A2DP 设备 |
|||
] |
|||
|
|||
let event: [String: Any] = [ |
|||
"type": "deviceConnected", |
|||
"device": deviceMap, |
|||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|||
] |
|||
|
|||
callback(event) |
|||
} |
|||
} |
|||
|
|||
// 发送设备断开事件 |
|||
private func sendDeviceDisconnectedEvent(device: [String: String]) { |
|||
if let callback = deviceEventCallback, |
|||
let name = device["name"], |
|||
let address = device["address"] { |
|||
|
|||
let deviceMap: [String: Any] = [ |
|||
"name": name, |
|||
"address": address, |
|||
"type": "a2dp" // iOS 默认认为是 A2DP 设备 |
|||
] |
|||
|
|||
let event: [String: Any] = [ |
|||
"type": "deviceDisconnected", |
|||
"device": deviceMap, |
|||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|||
] |
|||
|
|||
callback(event) |
|||
} |
|||
} |
|||
|
|||
// 检查设备是否存在于缓存中 |
|||
private func deviceExistsInCache(device: [String: String]) -> Bool { |
|||
return deviceExistsInList(device: device, list: bluetoothDevices) |
|||
} |
|||
|
|||
// 检查设备是否存在于列表中 |
|||
private func deviceExistsInList(device: [String: String], list: [[String: String]]) -> Bool { |
|||
guard let address = device["address"] else { return false } |
|||
return list.contains { $0["address"] == address } |
|||
} |
|||
|
|||
// 停止监听 |
|||
@objc func stopBluetoothDeviceMonitoring() { |
|||
NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil) |
|||
NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: "CBCentralManagerDidUpdateStateNotification"), object: nil) |
|||
} |
|||
|
|||
// 释放资源 |
|||
@objc func dispose() { |
|||
stopBluetoothDeviceMonitoring() |
|||
deviceEventCallback = nil |
|||
} |
|||
|
|||
// 打开系统设置 |
|||
@objc func openSettings() -> Bool { |
|||
NSLog("\(TAG): 尝试打开应用设置") |
|||
if let url = URL(string: UIApplication.openSettingsURLString) { |
|||
if UIApplication.shared.canOpenURL(url) { |
|||
UIApplication.shared.open(url, options: [:], completionHandler: nil) |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
// 打开蓝牙设置(iOS 只能打开系统设置,无法直接跳转到蓝牙设置) |
|||
@objc func openBluetoothSettings() -> Bool { |
|||
// 在 iOS 10+ 上直接跳转到蓝牙设置页面 |
|||
if #available(iOS 10.0, *) { |
|||
NSLog("\(TAG): 尝试使用 URL Scheme 打开蓝牙设置") |
|||
if let url = URL(string: "App-prefs:root=Bluetooth") { |
|||
if UIApplication.shared.canOpenURL(url) { |
|||
UIApplication.shared.open(url, options: [:], completionHandler: nil) |
|||
return true |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 如果无法直接跳转到蓝牙设置,则打开一般设置 |
|||
return openSettings() |
|||
} |
|||
} |
|||
@ -1 +1,7 @@ |
|||
#ifndef Runner_Bridging_Header_h |
|||
#define Runner_Bridging_Header_h |
|||
|
|||
#import "GeneratedPluginRegistrant.h" |
|||
#import <MicrosoftCognitiveServicesSpeech/SPXSpeechApi.h> |
|||
|
|||
#endif /* Runner_Bridging_Header_h */ |
|||
|
|||
@ -1,3 +0,0 @@ |
|||
// 重新导出FlutterTtsService类 |
|||
// 这个文件作为兼容层,将speech_impl/flutter_tts_service.dart中的服务导出 |
|||
export 'speech_impl/flutter_tts_service.dart'; |
|||
@ -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