Commit 6ea7f30be2448e812bd68c8456a5bc57d54f0d9d

Authored by yibo
1 parent 67e45743

fix(lifelog/diarize): v1.1 全局重聚类 + 修 OOM

解决 v1 的说话人爆炸(98)+ 内存崩溃:

- 不再每会话贪心建全局说话人。改为:diarization 给每段存"干净"的
  dominant-turn embedding(覆盖实时的混合 embedding),run 末尾跑一次
  SpeakerClusterer.reclusterAll 做全局聚类(本就全局 + 保留人工命名锚点)。
- 修 OutOfMemoryError:拼接会话音频原用 ArrayList<Float>(每 float 装箱 16B,
  6min 会话 ~92MB 垃圾)→ 改 List<FloatArray> 一次性 copy;AudioDecoder
  的 ArrayList<Short> → ByteArrayOutputStream。MAX_CONCAT 18→10min 留余量。

注:Daos.SegmentDao.updateEmbedding 这一行(diarization 需要)未在此 commit,
随 photolog WIP 一起;提交 photolog 时带上即可。

实测:无 OOM;但说话人仍 89——因单轮 7min 预算只 diarize 了 3/14 会话,
其余仍是脏 embedding。periodic 多跑几轮全部 diarize 后才会真正收缩。
有声书测试数据本就对声纹是地狱级,真实多人数据 + 阈值微调是正路。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/audio/AudioDecoder.kt
@@ -41,7 +41,7 @@ object AudioDecoder { @@ -41,7 +41,7 @@ object AudioDecoder {
41 var srcRate = inFormat.getIntOr(MediaFormat.KEY_SAMPLE_RATE, TARGET_SR) 41 var srcRate = inFormat.getIntOr(MediaFormat.KEY_SAMPLE_RATE, TARGET_SR)
42 var channels = inFormat.getIntOr(MediaFormat.KEY_CHANNEL_COUNT, 1) 42 var channels = inFormat.getIntOr(MediaFormat.KEY_CHANNEL_COUNT, 1)
43 43
44 - val shorts = ArrayList<Short>(TARGET_SR * 30) 44 + val pcm = java.io.ByteArrayOutputStream(TARGET_SR * 60) // 累积 PCM16 字节,不装箱
45 val info = MediaCodec.BufferInfo() 45 val info = MediaCodec.BufferInfo()
46 var inEos = false 46 var inEos = false
47 var outEos = false 47 var outEos = false
@@ -72,9 +72,10 @@ object AudioDecoder { @@ -72,9 +72,10 @@ object AudioDecoder {
72 oi >= 0 -> { 72 oi >= 0 -> {
73 if (info.size > 0) { 73 if (info.size > 0) {
74 val ob = codec.getOutputBuffer(oi)!! 74 val ob = codec.getOutputBuffer(oi)!!
75 - ob.position(info.offset); ob.limit(info.offset + info.size)  
76 - val sb = ob.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer()  
77 - while (sb.hasRemaining()) shorts.add(sb.get()) 75 + val bytes = ByteArray(info.size)
  76 + ob.position(info.offset)
  77 + ob.get(bytes)
  78 + pcm.write(bytes)
78 } 79 }
79 codec.releaseOutputBuffer(oi, false) 80 codec.releaseOutputBuffer(oi, false)
80 if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) outEos = true 81 if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) outEos = true
@@ -83,15 +84,18 @@ object AudioDecoder { @@ -83,15 +84,18 @@ object AudioDecoder {
83 } 84 }
84 codec.stop(); codec.release() 85 codec.stop(); codec.release()
85 86
  87 + val raw = pcm.toByteArray()
  88 + val sbuf = java.nio.ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer()
  89 + val nShorts = sbuf.remaining()
86 // 下混 stereo → mono 90 // 下混 stereo → mono
87 var mono = if (channels >= 2) { 91 var mono = if (channels >= 2) {
88 - FloatArray(shorts.size / channels) { i -> 92 + FloatArray(nShorts / channels) { i ->
89 var s = 0f 93 var s = 0f
90 - for (c in 0 until channels) s += shorts[i * channels + c] / 32768f 94 + for (c in 0 until channels) s += sbuf.get(i * channels + c) / 32768f
91 s / channels 95 s / channels
92 } 96 }
93 } else { 97 } else {
94 - FloatArray(shorts.size) { shorts[it] / 32768f } 98 + FloatArray(nShorts) { sbuf.get(it) / 32768f }
95 } 99 }
96 if (srcRate != TARGET_SR) mono = resample(mono, srcRate, TARGET_SR) 100 if (srcRate != TARGET_SR) mono = resample(mono, srcRate, TARGET_SR)
97 mono 101 mono
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/diarize/DiarizationWorker.kt
@@ -8,6 +8,7 @@ import com.lemon.lifelog.asr.DiarizationEngine @@ -8,6 +8,7 @@ import com.lemon.lifelog.asr.DiarizationEngine
8 import com.lemon.lifelog.asr.ModelDownloader 8 import com.lemon.lifelog.asr.ModelDownloader
9 import com.lemon.lifelog.asr.SpeakerClusterer 9 import com.lemon.lifelog.asr.SpeakerClusterer
10 import com.lemon.lifelog.asr.SpeakerEmbedder 10 import com.lemon.lifelog.asr.SpeakerEmbedder
  11 +import com.lemon.lifelog.asr.toLeBytes
11 import com.lemon.lifelog.audio.AudioDecoder 12 import com.lemon.lifelog.audio.AudioDecoder
12 import com.lemon.lifelog.data.Repository 13 import com.lemon.lifelog.data.Repository
13 import com.lemon.lifelog.data.Segment 14 import com.lemon.lifelog.data.Segment
@@ -35,7 +36,7 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke @@ -35,7 +36,7 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke
35 private val isRunning = AtomicBoolean(false) 36 private val isRunning = AtomicBoolean(false)
36 const val RUN_BUDGET_MS = 7 * 60 * 1000L 37 const val RUN_BUDGET_MS = 7 * 60 * 1000L
37 const val MIN_SESSION_SEGMENTS = 2 // 单段会话无需 diarize 38 const val MIN_SESSION_SEGMENTS = 2 // 单段会话无需 diarize
38 - const val MAX_CONCAT_SAMPLES = 18 * 60 * 16000 // ≤18min speech,防内存/超时 39 + const val MAX_CONCAT_SAMPLES = 10 * 60 * 16000 // ≤10min speech,防 OOM(与 photolog 共进程)/超时
39 private const val KEY_DONE = "diarized_sessions" // prefs StringSet of windowStartTs 40 private const val KEY_DONE = "diarized_sessions" // prefs StringSet of windowStartTs
40 } 41 }
41 42
@@ -105,6 +106,12 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke @@ -105,6 +106,12 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke
105 embedder?.release() 106 embedder?.release()
106 } 107 }
107 108
  109 + // 全局重聚类:用 diarization 产出的"干净"per-turn embedding 统一聚类
  110 + //(reclusterAll 本就全局 + 保留人工命名锚点)。只在有新会话被处理时跑,处理完即稳定。
  111 + if (processed > 0) {
  112 + val n = SpeakerClusterer.reclusterAll(repo, matchThreshold = threshold)
  113 + Log.i(TAG, "global recluster → $n speakers")
  114 + }
108 prefs.edit().putStringSet(KEY_DONE, done).apply() 115 prefs.edit().putStringSet(KEY_DONE, done).apply()
109 Log.i(TAG, "done: ${sessions.size} sessions, $processed diarized") 116 Log.i(TAG, "done: ${sessions.size} sessions, $processed diarized")
110 return Result.success() 117 return Result.success()
@@ -121,55 +128,52 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke @@ -121,55 +128,52 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke
121 // 1. 解码 + 拼接,记录每段在拼接波形里的 [offset, offset+len](采样) 128 // 1. 解码 + 拼接,记录每段在拼接波形里的 [offset, offset+len](采样)
122 data class SegSpan(val seg: Segment, val from: Int, val to: Int) 129 data class SegSpan(val seg: Segment, val from: Int, val to: Int)
123 val spans = ArrayList<SegSpan>(session.size) 130 val spans = ArrayList<SegSpan>(session.size)
124 - val audio = ArrayList<Float>(sr * 60) 131 + val chunks = ArrayList<FloatArray>(session.size) // 不装箱:存 FloatArray 块,最后一次性拼
  132 + var total = 0
125 for (seg in session) { 133 for (seg in session) {
126 val pcm = AudioDecoder.decodeToFloat(seg.audioPath) 134 val pcm = AudioDecoder.decodeToFloat(seg.audioPath)
127 if (pcm.isEmpty()) continue 135 if (pcm.isEmpty()) continue
128 - val from = audio.size  
129 - for (v in pcm) audio.add(v)  
130 - spans.add(SegSpan(seg, from, audio.size))  
131 - if (audio.size >= MAX_CONCAT_SAMPLES) break 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
132 } 140 }
133 - if (spans.size < MIN_SESSION_SEGMENTS || audio.isEmpty()) return@withContext false  
134 - val samples = FloatArray(audio.size) { audio[it] } 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()
135 146
136 // 2. diarize 147 // 2. diarize
137 val turns = engine.diarize(samples) 148 val turns = engine.diarize(samples)
138 if (turns.isEmpty()) return@withContext false 149 if (turns.isEmpty()) return@withContext false
139 150
140 - // 3. 每个局部说话人 → 最长 turn 的 embedding → 桥接全局  
141 - val byLocal = turns.groupBy { it.speaker }  
142 - val clusterer = SpeakerClusterer(repo, matchThreshold = threshold).also { it.load() }  
143 - val localToGlobal = HashMap<Int, Pair<Long, Float>>() // local → (globalSpeakerId, sim)  
144 - for ((local, ts) in byLocal) {  
145 - // 局部说话人总时长 <2s 视为噪声,不桥接(避免一堆短 turn 各建一个全局说话人)。  
146 - val totalSec = ts.sumOf { (it.endSec - it.startSec).toDouble() }  
147 - if (totalSec < 2.0) continue  
148 - val longest = ts.maxByOrNull { it.endSec - it.startSec } ?: continue  
149 - val a = (longest.startSec * sr).toInt().coerceIn(0, samples.size)  
150 - val b = (longest.endSec * sr).toInt().coerceIn(a, samples.size)  
151 - if (b - a < sr / 2) continue // <0.5s 太短,跳过该局部说话人  
152 - val emb = embedder.embed(samples.copyOfRange(a, b), sr) ?: continue  
153 - val durMs = ((longest.endSec - longest.startSec) * 1000).toLong()  
154 - val asg = clusterer.assign(emb, durMs)  
155 - if (asg.speakerId != null) localToGlobal[local] = asg.speakerId to (asg.sim ?: 1f)  
156 - }  
157 - if (localToGlobal.isEmpty()) return@withContext false  
158 -  
159 - // 4. 每段 → 重叠最大的 turn → 全局 speakerId 151 + // 3. 每段 → 重叠最大的 turn → 取该 turn 的"干净"embedding(单说话人)覆盖段 embedding。
  152 + // 不在这里建全局说话人——交给最后的全局 reclusterAll 统一聚类(避免每会话贪心爆炸)。
  153 + val turnEmb = HashMap<Int, FloatArray?>() // turn 下标 → embedding(缓存,多段共用一个 turn)
  154 + var embedded = 0
160 for (span in spans) { 155 for (span in spans) {
161 val s0 = span.from.toFloat() / sr 156 val s0 = span.from.toFloat() / sr
162 val s1 = span.to.toFloat() / sr 157 val s1 = span.to.toFloat() / sr
163 - var bestTurn: DiarizationEngine.Turn? = null 158 + var bestIdx = -1
164 var bestOv = 0f 159 var bestOv = 0f
165 - for (t in turns) { 160 + turns.forEachIndexed { i, t ->
166 val ov = minOf(s1, t.endSec) - maxOf(s0, t.startSec) 161 val ov = minOf(s1, t.endSec) - maxOf(s0, t.startSec)
167 - if (ov > bestOv) { bestOv = ov; bestTurn = t } 162 + if (ov > bestOv) { bestOv = ov; bestIdx = i }
  163 + }
  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)
  170 + }
  171 + if (emb != null) {
  172 + withContext(Dispatchers.IO) { repo.segments.updateEmbedding(span.seg.id, emb.toLeBytes()) }
  173 + embedded++
168 } 174 }
169 - val g = bestTurn?.let { localToGlobal[it.speaker] } ?: continue  
170 - withContext(Dispatchers.IO) { repo.segments.updateSpeaker(span.seg.id, g.first, g.second) }  
171 } 175 }
172 - Log.i(TAG, "session ${session.first().startTs}: ${spans.size} segs, ${turns.size} turns, ${byLocal.size} local speakers")  
173 - true 176 + Log.i(TAG, "session ${session.first().startTs}: ${spans.size} segs, ${turns.size} turns, $embedded clean-embedded")
  177 + embedded > 0
174 } 178 }
175 } 179 }