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,6 +85,7 @@ import com.lemon.lifelog.audio.SegmentPlayer
85 import com.lemon.lifelog.chat.ChatConfig 85 import com.lemon.lifelog.chat.ChatConfig
86 import com.lemon.lifelog.summary.SummaryProgress 86 import com.lemon.lifelog.summary.SummaryProgress
87 import com.lemon.lifelog.summary.SummaryRunLog 87 import com.lemon.lifelog.summary.SummaryRunLog
  88 +import com.lemon.lifelog.data.BlockRow
88 import com.lemon.lifelog.data.Repository 89 import com.lemon.lifelog.data.Repository
89 import com.lemon.lifelog.data.SegmentRow 90 import com.lemon.lifelog.data.SegmentRow
90 import com.lemon.lifelog.data.SpeakerProfile 91 import com.lemon.lifelog.data.SpeakerProfile
@@ -213,7 +214,12 @@ private fun RecordingScreen( @@ -213,7 +214,12 @@ private fun RecordingScreen(
213 } 214 }
214 Box(modifier = Modifier.weight(1f)) { 215 Box(modifier = Modifier.weight(1f)) {
215 when (sub) { 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 1 -> TimelineTab( 223 1 -> TimelineTab(
218 repo = repo, 224 repo = repo,
219 speakers = speakers, 225 speakers = speakers,
@@ -240,15 +246,27 @@ private fun RecordingScreen( @@ -240,15 +246,27 @@ private fun RecordingScreen(
240 // ============================================================ Home tab 246 // ============================================================ Home tab
241 247
242 @Composable 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 val running by RecorderState.isRunning.collectAsState() 250 val running by RecorderState.isRunning.collectAsState()
245 val paused by RecorderState.isPaused.collectAsState() 251 val paused by RecorderState.isPaused.collectAsState()
246 val status by RecorderState.status.collectAsState() 252 val status by RecorderState.status.collectAsState()
247 val segmentCount by RecorderState.segmentCount.collectAsState() 253 val segmentCount by RecorderState.segmentCount.collectAsState()
248 val lastAudioMs by RecorderState.lastAudioMs.collectAsState() 254 val lastAudioMs by RecorderState.lastAudioMs.collectAsState()
249 val lastWallMs by RecorderState.lastWallMs.collectAsState() 255 val lastWallMs by RecorderState.lastWallMs.collectAsState()
  256 + val recStartedAt by RecorderState.recordingStartedAt.collectAsState()
  257 + val recAccumMs by RecorderState.recordingAccumMs.collectAsState()
250 val scope = rememberCoroutineScope() 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 var asrStatus by remember { mutableStateOf("ASR: idle") } 271 var asrStatus by remember { mutableStateOf("ASR: idle") }
254 var asrLog by remember { mutableStateOf("") } 272 var asrLog by remember { mutableStateOf("") }
@@ -285,6 +303,15 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -&gt; U @@ -285,6 +303,15 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -&gt; U
285 } 303 }
286 Text("$stateLabel $status") 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 if (!running) { 315 if (!running) {
289 Button(onClick = { 316 Button(onClick = {
290 val perms = buildList { 317 val perms = buildList {
@@ -318,23 +345,25 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -&gt; U @@ -318,23 +345,25 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -&gt; U
318 345
319 HorizontalDivider() 346 HorizontalDivider()
320 347
321 - // 最近转写(实时):让用户在首页就能看到实时进度。 348 + // 录音记录:每个已处理「录音块」一行(起止/时长/状态/字数/人数),点击跳时间轴看该块详情。
322 Row( 349 Row(
323 modifier = Modifier.fillMaxWidth(), 350 modifier = Modifier.fillMaxWidth(),
324 horizontalArrangement = Arrangement.SpaceBetween, 351 horizontalArrangement = Arrangement.SpaceBetween,
325 verticalAlignment = Alignment.CenterVertically, 352 verticalAlignment = Alignment.CenterVertically,
326 ) { 353 ) {
327 - Text("最近转写", style = MaterialTheme.typography.titleSmall) 354 + Text("录音记录", style = MaterialTheme.typography.titleSmall)
328 TextButton(onClick = onOpenTimeline) { Text("查看全部 →") } 355 TextButton(onClick = onOpenTimeline) { Text("查看全部 →") }
329 } 356 }
330 - if (recent.isEmpty()) { 357 + if (blocks.isEmpty()) {
331 Text( 358 Text(
332 - if (running) "正在聆听…说话后这里会实时出现转写" else "还没有转写。开始录音后这里实时刷新。", 359 + if (running) "正在录音…块在静音≥5min 或满 10min 时收尾,随后出现在这里。"
  360 + else "还没有录音记录。开始录音后,处理完的录音块会显示在这里。",
333 style = MaterialTheme.typography.bodySmall, 361 style = MaterialTheme.typography.bodySmall,
334 ) 362 )
335 } else { 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,6 +845,7 @@ private fun SpeakersTab(
816 val countMap = remember(counts) { counts.associate { it.speakerId to it.count } } 845 val countMap = remember(counts) { counts.associate { it.speakerId to it.count } }
817 var reclustering by remember { mutableStateOf(false) } 846 var reclustering by remember { mutableStateOf(false) }
818 var reclusterMsg by remember { mutableStateOf("") } 847 var reclusterMsg by remember { mutableStateOf("") }
  848 + var clearConfirm by remember { mutableStateOf(false) }
819 849
820 var renameFor by remember { mutableStateOf<SpeakerProfile?>(null) } 850 var renameFor by remember { mutableStateOf<SpeakerProfile?>(null) }
821 var mergeFor by remember { mutableStateOf<SpeakerProfile?>(null) } 851 var mergeFor by remember { mutableStateOf<SpeakerProfile?>(null) }
@@ -830,13 +860,21 @@ private fun SpeakersTab( @@ -830,13 +860,21 @@ private fun SpeakersTab(
830 reclustering = true 860 reclustering = true
831 scope.launch { 861 scope.launch {
832 val n = withContext(Dispatchers.IO) { 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 reclustering = false 869 reclustering = false
836 reclusterMsg = "重新聚类完成:$n 个说话人" 870 reclusterMsg = "重新聚类完成:$n 个说话人"
837 } 871 }
838 }, 872 },
839 ) { Text(if (reclustering) "聚类中…" else "重新聚类") } 873 ) { Text(if (reclustering) "聚类中…" else "重新聚类") }
  874 + OutlinedButton(
  875 + enabled = !reclustering,
  876 + onClick = { clearConfirm = true },
  877 + ) { Text("清空重识别") }
840 } 878 }
841 if (reclusterMsg.isNotEmpty()) { 879 if (reclusterMsg.isNotEmpty()) {
842 Text(reclusterMsg, style = MaterialTheme.typography.bodySmall) 880 Text(reclusterMsg, style = MaterialTheme.typography.bodySmall)
@@ -922,6 +960,29 @@ private fun SpeakersTab( @@ -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 // ============================================================ Summaries tab 988 // ============================================================ Summaries tab
@@ -1399,6 +1460,37 @@ private fun TimelineRow( @@ -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 // ---- 时间段分组(按 5min 静音切,= 录音块边界)---- 1494 // ---- 时间段分组(按 5min 静音切,= 录音块边界)----
1403 1495
1404 private const val TIMELINE_GROUP_GAP_MS = 5 * 60 * 1000L 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,6 +1608,18 @@ private fun exportSegments(ctx: Context, rows: List&lt;SegmentRow&gt;) {
1516 // ============================================================ helpers 1608 // ============================================================ helpers
1517 1609
1518 private val TIMELINE_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) 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 private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) 1623 private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US)
1520 private val SUMMARY_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US) 1624 private val SUMMARY_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US)
1521 private val SUMMARY_DAY_FMT = SimpleDateFormat("yyyy-MM-dd", Locale.US) 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,6 +35,7 @@ import androidx.compose.ui.Alignment
35 import androidx.compose.ui.Modifier 35 import androidx.compose.ui.Modifier
36 import androidx.compose.ui.unit.dp 36 import androidx.compose.ui.unit.dp
37 import com.lemon.lifelog.asr.SpeakerClusterer 37 import com.lemon.lifelog.asr.SpeakerClusterer
  38 +import com.lemon.lifelog.service.BlockBuffer
38 import com.lemon.lifelog.chat.ChatConfig 39 import com.lemon.lifelog.chat.ChatConfig
39 import com.lemon.lifelog.summary.SummaryScheduler 40 import com.lemon.lifelog.summary.SummaryScheduler
40 import com.lemon.lifelog.summary.SummaryWorker 41 import com.lemon.lifelog.summary.SummaryWorker
@@ -50,6 +51,12 @@ fun SettingsScreen(activity: Context) { @@ -50,6 +51,12 @@ fun SettingsScreen(activity: Context) {
50 var minSpeech by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_MIN_SPEECH_SEC, SummaryWorker.DEFAULT_MIN_SPEECH_SEC)) } 51 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 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 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 val backendTargets = remember { ChatConfig.targets(activity) } 60 val backendTargets = remember { ChatConfig.targets(activity) }
54 61
55 var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) } 62 var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) }
@@ -91,6 +98,10 @@ fun SettingsScreen(activity: Context) { @@ -91,6 +98,10 @@ fun SettingsScreen(activity: Context) {
91 listOf(0.35f, 0.40f, 0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) { 98 listOf(0.35f, 0.40f, 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() 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 Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。", 105 Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。",
95 style = MaterialTheme.typography.labelSmall) 106 style = MaterialTheme.typography.labelSmall)
96 107
@@ -114,6 +125,11 @@ fun SettingsScreen(activity: Context) { @@ -114,6 +125,11 @@ fun SettingsScreen(activity: Context) {
114 125
115 HorizontalDivider() 126 HorizontalDivider()
116 Text("录音 / 电池", style = MaterialTheme.typography.titleMedium) 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 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { 133 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
118 Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)", 134 Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)",
119 style = MaterialTheme.typography.bodySmall) 135 style = MaterialTheme.typography.bodySmall)
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt
@@ -27,6 +27,7 @@ class SpeakerClusterer( @@ -27,6 +27,7 @@ class SpeakerClusterer(
27 private val repo: Repository, 27 private val repo: Repository,
28 private val matchThreshold: Float = MATCH_THRESHOLD, 28 private val matchThreshold: Float = MATCH_THRESHOLD,
29 private val minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, 29 private val minNewClusterMs: Long = MIN_NEW_CLUSTER_MS,
  30 + private val minRecognizeMs: Long = MIN_RECOGNIZE_MS,
30 private val maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID, 31 private val maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID,
31 ) { 32 ) {
32 private val mutex = Mutex() 33 private val mutex = Mutex()
@@ -48,6 +49,9 @@ class SpeakerClusterer( @@ -48,6 +49,9 @@ class SpeakerClusterer(
48 49
49 /** 将一个 embedding 归入/新建说话人簇,写穿到 DB。 */ 50 /** 将一个 embedding 归入/新建说话人簇,写穿到 DB。 */
50 suspend fun assign(emb: FloatArray, durationMs: Long): Assignment = mutex.withLock { 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 var best: Cluster? = null 55 var best: Cluster? = null
52 var bestSim = -1f 56 var bestSim = -1f
53 for (c in clusters) { 57 for (c in clusters) {
@@ -109,14 +113,23 @@ class SpeakerClusterer( @@ -109,14 +113,23 @@ class SpeakerClusterer(
109 /** 阈值持久化(设置页可调),存在 lifelog_prefs。 */ 113 /** 阈值持久化(设置页可调),存在 lifelog_prefs。 */
110 const val PREFS = "lifelog_prefs" 114 const val PREFS = "lifelog_prefs"
111 const val KEY_THRESHOLD = "speaker_threshold" 115 const val KEY_THRESHOLD = "speaker_threshold"
  116 + const val KEY_MIN_RECOGNIZE_SEC = "speaker_min_recognize_sec"
112 117
113 fun thresholdFromPrefs(ctx: android.content.Context): Float = 118 fun thresholdFromPrefs(ctx: android.content.Context): Float =
114 ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) 119 ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE)
115 .getFloat(KEY_THRESHOLD, MATCH_THRESHOLD) 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 /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */ 127 /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */
118 const val MIN_NEW_CLUSTER_MS = 1000L 128 const val MIN_NEW_CLUSTER_MS = 1000L
119 129
  130 + /** 短于此时长的段完全不识别声纹(不匹配、不开新簇)。默认 3s——CAMPPlus 在更短段上不可信。 */
  131 + const val MIN_RECOGNIZE_MS = 3000L
  132 +
120 /** 质心滑动均值的样本上限。 */ 133 /** 质心滑动均值的样本上限。 */
121 const val MAX_SAMPLES_PER_CENTROID = 50 134 const val MAX_SAMPLES_PER_CENTROID = 50
122 135
@@ -156,6 +169,7 @@ class SpeakerClusterer( @@ -156,6 +169,7 @@ class SpeakerClusterer(
156 repo: Repository, 169 repo: Repository,
157 matchThreshold: Float = MATCH_THRESHOLD, 170 matchThreshold: Float = MATCH_THRESHOLD,
158 minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, 171 minNewClusterMs: Long = MIN_NEW_CLUSTER_MS,
  172 + minRecognizeMs: Long = MIN_RECOGNIZE_MS,
159 maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID, 173 maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID,
160 ): Int = withContext(Dispatchers.IO) { 174 ): Int = withContext(Dispatchers.IO) {
161 // 1. 删掉自动簇,保留人工命名簇作种子。 175 // 1. 删掉自动簇,保留人工命名簇作种子。
@@ -174,6 +188,11 @@ class SpeakerClusterer( @@ -174,6 +188,11 @@ class SpeakerClusterer(
174 val segs = repo.segments.allWithEmbedding().sortedBy { it.startTs } 188 val segs = repo.segments.allWithEmbedding().sortedBy { it.startTs }
175 for (seg in segs) { 189 for (seg in segs) {
176 val emb = seg.embedding?.toFloatArrayLe() ?: continue 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 var best: Cluster? = null 196 var best: Cluster? = null
178 var bestSim = -1f 197 var bestSim = -1f
179 for (c in clusters) { 198 for (c in clusters) {
@@ -218,6 +237,23 @@ class SpeakerClusterer( @@ -218,6 +237,23 @@ class SpeakerClusterer(
218 Log.i(TAG, "reclusterAll: ${segs.size} segments → ${clusters.size} clusters") 237 Log.i(TAG, "reclusterAll: ${segs.size} segments → ${clusters.size} clusters")
219 clusters.size 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,6 +21,26 @@ data class SpeakerCount(
21 val count: Int, 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 @Dao 44 @Dao
25 interface SegmentDao { 45 interface SegmentDao {
26 @Insert 46 @Insert
@@ -49,6 +69,10 @@ interface SegmentDao { @@ -49,6 +69,10 @@ interface SegmentDao {
49 @Query("UPDATE segments SET speakerId = NULL, speakerSim = NULL WHERE speakerId = :speakerId") 69 @Query("UPDATE segments SET speakerId = NULL, speakerSim = NULL WHERE speakerId = :speakerId")
50 suspend fun clearSpeakerAssignments(speakerId: Long) 70 suspend fun clearSpeakerAssignments(speakerId: Long)
51 71
  72 + /** 清空全部段的说话人归属(清空声纹库重识别时用)。 */
  73 + @Query("UPDATE segments SET speakerId = NULL, speakerSim = NULL")
  74 + suspend fun clearAllSpeakerAssignments()
  75 +
52 /** 合并说话人:把 [from] 名下段落改派给 [into]。 */ 76 /** 合并说话人:把 [from] 名下段落改派给 [into]。 */
53 @Query("UPDATE segments SET speakerId = :into WHERE speakerId = :from") 77 @Query("UPDATE segments SET speakerId = :into WHERE speakerId = :from")
54 suspend fun reassignSpeaker(from: Long, into: Long) 78 suspend fun reassignSpeaker(from: Long, into: Long)
@@ -109,6 +133,28 @@ interface SegmentDao { @@ -109,6 +133,28 @@ interface SegmentDao {
109 ) 133 )
110 fun observeTimeline(limit: Int = 100): Flow<List<SegmentRow>> 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 @Query( 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,7 +21,12 @@ import org.json.JSONObject
21 * [close] 落 manifest(closed=true) 后交给 BlockProcessor;崩溃时块目录仍在,重启 [listPending] 重扫。 21 * [close] 落 manifest(closed=true) 后交给 BlockProcessor;崩溃时块目录仍在,重启 [listPending] 重扫。
22 * 只存 speech(静音不落盘)→ 内存/磁盘按语音时长计,不按墙钟。 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 data class Run(val ts: Long, val off: Int, val len: Int) 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,7 +61,7 @@ class BlockBuffer(private val root: File, private val sampleRate: Int = 16000) {
56 /** 在 [ts] 这一刻是否该关块(静音超时或块超长)。块未开恒 false。 */ 61 /** 在 [ts] 这一刻是否该关块(静音超时或块超长)。块未开恒 false。 */
57 fun shouldClose(ts: Long): Boolean { 62 fun shouldClose(ts: Long): Boolean {
58 if (!isOpen) return false 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 /** 关块:落 manifest(closed=true),返回块目录;空块直接删并返回 null。 */ 67 /** 关块:落 manifest(closed=true),返回块目录;空块直接删并返回 null。 */
@@ -99,9 +104,18 @@ class BlockBuffer(private val root: File, private val sampleRate: Int = 16000) { @@ -99,9 +104,18 @@ class BlockBuffer(private val root: File, private val sampleRate: Int = 16000) {
99 104
100 companion object { 105 companion object {
101 private const val TAG = "BlockBuffer" 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 const val MAX_MS = 10 * 60 * 1000L // 块 ≥10min 强切(长会议保活) 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 data class Loaded(val startTs: Long, val sampleRate: Int, val samples: FloatArray, val runs: List<Run>) 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,7 +88,7 @@ class RecorderService : Service() {
88 RecorderState.reset() 88 RecorderState.reset()
89 repo = Repository(this) 89 repo = Repository(this)
90 segWriter = SegmentWriter(File(filesDir, "segments")) 90 segWriter = SegmentWriter(File(filesDir, "segments"))
91 - blockBuffer = BlockBuffer(File(filesDir, "blocks")) 91 + blockBuffer = BlockBuffer(File(filesDir, "blocks"), silenceMs = BlockBuffer.silenceMsFromPrefs(this))
92 capture = AudioCapture() 92 capture = AudioCapture()
93 93
94 pipelineJob = scope.launch { runPipeline() } 94 pipelineJob = scope.launch { runPipeline() }
@@ -107,6 +107,7 @@ class RecorderService : Service() { @@ -107,6 +107,7 @@ class RecorderService : Service() {
107 paused = true 107 paused = true
108 capture.stop() 108 capture.stop()
109 wakeLock?.takeIf { it.isHeld }?.release() 109 wakeLock?.takeIf { it.isHeld }?.release()
  110 + RecorderState.onCapturePaused()
110 RecorderState.setPaused(true) 111 RecorderState.setPaused(true)
111 RecorderState.setStatus("已暂停") 112 RecorderState.setStatus("已暂停")
112 notify("已暂停") 113 notify("已暂停")
@@ -118,6 +119,7 @@ class RecorderService : Service() { @@ -118,6 +119,7 @@ class RecorderService : Service() {
118 paused = false 119 paused = false
119 acquireWakeLock() 120 acquireWakeLock()
120 capture.start(scope) 121 capture.start(scope)
  122 + RecorderState.onCapturing()
121 RecorderState.setPaused(false) 123 RecorderState.setPaused(false)
122 RecorderState.setStatus("录音中") 124 RecorderState.setStatus("录音中")
123 notify("录音中") 125 notify("录音中")
@@ -212,7 +214,11 @@ class RecorderService : Service() { @@ -212,7 +214,11 @@ class RecorderService : Service() {
212 } 214 }
213 215
214 // 2d. Online speaker clusterer (auto-discovers 说话人N; persists as speaker_profiles). 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 // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer. 222 // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer.
217 // load() is mutex-serialised vs assign() and idempotent, so reloading on every 223 // load() is mutex-serialised vs assign() and idempotent, so reloading on every
218 // speaker_profiles change (incl. the clusterer's own write-throughs) is safe. 224 // speaker_profiles change (incl. the clusterer's own write-throughs) is safe.
@@ -256,6 +262,7 @@ class RecorderService : Service() { @@ -256,6 +262,7 @@ class RecorderService : Service() {
256 // 3. Start capturing (unless paused arrived during model load). 262 // 3. Start capturing (unless paused arrived during model load).
257 if (!paused) { 263 if (!paused) {
258 capture.start(scope) 264 capture.start(scope)
  265 + RecorderState.onCapturing()
259 RecorderState.setStatus("录音中") 266 RecorderState.setStatus("录音中")
260 } 267 }
261 Log.i(TAG, "pipeline running (block-based diarization-first)") 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,6 +27,14 @@ object RecorderState {
27 private val _status = MutableStateFlow("idle") 27 private val _status = MutableStateFlow("idle")
28 val status: StateFlow<String> = _status 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 fun setRunning(v: Boolean) { 38 fun setRunning(v: Boolean) {
31 _isRunning.value = v 39 _isRunning.value = v
32 if (!v) _isPaused.value = false 40 if (!v) _isPaused.value = false
@@ -36,6 +44,18 @@ object RecorderState { @@ -36,6 +44,18 @@ object RecorderState {
36 _isPaused.value = v 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 fun onSegment(text: String, audioMs: Long, wallMs: Long) { 59 fun onSegment(text: String, audioMs: Long, wallMs: Long) {
40 _segmentCount.value++ 60 _segmentCount.value++
41 _lastTranscript.value = text 61 _lastTranscript.value = text
@@ -48,6 +68,8 @@ object RecorderState { @@ -48,6 +68,8 @@ object RecorderState {
48 _lastTranscript.value = "" 68 _lastTranscript.value = ""
49 _lastAudioMs.value = 0 69 _lastAudioMs.value = 0
50 _lastWallMs.value = 0 70 _lastWallMs.value = 0
  71 + _recordingStartedAt.value = 0
  72 + _recordingAccumMs.value = 0
51 } 73 }
52 74
53 fun setStatus(s: String) { 75 fun setStatus(s: String) {