@ -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.release.xcconfig" |
||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig" |
||||
#include "Generated.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 |
||||
|
} |
||||
@ -1,5 +1,12 @@ |
|||||
<?xml version="1.0" encoding="UTF-8"?> |
<?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"> |
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
<plist version="1.0"> |
<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> |
</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> |
#import <Flutter/Flutter.h> |
||||
|
|
||||
@interface AgentServicePlugin : NSObject<FlutterPlugin> |
@interface AgentServicePlugin : NSObject<FlutterPlugin> |
||||
@end |
@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 Flutter |
||||
import UIKit |
import UIKit |
||||
|
import AVFoundation |
||||
|
import MicrosoftCognitiveServicesSpeech |
||||
|
// 自定义语音处理组件,提供音频流处理等功能 |
||||
|
import speech |
||||
|
import os.log |
||||
|
|
||||
public class AzureSpeechPlugin: NSObject, FlutterPlugin { |
/** |
||||
private var ttsHelper: AzureTtsHelper? |
* Azure Speech Plugin |
||||
private var asrHelper: AzureAsrHelper? |
* |
||||
private var eventSink: FlutterEventSink? |
* 基于微软Azure语音服务的Flutter插件 |
||||
|
* 提供语音识别(ASR)和语音合成(TTS)功能 |
||||
public static func register(with registrar: FlutterPluginRegistrar) { |
*/ |
||||
let channel = FlutterMethodChannel(name: "azure_speech", binaryMessenger: registrar.messenger()) |
@objc public class AzureSpeechPlugin: NSObject, FlutterPlugin { |
||||
let instance = AzureSpeechPlugin() |
// 日志标签 |
||||
registrar.addMethodCallDelegate(instance, channel: channel) |
private let tag = "AzureSpeechPlugin" |
||||
|
// 日志对象 |
||||
// 初始化事件通道 |
private let log = OSLog(subsystem: "com.azure.speech", category: "AzureSpeechPlugin") |
||||
let eventChannel = FlutterEventChannel(name: "azure_speech/asr_events", binaryMessenger: registrar.messenger()) |
|
||||
eventChannel.setStreamHandler(AsrStreamHandler(instance: instance)) |
// ASR相关 |
||||
} |
private var asrChannel: FlutterMethodChannel? |
||||
|
private var asrEventChannel: FlutterEventChannel? |
||||
override init() { |
private var asrEventSink: FlutterEventSink? |
||||
super.init() |
private let azureAsrHelper = AzureAsrHelper() |
||||
ttsHelper = AzureTtsHelper() |
|
||||
asrHelper = AzureAsrHelper() |
// TTS相关 |
||||
|
private var ttsChannel: FlutterMethodChannel? |
||||
// 设置ASR事件处理 |
private var ttsEventChannel: FlutterEventChannel? |
||||
asrHelper?.setEventHandler { [weak self] event in |
private var ttsEventSink: FlutterEventSink? |
||||
self?.handleAsrEvent(event) |
private let azureTtsHelper = AzureTtsHelper() |
||||
} |
|
||||
} |
// 是否已添加TTS事件监听器 |
||||
|
private var isTtsListenerAdded = false |
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|
||||
switch call.method { |
// 当前的连续识别回调 |
||||
// TTS相关方法 |
private var currentAsrCallback: AsrCallbackWrapper? |
||||
case "initializeTts": |
|
||||
guard let args = call.arguments as? [String: Any], |
// 插件注册 |
||||
let subscriptionKey = args["subscriptionKey"] as? String, |
public static func register(with registrar: FlutterPluginRegistrar) { |
||||
let serviceRegion = args["serviceRegion"] as? String else { |
let instance = AzureSpeechPlugin() |
||||
result(false) |
|
||||
return |
// 初始化ASR通道 |
||||
} |
let asrChannel = FlutterMethodChannel(name: "azure_speech/asr", binaryMessenger: registrar.messenger()) |
||||
|
registrar.addMethodCallDelegate(instance, channel: asrChannel) |
||||
let language = args["language"] as? String ?? "zh-CN" |
instance.asrChannel = asrChannel |
||||
let success = ttsHelper?.initialize(speechSubscriptionKey: subscriptionKey, serviceRegion: serviceRegion, language: language) ?? false |
|
||||
result(success) |
// 初始化TTS通道 |
||||
|
let ttsChannel = FlutterMethodChannel(name: "azure_speech/tts", binaryMessenger: registrar.messenger()) |
||||
case "setTtsVoice": |
registrar.addMethodCallDelegate(instance, channel: ttsChannel) |
||||
guard let args = call.arguments as? [String: Any], |
instance.ttsChannel = ttsChannel |
||||
let voiceName = args["voiceName"] as? String else { |
|
||||
result(false) |
// 初始化ASR事件通道 |
||||
return |
let asrEventChannel = FlutterEventChannel(name: "azure_speech/asr_events", binaryMessenger: registrar.messenger()) |
||||
} |
asrEventChannel.setStreamHandler(instance) |
||||
|
instance.asrEventChannel = asrEventChannel |
||||
let success = ttsHelper?.setVoice(voiceName: voiceName) ?? false |
|
||||
result(success) |
// 初始化TTS事件通道 |
||||
|
let ttsEventChannel = FlutterEventChannel(name: "azure_speech/tts_events", binaryMessenger: registrar.messenger()) |
||||
case "setTtsSpeechParams": |
ttsEventChannel.setStreamHandler(instance) |
||||
guard let args = call.arguments as? [String: Any], |
instance.ttsEventChannel = ttsEventChannel |
||||
let rate = args["rate"] as? Int, |
} |
||||
let pitch = args["pitch"] as? Int, |
|
||||
let volume = args["volume"] as? Int else { |
// 发送ASR事件方法 |
||||
result(false) |
internal func sendAsrEvent(_ event: [String: Any]) { |
||||
return |
if asrEventSink == nil { |
||||
} |
os_log("无法发送ASR事件:事件通道未准备好", log: log, type: .error) |
||||
|
return |
||||
let success = ttsHelper?.setSpeechParams(rate: rate, pitch: pitch, volume: volume) ?? false |
} |
||||
result(success) |
|
||||
|
DispatchQueue.main.async { [weak self] in |
||||
case "speakText": |
guard let self = self else { return } |
||||
guard let args = call.arguments as? [String: Any], |
self.asrEventSink?(event) |
||||
let text = args["text"] as? String else { |
} |
||||
result(false) |
} |
||||
return |
|
||||
} |
// 发送TTS事件方法 |
||||
|
private func sendTtsEvent(_ event: [String: Any]) { |
||||
ttsHelper?.speakText(text: text) { success, _ in |
if ttsEventSink == nil { |
||||
result(success) |
os_log("无法发送TTS事件:事件通道未准备好", log: log, type: .error) |
||||
} |
return |
||||
|
} |
||||
case "stopSpeaking": |
|
||||
let success = ttsHelper?.stopSpeaking() ?? false |
DispatchQueue.main.async { [weak self] in |
||||
result(success) |
guard let self = self else { return } |
||||
|
self.ttsEventSink?(event) |
||||
case "isSpeaking": |
} |
||||
let speaking = ttsHelper?.isSpeaking() ?? false |
} |
||||
result(speaking) |
|
||||
|
// 设置TTS事件监听器 |
||||
case "setTtsAudioOutputType": |
private func setupTtsEventListener() { |
||||
guard let args = call.arguments as? [String: Any], |
if !isTtsListenerAdded { |
||||
let outputType = args["outputType"] as? String else { |
azureTtsHelper.addListener(self) |
||||
result(false) |
isTtsListenerAdded = true |
||||
return |
} |
||||
} |
} |
||||
|
|
||||
var type: AzureTtsHelper.AudioOutputType = .auto |
|
||||
switch outputType.uppercased() { |
|
||||
case "SPEAKER": |
// 处理Flutter方法调用 |
||||
type = .speaker |
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
||||
case "EARPIECE": |
if call.method.hasPrefix("tts_") { |
||||
type = .earpiece |
handleTtsMethodCall(call, result) |
||||
default: |
} else { |
||||
type = .auto |
handleAsrMethodCall(call, result) |
||||
} |
} |
||||
|
} |
||||
let success = ttsHelper?.setAudioOutputType(outputType: type) ?? false |
|
||||
result(success) |
// MARK: - ASR 方法处理 |
||||
|
|
||||
// ASR相关方法 |
private func handleAsrMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
||||
case "initializeAsr": |
switch call.method { |
||||
guard let args = call.arguments as? [String: Any], |
case "initialize": |
||||
let subscriptionKey = args["subscriptionKey"] as? String, |
guard let args = call.arguments as? [String: Any], |
||||
let serviceRegion = args["serviceRegion"] as? String, |
let subscriptionKey = args["subscriptionKey"] as? String, |
||||
let supportedLanguages = args["supportedLanguages"] as? [String] else { |
let region = args["region"] as? String else { |
||||
result(false) |
result(FlutterError(code: "INVALID_ARGUMENTS", message: "必要的参数不能为空", details: nil)) |
||||
return |
return |
||||
} |
} |
||||
|
|
||||
let success = asrHelper?.initialize(speechSubscriptionKey: subscriptionKey, serviceRegion: serviceRegion, supportedLanguages: supportedLanguages) ?? false |
let supportedLanguages = args["supportedLanguages"] as? [String] ?? ["zh-CN"] |
||||
result(success) |
|
||||
|
// 初始化ASR引擎 |
||||
case "recognizeOnce": |
let success = azureAsrHelper.initialize( |
||||
asrHelper?.recognizeOnce { success, text, language, error in |
subscriptionKey: subscriptionKey, |
||||
var resultMap: [String: Any] = ["success": success] |
region: region, |
||||
if success { |
supportedLanguages: supportedLanguages, |
||||
resultMap["text"] = text |
audioSourceType: .microphone |
||||
resultMap["language"] = language |
) |
||||
|
|
||||
|
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 { |
} else { |
||||
resultMap["error"] = error |
// 如果没有指定通道,清除所有通道 |
||||
|
asrEventSink = nil |
||||
|
ttsEventSink = nil |
||||
} |
} |
||||
result(resultMap) |
|
||||
} |
return nil |
||||
|
} |
||||
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) |
|
||||
} |
|
||||
} |
} |
||||
|
|
||||
// ASR事件流处理器 |
|
||||
class AsrStreamHandler: NSObject, FlutterStreamHandler { |
|
||||
private weak var plugin: AzureSpeechPlugin? |
// MARK: - TTS 事件监听实现 |
||||
|
extension AzureSpeechPlugin: TtsEventListener { |
||||
init(instance: AzureSpeechPlugin) { |
// 使用@objc特性为方法提供一个不同的Objective-C选择器名称 |
||||
self.plugin = instance |
@objc(onTtsEvent:) |
||||
super.init() |
public func onEvent(_ event: TtsEvent) { |
||||
} |
var eventMap: [String: Any] = [:] |
||||
|
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
// 根据事件类型转换 |
||||
plugin?.setEventSink(events) |
switch event.type { |
||||
return nil |
case .synthesisStarted: |
||||
} |
eventMap["type"] = "synthesis_started" |
||||
|
case .synthesisCompleted: |
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
eventMap["type"] = "synthesis_completed" |
||||
plugin?.setEventSink(nil) |
case .synthesisCanceled: |
||||
return nil |
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 Foundation |
||||
import CoreBluetooth |
import CoreBluetooth |
||||
import os.log |
import os.log |
||||
|
import AVFoundation |
||||
// 条件编译只导入在iOS 18+上可用的框架 |
|
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|
||||
import AccessorySetupKit |
import AccessorySetupKit |
||||
import UIKit |
import UIKit |
||||
#endif |
|
||||
|
|
||||
class BleAccessorySetup: NSObject { |
class BleAccessorySetup: NSObject { |
||||
// 单例实现 |
// MARK: - 属性 |
||||
static let shared = BleAccessorySetup() |
static let shared = BleAccessorySetup() |
||||
|
|
||||
// 基本属性 |
private var bleService: BleService? |
||||
private var bleService: Any? |
private var session: ASAccessorySession? |
||||
|
private var isSessionActivated = false |
||||
|
private var isInitializing = false |
||||
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") |
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") |
||||
|
|
||||
// iOS 18+ 特定属性和回调 |
// 杰理蓝牙设备的公司标识符 |
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
private let deviceCompanyIdentifier: UInt16 = 0xEFAB |
||||
@available(iOS 18.0, *) |
private let deviceDisplayName = "语音助理耳机" |
||||
class AccessorySessionWrapper { |
|
||||
var session: ASAccessorySession |
|
||||
|
|
||||
init() { |
|
||||
self.session = ASAccessorySession() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
var accessorySessionWrapper: Any? |
// 用于存储配对设备UUID的UserDefaults键 |
||||
|
private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid" |
||||
|
|
||||
typealias DeviceDiscoveredCallback = (UUID) -> Void |
typealias DeviceDiscoveredCallback = (UUID) -> Void |
||||
private var onDeviceDiscovered: DeviceDiscoveredCallback? |
private var onDeviceDiscovered: DeviceDiscoveredCallback? |
||||
#endif |
|
||||
|
|
||||
private override init() { |
// MARK: - 初始化 |
||||
super.init() |
private override init() { super.init() } |
||||
} |
|
||||
|
|
||||
// 设置BLE服务实例 |
// MARK: - 公共方法 |
||||
func setBleService(_ service: Any) { |
func setBleService(_ service: Any) { |
||||
self.bleService = service |
self.bleService = service as? BleService |
||||
} |
} |
||||
|
|
||||
// 设置设备发现回调 |
|
||||
func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { |
func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { |
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
self.onDeviceDiscovered = callback |
||||
if #available(iOS 18.0, *) { |
} |
||||
self.onDeviceDiscovered = callback |
|
||||
|
// 处理发现的设备并连接 |
||||
|
func connectToAccessory(uuid: UUID) { |
||||
|
guard let service = bleService else { |
||||
|
return |
||||
} |
} |
||||
#endif |
|
||||
|
service.connectToAccessory(uuid: uuid) |
||||
} |
} |
||||
|
|
||||
// 初始化设备发现会话 |
func addToReconnectQueue(uuid: UUID) { |
||||
func initializeDiscoverySession() { |
guard let service = bleService else { |
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
return |
||||
if #available(iOS 18.0, *) { |
} |
||||
os_log("初始化iOS 18 AccessorySetupKit会话", log: logger, type: .info) |
|
||||
|
service.addToReconnectQueue(uuid: uuid) |
||||
// 创建会话 |
} |
||||
accessorySessionWrapper = AccessorySessionWrapper() |
|
||||
|
// 从视图控制器显示设备选择器 - 主要入口点 |
||||
// 激活会话并设置事件处理 |
func showDevicePickerFromViewController(_ viewController: UIViewController) { |
||||
if let wrapper = accessorySessionWrapper as? AccessorySessionWrapper { |
initializeSession() |
||||
wrapper.session.activate(on: .main) { [weak self] event in |
} |
||||
guard let self = self else { return } |
|
||||
|
// 清除蓝牙状态(仅用于测试或重置,不会清除系统授权) |
||||
|
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 { |
for accessory in accessories { |
||||
case .activated: |
if let uuid = accessory.bluetoothIdentifier { |
||||
os_log("AccessorySetupKit会话已激活", log: self.logger, type: .info) |
onDeviceDiscovered?(uuid) |
||||
|
connectToAccessory(uuid: uuid) |
||||
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) |
|
||||
} |
} |
||||
|
|
||||
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 let error = error { |
||||
if !wrapper.session.accessories.isEmpty { |
let nsError = error as NSError |
||||
os_log("发现%{public}d个已授权设备", log: logger, type: .info, wrapper.session.accessories.count) |
os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
||||
|
} else { |
||||
// 如果有现有设备,触发回调 |
os_log("设备选择器完成", log: self.logger, type: .info) |
||||
for accessory in wrapper.session.accessories { |
|
||||
if let uuid = accessory.bluetoothIdentifier { |
|
||||
os_log("处理已授权设备: %{public}@", log: logger, type: .info, accessory.displayName) |
|
||||
self.onDeviceDiscovered?(uuid) |
|
||||
} |
|
||||
} |
|
||||
} |
} |
||||
} |
} |
||||
} else { |
|
||||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
|
||||
} |
} |
||||
#else |
|
||||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
|
||||
#endif |
|
||||
} |
} |
||||
|
|
||||
// 显示设备选择器 |
// MARK: - 私有辅助方法 |
||||
func showDevicePicker() { |
private func handleSessionEvent(_ event: ASAccessoryEvent) { |
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
switch event.eventType { |
||||
if #available(iOS 18.0, *) { |
case .activated: |
||||
if let wrapper = accessorySessionWrapper as? AccessorySessionWrapper { |
os_log("会话已激活", log: logger, type: .info) |
||||
os_log("显示iOS 18设备选择器", 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 |
for accessory in accessories { |
||||
@available(iOS 18.0, *) |
if let uuid = accessory.bluetoothIdentifier { |
||||
func createAndShowPicker() { |
onDeviceDiscovered?(uuid) |
||||
// 根据WWDC24视频,正确的类名是ASDiscoveryDescriptor |
connectToAccessory(uuid: uuid) |
||||
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) |
|
||||
} |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
// 调用内部函数 |
|
||||
createAndShowPicker() |
|
||||
} else { |
|
||||
os_log("会话未初始化", log: logger, type: .error) |
|
||||
} |
} |
||||
} else { |
} else { |
||||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
showDevicePicker() |
||||
} |
} |
||||
#else |
|
||||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
|
||||
#endif |
|
||||
} |
} |
||||
|
|
||||
// 处理发现的设备 |
private func handleAccessoryAdded(_ accessory: ASAccessory?) { |
||||
func handleDiscoveredAccessory(uuid: UUID) { |
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
return |
||||
if #available(iOS 18.0, *) { |
} |
||||
guard let service = bleService as? BleService else { |
|
||||
os_log("BLE服务未设置", log: logger, type: .error) |
// 保存配对设备的UUID |
||||
return |
savePairedMacAddress(uuid) |
||||
} |
|
||||
|
onDeviceDiscovered?(uuid) |
||||
// 获取中央管理器 |
connectToAccessory(uuid: uuid) |
||||
let centralManager = service.getCentralManager() |
} |
||||
|
|
||||
// 通过UUID检索外设 |
private func handleAccessoryRemoved(_ accessory: ASAccessory?) { |
||||
let peripherals = centralManager.retrievePeripherals(withIdentifiers: [uuid]) |
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 { |
for output in outputs { |
||||
os_log("连接到UUID对应的设备: %{public}@", log: logger, type: .info, peripheral.name ?? uuid.uuidString) |
if output.portType == .bluetoothA2DP || |
||||
service.connect(to: peripheral) |
output.portType == .bluetoothHFP || |
||||
} else { |
output.portType == .bluetoothLE { |
||||
os_log("未找到UUID对应的设备: %{public}@", log: logger, type: .error, uuid.uuidString) |
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 |
#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 |
name: ble_service |
||||
description: BLE服务插件,提供蓝牙扫描、连接和通信功能 |
description: 蓝牙低功耗服务插件,提供蓝牙设备连接、通信功能 |
||||
version: 0.0.1 |
version: 0.0.1 |
||||
homepage: |
homepage: |
||||
|
|
||||
environment: |
environment: |
||||
sdk: ">=2.16.0 <3.0.0" |
sdk: ">=2.18.0 <4.0.0" |
||||
flutter: ">=2.5.0" |
flutter: ">=3.3.0" |
||||
|
|
||||
dependencies: |
dependencies: |
||||
flutter: |
flutter: |
||||
sdk: flutter |
sdk: flutter |
||||
|
# 添加其他依赖项 |
||||
|
|
||||
dev_dependencies: |
dev_dependencies: |
||||
flutter_test: |
flutter_test: |
||||
sdk: flutter |
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: |
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: |
plugin: |
||||
platforms: |
platforms: |
||||
android: |
android: |
||||
package: com.yunqiinnovation.ble_service |
package: com.yunqiinnovation.ble_service |
||||
pluginClass: BleServicePlugin |
pluginClass: BleServicePlugin |
||||
ios: |
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 库的更新 |
||||
@ -0,0 +1,64 @@ |
|||||
|
group 'com.yunqiinnovation.chat_api' |
||||
|
version '1.0' |
||||
|
|
||||
|
buildscript { |
||||
|
ext.kotlin_version = '1.7.10' |
||||
|
repositories { |
||||
|
google() |
||||
|
mavenCentral() |
||||
|
} |
||||
|
|
||||
|
dependencies { |
||||
|
classpath 'com.android.tools.build:gradle:7.3.0' |
||||
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
allprojects { |
||||
|
repositories { |
||||
|
google() |
||||
|
mavenCentral() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
apply plugin: 'com.android.library' |
||||
|
apply plugin: 'kotlin-android' |
||||
|
|
||||
|
android { |
||||
|
compileSdkVersion 33 |
||||
|
|
||||
|
compileOptions { |
||||
|
sourceCompatibility JavaVersion.VERSION_1_8 |
||||
|
targetCompatibility JavaVersion.VERSION_1_8 |
||||
|
} |
||||
|
|
||||
|
kotlinOptions { |
||||
|
jvmTarget = '1.8' |
||||
|
} |
||||
|
|
||||
|
sourceSets { |
||||
|
main.java.srcDirs += 'src/main/kotlin' |
||||
|
test.java.srcDirs += 'src/test/kotlin' |
||||
|
} |
||||
|
|
||||
|
defaultConfig { |
||||
|
minSdkVersion 21 |
||||
|
} |
||||
|
|
||||
|
dependencies { |
||||
|
testImplementation 'org.jetbrains.kotlin:kotlin-test' |
||||
|
testImplementation 'org.mockito:mockito-core:5.0.0' |
||||
|
} |
||||
|
|
||||
|
testOptions { |
||||
|
unitTests.all { |
||||
|
useJUnitPlatform() |
||||
|
|
||||
|
testLogging { |
||||
|
events "passed", "skipped", "failed", "standardOut", "standardError" |
||||
|
outputs.upToDateWhen {false} |
||||
|
showStandardStreams = true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||