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 "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