Commit edbc4d242f8287de9813d0b403b4d8f968a29f38
1 parent
18e621c7
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,7 +834,11 @@ private fun SpeakersTab( | ||
| 834 | onConfirm = { name -> | 834 | onConfirm = { name -> |
| 835 | renameFor = null | 835 | renameFor = null |
| 836 | if (name.isNotBlank()) { | 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,6 +855,8 @@ private fun SpeakersTab( | ||
| 851 | repo.segments.reassignSpeaker(sp.id, into) | 855 | repo.segments.reassignSpeaker(sp.id, into) |
| 852 | repo.speakers.delete(sp.id) | 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,6 +120,27 @@ class SpeakerClusterer( | ||
| 120 | if (name.startsWith(AUTO_PREFIX)) name.removePrefix(AUTO_PREFIX).toIntOrNull() else null | 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 | * 离线重聚类:清掉旧的自动簇(保留人工命名的 anchor 作种子),按时间顺序把 | 144 | * 离线重聚类:清掉旧的自动簇(保留人工命名的 anchor 作种子),按时间顺序把 |
| 124 | * **所有**已存 embedding 的段重新跑一遍聚类,并回填每段的 speakerId。 | 145 | * **所有**已存 embedding 的段重新跑一遍聚类,并回填每段的 speakerId。 |
| 125 | * | 146 | * |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| @@ -53,6 +53,10 @@ interface SegmentDao { | @@ -53,6 +53,10 @@ interface SegmentDao { | ||
| 53 | @Query("UPDATE segments SET speakerId = :into WHERE speakerId = :from") | 53 | @Query("UPDATE segments SET speakerId = :into WHERE speakerId = :from") |
| 54 | suspend fun reassignSpeaker(from: Long, into: Long) | 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 | * Backfill: re-assign speakerId for all segments that have a stored embedding, | 61 | * Backfill: re-assign speakerId for all segments that have a stored embedding, |
| 58 | * using the latest profile set. Called once after a new profile is enrolled. | 62 | * using the latest profile set. Called once after a new profile is enrolled. |