Commit 516ec8eedcd18b08a370348dd3d314aad75f2716
1 parent
8f611bff
feat(lifelog/diarize): 声纹↔人物打通 + 改派/合并面板可搜索可滚动
声纹本质是要识别成人物。桥接方案(保留声纹库,识别后处处显示人物): - 声纹 tab 每个说话人显示「已识别为人物 X / 未识别」,加「识别为人物」入口: IdentifySpeakerSheet 输名字→绑已有人物或新建人物(声纹↔人物强制 1:1) - 时间轴/段操作面板:声纹一旦识别为人物就显示 👤人物名(SegmentRow LEFT JOIN persons) - 自动聚类仍只产「说话人N」,确认后再升人物(不自动灌 CRM);重命名与识别为人物分开 - PersonDao getByVoiceSpeaker/clearVoiceSpeaker;bindVoice 也保 1:1 - 顺带:改派说话人 / 合并说话人 由 AlertDialog 改 ModalBottomSheet, 原来列表塞进对话框被挤得不能搜索/滚动、没确认按钮 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
3 changed files
with
239 additions
and
40 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
| @@ -86,6 +86,7 @@ import com.lemon.lifelog.chat.ChatConfig | @@ -86,6 +86,7 @@ import com.lemon.lifelog.chat.ChatConfig | ||
| 86 | import com.lemon.lifelog.summary.SummaryProgress | 86 | import com.lemon.lifelog.summary.SummaryProgress |
| 87 | import com.lemon.lifelog.summary.SummaryRunLog | 87 | import com.lemon.lifelog.summary.SummaryRunLog |
| 88 | import com.lemon.lifelog.data.BlockRow | 88 | import com.lemon.lifelog.data.BlockRow |
| 89 | +import com.lemon.lifelog.data.Person | ||
| 89 | import com.lemon.lifelog.data.Repository | 90 | import com.lemon.lifelog.data.Repository |
| 90 | import com.lemon.lifelog.data.SegmentRow | 91 | import com.lemon.lifelog.data.SegmentRow |
| 91 | import com.lemon.lifelog.data.SpeakerProfile | 92 | import com.lemon.lifelog.data.SpeakerProfile |
| @@ -843,6 +844,11 @@ private fun SpeakersTab( | @@ -843,6 +844,11 @@ private fun SpeakersTab( | ||
| 843 | val ctx = LocalContext.current | 844 | val ctx = LocalContext.current |
| 844 | val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList()) | 845 | val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList()) |
| 845 | val countMap = remember(counts) { counts.associate { it.speakerId to it.count } } | 846 | val countMap = remember(counts) { counts.associate { it.speakerId to it.count } } |
| 847 | + // 声纹↔人物桥:哪个声纹已被「识别为」某人物(persons.voiceSpeakerId)。 | ||
| 848 | + val persons by repo.persons.observeAll().collectAsState(initial = emptyList()) | ||
| 849 | + val personBySpeaker = remember(persons) { | ||
| 850 | + persons.filter { it.voiceSpeakerId != null }.associateBy { it.voiceSpeakerId!! } | ||
| 851 | + } | ||
| 846 | var reclustering by remember { mutableStateOf(false) } | 852 | var reclustering by remember { mutableStateOf(false) } |
| 847 | var reclusterMsg by remember { mutableStateOf("") } | 853 | var reclusterMsg by remember { mutableStateOf("") } |
| 848 | var clearConfirm by remember { mutableStateOf(false) } | 854 | var clearConfirm by remember { mutableStateOf(false) } |
| @@ -850,6 +856,28 @@ private fun SpeakersTab( | @@ -850,6 +856,28 @@ private fun SpeakersTab( | ||
| 850 | var renameFor by remember { mutableStateOf<SpeakerProfile?>(null) } | 856 | var renameFor by remember { mutableStateOf<SpeakerProfile?>(null) } |
| 851 | var mergeFor by remember { mutableStateOf<SpeakerProfile?>(null) } | 857 | var mergeFor by remember { mutableStateOf<SpeakerProfile?>(null) } |
| 852 | var deleteFor by remember { mutableStateOf<SpeakerProfile?>(null) } | 858 | var deleteFor by remember { mutableStateOf<SpeakerProfile?>(null) } |
| 859 | + var identifyFor by remember { mutableStateOf<SpeakerProfile?>(null) } | ||
| 860 | + | ||
| 861 | + // 把声纹 sp「识别为」已有人物 pid:先把该声纹从任何人物解开(保 1:1),再绑到 pid 并纳入管理。 | ||
| 862 | + fun identifyExisting(sp: SpeakerProfile, pid: Long) { | ||
| 863 | + scope.launch { | ||
| 864 | + withContext(Dispatchers.IO) { | ||
| 865 | + repo.persons.clearVoiceSpeaker(sp.id) | ||
| 866 | + repo.persons.linkVoice(pid, sp.id) | ||
| 867 | + repo.persons.setManaged(pid, true) | ||
| 868 | + repo.persons.touch(pid, System.currentTimeMillis()) | ||
| 869 | + } | ||
| 870 | + } | ||
| 871 | + } | ||
| 872 | + // 把声纹 sp 新建成一个人物(直接 managed)。 | ||
| 873 | + fun identifyNew(sp: SpeakerProfile, name: String) { | ||
| 874 | + scope.launch { | ||
| 875 | + withContext(Dispatchers.IO) { | ||
| 876 | + repo.persons.clearVoiceSpeaker(sp.id) | ||
| 877 | + repo.persons.insert(Person(name = name.trim(), voiceSpeakerId = sp.id, managed = true)) | ||
| 878 | + } | ||
| 879 | + } | ||
| 880 | + } | ||
| 853 | 881 | ||
| 854 | Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { | 882 | Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { |
| 855 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { | 883 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| @@ -881,7 +909,7 @@ private fun SpeakersTab( | @@ -881,7 +909,7 @@ private fun SpeakersTab( | ||
| 881 | Text(reclusterMsg, style = MaterialTheme.typography.bodySmall) | 909 | Text(reclusterMsg, style = MaterialTheme.typography.bodySmall) |
| 882 | } | 910 | } |
| 883 | Text( | 911 | Text( |
| 884 | - "自动发现的说话人会命名为「说话人N」。点击进入按 TA 筛选时间轴;可重命名 / 合并 / 删除。", | 912 | + "自动发现的说话人会命名为「说话人N」。点「识别为人物」把这把声音认成 CRM 里的人——之后时间轴/各处都显示人物名,TA 的录音自动归到人物档案。", |
| 885 | style = MaterialTheme.typography.bodySmall, | 913 | style = MaterialTheme.typography.bodySmall, |
| 886 | ) | 914 | ) |
| 887 | Spacer(Modifier.height(8.dp)) | 915 | Spacer(Modifier.height(8.dp)) |
| @@ -892,6 +920,7 @@ private fun SpeakersTab( | @@ -892,6 +920,7 @@ private fun SpeakersTab( | ||
| 892 | 920 | ||
| 893 | LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { | 921 | LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { |
| 894 | items(speakers, key = { it.id }) { sp -> | 922 | items(speakers, key = { it.id }) { sp -> |
| 923 | + val person = personBySpeaker[sp.id] | ||
| 895 | Card(modifier = Modifier.fillMaxWidth().clickable { onFilterBySpeaker(sp.id) }) { | 924 | Card(modifier = Modifier.fillMaxWidth().clickable { onFilterBySpeaker(sp.id) }) { |
| 896 | Column(modifier = Modifier.padding(12.dp)) { | 925 | Column(modifier = Modifier.padding(12.dp)) { |
| 897 | Text(sp.name, style = MaterialTheme.typography.titleSmall) | 926 | Text(sp.name, style = MaterialTheme.typography.titleSmall) |
| @@ -899,7 +928,15 @@ private fun SpeakersTab( | @@ -899,7 +928,15 @@ private fun SpeakersTab( | ||
| 899 | "归属 ${countMap[sp.id] ?: 0} 段 · 质心样本 ${sp.sampleCount}", | 928 | "归属 ${countMap[sp.id] ?: 0} 段 · 质心样本 ${sp.sampleCount}", |
| 900 | style = MaterialTheme.typography.labelSmall, | 929 | style = MaterialTheme.typography.labelSmall, |
| 901 | ) | 930 | ) |
| 931 | + Text( | ||
| 932 | + if (person != null) "✓ 已识别为人物:${person.name}" else "○ 未识别为人物", | ||
| 933 | + style = MaterialTheme.typography.labelMedium, | ||
| 934 | + color = if (person != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, | ||
| 935 | + ) | ||
| 902 | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { | 936 | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 937 | + TextButton(onClick = { identifyFor = sp }) { | ||
| 938 | + Text(if (person != null) "改识别" else "识别为人物") | ||
| 939 | + } | ||
| 903 | TextButton(onClick = { renameFor = sp }) { Text("重命名") } | 940 | TextButton(onClick = { renameFor = sp }) { Text("重命名") } |
| 904 | TextButton(onClick = { mergeFor = sp }) { Text("合并到…") } | 941 | TextButton(onClick = { mergeFor = sp }) { Text("合并到…") } |
| 905 | TextButton(onClick = { deleteFor = sp }) { Text("删除") } | 942 | TextButton(onClick = { deleteFor = sp }) { Text("删除") } |
| @@ -910,6 +947,21 @@ private fun SpeakersTab( | @@ -910,6 +947,21 @@ private fun SpeakersTab( | ||
| 910 | } | 947 | } |
| 911 | } | 948 | } |
| 912 | 949 | ||
| 950 | + identifyFor?.let { sp -> | ||
| 951 | + IdentifySpeakerSheet( | ||
| 952 | + speaker = sp, | ||
| 953 | + currentPerson = personBySpeaker[sp.id], | ||
| 954 | + persons = persons, | ||
| 955 | + onDismiss = { identifyFor = null }, | ||
| 956 | + onPickExisting = { pid -> identifyFor = null; identifyExisting(sp, pid) }, | ||
| 957 | + onCreateNew = { name -> identifyFor = null; identifyNew(sp, name) }, | ||
| 958 | + onUnlink = { | ||
| 959 | + identifyFor = null | ||
| 960 | + scope.launch { withContext(Dispatchers.IO) { repo.persons.clearVoiceSpeaker(sp.id) } } | ||
| 961 | + }, | ||
| 962 | + ) | ||
| 963 | + } | ||
| 964 | + | ||
| 913 | renameFor?.let { sp -> | 965 | renameFor?.let { sp -> |
| 914 | RenameSpeakerDialog( | 966 | RenameSpeakerDialog( |
| 915 | currentName = sp.name, | 967 | currentName = sp.name, |
| @@ -1259,8 +1311,9 @@ private fun SegmentActionSheet( | @@ -1259,8 +1311,9 @@ private fun SegmentActionSheet( | ||
| 1259 | ) { | 1311 | ) { |
| 1260 | Text("段 #${row.id} · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.titleMedium) | 1312 | Text("段 #${row.id} · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.titleMedium) |
| 1261 | row.text?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } | 1313 | row.text?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } |
| 1314 | + row.personName?.let { Text("👤 人物:$it", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) } | ||
| 1262 | row.speakerName?.let { name -> | 1315 | row.speakerName?.let { name -> |
| 1263 | - Text("已识别:$name (sim=${"%.2f".format(row.speakerSim ?: 0f)})", style = MaterialTheme.typography.labelMedium) | 1316 | + Text("声纹:$name (sim=${"%.2f".format(row.speakerSim ?: 0f)})", style = MaterialTheme.typography.labelMedium) |
| 1264 | } | 1317 | } |
| 1265 | Spacer(Modifier.height(4.dp)) | 1318 | Spacer(Modifier.height(4.dp)) |
| 1266 | Button(onClick = onPlay, modifier = Modifier.fillMaxWidth()) { Text(if (isPlaying) "■ 停止" else "▶ 播放原音") } | 1319 | Button(onClick = onPlay, modifier = Modifier.fillMaxWidth()) { Text(if (isPlaying) "■ 停止" else "▶ 播放原音") } |
| @@ -1296,38 +1349,135 @@ private fun RenameSpeakerDialog(currentName: String, onDismiss: () -> Unit, onCo | @@ -1296,38 +1349,135 @@ private fun RenameSpeakerDialog(currentName: String, onDismiss: () -> Unit, onCo | ||
| 1296 | } | 1349 | } |
| 1297 | 1350 | ||
| 1298 | @Composable | 1351 | @Composable |
| 1352 | +@OptIn(ExperimentalMaterial3Api::class) | ||
| 1299 | private fun ReassignSpeakerDialog( | 1353 | private fun ReassignSpeakerDialog( |
| 1300 | row: SegmentRow, | 1354 | row: SegmentRow, |
| 1301 | speakers: List<SpeakerProfile>, | 1355 | speakers: List<SpeakerProfile>, |
| 1302 | onDismiss: () -> Unit, | 1356 | onDismiss: () -> Unit, |
| 1303 | onPick: (Long?) -> Unit, | 1357 | onPick: (Long?) -> Unit, |
| 1304 | ) { | 1358 | ) { |
| 1305 | - AlertDialog( | ||
| 1306 | - onDismissRequest = onDismiss, | ||
| 1307 | - title = { Text("改派说话人") }, | ||
| 1308 | - text = { | ||
| 1309 | - Column { | ||
| 1310 | - Text("段 #${row.id} 当前:${row.speakerName ?: "未归属"}", style = MaterialTheme.typography.bodySmall) | ||
| 1311 | - Spacer(Modifier.height(8.dp)) | ||
| 1312 | - Row(modifier = Modifier.fillMaxWidth().clickable { onPick(null) }.padding(vertical = 8.dp), | ||
| 1313 | - verticalAlignment = Alignment.CenterVertically) { | ||
| 1314 | - RadioButton(selected = row.speakerId == null, onClick = { onPick(null) }) | ||
| 1315 | - Text("(清除归属)") | 1359 | + val sheetState = rememberModalBottomSheetState() |
| 1360 | + var query by remember { mutableStateOf("") } | ||
| 1361 | + val shown = remember(speakers, query) { | ||
| 1362 | + val q = query.trim() | ||
| 1363 | + if (q.isBlank()) speakers else speakers.filter { it.name.contains(q, true) } | ||
| 1364 | + } | ||
| 1365 | + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { | ||
| 1366 | + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 16.dp)) { | ||
| 1367 | + Text("改派说话人", style = MaterialTheme.typography.titleMedium) | ||
| 1368 | + Text("段 #${row.id} 当前:${row.speakerName ?: "未归属"}", style = MaterialTheme.typography.bodySmall) | ||
| 1369 | + Text("点名字即改派", style = MaterialTheme.typography.labelSmall) | ||
| 1370 | + Spacer(Modifier.height(8.dp)) | ||
| 1371 | + OutlinedTextField( | ||
| 1372 | + value = query, onValueChange = { query = it }, singleLine = true, | ||
| 1373 | + placeholder = { Text("搜索说话人(共 ${speakers.size})") }, | ||
| 1374 | + modifier = Modifier.fillMaxWidth(), | ||
| 1375 | + ) | ||
| 1376 | + Spacer(Modifier.height(8.dp)) | ||
| 1377 | + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp)) { | ||
| 1378 | + item { | ||
| 1379 | + Row( | ||
| 1380 | + modifier = Modifier.fillMaxWidth().clickable { onPick(null) }.padding(vertical = 10.dp), | ||
| 1381 | + verticalAlignment = Alignment.CenterVertically, | ||
| 1382 | + ) { | ||
| 1383 | + RadioButton(selected = row.speakerId == null, onClick = { onPick(null) }) | ||
| 1384 | + Text("(清除归属)") | ||
| 1385 | + } | ||
| 1316 | } | 1386 | } |
| 1317 | - speakers.forEach { sp -> | ||
| 1318 | - Row(modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 8.dp), | ||
| 1319 | - verticalAlignment = Alignment.CenterVertically) { | 1387 | + items(shown, key = { it.id }) { sp -> |
| 1388 | + Row( | ||
| 1389 | + modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 10.dp), | ||
| 1390 | + verticalAlignment = Alignment.CenterVertically, | ||
| 1391 | + ) { | ||
| 1320 | RadioButton(selected = row.speakerId == sp.id, onClick = { onPick(sp.id) }) | 1392 | RadioButton(selected = row.speakerId == sp.id, onClick = { onPick(sp.id) }) |
| 1321 | Text(sp.name) | 1393 | Text(sp.name) |
| 1322 | } | 1394 | } |
| 1323 | } | 1395 | } |
| 1324 | } | 1396 | } |
| 1325 | - }, | ||
| 1326 | - confirmButton = {}, | ||
| 1327 | - dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, | ||
| 1328 | - ) | 1397 | + } |
| 1398 | + } | ||
| 1329 | } | 1399 | } |
| 1330 | 1400 | ||
| 1401 | +/** | ||
| 1402 | + * 「识别为人物」底部面板:把一个声纹认成 CRM 里的人物。 | ||
| 1403 | + * 输入框既是搜索、也是新建名字:输名字→点已有匹配项绑定,或点「➕ 新建人物」按这名字建。 | ||
| 1404 | + */ | ||
| 1405 | +@OptIn(ExperimentalMaterial3Api::class) | ||
| 1406 | +@Composable | ||
| 1407 | +private fun IdentifySpeakerSheet( | ||
| 1408 | + speaker: SpeakerProfile, | ||
| 1409 | + currentPerson: Person?, | ||
| 1410 | + persons: List<Person>, | ||
| 1411 | + onDismiss: () -> Unit, | ||
| 1412 | + onPickExisting: (Long) -> Unit, | ||
| 1413 | + onCreateNew: (String) -> Unit, | ||
| 1414 | + onUnlink: () -> Unit, | ||
| 1415 | +) { | ||
| 1416 | + val sheetState = rememberModalBottomSheetState() | ||
| 1417 | + var query by remember { mutableStateOf("") } | ||
| 1418 | + val q = query.trim() | ||
| 1419 | + val shown = remember(persons, q) { | ||
| 1420 | + if (q.isBlank()) persons | ||
| 1421 | + else persons.filter { | ||
| 1422 | + it.name.contains(q, true) || it.nickname?.contains(q, true) == true || | ||
| 1423 | + it.remark?.contains(q, true) == true || it.alias?.contains(q, true) == true || | ||
| 1424 | + it.pinyin?.contains(q, true) == true | ||
| 1425 | + } | ||
| 1426 | + } | ||
| 1427 | + val newName = q.ifBlank { speaker.name } | ||
| 1428 | + val exactExists = persons.any { it.name.equals(newName, true) } | ||
| 1429 | + | ||
| 1430 | + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { | ||
| 1431 | + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 16.dp)) { | ||
| 1432 | + Text("把「${speaker.name}」识别为人物", style = MaterialTheme.typography.titleMedium) | ||
| 1433 | + currentPerson?.let { | ||
| 1434 | + Text("当前已识别为:${it.name}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) | ||
| 1435 | + } | ||
| 1436 | + Text("输入名字搜索已有人物,或新建一个", style = MaterialTheme.typography.labelSmall) | ||
| 1437 | + Spacer(Modifier.height(8.dp)) | ||
| 1438 | + OutlinedTextField( | ||
| 1439 | + value = query, onValueChange = { query = it }, singleLine = true, | ||
| 1440 | + placeholder = { Text("搜索 / 新人物名字") }, modifier = Modifier.fillMaxWidth(), | ||
| 1441 | + ) | ||
| 1442 | + Spacer(Modifier.height(8.dp)) | ||
| 1443 | + if (!exactExists && newName.isNotBlank()) { | ||
| 1444 | + Row( | ||
| 1445 | + modifier = Modifier.fillMaxWidth().clickable { onCreateNew(newName) }.padding(vertical = 12.dp), | ||
| 1446 | + verticalAlignment = Alignment.CenterVertically, | ||
| 1447 | + ) { | ||
| 1448 | + Text("➕ 新建人物「$newName」并绑定", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary) | ||
| 1449 | + } | ||
| 1450 | + HorizontalDivider() | ||
| 1451 | + } | ||
| 1452 | + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp)) { | ||
| 1453 | + items(shown, key = { it.id }) { p -> | ||
| 1454 | + Row( | ||
| 1455 | + modifier = Modifier.fillMaxWidth().clickable { onPickExisting(p.id) }.padding(vertical = 10.dp), | ||
| 1456 | + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), | ||
| 1457 | + ) { | ||
| 1458 | + Column(Modifier.weight(1f)) { | ||
| 1459 | + Text(p.name, style = MaterialTheme.typography.bodyLarge) | ||
| 1460 | + val src = when { | ||
| 1461 | + p.contactRef?.startsWith("wechat:") == true -> "微信" | ||
| 1462 | + p.contactRef?.startsWith("phone:") == true -> "手机" | ||
| 1463 | + else -> null | ||
| 1464 | + } | ||
| 1465 | + val sub = listOfNotNull(src, p.voiceSpeakerId?.let { "已绑声纹" }).joinToString(" · ") | ||
| 1466 | + if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall) | ||
| 1467 | + } | ||
| 1468 | + if (p.voiceSpeakerId == speaker.id) Text("✓", color = MaterialTheme.colorScheme.primary) | ||
| 1469 | + } | ||
| 1470 | + } | ||
| 1471 | + } | ||
| 1472 | + if (currentPerson != null) { | ||
| 1473 | + Spacer(Modifier.height(8.dp)) | ||
| 1474 | + TextButton(onClick = onUnlink) { Text("取消识别(解绑人物)") } | ||
| 1475 | + } | ||
| 1476 | + } | ||
| 1477 | + } | ||
| 1478 | +} | ||
| 1479 | + | ||
| 1480 | +@OptIn(ExperimentalMaterial3Api::class) | ||
| 1331 | @Composable | 1481 | @Composable |
| 1332 | private fun MergeSpeakerDialog( | 1482 | private fun MergeSpeakerDialog( |
| 1333 | from: SpeakerProfile, | 1483 | from: SpeakerProfile, |
| @@ -1335,23 +1485,39 @@ private fun MergeSpeakerDialog( | @@ -1335,23 +1485,39 @@ private fun MergeSpeakerDialog( | ||
| 1335 | onDismiss: () -> Unit, | 1485 | onDismiss: () -> Unit, |
| 1336 | onPick: (Long) -> Unit, | 1486 | onPick: (Long) -> Unit, |
| 1337 | ) { | 1487 | ) { |
| 1338 | - AlertDialog( | ||
| 1339 | - onDismissRequest = onDismiss, | ||
| 1340 | - title = { Text("把「${from.name}」合并到…") }, | ||
| 1341 | - text = { | ||
| 1342 | - Column { | ||
| 1343 | - if (others.isEmpty()) Text("没有其它说话人可合并。") | ||
| 1344 | - others.forEach { sp -> | ||
| 1345 | - Row(modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 10.dp), | ||
| 1346 | - verticalAlignment = Alignment.CenterVertically) { | ||
| 1347 | - Text(sp.name) | 1488 | + val sheetState = rememberModalBottomSheetState() |
| 1489 | + var query by remember { mutableStateOf("") } | ||
| 1490 | + val shown = remember(others, query) { | ||
| 1491 | + val q = query.trim() | ||
| 1492 | + if (q.isBlank()) others else others.filter { it.name.contains(q, true) } | ||
| 1493 | + } | ||
| 1494 | + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { | ||
| 1495 | + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 16.dp)) { | ||
| 1496 | + Text("把「${from.name}」合并到…", style = MaterialTheme.typography.titleMedium) | ||
| 1497 | + Text("点名字即合并", style = MaterialTheme.typography.labelSmall) | ||
| 1498 | + Spacer(Modifier.height(8.dp)) | ||
| 1499 | + if (others.isEmpty()) { | ||
| 1500 | + Text("没有其它说话人可合并。", style = MaterialTheme.typography.bodyMedium) | ||
| 1501 | + } else { | ||
| 1502 | + OutlinedTextField( | ||
| 1503 | + value = query, onValueChange = { query = it }, singleLine = true, | ||
| 1504 | + placeholder = { Text("搜索说话人(共 ${others.size})") }, | ||
| 1505 | + modifier = Modifier.fillMaxWidth(), | ||
| 1506 | + ) | ||
| 1507 | + Spacer(Modifier.height(8.dp)) | ||
| 1508 | + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp)) { | ||
| 1509 | + items(shown, key = { it.id }) { sp -> | ||
| 1510 | + Row( | ||
| 1511 | + modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 12.dp), | ||
| 1512 | + verticalAlignment = Alignment.CenterVertically, | ||
| 1513 | + ) { | ||
| 1514 | + Text(sp.name, style = MaterialTheme.typography.bodyLarge) | ||
| 1515 | + } | ||
| 1348 | } | 1516 | } |
| 1349 | } | 1517 | } |
| 1350 | } | 1518 | } |
| 1351 | - }, | ||
| 1352 | - confirmButton = {}, | ||
| 1353 | - dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, | ||
| 1354 | - ) | 1519 | + } |
| 1520 | + } | ||
| 1355 | } | 1521 | } |
| 1356 | 1522 | ||
| 1357 | @Composable | 1523 | @Composable |
| @@ -1426,7 +1592,9 @@ private fun TimelineRow( | @@ -1426,7 +1592,9 @@ private fun TimelineRow( | ||
| 1426 | onPlay: (() -> Unit)? = null, | 1592 | onPlay: (() -> Unit)? = null, |
| 1427 | ) { | 1593 | ) { |
| 1428 | val time = TIMELINE_FMT.format(Date(row.startTs)) | 1594 | val time = TIMELINE_FMT.format(Date(row.startTs)) |
| 1595 | + // 已「识别为人物」的声纹,时间轴直接显示人物名(带👤),否则退回说话人名/相似度。 | ||
| 1429 | val speakerLabel = when { | 1596 | val speakerLabel = when { |
| 1597 | + row.personName != null -> "👤 ${row.personName}" | ||
| 1430 | row.speakerName != null && row.speakerSim != null -> "${row.speakerName} (${"%.2f".format(row.speakerSim)})" | 1598 | row.speakerName != null && row.speakerSim != null -> "${row.speakerName} (${"%.2f".format(row.speakerSim)})" |
| 1431 | row.speakerName != null -> row.speakerName | 1599 | row.speakerName != null -> row.speakerName |
| 1432 | row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})" | 1600 | row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})" |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| @@ -249,10 +249,13 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -249,10 +249,13 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 249 | /** 融合:把一个声纹绑到联系人(null = 解绑)。绑定后档案页"录音列表"才有内容。 */ | 249 | /** 融合:把一个声纹绑到联系人(null = 解绑)。绑定后档案页"录音列表"才有内容。 */ |
| 250 | fun bindVoice(personId: Long, speakerId: Long?) { | 250 | fun bindVoice(personId: Long, speakerId: Long?) { |
| 251 | viewModelScope.launch { | 251 | viewModelScope.launch { |
| 252 | - repo.persons.linkVoice(personId, speakerId) | ||
| 253 | if (speakerId != null) { | 252 | if (speakerId != null) { |
| 253 | + repo.persons.clearVoiceSpeaker(speakerId) // 保声纹↔人物 1:1:先从别的人物解开 | ||
| 254 | + repo.persons.linkVoice(personId, speakerId) | ||
| 254 | repo.persons.touch(personId, System.currentTimeMillis()) | 255 | repo.persons.touch(personId, System.currentTimeMillis()) |
| 255 | repo.persons.setManaged(personId, true) // 绑了声纹 = 我关心 → 纳入管理 | 256 | repo.persons.setManaged(personId, true) // 绑了声纹 = 我关心 → 纳入管理 |
| 257 | + } else { | ||
| 258 | + repo.persons.linkVoice(personId, null) | ||
| 256 | } | 259 | } |
| 257 | } | 260 | } |
| 258 | } | 261 | } |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| @@ -10,11 +10,16 @@ data class SegmentRow( | @@ -10,11 +10,16 @@ data class SegmentRow( | ||
| 10 | val id: Long, | 10 | val id: Long, |
| 11 | val startTs: Long, | 11 | val startTs: Long, |
| 12 | val durationMs: Long, | 12 | val durationMs: Long, |
| 13 | + /** 该 turn 在「块完整录音」(audioPath) 内的起始偏移(ms)。播放/切片必须带它,否则播整块对不上文字。 */ | ||
| 14 | + val audioStartMs: Long = 0, | ||
| 13 | val text: String?, | 15 | val text: String?, |
| 14 | val audioPath: String, | 16 | val audioPath: String, |
| 15 | val speakerId: Long?, | 17 | val speakerId: Long?, |
| 16 | val speakerSim: Float?, | 18 | val speakerSim: Float?, |
| 17 | val speakerName: String?, | 19 | val speakerName: String?, |
| 20 | + /** 该声纹已「识别为」的人物(persons.voiceSpeakerId = speakerId);null = 还没认成人物。 */ | ||
| 21 | + val personId: Long? = null, | ||
| 22 | + val personName: String? = null, | ||
| 18 | ) | 23 | ) |
| 19 | 24 | ||
| 20 | data class SpeakerCount( | 25 | data class SpeakerCount( |
| @@ -122,12 +127,15 @@ interface SegmentDao { | @@ -122,12 +127,15 @@ interface SegmentDao { | ||
| 122 | @Query( | 127 | @Query( |
| 123 | """ | 128 | """ |
| 124 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, | 129 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, |
| 130 | + s.audioStartMs AS audioStartMs, | ||
| 125 | t.text AS text, s.audioPath AS audioPath, | 131 | t.text AS text, s.audioPath AS audioPath, |
| 126 | s.speakerId AS speakerId, s.speakerSim AS speakerSim, | 132 | s.speakerId AS speakerId, s.speakerSim AS speakerSim, |
| 127 | - sp.name AS speakerName | 133 | + sp.name AS speakerName, |
| 134 | + p.id AS personId, p.name AS personName | ||
| 128 | FROM segments s | 135 | FROM segments s |
| 129 | LEFT JOIN transcripts t ON t.segmentId = s.id | 136 | LEFT JOIN transcripts t ON t.segmentId = s.id |
| 130 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId | 137 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId |
| 138 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | ||
| 131 | ORDER BY s.startTs DESC | 139 | ORDER BY s.startTs DESC |
| 132 | LIMIT :limit | 140 | LIMIT :limit |
| 133 | """, | 141 | """, |
| @@ -160,11 +168,14 @@ interface SegmentDao { | @@ -160,11 +168,14 @@ interface SegmentDao { | ||
| 160 | @Query( | 168 | @Query( |
| 161 | """ | 169 | """ |
| 162 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, | 170 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, |
| 171 | + s.audioStartMs AS audioStartMs, | ||
| 163 | t.text AS text, s.audioPath AS audioPath, | 172 | t.text AS text, s.audioPath AS audioPath, |
| 164 | - s.speakerId AS speakerId, s.speakerSim AS speakerSim, sp.name AS speakerName | 173 | + s.speakerId AS speakerId, s.speakerSim AS speakerSim, sp.name AS speakerName, |
| 174 | + p.id AS personId, p.name AS personName | ||
| 165 | FROM segments s | 175 | FROM segments s |
| 166 | LEFT JOIN transcripts t ON t.segmentId = s.id | 176 | LEFT JOIN transcripts t ON t.segmentId = s.id |
| 167 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId | 177 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId |
| 178 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | ||
| 168 | WHERE s.speakerId = :speakerId | 179 | WHERE s.speakerId = :speakerId |
| 169 | ORDER BY s.startTs DESC | 180 | ORDER BY s.startTs DESC |
| 170 | LIMIT :limit | 181 | LIMIT :limit |
| @@ -176,12 +187,15 @@ interface SegmentDao { | @@ -176,12 +187,15 @@ interface SegmentDao { | ||
| 176 | @Query( | 187 | @Query( |
| 177 | """ | 188 | """ |
| 178 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, | 189 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, |
| 190 | + s.audioStartMs AS audioStartMs, | ||
| 179 | t.text AS text, s.audioPath AS audioPath, | 191 | t.text AS text, s.audioPath AS audioPath, |
| 180 | s.speakerId AS speakerId, s.speakerSim AS speakerSim, | 192 | s.speakerId AS speakerId, s.speakerSim AS speakerSim, |
| 181 | - sp.name AS speakerName | 193 | + sp.name AS speakerName, |
| 194 | + p.id AS personId, p.name AS personName | ||
| 182 | FROM segments s | 195 | FROM segments s |
| 183 | LEFT JOIN transcripts t ON t.segmentId = s.id | 196 | LEFT JOIN transcripts t ON t.segmentId = s.id |
| 184 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId | 197 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId |
| 198 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | ||
| 185 | ORDER BY s.startTs DESC | 199 | ORDER BY s.startTs DESC |
| 186 | LIMIT :limit | 200 | LIMIT :limit |
| 187 | """, | 201 | """, |
| @@ -196,12 +210,15 @@ interface SegmentDao { | @@ -196,12 +210,15 @@ interface SegmentDao { | ||
| 196 | @Query( | 210 | @Query( |
| 197 | """ | 211 | """ |
| 198 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, | 212 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, |
| 213 | + s.audioStartMs AS audioStartMs, | ||
| 199 | t.text AS text, s.audioPath AS audioPath, | 214 | t.text AS text, s.audioPath AS audioPath, |
| 200 | s.speakerId AS speakerId, s.speakerSim AS speakerSim, | 215 | s.speakerId AS speakerId, s.speakerSim AS speakerSim, |
| 201 | - sp.name AS speakerName | 216 | + sp.name AS speakerName, |
| 217 | + p.id AS personId, p.name AS personName | ||
| 202 | FROM segments s | 218 | FROM segments s |
| 203 | INNER JOIN transcripts t ON t.segmentId = s.id | 219 | INNER JOIN transcripts t ON t.segmentId = s.id |
| 204 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId | 220 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId |
| 221 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | ||
| 205 | WHERE t.text LIKE '%' || :kw || '%' | 222 | WHERE t.text LIKE '%' || :kw || '%' |
| 206 | ORDER BY s.startTs DESC | 223 | ORDER BY s.startTs DESC |
| 207 | LIMIT :limit | 224 | LIMIT :limit |
| @@ -213,13 +230,16 @@ interface SegmentDao { | @@ -213,13 +230,16 @@ interface SegmentDao { | ||
| 213 | @Query( | 230 | @Query( |
| 214 | """ | 231 | """ |
| 215 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, | 232 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, |
| 233 | + s.audioStartMs AS audioStartMs, | ||
| 216 | t.text AS text, s.audioPath AS audioPath, | 234 | t.text AS text, s.audioPath AS audioPath, |
| 217 | s.speakerId AS speakerId, s.speakerSim AS speakerSim, | 235 | s.speakerId AS speakerId, s.speakerSim AS speakerSim, |
| 218 | - sp.name AS speakerName | 236 | + sp.name AS speakerName, |
| 237 | + p.id AS personId, p.name AS personName | ||
| 219 | FROM segments s | 238 | FROM segments s |
| 220 | INNER JOIN transcripts t ON t.segmentId = s.id | 239 | INNER JOIN transcripts t ON t.segmentId = s.id |
| 221 | INNER JOIN transcript_fts fts ON fts.docid = t.segmentId | 240 | INNER JOIN transcript_fts fts ON fts.docid = t.segmentId |
| 222 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId | 241 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId |
| 242 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | ||
| 223 | WHERE transcript_fts MATCH :query | 243 | WHERE transcript_fts MATCH :query |
| 224 | ORDER BY s.startTs DESC | 244 | ORDER BY s.startTs DESC |
| 225 | LIMIT :limit | 245 | LIMIT :limit |
| @@ -357,6 +377,14 @@ interface PersonDao { | @@ -357,6 +377,14 @@ interface PersonDao { | ||
| 357 | @Query("UPDATE persons SET voiceSpeakerId = :speakerId WHERE id = :id") | 377 | @Query("UPDATE persons SET voiceSpeakerId = :speakerId WHERE id = :id") |
| 358 | suspend fun linkVoice(id: Long, speakerId: Long?) | 378 | suspend fun linkVoice(id: Long, speakerId: Long?) |
| 359 | 379 | ||
| 380 | + /** 反查:哪个人物已「识别为」这个声纹(声纹↔人物 1:1)。 */ | ||
| 381 | + @Query("SELECT * FROM persons WHERE voiceSpeakerId = :speakerId LIMIT 1") | ||
| 382 | + suspend fun getByVoiceSpeaker(speakerId: Long): Person? | ||
| 383 | + | ||
| 384 | + /** 把某声纹从任何已绑它的人物上解开(重新识别为另一人前先调,保 1:1)。 */ | ||
| 385 | + @Query("UPDATE persons SET voiceSpeakerId = NULL WHERE voiceSpeakerId = :speakerId") | ||
| 386 | + suspend fun clearVoiceSpeaker(speakerId: Long) | ||
| 387 | + | ||
| 360 | @Query("DELETE FROM persons WHERE id = :id") | 388 | @Query("DELETE FROM persons WHERE id = :id") |
| 361 | suspend fun delete(id: Long) | 389 | suspend fun delete(id: Long) |
| 362 | } | 390 | } |