Browse Source
- 整个插件:local_plugins/test(模板插件,agent_service 的 Package.swift 声明了却没 import)、
local_plugins/bytedance_speech(不在任何 pubspec,Kotlin 零 import);连带改 pubspec、
agent_service 的 Package.swift 两行、android/settings.gradle.kts 两行、agent_service build.gradle.kts 一行
- 不再链接:device_jieli/android(pubspec 只剩 ios)、根目录 SpeexKit.xcframework、
ble_service 里注释写明已移除的 JLAudioUnitKit.xcframework、app res/raw 三个无引用的 mp3、
两份多余的 GoogleService-Info.plist、swift_sdk_local/Tests(连同 Package.swift 的 testTarget)
- 示例/模板残留:qq_music 的 ApiSample.kt / PcmExampleActivityNew.kt、azure_speech/lib/main.dart、
floating_ui_plugin 的模板测试、recorder_holder/sdk/example
- azure_speech Android:Dart 从不使用的 azure_speech/translation 方法通道与 translation_events 事件通道、
TranslationMethodHandler,以及因此失去引用的 MicrosoftTranslationAndTtsService 与两个字段
(MicrosoftTranslationServiceImpl 仍被 AzureAsrToAsr 使用,保留)
- agent_service Dart 包装里 13 个业务层零调用的方法(原生侧不动)
- 原生配置:Facebook 登录残留(Android meta-data/strings、iOS Info.plist 键、URL scheme 与 15 个查询 scheme)、
无对应代码的 NSMotionUsageDescription、已无依赖的火山引擎 Maven 仓库
验证:dart analyze 无 error;flutter test 221 全过;flutter build ios --no-codesign 通过。
⚠️ Android 编译本机未验证:azure_speech 的 generateDebugProto 需要执行 protoc-gen-grpc-java 1.57.2,
其 osx-aarch_64 制品实为 x86_64,本机未装 Rosetta 无法运行。已确认在本批改动之前的提交上同样失败,
与本次清理无关;装好 Rosetta 后需补跑一次 flutter build apk。
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 86df62086dc029ed2b2c7199dccc2d8a6e7a340d)
main
128 changed files with 1 additions and 17336 deletions
@ -1,30 +0,0 @@ |
|||||
<?xml version="1.0" encoding="UTF-8"?> |
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
||||
<plist version="1.0"> |
|
||||
<dict> |
|
||||
<key>API_KEY</key> |
|
||||
<string>AIzaSyBEQPL-2Qh6fERxrxLP9Pzcm95mTmTpBxE</string> |
|
||||
<key>GCM_SENDER_ID</key> |
|
||||
<string>733281995296</string> |
|
||||
<key>PLIST_VERSION</key> |
|
||||
<string>1</string> |
|
||||
<key>BUNDLE_ID</key> |
|
||||
<string>com.yimai.eaimar</string> |
|
||||
<key>PROJECT_ID</key> |
|
||||
<string>voitrans</string> |
|
||||
<key>STORAGE_BUCKET</key> |
|
||||
<string>voitrans.firebasestorage.app</string> |
|
||||
<key>IS_ADS_ENABLED</key> |
|
||||
<false></false> |
|
||||
<key>IS_ANALYTICS_ENABLED</key> |
|
||||
<false></false> |
|
||||
<key>IS_APPINVITE_ENABLED</key> |
|
||||
<true></true> |
|
||||
<key>IS_GCM_ENABLED</key> |
|
||||
<true></true> |
|
||||
<key>IS_SIGNIN_ENABLED</key> |
|
||||
<true></true> |
|
||||
<key>GOOGLE_APP_ID</key> |
|
||||
<string>1:733281995296:ios:6a8351691312100bd70a45</string> |
|
||||
</dict> |
|
||||
</plist> |
|
||||
@ -1,27 +0,0 @@ |
|||||
<?xml version="1.0" encoding="UTF-8"?> |
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
||||
<plist version="1.0"> |
|
||||
<dict> |
|
||||
<key>AvailableLibraries</key> |
|
||||
<array> |
|
||||
<dict> |
|
||||
<key>BinaryPath</key> |
|
||||
<string>SpeexKit.framework/SpeexKit</string> |
|
||||
<key>LibraryIdentifier</key> |
|
||||
<string>ios-arm64</string> |
|
||||
<key>LibraryPath</key> |
|
||||
<string>SpeexKit.framework</string> |
|
||||
<key>SupportedArchitectures</key> |
|
||||
<array> |
|
||||
<string>arm64</string> |
|
||||
</array> |
|
||||
<key>SupportedPlatform</key> |
|
||||
<string>ios</string> |
|
||||
</dict> |
|
||||
</array> |
|
||||
<key>CFBundlePackageType</key> |
|
||||
<string>XFWK</string> |
|
||||
<key>XCFrameworkFormatVersion</key> |
|
||||
<string>1.0</string> |
|
||||
</dict> |
|
||||
</plist> |
|
||||
@ -1,44 +0,0 @@ |
|||||
//
|
|
||||
// OpusTools.h
|
|
||||
// SpeexKit
|
|
||||
//
|
|
||||
// Created by EzioChan on 2024/1/17.
|
|
||||
// Copyright © 2024 www.zh-jieli.com. All rights reserved.
|
|
||||
//
|
|
||||
|
|
||||
#import <Foundation/Foundation.h> |
|
||||
|
|
||||
|
|
||||
NS_ASSUME_NONNULL_BEGIN |
|
||||
|
|
||||
@protocol OpusToolsDelegate <NSObject> |
|
||||
|
|
||||
-(void)opusToolsPcmData:(NSData*)data; |
|
||||
|
|
||||
-(void)opusToolsPcmFile:(NSData *)data; |
|
||||
|
|
||||
-(void)opusToolsOpusData:(NSData *)data; |
|
||||
|
|
||||
-(void)opusToolsOpusFile:(NSData *)data; |
|
||||
|
|
||||
@end |
|
||||
|
|
||||
@interface OpusTools : NSObject |
|
||||
|
|
||||
@property(nonatomic,weak)id<OpusToolsDelegate> 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 |
|
||||
@ -1,100 +0,0 @@ |
|||||
//
|
|
||||
// OpusUnit.h
|
|
||||
// QCY_Demo
|
|
||||
//
|
|
||||
// Created by 杰理科技 on 2021/7/5.
|
|
||||
// Copyright © 2021 杰理科技. All rights reserved.
|
|
||||
//
|
|
||||
|
|
||||
#import <Foundation/Foundation.h> |
|
||||
|
|
||||
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 |
|
||||
@ -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 <UIKit/UIKit.h> |
|
||||
|
|
||||
//! 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 <SpeexKit/PublicHeader.h>
|
|
||||
|
|
||||
#import <SpeexKit/SpeexUnit.h> |
|
||||
#import <SpeexKit/OpusUnit.h> |
|
||||
#import <SpeexKit/OpusTools.h> |
|
||||
@ -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 <Foundation/Foundation.h> |
|
||||
|
|
||||
/**
|
|
||||
* 流式解码回调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 |
|
||||
Binary file not shown.
@ -1,6 +0,0 @@ |
|||||
framework module SpeexKit { |
|
||||
umbrella header "SpeexKit.h" |
|
||||
export * |
|
||||
|
|
||||
module * { export * } |
|
||||
} |
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,6 +1,3 @@ |
|||||
<?xml version="1.0" encoding="utf-8"?> |
<?xml version="1.0" encoding="utf-8"?> |
||||
<resources> |
<resources> |
||||
<string name="facebook_app_id">3829022037361855</string> |
|
||||
<string name="fb_login_protocol_scheme">fb3829022037361855</string> |
|
||||
<string name="facebook_client_token">193d0a2b1757c41cfa557ea8e8f21c33</string> |
|
||||
</resources> |
</resources> |
||||
@ -1,30 +0,0 @@ |
|||||
<?xml version="1.0" encoding="UTF-8"?> |
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
||||
<plist version="1.0"> |
|
||||
<dict> |
|
||||
<key>API_KEY</key> |
|
||||
<string>AIzaSyBEQPL-2Qh6fERxrxLP9Pzcm95mTmTpBxE</string> |
|
||||
<key>GCM_SENDER_ID</key> |
|
||||
<string>733281995296</string> |
|
||||
<key>PLIST_VERSION</key> |
|
||||
<string>1</string> |
|
||||
<key>BUNDLE_ID</key> |
|
||||
<string>com.yimai.eaimar</string> |
|
||||
<key>PROJECT_ID</key> |
|
||||
<string>voitrans</string> |
|
||||
<key>STORAGE_BUCKET</key> |
|
||||
<string>voitrans.firebasestorage.app</string> |
|
||||
<key>IS_ADS_ENABLED</key> |
|
||||
<false></false> |
|
||||
<key>IS_ANALYTICS_ENABLED</key> |
|
||||
<false></false> |
|
||||
<key>IS_APPINVITE_ENABLED</key> |
|
||||
<true></true> |
|
||||
<key>IS_GCM_ENABLED</key> |
|
||||
<true></true> |
|
||||
<key>IS_SIGNIN_ENABLED</key> |
|
||||
<true></true> |
|
||||
<key>GOOGLE_APP_ID</key> |
|
||||
<string>1:733281995296:ios:6a8351691312100bd70a45</string> |
|
||||
</dict> |
|
||||
</plist> |
|
||||
@ -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() |
|
||||
} |
|
||||
} |
|
||||
@ -1,20 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
|
|
||||
void main() { |
|
||||
runApp(const MainApp()); |
|
||||
} |
|
||||
|
|
||||
class MainApp extends StatelessWidget { |
|
||||
const MainApp({super.key}); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return const MaterialApp( |
|
||||
home: Scaffold( |
|
||||
body: Center( |
|
||||
child: Text('Hello World!'), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -1,27 +0,0 @@ |
|||||
<?xml version="1.0" encoding="UTF-8"?> |
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
||||
<plist version="1.0"> |
|
||||
<dict> |
|
||||
<key>AvailableLibraries</key> |
|
||||
<array> |
|
||||
<dict> |
|
||||
<key>BinaryPath</key> |
|
||||
<string>JLAudioUnitKit.framework/JLAudioUnitKit</string> |
|
||||
<key>LibraryIdentifier</key> |
|
||||
<string>ios-arm64</string> |
|
||||
<key>LibraryPath</key> |
|
||||
<string>JLAudioUnitKit.framework</string> |
|
||||
<key>SupportedArchitectures</key> |
|
||||
<array> |
|
||||
<string>arm64</string> |
|
||||
</array> |
|
||||
<key>SupportedPlatform</key> |
|
||||
<string>ios</string> |
|
||||
</dict> |
|
||||
</array> |
|
||||
<key>CFBundlePackageType</key> |
|
||||
<string>XFWK</string> |
|
||||
<key>XCFrameworkFormatVersion</key> |
|
||||
<string>1.0</string> |
|
||||
</dict> |
|
||||
</plist> |
|
||||
@ -1,24 +0,0 @@ |
|||||
//
|
|
||||
// JLAudioUnitKit.h
|
|
||||
// JLAudioUnitKit
|
|
||||
//
|
|
||||
// Created by EzioChan on 2024/11/14.
|
|
||||
//
|
|
||||
|
|
||||
#import <Foundation/Foundation.h> |
|
||||
|
|
||||
//! 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 <JLAudioUnitKit/PublicHeader.h>
|
|
||||
|
|
||||
#import <JLAudioUnitKit/JLSpeexDecoder.h> |
|
||||
#import <JLAudioUnitKit/JLOpusDecoder.h> |
|
||||
#import <JLAudioUnitKit/JLOpusEncoder.h> |
|
||||
#import <JLAudioUnitKit/JLOpusFormat.h> |
|
||||
#import <JLAudioUnitKit/JLPcmToWtg.h> |
|
||||
#import <JLAudioUnitKit/JLPcmToWav.h> |
|
||||
#import <JLAudioUnitKit/JLAudioUnitPlayer.h> |
|
||||
@ -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 <Foundation/Foundation.h> |
|
||||
#import <AudioToolbox/AudioToolbox.h> |
|
||||
|
|
||||
NS_ASSUME_NONNULL_BEGIN |
|
||||
|
|
||||
typedef NS_ENUM(NSInteger, JLAudioUnitPlayerType) { |
|
||||
JLAudioUnitPlayerTypeFile, // 支持 MP3/WAV/AAC 等文件格式(AVAudioPlayer)
|
|
||||
JLAudioUnitPlayerTypePCM // 支持 PCM 流式播放(Audio Queue)
|
|
||||
}; |
|
||||
|
|
||||
@class JLAudioUnitPlayer; |
|
||||
|
|
||||
@protocol JLAudioPlayerDelegate <NSObject> |
|
||||
@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<JLAudioPlayerDelegate> 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 |
|
||||
@ -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 <Foundation/Foundation.h> |
|
||||
#import <JLAudioUnitKit/JLOpusFormat.h> |
|
||||
|
|
||||
NS_ASSUME_NONNULL_BEGIN |
|
||||
@class JLOpusDecoder; |
|
||||
|
|
||||
/// Opus 解码代理
|
|
||||
@protocol JLOpusDecoderDelegate <NSObject> |
|
||||
|
|
||||
/// 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<JLOpusDecoderDelegate> delegate; |
|
||||
|
|
||||
- (instancetype)init NS_UNAVAILABLE; |
|
||||
|
|
||||
/// 初始化
|
|
||||
/// - Parameters:
|
|
||||
/// - format: 解码格式
|
|
||||
/// - delegate: 代理
|
|
||||
- (instancetype)initDecoder:(JLOpusFormat *)format delegate:(id<JLOpusDecoderDelegate>)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 |
|
||||
@ -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 <Foundation/Foundation.h> |
|
||||
#import <JLAudioUnitKit/JLOpusFormat.h> |
|
||||
|
|
||||
NS_ASSUME_NONNULL_BEGIN |
|
||||
@class JLOpusEncoder; |
|
||||
|
|
||||
/// Opus 编码代理
|
|
||||
@protocol JLOpusEncoderDelegate <NSObject> |
|
||||
|
|
||||
/// 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<JLOpusEncoderDelegate> delegate; |
|
||||
|
|
||||
-(instancetype)init NS_UNAVAILABLE; |
|
||||
|
|
||||
/// 初始化
|
|
||||
/// - Parameters:
|
|
||||
/// - format: 音频格式
|
|
||||
/// - delegate: 代理
|
|
||||
-(instancetype)initFormat:(JLOpusFormat *)format delegate:(id<JLOpusEncoderDelegate>)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 |
|
||||
@ -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 <Foundation/Foundation.h> |
|
||||
|
|
||||
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 |
|
||||
@ -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 <Foundation/Foundation.h> |
|
||||
|
|
||||
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 |
|
||||
@ -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 <Foundation/Foundation.h> |
|
||||
|
|
||||
NS_ASSUME_NONNULL_BEGIN |
|
||||
|
|
||||
@interface JLPcm2WtgModel : NSObject <NSCopying> |
|
||||
|
|
||||
/// 音频文件
|
|
||||
@property (nonatomic, copy) NSString *pcmPath; |
|
||||
|
|
||||
/// 输出文件
|
|
||||
@property (nonatomic, copy) NSString *wtgPath; |
|
||||
|
|
||||
@end |
|
||||
|
|
||||
|
|
||||
/// JLPcmToWtgProtocol
|
|
||||
@protocol JLPcmToWtgDelegate <NSObject> |
|
||||
|
|
||||
/// 音频转码完成
|
|
||||
/// - Parameter model: 音频转码模型
|
|
||||
- (void)convertPcmToWtgDone:(JLPcm2WtgModel *)model; |
|
||||
|
|
||||
@end |
|
||||
|
|
||||
/// 音频转码
|
|
||||
/// PCM 转 WTG
|
|
||||
@interface JLPcmToWtg : NSObject |
|
||||
|
|
||||
- (instancetype)init NS_UNAVAILABLE; |
|
||||
|
|
||||
/// 音频转码初始化
|
|
||||
/// - Parameter delegate: 音频转码代理
|
|
||||
- (instancetype)initWithDelegate:(id<JLPcmToWtgDelegate>)delegate; |
|
||||
|
|
||||
/// 音频转码
|
|
||||
/// 当前接口模型只限制 PCM 文件格式为:8k 16bit
|
|
||||
/// 输入的pcm文件(16bit,小端,8k采样率),wtg_filename:输出的wtg文件
|
|
||||
/// - Parameter model: 音频转码模型
|
|
||||
-(void)convertPcmToWtg:(JLPcm2WtgModel *)model; |
|
||||
|
|
||||
@end |
|
||||
|
|
||||
NS_ASSUME_NONNULL_END |
|
||||
@ -1,54 +0,0 @@ |
|||||
//
|
|
||||
// JLSpeexUnit.h
|
|
||||
// JLAudioUnitKit
|
|
||||
//
|
|
||||
// Created by EzioChan on 2024/11/14.
|
|
||||
//
|
|
||||
|
|
||||
#import <Foundation/Foundation.h> |
|
||||
|
|
||||
NS_ASSUME_NONNULL_BEGIN |
|
||||
@class JLSpeexDecoder; |
|
||||
|
|
||||
@protocol JLSpeexDelegate <NSObject> |
|
||||
|
|
||||
/// 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<JLSpeexDelegate>)delegate; |
|
||||
|
|
||||
/// 委托
|
|
||||
@property (nonatomic, weak) id<JLSpeexDelegate> 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 |
|
||||
Binary file not shown.
Binary file not shown.
@ -1,6 +0,0 @@ |
|||||
framework module JLAudioUnitKit { |
|
||||
umbrella header "JLAudioUnitKit.h" |
|
||||
export * |
|
||||
|
|
||||
module * { export * } |
|
||||
} |
|
||||
@ -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 |
|
||||
|
|
||||
|
|
||||
@ -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) |
|
||||
@ -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")) |
|
||||
} |
|
||||
@ -1 +0,0 @@ |
|||||
rootProject.name = "bytedance_speech" |
|
||||
@ -1,8 +0,0 @@ |
|||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|
||||
xmlns:tools="http://schemas.android.com/tools"> |
|
||||
|
|
||||
<uses-permission android:name="android.permission.INTERNET" /> |
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" /> |
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> |
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> |
|
||||
</manifest> |
|
||||
@ -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<String> = 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<String>? = 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 |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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<AudioDeviceInfo>) { |
|
||||
Log.d(TAG, "音频设备连接: ${addedDevices.joinToString { "${it.productName}(${it.type})" }}") |
|
||||
rebuildIfNeeded() |
|
||||
} |
|
||||
|
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<AudioDeviceInfo>) { |
|
||||
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}" |
|
||||
} |
|
||||
} |
|
||||
@ -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<String>("ttsAppId") ?: "" |
|
||||
val ttsAppToken = call.argument<String>("ttsAppToken") ?: "" |
|
||||
val language = call.argument<String>("language") ?: "zh-CN" |
|
||||
|
|
||||
initializeTTS(ttsAppId, ttsAppToken, language, result) |
|
||||
} |
|
||||
"setVoice" -> { |
|
||||
val voice = call.argument<String>("voice") ?: "" |
|
||||
setVoice(voice, result) |
|
||||
} |
|
||||
"speakOnce" -> { |
|
||||
val text = call.argument<String>("text") ?: "" |
|
||||
speakOnce(text, result) |
|
||||
} |
|
||||
"speakStream" -> { |
|
||||
val text = call.argument<String>("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<String, Any>() |
|
||||
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<String, Any>?) { |
|
||||
if (ttsEventSink == null) return |
|
||||
|
|
||||
val event = HashMap<String, Any>() |
|
||||
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<String, Any>? = null) { |
|
||||
if (asrEventSink == null) return |
|
||||
|
|
||||
val event = HashMap<String, Any>() |
|
||||
event["type"] = eventType |
|
||||
event["data"] = data |
|
||||
|
|
||||
if (params != null) { |
|
||||
event["params"] = params |
|
||||
} |
|
||||
|
|
||||
mainHandler.post { |
|
||||
asrEventSink?.success(event) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
File diff suppressed because it is too large
@ -1,12 +0,0 @@ |
|||||
library bytedance_speech; |
|
||||
|
|
||||
import 'package:flutter/services.dart'; |
|
||||
|
|
||||
class BytedanceSpeech { |
|
||||
static const MethodChannel _channel = MethodChannel('bytedance_speech'); |
|
||||
|
|
||||
static Future<String?> getPlatformVersion() async { |
|
||||
final String? version = await _channel.invokeMethod('getPlatformVersion'); |
|
||||
return version; |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
@ -1,9 +0,0 @@ |
|||||
*.iml |
|
||||
.gradle |
|
||||
/local.properties |
|
||||
/.idea/workspace.xml |
|
||||
/.idea/libraries |
|
||||
.DS_Store |
|
||||
/build |
|
||||
/captures |
|
||||
.cxx |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1 +0,0 @@ |
|||||
rootProject.name = 'jielihome' |
|
||||
@ -1,12 +0,0 @@ |
|||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
|
||||
<uses-permission android:name="android.permission.INTERNET" /> |
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" |
|
||||
android:maxSdkVersion="30" /> |
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" |
|
||||
android:maxSdkVersion="30" /> |
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> |
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> |
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> |
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" /> |
|
||||
</manifest> |
|
||||
@ -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 仍可继续使用 |
|
||||
} |
|
||||
} |
|
||||
@ -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<String, Any?>) {} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 按事件类型分发的便捷适配器。子类只覆盖关心的回调,未覆盖的事件会落到 [onUnknown]。 |
|
||||
*/ |
|
||||
abstract class JieliEventAdapter : JieliEventListener { |
|
||||
|
|
||||
override fun onEvent(payload: Map<String, Any?>) { |
|
||||
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<String, Any?>) {} |
|
||||
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<String, Any?>) {} |
|
||||
open fun onMusicName(address: String?, name: String?) {} |
|
||||
open fun onMusicStatus(payload: Map<String, Any?>) {} |
|
||||
open fun onPlayMode(address: String?, mode: Int?) {} |
|
||||
|
|
||||
open fun onExpandFunction(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
/** 翻译事件统一入口(兜底,默认细分回调之后还会触发一次) */ |
|
||||
open fun onTranslationEvent(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
/** payload["pcm"] 为 ByteArray;附带 streamId/sampleRate/seq/tsMs/final 字段 */ |
|
||||
open fun onTranslationAudio(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
/** payload 含 srcLang/srcText/destLang/destText/requestId */ |
|
||||
open fun onTranslationResult(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
/** payload 含 code/message */ |
|
||||
open fun onTranslationError(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
open fun onTranslationLog(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
/** 耳机检测到唤醒/语音助手触发;payload: voiceType, sampleRate, vadWay, address, tsMs */ |
|
||||
open fun onSpeechStart(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
/** 语音助手音频帧;payload: pcm(ByteArray), encoding, sampleRate, channels, address */ |
|
||||
open fun onSpeechAudio(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
/** 语音助手会话结束;payload: reason, message, address */ |
|
||||
open fun onSpeechEnd(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
open fun onSpeechError(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
// ── 设备录音 ──────────────────────────────────────────────────────────── |
|
||||
|
|
||||
/** 录音上行已启动;payload: address, sampleRate, tsMs */ |
|
||||
open fun onDeviceRecordStart(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
/** |
|
||||
* 录音音频帧;payload: address, streamId("in.uplink"/"in.downlink"), |
|
||||
* pcm(ByteArray), sampleRate, channels, bitsPerSample, tsMs |
|
||||
*/ |
|
||||
open fun onDeviceRecordAudio(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
/** 录音上行已停止;payload: address, tsMs */ |
|
||||
open fun onDeviceRecordStop(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
/** 录音错误;payload: address, code, message */ |
|
||||
open fun onDeviceRecordError(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
/** OTA 进度/状态:state(String), sent(Long), total(Long), percent(Int) */ |
|
||||
open fun onOtaState(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
open fun onOtaError(payload: Map<String, Any?>) {} |
|
||||
|
|
||||
open fun onUnknown(payload: Map<String, Any?>) {} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
@ -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<ByteArray, ByteArray> { |
|
||||
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 |
|
||||
} |
|
||||
} |
|
||||
@ -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" |
|
||||
} |
|
||||
} |
|
||||
@ -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<JieliEventListener>() |
|
||||
|
|
||||
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<String, Any?>) { |
|
||||
// 原生监听同步派发(在事件原始线程,不经主线程切换,方便高频音频帧) |
|
||||
for (l in nativeListeners) { |
|
||||
try { l.onEvent(payload) } catch (_: Throwable) { /* swallow listener errors */ } |
|
||||
} |
|
||||
// Flutter 端必须在主线程 |
|
||||
val s = sink ?: return |
|
||||
mainHandler.post { runCatching { s.success(payload) } } |
|
||||
} |
|
||||
} |
|
||||
@ -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<Boolean>("multiDevice") ?: true, |
|
||||
skipNoNameDev = call.argument<Boolean>("skipNoNameDev") ?: false, |
|
||||
enableLog = call.argument<Boolean>("enableLog") ?: false, |
|
||||
) |
|
||||
result.success(true) |
|
||||
} |
|
||||
|
|
||||
"startScan" -> { |
|
||||
val timeout = call.argument<Int>("timeoutMs") ?: 30000 |
|
||||
val nameList = call.argument<List<String>>("nameList") ?: emptyList() |
|
||||
val uuidList = call.argument<List<String>>("uuidList") ?: emptyList() |
|
||||
val skipUnnamed = call.argument<Boolean>("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<String>("address") |
|
||||
?: return result.error("BAD_ARG", "address required", null) |
|
||||
server.connectFeature.connect( |
|
||||
bleAddress = mac, |
|
||||
edrAddress = call.argument<String>("edrAddr"), |
|
||||
deviceType = call.argument<Int>("deviceType") ?: -1, |
|
||||
connectWay = call.argument<Int>("connectWay") ?: 0, |
|
||||
).fold({ result.success(true) }, |
|
||||
{ result.error("CONNECT_FAILED", it.message, null) }) |
|
||||
} |
|
||||
|
|
||||
"disconnect" -> { |
|
||||
val mac = call.argument<String>("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<String>("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<String>("address") |
|
||||
?: return result.error("BAD_ARG", "address required", null) |
|
||||
result.success(server.deviceInfoFeature.snapshot(mac)) |
|
||||
} |
|
||||
|
|
||||
"queryTargetInfo" -> { |
|
||||
val mac = call.argument<String>("address") |
|
||||
?: return result.error("BAD_ARG", "address required", null) |
|
||||
val mask = call.argument<Int>("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<String>("address") |
|
||||
?: return result.error("BAD_ARG", "address required", null) |
|
||||
val opCode = call.argument<Int>("opCode") |
|
||||
?: return result.error("BAD_ARG", "opCode required", null) |
|
||||
val payload = call.argument<ByteArray>("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<Int>("modeId") |
|
||||
?: return result.error("BAD_ARG", "modeId required", null) |
|
||||
@Suppress("UNCHECKED_CAST") |
|
||||
val args = (call.argument<Map<String, Any?>>("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<String>("streamId") |
|
||||
?: return result.error("BAD_ARG", "streamId required", null) |
|
||||
val pcm = call.argument<ByteArray>("pcm") |
|
||||
?: return result.error("BAD_ARG", "pcm required", null) |
|
||||
val sampleRate = call.argument<Int>("sampleRate") ?: 16000 |
|
||||
val channels = call.argument<Int>("channels") ?: 1 |
|
||||
val bits = call.argument<Int>("bitsPerSample") ?: 16 |
|
||||
val isFinal = call.argument<Boolean>("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<String>("srcLang"), |
|
||||
srcText = call.argument<String>("srcText"), |
|
||||
destLang = call.argument<String>("destLang"), |
|
||||
destText = call.argument<String>("destText"), |
|
||||
requestId = call.argument<String>("requestId"), |
|
||||
) |
|
||||
result.success(true) |
|
||||
} |
|
||||
|
|
||||
"isSupportCallTranslationWithStereo" -> { |
|
||||
val mac = call.argument<String>("address") |
|
||||
result.success(server.translationFeature.isSupportCallTranslationWithStereo(mac)) |
|
||||
} |
|
||||
|
|
||||
"feedAudioFilePcm" -> { |
|
||||
val pcm = call.argument<ByteArray>("pcm") |
|
||||
?: return result.error("BAD_ARG", "pcm required", null) |
|
||||
val sampleRate = call.argument<Int>("sampleRate") ?: 16000 |
|
||||
result.success(server.translationFeature.feedAudioFilePcm(pcm, sampleRate)) |
|
||||
} |
|
||||
|
|
||||
"speechIsRecording" -> |
|
||||
result.success(server.speechFeature.isRecording(call.argument<String>("address"))) |
|
||||
|
|
||||
"speechStart" -> { |
|
||||
server.speechFeature.start( |
|
||||
address = call.argument<String>("address"), |
|
||||
voiceType = call.argument<Int>("voiceType") |
|
||||
?: com.jieli.bluetooth.bean.record.RecordParam.VOICE_TYPE_OPUS, |
|
||||
sampleRate = call.argument<Int>("sampleRate") |
|
||||
?: com.jieli.bluetooth.bean.record.RecordParam.SAMPLE_RATE_16K, |
|
||||
vadWay = call.argument<Int>("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<String>("address"), |
|
||||
reason = call.argument<Int>("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<Map<String, Any?>>("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<String>("firmwareFilePath") |
|
||||
?: return result.error("BAD_ARG", "firmwareFilePath required", null) |
|
||||
server.otaFeature.start( |
|
||||
address = call.argument<String>("address"), |
|
||||
firmwareFilePath = path, |
|
||||
blockSize = call.argument<Int>("blockSize") ?: 512, |
|
||||
fileFlagBytes = call.argument<ByteArray>("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()) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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<String, Any?>) { |
|
||||
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 |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -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<Unit> { |
|
||||
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<Unit> { |
|
||||
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<BluetoothDevice, Int>? { |
|
||||
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 |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
} |
|
||||
@ -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<String, Any?>? { |
|
||||
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<String, Any?>) -> 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) |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
} |
|
||||
@ -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<String> = emptyList() |
|
||||
@Volatile private var currentSkipUnnamed: Boolean = true |
|
||||
|
|
||||
@SuppressLint("MissingPermission") |
|
||||
fun startScan( |
|
||||
timeoutMs: Int, |
|
||||
nameList: List<String> = emptyList(), |
|
||||
uuidList: List<String> = emptyList(), |
|
||||
skipUnnamed: Boolean = true, |
|
||||
): Result<Unit> { |
|
||||
val scanner = leScanner() |
|
||||
?: return Result.failure(IllegalStateException("BLE scanner unavailable")) |
|
||||
|
|
||||
// 已在扫描:先停旧的(替换扫描参数) |
|
||||
if (scanning) doStop(scanner) |
|
||||
|
|
||||
currentNameList = nameList.filter { it.isNotEmpty() } |
|
||||
currentSkipUnnamed = skipUnnamed |
|
||||
|
|
||||
val filters = mutableListOf<ScanFilter>() |
|
||||
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<ScanResult>) { |
|
||||
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<AdRecord> { |
|
||||
val out = mutableListOf<AdRecord>() |
|
||||
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" |
|
||||
} |
|
||||
} |
|
||||
@ -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() } |
|
||||
} |
|
||||
} |
|
||||
@ -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<AssistantAudioFrame>( |
|
||||
replay = 0, |
|
||||
extraBufferCapacity = 128, |
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|
||||
) |
|
||||
private val _errors = MutableSharedFlow<AssistantError>( |
|
||||
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<AssistantAudioFormat> = setOf(PCM_16K_MONO_20MS) |
|
||||
override fun supportedSinkFormats(): Set<AssistantAudioFormat> = setOf(PCM_16K_MONO_20MS) |
|
||||
|
|
||||
override val audioFrames: Flow<AssistantAudioFrame> = _audioFrames.asSharedFlow() |
|
||||
override val errors: Flow<AssistantError> = _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<Int> { |
|
||||
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 |
|
||||
} |
|
||||
} |
|
||||
@ -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<com.jieli.bluetooth.bean.base.CommonResponse>( |
|
||||
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<com.jieli.bluetooth.bean.response.ExitUpdateModeResponse>(device, ExitUpdateModeCmd()) } |
|
||||
runCatching { sendCmd<com.jieli.bluetooth.bean.response.RebootDeviceResponse>(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 <reified R> 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<Unit> { 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) |
|
||||
) |
|
||||
} |
|
||||
} |
|
||||
@ -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<String, Any?>): Result<Unit> { |
|
||||
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(), |
|
||||
) |
|
||||
) |
|
||||
} |
|
||||
} |
|
||||
@ -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<DeviceRecordFrame>( |
|
||||
replay = 0, |
|
||||
extraBufferCapacity = 256, |
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|
||||
) |
|
||||
private val _errors = MutableSharedFlow<DeviceRecordError>( |
|
||||
replay = 0, |
|
||||
extraBufferCapacity = 16, |
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|
||||
) |
|
||||
|
|
||||
/** 录音 PCM 帧流(双声道交织)。 */ |
|
||||
val audioFrames: Flow<DeviceRecordFrame> = _audioFrames.asSharedFlow() |
|
||||
|
|
||||
/** 错误流。 */ |
|
||||
val errors: Flow<DeviceRecordError> = _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<Unit> { |
|
||||
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<Int> { |
|
||||
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() |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
@ -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<CallAudioFrame>( |
|
||||
replay = 0, |
|
||||
extraBufferCapacity = 128, |
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|
||||
) |
|
||||
private val _errors = MutableSharedFlow<CallTranslationError>( |
|
||||
replay = 0, |
|
||||
extraBufferCapacity = 16, |
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|
||||
) |
|
||||
|
|
||||
@Volatile private var entered = false |
|
||||
@Volatile private var savedBridge: TranslationAudioBridge? = null |
|
||||
|
|
||||
override fun supportedSourceFormats(): Set<CallAudioFormat> = setOf(PCM_16K_MONO_20MS) |
|
||||
override fun supportedSinkFormats(): Set<CallAudioFormat> = setOf(PCM_16K_MONO_20MS) |
|
||||
|
|
||||
override val audioFrames: Flow<CallAudioFrame> = _audioFrames.asSharedFlow() |
|
||||
override val errors: Flow<CallTranslationError> = _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<String, Long>() |
|
||||
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") |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
@ -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<Int, TranslationModeHandler> = 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<String, Any?>): Result<Unit> { |
|
||||
// 与官方 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<String> = current?.inputStreams ?: emptyList() |
|
||||
fun currentOutputStreams(): List<String> = 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 |
|
||||
} |
|
||||
} |
|
||||
@ -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<String> |
|
||||
val outputStreams: List<String> |
|
||||
|
|
||||
fun start(args: Map<String, Any?>) |
|
||||
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 |
|
||||
} |
|
||||
@ -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, |
|
||||
) |
|
||||
@ -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<String, Any?>) { |
|
||||
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 |
|
||||
} |
|
||||
} |
|
||||
@ -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<String, Any?>) { |
|
||||
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 |
|
||||
} |
|
||||
} |
|
||||
@ -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<String, Any?>) { |
|
||||
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 |
|
||||
} |
|
||||
} |
|
||||
@ -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<String, Any?>) { |
|
||||
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<Int> { |
|
||||
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 |
|
||||
} |
|
||||
} |
|
||||
@ -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<String, Any?>) { |
|
||||
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<Int> { |
|
||||
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 |
|
||||
} |
|
||||
} |
|
||||
@ -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<String, Any?>) { |
|
||||
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 |
|
||||
} |
|
||||
} |
|
||||
@ -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<String, ByteArrayOutputStream>() |
|
||||
private val bufferLock = Any() |
|
||||
private val encodeSeq = AtomicLong(0) |
|
||||
|
|
||||
/** 调试统计 */ |
|
||||
@Volatile private var rxFirstLogged = false |
|
||||
private val rxFirstPerSource = java.util.concurrent.ConcurrentHashMap<Int, Boolean>() |
|
||||
|
|
||||
// ─── 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 |
|
||||
} |
|
||||
} |
|
||||
@ -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 */ } |
|
||||
} |
|
||||
@ -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<Unit> { |
|
||||
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<Int> { |
|
||||
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() } |
|
||||
} |
|
||||
} |
|
||||
@ -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<Boolean> { |
|
||||
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<Boolean> { |
|
||||
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, |
|
||||
) |
|
||||
) |
|
||||
} |
|
||||
} |
|
||||
@ -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<DeviceCapability> = 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<DeviceCapability> = 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<NativeDeviceSession>? = null |
|
||||
@Volatile private var _pendingConnectAddress: String? = null |
|
||||
|
|
||||
private val _events = MutableSharedFlow<DevicePluginEvent>( |
|
||||
replay = 0, |
|
||||
extraBufferCapacity = 64, |
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|
||||
) |
|
||||
|
|
||||
override val eventStream: Flow<DevicePluginEvent> = _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<String, Any?>) { |
|
||||
val meta = mutableMapOf<String, Any?>( |
|
||||
"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<String, Any?>) { |
|
||||
_activeSession?.emitFeature("jieli.translation.audio", payload) |
|
||||
} |
|
||||
|
|
||||
override fun onTranslationResult(payload: Map<String, Any?>) { |
|
||||
_activeSession?.emitFeature("jieli.translation.result", payload) |
|
||||
} |
|
||||
|
|
||||
override fun onTranslationLog(payload: Map<String, Any?>) { |
|
||||
_activeSession?.emitFeature("jieli.translation.log", payload) |
|
||||
} |
|
||||
|
|
||||
override fun onTranslationError(payload: Map<String, Any?>) { |
|
||||
_activeSession?.emitError( |
|
||||
"device.feature_failed", |
|
||||
"translation: ${payload["code"]} ${payload["message"] ?: ""}", |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
override fun onDeviceRecordStart(payload: Map<String, Any?>) { |
|
||||
_activeSession?.emitFeature("jieli.deviceRecord.start", payload) |
|
||||
} |
|
||||
|
|
||||
override fun onDeviceRecordAudio(payload: Map<String, Any?>) { |
|
||||
_activeSession?.emitFeature("jieli.deviceRecord.audio", payload) |
|
||||
} |
|
||||
|
|
||||
override fun onDeviceRecordStop(payload: Map<String, Any?>) { |
|
||||
_activeSession?.emitFeature("jieli.deviceRecord.stop", payload) |
|
||||
} |
|
||||
|
|
||||
override fun onDeviceRecordError(payload: Map<String, Any?>) { |
|
||||
_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<DiscoveredDevice> { |
|
||||
// 杰理 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<NativeDeviceSession>() |
|
||||
_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}") |
|
||||
} |
|
||||
} |
|
||||
@ -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<DeviceCapability>, |
|
||||
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<DeviceSessionEvent>( |
|
||||
replay = 0, |
|
||||
extraBufferCapacity = 32, |
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|
||||
) |
|
||||
|
|
||||
override val eventStream: Flow<DeviceSessionEvent> = _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<String, Any?>) { |
|
||||
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<String, Any?>) { |
|
||||
_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<String, Any?>, |
|
||||
): Map<String, Any?> { |
|
||||
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<String, Any?>) ?: 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<int>) 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<Number>) |
|
||||
?.map { it.toByte() }?.toByteArray() |
|
||||
?: byteArrayOf() |
|
||||
// 同步等待(5s 超时)—— RCSP 自定义命令通常 < 500ms 返回。 |
|
||||
val deferred = java.util.concurrent.CompletableFuture<ByteArray?>() |
|
||||
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) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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_<ts>.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<DeviceOtaProgress>( |
|
||||
replay = 1, |
|
||||
extraBufferCapacity = 32, |
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
|
||||
) |
|
||||
|
|
||||
override val progressStream: Flow<DeviceOtaProgress> = _progress.asSharedFlow() |
|
||||
|
|
||||
override val isRunning: Boolean get() = _running.get() || server.otaFeature.isRunning() |
|
||||
|
|
||||
private val listener: JieliEventListener = object : JieliEventAdapter() { |
|
||||
override fun onOtaState(payload: Map<String, Any?>) { |
|
||||
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<String, Any?>) { |
|
||||
// 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<String, ByteArray> { |
|
||||
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<Number>)?.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 |
|
||||
} |
|
||||
} |
|
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -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); |
|
||||
} |
|
||||
} |
|
||||
@ -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<String>, 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<Data.Song>, index: Int, block: (() -> Unit)) { |
|
||||
val midList = ArrayList<String>() |
|
||||
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<String>, 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<String>, 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<String>, 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<String>, 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<String>, 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<String>, 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<String>, 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<String>, block: (() -> Unit)) { |
|
||||
api?.registerEventListener(events, object : IQQMusicApiEventListener.Stub() { |
|
||||
override fun onEvent(event: String, extra: Bundle) { |
|
||||
block() |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
fun unregisterEventListener(api: IQQMusicApi?, events: ArrayList<String>, 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<String>, 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<String>, 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<String>, 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<String>, 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) |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
|
|
||||
} |
|
||||
@ -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<Button>(R.id.bt_bind_service).setOnClickListener { |
|
||||
playService?.bindService() |
|
||||
} |
|
||||
|
|
||||
findViewById<Button>(R.id.bt_start_pcm_mode).setOnClickListener { |
|
||||
playService?.startPcmMode() |
|
||||
Toast.makeText(this, "进入PCM模式", Toast.LENGTH_SHORT).show() |
|
||||
} |
|
||||
|
|
||||
findViewById<Button>(R.id.bt_stop_pcm_mode).setOnClickListener { |
|
||||
playService?.stopPcmMode() |
|
||||
Toast.makeText(this, "退出PCM模式", Toast.LENGTH_SHORT).show() |
|
||||
} |
|
||||
|
|
||||
findViewById<Button>(R.id.bt_play).setOnClickListener { |
|
||||
playService?.resumeOrPause() |
|
||||
} |
|
||||
|
|
||||
findViewById<Button>(R.id.bt_next).setOnClickListener { |
|
||||
playService?.playNext() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override fun onDestroy() { |
|
||||
super.onDestroy() |
|
||||
unbindService(conn) |
|
||||
} |
|
||||
|
|
||||
} |
|
||||
|
|
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue