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

2593 lines
118 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.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 // 定稿后的界面语言译文(批次二:双语字幕)
}
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(
"3dspeaker_speech_eres2netv2_sv_zh-cn_16k-common.onnx", // more accurate
"3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx", // smaller fallback
)
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
// 最近邻兜底(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 断句
@Volatile private var rollingLowHits = 0
// 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
// 摘要模型一键开关:true=MiniCPM5-2B(新);false=回退已验证的 Gemma-4-E2B
private val useMinicpm5 = true
private val summarizeModelDirName: String
get() = if (useMinicpm5) "minicpm5-2b" else "gemma4-e2b"
private val summarizeGgufName: String
get() = if (useMinicpm5) "MiniCPM5-2B-Q4_K_M.gguf"
else "gemma-4-E2B_q4_0-it.gguf"
private val summarizeChatTemplate: String
get() = if (useMinicpm5) "minicpm" else "gemma4"
private lateinit var summarizeButton: Button
private lateinit var copyMinutesButton: Button
private lateinit var minutesStatus: TextView
private lateinit var minutesText: TextView
private lateinit var minutesHint: TextView
private var sessionTranscript: SessionTranscript? = null
@Volatile private var isSummarizing = false
@Volatile private var summarizeCancelled = false
// generate 阻塞在 native,取消靠 abort;引擎只在摘要线程持有
@Volatile private var summarizeEngine: LlmEngine? = null
// ---- 字幕双语翻译(批次二,2026-09-14)--------------------------------
// 独立翻译模型:Gemma-3-1B-it Q4_K_M(arch gemma3,.so 已支持,模板分支
// renderGemma3Prompt 现成)。定稿后句级翻译,原文+小字译文双语显示;
// 界面语言与句子语言一致时不翻;模型文件缺失 → 功能静默关闭(自然降级)。
private val translateModelDirName = "gemma-3-1b-it"
private val translateGgufName = "gemma-3-1b-it-Q4_K_M.gguf"
private val translateIdleUnloadMs = 60_000L
private val translateThread = HandlerThread("translate").also { it.start() }
private val translateHandler by lazy { Handler(translateThread.looper) }
@Volatile private var translateEngine: LlmEngine? = null
private var translateDisabled = false
// KV 污染 workaround(2026-09-14,不改 .so):nativeGenerate 不清 KV,
// 同引擎连续 generate 会复读上一条 → 每"批"generate 前重建引擎。
// 初译从逐句改为小批量编号翻译(凑 4 句或 2.5s 定时封批)。
private val initialBatchMax = 1 // 逐句直出:多句JSON批的格式/复读风险一并消除
private val initialBatchDelayMs = 2_500L
private val initialPending = ArrayList<Pair<Long, String>>()
private var initialBatchTimer: Runnable? = null
private val translatedSids = HashSet<Long>() // 每槽只翻一次
// 失败重试预算:每句最多 2 次,温度递进(0→0.3→0.7),贪心死路可被跳出。
// 仅在翻译线程读写,无并发。
private val translateAttempts = HashMap<Long, Int>()
private fun bumpAttempt(sid: Long): Int {
val n = (translateAttempts[sid] ?: 0) + 1
translateAttempts[sid] = n
return n
}
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
checkSerial++
activeLabelForWindow = null
lastConfirmedLabel = null
uiHandler.post { resetSubtitles() }
if (summarizeEnabled) {
// 每次开始会话 = 一个新会议文件(§4.2);主线程操作
sessionTranscript?.close()
sessionTranscript = SessionTranscript.begin(filesDir)
}
// 预热翻译引擎:把模型文件读进 OS 页缓存后即释放(每批仍按防污染
// 协议重建),消除开头几批的冷加载失败
translateHandler.post {
val e = ensureTranslateEngine()
if (e != null) {
translateEngine = null
runCatching { e.release() }
}
}
recordingThread = thread(true) {
processSamples()
}
Log.i(TAG, "Session started, recording")
}
/** 暂停:麦克风停,会话不死——录音线程退出时软 endpoint 定格在途句,
* 精修/声纹判定回来照常修正字幕,标签命名可用。 */
private fun pauseSession() {
closeTransSeg()
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() {
closeTransSeg()
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
while (isRecording) {
val ret = audioRecord?.read(buffer, 0, buffer.size)
if (ret != null && ret > 0) {
val samples = FloatArray(ret) { buffer[it] / 32768.0f }
silenceRun = if (rmsOf(samples) < silenceRms)
silenceRun + ret else 0
pushChunk(samples)
window.push(samples)
// P2(2026-09-11):跨句常驻检测,参考 embedding 为"行级"
// (appendNewLine 时重置)——新人开口后第一个周期内即可触发换行,
// 句间不再依赖 endpoint 的迟到判定
if (enableRollingSwitch && speakersEnabled) {
newSinceCheck += ret
if (newSinceCheck >= checkStep) {
newSinceCheck = 0
val win = window.snapshot()
val seq = checkSerial
val owner = sentenceId
if (rmsOf(win) >= minWindowRms) {
val rseq = ++rollingSeq
speakerCheckHandler.post {
// C:被更新的检测投递超过 → 直接作废(防积压)
if (rseq != rollingSeq) return@post
if (seq != checkSerial) return@post
// 窗长门槛:短窗不做判定/锚点(方差大是误切主因)
if (windowRefEmb == null) {
if (win.size < anchorMinSamples) return@post
} else if (win.size < judgeMinSamples) return@post
// E:滚动检测走轻量模型(base),推理快 ~4 倍;
// 匹配也只在 fast 空间的镜像 registry 里查
val emb = SpeakerRecognition.computeEmbeddingFast(
win, sampleRateInHz
) ?: return@post
if (rseq != rollingSeq) return@post
rollingEmbSid = owner
rollingEmb = emb
// 计算耗时内句子可能已结算:回写前二次核销
if (seq != checkSerial) return@post
val anchor = windowRefEmb
val prev = windowPrevEmb ?: anchor
if (anchor == null) {
// 本行首个窗口:同时定下锚点与短期参考
windowRefEmb = emb
windowPrevEmb = emb
rollingLowHits = 0
} 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 防漂移)
if (simA >= emaAbsorbMin) {
windowRefEmb = blendEmb(anchor, emb)
}
windowPrevEmb = emb
rollingLowHits = 0
} else if (simP >= emaAbsorbMin) {
// 只偏离锚点、紧贴上一窗 → 音量/情绪渐变,
// 更新双参考,不换行
windowRefEmb = blendEmb(anchor, emb)
windowPrevEmb = emb
rollingLowHits = 0
} else {
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)
windowPrevEmb = emb
}
// 当前行未归属而窗匹配到已知人 →
// 直接冠名,不必断行
newLabel != null && active == null -> {
uiHandler.post {
adoptActiveLabel(newLabel)
}
}
else -> {
// 路线A:只"预备",不立即切
switchSuspicionAtMs =
SystemClock.elapsedRealtime()
Log.i(TAG, "SWITCH-SUSPECT" +
" armed fast-endpoint owner=sid$owner")
}
}
}
}
}
}
}
}
}
stream.acceptWaveform(samples, sampleRate = sampleRateInHz)
while (onlineRecognizer.isReady(stream)) {
onlineRecognizer.decode(stream)
}
// 路线A:疑似换人后的第一个自然停顿(≥0.3s)即触发结算
val fastEp = switchSuspicionAtMs != 0L &&
SystemClock.elapsedRealtime() - switchSuspicionAtMs < 5_000L &&
silenceRun >= silenceNeedSamples
val susExpired = switchSuspicionAtMs != 0L && !fastEp &&
SystemClock.elapsedRealtime() - switchSuspicionAtMs >= 5_000L
if (susExpired) {
// 观察用:5s 内没等到停顿,记录当时静音累积便于校准
Log.i(TAG, "SWITCH-SUSPECT expired (no pause; silenceRun=" +
"$silenceRun/$silenceNeedSamples, lastRms=" +
"%.4f".format(if (samples.isNotEmpty())
rmsOf(samples) else 0f) + ")")
switchSuspicionAtMs = 0L
}
if (fastEp) {
switchSuspicionAtMs = 0L
silenceRun = 0
Log.i(TAG, "FAST-EP fired (suspicion + pause)")
}
val isEndpoint = onlineRecognizer.isEndpoint(stream) || fastEp
val rawAll = onlineRecognizer.getResult(stream).text
var text = rawAll
if (text.isNotBlank() && !isInAllowedLanguages(text)) {
text = "" // 被过滤的文字不进字幕
}
if (text.isNotBlank()) {
if (sentenceId == -1L) {
sentenceId = lineIdSeq.incrementAndGet()
// P2:同人行内新句不重置行级参考(换行由 appendNewLine 重置)
}
val sid = sentenceId
uiHandler.post { onPartial(sid, text) }
}
if (isEndpoint) {
onlineRecognizer.reset(stream)
if (sentenceId != -1L) {
val sid = sentenceId
sentenceId = -1L
newSinceCheck = 0
checkSerial++
val snap = flattenSamples() // fresh copy, 绝不共享引用
val dur = bufferTotal / sampleRateInHz.toFloat()
val prompt = rawAll // 未过滤底稿作提示(需求二:不丢词)
// 时间轴严格平铺:本段全部音频已交给 snap,缓冲区清零,
// 下一段从该断点开始(不再回含 0.5s 重叠)
resetBuffer()
// 整句声纹判定移出录音线程(停摆根因):后台算完
// 回主线程 confirmSpeaker 修正归属
postSpeakerVerdict(sid, snap)
// 定格 + 送检立即走主线程队列,不等声纹
uiHandler.post {
val accepted = submitRefineTask(
sid, snap, dur,
if (fastEp) "FAST_EP" else "NATURAL",
null, text, prompt
)
finalizeSentence(sid, accepted)
}
} else {
resetBuffer()
}
}
}
}
// Stop 兜底(软 endpoint):最后一句未完的转写同样走定格→判定→精修,
// 否则丢半句 + 灰"未识别"行滞留(用户反馈 2026-09-11)
if (sentenceId != -1L) {
val raw = onlineRecognizer.getResult(stream).text
val disp =
if (raw.isNotBlank() && isInAllowedLanguages(raw)) raw else ""
onlineRecognizer.reset(stream)
val sid = sentenceId
sentenceId = -1L
if (disp.isNotBlank() && bufferTotal > 0) {
val snap = flattenSamples()
val dur = bufferTotal / sampleRateInHz.toFloat()
resetBuffer()
postSpeakerVerdict(sid, snap)
uiHandler.post {
val accepted = submitRefineTask(
sid, snap, dur, "STOP", null, disp, disp
)
finalizeSentence(sid, accepted)
}
} else {
resetBuffer()
uiHandler.post { finalizeSentence(sid, false) }
}
}
stream.release()
}
/**
* 整句声纹判定:后台算准确 embedding + fast 镜像(句尾 2s 切片,与滚动
* 检测同视角),注册后回主线程 confirmSpeaker 修正归属。
* 自然 endpoint / 强制断句 / Stop 软 endpoint 共用。
*/
private fun postSpeakerVerdict(sid: Long, snap: FloatArray) {
speakerCheckHandler.post {
var emb = SpeakerRecognition.computeEmbedding(snap, sampleRateInHz)
if (emb == null && rollingEmbSid == sid) {
// 兜底①:句子太碎、整句 embedding 算不出 → 复用该槽滚动窗口残料
emb = rollingEmb
}
val fastEmb = if (emb != null && SpeakerRecognition.fastReady()) {
val tailSlice = if (snap.size > 32000)
snap.copyOfRange(snap.size - 32000, snap.size) else snap
SpeakerRecognition.computeEmbeddingFast(tailSlice, sampleRateInHz)
} else null
val label = if (emb == null) null else
(SpeakerRecognition.registry.search(emb, speakerThreshold)
// 兜底②(P1 收紧):最近邻+优势差;模糊→注册新人
?: nearestAttribution(
emb, SpeakerRecognition.registry, verdictFallbackMin
))
?: SpeakerRecognition.nextAutoLabel().also {
SpeakerRecognition.registry.addAuto(it, emb)
if (fastEmb != null)
SpeakerRecognition.fastRegistry.addAuto(it, fastEmb)
}
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)时跳过,滚动检测自动回退准确模型。
private fun initFastSpeakerModel() {
val fastName = speakerModels[1]
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)
}
for ((i, s) in line.sentences.withIndex()) {
if (s.text.isBlank()) continue
if (sb.length > start + head.length) {
// 历史逐句分行;直播按语言习惯衔接(分支写开:Char/String 混
// 在 if 表达式里会被推断成 Any,没有匹配的 append 重载)
if (historyMode) sb.append('\n')
else sb.append(joinerAfter(line.sentences[i - 1].text))
}
sb.append(if (historyMode) s.text else s.text.lowercase())
// 双语字幕:原文下方小字斜体译文(批次二;颜色沿用行色,整行着色)
s.trans?.takeIf { it.isNotBlank() }?.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)
}
}
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 }
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())
}
val line = SubtitleLine(
id = lineIdSeq.incrementAndGet(),
label = label,
colorIdx = colorIdxFor(label ?: "__unknown__"),
)
// P2:行级参考——新行开启即重置,下一句(新人)的第一窗重新定基准
windowRefEmb = null
windowPrevEmb = null
rollingLowHits = 0
list.add(line)
if (!historyMode) {
subtitleAdapter.notifyItemInserted(subtitleLines.size - 1)
}
// 进行中句子接在新行尾部,继续实时更新
line.sentences.addAll(migrating)
for (s in migrating) s.line = line
if (migrating.isNotEmpty()) old?.let { notifyLine(it) }
return line
}
// first-pass partial:实时刷新"当前活跃句"。新 endpoint 之前的句子先挂在
// 活跃行(=最后一条 open 行,沿用最近确认说话人);定格时若判定换人,
// 该整句会被移到新说话人的新行(finalizeSentence)。
private fun onPartial(sid: Long, partial: String) {
val existing = sentenceById[sid]
if (existing == null) {
// 只接"最底部的开放行";同人停顿超时则另起新行(用户确认的规则)
val last = subtitleLines.lastOrNull()
val fresh = SystemClock.elapsedRealtime() -
lastSentenceSettledAt < lineIdleTimeoutMs
val active = if (last != null && last.open && fresh) last
else appendNewLine(lastConfirmedLabel)
val sentence = Sentence(sid, partial, finalized = false)
sentence.line = active
active.sentences.add(sentence)
sentenceById[sid] = sentence
notifyLine(active)
} else if (existing.text != partial) {
existing.text = partial
existing.line?.let { notifyLine(it) }
}
autoScroll()
}
// endpoint: 定格当前句。声纹判定换人 → 该句整体移入新说话人的新行
// (原行不含此句,哪怕 partial 阶段曾短暂挂错);同人 → 句子并入当前行。
// endpoint/强制断句的 settle:该句就地定格(归属修正走 confirmSpeaker,
// v5 起声纹判定已移出录音线程)。
private fun finalizeSentence(sid: Long, willRefine: Boolean) {
lastSettledSid = sid
lastSentenceSettledAt = SystemClock.elapsedRealtime()
val sentence = sentenceById[sid] ?: return
if (sentence.finalized) return
sentence.finalized = true
if (summarizeEnabled) {
// 会话记录器挂钩①(settle):时长取 slotBook 记账(SKIP_SHORT 时无
// 记录,按 0 处理 —— startMs≈endMs,不影响纪要内容)
val durMs = ((slotBook[sid]?.durSec ?: 0f) * 1000f).toLong()
sessionTranscript?.onSettled(sid, durMs, sentence.text)
}
if (!speakersEnabled) {
// 声纹缺失:退化为按 endpoint 分行(显示"未识别")
sentence.line?.open = false
}
if (!willRefine) {
// 不会再有精修回来:立即收回句子索引
// (SKIP_SHORT 等情况 submitRefineTask 已写对账日志,不重复记)
sentenceById.remove(sid)
requestTranslate(sid, sentence.text) // 底稿即最终文本 → 直接翻译
}
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.line?.let { notifyLine(it) }
autoScroll()
} else {
sentence.refined = false // 未精修标记
}
if (summarizeEnabled) {
// 会话记录器挂钩②(终稿文本):与字幕层看到同一最终结果
sessionTranscript?.onRefined(sid, sentence.text, sentence.refined)
}
// 定稿文本已确定(应用或保底稿)→ 排队翻译(批次二:双语字幕)
requestTranslate(sid, sentence.text)
}
// 滚动窗口查"这是谁":有 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"
}
private fun requestTranslate(sid: Long, text: String) {
if (text.isBlank()) return
// ---- 轮次分段(换人/静默6s → 封段送二次翻译)----
findSentence(sid)?.line?.let { line ->
val now = SystemClock.elapsedRealtime()
var seg = openTransSeg
if (seg == null || seg.line !== line ||
now - seg.lastAddMs > transSegGapMs
) {
closeTransSeg()
seg = TransSeg(line)
openTransSeg = seg
}
seg.lastAddMs = now
findSentence(sid)?.let { st ->
if (!seg.sentences.contains(st)) seg.sentences.add(st)
}
armSegTimer()
}
// ---- 初译(小批量;段末二次翻译会逐句覆盖)----
if (translateDisabled) return
if (!translatedSids.add(sid)) return // 每槽初译只排一次
if (SummarizePipeline.detectLanguage(text) == uiLanguage()) return
initialPending.add(Pair(sid, text))
if (initialPending.size >= initialBatchMax) flushInitialBatch() else armInitialBatchTimer()
}
// ==================== 轮次分段与二次翻译(2026-09-14)====================
// 段 = 同一字幕行(同一说话人轮次)内连续定稿的句子。封段时机:
// ①不同行/不同说话人的句子定稿;②段内 6s 无新定稿(静默);
// ③暂停/停止。封段后整段带编号送二次翻译,按编号逐句覆盖初译;
// 单句段不重翻(初译已是全上下文)。
private class TransSeg(val line: SubtitleLine) {
val sentences = ArrayList<Sentence>()
var lastAddMs = 0L
}
private val transSegGapMs = 6_000L
private var openTransSeg: TransSeg? = null
private var transSegTimer: Runnable? = null
private fun armSegTimer() {
transSegTimer?.let { uiHandler.removeCallbacks(it) }
val r = Runnable {
val seg = openTransSeg ?: return@Runnable
if (SystemClock.elapsedRealtime() - seg.lastAddMs > transSegGapMs) {
closeTransSeg()
}
}
transSegTimer = r
uiHandler.postDelayed(r, transSegGapMs + 500)
}
private fun closeTransSeg() {
val seg = openTransSeg ?: return
openTransSeg = null
transSegTimer?.let { uiHandler.removeCallbacks(it) }
transSegTimer = null
flushInitialBatch()
val all = seg.sentences.filter { it.text.isNotBlank() }
if (translateDisabled) return
// 单句段:初译成功就不用重翻;失败/缺失则兜底补这一句
val refs = if (all.size < 2)
all.filter { it.trans.isNullOrBlank() } else all
if (refs.isEmpty()) return
if (SummarizePipeline.detectLanguage(refs.joinToString("")) ==
uiLanguage()
) return
translateHandler.post {
val map = translateNumbered(refs.map {
Pair(it.id.toString(), it.text)
})
if (map == null || !plausibleTranslations(map.values)) {
Log.i(TAG, "SEG-TRANSLATE rejected/failed, keep drafts")
return@post
}
uiHandler.post {
val touched = HashSet<SubtitleLine>()
refs.forEach { s2 ->
val tr = map[s2.id.toString()]
when {
tr == null || tr.isBlank() ->
Log.i(TAG, "MISS sid=" + s2.id + " reason=seg-empty")
isEcho(s2.text, tr, echoJaccardTh(s2.text)) ->
Log.i(TAG, "MISS sid=" + s2.id + " reason=seg-echo")
!targetLangOk(tr) ->
Log.i(TAG, "MISS sid=" + s2.id + " reason=seg-drift" +
" sample=" + tr.take(24).replace("\n", " "))
else -> if (s2.trans != tr) {
s2.trans = tr
s2.line?.let { touched.add(it) }
}
}
}
touched.forEach { notifyLine(it) }
Log.i(TAG, "SEG-TRANSLATE applied " + refs.size + " sentences")
}
}
}
// 容错提取 "编号":"译文" 对:顺序扫描,不要求 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? {
val f = File(File(externalModelDir, translateModelDirName), translateGgufName)
if (!f.isFile) {
Log.i(TAG, "translate model missing at ${f.absolutePath}; bilingual off")
return null
}
return try {
LlmEngine(f.absolutePath, 4096, 2, chatTemplate = "gemma3")
.also { translateEngine = it }
} catch (t: Throwable) {
Log.w(TAG, "translate engine init failed: ${t.message}")
null
}
}
private fun armInitialBatchTimer() {
initialBatchTimer?.let { uiHandler.removeCallbacks(it) }
val r = Runnable { flushInitialBatch() }
initialBatchTimer = r
uiHandler.postDelayed(r, initialBatchDelayMs)
}
private fun flushInitialBatch() {
initialBatchTimer?.let { uiHandler.removeCallbacks(it) }
initialBatchTimer = null
if (initialPending.isEmpty() || translateDisabled) return
val batch = ArrayList(initialPending)
initialPending.clear()
translateHandler.post { runTranslateBatch(batch, 0f) }
}
// 翻译线程执行:直出/编号批 → 逐条质检(空/复读/语言漂移)→ 主线程应用。
// 生成失败或语言漂移 → 温度递进重试(每句最多 2 次,translateAttempts 仅在本线程读写)。
private fun runTranslateBatch(batch: List<Pair<Long, String>>, temp: Float) {
val map = translateNumbered(
batch.map { Pair(it.first.toString(), it.second) }, temp
)
if (map == null || !plausibleTranslations(map.values)) {
val retry = ArrayList<Pair<Long, String>>()
for ((sid, src) in batch) {
val att = bumpAttempt(sid)
Log.i(TAG, "MISS sid=" + sid + " reason=genfail attempt=" + att)
if (att <= 2) retry.add(Pair(sid, src))
}
if (retry.isNotEmpty()) {
translateHandler.post {
runTranslateBatch(retry, if (temp < 0.5f) 0.3f else 0.7f)
}
}
return
}
val good = HashMap<Long, String>()
val drift = ArrayList<Pair<Long, String>>()
for ((sid, src) in batch) {
val tr = map[sid.toString()]
when {
tr == null || tr.isBlank() ->
Log.i(TAG, "MISS sid=" + sid + " reason=empty")
isEcho(src, tr, echoJaccardTh(src)) ->
Log.i(TAG, "MISS sid=" + sid + " reason=echo")
!targetLangOk(tr) -> {
val att = bumpAttempt(sid)
Log.i(TAG, "MISS sid=" + sid + " reason=langdrift attempt=" +
att + " sample=" + tr.take(24).replace("\n", " "))
if (att <= 1) drift.add(Pair(sid, src))
}
else -> good[sid] = tr
}
}
uiHandler.post {
val touched = HashSet<SubtitleLine>()
good.forEach { (sid, tr) ->
findSentence(sid)?.let { s2 ->
if (s2.trans != tr) {
s2.trans = tr
s2.line?.let { touched.add(it) }
}
}
}
touched.forEach { notifyLine(it) }
}
if (drift.isNotEmpty()) translateHandler.post {
runTranslateBatch(drift, 0.3f)
}
}
// 一批 = 一个全新引擎(nativeGenerate 不清 KV 的 workaround)。
// items: key(通常是 sid 字符串) × 原文;返回 key × 译文,失败 null。
private fun translateNumbered(
items: List<Pair<String, String>>, temp: Float = 0f,
): Map<String, String>? {
if (items.isEmpty()) return emptyMap()
val targetName = when (uiLanguage()) {
"ja" -> "日本語"; "en" -> "English"; else -> "简体中文"
}
val langHint = when (uiLanguage()) {
"ja" -> "译文必须是日语(须含假名,不要输出中文或繁体)"
"zh" -> "译文必须是简体中文(不得用繁体字,不要输出日语或英语)"
else -> "The translation must be English only (no CJK characters)"
}
translateEngine?.let { runCatching { it.release() } }
translateEngine = null
val engine = ensureTranslateEngine()
?: run { translateDisabled = true; 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 one = try {
engine.generate(
systemPrompt = "你是专业翻译。把给出的句子翻译成" + targetName +
"。只输出译文本身,不要解释、不要引号、不要抄写原文。" +
"你只在中文、日本語、English 三种语言之间翻译;" +
"输入不属于这三种语言时输出空字符串。" + langHint + "。",
userPrompt = segKeys[0].second,
maxNewTokens = budget, temperature = temp,
)
} catch (t: Throwable) {
Log.w(TAG, "TRANSLATE single failed: " + t.message); null
}
val tr = one?.trim()?.lines()?.firstOrNull { it.isNotBlank() }?.trim()
if (!tr.isNullOrEmpty()) got[segKeys[0].first] = tr
if (got.isEmpty()) 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,
)
} catch (t: Throwable) {
Log.w(TAG, "TRANSLATE batch failed: " + t.message); null
}
val byIdx = extractNumberedPairs(out)
segKeys.forEachIndexed { i, st ->
byIdx?.get((i + 1).toString())?.takeIf { it.isNotBlank() }
?.let { got[st.first] = it }
}
// 模型对编号批会"犯懒跳号"(只写最后一段等)→ 缺失段逐一直出
// 兜底;每段重建引擎(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 }
one?.trim()?.lines()?.firstOrNull { it.isNotBlank() }
?.trim()?.takeIf { it.isNotEmpty() }
?.let { got[st.first] = it }
}
}
if (got.isEmpty()) 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 {
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
}
// 复读/退化检测:一半以上译文雷同 → 判退化,保留旧译文
private fun plausibleTranslations(vals: Collection<String>): Boolean {
val v = vals.filter { it.isNotBlank() }
return v.size < 2 || v.distinct().size * 2 >= v.size
}
// 复读检测:译文与原文归一化后全等/长串包含/字符二元组 Jaccard≥0.72
// → 判为"把原文抄进译文",该条丢弃(维持原状),不写入字幕
private fun normalizeForEcho(x: String): String =
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。
private val tradOnlyChars = "們這來說對學體豐華讓裡國變顯點單據觀"
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 (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
}
private val unloadRunnable = Runnable {
val e = translateEngine ?: return@Runnable
if (initialPending.isEmpty()) {
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) {
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)
}
}
}