点击翻译两级架构+翻译质量治理+插话强切+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:
parent
f4501c8812
commit
0997a21461
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
}
|
||||
// 一次只允许一个在途翻译(排队期间后续点击 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,104 +1938,300 @@ 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
|
||||
// ==================== 点击字幕句翻译(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
|
||||
}
|
||||
seg.lastAddMs = now
|
||||
findSentence(sid)?.let { st ->
|
||||
if (!seg.sentences.contains(st)) seg.sentences.add(st)
|
||||
if (transBusy) {
|
||||
Toast.makeText(this, getString(R.string.trans_busy),
|
||||
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 (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()
|
||||
if (!sourceLangOk(s.text)) {
|
||||
Toast.makeText(this, getString(R.string.trans_not_3lang),
|
||||
Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
// ==================== 轮次分段与二次翻译(2026-09-14)====================
|
||||
// 段 = 同一字幕行(同一说话人轮次)内连续定稿的句子。封段时机:
|
||||
// ①不同行/不同说话人的句子定稿;②段内 6s 无新定稿(静默);
|
||||
// ③暂停/停止。封段后整段带编号送二次翻译,按编号逐句覆盖初译;
|
||||
// 单句段不重翻(初译已是全上下文)。
|
||||
private class TransSeg(val line: SubtitleLine) {
|
||||
val sentences = ArrayList<Sentence>()
|
||||
var lastAddMs = 0L
|
||||
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
|
||||
}
|
||||
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()
|
||||
// 同轮上下文:本行中该句之前的句子(最近 ≤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(' ')
|
||||
}
|
||||
}
|
||||
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
|
||||
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<SubtitleLine>()
|
||||
refs.forEach { s2 ->
|
||||
val tr = map[s2.id.toString()]
|
||||
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()
|
||||
}
|
||||
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 {
|
||||
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) }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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 完整闭合——
|
||||
// 生成被 maxNewTokens 截断时,已完成的条目仍能救出
|
||||
|
|
@ -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<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)。
|
||||
// items: key(通常是 sid 字符串) × 原文;返回 key × 译文,失败 null。
|
||||
// items: key(通常是 sid 字符串) × 原文;context: 可选的同轮参考上下文
|
||||
// (只帮助消解指代,不进译文);返回 key × 译文,失败 null。
|
||||
private fun translateNumbered(
|
||||
items: List<Pair<String, String>>, temp: Float = 0f,
|
||||
context: String = "",
|
||||
): Map<String, String>? {
|
||||
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<Pair<String, String>>()
|
||||
|
|
@ -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,17 +2344,20 @@ 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() }
|
||||
byIdx?.get((i + 1).toString())?.let { cleanTrans(it) }
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { got[st.first] = it }
|
||||
}
|
||||
// 模型对编号批会"犯懒跳号"(只写最后一段等)→ 缺失段逐一直出
|
||||
|
|
@ -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<String>): 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() } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@
|
|||
<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_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_stage_notes">notes</string>
|
||||
<string name="sum_stage_draft">drafting</string>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@
|
|||
<string name="sum_generating">議事録を生成中です。もう一度押すと中止できます</string>
|
||||
<string name="sum_no_abi">この端末のABIには要約ランタイムが含まれていません(arm64-v8aのみ)</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_stage_notes">メモ更新</string>
|
||||
<string name="sum_stage_draft">清書</string>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,13 @@
|
|||
<string name="sum_generating">纪要正在生成中,再点一次可取消</string>
|
||||
<string name="sum_no_abi">本机 ABI 未包含摘要运行时库(仅 arm64-v8a 真机可用)</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_stage_notes">滚动笔记</string>
|
||||
<string name="sum_stage_draft">成稿</string>
|
||||
|
|
|
|||
|
|
@ -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<char> 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<llama_token> recent;
|
||||
std::vector<bool> 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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue