2593 lines
118 KiB
Kotlin
2593 lines
118 KiB
Kotlin
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)
|
||
}
|
||
}
|
||
}
|