From 34e9fb3578677afd001156726bff244a061e66ba Mon Sep 17 00:00:00 2001 From: liwenhao2 Date: Mon, 14 Sep 2026 18:17:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=BA=E7=8E=B0=E6=9C=AA=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E4=BF=9D=E7=95=99=E5=AD=97=E5=B9=95bug?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=BB=99=E5=85=9C=E5=BA=95=EF=BC=8C?= =?UTF-8?q?=E4=B8=8E=E6=9C=80=E7=9B=B8=E4=BC=BC=E5=A3=B0=E7=BA=B9=E5=90=88?= =?UTF-8?q?=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 6 +- app/src/main/assets/summarize/prompts.md | 93 +- .../java/com/k2fsa/sherpa/onnx/LlmEngine.kt | 13 + .../com/k2fsa/sherpa/onnx/MainActivity.kt | 1179 ++++++++++++----- .../k2fsa/sherpa/onnx/SessionTranscript.kt | 12 +- .../com/k2fsa/sherpa/onnx/SpeakerEmbedding.kt | 26 +- .../k2fsa/sherpa/onnx/SummarizePipeline.kt | 125 +- app/src/main/res/layout/activity_main.xml | 56 +- app/src/main/res/values/strings.xml | 82 +- 9 files changed, 1126 insertions(+), 466 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 434e496..c582dbe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,7 +5,9 @@ plugins { android { namespace 'com.k2fsa.sherpa.onnx' - compileSdk 32 + // appcompat 1.6.1(应用内语言切换所需)要求 compileSdk≥33;仅编译期提升, + // targetSdk 保持 32(运行时行为不变),minSdk 保持 21(装机范围不变) + compileSdk 33 defaultConfig { applicationId "com.k2fsa.sherpa.onnx" @@ -48,7 +50,7 @@ android { dependencies { implementation 'androidx.core:core-ktx:1.7.0' - implementation 'androidx.appcompat:appcompat:1.5.1' + implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.7.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' testImplementation 'junit:junit:4.13.2' diff --git a/app/src/main/assets/summarize/prompts.md b/app/src/main/assets/summarize/prompts.md index 25f078c..2dcb6a0 100644 --- a/app/src/main/assets/summarize/prompts.md +++ b/app/src/main/assets/summarize/prompts.md @@ -1,7 +1,8 @@ # 摘要功能三段提示词(里程碑 1) -> 适用模型:`gemma-3-1b-it`(ggml-org/gemma-3-1b-it-GGUF,Q4_K_M;2026-09-11 由 -> gemma-4-E2B 换小提速。此前:`google/gemma-4-E2B-it-qat-q4_0-gguf`,架构 gemma4,Apache-2.0) +> 适用模型:`openbmb/MiniCPM5-2B-GGUF` 的 `MiniCPM5-2B-Q4_K_M.gguf`(架构 llama, +> ChatML 模板,官方语言 en/zh;2026-09-12 由 Gemma 系换入。历史:gemma-3-1b-it / +> google/gemma-4-E2B-it-qat-q4_0-gguf,模板分支保留在 LlmEngine 可回切) > 本文件是唯一事实源:App 内 `SummarizePipeline.kt` 逐字引用这里的文本。改提示词先改本文件。 > PC 调试方法见文末「用 PC llama.cpp 复现」。 @@ -10,22 +11,13 @@ | 步骤 | temperature | maxNewTokens | 备注 | |---|---|---|---| | P1 滚动笔记更新 | 0(Greedy) | 900 | 可复现;笔记要求确定性 | -| P2 成稿 | 0.5 / topK 64 / topP 0.95 | 2500 | Gemma 4 官方推荐采样档位,低温折中 | +| P2 成稿 | 0.5 / topK 64 / topP 0.95 | 2500 | 低温采样,兼顾成稿自然度与可复现 | 思考(thinking)关闭:App 渲染模板时不注入 `<|think|>` 标记;模型若仍输出 `<|channel>thought…` 块,App 侧正则剔除。 -## 1. 聊天模板(App 手工渲染,与 Gemma 4 canonical template 单轮路径等价) +## 1. 聊天模板(以代码为准) -``` -(tokenizer 自动) -<|turn>system -{SYSTEM} -<|turn>user -{USER} -<|turn>model -``` - -特殊 token id:``=2,``=1,`<|turn>`=105,``=106。生成遇 ``、``、或 8192 上下文耗尽即停。 +模板由 `LlmEngine.renderChatPrompt` 按模型家族渲染:gemma4(旧)/ gemma3 / minicpm(ChatML im_start/im_end + 空 think 前缀禁用思考)。MiniCPM5 的确切 token 序列与特殊 id 以 `renderMinicpmPrompt` 与 GGUF 内嵌模板为准;PC 调试直接加 --jinja 用内置模板即可。 --- @@ -34,33 +26,34 @@ ### SYSTEM(固定,勿动态改写) ``` -あなたは会議の文字起こしを処理する記録係です。与えられた「現在のメモ」に「新しい断片」の内容を統合し、更新後のメモをJSONだけで出力してください。 +你是处理会议转写记录的笔记员。请把【新的断片】的内容合并进给定的【当前笔记】,只以JSON输出更新后的笔记。 -厳守事項: -- 出力は指定スキーマのJSONオブジェクトのみ。説明文・コードフェンス・前置き禁止。 -- メモは日本語で書く。ただし人名・組織名・製品名・固有名詞は原文表記をそのまま維持。 -- 数字、金額、日時、期限、回数、単位は原文どおり正確に転記。ざっくり要約しない。 -- 否定・保留・反対の意図を消さない。「見送り」「再検討」「反対意見」はそれ自体が重要な情報。 -- 各項目に根拠となる発話のタイムレンジを「出典」に付ける(形式: "mm:ss-mm:ss"、複数可 ";" 区切り)。 -- 断片に新情報がない場合はメモをほぼ変更しない(前回末尾は必ず更新)。 -- 全体で500字を超えそうな場合、重要度の低い過去の項目を統合・削除して圧縮する。削除時は行動項目と已定事項を最後に守る。 -- 「前回末尾」には、次の断片と文脈が繋がるための直近話題を1文で書く。 -- JSONの値の中にダブルクォートが必要なら \" にエスケープ。改行は \n を使う。 +严格要求: +- 只输出符合指定schema的JSON对象;禁止说明文字、代码围栏、开场语。 +- schema的键名必须原样输出,不得翻译、转写或改写(程序按这些键校验)。 +- 笔记内容用中文书写;人名、组织名、产品名等专有名词保留原文写法。 +- 不得把输入笔记原样照抄:必须从【新的断片】中提取至少一条笔记尚未覆盖的信息并合并;断片确实没有新信息时,保留原笔记并只更新「前回末尾」。 +- 数字、金额、日期时间、期限、次数、单位按原文准确转记,不许模糊概括。 +- 不得删除否定、保留、反对的意图;「暂缓」「再议」「反对意见」本身就是重要信息。 +- 每个条目在「出典」字段给出依据发言的时间段(格式:"mm:ss-mm:ss",多个用";"分隔)。 +- 总计超过500字时,合并删除低价值的旧条目压缩;「行動項目」和「已定事項」最后才允许删。 +- 「前回末尾」用一句话写最近的议题,供下一个断片衔接上下文。 +- JSON字符串值内需要双引号时转义为\";换行用\n。 -スキーマ(キー名は変更禁止): +schema(键名禁止改动): {"已定事項":[{"内容":"","出典":""}],"討論中":[{"内容":"","出典":""}],"行動項目":[{"内容":"","担当者":"","期限":"","出典":""}],"人物":[{"名前":"","立場":""}],"前回末尾":""} ``` ### USER(每块渲染;`{}` 为占位符) ``` -【現在のメモ】 +【当前笔记】 {notes_json} -【新しい断片】(タイムスタンプは会議開始からの分:秒、[]内は話者ラベル) +【新的断片】(时间戳为会议开始的 分:秒,[]内是说话人标签) {chunk_text} -更新後のメモJSONを出力してください。 +请输出更新后的笔记JSON。 ``` --- @@ -70,35 +63,35 @@ ### SYSTEM(固定) ``` -あなたは会議の公式議事録作成者です。会議メモ(JSON)と用語集を受け取ったら、指定言語で完成した議事録をMarkdownで出力してください。 +你是会议的官方纪要撰写者。收到会议笔记(JSON)与术语表后,用指定成稿语言输出完整的Markdown会议纪要。 -厳守事項: -- 出力言語は指定された「成稿言語」を厳守。日本語メモの内容をそのまま指定言語の書き言葉文体で記述する。 -- 本文は成稿言語の文字のみで書く:中文稿なら仮名を一切出さない、English稿なら漢字・仮名を一切出さない、日本語稿なら製品名以外に英語の文を残さない。 -- ただし人物・会社・ブランドなどの固有名詞は、メモ・用語集にある原文表記のまま保留してよい(上記の文字制限の例外)。 -- 見出しは次の4つを必ずこの順で:「議題と経過」「決定事項」「行動項目」「未決事項・持ち帰り」。該当ゼロなら「(なし)」と明記。 -- 行動項目は表形式(担当者 / 内容 / 期限 / 出典)。担当者が不明なら「未定」と書く。 -- 各項目の末尾に出典を付ける。形式: `出典: mm:ss-mm:ss`。出典はメモJSONの「出典」フィールドのみを使う。捏造禁止。 -- メモにない事実・推測・補足説明を追加しない。 -- 人名・製品名は用語集の正式表記に統一する(用語集にない表記はメモのまま採用)。 -- 冒頭にメタ情報を置く: 会議日時(指定があれば)、話者数、使用言語、文字起こし総尺。 -- Markdown本文のみ出力。コードフェンスで全体を囲まない。 +严格要求: +- 输出语言严格遵守指定的「成稿语言」,用该语言的书面体撰写。 +- 正文只用成稿语言的文字书写:中文稿不得出现日文假名;英文稿不得出现汉字与假名;日文稿除产品名外不得残留英文句子。 +- 人物、公司、品牌等专有名词可保留笔记/术语表中的原文写法(上述文字限制的例外)。 +- 章节标题必须依序为「议题与经过」「决定事项」「行动项」「未决与待议」;成稿语言非中文时翻译成该语言的等价标题;没有条目的章节明确写"(无)"。 +- 行动项用表格输出,列为 负责人 / 内容 / 期限 / 出处;负责人不明时写"待定"。 +- 每个条目末尾给出处,格式 `出处: mm:ss-mm:ss`;出处只允许使用笔记JSON中「出典」字段的值,不得编造。 +- 不得添加笔记中没有的事实、推测或补充说明。 +- 人名、产品名以术语表的正式写法统一(术语表没有的沿用笔记写法)。 +- 开头放置元信息:会议时间(如有)、发言人数、使用语言、转写总时长。 +- 只输出Markdown正文,不要用代码围栏包裹全文。 ``` ### USER ``` -【成稿言語】{language_name} +【成稿语言】{language_name} -【会議メタ情報】開始: {meeting_start} / 話者数: {n_speakers} / 総尺: {total_duration} +【会议信息】开始: {meeting_start} / 发言人数: {n_speakers} / 总时长: {total_duration} -【用語集(正式表記の一覧。人名・製品名の誤変換をこれに統一)】 +【术语表(正式写法列表;人名、产品名的错误写法以此统一)】 {glossary_lines} -【会議メモJSON】 +【会议笔记JSON】 {notes_json} -上記に基づき、議事録Markdownを出力してください。 +请根据以上内容输出会议纪要Markdown。 ``` --- @@ -120,13 +113,13 @@ ## 用 PC llama.cpp 复现(提示词调优用) -1. 取 llama.cpp master(≥2026-09;gemma3 架构早已支持,App 的 `libsummarize-llm-jni.so` 无需重编)。 -2. 模型:`gemma-3-1b-it-Q4_K_M.gguf`(ggml-org/gemma-3-1b-it-GGUF)。 +1. 取 llama.cpp master(gemma3 及更早架构均支持,App 的 `libsummarize-llm-jni.so` 无需重编)。 +2. 模型:`MiniCPM5-2B-Q4_K_M.gguf`(openbmb/MiniCPM5-2B-GGUF,架构 llama)。 3. 跑 P1 示例(替换 sys/user 两段;`--jinja` 走 GGUF 内置模板,与 App 的 - `renderGemma3Prompt`——system 并入首个 user turn——等价): + `renderMinicpmPrompt`——ChatML + 预填空 `` 块禁用思考——等价): ```bash -llama-cli -m gemma-3-1b-it-Q4_K_M.gguf -c 8192 -ctk q8_0 -ctv f16 --jinja \ +llama-cli -m MiniCPM5-2B-Q4_K_M.gguf -c 8192 -ctk q8_0 -ctv f16 --jinja \ --temp 0 -n 900 \ --sys "$(sed -n '/^### SYSTEM/,/^```$/p' prompts.md | sed '1,2d;$d')" \ -p "$USER_PROMPT" diff --git a/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt b/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt index 3f26fd5..d9d947d 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt @@ -75,10 +75,23 @@ class LlmEngine( append("model\n") } + // ---- MiniCPM5(ChatML,生成 prompt 默认带 think 块)---- + // GGUF 模板 enable_thinking=false 时的 generation prompt 为: + // <|im_start|>assistant\n\n\n\n\n + // 显式预填空 think → 模型直接输出正文,不浪费解码在思考上。 + // EOG 为 <|im_end|>(llama.cpp 官方 GGUF,元数据已配)。 + fun renderMinicpmPrompt(system: String, user: String): String = + buildString { + append("<|im_start|>system\n").append(system.trim()).append("<|im_end|>\n") + append("<|im_start|>user\n").append(user.trim()).append("<|im_end|>\n") + append("<|im_start|>assistant\n\n\n\n\n") + } + fun renderChatPrompt(system: String, user: String, template: String = "gemma4"): String = when (template) { "gemma3" -> renderGemma3Prompt(system, user) + "minicpm" -> renderMinicpmPrompt(system, user) else -> renderGemma4Prompt(system, user) } } 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 619533f..11c0fed 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt @@ -20,6 +20,7 @@ import android.text.method.LinkMovementMethod import android.text.method.ScrollingMovementMethod import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan import android.text.style.StyleSpan import android.util.Log import android.view.LayoutInflater @@ -83,6 +84,7 @@ class MainActivity : AppCompatActivity() { ) { var line: SubtitleLine? = null var refined: Boolean = false // false => 底稿(第一遍)未被精修覆盖 + var trans: String? = null // 定稿后的界面语言译文(批次二:双语字幕) } private class SubtitleLine( @@ -130,6 +132,17 @@ class MainActivity : AppCompatActivity() { // sessions never grow the data or the view tree unboundedly private val maxSubtitleLines = 300 + // ---- 历史会议只读视图 ---- + private lateinit var historyButton: Button + private lateinit var historyTitle: TextView + private lateinit var historyBack: Button + private var historyMode = false + private var stashedLiveLines: ArrayList? = null + private var modelsLoaded = false + + // 字幕贴底跟随(纯自动,无按钮):用户上划即暂停跟随,回到底部自动恢复 + private var autoFollow = true + // Language of the first-pass (nemotron-3.5) model. The model selects ONE // language prompt per stream; there is no native "subset of languages" // option, so we combine: @@ -156,36 +169,49 @@ class MainActivity : AppCompatActivity() { // compares it (cosine) with the window reference taken at the start of // the current utterance. A big similarity drop == voice changed mid // utterance == force an endpoint cut at the switch point. - private class SpeakerSwitch( - val label: String?, - val cutSamples: Int, - val ownerSid: Long, // 判定属于哪句话;消费时句已结算则作废 - ) + // 路线A(2026-09-14):滚动检测不再句中强切,只"预备快速断句"—— + // 命中疑似换人后的 5s 内,一旦出现 ≥0.3s 的语音停顿,即按 endpoint + // 流程结算句子,由 endpoint 权威判定决定归属与换行(不再有错误的中途切)。 + @Volatile private var switchSuspicionAtMs = 0L + // 静音判据必须画在底噪地板之上(minWindowRms 同档):定 0.006 时 + // 底噪(~0.008-0.015)让 silenceRun 永远攒不满,FAST-EP 0 次触发 + private val silenceRms = 0.012f // 100ms 块静音判据(RMS) + private val silenceNeedSamples = 4800 // 0.3s 连续静音 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 + // 实测(2026-09-14):2s 短窗 + base 模型下同人 simA 在 0.05~0.7 震荡, + // 阈值维度已不可分 → 判定窗加长到 4s 拉开分布,阈值回调到 0.35 + private val speakerSwitchDrop = 0.35f // cos(window, reference) below this => switch + // 最近邻兜底(P1 收紧,2026-09-11):精确匹配不中→top≥0.42 且领先第二名 + // ≥0.06 才归入最相似身份;模糊判新人(修"错并到别人行") + private val verdictFallbackMin = 0.42f + private val nearestMargin = 0.06f // 滚动检测 fast 空间的兜底阈值(仅影响换行标签显示,endpoint 仍权威) - private val rollingFallbackMin = 0.30f + private val rollingFallbackMin = 0.36f + // P3 防漂移:只有高置信同人窗(≥0.5)才吸收进参考,权重也降到 15% + private val emaAbsorbMin = 0.50f // 滚动检测在 fast 空间查"这人是谁"仅用于**显示标签**,阈值放宽到 0.4; // endpoint 权威判定仍用准确模型 + speakerThreshold(0.5)。 private val fastSearchThreshold = 0.4f // 最速档:1.2s 周期 + 单票(switchStrikesNeeded=1)。误断的代价只是 // 多分行+流重置,不丢音频(快照平铺保证时间轴连续)。 - private val switchStrikesNeeded = 1 - private val minWindowRms = 0.004f // 静音主导窗不做判定(embedding 不可靠) + private val switchStrikesNeeded = 1 // 长窗方差小,单票(真换人检出 3~6s) + private val minWindowRms = 0.012f // 真语音窗才判定:0.004 挡不住房间底噪,垃圾嵌入引发换人风暴 + // 窗长门槛(短窗 embedding 方差是误切主因):锚点需 ≥2s,判定需 ≥3s + private val anchorMinSamples = 32000 + private val judgeMinSamples = 48000 private val enableRollingSwitch = true // A/B 诊断:关掉=只用自然 endpoint 断句 @Volatile private var rollingLowHits = 0 // C(最新者胜):每次投递滚动检测都刷新序号,队列里被超额的旧任务直接跳过 @Volatile private var rollingSeq = 0L // 兜底残料:当前句子槽位最近一次滚动窗口声纹。整句判定因碎片算不出 // embedding 时复用它做归属(用户要求 2026-09-11) + // 双参考检测(方案②):anchor=句级锚点(EMA 慢更新),prev=上一窗(短期参考)。 + // 与两者都低才判换人;只低锚点、贴上一窗 → 渐变,更新参考不断行。 + @Volatile private var windowPrevEmb: FloatArray? = null @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 @Volatile private var checkSerial = 0L // bumped when an utterance settles; @@ -216,6 +242,12 @@ class MainActivity : AppCompatActivity() { @Volatile private var isRecording: Boolean = false + // 会话(session)从「开始」到「停止」,暂停/继续不结束会话 + private var sessionActive = false + private var pauseStartedAt = 0L + private lateinit var pauseButton: Button + private lateinit var langButton: Button + // ---- External model loading (adb push) --------------------------------- // Models live in Android/data//files/models/ instead of assets: // adb push /sdcard/Android/data/com.k2fsa.sherpa.onnx/files/models/ @@ -228,7 +260,14 @@ class MainActivity : AppCompatActivity() { private val externalModelDir: File by lazy { File(getExternalFilesDir(null), "models") } - private var loadFromAssets = true + // 声纹模型统一放 models/speaker/(兼容读旧的 models 根平铺位置) + private val speakerModelDir: File by lazy { + File(externalModelDir, "speaker") + } + private fun speakerModelFile(name: String): File { + val inSub = File(speakerModelDir, name) + return if (inSub.isFile) inSub else File(externalModelDir, name) + } private var modelPrefix = "" // ---- Summarize (离线 LLM 会议纪要,里程碑 1) ------------------------------ @@ -236,9 +275,15 @@ class MainActivity : AppCompatActivity() { // 完全一致。摘要是独立世界:专用后台线程 + 独立 .so(llama.cpp), // 不 import 任何 sherpa-onnx ASR native 对象。 private val summarizeEnabled = true - private val summarizeModelDirName = "gemma4-e2b" - private val summarizeGgufName = "gemma-4-E2B_q4_0-it.gguf" - private val summarizeChatTemplate = "gemma4" + // 摘要模型一键开关:true=MiniCPM5-2B(新);false=回退已验证的 Gemma-4-E2B + private val useMinicpm5 = true + private val summarizeModelDirName: String + get() = if (useMinicpm5) "minicpm5-2b" else "gemma4-e2b" + private val summarizeGgufName: String + get() = if (useMinicpm5) "MiniCPM5-2B-Q4_K_M.gguf" + else "gemma-4-E2B_q4_0-it.gguf" + private val summarizeChatTemplate: String + get() = if (useMinicpm5) "minicpm" else "gemma4" private lateinit var summarizeButton: Button private lateinit var copyMinutesButton: Button private lateinit var minutesStatus: TextView @@ -250,6 +295,26 @@ class MainActivity : AppCompatActivity() { // generate 阻塞在 native,取消靠 abort;引擎只在摘要线程持有 @Volatile private var summarizeEngine: LlmEngine? = null + // ---- 字幕双语翻译(批次二,2026-09-14)-------------------------------- + // 独立翻译模型:Gemma-3-1B-it Q4_K_M(arch gemma3,.so 已支持,模板分支 + // renderGemma3Prompt 现成)。定稿后句级翻译,原文+小字译文双语显示; + // 界面语言与句子语言一致时不翻;模型文件缺失 → 功能静默关闭(自然降级)。 + private val translateModelDirName = "gemma-3-1b-it" + private val translateGgufName = "gemma-3-1b-it-Q4_K_M.gguf" + private val translateIdleUnloadMs = 60_000L + private val translateThread = HandlerThread("translate").also { it.start() } + private val translateHandler by lazy { Handler(translateThread.looper) } + @Volatile private var translateEngine: LlmEngine? = null + private var translateDisabled = false + // KV 污染 workaround(2026-09-14,不改 .so):nativeGenerate 不清 KV, + // 同引擎连续 generate 会复读上一条 → 每"批"generate 前重建引擎。 + // 初译从逐句改为小批量编号翻译(凑 4 句或 2.5s 定时封批)。 + private val initialBatchMax = 4 + private val initialBatchDelayMs = 2_500L + private val initialPending = ArrayList>() + private var initialBatchTimer: Runnable? = null + private val translatedSids = HashSet() // 每槽只翻一次 + companion object { private val SUMMARY_THREADS = 6 // 空闲时:大核全用 private val SUMMARY_THREADS_BUSY = 4 // 录音中成稿:给 ASR 让核(实测零丢帧) @@ -284,6 +349,19 @@ class MainActivity : AppCompatActivity() { recordButton.isEnabled = false recordButton.setOnClickListener { onclick() } + pauseButton = findViewById(R.id.pause_button) + pauseButton.isEnabled = false + pauseButton.setOnClickListener { onPauseResumeClick() } + + langButton = findViewById(R.id.lang_button) + langButton.setOnClickListener { showLanguagePicker() } + + historyButton = findViewById(R.id.history_button) + historyTitle = findViewById(R.id.history_title) + historyBack = findViewById(R.id.history_back) + historyButton.setOnClickListener { showHistoryPicker() } + historyBack.setOnClickListener { exitHistory() } + summarizeButton = findViewById(R.id.summarize_button) copyMinutesButton = findViewById(R.id.copy_minutes_button) minutesStatus = findViewById(R.id.minutes_status) @@ -292,7 +370,7 @@ class MainActivity : AppCompatActivity() { if (summarizeEnabled) { // 生成中再点同一按钮 = 取消(按钮文案随之切换) summarizeButton.setOnClickListener { - if (isSummarizing) cancelSummarize() else onSummarizeClick() + if (isSummarizing) cancelSummarize() else showSummarizePicker() } copyMinutesButton.isEnabled = false copyMinutesButton.setOnClickListener { onCopyMinutesClick() } @@ -310,6 +388,11 @@ class MainActivity : AppCompatActivity() { subtitleRecycler.layoutManager = LinearLayoutManager(this) subtitleAdapter = SubtitleAdapter() subtitleRecycler.adapter = subtitleAdapter + subtitleRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { + if (dy != 0) autoFollow = !rv.canScrollVertically(1) + } + }) // 后台工作线程降为 background 优先级:定稿/声纹再忙也不能抢占录音 // 回调与第一遍解码的 CPU(需求一.1 停摆加固)。 @@ -327,33 +410,35 @@ class MainActivity : AppCompatActivity() { thread(true) { try { decideModelSource() - setStatus("正在加载第一遍模型(nemotron-3.5)…") + setStatus(getString(R.string.loading_pass1)) initOnlineRecognizer() - setStatus("正在加载第二遍模型(funasr-nano)…") + setStatus(getString(R.string.loading_pass2)) initOfflineRecognizer() - setStatus("正在加载声纹模型…") + setStatus(getString(R.string.loading_speaker)) initSpeakerIdentification() if (speakersEnabled) { - initFastSpeakerModel( - application.assets.list("").orEmpty() - ) + initFastSpeakerModel() } - runOnUiThread { recordButton.isEnabled = true } - val speakerNote = if (speakersEnabled) "说话人功能已启用" - else "⚠ 说话人功能未启用:缺少声纹模型 ${speakerModels[0]}" + - "(请 adb push 到 ${externalModelDir.absolutePath})" + runOnUiThread { + modelsLoaded = true + if (!historyMode) recordButton.isEnabled = true + } + val speakerNote = if (speakersEnabled) getString(R.string.speaker_on) + else getString(R.string.speaker_off, speakerModels[0], + speakerModelDir.absolutePath) setStatus( - "模型加载完成(${if (loadFromAssets) "APK 内置模型" else "外部模型"})。" + - "$speakerNote。\n点击 Start 开始识别" + getString(R.string.load_done) + speakerNote + + "\n" + getString(R.string.tap_start) ) } catch (t: Throwable) { Log.e(TAG, "Failed to load models", t) setStatus( - "模型加载失败:${t.message}\n\n" + - "如使用外部模型,请在模型所在目录执行:\n" + - "adb push $firstModelDirName $secondModelDirName " + - "${speakerModels[0]} " + - "/sdcard/Android/data/com.k2fsa.sherpa.onnx/files/models/" + getString(R.string.load_failed, t.message, + externalModelDir.absolutePath) + + "\n\n" + + "adb push $firstModelDirName $externalModelDir.absolutePath/\n" + + "adb push $secondModelDirName $externalModelDir.absolutePath/\n" + + "adb push ${speakerModels[0]} ${speakerModelDir.absolutePath}/" ) } } @@ -361,26 +446,18 @@ class MainActivity : AppCompatActivity() { // Prefer models pushed to Android/data//files/models/; fall back to // the assets copy when the first-pass model directory is not found there. + // 单一来源(用户决定 2026-09-12):外部 models/ 目录为唯一模型路径, + // 废弃 APK assets 兜底——避免新旧两份模型并存的排查黑洞。 private fun decideModelSource() { - if (File(externalModelDir, firstModelDirName).isDirectory) { - loadFromAssets = false - modelPrefix = externalModelDir.absolutePath + "/" - Log.i(TAG, "Loading models from external dir: ${externalModelDir.absolutePath}") - if (!File(externalModelDir, secondModelDirName).isDirectory) { - Log.w(TAG, "Missing $secondModelDirName in external model dir") - } - } else { - loadFromAssets = true - modelPrefix = "" - Log.i(TAG, "External models not found, loading from assets") - // Native code calls exit() (uncatchable!) when an asset is missing, - // so verify up front and fail gracefully on screen instead. - val top = application.assets.list("").orEmpty() - if (!top.contains(firstModelDirName)) { - throw IllegalStateException( - "外部目录与 APK 内置 assets 中都找不到第一遍模型。" - ) - } + if (!File(externalModelDir, firstModelDirName).isDirectory) { + throw IllegalStateException( + "缺少第一遍模型,请 push 到 ${externalModelDir.absolutePath}" + ) + } + modelPrefix = externalModelDir.absolutePath + "/" + Log.i(TAG, "Loading models from external dir: ${externalModelDir.absolutePath}") + if (!File(externalModelDir, secondModelDirName).isDirectory) { + Log.w(TAG, "Missing $secondModelDirName in external model dir") } } @@ -399,53 +476,108 @@ class MainActivity : AppCompatActivity() { } subtitleThread.quitSafely() speakerCheckThread.quitSafely() + translateEngine?.let { e -> translateHandler.post { runCatching { e.release() } } } + translateThread.quitSafely() } private fun onclick() { - if (!isRecording) { - val ret = initMicrophone() - if (!ret) { - Log.e(TAG, "Failed to initialize microphone") - return - } - Log.i(TAG, "state: ${audioRecord?.state}") - audioRecord!!.startRecording() - recordButton.setText(R.string.stop) - isRecording = true - // 会议记录场景:录音期间保持屏幕常亮,防息屏冻结/MIUI 杀后台 - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - resetBuffer() - pendingSwitch = null - windowRefEmb = null - rollingLowHits = 0 - checkSerial++ - activeLabelForWindow = null - lastConfirmedLabel = null - uiHandler.post { resetSubtitles() } - if (summarizeEnabled) { - // 每次录音 = 一个新会话文件(§4.2);主线程操作 - sessionTranscript?.close() - sessionTranscript = SessionTranscript.begin(filesDir) - } + if (historyMode) return // 历史只读视图:Start 已置灰,双保险 + if (!sessionActive) startSession() else stopSession() + } - recordingThread = thread(true) { - processSamples() - } - Log.i(TAG, "Started recording") - } else { - isRecording = false - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - audioRecord!!.stop() - audioRecord!!.release() - audioRecord = null - recordButton.setText(R.string.start) - if (summarizeEnabled) { - // 保留 sessionTranscript 引用直到下次开始录音: - // 「刚停止」是点生成纪要最常见的时间窗 - sessionTranscript?.flush() - } - Log.i(TAG, "Stopped recording") + private fun onPauseResumeClick() { + if (historyMode || !sessionActive) return + if (isRecording) pauseSession() else resumeSession() + } + + /** 开始/继续一场会议会话(新建转写文件,字幕清零重开)。 */ + private fun startSession() { + if (!initMicrophone()) { + Log.e(TAG, "Failed to initialize microphone") + return } + audioRecord!!.startRecording() + sessionActive = true + isRecording = true + recordButton.setText(R.string.stop) + pauseButton.isEnabled = true + pauseButton.setText(R.string.pause) + // 会议记录场景:录音期间保持屏幕常亮,防息屏冻结/MIUI 杀后台 + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + resetBuffer() + switchSuspicionAtMs = 0L + windowRefEmb = null + windowPrevEmb = null + rollingLowHits = 0 + checkSerial++ + activeLabelForWindow = null + lastConfirmedLabel = null + uiHandler.post { resetSubtitles() } + if (summarizeEnabled) { + // 每次开始会话 = 一个新会议文件(§4.2);主线程操作 + sessionTranscript?.close() + sessionTranscript = SessionTranscript.begin(filesDir) + } + recordingThread = thread(true) { + processSamples() + } + Log.i(TAG, "Session started, recording") + } + + /** 暂停:麦克风停,会话不死——录音线程退出时软 endpoint 定格在途句, + * 精修/声纹判定回来照常修正字幕,标签命名可用。 */ + private fun pauseSession() { + closeTransSeg() + isRecording = false + pauseStartedAt = SystemClock.elapsedRealtime() + runCatching { audioRecord?.stop() } // 不 release,继续时复用 + pauseButton.setText(R.string.resume) + Log.i(TAG, "Session paused") + } + + /** 继续:恢复采集;暂停时长从会议时间轴扣除。 */ + private fun resumeSession() { + val rec = audioRecord + if (rec == null) { // 极端情况:实例被回收,重建 + if (!initMicrophone()) return + } + sessionTranscript?.addPaused( + SystemClock.elapsedRealtime() - pauseStartedAt + ) + audioRecord!!.startRecording() + isRecording = true + pauseButton.setText(R.string.pause) + recordingThread = thread(true) { + processSamples() + } + Log.i(TAG, "Session resumed") + } + + /** 停止:整场会议结束(转写保留,可继续生成纪要)。 */ + private fun stopSession() { + closeTransSeg() + if (isRecording) { + isRecording = false + runCatching { audioRecord?.stop() } + } else { + // 暂停态停止:这截暂停时间也要计入扣除,免得最后一句时间戳偏长 + sessionTranscript?.addPaused( + SystemClock.elapsedRealtime() - pauseStartedAt + ) + } + sessionActive = false + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + audioRecord?.release() + audioRecord = null + recordButton.setText(R.string.start) + pauseButton.isEnabled = false + pauseButton.setText(R.string.pause) + if (summarizeEnabled) { + // 保留 sessionTranscript 引用直到下次开始: + // 「刚停止」是点生成纪要最常见的时间窗 + sessionTranscript?.flush() + } + Log.i(TAG, "Session stopped") } private fun processSamples() { @@ -471,23 +603,28 @@ class MainActivity : AppCompatActivity() { // rolling speaker-check state (recording-thread only) // v8.1 定点回滚:窗口 2s / 周期 1.2s(v8 的 1.2s/0.7s 单票导致误断 // 刷屏灰"未识别");单票保留,配合误断防护使用 - val window = RollingWindow(sampleRateInHz * 2) - val checkStep = (sampleRateInHz * 1.2f).toInt() + val window = RollingWindow(sampleRateInHz * 4) + val checkStep = (sampleRateInHz * 2f).toInt() var newSinceCheck = 0 + var silenceRun = 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) + silenceRun + ret else 0 pushChunk(samples) window.push(samples) - if (enableRollingSwitch && speakersEnabled && sentenceId != -1L) { + // P2(2026-09-11):跨句常驻检测,参考 embedding 为"行级" + // (appendNewLine 时重置)——新人开口后第一个周期内即可触发换行, + // 句间不再依赖 endpoint 的迟到判定 + if (enableRollingSwitch && speakersEnabled) { newSinceCheck += ret if (newSinceCheck >= checkStep) { newSinceCheck = 0 val win = window.snapshot() - val cutAt = bufferTotal val seq = checkSerial val owner = sentenceId if (rmsOf(win) >= minWindowRms) { @@ -495,7 +632,11 @@ class MainActivity : AppCompatActivity() { speakerCheckHandler.post { // C:被更新的检测投递超过 → 直接作废(防积压) if (rseq != rollingSeq) return@post - if (seq != checkSerial || pendingSwitch != null) return@post + if (seq != checkSerial) return@post + // 窗长门槛:短窗不做判定/锚点(方差大是误切主因) + if (windowRefEmb == null) { + if (win.size < anchorMinSamples) return@post + } else if (win.size < judgeMinSamples) return@post // E:滚动检测走轻量模型(base),推理快 ~4 倍; // 匹配也只在 fast 空间的镜像 registry 里查 val emb = SpeakerRecognition.computeEmbeddingFast( @@ -506,46 +647,63 @@ class MainActivity : AppCompatActivity() { rollingEmb = emb // 计算耗时内句子可能已结算:回写前二次核销 if (seq != checkSerial) return@post - val ref = windowRefEmb - if (ref == null) { - // first window of this utterance becomes the - // voice reference + val anchor = windowRefEmb + val prev = windowPrevEmb ?: anchor + if (anchor == null) { + // 本行首个窗口:同时定下锚点与短期参考 windowRefEmb = emb + windowPrevEmb = emb rollingLowHits = 0 } else { - val sim = SpeakerRecognition.similarity(emb, ref) - if (sim >= speakerSwitchDrop) { - // 同人缓漂:参考嵌入 EMA 慢速更新 - windowRefEmb = blendEmb(ref, emb) + val simA = SpeakerRecognition.similarity(emb, anchor) + val simP = if (prev == null) simA else + SpeakerRecognition.similarity(emb, prev) + // ① 数据回路:输出相似度分布,阈值靠实测调 + Log.i(TAG, "SWITCH-CHECK simA=${"%.2f".format(simA)}" + + " simP=${"%.2f".format(simP)}" + + " drop=$speakerSwitchDrop hits=$rollingLowHits") + if (simA >= speakerSwitchDrop) { + // 同人缓漂:高置信窗才吸收进锚点(P3 防漂移) + if (simA >= emaAbsorbMin) { + windowRefEmb = blendEmb(anchor, emb) + } + windowPrevEmb = emb + rollingLowHits = 0 + } else if (simP >= emaAbsorbMin) { + // 只偏离锚点、紧贴上一窗 → 音量/情绪渐变, + // 更新双参考,不换行 + windowRefEmb = blendEmb(anchor, emb) + windowPrevEmb = emb rollingLowHits = 0 } else { rollingLowHits += 1 - if (rollingLowHits >= switchStrikesNeeded && - pendingSwitch == null - ) { + if (rollingLowHits >= switchStrikesNeeded) { rollingLowHits = 0 - val newLabel = - rollingSpeakerSearch(emb) + val newLabel = rollingSpeakerSearch(emb) val active = activeLabelForWindow when { - // ③ 误断防护:查出来就是当前人 → - // 相似度毛刺,不断行,参考回拉 + // 误断防护:查出来就是当前人 → + // 毛刺,不断行,参考回拉 newLabel != null && active != null && newLabel == active -> { - windowRefEmb = - blendEmb(ref, emb) + windowRefEmb = blendEmb(anchor, emb) + windowPrevEmb = emb } - // ③ 当前行还没归属、而窗口恰好匹配到 - // 某个已知人 → 直接冠名,不必断行 + // 当前行未归属而窗匹配到已知人 → + // 直接冠名,不必断行 newLabel != null && active == null -> { uiHandler.post { adoptActiveLabel(newLabel) } } - else -> pendingSwitch = SpeakerSwitch( - newLabel, cutAt, owner - ) + else -> { + // 路线A:只"预备",不立即切 + switchSuspicionAtMs = + SystemClock.elapsedRealtime() + Log.i(TAG, "SWITCH-SUSPECT" + + " armed fast-endpoint owner=sid$owner") + } } } } @@ -560,81 +718,27 @@ class MainActivity : AppCompatActivity() { onlineRecognizer.decode(stream) } - // ---- 声纹判定换人:强制 endpoint(需求一.4a/b/c) ---- - val ps = pendingSwitch - if (ps != null && ps.ownerSid == sentenceId) { - pendingSwitch = null - checkSerial++ // drop in-flight window jobs - val oldLabel = activeLabelForWindow // 被切断句子的归属人 - Log.i(TAG, "speaker-switch FIRED newLabel=" + - (ps.label?.let { displayName(it) } ?: "未识别") + - " owner=sid${ps.ownerSid} cut@" + ps.cutSamples + - " (active=" + (oldLabel?.let { displayName(it) } - ?: "null") + ")") - val size = samplesBuffer.size - if (size == 0) { - uiHandler.post { onSpeakerSwitched(ps.label) } - } else { - var k = ((ps.cutSamples + chunkN / 2) / chunkN) - .coerceIn(1, size) - // 在 ±3 块(±0.3s)内吸附到低能量(自然间隙)边界 - var bestK = k - var bestRms = Float.MAX_VALUE - for (c in maxOf(1, k - 3)..minOf(size, k + 3)) { - val r = (chunkRms.getOrNull(c - 1) ?: 0f) + - (chunkRms.getOrNull(c) ?: 0f) - if (r < bestRms) { - bestRms = r - bestK = c - } - } - k = bestK - var cutSamples = 0 - for (c in 0 until k) cutSamples += samplesBuffer[c].size - val rawText = onlineRecognizer.getResult(stream).text - val dispText = - if (rawText.isNotBlank() && isInAllowedLanguages(rawText)) - rawText else "" - onlineRecognizer.reset(stream) // OnlineStream 复位 - - val sid = sentenceId - sentenceId = -1L - windowRefEmb = null - rollingLowHits = 0 - newSinceCheck = 0 - - if (sid != -1L) { - if (dispText.isNotBlank()) { - val snap = flattenPrefix(k) // fresh copy 供后台 - val dur = - cutSamples / sampleRateInHz.toFloat() - uiHandler.post { - val accepted = submitRefineTask( - sid, snap, dur, "FORCE_SWITCH", - oldLabel, dispText, rawText - ) - finalizeSentence(sid, accepted) - } - } else { - // 过滤/尚无文字:丢弃该段,不产生空行 - uiHandler.post { - finalizeSentence(sid, false) - } - } - } - uiHandler.post { onSpeakerSwitched(ps.label) } - dropPrefixChunks(k) - // 切换点之后的音频留在缓冲区,归入新说话人(需求一.4c) - } - } else if (ps != null) { - // 判定属于已结算的旧句子:纯过期,直接丢弃 —— - // 不 reset、不丢音频(v5 误断第一遍的根因修复) - pendingSwitch = null - Log.i(TAG, "SPEAKER-SWITCH ignored: stale owner=${ps.ownerSid} current=$sentenceId") + // 路线A:疑似换人后的第一个自然停顿(≥0.3s)即触发结算 + val fastEp = switchSuspicionAtMs != 0L && + SystemClock.elapsedRealtime() - switchSuspicionAtMs < 5_000L && + silenceRun >= silenceNeedSamples + val susExpired = switchSuspicionAtMs != 0L && !fastEp && + SystemClock.elapsedRealtime() - switchSuspicionAtMs >= 5_000L + if (susExpired) { + // 观察用:5s 内没等到停顿,记录当时静音累积便于校准 + Log.i(TAG, "SWITCH-SUSPECT expired (no pause; silenceRun=" + + "$silenceRun/$silenceNeedSamples, lastRms=" + + "%.4f".format(if (samples.isNotEmpty()) + rmsOf(samples) else 0f) + ")") + switchSuspicionAtMs = 0L } - - val isEndpoint = onlineRecognizer.isEndpoint(stream) + if (fastEp) { + switchSuspicionAtMs = 0L + silenceRun = 0 + Log.i(TAG, "FAST-EP fired (suspicion + pause)") + } + val isEndpoint = onlineRecognizer.isEndpoint(stream) || fastEp val rawAll = onlineRecognizer.getResult(stream).text var text = rawAll @@ -644,8 +748,7 @@ class MainActivity : AppCompatActivity() { if (text.isNotBlank()) { if (sentenceId == -1L) { sentenceId = lineIdSeq.incrementAndGet() - windowRefEmb = null - rollingLowHits = 0 + // P2:同人行内新句不重置行级参考(换行由 appendNewLine 重置) } val sid = sentenceId uiHandler.post { onPartial(sid, text) } @@ -657,8 +760,6 @@ class MainActivity : AppCompatActivity() { if (sentenceId != -1L) { val sid = sentenceId sentenceId = -1L - windowRefEmb = null - rollingLowHits = 0 newSinceCheck = 0 checkSerial++ @@ -675,7 +776,9 @@ class MainActivity : AppCompatActivity() { // 定格 + 送检立即走主线程队列,不等声纹 uiHandler.post { val accepted = submitRefineTask( - sid, snap, dur, "NATURAL", null, text, prompt + sid, snap, dur, + if (fastEp) "FAST_EP" else "NATURAL", + null, text, prompt ) finalizeSentence(sid, accepted) } @@ -694,7 +797,6 @@ class MainActivity : AppCompatActivity() { onlineRecognizer.reset(stream) val sid = sentenceId sentenceId = -1L - windowRefEmb = null if (disp.isNotBlank() && bufferTotal > 0) { val snap = flattenSamples() val dur = bufferTotal / sampleRateInHz.toFloat() @@ -733,9 +835,9 @@ class MainActivity : AppCompatActivity() { } else null val label = if (emb == null) null else (SpeakerRecognition.registry.search(emb, speakerThreshold) - // 兜底②:精确匹配不中 → 归入最相似的已注册身份 - ?: SpeakerRecognition.registry.nearest( - emb, verdictFallbackMin + // 兜底②(P1 收紧):最近邻+优势差;模糊→注册新人 + ?: nearestAttribution( + emb, SpeakerRecognition.registry, verdictFallbackMin )) ?: SpeakerRecognition.nextAutoLabel().also { SpeakerRecognition.registry.addAuto(it, emb) @@ -776,34 +878,21 @@ class MainActivity : AppCompatActivity() { return true } + // 声纹准确模型:单一来源 models/speaker/(按序尝试 v2→base)。 + // 缺文件时 native 抛可捕获异常,禁用声纹功能但 ASR 不受影响。 private fun initSpeakerIdentification() { - val topAssets = application.assets.list("").orEmpty() for (model in speakerModels) { - // 1) external models dir; a missing file throws a catchable error try { SpeakerRecognition.initExtractor( assetManager = null, - model = File(externalModelDir, model).absolutePath, + model = speakerModelFile(model).absolutePath, ) speakersEnabled = true activeSpeakerModel = model - Log.i(TAG, "Speaker extractor ready (external: $model)") + Log.i(TAG, "Speaker extractor ready: $model") return } catch (t: Throwable) { - Log.i(TAG, "Speaker model '$model' not usable from external dir") - } - // 2) assets -- but ONLY if the file is really there: a missing - // asset makes the native code call exit() (uncatchable). - if (topAssets.contains(model)) { - try { - SpeakerRecognition.initExtractor(application.assets, model = model) - speakersEnabled = true - activeSpeakerModel = model - Log.i(TAG, "Speaker extractor ready (assets: $model)") - return - } catch (t: Throwable) { - Log.e(TAG, "Speaker model '$model' failed to load from assets: $t") - } + Log.i(TAG, "Speaker model '$model' not usable: ${t.message}") } } speakersEnabled = false @@ -811,26 +900,17 @@ class MainActivity : AppCompatActivity() { } // E:轻量声纹模型(eres2net base)专供滚动换人检测,推理快 ~4 倍。 - // 找不到该文件或准确模型本来就是 base 时跳过,滚动检测自动回退到准确模型。 - private fun initFastSpeakerModel(topAssets: Array) { + // 找不到该文件(或准确模型本来就是 base)时跳过,滚动检测自动回退准确模型。 + private fun initFastSpeakerModel() { val fastName = speakerModels[1] if (activeSpeakerModel == fastName) return // 准确模型已是 base try { SpeakerRecognition.initFastExtractor( - null, File(externalModelDir, fastName).absolutePath + null, speakerModelFile(fastName).absolutePath ) - Log.i(TAG, "fast speaker model ready (external): $fastName") - return + Log.i(TAG, "fast speaker model ready: $fastName") } catch (t: Throwable) { - Log.i(TAG, "fast model '$fastName' not in external dir") - } - if (topAssets.contains(fastName)) { - try { - SpeakerRecognition.initFastExtractor(application.assets, fastName) - Log.i(TAG, "fast speaker model ready (assets): $fastName") - } catch (t: Throwable) { - Log.e(TAG, "fast model '$fastName' failed: $t") - } + Log.i(TAG, "fast model '$fastName' not found, rolling falls back to v2") } } @@ -838,25 +918,25 @@ class MainActivity : AppCompatActivity() { // internalKey:该行的内部声纹键("S0"/真名);null=归属未定,提示稍后再命名。 private fun showNameSpeakerDialog(internalKey: String?) { if (internalKey == null) { - Toast.makeText(this, "该行声纹归属尚未确定,先说一句完整的话再命名", + Toast.makeText(this, getString(R.string.naming_wait_owner), Toast.LENGTH_SHORT).show() return } val input = EditText(this).apply { - hint = "「${displayName(internalKey)}」的真名,如 张三" + hint = getString(R.string.naming_hint, displayName(internalKey)) } AlertDialog.Builder(this) .setTitle(if (autoLabelRegex.matches(internalKey)) - "命名说话人「${displayName(internalKey)}」" - else "将「$internalKey」改名为…") + getString(R.string.naming_title_new, displayName(internalKey)) + else getString(R.string.naming_title_rename, displayName(internalKey))) .setView(input) - .setPositiveButton("确定") { _, _ -> + .setPositiveButton(getString(R.string.dlg_ok)) { _, _ -> val name = input.text.toString().trim() if (name.isNotEmpty() && name != internalKey) { renameSpeaker(internalKey, name) } } - .setNegativeButton("取消", null) + .setNegativeButton(getString(R.string.dlg_cancel), null) .show() } @@ -864,7 +944,7 @@ class MainActivity : AppCompatActivity() { // 真名不得长得像内部键/显示名,避免与 S{n}→已识别人N 的映射冲突 if (autoLabelRegex.matches(name) || Regex("^(已识别人?\\d+|未识别\\d*)$").matches(name)) { - Toast.makeText(this, "名字不能是 S数字/已识别人N/未识别N 形式", + Toast.makeText(this, getString(R.string.naming_bad_name), Toast.LENGTH_SHORT).show() return } @@ -886,7 +966,7 @@ class MainActivity : AppCompatActivity() { mergeAdjacentSameLabel() subtitleAdapter.notifyItemRangeChanged(0, subtitleLines.size) Log.i(TAG, "speaker renamed: $oldKey -> $name") - Toast.makeText(this, "已命名「$name」,相邻同名行已合并", + Toast.makeText(this, getString(R.string.naming_done, name), Toast.LENGTH_SHORT).show() } @@ -913,6 +993,23 @@ class MainActivity : AppCompatActivity() { } } + private fun showLanguagePicker() { + if (sessionActive) { + Toast.makeText(this, getString(R.string.lang_busy_session), + Toast.LENGTH_SHORT).show() + return + } + val modes = arrayOf(LanguageUtil.MODE_SYSTEM, LanguageUtil.MODE_ZH, + LanguageUtil.MODE_EN, LanguageUtil.MODE_JA) + val opts = arrayOf(getString(R.string.lang_system), + "简体中文", "English", "日本語") + AlertDialog.Builder(this) + .setTitle(getString(R.string.lang_title)) + .setItems(opts) { _, which -> LanguageUtil.set(this, modes[which]) } + .setNegativeButton(getString(R.string.dlg_cancel), null) + .show() + } + private fun initMicrophone(): Boolean { if (ActivityCompat.checkSelfPermission( this, Manifest.permission.RECORD_AUDIO @@ -927,15 +1024,18 @@ class MainActivity : AppCompatActivity() { TAG, "buffer size in milliseconds: ${numBytes * 1000.0f / sampleRateInHz}" ) - audioRecord = AudioRecord( - audioSource, - sampleRateInHz, - channelConfig, - audioFormat, - // 4x: ~400 ms of slack so occasional scheduling hiccups on the - // recording thread never overflow the OS capture ring (v5 停摆加固) - numBytes * 4 - ) + // 暂停/继续复用同一实例;仅首次(或释放后)创建 + if (audioRecord == null) { + audioRecord = AudioRecord( + audioSource, + sampleRateInHz, + channelConfig, + audioFormat, + // 4x: ~400 ms of slack so occasional scheduling hiccups on the + // recording thread never overflow the OS capture ring (v5 停摆加固) + numBytes * 4 + ) + } return true } @@ -958,7 +1058,7 @@ class MainActivity : AppCompatActivity() { } onlineRecognizer = OnlineRecognizer( - assetManager = if (loadFromAssets) application.assets else null, + assetManager = null, config = config, ) } @@ -983,7 +1083,7 @@ class MainActivity : AppCompatActivity() { offlineConfig = config offlineRecognizer = OfflineRecognizer( - assetManager = if (loadFromAssets) application.assets else null, + assetManager = null, config = config, ) } @@ -1128,7 +1228,20 @@ class MainActivity : AppCompatActivity() { // 音量/音色自然漂移,避免"越说越像别人"的连锁误判 private fun blendEmb(a: FloatArray, b: FloatArray): FloatArray { val n = minOf(a.size, b.size) - return FloatArray(n) { a[it] * 0.7f + b[it] * 0.3f } + // P3:0.85/0.15 —— 慢吸收,防把"正在切换的新人"喂进参考 + return FloatArray(n) { a[it] * 0.85f + b[it] * 0.15f } + } + + // 最近邻归属(P1):top≥minScore 且对第二名优势≥nearestMargin 才归入, + // 两个已注册者难分高下时返回 null → 按新人处理(宁多勿错) + private fun nearestAttribution( + emb: FloatArray, registry: SpeakerRegistry, minScore: Float, + ): String? { + val r = registry.nearestRank(emb) + val name = r.name ?: return null + return if (r.score >= minScore && r.score - r.secondScore >= nearestMargin) { + name + } else null } private fun flattenSamples(): FloatArray = flattenPrefix(samplesBuffer.size) @@ -1248,24 +1361,40 @@ class MainActivity : AppCompatActivity() { StyleSpan(Typeface.BOLD), start, start + head.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) - // 点击标签 → 命名/改名(绑定时捕获内部键,重命名后整表重绑) + // 点击标签 → 命名/改名(绑定时捕获内部键,重命名后整表重绑); + // 历史只读视图不提供点击 val internalKey = line.label - sb.setSpan(object : ClickableSpan() { - override fun onClick(widget: View) { - showNameSpeakerDialog(internalKey) - } + if (!historyMode) { + sb.setSpan(object : ClickableSpan() { + override fun onClick(widget: View) { + showNameSpeakerDialog(internalKey) + } - override fun updateDrawState(ds: TextPaint) { - ds.color = color - ds.isUnderlineText = true - } - }, start, start + head.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + override fun updateDrawState(ds: TextPaint) { + ds.color = color + ds.isUnderlineText = true + } + }, start, start + head.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } for ((i, s) in line.sentences.withIndex()) { if (s.text.isBlank()) continue if (sb.length > start + head.length) { - sb.append(joinerAfter(line.sentences[i - 1].text)) + // 历史逐句分行;直播按语言习惯衔接(分支写开:Char/String 混 + // 在 if 表达式里会被推断成 Any,没有匹配的 append 重载) + if (historyMode) sb.append('\n') + else sb.append(joinerAfter(line.sentences[i - 1].text)) + } + sb.append(if (historyMode) s.text else s.text.lowercase()) + // 双语字幕:原文下方小字斜体译文(批次二;颜色沿用行色,整行着色) + s.trans?.takeIf { it.isNotBlank() }?.let { tr -> + sb.append('\n') + val ts = sb.length + sb.append(tr) + sb.setSpan(RelativeSizeSpan(0.82f), ts, sb.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + sb.setSpan(StyleSpan(Typeface.ITALIC), ts, sb.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } - sb.append(s.text.lowercase()) } sb.setSpan( ForegroundColorSpan(color), start, sb.length, @@ -1284,6 +1413,7 @@ class MainActivity : AppCompatActivity() { colorSeq = 0 lastLabel = null lastSentenceSettledAt = SystemClock.elapsedRealtime() + autoFollow = true if (n > 0) subtitleAdapter.notifyItemRangeRemoved(0, n) textView.visibility = View.GONE subtitleRecycler.visibility = View.VISIBLE @@ -1294,9 +1424,9 @@ class MainActivity : AppCompatActivity() { private val autoLabelRegex = Regex("^S(\\d+)$") private fun displayName(label: String?): String { - if (label == null) return "未识别" + if (label == null) return getString(R.string.label_unknown) val m = autoLabelRegex.matchEntire(label) - return if (m != null) "已识别人${m.groupValues[1].toInt() + 1}" else label + return if (m != null) getString(R.string.label_identified, m.groupValues[1].toInt() + 1) else label } private fun colorIdxFor(label: String): Int = @@ -1306,7 +1436,9 @@ class MainActivity : AppCompatActivity() { // 新行出现即封掉之前的开放行,并把其中"未定格的进行中句子"整体迁移 // 过来 —— 硬保证 in-progress 只可能发生在最底行(修复:声纹判定搬家后, // 旧行里还在长字的句子被孤立在上方的问题) - val old = subtitleLines.lastOrNull() + // 历史只读态:残留回调要开的新行进暂存列表,不动展示中的历史行 + val list = if (historyMode) stashedLiveLines ?: subtitleLines else subtitleLines + val old = list.lastOrNull() val migrating = if (old != null && old.open) old.sentences.filter { !it.finalized } else emptyList() old?.open = false @@ -1318,8 +1450,14 @@ class MainActivity : AppCompatActivity() { label = label, colorIdx = colorIdxFor(label ?: "__unknown__"), ) - subtitleLines.add(line) - subtitleAdapter.notifyItemInserted(subtitleLines.size - 1) + // P2:行级参考——新行开启即重置,下一句(新人)的第一窗重新定基准 + windowRefEmb = null + windowPrevEmb = null + rollingLowHits = 0 + list.add(line) + if (!historyMode) { + subtitleAdapter.notifyItemInserted(subtitleLines.size - 1) + } // 进行中句子接在新行尾部,继续实时更新 line.sentences.addAll(migrating) for (s in migrating) s.line = line @@ -1375,12 +1513,19 @@ class MainActivity : AppCompatActivity() { // 不会再有精修回来:立即收回句子索引 // (SKIP_SHORT 等情况 submitRefineTask 已写对账日志,不重复记) sentenceById.remove(sid) + requestTranslate(sid, sentence.text) // 底稿即最终文本 → 直接翻译 } sentence.line?.let { notifyLine(it) } autoScroll() } private fun findSentence(sid: Long): Sentence? { + // 历史只读态:当前会话句子去暂存里找,绝不误伤展示中的历史行 + val pool = if (historyMode) stashedLiveLines else subtitleLines + pool?.let { list -> + for (l in list) for (s in l.sentences) if (s.id == sid) return s + } + if (historyMode) return null for (l in subtitleLines) { for (s in l.sentences) { if (s.id == sid) return s @@ -1389,6 +1534,107 @@ class MainActivity : AppCompatActivity() { return null } + // ==================== 历史会议只读视图 ==================== + + private fun showHistoryPicker() { + if (isRecording) { + Toast.makeText(this, getString(R.string.hist_busy_recording), Toast.LENGTH_SHORT).show() + return + } + val files = SessionTranscript.dirOf(filesDir) + .listFiles { f -> f.isFile && f.name.endsWith(".jsonl") } + ?.sortedByDescending { it.lastModified() } ?: emptyList() + val meetings = files.mapNotNull { f -> + runCatching { SessionTranscript.load(f) }.getOrNull() + ?.takeIf { it.isNotEmpty() }?.let { f to it } + } + if (meetings.isEmpty()) { + Toast.makeText(this, getString(R.string.hist_none), Toast.LENGTH_SHORT).show() + return + } + val labels = meetings.map { (f, es) -> + val hasMd = File( + SessionTranscript.dirOf(filesDir), f.nameWithoutExtension + ".md" + ).isFile + getString(R.string.hist_item, f.nameWithoutExtension, es.size, + SummarizePipeline.mmss(es.last().endMs), + if (hasMd) getString(R.string.hist_has_md) else "") + }.toTypedArray() + AlertDialog.Builder(this) + .setTitle(getString(R.string.hist_title)) + .setItems(labels) { _, which -> enterHistory(meetings[which].first, meetings[which].second) } + .setNegativeButton(getString(R.string.dlg_cancel), null) + .show() + } + + private fun enterHistory(f: File, entries: List) { + if (historyMode) exitHistory() + stashedLiveLines = ArrayList(subtitleLines) + subtitleLines.clear() + subtitleLines.addAll(buildHistoryLines(entries)) + subtitleAdapter.notifyDataSetChanged() + historyMode = true + // 进历史必须自己把列表切可见:本场没录过时 recycler 仍是 GONE + subtitleRecycler.visibility = View.VISIBLE + textView.visibility = View.GONE + recordButton.isEnabled = false + pauseButton.isEnabled = false + historyButton.visibility = View.GONE + historyBack.visibility = View.VISIBLE + historyTitle.visibility = View.VISIBLE + historyTitle.text = getString(R.string.history_reading, f.nameWithoutExtension) + subtitleRecycler.scrollToPosition(0) + } + + private fun exitHistory() { + if (!historyMode) return + historyMode = false + subtitleLines.clear() + stashedLiveLines?.let { subtitleLines.addAll(it) } + stashedLiveLines = null + subtitleAdapter.notifyDataSetChanged() + historyButton.visibility = View.VISIBLE + historyBack.visibility = View.GONE + historyTitle.visibility = View.GONE + recordButton.isEnabled = modelsLoaded + pauseButton.isEnabled = sessionActive + // 退出历史:有当前会话字幕则回到字幕视图,否则恢复状态文本页 + val hasLive = subtitleAdapter.itemCount > 0 + subtitleRecycler.visibility = if (hasLive) View.VISIBLE else View.GONE + textView.visibility = if (hasLive) View.GONE else View.VISIBLE + if (hasLive && subtitleAdapter.itemCount > 0) { + subtitleRecycler.scrollToPosition(subtitleAdapter.itemCount - 1) + } + } + + /** 历史行分组:同人并行且句间隔 ≤60s 并一行;每句自带时间戳前缀。 */ + private fun buildHistoryLines(entries: List): ArrayList { + val out = ArrayList() + var cur: SubtitleLine? = null + var lastEnd = -1L + for (e in entries) { + var line = cur + if (line == null || line.label != e.speaker || + e.startMs - lastEnd > lineIdleTimeoutMs + ) { + line = SubtitleLine( + 0, e.speaker, colorIdxFor(e.speaker ?: "__unknown__") + ) + out.add(line) + cur = line + } + val s = Sentence( + e.sid, + "(${SummarizePipeline.mmss(e.startMs)}-${SummarizePipeline.mmss(e.endMs)}) ${e.text.trim()}", + finalized = true, + ) + s.line = line + line.sentences.add(s) + lastEnd = e.endMs + } + return out + } + // 声纹线程回传的整句权威判定: // * 无判定或已同人 → 不动; // * 占位行首次判定 → 就地冠名;若满足"保守并回"条件(本行为底行、 @@ -1552,13 +1798,8 @@ class MainActivity : AppCompatActivity() { // 会话记录器挂钩②(终稿文本):与字幕层看到同一最终结果 sessionTranscript?.onRefined(sid, sentence.text, sentence.refined) } - } - - // 换人强制断句后:封掉旧行,之后的 partial 开新行并归属新说话人。 - private fun onSpeakerSwitched(label: String?) { - subtitleLines.lastOrNull()?.takeIf { it.open }?.open = false - lastConfirmedLabel = label - activeLabelForWindow = label + // 定稿文本已确定(应用或保底稿)→ 排队翻译(批次二:双语字幕) + requestTranslate(sid, sentence.text) } // 滚动窗口查"这是谁":有 fast 模型时查 fast 镜像(0.4 宽松阈值,仅供显示); @@ -1567,13 +1808,13 @@ class MainActivity : AppCompatActivity() { private fun rollingSpeakerSearch(emb: FloatArray): String? = if (SpeakerRecognition.fastReady()) SpeakerRecognition.fastRegistry.search(emb, fastSearchThreshold) - ?: SpeakerRecognition.fastRegistry.nearest( - emb, rollingFallbackMin + ?: nearestAttribution( + emb, SpeakerRecognition.fastRegistry, rollingFallbackMin ) else SpeakerRecognition.registry.search(emb, speakerThreshold) - ?: SpeakerRecognition.registry.nearest( - emb, verdictFallbackMin + ?: nearestAttribution( + emb, SpeakerRecognition.registry, verdictFallbackMin ) // ③ 滚动检测查到了已知人、但当前行还没有归属(占位"未识别"): @@ -1609,13 +1850,229 @@ class MainActivity : AppCompatActivity() { } } - private fun autoScroll() { - if (subtitleRecycler.visibility != View.VISIBLE) return - val lm = subtitleRecycler.layoutManager as LinearLayoutManager - val last = subtitleAdapter.itemCount - 1 - if (last >= 0 && lm.findLastCompletelyVisibleItemPosition() >= last - 1) { - subtitleRecycler.scrollToPosition(last) + // 跟随态贴底:最后一行不足一屏时置顶它;行比屏高(同人连说很多句)时 + // scrollBy 把**行尾(最新文字)**露出来——修复"长行永远停在第一行、 + // 且 100ms 刷新不断把用户上划弹回去"的问题 + // ==================== 字幕双语翻译(批次二)==================== + + private fun uiLanguage(): String { + @Suppress("DEPRECATION") + val l = resources.configuration.locale.language + return if (l == "ja" || l == "en") l else "zh" + } + + private fun requestTranslate(sid: Long, text: String) { + if (text.isBlank()) return + // ---- 轮次分段(换人/静默6s → 封段送二次翻译)---- + findSentence(sid)?.line?.let { line -> + val now = SystemClock.elapsedRealtime() + var seg = openTransSeg + if (seg == null || seg.line !== line || + now - seg.lastAddMs > transSegGapMs + ) { + closeTransSeg() + seg = TransSeg(line) + openTransSeg = seg + } + seg.lastAddMs = now + findSentence(sid)?.let { st -> + if (!seg.sentences.contains(st)) seg.sentences.add(st) + } + armSegTimer() } + // ---- 初译(小批量;段末二次翻译会逐句覆盖)---- + if (translateDisabled) return + if (!translatedSids.add(sid)) return // 每槽初译只排一次 + if (SummarizePipeline.detectLanguage(text) == uiLanguage()) return + initialPending.add(Pair(sid, text)) + if (initialPending.size >= initialBatchMax) flushInitialBatch() else armInitialBatchTimer() + } + + // ==================== 轮次分段与二次翻译(2026-09-14)==================== + // 段 = 同一字幕行(同一说话人轮次)内连续定稿的句子。封段时机: + // ①不同行/不同说话人的句子定稿;②段内 6s 无新定稿(静默); + // ③暂停/停止。封段后整段带编号送二次翻译,按编号逐句覆盖初译; + // 单句段不重翻(初译已是全上下文)。 + private class TransSeg(val line: SubtitleLine) { + val sentences = ArrayList() + var lastAddMs = 0L + } + private val transSegGapMs = 6_000L + private var openTransSeg: TransSeg? = null + private var transSegTimer: Runnable? = null + + private fun armSegTimer() { + transSegTimer?.let { uiHandler.removeCallbacks(it) } + val r = Runnable { + val seg = openTransSeg ?: return@Runnable + if (SystemClock.elapsedRealtime() - seg.lastAddMs > transSegGapMs) { + closeTransSeg() + } + } + transSegTimer = r + uiHandler.postDelayed(r, transSegGapMs + 500) + } + + private fun closeTransSeg() { + val seg = openTransSeg ?: return + openTransSeg = null + transSegTimer?.let { uiHandler.removeCallbacks(it) } + transSegTimer = null + flushInitialBatch() + val refs = seg.sentences.filter { it.text.isNotBlank() } + if (translateDisabled || refs.size < 2) return + if (SummarizePipeline.detectLanguage(refs.joinToString("")) == + uiLanguage() + ) return + translateHandler.post { + val map = translateNumbered(refs.map { + Pair(it.id.toString(), it.text) + }) + if (map == null || !plausibleTranslations(map.values)) { + Log.i(TAG, "SEG-TRANSLATE rejected/failed, keep drafts") + return@post + } + uiHandler.post { + val touched = HashSet() + refs.forEach { s2 -> + map[s2.id.toString()]?.takeIf { it.isNotBlank() }?.let { tr -> + if (s2.trans != tr) { + s2.trans = tr + s2.line?.let { touched.add(it) } + } + } + } + touched.forEach { notifyLine(it) } + Log.i(TAG, "SEG-TRANSLATE applied " + refs.size + " sentences") + } + } + } + + private fun parseNumberedJson(out: String?): Map? { + if (out.isNullOrBlank()) return null + val a = out.indexOf('{'); val b = out.lastIndexOf('}') + if (a < 0 || b <= a) return null + return try { + val o = org.json.JSONObject(out.substring(a, b + 1)) + val m = HashMap() + o.keys().forEach { k -> m[k] = o.optString(k) } + m + } catch (t: Throwable) { null } + } + + private fun ensureTranslateEngine(): LlmEngine? { + val f = File(File(externalModelDir, translateModelDirName), translateGgufName) + if (!f.isFile) { + Log.i(TAG, "translate model missing at ${f.absolutePath}; bilingual off") + return null + } + return try { + LlmEngine(f.absolutePath, 4096, 2, chatTemplate = "gemma3") + .also { translateEngine = it } + } catch (t: Throwable) { + Log.w(TAG, "translate engine init failed: ${t.message}") + null + } + } + + private fun armInitialBatchTimer() { + initialBatchTimer?.let { uiHandler.removeCallbacks(it) } + val r = Runnable { flushInitialBatch() } + initialBatchTimer = r + uiHandler.postDelayed(r, initialBatchDelayMs) + } + + private fun flushInitialBatch() { + initialBatchTimer?.let { uiHandler.removeCallbacks(it) } + initialBatchTimer = null + if (initialPending.isEmpty() || translateDisabled) return + val batch = ArrayList(initialPending) + initialPending.clear() + translateHandler.post { + val map = translateNumbered(batch.map { + Pair(it.first.toString(), it.second) + }) + if (map == null || !plausibleTranslations(map.values)) return@post + uiHandler.post { + val touched = HashSet() + batch.forEach { (sid, _) -> + map[sid.toString()]?.takeIf { it.isNotBlank() }?.let { tr -> + findSentence(sid)?.let { s2 -> + if (s2.trans != tr) { + s2.trans = tr + s2.line?.let { touched.add(it) } + } + } + } + } + touched.forEach { notifyLine(it) } + } + } + } + + // 一批 = 一个全新引擎(nativeGenerate 不清 KV 的 workaround)。 + // items: key(通常是 sid 字符串) × 原文;返回 key × 译文,失败 null。 + private fun translateNumbered(items: List>): Map? { + if (items.isEmpty()) return emptyMap() + val targetName = when (uiLanguage()) { + "ja" -> "日本語"; "en" -> "English"; else -> "简体中文" + } + val numbered = items.mapIndexed { i, (_, t) -> "${i + 1}. $t" } + .joinToString("\n") + translateEngine?.let { runCatching { it.release() } } + translateEngine = null + val engine = ensureTranslateEngine() + ?: run { translateDisabled = true; return null } + val out = try { + engine.generate( + systemPrompt = "你是专业翻译。逐句翻译下面编号句子为" + targetName + + "。各句相互独立,必须逐句对应翻译,不得合并、不得重复、不得解释。" + + "只输出JSON对象,键是编号字符串,值是译文," + + "例如 {\"1\":\"…\",\"2\":\"…\"}。不得增删编号。", + userPrompt = "【句子】\n$numbered", + maxNewTokens = (160 * items.size).coerceAtMost(900), + temperature = 0f, + ) + } catch (t: Throwable) { + Log.w(TAG, "TRANSLATE batch failed: ${t.message}"); null + } + val byIdx = parseNumberedJson(out) ?: return null + val res = HashMap() + items.forEachIndexed { i, (key, _) -> + byIdx[(i + 1).toString()]?.let { res[key] = it } + } + return res + } + + // 复读/退化检测:一半以上译文雷同 → 判退化,保留旧译文 + private fun plausibleTranslations(vals: Collection): Boolean { + val v = vals.filter { it.isNotBlank() } + return v.size < 2 || v.distinct().size * 2 >= v.size + } + + private val unloadRunnable = Runnable { + val e = translateEngine ?: return@Runnable + if (initialPending.isEmpty()) { + translateEngine = null + translateHandler.post { runCatching { e.release() } } + } + } + + private fun autoScroll() { + if (historyMode) return + if (subtitleRecycler.visibility != View.VISIBLE || !autoFollow) return + val last = subtitleAdapter.itemCount - 1 + if (last < 0) return + val lm = subtitleRecycler.layoutManager as LinearLayoutManager + val view = lm.findViewByPosition(last) + if (view == null) { + subtitleRecycler.scrollToPosition(last) + subtitleRecycler.post { if (autoFollow) autoScroll() } + return + } + val overflow = view.bottom - (subtitleRecycler.height - subtitleRecycler.paddingBottom) + if (overflow > 0) subtitleRecycler.scrollBy(0, overflow) + else subtitleRecycler.scrollToPosition(last) } // ==================== Summarize(会中/会后一键成稿,§4.2)================ @@ -1623,13 +2080,13 @@ class MainActivity : AppCompatActivity() { // 进度与结果内嵌在下半屏「会议纪要」区(生成中旧纪要不清空);成稿成功 // 落 <会话>.notes.json,下次点击只处理新增句子续写笔记,纪要始终覆盖 // "开会至今"全量。模型点击才 mmap、完成即 release(§4.2 模型生命周期)。 - private fun onSummarizeClick() { - if (isSummarizing) { - Toast.makeText(this, "纪要正在生成中,再点一次可取消", Toast.LENGTH_SHORT).show() - return - } + /** + * 「生成纪要」入口(2026-09-12):预检后先选会议——当前会话(有定格句时) + * 置顶,历史会议按时间倒序;选定后交给 runSummarize。 + */ + private fun showSummarizePicker() { if (!LlmRuntime.ensure()) { - Toast.makeText(this, "本机 ABI 未包含摘要运行时库(仅 arm64-v8a 真机可用)", + Toast.makeText(this, getString(R.string.sum_no_abi), Toast.LENGTH_LONG).show() return } @@ -1639,47 +2096,64 @@ class MainActivity : AppCompatActivity() { if (!gguf.isFile) { Toast.makeText( this, - "缺少摘要模型。请执行:\nadb push $summarizeGgufName " + - "/sdcard/Android/data/com.k2fsa.sherpa.onnx/files/models/$summarizeModelDirName/", + getString(R.string.sum_model_missing, + "adb push $summarizeGgufName ${modelDir.absolutePath}/"), Toast.LENGTH_LONG ).show() return } - - // 会话选择(2026-09-11 修订):只要存在当前会话,它就是唯一数据源 - // (含刚 Stop 未久的会话——供会后整理用)。新会话还没有定格句子时 - // 如实提示,绝不回退到上一场的 jsonl/notes,避免"新会议按旧笔记摘要"。 - // 仅当本进程没有会话上下文(如重启 app)时,才取盘上最新一场会议。 - val entries: List - val wallStart: Long - val baseName: String val cur = sessionTranscript - if (cur != null) { - val snap = cur.snapshot() - if (snap.isEmpty()) { - Toast.makeText(this, "当前会议还没有定格的转写,先录一段再生成纪要", - Toast.LENGTH_SHORT).show() - return - } - entries = snap; wallStart = cur.startWallMs - baseName = cur.file.nameWithoutExtension - } else { - val f = SessionTranscript.latestFile(filesDir) - if (f == null) { - Toast.makeText(this, "还没有任何会议转写,请先录一场会议", - Toast.LENGTH_SHORT).show() - return - } - entries = SessionTranscript.load(f) - if (entries.isEmpty()) { - Toast.makeText(this, "最近一场会议没有定格句子", Toast.LENGTH_SHORT).show() - return - } - wallStart = SessionTranscript.fileStartWallMs(f) - baseName = f.nameWithoutExtension + val snap = cur?.snapshot() ?: emptyList() + val dir = SessionTranscript.dirOf(filesDir) + val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".jsonl") } + ?.sortedByDescending { it.lastModified() } ?: emptyList() + val meetings = files.mapNotNull { f -> + if (f.name == cur?.file?.name) return@mapNotNull null // 当前会话用快照 + runCatching { SessionTranscript.load(f) }.getOrNull() + ?.takeIf { it.isNotEmpty() } + ?.let { Triple(f, it, SessionTranscript.fileStartWallMs(f)) } } + if (snap.isEmpty() && meetings.isEmpty()) { + Toast.makeText(this, getString(R.string.sum_none), + Toast.LENGTH_SHORT).show() + return + } + val items = ArrayList, Long>>() + val labels = ArrayList() + if (snap.isNotEmpty() && cur != null) { + items.add(Triple(cur.file, snap, cur.startWallMs)) + labels.add(getString(R.string.sum_current, snap.size, + SummarizePipeline.mmss(snap.last().endMs))) + } + for ((f, es, ws) in meetings) { + items.add(Triple(f, es, ws)) + val hasMd = File(dir, f.nameWithoutExtension + ".md").isFile + labels.add(getString(R.string.hist_item, f.nameWithoutExtension, + es.size, SummarizePipeline.mmss(es.last().endMs), + if (hasMd) getString(R.string.sum_has_md) else "")) + } + AlertDialog.Builder(this) + .setTitle(getString(R.string.sum_title)) + .setItems(labels.toTypedArray()) { _, which -> + runSummarize(items[which].first, items[which].second, + items[which].third) + } + .setNegativeButton(getString(R.string.dlg_cancel), null) + .show() + } + + /** 对指定会议跑纪要流水线;断点笔记/.md 均按该会议文件名命名。 */ + private fun runSummarize(sourceFile: File, entries: List, + wallStart: Long) { + if (entries.isEmpty()) { + Toast.makeText(this, getString(R.string.sum_empty_this), Toast.LENGTH_SHORT).show() + return + } + val baseName = sourceFile.nameWithoutExtension + val modelDir = File(externalModelDir, summarizeModelDirName) + val gguf = File(modelDir, summarizeGgufName) if (isRecording) { - Toast.makeText(this, "纪要生成中,录音转写不会中断", Toast.LENGTH_SHORT).show() + Toast.makeText(this, getString(R.string.sum_recording_note), Toast.LENGTH_SHORT).show() } // 增量断点:上次成稿留下的 <会话>.notes.json → 只处理新增句子续写笔记 val saved = MeetingNotesIO.load( @@ -1693,7 +2167,7 @@ class MainActivity : AppCompatActivity() { } isSummarizing = true summarizeCancelled = false - runOnUiThread { setMinutesBusyUi(true, "正在加载摘要模型(约 3.2 GB)…") } + runOnUiThread { setMinutesBusyUi(true, getString(R.string.sum_loading, summarizeModelDirName)) } val entriesRef = entries // 快照已在主线程拷贝,线程安全 thread(true) { @@ -1709,8 +2183,8 @@ class MainActivity : AppCompatActivity() { summarizeEngine = engine runOnUiThread { setMinutesBusyUi(true, if (resumeNotes != null) - "模型就绪,从上次断点续跑($resumeIndex/${entriesRef.size} 句已消化)…" - else "模型就绪,共 ${entriesRef.size} 句,开始滚动笔记…") + getString(R.string.sum_ready_resume, resumeIndex, entriesRef.size) + else getString(R.string.sum_ready, entriesRef.size)) } val prompts = PromptsLoader.load(application.assets) val glossary = File(modelDir, "glossary.txt").let { @@ -1727,7 +2201,12 @@ class MainActivity : AppCompatActivity() { listener = object : SummarizeListener { override fun onProgress(stage: String, done: Int, total: Int) { runOnUiThread { - minutesStatus.text = "正在$stage: $done/$total" + val sName = when (stage) { + "滚动笔记" -> getString(R.string.sum_stage_notes) + "成稿" -> getString(R.string.sum_stage_draft) + else -> stage + } + minutesStatus.text = getString(R.string.sum_progress, sName, done, total) } } override fun isCancelled(): Boolean = summarizeCancelled @@ -1746,10 +2225,11 @@ class MainActivity : AppCompatActivity() { minutesText.text = result.markdown minutesHint.visibility = View.GONE minutesStatus.visibility = View.VISIBLE - minutesStatus.text = "纪要已保存(${result.language}," + - "${result.totalChunks} 块" + - (if (result.failedChunks > 0) ",${result.failedChunks} 块解析失败" else "") + - "):${mdFile.name}" + minutesStatus.text = getString(R.string.sum_saved, + result.language, result.totalChunks, + if (result.failedChunks > 0) getString( + R.string.sum_failed_chunks, result.failedChunks) else "", + mdFile.name) copyMinutesButton.isEnabled = true } } catch (t: Throwable) { @@ -1758,15 +2238,15 @@ class MainActivity : AppCompatActivity() { runOnUiThread { minutesStatus.visibility = View.VISIBLE minutesStatus.text = when (t) { - is SummarizeCancelledException -> "已取消生成(保留上一次的纪要)" + is SummarizeCancelledException -> getString(R.string.sum_cancelled) is SummarizeEmptyException -> - "已自动取消:${t.message}(上次纪要未覆盖)" - else -> "生成失败:${t.message}" + getString(R.string.sum_auto_cancel, t.message) + else -> getString(R.string.sum_fail_toast, t.message) } Toast.makeText(this, when (t) { - is SummarizeCancelledException -> "已取消生成" - is SummarizeEmptyException -> "已自动取消生成" - else -> "纪要生成失败:${t.message}" + is SummarizeCancelledException -> getString(R.string.sum_cancel_toast) + is SummarizeEmptyException -> getString(R.string.sum_empty_toast) + else -> getString(R.string.sum_fail_toast, t.message) }, Toast.LENGTH_LONG).show() } } finally { @@ -1794,7 +2274,7 @@ class MainActivity : AppCompatActivity() { private fun cancelSummarize() { summarizeCancelled = true summarizeEngine?.abort() // 打断在途 generate;finally 里统一收尾 - minutesStatus.text = "正在取消…" + minutesStatus.text = getString(R.string.sum_canceling) minutesStatus.visibility = View.VISIBLE } @@ -1808,7 +2288,7 @@ class MainActivity : AppCompatActivity() { try { val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager cm.setPrimaryClip(android.content.ClipData.newPlainText("meeting-minutes", body)) - Toast.makeText(this, "纪要全文已复制(${body.length} 字)", Toast.LENGTH_SHORT).show() + Toast.makeText(this, getString(R.string.sum_copied), Toast.LENGTH_SHORT).show() } catch (t: Throwable) { Toast.makeText(this, "复制失败:$t", Toast.LENGTH_SHORT).show() } @@ -1826,7 +2306,7 @@ class MainActivity : AppCompatActivity() { minutesHint.visibility = View.GONE copyMinutesButton.isEnabled = true minutesStatus.visibility = View.VISIBLE - minutesStatus.text = "上次纪要:${latest.name}(生成新纪要后会覆盖)" + minutesStatus.text = getString(R.string.minutes_last, latest.name) } } catch (t: Throwable) { Log.w(TAG, "loadLastMinutes: $t") @@ -1843,8 +2323,7 @@ class MainActivity : AppCompatActivity() { val md = File(SessionTranscript.dirOf(filesDir), f.nameWithoutExtension + ".md") if (!md.isFile || md.lastModified() < f.lastModified()) { minutesStatus.visibility = View.VISIBLE - minutesStatus.text = "上一场会议「${f.nameWithoutExtension}」尚未出纪要," + - "点「生成纪要」可续写(增量,只处理新增句子)" + minutesStatus.text = getString(R.string.minutes_unfinished, f.nameWithoutExtension) } } } diff --git a/app/src/main/java/com/k2fsa/sherpa/onnx/SessionTranscript.kt b/app/src/main/java/com/k2fsa/sherpa/onnx/SessionTranscript.kt index aa1abe2..b938944 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/SessionTranscript.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/SessionTranscript.kt @@ -39,6 +39,13 @@ class SessionTranscript private constructor( private val entries = ArrayList() private val bySid = HashMap() private var writer: BufferedWriter? = null + // 暂停累计时长:定格句子的相对时间轴要扣除中场休息(需求:暂停/继续) + private var pauseOffsetMs = 0L + + /** 恢复录音时调用,累计暂停掉的毫秒数。 */ + fun addPaused(ms: Long) { + if (ms > 0) pauseOffsetMs += ms + } companion object { private const val TAG = "sherpa-onnx" @@ -138,10 +145,11 @@ class SessionTranscript private constructor( // ---- 主线程挂钩点 -------------------------------------------------------- - /** finalizeSentence 处调用:endMs=定格时刻(相对会话开始),startMs=endMs-dur。 */ + /** finalizeSentence 处调用:endMs=定格时刻(相对会话开始,已扣暂停)。 */ fun onSettled(sid: Long, durMs: Long, text: String) { if (text.isBlank()) return - val end = SystemClock.elapsedRealtime() - sessionStartElapsed + val end = (SystemClock.elapsedRealtime() - sessionStartElapsed - + pauseOffsetMs).coerceAtLeast(0L) val e = TranscriptEntry(sid, (end - durMs).coerceAtLeast(0), end, null, text, false) bySid[sid] = e; entries.add(e) appendLine(JSONObject().apply { 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 82f98da..af3a1fa 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/SpeakerEmbedding.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/SpeakerEmbedding.kt @@ -124,23 +124,33 @@ class SpeakerRegistry { } /** - * 最近邻兜底(用户要求 2026-09-11):"未识别"归到与 e 最相似的已注册者 - * (命名条目优先,其后自动条目;相似度须 ≥ minScore)。全空/不达标返回 null。 + * 最近邻兜底(收紧版,P1):按"名字级"聚合所有嵌入,返回最优名与 + * 名级最高/次高分。同人多嵌入只算一个竞争者,优势差判定才有意义; + * 调用方用 score≥minScore 且 (score-secondScore)≥margin 才归入, + * 模糊时判新人(宁多勿错)。 */ + class Rank(val name: String?, val score: Float, val secondScore: Float) + @Synchronized - fun nearest(e: FloatArray, minScore: Float): String? { + fun nearestRank(e: FloatArray): Rank { val q = l2normalize(e) - var bestName: String? = null - var best = -1f + val byName = LinkedHashMap() for ((name, emb) in named) { val s = dot(q, emb) - if (s > best) { best = s; bestName = name } + if (s > (byName[name] ?: -2f)) byName[name] = s } for ((name, emb) in auto) { val s = dot(q, emb) - if (s > best) { best = s; bestName = name } + if (s > (byName[name] ?: -2f)) byName[name] = s } - return if (best >= minScore) bestName else null + var best: String? = null + var bestScore = -2f + var secondScore = -2f + for ((name, s) in byName) { + if (s > bestScore) { secondScore = bestScore; bestScore = s; best = name } + else if (s > secondScore) secondScore = s + } + return Rank(best, bestScore, secondScore) } /** 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 625fe77..8dbe06f 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/SummarizePipeline.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/SummarizePipeline.kt @@ -87,41 +87,42 @@ object PromptsLoader { return Prompts(p1s, p1u, p2s, p2u) } - // 与 prompts.md 同源的内置副本(md 丢失/被改坏时的兜底)。 + // 与 prompts.md 同源的内置副本(md 丢失/被改坏时的兜底;2026-09-14 起中文)。 val BUILTIN = Prompts( - p1System = "あなたは会議の文字起こしを処理する記録係です。与えられた「現在のメモ」に「新しい断片」の内容を統合し、更新後のメモをJSONだけで出力してください。\n\n" + - "厳守事項:\n" + - "- 出力は指定スキーマのJSONオブジェクトのみ。説明文・コードフェンス・前置き禁止。\n" + - "- メモは日本語で書く。ただし人名・組織名・製品名・固有名詞は原文表記をそのまま維持。\n" + - "- 数字、金額、日時、期限、回数、単位は原文どおり正確に転記。ざっくり要約しない。\n" + - "- 否定・保留・反対の意図を消さない。「見送り」「再検討」「反対意見」はそれ自体が重要な情報。\n" + - "- 各項目に根拠となる発話のタイムレンジを「出典」に付ける(形式: \"mm:ss-mm:ss\"、複数可 \";\" 区切り)。\n" + - "- 断片に新情報がない場合はメモをほぼ変更しない(前回末尾は必ず更新)。\n" + - "- 全体で500字を超えそうな場合、重要度の低い過去の項目を統合・削除して圧縮する。削除時は行動項目と已定事項を最後に守る。\n" + - "- 「前回末尾」には、次の断片と文脈が繋がるための直近話題を1文で書く。\n" + - "- JSONの値の中にダブルクォートが必要なら \\\" にエスケープ。改行は \\n を使う。\n\n" + - "スキーマ(キー名は変更禁止):\n" + + p1System = "你是处理会议转写记录的笔记员。请把【新的断片】的内容合并进给定的【当前笔记】,只以JSON输出更新后的笔记。\n\n" + + "严格要求:\n" + + "- 只输出符合指定schema的JSON对象;禁止说明文字、代码围栏、开场语。\n" + + "- schema的键名必须原样输出,不得翻译、转写或改写(程序按这些键校验)。\n" + + "- 笔记内容用中文书写;人名、组织名、产品名等专有名词保留原文写法。\n" + + "- 不得把输入笔记原样照抄:必须从【新的断片】中提取至少一条笔记尚未覆盖的信息并合并;断片确实没有新信息时,保留原笔记并只更新「前回末尾」。\n" + + "- 数字、金额、日期时间、期限、次数、单位按原文准确转记,不许模糊概括。\n" + + "- 不得删除否定、保留、反对的意图;「暂缓」「再议」「反对意见」本身就是重要信息。\n" + + "- 每个条目在「出典」字段给出依据发言的时间段(格式:\"mm:ss-mm:ss\",多个用\";\"分隔)。\n" + + "- 总计超过500字时,合并删除低价值的旧条目压缩;「行動項目」和「已定事項」最后才允许删。\n" + + "- 「前回末尾」用一句话写最近的议题,供下一个断片衔接上下文。\n" + + "- JSON字符串值内需要双引号时转义为\\\";换行用\\n。\n\n" + + "schema(键名禁止改动):\n" + "{\"已定事項\":[{\"内容\":\"\",\"出典\":\"\"}],\"討論中\":[{\"内容\":\"\",\"出典\":\"\"}],\"行動項目\":[{\"内容\":\"\",\"担当者\":\"\",\"期限\":\"\",\"出典\":\"\"}],\"人物\":[{\"名前\":\"\",\"立場\":\"\"}],\"前回末尾\":\"\"}", - p1UserTpl = "【現在のメモ】\n{notes_json}\n\n" + - "【新しい断片】(タイムスタンプは会議開始からの分:秒、[]内は話者ラベル)\n{chunk_text}\n\n" + - "更新後のメモJSONを出力してください。", - p2System = "あなたは会議の公式議事録作成者です。会議メモ(JSON)と用語集を受け取ったら、指定言語で完成した議事録をMarkdownで出力してください。\n\n" + - "厳守事項:\n" + - "- 出力言語は指定された「成稿言語」を厳守。日本語メモの内容をそのまま指定言語の書き言葉文体で記述する。\n" + - "- 本文は成稿言語の文字のみで書く:中文稿なら仮名を一切出さない、English稿なら漢字・仮名を一切出さない、日本語稿なら製品名以外に英語の文を残さない。\n" + - "- ただし人物・会社・ブランドなどの固有名詞は、メモ・用語集にある原文表記のまま保留してよい(上記の文字制限の例外)。\n" + - "- 見出しは次の4つを必ずこの順で:「議題と経過」「決定事項」「行動項目」「未決事項・持ち帰り」。該当ゼロなら「(なし)」と明記。\n" + - "- 行動項目は表形式(担当者 / 内容 / 期限 / 出典)。担当者が不明なら「未定」と書く。\n" + - "- 各項目の末尾に出典を付ける。形式: `出典: mm:ss-mm:ss`。出典はメモJSONの「出典」フィールドのみを使う。捏造禁止。\n" + - "- メモにない事実・推測・補足説明を追加しない。\n" + - "- 人名・製品名は用語集の正式表記に統一する(用語集にない表記はメモのまま採用)。\n" + - "- 冒頭にメタ情報を置く: 会議日時(指定があれば)、話者数、使用言語、文字起こし総尺。\n" + - "- Markdown本文のみ出力。コードフェンスで全体を囲まない。", - p2UserTpl = "【成稿言語】{language_name}\n\n" + - "【会議メタ情報】開始: {meeting_start} / 話者数: {n_speakers} / 総尺: {total_duration}\n\n" + - "【用語集(正式表記の一覧。人名・製品名の誤変換をこれに統一)】\n{glossary_lines}\n\n" + - "【会議メモJSON】\n{notes_json}\n\n" + - "上記に基づき、議事録Markdownを出力してください。", + p1UserTpl = "【当前笔记】\n{notes_json}\n\n" + + "【新的断片】(时间戳为会议开始的 分:秒,[]内是说话人标签)\n{chunk_text}\n\n" + + "请输出更新后的笔记JSON。", + p2System = "你是会议的官方纪要撰写者。收到会议笔记(JSON)与术语表后,用指定成稿语言输出完整的Markdown会议纪要。\n\n" + + "严格要求:\n" + + "- 输出语言严格遵守指定的「成稿语言」,用该语言的书面体撰写。\n" + + "- 正文只用成稿语言的文字书写:中文稿不得出现日文假名;英文稿不得出现汉字与假名;日文稿除产品名外不得残留英文句子。\n" + + "- 人物、公司、品牌等专有名词可保留笔记/术语表中的原文写法(上述文字限制的例外)。\n" + + "- 章节标题必须依序为「议题与经过」「决定事项」「行动项」「未决与待议」;成稿语言非中文时翻译成该语言的等价标题;没有条目的章节明确写\"(无)\"。\n" + + "- 行动项用表格输出,列为 负责人 / 内容 / 期限 / 出处;负责人不明时写\"待定\"。\n" + + "- 每个条目末尾给出处,格式 `出处: mm:ss-mm:ss`;出处只允许使用笔记JSON中「出典」字段的值,不得编造。\n" + + "- 不得添加笔记中没有的事实、推测或补充说明。\n" + + "- 人名、产品名以术语表的正式写法统一(术语表没有的沿用笔记写法)。\n" + + "- 开头放置元信息:会议时间(如有)、发言人数、使用语言、转写总时长。\n" + + "- 只输出Markdown正文,不要用代码围栏包裹全文。", + p2UserTpl = "【成稿语言】{language_name}\n\n" + + "【会议信息】开始: {meeting_start} / 发言人数: {n_speakers} / 总时长: {total_duration}\n\n" + + "【术语表(正式写法列表;人名、产品名的错误写法以此统一)】\n{glossary_lines}\n\n" + + "【会议笔记JSON】\n{notes_json}\n\n" + + "请根据以上内容输出会议纪要Markdown。", ) } @@ -202,6 +203,25 @@ class SummarizePipeline( // §5 分块铁律:句子边界聚合到 2500±500 字;块间重叠 1 句; // 同一说话人连续段(轮次)不拆开:切点落在同人连续段中间则延到段尾。 // 另:超 P1 预算的块按行二分一次,保证 prompt ≤ ctx。 + + /** + * schema 键名别名表:中文系小模型常把日语/繁体键写成简体 + * (已定事项/行动项目…),不规范化则 extractJson 永远判失败—— + * 这正是"中文会议也解析不了"的头号嫌疑。 + */ + private val KEY_ALIASES = mapOf( + "已定事项" to "已定事項", "讨论中" to "討論中", + "行动项目" to "行動項目", "决定事项" to "已定事項", + "待办事项" to "行動項目", "上次末尾" to "前回末尾", + ) + + internal fun normalizeKeys(o: JSONObject): JSONObject { + for ((from, to) in KEY_ALIASES) { + if (o.has(from) && !o.has(to)) o.put(to, o.remove(from)) + } + return o + } + internal fun chunk(lines: List): List { val blocks = ArrayList() var i = 0 @@ -274,8 +294,11 @@ class SummarizePipeline( if (depth == 0) { val cand = raw.substring(start, i + 1) return try { - val o = JSONObject(cand) - if (o.has("已定事項") && o.has("行動項目")) cand else null + // 键名先规范化(简体别名→schema),再校验必需键 + val o = normalizeKeys(JSONObject(cand)) + if (o.has("已定事項") && o.has("行動項目")) + o.toString() + else null } catch (t: Throwable) { null } @@ -294,6 +317,8 @@ class SummarizePipeline( s = s.replace( Regex("<\\|channel>thought.*?", RegexOption.DOT_MATCHES_ALL), "" ) + // MiniCPM5 兜底:万一 think 块未被预填空掉仍然冒出来,一并剥除 + s = s.replace(Regex(".*?", RegexOption.DOT_MATCHES_ALL), "") s = s.replace(Regex("\n{3,}"), "\n\n") return s.trim() } @@ -382,23 +407,37 @@ class SummarizePipeline( maxNewTokens = 900, temperature = 0f, ) var parsed = extractJson(out) - if (parsed == null) { - // 一次修复重试:把畸形输出递回去(仍失败则保留旧笔记,计 failed) + // 空笔记视同失败:小模型吃不透指令时会把输入的空模板原样复读 + // (EMPTY 进 EMPTY 出)。首轮空结果带一次强约束重试再判。 + val emptyEcho = parsed != null && idx == 0 && + resumeNotes == null && notesEntriesEmpty(parsed) + if (parsed == null || emptyEcho) { + // 一次修复重试:把畸形/空洞输出递回去(仍失败保留旧笔记,计 failed) if (listener.isCancelled()) throw SummarizeCancelledException() - val fix = user + "\n\n(前回の出力はJSONとして不正でした: " + - out.take(200).replace("\n", " ") + " 正しいJSONのみを再出力してください。)" + val why = if (emptyEcho) + "上一次输出是没有任何条目的空笔记。" + else + "上一次输出不是合法JSON: " + + out.take(200).replace("\n", " ") + "。" + val fix = user + "\n\n(" + why + + "请从【新的断片】中至少提取一条并合并,只输出修正后的笔记JSON," + + "禁止照抄输入笔记。)" out = engine.generate( systemPrompt = prompts.p1System, userPrompt = fix, maxNewTokens = 900, temperature = 0f, ) - parsed = extractJson(out) + val p2 = extractJson(out) + if (p2 != null && !notesEntriesEmpty(p2)) parsed = p2 } if (parsed != null) notes = parsed else { failed++ + // 诊断转储:解析失败时把模型原文头尾写进日志,归因不靠猜 + Log.w(TAG, "P1 chunk=${idx + 1} UNPARSABLE raw(${out.length}) " + + "head=[" + out.take(160).replace("\n", "\\n") + "] " + + "tail=[" + out.takeLast(60).replace("\n", "\\n") + "]") // 及时止损:第一块都解析不出来,笔记链条已断,后面块只会白烧 token if (idx == 0) { - Log.w(TAG, "P1 first chunk unparsable, abort. raw=${out.take(120)}") throw SummarizeEmptyException("开头转写片段无法生成有效笔记(内容过少或输出不可解析)") } } @@ -413,7 +452,9 @@ class SummarizePipeline( "chunks=$total failed=$failed") // 空笔记终检:全部块失败,或笔记仍是空壳(total==0 已在入口取消) if (failed == total || notesEntriesEmpty(notes)) { - throw SummarizeEmptyException("整场转写未能形成任何笔记条目") + throw SummarizeEmptyException("整场转写未能形成任何笔记条目" + + "(failed=$failed/$total, notes=" + + notes.replace("\n", " ").take(120) + ")") } // ---- 成稿(§5.3)---- diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 205c48a..d9c8846 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -11,6 +11,53 @@ android:layout_height="match_parent" android:orientation="vertical"> + + + +