854 changed files with 724546 additions and 9083 deletions
@ -1,10 +1,93 @@ |
|||||
1. 不要主动运行程序, 如flutter run |
# 代码简明规范 |
||||
2. 不要改动android, iOS项目的配置信息,包括SDK, NDK版本等 |
## Dart / Flutter |
||||
3. 不要使用print, 而是使用logger |
1. 风格统一 |
||||
4. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget |
• 严格遵循 Effective Dart 格式与命名惯例。 |
||||
5. 不要增加重试逻辑,出现失败直接返回错误 |
2. 日志与调试 |
||||
6. 尽可能避免使用try catch |
• 禁止 print;仅在异常场景调用 logger.e(...)。 |
||||
7. 尽可能避免打印日志,仅在出错时记录日志 |
3. 业务逻辑 |
||||
8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化 |
• 默认使用 GetX(状态、路由、依赖注入等),避免手写 StatefulWidget。 |
||||
9. 请尽可能使用中文回复问题 |
4. 异常处理 |
||||
10. 重要:不要过度设计!! |
• 不写重试逻辑,失败即返回错误。 |
||||
|
• 谨慎使用 try-catch;优先通过类型系统与空安全防御。 |
||||
|
5. 代码演进 |
||||
|
• 任何改动都应 “小步、安全”,杜绝一次性大改或过度设计。 |
||||
|
|
||||
|
## 原生层(Android / iOS) |
||||
|
1. 配置不可改 |
||||
|
• 禁止修改 SDK、NDK、JDK 与 iOS 构建配置。 |
||||
|
2. Kotlin DSL |
||||
|
• 原生实现一律采用 Kotlin + 协程,遵守 Kotlin 最佳实践。 |
||||
|
• 不写 Java 代码。 |
||||
|
|
||||
|
## 通用要求 |
||||
|
1. 不自动执行 |
||||
|
• 生成脚本/代码不得包含 flutter run 等执行指令。 |
||||
|
2. 并发优先级 |
||||
|
• 并发场景首选 Kotlin 协程;仅在 Flutter 端按需使用 Dart Future / Stream。 |
||||
|
3. 语言 |
||||
|
• 与用户交流、注释及文档一律使用中文。 |
||||
|
|
||||
|
宗旨:保持极简,先跑通业务,再考虑优化。 |
||||
|
|
||||
|
|
||||
|
# 项目架构概述 |
||||
|
DeepSound是一个基于Flutter和GetX构建的多平台语音应用,具有语音合成、语音识别、语音聊天等功能。 |
||||
|
|
||||
|
## 总体架构 |
||||
|
- 采用GetX状态管理框架 |
||||
|
- 遵循模块化设计,各功能模块相对独立 |
||||
|
- 使用依赖注入实现组件解耦 |
||||
|
- 多语言支持,使用GetX国际化方案 |
||||
|
|
||||
|
## 主要目录结构 |
||||
|
- lib/core: 核心组件、工具和通用功能 |
||||
|
- lib/data: 数据模型、服务和提供者 |
||||
|
- lib/modules: 功能模块,按业务划分 |
||||
|
- lib/routes: 路由定义和管理 |
||||
|
- local_plugins: 本地插件,提供原生功能支持 |
||||
|
|
||||
|
# 核心模块说明 |
||||
|
## 语音处理 |
||||
|
- 火山语音服务: 提供语音识别和合成核心能力 |
||||
|
- Azure语音服务: 提供备选的语音识别和合成能力 |
||||
|
- 离线TTS支持: 在线语音合成失败时的备选方案 |
||||
|
|
||||
|
## 聊天功能 |
||||
|
- 基于OpenAI的对话功能 |
||||
|
- 支持语音输入、文字输入和语音输出 |
||||
|
- 支持多种角色和语音类型 |
||||
|
|
||||
|
## 用户系统 |
||||
|
- 支持多种登录方式:Firebase、Google、Facebook |
||||
|
- 用户资料管理和设置 |
||||
|
- 主题切换和语言设置 |
||||
|
|
||||
|
## 蓝牙配对 |
||||
|
- 支持蓝牙设备发现和连接 |
||||
|
- 提供简化的配对流程 |
||||
|
- 设备管理和状态监控 |
||||
|
|
||||
|
## 音频处理 |
||||
|
- 音频录制和播放 |
||||
|
- 支持波形显示 |
||||
|
- 音量控制和音频质量设置 |
||||
|
|
||||
|
# 技术栈 |
||||
|
- Flutter: UI框架 |
||||
|
- GetX: 状态管理和依赖注入 |
||||
|
- Firebase: 用户认证和后端服务 |
||||
|
- WebSocket: 实时通信 |
||||
|
- SQLite: 本地数据存储 |
||||
|
- 原生插件: 语音识别、合成和蓝牙功能 |
||||
|
|
||||
|
# 插件说明 |
||||
|
- jieli: 杰理蓝牙设备连接和控制插件 |
||||
|
- volcano_speech: 火山语音服务插件,提供语音识别和合成 |
||||
|
- azure_speech: 微软Azure语音服务插件 |
||||
|
- open_ai_service: OpenAI服务插件,提供AI对话能力 |
||||
|
- speech_to_text: 通用语音识别插件 |
||||
|
|
||||
|
# 环境配置 |
||||
|
- 环境变量通过.env文件管理 |
||||
|
- 支持不同环境的配置切换 |
||||
|
- 敏感信息不应直接硬编码在代码中 |
||||
@ -0,0 +1,10 @@ |
|||||
|
{ |
||||
|
"mcpServers": { |
||||
|
"web-search": { |
||||
|
"url": "http://mcp.ideapsound.com:8000/sse" |
||||
|
}, |
||||
|
"amap-amap-sse": { |
||||
|
"url": "https://mcp.amap.com/sse?key=df7b0b6747c632fc38ddad22e7176646" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
{ |
||||
|
"mcpServers": { |
||||
|
"web-search": { |
||||
|
"url": "http://mcp.ideapsound.com:8000/sse" |
||||
|
}, |
||||
|
"mp3": { |
||||
|
"url": "https://api.deapsound.com/sse" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
# Miscellaneous |
||||
|
*.class |
||||
|
*.log |
||||
|
*.pyc |
||||
|
*.swp |
||||
|
.DS_Store |
||||
|
.atom/ |
||||
|
.build/ |
||||
|
.buildlog/ |
||||
|
.history |
||||
|
.svn/ |
||||
|
.swiftpm/ |
||||
|
migrate_working_dir/ |
||||
|
|
||||
|
# IntelliJ related |
||||
|
*.iml |
||||
|
*.ipr |
||||
|
*.iws |
||||
|
.idea/ |
||||
|
|
||||
|
# The .vscode folder contains launch configuration and tasks you configure in |
||||
|
# VS Code which you may wish to be included in version control, so this line |
||||
|
# is commented out by default. |
||||
|
#.vscode/ |
||||
|
|
||||
|
# Flutter/Dart/Pub related |
||||
|
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. |
||||
|
/pubspec.lock |
||||
|
**/doc/api/ |
||||
|
.dart_tool/ |
||||
|
.flutter-plugins |
||||
|
.flutter-plugins-dependencies |
||||
|
build/ |
||||
@ -0,0 +1,33 @@ |
|||||
|
# This file tracks properties of this Flutter project. |
||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc. |
||||
|
# |
||||
|
# This file should be version controlled and should not be manually edited. |
||||
|
|
||||
|
version: |
||||
|
revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1" |
||||
|
channel: "stable" |
||||
|
|
||||
|
project_type: plugin |
||||
|
|
||||
|
# Tracks metadata for the flutter migrate command |
||||
|
migration: |
||||
|
platforms: |
||||
|
- platform: root |
||||
|
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 |
||||
|
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 |
||||
|
- platform: android |
||||
|
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 |
||||
|
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 |
||||
|
- platform: ios |
||||
|
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 |
||||
|
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 |
||||
|
|
||||
|
# User provided section |
||||
|
|
||||
|
# List of Local paths (relative to this file) that should be |
||||
|
# ignored by the migrate tool. |
||||
|
# |
||||
|
# Files that are not part of the templates will be ignored by default. |
||||
|
unmanaged_files: |
||||
|
- 'lib/main.dart' |
||||
|
- 'ios/Runner.xcodeproj/project.pbxproj' |
||||
@ -0,0 +1,3 @@ |
|||||
|
## 0.0.1 |
||||
|
|
||||
|
* TODO: Describe initial release. |
||||
@ -0,0 +1 @@ |
|||||
|
TODO: Add your license here. |
||||
@ -0,0 +1,15 @@ |
|||||
|
# amap_flutter |
||||
|
|
||||
|
A new Flutter project. |
||||
|
|
||||
|
## Getting Started |
||||
|
|
||||
|
This project is a starting point for a Flutter |
||||
|
[plug-in package](https://flutter.dev/to/develop-plugins), |
||||
|
a specialized package that includes platform-specific implementation code for |
||||
|
Android and/or iOS. |
||||
|
|
||||
|
For help getting started with Flutter development, view the |
||||
|
[online documentation](https://docs.flutter.dev), which offers tutorials, |
||||
|
samples, guidance on mobile development, and a full API reference. |
||||
|
|
||||
@ -0,0 +1,4 @@ |
|||||
|
include: package:flutter_lints/flutter.yaml |
||||
|
|
||||
|
# Additional information about this file can be found at |
||||
|
# https://dart.dev/guides/language/analysis-options |
||||
@ -0,0 +1,9 @@ |
|||||
|
*.iml |
||||
|
.gradle |
||||
|
/local.properties |
||||
|
/.idea/workspace.xml |
||||
|
/.idea/libraries |
||||
|
.DS_Store |
||||
|
/build |
||||
|
/captures |
||||
|
.cxx |
||||
@ -0,0 +1,54 @@ |
|||||
|
group = "com.example.amap_flutter" |
||||
|
version = "1.0" |
||||
|
|
||||
|
buildscript { |
||||
|
repositories { |
||||
|
google() |
||||
|
mavenCentral() |
||||
|
} |
||||
|
|
||||
|
dependencies { |
||||
|
classpath("com.android.tools.build:gradle:8.7.0") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
rootProject.allprojects { |
||||
|
repositories { |
||||
|
google() |
||||
|
mavenCentral() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
apply plugin: "com.android.library" |
||||
|
|
||||
|
android { |
||||
|
namespace = "com.example.amap_flutter" |
||||
|
|
||||
|
compileSdk = 35 |
||||
|
|
||||
|
compileOptions { |
||||
|
sourceCompatibility = JavaVersion.VERSION_11 |
||||
|
targetCompatibility = JavaVersion.VERSION_11 |
||||
|
} |
||||
|
|
||||
|
defaultConfig { |
||||
|
minSdk = 21 |
||||
|
} |
||||
|
|
||||
|
dependencies { |
||||
|
implementation 'com.amap.api:3dmap-location-search:latest.integration' |
||||
|
|
||||
|
testImplementation("junit:junit:4.13.2") |
||||
|
testImplementation("org.mockito:mockito-core:5.0.0") |
||||
|
} |
||||
|
|
||||
|
testOptions { |
||||
|
unitTests.all { |
||||
|
testLogging { |
||||
|
events "passed", "skipped", "failed", "standardOut", "standardError" |
||||
|
outputs.upToDateWhen {false} |
||||
|
showStandardStreams = true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
rootProject.name = 'amap_flutter' |
||||
@ -0,0 +1,3 @@ |
|||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
package="com.example.amap_flutter"> |
||||
|
</manifest> |
||||
@ -0,0 +1,169 @@ |
|||||
|
package com.example.amap_flutter; |
||||
|
|
||||
|
import android.content.Context; |
||||
|
import androidx.annotation.NonNull; |
||||
|
import android.util.Log; |
||||
|
|
||||
|
import com.amap.api.location.AMapLocation; |
||||
|
import com.amap.api.location.AMapLocationClient; |
||||
|
import com.amap.api.location.AMapLocationClientOption; |
||||
|
import com.amap.api.location.AMapLocationListener; |
||||
|
|
||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin; |
||||
|
import io.flutter.plugin.common.EventChannel; |
||||
|
import io.flutter.plugin.common.MethodCall; |
||||
|
import io.flutter.plugin.common.MethodChannel; |
||||
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler; |
||||
|
import io.flutter.plugin.common.MethodChannel.Result; |
||||
|
|
||||
|
import java.util.HashMap; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
/** AmapFlutterPlugin */ |
||||
|
public class AmapFlutterPlugin implements FlutterPlugin, MethodCallHandler { |
||||
|
/// The MethodChannel that will the communication between Flutter and native Android
|
||||
|
///
|
||||
|
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
|
||||
|
/// when the Flutter Engine is detached from the Activity
|
||||
|
private MethodChannel methodChannel; |
||||
|
private EventChannel eventChannel; |
||||
|
private Context applicationContext; |
||||
|
private AMapLocationClient mLocationClient; |
||||
|
private EventChannel.EventSink eventSink; |
||||
|
|
||||
|
@Override |
||||
|
public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { |
||||
|
applicationContext = flutterPluginBinding.getApplicationContext(); |
||||
|
methodChannel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "amap_flutter"); |
||||
|
methodChannel.setMethodCallHandler(this); |
||||
|
|
||||
|
// 初始化EventChannel
|
||||
|
eventChannel = new EventChannel(flutterPluginBinding.getBinaryMessenger(), "amap_flutter/events"); |
||||
|
eventChannel.setStreamHandler(new EventChannel.StreamHandler() { |
||||
|
@Override |
||||
|
public void onListen(Object arguments, EventChannel.EventSink events) { |
||||
|
eventSink = events; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onCancel(Object arguments) { |
||||
|
eventSink = null; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { |
||||
|
switch (call.method) { |
||||
|
case "setApiKey": |
||||
|
setApiKey(call, result); |
||||
|
break; |
||||
|
case "initLocation": |
||||
|
initLocation(result); |
||||
|
break; |
||||
|
case "startLocation": |
||||
|
startLocation(result); |
||||
|
break; |
||||
|
case "stopLocation": |
||||
|
stopLocation(result); |
||||
|
break; |
||||
|
case "dispose": |
||||
|
dispose(result); |
||||
|
break; |
||||
|
default: |
||||
|
result.notImplemented(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void setApiKey(MethodCall call, Result result) { |
||||
|
try { |
||||
|
String apiKey = call.argument("androidKey"); |
||||
|
AMapLocationClient.updatePrivacyShow(applicationContext, true, true); |
||||
|
AMapLocationClient.updatePrivacyAgree(applicationContext, true); |
||||
|
result.success(null); |
||||
|
} catch (Exception e) { |
||||
|
result.error("INIT_ERROR", "初始化失败", e.getMessage()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void initLocation(Result result) { |
||||
|
try { |
||||
|
mLocationClient = new AMapLocationClient(applicationContext); |
||||
|
|
||||
|
// 配置定位参数
|
||||
|
AMapLocationClientOption option = new AMapLocationClientOption(); |
||||
|
option.setOnceLocation(true); |
||||
|
option.setNeedAddress(true); |
||||
|
mLocationClient.setLocationOption(option); |
||||
|
|
||||
|
mLocationClient.setLocationListener(mLocationListener); |
||||
|
result.success(null); |
||||
|
} catch (Exception e) { |
||||
|
result.error("INIT_ERROR", "初始化失败", e.getMessage()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void startLocation(Result result) { |
||||
|
mLocationClient.startLocation(); |
||||
|
result.success(null); |
||||
|
} |
||||
|
|
||||
|
private void stopLocation(Result result) { |
||||
|
destroyLocationClient(); |
||||
|
result.success(null); |
||||
|
} |
||||
|
|
||||
|
private void dispose(Result result) { |
||||
|
try { |
||||
|
destroyLocationClient(); |
||||
|
result.success(null); |
||||
|
} catch (Exception e) { |
||||
|
result.error("DISPOSE_ERROR", "资源释放失败", e.getMessage()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void destroyLocationClient() { |
||||
|
if (mLocationClient != null) { |
||||
|
try { |
||||
|
// 停止定位
|
||||
|
mLocationClient.stopLocation(); |
||||
|
// 销毁定位客户端
|
||||
|
mLocationClient.onDestroy(); |
||||
|
mLocationClient = null; |
||||
|
|
||||
|
// 关闭事件通道
|
||||
|
if (eventSink != null) { |
||||
|
eventSink.endOfStream(); |
||||
|
eventSink = null; |
||||
|
} |
||||
|
} catch (Exception e) { |
||||
|
Log.e("AmapFlutter", "销毁定位客户端失败", e); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
AMapLocationListener mLocationListener = new AMapLocationListener() { |
||||
|
@Override |
||||
|
public void onLocationChanged(AMapLocation amapLocation) { |
||||
|
if (eventSink == null) return; |
||||
|
|
||||
|
if (amapLocation != null) { |
||||
|
if (amapLocation.getErrorCode() == 0) { |
||||
|
Map<String, Object> locationData = new HashMap<>(); |
||||
|
locationData.put("latitude", amapLocation.getLatitude()); |
||||
|
locationData.put("longitude", amapLocation.getLongitude()); |
||||
|
locationData.put("city", amapLocation.getCity()); |
||||
|
locationData.put("address", amapLocation.getAddress()); |
||||
|
eventSink.success(locationData); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
@Override |
||||
|
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { |
||||
|
methodChannel.setMethodCallHandler(null); |
||||
|
eventChannel.setStreamHandler(null); |
||||
|
destroyLocationClient(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,38 @@ |
|||||
|
.idea/ |
||||
|
.vagrant/ |
||||
|
.sconsign.dblite |
||||
|
.svn/ |
||||
|
|
||||
|
.DS_Store |
||||
|
*.swp |
||||
|
profile |
||||
|
|
||||
|
DerivedData/ |
||||
|
build/ |
||||
|
GeneratedPluginRegistrant.h |
||||
|
GeneratedPluginRegistrant.m |
||||
|
|
||||
|
.generated/ |
||||
|
|
||||
|
*.pbxuser |
||||
|
*.mode1v3 |
||||
|
*.mode2v3 |
||||
|
*.perspectivev3 |
||||
|
|
||||
|
!default.pbxuser |
||||
|
!default.mode1v3 |
||||
|
!default.mode2v3 |
||||
|
!default.perspectivev3 |
||||
|
|
||||
|
xcuserdata |
||||
|
|
||||
|
*.moved-aside |
||||
|
|
||||
|
*.pyc |
||||
|
*sync/ |
||||
|
Icon? |
||||
|
.tags* |
||||
|
|
||||
|
/Flutter/Generated.xcconfig |
||||
|
/Flutter/ephemeral/ |
||||
|
/Flutter/flutter_export_environment.sh |
||||
@ -0,0 +1,19 @@ |
|||||
|
import Flutter |
||||
|
import UIKit |
||||
|
|
||||
|
public class AmapFlutterPlugin: NSObject, FlutterPlugin { |
||||
|
public static func register(with registrar: FlutterPluginRegistrar) { |
||||
|
let channel = FlutterMethodChannel(name: "amap_flutter", binaryMessenger: registrar.messenger()) |
||||
|
let instance = AmapFlutterPlugin() |
||||
|
registrar.addMethodCallDelegate(instance, channel: channel) |
||||
|
} |
||||
|
|
||||
|
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
||||
|
switch call.method { |
||||
|
case "getPlatformVersion": |
||||
|
result("iOS " + UIDevice.current.systemVersion) |
||||
|
default: |
||||
|
result(FlutterMethodNotImplemented) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
<?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>NSPrivacyTrackingDomains</key> |
||||
|
<array/> |
||||
|
<key>NSPrivacyAccessedAPITypes</key> |
||||
|
<array/> |
||||
|
<key>NSPrivacyCollectedDataTypes</key> |
||||
|
<array/> |
||||
|
<key>NSPrivacyTracking</key> |
||||
|
<false/> |
||||
|
</dict> |
||||
|
</plist> |
||||
@ -0,0 +1,29 @@ |
|||||
|
# |
||||
|
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. |
||||
|
# Run `pod lib lint amap_flutter.podspec` to validate before publishing. |
||||
|
# |
||||
|
Pod::Spec.new do |s| |
||||
|
s.name = 'amap_flutter' |
||||
|
s.version = '0.0.1' |
||||
|
s.summary = 'A new Flutter project.' |
||||
|
s.description = <<-DESC |
||||
|
A new Flutter project. |
||||
|
DESC |
||||
|
s.homepage = 'http://example.com' |
||||
|
s.license = { :file => '../LICENSE' } |
||||
|
s.author = { 'Your Company' => 'email@example.com' } |
||||
|
s.source = { :path => '.' } |
||||
|
s.source_files = 'Classes/**/*' |
||||
|
s.dependency 'Flutter' |
||||
|
s.platform = :ios, '12.0' |
||||
|
|
||||
|
# Flutter.framework does not contain a i386 slice. |
||||
|
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } |
||||
|
s.swift_version = '5.0' |
||||
|
|
||||
|
# If your plugin requires a privacy manifest, for example if it uses any |
||||
|
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your |
||||
|
# plugin's privacy impact, and then uncomment this line. For more information, |
||||
|
# see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files |
||||
|
# s.resource_bundles = {'amap_flutter_privacy' => ['Resources/PrivacyInfo.xcprivacy']} |
||||
|
end |
||||
@ -0,0 +1,36 @@ |
|||||
|
import 'amap_flutter_platform_interface.dart'; |
||||
|
|
||||
|
class AmapFlutter { |
||||
|
// 初始化SDK |
||||
|
Future<void> initSdk({String? iosKey, String? androidKey}) async { |
||||
|
await AmapFlutterPlatform.instance.setApiKey( |
||||
|
iosKey: iosKey, |
||||
|
androidKey: androidKey, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 定位初始化 |
||||
|
Future<void> initLocation() async { |
||||
|
return AmapFlutterPlatform.instance.initLocation(); |
||||
|
} |
||||
|
|
||||
|
// 开始定位 |
||||
|
Future<void> startLocation() async { |
||||
|
return AmapFlutterPlatform.instance.startLocation(); |
||||
|
} |
||||
|
|
||||
|
// 监听定位返回位置 |
||||
|
Stream<dynamic> locationStream() { |
||||
|
return AmapFlutterPlatform.instance.locationStream(); |
||||
|
} |
||||
|
|
||||
|
// 停止定位 |
||||
|
Future<void> stopLocation() async { |
||||
|
await AmapFlutterPlatform.instance.stopLocation(); |
||||
|
} |
||||
|
|
||||
|
// 销毁 |
||||
|
Future<void> dispose() async { |
||||
|
await AmapFlutterPlatform.instance.dispose(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,45 @@ |
|||||
|
import 'package:flutter/foundation.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
|
||||
|
import 'amap_flutter_platform_interface.dart'; |
||||
|
|
||||
|
/// An implementation of [AmapFlutterPlatform] that uses method channels. |
||||
|
class MethodChannelAmapFlutter extends AmapFlutterPlatform { |
||||
|
/// The method channel used to interact with the native platform. |
||||
|
@visibleForTesting |
||||
|
final methodChannel = const MethodChannel('amap_flutter'); |
||||
|
final eventChannel = const EventChannel('amap_flutter/events'); |
||||
|
|
||||
|
@override |
||||
|
Future<void> setApiKey({String? iosKey, String? androidKey}) async { |
||||
|
await methodChannel.invokeMethod('setApiKey', { |
||||
|
"iosKey": iosKey, |
||||
|
"androidKey": androidKey, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<void> initLocation() async { |
||||
|
return await methodChannel.invokeMethod('initLocation'); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<void> startLocation() async { |
||||
|
return await methodChannel.invokeMethod('startLocation'); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Stream<dynamic> locationStream() { |
||||
|
return eventChannel.receiveBroadcastStream(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<void> stopLocation() async { |
||||
|
await methodChannel.invokeMethod('stopLocation'); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<void> dispose() async { |
||||
|
await methodChannel.invokeMethod('dispose'); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
import 'package:plugin_platform_interface/plugin_platform_interface.dart'; |
||||
|
|
||||
|
import 'amap_flutter_method_channel.dart'; |
||||
|
|
||||
|
abstract class AmapFlutterPlatform extends PlatformInterface { |
||||
|
/// Constructs a AmapFlutterPlatform. |
||||
|
AmapFlutterPlatform() : super(token: _token); |
||||
|
|
||||
|
static final Object _token = Object(); |
||||
|
|
||||
|
static AmapFlutterPlatform _instance = MethodChannelAmapFlutter(); |
||||
|
|
||||
|
/// The default instance of [AmapFlutterPlatform] to use. |
||||
|
/// |
||||
|
/// Defaults to [MethodChannelAmapFlutter]. |
||||
|
static AmapFlutterPlatform get instance => _instance; |
||||
|
|
||||
|
/// Platform-specific implementations should set this with their own |
||||
|
/// platform-specific class that extends [AmapFlutterPlatform] when |
||||
|
/// they register themselves. |
||||
|
static set instance(AmapFlutterPlatform instance) { |
||||
|
PlatformInterface.verifyToken(instance, _token); |
||||
|
_instance = instance; |
||||
|
} |
||||
|
|
||||
|
Future<void> setApiKey({String? iosKey, String? androidKey}) { |
||||
|
throw UnimplementedError('init() has not been implemented.'); |
||||
|
} |
||||
|
|
||||
|
Future<void> initLocation() { |
||||
|
throw UnimplementedError('initLocation() has not been implemented.'); |
||||
|
} |
||||
|
|
||||
|
Future<void> startLocation() { |
||||
|
throw UnimplementedError('startLocation() has not been implemented.'); |
||||
|
} |
||||
|
|
||||
|
Stream<dynamic> locationStream() { |
||||
|
throw UnimplementedError('locationStream() has not been implemented.'); |
||||
|
} |
||||
|
|
||||
|
Future<void> stopLocation() { |
||||
|
throw UnimplementedError('stopLocation() has not been implemented.'); |
||||
|
} |
||||
|
|
||||
|
Future<void> dispose() { |
||||
|
throw UnimplementedError('dispose() has not been implemented.'); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,72 @@ |
|||||
|
name: amap_flutter |
||||
|
description: "A new Flutter project." |
||||
|
version: 0.0.1 |
||||
|
homepage: |
||||
|
|
||||
|
environment: |
||||
|
sdk: ^3.7.0 |
||||
|
flutter: '>=3.3.0' |
||||
|
|
||||
|
dependencies: |
||||
|
flutter: |
||||
|
sdk: flutter |
||||
|
plugin_platform_interface: ^2.0.2 |
||||
|
|
||||
|
dev_dependencies: |
||||
|
flutter_test: |
||||
|
sdk: flutter |
||||
|
flutter_lints: ^5.0.0 |
||||
|
|
||||
|
# For information on the generic Dart part of this file, see the |
||||
|
# following page: https://dart.dev/tools/pub/pubspec |
||||
|
|
||||
|
# The following section is specific to Flutter packages. |
||||
|
flutter: |
||||
|
# This section identifies this Flutter project as a plugin project. |
||||
|
# The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) |
||||
|
# which should be registered in the plugin registry. This is required for |
||||
|
# using method channels. |
||||
|
# The Android 'package' specifies package in which the registered class is. |
||||
|
# This is required for using method channels on Android. |
||||
|
# The 'ffiPlugin' specifies that native code should be built and bundled. |
||||
|
# This is required for using `dart:ffi`. |
||||
|
# All these are used by the tooling to maintain consistency when |
||||
|
# adding or updating assets for this project. |
||||
|
plugin: |
||||
|
platforms: |
||||
|
android: |
||||
|
package: com.example.amap_flutter |
||||
|
pluginClass: AmapFlutterPlugin |
||||
|
ios: |
||||
|
pluginClass: AmapFlutterPlugin |
||||
|
|
||||
|
# 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/to/asset-from-package |
||||
|
# |
||||
|
# An image asset can refer to one or more resolution-specific "variants", see |
||||
|
# https://flutter.dev/to/resolution-aware-images |
||||
|
|
||||
|
# 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/to/font-from-package |
||||
@ -0,0 +1,87 @@ |
|||||
|
# Flutter Framework |
||||
|
# 这些规则保留了 Flutter 引擎和插件系统正常运行所需的类。 |
||||
|
-ignorewarnings |
||||
|
-keep class io.flutter.app.** { *; } |
||||
|
-keep class io.flutter.plugin.** { *; } |
||||
|
-keep class io.flutter.util.** { *; } |
||||
|
-keep class io.flutter.view.** { *; } |
||||
|
-keep class io.flutter.embedding.android.** { *; } |
||||
|
-keep class io.flutter.embedding.engine.** { *; } |
||||
|
-keep class io.flutter.embedding.engine.plugins.** { *; } |
||||
|
-keep class io.flutter.plugin.common.** { *; } |
||||
|
-keep class io.flutter.plugins.** { *; } |
||||
|
-keep class io.flutter.plugin.platform.** { *; } |
||||
|
|
||||
|
# 保留你的应用程序的主 Activity。 |
||||
|
# !!! 请将 com.example.deep_voice.MainActivity 替换为你的实际包名和 Activity 类名 !!! |
||||
|
-keep class com.yunqiinnovation.deepsound.MainActivity { *; } |
||||
|
|
||||
|
# 保留 open_ai_service 包下的所有类 |
||||
|
-keep class com.yunqiinnovation.open_ai_service.** { *; } |
||||
|
|
||||
|
# 保留 MCPClient 相关类 |
||||
|
-keep class com.yunqiinnovation.open_ai_service.MCPClient { *; } |
||||
|
-keep class com.yunqiinnovation.open_ai_service.MCPSubClient { *; } |
||||
|
-keep class com.yunqiinnovation.open_ai_service.FunctionHandler { *; } |
||||
|
-keep class com.yunqiinnovation.open_ai_service.ToolType { *; } |
||||
|
-keep class com.yunqiinnovation.open_ai_service.mcp.** { *; } |
||||
|
-keep class com.yunqiinnovation.open_ai_service.mcp.CustomSseClientTransport { *; } |
||||
|
-keep class com.yunqiinnovation.open_ai_service.SystemFunctionHandler { *; } |
||||
|
-keep class com.yunqiinnovation.open_ai_service.OpenAIService { *; } |
||||
|
-keep class com.yunqiinnovation.open_ai_service.OpenAIService$* { *; } |
||||
|
|
||||
|
# 保留 OpenAIServicePlugin 类 |
||||
|
-keep class com.yunqiinnovation.open_ai_service.OpenAIServicePlugin { *; } |
||||
|
|
||||
|
# 保留 Jackson 相关类 |
||||
|
-keep class com.fasterxml.jackson.** { *; } |
||||
|
-keep class com.fasterxml.jackson.databind.** { *; } |
||||
|
-keep class com.fasterxml.jackson.core.** { *; } |
||||
|
|
||||
|
# 保留 Ktor 相关类 |
||||
|
-keep class io.ktor.** { *; } |
||||
|
-keep class kotlinx.coroutines.** { *; } |
||||
|
-keep class kotlinx.serialization.** { *; } |
||||
|
|
||||
|
# 如果你的应用直接使用了 AndroidX 的某些特定类(例如,通过原生代码或插件),可能需要保留它们。 |
||||
|
# 例如,保留所有 Parcelable 实现类,如果它们被跨进程使用或序列化。 |
||||
|
# -keep class * implements android.os.Parcelable { |
||||
|
# public static final android.os.Parcelable$Creator *; |
||||
|
# } |
||||
|
|
||||
|
# 保留 MCP 相关依赖库 |
||||
|
-keep class io.modelcontextprotocol.** { *; } |
||||
|
-keep class com.anthropic.** { *; } |
||||
|
|
||||
|
# 保留 OkHttp 相关类 |
||||
|
-keep class okhttp3.** { *; } |
||||
|
-keep class okio.** { *; } |
||||
|
|
||||
|
# 如果你使用了特定的第三方库,并且这些库需要特定的 ProGuard 规则,请在此处添加。 |
||||
|
# 例如,OkHttp/Retrofit, Gson, Glide 等通常需要保留模型类或特定方法。 |
||||
|
# 查看库的官方文档通常会提供所需的 ProGuard 规则。 |
||||
|
|
||||
|
# -keepclasseswithmembers class * { |
||||
|
# @android.webkit.JavascriptInterface <methods>; |
||||
|
# } |
||||
|
|
||||
|
# -keepattributes Signature |
||||
|
# -keepattributes *Annotation* |
||||
|
|
||||
|
# 如果你有自定义的原生插件,需要保留它们的类。 |
||||
|
# !!! 将 com.yourcompany.yourplugin.** 替换为你的插件的实际包名 !!! |
||||
|
# -keep class com.yourcompany.yourplugin.** { *; } |
||||
|
|
||||
|
# 保留枚举类,防止 switch 语句因混淆出错 |
||||
|
-keepclassmembers enum * { |
||||
|
public static **[] values(); |
||||
|
public static ** valueOf(java.lang.String); |
||||
|
} |
||||
|
|
||||
|
# 保留 R 类及其内部类,防止资源访问出错 |
||||
|
-keep class **.R$* { *; } |
||||
|
|
||||
|
# 保留 native 方法 |
||||
|
-keepclasseswithmembernames class * { |
||||
|
native <methods>; |
||||
|
} |
||||
@ -1,7 +0,0 @@ |
|||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
|
||||
<!-- The INTERNET permission is required for development. Specifically, |
|
||||
the Flutter tool needs it to communicate with the running application |
|
||||
to allow setting breakpoints, to provide hot reload, etc. |
|
||||
--> |
|
||||
<uses-permission android:name="android.permission.INTERNET"/> |
|
||||
</manifest> |
|
||||
@ -1,654 +0,0 @@ |
|||||
package com.yunqiinnovation.deepsound |
|
||||
|
|
||||
import android.content.Context |
|
||||
import android.media.AudioAttributes |
|
||||
import android.media.AudioFormat |
|
||||
import android.media.AudioRecord |
|
||||
import android.media.MediaRecorder |
|
||||
import android.media.audiofx.AcousticEchoCanceler |
|
||||
import android.media.audiofx.NoiseSuppressor |
|
||||
import android.media.audiofx.AutomaticGainControl |
|
||||
import android.os.Process |
|
||||
import com.yunqiinnovation.deepsound.core.utils.FileLogger |
|
||||
import com.microsoft.cognitiveservices.speech.* |
|
||||
import com.microsoft.cognitiveservices.speech.audio.* |
|
||||
import com.microsoft.cognitiveservices.speech.util.EventHandler |
|
||||
import java.util.concurrent.ExecutionException |
|
||||
import java.util.concurrent.atomic.AtomicBoolean |
|
||||
// 移除WebRTC相关导入 |
|
||||
|
|
||||
class AzureAsrHelper(private val context: Context) { |
|
||||
private var recognizer: SpeechRecognizer? = null |
|
||||
private var speechConfig: SpeechConfig? = null |
|
||||
private val TAG = "AzureAsrHelper" |
|
||||
private var isContinuousRecognitionActive = false |
|
||||
private var currentLanguage = "zh-CN" |
|
||||
private var subscriptionKey = "" |
|
||||
private var serviceRegion = "" |
|
||||
private var isAutoDetectLanguage = false |
|
||||
private var supportedLanguages = arrayOf("zh-CN", "en-US") |
|
||||
|
|
||||
// 是否使用回音消除 - 内部控制常量 |
|
||||
private val useEchoCancellation = true |
|
||||
|
|
||||
// 自定义音频处理相关 |
|
||||
private var customAudioProcessor: CustomAudioProcessor? = null |
|
||||
private var pushStream: PushAudioInputStream? = null |
|
||||
private var audioConfig: AudioConfig? = null |
|
||||
|
|
||||
// 初始化SDK并创建recognizer |
|
||||
fun initialize(subscriptionKey: String, serviceRegion: String, |
|
||||
supportedLanguages: Array<String> = arrayOf("zh-CN", "en-US")): Boolean { |
|
||||
try { |
|
||||
FileLogger.d(TAG, "初始化 Azure 语音服务") |
|
||||
|
|
||||
// 检查配置是否为空 |
|
||||
if (subscriptionKey.isEmpty() || serviceRegion.isEmpty()) { |
|
||||
FileLogger.e(TAG, "Azure 配置信息不完整") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 释放之前的资源 |
|
||||
dispose() |
|
||||
|
|
||||
this.subscriptionKey = subscriptionKey |
|
||||
this.serviceRegion = serviceRegion |
|
||||
|
|
||||
// 设置语言 |
|
||||
if (supportedLanguages.isNotEmpty()) { |
|
||||
this.supportedLanguages = supportedLanguages |
|
||||
} |
|
||||
|
|
||||
// 根据支持的语言数量决定是否启用自动语言检测 |
|
||||
this.isAutoDetectLanguage = supportedLanguages.size >= 2 |
|
||||
|
|
||||
// 如果只有一种语言,设置为当前语言 |
|
||||
if (!isAutoDetectLanguage && supportedLanguages.isNotEmpty()) { |
|
||||
this.currentLanguage = supportedLanguages[0] |
|
||||
} |
|
||||
|
|
||||
// 创建语音配置 |
|
||||
speechConfig = SpeechConfig.fromSubscription(subscriptionKey, serviceRegion) |
|
||||
|
|
||||
// 设置语言配置 |
|
||||
if (isAutoDetectLanguage) { |
|
||||
// 设置自动语言检测 |
|
||||
speechConfig?.setProperty(PropertyId.SpeechServiceConnection_LanguageIdMode, "Continuous") |
|
||||
} else { |
|
||||
// 设置指定的识别语言 |
|
||||
speechConfig?.speechRecognitionLanguage = currentLanguage |
|
||||
} |
|
||||
|
|
||||
// 创建识别器 |
|
||||
try { |
|
||||
if (useEchoCancellation) { |
|
||||
// 如果使用回音消除,创建自定义音频输入流 |
|
||||
setupCustomAudioProcessing() |
|
||||
|
|
||||
if (isAutoDetectLanguage) { |
|
||||
val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) |
|
||||
recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) |
|
||||
} else { |
|
||||
recognizer = SpeechRecognizer(speechConfig, audioConfig) |
|
||||
} |
|
||||
} else { |
|
||||
// 使用默认麦克风输入 |
|
||||
if (isAutoDetectLanguage) { |
|
||||
val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) |
|
||||
recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) |
|
||||
} else { |
|
||||
recognizer = SpeechRecognizer(speechConfig) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
FileLogger.d(TAG, "Azure 语音服务初始化成功") |
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "创建识别器失败: ${e.message}") |
|
||||
stopCustomAudioProcessing() |
|
||||
return false |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "初始化失败: ${e.message}") |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 重置 recognizer |
|
||||
private fun resetRecognizer(): Boolean { |
|
||||
try { |
|
||||
// 释放之前的 recognizer |
|
||||
recognizer?.close() |
|
||||
recognizer = null |
|
||||
|
|
||||
// 停止当前的音频处理 |
|
||||
stopCustomAudioProcessing() |
|
||||
|
|
||||
// 使用现有配置重新创建 recognizer |
|
||||
if (speechConfig != null) { |
|
||||
if (useEchoCancellation) { |
|
||||
// 如果使用回音消除,创建自定义音频输入流 |
|
||||
setupCustomAudioProcessing() |
|
||||
|
|
||||
if (isAutoDetectLanguage) { |
|
||||
val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) |
|
||||
recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) |
|
||||
} else { |
|
||||
recognizer = SpeechRecognizer(speechConfig, audioConfig) |
|
||||
} |
|
||||
} else { |
|
||||
// 使用默认麦克风输入 |
|
||||
if (isAutoDetectLanguage) { |
|
||||
val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) |
|
||||
recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) |
|
||||
} else { |
|
||||
recognizer = SpeechRecognizer(speechConfig) |
|
||||
} |
|
||||
} |
|
||||
return true |
|
||||
} else { |
|
||||
FileLogger.e(TAG, "语音配置未初始化") |
|
||||
return false |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "重置识别器失败: ${e.message}") |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 开始一次性语音识别 |
|
||||
fun recognizeOnce(callback: RecognizeCallback) { |
|
||||
if (speechConfig == null) { |
|
||||
callback.onError("语音服务未初始化") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 重置 recognizer |
|
||||
if (!resetRecognizer()) { |
|
||||
callback.onError("重置识别器失败") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 启动音频处理 |
|
||||
startCustomAudioProcessing() |
|
||||
|
|
||||
// 执行识别 |
|
||||
val result = recognizer?.recognizeOnceAsync()?.get() |
|
||||
|
|
||||
// 停止音频处理 |
|
||||
stopCustomAudioProcessing() |
|
||||
|
|
||||
if (result != null && result.reason == ResultReason.RecognizedSpeech) { |
|
||||
val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(result)?.language |
|
||||
callback.onResult(result.text, detectedLanguage ?: "") |
|
||||
} else { |
|
||||
callback.onError("未能识别语音") |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
// 停止音频处理 |
|
||||
stopCustomAudioProcessing() |
|
||||
callback.onError("识别异常: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 开始连续语音识别 |
|
||||
fun startContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { |
|
||||
if (speechConfig == null) { |
|
||||
callback.onError("语音服务未初始化") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 如果已经在进行连续识别,先停止 |
|
||||
if (isContinuousRecognitionActive) { |
|
||||
stopContinuousRecognition(callback) |
|
||||
} |
|
||||
|
|
||||
// 重置 recognizer |
|
||||
if (!resetRecognizer()) { |
|
||||
callback.onError("重置识别器失败") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 启动音频处理 |
|
||||
startCustomAudioProcessing() |
|
||||
|
|
||||
// 设置识别事件处理 |
|
||||
// 最终识别结果 |
|
||||
recognizer?.recognized?.addEventListener( |
|
||||
EventHandler<SpeechRecognitionEventArgs> { _, event -> |
|
||||
if (event.result.reason == ResultReason.RecognizedSpeech) { |
|
||||
val detectedLanguage = if (isAutoDetectLanguage) { |
|
||||
AutoDetectSourceLanguageResult.fromResult(event.result)?.language ?: "" |
|
||||
} else { |
|
||||
currentLanguage |
|
||||
} |
|
||||
// FileLogger.d(TAG, "最终识别结果: ${event.result.text}") |
|
||||
callback.onResult(event.result.text, detectedLanguage) |
|
||||
} |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
// 识别中事件 |
|
||||
recognizer?.recognizing?.addEventListener( |
|
||||
EventHandler<SpeechRecognitionEventArgs> { _, event -> |
|
||||
if (event.result.reason == ResultReason.RecognizingSpeech) { |
|
||||
val detectedLanguage = if (isAutoDetectLanguage) { |
|
||||
AutoDetectSourceLanguageResult.fromResult(event.result)?.language ?: "" |
|
||||
} else { |
|
||||
currentLanguage |
|
||||
} |
|
||||
// FileLogger.d(TAG, "识别中结果: ${event.result.text}") |
|
||||
callback.onRecognizing(event.result.text, detectedLanguage) |
|
||||
} |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
// 会话事件 |
|
||||
recognizer?.sessionStarted?.addEventListener( |
|
||||
EventHandler<SessionEventArgs> { _, _ -> |
|
||||
isContinuousRecognitionActive = true |
|
||||
callback.onSessionStarted() |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
recognizer?.sessionStopped?.addEventListener( |
|
||||
EventHandler<SessionEventArgs> { _, _ -> |
|
||||
isContinuousRecognitionActive = false |
|
||||
callback.onSessionStopped() |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
// 取消事件 |
|
||||
recognizer?.canceled?.addEventListener( |
|
||||
EventHandler<SpeechRecognitionCanceledEventArgs> { _, event -> |
|
||||
val errorDetails = if (event.reason == CancellationReason.Error) event.errorDetails else "" |
|
||||
callback.onCanceled(event.reason.toString(), errorDetails) |
|
||||
isContinuousRecognitionActive = false |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
// 开始连续识别 |
|
||||
recognizer?.startContinuousRecognitionAsync()?.get() |
|
||||
isContinuousRecognitionActive = true |
|
||||
|
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
callback.onError("开始连续识别失败: ${e.message}") |
|
||||
isContinuousRecognitionActive = false |
|
||||
stopCustomAudioProcessing() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 停止连续语音识别 |
|
||||
fun stopContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { |
|
||||
if (!isContinuousRecognitionActive || recognizer == null) { |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
recognizer?.stopContinuousRecognitionAsync() |
|
||||
isContinuousRecognitionActive = false |
|
||||
callback.onSessionStopped() |
|
||||
|
|
||||
// 停止音频处理 |
|
||||
stopCustomAudioProcessing() |
|
||||
|
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
callback.onError("停止连续识别失败: ${e.message}") |
|
||||
isContinuousRecognitionActive = false |
|
||||
stopCustomAudioProcessing() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 检查连续识别是否活跃 |
|
||||
fun isContinuousRecognitionActive(): Boolean { |
|
||||
return isContinuousRecognitionActive |
|
||||
} |
|
||||
|
|
||||
// 设置自定义音频处理 |
|
||||
private fun setupCustomAudioProcessing() { |
|
||||
if (!useEchoCancellation) { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 1. 创建PushAudioInputStream |
|
||||
pushStream = PushAudioInputStream.create() |
|
||||
|
|
||||
// 2. 创建AudioConfig |
|
||||
audioConfig = AudioConfig.fromStreamInput(pushStream) |
|
||||
|
|
||||
// 3. 创建自定义音频处理器 |
|
||||
customAudioProcessor = CustomAudioProcessor(pushStream) |
|
||||
|
|
||||
FileLogger.d(TAG, "自定义音频处理设置完成") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "设置自定义音频处理失败: ${e.message}") |
|
||||
releaseCustomAudioProcessing() |
|
||||
|
|
||||
// 降级处理:如果自定义处理设置失败,尝试使用默认麦克风 |
|
||||
try { |
|
||||
FileLogger.d(TAG, "尝试降级到默认麦克风输入") |
|
||||
audioConfig = AudioConfig.fromDefaultMicrophoneInput() |
|
||||
} catch (e2: Exception) { |
|
||||
FileLogger.e(TAG, "默认麦克风输入设置也失败: ${e2.message}") |
|
||||
audioConfig = null |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 启动自定义音频处理 |
|
||||
private fun startCustomAudioProcessing() { |
|
||||
if (!useEchoCancellation || customAudioProcessor == null) { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
customAudioProcessor?.startRecording() |
|
||||
FileLogger.d(TAG, "自定义音频处理已启动") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "启动自定义音频处理失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 停止自定义音频处理 |
|
||||
private fun stopCustomAudioProcessing() { |
|
||||
if (!useEchoCancellation || customAudioProcessor == null) { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
customAudioProcessor?.stopRecording() |
|
||||
FileLogger.d(TAG, "自定义音频处理已停止") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "停止自定义音频处理失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 释放自定义音频处理资源 |
|
||||
private fun releaseCustomAudioProcessing() { |
|
||||
stopCustomAudioProcessing() |
|
||||
|
|
||||
try { |
|
||||
customAudioProcessor = null |
|
||||
pushStream?.close() |
|
||||
pushStream = null |
|
||||
audioConfig?.close() |
|
||||
audioConfig = null |
|
||||
|
|
||||
FileLogger.d(TAG, "自定义音频处理资源已释放") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "释放自定义音频处理资源时出错: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 释放所有资源 |
|
||||
fun dispose() { |
|
||||
try { |
|
||||
// 停止和释放音频处理 |
|
||||
releaseCustomAudioProcessing() |
|
||||
|
|
||||
recognizer?.close() |
|
||||
recognizer = null |
|
||||
|
|
||||
speechConfig?.close() |
|
||||
speechConfig = null |
|
||||
|
|
||||
isContinuousRecognitionActive = false |
|
||||
|
|
||||
FileLogger.d(TAG, "语音识别资源已释放") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "释放资源出错: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 自定义音频处理器 - 使用Android原生回音消除 |
|
||||
private inner class CustomAudioProcessor(private val pushStream: PushAudioInputStream?) { |
|
||||
private val SAMPLE_RATE = 16000 |
|
||||
private val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO |
|
||||
private val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT |
|
||||
private val BUFFER_SIZE = SAMPLE_RATE * 2 // 简化缓冲区大小计算,更稳定 |
|
||||
|
|
||||
private var audioRecord: AudioRecord? = null |
|
||||
private var echoCanceler: AcousticEchoCanceler? = null |
|
||||
private val isRecording = AtomicBoolean(false) |
|
||||
private var recordingThread: Thread? = null |
|
||||
|
|
||||
// 启动录音并处理音频数据 |
|
||||
fun startRecording() { |
|
||||
if (isRecording.get() || pushStream == null) { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 使用Builder模式构建AudioFormat |
|
||||
val audioFormat = AudioFormat.Builder() |
|
||||
.setSampleRate(SAMPLE_RATE) |
|
||||
.setEncoding(AUDIO_FORMAT) |
|
||||
.setChannelMask(CHANNEL_CONFIG) |
|
||||
.build() |
|
||||
|
|
||||
// 使用Builder模式创建AudioRecord实例 |
|
||||
audioRecord = AudioRecord.Builder() |
|
||||
.setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION) |
|
||||
.setAudioFormat(audioFormat) |
|
||||
.setBufferSizeInBytes(BUFFER_SIZE) |
|
||||
.build() |
|
||||
|
|
||||
// 检查AudioRecord初始化状态 |
|
||||
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { |
|
||||
FileLogger.e(TAG, "AudioRecord初始化失败,状态: ${audioRecord?.state}") |
|
||||
// 尝试使用DEFAULT音频源重试一次 |
|
||||
audioRecord?.release() |
|
||||
audioRecord = AudioRecord.Builder() |
|
||||
.setAudioSource(MediaRecorder.AudioSource.DEFAULT) |
|
||||
.setAudioFormat(audioFormat) |
|
||||
.setBufferSizeInBytes(BUFFER_SIZE) |
|
||||
.build() |
|
||||
|
|
||||
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { |
|
||||
FileLogger.e(TAG, "AudioRecord初始化第二次尝试也失败,放弃") |
|
||||
releaseAudioResources() |
|
||||
return |
|
||||
} else { |
|
||||
FileLogger.d(TAG, "使用默认音频源成功初始化AudioRecord") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 启用音频效果(回音消除、噪声抑制等) |
|
||||
enableAudioEffects() |
|
||||
|
|
||||
// 启动录音 |
|
||||
audioRecord?.startRecording() |
|
||||
isRecording.set(true) |
|
||||
|
|
||||
// 创建录音线程 |
|
||||
recordingThread = Thread({ |
|
||||
val buffer = ByteArray(BUFFER_SIZE) |
|
||||
|
|
||||
while (isRecording.get()) { |
|
||||
try { |
|
||||
val readSize = audioRecord?.read(buffer, 0, BUFFER_SIZE) ?: 0 |
|
||||
|
|
||||
if (readSize > 0) { |
|
||||
try { |
|
||||
// 将处理后的音频数据推送到流 |
|
||||
if (readSize == buffer.size) { |
|
||||
// 如果读取的大小等于buffer的大小,直接写入整个buffer |
|
||||
pushStream.write(buffer) |
|
||||
} else { |
|
||||
// 如果只读取了部分数据,创建新的数组只包含有效数据 |
|
||||
val validData = buffer.copyOfRange(0, readSize) |
|
||||
pushStream.write(validData) |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "写入音频数据失败: ${e.message}") |
|
||||
break |
|
||||
} |
|
||||
} else if (readSize == 0) { |
|
||||
// 读取为0,可能是临时的,等待一下继续尝试 |
|
||||
Thread.sleep(10) |
|
||||
} else { |
|
||||
// 负值表示错误 |
|
||||
FileLogger.e(TAG, "读取音频数据失败,错误码: $readSize") |
|
||||
break |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "录音线程异常: ${e.message}") |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
}, "AudioRecordingThread") |
|
||||
|
|
||||
// 设置线程优先级并启动 |
|
||||
recordingThread?.priority = Thread.MAX_PRIORITY |
|
||||
recordingThread?.start() |
|
||||
|
|
||||
FileLogger.d(TAG, "音频录制已启动" + (if(echoCanceler?.enabled == true) ",回音消除已启用" else "")) |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "启动音频录制失败: ${e.message}") |
|
||||
releaseAudioResources() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 启用音频效果(回音消除、噪声抑制等) |
|
||||
private fun enableAudioEffects() { |
|
||||
try { |
|
||||
val audioSessionId = audioRecord?.audioSessionId ?: -1 |
|
||||
|
|
||||
if (audioSessionId != -1) { |
|
||||
// 启用回音消除 |
|
||||
if (AcousticEchoCanceler.isAvailable()) { |
|
||||
try { |
|
||||
echoCanceler = AcousticEchoCanceler.create(audioSessionId) |
|
||||
if (echoCanceler != null) { |
|
||||
echoCanceler?.enabled = true |
|
||||
FileLogger.d(TAG, "回音消除已启用,会话ID: $audioSessionId") |
|
||||
} else { |
|
||||
FileLogger.w(TAG, "回音消除器创建返回null") |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "创建回音消除器时出错: ${e.message}") |
|
||||
} |
|
||||
} else { |
|
||||
FileLogger.d(TAG, "设备不支持回音消除") |
|
||||
} |
|
||||
|
|
||||
// 以下功能暂时不启用,可根据需要取消注释 |
|
||||
/* |
|
||||
// 启用噪声抑制 |
|
||||
if (NoiseSuppressor.isAvailable()) { |
|
||||
try { |
|
||||
val ns = NoiseSuppressor.create(audioSessionId) |
|
||||
ns?.enabled = true |
|
||||
FileLogger.d(TAG, "噪声抑制已启用") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "创建噪声抑制器时出错: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 启用自动增益控制 |
|
||||
if (AutomaticGainControl.isAvailable()) { |
|
||||
try { |
|
||||
val agc = AutomaticGainControl.create(audioSessionId) |
|
||||
agc?.enabled = true |
|
||||
FileLogger.d(TAG, "自动增益控制已启用") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "创建自动增益控制时出错: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
*/ |
|
||||
} else { |
|
||||
FileLogger.w(TAG, "无效的音频会话ID,无法启用音频效果") |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "启用音频效果时出错: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 停止录音 |
|
||||
fun stopRecording() { |
|
||||
if (!isRecording.get()) { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
isRecording.set(false) |
|
||||
|
|
||||
try { |
|
||||
// 等待录音线程结束 |
|
||||
recordingThread?.join(1000) |
|
||||
|
|
||||
// 释放资源 |
|
||||
releaseAudioResources() |
|
||||
|
|
||||
FileLogger.d(TAG, "音频录制已停止") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "停止音频录制失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 释放音频资源 |
|
||||
private fun releaseAudioResources() { |
|
||||
try { |
|
||||
// 停止录音 |
|
||||
try { |
|
||||
if (audioRecord?.state == AudioRecord.STATE_INITIALIZED) { |
|
||||
audioRecord?.stop() |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
// 忽略可能的IllegalStateException |
|
||||
FileLogger.w(TAG, "停止AudioRecord时出错: ${e.message}") |
|
||||
} |
|
||||
|
|
||||
// 释放回音消除器 |
|
||||
try { |
|
||||
if (echoCanceler != null) { |
|
||||
echoCanceler?.enabled = false |
|
||||
echoCanceler?.release() |
|
||||
echoCanceler = null |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.w(TAG, "释放回音消除器时出错: ${e.message}") |
|
||||
} finally { |
|
||||
echoCanceler = null |
|
||||
} |
|
||||
|
|
||||
// 释放音频记录器 |
|
||||
try { |
|
||||
audioRecord?.release() |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.w(TAG, "释放AudioRecord时出错: ${e.message}") |
|
||||
} finally { |
|
||||
audioRecord = null |
|
||||
} |
|
||||
|
|
||||
// 重置线程 |
|
||||
recordingThread = null |
|
||||
|
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "释放音频资源失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 一次性识别回调接口 |
|
||||
interface RecognizeCallback { |
|
||||
fun onResult(result: String, detectedLanguage: String = "") |
|
||||
fun onError(error: String) |
|
||||
} |
|
||||
|
|
||||
// 连续识别回调接口 |
|
||||
interface ContinuousRecognizeCallback { |
|
||||
fun onResult(result: String, detectedLanguage: String = "") |
|
||||
fun onRecognizing(recognizing: String, detectedLanguage: String = "") |
|
||||
fun onSessionStarted() |
|
||||
fun onSessionStopped() |
|
||||
fun onCanceled(reason: String, errorDetails: String) |
|
||||
fun onError(error: String) |
|
||||
} |
|
||||
} |
|
||||
@ -1,445 +0,0 @@ |
|||||
package com.yunqiinnovation.deepsound |
|
||||
|
|
||||
import android.content.Context |
|
||||
import android.media.AudioAttributes |
|
||||
import android.media.AudioDeviceInfo |
|
||||
import android.media.AudioManager |
|
||||
import android.os.Build |
|
||||
import com.yunqiinnovation.deepsound.core.utils.FileLogger |
|
||||
import com.microsoft.cognitiveservices.speech.* |
|
||||
import com.microsoft.cognitiveservices.speech.audio.* |
|
||||
import java.util.concurrent.Future |
|
||||
import java.util.concurrent.Semaphore |
|
||||
|
|
||||
/** |
|
||||
* Microsoft Text-to-Speech Helper |
|
||||
* |
|
||||
* 该类封装了微软语音 SDK 的 TTS 功能,提供简单的接口供 Flutter 调用 |
|
||||
* 基于微软官方文档: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-speech-synthesis |
|
||||
*/ |
|
||||
class AzureTtsHelper(private val context: Context) { |
|
||||
private val TAG = "AzureTtsHelper" |
|
||||
private var speechConfig: SpeechConfig? = null |
|
||||
private var synthesizer: SpeechSynthesizer? = null |
|
||||
private var isInitialized = false |
|
||||
private var audioManager: AudioManager? = null |
|
||||
|
|
||||
// 跟踪当前是否正在播放语音 |
|
||||
private var speaking = false |
|
||||
|
|
||||
// 音频输出设备类型 |
|
||||
enum class AudioOutputType { |
|
||||
SPEAKER, // 扬声器 |
|
||||
EARPIECE, // 听筒 |
|
||||
AUTO // 自动选择(有耳机用耳机,没有则用听筒) |
|
||||
} |
|
||||
|
|
||||
// 当前音频输出设备类型 |
|
||||
private var currentAudioOutputType = AudioOutputType.AUTO |
|
||||
|
|
||||
// 用于异步操作的信号量 |
|
||||
private val semaphore = Semaphore(0) |
|
||||
|
|
||||
// 当前语音设置 |
|
||||
private var currentVoiceName = "zh-CN-XiaoxiaoNeural" |
|
||||
private var currentSpeechRate = "0%" |
|
||||
private var currentPitch = "0%" |
|
||||
private var currentVolume = "100%" |
|
||||
|
|
||||
/** |
|
||||
* 初始化 TTS 引擎 |
|
||||
* |
|
||||
* @param subscriptionKey Azure 语音服务订阅密钥 |
|
||||
* @param serviceRegion Azure 语音服务区域 |
|
||||
* @param language 可选,默认语言,默认为 "zh-CN" |
|
||||
*/ |
|
||||
fun initialize(subscriptionKey: String, serviceRegion: String, language: String = "zh-CN"): Boolean { |
|
||||
try { |
|
||||
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager |
|
||||
|
|
||||
|
|
||||
// 创建语音配置 |
|
||||
speechConfig = SpeechConfig.fromSubscription(subscriptionKey, serviceRegion) |
|
||||
|
|
||||
// 设置语音合成输出格式为高质量音频 |
|
||||
speechConfig?.setSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat.Riff24Khz16BitMonoPcm) |
|
||||
|
|
||||
// 设置默认语言 |
|
||||
speechConfig?.setSpeechSynthesisLanguage(language) |
|
||||
|
|
||||
// 设置默认语音 |
|
||||
speechConfig?.setSpeechSynthesisVoiceName(currentVoiceName) |
|
||||
|
|
||||
// 创建音频配置 - 使用默认扬声器 |
|
||||
val audioConfig = AudioConfig.fromDefaultSpeakerOutput() |
|
||||
|
|
||||
// 创建语音合成器 |
|
||||
synthesizer = SpeechSynthesizer(speechConfig, audioConfig) |
|
||||
|
|
||||
// 设置事件监听 |
|
||||
synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> |
|
||||
speaking = false |
|
||||
semaphore.release() |
|
||||
} |
|
||||
synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> |
|
||||
speaking = false |
|
||||
semaphore.release() |
|
||||
} |
|
||||
|
|
||||
isInitialized = true |
|
||||
FileLogger.d(TAG, "TTS 引擎初始化成功") |
|
||||
|
|
||||
// 设置默认音频输出类型为自动 |
|
||||
if (audioManager != null) { |
|
||||
setAudioOutputType(AudioOutputType.AUTO) |
|
||||
} |
|
||||
|
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "TTS 引擎初始化失败: ${e.message}, ${serviceRegion}") |
|
||||
e.printStackTrace() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置音频输出设备类型 |
|
||||
* |
|
||||
* @param outputType 音频输出设备类型 |
|
||||
* @return 是否设置成功 |
|
||||
*/ |
|
||||
fun setAudioOutputType(outputType: AudioOutputType): Boolean { |
|
||||
if (!isInitialized) { |
|
||||
FileLogger.e(TAG, "TTS 引擎尚未初始化") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if (audioManager == null) { |
|
||||
FileLogger.e(TAG, "AudioManager 未初始化,无法设置音频输出设备") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
currentAudioOutputType = outputType |
|
||||
|
|
||||
when (outputType) { |
|
||||
AudioOutputType.SPEAKER -> { |
|
||||
// 使用扬声器 |
|
||||
audioManager?.mode = AudioManager.MODE_NORMAL |
|
||||
audioManager?.isSpeakerphoneOn = true |
|
||||
FileLogger.d(TAG, "已设置音频输出设备为扬声器") |
|
||||
} |
|
||||
AudioOutputType.EARPIECE -> { |
|
||||
// 使用听筒 |
|
||||
audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION |
|
||||
audioManager?.isSpeakerphoneOn = false |
|
||||
FileLogger.d(TAG, "已设置音频输出设备为听筒") |
|
||||
} |
|
||||
AudioOutputType.AUTO -> { |
|
||||
// 检查是否有耳机连接 |
|
||||
if (isHeadsetConnected()) { |
|
||||
// 有耳机,使用耳机 |
|
||||
audioManager?.mode = AudioManager.MODE_NORMAL |
|
||||
audioManager?.isSpeakerphoneOn = false |
|
||||
FileLogger.d(TAG, "已设置音频输出设备为耳机") |
|
||||
} else { |
|
||||
// 无耳机,使用听筒 |
|
||||
audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION |
|
||||
audioManager?.isSpeakerphoneOn = false |
|
||||
FileLogger.d(TAG, "已设置音频输出设备为听筒") |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 重新创建合成器以应用新的音频设置 |
|
||||
recreateSynthesizer() |
|
||||
|
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "设置音频输出设备失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 检查是否有耳机连接 |
|
||||
*/ |
|
||||
private fun isHeadsetConnected(): Boolean { |
|
||||
if (audioManager == null) return false |
|
||||
|
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { |
|
||||
// 使用新的 API 检查音频设备 |
|
||||
val devices = audioManager?.getDevices(AudioManager.GET_DEVICES_OUTPUTS) |
|
||||
devices?.any { device -> |
|
||||
device.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || |
|
||||
device.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || |
|
||||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || |
|
||||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO |
|
||||
} ?: false |
|
||||
} else { |
|
||||
// 使用旧的 API 检查耳机 |
|
||||
audioManager?.isWiredHeadsetOn == true || audioManager?.isBluetoothA2dpOn == true |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 重新创建语音合成器 |
|
||||
*/ |
|
||||
private fun recreateSynthesizer() { |
|
||||
try { |
|
||||
// 关闭旧的合成器 |
|
||||
synthesizer?.close() |
|
||||
|
|
||||
// 创建音频配置 |
|
||||
val audioConfig = AudioConfig.fromDefaultSpeakerOutput() |
|
||||
|
|
||||
// 创建新的合成器 |
|
||||
synthesizer = SpeechSynthesizer(speechConfig, audioConfig) |
|
||||
|
|
||||
// 重新设置事件监听 |
|
||||
synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> |
|
||||
speaking = false |
|
||||
semaphore.release() |
|
||||
} |
|
||||
synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> |
|
||||
speaking = false |
|
||||
semaphore.release() |
|
||||
} |
|
||||
|
|
||||
FileLogger.d(TAG, "已重新创建语音合成器") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "重新创建语音合成器失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置语音 |
|
||||
* |
|
||||
* @param voiceName 语音名称,例如 "zh-CN-XiaoxiaoNeural" |
|
||||
*/ |
|
||||
fun setVoice(voiceName: String): Boolean { |
|
||||
if (!isInitialized) { |
|
||||
FileLogger.e(TAG, "TTS 引擎尚未初始化") |
|
||||
return false |
|
||||
} |
|
||||
if (voiceName == currentVoiceName) { |
|
||||
FileLogger.d(TAG, "已设置语音: $voiceName") |
|
||||
return true |
|
||||
} |
|
||||
try { |
|
||||
currentVoiceName = voiceName |
|
||||
speechConfig?.setSpeechSynthesisVoiceName(voiceName) |
|
||||
|
|
||||
// 重新创建合成器以应用新的语音设置 |
|
||||
recreateSynthesizer() |
|
||||
|
|
||||
FileLogger.d(TAG, "已设置语音: $voiceName") |
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "设置语音失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置语音合成参数 |
|
||||
* |
|
||||
* @param rate 语速,范围 -100 到 100,默认为 0 |
|
||||
* @param pitch 音调,范围 -100 到 100,默认为 0 |
|
||||
* @param volume 音量,范围 0 到 100,默认为 100 |
|
||||
*/ |
|
||||
fun setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100): Boolean { |
|
||||
if (!isInitialized) { |
|
||||
FileLogger.e(TAG, "TTS 引擎尚未初始化") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
currentSpeechRate = if (rate == 0) "0%" else if (rate < 0) "${(rate * 0.9).toInt()}%" else "$rate%" |
|
||||
currentPitch = if (pitch == 0) "0%" else "${(pitch * 0.5).toInt()}%" |
|
||||
currentVolume = "${volume.coerceIn(0, 100)}%" |
|
||||
|
|
||||
FileLogger.d(TAG, "已设置语音参数: 语速=$currentSpeechRate, 音调=$currentPitch, 音量=$currentVolume") |
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "设置语音参数失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 合成文本为语音并播放 |
|
||||
* |
|
||||
* @param text 要合成的文本 |
|
||||
* @param callback 回调接口,用于返回结果或错误 |
|
||||
*/ |
|
||||
fun speakText(text: String, callback: TTSCallback) { |
|
||||
if (!isInitialized) { |
|
||||
callback.onError("TTS 引擎尚未初始化===") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
FileLogger.d(TAG, "开始合成文本: $text") |
|
||||
|
|
||||
|
|
||||
// 生成 SSML |
|
||||
val ssml = generateSsml(text) |
|
||||
|
|
||||
// 使用 SSML 合成语音 |
|
||||
speakSsml(ssml, callback) |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "语音合成异常: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
callback.onError("语音合成异常: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 生成 SSML 文本 |
|
||||
* |
|
||||
* @param text 要转换的文本 |
|
||||
* @return SSML 格式的文本 |
|
||||
*/ |
|
||||
private fun generateSsml(text: String): String { |
|
||||
return """ |
|
||||
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="zh-CN"> |
|
||||
<voice name="$currentVoiceName"> |
|
||||
<prosody rate="$currentSpeechRate" pitch="$currentPitch" volume="$currentVolume"> |
|
||||
$text |
|
||||
</prosody> |
|
||||
</voice> |
|
||||
</speak> |
|
||||
""".trimIndent() |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 合成 SSML 为语音并播放 |
|
||||
* |
|
||||
* @param ssml SSML 格式的文本 |
|
||||
* @param callback 回调接口,用于返回结果或错误 |
|
||||
*/ |
|
||||
fun speakSsml(ssml: String, callback: TTSCallback) { |
|
||||
if (!isInitialized) { |
|
||||
callback.onError("TTS 引擎尚未初始化") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
FileLogger.d(TAG, "开始合成 SSML") |
|
||||
|
|
||||
// 重置信号量 |
|
||||
semaphore.drainPermits() |
|
||||
|
|
||||
// 异步合成语音 |
|
||||
val task: Future<SpeechSynthesisResult> = synthesizer!!.SpeakSsmlAsync(ssml) |
|
||||
|
|
||||
// 标记为正在播放 |
|
||||
speaking = true |
|
||||
|
|
||||
// 等待合成完成 |
|
||||
Thread { |
|
||||
try { |
|
||||
// 等待合成完成信号 |
|
||||
semaphore.acquire() |
|
||||
|
|
||||
// 获取结果 |
|
||||
val result = task.get() |
|
||||
|
|
||||
when (result.reason) { |
|
||||
ResultReason.SynthesizingAudioCompleted -> { |
|
||||
FileLogger.d(TAG, "语音合成完成,音频长度: ${result.audioLength / 10000}ms,音频数据大小: ${result.audioData?.size ?: 0} 字节") |
|
||||
callback.onSuccess("语音合成完成") |
|
||||
} |
|
||||
ResultReason.Canceled -> { |
|
||||
val cancellation = SpeechSynthesisCancellationDetails.fromResult(result) |
|
||||
FileLogger.e(TAG, "语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}") |
|
||||
callback.onError("语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}") |
|
||||
} |
|
||||
else -> { |
|
||||
FileLogger.e(TAG, "语音合成失败kt: ${result.reason}") |
|
||||
callback.onError("语音合成失败kt: ${result.reason}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
result.close() |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "处理语音合成结果异常: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
callback.onError("处理语音合成结果异常: ${e.message}") |
|
||||
} |
|
||||
}.start() |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "语音合成异常: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
callback.onError("语音合成异常: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止当前语音合成 |
|
||||
*/ |
|
||||
fun stopSpeaking(): Boolean { |
|
||||
if (!isInitialized) { |
|
||||
FileLogger.e(TAG, "TTS 引擎尚未初始化") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
synthesizer?.StopSpeakingAsync() |
|
||||
speaking = false |
|
||||
|
|
||||
FileLogger.d(TAG, "已停止语音合成") |
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "停止语音合成失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 释放资源 |
|
||||
*/ |
|
||||
fun dispose() { |
|
||||
try { |
|
||||
stopSpeaking() |
|
||||
synthesizer?.close() |
|
||||
speechConfig?.close() |
|
||||
|
|
||||
// 恢复音频模式 |
|
||||
if (audioManager != null) { |
|
||||
audioManager?.mode = AudioManager.MODE_NORMAL |
|
||||
audioManager?.isSpeakerphoneOn = false |
|
||||
} |
|
||||
|
|
||||
isInitialized = false |
|
||||
speaking = false |
|
||||
FileLogger.d(TAG, "TTS 引擎已释放") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "释放 TTS 引擎失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 检查当前是否正在播放语音 |
|
||||
* |
|
||||
* @return 是否正在播放语音 |
|
||||
*/ |
|
||||
fun isSpeaking(): Boolean { |
|
||||
return speaking |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* TTS 回调接口 |
|
||||
*/ |
|
||||
interface TTSCallback { |
|
||||
fun onSuccess(message: String) |
|
||||
fun onError(error: String) |
|
||||
} |
|
||||
} |
|
||||
@ -1,242 +0,0 @@ |
|||||
package com.yunqiinnovation.deepsound |
|
||||
|
|
||||
import android.bluetooth.BluetoothAdapter |
|
||||
import android.bluetooth.BluetoothProfile |
|
||||
import android.content.Context |
|
||||
import android.content.pm.PackageManager |
|
||||
import android.os.Build |
|
||||
import android.util.Log |
|
||||
import android.Manifest |
|
||||
import androidx.core.content.ContextCompat |
|
||||
import java.util.concurrent.atomic.AtomicBoolean |
|
||||
import java.util.concurrent.CountDownLatch |
|
||||
import java.util.concurrent.TimeUnit |
|
||||
|
|
||||
/** |
|
||||
* 经典蓝牙辅助类,用于获取已连接的蓝牙设备信息 |
|
||||
*/ |
|
||||
class ClassicBluetoothHelper(private val context: Context) { |
|
||||
|
|
||||
private val TAG = "ClassicBluetoothHelper" |
|
||||
|
|
||||
// 缓存蓝牙代理对象 |
|
||||
private var a2dpProxy: BluetoothProfile? = null |
|
||||
private var headsetProxy: BluetoothProfile? = null |
|
||||
|
|
||||
/** |
|
||||
* 获取已连接的A2DP设备(音频输出设备) |
|
||||
* @param callback 回调接口,返回设备列表或错误信息 |
|
||||
*/ |
|
||||
fun getConnectedA2dpDevices(callback: BluetoothDevicesCallback) { |
|
||||
getConnectedBluetoothDevices(BluetoothProfile.A2DP, callback) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 获取已连接的蓝牙耳机设备 |
|
||||
* @param callback 回调接口,返回设备列表或错误信息 |
|
||||
*/ |
|
||||
fun getConnectedHeadsetDevices(callback: BluetoothDevicesCallback) { |
|
||||
getConnectedBluetoothDevices(BluetoothProfile.HEADSET, callback) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 检查是否有蓝牙连接权限 |
|
||||
* @return 是否有权限 |
|
||||
*/ |
|
||||
private fun hasBluetoothConnectPermission(): Boolean { |
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { |
|
||||
ContextCompat.checkSelfPermission( |
|
||||
context, |
|
||||
Manifest.permission.BLUETOOTH_CONNECT |
|
||||
) == PackageManager.PERMISSION_GRANTED |
|
||||
} else { |
|
||||
true |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 检查蓝牙是否启用 |
|
||||
* @return 蓝牙是否启用 |
|
||||
*/ |
|
||||
fun isBluetoothEnabled(): Boolean { |
|
||||
try { |
|
||||
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() |
|
||||
if (bluetoothAdapter == null) { |
|
||||
Log.w(TAG, "设备不支持蓝牙") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
return bluetoothAdapter.isEnabled |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "检查蓝牙状态失败: ${e.message}") |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 获取指定配置文件的已连接蓝牙设备 |
|
||||
* @param profile 蓝牙配置文件类型,如 BluetoothProfile.A2DP 或 BluetoothProfile.HEADSET |
|
||||
* @param callback 回调接口,返回设备列表或错误信息 |
|
||||
*/ |
|
||||
private fun getConnectedBluetoothDevices(profile: Int, callback: BluetoothDevicesCallback) { |
|
||||
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() |
|
||||
if (bluetoothAdapter == null) { |
|
||||
Log.e(TAG, "没有找到蓝牙适配器") |
|
||||
callback.onError("NO_ADAPTER", "没有找到蓝牙适配器") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 检查蓝牙权限 |
|
||||
if (!hasBluetoothConnectPermission()) { |
|
||||
Log.e(TAG, "缺少BLUETOOTH_CONNECT权限") |
|
||||
callback.onError("PERMISSION_DENIED", "缺少蓝牙连接权限,请在应用设置中授予该权限") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 检查是否已经有缓存的代理对象 |
|
||||
val cachedProxy = when (profile) { |
|
||||
BluetoothProfile.A2DP -> a2dpProxy |
|
||||
BluetoothProfile.HEADSET -> headsetProxy |
|
||||
else -> null |
|
||||
} |
|
||||
|
|
||||
if (cachedProxy != null) { |
|
||||
// 使用已缓存的代理对象 |
|
||||
processDeviceList(cachedProxy, profile, callback) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 使用原子布尔值确保回调只被调用一次 |
|
||||
val callbackCalled = AtomicBoolean(false) |
|
||||
|
|
||||
// 使用CountDownLatch等待服务连接 |
|
||||
val latch = CountDownLatch(1) |
|
||||
|
|
||||
// 通过 getProfileProxy 获取指定 Profile (A2DP/HEADSET) 的连接状态 |
|
||||
bluetoothAdapter.getProfileProxy(context, |
|
||||
object : BluetoothProfile.ServiceListener { |
|
||||
override fun onServiceConnected(profileId: Int, proxy: BluetoothProfile) { |
|
||||
Log.d(TAG, "蓝牙配置文件服务已连接: $profileId") |
|
||||
if (profileId == profile) { |
|
||||
// 缓存代理对象 |
|
||||
when (profileId) { |
|
||||
BluetoothProfile.A2DP -> a2dpProxy = proxy |
|
||||
BluetoothProfile.HEADSET -> headsetProxy = proxy |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
processDeviceList(proxy, profileId, callback) |
|
||||
latch.countDown() |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "处理设备信息时出错: ${e.message}") |
|
||||
|
|
||||
// 确保回调只被调用一次 |
|
||||
if (callbackCalled.compareAndSet(false, true)) { |
|
||||
callback.onError("DEVICE_ERROR", "处理设备信息时出错: ${e.message}") |
|
||||
} |
|
||||
latch.countDown() |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override fun onServiceDisconnected(profileId: Int) { |
|
||||
// Profile 断开连接 |
|
||||
Log.e(TAG, "蓝牙配置文件服务断开连接: $profileId") |
|
||||
|
|
||||
// 清除对应的缓存代理 |
|
||||
when (profileId) { |
|
||||
BluetoothProfile.A2DP -> a2dpProxy = null |
|
||||
BluetoothProfile.HEADSET -> headsetProxy = null |
|
||||
} |
|
||||
|
|
||||
// 确保回调只被调用一次,但不在这里调用错误回调 |
|
||||
// 避免服务断开时也返回错误导致重复回调 |
|
||||
if (callbackCalled.compareAndSet(false, true) && !latch.await(0, TimeUnit.MILLISECONDS)) { |
|
||||
callback.onError("SERVICE_DISCONNECTED", "蓝牙配置文件服务断开连接") |
|
||||
latch.countDown() |
|
||||
} |
|
||||
} |
|
||||
}, profile |
|
||||
) |
|
||||
|
|
||||
// 设置超时处理 |
|
||||
Thread { |
|
||||
try { |
|
||||
// 最多等待3秒 |
|
||||
if (!latch.await(3, TimeUnit.SECONDS)) { |
|
||||
Log.e(TAG, "获取蓝牙设备超时") |
|
||||
if (callbackCalled.compareAndSet(false, true)) { |
|
||||
callback.onError("TIMEOUT", "获取蓝牙设备超时") |
|
||||
} |
|
||||
} |
|
||||
} catch (e: InterruptedException) { |
|
||||
Log.e(TAG, "等待蓝牙服务被中断: ${e.message}") |
|
||||
if (callbackCalled.compareAndSet(false, true)) { |
|
||||
callback.onError("INTERRUPTED", "等待蓝牙服务被中断: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
}.start() |
|
||||
|
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "获取蓝牙设备失败: ${e.message}") |
|
||||
callback.onError("BLUETOOTH_ERROR", "获取蓝牙设备失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 处理设备列表 |
|
||||
*/ |
|
||||
private fun processDeviceList(proxy: BluetoothProfile, profileId: Int, callback: BluetoothDevicesCallback) { |
|
||||
// 获取已连接设备 |
|
||||
val connectedDevices = proxy.connectedDevices |
|
||||
|
|
||||
// 转换为设备信息列表 |
|
||||
val devicesList = connectedDevices.map { device -> |
|
||||
mapOf( |
|
||||
"name" to (device.name ?: "未知设备"), |
|
||||
"address" to device.address |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
Log.d(TAG, "获取到的设备列表:${devicesList.size}个设备") |
|
||||
|
|
||||
// 返回设备列表(不关闭代理) |
|
||||
callback.onSuccess(devicesList) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 释放资源 |
|
||||
*/ |
|
||||
fun dispose() { |
|
||||
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() ?: return |
|
||||
|
|
||||
// 释放A2DP代理 |
|
||||
a2dpProxy?.let { |
|
||||
try { |
|
||||
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, it) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "关闭A2DP代理失败: ${e.message}") |
|
||||
} |
|
||||
a2dpProxy = null |
|
||||
} |
|
||||
|
|
||||
// 释放HEADSET代理 |
|
||||
headsetProxy?.let { |
|
||||
try { |
|
||||
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, it) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "关闭HEADSET代理失败: ${e.message}") |
|
||||
} |
|
||||
headsetProxy = null |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 蓝牙设备回调接口 |
|
||||
*/ |
|
||||
interface BluetoothDevicesCallback { |
|
||||
fun onSuccess(devices: List<Map<String, String>>) |
|
||||
fun onError(code: String, message: String) |
|
||||
} |
|
||||
} |
|
||||
@ -1,809 +0,0 @@ |
|||||
package com.yunqiinnovation.deepsound |
|
||||
|
|
||||
import android.content.Intent |
|
||||
import android.os.Build |
|
||||
import android.os.Bundle |
|
||||
import android.util.Log |
|
||||
import io.flutter.embedding.engine.FlutterEngine |
|
||||
import io.flutter.plugin.common.MethodChannel |
|
||||
import io.flutter.plugin.common.MethodCall |
|
||||
import io.flutter.plugin.common.EventChannel |
|
||||
import io.flutter.plugins.GeneratedPluginRegistrant |
|
||||
import io.flutter.embedding.android.FlutterActivity |
|
||||
import android.content.Context |
|
||||
import android.content.BroadcastReceiver |
|
||||
import android.content.IntentFilter |
|
||||
import android.os.Handler |
|
||||
import android.os.Looper |
|
||||
import android.bluetooth.BluetoothAdapter |
|
||||
import android.bluetooth.BluetoothDevice |
|
||||
import androidx.security.crypto.EncryptedSharedPreferences |
|
||||
import androidx.security.crypto.MasterKey |
|
||||
import com.yunqiinnovation.deepsound.core.utils.FileLogger |
|
||||
|
|
||||
class MainActivity: FlutterActivity() { |
|
||||
|
|
||||
private val AZURE_ASR_CHANNEL = "com.deep_voice.azure_asr" |
|
||||
private val AZURE_ASR_EVENT_CHANNEL = "com.deep_voice.azure_asr_events" |
|
||||
private val AZURE_TTS_CHANNEL = "com.deep_voice.azure_tts" |
|
||||
private val VOICE_INTERACTION_CHANNEL = "com.deep_voice.voice_interaction" |
|
||||
private val VOICE_INTERACTION_EVENT_CHANNEL = "com.deep_voice.voice_interaction_events" |
|
||||
private val CLASSIC_BLUETOOTH_CHANNEL = "com.deep_voice.classic_bluetooth" |
|
||||
private val CLASSIC_BLUETOOTH_EVENT_CHANNEL = "com.deep_voice.classic_bluetooth_events" |
|
||||
private val TAG = "MainActivity" |
|
||||
private lateinit var azureAsrHelper: AzureAsrHelper |
|
||||
private lateinit var azureTtsHelper: AzureTtsHelper |
|
||||
private lateinit var classicBluetoothHelper: ClassicBluetoothHelper |
|
||||
private var azureAsrEventSink: EventChannel.EventSink? = null |
|
||||
private var voiceInteractionEventSink: EventChannel.EventSink? = null |
|
||||
private var bluetoothEventSink: EventChannel.EventSink? = null |
|
||||
|
|
||||
// 广播接收器 |
|
||||
private val voiceInteractionReceiver = object : BroadcastReceiver() { |
|
||||
override fun onReceive(context: Context?, intent: Intent?) { |
|
||||
Log.d(TAG, "收到广播: ${intent?.action}") |
|
||||
|
|
||||
when (intent?.action) { |
|
||||
VoiceInteractionService.ACTION_RECOGNITION_STARTED -> { |
|
||||
val timestamp = intent.getLongExtra("timestamp", 0) |
|
||||
Log.d(TAG, "收到语音识别启动广播: timestamp=$timestamp") |
|
||||
sendVoiceInteractionEvent(mapOf( |
|
||||
"type" to "recognition_started", |
|
||||
"timestamp" to timestamp |
|
||||
)) |
|
||||
} |
|
||||
VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED -> { |
|
||||
val agentId = intent.getStringExtra("agentId") ?: "default" |
|
||||
val userMessage = intent.getStringExtra("userMessage") ?: "" |
|
||||
val assistantMessage = intent.getStringExtra("assistantMessage") ?: "" |
|
||||
val timestamp = intent.getLongExtra("timestamp", System.currentTimeMillis()) |
|
||||
|
|
||||
Log.d(TAG, "收到聊天记录更新广播: agentId=$agentId, timestamp=$timestamp") |
|
||||
Log.d(TAG, "用户消息: ${userMessage.take(50)}...") |
|
||||
Log.d(TAG, "助手回复: ${assistantMessage.take(50)}...") |
|
||||
|
|
||||
sendChatHistoryEvent(agentId, userMessage, assistantMessage, timestamp) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 蓝牙状态变化广播接收器 |
|
||||
private val bluetoothStateReceiver = object : BroadcastReceiver() { |
|
||||
override fun onReceive(context: Context?, intent: Intent?) { |
|
||||
when (intent?.action) { |
|
||||
BluetoothDevice.ACTION_ACL_CONNECTED -> { |
|
||||
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { |
|
||||
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) |
|
||||
} else { |
|
||||
@Suppress("DEPRECATION") |
|
||||
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) |
|
||||
} |
|
||||
|
|
||||
if (device != null) { |
|
||||
Log.d(TAG, "蓝牙设备已连接: ${device.name ?: "未知设备"} (${device.address})") |
|
||||
sendBluetoothEvent(mapOf( |
|
||||
"type" to "deviceConnected", |
|
||||
"device" to mapOf( |
|
||||
"name" to (device.name ?: "未知设备"), |
|
||||
"address" to device.address |
|
||||
), |
|
||||
"timestamp" to System.currentTimeMillis() |
|
||||
)) |
|
||||
} |
|
||||
} |
|
||||
BluetoothDevice.ACTION_ACL_DISCONNECTED -> { |
|
||||
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { |
|
||||
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) |
|
||||
} else { |
|
||||
@Suppress("DEPRECATION") |
|
||||
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) |
|
||||
} |
|
||||
|
|
||||
if (device != null) { |
|
||||
Log.d(TAG, "蓝牙设备已断开: ${device.name ?: "未知设备"} (${device.address})") |
|
||||
sendBluetoothEvent(mapOf( |
|
||||
"type" to "deviceDisconnected", |
|
||||
"device" to mapOf( |
|
||||
"name" to (device.name ?: "未知设备"), |
|
||||
"address" to device.address |
|
||||
), |
|
||||
"timestamp" to System.currentTimeMillis() |
|
||||
)) |
|
||||
} |
|
||||
} |
|
||||
BluetoothAdapter.ACTION_STATE_CHANGED -> { |
|
||||
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) |
|
||||
when (state) { |
|
||||
BluetoothAdapter.STATE_ON -> { |
|
||||
Log.d(TAG, "蓝牙已开启") |
|
||||
sendBluetoothEvent(mapOf( |
|
||||
"type" to "bluetoothStateChanged", |
|
||||
"state" to "on", |
|
||||
"timestamp" to System.currentTimeMillis() |
|
||||
)) |
|
||||
} |
|
||||
BluetoothAdapter.STATE_OFF -> { |
|
||||
Log.d(TAG, "蓝牙已关闭") |
|
||||
sendBluetoothEvent(mapOf( |
|
||||
"type" to "bluetoothStateChanged", |
|
||||
"state" to "off", |
|
||||
"timestamp" to System.currentTimeMillis() |
|
||||
)) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 使用伴生对象存储配置信息,使其在整个应用中可访问 |
|
||||
companion object { |
|
||||
// Azure 语音服务配置 |
|
||||
var azureSpeechKey: String = "" |
|
||||
var azureSpeechRegion: String = "" |
|
||||
var volcanoAiApiKey: String = "" |
|
||||
|
|
||||
// 安全存储相关常量 |
|
||||
private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" |
|
||||
private const val KEY_AZURE_SPEECH_KEY = "azure_speech_key" |
|
||||
private const val KEY_AZURE_SPEECH_REGION = "azure_speech_region" |
|
||||
private const val KEY_VOLCANO_AI_API_KEY = "volcano_ai_api_key" |
|
||||
|
|
||||
// 会话管理 |
|
||||
private const val KEY_SESSION_ID = "session_id" |
|
||||
private var currentSessionId = "" |
|
||||
|
|
||||
/** |
|
||||
* 将密钥安全地存储到 EncryptedSharedPreferences |
|
||||
*/ |
|
||||
fun saveKeysToSecureStorage(context: Context) { |
|
||||
try { |
|
||||
// 创建或获取主密钥 |
|
||||
val masterKey = MasterKey.Builder(context) |
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) |
|
||||
.build() |
|
||||
|
|
||||
// 创建 EncryptedSharedPreferences |
|
||||
val sharedPreferences = EncryptedSharedPreferences.create( |
|
||||
context, |
|
||||
SECURE_PREFS_FILENAME, |
|
||||
masterKey, |
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, |
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM |
|
||||
) |
|
||||
|
|
||||
// 生成新的会话ID |
|
||||
if (currentSessionId.isEmpty()) { |
|
||||
currentSessionId = java.util.UUID.randomUUID().toString() |
|
||||
} |
|
||||
|
|
||||
// 保存密钥和会话ID |
|
||||
sharedPreferences.edit() |
|
||||
.putString(KEY_AZURE_SPEECH_KEY, azureSpeechKey) |
|
||||
.putString(KEY_AZURE_SPEECH_REGION, azureSpeechRegion) |
|
||||
.putString(KEY_VOLCANO_AI_API_KEY, volcanoAiApiKey) |
|
||||
.putString(KEY_SESSION_ID, currentSessionId) |
|
||||
.apply() |
|
||||
|
|
||||
FileLogger.d("MainActivity", "密钥已安全保存到加密存储中") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e("MainActivity", "保存密钥到加密存储失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 从 EncryptedSharedPreferences 获取密钥 |
|
||||
*/ |
|
||||
fun loadKeysFromSecureStorage(context: Context): Boolean { |
|
||||
try { |
|
||||
// 创建或获取主密钥 |
|
||||
val masterKey = MasterKey.Builder(context) |
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) |
|
||||
.build() |
|
||||
|
|
||||
// 获取 EncryptedSharedPreferences |
|
||||
val sharedPreferences = EncryptedSharedPreferences.create( |
|
||||
context, |
|
||||
SECURE_PREFS_FILENAME, |
|
||||
masterKey, |
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, |
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM |
|
||||
) |
|
||||
|
|
||||
// 检查会话ID是否匹配 |
|
||||
val savedSessionId = sharedPreferences.getString(KEY_SESSION_ID, "") ?: "" |
|
||||
|
|
||||
// 如果会话ID为空或不匹配,说明是新会话或应用被重启 |
|
||||
if (savedSessionId.isEmpty() || currentSessionId.isEmpty() || savedSessionId != currentSessionId) { |
|
||||
// 会话不匹配,但尝试加载密钥,不返回错误 |
|
||||
FileLogger.d("MainActivity", "会话ID不匹配,但尝试加载密钥") |
|
||||
// 设置当前会话ID为保存的会话ID |
|
||||
currentSessionId = savedSessionId |
|
||||
} |
|
||||
|
|
||||
// 加载密钥 |
|
||||
azureSpeechKey = sharedPreferences.getString(KEY_AZURE_SPEECH_KEY, "") ?: "" |
|
||||
azureSpeechRegion = sharedPreferences.getString(KEY_AZURE_SPEECH_REGION, "") ?: "" |
|
||||
volcanoAiApiKey = sharedPreferences.getString(KEY_VOLCANO_AI_API_KEY, "") ?: "" |
|
||||
|
|
||||
FileLogger.d("MainActivity", "已从加密存储加载密钥") |
|
||||
|
|
||||
// 检查是否成功获取所有密钥 |
|
||||
return azureSpeechKey.isNotEmpty() && azureSpeechRegion.isNotEmpty() && volcanoAiApiKey.isNotEmpty() |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e("MainActivity", "从加密存储加载密钥失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
|
||||
super.onCreate(savedInstanceState) |
|
||||
|
|
||||
// 初始化 FileLogger |
|
||||
FileLogger.init(applicationContext) |
|
||||
|
|
||||
// 初始化 Azure 语音服务 |
|
||||
azureAsrHelper = AzureAsrHelper(applicationContext) |
|
||||
azureTtsHelper = AzureTtsHelper(applicationContext) |
|
||||
classicBluetoothHelper = ClassicBluetoothHelper(applicationContext) |
|
||||
|
|
||||
// 注册广播接收器 |
|
||||
registerReceivers() |
|
||||
|
|
||||
// 确保 Flutter 引擎准备好后立即设置事件通道 |
|
||||
flutterEngine?.let { engine -> |
|
||||
Log.d(TAG, "Flutter 引擎已存在,立即设置事件通道") |
|
||||
setupEventChannels(engine) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun registerReceivers() { |
|
||||
// 注册语音交互广播接收器 |
|
||||
val voiceInteractionFilter = IntentFilter().apply { |
|
||||
addAction(VoiceInteractionService.ACTION_RECOGNITION_STARTED) |
|
||||
addAction(VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED) |
|
||||
} |
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { |
|
||||
registerReceiver(voiceInteractionReceiver, voiceInteractionFilter, Context.RECEIVER_NOT_EXPORTED) |
|
||||
} else { |
|
||||
registerReceiver(voiceInteractionReceiver, voiceInteractionFilter) |
|
||||
} |
|
||||
|
|
||||
// 注册蓝牙状态变化广播接收器 |
|
||||
val bluetoothFilter = IntentFilter().apply { |
|
||||
addAction(BluetoothDevice.ACTION_ACL_CONNECTED) |
|
||||
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED) |
|
||||
addAction(BluetoothAdapter.ACTION_STATE_CHANGED) |
|
||||
} |
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { |
|
||||
registerReceiver(bluetoothStateReceiver, bluetoothFilter, Context.RECEIVER_NOT_EXPORTED) |
|
||||
} else { |
|
||||
registerReceiver(bluetoothStateReceiver, bluetoothFilter) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { |
|
||||
Log.d(TAG, "开始配置 Flutter 引擎") |
|
||||
|
|
||||
// 先设置事件通道,确保在插件注册之前就已准备好 |
|
||||
setupEventChannels(flutterEngine) |
|
||||
|
|
||||
// 然后调用父类方法和注册插件 |
|
||||
super.configureFlutterEngine(flutterEngine) |
|
||||
GeneratedPluginRegistrant.registerWith(flutterEngine) |
|
||||
|
|
||||
// 设置方法通道 |
|
||||
setupMethodChannels(flutterEngine) |
|
||||
|
|
||||
// 初始化 Azure 语音服务 |
|
||||
azureAsrHelper = AzureAsrHelper(this) |
|
||||
azureTtsHelper = AzureTtsHelper(this) |
|
||||
|
|
||||
Log.d(TAG, "Flutter 引擎配置完成") |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置事件通道 |
|
||||
*/ |
|
||||
private fun setupEventChannels(flutterEngine: FlutterEngine) { |
|
||||
Log.d(TAG, "开始设置事件通道") |
|
||||
|
|
||||
try { |
|
||||
// 语音交互事件通道 |
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, VOICE_INTERACTION_EVENT_CHANNEL).setStreamHandler( |
|
||||
object : EventChannel.StreamHandler { |
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|
||||
Log.d(TAG, "语音交互事件通道开始监听") |
|
||||
voiceInteractionEventSink = events |
|
||||
} |
|
||||
|
|
||||
override fun onCancel(arguments: Any?) { |
|
||||
voiceInteractionEventSink = null |
|
||||
} |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
// Azure ASR 事件通道 |
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, AZURE_ASR_EVENT_CHANNEL).setStreamHandler( |
|
||||
object : EventChannel.StreamHandler { |
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|
||||
Log.d(TAG, "ASR事件通道开始监听") |
|
||||
azureAsrEventSink = events |
|
||||
} |
|
||||
|
|
||||
override fun onCancel(arguments: Any?) { |
|
||||
azureAsrEventSink = null |
|
||||
} |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
// 设置蓝牙事件通道 |
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CLASSIC_BLUETOOTH_EVENT_CHANNEL).setStreamHandler( |
|
||||
object : EventChannel.StreamHandler { |
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|
||||
Log.d(TAG, "蓝牙事件通道开始监听") |
|
||||
bluetoothEventSink = events |
|
||||
} |
|
||||
|
|
||||
override fun onCancel(arguments: Any?) { |
|
||||
Log.d(TAG, "蓝牙事件通道停止监听") |
|
||||
bluetoothEventSink = null |
|
||||
} |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "设置事件通道时发生错误: ${e.message}", e) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置方法通道 |
|
||||
*/ |
|
||||
private fun setupMethodChannels(flutterEngine: FlutterEngine) { |
|
||||
Log.d(TAG, "开始设置方法通道") |
|
||||
|
|
||||
// 设置 Azure ASR 方法通道 |
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AZURE_ASR_CHANNEL).setMethodCallHandler { call, result -> |
|
||||
when (call.method) { |
|
||||
"initialize" -> { |
|
||||
val subscriptionKey = call.argument<String>("subscriptionKey") |
|
||||
val region = call.argument<String>("region") |
|
||||
val supportedLanguages = call.argument<List<String>>("supportedLanguages")?.toTypedArray() ?: arrayOf("zh-CN", "en-US") |
|
||||
|
|
||||
if (subscriptionKey == null || region == null) { |
|
||||
result.error("INVALID_ARGUMENTS", "订阅密钥和区域不能为空", null) |
|
||||
return@setMethodCallHandler |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
val success = azureAsrHelper.initialize(subscriptionKey, region, supportedLanguages) |
|
||||
result.success(success) |
|
||||
} catch (e: Exception) { |
|
||||
result.error("INITIALIZATION_ERROR", e.message, null) |
|
||||
} |
|
||||
} |
|
||||
"recognizeOnce" -> { |
|
||||
|
|
||||
azureAsrHelper.recognizeOnce(object : AzureAsrHelper.RecognizeCallback { |
|
||||
override fun onResult(text: String, detectedLanguage: String) { |
|
||||
result.success(mapOf( |
|
||||
"text" to text, |
|
||||
"detectedLanguage" to detectedLanguage |
|
||||
)) |
|
||||
} |
|
||||
|
|
||||
override fun onError(error: String) { |
|
||||
result.error("RECOGNITION_ERROR", error, null) |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
"startContinuousRecognition" -> { |
|
||||
// 确保事件通道已准备好 |
|
||||
if (azureAsrEventSink == null) { |
|
||||
result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始连续识别", null) |
|
||||
return@setMethodCallHandler |
|
||||
} |
|
||||
|
|
||||
val success = azureAsrHelper.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { |
|
||||
override fun onResult(text: String, detectedLanguage: String) { |
|
||||
sendAsrEvent(mapOf( |
|
||||
"type" to "result", |
|
||||
"text" to text, |
|
||||
"detectedLanguage" to detectedLanguage |
|
||||
)) |
|
||||
} |
|
||||
|
|
||||
override fun onRecognizing(recognizing: String, detectedLanguage: String) { |
|
||||
sendAsrEvent(mapOf( |
|
||||
"type" to "recognizing", |
|
||||
"text" to recognizing, |
|
||||
"detectedLanguage" to detectedLanguage |
|
||||
)) |
|
||||
} |
|
||||
|
|
||||
override fun onSessionStarted() { |
|
||||
sendAsrEvent(mapOf("type" to "sessionStarted")) |
|
||||
} |
|
||||
|
|
||||
override fun onSessionStopped() { |
|
||||
sendAsrEvent(mapOf("type" to "sessionStopped")) |
|
||||
} |
|
||||
|
|
||||
override fun onCanceled(reason: String, errorDetails: String) { |
|
||||
sendAsrEvent(mapOf( |
|
||||
"type" to "canceled", |
|
||||
"reason" to reason, |
|
||||
"errorDetails" to errorDetails |
|
||||
)) |
|
||||
} |
|
||||
|
|
||||
override fun onError(error: String) { |
|
||||
sendAsrEvent(mapOf("type" to "error", "message" to error)) |
|
||||
} |
|
||||
}) |
|
||||
result.success(success) |
|
||||
} |
|
||||
"stopContinuousRecognition" -> { |
|
||||
try { |
|
||||
if (!azureAsrHelper.isContinuousRecognitionActive()) { |
|
||||
result.success(true) |
|
||||
return@setMethodCallHandler |
|
||||
} |
|
||||
|
|
||||
val success = azureAsrHelper.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { |
|
||||
override fun onResult(text: String, detectedLanguage: String) {} |
|
||||
override fun onRecognizing(recognizing: String, detectedLanguage: String) {} |
|
||||
override fun onSessionStarted() {} |
|
||||
override fun onSessionStopped() {} |
|
||||
override fun onCanceled(reason: String, errorDetails: String) {} |
|
||||
override fun onError(error: String) { |
|
||||
result.error("STOP_ERROR", error, null) |
|
||||
} |
|
||||
}) |
|
||||
result.success(success) |
|
||||
} catch (e: Exception) { |
|
||||
result.error("STOP_ERROR", e.message, null) |
|
||||
} |
|
||||
} |
|
||||
"isContinuousRecognitionActive" -> { |
|
||||
result.success(azureAsrHelper.isContinuousRecognitionActive()) |
|
||||
} |
|
||||
"dispose" -> { |
|
||||
azureAsrHelper.dispose() |
|
||||
result.success(true) |
|
||||
} |
|
||||
else -> { |
|
||||
result.notImplemented() |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 设置 Azure TTS 方法通道 |
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AZURE_TTS_CHANNEL).setMethodCallHandler { call, result -> |
|
||||
when (call.method) { |
|
||||
"initialize" -> { |
|
||||
val subscriptionKey = call.argument<String>("subscriptionKey") ?: "" |
|
||||
val region = call.argument<String>("region") ?: "" |
|
||||
val language = call.argument<String>("language") ?: "zh-CN" |
|
||||
|
|
||||
val success = azureTtsHelper.initialize(subscriptionKey, region, language) |
|
||||
result.success(success) |
|
||||
} |
|
||||
"setVoice" -> { |
|
||||
val voiceName = call.argument<String>("voiceName") ?: return@setMethodCallHandler result.error("INVALID_ARGUMENTS", "语音名称不能为空", null) |
|
||||
result.success(azureTtsHelper.setVoice(voiceName)) |
|
||||
} |
|
||||
"setSpeechParams" -> { |
|
||||
val rate = call.argument<Int>("rate") ?: 0 |
|
||||
val pitch = call.argument<Int>("pitch") ?: 0 |
|
||||
val volume = call.argument<Int>("volume") ?: 100 |
|
||||
result.success(azureTtsHelper.setSpeechParams(rate, pitch, volume)) |
|
||||
} |
|
||||
"setAudioOutputType" -> { |
|
||||
val outputTypeStr = call.argument<String>("outputType") ?: "speaker" |
|
||||
val outputType = when (outputTypeStr.lowercase()) { |
|
||||
"speaker" -> AzureTtsHelper.AudioOutputType.SPEAKER |
|
||||
"earpiece" -> AzureTtsHelper.AudioOutputType.EARPIECE |
|
||||
"auto" -> AzureTtsHelper.AudioOutputType.AUTO |
|
||||
else -> AzureTtsHelper.AudioOutputType.SPEAKER |
|
||||
} |
|
||||
result.success(azureTtsHelper.setAudioOutputType(outputType)) |
|
||||
} |
|
||||
"speakText" -> { |
|
||||
val text = call.argument<String>("text") ?: return@setMethodCallHandler result.error("INVALID_ARGUMENTS", "文本不能为空", null) |
|
||||
|
|
||||
azureTtsHelper.speakText(text, object : AzureTtsHelper.TTSCallback { |
|
||||
override fun onSuccess(message: String) { |
|
||||
runOnUiThread { result.success(message) } |
|
||||
} |
|
||||
|
|
||||
override fun onError(error: String) { |
|
||||
runOnUiThread { result.error("SPEAK_ERROR", error, null) } |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
"speakSsml" -> { |
|
||||
val ssml = call.argument<String>("ssml") ?: return@setMethodCallHandler result.error("INVALID_ARGUMENTS", "SSML不能为空", null) |
|
||||
|
|
||||
azureTtsHelper.speakSsml(ssml, object : AzureTtsHelper.TTSCallback { |
|
||||
override fun onSuccess(message: String) { |
|
||||
runOnUiThread { result.success(message) } |
|
||||
} |
|
||||
|
|
||||
override fun onError(error: String) { |
|
||||
runOnUiThread { result.error("SPEAK_ERROR", error, null) } |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
"stopSpeaking" -> { |
|
||||
result.success(azureTtsHelper.stopSpeaking()) |
|
||||
} |
|
||||
"isSpeaking" -> { |
|
||||
result.success(azureTtsHelper.isSpeaking()) |
|
||||
} |
|
||||
"dispose" -> { |
|
||||
azureTtsHelper.dispose() |
|
||||
result.success(true) |
|
||||
} |
|
||||
else -> { |
|
||||
result.notImplemented() |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 设置语音交互方法通道 |
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, VOICE_INTERACTION_CHANNEL).setMethodCallHandler { call, result -> |
|
||||
when (call.method) { |
|
||||
"startService" -> { |
|
||||
startVoiceInteractionService(call, result) |
|
||||
} |
|
||||
"stopService" -> { |
|
||||
stopVoiceInteractionService() |
|
||||
result.success(true) |
|
||||
} |
|
||||
"isServiceRunning" -> { |
|
||||
result.success(VoiceInteractionService.isServiceRunning()) |
|
||||
} |
|
||||
"pauseVoiceInteraction" -> { |
|
||||
pauseVoiceInteraction() |
|
||||
result.success(true) |
|
||||
} |
|
||||
else -> { |
|
||||
result.notImplemented() |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 设置经典蓝牙方法通道 |
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CLASSIC_BLUETOOTH_CHANNEL).setMethodCallHandler { call, result -> |
|
||||
when (call.method) { |
|
||||
"getConnectedA2dpDevices" -> { |
|
||||
try { |
|
||||
classicBluetoothHelper.getConnectedA2dpDevices(object : ClassicBluetoothHelper.BluetoothDevicesCallback { |
|
||||
override fun onSuccess(devices: List<Map<String, String>>) { |
|
||||
try { |
|
||||
result.success(devices) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "返回A2DP设备时发生异常: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override fun onError(code: String, message: String) { |
|
||||
try { |
|
||||
result.error(code, message, null) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "返回A2DP设备错误时发生异常: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
}) |
|
||||
} catch (e: Exception) { |
|
||||
try { |
|
||||
Log.e(TAG, "获取A2DP设备时发生异常: ${e.message}") |
|
||||
result.error("UNEXPECTED_ERROR", "获取A2DP设备时发生异常: ${e.message}", e.toString()) |
|
||||
} catch (e2: Exception) { |
|
||||
Log.e(TAG, "处理A2DP设备获取异常时发生二次异常: ${e2.message}") |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
"getConnectedHeadsetDevices" -> { |
|
||||
try { |
|
||||
classicBluetoothHelper.getConnectedHeadsetDevices(object : ClassicBluetoothHelper.BluetoothDevicesCallback { |
|
||||
override fun onSuccess(devices: List<Map<String, String>>) { |
|
||||
try { |
|
||||
result.success(devices) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "返回耳机设备时发生异常: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override fun onError(code: String, message: String) { |
|
||||
try { |
|
||||
result.error(code, message, null) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "返回耳机设备错误时发生异常: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
}) |
|
||||
} catch (e: Exception) { |
|
||||
try { |
|
||||
Log.e(TAG, "获取耳机设备时发生异常: ${e.message}") |
|
||||
result.error("UNEXPECTED_ERROR", "获取耳机设备时发生异常: ${e.message}", e.toString()) |
|
||||
} catch (e2: Exception) { |
|
||||
Log.e(TAG, "处理耳机设备获取异常时发生二次异常: ${e2.message}") |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
"isBluetoothEnabled" -> { |
|
||||
try { |
|
||||
val isEnabled = classicBluetoothHelper.isBluetoothEnabled() |
|
||||
result.success(isEnabled) |
|
||||
Log.d(TAG, "检查蓝牙状态: ${if (isEnabled) "已启用" else "未启用"}") |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "检查蓝牙状态时发生异常: ${e.message}") |
|
||||
result.error("BLUETOOTH_ERROR", "检查蓝牙状态失败: ${e.message}", e.toString()) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
"initialize" -> { |
|
||||
result.success(true) |
|
||||
} |
|
||||
else -> result.notImplemented() |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// ASR 事件发送方法 |
|
||||
private fun sendAsrEvent(event: Map<String, Any>) { |
|
||||
if (azureAsrEventSink == null) return |
|
||||
|
|
||||
runOnUiThread { |
|
||||
azureAsrEventSink?.success(event) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 启动语音交互服务(带配置参数) |
|
||||
*/ |
|
||||
private fun startVoiceInteractionService(call: MethodCall, result: MethodChannel.Result) { |
|
||||
FileLogger.d(TAG, "启动语音交互服务") |
|
||||
|
|
||||
// 获取配置参数 |
|
||||
val key = call.argument<String>("azure_speech_key") ?: "" |
|
||||
val region = call.argument<String>("azure_speech_region") ?: "" |
|
||||
val aiKey = call.argument<String>("volcano_ai_api_key") ?: "" |
|
||||
|
|
||||
// 设置 Azure Speech 配置 |
|
||||
azureSpeechKey = key |
|
||||
azureSpeechRegion = region |
|
||||
volcanoAiApiKey = aiKey |
|
||||
|
|
||||
// 保存密钥到安全存储 |
|
||||
saveKeysToSecureStorage(applicationContext) |
|
||||
|
|
||||
// 创建 Intent 并启动服务 |
|
||||
val serviceIntent = Intent(this, VoiceInteractionService::class.java) |
|
||||
|
|
||||
// 在 Android O 及以上版本,需要使用 startForegroundService |
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|
||||
startForegroundService(serviceIntent) |
|
||||
} else { |
|
||||
startService(serviceIntent) |
|
||||
} |
|
||||
|
|
||||
// 返回成功结果 |
|
||||
result.success(true) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止语音交互服务 |
|
||||
*/ |
|
||||
private fun stopVoiceInteractionService() { |
|
||||
Log.d(TAG, "停止语音交互服务") |
|
||||
val serviceIntent = Intent(this, VoiceInteractionService::class.java) |
|
||||
stopService(serviceIntent) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 暂停语音交互(停止TTS和语音识别,但保持服务运行) |
|
||||
*/ |
|
||||
private fun pauseVoiceInteraction() { |
|
||||
Log.d(TAG, "发送暂停语音交互请求") |
|
||||
val intent = Intent(this, VoiceInteractionService::class.java).apply { |
|
||||
action = VoiceInteractionService.ACTION_PAUSE_VOICE_INTERACTION |
|
||||
} |
|
||||
|
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|
||||
startForegroundService(intent) |
|
||||
} else { |
|
||||
startService(intent) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 发送聊天记录事件到 Flutter |
|
||||
*/ |
|
||||
private fun sendChatHistoryEvent(agentId: String, userMessage: String, assistantMessage: String, timestamp: Long) { |
|
||||
if (voiceInteractionEventSink == null) { |
|
||||
Log.e(TAG, "无法发送聊天记录事件:事件通道未准备好") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
runOnUiThread { |
|
||||
try { |
|
||||
val eventData = mapOf( |
|
||||
"type" to "chat_history_updated", |
|
||||
"agentId" to agentId, |
|
||||
"userMessage" to userMessage, |
|
||||
"assistantMessage" to assistantMessage, |
|
||||
"timestamp" to timestamp |
|
||||
) |
|
||||
voiceInteractionEventSink?.success(eventData) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "发送聊天记录事件到Flutter失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 发送语音交互事件到 Flutter |
|
||||
*/ |
|
||||
private fun sendVoiceInteractionEvent(event: Map<String, Any>) { |
|
||||
if (voiceInteractionEventSink == null) { |
|
||||
Log.e(TAG, "无法发送语音交互事件:事件通道未准备好") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
runOnUiThread { |
|
||||
try { |
|
||||
voiceInteractionEventSink?.success(event) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "发送语音交互事件到Flutter失败: ${e.message}", e) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 发送蓝牙事件到Flutter |
|
||||
private fun sendBluetoothEvent(event: Map<String, Any>) { |
|
||||
if (bluetoothEventSink == null) { |
|
||||
Log.w(TAG, "无法发送蓝牙事件:事件通道未准备好") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
runOnUiThread { |
|
||||
try { |
|
||||
bluetoothEventSink?.success(event) |
|
||||
Log.d(TAG, "已发送蓝牙事件: ${event["type"]}") |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "发送蓝牙事件失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "转到UI线程发送蓝牙事件失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override fun onDestroy() { |
|
||||
Log.d(TAG, "onDestroy") |
|
||||
// 注销所有广播接收器 |
|
||||
try { |
|
||||
unregisterReceiver(voiceInteractionReceiver) |
|
||||
unregisterReceiver(bluetoothStateReceiver) |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "注销广播接收器失败: ${e.message}") |
|
||||
} |
|
||||
|
|
||||
// 释放资源 |
|
||||
azureAsrHelper.dispose() |
|
||||
azureTtsHelper.dispose() |
|
||||
classicBluetoothHelper.dispose() |
|
||||
|
|
||||
super.onDestroy() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@ -1,913 +0,0 @@ |
|||||
package com.yunqiinnovation.deepsound |
|
||||
|
|
||||
import android.app.Notification |
|
||||
import android.app.NotificationChannel |
|
||||
import android.app.NotificationManager |
|
||||
import android.app.PendingIntent |
|
||||
import android.app.Service |
|
||||
import android.content.Context |
|
||||
import android.content.Intent |
|
||||
import android.os.Build |
|
||||
import android.os.IBinder |
|
||||
import android.util.Log |
|
||||
import android.view.KeyEvent |
|
||||
import androidx.annotation.Nullable |
|
||||
import androidx.core.app.NotificationCompat |
|
||||
import androidx.media.session.MediaButtonReceiver |
|
||||
import android.support.v4.media.session.MediaSessionCompat |
|
||||
import android.support.v4.media.session.PlaybackStateCompat |
|
||||
import android.content.ComponentName |
|
||||
import android.media.AudioManager |
|
||||
import android.os.Handler |
|
||||
import android.os.Looper |
|
||||
import java.util.concurrent.atomic.AtomicBoolean |
|
||||
import org.json.JSONArray |
|
||||
import android.media.MediaPlayer |
|
||||
import android.media.AudioAttributes |
|
||||
import android.net.Uri |
|
||||
import com.yunqiinnovation.deepsound.core.utils.FileLogger |
|
||||
|
|
||||
/** |
|
||||
* 后台语音交互 Service: |
|
||||
* 1) 前台服务,确保不会被系统轻易杀死 |
|
||||
* 2) MediaSession 捕获蓝牙耳机按键 |
|
||||
* 3) 处理录音/语音识别 |
|
||||
*/ |
|
||||
class VoiceInteractionService : Service() { |
|
||||
|
|
||||
companion object { |
|
||||
private const val TAG = "VoiceInteractionService" |
|
||||
private const val NOTIFICATION_ID = 1001 |
|
||||
private const val CHANNEL_ID = "voice_interaction_channel" |
|
||||
|
|
||||
// 语音识别超时时间(毫秒) |
|
||||
private const val RECOGNITION_TIMEOUT = 8000L |
|
||||
|
|
||||
// 用于跟踪服务是否正在运行 |
|
||||
private val isRunning = AtomicBoolean(false) |
|
||||
|
|
||||
// 获取服务运行状态 |
|
||||
fun isServiceRunning(): Boolean = isRunning.get() |
|
||||
|
|
||||
// 广播 Action |
|
||||
const val ACTION_RECOGNITION_STARTED = "com.yunqiinnovation.deepsound.ACTION_RECOGNITION_STARTED" |
|
||||
const val ACTION_PAUSE_VOICE_INTERACTION = "com.yunqiinnovation.deepsound.ACTION_PAUSE_VOICE_INTERACTION" |
|
||||
const val ACTION_CHAT_HISTORY_UPDATED = "com.yunqiinnovation.deepsound.ACTION_CHAT_HISTORY_UPDATED" |
|
||||
} |
|
||||
|
|
||||
// 服务状态 |
|
||||
private var isActive = false // 服务是否活跃 |
|
||||
private var isRecognitionActive = false // 语音识别是否活跃 |
|
||||
private var isTimeoutPaused = false // 是否因超时暂停 |
|
||||
private var hasSpeechDetected = false // 是否检测到语音 |
|
||||
private var isTtsSpeaking = false // 是否正在播放TTS |
|
||||
|
|
||||
// 按键处理 |
|
||||
private var lastKeyEventTime = 0L |
|
||||
private var keyEventCount = 0 |
|
||||
|
|
||||
// 活动时间 |
|
||||
private var lastActivityTime = 0L |
|
||||
|
|
||||
// 当前用户输入 |
|
||||
private var currentUserInput = "" |
|
||||
|
|
||||
|
|
||||
// 服务组件 |
|
||||
private lateinit var mediaSession: MediaSessionCompat |
|
||||
private lateinit var audioManager: AudioManager |
|
||||
private lateinit var azureAsrHelper: AzureAsrHelper |
|
||||
private lateinit var azureTtsHelper: AzureTtsHelper |
|
||||
private lateinit var volcanoAIService: VolcanoAIService |
|
||||
|
|
||||
// 定时器 |
|
||||
private val handler = Handler(Looper.getMainLooper()) |
|
||||
private val serviceMonitor = object : Runnable { |
|
||||
override fun run() { |
|
||||
monitorService() |
|
||||
handler.postDelayed(this, 1000) // 每秒执行一次 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 系统提示词 |
|
||||
private val systemPrompt = """ |
|
||||
你是一个智能语音助手,能够简洁明了地回答用户的问题。 |
|
||||
请保持回答简短、准确,避免过长的解释。 |
|
||||
如果用户的问题不清楚,请礼貌地请求澄清。 |
|
||||
不要使用复杂的术语,除非用户明确要求。 |
|
||||
用户用语音和你交互. |
|
||||
""".trimIndent() |
|
||||
|
|
||||
// 添加媒体播放器 |
|
||||
private var audioPlayer: MiniMediaPlayer? = null |
|
||||
|
|
||||
override fun onCreate() { |
|
||||
super.onCreate() |
|
||||
// 初始化文件日志系统 |
|
||||
FileLogger.init(applicationContext) |
|
||||
FileLogger.d(TAG, "onCreate - 语音交互服务正在创建") |
|
||||
|
|
||||
// 初始化状态 |
|
||||
isRunning.set(true) |
|
||||
resetState() |
|
||||
|
|
||||
// 初始化组件 |
|
||||
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager |
|
||||
FileLogger.d(TAG, "AudioManager初始化完成") |
|
||||
|
|
||||
initServices() |
|
||||
initMediaSession() |
|
||||
registerMediaButtonReceiver() |
|
||||
|
|
||||
// 创建音频播放器 |
|
||||
audioPlayer = MiniMediaPlayer(applicationContext) |
|
||||
audioPlayer?.play(R.raw.silence, 0f) |
|
||||
|
|
||||
// 设置为媒体播放状态 |
|
||||
setPlaybackState(PlaybackStateCompat.STATE_PAUSED) |
|
||||
// FileLogger.d(TAG, "设置播放状态为STATE_PAUSED") |
|
||||
|
|
||||
// 启动监控和前台服务 |
|
||||
startMonitoring() |
|
||||
startForegroundServiceWithNotification() |
|
||||
|
|
||||
FileLogger.d(TAG, "VoiceInteractionService 初始化完成,等待蓝牙按键事件...") |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 重置所有状态 |
|
||||
*/ |
|
||||
private fun resetState() { |
|
||||
isActive = false |
|
||||
isRecognitionActive = false |
|
||||
isTimeoutPaused = false |
|
||||
hasSpeechDetected = false |
|
||||
isTtsSpeaking = false |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 初始化所有服务 |
|
||||
*/ |
|
||||
private fun initServices() { |
|
||||
// 创建新的Azure服务实例 |
|
||||
FileLogger.d(TAG, "创建新的Azure服务实例") |
|
||||
azureAsrHelper = AzureAsrHelper(this) |
|
||||
azureTtsHelper = AzureTtsHelper(this) |
|
||||
|
|
||||
// 尝试从静态变量获取配置 |
|
||||
var subscriptionKey = MainActivity.azureSpeechKey |
|
||||
var serviceRegion = MainActivity.azureSpeechRegion |
|
||||
var volcanoKey = MainActivity.volcanoAiApiKey |
|
||||
|
|
||||
// 如果静态变量中没有配置,尝试从加密存储中加载 |
|
||||
if (subscriptionKey.isEmpty() || serviceRegion.isEmpty() || volcanoKey.isEmpty()) { |
|
||||
FileLogger.d(TAG, "静态变量中的配置信息不完整,尝试从加密存储加载") |
|
||||
|
|
||||
// 从加密存储加载密钥 |
|
||||
val keysLoaded = MainActivity.loadKeysFromSecureStorage(applicationContext) |
|
||||
|
|
||||
if (keysLoaded) { |
|
||||
// 更新本地变量 |
|
||||
subscriptionKey = MainActivity.azureSpeechKey |
|
||||
serviceRegion = MainActivity.azureSpeechRegion |
|
||||
volcanoKey = MainActivity.volcanoAiApiKey |
|
||||
|
|
||||
FileLogger.d(TAG, "已从加密存储加载配置信息") |
|
||||
} else { |
|
||||
FileLogger.e(TAG, "无法从加密存储加载配置信息") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 初始化语音服务 |
|
||||
if (subscriptionKey.isNotEmpty() && serviceRegion.isNotEmpty()) { |
|
||||
// 初始化ASR |
|
||||
azureAsrHelper.initialize(subscriptionKey, serviceRegion, arrayOf("zh-CN")) |
|
||||
|
|
||||
// 初始化TTS |
|
||||
azureTtsHelper.initialize(subscriptionKey, serviceRegion, "zh-CN") |
|
||||
|
|
||||
FileLogger.d(TAG, "Azure语音服务已初始化") |
|
||||
} else { |
|
||||
FileLogger.e(TAG, "Azure配置信息不完整,无法初始化Azure服务") |
|
||||
} |
|
||||
|
|
||||
// 初始化火山AI服务 |
|
||||
volcanoAIService = VolcanoAIService() |
|
||||
|
|
||||
// 初始化火山AI服务 |
|
||||
if (volcanoKey.isNotEmpty()) { |
|
||||
volcanoAIService.initialize(volcanoKey) |
|
||||
FileLogger.d(TAG, "火山AI服务已初始化") |
|
||||
} else { |
|
||||
FileLogger.e(TAG, "火山AI配置信息不完整,无法初始化火山AI服务") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 初始化 MediaSession |
|
||||
*/ |
|
||||
private fun initMediaSession() { |
|
||||
FileLogger.d(TAG, "开始初始化MediaSession") |
|
||||
mediaSession = MediaSessionCompat(applicationContext, "VoiceServiceSession") |
|
||||
|
|
||||
// 设置标志和状态 |
|
||||
mediaSession.setFlags( |
|
||||
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or |
|
||||
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS |
|
||||
) |
|
||||
FileLogger.d(TAG, "MediaSession标志设置完成") |
|
||||
|
|
||||
// 设置回调 |
|
||||
mediaSession.setCallback(object : MediaSessionCompat.Callback() { |
|
||||
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { |
|
||||
val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { |
|
||||
mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) |
|
||||
} else { |
|
||||
@Suppress("DEPRECATION") |
|
||||
mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? KeyEvent |
|
||||
} |
|
||||
|
|
||||
if (keyEvent != null) { |
|
||||
// 仅处理按下操作和相关按键 |
|
||||
if (keyEvent.action == KeyEvent.ACTION_DOWN) { |
|
||||
when (keyEvent.keyCode) { |
|
||||
KeyEvent.KEYCODE_HEADSETHOOK, |
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY, |
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE, |
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { |
|
||||
FileLogger.d(TAG, "接收到相关媒体按钮: keyCode=${keyEvent.keyCode}") |
|
||||
handleDoubleClick() |
|
||||
return true |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
FileLogger.d(TAG, "忽略不相关媒体按钮: action=${keyEvent.action}, keyCode=${keyEvent.keyCode}") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
FileLogger.d(TAG, "收到无按键的媒体按钮事件") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
override fun onPlay() { |
|
||||
FileLogger.d(TAG, "收到onPlay回调") |
|
||||
handleDoubleClick() |
|
||||
return super.onPlay() |
|
||||
} |
|
||||
|
|
||||
override fun onPause() { |
|
||||
FileLogger.d(TAG, "收到onPause回调") |
|
||||
handleDoubleClick() |
|
||||
return super.onPause() |
|
||||
} |
|
||||
}) |
|
||||
|
|
||||
// 激活 MediaSession |
|
||||
mediaSession.isActive = true |
|
||||
FileLogger.d(TAG, "MediaSession已激活") |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 注册媒体按钮接收器 |
|
||||
*/ |
|
||||
private fun registerMediaButtonReceiver() { |
|
||||
try { |
|
||||
val mediaButtonReceiver = ComponentName(packageName, MediaButtonReceiver::class.java.name) |
|
||||
audioManager.registerMediaButtonEventReceiver(mediaButtonReceiver) |
|
||||
FileLogger.d(TAG, "媒体按钮接收器注册成功: $mediaButtonReceiver") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "媒体按钮接收器注册失败: ${e.message}", e) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 启动监控 |
|
||||
*/ |
|
||||
private fun startMonitoring() { |
|
||||
handler.post(serviceMonitor) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止监控 |
|
||||
*/ |
|
||||
private fun stopMonitoring() { |
|
||||
handler.removeCallbacks(serviceMonitor) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 监控服务状态 |
|
||||
*/ |
|
||||
private fun monitorService() { |
|
||||
// 确保服务保持活跃状态 |
|
||||
if (!isActive) { |
|
||||
isActive = true |
|
||||
} |
|
||||
|
|
||||
// 检查语音识别状态 |
|
||||
if (isRecognitionActive) { |
|
||||
val currentTime = System.currentTimeMillis() |
|
||||
val elapsedTime = currentTime - lastActivityTime |
|
||||
|
|
||||
// 如果超过5秒没有检测到语音,且不在TTS播放中,暂停语音识别 |
|
||||
if (!hasSpeechDetected && !isTtsSpeaking && elapsedTime >= RECOGNITION_TIMEOUT) { |
|
||||
FileLogger.d(TAG, "超过${RECOGNITION_TIMEOUT/1000}秒未检测到语音,停止识别") |
|
||||
isTimeoutPaused = true |
|
||||
playNotification("没有听到您说话,已暂停对话。双击耳机按钮可重新开始。") |
|
||||
|
|
||||
// 停止语音识别并确保资源完全释放 |
|
||||
stopVoiceRecognition() |
|
||||
|
|
||||
// 清理识别状态 |
|
||||
isRecognitionActive = false |
|
||||
hasSpeechDetected = false |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 处理双击事件 |
|
||||
*/ |
|
||||
private fun handleDoubleClick() { |
|
||||
FileLogger.d(TAG, "处理双击事件") |
|
||||
|
|
||||
//屏蔽短时间重复响应的问题 |
|
||||
val currentTime = System.currentTimeMillis() |
|
||||
val timeDiff = currentTime - lastKeyEventTime |
|
||||
if (timeDiff < 500) { |
|
||||
FileLogger.d(TAG, "短时间重复响应,忽略") |
|
||||
return |
|
||||
} |
|
||||
lastKeyEventTime = currentTime |
|
||||
|
|
||||
// 停止当前TTS播放 |
|
||||
stopCurrentTTS() |
|
||||
|
|
||||
setPlaybackState(PlaybackStateCompat.STATE_PLAYING) |
|
||||
|
|
||||
// 播放提示音 |
|
||||
playPrompt("我在!") |
|
||||
// audioPlayer?.play(R.raw.listening) |
|
||||
|
|
||||
|
|
||||
// 设置为播放状态 |
|
||||
setPlaybackState(PlaybackStateCompat.STATE_PAUSED) |
|
||||
|
|
||||
// 重置超时暂停标志 |
|
||||
isTimeoutPaused = false |
|
||||
|
|
||||
// 启动或重置语音识别 |
|
||||
if (!isRecognitionActive) { |
|
||||
FileLogger.d(TAG, "语音识别未激活,开始启动") |
|
||||
|
|
||||
// 如果之前是因为超时暂停,重新初始化语音识别组件 |
|
||||
// if (isTimeoutPaused) { |
|
||||
// Log.d(TAG, "之前因超时暂停,重新初始化Azure服务") |
|
||||
// restartAsr() |
|
||||
// } |
|
||||
|
|
||||
startVoiceRecognition() |
|
||||
} else { |
|
||||
FileLogger.d(TAG, "语音识别已激活,更新活动时间") |
|
||||
updateLastActivityTime() |
|
||||
hasSpeechDetected = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 开始语音识别 |
|
||||
*/ |
|
||||
private fun startVoiceRecognition() { |
|
||||
if (isRecognitionActive) return |
|
||||
|
|
||||
// 检查录音权限 |
|
||||
if (!checkRecordAudioPermission()) { |
|
||||
playNotification("需要录音权限,请在设置中授予权限") |
|
||||
return |
|
||||
} |
|
||||
// 通知 Flutter 语音识别已启动 |
|
||||
notifyRecognitionStarted() |
|
||||
|
|
||||
isActive = true |
|
||||
isRecognitionActive = true |
|
||||
hasSpeechDetected = false |
|
||||
updateLastActivityTime() |
|
||||
|
|
||||
try { |
|
||||
azureAsrHelper.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { |
|
||||
override fun onRecognizing(recognizing: String, detectedLanguage: String) { |
|
||||
if (recognizing.isNotEmpty()) { |
|
||||
hasSpeechDetected = true |
|
||||
stopCurrentTTS() |
|
||||
updateLastActivityTime() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override fun onResult(result: String, detectedLanguage: String) { |
|
||||
if (result.isNotEmpty()) { |
|
||||
processWithVolcanoAI(result) |
|
||||
} |
|
||||
|
|
||||
// 重置状态,继续识别 |
|
||||
hasSpeechDetected = false |
|
||||
updateLastActivityTime() |
|
||||
} |
|
||||
|
|
||||
override fun onSessionStarted() { |
|
||||
updateLastActivityTime() |
|
||||
|
|
||||
} |
|
||||
|
|
||||
override fun onSessionStopped() { |
|
||||
isRecognitionActive = false |
|
||||
|
|
||||
|
|
||||
} |
|
||||
|
|
||||
override fun onCanceled(reason: String, errorDetails: String) { |
|
||||
isRecognitionActive = false |
|
||||
|
|
||||
|
|
||||
} |
|
||||
|
|
||||
override fun onError(error: String) { |
|
||||
isRecognitionActive = false |
|
||||
playNotification("语音识别出错") |
|
||||
|
|
||||
} |
|
||||
}) |
|
||||
} catch (e: Exception) { |
|
||||
isRecognitionActive = false |
|
||||
FileLogger.e(TAG, "启动语音识别失败: ${e.message}", e) |
|
||||
playNotification("启动语音识别失败") |
|
||||
|
|
||||
|
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止语音识别 |
|
||||
*/ |
|
||||
private fun stopVoiceRecognition() { |
|
||||
if (!isRecognitionActive) return |
|
||||
|
|
||||
FileLogger.d(TAG, "停止语音识别") |
|
||||
|
|
||||
try { |
|
||||
azureAsrHelper.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { |
|
||||
override fun onResult(result: String, detectedLanguage: String) {} |
|
||||
override fun onRecognizing(recognizing: String, detectedLanguage: String) {} |
|
||||
override fun onSessionStarted() {} |
|
||||
override fun onSessionStopped() { |
|
||||
isRecognitionActive = false |
|
||||
FileLogger.d(TAG, "语音识别会话已停止") |
|
||||
} |
|
||||
override fun onCanceled(reason: String, errorDetails: String) { |
|
||||
isRecognitionActive = false |
|
||||
FileLogger.d(TAG, "语音识别已取消: $reason") |
|
||||
} |
|
||||
override fun onError(error: String) { |
|
||||
isRecognitionActive = false |
|
||||
FileLogger.e(TAG, "停止语音识别时出错: $error") |
|
||||
} |
|
||||
}) |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "停止语音识别异常: ${e.message}", e) |
|
||||
// 确保状态一致性 |
|
||||
isRecognitionActive = false |
|
||||
} |
|
||||
|
|
||||
setPlaybackState(PlaybackStateCompat.STATE_PAUSED) |
|
||||
isRecognitionActive = false |
|
||||
hasSpeechDetected = false |
|
||||
// 不重置 isTimeoutPaused,保留暂停原因 |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 使用VolcanoAI处理语音识别结果 |
|
||||
*/ |
|
||||
private fun processWithVolcanoAI(text: String) { |
|
||||
// 保存当前用户输入,用于后续同步聊天记录 |
|
||||
currentUserInput = text |
|
||||
|
|
||||
Thread { |
|
||||
try { |
|
||||
val messages = JSONArray().apply { |
|
||||
put(volcanoAIService.createUserMessage(text)) |
|
||||
} |
|
||||
|
|
||||
val response = volcanoAIService.sendMessage(messages, systemPrompt) |
|
||||
|
|
||||
// 播放AI回复 |
|
||||
speakAIResponse(response) |
|
||||
|
|
||||
// 同步聊天记录到Flutter端 |
|
||||
notifyChatHistoryUpdated("personal_assistant", text, response) |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "AI处理出错: ${e.message}") |
|
||||
playNotification("AI处理出错") |
|
||||
} |
|
||||
}.start() |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 播放AI回复 |
|
||||
*/ |
|
||||
private fun speakAIResponse(text: String) { |
|
||||
isTtsSpeaking = true |
|
||||
azureTtsHelper.speakText(text, object : AzureTtsHelper.TTSCallback { |
|
||||
override fun onSuccess(message: String) { |
|
||||
isTtsSpeaking = false |
|
||||
updateLastActivityTime() |
|
||||
} |
|
||||
|
|
||||
override fun onError(error: String) { |
|
||||
isTtsSpeaking = false |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 播放提示音 |
|
||||
*/ |
|
||||
private fun playPrompt(message: String) { |
|
||||
isTtsSpeaking = true |
|
||||
azureTtsHelper.speakText(message, object : AzureTtsHelper.TTSCallback { |
|
||||
override fun onSuccess(message: String) { isTtsSpeaking = false } |
|
||||
override fun onError(error: String) { isTtsSpeaking = false } |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 播放通知提示音 |
|
||||
*/ |
|
||||
private fun playNotification(message: String) { |
|
||||
isTtsSpeaking = true |
|
||||
// 更新播放状态为播放中,增加接收蓝牙按键事件的几率 |
|
||||
setPlaybackState(PlaybackStateCompat.STATE_PLAYING) |
|
||||
// 确保媒体会话处于活跃状态 |
|
||||
mediaSession.isActive = true |
|
||||
|
|
||||
azureTtsHelper.speakText(message, object : AzureTtsHelper.TTSCallback { |
|
||||
override fun onSuccess(message: String) { |
|
||||
isTtsSpeaking = false |
|
||||
setPlaybackState(PlaybackStateCompat.STATE_PAUSED) |
|
||||
} |
|
||||
|
|
||||
override fun onError(error: String) { |
|
||||
isTtsSpeaking = false |
|
||||
setPlaybackState(PlaybackStateCompat.STATE_PAUSED) |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止当前TTS播放 |
|
||||
*/ |
|
||||
private fun stopCurrentTTS() { |
|
||||
if (isTtsSpeaking) { |
|
||||
azureTtsHelper.stopSpeaking() |
|
||||
isTtsSpeaking = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 更新最后活动时间 |
|
||||
*/ |
|
||||
private fun updateLastActivityTime() { |
|
||||
lastActivityTime = System.currentTimeMillis() |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置播放状态 |
|
||||
*/ |
|
||||
private fun setPlaybackState(state: Int) { |
|
||||
val playbackStateBuilder = PlaybackStateCompat.Builder() |
|
||||
.setActions( |
|
||||
PlaybackStateCompat.ACTION_PLAY or |
|
||||
PlaybackStateCompat.ACTION_PAUSE or |
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE or |
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or |
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or |
|
||||
PlaybackStateCompat.ACTION_STOP or |
|
||||
PlaybackStateCompat.ACTION_SEEK_TO or |
|
||||
PlaybackStateCompat.ACTION_FAST_FORWARD or |
|
||||
PlaybackStateCompat.ACTION_REWIND |
|
||||
) |
|
||||
// 使用更高的播放速度,让系统认为是真正的播放 |
|
||||
.setState(state, 0, 1.0f, System.currentTimeMillis()) |
|
||||
mediaSession.setPlaybackState(playbackStateBuilder.build()) |
|
||||
|
|
||||
// 确保MediaSession激活 |
|
||||
if (!mediaSession.isActive) { |
|
||||
mediaSession.isActive = true |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 检查录音权限 |
|
||||
*/ |
|
||||
private fun checkRecordAudioPermission(): Boolean { |
|
||||
val permission = android.Manifest.permission.RECORD_AUDIO |
|
||||
val result = applicationContext.checkCallingOrSelfPermission(permission) |
|
||||
return result == android.content.pm.PackageManager.PERMISSION_GRANTED |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 启动前台服务 |
|
||||
*/ |
|
||||
private fun startForegroundServiceWithNotification() { |
|
||||
val notification = createNotification() |
|
||||
startForeground(NOTIFICATION_ID, notification) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 创建通知 |
|
||||
*/ |
|
||||
private fun createNotification(): Notification { |
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager |
|
||||
|
|
||||
// Android 8.0+ 需要创建通知渠道 |
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|
||||
val channel = NotificationChannel( |
|
||||
CHANNEL_ID, |
|
||||
"语音交互服务", |
|
||||
NotificationManager.IMPORTANCE_LOW |
|
||||
) |
|
||||
manager.createNotificationChannel(channel) |
|
||||
} |
|
||||
|
|
||||
// 点击通知打开MainActivity |
|
||||
val intent = Intent(this, MainActivity::class.java).apply { |
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP |
|
||||
} |
|
||||
val pendingIntent = PendingIntent.getActivity( |
|
||||
this, |
|
||||
0, |
|
||||
intent, |
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT |
|
||||
) |
|
||||
|
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID) |
|
||||
.setContentTitle("语音助手") |
|
||||
.setContentText("双击蓝牙耳机按键即可开始语音交互") |
|
||||
.setSmallIcon(R.mipmap.ic_launcher) |
|
||||
.setContentIntent(pendingIntent) |
|
||||
.build() |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 当 Service 被启动时调用 |
|
||||
*/ |
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { |
|
||||
FileLogger.d(TAG, "onStartCommand: intent=$intent, action=${intent?.action}") |
|
||||
|
|
||||
// 处理媒体按键 Intent |
|
||||
if (intent?.action == Intent.ACTION_MEDIA_BUTTON) { |
|
||||
FileLogger.d(TAG, "收到媒体按钮Intent: ${intent.extras}") |
|
||||
|
|
||||
val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { |
|
||||
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) |
|
||||
} else { |
|
||||
@Suppress("DEPRECATION") |
|
||||
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as? KeyEvent |
|
||||
} |
|
||||
|
|
||||
if (keyEvent != null) { |
|
||||
FileLogger.d(TAG, "从Intent中解析到按键事件: action=${keyEvent.action}, keyCode=${keyEvent.keyCode}") |
|
||||
// 使用统一的按键处理逻辑 |
|
||||
handleKeyEvent(keyEvent) |
|
||||
} |
|
||||
} else if (intent?.action == ACTION_PAUSE_VOICE_INTERACTION) { |
|
||||
// 处理来自 Flutter 的暂停请求 |
|
||||
FileLogger.d(TAG, "收到暂停请求") |
|
||||
pauseVoiceInteraction() |
|
||||
} else { |
|
||||
FileLogger.d(TAG, "收到其他Intent: action=${intent?.action}") |
|
||||
} |
|
||||
|
|
||||
// 让 MediaSession 处理 Intent,MediaSession将通过callback处理事件 |
|
||||
try { |
|
||||
MediaButtonReceiver.handleIntent(mediaSession, intent) |
|
||||
FileLogger.d(TAG, "MediaButtonReceiver处理Intent完成") |
|
||||
} catch (e: Exception) { |
|
||||
FileLogger.e(TAG, "MediaButtonReceiver处理Intent失败: ${e.message}", e) |
|
||||
} |
|
||||
|
|
||||
return START_STICKY |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 处理按键事件 |
|
||||
*/ |
|
||||
private fun handleKeyEvent(keyEvent: KeyEvent) { |
|
||||
FileLogger.d(TAG, "处理按键事件: action=${keyEvent.action}, keyCode=${keyEvent.keyCode}") |
|
||||
|
|
||||
// 只处理按下操作和相关媒体按键 |
|
||||
if (keyEvent.action == KeyEvent.ACTION_DOWN) { |
|
||||
when (keyEvent.keyCode) { |
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY, |
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE, |
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { |
|
||||
FileLogger.d(TAG, "处理标准媒体按键: ${keyEvent.keyCode}") |
|
||||
handleDoubleClick() |
|
||||
} |
|
||||
KeyEvent.KEYCODE_HEADSETHOOK -> { |
|
||||
FileLogger.d(TAG, "处理HEADSETHOOK按键") |
|
||||
// 检测双击 |
|
||||
val currentTime = System.currentTimeMillis() |
|
||||
val timeDiff = currentTime - lastKeyEventTime |
|
||||
|
|
||||
FileLogger.d(TAG, "按键时间差: $timeDiff ms, 当前计数: $keyEventCount") |
|
||||
|
|
||||
if (timeDiff < 500) { // 500毫秒内的按键视为连续按键 |
|
||||
keyEventCount++ |
|
||||
FileLogger.d(TAG, "连续按键,计数增加到: $keyEventCount") |
|
||||
if (keyEventCount == 1) { // 这是第二次按键,即双击 |
|
||||
FileLogger.d(TAG, "检测到双击,执行处理") |
|
||||
handleDoubleClick() |
|
||||
keyEventCount = 0 |
|
||||
} |
|
||||
} else { |
|
||||
FileLogger.d(TAG, "非连续按键,重置计数") |
|
||||
keyEventCount = 0 |
|
||||
} |
|
||||
lastKeyEventTime = currentTime |
|
||||
} |
|
||||
else -> { |
|
||||
FileLogger.d(TAG, "忽略不相关的按键: ${keyEvent.keyCode}") |
|
||||
} |
|
||||
} |
|
||||
} else { |
|
||||
FileLogger.d(TAG, "忽略非ACTION_DOWN事件: ${keyEvent.action}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@Nullable |
|
||||
override fun onBind(intent: Intent?): IBinder? = null |
|
||||
|
|
||||
override fun onDestroy() { |
|
||||
FileLogger.d(TAG, "onDestroy - 语音交互服务正在销毁") |
|
||||
|
|
||||
// 释放音频播放器 |
|
||||
audioPlayer?.release() |
|
||||
audioPlayer = null |
|
||||
|
|
||||
// 停止监控 |
|
||||
stopMonitoring() |
|
||||
|
|
||||
// 停止语音识别 |
|
||||
if (isRecognitionActive) { |
|
||||
stopVoiceRecognition() |
|
||||
} |
|
||||
|
|
||||
// 停止TTS |
|
||||
stopCurrentTTS() |
|
||||
|
|
||||
// 释放 MediaSession |
|
||||
mediaSession.release() |
|
||||
|
|
||||
// 释放Azure服务实例 |
|
||||
FileLogger.d(TAG, "释放Azure服务实例") |
|
||||
azureAsrHelper.dispose() |
|
||||
azureTtsHelper.dispose() |
|
||||
|
|
||||
// 更新服务状态 |
|
||||
isRunning.set(false) |
|
||||
|
|
||||
// 关闭日志系统 |
|
||||
FileLogger.shutdown() |
|
||||
|
|
||||
super.onDestroy() |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 暂停语音交互(停止TTS和语音识别,但保持服务运行) |
|
||||
* 用于从 Flutter 端控制服务 |
|
||||
*/ |
|
||||
private fun pauseVoiceInteraction() { |
|
||||
FileLogger.d(TAG, "暂停后台语音交互(来自Flutter的请求)") |
|
||||
|
|
||||
// 停止当前TTS播放 |
|
||||
stopCurrentTTS() |
|
||||
|
|
||||
// 停止语音识别 |
|
||||
if (isRecognitionActive) { |
|
||||
stopVoiceRecognition() |
|
||||
} |
|
||||
|
|
||||
// 设置为暂停状态,但保持服务活跃 |
|
||||
isTimeoutPaused = true |
|
||||
setPlaybackState(PlaybackStateCompat.STATE_PAUSED) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 通知 Flutter 端聊天记录已更新 |
|
||||
*/ |
|
||||
private fun notifyChatHistoryUpdated(agentId: String, userMessage: String, assistantMessage: String) { |
|
||||
FileLogger.d(TAG, "通知Flutter聊天记录已更新: agentId=$agentId") |
|
||||
FileLogger.d(TAG, "用户消息: ${userMessage.take(50)}...") |
|
||||
FileLogger.d(TAG, "助手回复: ${assistantMessage.take(50)}...") |
|
||||
|
|
||||
// 创建广播 Intent |
|
||||
val intent = Intent(ACTION_CHAT_HISTORY_UPDATED).apply { |
|
||||
putExtra("agentId", agentId) |
|
||||
putExtra("userMessage", userMessage) |
|
||||
putExtra("assistantMessage", assistantMessage) |
|
||||
putExtra("timestamp", System.currentTimeMillis()) |
|
||||
} |
|
||||
|
|
||||
// 发送广播 |
|
||||
sendBroadcast(intent) |
|
||||
FileLogger.d(TAG, "已发送聊天记录广播") |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 通知 Flutter 端语音识别已启动 |
|
||||
*/ |
|
||||
private fun notifyRecognitionStarted() { |
|
||||
FileLogger.d(TAG, "通知Flutter语音识别已启动") |
|
||||
|
|
||||
// 创建广播 Intent |
|
||||
val intent = Intent(ACTION_RECOGNITION_STARTED).apply { |
|
||||
// 可以添加额外数据 |
|
||||
putExtra("timestamp", System.currentTimeMillis()) |
|
||||
} |
|
||||
|
|
||||
// 发送广播 |
|
||||
sendBroadcast(intent) |
|
||||
} |
|
||||
|
|
||||
|
|
||||
/** |
|
||||
* 简易媒体播放器,用于播放raw中的音频资源 |
|
||||
*/ |
|
||||
inner class MiniMediaPlayer(private val context: Context) { |
|
||||
private var mediaPlayer: MediaPlayer? = null |
|
||||
|
|
||||
/** |
|
||||
* 播放音频资源 |
|
||||
* @param resId 资源ID (R.raw.xxx) |
|
||||
* @param volume 音量 0.0-1.0 |
|
||||
*/ |
|
||||
fun play(resId: Int, volume: Float = 1.0f) { |
|
||||
try { |
|
||||
// 释放之前的资源 |
|
||||
release() |
|
||||
|
|
||||
// 创建播放器并设置资源 |
|
||||
mediaPlayer = MediaPlayer().apply { |
|
||||
// 设置音频资源 |
|
||||
context.resources.openRawResourceFd(resId)?.use { fd -> |
|
||||
setDataSource(fd.fileDescriptor, fd.startOffset, fd.length) |
|
||||
} |
|
||||
|
|
||||
// 设置音量 |
|
||||
setVolume(volume.coerceIn(0f, 1f), volume.coerceIn(0f, 1f)) |
|
||||
|
|
||||
// 播放完成后自动释放 |
|
||||
setOnCompletionListener { release() } |
|
||||
|
|
||||
// 准备并播放 |
|
||||
prepare() |
|
||||
start() |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
release() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止播放 |
|
||||
*/ |
|
||||
fun stop() { |
|
||||
try { |
|
||||
mediaPlayer?.apply { |
|
||||
if (isPlaying) stop() |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
// 忽略异常 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 释放资源 |
|
||||
*/ |
|
||||
fun release() { |
|
||||
try { |
|
||||
mediaPlayer?.apply { |
|
||||
if (isPlaying) stop() |
|
||||
release() |
|
||||
} |
|
||||
mediaPlayer = null |
|
||||
} catch (e: Exception) { |
|
||||
// 忽略异常 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 是否正在播放 |
|
||||
*/ |
|
||||
fun isPlaying() = mediaPlayer?.isPlaying == true |
|
||||
} |
|
||||
|
|
||||
} |
|
||||
@ -1,344 +0,0 @@ |
|||||
package com.yunqiinnovation.deepsound |
|
||||
|
|
||||
import android.util.Log |
|
||||
import okhttp3.* |
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull |
|
||||
import okhttp3.RequestBody.Companion.toRequestBody |
|
||||
import org.json.JSONArray |
|
||||
import org.json.JSONObject |
|
||||
import java.io.IOException |
|
||||
import java.util.concurrent.CountDownLatch |
|
||||
import java.util.concurrent.TimeUnit |
|
||||
|
|
||||
/** |
|
||||
* 火山AI服务的原生实现 |
|
||||
* |
|
||||
* 参考Flutter端的VolcanoAIService实现,提供同步和异步的API调用方式 |
|
||||
*/ |
|
||||
class VolcanoAIService() { |
|
||||
private val TAG = "VolcanoAIService" |
|
||||
private val baseUrl = "https://ark.cn-beijing.volces.com/api/v3" |
|
||||
private val chatEndpoint = "/chat/completions" |
|
||||
private val client = OkHttpClient.Builder() |
|
||||
.connectTimeout(30, TimeUnit.SECONDS) |
|
||||
.readTimeout(30, TimeUnit.SECONDS) |
|
||||
.writeTimeout(30, TimeUnit.SECONDS) |
|
||||
.build() |
|
||||
|
|
||||
private var apiKey: String = "" |
|
||||
private var isInitialized = false |
|
||||
|
|
||||
/** |
|
||||
* 初始化火山AI服务 |
|
||||
* |
|
||||
* @param apiKey 火山AI API密钥 |
|
||||
* @return 初始化是否成功 |
|
||||
*/ |
|
||||
fun initialize(apiKey: String): Boolean { |
|
||||
this.apiKey = apiKey |
|
||||
isInitialized = apiKey.isNotEmpty() |
|
||||
|
|
||||
if (!isInitialized) { |
|
||||
Log.e(TAG, "初始化失败:API key 不能为空") |
|
||||
} else { |
|
||||
Log.d(TAG, "火山AI服务初始化成功") |
|
||||
} |
|
||||
|
|
||||
return isInitialized |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 生成个性化问候语 |
|
||||
* |
|
||||
* @param agentName 代理名称 |
|
||||
* @param systemPrompt 系统提示词 |
|
||||
* @param callback 回调函数,返回生成的问候语 |
|
||||
*/ |
|
||||
fun generateGreeting(agentName: String, systemPrompt: String, callback: (String?, Exception?) -> Unit) { |
|
||||
val messages = JSONArray().apply { |
|
||||
put(JSONObject().apply { |
|
||||
put("role", "system") |
|
||||
put("content", systemPrompt) |
|
||||
}) |
|
||||
put(JSONObject().apply { |
|
||||
put("role", "user") |
|
||||
put("content", "请用一句简短的话向我打个招呼,要符合你的身份和性格特点,不要超过18个字。") |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
sendMessageStream(messages, systemPrompt, object : StreamCallback { |
|
||||
val stringBuilder = StringBuilder() |
|
||||
|
|
||||
override fun onToken(token: String) { |
|
||||
stringBuilder.append(token) |
|
||||
} |
|
||||
|
|
||||
override fun onComplete() { |
|
||||
callback(stringBuilder.toString(), null) |
|
||||
} |
|
||||
|
|
||||
override fun onError(e: Exception) { |
|
||||
callback(null, e) |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 发送消息(非流式输出) |
|
||||
* |
|
||||
* @param messages 消息列表 |
|
||||
* @param systemPrompt 系统提示词 |
|
||||
* @return 返回AI的回复 |
|
||||
* @throws VolcanoAIException 如果API调用失败 |
|
||||
*/ |
|
||||
@Throws(VolcanoAIException::class) |
|
||||
fun sendMessage(messages: JSONArray, systemPrompt: String): String { |
|
||||
// 检查是否已初始化 |
|
||||
if (!isInitialized || apiKey.isEmpty()) { |
|
||||
throw VolcanoAIException("火山AI服务未初始化或API key为空,请先调用initialize方法") |
|
||||
} |
|
||||
|
|
||||
val fullMessages = JSONArray().apply { |
|
||||
put(JSONObject().apply { |
|
||||
put("role", "system") |
|
||||
put("content", systemPrompt) |
|
||||
}) |
|
||||
for (i in 0 until messages.length()) { |
|
||||
put(messages.getJSONObject(i)) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
val requestBody = JSONObject().apply { |
|
||||
put("model", "doubao-1-5-lite-32k-250115") |
|
||||
put("messages", fullMessages) |
|
||||
put("temperature", 0.7) |
|
||||
put("max_tokens", 2000) |
|
||||
put("stream", false) |
|
||||
} |
|
||||
|
|
||||
val mediaType = "application/json".toMediaTypeOrNull() |
|
||||
val request = Request.Builder() |
|
||||
.url("$baseUrl$chatEndpoint") |
|
||||
.addHeader("Content-Type", "application/json") |
|
||||
.addHeader("Authorization", "Bearer $apiKey") |
|
||||
.post(requestBody.toString().toRequestBody(mediaType)) |
|
||||
.build() |
|
||||
|
|
||||
try { |
|
||||
client.newCall(request).execute().use { response -> |
|
||||
if (!response.isSuccessful) { |
|
||||
val errorBody = response.body?.string() ?: "" |
|
||||
val errorMessage = try { |
|
||||
JSONObject(errorBody).getJSONObject("error").getString("message") |
|
||||
} catch (e: Exception) { |
|
||||
"Unknown error occurred" |
|
||||
} |
|
||||
throw VolcanoAIException(errorMessage) |
|
||||
} |
|
||||
|
|
||||
val responseBody = response.body?.string() ?: throw VolcanoAIException("Empty response") |
|
||||
val jsonResponse = JSONObject(responseBody) |
|
||||
|
|
||||
if (jsonResponse.has("choices") && |
|
||||
jsonResponse.getJSONArray("choices").length() > 0 && |
|
||||
jsonResponse.getJSONArray("choices").getJSONObject(0).has("message")) { |
|
||||
return jsonResponse.getJSONArray("choices") |
|
||||
.getJSONObject(0) |
|
||||
.getJSONObject("message") |
|
||||
.getString("content") |
|
||||
} |
|
||||
|
|
||||
throw VolcanoAIException("Invalid response format") |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
if (e is VolcanoAIException) throw e |
|
||||
throw VolcanoAIException("Failed to communicate with AI service: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 发送消息(流式输出) |
|
||||
* |
|
||||
* @param messages 消息列表 |
|
||||
* @param systemPrompt 系统提示词 |
|
||||
* @param callback 回调函数,用于接收流式输出的结果 |
|
||||
*/ |
|
||||
fun sendMessageStream(messages: JSONArray, systemPrompt: String, callback: StreamCallback) { |
|
||||
// 检查是否已初始化 |
|
||||
if (!isInitialized || apiKey.isEmpty()) { |
|
||||
callback.onError(VolcanoAIException("火山AI服务未初始化或API key为空,请先调用initialize方法")) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
val fullMessages = JSONArray().apply { |
|
||||
put(JSONObject().apply { |
|
||||
put("role", "system") |
|
||||
put("content", systemPrompt) |
|
||||
}) |
|
||||
for (i in 0 until messages.length()) { |
|
||||
put(messages.getJSONObject(i)) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
val requestBody = JSONObject().apply { |
|
||||
put("model", "doubao-1-5-lite-32k-250115") |
|
||||
put("messages", fullMessages) |
|
||||
put("temperature", 0.7) |
|
||||
put("max_tokens", 2000) |
|
||||
put("stream", true) |
|
||||
} |
|
||||
|
|
||||
val mediaType = "application/json".toMediaTypeOrNull() |
|
||||
val request = Request.Builder() |
|
||||
.url("$baseUrl$chatEndpoint") |
|
||||
.addHeader("Content-Type", "application/json") |
|
||||
.addHeader("Authorization", "Bearer $apiKey") |
|
||||
.addHeader("Accept", "text/event-stream") |
|
||||
.post(requestBody.toString().toRequestBody(mediaType)) |
|
||||
.build() |
|
||||
|
|
||||
client.newCall(request).enqueue(object : Callback { |
|
||||
override fun onFailure(call: Call, e: IOException) { |
|
||||
callback.onError(VolcanoAIException("Failed to communicate with AI service: ${e.message}")) |
|
||||
} |
|
||||
|
|
||||
override fun onResponse(call: Call, response: Response) { |
|
||||
if (!response.isSuccessful) { |
|
||||
val errorBody = response.body?.string() ?: "" |
|
||||
val errorMessage = try { |
|
||||
JSONObject(errorBody).getJSONObject("error").getString("message") |
|
||||
} catch (e: Exception) { |
|
||||
"Unknown error occurred" |
|
||||
} |
|
||||
callback.onError(VolcanoAIException(errorMessage)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
val responseBody = response.body ?: return |
|
||||
val source = responseBody.source() |
|
||||
val bufferedSource = source.buffer |
|
||||
|
|
||||
try { |
|
||||
while (!bufferedSource.exhausted()) { |
|
||||
val line = bufferedSource.readUtf8Line() ?: continue |
|
||||
|
|
||||
if (line.isEmpty()) continue |
|
||||
if (line.startsWith("data: ")) { |
|
||||
val data = line.substring(6) |
|
||||
if (data == "[DONE]") { |
|
||||
callback.onComplete() |
|
||||
break |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
val jsonData = JSONObject(data) |
|
||||
if (jsonData.has("choices") && |
|
||||
jsonData.getJSONArray("choices").length() > 0 && |
|
||||
jsonData.getJSONArray("choices").getJSONObject(0).has("delta") && |
|
||||
jsonData.getJSONArray("choices").getJSONObject(0).getJSONObject("delta").has("content")) { |
|
||||
val content = jsonData.getJSONArray("choices") |
|
||||
.getJSONObject(0) |
|
||||
.getJSONObject("delta") |
|
||||
.getString("content") |
|
||||
callback.onToken(content) |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
// 忽略无效的JSON数据 |
|
||||
continue |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
callback.onError(VolcanoAIException("Error processing stream: ${e.message}")) |
|
||||
} finally { |
|
||||
response.close() |
|
||||
} |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 同步方式发送消息(流式输出) |
|
||||
* |
|
||||
* 注意:此方法会阻塞当前线程,请在后台线程中调用 |
|
||||
* |
|
||||
* @param messages 消息列表 |
|
||||
* @param systemPrompt 系统提示词 |
|
||||
* @return 返回完整的AI回复 |
|
||||
* @throws VolcanoAIException 如果API调用失败 |
|
||||
*/ |
|
||||
@Throws(VolcanoAIException::class) |
|
||||
fun sendMessageStreamSync(messages: JSONArray, systemPrompt: String): String { |
|
||||
val result = StringBuilder() |
|
||||
val latch = CountDownLatch(1) |
|
||||
var exception: Exception? = null |
|
||||
|
|
||||
sendMessageStream(messages, systemPrompt, object : StreamCallback { |
|
||||
override fun onToken(token: String) { |
|
||||
result.append(token) |
|
||||
} |
|
||||
|
|
||||
override fun onComplete() { |
|
||||
latch.countDown() |
|
||||
} |
|
||||
|
|
||||
override fun onError(e: Exception) { |
|
||||
exception = e |
|
||||
latch.countDown() |
|
||||
} |
|
||||
}) |
|
||||
|
|
||||
// 等待流式输出完成或出错 |
|
||||
latch.await(60, TimeUnit.SECONDS) |
|
||||
|
|
||||
if (exception != null) { |
|
||||
throw exception as VolcanoAIException |
|
||||
} |
|
||||
|
|
||||
return result.toString() |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 创建用户消息 |
|
||||
*/ |
|
||||
fun createUserMessage(content: String): JSONObject { |
|
||||
return JSONObject().apply { |
|
||||
put("role", "user") |
|
||||
put("content", content) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 创建系统消息 |
|
||||
*/ |
|
||||
fun createSystemMessage(content: String): JSONObject { |
|
||||
return JSONObject().apply { |
|
||||
put("role", "system") |
|
||||
put("content", content) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 创建助手消息 |
|
||||
*/ |
|
||||
fun createAssistantMessage(content: String): JSONObject { |
|
||||
return JSONObject().apply { |
|
||||
put("role", "assistant") |
|
||||
put("content", content) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 流式输出回调接口 |
|
||||
*/ |
|
||||
interface StreamCallback { |
|
||||
fun onToken(token: String) |
|
||||
fun onComplete() |
|
||||
fun onError(e: Exception) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 火山AI异常 |
|
||||
*/ |
|
||||
class VolcanoAIException(message: String) : Exception(message) |
|
||||
@ -0,0 +1,37 @@ |
|||||
|
package com.yunqiinnovation.deepsound |
||||
|
|
||||
|
import android.app.PendingIntent |
||||
|
import android.bluetooth.le.ScanFilter |
||||
|
import android.bluetooth.le.ScanSettings |
||||
|
import android.content.Intent |
||||
|
import android.os.Build |
||||
|
import android.os.Bundle |
||||
|
import io.flutter.embedding.engine.FlutterEngine |
||||
|
import io.flutter.plugins.GeneratedPluginRegistrant |
||||
|
import io.flutter.embedding.android.FlutterActivity |
||||
|
import android.content.Context |
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import com.yunqiinnovation.deepvoice_utils.FileLogger |
||||
|
import com.yunqiinnovation.ble_service.BleMediaService |
||||
|
|
||||
|
class MainActivity: FlutterActivity() { |
||||
|
private val TAG = "MainActivity" |
||||
|
|
||||
|
override fun onCreate(savedInstanceState: Bundle?) { |
||||
|
super.onCreate(savedInstanceState) |
||||
|
|
||||
|
} |
||||
|
|
||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { |
||||
|
super.configureFlutterEngine(flutterEngine) |
||||
|
|
||||
|
// 注册标准Flutter插件 |
||||
|
GeneratedPluginRegistrant.registerWith(flutterEngine) |
||||
|
} |
||||
|
|
||||
|
override fun onDestroy() { |
||||
|
super.onDestroy() |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -1,7 +0,0 @@ |
|||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
|
||||
<!-- The INTERNET permission is required for development. Specifically, |
|
||||
the Flutter tool needs it to communicate with the running application |
|
||||
to allow setting breakpoints, to provide hot reload, etc. |
|
||||
--> |
|
||||
<uses-permission android:name="android.permission.INTERNET"/> |
|
||||
</manifest> |
|
||||
@ -1,21 +0,0 @@ |
|||||
MIT License |
|
||||
|
|
||||
Copyright (c) 2024 Your Company |
|
||||
|
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
|
||||
of this software and associated documentation files (the "Software"), to deal |
|
||||
in the Software without restriction, including without limitation the rights |
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|
||||
copies of the Software, and to permit persons to whom the Software is |
|
||||
furnished to do so, subject to the following conditions: |
|
||||
|
|
||||
The above copyright notice and this permission notice shall be included in all |
|
||||
copies or substantial portions of the Software. |
|
||||
|
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|
||||
SOFTWARE. |
|
||||
@ -1,66 +0,0 @@ |
|||||
# Azure Speech Recognition |
|
||||
|
|
||||
A Flutter plugin for Microsoft Azure Speech services, providing both speech recognition (ASR) and text-to-speech (TTS) capabilities. |
|
||||
|
|
||||
## Features |
|
||||
|
|
||||
- Speech-to-text (Azure Speech Recognition) |
|
||||
- Text-to-speech (Azure Speech Synthesis) |
|
||||
- Support for multiple languages |
|
||||
- Language detection |
|
||||
- Continuous recognition |
|
||||
- Streaming synthesis |
|
||||
|
|
||||
## Getting Started |
|
||||
|
|
||||
### Prerequisites |
|
||||
|
|
||||
- Azure Speech service subscription key |
|
||||
- Azure Speech service region |
|
||||
|
|
||||
### Installation |
|
||||
|
|
||||
Add this to your package's `pubspec.yaml` file: |
|
||||
|
|
||||
```yaml |
|
||||
dependencies: |
|
||||
azure_speech_recognition: |
|
||||
path: ./azure |
|
||||
``` |
|
||||
|
|
||||
### Usage |
|
||||
|
|
||||
```dart |
|
||||
import 'package:azure_speech_recognition/azure_speech_recognition.dart'; |
|
||||
|
|
||||
// Initialize the service |
|
||||
await AzureSpeechRecognition.initialize( |
|
||||
subscriptionKey: 'your_subscription_key', |
|
||||
region: 'your_region', |
|
||||
supportedLanguages: ['zh-CN', 'en-US'], |
|
||||
); |
|
||||
|
|
||||
// Start continuous recognition |
|
||||
await AzureSpeechRecognition.startContinuousRecognition(); |
|
||||
|
|
||||
// Listen for recognition events |
|
||||
AzureSpeechRecognition.onRecognitionEvent.listen((event) { |
|
||||
if (event['type'] == 'result') { |
|
||||
print('Recognized: ${event['text']}'); |
|
||||
print('Detected language: ${event['detectedLanguage']}'); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
// Stop recognition when done |
|
||||
await AzureSpeechRecognition.stopContinuousRecognition(); |
|
||||
|
|
||||
// Speak text |
|
||||
await AzureSpeechRecognition.speakText('Hello, world!'); |
|
||||
|
|
||||
// Clean up |
|
||||
await AzureSpeechRecognition.dispose(); |
|
||||
``` |
|
||||
|
|
||||
## License |
|
||||
|
|
||||
This project is licensed under the MIT License - see the LICENSE file for details. |
|
||||
@ -1,757 +0,0 @@ |
|||||
import Foundation |
|
||||
import MicrosoftCognitiveServicesSpeech |
|
||||
import AVFoundation |
|
||||
import AudioToolbox |
|
||||
|
|
||||
/// Azure ASR工具类,负责实现语音识别服务接口 |
|
||||
@available(iOS 13.0, *) |
|
||||
class AzureAsrHelper: NSObject { |
|
||||
// MARK: - 属性 |
|
||||
|
|
||||
/// 事件处理回调 |
|
||||
private var eventHandler: (String, [String: Any]) -> Void |
|
||||
|
|
||||
/// 语音配置信息 |
|
||||
private var speechSubscriptionKey: String = "" |
|
||||
private var serviceRegion: String = "" |
|
||||
|
|
||||
/// 语音识别相关 |
|
||||
private var speechConfig: SPXSpeechConfiguration? |
|
||||
private var recognizer: SPXSpeechRecognizer? |
|
||||
private var audioConfig: SPXAudioConfiguration? |
|
||||
private var pushStream: SPXPushAudioInputStream? |
|
||||
|
|
||||
/// 音频处理相关 |
|
||||
private var audioProcessor: CustomAudioProcessor? |
|
||||
private var isProcessingAudio = false |
|
||||
private var audioProcessingTimer: Timer? |
|
||||
|
|
||||
/// 状态标志 |
|
||||
private var isInitialized = false |
|
||||
private var _isContinuousRecognitionActive = false |
|
||||
|
|
||||
/// 当前语言和支持的语言 |
|
||||
private var currentLanguage = "zh-CN" |
|
||||
private var supportedLanguages: [String] = ["zh-CN", "en-US"] |
|
||||
private var isAutoDetectLanguage = false |
|
||||
|
|
||||
// MARK: - 初始化 |
|
||||
|
|
||||
init(eventHandler: @escaping (String, [String: Any]) -> Void) { |
|
||||
self.eventHandler = eventHandler |
|
||||
super.init() |
|
||||
} |
|
||||
|
|
||||
deinit { |
|
||||
dispose() |
|
||||
} |
|
||||
|
|
||||
// MARK: - ASR Service 接口实现 |
|
||||
|
|
||||
/// 初始化语音识别服务 |
|
||||
/// - Parameters: |
|
||||
/// - speechSubscriptionKey: Azure 语音服务订阅密钥 |
|
||||
/// - serviceRegion: Azure 服务区域 (如 eastasia) |
|
||||
/// - supportedLanguages: 支持的语言代码数组 (可选) |
|
||||
/// - Returns: 初始化是否成功 |
|
||||
func initialize(speechSubscriptionKey: String, serviceRegion: String, supportedLanguages: [String]? = nil) -> Bool { |
|
||||
print("[AzureAsrHelper] 初始化 Azure 语音服务") |
|
||||
|
|
||||
// 检查配置是否为空 |
|
||||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|
||||
print("[AzureAsrHelper] 错误: Azure 配置信息不完整") |
|
||||
eventHandler("error", ["message": "Azure 配置信息不完整"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 释放之前的资源 |
|
||||
dispose() |
|
||||
|
|
||||
// 记录配置信息 |
|
||||
self.speechSubscriptionKey = speechSubscriptionKey |
|
||||
self.serviceRegion = serviceRegion |
|
||||
|
|
||||
// 设置语言 |
|
||||
if let languages = supportedLanguages, !languages.isEmpty { |
|
||||
self.supportedLanguages = languages |
|
||||
} |
|
||||
|
|
||||
// 根据支持的语言数量决定是否启用自动语言检测 |
|
||||
isAutoDetectLanguage = self.supportedLanguages.count >= 2 |
|
||||
|
|
||||
// 如果只有一种语言,设置为当前语言 |
|
||||
if !isAutoDetectLanguage && !self.supportedLanguages.isEmpty { |
|
||||
currentLanguage = self.supportedLanguages[0] |
|
||||
} |
|
||||
|
|
||||
// 创建识别器和设置回调 |
|
||||
if !createRecognizerAndSetupCallbacks() { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
print("[AzureAsrHelper] Azure 语音服务初始化成功") |
|
||||
isInitialized = true |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
/// 创建识别器并设置回调 |
|
||||
private func createRecognizerAndSetupCallbacks() -> Bool { |
|
||||
// 释放之前的 recognizer |
|
||||
recognizer = nil |
|
||||
audioConfig = nil |
|
||||
|
|
||||
do { |
|
||||
// 创建语音配置 |
|
||||
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
|
||||
|
|
||||
// 设置音频输入参数 |
|
||||
try setupAudioSession() |
|
||||
|
|
||||
// 创建自定义推送流,替代默认的麦克风输入 |
|
||||
pushStream = try SPXPushAudioInputStream() |
|
||||
audioConfig = try SPXAudioConfiguration(streamInput: pushStream!) |
|
||||
|
|
||||
// 初始化自定义音频处理器 |
|
||||
audioProcessor = CustomAudioProcessor() |
|
||||
|
|
||||
// 设置语言配置 |
|
||||
if isAutoDetectLanguage { |
|
||||
// 设置自动语言检测 |
|
||||
speechConfig?.setPropertyTo("Continuous", by: SPXPropertyId.speechServiceConnectionLanguageIdMode) |
|
||||
|
|
||||
// 创建自动语言检测配置 |
|
||||
let autoDetectSourceLanguageConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) |
|
||||
|
|
||||
// 创建识别器 |
|
||||
recognizer = try SPXSpeechRecognizer( |
|
||||
speechConfiguration: speechConfig!, |
|
||||
autoDetectSourceLanguageConfiguration: autoDetectSourceLanguageConfig, |
|
||||
audioConfiguration: audioConfig! |
|
||||
) |
|
||||
} else { |
|
||||
// 设置指定的识别语言 |
|
||||
speechConfig?.speechRecognitionLanguage = currentLanguage |
|
||||
|
|
||||
// 创建识别器 |
|
||||
recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) |
|
||||
} |
|
||||
|
|
||||
// 设置所有回调 |
|
||||
setupAllCallbacks() |
|
||||
|
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 错误: 创建识别器失败: \(error.localizedDescription)") |
|
||||
eventHandler("error", ["message": "创建识别器失败: \(error.localizedDescription)"]) |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置音频会话 |
|
||||
private func setupAudioSession() throws { |
|
||||
let audioSession = AVAudioSession.sharedInstance() |
|
||||
|
|
||||
// 使用playAndRecord类别允许同时录音和播放 |
|
||||
try audioSession.setCategory(.playAndRecord, |
|
||||
mode: .voiceChat, // 使用voiceChat模式能够更好地支持回音消除 |
|
||||
options: [.allowBluetooth, .defaultToSpeaker, .allowAirPlay, .mixWithOthers]) |
|
||||
|
|
||||
// 设置首选的输入和输出 |
|
||||
let currentRoute = audioSession.currentRoute |
|
||||
|
|
||||
// 获取当前是否连接了耳机或外部麦克风 |
|
||||
let hasHeadphones = currentRoute.outputs.contains { |
|
||||
$0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP |
|
||||
} |
|
||||
|
|
||||
// 如果没有耳机,明确启用内置麦克风和扬声器的回音消除 |
|
||||
if !hasHeadphones { |
|
||||
try audioSession.setMode(.voiceChat) // 语音聊天模式有更强的回音消除 |
|
||||
|
|
||||
// 启用回音消除和噪声抑制 |
|
||||
try audioSession.setInputGain(0.8) // 适当降低输入增益以减少扬声器音频被麦克风捕获的可能性 |
|
||||
} else { |
|
||||
// 耳机模式,可以使用不同的设置 |
|
||||
try audioSession.setMode(.voiceChat) |
|
||||
try audioSession.setInputGain(1.0) |
|
||||
} |
|
||||
|
|
||||
// 设置合适的采样率 |
|
||||
try audioSession.setPreferredSampleRate(16000.0) // Azure语音识别推荐的采样率 |
|
||||
try audioSession.setPreferredIOBufferDuration(0.01) // 较小的缓冲区大小以减少延迟 |
|
||||
|
|
||||
// 激活音频会话 |
|
||||
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) |
|
||||
|
|
||||
print("[AzureAsrHelper] 音频会话配置成功,已启用回音消除") |
|
||||
} |
|
||||
|
|
||||
/// 设置所有回调 |
|
||||
private func setupAllCallbacks() { |
|
||||
guard let recognizer = recognizer else { return } |
|
||||
|
|
||||
// 最终识别结果 |
|
||||
recognizer.addRecognizedEventHandler { [weak self] _, event in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
if event.result.reason == SPXResultReason.recognizedSpeech { |
|
||||
let detectedLanguage = self.getDetectedLanguage(from: event.result) |
|
||||
print("[AzureAsrHelper] 识别结果: \(event.result.text ?? ""), 语言: \(detectedLanguage)") |
|
||||
self.eventHandler("result", [ |
|
||||
"text": event.result.text ?? "", |
|
||||
"detectedLanguage": detectedLanguage |
|
||||
]) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 识别中事件 |
|
||||
recognizer.addRecognizingEventHandler { [weak self] _, event in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
if event.result.reason == SPXResultReason.recognizingSpeech { |
|
||||
let detectedLanguage = self.getDetectedLanguage(from: event.result) |
|
||||
// print("[AzureAsrHelper] 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") |
|
||||
self.eventHandler("recognizing", [ |
|
||||
"text": event.result.text ?? "", |
|
||||
"detectedLanguage": detectedLanguage |
|
||||
]) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 会话事件 |
|
||||
recognizer.addSessionStartedEventHandler { [weak self] _, _ in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
print("[AzureAsrHelper] 识别会话已开始") |
|
||||
self._isContinuousRecognitionActive = true |
|
||||
self.eventHandler("sessionStarted", [:]) |
|
||||
} |
|
||||
|
|
||||
recognizer.addSessionStoppedEventHandler { [weak self] _, _ in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
print("[AzureAsrHelper] 识别会话已结束") |
|
||||
self._isContinuousRecognitionActive = false |
|
||||
self.eventHandler("sessionStopped", [:]) |
|
||||
} |
|
||||
|
|
||||
// 取消事件 |
|
||||
recognizer.addCanceledEventHandler { [weak self] _, event in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
let reason = event.reason.rawValue |
|
||||
let errorDetails = event.errorDetails ?? "未知错误" |
|
||||
|
|
||||
print("[AzureAsrHelper] 识别取消: \(errorDetails)") |
|
||||
|
|
||||
self.eventHandler("canceled", [ |
|
||||
"reason": reason, |
|
||||
"errorDetails": errorDetails |
|
||||
]) |
|
||||
|
|
||||
self._isContinuousRecognitionActive = false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 执行一次性语音识别 |
|
||||
/// - Returns: 是否成功启动识别 |
|
||||
func recognizeOnce() -> Bool { |
|
||||
if !isInitialized { |
|
||||
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
|
||||
eventHandler("error", ["message": "语音服务未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 如果正在连续识别,先停止 |
|
||||
if _isContinuousRecognitionActive { |
|
||||
stopContinuousRecognition() |
|
||||
} |
|
||||
|
|
||||
// 确保识别器已创建 |
|
||||
if recognizer == nil && !createRecognizerAndSetupCallbacks() { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
do { |
|
||||
// 启动音频处理 |
|
||||
startAudioProcessing() |
|
||||
|
|
||||
// 通知会话开始 |
|
||||
eventHandler("sessionStarted", [:]) |
|
||||
|
|
||||
// 执行识别 |
|
||||
try recognizer?.recognizeOnceAsync { [weak self] result in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
// 停止音频处理 |
|
||||
self.stopAudioProcessing() |
|
||||
|
|
||||
if result.reason == SPXResultReason.recognizedSpeech { |
|
||||
let detectedLanguage = self.getDetectedLanguage(from: result) |
|
||||
self.eventHandler("result", [ |
|
||||
"text": result.text ?? "", |
|
||||
"detectedLanguage": detectedLanguage |
|
||||
]) |
|
||||
} else if result.reason == SPXResultReason.noMatch { |
|
||||
print("[AzureAsrHelper] 无匹配结果") |
|
||||
self.eventHandler("noMatch", [:]) |
|
||||
} else if result.reason == SPXResultReason.canceled { |
|
||||
do { |
|
||||
let details = try SPXCancellationDetails(fromCanceledRecognitionResult: result) |
|
||||
let errorDetails = details.errorDetails ?? "未知错误" |
|
||||
self.eventHandler("error", ["message": "识别取消: \(errorDetails)"]) |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 错误: 获取取消详情失败: \(error.localizedDescription)") |
|
||||
self.eventHandler("error", ["message": "识别取消,无法获取详细原因"]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 错误: 识别异常: \(error.localizedDescription)") |
|
||||
eventHandler("error", ["message": "识别异常: \(error.localizedDescription)"]) |
|
||||
stopAudioProcessing() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 开始连续语音识别 |
|
||||
/// - Returns: 是否成功启动识别 |
|
||||
func startContinuousRecognition() -> Bool { |
|
||||
if !isInitialized { |
|
||||
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
|
||||
eventHandler("error", ["message": "语音服务未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 如果已经在进行连续识别,先停止 |
|
||||
if _isContinuousRecognitionActive { |
|
||||
stopContinuousRecognition() |
|
||||
} |
|
||||
|
|
||||
// 确保识别器已创建 |
|
||||
if recognizer == nil && !createRecognizerAndSetupCallbacks() { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 重新确保音频设置正确 |
|
||||
do { |
|
||||
try setupAudioSession() |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 警告: 设置音频会话失败: \(error.localizedDescription)") |
|
||||
} |
|
||||
|
|
||||
do { |
|
||||
// 启动音频处理 |
|
||||
startAudioProcessing() |
|
||||
|
|
||||
// 启动连续识别 |
|
||||
try recognizer?.startContinuousRecognition() |
|
||||
_isContinuousRecognitionActive = true |
|
||||
|
|
||||
print("[AzureAsrHelper] 连续识别开始") |
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") |
|
||||
eventHandler("error", ["message": "开始连续识别失败: \(error.localizedDescription)"]) |
|
||||
_isContinuousRecognitionActive = false |
|
||||
stopAudioProcessing() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止连续语音识别 |
|
||||
/// - Returns: 是否成功停止识别 |
|
||||
func stopContinuousRecognition() -> Bool { |
|
||||
// 停止音频处理 |
|
||||
stopAudioProcessing() |
|
||||
|
|
||||
if !_isContinuousRecognitionActive || recognizer == nil { |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
do { |
|
||||
try recognizer?.stopContinuousRecognition() |
|
||||
_isContinuousRecognitionActive = false |
|
||||
print("[AzureAsrHelper] 连续识别已停止") |
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") |
|
||||
eventHandler("error", ["message": "停止连续识别失败: \(error.localizedDescription)"]) |
|
||||
_isContinuousRecognitionActive = false |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 检查连续识别是否活跃 |
|
||||
/// - Returns: 连续识别是否处于活跃状态 |
|
||||
func isContinuousRecognitionActive() -> Bool { |
|
||||
return _isContinuousRecognitionActive |
|
||||
} |
|
||||
|
|
||||
/// 释放资源 |
|
||||
func dispose() { |
|
||||
print("[AzureAsrHelper] 释放资源") |
|
||||
|
|
||||
// 停止音频处理 |
|
||||
stopAudioProcessing() |
|
||||
|
|
||||
// 停止连续识别 |
|
||||
if _isContinuousRecognitionActive { |
|
||||
stopContinuousRecognition() |
|
||||
} |
|
||||
|
|
||||
// 释放音频会话 |
|
||||
do { |
|
||||
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 警告: 释放音频会话失败: \(error.localizedDescription)") |
|
||||
} |
|
||||
|
|
||||
// 释放资源 |
|
||||
recognizer = nil |
|
||||
speechConfig = nil |
|
||||
audioConfig = nil |
|
||||
pushStream = nil |
|
||||
audioProcessor = nil |
|
||||
|
|
||||
// 重置状态 |
|
||||
_isContinuousRecognitionActive = false |
|
||||
isInitialized = false |
|
||||
} |
|
||||
|
|
||||
/// 从结果中获取检测到的语言 |
|
||||
private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { |
|
||||
if isAutoDetectLanguage { |
|
||||
do { |
|
||||
let langResult = try SPXAutoDetectSourceLanguageResult(result) |
|
||||
return langResult.language ?? currentLanguage |
|
||||
} catch { |
|
||||
print("[AzureAsrHelper] 错误: 获取检测到的语言失败: \(error.localizedDescription)") |
|
||||
return currentLanguage |
|
||||
} |
|
||||
} else { |
|
||||
return currentLanguage |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - 音频处理 |
|
||||
|
|
||||
/// 开始音频处理 |
|
||||
private func startAudioProcessing() { |
|
||||
guard !isProcessingAudio, let audioProcessor = audioProcessor else { return } |
|
||||
|
|
||||
isProcessingAudio = true |
|
||||
|
|
||||
// 启动音频处理器 |
|
||||
if !audioProcessor.startRecord() { |
|
||||
print("[AzureAsrHelper] 错误: 启动音频处理器失败") |
|
||||
eventHandler("error", ["message": "启动音频处理器失败"]) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 启动音频处理定时器 |
|
||||
audioProcessingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in |
|
||||
guard let self = self, self.isProcessingAudio, let processor = self.audioProcessor, let stream = self.pushStream else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 读取处理后的音频数据 |
|
||||
var bytes = [UInt8](repeating: 0, count: 2560) |
|
||||
let bytesRead = processor.read(bytes: &bytes) |
|
||||
|
|
||||
if bytesRead > 0 { |
|
||||
// 推送数据到Azure语音服务 |
|
||||
let data = Data(bytes: bytes, count: bytesRead) |
|
||||
stream.write(data) |
|
||||
|
|
||||
// 通知音频数据可用 |
|
||||
self.eventHandler("audioData", ["data": bytes]) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
print("[AzureAsrHelper] 音频处理已启动") |
|
||||
} |
|
||||
|
|
||||
/// 停止音频处理 |
|
||||
private func stopAudioProcessing() { |
|
||||
// 停止定时器 |
|
||||
audioProcessingTimer?.invalidate() |
|
||||
audioProcessingTimer = nil |
|
||||
|
|
||||
// 停止音频处理器 |
|
||||
audioProcessor?.stopRecord() |
|
||||
|
|
||||
isProcessingAudio = false |
|
||||
print("[AzureAsrHelper] 音频处理已停止") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - 自定义音频处理器 |
|
||||
|
|
||||
@available(iOS 13.0, *) |
|
||||
class CustomAudioProcessor: NSObject { |
|
||||
// 音频单元 |
|
||||
private var ioUnit: AudioUnit? |
|
||||
|
|
||||
// 音频格式 |
|
||||
private var audioFormat: AudioStreamBasicDescription |
|
||||
|
|
||||
// 音频缓冲 |
|
||||
private var audioBufferList: AudioBufferList |
|
||||
private var audioList: [Float] = [] |
|
||||
private let audioListQueue = DispatchQueue(label: "audioListQueue") |
|
||||
|
|
||||
// 回音消除状态 |
|
||||
private var isEchoCancellationEnabled = true |
|
||||
|
|
||||
override init() { |
|
||||
// 设置音频格式 - 16kHz, 16位, 单声道 |
|
||||
audioFormat = AudioStreamBasicDescription( |
|
||||
mSampleRate: 16000.0, |
|
||||
mFormatID: kAudioFormatLinearPCM, |
|
||||
mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked, |
|
||||
mBytesPerPacket: 2, |
|
||||
mFramesPerPacket: 1, |
|
||||
mBytesPerFrame: 2, |
|
||||
mChannelsPerFrame: 1, |
|
||||
mBitsPerChannel: 16, |
|
||||
mReserved: 0 |
|
||||
) |
|
||||
|
|
||||
// 初始化音频缓冲 |
|
||||
audioBufferList = AudioBufferList( |
|
||||
mNumberBuffers: 1, |
|
||||
mBuffers: AudioBuffer( |
|
||||
mNumberChannels: 1, |
|
||||
mDataByteSize: 4096, |
|
||||
mData: malloc(4096) |
|
||||
) |
|
||||
) |
|
||||
|
|
||||
super.init() |
|
||||
} |
|
||||
|
|
||||
deinit { |
|
||||
stopRecord() |
|
||||
free(audioBufferList.mBuffers.mData) |
|
||||
} |
|
||||
|
|
||||
/// 启动音频处理 |
|
||||
/// - Returns: 是否成功启动 |
|
||||
func startRecord() -> Bool { |
|
||||
print("[CustomAudioProcessor] 配置音频单元") |
|
||||
|
|
||||
// 创建音频组件描述 - 使用VoiceProcessingIO类型获取回音消除 |
|
||||
var ioUnitDescription = AudioComponentDescription( |
|
||||
componentType: kAudioUnitType_Output, |
|
||||
componentSubType: kAudioUnitSubType_VoiceProcessingIO, |
|
||||
componentManufacturer: kAudioUnitManufacturer_Apple, |
|
||||
componentFlags: 0, |
|
||||
componentFlagsMask: 0 |
|
||||
) |
|
||||
|
|
||||
// 查找音频组件 |
|
||||
guard let ioUnitRef = AudioComponentFindNext(nil, &ioUnitDescription) else { |
|
||||
print("[CustomAudioProcessor] 错误: 未找到音频组件") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 创建音频单元实例 |
|
||||
if checkError(AudioComponentInstanceNew(ioUnitRef, &ioUnit), "创建音频单元") { |
|
||||
ioUnit = nil |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 启用输入端口 |
|
||||
var enableInput: UInt32 = 1 |
|
||||
let kInputBus: AudioUnitElement = 1 |
|
||||
let kOutputBus: AudioUnitElement = 0 |
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
|
||||
kAudioUnitScope_Input, kInputBus, &enableInput, |
|
||||
UInt32(MemoryLayout<UInt32>.size)), "启用输入端口") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 禁用输出端口 (我们只需要输入) |
|
||||
var enableOutput: UInt32 = 0 |
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
|
||||
kAudioUnitScope_Output, kOutputBus, |
|
||||
&enableOutput, UInt32(MemoryLayout<UInt32>.size)), "禁用输出端口") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 设置缓冲区分配标志 |
|
||||
var flag: UInt32 = 0 |
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_ShouldAllocateBuffer, |
|
||||
kAudioUnitScope_Output, kInputBus, &flag, UInt32(MemoryLayout<UInt32>.size)), "设置缓冲区分配标志") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 设置音频格式 |
|
||||
let size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size) |
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
|
||||
kAudioUnitScope_Output, kInputBus, &audioFormat, size), "设置输入总线输出范围的流格式") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
|
||||
kAudioUnitScope_Input, kOutputBus, &audioFormat, size), "设置输出总线输入范围的流格式") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 启用回音消除 |
|
||||
if isEchoCancellationEnabled { |
|
||||
var echoCancellation: UInt32 = 1 |
|
||||
AudioUnitSetProperty(ioUnit!, kAUVoiceIOProperty_BypassVoiceProcessing, |
|
||||
kAudioUnitScope_Global, 0, &echoCancellation, UInt32(MemoryLayout<UInt32>.size)) |
|
||||
} |
|
||||
|
|
||||
// 设置输入回调 - 当有新音频数据时调用 |
|
||||
var inputCallback = AURenderCallbackStruct( |
|
||||
inputProc: CustomAudioProcessor.onAudioDataAvailable, |
|
||||
inputProcRefCon: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) |
|
||||
) |
|
||||
|
|
||||
if checkError(AudioUnitSetProperty(ioUnit!, |
|
||||
kAudioOutputUnitProperty_SetInputCallback, |
|
||||
kAudioUnitScope_Global, kInputBus, |
|
||||
&inputCallback, UInt32(MemoryLayout<AURenderCallbackStruct>.size)), "设置输入回调") { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 初始化音频单元 |
|
||||
var hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") |
|
||||
while hasError { |
|
||||
Thread.sleep(forTimeInterval: 0.1) |
|
||||
hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") |
|
||||
} |
|
||||
|
|
||||
// 启动音频单元 |
|
||||
hasError = checkError(AudioOutputUnitStart(ioUnit!), "启动音频单元") |
|
||||
|
|
||||
print("[CustomAudioProcessor] 音频处理器已启动,回音消除\(isEchoCancellationEnabled ? "已启用" : "已禁用")") |
|
||||
return !hasError |
|
||||
} |
|
||||
|
|
||||
/// 停止音频处理 |
|
||||
func stopRecord() { |
|
||||
print("[CustomAudioProcessor] 停止音频处理器") |
|
||||
|
|
||||
if let ioUnit = ioUnit { |
|
||||
// 停止音频单元 |
|
||||
_ = checkError(AudioOutputUnitStop(ioUnit), "停止音频单元") |
|
||||
|
|
||||
// 关闭音频单元 |
|
||||
_ = checkError(AudioUnitUninitialize(ioUnit), "反初始化音频单元") |
|
||||
_ = checkError(AudioComponentInstanceDispose(ioUnit), "释放音频单元") |
|
||||
|
|
||||
self.ioUnit = nil |
|
||||
} |
|
||||
|
|
||||
// 清空音频数据缓冲 |
|
||||
audioListQueue.sync { |
|
||||
audioList.removeAll() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 音频数据回调 - 当有新的音频数据可用时调用 |
|
||||
private static let onAudioDataAvailable: AURenderCallback = { inRefCon, ioActionFlags, inTimeStamp, inBusNumber, inNumberFrames, ioData in |
|
||||
// 获取实例 |
|
||||
let processor = Unmanaged<CustomAudioProcessor>.fromOpaque(inRefCon).takeUnretainedValue() |
|
||||
|
|
||||
// 计算预期数据大小 |
|
||||
let expectedDataByteSize = inNumberFrames * processor.audioFormat.mBytesPerFrame |
|
||||
|
|
||||
// 确保缓冲区足够大 |
|
||||
if processor.audioBufferList.mBuffers.mDataByteSize < expectedDataByteSize { |
|
||||
processor.audioBufferList.mBuffers.mData = realloc(processor.audioBufferList.mBuffers.mData, Int(expectedDataByteSize)) |
|
||||
processor.audioBufferList.mBuffers.mDataByteSize = expectedDataByteSize |
|
||||
} |
|
||||
|
|
||||
// 渲染音频数据 |
|
||||
let status = processor.checkOSStatus(AudioUnitRender(processor.ioUnit!, ioActionFlags, inTimeStamp, |
|
||||
inBusNumber, inNumberFrames, &processor.audioBufferList), |
|
||||
"渲染音频数据") |
|
||||
|
|
||||
// 将Int16数据转换为浮点数据进行处理 |
|
||||
var audioDataFloat = [Float](repeating: 0.0, count: Int(inNumberFrames)) |
|
||||
let buffer = processor.audioBufferList.mBuffers |
|
||||
let bufferData = buffer.mData!.assumingMemoryBound(to: Int16.self) |
|
||||
|
|
||||
for j in 0..<Int(buffer.mDataByteSize / UInt32(MemoryLayout<Int16>.size)) { |
|
||||
// 归一化到[-1.0, 1.0]范围 |
|
||||
audioDataFloat[j] = Float(bufferData[j]) / 32768.0 |
|
||||
} |
|
||||
|
|
||||
// 应用附加处理 (如有需要) |
|
||||
// processor.applyAdditionalProcessing(&audioDataFloat) |
|
||||
|
|
||||
// 保存处理后的数据 |
|
||||
if status == noErr { |
|
||||
processor.audioListQueue.async { |
|
||||
processor.audioList.append(contentsOf: audioDataFloat) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return status |
|
||||
} |
|
||||
|
|
||||
/// 读取处理后的音频数据 |
|
||||
/// - Parameter bytes: 输出字节数组 |
|
||||
/// - Returns: 读取的字节数 |
|
||||
func read(bytes: inout [UInt8]) -> Int { |
|
||||
return audioListQueue.sync { |
|
||||
// 如果没有数据,返回0 |
|
||||
if audioList.isEmpty { |
|
||||
return 0 |
|
||||
} |
|
||||
|
|
||||
// 确保有足够的数据 (至少1280个样本) |
|
||||
if audioList.count < 1280 { |
|
||||
return 0 |
|
||||
} |
|
||||
|
|
||||
// 读取一帧数据 (1280个样本) |
|
||||
let frameLength = 1280 |
|
||||
let buffer = Array(audioList.prefix(frameLength)) |
|
||||
audioList.removeFirst(frameLength) |
|
||||
|
|
||||
// 将浮点数据转回Int16格式 |
|
||||
var int16Data = buffer.map { Int16($0 * 32767) } |
|
||||
|
|
||||
// 转换为字节数组 |
|
||||
let data = Data(buffer: UnsafeBufferPointer(start: &int16Data, count: int16Data.count)) |
|
||||
bytes = [UInt8](data) |
|
||||
|
|
||||
// 每个样本2字节 (16位PCM) |
|
||||
return frameLength * 2 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 检查错误并打印日志 |
|
||||
/// - Parameters: |
|
||||
/// - status: 操作状态 |
|
||||
/// - operation: 操作描述 |
|
||||
/// - Returns: 是否发生错误 |
|
||||
private func checkError(_ status: OSStatus, _ operation: String) -> Bool { |
|
||||
if status != noErr { |
|
||||
print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") |
|
||||
return true |
|
||||
} |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
/// 检查OSStatus并返回状态 |
|
||||
/// - Parameters: |
|
||||
/// - status: 操作状态 |
|
||||
/// - operation: 操作描述 |
|
||||
/// - Returns: 原始状态 |
|
||||
private func checkOSStatus(_ status: OSStatus, _ operation: String) -> OSStatus { |
|
||||
if status != noErr { |
|
||||
print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") |
|
||||
} |
|
||||
return status |
|
||||
} |
|
||||
} |
|
||||
@ -1,18 +0,0 @@ |
|||||
import Flutter |
|
||||
import UIKit |
|
||||
|
|
||||
public class AzureSpeechRecognitionPlugin: NSObject, FlutterPlugin { |
|
||||
public static func register(with registrar: FlutterPluginRegistrar) { |
|
||||
if #available(iOS 13.0, *) { |
|
||||
SwiftAzureSpeechRecognitionPlugin.register(with: registrar) |
|
||||
} else { |
|
||||
// 如果低于iOS 13.0,返回不支持的错误 |
|
||||
let channel = FlutterMethodChannel(name: "com.deep_voice.azure_asr", binaryMessenger: registrar.messenger()) |
|
||||
channel.setMethodCallHandler { (call, result) in |
|
||||
result(FlutterError(code: "UNSUPPORTED", |
|
||||
message: "需要iOS 13.0及以上系统", |
|
||||
details: nil)) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,427 +0,0 @@ |
|||||
import Foundation |
|
||||
import MicrosoftCognitiveServicesSpeech |
|
||||
import AVFoundation |
|
||||
|
|
||||
/// Azure TTS工具类,负责实现TTS服务接口 |
|
||||
@available(iOS 13.0, *) |
|
||||
class AzureTtsHelper: NSObject { |
|
||||
// MARK: - 属性 |
|
||||
|
|
||||
/// 事件处理回调 |
|
||||
private var eventHandler: (String, [String: Any]) -> Void |
|
||||
|
|
||||
/// 语音配置信息 |
|
||||
private var speechSubscriptionKey: String = "" |
|
||||
private var serviceRegion: String = "" |
|
||||
|
|
||||
/// 语音合成配置 |
|
||||
private var speechConfig: SPXSpeechConfiguration? |
|
||||
|
|
||||
/// 语音合成器 |
|
||||
private var synthesizer: SPXSpeechSynthesizer? |
|
||||
|
|
||||
/// 是否初始化成功 |
|
||||
private var isInitialized = false |
|
||||
|
|
||||
/// 当前是否正在播放 |
|
||||
private var _isSpeaking = false |
|
||||
|
|
||||
/// 音频会话配置 |
|
||||
private var isAudioSessionConfigured = false |
|
||||
|
|
||||
// MARK: - 语音设置 |
|
||||
|
|
||||
/// 当前语音 |
|
||||
private var currentVoice = "zh-CN-XiaoxiaoNeural" |
|
||||
|
|
||||
/// 支持的语音映射 |
|
||||
private var voiceMap: [String: String] = [ |
|
||||
"zh-CN": "zh-CN-XiaoxiaoNeural", |
|
||||
"en-US": "en-US-JennyNeural", |
|
||||
"ja-JP": "ja-JP-NanamiNeural", |
|
||||
"ko-KR": "ko-KR-SunHiNeural", |
|
||||
"zh-TW": "zh-TW-HsiaoChenNeural", |
|
||||
"zh-HK": "zh-HK-HiuMaanNeural" |
|
||||
] |
|
||||
|
|
||||
/// 当前语音合成参数 |
|
||||
private var currentSpeechRate = "0%" |
|
||||
private var currentPitch = "0%" |
|
||||
private var currentVolume = "100%" |
|
||||
|
|
||||
// MARK: - 初始化 |
|
||||
|
|
||||
init(eventHandler: @escaping (String, [String: Any]) -> Void) { |
|
||||
self.eventHandler = eventHandler |
|
||||
super.init() |
|
||||
} |
|
||||
|
|
||||
deinit { |
|
||||
dispose() |
|
||||
} |
|
||||
|
|
||||
// MARK: - TTS 接口实现 |
|
||||
|
|
||||
/// 初始化语音合成服务 |
|
||||
/// - Parameters: |
|
||||
/// - speechSubscriptionKey: Azure 语音服务订阅密钥 |
|
||||
/// - serviceRegion: Azure 服务区域 (如 eastasia) |
|
||||
/// - language: 语言代码 (默认 zh-CN) |
|
||||
/// - Returns: 初始化是否成功 |
|
||||
func initialize(speechSubscriptionKey: String, serviceRegion: String, language: String = "zh-CN") -> Bool { |
|
||||
print("[AzureTtsHelper] 初始化语音合成服务") |
|
||||
|
|
||||
// 检查配置是否为空 |
|
||||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|
||||
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
|
||||
eventHandler("error", ["error": "Azure 配置信息不完整"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 释放之前的资源 |
|
||||
dispose() |
|
||||
|
|
||||
// 记录配置信息 |
|
||||
self.speechSubscriptionKey = speechSubscriptionKey |
|
||||
self.serviceRegion = serviceRegion |
|
||||
|
|
||||
// 配置音频会话 |
|
||||
if !configureAudioSession() { |
|
||||
print("[AzureTtsHelper] 警告: 音频会话配置失败,将尝试继续初始化") |
|
||||
} |
|
||||
|
|
||||
do { |
|
||||
// 创建语音配置 |
|
||||
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
|
||||
|
|
||||
// 设置默认语音 |
|
||||
let defaultVoice = getDefaultVoiceForLanguage(language) |
|
||||
currentVoice = defaultVoice |
|
||||
speechConfig?.speechSynthesisVoiceName = defaultVoice |
|
||||
|
|
||||
// 创建语音合成器 |
|
||||
synthesizer = try SPXSpeechSynthesizer(speechConfig!) |
|
||||
|
|
||||
// 设置事件处理器 |
|
||||
setupSynthesizerEvents() |
|
||||
|
|
||||
isInitialized = true |
|
||||
print("[AzureTtsHelper] TTS 引擎初始化成功") |
|
||||
|
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureTtsHelper] 错误: 初始化语音合成服务失败: \(error.localizedDescription)") |
|
||||
eventHandler("error", ["error": "初始化语音合成服务失败: \(error.localizedDescription)"]) |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 配置音频会话 |
|
||||
private func configureAudioSession() -> Bool { |
|
||||
let audioSession = AVAudioSession.sharedInstance() |
|
||||
do { |
|
||||
// 使用playback类别,但支持混合和空中播放 |
|
||||
try audioSession.setCategory(.playback, |
|
||||
mode: .spokenAudio, |
|
||||
options: [.mixWithOthers, .allowAirPlay, .duckOthers]) |
|
||||
|
|
||||
// 根据设备类型选择最佳配置 |
|
||||
let currentRoute = audioSession.currentRoute |
|
||||
let hasHeadphones = currentRoute.outputs.contains { |
|
||||
$0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP |
|
||||
} |
|
||||
|
|
||||
// 优化音频路由 |
|
||||
if hasHeadphones { |
|
||||
// 耳机模式,使用默认设置 |
|
||||
try audioSession.setPreferredIOBufferDuration(0.005) // 较小的缓冲区大小以减少延迟 |
|
||||
} else { |
|
||||
// 扬声器模式 |
|
||||
try audioSession.setPreferredIOBufferDuration(0.005) |
|
||||
} |
|
||||
|
|
||||
// 避免完全激活音频会话,因为ASR可能已经激活 |
|
||||
// 这里使用setActive(false)是为了不与ASR冲突 |
|
||||
if !audioSession.isOtherAudioPlaying { |
|
||||
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) |
|
||||
} |
|
||||
|
|
||||
isAudioSessionConfigured = true |
|
||||
print("[AzureTtsHelper] 音频会话配置成功") |
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureTtsHelper] 警告: 音频会话配置失败: \(error.localizedDescription)") |
|
||||
isAudioSessionConfigured = false |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语音 |
|
||||
/// - Parameter voiceName: 语音名称 (如 "zh-CN-XiaoxiaoNeural") |
|
||||
/// - Returns: 设置是否成功 |
|
||||
func setVoice(voiceName: String) -> Bool { |
|
||||
if !isInitialized { |
|
||||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|
||||
eventHandler("error", ["error": "TTS 引擎尚未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if voiceName.isEmpty { |
|
||||
print("[AzureTtsHelper] 错误: 声音名称为空") |
|
||||
eventHandler("error", ["error": "声音名称不能为空"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if voiceName == currentVoice { |
|
||||
print("[AzureTtsHelper] 已设置语音: \(voiceName)") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
print("[AzureTtsHelper] 设置声音: \(voiceName)") |
|
||||
currentVoice = voiceName |
|
||||
|
|
||||
// 更新语音配置 |
|
||||
if let speechConfig = speechConfig { |
|
||||
speechConfig.speechSynthesisVoiceName = voiceName |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
/// 设置语音合成参数 |
|
||||
/// - Parameters: |
|
||||
/// - rate: 语速,范围 -100 到 100,默认为 0 |
|
||||
/// - pitch: 音调,范围 -100 到 100,默认为 0 |
|
||||
/// - volume: 音量,范围 0 到 100,默认为 100 |
|
||||
/// - Returns: 是否设置成功 |
|
||||
func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { |
|
||||
if !isInitialized { |
|
||||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|
||||
eventHandler("error", ["error": "TTS 引擎尚未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
// 转换参数格式 |
|
||||
currentSpeechRate = formatRateParam(rate) |
|
||||
currentPitch = formatPitchParam(pitch) |
|
||||
currentVolume = formatVolumeParam(volume) |
|
||||
|
|
||||
print("[AzureTtsHelper] 已设置语音参数: 语速=\(currentSpeechRate), 音调=\(currentPitch), 音量=\(currentVolume)") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
/// 合成文本为语音并播放 |
|
||||
/// - Parameter text: 要合成的文本 |
|
||||
/// - Returns: 操作是否成功启动 |
|
||||
func speakText(text: String) -> Bool { |
|
||||
if !isInitialized { |
|
||||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|
||||
eventHandler("error", ["error": "TTS 引擎尚未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if text.isEmpty { |
|
||||
print("[AzureTtsHelper] 警告: 要播放的文本为空") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
// 确保音频会话已配置 |
|
||||
if !isAudioSessionConfigured { |
|
||||
_ = configureAudioSession() |
|
||||
} |
|
||||
|
|
||||
print("[AzureTtsHelper] 开始语音合成: \(text.prefix(50))...") |
|
||||
|
|
||||
// 生成SSML |
|
||||
let ssml = generateSsml(text: text) |
|
||||
|
|
||||
// 直接进行SSML合成 |
|
||||
return speakSsmlInternal(text: ssml) |
|
||||
} |
|
||||
|
|
||||
/// 内部SSML合成和播放 |
|
||||
private func speakSsmlInternal(text: String) -> Bool { |
|
||||
guard let synthesizer = synthesizer else { |
|
||||
print("[AzureTtsHelper] 错误: 合成器未初始化") |
|
||||
eventHandler("error", ["error": "合成器未初始化"]) |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
_isSpeaking = true |
|
||||
eventHandler("started", [:]) |
|
||||
|
|
||||
Task { |
|
||||
do { |
|
||||
// 使用异步方法进行合成并直接播放 |
|
||||
_ = try await synthesizer.startSpeakingSsml(text) |
|
||||
|
|
||||
} catch { |
|
||||
print("[AzureTtsHelper] 错误: 语音合成失败: \(error.localizedDescription)") |
|
||||
DispatchQueue.main.async { |
|
||||
self._isSpeaking = false |
|
||||
self.eventHandler("error", ["error": "语音合成失败: \(error.localizedDescription)"]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
/// 停止当前语音合成 |
|
||||
/// - Returns: 操作是否成功 |
|
||||
func stopSpeaking() -> Bool { |
|
||||
if !isInitialized || !_isSpeaking { |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
// 停止合成 |
|
||||
do { |
|
||||
try synthesizer?.stopSpeaking() |
|
||||
_isSpeaking = false |
|
||||
eventHandler("canceled", [:]) |
|
||||
print("[AzureTtsHelper] 已停止语音合成") |
|
||||
return true |
|
||||
} catch { |
|
||||
print("[AzureTtsHelper] 错误: 停止语音合成失败: \(error.localizedDescription)") |
|
||||
eventHandler("error", ["error": "停止语音合成失败: \(error.localizedDescription)"]) |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 检查是否正在播放 |
|
||||
/// - Returns: 当前是否正在播放语音 |
|
||||
func isSpeaking() -> Bool { |
|
||||
return _isSpeaking |
|
||||
} |
|
||||
|
|
||||
/// 释放资源 |
|
||||
func dispose() { |
|
||||
try? stopSpeaking() |
|
||||
|
|
||||
// 释放合成器和配置 |
|
||||
synthesizer = nil |
|
||||
speechConfig = nil |
|
||||
|
|
||||
isInitialized = false |
|
||||
_isSpeaking = false |
|
||||
isAudioSessionConfigured = false |
|
||||
print("[AzureTtsHelper] TTS 引擎已释放") |
|
||||
} |
|
||||
|
|
||||
// MARK: - 私有辅助方法 |
|
||||
|
|
||||
/// 设置合成器事件处理 |
|
||||
private func setupSynthesizerEvents() { |
|
||||
guard let synthesizer = synthesizer else { return } |
|
||||
|
|
||||
// 添加书签到达事件处理 |
|
||||
synthesizer.addBookmarkReachedEventHandler { _, e in |
|
||||
print("[AzureTtsHelper] 书签事件: 音频偏移: \((e.audioOffset + 5000) / 10000)ms, 文本: \"\(e.text)\"") |
|
||||
} |
|
||||
|
|
||||
// 合成完成事件 |
|
||||
synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in |
|
||||
guard let self = self else { return } |
|
||||
print("[AzureTtsHelper] 语音合成完成: 音频持续时间: \(e.result.audioDuration)") |
|
||||
DispatchQueue.main.async { |
|
||||
self._isSpeaking = false |
|
||||
self.eventHandler("completed", [:]) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 合成取消事件 |
|
||||
synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
let result = e.result |
|
||||
do { |
|
||||
let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: result) |
|
||||
print("[AzureTtsHelper] 语音合成取消: 原因: \(cancellationDetails.reason)") |
|
||||
|
|
||||
if cancellationDetails.reason == SPXCancellationReason.error { |
|
||||
print("[AzureTtsHelper] 错误代码: \(cancellationDetails.errorCode)") |
|
||||
print("[AzureTtsHelper] 错误详情: \(cancellationDetails.errorDetails ?? "未知")") |
|
||||
} |
|
||||
|
|
||||
DispatchQueue.main.async { |
|
||||
self._isSpeaking = false |
|
||||
self.eventHandler("error", ["error": "语音合成取消: \(cancellationDetails.errorDetails ?? "未知错误")"]) |
|
||||
} |
|
||||
} catch { |
|
||||
print("[AzureTtsHelper] 获取取消详情时出错: \(error)") |
|
||||
|
|
||||
DispatchQueue.main.async { |
|
||||
self._isSpeaking = false |
|
||||
self.eventHandler("error", ["error": "语音合成被取消"]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 合成开始事件 |
|
||||
synthesizer.addSynthesisStartedEventHandler { _, _ in |
|
||||
// print("[AzureTtsHelper] 语音合成开始") |
|
||||
} |
|
||||
|
|
||||
// 合成中事件 |
|
||||
synthesizer.addSynthesizingEventHandler { _, _ in |
|
||||
// print("[AzureTtsHelper] 语音合成中") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 生成 SSML 文本 |
|
||||
private func generateSsml(text: String) -> String { |
|
||||
return """ |
|
||||
<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='zh-CN'> |
|
||||
<voice name='\(currentVoice)'> |
|
||||
<prosody rate='\(currentSpeechRate)' pitch='\(currentPitch)' volume='\(currentVolume)'> |
|
||||
\(text) |
|
||||
</prosody> |
|
||||
</voice> |
|
||||
</speak> |
|
||||
""" |
|
||||
} |
|
||||
|
|
||||
/// 格式化语速参数 |
|
||||
private func formatRateParam(_ rate: Int) -> String { |
|
||||
let clampedRate = rate.clamp(min: -100, max: 100) |
|
||||
if clampedRate == 0 { |
|
||||
return "0%" |
|
||||
} else if clampedRate < 0 { |
|
||||
return "\(Int(Double(clampedRate) * 0.9))%" |
|
||||
} else { |
|
||||
return "+\(clampedRate)%" |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 格式化音调参数 |
|
||||
private func formatPitchParam(_ pitch: Int) -> String { |
|
||||
let clampedPitch = pitch.clamp(min: -100, max: 100) |
|
||||
if clampedPitch == 0 { |
|
||||
return "0%" |
|
||||
} else { |
|
||||
return "\(Int(Double(clampedPitch) * 0.5))%" |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 格式化音量参数 |
|
||||
private func formatVolumeParam(_ volume: Int) -> String { |
|
||||
let clampedVolume = volume.clamp(min: 0, max: 100) |
|
||||
return "\(clampedVolume)%" |
|
||||
} |
|
||||
|
|
||||
/// 获取指定语言的默认语音 |
|
||||
private func getDefaultVoiceForLanguage(_ language: String) -> String { |
|
||||
return voiceMap[language] ?? "zh-CN-XiaoxiaoNeural" |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - 扩展 |
|
||||
|
|
||||
extension Int { |
|
||||
func clamp(min: Int, max: Int) -> Int { |
|
||||
if self < min { return min } |
|
||||
if self > max { return max } |
|
||||
return self |
|
||||
} |
|
||||
} |
|
||||
@ -1,259 +0,0 @@ |
|||||
import Flutter |
|
||||
import UIKit |
|
||||
import MicrosoftCognitiveServicesSpeech |
|
||||
import AVFoundation |
|
||||
|
|
||||
@available(iOS 13.0, *) |
|
||||
public class SwiftAzureSpeechRecognitionPlugin: NSObject, FlutterPlugin { |
|
||||
private var azureChannel: FlutterMethodChannel |
|
||||
private var ttsChannel: FlutterMethodChannel |
|
||||
private var asrHelper: AzureAsrHelper |
|
||||
private var ttsHelper: AzureTtsHelper |
|
||||
private static var eventStreamHandler: AzureEventStreamHandler? |
|
||||
|
|
||||
// 创建方法到通道的映射 |
|
||||
private static var ttsMethodHandlers = [String: FlutterMethodCallHandler]() |
|
||||
private static var asrMethodHandlers = [String: FlutterMethodCallHandler]() |
|
||||
|
|
||||
public static func register(with registrar: FlutterPluginRegistrar) { |
|
||||
// ASR通道 |
|
||||
let channel = FlutterMethodChannel(name: "com.deep_voice.azure_asr", binaryMessenger: registrar.messenger()) |
|
||||
|
|
||||
// TTS通道 |
|
||||
let ttsChannel = FlutterMethodChannel(name: "com.deep_voice.azure_tts", binaryMessenger: registrar.messenger()) |
|
||||
|
|
||||
// 设置ASR事件通道 |
|
||||
let eventChannel = FlutterEventChannel(name: "com.deep_voice.azure_asr_events", binaryMessenger: registrar.messenger()) |
|
||||
eventStreamHandler = AzureEventStreamHandler() |
|
||||
eventChannel.setStreamHandler(eventStreamHandler) |
|
||||
|
|
||||
let instance = SwiftAzureSpeechRecognitionPlugin( |
|
||||
azureChannel: channel, |
|
||||
ttsChannel: ttsChannel, |
|
||||
eventStreamHandler: eventStreamHandler! |
|
||||
) |
|
||||
|
|
||||
// 直接设置各自通道的处理器 |
|
||||
channel.setMethodCallHandler(instance.handleAsrMethodCalls) |
|
||||
ttsChannel.setMethodCallHandler(instance.handleTtsMethodCalls) |
|
||||
} |
|
||||
|
|
||||
|
|
||||
|
|
||||
// 新增直接处理方法调用的函数 |
|
||||
private func handleTtsMethodCalls(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|
||||
handleTtsMethod(call, result) |
|
||||
} |
|
||||
|
|
||||
private func handleAsrMethodCalls(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|
||||
handleAsrMethod(call, result) |
|
||||
} |
|
||||
|
|
||||
|
|
||||
init(azureChannel: FlutterMethodChannel, ttsChannel: FlutterMethodChannel, eventStreamHandler: AzureEventStreamHandler) { |
|
||||
self.azureChannel = azureChannel |
|
||||
self.ttsChannel = ttsChannel |
|
||||
|
|
||||
// 创建辅助类实例,使用自定义事件回调处理器 |
|
||||
let eventHandler: (String, [String: Any]) -> Void = { eventName, arguments in |
|
||||
DispatchQueue.main.async { |
|
||||
if let eventSink = SwiftAzureSpeechRecognitionPlugin.eventStreamHandler?.eventSink { |
|
||||
var eventData = arguments |
|
||||
eventData["type"] = eventName |
|
||||
eventSink(eventData) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
asrHelper = AzureAsrHelper(eventHandler: eventHandler) |
|
||||
ttsHelper = AzureTtsHelper(eventHandler: eventHandler) |
|
||||
|
|
||||
super.init() |
|
||||
} |
|
||||
|
|
||||
private func handleAsrMethod(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
|
||||
|
|
||||
let args = call.arguments as? Dictionary<String, Any> |
|
||||
|
|
||||
switch call.method { |
|
||||
case "initialize": |
|
||||
// 仅在初始化时读取必要参数 |
|
||||
guard let speechSubscriptionKey = args?["subscriptionKey"] as? String, !speechSubscriptionKey.isEmpty else { |
|
||||
let errorMsg = "语音订阅密钥不能为空" |
|
||||
print("[AzurePlugin] 错误: \(errorMsg)") |
|
||||
result(FlutterError(code: "INVALID_SUBSCRIPTION_KEY", message: errorMsg, details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
guard let serviceRegion = args?["region"] as? String, !serviceRegion.isEmpty else { |
|
||||
let errorMsg = "服务区域不能为空" |
|
||||
print("[AzurePlugin] 错误: \(errorMsg)") |
|
||||
result(FlutterError(code: "INVALID_REGION", message: errorMsg, details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let supportedLanguages = args?["supportedLanguages"] as? [String] ?? [] |
|
||||
|
|
||||
let success = asrHelper.initialize( |
|
||||
speechSubscriptionKey: speechSubscriptionKey, |
|
||||
serviceRegion: serviceRegion, |
|
||||
supportedLanguages: supportedLanguages.isEmpty ? nil : supportedLanguages |
|
||||
) |
|
||||
result(success) |
|
||||
|
|
||||
case "startContinuousRecognition": |
|
||||
// 只有使用参数时才验证 |
|
||||
let success = asrHelper.startContinuousRecognition() |
|
||||
result(success) |
|
||||
|
|
||||
case "stopContinuousRecognition": |
|
||||
// 不需要额外参数 |
|
||||
let success = asrHelper.stopContinuousRecognition() |
|
||||
result(success) |
|
||||
|
|
||||
case "recognizeOnce": |
|
||||
// 只有使用参数时才验证 |
|
||||
let success = asrHelper.recognizeOnce() |
|
||||
result(success) |
|
||||
|
|
||||
case "isContinuousRecognitionActive": |
|
||||
// 不需要额外参数 |
|
||||
result(asrHelper.isContinuousRecognitionActive()) |
|
||||
|
|
||||
case "dispose": |
|
||||
// 不需要额外参数 |
|
||||
print("[AzurePlugin] 释放ASR资源") |
|
||||
asrHelper.dispose() |
|
||||
result(true) |
|
||||
|
|
||||
default: |
|
||||
print("[AzurePlugin] 错误: 未知ASR方法: \(call.method)") |
|
||||
result(FlutterMethodNotImplemented) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func handleTtsMethod(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
|
||||
|
|
||||
let args = call.arguments as? Dictionary<String, Any> |
|
||||
|
|
||||
switch call.method { |
|
||||
case "initialize": |
|
||||
// 仅在初始化时验证参数 |
|
||||
guard let speechSubscriptionKey = args?["subscriptionKey"] as? String, !speechSubscriptionKey.isEmpty else { |
|
||||
let errorMsg = "语音订阅密钥不能为空" |
|
||||
print("[AzurePlugin] 错误: \(errorMsg)") |
|
||||
result(FlutterError(code: "INVALID_SUBSCRIPTION_KEY", message: errorMsg, details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
guard let serviceRegion = args?["region"] as? String, !serviceRegion.isEmpty else { |
|
||||
let errorMsg = "服务区域不能为空" |
|
||||
print("[AzurePlugin] 错误: \(errorMsg)") |
|
||||
result(FlutterError(code: "INVALID_REGION", message: errorMsg, details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let language = args?["language"] as? String ?? "zh-CN" |
|
||||
|
|
||||
print("[AzurePlugin] 初始化TTS,语言: \(language)") |
|
||||
|
|
||||
let success = ttsHelper.initialize(speechSubscriptionKey: speechSubscriptionKey, serviceRegion: serviceRegion, language: language) |
|
||||
result(success) |
|
||||
|
|
||||
case "setVoice": |
|
||||
// 仅获取voice参数 |
|
||||
guard let voiceName = args?["voiceName"] as? String, !voiceName.isEmpty else { |
|
||||
let errorMsg = "声音名称不能为空" |
|
||||
print("[AzurePlugin] 错误: \(errorMsg)") |
|
||||
result(FlutterError(code: "INVALID_VOICE", message: errorMsg, details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
print("[AzurePlugin] 设置声音: \(voiceName)") |
|
||||
|
|
||||
let success = ttsHelper.setVoice(voiceName: voiceName) |
|
||||
result(success) |
|
||||
|
|
||||
case "speakText": |
|
||||
// 仅获取text参数 |
|
||||
let text = args?["text"] as? String ?? "" |
|
||||
|
|
||||
if text.isEmpty { |
|
||||
print("[AzurePlugin] 警告: 要播放的文本为空") |
|
||||
result("OK") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
print("[AzurePlugin] 播放文本: \(text.prefix(50))...") |
|
||||
|
|
||||
let success = ttsHelper.speakText(text: text) |
|
||||
result(success ? "OK" : "ERROR") |
|
||||
|
|
||||
case "speakSsml": |
|
||||
// 仅获取ssml参数 |
|
||||
guard let ssml = args?["ssml"] as? String, !ssml.isEmpty else { |
|
||||
let errorMsg = "SSML内容不能为空" |
|
||||
print("[AzurePlugin] 错误: \(errorMsg)") |
|
||||
result(FlutterError(code: "INVALID_SSML", message: errorMsg, details: nil)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
print("[AzurePlugin] 播放SSML: \(ssml.prefix(100))...") |
|
||||
|
|
||||
// 由于我们移除了speakSsml方法,这里改用speakText方法 |
|
||||
// Azure SDK内部会自动检测是普通文本还是SSML |
|
||||
let success = ttsHelper.speakText(text: ssml) |
|
||||
result(success) |
|
||||
|
|
||||
case "stopSpeaking": |
|
||||
// 不需要参数 |
|
||||
print("[AzurePlugin] 停止播放") |
|
||||
let success = ttsHelper.stopSpeaking() |
|
||||
result(success) |
|
||||
|
|
||||
case "isSpeaking": |
|
||||
// 不需要参数 |
|
||||
result(ttsHelper.isSpeaking()) |
|
||||
|
|
||||
case "setSpeechParams": |
|
||||
// 仅获取语音参数 |
|
||||
let rate = args?["rate"] as? Int ?? 0 |
|
||||
let pitch = args?["pitch"] as? Int ?? 0 |
|
||||
let volume = args?["volume"] as? Int ?? 100 |
|
||||
|
|
||||
print("[AzurePlugin] 设置语音参数: rate=\(rate), pitch=\(pitch), volume=\(volume)") |
|
||||
let success = ttsHelper.setSpeechParams(rate: rate, pitch: pitch, volume: volume) |
|
||||
result(success) |
|
||||
|
|
||||
case "dispose": |
|
||||
// 释放TTS资源 |
|
||||
print("[AzurePlugin] 释放TTS资源") |
|
||||
ttsHelper.dispose() |
|
||||
result(true) |
|
||||
|
|
||||
default: |
|
||||
print("[AzurePlugin] 错误: 未知TTS方法: \(call.method)") |
|
||||
result(FlutterMethodNotImplemented) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 用于处理事件流的辅助类 |
|
||||
@available(iOS 13.0, *) |
|
||||
class AzureEventStreamHandler: NSObject, FlutterStreamHandler { |
|
||||
var eventSink: FlutterEventSink? |
|
||||
|
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|
||||
self.eventSink = events |
|
||||
// 通知Flutter端事件通道已准备好 |
|
||||
DispatchQueue.main.async { |
|
||||
events(["type": "channelReady"]) |
|
||||
} |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|
||||
self.eventSink = nil |
|
||||
return nil |
|
||||
} |
|
||||
} |
|
||||
@ -1,24 +0,0 @@ |
|||||
# |
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. |
|
||||
# Run `pod lib lint azure_speech_recognition.podspec` to validate before publishing. |
|
||||
# |
|
||||
Pod::Spec.new do |s| |
|
||||
s.name = 'azure_speech_recognition' |
|
||||
s.version = '0.1.0' |
|
||||
s.summary = 'Azure Speech Recognition plugin for Flutter' |
|
||||
s.description = <<-DESC |
|
||||
A Flutter plugin for Microsoft Azure Speech services, providing both speech recognition (ASR) and text-to-speech (TTS) capabilities. |
|
||||
DESC |
|
||||
s.homepage = 'https://github.com/yourusername/azure_speech_recognition' |
|
||||
s.license = { :type => 'MIT', :file => '../LICENSE' } |
|
||||
s.author = { 'Your Company' => 'your-email@example.com' } |
|
||||
s.source = { :path => '.' } |
|
||||
s.source_files = 'Classes/**/*' |
|
||||
s.dependency 'Flutter' |
|
||||
s.dependency 'MicrosoftCognitiveServicesSpeech-iOS', '~> 1.34.0' |
|
||||
s.platform = :ios, '12.0' |
|
||||
|
|
||||
# Flutter.framework does not contain a i386 slice. |
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } |
|
||||
s.swift_version = '5.0' |
|
||||
end |
|
||||
@ -1,6 +0,0 @@ |
|||||
// This is a placeholder file that exports nothing. |
|
||||
// The actual implementation is in the app's services folder. |
|
||||
// This file exists just to satisfy the Flutter plugin structure requirements. |
|
||||
|
|
||||
// Empty library to satisfy plugin structure |
|
||||
library azure_speech_recognition; |
|
||||
@ -1,23 +0,0 @@ |
|||||
name: azure_speech_recognition |
|
||||
description: Azure Speech Recognition and Text-to-Speech services Flutter plugin |
|
||||
version: 0.1.0 |
|
||||
homepage: https://github.com/yourusername/azure_speech_recognition |
|
||||
|
|
||||
environment: |
|
||||
sdk: '>=2.12.0 <3.0.0' |
|
||||
flutter: ">=2.0.0" |
|
||||
|
|
||||
dependencies: |
|
||||
flutter: |
|
||||
sdk: flutter |
|
||||
|
|
||||
dev_dependencies: |
|
||||
flutter_test: |
|
||||
sdk: flutter |
|
||||
flutter_lints: ^1.0.0 |
|
||||
|
|
||||
flutter: |
|
||||
plugin: |
|
||||
platforms: |
|
||||
ios: |
|
||||
pluginClass: AzureSpeechRecognitionPlugin |
|
||||
@ -0,0 +1,89 @@ |
|||||
|
/// 杰里(Jieli)设备相关常量定义 |
||||
|
/// 用于标准化系统属性类型和功能码 |
||||
|
|
||||
|
/// 系统属性类型常量 |
||||
|
class JieliAttrType { |
||||
|
// 公共属性 |
||||
|
static const int VOLUME = 1; // 音量 |
||||
|
static const int PLAYING_TIME = 2; // 播放时间 |
||||
|
static const int BATTERY_LEVEL = 3; // 电池电量 |
||||
|
static const int WORK_MODE = 4; // 工作模式 |
||||
|
static const int PLAYBACK_MODE = 5; // 播放模式(循环模式) |
||||
|
static const int EQ_MODE = 6; // EQ模式 |
||||
|
static const int LANGUAGE = 7; // 语言设置 |
||||
|
static const int CONTRAST = 8; // 对比度 |
||||
|
static const int LED_ONOFF = 9; // LED开关 |
||||
|
static const int POWER_OFF_TIME = 10; // 关机时间 |
||||
|
static const int LIGHT_ONOFF = 11; // 背光开关 |
||||
|
static const int BLETYPE_SETTING = 12; // BLE类型设置 |
||||
|
static const int TIME_OUT = 13; // 超时设置 |
||||
|
static const int DEVICE_NAME = 14; // 设备名称 |
||||
|
static const int ALARM_ENABLE = 15; // 闹钟开关 |
||||
|
static const int DEV_MAC = 16; // 设备MAC地址 |
||||
|
|
||||
|
// 音乐相关属性 |
||||
|
static const int MUSIC_STATUS = 20; // 音乐状态 |
||||
|
static const int MUSIC_CURR_DEVICE = 21; // 当前音乐设备 |
||||
|
static const int MUSIC_TOTAL_TIME = 22; // 音乐总时长 |
||||
|
static const int MUSIC_CURR_PLAY_TIME = 23; // 当前播放时间 |
||||
|
static const int MUSIC_NUM = 24; // 音乐数量 |
||||
|
static const int MUSIC_DEV_STATUS = 25; // 设备存储状态 |
||||
|
static const int MUSIC_BREAK_POINT = 26; // 断点信息 |
||||
|
static const int MUSIC_CURR_FILENUM = 27; // 当前文件编号 |
||||
|
static const int MUSIC_TOTAL_FILENUM = 28; // 总文件数 |
||||
|
static const int MEDIA_TIMESTAMP = 29; // 媒体时间戳 |
||||
|
|
||||
|
// 模式相关 |
||||
|
static const int CUR_MODE = 30; // 当前模式 |
||||
|
|
||||
|
// TWS相关 |
||||
|
static const int TWS_CONN_STATUS = 60; // TWS连接状态 |
||||
|
static const int LEFT_CHN_BAT = 61; // 左耳电量 |
||||
|
static const int RIGHT_CHN_BAT = 62; // 右耳电量 |
||||
|
static const int TWS_STATUS = 63; // TWS状态 |
||||
|
} |
||||
|
|
||||
|
/// 功能码 |
||||
|
class JieliFunctionCode { |
||||
|
static const int COMMON = 0; // 公共功能 |
||||
|
static const int MUSIC = 1; // 音乐功能 |
||||
|
static const int RADIO = 2; // 收音机功能 |
||||
|
static const int LINEIN = 3; // 线路输入功能 |
||||
|
static const int RTC = 4; // 时钟功能 |
||||
|
static const int BT = 5; // 蓝牙功能 |
||||
|
static const int PC = 6; // PC功能 |
||||
|
static const int RECORD = 7; // 录音功能 |
||||
|
static const int UDISK = 8; // U盘功能 |
||||
|
static const int TALK = 9; // 通话功能 |
||||
|
static const int PHOTO = 10; // 拍照功能 |
||||
|
static const int VIDEO = 11; // 视频功能 |
||||
|
} |
||||
|
|
||||
|
/// 音乐播放状态 |
||||
|
class JieliMusicStatus { |
||||
|
static const int STOP = 0; // 停止状态 |
||||
|
static const int PLAY = 1; // 播放状态 |
||||
|
static const int PAUSE = 2; // 暂停状态 |
||||
|
static const int FAST_FORWARD = 3; // 快进状态 |
||||
|
static const int FAST_BACKWARD = 4; // 快退状态 |
||||
|
} |
||||
|
|
||||
|
/// 循环播放模式 |
||||
|
class JieliPlaybackMode { |
||||
|
static const int ALL = 0; // 全部循环 |
||||
|
static const int FOLDER = 1; // 文件夹循环 |
||||
|
static const int ONE = 2; // 单曲循环 |
||||
|
static const int RANDOM = 3; // 随机播放 |
||||
|
static const int BROWSE = 4; // 浏览播放 |
||||
|
} |
||||
|
|
||||
|
/// EQ模式 |
||||
|
class JieliEQMode { |
||||
|
static const int NORMAL = 0; // 正常 |
||||
|
static const int ROCK = 1; // 摇滚 |
||||
|
static const int POP = 2; // 流行 |
||||
|
static const int CLASSIC = 3; // 经典 |
||||
|
static const int JAZZ = 4; // 爵士 |
||||
|
static const int COUNTRY = 5; // 乡村 |
||||
|
static const int CUSTOM = 6; // 自定义 |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
import 'dart:convert'; |
||||
|
|
||||
|
import 'package:deep_voice/data/models/appconfig_model.dart'; |
||||
|
|
||||
|
class AppConfig { |
||||
|
static late UserGetAppConfigResp config; |
||||
|
|
||||
|
// 可选:提供初始化方法 |
||||
|
static void initialize(UserGetAppConfigResp resp) { |
||||
|
config = resp; |
||||
|
} |
||||
|
|
||||
|
//环境变量 |
||||
|
static String? env(String key) { |
||||
|
return config.env[key]; |
||||
|
} |
||||
|
|
||||
|
//mcp配置 |
||||
|
static dynamic mcpConfig() { |
||||
|
return {"mcpServers": config.mcps}; |
||||
|
} |
||||
|
|
||||
|
//智能体配置 |
||||
|
static String agent(String agentId) { |
||||
|
final foundAgent = config.agents.firstWhere((agent) => agent.id == agentId); |
||||
|
return jsonEncode((foundAgent)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,65 @@ |
|||||
|
import 'package:json_annotation/json_annotation.dart'; |
||||
|
|
||||
|
part 'appconfig_model.g.dart'; // 生成的文件名 |
||||
|
|
||||
|
@JsonSerializable() |
||||
|
class UserGetAppConfigResp { |
||||
|
final Map<String, String> env; |
||||
|
final List<DBAgent> agents; |
||||
|
final Map<String, DBMCPServer> mcps; |
||||
|
UserGetAppConfigResp( |
||||
|
{required this.env, required this.agents, required this.mcps}); |
||||
|
|
||||
|
factory UserGetAppConfigResp.fromJson(Map<String, dynamic> json) => |
||||
|
_$UserGetAppConfigRespFromJson(json); |
||||
|
Map<String, dynamic> toJson() => _$UserGetAppConfigRespToJson(this); |
||||
|
} |
||||
|
|
||||
|
@JsonSerializable() |
||||
|
class DBAgent { |
||||
|
final String id; |
||||
|
final String name; |
||||
|
final String avatarUrl; |
||||
|
final String description; |
||||
|
final String tag; |
||||
|
final bool isOnline; |
||||
|
final String voice; |
||||
|
final String? welcomeMessage; |
||||
|
final String? voiceWelcomeMessage; |
||||
|
final String? systemPrompt; |
||||
|
final List<String>? tools; |
||||
|
|
||||
|
DBAgent({ |
||||
|
required this.id, |
||||
|
required this.name, |
||||
|
required this.avatarUrl, |
||||
|
required this.description, |
||||
|
required this.tag, |
||||
|
required this.isOnline, |
||||
|
required this.voice, |
||||
|
required this.welcomeMessage, |
||||
|
required this.voiceWelcomeMessage, |
||||
|
required this.systemPrompt, |
||||
|
required this.tools, |
||||
|
}); |
||||
|
|
||||
|
factory DBAgent.fromJson(Map<String, dynamic> json) => |
||||
|
_$DBAgentFromJson(json); |
||||
|
Map<String, dynamic> toJson() => _$DBAgentToJson(this); |
||||
|
} |
||||
|
|
||||
|
@JsonSerializable() |
||||
|
class DBMCPServer { |
||||
|
final String servername; |
||||
|
final String url; |
||||
|
final List<String>? tools; |
||||
|
DBMCPServer({ |
||||
|
required this.servername, |
||||
|
required this.url, |
||||
|
required this.tools, |
||||
|
}); |
||||
|
|
||||
|
factory DBMCPServer.fromJson(Map<String, dynamic> json) => |
||||
|
_$DBMCPServerFromJson(json); |
||||
|
Map<String, dynamic> toJson() => _$DBMCPServerToJson(this); |
||||
|
} |
||||
@ -0,0 +1,70 @@ |
|||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND |
||||
|
|
||||
|
part of 'appconfig_model.dart'; |
||||
|
|
||||
|
// ************************************************************************** |
||||
|
// JsonSerializableGenerator |
||||
|
// ************************************************************************** |
||||
|
|
||||
|
UserGetAppConfigResp _$UserGetAppConfigRespFromJson( |
||||
|
Map<String, dynamic> json) => |
||||
|
UserGetAppConfigResp( |
||||
|
env: Map<String, String>.from(json['env'] as Map), |
||||
|
agents: (json['agents'] as List<dynamic>) |
||||
|
.map((e) => DBAgent.fromJson(e as Map<String, dynamic>)) |
||||
|
.toList(), |
||||
|
mcps: (json['mcps'] as Map<String, dynamic>).map( |
||||
|
(k, e) => MapEntry(k, DBMCPServer.fromJson(e as Map<String, dynamic>)), |
||||
|
), |
||||
|
); |
||||
|
|
||||
|
Map<String, dynamic> _$UserGetAppConfigRespToJson( |
||||
|
UserGetAppConfigResp instance) => |
||||
|
<String, dynamic>{ |
||||
|
'env': instance.env, |
||||
|
'agents': instance.agents, |
||||
|
'mcps': instance.mcps, |
||||
|
}; |
||||
|
|
||||
|
DBAgent _$DBAgentFromJson(Map<String, dynamic> json) => DBAgent( |
||||
|
id: json['id'] as String, |
||||
|
name: json['name'] as String, |
||||
|
avatarUrl: json['avatarUrl'] as String, |
||||
|
description: json['description'] as String, |
||||
|
tag: json['tag'] as String, |
||||
|
isOnline: json['isOnline'] as bool, |
||||
|
voice: json['voice'] as String, |
||||
|
welcomeMessage: json['welcomeMessage'] as String?, |
||||
|
voiceWelcomeMessage: json['voiceWelcomeMessage'] as String?, |
||||
|
systemPrompt: json['systemPrompt'] as String?, |
||||
|
tools: |
||||
|
(json['tools'] as List<dynamic>?)?.map((e) => e as String).toList(), |
||||
|
); |
||||
|
|
||||
|
Map<String, dynamic> _$DBAgentToJson(DBAgent instance) => <String, dynamic>{ |
||||
|
'id': instance.id, |
||||
|
'name': instance.name, |
||||
|
'avatarUrl': instance.avatarUrl, |
||||
|
'description': instance.description, |
||||
|
'tag': instance.tag, |
||||
|
'isOnline': instance.isOnline, |
||||
|
'voice': instance.voice, |
||||
|
'welcomeMessage': instance.welcomeMessage, |
||||
|
'voiceWelcomeMessage': instance.voiceWelcomeMessage, |
||||
|
'systemPrompt': instance.systemPrompt, |
||||
|
'tools': instance.tools, |
||||
|
}; |
||||
|
|
||||
|
DBMCPServer _$DBMCPServerFromJson(Map<String, dynamic> json) => DBMCPServer( |
||||
|
servername: json['servername'] as String, |
||||
|
url: json['url'] as String, |
||||
|
tools: |
||||
|
(json['tools'] as List<dynamic>?)?.map((e) => e as String).toList(), |
||||
|
); |
||||
|
|
||||
|
Map<String, dynamic> _$DBMCPServerToJson(DBMCPServer instance) => |
||||
|
<String, dynamic>{ |
||||
|
'servername': instance.servername, |
||||
|
'url': instance.url, |
||||
|
'tools': instance.tools, |
||||
|
}; |
||||
@ -0,0 +1,566 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:deep_voice/data/models/appconfig.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:ble_service/ble_service.dart'; |
||||
|
import 'package:deep_voice/core/utils/logger.dart'; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
import 'package:deep_voice/data/models/appconfig_model.dart'; |
||||
|
import 'dart:convert'; |
||||
|
import 'package:flutter/services.dart' show rootBundle; |
||||
|
|
||||
|
/// 命令类型常量(与原生层保持一致) |
||||
|
class BleCommands { |
||||
|
static const int GET_VERSION = 0x01; |
||||
|
static const int GET_BATTERY_INFO = 0x02; |
||||
|
static const int CONTROL_CODEC = 0x03; |
||||
|
static const int PLAY_A2DP = 0x04; |
||||
|
} |
||||
|
|
||||
|
/// 蓝牙设备信息缓存模型 |
||||
|
class DeviceInfo { |
||||
|
final String address; |
||||
|
final String name; |
||||
|
|
||||
|
// 整体电池电量 |
||||
|
final int batteryLevel; |
||||
|
|
||||
|
// 详细电池信息 |
||||
|
final int leftEarBattery; |
||||
|
final int rightEarBattery; |
||||
|
final int caseBattery; |
||||
|
|
||||
|
// 充电状态 |
||||
|
final bool leftEarCharging; |
||||
|
final bool rightEarCharging; |
||||
|
final bool caseCharging; |
||||
|
|
||||
|
final String firmwareVersion; |
||||
|
final DateTime lastUpdated; |
||||
|
|
||||
|
DeviceInfo({ |
||||
|
required this.address, |
||||
|
required this.name, |
||||
|
this.batteryLevel = 0, |
||||
|
this.leftEarBattery = 0, |
||||
|
this.rightEarBattery = 0, |
||||
|
this.caseBattery = 0, |
||||
|
this.leftEarCharging = false, |
||||
|
this.rightEarCharging = false, |
||||
|
this.caseCharging = false, |
||||
|
this.firmwareVersion = '', |
||||
|
DateTime? lastUpdated, |
||||
|
}) : lastUpdated = lastUpdated ?? DateTime.now(); |
||||
|
|
||||
|
/// 创建更新后的设备信息副本 |
||||
|
DeviceInfo copyWith({ |
||||
|
String? address, |
||||
|
String? name, |
||||
|
int? batteryLevel, |
||||
|
int? leftEarBattery, |
||||
|
int? rightEarBattery, |
||||
|
int? caseBattery, |
||||
|
bool? leftEarCharging, |
||||
|
bool? rightEarCharging, |
||||
|
bool? caseCharging, |
||||
|
String? firmwareVersion, |
||||
|
}) { |
||||
|
return DeviceInfo( |
||||
|
address: address ?? this.address, |
||||
|
name: name ?? this.name, |
||||
|
batteryLevel: batteryLevel ?? this.batteryLevel, |
||||
|
leftEarBattery: leftEarBattery ?? this.leftEarBattery, |
||||
|
rightEarBattery: rightEarBattery ?? this.rightEarBattery, |
||||
|
caseBattery: caseBattery ?? this.caseBattery, |
||||
|
leftEarCharging: leftEarCharging ?? this.leftEarCharging, |
||||
|
rightEarCharging: rightEarCharging ?? this.rightEarCharging, |
||||
|
caseCharging: caseCharging ?? this.caseCharging, |
||||
|
firmwareVersion: firmwareVersion ?? this.firmwareVersion, |
||||
|
lastUpdated: DateTime.now(), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
String toString() { |
||||
|
return 'DeviceInfo(address: $address, name: $name, ' |
||||
|
'battery: $batteryLevel%, L: $leftEarBattery%, R: $rightEarBattery%, Case: $caseBattery%, ' |
||||
|
'charging: L:$leftEarCharging, R:$rightEarCharging, C:$caseCharging, ' |
||||
|
'version: $firmwareVersion)'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// BLE管理器 - 专门处理BLE设备连接和信息管理 |
||||
|
class BleManager extends GetxService { |
||||
|
static BleManager get to => Get.find<BleManager>(); |
||||
|
|
||||
|
// BLE服务实例 |
||||
|
final BleService _bleService = BleService(); |
||||
|
|
||||
|
// BLE连接状态 |
||||
|
final _connectionState = BleConnectionState.disconnected.obs; |
||||
|
int get connectionState => _connectionState.value; |
||||
|
|
||||
|
// 连接状态监听 |
||||
|
final _isConnected = false.obs; |
||||
|
bool get isConnected => _isConnected.value; |
||||
|
|
||||
|
// 设备信息缓存 |
||||
|
final Rx<DeviceInfo?> _deviceInfo = Rx<DeviceInfo?>(null); |
||||
|
DeviceInfo? get deviceInfo => _deviceInfo.value; |
||||
|
|
||||
|
// 订阅 |
||||
|
StreamSubscription<dynamic>? _statusSubscription; |
||||
|
StreamSubscription<dynamic>? _dataSubscription; |
||||
|
final GetStorage _storage = GetStorage(); |
||||
|
|
||||
|
/// 初始化 |
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
Logger.info('BLE管理器初始化'); |
||||
|
|
||||
|
// 设置BLE事件监听 |
||||
|
_setupBleListener(); |
||||
|
} |
||||
|
|
||||
|
/// 释放资源 |
||||
|
@override |
||||
|
void onClose() { |
||||
|
_statusSubscription?.cancel(); |
||||
|
_dataSubscription?.cancel(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
/// 设置BLE事件监听 |
||||
|
void _setupBleListener() { |
||||
|
_statusSubscription?.cancel(); |
||||
|
_dataSubscription?.cancel(); |
||||
|
|
||||
|
try { |
||||
|
// 监听BLE状态更新 |
||||
|
_statusSubscription = _bleService.onStatusUpdated.listen( |
||||
|
(event) { |
||||
|
Logger.info('接收到BLE状态更新: $event'); |
||||
|
|
||||
|
if (event is Map) { |
||||
|
// 处理连接状态更新 |
||||
|
if (event.containsKey('connectionState')) { |
||||
|
final state = event['connectionState'] as int; |
||||
|
_connectionState.value = state; |
||||
|
|
||||
|
// 连接状态转换 |
||||
|
if (state == BleConnectionState.connected) { |
||||
|
Logger.info('BLE设备已连接'); |
||||
|
_isConnected.value = true; |
||||
|
|
||||
|
// 在连接成功后获取设备信息 |
||||
|
_fetchDeviceInfo(); |
||||
|
} else if (state == BleConnectionState.disconnected || |
||||
|
state == BleConnectionState.error) { |
||||
|
Logger.info('BLE设备已断开或发生错误'); |
||||
|
_isConnected.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 处理设备信息 |
||||
|
_processDeviceInfo(event); |
||||
|
} |
||||
|
}, |
||||
|
onError: (error) { |
||||
|
Logger.error('BLE状态事件流错误: $error'); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
// 监听BLE数据流 |
||||
|
_dataSubscription = _bleService.onDataReceived.listen( |
||||
|
(data) { |
||||
|
Logger.info('接收到BLE数据: $data'); |
||||
|
// 在这里处理来自设备的数据 |
||||
|
}, |
||||
|
onError: (error) { |
||||
|
Logger.error('BLE数据流错误: $error'); |
||||
|
}, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
Logger.error('设置BLE事件监听器失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理来自原生层的设备信息 |
||||
|
void _processDeviceInfo(Map<dynamic, dynamic> event) { |
||||
|
try { |
||||
|
// 检查是否是设备信息响应 |
||||
|
if (event.containsKey('command') && event.containsKey('success')) { |
||||
|
final bool success = event['success'] as bool; |
||||
|
if (!success) { |
||||
|
Logger.warning('设备命令执行失败: ${event['command']}'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 根据命令类型处理不同信息 |
||||
|
final command = event['command']; |
||||
|
|
||||
|
if (command is int) { |
||||
|
// 基于命令ID处理 |
||||
|
_processDeviceInfoByCommandId(command, event); |
||||
|
} else if (command is String) { |
||||
|
// 基于命令名称处理 |
||||
|
_processDeviceInfoByCommandName(command, event); |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('处理设备信息异常: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 根据命令ID处理设备信息 |
||||
|
void _processDeviceInfoByCommandId( |
||||
|
int commandId, Map<dynamic, dynamic> data) { |
||||
|
switch (commandId) { |
||||
|
case BleCommands.GET_BATTERY_INFO: |
||||
|
_updateBatteryInfoFromData(data); |
||||
|
break; |
||||
|
case BleCommands.GET_VERSION: |
||||
|
_updateVersionInfoFromData(data); |
||||
|
break; |
||||
|
default: |
||||
|
Logger.info('未处理的命令ID: $commandId'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 根据命令名称处理设备信息 |
||||
|
void _processDeviceInfoByCommandName( |
||||
|
String commandName, Map<dynamic, dynamic> data) { |
||||
|
switch (commandName) { |
||||
|
case 'batteryInfo': |
||||
|
_updateBatteryInfoFromData(data); |
||||
|
break; |
||||
|
case 'versionInfo': |
||||
|
_updateVersionInfoFromData(data); |
||||
|
break; |
||||
|
default: |
||||
|
Logger.info('未处理的命令名称: $commandName'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 更新电池信息 |
||||
|
void _updateBatteryInfoFromData(Map<dynamic, dynamic> data) { |
||||
|
try { |
||||
|
// 确保数据中包含电池信息 |
||||
|
if (!data.containsKey('leftEarBattery') || |
||||
|
!data.containsKey('rightEarBattery') || |
||||
|
!data.containsKey('caseBattery')) { |
||||
|
Logger.warning('电池信息数据不完整'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 提取电池信息 |
||||
|
final int leftBattery = data['leftEarBattery'] as int? ?? 0; |
||||
|
final int rightBattery = data['rightEarBattery'] as int? ?? 0; |
||||
|
final int caseBattery = data['caseBattery'] as int? ?? 0; |
||||
|
final bool leftCharging = data['leftEarCharging'] as bool? ?? false; |
||||
|
final bool rightCharging = data['rightEarCharging'] as bool? ?? false; |
||||
|
final bool caseCharging = data['caseCharging'] as bool? ?? false; |
||||
|
|
||||
|
// 获取整体电量(原生层使用左耳电量作为主要指示) |
||||
|
final int overallBattery = data['batteryLevel'] as int? ?? leftBattery; |
||||
|
|
||||
|
// 更新设备信息 |
||||
|
_updateDeviceInfoIfNeeded( |
||||
|
batteryLevel: overallBattery, |
||||
|
leftEarBattery: leftBattery, |
||||
|
rightEarBattery: rightBattery, |
||||
|
caseBattery: caseBattery, |
||||
|
leftEarCharging: leftCharging, |
||||
|
rightEarCharging: rightCharging, |
||||
|
caseCharging: caseCharging, |
||||
|
); |
||||
|
|
||||
|
Logger.info( |
||||
|
'设备电池信息已更新: 电量=$overallBattery%, 左耳=$leftBattery%, 右耳=$rightBattery%, 充电仓=$caseBattery%'); |
||||
|
} catch (e) { |
||||
|
Logger.error('更新电池信息失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 更新版本信息 |
||||
|
void _updateVersionInfoFromData(Map<dynamic, dynamic> data) { |
||||
|
try { |
||||
|
// 确保数据中包含版本信息 |
||||
|
if (!data.containsKey('version')) { |
||||
|
Logger.warning('版本信息数据不完整'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 提取版本信息 |
||||
|
final String version = data['version'] as String? ?? ''; |
||||
|
|
||||
|
// 更新设备信息 |
||||
|
_updateDeviceInfoIfNeeded( |
||||
|
firmwareVersion: version, |
||||
|
); |
||||
|
|
||||
|
Logger.info('设备版本信息已更新: 版本=$version'); |
||||
|
} catch (e) { |
||||
|
Logger.error('更新版本信息失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 更新设备信息(如果已存在设备) |
||||
|
void _updateDeviceInfoIfNeeded({ |
||||
|
int? batteryLevel, |
||||
|
int? leftEarBattery, |
||||
|
int? rightEarBattery, |
||||
|
int? caseBattery, |
||||
|
bool? leftEarCharging, |
||||
|
bool? rightEarCharging, |
||||
|
bool? caseCharging, |
||||
|
String? firmwareVersion, |
||||
|
}) { |
||||
|
// 如果设备信息已存在,则更新 |
||||
|
if (_deviceInfo.value != null) { |
||||
|
_deviceInfo.value = _deviceInfo.value!.copyWith( |
||||
|
batteryLevel: batteryLevel, |
||||
|
leftEarBattery: leftEarBattery, |
||||
|
rightEarBattery: rightEarBattery, |
||||
|
caseBattery: caseBattery, |
||||
|
leftEarCharging: leftEarCharging, |
||||
|
rightEarCharging: rightEarCharging, |
||||
|
caseCharging: caseCharging, |
||||
|
firmwareVersion: firmwareVersion, |
||||
|
); |
||||
|
} |
||||
|
// 如果设备信息不存在,但有地址和名称,则创建新的设备信息 |
||||
|
else if (_deviceInfo.value == null && |
||||
|
_isConnected.value && |
||||
|
(firmwareVersion != null || batteryLevel != null)) { |
||||
|
// 提示尝试创建设备信息但缺少必要信息 |
||||
|
Logger.warning('尝试更新设备信息,但设备基本信息不存在'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 连接成功后获取设备信息 |
||||
|
Future<void> _fetchDeviceInfo() async { |
||||
|
Logger.info('开始获取BLE设备详细信息'); |
||||
|
|
||||
|
try { |
||||
|
// 获取电池信息 |
||||
|
final batteryResult = await _bleService.getBatteryInfo(); |
||||
|
if (batteryResult) { |
||||
|
Logger.info('电池信息获取指令已发送'); |
||||
|
} else { |
||||
|
Logger.warning('电池信息获取指令发送失败'); |
||||
|
} |
||||
|
|
||||
|
// 获取设备版本信息 |
||||
|
final versionResult = await _bleService.getDeviceVersion(); |
||||
|
if (versionResult) { |
||||
|
Logger.info('版本信息获取指令已发送'); |
||||
|
} else { |
||||
|
Logger.warning('版本信息获取指令发送失败'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('获取BLE设备信息失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查BLE连接状态 |
||||
|
Future<int> getConnectionState() async { |
||||
|
try { |
||||
|
final isConnected = await _bleService.isConnected(); |
||||
|
_connectionState.value = isConnected |
||||
|
? BleConnectionState.connected |
||||
|
: BleConnectionState.disconnected; |
||||
|
_isConnected.value = isConnected; |
||||
|
|
||||
|
return _connectionState.value; |
||||
|
} catch (e) { |
||||
|
Logger.error('获取BLE连接状态失败: $e'); |
||||
|
return BleConnectionState.error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 启动BLE服务 |
||||
|
Future<bool> startBleService() async { |
||||
|
try { |
||||
|
// 从.env文件直接读取环境变量 |
||||
|
// final azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
||||
|
// final azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? 'eastasia'; |
||||
|
// final openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; |
||||
|
// final openaiBaseUrl = |
||||
|
// dotenv.env['OPENAI_BASE_URL'] ?? 'https://api.openai.com/v1'; |
||||
|
// final openaiModel = dotenv.env['OPENAI_MODEL'] ?? 'gpt-3.5-turbo'; |
||||
|
// 从Flutter资源包中加载.mcp.json |
||||
|
// final mcpConfig = await rootBundle.loadString('.mcp.json'); |
||||
|
|
||||
|
// 验证JSON格式 |
||||
|
// final jsonData = jsonDecode(mcpConfig); |
||||
|
|
||||
|
final azureSpeechKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; |
||||
|
final azureSpeechRegion = |
||||
|
AppConfig.env('AZURE_SPEECH_REGION') ?? 'eastasia'; |
||||
|
final openaiApiKey = AppConfig.env('OPENAI_API_KEY') ?? ''; |
||||
|
final openaiBaseUrl = |
||||
|
AppConfig.env('OPENAI_BASE_URL') ?? 'https://api.openai.com/v1'; |
||||
|
final openaiModel = AppConfig.env('OPENAI_MODEL') ?? 'gpt-3.5-turbo'; |
||||
|
// 从Flutter资源包中加载.mcp.json |
||||
|
final jsonData = AppConfig.mcpConfig(); |
||||
|
|
||||
|
final config = <String, dynamic>{ |
||||
|
// 语音代理配置参数 |
||||
|
'azureSpeechKey': azureSpeechKey, |
||||
|
'azureSpeechRegion': azureSpeechRegion, |
||||
|
'openaiApiKey': openaiApiKey, |
||||
|
'openaiBaseUrl': openaiBaseUrl, |
||||
|
'openaiModel': openaiModel, |
||||
|
'mcpServer': jsonEncode(jsonData), |
||||
|
}; |
||||
|
|
||||
|
// 记录日志(不记录敏感信息) |
||||
|
Logger.info('启动BLE服务,配置参数键名: ${config}'); |
||||
|
|
||||
|
if (azureSpeechKey.isEmpty || openaiApiKey.isEmpty) { |
||||
|
Logger.warning('缺少必要的API密钥,请检查.env文件配置'); |
||||
|
|
||||
|
// 检查.env中相关密钥是否存在 |
||||
|
if (azureSpeechKey.isEmpty) { |
||||
|
Logger.warning('AZURE_SPEECH_KEY未配置在.env文件中'); |
||||
|
} |
||||
|
if (openaiApiKey.isEmpty) { |
||||
|
Logger.warning('OPENAI_API_KEY未配置在.env文件中'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
final result = await _bleService.startBleService(config); |
||||
|
|
||||
|
if (result) { |
||||
|
Logger.info('BLE服务启动成功'); |
||||
|
|
||||
|
// 添加延迟以等待设备连接 |
||||
|
await Future.delayed(const Duration(seconds: 2)); |
||||
|
|
||||
|
// 检查连接状态 |
||||
|
final isConnected = await _bleService.isConnected(); |
||||
|
if (isConnected) { |
||||
|
_isConnected.value = true; |
||||
|
_connectionState.value = BleConnectionState.connected; |
||||
|
|
||||
|
// 如果设备信息存在,则获取详细信息 |
||||
|
if (_deviceInfo.value != null) { |
||||
|
// 获取详细设备信息 |
||||
|
_fetchDeviceInfo(); |
||||
|
} |
||||
|
} |
||||
|
} else { |
||||
|
Logger.error('BLE服务启动失败'); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
Logger.error('启动BLE服务失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止BLE服务 |
||||
|
Future<bool> stopBleService() async { |
||||
|
try { |
||||
|
final result = await _bleService.stopBleService(); |
||||
|
|
||||
|
if (result) { |
||||
|
Logger.info('BLE服务停止成功'); |
||||
|
_isConnected.value = false; |
||||
|
_connectionState.value = BleConnectionState.disconnected; |
||||
|
} else { |
||||
|
Logger.error('BLE服务停止失败'); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
Logger.error('停止BLE服务失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
registerBackgroundScan() { |
||||
|
_bleService.registerBackgroundScan(); |
||||
|
} |
||||
|
|
||||
|
/// 获取电池电量 |
||||
|
Future<int> getBatteryLevel() async { |
||||
|
try { |
||||
|
return await _bleService.getBatteryLevel(); |
||||
|
} catch (e) { |
||||
|
Logger.error('获取电池电量失败: $e'); |
||||
|
return 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 主动获取电池信息 |
||||
|
Future<bool> fetchBatteryInfo() async { |
||||
|
try { |
||||
|
return await _bleService.getBatteryInfo(); |
||||
|
} catch (e) { |
||||
|
Logger.error('获取电池信息失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 主动获取设备版本信息 |
||||
|
Future<bool> fetchDeviceVersion() async { |
||||
|
try { |
||||
|
return await _bleService.getDeviceVersion(); |
||||
|
} catch (e) { |
||||
|
Logger.error('获取设备版本失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 打开编码器 |
||||
|
Future<bool> openEncoder() async { |
||||
|
try { |
||||
|
return await _bleService.openEncoder(); |
||||
|
} catch (e) { |
||||
|
Logger.error('打开编码器失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 打开解码器 |
||||
|
Future<bool> openDecoder() async { |
||||
|
try { |
||||
|
return await _bleService.openDecoder(); |
||||
|
} catch (e) { |
||||
|
Logger.error('打开解码器失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 关闭编解码器 |
||||
|
Future<bool> closeCodec() async { |
||||
|
try { |
||||
|
return await _bleService.closeCodec(); |
||||
|
} catch (e) { |
||||
|
Logger.error('关闭编解码器失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 播放A2DP |
||||
|
Future<bool> playA2DP() async { |
||||
|
try { |
||||
|
return await _bleService.playA2DP(); |
||||
|
} catch (e) { |
||||
|
Logger.error('播放A2DP失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 注册服务 |
||||
|
static Future<BleManager> init() async { |
||||
|
if (!Get.isRegistered<BleManager>()) { |
||||
|
Get.put(BleManager(), permanent: true); |
||||
|
} |
||||
|
return Future.value(Get.find<BleManager>()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,125 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:classic_bluetooth/classic_bluetooth.dart'; |
||||
|
import 'package:deep_voice/core/utils/logger.dart'; |
||||
|
|
||||
|
|
||||
|
/// 蓝牙服务 - 管理经典蓝牙服务 |
||||
|
class BluetoothManager extends GetxService { |
||||
|
static BluetoothManager get to => Get.find<BluetoothManager>(); |
||||
|
|
||||
|
// 蓝牙状态 |
||||
|
final _bluetoothState = 'unknown'.obs; |
||||
|
String get bluetoothState => _bluetoothState.value; |
||||
|
|
||||
|
// 当前经典蓝牙设备 |
||||
|
final Rx<BluetoothDevice?> currentDeviceRx = Rx<BluetoothDevice?>(null); |
||||
|
BluetoothDevice? get currentDevice => currentDeviceRx.value; |
||||
|
|
||||
|
// 订阅 |
||||
|
StreamSubscription<BluetoothEvent>? _bluetoothSubscription; |
||||
|
|
||||
|
|
||||
|
/// 初始化 |
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
Logger.info('蓝牙服务初始化'); |
||||
|
|
||||
|
|
||||
|
// 设置蓝牙事件监听 |
||||
|
_setupBluetoothListener(); |
||||
|
} |
||||
|
|
||||
|
/// 释放资源 |
||||
|
@override |
||||
|
void onClose() { |
||||
|
_bluetoothSubscription?.cancel(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
/// 设置经典蓝牙事件监听 |
||||
|
void _setupBluetoothListener() { |
||||
|
_bluetoothSubscription?.cancel(); |
||||
|
|
||||
|
try { |
||||
|
_bluetoothSubscription = ClassicBluetooth.events.listen( |
||||
|
(event) { |
||||
|
if (event.type == 'stateChanged' && event.state != null) { |
||||
|
_bluetoothState.value = event.state!; |
||||
|
Logger.info('蓝牙状态变更: ${event.state}'); |
||||
|
|
||||
|
// 如果蓝牙关闭,清除设备 |
||||
|
if (event.state == 'off') { |
||||
|
currentDeviceRx.value = null; |
||||
|
} |
||||
|
} else if (event.type == 'deviceConnected' && event.device != null) { |
||||
|
// 直接从事件中获取设备信息 |
||||
|
final newDevice = event.device!; |
||||
|
|
||||
|
// 更新当前设备 |
||||
|
currentDeviceRx.value = newDevice; |
||||
|
|
||||
|
Logger.info('经典蓝牙设备已连接: ${newDevice.name} (${newDevice.address})'); |
||||
|
} else if (event.type == 'deviceDisconnected' && event.device != null) { |
||||
|
// 直接从事件中获取设备信息 |
||||
|
final disconnectedDevice = event.device!; |
||||
|
|
||||
|
Logger.info('经典蓝牙设备已断开: ${disconnectedDevice.name} (${disconnectedDevice.address})'); |
||||
|
|
||||
|
// 如果断开的是当前设备,清除当前设备 |
||||
|
if (currentDeviceRx.value?.address == disconnectedDevice.address) { |
||||
|
currentDeviceRx.value = null; |
||||
|
Logger.info('已清除当前设备引用'); |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
onError: (error) { |
||||
|
Logger.error('蓝牙事件流错误: $error'); |
||||
|
}, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
Logger.error('设置蓝牙事件监听器失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 刷新设备连接状态 |
||||
|
Future<void> refreshDeviceConnection() async { |
||||
|
try { |
||||
|
|
||||
|
|
||||
|
// 如果BLE未连接,则检查经典蓝牙连接 |
||||
|
final devices = await ClassicBluetooth.getConnectedDevices(); |
||||
|
|
||||
|
if (devices.isNotEmpty) { |
||||
|
final newDevice = devices.first; |
||||
|
|
||||
|
// 更新当前设备 |
||||
|
currentDeviceRx.value = newDevice; |
||||
|
|
||||
|
Logger.info('已连接经典蓝牙设备: ${newDevice.name} (${newDevice.address})'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 没有任何连接 |
||||
|
currentDeviceRx.value = null; |
||||
|
Logger.info('未找到已连接的蓝牙设备'); |
||||
|
|
||||
|
} catch (e) { |
||||
|
Logger.error('刷新设备连接失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查蓝牙是否已启用 |
||||
|
Future<bool> isBluetoothEnabled() async { |
||||
|
try { |
||||
|
final isEnabled = await ClassicBluetooth.isBluetoothEnabled(); |
||||
|
_bluetoothState.value = isEnabled ? 'on' : 'off'; |
||||
|
return isEnabled; |
||||
|
} catch (e) { |
||||
|
Logger.error('检查蓝牙状态失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -1,310 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:io' show Platform; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
import 'voice_interaction_service.dart'; |
|
||||
/// 蓝牙媒体按钮事件类型 |
|
||||
enum MediaButtonType { |
|
||||
/// 播放/暂停按钮 |
|
||||
playPause, |
|
||||
|
|
||||
/// 未知按钮 |
|
||||
unknown |
|
||||
} |
|
||||
|
|
||||
/// 蓝牙媒体按钮事件 |
|
||||
class MediaButtonEvent { |
|
||||
/// 按钮类型 |
|
||||
final MediaButtonType buttonType; |
|
||||
|
|
||||
/// 事件发生时间戳 |
|
||||
final int timestamp; |
|
||||
|
|
||||
MediaButtonEvent({ |
|
||||
required this.buttonType, |
|
||||
required this.timestamp, |
|
||||
}); |
|
||||
|
|
||||
factory MediaButtonEvent.fromMap(Map<dynamic, dynamic> map) { |
|
||||
MediaButtonType buttonType; |
|
||||
|
|
||||
switch (map['buttonType']) { |
|
||||
case 'playPause': |
|
||||
buttonType = MediaButtonType.playPause; |
|
||||
break; |
|
||||
default: |
|
||||
// 将所有其他事件类型映射为 playPause |
|
||||
if (map['buttonType'] == 'play' || |
|
||||
map['buttonType'] == 'pause' || |
|
||||
map['buttonType'] == 'togglePlayPause') { |
|
||||
buttonType = MediaButtonType.playPause; |
|
||||
} else { |
|
||||
// 忽略其他类型(next, previous, seekForward, seekBackward) |
|
||||
buttonType = MediaButtonType.unknown; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return MediaButtonEvent( |
|
||||
buttonType: buttonType, |
|
||||
timestamp: map['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
String toString() { |
|
||||
switch (buttonType) { |
|
||||
case MediaButtonType.playPause: |
|
||||
return '蓝牙媒体按钮: 播放/暂停'; |
|
||||
case MediaButtonType.unknown: |
|
||||
return '蓝牙媒体按钮: 未知'; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 蓝牙媒体按钮服务 |
|
||||
class BluetoothMediaButtonService extends GetxService { |
|
||||
static BluetoothMediaButtonService get to => Get.find<BluetoothMediaButtonService>(); |
|
||||
|
|
||||
// 方法通道名称 |
|
||||
final MethodChannel _channel = const MethodChannel('com.deep_voice.bluetooth_media_button'); |
|
||||
|
|
||||
// 事件通道名称 |
|
||||
final EventChannel _eventChannel = const EventChannel('com.deep_voice.bluetooth_media_button_events'); |
|
||||
|
|
||||
/// 按钮事件流控制器 |
|
||||
final _buttonEventController = StreamController<MediaButtonEvent>.broadcast(); |
|
||||
|
|
||||
/// 按钮事件流 |
|
||||
Stream<MediaButtonEvent> get buttonEvents => _buttonEventController.stream; |
|
||||
|
|
||||
/// 是否已启用监听 |
|
||||
final _isListening = false.obs; |
|
||||
bool get isListening => _isListening.value; |
|
||||
|
|
||||
/// 是否正在加载 |
|
||||
final isLoading = false.obs; |
|
||||
|
|
||||
/// 错误信息 |
|
||||
final errorMessage = ''.obs; |
|
||||
|
|
||||
/// 按钮事件流订阅 |
|
||||
StreamSubscription? _buttonEventSubscription; |
|
||||
|
|
||||
/// 服务是否已初始化 |
|
||||
bool _isInitialized = false; |
|
||||
|
|
||||
/// 是否支持媒体按钮服务(iOS 13.0及以上) |
|
||||
bool get isSupported { |
|
||||
if (!Platform.isIOS) return true; // 非iOS平台默认支持 |
|
||||
|
|
||||
// 获取平台版本信息 |
|
||||
final String version = Platform.operatingSystemVersion; |
|
||||
|
|
||||
// 尝试提取iOS版本号 (格式如: "Version 14.5 (Build 18E182)") |
|
||||
try { |
|
||||
// 提取版本号 |
|
||||
final RegExp versionRegex = RegExp(r'Version\s+(\d+)\.(\d+)'); |
|
||||
final match = versionRegex.firstMatch(version); |
|
||||
|
|
||||
if (match != null) { |
|
||||
final int majorVersion = int.parse(match.group(1) ?? '0'); |
|
||||
return majorVersion >= 13; // iOS 13及以上支持 |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('解析iOS版本失败: $e'); |
|
||||
} |
|
||||
|
|
||||
// 无法确定版本时,假设不支持 |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
/// 初始化服务 |
|
||||
BluetoothMediaButtonService() { |
|
||||
Logger.info('创建 BluetoothMediaButtonService 实例'); |
|
||||
} |
|
||||
|
|
||||
/// 初始化媒体按钮服务 |
|
||||
Future<bool> initialize() async { |
|
||||
if (_isInitialized) { |
|
||||
Logger.info('BluetoothMediaButtonService 已经初始化'); |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
// 检查系统版本是否支持 |
|
||||
if (!isSupported) { |
|
||||
Logger.info('当前iOS版本不支持蓝牙媒体按钮功能(需要iOS 13.0+)'); |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
Logger.info('正在初始化 BluetoothMediaButtonService...'); |
|
||||
|
|
||||
// 设置事件监听器 |
|
||||
_setupButtonEventListener(); |
|
||||
|
|
||||
_isInitialized = true; |
|
||||
Logger.info('BluetoothMediaButtonService 初始化成功'); |
|
||||
return true; |
|
||||
} catch (e) { |
|
||||
Logger.error('初始化 BluetoothMediaButtonService 失败: $e'); |
|
||||
errorMessage.value = '初始化蓝牙媒体按钮服务失败'; |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置按钮事件监听器 |
|
||||
void _setupButtonEventListener() { |
|
||||
_buttonEventSubscription?.cancel(); |
|
||||
|
|
||||
// 设置事件通道监听器 |
|
||||
_buttonEventSubscription = _eventChannel |
|
||||
.receiveBroadcastStream() |
|
||||
.listen(_handleButtonEvent, onError: _handleEventStreamError); |
|
||||
|
|
||||
Logger.info('已设置蓝牙媒体按钮事件监听器'); |
|
||||
} |
|
||||
|
|
||||
/// 处理按钮事件 |
|
||||
void _handleButtonEvent(dynamic event) { |
|
||||
Logger.info('BluetoothMediaButtonService 收到原始事件: $event'); |
|
||||
|
|
||||
if (event is! Map) { |
|
||||
Logger.error('收到无效的蓝牙媒体按钮事件 (非Map类型): $event'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
final Map<dynamic, dynamic> eventMap = event; |
|
||||
|
|
||||
try { |
|
||||
if (eventMap['type'] == 'mediaButtonEvent') { |
|
||||
// 获取按钮类型 |
|
||||
final String buttonType = eventMap['buttonType'] as String? ?? 'unknown'; |
|
||||
|
|
||||
// 判断是否为playPause相关事件 |
|
||||
if (buttonType == 'playPause' || |
|
||||
buttonType == 'play' || |
|
||||
buttonType == 'pause' || |
|
||||
buttonType == 'togglePlayPause') { |
|
||||
|
|
||||
// 创建playPause事件并广播 |
|
||||
final buttonEvent = MediaButtonEvent( |
|
||||
buttonType: MediaButtonType.playPause, |
|
||||
timestamp: eventMap['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, |
|
||||
); |
|
||||
|
|
||||
|
|
||||
|
|
||||
// 广播事件 |
|
||||
_buttonEventController.add(buttonEvent); |
|
||||
|
|
||||
// 广播事件 |
|
||||
_buttonEventController.add(buttonEvent); |
|
||||
Logger.info('发送playPause按钮事件: $buttonEvent'); |
|
||||
} else { |
|
||||
// 忽略其他类型的按钮事件 |
|
||||
Logger.info('忽略非playPause按钮事件: $buttonType'); |
|
||||
} |
|
||||
} else { |
|
||||
Logger.warning('收到未知类型的事件: ${eventMap['type']}'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('处理蓝牙媒体按钮事件时出错: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 处理事件流错误 |
|
||||
void _handleEventStreamError(dynamic error) { |
|
||||
Logger.error('蓝牙媒体按钮事件流错误: $error'); |
|
||||
errorMessage.value = '蓝牙媒体按钮监听出错'; |
|
||||
} |
|
||||
|
|
||||
/// 开始监听蓝牙媒体按钮事件 |
|
||||
Future<bool> startButtonListening() async { |
|
||||
// 检查系统版本是否支持 |
|
||||
if (!isSupported) { |
|
||||
Logger.info('当前iOS版本不支持蓝牙媒体按钮功能(需要iOS 13.0+)'); |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
if (!_isInitialized && !(await initialize())) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
if (_isListening.value) { |
|
||||
Logger.info('已经在监听蓝牙媒体按钮事件'); |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
isLoading.value = true; |
|
||||
|
|
||||
try { |
|
||||
final result = await _channel.invokeMethod<bool>('startButtonListening') ?? false; |
|
||||
|
|
||||
if (result) { |
|
||||
_isListening.value = true; |
|
||||
Logger.info('开始监听蓝牙媒体按钮事件成功'); |
|
||||
} else { |
|
||||
errorMessage.value = '开始监听蓝牙媒体按钮事件失败'; |
|
||||
Logger.error('开始监听蓝牙媒体按钮事件失败'); |
|
||||
} |
|
||||
|
|
||||
isLoading.value = false; |
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
isLoading.value = false; |
|
||||
errorMessage.value = '开始监听蓝牙媒体按钮事件出错: $e'; |
|
||||
Logger.error('开始监听蓝牙媒体按钮事件出错: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止监听蓝牙媒体按钮事件 |
|
||||
Future<bool> stopButtonListening() async { |
|
||||
if (!_isInitialized) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
if (!_isListening.value) { |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
isLoading.value = true; |
|
||||
|
|
||||
try { |
|
||||
final result = await _channel.invokeMethod<bool>('stopButtonListening') ?? false; |
|
||||
|
|
||||
if (result) { |
|
||||
_isListening.value = false; |
|
||||
Logger.info('停止监听蓝牙媒体按钮事件成功'); |
|
||||
} else { |
|
||||
errorMessage.value = '停止监听蓝牙媒体按钮事件失败'; |
|
||||
Logger.error('停止监听蓝牙媒体按钮事件失败'); |
|
||||
} |
|
||||
|
|
||||
isLoading.value = false; |
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
isLoading.value = false; |
|
||||
errorMessage.value = '停止监听蓝牙媒体按钮事件出错: $e'; |
|
||||
Logger.error('停止监听蓝牙媒体按钮事件出错: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 释放资源 |
|
||||
void dispose() { |
|
||||
_buttonEventSubscription?.cancel(); |
|
||||
_buttonEventSubscription = null; |
|
||||
_buttonEventController.close(); |
|
||||
_isInitialized = false; |
|
||||
_isListening.value = false; |
|
||||
Logger.info('BluetoothMediaButtonService 已释放'); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
dispose(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,243 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:permission_handler/permission_handler.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
import 'dart:io'; |
|
||||
|
|
||||
/// 蓝牙设备信息 |
|
||||
class BluetoothDevice { |
|
||||
final String name; |
|
||||
final String address; |
|
||||
final String type; |
|
||||
|
|
||||
BluetoothDevice( |
|
||||
{required this.name, required this.address, required this.type}); |
|
||||
|
|
||||
@override |
|
||||
String toString() => |
|
||||
'BluetoothDevice(name: $name, address: $address, type: $type)'; |
|
||||
} |
|
||||
|
|
||||
/// 经典蓝牙服务 |
|
||||
class ClassicBluetoothService extends GetxService { |
|
||||
static ClassicBluetoothService get to => Get.find<ClassicBluetoothService>(); |
|
||||
|
|
||||
// 通道名称 |
|
||||
final MethodChannel _channel = |
|
||||
const MethodChannel('com.deep_voice.classic_bluetooth'); |
|
||||
final EventChannel _eventChannel = |
|
||||
const EventChannel('com.deep_voice.classic_bluetooth_events'); |
|
||||
|
|
||||
/// 当前连接的设备 |
|
||||
final currentDevice = Rx<BluetoothDevice?>(null); |
|
||||
|
|
||||
/// 已连接耳机设备列表 |
|
||||
final connectedDevices = <BluetoothDevice>[].obs; |
|
||||
|
|
||||
/// 蓝牙设备状态监听 |
|
||||
StreamSubscription? _bluetoothStateListener; |
|
||||
|
|
||||
/// 蓝牙开关状态 |
|
||||
final bluetoothState = 'unknown'.obs; |
|
||||
|
|
||||
/// 蓝牙是否可用 |
|
||||
bool get isBluetoothOn => bluetoothState.value == 'on'; |
|
||||
|
|
||||
/// 服务是否已初始化 |
|
||||
bool _isInitialized = false; |
|
||||
|
|
||||
/// 初始化蓝牙服务 - 单一入口点 |
|
||||
Future<bool> initialize() async { |
|
||||
if (_isInitialized) { |
|
||||
Logger.info('蓝牙服务已经初始化,跳过'); |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
Logger.info('正在初始化蓝牙服务...'); |
|
||||
|
|
||||
// 获取当前蓝牙状态 |
|
||||
final bool isEnabled = |
|
||||
await _channel.invokeMethod<bool>('isBluetoothEnabled') ?? false; |
|
||||
bluetoothState.value = isEnabled ? 'on' : 'off'; |
|
||||
Logger.info('初始蓝牙状态: ${bluetoothState.value}'); |
|
||||
|
|
||||
await _channel.invokeMethod('initialize'); |
|
||||
|
|
||||
// 设置事件监听 |
|
||||
_setupBluetoothStateListener(); |
|
||||
|
|
||||
_isInitialized = true; |
|
||||
Logger.info('蓝牙服务初始化成功'); |
|
||||
return true; |
|
||||
} catch (e) { |
|
||||
Logger.error('初始化蓝牙服务失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 检查蓝牙权限 |
|
||||
Future<bool> checkPermissions() async { |
|
||||
// 检查蓝牙相关权限 |
|
||||
Map<Permission, PermissionStatus> statuses = await [ |
|
||||
Permission.bluetooth, |
|
||||
Permission.bluetoothConnect, |
|
||||
Permission.bluetoothScan, |
|
||||
Permission.microphone, |
|
||||
].request(); |
|
||||
|
|
||||
return statuses[Permission.bluetoothConnect] == PermissionStatus.granted; |
|
||||
} |
|
||||
|
|
||||
/// 刷新已连接的蓝牙耳机设备 |
|
||||
Future<bool> refreshConnectedDevices() async { |
|
||||
// 确保服务已初始化 |
|
||||
if (!_isInitialized && !(await initialize())) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
// 如果蓝牙未开启,无法获取设备 |
|
||||
if (!isBluetoothOn) { |
|
||||
Logger.info('蓝牙未开启,无法获取设备'); |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 只获取耳机设备 |
|
||||
final devices = await getConnectedHeadsetDevices(); |
|
||||
|
|
||||
// 更新设备列表 |
|
||||
connectedDevices.value = devices; |
|
||||
|
|
||||
// 更新当前设备 |
|
||||
if (connectedDevices.isNotEmpty && currentDevice.value == null) { |
|
||||
currentDevice.value = connectedDevices.first; |
|
||||
} |
|
||||
|
|
||||
return connectedDevices.isNotEmpty; |
|
||||
} catch (e) { |
|
||||
Logger.error('刷新已连接设备失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 获取已连接的耳机设备 |
|
||||
Future<List<BluetoothDevice>> getConnectedHeadsetDevices() async { |
|
||||
try { |
|
||||
// 确保服务已初始化 |
|
||||
if (!_isInitialized && !(await initialize())) { |
|
||||
return []; |
|
||||
} |
|
||||
|
|
||||
final result = await _channel.invokeMethod('getConnectedHeadsetDevices'); |
|
||||
|
|
||||
if (result is List) { |
|
||||
return result |
|
||||
.map((device) => BluetoothDevice( |
|
||||
name: device['name'] ?? '未知设备', |
|
||||
address: device['address'] ?? '', |
|
||||
type: 'headset')) |
|
||||
.toList(); |
|
||||
} |
|
||||
|
|
||||
return []; |
|
||||
} catch (e) { |
|
||||
Logger.error('获取已连接的耳机设备失败: $e'); |
|
||||
return []; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置蓝牙状态监听 |
|
||||
void _setupBluetoothStateListener() { |
|
||||
_bluetoothStateListener?.cancel(); |
|
||||
|
|
||||
_bluetoothStateListener = |
|
||||
_eventChannel.receiveBroadcastStream().listen((event) { |
|
||||
if (event is Map) { |
|
||||
Logger.info('蓝牙状态监听: $event'); |
|
||||
final type = event['type'] as String?; |
|
||||
|
|
||||
if (type == 'bluetoothStateChanged') { |
|
||||
final state = event['state'] as String?; |
|
||||
if (state != null) { |
|
||||
_handleBluetoothStateChange(state); |
|
||||
} |
|
||||
} else if (type == 'deviceConnected') { |
|
||||
_handleDeviceConnected(event); |
|
||||
} else if (type == 'deviceDisconnected') { |
|
||||
_handleDeviceDisconnected(event); |
|
||||
} |
|
||||
} |
|
||||
}, onError: (error) { |
|
||||
Logger.error('蓝牙状态监听错误: $error'); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/// 处理设备连接事件 |
|
||||
void _handleDeviceConnected(Map event) { |
|
||||
final deviceMap = event['device'] as Map?; |
|
||||
if (deviceMap != null) { |
|
||||
final device = BluetoothDevice( |
|
||||
name: deviceMap['name'] ?? '未知设备', |
|
||||
address: deviceMap['address'] ?? '', |
|
||||
type: deviceMap['type'] ?? 'unknown'); |
|
||||
|
|
||||
// 更新设备列表 |
|
||||
if (!connectedDevices.any((d) => d.address == device.address)) { |
|
||||
connectedDevices.add(device); |
|
||||
} |
|
||||
|
|
||||
// 更新当前设备 |
|
||||
currentDevice.value = device; |
|
||||
|
|
||||
Logger.info('设备已连接: ${device.name}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 处理设备断开事件 |
|
||||
void _handleDeviceDisconnected(Map event) { |
|
||||
final deviceMap = event['device'] as Map?; |
|
||||
if (deviceMap != null) { |
|
||||
final address = deviceMap['address'] as String?; |
|
||||
|
|
||||
if (address != null) { |
|
||||
// 更新设备列表 |
|
||||
connectedDevices.removeWhere((d) => d.address == address); |
|
||||
|
|
||||
// 如果是当前设备,清除当前设备 |
|
||||
if (currentDevice.value?.address == address) { |
|
||||
currentDevice.value = |
|
||||
connectedDevices.isEmpty ? null : connectedDevices.first; |
|
||||
Logger.info('当前设备已断开连接'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 处理蓝牙状态变化 |
|
||||
void _handleBluetoothStateChange(String state) { |
|
||||
Logger.info('----蓝牙状态变化: $state'); |
|
||||
|
|
||||
// 更新蓝牙状态 |
|
||||
bluetoothState.value = state; |
|
||||
|
|
||||
if (state == 'off') { |
|
||||
// 蓝牙关闭,清空设备信息 |
|
||||
connectedDevices.clear(); |
|
||||
currentDevice.value = null; |
|
||||
} else if (state == 'on') { |
|
||||
// 蓝牙重新打开,延迟一小段时间后刷新设备 |
|
||||
Future.delayed(const Duration(milliseconds: 1000), () { |
|
||||
refreshConnectedDevices(); |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
// 取消蓝牙状态监听 |
|
||||
_bluetoothStateListener?.cancel(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,97 +0,0 @@ |
|||||
import 'dart:convert'; |
|
||||
|
|
||||
/// 函数服务 |
|
||||
/// 仅实现各种功能函数,不包含AI交互逻辑 |
|
||||
class FunctionService { |
|
||||
/// 天气查询工具定义 |
|
||||
/// 返回一个可直接用于tools参数的天气查询工具定义 |
|
||||
static Map<String, dynamic> getWeatherToolDefinition() { |
|
||||
return { |
|
||||
'type': 'function', |
|
||||
'function': { |
|
||||
'name': 'get_current_weather', |
|
||||
'description': '获取指定地点的当前天气信息', |
|
||||
'parameters': { |
|
||||
'type': 'object', |
|
||||
'properties': { |
|
||||
'location': { |
|
||||
'type': 'string', |
|
||||
'description': '地理位置,例如:北京市', |
|
||||
}, |
|
||||
'unit': { |
|
||||
'type': 'string', |
|
||||
'description': '温度单位', |
|
||||
'enum': ['celsius', 'fahrenheit'], |
|
||||
}, |
|
||||
}, |
|
||||
'required': ['location'], |
|
||||
}, |
|
||||
}, |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
/// 模拟天气API实现 |
|
||||
/// 返回一个模拟的天气信息,实际应用中应该调用真实的天气API |
|
||||
static Future<Map<String, dynamic>> getWeatherInfo(Map<String, dynamic> args) async { |
|
||||
final location = args['location'] ?? '未知地点'; |
|
||||
final unit = args['unit'] ?? 'celsius'; |
|
||||
|
|
||||
// 在实际应用中,这里应该调用真实的天气API |
|
||||
return { |
|
||||
'location': location, |
|
||||
'temperature': unit == 'celsius' ? '25°C' : '77°F', |
|
||||
'conditions': '晴转多云', |
|
||||
'humidity': '55%', |
|
||||
'wind': '西南风 3级', |
|
||||
'date': DateTime.now().toString().split(' ')[0], |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
/// 解析函数调用参数 |
|
||||
/// 从AI响应中提取函数调用参数 |
|
||||
static Map<String, dynamic> parseFunctionCallArguments(Map<String, dynamic> functionCallResponse) { |
|
||||
if (functionCallResponse['tool_calls'] == null || |
|
||||
functionCallResponse['tool_calls'].isEmpty) { |
|
||||
throw FunctionServiceException('No tool calls found in response'); |
|
||||
} |
|
||||
|
|
||||
final toolCall = functionCallResponse['tool_calls'][0]; |
|
||||
if (toolCall['function'] == null || toolCall['function']['arguments'] == null) { |
|
||||
throw FunctionServiceException('Invalid function call format'); |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
return json.decode(toolCall['function']['arguments']); |
|
||||
} catch (e) { |
|
||||
throw FunctionServiceException('Failed to parse function arguments: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 获取函数调用ID和名称 |
|
||||
/// 用于构建工具响应消息 |
|
||||
static Map<String, String> getFunctionCallInfo(Map<String, dynamic> functionCallResponse) { |
|
||||
if (functionCallResponse['tool_calls'] == null || |
|
||||
functionCallResponse['tool_calls'].isEmpty) { |
|
||||
throw FunctionServiceException('No tool calls found in response'); |
|
||||
} |
|
||||
|
|
||||
final toolCall = functionCallResponse['tool_calls'][0]; |
|
||||
if (toolCall['id'] == null || toolCall['function'] == null || toolCall['function']['name'] == null) { |
|
||||
throw FunctionServiceException('Invalid function call format'); |
|
||||
} |
|
||||
|
|
||||
return { |
|
||||
'id': toolCall['id'], |
|
||||
'name': toolCall['function']['name'], |
|
||||
}; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 函数调用服务异常类 |
|
||||
class FunctionServiceException implements Exception { |
|
||||
final String message; |
|
||||
FunctionServiceException(this.message); |
|
||||
|
|
||||
@override |
|
||||
String toString() => message; |
|
||||
} |
|
||||
@ -1,4 +1,4 @@ |
|||||
class NWApi { |
class NWApi { |
||||
// static const baseApi = "https://www.deapsound.com"; |
// static const baseApi = "https://www.deapsound.com"; |
||||
static const baseApi = "https://www.deapsound.com"; |
static const baseApi = "https://api.deapsound.com"; |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,301 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:io'; |
||||
|
import 'dart:convert'; |
||||
|
import 'package:deep_voice/data/models/appconfig.dart'; |
||||
|
import 'package:deep_voice/data/models/appconfig_model.dart'; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
import 'package:open_ai_service/open_ai_service.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'ai_service.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:path_provider/path_provider.dart'; |
||||
|
|
||||
|
/// 定义流事件类型,用于区分不同类型的事件 |
||||
|
enum StreamEventType { token, functionCall, complete, error } |
||||
|
|
||||
|
/// 流事件包装类 |
||||
|
class StreamEvent { |
||||
|
final StreamEventType type; |
||||
|
final String? content; |
||||
|
final Map? meta; |
||||
|
|
||||
|
StreamEvent(this.type, {this.content, this.meta}); |
||||
|
} |
||||
|
|
||||
|
/// OpenAI服务适配器 - 连接AiService接口与OpenAIService插件 |
||||
|
class OpenAIServiceAdapter implements AiService { |
||||
|
final OpenAIService _openAIService = OpenAIService(); |
||||
|
StreamSubscription<OpenAIEvent>? _eventSubscription; |
||||
|
final StreamController<StreamEvent> _tokenStreamController = |
||||
|
StreamController<StreamEvent>.broadcast(); |
||||
|
final GetStorage _storage = GetStorage(); |
||||
|
|
||||
|
/// 构造函数 |
||||
|
OpenAIServiceAdapter() { |
||||
|
printInfo(info: '创建OpenAIServiceAdapter实例'); |
||||
|
_setupEventListener(); |
||||
|
} |
||||
|
|
||||
|
/// 设置事件监听器 |
||||
|
void _setupEventListener() { |
||||
|
try { |
||||
|
// 首先确保访问eventStream以初始化底层事件通道 |
||||
|
_openAIService.eventStream; |
||||
|
|
||||
|
// 设置事件处理 |
||||
|
_eventSubscription = _openAIService.eventStream.listen( |
||||
|
(event) { |
||||
|
try { |
||||
|
switch (event.type) { |
||||
|
case OpenAIEventType.token: |
||||
|
if (event.content is String) { |
||||
|
_tokenStreamController.add(StreamEvent(StreamEventType.token, |
||||
|
content: event.content as String)); |
||||
|
} else { |
||||
|
printInfo(info: '收到非字符串类型的token: ${event.content}'); |
||||
|
} |
||||
|
break; |
||||
|
case OpenAIEventType.complete: |
||||
|
_tokenStreamController |
||||
|
.add(StreamEvent(StreamEventType.complete)); |
||||
|
break; |
||||
|
case OpenAIEventType.error: |
||||
|
_tokenStreamController.add(StreamEvent(StreamEventType.error, |
||||
|
content: '未知错误: ${event.content}')); |
||||
|
break; |
||||
|
case OpenAIEventType.functionCall: |
||||
|
printInfo( |
||||
|
info: |
||||
|
'接收到 functionCall content: ${event.content} meta: ${event.meta}'); |
||||
|
_tokenStreamController.add(StreamEvent( |
||||
|
StreamEventType.functionCall, |
||||
|
meta: event.meta as Map<String, dynamic>?)); |
||||
|
// _tokenStreamController.add(StreamEvent( |
||||
|
// StreamEventType.error, |
||||
|
// content: '收到函数调用,该流仅支持文本响应' |
||||
|
// )); |
||||
|
break; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
printError(info: '处理事件出错: $e'); |
||||
|
_tokenStreamController |
||||
|
.add(StreamEvent(StreamEventType.error, content: '处理事件失败: $e')); |
||||
|
} |
||||
|
}, |
||||
|
onError: (error) { |
||||
|
printError(info: '事件流错误: $error'); |
||||
|
_tokenStreamController.add( |
||||
|
StreamEvent(StreamEventType.error, content: '事件流错误: $error')); |
||||
|
}, |
||||
|
onDone: () { |
||||
|
printInfo(info: '事件流已关闭'); |
||||
|
}, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
printError(info: '设置事件监听器失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 初始化OpenAI服务 |
||||
|
Future<bool> initialize() async { |
||||
|
try { |
||||
|
// 从.env文件中读取配置 |
||||
|
// final apiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; |
||||
|
// final baseUrl = dotenv.env['OPENAI_BASE_URL'] ?? ''; |
||||
|
// final model = dotenv.env['OPENAI_MODEL'] ?? ''; |
||||
|
// final _env = _storage.read("ENV") as Map<String, String>; |
||||
|
final apiKey = AppConfig.env('OPENAI_API_KEY') ?? ''; |
||||
|
final baseUrl = AppConfig.env('OPENAI_BASE_URL') ?? ''; |
||||
|
final model = AppConfig.env('OPENAI_MODEL') ?? ''; |
||||
|
|
||||
|
printInfo(info: '从.env读取OpenAI配置'); |
||||
|
printInfo(info: '基础URL: $baseUrl'); |
||||
|
printInfo(info: '模型名称: $model'); |
||||
|
|
||||
|
if (apiKey.isEmpty) { |
||||
|
printError(info: '错误: OpenAI API密钥未配置,请在.env文件中设置OPENAI_API_KEY'); |
||||
|
return false; |
||||
|
} |
||||
|
final _mcps = _storage.read("MCPS") as Map<String, DBMCPServer>; |
||||
|
// 读取.mcp.json文件 |
||||
|
String mcpConfig = jsonEncode(_mcps); |
||||
|
// try { |
||||
|
// // 从Flutter资源包中加载.mcp.json |
||||
|
// mcpConfig = await rootBundle.loadString('.mcp.json'); |
||||
|
// printInfo(info: '成功从资源包加载.mcp.json配置文件'); |
||||
|
|
||||
|
// // 验证JSON格式 |
||||
|
// final jsonData = jsonDecode(mcpConfig); |
||||
|
// if (jsonData is Map && jsonData.containsKey('mcpServers')) { |
||||
|
// printInfo(info: '解析到有效的mcpServers配置'); |
||||
|
// } else { |
||||
|
// printInfo(info: '.mcp.json内容格式不正确,期望包含mcpServers字段'); |
||||
|
// } |
||||
|
// } catch (e) { |
||||
|
// printError(info: '加载.mcp.json资源文件时出错: $e'); |
||||
|
// } |
||||
|
|
||||
|
// 初始化OpenAI服务 |
||||
|
final result = await _openAIService.initialize( |
||||
|
apiKey: apiKey, |
||||
|
baseUrl: baseUrl, |
||||
|
model: model, |
||||
|
mcpServer: mcpConfig, |
||||
|
); |
||||
|
|
||||
|
if (result) { |
||||
|
printInfo(info: 'OpenAI服务初始化成功'); |
||||
|
} else { |
||||
|
printError(info: 'OpenAI服务初始化失败'); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
printError(info: 'OpenAI服务初始化异常: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 发送消息并获取回复 |
||||
|
@override |
||||
|
Future<String> sendMessage({ |
||||
|
required List<Map<String, String>> messages, |
||||
|
String? systemPrompt, |
||||
|
String? userProperties, |
||||
|
}) async { |
||||
|
try { |
||||
|
// 在方法内直接转换 |
||||
|
final convertedMessages = |
||||
|
messages.map((m) => Map<String, dynamic>.from(m)).toList(); |
||||
|
|
||||
|
// 发送消息并获取回复 |
||||
|
final response = await _openAIService.sendMessage( |
||||
|
messages: convertedMessages, |
||||
|
systemPrompt: systemPrompt, |
||||
|
userProperties: userProperties, |
||||
|
); |
||||
|
|
||||
|
return response; |
||||
|
} catch (e) { |
||||
|
printError(info: 'OpenAI发送消息失败: $e'); |
||||
|
throw '发送消息失败: $e'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 发送消息并获取流式回复 |
||||
|
@override |
||||
|
Stream sendMessageStream({ |
||||
|
required List<Map<String, String>> messages, |
||||
|
String? systemPrompt, //提示词 |
||||
|
String? userProperties, //用户属性 |
||||
|
}) async* { |
||||
|
try { |
||||
|
// 在方法内直接转换 |
||||
|
final convertedMessages = |
||||
|
messages.map((m) => Map<String, dynamic>.from(m)).toList(); |
||||
|
|
||||
|
// 创建用于接收token的控制器 |
||||
|
final localController = StreamController(); |
||||
|
|
||||
|
// 添加从广播流到本地流的订阅 |
||||
|
final subscription = _tokenStreamController.stream.listen((streamEvent) { |
||||
|
switch (streamEvent.type) { |
||||
|
case StreamEventType.token: |
||||
|
if (streamEvent.content != null && !localController.isClosed) { |
||||
|
localController.add(streamEvent.content!); |
||||
|
} |
||||
|
break; |
||||
|
case StreamEventType.functionCall: |
||||
|
if (streamEvent.meta != null && !localController.isClosed) { |
||||
|
localController.add(streamEvent.meta!); |
||||
|
} |
||||
|
break; |
||||
|
case StreamEventType.complete: |
||||
|
if (!localController.isClosed) { |
||||
|
localController.close(); |
||||
|
} |
||||
|
break; |
||||
|
case StreamEventType.error: |
||||
|
if (!localController.isClosed) { |
||||
|
printError(info: '令牌流错误: ${streamEvent.content}'); |
||||
|
localController.addError(streamEvent.content ?? '未知错误'); |
||||
|
localController.close(); |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// 当本地控制器关闭时,取消订阅 |
||||
|
localController.onCancel = () { |
||||
|
subscription.cancel(); |
||||
|
}; |
||||
|
|
||||
|
// 启动流式消息请求 |
||||
|
bool started = false; |
||||
|
try { |
||||
|
started = await _openAIService.sendMessageStream( |
||||
|
messages: convertedMessages, |
||||
|
systemPrompt: systemPrompt, |
||||
|
userProperties: userProperties, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
printError(info: '启动消息流失败: $e'); |
||||
|
if (!localController.isClosed) { |
||||
|
localController.addError('启动消息流失败: $e'); |
||||
|
localController.close(); |
||||
|
} |
||||
|
throw '启动消息流失败: $e'; |
||||
|
} |
||||
|
|
||||
|
if (!started) { |
||||
|
printError(info: '无法启动消息流'); |
||||
|
if (!localController.isClosed) { |
||||
|
localController.addError('无法启动消息流'); |
||||
|
localController.close(); |
||||
|
} |
||||
|
throw '无法启动消息流'; |
||||
|
} |
||||
|
|
||||
|
// 通过yield*将controller的流转发 |
||||
|
yield* localController.stream; |
||||
|
} catch (e) { |
||||
|
printError(info: 'OpenAI流式消息处理失败: $e'); |
||||
|
throw '流式消息处理失败: $e'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 注册函数 |
||||
|
Future<bool> registerFunction( |
||||
|
String name, String description, Map<String, dynamic> parameters) async { |
||||
|
try { |
||||
|
final result = await _openAIService.registerFunction( |
||||
|
name: name, |
||||
|
description: description, |
||||
|
parameters: parameters, |
||||
|
); |
||||
|
|
||||
|
if (result) { |
||||
|
printInfo(info: '函数 "$name" 注册成功'); |
||||
|
} else { |
||||
|
printError(info: '函数 "$name" 注册失败'); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
printError(info: '注册函数失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 释放资源 |
||||
|
void dispose() { |
||||
|
try { |
||||
|
_eventSubscription?.cancel(); |
||||
|
_tokenStreamController.close(); |
||||
|
printInfo(info: 'OpenAIServiceAdapter资源已释放'); |
||||
|
} catch (e) { |
||||
|
printError(info: '释放资源时出错: $e'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,347 @@ |
|||||
|
import 'dart:async'; |
||||
|
|
||||
|
import 'package:flutter/foundation.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
|
||||
|
import '../asr_service.dart'; |
||||
|
|
||||
|
/// 火山语音识别服务实现 |
||||
|
class VolcanoAsrService extends GetxService implements AsrService { |
||||
|
/// 方法通道 |
||||
|
static const MethodChannel _channel = MethodChannel('volcano_speech/asr'); |
||||
|
|
||||
|
/// 事件通道 |
||||
|
static const EventChannel _eventChannel = |
||||
|
EventChannel('volcano_speech/asr_events'); |
||||
|
|
||||
|
/// 事件流控制器 |
||||
|
final StreamController<RecognitionEvent> _eventStreamController = |
||||
|
StreamController<RecognitionEvent>.broadcast(); |
||||
|
|
||||
|
/// 事件订阅 |
||||
|
StreamSubscription? _eventSubscription; |
||||
|
|
||||
|
/// 是否初始化完成 |
||||
|
bool _isInitialized = false; |
||||
|
|
||||
|
/// 是否正在连续识别 |
||||
|
bool _isContinuousRecognitionActive = false; |
||||
|
|
||||
|
/// 当前语言 |
||||
|
String _currentLanguage = 'zh-CN'; |
||||
|
|
||||
|
/// 支持的语言列表 |
||||
|
final List<String> _supportedLanguages = ['zh-CN', 'en-US']; |
||||
|
|
||||
|
@override |
||||
|
List<String> get supportedLanguages => _supportedLanguages; |
||||
|
|
||||
|
/// 构造函数 |
||||
|
VolcanoAsrService() { |
||||
|
_setupEventListener(); |
||||
|
} |
||||
|
|
||||
|
/// 初始化语音识别服务 |
||||
|
@override |
||||
|
Future<bool> initialize({ |
||||
|
List<String>? supportedLanguages, |
||||
|
}) async { |
||||
|
if (_isInitialized) return true; |
||||
|
|
||||
|
try { |
||||
|
// 如果提供了支持的语言列表,则替换默认列表 |
||||
|
if (supportedLanguages != null && supportedLanguages.isNotEmpty) { |
||||
|
_supportedLanguages.clear(); |
||||
|
_supportedLanguages.addAll(supportedLanguages); |
||||
|
} |
||||
|
|
||||
|
// 从.env文件读取配置 |
||||
|
final appId = dotenv.env['VOLCANO_SPEECH_APP_ID'] ?? ''; |
||||
|
final appToken = dotenv.env['VOLCANO_SPEECH_APP_TOKEN'] ?? ''; |
||||
|
final resourceId = |
||||
|
dotenv.env['VOLCANO_ASR_RESOURCE_ID'] ?? 'volc.bigasr.sauc.duration'; |
||||
|
|
||||
|
if (appId.isEmpty || appToken.isEmpty) { |
||||
|
debugPrint( |
||||
|
'火山语音初始化失败:缺少VOLCANO_SPEECH_APP_ID或VOLCANO_SPEECH_APP_TOKEN环境变量'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 初始化火山语音ASR |
||||
|
final initResult = await _channel.invokeMethod<bool>('initialize', { |
||||
|
'appId': appId, |
||||
|
'appToken': appToken, |
||||
|
'resourceId': resourceId, |
||||
|
}) ?? |
||||
|
false; |
||||
|
|
||||
|
if (!initResult) { |
||||
|
debugPrint('火山语音ASR初始化失败'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 设置默认语言、VAD参数和启用音量返回 |
||||
|
await _configureAsr(); |
||||
|
|
||||
|
_isInitialized = true; |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
debugPrint('火山语音ASR初始化异常:$e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 配置ASR参数 |
||||
|
Future<void> _configureAsr() async { |
||||
|
// 设置默认语言 |
||||
|
await _channel.invokeMethod<bool>('setLanguage', { |
||||
|
'language': _currentLanguage, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 设置事件监听 |
||||
|
void _setupEventListener() { |
||||
|
_eventSubscription = _eventChannel.receiveBroadcastStream().listen( |
||||
|
(dynamic event) { |
||||
|
if (event is! Map) return; |
||||
|
_handleAsrEvent(Map<String, dynamic>.from(event as Map)); |
||||
|
}, |
||||
|
onError: (Object error) { |
||||
|
debugPrint('ASR事件流错误: $error'); |
||||
|
_eventStreamController.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.error, |
||||
|
error: '事件流错误: $error', |
||||
|
)); |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 处理ASR事件 |
||||
|
void _handleAsrEvent(Map<String, dynamic> event) { |
||||
|
final eventType = event['type'] as String?; |
||||
|
|
||||
|
switch (eventType) { |
||||
|
case 'sessionStarted': |
||||
|
_eventStreamController.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.sessionStarted, |
||||
|
)); |
||||
|
break; |
||||
|
|
||||
|
case 'sessionStopped': |
||||
|
_eventStreamController.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.sessionStopped, |
||||
|
)); |
||||
|
_isContinuousRecognitionActive = false; |
||||
|
break; |
||||
|
|
||||
|
case 'recognizing': |
||||
|
final text = event['text'] as String?; |
||||
|
final language = event['language'] as String?; |
||||
|
|
||||
|
if (text != null) { |
||||
|
debugPrint('接收到中间识别结果: $text'); |
||||
|
_eventStreamController.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.intermediateResult, |
||||
|
text: text, |
||||
|
detectedLanguage: language ?? _currentLanguage, |
||||
|
)); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case 'result': |
||||
|
final text = event['text'] as String?; |
||||
|
final language = event['language'] as String?; |
||||
|
|
||||
|
if (text != null) { |
||||
|
debugPrint('接收到最终识别结果: $text'); |
||||
|
_eventStreamController.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.finalResult, |
||||
|
text: text, |
||||
|
detectedLanguage: language ?? _currentLanguage, |
||||
|
)); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case 'error': |
||||
|
final errorMessage = event['message'] as String?; |
||||
|
|
||||
|
debugPrint('接收到识别错误: ${errorMessage ?? "未知错误"}'); |
||||
|
_eventStreamController.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.error, |
||||
|
error: errorMessage ?? '未知错误', |
||||
|
)); |
||||
|
_isContinuousRecognitionActive = false; |
||||
|
break; |
||||
|
|
||||
|
case 'volumeChanged': |
||||
|
// 音量变化事件仅在日志中输出,不发送到事件流 |
||||
|
final volume = event['volume'] as int?; |
||||
|
if (volume != null && volume > 5) { |
||||
|
// 仅记录有意义的音量变化 |
||||
|
debugPrint('音量变化: $volume'); |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查初始化状态 |
||||
|
Future<bool> _ensureInitialized() async { |
||||
|
if (!_isInitialized) { |
||||
|
return await initialize(); |
||||
|
} |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
/// 执行一次性语音识别 |
||||
|
@override |
||||
|
Future<RecognitionEvent> recognizeOnce() async { |
||||
|
if (!await _ensureInitialized()) { |
||||
|
return RecognitionEvent( |
||||
|
type: RecognitionEventType.error, |
||||
|
error: '语音识别服务未初始化', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// 创建一个Completer用于等待最终结果 |
||||
|
final completer = Completer<RecognitionEvent>(); |
||||
|
|
||||
|
// 创建一次性事件监听 |
||||
|
StreamSubscription? subscription; |
||||
|
subscription = _eventStreamController.stream.listen((event) { |
||||
|
// 仅处理finalResult和error事件 |
||||
|
if (event.type == RecognitionEventType.finalResult) { |
||||
|
// 识别成功,返回结果 |
||||
|
completer.complete(event); |
||||
|
subscription?.cancel(); |
||||
|
} else if (event.type == RecognitionEventType.error) { |
||||
|
// 识别失败,返回错误 |
||||
|
completer.complete(event); |
||||
|
subscription?.cancel(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// 启动一次性识别 |
||||
|
final started = |
||||
|
await _channel.invokeMethod<bool>('recognizeOnce') ?? false; |
||||
|
|
||||
|
if (!started) { |
||||
|
subscription.cancel(); |
||||
|
return RecognitionEvent( |
||||
|
type: RecognitionEventType.error, |
||||
|
error: '启动一次性识别失败', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 设置超时,防止永久等待 |
||||
|
Future.delayed(const Duration(seconds: 30), () { |
||||
|
if (!completer.isCompleted) { |
||||
|
completer.complete(RecognitionEvent( |
||||
|
type: RecognitionEventType.error, |
||||
|
error: '识别超时', |
||||
|
)); |
||||
|
subscription?.cancel(); |
||||
|
// 停止识别 |
||||
|
_channel.invokeMethod('stopRecognize'); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// 等待结果 |
||||
|
return await completer.future; |
||||
|
} catch (e) { |
||||
|
// 确保停止识别 |
||||
|
try { |
||||
|
await _channel.invokeMethod('stopRecognize'); |
||||
|
} catch (_) {} |
||||
|
|
||||
|
return RecognitionEvent( |
||||
|
type: RecognitionEventType.error, |
||||
|
error: e.toString(), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 开始连续语音识别 |
||||
|
@override |
||||
|
Future<Stream<RecognitionEvent>> startContinuousRecognition() async { |
||||
|
if (!await _ensureInitialized()) { |
||||
|
_eventStreamController.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.error, |
||||
|
error: '语音识别服务未初始化', |
||||
|
)); |
||||
|
return _eventStreamController.stream; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
final success = |
||||
|
await _channel.invokeMethod<bool>('startContinuousRecognition') ?? |
||||
|
false; |
||||
|
|
||||
|
if (success) { |
||||
|
_isContinuousRecognitionActive = true; |
||||
|
} else { |
||||
|
_eventStreamController.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.error, |
||||
|
error: '启动连续识别失败', |
||||
|
)); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
_eventStreamController.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.error, |
||||
|
error: '启动连续识别异常:$e', |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
return _eventStreamController.stream; |
||||
|
} |
||||
|
|
||||
|
/// 停止连续语音识别 |
||||
|
@override |
||||
|
Future<bool> stopContinuousRecognition() async { |
||||
|
if (!_isInitialized || !_isContinuousRecognitionActive) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
final success = |
||||
|
await _channel.invokeMethod<bool>('stopContinuousRecognition') ?? |
||||
|
false; |
||||
|
|
||||
|
if (success) { |
||||
|
_isContinuousRecognitionActive = false; |
||||
|
} |
||||
|
return success; |
||||
|
} catch (e) { |
||||
|
debugPrint('停止连续识别异常:$e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查连续识别是否活跃 |
||||
|
@override |
||||
|
bool isContinuousRecognitionActive() { |
||||
|
return _isContinuousRecognitionActive; |
||||
|
} |
||||
|
|
||||
|
/// 释放资源 |
||||
|
@override |
||||
|
Future<void> dispose() async { |
||||
|
if (_isContinuousRecognitionActive) { |
||||
|
await stopContinuousRecognition(); |
||||
|
} |
||||
|
|
||||
|
await _eventSubscription?.cancel(); |
||||
|
_eventSubscription = null; |
||||
|
|
||||
|
if (!_eventStreamController.isClosed) { |
||||
|
await _eventStreamController.close(); |
||||
|
} |
||||
|
|
||||
|
if (_isInitialized) { |
||||
|
await _channel.invokeMethod<bool>('release'); |
||||
|
_isInitialized = false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,285 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:flutter/foundation.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../tts_service.dart'; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
|
||||
|
/// 火山语音合成服务实现 |
||||
|
class VolcanoTtsService extends GetxService implements TtsService { |
||||
|
/// 方法通道 |
||||
|
static const MethodChannel _channel = MethodChannel('volcano_speech/tts'); |
||||
|
|
||||
|
/// 事件通道 |
||||
|
static const EventChannel _eventChannel = EventChannel('volcano_speech/tts_events'); |
||||
|
|
||||
|
/// 音频数据通道 |
||||
|
static const EventChannel _audioDataChannel = EventChannel('volcano_speech/tts_audio_data'); |
||||
|
|
||||
|
/// 事件流控制器 |
||||
|
final StreamController<TtsEvent> _eventStreamController = |
||||
|
StreamController<TtsEvent>.broadcast(); |
||||
|
|
||||
|
/// 音频数据流控制器 |
||||
|
final StreamController<Uint8List> _audioDataStreamController = |
||||
|
StreamController<Uint8List>.broadcast(); |
||||
|
|
||||
|
/// 事件处理器 |
||||
|
StreamSubscription? _eventSubscription; |
||||
|
|
||||
|
/// 音频数据处理器 |
||||
|
StreamSubscription? _audioDataSubscription; |
||||
|
|
||||
|
/// 是否初始化完成 |
||||
|
bool _isInitialized = false; |
||||
|
|
||||
|
/// 当前语音名称 |
||||
|
String _currentVoice = "zh_female_qingxin"; |
||||
|
|
||||
|
/// 是否启用音频回调 |
||||
|
bool _isAudioCallbackEnabled = false; |
||||
|
|
||||
|
@override |
||||
|
String get currentVoice => _currentVoice; |
||||
|
|
||||
|
@override |
||||
|
bool get isSpeaking => _isSpeaking; |
||||
|
|
||||
|
/// 是否正在播放 |
||||
|
bool _isSpeaking = false; |
||||
|
|
||||
|
@override |
||||
|
Stream<TtsEvent> get onEvent => _eventStreamController.stream; |
||||
|
|
||||
|
/// 音频数据流 |
||||
|
Stream<Uint8List> get onAudioData => _audioDataStreamController.stream; |
||||
|
|
||||
|
/// 构造函数 |
||||
|
VolcanoTtsService() { |
||||
|
_setupEventListeners(); |
||||
|
_setupAudioDataListeners(); |
||||
|
} |
||||
|
|
||||
|
/// 设置事件监听 |
||||
|
void _setupEventListeners() { |
||||
|
_eventSubscription = _eventChannel.receiveBroadcastStream().listen( |
||||
|
(dynamic event) { |
||||
|
if (event is! Map) return; |
||||
|
|
||||
|
final Map<dynamic, dynamic> eventMap = event; |
||||
|
final String? type = eventMap['type'] as String?; |
||||
|
|
||||
|
switch (type) { |
||||
|
case 'started': |
||||
|
_isSpeaking = true; |
||||
|
_eventStreamController.add(TtsEvent(type: TtsEventType.started)); |
||||
|
break; |
||||
|
|
||||
|
case 'completed': |
||||
|
_isSpeaking = false; |
||||
|
_eventStreamController.add(TtsEvent(type: TtsEventType.completed)); |
||||
|
break; |
||||
|
|
||||
|
case 'canceled': |
||||
|
_isSpeaking = false; |
||||
|
_eventStreamController.add(TtsEvent(type: TtsEventType.canceled)); |
||||
|
break; |
||||
|
|
||||
|
case 'error': |
||||
|
_isSpeaking = false; |
||||
|
final errorCode = eventMap['errorCode'] as int? ?? 0; |
||||
|
final errorMessage = eventMap['errorMessage'] as String? ?? '未知错误'; |
||||
|
_eventStreamController.add(TtsEvent( |
||||
|
type: TtsEventType.error, |
||||
|
error: errorMessage, |
||||
|
)); |
||||
|
break; |
||||
|
} |
||||
|
}, |
||||
|
onError: (Object error) { |
||||
|
debugPrint('TTS事件流错误: $error'); |
||||
|
_eventStreamController.add(TtsEvent( |
||||
|
type: TtsEventType.error, |
||||
|
error: '事件流错误: $error', |
||||
|
)); |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 设置音频数据监听 |
||||
|
void _setupAudioDataListeners() { |
||||
|
_audioDataSubscription = _audioDataChannel.receiveBroadcastStream().listen( |
||||
|
(dynamic data) { |
||||
|
if (data is Uint8List) { |
||||
|
_audioDataStreamController.add(data); |
||||
|
} |
||||
|
}, |
||||
|
onError: (Object error) { |
||||
|
debugPrint('TTS音频数据流错误: $error'); |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> initialize({List<String>? supportedLanguages}) async { |
||||
|
if (_isInitialized) return true; |
||||
|
|
||||
|
try { |
||||
|
// 从.env文件读取配置 |
||||
|
final appId = dotenv.env['VOLCANO_SPEECH_APP_ID'] ?? ''; |
||||
|
final appToken = dotenv.env['VOLCANO_SPEECH_APP_TOKEN'] ?? ''; |
||||
|
|
||||
|
if (appId.isEmpty || appToken.isEmpty) { |
||||
|
debugPrint('火山语音初始化失败:缺少VOLCANO_SPEECH_APP_ID或VOLCANO_SPEECH_APP_TOKEN环境变量'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 初始化火山语音TTS |
||||
|
final result = await _channel.invokeMethod<bool>('initialize', { |
||||
|
'appId': appId, |
||||
|
'appToken': appToken, |
||||
|
}) ?? false; |
||||
|
|
||||
|
if (!result) { |
||||
|
debugPrint('火山语音TTS初始化失败'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
_isInitialized = true; |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
debugPrint('火山语音TTS初始化异常:$e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查初始化状态 |
||||
|
Future<bool> _ensureInitialized() async { |
||||
|
if (!_isInitialized) { |
||||
|
return await initialize(); |
||||
|
} |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> setVoice(String voiceName) async { |
||||
|
if (!await _ensureInitialized()) return false; |
||||
|
|
||||
|
try { |
||||
|
final result = await _channel.invokeMethod<bool>('setVoice', { |
||||
|
'voice': voiceName, |
||||
|
}) ?? false; |
||||
|
|
||||
|
if (result) { |
||||
|
_currentVoice = voiceName; |
||||
|
} |
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
debugPrint('设置语音失败:$e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置是否启用音频数据回调 |
||||
|
Future<bool> setEnableAudioCallback(bool enable) async { |
||||
|
if (!await _ensureInitialized()) return false; |
||||
|
|
||||
|
try { |
||||
|
final result = await _channel.invokeMethod<bool>('enableAudioCallback', { |
||||
|
'enable': enable, |
||||
|
}) ?? false; |
||||
|
|
||||
|
if (result) { |
||||
|
_isAudioCallbackEnabled = enable; |
||||
|
} |
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
debugPrint('设置音频回调失败:$e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> speakOnce(String text) async { |
||||
|
if (!await _ensureInitialized()) return false; |
||||
|
|
||||
|
try { |
||||
|
// 单次播放 |
||||
|
final result = await _channel.invokeMethod<bool>('speakOnce', { |
||||
|
'text': text, |
||||
|
}) ?? false; |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
debugPrint('单次播放失败:$e'); |
||||
|
_eventStreamController.add(TtsEvent.error('单次播放失败:$e')); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> speakStream(String text) async { |
||||
|
if (!await _ensureInitialized()) return false; |
||||
|
|
||||
|
try { |
||||
|
// 流式添加文本 |
||||
|
final result = await _channel.invokeMethod<bool>('speakStream', { |
||||
|
'text': text, |
||||
|
}) ?? false; |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
debugPrint('流式播放失败:$e'); |
||||
|
_eventStreamController.add(TtsEvent.error('流式播放失败:$e')); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> flushStream() async { |
||||
|
if (!await _ensureInitialized()) return false; |
||||
|
|
||||
|
try { |
||||
|
// 刷新流式文本 |
||||
|
return await _channel.invokeMethod<bool>('flushStream') ?? false; |
||||
|
} catch (e) { |
||||
|
debugPrint('刷新流式文本失败:$e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> stop() async { |
||||
|
if (!_isInitialized) return false; |
||||
|
|
||||
|
try { |
||||
|
return await _channel.invokeMethod<bool>('stop') ?? false; |
||||
|
} catch (e) { |
||||
|
debugPrint('停止播放失败:$e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<void> dispose() async { |
||||
|
if (_isSpeaking) { |
||||
|
await stop(); |
||||
|
} |
||||
|
|
||||
|
await _eventSubscription?.cancel(); |
||||
|
await _audioDataSubscription?.cancel(); |
||||
|
|
||||
|
if (!_eventStreamController.isClosed) { |
||||
|
await _eventStreamController.close(); |
||||
|
} |
||||
|
|
||||
|
if (!_audioDataStreamController.isClosed) { |
||||
|
await _audioDataStreamController.close(); |
||||
|
} |
||||
|
|
||||
|
if (_isInitialized) { |
||||
|
await _channel.invokeMethod<bool>('release'); |
||||
|
_isInitialized = false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,209 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:io'; |
||||
|
import 'dart:convert'; |
||||
|
import 'package:deep_voice/data/models/appconfig.dart'; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
import 'package:get_storage/get_storage.dart'; |
||||
|
import 'package:spotify_sdk/spotify_sdk.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'ai_service.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:path_provider/path_provider.dart'; |
||||
|
|
||||
|
/// OpenAI服务适配器 - 连接AiService接口与OpenAIService插件 |
||||
|
class SpotifService { |
||||
|
bool isInitialized = false; |
||||
|
// final GetStorage _storage = GetStorage(); |
||||
|
|
||||
|
late String SPOTIFY_CLIENT_ID; |
||||
|
late String SPOTIFY_REDIRECT_URL; |
||||
|
|
||||
|
/// 初始化OpenAI服务 |
||||
|
Future<bool> initialize() async { |
||||
|
if (!isInitialized) { |
||||
|
// final _env = _storage.read("ENV") as Map<String, String>; |
||||
|
SPOTIFY_CLIENT_ID = AppConfig.env('SPOTIFY_CLIENT_ID') ?? ''; |
||||
|
SPOTIFY_REDIRECT_URL = AppConfig.env('SPOTIFY_REDIRECT_URL') ?? ''; |
||||
|
printInfo( |
||||
|
info: |
||||
|
"SpotifService----------- initialize SPOTIFY_CLIENT_ID:${SPOTIFY_CLIENT_ID} SPOTIFY_REDIRECT_URL:${SPOTIFY_REDIRECT_URL}"); |
||||
|
|
||||
|
await getAccessToken(); |
||||
|
await connectToSpotifyRemote(); |
||||
|
isInitialized = true; |
||||
|
} |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
/// 获取Spotify SDK的版本信息 |
||||
|
Future<void> connectToSpotifyRemote() async { |
||||
|
try { |
||||
|
var result = await SpotifySdk.connectToSpotifyRemote( |
||||
|
clientId: SPOTIFY_CLIENT_ID, |
||||
|
redirectUrl: SPOTIFY_REDIRECT_URL, |
||||
|
scope: |
||||
|
'user-read-private user-read-email streaming app-remote-control user-modify-playback-state', |
||||
|
); |
||||
|
printInfo( |
||||
|
info: "SpotifService----------- connectToSpotifyRemote $result"); |
||||
|
} on PlatformException catch (e) { |
||||
|
printError(info: "SpotifService------------ PlatformException$e"); |
||||
|
} on MissingPluginException { |
||||
|
printError(info: "SpotifService------------ MissingPluginException"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 获取Spotify的访问令牌 |
||||
|
Future<String> getAccessToken() async { |
||||
|
try { |
||||
|
var authenticationToken = await SpotifySdk.getAccessToken( |
||||
|
clientId: SPOTIFY_CLIENT_ID, |
||||
|
redirectUrl: SPOTIFY_REDIRECT_URL, |
||||
|
scope: 'app-remote-control, ' |
||||
|
'user-modify-playback-state, ' |
||||
|
'playlist-read-private, ' |
||||
|
'playlist-modify-public,user-read-currently-playing', |
||||
|
); |
||||
|
printInfo( |
||||
|
info: "SpotifService----------- getAccessToken $authenticationToken"); |
||||
|
return authenticationToken; |
||||
|
} on PlatformException catch (e) { |
||||
|
printError( |
||||
|
info: |
||||
|
"SpotifService------------getAccessToken ${e.code}: ${e.message}"); |
||||
|
return Future.error('$e.code: $e.message'); |
||||
|
} on MissingPluginException { |
||||
|
printError( |
||||
|
info: "SpotifService------------getAccessToken not implemented"); |
||||
|
return Future.error('not implemented'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
//获取当前播放状态 |
||||
|
Future<bool> isPaused() async { |
||||
|
try { |
||||
|
var state = await SpotifySdk.getPlayerState(); |
||||
|
if (state != null) { |
||||
|
printInfo(info: "SpotifService----------- isPaused $state"); |
||||
|
return state.isPaused; |
||||
|
} else { |
||||
|
printError(info: "SpotifService----------- isPaused null"); |
||||
|
return true; |
||||
|
} |
||||
|
} on PlatformException catch (e) { |
||||
|
printError( |
||||
|
info: |
||||
|
"SpotifService------------getPlayerState ${e.code}: ${e.message}"); |
||||
|
} on MissingPluginException { |
||||
|
printError( |
||||
|
info: "SpotifService------------getPlayerState not implemented"); |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
//播放 音乐 |
||||
|
Future<void> play(String url) async { |
||||
|
try { |
||||
|
await SpotifySdk.play(spotifyUri: url); |
||||
|
printInfo(info: "SpotifService----------- play $url"); |
||||
|
} on PlatformException catch (e) { |
||||
|
printError(info: "SpotifService------------play ${e.code}: ${e.message}"); |
||||
|
} on MissingPluginException { |
||||
|
printError( |
||||
|
info: "SpotifService------------getAccessToken not implemented"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
//暂停 音乐 |
||||
|
Future<void> pause() async { |
||||
|
try { |
||||
|
await SpotifySdk.pause(); |
||||
|
printInfo(info: "SpotifService----------- pause"); |
||||
|
} on PlatformException catch (e) { |
||||
|
printError( |
||||
|
info: "SpotifService------------pause ${e.code}: ${e.message}"); |
||||
|
} on MissingPluginException { |
||||
|
printError( |
||||
|
info: "SpotifService------------getAccessToken not implemented"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
//停止 音乐 |
||||
|
Future<void> resume() async { |
||||
|
try { |
||||
|
await SpotifySdk.resume(); |
||||
|
printInfo(info: "SpotifService----------- resume"); |
||||
|
} on PlatformException catch (e) { |
||||
|
printError( |
||||
|
info: "SpotifService------------pause ${e.code}: ${e.message}"); |
||||
|
} on MissingPluginException { |
||||
|
printError( |
||||
|
info: "SpotifService------------getAccessToken not implemented"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
//下一首 音乐 |
||||
|
Future<void> skipNext() async { |
||||
|
try { |
||||
|
await SpotifySdk.skipNext(); |
||||
|
} on PlatformException catch (e) { |
||||
|
printError( |
||||
|
info: "SpotifService------------pause ${e.code}: ${e.message}"); |
||||
|
} on MissingPluginException { |
||||
|
printError( |
||||
|
info: "SpotifService------------getAccessToken not implemented"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
//跳过上一首 |
||||
|
Future<void> skipPrevious() async { |
||||
|
try { |
||||
|
await SpotifySdk.skipPrevious(); |
||||
|
} on PlatformException catch (e) { |
||||
|
printError( |
||||
|
info: "SpotifService------------pause ${e.code}: ${e.message}"); |
||||
|
} on MissingPluginException { |
||||
|
printError( |
||||
|
info: "SpotifService------------getAccessToken not implemented"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
//获取当前播放状态 |
||||
|
Future<void> seekTo() async { |
||||
|
try { |
||||
|
await SpotifySdk.seekTo(positionedMilliseconds: 20000); |
||||
|
} on PlatformException catch (e) { |
||||
|
printError( |
||||
|
info: "SpotifService------------pause ${e.code}: ${e.message}"); |
||||
|
} on MissingPluginException { |
||||
|
printError( |
||||
|
info: "SpotifService------------getAccessToken not implemented"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
//获取当前播放状态 |
||||
|
Future<void> seekToRelative() async { |
||||
|
try { |
||||
|
await SpotifySdk.seekToRelativePosition(relativeMilliseconds: 20000); |
||||
|
} on PlatformException catch (e) { |
||||
|
printError( |
||||
|
info: "SpotifService------------pause ${e.code}: ${e.message}"); |
||||
|
} on MissingPluginException { |
||||
|
printError( |
||||
|
info: "SpotifService------------getAccessToken not implemented"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
//获取当前播放状态 |
||||
|
Future<void> switchToLocalDevice() async { |
||||
|
try { |
||||
|
await SpotifySdk.switchToLocalDevice(); |
||||
|
} on PlatformException catch (e) { |
||||
|
printError( |
||||
|
info: "SpotifService------------pause ${e.code}: ${e.message}"); |
||||
|
} on MissingPluginException { |
||||
|
printError( |
||||
|
info: "SpotifService------------getAccessToken not implemented"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,275 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
import '../models/events/voice_interaction_event.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
import '../../modules/chat/models/message_model.dart'; |
|
||||
import 'chat_history_service.dart'; |
|
||||
|
|
||||
/// 语音交互服务接口 |
|
||||
/// |
|
||||
/// 管理与平台原生语音交互服务的通信,提供统一的接口供应用使用 |
|
||||
class VoiceInteractionService extends GetxService { |
|
||||
static VoiceInteractionService get to => Get.find<VoiceInteractionService>(); |
|
||||
|
|
||||
// 方法通道 |
|
||||
static const MethodChannel _channel = MethodChannel('com.deep_voice.voice_interaction'); |
|
||||
|
|
||||
// 事件通道 |
|
||||
static const EventChannel _eventChannel = EventChannel('com.deep_voice.voice_interaction_events'); |
|
||||
|
|
||||
// 服务状态 |
|
||||
final _isServiceRunning = false.obs; |
|
||||
bool get isServiceRunning => _isServiceRunning.value; |
|
||||
|
|
||||
// 流控制器 |
|
||||
final _eventStreamController = StreamController<VoiceInteractionEvent>.broadcast(); |
|
||||
|
|
||||
// 事件流 |
|
||||
Stream<VoiceInteractionEvent> get eventStream => _eventStreamController.stream; |
|
||||
|
|
||||
// 事件通道订阅 |
|
||||
StreamSubscription? _eventSubscription; |
|
||||
|
|
||||
// 标记是否初始化 |
|
||||
bool _isInitialized = false; |
|
||||
|
|
||||
// 配置信息 |
|
||||
late String _azureSpeechKey; |
|
||||
late String _azureSpeechRegion; |
|
||||
late String _volcanoAiKey; |
|
||||
|
|
||||
// 聊天历史服务 |
|
||||
late final ChatHistoryService _chatHistoryService; |
|
||||
|
|
||||
|
|
||||
/// 设置事件通道 |
|
||||
void _setupEventChannel() { |
|
||||
_eventSubscription = _eventChannel |
|
||||
.receiveBroadcastStream() |
|
||||
.listen(_handleVoiceInteractionEvent, onError: (error) { |
|
||||
Logger.error('语音交互事件通道错误: $error'); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/// 从环境变量加载配置 |
|
||||
void _loadConfig() { |
|
||||
_azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
|
||||
_azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; |
|
||||
_volcanoAiKey = dotenv.env['VOLCANO_AI_API_KEY'] ?? ''; |
|
||||
|
|
||||
if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { |
|
||||
Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); |
|
||||
} |
|
||||
|
|
||||
if (_volcanoAiKey.isEmpty) { |
|
||||
Logger.warning('未找到火山 AI API 密钥。请在 .env 文件中设置 VOLCANO_AI_API_KEY'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 处理来自原生层的事件 |
|
||||
void _handleVoiceInteractionEvent(dynamic event) { |
|
||||
if (event is! Map) return; |
|
||||
|
|
||||
final eventMap = event as Map<dynamic, dynamic>; |
|
||||
final String eventType = eventMap['type'] as String? ?? ''; |
|
||||
// final int timestamp = eventMap['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch; |
|
||||
|
|
||||
Logger.info('收到语音交互事件: $eventType'); |
|
||||
|
|
||||
switch (eventType) { |
|
||||
case 'recognition_started': |
|
||||
// 语音识别开始事件 |
|
||||
final recognitionEvent = RecognitionStartedEvent( |
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch, |
|
||||
); |
|
||||
_eventStreamController.add(recognitionEvent); |
|
||||
break; |
|
||||
|
|
||||
case 'chat_history_updated': |
|
||||
// 聊天历史更新事件 |
|
||||
final String agentId = eventMap['agentId'] as String? ?? ''; |
|
||||
final String userMessage = eventMap['userMessage'] as String? ?? ''; |
|
||||
final String assistantMessage = eventMap['assistantMessage'] as String? ?? ''; |
|
||||
|
|
||||
final chatHistoryEvent = ChatHistoryEvent( |
|
||||
agentId: agentId, |
|
||||
userMessage: userMessage, |
|
||||
assistantMessage: assistantMessage, |
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch, |
|
||||
); |
|
||||
_eventStreamController.add(chatHistoryEvent); |
|
||||
|
|
||||
// 保存聊天历史到ChatHistoryService |
|
||||
_saveChatHistory(agentId, userMessage, assistantMessage, DateTime.now().millisecondsSinceEpoch); |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 保存聊天历史 |
|
||||
void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { |
|
||||
try { |
|
||||
// 检查参数有效性 |
|
||||
if (userMessage.isEmpty) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 创建用户消息和助手消息 |
|
||||
final userMsg = Message( |
|
||||
role: 'user', |
|
||||
content: userMessage, |
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), |
|
||||
); |
|
||||
|
|
||||
final assistantMsg = Message( |
|
||||
role: 'assistant', |
|
||||
content: assistantMessage, |
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 |
|
||||
); |
|
||||
|
|
||||
// 加载现有历史记录 |
|
||||
final existingMessages = _chatHistoryService.loadHistory(agentId); |
|
||||
|
|
||||
// 添加新消息 |
|
||||
existingMessages.addAll([userMsg, assistantMsg]); |
|
||||
|
|
||||
// 保存更新后的历史记录 |
|
||||
_chatHistoryService.saveHistory(agentId, existingMessages); |
|
||||
|
|
||||
Logger.info('已保存聊天历史: agentId=$agentId'); |
|
||||
} catch (e) { |
|
||||
Logger.error('保存聊天历史失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 初始化服务 |
|
||||
Future<bool> initialize() async { |
|
||||
if (_isInitialized) return true; |
|
||||
|
|
||||
try { |
|
||||
Logger.info('正在初始化语音交互服务...'); |
|
||||
|
|
||||
// 获取聊天历史服务 |
|
||||
try { |
|
||||
_chatHistoryService = Get.find<ChatHistoryService>(); |
|
||||
} catch (e) { |
|
||||
Logger.warning('获取ChatHistoryService失败,将创建新实例'); |
|
||||
_chatHistoryService = Get.put(ChatHistoryService()); |
|
||||
} |
|
||||
|
|
||||
// 设置事件通道 |
|
||||
_setupEventChannel(); |
|
||||
|
|
||||
// 加载配置 |
|
||||
_loadConfig(); |
|
||||
|
|
||||
// 检查服务是否已在运行 |
|
||||
final bool running = await checkServiceStatus(); |
|
||||
|
|
||||
// 如果服务未运行,启动服务 |
|
||||
if (!running) { |
|
||||
// 启动语音交互服务 |
|
||||
final success = await startService(); |
|
||||
if (!success) { |
|
||||
Logger.error('语音交互服务启动失败'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
_isInitialized = true; |
|
||||
Logger.info('语音交互服务初始化完成'); |
|
||||
return true; |
|
||||
} catch (e) { |
|
||||
Logger.error('语音交互服务初始化失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 启动语音交互服务 |
|
||||
Future<bool> startService() async { |
|
||||
try { |
|
||||
Logger.info('启动语音交互服务...'); |
|
||||
|
|
||||
// 使用已加载的配置信息 |
|
||||
final result = await _channel.invokeMethod<bool>('startService', { |
|
||||
'azure_speech_key': _azureSpeechKey, |
|
||||
'azure_speech_region': _azureSpeechRegion, |
|
||||
'volcano_ai_api_key': _volcanoAiKey, |
|
||||
}) ?? false; |
|
||||
|
|
||||
if (result) { |
|
||||
_isServiceRunning.value = true; |
|
||||
Logger.info('语音交互服务已启动'); |
|
||||
} else { |
|
||||
Logger.error('启动语音交互服务失败'); |
|
||||
} |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('启动语音交互服务时发生错误: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止语音交互服务 |
|
||||
Future<bool> stopService() async { |
|
||||
try { |
|
||||
Logger.info('停止语音交互服务...'); |
|
||||
|
|
||||
final result = await _channel.invokeMethod<bool>('stopService') ?? false; |
|
||||
|
|
||||
if (result) { |
|
||||
_isServiceRunning.value = false; |
|
||||
Logger.info('语音交互服务已停止'); |
|
||||
} else { |
|
||||
Logger.error('停止语音交互服务失败'); |
|
||||
} |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('停止语音交互服务时发生错误: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 检查服务是否运行 |
|
||||
Future<bool> checkServiceStatus() async { |
|
||||
try { |
|
||||
final bool result = await _channel.invokeMethod<bool>('isServiceRunning') ?? false; |
|
||||
_isServiceRunning.value = result; |
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('检查服务状态时发生错误: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 暂停语音交互 |
|
||||
Future<bool> pauseVoiceInteraction() async { |
|
||||
try { |
|
||||
Logger.info('暂停后台语音交互...'); |
|
||||
|
|
||||
final result = await _channel.invokeMethod<bool>('pauseVoiceInteraction') ?? false; |
|
||||
|
|
||||
if (result) { |
|
||||
Logger.info('语音交互已暂停'); |
|
||||
} else { |
|
||||
Logger.error('暂停语音交互失败'); |
|
||||
} |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('暂停语音交互时发生错误: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 资源释放 |
|
||||
@override |
|
||||
void onClose() { |
|
||||
_eventSubscription?.cancel(); |
|
||||
_eventStreamController.close(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,9 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/agent_controller.dart'; |
||||
|
|
||||
|
class AgentBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<AgentController>(() => AgentController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,572 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:convert'; |
||||
|
import 'dart:io'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:agent_service/agent_service.dart'; |
||||
|
import 'package:logger/logger.dart'; |
||||
|
import 'package:chat_storage/chat_storage.dart'; |
||||
|
import 'package:image_picker/image_picker.dart'; |
||||
|
|
||||
|
class Message { |
||||
|
final bool isUser; |
||||
|
final String text; |
||||
|
final DateTime timestamp; |
||||
|
final bool isRecognizing; // 是否为语音识别中的临时消息 |
||||
|
final bool hasImage; // 是否包含图片 |
||||
|
final String? imagePath; // 图片路径 |
||||
|
|
||||
|
Message({ |
||||
|
required this.isUser, |
||||
|
required this.text, |
||||
|
DateTime? timestamp, |
||||
|
this.isRecognizing = false, |
||||
|
this.hasImage = false, |
||||
|
this.imagePath, |
||||
|
}) : this.timestamp = timestamp ?? DateTime.now(); |
||||
|
} |
||||
|
|
||||
|
class AgentController extends GetxController { |
||||
|
static AgentController get to => Get.find(); |
||||
|
|
||||
|
final logger = Logger(); |
||||
|
|
||||
|
// 聊天消息列表 |
||||
|
final messages = <Message>[].obs; |
||||
|
|
||||
|
// 控制器 |
||||
|
final TextEditingController textController = TextEditingController(); |
||||
|
final ScrollController scrollController = ScrollController(); |
||||
|
|
||||
|
// 状态标志 |
||||
|
final isInitialized = false.obs; |
||||
|
final isListening = false.obs; |
||||
|
final isSpeaking = false.obs; |
||||
|
final isProcessing = false.obs; |
||||
|
|
||||
|
// 图片处理状态 |
||||
|
final isImageProcessing = false.obs; |
||||
|
|
||||
|
// 输入模式控制 |
||||
|
final isTextInputMode = true.obs; |
||||
|
|
||||
|
// 当前输入的文本内容 |
||||
|
final currentText = ''.obs; |
||||
|
|
||||
|
// 图片输入相关 |
||||
|
final isImageInputActive = false.obs; // 是否处于图片输入模式 |
||||
|
final selectedImagePath = Rx<String?>(null); // 当前选择的图片路径 |
||||
|
|
||||
|
// 流事件订阅 |
||||
|
StreamSubscription<AgentServiceEvent>? _eventSubscription; |
||||
|
|
||||
|
// 用于跟踪AI回复状态 |
||||
|
bool _isNewAssistantResponse = true; |
||||
|
String _currentResponseId = ''; |
||||
|
|
||||
|
// 会话ID |
||||
|
final String sessionId = 'default_agent'; |
||||
|
|
||||
|
// 是否已加载聊天记录 |
||||
|
final isHistoryLoaded = false.obs; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
_loadChatHistory(); |
||||
|
_subscribeToEvents(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
AgentService.stopConversation(); |
||||
|
AgentService.stopTts(); |
||||
|
textController.dispose(); |
||||
|
scrollController.dispose(); |
||||
|
_eventSubscription?.cancel(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
// 加载聊天历史记录 |
||||
|
Future<void> _loadChatHistory() async { |
||||
|
try { |
||||
|
logger.i('正在加载聊天历史...'); |
||||
|
final result = await ChatStorage.getMessages( |
||||
|
sessionId: sessionId, |
||||
|
page: 1, |
||||
|
pageSize: 20, |
||||
|
); |
||||
|
|
||||
|
if (result.messages.isNotEmpty) { |
||||
|
// 转换并添加消息到列表 |
||||
|
final historyMessages = result.messages.map((msg) => Message( |
||||
|
isUser: msg.sender == 'user', |
||||
|
text: msg.message, |
||||
|
timestamp: DateTime.parse(msg.timestamp), |
||||
|
)).toList(); |
||||
|
|
||||
|
// 消息按时间排序,最新的消息在底部 |
||||
|
historyMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); |
||||
|
messages.assignAll(historyMessages); |
||||
|
|
||||
|
logger.i('成功加载 ${historyMessages.length} 条聊天记录'); |
||||
|
} else { |
||||
|
// 没有历史记录时添加欢迎消息 |
||||
|
_addWelcomeMessage(); |
||||
|
logger.i('没有历史记录,添加欢迎消息'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.e('加载聊天历史失败: $e'); |
||||
|
_addWelcomeMessage(); |
||||
|
} finally { |
||||
|
isHistoryLoaded.value = true; |
||||
|
|
||||
|
messages.refresh(); |
||||
|
// 在加载完成后直接定位到底部,无需动画 |
||||
|
WidgetsBinding.instance.addPostFrameCallback((_) { |
||||
|
scrollController.jumpTo(scrollController.position.maxScrollExtent); |
||||
|
}); |
||||
|
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 添加欢迎消息 |
||||
|
void _addWelcomeMessage() { |
||||
|
messages.add(Message( |
||||
|
isUser: false, |
||||
|
text: '你好!我是DeepVoice AI助手,我能帮你:\n\n' |
||||
|
'• 回答问题和提供信息\n' |
||||
|
'• 实时语音翻译\n' |
||||
|
'• 推荐音乐和播客\n' |
||||
|
'• 语音备忘录和提醒\n' |
||||
|
'• 控制耳机功能\n\n' |
||||
|
'有什么我可以帮你的吗?', |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
// 订阅代理服务事件 |
||||
|
void _subscribeToEvents() { |
||||
|
_eventSubscription = AgentService.events.listen((event) { |
||||
|
switch (event.type) { |
||||
|
case AgentServiceEventType.recognitionStarted: |
||||
|
isListening.value = true; |
||||
|
isTextInputMode.value = false; |
||||
|
isTextInputMode.refresh(); |
||||
|
// 只标记识别开始,不添加消息 |
||||
|
break; |
||||
|
case AgentServiceEventType.recognitionStopped: |
||||
|
isListening.value = false; |
||||
|
isTextInputMode.value = true; |
||||
|
isTextInputMode.refresh(); |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.recognizing: |
||||
|
final text = event.data['text'] ?? ''; |
||||
|
currentText.value = text; // 保留当前文本,以便其他地方使用 |
||||
|
|
||||
|
if (text.isNotEmpty) { |
||||
|
// 查找是否有正在识别中的消息 |
||||
|
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); |
||||
|
|
||||
|
if (index >= 0) { |
||||
|
// 更新已有的识别中消息 |
||||
|
messages[index] = Message( |
||||
|
isUser: true, |
||||
|
text: text, |
||||
|
isRecognizing: true, |
||||
|
timestamp: messages[index].timestamp, |
||||
|
); |
||||
|
} else { |
||||
|
// 没有识别中的消息,创建新消息 |
||||
|
messages.add(Message( |
||||
|
isUser: true, |
||||
|
text: text, |
||||
|
isRecognizing: true, |
||||
|
)); |
||||
|
} |
||||
|
messages.refresh(); |
||||
|
_scrollToBottom(); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.recognitionResult: |
||||
|
final text = event.data['text'] ?? ''; |
||||
|
|
||||
|
// 查找是否有正在识别中的消息 |
||||
|
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); |
||||
|
|
||||
|
if (text.isNotEmpty) { |
||||
|
if (index >= 0) { |
||||
|
// 将识别中的消息转换为最终消息 |
||||
|
messages[index] = Message( |
||||
|
isUser: true, |
||||
|
text: text, |
||||
|
timestamp: messages[index].timestamp, |
||||
|
); |
||||
|
messages.refresh(); |
||||
|
} else { |
||||
|
// 如果没有找到识别中的消息,添加新消息 |
||||
|
messages.add(Message(isUser: true, text: text)); |
||||
|
} |
||||
|
|
||||
|
isProcessing.value = true; |
||||
|
// 标记为新的AI回复 |
||||
|
_isNewAssistantResponse = true; |
||||
|
// 滚动到底部 |
||||
|
_scrollToBottom(); |
||||
|
} else if (index >= 0) { |
||||
|
// 如果识别结果为空,移除临时的识别消息 |
||||
|
messages.removeAt(index); |
||||
|
messages.refresh(); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.ttsStarted: |
||||
|
isSpeaking.value = true; |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.ttsCompleted: |
||||
|
case AgentServiceEventType.ttsStopped: |
||||
|
case AgentServiceEventType.ttsCanceled: |
||||
|
isSpeaking.value = false; |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.imageProcessing: |
||||
|
isImageProcessing.value = true; |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.imageReady: |
||||
|
// 图片准备就绪,但AI还没有开始处理,保持processing状态 |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.assistantToken: |
||||
|
if (!isProcessing.value) isProcessing.value = true; |
||||
|
|
||||
|
final token = event.data['token'] ?? ''; |
||||
|
final responseId = event.data['responseId'] ?? ''; |
||||
|
|
||||
|
if (token.isNotEmpty) { |
||||
|
// 如果是新的回复或者响应ID改变,创建新消息 |
||||
|
if (_isNewAssistantResponse || (_currentResponseId != responseId && responseId.isNotEmpty)) { |
||||
|
messages.add(Message(isUser: false, text: token)); |
||||
|
_isNewAssistantResponse = false; |
||||
|
_currentResponseId = responseId; |
||||
|
} else { |
||||
|
// 否则更新最后一条消息 |
||||
|
final lastIndex = messages.length - 1; |
||||
|
|
||||
|
if (lastIndex >= 0 && !messages[lastIndex].isUser) { |
||||
|
messages[lastIndex] = Message( |
||||
|
isUser: false, |
||||
|
text: messages[lastIndex].text + token, |
||||
|
timestamp: messages[lastIndex].timestamp, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
// 滚动到底部 |
||||
|
_scrollToBottom(); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.assistantResponse: |
||||
|
isProcessing.value = false; |
||||
|
isImageProcessing.value = false; |
||||
|
// 标记当前回复完成 |
||||
|
_isNewAssistantResponse = true; |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.error: |
||||
|
isListening.value = false; |
||||
|
isSpeaking.value = false; |
||||
|
isProcessing.value = false; |
||||
|
isImageProcessing.value = false; |
||||
|
|
||||
|
// 移除临时的识别消息 |
||||
|
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); |
||||
|
if (index >= 0) { |
||||
|
messages.removeAt(index); |
||||
|
messages.refresh(); |
||||
|
} |
||||
|
|
||||
|
logger.e('代理服务错误: ${event.data['message']}'); |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.autoStop: |
||||
|
isListening.value = false; |
||||
|
|
||||
|
// 移除临时的识别消息 |
||||
|
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); |
||||
|
if (index >= 0) { |
||||
|
// 只有当消息为空或文本太短时才移除 |
||||
|
if (messages[index].text.trim().length < 2) { |
||||
|
messages.removeAt(index); |
||||
|
messages.refresh(); |
||||
|
} else { |
||||
|
// 否则转换为正式消息 |
||||
|
messages[index] = Message( |
||||
|
isUser: true, |
||||
|
text: messages[index].text, |
||||
|
timestamp: messages[index].timestamp, |
||||
|
); |
||||
|
messages.refresh(); |
||||
|
|
||||
|
// 发送此消息到AI处理 |
||||
|
isProcessing.value = true; |
||||
|
_isNewAssistantResponse = true; |
||||
|
AgentService.processTextInput(messages[index].text, speakResponse: true); |
||||
|
} |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.functionCall: |
||||
|
// final functionName = event.data['name'] ?? ''; |
||||
|
// final arguments = event.data['arguments'] ?? ''; |
||||
|
// logger.i('mcp: $functionName, $arguments'); |
||||
|
break; |
||||
|
|
||||
|
case AgentServiceEventType.functionCallResult: |
||||
|
final result = event.data['result'] ?? ''; |
||||
|
final functionCall = event.data['function_call'] ?? ''; |
||||
|
break; |
||||
|
|
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 滚动到底部 |
||||
|
void _scrollToBottom() { |
||||
|
WidgetsBinding.instance.addPostFrameCallback((_) { |
||||
|
if (scrollController.hasClients) { |
||||
|
try { |
||||
|
scrollController.animateTo( |
||||
|
scrollController.position.maxScrollExtent, |
||||
|
duration: Duration(milliseconds: 300), |
||||
|
curve: Curves.easeOut, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
logger.e('滚动到底部失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 发送文本消息 |
||||
|
Future<void> sendTextMessage() async { |
||||
|
// 如果图片输入是活跃的,使用选中的图片和文本框的内容 |
||||
|
if (isImageInputActive.value && selectedImagePath.value != null) { |
||||
|
final text = textController.text.trim(); |
||||
|
final imagePath = selectedImagePath.value!; // 使用!强制断言非空 |
||||
|
await sendImageMessage(imagePath, text: text); |
||||
|
|
||||
|
// 清除图片输入状态 |
||||
|
clearImageInput(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
final text = textController.text.trim(); |
||||
|
if (text.isEmpty) return; |
||||
|
|
||||
|
final message = Message(isUser: true, text: text); |
||||
|
messages.add(message); |
||||
|
textController.clear(); |
||||
|
|
||||
|
// 滚动到底部 |
||||
|
_scrollToBottom(); |
||||
|
|
||||
|
try { |
||||
|
isProcessing.value = true; |
||||
|
// 标记为新的AI回复 |
||||
|
_isNewAssistantResponse = true; |
||||
|
await AgentService.processTextInput(text, speakResponse: true); |
||||
|
} catch (e) { |
||||
|
logger.e('发送文本消息失败: $e'); |
||||
|
isProcessing.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 发送图片消息 |
||||
|
Future<void> sendImageMessage(String imagePath, {String? text}) async { |
||||
|
if (imagePath.isEmpty || !File(imagePath).existsSync()) { |
||||
|
logger.e('图片不存在: $imagePath'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 显示的文本,如果没有提供则使用默认值 |
||||
|
final displayText = text?.isNotEmpty == true ? text! : '[图片]'; |
||||
|
|
||||
|
// 添加用户消息 |
||||
|
final message = Message( |
||||
|
isUser: true, |
||||
|
text: displayText, |
||||
|
hasImage: true, |
||||
|
imagePath: imagePath, |
||||
|
); |
||||
|
messages.add(message); |
||||
|
|
||||
|
// 清空文本输入 |
||||
|
textController.clear(); |
||||
|
|
||||
|
// 滚动到底部 |
||||
|
_scrollToBottom(); |
||||
|
|
||||
|
try { |
||||
|
isProcessing.value = true; |
||||
|
isImageProcessing.value = true; |
||||
|
// 标记为新的AI回复 |
||||
|
_isNewAssistantResponse = true; |
||||
|
|
||||
|
// 调用Agent Service处理图片 |
||||
|
await AgentService.processImageInput( |
||||
|
imagePath, |
||||
|
text: text ?? '', |
||||
|
speakResponse: true, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
logger.e('处理图片失败: $e'); |
||||
|
isProcessing.value = false; |
||||
|
isImageProcessing.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 开始语音输入 |
||||
|
Future<void> startVoiceInput() async { |
||||
|
if (isListening.value) return; |
||||
|
|
||||
|
try { |
||||
|
await AgentService.startConversation(); |
||||
|
} catch (e) { |
||||
|
logger.e('启动语音输入失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 停止语音输入 |
||||
|
Future<void> stopVoiceInput() async { |
||||
|
if (!isListening.value) return; |
||||
|
|
||||
|
try { |
||||
|
await AgentService.stopConversation(); |
||||
|
} catch (e) { |
||||
|
logger.e('停止语音输入失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 清除聊天历史 |
||||
|
Future<void> clearChat() async { |
||||
|
try { |
||||
|
await AgentService.clearChatHistory(); |
||||
|
|
||||
|
|
||||
|
// 清空本地消息列表 |
||||
|
messages.clear(); |
||||
|
|
||||
|
// 添加欢迎消息 |
||||
|
_addWelcomeMessage(); |
||||
|
|
||||
|
// 重置回复状态 |
||||
|
_isNewAssistantResponse = true; |
||||
|
} catch (e) { |
||||
|
logger.e('清除聊天历史失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 切换输入模式 |
||||
|
void toggleInputMode() { |
||||
|
// 如果当前在图片输入模式,先清除 |
||||
|
if (isImageInputActive.value) { |
||||
|
clearImageInput(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
isTextInputMode.toggle(); |
||||
|
|
||||
|
// 切换到语音模式时,直接开始语音输入 |
||||
|
if (!isTextInputMode.value) { |
||||
|
startVoiceInput(); |
||||
|
} |
||||
|
// 切换到文本模式时,直接停止语音输入 |
||||
|
else { |
||||
|
stopVoiceInput(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 从相册选择图片 |
||||
|
Future<void> pickImage() async { |
||||
|
try { |
||||
|
final ImagePicker picker = ImagePicker(); |
||||
|
final XFile? image = await picker.pickImage( |
||||
|
source: ImageSource.gallery, |
||||
|
imageQuality: 80, // 设置图片质量 |
||||
|
maxWidth: 1024, // 限制最大宽度 |
||||
|
maxHeight: 1024, // 限制最大高度 |
||||
|
); |
||||
|
|
||||
|
if (image != null) { |
||||
|
// 设置已选择的图片 |
||||
|
selectedImagePath.value = image.path; |
||||
|
// 激活图片输入模式 |
||||
|
isImageInputActive.value = true; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.e('选择图片失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 拍照获取图片 |
||||
|
Future<void> takePhoto() async { |
||||
|
try { |
||||
|
final ImagePicker picker = ImagePicker(); |
||||
|
final XFile? photo = await picker.pickImage( |
||||
|
source: ImageSource.camera, |
||||
|
imageQuality: 80, // 设置图片质量 |
||||
|
maxWidth: 1024, // 限制最大宽度 |
||||
|
maxHeight: 1024, // 限制最大高度 |
||||
|
); |
||||
|
|
||||
|
if (photo != null) { |
||||
|
// 设置已选择的图片 |
||||
|
selectedImagePath.value = photo.path; |
||||
|
// 激活图片输入模式 |
||||
|
isImageInputActive.value = true; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
logger.e('拍照失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 清除图片输入状态 |
||||
|
void clearImageInput() { |
||||
|
selectedImagePath.value = null; |
||||
|
isImageInputActive.value = false; |
||||
|
} |
||||
|
|
||||
|
// 使用预设提示词处理图片 |
||||
|
void useImagePrompt(String prompt) { |
||||
|
// 确保selectedImagePath.value不为null |
||||
|
if (selectedImagePath.value == null) return; |
||||
|
|
||||
|
// 填充文本框同时直接发送消息 |
||||
|
textController.text = prompt; |
||||
|
// 立即发送图片和提示词 |
||||
|
final imagePath = selectedImagePath.value!; |
||||
|
sendImageMessage(imagePath, text: prompt); |
||||
|
// 发送后清除图片输入状态 |
||||
|
clearImageInput(); |
||||
|
} |
||||
|
|
||||
|
// 预设提示词:总结图片内容 |
||||
|
void summarizeImage() { |
||||
|
useImagePrompt('请总结这张图片的内容'); |
||||
|
} |
||||
|
|
||||
|
// 预设提示词:抽取图像文字 |
||||
|
void extractTextFromImage() { |
||||
|
useImagePrompt('请提取这张图片中的所有文字'); |
||||
|
} |
||||
|
|
||||
|
// 预设提示词:翻译图像文字 |
||||
|
void translateImageText() { |
||||
|
useImagePrompt('请翻译这张图片中的文字'); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,502 @@ |
|||||
|
import 'dart:io'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/agent_controller.dart'; |
||||
|
import 'message_bubble.dart'; |
||||
|
|
||||
|
class AgentView extends StatelessWidget { |
||||
|
const AgentView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final controller = Get.find<AgentController>(); |
||||
|
|
||||
|
return Scaffold( |
||||
|
appBar: AppBar( |
||||
|
title: Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
width: 40, |
||||
|
height: 40, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Theme.of(context).primaryColor, |
||||
|
borderRadius: BorderRadius.circular(20), |
||||
|
), |
||||
|
child: const Icon( |
||||
|
Icons.smart_toy_outlined, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 10), |
||||
|
Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'DeepVoice AI', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
width: 8, |
||||
|
height: 8, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.green, |
||||
|
borderRadius: BorderRadius.circular(4), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 4), |
||||
|
Text( |
||||
|
'在线', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.green, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
actions: [ |
||||
|
PopupMenuButton( |
||||
|
icon: const Icon(Icons.more_vert), |
||||
|
itemBuilder: (context) => [ |
||||
|
PopupMenuItem( |
||||
|
child: const Text('清除聊天记录'), |
||||
|
onTap: () => controller.clearChat(), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
body: Column( |
||||
|
children: [ |
||||
|
// 聊天内容 |
||||
|
Expanded( |
||||
|
child: Obx( |
||||
|
() => ListView.builder( |
||||
|
controller: controller.scrollController, |
||||
|
reverse: false, |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
itemCount: controller.messages.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final message = controller.messages[index]; |
||||
|
return MessageBubble( |
||||
|
isUser: message.isUser, |
||||
|
message: message.text, |
||||
|
timestamp: message.timestamp, |
||||
|
isRecognizing: message.isRecognizing, |
||||
|
hasImage: message.hasImage, |
||||
|
imagePath: message.imagePath, |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// 输入区域 |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Theme.of(context).scaffoldBackgroundColor, |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 5, |
||||
|
offset: const Offset(0, -1), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
// 图片预览区域 - 仅在图片输入模式下显示 |
||||
|
Obx(() { |
||||
|
if (controller.isImageInputActive.value && controller.selectedImagePath.value != null) { |
||||
|
return Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
// 图片预览和关闭按钮 |
||||
|
Stack( |
||||
|
children: [ |
||||
|
ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
child: Image.file( |
||||
|
File(controller.selectedImagePath.value!), |
||||
|
height: 150, |
||||
|
width: double.infinity, |
||||
|
fit: BoxFit.cover, |
||||
|
), |
||||
|
), |
||||
|
Positioned( |
||||
|
top: 8, |
||||
|
right: 8, |
||||
|
child: GestureDetector( |
||||
|
onTap: () => controller.clearImageInput(), |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.all(4), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.black.withOpacity(0.6), |
||||
|
shape: BoxShape.circle, |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.close, |
||||
|
color: Colors.white, |
||||
|
size: 16, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
|
||||
|
// 预设提示选项 |
||||
|
SizedBox(height: 8), |
||||
|
SingleChildScrollView( |
||||
|
scrollDirection: Axis.horizontal, |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
_buildPromptChip( |
||||
|
context: context, |
||||
|
label: '总结图片内容', |
||||
|
onTap: () => controller.summarizeImage(), |
||||
|
), |
||||
|
SizedBox(width: 8), |
||||
|
_buildPromptChip( |
||||
|
context: context, |
||||
|
label: '抽取图像文字', |
||||
|
onTap: () => controller.extractTextFromImage(), |
||||
|
), |
||||
|
SizedBox(width: 8), |
||||
|
_buildPromptChip( |
||||
|
context: context, |
||||
|
label: '翻译图像文字', |
||||
|
onTap: () => controller.translateImageText(), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 12), |
||||
|
], |
||||
|
); |
||||
|
} else { |
||||
|
return SizedBox.shrink(); |
||||
|
} |
||||
|
}), |
||||
|
|
||||
|
// 输入控件行 |
||||
|
Row( |
||||
|
children: [ |
||||
|
// 键盘/语音切换按钮 |
||||
|
Obx(() { |
||||
|
final isTextMode = controller.isTextInputMode.value; |
||||
|
return GestureDetector( |
||||
|
onTap: () { |
||||
|
controller.toggleInputMode(); |
||||
|
}, |
||||
|
child: Container( |
||||
|
width: 48, |
||||
|
height: 48, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey[200], |
||||
|
borderRadius: BorderRadius.circular(24), |
||||
|
), |
||||
|
child: Icon( |
||||
|
isTextMode ? Icons.mic_none : Icons.keyboard, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
}), |
||||
|
|
||||
|
const SizedBox(width: 8), |
||||
|
|
||||
|
// 文本输入框或语音波形图 |
||||
|
Expanded( |
||||
|
child: Obx(() { |
||||
|
final isTextMode = controller.isTextInputMode.value; |
||||
|
|
||||
|
if (isTextMode) { |
||||
|
// 文本输入模式 - 注意观察图片模式时的高度变化 |
||||
|
return Container( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 16), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey[200], |
||||
|
borderRadius: BorderRadius.circular(24), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Obx(() { |
||||
|
// 图片模式下使用更高的输入框 |
||||
|
final isImageActive = controller.isImageInputActive.value; |
||||
|
|
||||
|
return TextField( |
||||
|
controller: controller.textController, |
||||
|
maxLines: isImageActive ? 2 : 1, |
||||
|
minLines: isImageActive ? 2 : 1, |
||||
|
decoration: InputDecoration( |
||||
|
hintText: isImageActive ? '添加对图片的问题或描述...' : '输入消息...', |
||||
|
border: InputBorder.none, |
||||
|
contentPadding: EdgeInsets.symmetric(vertical: isImageActive ? 12 : 0), |
||||
|
), |
||||
|
onSubmitted: (_) => controller.sendTextMessage(), |
||||
|
); |
||||
|
}), |
||||
|
), |
||||
|
GestureDetector( |
||||
|
onTap: () => controller.sendTextMessage(), |
||||
|
child: Icon( |
||||
|
Icons.send, |
||||
|
color: Theme.of(context).primaryColor, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} else { |
||||
|
// 语音输入模式 - 始终显示波形图 |
||||
|
return GestureDetector( |
||||
|
onTap: () { |
||||
|
if (controller.isListening.value) { |
||||
|
controller.stopVoiceInput(); |
||||
|
} else { |
||||
|
controller.startVoiceInput(); |
||||
|
} |
||||
|
}, |
||||
|
child: Container( |
||||
|
height: 48, |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 16), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey[200], |
||||
|
borderRadius: BorderRadius.circular(24), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
// 语音波形图 - 始终显示 |
||||
|
Expanded( |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.start, |
||||
|
children: [ |
||||
|
...List.generate(10, (index) { |
||||
|
return _buildSoundBar(index); |
||||
|
}), |
||||
|
const Spacer(), |
||||
|
// "正在聆听"文字 |
||||
|
Obx(() { |
||||
|
final isListening = controller.isListening.value; |
||||
|
return Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.mic, |
||||
|
size: 14, |
||||
|
color: isListening |
||||
|
? Theme.of(context).primaryColor |
||||
|
: Colors.grey, |
||||
|
), |
||||
|
const SizedBox(width: 4), |
||||
|
Text( |
||||
|
'正在聆听', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: isListening |
||||
|
? Theme.of(context).primaryColor |
||||
|
: Colors.grey, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
}), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
}), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(width: 8), |
||||
|
|
||||
|
// 图片选择按钮 |
||||
|
Container( |
||||
|
width: 48, |
||||
|
height: 48, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey[200], |
||||
|
borderRadius: BorderRadius.circular(24), |
||||
|
), |
||||
|
child: PopupMenuButton<String>( |
||||
|
icon: Icon( |
||||
|
Icons.add_photo_alternate_outlined, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
padding: EdgeInsets.zero, |
||||
|
onSelected: (value) async { |
||||
|
if (value == 'camera') { |
||||
|
await controller.takePhoto(); |
||||
|
} else if (value == 'gallery') { |
||||
|
await controller.pickImage(); |
||||
|
} |
||||
|
}, |
||||
|
itemBuilder: (context) => [ |
||||
|
PopupMenuItem<String>( |
||||
|
value: 'camera', |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon(Icons.camera_alt, color: Theme.of(context).primaryColor), |
||||
|
SizedBox(width: 8), |
||||
|
Text('拍照'), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
PopupMenuItem<String>( |
||||
|
value: 'gallery', |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon(Icons.photo_library, color: Theme.of(context).primaryColor), |
||||
|
SizedBox(width: 8), |
||||
|
Text('从相册选择'), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 构建提示选项芯片 |
||||
|
Widget _buildPromptChip({ |
||||
|
required BuildContext context, |
||||
|
required String label, |
||||
|
required VoidCallback onTap, |
||||
|
}) { |
||||
|
return GestureDetector( |
||||
|
onTap: onTap, |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Theme.of(context).primaryColor.withOpacity(0.1), |
||||
|
borderRadius: BorderRadius.circular(16), |
||||
|
border: Border.all( |
||||
|
color: Theme.of(context).primaryColor.withOpacity(0.3), |
||||
|
), |
||||
|
), |
||||
|
child: Text( |
||||
|
label, |
||||
|
style: TextStyle( |
||||
|
fontSize: 13, |
||||
|
color: Theme.of(context).primaryColor, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 构建声音柱 |
||||
|
Widget _buildSoundBar(int index) { |
||||
|
return Padding( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 1), |
||||
|
child: Obx(() => _SoundBar( |
||||
|
delay: index * 0.1, |
||||
|
isActive: Get.find<AgentController>().isListening.value, |
||||
|
)), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 声音柱动画组件 |
||||
|
class _SoundBar extends StatefulWidget { |
||||
|
final double delay; |
||||
|
final bool isActive; |
||||
|
|
||||
|
const _SoundBar({ |
||||
|
required this.delay, |
||||
|
required this.isActive, |
||||
|
}); |
||||
|
|
||||
|
@override |
||||
|
_SoundBarState createState() => _SoundBarState(); |
||||
|
} |
||||
|
|
||||
|
class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixin { |
||||
|
late AnimationController _controller; |
||||
|
late Animation<double> _animation; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
|
||||
|
_controller = AnimationController( |
||||
|
vsync: this, |
||||
|
duration: const Duration(milliseconds: 500), |
||||
|
); |
||||
|
|
||||
|
_animation = Tween<double>(begin: 3, end: 15).animate( |
||||
|
CurvedAnimation( |
||||
|
parent: _controller, |
||||
|
curve: Curves.easeInOut, |
||||
|
), |
||||
|
)..addListener(() { |
||||
|
setState(() {}); |
||||
|
}); |
||||
|
|
||||
|
_startOrStopAnimation(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void didUpdateWidget(_SoundBar oldWidget) { |
||||
|
super.didUpdateWidget(oldWidget); |
||||
|
if (widget.isActive != oldWidget.isActive) { |
||||
|
_startOrStopAnimation(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _startOrStopAnimation() { |
||||
|
if (widget.isActive) { |
||||
|
Future.delayed(Duration(milliseconds: (widget.delay * 1000).toInt()), () { |
||||
|
if (mounted && widget.isActive) { |
||||
|
_controller.repeat(reverse: true); |
||||
|
} |
||||
|
}); |
||||
|
} else { |
||||
|
_controller.reset(); |
||||
|
// 设置为小高度的静态状态 |
||||
|
_controller.animateTo(0.1, duration: Duration(milliseconds: 300)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_controller.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Container( |
||||
|
width: 2, |
||||
|
height: widget.isActive ? _animation.value : 3, |
||||
|
decoration: BoxDecoration( |
||||
|
color: widget.isActive ? Theme.of(context).primaryColor : Colors.grey[400], |
||||
|
borderRadius: BorderRadius.circular(1), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,879 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:intl/intl.dart'; |
||||
|
import 'dart:io'; |
||||
|
|
||||
|
/// 消息气泡组件 |
||||
|
class MessageBubble extends StatelessWidget { |
||||
|
final bool isUser; |
||||
|
final String message; |
||||
|
final DateTime timestamp; |
||||
|
final bool isRecognizing; |
||||
|
final bool hasImage; |
||||
|
final String? imagePath; |
||||
|
|
||||
|
const MessageBubble({ |
||||
|
Key? key, |
||||
|
required this.isUser, |
||||
|
required this.message, |
||||
|
required this.timestamp, |
||||
|
this.isRecognizing = false, |
||||
|
this.hasImage = false, |
||||
|
this.imagePath, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final timeFormat = DateFormat('HH:mm'); |
||||
|
|
||||
|
return Padding( |
||||
|
padding: const EdgeInsets.symmetric(vertical: 8), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
if (!isUser) _buildAvatar(isUser, context), |
||||
|
const SizedBox(width: 8), |
||||
|
Flexible( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: hasImage ? (message == '[图片]' ? EdgeInsets.zero : const EdgeInsets.all(12)) : const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: _getBubbleColor(context), |
||||
|
borderRadius: BorderRadius.circular(18).copyWith( |
||||
|
bottomLeft: isUser ? const Radius.circular(18) : const Radius.circular(4), |
||||
|
bottomRight: !isUser ? const Radius.circular(18) : const Radius.circular(4), |
||||
|
), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 2, |
||||
|
offset: const Offset(0, 1), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: _buildMessageContent(context), |
||||
|
), |
||||
|
const SizedBox(height: 4), |
||||
|
// 识别中的消息不显示时间 |
||||
|
if (!isRecognizing) |
||||
|
Text( |
||||
|
timeFormat.format(timestamp), |
||||
|
style: TextStyle( |
||||
|
fontSize: 10, |
||||
|
color: Colors.grey[500], |
||||
|
fontWeight: FontWeight.w400, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
if (isUser) _buildAvatar(isUser, context), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 获取气泡颜色 |
||||
|
Color _getBubbleColor(BuildContext context) { |
||||
|
if (isUser) { |
||||
|
return isRecognizing |
||||
|
? Color(0xFF3B82F6).withOpacity(0.8) // 识别中的用户消息使用半透明的蓝色 |
||||
|
: Color(0xFF3B82F6); // 使用更明亮的蓝色 |
||||
|
} else { |
||||
|
return Color(0xFFF1F5F9); // 更淡的灰色背景 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 构建头像 |
||||
|
Widget _buildAvatar(bool isUser, BuildContext context) { |
||||
|
return Container( |
||||
|
width: 36, |
||||
|
height: 36, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isUser ? Colors.grey[300] : Theme.of(context).primaryColor, |
||||
|
borderRadius: BorderRadius.circular(18), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 2, |
||||
|
offset: const Offset(0, 1), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Icon( |
||||
|
isUser ? Icons.person : Icons.smart_toy_outlined, |
||||
|
color: isUser ? Colors.grey[700] : Colors.white, |
||||
|
size: 20, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建消息内容 |
||||
|
Widget _buildMessageContent(BuildContext context) { |
||||
|
// 如果是识别中的消息,添加特殊样式 |
||||
|
if (isRecognizing) { |
||||
|
return Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Flexible( |
||||
|
child: Text( |
||||
|
message, |
||||
|
style: const TextStyle( |
||||
|
color: Colors.white, |
||||
|
fontStyle: FontStyle.italic, |
||||
|
fontSize: 14, |
||||
|
height: 1.4, |
||||
|
), |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
_buildTypingIndicator(), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 如果是图片消息,显示图片 |
||||
|
if (hasImage && imagePath != null) { |
||||
|
// 如果消息是默认的[图片]标记,只显示图片 |
||||
|
if (message == '[图片]') { |
||||
|
return ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(16), |
||||
|
child: Image.file( |
||||
|
File(imagePath!), |
||||
|
width: 200, |
||||
|
height: 200, |
||||
|
fit: BoxFit.cover, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 如果消息是自定义文本,显示文本+图片 |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
message, |
||||
|
style: TextStyle( |
||||
|
color: isUser ? Colors.white : Colors.black87, |
||||
|
fontSize: 14, |
||||
|
height: 1.4, |
||||
|
fontWeight: FontWeight.w400, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
child: Image.file( |
||||
|
File(imagePath!), |
||||
|
width: 200, |
||||
|
height: 200, |
||||
|
fit: BoxFit.cover, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 判断消息内容,显示特殊卡片 |
||||
|
if (!isUser) { |
||||
|
if (message.contains('播放列表') || message.contains('音乐')) { |
||||
|
return _buildMusicContent(context); |
||||
|
} else if (message.contains('景点') || message.contains('旅游')) { |
||||
|
return _buildTravelContent(context); |
||||
|
} else if (message.contains('天气') || message.contains('气温') || message.contains('预报')) { |
||||
|
return _buildWeatherContent(context); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return Text( |
||||
|
message, |
||||
|
style: TextStyle( |
||||
|
color: isUser ? Colors.white : Colors.black87, |
||||
|
fontSize: 14, |
||||
|
height: 1.4, |
||||
|
fontWeight: FontWeight.w400, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建输入中的指示器 |
||||
|
Widget _buildTypingIndicator() { |
||||
|
return SizedBox( |
||||
|
width: 40, |
||||
|
height: 20, |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
||||
|
children: [ |
||||
|
_buildDot(100), |
||||
|
_buildDot(300), |
||||
|
_buildDot(500), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建动画点 |
||||
|
Widget _buildDot(int milliseconds) { |
||||
|
return _AnimatedDot(delayMilliseconds: milliseconds); |
||||
|
} |
||||
|
|
||||
|
/// 构建音乐内容卡片 |
||||
|
Widget _buildMusicContent(BuildContext context) { |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'以下是一些推荐的音乐:', |
||||
|
style: TextStyle( |
||||
|
color: Colors.black87, |
||||
|
fontSize: 14, |
||||
|
height: 1.4, |
||||
|
fontWeight: FontWeight.w400, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.03), |
||||
|
blurRadius: 3, |
||||
|
spreadRadius: 1, |
||||
|
offset: const Offset(0, 1), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.music_note, |
||||
|
size: 16, |
||||
|
color: Theme.of(context).primaryColor.withOpacity(0.7), |
||||
|
), |
||||
|
const SizedBox(width: 6), |
||||
|
Text( |
||||
|
'雨天时光 · 播放列表', |
||||
|
style: TextStyle( |
||||
|
fontSize: 13, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.grey[700], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 10), |
||||
|
|
||||
|
// 歌曲示例 |
||||
|
_buildMusicItem('雨声与钢琴曲', '纯音乐', context), |
||||
|
Divider(height: 1, thickness: 1, color: Colors.grey[100]), |
||||
|
_buildMusicItem('Rainy Day', 'Coldplay', context), |
||||
|
Divider(height: 1, thickness: 1, color: Colors.grey[100]), |
||||
|
_buildMusicItem('雨天的爵士咖啡馆', '爵士合集', context), |
||||
|
|
||||
|
const SizedBox(height: 8), |
||||
|
Center( |
||||
|
child: TextButton( |
||||
|
onPressed: () {}, |
||||
|
style: TextButton.styleFrom( |
||||
|
minimumSize: Size.zero, |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), |
||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap, |
||||
|
), |
||||
|
child: Text( |
||||
|
'查看更多推荐', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Theme.of(context).primaryColor, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 10), |
||||
|
Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: OutlinedButton.icon( |
||||
|
onPressed: () {}, |
||||
|
icon: Icon(Icons.play_arrow, size: 16), |
||||
|
label: Text('播放全部'), |
||||
|
style: OutlinedButton.styleFrom( |
||||
|
foregroundColor: Theme.of(context).primaryColor, |
||||
|
side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(16), |
||||
|
), |
||||
|
textStyle: TextStyle(fontSize: 12), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 8), |
||||
|
Expanded( |
||||
|
child: OutlinedButton.icon( |
||||
|
onPressed: () {}, |
||||
|
icon: Icon(Icons.shuffle, size: 16), |
||||
|
label: Text('随机播放'), |
||||
|
style: OutlinedButton.styleFrom( |
||||
|
foregroundColor: Colors.grey[700], |
||||
|
side: BorderSide(color: Colors.grey[400]!), |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(16), |
||||
|
), |
||||
|
textStyle: TextStyle(fontSize: 12), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建旅游内容卡片 |
||||
|
Widget _buildTravelContent(BuildContext context) { |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'以下是一些推荐的景点:', |
||||
|
style: TextStyle( |
||||
|
color: Colors.black87, |
||||
|
fontSize: 14, |
||||
|
height: 1.4, |
||||
|
fontWeight: FontWeight.w400, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.03), |
||||
|
blurRadius: 3, |
||||
|
spreadRadius: 1, |
||||
|
offset: const Offset(0, 1), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.place, |
||||
|
size: 16, |
||||
|
color: Color(0xFFE11D48), |
||||
|
), |
||||
|
const SizedBox(width: 6), |
||||
|
Text( |
||||
|
'热门景点 · 东京', |
||||
|
style: TextStyle( |
||||
|
fontSize: 13, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.grey[700], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 10), |
||||
|
|
||||
|
// 景点列表 |
||||
|
_buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context), |
||||
|
Divider(height: 1, thickness: 1, color: Colors.grey[100]), |
||||
|
_buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context), |
||||
|
Divider(height: 1, thickness: 1, color: Colors.grey[100]), |
||||
|
_buildLocationItem('浅草寺', '东京最古老的寺庙', context), |
||||
|
|
||||
|
const SizedBox(height: 8), |
||||
|
Center( |
||||
|
child: TextButton( |
||||
|
onPressed: () {}, |
||||
|
style: TextButton.styleFrom( |
||||
|
minimumSize: Size.zero, |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), |
||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap, |
||||
|
), |
||||
|
child: Text( |
||||
|
'查看更多景点', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Theme.of(context).primaryColor, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 10), |
||||
|
Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: OutlinedButton.icon( |
||||
|
onPressed: () {}, |
||||
|
icon: Icon(Icons.map, size: 16), |
||||
|
label: Text('查看地图'), |
||||
|
style: OutlinedButton.styleFrom( |
||||
|
foregroundColor: Theme.of(context).primaryColor, |
||||
|
side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(16), |
||||
|
), |
||||
|
textStyle: TextStyle(fontSize: 12), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 8), |
||||
|
Expanded( |
||||
|
child: OutlinedButton.icon( |
||||
|
onPressed: () {}, |
||||
|
icon: Icon(Icons.bookmark_border, size: 16), |
||||
|
label: Text('保存到收藏'), |
||||
|
style: OutlinedButton.styleFrom( |
||||
|
foregroundColor: Colors.grey[700], |
||||
|
side: BorderSide(color: Colors.grey[400]!), |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(16), |
||||
|
), |
||||
|
textStyle: TextStyle(fontSize: 12), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建音乐项目 |
||||
|
Widget _buildMusicItem(String title, String artist, BuildContext context) { |
||||
|
return Padding( |
||||
|
padding: const EdgeInsets.symmetric(vertical: 8), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
width: 38, |
||||
|
height: 38, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey[100], |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.music_note, |
||||
|
size: 18, |
||||
|
color: Theme.of(context).primaryColor, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
title, |
||||
|
style: TextStyle( |
||||
|
fontSize: 13, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: Colors.grey[800], |
||||
|
), |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
SizedBox(height: 2), |
||||
|
Text( |
||||
|
artist, |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
onPressed: () {}, |
||||
|
icon: Icon(Icons.play_circle, color: Theme.of(context).primaryColor), |
||||
|
padding: EdgeInsets.zero, |
||||
|
constraints: BoxConstraints(), |
||||
|
iconSize: 24, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建地点项目 |
||||
|
Widget _buildLocationItem(String name, String description, BuildContext context) { |
||||
|
return Padding( |
||||
|
padding: const EdgeInsets.symmetric(vertical: 8), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
width: 38, |
||||
|
height: 38, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.blue[50], |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.place, |
||||
|
size: 18, |
||||
|
color: Color(0xFFE11D48), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
name, |
||||
|
style: TextStyle( |
||||
|
fontSize: 13, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: Colors.grey[800], |
||||
|
), |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
SizedBox(height: 2), |
||||
|
Text( |
||||
|
description, |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
onPressed: () {}, |
||||
|
icon: Icon(Icons.arrow_forward, color: Colors.grey[400]), |
||||
|
padding: EdgeInsets.zero, |
||||
|
constraints: BoxConstraints(), |
||||
|
iconSize: 20, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建天气内容卡片 |
||||
|
Widget _buildWeatherContent(BuildContext context) { |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'当前天气情况:', |
||||
|
style: TextStyle( |
||||
|
color: Colors.black87, |
||||
|
fontSize: 14, |
||||
|
height: 1.4, |
||||
|
fontWeight: FontWeight.w400, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
decoration: BoxDecoration( |
||||
|
gradient: LinearGradient( |
||||
|
begin: Alignment.topLeft, |
||||
|
end: Alignment.bottomRight, |
||||
|
colors: [Color(0xFF60A5FA), Color(0xFF3B82F6)], |
||||
|
), |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.1), |
||||
|
blurRadius: 8, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'北京', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 4), |
||||
|
Text( |
||||
|
'晴朗', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: Colors.white.withOpacity(0.9), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 12), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Text( |
||||
|
'25°', |
||||
|
style: TextStyle( |
||||
|
fontSize: 36, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 8), |
||||
|
Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'最高: 29°', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.white.withOpacity(0.9), |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
'最低: 18°', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.white.withOpacity(0.9), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
Icon( |
||||
|
Icons.wb_sunny, |
||||
|
size: 48, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 16), |
||||
|
Container( |
||||
|
height: 1, |
||||
|
color: Colors.white.withOpacity(0.2), |
||||
|
), |
||||
|
SizedBox(height: 16), |
||||
|
Text( |
||||
|
'未来预报', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 8), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
_buildForecastDay('周一', Icons.wb_sunny, '28°'), |
||||
|
_buildForecastDay('周二', Icons.cloud, '25°'), |
||||
|
_buildForecastDay('周三', Icons.wb_cloudy, '26°'), |
||||
|
_buildForecastDay('周四', Icons.grain, '24°'), |
||||
|
_buildForecastDay('周五', Icons.wb_sunny, '27°'), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 16), |
||||
|
Container( |
||||
|
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white.withOpacity(0.2), |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.info_outline, |
||||
|
size: 16, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
SizedBox(width: 6), |
||||
|
Flexible( |
||||
|
child: Text( |
||||
|
'今日紫外线指数较高,外出请做好防晒', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 10), |
||||
|
Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: OutlinedButton.icon( |
||||
|
onPressed: () {}, |
||||
|
icon: Icon(Icons.location_on, size: 16), |
||||
|
label: Text('更多地区'), |
||||
|
style: OutlinedButton.styleFrom( |
||||
|
foregroundColor: Theme.of(context).primaryColor, |
||||
|
side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(16), |
||||
|
), |
||||
|
textStyle: TextStyle(fontSize: 12), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 8), |
||||
|
Expanded( |
||||
|
child: OutlinedButton.icon( |
||||
|
onPressed: () {}, |
||||
|
icon: Icon(Icons.calendar_today, size: 16), |
||||
|
label: Text('15天预报'), |
||||
|
style: OutlinedButton.styleFrom( |
||||
|
foregroundColor: Colors.grey[700], |
||||
|
side: BorderSide(color: Colors.grey[400]!), |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(16), |
||||
|
), |
||||
|
textStyle: TextStyle(fontSize: 12), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 构建单日天气预报 |
||||
|
Widget _buildForecastDay(String day, IconData icon, String temp) { |
||||
|
return Column( |
||||
|
children: [ |
||||
|
Text( |
||||
|
day, |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.white.withOpacity(0.9), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 4), |
||||
|
Icon( |
||||
|
icon, |
||||
|
size: 20, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
SizedBox(height: 4), |
||||
|
Text( |
||||
|
temp, |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 动画点组件 |
||||
|
class _AnimatedDot extends StatefulWidget { |
||||
|
final int delayMilliseconds; |
||||
|
|
||||
|
const _AnimatedDot({required this.delayMilliseconds}); |
||||
|
|
||||
|
@override |
||||
|
_AnimatedDotState createState() => _AnimatedDotState(); |
||||
|
} |
||||
|
|
||||
|
class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderStateMixin { |
||||
|
late AnimationController _controller; |
||||
|
late Animation<double> _animation; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
|
||||
|
_controller = AnimationController( |
||||
|
vsync: this, |
||||
|
duration: const Duration(milliseconds: 1500), |
||||
|
); |
||||
|
|
||||
|
_animation = Tween<double>(begin: 0, end: 6).animate( |
||||
|
CurvedAnimation( |
||||
|
parent: _controller, |
||||
|
curve: Interval(0.0, 0.5, curve: Curves.easeOut), |
||||
|
reverseCurve: Interval(0.5, 1.0, curve: Curves.easeIn), |
||||
|
), |
||||
|
)..addListener(() { |
||||
|
setState(() {}); |
||||
|
}); |
||||
|
|
||||
|
Future.delayed(Duration(milliseconds: widget.delayMilliseconds), () { |
||||
|
if (mounted) { |
||||
|
_controller.repeat(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_controller.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Container( |
||||
|
width: 6, |
||||
|
height: 6, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(3), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.white.withOpacity(0.5), |
||||
|
blurRadius: _animation.value, |
||||
|
spreadRadius: _animation.value / 2, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,69 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
|
||||
|
class AnimatedGradientBackground extends StatefulWidget { |
||||
|
final Widget child; |
||||
|
|
||||
|
const AnimatedGradientBackground(this.child, {super.key}); |
||||
|
|
||||
|
@override |
||||
|
State<AnimatedGradientBackground> createState() => |
||||
|
_AnimatedGradientBackgroundState(); |
||||
|
} |
||||
|
|
||||
|
class _AnimatedGradientBackgroundState extends State<AnimatedGradientBackground> |
||||
|
with SingleTickerProviderStateMixin { |
||||
|
late AnimationController _controller; |
||||
|
late Animation<Alignment?> _beginAnimation; |
||||
|
late Animation<Alignment?> _endAnimation; |
||||
|
|
||||
|
final Color color1 = const Color(0xFFfbcfdd); |
||||
|
final Color color2 = const Color(0xFFe2e9fb); |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
|
||||
|
_controller = AnimationController( |
||||
|
vsync: this, |
||||
|
duration: const Duration(seconds: 10), |
||||
|
)..repeat(reverse: true); |
||||
|
|
||||
|
// 创建起始点动画 |
||||
|
_beginAnimation = AlignmentTween( |
||||
|
begin: Alignment.topCenter, |
||||
|
end: Alignment.bottomLeft, |
||||
|
).animate(_controller); |
||||
|
|
||||
|
// 创建结束点动画 |
||||
|
_endAnimation = AlignmentTween( |
||||
|
begin: Alignment.bottomCenter, |
||||
|
end: Alignment.topRight, |
||||
|
).animate(_controller); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_controller.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return AnimatedBuilder( |
||||
|
animation: _controller, |
||||
|
builder: (context, child) { |
||||
|
return Container( |
||||
|
decoration: BoxDecoration( |
||||
|
gradient: LinearGradient( |
||||
|
colors: [color1, color2], |
||||
|
begin: _beginAnimation.value!, |
||||
|
end: _endAnimation.value!, |
||||
|
), |
||||
|
), |
||||
|
child: child, |
||||
|
); |
||||
|
}, |
||||
|
child: widget.child, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue