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 | 86 | import com.lemon.lifelog.summary.SummaryProgress |
| 87 | 87 | import com.lemon.lifelog.summary.SummaryRunLog |
| 88 | 88 | import com.lemon.lifelog.data.BlockRow |
| 89 | +import com.lemon.lifelog.data.Person | |
| 89 | 90 | import com.lemon.lifelog.data.Repository |
| 90 | 91 | import com.lemon.lifelog.data.SegmentRow |
| 91 | 92 | import com.lemon.lifelog.data.SpeakerProfile |
| ... | ... | @@ -843,6 +844,11 @@ private fun SpeakersTab( |
| 843 | 844 | val ctx = LocalContext.current |
| 844 | 845 | val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList()) |
| 845 | 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 | 852 | var reclustering by remember { mutableStateOf(false) } |
| 847 | 853 | var reclusterMsg by remember { mutableStateOf("") } |
| 848 | 854 | var clearConfirm by remember { mutableStateOf(false) } |
| ... | ... | @@ -850,6 +856,28 @@ private fun SpeakersTab( |
| 850 | 856 | var renameFor by remember { mutableStateOf<SpeakerProfile?>(null) } |
| 851 | 857 | var mergeFor by remember { mutableStateOf<SpeakerProfile?>(null) } |
| 852 | 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 | 882 | Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { |
| 855 | 883 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| ... | ... | @@ -881,7 +909,7 @@ private fun SpeakersTab( |
| 881 | 909 | Text(reclusterMsg, style = MaterialTheme.typography.bodySmall) |
| 882 | 910 | } |
| 883 | 911 | Text( |
| 884 | - "自动发现的说话人会命名为「说话人N」。点击进入按 TA 筛选时间轴;可重命名 / 合并 / 删除。", | |
| 912 | + "自动发现的说话人会命名为「说话人N」。点「识别为人物」把这把声音认成 CRM 里的人——之后时间轴/各处都显示人物名,TA 的录音自动归到人物档案。", | |
| 885 | 913 | style = MaterialTheme.typography.bodySmall, |
| 886 | 914 | ) |
| 887 | 915 | Spacer(Modifier.height(8.dp)) |
| ... | ... | @@ -892,6 +920,7 @@ private fun SpeakersTab( |
| 892 | 920 | |
| 893 | 921 | LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { |
| 894 | 922 | items(speakers, key = { it.id }) { sp -> |
| 923 | + val person = personBySpeaker[sp.id] | |
| 895 | 924 | Card(modifier = Modifier.fillMaxWidth().clickable { onFilterBySpeaker(sp.id) }) { |
| 896 | 925 | Column(modifier = Modifier.padding(12.dp)) { |
| 897 | 926 | Text(sp.name, style = MaterialTheme.typography.titleSmall) |
| ... | ... | @@ -899,7 +928,15 @@ private fun SpeakersTab( |
| 899 | 928 | "归属 ${countMap[sp.id] ?: 0} 段 · 质心样本 ${sp.sampleCount}", |
| 900 | 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 | 936 | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
| 937 | + TextButton(onClick = { identifyFor = sp }) { | |
| 938 | + Text(if (person != null) "改识别" else "识别为人物") | |
| 939 | + } | |
| 903 | 940 | TextButton(onClick = { renameFor = sp }) { Text("重命名") } |
| 904 | 941 | TextButton(onClick = { mergeFor = sp }) { Text("合并到…") } |
| 905 | 942 | TextButton(onClick = { deleteFor = sp }) { Text("删除") } |
| ... | ... | @@ -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 | 965 | renameFor?.let { sp -> |
| 914 | 966 | RenameSpeakerDialog( |
| 915 | 967 | currentName = sp.name, |
| ... | ... | @@ -1259,8 +1311,9 @@ private fun SegmentActionSheet( |
| 1259 | 1311 | ) { |
| 1260 | 1312 | Text("段 #${row.id} · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.titleMedium) |
| 1261 | 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 | 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 | 1318 | Spacer(Modifier.height(4.dp)) |
| 1266 | 1319 | Button(onClick = onPlay, modifier = Modifier.fillMaxWidth()) { Text(if (isPlaying) "■ 停止" else "▶ 播放原音") } |
| ... | ... | @@ -1296,38 +1349,135 @@ private fun RenameSpeakerDialog(currentName: String, onDismiss: () -> Unit, onCo |
| 1296 | 1349 | } |
| 1297 | 1350 | |
| 1298 | 1351 | @Composable |
| 1352 | +@OptIn(ExperimentalMaterial3Api::class) | |
| 1299 | 1353 | private fun ReassignSpeakerDialog( |
| 1300 | 1354 | row: SegmentRow, |
| 1301 | 1355 | speakers: List<SpeakerProfile>, |
| 1302 | 1356 | onDismiss: () -> Unit, |
| 1303 | 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 | 1392 | RadioButton(selected = row.speakerId == sp.id, onClick = { onPick(sp.id) }) |
| 1321 | 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 | 1481 | @Composable |
| 1332 | 1482 | private fun MergeSpeakerDialog( |
| 1333 | 1483 | from: SpeakerProfile, |
| ... | ... | @@ -1335,23 +1485,39 @@ private fun MergeSpeakerDialog( |
| 1335 | 1485 | onDismiss: () -> Unit, |
| 1336 | 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 | 1523 | @Composable |
| ... | ... | @@ -1426,7 +1592,9 @@ private fun TimelineRow( |
| 1426 | 1592 | onPlay: (() -> Unit)? = null, |
| 1427 | 1593 | ) { |
| 1428 | 1594 | val time = TIMELINE_FMT.format(Date(row.startTs)) |
| 1595 | + // 已「识别为人物」的声纹,时间轴直接显示人物名(带👤),否则退回说话人名/相似度。 | |
| 1429 | 1596 | val speakerLabel = when { |
| 1597 | + row.personName != null -> "👤 ${row.personName}" | |
| 1430 | 1598 | row.speakerName != null && row.speakerSim != null -> "${row.speakerName} (${"%.2f".format(row.speakerSim)})" |
| 1431 | 1599 | row.speakerName != null -> row.speakerName |
| 1432 | 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 | 249 | /** 融合:把一个声纹绑到联系人(null = 解绑)。绑定后档案页"录音列表"才有内容。 */ |
| 250 | 250 | fun bindVoice(personId: Long, speakerId: Long?) { |
| 251 | 251 | viewModelScope.launch { |
| 252 | - repo.persons.linkVoice(personId, speakerId) | |
| 253 | 252 | if (speakerId != null) { |
| 253 | + repo.persons.clearVoiceSpeaker(speakerId) // 保声纹↔人物 1:1:先从别的人物解开 | |
| 254 | + repo.persons.linkVoice(personId, speakerId) | |
| 254 | 255 | repo.persons.touch(personId, System.currentTimeMillis()) |
| 255 | 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 | 10 | val id: Long, |
| 11 | 11 | val startTs: Long, |
| 12 | 12 | val durationMs: Long, |
| 13 | + /** 该 turn 在「块完整录音」(audioPath) 内的起始偏移(ms)。播放/切片必须带它,否则播整块对不上文字。 */ | |
| 14 | + val audioStartMs: Long = 0, | |
| 13 | 15 | val text: String?, |
| 14 | 16 | val audioPath: String, |
| 15 | 17 | val speakerId: Long?, |
| 16 | 18 | val speakerSim: Float?, |
| 17 | 19 | val speakerName: String?, |
| 20 | + /** 该声纹已「识别为」的人物(persons.voiceSpeakerId = speakerId);null = 还没认成人物。 */ | |
| 21 | + val personId: Long? = null, | |
| 22 | + val personName: String? = null, | |
| 18 | 23 | ) |
| 19 | 24 | |
| 20 | 25 | data class SpeakerCount( |
| ... | ... | @@ -122,12 +127,15 @@ interface SegmentDao { |
| 122 | 127 | @Query( |
| 123 | 128 | """ |
| 124 | 129 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, |
| 130 | + s.audioStartMs AS audioStartMs, | |
| 125 | 131 | t.text AS text, s.audioPath AS audioPath, |
| 126 | 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 | 135 | FROM segments s |
| 129 | 136 | LEFT JOIN transcripts t ON t.segmentId = s.id |
| 130 | 137 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId |
| 138 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | |
| 131 | 139 | ORDER BY s.startTs DESC |
| 132 | 140 | LIMIT :limit |
| 133 | 141 | """, |
| ... | ... | @@ -160,11 +168,14 @@ interface SegmentDao { |
| 160 | 168 | @Query( |
| 161 | 169 | """ |
| 162 | 170 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, |
| 171 | + s.audioStartMs AS audioStartMs, | |
| 163 | 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 | 175 | FROM segments s |
| 166 | 176 | LEFT JOIN transcripts t ON t.segmentId = s.id |
| 167 | 177 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId |
| 178 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | |
| 168 | 179 | WHERE s.speakerId = :speakerId |
| 169 | 180 | ORDER BY s.startTs DESC |
| 170 | 181 | LIMIT :limit |
| ... | ... | @@ -176,12 +187,15 @@ interface SegmentDao { |
| 176 | 187 | @Query( |
| 177 | 188 | """ |
| 178 | 189 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, |
| 190 | + s.audioStartMs AS audioStartMs, | |
| 179 | 191 | t.text AS text, s.audioPath AS audioPath, |
| 180 | 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 | 195 | FROM segments s |
| 183 | 196 | LEFT JOIN transcripts t ON t.segmentId = s.id |
| 184 | 197 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId |
| 198 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | |
| 185 | 199 | ORDER BY s.startTs DESC |
| 186 | 200 | LIMIT :limit |
| 187 | 201 | """, |
| ... | ... | @@ -196,12 +210,15 @@ interface SegmentDao { |
| 196 | 210 | @Query( |
| 197 | 211 | """ |
| 198 | 212 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, |
| 213 | + s.audioStartMs AS audioStartMs, | |
| 199 | 214 | t.text AS text, s.audioPath AS audioPath, |
| 200 | 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 | 218 | FROM segments s |
| 203 | 219 | INNER JOIN transcripts t ON t.segmentId = s.id |
| 204 | 220 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId |
| 221 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | |
| 205 | 222 | WHERE t.text LIKE '%' || :kw || '%' |
| 206 | 223 | ORDER BY s.startTs DESC |
| 207 | 224 | LIMIT :limit |
| ... | ... | @@ -213,13 +230,16 @@ interface SegmentDao { |
| 213 | 230 | @Query( |
| 214 | 231 | """ |
| 215 | 232 | SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, |
| 233 | + s.audioStartMs AS audioStartMs, | |
| 216 | 234 | t.text AS text, s.audioPath AS audioPath, |
| 217 | 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 | 238 | FROM segments s |
| 220 | 239 | INNER JOIN transcripts t ON t.segmentId = s.id |
| 221 | 240 | INNER JOIN transcript_fts fts ON fts.docid = t.segmentId |
| 222 | 241 | LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId |
| 242 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | |
| 223 | 243 | WHERE transcript_fts MATCH :query |
| 224 | 244 | ORDER BY s.startTs DESC |
| 225 | 245 | LIMIT :limit |
| ... | ... | @@ -357,6 +377,14 @@ interface PersonDao { |
| 357 | 377 | @Query("UPDATE persons SET voiceSpeakerId = :speakerId WHERE id = :id") |
| 358 | 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 | 388 | @Query("DELETE FROM persons WHERE id = :id") |
| 361 | 389 | suspend fun delete(id: Long) |
| 362 | 390 | } | ... | ... |