Commit eaee65ab65f794c58eaa4cfdb5d70ca7ab798fcd

Authored by yibo
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>
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.
... ...