Commit 4b73a1503b2dda2b465039c19f2ef0b9d61dc04b
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。 - 纪要要点带 [HHss] 来源时间戳,点击跳回时间轴对应段并高亮(含跨午夜处理)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
8 changed files
with
1452 additions
and
430 deletions
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() | ... | ... |