切后命名方案三(用户拍板):名字不诞生于开火,只诞生于切后纯净窗:

- 开火(FASTROLL/ONSET)不再发号发注册——只切行,grayLinePending 灰行先开(未识别);switchSerial/switchPrevActive 记录本切次
- deferredNaming(T+1.5~3.5s,取切后尾1.5s纯净素材,补喂机制保证全为新说法人声音):强匹配开火前主≥0.5→撤销误切;匹配已知人→上其名;确认无人(<0.42)→发新号双空间注册(素材干净永不产幽灵);灰色带→留灰由判定链收编
- adoptSwitchLabel(灰行就地冠名/未建行走预置);undoSwitch(重开抹迹/改签并回);serial 每次落地后自增防双发
- 根治病史:16:21 S6潮(单人在2.5min被8连发开火发出S7~S21七个幽灵,源自开火窗混合素材);新日志锚 NAMING-UNDO/ADOPT/NEW/WEAK
This commit is contained in:
liwenhao2 2026-09-16 16:43:54 +08:00
parent 31762d4627
commit 2725c62b14
1 changed files with 124 additions and 84 deletions

View File

@ -221,10 +221,6 @@ class MainActivity : AppCompatActivity() {
private val onsetMinSamples = 19200 // 1.2s 开口窗 private val onsetMinSamples = 19200 // 1.2s 开口窗
// 喂料恒定滞后(2026-09-16):识别流滞后 1.2s 喂入,切点前的音频可改道; // 喂料恒定滞后(2026-09-16):识别流滞后 1.2s 喂入,切点前的音频可改道;
// 全局字幕延迟 ~1.2s 的代价换取换人零串行 // 全局字幕延迟 ~1.2s 的代价换取换人零串行
// 主动撤销的强度门槛(2026-09-16 实锤:短插嘴混合窗复核出 0.5~0.6 弱匹配,
// 二元"认得出"误撤了 0.04 深低的真实异常;同人确认分实测 0.70~1.00,
// 0.68 取安全带) —— 复核低于此分只留痕不撤销,让切换立住
private val revertCancelMin = 0.68f
// 快速滚动通道(2026-09-16 用户三次拍板后终定 C:维持 2s 窗——最快档, // 快速滚动通道(2026-09-16 用户三次拍板后终定 C:维持 2s 窗——最快档,
// 接受误切闪烁,由并回自愈兜底)。窗长演化留档:2s(风暴,单窗20发/5min) // 接受误切闪烁,由并回自愈兜底)。窗长演化留档:2s(风暴,单窗20发/5min)
// →3s(折中)→ 终选回 2s;护持:连续两窗/注册表复用/销案/冷却/并回自愈。 // →3s(折中)→ 终选回 2s;护持:连续两窗/注册表复用/销案/冷却/并回自愈。
@ -234,6 +230,8 @@ class MainActivity : AppCompatActivity() {
private val fastStepSamples = 16000 // 1s private val fastStepSamples = 16000 // 1s
private val fastWinSamples = 32000 // 2s 尾窗(用户终选) private val fastWinSamples = 32000 // 2s 尾窗(用户终选)
private val fastMinSamples = 24000 // ≥1.5s 才判 private val fastMinSamples = 24000 // ≥1.5s 才判
private val namingWinSamples = 24000 // 切后命名窗 1.5s(纯净素材,方案三)
private val namingMinSamples = 16000 // ≥1s 才判
private val feedLagSamples = 24000 // 1.5s:覆盖判定链(1.2s采集+计算)的 private val feedLagSamples = 24000 // 1.5s:覆盖判定链(1.2s采集+计算)的
// 余量,开口渗漏≈0;全局字幕延迟即此值,调参范围 1.2~1.5s // 余量,开口渗漏≈0;全局字幕延迟即此值,调参范围 1.2~1.5s
@Volatile private var onsetSeq = 0L // 行切换/会话开始作废在途开口判定 @Volatile private var onsetSeq = 0L // 行切换/会话开始作废在途开口判定
@ -249,6 +247,12 @@ class MainActivity : AppCompatActivity() {
@Volatile private var pendingForceEndpoint = false @Volatile private var pendingForceEndpoint = false
// 滞后喂料的切点冻结请求(判定线程置位,录制线程消费,2026-09-16) // 滞后喂料的切点冻结请求(判定线程置位,录制线程消费,2026-09-16)
@Volatile private var cutRequested = false @Volatile private var cutRequested = false
// 切后命名(方案三,2026-09-16):开火只切行(灰行);名字由"切后纯净窗"
// 在 T+2.5s 诞生——强匹配旧人→上其名或撤销误切;确认无人→发新号(素材
// 干净,永不产幽灵);灰色地带留灰由判定链收编。serial 作废在途命名
@Volatile private var switchSerial = 0L
@Volatile private var switchPrevActive: String? = null
private var grayLinePending = false // 主线程:下一新行先以灰行开(等命名)
private val forceSwitchStreaks = 2 private val forceSwitchStreaks = 2
// 强切冷却(仅 speakerCheckHandler 线程访问,无需 volatile) // 强切冷却(仅 speakerCheckHandler 线程访问,无需 volatile)
private var lastForceSwitchAtMs = 0L private var lastForceSwitchAtMs = 0L
@ -653,6 +657,7 @@ class MainActivity : AppCompatActivity() {
var silenceRun = 0 var silenceRun = 0
var onsetRun = 0 // 开口即查:静音后已累积的开口语音采样数 var onsetRun = 0 // 开口即查:静音后已累积的开口语音采样数
var fastSince = 0 // 快速滚动通道:1s 周期的采样计数 var fastSince = 0 // 快速滚动通道:1s 周期的采样计数
var lastCutConsumeMs = 0L // 方案三:最近一次切点冻结的时刻(命名窗锚)
// 恒定滞后喂料(2026-09-16):音频先进 pendingBuf,滞后 feedLagSamples // 恒定滞后喂料(2026-09-16):音频先进 pendingBuf,滞后 feedLagSamples
// 再喂识别流/样本缓冲(窗口分析保持实时)。换人决定时冻结缓冲, // 再喂识别流/样本缓冲(窗口分析保持实时)。换人决定时冻结缓冲,
// 断句后补喂给新流——新人开头音频从不进入旧句子。 // 断句后补喂给新流——新人开头音频从不进入旧句子。
@ -676,6 +681,7 @@ class MainActivity : AppCompatActivity() {
if (!frozen && cutRequested) { if (!frozen && cutRequested) {
cutRequested = false cutRequested = false
frozen = true frozen = true
lastCutConsumeMs = SystemClock.elapsedRealtime()
if (sentenceId != -1L) pendingForceEndpoint = true if (sentenceId != -1L) pendingForceEndpoint = true
Log.i(TAG, "ONSET-HOLD frozen pending=" + Log.i(TAG, "ONSET-HOLD frozen pending=" +
"${pendingSamples * 1000 / sampleRateInHz}ms") "${pendingSamples * 1000 / sampleRateInHz}ms")
@ -711,19 +717,16 @@ class MainActivity : AppCompatActivity() {
val simFast = SpeakerRecognition.similarity( val simFast = SpeakerRecognition.similarity(
fastEmb, anchor) fastEmb, anchor)
if (simFast >= emaAbsorbMin) return@post if (simFast >= emaAbsorbMin) return@post
// 乐观快切(2026-09-16 用户拍板:第一次识别 // 方案三(2026-09-16):开火不发号——只切行(灰行),
// 先切,出错由判定链并回自愈;准确复核不再 // 名字由切后纯净窗在 T+2.5s 诞生
// 挡在切换前,只做善后) val serial = ++switchSerial
val active = activeLabelForWindow switchPrevActive = activeLabelForWindow
val label = SpeakerRecognition.nextAutoLabel().also {
SpeakerRecognition.fastRegistry.addAuto(it, fastEmb)
}
Log.i(TAG, "ONSET-SWITCH fastSim=" + Log.i(TAG, "ONSET-SWITCH fastSim=" +
"%.2f".format(simFast) + "%.2f".format(simFast) +
" label=$label active=${active ?: "-"}") " serial=$serial active=" +
(switchPrevActive ?: "-"))
cutRequested = true cutRequested = true
uiHandler.post { onOnsetSwitch(label) } uiHandler.post { onOnsetSwitch(null) }
accurateRecheckAfterSwitch(slice, label, active, "ONSET")
} }
} }
} }
@ -742,8 +745,24 @@ class MainActivity : AppCompatActivity() {
if (fastSince >= fastStepSamples) { if (fastSince >= fastStepSamples) {
fastSince = 0 fastSince = 0
val winF = window.snapshot() val winF = window.snapshot()
val nF = fastWinSamples.coerceAtMost(winF.size) // 方案三:开火后 1.5~3.5s 的 tick 走"切后命名"(纯净素材)
if (nF >= fastMinSamples && rmsOf(winF) >= minWindowRms) { val namingSerial = if (lastCutConsumeMs != 0L) {
val dt = SystemClock.elapsedRealtime() - lastCutConsumeMs
if (dt in 1_500L..3_500L) switchSerial else 0L
} else 0L
if (namingSerial != 0L) {
val nN = namingWinSamples.coerceAtMost(winF.size)
if (nN >= namingMinSamples &&
rmsOf(winF) >= minWindowRms
) {
val tailN = winF.copyOfRange(winF.size - nN, winF.size)
speakerCheckHandler.post {
deferredNaming(namingSerial, tailN)
}
}
} else {
val nF = fastWinSamples.coerceAtMost(winF.size)
if (nF >= fastMinSamples && rmsOf(winF) >= minWindowRms) {
val tailF = winF.copyOfRange(winF.size - nF, winF.size) val tailF = winF.copyOfRange(winF.size - nF, winF.size)
val rseq = onsetSeq val rseq = onsetSeq
speakerCheckHandler.post { speakerCheckHandler.post {
@ -761,8 +780,7 @@ class MainActivity : AppCompatActivity() {
if (fastRollLowStreak < 2) return@post if (fastRollLowStreak < 2) return@post
fastRollLowStreak = 0 fastRollLowStreak = 0
val active = activeLabelForWindow val active = activeLabelForWindow
// 先查注册表:认出当前人 → 销案(白捡的防护); // 先查注册表:认出当前人 → 销案(白捡的防护)
// 认出别人 → 复用其编号;彻底陌生才发新号
val known = rollingSpeakerSearch(emb) val known = rollingSpeakerSearch(emb)
if (known != null && known == active) { if (known != null && known == active) {
fastRollLowStreak = 0 fastRollLowStreak = 0
@ -774,18 +792,16 @@ class MainActivity : AppCompatActivity() {
return@post return@post
} }
lastForceSwitchAtMs = nowMs lastForceSwitchAtMs = nowMs
val label = known // 方案三(2026-09-16):开火不发号——只切行(灰行),
?: SpeakerRecognition.nextAutoLabel().also { // 名字由切后纯净窗在 T+2.5s 诞生
SpeakerRecognition.fastRegistry.addAuto(it, emb) val serial = ++switchSerial
} switchPrevActive = active
Log.i(TAG, "FASTROLL-SWITCH sim=" + Log.i(TAG, "FASTROLL-SWITCH sim=" +
"%.2f".format(sim) + "%.2f".format(sim) +
" label=$label kind=" + " serial=$serial active=${active ?: "-"}")
(if (known != null) "known" else "new") +
" active=${active ?: "-"}")
cutRequested = true cutRequested = true
uiHandler.post { onOnsetSwitch(label) } uiHandler.post { onOnsetSwitch(null) }
accurateRecheckAfterSwitch(tailF, label, active, "FASTROLL") }
} }
} }
} }
@ -1787,15 +1803,15 @@ class MainActivity : AppCompatActivity() {
// 底行还有在途句(旧人话尾仍在流里)→ 借强 endpoint 定格它,定格完成 // 底行还有在途句(旧人话尾仍在流里)→ 借强 endpoint 定格它,定格完成
// 再封行(onsetClosePending,见 finalizeSentence);已在途句为空则直接 // 再封行(onsetClosePending,见 finalizeSentence);已在途句为空则直接
// 封行。新标签经 pendingOnsetLabel 交给 appendNewLine。 // 封行。新标签经 pendingOnsetLabel 交给 appendNewLine。
private fun onOnsetSwitch(label: String) { private fun onOnsetSwitch(label: String?) {
val list = if (historyMode) stashedLiveLines ?: subtitleLines else subtitleLines val list = if (historyMode) stashedLiveLines ?: subtitleLines else subtitleLines
val bottom = list.lastOrNull() ?: return val bottom = list.lastOrNull() ?: return
pendingOnsetLabel = label pendingOnsetLabel = label
pendingOnsetLabelAt = SystemClock.elapsedRealtime() pendingOnsetLabelAt = SystemClock.elapsedRealtime()
// 换行标签不依赖"partial 及时到达"(2026-09-16:预置 5s 过期曾致新行 // 方案三(2026-09-16):开火传 null → 下一新行先以灰行开("未识别"),
// 沿用旧标签,观感=没换人——实锤 S2 案预置 16s 后丢弃):落锤即写 // 名字等切后纯净窗;deferredNaming 结果经 adoptSwitchLabel 补上
// 最近确认说话人,此后任何时刻开的新行都挂新标 if (label == null) grayLinePending = true
lastConfirmedLabel = label if (label != null) lastConfirmedLabel = label
if (!bottom.open) return // 底行已封:下一行自然消费预置标签 if (!bottom.open) return // 底行已封:下一行自然消费预置标签
val busy = bottom.sentences.any { !it.finalized } val busy = bottom.sentences.any { !it.finalized }
if (busy) { if (busy) {
@ -1814,73 +1830,91 @@ class MainActivity : AppCompatActivity() {
Log.i(TAG, "ONSET-CLOSE no-sentence forced endpoint") Log.i(TAG, "ONSET-CLOSE no-sentence forced endpoint")
} }
/** 乐观切换后的准确空间善后(2026-09-16 用户拍板:先切后改):切换已 /** 切后命名(方案三,2026-09-16 用户拍板):开火后 1.5~3.5s,用切后
* 发出,本函数只做善后——认得出是当前人 → 留痕(切错由判定链"并回" * 纯净窗(补喂保证该段已完全是新说法人的声音)做准确判定——
* 自愈);真新人 → 补写准确空间(防后续重发号);认出别人 → 留痕(判定 * 强匹配开火前主 → 误切撤销;强匹配某已知人 → 上其名;确认无人
* 链迁回)。在 speakerCheckHandler 线程执行。 */ * (最好分 < 0.42)→ 发新号注册(素材干净,永不产幽灵);灰色地带留灰,
private fun accurateRecheckAfterSwitch( * 由判定链收编。在 speakerCheckHandler 线程执行。 */
samples: FloatArray, label: String, active: String?, tag: String, private fun deferredNaming(serial: Long, samples: FloatArray) {
) { if (serial != switchSerial) return
val prevActive = switchPrevActive
val acc = SpeakerRecognition.computeEmbedding(samples, sampleRateInHz) val acc = SpeakerRecognition.computeEmbedding(samples, sampleRateInHz)
?: return ?: return
val r = SpeakerRecognition.registry.nearestRank(acc) val r = SpeakerRecognition.registry.nearestRank(acc)
val pre = r.name val name = r.name
val score = r.score val score = r.score
when { when {
pre != null && pre == active && score >= revertCancelMin -> { name != null && name == prevActive && score >= speakerThreshold -> {
Log.i(TAG, tag + "-CANCEL-LATE known=$pre(active)" + Log.i(TAG, "NAMING-UNDO serial=$serial known=$name" +
" score=" + "%.2f".format(score) + " active-heal") " score=" + "%.2f".format(score))
// 主动善后:不再被动等句子结束(长句让错标挂几十秒),立即撤销 uiHandler.post { undoSwitch(serial) }
uiHandler.post { revertOnsetSwitch(label, pre) }
} }
pre != null && pre == active -> { name != null && score >= speakerThreshold -> {
// 弱匹配(短插嘴混合窗常见 0.5~0.6):不撤销,让切换立住 Log.i(TAG, "NAMING-ADOPT serial=$serial label=$name" +
Log.i(TAG, tag + "-WEAK-SAME known=$pre" + " score=" + "%.2f".format(score))
" score=" + "%.2f".format(score) + uiHandler.post { adoptSwitchLabel(serial, name) }
" < $revertCancelMin keep-switch")
} }
pre != null -> name == null || score < verdictFallbackMin -> {
Log.i(TAG, tag + "-RECHECK known=$pre" + // 确认无人:发新号 + 双空间注册(纯净素材)
" score=" + "%.2f".format(score) + val fastEmb = SpeakerRecognition.computeEmbeddingFast(
" label=$label (verdict migrates)") samples, sampleRateInHz)
else -> SpeakerRecognition.registry.addAuto(label, acc) val label = SpeakerRecognition.nextAutoLabel().also {
SpeakerRecognition.registry.addAuto(it, acc)
if (fastEmb != null) {
SpeakerRecognition.fastRegistry.addAuto(it, fastEmb)
}
}
Log.i(TAG, "NAMING-NEW serial=$serial label=$label")
uiHandler.post { adoptSwitchLabel(serial, label) }
}
else -> Log.i(TAG, "NAMING-WEAK serial=$serial known=$name" +
" score=" + "%.2f".format(score) + " keep-gray")
} }
} }
/** 乐观切换的主动善后(2026-09-16"善后不理想"整改):准确复核认出同一 /** 命名落地(主线程):灰行就地冠名;灰行未建立 → 走预置标签给下一行 */
* 人时立即撤销,不等句子结束——覆盖预置标签、还原钉住的标签、占位行 private fun adoptSwitchLabel(serial: Long, label: String) {
* 就地改签并与上方同人并回、清理快镜像占位编号。主线程执行。 */ if (serial != switchSerial) return
private fun revertOnsetSwitch(firedLabel: String, prevActive: String?) { switchSerial++ // 关闭本切次命名窗口
if (prevActive == null) return // 无旧主可还原(会话极早期)→ 被动链 grayLinePending = false
val bottom = subtitleLines.lastOrNull() val bottom = subtitleLines.lastOrNull()
val pending = pendingOnsetLabel if (bottom != null && bottom.open && bottom.label == null) {
val inFlight = pending == firedLabel || bottom.label = label
(bottom != null && bottom.label == firedLabel && bottom.open) bottom.colorIdx = colorIdxFor(label)
if (!inFlight) return // 状态已走远(连环切换等)→ 交给被动链 notifyLine(bottom)
if (pending == firedLabel && bottom != null && Log.i(TAG, "SWITCH-LABEL serial=$serial label=$label (line)")
bottom.label != firedLabel } else {
) { pendingOnsetLabel = label
// 占位行尚未建立(补喂机制下多数如此):抹掉切换痕迹——预置标签 pendingOnsetLabelAt = SystemClock.elapsedRealtime()
// 作废、重开旧底行,同人续说零断行,观感上从未切过 Log.i(TAG, "SWITCH-LABEL serial=$serial label=$label (preset)")
pendingOnsetLabel = null
onsetClosePending = false // 防在途定格回来补封行
if (!bottom.open) bottom.open = true
lastConfirmedLabel = prevActive
SpeakerRecognition.fastRegistry.removeAuto(firedLabel)
Log.i(TAG, "SWITCH-REVERT(pre-line) $firedLabel -> $prevActive")
return
} }
// 占位行已建立:就地改签 + 与上方同人并回 lastConfirmedLabel = label
}
/** 误切撤销(方案三版,主线程):灰行未建立→重开旧行抹迹;已建立→改签并回 */
private fun undoSwitch(serial: Long) {
if (serial != switchSerial) return
switchSerial++
val prevActive = switchPrevActive ?: return
grayLinePending = false
pendingOnsetLabel = null pendingOnsetLabel = null
onsetClosePending = false
lastConfirmedLabel = prevActive lastConfirmedLabel = prevActive
if (bottom != null && bottom.label == firedLabel) { val bottom = subtitleLines.lastOrNull() ?: return
bottom.label = prevActive when {
bottom.colorIdx = colorIdxFor(prevActive) bottom.open && bottom.label == null -> {
val merged = mergeLineIntoPrevIfSame(bottom, prevActive) bottom.label = prevActive
if (!merged) notifyLine(bottom) bottom.colorIdx = colorIdxFor(prevActive)
val merged = mergeLineIntoPrevIfSame(bottom, prevActive)
if (!merged) notifyLine(bottom)
Log.i(TAG, "SWITCH-UNDO(line) serial=$serial -> $prevActive")
}
!bottom.open -> {
bottom.open = true
Log.i(TAG, "SWITCH-UNDO(pre-line) serial=$serial -> $prevActive")
}
else -> Log.i(TAG, "SWITCH-UNDO skipped serial=$serial (state moved)")
} }
SpeakerRecognition.fastRegistry.removeAuto(firedLabel)
Log.i(TAG, "SWITCH-REVERT(line) $firedLabel -> $prevActive (active-heal)")
} }
private fun appendNewLine(label: String?): SubtitleLine { private fun appendNewLine(label: String?): SubtitleLine {
@ -1905,9 +1939,15 @@ class MainActivity : AppCompatActivity() {
SystemClock.elapsedRealtime() - pendingOnsetLabelAt < 5_000L SystemClock.elapsedRealtime() - pendingOnsetLabelAt < 5_000L
) { ) {
pendingOnsetLabel = null pendingOnsetLabel = null
grayLinePending = false
lastConfirmedLabel = preset lastConfirmedLabel = preset
eff = preset eff = preset
Log.i(TAG, "ONSET-LINE new line label=$preset") Log.i(TAG, "ONSET-LINE new line label=$preset")
} else if (grayLinePending) {
// 方案三:切后命名未落定 → 灰行先开(未识别),名字稍后补
grayLinePending = false
eff = null
Log.i(TAG, "ONSET-LINE new line gray (naming pending)")
} else { } else {
if (preset != null) { if (preset != null) {
pendingOnsetLabel = null pendingOnsetLabel = null