diff --git a/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt b/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt index d9d947d..f5f61fe 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/LlmEngine.kt @@ -113,6 +113,8 @@ class LlmEngine( /** * 阻塞式生成(仅供摘要后台线程调用)。返回模型输出文本(不含输入)。 * temperature=0 为 Greedy。abort() 可从其它线程安全打断。 + * repeatPenalty>1 启用重复惩罚(翻译路径传 1.1 治 1B 复读循环; + * 摘要路径保持默认 0=关闭,行为与已验证版本一致)。 */ fun generate( systemPrompt: String, @@ -121,11 +123,12 @@ class LlmEngine( temperature: Float = 0f, topK: Int = 64, topP: Float = 0.95f, + repeatPenalty: Float = 0f, ): String { require(ptr != 0L) { "LlmEngine already released" } return nativeGenerate( ptr, renderChatPrompt(systemPrompt, userPrompt, chatTemplate), - maxNewTokens, temperature, topK, topP + maxNewTokens, temperature, topK, topP, repeatPenalty ) } @@ -155,7 +158,7 @@ class LlmEngine( private external fun nativeGenerate( ptr: Long, prompt: String, maxNewTokens: Int, - temperature: Float, topK: Int, topP: Float + temperature: Float, topK: Int, topP: Float, repeatPenalty: Float ): String private external fun nativeAbort(ptr: Long) diff --git a/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt b/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt index 26aedba..ea8ac62 100644 --- a/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt +++ b/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt @@ -18,6 +18,7 @@ import android.text.Spanned import android.text.TextPaint import android.text.method.LinkMovementMethod import android.text.method.ScrollingMovementMethod +import android.text.style.BackgroundColorSpan import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan import android.text.style.RelativeSizeSpan @@ -84,7 +85,9 @@ class MainActivity : AppCompatActivity() { ) { var line: SubtitleLine? = null var refined: Boolean = false // false => 底稿(第一遍)未被精修覆盖 - var trans: String? = null // 定稿后的界面语言译文(批次二:双语字幕) + var trans: String? = null // 点击翻译缓存(界面语言);原文变化时置空 + var transVisible: Boolean = true // 点击在已缓存译文上收起/展开 + var transPending: Boolean = false // 在途翻译占位(句下显示"翻译中…") } private class SubtitleLine( @@ -157,8 +160,11 @@ class MainActivity : AppCompatActivity() { // models dir (preferred) or at the ROOT of assets/. Download: // https://github.com/k2-fsa/sherpa-onnx/releases/tag/speaker-recongition-models private val speakerModels = listOf( - "3dspeaker_speech_eres2netv2_sv_zh-cn_16k-common.onnx", // more accurate - "3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx", // smaller fallback + // 主选:CAM++ 7.1M,中英训练,3D-Speaker 榜单 EER 优于 eres2net 系, + // 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 val speakerThreshold = 0.5f @@ -203,6 +209,13 @@ class MainActivity : AppCompatActivity() { private val judgeMinSamples = 48000 private val enableRollingSwitch = true // A/B 诊断:关掉=只用自然 endpoint 断句 @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(最新者胜):每次投递滚动检测都刷新序号,队列里被超额的旧任务直接跳过 @Volatile private var rollingSeq = 0L // 兜底残料:当前句子槽位最近一次滚动窗口声纹。整句判定因碎片算不出 @@ -295,34 +308,21 @@ class MainActivity : AppCompatActivity() { // generate 阻塞在 native,取消靠 abort;引擎只在摘要线程持有 @Volatile private var summarizeEngine: LlmEngine? = null - // ---- 字幕双语翻译(批次二,2026-09-14)-------------------------------- - // 独立翻译模型:Gemma-3-1B-it Q4_K_M(arch gemma3,.so 已支持,模板分支 - // renderGemma3Prompt 现成)。定稿后句级翻译,原文+小字译文双语显示; - // 界面语言与句子语言一致时不翻;模型文件缺失 → 功能静默关闭(自然降级)。 + // ---- 点击字幕句翻译(原"批次二自动双语",2026-09-15 改造)----------------- + // 独立翻译模型:Gemma-3-1B-it Q4_K_M(arch gemma3,模板 renderGemma3Prompt)。 + // 不再自动翻译:点击某句 → 带同轮上下文译该句,译文行内小字斜体挂在句下, + // 再点收起(缓存于 Sentence.trans,原文变化时失效)。首次点击懒加载引擎, + // 每次翻译按防污染协议重建引擎;闲置 60s 自动卸载。模型文件缺失 → toast 提示。 private val translateModelDirName = "gemma-3-1b-it" private val translateGgufName = "gemma-3-1b-it-Q4_K_M.gguf" private val translateIdleUnloadMs = 60_000L private val translateThread = HandlerThread("translate").also { it.start() } private val translateHandler by lazy { Handler(translateThread.looper) } @Volatile private var translateEngine: LlmEngine? = null - private var translateDisabled = false - // KV 污染 workaround(2026-09-14,不改 .so):nativeGenerate 不清 KV, - // 同引擎连续 generate 会复读上一条 → 每"批"generate 前重建引擎。 - // 初译从逐句改为小批量编号翻译(凑 4 句或 2.5s 定时封批)。 - private val initialBatchMax = 1 // 逐句直出:多句JSON批的格式/复读风险一并消除 - private val initialBatchDelayMs = 2_500L - private val initialPending = ArrayList>() - private var initialBatchTimer: Runnable? = null - private val translatedSids = HashSet() // 每槽只翻一次 - // 失败重试预算:每句最多 2 次,温度递进(0→0.3→0.7),贪心死路可被跳出。 - // 仅在翻译线程读写,无并发。 - private val translateAttempts = HashMap() - - private fun bumpAttempt(sid: Long): Int { - val n = (translateAttempts[sid] ?: 0) + 1 - translateAttempts[sid] = n - return n - } + // 一次只允许一个在途翻译(排队期间后续点击 toast"请稍候") + @Volatile private var transBusy = false + // 已翻译句子的淡琥珀背景高亮(约13%透明度,深浅主题皆可读) + private val transHighlightColor = Color.parseColor("#22FFA000") companion object { private val SUMMARY_THREADS = 6 // 空闲时:大核全用 @@ -518,6 +518,8 @@ class MainActivity : AppCompatActivity() { windowRefEmb = null windowPrevEmb = null rollingLowHits = 0 + rollingLowStreak = 0 + pendingForceEndpoint = false checkSerial++ activeLabelForWindow = null lastConfirmedLabel = null @@ -527,15 +529,6 @@ class MainActivity : AppCompatActivity() { sessionTranscript?.close() sessionTranscript = SessionTranscript.begin(filesDir) } - // 预热翻译引擎:把模型文件读进 OS 页缓存后即释放(每批仍按防污染 - // 协议重建),消除开头几批的冷加载失败 - translateHandler.post { - val e = ensureTranslateEngine() - if (e != null) { - translateEngine = null - runCatching { e.release() } - } - } recordingThread = thread(true) { processSamples() } @@ -545,7 +538,6 @@ class MainActivity : AppCompatActivity() { /** 暂停:麦克风停,会话不死——录音线程退出时软 endpoint 定格在途句, * 精修/声纹判定回来照常修正字幕,标签命名可用。 */ private fun pauseSession() { - closeTransSeg() isRecording = false pauseStartedAt = SystemClock.elapsedRealtime() runCatching { audioRecord?.stop() } // 不 release,继续时复用 @@ -573,7 +565,6 @@ class MainActivity : AppCompatActivity() { /** 停止:整场会议结束(转写保留,可继续生成纪要)。 */ private fun stopSession() { - closeTransSeg() if (isRecording) { isRecording = false runCatching { audioRecord?.stop() } @@ -672,6 +663,7 @@ class MainActivity : AppCompatActivity() { windowRefEmb = emb windowPrevEmb = emb rollingLowHits = 0 + rollingLowStreak = 0 } else { val simA = SpeakerRecognition.similarity(emb, anchor) val simP = if (prev == null) simA else @@ -682,6 +674,7 @@ class MainActivity : AppCompatActivity() { " drop=$speakerSwitchDrop hits=$rollingLowHits") if (simA >= speakerSwitchDrop) { // 同人缓漂:高置信窗才吸收进锚点(P3 防漂移) + rollingLowStreak = 0 if (simA >= emaAbsorbMin) { windowRefEmb = blendEmb(anchor, emb) } @@ -690,10 +683,12 @@ class MainActivity : AppCompatActivity() { } else if (simP >= emaAbsorbMin) { // 只偏离锚点、紧贴上一窗 → 音量/情绪渐变, // 更新双参考,不换行 + rollingLowStreak = 0 windowRefEmb = blendEmb(anchor, emb) windowPrevEmb = emb rollingLowHits = 0 } else { + rollingLowStreak += 1 rollingLowHits += 1 if (rollingLowHits >= switchStrikesNeeded) { rollingLowHits = 0 @@ -706,6 +701,7 @@ class MainActivity : AppCompatActivity() { active != null && newLabel == active -> { windowRefEmb = blendEmb(anchor, emb) + rollingLowStreak = 0 windowPrevEmb = emb } // 当前行未归属而窗匹配到已知人 → @@ -721,6 +717,15 @@ class MainActivity : AppCompatActivity() { SystemClock.elapsedRealtime() Log.i(TAG, "SWITCH-SUSPECT" + " 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)即触发结算 - val fastEp = switchSuspicionAtMs != 0L && + // 路线A:疑似换人后的第一个自然停顿(≥0.3s)即触发结算; + // 无缝插话确证(连续低分 streak)走 pendingForceEndpoint 直切 + val fastEp = pendingForceEndpoint || + (switchSuspicionAtMs != 0L && SystemClock.elapsedRealtime() - switchSuspicionAtMs < 5_000L && - silenceRun >= silenceNeedSamples + silenceRun >= silenceNeedSamples) val susExpired = switchSuspicionAtMs != 0L && !fastEp && SystemClock.elapsedRealtime() - switchSuspicionAtMs >= 5_000L if (susExpired) { @@ -752,9 +759,13 @@ class MainActivity : AppCompatActivity() { switchSuspicionAtMs = 0L } if (fastEp) { + val forced = pendingForceEndpoint + pendingForceEndpoint = false + rollingLowStreak = 0 switchSuspicionAtMs = 0L 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 @@ -862,6 +873,16 @@ class MainActivity : AppCompatActivity() { if (fastEmb != null) 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) } } } @@ -917,10 +938,12 @@ class MainActivity : AppCompatActivity() { activeSpeakerModel = null } - // E:轻量声纹模型(eres2net base)专供滚动换人检测,推理快 ~4 倍。 + // E:轻量声纹模型(eres2net base,列表末位)专供滚动换人检测,推理快 ~4 倍。 // 找不到该文件(或准确模型本来就是 base)时跳过,滚动检测自动回退准确模型。 + // 注:换 CAM++ 主选后 fast 空间暂仍用 base(独立 registry 镜像,互不污染); + // CAM++ 足够小,后续想让它兼职 fast 只需把末位换成同文件。 private fun initFastSpeakerModel() { - val fastName = speakerModels[1] + val fastName = speakerModels.last() if (activeSpeakerModel == fastName) return // 准确模型已是 base try { SpeakerRecognition.initFastExtractor( @@ -1394,17 +1417,38 @@ class MainActivity : AppCompatActivity() { } }, start, start + head.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } + var prevSubRow = false // 上一句是否渲染过句下行(译文/翻译中占位) for ((i, s) in line.sentences.withIndex()) { if (s.text.isBlank()) continue if (sb.length > start + head.length) { // 历史逐句分行;直播按语言习惯衔接(分支写开:Char/String 混 - // 在 if 表达式里会被推断成 Any,没有匹配的 append 重载) - if (historyMode) sb.append('\n') + // 在 if 表达式里会被推断成 Any,没有匹配的 append 重载)。 + // 上一句带译文/占位子行时必须换行,否则下一句原文黏在译文尾 + if (historyMode || prevSubRow) sb.append('\n') 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()) - // 双语字幕:原文下方小字斜体译文(批次二;颜色沿用行色,整行着色) - 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') val ts = sb.length sb.append(tr) @@ -1412,6 +1456,22 @@ class MainActivity : AppCompatActivity() { Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) sb.setSpan(StyleSpan(Typeface.ITALIC), ts, sb.length, 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( @@ -1472,6 +1532,7 @@ class MainActivity : AppCompatActivity() { windowRefEmb = null windowPrevEmb = null rollingLowHits = 0 + rollingLowStreak = 0 // 新行=新锚点,连续低分从头算 list.add(line) if (!historyMode) { subtitleAdapter.notifyItemInserted(subtitleLines.size - 1) @@ -1531,7 +1592,6 @@ class MainActivity : AppCompatActivity() { // 不会再有精修回来:立即收回句子索引 // (SKIP_SHORT 等情况 submitRefineTask 已写对账日志,不重复记) sentenceById.remove(sid) - requestTranslate(sid, sentence.text) // 底稿即最终文本 → 直接翻译 } sentence.line?.let { notifyLine(it) } autoScroll() @@ -1807,6 +1867,7 @@ class MainActivity : AppCompatActivity() { if (second != null && second != sentence.text) { sentence.text = second sentence.refined = true + sentence.trans = null // 原文已变,旧译文作废(再点击重译) sentence.line?.let { notifyLine(it) } autoScroll() } else { @@ -1816,8 +1877,6 @@ class MainActivity : AppCompatActivity() { // 会话记录器挂钩②(终稿文本):与字幕层看到同一最终结果 sessionTranscript?.onRefined(sid, sentence.text, sentence.refined) } - // 定稿文本已确定(应用或保底稿)→ 排队翻译(批次二:双语字幕) - requestTranslate(sid, sentence.text) } // 滚动窗口查"这是谁":有 fast 模型时查 fast 镜像(0.4 宽松阈值,仅供显示); @@ -1879,105 +1938,301 @@ class MainActivity : AppCompatActivity() { return if (l == "ja" || l == "en") l else "zh" } - private fun requestTranslate(sid: Long, text: String) { - if (text.isBlank()) return - // ---- 轮次分段(换人/静默6s → 封段送二次翻译)---- - findSentence(sid)?.line?.let { line -> - val now = SystemClock.elapsedRealtime() - var seg = openTransSeg - if (seg == null || seg.line !== line || - now - seg.lastAddMs > transSegGapMs - ) { - closeTransSeg() - seg = TransSeg(line) - openTransSeg = seg - } - seg.lastAddMs = now - findSentence(sid)?.let { st -> - if (!seg.sentences.contains(st)) seg.sentences.add(st) - } - armSegTimer() + // ==================== 点击字幕句翻译(2026-09-15 改造)==================== + // 自动翻译已下线。点击定稿句:首次=带同轮上下文译该句,结果缓存到 + // Sentence.trans 挂在句下;再点=收起/展开(不重译)。质量治理沿用: + // 空/复读/漂移拒收 + 温度递进重试、每句重建引擎(KV 污染 workaround)。 + private fun onSubtitleSentenceTap(s: Sentence) { + if (!s.finalized || s.text.isBlank()) return + if (s.transPending) return + s.trans?.let { + s.transVisible = !s.transVisible + s.line?.let { notifyLine(it) } + return } - // ---- 初译(小批量;段末二次翻译会逐句覆盖)---- - if (translateDisabled) return - if (!translatedSids.add(sid)) return // 每槽初译只排一次 - if (SummarizePipeline.detectLanguage(text) == uiLanguage()) return - initialPending.add(Pair(sid, text)) - if (initialPending.size >= initialBatchMax) flushInitialBatch() else armInitialBatchTimer() - } - - // ==================== 轮次分段与二次翻译(2026-09-14)==================== - // 段 = 同一字幕行(同一说话人轮次)内连续定稿的句子。封段时机: - // ①不同行/不同说话人的句子定稿;②段内 6s 无新定稿(静默); - // ③暂停/停止。封段后整段带编号送二次翻译,按编号逐句覆盖初译; - // 单句段不重翻(初译已是全上下文)。 - private class TransSeg(val line: SubtitleLine) { - val sentences = ArrayList() - var lastAddMs = 0L - } - private val transSegGapMs = 6_000L - private var openTransSeg: TransSeg? = null - private var transSegTimer: Runnable? = null - - private fun armSegTimer() { - transSegTimer?.let { uiHandler.removeCallbacks(it) } - val r = Runnable { - val seg = openTransSeg ?: return@Runnable - if (SystemClock.elapsedRealtime() - seg.lastAddMs > transSegGapMs) { - closeTransSeg() - } + if (transBusy) { + Toast.makeText(this, getString(R.string.trans_busy), + Toast.LENGTH_SHORT).show() + return } - transSegTimer = r - uiHandler.postDelayed(r, transSegGapMs + 500) - } - - private fun closeTransSeg() { - val seg = openTransSeg ?: return - 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 + if (SummarizePipeline.detectLanguage(s.text) == uiLanguage()) { + Toast.makeText(this, getString(R.string.trans_already), + Toast.LENGTH_SHORT).show() + return + } + if (!sourceLangOk(s.text)) { + Toast.makeText(this, getString(R.string.trans_not_3lang), + Toast.LENGTH_SHORT).show() + return + } + val gguf = File(File(externalModelDir, translateModelDirName), + translateGgufName) + if (!gguf.isFile) { + Toast.makeText(this, getString(R.string.trans_no_model, + "adb push $translateGgufName ${gguf.parent}/"), + Toast.LENGTH_LONG).show() + return + } + // 同轮上下文:本行中该句之前的句子(最近 ≤600 字),仅帮助模型消解 + // 指代/省略,不进入译文 + val ctx = StringBuilder() + for (prev in s.line?.sentences.orEmpty()) { + if (prev === s) break + if (prev.text.isNotBlank()) ctx.append(prev.text.trim()).append(' ') + } + var context = ctx.toString().trim() + if (context.length > 600) context = context.takeLast(600) + s.transPending = true + s.line?.let { notifyLine(it) } + transBusy = true + val srcText = s.text + val srcId = s.id translateHandler.post { - val map = translateNumbered(refs.map { - Pair(it.id.toString(), it.text) - }) - if (map == null || !plausibleTranslations(map.values)) { - Log.i(TAG, "SEG-TRANSLATE rejected/failed, keep drafts") - return@post - } + // 让核:翻译线程永远低于录音/ASR 线程优先级(工程惯例同定稿线程) + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND) + translateHandler.removeCallbacks(unloadRunnable) + val tr = translateOne(srcId, srcText, context) + transBusy = false uiHandler.post { - val touched = HashSet() - refs.forEach { s2 -> - val tr = map[s2.id.toString()] - when { - tr == null || tr.isBlank() -> - Log.i(TAG, "MISS sid=" + s2.id + " reason=seg-empty") - isEcho(s2.text, tr, echoJaccardTh(s2.text)) -> - Log.i(TAG, "MISS sid=" + s2.id + " reason=seg-echo") - !targetLangOk(tr) -> - Log.i(TAG, "MISS sid=" + s2.id + " reason=seg-drift" + - " sample=" + tr.take(24).replace("\n", " ")) - else -> if (s2.trans != tr) { - s2.trans = tr - s2.line?.let { touched.add(it) } - } - } + s.transPending = false + if (tr != null && s.text == srcText) { + 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() } - touched.forEach { notifyLine(it) } - Log.i(TAG, "SEG-TRANSLATE applied " + refs.size + " sentences") + 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() + val cnt = HashMap() + 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 { + tr.isNullOrBlank() -> { + Log.i(TAG, "TAP-MISS sid=$sid reason=" + + (if (raw != null) "repeat" else "genfail") + + " temp=$temp" + srcTag) + if (genRetry >= 2) return null + genRetry++; temp = if (temp == 0f) 0.3f else 0.7f + if (genRetry >= 2) ctx = "" + } + isEcho(src, tr, echoJaccardTh(src)) -> { + 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 } } } + // 升档兜底: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 完整闭合—— // 生成被 maxNewTokens 截断时,已完成的条目仍能救出 private fun extractNumberedPairs(out: String?): Map? { @@ -2015,11 +2270,14 @@ class MainActivity : AppCompatActivity() { private fun ensureTranslateEngine(): LlmEngine? { val f = File(File(externalModelDir, translateModelDirName), translateGgufName) 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 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 } } catch (t: Throwable) { 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>, temp: Float) { - val map = translateNumbered( - batch.map { Pair(it.first.toString(), it.second) }, temp - ) - if (map == null || !plausibleTranslations(map.values)) { - val retry = ArrayList>() - 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() - val drift = ArrayList>() - 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() - 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)。 - // items: key(通常是 sid 字符串) × 原文;返回 key × 译文,失败 null。 + // items: key(通常是 sid 字符串) × 原文;context: 可选的同轮参考上下文 + // (只帮助消解指代,不进译文);返回 key × 译文,失败 null。 private fun translateNumbered( items: List>, temp: Float = 0f, + context: String = "", ): Map? { if (items.isEmpty()) return emptyMap() - val targetName = when (uiLanguage()) { - "ja" -> "日本語"; "en" -> "English"; else -> "简体中文" - } - val langHint = when (uiLanguage()) { - "ja" -> "译文必须是日语(须含假名,不要输出中文或繁体)" - "zh" -> "译文必须是简体中文(不得用繁体字,不要输出日语或英语)" - else -> "The translation must be English only (no CJK characters)" - } + val targetName = transTargetName() + val langHint = transLangHint() + val ctxBlock = transCtxBlock(context) translateEngine?.let { runCatching { it.release() } } translateEngine = null - val engine = ensureTranslateEngine() - ?: run { translateDisabled = true; return null } + val engine = ensureTranslateEngine() ?: return null // 长句拆段:小模型对短输入才稳定;长句译到中途会"掉语言" // (简体→繁体→别的语言)。拆 ≤140 字段一次 generate,段序拼回。 val segKeys = ArrayList>() @@ -2135,16 +2318,19 @@ class MainActivity : AppCompatActivity() { val one = try { engine.generate( systemPrompt = "你是专业翻译。把给出的句子翻译成" + targetName + - "。只输出译文本身,不要解释、不要引号、不要抄写原文。" + + "。只输出译文本身,不要解释、不要引号、不要抄写原文," + + "不要以「翻訳:」等任何标签开头," + + "禁止道歉或说明无法完成,翻不了就输出空字符串。" + "你只在中文、日本語、English 三种语言之间翻译;" + "输入不属于这三种语言时输出空字符串。" + langHint + "。", - userPrompt = segKeys[0].second, + userPrompt = ctxBlock + segKeys[0].second, maxNewTokens = budget, temperature = temp, + repeatPenalty = 1.1f, ) } catch (t: Throwable) { 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 (got.isEmpty()) return null } else { @@ -2158,18 +2344,21 @@ class MainActivity : AppCompatActivity() { "只输出JSON对象,键是编号字符串,值是译文," + "例如 {\"1\":\"…\",\"2\":\"…\"}。不得增删编号。" + "严禁把原文照抄为译文;无法翻译的段值输出空字符串。" + - "你只在中文、日本語、English 三种语言之间翻译。" + + "你只在中文、日本語、English 三种语言之间翻译;" + + "段不属于这三种语言时其值输出空字符串。" + langHint + "。", - userPrompt = "【文本】\n" + numbered, + userPrompt = ctxBlock + "【文本】\n" + numbered, maxNewTokens = budget, temperature = temp, + repeatPenalty = 1.1f, ) } catch (t: Throwable) { Log.w(TAG, "TRANSLATE batch failed: " + t.message); null } val byIdx = extractNumberedPairs(out) segKeys.forEachIndexed { i, st -> - byIdx?.get((i + 1).toString())?.takeIf { it.isNotBlank() } - ?.let { got[st.first] = it } + byIdx?.get((i + 1).toString())?.let { cleanTrans(it) } + ?.takeIf { it.isNotBlank() } + ?.let { got[st.first] = it } } // 模型对编号批会"犯懒跳号"(只写最后一段等)→ 缺失段逐一直出 // 兜底;每段重建引擎(nativeGenerate 不清 KV,复用必复读) @@ -2184,8 +2373,11 @@ class MainActivity : AppCompatActivity() { val one = try { e2.generate( systemPrompt = "你是专业翻译。把给出的文本翻译成" + - targetName + "。只输出译文本身,不要解释。" + - "你只在中文、日本語、English 三种语言之间翻译。" + + targetName + "。只输出译文本身,不要解释," + + "不要以「翻訳:」等任何标签开头," + + "禁止道歉或说明无法完成,翻不了就输出空字符串。" + + "你只在中文、日本語、English 三种语言之间翻译;" + + "输入不属于这三种语言时输出空字符串。" + langHint + "。", userPrompt = st.second, maxNewTokens = (st.second.length * 2 + 96) @@ -2193,9 +2385,7 @@ class MainActivity : AppCompatActivity() { temperature = 0f, ) } catch (t: Throwable) { null } - one?.trim()?.lines()?.firstOrNull { it.isNotBlank() } - ?.trim()?.takeIf { it.isNotEmpty() } - ?.let { got[st.first] = it } + cleanTrans(one)?.let { got[st.first] = it } } } if (got.isEmpty()) return null @@ -2205,6 +2395,18 @@ class MainActivity : AppCompatActivity() { val n = segCount[key] if (n == null) got[key]?.let { res[key] = it } else { + // 跨段复读:拆段后各段译文本应互不相同;≥2 段同文(且非短句) + // = 模型把一段复制成多段 → 该 key 整条判退化(不装配),让 + // translateOne 的重试机制重建 + val segVals = (0 until n).mapNotNull { got[key + "#s" + it] } + if (segVals.size == n && n >= 2 && + segVals.distinct().size < n && + segVals.first().length >= 16 + ) { + Log.i(TAG, "TRANSLATE key=$key cross-seg repeat " + + n + "→" + segVals.distinct().size) + continue + } val sb = StringBuilder() var full = true for (i in 0 until n) { @@ -2219,12 +2421,6 @@ class MainActivity : AppCompatActivity() { return if (res.isEmpty()) null else res } - // 复读/退化检测:一半以上译文雷同 → 判退化,保留旧译文 - private fun plausibleTranslations(vals: Collection): Boolean { - val v = vals.filter { it.isNotBlank() } - return v.size < 2 || v.distinct().size * 2 >= v.size - } - // 复读检测:译文与原文归一化后全等/长串包含/字符二元组 Jaccard≥0.72 // → 判为"把原文抄进译文",该条丢弃(维持原状),不写入字幕 private fun normalizeForEcho(x: String): String = @@ -2266,7 +2462,11 @@ class MainActivity : AppCompatActivity() { // 目标语健全检查(治"译着译着变繁体/变第三种语言"): // ja 必须含足量假名;zh 不得有假名且繁体专用字 ≤1;en 不得含 CJK。 + // 带重音拉丁(à-ż 区段,如 população)→ 西/葡等语种漂移信号:中日目标 + // ≥2 个即拒;en 目标允许 ≤2(café/São Paulo 类专名),再多判漂移。 private val tradOnlyChars = "們這來說對學體豐華讓裡國變顯點單據觀" + private fun diacriticLatin(x: String): Int = + x.count { it.code in 0x00C0..0x024F } private fun targetLangOk(tr: String): Boolean { // 三语以外文字零容忍(漂移成俄语/韩语/阿语/泰语等时任何目标语都不该放行) tr.forEach { c -> @@ -2276,6 +2476,8 @@ class MainActivity : AppCompatActivity() { cp in 0xAC00..0xD7AF || cp in 0x1100..0x11FF ) return false } + if (diacriticLatin(tr) >= if (uiLanguage() == "en") 3 else 2) + return false if (tr.length < 12) return true return when (uiLanguage()) { "ja" -> { @@ -2313,9 +2515,10 @@ class MainActivity : AppCompatActivity() { return union > 0 && inter.toDouble() / union >= th } + // 闲置卸载:最后一次点击翻译 60s 后释放引擎(在途翻译由 transBusy 挡) private val unloadRunnable = Runnable { val e = translateEngine ?: return@Runnable - if (initialPending.isEmpty()) { + if (!transBusy) { translateEngine = null translateHandler.post { runCatching { e.release() } } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 4180b40..d77d7e6 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -50,6 +50,13 @@ Minutes in progress — tap again to cancel This device ABI lacks the summarizer runtime (arm64-v8a only) Summarizer model missing. Run:\n%1$s + + Translating… + Please wait for the current translation + This sentence is already in your language + Translation failed — tap the sentence again to retry + Only zh/ja/en translation is supported; this sentence is in another language + Translator model missing. Run:\n%1$s Loading summarizer model (%1$s)… notes drafting diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 50ac339..1f295cc 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -50,6 +50,13 @@ 議事録を生成中です。もう一度押すと中止できます この端末のABIには要約ランタイムが含まれていません(arm64-v8aのみ) 要約モデルがありません。実行してください:\n%1$s + + 翻訳中… + 現在の翻訳の完了をお待ちください + この文は表示言語と同じです + 翻訳に失敗しました。もう一度タップしてください + 中日英間の翻訳のみ対応。この文は該当言語ではありません + 翻訳モデルがありません。実行してください:\n%1$s 要約モデル(%1$s)を読み込み中… メモ更新 清書 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 37eab36..c1412c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,6 +51,13 @@ 纪要正在生成中,再点一次可取消 本机 ABI 未包含摘要运行时库(仅 arm64-v8a 真机可用) 缺少摘要模型。请执行:\n%1$s + + 翻译中… + 请等待当前翻译完成 + 该句已是当前界面语言 + 翻译失败,可再次点击重试 + 仅支持中/日/英互译,该句不属于这三种语言 + 缺少翻译模型。请执行:\n%1$s 正在加载摘要模型(%1$s)… 滚动笔记 成稿 diff --git a/tools/llama-jni/src/summarize_llm_jni.cpp b/tools/llama-jni/src/summarize_llm_jni.cpp index 2b062f7..e32a130 100644 --- a/tools/llama-jni/src/summarize_llm_jni.cpp +++ b/tools/llama-jni/src/summarize_llm_jni.cpp @@ -154,7 +154,8 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeSetThreads( 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) { + jstring jPrompt, jint maxNewTokens, jfloat temperature, jint topK, jfloat topP, + jfloat repeatPenalty) { auto * e = (Engine *) (uintptr_t) ptr; if (e == nullptr || e->ctx == nullptr) { @@ -214,7 +215,9 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate( // --- decode loop --- 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; out.reserve(1024); std::vector piece(64); @@ -223,6 +226,12 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate( const bool greedy = !(temperature > 0.0f); const int k = topK > 0 ? topK : 64; // Gemma 4 recommended: 64 const float p = topP > 0.0f ? topP : 0.95f; // recommended: ~0.95 + // Repeat penalty (llama.cpp style, unique-token window): 1B 模型在垃圾/ + // 歧义输入上极易进入复读循环。pen<=0 时禁用(摘要路径保持原行为), + // 翻译路径传 1.1。只惩罚本步已生成过的 token,窗口=最近 64 个生成 token。 + const bool usePen = repeatPenalty > 1.0f; + std::vector recent; + std::vector penalized; // scratch for unique marking for (int i = 0; i < maxNew; ++i) { 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); 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 llama_token next; if (greedy) { @@ -286,6 +310,7 @@ Java_com_k2fsa_sherpa_onnx_LlmEngine_nativeGenerate( } } cur = next; + if (usePen) recent.push_back(next); } LOGI("generated %d chars", (int) out.size());