diff --git a/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt b/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt index 5c80cb1..8066916 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt @@ -221,6 +221,13 @@ class MainActivity : AppCompatActivity() { private val onsetMinSamples = 19200 // 1.2s 开口窗 // 喂料恒定滞后(2026-09-16):识别流滞后 1.2s 喂入,切点前的音频可改道; // 全局字幕延迟 ~1.2s 的代价换取换人零串行 + // 快速滚动通道(2026-09-16 用户拍板乐观快切):2s 尾窗/1s 周期/偏离 + // 0.32 即切;准确复核只善后,切错由判定链并回自愈 + private val fastRollEnabled = true + private val fastRollDrop = 0.32f + private val fastStepSamples = 16000 // 1s + private val fastWinSamples = 32000 // 2s 尾窗 + private val fastMinSamples = 24000 // ≥1.5s 才判 private val feedLagSamples = 24000 // 1.5s:覆盖判定链(1.2s采集+计算)的 // 余量,开口渗漏≈0;全局字幕延迟即此值,调参范围 1.2~1.5s @Volatile private var onsetSeq = 0L // 行切换/会话开始作废在途开口判定 @@ -636,6 +643,7 @@ class MainActivity : AppCompatActivity() { var newSinceCheck = 0 var silenceRun = 0 var onsetRun = 0 // 开口即查:静音后已累积的开口语音采样数 + var fastSince = 0 // 快速滚动通道:1s 周期的采样计数 // 恒定滞后喂料(2026-09-16):音频先进 pendingBuf,滞后 feedLagSamples // 再喂识别流/样本缓冲(窗口分析保持实时)。换人决定时冻结缓冲, // 断句后补喂给新流——新人开头音频从不进入旧句子。 @@ -688,48 +696,25 @@ class MainActivity : AppCompatActivity() { speakerCheckHandler.post { if (rseq != onsetSeq) return@post val anchor = windowRefEmb ?: return@post - // 一级:快锚点相似度(灵敏);贴近锚点=同人,免准确复核 val fastEmb = SpeakerRecognition.computeEmbeddingFast( slice, sampleRateInHz) ?: return@post if (rseq != onsetSeq) return@post val simFast = SpeakerRecognition.similarity( fastEmb, anchor) if (simFast >= emaAbsorbMin) return@post - // 二级:准确空间复核(2026-09-16 误报实锤:A 暂停 - // 续说,fast 短窗 simA<0.5 误判新人→A 的续说句被切 - // 成"已识别人2"首句)。同人暂停后短窗声纹弱,fast - // 不可信;CAM++ 准确空间同人 0.77~0.93 强分离: - // 注册表认得出(且是当前人)→ 撤销切换;认得出别人 - // → 切给他;谁都认不出 → 才当新人 - val emb = SpeakerRecognition.computeEmbedding( - slice, sampleRateInHz) ?: return@post - if (rseq != onsetSeq) return@post + // 乐观快切(2026-09-16 用户拍板:第一次识别 + // 先切,出错由判定链并回自愈;准确复核不再 + // 挡在切换前,只做善后) val active = activeLabelForWindow - val known = SpeakerRecognition.registry.search( - emb, speakerThreshold) - ?: nearestAttribution( - emb, SpeakerRecognition.registry, - verdictFallbackMin) - if (known != null && known == active) { - // 复核撤销(同人暂停续说):留痕便于验证 - Log.i(TAG, "ONSET-CANCEL fastSim=" + - "%.2f".format(simFast) + - " label=$known(active)") - return@post + val label = SpeakerRecognition.nextAutoLabel().also { + SpeakerRecognition.fastRegistry.addAuto(it, fastEmb) } - val label = known - ?: SpeakerRecognition.nextAutoLabel().also { - SpeakerRecognition.registry.addAuto(it, emb) - SpeakerRecognition.fastRegistry.addAuto( - it, fastEmb) - } Log.i(TAG, "ONSET-SWITCH fastSim=" + "%.2f".format(simFast) + - " label=$label kind=" + - (if (known != null) "known" else "new") + - " active=${active ?: "-"}") + " label=$label active=${active ?: "-"}") cutRequested = true uiHandler.post { onOnsetSwitch(label) } + accurateRecheckAfterSwitch(slice, label, active, "ONSET") } } } @@ -738,6 +723,51 @@ class MainActivity : AppCompatActivity() { } } + // 快速滚动通道(2026-09-16 用户拍板:第一次识别先切、错由 + // 并回自愈):2s 尾窗/1s 周期;快模型偏离锚点即乐观快切; + // 准确复核只善后(注册/留痕);3s 冷却与强切通道共用 + if (fastRollEnabled && enableRollingSwitch && speakersEnabled && + !frozen + ) { + fastSince += ret + if (fastSince >= fastStepSamples) { + fastSince = 0 + val winF = window.snapshot() + val nF = fastWinSamples.coerceAtMost(winF.size) + if (nF >= fastMinSamples && rmsOf(winF) >= minWindowRms) { + val tailF = winF.copyOfRange(winF.size - nF, winF.size) + val rseq = onsetSeq + speakerCheckHandler.post { + if (rseq != onsetSeq) return@post + val anchor = windowRefEmb ?: return@post + val emb = SpeakerRecognition.computeEmbeddingFast( + tailF, sampleRateInHz) ?: return@post + if (rseq != onsetSeq) return@post + val sim = SpeakerRecognition.similarity(emb, anchor) + if (sim >= fastRollDrop) return@post + val nowMs = SystemClock.elapsedRealtime() + if (nowMs - lastForceSwitchAtMs < 3_000L) { + Log.i(TAG, "FASTROLL suppressed (cooldown)") + return@post + } + lastForceSwitchAtMs = nowMs + val active = activeLabelForWindow + val label = SpeakerRecognition.nextAutoLabel().also { + SpeakerRecognition.fastRegistry.addAuto(it, emb) + } + Log.i(TAG, "FASTROLL-SWITCH sim=" + + "%.2f".format(sim) + + " label=$label active=${active ?: "-"}") + cutRequested = true + uiHandler.post { onOnsetSwitch(label) } + accurateRecheckAfterSwitch(tailF, label, active, "FASTROLL") + } + } + } + } else { + fastSince = 0 + } + // P2(2026-09-11):跨句常驻检测,参考 embedding 为"行级" // (appendNewLine 时重置)——新人开口后第一个周期内即可触发换行, // 句间不再依赖 endpoint 的迟到判定 @@ -1759,6 +1789,26 @@ class MainActivity : AppCompatActivity() { Log.i(TAG, "ONSET-CLOSE no-sentence forced endpoint") } + /** 乐观切换后的准确空间善后(2026-09-16 用户拍板:先切后改):切换已 + * 发出,本函数只做善后——认得出是当前人 → 留痕(切错由判定链"并回" + * 自愈);真新人 → 补写准确空间(防后续重发号);认出别人 → 留痕(判定 + * 链迁回)。在 speakerCheckHandler 线程执行。 */ + private fun accurateRecheckAfterSwitch( + samples: FloatArray, label: String, active: String?, tag: String, + ) { + val acc = SpeakerRecognition.computeEmbedding(samples, sampleRateInHz) + ?: return + val pre = SpeakerRecognition.registry.search(acc, speakerThreshold) + ?: nearestAttribution(acc, SpeakerRecognition.registry, verdictFallbackMin) + when { + pre != null && pre == active -> + Log.i(TAG, tag + "-CANCEL-LATE known=$pre(active) revert-via-verdict") + pre != null -> + Log.i(TAG, tag + "-RECHECK known=$pre label=$label (verdict migrates)") + else -> SpeakerRecognition.registry.addAuto(label, acc) + } + } + private fun appendNewLine(label: String?): SubtitleLine { // 新行出现即封掉之前的开放行,并把其中"未定格的进行中句子"整体迁移 // 过来 —— 硬保证 in-progress 只可能发生在最底行(修复:声纹判定搬家后,