说话人识别+翻译模型优化

This commit is contained in:
liwenhao2 2026-09-16 18:34:26 +08:00
parent 064fab39e0
commit edbfbe6434
4 changed files with 183 additions and 245 deletions

View File

@ -67,9 +67,22 @@ class LlmEngine(
// MiniCPM5 模板分支 2026-09-16 随引擎退役摘除(三连空模板回显, // MiniCPM5 模板分支 2026-09-16 随引擎退役摘除(三连空模板回显,
// 用户拍板弃用;历史实现见 git history) // 用户拍板弃用;历史实现见 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, fun renderChatPrompt(system: String, user: String,
template: String = "gemma4"): String = template: String = "gemma4"): String =
when (template) { when (template) {
"hunyuan" -> renderHunyuanPrompt(user)
else -> renderGemma4Prompt(system, user) else -> renderGemma4Prompt(system, user)
} }
} }
@ -91,8 +104,10 @@ class LlmEngine(
/** /**
* 阻塞式生成(仅供摘要后台线程调用)。返回模型输出文本(不含输入)。 * 阻塞式生成(仅供摘要后台线程调用)。返回模型输出文本(不含输入)。
* temperature=0 为 Greedy。abort() 可从其它线程安全打断。 * temperature=0 为 Greedy。abort() 可从其它线程安全打断。
* repeatPenalty>1 启用重复惩罚(翻译路径传 1.1 治 1B 复读循环; * repeatPenalty>1 启用重复惩罚(翻译路径传 1.1 治复读循环)。
* 摘要路径保持默认 0=关闭,行为与已验证版本一致)。 * seed>0 = 显式随机种子(翻译路径每次点击换盐+重试错位,让重试真再抽签);
* seed<=0 = 旧行为 42+温度错位(摘要路径用)。
* minNew>0 = 前 minNew 步禁 EOS(防"空输出",翻译路径传 4;摘要传 0)。
*/ */
fun generate( fun generate(
systemPrompt: String, systemPrompt: String,
@ -102,11 +117,13 @@ class LlmEngine(
topK: Int = 64, topK: Int = 64,
topP: Float = 0.95f, topP: Float = 0.95f,
repeatPenalty: Float = 0f, repeatPenalty: Float = 0f,
seed: Int = 0,
minNew: Int = 0,
): String { ): String {
require(ptr != 0L) { "LlmEngine already released" } require(ptr != 0L) { "LlmEngine already released" }
return nativeGenerate( return nativeGenerate(
ptr, renderChatPrompt(systemPrompt, userPrompt, chatTemplate), 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( private external fun nativeGenerate(
ptr: Long, prompt: String, maxNewTokens: Int, 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 ): String
private external fun nativeAbort(ptr: Long) private external fun nativeAbort(ptr: Long)

View File

@ -251,6 +251,7 @@ class MainActivity : AppCompatActivity() {
// 干净,永不产幽灵);灰色地带留灰由判定链收编。serial 作废在途命名 // 干净,永不产幽灵);灰色地带留灰由判定链收编。serial 作废在途命名
@Volatile private var switchSerial = 0L @Volatile private var switchSerial = 0L
@Volatile private var switchPrevActive: String? = null @Volatile private var switchPrevActive: String? = null
@Volatile private var switchLabel: String? = null // 本切次开火即发的号
private var grayLinePending = false // 主线程:下一新行先以灰行开(等命名) private var grayLinePending = false // 主线程:下一新行先以灰行开(等命名)
private val forceSwitchStreaks = 3 private val forceSwitchStreaks = 3
// 强切冷却(仅 speakerCheckHandler 线程访问,无需 volatile) // 强切冷却(仅 speakerCheckHandler 线程访问,无需 volatile)
@ -336,6 +337,12 @@ class MainActivity : AppCompatActivity() {
private val summarizeModelDirName = "gemma4-e2b" private val summarizeModelDirName = "gemma4-e2b"
private val summarizeGgufName = "gemma-4-E2B_q4_0-it.gguf" private val summarizeGgufName = "gemma-4-E2B_q4_0-it.gguf"
private val summarizeChatTemplate = "gemma4" 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 summarizeButton: Button
private lateinit var copyMinutesButton: Button private lateinit var copyMinutesButton: Button
private lateinit var minutesStatus: TextView private lateinit var minutesStatus: TextView
@ -356,6 +363,9 @@ class MainActivity : AppCompatActivity() {
@Volatile private var translateEngine: LlmEngine? = null @Volatile private var translateEngine: LlmEngine? = null
// 一次只允许一个在途翻译(排队期间后续点击 toast"请稍候") // 一次只允许一个在途翻译(排队期间后续点击 toast"请稍候")
@Volatile private var transBusy = false @Volatile private var transBusy = false
// 点击翻译盐:每次点击递增,参与随机种子(重点/重试都是新抽签,
// 不再撞固定随机序列)。只在 translateHandler 单飞行内读写。
private var transSalt = 0
// 已翻译句子的淡琥珀背景高亮(约13%透明度,深浅主题皆可读) // 已翻译句子的淡琥珀背景高亮(约13%透明度,深浅主题皆可读)
private val transHighlightColor = Color.parseColor("#22FFA000") private val transHighlightColor = Color.parseColor("#22FFA000")
@ -715,16 +725,18 @@ class MainActivity : AppCompatActivity() {
// 稳健档:开口即查的"同人"判定线从 0.5 收到 0.35 // 稳健档:开口即查的"同人"判定线从 0.5 收到 0.35
// (0.5 时 0.35~0.5 的同人波动都会误切) // (0.5 时 0.35~0.5 的同人波动都会误切)
if (simFast >= onsetFireGate) return@post if (simFast >= onsetFireGate) return@post
// 方案三(2026-09-16):开火不发号——只切行(灰行), // 开火即发号(2026-09-16 用户拍板回归):显示层
// 名字由切后纯净窗在 T+2.5s 诞生 // 即时编号;注册素材仍等切后纯净窗校验
val serial = ++switchSerial val serial = ++switchSerial
switchPrevActive = activeLabelForWindow switchPrevActive = activeLabelForWindow
val label = SpeakerRecognition.nextAutoLabel()
switchLabel = label
Log.i(TAG, "ONSET-SWITCH fastSim=" + Log.i(TAG, "ONSET-SWITCH fastSim=" +
"%.2f".format(simFast) + "%.2f".format(simFast) +
" serial=$serial active=" + " serial=$serial label=$label active=" +
(switchPrevActive ?: "-")) (switchPrevActive ?: "-"))
cutRequested = true cutRequested = true
uiHandler.post { onOnsetSwitch(null) } uiHandler.post { onOnsetSwitch(label) }
} }
} }
} }
@ -790,15 +802,19 @@ class MainActivity : AppCompatActivity() {
return@post return@post
} }
lastForceSwitchAtMs = nowMs lastForceSwitchAtMs = nowMs
// 方案三(2026-09-16):开火不发号——只切行(灰行), // 开火即发号(2026-09-16 用户拍板回归):显示层
// 名字由切后纯净窗在 T+2.5s 诞生 // 即时编号;注册素材仍等切后纯净窗校验
val serial = ++switchSerial val serial = ++switchSerial
switchPrevActive = active switchPrevActive = active
val label = known ?: SpeakerRecognition.nextAutoLabel()
switchLabel = label
Log.i(TAG, "FASTROLL-SWITCH sim=" + Log.i(TAG, "FASTROLL-SWITCH sim=" +
"%.2f".format(sim) + "%.2f".format(sim) +
" serial=$serial active=${active ?: "-"}") " serial=$serial label=$label kind=" +
(if (known != null) "known" else "fresh") +
" active=${active ?: "-"}")
cutRequested = true cutRequested = true
uiHandler.post { onOnsetSwitch(null) } uiHandler.post { onOnsetSwitch(label) }
} }
} }
} }
@ -1822,61 +1838,63 @@ class MainActivity : AppCompatActivity() {
uiHandler.post { adoptSwitchLabel(serial, name) } uiHandler.post { adoptSwitchLabel(serial, name) }
} }
name == null || score < verdictFallbackMin -> { name == null || score < verdictFallbackMin -> {
// 确认无人:发新号注册(纯净素材);fastReady=false(单 CAM++)时 // 确认无人:把开火即发的号以纯净素材注册(脏素材不进注册表)
// 镜像无意义,跳过额外计算 val label = switchLabel
val fastEmb = if (SpeakerRecognition.fastReady()) { if (label != null) {
SpeakerRecognition.computeEmbeddingFast(samples, sampleRateInHz) SpeakerRecognition.registry.addAuto(label, acc)
} else null Log.i(TAG, "NAMING-CONFIRM serial=$serial label=$label")
val label = SpeakerRecognition.nextAutoLabel().also {
SpeakerRecognition.registry.addAuto(it, acc)
if (fastEmb != null) {
SpeakerRecognition.fastRegistry.addAuto(it, fastEmb)
} }
} }
Log.i(TAG, "NAMING-NEW serial=$serial label=$label")
uiHandler.post { adoptSwitchLabel(serial, label) }
}
else -> Log.i(TAG, "NAMING-WEAK serial=$serial known=$name" + 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 if (serial != switchSerial) return
switchSerial++ // 关闭本切次命名窗口 switchSerial++ // 关闭本切次命名窗口
val firedLabel = switchLabel
grayLinePending = false grayLinePending = false
val bottom = subtitleLines.lastOrNull() val bottom = subtitleLines.lastOrNull()
if (bottom != null && bottom.open && bottom.label == null) { when {
bottom.label = label pendingOnsetLabel != null && pendingOnsetLabel == firedLabel -> {
bottom.colorIdx = colorIdxFor(label) pendingOnsetLabel = newLabel
notifyLine(bottom)
Log.i(TAG, "SWITCH-LABEL serial=$serial label=$label (line)")
} else {
pendingOnsetLabel = label
pendingOnsetLabelAt = SystemClock.elapsedRealtime() pendingOnsetLabelAt = SystemClock.elapsedRealtime()
Log.i(TAG, "SWITCH-LABEL serial=$serial label=$label (preset)") Log.i(TAG, "SWITCH-LABEL serial=$serial $firedLabel -> $newLabel (preset)")
} }
lastConfirmedLabel = label 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 = newLabel
} }
/** 误切撤销(方案三版,主线程):灰行未建立→重开旧行抹迹;已建立→改签并回 */ /** 误切撤销(主线程):切后纯净窗认回开火前主 → 撤号抹迹。开火即发的
* 号此时尚未入注册表(注册在 CONFIRM),无需清理注册表 */
private fun undoSwitch(serial: Long) { private fun undoSwitch(serial: Long) {
if (serial != switchSerial) return if (serial != switchSerial) return
switchSerial++ switchSerial++
val prevActive = switchPrevActive ?: return val prevActive = switchPrevActive ?: return
val firedLabel = switchLabel
grayLinePending = false grayLinePending = false
pendingOnsetLabel = null if (pendingOnsetLabel != null && pendingOnsetLabel == firedLabel) {
pendingOnsetLabel = null // 占位行未建:预置作废
}
onsetClosePending = false onsetClosePending = false
lastConfirmedLabel = prevActive lastConfirmedLabel = prevActive
val bottom = subtitleLines.lastOrNull() ?: return val bottom = subtitleLines.lastOrNull() ?: return
when { when {
bottom.open && bottom.label == null -> { bottom.open && firedLabel != null && bottom.label == firedLabel -> {
bottom.label = prevActive bottom.label = prevActive
bottom.colorIdx = colorIdxFor(prevActive) bottom.colorIdx = colorIdxFor(prevActive)
val merged = mergeLineIntoPrevIfSame(bottom, prevActive) val merged = mergeLineIntoPrevIfSame(bottom, prevActive)
if (!merged) notifyLine(bottom) 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 -> {
bottom.open = true bottom.open = true
@ -2367,11 +2385,11 @@ class MainActivity : AppCompatActivity() {
Toast.LENGTH_SHORT).show() Toast.LENGTH_SHORT).show()
return return
} }
val gguf = File(File(externalModelDir, summarizeModelDirName), val gguf = File(File(externalModelDir, translateModelDirName),
summarizeGgufName) translateGgufName)
if (!gguf.isFile) { if (!gguf.isFile) {
Toast.makeText(this, getString(R.string.trans_no_model, Toast.makeText(this, getString(R.string.trans_no_model,
"adb push $summarizeGgufName ${gguf.parent}/"), "adb push $translateGgufName ${gguf.parent}/"),
Toast.LENGTH_LONG).show() Toast.LENGTH_LONG).show()
return return
} }
@ -2510,28 +2528,30 @@ class MainActivity : AppCompatActivity() {
return s.ifBlank { null } return s.ifBlank { null }
} }
// 点击翻译总入口:单引擎 E2B(2026-09-16 用户拍板,两级架构下线—— // 点击翻译总入口:Hy-MT2-1.8B-1.25Bit 专模(2026-09-16 二度换装;
// 1B 主链失败率过高,"先小后大"平均耗时反而不如直接上 E2B;日语/中文/ // 此前为 E2B 兼任,再前为 1B 主链+升档两级——历史见 git history)。
// 文学体全面更强)。温度递进 0→0.3→0.7;各闸门拒收即重试;被拒样本 // 温度递进 0→0.3→0.7;各闸门拒收即重试;被拒样本逐字相同立即止损。
// 逐字相同立即止损(换温度无用)。
private fun translateOne(sid: Long, src: String): String? { private fun translateOne(sid: Long, src: String): String? {
// 时间预算:录音中 45s、空闲 95s。超预算宁可失败(toast 可重试), // 时间预算:录音中 45s、空闲 95s。超预算宁可失败(toast 可重试),
// 也不让重试链长时间抢 ASR 的核(饥饿会引发第一遍字幕复读)。 // 也不让重试链长时间抢 ASR 的核(饥饿会引发第一遍字幕复读)。
val deadline = SystemClock.elapsedRealtime() + val deadline = SystemClock.elapsedRealtime() +
if (isRecording) 45_000L else 95_000L if (isRecording) 45_000L else 95_000L
// 重试预算:E2B 单次尝试约 10~15s;录音中 1 次(两次≈30s 封顶), // 重试预算:Hy-MT2 单次尝试约 4~15s;录音中 2 次、空闲 3 次
// 空闲 2 次 val maxGenRetry = if (isRecording) 2 else 3
val maxGenRetry = if (isRecording) 1 else 2
var temp = 0f
var genRetry = 0 var genRetry = 0
var lastRej = "" var lastRej = ""
val salt = ++transSalt // 每次点击换盐:同句重点也会重新抽签
while (true) { while (true) {
if (SystemClock.elapsedRealtime() > deadline) { if (SystemClock.elapsedRealtime() > deadline) {
Log.i(TAG, "TAP-MISS sid=$sid reason=budget src=" + src.take(20)) Log.i(TAG, "TAP-MISS sid=$sid reason=budget src=" + src.take(20))
return null 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( 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 raw = map?.get(sid.toString())?.trim()
val srcTag = " src=" + SummarizePipeline.detectLanguage(src) + val srcTag = " src=" + SummarizePipeline.detectLanguage(src) +
":" + src.take(20).replace("\n", " ") ":" + src.take(20).replace("\n", " ")
@ -2556,7 +2576,7 @@ class MainActivity : AppCompatActivity() {
(if (raw != null) "repeat" else "genfail") + (if (raw != null) "repeat" else "genfail") +
" temp=$temp" + srcTag) " temp=$temp" + srcTag)
if (genRetry >= maxGenRetry) return null if (genRetry >= maxGenRetry) return null
genRetry++; temp = if (temp == 0f) 0.3f else 0.7f genRetry++
} }
isEcho(src, tr, echoJaccardTh(src)) -> { isEcho(src, tr, echoJaccardTh(src)) -> {
val es = tr.replace("\n", " ") val es = tr.replace("\n", " ")
@ -2564,7 +2584,7 @@ class MainActivity : AppCompatActivity() {
if (es == lastRej) return null // 复读成惯性,换温度无用 if (es == lastRej) return null // 复读成惯性,换温度无用
lastRej = es lastRej = es
if (genRetry >= maxGenRetry) return null if (genRetry >= maxGenRetry) return null
genRetry++; temp = if (temp == 0f) 0.3f else 0.7f genRetry++
} }
!targetLangOk(tr) -> { !targetLangOk(tr) -> {
val sample = tr.replace("\n", " ") val sample = tr.replace("\n", " ")
@ -2576,70 +2596,31 @@ class MainActivity : AppCompatActivity() {
if (sample == lastRej) return null if (sample == lastRej) return null
lastRej = sample lastRej = sample
if (genRetry >= maxGenRetry) return null if (genRetry >= maxGenRetry) return null
genRetry++; temp = if (temp == 0f) 0.3f else 0.7f genRetry++
} }
else -> return tr else -> return tr
} }
} }
} }
// 翻译目标语名/提示语(单引擎共用) // Hy-MT2 官方提示词用中文语言名("将以下文本翻译成日语/英语/中文")
private fun transTargetName(): String = when (uiLanguage()) { private fun transTargetNameCn(): String = when (uiLanguage()) {
"ja" -> "日本語"; "en" -> "English"; else -> "简体中文" "ja" -> "日语"; "en" -> "英语"; 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<String, String>? {
if (out.isNullOrBlank()) return null
val m = HashMap<String, String>()
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
} }
private fun ensureTranslateEngine(): LlmEngine? { private fun ensureTranslateEngine(): LlmEngine? {
translateEngine?.let { return it } translateEngine?.let { return it }
val f = File(File(externalModelDir, summarizeModelDirName), summarizeGgufName) val f = File(File(externalModelDir, translateModelDirName), translateGgufName)
if (!f.isFile) { if (!f.isFile) {
Log.i(TAG, "translate model missing at ${f.absolutePath}; tap-translate off") Log.i(TAG, "translate model missing at ${f.absolutePath}; tap-translate off")
return null return null
} }
return try { return try {
// 单引擎 E2B(2026-09-16):录音中 4 线程给 ASR 让核(纪要真机 // Hy-MT2-1.8B-1.25Bit(2026-09-16):翻译专模;录音中 4 线程给
// 实测零丢帧),空闲 6;ctx 与纪要同级 8192 // ASR 让核,空闲 6;ctx 与纪要同级 8192;模板 hunyuan
LlmEngine(f.absolutePath, SUMMARY_CTX, LlmEngine(f.absolutePath, SUMMARY_CTX,
if (isRecording) SUMMARY_THREADS_BUSY else SUMMARY_THREADS, if (isRecording) SUMMARY_THREADS_BUSY else SUMMARY_THREADS,
chatTemplate = summarizeChatTemplate) chatTemplate = translateChatTemplate)
.also { translateEngine = it } .also { translateEngine = it }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(TAG, "translate engine init failed: ${t.message}") Log.w(TAG, "translate engine init failed: ${t.message}")
@ -2648,154 +2629,76 @@ class MainActivity : AppCompatActivity() {
} }
// items: key(通常是 sid 字符串) × 原文;返回 key × 译文,失败 null。 // items: key(通常是 sid 字符串) × 原文;返回 key × 译文,失败 null。
// 直译模式(2026-09-16):不携带同轮上下文;引擎复用(JNI 每次生成前清 KV) // Hy-MT2 直译模式(2026-09-16 二度换装):逐段独立生成,官方提示词
// 格式 + 官方采样档(topK20/topP0.8);翻译专模不做编号批处理,长句
// 拆段后逐段翻译再按序拼回。引擎复用(JNI 每次生成前清 KV)。
private fun translateNumbered( private fun translateNumbered(
items: List<Pair<String, String>>, temp: Float = 0f, items: List<Pair<String, String>>, temp: Float = 0f, variant: Int = 0,
seed: Int = 0,
): Map<String, String>? { ): Map<String, String>? {
if (items.isEmpty()) return emptyMap() if (items.isEmpty()) return emptyMap()
val targetName = transTargetName() val target = transTargetNameCn()
val langHint = transLangHint()
// 引擎复用(2026-09-16,JNI 已加 KV 清除):不再按批重建——
// "每批重建"曾让每次尝试都重载 E2B ~4-6s,是翻译单句 30~40s 的主因
val engine = translateEngine ?: ensureTranslateEngine() ?: return null val engine = translateEngine ?: ensureTranslateEngine() ?: return null
// 长句拆段:小模型对短输入才稳定;长句译到中途会"掉语言"
// (简体→繁体→别的语言)。拆 ≤140 字段一次 generate,段序拼回。
val segKeys = ArrayList<Pair<String, String>>()
val segCount = HashMap<String, Int>()
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<String, String>() val got = HashMap<String, String>()
if (segKeys.size == 1) { for ((key, text) in items) {
val sys1 = "你是专业翻译。把给出的句子翻译成" + targetName + val segs = splitSegments(text)
"。只输出译文本身,不要解释、不要引号、不要抄写原文," + val vals = ArrayList<String>(segs.size)
"不要以「翻訳:」等任何标签开头," + var complete = true
"禁止道歉或说明无法完成,翻不了就输出空字符串。" + for ((i, sg) in segs.withIndex()) {
"你只在中文、日本語、English 三种语言之间翻译;" + // 控制字符清洗(ASR 异常字节曾有旧案):保留换行,剔除其余 <0x20
"输入不属于这三种语言时输出空字符串。" + langHint + "。" val sgClean = sg.filter { c -> c == '\n' || c.code >= 0x20 }
val usr1 = segKeys[0].second // 提示词变体(空输出逃逸用):v0 官方;v1 极简;v2 只输出译文;
// 白卷事故诊断(2026-09-16):空 raw= 的现场缺"模型看到了什么" // v3 语音识别草稿容错(乱码/复读输入专用)
// 这一环;转储两段长度与首尾,排除提示词层脏数据 val user = when (variant) {
Log.i(TAG, "TRANSLATE prompt sys.len=" + sys1.length + 1 -> "将以下文本翻译成" + target + ":\n\n" + sgClean
" usr.len=" + usr1.length + 2 -> "请把下面这段文本翻译成" + target + ",只输出译文:\n\n" + sgClean
" usr.head=" + usr1.take(40).replace("\n", " ") + 3 -> "以下是一段语音识别草稿(可能有错字和重复),请按大意翻译成" +
" usr.tail=" + usr1.takeLast(48).replace("\n", " ")) 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 { val one = try {
engine.generate( engine.generate(
systemPrompt = sys1, systemPrompt = "", // Hy-MT 无系统提示词(官方)
userPrompt = usr1, userPrompt = user,
maxNewTokens = budget, temperature = temp, maxNewTokens = (sgClean.length * 2 + 96).coerceAtMost(1200),
temperature = temp,
topK = 20, topP = 0.8f, // 官方 1.8B 推荐采样档
repeatPenalty = 1.1f, repeatPenalty = 1.1f,
seed = seed,
minNew = 4, // 防空白:前 4 token 禁 EOS
) )
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(TAG, "TRANSLATE single failed: " + t.message); null Log.w(TAG, "TRANSLATE seg failed: " + t.message); null
} }
val tr = cleanTrans(one, segKeys[0].second) val tr = cleanTrans(one, sgClean)
if (!tr.isNullOrEmpty()) got[segKeys[0].first] = tr if (tr.isNullOrEmpty()) {
if (got.isEmpty()) { Log.w(TAG, "TRANSLATE seg empty v=$variant temp=$temp seed=$seed " +
// 空手而归的诊断(2026-09-16):区分「模型白卷」与「输出被清洗成空 "raw.len=" + (one?.length ?: -1) + " raw=" +
// (只有标签/残片)」——genfail 现场的两种死法原来看不出 (one?.take(24)?.replace("\n", "\\n") ?: "<null>"))
Log.w(TAG, "TRANSLATE single empty raw=" + complete = false; break
(one?.take(48)?.replace("\n", " ") ?: "<null>"))
return null
} }
} else { vals.add(tr)
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) if (!complete) continue
segKeys.forEachIndexed { i, st -> // 跨段复读保护:拆段后各段译文本应互不相同;≥2 段同文(且非短句)
byIdx?.get((i + 1).toString())?.let { cleanTrans(it, st.second) } // = 模型把一段复制成多段 → 整条判退化,交给重试机制
?.takeIf { it.isNotBlank() } if (vals.size >= 2 && vals.distinct().size < vals.size &&
?.let { got[st.first] = it } vals.first().length >= 16) {
}
// 模型对编号批会"犯懒跳号"(只写最后一段等)→ 缺失段逐一直出
// 兜底;每段重建引擎(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 }
}
}
if (got.isEmpty()) {
Log.w(TAG, "TRANSLATE batch empty out=" +
(out?.take(48)?.replace("\n", " ") ?: "<null>"))
return null
}
}
val res = HashMap<String, String>()
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 " + Log.i(TAG, "TRANSLATE key=$key cross-seg repeat " +
n + "→" + segVals.distinct().size) vals.size + "→" + vals.distinct().size)
continue continue
} }
val sb = StringBuilder() got[key] = vals.joinToString("")
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() return if (got.isEmpty()) null else got
else Log.i(TAG, "MISS key=" + key + " reason=seg-hole")
}
}
return if (res.isEmpty()) null else res
} }
// 复读检测:译文与原文归一化后全等/长串包含/字符二元组 Jaccard≥0.72 // 复读检测:译文与原文归一化后全等/长串包含/字符二元组 Jaccard≥0.72

View File

@ -22,6 +22,8 @@ fi
cd llama.cpp cd llama.cpp
git fetch --depth 1 origin "${LLAMA_COMMIT}" 2>/dev/null || true git fetch --depth 1 origin "${LLAMA_COMMIT}" 2>/dev/null || true
git checkout "${LLAMA_COMMIT}" 2>/dev/null || git pull git checkout "${LLAMA_COMMIT}" 2>/dev/null || git pull
# 应用本地修改:STQ1_0 1.25bit 内核移植(PR #22836)+ 私有编号重排(见 README)
git apply "../llama-df03399b-stq1_0.patch"
cd .. cd ..
cmake -B "build-${ABI}" \ cmake -B "build-${ABI}" \
@ -30,7 +32,13 @@ cmake -B "build-${ABI}" \
-DANDROID_PLATFORM="${PLATFORM}" \ -DANDROID_PLATFORM="${PLATFORM}" \
-DANDROID_STL=c++_static \ -DANDROID_STL=c++_static \
-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON \ -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON \
-DGGML_CPU_ARM_ARCH=armv8.2-a+dotprod \
-DCMAKE_BUILD_TYPE=Release -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)" cmake --build "build-${ABI}" -j "$(nproc)"
mkdir -p "${OUT_DIR}" mkdir -p "${OUT_DIR}"

View File

@ -155,7 +155,7 @@ JNIEXPORT jstring JNICALL
Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate( Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
JNIEnv * env, jclass /*clazz*/, jlong ptr, JNIEnv * env, jclass /*clazz*/, jlong ptr,
jstring jPrompt, jint maxNewTokens, jfloat temperature, jint topK, jfloat topP, jstring jPrompt, jint maxNewTokens, jfloat temperature, jint topK, jfloat topP,
jfloat repeatPenalty) { jfloat repeatPenalty, jint seed, jint minNew) {
auto * e = (Engine *) (uintptr_t) ptr; auto * e = (Engine *) (uintptr_t) ptr;
if (e == nullptr || e->ctx == nullptr) { if (e == nullptr || e->ctx == nullptr) {
@ -221,9 +221,10 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
// --- decode loop --- // --- decode loop ---
const llama_token eos = llama_vocab_eos(e->vocab); const llama_token eos = llama_vocab_eos(e->vocab);
std::mt19937 rng(42 + (uint32_t) (temperature * 1000.0f)); // seed:调用方显式给随机种子(翻译路径每次点击换盐、每次重试错位,让重试
// seed 随温度错位:温度递进的重试不再抽同一条随机序列(此前固定 42, // 是真正的再抽签);seed<=0 退回旧行为(42+温度错位,摘要路径用)
// 0.3 重试与 0.0 贪心几乎同果,"跳出死路"名存实亡) std::mt19937 rng(seed > 0 ? (uint32_t) seed
: (42 + (uint32_t) (temperature * 1000.0f)));
std::string out; std::string out;
out.reserve(1024); out.reserve(1024);
std::vector<char> piece(64); std::vector<char> piece(64);
@ -249,6 +250,14 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
float * logits = llama_get_logits_ith(e->ctx, 0); float * logits = llama_get_logits_ith(e->ctx, 0);
const int vsize = llama_vocab_n_tokens(e->vocab); 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 (usePen && !recent.empty()) {
if ((int) recent.size() > 64) if ((int) recent.size() > 64)
recent.erase(recent.begin(), recent.begin() + (recent.size() - 64)); recent.erase(recent.begin(), recent.begin() + (recent.size() - 64));