Commit eaee65ab65f794c58eaa4cfdb5d70ca7ab798fcd
1 parent
860e8100
feat(lifelog/diarize): L2 多 anchor — 重命名/合并时用全部段均值重建声纹 anchor
单段注册的 anchor 太弱:实测「宜博」「秦子琛」两个单段 anchor 余弦只隔 0.53, 高度混淆,导致段被错派。L2 把 anchor 从「单段」升级为「该人名下全部段的均值」: - SpeakerClusterer.rebuildAnchor(repo, speakerId):取该说话人所有带 embedding 段 的均值、L2 归一化写回质心(sampleCount 同步)。 - SegmentDao.embeddingsForSpeaker(speakerId):拉该人名下段 embedding。 - 触发点:重命名(命名即确认身份)、合并(成员集变大)后各 rebuild 一次。 配合 L1 冻结不漂移:anchor 干净(多段均值)且稳定(不被相邻说话人污染), 用户「听一段→命名/合并→重新聚类」即可把历史漂移搞坏的 anchor 一次性纠正。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
3 changed files
with
32 additions
and
1 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
| ... | ... | @@ -834,7 +834,11 @@ private fun SpeakersTab( |
| 834 | 834 | onConfirm = { name -> |
| 835 | 835 | renameFor = null |
| 836 | 836 | if (name.isNotBlank()) { |
| 837 | - scope.launch { withContext(Dispatchers.IO) { repo.speakers.updateName(sp.id, name.trim()) } } | |
| 837 | + scope.launch { | |
| 838 | + withContext(Dispatchers.IO) { repo.speakers.updateName(sp.id, name.trim()) } | |
| 839 | + // L2:命名即确认身份 → 用该簇所有段的均值重建 anchor(多 anchor,更鲁棒) | |
| 840 | + SpeakerClusterer.rebuildAnchor(repo, sp.id) | |
| 841 | + } | |
| 838 | 842 | } |
| 839 | 843 | }, |
| 840 | 844 | ) |
| ... | ... | @@ -851,6 +855,8 @@ private fun SpeakersTab( |
| 851 | 855 | repo.segments.reassignSpeaker(sp.id, into) |
| 852 | 856 | repo.speakers.delete(sp.id) |
| 853 | 857 | } |
| 858 | + // L2:合并后 into 的成员集变大 → 用合并后全部段的均值重建 anchor | |
| 859 | + SpeakerClusterer.rebuildAnchor(repo, into) | |
| 854 | 860 | } |
| 855 | 861 | }, |
| 856 | 862 | ) | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt
| ... | ... | @@ -120,6 +120,27 @@ class SpeakerClusterer( |
| 120 | 120 | if (name.startsWith(AUTO_PREFIX)) name.removePrefix(AUTO_PREFIX).toIntOrNull() else null |
| 121 | 121 | |
| 122 | 122 | /** |
| 123 | + * L2 多 anchor:用该说话人**当前名下所有段**的干净 embedding 均值重建其质心, | |
| 124 | + * 取代单段注册(单段 anchor 太弱:实测「宜博-秦子琛」两个单段 anchor 只隔 0.53, | |
| 125 | + * 互相混淆)。在用户「重命名(确认身份)」或「合并」后调用——这两步即多段标定, | |
| 126 | + * 均值后的 anchor 更鲁棒;再配合 L1 冻结不漂移,匹配显著更准。 | |
| 127 | + * @return 参与平均的段数(0 = 该说话人暂无带 embedding 的段,未改动) | |
| 128 | + */ | |
| 129 | + suspend fun rebuildAnchor(repo: Repository, speakerId: Long): Int = withContext(Dispatchers.IO) { | |
| 130 | + val embs = repo.segments.embeddingsForSpeaker(speakerId).map { it.toFloatArrayLe() } | |
| 131 | + if (embs.isEmpty()) return@withContext 0 | |
| 132 | + val dim = embs[0].size | |
| 133 | + val mean = FloatArray(dim) | |
| 134 | + for (e in embs) for (i in 0 until dim) mean[i] += e[i] | |
| 135 | + for (i in 0 until dim) mean[i] /= embs.size | |
| 136 | + l2normalize(mean) | |
| 137 | + val count = minOf(embs.size, MAX_SAMPLES_PER_CENTROID) | |
| 138 | + repo.speakers.updateEmbedding(speakerId, mean.toLeBytes(), count) | |
| 139 | + Log.i(TAG, "rebuildAnchor #$speakerId from ${embs.size} segs") | |
| 140 | + embs.size | |
| 141 | + } | |
| 142 | + | |
| 143 | + /** | |
| 123 | 144 | * 离线重聚类:清掉旧的自动簇(保留人工命名的 anchor 作种子),按时间顺序把 |
| 124 | 145 | * **所有**已存 embedding 的段重新跑一遍聚类,并回填每段的 speakerId。 |
| 125 | 146 | * | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| ... | ... | @@ -53,6 +53,10 @@ interface SegmentDao { |
| 53 | 53 | @Query("UPDATE segments SET speakerId = :into WHERE speakerId = :from") |
| 54 | 54 | suspend fun reassignSpeaker(from: Long, into: Long) |
| 55 | 55 | |
| 56 | + /** 某说话人名下所有有 embedding 的段(L2 多 anchor:用均值重建该人质心)。 */ | |
| 57 | + @Query("SELECT embedding FROM segments WHERE speakerId = :speakerId AND embedding IS NOT NULL") | |
| 58 | + suspend fun embeddingsForSpeaker(speakerId: Long): List<ByteArray> | |
| 59 | + | |
| 56 | 60 | /** |
| 57 | 61 | * Backfill: re-assign speakerId for all segments that have a stored embedding, |
| 58 | 62 | * using the latest profile set. Called once after a new profile is enrolled. | ... | ... |