说话人识别+翻译模型优化
This commit is contained in:
parent
064fab39e0
commit
edbfbe6434
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
when {
|
||||
pendingOnsetLabel != null && pendingOnsetLabel == firedLabel -> {
|
||||
pendingOnsetLabel = newLabel
|
||||
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) {
|
||||
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<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
|
||||
// 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<Pair<String, String>>, temp: Float = 0f,
|
||||
items: List<Pair<String, String>>, temp: Float = 0f, variant: Int = 0,
|
||||
seed: Int = 0,
|
||||
): Map<String, String>? {
|
||||
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<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>()
|
||||
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", " "))
|
||||
for ((key, text) in items) {
|
||||
val segs = splitSegments(text)
|
||||
val vals = ArrayList<String>(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 = sys1,
|
||||
userPrompt = usr1,
|
||||
maxNewTokens = budget, temperature = temp,
|
||||
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 single failed: " + t.message); null
|
||||
Log.w(TAG, "TRANSLATE seg 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", " ") ?: "<null>"))
|
||||
return 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") ?: "<null>"))
|
||||
complete = false; break
|
||||
}
|
||||
} 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
|
||||
vals.add(tr)
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
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
|
||||
) {
|
||||
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 " +
|
||||
n + "→" + segVals.distinct().size)
|
||||
vals.size + "→" + vals.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)
|
||||
got[key] = vals.joinToString("")
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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<char> 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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue