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 ffab623..619533f 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt @@ -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 diff --git a/app/src/main/java/com/k2fsa/sherpa/onnx/SpeakerEmbedding.kt b/app/src/main/java/com/k2fsa/sherpa/onnx/SpeakerEmbedding.kt index f714f5d..82f98da 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/SpeakerEmbedding.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/SpeakerEmbedding.kt @@ -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}(自动注册、未命名)升级为真名, * 或把已有真名改成另一个真名。同名允许多条嵌入并存 —— 之前误判成 diff --git a/app/src/main/java/com/k2fsa/sherpa/onnx/SummarizePipeline.kt b/app/src/main/java/com/k2fsa/sherpa/onnx/SummarizePipeline.kt index e7bc8b3..625fe77 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/SummarizePipeline.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/SummarizePipeline.kt @@ -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) }