Commit 393dd2545ff31294b1efee173991aba4dd2191dc
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>
Showing
7 changed files
with
260 additions
and
15 deletions
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: () -> 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: () -> 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<SegmentRow>) { |
| 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) { | ... | ... |