diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt index 14872ff..13a0d0f 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt @@ -86,6 +86,7 @@ import com.lemon.lifelog.chat.ChatConfig import com.lemon.lifelog.summary.SummaryProgress import com.lemon.lifelog.summary.SummaryRunLog import com.lemon.lifelog.data.BlockRow +import com.lemon.lifelog.data.Person import com.lemon.lifelog.data.Repository import com.lemon.lifelog.data.SegmentRow import com.lemon.lifelog.data.SpeakerProfile @@ -843,6 +844,11 @@ private fun SpeakersTab( val ctx = LocalContext.current val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList()) val countMap = remember(counts) { counts.associate { it.speakerId to it.count } } + // 声纹↔人物桥:哪个声纹已被「识别为」某人物(persons.voiceSpeakerId)。 + val persons by repo.persons.observeAll().collectAsState(initial = emptyList()) + val personBySpeaker = remember(persons) { + persons.filter { it.voiceSpeakerId != null }.associateBy { it.voiceSpeakerId!! } + } var reclustering by remember { mutableStateOf(false) } var reclusterMsg by remember { mutableStateOf("") } var clearConfirm by remember { mutableStateOf(false) } @@ -850,6 +856,28 @@ private fun SpeakersTab( var renameFor by remember { mutableStateOf(null) } var mergeFor by remember { mutableStateOf(null) } var deleteFor by remember { mutableStateOf(null) } + var identifyFor by remember { mutableStateOf(null) } + + // 把声纹 sp「识别为」已有人物 pid:先把该声纹从任何人物解开(保 1:1),再绑到 pid 并纳入管理。 + fun identifyExisting(sp: SpeakerProfile, pid: Long) { + scope.launch { + withContext(Dispatchers.IO) { + repo.persons.clearVoiceSpeaker(sp.id) + repo.persons.linkVoice(pid, sp.id) + repo.persons.setManaged(pid, true) + repo.persons.touch(pid, System.currentTimeMillis()) + } + } + } + // 把声纹 sp 新建成一个人物(直接 managed)。 + fun identifyNew(sp: SpeakerProfile, name: String) { + scope.launch { + withContext(Dispatchers.IO) { + repo.persons.clearVoiceSpeaker(sp.id) + repo.persons.insert(Person(name = name.trim(), voiceSpeakerId = sp.id, managed = true)) + } + } + } Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -881,7 +909,7 @@ private fun SpeakersTab( Text(reclusterMsg, style = MaterialTheme.typography.bodySmall) } Text( - "自动发现的说话人会命名为「说话人N」。点击进入按 TA 筛选时间轴;可重命名 / 合并 / 删除。", + "自动发现的说话人会命名为「说话人N」。点「识别为人物」把这把声音认成 CRM 里的人——之后时间轴/各处都显示人物名,TA 的录音自动归到人物档案。", style = MaterialTheme.typography.bodySmall, ) Spacer(Modifier.height(8.dp)) @@ -892,6 +920,7 @@ private fun SpeakersTab( LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { items(speakers, key = { it.id }) { sp -> + val person = personBySpeaker[sp.id] Card(modifier = Modifier.fillMaxWidth().clickable { onFilterBySpeaker(sp.id) }) { Column(modifier = Modifier.padding(12.dp)) { Text(sp.name, style = MaterialTheme.typography.titleSmall) @@ -899,7 +928,15 @@ private fun SpeakersTab( "归属 ${countMap[sp.id] ?: 0} 段 · 质心样本 ${sp.sampleCount}", style = MaterialTheme.typography.labelSmall, ) + Text( + if (person != null) "✓ 已识别为人物:${person.name}" else "○ 未识别为人物", + style = MaterialTheme.typography.labelMedium, + color = if (person != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { identifyFor = sp }) { + Text(if (person != null) "改识别" else "识别为人物") + } TextButton(onClick = { renameFor = sp }) { Text("重命名") } TextButton(onClick = { mergeFor = sp }) { Text("合并到…") } TextButton(onClick = { deleteFor = sp }) { Text("删除") } @@ -910,6 +947,21 @@ private fun SpeakersTab( } } + identifyFor?.let { sp -> + IdentifySpeakerSheet( + speaker = sp, + currentPerson = personBySpeaker[sp.id], + persons = persons, + onDismiss = { identifyFor = null }, + onPickExisting = { pid -> identifyFor = null; identifyExisting(sp, pid) }, + onCreateNew = { name -> identifyFor = null; identifyNew(sp, name) }, + onUnlink = { + identifyFor = null + scope.launch { withContext(Dispatchers.IO) { repo.persons.clearVoiceSpeaker(sp.id) } } + }, + ) + } + renameFor?.let { sp -> RenameSpeakerDialog( currentName = sp.name, @@ -1259,8 +1311,9 @@ private fun SegmentActionSheet( ) { Text("段 #${row.id} · ${"%.1fs".format(row.durationMs / 1000.0)}", style = MaterialTheme.typography.titleMedium) row.text?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + row.personName?.let { Text("👤 人物:$it", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) } row.speakerName?.let { name -> - Text("已识别:$name (sim=${"%.2f".format(row.speakerSim ?: 0f)})", style = MaterialTheme.typography.labelMedium) + Text("声纹:$name (sim=${"%.2f".format(row.speakerSim ?: 0f)})", style = MaterialTheme.typography.labelMedium) } Spacer(Modifier.height(4.dp)) Button(onClick = onPlay, modifier = Modifier.fillMaxWidth()) { Text(if (isPlaying) "■ 停止" else "▶ 播放原音") } @@ -1296,38 +1349,135 @@ private fun RenameSpeakerDialog(currentName: String, onDismiss: () -> Unit, onCo } @Composable +@OptIn(ExperimentalMaterial3Api::class) private fun ReassignSpeakerDialog( row: SegmentRow, speakers: List, onDismiss: () -> Unit, onPick: (Long?) -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("改派说话人") }, - text = { - Column { - Text("段 #${row.id} 当前:${row.speakerName ?: "未归属"}", style = MaterialTheme.typography.bodySmall) - Spacer(Modifier.height(8.dp)) - Row(modifier = Modifier.fillMaxWidth().clickable { onPick(null) }.padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = row.speakerId == null, onClick = { onPick(null) }) - Text("(清除归属)") + val sheetState = rememberModalBottomSheetState() + var query by remember { mutableStateOf("") } + val shown = remember(speakers, query) { + val q = query.trim() + if (q.isBlank()) speakers else speakers.filter { it.name.contains(q, true) } + } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 16.dp)) { + Text("改派说话人", style = MaterialTheme.typography.titleMedium) + Text("段 #${row.id} 当前:${row.speakerName ?: "未归属"}", style = MaterialTheme.typography.bodySmall) + Text("点名字即改派", style = MaterialTheme.typography.labelSmall) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = query, onValueChange = { query = it }, singleLine = true, + placeholder = { Text("搜索说话人(共 ${speakers.size})") }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp)) { + item { + Row( + modifier = Modifier.fillMaxWidth().clickable { onPick(null) }.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = row.speakerId == null, onClick = { onPick(null) }) + Text("(清除归属)") + } } - speakers.forEach { sp -> - Row(modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically) { + items(shown, key = { it.id }) { sp -> + Row( + modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { RadioButton(selected = row.speakerId == sp.id, onClick = { onPick(sp.id) }) Text(sp.name) } } } - }, - confirmButton = {}, - dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, - ) + } + } } +/** + * 「识别为人物」底部面板:把一个声纹认成 CRM 里的人物。 + * 输入框既是搜索、也是新建名字:输名字→点已有匹配项绑定,或点「➕ 新建人物」按这名字建。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun IdentifySpeakerSheet( + speaker: SpeakerProfile, + currentPerson: Person?, + persons: List, + onDismiss: () -> Unit, + onPickExisting: (Long) -> Unit, + onCreateNew: (String) -> Unit, + onUnlink: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + var query by remember { mutableStateOf("") } + val q = query.trim() + val shown = remember(persons, q) { + if (q.isBlank()) persons + else persons.filter { + it.name.contains(q, true) || it.nickname?.contains(q, true) == true || + it.remark?.contains(q, true) == true || it.alias?.contains(q, true) == true || + it.pinyin?.contains(q, true) == true + } + } + val newName = q.ifBlank { speaker.name } + val exactExists = persons.any { it.name.equals(newName, true) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 16.dp)) { + Text("把「${speaker.name}」识别为人物", style = MaterialTheme.typography.titleMedium) + currentPerson?.let { + Text("当前已识别为:${it.name}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + } + Text("输入名字搜索已有人物,或新建一个", style = MaterialTheme.typography.labelSmall) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = query, onValueChange = { query = it }, singleLine = true, + placeholder = { Text("搜索 / 新人物名字") }, modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + if (!exactExists && newName.isNotBlank()) { + Row( + modifier = Modifier.fillMaxWidth().clickable { onCreateNew(newName) }.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("➕ 新建人物「$newName」并绑定", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary) + } + HorizontalDivider() + } + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp)) { + items(shown, key = { it.id }) { p -> + Row( + modifier = Modifier.fillMaxWidth().clickable { onPickExisting(p.id) }.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f)) { + Text(p.name, style = MaterialTheme.typography.bodyLarge) + val src = when { + p.contactRef?.startsWith("wechat:") == true -> "微信" + p.contactRef?.startsWith("phone:") == true -> "手机" + else -> null + } + val sub = listOfNotNull(src, p.voiceSpeakerId?.let { "已绑声纹" }).joinToString(" · ") + if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall) + } + if (p.voiceSpeakerId == speaker.id) Text("✓", color = MaterialTheme.colorScheme.primary) + } + } + } + if (currentPerson != null) { + Spacer(Modifier.height(8.dp)) + TextButton(onClick = onUnlink) { Text("取消识别(解绑人物)") } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun MergeSpeakerDialog( from: SpeakerProfile, @@ -1335,23 +1485,39 @@ private fun MergeSpeakerDialog( onDismiss: () -> Unit, onPick: (Long) -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("把「${from.name}」合并到…") }, - text = { - Column { - if (others.isEmpty()) Text("没有其它说话人可合并。") - others.forEach { sp -> - Row(modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically) { - Text(sp.name) + val sheetState = rememberModalBottomSheetState() + var query by remember { mutableStateOf("") } + val shown = remember(others, query) { + val q = query.trim() + if (q.isBlank()) others else others.filter { it.name.contains(q, true) } + } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 16.dp)) { + Text("把「${from.name}」合并到…", style = MaterialTheme.typography.titleMedium) + Text("点名字即合并", style = MaterialTheme.typography.labelSmall) + Spacer(Modifier.height(8.dp)) + if (others.isEmpty()) { + Text("没有其它说话人可合并。", style = MaterialTheme.typography.bodyMedium) + } else { + OutlinedTextField( + value = query, onValueChange = { query = it }, singleLine = true, + placeholder = { Text("搜索说话人(共 ${others.size})") }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp)) { + items(shown, key = { it.id }) { sp -> + Row( + modifier = Modifier.fillMaxWidth().clickable { onPick(sp.id) }.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(sp.name, style = MaterialTheme.typography.bodyLarge) + } } } } - }, - confirmButton = {}, - dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, - ) + } + } } @Composable @@ -1426,7 +1592,9 @@ private fun TimelineRow( onPlay: (() -> Unit)? = null, ) { val time = TIMELINE_FMT.format(Date(row.startTs)) + // 已「识别为人物」的声纹,时间轴直接显示人物名(带👤),否则退回说话人名/相似度。 val speakerLabel = when { + row.personName != null -> "👤 ${row.personName}" row.speakerName != null && row.speakerSim != null -> "${row.speakerName} (${"%.2f".format(row.speakerSim)})" row.speakerName != null -> row.speakerName row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})" diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index 5c566aa..f0dd756 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -249,10 +249,13 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { /** 融合:把一个声纹绑到联系人(null = 解绑)。绑定后档案页"录音列表"才有内容。 */ fun bindVoice(personId: Long, speakerId: Long?) { viewModelScope.launch { - repo.persons.linkVoice(personId, speakerId) if (speakerId != null) { + repo.persons.clearVoiceSpeaker(speakerId) // 保声纹↔人物 1:1:先从别的人物解开 + repo.persons.linkVoice(personId, speakerId) repo.persons.touch(personId, System.currentTimeMillis()) repo.persons.setManaged(personId, true) // 绑了声纹 = 我关心 → 纳入管理 + } else { + repo.persons.linkVoice(personId, null) } } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 00ac268..9423df0 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -10,11 +10,16 @@ data class SegmentRow( val id: Long, val startTs: Long, val durationMs: Long, + /** 该 turn 在「块完整录音」(audioPath) 内的起始偏移(ms)。播放/切片必须带它,否则播整块对不上文字。 */ + val audioStartMs: Long = 0, val text: String?, val audioPath: String, val speakerId: Long?, val speakerSim: Float?, val speakerName: String?, + /** 该声纹已「识别为」的人物(persons.voiceSpeakerId = speakerId);null = 还没认成人物。 */ + val personId: Long? = null, + val personName: String? = null, ) data class SpeakerCount( @@ -122,12 +127,15 @@ interface SegmentDao { @Query( """ SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + s.audioStartMs AS audioStartMs, t.text AS text, s.audioPath AS audioPath, s.speakerId AS speakerId, s.speakerSim AS speakerSim, - sp.name AS speakerName + sp.name AS speakerName, + p.id AS personId, p.name AS personName FROM segments s LEFT JOIN transcripts t ON t.segmentId = s.id LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId ORDER BY s.startTs DESC LIMIT :limit """, @@ -160,11 +168,14 @@ interface SegmentDao { @Query( """ SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + s.audioStartMs AS audioStartMs, t.text AS text, s.audioPath AS audioPath, - s.speakerId AS speakerId, s.speakerSim AS speakerSim, sp.name AS speakerName + s.speakerId AS speakerId, s.speakerSim AS speakerSim, sp.name AS speakerName, + p.id AS personId, p.name AS personName FROM segments s LEFT JOIN transcripts t ON t.segmentId = s.id LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId WHERE s.speakerId = :speakerId ORDER BY s.startTs DESC LIMIT :limit @@ -176,12 +187,15 @@ interface SegmentDao { @Query( """ SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + s.audioStartMs AS audioStartMs, t.text AS text, s.audioPath AS audioPath, s.speakerId AS speakerId, s.speakerSim AS speakerSim, - sp.name AS speakerName + sp.name AS speakerName, + p.id AS personId, p.name AS personName FROM segments s LEFT JOIN transcripts t ON t.segmentId = s.id LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId ORDER BY s.startTs DESC LIMIT :limit """, @@ -196,12 +210,15 @@ interface SegmentDao { @Query( """ SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + s.audioStartMs AS audioStartMs, t.text AS text, s.audioPath AS audioPath, s.speakerId AS speakerId, s.speakerSim AS speakerSim, - sp.name AS speakerName + sp.name AS speakerName, + p.id AS personId, p.name AS personName FROM segments s INNER JOIN transcripts t ON t.segmentId = s.id LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId WHERE t.text LIKE '%' || :kw || '%' ORDER BY s.startTs DESC LIMIT :limit @@ -213,13 +230,16 @@ interface SegmentDao { @Query( """ SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + s.audioStartMs AS audioStartMs, t.text AS text, s.audioPath AS audioPath, s.speakerId AS speakerId, s.speakerSim AS speakerSim, - sp.name AS speakerName + sp.name AS speakerName, + p.id AS personId, p.name AS personName FROM segments s INNER JOIN transcripts t ON t.segmentId = s.id INNER JOIN transcript_fts fts ON fts.docid = t.segmentId LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId WHERE transcript_fts MATCH :query ORDER BY s.startTs DESC LIMIT :limit @@ -357,6 +377,14 @@ interface PersonDao { @Query("UPDATE persons SET voiceSpeakerId = :speakerId WHERE id = :id") suspend fun linkVoice(id: Long, speakerId: Long?) + /** 反查:哪个人物已「识别为」这个声纹(声纹↔人物 1:1)。 */ + @Query("SELECT * FROM persons WHERE voiceSpeakerId = :speakerId LIMIT 1") + suspend fun getByVoiceSpeaker(speakerId: Long): Person? + + /** 把某声纹从任何已绑它的人物上解开(重新识别为另一人前先调,保 1:1)。 */ + @Query("UPDATE persons SET voiceSpeakerId = NULL WHERE voiceSpeakerId = :speakerId") + suspend fun clearVoiceSpeaker(speakerId: Long) + @Query("DELETE FROM persons WHERE id = :id") suspend fun delete(id: Long) }