Commit 9beb325a9c788a525b1aa44cc61eca26d06c858f
1 parent
31f530a2
feat(lifelog/diarize): 凝聚式合并收尾,治声纹过度切分(同人被拆成多簇)
贪心单遍聚类会把同一人切成多簇:早期噪声段一旦另起簇,后续同人段按「最近质心」 各自归簇,再不会并回(实测主说话人被拆成 说话人11+12+29,质心却差 0.65+)。 - 新增 SpeakerClusterer.mergeOverSplit:重聚类后做 agglomerative 合并——反复把质心 余弦 ≥ mergeThreshold 的两簇并掉,质心用两簇全部成员段重算(比存的封顶滑动均值 更准,能暴露被掩盖的过切:实测 说话人11↔12 成员重算余弦 0.749)。 约束:不并两个人工命名簇;一人工一自动并入人工保留名字;都自动并入成员多的。 合并后刷新被并段的 speakerId 与相似度。 - reclusterAll / clearAllAndRecluster 收尾调用合并;mergeThreshold 默认 0.55,设置页可调 - 真机实测:32→29 簇,主说话人统一为单簇(580 段),残留仅 4 对 0.50–0.52 小簇待人工 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
3 changed files
with
92 additions
and
4 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
| ... | ... | @@ -864,6 +864,7 @@ private fun SpeakersTab( |
| 864 | 864 | repo, |
| 865 | 865 | matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx), |
| 866 | 866 | minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx), |
| 867 | + mergeThreshold = SpeakerClusterer.mergeThresholdFromPrefs(ctx), | |
| 867 | 868 | ) |
| 868 | 869 | } |
| 869 | 870 | reclustering = false |
| ... | ... | @@ -975,6 +976,7 @@ private fun SpeakersTab( |
| 975 | 976 | repo, |
| 976 | 977 | matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx), |
| 977 | 978 | minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx), |
| 979 | + mergeThreshold = SpeakerClusterer.mergeThresholdFromPrefs(ctx), | |
| 978 | 980 | ) |
| 979 | 981 | } |
| 980 | 982 | reclustering = false | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt
| ... | ... | @@ -51,6 +51,7 @@ fun SettingsScreen(activity: Context) { |
| 51 | 51 | var minSpeech by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_MIN_SPEECH_SEC, SummaryWorker.DEFAULT_MIN_SPEECH_SEC)) } |
| 52 | 52 | var backendId by remember { mutableStateOf(prefs.getString(SummaryWorker.KEY_BACKEND, ChatConfig.ID_2B) ?: ChatConfig.ID_2B) } |
| 53 | 53 | var threshold by remember { mutableStateOf(SpeakerClusterer.thresholdFromPrefs(activity)) } |
| 54 | + var mergeThreshold by remember { mutableStateOf(SpeakerClusterer.mergeThresholdFromPrefs(activity)) } | |
| 54 | 55 | var minRecognizeSec by remember { |
| 55 | 56 | mutableStateOf(prefs.getInt(SpeakerClusterer.KEY_MIN_RECOGNIZE_SEC, (SpeakerClusterer.MIN_RECOGNIZE_MS / 1000).toInt())) |
| 56 | 57 | } |
| ... | ... | @@ -98,11 +99,15 @@ fun SettingsScreen(activity: Context) { |
| 98 | 99 | listOf(0.35f, 0.40f, 0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) { |
| 99 | 100 | threshold = it; prefs.edit().putFloat(SpeakerClusterer.KEY_THRESHOLD, it).apply() |
| 100 | 101 | } |
| 102 | + DropdownSetting("合并阈值(越小越爱并同人)", "%.2f".format(mergeThreshold), | |
| 103 | + listOf(0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) { | |
| 104 | + mergeThreshold = it; prefs.edit().putFloat(SpeakerClusterer.KEY_MERGE_THRESHOLD, it).apply() | |
| 105 | + } | |
| 101 | 106 | DropdownSetting("最短识别时长(短于此不认声纹)", "$minRecognizeSec 秒", |
| 102 | 107 | listOf(1, 2, 3, 5, 8).map { "$it 秒" to it }) { |
| 103 | 108 | minRecognizeSec = it; prefs.edit().putInt(SpeakerClusterer.KEY_MIN_RECOGNIZE_SEC, it).apply() |
| 104 | 109 | } |
| 105 | - Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。", | |
| 110 | + Text("重聚类会先贪心分簇、再把质心很近的簇按「合并阈值」并回(治同人被拆成多个)。改完到「声纹」tab 点「重新聚类」生效。", | |
| 106 | 111 | style = MaterialTheme.typography.labelSmall) |
| 107 | 112 | |
| 108 | 113 | HorizontalDivider() | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt
| ... | ... | @@ -114,11 +114,21 @@ class SpeakerClusterer( |
| 114 | 114 | const val PREFS = "lifelog_prefs" |
| 115 | 115 | const val KEY_THRESHOLD = "speaker_threshold" |
| 116 | 116 | const val KEY_MIN_RECOGNIZE_SEC = "speaker_min_recognize_sec" |
| 117 | + const val KEY_MERGE_THRESHOLD = "speaker_merge_threshold" | |
| 117 | 118 | |
| 118 | 119 | fun thresholdFromPrefs(ctx: android.content.Context): Float = |
| 119 | 120 | ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) |
| 120 | 121 | .getFloat(KEY_THRESHOLD, MATCH_THRESHOLD) |
| 121 | 122 | |
| 123 | + /** 合并阈值(设置页可调):重聚类后,质心余弦 ≥ 此值的两簇判为同一人并合并。 */ | |
| 124 | + fun mergeThresholdFromPrefs(ctx: android.content.Context): Float = | |
| 125 | + ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) | |
| 126 | + .getFloat(KEY_MERGE_THRESHOLD, MERGE_THRESHOLD) | |
| 127 | + | |
| 128 | + /** 合并阈值默认值:贪心聚类易把同一人切成多簇(早期噪声段另起簇后互相吸段), | |
| 129 | + * 收尾做凝聚式合并把质心很近的簇并回来。0.55 偏保守,只并高置信对;调低并得更狠。 */ | |
| 130 | + const val MERGE_THRESHOLD = 0.55f | |
| 131 | + | |
| 122 | 132 | /** 最短识别时长(设置页可调,秒):短于此的段完全跳过声纹识别。 */ |
| 123 | 133 | fun minRecognizeMsFromPrefs(ctx: android.content.Context): Long = |
| 124 | 134 | ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) |
| ... | ... | @@ -170,6 +180,7 @@ class SpeakerClusterer( |
| 170 | 180 | matchThreshold: Float = MATCH_THRESHOLD, |
| 171 | 181 | minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, |
| 172 | 182 | minRecognizeMs: Long = MIN_RECOGNIZE_MS, |
| 183 | + mergeThreshold: Float = MERGE_THRESHOLD, | |
| 173 | 184 | maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID, |
| 174 | 185 | ): Int = withContext(Dispatchers.IO) { |
| 175 | 186 | // 1. 删掉自动簇,保留人工命名簇作种子。 |
| ... | ... | @@ -234,8 +245,77 @@ class SpeakerClusterer( |
| 234 | 245 | |
| 235 | 246 | repo.segments.updateSpeaker(seg.id, assignment.first, assignment.second) |
| 236 | 247 | } |
| 237 | - Log.i(TAG, "reclusterAll: ${segs.size} segments → ${clusters.size} clusters") | |
| 238 | - clusters.size | |
| 248 | + Log.i(TAG, "reclusterAll greedy: ${segs.size} segments → ${clusters.size} clusters") | |
| 249 | + // 3. 凝聚式合并:把过度切分的同人簇并回来。 | |
| 250 | + val merges = mergeOverSplit(repo, mergeThreshold) | |
| 251 | + val finalCount = repo.speakers.all().size | |
| 252 | + Log.i(TAG, "reclusterAll: merged $merges pairs → $finalCount clusters") | |
| 253 | + finalCount | |
| 254 | + } | |
| 255 | + | |
| 256 | + /** | |
| 257 | + * 凝聚式合并(agglomerative):解决贪心聚类的过度切分——同一人因早期噪声段另起簇后, | |
| 258 | + * 后续段按「最近质心」各自归簇,再不会并回。这里反复找质心余弦最大的一对簇, | |
| 259 | + * ≥ [mergeThreshold] 就合并(质心用两簇**全部成员段**重算),直到没有可并的对。 | |
| 260 | + * | |
| 261 | + * 约束:不合并两个**人工命名**簇(会毁掉用户标定);一人工一自动则并入人工(保留名字); | |
| 262 | + * 都自动则并入成员多的那个。合并后刷新被并段的 speakerId 与相似度。 | |
| 263 | + * @return 合并发生的次数 | |
| 264 | + */ | |
| 265 | + suspend fun mergeOverSplit( | |
| 266 | + repo: Repository, | |
| 267 | + mergeThreshold: Float = MERGE_THRESHOLD, | |
| 268 | + ): Int = withContext(Dispatchers.IO) { | |
| 269 | + class WC(val id: Long, val manual: Boolean, val members: MutableList<FloatArray>, var centroid: FloatArray) | |
| 270 | + fun meanNorm(ms: List<FloatArray>): FloatArray { | |
| 271 | + val dim = ms[0].size | |
| 272 | + val m = FloatArray(dim) | |
| 273 | + for (e in ms) for (i in 0 until dim) m[i] += e[i] | |
| 274 | + for (i in 0 until dim) m[i] /= ms.size | |
| 275 | + l2normalize(m); return m | |
| 276 | + } | |
| 277 | + val wcs = ArrayList<WC>() | |
| 278 | + for (p in repo.speakers.all()) { | |
| 279 | + val mem = repo.segments.embeddingsForSpeaker(p.id).map { it.toFloatArrayLe() } | |
| 280 | + if (mem.isEmpty()) continue | |
| 281 | + wcs += WC(p.id, autoIndexOf(p.name) == null, mem.toMutableList(), meanNorm(mem)) | |
| 282 | + } | |
| 283 | + var merges = 0 | |
| 284 | + while (true) { | |
| 285 | + var bi = -1; var bj = -1; var best = mergeThreshold | |
| 286 | + for (i in wcs.indices) for (j in i + 1 until wcs.size) { | |
| 287 | + if (wcs[i].manual && wcs[j].manual) continue // 两个人工名不并 | |
| 288 | + val c = cosineSim(wcs[i].centroid, wcs[j].centroid) | |
| 289 | + if (c >= best) { best = c; bi = i; bj = j } | |
| 290 | + } | |
| 291 | + if (bi < 0) break | |
| 292 | + val a = wcs[bi]; val b = wcs[bj] | |
| 293 | + val keep: WC; val drop: WC | |
| 294 | + when { | |
| 295 | + a.manual && !b.manual -> { keep = a; drop = b } | |
| 296 | + b.manual && !a.manual -> { keep = b; drop = a } | |
| 297 | + a.members.size >= b.members.size -> { keep = a; drop = b } | |
| 298 | + else -> { keep = b; drop = a } | |
| 299 | + } | |
| 300 | + keep.members.addAll(drop.members) | |
| 301 | + keep.centroid = meanNorm(keep.members) | |
| 302 | + repo.segments.reassignSpeaker(drop.id, keep.id) | |
| 303 | + repo.speakers.delete(drop.id) | |
| 304 | + wcs.remove(drop) | |
| 305 | + merges++ | |
| 306 | + } | |
| 307 | + // 持久化保留簇的质心 + 刷新各段相似度(合并后质心变了)。 | |
| 308 | + for (w in wcs) { | |
| 309 | + repo.speakers.updateEmbedding(w.id, w.centroid.toLeBytes(), minOf(w.members.size, MAX_SAMPLES_PER_CENTROID)) | |
| 310 | + } | |
| 311 | + val byId = wcs.associateBy { it.id } | |
| 312 | + for (seg in repo.segments.allWithEmbedding()) { | |
| 313 | + val w = byId[seg.speakerId] ?: continue | |
| 314 | + val sim = cosineSim(seg.embedding!!.toFloatArrayLe(), w.centroid) | |
| 315 | + repo.segments.updateSpeaker(seg.id, w.id, sim) | |
| 316 | + } | |
| 317 | + Log.i(TAG, "mergeOverSplit: $merges merges, ${wcs.size} clusters remain (threshold=$mergeThreshold)") | |
| 318 | + merges | |
| 239 | 319 | } |
| 240 | 320 | |
| 241 | 321 | /** |
| ... | ... | @@ -248,11 +328,12 @@ class SpeakerClusterer( |
| 248 | 328 | matchThreshold: Float = MATCH_THRESHOLD, |
| 249 | 329 | minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, |
| 250 | 330 | minRecognizeMs: Long = MIN_RECOGNIZE_MS, |
| 331 | + mergeThreshold: Float = MERGE_THRESHOLD, | |
| 251 | 332 | ): Int = withContext(Dispatchers.IO) { |
| 252 | 333 | repo.segments.clearAllSpeakerAssignments() |
| 253 | 334 | for (p in repo.speakers.all()) repo.speakers.delete(p.id) |
| 254 | 335 | Log.i(TAG, "clearAllAndRecluster: wiped all profiles + assignments") |
| 255 | - reclusterAll(repo, matchThreshold, minNewClusterMs, minRecognizeMs) | |
| 336 | + reclusterAll(repo, matchThreshold, minNewClusterMs, minRecognizeMs, mergeThreshold) | |
| 256 | 337 | } |
| 257 | 338 | } |
| 258 | 339 | } | ... | ... |