diff --git a/.metadata b/.metadata index 6ac59d7fb..b4fb38531 100644 --- a/.metadata +++ b/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 - - platform: android + - platform: ios create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 diff --git a/azure/LICENSE b/azure/LICENSE new file mode 100644 index 000000000..d3df29b8a --- /dev/null +++ b/azure/LICENSE @@ -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. \ No newline at end of file diff --git a/azure/README.md b/azure/README.md new file mode 100644 index 000000000..a19025680 --- /dev/null +++ b/azure/README.md @@ -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. \ No newline at end of file diff --git a/azure/ios/Classes/AzureAsrHelper.swift b/azure/ios/Classes/AzureAsrHelper.swift new file mode 100644 index 000000000..12bb57caf --- /dev/null +++ b/azure/ios/Classes/AzureAsrHelper.swift @@ -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.size)), "设置输入总线的EnableIO属性") { + return false + } + + var enableOutput: UInt32 = 0 + if CheckHasError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, + kAudioUnitScope_Output, kOutputBus, + &enableOutput, UInt32(MemoryLayout.size)), "设置输出总线的EnableIO属性") { + return false + } + + var flag: UInt32 = 0 + if CheckHasError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_ShouldAllocateBuffer, + kAudioUnitScope_Output, kInputBus, &flag, UInt32(MemoryLayout.size)), "设置ShouldAllocateBuffer属性") { + return false + } + + let size = UInt32(MemoryLayout.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.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.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...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 + } + } +} \ No newline at end of file diff --git a/azure/ios/Classes/AzureSpeechRecognitionPlugin.swift b/azure/ios/Classes/AzureSpeechRecognitionPlugin.swift new file mode 100644 index 000000000..141b77ef8 --- /dev/null +++ b/azure/ios/Classes/AzureSpeechRecognitionPlugin.swift @@ -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)) + } + } + } +} \ No newline at end of file diff --git a/azure/ios/Classes/AzureTtsHelper.swift b/azure/ios/Classes/AzureTtsHelper.swift new file mode 100644 index 000000000..650a4eeb8 --- /dev/null +++ b/azure/ios/Classes/AzureTtsHelper.swift @@ -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 """ + + + + \(text) + + + + """ + } + + /// 格式化语速参数 + 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 + } +} \ No newline at end of file diff --git a/azure/ios/Classes/SwiftAzureSpeechRecognitionPlugin.swift b/azure/ios/Classes/SwiftAzureSpeechRecognitionPlugin.swift new file mode 100644 index 000000000..de40821fe --- /dev/null +++ b/azure/ios/Classes/SwiftAzureSpeechRecognitionPlugin.swift @@ -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 + + 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 + + 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 + } +} \ No newline at end of file diff --git a/azure/ios/azure_speech_recognition.podspec b/azure/ios/azure_speech_recognition.podspec new file mode 100644 index 000000000..88797fb0c --- /dev/null +++ b/azure/ios/azure_speech_recognition.podspec @@ -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 \ No newline at end of file diff --git a/azure/lib/azure_speech_recognition.dart b/azure/lib/azure_speech_recognition.dart new file mode 100644 index 000000000..02b3f92d8 --- /dev/null +++ b/azure/lib/azure_speech_recognition.dart @@ -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; \ No newline at end of file diff --git a/azure/pubspec.yaml b/azure/pubspec.yaml new file mode 100644 index 000000000..a36c77bdc --- /dev/null +++ b/azure/pubspec.yaml @@ -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 \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile index e549ee22f..1a255e840 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -31,6 +31,10 @@ target 'Runner' do use_frameworks! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + # 添加 Microsoft Cognitive Services Speech SDK + pod 'MicrosoftCognitiveServicesSpeech-iOS', '~> 1.34.0' + target 'RunnerTests' do inherit! :search_paths end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 600c723fe..8296de8a7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,21 +1,32 @@ PODS: + - app_settings (5.1.1): + - Flutter - audio_service (0.0.1): - Flutter - FlutterMacOS - audio_session (0.0.1): - Flutter - - azure_speech_recognition_null_safety (0.0.1): + - audio_waveforms (0.0.1): + - Flutter + - azure_speech_recognition (0.1.0): - Flutter - - MicrosoftCognitiveServicesSpeech-iOS (~> 1.29.0) + - MicrosoftCognitiveServicesSpeech-iOS (~> 1.34.0) - device_info_plus (0.0.1): - Flutter - Flutter (1.0.0) + - flutter_blue_plus_darwin (0.0.2): + - Flutter + - FlutterMacOS - flutter_local_notifications (0.0.1): - Flutter + - flutter_tts (0.0.1): + - Flutter + - image_picker_ios (0.0.1): + - Flutter - just_audio (0.0.1): - Flutter - FlutterMacOS - - MicrosoftCognitiveServicesSpeech-iOS (1.29.0) + - MicrosoftCognitiveServicesSpeech-iOS (1.34.0) - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -23,40 +34,66 @@ PODS: - Flutter - record_darwin (1.0.0): - Flutter + - share_plus (0.0.1): + - Flutter + - speech_to_text (0.0.1): + - Flutter + - FlutterMacOS + - Try - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS + - Try (2.1.1) DEPENDENCIES: + - app_settings (from `.symlinks/plugins/app_settings/ios`) - audio_service (from `.symlinks/plugins/audio_service/darwin`) - audio_session (from `.symlinks/plugins/audio_session/ios`) - - azure_speech_recognition_null_safety (from `.symlinks/plugins/azure_speech_recognition_null_safety/ios`) + - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) + - azure_speech_recognition (from `.symlinks/plugins/azure_speech_recognition/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) + - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - just_audio (from `.symlinks/plugins/just_audio/darwin`) + - MicrosoftCognitiveServicesSpeech-iOS (~> 1.34.0) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - record_darwin (from `.symlinks/plugins/record_darwin/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) SPEC REPOS: trunk: - MicrosoftCognitiveServicesSpeech-iOS + - Try EXTERNAL SOURCES: + app_settings: + :path: ".symlinks/plugins/app_settings/ios" audio_service: :path: ".symlinks/plugins/audio_service/darwin" audio_session: :path: ".symlinks/plugins/audio_session/ios" - azure_speech_recognition_null_safety: - :path: ".symlinks/plugins/azure_speech_recognition_null_safety/ios" + audio_waveforms: + :path: ".symlinks/plugins/audio_waveforms/ios" + azure_speech_recognition: + :path: ".symlinks/plugins/azure_speech_recognition/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter + flutter_blue_plus_darwin: + :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_tts: + :path: ".symlinks/plugins/flutter_tts/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" just_audio: :path: ".symlinks/plugins/just_audio/darwin" path_provider_foundation: @@ -65,23 +102,35 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" record_darwin: :path: ".symlinks/plugins/record_darwin/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + speech_to_text: + :path: ".symlinks/plugins/speech_to_text/darwin" sqflite_darwin: :path: ".symlinks/plugins/sqflite_darwin/darwin" SPEC CHECKSUMS: + app_settings: 5127ae0678de1dcc19f2293271c51d37c89428b2 audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 - azure_speech_recognition_null_safety: aed1d7ededc73a1d0b7a342b1f4803ea166e2747 + audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf + azure_speech_recognition: 063bf4f294dbabaf2741f529f76132b2c38b744b device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_blue_plus_darwin: 09444a26fb6bdef523e55b68fc1c59af5a877ea6 flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4 + flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed - MicrosoftCognitiveServicesSpeech-iOS: 0de20a8ebb6bbd87decaeaa9a707dd40830106a6 + MicrosoftCognitiveServicesSpeech-iOS: 723eccb3e459bbb530df3d9bfe84a291074b09eb path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d record_darwin: fb1f375f1d9603714f55b8708a903bbb91ffdb0a + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + speech_to_text: 9dc43a5df3cbc2813f8c7cc9bd0fbf94268ed7ac sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + Try: 5ef669ae832617b3cee58cb2c6f99fb767a4ff96 -PODFILE CHECKSUM: 359b0564325fb2f8842a19a47bd7e63cbdcd9c04 +PODFILE CHECKSUM: f35c1d63b4b8ce9c2eb88107f162ac59923c4839 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e8a03afe1..3fa1bbd4e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,15 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 0A5EDBFC2D89C4FD00215E26 /* ClassicBluetoothHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5EDBFB2D89C4FD00215E26 /* ClassicBluetoothHelper.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3406320CAE3314A73EF78043 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5BBE10AD7954527B6BB12CFB /* Pods_RunnerTests.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - 993AA22300B2BA544C45E2B2 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D59A10400354CAD76662DBB3 /* Pods_RunnerTests.framework */; }; - F3E628587CFA36508BAB3B56 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 735C476F36391FA72F7CA27A /* Pods_Runner.framework */; }; + E1D2128FAB4726BEEED42F4C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4A9190D0838960F143BDC158 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,18 +43,22 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 02CF74744224D1C933681603 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 0906EF7A73CBA5EAFC64E05D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 0A5EDBFB2D89C4FD00215E26 /* ClassicBluetoothHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicBluetoothHelper.swift; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 478E46ED4F3AE11E50872120 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 5C3B0E63225CBCC97A48145A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 671AD9D18AB66E08CC945D07 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - 735C476F36391FA72F7CA27A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4A9190D0838960F143BDC158 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5BBE10AD7954527B6BB12CFB /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 77D16D761891F29239C8DF02 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8C1130E20389847CBD7B1AAC /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 930FE6D58BDB20688CE0A7CF /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -61,26 +66,23 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - BCC52C2C6E872DD19D86EA8B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - C0DB3B999FA4C3E6CFA90079 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - D59A10400354CAD76662DBB3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F83C5E570162235C4FE20CCB /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 9B84B894288BF76332D3F407 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 6AE85031FDE82288FAE3C673 /* Frameworks */ = { + 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 993AA22300B2BA544C45E2B2 /* Pods_RunnerTests.framework in Frameworks */, + E1D2128FAB4726BEEED42F4C /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 97C146EB1CF9000F007C117D /* Frameworks */ = { + B4459155A2535A0ED1D6C7D8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - F3E628587CFA36508BAB3B56 /* Pods_Runner.framework in Frameworks */, + 3406320CAE3314A73EF78043 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -95,6 +97,19 @@ path = RunnerTests; sourceTree = ""; }; + 776E5AB1CE0A62CBDBAB3996 /* Pods */ = { + isa = PBXGroup; + children = ( + 8C1130E20389847CBD7B1AAC /* Pods-Runner.debug.xcconfig */, + 77D16D761891F29239C8DF02 /* Pods-Runner.release.xcconfig */, + 0906EF7A73CBA5EAFC64E05D /* Pods-Runner.profile.xcconfig */, + 930FE6D58BDB20688CE0A7CF /* Pods-RunnerTests.debug.xcconfig */, + 02CF74744224D1C933681603 /* Pods-RunnerTests.release.xcconfig */, + 9B84B894288BF76332D3F407 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -113,8 +128,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, - 9DDB11208866A014130CF5A9 /* Pods */, - D1DDBFE3838710CC012E9C02 /* Frameworks */, + 776E5AB1CE0A62CBDBAB3996 /* Pods */, + A5A9FDE94E78257E9A48E2E8 /* Frameworks */, ); sourceTree = ""; }; @@ -130,6 +145,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 0A5EDBFB2D89C4FD00215E26 /* ClassicBluetoothHelper.swift */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -142,25 +158,11 @@ path = Runner; sourceTree = ""; }; - 9DDB11208866A014130CF5A9 /* Pods */ = { + A5A9FDE94E78257E9A48E2E8 /* Frameworks */ = { isa = PBXGroup; children = ( - F83C5E570162235C4FE20CCB /* Pods-Runner.debug.xcconfig */, - C0DB3B999FA4C3E6CFA90079 /* Pods-Runner.release.xcconfig */, - 478E46ED4F3AE11E50872120 /* Pods-Runner.profile.xcconfig */, - 5C3B0E63225CBCC97A48145A /* Pods-RunnerTests.debug.xcconfig */, - BCC52C2C6E872DD19D86EA8B /* Pods-RunnerTests.release.xcconfig */, - 671AD9D18AB66E08CC945D07 /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; - D1DDBFE3838710CC012E9C02 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 735C476F36391FA72F7CA27A /* Pods_Runner.framework */, - D59A10400354CAD76662DBB3 /* Pods_RunnerTests.framework */, + 4A9190D0838960F143BDC158 /* Pods_Runner.framework */, + 5BBE10AD7954527B6BB12CFB /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -172,10 +174,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 4CED93D53BF7D79C894B39B7 /* [CP] Check Pods Manifest.lock */, + 1BD7F444CF127CDFCB7F9F47 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, - 6AE85031FDE82288FAE3C673 /* Frameworks */, + B4459155A2535A0ED1D6C7D8 /* Frameworks */, ); buildRules = ( ); @@ -191,15 +193,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - CB1E1186EB3C828E55784697 /* [CP] Check Pods Manifest.lock */, + 8A6DB356BF41E96BFD7DF109 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - D7B8455F87E542E882C75D57 /* [CP] Embed Pods Frameworks */, - E839EB3BEF50366A68B2A46D /* [CP] Copy Pods Resources */, + 4A5C19D622EF675DB4056F07 /* [CP] Embed Pods Frameworks */, + D49299AF1487DF3783287D24 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -271,23 +273,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 4CED93D53BF7D79C894B39B7 /* [CP] Check Pods Manifest.lock */ = { + 1BD7F444CF127CDFCB7F9F47 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -309,22 +295,40 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 9740EEB61CF901F6004384FC /* Run Script */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); - name = "Run Script"; + name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - CB1E1186EB3C828E55784697 /* [CP] Check Pods Manifest.lock */ = { + 4A5C19D622EF675DB4056F07 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 8A6DB356BF41E96BFD7DF109 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -346,24 +350,22 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - D7B8455F87E542E882C75D57 /* [CP] Embed Pods Frameworks */ = { + 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + inputPaths = ( ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + name = "Run Script"; + outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - E839EB3BEF50366A68B2A46D /* [CP] Copy Pods Resources */ = { + D49299AF1487DF3783287D24 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -395,6 +397,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0A5EDBFC2D89C4FD00215E26 /* ClassicBluetoothHelper.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); @@ -505,7 +508,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5C3B0E63225CBCC97A48145A /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 930FE6D58BDB20688CE0A7CF /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -523,7 +526,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = BCC52C2C6E872DD19D86EA8B /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = 02CF74744224D1C933681603 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -539,7 +542,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 671AD9D18AB66E08CC945D07 /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = 9B84B894288BF76332D3F407 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -671,13 +674,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 42C82SZUVX; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 626664468..d44758105 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -3,11 +3,120 @@ import UIKit @main @objc class AppDelegate: FlutterAppDelegate { + private var classicBluetoothHelper: ClassicBluetoothHelper? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // 初始化ClassicBluetoothHelper + setupClassicBluetoothHelper() + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + private func setupClassicBluetoothHelper() { + let controller = window?.rootViewController as? FlutterViewController + if let controller = controller { + // 初始化ClassicBluetoothHelper + classicBluetoothHelper = ClassicBluetoothHelper() + + // 启动蓝牙设备监控 + classicBluetoothHelper?.startBluetoothDeviceMonitoring() + + // 注册蓝牙通道 + let channel = FlutterMethodChannel(name: "com.deep_voice.classic_bluetooth", binaryMessenger: controller.binaryMessenger) + + channel.setMethodCallHandler { [weak self] (call, result) in + guard let self = self else { + result(FlutterError(code: "UNAVAILABLE", message: "ClassicBluetoothHelper实例不可用", details: nil)) + return + } + + switch call.method { + case "isBluetoothEnabled": + result(self.classicBluetoothHelper?.isBluetoothEnabled()) + + case "getConnectedA2dpDevices": + self.classicBluetoothHelper?.getConnectedA2dpDevices { devices, error in + if let error = error { + result(FlutterError(code: "GET_A2DP_DEVICES_FAILED", message: error, details: nil)) + } else { + result(devices) + } + } + + case "getConnectedHeadsetDevices": + self.classicBluetoothHelper?.getConnectedHeadsetDevices { devices, error in + if let error = error { + result(FlutterError(code: "GET_HEADSET_DEVICES_FAILED", message: error, details: nil)) + } else { + result(devices) + } + } + + case "checkAndRequestNativePermission": + self.classicBluetoothHelper?.checkAndRequestNativePermission { permissionStatus in + result(permissionStatus) + } + + case "startBluetoothDeviceMonitoring": + self.classicBluetoothHelper?.startBluetoothDeviceMonitoring() + result(true) + + case "stopBluetoothDeviceMonitoring": + self.classicBluetoothHelper?.stopBluetoothDeviceMonitoring() + result(true) + + case "openSettings": + result(self.classicBluetoothHelper?.openSettings()) + + case "openBluetoothSettings": + result(self.classicBluetoothHelper?.openBluetoothSettings()) + + default: + result(FlutterMethodNotImplemented) + } + } + + // 注册蓝牙事件通道 + let eventChannel = FlutterEventChannel(name: "com.deep_voice.classic_bluetooth_events", binaryMessenger: controller.binaryMessenger) + + // 创建事件处理器 + let eventHandler = BluetoothStreamHandler(bluetoothHelper: self.classicBluetoothHelper!) + eventChannel.setStreamHandler(eventHandler) + } + } +} + +// 蓝牙事件流处理器 +class BluetoothStreamHandler: NSObject, FlutterStreamHandler { + private let bluetoothHelper: ClassicBluetoothHelper + private var eventSink: FlutterEventSink? + + init(bluetoothHelper: ClassicBluetoothHelper) { + self.bluetoothHelper = bluetoothHelper + super.init() + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + NSLog("BluetoothStreamHandler: 开始监听蓝牙事件") + self.eventSink = events + + // 注册设备事件回调 + bluetoothHelper.registerDeviceEventCallback { [weak self] event in + NSLog("BluetoothStreamHandler: 收到蓝牙事件 \(event)") + self?.eventSink?(event) + } + + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + NSLog("BluetoothStreamHandler: 取消监听蓝牙事件") + // 不要在这里调用dispose,只需清除回调 + eventSink = nil + return nil + } } diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c28516f..19d3697a4 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/ios/Runner/ClassicBluetoothHelper.swift b/ios/Runner/ClassicBluetoothHelper.swift new file mode 100644 index 000000000..871bf8475 --- /dev/null +++ b/ios/Runner/ClassicBluetoothHelper.swift @@ -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() + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index e52d405c9..3cd0a1f35 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,14 +26,8 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS - NSMicrophoneUsageDescription - 需要使用麦克风来进行语音交互 - UIBackgroundModes - - audio - fetch - remote-notification - + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -39,14 +35,27 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSBluetoothAlwaysUsageDescription + 需要蓝牙权限来连接和管理音频设备 + NSBluetoothPeripheralUsageDescription + 需要蓝牙权限来连接和管理音频设备 + NSMicrophoneUsageDescription + 需要麦克风权限来进行语音识别 + NSSpeechRecognitionUsageDescription + 需要语音识别权限来提供语音交互服务 + UIBackgroundModes + + bluetooth-central - NSUserNotificationsUsageDescription - 需要通知权限来保持与AI助手的实时对话 - UIViewControllerBasedStatusBarAppearance - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h index 308a2a560..72042ccf3 100644 --- a/ios/Runner/Runner-Bridging-Header.h +++ b/ios/Runner/Runner-Bridging-Header.h @@ -1 +1,7 @@ +#ifndef Runner_Bridging_Header_h +#define Runner_Bridging_Header_h + #import "GeneratedPluginRegistrant.h" +#import + +#endif /* Runner_Bridging_Header_h */ diff --git a/lib/data/services/chat_history_service.dart b/lib/data/services/chat_history_service.dart index 6647a6b41..8d14209d5 100644 --- a/lib/data/services/chat_history_service.dart +++ b/lib/data/services/chat_history_service.dart @@ -41,7 +41,7 @@ class ChatHistoryService extends GetxService { // 保存到存储 await _storage.write(_getHistoryKey(agentId), jsonString); - Logger.info('已保存聊天记录,代理: $agentId,消息数: ${messagesToSave.length}'); + // Logger.info('已保存聊天记录,代理: $agentId,消息数: ${messagesToSave.length}'); } catch (e) { Logger.error('保存聊天记录失败', e); diff --git a/lib/data/services/classic_bluetooth_service.dart b/lib/data/services/classic_bluetooth_service.dart index 634bbfb12..20f93e436 100644 --- a/lib/data/services/classic_bluetooth_service.dart +++ b/lib/data/services/classic_bluetooth_service.dart @@ -162,7 +162,9 @@ class ClassicBluetoothService extends GetxService { try { Logger.info('正在初始化 ClassicBluetoothService...'); - + // 开始监控蓝牙设备连接状态 + await _channel.invokeMethod('startBluetoothDeviceMonitoring'); + Logger.info('启动蓝牙设备监控'); // 设置事件监听器 _setupBluetoothStateListener(); @@ -187,6 +189,32 @@ class ClassicBluetoothService extends GetxService { return false; } + // 根据平台选择不同的权限检查方式 + if (GetPlatform.isIOS) { + // iOS: 使用原生权限检查 + final nativeResult = await checkNativePermission(); + if (nativeResult != null) { + Logger.info('iOS平台蓝牙权限检查结果: $nativeResult'); + + if (nativeResult['status'] == 'granted') { + return true; + } else if (nativeResult['status'] == 'denied') { + Logger.error('iOS蓝牙权限被拒绝'); + errorMessage.value = '蓝牙权限被拒绝,请在设置中允许蓝牙访问'; + return false; + } else if (nativeResult['status'] == 'disabled') { + Logger.error('iOS蓝牙未启用'); + errorMessage.value = '蓝牙未启用,请开启蓝牙'; + return false; + } + } + // 如果原生检查失败,继续使用Flutter权限包(作为备选) + Logger.info('iOS平台回退到Flutter权限检查'); + } + + // Android: 使用Flutter权限包进行检查(原有实现) + Logger.info('${GetPlatform.isAndroid ? "Android" : "通用"}平台使用Flutter权限检查'); + // 检查蓝牙相关权限 Map statuses = await [ Permission.bluetooth, @@ -211,6 +239,23 @@ class ClassicBluetoothService extends GetxService { } } + /// 使用原生方法检查蓝牙权限 + Future?> checkNativePermission() async { + try { + final result = await _channel.invokeMethod('checkAndRequestNativePermission'); + Logger.info('原生蓝牙权限检查原始结果: $result'); + + if (result is Map) { + return result; + } + + return null; + } catch (e) { + Logger.error('原生蓝牙权限检查失败: $e'); + return null; + } + } + /// 刷新已连接的蓝牙设备 Future refreshConnectedDevices() async { try { @@ -316,7 +361,7 @@ class ClassicBluetoothService extends GetxService { if (event is Map) { final type = event['type'] as String?; - if (type == 'stateChanged') { + if (type == 'bluetoothStateChanged') { final state = event['state'] as String?; if (state != null) { _handleBluetoothStateChange(state); @@ -421,8 +466,8 @@ class ClassicBluetoothService extends GetxService { /// 打开蓝牙设置 Future openBluetoothSettings() async { try { - await AppSettings.openAppSettings(); - return true; + final result = await _channel.invokeMethod('openBluetoothSettings'); + return result == true; } catch (e) { Logger.error('打开蓝牙设置失败: $e'); return false; diff --git a/lib/data/services/flutter_tts_service.dart b/lib/data/services/flutter_tts_service.dart deleted file mode 100644 index 61de220ee..000000000 --- a/lib/data/services/flutter_tts_service.dart +++ /dev/null @@ -1,3 +0,0 @@ -// 重新导出FlutterTtsService类 -// 这个文件作为兼容层,将speech_impl/flutter_tts_service.dart中的服务导出 -export 'speech_impl/flutter_tts_service.dart'; \ No newline at end of file diff --git a/lib/data/services/speech_factory.dart b/lib/data/services/speech_factory.dart index a6f1e4d50..65281520f 100644 --- a/lib/data/services/speech_factory.dart +++ b/lib/data/services/speech_factory.dart @@ -1,3 +1,4 @@ + import 'package:get/get.dart'; import 'asr_service.dart'; import 'tts_service.dart'; @@ -5,6 +6,7 @@ import 'speech_impl/azure_asr_service.dart'; import 'speech_impl/azure_tts_service.dart'; import 'speech_impl/flutter_asr_service.dart'; import 'speech_impl/flutter_tts_service.dart'; + import '../../core/utils/logger.dart'; /// 语音服务类型 @@ -54,8 +56,10 @@ class SpeechFactory extends GetxService { // 根据类型注册新服务 switch (_currentServiceType) { case SpeechServiceType.azure: - Get.lazyPut(() => AzureAsrService()); - Get.lazyPut(() => AzureTtsService()); + + Get.lazyPut(() => AzureAsrService()); + Get.lazyPut(() => AzureTtsService()); + break; case SpeechServiceType.flutter: diff --git a/lib/data/services/speech_impl/azure_tts_service.dart b/lib/data/services/speech_impl/azure_tts_service.dart index 73c182cf8..6afb3fd62 100644 --- a/lib/data/services/speech_impl/azure_tts_service.dart +++ b/lib/data/services/speech_impl/azure_tts_service.dart @@ -1,25 +1,14 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../../../core/utils/logger.dart'; import '../tts_service.dart'; -/// 音频输出设备类型 -enum AudioOutputType { - /// 扬声器 - speaker, - - /// 听筒 - earpiece, - - /// 自动选择(有耳机用耳机,没有则用听筒) - auto -} - /// 微软 Text-to-Speech 服务 /// -/// 该服务通过平台通道与 Android 上的 Microsoft Speech SDK 交互, +/// 该服务通过平台通道与原生 Microsoft Speech SDK 交互, /// 提供文本转语音功能。 class AzureTtsService extends GetxService implements TtsService { static final AzureTtsService to = Get.put(AzureTtsService()); @@ -34,7 +23,6 @@ class AzureTtsService extends GetxService implements TtsService { int _currentRate = 0; int _currentPitch = 0; int _currentVolume = 100; - AudioOutputType _currentAudioOutputType = AudioOutputType.auto; // 支持的语音列表 final List _supportedVoices = [ @@ -49,29 +37,40 @@ class AzureTtsService extends GetxService implements TtsService { final List<_SpeechItem> _textQueue = []; bool _isProcessingQueue = false; - // 事件流控制器 - final StreamController _eventController = StreamController.broadcast(); - @override - Stream get onEvent => _eventController.stream; - // 可观察状态 final isEnabled = true.obs; - final _isSpeaking = false.obs; - final audioOutputType = AudioOutputType.auto.obs; // 流式文本缓冲区 String _streamBuffer = ''; + /// 缓存的播放状态 + bool _speakingCache = false; + DateTime _lastSpeakingCheck = DateTime.now(); + @override String get currentVoice => _currentVoice; @override - bool get isSpeaking => _isSpeaking.value; + bool get isSpeaking { + // 如果未初始化,直接返回false + if (!_isInitialized) return false; + + // 如果距离上次检查不到50ms,则使用缓存值 + if (DateTime.now().difference(_lastSpeakingCheck).inMilliseconds < 50) { + return _speakingCache; + } + + // 否则异步更新缓存(但仍返回当前缓存值) + _checkSpeakingStatus(); + return _speakingCache; + } + + @override + Stream get onEvent => const Stream.empty(); @override List get supportedVoices => _supportedVoices; - AudioOutputType get currentAudioOutputType => _currentAudioOutputType; AzureTtsService() { _loadConfig(); @@ -112,15 +111,6 @@ class AzureTtsService extends GetxService implements TtsService { }); _isInitialized = result; - - // 确保设置默认的音频输出设备类型 - if (result) { - // 不需要显式调用 setAudioOutputType,因为原生层已经设置了默认值 - // 只需要确保 Dart 层的状态与原生层一致 - _currentAudioOutputType = AudioOutputType.auto; - audioOutputType.value = AudioOutputType.auto; - } - return result; } else { // 使用默认语言初始化 @@ -131,21 +121,10 @@ class AzureTtsService extends GetxService implements TtsService { }); _isInitialized = result; - - // 确保设置默认的音频输出设备类型 - if (result) { - _currentAudioOutputType = AudioOutputType.auto; - audioOutputType.value = AudioOutputType.auto; - } - return result; } } catch (e) { Logger.error('初始化失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('TTS初始化失败: $e')); - return false; } } @@ -164,36 +143,6 @@ class AzureTtsService extends GetxService implements TtsService { return result; } catch (e) { Logger.error('设置语音失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('设置语音失败: $e')); - - return false; - } - } - - /// 设置音频输出设备类型 - Future setAudioOutputType(AudioOutputType outputType) async { - if (!_isInitialized) await initialize(); - - try { - final String outputTypeStr = outputType.toString().split('.').last; - final result = await _channel.invokeMethod('setAudioOutputType', { - 'outputType': outputTypeStr, - }); - - if (result) { - _currentAudioOutputType = outputType; - audioOutputType.value = outputType; - } - - return result; - } catch (e) { - Logger.error('设置音频输出设备失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('设置音频输出设备失败: $e')); - return false; } } @@ -218,10 +167,6 @@ class AzureTtsService extends GetxService implements TtsService { return result; } catch (e) { Logger.error('设置语音参数失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('设置语音参数失败: $e')); - return false; } } @@ -232,26 +177,19 @@ class AzureTtsService extends GetxService implements TtsService { if (!isEnabled.value || text.isEmpty) return false; try { - _isSpeaking.value = true; - - // 发送开始事件 - _eventController.add(TtsEvent(type: TtsEventType.started)); + // 主动设置缓存状态为true + _speakingCache = true; final result = await _channel.invokeMethod('speakText', {'text': text}); - _isSpeaking.value = false; - - // 发送完成事件 - _eventController.add(TtsEvent(type: TtsEventType.completed)); + // 调用完成后主动检查状态 + _checkSpeakingStatus(); return result == "OK"; } catch (e) { - _isSpeaking.value = false; + // 发生错误时重置状态 + _speakingCache = false; Logger.error('语音合成失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('语音合成失败: $e')); - return false; } } @@ -262,30 +200,44 @@ class AzureTtsService extends GetxService implements TtsService { if (!isEnabled.value || ssml.isEmpty) return false; try { - _isSpeaking.value = true; - - // 发送开始事件 - _eventController.add(TtsEvent(type: TtsEventType.started)); + // 主动设置缓存状态为true + _speakingCache = true; final result = await _channel.invokeMethod('speakSsml', {'ssml': ssml}); - _isSpeaking.value = false; - - // 发送完成事件 - _eventController.add(TtsEvent(type: TtsEventType.completed)); + // 调用完成后主动检查状态 + _checkSpeakingStatus(); return result == "OK"; } catch (e) { - _isSpeaking.value = false; + // 发生错误时重置状态 + _speakingCache = false; Logger.error('SSML语音合成失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('SSML语音合成失败: $e')); - return false; } } + /// 等待语音播放完成 + Future waitForSpeechCompletion({Duration timeout = const Duration(seconds: 30)}) async { + // 首先检查当前状态 + _checkSpeakingStatus(); + if (!_speakingCache) return; + + final stopwatch = Stopwatch()..start(); + + while (_speakingCache && stopwatch.elapsed < timeout) { + // 短暂等待后再次检查 + await Future.delayed(const Duration(milliseconds: 100)); + // 检查状态 + await _channel.invokeMethod('isSpeaking').then((result) { + if (result != null) { + _speakingCache = result; + _lastSpeakingCheck = DateTime.now(); + } + }); + } + } + @override Future speakStream(String text) async { if (!isEnabled.value || text.isEmpty) return false; @@ -343,10 +295,6 @@ class AzureTtsService extends GetxService implements TtsService { return true; } catch (e) { Logger.error('处理流式文本失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('处理流式文本失败: $e')); - return false; } } @@ -380,18 +328,16 @@ class AzureTtsService extends GetxService implements TtsService { Logger.info('等待TTS队列播放完成,剩余${_textQueue.length}条'); // 等待队列处理完成 - while (_isProcessingQueue || _isSpeaking.value) { + await _checkSpeakingStatus(); + while (_isProcessingQueue || _speakingCache) { await Future.delayed(const Duration(milliseconds: 100)); + await _checkSpeakingStatus(); } Logger.info('TTS队列播放完成'); return true; } catch (e) { Logger.error('刷新TTS流失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('刷新TTS流失败: $e')); - return false; } } @@ -421,12 +367,12 @@ class AzureTtsService extends GetxService implements TtsService { // 使用speakOnce播放当前项 await speakOnce(item.text); + + // 等待播放完成 + await waitForSpeechCompletion(); } } catch (e) { Logger.error('处理语音队列出错: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('处理语音队列出错: $e')); } finally { _isProcessingQueue = false; } @@ -437,23 +383,16 @@ class AzureTtsService extends GetxService implements TtsService { try { _textQueue.clear(); - if (_isSpeaking.value) { + if (_speakingCache) { final result = await _channel.invokeMethod('stopSpeaking'); - _isSpeaking.value = false; - - // 发送取消事件 - _eventController.add(TtsEvent(type: TtsEventType.canceled)); - + // 重置缓存状态 + _speakingCache = false; return result; } return true; } catch (e) { Logger.error('停止语音合成出错: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('停止语音合成出错: $e')); - return false; } } @@ -492,25 +431,6 @@ class AzureTtsService extends GetxService implements TtsService { if (!isEnabled.value) stop(); } - /// 切换音频输出设备类型 - Future toggleAudioOutputType() async { - AudioOutputType newType; - - switch (_currentAudioOutputType) { - case AudioOutputType.speaker: - newType = AudioOutputType.earpiece; - break; - case AudioOutputType.earpiece: - newType = AudioOutputType.auto; - break; - case AudioOutputType.auto: - newType = AudioOutputType.speaker; - break; - } - - return await setAudioOutputType(newType); - } - @override Future dispose() async { if (!_isInitialized) return; @@ -518,40 +438,27 @@ class AzureTtsService extends GetxService implements TtsService { try { await stop(); await _channel.invokeMethod('dispose'); - - // 关闭事件流控制器 - await _eventController.close(); - _isInitialized = false; } catch (e) { Logger.error('释放资源失败: $e'); } } - /// 处理AI返回的文本流,将其转换为语音 - /// - /// [textStream] 文本流 - /// 返回一个Future,当流处理完成时完成 - Future processAiTextStream(Stream textStream) async { - if (!isEnabled.value) { - Logger.error('TTS服务未启用'); - return false; - } - + /// 异步检查并更新播放状态缓存 + Future _checkSpeakingStatus() async { + _lastSpeakingCheck = DateTime.now(); try { - await for (final text in textStream) { - if (text.isEmpty) continue; - await speakStream(text); + final result = await _channel.invokeMethod('isSpeaking'); + if (result != null) { + _speakingCache = result; } - - // 处理完流后,刷新缓冲区中的剩余文本 - await flushStream(); - return true; + return _speakingCache; } catch (e) { - Logger.error('处理文本流时出错: $e'); - return false; + Logger.error('获取播放状态失败: $e'); + return _speakingCache; } } + } /// 语音合成项目 diff --git a/lib/data/services/speech_impl/flutter_asr_service.dart b/lib/data/services/speech_impl/flutter_asr_service.dart index 0ae32a508..e17fb9198 100644 --- a/lib/data/services/speech_impl/flutter_asr_service.dart +++ b/lib/data/services/speech_impl/flutter_asr_service.dart @@ -165,12 +165,22 @@ class FlutterAsrService extends GetxService implements AsrService { // 如果当前正在监听,先停止 if (_speech.isListening) { await _speech.stop(); - // 短暂延迟确保资源释放 - await Future.delayed(const Duration(milliseconds: 300)); + // 延长等待时间,确保资源完全释放 + await Future.delayed(const Duration(milliseconds: 800)); } + // 在启动前再次检查状态 + if (_speech.isListening) { + Logger.warning('停止识别后,仍处于监听状态,可能有系统冲突'); + await _speech.stop(); + await Future.delayed(const Duration(milliseconds: 500)); + } + + // 启动语音识别前确保状态正确 + _asrState.value = AsrState.listening; + // 启动语音识别 - return await _speech.listen( + final result = await _speech.listen( onResult: onResult, localeId: _currentLocale.value, listenFor: listenFor, @@ -178,10 +188,20 @@ class FlutterAsrService extends GetxService implements AsrService { listenOptions: stt.SpeechListenOptions( partialResults: true, autoPunctuation: autoPunctuation, + // 使用独白模式,减少与系统交互冲突 + listenMode: stt.ListenMode.dictation, ), ) ?? false; + + if (!result) { + Logger.error('语音识别启动失败'); + _asrState.value = AsrState.error; + } + + return result; } catch (e) { Logger.error('启动语音识别失败: $e'); + _asrState.value = AsrState.error; return false; } } @@ -195,12 +215,29 @@ class FlutterAsrService extends GetxService implements AsrService { return; } - // 短暂延迟后再重新启动 - await Future.delayed(const Duration(milliseconds: 300)); + // 检查系统状态,避免频繁重启 + if (_speech.isListening) { + Logger.info('当前已在监听中,无需重启'); + return; + } + + // 延长等待时间,避免与系统资源冲突 + await Future.delayed(const Duration(milliseconds: 1000)); + + // 再次检查状态 + if (_continuousRecognitionController == null || _continuousRecognitionController!.isClosed) { + Logger.info('延迟期间控制器已关闭,跳过重启'); + return; + } + + Logger.info('尝试重新启动连续识别'); // 使用统一方法启动识别 final success = await _startListening( onResult: _onSpeechResult, + // 增加超时时间和暂停时间 + listenFor: const Duration(seconds: 30), + pauseFor: const Duration(seconds: 4), ); if (success) { @@ -208,11 +245,16 @@ class FlutterAsrService extends GetxService implements AsrService { } else { Logger.error('连续识别重启失败'); _continuousRecognitionController?.add( - RecognitionEvent.error('重启语音识别失败') + RecognitionEvent.error('重启语音识别失败,可能存在系统资源冲突') ); } } catch (e) { Logger.error('重启连续识别失败: $e'); + + // 发送错误给控制器 + _continuousRecognitionController?.add( + RecognitionEvent.error('重启失败: $e') + ); } } diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index 88dbd0d1d..e58e19e4c 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -109,6 +109,7 @@ class VoiceInteractionService extends GetxService { /// 初始化服务 Future initialize() async { + return this; try { if (_isInitialized) { return this; @@ -146,6 +147,7 @@ class VoiceInteractionService extends GetxService { /// 启动语音交互服务 Future startService() async { + return true; if (_isServiceRunning.value) { Logger.info('语音交互服务已经在运行'); return true; @@ -198,6 +200,7 @@ class VoiceInteractionService extends GetxService { /// 暂停语音交互(停止TTS和语音识别,但保持服务运行) Future pauseVoiceInteraction() async { + return true; if (!_isServiceRunning.value) { Logger.info('语音交互服务未运行,无法暂停'); return false; diff --git a/lib/main.dart b/lib/main.dart index e374b1554..5eef65cb0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -44,10 +44,12 @@ void main() async { } } + // 运行应用 runApp(const MainApp()); } + class MainApp extends StatelessWidget { const MainApp({super.key}); diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index 71ae638a2..b5567e65e 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -100,6 +100,12 @@ class HomeView extends GetView { _buildSectionTitle(isDarkMode), SizedBox(height: 16.h), _buildPremiumAILayout(), + + // 在合适的位置添加测试按钮 + ElevatedButton( + onPressed: () => Get.toNamed(Routes.ASR_TEST), + child: const Text('Azure语音识别测试'), + ), ], ), ), @@ -640,14 +646,16 @@ class HomeView extends GetView { overflow: TextOverflow.ellipsis, ), SizedBox(height: 4.h), - Text( - description, - style: TextStyle( - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - fontSize: 12.sp, + Flexible( + child: Text( + description, + style: TextStyle( + color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + fontSize: 12.sp, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), ], ), @@ -671,14 +679,16 @@ class HomeView extends GetView { overflow: TextOverflow.ellipsis, ), SizedBox(height: 2.h), - Text( - description, - style: TextStyle( - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - fontSize: 10.sp, + Flexible( + child: Text( + description, + style: TextStyle( + color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + fontSize: 10.sp, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), ], ), diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 7c3388e5e..acb1f1e8b 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -64,7 +64,6 @@ abstract class AppPages { page: () => const ProfileView(), binding: ProfileBinding(), ), - GetPage( name: Routes.translation, page: () => const TranslationView(), @@ -80,5 +79,6 @@ abstract class AppPages { page: () => const SettingsView(), binding: SettingsBinding(), ), + ]; } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 245cb88bd..3d1b8cc58 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,8 +14,6 @@ dependencies: get_storage: ^2.1.1 http: ^1.3.0 flutter_screenutil: ^5.9.0 - just_audio: ^0.9.36 - flutter_azure_tts: ^0.2.3 path_provider: ^2.1.5 audio_session: ^0.1.18 flutter_markdown: ^0.6.18+2 @@ -30,8 +28,11 @@ dependencies: flutter_tts: ^4.2.2 logger: ^2.0.2+1 flutter_blue_plus: ^1.31.16 - # shared_preferences: ^2.2.2 # 移除,使用 get_storage 代替 - # speech_to_text: ^7.0.0 # 移除,使用自定义的 ASR 服务 + just_audio: ^0.9.36 + azure_speech_recognition: + path: ./azure + + window_size: git: url: https://github.com/google/flutter-desktop-embedding.git diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 11ee9670c..000000000 --- a/test/widget_test.dart +++ /dev/null @@ -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); - }); -}