diff --git a/apps/client/GoogleService-Info.plist b/apps/client/GoogleService-Info.plist deleted file mode 100644 index 05a2b5ce..00000000 --- a/apps/client/GoogleService-Info.plist +++ /dev/null @@ -1,30 +0,0 @@ - - - - - API_KEY - AIzaSyBEQPL-2Qh6fERxrxLP9Pzcm95mTmTpBxE - GCM_SENDER_ID - 733281995296 - PLIST_VERSION - 1 - BUNDLE_ID - com.yimai.eaimar - PROJECT_ID - voitrans - STORAGE_BUCKET - voitrans.firebasestorage.app - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:733281995296:ios:6a8351691312100bd70a45 - - \ No newline at end of file diff --git a/apps/client/SpeexKit.xcframework/Info.plist b/apps/client/SpeexKit.xcframework/Info.plist deleted file mode 100644 index c654fe5c..00000000 --- a/apps/client/SpeexKit.xcframework/Info.plist +++ /dev/null @@ -1,27 +0,0 @@ - - - - - AvailableLibraries - - - BinaryPath - SpeexKit.framework/SpeexKit - LibraryIdentifier - ios-arm64 - LibraryPath - SpeexKit.framework - SupportedArchitectures - - arm64 - - SupportedPlatform - ios - - - CFBundlePackageType - XFWK - XCFrameworkFormatVersion - 1.0 - - diff --git a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/OpusTools.h b/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/OpusTools.h deleted file mode 100644 index 55fa7f1e..00000000 --- a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/OpusTools.h +++ /dev/null @@ -1,44 +0,0 @@ -// -// OpusTools.h -// SpeexKit -// -// Created by EzioChan on 2024/1/17. -// Copyright © 2024 www.zh-jieli.com. All rights reserved. -// - -#import - - -NS_ASSUME_NONNULL_BEGIN - -@protocol OpusToolsDelegate - --(void)opusToolsPcmData:(NSData*)data; - --(void)opusToolsPcmFile:(NSData *)data; - --(void)opusToolsOpusData:(NSData *)data; - --(void)opusToolsOpusFile:(NSData *)data; - -@end - -@interface OpusTools : NSObject - -@property(nonatomic,weak)id delegate; - --(void)opusDecodeInfo:(int)sampleRate Channel:(int)channel frameSize:(int) fs isHasHead:(BOOL)head; - --(void)opusEncodeInfo:(int)sampleRate Channel:(int)channel frameSize:(int)fs isHasHead:(BOOL)head; - --(void)decodeData:(NSData*)data; - --(void)decodeFile:(NSString*)path; - --(void)encodeData:(NSData*)data; - --(void)encodeFile:(NSString*)path; - -@end - -NS_ASSUME_NONNULL_END diff --git a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/OpusUnit.h b/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/OpusUnit.h deleted file mode 100644 index 37b12201..00000000 --- a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/OpusUnit.h +++ /dev/null @@ -1,100 +0,0 @@ -// -// OpusUnit.h -// QCY_Demo -// -// Created by 杰理科技 on 2021/7/5. -// Copyright © 2021 杰理科技. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -/** - * 通知:流式编码回调Opus数据 - * 通知数据类型:NSData(Opus) - */ -extern NSString *kOPUS_ENCODE_DATA; -/** - * 通知:流式解码回调PCM数据 - * 通知数据类型:NSData(PCM) - */ -extern NSString *kOPUS_DECODE_DATA; - - -@interface OpusUnit : NSObject - -+ (void)opusIsLog:(BOOL)log; - -#pragma mark - Opus参数 -/** - * 设置参数 - * @param rate 采样率 例如:48000、24000、16000、12000、8000(默认 16000) - * @param frameRate 帧长度 - * 16k :40 - * 32K :80 - * 64k :160 - * 96k :320 - * 128k:640 - * @param channel 声道 例如:1、2 (默认 1) - * @param isHeader 是否带头 - */ -+(void)opusSetSampleRate:(int)rate frameRate:(int)frameRate Channels:(int)channel isHeader:(BOOL)isHeader; - -#pragma mark - Opus解码 - -/** - * 直接【opus文件】转换成【pcm文件】 - * @param path_opus opus文件路径 - * @param path_pcm pcm文件路径 - */ -+ (int)opusDecodeOPUS:(NSString *)path_opus PCM:(NSString *)path_pcm; - -/** - * 流式解码【开启】 - * 注意:需要异步调用,放在工程单利类中,切勿频繁开启。 - */ -+ (int)opusDecoderRun; - -/** - * 输入Opus的数据 - * 通知监听“kOPUS_DECODE_DATA”获得解码后数据 - * 通知数据类型:NSNotification.object => NSData(PCM格式) - * @param data opus数据流(长度:1024) - */ -+ (void)opusWriteData:(NSData*)data; - -/** - * 流式解码【关闭】 - */ -+ (int)opusDecoderStop; - -#pragma mark - Opus编码 - -/** - * 【pcm文件】转换成【opus文件】 - */ -+ (int)opusEncodePCM:(NSString *)path_pcm OPUS:(NSString *)path_opus; - -/** - * 流式编码【开启】 - * 注意:需要异步调用,放在工程单利类中,切勿频繁开启。 - */ -+ (int)opusEncoderRun; - -/** - * 输入pcm的数据 - * 通知监听“kOPUS_DECODE_DATA”获得解码后数据 - * 通知数据类型:NSNotification.object => NSData(opus格式) - * @param data opus数据流(长度:1024) - */ -+ (void)pcmWriteData:(NSData*)data; - -/** - * 流式编码【关闭】 - */ -+ (int)opusEncoderStop; - -@end - -NS_ASSUME_NONNULL_END diff --git a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/SpeexKit.h b/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/SpeexKit.h deleted file mode 100644 index 0fb1c92b..00000000 --- a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/SpeexKit.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// SpeexKit.h -// SpeexKit -// -// Created by zhihui liang on 2018/8/20. -// Copyright © 2018年 www.zh-jieli.com. All rights reserved. -// - -#import - -//! Project version number for SpeexKit. -FOUNDATION_EXPORT double SpeexKitVersionNumber; - -//! Project version string for SpeexKit. -FOUNDATION_EXPORT const unsigned char SpeexKitVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - -#import -#import -#import diff --git a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/SpeexUnit.h b/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/SpeexUnit.h deleted file mode 100644 index da7e93da..00000000 --- a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Headers/SpeexUnit.h +++ /dev/null @@ -1,40 +0,0 @@ -// -// SpeexUnit.h -// SpeexKit -// -// Created by zhihui liang on 2018/8/20. -// Copyright © 2018年 www.zh-jieli.com. All rights reserved. -// - -#import - -/** - * 流式解码回调PCM数据 - */ -extern NSString *kSPEEX_DECODE_DATA; //speex decode - -@interface SpeexUnit : NSObject - -+(void)speexIsLog:(BOOL)log; - -/** - * 直接【speex文件】转换成【pcm文件】 - */ -+(int)speexDecodeSPX:(NSString *)path_spx PCM:(NSString *)path_pcm; - -/** - * 流式解码【开启】 - */ -+(int)speexDecoderRun; - -/** - * 输入Speex的数据 - */ -+(void)speexWriteData:(NSData*)data; - -/** - * 流式解码【关闭】 - */ -+(int)speexDecoderStop; - -@end diff --git a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Info.plist b/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Info.plist deleted file mode 100644 index f1478bd9..00000000 Binary files a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Info.plist and /dev/null differ diff --git a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Modules/module.modulemap b/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Modules/module.modulemap deleted file mode 100644 index db8a3680..00000000 --- a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/Modules/module.modulemap +++ /dev/null @@ -1,6 +0,0 @@ -framework module SpeexKit { - umbrella header "SpeexKit.h" - export * - - module * { export * } -} diff --git a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/SpeexKit b/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/SpeexKit deleted file mode 100644 index 194c091f..00000000 Binary files a/apps/client/SpeexKit.xcframework/ios-arm64/SpeexKit.framework/SpeexKit and /dev/null differ diff --git a/apps/client/android/app/src/main/AndroidManifest.xml b/apps/client/android/app/src/main/AndroidManifest.xml index 816b5935..b7c37f8b 100644 --- a/apps/client/android/app/src/main/AndroidManifest.xml +++ b/apps/client/android/app/src/main/AndroidManifest.xml @@ -112,8 +112,6 @@ android:name="androidx.work.impl.diagnostics.DiagnosticsReceiver" tools:node="remove" /> - - - 3829022037361855 - fb3829022037361855 - 193d0a2b1757c41cfa557ea8e8f21c33 \ No newline at end of file diff --git a/apps/client/android/build.gradle.kts b/apps/client/android/build.gradle.kts index d86e79d1..6359ffd1 100644 --- a/apps/client/android/build.gradle.kts +++ b/apps/client/android/build.gradle.kts @@ -4,10 +4,6 @@ allprojects { google() mavenCentral() maven { url = uri("https://storage.googleapis.com/download.flutter.io") } - // 添加火山引擎Maven仓库 - maven { - url = uri("https://artifact.bytedance.com/repository/Volcengine/") - } maven { url = uri("https://maven.pkg.jetbrains.space/public/p/ktor/eap") } // 修改为绝对路径,避免相对路径计算错误 diff --git a/apps/client/android/settings.gradle.kts b/apps/client/android/settings.gradle.kts index 7984d0cc..2571d00c 100644 --- a/apps/client/android/settings.gradle.kts +++ b/apps/client/android/settings.gradle.kts @@ -39,7 +39,6 @@ include(":classic_bluetooth") include(":agent_service") include(":speech") include(":chat_api") -include(":bytedance_speech") include(":qq_music") // 添加这行 include(":bluetooth_manager") // 恒玄(BES)经典蓝牙连接 + G722/mSBC 音频编解码 @@ -51,6 +50,5 @@ project(":classic_bluetooth").projectDir = file("../local_plugins/classic_blueto project(":agent_service").projectDir = file("../local_plugins/agent_service/android") project(":speech").projectDir = file("../local_plugins/speech/android") project(":chat_api").projectDir = file("../local_plugins/chat_api/android") -project(":bytedance_speech").projectDir = file("../local_plugins/bytedance_speech/android") project(":qq_music").projectDir = file("../local_plugins/qq_music/android") // 添加这行 project(":bluetooth_manager").projectDir = file("../local_plugins/bluetooth_manager/android") diff --git a/apps/client/assets/config/GoogleService-Info.plist b/apps/client/assets/config/GoogleService-Info.plist deleted file mode 100644 index 05a2b5ce..00000000 --- a/apps/client/assets/config/GoogleService-Info.plist +++ /dev/null @@ -1,30 +0,0 @@ - - - - - API_KEY - AIzaSyBEQPL-2Qh6fERxrxLP9Pzcm95mTmTpBxE - GCM_SENDER_ID - 733281995296 - PLIST_VERSION - 1 - BUNDLE_ID - com.yimai.eaimar - PROJECT_ID - voitrans - STORAGE_BUCKET - voitrans.firebasestorage.app - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:733281995296:ios:6a8351691312100bd70a45 - - \ No newline at end of file diff --git a/apps/client/ios/Runner/Info.plist b/apps/client/ios/Runner/Info.plist index f4cb5025..980c66a8 100644 --- a/apps/client/ios/Runner/Info.plist +++ b/apps/client/ios/Runner/Info.plist @@ -24,12 +24,6 @@ ???? CFBundleURLTypes - - CFBundleURLSchemes - - fb3829022037361855 - - CFBundleTypeRole Editor @@ -65,14 +59,6 @@ CFBundleVersion $(FLUTTER_BUILD_NUMBER) - FacebookAdvertiserIDCollectionEnabled - - FacebookAppID - 3829022037361855 - FacebookAutoLogAppEventsEnabled - - FacebookClientToken - 193d0a2b1757c41cfa557ea8e8f21c33 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes @@ -83,21 +69,6 @@ weixin weixinULAPI weixinURLParamsAPI - fbapi - fbapi20130214 - fbapi20130410 - fbapi20130702 - fbapi20131010 - fbapi20131219 - fbapi20140410 - fbapi20140116 - fbapi20150313 - fbapi20150629 - fbapi20160328 - fbauth - fb-messenger-share-api - fbauth2 - fbshareextension google.navigation googlemaps comgooglemaps @@ -170,8 +141,6 @@ Location access enables the app to provide location-based voice translation services, such as translating local place names and providing contextual information relevant to your current location during conversations. NSMicrophoneUsageDescription Our app's voice AI interaction features (such as voice wake-up for AI, issuing commands, and real-time conversations) require microphone access. With this permission, you can interact with AI directly through speech, improving operational efficiency. We strictly protect your voice data—only used for real-time processing of interaction requests, and raw audio will not be stored. - NSMotionUsageDescription - This app uses motion and fitness data to detect when you're walking or moving, which helps optimize voice recognition accuracy and automatically adjust microphone sensitivity for better audio recording quality during different activities. NSPhotoLibraryAddUsageDescription Photo library permission is required to save exported images (mind maps, statistics) to your album NSPhotoLibraryUsageDescription diff --git a/apps/client/local_plugins/agent_service/android/build.gradle.kts b/apps/client/local_plugins/agent_service/android/build.gradle.kts index 958edf9f..fc1a06f6 100644 --- a/apps/client/local_plugins/agent_service/android/build.gradle.kts +++ b/apps/client/local_plugins/agent_service/android/build.gradle.kts @@ -51,7 +51,6 @@ dependencies { add("compileOnly", project(":speech")) add("compileOnly", project(":chat_api")) add("compileOnly", project(":location_service")) - add("compileOnly", project(":bytedance_speech")) add("compileOnly", project(":user_portrait")) add("compileOnly", project(":qq_music")) diff --git a/apps/client/local_plugins/agent_service/ios/agent_service/Package.swift b/apps/client/local_plugins/agent_service/ios/agent_service/Package.swift index 413e98de..e2038122 100644 --- a/apps/client/local_plugins/agent_service/ios/agent_service/Package.swift +++ b/apps/client/local_plugins/agent_service/ios/agent_service/Package.swift @@ -8,7 +8,6 @@ let package = Package( .library(name: "agent-service", targets: ["agent_service"]) ], dependencies: [ - .package(path: "../../../test/ios/test"), .package(path: "../../../ble_service/ios/ble_service"), .package(path: "../../../speech/ios/speech"), .package(path: "../../../azure_speech/ios/azure_speech"), @@ -23,7 +22,6 @@ let package = Package( .target( name: "agent_service", dependencies: [ - .product(name: "test", package: "test"), .product(name: "ble-service", package: "ble_service"), .product(name: "speech", package: "speech"), .product(name: "azure-speech", package: "azure_speech"), diff --git a/apps/client/local_plugins/agent_service/lib/agent_service.dart b/apps/client/local_plugins/agent_service/lib/agent_service.dart index 06c1c1dd..801258e9 100644 --- a/apps/client/local_plugins/agent_service/lib/agent_service.dart +++ b/apps/client/local_plugins/agent_service/lib/agent_service.dart @@ -218,65 +218,6 @@ class AgentService { } } - /// 初始化代理服务 - /// - /// [config] 初始化配置,应包含以下参数: - /// - azureSpeechKey: Azure语音服务密钥 - /// - azureSpeechRegion: Azure语音服务区域 - /// - openaiApiKey: OpenAI API密钥 - /// - openaiBaseUrl: (可选) OpenAI API 基础URL - /// - openaiModel: (可选) OpenAI模型名称,默认为"gpt-3.5-turbo" - /// - mcpServer: (可选) MCP服务器地址 - /// - systemPrompt: (可选) 自定义系统提示词,用于设置AI语音助手的行为和风格 - /// - /// 返回是否初始化成功 - static Future initialize(Map config) async { - try { - final bool result = await _channel.invokeMethod('initialize', { - 'config': config, - }); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '初始化失败', e.details); - } - } - - /// 启动语音助手服务 - /// - /// 启动常驻的语音助手服务,支持后台蓝牙设备唤醒和媒体按钮唤醒 - /// [config] 配置参数,应包含以下参数: - /// - azureSpeechKey: Azure语音服务密钥 - /// - azureSpeechRegion: Azure语音服务区域 - /// - openaiApiKey: OpenAI API密钥 - /// - openaiBaseUrl: (可选) OpenAI API 基础URL - /// - openaiModel: (可选) OpenAI模型名称,默认为"gpt-3.5-turbo" - /// - mcpServer: (可选) MCP服务器地址 - /// - /// 返回是否成功启动服务 - static Future startAgentService(Map config) async { - try { - final bool result = await _channel.invokeMethod('startAgentService', { - 'config': config, - }); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '启动语音助手服务失败', e.details); - } - } - - /// 停止语音助手服务 - /// - /// 停止常驻的语音助手服务 - /// 返回是否成功停止服务 - static Future stopAgentService() async { - try { - final bool result = await _channel.invokeMethod('stopAgentService'); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '停止语音助手服务失败', e.details); - } - } - /// 开始对话 /// /// 启动语音识别,开始监听用户语音输入 @@ -317,22 +258,6 @@ class AgentService { } } - /// 推送外部 PCM 音频数据到识别引擎 - /// - /// 仅在通过 [startConversation] 设置 `isExternalActive = true` 时有效。 - /// PCM 格式要求:16bit signed, little-endian, 1 channel, 16000 Hz。 - static Future pushAudioData(Uint8List data) async { - if (data.isEmpty) return false; - try { - final bool result = await _channel.invokeMethod('pushAudioData', { - 'data': data, - }); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '推送音频数据失败', e.details); - } - } - static Future isInterrupt(bool isInterrupt) async { try { final bool result = await _channel.invokeMethod('isInterrupt', { @@ -344,46 +269,6 @@ class AgentService { } } - /// 设备识别器 - /// - /// 启动语音识别,开始监听用户语音输入 - /// 返回是否成功开始对话 - static Future recognizeCallback() async { - try { - final bool result = await _channel.invokeMethod('recognizeCallback'); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '设置识别器失败', e.details); - } - } - - /// 恢复原始音频状态 - /// - /// 恢复原始音频状态 - /// 返回是否成功恢复原始音频状态 - static Future restoreOriginalAudioState() async { - try { - final bool result = - await _channel.invokeMethod('restoreOriginalAudioState'); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '恢复原始音频状态失败', e.details); - } - } - - /// 禁用蓝牙音频 - /// - /// 禁用蓝牙音频 - /// 返回是否成功禁用蓝牙音频 - static Future disableBluetoothAudio() async { - try { - final bool result = await _channel.invokeMethod('disableBluetoothAudio'); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '禁用蓝牙音频失败', e.details); - } - } - /// 停止对话 /// /// 停止语音识别 @@ -428,71 +313,6 @@ class AgentService { // } // } - /// 处理文本输入 - /// - /// [text] 文本内容 - /// [speakResponse] 是否朗读响应 - /// 返回是否成功处理文本 - static Future processTextInput(String sessionid, String text, - {bool speakResponse = false}) async { - try { - final bool result = await _channel.invokeMethod('processTextInput', { - 'sessionid': sessionid, - 'text': text, - 'speakResponse': speakResponse, - }); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '处理文本输入失败', e.details); - } - } - - /// 朗读文本 - /// - /// [text] 要朗读的文本 - static Future speakText(String text) async { - try { - final bool result = await _channel.invokeMethod('speakText', { - 'text': text, - }); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '语音合成失败', e.details); - } - } - - /// 设置TTS语速 - static Future setTtsRate(int rate) async { - try { - final bool result = await _channel.invokeMethod('setTtsRate', { - 'rate': rate, - }); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '设置TTS语速失败', e.details); - } - } - - /// 停止语音合成 - static Future stopAiSteam() async { - try { - final bool result = await _channel.invokeMethod('stopAiSteam'); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '停止语音合成失败', e.details); - } - } - - /// 停止语音合成 - static Future stopTts() async { - try { - final bool result = await _channel.invokeMethod('stopTts'); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '停止语音合成失败', e.details); - } - } - /// 清除聊天历史 static Future clearChatHistory() async { try { @@ -547,30 +367,6 @@ class AgentService { } } - /// 处理图片输入 - /// - /// [imagePath] 图片文件路径 - /// [text] 可选的文本描述或问题 - /// [imageUrl] 图片url - /// [speakResponse] 是否朗读响应 - /// 返回是否成功处理图片 - static Future processImageInput( - String sessionid, String imagePath, String imageUrl, - {String text = "", bool speakResponse = false}) async { - try { - final bool result = await _channel.invokeMethod('processImageInput', { - 'sessionid': sessionid, - 'imagePath': imagePath, - 'imageUrl': imageUrl, - 'text': text, - 'speakResponse': speakResponse, - }); - return result; - } on PlatformException catch (e) { - throw AgentServiceException(e.code, e.message ?? '处理图片输入失败', e.details); - } - } - /// 设置支持的语音识别语言 /// /// [languages] 支持的语言列表,如 ["zh-CN", "en-US"] diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 667ada40..0ec7ce8b 100644 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -138,13 +138,6 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, } } - // 翻译(Volcano)相关 - private lateinit var translationChannel: MethodChannel - private lateinit var translationEventChannel: EventChannel - private var translationEventSink: EventChannel.EventSink? = null - private var microsoftTranslationService: MicrosoftTranslationServiceImpl? = null - private var microsoftTranslationAndTtsService: MicrosoftTranslationAndTtsService? = null - private var translationRestProvider: String = "microsoft" // 双向 AST 服务:A 和 B private var azureAstHelperA: IntegratedSpeechTranslationService? = null @@ -497,26 +490,6 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, } // 翻译事件发送方法 - /** - * 发送翻译事件到 Flutter 层 - * 参数通过 `Map` 传递,包含: - * - type: 事件类型,如 `translationStarted`、`translated`、`translationFailed` - * - 其他上下文字段:`text`、`originalText`、`translatedText`、`sourceLanguage`、`targetLanguage`、`error` 等 - */ - private fun sendTranslationEvent(event: Map) { - if (translationEventSink == null) { - FileLogger.w(tag, "无法发送翻译事件:事件通道未准备好") - return - } - - mainHandler.post { - try { - translationEventSink?.success(event) - } catch (e: Exception) { - FileLogger.e(tag, "发送翻译事件失败: ${e.message}") - } - } - } @@ -550,11 +523,6 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, astChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "azure_speech/ast") astChannel.setMethodCallHandler(AsTMethodHandler()) - // 初始化翻译通道 - translationChannel = - MethodChannel(flutterPluginBinding.binaryMessenger, "azure_speech/translation") - translationChannel.setMethodCallHandler(TranslationMethodHandler()) - // 初始化ASR事件通道 asrEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/asr_events") @@ -633,18 +601,6 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, handleBailianDialogCall(call, result) } - // 初始化翻译事件通道 - translationEventChannel = - EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/translation_events") - translationEventChannel.setStreamHandler(object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - translationEventSink = events - } - - override fun onCancel(arguments: Any?) { - translationEventSink = null - } - }) // 初始化音频数据事件通道 audioDataEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/audio_data_events") @@ -691,7 +647,6 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, doubaoAstHelperB = DoubaoE2ETranslateHelper(context) bailianAstHelperA = AliyunBailianE2EHelper(context) bailianAstHelperB = AliyunBailianE2EHelper(context) - microsoftTranslationService = MicrosoftTranslationServiceImpl() // 2. 初始化BleService并注册回调 if (BleService.initialize(context)) { @@ -1945,263 +1900,14 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, } // 翻译方法处理器(Volcano/Microsoft REST) - /** - * 翻译方法通道处理器 - * 提供: - * - initialize(accessKey, secretKey, region, maxRetryAttempts, timeout) - * - translateText(text, sourceLanguage, targetLanguage) - * - dispose() - */ - inner class TranslationMethodHandler : MethodCallHandler { - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - when (call.method) { - "initialize" -> { - /** - * 初始化翻译服务 - * provider: volcano/microsoft(默认 microsoft) - * 语音翻译已统一为「千问端到端 + 微软三段式」, - * REST 机器翻译的默认提供商随之从 volcano 改为 microsoft。 - * volcano入参:`accessKey`、`secretKey`、`region`、`maxRetryAttempts`、`timeout` - * microsoft入参:`subscriptionKey`/`accessKey`、`region`/`location`、`endpoint?`、`maxRetryAttempts?`、`timeout?` - */ - val provider = (call.argument("provider") ?: "microsoft").lowercase() - translationRestProvider = provider - - try { - // 火山已下线,REST 机器翻译统一走微软。 - // 这里刻意不再按 provider 分支——老客户端仍可能传 provider=volcano, - // 分支删掉后若不兜底会走不到 result.success,Dart 侧永久挂起。 - run { - val subscriptionKey = call.argument("subscriptionKey") - ?: call.argument("accessKey") - ?: "" - val location = call.argument("location") - val region = call.argument("region") ?: (location ?: "") - val endpoint = call.argument("endpoint") - ?: "https://api.cognitive.microsofttranslator.com" - val maxRetryAttempts = - call.argument("maxRetryAttempts")?.toInt() ?: 3 - val timeout = call.argument("timeout")?.toLong() ?: 10000L - - val svc = microsoftTranslationService - ?: MicrosoftTranslationServiceImpl().also { - microsoftTranslationService = it - } - kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val success = svc.initialize( - mapOf( - "subscriptionKey" to subscriptionKey, - "region" to region, - "endpoint" to endpoint, - "maxRetryAttempts" to maxRetryAttempts.toString(), - "timeout" to timeout.toString() - ) - ) - mainHandler.post { result.success(success) } - } - } - } catch (e: Exception) { - result.error("TRANSLATION_INIT_ERROR", e.message, null) - } - } - - "translateText" -> { - /** - * 文本翻译 - * 入参:`text` 源文本、`sourceLanguage` 源语言、`targetLanguage` 目标语言 - * 流程:先发送 `translationStarted` 事件,完成后发送 `translated` 或 `translationFailed` - */ - val text = call.argument("text") ?: return result.error( - "INVALID_ARGUMENTS", - "text不能为空", - null - ) - val sourceLanguage = call.argument("sourceLanguage") - ?: return result.error("INVALID_ARGUMENTS", "sourceLanguage不能为空", null) - val targetLanguage = call.argument("targetLanguage") - ?: return result.error("INVALID_ARGUMENTS", "targetLanguage不能为空", null) - Log.d( - tag, - "translateText: text=$text, sourceLanguage=$sourceLanguage, targetLanguage=$targetLanguage" - ) - if (translationEventSink == null) { - return result.error( - "EVENT_CHANNEL_NOT_READY", - "事件通道未准备好,无法执行翻译", - null - ) - } - Log.d(tag, "translateText: 发送 translationStarted 事件") - val provider = - (call.argument("provider") ?: translationRestProvider).lowercase() - Log.d(tag, "translateText: provider=$provider") - val svc = microsoftTranslationService - ?: MicrosoftTranslationServiceImpl().also { - microsoftTranslationService = it - } - microsoftTranslationService ?: MicrosoftTranslationServiceImpl().also { - microsoftTranslationService = it - } - sendTranslationEvent( - mapOf( - "type" to "translationStarted", - "text" to text, - "sourceLanguage" to sourceLanguage, - "targetLanguage" to targetLanguage - ) - ) - - kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val res = svc.translateText(text, sourceLanguage, targetLanguage) - val event = if (res.success) { - mapOf( - "type" to "translated", - "originalText" to text, - "translatedText" to (res.translatedText ?: ""), - "targetLanguage" to targetLanguage - ) - } else { - mapOf( - "type" to "translationFailed", - "text" to text, - "error" to (res.error ?: "未知错误") - ) - } - sendTranslationEvent(event) - mainHandler.post { result.success(res.success) } - } - } - - "initialize1" -> { - val speechSubscriptionKey = call.argument("speechSubscriptionKey") - ?: call.argument("subscriptionKey") - ?: "" - val speechRegion = call.argument("speechRegion") - ?: call.argument("region") - ?: "" - - val translationSubscriptionKey = - call.argument("translationSubscriptionKey") - ?: speechSubscriptionKey - val translationRegion = call.argument("translationRegion") - ?: speechRegion - - FileLogger.d( - tag, - "initialize1 called, speechRegion=$speechRegion, translationRegion=$translationRegion, " + - "speechSubscriptionKey=${speechSubscriptionKey}, translationSubscriptionKey=${translationSubscriptionKey}" - ) - - val svc = microsoftTranslationAndTtsService - ?: MicrosoftTranslationAndTtsService().also { - microsoftTranslationAndTtsService = it - } - - kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val config = MicrosoftTranslationAndTtsService.ServiceConfig( - speechSubscriptionKey = speechSubscriptionKey, - speechRegion = speechRegion, - translationSubscriptionKey = translationSubscriptionKey, - translationRegion = translationRegion - ) - val success = svc.initialize(config) - mainHandler.post { result.success(success) } - } - } - - "process" -> { - val text = call.argument("text") ?: return result.error( - "INVALID_ARGUMENTS", - "text不能为空", - null - ) - val sourceLanguage = call.argument("sourceLanguage") - ?: return result.error("INVALID_ARGUMENTS", "sourceLanguage不能为空", null) - val targetLanguage = call.argument("targetLanguage") - ?: return result.error("INVALID_ARGUMENTS", "targetLanguage不能为空", null) - val targetTTSLanguage = call.argument("targetTTSLanguage") - ?: return result.error( - "INVALID_ARGUMENTS", - "targetTTSLanguage不能为空", - null - ) - FileLogger.d( - tag, - "process called, textLength=${text.length}, source=$sourceLanguage, target=$targetLanguage, targetTTSLanguage=$targetTTSLanguage" - ) - - val svc = microsoftTranslationAndTtsService - if (svc == null) { - return result.error("SERVICE_NOT_INITIALIZED", "请先调用 initialize", null) - } - - // 使用应用files目录下的 tts_output 子目录 - val outputDir = java.io.File(context.filesDir, "tts_output") - - val cachedFile = java.io.File(outputDir, "$targetLanguage.wav") - if (cachedFile.exists() && cachedFile.isFile) { - FileLogger.d(tag, "process cache hit, filePath=${cachedFile.absolutePath}") - result.success(cachedFile.absolutePath) - return - } - - kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { - val filePath = svc.processAndSaveAudio( - text, - sourceLanguage, - targetLanguage, - targetTTSLanguage, - outputDir - ) - mainHandler.post { - if (filePath != null) { - FileLogger.d(tag, "process success, filePath=$filePath") - result.success(filePath) - } else { - FileLogger.e(tag, "process failed, filePath is null") - result.error("PROCESS_FAILED", "处理失败", null) - } - } - } - } - - "dispose1" -> { - FileLogger.d( - tag, - "dispose1 called, releasing MicrosoftTranslationAndTtsService" - ) - microsoftTranslationAndTtsService?.dispose() - result.success(true) - } - - "dispose" -> { - /** - * 释放翻译服务资源 - */ - try { - microsoftTranslationService?.dispose() - result.success(true) - } catch (e: Exception) { - result.error("TRANSLATION_DISPOSE_ERROR", e.message, null) - } - } - - else -> result.notImplemented() - } - } - } - - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { asrChannel.setMethodCallHandler(null) ttsChannel.setMethodCallHandler(null) astChannel.setMethodCallHandler(null) - translationChannel.setMethodCallHandler(null) asrEventChannel.setStreamHandler(null) ttsEventChannel.setStreamHandler(null) astEventChannel.setStreamHandler(null) if (::astTtsEventChannel.isInitialized) astTtsEventChannel.setStreamHandler(null) - translationEventChannel.setStreamHandler(null) try { if (isRegisteredToBle) { diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/MicrosoftTranslationAndTtsService.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/MicrosoftTranslationAndTtsService.kt deleted file mode 100644 index c1fcaec3..00000000 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/MicrosoftTranslationAndTtsService.kt +++ /dev/null @@ -1,189 +0,0 @@ -package com.yunqiinnovation.azure_speech - -import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream -import java.util.UUID - -/** - * 微软翻译与TTS综合服务 - * - * 功能: - * 1. 接收输入文本、源语言代码、目标语言代码 - * 2. 调用微软翻译服务将文本翻译为目标语言 - * 3. 调用微软TTS服务将翻译后的文本合成为语音 - * 4. 将合成的音频保存为 WAV 文件,文件名为 "{targetLanguage}.wav" - */ -class MicrosoftTranslationAndTtsService { - - companion object { - private const val TAG = "MsTransAndTtsService" - } - - private val translationService = MicrosoftTranslationServiceImpl() - private val ttsService = MicrosoftTTSServiceImpl() - - private var serviceConfig: ServiceConfig? = null - - /** - * 服务配置 - * @param speechSubscriptionKey Azure 语音服务订阅密钥(用于 TTS) - * @param speechRegion Azure 语音服务区域 (例如 "eastus") - * @param translationSubscriptionKey Azure 翻译服务订阅密钥(用于文本翻译) - * @param translationRegion Azure 翻译服务区域 - */ - data class ServiceConfig( - val speechSubscriptionKey: String, - val speechRegion: String, - val translationSubscriptionKey: String = speechSubscriptionKey, - val translationRegion: String = speechRegion - ) - - /** - * 初始化服务 - * @param config 服务配置 - */ - suspend fun initialize(config: ServiceConfig): Boolean { - this.serviceConfig = config - - // 初始化翻译服务 - // 翻译服务支持的配置键: subscriptionKey, region - val transConfig = mapOf( - "subscriptionKey" to config.translationSubscriptionKey, - "region" to config.translationRegion - ) - - Log.d(TAG, "正在初始化翻译服务...") - val transInitSuccess = translationService.initialize(transConfig) - if (!transInitSuccess) { - Log.e(TAG, "翻译服务初始化失败") - return false - } - - Log.d(TAG, "服务初始化成功") - return true - } - - /** - * 执行 翻译 -> TTS -> 保存文件 流程 - * - * @param text 待翻译和合成的文本 - * @param sourceLanguage 源语言代码 (例如 "zh-CN") - * @param targetLanguage 翻译目标语言代码 (例如 "en-US") - * @param targetTTSLanguage 实际用于TTS的语言代码(可与翻译目标不同) - * @param outputDirectory 音频文件保存目录 - * @return 生成的音频文件绝对路径,如果失败则返回 null - */ - suspend fun processAndSaveAudio( - text: String, - sourceLanguage: String, - targetLanguage: String, - targetTTSLanguage: String, - outputDirectory: File - ): String? = withContext(Dispatchers.IO) { - val config = serviceConfig - if (config == null) { - Log.e(TAG, "服务尚未初始化,请先调用 initialize()") - return@withContext null - } - - // --- 步骤 1: 文本翻译 --- - Log.d(TAG, "开始翻译: text=$text, $sourceLanguage -> $targetLanguage") - val translationResult = translationService.translateText(text, sourceLanguage, targetLanguage) - - if (!translationResult.success || translationResult.translatedText.isNullOrEmpty()) { - Log.e(TAG, "翻译失败: ${translationResult.error}") - return@withContext null - } - - val translatedText = translationResult.translatedText!! - Log.i(TAG, "翻译完成: $translatedText") - - // --- 步骤 2: TTS 合成 --- - Log.d(TAG, "开始TTS合成...") - - // 根据上传的 TTS 语言代码优先配置 TTS,其次回退到翻译目标语言 - val ttsConfig = resolveTtsConfig(targetTTSLanguage, config) - - // 创建一个空的 Callback,因为我们主要依赖 synthesize 方法的返回值 - val ttsCallback = object : MicrosoftTTSServiceImpl.TtsCallback { - override fun onSynthesisStarted(utteranceId: String, text: String) {} - override fun onSynthesisProgress(utteranceId: String, text: String, progress: Float) {} - override fun onAudioChunkGenerated(utteranceId: String, text: String, audioChunk: ByteArray) {} - override fun onSynthesisCompleted(utteranceId: String, text: String, fullAudio: ByteArray?) {} - override fun onSynthesisFailed(utteranceId: String, text: String, error: String) { - Log.w(TAG, "TTS Callback 报告失败: $error") - } - override fun onError(message: String) { - Log.w(TAG, "TTS Callback 报告错误: $message") - } - } - - val ttsInitSuccess = ttsService.initialize(ttsConfig, ttsCallback) - if (!ttsInitSuccess) { - Log.e(TAG, "TTS 服务初始化失败") - return@withContext null - } - - val utteranceId = UUID.randomUUID().toString() - val audioData = ttsService.synthesize(utteranceId, translatedText) - - if (audioData == null || audioData.isEmpty()) { - Log.e(TAG, "TTS 合成返回空数据") - return@withContext null - } - Log.i(TAG, "TTS 合成成功,数据大小: ${audioData.size} bytes") - - // --- 步骤 3: 保存文件 --- - try { - if (!outputDirectory.exists()) { - outputDirectory.mkdirs() - } - - // 文件名为目标语言代码.wav - val fileName = "$targetLanguage.wav" - val outputFile = File(outputDirectory, fileName) - - FileOutputStream(outputFile).use { fos -> - fos.write(audioData) - fos.flush() - } - - Log.i(TAG, "音频文件已保存: ${outputFile.absolutePath}") - return@withContext outputFile.absolutePath - - } catch (e: Exception) { - Log.e(TAG, "保存音频文件失败", e) - return@withContext null - } - } - - /** - * 根据目标语言构建 TTS 配置 - * @param targetTTSLanguage 翻译目标语言代码(如 en、en-US、zh-Hans) - * @param config 服务配置(包含语音服务 key/region) - */ - private fun resolveTtsConfig( - targetTTSLanguage: String, - config: ServiceConfig - ): MicrosoftTTSServiceImpl.TtsConfig { - - return MicrosoftTTSServiceImpl.TtsConfig( - subscriptionKey = config.speechSubscriptionKey, - region = config.speechRegion, - targetLanguage = targetTTSLanguage, - voiceName = targetTTSLanguage, - enableAudioPlayback = false - ) - } - - /** - * 释放资源 - */ - fun dispose() { - translationService.dispose() - ttsService.dispose() - } -} diff --git a/apps/client/local_plugins/azure_speech/lib/main.dart b/apps/client/local_plugins/azure_speech/lib/main.dart deleted file mode 100644 index a7256585..00000000 --- a/apps/client/local_plugins/azure_speech/lib/main.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; - -void main() { - runApp(const MainApp()); -} - -class MainApp extends StatelessWidget { - const MainApp({super.key}); - - @override - Widget build(BuildContext context) { - return const MaterialApp( - home: Scaffold( - body: Center( - child: Text('Hello World!'), - ), - ), - ); - } -} diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/Info.plist b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/Info.plist deleted file mode 100644 index 21abfc01..00000000 --- a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/Info.plist +++ /dev/null @@ -1,27 +0,0 @@ - - - - - AvailableLibraries - - - BinaryPath - JLAudioUnitKit.framework/JLAudioUnitKit - LibraryIdentifier - ios-arm64 - LibraryPath - JLAudioUnitKit.framework - SupportedArchitectures - - arm64 - - SupportedPlatform - ios - - - CFBundlePackageType - XFWK - XCFrameworkFormatVersion - 1.0 - - diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLAudioUnitKit.h b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLAudioUnitKit.h deleted file mode 100644 index b7141b1b..00000000 --- a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLAudioUnitKit.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// JLAudioUnitKit.h -// JLAudioUnitKit -// -// Created by EzioChan on 2024/11/14. -// - -#import - -//! Project version number for JLAudioUnitKit. -FOUNDATION_EXPORT double JLAudioUnitKitVersionNumber; - -//! Project version string for JLAudioUnitKit. -FOUNDATION_EXPORT const unsigned char JLAudioUnitKitVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - -#import -#import -#import -#import -#import -#import -#import diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLAudioUnitPlayer.h b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLAudioUnitPlayer.h deleted file mode 100644 index 7da831c6..00000000 --- a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLAudioUnitPlayer.h +++ /dev/null @@ -1,58 +0,0 @@ -// -// JLAudioUnitPlayer.h -// JLAudioUnitKit -// -// Created by EzioChan on 2025/4/16. -// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_ENUM(NSInteger, JLAudioUnitPlayerType) { - JLAudioUnitPlayerTypeFile, // 支持 MP3/WAV/AAC 等文件格式(AVAudioPlayer) - JLAudioUnitPlayerTypePCM // 支持 PCM 流式播放(Audio Queue) -}; - -@class JLAudioUnitPlayer; - -@protocol JLAudioPlayerDelegate -@optional -// 播放进度更新(秒) -- (void)audioPlayer:(JLAudioUnitPlayer *)player didUpdateProgress:(NSTimeInterval)currentTime duration:(NSTimeInterval)duration; -// 播放完成 -- (void)audioPlayerDidFinishPlaying:(JLAudioUnitPlayer *)player; -// 播放错误 -- (void)audioPlayer:(JLAudioUnitPlayer *)player didFailWithError:(NSError *)error; -@end - -@interface JLAudioUnitPlayer : NSObject - -@property (nonatomic, weak) id delegate; -@property (nonatomic, readonly) NSTimeInterval duration; // 总时长(文件模式下有效) -@property (nonatomic, readonly) NSTimeInterval currentTime; -@property (nonatomic, readonly) BOOL isPlaying; - -#pragma mark - 初始化方法 - -/// 文件播放初始化(MP3/WAV/AAC) -- (instancetype)initWithAudioFile:(NSString *)filePath; - -/// PCM 流播放初始化(需指定格式) -- (instancetype)initWithPCMFormat:(AudioStreamBasicDescription)format; - -#pragma mark - 播放控制 -- (void)play; -- (void)pause; -- (void)stop; -- (void)seekToTime:(NSTimeInterval)time; // 仅文件模式有效 - -#pragma mark - PCM 流式输入(仅 PCM 模式有效) -- (void)appendPCMData:(NSData *)pcmData; -- (void)endPCMStream; // 结束 PCM 流输入 - -@end - -NS_ASSUME_NONNULL_END diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusDecoder.h b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusDecoder.h deleted file mode 100644 index 711e2e99..00000000 --- a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusDecoder.h +++ /dev/null @@ -1,66 +0,0 @@ -// -// JLOpusDecoder.h -// JLAudioUnitKit -// -// Created by EzioChan on 2024/11/14. -// Copyright © 2024 ZhuHai JieLi Technology Co.,Ltd. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN -@class JLOpusDecoder; - -/// Opus 解码代理 -@protocol JLOpusDecoderDelegate - -/// Opus 数据解码 -/// - Parameters: -/// - decoder: 解码器 -/// - data: pcm 数据 -/// - error: 错误信息 --(void)opusDecoder:(JLOpusDecoder *)decoder Data:(NSData* _Nullable)data error:(NSError* _Nullable)error; - -@end - -typedef void(^JLOpusDecoderConvertBlock)(NSString *_Nullable pcmPath,NSError *_Nullable error); - -/// Opus 解码 -@interface JLOpusDecoder : NSObject - -/// 数据格式参数 -@property (nonatomic, strong) JLOpusFormat *opusFormat; - -/// 代理委托 -@property(nonatomic, weak) id delegate; - -- (instancetype)init NS_UNAVAILABLE; - -/// 初始化 -/// - Parameters: -/// - format: 解码格式 -/// - delegate: 代理 -- (instancetype)initDecoder:(JLOpusFormat *)format delegate:(id)delegate; - -/// 重置解码格式 -/// - Parameter format: 解码格式 --(void)resetOpusFramet:(JLOpusFormat *)format; - -/// 输入 Opus 数据 -/// - Parameter data: Opus 数据 --(void)opusDecoderInputData:(NSData *)data; - -/// 解码文件 -/// - Parameters: -/// - input: opus 文件 -/// - outPut: 输出路径 -/// - result: 结果回调 --(void)opusDecodeFile:(NSString *)input outPut:(NSString *_Nullable)outPut Resoult:(JLOpusDecoderConvertBlock _Nullable)result; - -/// 释放 --(void)opusOnRelease; - -@end - -NS_ASSUME_NONNULL_END diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusEncoder.h b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusEncoder.h deleted file mode 100644 index ef823053..00000000 --- a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusEncoder.h +++ /dev/null @@ -1,62 +0,0 @@ -// -// JLOpusEncoder.h -// JLAudioUnitKit -// -// Created by EzioChan on 2024/11/14. -// Copyright © 2024 ZhuHai JieLi Technology Co.,Ltd. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN -@class JLOpusEncoder; - -/// Opus 编码代理 -@protocol JLOpusEncoderDelegate - -/// PCM 数据编码 -/// - Parameters: -/// - encoder: 解码器 -/// - data: opus 数据 -/// - error: 错误信息 --(void)opusEncoder:(JLOpusEncoder *)encoder Data:(NSData* _Nullable)data error:(NSError* _Nullable)error; - -@end - -typedef void(^JLOpusEncoderConvertBlock)(NSString *_Nullable pcmPath,NSError *_Nullable error); - -/// Opus 编码 -@interface JLOpusEncoder : NSObject - -/// 音频格式 -@property (nonatomic, strong) JLOpusFormat *opusFormat; - -/// 代理 -@property (nonatomic, weak) id delegate; - --(instancetype)init NS_UNAVAILABLE; - -/// 初始化 -/// - Parameters: -/// - format: 音频格式 -/// - delegate: 代理 --(instancetype)initFormat:(JLOpusFormat *)format delegate:(id)delegate; - -/// PCM 数据 -/// - Parameter data: PCM 数据 --(void)opusEncodeData:(NSData *)data; - -/// PCM 文件转换成 Opus 文件 -/// - Parameters: -/// - pcmPath: PCM 文件存放路径 -/// - outPut: 文件输出路径 -/// - result: 结果回调 --(void)opusEncodeFile:(NSString *)pcmPath outPut:(NSString *_Nullable)outPut Resoult:(JLOpusEncoderConvertBlock _Nullable)result; - -/// 释放 --(void)opusOnRelease; - -@end - -NS_ASSUME_NONNULL_END diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusFormat.h b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusFormat.h deleted file mode 100644 index 4b9b4418..00000000 --- a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLOpusFormat.h +++ /dev/null @@ -1,53 +0,0 @@ -// -// JLOpusOptions.h -// JLAudioUnitKit -// -// Created by EzioChan on 2024/11/14. -// Copyright © 2024 ZhuHai JieLi Technology Co.,Ltd. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -#define OPUS_JL_MAX_FRAME_SIZE 48000 * 2 -#define OPUS_JL_MAX_PACKET_SIZE 1500 - - -@interface JLOpusFormat : NSObject - -/// 采样率 -@property (nonatomic, assign) int sampleRate; - -/// 单/双声道 -@property (nonatomic, assign) int channels; - -/// 帧长度 20ms 默认值 -@property (nonatomic, assign) int frameDuration; - -/// BitRate -@property (nonatomic, assign) int bitRate; - -/// 数据帧大小 -@property (nonatomic, assign, readonly) int frameSize; - -/// 数据大小 -@property (nonatomic, assign) int dataSize; - -/// 是否包含数据头部 -@property (nonatomic, assign) BOOL hasDataHeader; - --(instancetype)init NS_UNAVAILABLE; - -/// 默认配置 -/// sampleRate: 16000 -/// channels: 1 -/// frameDuration: 20 -/// dataSize: 40 -/// frameSize: 320 -/// hasDataHeader: YES -+(JLOpusFormat*)defaultFormats; - -@end - -NS_ASSUME_NONNULL_END diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLPcmToWav.h b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLPcmToWav.h deleted file mode 100644 index b32ecef2..00000000 --- a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLPcmToWav.h +++ /dev/null @@ -1,55 +0,0 @@ -// -// JLPcmToWav.h -// JLAudioUnitKit -// -// Created by EzioChan on 2025/4/15. -// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -/// PCM 转 WAV -@interface JLPcmToWav : NSObject - -#pragma mark - 流式编码接口 - -/// 初始化流式编码器 -/// @param outputPath 输出文件路径 -/// @param sampleRate 采样率(如 16000) -/// @param numChannels 声道数(1 或 2) -/// @param bitsPerSample 位深(16) -- (instancetype)initWithOutputPath:(NSString *)outputPath - sampleRate:(uint32_t)sampleRate - numChannels:(uint16_t)numChannels - bitsPerSample:(uint16_t)bitsPerSample; - -/// 追加 PCM 数据(需确保数据格式与初始化参数一致) -/// @param pcmData PCM 数据块 -/// @param error 错误信息 -- (BOOL)appendPCMData:(NSData *)pcmData error:(NSError **)error; - -/// 完成编码并关闭文件(必须调用以更新 WAV 头) -/// @param error 错误信息 -- (BOOL)finishWithError:(NSError **)error; - -#pragma mark - 一次性编码接口(便捷方法) - -/// 将 PCM 数据一次性转为 WAV 文件 -/// @param pcmData PCM 数据块 -/// @param outputPath 输出文件路径 -/// @param sampleRate 采样率(如 16000) -/// @param numChannels 声道数(1 或 2) -/// @param bitsPerSample 位深(16) -/// @param error 错误信息 -+ (BOOL)convertPCMData:(NSData *)pcmData - toWAVFile:(NSString *)outputPath - sampleRate:(uint32_t)sampleRate - numChannels:(uint16_t)numChannels - bitsPerSample:(uint16_t)bitsPerSample - error:(NSError **)error; - -@end - -NS_ASSUME_NONNULL_END diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLPcmToWtg.h b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLPcmToWtg.h deleted file mode 100644 index 231b3e83..00000000 --- a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLPcmToWtg.h +++ /dev/null @@ -1,51 +0,0 @@ -// -// JLPcmToWtg.h -// JLAudioUnitKit -// -// Created by EzioChan on 2025/1/21. -// Copyright © 2025 ZhuHai JieLi Technology Co.,Ltd. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface JLPcm2WtgModel : NSObject - -/// 音频文件 -@property (nonatomic, copy) NSString *pcmPath; - -/// 输出文件 -@property (nonatomic, copy) NSString *wtgPath; - -@end - - -/// JLPcmToWtgProtocol -@protocol JLPcmToWtgDelegate - -/// 音频转码完成 -/// - Parameter model: 音频转码模型 -- (void)convertPcmToWtgDone:(JLPcm2WtgModel *)model; - -@end - -/// 音频转码 -/// PCM 转 WTG -@interface JLPcmToWtg : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -/// 音频转码初始化 -/// - Parameter delegate: 音频转码代理 -- (instancetype)initWithDelegate:(id)delegate; - -/// 音频转码 -/// 当前接口模型只限制 PCM 文件格式为:8k 16bit -/// 输入的pcm文件(16bit,小端,8k采样率),wtg_filename:输出的wtg文件 -/// - Parameter model: 音频转码模型 --(void)convertPcmToWtg:(JLPcm2WtgModel *)model; - -@end - -NS_ASSUME_NONNULL_END diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLSpeexDecoder.h b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLSpeexDecoder.h deleted file mode 100644 index ad223d08..00000000 --- a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Headers/JLSpeexDecoder.h +++ /dev/null @@ -1,54 +0,0 @@ -// -// JLSpeexUnit.h -// JLAudioUnitKit -// -// Created by EzioChan on 2024/11/14. -// - -#import - -NS_ASSUME_NONNULL_BEGIN -@class JLSpeexDecoder; - -@protocol JLSpeexDelegate - -/// Speex 数据解码 -/// - Parameters: -/// - decoder: 解码器 -/// - data: PCM 数据 -/// - error: 错误信息 -- (void)speexDecoder:(JLSpeexDecoder *)decoder Data:(NSData* _Nullable)data error:(NSError* _Nullable)error; - -@end - -typedef void(^JLSpeexConvertBlock)(NSString *_Nullable pcmPath,NSError *_Nullable error); - -/// Speex 数据解码 -@interface JLSpeexDecoder : NSObject - --(instancetype)init NS_UNAVAILABLE; - -/// 初始化 -/// - Parameter delegate: 委托 --(instancetype)initWithDelegate:(id)delegate; - -/// 委托 -@property (nonatomic, weak) id delegate; - -/// 输入 Speex 数据 -/// - Parameter data: Speex 数据 --(void)speexInputData:(NSData*)data; - -/// 将 Speex 转换为 PCM -/// - Parameters: -/// - filePath: Speex 路径 -/// - opPath: 输出路径 -/// - result: 结果回调 --(void)speexConvertToPcm:(NSString *)filePath outPutFilePath:(NSString * _Nullable)opPath Result:(JLSpeexConvertBlock)result; - -/// 释放对象 --(void)speexOnRelease; - -@end - -NS_ASSUME_NONNULL_END diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Info.plist b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Info.plist deleted file mode 100644 index 8d397ffb..00000000 Binary files a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Info.plist and /dev/null differ diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/JLAudioUnitKit b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/JLAudioUnitKit deleted file mode 100755 index 4dc509fc..00000000 Binary files a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/JLAudioUnitKit and /dev/null differ diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Modules/module.modulemap b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Modules/module.modulemap deleted file mode 100644 index 5e1ae1d3..00000000 --- a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/Modules/module.modulemap +++ /dev/null @@ -1,6 +0,0 @@ -framework module JLAudioUnitKit { - umbrella header "JLAudioUnitKit.h" - export * - - module * { export * } -} diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/readme.txt b/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/readme.txt deleted file mode 100644 index 6789fe6e..00000000 --- a/apps/client/local_plugins/ble_service/ios/ble_service/JLAudioUnitKit.xcframework/ios-arm64/JLAudioUnitKit.framework/readme.txt +++ /dev/null @@ -1,52 +0,0 @@ -DESCRIPTION ------------ -This package includes the files needed to build the fixed point implementation -of the ITU-T G.729 Appendix IV. - -DIFFERENCES WITH G.729B ------------------------ -The ANSI-C source code simulating the bit-exact, fixed point simulation -software of enhanced VAD is integrated into the G.729B source code. -The modification to the G.729B source code updated files of coder.c, -cod_ld8k.c, bits.c, dec_sid.c, dtx.c, qsidgain.c, qsidlsf.c, tab_dtx.c, -tab_dtx.h, ld8k.h, deleted vad.c/h and integrated the enhanced VAD functions -into the G.729B project. The modification also replaced the original basic -operators used in the original G.729B source code by the basic operators of -ITU-T Software Tool Library STL2005. - -COMPILATION ------------ -For UNIX systems the following makefiles are provided - - coder.mak - decoder.mak - -Edit the makefiles coder.mak and decoder.mak to set the proper options -for your system. -The command to compile and link all code on a UNIX system is - - make -f coder.mak - make -f decoder.mak - -For other platforms, the *.mak files can be used to work out the -compilation procedures. - -USAGE ------ -The command line instruction for the encoder is as follows: - coder inputfile bitstreamfile dtx_option mode x -where: - inputfile : 8 kHz sampled data file containing 16 bit linear PCM signal - bitstreamfile: binary file containing bitstream - dtx_option : = 1 : DTX enabled 0 : DTX disabled - mode X : Run encoder with enhanced VAD in operating point X, - X=0 or 1 or 2 representing balanced or quality-preferred - or bandwidth saving operating point, respectively - -The command line for the decoder is as follows: - decoder bitstreamfile outputfile -where: - bitstreamfile: binary file containing bitstream - outputfile : 8 kHz sampled data file containing 16 bit linear PCM signal - - diff --git a/apps/client/local_plugins/bytedance_speech/README.md b/apps/client/local_plugins/bytedance_speech/README.md deleted file mode 100644 index 1c81cc3f..00000000 --- a/apps/client/local_plugins/bytedance_speech/README.md +++ /dev/null @@ -1,175 +0,0 @@ -# Bytedance Speech Plugin - -火山引擎语音识别和语音合成插件,为Flutter应用提供高质量的语音识别(ASR)和语音合成(TTS)能力。 - -## 功能特性 - -- **语音识别(ASR)** - - 支持实时语音识别 - - 支持流式输入和流式识别两种模式 - - 支持中文普通话等多种语言 - - 支持标点符号自动添加 - -- **语音合成(TTS)** - - 支持高质量语音合成 - - 支持多种音色选择 - - 支持多种音频格式输出 - - 支持使用大模型合成自然的语音 - -## 安装 - -将以下内容添加到您的`pubspec.yaml`文件中: - -```yaml -dependencies: - bytedance_speech: - path: ../local_plugins/bytedance_speech -``` - -## 使用方法 - -### 初始化插件 - -```dart -import 'package:bytedance_speech/bytedance_speech.dart'; - -// 获取插件实例 -final bytedanceSpeech = BytedanceSpeech(); - -// 初始化ASR -await bytedanceSpeech.initAsr( - AsrConfig( - appId: 'YOUR_APP_ID', - accessToken: 'YOUR_ACCESS_TOKEN', - streamMode: true, // true: 双向流式模式, false: 流式输入模式 - enablePunctuation: true, - sampleRate: 16000, - bits: 16, - channels: 1, - ), -); - -// 初始化TTS -await bytedanceSpeech.initTts( - TtsConfig( - appId: 'YOUR_APP_ID', - accessToken: 'YOUR_ACCESS_TOKEN', - speaker: 'zh_female_shuangkuaisisi_moon_bigtts', // 选择音色 - format: 'mp3', - sampleRate: 24000, - enableTimestamp: true, - ), -); -``` - -### 语音识别(ASR) - -```dart -// 监听ASR事件 -bytedanceSpeech.onAsrEvent.listen((event) { - switch (event.type) { - case AsrEventType.recognizing: - // 处理中间识别结果 - print('识别中:${event.text}'); - break; - - case AsrEventType.result: - // 处理最终识别结果 - print('识别结果:${event.text}'); - break; - - case AsrEventType.error: - // 处理错误 - print('识别错误:${event.errorCode} - ${event.errorMessage}'); - break; - - case AsrEventType.sessionStarted: - print('开始识别会话'); - break; - - case AsrEventType.sessionStopped: - print('识别会话结束'); - break; - - default: - break; - } -}); - -// 开始识别 -await bytedanceSpeech.startAsr(); - -// 发送音频数据 -await bytedanceSpeech.feedAudioData(audioBytes); - -// 停止识别 -await bytedanceSpeech.stopAsr(); - -// 释放资源 -await bytedanceSpeech.disposeAsr(); -``` - -### 语音合成(TTS) - -```dart -// 监听TTS事件 -bytedanceSpeech.onTtsEvent.listen((event) { - switch (event.type) { - case TtsEventType.audio: - // 处理音频数据 - if (event.audioData != null) { - // 播放或保存音频数据 - playAudio(event.audioData!); - } - break; - - case TtsEventType.completed: - // 合成完成 - print('语音合成完成'); - break; - - case TtsEventType.error: - // 处理错误 - print('合成错误:${event.errorCode} - ${event.errorMessage}'); - break; - - case TtsEventType.started: - print('开始合成'); - break; - - default: - break; - } -}); - -// 合成文本 -await bytedanceSpeech.synthesize('您好,欢迎使用火山引擎语音合成服务。'); - -// 停止合成 -await bytedanceSpeech.stopTts(); - -// 释放资源 -await bytedanceSpeech.disposeTts(); -``` - -## 音色列表 - -以下是部分可用的音色列表: - -| 音色ID | 描述 | -|--------|------| -| zh_female_shuangkuaisisi_moon_bigtts | 中文女声-爽快思思 | -| zh_male_zhiqiangqikai_moon_bigtts | 中文男声-志强起凯 | -| zh_female_xiaoya_moon_bigtts | 中文女声-小雅 | -| zh_male_xiaoming_moon_bigtts | 中文男声-小明 | - -完整的音色列表,请参考火山引擎官方文档:[火山引擎音色列表](https://www.volcengine.com/docs/6561/123172) - -## 许可证 - -本插件基于MIT许可证开源。 - -## 相关链接 - -- [火山引擎官网](https://www.volcengine.com/) -- [火山引擎语音服务文档](https://www.volcengine.com/docs/6561/65067) \ No newline at end of file diff --git a/apps/client/local_plugins/bytedance_speech/android/build.gradle.kts b/apps/client/local_plugins/bytedance_speech/android/build.gradle.kts deleted file mode 100644 index 402e0b7b..00000000 --- a/apps/client/local_plugins/bytedance_speech/android/build.gradle.kts +++ /dev/null @@ -1,56 +0,0 @@ -group = "com.yunqiinnovation.bytedance_speech" -version = "1.0.0" - -plugins { - id("com.android.library") - kotlin("android") -} - -buildscript { - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.1.0") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0") - } -} - -repositories { - google() - mavenCentral() -} - -android { - namespace = "com.deep_voice.bytedance_speech" - compileSdk = 35 - - defaultConfig { - minSdk = 26 - targetSdk = 35 - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = "1.8" - languageVersion = "1.7" - apiVersion = "1.7" - } -} - -dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") - implementation("com.squareup.okhttp3:okhttp:4.11.0") - implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") - implementation("com.google.code.gson:gson:2.10.1") - - - implementation(project(":speech")) -} \ No newline at end of file diff --git a/apps/client/local_plugins/bytedance_speech/android/settings.gradle.kts b/apps/client/local_plugins/bytedance_speech/android/settings.gradle.kts deleted file mode 100644 index 0ee463e3..00000000 --- a/apps/client/local_plugins/bytedance_speech/android/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "bytedance_speech" \ No newline at end of file diff --git a/apps/client/local_plugins/bytedance_speech/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/bytedance_speech/android/src/main/AndroidManifest.xml deleted file mode 100644 index 3fcca60b..00000000 --- a/apps/client/local_plugins/bytedance_speech/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceASR.kt b/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceASR.kt deleted file mode 100644 index 9e9da5ee..00000000 --- a/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceASR.kt +++ /dev/null @@ -1,730 +0,0 @@ -package com.deep_voice.bytedance_speech - -import android.content.Context -import android.util.Log -import com.google.gson.Gson -import com.google.gson.JsonObject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener -import okhttp3.logging.HttpLoggingInterceptor -import okhttp3.logging.HttpLoggingInterceptor.Level -import okio.ByteString -import org.json.JSONObject -import java.io.ByteArrayOutputStream -import java.util.UUID -import java.util.concurrent.TimeUnit -import java.util.zip.GZIPInputStream -import java.util.zip.GZIPOutputStream - -/** - * 火山引擎大模型语音识别实现 - * - * 基于WebSocket协议实现实时语音识别 - */ -class BytedanceASR( - private val context: Context, - private val resultCallback: (String, Boolean) -> Unit, - private val errorCallback: (String, String) -> Unit, - private val statusCallback: (String) -> Unit -) { - companion object { - private const val TAG = "BytedanceASR" - // 协议常量 - private const val PROTOCOL_VERSION = 0x01 - private const val DEFAULT_HEADER_SIZE = 0x01 - - // 消息类型 - private const val FULL_CLIENT_REQUEST = 0x01 - private const val AUDIO_ONLY_REQUEST = 0x02 - private const val FULL_SERVER_RESPONSE = 0x09 - private const val SERVER_ACK = 0x0B - private const val SERVER_ERROR_RESPONSE = 0x0F - - // 消息类型特定标志 - private const val NO_SEQUENCE = 0x00 - private const val POS_SEQUENCE = 0x01 - private const val NEG_SEQUENCE = 0x02 - private const val NEG_WITH_SEQUENCE = 0x03 - - // 序列化方法 - private const val NO_SERIALIZATION = 0x00 - private const val JSON = 0x01 - - // 压缩方法 - private const val NO_COMPRESSION = 0x00 - private const val GZIP = 0x01 - - // 状态常量 - private const val STATUS_INITIALIZING = "initializing" - private const val STATUS_READY = "ready" - private const val STATUS_RECOGNIZING = "recognizing" - private const val STATUS_STOPPED = "stopped" - private const val STATUS_ERROR = "error" - } - - // WebSocket连接 - private var webSocket: WebSocket? = null - private val client: OkHttpClient - private var seq = 1 - - // 配置参数 - private var appId: String = "" - private var accessToken: String = "" - private var streamMode: Boolean = true - private var enablePunctuation: Boolean = true - private var sampleRate: Int = 16000 - private var bits: Int = 16 - private var channels: Int = 1 - private var supportedLanguages: List = listOf("zh-CN") - - // 状态控制 - private var isRecognizing = false - private var isLastPacket = false - private var isContinuousMode = false - private var isInitialized = false - private var currentStatus = STATUS_STOPPED - - // 单次识别结果收集 - private var onceResultBuilder = StringBuilder() - private var singleRecognitionCallback: ((String) -> Unit)? = null - - init { - // 配置OkHttp客户端 - val loggingInterceptor = HttpLoggingInterceptor().apply { - level = Level.HEADERS - } - - client = OkHttpClient.Builder() - .pingInterval(30, TimeUnit.SECONDS) - .addInterceptor(loggingInterceptor) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() - } - - /** - * 初始化语音识别服务 - */ - suspend fun initialize( - subscriptionKey: String, - region: String, - supportedLanguages: List? = null - ): Boolean = withContext(Dispatchers.IO) { - try { - updateStatus(STATUS_INITIALIZING) - - // 设置必要参数 - this@BytedanceASR.appId = region // 火山引擎中region对应应用ID - this@BytedanceASR.accessToken = subscriptionKey // 订阅密钥作为访问令牌 - - if (!supportedLanguages.isNullOrEmpty()) { - this@BytedanceASR.supportedLanguages = supportedLanguages - } - - // 释放旧资源 - release() - - isInitialized = true - updateStatus(STATUS_READY) - true - } catch (e: Exception) { - errorCallback("INIT_ERROR", e.message ?: "初始化失败") - updateStatus(STATUS_ERROR) - false - } - } - - /** - * 单次识别(非流式),完成后自动断开连接 - */ - suspend fun recognizeOnce(audioData: ByteArray, callback: (String) -> Unit): Boolean = withContext(Dispatchers.IO) { - try { - if (!isInitialized) { - errorCallback("NOT_INITIALIZED", "请先调用initialize方法") - return@withContext false - } - - if (isRecognizing) { - errorCallback("BUSY", "语音识别正在进行中") - return@withContext false - } - - // 重置状态 - isContinuousMode = false - isLastPacket = false - onceResultBuilder.clear() - singleRecognitionCallback = callback - streamMode = false // 使用非流式模式 - - // 开始连接 - start() - - // 等待连接建立 - var attempts = 0 - while (!isRecognizing && attempts < 10) { - delay(300) - attempts++ - } - - if (!isRecognizing) { - errorCallback("CONNECTION_ERROR", "无法建立连接") - return@withContext false - } - - // 发送音频数据 - feedAudioData(audioData) - - // 发送结束标志 - stop() - - true - } catch (e: Exception) { - errorCallback("RECOGNIZE_ERROR", e.message ?: "识别失败") - updateStatus(STATUS_ERROR) - false - } - } - - /** - * 启动连续识别 - */ - suspend fun startContinuousRecognition(): Boolean = withContext(Dispatchers.IO) { - try { - if (!isInitialized) { - errorCallback("NOT_INITIALIZED", "请先调用initialize方法") - return@withContext false - } - - if (isRecognizing) { - errorCallback("BUSY", "语音识别正在进行中") - return@withContext false - } - - // 设置为连续模式 - isContinuousMode = true - isLastPacket = false - streamMode = true // 使用流式模式 - - // 开始连接 - start() - - true - } catch (e: Exception) { - errorCallback("START_ERROR", e.message ?: "启动连续识别失败") - updateStatus(STATUS_ERROR) - false - } - } - - /** - * 停止连续识别 - */ - suspend fun stopContinuousRecognition(): Boolean = withContext(Dispatchers.IO) { - try { - if (!isRecognizing || !isContinuousMode) { - return@withContext true // 已经停止或非连续模式 - } - - // 发送结束标志 - stop() - - true - } catch (e: Exception) { - errorCallback("STOP_ERROR", e.message ?: "停止连续识别失败") - false - } - } - - /** - * 推送音频数据 - */ - suspend fun pushAudioData(audioData: ByteArray): Boolean = withContext(Dispatchers.IO) { - try { - if (!isRecognizing) { - errorCallback("NOT_RUNNING", "语音识别未启动") - return@withContext false - } - - feedAudioData(audioData) - true - } catch (e: Exception) { - errorCallback("AUDIO_PUSH_ERROR", e.message ?: "推送音频数据失败") - false - } - } - - /** - * 用于暂停执行的辅助函数 - */ - private suspend fun delay(ms: Long) = withContext(Dispatchers.IO) { - try { - Thread.sleep(ms) - } catch (e: InterruptedException) { - // 忽略中断异常 - } - } - - /** - * 更新状态并通知Dart层 - */ - private fun updateStatus(status: String) { - currentStatus = status - statusCallback(status) - } - - /** - * 开始语音识别 - */ - suspend fun start() = withContext(Dispatchers.IO) { - if (isRecognizing) return@withContext - - isRecognizing = true - isLastPacket = false - seq = 1 - - val url = if (streamMode) { - "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel" - } else { - "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_nostream" - } - - val request = Request.Builder() - .url(url) - .header("X-Api-App-Key", appId) - .header("X-Api-Access-Key", accessToken) - .header("X-Api-Resource-Id", "volc.bigasr.sauc.duration") - .header("X-Api-Connect-Id", UUID.randomUUID().toString()) - .build() - - updateStatus(STATUS_RECOGNIZING) - - webSocket = client.newWebSocket(request, object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { - val logId = response.header("X-Tt-Logid") - Log.d(TAG, "ASR WebSocket连接成功, X-Tt-Logid: $logId") - - // 发送初始配置请求 - sendInitialRequest(webSocket) - } - - override fun onMessage(webSocket: WebSocket, text: String) { - Log.d(TAG, "收到文本消息: $text") - try { - val json = JSONObject(text) - // 解析结果 - resultCallback(json.optString("text", ""), false) - } catch (e: Exception) { - Log.e(TAG, "解析消息失败", e) - } - } - - override fun onMessage(webSocket: WebSocket, bytes: ByteString) { - // Log.d(TAG, "收到二进制消息") - // 在实际实现中解析二进制消息 - } - - override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - Log.d(TAG, "连接关闭中: $code, $reason") - isRecognizing = false - } - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - Log.d(TAG, "连接已关闭: $code, $reason") - isRecognizing = false - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - Log.e(TAG, "连接失败", t) - errorCallback("CONNECTION_ERROR", t.message ?: "连接失败") - isRecognizing = false - } - }) - } - - /** - * 向服务器发送音频数据 - */ - suspend fun feedAudioData(audioData: ByteArray) { - if (!isRecognizing || webSocket == null) { - Log.w(TAG, "未开始识别或WebSocket未连接") - return - } - - try { - // 在实际实现中需要按照火山引擎的协议格式封装音频数据 - val byteString = ByteString.of(*audioData) - webSocket?.send(byteString) - } catch (e: Exception) { - Log.e(TAG, "发送音频数据失败", e) - } - } - - /** - * 停止语音识别 - */ - suspend fun stop() { - if (!isRecognizing) { - return - } - - try { - // 发送最后一帧音频数据,标记结束 - // 在实际实现中需要按照协议发送结束标记 - - isRecognizing = false - webSocket?.close(1000, "正常关闭") - webSocket = null - } catch (e: Exception) { - Log.e(TAG, "停止识别失败", e) - } - } - - /** - * 释放资源 - */ - fun release() { - webSocket?.cancel() - webSocket = null - isRecognizing = false - isLastPacket = false - updateStatus(STATUS_STOPPED) - } - - /** - * 发送初始配置请求 - */ - private fun sendInitialRequest(webSocket: WebSocket) { - try { - // 构建请求参数 - val user = JsonObject().apply { - addProperty("uid", "test") - } - - val audio = JsonObject().apply { - addProperty("format", "pcm") - addProperty("sample_rate", sampleRate) - addProperty("bits", bits) - addProperty("channel", channels) - addProperty("codec", "raw") - } - - val request = JsonObject().apply { - addProperty("model_name", "bigmodel") - addProperty("enable_punc", enablePunctuation) - } - - val payload = JsonObject().apply { - add("user", user) - add("audio", audio) - add("request", request) - } - - val payloadStr = payload.toString() - Log.d(TAG, "ASR初始请求: $payloadStr") - - // 压缩负载 - val compressedPayload = gzipCompress(payloadStr.toByteArray()) - - // 构建消息头 - val header = getHeader( - FULL_CLIENT_REQUEST, - POS_SEQUENCE, - JSON, - GZIP, - 0 - ) - - // 构建消息序列号 - val seqBytes = intToBytes(seq) - - // 构建负载大小 - val payloadSizeBytes = intToBytes(compressedPayload.size) - - // 组装完整请求数据 - val requestBytes = ByteArray(header.size + seqBytes.size + payloadSizeBytes.size + compressedPayload.size) - var offset = 0 - - System.arraycopy(header, 0, requestBytes, offset, header.size) - offset += header.size - - System.arraycopy(seqBytes, 0, requestBytes, offset, seqBytes.size) - offset += seqBytes.size - - System.arraycopy(payloadSizeBytes, 0, requestBytes, offset, payloadSizeBytes.size) - offset += payloadSizeBytes.size - - System.arraycopy(compressedPayload, 0, requestBytes, offset, compressedPayload.size) - - // 发送请求 - val byteString = ByteString.of(*requestBytes) - webSocket.send(byteString) - } catch (e: Exception) { - Log.e(TAG, "发送初始请求失败", e) - } - } - - /** - * 发送音频数据请求 - */ - private fun sendAudioRequest(webSocket: WebSocket, audioData: ByteArray, isLast: Boolean) { - try { - seq++ - - // 如果是最后一个包,使用负序列号 - val actualSeq = if (isLast) -seq else seq - - // 设置消息标志 - val flag = if (isLast) NEG_WITH_SEQUENCE else POS_SEQUENCE - - // 构建消息头 - val header = getHeader( - AUDIO_ONLY_REQUEST, - flag, - NO_SERIALIZATION, - GZIP, - 0 - ) - - // 构建消息序列号 - val seqBytes = intToBytes(actualSeq) - - // 压缩音频数据 - val compressedPayload = if (audioData.isNotEmpty()) { - gzipCompress(audioData) - } else { - ByteArray(0) - } - - // 构建负载大小 - val payloadSizeBytes = intToBytes(compressedPayload.size) - - // 组装完整请求数据 - val requestBytes = ByteArray(header.size + seqBytes.size + payloadSizeBytes.size + compressedPayload.size) - var offset = 0 - - System.arraycopy(header, 0, requestBytes, offset, header.size) - offset += header.size - - System.arraycopy(seqBytes, 0, requestBytes, offset, seqBytes.size) - offset += seqBytes.size - - System.arraycopy(payloadSizeBytes, 0, requestBytes, offset, payloadSizeBytes.size) - offset += payloadSizeBytes.size - - if (compressedPayload.isNotEmpty()) { - System.arraycopy(compressedPayload, 0, requestBytes, offset, compressedPayload.size) - } - - // 发送请求 - val byteString = ByteString.of(*requestBytes) - webSocket.send(byteString) - } catch (e: Exception) { - Log.e(TAG, "发送音频请求失败", e) - } - } - - /** - * 解析响应数据 - */ - private fun parseResponse(data: ByteArray): AsrResponse { - val response = AsrResponse() - - // 解析头部 (4字节) - response.protocolVersion = (data[0].toInt() shr 4) and 0x0F - response.headerSize = data[0].toInt() and 0x0F - response.messageType = (data[1].toInt() shr 4) and 0x0F - response.messageTypeSpecificFlags = data[1].toInt() and 0x0F - response.serialization = (data[2].toInt() shr 4) and 0x0F - response.compression = data[2].toInt() and 0x0F - response.reserved = data[3].toInt() - - // 解析序列号 (4字节) - val seqBytes = ByteArray(4) - System.arraycopy(data, 4, seqBytes, 0, 4) - response.sequence = bytesToInt(seqBytes) - - // 解析负载大小 (4字节) - val sizeBytes = ByteArray(4) - System.arraycopy(data, 8, sizeBytes, 0, 4) - val payloadSize = bytesToInt(sizeBytes) - - // 解析负载数据 - if (payloadSize > 0 && data.size > 12) { - val payload = ByteArray(payloadSize) - System.arraycopy(data, 12, payload, 0, payloadSize) - response.payload = payload - } else { - response.payload = ByteArray(0) - } - - return response - } - - /** - * 从JSON响应中提取文本 - */ - private fun extractTextFromJson(jsonStr: String): String { - try { - val gson = Gson() - val jsonObject = gson.fromJson(jsonStr, JsonObject::class.java) - - // 提取识别结果文本 - if (jsonObject.has("result") && !jsonObject.get("result").isJsonNull) { - val result = jsonObject.getAsJsonObject("result") - - if (result.has("text") && !result.get("text").isJsonNull) { - return result.get("text").asString - } - - // 尝试从segments中提取文本 - if (result.has("segments") && result.get("segments").isJsonArray) { - val segments = result.getAsJsonArray("segments") - val builder = StringBuilder() - - for (i in 0 until segments.size()) { - val segment = segments.get(i).asJsonObject - if (segment.has("text") && !segment.get("text").isJsonNull) { - builder.append(segment.get("text").asString) - } - } - - return builder.toString() - } - } - - return "" - } catch (e: Exception) { - Log.e(TAG, "解析JSON失败", e) - return "" - } - } - - /** - * 获取消息头 - */ - private fun getHeader( - messageType: Int, - messageTypeSpecificFlags: Int, - serialMethod: Int, - compressionType: Int, - reservedData: Int - ): ByteArray { - val header = ByteArray(4) - header[0] = ((PROTOCOL_VERSION shl 4) or DEFAULT_HEADER_SIZE).toByte() - header[1] = ((messageType shl 4) or messageTypeSpecificFlags).toByte() - header[2] = ((serialMethod shl 4) or compressionType).toByte() - header[3] = reservedData.toByte() - return header - } - - /** - * 整数转字节数组 (大端序) - */ - private fun intToBytes(value: Int): ByteArray { - return byteArrayOf( - ((value shr 24) and 0xFF).toByte(), - ((value shr 16) and 0xFF).toByte(), - ((value shr 8) and 0xFF).toByte(), - (value and 0xFF).toByte() - ) - } - - /** - * 字节数组转整数 (大端序) - */ - private fun bytesToInt(bytes: ByteArray): Int { - if (bytes.size != 4) { - throw IllegalArgumentException("字节数组长度必须为4") - } - - return ((bytes[0].toInt() and 0xFF) shl 24) or - ((bytes[1].toInt() and 0xFF) shl 16) or - ((bytes[2].toInt() and 0xFF) shl 8) or - (bytes[3].toInt() and 0xFF) - } - - /** - * GZIP压缩 - */ - private fun gzipCompress(data: ByteArray): ByteArray { - if (data.isEmpty()) return ByteArray(0) - - val output = ByteArrayOutputStream() - GZIPOutputStream(output).use { gzip -> - gzip.write(data) - } - - return output.toByteArray() - } - - /** - * GZIP解压缩 - */ - private fun gzipDecompress(data: ByteArray): ByteArray { - if (data.isEmpty()) return ByteArray(0) - - val output = ByteArrayOutputStream() - GZIPInputStream(data.inputStream()).use { gzip -> - val buffer = ByteArray(1024) - var len: Int - - while (gzip.read(buffer).also { len = it } > 0) { - output.write(buffer, 0, len) - } - } - - return output.toByteArray() - } - - /** - * ASR响应数据结构 - */ - private data class AsrResponse( - var protocolVersion: Int = 0, - var headerSize: Int = 0, - var messageType: Int = 0, - var messageTypeSpecificFlags: Int = 0, - var serialization: Int = 0, - var compression: Int = 0, - var reserved: Int = 0, - var sequence: Int = 0, - var payload: ByteArray = ByteArray(0) - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as AsrResponse - - if (protocolVersion != other.protocolVersion) return false - if (headerSize != other.headerSize) return false - if (messageType != other.messageType) return false - if (messageTypeSpecificFlags != other.messageTypeSpecificFlags) return false - if (serialization != other.serialization) return false - if (compression != other.compression) return false - if (reserved != other.reserved) return false - if (sequence != other.sequence) return false - if (!payload.contentEquals(other.payload)) return false - - return true - } - - override fun hashCode(): Int { - var result = protocolVersion - result = 31 * result + headerSize - result = 31 * result + messageType - result = 31 * result + messageTypeSpecificFlags - result = 31 * result + serialization - result = 31 * result + compression - result = 31 * result + reserved - result = 31 * result + sequence - result = 31 * result + payload.contentHashCode() - return result - } - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceAudioPlayer.kt b/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceAudioPlayer.kt deleted file mode 100644 index f26eae6b..00000000 --- a/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceAudioPlayer.kt +++ /dev/null @@ -1,424 +0,0 @@ -package com.deep_voice.bytedance_speech - -import android.content.Context -import android.media.AudioAttributes -import android.media.AudioDeviceCallback -import android.media.AudioDeviceInfo -import android.media.AudioFormat -import android.media.AudioManager -import android.media.AudioTrack -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.util.Log -import com.deep_voice.speech.tts.AudioDataListener -import com.deep_voice.speech.tts.AudioOutputDevice - -/** - * 语音通信音频播放器 - 16kHz采样率 - * 设计要点: - * • 使用 USAGE_VOICE_COMMUNICATION + MODE_IN_COMMUNICATION 通信通路 - * • 利用 AudioDeviceCallback 监听设备热插拔 - * • API 31+ 使用 setPreferredDevice() 精准路由 - * • 有耳机自动从耳机播放,无耳机自动切到扬声器 - * • 内置回声消除和噪音抑制功能 - */ -class BytedanceAudioPlayer(private val context: Context) : AudioDataListener, AudioDeviceCallback() { - companion object { - private const val TAG = "BytedanceAudioPlayer" - private const val SAMPLE_RATE = 16000 // 固定16kHz采样率 - private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_OUT_MONO - private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT - } - - private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - private var audioTrack: AudioTrack? = null - private var isFirstData = true - private var totalBytesWritten = 0 - private var sessionActive = true - private var audioFocusRequest: Any? = null // 兼容不同API版本 - - // 音频焦点变化监听器 - private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> - when (focusChange) { - AudioManager.AUDIOFOCUS_LOSS -> { - // 永久失去焦点,停止播放 - Log.d(TAG, "音频焦点丢失") - stop() - } - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - // 暂时失去焦点,暂停播放 - Log.d(TAG, "音频焦点暂时丢失") - audioTrack?.pause() - } - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { - // 暂时失去焦点但可降低音量,这里选择暂停 - Log.d(TAG, "音频焦点暂时丢失(可降低音量)") - audioTrack?.pause() - } - AudioManager.AUDIOFOCUS_GAIN -> { - // 重新获得焦点,恢复播放 - Log.d(TAG, "音频焦点恢复") - if (sessionActive) { - audioTrack?.play() - } - } - } - } - - // 回调 - private var onPlayStarted: (() -> Unit)? = null - private var onPlayCompleted: (() -> Unit)? = null - - // 耳机设备类型(优先级顺序) - private val headsetTypes = intArrayOf( - AudioDeviceInfo.TYPE_WIRED_HEADSET, - AudioDeviceInfo.TYPE_WIRED_HEADPHONES, - AudioDeviceInfo.TYPE_BLUETOOTH_A2DP - // TYPE_BLUETOOTH_LE 在较新的 API 中才有,这里先不包含 - ) - - // 播放位置监听器 - private val playbackListener = object : AudioTrack.OnPlaybackPositionUpdateListener { - override fun onMarkerReached(track: AudioTrack) { - onPlayCompleted?.invoke() - } - - override fun onPeriodicNotification(track: AudioTrack) { - // 不使用周期性通知 - } - } - - init { - // 注册设备热插拔监听 - audioManager.registerAudioDeviceCallback(this, Handler(Looper.getMainLooper())) - // 请求音频焦点 - requestAudioFocus() - buildTrack(selectOutputDevice()) // 首次创建 - } - - /** - * 监听输出设备变化 - */ - override fun onAudioDevicesAdded(addedDevices: Array) { - Log.d(TAG, "音频设备连接: ${addedDevices.joinToString { "${it.productName}(${it.type})" }}") - rebuildIfNeeded() - } - - override fun onAudioDevicesRemoved(removedDevices: Array) { - Log.d(TAG, "音频设备断开: ${removedDevices.joinToString { "${it.productName}(${it.type})" }}") - rebuildIfNeeded() - } - - /** - * 检查设备变化,必要时重建AudioTrack - */ - private fun rebuildIfNeeded() { - val newDevice = selectOutputDevice() - val currentDevice = audioTrack?.preferredDevice - - // 设备发生变化时重建 - if (newDevice?.id != currentDevice?.id) { - Log.d(TAG, "设备切换: ${currentDevice?.productName} -> ${newDevice?.productName}") - - // 保存当前状态 - val wasPlaying = audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING - val savedFirstData = isFirstData - val savedTotalBytes = totalBytesWritten - val savedSessionActive = sessionActive - - // 重建AudioTrack - buildTrack(newDevice) - - // 恢复状态 - if (wasPlaying) { - isFirstData = savedFirstData - totalBytesWritten = savedTotalBytes - sessionActive = savedSessionActive - } - } - } - - /** - * 选择当前优先输出设备(耳机优先) - */ - private fun selectOutputDevice(): AudioDeviceInfo? { - return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) - .firstOrNull { it.type in headsetTypes } - } - - /** - * 构建16kHz单声道AudioTrack - */ - private fun buildTrack(device: AudioDeviceInfo?) { - audioTrack?.release() - - val minBufferSize = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT) - - audioTrack = AudioTrack.Builder() - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) // 语音通信通路 - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() - ) - .setAudioFormat( - AudioFormat.Builder() - .setSampleRate(SAMPLE_RATE) - .setEncoding(AUDIO_FORMAT) - .setChannelMask(CHANNEL_CONFIG) - .build() - ) - .setTransferMode(AudioTrack.MODE_STREAM) - .setBufferSizeInBytes(minBufferSize * 2) // 小缓冲区保证低延迟 - .apply { - // 低延迟模式 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) - } - } - .build().also { track -> - // API 31+ 精准路由到指定设备 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && device != null) { - track.preferredDevice = device - Log.d(TAG, "设置首选设备: ${device.productName} (${device.type})") - } - - // 设置播放监听器 - track.setPlaybackPositionUpdateListener(playbackListener) - - // 开始播放 - track.play() - } - - // 配置音频管理器 - 使用通信模式以启用回声消除和噪音抑制 - audioManager.mode = AudioManager.MODE_IN_COMMUNICATION - // 切换扬声器开关仅在无耳机时打开 - audioManager.isSpeakerphoneOn = (device == null) - - val deviceName = device?.productName ?: "扬声器" - Log.d(TAG, "AudioTrack已创建 - 输出设备: $deviceName, 采样率: ${SAMPLE_RATE}Hz") - } - - /** - * 请求音频焦点 - */ - private fun requestAudioFocus() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Android O及以上使用新的API - val focusRequest = android.media.AudioFocusRequest.Builder( - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE - ) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() - ) - .setAcceptsDelayedFocusGain(true) - .setOnAudioFocusChangeListener(audioFocusChangeListener) - .build() - - val result = audioManager.requestAudioFocus(focusRequest) - if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - audioFocusRequest = focusRequest - Log.d(TAG, "音频焦点请求成功") - } else { - Log.w(TAG, "音频焦点请求失败") - } - } else { - // Android O以下使用旧的API - @Suppress("DEPRECATION") - val result = audioManager.requestAudioFocus( - audioFocusChangeListener, - AudioManager.STREAM_VOICE_CALL, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE - ) - if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - Log.d(TAG, "音频焦点请求成功(旧API)") - } else { - Log.w(TAG, "音频焦点请求失败(旧API)") - } - } - } catch (e: Exception) { - Log.e(TAG, "请求音频焦点异常: ${e.message}") - } - } - - /** - * 释放音频焦点 - */ - private fun releaseAudioFocus() { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioFocusRequest?.let { - audioManager.abandonAudioFocusRequest(it as android.media.AudioFocusRequest) - } - } else { - @Suppress("DEPRECATION") - audioManager.abandonAudioFocus(audioFocusChangeListener) - } - Log.d(TAG, "音频焦点已释放") - } catch (e: Exception) { - Log.e(TAG, "释放音频焦点异常: ${e.message}") - } - } - - /** - * 开始新的播放会话 - */ - fun startSession() { - isFirstData = true - totalBytesWritten = 0 - sessionActive = true - - audioTrack?.let { track -> - if (track.state == AudioTrack.STATE_INITIALIZED) { - track.pause() - track.flush() - track.play() - } - } ?: buildTrack(selectOutputDevice()) - } - - /** - * 标记数据流结束 - */ - fun endSession() { - if (!sessionActive) return - - sessionActive = false - - audioTrack?.let { track -> - if (totalBytesWritten > 0) { - val totalFrames = totalBytesWritten / 2 - try { - track.setNotificationMarkerPosition(totalFrames) - } catch (e: Exception) { - val durationMs = (totalFrames * 1000L) / SAMPLE_RATE - Handler(Looper.getMainLooper()).postDelayed({ - onPlayCompleted?.invoke() - }, durationMs + 100) // 更短的延迟 - } - } else { - onPlayCompleted?.invoke() - } - } - } - - /** - * 接收音频数据 - */ - override fun onAudioData(data: ByteArray) { - if (data.isEmpty() || !sessionActive) return - if (audioTrack?.state != AudioTrack.STATE_INITIALIZED) return - - try { - audioTrack?.let { track -> - val bytesWritten = track.write(data, 0, data.size) - - if (bytesWritten > 0) { - if (isFirstData) { - isFirstData = false - onPlayStarted?.invoke() - } - - if (sessionActive) { - totalBytesWritten += bytesWritten - } - } - } - } catch (e: Exception) { - Log.w(TAG, "音频数据写入失败: ${e.message}") - } - } - - /** - * 停止播放 - */ - fun stop() { - sessionActive = false - - audioTrack?.let { track -> - if (track.state == AudioTrack.STATE_INITIALIZED) { - track.pause() - track.flush() - } - } - - if (!isFirstData) { - onPlayCompleted?.invoke() - } - } - - /** - * 释放资源 - */ - fun release() { - stop() - audioTrack?.release() - audioTrack = null - releaseAudioFocus() - audioManager.unregisterAudioDeviceCallback(this) - // 恢复音频模式 - audioManager.mode = AudioManager.MODE_NORMAL - } - - /** - * 设置播放开始回调 - */ - fun setOnPlayStarted(listener: () -> Unit) { - onPlayStarted = listener - } - - /** - * 设置播放完成回调 - */ - fun setOnPlayCompleted(listener: () -> Unit) { - onPlayCompleted = listener - } - - /** - * 设置错误回调(兼容接口) - */ - fun setOnError(listener: (String) -> Unit) { - // 不实现,仅为兼容 - } - - /** - * 检查是否正在播放 - */ - fun isPlaying(): Boolean { - return audioTrack?.playState == AudioTrack.PLAYSTATE_PLAYING - } - - /** - * 设置音频输出设备(兼容接口) - * 注意:此实现依赖自动设备切换,手动设置可能被热插拔覆盖 - */ - fun setAudioOutputDevice(device: AudioOutputDevice) { - // 本实现专注于自动设备切换 - // 手动设置会在下次设备变化时被自动逻辑覆盖 - Log.d(TAG, "setAudioOutputDevice被调用,但本实现依赖自动设备切换") - } - - /** - * 获取当前路由设备信息(用于调试) - */ - fun getCurrentDevice(): String { - val routedDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - audioTrack?.routedDevice - } else { - null - } - - val preferredDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - audioTrack?.preferredDevice - } else { - null - } - - return "路由设备: ${routedDevice?.productName}, 首选设备: ${preferredDevice?.productName}" - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceSpeechPlugin.kt b/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceSpeechPlugin.kt deleted file mode 100644 index a6003e05..00000000 --- a/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceSpeechPlugin.kt +++ /dev/null @@ -1,485 +0,0 @@ -package com.deep_voice.bytedance_speech - -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.util.Log -import androidx.annotation.NonNull -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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import com.deep_voice.speech.tts.TtsEvent -import com.deep_voice.speech.tts.TtsEventListener -import com.deep_voice.speech.tts.TtsEventType -import com.deep_voice.speech.tts.AudioDataListener -import java.util.concurrent.ConcurrentLinkedQueue - -/** - * 字节跳动语音服务Flutter插件 - * 提供TTS和ASR能力,通过独立的事件通道进行通信 - */ -class BytedanceSpeechPlugin : FlutterPlugin { - companion object { - private const val TAG = "BytedanceSpeechPlugin" - - // 通道名称 - private const val TTS_METHOD_CHANNEL = "com.deep_voice.bytedance_tts" - private const val ASR_METHOD_CHANNEL = "com.deep_voice.bytedance_asr" - - // 事件通道 - private const val TTS_EVENT_CHANNEL = "com.deep_voice.bytedance_tts/events" - private const val TTS_AUDIO_CHANNEL = "com.deep_voice.bytedance_tts/audio" - private const val ASR_EVENT_CHANNEL = "com.deep_voice.bytedance_asr/events" - } - - // Flutter插件相关 - private lateinit var ttsChannel: MethodChannel - private lateinit var asrChannel: MethodChannel - private lateinit var context: Context - - // TTS事件通道 - private var ttsEventSink: EventChannel.EventSink? = null - private var ttsAudioSink: EventChannel.EventSink? = null - - // ASR事件通道 - private var asrEventSink: EventChannel.EventSink? = null - - // 语音服务实例 - private var bytedanceTTS: BytedanceTTS? = null - private var bytedanceASR: BytedanceASR? = null - - // 协程作用域 - private val job = SupervisorJob() - private val scope = CoroutineScope(Dispatchers.Main + job) - - // 主线程Handler - private val mainHandler = Handler(Looper.getMainLooper()) - - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - - // 初始化TTS方法通道 - ttsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, TTS_METHOD_CHANNEL) - ttsChannel.setMethodCallHandler(TtsMethodHandler()) - - // 初始化ASR方法通道 - asrChannel = MethodChannel(flutterPluginBinding.binaryMessenger, ASR_METHOD_CHANNEL) - asrChannel.setMethodCallHandler(AsrMethodHandler()) - - // 设置TTS事件通道 - setupTtsEventChannels(flutterPluginBinding) - - // 设置ASR事件通道 - setupAsrEventChannel(flutterPluginBinding) - - Log.d(TAG, "字节跳动语音插件已附加到Flutter引擎") - } - - /** - * 设置TTS相关的事件通道 - */ - private fun setupTtsEventChannels(binding: FlutterPlugin.FlutterPluginBinding) { - // TTS事件通道 - EventChannel(binding.binaryMessenger, TTS_EVENT_CHANNEL).setStreamHandler( - object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - ttsEventSink = events - Log.d(TAG, "TTS事件流已建立连接") - } - - override fun onCancel(arguments: Any?) { - ttsEventSink = null - Log.d(TAG, "TTS事件流已取消") - } - } - ) - - // TTS音频数据通道 - EventChannel(binding.binaryMessenger, TTS_AUDIO_CHANNEL).setStreamHandler( - object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - ttsAudioSink = events - Log.d(TAG, "TTS音频流已建立连接") - } - - override fun onCancel(arguments: Any?) { - ttsAudioSink = null - Log.d(TAG, "TTS音频流已取消") - } - } - ) - } - - /** - * 设置ASR相关的事件通道 - */ - private fun setupAsrEventChannel(binding: FlutterPlugin.FlutterPluginBinding) { - // ASR事件通道 - EventChannel(binding.binaryMessenger, ASR_EVENT_CHANNEL).setStreamHandler( - object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - asrEventSink = events - Log.d(TAG, "ASR事件流已建立连接") - } - - override fun onCancel(arguments: Any?) { - asrEventSink = null - Log.d(TAG, "ASR事件流已取消") - } - } - ) - } - - /** - * TTS方法处理器 - */ - inner class TtsMethodHandler : MethodCallHandler { - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - when (call.method) { - "initialize" -> { - val ttsAppId = call.argument("ttsAppId") ?: "" - val ttsAppToken = call.argument("ttsAppToken") ?: "" - val language = call.argument("language") ?: "zh-CN" - - initializeTTS(ttsAppId, ttsAppToken, language, result) - } - "setVoice" -> { - val voice = call.argument("voice") ?: "" - setVoice(voice, result) - } - "speakOnce" -> { - val text = call.argument("text") ?: "" - speakOnce(text, result) - } - "speakStream" -> { - val text = call.argument("text") ?: "" - speakStream(text, result) - } - "flushStream" -> { - flushStream(result) - } - "stopTTS" -> { - stopTTS(result) - } - "disposeTTS" -> { - disposeTTS(result) - } - - else -> { - result.notImplemented() - } - } - } - } - - /** - * ASR方法处理器 - */ - inner class AsrMethodHandler : MethodCallHandler { - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - when (call.method) { - "initializeASR" -> { - result.notImplemented() - } - "startListening" -> { - result.notImplemented() - } - "stopListening" -> { - result.notImplemented() - } - "cancelASR" -> { - result.notImplemented() - } - else -> { - result.notImplemented() - } - } - } - } - - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - ttsChannel.setMethodCallHandler(null) - asrChannel.setMethodCallHandler(null) - - // 释放资源 - bytedanceTTS?.release() - bytedanceTTS = null - - bytedanceASR?.release() - bytedanceASR = null - - job.cancel() - - Log.d(TAG, "字节跳动语音插件已从Flutter引擎分离") - } - - /** - * 初始化TTS服务 - */ - private fun initializeTTS(ttsAppId: String, ttsAppToken: String, language: String, result: Result) { - scope.launch { - try { - if (ttsAppId.isEmpty() || ttsAppToken.isEmpty()) { - result.error( - "INVALID_PARAMS", - "AppID和AppToken不能为空", - null - ) - return@launch - } - - // 确保只有一个TTS实例 - bytedanceTTS?.release() - - // 创建新的TTS实例 - bytedanceTTS = BytedanceTTS(context) - - // 添加事件监听器 - bytedanceTTS?.addListener(object : TtsEventListener { - override fun onEvent(event: TtsEvent) { - when (event.type) { - TtsEventType.SYNTHESIS_STARTED -> { - sendTTSEvent("started", null) - } - TtsEventType.SYNTHESIS_COMPLETED -> { - sendTTSEvent("completed", null) - } - TtsEventType.SYNTHESIS_CANCELED -> { - sendTTSEvent("canceled", null) - } - TtsEventType.PLAYBACK_STARTED -> { - sendTTSEvent("playback_started", null) - } - TtsEventType.PLAYBACK_COMPLETED -> { - sendTTSEvent("playback_completed", null) - } - TtsEventType.ERROR -> { - val params = HashMap() - params["code"] = event.params["errorCode"] ?: "UNKNOWN_ERROR" - params["message"] = event.params["errorMessage"] ?: "未知错误" - sendTTSEvent("error", params) - } - } - } - }) - - // 添加音频数据监听器 - bytedanceTTS?.addAudioDataListener(object : AudioDataListener { - override fun onAudioData(data: ByteArray) { - sendTTSAudioData(data) - } - }) - - // 初始化TTS - val success = bytedanceTTS?.initialize( - ttsAppId = ttsAppId, - ttsAppToken = ttsAppToken, - ttsResource = "", // 火山TTS不使用resource - language = language - ) ?: false - - if (success) { - result.success(true) - } else { - result.error( - "INIT_FAILED", - "TTS初始化失败", - null - ) - } - } catch (e: Exception) { - Log.e(TAG, "TTS初始化异常: ${e.message}", e) - result.error( - "INIT_EXCEPTION", - "TTS初始化异常: ${e.message}", - null - ) - } - } - } - - /** - * 设置TTS音色 - */ - private fun setVoice(voice: String, result: Result) { - if (bytedanceTTS == null) { - result.error("NOT_INITIALIZED", "TTS未初始化", null) - return - } - - try { - val success = bytedanceTTS?.setVoice(voice) ?: false - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "设置音色异常: ${e.message}", e) - result.error( - "VOICE_EXCEPTION", - "设置音色异常: ${e.message}", - null - ) - } - } - - /** - * 合成并播放文本(单次) - */ - private fun speakOnce(text: String, result: Result) { - if (bytedanceTTS == null) { - result.error("NOT_INITIALIZED", "TTS未初始化", null) - return - } - - try { - val success = bytedanceTTS?.speakOnce(text) ?: false - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "合成文本异常: ${e.message}", e) - result.error( - "SPEAK_EXCEPTION", - "合成文本异常: ${e.message}", - null - ) - } - } - - /** - * 流式合成文本 - */ - private fun speakStream(text: String, result: Result) { - if (bytedanceTTS == null) { - result.error("NOT_INITIALIZED", "TTS未初始化", null) - return - } - - try { - val success = bytedanceTTS?.speakStream(text) ?: false - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "流式合成异常: ${e.message}", e) - result.error( - "STREAM_EXCEPTION", - "流式合成异常: ${e.message}", - null - ) - } - } - - /** - * 刷新流式合成缓冲区 - */ - private fun flushStream(result: Result) { - if (bytedanceTTS == null) { - result.error("NOT_INITIALIZED", "TTS未初始化", null) - return - } - - try { - val success = bytedanceTTS?.flushStream() ?: false - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "刷新流异常: ${e.message}", e) - result.error( - "FLUSH_EXCEPTION", - "刷新流异常: ${e.message}", - null - ) - } - } - - /** - * 停止TTS - */ - private fun stopTTS(result: Result) { - if (bytedanceTTS == null) { - result.success(true) - return - } - - try { - val success = bytedanceTTS?.stop() ?: true - result.success(success) - } catch (e: Exception) { - Log.e(TAG, "停止TTS异常: ${e.message}", e) - result.error( - "STOP_EXCEPTION", - "停止TTS异常: ${e.message}", - null - ) - } - } - - /** - * 释放TTS资源 - */ - private fun disposeTTS(result: Result) { - try { - bytedanceTTS?.release() - bytedanceTTS = null - result.success(true) - } catch (e: Exception) { - Log.e(TAG, "释放TTS资源异常: ${e.message}", e) - result.error( - "DISPOSE_EXCEPTION", - "释放TTS资源异常: ${e.message}", - null - ) - } - } - - - /** - * 发送TTS事件到Flutter端 - */ - private fun sendTTSEvent(eventName: String, params: Map?) { - if (ttsEventSink == null) return - - val event = HashMap() - event["event"] = eventName - - if (params != null) { - event["params"] = params - } - - mainHandler.post { - ttsEventSink?.success(event) - } - } - - /** - * 发送TTS音频数据到Flutter端 - */ - private fun sendTTSAudioData(data: ByteArray) { - if (ttsAudioSink == null) return - - mainHandler.post { - ttsAudioSink?.success(data) - } - } - - /** - * 发送ASR事件到Flutter端 - */ - private fun sendASREvent(eventType: String, data: Any, params: Map? = null) { - if (asrEventSink == null) return - - val event = HashMap() - event["type"] = eventType - event["data"] = data - - if (params != null) { - event["params"] = params - } - - mainHandler.post { - asrEventSink?.success(event) - } - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceTTS.kt b/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceTTS.kt deleted file mode 100644 index 6493340f..00000000 --- a/apps/client/local_plugins/bytedance_speech/android/src/main/kotlin/com/deep_voice/bytedance_speech/BytedanceTTS.kt +++ /dev/null @@ -1,1201 +0,0 @@ -package com.deep_voice.bytedance_speech - -import android.content.Context -import android.util.Log -import com.deep_voice.speech.tts.AudioDataListener -import com.deep_voice.speech.tts.AudioOutputDevice -import com.deep_voice.speech.tts.ITtsService -import com.deep_voice.speech.tts.TtsEvent -import com.deep_voice.speech.tts.TtsEventListener -import com.deep_voice.speech.tts.TtsEventType -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.CancellationException -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener -import okio.ByteString -import org.json.JSONObject -import java.util.UUID -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean - -/** - * 火山引擎大模型语音合成实现 - * - * 基于WebSocket协议实现双向流式TTS - */ -class BytedanceTTS(private val context: Context) : ITtsService, CoroutineScope { - companion object { - private const val TAG = "BytedanceTTS" - - // 协议常量 - private const val PROTOCOL_VERSION = 0x01 - private const val DEFAULT_HEADER_SIZE = 0x01 - - // 消息类型 - private const val FULL_CLIENT_REQUEST = 0x01 - private const val AUDIO_ONLY_RESPONSE = 0x0B - private const val FULL_SERVER_RESPONSE = 0x09 - private const val ERROR_INFORMATION = 0x0F - - // 消息类型特定标志 - private const val MSG_TYPE_FLAG_NO_SEQ = 0x00 - private const val MSG_TYPE_FLAG_POSITIVE_SEQ = 0x01 - private const val MSG_TYPE_FLAG_LAST_NO_SEQ = 0x02 - private const val MSG_TYPE_FLAG_NEGATIVE_SEQ = 0x03 - private const val MSG_TYPE_FLAG_WITH_EVENT = 0x04 - - // 序列化方法 - private const val NO_SERIALIZATION = 0x00 - private const val JSON = 0x01 - - // 压缩方法 - private const val COMPRESSION_NO = 0x00 - private const val COMPRESSION_GZIP = 0x01 - - // 事件类型 - private const val EVENT_NONE = 0 - private const val EVENT_START_CONNECTION = 1 - private const val EVENT_FINISH_CONNECTION = 2 - private const val EVENT_CONNECTION_STARTED = 50 - private const val EVENT_CONNECTION_FAILED = 51 - private const val EVENT_CONNECTION_FINISHED = 52 - private const val EVENT_START_SESSION = 100 - private const val EVENT_FINISH_SESSION = 102 - private const val EVENT_SESSION_STARTED = 150 - private const val EVENT_SESSION_FINISHED = 152 - private const val EVENT_SESSION_FAILED = 153 - private const val EVENT_TASK_REQUEST = 200 - private const val EVENT_TTS_SENTENCE_START = 350 - private const val EVENT_TTS_SENTENCE_END = 351 - private const val EVENT_TTS_RESPONSE = 352 - - // 状态常量 - private const val STATUS_INITIALIZING = "initializing" - private const val STATUS_READY = "ready" - private const val STATUS_SPEAKING = "speaking" - private const val STATUS_STOPPED = "stopped" - private const val STATUS_ERROR = "error" - - // 连接控制常量 - private const val MAX_CONNECTION_ATTEMPTS = 3 - private const val CONNECTION_TIMEOUT_MS = 10000 // 10秒 - } - - // 协程相关 - private val job = Job() - override val coroutineContext = Dispatchers.IO + job - - // 事件监听器列表 - private val eventListeners = mutableListOf() - private val audioDataListeners = mutableListOf() - - // 内部音频播放器 - private val audioPlayer = BytedanceAudioPlayer(context) - - // 是否使用内部播放器 - private var useInternalPlayer = true - - // WebSocket连接 - private var webSocket: WebSocket? = null - private val client: OkHttpClient - - // 会话信息 - private var sessionId = "" - private var isConnected = false - private var isSessionStarted = false - - // TTS配置参数 - private var appId: String = "" - private var accessToken: String = "" - private var speaker: String = "zh_female_yuanqinvyou_moon_bigtts" // 默认语音,使用Java示例的音色 - private var format: String = "pcm" // pcm 格式 - private var sampleRate: Int = 16000 // 使用16kHz采样率 - private var enableTimestamp: Boolean = false - private var language: String = "zh-CN" - - // 流式处理状态 - private var currentStatus = STATUS_STOPPED - - // 连接相关状态 - private var connectionAttempts = 0 - private var isConnecting = AtomicBoolean(false) - private var lastConnectionAttemptTime = 0L - - // 在类级别添加文本流通道 - private val textChannel = Channel(Channel.BUFFERED) - private var textProcessingJob: Job? = null - - init { - // 配置OkHttp客户端 - client = OkHttpClient.Builder() - .pingInterval(50, TimeUnit.SECONDS) // 匹配Java示例设置50秒 - .readTimeout(100, TimeUnit.SECONDS) // 匹配Java示例设置100秒 - .writeTimeout(100, TimeUnit.SECONDS) // 匹配Java示例设置100秒 - .build() - - // 生成会话ID - // sessionId = UUID.randomUUID().toString().replace("-", "") - Log.d(TAG, "BytedanceTTS实例化完成!") - - // 初始化音频播放器回调 - initAudioPlayerCallbacks() - - // 启动文本处理协程 - startTextProcessing() - } - - /** - * 初始化音频播放器回调 - */ - private fun initAudioPlayerCallbacks() { - audioPlayer.setOnPlayStarted { - Log.d(TAG, "播放开始") - updateStatus(STATUS_SPEAKING) - notifyEvent(TtsEventType.PLAYBACK_STARTED) - } - - // 设置播放完成回调 - audioPlayer.setOnPlayCompleted { - Log.d(TAG, "播放结束") - updateStatus(STATUS_READY) - notifyEvent(TtsEventType.PLAYBACK_COMPLETED) - } - - // 设置错误回调 - audioPlayer.setOnError { errorMsg -> - updateStatus(STATUS_ERROR) - notifyEvent(TtsEventType.ERROR, mapOf("errorCode" to "PLAYER_ERROR", "errorMessage" to errorMsg)) - } - - // 根据设置决定是否使用内部播放器 - updateInternalPlayerUsage() - } - - /** - * 启动文本处理协程 - */ - private fun startTextProcessing() { - textProcessingJob?.cancel() - textProcessingJob = launch { - try { - for (text in textChannel) { - try { - sendStreamTextRequest(text) - } catch (e: Exception) { - Log.e(TAG, "文本处理错误: ${e.message}") - } - } - } catch (e: CancellationException) { - // 协程被取消,正常情况 - } catch (e: Exception) { - Log.e(TAG, "文本处理异常: ${e.message}") - } - } - } - - /** - * 停止语音合成 - */ - override fun stop(): Boolean { - try { - Log.i(TAG, ">stop()") - - if (useInternalPlayer) { - audioPlayer.stop() - } - textProcessingJob?.cancel() - - - if (isSessionStarted) { - finishSession() - } - updateStatus(STATUS_STOPPED) - - return true - } catch (e: Exception) { - Log.e(TAG, "停止合成失败: ${e.message}") - return false - } - } - - /** - * 初始化火山语音服务 - */ - override fun initialize( - ttsAppId: String, - ttsAppToken: String, - ttsResource: String, - language: String - ): Boolean { - try { - updateStatus(STATUS_INITIALIZING) - - // 设置基本参数 - this.appId = ttsAppId - this.accessToken = ttsAppToken - this.language = language - - // 重置连接状态 - connectionAttempts = 0 - isConnecting.set(false) - lastConnectionAttemptTime = 0 - - // 释放旧连接 - webSocket?.cancel() - webSocket = null - isConnected = false - isSessionStarted = false - - return true - } catch (e: Exception) { - Log.e(TAG, "初始化失败: ${e.message}") - updateStatus(STATUS_ERROR) - return false - } - } - - /** - * 重置服务状态 - */ - fun reset() { - stop() - textProcessingJob?.cancel() - - connectionAttempts = 0 - isConnecting.set(false) - lastConnectionAttemptTime = 0 - - webSocket?.cancel() - webSocket = null - - isConnected = false - isSessionStarted = false - - updateStatus(STATUS_STOPPED) - startTextProcessing() - } - - /** - * 设置语音角色 - */ - override fun setVoice(voiceName: String): Boolean { - try { - speaker = voiceName - return true - } catch (e: Exception) { - val errorMsg = e.message ?: "设置语音角色失败" - notifyEvent(TtsEventType.ERROR, mapOf( - "errorCode" to "VOICE_ERROR", - "errorMessage" to errorMsg - )) - return false - } - } - - /** - * 单次播放(非流式) - */ - override fun speakOnce(text: String): Boolean { - // 未实现:只支持流式模式 - Log.w(TAG, "speakOnce未实现,请使用speakStream") - return false - } - - /** - * 流式播放开始 - */ - override fun speakStream(text: String): Boolean { - try { - if (text.isEmpty()) { - return true - } - - // 确保会话已就绪 - if (!isSessionStarted && !ensureSessionReady()) { - Log.e(TAG, "流式播放失败:无法建立会话") - return false - } - - // 如果文本处理协程不活跃,重新启动它 - if (textProcessingJob == null || textProcessingJob?.isActive == false) { - startTextProcessing() - } - // Log.d(TAG, "speakStream $sessionId, $text ") - - textChannel.trySend(text) - return true - } catch (e: Exception) { - Log.e(TAG, "流式合成失败: ${e.message}") - updateStatus(STATUS_ERROR) - return false - } - } - - /** - * 结束流式播放 - */ - override fun flushStream(): Boolean { - try { - // Log.d(TAG, "flushStream: $sessionId") - - if (isSessionStarted) { - isSessionStarted = false - finishSession() - } - - return true - } catch (e: Exception) { - Log.e(TAG, "结束流式合成失败: ${e.message}") - return false - } - } - - /** - * 释放资源 - */ - override fun release() { - Log.d(TAG, "BytedanceTTS release called.") - - // 关闭文本通道 - textChannel.close() - textProcessingJob?.cancel() - - job.cancel() - - // 释放音频播放器资源 - audioPlayer.release() - - // 重置所有状态 - reset() - - // 确保WebSocket连接完全关闭 - webSocket?.cancel() - webSocket = null - } - - /** - * 添加TTS事件监听器 - */ - override fun addListener(listener: TtsEventListener) { - if (!eventListeners.contains(listener)) { - eventListeners.add(listener) - } - } - - /** - * 移除TTS事件监听器 - */ - override fun removeListener(listener: TtsEventListener) { - eventListeners.remove(listener) - } - - /** - * 添加音频数据监听器 - */ - override fun addAudioDataListener(listener: AudioDataListener) { - if (!audioDataListeners.contains(listener)) { - audioDataListeners.add(listener) - } - } - - /** - * 移除音频数据监听器 - */ - override fun removeAudioDataListener(listener: AudioDataListener) { - audioDataListeners.remove(listener) - } - - /** - * 设置是否使用内部播放器 - */ - override fun setUseInternalPlayer(useInternalPlayer: Boolean) { - this.useInternalPlayer = useInternalPlayer - updateInternalPlayerUsage() - } - - /** - * 设置音频输出设备 - */ - override fun setAudioOutputDevice(device: AudioOutputDevice) : Boolean{ - if (useInternalPlayer) { - audioPlayer.setAudioOutputDevice(device) - } - return true; - } - - /** - * 更新内部播放器的使用状态 - */ - private fun updateInternalPlayerUsage() { - if (useInternalPlayer) { - // 使用内部播放器 - if (!audioDataListeners.contains(audioPlayer)) { - audioDataListeners.add(audioPlayer) - } - } else { - // 不使用内部播放器 - audioDataListeners.remove(audioPlayer) - } - } - - /** - * 通知事件处理 - */ - private fun notifyEvent(eventType: TtsEventType, params: Map = emptyMap()) { - val event = TtsEvent(eventType, params) - val listeners = ArrayList(eventListeners) - - for (listener in listeners) { - try { - listener.onEvent(event) - } catch (e: Exception) { - Log.e(TAG, "事件回调异常: ${e.message}") - } - } - - } - - /** - * 通知音频数据 - */ - private fun notifyAudioData(data: ByteArray) { - for (listener in audioDataListeners) { - try { - listener.onAudioData(data) - } catch (e: Exception) { - Log.e(TAG, "音频数据回调异常: ${e.message}") - } - } - - } - - /** - * 确保会话已就绪 - */ - private fun ensureSessionReady(): Boolean { - // 如果已连接和会话已开始,直接返回成功 - if (isConnected && isSessionStarted) { - return true - } - - // 如果已连接但会话未开始,只需要开始会话 - if (isConnected && !isSessionStarted) { - // Log.d(TAG, "WebSocket已连接,正在启动新会话...") - return startSessionOnly() - } - - // 如果正在连接中,等待连接完成 - if (isConnecting.get()) { - Log.d(TAG, "已有连接正在进行中,等待其完成...") - // 等待连接完成,最多5秒 - var attempts = 0 - while (isConnecting.get() && attempts < 10) { - try { - Thread.sleep(500) - attempts++ - } catch (e: InterruptedException) { - return false - } - } - - // 连接完成后,检查是否需要启动会话 - if (isConnected && !isSessionStarted) { - return startSessionOnly() - } - - return isConnected - } - - // 检查连接重试次数限制 - if (connectionAttempts >= MAX_CONNECTION_ATTEMPTS) { - Log.e(TAG, "已达到最大连接尝试次数($MAX_CONNECTION_ATTEMPTS),不再尝试连接") - return false - } - - // 检查连接间隔时间 - 避免短时间内频繁重连 - val currentTime = System.currentTimeMillis() - if (currentTime - lastConnectionAttemptTime < 2000 && lastConnectionAttemptTime > 0) { - Log.w(TAG, "连接请求太频繁,等待冷却时间...") - Thread.sleep(2000) - } - - // 更新连接计数和时间 - connectionAttempts++ - lastConnectionAttemptTime = System.currentTimeMillis() - - // 尝试建立连接 - isConnecting.set(true) - try { - connect() - - // 等待连接建立,设置超时 - val startWaitTime = System.currentTimeMillis() - while (!isConnected && System.currentTimeMillis() - startWaitTime < CONNECTION_TIMEOUT_MS) { - try { - Thread.sleep(500) - // 如果连接已失败,提前退出等待 - if (!isConnecting.get() && !isConnected) { - Log.e(TAG, "连接已失败,终止等待") - break - } - } catch (e: InterruptedException) { - isConnecting.set(false) - return false - } - } - - // 连接成功后,启动会话 - if (isConnected && !isSessionStarted) { - startSessionOnly() - } - } finally { - isConnecting.set(false) - } - - // 检查连接结果 - if (!isConnected) { - Log.e(TAG, "连接超时: 无法在${CONNECTION_TIMEOUT_MS/1000}秒内建立连接,连接尝试次数: $connectionAttempts") - return false - } - - // 连接成功,重置尝试计数 - connectionAttempts = 0 - return true - } - - /** - * 只启动会话,不重新建立WebSocket连接 - */ - private fun startSessionOnly(): Boolean { - if (!isConnected || webSocket == null) { - Log.e(TAG, "无法启动会话:WebSocket未连接") - return false - } - - if (isSessionStarted) { - return true - } - - try { - // 重新生成会话ID - sessionId = UUID.randomUUID().toString().replace("-", "") - Log.d(TAG, "启动新会话,会话ID: $sessionId") - - // 发送开始会话请求 - startTtsSession(webSocket!!) - - // 立即将会话视为已启动,不等待服务器确认 - isSessionStarted = true - updateStatus(STATUS_READY) - - return true - } catch (e: Exception) { - Log.e(TAG, "启动会话失败: ${e.message}", e) - return false - } - } - - /** - * 更新状态并通知Dart层 - */ - private fun updateStatus(status: String) { - currentStatus = status - } - - /** - * 建立WebSocket连接 - */ - private fun connect() { - webSocket?.cancel() - webSocket = null - - val url = "wss://openspeech.bytedance.com/api/v3/tts/bidirection" - - val connectId = UUID.randomUUID().toString() - val request = Request.Builder() - .url(url) - .header("X-Api-App-Key", appId) - .header("X-Api-Access-Key", accessToken) - .header("X-Api-Resource-Id", "volc.service_type.10029") - .header("X-Api-Connect-Id", connectId) - .build() - - isConnected = false - isSessionStarted = false - - webSocket = client.newWebSocket(request, object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { - isConnected = true - startConnection(webSocket) - } - - override fun onMessage(webSocket: WebSocket, bytes: ByteString) { - try { - val data = bytes.toByteArray() - val response = parseResponse(data) - - when (response.event) { - EVENT_CONNECTION_FAILED, EVENT_SESSION_FAILED -> { - val errorMsg = if (response.responseMetaJson.isNotEmpty()) { - response.responseMetaJson - } else { - "连接或会话失败" - } - notifyEvent(TtsEventType.ERROR, mapOf("errorCode" to "TTS_ERROR", - "errorMessage" to errorMsg)) - webSocket.close(1000, "Error") - updateStatus(STATUS_ERROR) - isSessionStarted = false - isConnected = false - isConnecting.set(false) - } - - EVENT_SESSION_STARTED -> { - connectionAttempts = 0 - // 会话开始时触发合成开始事件 - notifyEvent(TtsEventType.SYNTHESIS_STARTED) - // 如果使用内部播放器则启动播放器会话 - if (useInternalPlayer) { - audioPlayer.startSession() - } - } - - EVENT_TTS_RESPONSE -> { - if (response.messageType == AUDIO_ONLY_RESPONSE && response.payload.isNotEmpty()) { - notifyAudioData(response.payload) - } - } - - EVENT_CONNECTION_FINISHED -> { - isConnected = false - isSessionStarted = false - isConnecting.set(false) - webSocket.close(1000, "Connection finished") - } - - EVENT_SESSION_FINISHED -> { - isSessionStarted = false - Log.i(TAG, "EVENT_SESSION_FINISHED, ${sessionId}") - - // 触发合成完成事件 - notifyEvent(TtsEventType.SYNTHESIS_COMPLETED) - - // 如果使用内部播放器则结束播放器会话 - if (useInternalPlayer) { - audioPlayer.endSession() - } - - // 流式模式下不自动关闭连接 - } - } - } catch (e: Exception) { - Log.e(TAG, "解析响应失败: ${e.message}") - updateStatus(STATUS_ERROR) - isConnecting.set(false) - } - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - isConnected = false - isSessionStarted = false - isConnecting.set(false) - - Log.e(TAG, "WebSocket连接失败: ${t.message}") - notifyEvent(TtsEventType.ERROR, mapOf("errorCode" to "CONNECTION_ERROR", - "errorMessage" to (t.message ?: "连接失败"))) - updateStatus(STATUS_ERROR) - } - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - isConnected = false - isSessionStarted = false - isConnecting.set(false) - updateStatus(STATUS_STOPPED) - notifyEvent(TtsEventType.SYNTHESIS_CANCELED) - } - }) - } - - /** - * 开始连接 - */ - private fun startConnection(webSocket: WebSocket) { - val header = Header( - protocolVersion = PROTOCOL_VERSION, - headerSize = DEFAULT_HEADER_SIZE, - messageType = FULL_CLIENT_REQUEST, - messageTypeSpecificFlags = MSG_TYPE_FLAG_WITH_EVENT, - serializationMethod = JSON, - messageCompression = COMPRESSION_NO, - reserved = 0 - ) - - val optional = Optional( - event = EVENT_START_CONNECTION - ) - - val payload = "{}".toByteArray() - - sendEvent(webSocket, header, optional, payload) - } - - /** - * 结束连接 - */ - private fun finishConnection(webSocket: WebSocket) { - val header = Header( - protocolVersion = PROTOCOL_VERSION, - headerSize = DEFAULT_HEADER_SIZE, - messageType = FULL_CLIENT_REQUEST, - messageTypeSpecificFlags = MSG_TYPE_FLAG_WITH_EVENT, - serializationMethod = JSON, - messageCompression = COMPRESSION_NO, - reserved = 0 - ) - - val optional = Optional( - event = EVENT_FINISH_CONNECTION - ) - - val payload = "{}".toByteArray() - - sendEvent(webSocket, header, optional, payload) - } - - /** - * 开始TTS会话 - */ - private fun startTtsSession(webSocket: WebSocket) { - val header = Header( - protocolVersion = PROTOCOL_VERSION, - headerSize = DEFAULT_HEADER_SIZE, - messageType = FULL_CLIENT_REQUEST, - messageTypeSpecificFlags = MSG_TYPE_FLAG_WITH_EVENT, - serializationMethod = JSON, - messageCompression = COMPRESSION_NO, - reserved = 0 - ) - - val optional = Optional( - event = EVENT_START_SESSION, - sessionId = sessionId - ) - - val jsonObject = JSONObject() - val user = JSONObject() - user.put("uid", "123456") - jsonObject.put("user", user) - jsonObject.put("event", EVENT_START_SESSION) - jsonObject.put("namespace", "BidirectionalTTS") - - val reqParams = JSONObject() - reqParams.put("speaker", speaker) - - val audioParams = JSONObject() - audioParams.put("format", format) - audioParams.put("sample_rate", sampleRate) - audioParams.put("enable_timestamp", enableTimestamp) - - reqParams.put("audio_params", audioParams) - jsonObject.put("req_params", reqParams) - - val payload = jsonObject.toString().toByteArray() - - sendEvent(webSocket, header, optional, payload) - } - - - /** - * 发送文本进行合成 (流式) - */ - private fun sendStreamTextRequest(text: String) { - webSocket?.let { ws -> - val header = Header( - protocolVersion = PROTOCOL_VERSION, - headerSize = DEFAULT_HEADER_SIZE, - messageType = FULL_CLIENT_REQUEST, - messageTypeSpecificFlags = MSG_TYPE_FLAG_WITH_EVENT, - serializationMethod = JSON, - messageCompression = COMPRESSION_NO, - reserved = 0 - ) - - val optional = Optional( - event = EVENT_TASK_REQUEST, - sessionId = sessionId - ) - - val jsonObject = JSONObject() - val user = JSONObject() - user.put("uid", "123456") - jsonObject.put("user", user) - jsonObject.put("event", EVENT_TASK_REQUEST) - jsonObject.put("namespace", "BidirectionalTTS") - - val reqParams = JSONObject() - // 将#和*替换为空格 - val processedText = text.replace("#", " ").replace("*", " ") - reqParams.put("text", processedText) - reqParams.put("speaker", speaker) - - val audioParams = JSONObject() - audioParams.put("format", format) - audioParams.put("sample_rate", sampleRate) - audioParams.put("emotion", "happy") - - reqParams.put("audio_params", audioParams) - jsonObject.put("req_params", reqParams) - - val payload = jsonObject.toString().toByteArray() - // Log.d(TAG, "发送流式文本合成请求: ${jsonObject.toString()}") - - sendEvent(ws, header, optional, payload) - // 流式模式下不自动结束会话 - } - } - - /** - * 结束会话 - */ - private fun finishSession() { - webSocket?.let { ws -> - val header = Header( - protocolVersion = PROTOCOL_VERSION, - headerSize = DEFAULT_HEADER_SIZE, - messageType = FULL_CLIENT_REQUEST, - messageTypeSpecificFlags = MSG_TYPE_FLAG_WITH_EVENT, - serializationMethod = JSON, - messageCompression = COMPRESSION_NO, - reserved = 0 - ) - - val optional = Optional( - event = EVENT_FINISH_SESSION, - sessionId = sessionId - ) - - val payload = "{}".toByteArray() - - sendEvent(ws, header, optional, payload) - Log.d(TAG, "发送结束会话请求,会话ID: $sessionId") - // 不在这里设置 isSessionStarted = false - // 等待服务器的 SESSION_FINISHED 事件来设置 - } - } - - /** - * 发送事件 - */ - private fun sendEvent(webSocket: WebSocket, header: Header, optional: Optional, payload: ByteArray) { - try { - val headerBytes = header.getBytes() - val optionalBytes = optional.getBytes() - val payloadSizeBytes = intToBytes(payload.size) - - val requestBytes = ByteArray( - headerBytes.size + optionalBytes.size + payloadSizeBytes.size + payload.size - ) - var offset = 0 - - System.arraycopy(headerBytes, 0, requestBytes, offset, headerBytes.size) - offset += headerBytes.size - - System.arraycopy(optionalBytes, 0, requestBytes, offset, optionalBytes.size) - offset += optionalBytes.size - - System.arraycopy(payloadSizeBytes, 0, requestBytes, offset, payloadSizeBytes.size) - offset += payloadSizeBytes.size - - System.arraycopy(payload, 0, requestBytes, offset, payload.size) - - webSocket.send(ByteString.of(*requestBytes)) - } catch (e: Exception) { - Log.e(TAG, "发送WebSocket消息失败: ${e.message}") - } - } - - /** - * 解析响应 - */ - private fun parseResponse(data: ByteArray): TtsResponse { - val response = TtsResponse() - - // 解析头部 - val header = Header() - header.protocolVersion = (data[0].toInt() shr 4) and 0x0F - header.headerSize = data[0].toInt() and 0x0F - header.messageType = (data[1].toInt() shr 4) and 0x0F - header.messageTypeSpecificFlags = data[1].toInt() and 0x0F - header.serializationMethod = (data[2].toInt() shr 4) and 0x0F - header.messageCompression = data[2].toInt() and 0x0F - header.reserved = data[3].toInt() - - response.header = header - response.messageType = header.messageType - - var offset = 4 - - // 读取事件 - if (header.messageTypeSpecificFlags == MSG_TYPE_FLAG_WITH_EVENT) { - val eventBytes = ByteArray(4) - System.arraycopy(data, offset, eventBytes, 0, 4) - response.event = bytesToInt(eventBytes) - offset += 4 - } - - // 根据事件类型处理不同数据 - when (response.event) { - EVENT_CONNECTION_STARTED -> { - // 读取连接ID - offset = readConnectId(data, response, offset) - } - EVENT_CONNECTION_FAILED -> { - offset = readConnectId(data, response, offset) - readMetaJson(data, response, offset) - } - EVENT_SESSION_STARTED, - EVENT_TTS_RESPONSE, - EVENT_TTS_SENTENCE_START, - EVENT_TTS_SENTENCE_END -> { - offset = readSessionId(data, response, offset) - if (response.messageType == AUDIO_ONLY_RESPONSE) { - offset = readPayload(data, response, offset) - } - } - EVENT_SESSION_FAILED, - EVENT_SESSION_FINISHED -> { - offset = readSessionId(data, response, offset) - readMetaJson(data, response, offset) - } - EVENT_CONNECTION_FINISHED -> { - // 会话结束 - } - } - - return response - } - - /** - * 读取连接ID - */ - private fun readConnectId(data: ByteArray, response: TtsResponse, offset: Int): Int { - val sizeBytes = ByteArray(4) - System.arraycopy(data, offset, sizeBytes, 0, 4) - val size = bytesToInt(sizeBytes) - - if (size > 0 && offset + 4 + size <= data.size) { - val textBytes = ByteArray(size) - System.arraycopy(data, offset + 4, textBytes, 0, size) - response.connectionId = String(textBytes) - } - - return offset + 4 + size - } - - /** - * 读取元数据JSON - */ - private fun readMetaJson(data: ByteArray, response: TtsResponse, offset: Int): Int { - val sizeBytes = ByteArray(4) - System.arraycopy(data, offset, sizeBytes, 0, 4) - val size = bytesToInt(sizeBytes) - - if (size > 0 && offset + 4 + size <= data.size) { - val textBytes = ByteArray(size) - System.arraycopy(data, offset + 4, textBytes, 0, size) - response.responseMetaJson = String(textBytes) - } - - return offset + 4 + size - } - - /** - * 读取会话ID - */ - private fun readSessionId(data: ByteArray, response: TtsResponse, offset: Int): Int { - val sizeBytes = ByteArray(4) - System.arraycopy(data, offset, sizeBytes, 0, 4) - val size = bytesToInt(sizeBytes) - - if (size > 0 && offset + 4 + size <= data.size) { - val textBytes = ByteArray(size) - System.arraycopy(data, offset + 4, textBytes, 0, size) - response.sessionId = String(textBytes) - } - - return offset + 4 + size - } - - /** - * 读取负载数据 - */ - private fun readPayload(data: ByteArray, response: TtsResponse, offset: Int): Int { - val sizeBytes = ByteArray(4) - System.arraycopy(data, offset, sizeBytes, 0, 4) - val size = bytesToInt(sizeBytes) - - if (size > 0 && offset + 4 + size <= data.size) { - val payload = ByteArray(size) - System.arraycopy(data, offset + 4, payload, 0, size) - response.payload = payload - } - - return offset + 4 + size - } - - /** - * 整数转字节数组 (大端序) - */ - private fun intToBytes(value: Int): ByteArray { - return byteArrayOf( - ((value shr 24) and 0xFF).toByte(), - ((value shr 16) and 0xFF).toByte(), - ((value shr 8) and 0xFF).toByte(), - (value and 0xFF).toByte() - ) - } - - /** - * 字节数组转整数 (大端序) - */ - private fun bytesToInt(bytes: ByteArray): Int { - if (bytes.size != 4) { - throw IllegalArgumentException("字节数组长度必须为4") - } - - return ((bytes[0].toInt() and 0xFF) shl 24) or - ((bytes[1].toInt() and 0xFF) shl 16) or - ((bytes[2].toInt() and 0xFF) shl 8) or - (bytes[3].toInt() and 0xFF) - } - - /** - * 消息头数据类 - */ - private data class Header( - var protocolVersion: Int = PROTOCOL_VERSION, - var headerSize: Int = DEFAULT_HEADER_SIZE, - var messageType: Int = 0, - var messageTypeSpecificFlags: Int = 0, - var serializationMethod: Int = 0, - var messageCompression: Int = 0, - var reserved: Int = 0 - ) { - /** - * 转成字节数组 - */ - fun getBytes(): ByteArray { - return byteArrayOf( - ((protocolVersion shl 4) or headerSize).toByte(), - ((messageType shl 4) or messageTypeSpecificFlags).toByte(), - ((serializationMethod shl 4) or messageCompression).toByte(), - reserved.toByte() - ) - } - } - - /** - * 可选字段数据类 - */ - private data class Optional( - var event: Int = EVENT_NONE, - var sessionId: String = "" - ) { - /** - * 转成字节数组 - */ - fun getBytes(): ByteArray { - var bytes = ByteArray(0) - - // 添加事件字段 - if (event != EVENT_NONE) { - bytes = intToBytes(event) - } - - // 添加会话ID字段 - if (sessionId.isNotEmpty()) { - val sessionIdBytes = sessionId.toByteArray() - val sessionIdSizeBytes = intToBytes(sessionIdBytes.size) - - val temp = bytes - val newBytes = ByteArray(temp.size + sessionIdSizeBytes.size + sessionIdBytes.size) - - var offset = 0 - System.arraycopy(temp, 0, newBytes, offset, temp.size) - offset += temp.size - - System.arraycopy(sessionIdSizeBytes, 0, newBytes, offset, sessionIdSizeBytes.size) - offset += sessionIdSizeBytes.size - - System.arraycopy(sessionIdBytes, 0, newBytes, offset, sessionIdBytes.size) - - bytes = newBytes - } - - return bytes - } - - /** - * 整数转字节数组 (大端序) - */ - private fun intToBytes(value: Int): ByteArray { - return byteArrayOf( - ((value shr 24) and 0xFF).toByte(), - ((value shr 16) and 0xFF).toByte(), - ((value shr 8) and 0xFF).toByte(), - (value and 0xFF).toByte() - ) - } - } - - /** - * TTS响应数据类 - */ - private data class TtsResponse( - var header: Header = Header(), - var event: Int = EVENT_NONE, - var messageType: Int = 0, - var sessionId: String = "", - var connectionId: String = "", - var responseMetaJson: String = "", - var payload: ByteArray = ByteArray(0) - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as TtsResponse - - if (header != other.header) return false - if (event != other.event) return false - if (messageType != other.messageType) return false - if (sessionId != other.sessionId) return false - if (connectionId != other.connectionId) return false - if (responseMetaJson != other.responseMetaJson) return false - if (!payload.contentEquals(other.payload)) return false - - return true - } - - override fun hashCode(): Int { - var result = header.hashCode() - result = 31 * result + event - result = 31 * result + messageType - result = 31 * result + sessionId.hashCode() - result = 31 * result + connectionId.hashCode() - result = 31 * result + responseMetaJson.hashCode() - result = 31 * result + payload.contentHashCode() - return result - } - } - -} \ No newline at end of file diff --git a/apps/client/local_plugins/bytedance_speech/lib/bytedance_speech.dart b/apps/client/local_plugins/bytedance_speech/lib/bytedance_speech.dart deleted file mode 100644 index 522adb9b..00000000 --- a/apps/client/local_plugins/bytedance_speech/lib/bytedance_speech.dart +++ /dev/null @@ -1,12 +0,0 @@ -library bytedance_speech; - -import 'package:flutter/services.dart'; - -class BytedanceSpeech { - static const MethodChannel _channel = MethodChannel('bytedance_speech'); - - static Future getPlatformVersion() async { - final String? version = await _channel.invokeMethod('getPlatformVersion'); - return version; - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/bytedance_speech/pubspec.yaml b/apps/client/local_plugins/bytedance_speech/pubspec.yaml deleted file mode 100644 index 3bb55f40..00000000 --- a/apps/client/local_plugins/bytedance_speech/pubspec.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: bytedance_speech -description: 火山引擎语音识别和语音合成Flutter插件 -version: 0.0.1 -homepage: - -environment: - sdk: ">=2.19.0 <4.0.0" - flutter: ">=3.0.0" - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -flutter: - plugin: - platforms: - android: - package: com.deep_voice.bytedance_speech - pluginClass: BytedanceSpeechPlugin - ios: - pluginClass: BytedanceSpeechPlugin \ No newline at end of file diff --git a/apps/client/local_plugins/device_jieli/android/.gitignore b/apps/client/local_plugins/device_jieli/android/.gitignore deleted file mode 100644 index 161bdcda..00000000 --- a/apps/client/local_plugins/device_jieli/android/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea/workspace.xml -/.idea/libraries -.DS_Store -/build -/captures -.cxx diff --git a/apps/client/local_plugins/device_jieli/android/build.gradle b/apps/client/local_plugins/device_jieli/android/build.gradle deleted file mode 100644 index 9756badd..00000000 --- a/apps/client/local_plugins/device_jieli/android/build.gradle +++ /dev/null @@ -1,80 +0,0 @@ -group = "com.jielihome.jielihome" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "2.1.0" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.7.3") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - flatDir { - dirs "libs" - } - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace = "com.jielihome.jielihome" - - compileSdk = 36 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - minSdk = 21 - } - - dependencies { - // The JieLi AARs are compile-only here. The host app must redeclare them - // as runtime dependencies in its own build.gradle (see example/android/app). - compileOnly fileTree(dir: 'libs', include: ['*.aar', '*.jar']) - // device_plugin_interface 提供 DeviceCallTranslationPort 等通用接口, - // 让 JieliCallTranslationPort 可以无 vendor 耦合地被 translate_server 消费。 - implementation project(':device_plugin_interface') - implementation "com.squareup.okhttp3:okhttp:4.12.0" - implementation "com.google.code.gson:gson:2.10.1" - implementation "androidx.core:core-ktx:1.13.1" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" - - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.0.0") - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} diff --git a/apps/client/local_plugins/device_jieli/android/libs/jl_audio_decode_V2.1.0_20012-release.aar b/apps/client/local_plugins/device_jieli/android/libs/jl_audio_decode_V2.1.0_20012-release.aar deleted file mode 100644 index 1a609561..00000000 Binary files a/apps/client/local_plugins/device_jieli/android/libs/jl_audio_decode_V2.1.0_20012-release.aar and /dev/null differ diff --git a/apps/client/local_plugins/device_jieli/android/libs/jl_audio_v2_V1.0.0_9-release.aar b/apps/client/local_plugins/device_jieli/android/libs/jl_audio_v2_V1.0.0_9-release.aar deleted file mode 100644 index 9bfae54a..00000000 Binary files a/apps/client/local_plugins/device_jieli/android/libs/jl_audio_v2_V1.0.0_9-release.aar and /dev/null differ diff --git a/apps/client/local_plugins/device_jieli/android/libs/jl_bluetooth_rcsp_V4.2.0_beta1_40255_20260324.aar b/apps/client/local_plugins/device_jieli/android/libs/jl_bluetooth_rcsp_V4.2.0_beta1_40255_20260324.aar deleted file mode 100644 index 0ff72f1c..00000000 Binary files a/apps/client/local_plugins/device_jieli/android/libs/jl_bluetooth_rcsp_V4.2.0_beta1_40255_20260324.aar and /dev/null differ diff --git a/apps/client/local_plugins/device_jieli/android/libs/jldecryption_v0.4-release.aar b/apps/client/local_plugins/device_jieli/android/libs/jldecryption_v0.4-release.aar deleted file mode 100644 index fd933770..00000000 Binary files a/apps/client/local_plugins/device_jieli/android/libs/jldecryption_v0.4-release.aar and /dev/null differ diff --git a/apps/client/local_plugins/device_jieli/android/settings.gradle b/apps/client/local_plugins/device_jieli/android/settings.gradle deleted file mode 100644 index 8d18be72..00000000 --- a/apps/client/local_plugins/device_jieli/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'jielihome' diff --git a/apps/client/local_plugins/device_jieli/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/device_jieli/android/src/main/AndroidManifest.xml deleted file mode 100644 index 368a6627..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/JielihomePlugin.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/JielihomePlugin.kt deleted file mode 100644 index 4828c7db..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/JielihomePlugin.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.jielihome.jielihome - -import com.jielihome.jielihome.bridge.MethodRouter -import com.jielihome.jielihome.core.JieliHomeServer -import com.jielihome.jielihome.integration.JieliNativeDevicePlugin -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MethodChannel - -/** - * Flutter 入口,仅负责注册 Channel 并把请求转交给 MethodRouter。 - * 全部业务逻辑在 [JieliHomeServer] 与 feature/event 子模块中。 - */ -class JielihomePlugin : FlutterPlugin { - - private lateinit var methodChannel: MethodChannel - private lateinit var eventChannel: EventChannel - private lateinit var router: MethodRouter - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - val server = JieliHomeServer.get() - router = MethodRouter(binding.applicationContext, server) - - methodChannel = MethodChannel(binding.binaryMessenger, "device_jieli/method") - methodChannel.setMethodCallHandler(router) - - eventChannel = EventChannel(binding.binaryMessenger, "device_jieli/event") - eventChannel.setStreamHandler(server.dispatcher) - - // 把杰理 vendor 注册到 device_manager 的 NativeDevicePluginRegistry: - // 这样 app 走 device_manager 路径时能 listVendors / useVendor("jieli")。 - // SDK 直连路径(device_jieli/method 上面那条)保留不变;两条路径并存。 - JieliNativeDevicePlugin.register(binding.applicationContext) - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - methodChannel.setMethodCallHandler(null) - eventChannel.setStreamHandler(null) - // 进程级资源不在这里 shutdown,保持后续 attach 仍可继续使用 - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt deleted file mode 100644 index 908960f6..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.jielihome.jielihome.api - -/** - * 原生层事件监听器。宿主 App 在 Activity/Service 里实现并注册到 - * [com.jielihome.jielihome.core.JieliHomeServer.addEventListener], - * 即可订阅插件所有事件而不依赖 Flutter EventChannel。 - * - * 所有回调里的 payload 都是 [Map],字段与 Dart 侧 EventChannel 推上去的完全一致, - * 因此原生订阅与 Dart 订阅可以并存(fan-out)。 - * - * 默认提供「按 type 路由」的便捷扩展,子类只覆盖关心的方法即可。 - */ -interface JieliEventListener { - /** 任意事件,原始 payload。可以自己按 payload["type"] 路由。 */ - fun onEvent(payload: Map) {} -} - -/** - * 按事件类型分发的便捷适配器。子类只覆盖关心的回调,未覆盖的事件会落到 [onUnknown]。 - */ -abstract class JieliEventAdapter : JieliEventListener { - - override fun onEvent(payload: Map) { - when (payload["type"] as? String) { - "adapterStatus" -> onAdapterStatus( - enabled = payload["enabled"] as? Boolean ?: false, - hasBle = payload["hasBle"] as? Boolean ?: false, - ) - - "scanStatus" -> onScanStatus( - ble = payload["ble"] as? Boolean ?: false, - started = payload["started"] as? Boolean ?: false, - ) - - "deviceFound" -> onDeviceFound(payload) - "bondStatus" -> onBondStatus( - address = payload["address"] as? String ?: "", - status = payload["status"] as? Int ?: 0, - ) - "rcspInit" -> onRcspInit( - address = payload["address"] as? String ?: "", - code = payload["code"] as? Int ?: -1, - ) - "connectionState" -> onConnectionState( - address = payload["address"] as? String ?: "", - state = payload["state"] as? Int ?: -1, - ) - - "battery" -> onBattery( - address = payload["address"] as? String, - level = payload["level"] as? Int, - ) - "phoneCallStatus" -> onPhoneCallStatus( - address = payload["address"] as? String, - status = payload["status"] as? Int ?: 0, - ) - "voiceMode" -> onVoiceMode( - address = payload["address"] as? String, - modeId = payload["modeId"] as? Int, - ) - - "volume" -> onVolume(payload) - "musicName" -> onMusicName( - address = payload["address"] as? String, - name = payload["name"] as? String, - ) - "musicStatus" -> onMusicStatus(payload) - "playMode" -> onPlayMode( - address = payload["address"] as? String, - mode = payload["mode"] as? Int, - ) - - "expandFunction" -> onExpandFunction(payload) - - "translationAudio" -> { - onTranslationAudio(payload) - onTranslationEvent(payload) - } - "translationResult" -> { - onTranslationResult(payload) - onTranslationEvent(payload) - } - "translationError" -> { - onTranslationError(payload) - onTranslationEvent(payload) - } - "translationLog" -> { - onTranslationLog(payload) - onTranslationEvent(payload) - } - - "speechStart" -> onSpeechStart(payload) - "speechAudio" -> onSpeechAudio(payload) - "speechEnd" -> onSpeechEnd(payload) - "speechError" -> onSpeechError(payload) - - "deviceRecordStart" -> onDeviceRecordStart(payload) - "deviceRecordAudio" -> onDeviceRecordAudio(payload) - "deviceRecordStop" -> onDeviceRecordStop(payload) - "deviceRecordError" -> onDeviceRecordError(payload) - - "otaState" -> onOtaState(payload) - "otaError" -> onOtaError(payload) - - else -> onUnknown(payload) - } - } - - open fun onAdapterStatus(enabled: Boolean, hasBle: Boolean) {} - open fun onScanStatus(ble: Boolean, started: Boolean) {} - open fun onDeviceFound(payload: Map) {} - open fun onBondStatus(address: String, status: Int) {} - open fun onRcspInit(address: String, code: Int) {} - open fun onConnectionState(address: String, state: Int) {} - - open fun onBattery(address: String?, level: Int?) {} - open fun onPhoneCallStatus(address: String?, status: Int) {} - open fun onVoiceMode(address: String?, modeId: Int?) {} - - open fun onVolume(payload: Map) {} - open fun onMusicName(address: String?, name: String?) {} - open fun onMusicStatus(payload: Map) {} - open fun onPlayMode(address: String?, mode: Int?) {} - - open fun onExpandFunction(payload: Map) {} - - /** 翻译事件统一入口(兜底,默认细分回调之后还会触发一次) */ - open fun onTranslationEvent(payload: Map) {} - - /** payload["pcm"] 为 ByteArray;附带 streamId/sampleRate/seq/tsMs/final 字段 */ - open fun onTranslationAudio(payload: Map) {} - - /** payload 含 srcLang/srcText/destLang/destText/requestId */ - open fun onTranslationResult(payload: Map) {} - - /** payload 含 code/message */ - open fun onTranslationError(payload: Map) {} - - open fun onTranslationLog(payload: Map) {} - - /** 耳机检测到唤醒/语音助手触发;payload: voiceType, sampleRate, vadWay, address, tsMs */ - open fun onSpeechStart(payload: Map) {} - - /** 语音助手音频帧;payload: pcm(ByteArray), encoding, sampleRate, channels, address */ - open fun onSpeechAudio(payload: Map) {} - - /** 语音助手会话结束;payload: reason, message, address */ - open fun onSpeechEnd(payload: Map) {} - - open fun onSpeechError(payload: Map) {} - - // ── 设备录音 ──────────────────────────────────────────────────────────── - - /** 录音上行已启动;payload: address, sampleRate, tsMs */ - open fun onDeviceRecordStart(payload: Map) {} - - /** - * 录音音频帧;payload: address, streamId("in.uplink"/"in.downlink"), - * pcm(ByteArray), sampleRate, channels, bitsPerSample, tsMs - */ - open fun onDeviceRecordAudio(payload: Map) {} - - /** 录音上行已停止;payload: address, tsMs */ - open fun onDeviceRecordStop(payload: Map) {} - - /** 录音错误;payload: address, code, message */ - open fun onDeviceRecordError(payload: Map) {} - - /** OTA 进度/状态:state(String), sent(Long), total(Long), percent(Int) */ - open fun onOtaState(payload: Map) {} - - open fun onOtaError(payload: Map) {} - - open fun onUnknown(payload: Map) {} -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/LocalPlayer.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/LocalPlayer.kt deleted file mode 100644 index 01e9cfd3..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/LocalPlayer.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.jielihome.jielihome.audio - -import android.media.AudioAttributes -import android.media.AudioFormat -import android.media.AudioManager -import android.media.AudioTrack -import java.util.concurrent.atomic.AtomicBoolean - -/** - * 本地 PCM 播放(A2DP 路径)。用于音视频翻译模式:TTS 不回耳机,直接走 A2DP。 - */ -class LocalPlayer( - private val sampleRate: Int = 16000, - private val channels: Int = 1, -) { - private var track: AudioTrack? = null - private val playing = AtomicBoolean(false) - - fun start() { - if (playing.get()) return - val cfg = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO - val minBuf = AudioTrack.getMinBufferSize(sampleRate, cfg, AudioFormat.ENCODING_PCM_16BIT) - - track = AudioTrack.Builder() - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() - ) - .setAudioFormat( - AudioFormat.Builder() - .setSampleRate(sampleRate) - .setChannelMask(cfg) - .setEncoding(AudioFormat.ENCODING_PCM_16BIT) - .build() - ) - .setBufferSizeInBytes(maxOf(minBuf, sampleRate * 2 * channels)) // 1s - .setTransferMode(AudioTrack.MODE_STREAM) - .build() - track?.play() - playing.set(true) - } - - fun feed(pcm: ByteArray) { - if (!playing.get()) return - runCatching { track?.write(pcm, 0, pcm.size) } - } - - fun stop() { - if (!playing.compareAndSet(true, false)) return - runCatching { track?.stop() } - runCatching { track?.release() } - track = null - } - - @Suppress("unused") - fun setVolumeFollowingSystemMedia() { - // hint: 用 AudioManager.STREAM_MUSIC 已由 USAGE_MEDIA 隐式选择 - @Suppress("DEPRECATION") - AudioManager.STREAM_MUSIC // no-op - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt deleted file mode 100644 index 370357ba..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.jielihome.jielihome.audio - -import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback -import com.jieli.jl_audio_decode.callback.OnEncodeStreamCallback -import com.jieli.jl_audio_decode.opus.OpusManager -import com.jieli.jl_audio_decode.opus.model.OpusOption - -/** - * OPUS 流式解码器。 - * 输入:从耳机收到的 OPUS 编码帧 - * 输出:16kHz / mono PCM 流 - * - * **packetSize 是单个 OPUS 帧的实际字节大小,不是"传输块大小"**。 - * 16k/16bit/mono/20ms ≈ 40B/帧,stereo ≈ 80B/帧。 - * 杰理 SDK demo 里用 200/80 这类"块"值会让 OpusManager 把多帧拼包当成 1 帧解, - * 直接丢掉后面的帧(mono 200B = 5 帧 → 只解 1 帧,丢 80%)。 - */ -class OpusStreamDecoder( - private val channel: Int = 1, - private val packetSize: Int = 200, - private val sampleRate: Int = 16000, - private val hasHead: Boolean = false, - private val onPcm: (pcm: ByteArray) -> Unit, - private val onError: (code: Int, msg: String?) -> Unit = { _, _ -> }, -) { - private val manager = OpusManager() - - @Volatile - private var started = false - - fun start() { - if (started) return - val option = OpusOption() - .setHasHead(hasHead) - .setChannel(channel) - .setPacketSize(packetSize) - .setSampleRate(sampleRate) - manager.startDecodeStream(option, object : OnDecodeStreamCallback { - override fun onDecodeStream(pcm: ByteArray) { runCatching { onPcm(pcm) } } - override fun onStart() {} - override fun onComplete(p0: String?) {} - override fun onError(code: Int, msg: String?) { - runCatching { onError(code, msg) } - } - }) - started = true - } - - fun feedEncoded(opus: ByteArray) { - if (!started) return - runCatching { manager.writeAudioStream(opus) } - } - - fun stop() { - if (!started) return - runCatching { manager.stopDecodeStream() } - runCatching { manager.release() } - started = false - } -} - -/** - * OPUS 流式编码器。把 PCM 编码成 OPUS 帧后回调 [onOpus],由调用方包成 AudioData 回送给设备。 - */ -class OpusStreamEncoder( - private val channel: Int = 1, - private val packetSize: Int = 200, - private val sampleRate: Int = 16000, - private val hasHead: Boolean = false, - private val onOpus: (opus: ByteArray) -> Unit, - private val onError: (code: Int, msg: String?) -> Unit = { _, _ -> }, -) { - private val manager = OpusManager() - - @Volatile - private var started = false - - fun start() { - if (started) return - val option = OpusOption() - .setHasHead(hasHead) - .setChannel(channel) - .setPacketSize(packetSize) - .setSampleRate(sampleRate) - manager.startEncodeStream(option, object : OnEncodeStreamCallback { - override fun onEncodeStream(opus: ByteArray) { runCatching { onOpus(opus) } } - override fun onStart() {} - override fun onComplete(p0: String?) {} - override fun onError(code: Int, msg: String?) { - runCatching { onError(code, msg) } - } - }) - started = true - } - - fun feedPcm(pcm: ByteArray) { - if (!started) return - runCatching { manager.writeEncodeStream(pcm) } - } - - fun stop() { - if (!started) return - runCatching { manager.stopEncodeStream() } - runCatching { manager.release() } - started = false - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PcmKit.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PcmKit.kt deleted file mode 100644 index acf037f0..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PcmKit.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.jielihome.jielihome.audio - -object PcmKit { - /** - * 把 16bit 立体声 PCM 拆为左/右两路单声道。 - * 入参字节流:L0_lo L0_hi R0_lo R0_hi L1_lo L1_hi R1_lo R1_hi ... - * 返回:Pair(leftMono, rightMono) - */ - fun splitStereo16(pcm: ByteArray): Pair { - val frames = pcm.size / 4 - val left = ByteArray(frames * 2) - val right = ByteArray(frames * 2) - var li = 0 - var ri = 0 - var i = 0 - while (i + 3 < pcm.size) { - left[li++] = pcm[i] - left[li++] = pcm[i + 1] - right[ri++] = pcm[i + 2] - right[ri++] = pcm[i + 3] - i += 4 - } - return left to right - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PhoneMicCapture.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PhoneMicCapture.kt deleted file mode 100644 index c602a866..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PhoneMicCapture.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.jielihome.jielihome.audio - -import android.Manifest -import android.annotation.SuppressLint -import android.content.Context -import android.content.pm.PackageManager -import android.media.AudioFormat -import android.media.AudioRecord -import android.media.MediaRecorder -import android.util.Log -import androidx.core.content.ContextCompat -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.concurrent.thread - -/** - * 手机麦克风采集,输出 16kHz / 16bit / mono PCM。 - * 每 20ms 切一帧(320 samples = 640 bytes)。 - * - * 用法: - * val mic = PhoneMicCapture(context) { pcm -> /* push to bridge */ } - * mic.start() - * ... - * mic.stop() - */ -class PhoneMicCapture( - private val context: Context, - private val sampleRate: Int = 16000, - private val frameDurationMs: Int = 20, - private val onFrame: (pcm: ByteArray) -> Unit, - private val onError: (code: Int, msg: String) -> Unit = { _, _ -> }, -) { - private val running = AtomicBoolean(false) - private var recorder: AudioRecord? = null - private var worker: Thread? = null - - val frameSizeBytes: Int = sampleRate * frameDurationMs / 1000 * 2 /* 16bit */ - - @SuppressLint("MissingPermission") - fun start(): Boolean { - if (running.get()) return true - if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) - != PackageManager.PERMISSION_GRANTED - ) { - Log.w(TAG, "RECORD_AUDIO permission missing") - onError(-101, "RECORD_AUDIO permission missing") - return false - } - - val minBuf = AudioRecord.getMinBufferSize( - sampleRate, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT - ) - val bufSize = maxOf(minBuf, frameSizeBytes * 4) - - val ar = AudioRecord( - MediaRecorder.AudioSource.VOICE_COMMUNICATION, - sampleRate, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, - bufSize - ) - if (ar.state != AudioRecord.STATE_INITIALIZED) { - ar.release() - Log.w(TAG, "AudioRecord init failed") - onError(-102, "AudioRecord init failed (state=${ar.state})") - return false - } - recorder = ar - running.set(true) - ar.startRecording() - - worker = thread(name = "device-jieli-mic", isDaemon = true) { - val frame = ByteArray(frameSizeBytes) - while (running.get()) { - val n = try { ar.read(frame, 0, frameSizeBytes) } catch (t: Throwable) { - onError(-103, "AudioRecord.read crashed: ${t.message}"); -1 - } - when { - n < 0 -> { onError(-104, "AudioRecord.read err code=$n"); break } - n == 0 -> continue - n == frameSizeBytes -> onFrame(frame.copyOf()) - else -> onFrame(frame.copyOf(n)) - } - } - } - return true - } - - fun stop() { - if (!running.compareAndSet(true, false)) return - runCatching { recorder?.stop() } - runCatching { recorder?.release() } - recorder = null - worker?.join(200) - worker = null - } - - companion object { - private const val TAG = "PhoneMicCapture" - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/EventDispatcher.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/EventDispatcher.kt deleted file mode 100644 index 779c2e53..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/EventDispatcher.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.jielihome.jielihome.bridge - -import android.os.Handler -import android.os.Looper -import com.jielihome.jielihome.api.JieliEventListener -import io.flutter.plugin.common.EventChannel -import java.util.concurrent.CopyOnWriteArrayList - -/** - * 事件总线。所有 forwarder / feature 调用 [send],由本类做 fan-out: - * - Flutter 端:通过 EventChannel.EventSink 推到 Dart - * - 原生端:通过 [JieliEventListener] 推给宿主直接订阅 - * - * Flutter / 原生 / 多个原生监听器 可以同时存在;payload 形态完全一致。 - */ -class EventDispatcher : EventChannel.StreamHandler { - - private val mainHandler = Handler(Looper.getMainLooper()) - - @Volatile - private var sink: EventChannel.EventSink? = null - - private val nativeListeners = CopyOnWriteArrayList() - - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - sink = events - } - - override fun onCancel(arguments: Any?) { - sink = null - } - - fun addNativeListener(listener: JieliEventListener) { - if (!nativeListeners.contains(listener)) nativeListeners.add(listener) - } - - fun removeNativeListener(listener: JieliEventListener) { - nativeListeners.remove(listener) - } - - fun send(payload: Map) { - // 原生监听同步派发(在事件原始线程,不经主线程切换,方便高频音频帧) - for (l in nativeListeners) { - try { l.onEvent(payload) } catch (_: Throwable) { /* swallow listener errors */ } - } - // Flutter 端必须在主线程 - val s = sink ?: return - mainHandler.post { runCatching { s.success(payload) } } - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt deleted file mode 100644 index 67c532de..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt +++ /dev/null @@ -1,270 +0,0 @@ -package com.jielihome.jielihome.bridge - -import android.content.Context -import com.jielihome.jielihome.core.JieliHomeServer -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel - -/** - * MethodChannel 路由层:把 Dart 调用映射到 server 内的 feature 模块。 - * Plugin 类不直接处理任何业务逻辑,只装配 router。 - */ -class MethodRouter( - private val context: Context, - private val server: JieliHomeServer, -) : MethodChannel.MethodCallHandler { - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - try { - when (call.method) { - "getPlatformVersion" -> - result.success("Android ${android.os.Build.VERSION.RELEASE}") - - "initialize" -> { - server.initialize( - context = context, - multiDevice = call.argument("multiDevice") ?: true, - skipNoNameDev = call.argument("skipNoNameDev") ?: false, - enableLog = call.argument("enableLog") ?: false, - ) - result.success(true) - } - - "startScan" -> { - val timeout = call.argument("timeoutMs") ?: 30000 - val nameList = call.argument>("nameList") ?: emptyList() - val uuidList = call.argument>("uuidList") ?: emptyList() - val skipUnnamed = call.argument("skipUnnamed") ?: true - server.scanFeature.startScan(timeout, nameList, uuidList, skipUnnamed) - .fold({ result.success(true) }, - { result.error("SCAN_FAILED", it.message, null) }) - } - - "stopScan" -> { server.scanFeature.stopScan(); result.success(true) } - - "isScanning" -> result.success(server.scanFeature.isScanning()) - - "connect" -> { - val mac = call.argument("address") - ?: return result.error("BAD_ARG", "address required", null) - server.connectFeature.connect( - bleAddress = mac, - edrAddress = call.argument("edrAddr"), - deviceType = call.argument("deviceType") ?: -1, - connectWay = call.argument("connectWay") ?: 0, - ).fold({ result.success(true) }, - { result.error("CONNECT_FAILED", it.message, null) }) - } - - "disconnect" -> { - val mac = call.argument("address") - ?: return result.error("BAD_ARG", "address required", null) - server.connectFeature.disconnect(mac) - .fold({ result.success(true) }, - { result.error("DISCONNECT_FAILED", it.message, null) }) - } - - "isConnected" -> { - val mac = call.argument("address") - ?: return result.error("BAD_ARG", "address required", null) - result.success(server.connectFeature.isConnected(mac)) - } - - "connectedDevice" -> { - val d = server.connectFeature.connectedDevice() - if (d == null) result.success(null) - else result.success( - mapOf( - "address" to d.address, - "name" to runCatching { d.name }.getOrNull() - ) - ) - } - - "deviceSnapshot" -> { - val mac = call.argument("address") - ?: return result.error("BAD_ARG", "address required", null) - result.success(server.deviceInfoFeature.snapshot(mac)) - } - - "queryTargetInfo" -> { - val mac = call.argument("address") - ?: return result.error("BAD_ARG", "address required", null) - val mask = call.argument("mask") ?: 0x0F - server.deviceInfoFeature.queryTargetInfo( - mac, mask, - onSuccess = { result.success(it) }, - onError = { c, m -> result.error("TARGET_INFO_ERR", m, c) } - ) - } - - "sendCustomCmd" -> { - val mac = call.argument("address") - ?: return result.error("BAD_ARG", "address required", null) - val opCode = call.argument("opCode") - ?: return result.error("BAD_ARG", "opCode required", null) - val payload = call.argument("payload") ?: ByteArray(0) - server.customCmdFeature.send( - mac, opCode, payload, - onSuccess = { result.success(it) }, - onError = { c, m -> result.error("CUSTOM_CMD_ERR", m, c) } - ) - } - - "startTranslation" -> { - val modeId = call.argument("modeId") - ?: return result.error("BAD_ARG", "modeId required", null) - @Suppress("UNCHECKED_CAST") - val args = (call.argument>("args") ?: emptyMap()) - // 设备录音与翻译互斥:启动翻译前先停录音 - if (server.deviceRecordFeature.isRecording()) server.deviceRecordFeature.stop() - server.translationFeature.start(modeId, args) - .fold({ result.success(true) }, - { result.error("TRANSLATION_ERR", it.message, null) }) - } - - "stopTranslation" -> { - server.translationFeature.stop(); result.success(true) - } - - "translationStatus" -> result.success( - mapOf( - "working" to server.translationFeature.isWorking(), - "modeId" to server.translationFeature.currentModeId(), - "inputStreams" to server.translationFeature.currentInputStreams(), - "outputStreams" to server.translationFeature.currentOutputStreams() - ) - ) - - "feedTranslatedAudio" -> { - val streamId = call.argument("streamId") - ?: return result.error("BAD_ARG", "streamId required", null) - val pcm = call.argument("pcm") - ?: return result.error("BAD_ARG", "pcm required", null) - val sampleRate = call.argument("sampleRate") ?: 16000 - val channels = call.argument("channels") ?: 1 - val bits = call.argument("bitsPerSample") ?: 16 - val isFinal = call.argument("final") ?: false - val ok = server.translationFeature.feedTranslatedAudio( - streamId, pcm, - com.jielihome.jielihome.feature.translation.AudioFormat(sampleRate, channels, bits), - isFinal - ) - result.success(ok) - } - - "feedTranslationResult" -> { - server.translationFeature.feedTranslationResult( - srcLang = call.argument("srcLang"), - srcText = call.argument("srcText"), - destLang = call.argument("destLang"), - destText = call.argument("destText"), - requestId = call.argument("requestId"), - ) - result.success(true) - } - - "isSupportCallTranslationWithStereo" -> { - val mac = call.argument("address") - result.success(server.translationFeature.isSupportCallTranslationWithStereo(mac)) - } - - "feedAudioFilePcm" -> { - val pcm = call.argument("pcm") - ?: return result.error("BAD_ARG", "pcm required", null) - val sampleRate = call.argument("sampleRate") ?: 16000 - result.success(server.translationFeature.feedAudioFilePcm(pcm, sampleRate)) - } - - "speechIsRecording" -> - result.success(server.speechFeature.isRecording(call.argument("address"))) - - "speechStart" -> { - server.speechFeature.start( - address = call.argument("address"), - voiceType = call.argument("voiceType") - ?: com.jieli.bluetooth.bean.record.RecordParam.VOICE_TYPE_OPUS, - sampleRate = call.argument("sampleRate") - ?: com.jieli.bluetooth.bean.record.RecordParam.SAMPLE_RATE_16K, - vadWay = call.argument("vadWay") - ?: com.jieli.bluetooth.bean.record.RecordParam.VAD_WAY_DEVICE, - onResult = { ok, msg -> - if (ok) result.success(true) - else result.error("SPEECH_START_ERR", msg, null) - } - ) - } - - "speechStop" -> { - server.speechFeature.stop( - address = call.argument("address"), - reason = call.argument("reason") - ?: com.jieli.bluetooth.bean.record.RecordState.REASON_NORMAL, - onResult = { ok, msg -> - if (ok) result.success(true) - else result.error("SPEECH_STOP_ERR", msg, null) - } - ) - } - - // ── AI 助理通路(JieliAssistantPort:MODE_RECORD + DEVICE_ALWAYS_RECORDING)── - "assistantStart" -> { - // 翻译 / 设备录音与本通路互斥(同一 RCSP TranslationImpl 状态机) - if (server.translationFeature.isWorking()) server.translationFeature.stop() - if (server.deviceRecordFeature.isRecording()) server.deviceRecordFeature.stop() - val ok = server.assistantBridge.start() - if (ok) result.success(true) - else result.error("ASSISTANT_START_ERR", "assistant bridge start failed", null) - } - - "assistantStop" -> { - server.assistantBridge.stop() - result.success(true) - } - - "assistantIsRunning" -> - result.success(server.assistantBridge.isRunning()) - - // ── 设备录音 ────────────────────────────────────────────────── - "deviceRecordStart" -> { - @Suppress("UNCHECKED_CAST") - val args = (call.argument>("args") ?: emptyMap()) - // 翻译与设备录音互斥:启动录音前先停翻译 - if (server.translationFeature.isWorking()) server.translationFeature.stop() - server.deviceRecordFeature.start(args) - .fold({ result.success(true) }, - { result.error("DEVICE_RECORD_ERR", it.message, null) }) - } - - "deviceRecordStop" -> { - server.deviceRecordFeature.stop() - result.success(true) - } - - "deviceRecordStatus" -> result.success( - mapOf("recording" to server.deviceRecordFeature.isRecording()) - ) - - "otaStart" -> { - val path = call.argument("firmwareFilePath") - ?: return result.error("BAD_ARG", "firmwareFilePath required", null) - server.otaFeature.start( - address = call.argument("address"), - firmwareFilePath = path, - blockSize = call.argument("blockSize") ?: 512, - fileFlagBytes = call.argument("fileFlag") ?: ByteArray(0), - ) - result.success(true) - } - - "otaCancel" -> { server.otaFeature.cancel(); result.success(true) } - - "otaIsRunning" -> result.success(server.otaFeature.isRunning()) - - else -> result.notImplemented() - } - } catch (t: Throwable) { - result.error("PLUGIN_ERR", t.message, t.stackTraceToString()) - } - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt deleted file mode 100644 index fc8d371c..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt +++ /dev/null @@ -1,240 +0,0 @@ -package com.jielihome.jielihome.core - -import android.content.Context -import com.jieli.bluetooth.bean.BluetoothOption -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.impl.rcsp.RCSPController -import com.jieli.bluetooth.utils.JL_Log -import com.jielihome.jielihome.api.JieliEventListener -import com.jielihome.jielihome.bridge.EventDispatcher -import com.jielihome.jielihome.event.BluetoothEventForwarder -import com.jielihome.jielihome.event.CustomEventForwarder -import com.jielihome.jielihome.event.DeviceInfoEventForwarder -import com.jielihome.jielihome.event.MediaEventForwarder -import com.jielihome.jielihome.feature.ConnectFeature -import com.jielihome.jielihome.feature.CustomCmdFeature -import com.jielihome.jielihome.feature.DeviceInfoFeature -import com.jielihome.jielihome.feature.ScanFeature -import com.jielihome.jielihome.feature.ota.OtaFeature -import com.jielihome.jielihome.feature.record.DeviceRecordFeature -import com.jielihome.jielihome.feature.translation.EventChannelAudioBridge -import com.jielihome.jielihome.feature.translation.TranslationAudioBridge -import com.jielihome.jielihome.feature.translation.TranslationFeature -import com.jielihome.jielihome.feature.voice.SpeechFeature - -/** - * 全局单例。原生层和 Flutter 层共用同一个 server。 - * - * 原生用法: - * ``` - * val server = JieliHomeServer.get() - * server.initialize(context, multiDevice = true, skipNoNameDev = false, enableLog = true) - * server.addEventListener(myListener) // 订阅事件 - * server.scanFeature.startScan(30_000) - * server.translationFeature.start(MODE_CALL_TRANSLATION, mapOf("address" to mac)) - * server.translationFeature.feedTranslatedAudio("uplink", pcm, AudioFormat(), false) - * ``` - * - * Flutter 侧由 [com.jielihome.jielihome.JielihomePlugin] 自动绑定 EventChannel + MethodChannel。 - * 即使没有 Flutter,原生也能完整使用 server。 - */ -class JieliHomeServer private constructor() { - - companion object { - @Volatile - private var instance: JieliHomeServer? = null - - @JvmStatic - fun get(): JieliHomeServer = instance ?: synchronized(this) { - instance ?: JieliHomeServer().also { instance = it } - } - } - - @Volatile - var initialized: Boolean = false - private set - - /** 事件总线,原生层通过 [addEventListener] 订阅;Flutter 自动注入 EventChannel sink */ - val dispatcher: EventDispatcher = EventDispatcher() - - private lateinit var btManager: JL_BluetoothManager - - /** 模块内访问 btManager 用(如 [com.jielihome.jielihome.feature.assistant.JieliAssistantPort] - * 需要直接持有 [com.jieli.bluetooth.impl.rcsp.record.RecordOpImpl] 单例时)。 */ - internal val internalBtManager: JL_BluetoothManager get() = btManager - - private lateinit var bluetoothForwarder: BluetoothEventForwarder - private lateinit var deviceInfoForwarder: DeviceInfoEventForwarder - private lateinit var mediaForwarder: MediaEventForwarder - private lateinit var customForwarder: CustomEventForwarder - - lateinit var scanFeature: ScanFeature - private set - lateinit var connectFeature: ConnectFeature - private set - lateinit var deviceInfoFeature: DeviceInfoFeature - private set - lateinit var customCmdFeature: CustomCmdFeature - private set - lateinit var translationFeature: TranslationFeature - private set - lateinit var speechFeature: SpeechFeature - private set - lateinit var otaFeature: OtaFeature - private set - lateinit var deviceRecordFeature: DeviceRecordFeature - private set - - /** 默认翻译音频桥(EventChannel 透传到 Dart);宿主 native 想完全自管时可调 - * [TranslationFeature.setBridge] 替换。 - * - * 暴露 getter 供 native 编排器(如 [JieliCallTranslationPort])在 enter/exit - * 时拿到原始引用做"装上自己的 capture bridge / 退出后还原"切换。 - */ - lateinit var defaultBridge: EventChannelAudioBridge - private set - - fun initialize( - context: Context, - multiDevice: Boolean = true, - skipNoNameDev: Boolean = false, - enableLog: Boolean = false, - ) { - if (initialized) return - - JL_Log.configureLog(context, enableLog, enableLog) - - // 注意:扫描已不走 Jieli SDK(改为 ScanFeature 内的 BluetoothLeScanner), - // 所以 BluetoothOption 的 bleScanStrategy / scanFilterData 不再设置—— - // 它们仅影响 SDK 自己的 `btManager.scan()` 路径,留默认即可。 - val option = BluetoothOption.createDefaultOption() - option.setUseMultiDevice(multiDevice) - option.setSkipNoNameDev(skipNoNameDev) - // 启用 RCSP 设备认证(SDK 默认行为):连接成功后 SDK 会通过 - // `DeviceStatusManager.isAuthBtDevice` 走授权握手;未授权设备会被拒掉 - // → 表现为连上后 RCSP init 完成立刻断开。本项目耳机已发签名授权,保持开启。 - option.setUseDeviceAuth(true) - android.util.Log.d( - "JieliHome", - "init multiDev=$multiDevice skipNoName=$skipNoNameDev useAuth=true defaultProtocol=SPP" - ) - RCSPController.init(context, option) - - btManager = RCSPController.getInstance().bluetoothManager - ?: error("RCSPController.bluetoothManager is null after init") - - // 事件转发层 - bluetoothForwarder = BluetoothEventForwarder(btManager, dispatcher).also { it.attach() } - deviceInfoForwarder = DeviceInfoEventForwarder(btManager, dispatcher).also { it.attach() } - mediaForwarder = MediaEventForwarder(btManager, dispatcher).also { it.attach() } - customForwarder = CustomEventForwarder(btManager, dispatcher).also { it.attach() } - - // 业务功能层 - scanFeature = ScanFeature(context.applicationContext, dispatcher) - connectFeature = ConnectFeature(btManager) - deviceInfoFeature = DeviceInfoFeature(btManager) - customCmdFeature = CustomCmdFeature(btManager) - translationFeature = TranslationFeature(context.applicationContext, btManager, connectFeature) - speechFeature = SpeechFeature(btManager, connectFeature, dispatcher).also { it.attach() } - otaFeature = OtaFeature(btManager, connectFeature, dispatcher) - deviceRecordFeature = DeviceRecordFeature(this) - - // 默认音频桥:EventChannel;injector 把 Dart 推过来的 PCM 路由回当前 ModeHandler - defaultBridge = EventChannelAudioBridge(dispatcher) { _, streamId, pcm, fmt, isFinal -> - translationFeature.feedTranslatedAudio(streamId, pcm, fmt, isFinal) - } - translationFeature.setBridge(defaultBridge) - - // 内部级联收尾:连接断开 / 来电状态变化时,自动停止当前翻译,避免脏状态 - dispatcher.addNativeListener(internalCleanupListener) - - initialized = true - } - - /** 监听若干生命周期事件并触发自动收尾 */ - private val internalCleanupListener = object : com.jielihome.jielihome.api.JieliEventListener { - override fun onEvent(payload: Map) { - when (payload["type"] as? String) { - "connectionState" -> { - val state = payload["state"] as? Int ?: return - // StateCode.CONNECTION_DISCONNECT == 0 - if (state == 0) { - if (translationFeature.isWorking()) translationFeature.stop() - if (deviceRecordFeature.isRecording()) deviceRecordFeature.stop() - } - } - "phoneCallStatus" -> { - // 来电/通话进行中 (status != 0) 且当前不是通话翻译,自动 exit - val status = payload["status"] as? Int ?: return - if (status == 0) return - val mid = translationFeature.currentModeId() ?: return - val isCallMode = mid == com.jieli.bluetooth.bean.translation.TranslationMode.MODE_CALL_TRANSLATION || - mid == com.jieli.bluetooth.bean.translation.TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO - if (!isCallMode && translationFeature.isWorking()) { - translationFeature.stop() - } - } - } - } - } - - /** 原生事件订阅 */ - fun addEventListener(listener: JieliEventListener) = dispatcher.addNativeListener(listener) - fun removeEventListener(listener: JieliEventListener) = dispatcher.removeNativeListener(listener) - - /** 替换翻译音频桥(完全 native 直连翻译服务时使用) */ - fun setTranslationBridge(bridge: TranslationAudioBridge) { - translationFeature.setBridge(bridge) - } - - /** - * 通话翻译能力端口([com.aiagent.device_plugin_interface.DeviceCallTranslationPort] - * 的杰理实现)。延迟创建;翻译编排器(translate_server native)通过 - * [DeviceSession.invokeFeature("jieli.callTranslationPort")] 或类似入口拿到引用。 - */ - val callTranslationPort: com.jielihome.jielihome.feature.translation.JieliCallTranslationPort by lazy { - com.jielihome.jielihome.feature.translation.JieliCallTranslationPort(this) - } - - /** - * 设备录音端口 —— 原生层直连入口。 - * 收集 [deviceRecordPort.audioFrames] 获取 PCM 帧,调用 [deviceRecordPort.start]/[stop] 控制录音。 - */ - val deviceRecordPort: com.jielihome.jielihome.feature.record.JieliDeviceRecordPort by lazy { - com.jielihome.jielihome.feature.record.JieliDeviceRecordPort(this) - } - - /** - * AI 助理能力端口([com.aiagent.device_plugin_interface.DeviceAssistantPort] - * 的杰理实现)。延迟创建;`assistant_server` native 编排器通过 - * [JieliNativeDeviceSession.assistantPort] 拿引用,与 [callTranslationPort] 语义独立。 - * - * 底层同样依赖 RCSP `MODE_CALL_TRANSLATION`,但接口上已完全隐藏 leg/翻译语义。 - */ - val assistantPort: com.jielihome.jielihome.feature.assistant.JieliAssistantPort by lazy { - com.jielihome.jielihome.feature.assistant.JieliAssistantPort(this) - } - - /** - * Flutter ↔ [assistantPort] 的桥接。让 Dart 侧可以通过 MethodChannel - * 启停 AI 助理的耳机录音通路(`MODE_RECORD` + `STRATEGY_DEVICE_ALWAYS_RECORDING`), - * 上行 PCM 帧通过 EventDispatcher 以 `assistantAudio` 事件推给 Dart。 - */ - val assistantBridge: com.jielihome.jielihome.feature.assistant.AssistantBridge by lazy { - com.jielihome.jielihome.feature.assistant.AssistantBridge(assistantPort, dispatcher) - } - - fun shutdown() { - if (!initialized) return - runCatching { assistantBridge.shutdown() } - runCatching { otaFeature.cancel() } - runCatching { deviceRecordFeature.stop() } - runCatching { translationFeature.stop() } - runCatching { speechFeature.detach() } - runCatching { bluetoothForwarder.detach() } - runCatching { deviceInfoForwarder.detach() } - runCatching { mediaForwarder.detach() } - runCatching { customForwarder.detach() } - initialized = false - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt deleted file mode 100644 index 44b2c570..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.jielihome.jielihome.event - -import android.bluetooth.BluetoothDevice -import com.jieli.bluetooth.bean.BleScanMessage -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.interfaces.bluetooth.BluetoothCallbackImpl -import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspCallback -import com.jielihome.jielihome.bridge.EventDispatcher - -/** - * 蓝牙基础事件:适配器开关、配对、RCSP init、连接状态。 - * 与设备「业务数据」(电量、音乐等)解耦。 - * - * 注意:扫描结果(`onDiscovery` / `onDiscoveryStatus`)已不再走这里—— - * [com.jielihome.jielihome.feature.ScanFeature] 直接用 Android 原生 - * `BluetoothLeScanner` 扫描,避免 Jieli SDK `ParseDataUtil.isFilterBleDevice` - * 的 strategy / flagContent 过滤导致看不到设备。SDK 自身若偶发回调 - * `onDiscovery`(例如内部自动重连等路径),这里**忽略**,避免重复上报。 - */ -class BluetoothEventForwarder( - private val btManager: JL_BluetoothManager, - private val dispatcher: EventDispatcher, -) { - private val btCallback = object : BluetoothCallbackImpl() { - override fun onAdapterStatus(bEnabled: Boolean, bHasBle: Boolean) { - dispatcher.send( - mapOf("type" to "adapterStatus", "enabled" to bEnabled, "hasBle" to bHasBle) - ) - } - - // SDK 内部仍会触发,但扫描状态由 ScanFeature 自行派发,这里不转发。 - override fun onDiscoveryStatus(bBle: Boolean, bStart: Boolean) = Unit - - // 同上:扫描结果由 ScanFeature 直接派发,这里忽略。 - override fun onDiscovery(device: BluetoothDevice?, msg: BleScanMessage?) = Unit - - override fun onBondStatus(device: BluetoothDevice?, status: Int) { - if (device == null) return - dispatcher.send( - mapOf("type" to "bondStatus", "address" to device.address, "status" to status) - ) - } - } - - private val rcspCallback = object : OnRcspCallback() { - override fun onRcspInit(device: BluetoothDevice?, code: Int) { - if (device == null) return - dispatcher.send( - mapOf("type" to "rcspInit", "address" to device.address, "code" to code) - ) - if (code != 0) btManager.disconnect(device) - } - - override fun onConnectStateChange(device: BluetoothDevice?, status: Int) { - if (device == null) return - dispatcher.send( - mapOf("type" to "connectionState", "address" to device.address, "state" to status) - ) - } - } - - fun attach() { - btManager.addEventListener(btCallback) - btManager.registerOnRcspCallback(rcspCallback) - } - - fun detach() { - btManager.removeEventListener(btCallback) - btManager.unregisterOnRcspCallback(rcspCallback) - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt deleted file mode 100644 index 39cbee16..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.jielihome.jielihome.event - -import android.bluetooth.BluetoothDevice -import android.util.Base64 -import android.util.Log -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener -import com.jielihome.jielihome.bridge.EventDispatcher - -/** - * 厂商私有协议入口。 - * SDK 将所有未在内置协议表中匹配的「设备主动推」事件统一从 onExpandFunction 抛出, - * 这里把它原样透传到 Dart(payload 走 base64),由业务层自行解析。 - * - * 适用:自定义指令的设备->APP 反向通知,例如「检测到唤醒词」「自定义按键事件」。 - */ -class CustomEventForwarder( - private val btManager: JL_BluetoothManager, - private val dispatcher: EventDispatcher, -) { - companion object { - private const val TAG = "CustomEventForwarder" - } - - private val listener = object : OnRcspEventListener() { - override fun onExpandFunction(device: BluetoothDevice?, opCode: Int, payload: ByteArray?) { - Log.i(TAG, "[SDK<-DEV] onExpandFunction addr=${device?.address} opCode=$opCode(0x${String.format("%02X", opCode)}) payloadSize=${payload?.size ?: 0}") - dispatcher.send( - mapOf( - "type" to "expandFunction", - "address" to device?.address, - "opCode" to opCode, - "payloadBase64" to payload?.let { Base64.encodeToString(it, Base64.NO_WRAP) } - ) - ) - } - } - - fun attach() { - btManager.registerOnRcspEventListener(listener) - } - - fun detach() { - btManager.unregisterOnRcspEventListener(listener) - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/DeviceInfoEventForwarder.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/DeviceInfoEventForwarder.kt deleted file mode 100644 index 5fe0b90d..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/DeviceInfoEventForwarder.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.jielihome.jielihome.event - -import android.bluetooth.BluetoothDevice -import com.jieli.bluetooth.bean.base.VoiceMode -import com.jieli.bluetooth.bean.device.status.BatteryInfo -import com.jieli.bluetooth.bean.device.voice.VoiceFunc -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener -import com.jielihome.jielihome.bridge.EventDispatcher - -/** - * 设备信息类事件(设备主动推送):电量、来电状态、语音模式/功能、双连状态。 - * - * 实现思路:注册一个 OnRcspEventListener 实例,按主题分配到不同 forwarder。 - * 这里只负责「设备级状态」,多媒体放 MediaEventForwarder,私有协议放 CustomEventForwarder。 - * - * 多个 forwarder 各注册一个独立 listener,SDK 会全部派发,互不干扰。 - */ -class DeviceInfoEventForwarder( - private val btManager: JL_BluetoothManager, - private val dispatcher: EventDispatcher, -) { - private val listener = object : OnRcspEventListener() { - - override fun onBatteryChange(device: BluetoothDevice?, info: BatteryInfo?) { - dispatcher.send( - mapOf( - "type" to "battery", - "address" to device?.address, - "level" to info?.battery - ) - ) - } - - override fun onPhoneCallStatusChange(device: BluetoothDevice?, status: Int) { - dispatcher.send( - mapOf( - "type" to "phoneCallStatus", - "address" to device?.address, - "status" to status - ) - ) - } - - override fun onCurrentVoiceMode(device: BluetoothDevice?, mode: VoiceMode?) { - dispatcher.send( - mapOf( - "type" to "voiceMode", - "address" to device?.address, - "modeId" to mode?.mode - ) - ) - } - - override fun onVoiceFunctionChange(device: BluetoothDevice?, func: VoiceFunc?) { - dispatcher.send( - mapOf( - "type" to "voiceFunction", - "address" to device?.address, - "function" to func?.toString() - ) - ) - } - } - - fun attach() { - btManager.registerOnRcspEventListener(listener) - } - - fun detach() { - btManager.unregisterOnRcspEventListener(listener) - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/MediaEventForwarder.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/MediaEventForwarder.kt deleted file mode 100644 index 655e1afa..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/MediaEventForwarder.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.jielihome.jielihome.event - -import android.bluetooth.BluetoothDevice -import com.jieli.bluetooth.bean.device.eq.EqInfo -import com.jieli.bluetooth.bean.device.music.MusicNameInfo -import com.jieli.bluetooth.bean.device.music.MusicStatusInfo -import com.jieli.bluetooth.bean.device.music.PlayModeInfo -import com.jieli.bluetooth.bean.device.voice.VolumeInfo -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener -import com.jielihome.jielihome.bridge.EventDispatcher - -/** - * 多媒体事件:音乐名/状态/播放模式、EQ、音量。 - */ -class MediaEventForwarder( - private val btManager: JL_BluetoothManager, - private val dispatcher: EventDispatcher, -) { - private val listener = object : OnRcspEventListener() { - - override fun onVolumeChange(device: BluetoothDevice?, info: VolumeInfo?) { - dispatcher.send( - mapOf( - "type" to "volume", - "address" to device?.address, - "current" to info?.volume, - "max" to info?.maxVol - ) - ) - } - - override fun onEqChange(device: BluetoothDevice?, info: EqInfo?) { - dispatcher.send( - mapOf("type" to "eq", "address" to device?.address, "info" to info?.toString()) - ) - } - - override fun onMusicNameChange(device: BluetoothDevice?, info: MusicNameInfo?) { - dispatcher.send( - mapOf( - "type" to "musicName", - "address" to device?.address, - "name" to info?.name - ) - ) - } - - override fun onMusicStatusChange(device: BluetoothDevice?, info: MusicStatusInfo?) { - dispatcher.send( - mapOf( - "type" to "musicStatus", - "address" to device?.address, - "playing" to info?.isPlay, - "currentTime" to info?.currentTime, - "totalTime" to info?.totalTime - ) - ) - } - - override fun onPlayModeChange(device: BluetoothDevice?, info: PlayModeInfo?) { - dispatcher.send( - mapOf( - "type" to "playMode", - "address" to device?.address, - "mode" to info?.playMode - ) - ) - } - } - - fun attach() { - btManager.registerOnRcspEventListener(listener) - } - - fun detach() { - btManager.unregisterOnRcspEventListener(listener) - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ConnectFeature.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ConnectFeature.kt deleted file mode 100644 index 2ce086f8..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ConnectFeature.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.jielihome.jielihome.feature - -import android.bluetooth.BluetoothDevice -import com.jieli.bluetooth.constant.BluetoothConstant -import com.jieli.bluetooth.constant.JL_DeviceType -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.utils.BluetoothUtil - -/** - * 连接功能。 - * - * # 协议选择策略([resolveTarget]) - * 默认**走 SPP(经典蓝牙 RFCOMM)**——只要拿得到 EDR 地址、且不是手表设备: - * - 有 [edrAddress] 且 [deviceType] != WATCH: - * * [connectWay] 显式指定 `GATT_OVER_BR_EDR(2)`:用 GATT_OVER_BR_EDR - * * 其余情况(包括 SDK 默认 0 / 上层未指定 / 扫描解析回 SPP):用 **SPP(1)** - * - 否则(无 edrAddr 或 WATCH 设备):**fallback BLE(0)** - * - * 这个策略把"默认 SPP"作为成年男耳机/带 EDR 设备的首选——这类设备本身设计走经典 - * 蓝牙 + RCSP over SPP,BLE-only 通路在 OPUS 高速下行场景下吞吐受限。手表 / 仅 BLE - * 的设备会自动 fallback BLE,不影响其工作。 - */ -class ConnectFeature(private val btManager: JL_BluetoothManager) { - - fun connect( - bleAddress: String, - edrAddress: String?, - deviceType: Int, - connectWay: Int, - ): Result { - val target = resolveTarget(bleAddress, edrAddress, deviceType, connectWay) - ?: return Result.failure(IllegalStateException("remote device not found")) - btManager.connect(target.first, target.second) - return Result.success(Unit) - } - - fun disconnect(address: String): Result { - val device = BluetoothUtil.getRemoteDevice(address) - ?: return Result.failure(IllegalStateException("remote device not found")) - btManager.disconnect(device) - return Result.success(Unit) - } - - fun isConnected(address: String): Boolean { - val device = BluetoothUtil.getRemoteDevice(address) ?: return false - return btManager.isConnectedBtDevice(device) - } - - fun connectedDevice(): BluetoothDevice? = btManager.connectedDevice - - fun deviceByAddress(address: String): BluetoothDevice? = - BluetoothUtil.getRemoteDevice(address) - - private fun resolveTarget( - bleAddress: String, - edrAddress: String?, - deviceType: Int, - connectWay: Int, - ): Pair? { - val ble = BluetoothUtil.getRemoteDevice(bleAddress) ?: return null - - // 手表强制 BLE:JL Watch 系列只暴露 GATT,没有 SPP profile - if (deviceType == JL_DeviceType.JL_DEVICE_TYPE_WATCH) { - return ble to BluetoothConstant.PROTOCOL_TYPE_BLE - } - - // 默认走 SPP;调用方显式要求 GATT_OVER_BR_EDR 时尊重之 - if (!edrAddress.isNullOrEmpty()) { - val protocol = if (connectWay == BluetoothConstant.PROTOCOL_TYPE_GATT_OVER_BR_EDR) { - BluetoothConstant.PROTOCOL_TYPE_GATT_OVER_BR_EDR - } else { - BluetoothConstant.PROTOCOL_TYPE_SPP - } - BluetoothUtil.getRemoteDevice(edrAddress)?.let { return it to protocol } - } - - // 没有 EDR 地址,回退 BLE - return ble to BluetoothConstant.PROTOCOL_TYPE_BLE - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt deleted file mode 100644 index 8fc83dbb..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.jielihome.jielihome.feature - -import android.bluetooth.BluetoothDevice -import android.util.Log -import com.jieli.bluetooth.bean.base.BaseError -import com.jieli.bluetooth.bean.base.CommandBase -import com.jieli.bluetooth.bean.command.custom.CustomCmd -import com.jieli.bluetooth.bean.parameter.CustomParam -import com.jieli.bluetooth.bean.response.CustomResponse -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback -import com.jieli.bluetooth.utils.BluetoothUtil - -/** - * 厂商扩展指令通道。 - * 用法:业务方约定一个 opCode + payload 字节流,从这里下发;设备应答的 payload 透传回 Dart。 - * 设备「主动推」的扩展事件由 CustomEventForwarder.onExpandFunction 接收。 - */ -class CustomCmdFeature(private val btManager: JL_BluetoothManager) { - - companion object { - private const val TAG = "CustomCmdFeature" - } - - fun send( - address: String, - opCode: Int, - payload: ByteArray, - onSuccess: (ByteArray?) -> Unit, - onError: (Int, String?) -> Unit, - ) { - val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address) - ?: return onError(-1, "remote device not found") - - Log.i(TAG, "[APP->SDK] sendRcspCommand addr=$address opCode=$opCode(0x${String.format("%02X", opCode)}) payloadSize=${payload.size}") - val cmd = CustomCmd(opCode, CustomParam(payload)) - btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { - override fun onCommandResponse(dev: BluetoothDevice?, resp: CommandBase<*, *>?) { - val r = resp?.response as? CustomResponse - Log.i(TAG, "[SDK<-DEV] sendRcspCommand onResponse addr=${dev?.address} opCode=$opCode dataSize=${r?.data?.size ?: 0}") - onSuccess(r?.data) - } - - override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) { - Log.w(TAG, "[SDK<-DEV] sendRcspCommand onErrCode addr=${dev?.address} opCode=$opCode code=${err?.code} msg=${err?.message}") - onError(err?.code ?: -2, err?.message) - } - }) - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/DeviceInfoFeature.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/DeviceInfoFeature.kt deleted file mode 100644 index e0228e3f..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/DeviceInfoFeature.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.jielihome.jielihome.feature - -import android.bluetooth.BluetoothDevice -import com.jieli.bluetooth.bean.base.BaseError -import com.jieli.bluetooth.bean.base.CommandBase -import com.jieli.bluetooth.bean.command.GetTargetInfoCmd -import com.jieli.bluetooth.bean.parameter.GetTargetInfoParam -import com.jieli.bluetooth.bean.response.TargetInfoResponse -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback -import com.jieli.bluetooth.utils.BluetoothUtil - -/** - * 查询电量、版本号等设备静态信息(拉取式)。 - * 设备主动推的电量变化由 DeviceInfoEventForwarder 处理。 - */ -class DeviceInfoFeature(private val btManager: JL_BluetoothManager) { - - /** 直接读 SDK 缓存(连接成功后已自动拉取过一次) */ - fun snapshot(address: String): Map? { - val device = BluetoothUtil.getRemoteDevice(address) ?: return null - val info = btManager.getDeviceInfo(device) ?: return null - return mapOf( - "address" to device.address, - "battery" to info.quantity, - "volume" to info.volume, - "maxVolume" to info.maxVol, - "versionCode" to info.versionCode, - "versionName" to info.versionName, - "name" to info.name, - "vid" to info.vid, - "pid" to info.pid, - "uid" to info.uid, - "edrAddr" to info.edrAddr - ) - } - - /** - * 主动向设备发指令拉取最新 TargetInfo。 - * @param mask 设备字段位掩码,常见 0x0F 拉常用字段;具体 mask 见杰理协议文档 - */ - fun queryTargetInfo( - address: String, - mask: Int, - onSuccess: (Map) -> Unit, - onError: (Int, String?) -> Unit, - ) { - val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address) - ?: return onError(-1, "remote device not found") - - val cmd = GetTargetInfoCmd(GetTargetInfoParam(mask)) - btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { - override fun onCommandResponse(dev: BluetoothDevice?, resp: CommandBase<*, *>?) { - val r = resp?.response as? TargetInfoResponse - ?: return onError(-2, "no response") - onSuccess( - mapOf( - "address" to (dev?.address ?: address), - "battery" to r.quantity, - "volume" to r.volume, - "versionCode" to r.versionCode, - "versionName" to r.versionName, - "name" to r.name - ) - ) - } - - override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) { - onError(err?.code ?: -3, err?.message) - } - }) - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt deleted file mode 100644 index 47639b2d..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt +++ /dev/null @@ -1,298 +0,0 @@ -package com.jielihome.jielihome.feature - -import android.annotation.SuppressLint -import android.bluetooth.BluetoothManager -import android.bluetooth.le.BluetoothLeScanner -import android.bluetooth.le.ScanCallback -import android.bluetooth.le.ScanFilter -import android.bluetooth.le.ScanResult -import android.bluetooth.le.ScanSettings -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.os.ParcelUuid -import android.util.Log -import com.jieli.bluetooth.bean.BluetoothOption -import com.jieli.bluetooth.utils.ParseDataUtil -import com.jielihome.jielihome.bridge.EventDispatcher -import java.util.UUID - -/** - * BLE 扫描入口。 - * - * 不再走 Jieli SDK 的 `btManager.scan()`(受其内置 ParseDataUtil 的 - * flagContent / strategy 限制),改为直接用 Android 原生 - * [BluetoothLeScanner] + [ScanFilter] 扫描,复合过滤: - * - * - [nameList]:对 `BluetoothDevice.name` 精确匹配(忽略大小写),空 = 不按名过滤 - * - [uuidList]:构造 `ScanFilter.setServiceUuid(...)`,由 OS 过滤;空 = 不按 UUID 过滤 - * - * 两者同时设置时按 (UUID 由 OS 过滤后) AND (名字命中) 上报。 - * - * 由于不再依赖 Jieli SDK 的 `BleScanMessage`,发出的 `deviceFound` 事件里 - * `edrAddr` / `deviceType` / `connectWay` 三个字段为 null —— 后续连接由 - * [com.jielihome.jielihome.feature.ConnectFeature] 用默认值(deviceType=-1, - * connectWay=0)尝试,可在调用方通过 connectWay override 指定。 - */ -class ScanFeature( - private val context: Context, - private val dispatcher: EventDispatcher, -) { - - private val mainHandler = Handler(Looper.getMainLooper()) - - /** - * 用于把 Android 原生 ScanRecord 字节交给 Jieli SDK 解析的可复用 option。 - * `bleScanStrategy=3` + 空 flagContent → 跳过 SDK 的 flagContent.equals 校验, - * 任何 mode-3 可解析的 Jieli 广播都返回 BleScanMessage(含 edrAddr/pid 等)。 - */ - private val parserOption: BluetoothOption by lazy { - BluetoothOption.createDefaultOption().apply { - setBleScanStrategy(3) - setScanFilterData("") - } - } - - @Volatile private var scanCallback: ScanCallback? = null - @Volatile private var scanning: Boolean = false - private var stopRunnable: Runnable? = null - - @Volatile private var currentNameList: List = emptyList() - @Volatile private var currentSkipUnnamed: Boolean = true - - @SuppressLint("MissingPermission") - fun startScan( - timeoutMs: Int, - nameList: List = emptyList(), - uuidList: List = emptyList(), - skipUnnamed: Boolean = true, - ): Result { - val scanner = leScanner() - ?: return Result.failure(IllegalStateException("BLE scanner unavailable")) - - // 已在扫描:先停旧的(替换扫描参数) - if (scanning) doStop(scanner) - - currentNameList = nameList.filter { it.isNotEmpty() } - currentSkipUnnamed = skipUnnamed - - val filters = mutableListOf() - uuidList.forEach { raw -> - val uuid = parseUuid(raw) ?: return@forEach - filters.add( - ScanFilter.Builder() - .setServiceUuid(ParcelUuid(uuid)) - .build() - ) - Log.d(TAG, "add ScanFilter serviceUuid=$uuid") - } - - val settings = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) - .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) - .setReportDelay(0) - .build() - - val cb = object : ScanCallback() { - override fun onScanResult(callbackType: Int, result: ScanResult) { - handleScanResult(result) - } - - override fun onBatchScanResults(results: MutableList) { - for (r in results) handleScanResult(r) - } - - override fun onScanFailed(errorCode: Int) { - Log.e(TAG, "onScanFailed errorCode=$errorCode") - scanning = false - scanCallback = null - stopRunnable?.let { mainHandler.removeCallbacks(it) } - stopRunnable = null - dispatcher.send( - mapOf("type" to "scanStatus", "ble" to true, "started" to false) - ) - } - } - - return try { - // filters 为空时传 null("扫描所有 BLE 广播") - scanner.startScan(filters.takeIf { it.isNotEmpty() }, settings, cb) - scanCallback = cb - scanning = true - Log.d( - TAG, - "startScan timeoutMs=$timeoutMs filters=${filters.size} " + - "nameList=$currentNameList" - ) - dispatcher.send( - mapOf("type" to "scanStatus", "ble" to true, "started" to true) - ) - - val stop = Runnable { - Log.d(TAG, "scan timeout reached, stopping") - stopScan() - } - stopRunnable = stop - mainHandler.postDelayed(stop, timeoutMs.toLong()) - - Result.success(Unit) - } catch (e: Exception) { - Log.e(TAG, "startScan exception", e) - scanning = false - Result.failure(e) - } - } - - fun stopScan() { - val scanner = leScanner() ?: return - doStop(scanner) - } - - @SuppressLint("MissingPermission") - private fun doStop(scanner: BluetoothLeScanner) { - val cb = scanCallback - if (cb != null) { - try { - scanner.stopScan(cb) - } catch (e: Exception) { - Log.w(TAG, "stopScan exception: ${e.message}") - } - } - scanCallback = null - stopRunnable?.let { mainHandler.removeCallbacks(it) } - stopRunnable = null - scanning = false - currentNameList = emptyList() - dispatcher.send( - mapOf("type" to "scanStatus", "ble" to true, "started" to false) - ) - } - - fun isScanning(): Boolean = scanning - - @SuppressLint("MissingPermission") - private fun handleScanResult(result: ScanResult) { - val device = result.device - val name = try { - device.name ?: result.scanRecord?.deviceName - } catch (_: SecurityException) { null } - - // 未命名过滤(默认 ON):drop 掉 name 为空的环境噪声 - if (currentSkipUnnamed && name.isNullOrEmpty()) return - - val names = currentNameList - if (names.isNotEmpty()) { - if (name.isNullOrEmpty() || - names.none { it.equals(name, ignoreCase = true) } - ) return - } - - // 用 Jieli SDK 的 ParseDataUtil 解一次原始 ScanRecord,能解出来就拿到 - // edrAddr / pid / deviceType / connectWay 等私有字段;解不出来就当作普通 - // BLE 设备(这些字段为 null,连接时走 BLE-only fallback)。 - val rawBytes = result.scanRecord?.bytes - val msg = if (rawBytes != null) { - try { ParseDataUtil.isFilterBleDevice(parserOption, rawBytes) } - catch (e: Throwable) { - Log.w(TAG, "parseBleScanMessage failed: ${e.message}") - null - } - } else null - - // 解析出给 Flutter 侧 "广播详情" 弹窗用的结构化字段。 - val adRecords = if (rawBytes != null) parseAdRecords(rawBytes) else emptyList() - val advRecordsPayload = adRecords.map { r -> - mapOf( - "len" to "%02d".format(r.len), - "type" to "0x%02X".format(r.type), - "data" to "0x" + r.data.toUpperHex(), - ) - } - val advFlags = adRecords.firstOrNull { it.type == 0x01 } - ?.data?.takeIf { it.isNotEmpty() } - ?.let { it[0].toInt() and 0xFF } - val mfrRecord = adRecords.firstOrNull { it.type == 0xFF } - val manufacturerCompanyId = mfrRecord?.data?.takeIf { it.size >= 2 }?.let { - ((it[1].toInt() and 0xFF) shl 8) or (it[0].toInt() and 0xFF) - } - val manufacturerData = mfrRecord?.data?.takeIf { it.size > 2 } - ?.let { it.copyOfRange(2, it.size).toUpperHex() } - val serviceUuidStrings = result.scanRecord?.serviceUuids - ?.map { it.uuid.toString().uppercase() } ?: emptyList() - - dispatcher.send( - mapOf( - "type" to "deviceFound", - "name" to (name ?: ""), - "address" to device.address, - "edrAddr" to msg?.edrAddr, - "deviceType" to msg?.deviceType, - "connectWay" to msg?.connectWay, - "rssi" to result.rssi, - "rawAdv" to rawBytes?.toUpperHex(), - "advRecords" to advRecordsPayload, - "advFlags" to advFlags, - "manufacturerCompanyId" to manufacturerCompanyId, - "manufacturerData" to manufacturerData, - "serviceUuids" to serviceUuidStrings, - ) - ) - } - - private fun leScanner(): BluetoothLeScanner? { - val mgr = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager - ?: return null - return mgr.adapter?.bluetoothLeScanner - } - - /** 单个 AD (Advertising Data) 结构:len 为 "type + data" 的字节数,不含 len 字节自身。 */ - private data class AdRecord(val len: Int, val type: Int, val data: ByteArray) - - /** - * 按 BLE Core Spec §11(AD structure)解析 ScanRecord 字节。 - * 每条记录:`[len:1][type:1][data:len-1]`;遇到 len==0 视为 padding 结束。 - */ - private fun parseAdRecords(bytes: ByteArray): List { - val out = mutableListOf() - var i = 0 - while (i < bytes.size) { - val len = bytes[i].toInt() and 0xFF - if (len == 0) break - if (i + len >= bytes.size) break // 畸形包:data 越界,丢弃尾部 - val type = bytes[i + 1].toInt() and 0xFF - val data = bytes.copyOfRange(i + 2, i + 1 + len) - out.add(AdRecord(len, type, data)) - i += 1 + len - } - return out - } - - private fun ByteArray.toUpperHex(): String = - joinToString("") { "%02X".format(it) } - - private fun parseUuid(raw: String): UUID? { - val s = raw.trim() - return try { - when { - s.length == 4 -> UUID.fromString("0000$s-0000-1000-8000-00805f9b34fb") - s.length == 8 -> UUID.fromString("$s-0000-1000-8000-00805f9b34fb") - s.length == 36 -> UUID.fromString(s) - s.length == 32 -> UUID.fromString( - "${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}" + - "-${s.substring(16, 20)}-${s.substring(20, 32)}" - ) - else -> { - Log.w(TAG, "invalid UUID format: $raw") - null - } - } - } catch (e: IllegalArgumentException) { - Log.e(TAG, "parseUuid failed: $raw", e) - null - } - } - - private companion object { - const val TAG = "JieliScan" - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/AssistantBridge.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/AssistantBridge.kt deleted file mode 100644 index 300e5386..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/AssistantBridge.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.jielihome.jielihome.feature.assistant - -import android.util.Log -import com.aiagent.device_plugin_interface.AssistantAudioFormat -import com.jielihome.jielihome.bridge.EventDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import java.util.concurrent.atomic.AtomicLong - -/** - * Flutter ↔ [JieliAssistantPort] 的桥接。把 SharedFlow 形态的音频帧 / 错误流 - * 转成 [EventDispatcher] 事件(`assistantAudio` / `assistantError`),让 - * Dart 侧可以通过 EventChannel 实时收到耳机麦上行的 16 kHz PCM。 - * - * `start` 内部调用 [JieliAssistantPort.enter](`MODE_RECORD` + - * `STRATEGY_DEVICE_ALWAYS_RECORDING`),耳机会持续推 OPUS,由 Port 解码成 - * 16 kHz/16 bit/mono/20 ms PCM。`stop` 调用 [JieliAssistantPort.exit] 并 - * 取消订阅。两者通过 `synchronized` 互斥,重复调用安全。 - */ -class AssistantBridge( - private val port: JieliAssistantPort, - private val dispatcher: EventDispatcher, -) { - - companion object { - private const val TAG = "AssistantBridge" - } - - private val scopeJob = SupervisorJob() - private val scope = CoroutineScope(Dispatchers.IO + scopeJob) - - @Volatile private var audioJob: Job? = null - @Volatile private var errorJob: Job? = null - @Volatile private var running = false - - /** 调试:每秒打一次 PCM 转发统计,方便确认音频是否真正流到 Dart */ - private val fwdCount = AtomicLong(0) - @Volatile private var lastReportMs = 0L - - @Synchronized - fun start(): Boolean { - if (running) { - Log.w(TAG, "start: already running, ignored") - return true - } - - // 先订阅 Flow,再 enter(),避免首帧落在订阅者建立之前被 SharedFlow 丢掉 - // (JieliAssistantPort 用 replay=0 + DROP_OLDEST) - audioJob = port.audioFrames.onEach { frame -> - fwdCount.incrementAndGet() - val now = System.currentTimeMillis() - if (now - lastReportMs >= 1000L) { - Log.i( - TAG, - "fwd PCM stats (last 1s): frames=${fwdCount.getAndSet(0)} bytes/frame=${frame.bytes.size} sr=${frame.sampleRate}", - ) - lastReportMs = now - } - dispatcher.send( - mapOf( - "type" to "assistantAudio", - "encoding" to "pcm16", - "sampleRate" to frame.sampleRate, - "channels" to frame.channels, - "bitsPerSample" to 16, - "sequence" to frame.sequence, - "tsMs" to (frame.timestampUs / 1000L), - "pcm" to frame.bytes, - ) - ) - }.launchIn(scope) - - errorJob = port.errors.onEach { err -> - Log.w(TAG, "port.error code=${err.code} msg=${err.message}") - dispatcher.send( - mapOf( - "type" to "assistantError", - "code" to err.code, - "message" to err.message, - ) - ) - }.launchIn(scope) - - try { - port.enter(AssistantAudioFormat.PCM_S16LE_16K_MONO_20MS) - } catch (t: Throwable) { - Log.e(TAG, "start: port.enter failed: ${t.message}", t) - runCatching { audioJob?.cancel() } - runCatching { errorJob?.cancel() } - audioJob = null - errorJob = null - dispatcher.send( - mapOf( - "type" to "assistantError", - "code" to "device.assistant.enter_failed", - "message" to (t.message ?: t.javaClass.simpleName), - ) - ) - return false - } - - running = true - dispatcher.send( - mapOf( - "type" to "assistantStart", - "sampleRate" to 16000, - "tsMs" to System.currentTimeMillis(), - ) - ) - Log.i(TAG, "start: assistant port entered, audio bridge active") - return true - } - - @Synchronized - fun stop() { - if (!running) return - running = false - runCatching { audioJob?.cancel() } - runCatching { errorJob?.cancel() } - audioJob = null - errorJob = null - runCatching { port.exit() } - dispatcher.send( - mapOf( - "type" to "assistantEnd", - "tsMs" to System.currentTimeMillis(), - ) - ) - Log.i(TAG, "stop: assistant port exited, audio bridge inactive") - } - - fun isRunning(): Boolean = running - - fun shutdown() { - stop() - runCatching { scope.cancel() } - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/JieliAssistantPort.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/JieliAssistantPort.kt deleted file mode 100644 index 87f35cf6..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/JieliAssistantPort.kt +++ /dev/null @@ -1,276 +0,0 @@ -package com.jielihome.jielihome.feature.assistant - -import android.bluetooth.BluetoothDevice -import android.util.Log -import com.aiagent.device_plugin_interface.AssistantAudioCodec -import com.aiagent.device_plugin_interface.AssistantAudioFormat -import com.aiagent.device_plugin_interface.AssistantAudioFrame -import com.aiagent.device_plugin_interface.AssistantError -import com.aiagent.device_plugin_interface.AssistantPlaybackFrame -import com.aiagent.device_plugin_interface.DeviceAssistantPort -import com.jieli.bluetooth.bean.translation.AudioData -import com.jieli.bluetooth.bean.translation.TranslationMode -import com.jieli.bluetooth.constant.Constants -import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl -import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback -import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback -import com.jielihome.jielihome.audio.LocalPlayer -import com.jielihome.jielihome.audio.OpusStreamDecoder -import com.jielihome.jielihome.core.JieliHomeServer -import com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import java.util.concurrent.atomic.AtomicLong - -/** - * `DeviceAssistantPort` 的杰理实现 —— **录音模式(MODE_RECORD=1)通路**版。 - * - * # 设计要点 - * 杰理 demo 里没有「AI 助理」这种产品形态,但其 [TranslationImpl.enterMode] + - * `MODE_RECORD`(=1) + `STRATEGY_DEVICE_ALWAYS_RECORDING`(=1) 能让耳机持续上推 - * OPUS 音频帧到 [TranslationCallback.onReceiveAudioData],完全满足 AI 助理的 - * 上行采集需求,我们就借这条录音模式通路: - * - 上行采集:耳机麦 → 耳机固件 OPUS 编码 → onReceiveAudioData → OpusStreamDecoder - * → 16kHz PCM → SharedFlow 抛给编排器。**mode 字段 = 1,与 demo 行为一致**。 - * - 下行回灌:TTS PCM 写 [LocalPlayer](AudioTrack + USAGE_MEDIA),由 Android - * 蓝牙路由走 A2DP 通道送回耳机扬声器。不走 RCSP 下发,避免与 A2DP 媒体音频抢通道。 - * - * # 与 [com.jielihome.jielihome.feature.translation.JieliCallTranslationPort] 的关系 - * 两者都走 TranslationImpl,但 mode 不同、策略不同: - * - CallTranslation:mode=3/6(通话翻译),依赖真实 eSCO 通话 - * - 本类:mode=1(录音),纯上行,不依赖任何通话事件 - * 两者不会同时工作(enterMode 互斥)。 - * - * # 格式 - * 仅放开 PCM_S16LE / 16 kHz / mono / 20 ms。 - * 上行 SDK 推 OPUS,由 [OpusStreamDecoder] 解码成 16 kHz PCM 推到 Flow。 - * 下行 PCM 直接写 AudioTrack。 - * - * # 线程安全 - * `enter` / `exit` / `reportPlayback` 通过 `synchronized(this)` 互斥; - * SharedFlow.tryEmit / AudioTrack.write 自身线程安全; - * SDK 回调线程不固定,所有共享状态用 @Volatile 标记。 - */ -class JieliAssistantPort( - private val server: JieliHomeServer, -) : DeviceAssistantPort { - - companion object { - private const val TAG = "JieliAssistantPort" - private val PCM_16K_MONO_20MS = AssistantAudioFormat.PCM_S16LE_16K_MONO_20MS - } - - private val _audioFrames = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 128, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - private val _errors = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 16, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - - @Volatile private var entered = false - @Volatile private var device: BluetoothDevice? = null - @Volatile private var decoder: OpusStreamDecoder? = null - @Volatile private var player: LocalPlayer? = null - @Volatile private var translationImpl: TranslationImpl? = null - @Volatile private var translationCallback: TranslationCallback? = null - - /** 调试统计:每秒打一次 SDK PCM 帧产出量 */ - private val upCount = AtomicLong(0) - @Volatile private var lastReportMs = 0L - /** 首帧 flag:首次 onModeChange / onReceiveAudioData / 首次 PCM 帧发射都各打一条 INFO */ - @Volatile private var rxFirstModeChangeLogged = false - @Volatile private var rxFirstAudioLogged = false - /** 上行帧自增序号 */ - private val seqGen = AtomicLong(0) - - override fun supportedSourceFormats(): Set = setOf(PCM_16K_MONO_20MS) - override fun supportedSinkFormats(): Set = setOf(PCM_16K_MONO_20MS) - - override val audioFrames: Flow = _audioFrames.asSharedFlow() - override val errors: Flow = _errors.asSharedFlow() - - @Synchronized - override fun enter(sourceFormat: AssistantAudioFormat) { - require(sourceFormat == PCM_16K_MONO_20MS) { - "JieliAssistantPort only supports PCM_S16LE/16k/mono/20ms (got $sourceFormat)" - } - check(!entered) { "device.assistant.busy" } - - val dev = server.connectFeature.connectedDevice() - ?: throw IllegalStateException("device.assistant.no_device: no connected device") - device = dev - - // 1. 启动本地 A2DP 播放器(接收 TTS PCM) - val lp = LocalPlayer(sampleRate = 16000, channels = 1).also { it.start() } - player = lp - - // 2. 启动 Opus 解码器:耳机上推的录音流是 OPUS,解码后丢回 SharedFlow。 - // packetSize 必须是耳机单个 OPUS 帧的真实大小(16k/16bit/20ms 约 40B), - // 不是 SDK 里那种"块大小"。配 200 时 OpusManager 把 5 个拼接帧当 1 帧解, - // 1s 只出 10 帧 PCM(应有 50),丢 80% 数据 —— 与"AI 助理 10 fps"问题完全对应。 - val od = OpusStreamDecoder( - channel = 1, - packetSize = 40, - sampleRate = 16000, - onPcm = { pcm -> emitUplinkFrame(pcm) }, - onError = { c, m -> - _errors.tryEmit(AssistantError( - code = "device.decoder_failed", - message = "opus: code=$c msg=${m ?: ""}", - )) - }, - ).also { it.start() } - decoder = od - - // 3. 构造 TranslationImpl + 前置校验 - val impl = TranslationImpl(server.internalBtManager, NoOpAITranslationApi(), dev) - if (!impl.isInit) { - runCatching { impl.destroy() } - cleanup() - throw IllegalStateException("device.assistant.rcsp_not_init: RCSP not init for ${dev.address}") - } - if (!impl.isSupportTranslation) { - runCatching { impl.destroy() } - cleanup() - throw IllegalStateException("device.assistant.not_supported: device does not support translation") - } - - // 4. 注册 TranslationCallback —— 关键回调:onReceiveAudioData 拿 OPUS 帧 - val cb = object : TranslationCallback { - override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { - if (!rxFirstModeChangeLogged) { - rxFirstModeChangeLogged = true - Log.i(TAG, "[SDK<-DEV] onModeChange FIRST addr=${d.address} mode=${m.mode} type=${m.audioType} ch=${m.channel} sr=${m.sampleRate} strategy=${m.recordingStrategy}") - } else { - Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} strategy=${m.recordingStrategy}") - } - if (m.mode == TranslationMode.MODE_IDLE) { - // 耳机主动退出 RECORD 模式(按键 / 超时 / 被抢占) - _errors.tryEmit(AssistantError( - code = "device.mode_exited", - message = "headset exited MODE_RECORD → MODE_IDLE", - )) - } - } - - override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { - val payload = data.audioData - if (payload == null || payload.isEmpty()) { - Log.w(TAG, "[SDK<-DEV] onReceiveAudioData null/empty payload source=${data.source} type=${data.type}") - return - } - if (!rxFirstAudioLogged) { - rxFirstAudioLogged = true - Log.i(TAG, "[SDK<-DEV] onReceiveAudioData FIRST FRAME addr=${d.address} source=${data.source} type=${data.type} size=${payload.size}") - } - when (data.type) { - Constants.AUDIO_TYPE_PCM -> emitUplinkFrame(payload) - Constants.AUDIO_TYPE_OPUS -> decoder?.feedEncoded(payload) - else -> Log.w(TAG, "[SDK<-DEV] onReceiveAudioData unknown type=${data.type} (skip)") - } - } - - override fun onError(d: BluetoothDevice, code: Int, msg: String) { - Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg") - _errors.tryEmit(AssistantError( - code = "device.translation_error", - message = "code=$code msg=$msg", - )) - } - } - impl.addTranslationCallback(cb) - translationImpl = impl - translationCallback = cb - - // 5. 下发 enterMode(MODE_RECORD=1, OPUS, ch=1, 16k, STRATEGY_DEVICE_ALWAYS_RECORDING) - // - mode=1:与 demo 录音模式对齐,耳机 RCSP 状态机进入 MODE_RECORD - // - STRATEGY_DEVICE_ALWAYS_RECORDING=1:耳机固件自主采集并持续上推,APP 不用手机麦 - val sdkMode = TranslationMode( - TranslationMode.MODE_RECORD, - Constants.AUDIO_TYPE_OPUS, - 1, - 16000, - ).setRecordingStrategy(TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING) - Log.i(TAG, "[APP->SDK] enterMode addr=${dev.address} mode=${sdkMode.mode}(MODE_RECORD) type=${sdkMode.audioType}(OPUS) ch=${sdkMode.channel} sr=${sdkMode.sampleRate} strategy=${sdkMode.recordingStrategy}(DEVICE_ALWAYS_RECORDING)") - impl.enterMode(sdkMode, cb) - - entered = true - Log.d(TAG, "entered assistant mode (MODE_RECORD=1, DEVICE_ALWAYS_RECORDING, A2DP playback)") - } - - private fun emitUplinkFrame(pcm: ByteArray) { - upCount.incrementAndGet() - val now = System.currentTimeMillis() - if (now - lastReportMs >= 1000L) { - Log.d(TAG, "uplink PCM stats (last 1s): frames=${upCount.getAndSet(0)} bytes/frame=${pcm.size}") - lastReportMs = now - } - val frame = AssistantAudioFrame( - codec = AssistantAudioCodec.PCM_S16LE, - sampleRate = 16000, - channels = 1, - bytes = pcm, - sequence = seqGen.incrementAndGet(), - timestampUs = now * 1000L, - ) - if (!_audioFrames.tryEmit(frame)) { - Log.w(TAG, "audioFrames buffer overflow; dropped seq=${frame.sequence}") - } - } - - @Synchronized - override fun reportPlayback(frame: AssistantPlaybackFrame) { - check(entered) { "device.assistant.not_active" } - require(frame.codec == AssistantAudioCodec.PCM_S16LE) { - "JieliAssistantPort only accepts PCM_S16LE for sink (got ${frame.codec})" - } - // TTS PCM 写入本地 A2DP 播放器,由系统蓝牙路由送回耳机扬声器 - player?.feed(frame.bytes) - } - - @Synchronized - override fun exit() { - if (!entered) return - entered = false - cleanup() - Log.d(TAG, "exited assistant mode") - } - - /** 释放 enterMode / decoder / player / callback;可被 enter 失败路径调用,幂等 */ - private fun cleanup() { - val impl = translationImpl - val cb = translationCallback - val addr = device?.address - if (impl != null) { - runCatching { - Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_RECORD}") - impl.exitMode(object : OnRcspActionCallback { - override fun onSuccess(d: BluetoothDevice?, t: Int?) { - Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t") - } - override fun onError(d: BluetoothDevice?, e: com.jieli.bluetooth.bean.base.BaseError?) { - Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${e?.code} msg=${e?.message}") - } - }) - } - if (cb != null) runCatching { impl.removeTranslationCallback(cb) } - runCatching { impl.destroy() } - } - runCatching { decoder?.stop() } - runCatching { player?.stop() } - translationImpl = null - translationCallback = null - decoder = null - player = null - device = null - // 复位首帧标志,下次 enter 重新打点 - rxFirstModeChangeLogged = false - rxFirstAudioLogged = false - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ota/OtaFeature.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ota/OtaFeature.kt deleted file mode 100644 index 2f115bb8..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ota/OtaFeature.kt +++ /dev/null @@ -1,264 +0,0 @@ -package com.jielihome.jielihome.feature.ota - -import android.bluetooth.BluetoothDevice -import com.jieli.bluetooth.bean.base.BaseError -import com.jieli.bluetooth.bean.base.CommandBase -import com.jieli.bluetooth.bean.command.ota.EnterUpdateModeCmd -import com.jieli.bluetooth.bean.command.ota.ExitUpdateModeCmd -import com.jieli.bluetooth.bean.command.ota.FirmwareUpdateBlockCmd -import com.jieli.bluetooth.bean.command.ota.FirmwareUpdateStatusCmd -import com.jieli.bluetooth.bean.command.ota.GetUpdateFileOffsetCmd -import com.jieli.bluetooth.bean.command.ota.InquireUpdateCmd -import com.jieli.bluetooth.bean.command.ota.NotifyUpdateContentSizeCmd -import com.jieli.bluetooth.bean.command.ota.RebootDeviceCmd -import com.jieli.bluetooth.bean.parameter.FirmwareUpdateBlockParam -import com.jieli.bluetooth.bean.parameter.InquireUpdateParam -import com.jieli.bluetooth.bean.parameter.NotifyUpdateContentSizeParam -import com.jieli.bluetooth.bean.parameter.RebootDeviceParam -import com.jieli.bluetooth.bean.response.EnterUpdateModeResponse -import com.jieli.bluetooth.bean.response.FirmwareUpdateBlockResponse -import com.jieli.bluetooth.bean.response.FirmwareUpdateStatusResponse -import com.jieli.bluetooth.bean.response.InquireUpdateResponse -import com.jieli.bluetooth.bean.response.UpdateFileOffsetResponse -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback -import com.jielihome.jielihome.bridge.EventDispatcher -import com.jielihome.jielihome.feature.ConnectFeature -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import java.io.File -import java.io.RandomAccessFile -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -/** - * OTA 升级。 - * - * # 重要说明 - * **当前 SDK 包(jl_bluetooth_rcsp)只提供 OTA 协议的原始 Cmd,没有高层 OTA 管理器。** - * 完整 OTA 流(含 CRC 校验、断点续传、双备份切换)通常在杰理另发的 `jl_otalib_*.aar` - * 里实现。本实现是基于公开 cmd 的「最小可工作版」,**生产前需在真机验证**。 - * - * # 状态机 - * IDLE → INQUIRING → NOTIFYING_SIZE → ENTERING → TRANSFERRING(progress) → VERIFYING - * → REBOOTING → DONE / FAILED / CANCELLED - * - * # 数据流(推测的协议,需真机验证) - * 1. InquireUpdate(file flag) → device 决定能否升级 - * 2. NotifyUpdateContentSize(totalSize) → 告知固件包大小 - * 3. EnterUpdateMode → device 进入升级态 - * 4. Loop: - * GetUpdateFileOffset → device 告知它需要从哪个 offset 开始 - * FirmwareUpdateBlock(offset, len) → APP 发块(数据塞在响应里) - * until offset >= totalSize - * 5. FirmwareUpdateStatus → device 验证结果 - * 6. ExitUpdateMode + RebootDevice - * - * # 调用方式 - * server.otaFeature.start(address, "/sdcard/firmware.ufw") - * server.otaFeature.cancel() - */ -class OtaFeature( - private val btManager: JL_BluetoothManager, - private val connectFeature: ConnectFeature, - private val dispatcher: EventDispatcher, -) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val running = AtomicBoolean(false) - private var job: Job? = null - - fun isRunning(): Boolean = running.get() - - fun start( - address: String? = null, - firmwareFilePath: String, - /** 块大小(设备可能在响应里给出建议值;这里只是默认) */ - blockSize: Int = 512, - /** 升级文件标记(杰理协议要求;常见空数组或自定义校验) */ - fileFlagBytes: ByteArray = ByteArray(0), - ) { - if (!running.compareAndSet(false, true)) { - emitError(-1, "ota already running"); return - } - val device = address?.let { connectFeature.deviceByAddress(it) } - ?: connectFeature.connectedDevice() - if (device == null) { - emitError(-2, "no connected device") - running.set(false); return - } - val file = File(firmwareFilePath) - if (!file.isFile || file.length() <= 0) { - emitError(-3, "firmware file invalid: $firmwareFilePath") - running.set(false); return - } - emitState(State.INQUIRING, 0, file.length()) - job = scope.launch { runFlow(device, file, blockSize, fileFlagBytes) } - } - - fun cancel() { - job?.cancel() - job = null - running.set(false) - emitState(State.CANCELLED, -1, 0) - } - - // ───── 内部主流程 ───── - - private suspend fun runFlow( - device: BluetoothDevice, - file: File, - blockSize: Int, - fileFlagBytes: ByteArray, - ) { - try { - val totalSize = file.length() - - // 1. Inquire - val inquire: InquireUpdateResponse = sendCmd( - device, InquireUpdateCmd(InquireUpdateParam(fileFlagBytes)) - ) - if (inquire.canUpdateFlag != 0) { - throw IllegalStateException("device refused update, flag=${inquire.canUpdateFlag}") - } - - // 2. NotifyContentSize - emitState(State.NOTIFYING_SIZE, 0, totalSize) - sendCmd( - device, - NotifyUpdateContentSizeCmd(NotifyUpdateContentSizeParam(totalSize.toInt())) - ) - - // 3. Enter update mode - emitState(State.ENTERING, 0, totalSize) - val enter: EnterUpdateModeResponse = sendCmd(device, EnterUpdateModeCmd()) - if (enter.canUpdateFlag != 0) { - throw IllegalStateException("enter update mode failed, flag=${enter.canUpdateFlag}") - } - - // 4. Block transfer loop - emitState(State.TRANSFERRING, 0, totalSize) - RandomAccessFile(file, "r").use { raf -> - var safety = 0 - while (running.get()) { - val ofs: UpdateFileOffsetResponse = sendCmd(device, GetUpdateFileOffsetCmd()) - val nextOffset = ofs.updateFileFlagOffset.toLong() - val askLen = ofs.updateFileFlagLen.takeIf { it > 0 } ?: blockSize - if (nextOffset >= totalSize) break - - val len = minOf(askLen.toLong(), totalSize - nextOffset).toInt() - val data = ByteArray(len) - raf.seek(nextOffset) - raf.readFully(data) - - val blockCmd = FirmwareUpdateBlockCmd( - FirmwareUpdateBlockParam(nextOffset.toInt(), len) - ) - // 在响应里塞数据:杰理协议要求 APP 把块数据放在 response 里, - // 而不是 param 里(FirmwareUpdateBlockParam 没有 data 字段)。 - // 这里我们走 sendRcspResponse 模式:先把 cmd 当作"已收到的 device 请求" - // 处理,然后回响应。需要 SDK 内部支持;如果直接 sendRcspCommand 不行, - // 真机调试时切到 device-initiated 流(见类注释)。 - val resp = FirmwareUpdateBlockResponse().setFirmwareUpdateBlockData(data) - blockCmd.response = resp - sendBlock(device, blockCmd) - - val written = nextOffset + len - emitState(State.TRANSFERRING, written, totalSize) - safety++ - if (safety > 200_000) throw IllegalStateException("too many block iterations") - delay(0L) // yield - } - if (!running.get()) throw CancellationException() - } - - // 5. Verify - emitState(State.VERIFYING, totalSize, totalSize) - val status: FirmwareUpdateStatusResponse = sendCmd(device, FirmwareUpdateStatusCmd()) - if (status.result != 0) throw IllegalStateException("verify failed result=${status.result}") - - // 6. Exit + Reboot - emitState(State.REBOOTING, totalSize, totalSize) - runCatching { sendCmd(device, ExitUpdateModeCmd()) } - runCatching { sendCmd(device, RebootDeviceCmd(RebootDeviceParam(0))) } - - emitState(State.DONE, totalSize, totalSize) - } catch (ce: CancellationException) { - emitState(State.CANCELLED, -1, file.length()) - } catch (t: Throwable) { - emitError(-100, "ota failed: ${t.message}") - emitState(State.FAILED, -1, file.length()) - } finally { - running.set(false) - } - } - - /** 普通 OTA 控制命令:APP 发,等设备响应 */ - private suspend inline fun sendCmd( - device: BluetoothDevice, - cmd: CommandBase<*, *>, - ): R = suspendCancellableCoroutine { cont -> - btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { - override fun onCommandResponse(d: BluetoothDevice?, resp: CommandBase<*, *>?) { - @Suppress("UNCHECKED_CAST") - val r = resp?.response as? R - if (r != null) cont.resume(r) - else cont.resumeWithException(IllegalStateException("unexpected response")) - } - - override fun onErrCode(d: BluetoothDevice?, err: BaseError?) { - cont.resumeWithException(IllegalStateException("err ${err?.code}: ${err?.message}")) - } - }) - } - - /** 块响应:把已经填好 response 的 cmd 通过 sendRcspResponse 回设备 */ - private suspend fun sendBlock( - device: BluetoothDevice, - cmd: FirmwareUpdateBlockCmd, - ) = suspendCancellableCoroutine { cont -> - try { - // 先尝试 sendRcspResponse(device-initiated 模式) - btManager.sendRcspResponse(device, cmd) - cont.resume(Unit) - } catch (t: Throwable) { - // 兜底:sendRcspCommand - btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { - override fun onCommandResponse(d: BluetoothDevice?, resp: CommandBase<*, *>?) { cont.resume(Unit) } - override fun onErrCode(d: BluetoothDevice?, err: BaseError?) { - cont.resumeWithException(IllegalStateException("block err ${err?.code}: ${err?.message}")) - } - }) - } - } - - // ───── 事件 ───── - - enum class State { IDLE, INQUIRING, NOTIFYING_SIZE, ENTERING, TRANSFERRING, VERIFYING, REBOOTING, DONE, FAILED, CANCELLED } - - private fun emitState(state: State, sent: Long, total: Long) { - val percent = if (total > 0 && sent in 0..total) (sent * 100.0 / total).toInt() else -1 - dispatcher.send( - mapOf( - "type" to "otaState", - "state" to state.name, - "sent" to sent, - "total" to total, - "percent" to percent, - "tsMs" to System.currentTimeMillis(), - ) - ) - } - - private fun emitError(code: Int, msg: String?) { - dispatcher.send( - mapOf("type" to "otaError", "code" to code, "message" to msg) - ) - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/DeviceRecordFeature.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/DeviceRecordFeature.kt deleted file mode 100644 index 9feb165d..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/DeviceRecordFeature.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.jielihome.jielihome.feature.record - -import com.jielihome.jielihome.bridge.EventDispatcher -import com.jielihome.jielihome.core.JieliHomeServer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach - -/** - * 设备录音功能(MethodChannel/EventChannel 适配层)。 - * - * 实现下沉到 [JieliDeviceRecordPort]:MODE_CALL_TRANSLATION_WITH_STEREO(=6) - * + STRATEGY_DEVICE_ALWAYS_RECORDING + 双声道 OPUS(SOURCE_E_SCO_MIX)。耳机 - * 持续上推 stereo OPUS,Port 解码成 16k/16bit/stereo 交织 PCM;本类把 Flow - * 事件转发到 [EventDispatcher] 给 Dart 侧。 - * - * 事件类型: - * - `deviceRecordStart` — 上行启动成功(payload: address, sampleRate, tsMs) - * - `deviceRecordAudio` — PCM 帧 - * payload: address, streamId="in.stereo", sampleRate, channels=2, - * bitsPerSample=16, tsMs, pcm(ByteArray, 交织 LR) - * - `deviceRecordStop` — 上行已停止 - * - `deviceRecordError` — 错误 - * - * 与 [com.jielihome.jielihome.feature.translation.TranslationFeature] 互斥: - * 两者都调用 SDK enterMode,同时只能有一个活跃。 - * 调用方(MethodRouter)负责在启动前 stop 另一个。 - */ -class DeviceRecordFeature( - private val server: JieliHomeServer, -) { - private val dispatcher: EventDispatcher get() = server.dispatcher - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - @Volatile private var audioJob: Job? = null - @Volatile private var errorJob: Job? = null - @Volatile private var working = false - @Volatile private var deviceAddress: String? = null - - fun isRecording(): Boolean = working - - /** - * 启动设备录音上行。 - * - * @param args 可选参数: - * - `address` String 目标设备 MAC;省略则取当前已连设备 - * - `sampleRate` Int 采样率(Hz),默认 16000 - */ - fun start(args: Map): Result { - if (working) return Result.failure(IllegalStateException("already recording")) - - val address = args["address"] as? String - val sampleRate = (args["sampleRate"] as? Int) ?: 16000 - - // 解析地址用于 deviceRecordStart/Stop 事件携带(即使 args.address 省略,也要把 - // 实际连接到的设备地址回传给 Dart,便于上层做多设备路由) - val device = address?.let { server.connectFeature.deviceByAddress(it) } - ?: server.connectFeature.connectedDevice() - ?: return Result.failure( - IllegalStateException("no connected device; pass args.address or connect first") - ) - val resolvedAddress = device.address - - val port = server.deviceRecordPort - - // 先订阅再 start,避免首帧落在订阅建立之前被 SharedFlow 丢掉(replay=0) - audioJob = port.audioFrames.onEach { f -> - dispatcher.send( - mapOf( - "type" to "deviceRecordAudio", - "address" to f.address, - "streamId" to f.streamId, - "sampleRate" to f.sampleRate, - "channels" to f.channels, - "bitsPerSample" to f.bitsPerSample, - "tsMs" to f.tsMs, - "pcm" to f.pcm, - ) - ) - }.launchIn(scope) - - errorJob = port.errors.onEach { e -> - dispatcher.send( - mapOf( - "type" to "deviceRecordError", - "address" to e.address, - "code" to e.code, - "message" to e.message, - ) - ) - }.launchIn(scope) - - return port.start(address = resolvedAddress, sampleRate = sampleRate).fold( - onSuccess = { - working = true - deviceAddress = resolvedAddress - dispatcher.send( - mapOf( - "type" to "deviceRecordStart", - "address" to resolvedAddress, - "sampleRate" to sampleRate, - "tsMs" to System.currentTimeMillis(), - ) - ) - Result.success(Unit) - }, - onFailure = { err -> - runCatching { audioJob?.cancel() } - runCatching { errorJob?.cancel() } - audioJob = null - errorJob = null - Result.failure(err) - }, - ) - } - - /** 停止设备录音上行。幂等:未录音时无副作用。 */ - fun stop() { - if (!working) return - working = false - val addr = deviceAddress - deviceAddress = null - runCatching { audioJob?.cancel() } - runCatching { errorJob?.cancel() } - audioJob = null - errorJob = null - runCatching { server.deviceRecordPort.stop() } - dispatcher.send( - mapOf( - "type" to "deviceRecordStop", - "address" to addr, - "tsMs" to System.currentTimeMillis(), - ) - ) - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/JieliDeviceRecordPort.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/JieliDeviceRecordPort.kt deleted file mode 100644 index 8a2a953b..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/JieliDeviceRecordPort.kt +++ /dev/null @@ -1,316 +0,0 @@ -package com.jielihome.jielihome.feature.record - -import android.bluetooth.BluetoothDevice -import android.util.Log -import com.jieli.bluetooth.bean.translation.AudioData -import com.jieli.bluetooth.bean.translation.TranslationMode -import com.jieli.bluetooth.constant.Constants -import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl -import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback -import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback -import com.jielihome.jielihome.audio.OpusStreamDecoder -import com.jielihome.jielihome.core.JieliHomeServer -import com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import java.util.concurrent.atomic.AtomicLong - -/** 录音音频帧(已解码为 PCM_S16LE,双声道交织 L/R/L/R …)。 */ -data class DeviceRecordFrame( - val address: String, - /** 当前固定为 [JieliDeviceRecordPort.STREAM_STEREO]。双声道交织 PCM;左=本端,右=对端 */ - val streamId: String, - val pcm: ByteArray, - val sampleRate: Int, - val channels: Int = 2, - val bitsPerSample: Int = 16, - val tsMs: Long, -) { - override fun equals(other: Any?): Boolean = this === other - override fun hashCode(): Int = System.identityHashCode(this) -} - -/** 录音错误。 */ -data class DeviceRecordError(val address: String?, val code: Int, val message: String?) - -/** - * 设备录音端口 —— **立体声通话翻译模式(MODE_CALL_TRANSLATION_WITH_STEREO=6)+ 双声道**版。 - * - * # 设计要点 - * 与 [com.jielihome.jielihome.feature.assistant.JieliAssistantPort] 是同构关系,只是 - * 一个单声道、一个双声道: - * - JieliAssistantPort:mode=MODE_RECORD(1) + ch=1,耳机麦单通道上行 - * - 本类: mode=MODE_CALL_TRANSLATION_WITH_STEREO(6) + ch=2,耳机持续 - * 上推双声道(L=本端 UPLINK / R=对端 DOWNLINK)OPUS(source = - * SOURCE_E_SCO_MIX),由 [OpusStreamDecoder] 解码成 16k/16bit - * /stereo 交织 PCM 抛给 [audioFrames] - * - * 通路: - * 耳机双麦 → 耳机固件 OPUS 编码(stereo, MIX) → onReceiveAudioData - * → OpusStreamDecoder(channel=2, packetSize=80) → 16k/16bit/stereo 交织 PCM - * → SharedFlow - * - * # 与 [DeviceRecordFeature] 的关系 - * 两者都是录音通路实现,但走两条独立的 TranslationImpl: - * - [DeviceRecordFeature]:MODE_CALL_TRANSLATION + ch=1,单声道分两路(uplink/downlink) - * - 本类: MODE_CALL_TRANSLATION_WITH_STEREO + ch=2,双声道一路 stereo - * SDK enterMode 是设备级互斥的,两者不能同时活跃。Port 内部不再依赖 feature; - * MethodRouter / Flutter 侧仍走 feature,保持兼容。 - * - * # 设备能力 - * 进入前会校验 `TranslationImpl.isSupportCallTranslationWithStereo`;不支持的耳机直接 - * 失败返回,避免下发后耳机状态机卡 IDLE 静默丢帧。 - * - * # 格式 - * PCM_S16LE / 16 kHz / **stereo** / 20 ms。stereo 16k/16bit/20ms ≈ 80 B/OPUS 帧。 - * - * # 线程安全 - * `start` / `stop` 通过 `synchronized(this)` 互斥; - * SharedFlow.tryEmit 自身线程安全;SDK 回调线程不固定,所有共享状态用 @Volatile 标记。 - */ -class JieliDeviceRecordPort( - private val server: JieliHomeServer, -) { - companion object { - private const val TAG = "JieliDeviceRecordPort" - - /** 双声道交织 PCM,左=本端(耳机麦),右=对端(通话对方/参考) */ - const val STREAM_STEREO = "in.stereo" - } - - private val _audioFrames = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 256, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - private val _errors = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 16, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - - /** 录音 PCM 帧流(双声道交织)。 */ - val audioFrames: Flow = _audioFrames.asSharedFlow() - - /** 错误流。 */ - val errors: Flow = _errors.asSharedFlow() - - @Volatile private var entered = false - @Volatile private var device: BluetoothDevice? = null - @Volatile private var sampleRateHz = 16000 - @Volatile private var decoder: OpusStreamDecoder? = null - @Volatile private var translationImpl: TranslationImpl? = null - @Volatile private var translationCallback: TranslationCallback? = null - - val isRecording: Boolean get() = entered - - /** 调试统计:每秒打一次 SDK PCM 帧产出量 */ - private val upCount = AtomicLong(0) - @Volatile private var lastReportMs = 0L - /** 首帧 flag:首次 onModeChange / onReceiveAudioData / 首次 PCM 帧发射都各打一条 INFO */ - @Volatile private var rxFirstModeChangeLogged = false - @Volatile private var rxFirstAudioLogged = false - - /** - * 启动设备录音上行(MODE_RECORDING_TRANSLATION + stereo + DEVICE_ALWAYS_RECORDING)。 - * - * @param address 目标设备 MAC;null 取当前已连设备 - * @param sampleRate 采样率(Hz),默认 16000 - */ - @Synchronized - fun start(address: String? = null, sampleRate: Int = 16000): Result { - if (entered) return Result.failure(IllegalStateException("already recording")) - - // 与 TranslationFeature 互斥:两者都调用 enterMode,同时只能一个活跃 - if (server.translationFeature.isWorking()) server.translationFeature.stop() - - val dev = address?.let { server.connectFeature.deviceByAddress(it) } - ?: server.connectFeature.connectedDevice() - ?: return Result.failure( - IllegalStateException("device.record.no_device: pass args.address or connect first") - ) - - sampleRateHz = sampleRate - - // 1. 启动 Opus 解码器:耳机推上来的录音流是 stereo OPUS。 - // packetSize 是单帧真实大小:stereo 16k/16bit/20ms ≈ 80 B(mono 是 40 B)。 - // 详见 OpusStreamDecoder 注释。 - val od = OpusStreamDecoder( - channel = 2, - packetSize = 80, - sampleRate = sampleRate, - onPcm = { stereoPcm -> emitStereoFrame(dev.address, stereoPcm) }, - onError = { c, m -> - _errors.tryEmit( - DeviceRecordError( - address = dev.address, - code = c, - message = "opus stereo decode: ${m ?: ""}", - ) - ) - }, - ).also { it.start() } - decoder = od - - // 2. 构造 TranslationImpl + 前置校验 - val impl = TranslationImpl(server.internalBtManager, NoOpAITranslationApi(), dev) - if (!impl.isInit) { - runCatching { impl.destroy() } - cleanup() - return Result.failure( - IllegalStateException("device.record.rcsp_not_init: RCSP not init for ${dev.address}") - ) - } - if (!impl.isSupportTranslation) { - runCatching { impl.destroy() } - cleanup() - return Result.failure( - IllegalStateException("device.record.not_supported: device does not support translation") - ) - } - if (!impl.isSupportCallTranslationWithStereo) { - runCatching { impl.destroy() } - cleanup() - return Result.failure( - IllegalStateException("device.record.stereo_not_supported: device does not support stereo call translation") - ) - } - - // 3. 注册 TranslationCallback - val cb = object : TranslationCallback { - override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { - if (!rxFirstModeChangeLogged) { - rxFirstModeChangeLogged = true - Log.i(TAG, "[SDK<-DEV] onModeChange FIRST addr=${d.address} mode=${m.mode} type=${m.audioType} ch=${m.channel} sr=${m.sampleRate} strategy=${m.recordingStrategy}") - } else { - Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} strategy=${m.recordingStrategy}") - } - if (m.mode == TranslationMode.MODE_IDLE) { - _errors.tryEmit( - DeviceRecordError( - address = d.address, - code = -1, - message = "headset exited MODE_CALL_TRANSLATION_WITH_STEREO → MODE_IDLE", - ) - ) - } - } - - override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { - val payload = data.audioData - if (payload == null || payload.isEmpty()) { - Log.w(TAG, "[SDK<-DEV] onReceiveAudioData null/empty payload source=${data.source} type=${data.type}") - return - } - if (!rxFirstAudioLogged) { - rxFirstAudioLogged = true - Log.i(TAG, "[SDK<-DEV] onReceiveAudioData FIRST FRAME addr=${d.address} source=${data.source} type=${data.type} size=${payload.size}") - } - when (data.type) { - Constants.AUDIO_TYPE_PCM -> emitStereoFrame(d.address, payload) - Constants.AUDIO_TYPE_OPUS -> decoder?.feedEncoded(payload) - else -> Log.w(TAG, "[SDK<-DEV] onReceiveAudioData unknown type=${data.type} (skip)") - } - } - - override fun onError(d: BluetoothDevice, code: Int, msg: String) { - Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg") - _errors.tryEmit( - DeviceRecordError( - address = d.address, - code = code, - message = msg, - ) - ) - } - } - impl.addTranslationCallback(cb) - translationImpl = impl - translationCallback = cb - device = dev - - // 4. 下发 enterMode(MODE_CALL_RECORD=7, OPUS, ch=2, sr, STRATEGY_DEVICE_ALWAYS_RECORDING) - // - mode=6:立体声通话翻译模式,耳机 RCSP 状态机进入 STEREO 状态,固件按 - // SOURCE_E_SCO_MIX 上推双声道交织 OPUS - // - ch=2: 双声道(L=本端 UPLINK 耳机麦,R=对端 DOWNLINK 通话音/参考) - // - STRATEGY_DEVICE_ALWAYS_RECORDING=1:耳机固件自主采集并持续上推,APP 不用手机麦 - val sdkMode = TranslationMode( - TranslationMode.MODE_CALL_RECORD, - Constants.AUDIO_TYPE_OPUS, - 2, - sampleRate, - ).setRecordingStrategy(TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING) - Log.i(TAG, "[APP->SDK] enterMode addr=${dev.address} mode=${sdkMode.mode}(MODE_CALL_TRANSLATION_WITH_STEREO) type=${sdkMode.audioType}(OPUS) ch=${sdkMode.channel} sr=${sdkMode.sampleRate} strategy=${sdkMode.recordingStrategy}(DEVICE_ALWAYS_RECORDING)") - impl.enterMode(sdkMode, cb) - - entered = true - Log.d(TAG, "entered device record mode (MODE_CALL_TRANSLATION_WITH_STEREO=6, ch=2, DEVICE_ALWAYS_RECORDING)") - return Result.success(Unit) - } - - private fun emitStereoFrame(address: String, pcm: ByteArray) { - upCount.incrementAndGet() - val now = System.currentTimeMillis() - if (now - lastReportMs >= 1000L) { - Log.d(TAG, "uplink stereo PCM stats (last 1s): frames=${upCount.getAndSet(0)} bytes/frame=${pcm.size}") - lastReportMs = now - } - val frame = DeviceRecordFrame( - address = address, - streamId = STREAM_STEREO, - pcm = pcm, - sampleRate = sampleRateHz, - channels = 2, - bitsPerSample = 16, - tsMs = now, - ) - if (!_audioFrames.tryEmit(frame)) { - Log.w(TAG, "audioFrames buffer overflow; dropped streamId=$STREAM_STEREO") - } - } - - /** 停止设备录音上行。幂等。 */ - @Synchronized - fun stop() { - if (!entered) return - entered = false - cleanup() - Log.d(TAG, "exited device record mode") - } - - /** 释放 enterMode / decoder / callback;可被 start 失败路径调用,幂等 */ - private fun cleanup() { - val impl = translationImpl - val cb = translationCallback - val addr = device?.address - if (impl != null) { - runCatching { - Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_CALL_RECORD}") - impl.exitMode(object : OnRcspActionCallback { - override fun onSuccess(d: BluetoothDevice?, t: Int?) { - Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t") - } - override fun onError(d: BluetoothDevice?, e: com.jieli.bluetooth.bean.base.BaseError?) { - Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${e?.code} msg=${e?.message}") - } - }) - } - if (cb != null) runCatching { impl.removeTranslationCallback(cb) } - runCatching { impl.destroy() } - } - runCatching { decoder?.stop() } - translationImpl = null - translationCallback = null - decoder = null - device = null - rxFirstModeChangeLogged = false - rxFirstAudioLogged = false - } - - /** 兼容旧签名:本类不再依赖 EventDispatcher,无需 release。 */ - fun release() { - stop() - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/EventChannelAudioBridge.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/EventChannelAudioBridge.kt deleted file mode 100644 index ee856b79..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/EventChannelAudioBridge.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.jielihome.jielihome.feature.translation - -import com.jielihome.jielihome.bridge.EventDispatcher - -/** - * 默认桥实现:采集帧通过 EventChannel 推到 Dart(base64 PCM), - * Dart 把 PCM 喂给外部翻译服务,再通过 MethodChannel 回调 [TranslationFeature.feedTranslatedAudio]。 - * - * 16kHz / 16bit / 单声道 PCM 流量约 32KB/s,base64 后 ~43KB/s,跨通道开销可控。 - * 若宿主想完全 native 直连翻译服务,可实现自己的 [TranslationAudioBridge] 并通过 - * [TranslationFeature.setBridge] 替换。 - */ -class EventChannelAudioBridge( - private val dispatcher: EventDispatcher, - /** 由 TranslationFeature 注入:把 PCM 真正塞回当前 ModeHandler */ - var injector: TranslationAudioInjector? = null, -) : TranslationAudioBridge { - - override fun emitAudioFrame( - modeId: Int, - streamId: String, - pcm: ByteArray, - format: AudioFormat, - seq: Long, - tsMs: Long, - isFinal: Boolean, - ) { - // EventChannel 原生支持 byte[] → Uint8List,直接传字节流,避免 base64 33% 体积膨胀。 - dispatcher.send( - mapOf( - "type" to "translationAudio", - "modeId" to modeId, - "streamId" to streamId, - "sampleRate" to format.sampleRate, - "channels" to format.channels, - "bitsPerSample" to format.bitsPerSample, - "seq" to seq, - "tsMs" to tsMs, - "final" to isFinal, - "pcm" to pcm, - ) - ) - } - - override fun emitTranslationResult( - modeId: Int, - srcLang: String?, - srcText: String?, - destLang: String?, - destText: String?, - requestId: String?, - ) { - dispatcher.send( - mapOf( - "type" to "translationResult", - "modeId" to modeId, - "srcLang" to srcLang, - "srcText" to srcText, - "destLang" to destLang, - "destText" to destText, - "requestId" to requestId, - ) - ) - } - - override fun emitLog(modeId: Int, content: String) { - dispatcher.send( - mapOf("type" to "translationLog", "modeId" to modeId, "content" to content) - ) - } - - override fun emitError(modeId: Int, code: Int, message: String?) { - dispatcher.send( - mapOf( - "type" to "translationError", - "modeId" to modeId, - "code" to code, - "message" to message - ) - ) - } - - override fun feedTranslatedAudio( - modeId: Int, - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean { - return injector?.inject(modeId, outputStreamId, pcm, format, isFinal) ?: false - } -} - -/** - * 内部接口:TranslationFeature 决定当前 ModeHandler 是谁,由它消费 PCM。 - */ -fun interface TranslationAudioInjector { - fun inject( - modeId: Int, - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/JieliCallTranslationPort.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/JieliCallTranslationPort.kt deleted file mode 100644 index d84b1a15..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/JieliCallTranslationPort.kt +++ /dev/null @@ -1,215 +0,0 @@ -package com.jielihome.jielihome.feature.translation - -import android.util.Log -import com.aiagent.device_plugin_interface.CallAudioCodec -import com.aiagent.device_plugin_interface.CallAudioFormat -import com.aiagent.device_plugin_interface.CallAudioFrame -import com.aiagent.device_plugin_interface.CallTranslationError -import com.aiagent.device_plugin_interface.CallTranslationLeg -import com.aiagent.device_plugin_interface.DeviceCallTranslationPort -import com.aiagent.device_plugin_interface.TranslatedAudioFrame -import com.jielihome.jielihome.core.JieliHomeServer -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow - -/** - * `DeviceCallTranslationPort` 的杰理实现。 - * - * 当前阶段策略:**只支持 PCM_S16LE / 16kHz / mono / 20ms**。 - * 新版 SDK 已经在 native 内部把耳机端 OPUS 解码成 PCM 上推(见 [TranslationFeature] - * 的 `mode` handlers),上层零编解码即可贯通。OPUS 直通模式后续按需补。 - * - * # enter / exit 与 SDK bridge 的关系 - * - [enter] 时把 [JieliHomeServer.defaultBridge](EventChannel → Dart 的桥)替换为 - * 本实现内的 capture bridge:拦截 `IN_UPLINK` / `IN_DOWNLINK` 上推的 PCM 帧, - * 翻译成 [CallAudioFrame] 写入 [audioFrames] 流;同时把 `emitError` 接到 - * [errors] 流。 - * - 期间 Dart 的 `device_jieli/event` 不会再收到翻译音频事件——这正是"完全 native - * 直连翻译服务"的预期路径,与 `translate_server` native 编排器配套。 - * - [exit] 时还原默认桥,Dart 端的 EventChannel 恢复正常使用。 - * - * # TTS 回灌 - * 编排器把翻译完成的 PCM 通过 [reportTranslated] 回写: - * - `CallTranslationLeg.UPLINK` → SDK 的 `OUT_UPLINK`(对方耳机听) - * - `CallTranslationLeg.DOWNLINK` → SDK 的 `OUT_DOWNLINK`(本机用户耳机听) - * - * # 线程安全 - * `enter`/`exit`/`reportTranslated` 通过 `synchronized(this)` 互斥; - * SharedFlow `tryEmit` 自身线程安全,capture bridge 可被任意线程回调。 - */ -class JieliCallTranslationPort( - private val server: JieliHomeServer, -) : DeviceCallTranslationPort { - - companion object { - private const val TAG = "JieliCallTransPort" - private val PCM_16K_MONO_20MS = CallAudioFormat.PCM_S16LE_16K_MONO_20MS - } - - private val _audioFrames = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 128, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - private val _errors = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 16, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - - @Volatile private var entered = false - @Volatile private var savedBridge: TranslationAudioBridge? = null - - override fun supportedSourceFormats(): Set = setOf(PCM_16K_MONO_20MS) - override fun supportedSinkFormats(): Set = setOf(PCM_16K_MONO_20MS) - - override val audioFrames: Flow = _audioFrames.asSharedFlow() - override val errors: Flow = _errors.asSharedFlow() - - @Synchronized - override fun enter(sourceFormat: CallAudioFormat) { - require(sourceFormat == PCM_16K_MONO_20MS) { - "JieliCallTranslationPort only supports PCM_S16LE/16k/mono/20ms (got $sourceFormat)" - } - check(!entered) { "device.call_translation.busy" } - - val translationFeature = server.translationFeature - - // 调试:统计 SDK 给我们推了多少帧、按 leg 分桶;每秒打一次。 - val countByStream = java.util.concurrent.ConcurrentHashMap() - var lastReportMs = System.currentTimeMillis() - - val captureBridge = object : TranslationAudioBridge { - override fun emitAudioFrame( - modeId: Int, - streamId: String, - pcm: ByteArray, - format: AudioFormat, - seq: Long, - tsMs: Long, - isFinal: Boolean, - ) { - // 调试用:周期性打印 SDK 上推帧统计,确认设备端有没有真出 PCM。 - countByStream.merge(streamId, 1L) { old, _ -> old + 1L } - val now = System.currentTimeMillis() - if (now - lastReportMs >= 1000L) { - Log.d(TAG, "SDK frame stats (last 1s): $countByStream fmt=${format.sampleRate}Hz/${format.channels}ch") - countByStream.clear() - lastReportMs = now - } - val leg = when (streamId) { - TranslationStreams.IN_UPLINK -> CallTranslationLeg.UPLINK - TranslationStreams.IN_DOWNLINK -> CallTranslationLeg.DOWNLINK - else -> return // ignore non-call streams in this mode - } - val frame = CallAudioFrame( - leg = leg, - codec = CallAudioCodec.PCM_S16LE, - sampleRate = format.sampleRate, - channels = format.channels, - bytes = pcm, - sequence = seq, - timestampUs = tsMs * 1000L, - ) - if (!_audioFrames.tryEmit(frame)) { - Log.w(TAG, "audioFrames buffer overflow; dropped seq=$seq leg=$leg") - } - } - - override fun emitTranslationResult( - modeId: Int, - srcLang: String?, srcText: String?, - destLang: String?, destText: String?, - requestId: String?, - ) { - // 字幕在 native 编排器里由 agent 直接出,不依赖这个回调;忽略。 - } - - override fun emitLog(modeId: Int, content: String) { - Log.d(TAG, "translation log: modeId=$modeId $content") - } - - override fun emitError(modeId: Int, code: Int, message: String?) { - _errors.tryEmit( - CallTranslationError( - code = "device.feature_failed", - message = "code=$code msg=${message ?: ""}", - ) - ) - } - - override fun feedTranslatedAudio( - modeId: Int, - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean { - // capture bridge 不负责回灌——编排器走 [reportTranslated] 直接调 - // server.translationFeature.feedTranslatedAudio。 - return false - } - } - - savedBridge = server.defaultBridge - server.setTranslationBridge(captureBridge) - - // 显式走 6 号立体声通话翻译(MODE_CALL_TRANSLATION_WITH_STEREO): - // 耳机 SDK 会上推一路立体声 PCM(L=上行 / R=下行), - // 由 StereoCallTranslationModeHandler 内部拆成两路 mono 再分发到 - // IN_UPLINK / IN_DOWNLINK,对外 captureBridge 仍然看到 16k/mono/20ms。 - // 注意:该模式依赖真实 SCO 通话事件,没有通话时不会出帧。 - val result = translationFeature.start( - TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO, - emptyMap(), - ) - if (result.isFailure) { - // 启动失败:还原桥并抛出 - savedBridge?.let { runCatching { server.setTranslationBridge(it) } } - savedBridge = null - throw IllegalStateException( - "failed to enter MODE_CALL_TRANSLATION_WITH_STEREO: ${result.exceptionOrNull()?.message}", - result.exceptionOrNull(), - ) - } - entered = true - Log.d(TAG, "entered MODE_CALL_TRANSLATION_WITH_STEREO") - } - - @Synchronized - override fun reportTranslated(frame: TranslatedAudioFrame) { - check(entered) { "device.call_translation.not_active" } - require(frame.codec == CallAudioCodec.PCM_S16LE) { - "JieliCallTranslationPort only accepts PCM_S16LE for sink (got ${frame.codec})" - } - val streamId = when (frame.leg) { - CallTranslationLeg.UPLINK -> TranslationStreams.OUT_UPLINK - CallTranslationLeg.DOWNLINK -> TranslationStreams.OUT_DOWNLINK - } - val ok = server.translationFeature.feedTranslatedAudio( - outputStreamId = streamId, - pcm = frame.bytes, - format = AudioFormat( - sampleRate = frame.sampleRate, - channels = frame.channels, - bitsPerSample = 16, - ), - isFinal = frame.isFinal, - ) - if (!ok) { - Log.w(TAG, "feedTranslatedAudio rejected: streamId=$streamId leg=${frame.leg}") - } - } - - @Synchronized - override fun exit() { - if (!entered) return - entered = false - runCatching { server.translationFeature.stop() } - savedBridge?.let { runCatching { server.setTranslationBridge(it) } } - savedBridge = null - Log.d(TAG, "exited call translation") - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationAudioBridge.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationAudioBridge.kt deleted file mode 100644 index 8c566516..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationAudioBridge.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.jielihome.jielihome.feature.translation - -/** - * 翻译音频桥:插件 ↔ 外部翻译服务 的双向接口。 - * - * - 「采集面」:ModeHandler 调用 [emitAudioFrame] 把解码后的 PCM 帧推出去。 - * 默认实现走 EventChannel 推到 Dart,由 Dart 转发到外部翻译服务。 - * 也可以由宿主原生代码替换成自定义实现(比如直接走 gRPC)。 - * - * - 「回放面」:[feedTranslatedAudio] 是入口,外部翻译服务调用,把 TTS PCM 注入进来。 - * 交给当前 ModeHandler 编码并回送到耳机/A2DP。 - * - * - 文本结果(字幕)走 [emitTranslationResult],纯展示用,不影响音频路径。 - */ -interface TranslationAudioBridge { - - /** 插件→外部 :推一帧采集到的 PCM 音频 */ - fun emitAudioFrame( - modeId: Int, - streamId: String, - pcm: ByteArray, - format: AudioFormat, - seq: Long, - tsMs: Long, - isFinal: Boolean = false, - ) - - /** 插件→外部 :推翻译文本(可选)。requestId 由调用方贯穿同一段翻译,便于字幕拼接 */ - fun emitTranslationResult( - modeId: Int, - srcLang: String?, - srcText: String?, - destLang: String?, - destText: String?, - requestId: String? = null, - ) - - /** 插件→外部 :日志 */ - fun emitLog(modeId: Int, content: String) - - /** 插件→外部 :错误 */ - fun emitError(modeId: Int, code: Int, message: String?) - - /** 外部→插件 :注入翻译完成的 TTS PCM;插件负责回送到 outputStreamId 对应的物理通道 */ - fun feedTranslatedAudio( - modeId: Int, - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt deleted file mode 100644 index c51d8f47..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.jielihome.jielihome.feature.translation - -import android.content.Context -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jielihome.jielihome.feature.ConnectFeature -import com.jielihome.jielihome.feature.translation.mode.AudioTranslationModeHandler -import com.jielihome.jielihome.feature.translation.mode.CallTranslationModeHandler -import com.jielihome.jielihome.feature.translation.mode.FaceToFaceTranslationModeHandler -import com.jielihome.jielihome.feature.translation.mode.RecordModeHandler -import com.jielihome.jielihome.feature.translation.mode.RecordingTranslationModeHandler -import com.jielihome.jielihome.feature.translation.mode.StereoCallTranslationModeHandler - -/** - * 翻译特性入口。 - * - * # TTS PCM 回灌的两条路径 - * 宿主把外部翻译服务回送的 PCM 注入回插件时,**两条路径同时存在、宿主只需选一条**: - * - * 1) Dart / 远端进程 → MethodChannel `feedTranslatedAudio` → MethodRouter → - * `TranslationFeature.feedTranslatedAudio` → 当前 [TranslationModeHandler.onTranslatedAudio] - * 场景:Flutter UI 调用,或宿主原生代码使用通用入口(不区分桥实现时)。 - * - * 2) 自定义 native bridge → `TranslationAudioBridge.feedTranslatedAudio` → - * (桥内部决定如何下发;默认实现 [EventChannelAudioBridge] 通过 injector 转回 1)。 - * 场景:宿主用 [setBridge] 替换桥,希望把音频生命周期完全留在 native 层管理。 - * - * 推荐:除非有性能/隔离强需求,**统一走路径 1**(更简单、字幕和音频走同一条命令通道)。 - * - * # 立体声通话翻译 - * `MODE_CALL_TRANSLATION` 永远是双流单声道;`MODE_CALL_TRANSLATION_WITH_STEREO` - * 永远走立体声软件分声道方案。是否切到后者由调用方自行判断,可先调 - * [isSupportCallTranslationWithStereo] 探测设备能力。 - */ -class TranslationFeature( - private val context: Context, - private val btManager: JL_BluetoothManager, - private val connectFeature: ConnectFeature, -) { - @Volatile - private var bridge: TranslationAudioBridge? = null - - private var handlers: Map = emptyMap() - - @Volatile - private var current: TranslationModeHandler? = null - - /** 由 JieliHomeServer 在 init 时调一次;后续宿主可调 [setBridge] 替换 */ - fun setBridge(bridge: TranslationAudioBridge) { - this.bridge = bridge - // 重建 handlers,把新桥 + 上下文注入下去 - handlers = mapOf( - TranslationModeIds.MODE_RECORD to RecordModeHandler(context, btManager, bridge), - TranslationModeIds.MODE_RECORDING_TRANSLATION to RecordingTranslationModeHandler(context, btManager, bridge), - TranslationModeIds.MODE_CALL_TRANSLATION to CallTranslationModeHandler(context, btManager, connectFeature, bridge), - TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO to StereoCallTranslationModeHandler(context, btManager, connectFeature, bridge), - TranslationModeIds.MODE_AUDIO_TRANSLATION to AudioTranslationModeHandler(btManager, bridge), - TranslationModeIds.MODE_FACE_TO_FACE_TRANSLATION to FaceToFaceTranslationModeHandler(context, btManager, bridge), - ) - } - - fun start(modeId: Int, args: Map): Result { - // 与官方 demo (TranslationViewModel.enterMode) 对齐:MODE_CALL_TRANSLATION - // + OPUS + 设备支持立体声方案时,自动升级到 MODE_CALL_TRANSLATION_WITH_STEREO - // (channel=2)。很多固件版本下 mono CALL_TRANSLATION 通路实际上不出帧, - // 真正出数据的是立体声方案——上下行 mix 成一路立体声 PCM 上推。 - // - // 例外:AI 助理 / 纯上行录音 场景没有真实通话事件,固件不会下发 SCO_MIX, - // 升级到 stereo 反而会"零帧"。这种调用方应在 args 里传 - // "bypassStereoUpgrade"=true,强制走 mono mode=5 + ALWAYS_RECORDING。 - val bypassStereo = args["bypassStereoUpgrade"] == true - val effectiveModeId = if ( - !bypassStereo && - modeId == TranslationModeIds.MODE_CALL_TRANSLATION && - (args["audioType"] == null || - args["audioType"] == com.jieli.bluetooth.constant.Constants.AUDIO_TYPE_OPUS || - (args["audioType"] as? String)?.lowercase() == "opus") && - isSupportCallTranslationWithStereo(args["address"] as? String) - ) { - android.util.Log.d( - "TranslationFeature", - "MODE_CALL_TRANSLATION + OPUS + stereo-supported → upgrade to MODE_CALL_TRANSLATION_WITH_STEREO", - ) - TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO - } else modeId - if (bypassStereo) { - android.util.Log.d( - "TranslationFeature", - "bypassStereoUpgrade=true → keep mono MODE_CALL_TRANSLATION (no real phone-call scenario)", - ) - } - - val handler = handlers[effectiveModeId] - ?: return Result.failure(IllegalArgumentException("unknown modeId=$effectiveModeId")) - current?.takeIf { it.isWorking }?.stop() - handler.start(args) - current = handler - return Result.success(Unit) - } - - fun stop() { - current?.takeIf { it.isWorking }?.stop() - current = null - } - - fun isWorking(): Boolean = current?.isWorking == true - fun currentModeId(): Int? = current?.modeId - fun currentInputStreams(): List = current?.inputStreams ?: emptyList() - fun currentOutputStreams(): List = current?.outputStreams ?: emptyList() - - /** - * 翻译服务回送 TTS PCM 的统一入口。 - * 路由到当前活动 ModeHandler 的 onTranslatedAudio。 - */ - fun feedTranslatedAudio( - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean { - val h = current ?: return false - if (!h.isWorking) return false - if (outputStreamId !in h.outputStreams) return false - return h.onTranslatedAudio(outputStreamId, pcm, format, isFinal) - } - - /** 透传文本结果(字幕);requestId 由调用方贯穿同一段翻译,便于聚合 */ - fun feedTranslationResult( - srcLang: String?, srcText: String?, - destLang: String?, destText: String?, - requestId: String? = null, - ) { - val mid = current?.modeId ?: return - bridge?.emitTranslationResult(mid, srcLang, srcText, destLang, destText, requestId) - } - - /** - * 探测当前已连耳机是否支持立体声通话翻译方案。 - * 内部根据 SDK [com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl.isSupportCallTranslationWithStereo] - * 判断;若设备未连或未握手 RCSP 则返回 false。 - */ - fun isSupportCallTranslationWithStereo(connectedDeviceAddress: String?): Boolean { - val device = connectedDeviceAddress?.let { - com.jieli.bluetooth.utils.BluetoothUtil.getRemoteDevice(it) - } ?: btManager.connectedDevice ?: return false - val tmp = com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl( - btManager, - com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi(), - device, - ) - return runCatching { tmp.isSupportCallTranslationWithStereo }.getOrDefault(false) - .also { runCatching { tmp.destroy() } } - } - - /** - * 把外部音频文件解码后的 PCM 灌入「音视频翻译」模式(仅在该模式生效)。 - */ - fun feedAudioFilePcm(pcm: ByteArray, sampleRate: Int = 16000): Boolean { - val h = current as? com.jielihome.jielihome.feature.translation.mode.AudioTranslationModeHandler - ?: return false - h.feedFilePcm(pcm, sampleRate) - return true - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationModeHandler.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationModeHandler.kt deleted file mode 100644 index f8cae980..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationModeHandler.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.jielihome.jielihome.feature.translation - -import com.jieli.bluetooth.impl.JL_BluetoothManager - -/** - * 翻译模式处理器统一接口。每种模式独立实现: - * - 声明自己消费哪些「输入流」(采集面)和产出哪些「输出流」(回放面) - * - 实现 start:启动 SDK 端音频流(进入翻译模式 / 起手机麦),把解码后的 PCM 通过 bridge 推出 - * - 实现 stop:关闭音频流,释放编解码器 - * - 实现 onTranslatedAudio:接到外部翻译服务回送的 PCM,按 outputStreamId 路由 - */ -interface TranslationModeHandler { - val modeId: Int - val inputStreams: List - val outputStreams: List - - fun start(args: Map) - fun stop() - val isWorking: Boolean - - /** 外部翻译服务回送的 PCM 注入点,由 TranslationFeature 路由到当前活动 handler */ - fun onTranslatedAudio( - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean -} - -abstract class BaseTranslationModeHandler( - protected val btManager: JL_BluetoothManager, - protected val bridge: TranslationAudioBridge, -) : TranslationModeHandler { - - @Volatile - protected var working: Boolean = false - - override val isWorking: Boolean get() = working - - /** 把采集到的一帧 PCM 推给外部翻译服务 */ - protected fun pushFrame( - streamId: String, - pcm: ByteArray, - format: AudioFormat = AudioFormat(), - seq: Long = System.nanoTime(), - isFinal: Boolean = false, - ) { - bridge.emitAudioFrame(modeId, streamId, pcm, format, seq, System.currentTimeMillis(), isFinal) - } - - protected fun emitLog(content: String) = bridge.emitLog(modeId, content) - protected fun emitError(code: Int, msg: String?) = bridge.emitError(modeId, code, msg) -} - -/** - * 与 SDK [com.jieli.bluetooth.bean.translation.TranslationMode] 完全一致的取值, - * 方便直接拿插件 modeId 去构造 SDK 的 TranslationMode。 - */ -object TranslationModeIds { - const val MODE_IDLE = 0 - const val MODE_RECORD = 1 - const val MODE_RECORDING_TRANSLATION = 2 - const val MODE_CALL_TRANSLATION = 3 - const val MODE_AUDIO_TRANSLATION = 4 - const val MODE_FACE_TO_FACE_TRANSLATION = 5 - const val MODE_CALL_TRANSLATION_WITH_STEREO = 6 -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationStreams.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationStreams.kt deleted file mode 100644 index 96d6a07c..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationStreams.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.jielihome.jielihome.feature.translation - -/** - * 翻译流逻辑 ID 常量。 - * 「输入流」= 插件解码后推给翻译服务(设备麦克风、eSCO 上行/下行、本地音频文件等)。 - * 「输出流」= 翻译服务返回 TTS PCM 后,插件回送的目标(耳机扬声器、A2DP 本地、对端通话上行等)。 - */ -object TranslationStreams { - // 输入流:插件解码后推给翻译服务的音频 - const val IN_MIC = "in.mic" - const val IN_UPLINK = "in.uplink" // 通话上行:本机用户 - const val IN_DOWNLINK = "in.downlink" // 通话下行:对端 - const val IN_AUDIO_FILE = "in.audioFile" // 本地音频文件 - - // 输出流:翻译服务回送 TTS 后插件应送达的目的地 - const val OUT_SPEAKER = "out.speaker" - const val OUT_UPLINK = "out.uplink" // 译文给对端听 - const val OUT_DOWNLINK = "out.downlink" // 译文给本机用户听 - const val OUT_LOCAL_PLAYBACK = "out.localPlayback" -} - -/** 一帧 PCM 音频规格 */ -data class AudioFormat( - val sampleRate: Int = 16000, - val channels: Int = 1, - val bitsPerSample: Int = 16, -) diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/AudioTranslationModeHandler.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/AudioTranslationModeHandler.kt deleted file mode 100644 index 8eace3ec..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/AudioTranslationModeHandler.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.jielihome.jielihome.feature.translation.mode - -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jielihome.jielihome.audio.LocalPlayer -import com.jielihome.jielihome.feature.translation.AudioFormat -import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler -import com.jielihome.jielihome.feature.translation.TranslationAudioBridge -import com.jielihome.jielihome.feature.translation.TranslationModeIds -import com.jielihome.jielihome.feature.translation.TranslationStreams - -/** - * MODE_AUDIO_TRANSLATION —— 音视频翻译。 - * 输入:本地音频文件(由调用方自行解码并通过 [feedFilePcm] 灌进来,避免插件依赖各种封装格式解析) - * 输出:本机扬声器(A2DP),不回耳机 - */ -class AudioTranslationModeHandler( - btManager: JL_BluetoothManager, - bridge: TranslationAudioBridge, -) : BaseTranslationModeHandler(btManager, bridge) { - - override val modeId = TranslationModeIds.MODE_AUDIO_TRANSLATION - override val inputStreams = listOf(TranslationStreams.IN_AUDIO_FILE) - override val outputStreams = listOf(TranslationStreams.OUT_LOCAL_PLAYBACK) - - private var player: LocalPlayer? = null - - override fun start(args: Map) { - if (working) return - val sampleRate = (args["sampleRate"] as? Int) ?: 16000 - val channels = (args["channels"] as? Int) ?: 1 - player = LocalPlayer(sampleRate, channels).also { it.start() } - working = true - emitLog("AudioTranslation start sampleRate=$sampleRate channels=$channels") - } - - /** 调用方把解码出的 PCM 灌进来推给翻译服务 */ - @Suppress("unused") - fun feedFilePcm(pcm: ByteArray, sampleRate: Int = 16000) { - if (!working) return - pushFrame(TranslationStreams.IN_AUDIO_FILE, pcm, AudioFormat(sampleRate, 1, 16)) - } - - override fun stop() { - if (!working) return - player?.stop() - player = null - working = false - emitLog("AudioTranslation stop") - } - - override fun onTranslatedAudio( - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean { - if (outputStreamId != TranslationStreams.OUT_LOCAL_PLAYBACK) return false - player?.feed(pcm) - return true - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt deleted file mode 100644 index 087663f3..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.jielihome.jielihome.feature.translation.mode - -import android.content.Context -import com.jieli.bluetooth.bean.translation.AudioData -import com.jieli.bluetooth.bean.translation.TranslationMode -import com.jieli.bluetooth.constant.Constants -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jielihome.jielihome.feature.ConnectFeature -import com.jielihome.jielihome.feature.translation.AudioFormat -import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler -import com.jielihome.jielihome.feature.translation.TranslationAudioBridge -import com.jielihome.jielihome.feature.translation.TranslationModeIds -import com.jielihome.jielihome.feature.translation.TranslationStreams -import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime -import java.io.File - -/** - * MODE_CALL_TRANSLATION —— 通话翻译,eSCO 上下行各一路单声道。 - * 输入:uplink(本机用户)+ downlink(对端) - * 输出:uplink(TTS 给对端听)+ downlink(TTS 给本机用户听) - * - * 实现:通过 [RcspTranslationRuntime] 让耳机进入 SDK 翻译模式, - * OPUS 流式解码后按 source 分流推到 bridge; - * 回送 TTS PCM 时编码 OPUS 后通过同一 runtime 写回耳机。 - */ -class CallTranslationModeHandler( - private val context: Context, - btManager: JL_BluetoothManager, - private val connectFeature: ConnectFeature, - bridge: TranslationAudioBridge, -) : BaseTranslationModeHandler(btManager, bridge) { - - override val modeId = TranslationModeIds.MODE_CALL_TRANSLATION - override val inputStreams = listOf(TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK) - override val outputStreams = listOf(TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK) - - private var runtime: RcspTranslationRuntime? = null - - override fun start(args: Map) { - if (working) return - val address = args["address"] as? String - ?: connectFeature.connectedDevice()?.address - val device = address?.let { connectFeature.deviceByAddress(it) } - ?: connectFeature.connectedDevice() - if (device == null) { - emitError(-200, "no connected device; pass args.address or connect first") - return - } - - val sampleRate = (args["sampleRate"] as? Int) ?: 16000 - val audioType = parseAudioType(args["audioType"]) ?: Constants.AUDIO_TYPE_OPUS - // Recording strategy 选择: - // STRATEGY_DEVICE_AUTO_RECORDING(2) —— 耳机自动检测真实 SCO 通话才开始录, - // 手机端没在打电话时耳机不会上推 PCM,本场景(双向 AI 通话翻译)行不通。 - // STRATEGY_DEVICE_ALWAYS_RECORDING(1) —— 一进入翻译模式就持续上推双声道 - // PCM,正是 AI 通话翻译需要的"始终开麦"语义。 - // 允许通过 args["strategy"] 覆盖(数值或字符串 "auto"/"always"/"custom")。 - val strategy = parseStrategy(args["strategy"]) - ?: TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING - val sdkMode = TranslationMode( - TranslationMode.MODE_CALL_TRANSLATION, - audioType, - 1, // 单声道 - sampleRate, - ).setRecordingStrategy(strategy) - - val rt = RcspTranslationRuntime( - btManager = btManager, - device = device, - mode = sdkMode, - tempDir = File(context.cacheDir, "jieli_translation_tts"), - onPcm = { source, pcm -> - val streamId = when (source) { - AudioData.SOURCE_E_SCO_UP_LINK -> TranslationStreams.IN_UPLINK - AudioData.SOURCE_E_SCO_DOWN_LINK -> TranslationStreams.IN_DOWNLINK - else -> return@RcspTranslationRuntime - } - pushFrame(streamId, pcm, AudioFormat(sampleRate, 1, 16)) - }, - onError = { code, msg -> emitError(code, msg) } - ) - rt.start().fold( - { - runtime = rt - working = true - emitLog("CallTranslation start device=${device.address} audioType=$audioType sampleRate=$sampleRate") - }, - { emitError(-201, it.message); rt.stop() }, - ) - } - - private fun parseStrategy(raw: Any?): Int? = when (raw) { - null -> null - is Int -> raw - is Number -> raw.toInt() - is String -> when (raw.lowercase()) { - "custom" -> TranslationMode.STRATEGY_CUSTOM_RECORDING - "always" -> TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING - "auto" -> TranslationMode.STRATEGY_DEVICE_AUTO_RECORDING - else -> raw.toIntOrNull() - } - else -> null - } - - private fun parseAudioType(raw: Any?): Int? = when (raw) { - null -> null - is Int -> raw - is Number -> raw.toInt() - is String -> when (raw.lowercase()) { - "opus" -> Constants.AUDIO_TYPE_OPUS - "pcm" -> Constants.AUDIO_TYPE_PCM - "speex" -> Constants.AUDIO_TYPE_SPEEX - "msbc", "m_sbc" -> Constants.AUDIO_TYPE_M_SBC - "jla_v2", "jlav2" -> Constants.AUDIO_TYPE_JLA_V2 - else -> raw.toIntOrNull() - } - else -> null - } - - override fun stop() { - if (!working) return - runtime?.stop() - runtime = null - working = false - emitLog("CallTranslation stop") - } - - override fun onTranslatedAudio( - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean { - if (outputStreamId != TranslationStreams.OUT_UPLINK && - outputStreamId != TranslationStreams.OUT_DOWNLINK) return false - return runtime?.feedTtsPcm(outputStreamId, pcm, isFinal) ?: false - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/FaceToFaceTranslationModeHandler.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/FaceToFaceTranslationModeHandler.kt deleted file mode 100644 index 04624dc7..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/FaceToFaceTranslationModeHandler.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.jielihome.jielihome.feature.translation.mode - -import android.content.Context -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jielihome.jielihome.audio.LocalPlayer -import com.jielihome.jielihome.audio.PhoneMicCapture -import com.jielihome.jielihome.feature.translation.AudioFormat -import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler -import com.jielihome.jielihome.feature.translation.TranslationAudioBridge -import com.jielihome.jielihome.feature.translation.TranslationModeIds -import com.jielihome.jielihome.feature.translation.TranslationStreams - -/** - * MODE_FACE_TO_FACE_TRANSLATION —— 面对面翻译。 - * 输入:手机麦 - * 输出:本机扬声器(自带的 LocalPlayer,A2DP/外放都行) - */ -class FaceToFaceTranslationModeHandler( - private val context: Context, - btManager: JL_BluetoothManager, - bridge: TranslationAudioBridge, -) : BaseTranslationModeHandler(btManager, bridge) { - - override val modeId = TranslationModeIds.MODE_FACE_TO_FACE_TRANSLATION - override val inputStreams = listOf(TranslationStreams.IN_MIC) - override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) - - private var mic: PhoneMicCapture? = null - private var player: LocalPlayer? = null - - override fun start(args: Map) { - if (working) return - val sampleRate = (args["sampleRate"] as? Int) ?: 16000 - mic = PhoneMicCapture( - context, sampleRate, - onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, - onError = { code, msg -> emitError(code, msg) }, - ) - if (mic?.start() != true) return - player = LocalPlayer(sampleRate, 1).also { it.start() } - working = true - emitLog("FaceToFaceTranslation start") - } - - override fun stop() { - if (!working) return - mic?.stop(); mic = null - player?.stop(); player = null - working = false - emitLog("FaceToFaceTranslation stop") - } - - override fun onTranslatedAudio( - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean { - if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false - player?.feed(pcm) - return true - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt deleted file mode 100644 index 1ac08fbe..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.jielihome.jielihome.feature.translation.mode - -import android.bluetooth.BluetoothDevice -import android.content.Context -import android.util.Log -import com.jieli.bluetooth.bean.translation.AudioData -import com.jieli.bluetooth.bean.translation.TranslationMode -import com.jieli.bluetooth.constant.Constants -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl -import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback -import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback -import com.jielihome.jielihome.audio.PhoneMicCapture -import com.jielihome.jielihome.feature.translation.AudioFormat -import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler -import com.jielihome.jielihome.feature.translation.TranslationAudioBridge -import com.jielihome.jielihome.feature.translation.TranslationModeIds -import com.jielihome.jielihome.feature.translation.TranslationStreams -import com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi - -/** - * MODE_RECORD —— 单向录音翻译(mode=1)。 - * - * # 与 JL_HomeSdkDemo 对齐 - * Demo 里 TranslationFragment 进入"录音"时会调 `TranslationImpl.enterMode(MODE_RECORD=1, ...)`, - * 耳机端 RCSP 状态机随之从 MODE_IDLE(0) 切到 MODE_RECORD(0x01),耳机可能播放提示音 / 切 LED 状态。 - * 之前本类仅启动手机麦、**未发 enterMode**,导致耳机侧始终停在 IDLE(0),固件不认。 - * - * 现在按 demo: - * 1. 构造 TranslationMode(mode=1, OPUS/PCM, ch=1, sampleRate) - * + setRecordingStrategy(STRATEGY_CUSTOM_RECORDING=0) - * 2. TranslationImpl.enterMode(mode, callback) 通知耳机进入 RECORD 模式 - * 3. STRATEGY_CUSTOM_RECORDING 语义 → APP 用手机麦采音(与 demo 一致) - * 4. stop() 调 exitMode 让耳机回到 IDLE,避免状态机残留 - * - * # 音频流向 - * - 输入:手机麦 → PhoneMicCapture → pushFrame → 外部翻译服务 - * - 输出:OUT_SPEAKER 由宿主自行播放(STRATEGY_CUSTOM_RECORDING 不走 RCSP 下发 TTS) - * - * # 与 DeviceRecordFeature 区分 - * - 本类仍是 "手机麦 + 通知耳机进 RECORD 状态",和 demo 对齐 - * - DeviceRecordFeature 用 `MODE_CALL_TRANSLATION + STRATEGY_DEVICE_ALWAYS_RECORDING` - * 直接从耳机麦抓 OPUS 上行帧,路径完全不同 - */ -class RecordModeHandler( - private val context: Context, - btManager: JL_BluetoothManager, - bridge: TranslationAudioBridge, -) : BaseTranslationModeHandler(btManager, bridge) { - - companion object { - private const val TAG = "RecordModeHandler" - } - - override val modeId = TranslationModeIds.MODE_RECORD - override val inputStreams = listOf(TranslationStreams.IN_MIC) - override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) - - private var mic: PhoneMicCapture? = null - @Volatile private var translationImpl: TranslationImpl? = null - @Volatile private var activeDevice: BluetoothDevice? = null - - private val translationCallback = object : TranslationCallback { - override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { - Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} strategy=${m.recordingStrategy}") - } - - override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { - // STRATEGY_CUSTOM_RECORDING 下耳机不上推音频;若固件仍推帧则忽略。 - } - - override fun onError(d: BluetoothDevice, code: Int, msg: String) { - Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg") - emitError(code, msg) - } - } - - override fun start(args: Map) { - if (working) return - val sampleRate = (args["sampleRate"] as? Int) ?: 16000 - val frameMs = (args["frameDurationMs"] as? Int) ?: 20 - - // 1. 发 enterMode 让耳机进入 MODE_RECORD(0x01)状态,与 demo 对齐 - val device = btManager.connectedDevice - if (device == null) { - emitError(-200, "no connected device; cannot enter MODE_RECORD") - return - } - val impl = TranslationImpl(btManager, NoOpAITranslationApi(), device) - if (!impl.isInit) { - runCatching { impl.destroy() } - emitError(-201, "RCSP not init for ${device.address}") - return - } - val sdkMode = TranslationMode( - TranslationMode.MODE_RECORD, // mode=1 - Constants.AUDIO_TYPE_PCM, // RECORD 模式不需要 OPUS 编码,直接 PCM - 1, - sampleRate, - ).setRecordingStrategy(TranslationMode.STRATEGY_CUSTOM_RECORDING) // =0,APP 自己用手机麦采 - impl.addTranslationCallback(translationCallback) - Log.i(TAG, "[APP->SDK] enterMode addr=${device.address} mode=${sdkMode.mode}(MODE_RECORD) type=${sdkMode.audioType} sr=${sdkMode.sampleRate} strategy=${sdkMode.recordingStrategy}(CUSTOM_RECORDING)") - impl.enterMode(sdkMode, translationCallback) - translationImpl = impl - activeDevice = device - - // 2. 启动手机麦采集(STRATEGY_CUSTOM_RECORDING 语义) - mic = PhoneMicCapture( - context, sampleRate, frameMs, - onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, - onError = { code, msg -> emitError(code, msg) }, - ) - if (mic?.start() != true) { - // 起麦失败:回滚耳机端模式,避免卡在 RECORD - exitHeadsetMode() - return - } - working = true - emitLog("RecordMode start sampleRate=$sampleRate (headset in MODE_RECORD=1, phone mic active)") - } - - override fun stop() { - if (!working) return - mic?.stop() - mic = null - exitHeadsetMode() - working = false - emitLog("RecordMode stop (headset back to MODE_IDLE=0)") - } - - /** 让耳机 exitMode 回到 IDLE,并释放 TranslationImpl。幂等。 */ - private fun exitHeadsetMode() { - val impl = translationImpl ?: return - val addr = activeDevice?.address - runCatching { - Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_RECORD}") - impl.exitMode(object : OnRcspActionCallback { - override fun onSuccess(d: BluetoothDevice?, t: Int?) { - Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t") - } - override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) { - Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${err?.code} msg=${err?.message}") - } - }) - } - runCatching { impl.removeTranslationCallback(translationCallback) } - runCatching { impl.destroy() } - translationImpl = null - activeDevice = null - } - - override fun onTranslatedAudio( - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean { - if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false - // RECORD 模式下耳机不参与回送;如果宿主想播给用户听,自己用 AudioTrack 播即可。 - emitLog("recv tts pcm=${pcm.size}B final=$isFinal (host-side playback)") - return true - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt deleted file mode 100644 index 480ac32f..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.jielihome.jielihome.feature.translation.mode - -import android.bluetooth.BluetoothDevice -import android.content.Context -import android.util.Log -import com.jieli.bluetooth.bean.translation.AudioData -import com.jieli.bluetooth.bean.translation.TranslationMode -import com.jieli.bluetooth.constant.Constants -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl -import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback -import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback -import com.jielihome.jielihome.audio.PhoneMicCapture -import com.jielihome.jielihome.feature.translation.AudioFormat -import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler -import com.jielihome.jielihome.feature.translation.TranslationAudioBridge -import com.jielihome.jielihome.feature.translation.TranslationModeIds -import com.jielihome.jielihome.feature.translation.TranslationStreams -import com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi - -/** - * MODE_RECORDING_TRANSLATION —— 流式录音翻译(mode=2)。等同于 RecordMode 的实时版, - * 差别只在语义:调用方实现实时 STT/翻译流水线、不缓存整段录音再发。 - * - * # 与 JL_HomeSdkDemo 对齐 - * Demo 会调 `TranslationImpl.enterMode(MODE_RECORDING_TRANSLATION=2, ...)` 让耳机进入状态机。 - * 之前本类仅启动手机麦、**未发 enterMode**,耳机侧始终停在 MODE_IDLE(0),固件不认。 - * - * 现在按 demo: - * 1. 构造 TranslationMode(mode=2, PCM, ch=1, sampleRate) - * + setRecordingStrategy(STRATEGY_CUSTOM_RECORDING=0) - * 2. TranslationImpl.enterMode 通知耳机进入录音翻译模式 - * 3. APP 用手机麦采音并 pushFrame(与 demo 一致) - * 4. stop() 调 exitMode 让耳机回到 IDLE - */ -class RecordingTranslationModeHandler( - private val context: Context, - btManager: JL_BluetoothManager, - bridge: TranslationAudioBridge, -) : BaseTranslationModeHandler(btManager, bridge) { - - companion object { - private const val TAG = "RecTransModeHandler" - } - - override val modeId = TranslationModeIds.MODE_RECORDING_TRANSLATION - override val inputStreams = listOf(TranslationStreams.IN_MIC) - override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) - - private var mic: PhoneMicCapture? = null - @Volatile private var translationImpl: TranslationImpl? = null - @Volatile private var activeDevice: BluetoothDevice? = null - - private val translationCallback = object : TranslationCallback { - override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { - Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} strategy=${m.recordingStrategy}") - } - - override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { - // STRATEGY_CUSTOM_RECORDING 下耳机不上推音频;若固件仍推帧则忽略。 - } - - override fun onError(d: BluetoothDevice, code: Int, msg: String) { - Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg") - emitError(code, msg) - } - } - - override fun start(args: Map) { - if (working) return - val sampleRate = (args["sampleRate"] as? Int) ?: 16000 - - // 1. 发 enterMode 让耳机进入 MODE_RECORDING_TRANSLATION(0x02)状态,与 demo 对齐 - val device = btManager.connectedDevice - if (device == null) { - emitError(-200, "no connected device; cannot enter MODE_RECORDING_TRANSLATION") - return - } - val impl = TranslationImpl(btManager, NoOpAITranslationApi(), device) - if (!impl.isInit) { - runCatching { impl.destroy() } - emitError(-201, "RCSP not init for ${device.address}") - return - } - val sdkMode = TranslationMode( - TranslationMode.MODE_RECORDING_TRANSLATION, // mode=2 - Constants.AUDIO_TYPE_PCM, - 1, - sampleRate, - ).setRecordingStrategy(TranslationMode.STRATEGY_CUSTOM_RECORDING) - impl.addTranslationCallback(translationCallback) - Log.i(TAG, "[APP->SDK] enterMode addr=${device.address} mode=${sdkMode.mode}(MODE_RECORDING_TRANSLATION) type=${sdkMode.audioType} sr=${sdkMode.sampleRate} strategy=${sdkMode.recordingStrategy}(CUSTOM_RECORDING)") - impl.enterMode(sdkMode, translationCallback) - translationImpl = impl - activeDevice = device - - // 2. 启动手机麦采集 - mic = PhoneMicCapture( - context, sampleRate, - onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, - onError = { code, msg -> emitError(code, msg) }, - ) - if (mic?.start() != true) { - exitHeadsetMode() - return - } - working = true - emitLog("RecordingTranslation start sampleRate=$sampleRate (headset in MODE_RECORDING_TRANSLATION=2)") - } - - override fun stop() { - if (!working) return - mic?.stop() - mic = null - exitHeadsetMode() - working = false - emitLog("RecordingTranslation stop (headset back to MODE_IDLE=0)") - } - - /** 让耳机 exitMode 回到 IDLE,并释放 TranslationImpl。幂等。 */ - private fun exitHeadsetMode() { - val impl = translationImpl ?: return - val addr = activeDevice?.address - runCatching { - Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_RECORDING_TRANSLATION}") - impl.exitMode(object : OnRcspActionCallback { - override fun onSuccess(d: BluetoothDevice?, t: Int?) { - Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t") - } - override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) { - Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${err?.code} msg=${err?.message}") - } - }) - } - runCatching { impl.removeTranslationCallback(translationCallback) } - runCatching { impl.destroy() } - translationImpl = null - activeDevice = null - } - - override fun onTranslatedAudio( - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean { - if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false - emitLog("recv tts ${pcm.size}B final=$isFinal") - return true - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt deleted file mode 100644 index 2bafa2e3..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.jielihome.jielihome.feature.translation.mode - -import android.content.Context -import com.jieli.bluetooth.bean.translation.AudioData -import com.jieli.bluetooth.bean.translation.TranslationMode -import com.jieli.bluetooth.constant.Constants -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jielihome.jielihome.audio.PcmKit -import com.jielihome.jielihome.feature.ConnectFeature -import com.jielihome.jielihome.feature.translation.AudioFormat -import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler -import com.jielihome.jielihome.feature.translation.TranslationAudioBridge -import com.jielihome.jielihome.feature.translation.TranslationModeIds -import com.jielihome.jielihome.feature.translation.TranslationStreams -import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime -import java.io.File - -/** - * MODE_CALL_TRANSLATION_WITH_STEREO —— 立体声通话翻译。 - * SDK 上来一路立体声 PCM(左=上行,右=下行),软件分离后等价于普通通话翻译两路。 - */ -class StereoCallTranslationModeHandler( - private val context: Context, - btManager: JL_BluetoothManager, - private val connectFeature: ConnectFeature, - bridge: TranslationAudioBridge, -) : BaseTranslationModeHandler(btManager, bridge) { - - override val modeId = TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO - override val inputStreams = listOf(TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK) - override val outputStreams = listOf(TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK) - - private var runtime: RcspTranslationRuntime? = null - - override fun start(args: Map) { - if (working) return - val address = args["address"] as? String - ?: connectFeature.connectedDevice()?.address - val device = address?.let { connectFeature.deviceByAddress(it) } - ?: connectFeature.connectedDevice() - if (device == null) { - emitError(-200, "no connected device") - return - } - val sampleRate = (args["sampleRate"] as? Int) ?: 16000 - val audioType = parseAudioType(args["audioType"]) ?: Constants.AUDIO_TYPE_OPUS - // 与 demo 对齐:默认 ALWAYS_RECORDING,进 mode 即持续上推 PCM; - // AUTO 会在没有真实通话事件时静默不出帧。 - val strategy = parseStrategy(args["strategy"]) - ?: TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING - val sdkMode = TranslationMode( - TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO, - audioType, - 2, // 立体声 - sampleRate, - ).setRecordingStrategy(strategy) - - val rt = RcspTranslationRuntime( - btManager = btManager, - device = device, - mode = sdkMode, - tempDir = File(context.cacheDir, "jieli_translation_tts"), - onPcm = { source, stereoPcm -> - if (source != AudioData.SOURCE_E_SCO_MIX) return@RcspTranslationRuntime - val (left, right) = PcmKit.splitStereo16(stereoPcm) - val fmt = AudioFormat(sampleRate, 1, 16) - pushFrame(TranslationStreams.IN_UPLINK, left, fmt) - pushFrame(TranslationStreams.IN_DOWNLINK, right, fmt) - }, - onError = { code, msg -> emitError(code, msg) } - ) - rt.start().fold( - { - runtime = rt - working = true - emitLog("StereoCallTranslation start device=${device.address} audioType=$audioType sampleRate=$sampleRate") - }, - { emitError(-201, it.message); rt.stop() }, - ) - } - - private fun parseStrategy(raw: Any?): Int? = when (raw) { - null -> null - is Int -> raw - is Number -> raw.toInt() - is String -> when (raw.lowercase()) { - "custom" -> TranslationMode.STRATEGY_CUSTOM_RECORDING - "always" -> TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING - "auto" -> TranslationMode.STRATEGY_DEVICE_AUTO_RECORDING - else -> raw.toIntOrNull() - } - else -> null - } - - private fun parseAudioType(raw: Any?): Int? = when (raw) { - null -> null - is Int -> raw - is Number -> raw.toInt() - is String -> when (raw.lowercase()) { - "opus" -> Constants.AUDIO_TYPE_OPUS - "pcm" -> Constants.AUDIO_TYPE_PCM - "speex" -> Constants.AUDIO_TYPE_SPEEX - "msbc", "m_sbc" -> Constants.AUDIO_TYPE_M_SBC - "jla_v2", "jlav2" -> Constants.AUDIO_TYPE_JLA_V2 - else -> raw.toIntOrNull() - } - else -> null - } - - override fun stop() { - if (!working) return - runtime?.stop() - runtime = null - working = false - emitLog("StereoCallTranslation stop") - } - - override fun onTranslatedAudio( - outputStreamId: String, - pcm: ByteArray, - format: AudioFormat, - isFinal: Boolean, - ): Boolean { - if (outputStreamId != TranslationStreams.OUT_UPLINK && - outputStreamId != TranslationStreams.OUT_DOWNLINK) return false - return runtime?.feedTtsPcm(outputStreamId, pcm, isFinal) ?: false - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/JieliAITranslationBridge.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/JieliAITranslationBridge.kt deleted file mode 100644 index cfb04d34..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/JieliAITranslationBridge.kt +++ /dev/null @@ -1,281 +0,0 @@ -package com.jielihome.jielihome.feature.translation.runtime - -import android.util.Log -import com.jieli.bluetooth.bean.translation.AudioData -import com.jieli.bluetooth.bean.translation.TranslationMode -import com.jieli.bluetooth.bean.translation.TranslationResult -import com.jieli.bluetooth.constant.Constants -import com.jieli.bluetooth.interfaces.rcsp.translation.AITranslationCallback -import com.jieli.bluetooth.interfaces.rcsp.translation.IAITranslationApi -import com.jieli.jl_audio_decode.callback.OnStateCallback -import com.jieli.jl_audio_decode.opus.OpusManager -import com.jielihome.jielihome.audio.OpusStreamDecoder -import com.jielihome.jielihome.feature.translation.TranslationStreams -import java.io.ByteArrayOutputStream -import java.io.File -import java.util.concurrent.atomic.AtomicLong - -/** - * 杰理 SDK 的 [IAITranslationApi] 实现 —— call translation 路径专用的「上下行接力桥」。 - * - * # 为什么要有这个 bridge - * 早先的实现(旧 [RcspTranslationRuntime])走的是"自己手写 writeAudioData 队列"的方案: - * 1. 用 [com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback.onReceiveAudioData] - * 拿上行 OPUS,自己解码; - * 2. 把外部翻译服务回送的 PCM 周期切片 → OPUS 编码 → 用自己的 WriteScheduler 串行调 - * [com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl.writeAudioData] 推回耳机。 - * - * 实测踩到的两个坑: - * - 周期切片(每 1s)→ 多段独立 OPUS → 耳机端解码器频繁 reset → 杂音 / 断续; - * - 自己维护 in-flight=1 的写队列,溢出丢最老 → utterance 中段被截掉。 - * - * 官方 demo 的真正用法([com.jieli.bt.sdk.tool.translation.AITranslationImpl]):实现 - * [IAITranslationApi],把整段 utterance 编一次 OPUS,包成 [AudioData] 塞进 - * [TranslationResult.translationTTSData] 然后调 [AITranslationCallback.onTranslateResult], - * **SDK 内部** 接管 cmd=52 切包 / 发送时序 / 缓冲水位。 - * - * # 上下行职责 - * - **上行**(headset → app):SDK 主动调 [writeAudio],audioData.type = OPUS / PCM; - * 按 [AudioData.source] 分流到 up/down/stereo decoder,解码后通过 [onPcm] 抛出去。 - * - **下行**(app → headset):[feedTtsPcm] 累积外部翻译服务回送的 PCM,**纯 isFinal 驱动**: - * `isFinal=true` 时把累积 PCM 编成一段 OPUS,组装 [TranslationResult] 丢给 SDK。 - * 不再有周期 flush。如果上层一直不发 isFinal,缓冲到 [PCM_BUFFER_HARD_LIMIT_BYTES] - * 兜底强制 flush,避免内存爆。 - * - * # 与 [NoOpAITranslationApi] 的区别 - * - [NoOpAITranslationApi]:纯录音通路(JieliAssistantPort / JieliDeviceRecordPort 等) - * 只想拿原始 PCM,不希望 SDK 触发 AI 流程。 - * - 本类:通话翻译需要"上行解码 + 下行 SDK 接管 TTS 注入",要走 SDK 的标准 AI hook。 - * - * # 线程 - * - SDK 回调线程不固定;解码器 / 编码器调用都不假设线程。 - * - [feedTtsPcm] 可被任意线程调用,per-leg 缓冲用 [bufferLock] 互斥。 - * - OPUS 文件编码异步([OpusManager.encodeFile] 内部线程),完成后回到 SDK callback。 - */ -class JieliAITranslationBridge( - private val mode: TranslationMode, - private val tempDir: File, - /** 解码后的上行 PCM 出口;source 为 SDK 原值(SOURCE_E_SCO_UP_LINK / DOWN_LINK / MIX)。 */ - private val onPcm: (source: Int, pcm: ByteArray) -> Unit, - /** 解码 / 编码失败的统一出口。 */ - private val onError: (code: Int, msg: String?) -> Unit, - private val opusPacketSize: Int = if (mode.channel == 2) 80 else 200, -) : IAITranslationApi { - - companion object { - private const val TAG = "JieliAITranslationBridge" - /** 单段 utterance PCM 上限:≈ 60s 16k mono 16bit = 1.92MB。超限强制 flush 兜底。 */ - private const val PCM_BUFFER_HARD_LIMIT_BYTES = 2 * 1024 * 1024 - } - - private val isPcmMode = mode.audioType == Constants.AUDIO_TYPE_PCM - private val sampleRateHz = mode.sampleRate.takeIf { it > 0 } ?: 16000 - - @Volatile private var sdkCallback: AITranslationCallback? = null - @Volatile private var working = false - - /** 上行 OPUS 解码器:通话翻译有 up/down 两路单声道,stereo 模式只一路双声道。 */ - private val upDecoder: OpusStreamDecoder? = if (isPcmMode) null else OpusStreamDecoder( - channel = 1, - packetSize = opusPacketSize, - sampleRate = sampleRateHz, - onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_UP_LINK, pcm) }, - onError = { c, m -> onError(c, "upDecoder: $m") }, - ) - private val downDecoder: OpusStreamDecoder? = - if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION) OpusStreamDecoder( - channel = 1, - packetSize = opusPacketSize, - sampleRate = sampleRateHz, - onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_DOWN_LINK, pcm) }, - onError = { c, m -> onError(c, "downDecoder: $m") }, - ) else null - private val stereoDecoder: OpusStreamDecoder? = - if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO) OpusStreamDecoder( - channel = 2, packetSize = 80, - sampleRate = sampleRateHz, - onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_MIX, pcm) }, - onError = { c, m -> onError(c, "stereoDecoder: $m") }, - ) else null - - /** Per-leg PCM 缓冲:key = outputStreamId(OUT_UPLINK / OUT_DOWNLINK / OUT_SPEAKER)。 */ - private val pcmBuffers = mutableMapOf() - private val bufferLock = Any() - private val encodeSeq = AtomicLong(0) - - /** 调试统计 */ - @Volatile private var rxFirstLogged = false - private val rxFirstPerSource = java.util.concurrent.ConcurrentHashMap() - - // ─── IAITranslationApi 实现 ─────────────────────────────────────────── - - override fun isWorking(): Boolean = working - - override fun startTranslating(mode: TranslationMode, callback: AITranslationCallback) { - Log.i(TAG, "[SDK->APP] startTranslating mode=${mode.mode} type=${mode.audioType} sr=${mode.sampleRate} ch=${mode.channel}") - sdkCallback = callback - if (!tempDir.exists()) tempDir.mkdirs() - upDecoder?.start() - downDecoder?.start() - stereoDecoder?.start() - working = true - callback.onStart() - } - - override fun stopTranslating() { - Log.i(TAG, "[SDK->APP] stopTranslating") - working = false - runCatching { upDecoder?.stop() } - runCatching { downDecoder?.stop() } - runCatching { stereoDecoder?.stop() } - // 兜底:剩余 buffer 直接丢,不补发 onTranslateResult(utterance 已被打断)。 - synchronized(bufferLock) { - pcmBuffers.values.forEach { it.reset() } - pcmBuffers.clear() - } - sdkCallback?.onStop(0, "stopTranslating") - sdkCallback = null - } - - override fun writeAudio(data: AudioData) { - if (!working) return - if (data.type != mode.audioType) { - // SDK 极少数情况下会推与 mode.audioType 不一致的帧(例如握手期残留);忽略不报错。 - return - } - val payload = data.audioData ?: return - if (payload.isEmpty()) return - - // 首帧(总) - if (!rxFirstLogged) { - rxFirstLogged = true - Log.i(TAG, "[SDK->APP] writeAudio FIRST source=${data.source} type=${data.type} size=${payload.size}") - } - // 首帧(按 source) - if (rxFirstPerSource.putIfAbsent(data.source, true) == null) { - Log.i(TAG, "[SDK->APP] writeAudio FIRST source=${data.source} size=${payload.size}") - } - - if (isPcmMode) { - onPcm(data.source, payload) - return - } - when (data.source) { - AudioData.SOURCE_E_SCO_UP_LINK -> upDecoder?.feedEncoded(payload) - AudioData.SOURCE_E_SCO_DOWN_LINK -> downDecoder?.feedEncoded(payload) - AudioData.SOURCE_E_SCO_MIX -> stereoDecoder?.feedEncoded(payload) - else -> Log.w(TAG, "[SDK->APP] writeAudio unknown source=${data.source} size=${payload.size}") - } - } - - // ─── 下行:外部翻译服务的 TTS PCM 接力 ───────────────────────────────── - - /** - * 把外部翻译服务回送的 TTS PCM 接力给 SDK。 - * - * 策略: - * - PCM 模式:每次调用作为一段 [AudioData] 直接交给 SDK。 - * - OPUS 模式:累积 per-leg buffer;`isFinal=true` 或缓冲到 [PCM_BUFFER_HARD_LIMIT_BYTES] - * 时整段编码 → 通过 [AITranslationCallback.onTranslateResult] 交给 SDK,SDK 内部 - * 完成 cmd=52 切包 / 发送 / 速率控制。 - * - * @return 是否接受本帧(false 表示丢弃,例如未启动 / 缓冲爆 / SDK callback 缺失) - */ - fun feedTtsPcm(outputStreamId: String, pcm: ByteArray, isFinal: Boolean): Boolean { - if (!working) return false - val cb = sdkCallback ?: return false - val source = sourceOf(outputStreamId) - - if (isPcmMode) { - postTranslationResult(cb, source, Constants.AUDIO_TYPE_PCM, pcm) - return true - } - // OPUS 模式:累积 + isFinal/超限触发整段编码 - val pendingFlush: ByteArray? = synchronized(bufferLock) { - val buf = pcmBuffers.getOrPut(outputStreamId) { ByteArrayOutputStream() } - if (pcm.isNotEmpty()) { - if (buf.size() + pcm.size > PCM_BUFFER_HARD_LIMIT_BYTES) { - Log.w(TAG, "feedTtsPcm leg=$outputStreamId buffer hit hard limit ${PCM_BUFFER_HARD_LIMIT_BYTES}, force flush") - val out = buf.toByteArray() + pcm - buf.reset() - return@synchronized out - } - buf.write(pcm) - } - if (isFinal) { - val out = buf.toByteArray() - buf.reset() - if (out.isEmpty()) null else out - } else null - } - if (pendingFlush != null) { - encodeAndDeliverAsync(cb, source, outputStreamId, pendingFlush) - } - return true - } - - private fun postTranslationResult( - cb: AITranslationCallback, - source: Int, - audioType: Int, - bytes: ByteArray, - ) { - val result = TranslationResult().also { - it.translationTTSData = AudioData(source, audioType, bytes) - } - runCatching { cb.onTranslateResult(result) }.onFailure { - Log.w(TAG, "onTranslateResult threw: ${it.message}") - } - } - - private fun encodeAndDeliverAsync( - cb: AITranslationCallback, - source: Int, - leg: String, - pcmBytes: ByteArray, - ) { - val seq = encodeSeq.incrementAndGet() - val pcmFile = File(tempDir, "tts_${source}_$seq.pcm") - val opusFile = File(tempDir, "tts_${source}_$seq.opus") - try { - pcmFile.writeBytes(pcmBytes) - } catch (e: Throwable) { - Log.w(TAG, "encodeAndDeliver leg=$leg seq=$seq write pcm failed: ${e.message}") - return - } - // OpusManager 默认 OpusOption(带 head),与 demo `MachineTranslation.tryToTTS` 一致; - // SDK 接收端按"完整带 head 的 OPUS 段"解析,所以一次 onTranslateResult = 一段 utterance。 - val encoder = OpusManager() - encoder.encodeFile(pcmFile.absolutePath, opusFile.absolutePath, object : OnStateCallback { - override fun onStart() {} - override fun onComplete(path: String?) { - try { - val opusBytes = if (opusFile.exists()) opusFile.readBytes() else null - if (opusBytes == null || opusBytes.isEmpty()) { - Log.w(TAG, "encodeAndDeliver leg=$leg seq=$seq: empty opus output") - } else { - Log.d(TAG, "encodeAndDeliver leg=$leg seq=$seq pcm=${pcmBytes.size}B opus=${opusBytes.size}B → onTranslateResult") - postTranslationResult(cb, source, Constants.AUDIO_TYPE_OPUS, opusBytes) - } - } finally { - runCatching { encoder.release() } - runCatching { pcmFile.delete() } - runCatching { opusFile.delete() } - } - } - override fun onError(code: Int, message: String?) { - Log.w(TAG, "encodeAndDeliver leg=$leg seq=$seq encodeFile error code=$code msg=$message") - runCatching { encoder.release() } - runCatching { pcmFile.delete() } - runCatching { opusFile.delete() } - onError(code, "encodeFile $leg: $message") - } - }) - } - - private fun sourceOf(outputStreamId: String): Int = when (outputStreamId) { - TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK - TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK - else -> AudioData.SOURCE_PHONE_MIC - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/NoOpAITranslationApi.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/NoOpAITranslationApi.kt deleted file mode 100644 index 8cb7eee7..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/NoOpAITranslationApi.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.jielihome.jielihome.feature.translation.runtime - -import com.jieli.bluetooth.bean.translation.AudioData -import com.jieli.bluetooth.bean.translation.TranslationMode -import com.jieli.bluetooth.interfaces.rcsp.translation.AITranslationCallback -import com.jieli.bluetooth.interfaces.rcsp.translation.IAITranslationApi - -/** - * SDK 的 `TranslationImpl` 构造函数要求一个 [IAITranslationApi]。本类是给**纯录音通路** - * 占位用的空实现:上层只想拿原始 OPUS / PCM 流(自己挂 `TranslationCallback.onReceiveAudioData`), - * 不希望 SDK 触发任何 AI 翻译流程 / TTS 注入。 - * - * # 谁在用 - * - [com.jielihome.jielihome.feature.assistant.JieliAssistantPort](AI 助理上行通路) - * - [com.jielihome.jielihome.feature.record.JieliDeviceRecordPort](通话录音 MODE_CALL_RECORD) - * - [com.jielihome.jielihome.feature.translation.mode.RecordModeHandler](MODE_RECORD) - * - [com.jielihome.jielihome.feature.translation.mode.RecordingTranslationModeHandler](MODE_RECORDING_TRANSLATION) - * - [com.jielihome.jielihome.feature.translation.TranslationFeature](兜底 TranslationImpl 实例) - * - * # 谁**不在**用了 - * Call translation(mode 3 / 6)由 [JieliAITranslationBridge] 接管——那条路要的是 - * "SDK 接管 TTS 切包注入",必须给真正的 IAITranslationApi 实现,避免自己写 writeAudioData - * 队列触发的"耳机端解码器频繁 reset → 杂音 / 断续"。 - */ -internal class NoOpAITranslationApi : IAITranslationApi { - override fun isWorking(): Boolean = false - override fun startTranslating(mode: TranslationMode, cb: AITranslationCallback) { /* no-op */ } - override fun stopTranslating() { /* no-op */ } - override fun writeAudio(data: AudioData) { /* no-op */ } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt deleted file mode 100644 index aedd7bdc..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.jielihome.jielihome.feature.translation.runtime - -import android.bluetooth.BluetoothDevice -import android.util.Log -import com.jieli.bluetooth.bean.translation.AudioData -import com.jieli.bluetooth.bean.translation.TranslationMode -import com.jieli.bluetooth.constant.Constants -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl -import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback -import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback -import java.io.File - -/** - * RCSP 翻译模式运行时(call translation 路径专用)。 - * - * # 角色 - * 把"进入/退出 SDK 翻译模式"的 RCSP 状态机生命周期,跟「上下行音频接力」绑在一起。 - * 真正的音频路由由 [JieliAITranslationBridge] 这个 [com.jieli.bluetooth.interfaces.rcsp.translation.IAITranslationApi] - * 实现负责(见它头注释)。 - * - * # 与旧实现的区别 - * 之前的 runtime 大约 500 行:自己用 [TranslationCallback.onReceiveAudioData] 拿上行 OPUS、 - * 自己 OPUS 编码 TTS、自己维护 `WriteScheduler` 串行队列调 [TranslationImpl.writeAudioData]。 - * 实测耳机端解码器频繁 reset 导致杂音 / 断续。 - * - * 现在按官方 demo `AITranslationImpl` 的标准做法: - * 1. 构造 [TranslationImpl] 时传入真正的 [JieliAITranslationBridge],SDK 通过它把上行 - * OPUS 推给我们解码; - * 2. 下行 TTS PCM 累积成段后通过 [AITranslationCallback.onTranslateResult] 交给 SDK, - * 由 SDK 内部完成 cmd=52 切包 / 写时序 / 缓冲水位([TranslationImpl.PushDataWrapper])。 - * - * # 上下行 source 字段语义 - * - 通话翻译给「对端听」 → AudioData.source = SOURCE_E_SCO_UP_LINK - * - 通话翻译给「本机听」 → AudioData.source = SOURCE_E_SCO_DOWN_LINK - * - 录音/音视频/面对面 → AudioData.source = SOURCE_PHONE_MIC(SDK 按 mode 自分发) - */ -class RcspTranslationRuntime( - private val btManager: JL_BluetoothManager, - private val device: BluetoothDevice, - private val mode: TranslationMode, - private val tempDir: File, - /** 解码(或直传 PCM)后的音频上行;source 为 SDK 原值 */ - private val onPcm: (source: Int, pcm: ByteArray) -> Unit, - private val onError: (code: Int, msg: String?) -> Unit, - /** OPUS 解码 packetSize;mono call=200,stereo=80 */ - private val opusPacketSize: Int = if (mode.channel == 2) 80 else 200, -) { - - companion object { - private const val TAG = "RcspTranslationRuntime" - } - - /** SDK 的 AI hook 实现:上行解码、下行 TTS 接力都在这里。 */ - private val bridge = JieliAITranslationBridge( - mode = mode, - tempDir = tempDir, - onPcm = onPcm, - onError = onError, - opusPacketSize = opusPacketSize, - ) - - private val translationImpl = TranslationImpl(btManager, bridge, device) - private val isPcmMode = mode.audioType == Constants.AUDIO_TYPE_PCM - - /** - * 仅订阅 mode 变化事件用于日志 / 异常上报;不在这里消费音频 - * (音频走 [bridge] 的 `IAITranslationApi.writeAudio`,避免双路重复消费)。 - */ - private val translationCallback = object : TranslationCallback { - override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { - Log.i( - TAG, - "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} type=${m.audioType} " + - "sr=${m.sampleRate} ch=${m.channel} strategy=${m.recordingStrategy}" - ) - if (m.mode == TranslationMode.MODE_IDLE && mode.mode != TranslationMode.MODE_IDLE) { - onError(-1, "headset exited mode=${mode.mode} → MODE_IDLE") - } - } - - override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { - // 音频走 bridge.writeAudio;这里不消费,避免与 bridge 双路重复解码。 - } - - override fun onError(d: BluetoothDevice, code: Int, msg: String) { - Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg") - this@RcspTranslationRuntime.onError(code, msg) - } - } - - /** 启动前置校验 + 进入翻译模式。 */ - fun start(): Result { - if (!translationImpl.isInit) { - return Result.failure(IllegalStateException("RCSP not init for ${device.address}")) - } - if (!translationImpl.isSupportTranslation) { - return Result.failure(IllegalStateException("device does not support translation")) - } - if (mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO && - !translationImpl.isSupportCallTranslationWithStereo - ) { - return Result.failure(IllegalStateException("device does not support stereo call translation")) - } - if (!tempDir.exists()) tempDir.mkdirs() - - translationImpl.addTranslationCallback(translationCallback) - Log.i( - TAG, - "[APP->SDK] enterMode addr=${device.address} mode=${mode.mode} type=${mode.audioType} " + - "sr=${mode.sampleRate} ch=${mode.channel} strategy=${mode.recordingStrategy} " + - "(SDK will drive bridge.startTranslating)" - ) - translationImpl.enterMode(mode, translationCallback) - return Result.success(Unit) - } - - /** - * 把外部翻译服务回送的 PCM 接力给 SDK。 - * - * 行为完全委托给 [JieliAITranslationBridge.feedTtsPcm]: - * - PCM 模式:每次调用作为一段 [AudioData] 立即交付给 SDK; - * - OPUS 模式:累积,`isFinal=true` 时整段编码 → `onTranslateResult` 交给 SDK。 - * - * 调用方契约:**必须**在 utterance 末尾调一次 `isFinal=true`,否则音频会一直累积 - * 到 2MB 兜底上限才出。 - */ - fun feedTtsPcm(outputStreamId: String, pcm: ByteArray, isFinal: Boolean): Boolean = - bridge.feedTtsPcm(outputStreamId, pcm, isFinal) - - fun stop() { - runCatching { - Log.i(TAG, "[APP->SDK] exitMode addr=${device.address} mode=${mode.mode}") - translationImpl.exitMode(object : OnRcspActionCallback { - override fun onSuccess(d: BluetoothDevice?, t: Int?) { - Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t") - } - override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) { - Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${err?.code} msg=${err?.message}") - } - }) - } - runCatching { translationImpl.removeTranslationCallback(translationCallback) } - // bridge 由 SDK 在 exitMode 后通过 stopTranslating 自动 release decoders; - // 这里不重复调,避免双重 stop。 - runCatching { translationImpl.destroy() } - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/voice/SpeechFeature.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/voice/SpeechFeature.kt deleted file mode 100644 index 95ff7e4a..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/voice/SpeechFeature.kt +++ /dev/null @@ -1,196 +0,0 @@ -package com.jielihome.jielihome.feature.voice - -import android.bluetooth.BluetoothDevice -import com.jieli.bluetooth.bean.base.BaseError -import com.jieli.bluetooth.bean.record.RecordParam -import com.jieli.bluetooth.bean.record.RecordState -import com.jieli.bluetooth.impl.JL_BluetoothManager -import com.jieli.bluetooth.impl.rcsp.record.RecordOpImpl -import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback -import com.jieli.bluetooth.interfaces.rcsp.record.OnRecordStateCallback -import com.jielihome.jielihome.audio.OpusStreamDecoder -import com.jielihome.jielihome.bridge.EventDispatcher -import com.jielihome.jielihome.feature.ConnectFeature - -/** - * 语音助手 / 唤醒功能。 - * - * # 工作模型(重要) - * 唤醒是「**耳机端主动检测 + 主动推**」模式。零等待: - * - * 1. 插件初始化时立即注册一个常驻 [OnRecordStateCallback]([attach]) - * 2. 耳机本地检测到唤醒词 / 用户按下语音键 - * → SDK 直接回调 onStateChange(state=START),**不需要 APP 先发任何指令** - * → 紧接着持续回调 state=WORKING + voiceData(PCM 或 OPUS 帧) - * → 通话结束/取消时回调 state=IDLE - * 3. 插件解码后通过 EventDispatcher 推三类事件给宿主: - * `speechStart` / `speechAudio` / `speechEnd` - * - * # 主动控制(可选) - * - [start]:APP 主动触发语音助手(用户点 UI 的"按住说话"按钮场景) - * - [stop]:APP 主动结束(用户松手 / 超时取消) - * - 这两个调用内部封装了 SDK 的 StartSpeechCmd/StopSpeechCmd。 - * 大部分场景下你不需要调它们——耳机会自动管理。 - */ -class SpeechFeature( - private val btManager: JL_BluetoothManager, - private val connectFeature: ConnectFeature, - private val dispatcher: EventDispatcher, -) { - private val recordOp: RecordOpImpl = RecordOpImpl.getInstance(btManager) - - @Volatile - private var decoder: OpusStreamDecoder? = null - - @Volatile - private var currentParam: RecordParam? = null - - private val recordCallback = OnRecordStateCallback { device, state -> - if (device == null || state == null) return@OnRecordStateCallback - when (state.state) { - RecordState.RECORD_STATE_START -> onStart(device, state) - RecordState.RECORD_STATE_WORKING -> onWorking(device, state) - RecordState.RECORD_STATE_IDLE -> onEnd(device, state) - } - } - - fun attach() { - recordOp.addOnRecordStateCallback(recordCallback) - } - - fun detach() { - recordOp.removeOnRecordStateCallback(recordCallback) - releaseDecoder() - } - - fun isRecording(address: String?): Boolean { - val d = address?.let { connectFeature.deviceByAddress(it) } - ?: connectFeature.connectedDevice() ?: return false - return recordOp.isRecording(d) - } - - /** - * APP 主动启动语音助手;通常无需调用(耳机会自动推)。 - * @param voiceType RecordParam.VOICE_TYPE_PCM / SPEEX / OPUS - * @param sampleRate RecordParam.SAMPLE_RATE_8K / 16K(注意取值是 8/16,不是 8000/16000) - * @param vadWay RecordParam.VAD_WAY_DEVICE / SDK - */ - fun start( - address: String?, - voiceType: Int = RecordParam.VOICE_TYPE_OPUS, - sampleRate: Int = RecordParam.SAMPLE_RATE_16K, - vadWay: Int = RecordParam.VAD_WAY_DEVICE, - onResult: (ok: Boolean, msg: String?) -> Unit = { _, _ -> }, - ) { - val device = address?.let { connectFeature.deviceByAddress(it) } - ?: connectFeature.connectedDevice() - ?: return onResult(false, "no connected device") - val param = RecordParam(voiceType, sampleRate, vadWay) - recordOp.startRecord(device, param, object : OnRcspActionCallback { - override fun onSuccess(d: BluetoothDevice?, r: Boolean?) { onResult(r == true, null) } - override fun onError(d: BluetoothDevice?, e: BaseError?) { onResult(false, e?.message) } - }) - } - - fun stop( - address: String?, - reason: Int = RecordState.REASON_NORMAL, - onResult: (ok: Boolean, msg: String?) -> Unit = { _, _ -> }, - ) { - val device = address?.let { connectFeature.deviceByAddress(it) } - ?: connectFeature.connectedDevice() - ?: return onResult(false, "no connected device") - recordOp.stopRecord(device, reason, object : OnRcspActionCallback { - override fun onSuccess(d: BluetoothDevice?, r: Boolean?) { onResult(r == true, null) } - override fun onError(d: BluetoothDevice?, e: BaseError?) { onResult(false, e?.message) } - }) - } - - // ───── 内部回调处理 ───── - - private fun onStart(device: BluetoothDevice, state: RecordState) { - currentParam = state.recordParam - ensureDecoder(state.recordParam) - dispatcher.send( - mapOf( - "type" to "speechStart", - "address" to device.address, - "voiceType" to state.recordParam?.voiceType, - "sampleRate" to (state.recordParam?.sampleRate ?: 16) * 1000, // 8/16 → 8000/16000 - "vadWay" to state.recordParam?.vadWay, - "tsMs" to System.currentTimeMillis(), - ) - ) - } - - private fun onWorking(device: BluetoothDevice, state: RecordState) { - val payload = state.voiceDataBlock ?: state.voiceData ?: return - val type = (state.recordParam ?: currentParam)?.voiceType ?: RecordParam.VOICE_TYPE_OPUS - when (type) { - RecordParam.VOICE_TYPE_OPUS -> decoder?.feedEncoded(payload) ?: emitFrame(device, payload) - RecordParam.VOICE_TYPE_PCM -> emitFrame(device, payload) - RecordParam.VOICE_TYPE_SPEEX -> { - // 当前未挂 speex 解码器;原样透出,由宿主自己解(极少使用) - emitFrame(device, payload, encoding = "speex") - } - } - } - - private fun onEnd(device: BluetoothDevice, state: RecordState) { - releaseDecoder() - dispatcher.send( - mapOf( - "type" to "speechEnd", - "address" to device.address, - "reason" to state.reason, - "message" to state.message, - "tsMs" to System.currentTimeMillis(), - ) - ) - currentParam = null - } - - private fun ensureDecoder(param: RecordParam?) { - if (decoder != null) return - if (param?.voiceType != RecordParam.VOICE_TYPE_OPUS) return - val srKhz = param.sampleRate.takeIf { it > 0 } ?: RecordParam.SAMPLE_RATE_16K - val sampleRateHz = srKhz * 1000 - val packetSize = if (srKhz == RecordParam.SAMPLE_RATE_8K) 50 else 200 - decoder = OpusStreamDecoder( - channel = 1, - packetSize = packetSize, - sampleRate = sampleRateHz, - onPcm = { pcm -> emitFrame(connectFeature.connectedDevice(), pcm) }, - onError = { c, m -> - dispatcher.send( - mapOf("type" to "speechError", "code" to c, "message" to "decoder: $m") - ) - } - ).also { it.start() } - } - - private fun releaseDecoder() { - runCatching { decoder?.stop() } - decoder = null - } - - private fun emitFrame( - device: BluetoothDevice?, - pcm: ByteArray, - encoding: String = "pcm16", - ) { - val sr = (currentParam?.sampleRate ?: RecordParam.SAMPLE_RATE_16K) * 1000 - dispatcher.send( - mapOf( - "type" to "speechAudio", - "address" to device?.address, - "encoding" to encoding, - "sampleRate" to sr, - "channels" to 1, - "bitsPerSample" to 16, - "tsMs" to System.currentTimeMillis(), - "pcm" to pcm, - ) - ) - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt deleted file mode 100644 index ed334241..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt +++ /dev/null @@ -1,358 +0,0 @@ -package com.jielihome.jielihome.integration - -import android.content.Context -import android.util.Log -import com.aiagent.device_plugin_interface.DeviceCapability -import com.aiagent.device_plugin_interface.DeviceConnectOptions -import com.aiagent.device_plugin_interface.DeviceConnectionState -import com.aiagent.device_plugin_interface.DeviceErrorCode -import com.aiagent.device_plugin_interface.DeviceException -import com.aiagent.device_plugin_interface.DevicePluginConfig -import com.aiagent.device_plugin_interface.DevicePluginEvent -import com.aiagent.device_plugin_interface.DevicePluginEventType -import com.aiagent.device_plugin_interface.DeviceScanFilter -import com.aiagent.device_plugin_interface.DiscoveredDevice -import com.aiagent.device_plugin_interface.NativeDevicePlugin -import com.aiagent.device_plugin_interface.NativeDevicePluginRegistry -import com.aiagent.device_plugin_interface.NativeDeviceSession -import com.jielihome.jielihome.api.JieliEventAdapter -import com.jielihome.jielihome.api.JieliEventListener -import com.jielihome.jielihome.core.JieliHomeServer -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit - -/** - * 杰理 [NativeDevicePlugin] 实现 —— 把 [JieliHomeServer] 包成 vendor-agnostic 接口, - * 由 device_manager native ([com.aiagent.device_manager.DefaultNativeDeviceManager]) - * 通过 [NativeDevicePluginRegistry] 创建并管理。 - * - * 注册入口:[register],由 `JielihomePlugin.onAttachedToEngine` 调用一次。 - * - * 注意:本类**不**关闭 [JieliHomeServer] —— server 是进程级单例,可能被 Dart 直连 - * 路径 / translate_server / OTA 等并发使用,本类的 [dispose] 只解订阅事件 + 切断 - * 当前 session,server 自身保留。 - */ -class JieliNativeDevicePlugin( - private val context: Context, -) : NativeDevicePlugin { - - companion object { - private const val TAG = "JieliNativeDevicePlugin" - const val VENDOR_KEY = "jieli" - const val DISPLAY_NAME = "JieLi (杰理)" - - val CAPABILITIES: Set = setOf( - DeviceCapability.SCAN, - DeviceCapability.CONNECT, - DeviceCapability.BOND, - DeviceCapability.BATTERY, - DeviceCapability.CUSTOM_COMMAND, - DeviceCapability.WAKE_WORD, - DeviceCapability.OTA, - DeviceCapability.ON_DEVICE_CALL_TRANSLATION, - DeviceCapability.ON_DEVICE_FACE_TO_FACE_TRANSLATION, - DeviceCapability.ON_DEVICE_RECORDING_TRANSLATION, - ) - - /** - * 把杰理工厂注册到 [NativeDevicePluginRegistry],由 [JielihomePlugin] - * 在 onAttachedToEngine 调一次(FlutterPlugin 生命周期保证它早于 device_manager - * 的 listVendors / useVendor 调用)。 - */ - fun register(appContext: Context) { - NativeDevicePluginRegistry.register( - vendorKey = VENDOR_KEY, - displayName = DISPLAY_NAME, - capabilities = CAPABILITIES, - ) { JieliNativeDevicePlugin(appContext) } - } - } - - override val vendorKey: String = VENDOR_KEY - override val displayName: String = DISPLAY_NAME - override val capabilities: Set = CAPABILITIES - - private val server: JieliHomeServer = JieliHomeServer.get() - - @Volatile private var _initialized = false - @Volatile private var _disposed = false - - @Volatile private var _activeSession: JieliNativeDeviceSession? = null - @Volatile private var _pendingConnect: CompletableFuture? = null - @Volatile private var _pendingConnectAddress: String? = null - - private val _events = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 64, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - - override val eventStream: Flow = _events.asSharedFlow() - - private val listener: JieliEventListener = object : JieliEventAdapter() { - override fun onAdapterStatus(enabled: Boolean, hasBle: Boolean) { - emit(DevicePluginEvent( - type = DevicePluginEventType.BLUETOOTH_STATE_CHANGED, - bluetoothEnabled = enabled, - )) - } - - override fun onScanStatus(ble: Boolean, started: Boolean) { - emit(DevicePluginEvent( - type = if (started) DevicePluginEventType.SCAN_STARTED - else DevicePluginEventType.SCAN_STOPPED, - )) - } - - override fun onDeviceFound(payload: Map) { - val meta = mutableMapOf( - "edrAddr" to payload["edrAddr"], - "deviceType" to payload["deviceType"], - "connectWay" to payload["connectWay"], - ) - // 透传 ScanFeature 解析出的广播详情,供 UI 弹窗展示。 - listOf( - "rawAdv", - "advRecords", - "advFlags", - "manufacturerCompanyId", - "manufacturerData", - "serviceUuids", - ).forEach { key -> - if (payload.containsKey(key)) meta[key] = payload[key] - } - emit(DevicePluginEvent( - type = DevicePluginEventType.DEVICE_DISCOVERED, - discovered = DiscoveredDevice( - id = (payload["address"] as? String) ?: "", - name = (payload["name"] as? String) ?: "", - rssi = (payload["rssi"] as? Number)?.toInt(), - vendor = VENDOR_KEY, - metadata = meta, - ), - )) - } - - override fun onConnectionState(address: String, state: Int) { - val session = _activeSession ?: return - if (session.deviceId != address) return - Log.d(TAG, "onConnectionState addr=$address state=$state") - when (state) { - 2 -> session.setState(DeviceConnectionState.CONNECTING) - 1 -> session.setState(DeviceConnectionState.LINK_CONNECTED) - 0 -> { - session.setState(DeviceConnectionState.DISCONNECTED) - _activeSession = null - val pending = _pendingConnect - if (pending != null && !pending.isDone) { - pending.completeExceptionally( - DeviceException( - DeviceErrorCode.DISCONNECTED_REMOTE, - "disconnected before ready", - ) - ) - _pendingConnect = null - _pendingConnectAddress = null - } - } - } - } - - override fun onRcspInit(address: String, code: Int) { - val session = _activeSession ?: return - if (session.deviceId != address) return - Log.d(TAG, "onRcspInit addr=$address code=$code") - if (code == 0) { - session.setState(DeviceConnectionState.READY) - val pending = _pendingConnect - if (pending != null && _pendingConnectAddress == address && !pending.isDone) { - pending.complete(session) - _pendingConnect = null - _pendingConnectAddress = null - } - } else { - val pending = _pendingConnect - if (pending != null && _pendingConnectAddress == address && !pending.isDone) { - pending.completeExceptionally( - DeviceException( - DeviceErrorCode.HANDSHAKE_FAILED, - "RCSP init failed code=$code", - ) - ) - _pendingConnect = null - _pendingConnectAddress = null - } - } - } - - override fun onBattery(address: String?, level: Int?) { - val session = _activeSession ?: return - if (address != null && session.deviceId != address) return - // 由 session 自己 refresh info 拉一次最新快照 - runCatching { session.refreshInfo() } - } - - override fun onTranslationAudio(payload: Map) { - _activeSession?.emitFeature("jieli.translation.audio", payload) - } - - override fun onTranslationResult(payload: Map) { - _activeSession?.emitFeature("jieli.translation.result", payload) - } - - override fun onTranslationLog(payload: Map) { - _activeSession?.emitFeature("jieli.translation.log", payload) - } - - override fun onTranslationError(payload: Map) { - _activeSession?.emitError( - "device.feature_failed", - "translation: ${payload["code"]} ${payload["message"] ?: ""}", - ) - } - - override fun onDeviceRecordStart(payload: Map) { - _activeSession?.emitFeature("jieli.deviceRecord.start", payload) - } - - override fun onDeviceRecordAudio(payload: Map) { - _activeSession?.emitFeature("jieli.deviceRecord.audio", payload) - } - - override fun onDeviceRecordStop(payload: Map) { - _activeSession?.emitFeature("jieli.deviceRecord.stop", payload) - } - - override fun onDeviceRecordError(payload: Map) { - _activeSession?.emitError( - "device.feature_failed", - "deviceRecord: ${payload["code"]} ${payload["message"] ?: ""}", - ) - } - } - - // ─── NativeDevicePlugin 接口 ────────────────────────────────────────── - - override fun initialize(config: DevicePluginConfig) { - checkAlive() - if (_initialized) return - if (!server.initialized) { - server.initialize( - context = context, - multiDevice = (config.extra["multiDevice"] as? Boolean) ?: false, - skipNoNameDev = (config.extra["skipNoNameDev"] as? Boolean) ?: false, - enableLog = (config.extra["enableLog"] as? Boolean) ?: false, - ) - } - server.addEventListener(listener) - _initialized = true - emit(DevicePluginEvent(type = DevicePluginEventType.PLUGIN_READY)) - } - - override fun startScan(filter: DeviceScanFilter?, timeoutMs: Long?) { - requireInit() - val ms = (timeoutMs ?: 30_000L).toInt() - val nameList = filter?.nameList?.filter { it.isNotEmpty() } - ?.takeIf { it.isNotEmpty() } - ?: filter?.namePrefix?.takeIf { it.isNotEmpty() }?.let { listOf(it) } - ?: emptyList() - val uuidList = filter?.serviceUuids?.filter { it.isNotEmpty() } ?: emptyList() - val skipUnnamed = filter?.skipUnnamed ?: true - server.scanFeature.startScan(ms, nameList, uuidList, skipUnnamed).onFailure { - throw DeviceException("device.scan_failed", it.message, it) - } - } - - override fun stopScan() { - if (!_initialized) return - server.scanFeature.stopScan() - } - - override fun isScanning(): Boolean = - _initialized && server.scanFeature.isScanning() - - override fun bondedDevices(): List { - // 杰理 SDK 没暴露公开的"已配对"接口;不混入系统经典蓝牙列表。 - return emptyList() - } - - override fun connect(deviceId: String, options: DeviceConnectOptions?): NativeDeviceSession { - requireInit() - // 单设备:有旧 session 时先断 - _activeSession?.let { if (it.deviceId != deviceId) runCatching { it.disconnect() } } - - val extra = options?.extra ?: emptyMap() - val session = JieliNativeDeviceSession( - server = server, - deviceId = deviceId, - initialName = (extra["name"] as? String) ?: "", - capabilities = capabilities, - otaCacheDir = context.cacheDir, - ) - _activeSession = session - - val future = CompletableFuture() - _pendingConnect = future - _pendingConnectAddress = deviceId - - val edrAddress = extra["edrAddr"] as? String - val deviceType = (extra["deviceType"] as? Number)?.toInt() ?: 0 - val connectWay = (extra["connectWay"] as? Number)?.toInt() ?: 0 - Log.d(TAG, "connect deviceId=$deviceId edr=$edrAddress dt=$deviceType cw=$connectWay") - server.connectFeature.connect( - bleAddress = deviceId, - edrAddress = edrAddress, - deviceType = deviceType, - connectWay = connectWay, - ).onFailure { - future.completeExceptionally( - DeviceException(DeviceErrorCode.CONNECT_FAILED, it.message, it) - ) - _pendingConnect = null - _pendingConnectAddress = null - } - - val timeout = (options?.timeoutMs ?: 15_000L) - return try { - future.get(timeout, TimeUnit.MILLISECONDS) - } catch (e: java.util.concurrent.TimeoutException) { - _pendingConnect = null - _pendingConnectAddress = null - throw DeviceException(DeviceErrorCode.CONNECT_TIMEOUT, - "connect timeout after ${timeout}ms") - } catch (e: java.util.concurrent.ExecutionException) { - throw e.cause ?: e - } - } - - override val activeSession: NativeDeviceSession? - get() = _activeSession?.takeIf { it.state != DeviceConnectionState.DISCONNECTED } - - override fun dispose() { - if (_disposed) return - _disposed = true - runCatching { _activeSession?.disconnect() } - runCatching { server.removeEventListener(listener) } - _activeSession = null - emit(DevicePluginEvent(type = DevicePluginEventType.PLUGIN_DISPOSED)) - } - - // ─── 内部 ────────────────────────────────────────────────────────────── - - private fun checkAlive() { - if (_disposed) throw IllegalStateException("JieliNativeDevicePlugin disposed") - } - - private fun requireInit() { - checkAlive() - if (!_initialized) throw DeviceException(DeviceErrorCode.PLUGIN_NOT_INITIALIZED) - } - - private fun emit(e: DevicePluginEvent) { - if (!_events.tryEmit(e)) Log.w(TAG, "event buffer full; dropped ${e.type}") - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt deleted file mode 100644 index 0a5fbd1e..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt +++ /dev/null @@ -1,322 +0,0 @@ -package com.jielihome.jielihome.integration - -import android.util.Log -import com.aiagent.device_plugin_interface.DeviceAssistantPort -import com.aiagent.device_plugin_interface.DeviceCallTranslationPort -import com.aiagent.device_plugin_interface.DeviceCapability -import com.aiagent.device_plugin_interface.DeviceConnectionState -import com.aiagent.device_plugin_interface.DeviceErrorCode -import com.aiagent.device_plugin_interface.DeviceException -import com.aiagent.device_plugin_interface.DeviceFeatureEvent -import com.aiagent.device_plugin_interface.DeviceInfo -import com.aiagent.device_plugin_interface.DeviceOtaPort -import com.aiagent.device_plugin_interface.DeviceSessionEvent -import com.aiagent.device_plugin_interface.DeviceSessionEventType -import com.aiagent.device_plugin_interface.NativeDeviceSession -import com.jielihome.jielihome.core.JieliHomeServer -import com.jielihome.jielihome.feature.translation.TranslationStreams -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import java.io.File - -/** - * 杰理 [NativeDeviceSession] 实现 —— 包装 [JieliHomeServer] 的连接快照与事件。 - * - * 状态机由 [JieliNativeDevicePlugin] 在 RCSP 事件回调里推动: - * - ConnectionStateEvent.state == CONNECTING(2) → connecting - * - state == OK(1) + RcspInit 未到 → linkConnected - * - RcspInit(success=true) → ready - * - state == DISCONNECT(0) → disconnected - */ -class JieliNativeDeviceSession internal constructor( - private val server: JieliHomeServer, - override val deviceId: String, - initialName: String, - override val capabilities: Set, - private val otaCacheDir: File, -) : NativeDeviceSession { - - companion object { - private const val TAG = "JieliNativeSession" - } - - override val vendor: String = "jieli" - - @Volatile private var _state = DeviceConnectionState.CONNECTING - @Volatile private var _info = DeviceInfo( - id = deviceId, name = initialName, vendor = vendor, - ) - @Volatile private var _disposed = false - - override val state: DeviceConnectionState get() = _state - override val info: DeviceInfo get() = _info - - private val _events = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 32, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - - override val eventStream: Flow = _events.asSharedFlow() - - // ─── 由 plugin 在事件回调中调用 ───────────────────────────────────────── - - @Volatile private var _otaPort: JieliOtaPort? = null - - internal fun setState(s: DeviceConnectionState) { - if (_state == s) return - _state = s - emit(DeviceSessionEvent( - type = DeviceSessionEventType.CONNECTION_STATE_CHANGED, - deviceId = deviceId, - connectionState = s, - )) - if (s == DeviceConnectionState.DISCONNECTED) { - _disposed = true - // 端口随 session 收尾:补一帧 FAILED + 解订阅,UI 才能跳出 OTA 锁定。 - runCatching { _otaPort?.shutdown(DeviceErrorCode.DISCONNECTED_REMOTE) } - _otaPort = null - } - } - - internal fun emitFeature(key: String, data: Map) { - emit(DeviceSessionEvent( - type = DeviceSessionEventType.FEATURE, - deviceId = deviceId, - feature = DeviceFeatureEvent(key = key, data = data), - )) - } - - internal fun emitError(code: String, message: String?) { - emit(DeviceSessionEvent( - type = DeviceSessionEventType.ERROR, - deviceId = deviceId, - errorCode = code, - errorMessage = message, - )) - } - - private fun emit(e: DeviceSessionEvent) { - if (!_events.tryEmit(e)) { - Log.w(TAG, "session event buffer full; dropped ${e.type}") - } - } - - private fun applySnapshot(snapshot: Map) { - _info = _info.copyWith( - name = (snapshot["name"] as? String).takeIf { !it.isNullOrEmpty() } - ?: _info.name, - firmwareVersion = (snapshot["versionName"] as? String) - ?: _info.firmwareVersion, - batteryPercent = (snapshot["battery"] as? Number)?.toInt(), - metadata = snapshot, - ) - emit(DeviceSessionEvent( - type = DeviceSessionEventType.DEVICE_INFO_UPDATED, - deviceId = deviceId, - deviceInfo = _info, - )) - } - - // ─── NativeDeviceSession 接口 ────────────────────────────────────────── - - override fun readRssi(): Int { - requireReady() - throw DeviceException( - DeviceErrorCode.NOT_SUPPORTED, - "jieli native bridge does not expose RSSI yet", - ) - } - - override fun readBattery(): Int? { - requireReady() - return server.deviceInfoFeature.snapshot(deviceId) - ?.get("battery") as? Int - } - - override fun refreshInfo(): DeviceInfo { - requireReady() - val snap = server.deviceInfoFeature.snapshot(deviceId) ?: return _info - applySnapshot(snap) - return _info - } - - override fun invokeFeature( - featureKey: String, - args: Map, - ): Map { - requireReady() - return when (featureKey) { - "jieli.translation.support" -> mapOf( - "supportCallStereo" to server.translationFeature - .isSupportCallTranslationWithStereo(deviceId), - ) - - "jieli.translation.start" -> { - val modeId = (args["modeId"] as? Number)?.toInt() - ?: throw DeviceException( - DeviceErrorCode.INVALID_ARGUMENT, - "modeId(int) required", - ) - @Suppress("UNCHECKED_CAST") - val extra = (args["args"] as? Map) ?: emptyMap() - server.translationFeature.start(modeId, extra).getOrElse { - throw DeviceException( - "device.feature_failed", - "translation.start failed: ${it.message}", - it, - ) - } - mapOf("ok" to true) - } - - "jieli.translation.stop" -> { - server.translationFeature.stop() - mapOf("ok" to true) - } - - "jieli.translation.feedTranslatedAudio" -> { - val pcm = args["pcm"] as? ByteArray - ?: (args["pcm"] as? List<*>) - ?.let { it.map { v -> (v as Number).toByte() }.toByteArray() } - ?: throw DeviceException( - DeviceErrorCode.INVALID_ARGUMENT, - "pcm(ByteArray|List) required", - ) - val streamId = args["streamId"] as? String - ?: throw DeviceException( - DeviceErrorCode.INVALID_ARGUMENT, - "streamId required (e.g. ${TranslationStreams.OUT_UPLINK})", - ) - val ok = server.translationFeature.feedTranslatedAudio( - outputStreamId = streamId, - pcm = pcm, - format = com.jielihome.jielihome.feature.translation.AudioFormat( - sampleRate = (args["sampleRate"] as? Number)?.toInt() ?: 16000, - channels = (args["channels"] as? Number)?.toInt() ?: 1, - bitsPerSample = (args["bitsPerSample"] as? Number)?.toInt() ?: 16, - ), - isFinal = (args["final"] as? Boolean) == true, - ) - mapOf("ok" to ok) - } - - "jieli.translation.feedTranslationResult" -> { - server.translationFeature.feedTranslationResult( - srcLang = args["srcLang"] as? String, - srcText = args["srcText"] as? String, - destLang = args["destLang"] as? String, - destText = args["destText"] as? String, - requestId = args["requestId"] as? String, - ) - mapOf("ok" to true) - } - - // ── 设备录音 ─────────────────────────────────────────────────────── - "jieli.deviceRecord.start" -> { - if (server.translationFeature.isWorking()) server.translationFeature.stop() - val sampleRate = (args["sampleRate"] as? Number)?.toInt() ?: 16000 - server.deviceRecordFeature.start( - mapOf("address" to deviceId, "sampleRate" to sampleRate) - ).getOrElse { - throw DeviceException( - "device.feature_failed", - "deviceRecord.start failed: ${it.message}", - it, - ) - } - mapOf("ok" to true) - } - - "jieli.deviceRecord.stop" -> { - server.deviceRecordFeature.stop() - mapOf("ok" to true) - } - - "jieli.deviceRecord.status" -> mapOf( - "recording" to server.deviceRecordFeature.isRecording() - ) - - "jieli.cmd.send" -> { - val opCode = (args["opCode"] as? Number)?.toInt() - ?: throw DeviceException( - DeviceErrorCode.INVALID_ARGUMENT, - "opCode required", - ) - @Suppress("UNCHECKED_CAST") - val payload = (args["payload"] as? List) - ?.map { it.toByte() }?.toByteArray() - ?: byteArrayOf() - // 同步等待(5s 超时)—— RCSP 自定义命令通常 < 500ms 返回。 - val deferred = java.util.concurrent.CompletableFuture() - server.customCmdFeature.send( - address = deviceId, - opCode = opCode, - payload = payload, - onSuccess = { data -> deferred.complete(data) }, - onError = { code, msg -> - deferred.completeExceptionally( - DeviceException("device.feature_failed", - "cmd $opCode failed: code=$code msg=${msg ?: ""}") - ) - }, - ) - val resp = try { - deferred.get(5, java.util.concurrent.TimeUnit.SECONDS) - } catch (e: java.util.concurrent.TimeoutException) { - throw DeviceException( - "device.feature_failed", - "cmd $opCode timeout", - ) - } catch (e: java.util.concurrent.ExecutionException) { - throw e.cause ?: e - } - mapOf("response" to resp) - } - - else -> throw DeviceException( - DeviceErrorCode.NOT_SUPPORTED, - "unknown feature \"$featureKey\"", - ) - } - } - - override fun callTranslationPort(): DeviceCallTranslationPort = server.callTranslationPort - - override fun assistantPort(): DeviceAssistantPort = server.assistantPort - - override fun otaPort(): DeviceOtaPort? { - if (DeviceCapability.OTA !in capabilities) return null - // 懒创建:未 ready 时也允许构造(订阅事件流),但 start() 内部会再校验。 - var port = _otaPort - if (port == null) { - synchronized(this) { - port = _otaPort - if (port == null && !_disposed) { - port = JieliOtaPort( - server = server, - deviceId = deviceId, - cacheDir = File(otaCacheDir, "ota"), - ) - _otaPort = port - } - } - } - return port - } - - override fun disconnect() { - if (_disposed) return - setState(DeviceConnectionState.DISCONNECTING) - runCatching { server.connectFeature.disconnect(deviceId) } - // 真正的 disconnected 状态由 ConnectionStateEvent 推过来 - } - - private fun requireReady() { - if (_disposed || _state != DeviceConnectionState.READY) { - throw DeviceException(DeviceErrorCode.NO_ACTIVE_SESSION) - } - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliOtaPort.kt b/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliOtaPort.kt deleted file mode 100644 index 6ed4ea71..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliOtaPort.kt +++ /dev/null @@ -1,280 +0,0 @@ -package com.jielihome.jielihome.integration - -import android.util.Log -import com.aiagent.device_plugin_interface.DeviceErrorCode -import com.aiagent.device_plugin_interface.DeviceException -import com.aiagent.device_plugin_interface.DeviceOtaPort -import com.aiagent.device_plugin_interface.DeviceOtaProgress -import com.aiagent.device_plugin_interface.DeviceOtaRequest -import com.aiagent.device_plugin_interface.DeviceOtaState -import com.jielihome.jielihome.api.JieliEventAdapter -import com.jielihome.jielihome.api.JieliEventListener -import com.jielihome.jielihome.core.JieliHomeServer -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import java.io.File -import java.util.concurrent.atomic.AtomicBoolean - -/** - * 杰理 [DeviceOtaPort] 实现 —— 桥接 [com.jielihome.jielihome.feature.ota.OtaFeature]。 - * - * 不重写 OtaFeature 的状态机,只把它派的 `otaState` / `otaError` 事件翻译成 - * [DeviceOtaProgress] 推到 [progressStream]。 - * - * 请求子类支持矩阵: - * - [DeviceOtaRequest.File] ✓ 直接 path 传给 OtaFeature - * - [DeviceOtaRequest.Bytes] ✓ 落盘到 cacheDir/ota_.ufw 再走 path - * - [DeviceOtaRequest.Url] ✗ 杰理 SDK 无 HTTP 通道,抛 NOT_SUPPORTED - * - [DeviceOtaRequest.Vendor] ✓ payload['filePath'] + payload['fileFlag'] 透传 - * - * 端口生命周期: - * - session disconnect 时由 [JieliNativeDeviceSession] 调 [shutdown] —— 主动 cancel + - * 补一帧 FAILED(disconnected_remote) 让上层取消订阅 / 跳出锁定 UI。 - */ -class JieliOtaPort internal constructor( - private val server: JieliHomeServer, - private val deviceId: String, - private val cacheDir: File, -) : DeviceOtaPort { - - companion object { - private const val TAG = "JieliOtaPort" - } - - private val _running = AtomicBoolean(false) - @Volatile private var _totalBytes: Long = 0 - @Volatile private var _tempFile: File? = null - @Volatile private var _disposed = false - - private val _progress = MutableSharedFlow( - replay = 1, - extraBufferCapacity = 32, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - - override val progressStream: Flow = _progress.asSharedFlow() - - override val isRunning: Boolean get() = _running.get() || server.otaFeature.isRunning() - - private val listener: JieliEventListener = object : JieliEventAdapter() { - override fun onOtaState(payload: Map) { - val stateName = payload["state"] as? String ?: return - val state = parseState(stateName) ?: return - val sent = (payload["sent"] as? Number)?.toLong() ?: 0L - val total = (payload["total"] as? Number)?.toLong() ?: _totalBytes - val percent = (payload["percent"] as? Number)?.toInt() ?: -1 - val tsMs = (payload["tsMs"] as? Number)?.toLong() ?: System.currentTimeMillis() - emit(DeviceOtaProgress( - state = state, - sentBytes = sent, - totalBytes = total, - percent = percent, - tsMs = tsMs, - )) - if (state == DeviceOtaState.DONE || - state == DeviceOtaState.CANCELLED || - state == DeviceOtaState.FAILED) { - finalize() - } - } - - override fun onOtaError(payload: Map) { - // OtaFeature 用整数 code;映射到 device.* 命名空间。 - val rawCode = (payload["code"] as? Number)?.toInt() ?: -1 - val msg = payload["message"] as? String - val mapped = mapErrorCode(rawCode) - emit(DeviceOtaProgress( - state = DeviceOtaState.FAILED, - sentBytes = -1, - totalBytes = _totalBytes, - percent = -1, - tsMs = System.currentTimeMillis(), - errorCode = mapped, - errorMessage = msg, - )) - } - } - - init { - server.addEventListener(listener) - } - - // ─── DeviceOtaPort ──────────────────────────────────────────────────── - - @Synchronized - override fun start(request: DeviceOtaRequest) { - if (_disposed) throw DeviceException(DeviceErrorCode.NO_ACTIVE_SESSION) - if (!_running.compareAndSet(false, true) || server.otaFeature.isRunning()) { - _running.set(server.otaFeature.isRunning() || _running.get()) - throw DeviceException(DeviceErrorCode.OTA_BUSY, "ota already running") - } - val (path, fileFlag) = try { - resolveFirmware(request) - } catch (e: DeviceException) { - _running.set(false) - throw e - } catch (t: Throwable) { - _running.set(false) - throw DeviceException(DeviceErrorCode.OTA_FILE_INVALID, t.message ?: "firmware error", t) - } - _totalBytes = File(path).length().coerceAtLeast(0L) - - // 立即派一帧 INQUIRING,UI 不必等 OtaFeature 第一次回调。 - emit(DeviceOtaProgress( - state = DeviceOtaState.INQUIRING, - sentBytes = 0, - totalBytes = _totalBytes, - percent = 0, - tsMs = System.currentTimeMillis(), - )) - - try { - server.otaFeature.start( - address = deviceId, - firmwareFilePath = path, - blockSize = request.blockSize ?: 512, - fileFlagBytes = fileFlag, - ) - } catch (t: Throwable) { - _running.set(false) - cleanupTemp() - throw DeviceException(DeviceErrorCode.OTA_TRANSFER_FAILED, t.message, t) - } - } - - override fun cancel() { - if (!_running.get() && !server.otaFeature.isRunning()) return - server.otaFeature.cancel() - // 状态变更通过 onOtaState(CANCELLED) 回到 finalize。 - } - - /** 由 [JieliNativeDeviceSession] 在 disconnect 时调用:补 FAILED 帧并解订阅。 */ - fun shutdown(reason: String = DeviceErrorCode.DISCONNECTED_REMOTE) { - if (_disposed) return - _disposed = true - if (_running.get()) { - runCatching { server.otaFeature.cancel() } - emit(DeviceOtaProgress( - state = DeviceOtaState.FAILED, - sentBytes = -1, - totalBytes = _totalBytes, - percent = -1, - tsMs = System.currentTimeMillis(), - errorCode = reason, - errorMessage = "session terminated during ota", - )) - } - runCatching { server.removeEventListener(listener) } - cleanupTemp() - _running.set(false) - } - - // ─── 内部 ────────────────────────────────────────────────────────────── - - private fun resolveFirmware(req: DeviceOtaRequest): Pair { - val flag = ByteArray(0) // OtaFeature 默认 fileFlag - return when (req) { - is DeviceOtaRequest.File -> { - val f = File(req.filePath) - if (!f.isFile || f.length() <= 0) { - throw DeviceException( - DeviceErrorCode.OTA_FILE_INVALID, - "firmware file invalid: ${req.filePath}", - ) - } - req.filePath to flag - } - - is DeviceOtaRequest.Bytes -> { - if (req.bytes.isEmpty()) { - throw DeviceException( - DeviceErrorCode.OTA_FILE_INVALID, - "ota bytes empty", - ) - } - if (!cacheDir.isDirectory && !cacheDir.mkdirs()) { - throw DeviceException( - DeviceErrorCode.OTA_FILE_INVALID, - "cannot create ota cache dir: ${cacheDir.absolutePath}", - ) - } - val tmp = File(cacheDir, "ota_${System.currentTimeMillis()}.ufw") - tmp.writeBytes(req.bytes) - _tempFile = tmp - tmp.absolutePath to flag - } - - is DeviceOtaRequest.Url -> { - throw DeviceException( - DeviceErrorCode.NOT_SUPPORTED, - "jieli ota does not support remote url; download in app and use File request", - ) - } - - is DeviceOtaRequest.Vendor -> { - if (req.vendorKey != "jieli") { - throw DeviceException( - DeviceErrorCode.INVALID_ARGUMENT, - "vendor mismatch: expected 'jieli', got '${req.vendorKey}'", - ) - } - val path = req.payload["filePath"] as? String - ?: throw DeviceException( - DeviceErrorCode.INVALID_ARGUMENT, - "jieli ota.vendor: payload['filePath'] required", - ) - val f = File(path) - if (!f.isFile || f.length() <= 0) { - throw DeviceException( - DeviceErrorCode.OTA_FILE_INVALID, - "firmware file invalid: $path", - ) - } - @Suppress("UNCHECKED_CAST") - val customFlag = (req.payload["fileFlag"] as? ByteArray) - ?: (req.payload["fileFlag"] as? List)?.map { it.toByte() }?.toByteArray() - ?: flag - path to customFlag - } - } - } - - private fun finalize() { - _running.set(false) - cleanupTemp() - } - - private fun cleanupTemp() { - _tempFile?.let { runCatching { it.delete() } } - _tempFile = null - } - - private fun emit(p: DeviceOtaProgress) { - if (!_progress.tryEmit(p)) Log.w(TAG, "progress buffer full; dropped ${p.state}") - } - - private fun parseState(name: String): DeviceOtaState? = when (name) { - "IDLE" -> DeviceOtaState.IDLE - "INQUIRING" -> DeviceOtaState.INQUIRING - "NOTIFYING_SIZE" -> DeviceOtaState.NOTIFYING_SIZE - "ENTERING" -> DeviceOtaState.ENTERING - "TRANSFERRING" -> DeviceOtaState.TRANSFERRING - "VERIFYING" -> DeviceOtaState.VERIFYING - "REBOOTING" -> DeviceOtaState.REBOOTING - "DONE" -> DeviceOtaState.DONE - "FAILED" -> DeviceOtaState.FAILED - "CANCELLED" -> DeviceOtaState.CANCELLED - else -> null - } - - /** OtaFeature 的负数错误码映射到设备域命名空间。 */ - private fun mapErrorCode(raw: Int): String = when (raw) { - -1 -> DeviceErrorCode.OTA_BUSY - -2 -> DeviceErrorCode.NO_ACTIVE_SESSION - -3 -> DeviceErrorCode.OTA_FILE_INVALID - -100 -> DeviceErrorCode.OTA_TRANSFER_FAILED - else -> DeviceErrorCode.OTA_TRANSFER_FAILED - } -} diff --git a/apps/client/local_plugins/device_jieli/android/src/test/kotlin/com/jielihome/jielihome/JielihomePluginTest.kt b/apps/client/local_plugins/device_jieli/android/src/test/kotlin/com/jielihome/jielihome/JielihomePluginTest.kt deleted file mode 100644 index ed61d7cc..00000000 --- a/apps/client/local_plugins/device_jieli/android/src/test/kotlin/com/jielihome/jielihome/JielihomePluginTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.jielihome.jielihome - -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import kotlin.test.Test -import org.mockito.Mockito - -/* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. - * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. - */ - -internal class JielihomePluginTest { - @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = JielihomePlugin() - - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) - - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) - } -} diff --git a/apps/client/local_plugins/floating_ui_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java b/apps/client/local_plugins/floating_ui_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java deleted file mode 100644 index 8034030e..00000000 --- a/apps/client/local_plugins/floating_ui_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.example.native_plugin; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -import io.flutter.plugin.common.MethodCall; -import io.flutter.plugin.common.MethodChannel; -import org.junit.Test; - -/** - * This demonstrates a simple unit test of the Java portion of this plugin's implementation. - * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. - */ - -public class NativePluginTest { - @Test - public void onMethodCall_getPlatformVersion_returnsExpectedValue() { - NativePlugin plugin = new NativePlugin(); - - final MethodCall call = new MethodCall("getPlatformVersion", null); - MethodChannel.Result mockResult = mock(MethodChannel.Result.class); - plugin.onMethodCall(call, mockResult); - - verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE); - } -} diff --git a/apps/client/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/ApiSample.kt b/apps/client/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/ApiSample.kt deleted file mode 100644 index 1f1fb70d..00000000 --- a/apps/client/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/ApiSample.kt +++ /dev/null @@ -1,361 +0,0 @@ -package com.tencent.qqmusic.api.demo - -import android.os.Bundle -import androidx.annotation.Nullable -import android.util.Log -import com.google.gson.Gson -import com.tencent.qqmusic.third.api.contract.* -import com.tencent.qqmusic.third.api.contract.ErrorCodes.ERROR_API_NOT_INITIALIZED - - -// -// Created by tylertan on 2020-03-19. -// Copyright (c) 2020 Tencent. All rights reserved. -// - -/** - * 对[Methods]中的接口进行基础封装 - * - */ -object ApiSample { - - const val TAG = "ApiSample" - - /** - * 获取接口返回错误码,对应[ErrorCodes]中的错误码列表 - * - * @return 返回[ErrorCodes.ERROR_OK]代表成功,反之为失败 - */ - private fun apiCode(bundle: Bundle?): Int? { - return bundle?.getInt(Keys.API_RETURN_KEY_CODE) - } - - /** - * 获取接口返回错误详细信息 - * - */ - private fun apiError(bundle: Bundle?): String? { - return bundle?.getString(Keys.API_RETURN_KEY_ERROR) - } - - fun hi(api: IQQMusicApi?) { - val bundle = Bundle() - bundle.putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) - bundle.putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, CommonCmd.AIDL_PLATFORM_TYPE_PHONE) - val result = api?.execute("hi", bundle) - Log.i(TAG, "sayHi ret:" + result!!.getInt(Keys.API_RETURN_KEY_CODE)) - } - - fun playSongMid(api: IQQMusicApi?, ids: ArrayList, block: (() -> Unit)) { - val params = Bundle() - params.putStringArrayList("midList", ids) - api?.executeAsync("playSongMid", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - block() - } - }) - } - - private fun playSongMidAtIndex(api: IQQMusicApi?, songList: List, index: Int, block: (() -> Unit)) { - val midList = ArrayList() - for (i in songList.indices) { - midList.add(songList[i].mid) - } - - val params = Bundle() - params.putStringArrayList("midList", midList) - params.putInt("index", index) - Log.i(TAG, "[playSongMidAtIndex] 播放歌曲列表... $index,${songList.size}") - api?.executeAsync("playSongMidAtIndex", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - block() - } - }) - } - - fun playSongLocalPath(api: IQQMusicApi?, pathList: ArrayList, block: (() -> Unit)) { - val params = Bundle() - params.putStringArrayList("pathList", pathList) - api?.executeAsync("playSongLocalPath", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - block() - } - }) - } - - fun playMusic(api: IQQMusicApi?): Int { - val ret = api?.execute("playMusic", null) - return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 - } - - fun stopMusic(api: IQQMusicApi?): Int { - val ret = api?.execute("stopMusic", null) - return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 - } - - fun pauseMusic(api: IQQMusicApi?): Int { - val ret = api?.execute("pauseMusic", null) - return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 - } - - fun resumeMusic(api: IQQMusicApi?): Int { - val ret = api?.execute("resumeMusic", null) - return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 - } - - fun skipToNext(api: IQQMusicApi?): Int { - val ret = api?.execute("skipToNext", null) - return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 - } - - fun skipToPrevious(api: IQQMusicApi?): Int { - val ret = api?.execute("skipToPrevious", null) - return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 - } - - fun getPlaybackState(api: IQQMusicApi?): Int { - val ret = api?.execute("getPlaybackState", null) - return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 - } - - @Nullable - fun getCurrentSong(api: IQQMusicApi?, gson: Gson): Data.Song? { - val ret = api?.execute("getCurrentSong", null) - val curSongJson = ret?.getString(Keys.API_RETURN_KEY_DATA) - return gson.fromJson(curSongJson, Data.Song::class.java) - } - - fun addToFavourite(api: IQQMusicApi?, midList: ArrayList, block: (() -> Unit)) { - val params = Bundle() - params.putStringArrayList("midList", midList) - api?.executeAsync("addToFavourite", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - block() - } - }) - } - - fun removeFromFavourite(api: IQQMusicApi?, midList: ArrayList, block: (() -> Unit)) { - val params = Bundle() - params.putStringArrayList("midList", midList) - api?.executeAsync("removeFromFavourite", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - block() - } - }) - } - - fun addLocalPathToFavourite(api: IQQMusicApi?, localPathList: ArrayList, block: (() -> Unit)) { - val params = Bundle() - params.putStringArrayList("localPathList", localPathList) - api?.executeAsync("addLocalPathToFavourite", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - block() - } - }) - } - - fun removeLocalPathFromFavourite(api: IQQMusicApi?, localPathList: ArrayList, block: (() -> Unit)) { - val params = Bundle() - params.putStringArrayList("localPathList", localPathList) - api?.executeAsync("removeLocalPathFromFavourite", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - block() - } - }) - } - - fun isFavouriteMid(api: IQQMusicApi?, midList: ArrayList, block: ((ret: BooleanArray?) -> Unit)) { - val params = Bundle() - params.putStringArrayList("midList", midList) - api?.executeAsync("isFavouriteMid", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - val ret = result.getBooleanArray(Keys.API_RETURN_KEY_DATA) - block(ret) - } - }) - } - - fun isFavouriteLocalPath(api: IQQMusicApi?, localPathList: ArrayList, block: ((ret: BooleanArray?) -> Unit)) { - val params = Bundle() - params.putStringArrayList("localPathList", localPathList) - api?.executeAsync("isFavouriteLocalPath", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - val ret = result.getBooleanArray(Keys.API_RETURN_KEY_DATA) - block(ret) - } - }) - } - - fun registerEventListener(api: IQQMusicApi?, events: ArrayList, block: (() -> Unit)) { - api?.registerEventListener(events, object : IQQMusicApiEventListener.Stub() { - override fun onEvent(event: String, extra: Bundle) { - block() - } - }) - } - - fun unregisterEventListener(api: IQQMusicApi?, events: ArrayList, block: (() -> Unit)) { - api?.unregisterEventListener(events, object : IQQMusicApiEventListener.Stub() { - override fun onEvent(event: String, extra: Bundle) { - block() - } - }) - } - - fun playFromChorus(api: IQQMusicApi?, fromChorus: Boolean) { - val params = Bundle() - params.putBoolean("fromChorus", fromChorus) - api?.execute("playFromChorus", params) - } - - @Deprecated("Use playSongMid instead.") - fun playSongId(api: IQQMusicApi?, ids: ArrayList, block: (() -> Unit)) { - val params = Bundle() - params.putStringArrayList("songIdList", ids) - api?.executeAsync("playSongId", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - block() - } - }) - } - - @Deprecated("Use playSongMidAtIndex instead.") - fun playSongIdAtIndex(api: IQQMusicApi?, ids: ArrayList, block: (() -> Unit)) { - val params = Bundle() - params.putStringArrayList("songIdList", ids) - api?.executeAsync("playSongIdAtIndex", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - block() - } - }) - } - - fun getTotalTime(api: IQQMusicApi?): Long { - val result = api?.execute("getTotalTime", null) - return (result?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 - } - - fun getCurrTime(api: IQQMusicApi?): Long { - val result = api?.execute("getCurrTime", null) - return (result?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 - } - - fun getPlayList(api: IQQMusicApi?, ids: ArrayList, block: ((json: String?) -> Unit)) { - val params = Bundle() - params.putStringArrayList("songIdList", ids) - api?.executeAsync("getPlayList", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(result: Bundle) { - val json = result.getString(Keys.API_RETURN_KEY_DATA) - block(json) - } - }) - } - - fun getFolderList(api: IQQMusicApi?, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { - val params = Bundle().apply { - putString("folderId", folderId) - putInt("folderType", folderType) - putInt("page", page) - } - - api?.executeAsync("getFolderList", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(p0: Bundle?) { - val json = p0?.getString(Keys.API_RETURN_KEY_DATA) - block(json) - } - }) - } - - fun getSongList(api: IQQMusicApi?, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { - val params = Bundle().apply { - putString("folderId", folderId) - putInt("folderType", folderType) - putInt("page", page) - } - - api?.executeAsync("getSongList", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(p0: Bundle?) { - val json = p0?.getString(Keys.API_RETURN_KEY_DATA) - block(json) - } - }) - } - - fun getUserFolderList(api: IQQMusicApi?, openId: String, openToken: String, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { - val params = Bundle().apply { - putString("openId", openId) - putString("openToken", openToken) - putString("folderId", folderId) - putInt("folderType", folderType) - putInt("page", page) - } - - api?.executeAsync("getUserFolderList", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(p0: Bundle?) { - val json = p0?.getString(Keys.API_RETURN_KEY_DATA) - block(json) - } - }) - } - - fun getUserSongList(api: IQQMusicApi?, openId: String, openToken: String, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { - val params = Bundle().apply { - putString("openId", openId) - putString("openToken", openToken) - putString("folderId", folderId) - putInt("folderType", folderType) - putInt("page", page) - } - - api?.executeAsync("getUserSongList", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(p0: Bundle?) { - val json = p0?.getString(Keys.API_RETURN_KEY_DATA) - block(json) - } - }) - } - - fun search(api: IQQMusicApi?, block: () -> Unit) { - val params = Bundle().apply { - putString("keyword", "周杰伦") - putInt("searchType", 0) - putBoolean("firstPage", true) - } - - api?.executeAsync("search", params, object : IQQMusicApiCallback.Stub() { - override fun onReturn(p0: Bundle?) { - block() - } - }) - } - - fun voiceShortcut(api: IQQMusicApi?, intent: String, block: (code: Int?) -> Unit) { - val bundle = Bundle() - bundle.apply { - putString("intent", intent) - } - api?.executeAsync("voiceShortcut", bundle, object : IQQMusicApiCallback.Stub() { - override fun onReturn(p0: Bundle?) { - val code = p0?.getInt(Keys.API_RETURN_KEY_CODE) ?: ERROR_API_NOT_INITIALIZED - block(code) - } - }) - } - - fun voicePlay(api: IQQMusicApi?, query: String, slotList: ArrayList, block: (code: Int, json: String?) -> Unit) { - val bundle = Bundle() - bundle.apply { - putString("query", query) - putStringArrayList("slotList", slotList) - } - api?.executeAsync("voicePlay", bundle, object : IQQMusicApiCallback.Stub() { - override fun onReturn(p0: Bundle?) { - val code = p0?.getInt(Keys.API_RETURN_KEY_CODE) ?: ERROR_API_NOT_INITIALIZED - val json = p0?.getString(Keys.API_RETURN_KEY_DATA) - block(code, json) - } - }) - } - -} \ No newline at end of file diff --git a/apps/client/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PcmExampleActivityNew.kt b/apps/client/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PcmExampleActivityNew.kt deleted file mode 100644 index fcff9685..00000000 --- a/apps/client/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PcmExampleActivityNew.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.yunqiinnovation.qq_music.pcm - -import android.content.* -import android.os.Bundle -import android.os.IBinder -import androidx.appcompat.app.AppCompatActivity -import android.widget.Button -import android.widget.TextView -import android.widget.Toast -import com.yunqiinnovation.qq_music.* -import com.yunqiinnovation.qq_music.R - - -class PcmExampleActivityNew : AppCompatActivity() { - companion object { - const val TAG = "PcmExampleActivity" - } - - private lateinit var informationTextView: TextView - private var playService: IPlayService? = null - - private val conn = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - playService = IPlayService.Stub.asInterface(service) - playService?.setPrintMessageCallback(object :IPrint.Stub(){ - override fun print(msg: String?) { - runOnUiThread { - informationTextView.text = msg - } - } - }) - } - - override fun onServiceDisconnected(name: ComponentName?) { - playService?.setPrintMessageCallback(null) - playService = null - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_pcm_example) - - informationTextView = findViewById(R.id.tv_information) - - val intent = Intent(this, PlayerService::class.java) - startService(intent) - bindService(intent, conn, BIND_AUTO_CREATE) - - findViewById