Commit 1f08686ec09cdde48678fde1c5353f7bbabbb61b

Authored by yibo
1 parent 76b3421b

feat(lifelog): 统一「设置」tab — 集中所有可调参数

新增第 6 个 tab「设置」(SettingsScreen.kt),把散落各处的配置收进一页:
- 纪要:会话间隔 / 自动跑周期(改完即重排 WorkManager) / 会话最短语音 / 后端模型(2B/4B/Hermes)
- 声纹:聚类阈值(0.45–0.65)
- 本地模型端点:baseUrl / 2B / 4B model 名(ChatConfig)
- 录音/电池:白名单状态 + 申请

配套把写死的参数改成可配:
- SummaryWorker:KEY_PERIOD_MIN / KEY_MIN_SPEECH_SEC,min-speech 从 prefs 读
- SummaryScheduler:周期从 prefs 读(默认 15,硬下限 15)
- SpeakerClusterer:thresholdFromPrefs(),RecorderService 的 live 聚类器 +
  SpeakersTab 的 reclusterAll 都用 prefs 阈值

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize
25 import androidx.compose.foundation.layout.fillMaxWidth 25 import androidx.compose.foundation.layout.fillMaxWidth
26 import androidx.compose.foundation.layout.height 26 import androidx.compose.foundation.layout.height
27 import androidx.compose.foundation.layout.heightIn 27 import androidx.compose.foundation.layout.heightIn
  28 +import androidx.compose.foundation.layout.imePadding
28 import androidx.compose.foundation.layout.padding 29 import androidx.compose.foundation.layout.padding
29 import androidx.compose.foundation.layout.size 30 import androidx.compose.foundation.layout.size
30 import androidx.compose.foundation.layout.width 31 import androidx.compose.foundation.layout.width
@@ -104,6 +105,9 @@ import kotlinx.coroutines.withContext @@ -104,6 +105,9 @@ import kotlinx.coroutines.withContext
104 class MainActivity : ComponentActivity() { 105 class MainActivity : ComponentActivity() {
105 override fun onCreate(savedInstanceState: Bundle?) { 106 override fun onCreate(savedInstanceState: Bundle?) {
106 super.onCreate(savedInstanceState) 107 super.onCreate(savedInstanceState)
  108 + // edge-to-edge:让 IME / 系统栏 insets 传到 Compose(Android 15 targetSdk35 起必须),
  109 + // 否则键盘弹出会盖住聊天内容。Scaffold contentWindowInsets 默认 systemBars,会补回状态栏/导航栏 padding。
  110 + androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false)
107 setContent { 111 setContent {
108 MaterialTheme { 112 MaterialTheme {
109 Surface(modifier = Modifier.fillMaxSize()) { 113 Surface(modifier = Modifier.fillMaxSize()) {
@@ -120,6 +124,7 @@ private enum class Tab(val label: String, val icon: String) { @@ -120,6 +124,7 @@ private enum class Tab(val label: String, val icon: String) {
120 Timeline("时间轴", "≣"), 124 Timeline("时间轴", "≣"),
121 Speakers("声纹", "👤"), 125 Speakers("声纹", "👤"),
122 Summaries("纪要", "✎"), 126 Summaries("纪要", "✎"),
  127 + Settings("设置", "⚙"),
123 } 128 }
124 129
125 enum class SortMode(val label: String) { 130 enum class SortMode(val label: String) {
@@ -149,6 +154,8 @@ private fun MainScreen() { @@ -149,6 +154,8 @@ private fun MainScreen() {
149 val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList()) 154 val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList())
150 155
151 Scaffold( 156 Scaffold(
  157 + // imePadding:键盘弹出时整个 Scaffold(含底部 5 个 tab)抬到键盘上方,tab 仍可见可点。
  158 + modifier = Modifier.imePadding(),
152 bottomBar = { 159 bottomBar = {
153 NavigationBar { 160 NavigationBar {
154 Tab.entries.forEach { t -> 161 Tab.entries.forEach { t ->
@@ -191,6 +198,7 @@ private fun MainScreen() { @@ -191,6 +198,7 @@ private fun MainScreen() {
191 tab = Tab.Timeline 198 tab = Tab.Timeline
192 }, 199 },
193 ) 200 )
  201 + Tab.Settings -> SettingsScreen(activity)
194 } 202 }
195 } 203 }
196 } 204 }
@@ -707,6 +715,7 @@ private fun SpeakersTab( @@ -707,6 +715,7 @@ private fun SpeakersTab(
707 onFilterBySpeaker: (Long) -> Unit, 715 onFilterBySpeaker: (Long) -> Unit,
708 ) { 716 ) {
709 val scope = rememberCoroutineScope() 717 val scope = rememberCoroutineScope()
  718 + val ctx = LocalContext.current
710 val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList()) 719 val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList())
711 val countMap = remember(counts) { counts.associate { it.speakerId to it.count } } 720 val countMap = remember(counts) { counts.associate { it.speakerId to it.count } }
712 var reclustering by remember { mutableStateOf(false) } 721 var reclustering by remember { mutableStateOf(false) }
@@ -724,7 +733,9 @@ private fun SpeakersTab( @@ -724,7 +733,9 @@ private fun SpeakersTab(
724 onClick = { 733 onClick = {
725 reclustering = true 734 reclustering = true
726 scope.launch { 735 scope.launch {
727 - val n = withContext(Dispatchers.IO) { SpeakerClusterer.reclusterAll(repo) } 736 + val n = withContext(Dispatchers.IO) {
  737 + SpeakerClusterer.reclusterAll(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx))
  738 + }
728 reclustering = false 739 reclustering = false
729 reclusterMsg = "重新聚类完成:$n 个说话人" 740 reclusterMsg = "重新聚类完成:$n 个说话人"
730 } 741 }
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt 0 → 100644
  1 +package com.lemon.lifelog
  2 +
  3 +import android.annotation.SuppressLint
  4 +import android.content.Context
  5 +import android.content.Intent
  6 +import android.net.Uri
  7 +import android.os.PowerManager
  8 +import android.provider.Settings
  9 +import androidx.compose.foundation.layout.Arrangement
  10 +import androidx.compose.foundation.layout.Box
  11 +import androidx.compose.foundation.layout.Column
  12 +import androidx.compose.foundation.layout.Row
  13 +import androidx.compose.foundation.layout.Spacer
  14 +import androidx.compose.foundation.layout.fillMaxSize
  15 +import androidx.compose.foundation.layout.fillMaxWidth
  16 +import androidx.compose.foundation.layout.height
  17 +import androidx.compose.foundation.layout.padding
  18 +import androidx.compose.foundation.rememberScrollState
  19 +import androidx.compose.foundation.verticalScroll
  20 +import androidx.compose.material3.Button
  21 +import androidx.compose.material3.DropdownMenu
  22 +import androidx.compose.material3.DropdownMenuItem
  23 +import androidx.compose.material3.ExperimentalMaterial3Api
  24 +import androidx.compose.material3.FilterChip
  25 +import androidx.compose.material3.HorizontalDivider
  26 +import androidx.compose.material3.MaterialTheme
  27 +import androidx.compose.material3.OutlinedTextField
  28 +import androidx.compose.material3.Text
  29 +import androidx.compose.runtime.Composable
  30 +import androidx.compose.runtime.getValue
  31 +import androidx.compose.runtime.mutableStateOf
  32 +import androidx.compose.runtime.remember
  33 +import androidx.compose.runtime.setValue
  34 +import androidx.compose.ui.Alignment
  35 +import androidx.compose.ui.Modifier
  36 +import androidx.compose.ui.unit.dp
  37 +import com.lemon.lifelog.asr.SpeakerClusterer
  38 +import com.lemon.lifelog.chat.ChatConfig
  39 +import com.lemon.lifelog.summary.SummaryScheduler
  40 +import com.lemon.lifelog.summary.SummaryWorker
  41 +
  42 +/** 统一配置页:纪要 / 声纹 / 本地端点 / 录音电池。改完即存 SharedPreferences,下次跑生效。 */
  43 +@OptIn(ExperimentalMaterial3Api::class)
  44 +@Composable
  45 +fun SettingsScreen(activity: Context) {
  46 + val prefs = remember { activity.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) }
  47 +
  48 + var gapMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN)) }
  49 + var periodMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_PERIOD_MIN, SummaryWorker.DEFAULT_PERIOD_MIN)) }
  50 + var minSpeech by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_MIN_SPEECH_SEC, SummaryWorker.DEFAULT_MIN_SPEECH_SEC)) }
  51 + var backendId by remember { mutableStateOf(prefs.getString(SummaryWorker.KEY_BACKEND, ChatConfig.ID_2B) ?: ChatConfig.ID_2B) }
  52 + var threshold by remember { mutableStateOf(SpeakerClusterer.thresholdFromPrefs(activity)) }
  53 + val backendTargets = remember { ChatConfig.targets(activity) }
  54 +
  55 + var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) }
  56 + var m2b by remember { mutableStateOf(ChatConfig.model2b(activity)) }
  57 + var m4b by remember { mutableStateOf(ChatConfig.model4b(activity)) }
  58 + var endpointMsg by remember { mutableStateOf("") }
  59 +
  60 + var batteryExempt by remember { mutableStateOf(isBatteryExempt(activity)) }
  61 +
  62 + Column(
  63 + modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()),
  64 + verticalArrangement = Arrangement.spacedBy(10.dp),
  65 + ) {
  66 + Text("设置", style = MaterialTheme.typography.headlineSmall)
  67 +
  68 + Text("纪要", style = MaterialTheme.typography.titleMedium)
  69 + DropdownSetting("会话间隔(多久静音算另一场)", "$gapMin 分钟",
  70 + listOf(2, 5, 10, 15, 20, 30).map { "$it 分钟" to it }) {
  71 + gapMin = it; prefs.edit().putInt(SummaryWorker.KEY_GAP_MIN, it).apply()
  72 + }
  73 + DropdownSetting("自动跑纪要周期(≥15min 硬下限)", "$periodMin 分钟",
  74 + listOf(15, 30, 60).map { "$it 分钟" to it }) {
  75 + periodMin = it
  76 + prefs.edit().putInt(SummaryWorker.KEY_PERIOD_MIN, it).apply()
  77 + SummaryScheduler.schedulePeriodic(activity) // 立即重排
  78 + }
  79 + DropdownSetting("会话最短语音(短于此跳过)", "$minSpeech 秒",
  80 + listOf(10, 30, 60, 120).map { "$it 秒" to it }) {
  81 + minSpeech = it; prefs.edit().putInt(SummaryWorker.KEY_MIN_SPEECH_SEC, it).apply()
  82 + }
  83 + DropdownSetting("纪要模型后端", backendTargets.firstOrNull { it.id == backendId }?.label ?: "本地 2B",
  84 + backendTargets.map { it.label to it.id }) {
  85 + backendId = it; prefs.edit().putString(SummaryWorker.KEY_BACKEND, it).apply()
  86 + }
  87 +
  88 + HorizontalDivider()
  89 + Text("声纹", style = MaterialTheme.typography.titleMedium)
  90 + DropdownSetting("聚类阈值(越大分越细)", "%.2f".format(threshold),
  91 + listOf(0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) {
  92 + threshold = it; prefs.edit().putFloat(SpeakerClusterer.KEY_THRESHOLD, it).apply()
  93 + }
  94 + Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。",
  95 + style = MaterialTheme.typography.labelSmall)
  96 +
  97 + HorizontalDivider()
  98 + Text("本地模型端点(对话 + 纪要共用 llama-swap)", style = MaterialTheme.typography.titleMedium)
  99 + OutlinedTextField(value = localBase, onValueChange = { localBase = it },
  100 + label = { Text("baseUrl") }, singleLine = true, modifier = Modifier.fillMaxWidth())
  101 + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  102 + OutlinedTextField(value = m2b, onValueChange = { m2b = it },
  103 + label = { Text("2B model 名") }, singleLine = true, modifier = Modifier.weight(1f))
  104 + OutlinedTextField(value = m4b, onValueChange = { m4b = it },
  105 + label = { Text("4B model 名") }, singleLine = true, modifier = Modifier.weight(1f))
  106 + }
  107 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  108 + Button(onClick = {
  109 + ChatConfig.save(activity, localBase, m2b, m4b)
  110 + endpointMsg = "已保存"
  111 + }) { Text("保存端点") }
  112 + if (endpointMsg.isNotEmpty()) Text(endpointMsg, style = MaterialTheme.typography.labelSmall)
  113 + }
  114 +
  115 + HorizontalDivider()
  116 + Text("录音 / 电池", style = MaterialTheme.typography.titleMedium)
  117 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  118 + Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)",
  119 + style = MaterialTheme.typography.bodySmall)
  120 + if (!batteryExempt) {
  121 + Button(onClick = {
  122 + requestBatteryExempt(activity)
  123 + batteryExempt = isBatteryExempt(activity)
  124 + }) { Text("申请豁免") }
  125 + }
  126 + }
  127 +
  128 + Spacer(Modifier.height(24.dp))
  129 + }
  130 +}
  131 +
  132 +@OptIn(ExperimentalMaterial3Api::class)
  133 +@Composable
  134 +private fun <T> DropdownSetting(
  135 + label: String,
  136 + current: String,
  137 + options: List<Pair<String, T>>,
  138 + onPick: (T) -> Unit,
  139 +) {
  140 + var open by remember { mutableStateOf(false) }
  141 + Row(
  142 + modifier = Modifier.fillMaxWidth(),
  143 + verticalAlignment = Alignment.CenterVertically,
  144 + horizontalArrangement = Arrangement.spacedBy(8.dp),
  145 + ) {
  146 + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
  147 + Box {
  148 + FilterChip(selected = true, onClick = { open = true }, label = { Text(current) })
  149 + DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
  150 + options.forEach { (lbl, value) ->
  151 + DropdownMenuItem(text = { Text(lbl) }, onClick = { onPick(value); open = false })
  152 + }
  153 + }
  154 + }
  155 + }
  156 +}
  157 +
  158 +private fun isBatteryExempt(ctx: Context): Boolean {
  159 + val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
  160 + return pm.isIgnoringBatteryOptimizations(ctx.packageName)
  161 +}
  162 +
  163 +@SuppressLint("BatteryLife")
  164 +private fun requestBatteryExempt(ctx: Context) {
  165 + runCatching {
  166 + ctx.startActivity(
  167 + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
  168 + data = Uri.parse("package:${ctx.packageName}")
  169 + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
  170 + },
  171 + )
  172 + }
  173 +}
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt
@@ -97,10 +97,18 @@ class SpeakerClusterer( @@ -97,10 +97,18 @@ class SpeakerClusterer(
97 /** 自动簇命名前缀。重命名后不再以此开头,即视为「人工命名」。 */ 97 /** 自动簇命名前缀。重命名后不再以此开头,即视为「人工命名」。 */
98 const val AUTO_PREFIX = "说话人" 98 const val AUTO_PREFIX = "说话人"
99 99
100 - /** CAMPPlus 余弦阈值:同人通常 > 0.55,异人 < 0.45。0.5 偏松易过度合并  
101 - * (4B 测试时「说话人4」吃了 23 段),收紧到 0.55 分得更细。再细可上 0.6。 */ 100 + /** CAMPPlus 余弦阈值默认值:同人通常 > 0.55,异人 < 0.45。0.5 偏松易过度合并
  101 + * (4B 测试时「说话人4」吃了 23 段),0.55 分得更细。设置页可调,再细可上 0.6。 */
102 const val MATCH_THRESHOLD = 0.55f 102 const val MATCH_THRESHOLD = 0.55f
103 103
  104 + /** 阈值持久化(设置页可调),存在 lifelog_prefs。 */
  105 + const val PREFS = "lifelog_prefs"
  106 + const val KEY_THRESHOLD = "speaker_threshold"
  107 +
  108 + fun thresholdFromPrefs(ctx: android.content.Context): Float =
  109 + ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE)
  110 + .getFloat(KEY_THRESHOLD, MATCH_THRESHOLD)
  111 +
104 /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */ 112 /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */
105 const val MIN_NEW_CLUSTER_MS = 1000L 113 const val MIN_NEW_CLUSTER_MS = 1000L
106 114
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt
@@ -202,7 +202,7 @@ class RecorderService : Service() { @@ -202,7 +202,7 @@ class RecorderService : Service() {
202 } 202 }
203 203
204 // 2d. Online speaker clusterer (auto-discovers 说话人N; persists as speaker_profiles). 204 // 2d. Online speaker clusterer (auto-discovers 说话人N; persists as speaker_profiles).
205 - clusterer = SpeakerClusterer(repo).also { it.load() } 205 + clusterer = SpeakerClusterer(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(this)).also { it.load() }
206 // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer. 206 // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer.
207 // load() is mutex-serialised vs assign() and idempotent, so reloading on every 207 // load() is mutex-serialised vs assign() and idempotent, so reloading on every
208 // speaker_profiles change (incl. the clusterer's own write-throughs) is safe. 208 // speaker_profiles change (incl. the clusterer's own write-throughs) is safe.
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt
@@ -20,9 +20,11 @@ object SummaryScheduler { @@ -20,9 +20,11 @@ object SummaryScheduler {
20 .setRequiredNetworkType(NetworkType.NOT_REQUIRED) // 后端都在本机 20 .setRequiredNetworkType(NetworkType.NOT_REQUIRED) // 后端都在本机
21 .setRequiresBatteryNotLow(true) 21 .setRequiresBatteryNotLow(true)
22 .build() 22 .build()
23 - // 15 分钟是 WorkManager periodic 的硬下限(做不到正好 10min)。会话关闭后  
24 - // 最多 ~15min 出纪要。worker 自带并发互斥 + 时间预算,频繁触发也不会堆积。  
25 - val request = PeriodicWorkRequestBuilder<SummaryWorker>(15, TimeUnit.MINUTES) 23 + // 周期从设置页读(默认 15min);WorkManager periodic 硬下限 15,做不到更短。
  24 + val periodMin = ctx.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE)
  25 + .getInt(SummaryWorker.KEY_PERIOD_MIN, SummaryWorker.DEFAULT_PERIOD_MIN)
  26 + .coerceAtLeast(15)
  27 + val request = PeriodicWorkRequestBuilder<SummaryWorker>(periodMin.toLong(), TimeUnit.MINUTES)
26 .setConstraints(constraints) 28 .setConstraints(constraints)
27 .build() 29 .build()
28 WorkManager.getInstance(ctx).enqueueUniquePeriodicWork( 30 WorkManager.getInstance(ctx).enqueueUniquePeriodicWork(
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt
@@ -51,6 +51,14 @@ class SummaryWorker( @@ -51,6 +51,14 @@ class SummaryWorker(
51 * 端点/model 由 ChatConfig 解析(与对话共用配置)。 */ 51 * 端点/model 由 ChatConfig 解析(与对话共用配置)。 */
52 const val KEY_BACKEND = "summary_backend" 52 const val KEY_BACKEND = "summary_backend"
53 53
  54 + /** 自动跑纪要周期(分钟)。WorkManager periodic 硬下限 15。 */
  55 + const val KEY_PERIOD_MIN = "summary_period_min"
  56 + const val DEFAULT_PERIOD_MIN = 15
  57 +
  58 + /** 会话最短语音(秒):低于此跳过不总结。 */
  59 + const val KEY_MIN_SPEECH_SEC = "summary_min_speech_sec"
  60 + const val DEFAULT_MIN_SPEECH_SEC = 30
  61 +
54 /** 防并发:periodic + one-shot + RETRY 可能同时触发,多个 worker 并发打同一个 62 /** 防并发:periodic + one-shot + RETRY 可能同时触发,多个 worker 并发打同一个
55 * llama-server 会把上游搞 502(实测)。进程内 flag 保证同时只跑一个 worker body。 */ 63 * llama-server 会把上游搞 502(实测)。进程内 flag 保证同时只跑一个 worker body。 */
56 private val isRunning = java.util.concurrent.atomic.AtomicBoolean(false) 64 private val isRunning = java.util.concurrent.atomic.AtomicBoolean(false)
@@ -95,6 +103,7 @@ class SummaryWorker( @@ -95,6 +103,7 @@ class SummaryWorker(
95 val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 103 val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
96 val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 } 104 val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 }
97 ?: (prefs.getInt(KEY_GAP_MIN, DEFAULT_GAP_MIN) * 60_000L) 105 ?: (prefs.getInt(KEY_GAP_MIN, DEFAULT_GAP_MIN) * 60_000L)
  106 + val minSpeechMs = prefs.getInt(KEY_MIN_SPEECH_SEC, DEFAULT_MIN_SPEECH_SEC) * 1000L
98 107
99 // 解析后端(UI 选的 ChatTarget:local-2b / local-4b / hermes)。 108 // 解析后端(UI 选的 ChatTarget:local-2b / local-4b / hermes)。
100 val target = com.lemon.lifelog.chat.ChatConfig.targetById( 109 val target = com.lemon.lifelog.chat.ChatConfig.targetById(
@@ -127,7 +136,7 @@ class SummaryWorker( @@ -127,7 +136,7 @@ class SummaryWorker(
127 // 未结束的会话不动(可能还会有新段并入)。 136 // 未结束的会话不动(可能还会有新段并入)。
128 if (now - end <= gapMs) { skipped++; continue } 137 if (now - end <= gapMs) { skipped++; continue }
129 // 太短跳过。 138 // 太短跳过。
130 - if (session.sumOf { it.durationMs } < MIN_SESSION_SPEECH_MS) { skipped++; continue } 139 + if (session.sumOf { it.durationMs } < minSpeechMs) { skipped++; continue }
131 // 已总结过(时间范围重叠)跳过。 140 // 已总结过(时间范围重叠)跳过。
132 if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) { skipped++; continue } 141 if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) { skipped++; continue }
133 142