Commit 0c2594c912740b391a9dcb49f0ef65d87a54fa41
1 parent
d19239ac
docs(lifelog): 同步 block-based diarization-first 管线到 HANDOFF/CLAUDE
更新录音管线描述(07f3d22 的架构改动): - §一/概述:一句话管线改为「攒块→整块 pyannote 分轮→逐 turn 转写+认人」,标注无实时字幕 - §四 架构链路:RecorderService 攒块 + BlockBuffer/BlockProcessor + Channel 解耦 + 崩溃 listPending; 补块边界参数(SILENCE_MS=5min / MAX_MS=10min);标注旧 DiarizationWorker 已删 + WM 10min 上限 - 文件地图:加 BlockBuffer/BlockProcessor/DiarizationEngine;SpeakerClusterer 标 L1/L2 - 接口表:VAD→BlockBuffer→BlockProcessor→DiarizationEngine→逐 turn 入库 - 声纹纠偏 L1 冻结 anchor + L2 多 anchor 重建;阈值默认 0.45 - changelog 加 v0.7;多 anchor 待办标完成;logcat tag 加 BlockBuffer/BlockProcessor - 版本号 v0.6 → v0.7 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
2 changed files
with
74 additions
and
36 deletions
CLAUDE.md
| @@ -10,7 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co | @@ -10,7 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co | ||
| 10 | |---|---|---|---| | 10 | |---|---|---|---| |
| 11 | | **lemonPCRM** | `server/` + `frontend/` + `packages/` | Node.js + Express 后端 + 静态前端 | 主线 | | 11 | | **lemonPCRM** | `server/` + `frontend/` + `packages/` | Node.js + Express 后端 + 静态前端 | 主线 | |
| 12 | | **wechat_plain** | `wechat_plain/` | Android Xposed/A11y 模块 + Pixel 部署脚本 | 主线 | | 12 | | **wechat_plain** | `wechat_plain/` | Android Xposed/A11y 模块 + Pixel 部署脚本 | 主线 | |
| 13 | -| **lifelog-recorder** | `lifelog-recorder/` | Pixel 原生 Android 应用(Kotlin + Compose) | v0.6 (2026-06) | | 13 | +| **lifelog-recorder** | `lifelog-recorder/` | Pixel 原生 Android 应用(Kotlin + Compose) | v0.7 (2026-06) | |
| 14 | 14 | ||
| 15 | **lemonPCRM** — lemonAI 出品的 Local-First AI 个人人脉管理系统(Personal CRM)。以人为中心、以对话为链接,通过知识图谱管理微信、LinkedIn、X 等多平台社交关系,将每个联系人与其社媒动态、会议纪要、文件、照片全面关联。 | 15 | **lemonPCRM** — lemonAI 出品的 Local-First AI 个人人脉管理系统(Personal CRM)。以人为中心、以对话为链接,通过知识图谱管理微信、LinkedIn、X 等多平台社交关系,将每个联系人与其社媒动态、会议纪要、文件、照片全面关联。 |
| 16 | 16 | ||
| @@ -163,7 +163,7 @@ Launcher (ProcessLauncher spawn / NoneLauncher 无操作) | @@ -163,7 +163,7 @@ Launcher (ProcessLauncher spawn / NoneLauncher 无操作) | ||
| 163 | 163 | ||
| 164 | 详见 `server/services/wechat/README.md`。 | 164 | 详见 `server/services/wechat/README.md`。 |
| 165 | 165 | ||
| 166 | -## lifelog-recorder(Pixel 8 Pro 24/7 录音 + 本地中文 ASR + 本地 LLM,v0.6) | 166 | +## lifelog-recorder(Pixel 8 Pro 24/7 录音 + 本地中文 ASR + 本地 LLM,v0.7) |
| 167 | 167 | ||
| 168 | > 完整接手地图、踩坑清单、命令速查见 `lifelog-recorder/HANDOFF.md`。本节只勒住整个项目的**架构骨架 + 关键决策来源**,不重复细节。 | 168 | > 完整接手地图、踩坑清单、命令速查见 `lifelog-recorder/HANDOFF.md`。本节只勒住整个项目的**架构骨架 + 关键决策来源**,不重复细节。 |
| 169 | 169 | ||
| @@ -175,18 +175,26 @@ Launcher (ProcessLauncher spawn / NoneLauncher 无操作) | @@ -175,18 +175,26 @@ Launcher (ProcessLauncher spawn / NoneLauncher 无操作) | ||
| 175 | 175 | ||
| 176 | ### 架构骨架 | 176 | ### 架构骨架 |
| 177 | 177 | ||
| 178 | +**block-based diarization-first(2026-06-07 起,commit 07f3d22)**:弃掉「录一句·转一句·实时聚类」(短句 embedding 噪声大、实时滑动聚类污染 anchor → 声纹串人)。改为攒块 → 整块 diarize → 逐 turn 转写,从源头产出单说话人干净 embedding。**没有实时字幕**,文字在块关闭后出。 | ||
| 179 | + | ||
| 178 | ``` | 180 | ``` |
| 179 | Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) | 181 | Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) |
| 180 | └ AudioRecord 16k mono PCM16 (VOICE_RECOGNITION source, no AGC/NS) | 182 | └ AudioRecord 16k mono PCM16 (VOICE_RECOGNITION source, no AGC/NS) |
| 181 | - └ Silero VAD ONNX (sherpa-onnx 包装,500ms 静音切 / 30s 强切) | ||
| 182 | - ├ paraformer-zh int8 ONNX (sherpa-onnx JNI, 4 threads, ~20× realtime) | ||
| 183 | - └ 3dspeaker campplus 192-d 嵌入 → SpeakerClusterer 在线聚类(自动「说话人N」,方案B) | ||
| 184 | - └ Room SQLite v3 (Segment/Transcript/Summary/SpeakerProfile/Chat*; addMigrations, 已去 destructive) | ||
| 185 | - └ Compose 5-tab UI (录音 / 对话 / 时间轴 / 声纹 / 纪要) | ||
| 186 | - ├ SummaryWorker:按时间间隔切「会话」→ 本地 llama-swap(8080) Qwen3.5-2B 出纪要 | ||
| 187 | - └ ChatScreen:本地 2B/4B(llama-swap 8080) + Hermes(8642→Kimi) 多端点对话 | 183 | + └ Silero VAD ONNX (500ms 静音切段 / 30s 强切) ── 段不再直接入库 |
| 184 | + └ BlockBuffer:speech-only PCM 攒「块」(filesDir/blocks/<ts>/audio.pcm + manifest) | ||
| 185 | + 关块:静音≥5min 或 块≥10min ──Channel──> BlockProcessor (与采集解耦) | ||
| 186 | + └ DiarizationEngine: pyannote 分割 + campplus + 聚类 → turns[start,end,speaker] | ||
| 187 | + └ 每 turn: paraformer 转写 + campplus 干净嵌入 + SpeakerClusterer.assign 全局认人 | ||
| 188 | + └ Room SQLite (turn = 时间轴一行: Segment/Transcript/Summary/SpeakerProfile/Chat*) | ||
| 189 | + └ Compose 5-tab UI (录音 / 对话 / 时间轴 / 声纹 / 纪要) | ||
| 190 | + ├ SummaryWorker:按时间间隔切「会话」→ 本地 llama-swap(8080) Qwen3.5-2B 出纪要 | ||
| 191 | + └ ChatScreen:本地 2B/4B(llama-swap 8080) + Hermes(8642→Kimi) 多端点对话 | ||
| 188 | ``` | 192 | ``` |
| 189 | 193 | ||
| 194 | +**声纹纠偏 L1+L2**(`SpeakerClusterer`):L1 = reclusterAll 和 assign 都**冻结人工命名 anchor 不漂移**(止住「秦子琛吸宜博→质心漂→吞更多」雪球);L2 = 重命名/合并时 `rebuildAnchor` 用该人**全部段均值**重建质心(多 anchor,取代单段注册)。用户流程:听一段 → 命名/合并 → 「重新聚类」。阈值默认 0.45(设置页 0.35–0.65)。CAMPPlus 在短中文段上区分度有天花板,详见 memory `lifelog-block-diarization-pipeline.md`。 | ||
| 195 | + | ||
| 196 | +> ⚠️ 旧的周期 `DiarizationWorker`/`DiarizationScheduler` 已删除(diarize 现在在块关闭时当场做)。WorkManager 每 worker 有 **10min 硬上限**,是把 diarize 放进常驻前台服务而非 WM worker 的原因。 | ||
| 197 | + | ||
| 190 | **本地 LLM 栈(v0.6,零云端)**:Termux 自编 llama.cpp HEAD + llama-swap v223 监听 `127.0.0.1:8080`,按 model 名(`2b`/`4b`)热切换 Qwen3.5-{2B,4B}-Q4_K_M。纪要 worker + App 对话都打 8080;Magisk `service.d` 开机自启。完整部署/坑见 memory `pixel-local-llm-stack.md` + HANDOFF.md。 | 198 | **本地 LLM 栈(v0.6,零云端)**:Termux 自编 llama.cpp HEAD + llama-swap v223 监听 `127.0.0.1:8080`,按 model 名(`2b`/`4b`)热切换 Qwen3.5-{2B,4B}-Q4_K_M。纪要 worker + App 对话都打 8080;Magisk `service.d` 开机自启。完整部署/坑见 memory `pixel-local-llm-stack.md` + HANDOFF.md。 |
| 191 | 199 | ||
| 192 | 技术栈:Kotlin 2.1 + Compose BOM 2024.12.01 + Room 2.6.1 + WorkManager 2.10 + OkHttp 4.12,AGP 8.7.3,minSdk 30 / targetSdk 35(Android 14 强制 `foregroundServiceType` 起步),arm64-v8a 单 ABI。 | 200 | 技术栈:Kotlin 2.1 + Compose BOM 2024.12.01 + Room 2.6.1 + WorkManager 2.10 + OkHttp 4.12,AGP 8.7.3,minSdk 30 / targetSdk 35(Android 14 强制 `foregroundServiceType` 起步),arm64-v8a 单 ABI。 |
| @@ -199,9 +207,12 @@ Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) | @@ -199,9 +207,12 @@ Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) | ||
| 199 | |---|---|---|---| | 207 | |---|---|---|---| |
| 200 | | `AudioCapture` | SharedFlow | VAD | `FloatArray` 30ms 帧 (512 samples) | | 208 | | `AudioCapture` | SharedFlow | VAD | `FloatArray` 30ms 帧 (512 samples) | |
| 201 | | `Vad.acceptWaveform()` | poll | `Vad.front()` / `pop()` | `SpeechSegment(start, samples: FloatArray)` | | 209 | | `Vad.acceptWaveform()` | poll | `Vad.front()` / `pop()` | `SpeechSegment(start, samples: FloatArray)` | |
| 202 | -| `ParaformerEngine.transcribeFloat()` | sync | — | `OfflineRecognizerResult(text, tokens, timestamps, lang, emotion, event)` | | ||
| 203 | -| `SegmentWriter` | sync | filesystem | `filesDir/segments/YYYY-MM-DD/HH-MM-SS-MS.wav` | | ||
| 204 | -| `Repository.segments/transcripts` | suspend | Room | DAOs in `data/Daos.kt` | | 210 | +| `BlockBuffer.append()` | sync | filesystem | `filesDir/blocks/<startTs>/{audio.pcm, manifest.json}`(speech-only PCM + run 偏移) | |
| 211 | +| `BlockBuffer.close()` | Channel | `BlockProcessor.process()` | 块目录(关块条件:静音≥5min 或 块≥10min) | | ||
| 212 | +| `DiarizationEngine.diarize()` | sync | — | `List<Turn(startSec, endSec, speaker)>`(pyannote 分轮) | | ||
| 213 | +| `ParaformerEngine.transcribeFloat()` | sync | — | `OfflineRecognizerResult(text, tokens, timestamps, lang, emotion, event)`(逐 turn) | | ||
| 214 | +| `SegmentWriter` | sync | filesystem | `filesDir/segments/YYYY-MM-DD/HH-MM-SS-MS.opus`(turn 切片) | | ||
| 215 | +| `Repository.segments/transcripts` | suspend | Room | DAOs in `data/Daos.kt`(turn = 一行) | | ||
| 205 | 216 | ||
| 206 | **外部接口**: | 217 | **外部接口**: |
| 207 | - `ModelDownloader` → `https://hf-mirror.com/csukuangfj/sherpa-onnx-paraformer-zh-2023-09-14/`(断点续传,fallback huggingface.co) | 218 | - `ModelDownloader` → `https://hf-mirror.com/csukuangfj/sherpa-onnx-paraformer-zh-2023-09-14/`(断点续传,fallback huggingface.co) |
| @@ -251,7 +262,7 @@ Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) | @@ -251,7 +262,7 @@ Foreground Service (foregroundServiceType=microphone, WAKE_LOCK partial) | ||
| 251 | 262 | ||
| 252 | 完整待办、估时、技术路线见 `lifelog-recorder/HANDOFF.md` §五。 | 263 | 完整待办、估时、技术路线见 `lifelog-recorder/HANDOFF.md` §五。 |
| 253 | 264 | ||
| 254 | -✅ **v0.2–v0.6 已完成**:Opus 编码 / BOOT 自启 + 电池白名单 / FTS4 中文搜索 / 段播放 / **声纹在线聚类(方案B 自动 diarization)** / **会话级纪要(按时间间隔切会话)** / **5-tab UI** / **App 内对话(本地 2B/4B + Hermes)** / **本地 LLM 栈(llama-swap)** / **Room 迁移(去 destructive)**。 | 265 | +✅ **v0.2–v0.7 已完成**:Opus 编码 / BOOT 自启 + 电池白名单 / FTS4 中文搜索 / 段播放 / **声纹在线聚类** / **会话级纪要(按时间间隔切会话)** / **5-tab UI** / **App 内对话(本地 2B/4B + Hermes)** / **本地 LLM 栈(llama-swap)** / **Room 迁移(去 destructive)** / **block-based diarization-first 录音管线(v0.7,攒块→整块分轮→逐 turn 转写)** / **声纹纠偏 L1 冻结 anchor + L2 多 anchor 重建**。 |
| 255 | 266 | ||
| 256 | 剩余摘要: | 267 | 剩余摘要: |
| 257 | - ⭐ **存储 TTL**(删超 N 天 .opus,文字永留) | 268 | - ⭐ **存储 TTL**(删超 N 天 .opus,文字永留) |
lifelog-recorder/HANDOFF.md
| @@ -19,7 +19,9 @@ | @@ -19,7 +19,9 @@ | ||
| 19 | 19 | ||
| 20 | ## §一 这是什么 | 20 | ## §一 这是什么 |
| 21 | 21 | ||
| 22 | -24/7 后台录音 → VAD 切段 → 本地 paraformer 中文 ASR → 在线声纹聚类(自动「说话人N」)→ 入库 → 5-tab UI(录音/对话/时间轴/声纹/纪要)→ 按时间间隔切「会话」→ 本地 Qwen3.5 LLM 出纪要 + App 内对话。 | 22 | +24/7 后台录音 → VAD 攒「块」(静音≥5min 或 块≥10min 切)→ 整块 pyannote 分轮 → 每 turn 本地 paraformer 转写 + CAMPPlus 嵌入 + 全局认人 → 入库(**turn = 时间轴一行**)→ 5-tab UI(录音/对话/时间轴/声纹/纪要)→ 按时间间隔切「会话」→ 本地 Qwen3.5 LLM 出纪要 + App 内对话。 |
| 23 | + | ||
| 24 | +> **2026-06-07 架构改动**:录音从「录一句·转一句·实时聚类」改为 **block-based diarization-first**(commit 07f3d22)。短句(0.3-30s) embedding 区分度弱、实时滑动聚类把噪声平均进 anchor 是声纹串人根因;整块 diarize 出单说话人干净切片再嵌入才根治。**没有实时字幕**——文字在块关闭后才出。详见 §四 pipeline + memory `lifelog-block-diarization-pipeline.md`。 | ||
| 23 | 25 | ||
| 24 | **完全本地,零云端依赖**(讯飞客户端只作云复核 fallback;纪要/对话走本机 llama-swap,Hermes→Kimi 仅作对话里一个可选端点)。v0.6(2026-06-04)起接了本地 LLM 栈,详见 §六。 | 26 | **完全本地,零云端依赖**(讯飞客户端只作云复核 fallback;纪要/对话走本机 llama-swap,Hermes→Kimi 仅作对话里一个可选端点)。v0.6(2026-06-04)起接了本地 LLM 栈,详见 §六。 |
| 25 | 27 | ||
| @@ -50,17 +52,20 @@ app/src/main/kotlin/com/lemon/lifelog/ | @@ -50,17 +52,20 @@ app/src/main/kotlin/com/lemon/lifelog/ | ||
| 50 | │ ├── SummaryProgress.kt — worker→UI 实时进度 singleton(纪要 tab 的 console) | 52 | │ ├── SummaryProgress.kt — worker→UI 实时进度 singleton(纪要 tab 的 console) |
| 51 | │ └── SummaryScheduler.kt — WorkManager periodic 每 15min(UPDATE 策略) + 手动 enqueueUniqueWork | 53 | │ └── SummaryScheduler.kt — WorkManager periodic 每 15min(UPDATE 策略) + 手动 enqueueUniqueWork |
| 52 | ├── service/ | 54 | ├── service/ |
| 53 | -│ ├── RecorderService.kt — 前台服务,pipeline 主体 ←★ 改 ASR 流程改这里 | ||
| 54 | -│ ├── RecorderState.kt — StateFlow 把服务状态推到 UI | 55 | +│ ├── RecorderService.kt — 前台服务,pipeline 主体(VAD 攒块 + 块处理协程)←★ 改录音流程改这里 |
| 56 | +│ ├── BlockBuffer.kt — ★ 当前块累积器:speech PCM→audio.pcm + manifest,关块条件,listPending 崩溃恢复 | ||
| 57 | +│ ├── BlockProcessor.kt — ★ 块→turns:pyannote 分轮 + 逐 turn 转写/嵌入/认人/入库 | ||
| 58 | +│ ├── RecorderState.kt — StateFlow 把服务状态推到 UI(录音中/处理段落…,无实时字幕) | ||
| 55 | │ └── BootReceiver.kt — BOOT_COMPLETED 自启 (Android 14 有 caveat 见 §七) | 59 | │ └── BootReceiver.kt — BOOT_COMPLETED 自启 (Android 14 有 caveat 见 §七) |
| 56 | ├── audio/ | 60 | ├── audio/ |
| 57 | │ ├── AudioCapture.kt — AudioRecord wrapper,emits FloatArray frames | 61 | │ ├── AudioCapture.kt — AudioRecord wrapper,emits FloatArray frames |
| 58 | │ ├── SegmentWriter.kt — Opus encode via MediaCodec + Ogg muxer,~17× 压缩 | 62 | │ ├── SegmentWriter.kt — Opus encode via MediaCodec + Ogg muxer,~17× 压缩 |
| 59 | │ └── SegmentPlayer.kt — MediaPlayer 包装,Opus 段原音播放(系统原生支持) | 63 | │ └── SegmentPlayer.kt — MediaPlayer 包装,Opus 段原音播放(系统原生支持) |
| 60 | ├── asr/ | 64 | ├── asr/ |
| 61 | -│ ├── ParaformerEngine.kt — sherpa-onnx paraformer 包装 ←★ 主 ASR | 65 | +│ ├── ParaformerEngine.kt — sherpa-onnx paraformer 包装 ←★ 主 ASR(逐 turn 转写) |
| 66 | +│ ├── DiarizationEngine.kt — ★ sherpa-onnx 离线 diarization(pyannote 分割 + campplus + 聚类)→ turns | ||
| 62 | │ ├── SpeakerEmbedder.kt — sherpa-onnx 3dspeaker campplus 192-d 嵌入 + cosineSim + l2normalize | 67 | │ ├── SpeakerEmbedder.kt — sherpa-onnx 3dspeaker campplus 192-d 嵌入 + cosineSim + l2normalize |
| 63 | -│ ├── SpeakerClusterer.kt — ★ 方案B 在线聚类:自动发现「说话人N」+ reclusterAll 离线重算 | 68 | +│ ├── SpeakerClusterer.kt — ★ 在线聚类「说话人N」+ reclusterAll;L1 冻结命名 anchor + L2 rebuildAnchor 多 anchor |
| 64 | │ ├── SpeakerMatcher.kt — 回填工具:用当前 profile 集重算所有段的 speakerId | 69 | │ ├── SpeakerMatcher.kt — 回填工具:用当前 profile 集重算所有段的 speakerId |
| 65 | │ ├── HermesClient.kt — OpenAI 兼容(纪要 worker 用,指本地 llama-swap;readTimeout 600s) | 70 | │ ├── HermesClient.kt — OpenAI 兼容(纪要 worker 用,指本地 llama-swap;readTimeout 600s) |
| 66 | │ ├── ModelDownloader.kt — 首次启动下模型(paraformer 232 MB + speaker 28 MB) | 71 | │ ├── ModelDownloader.kt — 首次启动下模型(paraformer 232 MB + speaker 28 MB) |
| @@ -96,8 +101,8 @@ adb shell pm grant com.lemon.lifelog android.permission.RECORD_AUDIO | @@ -96,8 +101,8 @@ adb shell pm grant com.lemon.lifelog android.permission.RECORD_AUDIO | ||
| 96 | adb shell pm grant com.lemon.lifelog android.permission.POST_NOTIFICATIONS | 101 | adb shell pm grant com.lemon.lifelog android.permission.POST_NOTIFICATIONS |
| 97 | adb shell am start -n com.lemon.lifelog/.MainActivity | 102 | adb shell am start -n com.lemon.lifelog/.MainActivity |
| 98 | 103 | ||
| 99 | -# 看 pipeline 日志 | ||
| 100 | -adb logcat -v time -s RecorderService:V ParaformerEngine:V AudioCapture:V | 104 | +# 看 pipeline 日志(块攒/关/处理) |
| 105 | +adb logcat -v time -s RecorderService:V BlockBuffer:V BlockProcessor:V DiarizationEngine:V ParaformerEngine:V | ||
| 101 | 106 | ||
| 102 | # 手动启 service(绕开 UI 点按) | 107 | # 手动启 service(绕开 UI 点按) |
| 103 | adb shell 'su -c "am start-foreground-service -n com.lemon.lifelog/.service.RecorderService"' | 108 | adb shell 'su -c "am start-foreground-service -n com.lemon.lifelog/.service.RecorderService"' |
| @@ -127,39 +132,60 @@ adb shell 'su -c " | @@ -127,39 +132,60 @@ adb shell 'su -c " | ||
| 127 | 132 | ||
| 128 | ## §四 架构链路 | 133 | ## §四 架构链路 |
| 129 | 134 | ||
| 135 | +**block-based diarization-first(2026-06-07 起)**:录音端只攒块,处理端整块分轮——两端用 `Channel` 解耦,处理不阻塞采集。 | ||
| 136 | + | ||
| 130 | ``` | 137 | ``` |
| 131 | RecorderService.onCreate() | 138 | RecorderService.onCreate() |
| 132 | ├─ startForeground(NOTIFICATION_ID, notification, TYPE_MICROPHONE) | 139 | ├─ startForeground(NOTIFICATION_ID, notification, TYPE_MICROPHONE) |
| 133 | ├─ wakeLock.acquire() | 140 | ├─ wakeLock.acquire() |
| 134 | └─ scope.launch { runPipeline() } | 141 | └─ scope.launch { runPipeline() } |
| 135 | │ | 142 | │ |
| 136 | - ├─ ModelDownloader.ensure() — 232 MB 模型在 filesDir 就 skip | ||
| 137 | - ├─ ParaformerEngine.load(numThreads=4) — 加载 ~1.3s 一次性 | 143 | + ├─ ModelDownloader.ensure() — paraformer 232 MB + speaker 28 MB + pyannote 6 MB |
| 144 | + ├─ ParaformerEngine.load(numThreads=4) — 一次性常驻 | ||
| 145 | + ├─ SpeakerEmbedder.load() / SpeakerClusterer().load() | ||
| 146 | + ├─ DiarizationEngine.load(pyannote, campplus, thr=0.45) — 常驻(缺则降级单 turn) | ||
| 147 | + ├─ BlockProcessor + 处理协程:for (dir in pendingBlocks) processor.process(dir) | ||
| 148 | + ├─ BlockBuffer.listPending(filesDir/blocks) → pendingBlocks ← 崩溃恢复 | ||
| 138 | ├─ Vad(silero, 500ms silence, 300ms min, 30s max) | 149 | ├─ Vad(silero, 500ms silence, 300ms min, 30s max) |
| 139 | ├─ capture.start(scope) — AudioRecord 16k mono PCM | 150 | ├─ capture.start(scope) — AudioRecord 16k mono PCM |
| 140 | └─ capture.frames.collect { samples -> | 151 | └─ capture.frames.collect { samples -> |
| 141 | vad.acceptWaveform(samples) | 152 | vad.acceptWaveform(samples) |
| 142 | - while (!vad.empty()) { | ||
| 143 | - val seg = vad.front(); vad.pop() | ||
| 144 | - handleSegment(seg.samples) | ||
| 145 | - } | 153 | + while (!vad.empty()) { appendSpeech(vad.front().samples); vad.pop() } |
| 154 | + if (blockBuffer.shouldClose(now)) closeBlock() ← 静音/超长也要每帧查 | ||
| 146 | } | 155 | } |
| 147 | │ | 156 | │ |
| 148 | - handleSegment(samples): | ||
| 149 | - ├─ segWriter.write(samples) → WAV 到 filesDir/segments/YYYY-MM-DD/HH-MM-SS-MS.wav | ||
| 150 | - ├─ engine.transcribeFloat(samples) → text | ||
| 151 | - ├─ repo.segments.insert(Segment(..., audioPath, durationMs)) | ||
| 152 | - ├─ repo.transcripts.insert(Transcript(segmentId, text, "paraformer-zh-on-device")) | ||
| 153 | - └─ RecorderState.onSegment(text, audioMs, wallMs) → UI 实时刷新 | 157 | + appendSpeech(seg): blockBuffer.append(seg, startTs) — 跨静音/超长先关旧块 |
| 158 | + └─ BlockBuffer:speech-only PCM 追加 filesDir/blocks/<startTs>/audio.pcm | ||
| 159 | + + manifest.json 记每段 {ts, off, len}(off→真实时间映射) | ||
| 160 | + closeBlock(): blockBuffer.close() → pendingBlocks.trySend(dir) | ||
| 161 | + │ | ||
| 162 | + BlockProcessor.process(dir): — 整块一次性处理 | ||
| 163 | + ├─ BlockBuffer.load() → 整块 FloatArray + runs | ||
| 164 | + ├─ diar.diarize(samples) → turns[start,end,speaker](pyannote 分轮) | ||
| 165 | + └─ 每 turn 切片: | ||
| 166 | + ├─ asr.transcribeFloat(slice) → text | ||
| 167 | + ├─ embedder.embed(slice) → 干净单说话人 embedding | ||
| 168 | + ├─ clusterer.assign(emb) → 全局 speakerId(L1 命名 anchor 冻结) | ||
| 169 | + ├─ segWriter.write(slice) → filesDir/segments/.../<ts>.opus | ||
| 170 | + └─ repo.segments.insert + repo.transcripts.insert(turn = 时间轴一行) | ||
| 171 | + └─ dir.deleteRecursively() — 处理完即删(= 已处理标记) | ||
| 154 | ``` | 172 | ``` |
| 155 | 173 | ||
| 174 | +**没有实时字幕**:`RecorderState` 只反映「录音中 / 处理段落…」;时间轴行由 DB Flow 在块处理完后刷新。 | ||
| 175 | + | ||
| 156 | VAD 参数(在 `RecorderService.runPipeline` 里调): | 176 | VAD 参数(在 `RecorderService.runPipeline` 里调): |
| 157 | - `threshold=0.5` — Silero 阈值 | 177 | - `threshold=0.5` — Silero 阈值 |
| 158 | -- `minSilenceDuration=0.5s` — 500ms 静音才切段 | 178 | +- `minSilenceDuration=0.5s` — 500ms 静音才切「段」(段攒进块,非直接入库) |
| 159 | - `minSpeechDuration=0.3s` — 短于 300ms 的丢 | 179 | - `minSpeechDuration=0.3s` — 短于 300ms 的丢 |
| 160 | -- `maxSpeechDuration=30s` — 强切上限 | 180 | +- `maxSpeechDuration=30s` — 段强切上限 |
| 161 | - `windowSize=512` — Silero 输入窗口(32ms) | 181 | - `windowSize=512` — Silero 输入窗口(32ms) |
| 162 | 182 | ||
| 183 | +块边界参数(`BlockBuffer` companion): | ||
| 184 | +- `SILENCE_MS=5min` — 距上次语音静音超时切块 | ||
| 185 | +- `MAX_MS=10min` — 块超长强切(长会议保活,避免全程不出文字) | ||
| 186 | + | ||
| 187 | +> ⚠️ 旧的周期 `DiarizationWorker`/`DiarizationScheduler` 已删除(diarize 现在在块关闭时当场做,不再事后整体重跑)。声纹纠偏走「声纹」tab 的「重新聚类」(reclusterAll)。WorkManager 每 worker 有 **10min 硬上限**——这也是把 diarize 放进常驻前台服务而非 WM worker 的原因。 | ||
| 188 | + | ||
| 163 | --- | 189 | --- |
| 164 | 190 | ||
| 165 | ## §五 §六 待办(按优先级) | 191 | ## §五 §六 待办(按优先级) |
| @@ -170,12 +196,13 @@ VAD 参数(在 `RecorderService.runPipeline` 里调): | @@ -170,12 +196,13 @@ VAD 参数(在 `RecorderService.runPipeline` 里调): | ||
| 170 | - v0.4 (2026-06):段播放 (MediaPlayer Opus) / bottom sheet 操作菜单 / enroll 后自动回填老段 | 196 | - v0.4 (2026-06):段播放 (MediaPlayer Opus) / bottom sheet 操作菜单 / enroll 后自动回填老段 |
| 171 | - v0.5 (2026-06):hermes api_server 端点 + SummaryWorker + 纪要 UI section | 197 | - v0.5 (2026-06):hermes api_server 端点 + SummaryWorker + 纪要 UI section |
| 172 | - v0.6 (2026-06-04):**声纹在线聚类**(SpeakerClusterer,自动「说话人N」+ reclusterAll)/ **5-tab UI**(录音/对话/时间轴/声纹/纪要,时间轴全 CRUD + 批量删导出 + 按说话人/时间/未匹配筛选 + 排序)/ **真暂停**(停采集留模型)/ **会话级纪要**(按时间间隔切会话、可调阈值、要点带 [HH:mm:ss] 点击跳回时间轴、折叠头显示说话人名单)/ **App 内对话**(本地 2B/4B + Hermes 多端点流式)/ **本地 LLM 栈**(llama.cpp HEAD + llama-swap 8080,纪要+对话全本地,见 §六)/ **Room v3 迁移**(去 destructive 改 fail-loud) | 198 | - v0.6 (2026-06-04):**声纹在线聚类**(SpeakerClusterer,自动「说话人N」+ reclusterAll)/ **5-tab UI**(录音/对话/时间轴/声纹/纪要,时间轴全 CRUD + 批量删导出 + 按说话人/时间/未匹配筛选 + 排序)/ **真暂停**(停采集留模型)/ **会话级纪要**(按时间间隔切会话、可调阈值、要点带 [HH:mm:ss] 点击跳回时间轴、折叠头显示说话人名单)/ **App 内对话**(本地 2B/4B + Hermes 多端点流式)/ **本地 LLM 栈**(llama.cpp HEAD + llama-swap 8080,纪要+对话全本地,见 §六)/ **Room v3 迁移**(去 destructive 改 fail-loud) |
| 199 | +- v0.7 (2026-06-07):**block-based diarization-first 录音管线**(攒块→整块 pyannote 分轮→逐 turn 转写+嵌入+认人,turn=时间轴一行;去实时字幕;BlockBuffer/BlockProcessor;崩溃 listPending 续跑;commit 07f3d22)/ **声纹纠偏 L1+L2**(L1=reclusterAll & assign 冻结命名 anchor 不漂移止串人;L2=重命名/合并时 rebuildAnchor 用该人全部段均值重建多 anchor)/ 阈值默认 0.45、设置页 0.35-0.65 可调 | ||
| 173 | 200 | ||
| 174 | 剩下: | 201 | 剩下: |
| 175 | 202 | ||
| 176 | 1. **存储 TTL**:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。 | 203 | 1. **存储 TTL**:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。 |
| 177 | 2. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(v1 定的 MVP 是「一键暂停就够」,但 v2 可能要细化)。 | 204 | 2. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(v1 定的 MVP 是「一键暂停就够」,但 v2 可能要细化)。 |
| 178 | -3. **多 anchor 平均**:现在 enroll 是单段单 anchor,可以「连选多段都标 我」自动算平均 embedding 提高匹配率。 | 205 | +3. ✅ **多 anchor 平均**(2026-06-07 完成,L2):重命名/合并某说话人时 `rebuildAnchor` 用该人名下全部段的均值重建 anchor,取代单段注册。 |
| 179 | 4. **回链 lemonPCRM**:Transcript 提到的人名 / 项目 → GraphStore `MENTIONED_IN` 边。 | 206 | 4. **回链 lemonPCRM**:Transcript 提到的人名 / 项目 → GraphStore `MENTIONED_IN` 边。 |
| 180 | 5. ✅ **hermes gateway 自启**(2026-06-04 完成):用 Magisk `service.d` 脚本开机拉起(Termux:Boot 未装,root 方案更稳),见下方「§ 自启(Magisk service.d)」。 | 207 | 5. ✅ **hermes gateway 自启**(2026-06-04 完成):用 Magisk `service.d` 脚本开机拉起(Termux:Boot 未装,root 方案更稳),见下方「§ 自启(Magisk service.d)」。 |
| 181 | 208 | ||
| @@ -323,7 +350,7 @@ bot 现在跑得起来(v0.1 闭环已通),但还差几件让它能脱离 d | @@ -323,7 +350,7 @@ bot 现在跑得起来(v0.1 闭环已通),但还差几件让它能脱离 d | ||
| 323 | 350 | ||
| 324 | ./gradlew :app:assembleDebug --no-daemon | 351 | ./gradlew :app:assembleDebug --no-daemon |
| 325 | adb install -r app/build/outputs/apk/debug/app-debug.apk | 352 | adb install -r app/build/outputs/apk/debug/app-debug.apk |
| 326 | - adb logcat -v time -s RecorderService:V ParaformerEngine:V | 353 | + adb logcat -v time -s RecorderService:V BlockBuffer:V BlockProcessor:V ParaformerEngine:V |
| 327 | 354 | ||
| 328 | 读完先回我:(a) bot 当前在不在跑(adb shell dumpsys activity services | 355 | 读完先回我:(a) bot 当前在不在跑(adb shell dumpsys activity services |
| 329 | com.lemon.lifelog),(b) 你建议先做 §五 里哪 1-2 项,理由。 | 356 | com.lemon.lifelog),(b) 你建议先做 §五 里哪 1-2 项,理由。 |