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"?> |
|||
<resources> |
|||
<string name="facebook_app_id">3829022037361855</string> |
|||
<string name="fb_login_protocol_scheme">fb3829022037361855</string> |
|||
<string name="facebook_client_token">193d0a2b1757c41cfa557ea8e8f21c33</string> |
|||
</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