Commit 393dd2545ff31294b1efee173991aba4dd2191dc

Authored by yibo
1 parent 83ef0a01

feat(lifelog): 录音 tab 已录制秒表 + 录音记录列表 + 声纹短段过滤/清空重识别 + 分段间隔可配

录音 tab:
- 录音中显示「⏱ 已录制 mm:ss」秒表(RecorderState 记 startedAt/accum,暂停冻结、继续累加)
- 「最近转写」换成「录音记录」列表:按 audioPath 聚合录音块,每行显示起止/时长/转写状态/字数/人数/段数,点击跳时间轴定位该块

声纹:
- 短段不认声纹:assign/reclusterAll 加最短识别时长闸门(默认 3s,设置页 1–8s 可调),止住短段量产伪说话人
- 新增 clearAllAndRecluster + 「声纹」tab「清空重识别」按钮:删全部声纹+清归属后从零重聚类

录音分段:
- BlockBuffer 静音切段间隔由编译期常量改为构造参数,设置页「分段间隔」1–15min 可调(下次服务启动生效)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
... ... @@ -85,6 +85,7 @@ import com.lemon.lifelog.audio.SegmentPlayer
85 85 import com.lemon.lifelog.chat.ChatConfig
86 86 import com.lemon.lifelog.summary.SummaryProgress
87 87 import com.lemon.lifelog.summary.SummaryRunLog
  88 +import com.lemon.lifelog.data.BlockRow
88 89 import com.lemon.lifelog.data.Repository
89 90 import com.lemon.lifelog.data.SegmentRow
90 91 import com.lemon.lifelog.data.SpeakerProfile
... ... @@ -213,7 +214,12 @@ private fun RecordingScreen(
213 214 }
214 215 Box(modifier = Modifier.weight(1f)) {
215 216 when (sub) {
216   - 0 -> HomeTab(activity, repo, onOpenTimeline = { onSub(1) })
  217 + 0 -> HomeTab(
  218 + activity, repo,
  219 + onOpenTimeline = { onSub(1) },
  220 + // 点录音记录 → 清筛选 + 跳时间轴对应块(按块起始时间定位时间段)
  221 + onOpenBlock = { ts -> onFilterSpeakerId(null); onJumpTo(ts); onSub(1) },
  222 + )
217 223 1 -> TimelineTab(
218 224 repo = repo,
219 225 speakers = speakers,
... ... @@ -240,15 +246,27 @@ private fun RecordingScreen(
240 246 // ============================================================ Home tab
241 247  
242 248 @Composable
243   -private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> Unit) {
  249 +private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> Unit, onOpenBlock: (Long) -> Unit) {
244 250 val running by RecorderState.isRunning.collectAsState()
245 251 val paused by RecorderState.isPaused.collectAsState()
246 252 val status by RecorderState.status.collectAsState()
247 253 val segmentCount by RecorderState.segmentCount.collectAsState()
248 254 val lastAudioMs by RecorderState.lastAudioMs.collectAsState()
249 255 val lastWallMs by RecorderState.lastWallMs.collectAsState()
  256 + val recStartedAt by RecorderState.recordingStartedAt.collectAsState()
  257 + val recAccumMs by RecorderState.recordingAccumMs.collectAsState()
250 258 val scope = rememberCoroutineScope()
251   - val recent by repo.segments.observeTimeline(5).collectAsState(initial = emptyList())
  259 + val blocks by repo.segments.observeBlocks(50).collectAsState(initial = emptyList())
  260 +
  261 + // 已录制秒表:每秒滴答一次,让 elapsed 重算。
  262 + var nowTick by remember { mutableStateOf(0L) }
  263 + LaunchedEffect(running) {
  264 + while (running) {
  265 + nowTick = System.currentTimeMillis()
  266 + delay(1000)
  267 + }
  268 + }
  269 + val elapsedMs = recAccumMs + if (recStartedAt > 0) (nowTick - recStartedAt).coerceAtLeast(0) else 0
252 270  
253 271 var asrStatus by remember { mutableStateOf("ASR: idle") }
254 272 var asrLog by remember { mutableStateOf("") }
... ... @@ -285,6 +303,15 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -&gt; U
285 303 }
286 304 Text("$stateLabel $status")
287 305  
  306 + // 已录制秒表:录音中显示当前这段连续采集的累计时长。
  307 + if (running) {
  308 + Text(
  309 + "⏱ 已录制 ${fmtClock(elapsedMs)}",
  310 + style = MaterialTheme.typography.headlineSmall,
  311 + color = if (paused) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
  312 + )
  313 + }
  314 +
288 315 if (!running) {
289 316 Button(onClick = {
290 317 val perms = buildList {
... ... @@ -318,23 +345,25 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -&gt; U
318 345  
319 346 HorizontalDivider()
320 347  
321   - // 最近转写(实时):让用户在首页就能看到实时进度。
  348 + // 录音记录:每个已处理「录音块」一行(起止/时长/状态/字数/人数),点击跳时间轴看该块详情。
322 349 Row(
323 350 modifier = Modifier.fillMaxWidth(),
324 351 horizontalArrangement = Arrangement.SpaceBetween,
325 352 verticalAlignment = Alignment.CenterVertically,
326 353 ) {
327   - Text("最近转写", style = MaterialTheme.typography.titleSmall)
  354 + Text("录音记录", style = MaterialTheme.typography.titleSmall)
328 355 TextButton(onClick = onOpenTimeline) { Text("查看全部 →") }
329 356 }
330   - if (recent.isEmpty()) {
  357 + if (blocks.isEmpty()) {
331 358 Text(
332   - if (running) "正在聆听…说话后这里会实时出现转写" else "还没有转写。开始录音后这里实时刷新。",
  359 + if (running) "正在录音…块在静音≥5min 或满 10min 时收尾,随后出现在这里。"
  360 + else "还没有录音记录。开始录音后,处理完的录音块会显示在这里。",
333 361 style = MaterialTheme.typography.bodySmall,
334 362 )
335 363 } else {
336   - recent.forEach { row ->
337   - TimelineRow(row = row, isPlaying = false, onClick = onOpenTimeline)
  364 + blocks.forEach { b ->
  365 + BlockRowItem(b = b, onClick = { onOpenBlock(b.startTs) })
  366 + HorizontalDivider()
338 367 }
339 368 }
340 369  
... ... @@ -816,6 +845,7 @@ private fun SpeakersTab(
816 845 val countMap = remember(counts) { counts.associate { it.speakerId to it.count } }
817 846 var reclustering by remember { mutableStateOf(false) }
818 847 var reclusterMsg by remember { mutableStateOf("") }
  848 + var clearConfirm by remember { mutableStateOf(false) }
819 849  
820 850 var renameFor by remember { mutableStateOf<SpeakerProfile?>(null) }
821 851 var mergeFor by remember { mutableStateOf<SpeakerProfile?>(null) }
... ... @@ -830,13 +860,21 @@ private fun SpeakersTab(
830 860 reclustering = true
831 861 scope.launch {
832 862 val n = withContext(Dispatchers.IO) {
833   - SpeakerClusterer.reclusterAll(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx))
  863 + SpeakerClusterer.reclusterAll(
  864 + repo,
  865 + matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx),
  866 + minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx),
  867 + )
834 868 }
835 869 reclustering = false
836 870 reclusterMsg = "重新聚类完成:$n 个说话人"
837 871 }
838 872 },
839 873 ) { Text(if (reclustering) "聚类中…" else "重新聚类") }
  874 + OutlinedButton(
  875 + enabled = !reclustering,
  876 + onClick = { clearConfirm = true },
  877 + ) { Text("清空重识别") }
840 878 }
841 879 if (reclusterMsg.isNotEmpty()) {
842 880 Text(reclusterMsg, style = MaterialTheme.typography.bodySmall)
... ... @@ -922,6 +960,29 @@ private fun SpeakersTab(
922 960 },
923 961 )
924 962 }
  963 + if (clearConfirm) {
  964 + ConfirmDialog(
  965 + title = "清空全部声纹并重新识别?",
  966 + message = "会删除全部说话人(含已命名的),清掉所有段的归属,再按当前阈值/最短识别时长从零重认。段本身和转写不受影响。",
  967 + confirmLabel = "清空重识别",
  968 + onDismiss = { clearConfirm = false },
  969 + onConfirm = {
  970 + clearConfirm = false
  971 + reclustering = true
  972 + scope.launch {
  973 + val n = withContext(Dispatchers.IO) {
  974 + SpeakerClusterer.clearAllAndRecluster(
  975 + repo,
  976 + matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx),
  977 + minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx),
  978 + )
  979 + }
  980 + reclustering = false
  981 + reclusterMsg = "已清空并重新识别:$n 个说话人"
  982 + }
  983 + },
  984 + )
  985 + }
925 986 }
926 987  
927 988 // ============================================================ Summaries tab
... ... @@ -1399,6 +1460,37 @@ private fun TimelineRow(
1399 1460 }
1400 1461 }
1401 1462  
  1463 +/** 录音 tab 的一条「录音块」记录:起止时间 / 时长 / 转写状态 / 字数 / 说话人数。 */
  1464 +@Composable
  1465 +private fun BlockRowItem(b: BlockRow, onClick: () -> Unit) {
  1466 + val sameDay = BLOCK_DAY_FMT.format(Date(b.startTs)) == BLOCK_DAY_FMT.format(Date(b.endTs))
  1467 + val startLabel = BLOCK_FMT.format(Date(b.startTs))
  1468 + val endLabel = (if (sameDay) BLOCK_TIME_FMT else BLOCK_FMT).format(Date(b.endTs))
  1469 + val done = b.transcribedCount >= b.turnCount && b.charCount > 0
  1470 + Column(
  1471 + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 6.dp),
  1472 + verticalArrangement = Arrangement.spacedBy(3.dp),
  1473 + ) {
  1474 + Row(
  1475 + modifier = Modifier.fillMaxWidth(),
  1476 + horizontalArrangement = Arrangement.SpaceBetween,
  1477 + ) {
  1478 + Text("$startLabel – $endLabel", style = MaterialTheme.typography.bodyMedium)
  1479 + Text("时长 ${fmtClock(b.endTs - b.startTs)}", style = MaterialTheme.typography.bodyMedium)
  1480 + }
  1481 + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
  1482 + Text(
  1483 + if (done) "✓ 已转写" else "… 处理中",
  1484 + style = MaterialTheme.typography.labelSmall,
  1485 + color = if (done) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
  1486 + )
  1487 + Text("${b.charCount} 字", style = MaterialTheme.typography.labelSmall)
  1488 + Text("${b.speakerCount} 人", style = MaterialTheme.typography.labelSmall)
  1489 + Text("${b.turnCount} 段", style = MaterialTheme.typography.labelSmall)
  1490 + }
  1491 + }
  1492 +}
  1493 +
1402 1494 // ---- 时间段分组(按 5min 静音切,= 录音块边界)----
1403 1495  
1404 1496 private const val TIMELINE_GROUP_GAP_MS = 5 * 60 * 1000L
... ... @@ -1516,6 +1608,18 @@ private fun exportSegments(ctx: Context, rows: List&lt;SegmentRow&gt;) {
1516 1608 // ============================================================ helpers
1517 1609  
1518 1610 private val TIMELINE_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US)
  1611 +private val BLOCK_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US)
  1612 +private val BLOCK_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US)
  1613 +private val BLOCK_DAY_FMT = SimpleDateFormat("yyyy-MM-dd", Locale.US)
  1614 +
  1615 +/** ms → mm:ss(不足 1 小时)或 h:mm:ss。 */
  1616 +private fun fmtClock(ms: Long): String {
  1617 + val totalSec = (ms / 1000).coerceAtLeast(0)
  1618 + val h = totalSec / 3600
  1619 + val m = (totalSec % 3600) / 60
  1620 + val s = totalSec % 60
  1621 + return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
  1622 +}
1519 1623 private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US)
1520 1624 private val SUMMARY_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US)
1521 1625 private val SUMMARY_DAY_FMT = SimpleDateFormat("yyyy-MM-dd", Locale.US)
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt
... ... @@ -35,6 +35,7 @@ import androidx.compose.ui.Alignment
35 35 import androidx.compose.ui.Modifier
36 36 import androidx.compose.ui.unit.dp
37 37 import com.lemon.lifelog.asr.SpeakerClusterer
  38 +import com.lemon.lifelog.service.BlockBuffer
38 39 import com.lemon.lifelog.chat.ChatConfig
39 40 import com.lemon.lifelog.summary.SummaryScheduler
40 41 import com.lemon.lifelog.summary.SummaryWorker
... ... @@ -50,6 +51,12 @@ fun SettingsScreen(activity: Context) {
50 51 var minSpeech by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_MIN_SPEECH_SEC, SummaryWorker.DEFAULT_MIN_SPEECH_SEC)) }
51 52 var backendId by remember { mutableStateOf(prefs.getString(SummaryWorker.KEY_BACKEND, ChatConfig.ID_2B) ?: ChatConfig.ID_2B) }
52 53 var threshold by remember { mutableStateOf(SpeakerClusterer.thresholdFromPrefs(activity)) }
  54 + var minRecognizeSec by remember {
  55 + mutableStateOf(prefs.getInt(SpeakerClusterer.KEY_MIN_RECOGNIZE_SEC, (SpeakerClusterer.MIN_RECOGNIZE_MS / 1000).toInt()))
  56 + }
  57 + var blockSilenceMin by remember {
  58 + mutableStateOf(prefs.getInt(BlockBuffer.KEY_SILENCE_MIN, (BlockBuffer.SILENCE_MS / 60_000).toInt()))
  59 + }
53 60 val backendTargets = remember { ChatConfig.targets(activity) }
54 61  
55 62 var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) }
... ... @@ -91,6 +98,10 @@ fun SettingsScreen(activity: Context) {
91 98 listOf(0.35f, 0.40f, 0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) {
92 99 threshold = it; prefs.edit().putFloat(SpeakerClusterer.KEY_THRESHOLD, it).apply()
93 100 }
  101 + DropdownSetting("最短识别时长(短于此不认声纹)", "$minRecognizeSec 秒",
  102 + listOf(1, 2, 3, 5, 8).map { "$it 秒" to it }) {
  103 + minRecognizeSec = it; prefs.edit().putInt(SpeakerClusterer.KEY_MIN_RECOGNIZE_SEC, it).apply()
  104 + }
94 105 Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。",
95 106 style = MaterialTheme.typography.labelSmall)
96 107  
... ... @@ -114,6 +125,11 @@ fun SettingsScreen(activity: Context) {
114 125  
115 126 HorizontalDivider()
116 127 Text("录音 / 电池", style = MaterialTheme.typography.titleMedium)
  128 + DropdownSetting("分段间隔(静音多久收尾一段)", "$blockSilenceMin 分钟",
  129 + listOf(1, 2, 3, 5, 10, 15).map { "$it 分钟" to it }) {
  130 + blockSilenceMin = it; prefs.edit().putInt(BlockBuffer.KEY_SILENCE_MIN, it).apply()
  131 + }
  132 + Text("改完下次录音服务启动生效(停止再开始 / 重启 app)。", style = MaterialTheme.typography.labelSmall)
117 133 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
118 134 Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)",
119 135 style = MaterialTheme.typography.bodySmall)
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt
... ... @@ -27,6 +27,7 @@ class SpeakerClusterer(
27 27 private val repo: Repository,
28 28 private val matchThreshold: Float = MATCH_THRESHOLD,
29 29 private val minNewClusterMs: Long = MIN_NEW_CLUSTER_MS,
  30 + private val minRecognizeMs: Long = MIN_RECOGNIZE_MS,
30 31 private val maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID,
31 32 ) {
32 33 private val mutex = Mutex()
... ... @@ -48,6 +49,9 @@ class SpeakerClusterer(
48 49  
49 50 /** 将一个 embedding 归入/新建说话人簇,写穿到 DB。 */
50 51 suspend fun assign(emb: FloatArray, durationMs: Long): Assignment = mutex.withLock {
  52 + // 太短的段不认声纹:CAMPPlus 在 <3s 上区分度差,强行匹配/开新簇会量产伪说话人。
  53 + if (durationMs < minRecognizeMs) return@withLock Assignment(null, null, null)
  54 +
51 55 var best: Cluster? = null
52 56 var bestSim = -1f
53 57 for (c in clusters) {
... ... @@ -109,14 +113,23 @@ class SpeakerClusterer(
109 113 /** 阈值持久化(设置页可调),存在 lifelog_prefs。 */
110 114 const val PREFS = "lifelog_prefs"
111 115 const val KEY_THRESHOLD = "speaker_threshold"
  116 + const val KEY_MIN_RECOGNIZE_SEC = "speaker_min_recognize_sec"
112 117  
113 118 fun thresholdFromPrefs(ctx: android.content.Context): Float =
114 119 ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE)
115 120 .getFloat(KEY_THRESHOLD, MATCH_THRESHOLD)
116 121  
  122 + /** 最短识别时长(设置页可调,秒):短于此的段完全跳过声纹识别。 */
  123 + fun minRecognizeMsFromPrefs(ctx: android.content.Context): Long =
  124 + ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE)
  125 + .getInt(KEY_MIN_RECOGNIZE_SEC, (MIN_RECOGNIZE_MS / 1000).toInt()) * 1000L
  126 +
117 127 /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */
118 128 const val MIN_NEW_CLUSTER_MS = 1000L
119 129  
  130 + /** 短于此时长的段完全不识别声纹(不匹配、不开新簇)。默认 3s——CAMPPlus 在更短段上不可信。 */
  131 + const val MIN_RECOGNIZE_MS = 3000L
  132 +
120 133 /** 质心滑动均值的样本上限。 */
121 134 const val MAX_SAMPLES_PER_CENTROID = 50
122 135  
... ... @@ -156,6 +169,7 @@ class SpeakerClusterer(
156 169 repo: Repository,
157 170 matchThreshold: Float = MATCH_THRESHOLD,
158 171 minNewClusterMs: Long = MIN_NEW_CLUSTER_MS,
  172 + minRecognizeMs: Long = MIN_RECOGNIZE_MS,
159 173 maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID,
160 174 ): Int = withContext(Dispatchers.IO) {
161 175 // 1. 删掉自动簇,保留人工命名簇作种子。
... ... @@ -174,6 +188,11 @@ class SpeakerClusterer(
174 188 val segs = repo.segments.allWithEmbedding().sortedBy { it.startTs }
175 189 for (seg in segs) {
176 190 val emb = seg.embedding?.toFloatArrayLe() ?: continue
  191 + // 太短的段不认声纹:清掉旧归属,跳过(与在线 assign 同口径)。
  192 + if (seg.durationMs < minRecognizeMs) {
  193 + repo.segments.updateSpeaker(seg.id, null, null)
  194 + continue
  195 + }
177 196 var best: Cluster? = null
178 197 var bestSim = -1f
179 198 for (c in clusters) {
... ... @@ -218,6 +237,23 @@ class SpeakerClusterer(
218 237 Log.i(TAG, "reclusterAll: ${segs.size} segments → ${clusters.size} clusters")
219 238 clusters.size
220 239 }
  240 +
  241 + /**
  242 + * 彻底清空声纹库(含人工命名 anchor)+ 清掉所有段的归属,然后从零重新聚类。
  243 + * 用途:用户想推倒重来(如调过阈值/最短识别时长后,让历史段按新规则重认)。
  244 + * @return 跑完后的簇总数
  245 + */
  246 + suspend fun clearAllAndRecluster(
  247 + repo: Repository,
  248 + matchThreshold: Float = MATCH_THRESHOLD,
  249 + minNewClusterMs: Long = MIN_NEW_CLUSTER_MS,
  250 + minRecognizeMs: Long = MIN_RECOGNIZE_MS,
  251 + ): Int = withContext(Dispatchers.IO) {
  252 + repo.segments.clearAllSpeakerAssignments()
  253 + for (p in repo.speakers.all()) repo.speakers.delete(p.id)
  254 + Log.i(TAG, "clearAllAndRecluster: wiped all profiles + assignments")
  255 + reclusterAll(repo, matchThreshold, minNewClusterMs, minRecognizeMs)
  256 + }
221 257 }
222 258 }
223 259  
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
... ... @@ -21,6 +21,26 @@ data class SpeakerCount(
21 21 val count: Int,
22 22 )
23 23  
  24 +/**
  25 + * 一个「录音块」的聚合行(录音 tab 的录音记录列表用)。
  26 + * 块没有独立表——同一块的所有 turn 共享同一份「块完整录音」文件,故按 [audioPath] 分组还原。
  27 + */
  28 +data class BlockRow(
  29 + val audioPath: String,
  30 + /** 块内最早 turn 的起始墙钟 */
  31 + val startTs: Long,
  32 + /** 块内最晚 turn 的结束墙钟(startTs + durationMs 的最大值) */
  33 + val endTs: Long,
  34 + /** 块内 turn 数 */
  35 + val turnCount: Int,
  36 + /** 已出转写文本的 turn 数(= transcripts 命中数) */
  37 + val transcribedCount: Int,
  38 + /** 块内转写总字数 */
  39 + val charCount: Int,
  40 + /** 块内出现的不同说话人数(已归属的去重计数) */
  41 + val speakerCount: Int,
  42 +)
  43 +
24 44 @Dao
25 45 interface SegmentDao {
26 46 @Insert
... ... @@ -49,6 +69,10 @@ interface SegmentDao {
49 69 @Query("UPDATE segments SET speakerId = NULL, speakerSim = NULL WHERE speakerId = :speakerId")
50 70 suspend fun clearSpeakerAssignments(speakerId: Long)
51 71  
  72 + /** 清空全部段的说话人归属(清空声纹库重识别时用)。 */
  73 + @Query("UPDATE segments SET speakerId = NULL, speakerSim = NULL")
  74 + suspend fun clearAllSpeakerAssignments()
  75 +
52 76 /** 合并说话人:把 [from] 名下段落改派给 [into]。 */
53 77 @Query("UPDATE segments SET speakerId = :into WHERE speakerId = :from")
54 78 suspend fun reassignSpeaker(from: Long, into: Long)
... ... @@ -109,6 +133,28 @@ interface SegmentDao {
109 133 )
110 134 fun observeTimeline(limit: Int = 100): Flow<List<SegmentRow>>
111 135  
  136 + /**
  137 + * 按「录音块」聚合(录音 tab 的录音记录列表)。同块 turn 共享 audioPath → GROUP BY 还原块。
  138 + * 旧逐句段每段自成一文件,会各自成 1-turn 块(可接受)。
  139 + */
  140 + @Query(
  141 + """
  142 + SELECT s.audioPath AS audioPath,
  143 + MIN(s.startTs) AS startTs,
  144 + MAX(s.startTs + s.durationMs) AS endTs,
  145 + COUNT(*) AS turnCount,
  146 + COUNT(t.segmentId) AS transcribedCount,
  147 + COALESCE(SUM(LENGTH(t.text)), 0) AS charCount,
  148 + COUNT(DISTINCT s.speakerId) AS speakerCount
  149 + FROM segments s
  150 + LEFT JOIN transcripts t ON t.segmentId = s.id
  151 + GROUP BY s.audioPath
  152 + ORDER BY startTs DESC
  153 + LIMIT :limit
  154 + """,
  155 + )
  156 + fun observeBlocks(limit: Int = 50): Flow<List<BlockRow>>
  157 +
112 158 /** 某声纹(说话人)名下的录音段(人物档案页"录音列表"用)。 */
113 159 @Query(
114 160 """
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockBuffer.kt
... ... @@ -21,7 +21,12 @@ import org.json.JSONObject
21 21 * [close] 落 manifest(closed=true) 后交给 BlockProcessor;崩溃时块目录仍在,重启 [listPending] 重扫。
22 22 * 只存 speech(静音不落盘)→ 内存/磁盘按语音时长计,不按墙钟。
23 23 */
24   -class BlockBuffer(private val root: File, private val sampleRate: Int = 16000) {
  24 +class BlockBuffer(
  25 + private val root: File,
  26 + private val sampleRate: Int = 16000,
  27 + /** 静音多久收尾一段(ms);设置页可调,默认 5min。 */
  28 + private val silenceMs: Long = SILENCE_MS,
  29 +) {
25 30  
26 31 data class Run(val ts: Long, val off: Int, val len: Int)
27 32  
... ... @@ -56,7 +61,7 @@ class BlockBuffer(private val root: File, private val sampleRate: Int = 16000) {
56 61 /** 在 [ts] 这一刻是否该关块(静音超时或块超长)。块未开恒 false。 */
57 62 fun shouldClose(ts: Long): Boolean {
58 63 if (!isOpen) return false
59   - return (ts - lastSpeechEndTs >= SILENCE_MS) || (ts - blockStartTs >= MAX_MS)
  64 + return (ts - lastSpeechEndTs >= silenceMs) || (ts - blockStartTs >= MAX_MS)
60 65 }
61 66  
62 67 /** 关块:落 manifest(closed=true),返回块目录;空块直接删并返回 null。 */
... ... @@ -99,9 +104,18 @@ class BlockBuffer(private val root: File, private val sampleRate: Int = 16000) {
99 104  
100 105 companion object {
101 106 private const val TAG = "BlockBuffer"
102   - const val SILENCE_MS = 5 * 60 * 1000L // 静音 ≥5min 切块
  107 + const val SILENCE_MS = 5 * 60 * 1000L // 静音 ≥5min 切块(默认)
103 108 const val MAX_MS = 10 * 60 * 1000L // 块 ≥10min 强切(长会议保活)
104 109  
  110 + /** 分段静音间隔持久化(设置页可调,分钟),存在 lifelog_prefs。 */
  111 + const val PREFS = "lifelog_prefs"
  112 + const val KEY_SILENCE_MIN = "block_silence_min"
  113 +
  114 + /** 从 prefs 读分段间隔(ms),默认 5min。改完下次服务启动生效。 */
  115 + fun silenceMsFromPrefs(ctx: android.content.Context): Long =
  116 + ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE)
  117 + .getInt(KEY_SILENCE_MIN, (SILENCE_MS / 60_000).toInt()) * 60_000L
  118 +
105 119 data class Loaded(val startTs: Long, val sampleRate: Int, val samples: FloatArray, val runs: List<Run>)
106 120  
107 121 /** 启动时列出所有未处理块目录(崩溃恢复:处理后目录会被删除)。 */
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt
... ... @@ -88,7 +88,7 @@ class RecorderService : Service() {
88 88 RecorderState.reset()
89 89 repo = Repository(this)
90 90 segWriter = SegmentWriter(File(filesDir, "segments"))
91   - blockBuffer = BlockBuffer(File(filesDir, "blocks"))
  91 + blockBuffer = BlockBuffer(File(filesDir, "blocks"), silenceMs = BlockBuffer.silenceMsFromPrefs(this))
92 92 capture = AudioCapture()
93 93  
94 94 pipelineJob = scope.launch { runPipeline() }
... ... @@ -107,6 +107,7 @@ class RecorderService : Service() {
107 107 paused = true
108 108 capture.stop()
109 109 wakeLock?.takeIf { it.isHeld }?.release()
  110 + RecorderState.onCapturePaused()
110 111 RecorderState.setPaused(true)
111 112 RecorderState.setStatus("已暂停")
112 113 notify("已暂停")
... ... @@ -118,6 +119,7 @@ class RecorderService : Service() {
118 119 paused = false
119 120 acquireWakeLock()
120 121 capture.start(scope)
  122 + RecorderState.onCapturing()
121 123 RecorderState.setPaused(false)
122 124 RecorderState.setStatus("录音中")
123 125 notify("录音中")
... ... @@ -212,7 +214,11 @@ class RecorderService : Service() {
212 214 }
213 215  
214 216 // 2d. Online speaker clusterer (auto-discovers 说话人N; persists as speaker_profiles).
215   - clusterer = SpeakerClusterer(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(this)).also { it.load() }
  217 + clusterer = SpeakerClusterer(
  218 + repo,
  219 + matchThreshold = SpeakerClusterer.thresholdFromPrefs(this),
  220 + minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(this),
  221 + ).also { it.load() }
216 222 // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer.
217 223 // load() is mutex-serialised vs assign() and idempotent, so reloading on every
218 224 // speaker_profiles change (incl. the clusterer's own write-throughs) is safe.
... ... @@ -256,6 +262,7 @@ class RecorderService : Service() {
256 262 // 3. Start capturing (unless paused arrived during model load).
257 263 if (!paused) {
258 264 capture.start(scope)
  265 + RecorderState.onCapturing()
259 266 RecorderState.setStatus("录音中")
260 267 }
261 268 Log.i(TAG, "pipeline running (block-based diarization-first)")
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt
... ... @@ -27,6 +27,14 @@ object RecorderState {
27 27 private val _status = MutableStateFlow("idle")
28 28 val status: StateFlow<String> = _status
29 29  
  30 + /** 当前这段连续采集的开始墙钟(ms);0 = 未在采集(已停止或暂停)。UI 据此计「已录制」秒表。 */
  31 + private val _recordingStartedAt = MutableStateFlow(0L)
  32 + val recordingStartedAt: StateFlow<Long> = _recordingStartedAt
  33 +
  34 + /** 暂停前已累计的录音时长(ms)。已录制 = accum + (now - startedAt)。 */
  35 + private val _recordingAccumMs = MutableStateFlow(0L)
  36 + val recordingAccumMs: StateFlow<Long> = _recordingAccumMs
  37 +
30 38 fun setRunning(v: Boolean) {
31 39 _isRunning.value = v
32 40 if (!v) _isPaused.value = false
... ... @@ -36,6 +44,18 @@ object RecorderState {
36 44 _isPaused.value = v
37 45 }
38 46  
  47 + /** 采集开始/恢复:开表。 */
  48 + fun onCapturing() {
  49 + _recordingStartedAt.value = System.currentTimeMillis()
  50 + }
  51 +
  52 + /** 采集暂停:累加本段时长后停表。 */
  53 + fun onCapturePaused() {
  54 + val start = _recordingStartedAt.value
  55 + if (start > 0) _recordingAccumMs.value += System.currentTimeMillis() - start
  56 + _recordingStartedAt.value = 0
  57 + }
  58 +
39 59 fun onSegment(text: String, audioMs: Long, wallMs: Long) {
40 60 _segmentCount.value++
41 61 _lastTranscript.value = text
... ... @@ -48,6 +68,8 @@ object RecorderState {
48 68 _lastTranscript.value = ""
49 69 _lastAudioMs.value = 0
50 70 _lastWallMs.value = 0
  71 + _recordingStartedAt.value = 0
  72 + _recordingAccumMs.value = 0
51 73 }
52 74  
53 75 fun setStatus(s: String) {
... ...