You need to sign in before continuing.

Commit 45e3e509b705d4d760777b018fc155cb43899099

Authored by yibo
1 parent 72d67304

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>
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: () -&gt; Unit, onCo @@ -1296,38 +1349,135 @@ private fun RenameSpeakerDialog(currentName: String, onDismiss: () -&gt; 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 }