出现未识别标签保留字幕bug修复:给兜底,与最相似声纹合并

This commit is contained in:
liwenhao2 2026-09-11 18:23:04 +08:00
parent 236037a0b0
commit 7892e35d2d
3 changed files with 159 additions and 44 deletions

View File

@ -165,6 +165,11 @@ class MainActivity : AppCompatActivity() {
private val speakerCheckThread = HandlerThread("speaker-window").also { it.start() } private val speakerCheckThread = HandlerThread("speaker-window").also { it.start() }
private val speakerCheckHandler by lazy { Handler(speakerCheckThread.looper) } private val speakerCheckHandler by lazy { Handler(speakerCheckThread.looper) }
private val speakerSwitchDrop = 0.30f // cos(window, reference) below this => switch private val speakerSwitchDrop = 0.30f // cos(window, reference) below this => switch
// 最近邻兜底阈值(用户要求:未识别归到最相似的已识别人):
// endpoint 精确匹配不中→nearest≥0.35 归入;再不行才注册新人
private val verdictFallbackMin = 0.35f
// 滚动检测 fast 空间的兜底阈值(仅影响换行标签显示,endpoint 仍权威)
private val rollingFallbackMin = 0.30f
// 滚动检测在 fast 空间查"这人是谁"仅用于**显示标签**,阈值放宽到 0.4; // 滚动检测在 fast 空间查"这人是谁"仅用于**显示标签**,阈值放宽到 0.4;
// endpoint 权威判定仍用准确模型 + speakerThreshold(0.5)。 // endpoint 权威判定仍用准确模型 + speakerThreshold(0.5)。
private val fastSearchThreshold = 0.4f private val fastSearchThreshold = 0.4f
@ -176,6 +181,10 @@ class MainActivity : AppCompatActivity() {
@Volatile private var rollingLowHits = 0 @Volatile private var rollingLowHits = 0
// C(最新者胜):每次投递滚动检测都刷新序号,队列里被超额的旧任务直接跳过 // C(最新者胜):每次投递滚动检测都刷新序号,队列里被超额的旧任务直接跳过
@Volatile private var rollingSeq = 0L @Volatile private var rollingSeq = 0L
// 兜底残料:当前句子槽位最近一次滚动窗口声纹。整句判定因碎片算不出
// embedding 时复用它做归属(用户要求 2026-09-11)
@Volatile private var rollingEmbSid = -1L
@Volatile private var rollingEmb: FloatArray? = null
@Volatile private var pendingSwitch: SpeakerSwitch? = null @Volatile private var pendingSwitch: SpeakerSwitch? = null
@Volatile private var windowRefEmb: FloatArray? = null @Volatile private var windowRefEmb: FloatArray? = null
@Volatile private var activeLabelForWindow: String? = null @Volatile private var activeLabelForWindow: String? = null
@ -493,6 +502,8 @@ class MainActivity : AppCompatActivity() {
win, sampleRateInHz win, sampleRateInHz
) ?: return@post ) ?: return@post
if (rseq != rollingSeq) return@post if (rseq != rollingSeq) return@post
rollingEmbSid = owner
rollingEmb = emb
// 计算耗时内句子可能已结算:回写前二次核销 // 计算耗时内句子可能已结算:回写前二次核销
if (seq != checkSerial) return@post if (seq != checkSerial) return@post
val ref = windowRefEmb val ref = windowRefEmb
@ -660,35 +671,7 @@ class MainActivity : AppCompatActivity() {
// 整句声纹判定移出录音线程(停摆根因):后台算完 // 整句声纹判定移出录音线程(停摆根因):后台算完
// 回主线程 confirmSpeaker 修正归属 // 回主线程 confirmSpeaker 修正归属
speakerCheckHandler.post { postSpeakerVerdict(sid, snap)
val emb = SpeakerRecognition.computeEmbedding(
snap, sampleRateInHz
)
// 镜像嵌入:② 用句尾 2s 切片(与滚动检测同视角),
// 整句 embedding 会拉低窗口匹配度,导致换行时查不到人
val fastEmb = if (emb != null &&
SpeakerRecognition.fastReady()
) {
val tailSlice = if (snap.size > 32000)
snap.copyOfRange(
snap.size - 32000, snap.size
) else snap
SpeakerRecognition.computeEmbeddingFast(
tailSlice, sampleRateInHz
)
} else null
val label = if (emb == null) null else
SpeakerRecognition.registry.search(
emb, speakerThreshold
)
?: SpeakerRecognition.nextAutoLabel().also {
SpeakerRecognition.registry.addAuto(it, emb)
if (fastEmb != null)
SpeakerRecognition.fastRegistry
.addAuto(it, fastEmb)
}
uiHandler.post { confirmSpeaker(sid, label, emb, fastEmb) }
}
// 定格 + 送检立即走主线程队列,不等声纹 // 定格 + 送检立即走主线程队列,不等声纹
uiHandler.post { uiHandler.post {
val accepted = submitRefineTask( val accepted = submitRefineTask(
@ -702,9 +685,67 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
// Stop 兜底(软 endpoint):最后一句未完的转写同样走定格→判定→精修,
// 否则丢半句 + 灰"未识别"行滞留(用户反馈 2026-09-11)
if (sentenceId != -1L) {
val raw = onlineRecognizer.getResult(stream).text
val disp =
if (raw.isNotBlank() && isInAllowedLanguages(raw)) raw else ""
onlineRecognizer.reset(stream)
val sid = sentenceId
sentenceId = -1L
windowRefEmb = null
if (disp.isNotBlank() && bufferTotal > 0) {
val snap = flattenSamples()
val dur = bufferTotal / sampleRateInHz.toFloat()
resetBuffer()
postSpeakerVerdict(sid, snap)
uiHandler.post {
val accepted = submitRefineTask(
sid, snap, dur, "STOP", null, disp, disp
)
finalizeSentence(sid, accepted)
}
} else {
resetBuffer()
uiHandler.post { finalizeSentence(sid, false) }
}
}
stream.release() stream.release()
} }
/**
* 整句声纹判定:后台算准确 embedding + fast 镜像(句尾 2s 切片,与滚动
* 检测同视角),注册后回主线程 confirmSpeaker 修正归属。
* 自然 endpoint / 强制断句 / Stop 软 endpoint 共用。
*/
private fun postSpeakerVerdict(sid: Long, snap: FloatArray) {
speakerCheckHandler.post {
var emb = SpeakerRecognition.computeEmbedding(snap, sampleRateInHz)
if (emb == null && rollingEmbSid == sid) {
// 兜底①:句子太碎、整句 embedding 算不出 → 复用该槽滚动窗口残料
emb = rollingEmb
}
val fastEmb = if (emb != null && SpeakerRecognition.fastReady()) {
val tailSlice = if (snap.size > 32000)
snap.copyOfRange(snap.size - 32000, snap.size) else snap
SpeakerRecognition.computeEmbeddingFast(tailSlice, sampleRateInHz)
} else null
val label = if (emb == null) null else
(SpeakerRecognition.registry.search(emb, speakerThreshold)
// 兜底②:精确匹配不中 → 归入最相似的已注册身份
?: SpeakerRecognition.registry.nearest(
emb, verdictFallbackMin
))
?: SpeakerRecognition.nextAutoLabel().also {
SpeakerRecognition.registry.addAuto(it, emb)
if (fastEmb != null)
SpeakerRecognition.fastRegistry.addAuto(it, fastEmb)
}
uiHandler.post { confirmSpeaker(sid, label, emb, fastEmb) }
}
}
// Keep only zh/ja/en output when firstPassLanguage == "auto": // Keep only zh/ja/en output when firstPassLanguage == "auto":
// letters must be ASCII latin (en), kana (ja), or CJK hanzi (zh/ja); // letters must be ASCII latin (en), kana (ja), or CJK hanzi (zh/ja);
// everything else (hangul, cyrillic, thai, arabic, accented latin ...) // everything else (hangul, cyrillic, thai, arabic, accented latin ...)
@ -820,9 +861,11 @@ class MainActivity : AppCompatActivity() {
} }
private fun renameSpeaker(oldKey: String, name: String) { private fun renameSpeaker(oldKey: String, name: String) {
// 真名不得长得像内部键/显示名,避免与 S{n}→未识别N 的映射冲突 // 真名不得长得像内部键/显示名,避免与 S{n}→已识别人N 的映射冲突
if (autoLabelRegex.matches(name)) { if (autoLabelRegex.matches(name) ||
Toast.makeText(this, "名字不能是 S+数字 形式", Toast.LENGTH_SHORT).show() Regex("^(已识别人?\\d+|未识别\\d*)$").matches(name)) {
Toast.makeText(this, "名字不能是 S数字/已识别人N/未识别N 形式",
Toast.LENGTH_SHORT).show()
return return
} }
// 声纹库升级:S{n} 的嵌入转为真名注册(真名可多组嵌入并存, // 声纹库升级:S{n} 的嵌入转为真名注册(真名可多组嵌入并存,
@ -1246,14 +1289,14 @@ class MainActivity : AppCompatActivity() {
subtitleRecycler.visibility = View.VISIBLE subtitleRecycler.visibility = View.VISIBLE
} }
// 内部标签 S0/S1/…(注册键,可被命名为真名)对外显示为 未识别1/未识别2…; // 内部标签 S0/S1/…(已注册、能区分但尚未起名的身份)对外显示"已识别人1/2…";
// 已命名者直接显示真名。 // 已命名者显示真名;label==null(完全无法归属)才显示灰色"未识别"。
private val autoLabelRegex = Regex("^S(\\d+)$") private val autoLabelRegex = Regex("^S(\\d+)$")
private fun displayName(label: String?): String { private fun displayName(label: String?): String {
if (label == null) return "未识别" if (label == null) return "未识别"
val m = autoLabelRegex.matchEntire(label) val m = autoLabelRegex.matchEntire(label)
return if (m != null) "未识别${m.groupValues[1].toInt() + 1}" else label return if (m != null) "已识别人${m.groupValues[1].toInt() + 1}" else label
} }
private fun colorIdxFor(label: String): Int = private fun colorIdxFor(label: String): Int =
@ -1524,8 +1567,14 @@ class MainActivity : AppCompatActivity() {
private fun rollingSpeakerSearch(emb: FloatArray): String? = private fun rollingSpeakerSearch(emb: FloatArray): String? =
if (SpeakerRecognition.fastReady()) if (SpeakerRecognition.fastReady())
SpeakerRecognition.fastRegistry.search(emb, fastSearchThreshold) SpeakerRecognition.fastRegistry.search(emb, fastSearchThreshold)
?: SpeakerRecognition.fastRegistry.nearest(
emb, rollingFallbackMin
)
else else
SpeakerRecognition.registry.search(emb, speakerThreshold) SpeakerRecognition.registry.search(emb, speakerThreshold)
?: SpeakerRecognition.registry.nearest(
emb, verdictFallbackMin
)
// ③ 滚动检测查到了已知人、但当前行还没有归属(占位"未识别"): // ③ 滚动检测查到了已知人、但当前行还没有归属(占位"未识别"):
// 直接把活跃归属采纳为他,等 endpoint 权威判定给行冠名,不再多切一行 // 直接把活跃归属采纳为他,等 endpoint 权威判定给行冠名,不再多切一行
@ -1533,6 +1582,15 @@ class MainActivity : AppCompatActivity() {
if (lastConfirmedLabel == null) { if (lastConfirmedLabel == null) {
lastConfirmedLabel = label lastConfirmedLabel = label
activeLabelForWindow = label activeLabelForWindow = label
// 修"灰色未识别滞留"来源之一:滚动已匹配到人,顺带给当前灰色
// 占位行冠名(能并回上方同人行则并),不必等 endpoint 权威判定
subtitleLines.lastOrNull()
?.takeIf { it.open && it.label == null }?.let { gray ->
gray.label = label
gray.colorIdx = colorIdxFor(label)
if (!mergeLineIntoPrevIfSame(gray, label)) notifyLine(gray)
Log.i(TAG, "adopt named gray line -> ${displayName(label)}")
}
} }
} }
@ -1695,15 +1753,21 @@ class MainActivity : AppCompatActivity() {
copyMinutesButton.isEnabled = true copyMinutesButton.isEnabled = true
} }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(TAG, "SUMMARIZE failed", t) if (t is SummarizeEmptyException) Log.i(TAG, "SUMMARIZE empty: ${t.message}")
else Log.e(TAG, "SUMMARIZE failed", t)
runOnUiThread { runOnUiThread {
minutesStatus.visibility = View.VISIBLE minutesStatus.visibility = View.VISIBLE
minutesStatus.text = if (t is SummarizeCancelledException) minutesStatus.text = when (t) {
"已取消生成(保留上一次的纪要)" is SummarizeCancelledException -> "已取消生成(保留上一次的纪要)"
else "生成失败:${t.message}" is SummarizeEmptyException ->
Toast.makeText(this, "已自动取消:${t.message}(上次纪要未覆盖)"
if (t is SummarizeCancelledException) "已取消生成" else -> "生成失败:${t.message}"
else "纪要生成失败:${t.message}", Toast.LENGTH_LONG).show() }
Toast.makeText(this, when (t) {
is SummarizeCancelledException -> "已取消生成"
is SummarizeEmptyException -> "已自动取消生成"
else -> "纪要生成失败:${t.message}"
}, Toast.LENGTH_LONG).show()
} }
} finally { } finally {
summarizeEngine = null summarizeEngine = null

View File

@ -123,6 +123,26 @@ class SpeakerRegistry {
return best return best
} }
/**
* 最近邻兜底(用户要求 2026-09-11):"未识别"归到与 e 最相似的已注册者
* (命名条目优先,其后自动条目;相似度须 ≥ minScore)。全空/不达标返回 null。
*/
@Synchronized
fun nearest(e: FloatArray, minScore: Float): String? {
val q = l2normalize(e)
var bestName: String? = null
var best = -1f
for ((name, emb) in named) {
val s = dot(q, emb)
if (s > best) { best = s; bestName = name }
}
for ((name, emb) in auto) {
val s = dot(q, emb)
if (s > best) { best = s; bestName = name }
}
return if (best >= minScore) bestName else null
}
/** /**
* 点击标签命名用:把内部键 S{n}(自动注册、未命名)升级为真名, * 点击标签命名用:把内部键 S{n}(自动注册、未命名)升级为真名,
* 或把已有真名改成另一个真名。同名允许多条嵌入并存 —— 之前误判成 * 或把已有真名改成另一个真名。同名允许多条嵌入并存 —— 之前误判成

View File

@ -16,6 +16,9 @@ import org.json.JSONObject
class SummarizeCancelledException : Exception("summarize cancelled") class SummarizeCancelledException : Exception("summarize cancelled")
/** 空笔记防线(用户要求 2026-09-11):笔记/成稿为空 → 提示并自动取消,不落盘。 */
class SummarizeEmptyException(msg: String) : Exception(msg)
data class Prompts( data class Prompts(
val p1System: String, val p1UserTpl: String, val p1System: String, val p1UserTpl: String,
val p2System: String, val p2UserTpl: String, val p2System: String, val p2UserTpl: String,
@ -167,6 +170,14 @@ class SummarizePipeline(
else -> "English (formal written English)" else -> "English (formal written English)"
} }
/** 笔记是否无任何实质条目(四类条目全空;"前回末尾"不算)。解析失败按空处理。 */
internal fun notesEntriesEmpty(notes: String): Boolean = runCatching {
val o = JSONObject(notes)
listOf("已定事項", "討論中", "行動項目", "人物").all { k ->
o.optJSONArray(k)?.length() == 0
}
}.getOrDefault(true)
fun mmss(ms: Long): String { fun mmss(ms: Long): String {
val t = ms / 1000 val t = ms / 1000
return "%02d:%02d".format(t / 60, t % 60) return "%02d:%02d".format(t / 60, t % 60)
@ -318,7 +329,7 @@ class SummarizePipeline(
} }
/** /**
* @param labelOf 内部说话人键(S{n}/真名)→ 显示名(未识别N/真名) * @param labelOf 内部说话人键(S{n}/真名)→ 显示名(已识别人N/真名/未识别)
* @param entries SessionTranscript 快照(定格顺序,不可为空) * @param entries SessionTranscript 快照(定格顺序,不可为空)
* @param resumeNotes/resumeFromIndex M2:会中滚动笔记已覆盖前 resumeFromIndex 句, * @param resumeNotes/resumeFromIndex M2:会中滚动笔记已覆盖前 resumeFromIndex 句,
* 仅补跑尾部(进度、成稿复用同一份笔记) * 仅补跑尾部(进度、成稿复用同一份笔记)
@ -345,6 +356,13 @@ class SummarizePipeline(
for (e in entries.drop(startIdx)) lines.add(renderLine(e)) for (e in entries.drop(startIdx)) lines.add(renderLine(e))
val chunks = chunk(lines) val chunks = chunk(lines)
val total = chunks.size val total = chunks.size
// 用户要求(2026-09-11):没有新句可消化(再点生成纪要,断点已覆盖全部
// 转写)时不再空跑"成稿 0/0",直接提示并自动取消
if (total == 0) {
throw SummarizeEmptyException(if (resumeNotes != null)
"本场会议没有新增转写,纪要已是最新"
else "没有可分析的转写内容")
}
Log.i(TAG, "SUMMARIZE: ${entries.size} utterances -> $total chunks, " + Log.i(TAG, "SUMMARIZE: ${entries.size} utterances -> $total chunks, " +
"lang=$lang, resumeIdx=$startIdx") "lang=$lang, resumeIdx=$startIdx")
@ -375,7 +393,15 @@ class SummarizePipeline(
) )
parsed = extractJson(out) parsed = extractJson(out)
} }
if (parsed != null) notes = parsed else failed++ if (parsed != null) notes = parsed
else {
failed++
// 及时止损:第一块都解析不出来,笔记链条已断,后面块只会白烧 token
if (idx == 0) {
Log.w(TAG, "P1 first chunk unparsable, abort. raw=${out.take(120)}")
throw SummarizeEmptyException("开头转写片段无法生成有效笔记(内容过少或输出不可解析)")
}
}
// 测量点(baseline,不改行为):raw≫json 说明解码浪费在废话/思考上; // 测量点(baseline,不改行为):raw≫json 说明解码浪费在废话/思考上;
// ms 是单块真实耗时,决定 diff 化/大块合并的参数 // ms 是单块真实耗时,决定 diff 化/大块合并的参数
Log.i(TAG, "P1 chunk=${idx + 1}/$total prompt_len=${user.length} " + Log.i(TAG, "P1 chunk=${idx + 1}/$total prompt_len=${user.length} " +
@ -385,6 +411,10 @@ class SummarizePipeline(
} }
Log.i(TAG, "P1 phase total_ms=${System.currentTimeMillis() - tP1Start} " + Log.i(TAG, "P1 phase total_ms=${System.currentTimeMillis() - tP1Start} " +
"chunks=$total failed=$failed") "chunks=$total failed=$failed")
// 空笔记终检:全部块失败,或笔记仍是空壳(total==0 已在入口取消)
if (failed == total || notesEntriesEmpty(notes)) {
throw SummarizeEmptyException("整场转写未能形成任何笔记条目")
}
// ---- 成稿(§5.3)---- // ---- 成稿(§5.3)----
if (listener.isCancelled()) throw SummarizeCancelledException() if (listener.isCancelled()) throw SummarizeCancelledException()
@ -406,6 +436,7 @@ class SummarizePipeline(
// 纯统计日志(零成本),纪要中的混语留给人工处理。 // 纯统计日志(零成本),纪要中的混语留给人工处理。
val viol = scriptViolation(lang, md) val viol = scriptViolation(lang, md)
if (viol != null) Log.i(TAG, "SUMMARIZE langcheck NOTE($lang): $viol (no rewrite)") if (viol != null) Log.i(TAG, "SUMMARIZE langcheck NOTE($lang): $viol (no rewrite)")
if (md.isBlank()) throw SummarizeEmptyException("成稿输出为空")
Log.i(TAG, "SUMMARIZE wall clock total_ms=${System.currentTimeMillis() - tP1Start}") Log.i(TAG, "SUMMARIZE wall clock total_ms=${System.currentTimeMillis() - tP1Start}")
return MeetingSummary(md, lang, notes, total, failed) return MeetingSummary(md, lang, notes, total, failed)
} }