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 a04a9a9..8ee8d51 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt @@ -208,6 +208,16 @@ class MainActivity : AppCompatActivity() { private val anchorMinSamples = 32000 private val judgeMinSamples = 48000 private val enableRollingSwitch = true // A/B 诊断:关掉=只用自然 endpoint 断句 + // 开口即查(2026-09-16,用户三选:静音后开口/一票制快切/立即占位编号): + // ≥0.6s 静音后的开口 → 攒 1.2s 语音做一次快模型判定,贴当前人不动/ + // 像别人换行上已知标签/都不像换行当场占号。换行延迟 4~6s → ~1.5s。 + // 无缝插话(无静音)仍由 SWITCH-FORCE streak 机制兜底。 + private val onsetEnabled = true + private val onsetGapSamples = 9600 // 0.6s 前置静音 + private val onsetMinSamples = 19200 // 1.2s 开口窗 + @Volatile private var onsetSeq = 0L // 行切换/会话开始作废在途开口判定 + private var pendingOnsetLabel: String? = null // 主线程:下一新行预置标签 + private var onsetClosePending = false // 主线程:强切后旧句定格即封底行 @Volatile private var rollingLowHits = 0 // 无缝插话确证(2026-09-15):SUSPECT 等停顿会被"对方话没停就接话"憋死 // (日志实锤:simA 连4窗 0.14~0.19,SUSPECT 连 armed 三次全 expired, @@ -520,6 +530,9 @@ class MainActivity : AppCompatActivity() { rollingLowHits = 0 rollingLowStreak = 0 pendingForceEndpoint = false + onsetSeq++ + pendingOnsetLabel = null + onsetClosePending = false checkSerial++ activeLabelForWindow = null lastConfirmedLabel = null @@ -616,16 +629,59 @@ class MainActivity : AppCompatActivity() { val checkStep = (sampleRateInHz * 2f).toInt() var newSinceCheck = 0 var silenceRun = 0 + var onsetRun = 0 // 开口即查:静音后已累积的开口语音采样数 while (isRecording) { val ret = audioRecord?.read(buffer, 0, buffer.size) if (ret != null && ret > 0) { val samples = FloatArray(ret) { buffer[it] / 32768.0f } - silenceRun = if (rmsOf(samples) < silenceRms) + val blockRms = rmsOf(samples) + val wasSilent = silenceRun >= onsetGapSamples + silenceRun = if (blockRms < silenceRms) silenceRun + ret else 0 pushChunk(samples) window.push(samples) + // 开口即查(2026-09-16):≥0.6s 静音后的开口攒 1.2s 立即判一次 + if (onsetEnabled && enableRollingSwitch && speakersEnabled) { + if (blockRms >= silenceRms) { + if (wasSilent && onsetRun == 0) onsetRun = ret + else if (onsetRun > 0) onsetRun += ret + if (onsetRun >= onsetMinSamples) { + onsetRun = 0 + val win = window.snapshot() + val n = onsetMinSamples.coerceAtMost(win.size) + if (n >= onsetMinSamples && rmsOf(win) >= minWindowRms) { + val slice = win.copyOfRange(win.size - n, win.size) + val rseq = onsetSeq + speakerCheckHandler.post { + if (rseq != onsetSeq) return@post + val emb = SpeakerRecognition.computeEmbeddingFast( + slice, sampleRateInHz) ?: return@post + if (rseq != onsetSeq) return@post + val anchor = windowRefEmb ?: return@post + val simA = SpeakerRecognition.similarity(emb, anchor) + if (simA >= emaAbsorbMin) return@post + val active = activeLabelForWindow + val known = rollingSpeakerSearch(emb) + if (known != null && known == active) return@post + val label = known + ?: SpeakerRecognition.nextAutoLabel().also { + SpeakerRecognition.fastRegistry.addAuto(it, emb) + } + Log.i(TAG, "ONSET-SWITCH simA=${"%.2f".format(simA)}" + + " label=$label kind=" + + (if (known != null) "known" else "new") + + " active=${active ?: "-"}") + uiHandler.post { onOnsetSwitch(label) } + } + } + } + } else { + onsetRun = 0 + } + } + // P2(2026-09-11):跨句常驻检测,参考 embedding 为"行级" // (appendNewLine 时重置)——新人开口后第一个周期内即可触发换行, // 句间不再依赖 endpoint 的迟到判定 @@ -868,10 +924,17 @@ class MainActivity : AppCompatActivity() { ?: nearestAttribution( emb, SpeakerRecognition.registry, verdictFallbackMin )) - ?: SpeakerRecognition.nextAutoLabel().also { - SpeakerRecognition.registry.addAuto(it, emb) - if (fastEmb != null) - SpeakerRecognition.fastRegistry.addAuto(it, fastEmb) + ?: run { + // 开口即查已在 fast 空间给新人占过号 → 优先复用,防同一 + // 人双号(开口 S0 / 结算 S1);命中即顺带补全准确空间镜像 + val fh = if (fastEmb != null) + SpeakerRecognition.fastRegistry.search( + fastEmb, fastSearchThreshold) else null + (fh ?: SpeakerRecognition.nextAutoLabel()).also { + SpeakerRecognition.registry.addAuto(it, emb) + if (fastEmb != null) + SpeakerRecognition.fastRegistry.addAuto(it, fastEmb) + } } // CAM++ 校准数据回路:打印判定前的原始余弦最高分(含注册表规模)。 // 判错人时看 best 落在哪:best≥0.5 且人不对 → speakerThreshold @@ -1510,6 +1573,24 @@ class MainActivity : AppCompatActivity() { private fun colorIdxFor(label: String): Int = labelColorIdx.getOrPut(label) { colorSeq++ % speakerPalette.size } + // 开口即查的切换落地(主线程):ONSET 判定换了人 → 让下一句另起新行。 + // 底行还有在途句(旧人话尾仍在流里)→ 借强 endpoint 定格它,定格完成 + // 再封行(onsetClosePending,见 finalizeSentence);已在途句为空则直接 + // 封行。新标签经 pendingOnsetLabel 交给 appendNewLine。 + private fun onOnsetSwitch(label: String) { + val list = if (historyMode) stashedLiveLines ?: subtitleLines else subtitleLines + val bottom = list.lastOrNull() ?: return + pendingOnsetLabel = label + if (!bottom.open) return // 底行已封:下一行自然消费预置标签 + val busy = bottom.sentences.any { !it.finalized } + if (busy) { + onsetClosePending = true + pendingForceEndpoint = true + } else { + bottom.open = false + } + } + private fun appendNewLine(label: String?): SubtitleLine { // 新行出现即封掉之前的开放行,并把其中"未定格的进行中句子"整体迁移 // 过来 —— 硬保证 in-progress 只可能发生在最底行(修复:声纹判定搬家后, @@ -1523,16 +1604,25 @@ class MainActivity : AppCompatActivity() { if (migrating.isNotEmpty()) { old?.sentences?.removeAll(migrating.toSet()) } + // 开口即查预置标签(ONSET-SWITCH 判了新说话人)→ 新行直接采用, + // 并成为"最近确认说话人"(后续空闲新行沿用);不等 endpoint 权威判定 + val eff = pendingOnsetLabel ?: label + if (pendingOnsetLabel != null) { + pendingOnsetLabel = null + lastConfirmedLabel = eff + Log.i(TAG, "ONSET-LINE new line label=$eff") + } val line = SubtitleLine( id = lineIdSeq.incrementAndGet(), - label = label, - colorIdx = colorIdxFor(label ?: "__unknown__"), + label = eff, + colorIdx = colorIdxFor(eff ?: "__unknown__"), ) // P2:行级参考——新行开启即重置,下一句(新人)的第一窗重新定基准 windowRefEmb = null windowPrevEmb = null rollingLowHits = 0 rollingLowStreak = 0 // 新行=新锚点,连续低分从头算 + onsetSeq++ // 作废在途开口判定(锚点已换) list.add(line) if (!historyMode) { subtitleAdapter.notifyItemInserted(subtitleLines.size - 1) @@ -1593,6 +1683,12 @@ class MainActivity : AppCompatActivity() { // (SKIP_SHORT 等情况 submitRefineTask 已写对账日志,不重复记) sentenceById.remove(sid) } + // 开口即查强切:旧句已定格,此刻封底行,让新说话人首句另起带 + // 预置标签的新行(由后续 onPartial 尾部创建) + if (onsetClosePending) { + onsetClosePending = false + subtitleLines.lastOrNull()?.takeIf { it.open }?.open = false + } sentence.line?.let { notifyLine(it) } autoScroll() }