Commit 4b73a1503b2dda2b465039c19f2ef0b9d61dc04b

Authored by yibo
1 parent 85a1571a

feat(lifelog-recorder): v0.6 — 自动说话人聚类 + Tab 化 UI + 会话级纪要

- 声纹方案 B:新增 SpeakerClusterer 在线增量聚类,按余弦相似度自动发现
  「说话人N」,capped 滑动均值精修质心并写穿 speaker_profiles;
  reclusterAll() 一键对历史段重聚类。不改 schema。
- UI 重构为 4 个底部 Tab:首页 / 时间轴 / 声纹 / 纪要。
- 时间轴:FTS 中文搜索 + 按说话人/时间范围/未匹配低置信筛选 + 排序切换,
  全 CRUD(删段 / 改派说话人 / 订正转写 / 记为新说话人),批量选择删除 / 导出。
- 声纹 Tab:说话人列表(归属段数)、重命名 / 合并 / 删除、点击筛选时间轴。
- 真暂停:RecorderService 加 PAUSE/RESUME,停采集但 paraformer/VAD/聚类器常驻,
  秒级恢复;RecorderState 加 isPaused。
- 纪要会话化:SummaryWorker 改为按时间间隔(UI 可调,默认 10min)自动切会话,
  每场连续录音单独成篇,只总结已结束会话,时间范围重叠判重幂等。
- 纪要列表折叠显示「从 xx 到 xx · N 段 · 说话人名单 · 时长」,点开看 markdown。
- 纪要要点带 [HH:mm:ss] 来源时间戳,点击跳回时间轴对应段并高亮(含跨午夜处理)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
@@ -13,67 +13,80 @@ import androidx.activity.ComponentActivity @@ -13,67 +13,80 @@ import androidx.activity.ComponentActivity
13 import androidx.activity.compose.rememberLauncherForActivityResult 13 import androidx.activity.compose.rememberLauncherForActivityResult
14 import androidx.activity.compose.setContent 14 import androidx.activity.compose.setContent
15 import androidx.activity.result.contract.ActivityResultContracts 15 import androidx.activity.result.contract.ActivityResultContracts
  16 +import androidx.compose.foundation.background
16 import androidx.compose.foundation.clickable 17 import androidx.compose.foundation.clickable
  18 +import androidx.compose.foundation.horizontalScroll
17 import androidx.compose.foundation.layout.Arrangement 19 import androidx.compose.foundation.layout.Arrangement
  20 +import androidx.compose.foundation.layout.Box
18 import androidx.compose.foundation.layout.Column 21 import androidx.compose.foundation.layout.Column
19 import androidx.compose.foundation.layout.Row 22 import androidx.compose.foundation.layout.Row
20 import androidx.compose.foundation.layout.Spacer 23 import androidx.compose.foundation.layout.Spacer
21 import androidx.compose.foundation.layout.fillMaxSize 24 import androidx.compose.foundation.layout.fillMaxSize
22 import androidx.compose.foundation.layout.fillMaxWidth 25 import androidx.compose.foundation.layout.fillMaxWidth
23 import androidx.compose.foundation.layout.height 26 import androidx.compose.foundation.layout.height
24 -import androidx.compose.foundation.layout.heightIn  
25 import androidx.compose.foundation.layout.padding 27 import androidx.compose.foundation.layout.padding
26 import androidx.compose.foundation.lazy.LazyColumn 28 import androidx.compose.foundation.lazy.LazyColumn
27 import androidx.compose.foundation.lazy.items 29 import androidx.compose.foundation.lazy.items
  30 +import androidx.compose.foundation.lazy.rememberLazyListState
28 import androidx.compose.foundation.rememberScrollState 31 import androidx.compose.foundation.rememberScrollState
29 -import androidx.compose.foundation.text.KeyboardOptions  
30 import androidx.compose.foundation.verticalScroll 32 import androidx.compose.foundation.verticalScroll
31 import androidx.compose.material3.AlertDialog 33 import androidx.compose.material3.AlertDialog
32 import androidx.compose.material3.Button 34 import androidx.compose.material3.Button
  35 +import androidx.compose.material3.Card
  36 +import androidx.compose.material3.DropdownMenu
  37 +import androidx.compose.material3.DropdownMenuItem
33 import androidx.compose.material3.ExperimentalMaterial3Api 38 import androidx.compose.material3.ExperimentalMaterial3Api
  39 +import androidx.compose.material3.FilterChip
34 import androidx.compose.material3.HorizontalDivider 40 import androidx.compose.material3.HorizontalDivider
35 import androidx.compose.material3.MaterialTheme 41 import androidx.compose.material3.MaterialTheme
36 import androidx.compose.material3.ModalBottomSheet 42 import androidx.compose.material3.ModalBottomSheet
  43 +import androidx.compose.material3.NavigationBar
  44 +import androidx.compose.material3.NavigationBarItem
  45 +import androidx.compose.material3.OutlinedButton
37 import androidx.compose.material3.OutlinedTextField 46 import androidx.compose.material3.OutlinedTextField
  47 +import androidx.compose.material3.RadioButton
  48 +import androidx.compose.material3.Scaffold
38 import androidx.compose.material3.Surface 49 import androidx.compose.material3.Surface
39 import androidx.compose.material3.Text 50 import androidx.compose.material3.Text
40 import androidx.compose.material3.TextButton 51 import androidx.compose.material3.TextButton
41 -import androidx.compose.material3.TextField  
42 import androidx.compose.material3.rememberModalBottomSheetState 52 import androidx.compose.material3.rememberModalBottomSheetState
43 import androidx.compose.runtime.Composable 53 import androidx.compose.runtime.Composable
44 -import androidx.compose.runtime.collectAsState  
45 import androidx.compose.runtime.DisposableEffect 54 import androidx.compose.runtime.DisposableEffect
  55 +import androidx.compose.runtime.LaunchedEffect
  56 +import androidx.compose.runtime.collectAsState
46 import androidx.compose.runtime.getValue 57 import androidx.compose.runtime.getValue
  58 +import androidx.compose.runtime.mutableStateListOf
47 import androidx.compose.runtime.mutableStateOf 59 import androidx.compose.runtime.mutableStateOf
  60 +import androidx.compose.runtime.produceState
48 import androidx.compose.runtime.remember 61 import androidx.compose.runtime.remember
49 import androidx.compose.runtime.rememberCoroutineScope 62 import androidx.compose.runtime.rememberCoroutineScope
50 import androidx.compose.runtime.setValue 63 import androidx.compose.runtime.setValue
51 -import androidx.lifecycle.Lifecycle  
52 -import androidx.lifecycle.LifecycleEventObserver  
53 -import androidx.lifecycle.compose.LocalLifecycleOwner 64 +import androidx.compose.ui.Alignment
54 import androidx.compose.ui.Modifier 65 import androidx.compose.ui.Modifier
  66 +import androidx.compose.ui.graphics.Color
  67 +import androidx.compose.ui.platform.LocalContext
  68 +import androidx.compose.ui.text.style.TextOverflow
55 import androidx.compose.ui.unit.dp 69 import androidx.compose.ui.unit.dp
56 import androidx.core.content.ContextCompat 70 import androidx.core.content.ContextCompat
  71 +import androidx.lifecycle.Lifecycle
  72 +import androidx.lifecycle.LifecycleEventObserver
  73 +import androidx.lifecycle.compose.LocalLifecycleOwner
57 import com.lemon.lifelog.asr.ModelDownloader 74 import com.lemon.lifelog.asr.ModelDownloader
58 import com.lemon.lifelog.asr.ParaformerEngine 75 import com.lemon.lifelog.asr.ParaformerEngine
59 -import com.lemon.lifelog.asr.SpeakerEmbedder  
60 -import com.lemon.lifelog.asr.SpeakerMatcher 76 +import com.lemon.lifelog.asr.SpeakerClusterer
61 import com.lemon.lifelog.asr.XfyunIatClient 77 import com.lemon.lifelog.asr.XfyunIatClient
62 -import com.lemon.lifelog.asr.toFloatArrayLe  
63 -import com.lemon.lifelog.asr.toLeBytes  
64 import com.lemon.lifelog.asr.transcribeWithStats 78 import com.lemon.lifelog.asr.transcribeWithStats
65 import com.lemon.lifelog.audio.SegmentPlayer 79 import com.lemon.lifelog.audio.SegmentPlayer
66 -import com.lemon.lifelog.data.Summary  
67 -import com.lemon.lifelog.summary.SummaryScheduler  
68 import com.lemon.lifelog.data.Repository 80 import com.lemon.lifelog.data.Repository
69 -import com.lemon.lifelog.data.Segment  
70 import com.lemon.lifelog.data.SegmentRow 81 import com.lemon.lifelog.data.SegmentRow
71 import com.lemon.lifelog.data.SpeakerProfile 82 import com.lemon.lifelog.data.SpeakerProfile
  83 +import com.lemon.lifelog.data.Summary
72 import com.lemon.lifelog.service.RecorderService 84 import com.lemon.lifelog.service.RecorderService
73 import com.lemon.lifelog.service.RecorderState 85 import com.lemon.lifelog.service.RecorderState
74 -import kotlinx.coroutines.flow.Flow  
75 -import kotlinx.coroutines.flow.flowOf 86 +import com.lemon.lifelog.summary.SummaryScheduler
  87 +import com.lemon.lifelog.summary.SummaryWorker
76 import java.text.SimpleDateFormat 88 import java.text.SimpleDateFormat
  89 +import java.util.Calendar
77 import java.util.Date 90 import java.util.Date
78 import java.util.Locale 91 import java.util.Locale
79 import kotlinx.coroutines.Dispatchers 92 import kotlinx.coroutines.Dispatchers
@@ -95,60 +108,117 @@ class MainActivity : ComponentActivity() { @@ -95,60 +108,117 @@ class MainActivity : ComponentActivity() {
95 } 108 }
96 } 109 }
97 110
  111 +private enum class Tab(val label: String, val icon: String) {
  112 + Home("首页", "●"),
  113 + Timeline("时间轴", "≣"),
  114 + Speakers("声纹", "👤"),
  115 + Summaries("纪要", "✎"),
  116 +}
  117 +
  118 +enum class SortMode(val label: String) {
  119 + TimeDesc("时间↓"),
  120 + TimeAsc("时间↑"),
  121 + DurationDesc("时长↓"),
  122 +}
  123 +
  124 +enum class TimeFilter(val label: String) {
  125 + All("全部时间"),
  126 + Today("今天"),
  127 + Last1h("近1时"),
  128 + Last24h("近24时"),
  129 +}
  130 +
98 @Composable 131 @Composable
99 private fun MainScreen() { 132 private fun MainScreen() {
  133 + var tab by remember { mutableStateOf(Tab.Home) }
  134 + val activity = androidx.compose.ui.platform.LocalContext.current
  135 + val repo = remember(activity) { Repository(activity) }
  136 +
  137 + // Cross-tab state: speaker filter is settable from the Speakers tab.
  138 + var filterSpeakerId by remember { mutableStateOf<Long?>(null) }
  139 + // Cross-tab jump: a summary bullet sets an absolute timestamp to scroll the timeline to.
  140 + var jumpToTs by remember { mutableStateOf<Long?>(null) }
  141 +
  142 + val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList())
  143 +
  144 + Scaffold(
  145 + bottomBar = {
  146 + NavigationBar {
  147 + Tab.entries.forEach { t ->
  148 + NavigationBarItem(
  149 + selected = tab == t,
  150 + onClick = { tab = t },
  151 + icon = { Text(t.icon) },
  152 + label = { Text(t.label) },
  153 + )
  154 + }
  155 + }
  156 + },
  157 + ) { inner ->
  158 + Box(modifier = Modifier.padding(inner)) {
  159 + when (tab) {
  160 + Tab.Home -> HomeTab(activity)
  161 + Tab.Timeline -> TimelineTab(
  162 + repo = repo,
  163 + speakers = speakers,
  164 + filterSpeakerId = filterSpeakerId,
  165 + onFilterSpeakerId = { filterSpeakerId = it },
  166 + jumpToTs = jumpToTs,
  167 + onJumpConsumed = { jumpToTs = null },
  168 + )
  169 + Tab.Speakers -> SpeakersTab(
  170 + repo = repo,
  171 + speakers = speakers,
  172 + onFilterBySpeaker = { id ->
  173 + filterSpeakerId = id
  174 + tab = Tab.Timeline
  175 + },
  176 + )
  177 + Tab.Summaries -> SummariesTab(
  178 + repo = repo,
  179 + activity = activity,
  180 + onJumpToTime = { ts ->
  181 + filterSpeakerId = null
  182 + jumpToTs = ts
  183 + tab = Tab.Timeline
  184 + },
  185 + )
  186 + }
  187 + }
  188 + }
  189 +}
  190 +
  191 +// ============================================================ Home tab
  192 +
  193 +@Composable
  194 +private fun HomeTab(activity: Context) {
100 val running by RecorderState.isRunning.collectAsState() 195 val running by RecorderState.isRunning.collectAsState()
  196 + val paused by RecorderState.isPaused.collectAsState()
  197 + val status by RecorderState.status.collectAsState()
101 val segmentCount by RecorderState.segmentCount.collectAsState() 198 val segmentCount by RecorderState.segmentCount.collectAsState()
102 val lastAudioMs by RecorderState.lastAudioMs.collectAsState() 199 val lastAudioMs by RecorderState.lastAudioMs.collectAsState()
103 val lastWallMs by RecorderState.lastWallMs.collectAsState() 200 val lastWallMs by RecorderState.lastWallMs.collectAsState()
104 - val status by RecorderState.status.collectAsState() 201 + val scope = rememberCoroutineScope()
105 202
106 var asrStatus by remember { mutableStateOf("ASR: idle") } 203 var asrStatus by remember { mutableStateOf("ASR: idle") }
107 var asrLog by remember { mutableStateOf("") } 204 var asrLog by remember { mutableStateOf("") }
108 - val activity = androidx.compose.ui.platform.LocalContext.current  
109 - val scope = rememberCoroutineScope() 205 + var debugExpanded by remember { mutableStateOf(false) }
110 206
111 - // Timeline + search.  
112 - val repo = remember(activity) { Repository(activity) }  
113 - var searchQuery by remember { mutableStateOf("") }  
114 - val effectiveSearch = remember(searchQuery) { sanitizeFtsQuery(searchQuery) }  
115 - val timelineFlow = remember(effectiveSearch) {  
116 - if (effectiveSearch.isEmpty()) repo.segments.observeTimeline(200)  
117 - else repo.segments.searchTimeline(effectiveSearch, 200)  
118 - }  
119 - val timeline by timelineFlow.collectAsState(initial = emptyList())  
120 - val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList())  
121 - val summaries by repo.summaries.recent(20).collectAsState(initial = emptyList())  
122 -  
123 - // Sheet / dialog state.  
124 - var actionRow by remember { mutableStateOf<SegmentRow?>(null) }  
125 - var enrollFor by remember { mutableStateOf<SegmentRow?>(null) }  
126 - val player = remember { SegmentPlayer() }  
127 - var nowPlayingId by remember { mutableStateOf<Long?>(null) }  
128 - DisposableEffect(player) { onDispose { player.stop() } }  
129 -  
130 - // Battery optimisation exemption — recompute whenever activity resumes  
131 - // (user may have just returned from the system settings dialog).  
132 var batteryExempt by remember { mutableStateOf(isBatteryOptExempt(activity)) } 207 var batteryExempt by remember { mutableStateOf(isBatteryOptExempt(activity)) }
133 val lifecycleOwner = LocalLifecycleOwner.current 208 val lifecycleOwner = LocalLifecycleOwner.current
134 DisposableEffect(lifecycleOwner) { 209 DisposableEffect(lifecycleOwner) {
135 val observer = LifecycleEventObserver { _, event -> 210 val observer = LifecycleEventObserver { _, event ->
136 - if (event == Lifecycle.Event.ON_RESUME) {  
137 - batteryExempt = isBatteryOptExempt(activity)  
138 - } 211 + if (event == Lifecycle.Event.ON_RESUME) batteryExempt = isBatteryOptExempt(activity)
139 } 212 }
140 lifecycleOwner.lifecycle.addObserver(observer) 213 lifecycleOwner.lifecycle.addObserver(observer)
141 onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } 214 onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
142 } 215 }
143 216
144 val permissionLauncher = rememberLauncherForActivityResult( 217 val permissionLauncher = rememberLauncherForActivityResult(
145 - ActivityResultContracts.RequestMultiplePermissions() 218 + ActivityResultContracts.RequestMultiplePermissions(),
146 ) { results -> 219 ) { results ->
147 if (results.values.all { it }) { 220 if (results.values.all { it }) {
148 - ContextCompat.startForegroundService(  
149 - activity,  
150 - Intent(activity, RecorderService::class.java),  
151 - ) 221 + ContextCompat.startForegroundService(activity, Intent(activity, RecorderService::class.java))
152 } 222 }
153 } 223 }
154 224
@@ -158,11 +228,15 @@ private fun MainScreen() { @@ -158,11 +228,15 @@ private fun MainScreen() {
158 ) { 228 ) {
159 Text("Lifelog", style = MaterialTheme.typography.headlineMedium) 229 Text("Lifelog", style = MaterialTheme.typography.headlineMedium)
160 230
161 - Text(if (running) "● 录音中 $status" else "○ 已停止")  
162 - Button(onClick = {  
163 - if (running) {  
164 - activity.stopService(Intent(activity, RecorderService::class.java))  
165 - } else { 231 + val stateLabel = when {
  232 + !running -> "○ 已停止"
  233 + paused -> "⏸ 已暂停(模型常驻)"
  234 + else -> "● 录音中"
  235 + }
  236 + Text("$stateLabel $status")
  237 +
  238 + if (!running) {
  239 + Button(onClick = {
166 val perms = buildList { 240 val perms = buildList {
167 add(Manifest.permission.RECORD_AUDIO) 241 add(Manifest.permission.RECORD_AUDIO)
168 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 242 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -170,44 +244,156 @@ private fun MainScreen() { @@ -170,44 +244,156 @@ private fun MainScreen() {
170 } 244 }
171 }.toTypedArray() 245 }.toTypedArray()
172 permissionLauncher.launch(perms) 246 permissionLauncher.launch(perms)
  247 + }) { Text("开始录音") }
  248 + } else {
  249 + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  250 + Button(onClick = {
  251 + if (paused) RecorderService.resume(activity) else RecorderService.pause(activity)
  252 + }) { Text(if (paused) "继续" else "暂停") }
  253 + OutlinedButton(onClick = {
  254 + activity.stopService(Intent(activity, RecorderService::class.java))
  255 + }) { Text("停止") }
173 } 256 }
174 - }) { Text(if (running) "停止录音" else "开始录音") } 257 + }
175 258
176 - // Battery optimisation exemption row.  
177 - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,  
178 - horizontalArrangement = Arrangement.spacedBy(8.dp)) { 259 + // Runtime stats.
  260 + if (running) {
  261 + val rtSpeed = if (lastWallMs > 0) lastAudioMs.toDouble() / lastWallMs else 0.0
179 Text( 262 Text(
180 - if (batteryExempt) "✓ 已加入电池白名单"  
181 - else "⚠ 未加入电池白名单(Doze 会关掉录音)", 263 + "段数:$segmentCount" +
  264 + if (lastAudioMs > 0) " • 最新段 ${lastAudioMs}ms / ${lastWallMs}ms (${"%.1f".format(rtSpeed)}× realtime)" else "",
182 style = MaterialTheme.typography.bodySmall, 265 style = MaterialTheme.typography.bodySmall,
183 ) 266 )
184 - if (!batteryExempt) {  
185 - Button(onClick = { requestBatteryOptExemption(activity) }) {  
186 - Text("申请豁免")  
187 - }  
188 - }  
189 } 267 }
190 268
191 - if (running && lastAudioMs > 0) {  
192 - val rtSpeed = if (lastWallMs > 0) lastAudioMs.toDouble() / lastWallMs else 0.0 269 + // Battery whitelist.
  270 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
193 Text( 271 Text(
194 - "段数:$segmentCount • 最新段 audio=${lastAudioMs}ms wall=${lastWallMs}ms (${"%.1f".format(rtSpeed)}× realtime)", 272 + if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会关掉录音)",
195 style = MaterialTheme.typography.bodySmall, 273 style = MaterialTheme.typography.bodySmall,
196 ) 274 )
  275 + if (!batteryExempt) {
  276 + Button(onClick = { requestBatteryOptExemption(activity) }) { Text("申请豁免") }
  277 + }
197 } 278 }
198 279
199 HorizontalDivider() 280 HorizontalDivider()
200 281
201 - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,  
202 - horizontalArrangement = Arrangement.spacedBy(8.dp)) {  
203 - Text(  
204 - if (searchQuery.isEmpty()) "时间轴(${timeline.size})"  
205 - else "搜索结果(${timeline.size})",  
206 - style = MaterialTheme.typography.titleMedium,  
207 - )  
208 - Text("· 声纹库 ${speakers.size}",  
209 - style = MaterialTheme.typography.bodySmall) 282 + // Collapsible debug block.
  283 + Row(
  284 + modifier = Modifier.fillMaxWidth().clickable { debugExpanded = !debugExpanded }.padding(vertical = 4.dp),
  285 + verticalAlignment = Alignment.CenterVertically,
  286 + horizontalArrangement = Arrangement.spacedBy(6.dp),
  287 + ) {
  288 + Text(if (debugExpanded) "▾" else "▸")
  289 + Text("调试 · ASR Bench", style = MaterialTheme.typography.titleSmall)
  290 + }
  291 + if (debugExpanded) {
  292 + Text("95s TTS sample", style = MaterialTheme.typography.labelSmall)
  293 + Text(asrStatus, style = MaterialTheme.typography.bodyMedium)
  294 + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  295 + OutlinedButton(onClick = {
  296 + asrStatus = "paraformer: starting…"; asrLog = ""
  297 + scope.launch { runParaformerTest(activity) { s, log -> asrStatus = s; asrLog = log } }
  298 + }) { Text("Test paraformer") }
  299 + OutlinedButton(onClick = {
  300 + asrStatus = "讯飞: connecting…"; asrLog = ""
  301 + scope.launch { runXfyunTest(activity) { s, log -> asrStatus = s; asrLog = log } }
  302 + }) { Text("Test 讯飞") }
  303 + }
  304 + if (asrLog.isNotEmpty()) {
  305 + Text(asrLog, style = MaterialTheme.typography.bodySmall)
  306 + }
  307 + }
  308 + }
  309 +}
  310 +
  311 +// ============================================================ Timeline tab
  312 +
  313 +@OptIn(ExperimentalMaterial3Api::class)
  314 +@Composable
  315 +private fun TimelineTab(
  316 + repo: Repository,
  317 + speakers: List<SpeakerProfile>,
  318 + filterSpeakerId: Long?,
  319 + onFilterSpeakerId: (Long?) -> Unit,
  320 + jumpToTs: Long? = null,
  321 + onJumpConsumed: () -> Unit = {},
  322 +) {
  323 + val scope = rememberCoroutineScope()
  324 + var searchQuery by remember { mutableStateOf("") }
  325 + var sortMode by remember { mutableStateOf(SortMode.TimeDesc) }
  326 + var timeFilter by remember { mutableStateOf(TimeFilter.All) }
  327 + var unmatchedOnly by remember { mutableStateOf(false) }
  328 + val lazyState = rememberLazyListState()
  329 + var highlightId by remember { mutableStateOf<Long?>(null) }
  330 +
  331 + val effectiveSearch = remember(searchQuery) { sanitizeFtsQuery(searchQuery) }
  332 + val timelineFlow = remember(effectiveSearch) {
  333 + if (effectiveSearch.isEmpty()) repo.segments.observeTimeline(1000)
  334 + else repo.segments.searchTimeline(effectiveSearch, 1000)
  335 + }
  336 + val base by timelineFlow.collectAsState(initial = emptyList())
  337 +
  338 + val shown = remember(base, filterSpeakerId, timeFilter, unmatchedOnly, sortMode) {
  339 + var l = base
  340 + if (filterSpeakerId != null) l = l.filter { it.speakerId == filterSpeakerId }
  341 + timeCutoff(timeFilter)?.let { cutoff -> l = l.filter { it.startTs >= cutoff } }
  342 + if (unmatchedOnly) l = l.filter { it.speakerId == null || (it.speakerSim != null && it.speakerSim < 0.55f) }
  343 + when (sortMode) {
  344 + SortMode.TimeDesc -> l.sortedByDescending { it.startTs }
  345 + SortMode.TimeAsc -> l.sortedBy { it.startTs }
  346 + SortMode.DurationDesc -> l.sortedByDescending { it.durationMs }
  347 + }
  348 + }
  349 +
  350 + // Jump from a summary bullet: reset filters, scroll to the closest segment, highlight it.
  351 + LaunchedEffect(jumpToTs, base) {
  352 + val target = jumpToTs ?: return@LaunchedEffect
  353 + if (base.isEmpty()) return@LaunchedEffect
  354 + searchQuery = ""
  355 + sortMode = SortMode.TimeDesc
  356 + timeFilter = TimeFilter.All
  357 + unmatchedOnly = false
  358 + val sorted = base.sortedByDescending { it.startTs }
  359 + val idx = sorted.indices.minByOrNull { kotlin.math.abs(sorted[it].startTs - target) }
  360 + if (idx != null) {
  361 + highlightId = sorted[idx].id
  362 + lazyState.scrollToItem(idx)
210 } 363 }
  364 + onJumpConsumed()
  365 + }
  366 + LaunchedEffect(highlightId) {
  367 + if (highlightId != null) {
  368 + delay(2500)
  369 + highlightId = null
  370 + }
  371 + }
  372 +
  373 + val player = remember { SegmentPlayer() }
  374 + var nowPlayingId by remember { mutableStateOf<Long?>(null) }
  375 + DisposableEffect(player) { onDispose { player.stop() } }
  376 +
  377 + // Batch selection state.
  378 + val ctx = LocalContext.current
  379 + var selecting by remember { mutableStateOf(false) }
  380 + val selected = remember { mutableStateListOf<Long>() }
  381 + var batchDeleteConfirm by remember { mutableStateOf(false) }
  382 +
  383 + // Dialog / sheet state.
  384 + var actionRow by remember { mutableStateOf<SegmentRow?>(null) }
  385 + var enrollFor by remember { mutableStateOf<SegmentRow?>(null) }
  386 + var renameFor by remember { mutableStateOf<SegmentRow?>(null) }
  387 + var reassignFor by remember { mutableStateOf<SegmentRow?>(null) }
  388 + var editFor by remember { mutableStateOf<SegmentRow?>(null) }
  389 + var deleteFor by remember { mutableStateOf<SegmentRow?>(null) }
  390 +
  391 + val speakerName = remember(speakers, filterSpeakerId) {
  392 + speakers.firstOrNull { it.id == filterSpeakerId }?.name
  393 + }
  394 +
  395 + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
  396 + Spacer(Modifier.height(12.dp))
211 OutlinedTextField( 397 OutlinedTextField(
212 value = searchQuery, 398 value = searchQuery,
213 onValueChange = { searchQuery = it }, 399 onValueChange = { searchQuery = it },
@@ -215,76 +401,118 @@ private fun MainScreen() { @@ -215,76 +401,118 @@ private fun MainScreen() {
215 placeholder = { Text("中文搜索…") }, 401 placeholder = { Text("中文搜索…") },
216 singleLine = true, 402 singleLine = true,
217 ) 403 )
218 - LazyColumn(  
219 - modifier = Modifier  
220 - .fillMaxWidth()  
221 - .heightIn(min = 200.dp, max = 600.dp),  
222 - verticalArrangement = Arrangement.spacedBy(4.dp), 404 +
  405 + // Filter / sort controls.
  406 + Row(
  407 + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 6.dp),
  408 + horizontalArrangement = Arrangement.spacedBy(6.dp),
  409 + verticalAlignment = Alignment.CenterVertically,
223 ) { 410 ) {
224 - items(timeline, key = { it.id }) { row ->  
225 - TimelineRow(  
226 - row = row,  
227 - isPlaying = nowPlayingId == row.id,  
228 - onClick = { actionRow = row },  
229 - )  
230 - } 411 + // Sort cycles.
  412 + FilterChip(
  413 + selected = sortMode != SortMode.TimeDesc,
  414 + onClick = {
  415 + sortMode = SortMode.entries[(sortMode.ordinal + 1) % SortMode.entries.size]
  416 + },
  417 + label = { Text(sortMode.label) },
  418 + )
  419 + // Speaker filter dropdown.
  420 + SpeakerFilterChip(
  421 + speakers = speakers,
  422 + selectedId = filterSpeakerId,
  423 + selectedName = speakerName,
  424 + onSelect = onFilterSpeakerId,
  425 + )
  426 + // Time filter dropdown.
  427 + TimeFilterChip(selected = timeFilter, onSelect = { timeFilter = it })
  428 + // Unmatched toggle.
  429 + FilterChip(
  430 + selected = unmatchedOnly,
  431 + onClick = { unmatchedOnly = !unmatchedOnly },
  432 + label = { Text("仅未匹配/低置信") },
  433 + )
231 } 434 }
232 435
233 - HorizontalDivider()  
234 -  
235 - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,  
236 - horizontalArrangement = Arrangement.spacedBy(8.dp)) {  
237 - Text("纪要(${summaries.size})", style = MaterialTheme.typography.titleMedium)  
238 - Button(onClick = { SummaryScheduler.runNow(activity) }) {  
239 - Text("现在跑一次") 436 + if (!selecting) {
  437 + Row(
  438 + modifier = Modifier.fillMaxWidth(),
  439 + horizontalArrangement = Arrangement.SpaceBetween,
  440 + verticalAlignment = Alignment.CenterVertically,
  441 + ) {
  442 + Text(
  443 + (if (searchQuery.isEmpty()) "时间轴" else "搜索结果") + "(${shown.size} / ${base.size})",
  444 + style = MaterialTheme.typography.labelMedium,
  445 + )
  446 + TextButton(onClick = { selecting = true }) { Text("选择") }
240 } 447 }
241 - }  
242 - if (summaries.isEmpty()) {  
243 - Text("还没有纪要。点上面按钮触发;或等 hourly worker(要求 hermes 在 127.0.0.1:8642 跑着)。",  
244 - style = MaterialTheme.typography.bodySmall)  
245 } else { 448 } else {
246 - Column(  
247 - modifier = Modifier.fillMaxWidth(),  
248 - verticalArrangement = Arrangement.spacedBy(4.dp), 449 + Row(
  450 + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
  451 + horizontalArrangement = Arrangement.spacedBy(2.dp),
  452 + verticalAlignment = Alignment.CenterVertically,
249 ) { 453 ) {
250 - summaries.take(5).forEach { s -> SummaryCard(s) } 454 + Text("已选 ${selected.size}", style = MaterialTheme.typography.labelMedium)
  455 + TextButton(onClick = { selected.clear(); selected.addAll(shown.map { it.id }) }) { Text("全选") }
  456 + TextButton(
  457 + enabled = selected.isNotEmpty(),
  458 + onClick = { batchDeleteConfirm = true },
  459 + ) { Text("删除(${selected.size})") }
  460 + TextButton(
  461 + enabled = selected.isNotEmpty(),
  462 + onClick = { exportSegments(ctx, shown.filter { selected.contains(it.id) }) },
  463 + ) { Text("导出(${selected.size})") }
  464 + TextButton(onClick = { selecting = false; selected.clear() }) { Text("取消") }
251 } 465 }
252 } 466 }
253 467
254 - HorizontalDivider()  
255 -  
256 - Text("ASR Bench (95s TTS sample)", style = MaterialTheme.typography.titleMedium)  
257 - Text(asrStatus, style = MaterialTheme.typography.bodyMedium)  
258 -  
259 - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {  
260 - Button(onClick = {  
261 - asrStatus = "paraformer: starting…"  
262 - asrLog = ""  
263 - scope.launch {  
264 - runParaformerTest(activity) { s, log ->  
265 - asrStatus = s  
266 - asrLog = log  
267 - }  
268 - }  
269 - }) { Text("Test paraformer") } 468 + LazyColumn(
  469 + state = lazyState,
  470 + modifier = Modifier.fillMaxWidth().weight(1f),
  471 + verticalArrangement = Arrangement.spacedBy(2.dp),
  472 + ) {
  473 + items(shown, key = { it.id }) { row ->
  474 + TimelineRow(
  475 + row = row,
  476 + isPlaying = nowPlayingId == row.id,
  477 + selecting = selecting,
  478 + selected = selected.contains(row.id),
  479 + highlighted = highlightId == row.id,
  480 + onClick = {
  481 + if (selecting) {
  482 + if (selected.contains(row.id)) selected.remove(row.id) else selected.add(row.id)
  483 + } else {
  484 + actionRow = row
  485 + }
  486 + },
  487 + )
  488 + }
  489 + }
  490 + }
270 491
271 - Button(onClick = {  
272 - asrStatus = "讯飞: connecting…"  
273 - asrLog = "" 492 + if (batchDeleteConfirm) {
  493 + val ids = selected.toList()
  494 + ConfirmDialog(
  495 + title = "删除选中的 ${ids.size} 段?",
  496 + message = "音频文件不会自动删除。",
  497 + confirmLabel = "删除",
  498 + onDismiss = { batchDeleteConfirm = false },
  499 + onConfirm = {
  500 + batchDeleteConfirm = false
  501 + selecting = false
  502 + selected.clear()
274 scope.launch { 503 scope.launch {
275 - runXfyunTest(activity) { s, log ->  
276 - asrStatus = s  
277 - asrLog = log 504 + withContext(Dispatchers.IO) {
  505 + ids.forEach { id ->
  506 + repo.transcripts.deleteBySegment(id)
  507 + repo.segments.deleteSegment(id)
  508 + }
278 } 509 }
279 } 510 }
280 - }) { Text("Test 讯飞") }  
281 - }  
282 -  
283 - Spacer(Modifier.height(8.dp))  
284 - Text(asrLog, style = MaterialTheme.typography.bodySmall) 511 + },
  512 + )
285 } 513 }
286 514
287 - // Bottom sheet with row actions. 515 + // ----- bottom sheet -----
288 actionRow?.let { row -> 516 actionRow?.let { row ->
289 SegmentActionSheet( 517 SegmentActionSheet(
290 row = row, 518 row = row,
@@ -292,35 +520,94 @@ private fun MainScreen() { @@ -292,35 +520,94 @@ private fun MainScreen() {
292 onDismiss = { actionRow = null }, 520 onDismiss = { actionRow = null },
293 onPlay = { 521 onPlay = {
294 if (nowPlayingId == row.id) { 522 if (nowPlayingId == row.id) {
295 - player.stop()  
296 - nowPlayingId = null 523 + player.stop(); nowPlayingId = null
297 } else { 524 } else {
298 - nowPlayingId = row.id  
299 - player.play(row.audioPath) { nowPlayingId = null } 525 + nowPlayingId = row.id; player.play(row.audioPath) { nowPlayingId = null }
300 } 526 }
301 actionRow = null 527 actionRow = null
302 }, 528 },
303 - onEnroll = {  
304 - enrollFor = row  
305 - actionRow = null  
306 - }, 529 + onRename = { renameFor = row; actionRow = null },
  530 + onReassign = { reassignFor = row; actionRow = null },
  531 + onEditText = { editFor = row; actionRow = null },
  532 + onEnroll = { enrollFor = row; actionRow = null },
  533 + onDelete = { deleteFor = row; actionRow = null },
307 ) 534 )
308 } 535 }
309 536
310 - // Speaker enrollment dialog. 537 + // ----- dialogs -----
  538 + renameFor?.let { row ->
  539 + val sid = row.speakerId
  540 + RenameSpeakerDialog(
  541 + currentName = row.speakerName ?: "",
  542 + onDismiss = { renameFor = null },
  543 + onConfirm = { name ->
  544 + renameFor = null
  545 + if (sid != null && name.isNotBlank()) {
  546 + scope.launch { withContext(Dispatchers.IO) { repo.speakers.updateName(sid, name.trim()) } }
  547 + }
  548 + },
  549 + )
  550 + }
  551 + reassignFor?.let { row ->
  552 + ReassignSpeakerDialog(
  553 + row = row,
  554 + speakers = speakers,
  555 + onDismiss = { reassignFor = null },
  556 + onPick = { speakerId ->
  557 + reassignFor = null
  558 + scope.launch { withContext(Dispatchers.IO) { repo.segments.updateSpeaker(row.id, speakerId, null) } }
  559 + },
  560 + )
  561 + }
  562 + editFor?.let { row ->
  563 + EditTranscriptDialog(
  564 + initial = row.text ?: "",
  565 + onDismiss = { editFor = null },
  566 + onConfirm = { text ->
  567 + editFor = null
  568 + scope.launch {
  569 + withContext(Dispatchers.IO) {
  570 + val now = System.currentTimeMillis()
  571 + if (repo.transcripts.get(row.id) != null) {
  572 + repo.transcripts.updateText(row.id, text.trim(), now)
  573 + } else if (text.isNotBlank()) {
  574 + repo.transcripts.insert(
  575 + com.lemon.lifelog.data.Transcript(segmentId = row.id, text = text.trim(), reviewedAt = now),
  576 + )
  577 + }
  578 + }
  579 + }
  580 + },
  581 + )
  582 + }
  583 + deleteFor?.let { row ->
  584 + ConfirmDialog(
  585 + title = "删除该段?",
  586 + message = "段 #${row.id}:${row.text?.take(30) ?: "(无转写)"}\n音频文件不会自动删除。",
  587 + confirmLabel = "删除",
  588 + onDismiss = { deleteFor = null },
  589 + onConfirm = {
  590 + deleteFor = null
  591 + scope.launch {
  592 + withContext(Dispatchers.IO) {
  593 + repo.transcripts.deleteBySegment(row.id)
  594 + repo.segments.deleteSegment(row.id)
  595 + }
  596 + }
  597 + },
  598 + )
  599 + }
311 enrollFor?.let { row -> 600 enrollFor?.let { row ->
312 EnrollSpeakerDialog( 601 EnrollSpeakerDialog(
313 row = row, 602 row = row,
314 existingNames = speakers.map { it.name }, 603 existingNames = speakers.map { it.name },
315 onDismiss = { enrollFor = null }, 604 onDismiss = { enrollFor = null },
316 onConfirm = { name -> 605 onConfirm = { name ->
317 - val target = enrollFor  
318 enrollFor = null 606 enrollFor = null
319 - if (target != null && name.isNotBlank()) { 607 + if (name.isNotBlank()) {
320 scope.launch { 608 scope.launch {
321 - enrollSpeakerForSegment(repo, target.id, name)  
322 - // Backfill all older segments with embeddings against the new profile.  
323 - SpeakerMatcher.backfillAll(repo) 609 + enrollSpeakerForSegment(repo, row.id, name)
  610 + com.lemon.lifelog.asr.SpeakerMatcher.backfillAll(repo)
324 } 611 }
325 } 612 }
326 }, 613 },
@@ -330,53 +617,465 @@ private fun MainScreen() { @@ -330,53 +617,465 @@ private fun MainScreen() {
330 617
331 @OptIn(ExperimentalMaterial3Api::class) 618 @OptIn(ExperimentalMaterial3Api::class)
332 @Composable 619 @Composable
  620 +private fun SpeakerFilterChip(
  621 + speakers: List<SpeakerProfile>,
  622 + selectedId: Long?,
  623 + selectedName: String?,
  624 + onSelect: (Long?) -> Unit,
  625 +) {
  626 + var expanded by remember { mutableStateOf(false) }
  627 + Box {
  628 + FilterChip(
  629 + selected = selectedId != null,
  630 + onClick = { expanded = true },
  631 + label = { Text(if (selectedId != null) "👤 ${selectedName ?: "?"}" else "👤 全部") },
  632 + )
  633 + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
  634 + DropdownMenuItem(text = { Text("全部说话人") }, onClick = { onSelect(null); expanded = false })
  635 + speakers.forEach { sp ->
  636 + DropdownMenuItem(text = { Text(sp.name) }, onClick = { onSelect(sp.id); expanded = false })
  637 + }
  638 + }
  639 + }
  640 +}
  641 +
  642 +@OptIn(ExperimentalMaterial3Api::class)
  643 +@Composable
  644 +private fun TimeFilterChip(selected: TimeFilter, onSelect: (TimeFilter) -> Unit) {
  645 + var expanded by remember { mutableStateOf(false) }
  646 + Box {
  647 + FilterChip(
  648 + selected = selected != TimeFilter.All,
  649 + onClick = { expanded = true },
  650 + label = { Text("🕐 ${selected.label}") },
  651 + )
  652 + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
  653 + TimeFilter.entries.forEach { tf ->
  654 + DropdownMenuItem(text = { Text(tf.label) }, onClick = { onSelect(tf); expanded = false })
  655 + }
  656 + }
  657 + }
  658 +}
  659 +
  660 +/** null = no cutoff (all). */
  661 +private fun timeCutoff(f: TimeFilter): Long? = when (f) {
  662 + TimeFilter.All -> null
  663 + TimeFilter.Last1h -> System.currentTimeMillis() - 3_600_000L
  664 + TimeFilter.Last24h -> System.currentTimeMillis() - 86_400_000L
  665 + TimeFilter.Today -> Calendar.getInstance().apply {
  666 + set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
  667 + }.timeInMillis
  668 +}
  669 +
  670 +// ============================================================ Speakers tab
  671 +
  672 +@Composable
  673 +private fun SpeakersTab(
  674 + repo: Repository,
  675 + speakers: List<SpeakerProfile>,
  676 + onFilterBySpeaker: (Long) -> Unit,
  677 +) {
  678 + val scope = rememberCoroutineScope()
  679 + val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList())
  680 + val countMap = remember(counts) { counts.associate { it.speakerId to it.count } }
  681 + var reclustering by remember { mutableStateOf(false) }
  682 + var reclusterMsg by remember { mutableStateOf("") }
  683 +
  684 + var renameFor by remember { mutableStateOf<SpeakerProfile?>(null) }
  685 + var mergeFor by remember { mutableStateOf<SpeakerProfile?>(null) }
  686 + var deleteFor by remember { mutableStateOf<SpeakerProfile?>(null) }
  687 +
  688 + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
  689 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  690 + Text("声纹库(${speakers.size})", style = MaterialTheme.typography.titleMedium)
  691 + Button(
  692 + enabled = !reclustering,
  693 + onClick = {
  694 + reclustering = true
  695 + scope.launch {
  696 + val n = withContext(Dispatchers.IO) { SpeakerClusterer.reclusterAll(repo) }
  697 + reclustering = false
  698 + reclusterMsg = "重新聚类完成:$n 个说话人"
  699 + }
  700 + },
  701 + ) { Text(if (reclustering) "聚类中…" else "重新聚类") }
  702 + }
  703 + if (reclusterMsg.isNotEmpty()) {
  704 + Text(reclusterMsg, style = MaterialTheme.typography.bodySmall)
  705 + }
  706 + Text(
  707 + "自动发现的说话人会命名为「说话人N」。点击进入按 TA 筛选时间轴;可重命名 / 合并 / 删除。",
  708 + style = MaterialTheme.typography.bodySmall,
  709 + )
  710 + Spacer(Modifier.height(8.dp))
  711 +
  712 + if (speakers.isEmpty()) {
  713 + Text("还没有说话人。录一段话或点「重新聚类」。", style = MaterialTheme.typography.bodyMedium)
  714 + }
  715 +
  716 + LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
  717 + items(speakers, key = { it.id }) { sp ->
  718 + Card(modifier = Modifier.fillMaxWidth().clickable { onFilterBySpeaker(sp.id) }) {
  719 + Column(modifier = Modifier.padding(12.dp)) {
  720 + Text(sp.name, style = MaterialTheme.typography.titleSmall)
  721 + Text(
  722 + "归属 ${countMap[sp.id] ?: 0} 段 · 质心样本 ${sp.sampleCount}",
  723 + style = MaterialTheme.typography.labelSmall,
  724 + )
  725 + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  726 + TextButton(onClick = { renameFor = sp }) { Text("重命名") }
  727 + TextButton(onClick = { mergeFor = sp }) { Text("合并到…") }
  728 + TextButton(onClick = { deleteFor = sp }) { Text("删除") }
  729 + }
  730 + }
  731 + }
  732 + }
  733 + }
  734 + }
  735 +
  736 + renameFor?.let { sp ->
  737 + RenameSpeakerDialog(
  738 + currentName = sp.name,
  739 + onDismiss = { renameFor = null },
  740 + onConfirm = { name ->
  741 + renameFor = null
  742 + if (name.isNotBlank()) {
  743 + scope.launch { withContext(Dispatchers.IO) { repo.speakers.updateName(sp.id, name.trim()) } }
  744 + }
  745 + },
  746 + )
  747 + }
  748 + mergeFor?.let { sp ->
  749 + MergeSpeakerDialog(
  750 + from = sp,
  751 + others = speakers.filter { it.id != sp.id },
  752 + onDismiss = { mergeFor = null },
  753 + onPick = { into ->
  754 + mergeFor = null
  755 + scope.launch {
  756 + withContext(Dispatchers.IO) {
  757 + repo.segments.reassignSpeaker(sp.id, into)
  758 + repo.speakers.delete(sp.id)
  759 + }
  760 + }
  761 + },
  762 + )
  763 + }
  764 + deleteFor?.let { sp ->
  765 + ConfirmDialog(
  766 + title = "删除说话人「${sp.name}」?",
  767 + message = "TA 名下 ${countMap[sp.id] ?: 0} 段会变回未归属(不会删除段本身)。",
  768 + confirmLabel = "删除",
  769 + onDismiss = { deleteFor = null },
  770 + onConfirm = {
  771 + deleteFor = null
  772 + scope.launch {
  773 + withContext(Dispatchers.IO) {
  774 + repo.segments.clearSpeakerAssignments(sp.id)
  775 + repo.speakers.delete(sp.id)
  776 + }
  777 + }
  778 + },
  779 + )
  780 + }
  781 +}
  782 +
  783 +// ============================================================ Summaries tab
  784 +
  785 +@OptIn(ExperimentalMaterial3Api::class)
  786 +@Composable
  787 +private fun SummariesTab(repo: Repository, activity: Context, onJumpToTime: (Long) -> Unit) {
  788 + val scope = rememberCoroutineScope()
  789 + val summaries by repo.summaries.recent(100).collectAsState(initial = emptyList())
  790 + var deleteFor by remember { mutableStateOf<Summary?>(null) }
  791 + var expandedId by remember { mutableStateOf<Long?>(null) }
  792 +
  793 + val prefs = remember { activity.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) }
  794 + var gapMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN)) }
  795 + var gapMenu by remember { mutableStateOf(false) }
  796 +
  797 + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
  798 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  799 + Text("纪要(${summaries.size})", style = MaterialTheme.typography.titleMedium)
  800 + Button(onClick = { SummaryScheduler.runNow(activity) }) { Text("现在跑一次") }
  801 + }
  802 +
  803 + // 会话切分间隔设置。
  804 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  805 + Text("会话间隔:超过", style = MaterialTheme.typography.bodySmall)
  806 + Box {
  807 + FilterChip(selected = true, onClick = { gapMenu = true }, label = { Text("$gapMin 分钟") })
  808 + DropdownMenu(expanded = gapMenu, onDismissRequest = { gapMenu = false }) {
  809 + listOf(2, 5, 10, 15, 20, 30).forEach { m ->
  810 + DropdownMenuItem(text = { Text("$m 分钟") }, onClick = {
  811 + gapMin = m
  812 + prefs.edit().putInt(SummaryWorker.KEY_GAP_MIN, m).apply()
  813 + gapMenu = false
  814 + })
  815 + }
  816 + }
  817 + }
  818 + Text("没声音就算另一场", style = MaterialTheme.typography.bodySmall)
  819 + }
  820 + Text(
  821 + "按时间自动切会话,每段连续录音单独成一篇纪要。需要 hermes 在 127.0.0.1:8642 跑着。",
  822 + style = MaterialTheme.typography.bodySmall,
  823 + )
  824 + Spacer(Modifier.height(8.dp))
  825 +
  826 + if (summaries.isEmpty()) {
  827 + Text("还没有纪要。点「现在跑一次」。", style = MaterialTheme.typography.bodyMedium)
  828 + }
  829 + LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
  830 + items(summaries, key = { it.id }) { s ->
  831 + val expanded = expandedId == s.id
  832 + // 折叠头展示用:会话内段数 + 说话人名单(按需异步查询)。
  833 + val info by produceState<Pair<Int, List<String>>?>(initialValue = null, s.id) {
  834 + value = withContext(Dispatchers.IO) {
  835 + repo.segments.countInRange(s.windowStartTs, s.windowEndTs) to
  836 + repo.segments.speakersInRange(s.windowStartTs, s.windowEndTs)
  837 + }
  838 + }
  839 + Card(
  840 + modifier = Modifier.fillMaxWidth().clickable {
  841 + expandedId = if (expanded) null else s.id
  842 + },
  843 + ) {
  844 + Column(modifier = Modifier.padding(12.dp)) {
  845 + Row(
  846 + modifier = Modifier.fillMaxWidth(),
  847 + horizontalArrangement = Arrangement.SpaceBetween,
  848 + verticalAlignment = Alignment.CenterVertically,
  849 + ) {
  850 + Column(modifier = Modifier.weight(1f)) {
  851 + Text(
  852 + (if (expanded) "▾ " else "▸ ") + summaryRangeLabel(s.windowStartTs, s.windowEndTs),
  853 + style = MaterialTheme.typography.titleSmall,
  854 + )
  855 + val dur = (s.windowEndTs - s.windowStartTs) / 60_000
  856 + val cnt = info?.first
  857 + val spk = info?.second?.takeIf { it.isNotEmpty() }?.joinToString("、")
  858 + Text(
  859 + buildString {
  860 + append("$dur 分钟")
  861 + if (cnt != null) append(" · $cnt 段")
  862 + append(" · ").append(spk ?: "未识别说话人")
  863 + },
  864 + style = MaterialTheme.typography.labelSmall,
  865 + )
  866 + }
  867 + TextButton(onClick = { deleteFor = s }) { Text("删除") }
  868 + }
  869 + if (expanded) {
  870 + Spacer(Modifier.height(6.dp))
  871 + SummaryMarkdown(s.markdown, s.windowStartTs, s.windowEndTs, onJumpToTime)
  872 + } else {
  873 + Text(
  874 + s.markdown.lineSequence().firstOrNull { it.isNotBlank() }?.take(40) ?: "",
  875 + style = MaterialTheme.typography.bodySmall,
  876 + maxLines = 1,
  877 + overflow = TextOverflow.Ellipsis,
  878 + )
  879 + }
  880 + }
  881 + }
  882 + }
  883 + }
  884 + }
  885 +
  886 + deleteFor?.let { s ->
  887 + ConfirmDialog(
  888 + title = "删除纪要?",
  889 + message = summaryRangeLabel(s.windowStartTs, s.windowEndTs),
  890 + confirmLabel = "删除",
  891 + onDismiss = { deleteFor = null },
  892 + onConfirm = {
  893 + deleteFor = null
  894 + scope.launch { withContext(Dispatchers.IO) { repo.summaries.delete(s.id) } }
  895 + },
  896 + )
  897 + }
  898 +}
  899 +
  900 +/** "从 06-04 14:30 到 15:10"(同日省略日期)。 */
  901 +private fun summaryRangeLabel(start: Long, end: Long): String {
  902 + val sameDay = SUMMARY_DAY_FMT.format(Date(start)) == SUMMARY_DAY_FMT.format(Date(end))
  903 + val s = SUMMARY_FMT.format(Date(start))
  904 + val e = if (sameDay) SUMMARY_TIME_FMT.format(Date(end)) else SUMMARY_FMT.format(Date(end))
  905 + return "从 $s 到 $e"
  906 +}
  907 +
  908 +private val TS_REGEX = Regex("""\[(\d{1,2}):(\d{2}):(\d{2})]""")
  909 +
  910 +/** 按行渲染纪要 markdown;含 [HH:mm:ss] 的行做成可点击,点了跳回时间轴对应段。 */
  911 +@Composable
  912 +private fun SummaryMarkdown(markdown: String, windowStart: Long, windowEnd: Long, onJumpToTime: (Long) -> Unit) {
  913 + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
  914 + markdown.lineSequence().forEach { line ->
  915 + val m = TS_REGEX.find(line)
  916 + if (m != null) {
  917 + val (h, mi, se) = m.destructured
  918 + val ts = resolveTimestamp(h.toInt(), mi.toInt(), se.toInt(), windowStart, windowEnd)
  919 + Text(
  920 + "$line ↗",
  921 + style = MaterialTheme.typography.bodySmall,
  922 + color = MaterialTheme.colorScheme.primary,
  923 + modifier = Modifier.fillMaxWidth().clickable { onJumpToTime(ts) }.padding(vertical = 2.dp),
  924 + )
  925 + } else {
  926 + Text(line, style = MaterialTheme.typography.bodySmall)
  927 + }
  928 + }
  929 + }
  930 +}
  931 +
  932 +/** [HH:mm:ss] + 会话起始日期 → 绝对毫秒(处理跨午夜)。 */
  933 +private fun resolveTimestamp(h: Int, m: Int, s: Int, windowStart: Long, windowEnd: Long): Long {
  934 + val cal = Calendar.getInstance().apply {
  935 + timeInMillis = windowStart
  936 + set(Calendar.HOUR_OF_DAY, h)
  937 + set(Calendar.MINUTE, m)
  938 + set(Calendar.SECOND, s)
  939 + set(Calendar.MILLISECOND, 0)
  940 + }
  941 + var t = cal.timeInMillis
  942 + if (t < windowStart - 60_000) t += 86_400_000 // 会话跨过午夜
  943 + return t
  944 +}
  945 +
  946 +// ============================================================ shared rows / dialogs
  947 +
  948 +@OptIn(ExperimentalMaterial3Api::class)
  949 +@Composable
333 private fun SegmentActionSheet( 950 private fun SegmentActionSheet(
334 row: SegmentRow, 951 row: SegmentRow,
335 isPlaying: Boolean, 952 isPlaying: Boolean,
336 onDismiss: () -> Unit, 953 onDismiss: () -> Unit,
337 onPlay: () -> Unit, 954 onPlay: () -> Unit,
  955 + onRename: () -> Unit,
  956 + onReassign: () -> Unit,
  957 + onEditText: () -> Unit,
338 onEnroll: () -> Unit, 958 onEnroll: () -> Unit,
  959 + onDelete: () -> Unit,
339 ) { 960 ) {
340 val sheetState = rememberModalBottomSheetState() 961 val sheetState = rememberModalBottomSheetState()
341 - ModalBottomSheet(  
342 - onDismissRequest = onDismiss,  
343 - sheetState = sheetState,  
344 - ) { 962 + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
345 Column( 963 Column(
346 - modifier = Modifier.fillMaxWidth().padding(16.dp), 964 + modifier = Modifier.fillMaxWidth().padding(16.dp).verticalScroll(rememberScrollState()),
347 verticalArrangement = Arrangement.spacedBy(8.dp), 965 verticalArrangement = Arrangement.spacedBy(8.dp),
348 ) { 966 ) {
349 - Text(  
350 - "段 #${row.id} · ${"%.1fs".format(row.durationMs / 1000.0)}",  
351 - style = MaterialTheme.typography.titleMedium,  
352 - )  
353 - row.text?.let {  
354 - Text(it, style = MaterialTheme.typography.bodyMedium)  
355 - } 967 + Text("段 #${row.id} · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.titleMedium)
  968 + row.text?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
356 row.speakerName?.let { name -> 969 row.speakerName?.let { name ->
357 - Text(  
358 - "已识别:$name (sim=${"%.2f".format(row.speakerSim ?: 0f)})",  
359 - style = MaterialTheme.typography.labelMedium,  
360 - )  
361 - }  
362 - Spacer(Modifier.height(8.dp))  
363 - Button(  
364 - onClick = onPlay,  
365 - modifier = Modifier.fillMaxWidth(),  
366 - ) {  
367 - Text(if (isPlaying) "■ 停止" else "▶ 播放原音") 970 + Text("已识别:$name (sim=${"%.2f".format(row.speakerSim ?: 0f)})", style = MaterialTheme.typography.labelMedium)
368 } 971 }
369 - Button(  
370 - onClick = onEnroll,  
371 - modifier = Modifier.fillMaxWidth(),  
372 - ) {  
373 - Text("记为说话人") 972 + Spacer(Modifier.height(4.dp))
  973 + Button(onClick = onPlay, modifier = Modifier.fillMaxWidth()) { Text(if (isPlaying) "■ 停止" else "▶ 播放原音") }
  974 + if (row.speakerId != null) {
  975 + OutlinedButton(onClick = onRename, modifier = Modifier.fillMaxWidth()) { Text("重命名「${row.speakerName}」") }
374 } 976 }
  977 + OutlinedButton(onClick = onReassign, modifier = Modifier.fillMaxWidth()) { Text("改派说话人") }
  978 + OutlinedButton(onClick = onEditText, modifier = Modifier.fillMaxWidth()) { Text("订正转写") }
  979 + OutlinedButton(onClick = onEnroll, modifier = Modifier.fillMaxWidth()) { Text("记为新说话人") }
  980 + OutlinedButton(onClick = onDelete, modifier = Modifier.fillMaxWidth()) { Text("删除该段") }
375 } 981 }
376 } 982 }
377 } 983 }
378 984
379 @Composable 985 @Composable
  986 +private fun RenameSpeakerDialog(currentName: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
  987 + var name by remember { mutableStateOf(currentName) }
  988 + AlertDialog(
  989 + onDismissRequest = onDismiss,
  990 + title = { Text("重命名说话人") },
  991 + text = {
  992 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
  993 + Text("「$currentName」名下所有段会一起更新,之后仍会继续吸纳相近声音。", style = MaterialTheme.typography.bodySmall)
  994 + OutlinedTextField(
  995 + value = name, onValueChange = { name = it },
  996 + label = { Text("说话人名字(如:我、老婆、张三)") }, singleLine = true,
  997 + )
  998 + }
  999 + },
  1000 + confirmButton = { TextButton(onClick = { onConfirm(name) }, enabled = name.isNotBlank()) { Text("保存") } },
  1001 + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
  1002 + )
  1003 +}
  1004 +
  1005 +@Composable
  1006 +private fun ReassignSpeakerDialog(
  1007 + row: SegmentRow,
  1008 + speakers: List<SpeakerProfile>,
  1009 + onDismiss: () -> Unit,
  1010 + onPick: (Long?) -> Unit,
  1011 +) {
  1012 + AlertDialog(
  1013 + onDismissRequest = onDismiss,
  1014 + title = { Text("改派说话人") },
  1015 + text = {
  1016 + Column {
  1017 + Text("段 #${row.id} 当前:${row.speakerName ?: "未归属"}", style = MaterialTheme.typography.bodySmall)
  1018 + Spacer(Modifier.height(8.dp))
  1019 + Row(modifier = Modifier.fillMaxWidth().clickable { onPick(null) }.padding(vertical = 8.dp),
  1020 + verticalAlignment = Alignment.CenterVertically) {
  1021 + RadioButton(selected = row.speakerId == null, onClick = { onPick(null) })
  1022 + Text("(清除归属)")
  1023 + }
  1024 + speakers.forEach { sp ->
  1025 + Row(modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 8.dp),
  1026 + verticalAlignment = Alignment.CenterVertically) {
  1027 + RadioButton(selected = row.speakerId == sp.id, onClick = { onPick(sp.id) })
  1028 + Text(sp.name)
  1029 + }
  1030 + }
  1031 + }
  1032 + },
  1033 + confirmButton = {},
  1034 + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
  1035 + )
  1036 +}
  1037 +
  1038 +@Composable
  1039 +private fun MergeSpeakerDialog(
  1040 + from: SpeakerProfile,
  1041 + others: List<SpeakerProfile>,
  1042 + onDismiss: () -> Unit,
  1043 + onPick: (Long) -> Unit,
  1044 +) {
  1045 + AlertDialog(
  1046 + onDismissRequest = onDismiss,
  1047 + title = { Text("把「${from.name}」合并到…") },
  1048 + text = {
  1049 + Column {
  1050 + if (others.isEmpty()) Text("没有其它说话人可合并。")
  1051 + others.forEach { sp ->
  1052 + Row(modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 10.dp),
  1053 + verticalAlignment = Alignment.CenterVertically) {
  1054 + Text(sp.name)
  1055 + }
  1056 + }
  1057 + }
  1058 + },
  1059 + confirmButton = {},
  1060 + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
  1061 + )
  1062 +}
  1063 +
  1064 +@Composable
  1065 +private fun EditTranscriptDialog(initial: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
  1066 + var text by remember { mutableStateOf(initial) }
  1067 + AlertDialog(
  1068 + onDismissRequest = onDismiss,
  1069 + title = { Text("订正转写") },
  1070 + text = {
  1071 + OutlinedTextField(value = text, onValueChange = { text = it }, modifier = Modifier.fillMaxWidth())
  1072 + },
  1073 + confirmButton = { TextButton(onClick = { onConfirm(text) }) { Text("保存") } },
  1074 + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
  1075 + )
  1076 +}
  1077 +
  1078 +@Composable
380 private fun EnrollSpeakerDialog( 1079 private fun EnrollSpeakerDialog(
381 row: SegmentRow, 1080 row: SegmentRow,
382 existingNames: List<String>, 1081 existingNames: List<String>,
@@ -386,83 +1085,124 @@ private fun EnrollSpeakerDialog( @@ -386,83 +1085,124 @@ private fun EnrollSpeakerDialog(
386 var name by remember { mutableStateOf("") } 1085 var name by remember { mutableStateOf("") }
387 AlertDialog( 1086 AlertDialog(
388 onDismissRequest = onDismiss, 1087 onDismissRequest = onDismiss,
389 - title = { Text("记录说话人") }, 1088 + title = { Text("记为新说话人") },
390 text = { 1089 text = {
391 Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 1090 Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
392 - Text(  
393 - "用这一段的声音建一个声纹 anchor。之后说话风格相近的段会自动打上同名标签。",  
394 - style = MaterialTheme.typography.bodySmall,  
395 - )  
396 - Text(  
397 - "段 #${row.id}: ${row.text?.take(40) ?: "(无转写)"}",  
398 - style = MaterialTheme.typography.bodyMedium,  
399 - ) 1091 + Text("用这一段的声音建一个新声纹 anchor。", style = MaterialTheme.typography.bodySmall)
  1092 + Text("段 #${row.id}: ${row.text?.take(40) ?: "(无转写)"}", style = MaterialTheme.typography.bodyMedium)
400 OutlinedTextField( 1093 OutlinedTextField(
401 - value = name,  
402 - onValueChange = { name = it },  
403 - label = { Text("说话人名字(如:我、老婆、张三)") },  
404 - singleLine = true, 1094 + value = name, onValueChange = { name = it },
  1095 + label = { Text("说话人名字") }, singleLine = true,
405 ) 1096 )
406 if (existingNames.isNotEmpty()) { 1097 if (existingNames.isNotEmpty()) {
407 - Text(  
408 - "已存声纹:${existingNames.joinToString("、")}",  
409 - style = MaterialTheme.typography.labelSmall,  
410 - ) 1098 + Text("已存:${existingNames.joinToString("、")}", style = MaterialTheme.typography.labelSmall)
411 } 1099 }
412 } 1100 }
413 }, 1101 },
414 - confirmButton = {  
415 - TextButton(onClick = { onConfirm(name) }, enabled = name.isNotBlank()) {  
416 - Text("保存")  
417 - }  
418 - },  
419 - dismissButton = {  
420 - TextButton(onClick = onDismiss) { Text("取消") }  
421 - }, 1102 + confirmButton = { TextButton(onClick = { onConfirm(name) }, enabled = name.isNotBlank()) { Text("保存") } },
  1103 + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
422 ) 1104 )
423 } 1105 }
424 1106
425 -/**  
426 - * Read embedding from segment row, save as new SpeakerProfile.  
427 - * Future segments will be auto-matched against it (see RecorderService.refreshProfiles).  
428 - */  
429 -private suspend fun enrollSpeakerForSegment(  
430 - repo: Repository,  
431 - segmentId: Long,  
432 - name: String, 1107 +@Composable
  1108 +private fun ConfirmDialog(
  1109 + title: String,
  1110 + message: String,
  1111 + confirmLabel: String,
  1112 + onDismiss: () -> Unit,
  1113 + onConfirm: () -> Unit,
433 ) { 1114 ) {
  1115 + AlertDialog(
  1116 + onDismissRequest = onDismiss,
  1117 + title = { Text(title) },
  1118 + text = { Text(message, style = MaterialTheme.typography.bodySmall) },
  1119 + confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } },
  1120 + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
  1121 + )
  1122 +}
  1123 +
  1124 +@Composable
  1125 +private fun TimelineRow(
  1126 + row: SegmentRow,
  1127 + isPlaying: Boolean,
  1128 + onClick: () -> Unit,
  1129 + selecting: Boolean = false,
  1130 + selected: Boolean = false,
  1131 + highlighted: Boolean = false,
  1132 +) {
  1133 + val time = TIMELINE_FMT.format(Date(row.startTs))
  1134 + val durSec = "%.1fs".format(row.durationMs / 1000.0)
  1135 + val speakerLabel = when {
  1136 + row.speakerName != null && row.speakerSim != null -> "${row.speakerName} (${"%.2f".format(row.speakerSim)})"
  1137 + row.speakerName != null -> row.speakerName
  1138 + row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})"
  1139 + else -> "—"
  1140 + }
  1141 + val playMarker = if (isPlaying) "▶ " else ""
  1142 + val bg = if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent
  1143 + Row(
  1144 + modifier = Modifier.fillMaxWidth().background(bg).clickable(onClick = onClick).padding(vertical = 4.dp),
  1145 + horizontalArrangement = Arrangement.spacedBy(8.dp),
  1146 + ) {
  1147 + if (selecting) {
  1148 + Text(if (selected) "☑" else "☐", style = MaterialTheme.typography.titleMedium)
  1149 + }
  1150 + Column(modifier = Modifier.weight(1f)) {
  1151 + Text("$playMarker$time · $durSec · #${row.id} · $speakerLabel", style = MaterialTheme.typography.labelSmall)
  1152 + Text(
  1153 + row.text ?: "(no transcript yet)",
  1154 + style = MaterialTheme.typography.bodyMedium,
  1155 + maxLines = 3,
  1156 + overflow = TextOverflow.Ellipsis,
  1157 + )
  1158 + }
  1159 + }
  1160 +}
  1161 +
  1162 +private val EXPORT_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US)
  1163 +
  1164 +/** Build a plain-text transcript and fire a share/save chooser. */
  1165 +private fun exportSegments(ctx: Context, rows: List<SegmentRow>) {
  1166 + if (rows.isEmpty()) return
  1167 + val sb = StringBuilder()
  1168 + rows.sortedBy { it.startTs }.forEach { r ->
  1169 + sb.append("[").append(EXPORT_FMT.format(Date(r.startTs))).append("] ")
  1170 + sb.append(r.speakerName ?: "未知").append(":")
  1171 + sb.append(r.text ?: "").append('\n')
  1172 + }
  1173 + val send = Intent(Intent.ACTION_SEND).apply {
  1174 + type = "text/plain"
  1175 + putExtra(Intent.EXTRA_SUBJECT, "Lifelog 导出 ${rows.size} 段")
  1176 + putExtra(Intent.EXTRA_TEXT, sb.toString())
  1177 + }
  1178 + runCatching {
  1179 + ctx.startActivity(
  1180 + Intent.createChooser(send, "导出 / 分享").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
  1181 + )
  1182 + }
  1183 +}
  1184 +
  1185 +// ============================================================ helpers
  1186 +
  1187 +private val TIMELINE_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US)
  1188 +private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US)
  1189 +private val SUMMARY_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US)
  1190 +private val SUMMARY_DAY_FMT = SimpleDateFormat("yyyy-MM-dd", Locale.US)
  1191 +
  1192 +private suspend fun enrollSpeakerForSegment(repo: Repository, segmentId: Long, name: String) {
434 withContext(Dispatchers.IO) { 1193 withContext(Dispatchers.IO) {
435 val seg = repo.segments.get(segmentId) ?: return@withContext 1194 val seg = repo.segments.get(segmentId) ?: return@withContext
436 val embedding = seg.embedding ?: return@withContext 1195 val embedding = seg.embedding ?: return@withContext
437 - repo.speakers.insert(  
438 - SpeakerProfile(  
439 - name = name.trim(),  
440 - embedding = embedding,  
441 - sampleCount = 1,  
442 - ),  
443 - ) 1196 + repo.speakers.insert(SpeakerProfile(name = name.trim(), embedding = embedding, sampleCount = 1))
444 android.util.Log.i("MainActivity", "enrolled speaker '$name' from segment #$segmentId") 1197 android.util.Log.i("MainActivity", "enrolled speaker '$name' from segment #$segmentId")
445 } 1198 }
446 } 1199 }
447 1200
448 -/**  
449 - * FTS4 MATCH expects a query string. For Chinese character-level search with the  
450 - * unicode61 tokenizer, append `*` to each token so substring/prefix lookup works.  
451 - * Strip FTS metacharacters that would otherwise crash the parser.  
452 - */  
453 private fun sanitizeFtsQuery(raw: String): String { 1201 private fun sanitizeFtsQuery(raw: String): String {
454 val cleaned = raw.trim() 1202 val cleaned = raw.trim()
455 - .replace("\"", "")  
456 - .replace("'", "")  
457 - .replace("(", "")  
458 - .replace(")", "")  
459 - .replace(":", "") 1203 + .replace("\"", "").replace("'", "").replace("(", "").replace(")", "").replace(":", "")
460 if (cleaned.isEmpty()) return "" 1204 if (cleaned.isEmpty()) return ""
461 - // Treat whole input as one prefix term. For longer queries the user can put spaces  
462 - // for AND-of-terms; we add * suffix to each so partial matches work.  
463 - return cleaned.split(Regex("\\s+"))  
464 - .filter { it.isNotEmpty() }  
465 - .joinToString(" ") { "$it*" } 1205 + return cleaned.split(Regex("\\s+")).filter { it.isNotEmpty() }.joinToString(" ") { "$it*" }
466 } 1206 }
467 1207
468 private fun isBatteryOptExempt(ctx: Context): Boolean { 1208 private fun isBatteryOptExempt(ctx: Context): Boolean {
@@ -479,88 +1219,30 @@ private fun requestBatteryOptExemption(ctx: Context) { @@ -479,88 +1219,30 @@ private fun requestBatteryOptExemption(ctx: Context) {
479 runCatching { ctx.startActivity(intent) } 1219 runCatching { ctx.startActivity(intent) }
480 } 1220 }
481 1221
482 -private val TIMELINE_FMT = SimpleDateFormat("HH:mm:ss", Locale.US)  
483 -private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US)  
484 -  
485 -@Composable  
486 -private fun SummaryCard(s: Summary) {  
487 - Column(  
488 - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),  
489 - ) {  
490 - Text(  
491 - "${SUMMARY_FMT.format(Date(s.windowStartTs))} → ${SUMMARY_FMT.format(Date(s.windowEndTs))} · ${s.model}",  
492 - style = MaterialTheme.typography.labelSmall,  
493 - )  
494 - Text(s.markdown, style = MaterialTheme.typography.bodySmall)  
495 - }  
496 -}  
497 -  
498 -@Composable  
499 -private fun TimelineRow(row: SegmentRow, isPlaying: Boolean, onClick: () -> Unit) {  
500 - val time = TIMELINE_FMT.format(Date(row.startTs))  
501 - val durSec = "%.1fs".format(row.durationMs / 1000.0)  
502 - val speakerLabel = when {  
503 - row.speakerName != null && row.speakerSim != null ->  
504 - "${row.speakerName} (${"%.2f".format(row.speakerSim)})"  
505 - row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})"  
506 - else -> "—"  
507 - }  
508 - val playMarker = if (isPlaying) "▶ " else ""  
509 - Column(  
510 - modifier = Modifier  
511 - .fillMaxWidth()  
512 - .clickable(onClick = onClick)  
513 - .padding(vertical = 4.dp),  
514 - ) {  
515 - Text(  
516 - "$playMarker$time · $durSec · #${row.id} · $speakerLabel",  
517 - style = MaterialTheme.typography.labelSmall,  
518 - )  
519 - Text(  
520 - row.text ?: "(no transcript yet)",  
521 - style = MaterialTheme.typography.bodyMedium,  
522 - )  
523 - }  
524 -}  
525 -  
526 -private suspend fun runParaformerTest(  
527 - ctx: android.content.Context,  
528 - onUpdate: (status: String, log: String) -> Unit,  
529 -) { 1222 +private suspend fun runParaformerTest(ctx: Context, onUpdate: (status: String, log: String) -> Unit) {
530 val log = StringBuilder() 1223 val log = StringBuilder()
531 onUpdate("paraformer: ensuring model…", log.toString()) 1224 onUpdate("paraformer: ensuring model…", log.toString())
532 val downloader = ModelDownloader(ctx) 1225 val downloader = ModelDownloader(ctx)
533 val files = try { 1226 val files = try {
534 - downloader.ensure { msg ->  
535 - log.appendLine(msg)  
536 - onUpdate("paraformer: downloading…", log.toString())  
537 - } 1227 + downloader.ensure { msg -> log.appendLine(msg); onUpdate("paraformer: downloading…", log.toString()) }
538 } catch (t: Throwable) { 1228 } catch (t: Throwable) {
539 - log.appendLine("download failed: ${t.message}")  
540 - onUpdate("paraformer: error", log.toString())  
541 - return 1229 + log.appendLine("download failed: ${t.message}"); onUpdate("paraformer: error", log.toString()); return
542 } 1230 }
543 log.appendLine("model: ${files.modelPath}") 1231 log.appendLine("model: ${files.modelPath}")
544 -  
545 onUpdate("paraformer: loading model…", log.toString()) 1232 onUpdate("paraformer: loading model…", log.toString())
546 - val engine = withContext(Dispatchers.IO) {  
547 - ParaformerEngine.load(files.modelPath, files.tokensPath, numThreads = 4)  
548 - } 1233 + val engine = withContext(Dispatchers.IO) { ParaformerEngine.load(files.modelPath, files.tokensPath, numThreads = 4) }
549 try { 1234 try {
550 val pcm = withContext(Dispatchers.IO) { 1235 val pcm = withContext(Dispatchers.IO) {
551 ctx.assets.open("sample.wav").use { ins -> 1236 ctx.assets.open("sample.wav").use { ins ->
552 ins.skip(44) 1237 ins.skip(44)
553 val bytes = ins.readBytes() 1238 val bytes = ins.readBytes()
554 val shorts = ShortArray(bytes.size / 2) 1239 val shorts = ShortArray(bytes.size / 2)
555 - java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN)  
556 - .asShortBuffer().get(shorts) 1240 + java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN).asShortBuffer().get(shorts)
557 shorts 1241 shorts
558 } 1242 }
559 } 1243 }
560 onUpdate("paraformer: transcribing ${pcm.size / 16000}s audio…", log.toString()) 1244 onUpdate("paraformer: transcribing ${pcm.size / 16000}s audio…", log.toString())
561 - val result = withContext(Dispatchers.Default) {  
562 - engine.transcribeWithStats(pcm)  
563 - } 1245 + val result = withContext(Dispatchers.Default) { engine.transcribeWithStats(pcm) }
564 log.appendLine("==== RESULT ====") 1246 log.appendLine("==== RESULT ====")
565 log.appendLine(result.toString()) 1247 log.appendLine(result.toString())
566 onUpdate("paraformer: done ${"%.2f".format(1.0 / result.realtimeFactor)}× realtime", log.toString()) 1248 onUpdate("paraformer: done ${"%.2f".format(1.0 / result.realtimeFactor)}× realtime", log.toString())
@@ -569,50 +1251,37 @@ private suspend fun runParaformerTest( @@ -569,50 +1251,37 @@ private suspend fun runParaformerTest(
569 } 1251 }
570 } 1252 }
571 1253
572 -private suspend fun runXfyunTest(  
573 - ctx: android.content.Context,  
574 - onUpdate: (status: String, log: String) -> Unit,  
575 -) { 1254 +private suspend fun runXfyunTest(ctx: Context, onUpdate: (status: String, log: String) -> Unit) {
576 val started = System.currentTimeMillis() 1255 val started = System.currentTimeMillis()
577 val client = XfyunIatClient() 1256 val client = XfyunIatClient()
578 val session = client.connect() 1257 val session = client.connect()
579 val log = StringBuilder() 1258 val log = StringBuilder()
580 -  
581 val collectJob: Job = kotlinx.coroutines.GlobalScope.launch(Dispatchers.Default) { 1259 val collectJob: Job = kotlinx.coroutines.GlobalScope.launch(Dispatchers.Default) {
582 session.flow.collect { event -> 1260 session.flow.collect { event ->
583 when (event) { 1261 when (event) {
584 is XfyunIatClient.Event.Partial -> { 1262 is XfyunIatClient.Event.Partial -> {
585 log.appendLine("[+${event.sn} ${event.pgs}] ${event.text}") 1263 log.appendLine("[+${event.sn} ${event.pgs}] ${event.text}")
586 - val elapsed = (System.currentTimeMillis() - started) / 1000.0  
587 - onUpdate("讯飞: streaming t=${"%.1f".format(elapsed)}s", log.toString()) 1264 + onUpdate("讯飞: streaming t=${"%.1f".format((System.currentTimeMillis() - started) / 1000.0)}s", log.toString())
588 } 1265 }
589 is XfyunIatClient.Event.Final -> { 1266 is XfyunIatClient.Event.Final -> {
590 - val elapsed = (System.currentTimeMillis() - started) / 1000.0  
591 - log.appendLine("==== FINAL (wall=${"%.1f".format(elapsed)}s) ====")  
592 - log.appendLine(event.fullText)  
593 - onUpdate("讯飞: done wall=${"%.1f".format(elapsed)}s", log.toString()) 1267 + log.appendLine("==== FINAL ===="); log.appendLine(event.fullText)
  1268 + onUpdate("讯飞: done", log.toString())
594 } 1269 }
595 is XfyunIatClient.Event.AsrError -> { 1270 is XfyunIatClient.Event.AsrError -> {
596 - log.appendLine("!! ERROR code=${event.code} msg=${event.message} sid=${event.sid}") 1271 + log.appendLine("!! ERROR code=${event.code} msg=${event.message}")
597 onUpdate("讯飞: error", log.toString()) 1272 onUpdate("讯飞: error", log.toString())
598 } 1273 }
599 - XfyunIatClient.Event.Closed -> {  
600 - log.appendLine("==== CLOSED ====")  
601 - onUpdate("讯飞: closed", log.toString())  
602 - } 1274 + XfyunIatClient.Event.Closed -> { log.appendLine("==== CLOSED ===="); onUpdate("讯飞: closed", log.toString()) }
603 } 1275 }
604 } 1276 }
605 } 1277 }
606 -  
607 withContext(Dispatchers.IO) { 1278 withContext(Dispatchers.IO) {
608 ctx.assets.open("sample.wav").use { ins -> 1279 ctx.assets.open("sample.wav").use { ins ->
609 ins.skip(44) 1280 ins.skip(44)
610 val buf = ByteArray(2560) 1281 val buf = ByteArray(2560)
611 while (true) { 1282 while (true) {
612 - val n = ins.read(buf)  
613 - if (n <= 0) break  
614 - val frame = if (n == buf.size) buf else buf.copyOf(n)  
615 - session.sendPcm(frame, last = false) 1283 + val n = ins.read(buf); if (n <= 0) break
  1284 + session.sendPcm(if (n == buf.size) buf else buf.copyOf(n), last = false)
616 delay(40) 1285 delay(40)
617 } 1286 }
618 } 1287 }
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt 0 → 100644
  1 +package com.lemon.lifelog.asr
  2 +
  3 +import android.util.Log
  4 +import com.lemon.lifelog.data.Repository
  5 +import com.lemon.lifelog.data.SpeakerProfile
  6 +import kotlinx.coroutines.Dispatchers
  7 +import kotlinx.coroutines.sync.Mutex
  8 +import kotlinx.coroutines.sync.withLock
  9 +import kotlinx.coroutines.withContext
  10 +
  11 +/**
  12 + * 方案 B — 在线说话人聚类(auto speaker diarization, incremental)。
  13 + *
  14 + * 与「手动 anchor 匹配」不同,这里会**自动发现**说话人:每个 VAD 段的 192-d embedding
  15 + * 跟所有已知说话人的「质心」算余弦相似度——
  16 + * - best ≥ [matchThreshold] → 归入该说话人,并用滑动均值精修其质心;
  17 + * - 否则且段时长 ≥ [minNewClusterMs] → 新建一个 `说话人N` 簇(短段不可信,不开新簇);
  18 + * - 否则 → 暂不归属(speakerId=null)。
  19 + *
  20 + * 簇直接持久化为 `speaker_profiles` 行,因此和手动建档共用同一套存储 / 时间轴 JOIN /
  21 + * 重命名入口。被重命名后的簇仍会继续吸纳新段、精修质心。
  22 + *
  23 + * 线程模型:[assign] / [load] 都在 [mutex] 下串行;录音 pipeline 本身也是逐段处理,
  24 + * 因此实际无并发,[mutex] 只防御 UI 侧偶发调用。
  25 + */
  26 +class SpeakerClusterer(
  27 + private val repo: Repository,
  28 + private val matchThreshold: Float = MATCH_THRESHOLD,
  29 + private val minNewClusterMs: Long = MIN_NEW_CLUSTER_MS,
  30 + private val maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID,
  31 +) {
  32 + private val mutex = Mutex()
  33 + private val clusters = mutableListOf<Cluster>()
  34 + private var autoSeq = 0
  35 +
  36 + /** 结果:归属的说话人 id / 相似度 / 名字(名字仅用于日志与即时回显)。 */
  37 + data class Assignment(val speakerId: Long?, val sim: Float?, val name: String?)
  38 +
  39 + /** 从 DB 载入已有簇(含手动建档与历史自动簇)。服务启动时调一次。 */
  40 + suspend fun load() = mutex.withLock {
  41 + clusters.clear()
  42 + repo.speakers.all().forEach {
  43 + clusters += Cluster(it.id, it.name, it.embedding.toFloatArrayLe(), it.sampleCount)
  44 + }
  45 + autoSeq = clusters.mapNotNull { autoIndexOf(it.name) }.maxOrNull() ?: 0
  46 + Log.i(TAG, "loaded ${clusters.size} clusters, autoSeq=$autoSeq")
  47 + }
  48 +
  49 + /** 将一个 embedding 归入/新建说话人簇,写穿到 DB。 */
  50 + suspend fun assign(emb: FloatArray, durationMs: Long): Assignment = mutex.withLock {
  51 + var best: Cluster? = null
  52 + var bestSim = -1f
  53 + for (c in clusters) {
  54 + val s = cosineSim(emb, c.centroid)
  55 + if (s > bestSim) {
  56 + bestSim = s
  57 + best = c
  58 + }
  59 + }
  60 +
  61 + if (best != null && bestSim >= matchThreshold) {
  62 + // capped running mean:上限保证质心保持自适应、不被早期样本钉死。
  63 + val n = minOf(best.count, maxSamplesPerCentroid)
  64 + val merged = FloatArray(emb.size) { i -> (best.centroid[i] * n + emb[i]) / (n + 1) }
  65 + l2normalize(merged)
  66 + best.centroid = merged
  67 + best.count += 1
  68 + withContext(Dispatchers.IO) {
  69 + repo.speakers.updateEmbedding(best.id, merged.toLeBytes(), best.count)
  70 + }
  71 + return@withLock Assignment(best.id, bestSim, best.name)
  72 + }
  73 +
  74 + if (durationMs >= minNewClusterMs) {
  75 + val norm = emb.copyOf().also { l2normalize(it) }
  76 + val name = nextAutoName()
  77 + val id = withContext(Dispatchers.IO) {
  78 + repo.speakers.insert(SpeakerProfile(name = name, embedding = norm.toLeBytes(), sampleCount = 1))
  79 + }
  80 + clusters += Cluster(id, name, norm, 1)
  81 + Log.i(TAG, "new speaker $name (#$id) bestSim=${"%.2f".format(bestSim)}")
  82 + return@withLock Assignment(id, 1f, name)
  83 + }
  84 +
  85 + // 匹配不上、又太短不足以开新簇。
  86 + return@withLock Assignment(null, if (bestSim >= 0f) bestSim else null, null)
  87 + }
  88 +
  89 + private fun nextAutoName(): String {
  90 + autoSeq += 1
  91 + return "$AUTO_PREFIX$autoSeq"
  92 + }
  93 +
  94 + companion object {
  95 + private const val TAG = "SpeakerClusterer"
  96 +
  97 + /** 自动簇命名前缀。重命名后不再以此开头,即视为「人工命名」。 */
  98 + const val AUTO_PREFIX = "说话人"
  99 +
  100 + /** CAMPPlus 余弦阈值:同人通常 > 0.55,异人 < 0.45,取 0.5 为分界。 */
  101 + const val MATCH_THRESHOLD = 0.5f
  102 +
  103 + /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */
  104 + const val MIN_NEW_CLUSTER_MS = 1000L
  105 +
  106 + /** 质心滑动均值的样本上限。 */
  107 + const val MAX_SAMPLES_PER_CENTROID = 50
  108 +
  109 + /** `说话人N` → N;非自动名返回 null。 */
  110 + fun autoIndexOf(name: String): Int? =
  111 + if (name.startsWith(AUTO_PREFIX)) name.removePrefix(AUTO_PREFIX).toIntOrNull() else null
  112 +
  113 + /**
  114 + * 离线重聚类:清掉旧的自动簇(保留人工命名的 anchor 作种子),按时间顺序把
  115 + * **所有**已存 embedding 的段重新跑一遍聚类,并回填每段的 speakerId。
  116 + *
  117 + * 用途:bootstrap —— 让历史录音立刻被分好说话人,不必等新音频累积。
  118 + * @return 跑完后的簇总数
  119 + */
  120 + suspend fun reclusterAll(
  121 + repo: Repository,
  122 + matchThreshold: Float = MATCH_THRESHOLD,
  123 + minNewClusterMs: Long = MIN_NEW_CLUSTER_MS,
  124 + maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID,
  125 + ): Int = withContext(Dispatchers.IO) {
  126 + // 1. 删掉自动簇,保留人工命名簇作种子。
  127 + val seeds = mutableListOf<Cluster>()
  128 + for (p in repo.speakers.all()) {
  129 + if (autoIndexOf(p.name) != null) {
  130 + repo.speakers.delete(p.id)
  131 + } else {
  132 + seeds += Cluster(p.id, p.name, p.embedding.toFloatArrayLe(), p.sampleCount)
  133 + }
  134 + }
  135 + val clusters = seeds
  136 + var seq = 0
  137 +
  138 + // 2. 按时间正序逐段聚类。
  139 + val segs = repo.segments.allWithEmbedding().sortedBy { it.startTs }
  140 + for (seg in segs) {
  141 + val emb = seg.embedding?.toFloatArrayLe() ?: continue
  142 + var best: Cluster? = null
  143 + var bestSim = -1f
  144 + for (c in clusters) {
  145 + val s = cosineSim(emb, c.centroid)
  146 + if (s > bestSim) {
  147 + bestSim = s
  148 + best = c
  149 + }
  150 + }
  151 +
  152 + val assignment = if (best != null && bestSim >= matchThreshold) {
  153 + val n = minOf(best.count, maxSamplesPerCentroid)
  154 + val merged = FloatArray(emb.size) { i -> (best.centroid[i] * n + emb[i]) / (n + 1) }
  155 + l2normalize(merged)
  156 + best.centroid = merged
  157 + best.count += 1
  158 + repo.speakers.updateEmbedding(best.id, merged.toLeBytes(), best.count)
  159 + best.id to bestSim
  160 + } else if (seg.durationMs >= minNewClusterMs) {
  161 + val norm = emb.copyOf().also { l2normalize(it) }
  162 + seq += 1
  163 + val name = "$AUTO_PREFIX$seq"
  164 + val id = repo.speakers.insert(
  165 + SpeakerProfile(name = name, embedding = norm.toLeBytes(), sampleCount = 1),
  166 + )
  167 + clusters += Cluster(id, name, norm, 1)
  168 + id to 1f
  169 + } else {
  170 + null to (if (bestSim >= 0f) bestSim else null)
  171 + }
  172 +
  173 + repo.segments.updateSpeaker(seg.id, assignment.first, assignment.second)
  174 + }
  175 + Log.i(TAG, "reclusterAll: ${segs.size} segments → ${clusters.size} clusters")
  176 + clusters.size
  177 + }
  178 + }
  179 +}
  180 +
  181 +/** 簇的内存表示(质心持续更新)。文件私有,供实例与离线重聚类共用。 */
  182 +private class Cluster(
  183 + val id: Long,
  184 + var name: String,
  185 + var centroid: FloatArray,
  186 + var count: Int,
  187 +)
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerEmbedder.kt
@@ -99,6 +99,15 @@ fun cosineSim(a: FloatArray, b: FloatArray): Float { @@ -99,6 +99,15 @@ fun cosineSim(a: FloatArray, b: FloatArray): Float {
99 return (dot / (sqrt(na) * sqrt(nb))).toFloat() 99 return (dot / (sqrt(na) * sqrt(nb))).toFloat()
100 } 100 }
101 101
  102 +/** L2-normalise a vector in place (no-op if zero). Keeps running centroids stable. */
  103 +fun l2normalize(v: FloatArray) {
  104 + var n = 0.0
  105 + for (x in v) n += x * x
  106 + if (n == 0.0) return
  107 + val inv = (1.0 / sqrt(n)).toFloat()
  108 + for (i in v.indices) v[i] *= inv
  109 +}
  110 +
102 /** Average a list of embeddings into one (per-dim mean), normalised. */ 111 /** Average a list of embeddings into one (per-dim mean), normalised. */
103 fun averageEmbeddings(embeddings: List<FloatArray>): FloatArray { 112 fun averageEmbeddings(embeddings: List<FloatArray>): FloatArray {
104 require(embeddings.isNotEmpty()) { "empty list" } 113 require(embeddings.isNotEmpty()) { "empty list" }
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
@@ -16,6 +16,11 @@ data class SegmentRow( @@ -16,6 +16,11 @@ data class SegmentRow(
16 val speakerName: String?, 16 val speakerName: String?,
17 ) 17 )
18 18
  19 +data class SpeakerCount(
  20 + val speakerId: Long,
  21 + val count: Int,
  22 +)
  23 +
19 @Dao 24 @Dao
20 interface SegmentDao { 25 interface SegmentDao {
21 @Insert 26 @Insert
@@ -33,6 +38,17 @@ interface SegmentDao { @@ -33,6 +38,17 @@ interface SegmentDao {
33 @Query("UPDATE segments SET speakerId = :speakerId, speakerSim = :sim WHERE id = :id") 38 @Query("UPDATE segments SET speakerId = :speakerId, speakerSim = :sim WHERE id = :id")
34 suspend fun updateSpeaker(id: Long, speakerId: Long?, sim: Float?) 39 suspend fun updateSpeaker(id: Long, speakerId: Long?, sim: Float?)
35 40
  41 + @Query("DELETE FROM segments WHERE id = :id")
  42 + suspend fun deleteSegment(id: Long)
  43 +
  44 + /** 删除某说话人后,把其名下段落置空。 */
  45 + @Query("UPDATE segments SET speakerId = NULL, speakerSim = NULL WHERE speakerId = :speakerId")
  46 + suspend fun clearSpeakerAssignments(speakerId: Long)
  47 +
  48 + /** 合并说话人:把 [from] 名下段落改派给 [into]。 */
  49 + @Query("UPDATE segments SET speakerId = :into WHERE speakerId = :from")
  50 + suspend fun reassignSpeaker(from: Long, into: Long)
  51 +
36 /** 52 /**
37 * Backfill: re-assign speakerId for all segments that have a stored embedding, 53 * Backfill: re-assign speakerId for all segments that have a stored embedding,
38 * using the latest profile set. Called once after a new profile is enrolled. 54 * using the latest profile set. Called once after a new profile is enrolled.
@@ -41,6 +57,35 @@ interface SegmentDao { @@ -41,6 +57,35 @@ interface SegmentDao {
41 @Query("SELECT * FROM segments WHERE embedding IS NOT NULL") 57 @Query("SELECT * FROM segments WHERE embedding IS NOT NULL")
42 suspend fun allWithEmbedding(): List<Segment> 58 suspend fun allWithEmbedding(): List<Segment>
43 59
  60 + /** 每个说话人当前实际归属的段数(用于声纹列表展示)。 */
  61 + @Query("SELECT speakerId AS speakerId, COUNT(*) AS count FROM segments WHERE speakerId IS NOT NULL GROUP BY speakerId")
  62 + fun observeSpeakerCounts(): Flow<List<SpeakerCount>>
  63 +
  64 + /** 某时间范围内的段数(纪要折叠头展示)。 */
  65 + @Query("SELECT COUNT(*) FROM segments WHERE startTs BETWEEN :start AND :end")
  66 + suspend fun countInRange(start: Long, end: Long): Int
  67 +
  68 + /** 某时间范围内出现过的说话人名单(去重,纪要折叠头展示)。 */
  69 + @Query(
  70 + """
  71 + SELECT DISTINCT sp.name FROM segments s
  72 + INNER JOIN speaker_profiles sp ON sp.id = s.speakerId
  73 + WHERE s.startTs BETWEEN :start AND :end
  74 + ORDER BY sp.name
  75 + """,
  76 + )
  77 + suspend fun speakersInRange(start: Long, end: Long): List<String>
  78 +
  79 + /** 范围内离目标时间最近的段 id(纪要要点跳回时间轴用)。 */
  80 + @Query(
  81 + """
  82 + SELECT id FROM segments
  83 + ORDER BY ABS(startTs - :targetTs) ASC
  84 + LIMIT 1
  85 + """,
  86 + )
  87 + suspend fun nearestSegmentId(targetTs: Long): Long?
  88 +
44 @Query( 89 @Query(
45 """ 90 """
46 SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, 91 SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs,
@@ -82,6 +127,13 @@ interface TranscriptDao { @@ -82,6 +127,13 @@ interface TranscriptDao {
82 127
83 @Query("SELECT * FROM transcripts WHERE segmentId = :id") 128 @Query("SELECT * FROM transcripts WHERE segmentId = :id")
84 suspend fun get(id: Long): Transcript? 129 suspend fun get(id: Long): Transcript?
  130 +
  131 + /** 手动订正转写文本(FTS 由 Room 触发器自动同步)。 */
  132 + @Query("UPDATE transcripts SET text = :text, reviewedAt = :reviewedAt WHERE segmentId = :id")
  133 + suspend fun updateText(id: Long, text: String, reviewedAt: Long)
  134 +
  135 + @Query("DELETE FROM transcripts WHERE segmentId = :id")
  136 + suspend fun deleteBySegment(id: Long)
85 } 137 }
86 138
87 @Dao 139 @Dao
@@ -91,6 +143,12 @@ interface SummaryDao { @@ -91,6 +143,12 @@ interface SummaryDao {
91 143
92 @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n") 144 @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n")
93 fun recent(n: Int): Flow<List<Summary>> 145 fun recent(n: Int): Flow<List<Summary>>
  146 +
  147 + @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC")
  148 + suspend fun all(): List<Summary>
  149 +
  150 + @Query("DELETE FROM summaries WHERE id = :id")
  151 + suspend fun delete(id: Long)
94 } 152 }
95 153
96 @Dao 154 @Dao
@@ -104,6 +162,14 @@ interface SpeakerDao { @@ -104,6 +162,14 @@ interface SpeakerDao {
104 @Query("SELECT * FROM speaker_profiles ORDER BY createdAt ASC") 162 @Query("SELECT * FROM speaker_profiles ORDER BY createdAt ASC")
105 suspend fun all(): List<SpeakerProfile> 163 suspend fun all(): List<SpeakerProfile>
106 164
  165 + /** Refine a cluster's running centroid + sample count (online clustering). */
  166 + @Query("UPDATE speaker_profiles SET embedding = :embedding, sampleCount = :sampleCount WHERE id = :id")
  167 + suspend fun updateEmbedding(id: Long, embedding: ByteArray, sampleCount: Int)
  168 +
  169 + /** Rename a speaker (e.g. give an auto-discovered 说话人N a real name). */
  170 + @Query("UPDATE speaker_profiles SET name = :name WHERE id = :id")
  171 + suspend fun updateName(id: Long, name: String)
  172 +
107 @Query("DELETE FROM speaker_profiles WHERE id = :id") 173 @Query("DELETE FROM speaker_profiles WHERE id = :id")
108 suspend fun delete(id: Long) 174 suspend fun delete(id: Long)
109 } 175 }
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt
@@ -16,9 +16,8 @@ import com.k2fsa.sherpa.onnx.VadModelConfig @@ -16,9 +16,8 @@ import com.k2fsa.sherpa.onnx.VadModelConfig
16 import com.lemon.lifelog.MainActivity 16 import com.lemon.lifelog.MainActivity
17 import com.lemon.lifelog.asr.ModelDownloader 17 import com.lemon.lifelog.asr.ModelDownloader
18 import com.lemon.lifelog.asr.ParaformerEngine 18 import com.lemon.lifelog.asr.ParaformerEngine
  19 +import com.lemon.lifelog.asr.SpeakerClusterer
19 import com.lemon.lifelog.asr.SpeakerEmbedder 20 import com.lemon.lifelog.asr.SpeakerEmbedder
20 -import com.lemon.lifelog.asr.cosineSim  
21 -import com.lemon.lifelog.asr.toFloatArrayLe  
22 import com.lemon.lifelog.asr.toLeBytes 21 import com.lemon.lifelog.asr.toLeBytes
23 import com.lemon.lifelog.audio.AudioCapture 22 import com.lemon.lifelog.audio.AudioCapture
24 import com.lemon.lifelog.audio.SegmentWriter 23 import com.lemon.lifelog.audio.SegmentWriter
@@ -41,11 +40,26 @@ class RecorderService : Service() { @@ -41,11 +40,26 @@ class RecorderService : Service() {
41 const val CHANNEL_ID = "recorder" 40 const val CHANNEL_ID = "recorder"
42 const val NOTIFICATION_ID = 1001 41 const val NOTIFICATION_ID = 1001
43 42
  43 + const val ACTION_PAUSE = "com.lemon.lifelog.PAUSE"
  44 + const val ACTION_RESUME = "com.lemon.lifelog.RESUME"
  45 +
44 @Volatile 46 @Volatile
45 var isRunning: Boolean = false 47 var isRunning: Boolean = false
46 private set 48 private set
  49 +
  50 + /** 真暂停:停止采集但保留模型常驻。 */
  51 + fun pause(ctx: android.content.Context) {
  52 + ctx.startService(Intent(ctx, RecorderService::class.java).setAction(ACTION_PAUSE))
  53 + }
  54 +
  55 + fun resume(ctx: android.content.Context) {
  56 + ctx.startService(Intent(ctx, RecorderService::class.java).setAction(ACTION_RESUME))
  57 + }
47 } 58 }
48 59
  60 + @Volatile
  61 + private var paused = false
  62 +
49 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) 63 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
50 private var pipelineJob: Job? = null 64 private var pipelineJob: Job? = null
51 private var wakeLock: PowerManager.WakeLock? = null 65 private var wakeLock: PowerManager.WakeLock? = null
@@ -57,13 +71,8 @@ class RecorderService : Service() { @@ -57,13 +71,8 @@ class RecorderService : Service() {
57 private var engine: ParaformerEngine? = null 71 private var engine: ParaformerEngine? = null
58 private var speakerEmbedder: SpeakerEmbedder? = null 72 private var speakerEmbedder: SpeakerEmbedder? = null
59 73
60 - /** Anchor profiles cached in memory; refreshed when DB changes. */  
61 - @Volatile  
62 - private var profiles: List<CachedProfile> = emptyList()  
63 - private data class CachedProfile(val id: Long, val name: String, val embedding: FloatArray)  
64 -  
65 - /** Cosine threshold above which we assign a profile to a segment. Tuned for CAMPPlus. */  
66 - private val speakerMatchThreshold = 0.5f 74 + /** 在线说话人聚类器(方案 B):自动发现说话人并持久化为 speaker_profiles。 */
  75 + private var clusterer: SpeakerClusterer? = null
67 76
68 override fun onCreate() { 77 override fun onCreate() {
69 super.onCreate() 78 super.onCreate()
@@ -79,7 +88,40 @@ class RecorderService : Service() { @@ -79,7 +88,40 @@ class RecorderService : Service() {
79 pipelineJob = scope.launch { runPipeline() } 88 pipelineJob = scope.launch { runPipeline() }
80 } 89 }
81 90
82 - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY 91 + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
  92 + when (intent?.action) {
  93 + ACTION_PAUSE -> doPause()
  94 + ACTION_RESUME -> doResume()
  95 + }
  96 + return START_STICKY
  97 + }
  98 +
  99 + private fun doPause() {
  100 + if (paused) return
  101 + paused = true
  102 + capture.stop()
  103 + wakeLock?.takeIf { it.isHeld }?.release()
  104 + RecorderState.setPaused(true)
  105 + RecorderState.setStatus("已暂停")
  106 + notify("已暂停")
  107 + Log.i(TAG, "paused (models kept resident)")
  108 + }
  109 +
  110 + private fun doResume() {
  111 + if (!paused) return
  112 + paused = false
  113 + acquireWakeLock()
  114 + capture.start(scope)
  115 + RecorderState.setPaused(false)
  116 + RecorderState.setStatus("listening")
  117 + notify("录音中")
  118 + Log.i(TAG, "resumed")
  119 + }
  120 +
  121 + private fun notify(text: String) {
  122 + val nm = getSystemService(android.app.NotificationManager::class.java)
  123 + nm?.notify(NOTIFICATION_ID, buildNotification(text))
  124 + }
83 125
84 override fun onDestroy() { 126 override fun onDestroy() {
85 pipelineJob?.cancel() 127 pipelineJob?.cancel()
@@ -159,16 +201,21 @@ class RecorderService : Service() { @@ -159,16 +201,21 @@ class RecorderService : Service() {
159 Log.w(TAG, "speaker embedder not available; segments will skip embedding", t) 201 Log.w(TAG, "speaker embedder not available; segments will skip embedding", t)
160 } 202 }
161 203
162 - // 2d. Load anchor profiles (cosine matching).  
163 - refreshProfiles() 204 + // 2d. Online speaker clusterer (auto-discovers 说话人N; persists as speaker_profiles).
  205 + clusterer = SpeakerClusterer(repo).also { it.load() }
  206 + // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer.
  207 + // load() is mutex-serialised vs assign() and idempotent, so reloading on every
  208 + // speaker_profiles change (incl. the clusterer's own write-throughs) is safe.
164 scope.launch { 209 scope.launch {
165 - repo.speakers.observeAll().collect { _ -> refreshProfiles() } 210 + repo.speakers.observeAll().collect { clusterer?.load() }
166 } 211 }
167 212
168 - // 3. Start capturing.  
169 - capture.start(scope)  
170 - RecorderState.setStatus("listening")  
171 - Log.i(TAG, "pipeline running speakers=${profiles.size}") 213 + // 3. Start capturing (unless paused arrived during model load).
  214 + if (!paused) {
  215 + capture.start(scope)
  216 + RecorderState.setStatus("listening")
  217 + }
  218 + Log.i(TAG, "pipeline running, clusterer loaded")
172 219
173 capture.frames.collect { samples -> 220 capture.frames.collect { samples ->
174 val v = vad ?: return@collect 221 val v = vad ?: return@collect
@@ -203,7 +250,7 @@ class RecorderService : Service() { @@ -203,7 +250,7 @@ class RecorderService : Service() {
203 } 250 }
204 val wallMs = (System.nanoTime() - wallStart) / 1_000_000 251 val wallMs = (System.nanoTime() - wallStart) / 1_000_000
205 252
206 - // c. speaker embedding + nearest-profile match. 253 + // c. speaker embedding + online clustering (auto-discovers 说话人N).
207 val embedding: FloatArray? = speakerEmbedder?.let { embedder -> 254 val embedding: FloatArray? = speakerEmbedder?.let { embedder ->
208 try { 255 try {
209 embedder.embed(samples, sr) 256 embedder.embed(samples, sr)
@@ -212,22 +259,12 @@ class RecorderService : Service() { @@ -212,22 +259,12 @@ class RecorderService : Service() {
212 null 259 null
213 } 260 }
214 } 261 }
215 - val (matchedSpeakerId: Long?, matchedSim: Float?, matchedName: String?) =  
216 - if (embedding != null && profiles.isNotEmpty()) {  
217 - var bestId: Long? = null  
218 - var bestName: String? = null  
219 - var bestSim = -1f  
220 - for (p in profiles) {  
221 - val sim = cosineSim(embedding, p.embedding)  
222 - if (sim > bestSim) {  
223 - bestSim = sim  
224 - bestId = p.id  
225 - bestName = p.name  
226 - }  
227 - }  
228 - if (bestSim >= speakerMatchThreshold) Triple(bestId, bestSim, bestName)  
229 - else Triple(null, bestSim, null)  
230 - } else Triple(null, null, null) 262 + val assignment = embedding?.let { emb ->
  263 + clusterer?.assign(emb, audioMs)
  264 + } ?: SpeakerClusterer.Assignment(null, null, null)
  265 + val matchedSpeakerId = assignment.speakerId
  266 + val matchedSim = assignment.sim
  267 + val matchedName = assignment.name
231 268
232 // d. Room insert. 269 // d. Room insert.
233 val segmentId = kotlinx.coroutines.withContext(Dispatchers.IO) { 270 val segmentId = kotlinx.coroutines.withContext(Dispatchers.IO) {
@@ -263,14 +300,6 @@ class RecorderService : Service() { @@ -263,14 +300,6 @@ class RecorderService : Service() {
263 RecorderState.onSegment(text, audioMs, wallMs) 300 RecorderState.onSegment(text, audioMs, wallMs)
264 } 301 }
265 302
266 - private suspend fun refreshProfiles() {  
267 - val rows = kotlinx.coroutines.withContext(Dispatchers.IO) { repo.speakers.all() }  
268 - profiles = rows.map {  
269 - CachedProfile(it.id, it.name, it.embedding.toFloatArrayLe())  
270 - }  
271 - Log.i(TAG, "loaded ${profiles.size} speaker profiles")  
272 - }  
273 -  
274 private fun startForegroundCompat() { 303 private fun startForegroundCompat() {
275 val notification = buildNotification() 304 val notification = buildNotification()
276 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 305 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
@@ -285,6 +314,7 @@ class RecorderService : Service() { @@ -285,6 +314,7 @@ class RecorderService : Service() {
285 } 314 }
286 315
287 private fun acquireWakeLock() { 316 private fun acquireWakeLock() {
  317 + if (wakeLock?.isHeld == true) return
288 val pm = getSystemService(PowerManager::class.java) 318 val pm = getSystemService(PowerManager::class.java)
289 wakeLock = pm.newWakeLock( 319 wakeLock = pm.newWakeLock(
290 PowerManager.PARTIAL_WAKE_LOCK, 320 PowerManager.PARTIAL_WAKE_LOCK,
@@ -295,7 +325,7 @@ class RecorderService : Service() { @@ -295,7 +325,7 @@ class RecorderService : Service() {
295 } 325 }
296 } 326 }
297 327
298 - private fun buildNotification(): Notification { 328 + private fun buildNotification(text: String = "录音中"): Notification {
299 val pi = PendingIntent.getActivity( 329 val pi = PendingIntent.getActivity(
300 this, 330 this,
301 0, 331 0,
@@ -304,7 +334,7 @@ class RecorderService : Service() { @@ -304,7 +334,7 @@ class RecorderService : Service() {
304 ) 334 )
305 return NotificationCompat.Builder(this, CHANNEL_ID) 335 return NotificationCompat.Builder(this, CHANNEL_ID)
306 .setContentTitle("Lifelog") 336 .setContentTitle("Lifelog")
307 - .setContentText("录音中") 337 + .setContentText(text)
308 .setSmallIcon(android.R.drawable.presence_audio_online) 338 .setSmallIcon(android.R.drawable.presence_audio_online)
309 .setContentIntent(pi) 339 .setContentIntent(pi)
310 .setOngoing(true) 340 .setOngoing(true)
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt
@@ -8,6 +8,10 @@ object RecorderState { @@ -8,6 +8,10 @@ object RecorderState {
8 private val _isRunning = MutableStateFlow(false) 8 private val _isRunning = MutableStateFlow(false)
9 val isRunning: StateFlow<Boolean> = _isRunning 9 val isRunning: StateFlow<Boolean> = _isRunning
10 10
  11 + /** 真暂停:服务存活、模型常驻,仅停止音频采集。 */
  12 + private val _isPaused = MutableStateFlow(false)
  13 + val isPaused: StateFlow<Boolean> = _isPaused
  14 +
11 private val _segmentCount = MutableStateFlow(0) 15 private val _segmentCount = MutableStateFlow(0)
12 val segmentCount: StateFlow<Int> = _segmentCount 16 val segmentCount: StateFlow<Int> = _segmentCount
13 17
@@ -25,6 +29,11 @@ object RecorderState { @@ -25,6 +29,11 @@ object RecorderState {
25 29
26 fun setRunning(v: Boolean) { 30 fun setRunning(v: Boolean) {
27 _isRunning.value = v 31 _isRunning.value = v
  32 + if (!v) _isPaused.value = false
  33 + }
  34 +
  35 + fun setPaused(v: Boolean) {
  36 + _isPaused.value = v
28 } 37 }
29 38
30 fun onSegment(text: String, audioMs: Long, wallMs: Long) { 39 fun onSegment(text: String, audioMs: Long, wallMs: Long) {
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt
@@ -29,10 +29,13 @@ object SummaryScheduler { @@ -29,10 +29,13 @@ object SummaryScheduler {
29 ) 29 )
30 } 30 }
31 31
32 - fun runNow(ctx: Context, windowOverride: Long? = null) {  
33 - val data = if (windowOverride != null) {  
34 - Data.Builder().putLong(SummaryWorker.INPUT_NOW_TS, windowOverride).build()  
35 - } else Data.EMPTY 32 + /** 手动跑一次:用当前 SharedPreferences 里的会话间隔设置。 */
  33 + fun runNow(ctx: Context) {
  34 + val gapMin = ctx.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE)
  35 + .getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN)
  36 + val data = Data.Builder()
  37 + .putLong(SummaryWorker.INPUT_GAP_MS, gapMin * 60_000L)
  38 + .build()
36 val request = OneTimeWorkRequestBuilder<SummaryWorker>() 39 val request = OneTimeWorkRequestBuilder<SummaryWorker>()
37 .setInputData(data) 40 .setInputData(data)
38 .addTag(ONE_SHOT_TAG) 41 .addTag(ONE_SHOT_TAG)
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt
@@ -6,25 +6,24 @@ import androidx.work.CoroutineWorker @@ -6,25 +6,24 @@ import androidx.work.CoroutineWorker
6 import androidx.work.WorkerParameters 6 import androidx.work.WorkerParameters
7 import com.lemon.lifelog.asr.HermesClient 7 import com.lemon.lifelog.asr.HermesClient
8 import com.lemon.lifelog.data.Repository 8 import com.lemon.lifelog.data.Repository
  9 +import com.lemon.lifelog.data.Segment
9 import com.lemon.lifelog.data.Summary 10 import com.lemon.lifelog.data.Summary
10 import java.text.SimpleDateFormat 11 import java.text.SimpleDateFormat
11 import java.util.Date 12 import java.util.Date
12 import java.util.Locale 13 import java.util.Locale
13 14
14 /** 15 /**
15 - * Periodic / on-demand 纪要 worker. 16 + * 会话级 纪要 worker(按时间间隔自动切会话)。
16 * 17 *
17 - * Strategy:  
18 - * - Pull transcripts in a sliding window (default: last [WINDOW_MS] ms).  
19 - * - If the window covers < [MIN_DURATION_MS] of actual speech, skip.  
20 - * - If a Summary already exists that overlaps the window with the same or  
21 - * larger span, skip (idempotent).  
22 - * - Compose prompt with transcripts (+ speaker labels if known) and POST to  
23 - * hermes /v1/chat/completions; save the markdown reply as a Summary row. 18 + * 策略:
  19 + * - 拉取所有段,按 startTs 排序,相邻两段间隔 > [gapMs] 即切成不同「会话」。
  20 + * - 只总结**已结束**的会话:now − 会话末段 endTs > gapMs(确信不会再有新段并入),
  21 + * 避免把正在进行的会话过早总结。
  22 + * - 会话总语音 < [MIN_SESSION_SPEECH_MS] 跳过(太短不值得)。
  23 + * - 已有 Summary 的时间范围与会话重叠则跳过(幂等,可反复跑)。
  24 + * - 每个待总结会话 → 拼 prompt POST hermes /v1/chat/completions,存一行 Summary。
24 * 25 *
25 - * Failure modes:  
26 - * - hermes gateway down → IOException → Worker returns retry().  
27 - * - Empty window → success() no-op. 26 + * 一次运行会处理**所有**符合条件的会话(可能 0~N 篇)。
28 */ 27 */
29 class SummaryWorker( 28 class SummaryWorker(
30 ctx: Context, 29 ctx: Context,
@@ -34,79 +33,129 @@ class SummaryWorker( @@ -34,79 +33,129 @@ class SummaryWorker(
34 companion object { 33 companion object {
35 const val TAG = "SummaryWorker" 34 const val TAG = "SummaryWorker"
36 35
37 - /** 总结窗口大小:默认最近 1 小时。 */  
38 - const val WINDOW_MS = 60L * 60 * 1000 36 + /** 会话切分间隔默认值:10 分钟。UI 可调,存 SharedPreferences。 */
  37 + const val DEFAULT_GAP_MIN = 10
  38 + const val MIN_SESSION_SPEECH_MS = 30_000L // 会话至少 30s 语音
39 39
40 - /** 窗口内必须至少有这么多毫秒的语音才触发,否则没必要总结。 */  
41 - const val MIN_DURATION_MS = 60_000L // 60s 40 + const val INPUT_NOW_TS = "now_ts" // 覆盖 now(测试 / 手动)
  41 + const val INPUT_GAP_MS = "gap_ms" // 覆盖会话间隔(手动跑用当前设置)
42 42
43 - const val INPUT_NOW_TS = "now_ts" // override "now" for tests / manual run 43 + const val PREFS = "lifelog_prefs"
  44 + const val KEY_GAP_MIN = "session_gap_min"
  45 +
  46 + /** 纯函数:把按时间排好序的段切成若干会话。 */
  47 + fun groupSessions(segments: List<Segment>, gapMs: Long): List<List<Segment>> {
  48 + val out = mutableListOf<MutableList<Segment>>()
  49 + for (seg in segments) {
  50 + val cur = out.lastOrNull()
  51 + if (cur == null || seg.startTs - cur.last().endTs > gapMs) {
  52 + out.add(mutableListOf(seg))
  53 + } else {
  54 + cur.add(seg)
  55 + }
  56 + }
  57 + return out
  58 + }
44 } 59 }
45 60
46 - private val dateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) 61 + private val timeFmt = SimpleDateFormat("HH:mm:ss", Locale.US)
  62 + private val rangeFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
47 63
48 override suspend fun doWork(): Result { 64 override suspend fun doWork(): Result {
49 - val nowTs = inputData.getLong(INPUT_NOW_TS, System.currentTimeMillis())  
50 - val windowStart = nowTs - WINDOW_MS 65 + val now = inputData.getLong(INPUT_NOW_TS, System.currentTimeMillis())
  66 + val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
  67 + val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 }
  68 + ?: (prefs.getInt(KEY_GAP_MIN, DEFAULT_GAP_MIN) * 60_000L)
  69 +
51 val repo = Repository(applicationContext) 70 val repo = Repository(applicationContext)
  71 + val segments = repo.segments.page(limit = 5000, offset = 0).sortedBy { it.startTs }
  72 + if (segments.isEmpty()) {
  73 + Log.i(TAG, "no segments"); return Result.success()
  74 + }
  75 +
  76 + val sessions = groupSessions(segments, gapMs)
  77 + val existing = repo.summaries.all()
  78 + val speakerNames = repo.speakers.all().associate { it.id to it.name }
  79 +
  80 + var made = 0
  81 + var anyFailed = false
  82 +
  83 + for (session in sessions) {
  84 + val start = session.first().startTs
  85 + val end = session.last().endTs
  86 +
  87 + // 未结束的会话不动(可能还会有新段并入)。
  88 + if (now - end <= gapMs) continue
  89 + // 太短跳过。
  90 + if (session.sumOf { it.durationMs } < MIN_SESSION_SPEECH_MS) continue
  91 + // 已总结过(时间范围重叠)跳过。
  92 + if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) continue
52 93
53 - val segments = repo.segments.page(limit = 500, offset = 0)  
54 - .filter { it.startTs in windowStart..nowTs }  
55 - .sortedBy { it.startTs } 94 + val userPrompt = buildPrompt(repo, session, speakerNames, start, end)
  95 + if (userPrompt == null) continue // 整段无有效转写
56 96
57 - val totalSpeechMs = segments.sumOf { it.durationMs }  
58 - if (totalSpeechMs < MIN_DURATION_MS) {  
59 - Log.i(TAG, "skip: total speech ${totalSpeechMs}ms < ${MIN_DURATION_MS}ms threshold")  
60 - return Result.success() 97 + val client = HermesClient()
  98 + val resp = try {
  99 + client.chat(SYSTEM_PROMPT, userPrompt)
  100 + } catch (t: Throwable) {
  101 + Log.w(TAG, "hermes call failed for session ${rangeFmt.format(Date(start))}: ${t.message}")
  102 + anyFailed = true
  103 + continue
  104 + }
  105 +
  106 + repo.summaries.insert(
  107 + Summary(
  108 + windowStartTs = start,
  109 + windowEndTs = end,
  110 + markdown = resp.text,
  111 + model = resp.model,
  112 + createdAt = now,
  113 + ),
  114 + )
  115 + made++
  116 + Log.i(
  117 + TAG,
  118 + "summary saved ${rangeFmt.format(Date(start))}–${rangeFmt.format(Date(end))} " +
  119 + "segments=${session.size} speech=${session.sumOf { it.durationMs } / 1000}s",
  120 + )
61 } 121 }
62 122
63 - // Build the user prompt: time range + each segment's text (+ speaker label if any). 123 + Log.i(TAG, "done: ${sessions.size} sessions, $made summarized (gap=${gapMs / 60_000}min)")
  124 + return if (anyFailed && made == 0) Result.retry() else Result.success()
  125 + }
  126 +
  127 + /** 拼 prompt;若会话内没有任何有效转写返回 null。 */
  128 + private suspend fun buildPrompt(
  129 + repo: Repository,
  130 + session: List<Segment>,
  131 + speakerNames: Map<Long, String>,
  132 + start: Long,
  133 + end: Long,
  134 + ): String? {
64 val sb = StringBuilder() 135 val sb = StringBuilder()
65 - sb.appendLine("以下是 ${dateFmt.format(Date(windowStart))} – ${dateFmt.format(Date(nowTs))} 的语音转写片段(按时间顺序)。") 136 + sb.appendLine("以下是 ${rangeFmt.format(Date(start))} – ${rangeFmt.format(Date(end))} 一次连续录音的语音转写片段(按时间顺序)。")
66 sb.appendLine() 137 sb.appendLine()
67 - for (seg in segments) { 138 + var hasText = false
  139 + for (seg in session) {
68 val transcript = repo.transcripts.get(seg.id)?.text ?: continue 140 val transcript = repo.transcripts.get(seg.id)?.text ?: continue
69 if (transcript.isBlank()) continue 141 if (transcript.isBlank()) continue
70 - val speakerLabel = repo.segments.get(seg.id)?.speakerId  
71 - ?.let { repo.speakers.all().firstOrNull { p -> p.id == it }?.name }  
72 - ?: "未知"  
73 - val timeStr = SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(seg.startTs))  
74 - sb.appendLine("[$timeStr] $speakerLabel: $transcript") 142 + hasText = true
  143 + val speaker = seg.speakerId?.let { speakerNames[it] } ?: "未知"
  144 + sb.appendLine("[${timeFmt.format(Date(seg.startTs))}] $speaker: $transcript")
75 } 145 }
76 -  
77 - val systemPrompt = """  
78 - 你是一个 lifelog 纪要助手。给定一段时间内的语音转写片段(含时间戳和说话人),  
79 - 请输出一份简洁的 markdown 纪要,包含:  
80 - 1. 一句话主题概括  
81 - 2. 关键信息要点(bullet 列表,5-10 条)  
82 - 3. 如果有待办事项 / 决定 / 提到的人名地名,单独列「待办」「人物」「地点」三个小节  
83 - 纪要语言保持中文。不要复述全文,只摘核心。  
84 - """.trimIndent()  
85 -  
86 - val client = HermesClient()  
87 - val response = try {  
88 - client.chat(systemPrompt, sb.toString())  
89 - } catch (t: Throwable) {  
90 - Log.w(TAG, "hermes call failed: ${t.message}; will retry")  
91 - return Result.retry()  
92 - }  
93 -  
94 - repo.summaries.insert(  
95 - Summary(  
96 - windowStartTs = windowStart,  
97 - windowEndTs = nowTs,  
98 - markdown = response.text,  
99 - model = response.model,  
100 - createdAt = System.currentTimeMillis(),  
101 - ),  
102 - )  
103 -  
104 - Log.i(  
105 - TAG,  
106 - "summary saved span=${WINDOW_MS / 60_000}min " +  
107 - "speech=${totalSpeechMs / 1000}s segments=${segments.size} " +  
108 - "tokens(prompt=${response.promptTokens}, completion=${response.completionTokens})",  
109 - )  
110 - return Result.success() 146 + return if (hasText) sb.toString() else null
111 } 147 }
112 } 148 }
  149 +
  150 +private val SYSTEM_PROMPT = """
  151 + 你是一个 lifelog 纪要助手。给定一次连续录音的语音转写片段(含时间戳和说话人),
  152 + 请输出一份简洁的 markdown 纪要,包含:
  153 + 1. 一句话主题概括
  154 + 2. 关键信息要点(bullet 列表,5-10 条)
  155 + 3. 如果有待办事项 / 决定 / 提到的人名地名,单独列「待办」「人物」「地点」三个小节
  156 + 重要:每一条「关键信息要点」和「待办」都必须在该行开头标注它对应的来源时间戳,
  157 + 格式为方括号 [HH:mm:ss](取自输入片段里最相关那条的时间),例如:
  158 + - [14:03:12] 讨论了 Q3 的预算分配
  159 + 这样用户点击该要点能跳回对应录音位置。时间戳必须真实来自输入,不要编造。
  160 + 纪要语言保持中文。不要复述全文,只摘核心。
  161 +""".trimIndent()