出现未识别标签保留字幕bug修复:给兜底,与最相似声纹合并
This commit is contained in:
parent
236037a0b0
commit
7892e35d2d
|
|
@ -165,6 +165,11 @@ class MainActivity : AppCompatActivity() {
|
|||
private val speakerCheckThread = HandlerThread("speaker-window").also { it.start() }
|
||||
private val speakerCheckHandler by lazy { Handler(speakerCheckThread.looper) }
|
||||
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;
|
||||
// endpoint 权威判定仍用准确模型 + speakerThreshold(0.5)。
|
||||
private val fastSearchThreshold = 0.4f
|
||||
|
|
@ -176,6 +181,10 @@ class MainActivity : AppCompatActivity() {
|
|||
@Volatile private var rollingLowHits = 0
|
||||
// C(最新者胜):每次投递滚动检测都刷新序号,队列里被超额的旧任务直接跳过
|
||||
@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 windowRefEmb: FloatArray? = null
|
||||
@Volatile private var activeLabelForWindow: String? = null
|
||||
|
|
@ -493,6 +502,8 @@ class MainActivity : AppCompatActivity() {
|
|||
win, sampleRateInHz
|
||||
) ?: return@post
|
||||
if (rseq != rollingSeq) return@post
|
||||
rollingEmbSid = owner
|
||||
rollingEmb = emb
|
||||
// 计算耗时内句子可能已结算:回写前二次核销
|
||||
if (seq != checkSerial) return@post
|
||||
val ref = windowRefEmb
|
||||
|
|
@ -660,35 +671,7 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
// 整句声纹判定移出录音线程(停摆根因):后台算完
|
||||
// 回主线程 confirmSpeaker 修正归属
|
||||
speakerCheckHandler.post {
|
||||
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) }
|
||||
}
|
||||
postSpeakerVerdict(sid, snap)
|
||||
// 定格 + 送检立即走主线程队列,不等声纹
|
||||
uiHandler.post {
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 整句声纹判定:后台算准确 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":
|
||||
// letters must be ASCII latin (en), kana (ja), or CJK hanzi (zh/ja);
|
||||
// everything else (hangul, cyrillic, thai, arabic, accented latin ...)
|
||||
|
|
@ -820,9 +861,11 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
private fun renameSpeaker(oldKey: String, name: String) {
|
||||
// 真名不得长得像内部键/显示名,避免与 S{n}→未识别N 的映射冲突
|
||||
if (autoLabelRegex.matches(name)) {
|
||||
Toast.makeText(this, "名字不能是 S+数字 形式", Toast.LENGTH_SHORT).show()
|
||||
// 真名不得长得像内部键/显示名,避免与 S{n}→已识别人N 的映射冲突
|
||||
if (autoLabelRegex.matches(name) ||
|
||||
Regex("^(已识别人?\\d+|未识别\\d*)$").matches(name)) {
|
||||
Toast.makeText(this, "名字不能是 S数字/已识别人N/未识别N 形式",
|
||||
Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
// 声纹库升级:S{n} 的嵌入转为真名注册(真名可多组嵌入并存,
|
||||
|
|
@ -1246,14 +1289,14 @@ class MainActivity : AppCompatActivity() {
|
|||
subtitleRecycler.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
// 内部标签 S0/S1/…(注册键,可被命名为真名)对外显示为 未识别1/未识别2…;
|
||||
// 已命名者直接显示真名。
|
||||
// 内部标签 S0/S1/…(已注册、能区分但尚未起名的身份)对外显示"已识别人1/2…";
|
||||
// 已命名者显示真名;label==null(完全无法归属)才显示灰色"未识别"。
|
||||
private val autoLabelRegex = Regex("^S(\\d+)$")
|
||||
|
||||
private fun displayName(label: String?): String {
|
||||
if (label == null) return "未识别"
|
||||
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 =
|
||||
|
|
@ -1524,8 +1567,14 @@ class MainActivity : AppCompatActivity() {
|
|||
private fun rollingSpeakerSearch(emb: FloatArray): String? =
|
||||
if (SpeakerRecognition.fastReady())
|
||||
SpeakerRecognition.fastRegistry.search(emb, fastSearchThreshold)
|
||||
?: SpeakerRecognition.fastRegistry.nearest(
|
||||
emb, rollingFallbackMin
|
||||
)
|
||||
else
|
||||
SpeakerRecognition.registry.search(emb, speakerThreshold)
|
||||
?: SpeakerRecognition.registry.nearest(
|
||||
emb, verdictFallbackMin
|
||||
)
|
||||
|
||||
// ③ 滚动检测查到了已知人、但当前行还没有归属(占位"未识别"):
|
||||
// 直接把活跃归属采纳为他,等 endpoint 权威判定给行冠名,不再多切一行
|
||||
|
|
@ -1533,6 +1582,15 @@ class MainActivity : AppCompatActivity() {
|
|||
if (lastConfirmedLabel == null) {
|
||||
lastConfirmedLabel = 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
|
||||
}
|
||||
} 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 {
|
||||
minutesStatus.visibility = View.VISIBLE
|
||||
minutesStatus.text = if (t is SummarizeCancelledException)
|
||||
"已取消生成(保留上一次的纪要)"
|
||||
else "生成失败:${t.message}"
|
||||
Toast.makeText(this,
|
||||
if (t is SummarizeCancelledException) "已取消生成"
|
||||
else "纪要生成失败:${t.message}", Toast.LENGTH_LONG).show()
|
||||
minutesStatus.text = when (t) {
|
||||
is SummarizeCancelledException -> "已取消生成(保留上一次的纪要)"
|
||||
is SummarizeEmptyException ->
|
||||
"已自动取消:${t.message}(上次纪要未覆盖)"
|
||||
else -> "生成失败:${t.message}"
|
||||
}
|
||||
Toast.makeText(this, when (t) {
|
||||
is SummarizeCancelledException -> "已取消生成"
|
||||
is SummarizeEmptyException -> "已自动取消生成"
|
||||
else -> "纪要生成失败:${t.message}"
|
||||
}, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} finally {
|
||||
summarizeEngine = null
|
||||
|
|
|
|||
|
|
@ -123,6 +123,26 @@ class SpeakerRegistry {
|
|||
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}(自动注册、未命名)升级为真名,
|
||||
* 或把已有真名改成另一个真名。同名允许多条嵌入并存 —— 之前误判成
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import org.json.JSONObject
|
|||
|
||||
class SummarizeCancelledException : Exception("summarize cancelled")
|
||||
|
||||
/** 空笔记防线(用户要求 2026-09-11):笔记/成稿为空 → 提示并自动取消,不落盘。 */
|
||||
class SummarizeEmptyException(msg: String) : Exception(msg)
|
||||
|
||||
data class Prompts(
|
||||
val p1System: String, val p1UserTpl: String,
|
||||
val p2System: String, val p2UserTpl: String,
|
||||
|
|
@ -167,6 +170,14 @@ class SummarizePipeline(
|
|||
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 {
|
||||
val t = ms / 1000
|
||||
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 resumeNotes/resumeFromIndex M2:会中滚动笔记已覆盖前 resumeFromIndex 句,
|
||||
* 仅补跑尾部(进度、成稿复用同一份笔记)
|
||||
|
|
@ -345,6 +356,13 @@ class SummarizePipeline(
|
|||
for (e in entries.drop(startIdx)) lines.add(renderLine(e))
|
||||
val chunks = chunk(lines)
|
||||
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, " +
|
||||
"lang=$lang, resumeIdx=$startIdx")
|
||||
|
||||
|
|
@ -375,7 +393,15 @@ class SummarizePipeline(
|
|||
)
|
||||
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 说明解码浪费在废话/思考上;
|
||||
// ms 是单块真实耗时,决定 diff 化/大块合并的参数
|
||||
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} " +
|
||||
"chunks=$total failed=$failed")
|
||||
// 空笔记终检:全部块失败,或笔记仍是空壳(total==0 已在入口取消)
|
||||
if (failed == total || notesEntriesEmpty(notes)) {
|
||||
throw SummarizeEmptyException("整场转写未能形成任何笔记条目")
|
||||
}
|
||||
|
||||
// ---- 成稿(§5.3)----
|
||||
if (listener.isCancelled()) throw SummarizeCancelledException()
|
||||
|
|
@ -406,6 +436,7 @@ class SummarizePipeline(
|
|||
// 纯统计日志(零成本),纪要中的混语留给人工处理。
|
||||
val viol = scriptViolation(lang, md)
|
||||
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}")
|
||||
return MeetingSummary(md, lang, notes, total, failed)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue