Commit 03db2ccf1c2dad70b47d929c53bc3cba445e48b9

Authored by yibo
1 parent a764c53c

feat(lifelog/diarize): v1.2 长会议分块 diarize + 断点续跑 + 阈值 0.35/0.40

整场会议不再截断在前 ~140 段:按 3min speech 分块独立 diarize(块内说话人
index 不跨块,身份交全局 reclusterAll),断点 cursor 持久化到 prefs,下轮从
上次中断的段续跑而非重头。

修卡死真 bug:WorkManager 对每个 worker 有 10min 硬上限,原来 8min 大 chunk
一次 diarize 逼近上限被系统 kill、cursor 写不回去 → 永远卡在同一段。改成
3min chunk + 5min 预算,一轮跑完多块、多次写 cursor,绝不撞红线。实测 473 段
真实会议 4 轮自动跑完(142→232→336→424→done)。

SettingsScreen 阈值下拉补 0.35/0.40(0.4 过合并、0.55 过分裂,甜点 ~0.45)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt
... ... @@ -88,7 +88,7 @@ fun SettingsScreen(activity: Context) {
88 88 HorizontalDivider()
89 89 Text("声纹", style = MaterialTheme.typography.titleMedium)
90 90 DropdownSetting("聚类阈值(越大分越细)", "%.2f".format(threshold),
91   - listOf(0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) {
  91 + listOf(0.35f, 0.40f, 0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) {
92 92 threshold = it; prefs.edit().putFloat(SpeakerClusterer.KEY_THRESHOLD, it).apply()
93 93 }
94 94 Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。",
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/diarize/DiarizationWorker.kt
... ... @@ -34,10 +34,15 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke
34 34 companion object {
35 35 const val TAG = "DiarizationWorker"
36 36 private val isRunning = AtomicBoolean(false)
37   - const val RUN_BUDGET_MS = 7 * 60 * 1000L
  37 + // WorkManager 给每个 worker 硬上限 10min,超时直接 kill。预算压到 5min,
  38 + // 留足余量让最后一块 diarize 收尾 + 全局 recluster + 写 cursor,绝不撞红线。
  39 + const val RUN_BUDGET_MS = 5 * 60 * 1000L
38 40 const val MIN_SESSION_SEGMENTS = 2 // 单段会话无需 diarize
39   - const val MAX_CONCAT_SAMPLES = 10 * 60 * 16000 // ≤10min speech,防 OOM(与 photolog 共进程)/超时
  41 + // 单块 ≤3min speech:一次 diarize ~3min,远小于 10min 红线,一轮能跑完多块、
  42 + // 多次写 cursor 推进;块内说话人 index 不跨块——身份交全局 reclusterAll。
  43 + const val CHUNK_SAMPLES = 3 * 60 * 16000
40 44 private const val KEY_DONE = "diarized_sessions" // prefs StringSet of windowStartTs
  45 + private const val KEY_CURSOR = "diar_cursor_" // + key → 已处理段数(断点续跑长会议)
41 46 }
42 47  
43 48 private val sr = 16000
... ... @@ -79,8 +84,10 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke
79 84 var engine: DiarizationEngine? = null
80 85 var embedder: SpeakerEmbedder? = null
81 86 var processed = 0
  87 + var attempted = false
82 88 try {
83   - for (session in sessions) {
  89 + // 最新会话优先(用户更关心最近的;旧会话后续轮次补)。
  90 + for (session in sessions.reversed()) {
84 91 val start = session.first().startTs
85 92 val end = session.last().endTs
86 93 val key = start.toString()
... ... @@ -98,8 +105,17 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke
98 105 embedder = SpeakerEmbedder.load(speakerFile.path, numThreads = 2)
99 106 }
100 107  
101   - val ok = diarizeSession(repo, session, engine!!, embedder!!, threshold)
102   - if (ok) { done.add(key); processed++ }
  108 + attempted = true
  109 + val startIdx = prefs.getInt(KEY_CURSOR + key, 0)
  110 + val reached = diarizeSession(repo, session, engine!!, embedder!!, runStart, startIdx)
  111 + if (reached >= session.size) {
  112 + done.add(key); processed++
  113 + prefs.edit().remove(KEY_CURSOR + key).apply()
  114 + } else {
  115 + // 预算中途用完:记录断点,下轮从此处续跑(不重头),然后退出本轮。
  116 + prefs.edit().putInt(KEY_CURSOR + key, reached).apply()
  117 + Log.i(TAG, "session $key partial: cursor=$reached/${session.size}, resume next round"); break
  118 + }
103 119 }
104 120 } finally {
105 121 engine?.release()
... ... @@ -107,8 +123,8 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke
107 123 }
108 124  
109 125 // 全局重聚类:用 diarization 产出的"干净"per-turn embedding 统一聚类
110   - //(reclusterAll 本就全局 + 保留人工命名锚点)。只在有新会话被处理时跑,处理完即稳定。
111   - if (processed > 0) {
  126 + //(reclusterAll 本就全局 + 保留人工命名锚点)。只要这轮碰过会话就跑。
  127 + if (attempted) {
112 128 val n = SpeakerClusterer.reclusterAll(repo, matchThreshold = threshold)
113 129 Log.i(TAG, "global recluster → $n speakers")
114 130 }
... ... @@ -117,63 +133,74 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke
117 133 return Result.success()
118 134 }
119 135  
120   - /** 对单个会话做 diarization + 段重标。返回是否成功处理。 */
  136 + /**
  137 + * 分块 diarize 整个会话(长会议不再只取前 N min),从 [startIdx] 段断点续跑。
  138 + * 返回**已处理到的段下标**(== session.size 表示整场跑完;< 表示预算中途用完,
  139 + * 外层据此存断点、下轮从这里续跑——块内说话人 index 不跨块,身份交全局 reclusterAll)。
  140 + */
121 141 private suspend fun diarizeSession(
122 142 repo: Repository,
123 143 session: List<Segment>,
124 144 engine: DiarizationEngine,
125 145 embedder: SpeakerEmbedder,
126   - threshold: Float,
127   - ): Boolean = withContext(Dispatchers.Default) {
128   - // 1. 解码 + 拼接,记录每段在拼接波形里的 [offset, offset+len](采样)
129   - data class SegSpan(val seg: Segment, val from: Int, val to: Int)
130   - val spans = ArrayList<SegSpan>(session.size)
131   - val chunks = ArrayList<FloatArray>(session.size) // 不装箱:存 FloatArray 块,最后一次性拼
132   - var total = 0
133   - for (seg in session) {
134   - val pcm = AudioDecoder.decodeToFloat(seg.audioPath)
135   - if (pcm.isEmpty()) continue
136   - val from = total
137   - chunks.add(pcm); total += pcm.size
138   - spans.add(SegSpan(seg, from, total))
139   - if (total >= MAX_CONCAT_SAMPLES) break
140   - }
141   - if (spans.size < MIN_SESSION_SEGMENTS || total == 0) return@withContext false
142   - val samples = FloatArray(total)
143   - var off = 0
144   - for (c in chunks) { c.copyInto(samples, off); off += c.size }
145   - chunks.clear()
146   -
147   - // 2. diarize
148   - val turns = engine.diarize(samples)
149   - if (turns.isEmpty()) return@withContext false
150   -
151   - // 3. 每段 → 重叠最大的 turn → 取该 turn 的"干净"embedding(单说话人)覆盖段 embedding。
152   - // 不在这里建全局说话人——交给最后的全局 reclusterAll 统一聚类(避免每会话贪心爆炸)。
153   - val turnEmb = HashMap<Int, FloatArray?>() // turn 下标 → embedding(缓存,多段共用一个 turn)
  146 + runStart: Long,
  147 + startIdx: Int,
  148 + ): Int = withContext(Dispatchers.Default) {
  149 + var idx = startIdx.coerceIn(0, session.size)
  150 + var nChunks = 0
  151 + var totalTurns = 0
154 152 var embedded = 0
155   - for (span in spans) {
156   - val s0 = span.from.toFloat() / sr
157   - val s1 = span.to.toFloat() / sr
158   - var bestIdx = -1
159   - var bestOv = 0f
160   - turns.forEachIndexed { i, t ->
161   - val ov = minOf(s1, t.endSec) - maxOf(s0, t.startSec)
162   - if (ov > bestOv) { bestOv = ov; bestIdx = i }
  153 + while (idx < session.size) {
  154 + if (System.currentTimeMillis() - runStart > RUN_BUDGET_MS) {
  155 + Log.i(TAG, "session ${session.first().startTs}: budget hit at seg $idx/${session.size}")
  156 + return@withContext idx // 断点,外层续跑
163 157 }
164   - if (bestIdx < 0) continue
165   - val emb = turnEmb.getOrPut(bestIdx) {
166   - val t = turns[bestIdx]
167   - val a = (t.startSec * sr).toInt().coerceIn(0, samples.size)
168   - val b = (t.endSec * sr).toInt().coerceIn(a, samples.size)
169   - if (b - a < sr / 4) null else embedder.embed(samples.copyOfRange(a, b), sr)
  158 + // 攒一块(≤CHUNK_SAMPLES),解码即拼,记录每段在块内的 [from,to]
  159 + val segs = ArrayList<Segment>()
  160 + val froms = ArrayList<Int>()
  161 + val tos = ArrayList<Int>()
  162 + val pcms = ArrayList<FloatArray>()
  163 + var len = 0
  164 + while (idx < session.size && len < CHUNK_SAMPLES) {
  165 + val seg = session[idx]; idx++
  166 + val pcm = AudioDecoder.decodeToFloat(seg.audioPath)
  167 + if (pcm.isEmpty()) continue
  168 + segs.add(seg); froms.add(len); pcms.add(pcm); len += pcm.size; tos.add(len)
170 169 }
171   - if (emb != null) {
172   - withContext(Dispatchers.IO) { repo.segments.updateEmbedding(span.seg.id, emb.toLeBytes()) }
173   - embedded++
  170 + if (segs.isEmpty()) continue
  171 + val samples = FloatArray(len)
  172 + var off = 0
  173 + for (p in pcms) { p.copyInto(samples, off); off += p.size }
  174 + pcms.clear()
  175 +
  176 + val turns = engine.diarize(samples)
  177 + if (turns.isEmpty()) continue
  178 + nChunks++; totalTurns += turns.size
  179 +
  180 + val turnEmb = HashMap<Int, FloatArray?>() // turn 下标 → embedding(多段共用同一 turn 缓存)
  181 + for (k in segs.indices) {
  182 + val s0 = froms[k].toFloat() / sr
  183 + val s1 = tos[k].toFloat() / sr
  184 + var bestIdx = -1
  185 + var bestOv = 0f
  186 + turns.forEachIndexed { i, t ->
  187 + val ov = minOf(s1, t.endSec) - maxOf(s0, t.startSec)
  188 + if (ov > bestOv) { bestOv = ov; bestIdx = i }
  189 + }
  190 + if (bestIdx < 0) continue
  191 + val emb = turnEmb.getOrPut(bestIdx) {
  192 + val t = turns[bestIdx]
  193 + val a = (t.startSec * sr).toInt().coerceIn(0, samples.size)
  194 + val b = (t.endSec * sr).toInt().coerceIn(a, samples.size)
  195 + if (b - a < sr / 4) null else embedder.embed(samples.copyOfRange(a, b), sr)
  196 + }
  197 + if (emb != null) {
  198 + withContext(Dispatchers.IO) { repo.segments.updateEmbedding(segs[k].id, emb.toLeBytes()) }
  199 + embedded++
  200 + }
174 201 }
175 202 }
176   - Log.i(TAG, "session ${session.first().startTs}: ${spans.size} segs, ${turns.size} turns, $embedded clean-embedded")
177   - embedded > 0
  203 + Log.i(TAG, "session ${session.first().startTs}: $startIdx→${session.size} segs, $nChunks chunks, $totalTurns turns, $embedded clean-embedded")
  204 + idx // == session.size,整场跑完
178 205 }
179 206 }
... ...