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