开口即查:静音后开口1.2s立即判换人并换行(用户三选:静音后开口/一票制/立即占位编号):
- 录制循环:≥0.6s静音后的开口攒1.2s语音,快模型(base)一次判定——贴当前人(≥0.5)不动;命中注册表其他人→立即换行+已知标签;都不像→当场 nextAutoLabel 占号+fastRegistry注册,不等endpoint - 落地链:ONSET-SWITCH→onOnsetSwitch(底行有在途句→pendingForceEndpoint强定格+onsetClosePending封行;否则直接封行)→appendNewLine消费pendingOnsetLabel(pendingOnsetLabel=新行标签+lastConfirmedLabel沿用);onsetSeq在换行/会话开始时作废在途判定 - 防双号:postSpeakerVerdict新号分配前先查fastRegistry(0.4),命中则复用开口占号并补全双空间镜像 - 换行延迟4~6s→~1.5s;无缝插话仍由SWITCH-FORCE streak兜底;onsetEnabled开关可A/B关闭 - 老机制全保留:滚动窗口/误断防护/EMA/60s空闲换行蹲;ktlint parse通过
This commit is contained in:
parent
2bdeee2147
commit
86cea18dcb
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue