package com.k2fsa.sherpa.onnx import android.Manifest import android.content.pm.PackageManager import android.graphics.Color import android.graphics.Typeface import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder import android.os.Bundle import android.os.Handler import android.os.HandlerThread import android.os.Looper import android.os.Process import android.os.SystemClock import android.text.SpannableStringBuilder 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 import android.text.style.StyleSpan import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.Button import android.widget.EditText import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import java.io.File import java.util.concurrent.atomic.AtomicLong import kotlin.concurrent.thread private const val TAG = "sherpa-onnx" private const val REQUEST_RECORD_AUDIO_PERMISSION = 200 // adb emu avd hostmicon // to enable microphone inside the emulator class MainActivity : AppCompatActivity() { private val permissions: Array = arrayOf(Manifest.permission.RECORD_AUDIO) private lateinit var onlineRecognizer: OnlineRecognizer private lateinit var offlineRecognizer: OfflineRecognizer // 精修提示注入用(setConfig 复用同一个 Kotlin 配置对象;只在 // subtitleHandler 串行队列上被改写,nano 解码期间不会有人读它) private lateinit var offlineConfig: OfflineRecognizerConfig private var audioRecord: AudioRecord? = null private lateinit var recordButton: Button private lateinit var textView: TextView private var recordingThread: Thread? = null private val audioSource = MediaRecorder.AudioSource.MIC private val sampleRateInHz = 16000 private val channelConfig = AudioFormat.CHANNEL_IN_MONO private var samplesBuffer = arrayListOf() // Note: We don't use AudioFormat.ENCODING_PCM_FLOAT // since the AudioRecord.read(float[]) needs API level >= 23 // but we are targeting API level >= 21 private val audioFormat = AudioFormat.ENCODING_PCM_16BIT // ---- Subtitle dialog stream (UI layer) ---------------------------------- // A SubtitleLine is one speaker's turn: a list of finalized/in-progress // sentences rendered as a single colored paragraph. A new line is started // ONLY when the endpoint voice print says "a different speaker" (or when // speaker id is disabled => one sentence per line). // ALL of this state is touched on the main thread only (worker threads // post updates through uiHandler). private class Sentence( val id: Long, // utterance id allocated on the recording thread var text: String, var finalized: Boolean, ) { var line: SubtitleLine? = null var refined: Boolean = false // false => 底稿(第一遍)未被精修覆盖 var trans: String? = null // 点击翻译缓存(界面语言);原文变化时置空 var transVisible: Boolean = true // 点击在已缓存译文上收起/展开 var transPending: Boolean = false // 在途翻译占位(句下显示"翻译中…") } private class SubtitleLine( val id: Long, var label: String?, // speaker display name; null => 未识别 var colorIdx: Int, // index into speakerPalette ) { val sentences = ArrayList() var open = true // active line: still accepting sentences } private val uiHandler = Handler(Looper.getMainLooper()) private val subtitleThread = HandlerThread("second-pass").also { it.start() } private val subtitleHandler by lazy { Handler(subtitleThread.looper) } private val lineIdSeq = AtomicLong(0) private val subtitleLines = ArrayList() private val sentenceById = HashMap() private val labelColorIdx = HashMap() private var colorSeq = 0 private var lastConfirmedLabel: String? = null // partial 行沿用的最近确认说话人 private var lastSettledSid = -1L // 主线程:最近定格的句子槽位 // 同人停顿超时封行:距上一句定格 > 该时长再开口,同一人也另起新行 // (会议记录里算新一轮发言)。仅作用于"最底行接句"判断。 private val lineIdleTimeoutMs = 60_000L private var lastSentenceSettledAt = 0L private lateinit var subtitleRecycler: RecyclerView private lateinit var subtitleAdapter: SubtitleAdapter // main-thread only: most recent confirmed speaker label (naming dialog, // new-line inheritance). Written on the main thread in finalizeSentence. private var lastLabel: String? = null private val speakerPalette = intArrayOf( Color.parseColor("#1565C0"), // blue Color.parseColor("#2E7D32"), // green Color.parseColor("#E65100"), // orange Color.parseColor("#6A1B9A"), // purple Color.parseColor("#00695C"), // teal Color.parseColor("#C62828"), // red Color.parseColor("#283593"), // indigo Color.parseColor("#5D4037"), // brown ) // keep at most this many rendered lines; older ones are recycled so long // sessions never grow the data or the view tree unboundedly private val maxSubtitleLines = 300 // ---- 历史会议只读视图 ---- private lateinit var historyButton: Button private lateinit var historyTitle: TextView private lateinit var historyBack: Button private var historyMode = false private var stashedLiveLines: ArrayList? = null private var modelsLoaded = false // 字幕贴底跟随(纯自动,无按钮):用户上划即暂停跟随,回到底部自动恢复 private var autoFollow = true // Language of the first-pass (nemotron-3.5) model. The model selects ONE // language prompt per stream; there is no native "subset of languages" // option, so we combine: // 1. the stream option "language": "auto" (detect), "zh"/"zh-CN"/"zh-TW", // "ja"/"ja-JP", "en" (see prompt_dictionary in encoder.int8.onnx); // 2. when "auto", a zh/ja/en post-filter (isInAllowedLanguages) so that // segments decoded into other languages are dropped. private var firstPassLanguage = "auto" // ---- Speaker separation via voice-print embeddings --------------------- // Embedding model candidates, tried in order. Place one in the external // 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( // 主选: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 private var speakersEnabled = false // ---- rolling speaker-change detection (需求一.4) ------------------------- // Every ~1.5 s the speaker-check thread embeds the last 2 s window and // compares it (cosine) with the window reference taken at the start of // the current utterance. A big similarity drop == voice changed mid // utterance == force an endpoint cut at the switch point. // 路线A(2026-09-14):滚动检测不再句中强切,只"预备快速断句"—— // 命中疑似换人后的 5s 内,一旦出现 ≥0.3s 的语音停顿,即按 endpoint // 流程结算句子,由 endpoint 权威判定决定归属与换行(不再有错误的中途切)。 @Volatile private var switchSuspicionAtMs = 0L // 静音判据必须画在底噪地板之上(minWindowRms 同档):定 0.006 时 // 底噪(~0.008-0.015)让 silenceRun 永远攒不满,FAST-EP 0 次触发 private val silenceRms = 0.012f // 100ms 块静音判据(RMS) private val silenceNeedSamples = 4800 // 0.3s 连续静音 private val speakerCheckThread = HandlerThread("speaker-window").also { it.start() } private val speakerCheckHandler by lazy { Handler(speakerCheckThread.looper) } // 实测(2026-09-14):2s 短窗 + base 模型下同人 simA 在 0.05~0.7 震荡, // 阈值维度已不可分 → 判定窗加长到 4s 拉开分布,阈值回调到 0.35 private val speakerSwitchDrop = 0.35f // cos(window, reference) below this => switch // 深低分一票强切(2026-09-16):同人波动实测最低 ~0.39,异人插话窗稳落 // 0.14~0.19,0.22 取安全带中枢——深低首票即切(无缝插话 4~6s→3~4s); // 0.22~0.35 浅低维持连续两票,毛刺照旧被误断防护拦下 private val deepLowSwitch = 0.22f // 最近邻兜底(P1 收紧,2026-09-11):精确匹配不中→top≥0.42 且领先第二名 // ≥0.06 才归入最相似身份;模糊判新人(修"错并到别人行") private val verdictFallbackMin = 0.42f private val nearestMargin = 0.06f // 滚动检测 fast 空间的兜底阈值(仅影响换行标签显示,endpoint 仍权威) private val rollingFallbackMin = 0.36f // P3 防漂移:只有高置信同人窗(≥0.5)才吸收进参考,权重也降到 15% private val emaAbsorbMin = 0.50f // 滚动检测在 fast 空间查"这人是谁"仅用于**显示标签**,阈值放宽到 0.4; // endpoint 权威判定仍用准确模型 + speakerThreshold(0.5)。 private val fastSearchThreshold = 0.4f // 最速档:1.2s 周期 + 单票(switchStrikesNeeded=1)。误断的代价只是 // 多分行+流重置,不丢音频(快照平铺保证时间轴连续)。 private val switchStrikesNeeded = 1 // 长窗方差小,单票(真换人检出 3~6s) private val minWindowRms = 0.012f // 真语音窗才判定:0.004 挡不住房间底噪,垃圾嵌入引发换人风暴 // 窗长门槛(短窗 embedding 方差是误切主因):锚点需 ≥2s,判定需 ≥3s private val anchorMinSamples = 32000 private val judgeMinSamples = 48000 private val enableRollingSwitch = true // A/B 诊断:关掉=只用自然 endpoint 断句 // 开口即查(2026-09-16,用户三选:静音后开口/一票制快切/立即占位编号): // ≥0.6s 静音后的开口 → 攒 1.2s 语音做一次快模型判定,贴当前人不动/ // 像别人换行上已知标签/都不像换行当场占号。换行延迟 4~6s → ~1.5s。 // 无缝插话(无静音)仍由 SWITCH-FORCE streak 机制兜底。 private val onsetEnabled = true private val onsetGapSamples = 9600 // 0.6s 前置静音 private val onsetMinSamples = 19200 // 1.2s 开口窗 @Volatile private var onsetSeq = 0L // 行切换/会话开始作废在途开口判定 private var pendingOnsetLabel: String? = null // 主线程:下一新行预置标签 private var onsetClosePending = false // 主线程:强切后旧句定格即封底行 @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 // 兜底残料:当前句子槽位最近一次滚动窗口声纹。整句判定因碎片算不出 // embedding 时复用它做归属(用户要求 2026-09-11) // 双参考检测(方案②):anchor=句级锚点(EMA 慢更新),prev=上一窗(短期参考)。 // 与两者都低才判换人;只低锚点、贴上一窗 → 渐变,更新参考不断行。 @Volatile private var windowPrevEmb: FloatArray? = null @Volatile private var rollingEmbSid = -1L @Volatile private var rollingEmb: FloatArray? = null @Volatile private var windowRefEmb: FloatArray? = null @Volatile private var activeLabelForWindow: String? = null @Volatile private var checkSerial = 0L // bumped when an utterance settles; // stale in-flight jobs are dropped // ---- buffer accounting (chunk == 100 ms) -------------------------------- // Parallel per-chunk RMS lets the forced cut snap to a low-energy (natural) // chunk boundary within ±3 chunks (±0.3 s) of the detection point. private val chunkRms = ArrayList() private var bufferTotal = 0 // ---- refine-slot bookkeeping (需求二.4/二.5) ----------------------------- // 每个句子槽位一条全字段对账日志(时长/路径/声纹判定/底稿/精修/结果)。 // slotBook 仅主线程访问(submit/finalize/timeout 都经 uiHandler 排队)。 private class SlotRec( val path: String, val durSec: Float, val speaker: String?, val first: String, val prompt: String, var watchdog: Runnable? = null, ) private val slotBook = LinkedHashMap() private val minRefineSamples = 6400 // 0.4 s: below this keep 第一遍底稿 private val refineTimeoutMs = 8000L // 第二遍超时 → 底稿铁律兜底 @Volatile private var isRecording: Boolean = false // 会话(session)从「开始」到「停止」,暂停/继续不结束会话 private var sessionActive = false private var pauseStartedAt = 0L private lateinit var pauseButton: Button private lateinit var langButton: Button // ---- External model loading (adb push) --------------------------------- // Models live in Android/data//files/models/ instead of assets: // adb push /sdcard/Android/data/com.k2fsa.sherpa.onnx/files/models/ // If the first-pass model dir is found there we load everything from the // file system (assetManager = null, absolute paths); otherwise we fall // back to the copy inside the APK's assets. private val firstModelDirName = "sherpa-onnx-nemotron-3.5-asr-streaming-0.6b-560ms-int8-2026-06-11" private val secondModelDirName = "sherpa-onnx-funasr-nano-int8-2025-12-30" private val externalModelDir: File by lazy { File(getExternalFilesDir(null), "models") } // 声纹模型统一放 models/speaker/(兼容读旧的 models 根平铺位置) private val speakerModelDir: File by lazy { File(externalModelDir, "speaker") } private fun speakerModelFile(name: String): File { val inSub = File(speakerModelDir, name) return if (inSub.isFile) inSub else File(externalModelDir, name) } private var modelPrefix = "" // ---- Summarize (离线 LLM 会议纪要,里程碑 1) ------------------------------ // §7 红线:总开关 false 时不建会话记录、不显示按钮,App 行为与加功能前 // 完全一致。摘要是独立世界:专用后台线程 + 独立 .so(llama.cpp), // 不 import 任何 sherpa-onnx ASR native 对象。 private val summarizeEnabled = true // 摘要模型一键开关:true=MiniCPM5-2B(新);false=回退已验证的 Gemma-4-E2B private val useMinicpm5 = true private val summarizeModelDirName: String get() = if (useMinicpm5) "minicpm5-2b" else "gemma4-e2b" private val summarizeGgufName: String get() = if (useMinicpm5) "MiniCPM5-2B-Q4_K_M.gguf" else "gemma-4-E2B_q4_0-it.gguf" private val summarizeChatTemplate: String get() = if (useMinicpm5) "minicpm" else "gemma4" private lateinit var summarizeButton: Button private lateinit var copyMinutesButton: Button private lateinit var minutesStatus: TextView private lateinit var minutesText: TextView private lateinit var minutesHint: TextView private var sessionTranscript: SessionTranscript? = null @Volatile private var isSummarizing = false @Volatile private var summarizeCancelled = false // generate 阻塞在 native,取消靠 abort;引擎只在摘要线程持有 @Volatile private var summarizeEngine: LlmEngine? = null // ---- 点击字幕句翻译(原"批次二自动双语",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 // 一次只允许一个在途翻译(排队期间后续点击 toast"请稍候") @Volatile private var transBusy = false // 已翻译句子的淡琥珀背景高亮(约13%透明度,深浅主题皆可读) private val transHighlightColor = Color.parseColor("#22FFA000") companion object { private val SUMMARY_THREADS = 6 // 空闲时:大核全用 private val SUMMARY_THREADS_BUSY = 4 // 录音中成稿:给 ASR 让核(实测零丢帧) private val SUMMARY_CTX = 8192 // §3 资源账:笔记+单块远小于此 } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) val permissionToRecordAccepted = if (requestCode == REQUEST_RECORD_AUDIO_PERMISSION) { grantResults[0] == PackageManager.PERMISSION_GRANTED } else { false } if (!permissionToRecordAccepted) { Log.e(TAG, "Audio record is disallowed") finish() } Log.i(TAG, "Audio record is permitted") } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) ActivityCompat.requestPermissions(this, permissions, REQUEST_RECORD_AUDIO_PERMISSION) recordButton = findViewById(R.id.record_button) recordButton.isEnabled = false recordButton.setOnClickListener { onclick() } pauseButton = findViewById(R.id.pause_button) pauseButton.isEnabled = false pauseButton.setOnClickListener { onPauseResumeClick() } langButton = findViewById(R.id.lang_button) langButton.setOnClickListener { showLanguagePicker() } historyButton = findViewById(R.id.history_button) historyTitle = findViewById(R.id.history_title) historyBack = findViewById(R.id.history_back) historyButton.setOnClickListener { showHistoryPicker() } historyBack.setOnClickListener { exitHistory() } summarizeButton = findViewById(R.id.summarize_button) copyMinutesButton = findViewById(R.id.copy_minutes_button) minutesStatus = findViewById(R.id.minutes_status) minutesText = findViewById(R.id.minutes_text) minutesHint = findViewById(R.id.minutes_hint) if (summarizeEnabled) { // 生成中再点同一按钮 = 取消(按钮文案随之切换) summarizeButton.setOnClickListener { if (isSummarizing) cancelSummarize() else showSummarizePicker() } copyMinutesButton.isEnabled = false copyMinutesButton.setOnClickListener { onCopyMinutesClick() } } else { summarizeButton.visibility = View.GONE copyMinutesButton.visibility = View.GONE } loadLastMinutes() hintUnfinishedMeeting() textView = findViewById(R.id.my_text) textView.movementMethod = ScrollingMovementMethod() subtitleRecycler = findViewById(R.id.subtitle_list) subtitleRecycler.layoutManager = LinearLayoutManager(this) subtitleAdapter = SubtitleAdapter() subtitleRecycler.adapter = subtitleAdapter subtitleRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { if (dy != 0) autoFollow = !rv.canScrollVertically(1) } }) // 后台工作线程降为 background 优先级:定稿/声纹再忙也不能抢占录音 // 回调与第一遍解码的 CPU(需求一.1 停摆加固)。 subtitleHandler.post { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND) } // D:声纹检测线程优先级高于定稿线程(nano),换行判定不被定稿挤慢 speakerCheckHandler.post { Process.setThreadPriority(Process.THREAD_PRIORITY_DEFAULT) } // Loading ~1.6 GB of ONNX weights takes tens of seconds. Do it OFF the // main thread, otherwise Android shows an ANR ("app not responding") // after 5 s and may kill the app. thread(true) { try { decideModelSource() setStatus(getString(R.string.loading_pass1)) initOnlineRecognizer() setStatus(getString(R.string.loading_pass2)) initOfflineRecognizer() setStatus(getString(R.string.loading_speaker)) initSpeakerIdentification() if (speakersEnabled) { initFastSpeakerModel() } runOnUiThread { modelsLoaded = true if (!historyMode) recordButton.isEnabled = true } val speakerNote = if (speakersEnabled) getString(R.string.speaker_on) else getString(R.string.speaker_off, speakerModels[0], speakerModelDir.absolutePath) setStatus( getString(R.string.load_done) + speakerNote + "\n" + getString(R.string.tap_start) ) } catch (t: Throwable) { Log.e(TAG, "Failed to load models", t) setStatus( getString(R.string.load_failed, t.message, externalModelDir.absolutePath) + "\n\n" + "adb push $firstModelDirName $externalModelDir.absolutePath/\n" + "adb push $secondModelDirName $externalModelDir.absolutePath/\n" + "adb push ${speakerModels[0]} ${speakerModelDir.absolutePath}/" ) } } } // Prefer models pushed to Android/data//files/models/; fall back to // the assets copy when the first-pass model directory is not found there. // 单一来源(用户决定 2026-09-12):外部 models/ 目录为唯一模型路径, // 废弃 APK assets 兜底——避免新旧两份模型并存的排查黑洞。 private fun decideModelSource() { if (!File(externalModelDir, firstModelDirName).isDirectory) { throw IllegalStateException( "缺少第一遍模型,请 push 到 ${externalModelDir.absolutePath}" ) } modelPrefix = externalModelDir.absolutePath + "/" Log.i(TAG, "Loading models from external dir: ${externalModelDir.absolutePath}") if (!File(externalModelDir, secondModelDirName).isDirectory) { Log.w(TAG, "Missing $secondModelDirName in external model dir") } } private fun setStatus(msg: String) { Log.i(TAG, msg) runOnUiThread { if (!isRecording) textView.text = msg } } override fun onDestroy() { super.onDestroy() isRecording = false sessionTranscript?.close() if (isSummarizing) { summarizeCancelled = true summarizeEngine?.abort() } subtitleThread.quitSafely() speakerCheckThread.quitSafely() translateEngine?.let { e -> translateHandler.post { runCatching { e.release() } } } translateThread.quitSafely() } private fun onclick() { if (historyMode) return // 历史只读视图:Start 已置灰,双保险 if (!sessionActive) startSession() else stopSession() } private fun onPauseResumeClick() { if (historyMode || !sessionActive) return if (isRecording) pauseSession() else resumeSession() } /** 开始/继续一场会议会话(新建转写文件,字幕清零重开)。 */ private fun startSession() { if (!initMicrophone()) { Log.e(TAG, "Failed to initialize microphone") return } audioRecord!!.startRecording() sessionActive = true isRecording = true recordButton.setText(R.string.stop) pauseButton.isEnabled = true pauseButton.setText(R.string.pause) // 会议记录场景:录音期间保持屏幕常亮,防息屏冻结/MIUI 杀后台 window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) resetBuffer() switchSuspicionAtMs = 0L windowRefEmb = null windowPrevEmb = null rollingLowHits = 0 rollingLowStreak = 0 pendingForceEndpoint = false onsetSeq++ pendingOnsetLabel = null onsetClosePending = false checkSerial++ activeLabelForWindow = null lastConfirmedLabel = null uiHandler.post { resetSubtitles() } if (summarizeEnabled) { // 每次开始会话 = 一个新会议文件(§4.2);主线程操作 sessionTranscript?.close() sessionTranscript = SessionTranscript.begin(filesDir) } recordingThread = thread(true) { processSamples() } Log.i(TAG, "Session started, recording") } /** 暂停:麦克风停,会话不死——录音线程退出时软 endpoint 定格在途句, * 精修/声纹判定回来照常修正字幕,标签命名可用。 */ private fun pauseSession() { isRecording = false pauseStartedAt = SystemClock.elapsedRealtime() runCatching { audioRecord?.stop() } // 不 release,继续时复用 pauseButton.setText(R.string.resume) Log.i(TAG, "Session paused") } /** 继续:恢复采集;暂停时长从会议时间轴扣除。 */ private fun resumeSession() { val rec = audioRecord if (rec == null) { // 极端情况:实例被回收,重建 if (!initMicrophone()) return } sessionTranscript?.addPaused( SystemClock.elapsedRealtime() - pauseStartedAt ) audioRecord!!.startRecording() isRecording = true pauseButton.setText(R.string.pause) recordingThread = thread(true) { processSamples() } Log.i(TAG, "Session resumed") } /** 停止:整场会议结束(转写保留,可继续生成纪要)。 */ private fun stopSession() { if (isRecording) { isRecording = false runCatching { audioRecord?.stop() } } else { // 暂停态停止:这截暂停时间也要计入扣除,免得最后一句时间戳偏长 sessionTranscript?.addPaused( SystemClock.elapsedRealtime() - pauseStartedAt ) } sessionActive = false window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) audioRecord?.release() audioRecord = null recordButton.setText(R.string.start) pauseButton.isEnabled = false pauseButton.setText(R.string.pause) if (summarizeEnabled) { // 保留 sessionTranscript 引用直到下次开始: // 「刚停止」是点生成纪要最常见的时间窗 sessionTranscript?.flush() } Log.i(TAG, "Session stopped") } private fun processSamples() { Log.i(TAG, "processing samples") // 录音回调线程提优:定稿/声纹再重也不能拖累音频读取节奏(需求一.1) Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO) val stream = onlineRecognizer.createStream() // Nemotron reads this option on every decoded chunk, so it even can be // changed while recording (e.g. from a UI button). stream.setOption("language", firstPassLanguage) val interval = 0.1 // i.e., 100 ms val chunkN = (interval * sampleRateInHz).toInt() // in samples val buffer = ShortArray(chunkN) // sentenceId of the utterance currently being spoken (-1 = none yet). // ids are allocated here (recording thread) and shared with the main // thread, so partials / endpoint / async refine all hit the same // sentence inside whichever line it ended up on. var sentenceId = -1L // rolling speaker-check state (recording-thread only) // v8.1 定点回滚:窗口 2s / 周期 1.2s(v8 的 1.2s/0.7s 单票导致误断 // 刷屏灰"未识别");单票保留,配合误断防护使用 val window = RollingWindow(sampleRateInHz * 4) val checkStep = (sampleRateInHz * 2f).toInt() var newSinceCheck = 0 var silenceRun = 0 var onsetRun = 0 // 开口即查:静音后已累积的开口语音采样数 while (isRecording) { val ret = audioRecord?.read(buffer, 0, buffer.size) if (ret != null && ret > 0) { val samples = FloatArray(ret) { buffer[it] / 32768.0f } val blockRms = rmsOf(samples) val wasSilent = silenceRun >= onsetGapSamples silenceRun = if (blockRms < silenceRms) silenceRun + ret else 0 pushChunk(samples) window.push(samples) // 开口即查(2026-09-16):≥0.6s 静音后的开口攒 1.2s 立即判一次 if (onsetEnabled && enableRollingSwitch && speakersEnabled) { if (blockRms >= silenceRms) { if (wasSilent && onsetRun == 0) onsetRun = ret else if (onsetRun > 0) onsetRun += ret if (onsetRun >= onsetMinSamples) { onsetRun = 0 val win = window.snapshot() val n = onsetMinSamples.coerceAtMost(win.size) if (n >= onsetMinSamples && rmsOf(win) >= minWindowRms) { val slice = win.copyOfRange(win.size - n, win.size) val rseq = onsetSeq speakerCheckHandler.post { if (rseq != onsetSeq) return@post val emb = SpeakerRecognition.computeEmbeddingFast( slice, sampleRateInHz) ?: return@post if (rseq != onsetSeq) return@post val anchor = windowRefEmb ?: return@post val simA = SpeakerRecognition.similarity(emb, anchor) if (simA >= emaAbsorbMin) return@post val active = activeLabelForWindow val known = rollingSpeakerSearch(emb) if (known != null && known == active) return@post val label = known ?: SpeakerRecognition.nextAutoLabel().also { SpeakerRecognition.fastRegistry.addAuto(it, emb) } Log.i(TAG, "ONSET-SWITCH simA=${"%.2f".format(simA)}" + " label=$label kind=" + (if (known != null) "known" else "new") + " active=${active ?: "-"}") uiHandler.post { onOnsetSwitch(label) } } } } } else { onsetRun = 0 } } // P2(2026-09-11):跨句常驻检测,参考 embedding 为"行级" // (appendNewLine 时重置)——新人开口后第一个周期内即可触发换行, // 句间不再依赖 endpoint 的迟到判定 if (enableRollingSwitch && speakersEnabled) { newSinceCheck += ret if (newSinceCheck >= checkStep) { newSinceCheck = 0 val win = window.snapshot() val seq = checkSerial val owner = sentenceId if (rmsOf(win) >= minWindowRms) { val rseq = ++rollingSeq speakerCheckHandler.post { // C:被更新的检测投递超过 → 直接作废(防积压) if (rseq != rollingSeq) return@post if (seq != checkSerial) return@post // 窗长门槛:短窗不做判定/锚点(方差大是误切主因) if (windowRefEmb == null) { if (win.size < anchorMinSamples) return@post } else if (win.size < judgeMinSamples) return@post // E:滚动检测走轻量模型(base),推理快 ~4 倍; // 匹配也只在 fast 空间的镜像 registry 里查 val emb = SpeakerRecognition.computeEmbeddingFast( win, sampleRateInHz ) ?: return@post if (rseq != rollingSeq) return@post rollingEmbSid = owner rollingEmb = emb // 计算耗时内句子可能已结算:回写前二次核销 if (seq != checkSerial) return@post val anchor = windowRefEmb val prev = windowPrevEmb ?: anchor if (anchor == null) { // 本行首个窗口:同时定下锚点与短期参考 windowRefEmb = emb windowPrevEmb = emb rollingLowHits = 0 rollingLowStreak = 0 } else { val simA = SpeakerRecognition.similarity(emb, anchor) val simP = if (prev == null) simA else SpeakerRecognition.similarity(emb, prev) // ① 数据回路:输出相似度分布,阈值靠实测调 Log.i(TAG, "SWITCH-CHECK simA=${"%.2f".format(simA)}" + " simP=${"%.2f".format(simP)}" + " drop=$speakerSwitchDrop hits=$rollingLowHits") if (simA >= speakerSwitchDrop) { // 同人缓漂:高置信窗才吸收进锚点(P3 防漂移) rollingLowStreak = 0 if (simA >= emaAbsorbMin) { windowRefEmb = blendEmb(anchor, emb) } windowPrevEmb = emb rollingLowHits = 0 } else if (simP >= emaAbsorbMin) { // 只偏离锚点、紧贴上一窗 → 音量/情绪渐变, // 更新双参考,不换行 rollingLowStreak = 0 windowRefEmb = blendEmb(anchor, emb) windowPrevEmb = emb rollingLowHits = 0 } else { rollingLowStreak += 1 rollingLowHits += 1 if (rollingLowHits >= switchStrikesNeeded) { rollingLowHits = 0 val newLabel = rollingSpeakerSearch(emb) val active = activeLabelForWindow when { // 误断防护:查出来就是当前人 → // 毛刺,不断行,参考回拉 newLabel != null && active != null && newLabel == active -> { windowRefEmb = blendEmb(anchor, emb) rollingLowStreak = 0 windowPrevEmb = emb } // 当前行未归属而窗匹配到已知人 → // 直接冠名,不必断行 newLabel != null && active == null -> { uiHandler.post { adoptActiveLabel(newLabel) } } else -> { // 路线A:只"预备",不立即切 switchSuspicionAtMs = SystemClock.elapsedRealtime() val deepLow = simA < deepLowSwitch Log.i(TAG, "SWITCH-SUSPECT" + " armed fast-endpoint owner=sid$owner" + (if (deepLow) " deepLow" else "")) // 无缝插话确证:深低一票即切; // 浅低维持连续两票。切行同时把 // 新说话人挂名(切行即挂名,复用 // 开口即查的落地通道 onOnsetSwitch) if (deepLow || rollingLowStreak >= forceSwitchStreaks ) { rollingLowStreak = 0 val label = newLabel ?: SpeakerRecognition.nextAutoLabel() .also { SpeakerRecognition .fastRegistry .addAuto(it, emb) } Log.i(TAG, "SWITCH-FORCE" + (if (deepLow) " deep-low<${deepLowSwitch}" else " streak>=$forceSwitchStreaks") + " label=$label" + " owner=sid$owner") uiHandler.post { onOnsetSwitch(label) } } } } } } } } } } } stream.acceptWaveform(samples, sampleRate = sampleRateInHz) while (onlineRecognizer.isReady(stream)) { onlineRecognizer.decode(stream) } // 路线A:疑似换人后的第一个自然停顿(≥0.3s)即触发结算; // 无缝插话确证(连续低分 streak)走 pendingForceEndpoint 直切 val fastEp = pendingForceEndpoint || (switchSuspicionAtMs != 0L && SystemClock.elapsedRealtime() - switchSuspicionAtMs < 5_000L && silenceRun >= silenceNeedSamples) val susExpired = switchSuspicionAtMs != 0L && !fastEp && SystemClock.elapsedRealtime() - switchSuspicionAtMs >= 5_000L if (susExpired) { // 观察用:5s 内没等到停顿,记录当时静音累积便于校准 Log.i(TAG, "SWITCH-SUSPECT expired (no pause; silenceRun=" + "$silenceRun/$silenceNeedSamples, lastRms=" + "%.4f".format(if (samples.isNotEmpty()) rmsOf(samples) else 0f) + ")") switchSuspicionAtMs = 0L } if (fastEp) { val forced = pendingForceEndpoint pendingForceEndpoint = false rollingLowStreak = 0 switchSuspicionAtMs = 0L silenceRun = 0 Log.i(TAG, "FAST-EP fired (" + (if (forced) "force: low-streak)" else "suspicion + pause)")) } val isEndpoint = onlineRecognizer.isEndpoint(stream) || fastEp val rawAll = onlineRecognizer.getResult(stream).text var text = rawAll if (text.isNotBlank() && !isInAllowedLanguages(text)) { text = "" // 被过滤的文字不进字幕 } if (text.isNotBlank()) { if (sentenceId == -1L) { sentenceId = lineIdSeq.incrementAndGet() // P2:同人行内新句不重置行级参考(换行由 appendNewLine 重置) } val sid = sentenceId uiHandler.post { onPartial(sid, text) } } if (isEndpoint) { onlineRecognizer.reset(stream) if (sentenceId != -1L) { val sid = sentenceId sentenceId = -1L newSinceCheck = 0 checkSerial++ val snap = flattenSamples() // fresh copy, 绝不共享引用 val dur = bufferTotal / sampleRateInHz.toFloat() val prompt = rawAll // 未过滤底稿作提示(需求二:不丢词) // 时间轴严格平铺:本段全部音频已交给 snap,缓冲区清零, // 下一段从该断点开始(不再回含 0.5s 重叠) resetBuffer() // 整句声纹判定移出录音线程(停摆根因):后台算完 // 回主线程 confirmSpeaker 修正归属 postSpeakerVerdict(sid, snap) // 定格 + 送检立即走主线程队列,不等声纹 uiHandler.post { val accepted = submitRefineTask( sid, snap, dur, if (fastEp) "FAST_EP" else "NATURAL", null, text, prompt ) finalizeSentence(sid, accepted) } } else { resetBuffer() } } } } // Stop 兜底(软 endpoint):最后一句未完的转写同样走定格→判定→精修, // 否则丢半句 + 灰"未识别"行滞留(用户反馈 2026-09-11) if (sentenceId != -1L) { val raw = onlineRecognizer.getResult(stream).text val disp = if (raw.isNotBlank() && isInAllowedLanguages(raw)) raw else "" onlineRecognizer.reset(stream) val sid = sentenceId sentenceId = -1L if (disp.isNotBlank() && bufferTotal > 0) { val snap = flattenSamples() val dur = bufferTotal / sampleRateInHz.toFloat() resetBuffer() postSpeakerVerdict(sid, snap) uiHandler.post { val accepted = submitRefineTask( sid, snap, dur, "STOP", null, disp, disp ) finalizeSentence(sid, accepted) } } else { resetBuffer() uiHandler.post { finalizeSentence(sid, false) } } } stream.release() } /** * 整句声纹判定:后台算准确 embedding + fast 镜像(句尾 2s 切片,与滚动 * 检测同视角),注册后回主线程 confirmSpeaker 修正归属。 * 自然 endpoint / 强制断句 / Stop 软 endpoint 共用。 */ private fun postSpeakerVerdict(sid: Long, snap: FloatArray) { speakerCheckHandler.post { var emb = SpeakerRecognition.computeEmbedding(snap, sampleRateInHz) if (emb == null && rollingEmbSid == sid) { // 兜底①:句子太碎、整句 embedding 算不出 → 复用该槽滚动窗口残料 emb = rollingEmb } val fastEmb = if (emb != null && SpeakerRecognition.fastReady()) { val tailSlice = if (snap.size > 32000) snap.copyOfRange(snap.size - 32000, snap.size) else snap SpeakerRecognition.computeEmbeddingFast(tailSlice, sampleRateInHz) } else null val label = if (emb == null) null else (SpeakerRecognition.registry.search(emb, speakerThreshold) // 兜底②(P1 收紧):最近邻+优势差;模糊→注册新人 ?: nearestAttribution( emb, SpeakerRecognition.registry, verdictFallbackMin )) ?: run { // 开口即查已在 fast 空间给新人占过号 → 优先复用,防同一 // 人双号(开口 S0 / 结算 S1);命中即顺带补全准确空间镜像 val fh = if (fastEmb != null) SpeakerRecognition.fastRegistry.search( fastEmb, fastSearchThreshold) else null (fh ?: SpeakerRecognition.nextAutoLabel()).also { SpeakerRecognition.registry.addAuto(it, emb) 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) } } } // Keep only zh/ja/en output when firstPassLanguage == "auto": // letters must be ASCII latin (en), kana (ja), or CJK hanzi (zh/ja); // everything else (hangul, cyrillic, thai, arabic, accented latin ...) // means the model decoded another language, so the segment is dropped. // When a single language is pinned via the prompt, the filter is bypassed. private fun isInAllowedLanguages(text: String): Boolean { if (firstPassLanguage != "auto") { return true } for (ch in text) { if (!Character.isLetter(ch)) { continue } val cp = ch.code val allowed = (cp in 0x0041..0x005A) || // A-Z (cp in 0x0061..0x007A) || // a-z (cp in 0x3040..0x30FF) || // hiragana + katakana (cp in 0x31F0..0x31FF) || // katakana phonetic ext. (cp in 0x3400..0x4DBF) || // CJK ext. A (cp in 0x4E00..0x9FFF) || // CJK unified (cp in 0xF900..0xFAFF) || // CJK compatibility (cp in 0xFF66..0xFF9F) // halfwidth katakana if (!allowed) { return false } } return true } // 声纹准确模型:单一来源 models/speaker/(按序尝试 v2→base)。 // 缺文件时 native 抛可捕获异常,禁用声纹功能但 ASR 不受影响。 private fun initSpeakerIdentification() { for (model in speakerModels) { try { SpeakerRecognition.initExtractor( assetManager = null, model = speakerModelFile(model).absolutePath, ) speakersEnabled = true activeSpeakerModel = model Log.i(TAG, "Speaker extractor ready: $model") return } catch (t: Throwable) { Log.i(TAG, "Speaker model '$model' not usable: ${t.message}") } } speakersEnabled = false activeSpeakerModel = null } // E:轻量声纹模型(eres2net base,列表末位)专供滚动换人检测,推理快 ~4 倍。 // 找不到该文件(或准确模型本来就是 base)时跳过,滚动检测自动回退准确模型。 // 注:换 CAM++ 主选后 fast 空间暂仍用 base(独立 registry 镜像,互不污染); // CAM++ 足够小,后续想让它兼职 fast 只需把末位换成同文件。 private fun initFastSpeakerModel() { val fastName = speakerModels.last() if (activeSpeakerModel == fastName) return // 准确模型已是 base try { SpeakerRecognition.initFastExtractor( null, speakerModelFile(fastName).absolutePath ) Log.i(TAG, "fast speaker model ready: $fastName") } catch (t: Throwable) { Log.i(TAG, "fast model '$fastName' not found, rolling falls back to v2") } } // 点击行首说话人标签(ClickableSpan)→ 命名或改名。 // internalKey:该行的内部声纹键("S0"/真名);null=归属未定,提示稍后再命名。 private fun showNameSpeakerDialog(internalKey: String?) { if (internalKey == null) { Toast.makeText(this, getString(R.string.naming_wait_owner), Toast.LENGTH_SHORT).show() return } val input = EditText(this).apply { hint = getString(R.string.naming_hint, displayName(internalKey)) } AlertDialog.Builder(this) .setTitle(if (autoLabelRegex.matches(internalKey)) getString(R.string.naming_title_new, displayName(internalKey)) else getString(R.string.naming_title_rename, displayName(internalKey))) .setView(input) .setPositiveButton(getString(R.string.dlg_ok)) { _, _ -> val name = input.text.toString().trim() if (name.isNotEmpty() && name != internalKey) { renameSpeaker(internalKey, name) } } .setNegativeButton(getString(R.string.dlg_cancel), null) .show() } private fun renameSpeaker(oldKey: String, name: String) { // 真名不得长得像内部键/显示名,避免与 S{n}→已识别人N 的映射冲突 if (autoLabelRegex.matches(name) || Regex("^(已识别人?\\d+|未识别\\d*)$").matches(name)) { Toast.makeText(this, getString(R.string.naming_bad_name), Toast.LENGTH_SHORT).show() return } // 声纹库升级:S{n} 的嵌入转为真名注册(真名可多组嵌入并存, // 即"两个误判声纹归为同一人",今后 search 对两组嵌入都返回同名) SpeakerRecognition.registry.renameToName(oldKey, name) SpeakerRecognition.fastRegistry.renameToName(oldKey, name) for (l in subtitleLines) { if (l.label == oldKey) { l.label = name l.colorIdx = colorIdxFor(name) } } labelColorIdx.remove(oldKey) if (lastConfirmedLabel == oldKey) lastConfirmedLabel = name if (lastLabel == oldKey) lastLabel = name if (activeLabelForWindow == oldKey) activeLabelForWindow = name if (summarizeEnabled) sessionTranscript?.onRename(oldKey, name) mergeAdjacentSameLabel() subtitleAdapter.notifyItemRangeChanged(0, subtitleLines.size) Log.i(TAG, "speaker renamed: $oldKey -> $name") Toast.makeText(this, getString(R.string.naming_done, name), Toast.LENGTH_SHORT).show() } // 命名归并(用户确认的规则:只并相邻同名行,时间线不回改旧行)。 // 含未定格句子的行不并入 —— 保护"in-progress 只存在于最底行"不变量。 private fun mergeAdjacentSameLabel() { var i = 0 while (i < subtitleLines.size - 1) { val a = subtitleLines[i] val b = subtitleLines[i + 1] if (a.label != null && a.label == b.label && b.sentences.none { !it.finalized } ) { Log.i(TAG, "rename merge: line@${i + 1} -> line@$i (${a.label})") for (s in b.sentences.toList()) { s.line = a a.sentences.add(s) } subtitleLines.removeAt(i + 1) subtitleAdapter.notifyItemRemoved(i + 1) } else { i++ } } } private fun showLanguagePicker() { if (sessionActive) { Toast.makeText(this, getString(R.string.lang_busy_session), Toast.LENGTH_SHORT).show() return } val modes = arrayOf(LanguageUtil.MODE_SYSTEM, LanguageUtil.MODE_ZH, LanguageUtil.MODE_EN, LanguageUtil.MODE_JA) val opts = arrayOf(getString(R.string.lang_system), "简体中文", "English", "日本語") AlertDialog.Builder(this) .setTitle(getString(R.string.lang_title)) .setItems(opts) { _, which -> LanguageUtil.set(this, modes[which]) } .setNegativeButton(getString(R.string.dlg_cancel), null) .show() } private fun initMicrophone(): Boolean { if (ActivityCompat.checkSelfPermission( this, Manifest.permission.RECORD_AUDIO ) != PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions(this, permissions, REQUEST_RECORD_AUDIO_PERMISSION) return false } val numBytes = AudioRecord.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat) Log.i( TAG, "buffer size in milliseconds: ${numBytes * 1000.0f / sampleRateInHz}" ) // 暂停/继续复用同一实例;仅首次(或释放后)创建 if (audioRecord == null) { audioRecord = AudioRecord( audioSource, sampleRateInHz, channelConfig, audioFormat, // 4x: ~400 ms of slack so occasional scheduling hiccups on the // recording thread never overflow the OS capture ring (v5 停摆加固) numBytes * 4 ) } return true } private fun initOnlineRecognizer() { // Please change getModelConfig() to add new models // See https://k2-fsa.github.io/sherpa/onnx/pretrained_models/index.html // for a list of available models val firstType = 34 val firstRuleFsts: String? firstRuleFsts = null Log.i(TAG, "Select model type $firstType for the first pass") val config = OnlineRecognizerConfig( featConfig = getFeatureConfig(sampleRate = sampleRateInHz, featureDim = 80), modelConfig = getModelConfig(type = firstType, modelPrefix = modelPrefix)!!, endpointConfig = getEndpointConfig(), enableEndpoint = true, ) if (firstRuleFsts != null) { config.ruleFsts = firstRuleFsts; } onlineRecognizer = OnlineRecognizer( assetManager = null, config = config, ) } private fun initOfflineRecognizer() { // Please change getOfflineModelConfig() to add new models // See https://k2-fsa.github.io/sherpa/onnx/pretrained_models/index.html // for a list of available models val secondType = 46 var secondRuleFsts: String? secondRuleFsts = null Log.i(TAG, "Select model type $secondType for the second pass") val config = OfflineRecognizerConfig( featConfig = getFeatureConfig(sampleRate = sampleRateInHz, featureDim = 80), modelConfig = getOfflineModelConfig(type = secondType, modelPrefix = modelPrefix)!!, ) if (secondRuleFsts != null) { config.ruleFsts = secondRuleFsts } offlineConfig = config offlineRecognizer = OfflineRecognizer( assetManager = null, config = config, ) } // Runs on subtitleHandler (serial): never touches UI state, only decodes // a snapshot of one completed utterance. Trims a graded amount of trailing // audio (usually the gap/silence that ended the segment); segments are // strictly contiguous (v6: no 0.5 s overlap between clips). // // 需求二(prompt):第一遍草稿经 setConfig 注入 funasrNano.hotwords —— // native 会把它拼进模型训练时学过的"上下文信息/热词列表"提示模板。 // 本函数只在精修串行线程上跑,解码前后各 setConfig 一次,无并发读者。 private fun decodeSecondPass(snapshot: FloatArray, prompt: String): String { val trim = when { snapshot.size >= 24000 -> 8000 snapshot.size >= 12000 -> 4000 else -> 0 } val end = snapshot.size - trim if (end <= 0) return "" val nano = offlineConfig.modelConfig.funasrNano val injected = prompt.isNotBlank() if (injected) { nano.hotwords = sanitizePrompt(prompt) offlineRecognizer.setConfig(offlineConfig) } val stream = offlineRecognizer.createStream() try { stream.acceptWaveform(snapshot.copyOfRange(0, end), sampleRateInHz) offlineRecognizer.decode(stream) return offlineRecognizer.getResult(stream).text } finally { stream.release() if (injected) { nano.hotwords = "" offlineRecognizer.setConfig(offlineConfig) } } } // ---- refine task 统一入口(需求二.4:快照 copyOf + 槽位只产生一次任务) ---- // 仅主线程调用。返回 true 表示精修任务已排队。超时看门狗:精修未在 // refineTimeoutMs 内回来 → 记 TIMEOUT、收回槽位、底稿原地保留(需求二.1)。 private fun submitRefineTask( sid: Long, snapshot: FloatArray, durSec: Float, path: String, speaker: String?, firstText: String, prompt: String = "", ): Boolean { if (slotBook.containsKey(sid)) { Log.w(TAG, "SLOT sid=$sid path=$path RESULT=DUP_IGNORED (一槽两位防护)") return false } val rec = SlotRec(path, durSec, speaker, firstText, prompt) slotBook[sid] = rec if (slotBook.size > 512) { for (k in slotBook.keys.take(256).toList()) slotBook.remove(k) } if (snapshot.size < minRefineSamples) { slotBook.remove(sid) Log.i(TAG, slotLog(sid, rec, "SKIP_SHORT", null, "底稿保留")) return false } val wd = Runnable { if (sentenceById.remove(sid) != null) { slotBook.remove(sid) Log.w(TAG, slotLog(sid, rec, "TIMEOUT", null, "底稿保留")) } } rec.watchdog = wd uiHandler.postDelayed(wd, refineTimeoutMs) subtitleHandler.post { val refined: String? = try { decodeSecondPass(snapshot, prompt) } catch (t: Throwable) { Log.e(TAG, "SLOT sid=$sid decode exception: $t") null } uiHandler.post { applyRefined(sid, refined) } } return true } // 一槽一条、全字段对账日志(需求二.5;prompt=实际传入的提示文本) private fun slotLog( sid: Long, rec: SlotRec?, result: String, second: String?, note: String = "", ): String { val sp = rec?.speaker?.let { displayName(it) } ?: "未匹配" val pr = if (rec?.prompt?.isNotEmpty() == true) " prompt=\"${rec.prompt}\"" else "" return ("SLOT sid=$sid dur=${"%.1f".format(rec?.durSec ?: 0f)}s " + "path=${rec?.path ?: "?"} speaker=$sp$pr " + "first=\"${rec?.first ?: ""}\" " + "second=${second?.let { "\"$it\"" } ?: "-"} " + "RESULT=$result${if (note.isEmpty()) "" else " ($note)"}") } // hotwords 通道以逗号/分号切词:统一替换成空格,避免草稿被拆碎; // 限长 80 字,防止提示 token 挤占 max_total_len 的音频 KV 空间 // (native 侧对超长 prompt 有截断告警)。 private fun sanitizePrompt(s: String): String { val flat = s.replace(Regex("[,;,;\\r\\n\\t]"), " ").trim() return if (flat.length <= 80) flat else flat.take(80) } // ---- chunked buffer helpers (recording thread only) ---------------------- private fun pushChunk(s: FloatArray) { samplesBuffer.add(s) chunkRms.add(rmsOf(s)) bufferTotal += s.size } private fun resetBuffer() { samplesBuffer.clear() chunkRms.clear() bufferTotal = 0 } private fun dropPrefixChunks(n: Int) { repeat(minOf(n, samplesBuffer.size)) { bufferTotal -= samplesBuffer.removeAt(0).size chunkRms.removeAt(0) } } private fun rmsOf(s: FloatArray): Float { if (s.isEmpty()) return 0f var acc = 0.0 for (x in s) acc += x.toDouble() * x return Math.sqrt(acc / s.size).toFloat() } // 句内参考 embedding 的 EMA 混合(7:3),让基准缓慢跟随说话人的 // 音量/音色自然漂移,避免"越说越像别人"的连锁误判 private fun blendEmb(a: FloatArray, b: FloatArray): FloatArray { val n = minOf(a.size, b.size) // P3:0.85/0.15 —— 慢吸收,防把"正在切换的新人"喂进参考 return FloatArray(n) { a[it] * 0.85f + b[it] * 0.15f } } // 最近邻归属(P1):top≥minScore 且对第二名优势≥nearestMargin 才归入, // 两个已注册者难分高下时返回 null → 按新人处理(宁多勿错) private fun nearestAttribution( emb: FloatArray, registry: SpeakerRegistry, minScore: Float, ): String? { val r = registry.nearestRank(emb) val name = r.name ?: return null return if (r.score >= minScore && r.score - r.secondScore >= nearestMargin) { name } else null } private fun flattenSamples(): FloatArray = flattenPrefix(samplesBuffer.size) // fresh copy of the first k chunks — never hand the shared buffer to threads private fun flattenPrefix(k: Int): FloatArray { var total = 0 for (i in 0 until minOf(k, samplesBuffer.size)) total += samplesBuffer[i].size val flat = FloatArray(total) var i = 0 for (c in 0 until minOf(k, samplesBuffer.size)) { samplesBuffer[c].copyInto(flat, i) i += samplesBuffer[c].size } return flat } private fun bufferDurationSec(): Float = bufferTotal / sampleRateInHz.toFloat() // fixed-size sliding window over recent audio (recording thread only) private class RollingWindow(private val size: Int) { private val buf = FloatArray(size) private var len = 0 fun push(s: FloatArray) { if (s.size >= size) { System.arraycopy(s, s.size - size, buf, 0, size) len = size return } val overflow = len + s.size - size var base = len if (overflow > 0) { System.arraycopy(buf, overflow, buf, 0, len - overflow) base = len - overflow } System.arraycopy(s, 0, buf, base, s.size) len = minOf(size, len + s.size) } fun snapshot(): FloatArray = buf.copyOf(len) } // funasr-nano (Qwen3-0.6B decoder) can fall into repetition loops, // especially when the clip starts with the 0.5 s tail re-fed from the // previous utterance. Collapse adjacent repeats conservatively so that // legitimate echoes ("非常感谢", "哈哈哈") survive: // - >=3 copies of a unit of >=4 chars (>=12 chars total), or // - exactly 2 copies of a long unit (>=10 chars, e.g. a whole clause). private fun collapseRepetitions(s: String): String { if (s.length < 8) return s var cur = s var changed = true while (changed) { changed = false outer@ for (unit in cur.length / 2 downTo 4) { var i = 0 while (i + unit <= cur.length) { val base = cur.substring(i, i + unit) var count = 1 var j = i + unit while (j + unit <= cur.length && cur.substring(j, j + unit) == base ) { count++ j += unit } val hit = (count >= 3 && unit >= 4) || (count == 2 && unit >= 10) if (hit) { Log.i(TAG, "Collapsed ${count}x repeat: $base") cur = cur.substring(0, i + unit) + cur.substring(j) changed = true break@outer } i = if (count > 1) j else i + 1 } } } return cur } // ==================== Subtitle stream (main thread only) ==================== private inner class SubtitleAdapter : RecyclerView.Adapter() { inner class VH(val text: TextView) : RecyclerView.ViewHolder(text) override fun onCreateViewHolder(parent: ViewGroup, type: Int): VH { val vh = VH( LayoutInflater.from(parent.context) .inflate(R.layout.item_subtitle, parent, false) as TextView ) // 行首标签是 ClickableSpan;没有 movementMethod 点击不会生效 vh.text.movementMethod = LinkMovementMethod.getInstance() return vh } override fun getItemCount(): Int = subtitleLines.size override fun onBindViewHolder(holder: VH, position: Int) { holder.text.text = buildLineSpannable(subtitleLines[position]) } } // "[张三] 第一句。第二句"——同一行内句子按语言习惯衔接。 private fun buildLineSpannable(line: SubtitleLine): CharSequence { val sb = SpannableStringBuilder() val label = displayName(line.label) val color = if (line.label == null) Color.parseColor("#757575") else speakerPalette[line.colorIdx] val start = sb.length val head = "[$label] " sb.append(head) sb.setSpan( StyleSpan(Typeface.BOLD), start, start + head.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) // 点击标签 → 命名/改名(绑定时捕获内部键,重命名后整表重绑); // 历史只读视图不提供点击 val internalKey = line.label if (!historyMode) { sb.setSpan(object : ClickableSpan() { override fun onClick(widget: View) { showNameSpeakerDialog(internalKey) } override fun updateDrawState(ds: TextPaint) { ds.color = color ds.isUnderlineText = true } }, 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 || 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()) // 点击原文句 → 翻译/收起(历史只读视图不挂;不改视觉样式) 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) sb.setSpan(RelativeSizeSpan(0.82f), ts, sb.length, 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( ForegroundColorSpan(color), start, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) return sb } // 句间衔接已提出为顶层函数 SubtitleFormat.kt#joinerAfter(可 JVM 单测) private fun resetSubtitles() { val n = subtitleLines.size subtitleLines.clear() sentenceById.clear() labelColorIdx.clear() colorSeq = 0 lastLabel = null lastSentenceSettledAt = SystemClock.elapsedRealtime() autoFollow = true if (n > 0) subtitleAdapter.notifyItemRangeRemoved(0, n) textView.visibility = View.GONE subtitleRecycler.visibility = View.VISIBLE } // 内部标签 S0/S1/…(已注册、能区分但尚未起名的身份)对外显示"已识别人1/2…"; // 已命名者显示真名;label==null(完全无法归属)才显示灰色"未识别"。 private val autoLabelRegex = Regex("^S(\\d+)$") private fun displayName(label: String?): String { if (label == null) return getString(R.string.label_unknown) val m = autoLabelRegex.matchEntire(label) return if (m != null) getString(R.string.label_identified, m.groupValues[1].toInt() + 1) else label } private fun colorIdxFor(label: String): Int = labelColorIdx.getOrPut(label) { colorSeq++ % speakerPalette.size } // 开口即查的切换落地(主线程):ONSET 判定换了人 → 让下一句另起新行。 // 底行还有在途句(旧人话尾仍在流里)→ 借强 endpoint 定格它,定格完成 // 再封行(onsetClosePending,见 finalizeSentence);已在途句为空则直接 // 封行。新标签经 pendingOnsetLabel 交给 appendNewLine。 private fun onOnsetSwitch(label: String) { val list = if (historyMode) stashedLiveLines ?: subtitleLines else subtitleLines val bottom = list.lastOrNull() ?: return pendingOnsetLabel = label if (!bottom.open) return // 底行已封:下一行自然消费预置标签 val busy = bottom.sentences.any { !it.finalized } if (busy) { onsetClosePending = true pendingForceEndpoint = true } else { bottom.open = false } } private fun appendNewLine(label: String?): SubtitleLine { // 新行出现即封掉之前的开放行,并把其中"未定格的进行中句子"整体迁移 // 过来 —— 硬保证 in-progress 只可能发生在最底行(修复:声纹判定搬家后, // 旧行里还在长字的句子被孤立在上方的问题) // 历史只读态:残留回调要开的新行进暂存列表,不动展示中的历史行 val list = if (historyMode) stashedLiveLines ?: subtitleLines else subtitleLines val old = list.lastOrNull() val migrating = if (old != null && old.open) old.sentences.filter { !it.finalized } else emptyList() old?.open = false if (migrating.isNotEmpty()) { old?.sentences?.removeAll(migrating.toSet()) } // 开口即查预置标签(ONSET-SWITCH 判了新说话人)→ 新行直接采用, // 并成为"最近确认说话人"(后续空闲新行沿用);不等 endpoint 权威判定 val eff = pendingOnsetLabel ?: label if (pendingOnsetLabel != null) { pendingOnsetLabel = null lastConfirmedLabel = eff Log.i(TAG, "ONSET-LINE new line label=$eff") } val line = SubtitleLine( id = lineIdSeq.incrementAndGet(), label = eff, colorIdx = colorIdxFor(eff ?: "__unknown__"), ) // P2:行级参考——新行开启即重置,下一句(新人)的第一窗重新定基准 windowRefEmb = null windowPrevEmb = null rollingLowHits = 0 rollingLowStreak = 0 // 新行=新锚点,连续低分从头算 onsetSeq++ // 作废在途开口判定(锚点已换) list.add(line) if (!historyMode) { subtitleAdapter.notifyItemInserted(subtitleLines.size - 1) } // 进行中句子接在新行尾部,继续实时更新 line.sentences.addAll(migrating) for (s in migrating) s.line = line if (migrating.isNotEmpty()) old?.let { notifyLine(it) } return line } // first-pass partial:实时刷新"当前活跃句"。新 endpoint 之前的句子先挂在 // 活跃行(=最后一条 open 行,沿用最近确认说话人);定格时若判定换人, // 该整句会被移到新说话人的新行(finalizeSentence)。 private fun onPartial(sid: Long, partial: String) { val existing = sentenceById[sid] if (existing == null) { // 只接"最底部的开放行";同人停顿超时则另起新行(用户确认的规则) val last = subtitleLines.lastOrNull() val fresh = SystemClock.elapsedRealtime() - lastSentenceSettledAt < lineIdleTimeoutMs val active = if (last != null && last.open && fresh) last else appendNewLine(lastConfirmedLabel) val sentence = Sentence(sid, partial, finalized = false) sentence.line = active active.sentences.add(sentence) sentenceById[sid] = sentence notifyLine(active) } else if (existing.text != partial) { existing.text = partial existing.line?.let { notifyLine(it) } } autoScroll() } // endpoint: 定格当前句。声纹判定换人 → 该句整体移入新说话人的新行 // (原行不含此句,哪怕 partial 阶段曾短暂挂错);同人 → 句子并入当前行。 // endpoint/强制断句的 settle:该句就地定格(归属修正走 confirmSpeaker, // v5 起声纹判定已移出录音线程)。 private fun finalizeSentence(sid: Long, willRefine: Boolean) { lastSettledSid = sid lastSentenceSettledAt = SystemClock.elapsedRealtime() val sentence = sentenceById[sid] ?: return if (sentence.finalized) return sentence.finalized = true if (summarizeEnabled) { // 会话记录器挂钩①(settle):时长取 slotBook 记账(SKIP_SHORT 时无 // 记录,按 0 处理 —— startMs≈endMs,不影响纪要内容) val durMs = ((slotBook[sid]?.durSec ?: 0f) * 1000f).toLong() sessionTranscript?.onSettled(sid, durMs, sentence.text) } if (!speakersEnabled) { // 声纹缺失:退化为按 endpoint 分行(显示"未识别") sentence.line?.open = false } if (!willRefine) { // 不会再有精修回来:立即收回句子索引 // (SKIP_SHORT 等情况 submitRefineTask 已写对账日志,不重复记) sentenceById.remove(sid) } // 开口即查强切:旧句已定格,此刻封底行,让新说话人首句另起带 // 预置标签的新行(由后续 onPartial 尾部创建) if (onsetClosePending) { onsetClosePending = false subtitleLines.lastOrNull()?.takeIf { it.open }?.open = false } sentence.line?.let { notifyLine(it) } autoScroll() } private fun findSentence(sid: Long): Sentence? { // 历史只读态:当前会话句子去暂存里找,绝不误伤展示中的历史行 val pool = if (historyMode) stashedLiveLines else subtitleLines pool?.let { list -> for (l in list) for (s in l.sentences) if (s.id == sid) return s } if (historyMode) return null for (l in subtitleLines) { for (s in l.sentences) { if (s.id == sid) return s } } return null } // ==================== 历史会议只读视图 ==================== private fun showHistoryPicker() { if (isRecording) { Toast.makeText(this, getString(R.string.hist_busy_recording), Toast.LENGTH_SHORT).show() return } val files = SessionTranscript.dirOf(filesDir) .listFiles { f -> f.isFile && f.name.endsWith(".jsonl") } ?.sortedByDescending { it.lastModified() } ?: emptyList() val meetings = files.mapNotNull { f -> runCatching { SessionTranscript.load(f) }.getOrNull() ?.takeIf { it.isNotEmpty() }?.let { f to it } } if (meetings.isEmpty()) { Toast.makeText(this, getString(R.string.hist_none), Toast.LENGTH_SHORT).show() return } val labels = meetings.map { (f, es) -> val hasMd = File( SessionTranscript.dirOf(filesDir), f.nameWithoutExtension + ".md" ).isFile getString(R.string.hist_item, f.nameWithoutExtension, es.size, SummarizePipeline.mmss(es.last().endMs), if (hasMd) getString(R.string.hist_has_md) else "") }.toTypedArray() AlertDialog.Builder(this) .setTitle(getString(R.string.hist_title)) .setItems(labels) { _, which -> enterHistory(meetings[which].first, meetings[which].second) } .setNegativeButton(getString(R.string.dlg_cancel), null) .show() } private fun enterHistory(f: File, entries: List) { if (historyMode) exitHistory() stashedLiveLines = ArrayList(subtitleLines) subtitleLines.clear() subtitleLines.addAll(buildHistoryLines(entries)) subtitleAdapter.notifyDataSetChanged() historyMode = true // 进历史必须自己把列表切可见:本场没录过时 recycler 仍是 GONE subtitleRecycler.visibility = View.VISIBLE textView.visibility = View.GONE recordButton.isEnabled = false pauseButton.isEnabled = false historyButton.visibility = View.GONE historyBack.visibility = View.VISIBLE historyTitle.visibility = View.VISIBLE historyTitle.text = getString(R.string.history_reading, f.nameWithoutExtension) subtitleRecycler.scrollToPosition(0) } private fun exitHistory() { if (!historyMode) return historyMode = false subtitleLines.clear() stashedLiveLines?.let { subtitleLines.addAll(it) } stashedLiveLines = null subtitleAdapter.notifyDataSetChanged() historyButton.visibility = View.VISIBLE historyBack.visibility = View.GONE historyTitle.visibility = View.GONE recordButton.isEnabled = modelsLoaded pauseButton.isEnabled = sessionActive // 退出历史:有当前会话字幕则回到字幕视图,否则恢复状态文本页 val hasLive = subtitleAdapter.itemCount > 0 subtitleRecycler.visibility = if (hasLive) View.VISIBLE else View.GONE textView.visibility = if (hasLive) View.GONE else View.VISIBLE if (hasLive && subtitleAdapter.itemCount > 0) { subtitleRecycler.scrollToPosition(subtitleAdapter.itemCount - 1) } } /** 历史行分组:同人并行且句间隔 ≤60s 并一行;每句自带时间戳前缀。 */ private fun buildHistoryLines(entries: List): ArrayList { val out = ArrayList() var cur: SubtitleLine? = null var lastEnd = -1L for (e in entries) { var line = cur if (line == null || line.label != e.speaker || e.startMs - lastEnd > lineIdleTimeoutMs ) { line = SubtitleLine( 0, e.speaker, colorIdxFor(e.speaker ?: "__unknown__") ) out.add(line) cur = line } val s = Sentence( e.sid, "(${SummarizePipeline.mmss(e.startMs)}-${SummarizePipeline.mmss(e.endMs)}) ${e.text.trim()}", finalized = true, ) s.line = line line.sentences.add(s) lastEnd = e.endMs } return out } // 声纹线程回传的整句权威判定: // * 无判定或已同人 → 不动; // * 占位行首次判定 → 就地冠名;若满足"保守并回"条件(本行为底行、 // 上一行正是判定同人)→ 整行并回上一行,撤销误切的那次换行; // * 行已归属他人 → 句子迁出;若误切行因此清空、且它是底行、上一行 // 正是判定同人 → 同样并回上方,不再另起新行; // * 其余情况维持原行为(并回底部同人行,没有则在最下方新建)。 // * embedding 回写供"命名说话人";仅当该槽是最近 settle 的句子才更新全局归属。 private fun confirmSpeaker( sid: Long, label: String?, emb: FloatArray?, fastEmb: FloatArray?, ) { Log.i(TAG, "SLOT sid=$sid VERDICT speaker=" + (label?.let { displayName(it) } ?: "未匹配/无判定")) if (summarizeEnabled && label != null) { // 会话记录器挂钩③(说话人归属;改名走 renameSpeaker 的 RENAME 事件) sessionTranscript?.onSpeaker(sid, label) } val sentence = findSentence(sid) ?: return var line = sentence.line ?: return if (label != null && label != line.label) { if (line.label == null) { // 占位行首次判定:整行就地冠名 line.label = label line.colorIdx = colorIdxFor(label) if (!mergeLineIntoPrevIfSame(line, label)) { notifyLine(line) } } else { line.sentences.remove(sentence) val pos = subtitleLines.indexOf(line) val wasLast = pos == subtitleLines.size - 1 val prevSame = if (pos > 0) subtitleLines[pos - 1].takeIf { it.label == label } else null val lineEmpty = line.sentences.isEmpty() if (lineEmpty && pos >= 0) { subtitleLines.removeAt(pos) subtitleAdapter.notifyItemRemoved(pos) } else if (pos >= 0) { notifyLine(line) } if (lineEmpty && wasLast && prevSame != null) { // 保守并回:误切行清空后撤销该行,句子回到上方同人行 Log.i(TAG, "verdict merge-back sid=$sid -> line above") prevSame.sentences.add(sentence) sentence.line = prevSame line = prevSame notifyLine(prevSame) autoScroll() } else { // 只并回"最底部且同人"的行;否则在最下方新建 val target = subtitleLines.lastOrNull() ?.takeIf { it.open && it.label == label } ?: appendNewLine(label) // 定格句插到该行"进行中句子"之前,保持行内时间序 val ins = target.sentences.indexOfFirst { !it.finalized } if (ins >= 0) target.sentences.add(ins, sentence) else target.sentences.add(sentence) sentence.line = target line = target notifyLine(target) autoScroll() } } } if (label != null && sid == lastSettledSid) { lastConfirmedLabel = label lastLabel = label activeLabelForWindow = label } } // 保守并回(用户确认的规则,2026-09-10):误切行仍是底行、且它的上一条 // 恰好就是判定出的同一个人 → 整行并回上一行后删除。in-progress 句子随行 // 迁移,合并后 prev 成为底行,"进行中只存在于最底行"不变量保持成立。 // 只撤销"被判定翻转的误切行";60s 超时产生的相邻同人行不在此列 // (超时行建档时就带着标签,永远走不到本分支)。 private fun mergeLineIntoPrevIfSame(line: SubtitleLine, verdict: String): Boolean { val pos = subtitleLines.indexOf(line) if (pos <= 0 || pos != subtitleLines.size - 1) return false val prev = subtitleLines[pos - 1] if (prev.label != verdict) return false Log.i(TAG, "verdict merge-back: whole line@$pos -> prev(same speaker)") val snapshot = line.sentences.toList() for (s in snapshot) { s.line = prev prev.sentences.add(s) } line.sentences.clear() subtitleLines.removeAt(pos) subtitleAdapter.notifyItemRemoved(pos) notifyLine(prev) autoScroll() return true } // second-pass refine, 需求二加固: // * 底稿铁律:refine 只能覆盖对应句;空/异常/超时(看门狗)/折叠误杀/ // 明显缩短 → 一律保留第一遍文字,该句标记 sentence.refined=false。 // * collapseRepetitions 仅作用于第二遍输出;折叠后 <50% 判误杀回退原文。 // * 每槽恰好应用一次(sentenceById/slotBook 双 remove 幂等), // 终结时输出一条全字段对账日志。 private fun applyRefined(sid: Long, refined: String?) { val sentence = sentenceById.remove(sid) val rec = slotBook.remove(sid) rec?.watchdog?.let { uiHandler.removeCallbacks(it) } if (sentence == null) { Log.w(TAG, slotLog(sid, rec, "LATE_OR_DUP", refined, "槽位已收回,结果丢弃")) return } val first = sentence.text.ifEmpty { rec?.first ?: "" } var second: String? = null var result = "APPLIED" var note = "" if (refined == null) { result = "REJECT_EXCEPTION" note = "底稿保留" } else { // 长度锚定(v8.4):底稿是"实时音频能装下多少字"的自然上界。 // 精修 ≤ 底稿×1.5+8 → 无膨胀迹象,原文直接用(真说出口的重叠词 // 如"谢谢谢谢"原样保留,不再误折叠); // 超出 → 必是 LLM 复读,折叠;折叠后仍超界或为空 → 整条拒绝留底稿。 // (修复:旧规则按"折叠量>50%判误杀回退原文",恰好给真复读开了后门) val ceiling = first.length * 3 / 2 + 8 var candidate = "" if (refined.length <= ceiling) { candidate = refined } else { note = "精修超长(${refined.length}>ceil $ceiling)疑似复读;" val collapsed = collapseRepetitions(refined) if (collapsed.isNotEmpty() && collapsed.length <= ceiling) { candidate = collapsed note += "折叠后采用" } } when { candidate.isEmpty() -> { result = "REJECT_TOO_LONG"; note += "底稿保留" } candidate.isBlank() -> { result = "REJECT_EMPTY"; note = "底稿保留" } !isInAllowedLanguages(candidate) -> { result = "REJECT_LANGUAGE"; note = "底稿保留" } first.isNotBlank() && candidate.length * 2 < first.length -> { result = "REJECT_TOO_SHORT"; note = "不足底稿50%,底稿保留" } else -> second = candidate } } Log.i(TAG, slotLog(sid, rec, result, second, note)) if (second != null && second != sentence.text) { sentence.text = second sentence.refined = true sentence.trans = null // 原文已变,旧译文作废(再点击重译) sentence.line?.let { notifyLine(it) } autoScroll() } else { sentence.refined = false // 未精修标记 } if (summarizeEnabled) { // 会话记录器挂钩②(终稿文本):与字幕层看到同一最终结果 sessionTranscript?.onRefined(sid, sentence.text, sentence.refined) } } // 滚动窗口查"这是谁":有 fast 模型时查 fast 镜像(0.4 宽松阈值,仅供显示); // 没有 base 文件时滚动检测本身回退在准确模型空间 → 直查准确 registry。 // 修复:base 缺失时镜像库恒空、换行标签全 null、误断防护失效的问题。 private fun rollingSpeakerSearch(emb: FloatArray): String? = if (SpeakerRecognition.fastReady()) SpeakerRecognition.fastRegistry.search(emb, fastSearchThreshold) ?: nearestAttribution( emb, SpeakerRecognition.fastRegistry, rollingFallbackMin ) else SpeakerRecognition.registry.search(emb, speakerThreshold) ?: nearestAttribution( emb, SpeakerRecognition.registry, verdictFallbackMin ) // ③ 滚动检测查到了已知人、但当前行还没有归属(占位"未识别"): // 直接把活跃归属采纳为他,等 endpoint 权威判定给行冠名,不再多切一行 private fun adoptActiveLabel(label: String) { if (lastConfirmedLabel == null) { lastConfirmedLabel = label activeLabelForWindow = label // 修"灰色未识别滞留"来源之一:滚动已匹配到人,顺带给当前灰色 // 占位行冠名(能并回上方同人行则并),不必等 endpoint 权威判定 subtitleLines.lastOrNull() ?.takeIf { it.open && it.label == null }?.let { gray -> gray.label = label gray.colorIdx = colorIdxFor(label) if (!mergeLineIntoPrevIfSame(gray, label)) notifyLine(gray) Log.i(TAG, "adopt named gray line -> ${displayName(label)}") } } } private fun notifyLine(line: SubtitleLine) { trimOldLines() val pos = subtitleLines.indexOf(line) if (pos >= 0) subtitleAdapter.notifyItemChanged(pos) } // 超长会话:丢最旧的行(数据+视图一起回收),句子索引同步清理 private fun trimOldLines() { while (subtitleLines.size > maxSubtitleLines) { val old = subtitleLines.removeAt(0) for (s in old.sentences) sentenceById.remove(s.id) subtitleAdapter.notifyItemRemoved(0) } } // 跟随态贴底:最后一行不足一屏时置顶它;行比屏高(同人连说很多句)时 // scrollBy 把**行尾(最新文字)**露出来——修复"长行永远停在第一行、 // 且 100ms 刷新不断把用户上划弹回去"的问题 // ==================== 字幕双语翻译(批次二)==================== private fun uiLanguage(): String { @Suppress("DEPRECATION") val l = resources.configuration.locale.language return if (l == "ja" || l == "en") l else "zh" } // ==================== 点击字幕句翻译(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 (transBusy) { Toast.makeText(this, getString(R.string.trans_busy), Toast.LENGTH_SHORT).show() 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 { // 让核:翻译线程永远低于录音/ASR 线程优先级(工程惯例同定稿线程) Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND) translateHandler.removeCallbacks(unloadRunnable) val tr = translateOne(srcId, srcText, context) transBusy = false uiHandler.post { 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() 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) // 开头格式残片(2026-09-16 现场:翻译开头出现 "html>"):Gemma 词表里 // "" 的尾巴是独立 BPE 词元,模型困惑态冷启动偶尔第一个词元就 // 落在这类"文档结尾"残留上,后面译文正常、全部质检放行 → 噪声上屏。 // 保守集合:围栏符/半截标签/模板词元字面/html 尾巴;循环里与标签剥除 // 交替直至稳定。剥到空 → null 走重试,不外发。 private val transNoisePrefix = Regex( "^(`{3,}\\s*[A-Za-z]*>?||\\.?html?>)", RegexOption.IGNORE_CASE) // 小写 ASCII 残片(aby/nope/fewest 族):2~6 个小写字母+空白/标点后随 // CJK/假名。必须"原文里没有该片段"才剥——原件含 iPhone/PDF 等真词时 // 不误伤;大写开头(AI/OK)天然不在此列 private val transAsciiNoise = Regex( "^([a-z]{2,6})[\\s,、,。!!??]+(?=[\\u4e00-\\u9fff\\u3040-\\u30ff])") private fun cleanTrans(raw: String?, src: String? = null): 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() val s1 = s s = transNoisePrefix.replaceFirst(s, "").trim() if (s == s1 && src != null) { transAsciiNoise.find(s)?.let { m -> val frag = m.groupValues[1] if (!src.contains(frag, ignoreCase = true)) { s = s.removeRange(m.range).trim() } } } if (s != s1) { Log.i(TAG, "TAP-NOISE stripped: " + s1.take(16).replace("\n", " ")) } } 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 // 重试预算:录音中只允许 1 次(两次尝试≈30s),给兜底留出时间窗—— // 现场:三次尝试 15s×3 吃满 45s 预算,兜底被 skipped=budget 拒之门外 val maxGenRetry = if (isRecording) 1 else 2 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 >= maxGenRetry) 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 >= maxGenRetry) 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) + // 早前版本只打前 24 字,曾误导"译文看起来没问题为何拒" // ——判错常在后半段(尾巴滑回源语言),尾 16 字必打 " tail=" + sample.takeLast(16) + " len=" + sample.length + 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 } // 内存峰值加固(2026-09-16):兜底(Big,1.5~3GB)加载前先释放 1B 引擎 // ——它刚连败无保留价值;此刻释放把瞬时峰值直接降 ~0.8GB。 // 本函数跑在翻译线程,与 translateNumbered 的引擎生命周期同线程,安全 translateEngine?.let { runCatching { it.release() } } translateEngine = 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, src) 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? { if (out.isNullOrBlank()) return null val m = HashMap() var i = 0 while (i < out.length) { if (out[i] != '"') { i++; continue } val keyEnd = out.indexOf(':', i + 1) if (keyEnd < 0) break val key = out.substring(i + 1, keyEnd).trim() if (key.isEmpty() || key.any { c -> !c.isDigit() }) { i++; continue } var j = keyEnd + 1 while (j < out.length && out[j] != '"') j++ if (j >= out.length) break val sb = StringBuilder() j++ while (j < out.length) { val c = out[j] if (c == '\\' && j + 1 < out.length) { val n = out[j + 1] sb.append(if (n == 'n') '\n' else if (n == 'r') '\r' else n) j += 2 continue } if (c == '"') break sb.append(c); j++ } m[key] = sb.toString() i = j + 1 } return if (m.isEmpty()) null else m } private fun ensureTranslateEngine(): LlmEngine? { val f = File(File(externalModelDir, translateModelDirName), translateGgufName) if (!f.isFile) { Log.i(TAG, "translate model missing at ${f.absolutePath}; tap-translate off") return null } return try { // 录音中降载: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}") null } } // 一批 = 一个全新引擎(nativeGenerate 不清 KV 的 workaround)。 // items: key(通常是 sid 字符串) × 原文;context: 可选的同轮参考上下文 // (只帮助消解指代,不进译文);返回 key × 译文,失败 null。 private fun translateNumbered( items: List>, temp: Float = 0f, context: String = "", ): Map? { if (items.isEmpty()) return emptyMap() val targetName = transTargetName() val langHint = transLangHint() val ctxBlock = transCtxBlock(context) translateEngine?.let { runCatching { it.release() } } translateEngine = null val engine = ensureTranslateEngine() ?: return null // 长句拆段:小模型对短输入才稳定;长句译到中途会"掉语言" // (简体→繁体→别的语言)。拆 ≤140 字段一次 generate,段序拼回。 val segKeys = ArrayList>() val segCount = HashMap() for ((key, t) in items) { val segs = splitSegments(t) if (segs.size > 1) segCount[key] = segs.size segs.forEachIndexed { i, sg -> val sk = if (segs.size == 1) key else key + "#s" + i segKeys.add(sk to sg) } } val budget = (segKeys.sumOf { it.second.length } * 2 + 96) .coerceAtMost(1200) val got = HashMap() if (segKeys.size == 1) { val one = try { engine.generate( systemPrompt = "你是专业翻译。把给出的句子翻译成" + targetName + "。只输出译文本身,不要解释、不要引号、不要抄写原文," + "不要以「翻訳:」等任何标签开头," + "禁止道歉或说明无法完成,翻不了就输出空字符串。" + "你只在中文、日本語、English 三种语言之间翻译;" + "输入不属于这三种语言时输出空字符串。" + langHint + "。", 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 = cleanTrans(one, segKeys[0].second) if (!tr.isNullOrEmpty()) got[segKeys[0].first] = tr if (got.isEmpty()) { // 空手而归的诊断(2026-09-16):区分「模型白卷」与「输出被清洗成空 // (只有标签/残片)」——genfail 现场的两种死法原来看不出 Log.w(TAG, "TRANSLATE single empty raw=" + (one?.take(48)?.replace("\n", " ") ?: "")) return null } } else { val numbered = segKeys.mapIndexed { i, st -> (i + 1).toString() + ". " + st.second }.joinToString("\n") val out = try { engine.generate( systemPrompt = "你是专业翻译。逐段翻译下面编号文本为" + targetName + "。各段相互独立,逐段对应,不得合并、不得重复、不得解释。" + "只输出JSON对象,键是编号字符串,值是译文," + "例如 {\"1\":\"…\",\"2\":\"…\"}。不得增删编号。" + "严禁把原文照抄为译文;无法翻译的段值输出空字符串。" + "你只在中文、日本語、English 三种语言之间翻译;" + "段不属于这三种语言时其值输出空字符串。" + langHint + "。", userPrompt = 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())?.let { cleanTrans(it, st.second) } ?.takeIf { it.isNotBlank() } ?.let { got[st.first] = it } } // 模型对编号批会"犯懒跳号"(只写最后一段等)→ 缺失段逐一直出 // 兜底;每段重建引擎(nativeGenerate 不清 KV,复用必复读) val missSegs = segKeys.filter { !got.containsKey(it.first) } if (missSegs.isNotEmpty()) { Log.i(TAG, "BATCH incomplete " + got.size + "/" + segKeys.size + " -> per-seg fallback") for (st in missSegs) { translateEngine?.let { runCatching { it.release() } } translateEngine = null val e2 = ensureTranslateEngine() ?: break val one = try { e2.generate( systemPrompt = "你是专业翻译。把给出的文本翻译成" + targetName + "。只输出译文本身,不要解释," + "不要以「翻訳:」等任何标签开头," + "禁止道歉或说明无法完成,翻不了就输出空字符串。" + "你只在中文、日本語、English 三种语言之间翻译;" + "输入不属于这三种语言时输出空字符串。" + langHint + "。", userPrompt = st.second, maxNewTokens = (st.second.length * 2 + 96) .coerceAtMost(1200), temperature = 0f, ) } catch (t: Throwable) { null } cleanTrans(one, st.second)?.let { got[st.first] = it } } } if (got.isEmpty()) { Log.w(TAG, "TRANSLATE batch empty out=" + (out?.take(48)?.replace("\n", " ") ?: "")) return null } } val res = HashMap() for ((key, _) in items) { val n = segCount[key] if (n == null) got[key]?.let { res[key] = it } else { // 跨段复读:拆段后各段译文本应互不相同;≥2 段同文(且非短句) // = 模型把一段复制成多段 → 该 key 整条判退化(不装配),让 // translateOne 的重试机制重建 val segVals = (0 until n).mapNotNull { got[key + "#s" + it] } if (segVals.size == n && n >= 2 && segVals.distinct().size < n && segVals.first().length >= 16 ) { Log.i(TAG, "TRANSLATE key=$key cross-seg repeat " + n + "→" + segVals.distinct().size) continue } val sb = StringBuilder() var full = true for (i in 0 until n) { val g = got[key + "#s" + i] if (g == null) { full = false; break } sb.append(g) } if (full) res[key] = sb.toString() else Log.i(TAG, "MISS key=" + key + " reason=seg-hole") } } return if (res.isEmpty()) null else res } // 复读检测:译文与原文归一化后全等/长串包含/字符二元组 Jaccard≥0.72 // → 判为"把原文抄进译文",该条丢弃(维持原状),不写入字幕 private fun normalizeForEcho(x: String): String = x.lowercase().filter { it.isLetterOrDigit() || it.code >= 0x3040 } // 日↔中互译共享汉字,正常译文的二元组重合本来就高 → 该方向阈值放宽到 // 0.88(只抓真抄写);其他方向维持 0.72。修"isEcho 错杀正常翻译"的漏句。 private fun echoJaccardTh(srcText: String): Double { val d = SummarizePipeline.detectLanguage(srcText) val t = uiLanguage() return if ((d == "ja" || d == "zh") && (t == "ja" || t == "zh")) 0.88 else 0.72 } // 长文按句读切成 ≤140 字的段(优先在句末标点断开,兜底硬切) private fun splitSegments(t: String, max: Int = 140): List { if (t.length <= max) return listOf(t) val parts = ArrayList() val cur = StringBuilder() for (ch in t) { cur.append(ch) if (cur.length >= max * 3 / 4 && (ch == '。' || ch == '!' || ch == '?' || ch == '!' || ch == '?' || ch == ';' || ch == ';' || ch == '\n') ) { parts.add(cur.toString()); cur.setLength(0) } } if (cur.isNotBlank()) parts.add(cur.toString()) val out = ArrayList() for (raw in parts) { var x = raw while (x.length > max) { out.add(x.substring(0, max)); x = x.substring(max) } if (x.isNotBlank()) out.add(x) } return if (out.isEmpty()) listOf(t) else out } // 目标语健全检查(治"译着译着变繁体/变第三种语言"): // 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 -> 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 ) return false } if (diacriticLatin(tr) >= if (uiLanguage() == "en") 3 else 2) return false if (tr.length < 12) return true return when (uiLanguage()) { "ja" -> { val kana = tr.count { it.code in 0x3040..0x30FF } val latin = tr.count { it in 'a'..'z' || it in 'A'..'Z' } kana >= 2 && kana * 50 >= tr.length && latin * 3 <= tr.length } "zh" -> { val latin = tr.count { it in 'a'..'z' || it in 'A'..'Z' } tr.none { it.code in 0x3040..0x30FF } && tr.count { c -> tradOnlyChars.contains(c) } <= 1 && latin * 3 <= tr.length } else -> tr.none { it.code >= 0x2E80 } } } private fun isEcho(src: String, tr: String, th: Double = 0.72): Boolean { val a = normalizeForEcho(src) val b = normalizeForEcho(tr) if (a.isEmpty() || b.isEmpty()) return false if (a == b) return true if (a.length >= 8 && (a.contains(b) || b.contains(a))) return true if (a.length < 6 || b.length < 6) return false fun bigrams(x: String): HashSet { val set = HashSet() for (i in 0 until x.length - 1) set.add(x.substring(i, i + 2)) return set } val ba = bigrams(a); val bb = bigrams(b) if (ba.isEmpty() || bb.isEmpty()) return false val inter = bb.count { ba.contains(it) } val union = ba.size + bb.size - inter return union > 0 && inter.toDouble() / union >= th } // 闲置卸载:最后一次点击翻译 60s 后释放引擎(在途翻译由 transBusy 挡) private val unloadRunnable = Runnable { val e = translateEngine ?: return@Runnable if (!transBusy) { translateEngine = null translateHandler.post { runCatching { e.release() } } } } private fun autoScroll() { if (historyMode) return if (subtitleRecycler.visibility != View.VISIBLE || !autoFollow) return val last = subtitleAdapter.itemCount - 1 if (last < 0) return val lm = subtitleRecycler.layoutManager as LinearLayoutManager val view = lm.findViewByPosition(last) if (view == null) { subtitleRecycler.scrollToPosition(last) subtitleRecycler.post { if (autoFollow) autoScroll() } return } val overflow = view.bottom - (subtitleRecycler.height - subtitleRecycler.paddingBottom) if (overflow > 0) subtitleRecycler.scrollBy(0, overflow) else subtitleRecycler.scrollToPosition(last) } // ==================== Summarize(会中/会后一键成稿,§4.2)================ // 录音中点击 = 对截至当时已输出的全部定稿字幕生成纪要,录音转写全程不停。 // 进度与结果内嵌在下半屏「会议纪要」区(生成中旧纪要不清空);成稿成功 // 落 <会话>.notes.json,下次点击只处理新增句子续写笔记,纪要始终覆盖 // "开会至今"全量。模型点击才 mmap、完成即 release(§4.2 模型生命周期)。 /** * 「生成纪要」入口(2026-09-12):预检后先选会议——当前会话(有定格句时) * 置顶,历史会议按时间倒序;选定后交给 runSummarize。 */ private fun showSummarizePicker() { if (!LlmRuntime.ensure()) { Toast.makeText(this, getString(R.string.sum_no_abi), Toast.LENGTH_LONG).show() return } val modelDir = File(externalModelDir, summarizeModelDirName) val gguf = File(modelDir, summarizeGgufName) // 存在性检查必须在任何 native 调用之前(工程教训:缺文件会让 native exit()) if (!gguf.isFile) { Toast.makeText( this, getString(R.string.sum_model_missing, "adb push $summarizeGgufName ${modelDir.absolutePath}/"), Toast.LENGTH_LONG ).show() return } val cur = sessionTranscript val snap = cur?.snapshot() ?: emptyList() val dir = SessionTranscript.dirOf(filesDir) val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".jsonl") } ?.sortedByDescending { it.lastModified() } ?: emptyList() val meetings = files.mapNotNull { f -> if (f.name == cur?.file?.name) return@mapNotNull null // 当前会话用快照 runCatching { SessionTranscript.load(f) }.getOrNull() ?.takeIf { it.isNotEmpty() } ?.let { Triple(f, it, SessionTranscript.fileStartWallMs(f)) } } if (snap.isEmpty() && meetings.isEmpty()) { Toast.makeText(this, getString(R.string.sum_none), Toast.LENGTH_SHORT).show() return } val items = ArrayList, Long>>() val labels = ArrayList() if (snap.isNotEmpty() && cur != null) { items.add(Triple(cur.file, snap, cur.startWallMs)) labels.add(getString(R.string.sum_current, snap.size, SummarizePipeline.mmss(snap.last().endMs))) } for ((f, es, ws) in meetings) { items.add(Triple(f, es, ws)) val hasMd = File(dir, f.nameWithoutExtension + ".md").isFile labels.add(getString(R.string.hist_item, f.nameWithoutExtension, es.size, SummarizePipeline.mmss(es.last().endMs), if (hasMd) getString(R.string.sum_has_md) else "")) } AlertDialog.Builder(this) .setTitle(getString(R.string.sum_title)) .setItems(labels.toTypedArray()) { _, which -> runSummarize(items[which].first, items[which].second, items[which].third) } .setNegativeButton(getString(R.string.dlg_cancel), null) .show() } /** 对指定会议跑纪要流水线;断点笔记/.md 均按该会议文件名命名。 */ private fun runSummarize(sourceFile: File, entries: List, wallStart: Long) { if (entries.isEmpty()) { Toast.makeText(this, getString(R.string.sum_empty_this), Toast.LENGTH_SHORT).show() return } val baseName = sourceFile.nameWithoutExtension val modelDir = File(externalModelDir, summarizeModelDirName) val gguf = File(modelDir, summarizeGgufName) if (isRecording) { Toast.makeText(this, getString(R.string.sum_recording_note), Toast.LENGTH_SHORT).show() } // 增量断点:上次成稿留下的 <会话>.notes.json → 只处理新增句子续写笔记 val saved = MeetingNotesIO.load( MeetingNotesIO.pathFor(SessionTranscript.dirOf(filesDir), baseName), baseName) var resumeIndex = 0 var resumeNotes: String? = null if (saved != null && saved.entriesP1Done > 0 && saved.notes != SummarizePipeline.EMPTY_NOTES) { resumeIndex = saved.entriesP1Done.coerceAtMost(entries.size) resumeNotes = saved.notes Log.i(TAG, "SUMMARIZE resume from notes at $resumeIndex/${entries.size}") } isSummarizing = true summarizeCancelled = false runOnUiThread { setMinutesBusyUi(true, getString(R.string.sum_loading, summarizeModelDirName)) } val entriesRef = entries // 快照已在主线程拷贝,线程安全 thread(true) { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND) var engine: LlmEngine? = null try { val tLoad = System.currentTimeMillis() engine = LlmEngine(gguf.absolutePath, SUMMARY_CTX, if (isRecording) SUMMARY_THREADS_BUSY else SUMMARY_THREADS, chatTemplate = summarizeChatTemplate) Log.i(TAG, "SUMMARIZE engine loaded ms=" + (System.currentTimeMillis() - tLoad)) summarizeEngine = engine runOnUiThread { setMinutesBusyUi(true, if (resumeNotes != null) getString(R.string.sum_ready_resume, resumeIndex, entriesRef.size) else getString(R.string.sum_ready, entriesRef.size)) } val prompts = PromptsLoader.load(application.assets) val glossary = File(modelDir, "glossary.txt").let { if (it.isFile) runCatching { it.readText() }.getOrNull() ?: "" else "" } val pipeline = SummarizePipeline(engine, prompts) val result = pipeline.run( labelOf = { l -> displayName(l) }, // 纯函数,跨线程安全 entries = entriesRef, meetingStartWallMs = wallStart, glossary = glossary, resumeNotes = resumeNotes, resumeFromIndex = resumeIndex, listener = object : SummarizeListener { override fun onProgress(stage: String, done: Int, total: Int) { runOnUiThread { val sName = when (stage) { "滚动笔记" -> getString(R.string.sum_stage_notes) "成稿" -> getString(R.string.sum_stage_draft) else -> stage } minutesStatus.text = getString(R.string.sum_progress, sName, done, total) } } override fun isCancelled(): Boolean = summarizeCancelled }, ) val mdFile = File(SessionTranscript.dirOf(filesDir), "$baseName.md") mdFile.writeText(result.markdown) // 增量断点落盘:本次条目全部进了笔记,下次点击只消化新增句子 MeetingNotesIO.save( MeetingNotesIO.pathFor(SessionTranscript.dirOf(filesDir), baseName), MeetingNotes(result.notesJson, entriesRef.size, entriesRef.last().endMs, result.failedChunks, baseName)) Log.i(TAG, "SUMMARIZE done: ${result.totalChunks} chunks, " + "failed=${result.failedChunks}, saved=${mdFile.absolutePath}") runOnUiThread { minutesText.text = result.markdown minutesHint.visibility = View.GONE minutesStatus.visibility = View.VISIBLE minutesStatus.text = getString(R.string.sum_saved, result.language, result.totalChunks, if (result.failedChunks > 0) getString( R.string.sum_failed_chunks, result.failedChunks) else "", mdFile.name) copyMinutesButton.isEnabled = true } } catch (t: Throwable) { if (t is SummarizeEmptyException) Log.i(TAG, "SUMMARIZE empty: ${t.message}") else Log.e(TAG, "SUMMARIZE failed", t) runOnUiThread { minutesStatus.visibility = View.VISIBLE minutesStatus.text = when (t) { is SummarizeCancelledException -> getString(R.string.sum_cancelled) is SummarizeEmptyException -> getString(R.string.sum_auto_cancel, t.message) else -> getString(R.string.sum_fail_toast, t.message) } Toast.makeText(this, when (t) { is SummarizeCancelledException -> getString(R.string.sum_cancel_toast) is SummarizeEmptyException -> getString(R.string.sum_empty_toast) else -> getString(R.string.sum_fail_toast, t.message) }, Toast.LENGTH_LONG).show() } } finally { summarizeEngine = null engine?.release() // 用完必须显式释放,勿依赖 finalize() isSummarizing = false runOnUiThread { setMinutesBusyUi(false, null) } } } } /** 生成中:纪要按钮变「取消生成」+ 状态行显示 msg;结束:复原按钮。 */ private fun setMinutesBusyUi(busy: Boolean, msg: String?) { if (busy) { summarizeButton.setText(R.string.cancel_minutes) if (msg != null) { minutesStatus.visibility = View.VISIBLE minutesStatus.text = msg } } else { summarizeButton.setText(R.string.generate_minutes) } } private fun cancelSummarize() { summarizeCancelled = true summarizeEngine?.abort() // 打断在途 generate;finally 里统一收尾 minutesStatus.text = getString(R.string.sum_canceling) minutesStatus.visibility = View.VISIBLE } /** 把当前纪要正文复制到剪贴板(方便粘到 PC/邮件)。 */ private fun onCopyMinutesClick() { val body = minutesText.text?.toString().orEmpty() if (body.isBlank()) { Toast.makeText(this, "尚无纪要可复制", Toast.LENGTH_SHORT).show() return } try { val cm = getSystemService(CLIPBOARD_SERVICE) as android.content.ClipboardManager cm.setPrimaryClip(android.content.ClipData.newPlainText("meeting-minutes", body)) Toast.makeText(this, getString(R.string.sum_copied), Toast.LENGTH_SHORT).show() } catch (t: Throwable) { Toast.makeText(this, "复制失败:$t", Toast.LENGTH_SHORT).show() } } /** 冷启动把上一场最近的纪要填进下方区(.md 在盘上,读文件即可)。 */ private fun loadLastMinutes() { try { val latest = SessionTranscript.dirOf(filesDir) .listFiles { f -> f.isFile && f.name.endsWith(".md") } ?.maxByOrNull { it.lastModified() } ?: return val body = latest.readText() if (body.isNotBlank()) { minutesText.text = body minutesHint.visibility = View.GONE copyMinutesButton.isEnabled = true minutesStatus.visibility = View.VISIBLE minutesStatus.text = getString(R.string.minutes_last, latest.name) } } catch (t: Throwable) { Log.w(TAG, "loadLastMinutes: $t") } } /** * ②息屏生存能力(重启提示部分):本进程没有会话上下文时,若盘上 * 最新会议的 .md 缺失或比转写旧(生成中途被杀/没点生成),状态行提示可续写。 */ private fun hintUnfinishedMeeting() { if (!summarizeEnabled || sessionTranscript != null) return val f = SessionTranscript.latestFile(filesDir) ?: return val md = File(SessionTranscript.dirOf(filesDir), f.nameWithoutExtension + ".md") if (!md.isFile || md.lastModified() < f.lastModified()) { minutesStatus.visibility = View.VISIBLE minutesStatus.text = getString(R.string.minutes_unfinished, f.nameWithoutExtension) } } }