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