Commit 516ec8eedcd18b08a370348dd3d314aad75f2716

Authored by yibo
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>
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: () -&gt; 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 }
... ...