点击翻译两级架构+翻译质量治理+插话强切+CAM++声纹主选:

- 自动双语下线(requestTranslate/TransSeg封段二次译/初译批量队列/Start预热全删),改点击定稿句触发:同轮上下文≤600字、句下内联小字斜体、再点收起、精修改原文作废译文缓存;已翻译块淡琥珀背景高亮(收起即消,失败/在途不标)
- 两级翻译:1B主链(genfail/echo温度递进0→0.3→0.7末次丢上下文;langdrift一次即升档;被拒样本逐字相同立即止损)+摘要引擎兜底(临时加载用完release,文学体意译提示词,同套质检)
- 翻译质量:JNI加repeatPenalty(翻译路径1.1/摘要0不变)+种子随温度错位;cleanTrans剥「翻訳:」类标签;译文复读折叠+原文自重复忠实放行(srcHasRepetition);三语闸门sourceLangOk+targetLangOk重音拉丁档;录音中1线程+BACKGROUND+预算45/90s
- 声纹:无缝插话确证(连续低分streak≥2→SWITCH-FORCE强判endpoint,实锤日志:插话场景SUSPECT三次armed三次expired);VERDICT-SCORE校准日志;speakerModels主换CAM++ zh_en(v1.13架构无关换文件即生效,v2/base兜底,fast取末位防错位)
- strings trans_*×6三语;ktlint全量parse通过;libsummarize-llm-jni.so已重编交付(*.so不入库)
This commit is contained in:
liwenhao2 2026-09-16 09:36:02 +08:00
parent f4501c8812
commit 0997a21461
6 changed files with 498 additions and 246 deletions

View File

@ -113,6 +113,8 @@ class LlmEngine(
/** /**
* 阻塞式生成(仅供摘要后台线程调用)。返回模型输出文本(不含输入)。 * 阻塞式生成(仅供摘要后台线程调用)。返回模型输出文本(不含输入)。
* temperature=0 为 Greedy。abort() 可从其它线程安全打断。 * temperature=0 为 Greedy。abort() 可从其它线程安全打断。
* repeatPenalty>1 启用重复惩罚(翻译路径传 1.1 治 1B 复读循环;
* 摘要路径保持默认 0=关闭,行为与已验证版本一致)。
*/ */
fun generate( fun generate(
systemPrompt: String, systemPrompt: String,
@ -121,11 +123,12 @@ class LlmEngine(
temperature: Float = 0f, temperature: Float = 0f,
topK: Int = 64, topK: Int = 64,
topP: Float = 0.95f, topP: Float = 0.95f,
repeatPenalty: Float = 0f,
): 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 maxNewTokens, temperature, topK, topP, repeatPenalty
) )
} }
@ -155,7 +158,7 @@ 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 temperature: Float, topK: Int, topP: Float, repeatPenalty: Float
): String ): String
private external fun nativeAbort(ptr: Long) private external fun nativeAbort(ptr: Long)

View File

@ -18,6 +18,7 @@ import android.text.Spanned
import android.text.TextPaint import android.text.TextPaint
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.text.method.ScrollingMovementMethod import android.text.method.ScrollingMovementMethod
import android.text.style.BackgroundColorSpan
import android.text.style.ClickableSpan import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan import android.text.style.RelativeSizeSpan
@ -84,7 +85,9 @@ class MainActivity : AppCompatActivity() {
) { ) {
var line: SubtitleLine? = null var line: SubtitleLine? = null
var refined: Boolean = false // false => 底稿(第一遍)未被精修覆盖 var refined: Boolean = false // false => 底稿(第一遍)未被精修覆盖
var trans: String? = null // 定稿后的界面语言译文(批次二:双语字幕) var trans: String? = null // 点击翻译缓存(界面语言);原文变化时置空
var transVisible: Boolean = true // 点击在已缓存译文上收起/展开
var transPending: Boolean = false // 在途翻译占位(句下显示"翻译中…")
} }
private class SubtitleLine( private class SubtitleLine(
@ -157,8 +160,11 @@ class MainActivity : AppCompatActivity() {
// models dir (preferred) or at the ROOT of assets/. Download: // models dir (preferred) or at the ROOT of assets/. Download:
// https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models // https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models
private val speakerModels = listOf( private val speakerModels = listOf(
"3dspeaker_speech_eres2netv2_sv_zh-cn_16k-common.onnx", // more accurate // 主选:CAM++ 7.1M,中英训练,3D-Speaker 榜单 EER 优于 eres2net 系,
"3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx", // smaller fallback // v1.13 声纹提取器架构无关(读 ONNX 元数据),换文件即生效
"3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced.onnx",
"3dspeaker_speech_eres2netv2_sv_zh-cn_16k-common.onnx", // accurate fallback
"3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx", // smaller/faster
) )
private var activeSpeakerModel: String? = null private var activeSpeakerModel: String? = null
private val speakerThreshold = 0.5f private val speakerThreshold = 0.5f
@ -203,6 +209,13 @@ class MainActivity : AppCompatActivity() {
private val judgeMinSamples = 48000 private val judgeMinSamples = 48000
private val enableRollingSwitch = true // A/B 诊断:关掉=只用自然 endpoint 断句 private val enableRollingSwitch = true // A/B 诊断:关掉=只用自然 endpoint 断句
@Volatile private var rollingLowHits = 0 @Volatile private var rollingLowHits = 0
// 无缝插话确证(2026-09-15):SUSPECT 等停顿会被"对方话没停就接话"憋死
// (日志实锤:simA 连4窗 0.14~0.19,SUSPECT 连 armed 三次全 expired,
// silenceRun=0/4800)。连续第 2 个低分窗 = 约 4~6s 持续异人声纹,
// 不再等停顿,直接强判 endpoint 断句。单窗毛刺(音量/瞬时)攒不满 2。
@Volatile private var rollingLowStreak = 0
@Volatile private var pendingForceEndpoint = false
private val forceSwitchStreaks = 2
// C(最新者胜):每次投递滚动检测都刷新序号,队列里被超额的旧任务直接跳过 // C(最新者胜):每次投递滚动检测都刷新序号,队列里被超额的旧任务直接跳过
@Volatile private var rollingSeq = 0L @Volatile private var rollingSeq = 0L
// 兜底残料:当前句子槽位最近一次滚动窗口声纹。整句判定因碎片算不出 // 兜底残料:当前句子槽位最近一次滚动窗口声纹。整句判定因碎片算不出
@ -295,34 +308,21 @@ class MainActivity : AppCompatActivity() {
// generate 阻塞在 native,取消靠 abort;引擎只在摘要线程持有 // generate 阻塞在 native,取消靠 abort;引擎只在摘要线程持有
@Volatile private var summarizeEngine: LlmEngine? = null @Volatile private var summarizeEngine: LlmEngine? = null
// ---- 字幕双语翻译(批次二,2026-09-14)-------------------------------- // ---- 点击字幕句翻译(原"批次二自动双语",2026-09-15 改造)-----------------
// 独立翻译模型:Gemma-3-1B-it Q4_K_M(arch gemma3,.so 已支持,模板分支 // 独立翻译模型:Gemma-3-1B-it Q4_K_M(arch gemma3,模板 renderGemma3Prompt)。
// renderGemma3Prompt 现成)。定稿后句级翻译,原文+小字译文双语显示; // 不再自动翻译:点击某句 → 带同轮上下文译该句,译文行内小字斜体挂在句下,
// 界面语言与句子语言一致时不翻;模型文件缺失 → 功能静默关闭(自然降级)。 // 再点收起(缓存于 Sentence.trans,原文变化时失效)。首次点击懒加载引擎,
// 每次翻译按防污染协议重建引擎;闲置 60s 自动卸载。模型文件缺失 → toast 提示。
private val translateModelDirName = "gemma-3-1b-it" private val translateModelDirName = "gemma-3-1b-it"
private val translateGgufName = "gemma-3-1b-it-Q4_K_M.gguf" private val translateGgufName = "gemma-3-1b-it-Q4_K_M.gguf"
private val translateIdleUnloadMs = 60_000L private val translateIdleUnloadMs = 60_000L
private val translateThread = HandlerThread("translate").also { it.start() } private val translateThread = HandlerThread("translate").also { it.start() }
private val translateHandler by lazy { Handler(translateThread.looper) } private val translateHandler by lazy { Handler(translateThread.looper) }
@Volatile private var translateEngine: LlmEngine? = null @Volatile private var translateEngine: LlmEngine? = null
private var translateDisabled = false // 一次只允许一个在途翻译(排队期间后续点击 toast"请稍候")
// KV 污染 workaround(2026-09-14,不改 .so):nativeGenerate 不清 KV, @Volatile private var transBusy = false
// 同引擎连续 generate 会复读上一条 → 每"批"generate 前重建引擎。 // 已翻译句子的淡琥珀背景高亮(约13%透明度,深浅主题皆可读)
// 初译从逐句改为小批量编号翻译(凑 4 句或 2.5s 定时封批)。 private val transHighlightColor = Color.parseColor("#22FFA000")
private val initialBatchMax = 1 // 逐句直出:多句JSON批的格式/复读风险一并消除
private val initialBatchDelayMs = 2_500L
private val initialPending = ArrayList<Pair<Long, String>>()
private var initialBatchTimer: Runnable? = null
private val translatedSids = HashSet<Long>() // 每槽只翻一次
// 失败重试预算:每句最多 2 次,温度递进(0→0.3→0.7),贪心死路可被跳出。
// 仅在翻译线程读写,无并发。
private val translateAttempts = HashMap<Long, Int>()
private fun bumpAttempt(sid: Long): Int {
val n = (translateAttempts[sid] ?: 0) + 1
translateAttempts[sid] = n
return n
}
companion object { companion object {
private val SUMMARY_THREADS = 6 // 空闲时:大核全用 private val SUMMARY_THREADS = 6 // 空闲时:大核全用
@ -518,6 +518,8 @@ class MainActivity : AppCompatActivity() {
windowRefEmb = null windowRefEmb = null
windowPrevEmb = null windowPrevEmb = null
rollingLowHits = 0 rollingLowHits = 0
rollingLowStreak = 0
pendingForceEndpoint = false
checkSerial++ checkSerial++
activeLabelForWindow = null activeLabelForWindow = null
lastConfirmedLabel = null lastConfirmedLabel = null
@ -527,15 +529,6 @@ class MainActivity : AppCompatActivity() {
sessionTranscript?.close() sessionTranscript?.close()
sessionTranscript = SessionTranscript.begin(filesDir) sessionTranscript = SessionTranscript.begin(filesDir)
} }
// 预热翻译引擎:把模型文件读进 OS 页缓存后即释放(每批仍按防污染
// 协议重建),消除开头几批的冷加载失败
translateHandler.post {
val e = ensureTranslateEngine()
if (e != null) {
translateEngine = null
runCatching { e.release() }
}
}
recordingThread = thread(true) { recordingThread = thread(true) {
processSamples() processSamples()
} }
@ -545,7 +538,6 @@ class MainActivity : AppCompatActivity() {
/** 暂停:麦克风停,会话不死——录音线程退出时软 endpoint 定格在途句, /** 暂停:麦克风停,会话不死——录音线程退出时软 endpoint 定格在途句,
* 精修/声纹判定回来照常修正字幕,标签命名可用。 */ * 精修/声纹判定回来照常修正字幕,标签命名可用。 */
private fun pauseSession() { private fun pauseSession() {
closeTransSeg()
isRecording = false isRecording = false
pauseStartedAt = SystemClock.elapsedRealtime() pauseStartedAt = SystemClock.elapsedRealtime()
runCatching { audioRecord?.stop() } // 不 release,继续时复用 runCatching { audioRecord?.stop() } // 不 release,继续时复用
@ -573,7 +565,6 @@ class MainActivity : AppCompatActivity() {
/** 停止:整场会议结束(转写保留,可继续生成纪要)。 */ /** 停止:整场会议结束(转写保留,可继续生成纪要)。 */
private fun stopSession() { private fun stopSession() {
closeTransSeg()
if (isRecording) { if (isRecording) {
isRecording = false isRecording = false
runCatching { audioRecord?.stop() } runCatching { audioRecord?.stop() }
@ -672,6 +663,7 @@ class MainActivity : AppCompatActivity() {
windowRefEmb = emb windowRefEmb = emb
windowPrevEmb = emb windowPrevEmb = emb
rollingLowHits = 0 rollingLowHits = 0
rollingLowStreak = 0
} else { } else {
val simA = SpeakerRecognition.similarity(emb, anchor) val simA = SpeakerRecognition.similarity(emb, anchor)
val simP = if (prev == null) simA else val simP = if (prev == null) simA else
@ -682,6 +674,7 @@ class MainActivity : AppCompatActivity() {
" drop=$speakerSwitchDrop hits=$rollingLowHits") " drop=$speakerSwitchDrop hits=$rollingLowHits")
if (simA >= speakerSwitchDrop) { if (simA >= speakerSwitchDrop) {
// 同人缓漂:高置信窗才吸收进锚点(P3 防漂移) // 同人缓漂:高置信窗才吸收进锚点(P3 防漂移)
rollingLowStreak = 0
if (simA >= emaAbsorbMin) { if (simA >= emaAbsorbMin) {
windowRefEmb = blendEmb(anchor, emb) windowRefEmb = blendEmb(anchor, emb)
} }
@ -690,10 +683,12 @@ class MainActivity : AppCompatActivity() {
} else if (simP >= emaAbsorbMin) { } else if (simP >= emaAbsorbMin) {
// 只偏离锚点、紧贴上一窗 → 音量/情绪渐变, // 只偏离锚点、紧贴上一窗 → 音量/情绪渐变,
// 更新双参考,不换行 // 更新双参考,不换行
rollingLowStreak = 0
windowRefEmb = blendEmb(anchor, emb) windowRefEmb = blendEmb(anchor, emb)
windowPrevEmb = emb windowPrevEmb = emb
rollingLowHits = 0 rollingLowHits = 0
} else { } else {
rollingLowStreak += 1
rollingLowHits += 1 rollingLowHits += 1
if (rollingLowHits >= switchStrikesNeeded) { if (rollingLowHits >= switchStrikesNeeded) {
rollingLowHits = 0 rollingLowHits = 0
@ -706,6 +701,7 @@ class MainActivity : AppCompatActivity() {
active != null && active != null &&
newLabel == active -> { newLabel == active -> {
windowRefEmb = blendEmb(anchor, emb) windowRefEmb = blendEmb(anchor, emb)
rollingLowStreak = 0
windowPrevEmb = emb windowPrevEmb = emb
} }
// 当前行未归属而窗匹配到已知人 → // 当前行未归属而窗匹配到已知人 →
@ -721,6 +717,15 @@ class MainActivity : AppCompatActivity() {
SystemClock.elapsedRealtime() SystemClock.elapsedRealtime()
Log.i(TAG, "SWITCH-SUSPECT" + Log.i(TAG, "SWITCH-SUSPECT" +
" armed fast-endpoint owner=sid$owner") " armed fast-endpoint owner=sid$owner")
// 无缝插话确证:连续低分够了
// → 不等停顿,本窗立即强判
if (rollingLowStreak >= forceSwitchStreaks) {
pendingForceEndpoint = true
rollingLowStreak = 0
Log.i(TAG, "SWITCH-FORCE" +
" streak>=$forceSwitchStreaks" +
" owner=sid$owner")
}
} }
} }
} }
@ -737,10 +742,12 @@ class MainActivity : AppCompatActivity() {
} }
// 路线A:疑似换人后的第一个自然停顿(≥0.3s)即触发结算 // 路线A:疑似换人后的第一个自然停顿(≥0.3s)即触发结算;
val fastEp = switchSuspicionAtMs != 0L && // 无缝插话确证(连续低分 streak)走 pendingForceEndpoint 直切
val fastEp = pendingForceEndpoint ||
(switchSuspicionAtMs != 0L &&
SystemClock.elapsedRealtime() - switchSuspicionAtMs < 5_000L && SystemClock.elapsedRealtime() - switchSuspicionAtMs < 5_000L &&
silenceRun >= silenceNeedSamples silenceRun >= silenceNeedSamples)
val susExpired = switchSuspicionAtMs != 0L && !fastEp && val susExpired = switchSuspicionAtMs != 0L && !fastEp &&
SystemClock.elapsedRealtime() - switchSuspicionAtMs >= 5_000L SystemClock.elapsedRealtime() - switchSuspicionAtMs >= 5_000L
if (susExpired) { if (susExpired) {
@ -752,9 +759,13 @@ class MainActivity : AppCompatActivity() {
switchSuspicionAtMs = 0L switchSuspicionAtMs = 0L
} }
if (fastEp) { if (fastEp) {
val forced = pendingForceEndpoint
pendingForceEndpoint = false
rollingLowStreak = 0
switchSuspicionAtMs = 0L switchSuspicionAtMs = 0L
silenceRun = 0 silenceRun = 0
Log.i(TAG, "FAST-EP fired (suspicion + pause)") Log.i(TAG, "FAST-EP fired (" +
(if (forced) "force: low-streak)" else "suspicion + pause)"))
} }
val isEndpoint = onlineRecognizer.isEndpoint(stream) || fastEp val isEndpoint = onlineRecognizer.isEndpoint(stream) || fastEp
@ -862,6 +873,16 @@ class MainActivity : AppCompatActivity() {
if (fastEmb != null) if (fastEmb != null)
SpeakerRecognition.fastRegistry.addAuto(it, fastEmb) SpeakerRecognition.fastRegistry.addAuto(it, fastEmb)
} }
// CAM++ 校准数据回路:打印判定前的原始余弦最高分(含注册表规模)。
// 判错人时看 best 落在哪:best≥0.5 且人不对 → speakerThreshold
// 对 CAM++ 偏高/偏低要重标;best 模糊区(0.4~0.55)密集 → 换窗长。
if (emb != null) {
Log.i(TAG, "VERDICT-SCORE sid=$sid" +
" best=" + "%.3f".format(
SpeakerRecognition.registry.bestScore(emb)) +
" thr=$speakerThreshold fallback=$verdictFallbackMin" +
" label=$label dur=${snap.size / sampleRateInHz}s")
}
uiHandler.post { confirmSpeaker(sid, label, emb, fastEmb) } uiHandler.post { confirmSpeaker(sid, label, emb, fastEmb) }
} }
} }
@ -917,10 +938,12 @@ class MainActivity : AppCompatActivity() {
activeSpeakerModel = null activeSpeakerModel = null
} }
// E:轻量声纹模型(eres2net base)专供滚动换人检测,推理快 ~4 倍。 // E:轻量声纹模型(eres2net base,列表末位)专供滚动换人检测,推理快 ~4 倍。
// 找不到该文件(或准确模型本来就是 base)时跳过,滚动检测自动回退准确模型。 // 找不到该文件(或准确模型本来就是 base)时跳过,滚动检测自动回退准确模型。
// 注:换 CAM++ 主选后 fast 空间暂仍用 base(独立 registry 镜像,互不污染);
// CAM++ 足够小,后续想让它兼职 fast 只需把末位换成同文件。
private fun initFastSpeakerModel() { private fun initFastSpeakerModel() {
val fastName = speakerModels[1] val fastName = speakerModels.last()
if (activeSpeakerModel == fastName) return // 准确模型已是 base if (activeSpeakerModel == fastName) return // 准确模型已是 base
try { try {
SpeakerRecognition.initFastExtractor( SpeakerRecognition.initFastExtractor(
@ -1394,17 +1417,38 @@ class MainActivity : AppCompatActivity() {
} }
}, start, start + head.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }, start, start + head.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
} }
var prevSubRow = false // 上一句是否渲染过句下行(译文/翻译中占位)
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) {
// 历史逐句分行;直播按语言习惯衔接(分支写开:Char/String 混 // 历史逐句分行;直播按语言习惯衔接(分支写开:Char/String 混
// 在 if 表达式里会被推断成 Any,没有匹配的 append 重载) // 在 if 表达式里会被推断成 Any,没有匹配的 append 重载)。
if (historyMode) sb.append('\n') // 上一句带译文/占位子行时必须换行,否则下一句原文黏在译文尾
if (historyMode || prevSubRow) sb.append('\n')
else sb.append(joinerAfter(line.sentences[i - 1].text)) else sb.append(joinerAfter(line.sentences[i - 1].text))
} }
prevSubRow = false
val sFrom = sb.length
sb.append(if (historyMode) s.text else s.text.lowercase()) sb.append(if (historyMode) s.text else s.text.lowercase())
// 双语字幕:原文下方小字斜体译文(批次二;颜色沿用行色,整行着色) // 点击原文句 → 翻译/收起(历史只读视图不挂;不改视觉样式)
s.trans?.takeIf { it.isNotBlank() }?.let { tr -> if (!historyMode && s.finalized) {
val qStart = sb.length - s.text.lowercase().length
sb.setSpan(object : ClickableSpan() {
override fun onClick(widget: View) {
onSubtitleSentenceTap(s)
}
override fun updateDrawState(ds: TextPaint) {
ds.color = color
ds.isUnderlineText = false
}
}, qStart, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
// 译文行:小字斜体挂在句下(点击翻译产物;收起态不渲染)。
// 已翻译且译文可见 → 原文+译文整块淡色背景高亮(收起即消失;
// 失败/在途不标)
var hlTo = -1
s.trans?.takeIf { it.isNotBlank() && s.transVisible }?.let { tr ->
sb.append('\n') sb.append('\n')
val ts = sb.length val ts = sb.length
sb.append(tr) sb.append(tr)
@ -1412,6 +1456,22 @@ class MainActivity : AppCompatActivity() {
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
sb.setSpan(StyleSpan(Typeface.ITALIC), ts, sb.length, sb.setSpan(StyleSpan(Typeface.ITALIC), ts, sb.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
prevSubRow = true
hlTo = sb.length
}
if (hlTo > sFrom) {
sb.setSpan(BackgroundColorSpan(transHighlightColor),
sFrom, hlTo, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
if (s.transPending) {
sb.append('\n')
val ps = sb.length
sb.append(getString(R.string.trans_pending))
sb.setSpan(RelativeSizeSpan(0.82f), ps, sb.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
sb.setSpan(StyleSpan(Typeface.ITALIC), ps, sb.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
prevSubRow = true
} }
} }
sb.setSpan( sb.setSpan(
@ -1472,6 +1532,7 @@ class MainActivity : AppCompatActivity() {
windowRefEmb = null windowRefEmb = null
windowPrevEmb = null windowPrevEmb = null
rollingLowHits = 0 rollingLowHits = 0
rollingLowStreak = 0 // 新行=新锚点,连续低分从头算
list.add(line) list.add(line)
if (!historyMode) { if (!historyMode) {
subtitleAdapter.notifyItemInserted(subtitleLines.size - 1) subtitleAdapter.notifyItemInserted(subtitleLines.size - 1)
@ -1531,7 +1592,6 @@ class MainActivity : AppCompatActivity() {
// 不会再有精修回来:立即收回句子索引 // 不会再有精修回来:立即收回句子索引
// (SKIP_SHORT 等情况 submitRefineTask 已写对账日志,不重复记) // (SKIP_SHORT 等情况 submitRefineTask 已写对账日志,不重复记)
sentenceById.remove(sid) sentenceById.remove(sid)
requestTranslate(sid, sentence.text) // 底稿即最终文本 → 直接翻译
} }
sentence.line?.let { notifyLine(it) } sentence.line?.let { notifyLine(it) }
autoScroll() autoScroll()
@ -1807,6 +1867,7 @@ class MainActivity : AppCompatActivity() {
if (second != null && second != sentence.text) { if (second != null && second != sentence.text) {
sentence.text = second sentence.text = second
sentence.refined = true sentence.refined = true
sentence.trans = null // 原文已变,旧译文作废(再点击重译)
sentence.line?.let { notifyLine(it) } sentence.line?.let { notifyLine(it) }
autoScroll() autoScroll()
} else { } else {
@ -1816,8 +1877,6 @@ class MainActivity : AppCompatActivity() {
// 会话记录器挂钩②(终稿文本):与字幕层看到同一最终结果 // 会话记录器挂钩②(终稿文本):与字幕层看到同一最终结果
sessionTranscript?.onRefined(sid, sentence.text, sentence.refined) sessionTranscript?.onRefined(sid, sentence.text, sentence.refined)
} }
// 定稿文本已确定(应用或保底稿)→ 排队翻译(批次二:双语字幕)
requestTranslate(sid, sentence.text)
} }
// 滚动窗口查"这是谁":有 fast 模型时查 fast 镜像(0.4 宽松阈值,仅供显示); // 滚动窗口查"这是谁":有 fast 模型时查 fast 镜像(0.4 宽松阈值,仅供显示);
@ -1879,104 +1938,300 @@ class MainActivity : AppCompatActivity() {
return if (l == "ja" || l == "en") l else "zh" return if (l == "ja" || l == "en") l else "zh"
} }
private fun requestTranslate(sid: Long, text: String) { // ==================== 点击字幕句翻译(2026-09-15 改造)====================
if (text.isBlank()) return // 自动翻译已下线。点击定稿句:首次=带同轮上下文译该句,结果缓存到
// ---- 轮次分段(换人/静默6s → 封段送二次翻译)---- // Sentence.trans 挂在句下;再点=收起/展开(不重译)。质量治理沿用:
findSentence(sid)?.line?.let { line -> // 空/复读/漂移拒收 + 温度递进重试、每句重建引擎(KV 污染 workaround)。
val now = SystemClock.elapsedRealtime() private fun onSubtitleSentenceTap(s: Sentence) {
var seg = openTransSeg if (!s.finalized || s.text.isBlank()) return
if (seg == null || seg.line !== line || if (s.transPending) return
now - seg.lastAddMs > transSegGapMs s.trans?.let {
) { s.transVisible = !s.transVisible
closeTransSeg() s.line?.let { notifyLine(it) }
seg = TransSeg(line) return
openTransSeg = seg
} }
seg.lastAddMs = now if (transBusy) {
findSentence(sid)?.let { st -> Toast.makeText(this, getString(R.string.trans_busy),
if (!seg.sentences.contains(st)) seg.sentences.add(st) Toast.LENGTH_SHORT).show()
return
} }
armSegTimer() if (SummarizePipeline.detectLanguage(s.text) == uiLanguage()) {
Toast.makeText(this, getString(R.string.trans_already),
Toast.LENGTH_SHORT).show()
return
} }
// ---- 初译(小批量;段末二次翻译会逐句覆盖)---- if (!sourceLangOk(s.text)) {
if (translateDisabled) return Toast.makeText(this, getString(R.string.trans_not_3lang),
if (!translatedSids.add(sid)) return // 每槽初译只排一次 Toast.LENGTH_SHORT).show()
if (SummarizePipeline.detectLanguage(text) == uiLanguage()) return return
initialPending.add(Pair(sid, text))
if (initialPending.size >= initialBatchMax) flushInitialBatch() else armInitialBatchTimer()
} }
val gguf = File(File(externalModelDir, translateModelDirName),
// ==================== 轮次分段与二次翻译(2026-09-14)==================== translateGgufName)
// 段 = 同一字幕行(同一说话人轮次)内连续定稿的句子。封段时机: if (!gguf.isFile) {
// ①不同行/不同说话人的句子定稿;②段内 6s 无新定稿(静默); Toast.makeText(this, getString(R.string.trans_no_model,
// ③暂停/停止。封段后整段带编号送二次翻译,按编号逐句覆盖初译; "adb push $translateGgufName ${gguf.parent}/"),
// 单句段不重翻(初译已是全上下文)。 Toast.LENGTH_LONG).show()
private class TransSeg(val line: SubtitleLine) { return
val sentences = ArrayList<Sentence>()
var lastAddMs = 0L
} }
private val transSegGapMs = 6_000L // 同轮上下文:本行中该句之前的句子(最近 ≤600 字),仅帮助模型消解
private var openTransSeg: TransSeg? = null // 指代/省略,不进入译文
private var transSegTimer: Runnable? = null val ctx = StringBuilder()
for (prev in s.line?.sentences.orEmpty()) {
private fun armSegTimer() { if (prev === s) break
transSegTimer?.let { uiHandler.removeCallbacks(it) } if (prev.text.isNotBlank()) ctx.append(prev.text.trim()).append(' ')
val r = Runnable {
val seg = openTransSeg ?: return@Runnable
if (SystemClock.elapsedRealtime() - seg.lastAddMs > transSegGapMs) {
closeTransSeg()
} }
} var context = ctx.toString().trim()
transSegTimer = r if (context.length > 600) context = context.takeLast(600)
uiHandler.postDelayed(r, transSegGapMs + 500) s.transPending = true
} s.line?.let { notifyLine(it) }
transBusy = true
private fun closeTransSeg() { val srcText = s.text
val seg = openTransSeg ?: return val srcId = s.id
openTransSeg = null
transSegTimer?.let { uiHandler.removeCallbacks(it) }
transSegTimer = null
flushInitialBatch()
val all = seg.sentences.filter { it.text.isNotBlank() }
if (translateDisabled) return
// 单句段:初译成功就不用重翻;失败/缺失则兜底补这一句
val refs = if (all.size < 2)
all.filter { it.trans.isNullOrBlank() } else all
if (refs.isEmpty()) return
if (SummarizePipeline.detectLanguage(refs.joinToString("")) ==
uiLanguage()
) return
translateHandler.post { translateHandler.post {
val map = translateNumbered(refs.map { // 让核:翻译线程永远低于录音/ASR 线程优先级(工程惯例同定稿线程)
Pair(it.id.toString(), it.text) Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND)
}) translateHandler.removeCallbacks(unloadRunnable)
if (map == null || !plausibleTranslations(map.values)) { val tr = translateOne(srcId, srcText, context)
Log.i(TAG, "SEG-TRANSLATE rejected/failed, keep drafts") transBusy = false
return@post
}
uiHandler.post { uiHandler.post {
val touched = HashSet<SubtitleLine>() s.transPending = false
refs.forEach { s2 -> if (tr != null && s.text == srcText) {
val tr = map[s2.id.toString()] s.trans = tr
s.transVisible = true
} else if (tr == null && s.text == srcText) {
Toast.makeText(this, getString(R.string.trans_failed),
Toast.LENGTH_SHORT).show()
}
s.line?.let { notifyLine(it) }
}
translateHandler.postDelayed(unloadRunnable, translateIdleUnloadMs)
}
}
// 翻译输入侧"只认中日英"闸门(点击才触发,确定性字符判定,不问模型):
// ①西里尔/阿拉伯/希伯来/泰文/谚文/片假名区外日韩文等三语外文字 → 拒;
// ②带重音拉丁(à-ż)≥2 视为法/西/葡等欧洲语言(正常英文句极少连用,
// café+naïve 两句才误伤,可接受);③必须含三语正文字符。
private fun sourceLangOk(x: String): Boolean {
var foreign = 0
var diacritic = 0
var native_ = 0
for (c in x) {
val cp = c.code
if (cp in 0x400..0x4FF || cp in 0x600..0x6FF || cp in 0x590..0x5FF ||
cp in 0xE00..0xE7F || cp in 0x3130..0x318F ||
cp in 0xAC00..0xD7AF || cp in 0x1100..0x11FF
) foreign++
if (cp in 0x00C0..0x024F) diacritic++
if (cp in 0x3040..0x30FF || cp in 0x3400..0x4DBF ||
cp in 0x41..0x5A || cp in 0x61..0x7A ||
cp in 0x4E00..0x9FFF || cp in 0xFF66..0xFF9F ||
cp in 0xF900..0xFAFF
) native_++
}
return foreign == 0 && diacritic < 2 && native_ > 0
}
// 原文自身复读探测(ASR 草稿复读时,译文忠实复现不该被退化闸门误杀):
// 任一 4-gram 非重叠出现 ≥3 次即判复读("数据显示,数据显示,数据显示"
// 尾部缺标点,整单元精确折叠匹配不到,故用 gram 计数)。
private fun srcHasRepetition(x: String): Boolean {
if (x.length < 12) return false
val lastPos = HashMap<String, Int>()
val cnt = HashMap<String, Int>()
for (i in 0..x.length - 4) {
val g = x.substring(i, i + 4)
if (g.isBlank()) continue
val lp = lastPos[g]
if (lp == null || i - lp >= 4) {
val n = (cnt[g] ?: 0) + 1
cnt[g] = n
lastPos[g] = i
if (n >= 3) return true
}
}
return false
}
// 1B 主链:genfail/echo 温度递进重试(0→0.3→0.7,末次丢上下文);
// langdrift 一次即弃 → return null 交给升档兜底(1B 的漂移是能力问题,
// 换温度没用);与上次被拒样本逐字相同 = 确定性掉沟,立即止损。
// 译文清洗(2026-09-15):1B 聊天模型爱把"翻訳:"这类答案标签混进译文。
// ①标签独占行("翻訳:\n正文")→ 丢标签行取正文;②同行前缀
// ("翻訳:正文"/"**翻訳**:正文"/引号包裹)→ 反复剥前缀;全空返 null
// (走重试,绝不把标签写上屏)。
private val transLabelLine = Regex(
"^\\s*(以下是?)?\\s*(翻訳|翻译|訳文|译文|translation|translate)" +
"(结果|結果)?\\s*(如下)?\\s*[::—\\-|.。・]*\\s*$",
RegexOption.IGNORE_CASE)
private val transLabelPrefix = Regex(
"^\\s*(\\*\\*|__|#+\\s*)?\\s*[「\"“'『]?\\s*(以下是?)?\\s*(翻訳|翻译|訳文|译文|" +
"translation|translate)\\s*(结果|結果)?\\s*(如下)?\\s*(\\*\\*|__)?\\s*" +
"(は|的|为|:)?\\s*[::—\\-]\\s*[」\"”'』]?\\s*", RegexOption.IGNORE_CASE)
private fun cleanTrans(raw: String?): String? {
if (raw.isNullOrBlank()) return null
val ls = raw.trim().lines().toMutableList()
while (ls.isNotEmpty() && transLabelLine.matches(ls.first().trim()))
ls.removeAt(0)
var s = ls.joinToString("\n").trim()
var prev: String
do {
prev = s
s = transLabelPrefix.replaceFirst(s, "").trim()
} while (s != prev && s.isNotEmpty())
if (s.length >= 2 && ((s.startsWith("「") && s.endsWith("」")) ||
(s.startsWith("\"") && s.endsWith("\"")) ||
(s.startsWith("“") && s.endsWith("”")))
) s = s.substring(1, s.length - 1).trim()
return s.ifBlank { null }
}
// 点击翻译总入口:1B 主链失败 → 升档兜底(摘要引擎,多语言能力强得多)。
// 歌词体/文言中文("最贪富贵的潦倒一生…")超 1B 边界,温度重试无用,
// 唯一解是换更大的模型再试一次。
private fun translateOne(sid: Long, src: String, context: String): String? {
// 时间预算:录音中 45s、空闲 95s。超预算宁可失败(toast 可重试),
// 也不让重试链长时间抢 ASR 的核(饥饿会引发第一遍字幕复读)。
val deadline = SystemClock.elapsedRealtime() +
if (isRecording) 45_000L else 95_000L
translateOne1B(sid, src, context, deadline)?.let { return it }
return translateFallback(sid, src, context, deadline)
}
private fun translateOne1B(
sid: Long, src: String, context: String, deadline: Long,
): String? {
var temp = 0f
var genRetry = 0
var ctx = context
var lastRej = ""
while (true) {
if (SystemClock.elapsedRealtime() > deadline) {
Log.i(TAG, "TAP-MISS sid=$sid reason=budget src=" + src.take(20))
return null
}
val map = translateNumbered(
listOf(Pair(sid.toString(), src)), temp, ctx)
val raw = map?.get(sid.toString())?.trim()
val srcTag = " src=" + SummarizePipeline.detectLanguage(src) +
":" + src.take(20).replace("\n", " ")
// 译文内部复读治理(2026-09-15):1B 批译长句会把某个译文段复制
// 多份(拼回后"大量重复某一句")。先折叠;折叠即腰斩 → 判退化。
// 但先对照原文:ASR 自己复读的句子("数据显示,数据显示,数据显示"),
// 译文忠实复现不算退化 → 原文也有同等复读时放行原文不折叠。
val tr = when {
raw == null -> null
else -> {
val c = collapseRepetitions(raw)
if (c.length * 2 >= raw.length || raw.length < 16) c
else if (srcHasRepetition(src) &&
raw.length <= src.length * 4 + 16
) {
Log.i(TAG, "TAP-FAITHFUL sid=$sid src-self-repeat")
raw
} else null
}
}
when { when {
tr == null || tr.isBlank() -> tr.isNullOrBlank() -> {
Log.i(TAG, "MISS sid=" + s2.id + " reason=seg-empty") Log.i(TAG, "TAP-MISS sid=$sid reason=" +
isEcho(s2.text, tr, echoJaccardTh(s2.text)) -> (if (raw != null) "repeat" else "genfail") +
Log.i(TAG, "MISS sid=" + s2.id + " reason=seg-echo") " temp=$temp" + srcTag)
!targetLangOk(tr) -> if (genRetry >= 2) return null
Log.i(TAG, "MISS sid=" + s2.id + " reason=seg-drift" + genRetry++; temp = if (temp == 0f) 0.3f else 0.7f
" sample=" + tr.take(24).replace("\n", " ")) if (genRetry >= 2) ctx = ""
else -> if (s2.trans != tr) { }
s2.trans = tr isEcho(src, tr, echoJaccardTh(src)) -> {
s2.line?.let { touched.add(it) } val es = tr.replace("\n", " ")
Log.i(TAG, "TAP-MISS sid=$sid reason=echo temp=$temp" + srcTag)
if (es == lastRej) return null // 复读成惯性,换温度无用
lastRej = es
if (genRetry >= 2) return null
genRetry++; temp = if (temp == 0f) 0.3f else 0.7f
if (genRetry >= 2) ctx = ""
}
!targetLangOk(tr) -> {
val sample = tr.replace("\n", " ")
Log.i(TAG, "TAP-MISS sid=$sid reason=langdrift temp=$temp" +
" sample=" + sample.take(24) + srcTag)
if (sample == lastRej) return null
lastRej = sample
// 漂移一次即弃治疗直接升档:1B 对中文口语/文学体的
// zh→ja 是系统性弱项(繁体+脑补续写),温度递进只是
// 在同一口浅井里打水,不如省 20s 交给兜底引擎
return null
}
else -> return tr
} }
} }
} }
touched.forEach { notifyLine(it) }
Log.i(TAG, "SEG-TRANSLATE applied " + refs.size + " sentences") // 升档兜底:1B 全链失败后,用摘要引擎(E2B/MiniCPM5,已随纪要常驻外部
// 目录)低温再译一次。临时引擎用完即 release(KV 污染协议);预算将尽
// (剩不到 8s)不启用。产物过同样的 echo/drift 质检,宁缺毋滥。
private fun translateFallback(
sid: Long, src: String, context: String, deadline: Long,
): String? {
if (SystemClock.elapsedRealtime() > deadline - 8_000) {
Log.i(TAG, "TAP-FALLBACK sid=$sid skipped=budget")
return null
}
val gguf = File(File(externalModelDir, summarizeModelDirName),
summarizeGgufName)
if (!gguf.isFile) {
Log.i(TAG, "TAP-FALLBACK sid=$sid skipped=no-model " +
gguf.absolutePath)
return null
}
var e: LlmEngine? = null
return try {
e = LlmEngine(gguf.absolutePath, SUMMARY_CTX,
if (isRecording) SUMMARY_THREADS_BUSY else SUMMARY_THREADS,
chatTemplate = summarizeChatTemplate)
val out = e.generate(
systemPrompt = "你是专业翻译。把给出的句子翻译成" + transTargetName() +
"。只输出译文本身,不要解释、不要引号、不要抄写原文," +
"不要以「翻訳:」等任何标签开头," +
"禁止道歉或说明无法完成,翻不了就输出空字符串。" +
"文学性表达按原文风格意译,保留重复与排比。" +
transLangHint() + "。",
userPrompt = transCtxBlock(context) + src,
maxNewTokens = (src.length * 2 + 96).coerceAtMost(1200),
temperature = 0.2f, repeatPenalty = 1.1f,
)
val tr = cleanTrans(out)
when {
tr.isNullOrBlank() || isEcho(src, tr, echoJaccardTh(src)) ||
!targetLangOk(tr) -> {
Log.i(TAG, "TAP-FALLBACK sid=$sid rejected src=" + src.take(20))
null
}
else -> {
Log.i(TAG, "TAP-FALLBACK sid=$sid applied")
tr
} }
} }
} catch (t: Throwable) {
Log.w(TAG, "TAP-FALLBACK sid=$sid error: ${t.message}")
null
} finally {
e?.let { runCatching { it.release() } }
} }
}
// 翻译目标语名/提示语/上下文块(1B 与兜底两路共用)
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)"
}
private fun transCtxBlock(context: String): String =
if (context.isBlank()) "" else
"【参考上下文(不要翻译)】\n" + context + "\n\n【需要翻译的句子】\n"
// 容错提取 "编号":"译文" 对:顺序扫描,不要求 JSON 完整闭合—— // 容错提取 "编号":"译文" 对:顺序扫描,不要求 JSON 完整闭合——
// 生成被 maxNewTokens 截断时,已完成的条目仍能救出 // 生成被 maxNewTokens 截断时,已完成的条目仍能救出
@ -2015,11 +2270,14 @@ class MainActivity : AppCompatActivity() {
private fun ensureTranslateEngine(): LlmEngine? { private fun ensureTranslateEngine(): LlmEngine? {
val f = File(File(externalModelDir, translateModelDirName), translateGgufName) val f = File(File(externalModelDir, translateModelDirName), translateGgufName)
if (!f.isFile) { if (!f.isFile) {
Log.i(TAG, "translate model missing at ${f.absolutePath}; bilingual off") Log.i(TAG, "translate model missing at ${f.absolutePath}; tap-translate off")
return null return null
} }
return try { return try {
LlmEngine(f.absolutePath, 4096, 2, chatTemplate = "gemma3") // 录音中降载:1 线程给第一遍/第二遍 ASR 让核(实测:满线程重建风暴
// 会让 nemotron 饥饿→字幕复读某一小句);空闲时才用 2 线程
LlmEngine(f.absolutePath, 4096, if (isRecording) 1 else 2,
chatTemplate = "gemma3")
.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}")
@ -2027,95 +2285,20 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun armInitialBatchTimer() {
initialBatchTimer?.let { uiHandler.removeCallbacks(it) }
val r = Runnable { flushInitialBatch() }
initialBatchTimer = r
uiHandler.postDelayed(r, initialBatchDelayMs)
}
private fun flushInitialBatch() {
initialBatchTimer?.let { uiHandler.removeCallbacks(it) }
initialBatchTimer = null
if (initialPending.isEmpty() || translateDisabled) return
val batch = ArrayList(initialPending)
initialPending.clear()
translateHandler.post { runTranslateBatch(batch, 0f) }
}
// 翻译线程执行:直出/编号批 → 逐条质检(空/复读/语言漂移)→ 主线程应用。
// 生成失败或语言漂移 → 温度递进重试(每句最多 2 次,translateAttempts 仅在本线程读写)。
private fun runTranslateBatch(batch: List<Pair<Long, String>>, temp: Float) {
val map = translateNumbered(
batch.map { Pair(it.first.toString(), it.second) }, temp
)
if (map == null || !plausibleTranslations(map.values)) {
val retry = ArrayList<Pair<Long, String>>()
for ((sid, src) in batch) {
val att = bumpAttempt(sid)
Log.i(TAG, "MISS sid=" + sid + " reason=genfail attempt=" + att)
if (att <= 2) retry.add(Pair(sid, src))
}
if (retry.isNotEmpty()) {
translateHandler.post {
runTranslateBatch(retry, if (temp < 0.5f) 0.3f else 0.7f)
}
}
return
}
val good = HashMap<Long, String>()
val drift = ArrayList<Pair<Long, String>>()
for ((sid, src) in batch) {
val tr = map[sid.toString()]
when {
tr == null || tr.isBlank() ->
Log.i(TAG, "MISS sid=" + sid + " reason=empty")
isEcho(src, tr, echoJaccardTh(src)) ->
Log.i(TAG, "MISS sid=" + sid + " reason=echo")
!targetLangOk(tr) -> {
val att = bumpAttempt(sid)
Log.i(TAG, "MISS sid=" + sid + " reason=langdrift attempt=" +
att + " sample=" + tr.take(24).replace("\n", " "))
if (att <= 1) drift.add(Pair(sid, src))
}
else -> good[sid] = tr
}
}
uiHandler.post {
val touched = HashSet<SubtitleLine>()
good.forEach { (sid, tr) ->
findSentence(sid)?.let { s2 ->
if (s2.trans != tr) {
s2.trans = tr
s2.line?.let { touched.add(it) }
}
}
}
touched.forEach { notifyLine(it) }
}
if (drift.isNotEmpty()) translateHandler.post {
runTranslateBatch(drift, 0.3f)
}
}
// 一批 = 一个全新引擎(nativeGenerate 不清 KV 的 workaround)。 // 一批 = 一个全新引擎(nativeGenerate 不清 KV 的 workaround)。
// items: key(通常是 sid 字符串) × 原文;返回 key × 译文,失败 null。 // items: key(通常是 sid 字符串) × 原文;context: 可选的同轮参考上下文
// (只帮助消解指代,不进译文);返回 key × 译文,失败 null。
private fun translateNumbered( private fun translateNumbered(
items: List<Pair<String, String>>, temp: Float = 0f, items: List<Pair<String, String>>, temp: Float = 0f,
context: String = "",
): Map<String, String>? { ): Map<String, String>? {
if (items.isEmpty()) return emptyMap() if (items.isEmpty()) return emptyMap()
val targetName = when (uiLanguage()) { val targetName = transTargetName()
"ja" -> "日本語"; "en" -> "English"; else -> "简体中文" val langHint = transLangHint()
} val ctxBlock = transCtxBlock(context)
val langHint = when (uiLanguage()) {
"ja" -> "译文必须是日语(须含假名,不要输出中文或繁体)"
"zh" -> "译文必须是简体中文(不得用繁体字,不要输出日语或英语)"
else -> "The translation must be English only (no CJK characters)"
}
translateEngine?.let { runCatching { it.release() } } translateEngine?.let { runCatching { it.release() } }
translateEngine = null translateEngine = null
val engine = ensureTranslateEngine() val engine = ensureTranslateEngine() ?: return null
?: run { translateDisabled = true; return null }
// 长句拆段:小模型对短输入才稳定;长句译到中途会"掉语言" // 长句拆段:小模型对短输入才稳定;长句译到中途会"掉语言"
// (简体→繁体→别的语言)。拆 ≤140 字段一次 generate,段序拼回。 // (简体→繁体→别的语言)。拆 ≤140 字段一次 generate,段序拼回。
val segKeys = ArrayList<Pair<String, String>>() val segKeys = ArrayList<Pair<String, String>>()
@ -2135,16 +2318,19 @@ class MainActivity : AppCompatActivity() {
val one = try { val one = try {
engine.generate( engine.generate(
systemPrompt = "你是专业翻译。把给出的句子翻译成" + targetName + systemPrompt = "你是专业翻译。把给出的句子翻译成" + targetName +
"。只输出译文本身,不要解释、不要引号、不要抄写原文。" + "。只输出译文本身,不要解释、不要引号、不要抄写原文," +
"不要以「翻訳:」等任何标签开头," +
"禁止道歉或说明无法完成,翻不了就输出空字符串。" +
"你只在中文、日本語、English 三种语言之间翻译;" + "你只在中文、日本語、English 三种语言之间翻译;" +
"输入不属于这三种语言时输出空字符串。" + langHint + "。", "输入不属于这三种语言时输出空字符串。" + langHint + "。",
userPrompt = segKeys[0].second, userPrompt = ctxBlock + segKeys[0].second,
maxNewTokens = budget, temperature = temp, maxNewTokens = budget, temperature = temp,
repeatPenalty = 1.1f,
) )
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(TAG, "TRANSLATE single failed: " + t.message); null Log.w(TAG, "TRANSLATE single failed: " + t.message); null
} }
val tr = one?.trim()?.lines()?.firstOrNull { it.isNotBlank() }?.trim() val tr = cleanTrans(one)
if (!tr.isNullOrEmpty()) got[segKeys[0].first] = tr if (!tr.isNullOrEmpty()) got[segKeys[0].first] = tr
if (got.isEmpty()) return null if (got.isEmpty()) return null
} else { } else {
@ -2158,17 +2344,20 @@ class MainActivity : AppCompatActivity() {
"只输出JSON对象,键是编号字符串,值是译文," + "只输出JSON对象,键是编号字符串,值是译文," +
"例如 {\"1\":\"…\",\"2\":\"…\"}。不得增删编号。" + "例如 {\"1\":\"…\",\"2\":\"…\"}。不得增删编号。" +
"严禁把原文照抄为译文;无法翻译的段值输出空字符串。" + "严禁把原文照抄为译文;无法翻译的段值输出空字符串。" +
"你只在中文、日本語、English 三种语言之间翻译。" + "你只在中文、日本語、English 三种语言之间翻译;" +
"段不属于这三种语言时其值输出空字符串。" +
langHint + "。", langHint + "。",
userPrompt = "【文本】\n" + numbered, userPrompt = ctxBlock + "【文本】\n" + numbered,
maxNewTokens = budget, temperature = temp, maxNewTokens = budget, temperature = temp,
repeatPenalty = 1.1f,
) )
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(TAG, "TRANSLATE batch failed: " + t.message); null Log.w(TAG, "TRANSLATE batch failed: " + t.message); null
} }
val byIdx = extractNumberedPairs(out) val byIdx = extractNumberedPairs(out)
segKeys.forEachIndexed { i, st -> segKeys.forEachIndexed { i, st ->
byIdx?.get((i + 1).toString())?.takeIf { it.isNotBlank() } byIdx?.get((i + 1).toString())?.let { cleanTrans(it) }
?.takeIf { it.isNotBlank() }
?.let { got[st.first] = it } ?.let { got[st.first] = it }
} }
// 模型对编号批会"犯懒跳号"(只写最后一段等)→ 缺失段逐一直出 // 模型对编号批会"犯懒跳号"(只写最后一段等)→ 缺失段逐一直出
@ -2184,8 +2373,11 @@ class MainActivity : AppCompatActivity() {
val one = try { val one = try {
e2.generate( e2.generate(
systemPrompt = "你是专业翻译。把给出的文本翻译成" + systemPrompt = "你是专业翻译。把给出的文本翻译成" +
targetName + "。只输出译文本身,不要解释。" + targetName + "。只输出译文本身,不要解释," +
"你只在中文、日本語、English 三种语言之间翻译。" + "不要以「翻訳:」等任何标签开头," +
"禁止道歉或说明无法完成,翻不了就输出空字符串。" +
"你只在中文、日本語、English 三种语言之间翻译;" +
"输入不属于这三种语言时输出空字符串。" +
langHint + "。", langHint + "。",
userPrompt = st.second, userPrompt = st.second,
maxNewTokens = (st.second.length * 2 + 96) maxNewTokens = (st.second.length * 2 + 96)
@ -2193,9 +2385,7 @@ class MainActivity : AppCompatActivity() {
temperature = 0f, temperature = 0f,
) )
} catch (t: Throwable) { null } } catch (t: Throwable) { null }
one?.trim()?.lines()?.firstOrNull { it.isNotBlank() } cleanTrans(one)?.let { got[st.first] = it }
?.trim()?.takeIf { it.isNotEmpty() }
?.let { got[st.first] = it }
} }
} }
if (got.isEmpty()) return null if (got.isEmpty()) return null
@ -2205,6 +2395,18 @@ class MainActivity : AppCompatActivity() {
val n = segCount[key] val n = segCount[key]
if (n == null) got[key]?.let { res[key] = it } if (n == null) got[key]?.let { res[key] = it }
else { 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() val sb = StringBuilder()
var full = true var full = true
for (i in 0 until n) { for (i in 0 until n) {
@ -2219,12 +2421,6 @@ class MainActivity : AppCompatActivity() {
return if (res.isEmpty()) null else res return if (res.isEmpty()) null else res
} }
// 复读/退化检测:一半以上译文雷同 → 判退化,保留旧译文
private fun plausibleTranslations(vals: Collection<String>): Boolean {
val v = vals.filter { it.isNotBlank() }
return v.size < 2 || v.distinct().size * 2 >= v.size
}
// 复读检测:译文与原文归一化后全等/长串包含/字符二元组 Jaccard≥0.72 // 复读检测:译文与原文归一化后全等/长串包含/字符二元组 Jaccard≥0.72
// → 判为"把原文抄进译文",该条丢弃(维持原状),不写入字幕 // → 判为"把原文抄进译文",该条丢弃(维持原状),不写入字幕
private fun normalizeForEcho(x: String): String = private fun normalizeForEcho(x: String): String =
@ -2266,7 +2462,11 @@ class MainActivity : AppCompatActivity() {
// 目标语健全检查(治"译着译着变繁体/变第三种语言"): // 目标语健全检查(治"译着译着变繁体/变第三种语言"):
// ja 必须含足量假名;zh 不得有假名且繁体专用字 ≤1;en 不得含 CJK。 // ja 必须含足量假名;zh 不得有假名且繁体专用字 ≤1;en 不得含 CJK。
// 带重音拉丁(à-ż 区段,如 população)→ 西/葡等语种漂移信号:中日目标
// ≥2 个即拒;en 目标允许 ≤2(café/São Paulo 类专名),再多判漂移。
private val tradOnlyChars = "們這來說對學體豐華讓裡國變顯點單據觀" private val tradOnlyChars = "們這來說對學體豐華讓裡國變顯點單據觀"
private fun diacriticLatin(x: String): Int =
x.count { it.code in 0x00C0..0x024F }
private fun targetLangOk(tr: String): Boolean { private fun targetLangOk(tr: String): Boolean {
// 三语以外文字零容忍(漂移成俄语/韩语/阿语/泰语等时任何目标语都不该放行) // 三语以外文字零容忍(漂移成俄语/韩语/阿语/泰语等时任何目标语都不该放行)
tr.forEach { c -> tr.forEach { c ->
@ -2276,6 +2476,8 @@ class MainActivity : AppCompatActivity() {
cp in 0xAC00..0xD7AF || cp in 0x1100..0x11FF cp in 0xAC00..0xD7AF || cp in 0x1100..0x11FF
) return false ) return false
} }
if (diacriticLatin(tr) >= if (uiLanguage() == "en") 3 else 2)
return false
if (tr.length < 12) return true if (tr.length < 12) return true
return when (uiLanguage()) { return when (uiLanguage()) {
"ja" -> { "ja" -> {
@ -2313,9 +2515,10 @@ class MainActivity : AppCompatActivity() {
return union > 0 && inter.toDouble() / union >= th return union > 0 && inter.toDouble() / union >= th
} }
// 闲置卸载:最后一次点击翻译 60s 后释放引擎(在途翻译由 transBusy 挡)
private val unloadRunnable = Runnable { private val unloadRunnable = Runnable {
val e = translateEngine ?: return@Runnable val e = translateEngine ?: return@Runnable
if (initialPending.isEmpty()) { if (!transBusy) {
translateEngine = null translateEngine = null
translateHandler.post { runCatching { e.release() } } translateHandler.post { runCatching { e.release() } }
} }

View File

@ -50,6 +50,13 @@
<string name="sum_generating">Minutes in progress — tap again to cancel</string> <string name="sum_generating">Minutes in progress — tap again to cancel</string>
<string name="sum_no_abi">This device ABI lacks the summarizer runtime (arm64-v8a only)</string> <string name="sum_no_abi">This device ABI lacks the summarizer runtime (arm64-v8a only)</string>
<string name="sum_model_missing">Summarizer model missing. Run:\n%1$s</string> <string name="sum_model_missing">Summarizer model missing. Run:\n%1$s</string>
<!-- Tap-to-translate -->
<string name="trans_pending">Translating…</string>
<string name="trans_busy">Please wait for the current translation</string>
<string name="trans_already">This sentence is already in your language</string>
<string name="trans_failed">Translation failed — tap the sentence again to retry</string>
<string name="trans_not_3lang">Only zh/ja/en translation is supported; this sentence is in another language</string>
<string name="trans_no_model">Translator model missing. Run:\n%1$s</string>
<string name="sum_loading">Loading summarizer model (%1$s)…</string> <string name="sum_loading">Loading summarizer model (%1$s)…</string>
<string name="sum_stage_notes">notes</string> <string name="sum_stage_notes">notes</string>
<string name="sum_stage_draft">drafting</string> <string name="sum_stage_draft">drafting</string>

View File

@ -50,6 +50,13 @@
<string name="sum_generating">議事録を生成中です。もう一度押すと中止できます</string> <string name="sum_generating">議事録を生成中です。もう一度押すと中止できます</string>
<string name="sum_no_abi">この端末のABIには要約ランタイムが含まれていません(arm64-v8aのみ)</string> <string name="sum_no_abi">この端末のABIには要約ランタイムが含まれていません(arm64-v8aのみ)</string>
<string name="sum_model_missing">要約モデルがありません。実行してください:\n%1$s</string> <string name="sum_model_missing">要約モデルがありません。実行してください:\n%1$s</string>
<!-- タップ翻訳 -->
<string name="trans_pending">翻訳中…</string>
<string name="trans_busy">現在の翻訳の完了をお待ちください</string>
<string name="trans_already">この文は表示言語と同じです</string>
<string name="trans_failed">翻訳に失敗しました。もう一度タップしてください</string>
<string name="trans_not_3lang">中日英間の翻訳のみ対応。この文は該当言語ではありません</string>
<string name="trans_no_model">翻訳モデルがありません。実行してください:\n%1$s</string>
<string name="sum_loading">要約モデル(%1$s)を読み込み中…</string> <string name="sum_loading">要約モデル(%1$s)を読み込み中…</string>
<string name="sum_stage_notes">メモ更新</string> <string name="sum_stage_notes">メモ更新</string>
<string name="sum_stage_draft">清書</string> <string name="sum_stage_draft">清書</string>

View File

@ -51,6 +51,13 @@
<string name="sum_generating">纪要正在生成中,再点一次可取消</string> <string name="sum_generating">纪要正在生成中,再点一次可取消</string>
<string name="sum_no_abi">本机 ABI 未包含摘要运行时库(仅 arm64-v8a 真机可用)</string> <string name="sum_no_abi">本机 ABI 未包含摘要运行时库(仅 arm64-v8a 真机可用)</string>
<string name="sum_model_missing">缺少摘要模型。请执行:\n%1$s</string> <string name="sum_model_missing">缺少摘要模型。请执行:\n%1$s</string>
<!-- 点击字幕句翻译 -->
<string name="trans_pending">翻译中…</string>
<string name="trans_busy">请等待当前翻译完成</string>
<string name="trans_already">该句已是当前界面语言</string>
<string name="trans_failed">翻译失败,可再次点击重试</string>
<string name="trans_not_3lang">仅支持中/日/英互译,该句不属于这三种语言</string>
<string name="trans_no_model">缺少翻译模型。请执行:\n%1$s</string>
<string name="sum_loading">正在加载摘要模型(%1$s)…</string> <string name="sum_loading">正在加载摘要模型(%1$s)…</string>
<string name="sum_stage_notes">滚动笔记</string> <string name="sum_stage_notes">滚动笔记</string>
<string name="sum_stage_draft">成稿</string> <string name="sum_stage_draft">成稿</string>

View File

@ -154,7 +154,8 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeSetThreads(
JNIEXPORT jstring JNICALL 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) {
auto * e = (Engine *) (uintptr_t) ptr; auto * e = (Engine *) (uintptr_t) ptr;
if (e == nullptr || e->ctx == nullptr) { if (e == nullptr || e->ctx == nullptr) {
@ -214,7 +215,9 @@ 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); // fixed seed => reproducible minutes across runs std::mt19937 rng(42 + (uint32_t) (temperature * 1000.0f));
// seed 随温度错位:温度递进的重试不再抽同一条随机序列(此前固定 42,
// 0.3 重试与 0.0 贪心几乎同果,"跳出死路"名存实亡)
std::string out; std::string out;
out.reserve(1024); out.reserve(1024);
std::vector<char> piece(64); std::vector<char> piece(64);
@ -223,6 +226,12 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
const bool greedy = !(temperature > 0.0f); const bool greedy = !(temperature > 0.0f);
const int k = topK > 0 ? topK : 64; // Gemma 4 recommended: 64 const int k = topK > 0 ? topK : 64; // Gemma 4 recommended: 64
const float p = topP > 0.0f ? topP : 0.95f; // recommended: ~0.95 const float p = topP > 0.0f ? topP : 0.95f; // recommended: ~0.95
// Repeat penalty (llama.cpp style, unique-token window): 1B 模型在垃圾/
// 歧义输入上极易进入复读循环。pen<=0 时禁用(摘要路径保持原行为),
// 翻译路径传 1.1。只惩罚本步已生成过的 token,窗口=最近 64 个生成 token。
const bool usePen = repeatPenalty > 1.0f;
std::vector<llama_token> recent;
std::vector<bool> penalized; // scratch for unique marking
for (int i = 0; i < maxNew; ++i) { for (int i = 0; i < maxNew; ++i) {
if (e->abort.load()) break; if (e->abort.load()) break;
@ -234,6 +243,21 @@ 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);
if (usePen && !recent.empty()) {
if ((int) recent.size() > 64)
recent.erase(recent.begin(), recent.begin() + (recent.size() - 64));
if (penalized.size() < (size_t) vsize)
penalized.assign((size_t) vsize + 4096, false);
for (llama_token t : recent) {
if (t >= 0 && t < vsize && !penalized[t]) {
penalized[t] = true;
float & lg = logits[t];
lg = lg < 0 ? lg * repeatPenalty : lg / repeatPenalty;
}
}
for (llama_token t : recent) if (t >= 0 && t < vsize) penalized[t] = false;
}
// pick next token // pick next token
llama_token next; llama_token next;
if (greedy) { if (greedy) {
@ -286,6 +310,7 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate(
} }
} }
cur = next; cur = next;
if (usePen) recent.push_back(next);
} }
LOGI("generated %d chars", (int) out.size()); LOGI("generated %d chars", (int) out.size());