@ -0,0 +1,2 @@ |
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig" |
|||
#include "Generated.xcconfig" |
|||
@ -1,2 +1,3 @@ |
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" |
|||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig" |
|||
#include "Generated.xcconfig" |
|||
|
|||
@ -0,0 +1,213 @@ |
|||
{ |
|||
"originHash" : "fb7ecff82f4ff109531189491cc6cc2299a7357c61874f58bae1e0f7b7d9e8d0", |
|||
"pins" : [ |
|||
{ |
|||
"identity" : "abseil-cpp-binary", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/abseil-cpp-binary.git", |
|||
"state" : { |
|||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", |
|||
"version" : "1.2024072200.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "app-check", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/app-check.git", |
|||
"state" : { |
|||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5", |
|||
"version" : "11.2.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "appauth-ios", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/openid/AppAuth-iOS.git", |
|||
"state" : { |
|||
"revision" : "2781038865a80e2c425a1da12cc1327bcd56501f", |
|||
"version" : "1.7.6" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "dkcamera", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/zhangao0086/DKCamera", |
|||
"state" : { |
|||
"branch" : "master", |
|||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "dkimagepickercontroller", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/zhangao0086/DKImagePickerController", |
|||
"state" : { |
|||
"branch" : "4.3.9", |
|||
"revision" : "0bdfeacefa308545adde07bef86e349186335915" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "dkphotogallery", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/zhangao0086/DKPhotoGallery", |
|||
"state" : { |
|||
"branch" : "master", |
|||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "firebase-ios-sdk", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/firebase-ios-sdk", |
|||
"state" : { |
|||
"revision" : "3663b1aa6c7a1bed67ee80fd09dc6d0f9c3bb660", |
|||
"version" : "11.13.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "flutterfire", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/flutterfire", |
|||
"state" : { |
|||
"revision" : "a80a123386fd4904cad6938673020a7bcf31b2f2", |
|||
"version" : "3.13.0-firebase-core-swift" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googleappmeasurement", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleAppMeasurement.git", |
|||
"state" : { |
|||
"revision" : "543071966b3fb6613a2fc5c6e7112d1e998184a7", |
|||
"version" : "11.13.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googledatatransport", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleDataTransport.git", |
|||
"state" : { |
|||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9", |
|||
"version" : "10.1.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googlesignin-ios", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleSignIn-iOS.git", |
|||
"state" : { |
|||
"revision" : "a7965d134c5d3567026c523e0a8a583f73b62b0d", |
|||
"version" : "7.1.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googleutilities", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleUtilities.git", |
|||
"state" : { |
|||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e", |
|||
"version" : "8.1.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "grpc-binary", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/grpc-binary.git", |
|||
"state" : { |
|||
"revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", |
|||
"version" : "1.69.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "gtm-session-fetcher", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/gtm-session-fetcher.git", |
|||
"state" : { |
|||
"revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", |
|||
"version" : "3.5.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "gtmappauth", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GTMAppAuth.git", |
|||
"state" : { |
|||
"revision" : "5d7d66f647400952b1758b230e019b07c0b4b22a", |
|||
"version" : "4.1.1" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "interop-ios-for-google-sdks", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git", |
|||
"state" : { |
|||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", |
|||
"version" : "101.0.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "leveldb", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/leveldb.git", |
|||
"state" : { |
|||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", |
|||
"version" : "1.22.5" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "nanopb", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/nanopb.git", |
|||
"state" : { |
|||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", |
|||
"version" : "2.30910.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "promises", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/promises.git", |
|||
"state" : { |
|||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", |
|||
"version" : "2.4.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "sdwebimage", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/SDWebImage/SDWebImage", |
|||
"state" : { |
|||
"revision" : "cac9a55a3ae92478a2c95042dcc8d9695d2129ca", |
|||
"version" : "5.21.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "swift-protobuf", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/apple/swift-protobuf.git", |
|||
"state" : { |
|||
"revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", |
|||
"version" : "1.29.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "swiftygif", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/kirualex/SwiftyGif.git", |
|||
"state" : { |
|||
"revision" : "4430cbc148baa3907651d40562d96325426f409a", |
|||
"version" : "5.4.5" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "tocropviewcontroller", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/TimOliver/TOCropViewController", |
|||
"state" : { |
|||
"revision" : "a634cb7cdfd580006e79a6e74e64417fe9e9783b", |
|||
"version" : "2.7.4" |
|||
} |
|||
} |
|||
], |
|||
"version" : 3 |
|||
} |
|||
@ -0,0 +1,213 @@ |
|||
{ |
|||
"originHash" : "fb7ecff82f4ff109531189491cc6cc2299a7357c61874f58bae1e0f7b7d9e8d0", |
|||
"pins" : [ |
|||
{ |
|||
"identity" : "abseil-cpp-binary", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/abseil-cpp-binary.git", |
|||
"state" : { |
|||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", |
|||
"version" : "1.2024072200.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "app-check", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/app-check.git", |
|||
"state" : { |
|||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5", |
|||
"version" : "11.2.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "appauth-ios", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/openid/AppAuth-iOS.git", |
|||
"state" : { |
|||
"revision" : "2781038865a80e2c425a1da12cc1327bcd56501f", |
|||
"version" : "1.7.6" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "dkcamera", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/zhangao0086/DKCamera", |
|||
"state" : { |
|||
"branch" : "master", |
|||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "dkimagepickercontroller", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/zhangao0086/DKImagePickerController", |
|||
"state" : { |
|||
"branch" : "4.3.9", |
|||
"revision" : "0bdfeacefa308545adde07bef86e349186335915" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "dkphotogallery", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/zhangao0086/DKPhotoGallery", |
|||
"state" : { |
|||
"branch" : "master", |
|||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "firebase-ios-sdk", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/firebase-ios-sdk", |
|||
"state" : { |
|||
"revision" : "3663b1aa6c7a1bed67ee80fd09dc6d0f9c3bb660", |
|||
"version" : "11.13.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "flutterfire", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/flutterfire", |
|||
"state" : { |
|||
"revision" : "a80a123386fd4904cad6938673020a7bcf31b2f2", |
|||
"version" : "3.13.0-firebase-core-swift" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googleappmeasurement", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleAppMeasurement.git", |
|||
"state" : { |
|||
"revision" : "543071966b3fb6613a2fc5c6e7112d1e998184a7", |
|||
"version" : "11.13.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googledatatransport", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleDataTransport.git", |
|||
"state" : { |
|||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9", |
|||
"version" : "10.1.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googlesignin-ios", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleSignIn-iOS.git", |
|||
"state" : { |
|||
"revision" : "a7965d134c5d3567026c523e0a8a583f73b62b0d", |
|||
"version" : "7.1.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googleutilities", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleUtilities.git", |
|||
"state" : { |
|||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e", |
|||
"version" : "8.1.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "grpc-binary", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/grpc-binary.git", |
|||
"state" : { |
|||
"revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", |
|||
"version" : "1.69.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "gtm-session-fetcher", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/gtm-session-fetcher.git", |
|||
"state" : { |
|||
"revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", |
|||
"version" : "3.5.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "gtmappauth", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GTMAppAuth.git", |
|||
"state" : { |
|||
"revision" : "5d7d66f647400952b1758b230e019b07c0b4b22a", |
|||
"version" : "4.1.1" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "interop-ios-for-google-sdks", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git", |
|||
"state" : { |
|||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", |
|||
"version" : "101.0.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "leveldb", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/leveldb.git", |
|||
"state" : { |
|||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", |
|||
"version" : "1.22.5" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "nanopb", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/nanopb.git", |
|||
"state" : { |
|||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", |
|||
"version" : "2.30910.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "promises", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/promises.git", |
|||
"state" : { |
|||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", |
|||
"version" : "2.4.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "sdwebimage", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/SDWebImage/SDWebImage", |
|||
"state" : { |
|||
"revision" : "cac9a55a3ae92478a2c95042dcc8d9695d2129ca", |
|||
"version" : "5.21.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "swift-protobuf", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/apple/swift-protobuf.git", |
|||
"state" : { |
|||
"revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", |
|||
"version" : "1.29.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "swiftygif", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/kirualex/SwiftyGif.git", |
|||
"state" : { |
|||
"revision" : "4430cbc148baa3907651d40562d96325426f409a", |
|||
"version" : "5.4.5" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "tocropviewcontroller", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/TimOliver/TOCropViewController", |
|||
"state" : { |
|||
"revision" : "a634cb7cdfd580006e79a6e74e64417fe9e9783b", |
|||
"version" : "2.7.4" |
|||
} |
|||
} |
|||
], |
|||
"version" : 3 |
|||
} |
|||
@ -1,5 +1,12 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict/> |
|||
<dict> |
|||
<key>com.apple.developer.networking.wifi-info</key> |
|||
<true/> |
|||
<key>com.apple.external-accessory.wireless-configuration</key> |
|||
<true/> |
|||
<key>com.apple.developer.homekit</key> |
|||
<true/> |
|||
</dict> |
|||
</plist> |
|||
|
|||
@ -1,478 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'package:flutter/foundation.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:speech_to_text/speech_to_text.dart' as stt; |
|||
import 'package:speech_to_text/speech_recognition_result.dart'; |
|||
import 'package:speech_to_text/speech_recognition_error.dart'; |
|||
import '../../../core/utils/logger.dart'; |
|||
import '../asr_service.dart'; |
|||
|
|||
/// ASR状态枚举 |
|||
enum AsrState { |
|||
/// 未初始化 |
|||
notInitialized, |
|||
|
|||
/// 已初始化但未开始监听 |
|||
initialized, |
|||
|
|||
/// 正在监听 |
|||
listening, |
|||
|
|||
/// 正在处理识别结果 |
|||
processing, |
|||
|
|||
/// 识别完成 |
|||
done, |
|||
|
|||
/// 发生错误 |
|||
error, |
|||
} |
|||
|
|||
/// Flutter ASR服务,负责本地语音转文本功能 |
|||
/// 使用speech_to_text插件实现语音识别功能 |
|||
class FlutterAsrService extends GetxService implements AsrService { |
|||
static final FlutterAsrService to = Get.put(FlutterAsrService()); |
|||
|
|||
// speech_to_text 实例 |
|||
final stt.SpeechToText _speech = stt.SpeechToText(); |
|||
|
|||
// 可用的语音识别语言 |
|||
final List<String> _supportedLanguages = []; |
|||
@override |
|||
List<String> get supportedLanguages => _supportedLanguages; |
|||
|
|||
// 当前选择的语言 |
|||
final RxString _currentLocale = ''.obs; |
|||
String get currentLocale => _currentLocale.value; |
|||
|
|||
// 是否可用 |
|||
final RxBool _isAvailable = false.obs; |
|||
bool get isAvailable => _isAvailable.value; |
|||
|
|||
// 是否正在监听 |
|||
bool _isListening = false; |
|||
|
|||
// 当前ASR状态 |
|||
final Rx<AsrState> _asrState = AsrState.notInitialized.obs; |
|||
AsrState get asrState => _asrState.value; |
|||
|
|||
// 连续识别模式的结果流控制器 |
|||
StreamController<RecognitionEvent>? _continuousRecognitionController; |
|||
|
|||
FlutterAsrService() { |
|||
_initSpeech(); |
|||
} |
|||
|
|||
/// 初始化语音识别 |
|||
Future<void> _initSpeech() async { |
|||
try { |
|||
_asrState.value = AsrState.notInitialized; |
|||
|
|||
// 初始化语音识别,使用较短的最终超时时间,提高响应速度 |
|||
_isAvailable.value = await _speech.initialize( |
|||
onStatus: _onSpeechStatus, |
|||
onError: _onSpeechError, |
|||
debugLogging: false, |
|||
finalTimeout: const Duration(milliseconds: 800), // 更短的最终超时时间,加速结果返回 |
|||
); |
|||
|
|||
if (_isAvailable.value) { |
|||
// 获取支持的语言列表 |
|||
final systemLocales = await _speech.locales(); |
|||
|
|||
// 清空并重新填充支持的语言列表 |
|||
_supportedLanguages.clear(); |
|||
for (var locale in systemLocales) { |
|||
if (!_supportedLanguages.contains(locale.localeId)) { |
|||
_supportedLanguages.add(locale.localeId); |
|||
} |
|||
} |
|||
|
|||
// 设置默认语言(优先使用系统语言或中文) |
|||
var systemLocale = await _speech.systemLocale(); |
|||
if (systemLocale != null) { |
|||
_currentLocale.value = systemLocale.localeId; |
|||
} else if (_supportedLanguages.contains('zh-CN')) { |
|||
_currentLocale.value = 'zh-CN'; |
|||
} else if (_supportedLanguages.isNotEmpty) { |
|||
_currentLocale.value = _supportedLanguages.first; |
|||
} |
|||
|
|||
_asrState.value = AsrState.initialized; |
|||
Logger.info('语音识别初始化成功,支持 ${_supportedLanguages.length} 种语言'); |
|||
} else { |
|||
_asrState.value = AsrState.error; |
|||
Logger.error('语音识别不可用'); |
|||
} |
|||
} catch (e) { |
|||
_isAvailable.value = false; |
|||
_asrState.value = AsrState.error; |
|||
Logger.error('语音识别初始化失败: ${e.toString()}'); |
|||
} |
|||
} |
|||
|
|||
/// 处理语音识别状态变化 |
|||
void _onSpeechStatus(String status) { |
|||
Logger.info('语音识别状态: $status'); |
|||
|
|||
switch (status) { |
|||
case 'listening': |
|||
_asrState.value = AsrState.listening; |
|||
_isListening = true; |
|||
break; |
|||
case 'notListening': |
|||
if (_asrState.value == AsrState.listening) { |
|||
_asrState.value = AsrState.processing; |
|||
} |
|||
_isListening = false; |
|||
break; |
|||
case 'done': |
|||
_asrState.value = AsrState.done; |
|||
_isListening = false; |
|||
|
|||
// 在连续识别模式下,当一次识别完成后立即开始下一次识别 |
|||
if (_continuousRecognitionController != null && |
|||
!_continuousRecognitionController!.isClosed) { |
|||
_restartContinuousRecognition(); |
|||
} |
|||
break; |
|||
} |
|||
} |
|||
|
|||
/// 处理语音识别错误 |
|||
void _onSpeechError(SpeechRecognitionError error) { |
|||
Logger.error('语音识别错误: ${error.errorMsg} (永久性错误: ${error.permanent})'); |
|||
_asrState.value = AsrState.error; |
|||
|
|||
// 向连续识别控制器发送错误事件(如果存在) |
|||
_continuousRecognitionController |
|||
?.add(RecognitionEvent.error(error.errorMsg)); |
|||
|
|||
// 在连续识别模式下,尝试从错误中恢复(非永久性错误) |
|||
if (!error.permanent && |
|||
_continuousRecognitionController != null && |
|||
!_continuousRecognitionController!.isClosed) { |
|||
_restartContinuousRecognition(); |
|||
} |
|||
} |
|||
|
|||
/// 启动语音识别的统一方法 |
|||
Future<bool> _startListening({ |
|||
required Function(SpeechRecognitionResult) onResult, |
|||
Duration listenFor = const Duration(seconds: 30), |
|||
Duration pauseFor = const Duration(seconds: 3), |
|||
bool autoPunctuation = true, |
|||
}) async { |
|||
try { |
|||
// 如果当前正在监听,先停止 |
|||
if (_speech.isListening) { |
|||
await _speech.stop(); |
|||
// 延长等待时间,确保资源完全释放 |
|||
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; |
|||
|
|||
// 启动语音识别 |
|||
final result = await _speech.listen( |
|||
onResult: onResult, |
|||
localeId: _currentLocale.value, |
|||
listenFor: listenFor, |
|||
pauseFor: pauseFor, |
|||
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.toString()}'); |
|||
_asrState.value = AsrState.error; |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 重新启动连续识别 |
|||
Future<void> _restartContinuousRecognition() async { |
|||
try { |
|||
// 确保控制器有效 |
|||
if (_continuousRecognitionController == null || |
|||
_continuousRecognitionController!.isClosed) { |
|||
Logger.info('连续识别控制器已关闭,跳过重启'); |
|||
return; |
|||
} |
|||
|
|||
// 检查系统状态,避免频繁重启 |
|||
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) { |
|||
Logger.info('连续识别重启成功'); |
|||
} else { |
|||
Logger.error('连续识别重启失败'); |
|||
_continuousRecognitionController |
|||
?.add(RecognitionEvent.error('重启语音识别失败,可能存在系统资源冲突')); |
|||
} |
|||
} catch (e) { |
|||
Logger.error('重启连续识别失败: ${e.toString()}'); |
|||
|
|||
// 发送错误给控制器 |
|||
_continuousRecognitionController?.add(RecognitionEvent.error('重启失败: $e')); |
|||
} |
|||
} |
|||
|
|||
/// 处理语音识别结果 |
|||
void _onSpeechResult(SpeechRecognitionResult result) { |
|||
// 如果是连续识别,发送到连续识别控制器 |
|||
if (_continuousRecognitionController != null) { |
|||
if (result.finalResult) { |
|||
Logger.info('收到最终识别结果: "${result.recognizedWords}"'); |
|||
_continuousRecognitionController!.add(RecognitionEvent.finalResult( |
|||
text: result.recognizedWords, |
|||
detectedLanguage: currentLocale, |
|||
)); |
|||
} else { |
|||
_continuousRecognitionController!.add(RecognitionEvent( |
|||
type: RecognitionEventType.intermediateResult, |
|||
text: result.recognizedWords, |
|||
detectedLanguage: currentLocale, |
|||
)); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 设置当前语言 |
|||
void setLocale(String localeId) { |
|||
if (_supportedLanguages.contains(localeId)) { |
|||
_currentLocale.value = localeId; |
|||
Logger.info('设置语音识别语言: $localeId'); |
|||
} else { |
|||
Logger.error('不支持的语言: $localeId'); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> initialize({ |
|||
List<String>? supportedLanguages, |
|||
}) async { |
|||
try { |
|||
if (supportedLanguages != null && supportedLanguages.isNotEmpty) { |
|||
_supportedLanguages.clear(); |
|||
_supportedLanguages.addAll(supportedLanguages); |
|||
} |
|||
|
|||
// 初始化语音识别 |
|||
await _initSpeech(); |
|||
return _isAvailable.value; |
|||
} catch (e) { |
|||
Logger.error('语音识别初始化失败: ${e.toString()}'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<RecognitionEvent> recognizeOnce() async { |
|||
try { |
|||
if (!_isAvailable.value) { |
|||
return RecognitionEvent.error('语音识别不可用'); |
|||
} |
|||
|
|||
if (_isListening) { |
|||
await stopContinuousRecognition(); |
|||
} |
|||
|
|||
// 创建Completer来等待最终结果 |
|||
final completer = Completer<RecognitionEvent>(); |
|||
|
|||
// 开始监听 |
|||
_asrState.value = AsrState.listening; |
|||
|
|||
// 定义结果处理函数 |
|||
void resultListener(SpeechRecognitionResult result) { |
|||
if (result.finalResult) { |
|||
// 只有在最终结果时才完成completer |
|||
completer.complete(RecognitionEvent.finalResult( |
|||
text: result.recognizedWords, |
|||
detectedLanguage: currentLocale, |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// 使用统一方法启动识别,单次识别使用更短的时间参数 |
|||
final success = await _startListening( |
|||
onResult: resultListener, |
|||
listenFor: const Duration(seconds: 15), |
|||
pauseFor: const Duration(seconds: 2), |
|||
); |
|||
|
|||
if (!success) { |
|||
return RecognitionEvent.error('启动语音识别失败'); |
|||
} |
|||
|
|||
// 设置较短的超时时间 |
|||
return await completer.future.timeout( |
|||
const Duration(seconds: 20), // 略长于listenFor以确保获取到结果 |
|||
onTimeout: () { |
|||
_speech.stop(); |
|||
return RecognitionEvent.error('语音识别超时'); |
|||
}, |
|||
); |
|||
} catch (e) { |
|||
Logger.error('一次性语音识别失败: ${e.toString()}'); |
|||
return RecognitionEvent.error('$e'); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<Stream<RecognitionEvent>> startContinuousRecognition( |
|||
bool audioSourceType) async { |
|||
try { |
|||
if (!_isAvailable.value) { |
|||
final controller = StreamController<RecognitionEvent>.broadcast(); |
|||
controller.add(RecognitionEvent.error('语音识别不可用')); |
|||
controller.close(); |
|||
return controller.stream; |
|||
} |
|||
|
|||
// 如果已经在监听,先停止 |
|||
if (_isListening) { |
|||
await stopContinuousRecognition(); |
|||
} |
|||
|
|||
// 创建新的流控制器 |
|||
_continuousRecognitionController = |
|||
StreamController<RecognitionEvent>.broadcast(); |
|||
|
|||
// 开始会话 |
|||
_continuousRecognitionController! |
|||
.add(RecognitionEvent(type: RecognitionEventType.sessionStarted)); |
|||
|
|||
// 开始连续监听 |
|||
_asrState.value = AsrState.listening; |
|||
|
|||
// 使用统一方法启动识别,连续识别使用较长的参数 |
|||
final success = await _startListening( |
|||
onResult: _onSpeechResult, |
|||
); |
|||
|
|||
if (!success) { |
|||
// 添加错误事件,但保留流 |
|||
_continuousRecognitionController! |
|||
.add(RecognitionEvent.error('启动连续语音识别失败')); |
|||
|
|||
// 返回已有的流控制器 |
|||
return _continuousRecognitionController!.stream; |
|||
} |
|||
|
|||
_isListening = true; |
|||
return _continuousRecognitionController!.stream; |
|||
} catch (e) { |
|||
Logger.error('启动连续语音识别失败: ${e.toString()}'); |
|||
|
|||
// 如果已经创建了控制器,使用它 |
|||
if (_continuousRecognitionController != null) { |
|||
_continuousRecognitionController!.add(RecognitionEvent.error('$e')); |
|||
return _continuousRecognitionController!.stream; |
|||
} else { |
|||
// 否则创建新的控制器 |
|||
final controller = StreamController<RecognitionEvent>.broadcast(); |
|||
controller.add(RecognitionEvent.error('$e')); |
|||
return controller.stream; |
|||
} |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> stopContinuousRecognition() async { |
|||
try { |
|||
// 无论是否正在监听,都尝试停止 |
|||
if (_speech.isListening) { |
|||
await _speech.stop(); |
|||
} |
|||
|
|||
_isListening = false; |
|||
|
|||
// 发送会话结束事件 |
|||
_continuousRecognitionController |
|||
?.add(RecognitionEvent(type: RecognitionEventType.sessionStopped)); |
|||
|
|||
// 关闭流控制器 |
|||
await _continuousRecognitionController?.close(); |
|||
_continuousRecognitionController = null; |
|||
|
|||
_asrState.value = AsrState.done; |
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('停止连续语音识别失败: ${e.toString()}'); |
|||
|
|||
// 强制重置状态 |
|||
_isListening = false; |
|||
_asrState.value = AsrState.error; |
|||
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
bool isContinuousRecognitionActive() { |
|||
return _isListening; |
|||
} |
|||
|
|||
@override |
|||
Future<void> dispose() async { |
|||
try { |
|||
// 确保停止所有识别活动 |
|||
if (_speech.isListening) { |
|||
await _speech.stop(); |
|||
} |
|||
|
|||
// 关闭控制器 |
|||
await _continuousRecognitionController?.close(); |
|||
_continuousRecognitionController = null; |
|||
|
|||
_isListening = false; |
|||
_asrState.value = AsrState.notInitialized; |
|||
} catch (e) { |
|||
Logger.error('释放语音识别资源失败: ${e.toString()}'); |
|||
} |
|||
} |
|||
} |
|||
@ -1,484 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'dart:collection'; |
|||
import 'package:flutter/foundation.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_tts/flutter_tts.dart'; |
|||
import '../../../core/utils/logger.dart'; |
|||
import '../tts_service.dart'; |
|||
|
|||
/// Flutter TTS服务,负责基本的文本转语音功能 |
|||
/// |
|||
/// 使用flutter_tts插件实现TtsService接口 |
|||
class FlutterTtsService extends GetxService implements TtsService { |
|||
static final FlutterTtsService to = Get.put(FlutterTtsService()); |
|||
|
|||
// flutter_tts实例 |
|||
late final FlutterTts _flutterTts; |
|||
|
|||
// 支持的语音列表 |
|||
final List<String> _supportedVoices = []; |
|||
List<String> get supportedVoices => _supportedVoices; |
|||
|
|||
// 当前语音 |
|||
final RxString _currentVoice = ''.obs; |
|||
@override |
|||
String get currentVoice => _currentVoice.value; |
|||
|
|||
// 播放状态 |
|||
final RxBool _isSpeaking = false.obs; |
|||
@override |
|||
bool get isSpeaking => _isSpeaking.value; |
|||
|
|||
// 文本队列 |
|||
final Queue<_SpeechItem> _textQueue = Queue<_SpeechItem>(); |
|||
bool _isProcessingQueue = false; |
|||
|
|||
// 事件流控制器 |
|||
final StreamController<TtsEvent> _eventController = StreamController<TtsEvent>.broadcast(); |
|||
@override |
|||
Stream<TtsEvent> get onEvent => _eventController.stream; |
|||
|
|||
// 语言 |
|||
String _language = 'zh-CN'; |
|||
|
|||
// 流式文本缓冲区 |
|||
String _streamBuffer = ''; |
|||
|
|||
// 当前参数设置 |
|||
double _volume = 1.0; |
|||
double _rate = 0.5; |
|||
double _pitch = 1.0; |
|||
|
|||
FlutterTtsService() { |
|||
_flutterTts = FlutterTts(); |
|||
_initTts(); |
|||
} |
|||
|
|||
/// 初始化TTS |
|||
Future<void> _initTts() async { |
|||
try { |
|||
// 设置完成回调 |
|||
_flutterTts.setCompletionHandler(() { |
|||
_isSpeaking.value = false; |
|||
_eventController.add(TtsEvent(type: TtsEventType.completed)); |
|||
}); |
|||
|
|||
// 设置开始回调 |
|||
_flutterTts.setStartHandler(() { |
|||
_isSpeaking.value = true; |
|||
_eventController.add(TtsEvent(type: TtsEventType.started)); |
|||
}); |
|||
|
|||
// 设置错误回调 |
|||
_flutterTts.setErrorHandler((msg) { |
|||
_isSpeaking.value = false; |
|||
_eventController.add(TtsEvent.error(msg)); |
|||
Logger.error('TTS错误: $msg'); |
|||
}); |
|||
|
|||
// 设置取消回调 |
|||
_flutterTts.setCancelHandler(() { |
|||
_isSpeaking.value = false; |
|||
_eventController.add(TtsEvent(type: TtsEventType.canceled)); |
|||
}); |
|||
|
|||
// 设置默认语言 |
|||
await _flutterTts.setLanguage(_language); |
|||
|
|||
// 设置音量 |
|||
await _flutterTts.setVolume(_volume); |
|||
|
|||
// 设置语速 |
|||
await _flutterTts.setSpeechRate(_rate); |
|||
|
|||
// 设置音调 |
|||
await _flutterTts.setPitch(_pitch); |
|||
|
|||
Logger.info('FlutterTtsService 已初始化'); |
|||
} catch (e) { |
|||
Logger.error('TTS初始化失败: ${e.toString()}'); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> initialize({ |
|||
List<String>? supportedLanguages, |
|||
}) async { |
|||
try { |
|||
// 获取可用的语音 |
|||
await _loadVoices(); |
|||
|
|||
// 更新支持的语言列表(如果提供) |
|||
if (supportedLanguages != null && supportedLanguages.isNotEmpty) { |
|||
_language = supportedLanguages.first; |
|||
await _flutterTts.setLanguage(_language); |
|||
} |
|||
|
|||
Logger.info('FlutterTtsService 初始化成功'); |
|||
Logger.info('支持的语音: ${_supportedVoices.join(", ")}'); |
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('TTS初始化失败: ${e.toString()}'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 加载可用的语音 |
|||
Future<void> _loadVoices() async { |
|||
try { |
|||
_supportedVoices.clear(); |
|||
|
|||
// 获取可用的语音 |
|||
final voices = await _flutterTts.getVoices; |
|||
|
|||
if (voices != null && voices is List) { |
|||
for (final voice in voices) { |
|||
if (voice is Map && voice.containsKey('name')) { |
|||
_supportedVoices.add(voice['name'].toString()); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 如果没有找到语音,添加默认语音 |
|||
if (_supportedVoices.isEmpty) { |
|||
_supportedVoices.add('default'); |
|||
} |
|||
|
|||
// 设置默认语音 |
|||
if (_currentVoice.value.isEmpty && _supportedVoices.isNotEmpty) { |
|||
_currentVoice.value = _supportedVoices.first; |
|||
} |
|||
} catch (e) { |
|||
Logger.error('加载TTS语音失败: ${e.toString()}'); |
|||
|
|||
// 添加默认语音 |
|||
if (_supportedVoices.isEmpty) { |
|||
_supportedVoices.add('default'); |
|||
_currentVoice.value = 'default'; |
|||
} |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> setVoice(String voiceName) async { |
|||
try { |
|||
// 检查语音是否存在 |
|||
if (!_supportedVoices.contains(voiceName) && voiceName != 'default') { |
|||
Logger.warning('语音不存在: $voiceName,使用默认语音'); |
|||
voiceName = _supportedVoices.isNotEmpty ? _supportedVoices.first : 'default'; |
|||
} |
|||
|
|||
// 设置语音 |
|||
await _flutterTts.setVoice({"name": voiceName}); |
|||
_currentVoice.value = voiceName; |
|||
|
|||
Logger.info('设置TTS语音: $voiceName'); |
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('设置TTS语音失败: ${e.toString()}'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 设置语音参数 |
|||
Future<bool> setSpeechParams({double? rate, double? pitch, double? volume}) async { |
|||
try { |
|||
if (volume != null) { |
|||
await _flutterTts.setVolume(volume); |
|||
_volume = volume; |
|||
} |
|||
|
|||
if (rate != null) { |
|||
await _flutterTts.setSpeechRate(rate); |
|||
_rate = rate; |
|||
} |
|||
|
|||
if (pitch != null) { |
|||
await _flutterTts.setPitch(pitch); |
|||
_pitch = pitch; |
|||
} |
|||
|
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('设置语音参数失败: ${e.toString()}'); |
|||
|
|||
// 发送错误事件 |
|||
_eventController.add(TtsEvent.error('设置语音参数失败: $e')); |
|||
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> speakOnce(String text) async { |
|||
if (text.isEmpty) return false; |
|||
|
|||
try { |
|||
// 如果正在播放,先停止 |
|||
if (_isSpeaking.value) { |
|||
await stop(); |
|||
} |
|||
|
|||
// 播放文本 |
|||
await _flutterTts.speak(text); |
|||
|
|||
return true; |
|||
} catch (e) { |
|||
// 发送错误事件 |
|||
_eventController.add(TtsEvent.error(e.toString())); |
|||
|
|||
Logger.error('TTS播放失败: ${e.toString()}'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> speakStream(String text) async { |
|||
if (text.isEmpty) return false; |
|||
|
|||
try { |
|||
// 添加文本到缓冲区 |
|||
_streamBuffer += text; |
|||
|
|||
// 如果缓冲区为空,直接返回 |
|||
if (_streamBuffer.isEmpty) return true; |
|||
|
|||
// 使用正则表达式匹配句子,包括结束符号 |
|||
// 匹配任意字符,直到遇到句子结束符号 |
|||
final sentenceRegex = RegExp( |
|||
r'([^。.!!??;;:\n\r]+[。.!!??;;::\n\r]|[^。.!!??;;:\n\r]+(?:\.{3,}|…)|[^。.!!??;;:\n\r]+["」』])' |
|||
); |
|||
|
|||
bool hasProcessed = false; |
|||
|
|||
// 查找所有完整句子 |
|||
final matches = sentenceRegex.allMatches(_streamBuffer); |
|||
final List<String> sentences = []; |
|||
int lastMatchEnd = 0; |
|||
|
|||
for (final match in matches) { |
|||
// 提取完整句子(包含结束符号) |
|||
final sentence = match.group(1)?.trim(); |
|||
if (sentence != null && sentence.isNotEmpty) { |
|||
sentences.add(sentence); |
|||
lastMatchEnd = match.end; |
|||
} |
|||
} |
|||
|
|||
// 处理找到的句子 |
|||
for (final sentence in sentences) { |
|||
_textQueue.add(_SpeechItem( |
|||
text: sentence, |
|||
rate: _rate, |
|||
pitch: _pitch, |
|||
volume: _volume, |
|||
)); |
|||
hasProcessed = true; |
|||
} |
|||
|
|||
// 更新缓冲区,只保留未完成的部分 |
|||
if (lastMatchEnd > 0) { |
|||
_streamBuffer = _streamBuffer.substring(lastMatchEnd); |
|||
} |
|||
|
|||
// 如果处理了文本并且队列未在处理中,开始处理队列 |
|||
if (hasProcessed && !_isProcessingQueue) { |
|||
_processQueue(); |
|||
} |
|||
|
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('处理流式文本失败: ${e.toString()}'); |
|||
|
|||
// 发送错误事件 |
|||
_eventController.add(TtsEvent.error(e.toString())); |
|||
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> flushStream() async { |
|||
if (_textQueue.isEmpty && _streamBuffer.isEmpty) return true; |
|||
|
|||
try { |
|||
// 处理缓冲区中可能的完整句子 |
|||
await speakStream(''); |
|||
|
|||
// 如果缓冲区仍有剩余文本,将其添加到播放队列 |
|||
if (_streamBuffer.isNotEmpty && _streamBuffer.trim().isNotEmpty) { |
|||
_textQueue.add(_SpeechItem( |
|||
text: _streamBuffer, |
|||
rate: _rate, |
|||
pitch: _pitch, |
|||
volume: _volume, |
|||
)); |
|||
|
|||
// 清空缓冲区 |
|||
_streamBuffer = ''; |
|||
|
|||
// 如果队列未在处理中,开始处理队列 |
|||
if (!_isProcessingQueue) { |
|||
_processQueue(); |
|||
} |
|||
} |
|||
|
|||
Logger.info('等待TTS队列播放完成,剩余${_textQueue.length}条'); |
|||
|
|||
// 等待队列处理完成 |
|||
while (_isProcessingQueue || _isSpeaking.value) { |
|||
await Future.delayed(const Duration(milliseconds: 100)); |
|||
} |
|||
|
|||
Logger.info('TTS队列播放完成'); |
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('等待TTS队列播放完成失败: ${e.toString()}'); |
|||
|
|||
// 发送错误事件 |
|||
_eventController.add(TtsEvent.error(e.toString())); |
|||
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 处理队列中的文本 |
|||
Future<void> _processQueue() async { |
|||
if (_isProcessingQueue) return; |
|||
|
|||
_isProcessingQueue = true; |
|||
|
|||
try { |
|||
while (_textQueue.isNotEmpty) { |
|||
if (!_isSpeaking.value) { |
|||
final item = _textQueue.removeFirst(); |
|||
|
|||
// 如果参数有变化,更新参数 |
|||
if (item.rate != _rate || item.pitch != _pitch || item.volume != _volume) { |
|||
await setSpeechParams( |
|||
rate: item.rate, |
|||
pitch: item.pitch, |
|||
volume: item.volume, |
|||
); |
|||
} |
|||
|
|||
// 播放文本 |
|||
await _flutterTts.speak(item.text); |
|||
|
|||
// 等待开始播放 |
|||
int attempts = 0; |
|||
while (!_isSpeaking.value && attempts < 10) { |
|||
await Future.delayed(const Duration(milliseconds: 50)); |
|||
attempts++; |
|||
} |
|||
|
|||
// 等待播放完成 |
|||
while (_isSpeaking.value) { |
|||
await Future.delayed(const Duration(milliseconds: 100)); |
|||
} |
|||
} else { |
|||
// 等待一小段时间再检查队列 |
|||
await Future.delayed(const Duration(milliseconds: 100)); |
|||
} |
|||
} |
|||
} catch (e) { |
|||
Logger.error('TTS处理队列失败: ${e.toString()}'); |
|||
|
|||
// 发送错误事件 |
|||
_eventController.add(TtsEvent.error(e.toString())); |
|||
} finally { |
|||
_isProcessingQueue = false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<bool> stop() async { |
|||
try { |
|||
Logger.info('尝试停止TTS播放...'); |
|||
|
|||
// 清空队列 |
|||
_textQueue.clear(); |
|||
_streamBuffer = ''; |
|||
|
|||
// 如果正在播放,停止播放 |
|||
if (_isSpeaking.value) { |
|||
// 先重置状态标志,避免处理队列循环继续等待 |
|||
_isProcessingQueue = false; |
|||
|
|||
// 执行停止 |
|||
final result = await _flutterTts.stop(); |
|||
Logger.info('TTS停止结果: $result'); |
|||
|
|||
// 短暂延迟等待状态更新 |
|||
await Future.delayed(const Duration(milliseconds: 100)); |
|||
|
|||
// 强制更新状态 |
|||
_isSpeaking.value = false; |
|||
|
|||
// 发送取消事件 |
|||
_eventController.add(TtsEvent(type: TtsEventType.canceled)); |
|||
} |
|||
|
|||
Logger.info('TTS停止播放完成'); |
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('TTS停止播放失败: ${e.toString()}'); |
|||
|
|||
// 即使出错也尝试强制重置状态 |
|||
_isSpeaking.value = false; |
|||
_isProcessingQueue = false; |
|||
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<void> dispose() async { |
|||
try { |
|||
await stop(); |
|||
_textQueue.clear(); |
|||
|
|||
// 关闭事件流控制器 |
|||
await _eventController.close(); |
|||
|
|||
Logger.info('TTS资源已释放'); |
|||
} catch (e) { |
|||
Logger.error('释放TTS资源失败: ${e.toString()}'); |
|||
} |
|||
} |
|||
|
|||
/// 处理AI返回的文本流,将其转换为语音 |
|||
/// |
|||
/// [textStream] 文本流 |
|||
/// 返回一个Future,当流处理完成时完成 |
|||
Future<bool> processAiTextStream(Stream<String> textStream) async { |
|||
try { |
|||
await for (final text in textStream) { |
|||
if (text.isEmpty) continue; |
|||
await speakStream(text); |
|||
} |
|||
|
|||
// 处理完流后,刷新缓冲区中的剩余文本 |
|||
await flushStream(); |
|||
return true; |
|||
} catch (e) { |
|||
Logger.error('处理文本流时出错: ${e.toString()}'); |
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 语音合成项目 |
|||
class _SpeechItem { |
|||
final String text; |
|||
final double rate; |
|||
final double pitch; |
|||
final double volume; |
|||
|
|||
_SpeechItem({ |
|||
required this.text, |
|||
this.rate = 0.5, |
|||
this.pitch = 1.0, |
|||
this.volume = 1.0, |
|||
}); |
|||
} |
|||
@ -1,9 +0,0 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/opus_test_controller.dart'; |
|||
|
|||
class OpusTestBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<OpusTestController>(() => OpusTestController()); |
|||
} |
|||
} |
|||
@ -1,404 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'dart:io'; |
|||
import 'dart:typed_data'; |
|||
import 'package:file_picker/file_picker.dart'; |
|||
import 'package:flutter/foundation.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:path_provider/path_provider.dart'; |
|||
import 'package:just_audio/just_audio.dart'; |
|||
import 'package:jl_opus/jl_opus.dart'; |
|||
import 'package:permission_handler/permission_handler.dart'; |
|||
|
|||
class OpusTestController extends GetxController { |
|||
// 选中的文件 |
|||
final selectedFiles = <PlatformFile>[].obs; |
|||
|
|||
// 是否携带协议头 |
|||
final hasHeader = false.obs; |
|||
|
|||
// 数据包长度 |
|||
final packetLength = '40'.obs; |
|||
|
|||
// 采样率选项 |
|||
final sampleRateOptions = [8000, 16000, 24000, 32000, 44100, 48000]; |
|||
final selectedSampleRate = 16000.obs; |
|||
|
|||
// 通道数量 |
|||
final isMono = true.obs; |
|||
|
|||
// 播放状态 |
|||
final isPlaying = false.obs; |
|||
final isDecoding = false.obs; |
|||
final statusMessage = ''.obs; |
|||
|
|||
// 播放器 |
|||
final player = AudioPlayer(); |
|||
|
|||
// 临时文件路径 |
|||
String? tempPcmPath; |
|||
|
|||
// 杰理OPUS解码器 |
|||
late JlOpus jlOpus; |
|||
StreamSubscription? _eventSubscription; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
requestPermissions(); |
|||
_initOpusDecoder(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
player.dispose(); |
|||
_eventSubscription?.cancel(); |
|||
jlOpus.dispose(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
// 初始化OPUS解码器 |
|||
Future<void> _initOpusDecoder() async { |
|||
jlOpus = JlOpus(); |
|||
|
|||
// 监听解码器事件 |
|||
_eventSubscription = jlOpus.eventStream.listen((event) { |
|||
switch (event.event) { |
|||
case 'onStart': |
|||
statusMessage.value = '开始${event.type == "file" ? "文件" : "流"}解码...'; |
|||
break; |
|||
case 'onComplete': |
|||
if (event.type == 'file') { |
|||
statusMessage.value = '解码完成: ${event.filePath}'; |
|||
} else { |
|||
statusMessage.value = '流解码完成'; |
|||
} |
|||
break; |
|||
case 'onError': |
|||
statusMessage.value = '解码错误: [${event.errorCode}] ${event.errorMessage}'; |
|||
isDecoding.value = false; |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
// 初始化OPUS解码器 |
|||
final initialized = await jlOpus.initOpusDecoder(); |
|||
if (!initialized) { |
|||
statusMessage.value = 'OPUS解码器初始化失败'; |
|||
} |
|||
} |
|||
|
|||
// 请求必要权限 |
|||
Future<void> requestPermissions() async { |
|||
if (Platform.isAndroid) { |
|||
await Permission.storage.request(); |
|||
} |
|||
} |
|||
|
|||
// 选择文件 |
|||
Future<void> pickFiles() async { |
|||
try { |
|||
final result = await FilePicker.platform.pickFiles( |
|||
type: FileType.custom, |
|||
allowedExtensions: ['opus'], |
|||
allowMultiple: false, |
|||
); |
|||
|
|||
if (result != null && result.files.isNotEmpty) { |
|||
selectedFiles.clear(); |
|||
selectedFiles.add(result.files.first); |
|||
statusMessage.value = '已选择文件:${result.files.first.name}'; |
|||
} |
|||
} catch (e) { |
|||
statusMessage.value = '选择文件失败: $e'; |
|||
} |
|||
} |
|||
|
|||
// 切换通道模式 |
|||
void toggleChannel(bool isSingleChannel) { |
|||
isMono.value = isSingleChannel; |
|||
} |
|||
|
|||
// 设置采样率 |
|||
void setSampleRate(int rate) { |
|||
selectedSampleRate.value = rate; |
|||
} |
|||
|
|||
// 设置数据包长度 |
|||
void setPacketLength(String length) { |
|||
packetLength.value = length; |
|||
} |
|||
|
|||
// 切换是否携带协议头 |
|||
void toggleHeader(bool value) { |
|||
hasHeader.value = value; |
|||
} |
|||
|
|||
// 播放音频 |
|||
Future<void> playAudio() async { |
|||
if (isPlaying.value) { |
|||
await player.stop(); |
|||
isPlaying.value = false; |
|||
statusMessage.value = '已停止播放'; |
|||
return; |
|||
} |
|||
|
|||
if (selectedFiles.isEmpty) { |
|||
statusMessage.value = '请先选择Opus文件'; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
isDecoding.value = true; |
|||
statusMessage.value = '正在解码...'; |
|||
|
|||
// 使用杰理OPUS库进行解码 |
|||
final inPath = selectedFiles.first.path!; |
|||
|
|||
// 创建WAV输出文件路径 |
|||
final tempDir = await getTemporaryDirectory(); |
|||
final wavFileName = 'decoded_opus_${DateTime.now().millisecondsSinceEpoch}.wav'; |
|||
final outPath = '${tempDir.path}/$wavFileName'; |
|||
|
|||
// 创建解码参数 |
|||
final option = OpusOption( |
|||
hasHeader: hasHeader.value, |
|||
channel: isMono.value ? 1 : 2, |
|||
sampleRate: selectedSampleRate.value, |
|||
packetSize: int.tryParse(packetLength.value) ?? 40, |
|||
); |
|||
|
|||
// 解码文件 |
|||
final pcmPath = await jlOpus.decodeOpusFile(inPath, outPath, option); |
|||
if (pcmPath == null) { |
|||
statusMessage.value = '解码失败'; |
|||
isDecoding.value = false; |
|||
return; |
|||
} |
|||
|
|||
tempPcmPath = pcmPath; |
|||
|
|||
// 将PCM文件转换为WAV文件 |
|||
final wavFile = await _convertPcmToWav( |
|||
pcmPath, |
|||
'${tempDir.path}/$wavFileName', |
|||
selectedSampleRate.value, |
|||
isMono.value ? 1 : 2 |
|||
); |
|||
|
|||
statusMessage.value = '解码完成,准备播放'; |
|||
|
|||
// 播放WAV文件 |
|||
await player.setFilePath(wavFile.path); |
|||
await player.play(); |
|||
isPlaying.value = true; |
|||
statusMessage.value = '正在播放解码后的音频'; |
|||
|
|||
// 监听播放完成 |
|||
player.playerStateStream.listen((state) { |
|||
if (state.processingState == ProcessingState.completed) { |
|||
isPlaying.value = false; |
|||
statusMessage.value = '播放完成'; |
|||
} |
|||
}); |
|||
} catch (e) { |
|||
statusMessage.value = '解码或播放失败: $e'; |
|||
} finally { |
|||
isDecoding.value = false; |
|||
} |
|||
} |
|||
|
|||
// 将PCM转换为WAV格式 |
|||
Future<File> _convertPcmToWav(String pcmPath, String wavPath, int sampleRate, int channels) async { |
|||
final pcmFile = File(pcmPath); |
|||
final pcmData = await pcmFile.readAsBytes(); |
|||
|
|||
// 创建WAV文件 (44字节头部 + PCM数据) |
|||
final wavSize = 44 + pcmData.length; |
|||
final wavData = ByteData(wavSize); |
|||
|
|||
// 写入WAV头部 |
|||
_writeWavHeader(wavData, pcmData.length, sampleRate, channels); |
|||
|
|||
// 写入PCM数据 |
|||
for (int i = 0; i < pcmData.length; i++) { |
|||
wavData.setUint8(44 + i, pcmData[i]); |
|||
} |
|||
|
|||
// 保存为WAV文件 |
|||
final wavFile = File(wavPath); |
|||
await wavFile.writeAsBytes(wavData.buffer.asUint8List()); |
|||
|
|||
return wavFile; |
|||
} |
|||
|
|||
// 写入WAV头部到ByteData |
|||
void _writeWavHeader(ByteData data, int pcmLength, int sampleRate, int channels) { |
|||
final bytesPerSample = 2; // 16位PCM |
|||
final byteRate = sampleRate * channels * bytesPerSample; |
|||
final blockAlign = channels * bytesPerSample; |
|||
|
|||
// "RIFF"标识 |
|||
data.setUint8(0, 0x52); // 'R' |
|||
data.setUint8(1, 0x49); // 'I' |
|||
data.setUint8(2, 0x46); // 'F' |
|||
data.setUint8(3, 0x46); // 'F' |
|||
|
|||
// 文件大小 (减去8个字节的RIFF头部和大小字段) |
|||
data.setUint32(4, 36 + pcmLength, Endian.little); |
|||
|
|||
// "WAVE"标识 |
|||
data.setUint8(8, 0x57); // 'W' |
|||
data.setUint8(9, 0x41); // 'A' |
|||
data.setUint8(10, 0x56); // 'V' |
|||
data.setUint8(11, 0x45); // 'E' |
|||
|
|||
// "fmt "子块 |
|||
data.setUint8(12, 0x66); // 'f' |
|||
data.setUint8(13, 0x6D); // 'm' |
|||
data.setUint8(14, 0x74); // 't' |
|||
data.setUint8(15, 0x20); // ' ' |
|||
|
|||
// 子块大小 (16 for PCM) |
|||
data.setUint32(16, 16, Endian.little); |
|||
|
|||
// 音频格式 (1 表示PCM) |
|||
data.setUint16(20, 1, Endian.little); |
|||
|
|||
// 通道数量 |
|||
data.setUint16(22, channels, Endian.little); |
|||
|
|||
// 采样率 |
|||
data.setUint32(24, sampleRate, Endian.little); |
|||
|
|||
// 字节率 = 采样率 * 通道数 * 每样本字节数 |
|||
data.setUint32(28, byteRate, Endian.little); |
|||
|
|||
// 块对齐 = 通道数 * 每样本字节数 |
|||
data.setUint16(32, blockAlign, Endian.little); |
|||
|
|||
// 每个样本位数 |
|||
data.setUint16(34, bytesPerSample * 8, Endian.little); |
|||
|
|||
// "data"子块 |
|||
data.setUint8(36, 0x64); // 'd' |
|||
data.setUint8(37, 0x61); // 'a' |
|||
data.setUint8(38, 0x74); // 't' |
|||
data.setUint8(39, 0x61); // 'a' |
|||
|
|||
// 数据大小 |
|||
data.setUint32(40, pcmLength, Endian.little); |
|||
} |
|||
} |
|||
|
|||
// PCM音频源 |
|||
class PCMAudioSource extends StreamAudioSource { |
|||
final Uint8List pcmData; |
|||
final int sampleRate; |
|||
final int channels; |
|||
|
|||
PCMAudioSource({ |
|||
required this.pcmData, |
|||
this.sampleRate = 16000, |
|||
this.channels = 1, |
|||
}); |
|||
|
|||
@override |
|||
Future<StreamAudioResponse> request([int? start, int? end]) async { |
|||
start = start ?? 0; |
|||
end = end ?? pcmData.length; |
|||
|
|||
// 转换PCM到WAV格式 |
|||
final wavHeader = _createWavHeader(pcmData.length, sampleRate, channels); |
|||
final wavData = Uint8List(wavHeader.length + pcmData.length); |
|||
wavData.setRange(0, wavHeader.length, wavHeader); |
|||
wavData.setRange(wavHeader.length, wavHeader.length + pcmData.length, pcmData); |
|||
|
|||
// 调整请求范围来适应WAV头部 |
|||
int adjustedStart = start; |
|||
int adjustedEnd = end; |
|||
|
|||
if (start < wavHeader.length) { |
|||
adjustedStart = start; |
|||
} else { |
|||
adjustedStart = start + wavHeader.length; |
|||
} |
|||
|
|||
if (end <= wavHeader.length) { |
|||
adjustedEnd = end; |
|||
} else { |
|||
adjustedEnd = end + wavHeader.length; |
|||
adjustedEnd = adjustedEnd > wavData.length ? wavData.length : adjustedEnd; |
|||
} |
|||
|
|||
return StreamAudioResponse( |
|||
sourceLength: wavData.length, |
|||
contentLength: adjustedEnd - adjustedStart, |
|||
offset: adjustedStart, |
|||
stream: Stream.value(wavData.sublist(adjustedStart, adjustedEnd)), |
|||
contentType: 'audio/wav', |
|||
); |
|||
} |
|||
|
|||
// 创建WAV头部 |
|||
Uint8List _createWavHeader(int dataLength, int sampleRate, int channels) { |
|||
final bytesPerSample = 2; // 16位PCM |
|||
final byteRate = sampleRate * channels * bytesPerSample; |
|||
final blockAlign = channels * bytesPerSample; |
|||
|
|||
final header = ByteData(44); // WAV头部固定44字节 |
|||
|
|||
// "RIFF"标识 |
|||
header.setUint8(0, 0x52); // 'R' |
|||
header.setUint8(1, 0x49); // 'I' |
|||
header.setUint8(2, 0x46); // 'F' |
|||
header.setUint8(3, 0x46); // 'F' |
|||
|
|||
// 文件大小 (减去8个字节的RIFF头部和大小字段) |
|||
header.setUint32(4, 36 + dataLength, Endian.little); |
|||
|
|||
// "WAVE"标识 |
|||
header.setUint8(8, 0x57); // 'W' |
|||
header.setUint8(9, 0x41); // 'A' |
|||
header.setUint8(10, 0x56); // 'V' |
|||
header.setUint8(11, 0x45); // 'E' |
|||
|
|||
// "fmt "子块 |
|||
header.setUint8(12, 0x66); // 'f' |
|||
header.setUint8(13, 0x6D); // 'm' |
|||
header.setUint8(14, 0x74); // 't' |
|||
header.setUint8(15, 0x20); // ' ' |
|||
|
|||
// 子块大小 (16 for PCM) |
|||
header.setUint32(16, 16, Endian.little); |
|||
|
|||
// 音频格式 (1 表示PCM) |
|||
header.setUint16(20, 1, Endian.little); |
|||
|
|||
// 通道数量 |
|||
header.setUint16(22, channels, Endian.little); |
|||
|
|||
// 采样率 |
|||
header.setUint32(24, sampleRate, Endian.little); |
|||
|
|||
// 字节率 = 采样率 * 通道数 * 每样本字节数 |
|||
header.setUint32(28, byteRate, Endian.little); |
|||
|
|||
// 块对齐 = 通道数 * 每样本字节数 |
|||
header.setUint16(32, blockAlign, Endian.little); |
|||
|
|||
// 每个样本位数 |
|||
header.setUint16(34, bytesPerSample * 8, Endian.little); |
|||
|
|||
// "data"子块 |
|||
header.setUint8(36, 0x64); // 'd' |
|||
header.setUint8(37, 0x61); // 'a' |
|||
header.setUint8(38, 0x74); // 't' |
|||
header.setUint8(39, 0x61); // 'a' |
|||
|
|||
// 数据大小 |
|||
header.setUint32(40, dataLength, Endian.little); |
|||
|
|||
return header.buffer.asUint8List(); |
|||
} |
|||
} |
|||
@ -1,445 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/opus_test_controller.dart'; |
|||
|
|||
class OpusTestView extends GetView<OpusTestController> { |
|||
const OpusTestView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
// 获取主题模式 |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return DefaultTabController( |
|||
length: 2, |
|||
child: Scaffold( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
|||
appBar: AppBar( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
elevation: 0.5, |
|||
title: Text( |
|||
'OPUS解码', |
|||
style: TextStyle( |
|||
fontSize: 17.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
centerTitle: true, |
|||
bottom: TabBar( |
|||
indicatorColor: Colors.blue, |
|||
indicatorWeight: 3, |
|||
labelColor: isDarkMode ? Colors.white : Colors.black, |
|||
unselectedLabelColor: isDarkMode ? Colors.grey : Colors.grey, |
|||
tabs: const [ |
|||
Tab(text: "解码"), |
|||
Tab(text: "输出"), |
|||
], |
|||
), |
|||
), |
|||
body: TabBarView( |
|||
children: [ |
|||
_buildDecodeTab(isDarkMode), |
|||
_buildEncodeTab(isDarkMode), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 解码标签页 |
|||
Widget _buildDecodeTab(bool isDarkMode) { |
|||
return ListView( |
|||
padding: EdgeInsets.all(16.w), |
|||
children: [ |
|||
// 文件选择部分 |
|||
_buildSection( |
|||
title: '选择文件', |
|||
isDarkMode: isDarkMode, |
|||
child: Column( |
|||
children: [ |
|||
Obx(() => controller.selectedFiles.isEmpty |
|||
? const SizedBox.shrink() |
|||
: ListView.builder( |
|||
shrinkWrap: true, |
|||
physics: const NeverScrollableScrollPhysics(), |
|||
itemCount: controller.selectedFiles.length, |
|||
itemBuilder: (context, index) { |
|||
final file = controller.selectedFiles[index]; |
|||
return ListTile( |
|||
title: Text( |
|||
file.name, |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white70 : Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
subtitle: Text( |
|||
'${(file.size / 1024).toStringAsFixed(2)} Kb', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white38 : Colors.black54, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
trailing: Radio<int>( |
|||
value: index, |
|||
groupValue: 0, |
|||
onChanged: (value) {}, |
|||
activeColor: Colors.blue, |
|||
), |
|||
); |
|||
}, |
|||
)), |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.end, |
|||
children: [ |
|||
IconButton( |
|||
icon: Icon( |
|||
Icons.refresh, |
|||
color: isDarkMode ? Colors.blue[300] : Colors.blue, |
|||
), |
|||
onPressed: () { |
|||
controller.selectedFiles.clear(); |
|||
}, |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
), |
|||
SizedBox(height: 16.h), |
|||
|
|||
// 解码参数部分 |
|||
_buildSection( |
|||
title: '解码参数', |
|||
isDarkMode: isDarkMode, |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Obx(() => Checkbox( |
|||
value: controller.hasHeader.value, |
|||
onChanged: (value) => controller.toggleHeader(value!), |
|||
activeColor: Colors.blue, |
|||
)), |
|||
Text( |
|||
'是否携带协议头', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white70 : Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
// 数据包长度 |
|||
Row( |
|||
children: [ |
|||
Text( |
|||
'数据包长度', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white70 : Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
SizedBox(width: 16.w), |
|||
Expanded( |
|||
child: Obx(() => TextField( |
|||
controller: TextEditingController( |
|||
text: controller.packetLength.value), |
|||
onChanged: (value) => controller.setPacketLength(value), |
|||
keyboardType: TextInputType.number, |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
decoration: InputDecoration( |
|||
isDense: true, |
|||
suffix: Text( |
|||
'Byte', |
|||
style: TextStyle( |
|||
color: isDarkMode |
|||
? Colors.white60 |
|||
: Colors.black54, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
enabledBorder: UnderlineInputBorder( |
|||
borderSide: BorderSide( |
|||
color: isDarkMode |
|||
? Colors.white30 |
|||
: Colors.black26, |
|||
), |
|||
), |
|||
focusedBorder: UnderlineInputBorder( |
|||
borderSide: BorderSide( |
|||
color: Colors.blue, |
|||
), |
|||
), |
|||
), |
|||
)), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 20.h), |
|||
|
|||
// 采样率 |
|||
Row( |
|||
children: [ |
|||
Text( |
|||
'采样率', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white70 : Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
SizedBox(width: 16.w), |
|||
Expanded( |
|||
child: Obx(() => DropdownButton<int>( |
|||
value: controller.selectedSampleRate.value, |
|||
isExpanded: true, |
|||
dropdownColor: |
|||
isDarkMode ? Colors.grey[800] : Colors.white, |
|||
items: controller.sampleRateOptions.map((rate) { |
|||
return DropdownMenuItem<int>( |
|||
value: rate, |
|||
child: Text( |
|||
rate.toString(), |
|||
style: TextStyle( |
|||
color: isDarkMode |
|||
? Colors.white |
|||
: Colors.black87, |
|||
), |
|||
), |
|||
); |
|||
}).toList(), |
|||
onChanged: (value) => |
|||
controller.setSampleRate(value!), |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
)), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 20.h), |
|||
|
|||
// 通道数量 |
|||
Text( |
|||
'通道数量', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white70 : Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: Row( |
|||
children: [ |
|||
Obx(() => Radio<bool>( |
|||
value: true, |
|||
groupValue: controller.isMono.value, |
|||
onChanged: (value) => |
|||
controller.toggleChannel(value!), |
|||
activeColor: Colors.blue, |
|||
)), |
|||
Text( |
|||
'单通道', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white70 : Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
Expanded( |
|||
child: Row( |
|||
children: [ |
|||
Obx(() => Radio<bool>( |
|||
value: false, |
|||
groupValue: controller.isMono.value, |
|||
onChanged: (value) => |
|||
controller.toggleChannel(value!), |
|||
activeColor: Colors.blue, |
|||
)), |
|||
Text( |
|||
'双通道', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white70 : Colors.black87, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
), |
|||
|
|||
SizedBox(height: 16.h), |
|||
|
|||
// 状态消息 |
|||
Obx(() => controller.statusMessage.value.isNotEmpty |
|||
? Container( |
|||
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), |
|||
margin: EdgeInsets.only(bottom: 16.h), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode |
|||
? Colors.blue.withOpacity(0.2) |
|||
: Colors.blue.withOpacity(0.1), |
|||
borderRadius: BorderRadius.circular(4.r), |
|||
border: Border.all( |
|||
color: isDarkMode |
|||
? Colors.blue.withOpacity(0.3) |
|||
: Colors.blue.withOpacity(0.2), |
|||
width: 1, |
|||
), |
|||
), |
|||
child: Text( |
|||
controller.statusMessage.value, |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.blue[300] : Colors.blue[700], |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
) |
|||
: const SizedBox.shrink()), |
|||
|
|||
// 播放按钮 |
|||
Container( |
|||
margin: EdgeInsets.symmetric(vertical: 8.h), |
|||
child: ElevatedButton.icon( |
|||
icon: Obx(() => Icon( |
|||
controller.isPlaying.value |
|||
? Icons.stop |
|||
: Icons.play_arrow, |
|||
color: Colors.white, |
|||
)), |
|||
label: Obx(() => Text( |
|||
controller.isPlaying.value ? '停止播放' : '播放音频', |
|||
style: TextStyle( |
|||
color: Colors.white, |
|||
fontSize: 16.sp, |
|||
), |
|||
)), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.blue, |
|||
padding: EdgeInsets.symmetric(vertical: 16.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
onPressed: controller.isDecoding.value |
|||
? null |
|||
: () { |
|||
controller.playAudio(); |
|||
}, |
|||
), |
|||
), |
|||
|
|||
// 解码按钮 |
|||
Container( |
|||
margin: EdgeInsets.symmetric(vertical: 8.h), |
|||
child: Obx(() => ElevatedButton( |
|||
onPressed: controller.selectedFiles.isEmpty || |
|||
controller.isDecoding.value |
|||
? null |
|||
: () { |
|||
controller.pickFiles(); |
|||
}, |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.blue, |
|||
padding: EdgeInsets.symmetric(vertical: 16.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
child: Text( |
|||
'开始解码', |
|||
style: TextStyle( |
|||
color: Colors.white, |
|||
fontSize: 16.sp, |
|||
), |
|||
), |
|||
)), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// 编码标签页(暂未实现) |
|||
Widget _buildEncodeTab(bool isDarkMode) { |
|||
return Center( |
|||
child: Text( |
|||
'编码功能尚未实现', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white54 : Colors.black54, |
|||
fontSize: 16.sp, |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建带标题的区段 |
|||
Widget _buildSection({ |
|||
required String title, |
|||
required Widget child, |
|||
required bool isDarkMode, |
|||
}) { |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Text( |
|||
title, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Expanded( |
|||
child: Container( |
|||
height: 1, |
|||
color: isDarkMode ? Colors.white24 : Colors.black12, |
|||
), |
|||
), |
|||
if (title == '选择文件') |
|||
IconButton( |
|||
icon: Icon( |
|||
Icons.add_circle_outline, |
|||
color: isDarkMode ? Colors.blue[300] : Colors.blue, |
|||
), |
|||
onPressed: () { |
|||
controller.pickFiles(); |
|||
}, |
|||
), |
|||
], |
|||
), |
|||
Container( |
|||
padding: EdgeInsets.all(12.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: isDarkMode |
|||
? Colors.black.withOpacity(0.2) |
|||
: Colors.black.withOpacity(0.03), |
|||
blurRadius: 4, |
|||
offset: Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: child, |
|||
), |
|||
], |
|||
); |
|||
} |
|||
} |
|||
@ -1,994 +0,0 @@ |
|||
import Foundation |
|||
import MicrosoftCognitiveServicesSpeech |
|||
import open_ai_service |
|||
import chat_storage |
|||
|
|||
/// 代理服务 - 处理语音识别、TTS和AI对话相关逻辑 |
|||
/// |
|||
/// 负责集成Azure语音服务、OpenAI服务和本地存储服务, |
|||
/// 提供语音识别、语音合成、AI对话等功能 |
|||
class AgentService { |
|||
// 常量定义 |
|||
private let TAG = "AgentService" |
|||
|
|||
// 上下文和监听器 |
|||
private var listeners = NSHashTable<AnyObject>.weakObjects() |
|||
|
|||
// 配置参数 |
|||
private var azureSpeechKey: String = "" |
|||
private var azureSpeechRegion: String = "" |
|||
private var openaiApiKey: String = "" |
|||
private var openaiBaseUrl: String = "" |
|||
private var openaiModel: String = "gpt-3.5-turbo" |
|||
private var mcpServer: String = "" |
|||
|
|||
// Azure服务 |
|||
private var azureAsrHelper: AzureAsrHelper? |
|||
private var azureTtsHelper: AzureTtsHelper? |
|||
|
|||
// OpenAI服务 |
|||
private var openAIService: OpenAIServiceBridge? |
|||
|
|||
// 聊天存储服务 |
|||
private var chatStorageHelper: ChatStorageHelperBridge? |
|||
|
|||
// 会话ID,用于区分不同聊天上下文 |
|||
private let sessionId = "default_agent" |
|||
|
|||
// 历史聊天消息缓存 |
|||
private var historyMessages = [[String: Any]]() |
|||
|
|||
// 系统提示词 |
|||
private var systemPrompt = "" |
|||
|
|||
// 状态 |
|||
private var isInitialized = false |
|||
private var isRecognitionActive = false |
|||
private var isTtsSpeaking = false |
|||
private var hasSpeechDetected = false |
|||
private var isAiStreaming = false |
|||
|
|||
// AI流生成相关 |
|||
private var currentAiTask: DispatchWorkItem? |
|||
|
|||
// 空闲检测相关 |
|||
private var idleCheckTimer: Timer? |
|||
private let maxIdleSeconds: TimeInterval = 10 // 最大空闲秒数 |
|||
|
|||
/// 初始化 |
|||
init() { |
|||
// 创建OpenAI服务桥接器 |
|||
openAIService = OpenAIServiceBridge() |
|||
|
|||
// 创建聊天存储桥接器 |
|||
chatStorageHelper = ChatStorageHelperBridge() |
|||
} |
|||
|
|||
/** |
|||
* 初始化系统提示词 |
|||
*/ |
|||
private func initSystemPrompt() { |
|||
systemPrompt = """ |
|||
你是一个友好、专业的语音助手,名叫"小语"。你的目标是通过对话为用户提供帮助、解答问题和完成任务。 |
|||
遵循以下指导原则: |
|||
1. 保持简短精炼的回答,因为用户是通过语音与你交流 |
|||
2. 优先使用中文回复,除非用户明确要求使用其他语言 |
|||
3. 当用户问题不明确时,礼貌地请求更多信息 |
|||
4. 避免过长的列表,尽量将信息分成小段 |
|||
5. 不要使用需要视觉展示的元素(如表格、图表或代码块) |
|||
6. 记住用户之前的对话内容,保持对话连贯 |
|||
7. 如果用户发送了图片,请根据图片内容和文字要求回答问题 |
|||
你不仅可以回答知识性问题,还可以帮助用户设置提醒、提供建议,或进行轻松愉快的对话。 |
|||
无论遇到什么问题,都要尽力以温暖、贴心的语气提供最佳帮助。 |
|||
""" |
|||
} |
|||
|
|||
/** |
|||
* 初始化 |
|||
* |
|||
* - Parameter config: 配置参数,包含所需的所有API密钥和设置 |
|||
* - Returns: 是否初始化成功 |
|||
*/ |
|||
func initialize(config: [String: Any]) -> Bool { |
|||
if isInitialized { return true } |
|||
|
|||
do { |
|||
// 从配置中获取参数 |
|||
if let azureKey = config["azureSpeechKey"] as? String { |
|||
azureSpeechKey = azureKey |
|||
} |
|||
|
|||
if let azureRegion = config["azureSpeechRegion"] as? String { |
|||
azureSpeechRegion = azureRegion |
|||
} |
|||
|
|||
if let openaiKey = config["openaiApiKey"] as? String { |
|||
openaiApiKey = openaiKey |
|||
} |
|||
|
|||
if let baseUrl = config["openaiBaseUrl"] as? String { |
|||
openaiBaseUrl = baseUrl |
|||
} |
|||
|
|||
if let model = config["openaiModel"] as? String { |
|||
openaiModel = model |
|||
} |
|||
|
|||
if let server = config["mcpServer"] as? String { |
|||
mcpServer = server |
|||
} |
|||
|
|||
// 自定义系统提示词 |
|||
if let customSystemPrompt = config["systemPrompt"] as? String, !customSystemPrompt.isEmpty { |
|||
systemPrompt = customSystemPrompt |
|||
} else { |
|||
// 使用默认系统提示词 |
|||
initSystemPrompt() |
|||
} |
|||
|
|||
// 检查必要参数 |
|||
if azureSpeechKey.isEmpty || azureSpeechRegion.isEmpty || openaiApiKey.isEmpty { |
|||
print("\(TAG): 初始化失败:关键配置参数缺失") |
|||
return false |
|||
} |
|||
|
|||
// 初始化OpenAI服务 |
|||
openAIService?.initialize(apiKey: openaiApiKey, baseUrl: openaiBaseUrl, model: openaiModel, mcpServer: mcpServer) |
|||
|
|||
// 初始化Azure ASR |
|||
azureAsrHelper = AzureAsrHelper(self) |
|||
let asrInitResult = azureAsrHelper?.initialize( |
|||
subscriptionKey: azureSpeechKey, |
|||
region: azureSpeechRegion, |
|||
audioSourceType: .microphone |
|||
) ?? false |
|||
|
|||
if !asrInitResult { |
|||
print("\(TAG): Azure ASR初始化失败") |
|||
return false |
|||
} |
|||
|
|||
// 初始化Azure TTS |
|||
azureTtsHelper = AzureTtsHelper(self) |
|||
let ttsInitResult = azureTtsHelper?.initialize( |
|||
subscriptionKey: azureSpeechKey, |
|||
region: azureSpeechRegion, |
|||
callback: self |
|||
) ?? false |
|||
|
|||
if !ttsInitResult { |
|||
print("\(TAG): Azure TTS初始化失败") |
|||
return false |
|||
} |
|||
|
|||
// 加载最近的聊天记录 |
|||
loadChatHistory() |
|||
|
|||
isInitialized = true |
|||
print("\(TAG): 代理服务初始化成功") |
|||
return true |
|||
} catch { |
|||
print("\(TAG): 初始化失败:\(error.localizedDescription)") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 添加事件监听器 |
|||
* |
|||
* - Parameter listener: 要添加的监听器 |
|||
*/ |
|||
func addListener(_ listener: AgentServiceListener) { |
|||
listeners.add(listener as AnyObject) |
|||
} |
|||
|
|||
/** |
|||
* 设置事件监听器(替换现有监听器) |
|||
* |
|||
* - Parameter listener: 要设置的监听器 |
|||
*/ |
|||
func setListener(_ listener: AgentServiceListener) { |
|||
listeners.removeAllObjects() |
|||
listeners.add(listener as AnyObject) |
|||
} |
|||
|
|||
/** |
|||
* 移除事件监听器 |
|||
* |
|||
* - Parameter listener: 要移除的监听器 |
|||
*/ |
|||
func removeListener(_ listener: AgentServiceListener) { |
|||
listeners.remove(listener as AnyObject) |
|||
} |
|||
|
|||
/** |
|||
* 移除所有事件监听器 |
|||
*/ |
|||
func clearListeners() { |
|||
listeners.removeAllObjects() |
|||
} |
|||
|
|||
/** |
|||
* 启动空闲检测 |
|||
*/ |
|||
private func startIdleCheck() { |
|||
stopIdleCheck() // 先停止现有的检查 |
|||
|
|||
if !isRecognitionActive { return } |
|||
|
|||
// 创建定时器 |
|||
idleCheckTimer = Timer.scheduledTimer(withTimeInterval: maxIdleSeconds, repeats: false) { [weak self] _ in |
|||
guard let self = self else { return } |
|||
|
|||
// 如果状态仍然是空闲的,则停止识别 |
|||
if self.isRecognitionActive && !self.hasSpeechDetected && !self.isTtsSpeaking && !self.isAiStreaming { |
|||
self.stopRecognition() |
|||
self.sendEvent("auto_stop", ["reason": "idle_timeout", "seconds": self.maxIdleSeconds]) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止空闲检测 |
|||
*/ |
|||
private func stopIdleCheck() { |
|||
idleCheckTimer?.invalidate() |
|||
idleCheckTimer = nil |
|||
} |
|||
|
|||
/** |
|||
* 重启空闲检测 |
|||
* 当状态发生变化时调用 |
|||
*/ |
|||
private func restartIdleCheck() { |
|||
if isRecognitionActive { |
|||
startIdleCheck() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 开始语音识别 |
|||
* |
|||
* - Returns: 是否成功开始识别 |
|||
*/ |
|||
func startRecognition() -> Bool { |
|||
if !isInitialized { |
|||
print("\(TAG): 服务未初始化") |
|||
return false |
|||
} |
|||
|
|||
if isRecognitionActive { return true } |
|||
|
|||
isRecognitionActive = true |
|||
hasSpeechDetected = false |
|||
|
|||
do { |
|||
// 创建连续识别回调 |
|||
class ContinuousRecognizeCallbackImpl: AzureAsrHelper.ContinuousRecognizeCallback { |
|||
private weak var service: AgentService? |
|||
|
|||
init(_ service: AgentService) { |
|||
self.service = service |
|||
} |
|||
|
|||
func onRecognizing(recognizing: String, detectedLanguage: String) { |
|||
guard let service = service else { return } |
|||
|
|||
if !recognizing.isEmpty { |
|||
// 检测到语音,更新状态 |
|||
let previousHasSpeech = service.hasSpeechDetected |
|||
service.hasSpeechDetected = true |
|||
|
|||
// 状态发生变化时重启空闲检测 |
|||
if !previousHasSpeech { |
|||
service.restartIdleCheck() |
|||
} |
|||
|
|||
service.sendEvent("recognizing", [ |
|||
"text": recognizing, |
|||
"language": detectedLanguage |
|||
]) |
|||
|
|||
// 如果TTS正在播放或AI正在生成,则触发打断 |
|||
if service.isTtsSpeaking || service.isAiStreaming { |
|||
service.interruptCurrentResponse() |
|||
} |
|||
} |
|||
} |
|||
|
|||
func onResult(text: String, detectedLanguage: String) { |
|||
guard let service = service else { return } |
|||
|
|||
if !text.isEmpty { |
|||
service.sendEvent("recognition_result", [ |
|||
"text": text, |
|||
"language": detectedLanguage |
|||
]) |
|||
|
|||
service.processWithOpenAI(text: text) |
|||
} |
|||
|
|||
// 重置状态,继续识别 |
|||
let previousHasSpeech = service.hasSpeechDetected |
|||
service.hasSpeechDetected = false |
|||
|
|||
// 状态发生变化时重启空闲检测 |
|||
if previousHasSpeech { |
|||
service.restartIdleCheck() |
|||
} |
|||
} |
|||
|
|||
func onSessionStarted() { |
|||
guard let service = service else { return } |
|||
|
|||
service.sendEvent("recognition_started", ["status": "started"]) |
|||
// 启动空闲检测 |
|||
service.startIdleCheck() |
|||
} |
|||
|
|||
func onSessionStopped() { |
|||
guard let service = service else { return } |
|||
|
|||
service.isRecognitionActive = false |
|||
service.stopIdleCheck() |
|||
service.sendEvent("recognition_stopped", ["status": "stopped"]) |
|||
} |
|||
|
|||
func onCanceled(reason: String, errorDetails: String) { |
|||
guard let service = service else { return } |
|||
|
|||
service.isRecognitionActive = false |
|||
service.stopIdleCheck() |
|||
service.sendEvent("recognition_canceled", [ |
|||
"reason": reason, |
|||
"details": errorDetails |
|||
]) |
|||
} |
|||
|
|||
func onError(error: String) { |
|||
guard let service = service else { return } |
|||
|
|||
service.isRecognitionActive = false |
|||
service.stopIdleCheck() |
|||
print("\(service.TAG): 语音识别出错: \(error)") |
|||
service.sendEvent("error", [ |
|||
"code": "RECOGNITION_ERROR", |
|||
"message": error |
|||
]) |
|||
} |
|||
} |
|||
|
|||
let callback = ContinuousRecognizeCallbackImpl(self) |
|||
let result = azureAsrHelper?.startContinuousRecognition(callback) ?? false |
|||
|
|||
if !result { |
|||
isRecognitionActive = false |
|||
print("\(TAG): 启动语音识别失败") |
|||
sendEvent("error", [ |
|||
"code": "RECOGNITION_START_ERROR", |
|||
"message": "启动语音识别失败" |
|||
]) |
|||
} |
|||
|
|||
return result |
|||
} catch { |
|||
isRecognitionActive = false |
|||
print("\(TAG): 启动语音识别失败: \(error.localizedDescription)") |
|||
sendEvent("error", [ |
|||
"code": "RECOGNITION_START_ERROR", |
|||
"message": error.localizedDescription |
|||
]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止语音识别 |
|||
*/ |
|||
func stopRecognition() { |
|||
if !isRecognitionActive { return } |
|||
|
|||
print("\(TAG): 停止语音识别") |
|||
|
|||
// 停止识别 |
|||
let _ = azureAsrHelper?.stopContinuousRecognition() |
|||
isRecognitionActive = false |
|||
stopIdleCheck() |
|||
print("\(TAG): 语音识别已停止") |
|||
} |
|||
|
|||
/** |
|||
* 打断当前响应 |
|||
* 停止TTS播放和AI流输出 |
|||
*/ |
|||
func interruptCurrentResponse() { |
|||
if isAiStreaming || isTtsSpeaking { |
|||
// 停止TTS播放 |
|||
stopTts() |
|||
|
|||
// 停止AI流输出 |
|||
stopAiStream() |
|||
|
|||
// 发送打断事件 |
|||
sendEvent("response_interrupted", ["status": "interrupted"]) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止AI流输出 |
|||
*/ |
|||
private func stopAiStream() { |
|||
if isAiStreaming { |
|||
// 取消当前AI生成任务 |
|||
currentAiTask?.cancel() |
|||
currentAiTask = nil |
|||
|
|||
// 通知OpenAI服务终止当前流式请求 |
|||
openAIService?.cancelCurrentStream() |
|||
|
|||
// 更新状态 |
|||
isAiStreaming = false |
|||
|
|||
// 记录日志 |
|||
print("\(TAG): AI流输出已停止") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理文本输入 |
|||
* 作为语音输入的补充,直接处理文本并通过事件返回结果 |
|||
* |
|||
* - Parameters: |
|||
* - text: 用户输入文本 |
|||
* - speakResponse: 是否朗读回复,默认为false |
|||
* - Returns: 是否成功开始处理 |
|||
*/ |
|||
func processTextInput(text: String, speakResponse: Bool = false) -> Bool { |
|||
if !isInitialized { |
|||
print("\(TAG): 服务未初始化") |
|||
sendEvent("error", ["code": "NOT_INITIALIZED", "message": "服务未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
if text.isEmpty { |
|||
print("\(TAG): 文本输入不能为空") |
|||
sendEvent("error", ["code": "EMPTY_TEXT", "message": "文本输入不能为空"]) |
|||
return false |
|||
} |
|||
|
|||
// 使用OpenAI处理文本 |
|||
processWithOpenAI(text: text, speakResponse: speakResponse) |
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 使用OpenAI处理语音识别结果 |
|||
* |
|||
* - Parameter text: 用户输入文本 |
|||
*/ |
|||
private func processWithOpenAI(text: String) { |
|||
processWithOpenAI(text: text, speakResponse: true) |
|||
} |
|||
|
|||
/** |
|||
* 使用OpenAI处理文本消息 |
|||
* |
|||
* - Parameters: |
|||
* - text: 用户输入文本 |
|||
* - speakResponse: 是否使用TTS朗读回复 |
|||
*/ |
|||
private func processWithOpenAI(text: String, speakResponse: Bool = true) { |
|||
print("\(TAG): 用户问题: \(text)") |
|||
|
|||
// 创建用户文本消息并处理 |
|||
guard let userMessage = openAIService?.createUserMessage(text: text) else { |
|||
print("\(TAG): 创建用户消息失败") |
|||
return |
|||
} |
|||
|
|||
processWithOpenAIInternal(userMessage: userMessage, displayText: text, speakResponse: speakResponse) |
|||
} |
|||
|
|||
/** |
|||
* 使用OpenAI处理图片 |
|||
* |
|||
* - Parameters: |
|||
* - imagePath: 图片文件路径 |
|||
* - text: 可选的文本描述或问题 |
|||
* - speakResponse: 是否朗读回复 |
|||
* - Returns: 是否成功开始处理 |
|||
*/ |
|||
func processImageInput(imagePath: String, text: String = "", speakResponse: Bool = false) -> Bool { |
|||
if !isInitialized { |
|||
print("\(TAG): 服务未初始化") |
|||
sendEvent("error", ["code": "NOT_INITIALIZED", "message": "服务未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
if imagePath.isEmpty { |
|||
print("\(TAG): 图片路径不能为空") |
|||
sendEvent("error", ["code": "EMPTY_IMAGE_PATH", "message": "图片路径不能为空"]) |
|||
return false |
|||
} |
|||
|
|||
// 通知开始处理图片 |
|||
sendEvent("image_processing", [ |
|||
"status": "processing", |
|||
"imagePath": imagePath |
|||
]) |
|||
|
|||
// 异步处理图片 |
|||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
|||
guard let self = self else { return } |
|||
|
|||
// 将图片转换为Base64格式 |
|||
guard let imageBase64 = self.openAIService?.fileToBase64(filePath: imagePath) else { |
|||
DispatchQueue.main.async { |
|||
print("\(self.TAG): 图片转换失败: \(imagePath)") |
|||
self.sendEvent("error", [ |
|||
"code": "IMAGE_CONVERSION_FAILED", |
|||
"message": "图片转换失败" |
|||
]) |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 通知图片准备完成 |
|||
DispatchQueue.main.async { |
|||
self.sendEvent("image_ready", [ |
|||
"status": "ready", |
|||
"imagePath": imagePath |
|||
]) |
|||
|
|||
// 处理包含图片的消息 |
|||
self.processImageWithOpenAI(imageBase64: imageBase64, text: text, speakResponse: speakResponse) |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 使用OpenAI处理图片 |
|||
* |
|||
* - Parameters: |
|||
* - imageBase64: Base64编码的图片数据 |
|||
* - text: 可选的文本描述或问题 |
|||
* - speakResponse: 是否朗读回复 |
|||
*/ |
|||
private func processImageWithOpenAI(imageBase64: String, text: String = "", speakResponse: Bool = false) { |
|||
print("\(TAG): 处理图片输入: \(text.isEmpty ? "无附加文本" : "附带文本: \(text)")") |
|||
|
|||
// 创建带图片的用户消息并处理 |
|||
guard let userMessage = openAIService?.createUserMessageWithImage(text: text, imageBase64: imageBase64) else { |
|||
print("\(TAG): 创建带图片的用户消息失败") |
|||
return |
|||
} |
|||
|
|||
// 图片描述用于存储 |
|||
let displayText = text.isEmpty ? "[图片]" : text |
|||
processWithOpenAIInternal(userMessage: userMessage, displayText: displayText, speakResponse: speakResponse, hasImage: true) |
|||
} |
|||
|
|||
/** |
|||
* 内部方法:通用的OpenAI处理逻辑 |
|||
* |
|||
* - Parameters: |
|||
* - userMessage: 用户消息(可以是文本或图片格式) |
|||
* - displayText: 用于显示和存储的文本 |
|||
* - speakResponse: 是否朗读回复 |
|||
* - hasImage: 是否包含图片 |
|||
*/ |
|||
private func processWithOpenAIInternal(userMessage: [String: Any], displayText: String, speakResponse: Bool = true, hasImage: Bool = false) { |
|||
// 如果有正在进行的AI流式输出,先停止它 |
|||
stopAiStream() |
|||
|
|||
// 创建AI任务 |
|||
let workItem = DispatchWorkItem { [weak self] in |
|||
guard let self = self else { return } |
|||
|
|||
// 设置状态为正在流式输出 |
|||
self.isAiStreaming = true |
|||
|
|||
// 使用历史记录作为上下文发送到OpenAI |
|||
var responseBuilder = "" |
|||
|
|||
// 添加系统提示词到历史消息的副本中 |
|||
var messagesWithSystemPrompt: [[String: Any]] = [] |
|||
|
|||
// 先添加系统提示词 |
|||
if !self.systemPrompt.isEmpty { |
|||
if let systemMessage = self.openAIService?.createSystemMessage(text: self.systemPrompt) { |
|||
messagesWithSystemPrompt.append(systemMessage) |
|||
} |
|||
} |
|||
|
|||
// 再添加历史消息 |
|||
messagesWithSystemPrompt.append(contentsOf: self.historyMessages) |
|||
messagesWithSystemPrompt.append(userMessage) |
|||
|
|||
// 将用户消息添加到历史记录 |
|||
self.addToHistoryMessages(userMessage) |
|||
|
|||
// 流式回调 |
|||
class StreamCallbackBridge: NSObject, OpenAIStreamCallback { |
|||
private weak var service: AgentService? |
|||
private var responseBuilder: String |
|||
private let speakResponse: Bool |
|||
private let displayText: String |
|||
private let hasImage: Bool |
|||
|
|||
init(_ service: AgentService, responseBuilder: String = "", speakResponse: Bool, displayText: String, hasImage: Bool) { |
|||
self.service = service |
|||
self.responseBuilder = responseBuilder |
|||
self.speakResponse = speakResponse |
|||
self.displayText = displayText |
|||
self.hasImage = hasImage |
|||
super.init() |
|||
} |
|||
|
|||
func onToken(token: String) { |
|||
guard let service = service else { return } |
|||
|
|||
responseBuilder.append(token) |
|||
if speakResponse { |
|||
_ = service.azureTtsHelper?.speakStream(token) |
|||
} |
|||
// 发送流式回复token |
|||
service.sendEvent("assistant_token", ["token": token]) |
|||
} |
|||
|
|||
func onComplete() { |
|||
guard let service = service else { return } |
|||
|
|||
// 视情况决定是否朗读回复 |
|||
if speakResponse { |
|||
_ = service.azureTtsHelper?.flushStream() |
|||
} |
|||
|
|||
if !responseBuilder.isEmpty { |
|||
// 发送完整回复,包含是否有图片的标记 |
|||
var responseData: [String: Any] = [ |
|||
"text": responseBuilder, |
|||
"userInput": displayText |
|||
] |
|||
|
|||
if hasImage { |
|||
responseData["hasImage"] = true |
|||
} |
|||
|
|||
service.sendEvent("assistant_response", responseData) |
|||
|
|||
// 添加AI回复到历史记录 |
|||
if let assistantMessage = service.openAIService?.createAssistantMessage(text: responseBuilder) { |
|||
service.addToHistoryMessages(assistantMessage) |
|||
} |
|||
|
|||
// 保存聊天记录 |
|||
service.saveChatMessage(userMessage: displayText, assistantMessage: responseBuilder) |
|||
} |
|||
|
|||
// 标记AI流式输出已完成 |
|||
service.isAiStreaming = false |
|||
service.currentAiTask = nil |
|||
} |
|||
|
|||
func onError(error: Error) { |
|||
guard let service = service else { return } |
|||
|
|||
print("\(service.TAG): AI处理出错: \(error.localizedDescription)") |
|||
service.sendEvent("error", [ |
|||
"code": "AI_ERROR", |
|||
"message": error.localizedDescription |
|||
]) |
|||
|
|||
// 标记AI流式输出已完成 |
|||
service.isAiStreaming = false |
|||
service.currentAiTask = nil |
|||
} |
|||
|
|||
func onFunctionCall(call: [String: Any]) { |
|||
guard let service = service else { return } |
|||
|
|||
if let name = call["name"] as? String { |
|||
service.sendEvent("function_call", [ |
|||
"name": name, |
|||
"arguments": call |
|||
]) |
|||
|
|||
if name == "exit_interaction" { |
|||
service.stopRecognition() |
|||
} |
|||
} |
|||
} |
|||
|
|||
func onFunctionCallResult(functionCall: [String: Any], functionCallResult: [String: Any]) { |
|||
guard let service = service else { return } |
|||
|
|||
service.sendEvent("function_call_result", [ |
|||
"function_call": functionCall, |
|||
"result": functionCallResult, |
|||
]) |
|||
} |
|||
} |
|||
|
|||
let callback = StreamCallbackBridge(self, responseBuilder: responseBuilder, speakResponse: speakResponse, displayText: displayText, hasImage: hasImage) |
|||
|
|||
self.openAIService?.sendMessageStream(messages: messagesWithSystemPrompt, callback: callback) |
|||
} |
|||
|
|||
// 保存任务引用并在全局队列中执行 |
|||
currentAiTask = workItem |
|||
DispatchQueue.global(qos: .userInitiated).async(execute: workItem) |
|||
} |
|||
|
|||
/** |
|||
* 加载最近的聊天历史记录 |
|||
*/ |
|||
private func loadChatHistory() { |
|||
// 清空当前历史记录 |
|||
historyMessages.removeAll() |
|||
|
|||
// 使用ChatStorageHelper直接获取最近消息 |
|||
guard let recentMessages = chatStorageHelper?.getRecentMessages(sessionId: sessionId, count: 10) else { |
|||
print("\(TAG): 没有找到历史记录") |
|||
return |
|||
} |
|||
|
|||
// 将消息添加到历史记录 |
|||
for message in recentMessages { |
|||
if let sender = message["sender"] as? String, |
|||
let content = message["message"] as? String { |
|||
|
|||
if sender == "user" { |
|||
if let userMessage = openAIService?.createUserMessage(text: content) { |
|||
addToHistoryMessages(userMessage) |
|||
} |
|||
} else if sender == "assistant" { |
|||
if let assistantMessage = openAIService?.createAssistantMessage(text: content) { |
|||
addToHistoryMessages(assistantMessage) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
print("\(TAG): 已加载\(recentMessages.count)条历史记录") |
|||
} |
|||
|
|||
/** |
|||
* 添加消息到历史记录,保持最近10条 |
|||
*/ |
|||
private func addToHistoryMessages(_ message: [String: Any]) { |
|||
// 添加新消息 |
|||
historyMessages.append(message) |
|||
|
|||
// 如果超过10条,删除最早的消息 |
|||
while historyMessages.count > 10 { |
|||
historyMessages.removeFirst() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* TTS播放函数 |
|||
* |
|||
* - Parameter text: 要播放的文本 |
|||
*/ |
|||
func speakText(text: String) { |
|||
if text.isEmpty { return } |
|||
|
|||
// 更新状态 |
|||
isTtsSpeaking = true |
|||
restartIdleCheck() // 状态变化,重启检测 |
|||
|
|||
// 播放文本 |
|||
_ = azureTtsHelper?.speakText(text) |
|||
} |
|||
|
|||
/** |
|||
* 停止TTS播放 |
|||
*/ |
|||
func stopTts() { |
|||
if isTtsSpeaking { |
|||
_ = azureTtsHelper?.stopSpeaking() |
|||
isTtsSpeaking = false |
|||
restartIdleCheck() // 状态变化,重启检测 |
|||
sendEvent("tts_stopped", ["status": "stopped"]) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 保存聊天消息 |
|||
*/ |
|||
private func saveChatMessage(userMessage: String, assistantMessage: String) { |
|||
DispatchQueue.global(qos: .background).async { [weak self] in |
|||
guard let self = self else { return } |
|||
|
|||
// 保存用户消息 |
|||
let userMessageId = self.chatStorageHelper?.saveMessage( |
|||
sessionId: self.sessionId, |
|||
message: userMessage, |
|||
sender: "user" |
|||
) ?? -1 |
|||
|
|||
if userMessageId != -1 { |
|||
// 保存AI回复 |
|||
let assistantMessageId = self.chatStorageHelper?.saveMessage( |
|||
sessionId: self.sessionId, |
|||
message: assistantMessage, |
|||
sender: "assistant" |
|||
) ?? -1 |
|||
|
|||
if assistantMessageId == -1 { |
|||
print("\(self.TAG): 保存助手消息失败") |
|||
} |
|||
} else { |
|||
print("\(self.TAG): 保存用户消息失败") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清除聊天历史 |
|||
*/ |
|||
func clearChatHistory(completion: @escaping (Bool) -> Void) { |
|||
DispatchQueue.global(qos: .background).async { [weak self] in |
|||
guard let self = self else { |
|||
DispatchQueue.main.async { |
|||
completion(false) |
|||
} |
|||
return |
|||
} |
|||
|
|||
// 清除指定会话的所有消息 |
|||
let success = self.chatStorageHelper?.deleteMessages(sessionId: self.sessionId) ?? false |
|||
|
|||
if success { |
|||
// 清空内存中的历史记录 |
|||
self.historyMessages.removeAll() |
|||
print("\(self.TAG): 聊天历史已清除") |
|||
} else { |
|||
print("\(self.TAG): 清除聊天历史失败") |
|||
} |
|||
|
|||
DispatchQueue.main.async { |
|||
completion(success) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送事件 |
|||
*/ |
|||
private func sendEvent(_ eventName: String, _ data: [String: Any]) { |
|||
// 向所有监听器发送事件 |
|||
let allListeners = listeners.allObjects |
|||
for case let listener as AgentServiceListener in allListeners { |
|||
listener.onEvent(eventName: eventName, data: data) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 释放资源 |
|||
*/ |
|||
func dispose() { |
|||
stopRecognition() |
|||
stopTts() |
|||
stopAiStream() |
|||
stopIdleCheck() |
|||
|
|||
azureAsrHelper?.dispose() |
|||
azureTtsHelper?.dispose() |
|||
|
|||
// 清除所有监听器 |
|||
clearListeners() |
|||
|
|||
// 重置单例状态,以便下次使用时可以重新初始化 |
|||
isInitialized = false |
|||
|
|||
print("\(TAG): 代理服务资源已释放") |
|||
} |
|||
} |
|||
|
|||
// MARK: - AzureTtsHelper.TtsCallback |
|||
extension AgentService: AzureTtsHelper.TtsCallback { |
|||
func onSynthesisStarted() { |
|||
isTtsSpeaking = true |
|||
// 状态变化,重置空闲检测 |
|||
restartIdleCheck() |
|||
sendEvent("tts_started", ["status": "started"]) |
|||
} |
|||
|
|||
func onSynthesizing() { |
|||
// 可以在此添加TTS合成中的处理逻辑 |
|||
} |
|||
|
|||
func onSynthesisCompleted() { |
|||
isTtsSpeaking = false |
|||
// 状态变化,重启空闲检测 |
|||
restartIdleCheck() |
|||
sendEvent("tts_completed", ["status": "completed"]) |
|||
} |
|||
|
|||
func onSynthesisCanceled() { |
|||
isTtsSpeaking = false |
|||
// 状态变化,重启空闲检测 |
|||
restartIdleCheck() |
|||
sendEvent("tts_canceled", ["status": "canceled"]) |
|||
} |
|||
} |
|||
|
|||
// MARK: - 外部插件桥接器 |
|||
|
|||
/// 桥接OpenAIService插件 |
|||
class OpenAIServiceBridge { |
|||
private let service = OpenAIService() |
|||
|
|||
/// 初始化服务 |
|||
func initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String) { |
|||
service.initialize(apiKey: apiKey, baseUrl: baseUrl, model: model, mcpServer: mcpServer) |
|||
} |
|||
|
|||
/// 创建系统消息 |
|||
func createSystemMessage(text: String) -> [String: Any] { |
|||
return service.createSystemMessage(text) |
|||
} |
|||
|
|||
/// 创建用户消息 |
|||
func createUserMessage(text: String) -> [String: Any] { |
|||
return service.createUserMessage(text) |
|||
} |
|||
|
|||
/// 创建助手消息 |
|||
func createAssistantMessage(text: String) -> [String: Any] { |
|||
return service.createAssistantMessage(text) |
|||
} |
|||
|
|||
/// 创建带图片的用户消息 |
|||
func createUserMessageWithImage(text: String, imageBase64: String) -> [String: Any]? { |
|||
return service.createUserMessageWithImage(text: text, imageBase64: imageBase64) |
|||
} |
|||
|
|||
/// 文件转Base64 |
|||
func fileToBase64(filePath: String) -> String? { |
|||
return service.fileToBase64(filePath: filePath) |
|||
} |
|||
|
|||
/// 发送流式消息 |
|||
func sendMessageStream(messages: [[String: Any]], callback: OpenAIStreamCallback) { |
|||
service.sendMessageStream(messages: messages, callback: callback) |
|||
} |
|||
|
|||
/// 取消当前流 |
|||
func cancelCurrentStream() { |
|||
service.cancelCurrentStream() |
|||
} |
|||
} |
|||
|
|||
/// OpenAI流式回调协议 |
|||
@objc protocol OpenAIStreamCallback { |
|||
func onToken(token: String) |
|||
func onComplete() |
|||
func onError(error: Error) |
|||
func onFunctionCall(call: [String: Any]) |
|||
func onFunctionCallResult(functionCall: [String: Any], functionCallResult: [String: Any]) |
|||
} |
|||
|
|||
/// 桥接ChatStorageHelper插件 |
|||
class ChatStorageHelperBridge { |
|||
private let helper = ChatStorageHelper() |
|||
|
|||
/// 保存消息 |
|||
func saveMessage(sessionId: String, message: String, sender: String) -> Int64 { |
|||
return helper.saveMessage(sessionId: sessionId, message: message, sender: sender) |
|||
} |
|||
|
|||
/// 获取最近消息 |
|||
func getRecentMessages(sessionId: String, count: Int) -> [[String: Any]]? { |
|||
return helper.getRecentMessages(sessionId: sessionId, count: count) |
|||
} |
|||
|
|||
/// 删除消息 |
|||
func deleteMessages(sessionId: String) -> Bool { |
|||
return helper.deleteMessages(sessionId: sessionId) |
|||
} |
|||
} |
|||
@ -0,0 +1,991 @@ |
|||
import Foundation |
|||
import AVFoundation |
|||
import ble_service |
|||
import speech |
|||
import azure_speech |
|||
import os |
|||
import os.log |
|||
import agent_service |
|||
import open_ai_service |
|||
import chat_storage |
|||
|
|||
protocol AgentServiceListener: AnyObject { |
|||
func onEvent(eventName: String, data: [String: Any]) |
|||
} |
|||
|
|||
class AgentServiceImpl: NSObject { |
|||
private let TAG = "AgentServiceImpl" |
|||
|
|||
static let shared = AgentServiceImpl() |
|||
|
|||
private var listeners = [AgentServiceListener]() |
|||
private let listenersLock = NSLock() |
|||
|
|||
private var azureSpeechKey: String = "" |
|||
private var azureSpeechRegion: String = "" |
|||
|
|||
private var isSpeaking: Bool = false |
|||
|
|||
private var azureAsrHelper: AzureAsrHelper? |
|||
internal var azureTtsHelper: AzureTtsHelper? |
|||
|
|||
private var openAIService: OpenAIService? |
|||
private var apiKey: String = "" |
|||
private var baseUrl: String = "https://api.openai.com/v1/chat/completions" |
|||
private var model: String = "gpt-3.5-turbo" |
|||
private var systemPrompt: String = "" |
|||
private var chatHistory: [[String: Any]] = [] |
|||
|
|||
private var mcpServer: String = "" |
|||
|
|||
// 聊天存储服务(使用单例) |
|||
private var chatStorageHelper: ChatStorageHelper { |
|||
return ChatStorageHelper.shared |
|||
} |
|||
|
|||
// 会话ID,用于区分不同聊天上下文 |
|||
private let sessionId = "default_agent" |
|||
|
|||
private var isInitialized: Bool = false |
|||
private var isRecognizing: Bool = false |
|||
private var hasSpeechDetected: Bool = false |
|||
internal var isAiStreaming: Bool = false |
|||
|
|||
private var idleTimer: DispatchSourceTimer? |
|||
private let maxIdleSeconds: TimeInterval = 10 |
|||
|
|||
internal var audioPlayer: AudioPlayer? |
|||
|
|||
internal let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "AgentServiceImpl") |
|||
|
|||
private override init() { |
|||
super.init() |
|||
|
|||
audioPlayer = AudioPlayer() |
|||
|
|||
azureAsrHelper = AzureAsrHelper() |
|||
azureTtsHelper = AzureTtsHelper() |
|||
|
|||
openAIService = OpenAIService() |
|||
|
|||
// chatStorageHelper 现在是单例,不需要初始化 |
|||
|
|||
BleService.shared.setDelegate(self) |
|||
} |
|||
|
|||
func addListener(_ listener: AgentServiceListener) { |
|||
listenersLock.lock() |
|||
if !listeners.contains(where: { $0 === listener }) { |
|||
listeners.append(listener) |
|||
} |
|||
listenersLock.unlock() |
|||
} |
|||
|
|||
func removeListener(_ listener: AgentServiceListener) { |
|||
listenersLock.lock() |
|||
listeners.removeAll(where: { $0 === listener }) |
|||
listenersLock.unlock() |
|||
} |
|||
|
|||
func clearListeners() { |
|||
listenersLock.lock() |
|||
listeners.removeAll() |
|||
listenersLock.unlock() |
|||
} |
|||
|
|||
internal func sendEvent(name eventName: String, data: [String: Any]) { |
|||
listenersLock.lock() |
|||
let currentListeners = self.listeners |
|||
listenersLock.unlock() |
|||
|
|||
for listener in currentListeners { |
|||
listener.onEvent(eventName: eventName, data: data) |
|||
} |
|||
} |
|||
|
|||
private func sendError(_ message: String, code: String = "ERROR") { |
|||
sendEvent(name: "error", data: ["code": code, "message": message]) |
|||
} |
|||
|
|||
private func initSystemPrompt() { |
|||
systemPrompt = """ |
|||
你是小言,一个亲切而温暖的智能AI语音助手,拥有智能、细致而贴心的服务能力。你的任务不仅是高效处理用户的日常事务,更要陪伴用户,提供情感支持和温暖陪伴。你的能力包括: |
|||
1. 日程与任务管理:温柔提醒用户的重要日程、待办事项,让用户感到安心和舒适。 |
|||
2. 日常信息与建议:亲切、迅速地提供天气、新闻、生活小贴士等实用信息。 |
|||
3. 个人成长陪伴:鼓励并陪伴用户实现个人目标,提出富有同理心的行动建议。 |
|||
4. 财务温馨提示:提供贴心的理财建议,帮助用户轻松管理预算和财务。 |
|||
5. 健康贴心关怀:给出友善的饮食、运动、睡眠和心理健康建议,陪伴用户健康生活。 |
|||
6. 沟通支持与倾听:协助用户撰写和优化沟通内容,并耐心倾听用户的心情与困扰。 |
|||
7. 日常技术帮助:轻松解决技术问题,耐心地指导用户掌握新技能。 |
|||
8. 创意激发与鼓励:温柔启发用户的创造力,为用户提供鼓励和灵感。 |
|||
9. 旅行与活动贴心安排:细心规划用户的出行和活动,注重每个细节,确保舒适与快乐。 |
|||
10. 学习与温暖鼓励:耐心推荐学习资源,鼓励用户持续进步,陪伴用户共同成长。 |
|||
|
|||
你与用户的交流风格应始终温暖、亲切、充满同理心,随时表达关怀与鼓励,让用户感受到真诚的陪伴与支持。 |
|||
""" |
|||
} |
|||
func initialize(config: [String: Any]) -> Bool { |
|||
if isInitialized { return true } |
|||
// os_log("initialize: config=%{public}@", log: logger, type: .info, config) |
|||
|
|||
if let openaiApiKey = config["openaiApiKey"] as? String { |
|||
self.apiKey = openaiApiKey |
|||
} else { |
|||
sendError("OpenAI API密钥缺失", code: "CONFIG_ERROR") |
|||
return false |
|||
} |
|||
|
|||
if let openaiBaseUrl = config["openaiBaseUrl"] as? String { |
|||
self.baseUrl = openaiBaseUrl |
|||
} |
|||
|
|||
if let openaiModel = config["openaiModel"] as? String { |
|||
self.model = openaiModel |
|||
} |
|||
|
|||
if let customSystemPrompt = config["systemPrompt"] as? String, !customSystemPrompt.isEmpty { |
|||
self.systemPrompt = customSystemPrompt |
|||
} else { |
|||
initSystemPrompt() |
|||
} |
|||
if let azureSpeechKey = config["azureSpeechKey"] as? String { |
|||
self.azureSpeechKey = azureSpeechKey |
|||
} else { |
|||
sendError("Azure语音密钥缺失", code: "CONFIG_ERROR") |
|||
return false |
|||
} |
|||
|
|||
if let azureSpeechRegion = config["azureSpeechRegion"] as? String { |
|||
self.azureSpeechRegion = azureSpeechRegion |
|||
} else { |
|||
sendError("Azure语音区域缺失", code: "CONFIG_ERROR") |
|||
return false |
|||
} |
|||
|
|||
if let mcpServer = config["mcpServer"] as? String { |
|||
self.mcpServer = mcpServer |
|||
} |
|||
|
|||
let asrInitSuccess = initializeAzureSpeech() |
|||
|
|||
let openaiInitSuccess = initializeOpenAIService() |
|||
|
|||
isInitialized = asrInitSuccess && openaiInitSuccess |
|||
|
|||
if isInitialized { |
|||
loadChatHistory() |
|||
} |
|||
|
|||
return isInitialized |
|||
} |
|||
|
|||
private func initializeAzureSpeech() -> Bool { |
|||
azureTtsHelper?.addListener(self) |
|||
|
|||
guard let asrSuccess = azureAsrHelper?.initialize( |
|||
subscriptionKey: azureSpeechKey, |
|||
region: azureSpeechRegion, |
|||
supportedLanguages: ["zh-CN"], |
|||
audioSourceType: .microphone |
|||
), asrSuccess else { |
|||
sendError("初始化语音识别服务失败", code: "ASR_INIT_ERROR") |
|||
return false |
|||
} |
|||
|
|||
guard let ttsSuccess = azureTtsHelper?.initialize( |
|||
ttsAppId: "", |
|||
ttsAppToken: azureSpeechKey, |
|||
ttsResource: azureSpeechRegion, |
|||
language: "zh-CN" |
|||
), ttsSuccess else { |
|||
sendError("初始化语音合成服务失败", code: "TTS_INIT_ERROR") |
|||
return false |
|||
} |
|||
|
|||
_ = azureTtsHelper?.setVoice("zh-CN-XiaoxiaoNeural") |
|||
|
|||
return true |
|||
} |
|||
|
|||
private func initializeOpenAIService() -> Bool { |
|||
guard let openAIService = openAIService else { |
|||
sendError("OpenAI服务未创建", code: "OPENAI_INIT_ERROR") |
|||
return false |
|||
} |
|||
|
|||
let success = openAIService.initialize( |
|||
apiKey: apiKey, |
|||
baseUrl: baseUrl, |
|||
model: model, |
|||
mcpServer: mcpServer |
|||
) |
|||
|
|||
if !success { |
|||
sendError("初始化OpenAI服务失败", code: "OPENAI_INIT_ERROR") |
|||
return false |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
private func startIdleCheck() { |
|||
stopIdleCheck() |
|||
|
|||
guard isRecognizing else { return } |
|||
|
|||
// 使用全局队列而不是主队列,避免后台挂起问题 |
|||
idleTimer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility)) |
|||
idleTimer?.schedule(deadline: .now() + maxIdleSeconds) |
|||
idleTimer?.setEventHandler { [weak self] in |
|||
// 使用更安全的方式检查self是否存在 |
|||
DispatchQueue.main.async { [weak self] in |
|||
guard let self = self else { return } |
|||
|
|||
if self.isRecognizing && !self.hasSpeechDetected && !self.isSpeaking && !self.isAiStreaming { |
|||
self.stopRecognition() |
|||
self.sendEvent(name: "auto_stop", data: [ |
|||
"reason": "idle_timeout", |
|||
"seconds": self.maxIdleSeconds |
|||
]) |
|||
} |
|||
} |
|||
} |
|||
idleTimer?.resume() |
|||
} |
|||
|
|||
private func stopIdleCheck() { |
|||
if let timer = idleTimer { |
|||
timer.cancel() |
|||
idleTimer = nil |
|||
} |
|||
} |
|||
|
|||
private func restartIdleCheck() { |
|||
if isRecognizing { |
|||
startIdleCheck() |
|||
} |
|||
} |
|||
|
|||
func startRecognition(useBle: Bool = false) -> Bool { |
|||
if !isInitialized { |
|||
sendError("服务未初始化", code: "NOT_INITIALIZED") |
|||
return false |
|||
} |
|||
|
|||
if !useBle { |
|||
guard AVAudioSession.sharedInstance().recordPermission == .granted else { |
|||
sendError("无麦克风权限", code: "PERMISSION_DENIED") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
if isRecognizing { |
|||
stopRecognition() |
|||
} |
|||
|
|||
let audioSourceType: AzureAsrHelper.AudioSourceType = useBle ? .external : .microphone |
|||
|
|||
if useBle { |
|||
AudioSessionHub.shared.begin(.playback) |
|||
} else { |
|||
AudioSessionHub.shared.begin(.voice) |
|||
} |
|||
|
|||
guard let success = azureAsrHelper?.startContinuousRecognition( |
|||
callback: self, |
|||
audioSourceType: audioSourceType |
|||
), success else { |
|||
sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR") |
|||
return false |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
func stopRecognition() -> Bool { |
|||
if !isRecognizing { |
|||
return true |
|||
} |
|||
|
|||
guard let success = azureAsrHelper?.stopContinuousRecognition(), success else { |
|||
return false |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
func pushAudioData(_ audioData: Data) -> Bool { |
|||
if !isRecognizing || !isInitialized { |
|||
return false |
|||
} |
|||
|
|||
azureAsrHelper?.pushAudioData(data: audioData) |
|||
return true |
|||
} |
|||
|
|||
func speakText(_ text: String) -> Bool { |
|||
if !isInitialized { |
|||
sendError("服务未初始化", code: "NOT_INITIALIZED") |
|||
return false |
|||
} |
|||
|
|||
if text.isEmpty { |
|||
return false |
|||
} |
|||
|
|||
return azureTtsHelper?.speakOnce(text) ?? false |
|||
} |
|||
|
|||
func stopTts() -> Bool { |
|||
if !isSpeaking { |
|||
return true |
|||
} |
|||
|
|||
let success = azureTtsHelper?.stop() ?? false |
|||
|
|||
if success { |
|||
isSpeaking = false |
|||
restartIdleCheck() |
|||
sendEvent(name: "tts_stopped", data: ["status": "stopped"]) |
|||
} |
|||
|
|||
return success |
|||
} |
|||
|
|||
func interruptCurrentResponse() -> Bool { |
|||
var interrupted = false |
|||
|
|||
if isSpeaking { |
|||
interrupted = stopTts() || interrupted |
|||
} |
|||
|
|||
if isAiStreaming { |
|||
isAiStreaming = false |
|||
_ = openAIService?.cancelCurrentStream() |
|||
interrupted = true |
|||
} |
|||
|
|||
if interrupted { |
|||
sendEvent(name: "response_interrupted", data: ["status": "interrupted"]) |
|||
} |
|||
|
|||
return interrupted |
|||
} |
|||
|
|||
func processTextInput(_ text: String, speakResponse: Bool) -> Bool { |
|||
if !isInitialized { |
|||
sendError("服务未初始化", code: "NOT_INITIALIZED") |
|||
return false |
|||
} |
|||
|
|||
if text.isEmpty { |
|||
sendError("文本输入不能为空", code: "EMPTY_TEXT") |
|||
return false |
|||
} |
|||
|
|||
processWithOpenAI(text: text, speakResponse: speakResponse) |
|||
return true |
|||
} |
|||
|
|||
private func processWithOpenAI(text: String, speakResponse: Bool = true) { |
|||
guard let openAIService = openAIService else { |
|||
sendError("OpenAI服务未初始化", code: "OPENAI_NOT_INITIALIZED") |
|||
return |
|||
} |
|||
|
|||
let userMessage = openAIService.createUserMessage(content: text) |
|||
processWithOpenAIInternal(userMessage: userMessage, displayText: text, speakResponse: speakResponse) |
|||
} |
|||
|
|||
private func processWithOpenAIInternal(userMessage: [String: Any], displayText: String, speakResponse: Bool = true, hasImage: Bool = false) { |
|||
guard let openAIService = openAIService else { |
|||
sendError("OpenAI服务未初始化", code: "OPENAI_NOT_INITIALIZED") |
|||
return |
|||
} |
|||
|
|||
if isAiStreaming { |
|||
_ = interruptCurrentResponse() |
|||
} |
|||
|
|||
isAiStreaming = true |
|||
|
|||
var messages: [[String: Any]] = [] |
|||
|
|||
if !systemPrompt.isEmpty { |
|||
messages.append(openAIService.createSystemMessage(content: systemPrompt)) |
|||
} |
|||
|
|||
messages.append(contentsOf: chatHistory) |
|||
|
|||
messages.append(userMessage) |
|||
|
|||
addToHistoryMessages(openAIService.createUserMessage(content: displayText)) |
|||
|
|||
let callback = OpenAIStreamCallback( |
|||
agentService: self, |
|||
speakResponse: speakResponse, |
|||
displayText: displayText, |
|||
hasImage: hasImage |
|||
) |
|||
|
|||
openAIService.sendMessageStream(messages: messages, callback: callback) |
|||
} |
|||
|
|||
func processImageInput(imagePath: String, text: String, speakResponse: Bool) -> Bool { |
|||
if !isInitialized { |
|||
sendError("服务未初始化", code: "NOT_INITIALIZED") |
|||
return false |
|||
} |
|||
|
|||
if imagePath.isEmpty { |
|||
sendError("图片路径不能为空", code: "EMPTY_IMAGE_PATH") |
|||
return false |
|||
} |
|||
|
|||
guard let openAIService = openAIService else { |
|||
sendError("OpenAI服务未初始化", code: "OPENAI_NOT_INITIALIZED") |
|||
return false |
|||
} |
|||
|
|||
sendEvent(name: "image_processing", data: [ |
|||
"status": "processing", |
|||
"imagePath": imagePath |
|||
]) |
|||
|
|||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
|||
guard let self = self else { return } |
|||
|
|||
guard let imageBase64 = openAIService.fileToBase64(filePath: imagePath) else { |
|||
DispatchQueue.main.async { |
|||
self.sendEvent(name: "error", data: [ |
|||
"code": "IMAGE_CONVERSION_FAILED", |
|||
"message": "图片转换失败" |
|||
]) |
|||
} |
|||
return |
|||
} |
|||
|
|||
DispatchQueue.main.async { |
|||
self.sendEvent(name: "image_ready", data: [ |
|||
"status": "ready", |
|||
"imagePath": imagePath |
|||
]) |
|||
|
|||
let userMessage = openAIService.createUserMessageWithImage(text: text, imageBase64: imageBase64) |
|||
let displayText = text.isEmpty ? "[图片]" : text |
|||
|
|||
self.processWithOpenAIInternal( |
|||
userMessage: userMessage, |
|||
displayText: displayText, |
|||
speakResponse: speakResponse, |
|||
hasImage: true |
|||
) |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
internal func addToHistoryMessages(_ message: [String: Any]) { |
|||
chatHistory.append(message) |
|||
|
|||
while chatHistory.count > 10 { |
|||
chatHistory.removeFirst() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 加载最近的聊天历史记录 |
|||
*/ |
|||
private func loadChatHistory() { |
|||
// 清空当前历史记录 |
|||
chatHistory.removeAll() |
|||
|
|||
// 获取最近10条消息 |
|||
let recentMessages = chatStorageHelper.getRecentMessages(sessionId: sessionId, limit: 10) |
|||
|
|||
if recentMessages.isEmpty { |
|||
return |
|||
} |
|||
|
|||
// 添加消息到历史记录 |
|||
for message in recentMessages { |
|||
guard let sender = message["sender"] as? String, |
|||
let content = message["message"] as? String else { |
|||
continue |
|||
} |
|||
|
|||
if sender == "user" { |
|||
if let openAIService = openAIService { |
|||
addToHistoryMessages(openAIService.createUserMessage(content: content)) |
|||
} |
|||
} else if sender == "assistant" { |
|||
let assistantMessage: [String: Any] = [ |
|||
"role": "assistant", |
|||
"content": content |
|||
] |
|||
addToHistoryMessages(assistantMessage) |
|||
} |
|||
} |
|||
|
|||
os_log("已加载%d条历史记录", log: logger, type: .info, recentMessages.count) |
|||
} |
|||
|
|||
/** |
|||
* 保存聊天消息 |
|||
*/ |
|||
internal func saveChatMessage(userMessage: String, assistantMessage: String, metadata: String = "") { |
|||
DispatchQueue.global(qos: .utility).async { |
|||
// 保存用户消息 |
|||
let userMessageId = self.chatStorageHelper.saveMessage( |
|||
sessionId: self.sessionId, |
|||
message: userMessage, |
|||
sender: "user", |
|||
metadata: nil as String? |
|||
) |
|||
|
|||
if userMessageId != -1 { |
|||
// 保存AI回复 |
|||
let assistantMessageId = self.chatStorageHelper.saveMessage( |
|||
sessionId: self.sessionId, |
|||
message: assistantMessage, |
|||
sender: "assistant", |
|||
metadata: metadata.isEmpty ? nil : metadata |
|||
) |
|||
|
|||
if assistantMessageId == -1 { |
|||
os_log("保存助手消息失败", log: self.logger, type: .error) |
|||
} |
|||
} else { |
|||
os_log("保存用户消息失败", log: self.logger, type: .error) |
|||
} |
|||
} |
|||
} |
|||
|
|||
@discardableResult |
|||
internal func autoHandleFunctionCallResult(_ functionCallResult: [String: Any]) -> String { |
|||
guard let metaStr = functionCallResult["meta"] as? String, !metaStr.isEmpty else { |
|||
return "" |
|||
} |
|||
|
|||
do { |
|||
guard let metaData = metaStr.data(using: .utf8), |
|||
let meta = try JSONSerialization.jsonObject(with: metaData) as? [String: Any] else { |
|||
return metaStr |
|||
} |
|||
|
|||
if let cardMusic = meta["card_music"] as? [String: Any] { |
|||
let id = cardMusic["id"] as? String ?? "" |
|||
let url = cardMusic["url"] as? String ?? "" |
|||
let name = cardMusic["name"] as? String ?? "" |
|||
let sgener = cardMusic["sgener"] as? String ?? "" |
|||
let image = cardMusic["image"] as? String ?? "" |
|||
|
|||
processMusicPlay([ |
|||
"id": id, |
|||
"url": url, |
|||
"title": name, |
|||
"artist": sgener, |
|||
"coverUrl": image |
|||
]) |
|||
} |
|||
|
|||
return metaStr |
|||
} catch { |
|||
return metaStr |
|||
} |
|||
} |
|||
|
|||
private func processMusicPlay(_ musicData: [String: Any]) { |
|||
sendEvent(name: "music_play", data: musicData) |
|||
} |
|||
|
|||
func clearChatHistory() -> Bool { |
|||
chatHistory.removeAll() |
|||
|
|||
DispatchQueue.global(qos: .utility).async { |
|||
let success = self.chatStorageHelper.deleteMessages(sessionId: self.sessionId, messageIds: nil as [Int]?) |
|||
if !success { |
|||
os_log("清除聊天历史失败", log: self.logger, type: .error) |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
func dispose() -> Bool { |
|||
if isRecognizing { |
|||
stopRecognition() |
|||
} |
|||
|
|||
if isSpeaking { |
|||
stopTts() |
|||
} |
|||
|
|||
stopIdleCheck() |
|||
|
|||
BleService.shared.setDelegate(nil) |
|||
|
|||
azureTtsHelper?.removeListener(self) |
|||
azureAsrHelper?.dispose() |
|||
azureTtsHelper?.dispose() |
|||
|
|||
openAIService?.cancelAll() |
|||
openAIService = nil |
|||
|
|||
audioPlayer = nil |
|||
|
|||
// chatStorageHelper 是单例,不需要手动清理 |
|||
|
|||
clearListeners() |
|||
|
|||
AudioSessionHub.shared.appDidEnterBackground() |
|||
|
|||
isInitialized = false |
|||
|
|||
return true |
|||
} |
|||
} |
|||
|
|||
class OpenAIStreamCallback: StreamCallback { |
|||
private weak var agentService: AgentServiceImpl? |
|||
private let speakResponse: Bool |
|||
private let displayText: String |
|||
private let hasImage: Bool |
|||
private var responseBuilder = "" |
|||
private var metadata = "" |
|||
|
|||
init(agentService: AgentServiceImpl, speakResponse: Bool, displayText: String, hasImage: Bool) { |
|||
self.agentService = agentService |
|||
self.speakResponse = speakResponse |
|||
self.displayText = displayText |
|||
self.hasImage = hasImage |
|||
} |
|||
|
|||
func onToken(_ token: String) { |
|||
guard let agentService = agentService else { return } |
|||
|
|||
responseBuilder += token |
|||
|
|||
agentService.sendEvent(name: "assistant_token", data: ["token": token]) |
|||
|
|||
if speakResponse { |
|||
agentService.azureTtsHelper?.speakStream(token) |
|||
} |
|||
} |
|||
|
|||
func onComplete() { |
|||
guard let agentService = agentService else { return } |
|||
|
|||
if speakResponse { |
|||
agentService.azureTtsHelper?.flushStream() |
|||
} |
|||
|
|||
let response = responseBuilder |
|||
|
|||
if !response.isEmpty { |
|||
var responseData: [String: Any] = [ |
|||
"text": response, |
|||
"userInput": displayText |
|||
] |
|||
if hasImage { |
|||
responseData["hasImage"] = true |
|||
} |
|||
agentService.sendEvent(name: "assistant_response", data: responseData) |
|||
|
|||
let assistantMessage: [String: Any] = [ |
|||
"role": "assistant", |
|||
"content": response |
|||
] |
|||
agentService.addToHistoryMessages(assistantMessage) |
|||
|
|||
// 保存聊天记录 |
|||
agentService.saveChatMessage(userMessage: displayText, assistantMessage: response, metadata: metadata) |
|||
} |
|||
|
|||
agentService.isAiStreaming = false |
|||
} |
|||
|
|||
func onError(_ error: Error) { |
|||
guard let agentService = agentService else { return } |
|||
|
|||
agentService.sendEvent(name: "error", data: [ |
|||
"code": "AI_ERROR", |
|||
"message": error.localizedDescription |
|||
]) |
|||
|
|||
agentService.isAiStreaming = false |
|||
} |
|||
|
|||
func onFunctionCall(_ functionCall: [String: Any]) { |
|||
guard let agentService = agentService else { return } |
|||
|
|||
agentService.audioPlayer?.playCallingSound() |
|||
|
|||
let name = functionCall["name"] as? String ?? "" |
|||
agentService.sendEvent(name: "function_call", data: [ |
|||
"name": name, |
|||
"arguments": functionCall |
|||
]) |
|||
|
|||
if name == "exit_interaction" { |
|||
agentService.stopRecognition() |
|||
} |
|||
} |
|||
|
|||
func onFunctionCallResult(_ functionCall: [String: Any], _ functionCallResult: [String: Any]) { |
|||
guard let agentService = agentService else { return } |
|||
|
|||
agentService.audioPlayer?.stopCallingSound() |
|||
|
|||
agentService.sendEvent(name: "function_call_result", data: [ |
|||
"function_call": functionCall, |
|||
"result": functionCallResult |
|||
]) |
|||
|
|||
metadata = agentService.autoHandleFunctionCallResult(functionCallResult) |
|||
} |
|||
} |
|||
|
|||
extension AgentServiceImpl: TtsEventListener { |
|||
func onEvent(_ event: TtsEvent) { |
|||
switch event.type { |
|||
case .synthesisStarted: |
|||
isSpeaking = true |
|||
restartIdleCheck() |
|||
sendEvent(name: "tts_started", data: ["status": "started"]) |
|||
|
|||
case .synthesisCompleted: |
|||
isSpeaking = false |
|||
restartIdleCheck() |
|||
sendEvent(name: "tts_completed", data: ["status": "completed"]) |
|||
|
|||
case .synthesisCanceled: |
|||
isSpeaking = false |
|||
restartIdleCheck() |
|||
|
|||
var data: [String: Any] = ["status": "canceled"] |
|||
if let reason = event.params["reason"] as? String { |
|||
data["reason"] = reason |
|||
} |
|||
if let errorDetails = event.params["errorDetails"] as? String { |
|||
data["details"] = errorDetails |
|||
} |
|||
|
|||
sendEvent(name: "tts_canceled", data: data) |
|||
|
|||
case .error: |
|||
isSpeaking = false |
|||
restartIdleCheck() |
|||
|
|||
var data: [String: Any] = [:] |
|||
if let errorCode = event.params["errorCode"] as? String { |
|||
data["errorCode"] = errorCode |
|||
} |
|||
if let errorMessage = event.params["errorMessage"] as? String { |
|||
data["message"] = errorMessage |
|||
} else { |
|||
data["message"] = "TTS错误" |
|||
} |
|||
|
|||
sendEvent(name: "error", data: data) |
|||
|
|||
default: |
|||
break |
|||
} |
|||
} |
|||
} |
|||
|
|||
extension AgentServiceImpl: BleService.Callback { |
|||
func onScanResult(devices: [[String: Any]]) { |
|||
} |
|||
|
|||
func onConnectionStateChanged(state: Int) { |
|||
} |
|||
|
|||
func onAudioDataReceived(data: Data) { |
|||
pushAudioData(data) |
|||
} |
|||
|
|||
func onWakeupSignalReceived() { |
|||
stopTts() |
|||
|
|||
stopRecognition() |
|||
|
|||
startRecognition(useBle: true) |
|||
} |
|||
|
|||
func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) { |
|||
} |
|||
} |
|||
|
|||
class AudioPlayer { |
|||
private var player: AVAudioPlayer? |
|||
private var callingPlayer: AVAudioPlayer? |
|||
private let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "AudioPlayer") |
|||
private let audioQueue = DispatchQueue(label: "agent.audio.player", qos: .userInitiated) |
|||
|
|||
func playStartSound() { |
|||
playSound(named: "start", fileType: "mp3") |
|||
} |
|||
|
|||
func playStopSound() { |
|||
playSound(named: "stop", fileType: "mp3") |
|||
} |
|||
|
|||
func playCallingSound() { |
|||
playLoopSound(named: "calling", fileType: "mp3") |
|||
} |
|||
|
|||
func stopCallingSound() { |
|||
callingPlayer?.stop() |
|||
callingPlayer = nil |
|||
} |
|||
|
|||
private func playSound(named: String, fileType: String = "mp3") { |
|||
if let path = Bundle(for: type(of: self)).path(forResource: named, ofType: fileType) { |
|||
let url = URL(fileURLWithPath: path) |
|||
safePlayAudio(url: url) |
|||
return |
|||
} |
|||
|
|||
os_log("未找到音频文件: %{public}@.%{public}@", log: logger, type: .error, named, fileType) |
|||
} |
|||
|
|||
private func playLoopSound(named: String, fileType: String = "mp3") { |
|||
stopCallingSound() |
|||
|
|||
if let path = Bundle(for: type(of: self)).path(forResource: named, ofType: fileType) { |
|||
let url = URL(fileURLWithPath: path) |
|||
safePlayLoopAudio(url: url) |
|||
return |
|||
} |
|||
|
|||
os_log("未找到循环音频文件: %{public}@.%{public}@", log: logger, type: .error, named, fileType) |
|||
} |
|||
|
|||
private func safePlayAudio(url: URL) { |
|||
do { |
|||
player?.stop() |
|||
player = nil |
|||
|
|||
player = try AVAudioPlayer(contentsOf: url) |
|||
|
|||
guard let player = player else { |
|||
return |
|||
} |
|||
|
|||
player.volume = 0.7 |
|||
player.prepareToPlay() |
|||
|
|||
audioQueue.async { [weak self] in |
|||
guard let self = self else { return } |
|||
|
|||
let playResult = player.play() |
|||
|
|||
let playTime = player.duration + 0.5 |
|||
Thread.sleep(forTimeInterval: playTime) |
|||
} |
|||
} catch { |
|||
os_log("播放音频失败: %{public}@", log: logger, type: .error, error.localizedDescription) |
|||
} |
|||
} |
|||
|
|||
private func safePlayLoopAudio(url: URL) { |
|||
do { |
|||
callingPlayer = try AVAudioPlayer(contentsOf: url) |
|||
|
|||
guard let callingPlayer = callingPlayer else { |
|||
return |
|||
} |
|||
|
|||
callingPlayer.numberOfLoops = -1 |
|||
callingPlayer.volume = 0.5 |
|||
callingPlayer.prepareToPlay() |
|||
|
|||
let playResult = callingPlayer.play() |
|||
|
|||
} catch { |
|||
os_log("播放循环音频失败: %{public}@", log: logger, type: .error, error.localizedDescription) |
|||
} |
|||
} |
|||
} |
|||
|
|||
extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { |
|||
func onResult(_ text: String, _ detectedLanguage: String) { |
|||
if !text.isEmpty { |
|||
var data: [String: Any] = ["text": text] |
|||
if !detectedLanguage.isEmpty { |
|||
data["language"] = detectedLanguage |
|||
} |
|||
sendEvent(name: "recognition_result", data: data) |
|||
|
|||
processTextInput(text, speakResponse: true) |
|||
} |
|||
|
|||
let previousHasSpeech = hasSpeechDetected |
|||
hasSpeechDetected = false |
|||
|
|||
if previousHasSpeech { |
|||
restartIdleCheck() |
|||
} |
|||
} |
|||
|
|||
func onRecognizing(_ recognizing: String, _ detectedLanguage: String) { |
|||
if !recognizing.isEmpty { |
|||
let previousHasSpeech = hasSpeechDetected |
|||
hasSpeechDetected = true |
|||
|
|||
if !previousHasSpeech { |
|||
restartIdleCheck() |
|||
} |
|||
|
|||
var data: [String: Any] = ["text": recognizing] |
|||
if !detectedLanguage.isEmpty { |
|||
data["language"] = detectedLanguage |
|||
} |
|||
sendEvent(name: "recognizing", data: data) |
|||
|
|||
if isSpeaking || isAiStreaming { |
|||
interruptCurrentResponse() |
|||
} |
|||
} |
|||
} |
|||
|
|||
func onSessionStarted() { |
|||
sendEvent(name: "recognition_started", data: ["status": "started"]) |
|||
isRecognizing = true |
|||
hasSpeechDetected = false |
|||
startIdleCheck() |
|||
audioPlayer?.playStartSound() |
|||
} |
|||
|
|||
func onSessionStopped() { |
|||
sendEvent(name: "recognition_stopped", data: ["status": "stopped"]) |
|||
isRecognizing = false |
|||
hasSpeechDetected = false |
|||
stopIdleCheck() |
|||
audioPlayer?.playStopSound() |
|||
} |
|||
|
|||
func onCanceled(_ reason: String, _ errorDetails: String) { |
|||
var data: [String: Any] = [:] |
|||
if !reason.isEmpty { |
|||
data["reason"] = reason |
|||
} |
|||
if !errorDetails.isEmpty { |
|||
data["details"] = errorDetails |
|||
} |
|||
|
|||
sendEvent(name: "recognition_canceled", data: data) |
|||
isRecognizing = false |
|||
stopIdleCheck() |
|||
} |
|||
|
|||
func onError(_ error: String) { |
|||
let data: [String: Any] = ["message": error.isEmpty ? "未知错误" : error] |
|||
sendEvent(name: "error", data: data) |
|||
isRecognizing = false |
|||
stopIdleCheck() |
|||
} |
|||
} |
|||
@ -1,11 +0,0 @@ |
|||
import Foundation |
|||
|
|||
/// 代理服务事件监听器协议 |
|||
/// 用于接收代理服务发送的各类事件 |
|||
protocol AgentServiceListener: AnyObject { |
|||
/// 事件回调 |
|||
/// - Parameters: |
|||
/// - eventName: 事件名称 |
|||
/// - data: 事件数据 |
|||
func onEvent(eventName: String, data: [String: Any]) |
|||
} |
|||
@ -1,4 +1,4 @@ |
|||
#import <Flutter/Flutter.h> |
|||
|
|||
|
|||
@interface AgentServicePlugin : NSObject<FlutterPlugin> |
|||
@end |
|||
@ -0,0 +1,160 @@ |
|||
import Flutter |
|||
import UIKit |
|||
import Foundation |
|||
import os.log |
|||
|
|||
/// 代理服务Flutter插件,负责将Flutter调用桥接到原生实现 |
|||
public class AgentServicePlugin: NSObject, FlutterPlugin { |
|||
|
|||
/// 插件注册方法,Flutter引擎调用 |
|||
public static func register(with registrar: FlutterPluginRegistrar) { |
|||
let channel = FlutterMethodChannel(name: "com.yunqiinnovation.agent_service", binaryMessenger: registrar.messenger()) |
|||
let eventChannel = FlutterEventChannel(name: "com.yunqiinnovation.agent_service/events", binaryMessenger: registrar.messenger()) |
|||
let instance = AgentServicePlugin() |
|||
|
|||
registrar.addMethodCallDelegate(instance, channel: channel) |
|||
eventChannel.setStreamHandler(instance.eventHandler) |
|||
} |
|||
|
|||
// MARK: - 属性 |
|||
|
|||
private let impl = AgentServiceImpl.shared |
|||
private lazy var eventHandler = AgentServiceEventHandler(impl: impl) |
|||
|
|||
// MARK: - FlutterPlugin 实现 |
|||
|
|||
/// 处理Flutter发来的方法调用 |
|||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|||
switch call.method { |
|||
case "startAgentService": |
|||
guard let arguments = call.arguments as? [String: Any], |
|||
let config = arguments["config"] as? [String: Any] else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "配置参数不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = impl.initialize(config: config) |
|||
result(success) |
|||
|
|||
case "stopAgentService": |
|||
let success = impl.dispose() |
|||
result(success) |
|||
|
|||
case "startConversation": |
|||
impl.startRecognition() |
|||
result(true) |
|||
|
|||
case "stopConversation": |
|||
impl.stopRecognition() |
|||
result(true) |
|||
|
|||
case "processTextInput": |
|||
guard let arguments = call.arguments as? [String: Any], |
|||
let text = arguments["text"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let speakResponse = arguments["speakResponse"] as? Bool ?? false |
|||
let success = impl.processTextInput(text, speakResponse: speakResponse) |
|||
result(success) |
|||
|
|||
case "processImageInput": |
|||
guard let arguments = call.arguments as? [String: Any], |
|||
let imagePath = arguments["imagePath"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "图片路径不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let text = arguments["text"] as? String ?? "" |
|||
let speakResponse = arguments["speakResponse"] as? Bool ?? false |
|||
let success = impl.processImageInput(imagePath: imagePath, text: text, speakResponse: speakResponse) |
|||
result(success) |
|||
|
|||
case "speakText": |
|||
guard let arguments = call.arguments as? [String: Any], |
|||
let text = arguments["text"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = impl.speakText(text) |
|||
result(success) |
|||
|
|||
case "stopTts": |
|||
let success = impl.stopTts() |
|||
result(success) |
|||
|
|||
case "clearChatHistory": |
|||
let success = impl.clearChatHistory() |
|||
result(success) |
|||
|
|||
case "interruptCurrentResponse": |
|||
let success = impl.interruptCurrentResponse() |
|||
result(success) |
|||
|
|||
case "dispose": |
|||
let success = impl.dispose() |
|||
result(success) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 代理服务事件处理器,负责将原生事件传递给Flutter |
|||
class AgentServiceEventHandler: NSObject, FlutterStreamHandler { |
|||
private let impl: AgentServiceImpl |
|||
private var eventSink: FlutterEventSink? |
|||
private let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "AgentServiceEventHandler") |
|||
|
|||
init(impl: AgentServiceImpl) { |
|||
self.impl = impl |
|||
super.init() |
|||
} |
|||
|
|||
/// 监听开始时调用 |
|||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|||
self.eventSink = events |
|||
|
|||
// 添加实现的事件监听器 |
|||
impl.addListener(self) |
|||
return nil |
|||
} |
|||
|
|||
/// 监听取消时调用 |
|||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|||
impl.removeListener(self) |
|||
self.eventSink = nil |
|||
return nil |
|||
} |
|||
} |
|||
|
|||
// 扩展事件处理器以实现AgentServiceListener接口 |
|||
extension AgentServiceEventHandler: AgentServiceListener { |
|||
func onEvent(eventName: String, data: [String: Any]) { |
|||
// 确保在主线程上调用 |
|||
DispatchQueue.main.async { [weak self] in |
|||
guard let self = self else { return } |
|||
|
|||
// 将事件数据格式化为与Android版本一致的格式 |
|||
let eventData: [String: Any] = [ |
|||
"event": eventName, |
|||
"data": data |
|||
] |
|||
|
|||
// 转换为JSON字符串发送,与Android版本保持一致 |
|||
do { |
|||
let jsonData = try JSONSerialization.data(withJSONObject: eventData, options: []) |
|||
if let jsonString = String(data: jsonData, encoding: .utf8) { |
|||
self.eventSink?(jsonString) |
|||
} else { |
|||
os_log("无法创建JSON字符串", log: self.logger, type: .error) |
|||
} |
|||
} catch { |
|||
os_log("JSON序列化失败: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,240 @@ |
|||
import AVFoundation |
|||
import os.log |
|||
|
|||
public enum AudioIntent { |
|||
case voice |
|||
case playback |
|||
} |
|||
|
|||
public class AudioSessionHub { |
|||
|
|||
public static let shared = AudioSessionHub() |
|||
|
|||
private let logger = OSLog(subsystem: "com.yunqiinnovation.agent_service", category: "AudioSessionHub") |
|||
private var currentIntent: AudioIntent? |
|||
private let audioSession = AVAudioSession.sharedInstance() |
|||
private var isInterrupted = false |
|||
|
|||
private init() { |
|||
setupNotifications() |
|||
} |
|||
|
|||
public func begin(_ intent: AudioIntent) { |
|||
if intent == currentIntent && audioSession.isOtherAudioPlaying == false { |
|||
return |
|||
} |
|||
|
|||
currentIntent = intent |
|||
|
|||
do { |
|||
switch intent { |
|||
|
|||
case .voice: |
|||
try audioSession.setCategory(.playAndRecord, |
|||
mode: .voiceChat, |
|||
options: [.allowBluetooth, .mixWithOthers]) |
|||
|
|||
// 如果有蓝牙HFP设备,强制设置为首选输入 |
|||
if let bluetoothInput = audioSession.availableInputs?.first(where: { $0.portType == .bluetoothHFP }) { |
|||
try audioSession.setPreferredInput(bluetoothInput) |
|||
os_log("已设置蓝牙HFP为首选输入: %{public}@", log: logger, type: .info, bluetoothInput.portName) |
|||
} |
|||
|
|||
case .playback: |
|||
try audioSession.setCategory(.playback, |
|||
mode: .default, |
|||
options: [.allowBluetooth, .mixWithOthers]) |
|||
|
|||
} |
|||
|
|||
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) |
|||
|
|||
os_log("音频会话已设置为:%{public}@", log: logger, type: .debug, String(describing: intent)) |
|||
|
|||
// 输出当前音频路由信息 |
|||
let hasBluetoothDevice = hasBluetoothAudioDevice() |
|||
let hasBluetoothMic = hasBluetoothMicrophone() |
|||
let currentInput = getCurrentInputDevice() |
|||
|
|||
os_log("蓝牙音频设备状态: %{public}@", log: logger, type: .info, hasBluetoothDevice ? "已连接" : "未连接") |
|||
os_log("蓝牙麦克风状态: %{public}@", log: logger, type: .info, hasBluetoothMic ? "已连接" : "未连接") |
|||
os_log("当前输入设备: %{public}@", log: logger, type: .info, currentInput) |
|||
|
|||
if hasBluetoothDevice { |
|||
os_log("%{public}@", log: logger, type: .debug, getCurrentAudioRoute()) |
|||
} |
|||
} catch { |
|||
os_log("设置音频会话失败:%{public}@", log: logger, type: .error, error.localizedDescription) |
|||
} |
|||
} |
|||
|
|||
public func appWillEnterForeground() { |
|||
if let intent = currentIntent { |
|||
begin(intent) |
|||
} |
|||
} |
|||
|
|||
public func appDidEnterBackground() { |
|||
do { |
|||
try audioSession.setActive(false, options: .notifyOthersOnDeactivation) |
|||
os_log("音频会话已释放(应用进入后台)", log: logger, type: .debug) |
|||
} catch { |
|||
os_log("释放音频会话失败:%{public}@", log: logger, type: .error, error.localizedDescription) |
|||
} |
|||
} |
|||
|
|||
/// 获取当前音频路由信息 |
|||
public func getCurrentAudioRoute() -> String { |
|||
let currentRoute = audioSession.currentRoute |
|||
var routeInfo = "当前音频路由:\n" |
|||
|
|||
// 输出设备 |
|||
routeInfo += "输出设备:\n" |
|||
for output in currentRoute.outputs { |
|||
routeInfo += " - \(output.portName) (\(output.portType.rawValue))\n" |
|||
} |
|||
|
|||
// 输入设备 |
|||
routeInfo += "输入设备:\n" |
|||
for input in currentRoute.inputs { |
|||
routeInfo += " - \(input.portName) (\(input.portType.rawValue))\n" |
|||
} |
|||
|
|||
return routeInfo |
|||
} |
|||
|
|||
/// 检查是否有蓝牙音频设备连接 |
|||
public func hasBluetoothAudioDevice() -> Bool { |
|||
let currentRoute = audioSession.currentRoute |
|||
|
|||
// 检查输出设备 |
|||
for output in currentRoute.outputs { |
|||
if output.portType == .bluetoothA2DP || |
|||
output.portType == .bluetoothHFP || |
|||
output.portType == .bluetoothLE { |
|||
return true |
|||
} |
|||
} |
|||
|
|||
// 检查输入设备(这是关键!) |
|||
for input in currentRoute.inputs { |
|||
if input.portType == .bluetoothHFP { |
|||
return true |
|||
} |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
/// 检查是否有蓝牙麦克风作为输入设备 |
|||
public func hasBluetoothMicrophone() -> Bool { |
|||
let currentRoute = audioSession.currentRoute |
|||
|
|||
for input in currentRoute.inputs { |
|||
if input.portType == .bluetoothHFP { |
|||
return true |
|||
} |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
/// 获取当前输入设备信息 |
|||
public func getCurrentInputDevice() -> String { |
|||
let currentRoute = audioSession.currentRoute |
|||
|
|||
if let input = currentRoute.inputs.first { |
|||
return "\(input.portName) (\(input.portType.rawValue))" |
|||
} |
|||
|
|||
return "无输入设备" |
|||
} |
|||
|
|||
private func setupNotifications() { |
|||
NotificationCenter.default.addObserver( |
|||
self, |
|||
selector: #selector(handleAudioSessionInterruption), |
|||
name: AVAudioSession.interruptionNotification, |
|||
object: nil |
|||
) |
|||
|
|||
NotificationCenter.default.addObserver( |
|||
self, |
|||
selector: #selector(handleAudioRouteChange), |
|||
name: AVAudioSession.routeChangeNotification, |
|||
object: nil |
|||
) |
|||
} |
|||
|
|||
/// 处理音频中断 |
|||
@objc private func handleAudioSessionInterruption(notification: Notification) { |
|||
guard let userInfo = notification.userInfo, |
|||
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, |
|||
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { |
|||
return |
|||
} |
|||
|
|||
switch type { |
|||
case .began: |
|||
// 中断开始 |
|||
isInterrupted = true |
|||
os_log("音频会话被中断", log: logger, type: .debug) |
|||
|
|||
case .ended: |
|||
// 中断结束 |
|||
isInterrupted = false |
|||
|
|||
// 检查是否应该恢复 |
|||
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { |
|||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) |
|||
if options.contains(.shouldResume), let intent = currentIntent { |
|||
// 恢复之前的音频意图 |
|||
begin(intent) |
|||
os_log("音频会话中断结束,已恢复", log: logger, type: .debug) |
|||
} |
|||
} |
|||
|
|||
@unknown default: |
|||
os_log("未知的音频中断类型: %{public}@", log: logger, type: .error, "\(type)") |
|||
} |
|||
} |
|||
|
|||
/// 处理音频路由变化 |
|||
@objc private func handleAudioRouteChange(notification: Notification) { |
|||
guard let userInfo = notification.userInfo, |
|||
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, |
|||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { |
|||
return |
|||
} |
|||
|
|||
switch reason { |
|||
case .newDeviceAvailable: |
|||
os_log("新音频设备已连接", log: logger, type: .info) |
|||
os_log("%{public}@", log: logger, type: .debug, getCurrentAudioRoute()) |
|||
|
|||
// 重新应用当前意图以确保正确路由 |
|||
if let intent = currentIntent { |
|||
begin(intent) |
|||
} |
|||
|
|||
case .oldDeviceUnavailable: |
|||
os_log("音频设备已断开", log: logger, type: .info) |
|||
os_log("%{public}@", log: logger, type: .debug, getCurrentAudioRoute()) |
|||
|
|||
// 重新应用当前意图以确保正确路由 |
|||
if let intent = currentIntent { |
|||
begin(intent) |
|||
} |
|||
|
|||
case .categoryChange: |
|||
os_log("音频类别已变化", log: logger, type: .info) |
|||
|
|||
default: |
|||
break |
|||
} |
|||
} |
|||
|
|||
deinit { |
|||
NotificationCenter.default.removeObserver(self) |
|||
} |
|||
} |
|||
@ -1,259 +0,0 @@ |
|||
import Flutter |
|||
import UIKit |
|||
import open_ai_service |
|||
import chat_storage |
|||
|
|||
/// Swift版Agent Service插件 |
|||
/// 处理Flutter与原生层的通信 |
|||
public class SwiftAgentServicePlugin: NSObject, FlutterPlugin { |
|||
// Flutter方法通道 |
|||
private var methodChannel: FlutterMethodChannel? |
|||
|
|||
// Flutter事件通道 |
|||
private var eventChannel: FlutterEventChannel? |
|||
|
|||
// 事件sink,用于向Flutter发送事件 |
|||
private var eventSink: FlutterEventSink? |
|||
|
|||
// 核心服务实例 |
|||
private var agentService: AgentService? |
|||
|
|||
/// 注册插件 |
|||
public static func register(with registrar: FlutterPluginRegistrar) { |
|||
let instance = SwiftAgentServicePlugin() |
|||
|
|||
// 初始化方法通道 |
|||
let methodChannel = FlutterMethodChannel(name: "com.yunqiinnovation.agent_service", binaryMessenger: registrar.messenger()) |
|||
registrar.addMethodCallDelegate(instance, channel: methodChannel) |
|||
instance.methodChannel = methodChannel |
|||
|
|||
// 初始化事件通道 |
|||
let eventChannel = FlutterEventChannel(name: "com.yunqiinnovation.agent_service/events", binaryMessenger: registrar.messenger()) |
|||
eventChannel.setStreamHandler(instance) |
|||
instance.eventChannel = eventChannel |
|||
} |
|||
|
|||
/// 处理方法调用 |
|||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|||
switch call.method { |
|||
case "initialize": |
|||
handleInitialize(call, result) |
|||
|
|||
case "startConversation": |
|||
handleStartConversation(result) |
|||
|
|||
case "stopConversation": |
|||
handleStopConversation(result) |
|||
|
|||
case "processTextInput": |
|||
handleProcessTextInput(call, result) |
|||
|
|||
case "speakText": |
|||
handleSpeakText(call, result) |
|||
|
|||
case "stopTts": |
|||
handleStopTts(result) |
|||
|
|||
case "clearChatHistory": |
|||
handleClearChatHistory(result) |
|||
|
|||
case "interruptCurrentResponse": |
|||
handleInterruptCurrentResponse(result) |
|||
|
|||
case "processImageInput": |
|||
handleProcessImageInput(call, result) |
|||
|
|||
case "dispose": |
|||
handleDispose(result) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
// MARK: - 方法处理 |
|||
|
|||
/// 初始化 |
|||
private func handleInitialize(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
|||
guard let args = call.arguments as? [String: Any], |
|||
let config = args["config"] as? [String: Any] else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "缺少配置参数", details: nil)) |
|||
return |
|||
} |
|||
|
|||
// 初始化代理服务 |
|||
if agentService == nil { |
|||
agentService = AgentService() |
|||
|
|||
// 设置事件监听器 |
|||
agentService?.setListener(self) |
|||
} |
|||
|
|||
// 执行初始化 |
|||
let success = agentService?.initialize(config: config) ?? false |
|||
result(success) |
|||
} |
|||
|
|||
/// 开始对话 |
|||
private func handleStartConversation(_ result: @escaping FlutterResult) { |
|||
guard let service = agentService else { |
|||
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = service.startRecognition() |
|||
result(success) |
|||
} |
|||
|
|||
/// 停止对话 |
|||
private func handleStopConversation(_ result: @escaping FlutterResult) { |
|||
guard let service = agentService else { |
|||
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) |
|||
return |
|||
} |
|||
|
|||
service.stopRecognition() |
|||
result(true) |
|||
} |
|||
|
|||
/// 处理文本输入 |
|||
private func handleProcessTextInput(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
|||
guard let service = agentService else { |
|||
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) |
|||
return |
|||
} |
|||
|
|||
guard let args = call.arguments as? [String: Any], |
|||
let text = args["text"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "无效的文本参数", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let speakResponse = (args["speakResponse"] as? Bool) ?? false |
|||
let success = service.processTextInput(text: text, speakResponse: speakResponse) |
|||
result(success) |
|||
} |
|||
|
|||
/// 朗读文本 |
|||
private func handleSpeakText(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
|||
guard let service = agentService else { |
|||
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) |
|||
return |
|||
} |
|||
|
|||
guard let args = call.arguments as? [String: Any], |
|||
let text = args["text"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "无效的文本参数", details: nil)) |
|||
return |
|||
} |
|||
|
|||
service.speakText(text: text) |
|||
result(true) |
|||
} |
|||
|
|||
/// 停止语音合成 |
|||
private func handleStopTts(_ result: @escaping FlutterResult) { |
|||
guard let service = agentService else { |
|||
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) |
|||
return |
|||
} |
|||
|
|||
service.stopTts() |
|||
result(true) |
|||
} |
|||
|
|||
/// 清除聊天历史 |
|||
private func handleClearChatHistory(_ result: @escaping FlutterResult) { |
|||
guard let service = agentService else { |
|||
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) |
|||
return |
|||
} |
|||
|
|||
service.clearChatHistory { success in |
|||
result(success) |
|||
} |
|||
} |
|||
|
|||
/// 中断当前响应 |
|||
private func handleInterruptCurrentResponse(_ result: @escaping FlutterResult) { |
|||
guard let service = agentService else { |
|||
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) |
|||
return |
|||
} |
|||
|
|||
service.interruptCurrentResponse() |
|||
result(true) |
|||
} |
|||
|
|||
/// 处理图片输入 |
|||
private func handleProcessImageInput(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
|||
guard let service = agentService else { |
|||
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil)) |
|||
return |
|||
} |
|||
|
|||
guard let args = call.arguments as? [String: Any], |
|||
let imagePath = args["imagePath"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "无效的图片路径参数", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let text = (args["text"] as? String) ?? "" |
|||
let speakResponse = (args["speakResponse"] as? Bool) ?? false |
|||
|
|||
let success = service.processImageInput(imagePath: imagePath, text: text, speakResponse: speakResponse) |
|||
result(success) |
|||
} |
|||
|
|||
/// 释放资源 |
|||
private func handleDispose(_ result: @escaping FlutterResult) { |
|||
guard let service = agentService else { |
|||
result(true) // 已经是nil,直接返回成功 |
|||
return |
|||
} |
|||
|
|||
service.dispose() |
|||
agentService = nil |
|||
|
|||
result(true) |
|||
} |
|||
} |
|||
|
|||
// MARK: - FlutterStreamHandler |
|||
extension SwiftAgentServicePlugin: FlutterStreamHandler { |
|||
public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|||
self.eventSink = events |
|||
return nil |
|||
} |
|||
|
|||
public func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|||
self.eventSink = nil |
|||
return nil |
|||
} |
|||
} |
|||
|
|||
// MARK: - AgentServiceListener |
|||
extension SwiftAgentServicePlugin: AgentServiceListener { |
|||
func onEvent(eventName: String, data: [String: Any]) { |
|||
guard let sink = eventSink else { return } |
|||
|
|||
do { |
|||
// 构建事件JSON |
|||
let eventMap: [String: Any] = [ |
|||
"event": eventName, |
|||
"data": data |
|||
] |
|||
|
|||
// 将事件转换为JSON字符串 |
|||
let jsonData = try JSONSerialization.data(withJSONObject: eventMap, options: []) |
|||
if let jsonString = String(data: jsonData, encoding: .utf8) { |
|||
// 发送到Flutter |
|||
DispatchQueue.main.async { |
|||
sink(jsonString) |
|||
} |
|||
} |
|||
} catch { |
|||
print("事件序列化失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
# |
|||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. |
|||
# Run `pod lib lint agent_service.podspec` to validate before publishing. |
|||
# |
|||
Pod::Spec.new do |s| |
|||
s.name = 'agent_service' |
|||
s.version = '0.0.1' |
|||
s.summary = '智能助手代理服务' |
|||
s.description = <<-DESC |
|||
智能助手代理服务,提供语音识别、语音合成和OpenAI对话功能 |
|||
DESC |
|||
s.homepage = 'http://example.com' |
|||
s.license = { :file => '../LICENSE' } |
|||
s.author = { 'Your Company' => 'email@example.com' } |
|||
s.source = { :path => '.' } |
|||
s.source_files = 'Classes/**/*.swift' |
|||
s.resources = ['Resources/*.mp3'] |
|||
|
|||
s.dependency 'Flutter' |
|||
s.dependency 'azure_speech' |
|||
s.dependency 'ble_service' |
|||
s.dependency 'open_ai_service' |
|||
s.dependency 'chat_storage' |
|||
s.platform = :ios, '13.0' |
|||
|
|||
# Flutter.framework does not contain a i386 slice. Only x86_64 simulators are supported. |
|||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } |
|||
s.swift_version = '5.0' |
|||
end |
|||
@ -1,34 +0,0 @@ |
|||
**/dgph |
|||
*.mode1v3 |
|||
*.mode2v3 |
|||
*.moved-aside |
|||
*.pbxuser |
|||
*.perspectivev3 |
|||
**/*sync/ |
|||
.sconsign.dblite |
|||
.tags* |
|||
**/.vagrant/ |
|||
**/DerivedData/ |
|||
Icon? |
|||
**/Pods/ |
|||
**/.symlinks/ |
|||
profile |
|||
xcuserdata |
|||
**/.generated/ |
|||
Flutter/App.framework |
|||
Flutter/Flutter.framework |
|||
Flutter/Flutter.podspec |
|||
Flutter/Generated.xcconfig |
|||
Flutter/ephemeral/ |
|||
Flutter/app.flx |
|||
Flutter/app.zip |
|||
Flutter/flutter_assets/ |
|||
Flutter/flutter_export_environment.sh |
|||
ServiceDefinitions.json |
|||
Runner/GeneratedPluginRegistrant.* |
|||
|
|||
# Exceptions to above rules. |
|||
!default.mode1v3 |
|||
!default.mode2v3 |
|||
!default.pbxuser |
|||
!default.perspectivev3 |
|||
@ -0,0 +1,17 @@ |
|||
//
|
|||
// AzureSpeechPlugin.h
|
|||
// azure_speech
|
|||
//
|
|||
// Created for azure_speech plugin compatibility.
|
|||
//
|
|||
|
|||
#import <Flutter/Flutter.h> |
|||
|
|||
/**
|
|||
* Azure Speech Plugin 头文件 |
|||
* |
|||
* 注:实际实现使用 Swift,此头文件仅用于 Flutter 框架兼容 |
|||
*/ |
|||
@interface AzureSpeechPlugin : NSObject <FlutterPlugin> |
|||
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar; |
|||
@end |
|||
@ -1,182 +1,422 @@ |
|||
import Flutter |
|||
import UIKit |
|||
import AVFoundation |
|||
import MicrosoftCognitiveServicesSpeech |
|||
// 自定义语音处理组件,提供音频流处理等功能 |
|||
import speech |
|||
import os.log |
|||
|
|||
public class AzureSpeechPlugin: NSObject, FlutterPlugin { |
|||
private var ttsHelper: AzureTtsHelper? |
|||
private var asrHelper: AzureAsrHelper? |
|||
private var eventSink: FlutterEventSink? |
|||
|
|||
public static func register(with registrar: FlutterPluginRegistrar) { |
|||
let channel = FlutterMethodChannel(name: "azure_speech", binaryMessenger: registrar.messenger()) |
|||
let instance = AzureSpeechPlugin() |
|||
registrar.addMethodCallDelegate(instance, channel: channel) |
|||
|
|||
// 初始化事件通道 |
|||
let eventChannel = FlutterEventChannel(name: "azure_speech/asr_events", binaryMessenger: registrar.messenger()) |
|||
eventChannel.setStreamHandler(AsrStreamHandler(instance: instance)) |
|||
} |
|||
|
|||
override init() { |
|||
super.init() |
|||
ttsHelper = AzureTtsHelper() |
|||
asrHelper = AzureAsrHelper() |
|||
|
|||
// 设置ASR事件处理 |
|||
asrHelper?.setEventHandler { [weak self] event in |
|||
self?.handleAsrEvent(event) |
|||
} |
|||
} |
|||
|
|||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|||
switch call.method { |
|||
// TTS相关方法 |
|||
case "initializeTts": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let subscriptionKey = args["subscriptionKey"] as? String, |
|||
let serviceRegion = args["serviceRegion"] as? String else { |
|||
result(false) |
|||
return |
|||
} |
|||
|
|||
let language = args["language"] as? String ?? "zh-CN" |
|||
let success = ttsHelper?.initialize(speechSubscriptionKey: subscriptionKey, serviceRegion: serviceRegion, language: language) ?? false |
|||
result(success) |
|||
|
|||
case "setTtsVoice": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let voiceName = args["voiceName"] as? String else { |
|||
result(false) |
|||
return |
|||
} |
|||
|
|||
let success = ttsHelper?.setVoice(voiceName: voiceName) ?? false |
|||
result(success) |
|||
|
|||
case "setTtsSpeechParams": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let rate = args["rate"] as? Int, |
|||
let pitch = args["pitch"] as? Int, |
|||
let volume = args["volume"] as? Int else { |
|||
result(false) |
|||
return |
|||
} |
|||
|
|||
let success = ttsHelper?.setSpeechParams(rate: rate, pitch: pitch, volume: volume) ?? false |
|||
result(success) |
|||
|
|||
case "speakText": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let text = args["text"] as? String else { |
|||
result(false) |
|||
return |
|||
} |
|||
|
|||
ttsHelper?.speakText(text: text) { success, _ in |
|||
result(success) |
|||
} |
|||
|
|||
case "stopSpeaking": |
|||
let success = ttsHelper?.stopSpeaking() ?? false |
|||
result(success) |
|||
|
|||
case "isSpeaking": |
|||
let speaking = ttsHelper?.isSpeaking() ?? false |
|||
result(speaking) |
|||
|
|||
case "setTtsAudioOutputType": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let outputType = args["outputType"] as? String else { |
|||
result(false) |
|||
return |
|||
} |
|||
|
|||
var type: AzureTtsHelper.AudioOutputType = .auto |
|||
switch outputType.uppercased() { |
|||
case "SPEAKER": |
|||
type = .speaker |
|||
case "EARPIECE": |
|||
type = .earpiece |
|||
default: |
|||
type = .auto |
|||
} |
|||
|
|||
let success = ttsHelper?.setAudioOutputType(outputType: type) ?? false |
|||
result(success) |
|||
|
|||
// ASR相关方法 |
|||
case "initializeAsr": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let subscriptionKey = args["subscriptionKey"] as? String, |
|||
let serviceRegion = args["serviceRegion"] as? String, |
|||
let supportedLanguages = args["supportedLanguages"] as? [String] else { |
|||
result(false) |
|||
return |
|||
} |
|||
|
|||
let success = asrHelper?.initialize(speechSubscriptionKey: subscriptionKey, serviceRegion: serviceRegion, supportedLanguages: supportedLanguages) ?? false |
|||
result(success) |
|||
|
|||
case "recognizeOnce": |
|||
asrHelper?.recognizeOnce { success, text, language, error in |
|||
var resultMap: [String: Any] = ["success": success] |
|||
if success { |
|||
resultMap["text"] = text |
|||
resultMap["language"] = language |
|||
/** |
|||
* Azure Speech Plugin |
|||
* |
|||
* 基于微软Azure语音服务的Flutter插件 |
|||
* 提供语音识别(ASR)和语音合成(TTS)功能 |
|||
*/ |
|||
@objc public class AzureSpeechPlugin: NSObject, FlutterPlugin { |
|||
// 日志标签 |
|||
private let tag = "AzureSpeechPlugin" |
|||
// 日志对象 |
|||
private let log = OSLog(subsystem: "com.azure.speech", category: "AzureSpeechPlugin") |
|||
|
|||
// ASR相关 |
|||
private var asrChannel: FlutterMethodChannel? |
|||
private var asrEventChannel: FlutterEventChannel? |
|||
private var asrEventSink: FlutterEventSink? |
|||
private let azureAsrHelper = AzureAsrHelper() |
|||
|
|||
// TTS相关 |
|||
private var ttsChannel: FlutterMethodChannel? |
|||
private var ttsEventChannel: FlutterEventChannel? |
|||
private var ttsEventSink: FlutterEventSink? |
|||
private let azureTtsHelper = AzureTtsHelper() |
|||
|
|||
// 是否已添加TTS事件监听器 |
|||
private var isTtsListenerAdded = false |
|||
|
|||
// 当前的连续识别回调 |
|||
private var currentAsrCallback: AsrCallbackWrapper? |
|||
|
|||
// 插件注册 |
|||
public static func register(with registrar: FlutterPluginRegistrar) { |
|||
let instance = AzureSpeechPlugin() |
|||
|
|||
// 初始化ASR通道 |
|||
let asrChannel = FlutterMethodChannel(name: "azure_speech/asr", binaryMessenger: registrar.messenger()) |
|||
registrar.addMethodCallDelegate(instance, channel: asrChannel) |
|||
instance.asrChannel = asrChannel |
|||
|
|||
// 初始化TTS通道 |
|||
let ttsChannel = FlutterMethodChannel(name: "azure_speech/tts", binaryMessenger: registrar.messenger()) |
|||
registrar.addMethodCallDelegate(instance, channel: ttsChannel) |
|||
instance.ttsChannel = ttsChannel |
|||
|
|||
// 初始化ASR事件通道 |
|||
let asrEventChannel = FlutterEventChannel(name: "azure_speech/asr_events", binaryMessenger: registrar.messenger()) |
|||
asrEventChannel.setStreamHandler(instance) |
|||
instance.asrEventChannel = asrEventChannel |
|||
|
|||
// 初始化TTS事件通道 |
|||
let ttsEventChannel = FlutterEventChannel(name: "azure_speech/tts_events", binaryMessenger: registrar.messenger()) |
|||
ttsEventChannel.setStreamHandler(instance) |
|||
instance.ttsEventChannel = ttsEventChannel |
|||
} |
|||
|
|||
// 发送ASR事件方法 |
|||
internal func sendAsrEvent(_ event: [String: Any]) { |
|||
if asrEventSink == nil { |
|||
os_log("无法发送ASR事件:事件通道未准备好", log: log, type: .error) |
|||
return |
|||
} |
|||
|
|||
DispatchQueue.main.async { [weak self] in |
|||
guard let self = self else { return } |
|||
self.asrEventSink?(event) |
|||
} |
|||
} |
|||
|
|||
// 发送TTS事件方法 |
|||
private func sendTtsEvent(_ event: [String: Any]) { |
|||
if ttsEventSink == nil { |
|||
os_log("无法发送TTS事件:事件通道未准备好", log: log, type: .error) |
|||
return |
|||
} |
|||
|
|||
DispatchQueue.main.async { [weak self] in |
|||
guard let self = self else { return } |
|||
self.ttsEventSink?(event) |
|||
} |
|||
} |
|||
|
|||
// 设置TTS事件监听器 |
|||
private func setupTtsEventListener() { |
|||
if !isTtsListenerAdded { |
|||
azureTtsHelper.addListener(self) |
|||
isTtsListenerAdded = true |
|||
} |
|||
} |
|||
|
|||
|
|||
|
|||
// 处理Flutter方法调用 |
|||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|||
if call.method.hasPrefix("tts_") { |
|||
handleTtsMethodCall(call, result) |
|||
} else { |
|||
handleAsrMethodCall(call, result) |
|||
} |
|||
} |
|||
|
|||
// MARK: - ASR 方法处理 |
|||
|
|||
private func handleAsrMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
|||
switch call.method { |
|||
case "initialize": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let subscriptionKey = args["subscriptionKey"] as? String, |
|||
let region = args["region"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "必要的参数不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let supportedLanguages = args["supportedLanguages"] as? [String] ?? ["zh-CN"] |
|||
|
|||
// 初始化ASR引擎 |
|||
let success = azureAsrHelper.initialize( |
|||
subscriptionKey: subscriptionKey, |
|||
region: region, |
|||
supportedLanguages: supportedLanguages, |
|||
audioSourceType: .microphone |
|||
) |
|||
|
|||
result(success) |
|||
|
|||
case "recognizeOnce": |
|||
// iOS版本不支持recognizeOnce |
|||
result(FlutterError(code: "NOT_SUPPORTED", message: "iOS版本不支持recognizeOnce", details: nil)) |
|||
|
|||
case "startContinuousRecognition": |
|||
// 确保事件通道已准备好 |
|||
guard asrEventSink != nil else { |
|||
result(FlutterError(code: "EVENT_CHANNEL_NOT_READY", message: "事件通道未准备好,无法开始连续识别", details: nil)) |
|||
return |
|||
} |
|||
|
|||
// 解析音频源类型 |
|||
let audioSourceType: AzureAsrHelper.AudioSourceType |
|||
if let args = call.arguments as? [String: Any], |
|||
let audioSourceString = args["audioSourceType"] as? String { |
|||
switch audioSourceString.lowercased() { |
|||
case "external": |
|||
audioSourceType = .external |
|||
default: |
|||
audioSourceType = .microphone |
|||
} |
|||
} else { |
|||
audioSourceType = .microphone |
|||
} |
|||
|
|||
// 创建回调包装器 |
|||
currentAsrCallback = AsrCallbackWrapper(plugin: self) |
|||
|
|||
let success = azureAsrHelper.startContinuousRecognition( |
|||
callback: currentAsrCallback!, |
|||
audioSourceType: audioSourceType |
|||
) |
|||
result(success) |
|||
|
|||
case "stopContinuousRecognition": |
|||
let success = azureAsrHelper.stopContinuousRecognition() |
|||
currentAsrCallback = nil |
|||
result(success) |
|||
|
|||
case "isContinuousRecognitionActive": |
|||
result(azureAsrHelper.isContinuousRecognitionActive()) |
|||
|
|||
case "dispose": |
|||
azureAsrHelper.dispose() |
|||
currentAsrCallback = nil |
|||
result(true) |
|||
|
|||
case "pushAudioData": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let audioBytes = args["data"] as? FlutterStandardTypedData else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "音频数据不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
azureAsrHelper.pushAudioData(data: audioBytes.data) |
|||
result(true) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
// MARK: - TTS 方法处理 |
|||
|
|||
private func handleTtsMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
|||
switch call.method { |
|||
case "tts_initialize": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let subscriptionKey = args["subscriptionKey"] as? String, |
|||
let region = args["region"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "必要的参数不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
// 初始化TTS引擎 |
|||
let language = args["language"] as? String ?? "zh-CN" |
|||
let success = azureTtsHelper.initialize(ttsAppId: "", ttsAppToken: subscriptionKey, ttsResource: region, language: language) |
|||
|
|||
// 设置TTS事件监听器 |
|||
setupTtsEventListener() |
|||
|
|||
result(success) |
|||
|
|||
case "tts_set_voice": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let voiceName = args["voiceName"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "语音名称不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = azureTtsHelper.setVoice(voiceName) |
|||
result(success) |
|||
|
|||
case "tts_speak_once": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let text = args["text"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = azureTtsHelper.speakOnce(text) |
|||
result(success) |
|||
|
|||
case "tts_speak_stream": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let text = args["text"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = azureTtsHelper.speakStream(text) |
|||
result(success) |
|||
|
|||
case "tts_flush_stream": |
|||
let success = azureTtsHelper.flushStream() |
|||
result(success) |
|||
|
|||
case "tts_stop": |
|||
let success = azureTtsHelper.stop() |
|||
result(success) |
|||
|
|||
case "tts_isSpeaking": |
|||
result(azureTtsHelper.isSpeaking()) |
|||
|
|||
case "tts_release": |
|||
azureTtsHelper.dispose() |
|||
result(true) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// MARK: - ASR回调包装器 |
|||
private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { |
|||
private weak var plugin: AzureSpeechPlugin? |
|||
|
|||
init(plugin: AzureSpeechPlugin) { |
|||
self.plugin = plugin |
|||
} |
|||
|
|||
func onResult(_ text: String, _ detectedLanguage: String) { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "result", |
|||
"text": text, |
|||
"language": detectedLanguage |
|||
]) |
|||
} |
|||
|
|||
func onRecognizing(_ recognizing: String, _ detectedLanguage: String) { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "recognizing", |
|||
"text": recognizing, |
|||
"language": detectedLanguage |
|||
]) |
|||
} |
|||
|
|||
func onSessionStarted() { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "sessionStarted" |
|||
]) |
|||
} |
|||
|
|||
func onSessionStopped() { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "sessionStopped" |
|||
]) |
|||
} |
|||
|
|||
func onCanceled(_ reason: String, _ errorDetails: String) { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "canceled", |
|||
"reason": reason, |
|||
"errorDetails": errorDetails |
|||
]) |
|||
} |
|||
|
|||
func onError(_ error: String) { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "error", |
|||
"error": error |
|||
]) |
|||
} |
|||
} |
|||
|
|||
// MARK: - 事件处理 |
|||
extension AzureSpeechPlugin: FlutterStreamHandler { |
|||
public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|||
// 根据通道类型设置事件接收器 |
|||
if let args = arguments as? [String: Any], |
|||
let channel = args["channel"] as? String { |
|||
|
|||
if channel == "asr" { |
|||
asrEventSink = events |
|||
} else if channel == "tts" { |
|||
ttsEventSink = events |
|||
setupTtsEventListener() |
|||
} |
|||
} else if arguments == nil { |
|||
// 如果没有指定通道,尝试弄清楚是哪个通道在监听 |
|||
if asrEventSink == nil && ttsEventSink != nil { |
|||
asrEventSink = events |
|||
} else if asrEventSink != nil && ttsEventSink == nil { |
|||
ttsEventSink = events |
|||
setupTtsEventListener() |
|||
} else { |
|||
// 无法确定哪个通道,默认设置为ASR |
|||
asrEventSink = events |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
public func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|||
// 根据通道类型清除事件接收器 |
|||
if let args = arguments as? [String: Any], |
|||
let channel = args["channel"] as? String { |
|||
|
|||
if channel == "asr" { |
|||
asrEventSink = nil |
|||
} else if channel == "tts" { |
|||
ttsEventSink = nil |
|||
} |
|||
} else { |
|||
resultMap["error"] = error |
|||
// 如果没有指定通道,清除所有通道 |
|||
asrEventSink = nil |
|||
ttsEventSink = nil |
|||
} |
|||
result(resultMap) |
|||
} |
|||
|
|||
case "startContinuousRecognition": |
|||
let success = asrHelper?.startContinuousRecognition() ?? false |
|||
result(success) |
|||
|
|||
case "stopContinuousRecognition": |
|||
let success = asrHelper?.stopContinuousRecognition() ?? false |
|||
result(success) |
|||
|
|||
case "isContinuousRecognitionActive": |
|||
let isActive = asrHelper?.isContinuousRecognitionActive() ?? false |
|||
result(isActive) |
|||
|
|||
case "dispose": |
|||
ttsHelper?.dispose() |
|||
asrHelper?.dispose() |
|||
result(nil) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
// 设置事件接收器 |
|||
func setEventSink(_ sink: FlutterEventSink?) { |
|||
self.eventSink = sink |
|||
} |
|||
|
|||
// 处理ASR事件 |
|||
private func handleAsrEvent(_ event: [String: Any]) { |
|||
self.eventSink?(event) |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
} |
|||
|
|||
// ASR事件流处理器 |
|||
class AsrStreamHandler: NSObject, FlutterStreamHandler { |
|||
private weak var plugin: AzureSpeechPlugin? |
|||
|
|||
init(instance: AzureSpeechPlugin) { |
|||
self.plugin = instance |
|||
super.init() |
|||
} |
|||
|
|||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|||
plugin?.setEventSink(events) |
|||
return nil |
|||
} |
|||
|
|||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|||
plugin?.setEventSink(nil) |
|||
return nil |
|||
} |
|||
|
|||
|
|||
// MARK: - TTS 事件监听实现 |
|||
extension AzureSpeechPlugin: TtsEventListener { |
|||
// 使用@objc特性为方法提供一个不同的Objective-C选择器名称 |
|||
@objc(onTtsEvent:) |
|||
public func onEvent(_ event: TtsEvent) { |
|||
var eventMap: [String: Any] = [:] |
|||
|
|||
// 根据事件类型转换 |
|||
switch event.type { |
|||
case .synthesisStarted: |
|||
eventMap["type"] = "synthesis_started" |
|||
case .synthesisCompleted: |
|||
eventMap["type"] = "synthesis_completed" |
|||
case .synthesisCanceled: |
|||
eventMap["type"] = "synthesis_canceled" |
|||
if let reason = event.params["reason"] { |
|||
eventMap["reason"] = reason |
|||
} |
|||
if let errorDetails = event.params["errorDetails"] { |
|||
eventMap["errorDetails"] = errorDetails |
|||
} |
|||
case .error: |
|||
eventMap["type"] = "error" |
|||
if let errorCode = event.params["errorCode"] { |
|||
eventMap["errorCode"] = errorCode |
|||
} |
|||
if let errorMessage = event.params["errorMessage"] { |
|||
eventMap["errorMessage"] = errorMessage |
|||
} |
|||
@unknown default: |
|||
eventMap["type"] = "unknown" |
|||
for (key, value) in event.params { |
|||
eventMap[key] = value |
|||
} |
|||
} |
|||
|
|||
sendTtsEvent(eventMap) |
|||
} |
|||
} |
|||
|
|||
// MARK: - 音频数据监听实现 |
|||
extension AzureSpeechPlugin: AudioDataListener { |
|||
public func onAudioData(_ data: Data) { |
|||
if ttsEventSink == nil { return } |
|||
|
|||
let eventMap: [String: Any] = [ |
|||
"type": "audio_data", |
|||
"data": FlutterStandardTypedData(bytes: data) |
|||
] |
|||
|
|||
sendTtsEvent(eventMap) |
|||
} |
|||
} |
|||
@ -1,26 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>CFBundleDevelopmentRegion</key> |
|||
<string>en</string> |
|||
<key>CFBundleExecutable</key> |
|||
<string>App</string> |
|||
<key>CFBundleIdentifier</key> |
|||
<string>io.flutter.flutter.app</string> |
|||
<key>CFBundleInfoDictionaryVersion</key> |
|||
<string>6.0</string> |
|||
<key>CFBundleName</key> |
|||
<string>App</string> |
|||
<key>CFBundlePackageType</key> |
|||
<string>FMWK</string> |
|||
<key>CFBundleShortVersionString</key> |
|||
<string>1.0</string> |
|||
<key>CFBundleSignature</key> |
|||
<string>????</string> |
|||
<key>CFBundleVersion</key> |
|||
<string>1.0</string> |
|||
<key>MinimumOSVersion</key> |
|||
<string>12.0</string> |
|||
</dict> |
|||
</plist> |
|||
@ -1 +0,0 @@ |
|||
#include "Generated.xcconfig" |
|||
@ -1 +0,0 @@ |
|||
#include "Generated.xcconfig" |
|||
@ -1,619 +0,0 @@ |
|||
// !$*UTF8*$! |
|||
{ |
|||
archiveVersion = 1; |
|||
classes = { |
|||
}; |
|||
objectVersion = 54; |
|||
objects = { |
|||
|
|||
/* Begin PBXBuildFile section */ |
|||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; |
|||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; |
|||
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 */; }; |
|||
/* End PBXBuildFile section */ |
|||
|
|||
/* Begin PBXContainerItemProxy section */ |
|||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { |
|||
isa = PBXContainerItemProxy; |
|||
containerPortal = 97C146E61CF9000F007C117D /* Project object */; |
|||
proxyType = 1; |
|||
remoteGlobalIDString = 97C146ED1CF9000F007C117D; |
|||
remoteInfo = Runner; |
|||
}; |
|||
/* End PBXContainerItemProxy section */ |
|||
|
|||
/* Begin PBXCopyFilesBuildPhase section */ |
|||
9705A1C41CF9048500538489 /* Embed Frameworks */ = { |
|||
isa = PBXCopyFilesBuildPhase; |
|||
buildActionMask = 2147483647; |
|||
dstPath = ""; |
|||
dstSubfolderSpec = 10; |
|||
files = ( |
|||
); |
|||
name = "Embed Frameworks"; |
|||
runOnlyForDeploymentPostprocessing = 0; |
|||
}; |
|||
/* End PBXCopyFilesBuildPhase section */ |
|||
|
|||
/* Begin PBXFileReference section */ |
|||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; |
|||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; |
|||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; |
|||
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 = "<group>"; }; |
|||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; |
|||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; |
|||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; |
|||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; |
|||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; |
|||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; |
|||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; |
|||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; |
|||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; |
|||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; |
|||
/* End PBXFileReference section */ |
|||
|
|||
/* Begin PBXFrameworksBuildPhase section */ |
|||
97C146EB1CF9000F007C117D /* Frameworks */ = { |
|||
isa = PBXFrameworksBuildPhase; |
|||
buildActionMask = 2147483647; |
|||
files = ( |
|||
); |
|||
runOnlyForDeploymentPostprocessing = 0; |
|||
}; |
|||
/* End PBXFrameworksBuildPhase section */ |
|||
|
|||
/* Begin PBXGroup section */ |
|||
331C8082294A63A400263BE5 /* RunnerTests */ = { |
|||
isa = PBXGroup; |
|||
children = ( |
|||
331C807B294A618700263BE5 /* RunnerTests.swift */, |
|||
); |
|||
path = RunnerTests; |
|||
sourceTree = "<group>"; |
|||
}; |
|||
9740EEB11CF90186004384FC /* Flutter */ = { |
|||
isa = PBXGroup; |
|||
children = ( |
|||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, |
|||
9740EEB21CF90195004384FC /* Debug.xcconfig */, |
|||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, |
|||
9740EEB31CF90195004384FC /* Generated.xcconfig */, |
|||
); |
|||
name = Flutter; |
|||
sourceTree = "<group>"; |
|||
}; |
|||
97C146E51CF9000F007C117D = { |
|||
isa = PBXGroup; |
|||
children = ( |
|||
9740EEB11CF90186004384FC /* Flutter */, |
|||
97C146F01CF9000F007C117D /* Runner */, |
|||
97C146EF1CF9000F007C117D /* Products */, |
|||
331C8082294A63A400263BE5 /* RunnerTests */, |
|||
); |
|||
sourceTree = "<group>"; |
|||
}; |
|||
97C146EF1CF9000F007C117D /* Products */ = { |
|||
isa = PBXGroup; |
|||
children = ( |
|||
97C146EE1CF9000F007C117D /* Runner.app */, |
|||
331C8081294A63A400263BE5 /* RunnerTests.xctest */, |
|||
); |
|||
name = Products; |
|||
sourceTree = "<group>"; |
|||
}; |
|||
97C146F01CF9000F007C117D /* Runner */ = { |
|||
isa = PBXGroup; |
|||
children = ( |
|||
97C146FA1CF9000F007C117D /* Main.storyboard */, |
|||
97C146FD1CF9000F007C117D /* Assets.xcassets */, |
|||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, |
|||
97C147021CF9000F007C117D /* Info.plist */, |
|||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, |
|||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, |
|||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, |
|||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, |
|||
); |
|||
path = Runner; |
|||
sourceTree = "<group>"; |
|||
}; |
|||
/* End PBXGroup section */ |
|||
|
|||
/* Begin PBXNativeTarget section */ |
|||
331C8080294A63A400263BE5 /* RunnerTests */ = { |
|||
isa = PBXNativeTarget; |
|||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; |
|||
buildPhases = ( |
|||
331C807D294A63A400263BE5 /* Sources */, |
|||
331C807F294A63A400263BE5 /* Resources */, |
|||
); |
|||
buildRules = ( |
|||
); |
|||
dependencies = ( |
|||
331C8086294A63A400263BE5 /* PBXTargetDependency */, |
|||
); |
|||
name = RunnerTests; |
|||
productName = RunnerTests; |
|||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; |
|||
productType = "com.apple.product-type.bundle.unit-test"; |
|||
}; |
|||
97C146ED1CF9000F007C117D /* Runner */ = { |
|||
isa = PBXNativeTarget; |
|||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; |
|||
buildPhases = ( |
|||
9740EEB61CF901F6004384FC /* Run Script */, |
|||
97C146EA1CF9000F007C117D /* Sources */, |
|||
97C146EB1CF9000F007C117D /* Frameworks */, |
|||
97C146EC1CF9000F007C117D /* Resources */, |
|||
9705A1C41CF9048500538489 /* Embed Frameworks */, |
|||
3B06AD1E1E4923F5004D2608 /* Thin Binary */, |
|||
); |
|||
buildRules = ( |
|||
); |
|||
dependencies = ( |
|||
); |
|||
name = Runner; |
|||
productName = Runner; |
|||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */; |
|||
productType = "com.apple.product-type.application"; |
|||
}; |
|||
/* End PBXNativeTarget section */ |
|||
|
|||
/* Begin PBXProject section */ |
|||
97C146E61CF9000F007C117D /* Project object */ = { |
|||
isa = PBXProject; |
|||
attributes = { |
|||
BuildIndependentTargetsInParallel = YES; |
|||
LastUpgradeCheck = 1510; |
|||
ORGANIZATIONNAME = ""; |
|||
TargetAttributes = { |
|||
331C8080294A63A400263BE5 = { |
|||
CreatedOnToolsVersion = 14.0; |
|||
TestTargetID = 97C146ED1CF9000F007C117D; |
|||
}; |
|||
97C146ED1CF9000F007C117D = { |
|||
CreatedOnToolsVersion = 7.3.1; |
|||
LastSwiftMigration = 1100; |
|||
}; |
|||
}; |
|||
}; |
|||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; |
|||
compatibilityVersion = "Xcode 9.3"; |
|||
developmentRegion = en; |
|||
hasScannedForEncodings = 0; |
|||
knownRegions = ( |
|||
en, |
|||
Base, |
|||
); |
|||
mainGroup = 97C146E51CF9000F007C117D; |
|||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; |
|||
projectDirPath = ""; |
|||
projectRoot = ""; |
|||
targets = ( |
|||
97C146ED1CF9000F007C117D /* Runner */, |
|||
331C8080294A63A400263BE5 /* RunnerTests */, |
|||
); |
|||
}; |
|||
/* End PBXProject section */ |
|||
|
|||
/* Begin PBXResourcesBuildPhase section */ |
|||
331C807F294A63A400263BE5 /* Resources */ = { |
|||
isa = PBXResourcesBuildPhase; |
|||
buildActionMask = 2147483647; |
|||
files = ( |
|||
); |
|||
runOnlyForDeploymentPostprocessing = 0; |
|||
}; |
|||
97C146EC1CF9000F007C117D /* Resources */ = { |
|||
isa = PBXResourcesBuildPhase; |
|||
buildActionMask = 2147483647; |
|||
files = ( |
|||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, |
|||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, |
|||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, |
|||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, |
|||
); |
|||
runOnlyForDeploymentPostprocessing = 0; |
|||
}; |
|||
/* 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"; |
|||
}; |
|||
9740EEB61CF901F6004384FC /* Run Script */ = { |
|||
isa = PBXShellScriptBuildPhase; |
|||
alwaysOutOfDate = 1; |
|||
buildActionMask = 2147483647; |
|||
files = ( |
|||
); |
|||
inputPaths = ( |
|||
); |
|||
name = "Run Script"; |
|||
outputPaths = ( |
|||
); |
|||
runOnlyForDeploymentPostprocessing = 0; |
|||
shellPath = /bin/sh; |
|||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; |
|||
}; |
|||
/* End PBXShellScriptBuildPhase section */ |
|||
|
|||
/* Begin PBXSourcesBuildPhase section */ |
|||
331C807D294A63A400263BE5 /* Sources */ = { |
|||
isa = PBXSourcesBuildPhase; |
|||
buildActionMask = 2147483647; |
|||
files = ( |
|||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, |
|||
); |
|||
runOnlyForDeploymentPostprocessing = 0; |
|||
}; |
|||
97C146EA1CF9000F007C117D /* Sources */ = { |
|||
isa = PBXSourcesBuildPhase; |
|||
buildActionMask = 2147483647; |
|||
files = ( |
|||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, |
|||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, |
|||
); |
|||
runOnlyForDeploymentPostprocessing = 0; |
|||
}; |
|||
/* End PBXSourcesBuildPhase section */ |
|||
|
|||
/* Begin PBXTargetDependency section */ |
|||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = { |
|||
isa = PBXTargetDependency; |
|||
target = 97C146ED1CF9000F007C117D /* Runner */; |
|||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; |
|||
}; |
|||
/* End PBXTargetDependency section */ |
|||
|
|||
/* Begin PBXVariantGroup section */ |
|||
97C146FA1CF9000F007C117D /* Main.storyboard */ = { |
|||
isa = PBXVariantGroup; |
|||
children = ( |
|||
97C146FB1CF9000F007C117D /* Base */, |
|||
); |
|||
name = Main.storyboard; |
|||
sourceTree = "<group>"; |
|||
}; |
|||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { |
|||
isa = PBXVariantGroup; |
|||
children = ( |
|||
97C147001CF9000F007C117D /* Base */, |
|||
); |
|||
name = LaunchScreen.storyboard; |
|||
sourceTree = "<group>"; |
|||
}; |
|||
/* End PBXVariantGroup section */ |
|||
|
|||
/* Begin XCBuildConfiguration section */ |
|||
249021D3217E4FDB00AE95B9 /* Profile */ = { |
|||
isa = XCBuildConfiguration; |
|||
buildSettings = { |
|||
ALWAYS_SEARCH_USER_PATHS = NO; |
|||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; |
|||
CLANG_ANALYZER_NONNULL = YES; |
|||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; |
|||
CLANG_CXX_LIBRARY = "libc++"; |
|||
CLANG_ENABLE_MODULES = YES; |
|||
CLANG_ENABLE_OBJC_ARC = YES; |
|||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; |
|||
CLANG_WARN_BOOL_CONVERSION = YES; |
|||
CLANG_WARN_COMMA = YES; |
|||
CLANG_WARN_CONSTANT_CONVERSION = YES; |
|||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; |
|||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; |
|||
CLANG_WARN_EMPTY_BODY = YES; |
|||
CLANG_WARN_ENUM_CONVERSION = YES; |
|||
CLANG_WARN_INFINITE_RECURSION = YES; |
|||
CLANG_WARN_INT_CONVERSION = YES; |
|||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; |
|||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; |
|||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; |
|||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; |
|||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; |
|||
CLANG_WARN_STRICT_PROTOTYPES = YES; |
|||
CLANG_WARN_SUSPICIOUS_MOVE = YES; |
|||
CLANG_WARN_UNREACHABLE_CODE = YES; |
|||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; |
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; |
|||
COPY_PHASE_STRIP = NO; |
|||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; |
|||
ENABLE_NS_ASSERTIONS = NO; |
|||
ENABLE_STRICT_OBJC_MSGSEND = YES; |
|||
ENABLE_USER_SCRIPT_SANDBOXING = NO; |
|||
GCC_C_LANGUAGE_STANDARD = gnu99; |
|||
GCC_NO_COMMON_BLOCKS = YES; |
|||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; |
|||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; |
|||
GCC_WARN_UNDECLARED_SELECTOR = YES; |
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; |
|||
GCC_WARN_UNUSED_FUNCTION = YES; |
|||
GCC_WARN_UNUSED_VARIABLE = YES; |
|||
IPHONEOS_DEPLOYMENT_TARGET = 12.0; |
|||
MTL_ENABLE_DEBUG_INFO = NO; |
|||
SDKROOT = iphoneos; |
|||
SUPPORTED_PLATFORMS = iphoneos; |
|||
TARGETED_DEVICE_FAMILY = "1,2"; |
|||
VALIDATE_PRODUCT = YES; |
|||
}; |
|||
name = Profile; |
|||
}; |
|||
249021D4217E4FDB00AE95B9 /* Profile */ = { |
|||
isa = XCBuildConfiguration; |
|||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; |
|||
buildSettings = { |
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
|||
CLANG_ENABLE_MODULES = YES; |
|||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; |
|||
DEVELOPMENT_TEAM = PNNU77KTCP; |
|||
ENABLE_BITCODE = NO; |
|||
INFOPLIST_FILE = Runner/Info.plist; |
|||
LD_RUNPATH_SEARCH_PATHS = ( |
|||
"$(inherited)", |
|||
"@executable_path/Frameworks", |
|||
); |
|||
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech; |
|||
PRODUCT_NAME = "$(TARGET_NAME)"; |
|||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; |
|||
SWIFT_VERSION = 5.0; |
|||
VERSIONING_SYSTEM = "apple-generic"; |
|||
}; |
|||
name = Profile; |
|||
}; |
|||
331C8088294A63A400263BE5 /* Debug */ = { |
|||
isa = XCBuildConfiguration; |
|||
buildSettings = { |
|||
BUNDLE_LOADER = "$(TEST_HOST)"; |
|||
CODE_SIGN_STYLE = Automatic; |
|||
CURRENT_PROJECT_VERSION = 1; |
|||
GENERATE_INFOPLIST_FILE = YES; |
|||
MARKETING_VERSION = 1.0; |
|||
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech.RunnerTests; |
|||
PRODUCT_NAME = "$(TARGET_NAME)"; |
|||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; |
|||
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; |
|||
SWIFT_VERSION = 5.0; |
|||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; |
|||
}; |
|||
name = Debug; |
|||
}; |
|||
331C8089294A63A400263BE5 /* Release */ = { |
|||
isa = XCBuildConfiguration; |
|||
buildSettings = { |
|||
BUNDLE_LOADER = "$(TEST_HOST)"; |
|||
CODE_SIGN_STYLE = Automatic; |
|||
CURRENT_PROJECT_VERSION = 1; |
|||
GENERATE_INFOPLIST_FILE = YES; |
|||
MARKETING_VERSION = 1.0; |
|||
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech.RunnerTests; |
|||
PRODUCT_NAME = "$(TARGET_NAME)"; |
|||
SWIFT_VERSION = 5.0; |
|||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; |
|||
}; |
|||
name = Release; |
|||
}; |
|||
331C808A294A63A400263BE5 /* Profile */ = { |
|||
isa = XCBuildConfiguration; |
|||
buildSettings = { |
|||
BUNDLE_LOADER = "$(TEST_HOST)"; |
|||
CODE_SIGN_STYLE = Automatic; |
|||
CURRENT_PROJECT_VERSION = 1; |
|||
GENERATE_INFOPLIST_FILE = YES; |
|||
MARKETING_VERSION = 1.0; |
|||
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech.RunnerTests; |
|||
PRODUCT_NAME = "$(TARGET_NAME)"; |
|||
SWIFT_VERSION = 5.0; |
|||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; |
|||
}; |
|||
name = Profile; |
|||
}; |
|||
97C147031CF9000F007C117D /* Debug */ = { |
|||
isa = XCBuildConfiguration; |
|||
buildSettings = { |
|||
ALWAYS_SEARCH_USER_PATHS = NO; |
|||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; |
|||
CLANG_ANALYZER_NONNULL = YES; |
|||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; |
|||
CLANG_CXX_LIBRARY = "libc++"; |
|||
CLANG_ENABLE_MODULES = YES; |
|||
CLANG_ENABLE_OBJC_ARC = YES; |
|||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; |
|||
CLANG_WARN_BOOL_CONVERSION = YES; |
|||
CLANG_WARN_COMMA = YES; |
|||
CLANG_WARN_CONSTANT_CONVERSION = YES; |
|||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; |
|||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; |
|||
CLANG_WARN_EMPTY_BODY = YES; |
|||
CLANG_WARN_ENUM_CONVERSION = YES; |
|||
CLANG_WARN_INFINITE_RECURSION = YES; |
|||
CLANG_WARN_INT_CONVERSION = YES; |
|||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; |
|||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; |
|||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; |
|||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; |
|||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; |
|||
CLANG_WARN_STRICT_PROTOTYPES = YES; |
|||
CLANG_WARN_SUSPICIOUS_MOVE = YES; |
|||
CLANG_WARN_UNREACHABLE_CODE = YES; |
|||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; |
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; |
|||
COPY_PHASE_STRIP = NO; |
|||
DEBUG_INFORMATION_FORMAT = dwarf; |
|||
ENABLE_STRICT_OBJC_MSGSEND = YES; |
|||
ENABLE_TESTABILITY = YES; |
|||
ENABLE_USER_SCRIPT_SANDBOXING = NO; |
|||
GCC_C_LANGUAGE_STANDARD = gnu99; |
|||
GCC_DYNAMIC_NO_PIC = NO; |
|||
GCC_NO_COMMON_BLOCKS = YES; |
|||
GCC_OPTIMIZATION_LEVEL = 0; |
|||
GCC_PREPROCESSOR_DEFINITIONS = ( |
|||
"DEBUG=1", |
|||
"$(inherited)", |
|||
); |
|||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; |
|||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; |
|||
GCC_WARN_UNDECLARED_SELECTOR = YES; |
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; |
|||
GCC_WARN_UNUSED_FUNCTION = YES; |
|||
GCC_WARN_UNUSED_VARIABLE = YES; |
|||
IPHONEOS_DEPLOYMENT_TARGET = 12.0; |
|||
MTL_ENABLE_DEBUG_INFO = YES; |
|||
ONLY_ACTIVE_ARCH = YES; |
|||
SDKROOT = iphoneos; |
|||
TARGETED_DEVICE_FAMILY = "1,2"; |
|||
}; |
|||
name = Debug; |
|||
}; |
|||
97C147041CF9000F007C117D /* Release */ = { |
|||
isa = XCBuildConfiguration; |
|||
buildSettings = { |
|||
ALWAYS_SEARCH_USER_PATHS = NO; |
|||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; |
|||
CLANG_ANALYZER_NONNULL = YES; |
|||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; |
|||
CLANG_CXX_LIBRARY = "libc++"; |
|||
CLANG_ENABLE_MODULES = YES; |
|||
CLANG_ENABLE_OBJC_ARC = YES; |
|||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; |
|||
CLANG_WARN_BOOL_CONVERSION = YES; |
|||
CLANG_WARN_COMMA = YES; |
|||
CLANG_WARN_CONSTANT_CONVERSION = YES; |
|||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; |
|||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; |
|||
CLANG_WARN_EMPTY_BODY = YES; |
|||
CLANG_WARN_ENUM_CONVERSION = YES; |
|||
CLANG_WARN_INFINITE_RECURSION = YES; |
|||
CLANG_WARN_INT_CONVERSION = YES; |
|||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; |
|||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; |
|||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; |
|||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; |
|||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; |
|||
CLANG_WARN_STRICT_PROTOTYPES = YES; |
|||
CLANG_WARN_SUSPICIOUS_MOVE = YES; |
|||
CLANG_WARN_UNREACHABLE_CODE = YES; |
|||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; |
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; |
|||
COPY_PHASE_STRIP = NO; |
|||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; |
|||
ENABLE_NS_ASSERTIONS = NO; |
|||
ENABLE_STRICT_OBJC_MSGSEND = YES; |
|||
ENABLE_USER_SCRIPT_SANDBOXING = NO; |
|||
GCC_C_LANGUAGE_STANDARD = gnu99; |
|||
GCC_NO_COMMON_BLOCKS = YES; |
|||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; |
|||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; |
|||
GCC_WARN_UNDECLARED_SELECTOR = YES; |
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; |
|||
GCC_WARN_UNUSED_FUNCTION = YES; |
|||
GCC_WARN_UNUSED_VARIABLE = YES; |
|||
IPHONEOS_DEPLOYMENT_TARGET = 12.0; |
|||
MTL_ENABLE_DEBUG_INFO = NO; |
|||
SDKROOT = iphoneos; |
|||
SUPPORTED_PLATFORMS = iphoneos; |
|||
SWIFT_COMPILATION_MODE = wholemodule; |
|||
SWIFT_OPTIMIZATION_LEVEL = "-O"; |
|||
TARGETED_DEVICE_FAMILY = "1,2"; |
|||
VALIDATE_PRODUCT = YES; |
|||
}; |
|||
name = Release; |
|||
}; |
|||
97C147061CF9000F007C117D /* Debug */ = { |
|||
isa = XCBuildConfiguration; |
|||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; |
|||
buildSettings = { |
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
|||
CLANG_ENABLE_MODULES = YES; |
|||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; |
|||
DEVELOPMENT_TEAM = PNNU77KTCP; |
|||
ENABLE_BITCODE = NO; |
|||
INFOPLIST_FILE = Runner/Info.plist; |
|||
LD_RUNPATH_SEARCH_PATHS = ( |
|||
"$(inherited)", |
|||
"@executable_path/Frameworks", |
|||
); |
|||
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech; |
|||
PRODUCT_NAME = "$(TARGET_NAME)"; |
|||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; |
|||
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; |
|||
SWIFT_VERSION = 5.0; |
|||
VERSIONING_SYSTEM = "apple-generic"; |
|||
}; |
|||
name = Debug; |
|||
}; |
|||
97C147071CF9000F007C117D /* Release */ = { |
|||
isa = XCBuildConfiguration; |
|||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; |
|||
buildSettings = { |
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
|||
CLANG_ENABLE_MODULES = YES; |
|||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; |
|||
DEVELOPMENT_TEAM = PNNU77KTCP; |
|||
ENABLE_BITCODE = NO; |
|||
INFOPLIST_FILE = Runner/Info.plist; |
|||
LD_RUNPATH_SEARCH_PATHS = ( |
|||
"$(inherited)", |
|||
"@executable_path/Frameworks", |
|||
); |
|||
PRODUCT_BUNDLE_IDENTIFIER = com.example.azureSpeech; |
|||
PRODUCT_NAME = "$(TARGET_NAME)"; |
|||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; |
|||
SWIFT_VERSION = 5.0; |
|||
VERSIONING_SYSTEM = "apple-generic"; |
|||
}; |
|||
name = Release; |
|||
}; |
|||
/* End XCBuildConfiguration section */ |
|||
|
|||
/* Begin XCConfigurationList section */ |
|||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { |
|||
isa = XCConfigurationList; |
|||
buildConfigurations = ( |
|||
331C8088294A63A400263BE5 /* Debug */, |
|||
331C8089294A63A400263BE5 /* Release */, |
|||
331C808A294A63A400263BE5 /* Profile */, |
|||
); |
|||
defaultConfigurationIsVisible = 0; |
|||
defaultConfigurationName = Release; |
|||
}; |
|||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { |
|||
isa = XCConfigurationList; |
|||
buildConfigurations = ( |
|||
97C147031CF9000F007C117D /* Debug */, |
|||
97C147041CF9000F007C117D /* Release */, |
|||
249021D3217E4FDB00AE95B9 /* Profile */, |
|||
); |
|||
defaultConfigurationIsVisible = 0; |
|||
defaultConfigurationName = Release; |
|||
}; |
|||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { |
|||
isa = XCConfigurationList; |
|||
buildConfigurations = ( |
|||
97C147061CF9000F007C117D /* Debug */, |
|||
97C147071CF9000F007C117D /* Release */, |
|||
249021D4217E4FDB00AE95B9 /* Profile */, |
|||
); |
|||
defaultConfigurationIsVisible = 0; |
|||
defaultConfigurationName = Release; |
|||
}; |
|||
/* End XCConfigurationList section */ |
|||
}; |
|||
rootObject = 97C146E61CF9000F007C117D /* Project object */; |
|||
} |
|||
@ -1,7 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<Workspace |
|||
version = "1.0"> |
|||
<FileRef |
|||
location = "self:"> |
|||
</FileRef> |
|||
</Workspace> |
|||
@ -1,8 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>IDEDidComputeMac32BitWarning</key> |
|||
<true/> |
|||
</dict> |
|||
</plist> |
|||
@ -1,8 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>PreviewsEnabled</key> |
|||
<false/> |
|||
</dict> |
|||
</plist> |
|||
@ -1,7 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<Workspace |
|||
version = "1.0"> |
|||
<FileRef |
|||
location = "group:Runner.xcodeproj"> |
|||
</FileRef> |
|||
</Workspace> |
|||
@ -1,8 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>IDEDidComputeMac32BitWarning</key> |
|||
<true/> |
|||
</dict> |
|||
</plist> |
|||
@ -1,8 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>PreviewsEnabled</key> |
|||
<false/> |
|||
</dict> |
|||
</plist> |
|||
@ -1,13 +0,0 @@ |
|||
import Flutter |
|||
import UIKit |
|||
|
|||
@main |
|||
@objc class AppDelegate: FlutterAppDelegate { |
|||
override func application( |
|||
_ application: UIApplication, |
|||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? |
|||
) -> Bool { |
|||
GeneratedPluginRegistrant.register(with: self) |
|||
return super.application(application, didFinishLaunchingWithOptions: launchOptions) |
|||
} |
|||
} |
|||
@ -1,122 +0,0 @@ |
|||
{ |
|||
"images" : [ |
|||
{ |
|||
"size" : "20x20", |
|||
"idiom" : "iphone", |
|||
"filename" : "Icon-App-20x20@2x.png", |
|||
"scale" : "2x" |
|||
}, |
|||
{ |
|||
"size" : "20x20", |
|||
"idiom" : "iphone", |
|||
"filename" : "Icon-App-20x20@3x.png", |
|||
"scale" : "3x" |
|||
}, |
|||
{ |
|||
"size" : "29x29", |
|||
"idiom" : "iphone", |
|||
"filename" : "Icon-App-29x29@1x.png", |
|||
"scale" : "1x" |
|||
}, |
|||
{ |
|||
"size" : "29x29", |
|||
"idiom" : "iphone", |
|||
"filename" : "Icon-App-29x29@2x.png", |
|||
"scale" : "2x" |
|||
}, |
|||
{ |
|||
"size" : "29x29", |
|||
"idiom" : "iphone", |
|||
"filename" : "Icon-App-29x29@3x.png", |
|||
"scale" : "3x" |
|||
}, |
|||
{ |
|||
"size" : "40x40", |
|||
"idiom" : "iphone", |
|||
"filename" : "Icon-App-40x40@2x.png", |
|||
"scale" : "2x" |
|||
}, |
|||
{ |
|||
"size" : "40x40", |
|||
"idiom" : "iphone", |
|||
"filename" : "Icon-App-40x40@3x.png", |
|||
"scale" : "3x" |
|||
}, |
|||
{ |
|||
"size" : "60x60", |
|||
"idiom" : "iphone", |
|||
"filename" : "Icon-App-60x60@2x.png", |
|||
"scale" : "2x" |
|||
}, |
|||
{ |
|||
"size" : "60x60", |
|||
"idiom" : "iphone", |
|||
"filename" : "Icon-App-60x60@3x.png", |
|||
"scale" : "3x" |
|||
}, |
|||
{ |
|||
"size" : "20x20", |
|||
"idiom" : "ipad", |
|||
"filename" : "Icon-App-20x20@1x.png", |
|||
"scale" : "1x" |
|||
}, |
|||
{ |
|||
"size" : "20x20", |
|||
"idiom" : "ipad", |
|||
"filename" : "Icon-App-20x20@2x.png", |
|||
"scale" : "2x" |
|||
}, |
|||
{ |
|||
"size" : "29x29", |
|||
"idiom" : "ipad", |
|||
"filename" : "Icon-App-29x29@1x.png", |
|||
"scale" : "1x" |
|||
}, |
|||
{ |
|||
"size" : "29x29", |
|||
"idiom" : "ipad", |
|||
"filename" : "Icon-App-29x29@2x.png", |
|||
"scale" : "2x" |
|||
}, |
|||
{ |
|||
"size" : "40x40", |
|||
"idiom" : "ipad", |
|||
"filename" : "Icon-App-40x40@1x.png", |
|||
"scale" : "1x" |
|||
}, |
|||
{ |
|||
"size" : "40x40", |
|||
"idiom" : "ipad", |
|||
"filename" : "Icon-App-40x40@2x.png", |
|||
"scale" : "2x" |
|||
}, |
|||
{ |
|||
"size" : "76x76", |
|||
"idiom" : "ipad", |
|||
"filename" : "Icon-App-76x76@1x.png", |
|||
"scale" : "1x" |
|||
}, |
|||
{ |
|||
"size" : "76x76", |
|||
"idiom" : "ipad", |
|||
"filename" : "Icon-App-76x76@2x.png", |
|||
"scale" : "2x" |
|||
}, |
|||
{ |
|||
"size" : "83.5x83.5", |
|||
"idiom" : "ipad", |
|||
"filename" : "Icon-App-83.5x83.5@2x.png", |
|||
"scale" : "2x" |
|||
}, |
|||
{ |
|||
"size" : "1024x1024", |
|||
"idiom" : "ios-marketing", |
|||
"filename" : "Icon-App-1024x1024@1x.png", |
|||
"scale" : "1x" |
|||
} |
|||
], |
|||
"info" : { |
|||
"version" : 1, |
|||
"author" : "xcode" |
|||
} |
|||
} |
|||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
@ -1,23 +0,0 @@ |
|||
{ |
|||
"images" : [ |
|||
{ |
|||
"idiom" : "universal", |
|||
"filename" : "LaunchImage.png", |
|||
"scale" : "1x" |
|||
}, |
|||
{ |
|||
"idiom" : "universal", |
|||
"filename" : "LaunchImage@2x.png", |
|||
"scale" : "2x" |
|||
}, |
|||
{ |
|||
"idiom" : "universal", |
|||
"filename" : "LaunchImage@3x.png", |
|||
"scale" : "3x" |
|||
} |
|||
], |
|||
"info" : { |
|||
"version" : 1, |
|||
"author" : "xcode" |
|||
} |
|||
} |
|||
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B |
@ -1,5 +0,0 @@ |
|||
# Launch Screen Assets |
|||
|
|||
You can customize the launch screen with your own desired assets by replacing the image files in this directory. |
|||
|
|||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. |
|||
@ -1,37 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> |
|||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM"> |
|||
<dependencies> |
|||
<deployment identifier="iOS"/> |
|||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/> |
|||
</dependencies> |
|||
<scenes> |
|||
<!--View Controller--> |
|||
<scene sceneID="EHf-IW-A2E"> |
|||
<objects> |
|||
<viewController id="01J-lp-oVM" sceneMemberID="viewController"> |
|||
<layoutGuides> |
|||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/> |
|||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/> |
|||
</layoutGuides> |
|||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> |
|||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> |
|||
<subviews> |
|||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"> |
|||
</imageView> |
|||
</subviews> |
|||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> |
|||
<constraints> |
|||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/> |
|||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/> |
|||
</constraints> |
|||
</view> |
|||
</viewController> |
|||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/> |
|||
</objects> |
|||
<point key="canvasLocation" x="53" y="375"/> |
|||
</scene> |
|||
</scenes> |
|||
<resources> |
|||
<image name="LaunchImage" width="168" height="185"/> |
|||
</resources> |
|||
</document> |
|||
@ -1,26 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> |
|||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r"> |
|||
<dependencies> |
|||
<deployment identifier="iOS"/> |
|||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/> |
|||
</dependencies> |
|||
<scenes> |
|||
<!--Flutter View Controller--> |
|||
<scene sceneID="tne-QT-ifu"> |
|||
<objects> |
|||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController"> |
|||
<layoutGuides> |
|||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/> |
|||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/> |
|||
</layoutGuides> |
|||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC"> |
|||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/> |
|||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> |
|||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/> |
|||
</view> |
|||
</viewController> |
|||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/> |
|||
</objects> |
|||
</scene> |
|||
</scenes> |
|||
</document> |
|||
@ -1,49 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>CFBundleDevelopmentRegion</key> |
|||
<string>$(DEVELOPMENT_LANGUAGE)</string> |
|||
<key>CFBundleDisplayName</key> |
|||
<string>Azure Speech</string> |
|||
<key>CFBundleExecutable</key> |
|||
<string>$(EXECUTABLE_NAME)</string> |
|||
<key>CFBundleIdentifier</key> |
|||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> |
|||
<key>CFBundleInfoDictionaryVersion</key> |
|||
<string>6.0</string> |
|||
<key>CFBundleName</key> |
|||
<string>azure_speech</string> |
|||
<key>CFBundlePackageType</key> |
|||
<string>APPL</string> |
|||
<key>CFBundleShortVersionString</key> |
|||
<string>$(FLUTTER_BUILD_NAME)</string> |
|||
<key>CFBundleSignature</key> |
|||
<string>????</string> |
|||
<key>CFBundleVersion</key> |
|||
<string>$(FLUTTER_BUILD_NUMBER)</string> |
|||
<key>LSRequiresIPhoneOS</key> |
|||
<true/> |
|||
<key>UILaunchStoryboardName</key> |
|||
<string>LaunchScreen</string> |
|||
<key>UIMainStoryboardFile</key> |
|||
<string>Main</string> |
|||
<key>UISupportedInterfaceOrientations</key> |
|||
<array> |
|||
<string>UIInterfaceOrientationPortrait</string> |
|||
<string>UIInterfaceOrientationLandscapeLeft</string> |
|||
<string>UIInterfaceOrientationLandscapeRight</string> |
|||
</array> |
|||
<key>UISupportedInterfaceOrientations~ipad</key> |
|||
<array> |
|||
<string>UIInterfaceOrientationPortrait</string> |
|||
<string>UIInterfaceOrientationPortraitUpsideDown</string> |
|||
<string>UIInterfaceOrientationLandscapeLeft</string> |
|||
<string>UIInterfaceOrientationLandscapeRight</string> |
|||
</array> |
|||
<key>CADisableMinimumFrameDurationOnPhone</key> |
|||
<true/> |
|||
<key>UIApplicationSupportsIndirectInputEvents</key> |
|||
<true/> |
|||
</dict> |
|||
</plist> |
|||
@ -1 +0,0 @@ |
|||
#import "GeneratedPluginRegistrant.h" |
|||
@ -1,12 +0,0 @@ |
|||
import Flutter |
|||
import UIKit |
|||
import XCTest |
|||
|
|||
class RunnerTests: XCTestCase { |
|||
|
|||
func testExample() { |
|||
// If you add code to the Runner application, consider adding tests here. |
|||
// See https://developer.apple.com/documentation/xctest for more information about using XCTest. |
|||
} |
|||
|
|||
} |
|||
@ -1,20 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
|
|||
void main() { |
|||
runApp(const MainApp()); |
|||
} |
|||
|
|||
class MainApp extends StatelessWidget { |
|||
const MainApp({super.key}); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return const MaterialApp( |
|||
home: Scaffold( |
|||
body: Center( |
|||
child: Text('Hello World!'), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -1,195 +1,305 @@ |
|||
import Foundation |
|||
import CoreBluetooth |
|||
import os.log |
|||
|
|||
// 条件编译只导入在iOS 18+上可用的框架 |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
import AVFoundation |
|||
import AccessorySetupKit |
|||
import UIKit |
|||
#endif |
|||
|
|||
class BleAccessorySetup: NSObject { |
|||
// 单例实现 |
|||
// MARK: - 属性 |
|||
static let shared = BleAccessorySetup() |
|||
|
|||
// 基本属性 |
|||
private var bleService: Any? |
|||
private var bleService: BleService? |
|||
private var session: ASAccessorySession? |
|||
private var isSessionActivated = false |
|||
private var isInitializing = false |
|||
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") |
|||
|
|||
// iOS 18+ 特定属性和回调 |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
@available(iOS 18.0, *) |
|||
class AccessorySessionWrapper { |
|||
var session: ASAccessorySession |
|||
|
|||
init() { |
|||
self.session = ASAccessorySession() |
|||
} |
|||
} |
|||
// 杰理蓝牙设备的公司标识符 |
|||
private let deviceCompanyIdentifier: UInt16 = 0xEFAB |
|||
private let deviceDisplayName = "语音助理耳机" |
|||
|
|||
var accessorySessionWrapper: Any? |
|||
// 用于存储配对设备UUID的UserDefaults键 |
|||
private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid" |
|||
|
|||
typealias DeviceDiscoveredCallback = (UUID) -> Void |
|||
private var onDeviceDiscovered: DeviceDiscoveredCallback? |
|||
#endif |
|||
|
|||
private override init() { |
|||
super.init() |
|||
} |
|||
// MARK: - 初始化 |
|||
private override init() { super.init() } |
|||
|
|||
// 设置BLE服务实例 |
|||
// MARK: - 公共方法 |
|||
func setBleService(_ service: Any) { |
|||
self.bleService = service |
|||
self.bleService = service as? BleService |
|||
} |
|||
|
|||
// 设置设备发现回调 |
|||
func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
if #available(iOS 18.0, *) { |
|||
self.onDeviceDiscovered = callback |
|||
self.onDeviceDiscovered = callback |
|||
} |
|||
|
|||
// 处理发现的设备并连接 |
|||
func connectToAccessory(uuid: UUID) { |
|||
guard let service = bleService else { |
|||
return |
|||
} |
|||
#endif |
|||
|
|||
service.connectToAccessory(uuid: uuid) |
|||
} |
|||
|
|||
// 初始化设备发现会话 |
|||
func initializeDiscoverySession() { |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
if #available(iOS 18.0, *) { |
|||
os_log("初始化iOS 18 AccessorySetupKit会话", log: logger, type: .info) |
|||
|
|||
// 创建会话 |
|||
accessorySessionWrapper = AccessorySessionWrapper() |
|||
|
|||
// 激活会话并设置事件处理 |
|||
if let wrapper = accessorySessionWrapper as? AccessorySessionWrapper { |
|||
wrapper.session.activate(on: .main) { [weak self] event in |
|||
guard let self = self else { return } |
|||
func addToReconnectQueue(uuid: UUID) { |
|||
guard let service = bleService else { |
|||
return |
|||
} |
|||
|
|||
service.addToReconnectQueue(uuid: uuid) |
|||
} |
|||
|
|||
// 从视图控制器显示设备选择器 - 主要入口点 |
|||
func showDevicePickerFromViewController(_ viewController: UIViewController) { |
|||
initializeSession() |
|||
} |
|||
|
|||
// 清除蓝牙状态(仅用于测试或重置,不会清除系统授权) |
|||
func resetBluetoothState() { |
|||
if let service = bleService { |
|||
service.disconnect() |
|||
} |
|||
} |
|||
|
|||
// MARK: - 会话管理 |
|||
func initializeSession() { |
|||
if isInitializing { |
|||
os_log("会话正在初始化中,跳过", log: logger, type: .info) |
|||
return |
|||
} |
|||
|
|||
// 如果会话已激活,检查是否有配对设备 |
|||
if session != nil && isSessionActivated { |
|||
os_log("会话已激活,检查配对设备", log: logger, type: .info) |
|||
// 会话已激活,检查是否有配对设备 |
|||
if let accessories = session?.accessories, !accessories.isEmpty { |
|||
os_log("发现 %d 个已配对设备,尝试连接", log: logger, type: .info, accessories.count) |
|||
// 有配对设备,尝试连接 |
|||
if let service = bleService { |
|||
let _ = service.getCentralManager() |
|||
|
|||
switch event.eventType { |
|||
case .activated: |
|||
os_log("AccessorySetupKit会话已激活", log: self.logger, type: .info) |
|||
|
|||
case .accessoryAdded: |
|||
if let accessory = event.accessory, let uuid = accessory.bluetoothIdentifier { |
|||
os_log("发现新设备: %{public}@", log: self.logger, type: .info, accessory.displayName) |
|||
self.onDeviceDiscovered?(uuid) |
|||
} |
|||
|
|||
case .accessoryRemoved: |
|||
if let accessory = event.accessory { |
|||
os_log("设备已移除: %{public}@", log: self.logger, type: .info, accessory.displayName) |
|||
} |
|||
|
|||
// 根据WWDC24视频,正确的事件类型为accessoryChanged |
|||
case .accessoryChanged: |
|||
if let accessory = event.accessory { |
|||
os_log("设备已更新: %{public}@", log: self.logger, type: .info, accessory.displayName) |
|||
} |
|||
|
|||
// 根据WWDC24视频,失败事件类型已变更 |
|||
case .pickerSetupFailed: |
|||
if let error = event.error { |
|||
os_log("会话出错: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
|||
for accessory in accessories { |
|||
if let uuid = accessory.bluetoothIdentifier { |
|||
onDeviceDiscovered?(uuid) |
|||
connectToAccessory(uuid: uuid) |
|||
} |
|||
|
|||
default: |
|||
os_log("收到其他会话事件: %{public}d", log: self.logger, type: .debug, event.eventType.rawValue) |
|||
} |
|||
} |
|||
} else { |
|||
os_log("没有已配对设备,显示配对框", log: logger, type: .info) |
|||
// 没有配对设备,直接显示配对框 |
|||
showDevicePicker() |
|||
} |
|||
return |
|||
} |
|||
|
|||
os_log("开始初始化新会话", log: logger, type: .info) |
|||
isInitializing = true |
|||
|
|||
do { |
|||
session = try ASAccessorySession() |
|||
session?.activate(on: .main) { [weak self] event in |
|||
guard let self = self else { return } |
|||
self.handleSessionEvent(event) |
|||
} |
|||
} catch { |
|||
os_log("会话初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) |
|||
handleSessionInitError(error) |
|||
} |
|||
} |
|||
|
|||
func resetSession() { |
|||
session?.invalidate() |
|||
session = nil |
|||
isSessionActivated = false |
|||
isInitializing = false |
|||
} |
|||
|
|||
// MARK: - 设备选择器显示 |
|||
private func showDevicePicker() { |
|||
guard let session = session, isSessionActivated else { |
|||
os_log("无法显示配对框:会话未激活", log: logger, type: .error) |
|||
return |
|||
} |
|||
|
|||
os_log("准备显示设备配对框", log: logger, type: .info) |
|||
|
|||
let descriptor = ASDiscoveryDescriptor() |
|||
descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier) |
|||
|
|||
let deviceImage = UIImage(systemName: "headphones") ?? UIImage() |
|||
let displayItem = ASPickerDisplayItem( |
|||
name: deviceDisplayName, |
|||
productImage: deviceImage, |
|||
descriptor: descriptor |
|||
) |
|||
|
|||
DispatchQueue.main.async { |
|||
os_log("正在显示设备配对框", log: self.logger, type: .info) |
|||
session.showPicker(for: [displayItem]) { [weak self] error in |
|||
guard let self = self else { return } |
|||
|
|||
// 检查现有授权设备 |
|||
if !wrapper.session.accessories.isEmpty { |
|||
os_log("发现%{public}d个已授权设备", log: logger, type: .info, wrapper.session.accessories.count) |
|||
|
|||
// 如果有现有设备,触发回调 |
|||
for accessory in wrapper.session.accessories { |
|||
if let uuid = accessory.bluetoothIdentifier { |
|||
os_log("处理已授权设备: %{public}@", log: logger, type: .info, accessory.displayName) |
|||
self.onDeviceDiscovered?(uuid) |
|||
} |
|||
} |
|||
if let error = error { |
|||
let nsError = error as NSError |
|||
os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
|||
} else { |
|||
os_log("设备选择器完成", log: self.logger, type: .info) |
|||
} |
|||
} |
|||
} else { |
|||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
|||
} |
|||
#else |
|||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
|||
#endif |
|||
} |
|||
|
|||
// 显示设备选择器 |
|||
func showDevicePicker() { |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
if #available(iOS 18.0, *) { |
|||
if let wrapper = accessorySessionWrapper as? AccessorySessionWrapper { |
|||
os_log("显示iOS 18设备选择器", log: logger, type: .info) |
|||
// MARK: - 私有辅助方法 |
|||
private func handleSessionEvent(_ event: ASAccessoryEvent) { |
|||
switch event.eventType { |
|||
case .activated: |
|||
os_log("会话已激活", log: logger, type: .info) |
|||
handleSessionActivated() |
|||
|
|||
case .accessoryAdded: |
|||
os_log("发现新设备: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
|||
handleAccessoryAdded(event.accessory) |
|||
|
|||
case .accessoryRemoved: |
|||
os_log("设备已移除: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
|||
handleAccessoryRemoved(event.accessory) |
|||
|
|||
case .accessoryChanged: |
|||
os_log("设备状态已改变", log: logger, type: .info) |
|||
break |
|||
|
|||
case .pickerDidPresent: |
|||
os_log("配对框已显示", log: logger, type: .info) |
|||
break |
|||
|
|||
case .pickerDidDismiss: |
|||
os_log("配对框已关闭", log: logger, type: .info) |
|||
break |
|||
|
|||
case .pickerSetupFailed: |
|||
os_log("选择器设置失败: %{public}@", log: logger, type: .error, |
|||
event.error?.localizedDescription ?? "未知错误") |
|||
|
|||
default: |
|||
os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue) |
|||
break |
|||
} |
|||
} |
|||
|
|||
private func handleSessionActivated() { |
|||
isSessionActivated = true |
|||
isInitializing = false |
|||
|
|||
if let accessories = session?.accessories, !accessories.isEmpty { |
|||
if let service = bleService { |
|||
let _ = service.getCentralManager() |
|||
|
|||
// 创建描述符和选择器项,这些API也需要iOS 18 |
|||
@available(iOS 18.0, *) |
|||
func createAndShowPicker() { |
|||
// 根据WWDC24视频,正确的类名是ASDiscoveryDescriptor |
|||
let descriptor = ASDiscoveryDescriptor() |
|||
|
|||
// 设置过滤条件 |
|||
// 使用ASBluetoothCompanyIdentifier类型 |
|||
descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(UInt16(0xABEF)) // BleConst.MANUFACTURER_ID |
|||
descriptor.bluetoothServiceUUID = CBUUID(string: "0000abc0-0000-1111-2222-123456789abc") // BleConst.PRIMARY_SERVICE_UUID |
|||
|
|||
// 创建默认图像以防系统图标不可用 |
|||
let headphonesImage = UIImage(systemName: "headphones") ?? UIImage() |
|||
|
|||
// 根据WWDC24视频,正确的类名是ASPickerDisplayItem |
|||
let displayItem = ASPickerDisplayItem( |
|||
name: "语音助理耳机", |
|||
productImage: headphonesImage, |
|||
descriptor: descriptor |
|||
) |
|||
|
|||
// 显示选择器 |
|||
wrapper.session.showPicker(for: [displayItem]) { error in |
|||
if let error = error { |
|||
// 修复OSLog的错误用法 |
|||
os_log("设备选择器显示错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
|||
} |
|||
for accessory in accessories { |
|||
if let uuid = accessory.bluetoothIdentifier { |
|||
onDeviceDiscovered?(uuid) |
|||
connectToAccessory(uuid: uuid) |
|||
} |
|||
} |
|||
|
|||
// 调用内部函数 |
|||
createAndShowPicker() |
|||
} else { |
|||
os_log("会话未初始化", log: logger, type: .error) |
|||
} |
|||
} else { |
|||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
|||
showDevicePicker() |
|||
} |
|||
#else |
|||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
|||
#endif |
|||
} |
|||
|
|||
// 处理发现的设备 |
|||
func handleDiscoveredAccessory(uuid: UUID) { |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
if #available(iOS 18.0, *) { |
|||
guard let service = bleService as? BleService else { |
|||
os_log("BLE服务未设置", log: logger, type: .error) |
|||
return |
|||
} |
|||
|
|||
// 获取中央管理器 |
|||
let centralManager = service.getCentralManager() |
|||
|
|||
// 通过UUID检索外设 |
|||
let peripherals = centralManager.retrievePeripherals(withIdentifiers: [uuid]) |
|||
private func handleAccessoryAdded(_ accessory: ASAccessory?) { |
|||
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
|||
return |
|||
} |
|||
|
|||
// 保存配对设备的UUID |
|||
savePairedMacAddress(uuid) |
|||
|
|||
onDeviceDiscovered?(uuid) |
|||
connectToAccessory(uuid: uuid) |
|||
} |
|||
|
|||
private func handleAccessoryRemoved(_ accessory: ASAccessory?) { |
|||
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
|||
return |
|||
} |
|||
|
|||
if let service = bleService { |
|||
service.disconnect() |
|||
} |
|||
} |
|||
|
|||
private func handleSessionInitError(_ error: Error) { |
|||
isInitializing = false |
|||
session = nil |
|||
} |
|||
|
|||
// MARK: - 蓝牙状态检查 |
|||
func isBluetoothAvailable() -> Bool { |
|||
do { |
|||
let audioSession = AVAudioSession.sharedInstance() |
|||
let outputs = audioSession.currentRoute.outputs |
|||
|
|||
if let peripheral = peripherals.first { |
|||
os_log("连接到UUID对应的设备: %{public}@", log: logger, type: .info, peripheral.name ?? uuid.uuidString) |
|||
service.connect(to: peripheral) |
|||
} else { |
|||
os_log("未找到UUID对应的设备: %{public}@", log: logger, type: .error, uuid.uuidString) |
|||
for output in outputs { |
|||
if output.portType == .bluetoothA2DP || |
|||
output.portType == .bluetoothHFP || |
|||
output.portType == .bluetoothLE { |
|||
return true |
|||
} |
|||
} |
|||
} catch { |
|||
os_log("检查蓝牙状态出错: %{public}@", log: logger, type: .error, error.localizedDescription) |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
// MARK: - 清除配对关系 |
|||
func clearAssociations() { |
|||
resetBluetoothState() |
|||
|
|||
// 清除保存的配对设备UUID |
|||
clearPairedMacAddress() |
|||
|
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
if #available(iOS 18.0, *) { |
|||
resetSession() |
|||
initializeSession() |
|||
} |
|||
#endif |
|||
} |
|||
|
|||
// MARK: - 配对设备UUID管理 |
|||
|
|||
/// 获取已配对设备的UUID字符串 |
|||
/// 返回存储的配对设备UUID,如果没有则返回nil |
|||
func getPairedMacAddress() -> String? { |
|||
let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey) |
|||
if let uuidString = uuidString, !uuidString.isEmpty { |
|||
os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
|||
return uuidString |
|||
} |
|||
os_log("未找到已配对设备UUID", log: logger, type: .info) |
|||
return nil |
|||
} |
|||
|
|||
/// 保存配对设备的UUID |
|||
/// - Parameter uuid: 要保存的设备UUID |
|||
func savePairedMacAddress(_ uuid: UUID) { |
|||
let uuidString = uuid.uuidString |
|||
UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey) |
|||
UserDefaults.standard.synchronize() |
|||
os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
|||
} |
|||
|
|||
/// 清除已保存的配对设备UUID |
|||
private func clearPairedMacAddress() { |
|||
UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey) |
|||
UserDefaults.standard.synchronize() |
|||
os_log("已清除配对设备UUID", log: logger, type: .info) |
|||
} |
|||
} |
|||
@ -0,0 +1,164 @@ |
|||
import Foundation |
|||
import AVFoundation |
|||
import YbridOpus |
|||
|
|||
@available(iOS 13.0, *) |
|||
class SwiftOpusAudioProcessor: NSObject { |
|||
|
|||
protocol AudioDataCallback: AnyObject { |
|||
func onAudioDataReceived(data: Data) |
|||
} |
|||
|
|||
private var isDecodingActive = false |
|||
private weak var callback: AudioDataCallback? |
|||
|
|||
// Opus 解码器相关 - 使用 YbridOpus C API |
|||
private var decoder: OpaquePointer? |
|||
private var sampleRate: Int32 = 16000 |
|||
private var channels: Int32 = 1 |
|||
private let maxFrameSize: Int32 = 5760 // 120 ms @48 kHz 的最大值 |
|||
|
|||
override init() { |
|||
super.init() |
|||
} |
|||
|
|||
deinit { |
|||
cleanup() |
|||
} |
|||
|
|||
private func initializeOpusDecoder() -> Bool { |
|||
if decoder != nil { |
|||
return true |
|||
} |
|||
|
|||
var error: Int32 = 0 |
|||
decoder = opus_decoder_create(sampleRate, channels, &error) |
|||
|
|||
guard error == OPUS_OK, decoder != nil else { |
|||
return false |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
private func destroyOpusDecoder() { |
|||
if let decoder = decoder { |
|||
opus_decoder_destroy(decoder) |
|||
self.decoder = nil |
|||
} |
|||
} |
|||
|
|||
func setCallback(_ callback: AudioDataCallback?) { |
|||
self.callback = callback |
|||
} |
|||
|
|||
func startDecoding(sampleRate: Int32 = 16000, channels: Int32 = 1, frameSize: Int32 = 640) -> Bool { |
|||
if isDecodingActive { |
|||
return true |
|||
} |
|||
|
|||
self.sampleRate = sampleRate |
|||
self.channels = channels |
|||
|
|||
guard initializeOpusDecoder() else { |
|||
return false |
|||
} |
|||
|
|||
isDecodingActive = true |
|||
|
|||
return true |
|||
} |
|||
|
|||
func stopDecoding() -> Bool { |
|||
if !isDecodingActive { |
|||
return false |
|||
} |
|||
|
|||
isDecodingActive = false |
|||
destroyOpusDecoder() |
|||
|
|||
return true |
|||
} |
|||
|
|||
func processAudioData(_ data: Data) { |
|||
guard isDecodingActive, let decoder = decoder, data.count > 0 else { |
|||
return |
|||
} |
|||
|
|||
// 过滤明显异常的数据包 |
|||
guard data.count <= 1275 else { |
|||
return |
|||
} |
|||
|
|||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
|||
self?.decodeOpusPacket(data) |
|||
} |
|||
} |
|||
|
|||
// 简化的 Opus 解码方法,重点是容错处理 |
|||
private func decodeOpusPacket(_ packet: Data) { |
|||
guard let decoder = decoder else { return } |
|||
|
|||
let maxSamples = Int(maxFrameSize) |
|||
var pcmBuffer = [opus_int16](repeating: 0, count: maxSamples * Int(channels)) |
|||
|
|||
let decodedSamples = packet.withUnsafeBytes { packetPtr in |
|||
let uint8Ptr = packetPtr.bindMemory(to: UInt8.self).baseAddress |
|||
return opus_decode( |
|||
decoder, |
|||
uint8Ptr, |
|||
opus_int32(packet.count), |
|||
&pcmBuffer, |
|||
Int32(maxSamples), |
|||
0 |
|||
) |
|||
} |
|||
|
|||
// 简单的错误处理:失败时重新初始化解码器 |
|||
if decodedSamples <= 0 { |
|||
print("Opus解码失败,重新初始化解码器") |
|||
resetDecoder() |
|||
return |
|||
} |
|||
|
|||
// 提取解码的音频数据 |
|||
let samplesPerChannel = Int(decodedSamples) |
|||
let totalSamples = samplesPerChannel * Int(channels) |
|||
let outputPCM = Array(pcmBuffer[0..<totalSamples]) |
|||
|
|||
if !outputPCM.isEmpty { |
|||
processPCMData(outputPCM) |
|||
} |
|||
} |
|||
|
|||
// 重置解码器的简单方法 |
|||
private func resetDecoder() { |
|||
destroyOpusDecoder() |
|||
_ = initializeOpusDecoder() |
|||
} |
|||
|
|||
private func processPCMData(_ pcmData: [opus_int16]) { |
|||
// 转换为 Data |
|||
let data = Data(bytes: pcmData, count: pcmData.count * MemoryLayout<opus_int16>.size) |
|||
|
|||
// 回调通知 |
|||
DispatchQueue.main.async { [weak self] in |
|||
self?.callback?.onAudioDataReceived(data: data) |
|||
} |
|||
} |
|||
|
|||
func isDecoding() -> Bool { |
|||
return isDecodingActive |
|||
} |
|||
|
|||
func cleanup() { |
|||
stopDecoding() |
|||
} |
|||
|
|||
// MARK: - 公共接口 |
|||
|
|||
/// 获取当前配置信息 |
|||
func getCurrentConfig() -> (sampleRate: Int32, channels: Int32) { |
|||
return (sampleRate, channels) |
|||
} |
|||
} |
|||
@ -1,26 +1,72 @@ |
|||
name: ble_service |
|||
description: BLE服务插件,提供蓝牙扫描、连接和通信功能 |
|||
description: 蓝牙低功耗服务插件,提供蓝牙设备连接、通信功能 |
|||
version: 0.0.1 |
|||
homepage: |
|||
|
|||
environment: |
|||
sdk: ">=2.16.0 <3.0.0" |
|||
flutter: ">=2.5.0" |
|||
sdk: ">=2.18.0 <4.0.0" |
|||
flutter: ">=3.3.0" |
|||
|
|||
dependencies: |
|||
flutter: |
|||
sdk: flutter |
|||
# 添加其他依赖项 |
|||
|
|||
dev_dependencies: |
|||
flutter_test: |
|||
sdk: flutter |
|||
flutter_lints: ^1.0.0 |
|||
flutter_lints: ^2.0.0 |
|||
|
|||
# For information on the generic Dart part of this file, see the |
|||
# following page: https://dart.dev/tools/pub/pubspec |
|||
|
|||
# The following section is specific to Flutter packages. |
|||
flutter: |
|||
# This section identifies this Flutter project as a plugin project. |
|||
# The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) |
|||
# which should be registered in the plugin registry. This is required for |
|||
# using method channels. |
|||
# The Android 'package' specifies package in which the registered class is. |
|||
# This is required for using method channels on Android. |
|||
# The 'ffiPlugin' specifies that native code should be built and bundled. |
|||
# This is required for using `dart:ffi`. |
|||
# All these are used by the tooling to maintain consistency when |
|||
# adding or updating assets for this project. |
|||
plugin: |
|||
platforms: |
|||
android: |
|||
package: com.yunqiinnovation.ble_service |
|||
pluginClass: BleServicePlugin |
|||
pluginClass: BleServicePlugin |
|||
ios: |
|||
pluginClass: BleServicePlugin |
|||
pluginClass: BleServicePlugin |
|||
|
|||
# To add assets to your plugin package, add an assets section, like this: |
|||
# assets: |
|||
# - images/a_dot_burr.jpeg |
|||
# - images/a_dot_ham.jpeg |
|||
# |
|||
# For details regarding assets in packages, see |
|||
# https://flutter.dev/assets-and-images/#from-packages |
|||
# |
|||
# An image asset can refer to one or more resolution-specific "variants", see |
|||
# https://flutter.dev/assets-and-images/#resolution-aware |
|||
|
|||
# To add custom fonts to your plugin package, add a fonts section here, |
|||
# in this "flutter" section. Each entry in this list should have a |
|||
# "family" key with the font family name, and a "fonts" key with a |
|||
# list giving the asset and other descriptors for the font. For |
|||
# example: |
|||
# fonts: |
|||
# - family: Schyler |
|||
# fonts: |
|||
# - asset: fonts/Schyler-Regular.ttf |
|||
# - asset: fonts/Schyler-Italic.ttf |
|||
# style: italic |
|||
# - family: Trajan Pro |
|||
# fonts: |
|||
# - asset: fonts/TrajanPro.ttf |
|||
# - asset: fonts/TrajanPro_Bold.ttf |
|||
# weight: 700 |
|||
# |
|||
# For details regarding fonts in packages, see |
|||
# https://flutter.dev/custom-fonts/#from-packages |
|||
@ -0,0 +1,84 @@ |
|||
# iOS 集成指南(旧版 CocoaPods 项目)- Chat API Plugin |
|||
|
|||
> **注意**:本指南仅适用于未启用 Swift Package Manager 的旧版 Flutter 项目。 |
|||
> |
|||
> 如果你的 Flutter 版本 >= 3.24,强烈建议启用 SPM 支持: |
|||
> ```bash |
|||
> flutter config --enable-swift-package-manager |
|||
> ``` |
|||
> 启用后,插件会自动处理所有依赖,无需手动配置。 |
|||
|
|||
## 手动集成步骤(仅限 CocoaPods 项目) |
|||
|
|||
由于 Flutter 插件中 CocoaPods 和 Swift Package Manager 的兼容性限制,需要手动在 Xcode 中添加 OpenAI 包依赖。 |
|||
|
|||
## 步骤说明 |
|||
|
|||
### 1. 运行 pod install |
|||
|
|||
首先在项目根目录运行: |
|||
|
|||
```bash |
|||
cd ios |
|||
pod install |
|||
``` |
|||
|
|||
### 2. 打开 Xcode 项目 |
|||
|
|||
打开生成的 workspace 文件: |
|||
|
|||
```bash |
|||
open Runner.xcworkspace |
|||
``` |
|||
|
|||
### 3. 添加 Swift Package 依赖 |
|||
|
|||
1. 在 Xcode 中,选择左侧的 `Runner` 项目(蓝色图标) |
|||
2. 在主编辑区域,选择 `Runner` target |
|||
3. 点击顶部的 `Package Dependencies` 标签 |
|||
4. 点击左下角的 `+` 按钮 |
|||
5. 在弹出的窗口中输入:`https://github.com/MacPaw/OpenAI.git` |
|||
6. 点击 `Add Package` |
|||
7. 在版本选择界面: |
|||
- 选择 `Up to Next Major Version` |
|||
- 起始版本填写:`0.4.0` |
|||
8. 点击 `Add Package` |
|||
9. 在包产品选择界面,确保 `OpenAI` 被选中,并且目标是 `Runner` |
|||
10. 点击 `Add Package` |
|||
|
|||
### 4. 验证集成 |
|||
|
|||
1. 在 Xcode 左侧导航栏中,展开 `Package Dependencies` |
|||
2. 确认能看到 `OpenAI` 包 |
|||
3. 尝试构建项目(Cmd + B)确保没有错误 |
|||
|
|||
### 5. 可能的问题 |
|||
|
|||
#### 问题:构建时提示找不到 OpenAI 模块 |
|||
|
|||
**解决方案**: |
|||
1. 清理构建缓存:`Product > Clean Build Folder` |
|||
2. 关闭 Xcode |
|||
3. 删除 `DerivedData`: |
|||
```bash |
|||
rm -rf ~/Library/Developer/Xcode/DerivedData |
|||
``` |
|||
4. 重新打开项目并构建 |
|||
|
|||
#### 问题:包依赖下载失败 |
|||
|
|||
**解决方案**: |
|||
1. 检查网络连接 |
|||
2. 在 Xcode 中:`File > Packages > Reset Package Caches` |
|||
3. 重新添加包依赖 |
|||
|
|||
## 注意事项 |
|||
|
|||
- 每次运行 `pod install` 后,包依赖配置会保留 |
|||
- 如果切换了 Xcode 版本,可能需要重新解析包依赖 |
|||
- 建议将 `.xcworkspace` 文件加入版本控制,但排除 `xcuserdata` 目录 |
|||
|
|||
## 相关链接 |
|||
|
|||
- [MacPaw OpenAI Swift 库](https://github.com/MacPaw/OpenAI) |
|||
- [Swift Package Manager 文档](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app) |
|||
@ -0,0 +1,239 @@ |
|||
# Chat API Plugin |
|||
|
|||
基于 MacPaw OpenAI Swift 库的 Flutter 聊天 API 插件。 |
|||
|
|||
## 功能特性 |
|||
|
|||
- ✅ 支持 OpenAI Chat API 的完整功能 |
|||
- ✅ 流式输出支持 |
|||
- ✅ 函数调用支持 |
|||
- ✅ 多模态支持(文本、图片) |
|||
- ✅ 自定义模型支持 |
|||
- ✅ Token 计数估算 |
|||
- ✅ 支持自定义 API 端点 |
|||
- ✅ 自动 Swift Package Manager (SPM) 集成 |
|||
|
|||
## 安装 |
|||
|
|||
### 1. 启用 Swift Package Manager |
|||
|
|||
确保你的 Flutter 版本 >= 3.24,并启用 SPM 支持: |
|||
|
|||
```bash |
|||
flutter config --enable-swift-package-manager |
|||
``` |
|||
|
|||
### 2. 添加依赖 |
|||
|
|||
在你的 `pubspec.yaml` 文件中添加: |
|||
|
|||
```yaml |
|||
dependencies: |
|||
chat_api: |
|||
path: ../local_plugins/chat_api |
|||
``` |
|||
|
|||
### 3. iOS 配置 |
|||
|
|||
运行以下命令,Flutter 会自动处理 SPM 依赖: |
|||
|
|||
```bash |
|||
cd your_project |
|||
flutter pub get |
|||
cd ios |
|||
flutter run |
|||
``` |
|||
|
|||
首次运行时,Flutter 会自动: |
|||
- 迁移项目到 SPM 结构(如果还没有迁移) |
|||
- 下载并集成 MacPaw OpenAI Swift 包 |
|||
- 配置所有必要的依赖 |
|||
|
|||
**注意**:不再需要手动在 Xcode 中添加包依赖! |
|||
|
|||
### 4. Android 配置 |
|||
|
|||
当前 Android 端仅提供占位实现,如需完整功能请使用 iOS 平台。 |
|||
|
|||
## 使用方法 |
|||
|
|||
### 初始化 |
|||
|
|||
```dart |
|||
import 'package:chat_api/chat_api.dart'; |
|||
|
|||
final chatApi = ChatApi(); |
|||
|
|||
// 初始化 |
|||
await chatApi.initialize( |
|||
apiKey: 'your-api-key', |
|||
organization: 'your-org-id', // 可选 |
|||
model: 'gpt-3.5-turbo', // 默认模型 |
|||
baseUrl: 'https://api.openai.com/v1/chat/completions', // 可选,自定义端点 |
|||
); |
|||
``` |
|||
|
|||
### 发送消息(非流式) |
|||
|
|||
```dart |
|||
// 创建消息列表 |
|||
final messages = [ |
|||
ChatMessage( |
|||
role: MessageRole.system, |
|||
content: '你是一个有帮助的助手。', |
|||
), |
|||
ChatMessage( |
|||
role: MessageRole.user, |
|||
content: '你好,请介绍一下你自己。', |
|||
), |
|||
]; |
|||
|
|||
// 发送消息 |
|||
try { |
|||
final response = await chatApi.sendMessage( |
|||
messages: messages, |
|||
temperature: 0.7, |
|||
maxTokens: 1000, |
|||
); |
|||
print('回复: $response'); |
|||
} catch (e) { |
|||
print('错误: $e'); |
|||
} |
|||
``` |
|||
|
|||
### 发送消息(流式) |
|||
|
|||
```dart |
|||
// 监听事件流 |
|||
chatApi.eventStream.listen((event) { |
|||
switch (event.type) { |
|||
case ChatApiEventType.token: |
|||
// 收到新的token |
|||
print(event.content); |
|||
break; |
|||
case ChatApiEventType.complete: |
|||
// 对话完成 |
|||
print('完成'); |
|||
break; |
|||
case ChatApiEventType.error: |
|||
// 发生错误 |
|||
print('错误: ${event.content}'); |
|||
break; |
|||
case ChatApiEventType.functionCall: |
|||
// 函数调用 |
|||
print('函数调用: ${event.content}'); |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
// 发送流式消息 |
|||
await chatApi.sendMessageStream( |
|||
messages: messages, |
|||
temperature: 0.7, |
|||
); |
|||
``` |
|||
|
|||
### 函数调用 |
|||
|
|||
```dart |
|||
// 定义函数 |
|||
final functions = [ |
|||
{ |
|||
'name': 'get_weather', |
|||
'description': '获取指定位置的天气', |
|||
'parameters': { |
|||
'type': 'object', |
|||
'properties': { |
|||
'location': { |
|||
'type': 'string', |
|||
'description': '城市名称', |
|||
}, |
|||
}, |
|||
'required': ['location'], |
|||
}, |
|||
}, |
|||
]; |
|||
|
|||
// 发送带函数的消息 |
|||
await chatApi.sendMessageStream( |
|||
messages: messages, |
|||
functions: functions, |
|||
); |
|||
``` |
|||
|
|||
### 多模态支持 |
|||
|
|||
```dart |
|||
// 发送带图片的消息 |
|||
final imageMessage = ChatMessage( |
|||
role: MessageRole.user, |
|||
content: [ |
|||
{ |
|||
'type': 'text', |
|||
'text': '这张图片里有什么?', |
|||
}, |
|||
{ |
|||
'type': 'image_url', |
|||
'image_url': { |
|||
'url': 'data:image/jpeg;base64,${base64EncodedImage}', |
|||
}, |
|||
}, |
|||
], |
|||
); |
|||
``` |
|||
|
|||
### 其他功能 |
|||
|
|||
```dart |
|||
// 获取可用模型列表 |
|||
final models = await chatApi.getAvailableModels(); |
|||
|
|||
// 设置模型 |
|||
await chatApi.setModel('gpt-4'); |
|||
|
|||
// 估算token数量 |
|||
final tokenCount = await chatApi.countTokens('这是一段测试文本'); |
|||
|
|||
// 取消流式请求 |
|||
await chatApi.cancelStream(); |
|||
``` |
|||
|
|||
## 兼容性说明 |
|||
|
|||
### Swift Package Manager 项目 |
|||
|
|||
如果你的项目已启用 SPM(Flutter 3.24+),插件会自动通过 SPM 集成,无需额外配置。 |
|||
|
|||
### CocoaPods 项目 |
|||
|
|||
如果你的项目仍在使用 CocoaPods(或有其他插件未迁移到 SPM),插件会自动降级到 CocoaPods 模式。这种情况下,你需要手动在 Xcode 中添加 OpenAI 包依赖。详见 `IOS_INTEGRATION_LEGACY.md`。 |
|||
|
|||
## 故障排除 |
|||
|
|||
### 问题:构建失败,提示找不到 OpenAI 模块 |
|||
|
|||
**解决方案**: |
|||
1. 确保已启用 SPM:`flutter config --enable-swift-package-manager` |
|||
2. 清理并重新构建: |
|||
```bash |
|||
flutter clean |
|||
cd ios |
|||
rm -rf Pods Podfile.lock |
|||
flutter pub get |
|||
flutter run |
|||
``` |
|||
|
|||
### 问题:Xcode 中看不到 Package Dependencies |
|||
|
|||
**解决方案**: |
|||
这可能意味着项目还在使用 CocoaPods 模式。运行 `flutter run` 应该会自动迁移到 SPM。 |
|||
|
|||
## 注意事项 |
|||
|
|||
1. **API 密钥安全**:不要将 API 密钥硬编码在代码中,建议使用环境变量或安全存储。 |
|||
2. **错误处理**:始终使用 try-catch 包装 API 调用以处理可能的错误。 |
|||
3. **流式请求**:记得在不需要时取消流式请求,避免资源浪费。 |
|||
|
|||
## 许可证 |
|||
|
|||
MIT License |
|||
@ -0,0 +1,108 @@ |
|||
# Chat API 插件 SPM 迁移说明 |
|||
|
|||
## 迁移概述 |
|||
|
|||
`chat_api` 插件已经完成了 Swift Package Manager (SPM) 支持的迁移,现在可以自动处理 OpenAI Swift 包的依赖。 |
|||
|
|||
## 主要变更 |
|||
|
|||
### 1. 目录结构调整 |
|||
|
|||
``` |
|||
ios/ |
|||
├── chat_api.podspec # 保留,支持双轨模式 |
|||
├── Classes/ # 已清空(源码已迁移) |
|||
└── chat_api/ # 新增 SPM 结构 |
|||
├── Package.swift # SPM 包定义 |
|||
└── Sources/ |
|||
└── chat_api/ # 源码新位置 |
|||
├── ChatApiPlugin.swift |
|||
└── ChatService.swift |
|||
``` |
|||
|
|||
### 2. Package.swift 配置 |
|||
|
|||
- 声明了 MacPaw OpenAI 包依赖 |
|||
- 自动管理版本(0.4.0+) |
|||
- 设置最低 iOS 版本为 13.0 |
|||
|
|||
### 3. podspec 更新 |
|||
|
|||
- 更新了 `source_files` 路径指向新的 SPM 结构 |
|||
- 添加了友好的迁移提示脚本 |
|||
|
|||
### 4. pubspec.yaml 更新 |
|||
|
|||
- 添加了 `dartPluginClass` 声明,明确支持 SPM |
|||
|
|||
## 使用方式对比 |
|||
|
|||
### 旧方式(手动集成) |
|||
|
|||
1. 运行 `pod install` |
|||
2. 打开 Xcode |
|||
3. 手动添加 OpenAI Swift Package |
|||
4. 配置依赖关系 |
|||
|
|||
### 新方式(自动集成) |
|||
|
|||
1. 启用 SPM:`flutter config --enable-swift-package-manager` |
|||
2. 运行 `flutter run` |
|||
3. 完成!Flutter 自动处理所有依赖 |
|||
|
|||
## 兼容性 |
|||
|
|||
- **Flutter 3.24+**:完整 SPM 支持,推荐使用 |
|||
- **Flutter < 3.24**:自动降级到 CocoaPods 模式 |
|||
- **混合项目**:如果项目中有其他插件未迁移到 SPM,Flutter 会智能选择合适的集成方式 |
|||
|
|||
## 验证集成 |
|||
|
|||
### SPM 项目 |
|||
|
|||
```bash |
|||
# 在 example 目录下 |
|||
flutter clean |
|||
flutter pub get |
|||
flutter run --verbose |
|||
``` |
|||
|
|||
成功标志: |
|||
- 看到 "Adding Swift Package Manager integration..." 消息 |
|||
- Xcode 中 Package Dependencies 自动出现 OpenAI 包 |
|||
- 构建成功,无需手动干预 |
|||
|
|||
### CocoaPods 项目 |
|||
|
|||
如果项目仍使用 CocoaPods,会看到: |
|||
- podspec 中的迁移提示 |
|||
- 需要按照 `IOS_INTEGRATION_LEGACY.md` 手动添加依赖 |
|||
|
|||
## 故障排除 |
|||
|
|||
1. **清理缓存** |
|||
```bash |
|||
flutter clean |
|||
rm -rf ios/Pods ios/Podfile.lock |
|||
rm -rf ~/Library/Developer/Xcode/DerivedData |
|||
``` |
|||
|
|||
2. **重置 SPM 缓存** |
|||
- Xcode: File > Packages > Reset Package Caches |
|||
|
|||
3. **确认 SPM 已启用** |
|||
```bash |
|||
flutter config |
|||
# 应该看到 enable-swift-package-manager: true |
|||
``` |
|||
|
|||
## 开发者注意事项 |
|||
|
|||
1. **源码位置**:所有 Swift 代码现在位于 `ios/chat_api/Sources/chat_api/` |
|||
2. **双轨支持**:保持 podspec 和 Package.swift 同时更新 |
|||
3. **版本管理**:OpenAI 包版本在 Package.swift 中管理 |
|||
|
|||
## 未来计划 |
|||
|
|||
- 当 Flutter 生态系统完全迁移到 SPM 后,可以考虑移除 CocoaPods 支持 |
|||
- 持续跟踪 MacPaw OpenAI 库的更新 |
|||