ASR-Android/app/src/main/java/com/k2fsa/sherpa/onnx/MainActivity.kt

3110 lines
149 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String> = 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<FloatArray>()
// 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<Sentence>()
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<SubtitleLine>()
private val sentenceById = HashMap<Long, Sentence>()
private val labelColorIdx = HashMap<String, Int>()
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<SubtitleLine>? = 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 开口窗
// 喂料恒定滞后(2026-09-16):识别流滞后 1.2s 喂入,切点前的音频可改道;
// 全局字幕延迟 ~1.2s 的代价换取换人零串行
// 快速滚动通道(2026-09-16 用户拍板乐观快切):4s 窗(与主通道同源
// 同尺寸——base 模型短窗(≤2s)同人相似度 0.05~0.7 狂震是 v8.1
// 换人风暴的根因,勿再踩)/1s 周期(唯一提速来源)/0.35 与主通道同款;
// 占号前先查注册表复用编号;准确复核只善后,切错由判定链并回自愈
private val fastRollEnabled = true
private val fastStepSamples = 16000 // 1s
private val fastWinSamples = 64000 // 4s 窗(全窗)
private val fastMinSamples = 48000 // ≥3s 才判(同 judgeMinSamples)
private val feedLagSamples = 24000 // 1.5s:覆盖判定链(1.2s采集+计算)的
// 余量,开口渗漏≈0;全局字幕延迟即此值,调参范围 1.2~1.5s
@Volatile private var onsetSeq = 0L // 行切换/会话开始作废在途开口判定
private var pendingOnsetLabel: String? = null // 主线程:下一新行预置标签
private var pendingOnsetLabelAt = 0L // 预置标签时间戳(过期作废)
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
// 滞后喂料的切点冻结请求(判定线程置位,录制线程消费,2026-09-16)
@Volatile private var cutRequested = false
private val forceSwitchStreaks = 2
// 强切冷却(仅 speakerCheckHandler 线程访问,无需 volatile)
private var lastForceSwitchAtMs = 0L
// 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<Float>()
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<Long, SlotRec>()
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/<pkg>/files/models/ instead of assets:
// adb push <model-dir> /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
// 摘要/翻译兜底引擎:固定 Gemma-4-E2B(2026-09-16 用户拍板,休眠开关
// 一并摘除)。历史:MiniCPM5-2B 三连空模板回显(9/14 起)被弃用,
// 切换实现见 git history;E2B 为 9/11 真机验证过的稳定引擎
private val summarizeModelDirName = "gemma4-e2b"
private val summarizeGgufName = "gemma-4-E2B_q4_0-it.gguf"
private val summarizeChatTemplate = "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 改造)-----------------
// 翻译引擎 = 纪要引擎 E2B(2026-09-16 单引擎化;原 gemma-3-1b-it
// 独立模型退役,历史见 git history)
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<String>, 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/<pkg>/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 // 开口即查:静音后已累积的开口语音采样数
var fastSince = 0 // 快速滚动通道:1s 周期的采样计数
// 恒定滞后喂料(2026-09-16):音频先进 pendingBuf,滞后 feedLagSamples
// 再喂识别流/样本缓冲(窗口分析保持实时)。换人决定时冻结缓冲,
// 断句后补喂给新流——新人开头音频从不进入旧句子。
val pendingBuf = java.util.ArrayDeque<FloatArray>()
var pendingSamples = 0
var frozen = false
fun feedChunk(ch: FloatArray) {
pushChunk(ch)
stream.acceptWaveform(ch, sampleRate = sampleRateInHz)
}
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
window.push(samples) // 分析窗口实时;识别流滞后喂
if (!frozen && cutRequested) {
cutRequested = false
frozen = true
if (sentenceId != -1L) pendingForceEndpoint = true
Log.i(TAG, "ONSET-HOLD frozen pending=" +
"${pendingSamples * 1000 / sampleRateInHz}ms")
}
pendingBuf.addLast(samples)
pendingSamples += samples.size
if (!frozen) {
while (pendingSamples > feedLagSamples) {
val head = pendingBuf.removeFirst()
pendingSamples -= head.size
feedChunk(head)
}
}
// 开口即查(2026-09-16):≥0.6s 静音后的开口攒 1.2s 立即判一次
if (onsetEnabled && enableRollingSwitch && speakersEnabled && !frozen) {
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 anchor = windowRefEmb ?: return@post
val fastEmb = SpeakerRecognition.computeEmbeddingFast(
slice, sampleRateInHz) ?: return@post
if (rseq != onsetSeq) return@post
val simFast = SpeakerRecognition.similarity(
fastEmb, anchor)
if (simFast >= emaAbsorbMin) return@post
// 乐观快切(2026-09-16 用户拍板:第一次识别
// 先切,出错由判定链并回自愈;准确复核不再
// 挡在切换前,只做善后)
val active = activeLabelForWindow
val label = SpeakerRecognition.nextAutoLabel().also {
SpeakerRecognition.fastRegistry.addAuto(it, fastEmb)
}
Log.i(TAG, "ONSET-SWITCH fastSim=" +
"%.2f".format(simFast) +
" label=$label active=${active ?: "-"}")
cutRequested = true
uiHandler.post { onOnsetSwitch(label) }
accurateRecheckAfterSwitch(slice, label, active, "ONSET")
}
}
}
} else {
onsetRun = 0
}
}
// 快速滚动通道(2026-09-16 用户拍板:第一次识别先切、错由
// 并回自愈):2s 尾窗/1s 周期;快模型偏离锚点即乐观快切;
// 准确复核只善后(注册/留痕);3s 冷却与强切通道共用
if (fastRollEnabled && enableRollingSwitch && speakersEnabled &&
!frozen
) {
fastSince += ret
if (fastSince >= fastStepSamples) {
fastSince = 0
val winF = window.snapshot()
val nF = fastWinSamples.coerceAtMost(winF.size)
if (nF >= fastMinSamples && rmsOf(winF) >= minWindowRms) {
val tailF = winF.copyOfRange(winF.size - nF, winF.size)
val rseq = onsetSeq
speakerCheckHandler.post {
if (rseq != onsetSeq) return@post
val anchor = windowRefEmb ?: return@post
val emb = SpeakerRecognition.computeEmbeddingFast(
tailF, sampleRateInHz) ?: return@post
if (rseq != onsetSeq) return@post
val sim = SpeakerRecognition.similarity(emb, anchor)
if (sim >= speakerSwitchDrop) return@post
val active = activeLabelForWindow
// 先查注册表:认出当前人 → 销案(白捡的防护);
// 认出别人 → 复用其编号;彻底陌生才发新号
val known = rollingSpeakerSearch(emb)
if (known != null && known == active) return@post
val nowMs = SystemClock.elapsedRealtime()
if (nowMs - lastForceSwitchAtMs < 3_000L) {
Log.i(TAG, "FASTROLL suppressed (cooldown)")
return@post
}
lastForceSwitchAtMs = nowMs
val label = known
?: SpeakerRecognition.nextAutoLabel().also {
SpeakerRecognition.fastRegistry.addAuto(it, emb)
}
Log.i(TAG, "FASTROLL-SWITCH sim=" +
"%.2f".format(sim) +
" label=$label kind=" +
(if (known != null) "known" else "new") +
" active=${active ?: "-"}")
cutRequested = true
uiHandler.post { onOnsetSwitch(label) }
accurateRecheckAfterSwitch(tailF, label, active, "FASTROLL")
}
}
}
} else {
fastSince = 0
}
// P2(2026-09-11):跨句常驻检测,参考 embedding 为"行级"
// (appendNewLine 时重置)——新人开口后第一个周期内即可触发换行,
// 句间不再依赖 endpoint 的迟到判定
if (enableRollingSwitch && speakersEnabled && !frozen) {
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 "") +
" rms=" + "%.3f".format(rmsOf(win)))
// 无缝插话确证:深低一票即切;
// 浅低维持连续两票。切行同时把
// 新说话人挂名(切行即挂名,复用
// 开口即查的落地通道 onOnsetSwitch)
if (deepLow ||
rollingLowStreak >= forceSwitchStreaks
) {
rollingLowStreak = 0
// 3s 冷却闸(2026-09-16 实测:
// 强切落地链要 1~2s 完成,期间
// 下一个深低窗会再开一枪,日志
// 双发;功能无害但去噪+防叠发)
val nowMs = SystemClock.elapsedRealtime()
if (nowMs - lastForceSwitchAtMs < 3_000L) {
Log.i(TAG, "SWITCH-FORCE" +
" suppressed (cooldown)" +
" owner=sid$owner")
} else {
lastForceSwitchAtMs = nowMs
val label = newLabel
?: SpeakerRecognition.nextAutoLabel()
.also {
SpeakerRecognition
.fastRegistry
.addAuto(it, emb)
// 双空间注册(2026-09-16 S2 双号实锤):
// 只写 fast 镜像时,后续 endpoint 准确判定
// 认不出同一人 → 重新发号(S1/S2 分裂)
SpeakerRecognition
.computeEmbedding(
win,
sampleRateInHz)
?.let { acc ->
SpeakerRecognition
.registry
.addAuto(it, acc)
}
}
Log.i(TAG, "SWITCH-FORCE" +
(if (deepLow)
" deep-low<${deepLowSwitch}"
else
" streak>=$forceSwitchStreaks") +
" label=$label" +
" owner=sid$owner")
cutRequested = true
uiHandler.post {
onOnsetSwitch(label)
}
}
}
}
}
}
}
}
}
}
}
}
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
}
var forcedEp = false
if (fastEp) {
val forced = pendingForceEndpoint
pendingForceEndpoint = false
forcedEp = forced
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 {
// 强切但无句可定格(owner=-1):封行/预置标签仍在
// 主线程挂着,finalizeSentence 永不来的这条路径上
// 补发封行,否则换行哑火(2026-09-16 现场根因)
resetBuffer()
if (forcedEp) {
uiHandler.post { closeOnsetLineIfPending() }
}
}
// 冻结缓冲在断句/重置后补喂给新流(时间轴连续)
if (frozen) {
var drained = 0
while (pendingBuf.isNotEmpty()) {
val ch = pendingBuf.removeFirst()
drained += ch.size
feedChunk(ch)
}
pendingSamples = 0
frozen = false
// 锚点随切点作废(2026-09-16 风暴根治):重置依赖"新人
// 首句 partial→开新行",碎片音/静默期无 partial 时旧
// 锚点会让每个噪声窗都读"深低"→连环强切;切点即弃,
// 下一窗静默重定基准
windowRefEmb = null
windowPrevEmb = null
rollingLowStreak = 0
Log.i(TAG, "ONSET-FLUSH " +
"${drained * 1000 / sampleRateInHz}ms -> new stream")
}
}
// 冻结时无句可断(旧句已结算)→ 直接补喂开新句
if (frozen && sentenceId == -1L) {
var drained = 0
while (pendingBuf.isNotEmpty()) {
val ch = pendingBuf.removeFirst()
drained += ch.size
feedChunk(ch)
}
pendingSamples = 0
frozen = false
windowRefEmb = null
windowPrevEmb = null
rollingLowStreak = 0
Log.i(TAG, "ONSET-FLUSH(no-sentence) " +
"${drained * 1000 / sampleRateInHz}ms")
}
}
}
// Stop:补齐滞后缓冲(否则丢最后 ~1.2s),解码后再走软 endpoint
if (pendingBuf.isNotEmpty()) {
while (pendingBuf.isNotEmpty()) feedChunk(pendingBuf.removeFirst())
pendingSamples = 0
frozen = false
}
while (onlineRecognizer.isReady(stream)) {
onlineRecognizer.decode(stream)
}
// 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<SubtitleAdapter.VH>() {
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
pendingOnsetLabelAt = SystemClock.elapsedRealtime()
// 换行标签不依赖"partial 及时到达"(2026-09-16:预置 5s 过期曾致新行
// 沿用旧标签,观感=没换人——实锤 S2 案预置 16s 后丢弃):落锤即写
// 最近确认说话人,此后任何时刻开的新行都挂新标
lastConfirmedLabel = label
if (!bottom.open) return // 底行已封:下一行自然消费预置标签
val busy = bottom.sentences.any { !it.finalized }
if (busy) {
onsetClosePending = true
pendingForceEndpoint = true
} else {
bottom.open = false
}
}
/** 强切落进"无句可定格"分支时的封行兜底(主线程);预置标签保留等待下一行 */
private fun closeOnsetLineIfPending() {
if (!onsetClosePending) return
onsetClosePending = false
subtitleLines.lastOrNull()?.takeIf { it.open }?.open = false
Log.i(TAG, "ONSET-CLOSE no-sentence forced endpoint")
}
/** 乐观切换后的准确空间善后(2026-09-16 用户拍板:先切后改):切换已
* 发出,本函数只做善后——认得出是当前人 → 留痕(切错由判定链"并回"
* 自愈);真新人 → 补写准确空间(防后续重发号);认出别人 → 留痕(判定
* 链迁回)。在 speakerCheckHandler 线程执行。 */
private fun accurateRecheckAfterSwitch(
samples: FloatArray, label: String, active: String?, tag: String,
) {
val acc = SpeakerRecognition.computeEmbedding(samples, sampleRateInHz)
?: return
val pre = SpeakerRecognition.registry.search(acc, speakerThreshold)
?: nearestAttribution(acc, SpeakerRecognition.registry, verdictFallbackMin)
when {
pre != null && pre == active ->
Log.i(TAG, tag + "-CANCEL-LATE known=$pre(active) revert-via-verdict")
pre != null ->
Log.i(TAG, tag + "-RECHECK known=$pre label=$label (verdict migrates)")
else -> SpeakerRecognition.registry.addAuto(label, acc)
}
}
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 权威判定。
// 超 5s 未消费 = 强切未落地(如无句可定格且无后续新句),作废防误挂
val preset = pendingOnsetLabel
val eff: String?
if (preset != null &&
SystemClock.elapsedRealtime() - pendingOnsetLabelAt < 5_000L
) {
pendingOnsetLabel = null
lastConfirmedLabel = preset
eff = preset
Log.i(TAG, "ONSET-LINE new line label=$preset")
} else {
if (preset != null) {
pendingOnsetLabel = null
Log.i(TAG, "ONSET-LINE stale preset dropped")
}
eff = label
}
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) {
// 开口即查/强切封行:必须在任何早退之前执行——sentenceById 缺失或
// 重复定格都会从下面两行 return,尾部执行曾让封行永哑
// (2026-09-16 现场:强切 owner=sid-1,强制 endpoint 无句可定格,换行丢失)
if (onsetClosePending) {
onsetClosePending = false
subtitleLines.lastOrNull()?.takeIf { it.open }?.open = false
}
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)
}
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<TranscriptEntry>) {
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<TranscriptEntry>): ArrayList<SubtitleLine> {
val out = ArrayList<SubtitleLine>()
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, summarizeModelDirName),
summarizeGgufName)
if (!gguf.isFile) {
Toast.makeText(this, getString(R.string.trans_no_model,
"adb push $summarizeGgufName ${gguf.parent}/"),
Toast.LENGTH_LONG).show()
return
}
// 直译模式(2026-09-16 用户拍板):不带同轮上下文,逐句独立翻译。
// 原先 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)
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<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)
// 开头格式残片(2026-09-16 现场:翻译开头出现 "html>"):Gemma 词表里
// "</html>" 的尾巴是独立 BPE 词元,模型困惑态冷启动偶尔第一个词元就
// 落在这类"文档结尾"残留上,后面译文正常、全部质检放行 → 噪声上屏。
// 保守集合:围栏符/半截标签/模板词元字面/html 尾巴;循环里与标签剥除
// 交替直至稳定。剥到空 → null 走重试,不外发。
private val transNoisePrefix = Regex(
"^(`{3,}\\s*[A-Za-z]*>?|</?[A-Za-z0-9_|]{1,18}>|\\.?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 }
}
// 点击翻译总入口:单引擎 E2B(2026-09-16 用户拍板,两级架构下线——
// 1B 主链失败率过高,"先小后大"平均耗时反而不如直接上 E2B;日语/中文/
// 文学体全面更强)。温度递进 0→0.3→0.7;各闸门拒收即重试;被拒样本
// 逐字相同立即止损(换温度无用)。
private fun translateOne(sid: Long, src: String): String? {
// 时间预算:录音中 45s、空闲 95s。超预算宁可失败(toast 可重试),
// 也不让重试链长时间抢 ASR 的核(饥饿会引发第一遍字幕复读)。
val deadline = SystemClock.elapsedRealtime() +
if (isRecording) 45_000L else 95_000L
// 重试预算:E2B 单次尝试约 10~15s;录音中 1 次(两次≈30s 封顶),
// 空闲 2 次
val maxGenRetry = if (isRecording) 1 else 2
var temp = 0f
var genRetry = 0
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)
val raw = map?.get(sid.toString())?.trim()
val srcTag = " src=" + SummarizePipeline.detectLanguage(src) +
":" + src.take(20).replace("\n", " ")
// 译文内部复读治理:先折叠;折叠即腰斩 → 判退化。原文自复读
// ("数据显示,数据显示,…")时忠实复现放行原文不折叠
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
}
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
}
!targetLangOk(tr) -> {
val sample = tr.replace("\n", " ")
Log.i(TAG, "TAP-MISS sid=$sid reason=langdrift temp=$temp" +
" sample=" + sample.take(24) +
// 尾 16 字必打:判错常在后半段(尾巴滑回源语言)
" tail=" + sample.takeLast(16) +
" len=" + sample.length + srcTag)
if (sample == lastRej) return null
lastRej = sample
if (genRetry >= maxGenRetry) return null
genRetry++; temp = if (temp == 0f) 0.3f else 0.7f
}
else -> return tr
}
}
}
// 翻译目标语名/提示语(单引擎共用)
private fun transTargetName(): String = when (uiLanguage()) {
"ja" -> "日本語"; "en" -> "English"; else -> "简体中文"
}
private fun transLangHint(): String = when (uiLanguage()) {
"ja" -> "译文必须是日语(须含假名,不要输出中文或繁体)"
"zh" -> "译文必须是简体中文(不得用繁体字,不要输出日语或英语)"
else -> "The translation must be English only (no CJK characters)"
}
// 容错提取 "编号":"译文" 对:顺序扫描,不要求 JSON 完整闭合——
// 生成被 maxNewTokens 截断时,已完成的条目仍能救出
private fun extractNumberedPairs(out: String?): Map<String, String>? {
if (out.isNullOrBlank()) return null
val m = HashMap<String, String>()
var i = 0
while (i < out.length) {
if (out[i] != '"') { i++; continue }
val keyEnd = out.indexOf(':', i + 1)
if (keyEnd < 0) break
val key = out.substring(i + 1, keyEnd).trim()
if (key.isEmpty() || key.any { c -> !c.isDigit() }) { i++; continue }
var j = keyEnd + 1
while (j < out.length && out[j] != '"') j++
if (j >= out.length) break
val sb = StringBuilder()
j++
while (j < out.length) {
val c = out[j]
if (c == '\\' && j + 1 < out.length) {
val n = out[j + 1]
sb.append(if (n == 'n') '\n' else if (n == 'r') '\r' else n)
j += 2
continue
}
if (c == '"') break
sb.append(c); j++
}
m[key] = sb.toString()
i = j + 1
}
return if (m.isEmpty()) null else m
}
private fun ensureTranslateEngine(): LlmEngine? {
translateEngine?.let { return it }
val f = File(File(externalModelDir, summarizeModelDirName), summarizeGgufName)
if (!f.isFile) {
Log.i(TAG, "translate model missing at ${f.absolutePath}; tap-translate off")
return null
}
return try {
// 单引擎 E2B(2026-09-16):录音中 4 线程给 ASR 让核(纪要真机
// 实测零丢帧),空闲 6;ctx 与纪要同级 8192
LlmEngine(f.absolutePath, SUMMARY_CTX,
if (isRecording) SUMMARY_THREADS_BUSY else SUMMARY_THREADS,
chatTemplate = summarizeChatTemplate)
.also { translateEngine = it }
} catch (t: Throwable) {
Log.w(TAG, "translate engine init failed: ${t.message}")
null
}
}
// items: key(通常是 sid 字符串) × 原文;返回 key × 译文,失败 null。
// 直译模式(2026-09-16):不携带同轮上下文;引擎复用(JNI 每次生成前清 KV)
private fun translateNumbered(
items: List<Pair<String, String>>, temp: Float = 0f,
): Map<String, String>? {
if (items.isEmpty()) return emptyMap()
val targetName = transTargetName()
val langHint = transLangHint()
// 引擎复用(2026-09-16,JNI 已加 KV 清除):不再按批重建——
// "每批重建"曾让每次尝试都重载 E2B ~4-6s,是翻译单句 30~40s 的主因
val engine = translateEngine ?: ensureTranslateEngine() ?: return null
// 长句拆段:小模型对短输入才稳定;长句译到中途会"掉语言"
// (简体→繁体→别的语言)。拆 ≤140 字段一次 generate,段序拼回。
val segKeys = ArrayList<Pair<String, String>>()
val segCount = HashMap<String, Int>()
for ((key, t) in items) {
val segs = splitSegments(t)
if (segs.size > 1) segCount[key] = segs.size
segs.forEachIndexed { i, sg ->
val sk = if (segs.size == 1) key else key + "#s" + i
segKeys.add(sk to sg)
}
}
val budget = (segKeys.sumOf { it.second.length } * 2 + 96)
.coerceAtMost(1200)
val got = HashMap<String, String>()
if (segKeys.size == 1) {
val sys1 = "你是专业翻译。把给出的句子翻译成" + targetName +
"。只输出译文本身,不要解释、不要引号、不要抄写原文," +
"不要以「翻訳:」等任何标签开头," +
"禁止道歉或说明无法完成,翻不了就输出空字符串。" +
"你只在中文、日本語、English 三种语言之间翻译;" +
"输入不属于这三种语言时输出空字符串。" + langHint + "。"
val usr1 = segKeys[0].second
// 白卷事故诊断(2026-09-16):空 raw= 的现场缺"模型看到了什么"
// 这一环;转储两段长度与首尾,排除提示词层脏数据
Log.i(TAG, "TRANSLATE prompt sys.len=" + sys1.length +
" usr.len=" + usr1.length +
" usr.head=" + usr1.take(40).replace("\n", " ") +
" usr.tail=" + usr1.takeLast(48).replace("\n", " "))
val one = try {
engine.generate(
systemPrompt = sys1,
userPrompt = usr1,
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", " ") ?: "<null>"))
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 = "【文本】\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", " ") ?: "<null>"))
return null
}
}
val res = HashMap<String, String>()
for ((key, _) in items) {
val n = segCount[key]
if (n == null) got[key]?.let { res[key] = it }
else {
// 跨段复读:拆段后各段译文本应互不相同;≥2 段同文(且非短句)
// = 模型把一段复制成多段 → 该 key 整条判退化(不装配),让
// translateOne 的重试机制重建
val segVals = (0 until n).mapNotNull { got[key + "#s" + it] }
if (segVals.size == n && n >= 2 &&
segVals.distinct().size < n &&
segVals.first().length >= 16
) {
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<String> {
if (t.length <= max) return listOf(t)
val parts = ArrayList<String>()
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<String>()
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<String> {
val set = HashSet<String>()
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<Triple<File, List<TranscriptEntry>, Long>>()
val labels = ArrayList<String>()
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<TranscriptEntry>,
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 &&
saved.notes.contains("\"概括\"")
) {
// schema 栅(2026-09-16):旧版五键笔记(已定事項…)视为不可续,
// 直接全量重跑,防旧 schema 喂进新提示词惹乱
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)
}
}
}