diff --git a/lib/data/services/speech_impl/azure_ast_service.dart b/lib/data/services/speech_impl/azure_ast_service.dart index 4f7cb85de..2a5b286ba 100644 --- a/lib/data/services/speech_impl/azure_ast_service.dart +++ b/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 _defaultSupportedLanguages = ['zh-CN', 'en-US']; @override List 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? _astTtsController; StreamSubscription? _astTtsRawSub; + @override Stream get astTtsFrames { final c = _astTtsController ??= StreamController.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)); } diff --git a/lib/modules/opus_test/controllers/opus_test_controller.dart b/lib/modules/opus_test/controllers/opus_test_controller.dart index c93f2bac4..4344bae21 100644 --- a/lib/modules/opus_test/controllers/opus_test_controller.dart +++ b/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 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 shareExternalFile(PlatformFile file) async { try { diff --git a/lib/modules/opus_test/views/opus_test_view.dart b/lib/modules/opus_test/views/opus_test_view.dart index de02e5138..8d70e350f 100644 --- a/lib/modules/opus_test/views/opus_test_view.dart +++ b/lib/modules/opus_test/views/opus_test_view.dart @@ -95,26 +95,41 @@ class OpusTestView extends GetView { }, )), - // 外部文件列表标题和刷新按钮 + // 外部文件列表标题 + 刷新 / 一键清理 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 { child: Padding( padding: EdgeInsets.all(16.h), child: Text( - '未找到Opus文件', + '未找到音频文件(点刷新扫描翻译落地的 wav / opus)', style: TextStyle( color: isDarkMode ? Colors.white54 : Colors.black54, ), diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt index d100a8ad1..69fd4b874 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt +++ b/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 -> { diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt index 60ff8eb8d..3ce4ef883 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt +++ b/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 = 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 = 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 } } diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 1f2453105..72a814811 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/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("alibabaAppKey") ?: "" val alibabaAppId = call.argument("alibabaAppId") ?: "" val alibabaAppURL = call.argument("alibabaAppURL") ?: "" + + // 火山 AST VAD 断句控制;0 = 使用服务端默认值 + val volcanoEndSilenceTimeMs = call.argument("volcanoEndSilenceTimeMs") ?: 0 + val volcanoVadSilenceTimeMs = call.argument("volcanoVadSilenceTimeMs") ?: 0 + val volcanoVadSegmentDurationMs = call.argument("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") diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/DoubaoE2ETranslateHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/DoubaoE2ETranslateHelper.kt index d2beac3a0..e583bec92 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/DoubaoE2ETranslateHelper.kt +++ b/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, ) diff --git a/local_plugins/azure_speech/android/src/main/protos/products/understanding/ast/ast_service.proto b/local_plugins/azure_speech/android/src/main/protos/products/understanding/ast/ast_service.proto index 2117d6d27..cc831812c 100644 --- a/local_plugins/azure_speech/android/src/main/protos/products/understanding/ast/ast_service.proto +++ b/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; } diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt index 087663f3c..54a92432c 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt +++ b/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 diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt index 2bafa2e3b..3fb6f16bb 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt +++ b/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) diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/JieliAITranslationBridge.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/JieliAITranslationBridge.kt index cfb04d34f..6d34ea6bf 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/JieliAITranslationBridge.kt +++ b/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)就立刻下发:连续说话(段间隔一直 + Thread(r, "tts-coalesce").apply { isDaemon = true } + } + private val idleFutures = + java.util.concurrent.ConcurrentHashMap>() + + /** 调试落地:整句 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() + private val sessionPcmFiles = mutableMapOf() + private val sessionDumpLock = Any() + + /** 首帧确认日志用:标记总首帧 / 各 source 首帧是否已打过日志。 */ @Volatile private var rxFirstLogged = false private val rxFirstPerSource = java.util.concurrent.ConcurrentHashMap() @@ -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 + 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() + } } diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt index aedd7bdc5..4261abb83 100644 --- a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt +++ b/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)