Compare commits

...

2 Commits

26 changed files with 2489 additions and 253 deletions

6
.gitignore vendored
View File

@ -14,5 +14,11 @@
.cxx .cxx
local.properties local.properties
# 构建产物与日志不入库
/build-check/
*.log
/tools/llama-jni/llama.cpp/
/tools/llama-jni/build-*/
# 模型文件体积大,不入库;运行时手动放入 app/src/main/assets/ # 模型文件体积大,不入库;运行时手动放入 app/src/main/assets/
*.onnx *.onnx

View File

@ -52,6 +52,8 @@ dependencies {
implementation 'com.google.android.material:material:1.7.0' implementation 'com.google.android.material:material:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
// 摘要流水线的 JVM 单测需要真实 org.json(android.jar 里的是会抛 Stub! 的桩)
testImplementation 'org.json:json:20180813'
androidTestImplementation 'androidx.test.ext:junit:1.1.4' androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
} }

View File

@ -0,0 +1,139 @@
# 摘要功能三段提示词(里程碑 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)
> 本文件是唯一事实源:App 内 `SummarizePipeline.kt` 逐字引用这里的文本。改提示词先改本文件。
> PC 调试方法见文末「用 PC llama.cpp 复现」。
## 0. 运行参数(与 App 实现一致)
| 步骤 | temperature | maxNewTokens | 备注 |
|---|---|---|---|
| P1 滚动笔记更新 | 0(Greedy) | 900 | 可复现;笔记要求确定性 |
| P2 成稿 | 0.5 / topK 64 / topP 0.95 | 2500 | Gemma 4 官方推荐采样档位,低温折中 |
思考(thinking)关闭:App 渲染模板时不注入 `<|think|>` 标记;模型若仍输出 `<|channel>thought…` 块,App 侧正则剔除。
## 1. 聊天模板(App 手工渲染,与 Gemma 4 canonical template 单轮路径等价)
```
<bos>(tokenizer 自动)
<|turn>system
{SYSTEM}<turn|>
<|turn>user
{USER}<turn|>
<|turn>model
```
特殊 token id:`<bos>`=2,`<eos>`=1,`<|turn>`=105,`<turn|>`=106。生成遇 `<eos>`、`<turn|>`、或 8192 上下文耗尽即停。
---
## P1 — 滚动笔记更新
### SYSTEM(固定,勿动态改写)
```
あなたは会議の文字起こしを処理する記録係です。与えられた「現在のメモ」に「新しい断片」の内容を統合し、更新後のメモをJSONだけで出力してください。
厳守事項:
- 出力は指定スキーマのJSONオブジェクトのみ。説明文・コードフェンス・前置き禁止。
- メモは日本語で書く。ただし人名・組織名・製品名・固有名詞は原文表記をそのまま維持。
- 数字、金額、日時、期限、回数、単位は原文どおり正確に転記。ざっくり要約しない。
- 否定・保留・反対の意図を消さない。「見送り」「再検討」「反対意見」はそれ自体が重要な情報。
- 各項目に根拠となる発話のタイムレンジを「出典」に付ける(形式: "mm:ss-mm:ss"、複数可 ";" 区切り)。
- 断片に新情報がない場合はメモをほぼ変更しない(前回末尾は必ず更新)。
- 全体で500字を超えそうな場合、重要度の低い過去の項目を統合・削除して圧縮する。削除時は行動項目と已定事項を最後に守る。
- 「前回末尾」には、次の断片と文脈が繋がるための直近話題を1文で書く。
- JSONの値の中にダブルクォートが必要なら \" にエスケープ。改行は \n を使う。
スキーマ(キー名は変更禁止):
{"已定事項":[{"内容":"","出典":""}],"討論中":[{"内容":"","出典":""}],"行動項目":[{"内容":"","担当者":"","期限":"","出典":""}],"人物":[{"名前":"","立場":""}],"前回末尾":""}
```
### USER(每块渲染;`{}` 为占位符)
```
【現在のメモ】
{notes_json}
【新しい断片】(タイムスタンプは会議開始からの分:秒、[]内は話者ラベル)
{chunk_text}
更新後のメモJSONを出力してください。
```
---
## P2 — 最终成稿
### SYSTEM(固定)
```
あなたは会議の公式議事録作成者です。会議メモ(JSON)と用語集を受け取ったら、指定言語で完成した議事録をMarkdownで出力してください。
厳守事項:
- 出力言語は指定された「成稿言語」を厳守。日本語メモの内容をそのまま指定言語の書き言葉文体で記述する。
- 本文は成稿言語の文字のみで書く:中文稿なら仮名を一切出さない、English稿なら漢字・仮名を一切出さない、日本語稿なら製品名以外に英語の文を残さない。
- ただし人物・会社・ブランドなどの固有名詞は、メモ・用語集にある原文表記のまま保留してよい(上記の文字制限の例外)。
- 見出しは次の4つを必ずこの順で:「議題と経過」「決定事項」「行動項目」「未決事項・持ち帰り」。該当ゼロなら「(なし)」と明記。
- 行動項目は表形式(担当者 / 内容 / 期限 / 出典)。担当者が不明なら「未定」と書く。
- 各項目の末尾に出典を付ける。形式: `出典: mm:ss-mm:ss`。出典はメモJSONの「出典」フィールドのみを使う。捏造禁止。
- メモにない事実・推測・補足説明を追加しない。
- 人名・製品名は用語集の正式表記に統一する(用語集にない表記はメモのまま採用)。
- 冒頭にメタ情報を置く: 会議日時(指定があれば)、話者数、使用言語、文字起こし総尺。
- Markdown本文のみ出力。コードフェンスで全体を囲まない。
```
### USER
```
【成稿言語】{language_name}
【会議メタ情報】開始: {meeting_start} / 話者数: {n_speakers} / 総尺: {total_duration}
【用語集(正式表記の一覧。人名・製品名の誤変換をこれに統一)】
{glossary_lines}
【会議メモJSON】
{notes_json}
上記に基づき、議事録Markdownを出力してください。
```
---
## 语言名映射(P2 `{language_name}`)
| 会议语言 | language_name |
|---|---|
| ja | 日本語 |
| zh | 中文(简体书面语) |
| en | English (formal written English) |
## 分块参数(Kotlin 侧,不进提示词)
- 目标 2500±500 字/块,按句子边界聚合,块间重叠 1 句;同一说话人轮次不拆开。
- 行格式:`(mm:ss-mm:ss)[话者名] 文本`,时间戳来自 SessionTranscript。
- 首轮 `{notes_json}` = 空 schema(`{"已定事項":[],"討論中":[],"行動項目":[],"人物":[],"前回末尾":""}`)。
- 单块+笔记的总输入若超过 ~5000 字符,Kotlin 先把该块对半拆成两次 P1 调用(块长自适应,不截断内容)。
## 用 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)。
3. 跑 P1 示例(替换 sys/user 两段;`--jinja` 走 GGUF 内置模板,与 App 的
`renderGemma3Prompt`——system 并入首个 user turn——等价):
```bash
llama-cli -m gemma-3-1b-it-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"
```
4. 调优结果(好例/坏例)记录到本文件末尾「调优日志」。
## 调优日志
(空——等 PC/真机验证后追加)

View File

@ -0,0 +1,151 @@
package com.k2fsa.sherpa.onnx
import android.util.Log
// ============================================================================
// LlmEngine — libsummarize-llm-jni.so(llama.cpp + 薄 JNI)的 Kotlin 封装。
//
// 与工程其它 native wrapper(OnlineRecognizer/OfflineRecognizer/Extractor)
// 同范式:ptr + require + finalize()/release()。但注意:
// * 本库绝不能进录音/主线程路径 —— generate() 是长阻塞调用,设计为只在
// 摘要专用后台线程上使用,一次只能有一个 generate 在跑(JNI 内有锁)。
// * 模型文件必须由用户 adb push,App 不下载;调用 nativeNewFromFile 前
// 必须在 Kotlin 侧确认文件存在(工程教训:缺文件时有的 native 会 exit(),
// 本 .so 已做 access() 双保险并抛异常,但仍按规矩先查)。
// ============================================================================
/** 摘要 LLM 的可用性探测。so 只编了 arm64-v8a,x86_64 模拟器上自动不可用。 */
object LlmRuntime {
private const val LIB = "summarize-llm-jni"
@Volatile private var tried = false
@Volatile var nativeAvailable = false
private set
fun ensure(): Boolean {
if (!tried) {
synchronized(this) {
if (!tried) {
tried = true
nativeAvailable = try {
System.loadLibrary(LIB)
true
} catch (t: Throwable) {
Log.w("sherpa-onnx", "summarize lib $LIB not loadable: $t")
false
}
}
}
}
return nativeAvailable
}
}
class LlmEngine(
modelPath: String,
ctxSize: Int = 8192, // 滚动笔记+单块输入远小于此(§3 资源账)
nThreads: Int = 6, // 8 Gen3 大核 4 个,给摘要留余量:6 线程够用
kvTypeK: String = "q8_0", // KV 用 q8_0 压显存/内存(§3)
kvTypeV: String = "f16", // V 保持 f16:K 量化到 q8_0 是 llama.cpp 推荐档
chatTemplate: String = "gemma4", // "gemma4" | "gemma3",须与 GGUF 内嵌模板一致
) {
companion object {
private const val TAG = "sherpa-onnx"
// ---- Gemma 4 canonical template 的单轮(system+user)渲染 ----
// 与 GGUF 内嵌模板(enable_thinking=false, 无 tools)逐字等价:
// bos <|turn>system\nSYS<turn|>\n <|turn>user\nUSR<turn|>\n <|turn>model\n
// bos 由 tokenizer 的 add_bos_token 自动添加(native 侧 parse_special=true)。
fun renderGemma4Prompt(system: String, user: String): String =
buildString {
append("<|turn>system\n").append(system.trim()).append("<turn|>\n")
append("<|turn>user\n").append(user.trim()).append("<turn|>\n")
append("<|turn>model\n")
}
// ---- Gemma 3 官方模板的单轮渲染(ggml-org/gemma-3-1b-it-GGUF)----
// 官方 jinja:system 不作为独立 turn,而是拼进第一个 user turn;
// <start_of_turn>user\n{system}\n\n{user}<end_of_turn>\n<start_of_turn>model\n
// EOG token 是 <end_of_turn>(gemma3 的 eot),依赖 native 按 eog 停止。
fun renderGemma3Prompt(system: String, user: String): String =
buildString {
append("<start_of_turn>user\n")
append(system.trim()).append("\n\n")
append(user.trim())
append("<end_of_turn>\n")
append("<start_of_turn>model\n")
}
fun renderChatPrompt(system: String, user: String,
template: String = "gemma4"): String =
when (template) {
"gemma3" -> renderGemma3Prompt(system, user)
else -> renderGemma4Prompt(system, user)
}
}
private val chatTemplate: String = chatTemplate
private var ptr: Long = 0L
init {
require(LlmRuntime.ensure()) { "摘要推理库不可用(设备 ABI 不支持或未打包)" }
require(existsFile(modelPath)) { "找不到模型文件: $modelPath" }
ptr = nativeNewFromFile(modelPath, ctxSize, nThreads, kvTypeK, kvTypeV)
require(ptr != 0L) { "LLM 模型加载失败: $modelPath" }
}
// 与工程教训对齐的存在性检查:加载任何模型文件前必须先确认文件在
private fun existsFile(path: String): Boolean =
java.io.File(path).let { it.isFile && it.length() > 100L }
/**
* 阻塞式生成(仅供摘要后台线程调用)。返回模型输出文本(不含输入)。
* temperature=0 为 Greedy。abort() 可从其它线程安全打断。
*/
fun generate(
systemPrompt: String,
userPrompt: String,
maxNewTokens: Int = 900,
temperature: Float = 0f,
topK: Int = 64,
topP: Float = 0.95f,
): String {
require(ptr != 0L) { "LlmEngine already released" }
return nativeGenerate(
ptr, renderChatPrompt(systemPrompt, userPrompt, chatTemplate),
maxNewTokens, temperature, topK, topP
)
}
fun abort() {
if (ptr != 0L) nativeAbort(ptr)
}
/** 模型内嵌的 jinja 聊天模板(调试用,验证渲染一致性)。 */
fun chatTemplate(): String? {
if (ptr == 0L) return null
return nativeGetStringMeta(ptr, "tokenizer.chat_template")
}
protected fun finalize() {
if (ptr != 0L) {
nativeFree(ptr) // JNI 内部等待在途 generate 结束后再释放
ptr = 0L
}
}
fun release() = finalize()
// ---- native(实现在 tools/llama-jni/src/summarize_llm_jni.cpp)----
private external fun nativeNewFromFile(
path: String, ctxSize: Int, nThreads: Int, typeK: String, typeV: String
): Long
private external fun nativeGenerate(
ptr: Long, prompt: String, maxNewTokens: Int,
temperature: Float, topK: Int, topP: Float
): String
private external fun nativeAbort(ptr: Long)
private external fun nativeFree(ptr: Long)
private external fun nativeGetStringMeta(ptr: Long, key: String): String?
}

View File

@ -15,13 +15,17 @@ import android.os.Process
import android.os.SystemClock import android.os.SystemClock
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.text.TextPaint
import android.text.method.LinkMovementMethod
import android.text.method.ScrollingMovementMethod import android.text.method.ScrollingMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager
import android.widget.Button import android.widget.Button
import android.widget.EditText import android.widget.EditText
import android.widget.TextView import android.widget.TextView
@ -146,11 +150,6 @@ class MainActivity : AppCompatActivity() {
private var activeSpeakerModel: String? = null private var activeSpeakerModel: String? = null
private val speakerThreshold = 0.5f private val speakerThreshold = 0.5f
private var speakersEnabled = false private var speakersEnabled = false
private var lastEmbedding: FloatArray? = null
// 同一句话在 fast(轻量)空间的镜像嵌入,命名时双写进 fastRegistry
private var lastEmbeddingFast: FloatArray? = null
private lateinit var nameSpeakerButton: Button
private lateinit var clearSpeakersButton: Button
// ---- rolling speaker-change detection (需求一.4) ------------------------- // ---- rolling speaker-change detection (需求一.4) -------------------------
// Every ~1.5 s the speaker-check thread embeds the last 2 s window and // Every ~1.5 s the speaker-check thread embeds the last 2 s window and
@ -166,6 +165,11 @@ class MainActivity : AppCompatActivity() {
private val speakerCheckThread = HandlerThread("speaker-window").also { it.start() } private val speakerCheckThread = HandlerThread("speaker-window").also { it.start() }
private val speakerCheckHandler by lazy { Handler(speakerCheckThread.looper) } private val speakerCheckHandler by lazy { Handler(speakerCheckThread.looper) }
private val speakerSwitchDrop = 0.30f // cos(window, reference) below this => switch private val speakerSwitchDrop = 0.30f // cos(window, reference) below this => switch
// 最近邻兜底阈值(用户要求:未识别归到最相似的已识别人):
// endpoint 精确匹配不中→nearest≥0.35 归入;再不行才注册新人
private val verdictFallbackMin = 0.35f
// 滚动检测 fast 空间的兜底阈值(仅影响换行标签显示,endpoint 仍权威)
private val rollingFallbackMin = 0.30f
// 滚动检测在 fast 空间查"这人是谁"仅用于**显示标签**,阈值放宽到 0.4; // 滚动检测在 fast 空间查"这人是谁"仅用于**显示标签**,阈值放宽到 0.4;
// endpoint 权威判定仍用准确模型 + speakerThreshold(0.5)。 // endpoint 权威判定仍用准确模型 + speakerThreshold(0.5)。
private val fastSearchThreshold = 0.4f private val fastSearchThreshold = 0.4f
@ -177,6 +181,10 @@ class MainActivity : AppCompatActivity() {
@Volatile private var rollingLowHits = 0 @Volatile private var rollingLowHits = 0
// C(最新者胜):每次投递滚动检测都刷新序号,队列里被超额的旧任务直接跳过 // C(最新者胜):每次投递滚动检测都刷新序号,队列里被超额的旧任务直接跳过
@Volatile private var rollingSeq = 0L @Volatile private var rollingSeq = 0L
// 兜底残料:当前句子槽位最近一次滚动窗口声纹。整句判定因碎片算不出
// embedding 时复用它做归属(用户要求 2026-09-11)
@Volatile private var rollingEmbSid = -1L
@Volatile private var rollingEmb: FloatArray? = null
@Volatile private var pendingSwitch: SpeakerSwitch? = null @Volatile private var pendingSwitch: SpeakerSwitch? = null
@Volatile private var windowRefEmb: FloatArray? = null @Volatile private var windowRefEmb: FloatArray? = null
@Volatile private var activeLabelForWindow: String? = null @Volatile private var activeLabelForWindow: String? = null
@ -208,9 +216,6 @@ class MainActivity : AppCompatActivity() {
@Volatile @Volatile
private var isRecording: Boolean = false private var isRecording: Boolean = false
@Volatile
private var modelsReady: Boolean = false
// ---- External model loading (adb push) --------------------------------- // ---- External model loading (adb push) ---------------------------------
// Models live in Android/data/<pkg>/files/models/ instead of assets: // Models live in Android/data/<pkg>/files/models/ instead of assets:
// adb push <model-dir> /sdcard/Android/data/com.k2fsa.sherpa.onnx/files/models/ // adb push <model-dir> /sdcard/Android/data/com.k2fsa.sherpa.onnx/files/models/
@ -226,6 +231,31 @@ class MainActivity : AppCompatActivity() {
private var loadFromAssets = true private var loadFromAssets = true
private var modelPrefix = "" private var modelPrefix = ""
// ---- Summarize (离线 LLM 会议纪要,里程碑 1) ------------------------------
// §7 红线:总开关 false 时不建会话记录、不显示按钮,App 行为与加功能前
// 完全一致。摘要是独立世界:专用后台线程 + 独立 .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"
private lateinit var summarizeButton: Button
private lateinit var copyMinutesButton: Button
private lateinit var minutesStatus: TextView
private lateinit var minutesText: TextView
private lateinit var minutesHint: TextView
private var sessionTranscript: SessionTranscript? = null
@Volatile private var isSummarizing = false
@Volatile private var summarizeCancelled = false
// generate 阻塞在 native,取消靠 abort;引擎只在摘要线程持有
@Volatile private var summarizeEngine: LlmEngine? = null
companion object {
private val SUMMARY_THREADS = 6 // 空闲时:大核全用
private val SUMMARY_THREADS_BUSY = 4 // 录音中成稿:给 ASR 让核(实测零丢帧)
private val SUMMARY_CTX = 8192 // §3 资源账:笔记+单块远小于此
}
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>, grantResults: IntArray requestCode: Int, permissions: Array<String>, grantResults: IntArray
) { ) {
@ -254,21 +284,24 @@ class MainActivity : AppCompatActivity() {
recordButton.isEnabled = false recordButton.isEnabled = false
recordButton.setOnClickListener { onclick() } recordButton.setOnClickListener { onclick() }
nameSpeakerButton = findViewById(R.id.name_speaker_button) summarizeButton = findViewById(R.id.summarize_button)
nameSpeakerButton.isEnabled = false copyMinutesButton = findViewById(R.id.copy_minutes_button)
nameSpeakerButton.setOnClickListener { onNameSpeakerClick() } minutesStatus = findViewById(R.id.minutes_status)
minutesText = findViewById(R.id.minutes_text)
clearSpeakersButton = findViewById(R.id.clear_speakers_button) minutesHint = findViewById(R.id.minutes_hint)
clearSpeakersButton.setOnClickListener { if (summarizeEnabled) {
SpeakerRecognition.resetSpeakers() // 生成中再点同一按钮 = 取消(按钮文案随之切换)
lastEmbedding = null summarizeButton.setOnClickListener {
lastEmbeddingFast = null if (isSummarizing) cancelSummarize() else onSummarizeClick()
lastLabel = null
lastConfirmedLabel = null
activeLabelForWindow = null
nameSpeakerButton.isEnabled = false
Toast.makeText(this, "已清除所有说话人记录", Toast.LENGTH_SHORT).show()
} }
copyMinutesButton.isEnabled = false
copyMinutesButton.setOnClickListener { onCopyMinutesClick() }
} else {
summarizeButton.visibility = View.GONE
copyMinutesButton.visibility = View.GONE
}
loadLastMinutes()
hintUnfinishedMeeting()
textView = findViewById(R.id.my_text) textView = findViewById(R.id.my_text)
textView.movementMethod = ScrollingMovementMethod() textView.movementMethod = ScrollingMovementMethod()
@ -305,7 +338,6 @@ class MainActivity : AppCompatActivity() {
application.assets.list("").orEmpty() application.assets.list("").orEmpty()
) )
} }
modelsReady = true
runOnUiThread { recordButton.isEnabled = true } runOnUiThread { recordButton.isEnabled = true }
val speakerNote = if (speakersEnabled) "说话人功能已启用" val speakerNote = if (speakersEnabled) "说话人功能已启用"
else "⚠ 说话人功能未启用:缺少声纹模型 ${speakerModels[0]}" + else "⚠ 说话人功能未启用:缺少声纹模型 ${speakerModels[0]}" +
@ -360,6 +392,11 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
isRecording = false isRecording = false
sessionTranscript?.close()
if (isSummarizing) {
summarizeCancelled = true
summarizeEngine?.abort()
}
subtitleThread.quitSafely() subtitleThread.quitSafely()
speakerCheckThread.quitSafely() speakerCheckThread.quitSafely()
} }
@ -375,6 +412,8 @@ class MainActivity : AppCompatActivity() {
audioRecord!!.startRecording() audioRecord!!.startRecording()
recordButton.setText(R.string.stop) recordButton.setText(R.string.stop)
isRecording = true isRecording = true
// 会议记录场景:录音期间保持屏幕常亮,防息屏冻结/MIUI 杀后台
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
resetBuffer() resetBuffer()
pendingSwitch = null pendingSwitch = null
windowRefEmb = null windowRefEmb = null
@ -383,6 +422,11 @@ class MainActivity : AppCompatActivity() {
activeLabelForWindow = null activeLabelForWindow = null
lastConfirmedLabel = null lastConfirmedLabel = null
uiHandler.post { resetSubtitles() } uiHandler.post { resetSubtitles() }
if (summarizeEnabled) {
// 每次录音 = 一个新会话文件(§4.2);主线程操作
sessionTranscript?.close()
sessionTranscript = SessionTranscript.begin(filesDir)
}
recordingThread = thread(true) { recordingThread = thread(true) {
processSamples() processSamples()
@ -390,10 +434,16 @@ class MainActivity : AppCompatActivity() {
Log.i(TAG, "Started recording") Log.i(TAG, "Started recording")
} else { } else {
isRecording = false isRecording = false
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
audioRecord!!.stop() audioRecord!!.stop()
audioRecord!!.release() audioRecord!!.release()
audioRecord = null audioRecord = null
recordButton.setText(R.string.start) recordButton.setText(R.string.start)
if (summarizeEnabled) {
// 保留 sessionTranscript 引用直到下次开始录音:
// 「刚停止」是点生成纪要最常见的时间窗
sessionTranscript?.flush()
}
Log.i(TAG, "Stopped recording") Log.i(TAG, "Stopped recording")
} }
} }
@ -452,6 +502,8 @@ class MainActivity : AppCompatActivity() {
win, sampleRateInHz win, sampleRateInHz
) ?: return@post ) ?: return@post
if (rseq != rollingSeq) return@post if (rseq != rollingSeq) return@post
rollingEmbSid = owner
rollingEmb = emb
// 计算耗时内句子可能已结算:回写前二次核销 // 计算耗时内句子可能已结算:回写前二次核销
if (seq != checkSerial) return@post if (seq != checkSerial) return@post
val ref = windowRefEmb val ref = windowRefEmb
@ -619,35 +671,7 @@ class MainActivity : AppCompatActivity() {
// 整句声纹判定移出录音线程(停摆根因):后台算完 // 整句声纹判定移出录音线程(停摆根因):后台算完
// 回主线程 confirmSpeaker 修正归属 // 回主线程 confirmSpeaker 修正归属
speakerCheckHandler.post { postSpeakerVerdict(sid, snap)
val emb = SpeakerRecognition.computeEmbedding(
snap, sampleRateInHz
)
// 镜像嵌入:② 用句尾 2s 切片(与滚动检测同视角),
// 整句 embedding 会拉低窗口匹配度,导致换行时查不到人
val fastEmb = if (emb != null &&
SpeakerRecognition.fastReady()
) {
val tailSlice = if (snap.size > 32000)
snap.copyOfRange(
snap.size - 32000, snap.size
) else snap
SpeakerRecognition.computeEmbeddingFast(
tailSlice, sampleRateInHz
)
} else null
val label = if (emb == null) null else
SpeakerRecognition.registry.search(
emb, speakerThreshold
)
?: SpeakerRecognition.nextAutoLabel().also {
SpeakerRecognition.registry.addAuto(it, emb)
if (fastEmb != null)
SpeakerRecognition.fastRegistry
.addAuto(it, fastEmb)
}
uiHandler.post { confirmSpeaker(sid, label, emb, fastEmb) }
}
// 定格 + 送检立即走主线程队列,不等声纹 // 定格 + 送检立即走主线程队列,不等声纹
uiHandler.post { uiHandler.post {
val accepted = submitRefineTask( val accepted = submitRefineTask(
@ -661,9 +685,67 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
// Stop 兜底(软 endpoint):最后一句未完的转写同样走定格→判定→精修,
// 否则丢半句 + 灰"未识别"行滞留(用户反馈 2026-09-11)
if (sentenceId != -1L) {
val raw = onlineRecognizer.getResult(stream).text
val disp =
if (raw.isNotBlank() && isInAllowedLanguages(raw)) raw else ""
onlineRecognizer.reset(stream)
val sid = sentenceId
sentenceId = -1L
windowRefEmb = null
if (disp.isNotBlank() && bufferTotal > 0) {
val snap = flattenSamples()
val dur = bufferTotal / sampleRateInHz.toFloat()
resetBuffer()
postSpeakerVerdict(sid, snap)
uiHandler.post {
val accepted = submitRefineTask(
sid, snap, dur, "STOP", null, disp, disp
)
finalizeSentence(sid, accepted)
}
} else {
resetBuffer()
uiHandler.post { finalizeSentence(sid, false) }
}
}
stream.release() stream.release()
} }
/**
* 整句声纹判定:后台算准确 embedding + fast 镜像(句尾 2s 切片,与滚动
* 检测同视角),注册后回主线程 confirmSpeaker 修正归属。
* 自然 endpoint / 强制断句 / Stop 软 endpoint 共用。
*/
private fun postSpeakerVerdict(sid: Long, snap: FloatArray) {
speakerCheckHandler.post {
var emb = SpeakerRecognition.computeEmbedding(snap, sampleRateInHz)
if (emb == null && rollingEmbSid == sid) {
// 兜底①:句子太碎、整句 embedding 算不出 → 复用该槽滚动窗口残料
emb = rollingEmb
}
val fastEmb = if (emb != null && SpeakerRecognition.fastReady()) {
val tailSlice = if (snap.size > 32000)
snap.copyOfRange(snap.size - 32000, snap.size) else snap
SpeakerRecognition.computeEmbeddingFast(tailSlice, sampleRateInHz)
} else null
val label = if (emb == null) null else
(SpeakerRecognition.registry.search(emb, speakerThreshold)
// 兜底②:精确匹配不中 → 归入最相似的已注册身份
?: SpeakerRecognition.registry.nearest(
emb, verdictFallbackMin
))
?: SpeakerRecognition.nextAutoLabel().also {
SpeakerRecognition.registry.addAuto(it, emb)
if (fastEmb != null)
SpeakerRecognition.fastRegistry.addAuto(it, fastEmb)
}
uiHandler.post { confirmSpeaker(sid, label, emb, fastEmb) }
}
}
// Keep only zh/ja/en output when firstPassLanguage == "auto": // Keep only zh/ja/en output when firstPassLanguage == "auto":
// letters must be ASCII latin (en), kana (ja), or CJK hanzi (zh/ja); // letters must be ASCII latin (en), kana (ja), or CJK hanzi (zh/ja);
// everything else (hangul, cyrillic, thai, arabic, accented latin ...) // everything else (hangul, cyrillic, thai, arabic, accented latin ...)
@ -752,54 +834,83 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun onNameSpeakerClick() { // 点击行首说话人标签(ClickableSpan)→ 命名或改名。
val emb = lastEmbedding // internalKey:该行的内部声纹键("S0"/真名);null=归属未定,提示稍后再命名。
if (emb == null) { private fun showNameSpeakerDialog(internalKey: String?) {
Toast.makeText(this, "先录一句话,再为这位说话人命名", Toast.LENGTH_SHORT).show() if (internalKey == null) {
Toast.makeText(this, "该行声纹归属尚未确定,先说一句完整的话再命名",
Toast.LENGTH_SHORT).show()
return return
} }
val input = EditText(this).apply { hint = "说话人名字,如 张三" } val input = EditText(this).apply {
AlertDialog.Builder(this) hint = "「${displayName(internalKey)}」的真名,如 张三"
.setTitle("为说话人「${displayName(lastLabel)}」命名")
.setView(input)
.setPositiveButton("注册") { _, _ ->
val name = input.text.toString().trim()
if (name.isNotEmpty()) {
SpeakerRecognition.registry.addNamed(name, emb)
lastEmbeddingFast?.let {
SpeakerRecognition.fastRegistry.addNamed(name, it)
} }
relabelSpeakerLines(name) AlertDialog.Builder(this)
Toast.makeText(this, "之后「$name」说话时会显示真名", Toast.LENGTH_SHORT).show() .setTitle(if (autoLabelRegex.matches(internalKey))
"命名说话人「${displayName(internalKey)}」"
else "将「$internalKey」改名为…")
.setView(input)
.setPositiveButton("确定") { _, _ ->
val name = input.text.toString().trim()
if (name.isNotEmpty() && name != internalKey) {
renameSpeaker(internalKey, name)
} }
} }
.setNegativeButton("取消", null) .setNegativeButton("取消", null)
.show() .show()
} }
// 命名后:当前会话里这位说话人的所有行立即改显真名(颜色跟人走); private fun renameSpeaker(oldKey: String, name: String) {
// 之后的新行也会用真名(声纹 search 命名优先返回真名)。 // 真名不得长得像内部键/显示名,避免与 S{n}→已识别人N 的映射冲突
private fun relabelSpeakerLines(name: String) { if (autoLabelRegex.matches(name) ||
val old = lastLabel Regex("^(已识别人?\\d+|未识别\\d*)$").matches(name)) {
if (old == null) { Toast.makeText(this, "名字不能是 S数字/已识别人N/未识别N 形式",
lastLabel = name Toast.LENGTH_SHORT).show()
return return
} }
if (old != name) { // 声纹库升级:S{n} 的嵌入转为真名注册(真名可多组嵌入并存,
val idx = labelColorIdx.remove(old) ?: colorIdxFor(name) // 即"两个误判声纹归为同一人",今后 search 对两组嵌入都返回同名)
labelColorIdx[name] = idx SpeakerRecognition.registry.renameToName(oldKey, name)
SpeakerRecognition.fastRegistry.renameToName(oldKey, name)
for (l in subtitleLines) { for (l in subtitleLines) {
if (l.label == old) { if (l.label == oldKey) {
l.label = name l.label = name
l.colorIdx = idx l.colorIdx = colorIdxFor(name)
val pos = subtitleLines.indexOf(l)
if (pos >= 0) subtitleAdapter.notifyItemChanged(pos)
} }
} }
labelColorIdx.remove(oldKey)
if (lastConfirmedLabel == oldKey) lastConfirmedLabel = name
if (lastLabel == oldKey) lastLabel = name
if (activeLabelForWindow == oldKey) activeLabelForWindow = name
if (summarizeEnabled) sessionTranscript?.onRename(oldKey, name)
mergeAdjacentSameLabel()
subtitleAdapter.notifyItemRangeChanged(0, subtitleLines.size)
Log.i(TAG, "speaker renamed: $oldKey -> $name")
Toast.makeText(this, "已命名「$name」,相邻同名行已合并",
Toast.LENGTH_SHORT).show()
}
// 命名归并(用户确认的规则:只并相邻同名行,时间线不回改旧行)。
// 含未定格句子的行不并入 —— 保护"in-progress 只存在于最底行"不变量。
private fun mergeAdjacentSameLabel() {
var i = 0
while (i < subtitleLines.size - 1) {
val a = subtitleLines[i]
val b = subtitleLines[i + 1]
if (a.label != null && a.label == b.label &&
b.sentences.none { !it.finalized }
) {
Log.i(TAG, "rename merge: line@${i + 1} -> line@$i (${a.label})")
for (s in b.sentences.toList()) {
s.line = a
a.sentences.add(s)
}
subtitleLines.removeAt(i + 1)
subtitleAdapter.notifyItemRemoved(i + 1)
} else {
i++
}
} }
lastLabel = name
lastConfirmedLabel = name
activeLabelForWindow = name
} }
private fun initMicrophone(): Boolean { private fun initMicrophone(): Boolean {
@ -1106,10 +1217,15 @@ class MainActivity : AppCompatActivity() {
private inner class SubtitleAdapter : RecyclerView.Adapter<SubtitleAdapter.VH>() { private inner class SubtitleAdapter : RecyclerView.Adapter<SubtitleAdapter.VH>() {
inner class VH(val text: TextView) : RecyclerView.ViewHolder(text) inner class VH(val text: TextView) : RecyclerView.ViewHolder(text)
override fun onCreateViewHolder(parent: ViewGroup, type: Int): VH = VH( override fun onCreateViewHolder(parent: ViewGroup, type: Int): VH {
val vh = VH(
LayoutInflater.from(parent.context) LayoutInflater.from(parent.context)
.inflate(R.layout.item_subtitle, parent, false) as TextView .inflate(R.layout.item_subtitle, parent, false) as TextView
) )
// 行首标签是 ClickableSpan;没有 movementMethod 点击不会生效
vh.text.movementMethod = LinkMovementMethod.getInstance()
return vh
}
override fun getItemCount(): Int = subtitleLines.size override fun getItemCount(): Int = subtitleLines.size
@ -1132,6 +1248,18 @@ class MainActivity : AppCompatActivity() {
StyleSpan(Typeface.BOLD), start, start + head.length, StyleSpan(Typeface.BOLD), start, start + head.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
) )
// 点击标签 → 命名/改名(绑定时捕获内部键,重命名后整表重绑)
val internalKey = line.label
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)
for ((i, s) in line.sentences.withIndex()) { for ((i, s) in line.sentences.withIndex()) {
if (s.text.isBlank()) continue if (s.text.isBlank()) continue
if (sb.length > start + head.length) { if (sb.length > start + head.length) {
@ -1146,16 +1274,7 @@ class MainActivity : AppCompatActivity() {
return sb return sb
} }
// 句间衔接:已带标点则空格;CJK/假名结尾用句号;拉丁结尾用英文句号。 // 句间衔接已提出为顶层函数 SubtitleFormat.kt#joinerAfter(可 JVM 单测)
private fun joinerAfter(prev: String): String {
val last = prev.trimEnd().lastOrNull() ?: return " "
return when {
last in "。!?;.!?;…\"”’』】》))" -> " "
last.code >= 0x2E80 -> "。"
Character.isLetterOrDigit(last) -> ". "
else -> " "
}
}
private fun resetSubtitles() { private fun resetSubtitles() {
val n = subtitleLines.size val n = subtitleLines.size
@ -1170,14 +1289,14 @@ class MainActivity : AppCompatActivity() {
subtitleRecycler.visibility = View.VISIBLE subtitleRecycler.visibility = View.VISIBLE
} }
// 内部标签 S0/S1/…(注册键,可被命名为真名)对外显示为 未识别1/未识别2…; // 内部标签 S0/S1/…(已注册、能区分但尚未起名的身份)对外显示"已识别人1/2…";
// 已命名者直接显示真名。 // 已命名者显示真名;label==null(完全无法归属)才显示灰色"未识别"。
private val autoLabelRegex = Regex("^S(\\d+)$") private val autoLabelRegex = Regex("^S(\\d+)$")
private fun displayName(label: String?): String { private fun displayName(label: String?): String {
if (label == null) return "未识别" if (label == null) return "未识别"
val m = autoLabelRegex.matchEntire(label) val m = autoLabelRegex.matchEntire(label)
return if (m != null) "未识别${m.groupValues[1].toInt() + 1}" else label return if (m != null) "已识别人${m.groupValues[1].toInt() + 1}" else label
} }
private fun colorIdxFor(label: String): Int = private fun colorIdxFor(label: String): Int =
@ -1242,6 +1361,12 @@ class MainActivity : AppCompatActivity() {
val sentence = sentenceById[sid] ?: return val sentence = sentenceById[sid] ?: return
if (sentence.finalized) return if (sentence.finalized) return
sentence.finalized = true sentence.finalized = true
if (summarizeEnabled) {
// 会话记录器挂钩①(settle):时长取 slotBook 记账(SKIP_SHORT 时无
// 记录,按 0 处理 —— startMs≈endMs,不影响纪要内容)
val durMs = ((slotBook[sid]?.durSec ?: 0f) * 1000f).toLong()
sessionTranscript?.onSettled(sid, durMs, sentence.text)
}
if (!speakersEnabled) { if (!speakersEnabled) {
// 声纹缺失:退化为按 endpoint 分行(显示"未识别") // 声纹缺失:退化为按 endpoint 分行(显示"未识别")
sentence.line?.open = false sentence.line?.open = false
@ -1277,10 +1402,9 @@ class MainActivity : AppCompatActivity() {
) { ) {
Log.i(TAG, "SLOT sid=$sid VERDICT speaker=" + Log.i(TAG, "SLOT sid=$sid VERDICT speaker=" +
(label?.let { displayName(it) } ?: "未匹配/无判定")) (label?.let { displayName(it) } ?: "未匹配/无判定"))
if (emb != null) { if (summarizeEnabled && label != null) {
lastEmbedding = emb // 会话记录器挂钩③(说话人归属;改名走 renameSpeaker 的 RENAME 事件)
lastEmbeddingFast = fastEmb sessionTranscript?.onSpeaker(sid, label)
nameSpeakerButton.isEnabled = true
} }
val sentence = findSentence(sid) ?: return val sentence = findSentence(sid) ?: return
var line = sentence.line ?: return var line = sentence.line ?: return
@ -1424,6 +1548,10 @@ class MainActivity : AppCompatActivity() {
} else { } else {
sentence.refined = false // 未精修标记 sentence.refined = false // 未精修标记
} }
if (summarizeEnabled) {
// 会话记录器挂钩②(终稿文本):与字幕层看到同一最终结果
sessionTranscript?.onRefined(sid, sentence.text, sentence.refined)
}
} }
// 换人强制断句后:封掉旧行,之后的 partial 开新行并归属新说话人。 // 换人强制断句后:封掉旧行,之后的 partial 开新行并归属新说话人。
@ -1439,8 +1567,14 @@ class MainActivity : AppCompatActivity() {
private fun rollingSpeakerSearch(emb: FloatArray): String? = private fun rollingSpeakerSearch(emb: FloatArray): String? =
if (SpeakerRecognition.fastReady()) if (SpeakerRecognition.fastReady())
SpeakerRecognition.fastRegistry.search(emb, fastSearchThreshold) SpeakerRecognition.fastRegistry.search(emb, fastSearchThreshold)
?: SpeakerRecognition.fastRegistry.nearest(
emb, rollingFallbackMin
)
else else
SpeakerRecognition.registry.search(emb, speakerThreshold) SpeakerRecognition.registry.search(emb, speakerThreshold)
?: SpeakerRecognition.registry.nearest(
emb, verdictFallbackMin
)
// ③ 滚动检测查到了已知人、但当前行还没有归属(占位"未识别"): // ③ 滚动检测查到了已知人、但当前行还没有归属(占位"未识别"):
// 直接把活跃归属采纳为他,等 endpoint 权威判定给行冠名,不再多切一行 // 直接把活跃归属采纳为他,等 endpoint 权威判定给行冠名,不再多切一行
@ -1448,6 +1582,15 @@ class MainActivity : AppCompatActivity() {
if (lastConfirmedLabel == null) { if (lastConfirmedLabel == null) {
lastConfirmedLabel = label lastConfirmedLabel = label
activeLabelForWindow = label activeLabelForWindow = label
// 修"灰色未识别滞留"来源之一:滚动已匹配到人,顺带给当前灰色
// 占位行冠名(能并回上方同人行则并),不必等 endpoint 权威判定
subtitleLines.lastOrNull()
?.takeIf { it.open && it.label == null }?.let { gray ->
gray.label = label
gray.colorIdx = colorIdxFor(label)
if (!mergeLineIntoPrevIfSame(gray, label)) notifyLine(gray)
Log.i(TAG, "adopt named gray line -> ${displayName(label)}")
}
} }
} }
@ -1474,4 +1617,234 @@ class MainActivity : AppCompatActivity() {
subtitleRecycler.scrollToPosition(last) subtitleRecycler.scrollToPosition(last)
} }
} }
// ==================== Summarize(会中/会后一键成稿,§4.2)================
// 录音中点击 = 对截至当时已输出的全部定稿字幕生成纪要,录音转写全程不停。
// 进度与结果内嵌在下半屏「会议纪要」区(生成中旧纪要不清空);成稿成功
// 落 <会话>.notes.json,下次点击只处理新增句子续写笔记,纪要始终覆盖
// "开会至今"全量。模型点击才 mmap、完成即 release(§4.2 模型生命周期)。
private fun onSummarizeClick() {
if (isSummarizing) {
Toast.makeText(this, "纪要正在生成中,再点一次可取消", Toast.LENGTH_SHORT).show()
return
}
if (!LlmRuntime.ensure()) {
Toast.makeText(this, "本机 ABI 未包含摘要运行时库(仅 arm64-v8a 真机可用)",
Toast.LENGTH_LONG).show()
return
}
val modelDir = File(externalModelDir, summarizeModelDirName)
val gguf = File(modelDir, summarizeGgufName)
// 存在性检查必须在任何 native 调用之前(工程教训:缺文件会让 native exit())
if (!gguf.isFile) {
Toast.makeText(
this,
"缺少摘要模型。请执行:\nadb push $summarizeGgufName " +
"/sdcard/Android/data/com.k2fsa.sherpa.onnx/files/models/$summarizeModelDirName/",
Toast.LENGTH_LONG
).show()
return
}
// 会话选择(2026-09-11 修订):只要存在当前会话,它就是唯一数据源
// (含刚 Stop 未久的会话——供会后整理用)。新会话还没有定格句子时
// 如实提示,绝不回退到上一场的 jsonl/notes,避免"新会议按旧笔记摘要"。
// 仅当本进程没有会话上下文(如重启 app)时,才取盘上最新一场会议。
val entries: List<TranscriptEntry>
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
}
if (isRecording) {
Toast.makeText(this, "纪要生成中,录音转写不会中断", Toast.LENGTH_SHORT).show()
}
// 增量断点:上次成稿留下的 <会话>.notes.json → 只处理新增句子续写笔记
val saved = MeetingNotesIO.load(
MeetingNotesIO.pathFor(SessionTranscript.dirOf(filesDir), baseName), baseName)
var resumeIndex = 0
var resumeNotes: String? = null
if (saved != null && saved.entriesP1Done > 0 && saved.notes != SummarizePipeline.EMPTY_NOTES) {
resumeIndex = saved.entriesP1Done.coerceAtMost(entries.size)
resumeNotes = saved.notes
Log.i(TAG, "SUMMARIZE resume from notes at $resumeIndex/${entries.size}")
}
isSummarizing = true
summarizeCancelled = false
runOnUiThread { setMinutesBusyUi(true, "正在加载摘要模型(约 3.2 GB)…") }
val entriesRef = entries // 快照已在主线程拷贝,线程安全
thread(true) {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)
var engine: LlmEngine? = null
try {
val tLoad = System.currentTimeMillis()
engine = LlmEngine(gguf.absolutePath, SUMMARY_CTX,
if (isRecording) SUMMARY_THREADS_BUSY else SUMMARY_THREADS,
chatTemplate = summarizeChatTemplate)
Log.i(TAG, "SUMMARIZE engine loaded ms=" +
(System.currentTimeMillis() - tLoad))
summarizeEngine = engine
runOnUiThread {
setMinutesBusyUi(true, if (resumeNotes != null)
"模型就绪,从上次断点续跑($resumeIndex/${entriesRef.size} 句已消化)…"
else "模型就绪,共 ${entriesRef.size} 句,开始滚动笔记…")
}
val prompts = PromptsLoader.load(application.assets)
val glossary = File(modelDir, "glossary.txt").let {
if (it.isFile) runCatching { it.readText() }.getOrNull() ?: "" else ""
}
val pipeline = SummarizePipeline(engine, prompts)
val result = pipeline.run(
labelOf = { l -> displayName(l) }, // 纯函数,跨线程安全
entries = entriesRef,
meetingStartWallMs = wallStart,
glossary = glossary,
resumeNotes = resumeNotes,
resumeFromIndex = resumeIndex,
listener = object : SummarizeListener {
override fun onProgress(stage: String, done: Int, total: Int) {
runOnUiThread {
minutesStatus.text = "正在$stage: $done/$total"
}
}
override fun isCancelled(): Boolean = summarizeCancelled
},
)
val mdFile = File(SessionTranscript.dirOf(filesDir), "$baseName.md")
mdFile.writeText(result.markdown)
// 增量断点落盘:本次条目全部进了笔记,下次点击只消化新增句子
MeetingNotesIO.save(
MeetingNotesIO.pathFor(SessionTranscript.dirOf(filesDir), baseName),
MeetingNotes(result.notesJson, entriesRef.size,
entriesRef.last().endMs, result.failedChunks, baseName))
Log.i(TAG, "SUMMARIZE done: ${result.totalChunks} chunks, " +
"failed=${result.failedChunks}, saved=${mdFile.absolutePath}")
runOnUiThread {
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}"
copyMinutesButton.isEnabled = true
}
} catch (t: Throwable) {
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 = 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
engine?.release() // 用完必须显式释放,勿依赖 finalize()
isSummarizing = false
runOnUiThread { setMinutesBusyUi(false, null) }
}
}
}
/** 生成中:纪要按钮变「取消生成」+ 状态行显示 msg;结束:复原按钮。 */
private fun setMinutesBusyUi(busy: Boolean, msg: String?) {
if (busy) {
summarizeButton.setText(R.string.cancel_minutes)
if (msg != null) {
minutesStatus.visibility = View.VISIBLE
minutesStatus.text = msg
}
} else {
summarizeButton.setText(R.string.generate_minutes)
}
}
private fun cancelSummarize() {
summarizeCancelled = true
summarizeEngine?.abort() // 打断在途 generate;finally 里统一收尾
minutesStatus.text = "正在取消…"
minutesStatus.visibility = View.VISIBLE
}
/** 把当前纪要正文复制到剪贴板(方便粘到 PC/邮件)。 */
private fun onCopyMinutesClick() {
val body = minutesText.text?.toString().orEmpty()
if (body.isBlank()) {
Toast.makeText(this, "尚无纪要可复制", Toast.LENGTH_SHORT).show()
return
}
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()
} catch (t: Throwable) {
Toast.makeText(this, "复制失败:$t", Toast.LENGTH_SHORT).show()
}
}
/** 冷启动把上一场最近的纪要填进下方区(.md 在盘上,读文件即可)。 */
private fun loadLastMinutes() {
try {
val latest = SessionTranscript.dirOf(filesDir)
.listFiles { f -> f.isFile && f.name.endsWith(".md") }
?.maxByOrNull { it.lastModified() } ?: return
val body = latest.readText()
if (body.isNotBlank()) {
minutesText.text = body
minutesHint.visibility = View.GONE
copyMinutesButton.isEnabled = true
minutesStatus.visibility = View.VISIBLE
minutesStatus.text = "上次纪要:${latest.name}(生成新纪要后会覆盖)"
}
} catch (t: Throwable) {
Log.w(TAG, "loadLastMinutes: $t")
}
}
/**
* ②息屏生存能力(重启提示部分):本进程没有会话上下文时,若盘上
* 最新会议的 .md 缺失或比转写旧(生成中途被杀/没点生成),状态行提示可续写。
*/
private fun hintUnfinishedMeeting() {
if (!summarizeEnabled || sessionTranscript != null) return
val f = SessionTranscript.latestFile(filesDir) ?: return
val md = File(SessionTranscript.dirOf(filesDir), f.nameWithoutExtension + ".md")
if (!md.isFile || md.lastModified() < f.lastModified()) {
minutesStatus.visibility = View.VISIBLE
minutesStatus.text = "上一场会议「${f.nameWithoutExtension}」尚未出纪要," +
"点「生成纪要」可续写(增量,只处理新增句子)"
}
}
} }

View File

@ -0,0 +1,63 @@
package com.k2fsa.sherpa.onnx
import android.util.Log
import org.json.JSONObject
import java.io.File
// ============================================================================
// MeetingNotes — 会议滚动笔记的盘上断点(meetings/<会话>.notes.json)
//
// 「会中手动成稿」的增量基础:每次成稿成功后把 P1 笔记 + 已消化句子数写入;
// 下次点「生成纪要」只处理新增句子(接同一份笔记续跑),长会第 N 次点击仍是
// 分钟级。笔记文件缺失/校验不过 → 自动退回全量重跑(里程碑 1 行为)。
// 取代了已取消的"会中自动滚动"(RollingSummarizer,2026-09-11 需求变更)。
// ============================================================================
data class MeetingNotes(
val notes: String,
val entriesP1Done: Int,
val lastEndMs: Long,
val failedChunks: Int,
val sessionId: String,
)
object MeetingNotesIO {
private const val TAG = "sherpa-onnx"
fun pathFor(meetingDir: File, sessionBaseName: String): File =
File(meetingDir, "$sessionBaseName.notes.json")
/** sessionId 必须与文件一致(防止把别场会议的笔记接错);笔记 JSON 校验必需键。 */
fun load(file: File, sessionId: String): MeetingNotes? {
if (!file.isFile) return null
return try {
val o = JSONObject(file.readText())
if (o.optString("sessionId") != sessionId) return null
val notes = o.optString("notes")
if (SummarizePipeline.extractJson(notes) == null) return null
MeetingNotes(
notes, o.optInt("entriesP1Done"), o.optLong("lastEndMs"),
o.optInt("failedChunks"), sessionId,
)
} catch (t: Throwable) {
Log.w(TAG, "notes file unreadable: $t")
null
}
}
/** tmp+rename 原子写:成稿线程调用(会议目录私有 IO,毫秒级)。 */
fun save(file: File, p: MeetingNotes) {
try {
val tmp = File(file.parentFile, file.name + ".tmp")
tmp.writeText(JSONObject().apply {
put("sessionId", p.sessionId); put("notes", p.notes)
put("entriesP1Done", p.entriesP1Done); put("lastEndMs", p.lastEndMs)
put("failedChunks", p.failedChunks); put("savedAt", System.currentTimeMillis())
}.toString())
if (file.exists()) file.delete()
if (!tmp.renameTo(file)) tmp.copyTo(file, overwrite = true)
} catch (t: Throwable) {
Log.w(TAG, "notes persist failed: $t")
}
}
}

View File

@ -0,0 +1,216 @@
package com.k2fsa.sherpa.onnx
import android.os.SystemClock
import android.util.Log
import org.json.JSONObject
import java.io.BufferedWriter
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// ============================================================================
// SessionTranscript — 全量会议转写记录器(摘要功能·里程碑 1,§4.2)
//
// 独立于字幕 UI 的追加式事件流:字幕层受 300 行上限并会被迁移/合并/改名,
// 这里以 sid 为主键记录每条定格句的 (startMs, endMs, speaker, text, refined),
// 落盘 JSONL(filesDir/meetings/<yyyyMMdd-HHmm>.jsonl),回放事件重建全文。
//
// 线程规矩:所有 on* 方法只在主线程被调用(finalizeSentence / confirmSpeaker /
// applyRefined / relabelSpeakerLines 本身就在主线程);JSONL 逐行 append 是
// 毫秒级 IO,每句几秒一次的频率不会卡 UI。snapshot()/load() 在摘要后台线程
// 读文件,与写入的逐行 flush 兼容(读侧容忍最后一行残缺)。
// ============================================================================
class TranscriptEntry(
val sid: Long,
val startMs: Long, // 相对会话开始(ms)
val endMs: Long, // 相对会话开始(ms)
var speaker: String?,// 内部标签 S{n}/真名;null=未识别
var text: String,
var refined: Boolean,
)
class SessionTranscript private constructor(
val file: File,
val sessionStartElapsed: Long,
) {
val startWallMs: Long = System.currentTimeMillis()
private val entries = ArrayList<TranscriptEntry>()
private val bySid = HashMap<Long, TranscriptEntry>()
private var writer: BufferedWriter? = null
companion object {
private const val TAG = "sherpa-onnx"
const val DIR_NAME = "meetings"
/** 本地最多保留的会议场数(超出自动清理 jsonl/notes/md) */
const val KEEP_SESSIONS = 50
private fun tsFmt() = SimpleDateFormat("yyyyMMdd-HHmm", Locale.US)
fun dirOf(filesDir: File): File = File(filesDir, DIR_NAME).apply { mkdirs() }
fun latestFile(filesDir: File): File? =
dirOf(filesDir).listFiles { f -> f.name.endsWith(".jsonl") }
?.maxByOrNull { it.lastModified() }
/** 从文件第一行 H 事件读会议真实开始时间;缺失时退回文件 mtime。 */
fun fileStartWallMs(file: File): Long {
return try {
file.bufferedReader().use { r ->
val line = r.readLine() ?: return@use file.lastModified()
val o = JSONObject(line)
if (o.optString("t") == "H") o.optLong("wall", file.lastModified())
else file.lastModified()
}
} catch (t: Throwable) {
file.lastModified()
}
}
/** 录音开始时在主线程调用,新建会话文件。 */
fun begin(filesDir: File): SessionTranscript {
val dir = dirOf(filesDir)
pruneOldSessions(dir, KEEP_SESSIONS)
val name = tsFmt().format(Date()) + ".jsonl"
var f = File(dir, name)
var i = 1
while (f.exists()) { f = File(dir, name.removeSuffix(".jsonl") + "-$i.jsonl"); i++ }
val t = SessionTranscript(f, SystemClock.elapsedRealtime())
t.openWriter()
t.appendLine(JSONObject().apply {
put("t", "H"); put("wall", System.currentTimeMillis())
})
return t
}
/**
* ③ 保留策略:按会话名分组(jsonl/notes/md 同前缀归一组),
* 按组内最新修改时间排序,只留最近 KEEP_SESSIONS 场。
* 删除失败静默(文件可能被并发读),不影响新会话创建。
*/
internal fun pruneOldSessions(dir: File, keep: Int = KEEP_SESSIONS) {
runCatching {
dir.listFiles { f -> f.isFile }
?.groupBy { it.name.substringBeforeLast('.') }
?.entries
?.sortedByDescending { (_, files) -> files.maxOf { it.lastModified() } }
?.drop(keep)
?.forEach { (_, files) -> files.forEach { runCatching { it.delete() } } }
}
}
/** 从 JSONL 回放(摘要后台线程用)。容忍最后一行残缺。 */
fun load(file: File): List<TranscriptEntry> {
val out = ArrayList<TranscriptEntry>()
val idx = HashMap<Long, TranscriptEntry>()
var renames = ArrayList<JSONObject>()
file.forEachLine { line ->
val o = try { JSONObject(line) } catch (t: Throwable) { return@forEachLine }
when (o.optString("t")) {
"S" -> {
val e = TranscriptEntry(
sid = o.optLong("sid"),
startMs = o.optLong("b"), endMs = o.optLong("e"),
speaker = o.optString("sp").takeIf { it.isNotEmpty() && it != "null" },
text = o.optString("x"), refined = o.optInt("r") == 1,
)
idx[e.sid] = e; out.add(e)
}
"R" -> idx[o.optLong("sid")]?.let {
it.text = o.optString("x", it.text); it.refined = o.optInt("r") == 1
}
"P" -> idx[o.optLong("sid")]?.let {
val sp = o.optString("sp").takeIf { s -> s.isNotEmpty() && s != "null" }
if (sp != null) it.speaker = sp
}
"N" -> renames.add(o)
}
}
// 改名事件最后统一回放(顺序与录制时一致)
for (rn in renames) {
val from = rn.optString("from"); val to = rn.optString("to")
for (e in out) if (e.speaker == from) e.speaker = to
}
return out
}
}
// ---- 主线程挂钩点 --------------------------------------------------------
/** finalizeSentence 处调用:endMs=定格时刻(相对会话开始),startMs=endMs-dur。 */
fun onSettled(sid: Long, durMs: Long, text: String) {
if (text.isBlank()) return
val end = SystemClock.elapsedRealtime() - sessionStartElapsed
val e = TranscriptEntry(sid, (end - durMs).coerceAtLeast(0), end, null, text, false)
bySid[sid] = e; entries.add(e)
appendLine(JSONObject().apply {
put("t", "S"); put("sid", sid); put("b", e.startMs); put("e", end)
put("x", text)
})
}
/** applyRefined 处调用(每槽恰好一次,与字幕层幂等规则一致)。 */
fun onRefined(sid: Long, text: String, refined: Boolean) {
val e = bySid[sid] ?: return
if (text.isBlank()) return
e.text = text; e.refined = refined
appendLine(JSONObject().apply {
put("t", "R"); put("sid", sid); put("x", text); put("r", if (refined) 1 else 0)
})
}
/** confirmSpeaker 处调用(含声纹线程回传后的所有修正路径)。 */
fun onSpeaker(sid: Long, label: String?) {
val e = bySid[sid] ?: return
if (label != null && e.speaker != label) {
e.speaker = label
appendLine(JSONObject().apply {
put("t", "P"); put("sid", sid); put("sp", label)
})
}
}
/** renameSpeaker(点击标签命名/改名)处调用:S{n}→真名。 */
fun onRename(from: String, to: String) {
var touched = false
for (e in entries) if (e.speaker == from) { e.speaker = to; touched = true }
if (touched) appendLine(JSONObject().apply {
put("t", "N"); put("from", from); put("to", to)
})
}
// ---- 读取 / 收尾 ----------------------------------------------------------
/** 主线程取快照(拷贝,供摘要后台线程安全读)。 */
fun snapshot(): List<TranscriptEntry> = synchronized(entries) {
entries.map {
TranscriptEntry(it.sid, it.startMs, it.endMs, it.speaker, it.text, it.refined)
}
}
fun flush() {
try { writer?.flush() } catch (t: Throwable) { Log.w(TAG, "transcript flush: $t") }
}
fun close() {
flush()
try { writer?.close() } catch (_: Throwable) {}
writer = null
}
// ---- 内部 -----------------------------------------------------------------
private fun openWriter() {
try { writer = file.bufferedWriter() } catch (t: Throwable) {
Log.e(TAG, "SessionTranscript cannot open $file", t)
}
}
private fun appendLine(o: JSONObject) {
val w = writer ?: return
try {
w.write(o.toString()); w.write("\n"); w.flush()
} catch (t: Throwable) { Log.w(TAG, "transcript append: $t") }
}
}

View File

@ -123,6 +123,49 @@ class SpeakerRegistry {
return best return best
} }
/**
* 最近邻兜底(用户要求 2026-09-11):"未识别"归到与 e 最相似的已注册者
* (命名条目优先,其后自动条目;相似度须 ≥ minScore)。全空/不达标返回 null。
*/
@Synchronized
fun nearest(e: FloatArray, minScore: Float): String? {
val q = l2normalize(e)
var bestName: String? = null
var best = -1f
for ((name, emb) in named) {
val s = dot(q, emb)
if (s > best) { best = s; bestName = name }
}
for ((name, emb) in auto) {
val s = dot(q, emb)
if (s > best) { best = s; bestName = name }
}
return if (best >= minScore) bestName else null
}
/**
* 点击标签命名用:把内部键 S{n}(自动注册、未命名)升级为真名,
* 或把已有真名改成另一个真名。同名允许多条嵌入并存 —— 之前误判成
* 两个说话人、后被命名为同一名字的两组声纹,自此 search 都返回同一名。
*/
@Synchronized
fun renameToName(oldKey: String, name: String): Boolean {
val ai = auto.indexOfFirst { it.first == oldKey }
if (ai >= 0) {
named.add(Pair(name, auto[ai].second))
auto.removeAt(ai)
return true
}
var changed = false
for (i in named.indices) {
if (named[i].first == oldKey) {
named[i] = Pair(name, named[i].second)
changed = true
}
}
return changed
}
private fun match( private fun match(
list: List<Pair<String, FloatArray>>, list: List<Pair<String, FloatArray>>,
e: FloatArray, e: FloatArray,

View File

@ -0,0 +1,16 @@
package com.k2fsa.sherpa.onnx
// ============================================================================
// SubtitleFormat — 字幕行内句间衔接的纯函数(从 MainActivity 提出以便 JVM 单测)
// ============================================================================
/** 句间衔接:已带标点则空格;CJK/假名结尾用句号;拉丁结尾用英文句号。 */
internal fun joinerAfter(prev: String): String {
val last = prev.trimEnd().lastOrNull() ?: return " "
return when {
last in "。!?;.!?;…\"”’』】》))" -> " "
last.code >= 0x2E80 -> "。"
Character.isLetterOrDigit(last) -> ". "
else -> " "
}
}

View File

@ -0,0 +1,443 @@
package com.k2fsa.sherpa.onnx
import android.content.res.AssetManager
import android.util.Log
import org.json.JSONObject
// ============================================================================
// SummarizePipeline — §5 摘要流水线(分块 → 滚动笔记 → 成稿)
//
// 提示词唯一事实源是 assets/summarize/prompts.md(用户可直接编辑/复制到 PC
// llama.cpp 调试)。解析失败时退回本文件内置的同源副本 —— 两份不一致以 md
// 为准,改提示词请同步改 md。
//
// 只在摘要专用后台线程上 new 出来跑完就丢,绝不触碰录音/主线程对象。
// ============================================================================
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,
)
data class MeetingSummary(
val markdown: String,
val language: String, // ja / zh / en
val notesJson: String,
val totalChunks: Int,
val failedChunks: Int,
)
interface SummarizeListener {
/** stage 人话描述("正在加载模型…"、"第 3/12 块…")。在摘要线程回调。 */
fun onProgress(stage: String, done: Int, total: Int)
fun isCancelled(): Boolean
}
// ---- prompts.md 解析 --------------------------------------------------------
object PromptsLoader {
private const val TAG = "sherpa-onnx"
private const val ASSET = "summarize/prompts.md"
fun load(assets: AssetManager): Prompts {
val md = try {
assets.open(ASSET).bufferedReader().use { it.readText() }
} catch (t: Throwable) {
Log.w(TAG, "prompts.md missing/unreadable, using builtin: $t")
return BUILTIN
}
return try {
parse(md)
} catch (t: Throwable) {
Log.e(TAG, "prompts.md parse failed, using builtin", t)
BUILTIN
}
}
// "## P1 …" 小节里前两个代码围栏块依次是 SYSTEM、USER;"## P2" 同理。
internal fun parse(md: String): Prompts {
fun section(marker: String): Pair<String, String> {
val start = md.indexOf(marker)
require(start >= 0) { "section $marker not found" }
val end = md.indexOf("\n## ", start + marker.length).let {
if (it < 0) md.length else it
}
val body = md.substring(start, end)
val fences = ArrayList<String>()
var i = 0
while (true) {
val open = body.indexOf("```", i)
if (open < 0) break
val close = body.indexOf("```", open + 3)
if (close < 0) break
fences.add(body.substring(open + 3, close).trim('\n').trim())
i = close + 3
}
require(fences.size >= 2) { "$marker: need 2 fenced blocks, got ${fences.size}" }
return fences[0] to fences[1]
}
val (p1s, p1u) = section("## P1")
val (p2s, p2u) = section("## P2")
require(p1s.isNotBlank() && p2s.isNotBlank())
return Prompts(p1s, p1u, p2s, p2u)
}
// 与 prompts.md 同源的内置副本(md 丢失/被改坏时的兜底)。
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" +
"{\"已定事項\":[{\"内容\":\"\",\"出典\":\"\"}],\"討論中\":[{\"内容\":\"\",\"出典\":\"\"}],\"行動項目\":[{\"内容\":\"\",\"担当者\":\"\",\"期限\":\"\",\"出典\":\"\"}],\"人物\":[{\"名前\":\"\",\"立場\":\"\"}],\"前回末尾\":\"\"}",
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を出力してください。",
)
}
// ---- 流水线主体 --------------------------------------------------------------
class SummarizePipeline(
private val engine: LlmEngine,
private val prompts: Prompts,
) {
companion object {
private const val TAG = "sherpa-onnx"
private const val CHUNK_TARGET = 2500 // §5:2500±500 字/块
private const val CHUNK_MIN = 2000
private const val CHUNK_MAX = 3000
// P1 输入(笔记+块+固定文案)字符预算:超了就对半拆块,
// 保证 ≤8192 ctx 永远够用(日语 1 字 ≈ 1 token 多一点)
private const val P1_MAX_PROMPT_CHARS = 6500
private const val NOTES_RESERVE = 1200 // 笔记+模板文案预留
val EMPTY_NOTES =
"{\"已定事項\":[],\"討論中\":[],\"行動項目\":[],\"人物\":[],\"前回末尾\":\"\"}"
// 脚本占比判语种:假名出现即日语信号(日语里汉字占比也高,不能只看汉字);
// 无假名时看汉字 vs 拉丁;全兜底 en。
fun detectLanguage(text: String): String {
var kana = 0
var han = 0
var latin = 0
for (ch in text) {
val cp = ch.code
when {
cp in 0x3040..0x30FF || cp in 0x31F0..0x31FF || cp in 0xFF66..0xFF9F -> kana++
cp in 0x3400..0x4DBF || cp in 0x4E00..0x9FFF || cp in 0xF900..0xFAFF -> han++
cp in 0x41..0x5A || cp in 0x61..0x7A -> latin++
}
}
return when {
kana > 0 && kana * 20 >= kana + han -> "ja" // 假名/汉字 ≥ 5%
han >= latin && han > 0 -> "zh"
else -> "en"
}
}
fun languageName(lang: String) = when (lang) {
"ja" -> "日本語"
"zh" -> "中文(简体书面语)"
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)
}
fun formatDate(wallMs: Long): String =
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.US)
.format(java.util.Date(wallMs))
/** P2 成稿 user 提示词构造(滚动成稿与里程碑 1 共用,保证两路输出同构)。 */
fun buildDraftUserPrompt(
prompts: Prompts, lang: String, meetingStartWallMs: Long,
nSpeakers: Int, totalDurMs: Long, glossary: String, notes: String,
): String = prompts.p2UserTpl
.replace("{language_name}", languageName(lang))
.replace("{meeting_start}", formatDate(meetingStartWallMs))
.replace("{n_speakers}", nSpeakers.toString())
.replace("{total_duration}", mmss(totalDurMs))
.replace("{glossary_lines}", glossary.ifBlank { "(なし)" })
.replace("{notes_json}", notes)
// §5 分块铁律:句子边界聚合到 2500±500 字;块间重叠 1 句;
// 同一说话人连续段(轮次)不拆开:切点落在同人连续段中间则延到段尾。
// 另:超 P1 预算的块按行二分一次,保证 prompt ≤ ctx。
internal fun chunk(lines: List<String>): List<String> {
val blocks = ArrayList<String>()
var i = 0
var overlap: String? = null
while (i < lines.size) {
var j = i
var len = 0
while (j < lines.size) {
val l = lines[j]
if (len > 0 && len + l.length > CHUNK_MAX) break
if (len >= CHUNK_TARGET) {
// 与上一句同一说话人 → 轮次未完,继续收
if (j == i || speakerOf(l) != speakerOf(lines[j - 1])) {
if (len >= CHUNK_MIN) break
}
}
len += l.length + 1
j++
}
if (j == i) j++ // 单句超长的极端情况:至少吞一句
blocks.add(
StringBuilder().apply {
overlap?.let { append(it).append('\n') }
for (k in i until j) append(lines[k]).append('\n')
}.toString().trimEnd()
)
overlap = if (j < lines.size) lines[j - 1] else null
i = j
}
val fine = ArrayList<String>()
for (c in blocks) {
if (c.length + NOTES_RESERVE <= P1_MAX_PROMPT_CHARS) {
fine.add(c)
} else {
val ls = c.split('\n').filter { it.isNotBlank() }
val half = (ls.size / 2).coerceAtLeast(1)
val a = ls.subList(0, half).joinToString("\n")
val b = ls.subList(half, ls.size).joinToString("\n")
if (a.isNotBlank()) fine.add(a)
if (b.isNotBlank()) fine.add(b)
}
}
return fine
}
private fun speakerOf(line: String): String {
val a = line.indexOf('[')
val b = line.indexOf(']', a + 1)
return if (a >= 0 && b > a) line.substring(a, b) else line
}
// 笔记输出健壮性:取首个平衡 {...}(前后的客套话/代码围栏自然被忽略)、
// 必需键检查
internal fun extractJson(raw: String): String? {
val start = raw.indexOf('{')
if (start < 0) return null
var depth = 0
var inStr = false
var esc = false
for (i in start until raw.length) {
val c = raw[i]
if (esc) { esc = false; continue }
when {
c == '\\' && inStr -> esc = true
c == '"' -> inStr = !inStr
!inStr && c == '{' -> depth++
!inStr && c == '}' -> {
depth--
if (depth == 0) {
val cand = raw.substring(start, i + 1)
return try {
val o = JSONObject(cand)
if (o.has("已定事項") && o.has("行動項目")) cand else null
} catch (t: Throwable) {
null
}
}
}
}
}
return null
}
internal fun cleanDraft(md: String): String {
var s = md.trim()
if (s.startsWith("```")) {
s = s.substringAfter('\n').substringBeforeLast("```")
}
s = s.replace(
Regex("<\\|channel>thought.*?<channel\\|>", RegexOption.DOT_MATCHES_ALL), ""
)
s = s.replace(Regex("\n{3,}"), "\n\n")
return s.trim()
}
/**
* 输出语种纯度统计(2026-09-11 起只用于对账日志,不再触发整篇重写):
* zh → 假名 > 8 个判违规;en → 汉字+假名 > 6 个判违规;ja → 不检查。
* 返回违规样本描述,null = 通过。
*/
internal fun scriptViolation(lang: String, text: String): String? {
var kana = 0
var han = 0
var firstBad = -1
for (i in text.indices) {
val cp = text[i].code
when {
cp in 0x3040..0x30FF || cp in 0xFF66..0xFF9F -> { kana++; if (firstBad < 0) firstBad = i }
cp in 0x3400..0x4DBF || cp in 0x4E00..0x9FFF || cp in 0xF900..0xFAFF -> {
han++
if (firstBad < 0) firstBad = i
}
}
}
fun sample(): String = text.substring(
firstBad, (firstBad + 16).coerceAtMost(text.length)
).replace("\n", " ")
return when (lang) {
"zh" -> if (kana > 8) "假名 $kana 个(如「${sample()}」)" else null
"en" -> if (kana + han > 6) "中日文字 $han+$kana 个(around \"${sample()}\")" else null
else -> null
}
}
}
/**
* @param labelOf 内部说话人键(S{n}/真名)→ 显示名(已识别人N/真名/未识别)
* @param entries SessionTranscript 快照(定格顺序,不可为空)
* @param resumeNotes/resumeFromIndex M2:会中滚动笔记已覆盖前 resumeFromIndex 句,
* 仅补跑尾部(进度、成稿复用同一份笔记)
*/
fun run(
labelOf: (String?) -> String,
entries: List<TranscriptEntry>,
meetingStartWallMs: Long,
glossary: String,
listener: SummarizeListener,
resumeNotes: String? = null,
resumeFromIndex: Int = 0,
): MeetingSummary {
if (entries.isEmpty()) throw IllegalArgumentException("会议转写为空")
val lang = detectLanguage(entries.joinToString("") { it.text })
// ---- 分块(从断点句开始;断点前借 1 句做上下文衔接,同块间重叠规则)----
val startIdx = resumeFromIndex.coerceIn(0, entries.size)
fun renderLine(e: TranscriptEntry) = "(%s-%s)[%s] %s".format(
mmss(e.startMs), mmss(e.endMs), labelOf(e.speaker), e.text.trim()
)
val lines = ArrayList<String>()
if (startIdx > 0 && startIdx < entries.size) lines.add(renderLine(entries[startIdx - 1]))
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")
// ---- 滚动笔记(§5.2)----
var notes = resumeNotes ?: EMPTY_NOTES
var failed = 0
val tP1Start = System.currentTimeMillis()
chunks.forEachIndexed { idx, chunkText ->
if (listener.isCancelled()) throw SummarizeCancelledException()
val user = prompts.p1UserTpl
.replace("{notes_json}", notes)
.replace("{chunk_text}", chunkText)
listener.onProgress("滚动笔记", idx + 1, total)
val t0 = System.currentTimeMillis()
var out = engine.generate(
systemPrompt = prompts.p1System, userPrompt = user,
maxNewTokens = 900, temperature = 0f,
)
var parsed = extractJson(out)
if (parsed == null) {
// 一次修复重试:把畸形输出递回去(仍失败则保留旧笔记,计 failed)
if (listener.isCancelled()) throw SummarizeCancelledException()
val fix = user + "\n\n(前回の出力はJSONとして不正でした: " +
out.take(200).replace("\n", " ") + " 正しいJSONのみを再出力してください。)"
out = engine.generate(
systemPrompt = prompts.p1System, userPrompt = fix,
maxNewTokens = 900, temperature = 0f,
)
parsed = extractJson(out)
}
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} " +
"raw=${out.length} json=${parsed?.length ?: 0} " +
"retry=${if (parsed == null || out.length < 1) 1 else 0} " +
"ms=${System.currentTimeMillis() - t0}")
}
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()
listener.onProgress("成稿", total, total)
val nSpeakers = entries.map { it.speaker ?: "?" }.distinct().size
val user2 = buildDraftUserPrompt(
prompts, lang, meetingStartWallMs, nSpeakers,
entries.last().endMs, glossary, notes
)
val tP2 = System.currentTimeMillis()
val md = cleanDraft(engine.generate(
systemPrompt = prompts.p2System, userPrompt = user2,
maxNewTokens = 2500, temperature = 0.5f, topK = 64, topP = 0.95f,
))
Log.i(TAG, "P2 draft prompt_len=${user2.length} draft_len=${md.length} " +
"ms=${System.currentTimeMillis() - tP2}")
// 语种"校正"(整篇重写)已按用户要求移除:识别是什么语言,纪要就存
// 什么语言,不再为混语倾向多花一次 2500 token 生成。这里只留一行
// 纯统计日志(零成本),纪要中的混语留给人工处理。
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)
}
}

View File

@ -9,7 +9,6 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"> android:orientation="vertical">
<FrameLayout <FrameLayout
@ -17,7 +16,8 @@
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="2.5"> android:layout_weight="2.5">
<!-- subtitle dialog stream, one item per speaker line --> <!-- subtitle dialog stream, one item per speaker line;
tap the underlined speaker label to (re)name that speaker -->
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/subtitle_list" android:id="@+id/subtitle_list"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -43,32 +43,82 @@
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
</FrameLayout> </FrameLayout>
<!-- 录音控制紧贴字幕区下方:开始/停止一眼可见 -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="0.5"
android:gravity="center" android:gravity="center"
android:orientation="horizontal"> android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<Button <Button
android:id="@+id/record_button" android:id="@+id/record_button"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/start" /> android:text="@string/start" />
</LinearLayout>
<!-- 纪要面板:状态一行 + 正文(hint 占位/markdown 正文叠放) -->
<TextView
android:id="@+id/minutes_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:textColor="#666666"
android:textSize="12sp"
android:visibility="gone" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1.2">
<TextView
android:id="@+id/minutes_hint"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:padding="16dp"
android:text="点击「生成纪要」离线总结本场对话;结果与上次纪要显示在这里"
android:textColor="#999999"
android:textSize="13sp" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="12dp">
<TextView
android:id="@+id/minutes_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:textSize="13sp" />
</ScrollView>
</FrameLayout>
<!-- 纪要操作按钮与纪要面板放在同一区,贴底部 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:paddingBottom="12dp">
<Button <Button
android:id="@+id/name_speaker_button" android:id="@+id/summarize_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/generate_minutes" />
<Button
android:id="@+id/copy_minutes_button"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="12dp" android:layout_marginStart="12dp"
android:text="@string/name_speaker" /> android:text="@string/copy_minutes" />
<Button
android:id="@+id/clear_speakers_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/clear_speakers" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>

View File

@ -4,5 +4,4 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingBottom="8dp" android:paddingBottom="8dp"
android:textIsSelectable="true"
android:textSize="16sp" /> android:textSize="16sp" />

View File

@ -4,12 +4,13 @@
\n \n
\n\n\n \n\n\n
The source code and pre-trained models are publicly available. The source code and pre-trained models are publicly available.
Please see https://github.com/k2-fsa/sherpa-onnx for details. Please see https://github.com/k2fsa/sherpa-onnx for details.
\n\n \n\n
Two-pass speech recognition with Next-gen Kaldi. Two-pass speech recognition with Next-gen Kaldi.
</string> </string>
<string name="start">Start</string> <string name="start">Start</string>
<string name="stop">Stop</string> <string name="stop">Stop</string>
<string name="name_speaker">命名说话人</string> <string name="generate_minutes">生成纪要</string>
<string name="clear_speakers">清除说话人</string> <string name="cancel_minutes">取消生成</string>
<string name="copy_minutes">复制纪要</string>
</resources> </resources>

View File

@ -0,0 +1,45 @@
package com.k2fsa.sherpa.onnx
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
// ============================================================================
// 手动会中成稿的增量断点单测:.notes.json 读写校验 + P2 提示词两路同构。
// ============================================================================
class MeetingNotesTest {
@Test
fun persistRoundTripAndValidation() {
val dir = File.createTempFile("notes", "dir").apply { delete(); mkdirs() }
val f = MeetingNotesIO.pathFor(dir, "20260911-1300")
val notes = """{"已定事項":[],"討論中":[],"行動項目":[],"人物":[],"前回末尾":"x"}"""
MeetingNotesIO.save(f, MeetingNotes(notes, 42, 321_000L, 1, "20260911-1300"))
val back = MeetingNotesIO.load(f, "20260911-1300")!!
assertEquals(42, back.entriesP1Done)
assertEquals(321_000L, back.lastEndMs)
assertEquals(1, back.failedChunks)
assertEquals(notes, back.notes)
// sessionId 不匹配 → null(不会把别场会议的笔记接错)
assertNull(MeetingNotesIO.load(f, "other-session"))
// 笔记被写坏 → null(自动退回全量重跑)
f.writeText("""{"sessionId":"20260911-1300","notes":"garbage{"}""")
assertNull(MeetingNotesIO.load(f, "20260911-1300"))
// 文件不存在 → null
assertNull(MeetingNotesIO.load(File(dir, "nope.notes.json"), "nope"))
dir.deleteRecursively()
}
@Test
fun draftPromptSameShapeBothPaths() {
val p = PromptsLoader.BUILTIN
val u = SummarizePipeline.buildDraftUserPrompt(
p, "ja", 1_789_000_000_000L, 3, 3_720_000L, "张三→張三", "NOTESJSON")
assertTrue(u.contains("日本語"))
assertTrue(u.contains("62:00"))
assertTrue(u.contains("NOTESJSON"))
assertTrue(u.contains("张三→張三"))
}
}

View File

@ -0,0 +1,78 @@
package com.k2fsa.sherpa.onnx
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SubtitleFormatTest {
@Test
fun joinerRules() {
// 已有标点 → 空格衔接
assertEquals(" ", joinerAfter("今天定了预算。"))
assertEquals(" ", joinerAfter("deadline is Friday."))
assertEquals(" ", joinerAfter("終わりです!"))
// CJK/假名结尾 → 中文句号
assertEquals("。", joinerAfter("予算を承認"))
assertEquals("。", joinerAfter("会议延期"))
// 拉丁结尾 → 英文句点+空格
assertEquals(". ", joinerAfter("we agreed"))
assertEquals(". ", joinerAfter("Q3"))
// 空/符号结尾
assertEquals(" ", joinerAfter(""))
assertEquals(" ", joinerAfter(" "))
assertEquals(" ", joinerAfter("待定:"))
}
}
class SpeakerRegistryTest {
private fun e(vararg v: Float) = v.map { it.toFloat() }.toFloatArray()
@Test
fun autoThenRenameKeepsIdentity() {
val r = SpeakerRegistry()
val a = e(1f, 0f, 0f)
r.addAuto("S0", a)
assertEquals("S0", r.search(a, 0.5f))
assertTrue(r.renameToName("S0", "张三"))
// 改名后同一嵌入直接出真名,不再命中 S0
assertEquals("张三", r.search(a, 0.5f))
assertNull(r.search(e(0f, 1f, 0f), 0.5f)) // 无关向量不匹配
assertFalse(r.renameToName("S9", "李四")) // 不存在的键
}
@Test
fun sameNameCanHoldMultipleEmbeddings() {
// 两个被误判分开的人(正交嵌入),命名同名后都归一
val r = SpeakerRegistry()
val a = e(1f, 0f, 0f)
val b = e(0f, 1f, 0f)
r.addAuto("S0", a)
r.addAuto("S1", b)
assertTrue(r.renameToName("S0", "张三"))
assertTrue(r.renameToName("S1", "张三"))
assertEquals("张三", r.search(a, 0.5f))
assertEquals("张三", r.search(b, 0.5f))
// 部分匹配:与 a 接近的新嵌入仍判张三
assertEquals("张三", r.search(e(0.9f, 0f, 0.1f), 0.5f))
}
@Test
fun namedWinsOverAutoOnTie() {
val r = SpeakerRegistry()
val a = e(1f, 0f, 0f)
r.addAuto("S0", a)
r.addNamed("张三", a)
assertEquals("张三", r.search(a, 0.5f))
}
@Test
fun similarityBasics() {
val a = e(1f, 0f, 0f)
assertEquals(1f, SpeakerRecognition.similarity(a, a), 1e-4f)
assertEquals(0f, SpeakerRecognition.similarity(a, e(0f, 1f, 0f)), 1e-4f)
assertEquals(-1f, SpeakerRecognition.similarity(a, e(-1f, 0f, 0f)), 1e-4f)
assertEquals(0f, SpeakerRecognition.similarity(a, e(0f, 0f, 0f)), 1e-4f)
}
}

View File

@ -0,0 +1,176 @@
package com.k2fsa.sherpa.onnx
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
// ============================================================================
// 摘要流水线纯逻辑 JVM 单测(不依赖设备/native):
// ./gradlew :app:testDebugUnitTest
// 注意:unit test classpath 里放了真实 org.json(build.gradle testImplementation)。
// ============================================================================
class SummarizeLogicTest {
// ---- prompts.md ↔ 内置 BUILTIN 一致性(md 是唯一事实源,漂移即失败) ----
@Test
fun builtinMatchesPromptsMd() {
val md = File("src/main/assets/summarize/prompts.md")
assertTrue("prompts.md not found at ${md.absolutePath}", md.isFile)
val parsed = PromptsLoader.parse(md.readText())
val b = PromptsLoader.BUILTIN
assertEquals("P1 SYSTEM 与 md 不一致", b.p1System, parsed.p1System)
assertEquals("P1 USER 与 md 不一致", b.p1UserTpl, parsed.p1UserTpl)
assertEquals("P2 SYSTEM 与 md 不一致", b.p2System, parsed.p2System)
assertEquals("P2 USER 与 md 不一致", b.p2UserTpl, parsed.p2UserTpl)
}
// ---- 分块 ----
private fun line(spk: String, i: Int) =
"(00:%02d-00:%02d)[%s] %s".format(i % 60, i % 60, spk, "あ".repeat(80) + i)
@Test
fun chunkCoversEveryUtteranceOnceIgnoringOverlap() {
val lines = (0 until 60).map { line(if (it / 7 % 2 == 0) "S0" else "S1", it) }
val blocks = SummarizePipeline.chunk(lines)
// 每条原句(去掉重叠复制)合起来必须完整覆盖
val originals = blocks.flatMap { it.split('\n') }.filter { it.isNotBlank() }
val dedup = LinkedHashSet(originals)
assertEquals("有句子丢失", 60, dedup.size)
assertTrue("块数不合常理", blocks.size in 2..12)
}
@Test
fun chunkNeverSplitsSameSpeakerRun() {
// 每 7 句一组同人轮次;切点不得落在组中间
val lines = ArrayList<String>()
var spk = 0
var k = 0
repeat(70) {
if (it % 7 == 0) { spk++; }
lines.add(line("S$spk", k++))
}
val blocks = SummarizePipeline.chunk(lines)
fun spkOf(l: String): String =
l.substringAfterLast('[').substringBeforeLast(']')
// 相邻块之间:上一块最后一句与下一块第二句(第一句是重叠)必须不同人
for (i in 0 until blocks.size - 1) {
val lastOfPrev = blocks[i].split('\n').last()
val firstRealOfNext = blocks[i + 1].split('\n').drop(1).first()
assertTrue(
"轮次被拆:prev尾=${lastOfPrev.take(30)} next=${firstRealOfNext.take(30)}",
spkOf(lastOfPrev) != spkOf(firstRealOfNext)
)
}
}
@Test
fun chunkOverlapsOneSentence() {
val lines = (0 until 40).map { line("S${it % 3}", it) }
val blocks = SummarizePipeline.chunk(lines)
for (i in 0 until blocks.size - 1) {
val tail = blocks[i].split('\n').last()
val nextHead = blocks[i + 1].split('\n').first()
assertEquals("块 ${i + 1}->${i + 2} 缺少 1 句重叠", tail, nextHead)
}
}
// ---- 笔记 JSON 提取 ----
private val goodJson =
"""{"已定事項":[{"内容":"Xを承認","出典":"01:00-01:20"}],"討論中":[],"行動項目":[{"内容":"Y","担当者":"田中","期限":"9/30","出典":"02:00-02:10"}],"人物":[{"名前":"田中","立場":"PM"}],"前回末尾":"Yの進め方" }"""
@Test
fun extractJsonPlain() = assertEquals(goodJson, SummarizePipeline.extractJson(goodJson))
@Test
fun extractJsonWithFenceAndProse() {
val raw = "はい、更新します。\n```json\n$goodJson\n```\n以上です。"
assertEquals(goodJson, SummarizePipeline.extractJson(raw))
}
@Test
fun extractJsonHandlesBracesInsideStrings() {
val raw = """{"已定事項":[{"内容":"値は{要確認}","出典":""}],"討論中":[],"行動項目":[],"人物":[],"前回末尾":""}"""
assertNotNull(SummarizePipeline.extractJson(raw))
}
@Test
fun extractJsonRejectsGarbageAndMissingKeys() {
assertNull(SummarizePipeline.extractJson("ごめんなさい、分かりません"))
assertNull(SummarizePipeline.extractJson("""{"hello":"world"}"""))
assertNull(SummarizePipeline.extractJson("""{"已定事項":[],}""")) // 不平衡/畸形
}
// ---- 成稿清洗 ----
@Test
fun cleanDraftStripsFenceAndThinking() {
val raw = "```markdown\n<|channel>thought\n長い熟考…\n<channel|>\n# 議題\n\n\n\n本文\n```"
val out = SummarizePipeline.cleanDraft(raw)
assertTrue(out.startsWith("# 議題"))
assertTrue(!out.contains("thought"))
assertTrue(!out.contains("```"))
}
// ---- 语言判定 ----
@Test
fun detectLanguageBasics() {
assertEquals("ja", SummarizePipeline.detectLanguage("会議を始めます。本日はよろしくお願いいたします。"))
assertEquals("zh", SummarizePipeline.detectLanguage("今天开会讨论季度预算和市场推广计划"))
assertEquals("en", SummarizePipeline.detectLanguage("Let us review the quarterly budget plan together"))
}
// ---- 语种纯度校验(中→全中/英→全英,专名例外;阈值=句子级混语) ----
@Test
fun scriptViolationEnforcement() {
// 中文纪要 + 少量假名专名(田中さくら)→ 通过(专名例外)
assertNull(SummarizePipeline.scriptViolation(
"zh", "行动项:请 田中さくら 在9月30日前提交预算表,负责人张三。"))
// 中文纪要被日语句子污染 → 违规
assertNotNull(SummarizePipeline.scriptViolation(
"zh", "决定事项:来月の予算を承認しました。本件は保留となります。"))
// 英文纪要混入汉字 → 违规
assertNotNull(SummarizePipeline.scriptViolation(
"en", "Action: 佐藤が担当する。Deliver the draft by Friday and review together."))
// 英文纪要单个汉字公司名(专名级)→ 阈值内通过
assertNull(SummarizePipeline.scriptViolation(
"en", "Decision: approve partnership with 丰田汽车 by Q4."))
// 日文纪要不做字符检查(汉字假名天然混排)
assertNull(SummarizePipeline.scriptViolation("ja", "意思決定:予算を承認。We agree と結論"))
}
// ---- Gemma 4 模板渲染 ----
@Test
fun chatTemplateRendering() {
val p = LlmEngine.renderChatPrompt("SYS ", " USER")
assertEquals(
"<|turn>system\nSYS<turn|>\n<|turn>user\nUSER<turn|>\n<|turn>model\n", p
)
}
// ---- SessionTranscript JSONL 回放 ----
@Test
fun transcriptReplayFoldsEvents() {
val dir = File.createTempFile("mtg", "dir").apply { delete(); mkdirs() }
val f = File(dir, "test.jsonl")
f.writeText(
"""{"t":"H","wall":1000}
{"t":"S","sid":1,"b":0,"e":1000,"x":"第一稿"}
{"t":"S","sid":2,"b":1000,"e":2000,"x":"第二句"}
{"t":"R","sid":1,"x":"精修后第一稿","r":1}
{"t":"P","sid":1,"sp":"S0"}
{"t":"P","sid":2,"sp":"S1"}
{"t":"N","from":"S1","to":"张三"}
""".replace("\n", System.lineSeparator())
)
val out = SessionTranscript.load(f)
assertEquals(2, out.size)
assertEquals("精修后第一稿", out[0].text)
assertTrue(out[0].refined)
assertEquals("S0", out[0].speaker)
assertEquals("张三", out[1].speaker)
assertEquals(1000L, SessionTranscript.fileStartWallMs(f))
dir.deleteRecursively()
}
}

57
docs/M2_STRESS_TEST.md Normal file
View File

@ -0,0 +1,57 @@
# 「会中手动成稿」共存压测方案
> 需求变更后取代原"会中自动滚动"压测(2026-09-11):滚动机制已拆除,
> 现行为 = 点「生成纪要」→ 对截至当时已定稿的字幕成稿,录音转写全程不停;
> 增量笔记(.notes.json)使第二次及以后的点击只处理新增句子(分钟级)。
>
> 通过线:录音零丢帧、定稿不超时不劣化、成稿期间 UI 不卡死、多次点击增量正确。
## 1. 准备
1. 安装 `build-check/summarize-manual-debug.apk`。
2. 测试音频:任意真实会议录音(日语最佳)≥30 分钟,播放器开单曲循环凑 2~3 小时。
3. 手机充电、屏幕常亮(锁屏/Doze 会干扰后台线程)。
4. 如要卸载旧版重装:`adb pull /sdcard/Android/data/com.k2fsa.sherpa.onnx/files/models/ D:\sherpa-models-backup` 备份模型,装完 push 回去。
## 2. 日志采集(PowerShell 开三个窗口)
```powershell
# 窗1:App 主日志(SLOT 对账 / SUMMARIZE / ROLLING 续跑标记)
adb logcat -s sherpa-onnx SummarizeLlmJni | Tee-Object D:\stress-app.log
# 窗2:每 5 分钟采样 RSS 与电池温度
while ($true) {
adb shell dumpsys meminfo com.k2fsa.sherpa.onnx | Select-String "TOTAL RSS"
adb shell dumpsys battery | Select-String "temperature|level"
Start-Sleep 300
} | Tee-Object D:\stress-rss.log
# 窗3:录音丢帧(看 s: 恒 0、m: 不增长)
adb logcat | Select-String "audioRecordData" | Tee-Object D:\stress-audio.log
```
## 3. 跑测步骤
1. Start 录音,循环播放会议音频。
2. 约 1 小时后点「生成纪要」→(第 1 次)全量流水线 → 出稿。**记录耗时 T1**。生成期间字幕应继续实时更新。
3. 再过 1 小时点第二次 → 日志应出现 `SUMMARIZE resume from notes at N/M`,只消化新增段,**记录耗时 T2**(应远小于 T1)。
4. 会议结束 Stop → 再点第三次出终稿。
5. 取回产物:
```powershell
adb exec-out run-as com.k2fsa.sherpa.onnx tar -C files/meetings -c . > D:\stress-out.tar
```
应看到 `<会话>.jsonl`(全量转写)、`<会话>.notes.json`(断点笔记)、`<会话>.md`(每次覆盖为最新终稿)。
## 4. 判读标准(把三个 log + 三份 md 发我)
| 项 | 通过线 | 依据 |
|---|---|---|
| 录音零丢帧 | `s:0` 恒 0,`m:` 在成稿窗口内不增长 | stress-audio.log |
| 定稿不劣化 | 成稿的 1~2 分钟窗口里 `SLOT … RESULT=TIMEOUT` 频率不高于基线 | stress-app.log |
| 增量正确 | 第 2/3 次点击 `resume from notes` 命中,T2 << T1;终稿 .md 覆盖全程且无重复条目 | stress-app.log + .md |
| 资源 | 引擎驻留只在生成期间(~3.5GB RSS 峰值,完成即回落);温度 >45°C 时记录掉速 | stress-rss.log |
| 质量 | 三次纪要人工评审"无漏关键决定";出典时间戳可回查 .jsonl | .md |
## 5. 红线回归
`MainActivity` 顶部 `summarizeEnabled=false` → 无按钮、无会话文件、行为与加摘要功能前一致。

View File

@ -0,0 +1,22 @@
# Builds libsummarize-llm-jni.so = llama.cpp (static) + thin JNI layer.
# llama.cpp is expected at ./llama.cpp (pinned by build_arm64.sh), NOT committed here.
cmake_minimum_required(VERSION 3.22)
project(summarize_llm_jni CXX C)
set(LLAMA_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(LLAMA_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(LLAMA_BUILD_SERVER OFF CACHE BOOL "" FORCE)
set(LLAMA_BUILD_UI OFF CACHE BOOL "" FORCE)
set(LLAMA_BUILD_APP OFF CACHE BOOL "" FORCE)
set(LLAMA_BUILD_COMMON OFF CACHE BOOL "" FORCE)
set(GGML_OPENMP OFF CACHE BOOL "" FORCE)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
add_subdirectory(llama.cpp EXCLUDE_FROM_ALL)
add_library(summarize-llm-jni SHARED src/summarize_llm_jni.cpp)
target_link_libraries(summarize-llm-jni PRIVATE llama android log)
target_compile_options(summarize-llm-jni PRIVATE -O2 -fvisibility=hidden)
target_link_options(summarize-llm-jni PRIVATE
"-Wl,--build-id=sha1" "-Wl,--gc-sections" "-Wl,-z,max-page-size=16384"
"-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/exports.map")

View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Cross-compiles libsummarize-llm-jni.so (arm64-v8a) for the summarize feature.
#
# One-off build on a Linux box with network access (this project was built in
# a sandbox with NDK r27c; the Android Studio side does NOT need any NDK).
# Run from tools/llama-jni/. Requires: git, cmake >= 3.22, unzip, $ANDROID_NDK
# pointing at an extracted NDK (r26+), ANDROID_ABI override optional.
#
# llama.cpp commit pinned below = the one verified to support arch "gemma4"
# (src/llama-arch.cpp LLM_ARCH_GEMMA4). Bump only together with re-running
# PC-side prompt validation.
set -euo pipefail
LLAMA_COMMIT="df03399b885831b2a1603b3abb0d8c156808e363"
ABI="${ANDROID_ABI:-arm64-v8a}"
PLATFORM="${ANDROID_PLATFORM:-android-24}"
OUT_DIR="../../app/src/main/jniLibs/${ABI}"
if [ ! -d llama.cpp ]; then
git clone https://github.com/ggml-org/llama.cpp
fi
cd llama.cpp
git fetch --depth 1 origin "${LLAMA_COMMIT}" 2>/dev/null || true
git checkout "${LLAMA_COMMIT}" 2>/dev/null || git pull
cd ..
cmake -B "build-${ABI}" \
-DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK}/build/cmake/android.toolchain.cmake" \
-DANDROID_ABI="${ABI}" \
-DANDROID_PLATFORM="${PLATFORM}" \
-DANDROID_STL=c++_static \
-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON \
-DCMAKE_BUILD_TYPE=Release
cmake --build "build-${ABI}" -j "$(nproc)"
mkdir -p "${OUT_DIR}"
cp -v "build-${ABI}/libsummarize-llm-jni.so" "${OUT_DIR}/"
echo "done -> ${OUT_DIR}/libsummarize-llm-jni.so"

View File

@ -0,0 +1,6 @@
{
global:
Java_com_k2fsa_sherpa_onnx_LlmEngine_*;
local:
*;
};

View File

@ -0,0 +1,312 @@
// Summarize-LLM thin JNI layer over llama.cpp (milestone 1: post-meeting minutes).
//
// This is the ONLY native code added for the summarize feature. It is compiled
// together with a pinned llama.cpp checkout into a single prebuilt shared
// library (libsummarize-llm-jni.so) so that the app's Gradle build keeps using
// only prebuilt .so files (no externalNativeBuild, no NDK required on the
// developer machine). See tools/llama-jni/build_arm64.sh.
//
// Safety rules (mirrors the project's "missing asset => native exit()" lesson):
// - Every entry point validates inputs and throws a Java exception instead
// of crashing; model loading never calls exit().
// - The caller (Kotlin) MUST check that the GGUF file exists before
// nativeNewFromFile(); we double-check with access() here as well.
#include <jni.h>
#include <android/log.h>
#include <atomic>
#include <mutex>
#include <string>
#include <vector>
#include <algorithm>
#include <cmath>
#include <random>
#include <unistd.h>
#include "llama.h"
#define LOG_TAG "SummarizeLlmJni"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
namespace {
struct Engine {
llama_model * model = nullptr;
llama_context * ctx = nullptr;
const llama_vocab * vocab = nullptr;
std::mutex mu; // generate() is single-flight
std::atomic<bool> abort{false};
};
void throwJava(JNIEnv * env, const char * msg) {
jclass cls = env->FindClass("java/lang/RuntimeException");
if (cls != nullptr) {
env->ThrowNew(cls, msg);
}
}
std::string jstringToUtf8(JNIEnv * env, jstring s) {
if (s == nullptr) return {};
const char * c = env->GetStringUTFChars(s, nullptr);
std::string out = c != nullptr ? c : "";
if (c != nullptr) env->ReleaseStringUTFChars(s, c);
return out;
}
ggml_type parseKvType(const std::string & s, ggml_type fallback) {
if (s == "f16") return GGML_TYPE_F16;
if (s == "f32") return GGML_TYPE_F32;
if (s == "q8_0") return GGML_TYPE_Q8_0;
if (s == "q4_0") return GGML_TYPE_Q4_0;
if (s == "q4_1") return GGML_TYPE_Q4_1;
return fallback;
}
// Abort callback for llama_set_abort_callback.
bool abortCb(void * user) {
auto * e = static_cast<Engine *>(user);
return e->abort.load(std::memory_order_relaxed);
}
} // namespace
extern "C" {
JNIEXPORT jlong JNICALL
Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeNewFromFile(
JNIEnv * env, jclass /*clazz*/,
jstring jPath, jint ctxSize, jint nThreads,
jstring jTypeK, jstring jTypeV) {
const std::string path = jstringToUtf8(env, jPath);
if (path.empty()) {
throwJava(env, "nativeNewFromFile: empty model path");
return 0;
}
if (access(path.c_str(), R_OK) != 0) {
throwJava(env, ("nativeNewFromFile: model file not readable: " + path).c_str());
return 0;
}
llama_model_params mparams = llama_model_default_params();
mparams.n_gpu_layers = 0; // CPU only in milestone 1
mparams.load_mode = LLAMA_LOAD_MODE_MMAP; // page cache can be reclaimed under memory pressure
llama_model * model = llama_model_load_from_file(path.c_str(), mparams);
if (model == nullptr) {
throwJava(env, ("nativeNewFromFile: llama failed to load GGUF: " + path).c_str());
return 0;
}
llama_context_params cparams = llama_context_default_params();
cparams.n_ctx = ctxSize > 0 ? (uint32_t) ctxSize : 8192;
cparams.n_threads = nThreads > 0 ? nThreads : 4;
cparams.n_threads_batch = nThreads > 0 ? nThreads : 4;
cparams.type_k = parseKvType(jstringToUtf8(env, jTypeK), GGML_TYPE_Q8_0);
cparams.type_v = parseKvType(jstringToUtf8(env, jTypeV), GGML_TYPE_F16);
llama_context * ctx = llama_init_from_model(model, cparams);
if (ctx == nullptr) {
llama_model_free(model);
throwJava(env, "nativeNewFromFile: failed to create llama_context");
return 0;
}
auto * e = new Engine();
e->model = model;
e->ctx = ctx;
e->vocab = llama_model_get_vocab(model);
llama_set_abort_callback(ctx, abortCb, e);
LOGI("model loaded: %s n_ctx=%d type_k=%d type_v=%d", path.c_str(),
cparams.n_ctx, (int) cparams.type_k, (int) cparams.type_v);
return (jlong) (uintptr_t) e;
}
JNIEXPORT jstring JNICALL
Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGetStringMeta(
JNIEnv * env, jclass /*clazz*/, jlong ptr, jstring jKey) {
auto * e = (Engine *) (uintptr_t) ptr;
if (e == nullptr || e->model == nullptr) return nullptr;
const std::string key = jstringToUtf8(env, jKey);
char buf[8192];
int32_t n = llama_model_meta_val_str(e->model, key.c_str(), buf, sizeof(buf));
if (n < 0) return nullptr;
return env->NewStringUTF(buf);
}
// M2:录音中降线程(给 ASR 让核),空闲/成稿时拉满。与在途 generate 共用
// 一把 mu:generate 正在跑时本调用会排队到它结束,不会半路改线程数。
JNIEXPORT void JNICALL
Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeSetThreads(
JNIEnv * /*env*/, jclass /*clazz*/, jlong ptr, jint n) {
auto * e = (Engine *) (uintptr_t) ptr;
if (e == nullptr || e->ctx == nullptr || n <= 0) return;
std::lock_guard<std::mutex> lock(e->mu);
llama_set_n_threads(e->ctx, n, n);
}
// Blocking generation; call from a background thread. Returns generated text
// (without the prompt). Stops on EOS, maxNewTokens or nativeAbort().
JNIEXPORT jstring JNICALL
Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
JNIEnv * env, jclass /*clazz*/, jlong ptr,
jstring jPrompt, jint maxNewTokens, jfloat temperature, jint topK, jfloat topP) {
auto * e = (Engine *) (uintptr_t) ptr;
if (e == nullptr || e->ctx == nullptr) {
throwJava(env, "nativeGenerate: engine is released");
return nullptr;
}
std::lock_guard<std::mutex> lock(e->mu);
e->abort.store(false);
const std::string prompt = jstringToUtf8(env, jPrompt);
if (prompt.empty()) {
throwJava(env, "nativeGenerate: empty prompt");
return nullptr;
}
// --- tokenize the prompt ---
// parse_special=true: the Kotlin side renders the Gemma 4 template with
// literal <|turn>/<turn|> markers, which must become control tokens 105/106.
std::vector<llama_token> tokens(prompt.size() / 2 + 64);
int32_t n;
while ((n = llama_tokenize(e->vocab, prompt.data(), (int32_t) prompt.size(),
tokens.data(), (int32_t) tokens.size(),
/*add_special=*/true, /*parse_special=*/true)) < 0) {
tokens.resize((size_t) (-n) + 32);
if (tokens.size() > 4 * 1024 * 1024) {
throwJava(env, "nativeGenerate: prompt too large to tokenize");
return nullptr;
}
}
// --- fit into the context window (left-truncate keeps the newest content,
// which for our rolling-notes design is the part that must survive) ---
const uint32_t nCtx = llama_n_ctx(e->ctx);
const int32_t keep = (int32_t) (nCtx > 64 ? nCtx - 32 : nCtx);
if (n > keep) {
LOGE("prompt tokens %d exceed ctx %u, left-truncating", n, nCtx);
int32_t drop = n - keep;
// never drop into the middle of multi-byte UTF-8: only relevant on the
// decode side; tokens are atomic here, so a plain shift is safe.
tokens.erase(tokens.begin(), tokens.begin() + drop);
n = keep;
}
// --- prefill in chunks ---
const int32_t chunk = 512;
llama_pos pos = 0;
for (int32_t i = 0; i < n; i += chunk) {
if (e->abort.load()) return env->NewStringUTF("");
int32_t len = std::min(chunk, n - i);
llama_batch b = llama_batch_get_one(tokens.data() + i, len);
if (llama_decode(e->ctx, b) != 0) {
throwJava(env, "nativeGenerate: prefill decode failed");
return nullptr;
}
pos += len;
}
// --- decode loop ---
const llama_token eos = llama_vocab_eos(e->vocab);
std::mt19937 rng(42); // fixed seed => reproducible minutes across runs
std::string out;
out.reserve(1024);
std::vector<char> piece(64);
int32_t cur = tokens.back();
const int maxNew = maxNewTokens > 0 ? maxNewTokens : 512;
const bool greedy = !(temperature > 0.0f);
const int k = topK > 0 ? topK : 64; // Gemma 4 recommended: 64
const float p = topP > 0.0f ? topP : 0.95f; // recommended: ~0.95
for (int i = 0; i < maxNew; ++i) {
if (e->abort.load()) break;
llama_batch b = llama_batch_get_one(&cur, 1);
if (llama_decode(e->ctx, b) != 0) {
throwJava(env, "nativeGenerate: decode failed");
return nullptr;
}
float * logits = llama_get_logits_ith(e->ctx, 0);
const int vsize = llama_vocab_n_tokens(e->vocab);
// pick next token
llama_token next;
if (greedy) {
next = 0;
float best = logits[0];
for (int t = 1; t < vsize; ++t) {
if (logits[t] > best) { best = logits[t]; next = t; }
}
} else {
// top-k cut (partial sort), then temperature softmax + top-p cut, then sample
std::vector<std::pair<float, int32_t>> cand;
cand.reserve(k);
for (int t = 0; t < vsize; ++t) {
if (cand.size() < (size_t) k) {
cand.emplace_back(logits[t], t);
std::push_heap(cand.begin(), cand.end());
} else if (logits[t] > cand.front().first) {
std::pop_heap(cand.begin(), cand.end());
cand.back() = {logits[t], t};
std::push_heap(cand.begin(), cand.end());
}
}
float maxv = cand.front().first;
double sum = 0;
for (auto & c : cand) { c.first = std::exp((c.first - maxv) / temperature); sum += c.first; }
std::sort(cand.begin(), cand.end(), std::greater<>());
std::uniform_real_distribution<double> dist(0.0, sum);
double target = dist(rng), acc = 0;
next = cand[0].second;
for (auto & c : cand) {
acc += c.first;
next = c.second;
if (acc / sum >= p) break;
}
}
if (eos >= 0 && next == eos) break;
if (llama_vocab_is_eog(e->vocab, next)) break;
int32_t len = llama_token_to_piece(e->vocab, next, piece.data(),
(int32_t) piece.size(), 0, false);
if (len > 0) {
out.append(piece.data(), (size_t) len);
// Gemma 4 closes its turn with "<turn|>" (id 106). It may not be
// registered as eog in GGUF metadata, so stop on the string too
// and strip it from the output.
if (out.size() >= 7 && out.compare(out.size() - 7, 7, "<turn|>") == 0) {
out.erase(out.size() - 7);
break;
}
}
cur = next;
}
LOGI("generated %d chars", (int) out.size());
return env->NewStringUTF(out.c_str());
}
JNIEXPORT void JNICALL
Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeAbort(JNIEnv * /*env*/, jclass, jlong ptr) {
auto * e = (Engine *) (uintptr_t) ptr;
if (e != nullptr) e->abort.store(true);
}
JNIEXPORT void JNICALL
Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeFree(JNIEnv * /*env*/, jclass, jlong ptr) {
auto * e = (Engine *) (uintptr_t) ptr;
if (e == nullptr) return;
std::lock_guard<std::mutex> lock(e->mu); // wait for an in-flight generate
if (e->ctx != nullptr) llama_free(e->ctx);
if (e->model != nullptr) llama_model_free(e->model);
delete e;
LOGI("engine released");
}
} // extern "C"