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 13 import androidx.activity.compose.rememberLauncherForActivityResult
14 14 import androidx.activity.compose.setContent
15 15 import androidx.activity.result.contract.ActivityResultContracts
  16 +import androidx.compose.foundation.background
16 17 import androidx.compose.foundation.clickable
  18 +import androidx.compose.foundation.horizontalScroll
17 19 import androidx.compose.foundation.layout.Arrangement
  20 +import androidx.compose.foundation.layout.Box
18 21 import androidx.compose.foundation.layout.Column
19 22 import androidx.compose.foundation.layout.Row
20 23 import androidx.compose.foundation.layout.Spacer
21 24 import androidx.compose.foundation.layout.fillMaxSize
22 25 import androidx.compose.foundation.layout.fillMaxWidth
23 26 import androidx.compose.foundation.layout.height
24   -import androidx.compose.foundation.layout.heightIn
25 27 import androidx.compose.foundation.layout.padding
26 28 import androidx.compose.foundation.lazy.LazyColumn
27 29 import androidx.compose.foundation.lazy.items
  30 +import androidx.compose.foundation.lazy.rememberLazyListState
28 31 import androidx.compose.foundation.rememberScrollState
29   -import androidx.compose.foundation.text.KeyboardOptions
30 32 import androidx.compose.foundation.verticalScroll
31 33 import androidx.compose.material3.AlertDialog
32 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 38 import androidx.compose.material3.ExperimentalMaterial3Api
  39 +import androidx.compose.material3.FilterChip
34 40 import androidx.compose.material3.HorizontalDivider
35 41 import androidx.compose.material3.MaterialTheme
36 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 46 import androidx.compose.material3.OutlinedTextField
  47 +import androidx.compose.material3.RadioButton
  48 +import androidx.compose.material3.Scaffold
38 49 import androidx.compose.material3.Surface
39 50 import androidx.compose.material3.Text
40 51 import androidx.compose.material3.TextButton
41   -import androidx.compose.material3.TextField
42 52 import androidx.compose.material3.rememberModalBottomSheetState
43 53 import androidx.compose.runtime.Composable
44   -import androidx.compose.runtime.collectAsState
45 54 import androidx.compose.runtime.DisposableEffect
  55 +import androidx.compose.runtime.LaunchedEffect
  56 +import androidx.compose.runtime.collectAsState
46 57 import androidx.compose.runtime.getValue
  58 +import androidx.compose.runtime.mutableStateListOf
47 59 import androidx.compose.runtime.mutableStateOf
  60 +import androidx.compose.runtime.produceState
48 61 import androidx.compose.runtime.remember
49 62 import androidx.compose.runtime.rememberCoroutineScope
50 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 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 69 import androidx.compose.ui.unit.dp
56 70 import androidx.core.content.ContextCompat
  71 +import androidx.lifecycle.Lifecycle
  72 +import androidx.lifecycle.LifecycleEventObserver
  73 +import androidx.lifecycle.compose.LocalLifecycleOwner
57 74 import com.lemon.lifelog.asr.ModelDownloader
58 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 77 import com.lemon.lifelog.asr.XfyunIatClient
62   -import com.lemon.lifelog.asr.toFloatArrayLe
63   -import com.lemon.lifelog.asr.toLeBytes
64 78 import com.lemon.lifelog.asr.transcribeWithStats
65 79 import com.lemon.lifelog.audio.SegmentPlayer
66   -import com.lemon.lifelog.data.Summary
67   -import com.lemon.lifelog.summary.SummaryScheduler
68 80 import com.lemon.lifelog.data.Repository
69   -import com.lemon.lifelog.data.Segment
70 81 import com.lemon.lifelog.data.SegmentRow
71 82 import com.lemon.lifelog.data.SpeakerProfile
  83 +import com.lemon.lifelog.data.Summary
72 84 import com.lemon.lifelog.service.RecorderService
73 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 88 import java.text.SimpleDateFormat
  89 +import java.util.Calendar
77 90 import java.util.Date
78 91 import java.util.Locale
79 92 import kotlinx.coroutines.Dispatchers
... ... @@ -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 131 @Composable
99 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 195 val running by RecorderState.isRunning.collectAsState()
  196 + val paused by RecorderState.isPaused.collectAsState()
  197 + val status by RecorderState.status.collectAsState()
101 198 val segmentCount by RecorderState.segmentCount.collectAsState()
102 199 val lastAudioMs by RecorderState.lastAudioMs.collectAsState()
103 200 val lastWallMs by RecorderState.lastWallMs.collectAsState()
104   - val status by RecorderState.status.collectAsState()
  201 + val scope = rememberCoroutineScope()
105 202  
106 203 var asrStatus by remember { mutableStateOf("ASR: idle") }
107 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 207 var batteryExempt by remember { mutableStateOf(isBatteryOptExempt(activity)) }
133 208 val lifecycleOwner = LocalLifecycleOwner.current
134 209 DisposableEffect(lifecycleOwner) {
135 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 213 lifecycleOwner.lifecycle.addObserver(observer)
141 214 onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
142 215 }
143 216  
144 217 val permissionLauncher = rememberLauncherForActivityResult(
145   - ActivityResultContracts.RequestMultiplePermissions()
  218 + ActivityResultContracts.RequestMultiplePermissions(),
146 219 ) { results ->
147 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 228 ) {
159 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 240 val perms = buildList {
167 241 add(Manifest.permission.RECORD_AUDIO)
168 242 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
... ... @@ -170,44 +244,156 @@ private fun MainScreen() {
170 244 }
171 245 }.toTypedArray()
172 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 262 Text(
180   - if (batteryExempt) "✓ 已加入电池白名单"
181   - else "⚠ 未加入电池白名单(Doze 会关掉录音)",
  263 + "段数:$segmentCount" +
  264 + if (lastAudioMs > 0) " • 最新段 ${lastAudioMs}ms / ${lastWallMs}ms (${"%.1f".format(rtSpeed)}× realtime)" else "",
182 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 271 Text(
194   - "段数:$segmentCount • 最新段 audio=${lastAudioMs}ms wall=${lastWallMs}ms (${"%.1f".format(rtSpeed)}× realtime)",
  272 + if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会关掉录音)",
195 273 style = MaterialTheme.typography.bodySmall,
196 274 )
  275 + if (!batteryExempt) {
  276 + Button(onClick = { requestBatteryOptExemption(activity) }) { Text("申请豁免") }
  277 + }
197 278 }
198 279  
199 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 397 OutlinedTextField(
212 398 value = searchQuery,
213 399 onValueChange = { searchQuery = it },
... ... @@ -215,76 +401,118 @@ private fun MainScreen() {
215 401 placeholder = { Text("中文搜索…") },
216 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 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 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 516 actionRow?.let { row ->
289 517 SegmentActionSheet(
290 518 row = row,
... ... @@ -292,35 +520,94 @@ private fun MainScreen() {
292 520 onDismiss = { actionRow = null },
293 521 onPlay = {
294 522 if (nowPlayingId == row.id) {
295   - player.stop()
296   - nowPlayingId = null
  523 + player.stop(); nowPlayingId = null
297 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 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 600 enrollFor?.let { row ->
312 601 EnrollSpeakerDialog(
313 602 row = row,
314 603 existingNames = speakers.map { it.name },
315 604 onDismiss = { enrollFor = null },
316 605 onConfirm = { name ->
317   - val target = enrollFor
318 606 enrollFor = null
319   - if (target != null && name.isNotBlank()) {
  607 + if (name.isNotBlank()) {
320 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 617  
331 618 @OptIn(ExperimentalMaterial3Api::class)
332 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 950 private fun SegmentActionSheet(
334 951 row: SegmentRow,
335 952 isPlaying: Boolean,
336 953 onDismiss: () -> Unit,
337 954 onPlay: () -> Unit,
  955 + onRename: () -> Unit,
  956 + onReassign: () -> Unit,
  957 + onEditText: () -> Unit,
338 958 onEnroll: () -> Unit,
  959 + onDelete: () -> Unit,
339 960 ) {
340 961 val sheetState = rememberModalBottomSheetState()
341   - ModalBottomSheet(
342   - onDismissRequest = onDismiss,
343   - sheetState = sheetState,
344   - ) {
  962 + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
345 963 Column(
346   - modifier = Modifier.fillMaxWidth().padding(16.dp),
  964 + modifier = Modifier.fillMaxWidth().padding(16.dp).verticalScroll(rememberScrollState()),
347 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 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 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 1079 private fun EnrollSpeakerDialog(
381 1080 row: SegmentRow,
382 1081 existingNames: List<String>,
... ... @@ -386,83 +1085,124 @@ private fun EnrollSpeakerDialog(
386 1085 var name by remember { mutableStateOf("") }
387 1086 AlertDialog(
388 1087 onDismissRequest = onDismiss,
389   - title = { Text("记录说话人") },
  1088 + title = { Text("记为新说话人") },
390 1089 text = {
391 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 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 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 1193 withContext(Dispatchers.IO) {
435 1194 val seg = repo.segments.get(segmentId) ?: return@withContext
436 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 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 1201 private fun sanitizeFtsQuery(raw: String): String {
454 1202 val cleaned = raw.trim()
455   - .replace("\"", "")
456   - .replace("'", "")
457   - .replace("(", "")
458   - .replace(")", "")
459   - .replace(":", "")
  1203 + .replace("\"", "").replace("'", "").replace("(", "").replace(")", "").replace(":", "")
460 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 1208 private fun isBatteryOptExempt(ctx: Context): Boolean {
... ... @@ -479,88 +1219,30 @@ private fun requestBatteryOptExemption(ctx: Context) {
479 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 1223 val log = StringBuilder()
531 1224 onUpdate("paraformer: ensuring model…", log.toString())
532 1225 val downloader = ModelDownloader(ctx)
533 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 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 1231 log.appendLine("model: ${files.modelPath}")
544   -
545 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 1234 try {
550 1235 val pcm = withContext(Dispatchers.IO) {
551 1236 ctx.assets.open("sample.wav").use { ins ->
552 1237 ins.skip(44)
553 1238 val bytes = ins.readBytes()
554 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 1241 shorts
558 1242 }
559 1243 }
560 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 1246 log.appendLine("==== RESULT ====")
565 1247 log.appendLine(result.toString())
566 1248 onUpdate("paraformer: done ${"%.2f".format(1.0 / result.realtimeFactor)}× realtime", log.toString())
... ... @@ -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 1255 val started = System.currentTimeMillis()
577 1256 val client = XfyunIatClient()
578 1257 val session = client.connect()
579 1258 val log = StringBuilder()
580   -
581 1259 val collectJob: Job = kotlinx.coroutines.GlobalScope.launch(Dispatchers.Default) {
582 1260 session.flow.collect { event ->
583 1261 when (event) {
584 1262 is XfyunIatClient.Event.Partial -> {
585 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 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 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 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 1278 withContext(Dispatchers.IO) {
608 1279 ctx.assets.open("sample.wav").use { ins ->
609 1280 ins.skip(44)
610 1281 val buf = ByteArray(2560)
611 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 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 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 111 /** Average a list of embeddings into one (per-dim mean), normalised. */
103 112 fun averageEmbeddings(embeddings: List<FloatArray>): FloatArray {
104 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 16 val speakerName: String?,
17 17 )
18 18  
  19 +data class SpeakerCount(
  20 + val speakerId: Long,
  21 + val count: Int,
  22 +)
  23 +
19 24 @Dao
20 25 interface SegmentDao {
21 26 @Insert
... ... @@ -33,6 +38,17 @@ interface SegmentDao {
33 38 @Query("UPDATE segments SET speakerId = :speakerId, speakerSim = :sim WHERE id = :id")
34 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 53 * Backfill: re-assign speakerId for all segments that have a stored embedding,
38 54 * using the latest profile set. Called once after a new profile is enrolled.
... ... @@ -41,6 +57,35 @@ interface SegmentDao {
41 57 @Query("SELECT * FROM segments WHERE embedding IS NOT NULL")
42 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 89 @Query(
45 90 """
46 91 SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs,
... ... @@ -82,6 +127,13 @@ interface TranscriptDao {
82 127  
83 128 @Query("SELECT * FROM transcripts WHERE segmentId = :id")
84 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 139 @Dao
... ... @@ -91,6 +143,12 @@ interface SummaryDao {
91 143  
92 144 @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n")
93 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 154 @Dao
... ... @@ -104,6 +162,14 @@ interface SpeakerDao {
104 162 @Query("SELECT * FROM speaker_profiles ORDER BY createdAt ASC")
105 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 173 @Query("DELETE FROM speaker_profiles WHERE id = :id")
108 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 16 import com.lemon.lifelog.MainActivity
17 17 import com.lemon.lifelog.asr.ModelDownloader
18 18 import com.lemon.lifelog.asr.ParaformerEngine
  19 +import com.lemon.lifelog.asr.SpeakerClusterer
19 20 import com.lemon.lifelog.asr.SpeakerEmbedder
20   -import com.lemon.lifelog.asr.cosineSim
21   -import com.lemon.lifelog.asr.toFloatArrayLe
22 21 import com.lemon.lifelog.asr.toLeBytes
23 22 import com.lemon.lifelog.audio.AudioCapture
24 23 import com.lemon.lifelog.audio.SegmentWriter
... ... @@ -41,11 +40,26 @@ class RecorderService : Service() {
41 40 const val CHANNEL_ID = "recorder"
42 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 46 @Volatile
45 47 var isRunning: Boolean = false
46 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 63 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
50 64 private var pipelineJob: Job? = null
51 65 private var wakeLock: PowerManager.WakeLock? = null
... ... @@ -57,13 +71,8 @@ class RecorderService : Service() {
57 71 private var engine: ParaformerEngine? = null
58 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 77 override fun onCreate() {
69 78 super.onCreate()
... ... @@ -79,7 +88,40 @@ class RecorderService : Service() {
79 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 126 override fun onDestroy() {
85 127 pipelineJob?.cancel()
... ... @@ -159,16 +201,21 @@ class RecorderService : Service() {
159 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 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 220 capture.frames.collect { samples ->
174 221 val v = vad ?: return@collect
... ... @@ -203,7 +250,7 @@ class RecorderService : Service() {
203 250 }
204 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 254 val embedding: FloatArray? = speakerEmbedder?.let { embedder ->
208 255 try {
209 256 embedder.embed(samples, sr)
... ... @@ -212,22 +259,12 @@ class RecorderService : Service() {
212 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 269 // d. Room insert.
233 270 val segmentId = kotlinx.coroutines.withContext(Dispatchers.IO) {
... ... @@ -263,14 +300,6 @@ class RecorderService : Service() {
263 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 303 private fun startForegroundCompat() {
275 304 val notification = buildNotification()
276 305 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
... ... @@ -285,6 +314,7 @@ class RecorderService : Service() {
285 314 }
286 315  
287 316 private fun acquireWakeLock() {
  317 + if (wakeLock?.isHeld == true) return
288 318 val pm = getSystemService(PowerManager::class.java)
289 319 wakeLock = pm.newWakeLock(
290 320 PowerManager.PARTIAL_WAKE_LOCK,
... ... @@ -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 329 val pi = PendingIntent.getActivity(
300 330 this,
301 331 0,
... ... @@ -304,7 +334,7 @@ class RecorderService : Service() {
304 334 )
305 335 return NotificationCompat.Builder(this, CHANNEL_ID)
306 336 .setContentTitle("Lifelog")
307   - .setContentText("录音中")
  337 + .setContentText(text)
308 338 .setSmallIcon(android.R.drawable.presence_audio_online)
309 339 .setContentIntent(pi)
310 340 .setOngoing(true)
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt
... ... @@ -8,6 +8,10 @@ object RecorderState {
8 8 private val _isRunning = MutableStateFlow(false)
9 9 val isRunning: StateFlow<Boolean> = _isRunning
10 10  
  11 + /** 真暂停:服务存活、模型常驻,仅停止音频采集。 */
  12 + private val _isPaused = MutableStateFlow(false)
  13 + val isPaused: StateFlow<Boolean> = _isPaused
  14 +
11 15 private val _segmentCount = MutableStateFlow(0)
12 16 val segmentCount: StateFlow<Int> = _segmentCount
13 17  
... ... @@ -25,6 +29,11 @@ object RecorderState {
25 29  
26 30 fun setRunning(v: Boolean) {
27 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 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 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 39 val request = OneTimeWorkRequestBuilder<SummaryWorker>()
37 40 .setInputData(data)
38 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 6 import androidx.work.WorkerParameters
7 7 import com.lemon.lifelog.asr.HermesClient
8 8 import com.lemon.lifelog.data.Repository
  9 +import com.lemon.lifelog.data.Segment
9 10 import com.lemon.lifelog.data.Summary
10 11 import java.text.SimpleDateFormat
11 12 import java.util.Date
12 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 28 class SummaryWorker(
30 29 ctx: Context,
... ... @@ -34,79 +33,129 @@ class SummaryWorker(
34 33 companion object {
35 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 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 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 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 137 sb.appendLine()
67   - for (seg in segments) {
  138 + var hasText = false
  139 + for (seg in session) {
68 140 val transcript = repo.transcripts.get(seg.id)?.text ?: continue
69 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()
... ...