From 4b73a1503b2dda2b465039c19f2ef0b9d61dc04b Mon Sep 17 00:00:00 2001 From: yibo Date: Thu, 4 Jun 2026 00:28:07 +0800 Subject: [PATCH] feat(lifelog-recorder): v0.6 — 自动说话人聚类 + Tab 化 UI + 会话级纪要 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt | 1295 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerEmbedder.kt | 9 +++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt | 9 +++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt | 11 +++++++---- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------- 8 files changed, 1452 insertions(+), 430 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt index 0890f8b..9a2b1db 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt @@ -13,67 +13,80 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import com.lemon.lifelog.asr.ModelDownloader import com.lemon.lifelog.asr.ParaformerEngine -import com.lemon.lifelog.asr.SpeakerEmbedder -import com.lemon.lifelog.asr.SpeakerMatcher +import com.lemon.lifelog.asr.SpeakerClusterer import com.lemon.lifelog.asr.XfyunIatClient -import com.lemon.lifelog.asr.toFloatArrayLe -import com.lemon.lifelog.asr.toLeBytes import com.lemon.lifelog.asr.transcribeWithStats import com.lemon.lifelog.audio.SegmentPlayer -import com.lemon.lifelog.data.Summary -import com.lemon.lifelog.summary.SummaryScheduler import com.lemon.lifelog.data.Repository -import com.lemon.lifelog.data.Segment import com.lemon.lifelog.data.SegmentRow import com.lemon.lifelog.data.SpeakerProfile +import com.lemon.lifelog.data.Summary import com.lemon.lifelog.service.RecorderService import com.lemon.lifelog.service.RecorderState -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf +import com.lemon.lifelog.summary.SummaryScheduler +import com.lemon.lifelog.summary.SummaryWorker import java.text.SimpleDateFormat +import java.util.Calendar import java.util.Date import java.util.Locale import kotlinx.coroutines.Dispatchers @@ -95,60 +108,117 @@ class MainActivity : ComponentActivity() { } } +private enum class Tab(val label: String, val icon: String) { + Home("首页", "●"), + Timeline("时间轴", "≣"), + Speakers("声纹", "👤"), + Summaries("纪要", "✎"), +} + +enum class SortMode(val label: String) { + TimeDesc("时间↓"), + TimeAsc("时间↑"), + DurationDesc("时长↓"), +} + +enum class TimeFilter(val label: String) { + All("全部时间"), + Today("今天"), + Last1h("近1时"), + Last24h("近24时"), +} + @Composable private fun MainScreen() { + var tab by remember { mutableStateOf(Tab.Home) } + val activity = androidx.compose.ui.platform.LocalContext.current + val repo = remember(activity) { Repository(activity) } + + // Cross-tab state: speaker filter is settable from the Speakers tab. + var filterSpeakerId by remember { mutableStateOf(null) } + // Cross-tab jump: a summary bullet sets an absolute timestamp to scroll the timeline to. + var jumpToTs by remember { mutableStateOf(null) } + + val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList()) + + Scaffold( + bottomBar = { + NavigationBar { + Tab.entries.forEach { t -> + NavigationBarItem( + selected = tab == t, + onClick = { tab = t }, + icon = { Text(t.icon) }, + label = { Text(t.label) }, + ) + } + } + }, + ) { inner -> + Box(modifier = Modifier.padding(inner)) { + when (tab) { + Tab.Home -> HomeTab(activity) + Tab.Timeline -> TimelineTab( + repo = repo, + speakers = speakers, + filterSpeakerId = filterSpeakerId, + onFilterSpeakerId = { filterSpeakerId = it }, + jumpToTs = jumpToTs, + onJumpConsumed = { jumpToTs = null }, + ) + Tab.Speakers -> SpeakersTab( + repo = repo, + speakers = speakers, + onFilterBySpeaker = { id -> + filterSpeakerId = id + tab = Tab.Timeline + }, + ) + Tab.Summaries -> SummariesTab( + repo = repo, + activity = activity, + onJumpToTime = { ts -> + filterSpeakerId = null + jumpToTs = ts + tab = Tab.Timeline + }, + ) + } + } + } +} + +// ============================================================ Home tab + +@Composable +private fun HomeTab(activity: Context) { val running by RecorderState.isRunning.collectAsState() + val paused by RecorderState.isPaused.collectAsState() + val status by RecorderState.status.collectAsState() val segmentCount by RecorderState.segmentCount.collectAsState() val lastAudioMs by RecorderState.lastAudioMs.collectAsState() val lastWallMs by RecorderState.lastWallMs.collectAsState() - val status by RecorderState.status.collectAsState() + val scope = rememberCoroutineScope() var asrStatus by remember { mutableStateOf("ASR: idle") } var asrLog by remember { mutableStateOf("") } - val activity = androidx.compose.ui.platform.LocalContext.current - val scope = rememberCoroutineScope() + var debugExpanded by remember { mutableStateOf(false) } - // Timeline + search. - val repo = remember(activity) { Repository(activity) } - var searchQuery by remember { mutableStateOf("") } - val effectiveSearch = remember(searchQuery) { sanitizeFtsQuery(searchQuery) } - val timelineFlow = remember(effectiveSearch) { - if (effectiveSearch.isEmpty()) repo.segments.observeTimeline(200) - else repo.segments.searchTimeline(effectiveSearch, 200) - } - val timeline by timelineFlow.collectAsState(initial = emptyList()) - val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList()) - val summaries by repo.summaries.recent(20).collectAsState(initial = emptyList()) - - // Sheet / dialog state. - var actionRow by remember { mutableStateOf(null) } - var enrollFor by remember { mutableStateOf(null) } - val player = remember { SegmentPlayer() } - var nowPlayingId by remember { mutableStateOf(null) } - DisposableEffect(player) { onDispose { player.stop() } } - - // Battery optimisation exemption — recompute whenever activity resumes - // (user may have just returned from the system settings dialog). var batteryExempt by remember { mutableStateOf(isBatteryOptExempt(activity)) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - batteryExempt = isBatteryOptExempt(activity) - } + if (event == Lifecycle.Event.ON_RESUME) batteryExempt = isBatteryOptExempt(activity) } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() + ActivityResultContracts.RequestMultiplePermissions(), ) { results -> if (results.values.all { it }) { - ContextCompat.startForegroundService( - activity, - Intent(activity, RecorderService::class.java), - ) + ContextCompat.startForegroundService(activity, Intent(activity, RecorderService::class.java)) } } @@ -158,11 +228,15 @@ private fun MainScreen() { ) { Text("Lifelog", style = MaterialTheme.typography.headlineMedium) - Text(if (running) "● 录音中 $status" else "○ 已停止") - Button(onClick = { - if (running) { - activity.stopService(Intent(activity, RecorderService::class.java)) - } else { + val stateLabel = when { + !running -> "○ 已停止" + paused -> "⏸ 已暂停(模型常驻)" + else -> "● 录音中" + } + Text("$stateLabel $status") + + if (!running) { + Button(onClick = { val perms = buildList { add(Manifest.permission.RECORD_AUDIO) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -170,44 +244,156 @@ private fun MainScreen() { } }.toTypedArray() permissionLauncher.launch(perms) + }) { Text("开始录音") } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + if (paused) RecorderService.resume(activity) else RecorderService.pause(activity) + }) { Text(if (paused) "继续" else "暂停") } + OutlinedButton(onClick = { + activity.stopService(Intent(activity, RecorderService::class.java)) + }) { Text("停止") } } - }) { Text(if (running) "停止录音" else "开始录音") } + } - // Battery optimisation exemption row. - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // Runtime stats. + if (running) { + val rtSpeed = if (lastWallMs > 0) lastAudioMs.toDouble() / lastWallMs else 0.0 Text( - if (batteryExempt) "✓ 已加入电池白名单" - else "⚠ 未加入电池白名单(Doze 会关掉录音)", + "段数:$segmentCount" + + if (lastAudioMs > 0) " • 最新段 ${lastAudioMs}ms / ${lastWallMs}ms (${"%.1f".format(rtSpeed)}× realtime)" else "", style = MaterialTheme.typography.bodySmall, ) - if (!batteryExempt) { - Button(onClick = { requestBatteryOptExemption(activity) }) { - Text("申请豁免") - } - } } - if (running && lastAudioMs > 0) { - val rtSpeed = if (lastWallMs > 0) lastAudioMs.toDouble() / lastWallMs else 0.0 + // Battery whitelist. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( - "段数:$segmentCount • 最新段 audio=${lastAudioMs}ms wall=${lastWallMs}ms (${"%.1f".format(rtSpeed)}× realtime)", + if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会关掉录音)", style = MaterialTheme.typography.bodySmall, ) + if (!batteryExempt) { + Button(onClick = { requestBatteryOptExemption(activity) }) { Text("申请豁免") } + } } HorizontalDivider() - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - if (searchQuery.isEmpty()) "时间轴(${timeline.size})" - else "搜索结果(${timeline.size})", - style = MaterialTheme.typography.titleMedium, - ) - Text("· 声纹库 ${speakers.size}", - style = MaterialTheme.typography.bodySmall) + // Collapsible debug block. + Row( + modifier = Modifier.fillMaxWidth().clickable { debugExpanded = !debugExpanded }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(if (debugExpanded) "▾" else "▸") + Text("调试 · ASR Bench", style = MaterialTheme.typography.titleSmall) + } + if (debugExpanded) { + Text("95s TTS sample", style = MaterialTheme.typography.labelSmall) + Text(asrStatus, style = MaterialTheme.typography.bodyMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { + asrStatus = "paraformer: starting…"; asrLog = "" + scope.launch { runParaformerTest(activity) { s, log -> asrStatus = s; asrLog = log } } + }) { Text("Test paraformer") } + OutlinedButton(onClick = { + asrStatus = "讯飞: connecting…"; asrLog = "" + scope.launch { runXfyunTest(activity) { s, log -> asrStatus = s; asrLog = log } } + }) { Text("Test 讯飞") } + } + if (asrLog.isNotEmpty()) { + Text(asrLog, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +// ============================================================ Timeline tab + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TimelineTab( + repo: Repository, + speakers: List, + filterSpeakerId: Long?, + onFilterSpeakerId: (Long?) -> Unit, + jumpToTs: Long? = null, + onJumpConsumed: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + var searchQuery by remember { mutableStateOf("") } + var sortMode by remember { mutableStateOf(SortMode.TimeDesc) } + var timeFilter by remember { mutableStateOf(TimeFilter.All) } + var unmatchedOnly by remember { mutableStateOf(false) } + val lazyState = rememberLazyListState() + var highlightId by remember { mutableStateOf(null) } + + val effectiveSearch = remember(searchQuery) { sanitizeFtsQuery(searchQuery) } + val timelineFlow = remember(effectiveSearch) { + if (effectiveSearch.isEmpty()) repo.segments.observeTimeline(1000) + else repo.segments.searchTimeline(effectiveSearch, 1000) + } + val base by timelineFlow.collectAsState(initial = emptyList()) + + val shown = remember(base, filterSpeakerId, timeFilter, unmatchedOnly, sortMode) { + var l = base + if (filterSpeakerId != null) l = l.filter { it.speakerId == filterSpeakerId } + timeCutoff(timeFilter)?.let { cutoff -> l = l.filter { it.startTs >= cutoff } } + if (unmatchedOnly) l = l.filter { it.speakerId == null || (it.speakerSim != null && it.speakerSim < 0.55f) } + when (sortMode) { + SortMode.TimeDesc -> l.sortedByDescending { it.startTs } + SortMode.TimeAsc -> l.sortedBy { it.startTs } + SortMode.DurationDesc -> l.sortedByDescending { it.durationMs } + } + } + + // Jump from a summary bullet: reset filters, scroll to the closest segment, highlight it. + LaunchedEffect(jumpToTs, base) { + val target = jumpToTs ?: return@LaunchedEffect + if (base.isEmpty()) return@LaunchedEffect + searchQuery = "" + sortMode = SortMode.TimeDesc + timeFilter = TimeFilter.All + unmatchedOnly = false + val sorted = base.sortedByDescending { it.startTs } + val idx = sorted.indices.minByOrNull { kotlin.math.abs(sorted[it].startTs - target) } + if (idx != null) { + highlightId = sorted[idx].id + lazyState.scrollToItem(idx) } + onJumpConsumed() + } + LaunchedEffect(highlightId) { + if (highlightId != null) { + delay(2500) + highlightId = null + } + } + + val player = remember { SegmentPlayer() } + var nowPlayingId by remember { mutableStateOf(null) } + DisposableEffect(player) { onDispose { player.stop() } } + + // Batch selection state. + val ctx = LocalContext.current + var selecting by remember { mutableStateOf(false) } + val selected = remember { mutableStateListOf() } + var batchDeleteConfirm by remember { mutableStateOf(false) } + + // Dialog / sheet state. + var actionRow by remember { mutableStateOf(null) } + var enrollFor by remember { mutableStateOf(null) } + var renameFor by remember { mutableStateOf(null) } + var reassignFor by remember { mutableStateOf(null) } + var editFor by remember { mutableStateOf(null) } + var deleteFor by remember { mutableStateOf(null) } + + val speakerName = remember(speakers, filterSpeakerId) { + speakers.firstOrNull { it.id == filterSpeakerId }?.name + } + + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + Spacer(Modifier.height(12.dp)) OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, @@ -215,76 +401,118 @@ private fun MainScreen() { placeholder = { Text("中文搜索…") }, singleLine = true, ) - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 200.dp, max = 600.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + + // Filter / sort controls. + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, ) { - items(timeline, key = { it.id }) { row -> - TimelineRow( - row = row, - isPlaying = nowPlayingId == row.id, - onClick = { actionRow = row }, - ) - } + // Sort cycles. + FilterChip( + selected = sortMode != SortMode.TimeDesc, + onClick = { + sortMode = SortMode.entries[(sortMode.ordinal + 1) % SortMode.entries.size] + }, + label = { Text(sortMode.label) }, + ) + // Speaker filter dropdown. + SpeakerFilterChip( + speakers = speakers, + selectedId = filterSpeakerId, + selectedName = speakerName, + onSelect = onFilterSpeakerId, + ) + // Time filter dropdown. + TimeFilterChip(selected = timeFilter, onSelect = { timeFilter = it }) + // Unmatched toggle. + FilterChip( + selected = unmatchedOnly, + onClick = { unmatchedOnly = !unmatchedOnly }, + label = { Text("仅未匹配/低置信") }, + ) } - HorizontalDivider() - - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text("纪要(${summaries.size})", style = MaterialTheme.typography.titleMedium) - Button(onClick = { SummaryScheduler.runNow(activity) }) { - Text("现在跑一次") + if (!selecting) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + (if (searchQuery.isEmpty()) "时间轴" else "搜索结果") + "(${shown.size} / ${base.size})", + style = MaterialTheme.typography.labelMedium, + ) + TextButton(onClick = { selecting = true }) { Text("选择") } } - } - if (summaries.isEmpty()) { - Text("还没有纪要。点上面按钮触发;或等 hourly worker(要求 hermes 在 127.0.0.1:8642 跑着)。", - style = MaterialTheme.typography.bodySmall) } else { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, ) { - summaries.take(5).forEach { s -> SummaryCard(s) } + Text("已选 ${selected.size}", style = MaterialTheme.typography.labelMedium) + TextButton(onClick = { selected.clear(); selected.addAll(shown.map { it.id }) }) { Text("全选") } + TextButton( + enabled = selected.isNotEmpty(), + onClick = { batchDeleteConfirm = true }, + ) { Text("删除(${selected.size})") } + TextButton( + enabled = selected.isNotEmpty(), + onClick = { exportSegments(ctx, shown.filter { selected.contains(it.id) }) }, + ) { Text("导出(${selected.size})") } + TextButton(onClick = { selecting = false; selected.clear() }) { Text("取消") } } } - HorizontalDivider() - - Text("ASR Bench (95s TTS sample)", style = MaterialTheme.typography.titleMedium) - Text(asrStatus, style = MaterialTheme.typography.bodyMedium) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { - asrStatus = "paraformer: starting…" - asrLog = "" - scope.launch { - runParaformerTest(activity) { s, log -> - asrStatus = s - asrLog = log - } - } - }) { Text("Test paraformer") } + LazyColumn( + state = lazyState, + modifier = Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(shown, key = { it.id }) { row -> + TimelineRow( + row = row, + isPlaying = nowPlayingId == row.id, + selecting = selecting, + selected = selected.contains(row.id), + highlighted = highlightId == row.id, + onClick = { + if (selecting) { + if (selected.contains(row.id)) selected.remove(row.id) else selected.add(row.id) + } else { + actionRow = row + } + }, + ) + } + } + } - Button(onClick = { - asrStatus = "讯飞: connecting…" - asrLog = "" + if (batchDeleteConfirm) { + val ids = selected.toList() + ConfirmDialog( + title = "删除选中的 ${ids.size} 段?", + message = "音频文件不会自动删除。", + confirmLabel = "删除", + onDismiss = { batchDeleteConfirm = false }, + onConfirm = { + batchDeleteConfirm = false + selecting = false + selected.clear() scope.launch { - runXfyunTest(activity) { s, log -> - asrStatus = s - asrLog = log + withContext(Dispatchers.IO) { + ids.forEach { id -> + repo.transcripts.deleteBySegment(id) + repo.segments.deleteSegment(id) + } } } - }) { Text("Test 讯飞") } - } - - Spacer(Modifier.height(8.dp)) - Text(asrLog, style = MaterialTheme.typography.bodySmall) + }, + ) } - // Bottom sheet with row actions. + // ----- bottom sheet ----- actionRow?.let { row -> SegmentActionSheet( row = row, @@ -292,35 +520,94 @@ private fun MainScreen() { onDismiss = { actionRow = null }, onPlay = { if (nowPlayingId == row.id) { - player.stop() - nowPlayingId = null + player.stop(); nowPlayingId = null } else { - nowPlayingId = row.id - player.play(row.audioPath) { nowPlayingId = null } + nowPlayingId = row.id; player.play(row.audioPath) { nowPlayingId = null } } actionRow = null }, - onEnroll = { - enrollFor = row - actionRow = null - }, + onRename = { renameFor = row; actionRow = null }, + onReassign = { reassignFor = row; actionRow = null }, + onEditText = { editFor = row; actionRow = null }, + onEnroll = { enrollFor = row; actionRow = null }, + onDelete = { deleteFor = row; actionRow = null }, ) } - // Speaker enrollment dialog. + // ----- dialogs ----- + renameFor?.let { row -> + val sid = row.speakerId + RenameSpeakerDialog( + currentName = row.speakerName ?: "", + onDismiss = { renameFor = null }, + onConfirm = { name -> + renameFor = null + if (sid != null && name.isNotBlank()) { + scope.launch { withContext(Dispatchers.IO) { repo.speakers.updateName(sid, name.trim()) } } + } + }, + ) + } + reassignFor?.let { row -> + ReassignSpeakerDialog( + row = row, + speakers = speakers, + onDismiss = { reassignFor = null }, + onPick = { speakerId -> + reassignFor = null + scope.launch { withContext(Dispatchers.IO) { repo.segments.updateSpeaker(row.id, speakerId, null) } } + }, + ) + } + editFor?.let { row -> + EditTranscriptDialog( + initial = row.text ?: "", + onDismiss = { editFor = null }, + onConfirm = { text -> + editFor = null + scope.launch { + withContext(Dispatchers.IO) { + val now = System.currentTimeMillis() + if (repo.transcripts.get(row.id) != null) { + repo.transcripts.updateText(row.id, text.trim(), now) + } else if (text.isNotBlank()) { + repo.transcripts.insert( + com.lemon.lifelog.data.Transcript(segmentId = row.id, text = text.trim(), reviewedAt = now), + ) + } + } + } + }, + ) + } + deleteFor?.let { row -> + ConfirmDialog( + title = "删除该段?", + message = "段 #${row.id}:${row.text?.take(30) ?: "(无转写)"}\n音频文件不会自动删除。", + confirmLabel = "删除", + onDismiss = { deleteFor = null }, + onConfirm = { + deleteFor = null + scope.launch { + withContext(Dispatchers.IO) { + repo.transcripts.deleteBySegment(row.id) + repo.segments.deleteSegment(row.id) + } + } + }, + ) + } enrollFor?.let { row -> EnrollSpeakerDialog( row = row, existingNames = speakers.map { it.name }, onDismiss = { enrollFor = null }, onConfirm = { name -> - val target = enrollFor enrollFor = null - if (target != null && name.isNotBlank()) { + if (name.isNotBlank()) { scope.launch { - enrollSpeakerForSegment(repo, target.id, name) - // Backfill all older segments with embeddings against the new profile. - SpeakerMatcher.backfillAll(repo) + enrollSpeakerForSegment(repo, row.id, name) + com.lemon.lifelog.asr.SpeakerMatcher.backfillAll(repo) } } }, @@ -330,53 +617,465 @@ private fun MainScreen() { @OptIn(ExperimentalMaterial3Api::class) @Composable +private fun SpeakerFilterChip( + speakers: List, + selectedId: Long?, + selectedName: String?, + onSelect: (Long?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + FilterChip( + selected = selectedId != null, + onClick = { expanded = true }, + label = { Text(if (selectedId != null) "👤 ${selectedName ?: "?"}" else "👤 全部") }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem(text = { Text("全部说话人") }, onClick = { onSelect(null); expanded = false }) + speakers.forEach { sp -> + DropdownMenuItem(text = { Text(sp.name) }, onClick = { onSelect(sp.id); expanded = false }) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TimeFilterChip(selected: TimeFilter, onSelect: (TimeFilter) -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + FilterChip( + selected = selected != TimeFilter.All, + onClick = { expanded = true }, + label = { Text("🕐 ${selected.label}") }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + TimeFilter.entries.forEach { tf -> + DropdownMenuItem(text = { Text(tf.label) }, onClick = { onSelect(tf); expanded = false }) + } + } + } +} + +/** null = no cutoff (all). */ +private fun timeCutoff(f: TimeFilter): Long? = when (f) { + TimeFilter.All -> null + TimeFilter.Last1h -> System.currentTimeMillis() - 3_600_000L + TimeFilter.Last24h -> System.currentTimeMillis() - 86_400_000L + TimeFilter.Today -> Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) + }.timeInMillis +} + +// ============================================================ Speakers tab + +@Composable +private fun SpeakersTab( + repo: Repository, + speakers: List, + onFilterBySpeaker: (Long) -> Unit, +) { + val scope = rememberCoroutineScope() + val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList()) + val countMap = remember(counts) { counts.associate { it.speakerId to it.count } } + var reclustering by remember { mutableStateOf(false) } + var reclusterMsg by remember { mutableStateOf("") } + + var renameFor by remember { mutableStateOf(null) } + var mergeFor by remember { mutableStateOf(null) } + var deleteFor by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("声纹库(${speakers.size})", style = MaterialTheme.typography.titleMedium) + Button( + enabled = !reclustering, + onClick = { + reclustering = true + scope.launch { + val n = withContext(Dispatchers.IO) { SpeakerClusterer.reclusterAll(repo) } + reclustering = false + reclusterMsg = "重新聚类完成:$n 个说话人" + } + }, + ) { Text(if (reclustering) "聚类中…" else "重新聚类") } + } + if (reclusterMsg.isNotEmpty()) { + Text(reclusterMsg, style = MaterialTheme.typography.bodySmall) + } + Text( + "自动发现的说话人会命名为「说话人N」。点击进入按 TA 筛选时间轴;可重命名 / 合并 / 删除。", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(8.dp)) + + if (speakers.isEmpty()) { + Text("还没有说话人。录一段话或点「重新聚类」。", style = MaterialTheme.typography.bodyMedium) + } + + LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(speakers, key = { it.id }) { sp -> + Card(modifier = Modifier.fillMaxWidth().clickable { onFilterBySpeaker(sp.id) }) { + Column(modifier = Modifier.padding(12.dp)) { + Text(sp.name, style = MaterialTheme.typography.titleSmall) + Text( + "归属 ${countMap[sp.id] ?: 0} 段 · 质心样本 ${sp.sampleCount}", + style = MaterialTheme.typography.labelSmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { renameFor = sp }) { Text("重命名") } + TextButton(onClick = { mergeFor = sp }) { Text("合并到…") } + TextButton(onClick = { deleteFor = sp }) { Text("删除") } + } + } + } + } + } + } + + renameFor?.let { sp -> + RenameSpeakerDialog( + currentName = sp.name, + onDismiss = { renameFor = null }, + onConfirm = { name -> + renameFor = null + if (name.isNotBlank()) { + scope.launch { withContext(Dispatchers.IO) { repo.speakers.updateName(sp.id, name.trim()) } } + } + }, + ) + } + mergeFor?.let { sp -> + MergeSpeakerDialog( + from = sp, + others = speakers.filter { it.id != sp.id }, + onDismiss = { mergeFor = null }, + onPick = { into -> + mergeFor = null + scope.launch { + withContext(Dispatchers.IO) { + repo.segments.reassignSpeaker(sp.id, into) + repo.speakers.delete(sp.id) + } + } + }, + ) + } + deleteFor?.let { sp -> + ConfirmDialog( + title = "删除说话人「${sp.name}」?", + message = "TA 名下 ${countMap[sp.id] ?: 0} 段会变回未归属(不会删除段本身)。", + confirmLabel = "删除", + onDismiss = { deleteFor = null }, + onConfirm = { + deleteFor = null + scope.launch { + withContext(Dispatchers.IO) { + repo.segments.clearSpeakerAssignments(sp.id) + repo.speakers.delete(sp.id) + } + } + }, + ) + } +} + +// ============================================================ Summaries tab + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Long) -> Unit) { + val scope = rememberCoroutineScope() + val summaries by repo.summaries.recent(100).collectAsState(initial = emptyList()) + var deleteFor by remember { mutableStateOf(null) } + var expandedId by remember { mutableStateOf(null) } + + val prefs = remember { activity.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) } + var gapMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN)) } + var gapMenu by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("纪要(${summaries.size})", style = MaterialTheme.typography.titleMedium) + Button(onClick = { SummaryScheduler.runNow(activity) }) { Text("现在跑一次") } + } + + // 会话切分间隔设置。 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("会话间隔:超过", style = MaterialTheme.typography.bodySmall) + Box { + FilterChip(selected = true, onClick = { gapMenu = true }, label = { Text("$gapMin 分钟") }) + DropdownMenu(expanded = gapMenu, onDismissRequest = { gapMenu = false }) { + listOf(2, 5, 10, 15, 20, 30).forEach { m -> + DropdownMenuItem(text = { Text("$m 分钟") }, onClick = { + gapMin = m + prefs.edit().putInt(SummaryWorker.KEY_GAP_MIN, m).apply() + gapMenu = false + }) + } + } + } + Text("没声音就算另一场", style = MaterialTheme.typography.bodySmall) + } + Text( + "按时间自动切会话,每段连续录音单独成一篇纪要。需要 hermes 在 127.0.0.1:8642 跑着。", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(8.dp)) + + if (summaries.isEmpty()) { + Text("还没有纪要。点「现在跑一次」。", style = MaterialTheme.typography.bodyMedium) + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { + items(summaries, key = { it.id }) { s -> + val expanded = expandedId == s.id + // 折叠头展示用:会话内段数 + 说话人名单(按需异步查询)。 + val info by produceState>?>(initialValue = null, s.id) { + value = withContext(Dispatchers.IO) { + repo.segments.countInRange(s.windowStartTs, s.windowEndTs) to + repo.segments.speakersInRange(s.windowStartTs, s.windowEndTs) + } + } + Card( + modifier = Modifier.fillMaxWidth().clickable { + expandedId = if (expanded) null else s.id + }, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + (if (expanded) "▾ " else "▸ ") + summaryRangeLabel(s.windowStartTs, s.windowEndTs), + style = MaterialTheme.typography.titleSmall, + ) + val dur = (s.windowEndTs - s.windowStartTs) / 60_000 + val cnt = info?.first + val spk = info?.second?.takeIf { it.isNotEmpty() }?.joinToString("、") + Text( + buildString { + append("$dur 分钟") + if (cnt != null) append(" · $cnt 段") + append(" · ").append(spk ?: "未识别说话人") + }, + style = MaterialTheme.typography.labelSmall, + ) + } + TextButton(onClick = { deleteFor = s }) { Text("删除") } + } + if (expanded) { + Spacer(Modifier.height(6.dp)) + SummaryMarkdown(s.markdown, s.windowStartTs, s.windowEndTs, onJumpToTime) + } else { + Text( + s.markdown.lineSequence().firstOrNull { it.isNotBlank() }?.take(40) ?: "", + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } + + deleteFor?.let { s -> + ConfirmDialog( + title = "删除纪要?", + message = summaryRangeLabel(s.windowStartTs, s.windowEndTs), + confirmLabel = "删除", + onDismiss = { deleteFor = null }, + onConfirm = { + deleteFor = null + scope.launch { withContext(Dispatchers.IO) { repo.summaries.delete(s.id) } } + }, + ) + } +} + +/** "从 06-04 14:30 到 15:10"(同日省略日期)。 */ +private fun summaryRangeLabel(start: Long, end: Long): String { + val sameDay = SUMMARY_DAY_FMT.format(Date(start)) == SUMMARY_DAY_FMT.format(Date(end)) + val s = SUMMARY_FMT.format(Date(start)) + val e = if (sameDay) SUMMARY_TIME_FMT.format(Date(end)) else SUMMARY_FMT.format(Date(end)) + return "从 $s 到 $e" +} + +private val TS_REGEX = Regex("""\[(\d{1,2}):(\d{2}):(\d{2})]""") + +/** 按行渲染纪要 markdown;含 [HH:mm:ss] 的行做成可点击,点了跳回时间轴对应段。 */ +@Composable +private fun SummaryMarkdown(markdown: String, windowStart: Long, windowEnd: Long, onJumpToTime: (Long) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + markdown.lineSequence().forEach { line -> + val m = TS_REGEX.find(line) + if (m != null) { + val (h, mi, se) = m.destructured + val ts = resolveTimestamp(h.toInt(), mi.toInt(), se.toInt(), windowStart, windowEnd) + Text( + "$line ↗", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().clickable { onJumpToTime(ts) }.padding(vertical = 2.dp), + ) + } else { + Text(line, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +/** [HH:mm:ss] + 会话起始日期 → 绝对毫秒(处理跨午夜)。 */ +private fun resolveTimestamp(h: Int, m: Int, s: Int, windowStart: Long, windowEnd: Long): Long { + val cal = Calendar.getInstance().apply { + timeInMillis = windowStart + set(Calendar.HOUR_OF_DAY, h) + set(Calendar.MINUTE, m) + set(Calendar.SECOND, s) + set(Calendar.MILLISECOND, 0) + } + var t = cal.timeInMillis + if (t < windowStart - 60_000) t += 86_400_000 // 会话跨过午夜 + return t +} + +// ============================================================ shared rows / dialogs + +@OptIn(ExperimentalMaterial3Api::class) +@Composable private fun SegmentActionSheet( row: SegmentRow, isPlaying: Boolean, onDismiss: () -> Unit, onPlay: () -> Unit, + onRename: () -> Unit, + onReassign: () -> Unit, + onEditText: () -> Unit, onEnroll: () -> Unit, + onDelete: () -> Unit, ) { val sheetState = rememberModalBottomSheetState() - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - "段 #${row.id} · ${"%.1fs".format(row.durationMs / 1000.0)}", - style = MaterialTheme.typography.titleMedium, - ) - row.text?.let { - Text(it, style = MaterialTheme.typography.bodyMedium) - } + Text("段 #${row.id} · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.titleMedium) + row.text?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } row.speakerName?.let { name -> - Text( - "已识别:$name (sim=${"%.2f".format(row.speakerSim ?: 0f)})", - style = MaterialTheme.typography.labelMedium, - ) - } - Spacer(Modifier.height(8.dp)) - Button( - onClick = onPlay, - modifier = Modifier.fillMaxWidth(), - ) { - Text(if (isPlaying) "■ 停止" else "▶ 播放原音") + Text("已识别:$name (sim=${"%.2f".format(row.speakerSim ?: 0f)})", style = MaterialTheme.typography.labelMedium) } - Button( - onClick = onEnroll, - modifier = Modifier.fillMaxWidth(), - ) { - Text("记为说话人") + Spacer(Modifier.height(4.dp)) + Button(onClick = onPlay, modifier = Modifier.fillMaxWidth()) { Text(if (isPlaying) "■ 停止" else "▶ 播放原音") } + if (row.speakerId != null) { + OutlinedButton(onClick = onRename, modifier = Modifier.fillMaxWidth()) { Text("重命名「${row.speakerName}」") } } + OutlinedButton(onClick = onReassign, modifier = Modifier.fillMaxWidth()) { Text("改派说话人") } + OutlinedButton(onClick = onEditText, modifier = Modifier.fillMaxWidth()) { Text("订正转写") } + OutlinedButton(onClick = onEnroll, modifier = Modifier.fillMaxWidth()) { Text("记为新说话人") } + OutlinedButton(onClick = onDelete, modifier = Modifier.fillMaxWidth()) { Text("删除该段") } } } } @Composable +private fun RenameSpeakerDialog(currentName: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + var name by remember { mutableStateOf(currentName) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("重命名说话人") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("「$currentName」名下所有段会一起更新,之后仍会继续吸纳相近声音。", style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = name, onValueChange = { name = it }, + label = { Text("说话人名字(如:我、老婆、张三)") }, singleLine = true, + ) + } + }, + confirmButton = { TextButton(onClick = { onConfirm(name) }, enabled = name.isNotBlank()) { Text("保存") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} + +@Composable +private fun ReassignSpeakerDialog( + row: SegmentRow, + speakers: List, + onDismiss: () -> Unit, + onPick: (Long?) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("改派说话人") }, + text = { + Column { + Text("段 #${row.id} 当前:${row.speakerName ?: "未归属"}", style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth().clickable { onPick(null) }.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = row.speakerId == null, onClick = { onPick(null) }) + Text("(清除归属)") + } + speakers.forEach { sp -> + Row(modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = row.speakerId == sp.id, onClick = { onPick(sp.id) }) + Text(sp.name) + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} + +@Composable +private fun MergeSpeakerDialog( + from: SpeakerProfile, + others: List, + onDismiss: () -> Unit, + onPick: (Long) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("把「${from.name}」合并到…") }, + text = { + Column { + if (others.isEmpty()) Text("没有其它说话人可合并。") + others.forEach { sp -> + Row(modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically) { + Text(sp.name) + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} + +@Composable +private fun EditTranscriptDialog(initial: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + var text by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("订正转写") }, + text = { + OutlinedTextField(value = text, onValueChange = { text = it }, modifier = Modifier.fillMaxWidth()) + }, + confirmButton = { TextButton(onClick = { onConfirm(text) }) { Text("保存") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} + +@Composable private fun EnrollSpeakerDialog( row: SegmentRow, existingNames: List, @@ -386,83 +1085,124 @@ private fun EnrollSpeakerDialog( var name by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, - title = { Text("记录说话人") }, + title = { Text("记为新说话人") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - "用这一段的声音建一个声纹 anchor。之后说话风格相近的段会自动打上同名标签。", - style = MaterialTheme.typography.bodySmall, - ) - Text( - "段 #${row.id}: ${row.text?.take(40) ?: "(无转写)"}", - style = MaterialTheme.typography.bodyMedium, - ) + Text("用这一段的声音建一个新声纹 anchor。", style = MaterialTheme.typography.bodySmall) + Text("段 #${row.id}: ${row.text?.take(40) ?: "(无转写)"}", style = MaterialTheme.typography.bodyMedium) OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("说话人名字(如:我、老婆、张三)") }, - singleLine = true, + value = name, onValueChange = { name = it }, + label = { Text("说话人名字") }, singleLine = true, ) if (existingNames.isNotEmpty()) { - Text( - "已存声纹:${existingNames.joinToString("、")}", - style = MaterialTheme.typography.labelSmall, - ) + Text("已存:${existingNames.joinToString("、")}", style = MaterialTheme.typography.labelSmall) } } }, - confirmButton = { - TextButton(onClick = { onConfirm(name) }, enabled = name.isNotBlank()) { - Text("保存") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("取消") } - }, + confirmButton = { TextButton(onClick = { onConfirm(name) }, enabled = name.isNotBlank()) { Text("保存") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, ) } -/** - * Read embedding from segment row, save as new SpeakerProfile. - * Future segments will be auto-matched against it (see RecorderService.refreshProfiles). - */ -private suspend fun enrollSpeakerForSegment( - repo: Repository, - segmentId: Long, - name: String, +@Composable +private fun ConfirmDialog( + title: String, + message: String, + confirmLabel: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, ) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(message, style = MaterialTheme.typography.bodySmall) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} + +@Composable +private fun TimelineRow( + row: SegmentRow, + isPlaying: Boolean, + onClick: () -> Unit, + selecting: Boolean = false, + selected: Boolean = false, + highlighted: Boolean = false, +) { + val time = TIMELINE_FMT.format(Date(row.startTs)) + val durSec = "%.1fs".format(row.durationMs / 1000.0) + val speakerLabel = when { + row.speakerName != null && row.speakerSim != null -> "${row.speakerName} (${"%.2f".format(row.speakerSim)})" + row.speakerName != null -> row.speakerName + row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})" + else -> "—" + } + val playMarker = if (isPlaying) "▶ " else "" + val bg = if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent + Row( + modifier = Modifier.fillMaxWidth().background(bg).clickable(onClick = onClick).padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (selecting) { + Text(if (selected) "☑" else "☐", style = MaterialTheme.typography.titleMedium) + } + Column(modifier = Modifier.weight(1f)) { + Text("$playMarker$time · $durSec · #${row.id} · $speakerLabel", style = MaterialTheme.typography.labelSmall) + Text( + row.text ?: "(no transcript yet)", + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private val EXPORT_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) + +/** Build a plain-text transcript and fire a share/save chooser. */ +private fun exportSegments(ctx: Context, rows: List) { + if (rows.isEmpty()) return + val sb = StringBuilder() + rows.sortedBy { it.startTs }.forEach { r -> + sb.append("[").append(EXPORT_FMT.format(Date(r.startTs))).append("] ") + sb.append(r.speakerName ?: "未知").append(":") + sb.append(r.text ?: "").append('\n') + } + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "Lifelog 导出 ${rows.size} 段") + putExtra(Intent.EXTRA_TEXT, sb.toString()) + } + runCatching { + ctx.startActivity( + Intent.createChooser(send, "导出 / 分享").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } +} + +// ============================================================ helpers + +private val TIMELINE_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) +private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) +private val SUMMARY_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US) +private val SUMMARY_DAY_FMT = SimpleDateFormat("yyyy-MM-dd", Locale.US) + +private suspend fun enrollSpeakerForSegment(repo: Repository, segmentId: Long, name: String) { withContext(Dispatchers.IO) { val seg = repo.segments.get(segmentId) ?: return@withContext val embedding = seg.embedding ?: return@withContext - repo.speakers.insert( - SpeakerProfile( - name = name.trim(), - embedding = embedding, - sampleCount = 1, - ), - ) + repo.speakers.insert(SpeakerProfile(name = name.trim(), embedding = embedding, sampleCount = 1)) android.util.Log.i("MainActivity", "enrolled speaker '$name' from segment #$segmentId") } } -/** - * FTS4 MATCH expects a query string. For Chinese character-level search with the - * unicode61 tokenizer, append `*` to each token so substring/prefix lookup works. - * Strip FTS metacharacters that would otherwise crash the parser. - */ private fun sanitizeFtsQuery(raw: String): String { val cleaned = raw.trim() - .replace("\"", "") - .replace("'", "") - .replace("(", "") - .replace(")", "") - .replace(":", "") + .replace("\"", "").replace("'", "").replace("(", "").replace(")", "").replace(":", "") if (cleaned.isEmpty()) return "" - // Treat whole input as one prefix term. For longer queries the user can put spaces - // for AND-of-terms; we add * suffix to each so partial matches work. - return cleaned.split(Regex("\\s+")) - .filter { it.isNotEmpty() } - .joinToString(" ") { "$it*" } + return cleaned.split(Regex("\\s+")).filter { it.isNotEmpty() }.joinToString(" ") { "$it*" } } private fun isBatteryOptExempt(ctx: Context): Boolean { @@ -479,88 +1219,30 @@ private fun requestBatteryOptExemption(ctx: Context) { runCatching { ctx.startActivity(intent) } } -private val TIMELINE_FMT = SimpleDateFormat("HH:mm:ss", Locale.US) -private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) - -@Composable -private fun SummaryCard(s: Summary) { - Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - ) { - Text( - "${SUMMARY_FMT.format(Date(s.windowStartTs))} → ${SUMMARY_FMT.format(Date(s.windowEndTs))} · ${s.model}", - style = MaterialTheme.typography.labelSmall, - ) - Text(s.markdown, style = MaterialTheme.typography.bodySmall) - } -} - -@Composable -private fun TimelineRow(row: SegmentRow, isPlaying: Boolean, onClick: () -> Unit) { - val time = TIMELINE_FMT.format(Date(row.startTs)) - val durSec = "%.1fs".format(row.durationMs / 1000.0) - val speakerLabel = when { - row.speakerName != null && row.speakerSim != null -> - "${row.speakerName} (${"%.2f".format(row.speakerSim)})" - row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})" - else -> "—" - } - val playMarker = if (isPlaying) "▶ " else "" - Column( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = 4.dp), - ) { - Text( - "$playMarker$time · $durSec · #${row.id} · $speakerLabel", - style = MaterialTheme.typography.labelSmall, - ) - Text( - row.text ?: "(no transcript yet)", - style = MaterialTheme.typography.bodyMedium, - ) - } -} - -private suspend fun runParaformerTest( - ctx: android.content.Context, - onUpdate: (status: String, log: String) -> Unit, -) { +private suspend fun runParaformerTest(ctx: Context, onUpdate: (status: String, log: String) -> Unit) { val log = StringBuilder() onUpdate("paraformer: ensuring model…", log.toString()) val downloader = ModelDownloader(ctx) val files = try { - downloader.ensure { msg -> - log.appendLine(msg) - onUpdate("paraformer: downloading…", log.toString()) - } + downloader.ensure { msg -> log.appendLine(msg); onUpdate("paraformer: downloading…", log.toString()) } } catch (t: Throwable) { - log.appendLine("download failed: ${t.message}") - onUpdate("paraformer: error", log.toString()) - return + log.appendLine("download failed: ${t.message}"); onUpdate("paraformer: error", log.toString()); return } log.appendLine("model: ${files.modelPath}") - onUpdate("paraformer: loading model…", log.toString()) - val engine = withContext(Dispatchers.IO) { - ParaformerEngine.load(files.modelPath, files.tokensPath, numThreads = 4) - } + val engine = withContext(Dispatchers.IO) { ParaformerEngine.load(files.modelPath, files.tokensPath, numThreads = 4) } try { val pcm = withContext(Dispatchers.IO) { ctx.assets.open("sample.wav").use { ins -> ins.skip(44) val bytes = ins.readBytes() val shorts = ShortArray(bytes.size / 2) - java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN) - .asShortBuffer().get(shorts) + java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN).asShortBuffer().get(shorts) shorts } } onUpdate("paraformer: transcribing ${pcm.size / 16000}s audio…", log.toString()) - val result = withContext(Dispatchers.Default) { - engine.transcribeWithStats(pcm) - } + val result = withContext(Dispatchers.Default) { engine.transcribeWithStats(pcm) } log.appendLine("==== RESULT ====") log.appendLine(result.toString()) onUpdate("paraformer: done ${"%.2f".format(1.0 / result.realtimeFactor)}× realtime", log.toString()) @@ -569,50 +1251,37 @@ private suspend fun runParaformerTest( } } -private suspend fun runXfyunTest( - ctx: android.content.Context, - onUpdate: (status: String, log: String) -> Unit, -) { +private suspend fun runXfyunTest(ctx: Context, onUpdate: (status: String, log: String) -> Unit) { val started = System.currentTimeMillis() val client = XfyunIatClient() val session = client.connect() val log = StringBuilder() - val collectJob: Job = kotlinx.coroutines.GlobalScope.launch(Dispatchers.Default) { session.flow.collect { event -> when (event) { is XfyunIatClient.Event.Partial -> { log.appendLine("[+${event.sn} ${event.pgs}] ${event.text}") - val elapsed = (System.currentTimeMillis() - started) / 1000.0 - onUpdate("讯飞: streaming t=${"%.1f".format(elapsed)}s", log.toString()) + onUpdate("讯飞: streaming t=${"%.1f".format((System.currentTimeMillis() - started) / 1000.0)}s", log.toString()) } is XfyunIatClient.Event.Final -> { - val elapsed = (System.currentTimeMillis() - started) / 1000.0 - log.appendLine("==== FINAL (wall=${"%.1f".format(elapsed)}s) ====") - log.appendLine(event.fullText) - onUpdate("讯飞: done wall=${"%.1f".format(elapsed)}s", log.toString()) + log.appendLine("==== FINAL ===="); log.appendLine(event.fullText) + onUpdate("讯飞: done", log.toString()) } is XfyunIatClient.Event.AsrError -> { - log.appendLine("!! ERROR code=${event.code} msg=${event.message} sid=${event.sid}") + log.appendLine("!! ERROR code=${event.code} msg=${event.message}") onUpdate("讯飞: error", log.toString()) } - XfyunIatClient.Event.Closed -> { - log.appendLine("==== CLOSED ====") - onUpdate("讯飞: closed", log.toString()) - } + XfyunIatClient.Event.Closed -> { log.appendLine("==== CLOSED ===="); onUpdate("讯飞: closed", log.toString()) } } } } - withContext(Dispatchers.IO) { ctx.assets.open("sample.wav").use { ins -> ins.skip(44) val buf = ByteArray(2560) while (true) { - val n = ins.read(buf) - if (n <= 0) break - val frame = if (n == buf.size) buf else buf.copyOf(n) - session.sendPcm(frame, last = false) + val n = ins.read(buf); if (n <= 0) break + session.sendPcm(if (n == buf.size) buf else buf.copyOf(n), last = false) delay(40) } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt new file mode 100644 index 0000000..54854ca --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt @@ -0,0 +1,187 @@ +package com.lemon.lifelog.asr + +import android.util.Log +import com.lemon.lifelog.data.Repository +import com.lemon.lifelog.data.SpeakerProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * 方案 B — 在线说话人聚类(auto speaker diarization, incremental)。 + * + * 与「手动 anchor 匹配」不同,这里会**自动发现**说话人:每个 VAD 段的 192-d embedding + * 跟所有已知说话人的「质心」算余弦相似度—— + * - best ≥ [matchThreshold] → 归入该说话人,并用滑动均值精修其质心; + * - 否则且段时长 ≥ [minNewClusterMs] → 新建一个 `说话人N` 簇(短段不可信,不开新簇); + * - 否则 → 暂不归属(speakerId=null)。 + * + * 簇直接持久化为 `speaker_profiles` 行,因此和手动建档共用同一套存储 / 时间轴 JOIN / + * 重命名入口。被重命名后的簇仍会继续吸纳新段、精修质心。 + * + * 线程模型:[assign] / [load] 都在 [mutex] 下串行;录音 pipeline 本身也是逐段处理, + * 因此实际无并发,[mutex] 只防御 UI 侧偶发调用。 + */ +class SpeakerClusterer( + private val repo: Repository, + private val matchThreshold: Float = MATCH_THRESHOLD, + private val minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, + private val maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID, +) { + private val mutex = Mutex() + private val clusters = mutableListOf() + private var autoSeq = 0 + + /** 结果:归属的说话人 id / 相似度 / 名字(名字仅用于日志与即时回显)。 */ + data class Assignment(val speakerId: Long?, val sim: Float?, val name: String?) + + /** 从 DB 载入已有簇(含手动建档与历史自动簇)。服务启动时调一次。 */ + suspend fun load() = mutex.withLock { + clusters.clear() + repo.speakers.all().forEach { + clusters += Cluster(it.id, it.name, it.embedding.toFloatArrayLe(), it.sampleCount) + } + autoSeq = clusters.mapNotNull { autoIndexOf(it.name) }.maxOrNull() ?: 0 + Log.i(TAG, "loaded ${clusters.size} clusters, autoSeq=$autoSeq") + } + + /** 将一个 embedding 归入/新建说话人簇,写穿到 DB。 */ + suspend fun assign(emb: FloatArray, durationMs: Long): Assignment = mutex.withLock { + var best: Cluster? = null + var bestSim = -1f + for (c in clusters) { + val s = cosineSim(emb, c.centroid) + if (s > bestSim) { + bestSim = s + best = c + } + } + + if (best != null && bestSim >= matchThreshold) { + // capped running mean:上限保证质心保持自适应、不被早期样本钉死。 + val n = minOf(best.count, maxSamplesPerCentroid) + val merged = FloatArray(emb.size) { i -> (best.centroid[i] * n + emb[i]) / (n + 1) } + l2normalize(merged) + best.centroid = merged + best.count += 1 + withContext(Dispatchers.IO) { + repo.speakers.updateEmbedding(best.id, merged.toLeBytes(), best.count) + } + return@withLock Assignment(best.id, bestSim, best.name) + } + + if (durationMs >= minNewClusterMs) { + val norm = emb.copyOf().also { l2normalize(it) } + val name = nextAutoName() + val id = withContext(Dispatchers.IO) { + repo.speakers.insert(SpeakerProfile(name = name, embedding = norm.toLeBytes(), sampleCount = 1)) + } + clusters += Cluster(id, name, norm, 1) + Log.i(TAG, "new speaker $name (#$id) bestSim=${"%.2f".format(bestSim)}") + return@withLock Assignment(id, 1f, name) + } + + // 匹配不上、又太短不足以开新簇。 + return@withLock Assignment(null, if (bestSim >= 0f) bestSim else null, null) + } + + private fun nextAutoName(): String { + autoSeq += 1 + return "$AUTO_PREFIX$autoSeq" + } + + companion object { + private const val TAG = "SpeakerClusterer" + + /** 自动簇命名前缀。重命名后不再以此开头,即视为「人工命名」。 */ + const val AUTO_PREFIX = "说话人" + + /** CAMPPlus 余弦阈值:同人通常 > 0.55,异人 < 0.45,取 0.5 为分界。 */ + const val MATCH_THRESHOLD = 0.5f + + /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */ + const val MIN_NEW_CLUSTER_MS = 1000L + + /** 质心滑动均值的样本上限。 */ + const val MAX_SAMPLES_PER_CENTROID = 50 + + /** `说话人N` → N;非自动名返回 null。 */ + fun autoIndexOf(name: String): Int? = + if (name.startsWith(AUTO_PREFIX)) name.removePrefix(AUTO_PREFIX).toIntOrNull() else null + + /** + * 离线重聚类:清掉旧的自动簇(保留人工命名的 anchor 作种子),按时间顺序把 + * **所有**已存 embedding 的段重新跑一遍聚类,并回填每段的 speakerId。 + * + * 用途:bootstrap —— 让历史录音立刻被分好说话人,不必等新音频累积。 + * @return 跑完后的簇总数 + */ + suspend fun reclusterAll( + repo: Repository, + matchThreshold: Float = MATCH_THRESHOLD, + minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, + maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID, + ): Int = withContext(Dispatchers.IO) { + // 1. 删掉自动簇,保留人工命名簇作种子。 + val seeds = mutableListOf() + for (p in repo.speakers.all()) { + if (autoIndexOf(p.name) != null) { + repo.speakers.delete(p.id) + } else { + seeds += Cluster(p.id, p.name, p.embedding.toFloatArrayLe(), p.sampleCount) + } + } + val clusters = seeds + var seq = 0 + + // 2. 按时间正序逐段聚类。 + val segs = repo.segments.allWithEmbedding().sortedBy { it.startTs } + for (seg in segs) { + val emb = seg.embedding?.toFloatArrayLe() ?: continue + var best: Cluster? = null + var bestSim = -1f + for (c in clusters) { + val s = cosineSim(emb, c.centroid) + if (s > bestSim) { + bestSim = s + best = c + } + } + + val assignment = if (best != null && bestSim >= matchThreshold) { + val n = minOf(best.count, maxSamplesPerCentroid) + val merged = FloatArray(emb.size) { i -> (best.centroid[i] * n + emb[i]) / (n + 1) } + l2normalize(merged) + best.centroid = merged + best.count += 1 + repo.speakers.updateEmbedding(best.id, merged.toLeBytes(), best.count) + best.id to bestSim + } else if (seg.durationMs >= minNewClusterMs) { + val norm = emb.copyOf().also { l2normalize(it) } + seq += 1 + val name = "$AUTO_PREFIX$seq" + val id = repo.speakers.insert( + SpeakerProfile(name = name, embedding = norm.toLeBytes(), sampleCount = 1), + ) + clusters += Cluster(id, name, norm, 1) + id to 1f + } else { + null to (if (bestSim >= 0f) bestSim else null) + } + + repo.segments.updateSpeaker(seg.id, assignment.first, assignment.second) + } + Log.i(TAG, "reclusterAll: ${segs.size} segments → ${clusters.size} clusters") + clusters.size + } + } +} + +/** 簇的内存表示(质心持续更新)。文件私有,供实例与离线重聚类共用。 */ +private class Cluster( + val id: Long, + var name: String, + var centroid: FloatArray, + var count: Int, +) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerEmbedder.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerEmbedder.kt index b997f57..a961409 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerEmbedder.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerEmbedder.kt @@ -99,6 +99,15 @@ fun cosineSim(a: FloatArray, b: FloatArray): Float { return (dot / (sqrt(na) * sqrt(nb))).toFloat() } +/** L2-normalise a vector in place (no-op if zero). Keeps running centroids stable. */ +fun l2normalize(v: FloatArray) { + var n = 0.0 + for (x in v) n += x * x + if (n == 0.0) return + val inv = (1.0 / sqrt(n)).toFloat() + for (i in v.indices) v[i] *= inv +} + /** Average a list of embeddings into one (per-dim mean), normalised. */ fun averageEmbeddings(embeddings: List): FloatArray { require(embeddings.isNotEmpty()) { "empty list" } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 7131d37..a021920 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -16,6 +16,11 @@ data class SegmentRow( val speakerName: String?, ) +data class SpeakerCount( + val speakerId: Long, + val count: Int, +) + @Dao interface SegmentDao { @Insert @@ -33,6 +38,17 @@ interface SegmentDao { @Query("UPDATE segments SET speakerId = :speakerId, speakerSim = :sim WHERE id = :id") suspend fun updateSpeaker(id: Long, speakerId: Long?, sim: Float?) + @Query("DELETE FROM segments WHERE id = :id") + suspend fun deleteSegment(id: Long) + + /** 删除某说话人后,把其名下段落置空。 */ + @Query("UPDATE segments SET speakerId = NULL, speakerSim = NULL WHERE speakerId = :speakerId") + suspend fun clearSpeakerAssignments(speakerId: Long) + + /** 合并说话人:把 [from] 名下段落改派给 [into]。 */ + @Query("UPDATE segments SET speakerId = :into WHERE speakerId = :from") + suspend fun reassignSpeaker(from: Long, into: Long) + /** * Backfill: re-assign speakerId for all segments that have a stored embedding, * using the latest profile set. Called once after a new profile is enrolled. @@ -41,6 +57,35 @@ interface SegmentDao { @Query("SELECT * FROM segments WHERE embedding IS NOT NULL") suspend fun allWithEmbedding(): List + /** 每个说话人当前实际归属的段数(用于声纹列表展示)。 */ + @Query("SELECT speakerId AS speakerId, COUNT(*) AS count FROM segments WHERE speakerId IS NOT NULL GROUP BY speakerId") + fun observeSpeakerCounts(): Flow> + + /** 某时间范围内的段数(纪要折叠头展示)。 */ + @Query("SELECT COUNT(*) FROM segments WHERE startTs BETWEEN :start AND :end") + suspend fun countInRange(start: Long, end: Long): Int + + /** 某时间范围内出现过的说话人名单(去重,纪要折叠头展示)。 */ + @Query( + """ + SELECT DISTINCT sp.name FROM segments s + INNER JOIN speaker_profiles sp ON sp.id = s.speakerId + WHERE s.startTs BETWEEN :start AND :end + ORDER BY sp.name + """, + ) + suspend fun speakersInRange(start: Long, end: Long): List + + /** 范围内离目标时间最近的段 id(纪要要点跳回时间轴用)。 */ + @Query( + """ + SELECT id FROM segments + ORDER BY ABS(startTs - :targetTs) ASC + LIMIT 1 + """, + ) + suspend fun nearestSegmentId(targetTs: Long): Long? + @Query( """ SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, @@ -82,6 +127,13 @@ interface TranscriptDao { @Query("SELECT * FROM transcripts WHERE segmentId = :id") suspend fun get(id: Long): Transcript? + + /** 手动订正转写文本(FTS 由 Room 触发器自动同步)。 */ + @Query("UPDATE transcripts SET text = :text, reviewedAt = :reviewedAt WHERE segmentId = :id") + suspend fun updateText(id: Long, text: String, reviewedAt: Long) + + @Query("DELETE FROM transcripts WHERE segmentId = :id") + suspend fun deleteBySegment(id: Long) } @Dao @@ -91,6 +143,12 @@ interface SummaryDao { @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n") fun recent(n: Int): Flow> + + @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC") + suspend fun all(): List + + @Query("DELETE FROM summaries WHERE id = :id") + suspend fun delete(id: Long) } @Dao @@ -104,6 +162,14 @@ interface SpeakerDao { @Query("SELECT * FROM speaker_profiles ORDER BY createdAt ASC") suspend fun all(): List + /** Refine a cluster's running centroid + sample count (online clustering). */ + @Query("UPDATE speaker_profiles SET embedding = :embedding, sampleCount = :sampleCount WHERE id = :id") + suspend fun updateEmbedding(id: Long, embedding: ByteArray, sampleCount: Int) + + /** Rename a speaker (e.g. give an auto-discovered 说话人N a real name). */ + @Query("UPDATE speaker_profiles SET name = :name WHERE id = :id") + suspend fun updateName(id: Long, name: String) + @Query("DELETE FROM speaker_profiles WHERE id = :id") suspend fun delete(id: Long) } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt index b1bff8d..2e0164b 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt @@ -16,9 +16,8 @@ import com.k2fsa.sherpa.onnx.VadModelConfig import com.lemon.lifelog.MainActivity import com.lemon.lifelog.asr.ModelDownloader import com.lemon.lifelog.asr.ParaformerEngine +import com.lemon.lifelog.asr.SpeakerClusterer import com.lemon.lifelog.asr.SpeakerEmbedder -import com.lemon.lifelog.asr.cosineSim -import com.lemon.lifelog.asr.toFloatArrayLe import com.lemon.lifelog.asr.toLeBytes import com.lemon.lifelog.audio.AudioCapture import com.lemon.lifelog.audio.SegmentWriter @@ -41,11 +40,26 @@ class RecorderService : Service() { const val CHANNEL_ID = "recorder" const val NOTIFICATION_ID = 1001 + const val ACTION_PAUSE = "com.lemon.lifelog.PAUSE" + const val ACTION_RESUME = "com.lemon.lifelog.RESUME" + @Volatile var isRunning: Boolean = false private set + + /** 真暂停:停止采集但保留模型常驻。 */ + fun pause(ctx: android.content.Context) { + ctx.startService(Intent(ctx, RecorderService::class.java).setAction(ACTION_PAUSE)) + } + + fun resume(ctx: android.content.Context) { + ctx.startService(Intent(ctx, RecorderService::class.java).setAction(ACTION_RESUME)) + } } + @Volatile + private var paused = false + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var pipelineJob: Job? = null private var wakeLock: PowerManager.WakeLock? = null @@ -57,13 +71,8 @@ class RecorderService : Service() { private var engine: ParaformerEngine? = null private var speakerEmbedder: SpeakerEmbedder? = null - /** Anchor profiles cached in memory; refreshed when DB changes. */ - @Volatile - private var profiles: List = emptyList() - private data class CachedProfile(val id: Long, val name: String, val embedding: FloatArray) - - /** Cosine threshold above which we assign a profile to a segment. Tuned for CAMPPlus. */ - private val speakerMatchThreshold = 0.5f + /** 在线说话人聚类器(方案 B):自动发现说话人并持久化为 speaker_profiles。 */ + private var clusterer: SpeakerClusterer? = null override fun onCreate() { super.onCreate() @@ -79,7 +88,40 @@ class RecorderService : Service() { pipelineJob = scope.launch { runPipeline() } } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_PAUSE -> doPause() + ACTION_RESUME -> doResume() + } + return START_STICKY + } + + private fun doPause() { + if (paused) return + paused = true + capture.stop() + wakeLock?.takeIf { it.isHeld }?.release() + RecorderState.setPaused(true) + RecorderState.setStatus("已暂停") + notify("已暂停") + Log.i(TAG, "paused (models kept resident)") + } + + private fun doResume() { + if (!paused) return + paused = false + acquireWakeLock() + capture.start(scope) + RecorderState.setPaused(false) + RecorderState.setStatus("listening") + notify("录音中") + Log.i(TAG, "resumed") + } + + private fun notify(text: String) { + val nm = getSystemService(android.app.NotificationManager::class.java) + nm?.notify(NOTIFICATION_ID, buildNotification(text)) + } override fun onDestroy() { pipelineJob?.cancel() @@ -159,16 +201,21 @@ class RecorderService : Service() { Log.w(TAG, "speaker embedder not available; segments will skip embedding", t) } - // 2d. Load anchor profiles (cosine matching). - refreshProfiles() + // 2d. Online speaker clusterer (auto-discovers 说话人N; persists as speaker_profiles). + clusterer = SpeakerClusterer(repo).also { it.load() } + // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer. + // load() is mutex-serialised vs assign() and idempotent, so reloading on every + // speaker_profiles change (incl. the clusterer's own write-throughs) is safe. scope.launch { - repo.speakers.observeAll().collect { _ -> refreshProfiles() } + repo.speakers.observeAll().collect { clusterer?.load() } } - // 3. Start capturing. - capture.start(scope) - RecorderState.setStatus("listening") - Log.i(TAG, "pipeline running speakers=${profiles.size}") + // 3. Start capturing (unless paused arrived during model load). + if (!paused) { + capture.start(scope) + RecorderState.setStatus("listening") + } + Log.i(TAG, "pipeline running, clusterer loaded") capture.frames.collect { samples -> val v = vad ?: return@collect @@ -203,7 +250,7 @@ class RecorderService : Service() { } val wallMs = (System.nanoTime() - wallStart) / 1_000_000 - // c. speaker embedding + nearest-profile match. + // c. speaker embedding + online clustering (auto-discovers 说话人N). val embedding: FloatArray? = speakerEmbedder?.let { embedder -> try { embedder.embed(samples, sr) @@ -212,22 +259,12 @@ class RecorderService : Service() { null } } - val (matchedSpeakerId: Long?, matchedSim: Float?, matchedName: String?) = - if (embedding != null && profiles.isNotEmpty()) { - var bestId: Long? = null - var bestName: String? = null - var bestSim = -1f - for (p in profiles) { - val sim = cosineSim(embedding, p.embedding) - if (sim > bestSim) { - bestSim = sim - bestId = p.id - bestName = p.name - } - } - if (bestSim >= speakerMatchThreshold) Triple(bestId, bestSim, bestName) - else Triple(null, bestSim, null) - } else Triple(null, null, null) + val assignment = embedding?.let { emb -> + clusterer?.assign(emb, audioMs) + } ?: SpeakerClusterer.Assignment(null, null, null) + val matchedSpeakerId = assignment.speakerId + val matchedSim = assignment.sim + val matchedName = assignment.name // d. Room insert. val segmentId = kotlinx.coroutines.withContext(Dispatchers.IO) { @@ -263,14 +300,6 @@ class RecorderService : Service() { RecorderState.onSegment(text, audioMs, wallMs) } - private suspend fun refreshProfiles() { - val rows = kotlinx.coroutines.withContext(Dispatchers.IO) { repo.speakers.all() } - profiles = rows.map { - CachedProfile(it.id, it.name, it.embedding.toFloatArrayLe()) - } - Log.i(TAG, "loaded ${profiles.size} speaker profiles") - } - private fun startForegroundCompat() { val notification = buildNotification() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -285,6 +314,7 @@ class RecorderService : Service() { } private fun acquireWakeLock() { + if (wakeLock?.isHeld == true) return val pm = getSystemService(PowerManager::class.java) wakeLock = pm.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, @@ -295,7 +325,7 @@ class RecorderService : Service() { } } - private fun buildNotification(): Notification { + private fun buildNotification(text: String = "录音中"): Notification { val pi = PendingIntent.getActivity( this, 0, @@ -304,7 +334,7 @@ class RecorderService : Service() { ) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Lifelog") - .setContentText("录音中") + .setContentText(text) .setSmallIcon(android.R.drawable.presence_audio_online) .setContentIntent(pi) .setOngoing(true) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt index 7b7c3f1..5bdb533 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt @@ -8,6 +8,10 @@ object RecorderState { private val _isRunning = MutableStateFlow(false) val isRunning: StateFlow = _isRunning + /** 真暂停:服务存活、模型常驻,仅停止音频采集。 */ + private val _isPaused = MutableStateFlow(false) + val isPaused: StateFlow = _isPaused + private val _segmentCount = MutableStateFlow(0) val segmentCount: StateFlow = _segmentCount @@ -25,6 +29,11 @@ object RecorderState { fun setRunning(v: Boolean) { _isRunning.value = v + if (!v) _isPaused.value = false + } + + fun setPaused(v: Boolean) { + _isPaused.value = v } fun onSegment(text: String, audioMs: Long, wallMs: Long) { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt index 570a461..e163a17 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt @@ -29,10 +29,13 @@ object SummaryScheduler { ) } - fun runNow(ctx: Context, windowOverride: Long? = null) { - val data = if (windowOverride != null) { - Data.Builder().putLong(SummaryWorker.INPUT_NOW_TS, windowOverride).build() - } else Data.EMPTY + /** 手动跑一次:用当前 SharedPreferences 里的会话间隔设置。 */ + fun runNow(ctx: Context) { + val gapMin = ctx.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) + .getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN) + val data = Data.Builder() + .putLong(SummaryWorker.INPUT_GAP_MS, gapMin * 60_000L) + .build() val request = OneTimeWorkRequestBuilder() .setInputData(data) .addTag(ONE_SHOT_TAG) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt index b3bc683..b8ed3b9 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt @@ -6,25 +6,24 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.lemon.lifelog.asr.HermesClient import com.lemon.lifelog.data.Repository +import com.lemon.lifelog.data.Segment import com.lemon.lifelog.data.Summary import java.text.SimpleDateFormat import java.util.Date import java.util.Locale /** - * Periodic / on-demand 纪要 worker. + * 会话级 纪要 worker(按时间间隔自动切会话)。 * - * Strategy: - * - Pull transcripts in a sliding window (default: last [WINDOW_MS] ms). - * - If the window covers < [MIN_DURATION_MS] of actual speech, skip. - * - If a Summary already exists that overlaps the window with the same or - * larger span, skip (idempotent). - * - Compose prompt with transcripts (+ speaker labels if known) and POST to - * hermes /v1/chat/completions; save the markdown reply as a Summary row. + * 策略: + * - 拉取所有段,按 startTs 排序,相邻两段间隔 > [gapMs] 即切成不同「会话」。 + * - 只总结**已结束**的会话:now − 会话末段 endTs > gapMs(确信不会再有新段并入), + * 避免把正在进行的会话过早总结。 + * - 会话总语音 < [MIN_SESSION_SPEECH_MS] 跳过(太短不值得)。 + * - 已有 Summary 的时间范围与会话重叠则跳过(幂等,可反复跑)。 + * - 每个待总结会话 → 拼 prompt POST hermes /v1/chat/completions,存一行 Summary。 * - * Failure modes: - * - hermes gateway down → IOException → Worker returns retry(). - * - Empty window → success() no-op. + * 一次运行会处理**所有**符合条件的会话(可能 0~N 篇)。 */ class SummaryWorker( ctx: Context, @@ -34,79 +33,129 @@ class SummaryWorker( companion object { const val TAG = "SummaryWorker" - /** 总结窗口大小:默认最近 1 小时。 */ - const val WINDOW_MS = 60L * 60 * 1000 + /** 会话切分间隔默认值:10 分钟。UI 可调,存 SharedPreferences。 */ + const val DEFAULT_GAP_MIN = 10 + const val MIN_SESSION_SPEECH_MS = 30_000L // 会话至少 30s 语音 - /** 窗口内必须至少有这么多毫秒的语音才触发,否则没必要总结。 */ - const val MIN_DURATION_MS = 60_000L // 60s + const val INPUT_NOW_TS = "now_ts" // 覆盖 now(测试 / 手动) + const val INPUT_GAP_MS = "gap_ms" // 覆盖会话间隔(手动跑用当前设置) - const val INPUT_NOW_TS = "now_ts" // override "now" for tests / manual run + const val PREFS = "lifelog_prefs" + const val KEY_GAP_MIN = "session_gap_min" + + /** 纯函数:把按时间排好序的段切成若干会话。 */ + fun groupSessions(segments: List, gapMs: Long): List> { + val out = mutableListOf>() + for (seg in segments) { + val cur = out.lastOrNull() + if (cur == null || seg.startTs - cur.last().endTs > gapMs) { + out.add(mutableListOf(seg)) + } else { + cur.add(seg) + } + } + return out + } } - private val dateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) + private val timeFmt = SimpleDateFormat("HH:mm:ss", Locale.US) + private val rangeFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) override suspend fun doWork(): Result { - val nowTs = inputData.getLong(INPUT_NOW_TS, System.currentTimeMillis()) - val windowStart = nowTs - WINDOW_MS + val now = inputData.getLong(INPUT_NOW_TS, System.currentTimeMillis()) + val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 } + ?: (prefs.getInt(KEY_GAP_MIN, DEFAULT_GAP_MIN) * 60_000L) + val repo = Repository(applicationContext) + val segments = repo.segments.page(limit = 5000, offset = 0).sortedBy { it.startTs } + if (segments.isEmpty()) { + Log.i(TAG, "no segments"); return Result.success() + } + + val sessions = groupSessions(segments, gapMs) + val existing = repo.summaries.all() + val speakerNames = repo.speakers.all().associate { it.id to it.name } + + var made = 0 + var anyFailed = false + + for (session in sessions) { + val start = session.first().startTs + val end = session.last().endTs + + // 未结束的会话不动(可能还会有新段并入)。 + if (now - end <= gapMs) continue + // 太短跳过。 + if (session.sumOf { it.durationMs } < MIN_SESSION_SPEECH_MS) continue + // 已总结过(时间范围重叠)跳过。 + if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) continue - val segments = repo.segments.page(limit = 500, offset = 0) - .filter { it.startTs in windowStart..nowTs } - .sortedBy { it.startTs } + val userPrompt = buildPrompt(repo, session, speakerNames, start, end) + if (userPrompt == null) continue // 整段无有效转写 - val totalSpeechMs = segments.sumOf { it.durationMs } - if (totalSpeechMs < MIN_DURATION_MS) { - Log.i(TAG, "skip: total speech ${totalSpeechMs}ms < ${MIN_DURATION_MS}ms threshold") - return Result.success() + val client = HermesClient() + val resp = try { + client.chat(SYSTEM_PROMPT, userPrompt) + } catch (t: Throwable) { + Log.w(TAG, "hermes call failed for session ${rangeFmt.format(Date(start))}: ${t.message}") + anyFailed = true + continue + } + + repo.summaries.insert( + Summary( + windowStartTs = start, + windowEndTs = end, + markdown = resp.text, + model = resp.model, + createdAt = now, + ), + ) + made++ + Log.i( + TAG, + "summary saved ${rangeFmt.format(Date(start))}–${rangeFmt.format(Date(end))} " + + "segments=${session.size} speech=${session.sumOf { it.durationMs } / 1000}s", + ) } - // Build the user prompt: time range + each segment's text (+ speaker label if any). + Log.i(TAG, "done: ${sessions.size} sessions, $made summarized (gap=${gapMs / 60_000}min)") + return if (anyFailed && made == 0) Result.retry() else Result.success() + } + + /** 拼 prompt;若会话内没有任何有效转写返回 null。 */ + private suspend fun buildPrompt( + repo: Repository, + session: List, + speakerNames: Map, + start: Long, + end: Long, + ): String? { val sb = StringBuilder() - sb.appendLine("以下是 ${dateFmt.format(Date(windowStart))} – ${dateFmt.format(Date(nowTs))} 的语音转写片段(按时间顺序)。") + sb.appendLine("以下是 ${rangeFmt.format(Date(start))} – ${rangeFmt.format(Date(end))} 一次连续录音的语音转写片段(按时间顺序)。") sb.appendLine() - for (seg in segments) { + var hasText = false + for (seg in session) { val transcript = repo.transcripts.get(seg.id)?.text ?: continue if (transcript.isBlank()) continue - val speakerLabel = repo.segments.get(seg.id)?.speakerId - ?.let { repo.speakers.all().firstOrNull { p -> p.id == it }?.name } - ?: "未知" - val timeStr = SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(seg.startTs)) - sb.appendLine("[$timeStr] $speakerLabel: $transcript") + hasText = true + val speaker = seg.speakerId?.let { speakerNames[it] } ?: "未知" + sb.appendLine("[${timeFmt.format(Date(seg.startTs))}] $speaker: $transcript") } - - val systemPrompt = """ - 你是一个 lifelog 纪要助手。给定一段时间内的语音转写片段(含时间戳和说话人), - 请输出一份简洁的 markdown 纪要,包含: - 1. 一句话主题概括 - 2. 关键信息要点(bullet 列表,5-10 条) - 3. 如果有待办事项 / 决定 / 提到的人名地名,单独列「待办」「人物」「地点」三个小节 - 纪要语言保持中文。不要复述全文,只摘核心。 - """.trimIndent() - - val client = HermesClient() - val response = try { - client.chat(systemPrompt, sb.toString()) - } catch (t: Throwable) { - Log.w(TAG, "hermes call failed: ${t.message}; will retry") - return Result.retry() - } - - repo.summaries.insert( - Summary( - windowStartTs = windowStart, - windowEndTs = nowTs, - markdown = response.text, - model = response.model, - createdAt = System.currentTimeMillis(), - ), - ) - - Log.i( - TAG, - "summary saved span=${WINDOW_MS / 60_000}min " + - "speech=${totalSpeechMs / 1000}s segments=${segments.size} " + - "tokens(prompt=${response.promptTokens}, completion=${response.completionTokens})", - ) - return Result.success() + return if (hasText) sb.toString() else null } } + +private val SYSTEM_PROMPT = """ + 你是一个 lifelog 纪要助手。给定一次连续录音的语音转写片段(含时间戳和说话人), + 请输出一份简洁的 markdown 纪要,包含: + 1. 一句话主题概括 + 2. 关键信息要点(bullet 列表,5-10 条) + 3. 如果有待办事项 / 决定 / 提到的人名地名,单独列「待办」「人物」「地点」三个小节 + 重要:每一条「关键信息要点」和「待办」都必须在该行开头标注它对应的来源时间戳, + 格式为方括号 [HH:mm:ss](取自输入片段里最相关那条的时间),例如: + - [14:03:12] 讨论了 Q3 的预算分配 + 这样用户点击该要点能跳回对应录音位置。时间戳必须真实来自输入,不要编造。 + 纪要语言保持中文。不要复述全文,只摘核心。 +""".trimIndent() -- libgit2 0.21.4