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 078f70f..21a3b4c 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 @@ -834,7 +834,11 @@ private fun SpeakersTab( onConfirm = { name -> renameFor = null if (name.isNotBlank()) { - scope.launch { withContext(Dispatchers.IO) { repo.speakers.updateName(sp.id, name.trim()) } } + scope.launch { + withContext(Dispatchers.IO) { repo.speakers.updateName(sp.id, name.trim()) } + // L2:命名即确认身份 → 用该簇所有段的均值重建 anchor(多 anchor,更鲁棒) + SpeakerClusterer.rebuildAnchor(repo, sp.id) + } } }, ) @@ -851,6 +855,8 @@ private fun SpeakersTab( repo.segments.reassignSpeaker(sp.id, into) repo.speakers.delete(sp.id) } + // L2:合并后 into 的成员集变大 → 用合并后全部段的均值重建 anchor + SpeakerClusterer.rebuildAnchor(repo, into) } }, ) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt index 927a1fb..728ec92 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt @@ -120,6 +120,27 @@ class SpeakerClusterer( if (name.startsWith(AUTO_PREFIX)) name.removePrefix(AUTO_PREFIX).toIntOrNull() else null /** + * L2 多 anchor:用该说话人**当前名下所有段**的干净 embedding 均值重建其质心, + * 取代单段注册(单段 anchor 太弱:实测「宜博-秦子琛」两个单段 anchor 只隔 0.53, + * 互相混淆)。在用户「重命名(确认身份)」或「合并」后调用——这两步即多段标定, + * 均值后的 anchor 更鲁棒;再配合 L1 冻结不漂移,匹配显著更准。 + * @return 参与平均的段数(0 = 该说话人暂无带 embedding 的段,未改动) + */ + suspend fun rebuildAnchor(repo: Repository, speakerId: Long): Int = withContext(Dispatchers.IO) { + val embs = repo.segments.embeddingsForSpeaker(speakerId).map { it.toFloatArrayLe() } + if (embs.isEmpty()) return@withContext 0 + val dim = embs[0].size + val mean = FloatArray(dim) + for (e in embs) for (i in 0 until dim) mean[i] += e[i] + for (i in 0 until dim) mean[i] /= embs.size + l2normalize(mean) + val count = minOf(embs.size, MAX_SAMPLES_PER_CENTROID) + repo.speakers.updateEmbedding(speakerId, mean.toLeBytes(), count) + Log.i(TAG, "rebuildAnchor #$speakerId from ${embs.size} segs") + embs.size + } + + /** * 离线重聚类:清掉旧的自动簇(保留人工命名的 anchor 作种子),按时间顺序把 * **所有**已存 embedding 的段重新跑一遍聚类,并回填每段的 speakerId。 * 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 85910a4..9eb0ea9 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 @@ -53,6 +53,10 @@ interface SegmentDao { @Query("UPDATE segments SET speakerId = :into WHERE speakerId = :from") suspend fun reassignSpeaker(from: Long, into: Long) + /** 某说话人名下所有有 embedding 的段(L2 多 anchor:用均值重建该人质心)。 */ + @Query("SELECT embedding FROM segments WHERE speakerId = :speakerId AND embedding IS NOT NULL") + suspend fun embeddingsForSpeaker(speakerId: Long): List + /** * Backfill: re-assign speakerId for all segments that have a stored embedding, * using the latest profile set. Called once after a new profile is enrolled.