Commit 6ea7f30be2448e812bd68c8456a5bc57d54f0d9d
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>
Showing
2 changed files
with
50 additions
and
42 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/audio/AudioDecoder.kt
| ... | ... | @@ -41,7 +41,7 @@ object AudioDecoder { |
| 41 | 41 | var srcRate = inFormat.getIntOr(MediaFormat.KEY_SAMPLE_RATE, TARGET_SR) |
| 42 | 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 | 45 | val info = MediaCodec.BufferInfo() |
| 46 | 46 | var inEos = false |
| 47 | 47 | var outEos = false |
| ... | ... | @@ -72,9 +72,10 @@ object AudioDecoder { |
| 72 | 72 | oi >= 0 -> { |
| 73 | 73 | if (info.size > 0) { |
| 74 | 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 | 80 | codec.releaseOutputBuffer(oi, false) |
| 80 | 81 | if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) outEos = true |
| ... | ... | @@ -83,15 +84,18 @@ object AudioDecoder { |
| 83 | 84 | } |
| 84 | 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 | 90 | // 下混 stereo → mono |
| 87 | 91 | var mono = if (channels >= 2) { |
| 88 | - FloatArray(shorts.size / channels) { i -> | |
| 92 | + FloatArray(nShorts / channels) { i -> | |
| 89 | 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 | 95 | s / channels |
| 92 | 96 | } |
| 93 | 97 | } else { |
| 94 | - FloatArray(shorts.size) { shorts[it] / 32768f } | |
| 98 | + FloatArray(nShorts) { sbuf.get(it) / 32768f } | |
| 95 | 99 | } |
| 96 | 100 | if (srcRate != TARGET_SR) mono = resample(mono, srcRate, TARGET_SR) |
| 97 | 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 | 8 | import com.lemon.lifelog.asr.ModelDownloader |
| 9 | 9 | import com.lemon.lifelog.asr.SpeakerClusterer |
| 10 | 10 | import com.lemon.lifelog.asr.SpeakerEmbedder |
| 11 | +import com.lemon.lifelog.asr.toLeBytes | |
| 11 | 12 | import com.lemon.lifelog.audio.AudioDecoder |
| 12 | 13 | import com.lemon.lifelog.data.Repository |
| 13 | 14 | import com.lemon.lifelog.data.Segment |
| ... | ... | @@ -35,7 +36,7 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke |
| 35 | 36 | private val isRunning = AtomicBoolean(false) |
| 36 | 37 | const val RUN_BUDGET_MS = 7 * 60 * 1000L |
| 37 | 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 | 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 | 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 | 115 | prefs.edit().putStringSet(KEY_DONE, done).apply() |
| 109 | 116 | Log.i(TAG, "done: ${sessions.size} sessions, $processed diarized") |
| 110 | 117 | return Result.success() |
| ... | ... | @@ -121,55 +128,52 @@ class DiarizationWorker(ctx: Context, params: WorkerParameters) : CoroutineWorke |
| 121 | 128 | // 1. 解码 + 拼接,记录每段在拼接波形里的 [offset, offset+len](采样) |
| 122 | 129 | data class SegSpan(val seg: Segment, val from: Int, val to: Int) |
| 123 | 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 | 133 | for (seg in session) { |
| 126 | 134 | val pcm = AudioDecoder.decodeToFloat(seg.audioPath) |
| 127 | 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 | 147 | // 2. diarize |
| 137 | 148 | val turns = engine.diarize(samples) |
| 138 | 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 | 155 | for (span in spans) { |
| 161 | 156 | val s0 = span.from.toFloat() / sr |
| 162 | 157 | val s1 = span.to.toFloat() / sr |
| 163 | - var bestTurn: DiarizationEngine.Turn? = null | |
| 158 | + var bestIdx = -1 | |
| 164 | 159 | var bestOv = 0f |
| 165 | - for (t in turns) { | |
| 160 | + turns.forEachIndexed { i, t -> | |
| 166 | 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 | } | ... | ... |