From edbfbe6434b37cea545a7a52106b607d00cffc2c Mon Sep 17 00:00:00 2001 From: liwenhao2 Date: Wed, 16 Sep 2026 18:34:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=B4=E8=AF=9D=E4=BA=BA=E8=AF=86=E5=88=AB+?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=E6=A8=A1=E5=9E=8B=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/k2fsa/sherpa/onnx/LlmEngine.kt | 26 +- .../com/k2fsa/sherpa/onnx/MainActivity.kt | 377 +++++++----------- tools/llama-jni/build_arm64.sh | 8 + tools/llama-jni/src/summarize_llm_jni.cpp | 17 +- 4 files changed, 183 insertions(+), 245 deletions(-) diff --git a/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt b/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt index 3441dac..ec9cc62 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt @@ -67,9 +67,22 @@ class LlmEngine( // MiniCPM5 模板分支 2026-09-16 随引擎退役摘除(三连空模板回显, // 用户拍板弃用;历史实现见 git history) + // ---- Hunyuan-MT(Hy-MT2)canonical 模板,单轮 user ---- + // 与 GGUF 内嵌模板(add_generation_prompt 路径,无 system)逐字等价: + // <|hy_begin▁of▁sentence|><|hy_User|>{user}<|hy_Assistant|> + // 生成结束依赖原生 eos(= <|hy_place▁holder▁no▁2|>,id 120020), + // JNI 现成的 eos 判定即可,无需额外停止串。 + fun renderHunyuanPrompt(user: String): String = + buildString { + append("<|hy_begin▁of▁sentence|>") + append("<|hy_User|>").append(user.trim()) + append("<|hy_Assistant|>") + } + fun renderChatPrompt(system: String, user: String, template: String = "gemma4"): String = when (template) { + "hunyuan" -> renderHunyuanPrompt(user) else -> renderGemma4Prompt(system, user) } } @@ -91,8 +104,10 @@ class LlmEngine( /** * 阻塞式生成(仅供摘要后台线程调用)。返回模型输出文本(不含输入)。 * temperature=0 为 Greedy。abort() 可从其它线程安全打断。 - * repeatPenalty>1 启用重复惩罚(翻译路径传 1.1 治 1B 复读循环; - * 摘要路径保持默认 0=关闭,行为与已验证版本一致)。 + * repeatPenalty>1 启用重复惩罚(翻译路径传 1.1 治复读循环)。 + * seed>0 = 显式随机种子(翻译路径每次点击换盐+重试错位,让重试真再抽签); + * seed<=0 = 旧行为 42+温度错位(摘要路径用)。 + * minNew>0 = 前 minNew 步禁 EOS(防"空输出",翻译路径传 4;摘要传 0)。 */ fun generate( systemPrompt: String, @@ -102,11 +117,13 @@ class LlmEngine( topK: Int = 64, topP: Float = 0.95f, repeatPenalty: Float = 0f, + seed: Int = 0, + minNew: Int = 0, ): String { require(ptr != 0L) { "LlmEngine already released" } return nativeGenerate( ptr, renderChatPrompt(systemPrompt, userPrompt, chatTemplate), - maxNewTokens, temperature, topK, topP, repeatPenalty + maxNewTokens, temperature, topK, topP, repeatPenalty, seed, minNew ) } @@ -136,7 +153,8 @@ class LlmEngine( private external fun nativeGenerate( ptr: Long, prompt: String, maxNewTokens: Int, - temperature: Float, topK: Int, topP: Float, repeatPenalty: Float + temperature: Float, topK: Int, topP: Float, repeatPenalty: Float, + seed: Int, minNew: Int ): String private external fun nativeAbort(ptr: Long) diff --git a/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt b/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt index 383c4dd..e6f74fc 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt @@ -251,6 +251,7 @@ class MainActivity : AppCompatActivity() { // 干净,永不产幽灵);灰色地带留灰由判定链收编。serial 作废在途命名 @Volatile private var switchSerial = 0L @Volatile private var switchPrevActive: String? = null + @Volatile private var switchLabel: String? = null // 本切次开火即发的号 private var grayLinePending = false // 主线程:下一新行先以灰行开(等命名) private val forceSwitchStreaks = 3 // 强切冷却(仅 speakerCheckHandler 线程访问,无需 volatile) @@ -336,6 +337,12 @@ class MainActivity : AppCompatActivity() { private val summarizeModelDirName = "gemma4-e2b" private val summarizeGgufName = "gemma-4-E2B_q4_0-it.gguf" private val summarizeChatTemplate = "gemma4" + // 翻译引擎 = Hy-MT2-1.8B-1.25Bit(2026-09-16 二度换装:翻译迁出 E2B, + // 改用翻译专用模型——腾讯 Hy-MT2 的 1.25bit STQ1_0 量化,462MB; + // 纪要仍单扛 E2B。模型文件必须用腾讯官方仓那份(见交付说明)) + private val translateModelDirName = "hy-mt2" + private val translateGgufName = "Hy-MT2-1.8B-1.25Bit.gguf" + private val translateChatTemplate = "hunyuan" private lateinit var summarizeButton: Button private lateinit var copyMinutesButton: Button private lateinit var minutesStatus: TextView @@ -356,6 +363,9 @@ class MainActivity : AppCompatActivity() { @Volatile private var translateEngine: LlmEngine? = null // 一次只允许一个在途翻译(排队期间后续点击 toast"请稍候") @Volatile private var transBusy = false + // 点击翻译盐:每次点击递增,参与随机种子(重点/重试都是新抽签, + // 不再撞固定随机序列)。只在 translateHandler 单飞行内读写。 + private var transSalt = 0 // 已翻译句子的淡琥珀背景高亮(约13%透明度,深浅主题皆可读) private val transHighlightColor = Color.parseColor("#22FFA000") @@ -715,16 +725,18 @@ class MainActivity : AppCompatActivity() { // 稳健档:开口即查的"同人"判定线从 0.5 收到 0.35 // (0.5 时 0.35~0.5 的同人波动都会误切) if (simFast >= onsetFireGate) return@post - // 方案三(2026-09-16):开火不发号——只切行(灰行), - // 名字由切后纯净窗在 T+2.5s 诞生 + // 开火即发号(2026-09-16 用户拍板回归):显示层 + // 即时编号;注册素材仍等切后纯净窗校验 val serial = ++switchSerial switchPrevActive = activeLabelForWindow + val label = SpeakerRecognition.nextAutoLabel() + switchLabel = label Log.i(TAG, "ONSET-SWITCH fastSim=" + "%.2f".format(simFast) + - " serial=$serial active=" + + " serial=$serial label=$label active=" + (switchPrevActive ?: "-")) cutRequested = true - uiHandler.post { onOnsetSwitch(null) } + uiHandler.post { onOnsetSwitch(label) } } } } @@ -790,15 +802,19 @@ class MainActivity : AppCompatActivity() { return@post } lastForceSwitchAtMs = nowMs - // 方案三(2026-09-16):开火不发号——只切行(灰行), - // 名字由切后纯净窗在 T+2.5s 诞生 + // 开火即发号(2026-09-16 用户拍板回归):显示层 + // 即时编号;注册素材仍等切后纯净窗校验 val serial = ++switchSerial switchPrevActive = active + val label = known ?: SpeakerRecognition.nextAutoLabel() + switchLabel = label Log.i(TAG, "FASTROLL-SWITCH sim=" + "%.2f".format(sim) + - " serial=$serial active=${active ?: "-"}") + " serial=$serial label=$label kind=" + + (if (known != null) "known" else "fresh") + + " active=${active ?: "-"}") cutRequested = true - uiHandler.post { onOnsetSwitch(null) } + uiHandler.post { onOnsetSwitch(label) } } } } @@ -1822,61 +1838,63 @@ class MainActivity : AppCompatActivity() { uiHandler.post { adoptSwitchLabel(serial, name) } } name == null || score < verdictFallbackMin -> { - // 确认无人:发新号注册(纯净素材);fastReady=false(单 CAM++)时 - // 镜像无意义,跳过额外计算 - val fastEmb = if (SpeakerRecognition.fastReady()) { - SpeakerRecognition.computeEmbeddingFast(samples, sampleRateInHz) - } else null - val label = SpeakerRecognition.nextAutoLabel().also { - SpeakerRecognition.registry.addAuto(it, acc) - if (fastEmb != null) { - SpeakerRecognition.fastRegistry.addAuto(it, fastEmb) - } + // 确认无人:把开火即发的号以纯净素材注册(脏素材不进注册表) + val label = switchLabel + if (label != null) { + SpeakerRecognition.registry.addAuto(label, acc) + Log.i(TAG, "NAMING-CONFIRM serial=$serial label=$label") } - Log.i(TAG, "NAMING-NEW serial=$serial label=$label") - uiHandler.post { adoptSwitchLabel(serial, label) } } else -> Log.i(TAG, "NAMING-WEAK serial=$serial known=$name" + - " score=" + "%.2f".format(score) + " keep-gray") + " score=" + "%.2f".format(score) + " keep-provisional") } } - /** 命名落地(主线程):灰行就地冠名;灰行未建立 → 走预置标签给下一行 */ - private fun adoptSwitchLabel(serial: Long, label: String) { + /** 校验改签(主线程):切后纯净窗认出别人 → 发号就地改签为真身 */ + private fun adoptSwitchLabel(serial: Long, newLabel: String) { if (serial != switchSerial) return switchSerial++ // 关闭本切次命名窗口 + val firedLabel = switchLabel grayLinePending = false val bottom = subtitleLines.lastOrNull() - if (bottom != null && bottom.open && bottom.label == null) { - bottom.label = label - bottom.colorIdx = colorIdxFor(label) - notifyLine(bottom) - Log.i(TAG, "SWITCH-LABEL serial=$serial label=$label (line)") - } else { - pendingOnsetLabel = label - pendingOnsetLabelAt = SystemClock.elapsedRealtime() - Log.i(TAG, "SWITCH-LABEL serial=$serial label=$label (preset)") + when { + pendingOnsetLabel != null && pendingOnsetLabel == firedLabel -> { + pendingOnsetLabel = newLabel + pendingOnsetLabelAt = SystemClock.elapsedRealtime() + Log.i(TAG, "SWITCH-LABEL serial=$serial $firedLabel -> $newLabel (preset)") + } + bottom != null && bottom.open && bottom.label == firedLabel -> { + bottom.label = newLabel + bottom.colorIdx = colorIdxFor(newLabel) + notifyLine(bottom) + Log.i(TAG, "SWITCH-LABEL serial=$serial $firedLabel -> $newLabel (line)") + } + else -> Log.i(TAG, "SWITCH-LABEL serial=$serial skipped (state moved)") } - lastConfirmedLabel = label + lastConfirmedLabel = newLabel } - /** 误切撤销(方案三版,主线程):灰行未建立→重开旧行抹迹;已建立→改签并回 */ + /** 误切撤销(主线程):切后纯净窗认回开火前主 → 撤号抹迹。开火即发的 + * 号此时尚未入注册表(注册在 CONFIRM),无需清理注册表 */ private fun undoSwitch(serial: Long) { if (serial != switchSerial) return switchSerial++ val prevActive = switchPrevActive ?: return + val firedLabel = switchLabel grayLinePending = false - pendingOnsetLabel = null + if (pendingOnsetLabel != null && pendingOnsetLabel == firedLabel) { + pendingOnsetLabel = null // 占位行未建:预置作废 + } onsetClosePending = false lastConfirmedLabel = prevActive val bottom = subtitleLines.lastOrNull() ?: return when { - bottom.open && bottom.label == null -> { + bottom.open && firedLabel != null && bottom.label == firedLabel -> { bottom.label = prevActive bottom.colorIdx = colorIdxFor(prevActive) val merged = mergeLineIntoPrevIfSame(bottom, prevActive) if (!merged) notifyLine(bottom) - Log.i(TAG, "SWITCH-UNDO(line) serial=$serial -> $prevActive") + Log.i(TAG, "SWITCH-UNDO(line) serial=$serial $firedLabel -> $prevActive") } !bottom.open -> { bottom.open = true @@ -2367,11 +2385,11 @@ class MainActivity : AppCompatActivity() { Toast.LENGTH_SHORT).show() return } - val gguf = File(File(externalModelDir, summarizeModelDirName), - summarizeGgufName) + val gguf = File(File(externalModelDir, translateModelDirName), + translateGgufName) if (!gguf.isFile) { Toast.makeText(this, getString(R.string.trans_no_model, - "adb push $summarizeGgufName ${gguf.parent}/"), + "adb push $translateGgufName ${gguf.parent}/"), Toast.LENGTH_LONG).show() return } @@ -2510,28 +2528,30 @@ class MainActivity : AppCompatActivity() { return s.ifBlank { null } } - // 点击翻译总入口:单引擎 E2B(2026-09-16 用户拍板,两级架构下线—— - // 1B 主链失败率过高,"先小后大"平均耗时反而不如直接上 E2B;日语/中文/ - // 文学体全面更强)。温度递进 0→0.3→0.7;各闸门拒收即重试;被拒样本 - // 逐字相同立即止损(换温度无用)。 + // 点击翻译总入口:Hy-MT2-1.8B-1.25Bit 专模(2026-09-16 二度换装; + // 此前为 E2B 兼任,再前为 1B 主链+升档两级——历史见 git history)。 + // 温度递进 0→0.3→0.7;各闸门拒收即重试;被拒样本逐字相同立即止损。 private fun translateOne(sid: Long, src: String): String? { // 时间预算:录音中 45s、空闲 95s。超预算宁可失败(toast 可重试), // 也不让重试链长时间抢 ASR 的核(饥饿会引发第一遍字幕复读)。 val deadline = SystemClock.elapsedRealtime() + if (isRecording) 45_000L else 95_000L - // 重试预算:E2B 单次尝试约 10~15s;录音中 1 次(两次≈30s 封顶), - // 空闲 2 次 - val maxGenRetry = if (isRecording) 1 else 2 - var temp = 0f + // 重试预算:Hy-MT2 单次尝试约 4~15s;录音中 2 次、空闲 3 次 + val maxGenRetry = if (isRecording) 2 else 3 var genRetry = 0 var lastRej = "" + val salt = ++transSalt // 每次点击换盐:同句重点也会重新抽签 while (true) { if (SystemClock.elapsedRealtime() > deadline) { Log.i(TAG, "TAP-MISS sid=$sid reason=budget src=" + src.take(20)) return null } + // 三轴逃逸:变体(提示词问法)× 温度 × 种子(盐 + 尝试序号错位) + val attempt = genRetry.coerceAtMost(3) + val temp = when (attempt) { 0 -> 0f; 1 -> 0.3f; 2 -> 0.7f; else -> 0.9f } + val seed = 42 + attempt * 131 + salt * 7919 val map = translateNumbered( - listOf(Pair(sid.toString(), src)), temp) + listOf(Pair(sid.toString(), src)), temp, attempt, seed) val raw = map?.get(sid.toString())?.trim() val srcTag = " src=" + SummarizePipeline.detectLanguage(src) + ":" + src.take(20).replace("\n", " ") @@ -2556,7 +2576,7 @@ class MainActivity : AppCompatActivity() { (if (raw != null) "repeat" else "genfail") + " temp=$temp" + srcTag) if (genRetry >= maxGenRetry) return null - genRetry++; temp = if (temp == 0f) 0.3f else 0.7f + genRetry++ } isEcho(src, tr, echoJaccardTh(src)) -> { val es = tr.replace("\n", " ") @@ -2564,7 +2584,7 @@ class MainActivity : AppCompatActivity() { if (es == lastRej) return null // 复读成惯性,换温度无用 lastRej = es if (genRetry >= maxGenRetry) return null - genRetry++; temp = if (temp == 0f) 0.3f else 0.7f + genRetry++ } !targetLangOk(tr) -> { val sample = tr.replace("\n", " ") @@ -2576,70 +2596,31 @@ class MainActivity : AppCompatActivity() { if (sample == lastRej) return null lastRej = sample if (genRetry >= maxGenRetry) return null - genRetry++; temp = if (temp == 0f) 0.3f else 0.7f + genRetry++ } else -> return tr } } } - // 翻译目标语名/提示语(单引擎共用) - private fun transTargetName(): String = when (uiLanguage()) { - "ja" -> "日本語"; "en" -> "English"; else -> "简体中文" - } - private fun transLangHint(): String = when (uiLanguage()) { - "ja" -> "译文必须是日语(须含假名,不要输出中文或繁体)" - "zh" -> "译文必须是简体中文(不得用繁体字,不要输出日语或英语)" - else -> "The translation must be English only (no CJK characters)" - } - - // 容错提取 "编号":"译文" 对:顺序扫描,不要求 JSON 完整闭合—— - // 生成被 maxNewTokens 截断时,已完成的条目仍能救出 - private fun extractNumberedPairs(out: String?): Map? { - if (out.isNullOrBlank()) return null - val m = HashMap() - var i = 0 - while (i < out.length) { - if (out[i] != '"') { i++; continue } - val keyEnd = out.indexOf(':', i + 1) - if (keyEnd < 0) break - val key = out.substring(i + 1, keyEnd).trim() - if (key.isEmpty() || key.any { c -> !c.isDigit() }) { i++; continue } - var j = keyEnd + 1 - while (j < out.length && out[j] != '"') j++ - if (j >= out.length) break - val sb = StringBuilder() - j++ - while (j < out.length) { - val c = out[j] - if (c == '\\' && j + 1 < out.length) { - val n = out[j + 1] - sb.append(if (n == 'n') '\n' else if (n == 'r') '\r' else n) - j += 2 - continue - } - if (c == '"') break - sb.append(c); j++ - } - m[key] = sb.toString() - i = j + 1 - } - return if (m.isEmpty()) null else m + // Hy-MT2 官方提示词用中文语言名("将以下文本翻译成日语/英语/中文") + private fun transTargetNameCn(): String = when (uiLanguage()) { + "ja" -> "日语"; "en" -> "英语"; else -> "中文" } private fun ensureTranslateEngine(): LlmEngine? { translateEngine?.let { return it } - val f = File(File(externalModelDir, summarizeModelDirName), summarizeGgufName) + val f = File(File(externalModelDir, translateModelDirName), translateGgufName) if (!f.isFile) { Log.i(TAG, "translate model missing at ${f.absolutePath}; tap-translate off") return null } return try { - // 单引擎 E2B(2026-09-16):录音中 4 线程给 ASR 让核(纪要真机 - // 实测零丢帧),空闲 6;ctx 与纪要同级 8192 + // Hy-MT2-1.8B-1.25Bit(2026-09-16):翻译专模;录音中 4 线程给 + // ASR 让核,空闲 6;ctx 与纪要同级 8192;模板 hunyuan LlmEngine(f.absolutePath, SUMMARY_CTX, if (isRecording) SUMMARY_THREADS_BUSY else SUMMARY_THREADS, - chatTemplate = summarizeChatTemplate) + chatTemplate = translateChatTemplate) .also { translateEngine = it } } catch (t: Throwable) { Log.w(TAG, "translate engine init failed: ${t.message}") @@ -2648,154 +2629,76 @@ class MainActivity : AppCompatActivity() { } // items: key(通常是 sid 字符串) × 原文;返回 key × 译文,失败 null。 - // 直译模式(2026-09-16):不携带同轮上下文;引擎复用(JNI 每次生成前清 KV) + // Hy-MT2 直译模式(2026-09-16 二度换装):逐段独立生成,官方提示词 + // 格式 + 官方采样档(topK20/topP0.8);翻译专模不做编号批处理,长句 + // 拆段后逐段翻译再按序拼回。引擎复用(JNI 每次生成前清 KV)。 private fun translateNumbered( - items: List>, temp: Float = 0f, + items: List>, temp: Float = 0f, variant: Int = 0, + seed: Int = 0, ): Map? { if (items.isEmpty()) return emptyMap() - val targetName = transTargetName() - val langHint = transLangHint() - // 引擎复用(2026-09-16,JNI 已加 KV 清除):不再按批重建—— - // "每批重建"曾让每次尝试都重载 E2B ~4-6s,是翻译单句 30~40s 的主因 + val target = transTargetNameCn() val engine = translateEngine ?: ensureTranslateEngine() ?: return null - // 长句拆段:小模型对短输入才稳定;长句译到中途会"掉语言" - // (简体→繁体→别的语言)。拆 ≤140 字段一次 generate,段序拼回。 - val segKeys = ArrayList>() - val segCount = HashMap() - for ((key, t) in items) { - val segs = splitSegments(t) - if (segs.size > 1) segCount[key] = segs.size - segs.forEachIndexed { i, sg -> - val sk = if (segs.size == 1) key else key + "#s" + i - segKeys.add(sk to sg) - } - } - val budget = (segKeys.sumOf { it.second.length } * 2 + 96) - .coerceAtMost(1200) val got = HashMap() - if (segKeys.size == 1) { - val sys1 = "你是专业翻译。把给出的句子翻译成" + targetName + - "。只输出译文本身,不要解释、不要引号、不要抄写原文," + - "不要以「翻訳:」等任何标签开头," + - "禁止道歉或说明无法完成,翻不了就输出空字符串。" + - "你只在中文、日本語、English 三种语言之间翻译;" + - "输入不属于这三种语言时输出空字符串。" + langHint + "。" - val usr1 = segKeys[0].second - // 白卷事故诊断(2026-09-16):空 raw= 的现场缺"模型看到了什么" - // 这一环;转储两段长度与首尾,排除提示词层脏数据 - Log.i(TAG, "TRANSLATE prompt sys.len=" + sys1.length + - " usr.len=" + usr1.length + - " usr.head=" + usr1.take(40).replace("\n", " ") + - " usr.tail=" + usr1.takeLast(48).replace("\n", " ")) - val one = try { - engine.generate( - systemPrompt = sys1, - userPrompt = usr1, - maxNewTokens = budget, temperature = temp, - repeatPenalty = 1.1f, - ) - } catch (t: Throwable) { - Log.w(TAG, "TRANSLATE single failed: " + t.message); null - } - val tr = cleanTrans(one, segKeys[0].second) - if (!tr.isNullOrEmpty()) got[segKeys[0].first] = tr - if (got.isEmpty()) { - // 空手而归的诊断(2026-09-16):区分「模型白卷」与「输出被清洗成空 - // (只有标签/残片)」——genfail 现场的两种死法原来看不出 - Log.w(TAG, "TRANSLATE single empty raw=" + - (one?.take(48)?.replace("\n", " ") ?: "")) - return null - } - } else { - val numbered = segKeys.mapIndexed { i, st -> - (i + 1).toString() + ". " + st.second - }.joinToString("\n") - val out = try { - engine.generate( - systemPrompt = "你是专业翻译。逐段翻译下面编号文本为" + targetName + - "。各段相互独立,逐段对应,不得合并、不得重复、不得解释。" + - "只输出JSON对象,键是编号字符串,值是译文," + - "例如 {\"1\":\"…\",\"2\":\"…\"}。不得增删编号。" + - "严禁把原文照抄为译文;无法翻译的段值输出空字符串。" + - "你只在中文、日本語、English 三种语言之间翻译;" + - "段不属于这三种语言时其值输出空字符串。" + - langHint + "。", - userPrompt = "【文本】\n" + numbered, - maxNewTokens = budget, temperature = temp, - repeatPenalty = 1.1f, - ) - } catch (t: Throwable) { - Log.w(TAG, "TRANSLATE batch failed: " + t.message); null - } - val byIdx = extractNumberedPairs(out) - segKeys.forEachIndexed { i, st -> - byIdx?.get((i + 1).toString())?.let { cleanTrans(it, st.second) } - ?.takeIf { it.isNotBlank() } - ?.let { got[st.first] = it } - } - // 模型对编号批会"犯懒跳号"(只写最后一段等)→ 缺失段逐一直出 - // 兜底;每段重建引擎(nativeGenerate 不清 KV,复用必复读) - val missSegs = segKeys.filter { !got.containsKey(it.first) } - if (missSegs.isNotEmpty()) { - Log.i(TAG, "BATCH incomplete " + got.size + "/" + - segKeys.size + " -> per-seg fallback") - for (st in missSegs) { - translateEngine?.let { runCatching { it.release() } } - translateEngine = null - val e2 = ensureTranslateEngine() ?: break - val one = try { - e2.generate( - systemPrompt = "你是专业翻译。把给出的文本翻译成" + - targetName + "。只输出译文本身,不要解释," + - "不要以「翻訳:」等任何标签开头," + - "禁止道歉或说明无法完成,翻不了就输出空字符串。" + - "你只在中文、日本語、English 三种语言之间翻译;" + - "输入不属于这三种语言时输出空字符串。" + - langHint + "。", - userPrompt = st.second, - maxNewTokens = (st.second.length * 2 + 96) - .coerceAtMost(1200), - temperature = 0f, - ) - } catch (t: Throwable) { null } - cleanTrans(one, st.second)?.let { got[st.first] = it } + for ((key, text) in items) { + val segs = splitSegments(text) + val vals = ArrayList(segs.size) + var complete = true + for ((i, sg) in segs.withIndex()) { + // 控制字符清洗(ASR 异常字节曾有旧案):保留换行,剔除其余 <0x20 + val sgClean = sg.filter { c -> c == '\n' || c.code >= 0x20 } + // 提示词变体(空输出逃逸用):v0 官方;v1 极简;v2 只输出译文; + // v3 语音识别草稿容错(乱码/复读输入专用) + val user = when (variant) { + 1 -> "将以下文本翻译成" + target + ":\n\n" + sgClean + 2 -> "请把下面这段文本翻译成" + target + ",只输出译文:\n\n" + sgClean + 3 -> "以下是一段语音识别草稿(可能有错字和重复),请按大意翻译成" + + target + ",只输出译文:\n\n" + sgClean + else -> "将以下文本翻译成" + target + + ",注意只需要输出翻译后的结果,不要额外解释:\n\n" + sgClean } + if (i == 0) { + // 白卷/漏句现场诊断:转储变体、目标、长度与首尾 + Log.i(TAG, "TRANSLATE prompt v=$variant target=$target usr.len=" + + user.length + + " head=" + sgClean.take(40).replace("\n", " ") + + " tail=" + sgClean.takeLast(48).replace("\n", " ")) + } + val one = try { + engine.generate( + systemPrompt = "", // Hy-MT 无系统提示词(官方) + userPrompt = user, + maxNewTokens = (sgClean.length * 2 + 96).coerceAtMost(1200), + temperature = temp, + topK = 20, topP = 0.8f, // 官方 1.8B 推荐采样档 + repeatPenalty = 1.1f, + seed = seed, + minNew = 4, // 防空白:前 4 token 禁 EOS + ) + } catch (t: Throwable) { + Log.w(TAG, "TRANSLATE seg failed: " + t.message); null + } + val tr = cleanTrans(one, sgClean) + if (tr.isNullOrEmpty()) { + Log.w(TAG, "TRANSLATE seg empty v=$variant temp=$temp seed=$seed " + + "raw.len=" + (one?.length ?: -1) + " raw=" + + (one?.take(24)?.replace("\n", "\\n") ?: "")) + complete = false; break + } + vals.add(tr) } - if (got.isEmpty()) { - Log.w(TAG, "TRANSLATE batch empty out=" + - (out?.take(48)?.replace("\n", " ") ?: "")) - return null + if (!complete) continue + // 跨段复读保护:拆段后各段译文本应互不相同;≥2 段同文(且非短句) + // = 模型把一段复制成多段 → 整条判退化,交给重试机制 + if (vals.size >= 2 && vals.distinct().size < vals.size && + vals.first().length >= 16) { + Log.i(TAG, "TRANSLATE key=$key cross-seg repeat " + + vals.size + "→" + vals.distinct().size) + continue } + got[key] = vals.joinToString("") } - val res = HashMap() - for ((key, _) in items) { - val n = segCount[key] - if (n == null) got[key]?.let { res[key] = it } - else { - // 跨段复读:拆段后各段译文本应互不相同;≥2 段同文(且非短句) - // = 模型把一段复制成多段 → 该 key 整条判退化(不装配),让 - // translateOne 的重试机制重建 - val segVals = (0 until n).mapNotNull { got[key + "#s" + it] } - if (segVals.size == n && n >= 2 && - segVals.distinct().size < n && - segVals.first().length >= 16 - ) { - Log.i(TAG, "TRANSLATE key=$key cross-seg repeat " + - n + "→" + segVals.distinct().size) - continue - } - val sb = StringBuilder() - var full = true - for (i in 0 until n) { - val g = got[key + "#s" + i] - if (g == null) { full = false; break } - sb.append(g) - } - if (full) res[key] = sb.toString() - else Log.i(TAG, "MISS key=" + key + " reason=seg-hole") - } - } - return if (res.isEmpty()) null else res + return if (got.isEmpty()) null else got } // 复读检测:译文与原文归一化后全等/长串包含/字符二元组 Jaccard≥0.72 diff --git a/tools/llama-jni/build_arm64.sh b/tools/llama-jni/build_arm64.sh index 7a055a5..2fd9584 100644 --- a/tools/llama-jni/build_arm64.sh +++ b/tools/llama-jni/build_arm64.sh @@ -22,6 +22,8 @@ fi cd llama.cpp git fetch --depth 1 origin "${LLAMA_COMMIT}" 2>/dev/null || true git checkout "${LLAMA_COMMIT}" 2>/dev/null || git pull +# 应用本地修改:STQ1_0 1.25bit 内核移植(PR #22836)+ 私有编号重排(见 README) +git apply "../llama-df03399b-stq1_0.patch" cd .. cmake -B "build-${ABI}" \ @@ -30,7 +32,13 @@ cmake -B "build-${ABI}" \ -DANDROID_PLATFORM="${PLATFORM}" \ -DANDROID_STL=c++_static \ -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON \ + -DGGML_CPU_ARM_ARCH=armv8.2-a+dotprod \ -DCMAKE_BUILD_TYPE=Release +# 注:GGML_CPU_ARM_ARCH 给 ggml-cpu 编入 dotprod 真指令(dotprod 版 sdot 内核 +# 是 STQ1_0 被独立验证过的路径;基线 armv8-a 编出的模拟路径在真机上有数值 +# 偏差案)。运行时要求 CPU 支持 dotprod(高通 845 世代以后/所有目标机型)。 +# 该 commit 的 GGML_CPU_ALL_VARIANTS 已强制 GGML_BACKEND_DL(多 .so),与 +# 单 .so 打包不符,故用单变体开关。x86_64 构建忽略此变量。 cmake --build "build-${ABI}" -j "$(nproc)" mkdir -p "${OUT_DIR}" diff --git a/tools/llama-jni/src/summarize_llm_jni.cpp b/tools/llama-jni/src/summarize_llm_jni.cpp index 2742c33..aadac7f 100644 --- a/tools/llama-jni/src/summarize_llm_jni.cpp +++ b/tools/llama-jni/src/summarize_llm_jni.cpp @@ -155,7 +155,7 @@ 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, - jfloat repeatPenalty) { + jfloat repeatPenalty, jint seed, jint minNew) { auto * e = (Engine *) (uintptr_t) ptr; if (e == nullptr || e->ctx == nullptr) { @@ -221,9 +221,10 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate( // --- decode loop --- const llama_token eos = llama_vocab_eos(e->vocab); - std::mt19937 rng(42 + (uint32_t) (temperature * 1000.0f)); - // seed 随温度错位:温度递进的重试不再抽同一条随机序列(此前固定 42, - // 0.3 重试与 0.0 贪心几乎同果,"跳出死路"名存实亡) + // seed:调用方显式给随机种子(翻译路径每次点击换盐、每次重试错位,让重试 + // 是真正的再抽签);seed<=0 退回旧行为(42+温度错位,摘要路径用) + std::mt19937 rng(seed > 0 ? (uint32_t) seed + : (42 + (uint32_t) (temperature * 1000.0f))); std::string out; out.reserve(1024); std::vector piece(64); @@ -249,6 +250,14 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate( float * logits = llama_get_logits_ith(e->ctx, 0); const int vsize = llama_vocab_n_tokens(e->vocab); + // 防空白输出(2026-09-16 真机实锤:极端量化下个别输入首 token 直落 + // EOS,表现为 raw.len=0 的"空输出"):前 minNew 步把 EOS 置 -inf, + // 强制模型至少吐几个 token;有害输出交给上层质检闸门。 + // minNew<=0 = 关闭(摘要路径用)。 + if (minNew > 0 && i < minNew && eos >= 0 && eos < vsize) { + logits[eos] = -INFINITY; + } + if (usePen && !recent.empty()) { if ((int) recent.size() > 64) recent.erase(recent.begin(), recent.begin() + (recent.size() - 64));