Browse Source

上传wicue 项目优化

weicu
liwei1dao 4 months ago
parent
commit
f9d63975a6
  1. 17
      lib/data/services/speech_impl/azure_ast_service.dart
  2. 107
      lib/modules/opus_test/controllers/opus_test_controller.dart
  3. 33
      lib/modules/opus_test/views/opus_test_view.dart
  4. 26
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt
  5. 93
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt
  6. 21
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  7. 32
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/DoubaoE2ETranslateHelper.kt
  8. 8
      local_plugins/azure_speech/android/src/main/protos/products/understanding/ast/ast_service.proto
  9. 2
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt
  10. 2
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt
  11. 279
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/JieliAITranslationBridge.kt
  12. 3
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt

17
lib/data/services/speech_impl/azure_ast_service.dart

@ -43,6 +43,17 @@ class AzureAstService extends GetxService implements AstService {
late final String _alibabaAppId;
late final String _alibabaAppURL;
/// 火山 AST VAD 断句控制(直接写死,不读 .env;线上环境配置由服务端下发)。
/// 0 = 使用服务端默认值。
/// _volcanoEndSilenceTimeMs:句尾静音多久触发断句(ms),只影响**识别层**的整句完整度
/// (防止一句话被从中间切开),不影响 Doubao 的 TTS 分段。所以"音频太碎"靠它没用,
/// 实测调到 2500 也没效果,已确认 TTS 分段由模型按译文小句切。
/// 这里保持 1500ms:兼顾识别整句完整,又不过度增加识别延迟。
/// 输出"碎/不连贯"改由 bridge 端的「段合并(coalesce)」处理。
static const int _volcanoEndSilenceTimeMs = 1500;
static const int _volcanoVadSilenceTimeMs = 0;
static const int _volcanoVadSegmentDurationMs = 8000;
final List<String> _defaultSupportedLanguages = ['zh-CN', 'en-US'];
@override
List<String> get supportedLanguages => _defaultSupportedLanguages;
@ -445,6 +456,10 @@ class AzureAstService extends GetxService implements AstService {
'alibabaAppKey': _alibabaAppKey,
'alibabaAppId': _alibabaAppId,
'alibabaAppURL': _alibabaAppURL,
'volcanoEndSilenceTimeMs': _volcanoEndSilenceTimeMs,
'volcanoVadSilenceTimeMs': _volcanoVadSilenceTimeMs,
'volcanoVadSegmentDurationMs': _volcanoVadSegmentDurationMs,
});
_isInitialized = result;
@ -490,6 +505,7 @@ class AzureAstService extends GetxService implements AstService {
// AST TTS 输出帧流(lazy + broadcast,多方订阅安全)
StreamController<AstTtsFrame>? _astTtsController;
StreamSubscription? _astTtsRawSub;
@override
Stream<AstTtsFrame> get astTtsFrames {
final c = _astTtsController ??= StreamController<AstTtsFrame>.broadcast(
@ -505,6 +521,7 @@ class AzureAstService extends GetxService implements AstService {
// 兼容旧 native:未带 final 字段时按 false 处理。
final isFinal = raw['final'] == true;
if (leg == null || pcm is! Uint8List) return;
if (!c.isClosed) {
c.add(AstTtsFrame(leg: leg, pcm: pcm, isFinal: isFinal));
}

107
lib/modules/opus_test/controllers/opus_test_controller.dart

@ -164,7 +164,10 @@ class OpusTestController extends GetxController {
for (var entity in files) {
if (entity is File) {
final path = entity.path.toLowerCase();
if (path.endsWith('.opus') || path.endsWith('.txt')) {
// 翻译落地音频:.wav(编码前原始)+ .opus(编码后实际下发);保留 .txt 兼容
if (path.endsWith('.opus') ||
path.endsWith('.wav') ||
path.endsWith('.txt')) {
final fileSize = await entity.length();
externalFiles.add(PlatformFile(
name: entity.path.split('/').last,
@ -177,7 +180,10 @@ class OpusTestController extends GetxController {
}
}
statusMessage.value = '扫描完成,找到 ${externalFiles.length} 个Opus文件';
// 翻译音频靠文件名(翻译-左/右-序号)排序,按句顺序排列,方便顺序试听
externalFiles.sort((a, b) => a.name.compareTo(b.name));
statusMessage.value = '扫描完成,找到 ${externalFiles.length} 个音频文件';
} catch (e) {
statusMessage.value = '扫描失败: $e';
}
@ -206,6 +212,13 @@ class OpusTestController extends GetxController {
void selectExternalFile(PlatformFile file) {
selectedFiles.clear();
selectedFiles.add(file);
// 翻译落地的 OPUS 由 OpusManager 默认编码(带 head / mono / 16k),
// 自动套上对应解码参数,直接点播放即可还原,方便和同名 .wav 对比。
if (file.name.startsWith('翻译-') && file.name.toLowerCase().endsWith('.opus')) {
hasHeader.value = true;
isMono.value = true;
selectedSampleRate.value = 16000;
}
statusMessage.value = '已选择文件: ${file.name}';
}
@ -239,7 +252,27 @@ class OpusTestController extends GetxController {
}
if (selectedFiles.isEmpty) {
statusMessage.value = '请先选择Opus文件';
statusMessage.value = '请先选择文件';
return;
}
final selectedPath = selectedFiles.first.path!;
// WAV(编码前原始音频)可直接播放,无需 OPUS 解码
if (selectedPath.toLowerCase().endsWith('.wav')) {
try {
statusMessage.value = '正在播放 WAV 原始音频';
await player.setFilePath(selectedPath);
await player.play();
isPlaying.value = true;
player.playerStateStream.listen((state) {
if (state.processingState == ProcessingState.completed) {
isPlaying.value = false;
statusMessage.value = '播放完成';
}
});
} catch (e) {
statusMessage.value = '播放WAV失败: $e';
}
return;
}
@ -248,7 +281,7 @@ class OpusTestController extends GetxController {
statusMessage.value = '正在解码...';
// 使用杰理OPUS库进行解码
final inPath = selectedFiles.first.path!;
final inPath = selectedPath;
// 创建WAV输出文件路径
final tempDir = await getTemporaryDirectory();
@ -409,6 +442,72 @@ class OpusTestController extends GetxController {
}
}
/// 一键清理:删除当前列表里扫描到的全部文件(清空历史落地音频)
Future<void> clearAllFiles() async {
final total = externalFiles.length;
if (total == 0) {
statusMessage.value = '没有可清理的文件';
return;
}
int deleted = 0;
int failed = 0;
// 复制一份遍历,避免边删边改 observable 列表
for (final file in externalFiles.toList()) {
try {
final f = File(file.path!);
if (await f.exists()) await f.delete();
deleted++;
} catch (_) {
failed++;
}
}
externalFiles.clear();
selectedFiles.clear();
if (isPlaying.value) {
await player.stop();
isPlaying.value = false;
}
statusMessage.value = '已清理 $deleted 个文件${failed > 0 ? ',$failed 个失败' : ''}';
}
/// 清理前的确认弹窗
void confirmClearAll(BuildContext context) {
final total = externalFiles.length;
if (total == 0) {
statusMessage.value = '没有可清理的文件';
return;
}
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Get.isDarkMode ? Colors.grey[900] : Colors.white,
title: Text(
'清理全部文件',
style: TextStyle(color: Get.isDarkMode ? Colors.white : Colors.black),
),
content: Text(
'将删除列表中全部 $total 个文件,此操作不可撤销。',
style:
TextStyle(color: Get.isDarkMode ? Colors.white70 : Colors.black87),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
clearAllFiles();
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('全部清理'),
),
],
),
);
}
/// 分享外部文件
Future<void> shareExternalFile(PlatformFile file) async {
try {

33
lib/modules/opus_test/views/opus_test_view.dart

@ -95,26 +95,41 @@ class OpusTestView extends GetView<OpusTestController> {
},
)),
// 外部文件列表标题和刷新按钮
// 外部文件列表标题 + 刷新 / 一键清理
Padding(
padding: EdgeInsets.symmetric(vertical: 8.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'外部存储文件',
'翻译落地音频',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black,
),
),
IconButton(
icon: Icon(
Icons.refresh,
color: isDarkMode ? Colors.blue[300] : Colors.blue,
),
onPressed: controller.loadExternalFiles,
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: '一键清理全部',
icon: Icon(
Icons.delete_sweep,
color: Colors.red[400],
),
onPressed: () =>
controller.confirmClearAll(Get.context!),
),
IconButton(
tooltip: '刷新',
icon: Icon(
Icons.refresh,
color: isDarkMode ? Colors.blue[300] : Colors.blue,
),
onPressed: controller.loadExternalFiles,
),
],
),
],
),
@ -126,7 +141,7 @@ class OpusTestView extends GetView<OpusTestController> {
child: Padding(
padding: EdgeInsets.all(16.h),
child: Text(
'未找到Opus文件',
'未找到音频文件(点刷新扫描翻译落地的 wav / opus)',
style: TextStyle(
color: isDarkMode ? Colors.white54 : Colors.black54,
),

26
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt

@ -95,6 +95,7 @@ class AliyunBailianE2EHelper(
// 诊断计数器
private var pushCount = 0L
private var pushDroppedCount = 0L
private var recvMsgCount = 0L
@ -403,21 +404,13 @@ class AliyunBailianE2EHelper(
"response.audio.delta" -> {
val base64Audio = json.optString("delta", "")
if (base64Audio.isNotEmpty()) {
// Log.d(TAG, "event_id: ${UUID.randomUUID().toString()}, onPartialAudio: $base64Audio")
try {
val audioBytes = android.util.Base64.decode(base64Audio, android.util.Base64.DEFAULT)
if (audioBytes != null && audioBytes.isNotEmpty()) {
// 默认输出 24k,需重采样到 16k
val resampled = resample24kTo16k(audioBytes)
try { fullAudioBuffer.write(resampled) } catch (_: Exception) {}
try {
fullAudioBuffer.write(resampled)
} catch (_: Exception) {}
Log.d(TAG, "onPartialAudio: rawSize=${audioBytes.size} resampledSize=${resampled.size}")
// 使用 processAudioChunk 进行分块回调
processAudioChunk(resampled)
}
} catch (e: Exception) {
Log.e(TAG, "Base64 decode error", e)
@ -426,8 +419,19 @@ class AliyunBailianE2EHelper(
}
"response.done" -> {
// 一次响应结束
Log.d(TAG, "Response done")
// 一次响应(utterance)结束:先 flush audioChunkBuffer 残留,再通知段尾。
// processAudioChunk 按 1280B 对齐,末尾不足一块的字节会滞留在 audioChunkBuffer;
// 不在这里 flush,那部分 PCM 永远丢失。
if (audioChunkBuffer.isNotEmpty()) {
callback?.onPartialAudio(sessionId, audioChunkBuffer)
audioChunkBuffer = ByteArray(0)
}
// 触发 AliyunAstCallback.onSessionFinished → audioWriter.markEnd()
// → JieliAITranslationBridge 把整段 PCM 编 OPUS 下发给耳机。
// 不调此行,PCM 会一直积累到 2MB 兜底限才下发(约 60s 延迟)。
Log.d(TAG, "Response done → flush residual + onSessionFinished sid=$sessionId")
callback?.onSessionFinished(sessionId, fullTextBuffer.toString(), fullAudioBuffer.toByteArray())
fullAudioBuffer.reset()
}
else -> {

93
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt

@ -542,35 +542,7 @@ class DoubaoAstCallback(
private val tag = "DoubaoAstCallback"
private val doubaoFinalSourceTextCache: MutableMap<String, String> = mutableMapOf()
// ─── 段尾信号诊断 ─────────────────────────────────────────────
// 怀疑豆包端到端没有给出段尾 → bridge 永远等不到 isFinal=true → 音频卡在缓冲。
// 这里按 session 维护一份计数,把"豆包到底回调了哪几个方法、各自多少次/多少字节"
// 全部打印出来。一段正常 utterance 期望看到:
// 1) onSessionStarted — 1 次
// 2) onPartialSourceText/onPartialText — 多次(流式)
// 3) onPartialAudio — 多次(流式 TTS)
// 4) onFinalSourceText / onFinalTranslatedText — 各 1 次
// 5) onSessionFinished — 1 次(→ markEnd 触发下游 isFinal=true)
// 实测如果第 5 步缺失,就说明豆包没出段尾信号。
private data class SessionStat(
@Volatile var partialAudioBytes: Long = 0,
@Volatile var partialAudioFrames: Long = 0,
@Volatile var partialTextCount: Long = 0,
@Volatile var partialSourceTextCount: Long = 0,
@Volatile var finalSourceTextHit: Boolean = false,
@Volatile var finalTranslatedTextHit: Boolean = false,
@Volatile var sessionFinishedHit: Boolean = false,
@Volatile var sessionErrorHit: Boolean = false,
@Volatile var firstAudioLogged: Boolean = false,
@Volatile var startedAtMs: Long = System.currentTimeMillis(),
)
private val sessionStats: MutableMap<String, SessionStat> = java.util.concurrent.ConcurrentHashMap()
private fun statOf(sessionId: String): SessionStat =
sessionStats.getOrPut(sessionId) { SessionStat() }
override fun onSessionStarted(sessionId: String) {
sessionStats[sessionId] = SessionStat()
FileLogger.i(tag, "[E2E-DIAG] [$serviceId/$direction] onSessionStarted sid=$sessionId")
eventSender.send(
mapOf(
"type" to "serviceInitialized",
@ -582,8 +554,6 @@ class DoubaoAstCallback(
}
override fun onPartialSourceText(sessionId: String, text: String) {
val s = statOf(sessionId)
s.partialSourceTextCount++
eventSender.send(
mapOf(
"type" to "recognizing",
@ -597,12 +567,6 @@ class DoubaoAstCallback(
}
override fun onFinalSourceText(sessionId: String, finalText: String) {
val s = statOf(sessionId)
s.finalSourceTextHit = true
FileLogger.i(
tag,
"[E2E-DIAG] [$serviceId/$direction] onFinalSourceText sid=$sessionId text=\"$finalText\""
)
val key = "$serviceId:$sessionId"
doubaoFinalSourceTextCache[key] = finalText
eventSender.send(
@ -618,8 +582,6 @@ class DoubaoAstCallback(
}
override fun onPartialText(sessionId: String, text: String) {
val s = statOf(sessionId)
s.partialTextCount++
eventSender.send(
mapOf(
"type" to "translatedInterim",
@ -634,23 +596,6 @@ class DoubaoAstCallback(
}
override fun onPartialAudio(sessionId: String, data: ByteArray) {
val s = statOf(sessionId)
s.partialAudioBytes += data.size
s.partialAudioFrames++
if (!s.firstAudioLogged) {
s.firstAudioLogged = true
FileLogger.i(
tag,
"[E2E-DIAG] [$serviceId/$direction] onPartialAudio FIRST sid=$sessionId size=${data.size}"
)
}
// 每 50 帧或累计每 ~16KB 再打一次进度,避免淹没 logcat
if (s.partialAudioFrames % 50L == 0L) {
FileLogger.d(
tag,
"[E2E-DIAG] [$serviceId/$direction] onPartialAudio progress sid=$sessionId frames=${s.partialAudioFrames} bytes=${s.partialAudioBytes}"
)
}
audioWriter?.write(data)
}
@ -659,17 +604,6 @@ class DoubaoAstCallback(
finalText: String,
finalAudio: ByteArray
) {
val s = statOf(sessionId)
s.sessionFinishedHit = true
val durMs = System.currentTimeMillis() - s.startedAtMs
FileLogger.i(
tag,
"[E2E-DIAG] [$serviceId/$direction] onSessionFinished sid=$sessionId " +
"dur=${durMs}ms partialAudio=${s.partialAudioBytes}B/${s.partialAudioFrames}f " +
"partialText=${s.partialTextCount} partialSrc=${s.partialSourceTextCount} " +
"finalSrcHit=${s.finalSourceTextHit} finalTransHit=${s.finalTranslatedTextHit} " +
"finalText=\"$finalText\" finalAudio=${finalAudio.size}B → markEnd()"
)
eventSender.send(
mapOf(
"type" to "translated",
@ -687,18 +621,9 @@ class DoubaoAstCallback(
// }
// 火山豆包段尾:触发下游 RCSP runtime 立即整段下发。
audioWriter?.markEnd()
sessionStats.remove(sessionId)
}
override fun onSessionError(sessionId: String, code: Int, message: String) {
val s = statOf(sessionId)
s.sessionErrorHit = true
FileLogger.w(
tag,
"[E2E-DIAG] [$serviceId/$direction] onSessionError sid=$sessionId code=$code msg=$message " +
"partialAudio=${s.partialAudioBytes}B/${s.partialAudioFrames}f " +
"finalSrcHit=${s.finalSourceTextHit} finishedHit=${s.sessionFinishedHit} → markEnd()"
)
eventSender.send(
mapOf(
"type" to "error",
@ -711,20 +636,12 @@ class DoubaoAstCallback(
)
// 错误也要把当前 buffer 释放掉,避免残留。
audioWriter?.markEnd()
sessionStats.remove(sessionId)
}
override fun onFinalTranslatedText(sessionId: String, finalText: String) {
val s = statOf(sessionId)
s.finalTranslatedTextHit = true
// 注意:onFinalTranslatedText 是"字幕段"结束(TranslationSubtitleEnd),早于真正
// 的 TTS 段结束(TTSSentenceEnd)。所以这里**不能**调 markEnd —— 此刻 TTS 音频还
// 没来;要等 onTtsSegmentEnd。
FileLogger.i(
tag,
"[E2E-DIAG] [$serviceId/$direction] onFinalTranslatedText sid=$sessionId " +
"text=\"$finalText\" partialAudio=${s.partialAudioBytes}B/${s.partialAudioFrames}f"
)
val key = "$serviceId:$sessionId"
val original = doubaoFinalSourceTextCache.remove(key) ?: ""
eventSender.send(
@ -746,17 +663,7 @@ class DoubaoAstCallback(
* 等不到 isFinal=true,PCM 累积到 2MB 兜底限才下发。
*/
override fun onTtsSegmentEnd(sessionId: String) {
val s = statOf(sessionId)
FileLogger.i(
tag,
"[E2E-DIAG] [$serviceId/$direction] onTtsSegmentEnd sid=$sessionId " +
"audio=${s.partialAudioBytes}B/${s.partialAudioFrames}f → markEnd()"
)
audioWriter?.markEnd()
// 下一段重新累计;保留 session-level 文本统计(仍可能继续触发同 sessionId 的字幕事件)。
s.partialAudioBytes = 0
s.partialAudioFrames = 0
s.firstAudioLogged = false
}
}

21
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -76,6 +76,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
private lateinit var astTtsEventChannel: EventChannel
private var astTtsEventSink: EventChannel.EventSink? = null
// TTS 帧序号:用于 Dart 侧检测帧丢失 / 乱序
private val ttsSeqA = java.util.concurrent.atomic.AtomicLong(0)
private val ttsSeqB = java.util.concurrent.atomic.AtomicLong(0)
// 通话翻译:对方(B)路 E2E 翻译音频(16kHz PCM16 mono)本地播放器
private val callBPcmPlayer = CallBPcmPlayer()
// 设备产品配置 broadcastpeertranslate:true=B 路同时也走外放(耳机一路总会走,外放是叠加)。
@ -86,12 +90,12 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
private fun emitAstTtsFrame(leg: String, data: ByteArray, isFinal: Boolean = false) {
val sink = astTtsEventSink ?: return
// EventChannel 在主线程 send,确保有效;TTS 段一段一段过来,量级在 KB,主线程开销可忽略
val seq = if (leg == "A") ttsSeqA.getAndIncrement() else ttsSeqB.getAndIncrement()
mainHandler.post {
try {
sink.success(mapOf("leg" to leg, "pcm" to data, "final" to isFinal))
sink.success(mapOf("leg" to leg, "pcm" to data, "final" to isFinal, "seq" to seq))
} catch (e: Exception) {
FileLogger.e(tag, "发送 AST TTS 事件失败 leg=$leg: ${e.message}")
FileLogger.e(tag, "EventChannel send FAILED leg=$leg seq=$seq: ${e.message}")
}
}
}
@ -1403,6 +1407,12 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
val alibabaAppKey = call.argument<String>("alibabaAppKey") ?: ""
val alibabaAppId = call.argument<String>("alibabaAppId") ?: ""
val alibabaAppURL = call.argument<String>("alibabaAppURL") ?: ""
// 火山 AST VAD 断句控制;0 = 使用服务端默认值
val volcanoEndSilenceTimeMs = call.argument<Int>("volcanoEndSilenceTimeMs") ?: 0
val volcanoVadSilenceTimeMs = call.argument<Int>("volcanoVadSilenceTimeMs") ?: 0
val volcanoVadSegmentDurationMs = call.argument<Int>("volcanoVadSegmentDurationMs") ?: 0
if (currentAstProvider == "iflytek" ) {
Log.d(tag, "initializeIntegrated: iflytek")
val azureConfig = IflytekIntegratedSpeechService.AzureConfiguration(
@ -1542,7 +1552,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
appKey = appKey,
accessKey = accessKey,
resourceId = resourceId,
clientWaitMs = 60_000L
clientWaitMs = 60_000L,
endSilenceTimeMs = volcanoEndSilenceTimeMs,
vadSilenceTimeMs = volcanoVadSilenceTimeMs,
vadSegmentDurationMs = volcanoVadSegmentDurationMs,
)
Log.d(tag, "initializeIntegrated: volcano $astWsConfig")

32
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/DoubaoE2ETranslateHelper.kt

@ -262,6 +262,11 @@ class DoubaoE2ETranslateHelper(
|| msg.contains("stream is done", ignoreCase = true)
|| msg.contains("session timeout", ignoreCase = true)
|| msg.contains("current state=Finished", ignoreCase = true)
// 服务端优雅关闭 HTTP/2 流(空闲超时 / 负载均衡回收连接):
// "rpc error: code = 13 ... stream terminated by RST_STREAM with error code: NO_ERROR"
// 这是瞬态、可恢复的,不应把整个通话翻译退掉,重连即可。
|| msg.contains("RST_STREAM", ignoreCase = true)
|| msg.contains("stream terminated", ignoreCase = true)
if (isRetryable) {
Log.i(TAG, "onMessage: retryable server error, triggering auto-restart: '$msg'")
restartSession()
@ -286,6 +291,18 @@ class DoubaoE2ETranslateHelper(
// 每段 utterance 的真正音频段尾是 TTSSentenceEnd。下游(接耳机)需要这个信号
// 触发 markEnd 把 bridge buffer 整段下发。
if (event == Type.TTSSentenceEnd) {
// Doubao 服务端有时会在 TTSSentenceEnd 消息里同时携带最后一帧 PCM data;
// 必须先把 data 喂进 processAudioChunk,再 flush audioChunkBuffer,
// 否则最后一帧被丢弃,用户听到的译文会被截断("另一半没了"的根因)。
if (data.isNotEmpty()) {
recvAudio.write(data)
Log.d(TAG, "onMessage: TTSSentenceEnd cocarried audio size=${data.size}")
processAudioChunk(data)
}
if (audioChunkBuffer.isNotEmpty()) {
callback?.onPartialAudio(sessionId, audioChunkBuffer)
audioChunkBuffer = ByteArray(0)
}
Log.d(TAG, "onMessage: TTSSentenceEnd → onTtsSegmentEnd sid=$sessionId")
callback?.onTtsSegmentEnd(sessionId)
return
@ -424,9 +441,6 @@ class DoubaoE2ETranslateHelper(
*/
fun pushAudioData(data: ByteArray): Boolean {
pushCount++
if (pushCount % 100 == 1L) {
Log.d(TAG, "pushAudioData: size=${data.size} isStarted=${isStarted.get()} wsIsNull=${webSocket==null} count=$pushCount pending=${pendingAudioData.size}")
}
if (data.isEmpty()) return true
// 切换/重连窗口:ws 还没就绪 → 暂存到 pendingAudioData,等 onMessage 里第一条服务端消息到达再 flush
if (!isStarted.get() || webSocket == null) {
@ -614,6 +628,11 @@ class DoubaoE2ETranslateHelper(
.setMode("s2s")
.setSourceLanguage(conf.sourceLanguage)
.setTargetLanguage(conf.targetLanguage)
.also {
if (conf.endSilenceTimeMs > 0) it.setEndSilenceTime(conf.endSilenceTimeMs)
if (conf.vadSilenceTimeMs > 0) it.setVadSilenceTime(conf.vadSilenceTimeMs)
if (conf.vadSegmentDurationMs > 0) it.setVadSegmentDuration(conf.vadSegmentDurationMs)
}
.build()
val meta = RequestMeta.newBuilder()
@ -695,5 +714,10 @@ data class Config(
val resourceId: String = "volc.service_type.10053",
val sourceLanguage: String = "zh",
val targetLanguage: String = "en",
val clientWaitMs: Long = 60_000 // 等待会话结束的最大时间,可按需调整
val clientWaitMs: Long = 60_000, // 等待会话结束的最大时间,可按需调整
// VAD 断句控制:0 表示使用服务端默认值
// 默认约 400ms 断句太激进,建议设为 800-1200ms 以获得更完整的句子
val endSilenceTimeMs: Int = 0,
val vadSilenceTimeMs: Int = 0,
val vadSegmentDurationMs: Int = 0,
)

8
local_plugins/azure_speech/android/src/main/protos/products/understanding/ast/ast_service.proto

@ -15,6 +15,14 @@ message ReqParams {
string target_language = 3; // 目标语言
string speaker_id = 4;
// VAD 断句控制(与 au_base.proto data.speech.understanding.ReqParams 共用字段号)
// end_silence_time: 句尾静音时长(ms),超过后强制断句,默认约 400ms,建议 800-1200ms
int32 end_silence_time = 62;
// vad_silence_time: VAD 判定静音的窗口长度(ms)
int32 vad_silence_time = 63;
// vad_segment_duration: 单段最大时长(ms),超过后强制切断,防止一句话太长
int32 vad_segment_duration = 66;
data.speech.understanding.Corpus corpus = 100;
}

2
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt

@ -69,6 +69,8 @@ class CallTranslationModeHandler(
device = device,
mode = sdkMode,
tempDir = File(context.cacheDir, "jieli_translation_tts"),
// 调试落地:整句 TTS 存到外部目录,方便 adb pull 听辨"丢句"在 app 还是 SDK。
dumpDir = context.getExternalFilesDir(null)?.let { File(it, "tts_dump") },
onPcm = { source, pcm ->
val streamId = when (source) {
AudioData.SOURCE_E_SCO_UP_LINK -> TranslationStreams.IN_UPLINK

2
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt

@ -60,6 +60,8 @@ class StereoCallTranslationModeHandler(
device = device,
mode = sdkMode,
tempDir = File(context.cacheDir, "jieli_translation_tts"),
// 调试落地:整句 TTS 存到外部目录,方便 adb pull / opus_test 听辨"丢句"在 app 还是 SDK。
dumpDir = context.getExternalFilesDir(null)?.let { File(it, "tts_dump") },
onPcm = { source, stereoPcm ->
if (source != AudioData.SOURCE_E_SCO_MIX) return@RcspTranslationRuntime
val (left, right) = PcmKit.splitStereo16(stereoPcm)

279
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/JieliAITranslationBridge.kt

@ -60,12 +60,48 @@ class JieliAITranslationBridge(
/** 解码 / 编码失败的统一出口。 */
private val onError: (code: Int, msg: String?) -> Unit,
private val opusPacketSize: Int = if (mode.channel == 2) 80 else 200,
/**
* 调试用:整句 TTS 落地目录。给定后每段交给 SDK 的整句音频会落成 .wav + .opus,
* 用于人工听辨"丢句"发生在 app(我们累积/编码就缺)还是 SDK(我们给全了但耳机没播)。
* null = 不落地。建议传外部目录(context.getExternalFilesDir)方便 adb pull。
*/
private val dumpDir: File? = null,
) : 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
/**
* 每段末尾补的静音时长(ms)。修"每句尾音不完整":
* - 防 OPUS 编码丢掉最后不足一帧的真实尾音(丢的改成丢这段静音);
* - 防设备切到下一段时把当前段尾巴咬掉(咬掉的也是静音)。
* 100ms 在通话里听不出来,但能保住尾音。觉得还缺就加大。0 = 关闭。
*/
private const val TTS_TAIL_PAD_MS = 100
// ─── 下行 TTS 段合并(coalesce)─────────────────────────────────────
// 背景:Doubao E2E 的 TTS 按"译文小句"切,每句一个 TTSSentenceEnd→isFinal,
// 段都很碎(1~2.5s)。碎段各自独立 OPUS 发给 SDK → 设备端解码器频繁 reset、
// 段间留空隙 → 对端听着不连贯。合并:把连续到达的几段 PCM 拼成一整段再发。
/** 合并开关。true = 把"间隔 < COALESCE_IDLE_MS 的连续碎段"在 per-leg buffer 里
* 无缝拼接(PCM 层拼接,无 OPUS 边界)成一整段再下发;A 合 A、B 合 B,绝不跨腿。
* false = 老行为(每个 isFinal 立刻下发)。
* 目的:Doubao 把一句译文切成多个 1~2.5s 碎段,各自独立 OPUS 会让设备解码器频繁 reset、
* 段间留空隙 → 对端听着细碎不连贯。合并后段间无 OPUS 边界、更连贯,并顺带降低推流次数。
* 注意:启用后才激活下面 scheduleIdleFlush 的空闲兜底(关时那段是永不执行的死代码)。
* 有了兜底,isFinal(TTSSentenceEnd 段尾)延迟/丢失时最多卡 COALESCE_IDLE_MS 就下发,
* 不会再一直卡到对端说下一句才补播。 */
private const val COALESCE_ENABLED = true
/** 累计音频达到该时长(ms)就立刻下发:连续说话(段间隔一直 <IDLE)时的最大起播延迟上限,
* 防止一直接话把 buffer 越滚越大、延迟失控。= "连贯优先"档。 */
private const val COALESCE_TARGET_MS = 3000
/** 距上一段语音空闲超过该时长(ms)即认为"这一组说完了",把已累积音频整段下发。
* = "合并窗口":下一段在该时长内到达就拼进当前组,超过就把当前组单独发。
* 也是一组说完后的最大尾延迟。2000ms = 把间隔 2 秒内到达的碎段合并成一个。 */
private const val COALESCE_IDLE_MS = 2000L
}
private val isPcmMode = mode.audioType == Constants.AUDIO_TYPE_PCM
@ -103,7 +139,27 @@ class JieliAITranslationBridge(
private val bufferLock = Any()
private val encodeSeq = AtomicLong(0)
/** 调试统计 */
/** 段合并的去抖定时器(per-leg):空闲超时后把累积的 PCM 整段下发。 */
private val coalesceExecutor =
java.util.concurrent.Executors.newSingleThreadScheduledExecutor { r ->
Thread(r, "tts-coalesce").apply { isDaemon = true }
}
private val idleFutures =
java.util.concurrent.ConcurrentHashMap<String, java.util.concurrent.ScheduledFuture<*>>()
/** 调试落地:整句 TTS 音频目录(懒创建)。null = 关闭。 */
private val resolvedDumpDir: File? = dumpDir?.also { runCatching { if (!it.exists()) it.mkdirs() } }
/**
* 整通会话级 per-leg PCM 落地(disk-backed,避免长通话 OOM)。
* key = 方向标签(翻译-左 / 翻译-右)。每段 flush 出来的 PCM 追加到对应文件,
* stopTranslating 时统一导出 翻译-左/右 的 .wav + .opus 各一份。
*/
private val sessionPcmStreams = mutableMapOf<String, java.io.FileOutputStream>()
private val sessionPcmFiles = mutableMapOf<String, File>()
private val sessionDumpLock = Any()
/** 首帧确认日志用:标记总首帧 / 各 source 首帧是否已打过日志。 */
@Volatile private var rxFirstLogged = false
private val rxFirstPerSource = java.util.concurrent.ConcurrentHashMap<Int, Boolean>()
@ -113,6 +169,13 @@ class JieliAITranslationBridge(
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}")
resolvedDumpDir?.let { dir ->
Log.i(TAG, "[TTS-DUMP] enabled dir=${dir.absolutePath}")
// 清理上次异常退出(未走 stopTranslating)残留的会话级临时 PCM
runCatching {
dir.listFiles { f -> f.name.endsWith(".session.pcm") }?.forEach { it.delete() }
}
}
sdkCallback = callback
if (!tempDir.exists()) tempDir.mkdirs()
upDecoder?.start()
@ -125,6 +188,9 @@ class JieliAITranslationBridge(
override fun stopTranslating() {
Log.i(TAG, "[SDK->APP] stopTranslating")
working = false
// 取消所有未触发的合并定时器(utterance 已被打断,剩余直接丢)。
idleFutures.values.forEach { it.cancel(false) }
idleFutures.clear()
runCatching { upDecoder?.stop() }
runCatching { downDecoder?.stop() }
runCatching { stereoDecoder?.stop() }
@ -133,6 +199,8 @@ class JieliAITranslationBridge(
pcmBuffers.values.forEach { it.reset() }
pcmBuffers.clear()
}
// 会话结束:把整通累积的 per-leg PCM 导出成 翻译-左/右 的 wav + opus 各一份。
finalizeSessionDumps()
sdkCallback?.onStop(0, "stopTranslating")
sdkCallback = null
}
@ -190,30 +258,73 @@ class JieliAITranslationBridge(
postTranslationResult(cb, source, Constants.AUDIO_TYPE_PCM, pcm)
return true
}
// OPUS 模式:累积 + isFinal/超限触发整段编码
val pendingFlush: ByteArray? = synchronized(bufferLock) {
// 合并目标字节(按当前采样率换算):累计达到就立刻下发。
val targetBytes = COALESCE_TARGET_MS * (sampleRateHz / 1000 * 2).coerceAtLeast(1)
var pendingFlush: ByteArray? = null
var armIdle = false
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
when {
// 硬上限兜底:无论如何先放出去,防止内存爆。
buf.size() > PCM_BUFFER_HARD_LIMIT_BYTES -> {
Log.w(TAG, "feedTtsPcm leg=$outputStreamId hit hard limit, force flush")
pendingFlush = buf.toByteArray(); buf.reset()
}
// 不合并:老行为,isFinal 立即下发。
!COALESCE_ENABLED && isFinal -> {
val out = buf.toByteArray(); buf.reset()
if (out.isNotEmpty()) pendingFlush = out
}
// 合并:累计够长了立刻发,否则等空闲去抖(期待后续段拼进来)。
COALESCE_ENABLED && isFinal -> {
if (buf.size() >= targetBytes) {
val out = buf.toByteArray(); buf.reset()
if (out.isNotEmpty()) pendingFlush = out
} else if (buf.size() > 0) {
armIdle = true
}
}
}
}
// 正在收音频 = 没空闲,取消挂着的空闲定时器,避免一段 burst 中途被 flush 切断。
if (pcm.isNotEmpty()) cancelIdleFlush(outputStreamId)
if (pendingFlush != null) {
encodeAndDeliverAsync(cb, source, outputStreamId, pendingFlush)
cancelIdleFlush(outputStreamId)
encodeAndDeliverAsync(cb, source, outputStreamId, pendingFlush!!)
} else if (armIdle) {
scheduleIdleFlush(cb, source, outputStreamId)
}
return true
}
/** 重置/启动 per-leg 空闲定时器:空闲 [COALESCE_IDLE_MS] 后把累积 PCM 整段下发。 */
private fun scheduleIdleFlush(cb: AITranslationCallback, source: Int, leg: String) {
idleFutures.remove(leg)?.cancel(false)
val task = Runnable {
val out: ByteArray? = synchronized(bufferLock) {
val buf = pcmBuffers[leg]
if (buf != null && buf.size() > 0) buf.toByteArray().also { buf.reset() } else null
}
if (out != null && working) {
encodeAndDeliverAsync(cb, source, leg, out)
}
}
val future = runCatching {
coalesceExecutor.schedule(task, COALESCE_IDLE_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
}.getOrNull()
if (future != null) idleFutures[leg] = future
}
private fun cancelIdleFlush(leg: String) {
idleFutures.remove(leg)?.cancel(false)
}
private fun postTranslationResult(
cb: AITranslationCallback,
source: Int,
@ -237,8 +348,14 @@ class JieliAITranslationBridge(
val seq = encodeSeq.incrementAndGet()
val pcmFile = File(tempDir, "tts_${source}_$seq.pcm")
val opusFile = File(tempDir, "tts_${source}_$seq.opus")
// 调试落地:把本段 PCM 追加进"整通会话级"per-leg 累积(翻译-左/右 各一份),
// 会话结束(stopTranslating)时统一导出 wav + opus,不再每段一个文件。
appendSessionPcm(leg, pcmBytes)
// 末尾补静音再编码:保住尾音(防编码丢尾帧 / 防设备切段咬尾)。
val padBytes = TTS_TAIL_PAD_MS * (sampleRateHz / 1000 * 2).coerceAtLeast(1)
val toEncode = if (padBytes > 0) pcmBytes + ByteArray(padBytes) else pcmBytes
try {
pcmFile.writeBytes(pcmBytes)
pcmFile.writeBytes(toEncode)
} catch (e: Throwable) {
Log.w(TAG, "encodeAndDeliver leg=$leg seq=$seq write pcm failed: ${e.message}")
return
@ -249,19 +366,16 @@ class JieliAITranslationBridge(
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() }
val opusBytes = runCatching { if (opusFile.exists()) opusFile.readBytes() else null }.getOrNull()
runCatching { encoder.release() }
runCatching { pcmFile.delete() }
runCatching { opusFile.delete() }
if (opusBytes == null || opusBytes.isEmpty()) {
Log.w(TAG, "encodeAndDeliver leg=$leg seq=$seq: empty opus output")
return
}
postTranslationResult(cb, source, Constants.AUDIO_TYPE_OPUS, opusBytes)
}
override fun onError(code: Int, message: String?) {
Log.w(TAG, "encodeAndDeliver leg=$leg seq=$seq encodeFile error code=$code msg=$message")
@ -278,4 +392,113 @@ class JieliAITranslationBridge(
TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK
else -> AudioData.SOURCE_PHONE_MIC
}
// ─── 调试落地 ─────────────────────────────────────────────────────────
/** leg → 中文方向标签,用于文件名:上行=翻译-左,下行=翻译-右,其它=翻译-mic。 */
private fun legLabel(leg: String): String = when (leg) {
TranslationStreams.OUT_UPLINK -> "翻译-左"
TranslationStreams.OUT_DOWNLINK -> "翻译-右"
else -> "翻译-mic"
}
/** 把本段 PCM 追加进对应方向的会话级临时 PCM 文件(disk-backed)。 */
private fun appendSessionPcm(leg: String, pcm: ByteArray) {
val dir = resolvedDumpDir ?: return
if (pcm.isEmpty()) return
val label = legLabel(leg)
synchronized(sessionDumpLock) {
val os = sessionPcmStreams[label] ?: runCatching {
val f = File(dir, "$label.session.pcm")
runCatching { f.delete() }
java.io.FileOutputStream(f, false).also {
sessionPcmStreams[label] = it
sessionPcmFiles[label] = f
}
}.getOrNull() ?: return
runCatching { os.write(pcm) }
.onFailure { Log.w(TAG, "[TTS-DUMP] append $label failed: ${it.message}") }
}
}
/**
* 会话结束:关闭各方向 PCM 流,把整通累积的 PCM 导出成
* 翻译-左/右 的 .wav(编码前原始)+ .opus(编码后)各一份。
*/
private fun finalizeSessionDumps() {
val dir = resolvedDumpDir ?: return
val files: Map<String, File>
synchronized(sessionDumpLock) {
sessionPcmStreams.values.forEach { runCatching { it.flush(); it.close() } }
sessionPcmStreams.clear()
files = sessionPcmFiles.toMap()
sessionPcmFiles.clear()
}
files.forEach { (label, pcmFile) ->
if (!pcmFile.exists() || pcmFile.length() == 0L) {
runCatching { pcmFile.delete() }
return@forEach
}
// 1) WAV(同步):标准 44 字节头 + 整通 PCM
runCatching {
val wav = File(dir, "$label.wav")
writeWavFromPcmFile(wav, pcmFile)
val ms = pcmFile.length() / (sampleRateHz / 1000 * 2).coerceAtLeast(1)
Log.i(TAG, "[TTS-DUMP] session wav $label pcm=${pcmFile.length()}B dur=${ms}ms → ${wav.absolutePath}")
}.onFailure { Log.w(TAG, "[TTS-DUMP] session wav $label failed: ${it.message}") }
// 2) OPUS(异步编码):整通 PCM 编一份;完成后删临时 PCM
val opusOut = File(dir, "$label.opus")
val enc = OpusManager()
runCatching {
enc.encodeFile(pcmFile.absolutePath, opusOut.absolutePath, object : OnStateCallback {
override fun onStart() {}
override fun onComplete(path: String?) {
runCatching { enc.release() }
runCatching { pcmFile.delete() }
Log.i(TAG, "[TTS-DUMP] session opus $label → ${opusOut.absolutePath}")
}
override fun onError(code: Int, message: String?) {
runCatching { enc.release() }
runCatching { pcmFile.delete() }
Log.w(TAG, "[TTS-DUMP] session opus $label encode error code=$code msg=$message")
}
})
}.onFailure {
runCatching { enc.release() }
runCatching { pcmFile.delete() }
Log.w(TAG, "[TTS-DUMP] session opus $label start failed: ${it.message}")
}
}
}
/** 流式写 WAV:先写 44 字节头(按 pcm 文件大小),再把 PCM 文件内容拷进去(不占内存)。 */
private fun writeWavFromPcmFile(wavFile: File, pcmFile: File) {
val pcmLen = pcmFile.length().toInt()
wavFile.outputStream().use { os ->
os.write(wavHeader(pcmLen, sampleRateHz, 1))
pcmFile.inputStream().use { it.copyTo(os) }
}
}
/** 标准 44 字节 PCM WAV 头(16bit)。 */
private fun wavHeader(pcmSize: Int, sampleRate: Int, channels: Int): ByteArray {
val bitsPerSample = 16
val byteRate = sampleRate * channels * bitsPerSample / 8
val blockAlign = channels * bitsPerSample / 8
val header = java.nio.ByteBuffer.allocate(44).order(java.nio.ByteOrder.LITTLE_ENDIAN)
header.put("RIFF".toByteArray(Charsets.US_ASCII))
header.putInt(36 + pcmSize)
header.put("WAVE".toByteArray(Charsets.US_ASCII))
header.put("fmt ".toByteArray(Charsets.US_ASCII))
header.putInt(16) // fmt chunk size (PCM)
header.putShort(1.toShort()) // audio format = PCM
header.putShort(channels.toShort())
header.putInt(sampleRate)
header.putInt(byteRate)
header.putShort(blockAlign.toShort())
header.putShort(bitsPerSample.toShort())
header.put("data".toByteArray(Charsets.US_ASCII))
header.putInt(pcmSize)
return header.array()
}
}

3
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt

@ -45,6 +45,8 @@ class RcspTranslationRuntime(
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,
/** 调试用:整句 TTS 落地目录,透传给 [JieliAITranslationBridge]。null = 不落地。 */
private val dumpDir: File? = null,
) {
companion object {
@ -58,6 +60,7 @@ class RcspTranslationRuntime(
onPcm = onPcm,
onError = onError,
opusPacketSize = opusPacketSize,
dumpDir = dumpDir,
)
private val translationImpl = TranslationImpl(btManager, bridge, device)

Loading…
Cancel
Save