# lifelog-recorder — HANDOFF > Pixel 8 Pro 上 24/7 录音 + 本地中文 ASR + 时间轴 lifelog 应用。 > 接手前先把下面 §零 看完,免得踩坑。 --- ## §零 不可以的事 / 必读约束 1. **不要把 Whisper 当默认 STT**。Tensor G3 上 whisper-small 实测 RTF 1.76(0.57× realtime),跑不动。已确定走 **sherpa-onnx + paraformer-zh int8**,Tensor G3 实测 RTF 0.04(**25× realtime**)。 2. **不要把模型文件提交到仓库**。`model.int8.onnx` 是 232 MB,首次启动从 hf-mirror 自动下载到 `app.filesDir/models/paraformer-zh/`。app 内只内嵌 silero VAD(629 KB)+ sample.wav(3 MB)。 3. **不要从 assets 加载 onnx**:sherpa-onnx native 代码对 asset-mmap 有兼容问题(v1.13.2 上 silero VAD 直接 SIGSEGV)。已改成「assets 拷到 filesDir 后用 file path 加载」,见 `RecorderService.runPipeline()` 里 vadFile 那段。 4. **Tensor G3 线程甜点 = 4**。实测 paraformer t=4 比 t=8 快(8 时 1×X3 + 4×A715 协调开销大于收益)。改 `ParaformerEngine.load(numThreads=4)` 之前先 bench。 5. **`-G 3003`(AID_INET)**:跨设备 root + adb push 调试时,用 `su 10288 -c` 切换到 Termux uid 跑命令必须带 `-G 3003`,否则 DNS 全断。详见 `~/.claude/projects/-Users-yibo-Documents-aipcrm/memory/magisk-su-android-inet-group.md`。 6. **keys.properties 是 gitignored**。模板在 `keys.properties.example`。讯飞凭证从 [console](https://console.xfyun.cn/services/iat) 申请,写进去就行。 7. `local.properties` 也 gitignored。`sdk.dir=/opt/homebrew/share/android-commandlinetools`(macOS brew 路径,换机改这个)。 --- ## §一 这是什么 24/7 后台录音 → VAD 攒「块」(静音≥5min 或 块≥10min 切)→ 整块 pyannote 分轮 → 每 turn 本地 paraformer 转写 + CAMPPlus 嵌入 + 全局认人 → 入库(**turn = 时间轴一行**)→ 5-tab UI(录音/对话/时间轴/声纹/纪要)→ 按时间间隔切「会话」→ 本地 Qwen3.5 LLM 出纪要 + App 内对话。 > **2026-06-07 架构改动**:录音从「录一句·转一句·实时聚类」改为 **block-based diarization-first**(commit 07f3d22)。短句(0.3-30s) embedding 区分度弱、实时滑动聚类把噪声平均进 anchor 是声纹串人根因;整块 diarize 出单说话人干净切片再嵌入才根治。**没有实时字幕**——文字在块关闭后才出。详见 §四 pipeline + memory `lifelog-block-diarization-pipeline.md`。 **完全本地,零云端依赖**(讯飞客户端只作云复核 fallback;纪要/对话走本机 llama-swap,Hermes→Kimi 仅作对话里一个可选端点)。v0.6(2026-06-04)起接了本地 LLM 栈,详见 §六。 ### 实测性能(Pixel 8 Pro / Tensor G3 / Android 16 BP2A) ``` 模型冷启动: 1.3 s(一次性) VAD 延迟: <1 ms/30ms 帧 ASR: 20-32× realtime(典型段 1-10s → 50-300ms 转写) APK 大小: 62 MB(含 34 MB sherpa-onnx .so + 0.6 MB silero VAD + 3 MB sample.wav) 模型下载: 232 MB(首次启动,hf-mirror.com,10-30 秒) ``` --- ## §二 核心文件(接手只看这些) ``` app/src/main/kotlin/com/lemon/lifelog/ ├── LifelogApp.kt — Application,建 NotificationChannel ├── MainActivity.kt — Compose 5-tab 壳 + 时间轴/声纹/纪要 tab + 全 CRUD/批量/筛选 ├── ChatScreen.kt — 对话 tab(ChatGPT 式:会话列表 + 流式气泡 + 端点切换) ├── chat/ │ ├── ChatConfig.kt — 聊天端点配置(本地 8080/2b/4b 存 SharedPreferences;Hermes 走 BuildConfig) │ └── ChatClient.kt — OpenAI 兼容流式客户端(本地目标传 enable_thinking=false) ├── summary/ │ ├── SummaryWorker.kt — ★ 会话级纪要:切会话 → 选定后端(ChatConfig) 出纪要;AtomicBoolean 并发互斥 + 7min 单轮预算 + 不 retry │ ├── SummaryProgress.kt — worker→UI 实时进度 singleton(纪要 tab 的 console) │ └── SummaryScheduler.kt — WorkManager periodic 每 15min(UPDATE 策略) + 手动 enqueueUniqueWork ├── service/ │ ├── RecorderService.kt — 前台服务,pipeline 主体(VAD 攒块 + 块处理协程)←★ 改录音流程改这里 │ ├── BlockBuffer.kt — ★ 当前块累积器:speech PCM→audio.pcm + manifest,关块条件,listPending 崩溃恢复 │ ├── BlockProcessor.kt — ★ 块→turns:pyannote 分轮 + 逐 turn 转写/嵌入/认人/入库 │ ├── RecorderState.kt — StateFlow 把服务状态推到 UI(录音中/处理段落…,无实时字幕) │ └── BootReceiver.kt — BOOT_COMPLETED 自启 (Android 14 有 caveat 见 §七) ├── audio/ │ ├── AudioCapture.kt — AudioRecord wrapper,emits FloatArray frames │ ├── SegmentWriter.kt — Opus encode via MediaCodec + Ogg muxer,~17× 压缩 │ └── SegmentPlayer.kt — MediaPlayer 包装,Opus 段原音播放(系统原生支持) ├── asr/ │ ├── ParaformerEngine.kt — sherpa-onnx paraformer 包装 ←★ 主 ASR(逐 turn 转写) │ ├── DiarizationEngine.kt — ★ sherpa-onnx 离线 diarization(pyannote 分割 + campplus + 聚类)→ turns │ ├── SpeakerEmbedder.kt — sherpa-onnx 3dspeaker campplus 192-d 嵌入 + cosineSim + l2normalize │ ├── SpeakerClusterer.kt — ★ 在线聚类「说话人N」+ reclusterAll;L1 冻结命名 anchor + L2 rebuildAnchor 多 anchor │ ├── SpeakerMatcher.kt — 回填工具:用当前 profile 集重算所有段的 speakerId │ ├── HermesClient.kt — OpenAI 兼容(纪要 worker 用,指本地 llama-swap;readTimeout 600s) │ ├── ModelDownloader.kt — 首次启动下模型(paraformer 232 MB + speaker 28 MB) │ └── XfyunIatClient.kt — 讯飞大模型 WS 客户端,仅 fallback / 云复核用 ├── PhotologScreen.kt — 「人物」+「相册」一级 tab(通讯录/档案/照片网格/全相册索引+搜索+VL 控制) ├── PhotologViewModel.kt — photolog 总线(导入/标注/合并/绑声纹/AI 印象/搜图/VL 控制) ├── PersonProfileScreen.kt — 联系人档案(照片/录音/微信聊天/朋友圈/AI 印象,tab 式 + 记录搜索) ├── vision/ — ★ photolog 视觉层(人脸 + 全相册自动索引,见 §四下半) │ ├── GalleryScanner.kt — 全相册 MediaStore 游标扫描,content:// uri 原地登记(不拷文件),mediaId 去重增量 │ ├── PhotoFaceWorker.kt — Tier-0:扫描 + ML Kit 人脸 + 512d emb + GPS(快,battery-not-low) │ ├── PhotoVlWorker.kt — Tier-1:Qwen-VL 场景描述 + caption 语义向量(慢,充电时;尊重暂停标志) │ ├── PhotoVlState.kt — ★ VL 运行态 singleton(running/currentPhotoId/paused 持久化),UI 控制按钮 + 单张转圈 │ ├── PhotoScheduler.kt — 两条周期 worker(face 15min / vl 15min+charging)+ runNow/runVlNow/cancelVl │ ├── PhotoProcessor.kt — faceStage(Tier-0)/ captionStage(Tier-1),按来源解码 │ ├── FaceEngine.kt — ML Kit 检测+5点对齐 → TFLite MobileFaceNet 512d(非 onnx,避与 sherpa 冲突) │ ├── FaceMatcher.kt — 人脸归属(人物锚点+已标注脸 gallery,余弦 0.45)+ backfill │ ├── VisionClient.kt — llama-swap "vl" 模型出场景描述(不报人名) │ ├── EmbedClient.kt — llama-swap "embed" 模型出 caption 文本向量(语义搜;未配置则优雅降级) │ ├── ImageUtil.kt — EXIF/解码/缩放/embedding 打包;decode 按 source 走 file 或 content uri │ └── WeChatEnrollWorker.kt — 微信头像 → 人脸锚点入库 └── data/ ├── Entities.kt — Segment / Transcript / Summary / SpeakerProfile / TranscriptFts / Chat* / Person / Photo / Face / Moment / WechatMessage ├── Daos.kt — observeTimeline + searchTimeline(FTS4) + Photo/Face/Person/... DAO;★ photos 分层队列(pendingFace/pendingCaption) + observeProgress + searchByKeyword + captionVecs ├── AppDatabase.kt — Room **v14**,addMigrations(... MIGRATION_13_14),★ 已删 fallbackToDestructiveMigration(改 schema 不写迁移会崩,绝不静默清库) └── Repository.kt ``` **sherpa-onnx Kotlin 绑定**:copy 自 [k2-fsa/sherpa-onnx v1.13.2](https://github.com/k2-fsa/sherpa-onnx) 的 `android/SherpaOnnxAar/sherpa_onnx/src/main/java/com/k2fsa/sherpa/onnx/`,整个目录原样塞进 `app/src/main/kotlin/com/k2fsa/sherpa/onnx/`,22 个 .kt 文件,**不要手改**(升级时整目录替换)。 **JNI .so**:`app/src/main/jniLibs/arm64-v8a/`,4 个文件共 34 MB。从 [sherpa-onnx releases](https://github.com/k2-fsa/sherpa-onnx/releases) 下 `sherpa-onnx-v1.13.2-android.tar.bz2`,拷 `jniLibs/arm64-v8a/` 进来。**只装 arm64-v8a**(在 `app/build.gradle.kts` 里 abiFilters 限制了,避免 APK 多出 100 MB)。 --- ## §三 改代码 → 装机器(开发内循环) 前置:Mac brew 装 `openjdk@17`(AGP 8.7.3 要 JDK 17)+ Android SDK(`/opt/homebrew/share/android-commandlinetools`)。 ```bash cd /Users/yibo/Documents/aipcrm/lifelog-recorder export JAVA_HOME=/opt/homebrew/opt/openjdk@17 export PATH=$JAVA_HOME/bin:$PATH # build (3-10s 增量, 60-90s 冷) ./gradlew :app:assembleDebug --no-daemon # install + 授权 + 启动 adb install -r app/build/outputs/apk/debug/app-debug.apk adb shell pm grant com.lemon.lifelog android.permission.RECORD_AUDIO adb shell pm grant com.lemon.lifelog android.permission.POST_NOTIFICATIONS adb shell am start -n com.lemon.lifelog/.MainActivity # 看 pipeline 日志(块攒/关/处理) adb logcat -v time -s RecorderService:V BlockBuffer:V BlockProcessor:V DiarizationEngine:V ParaformerEngine:V # 手动启 service(绕开 UI 点按) adb shell 'su -c "am start-foreground-service -n com.lemon.lifelog/.service.RecorderService"' ``` **adb shell + su 走 Magisk root**(Pixel 8 Pro 已 root,见 `~/.claude/projects/-Users-yibo-Documents-aipcrm/memory/` 里的 root 笔记)。 ### 跳过模型下载(debug 加速) 模型 232 MB 第一次下要 10-30s。dev 时可以预先 push: ```bash # 拷到 app 私有目录 adb push /tmp/pixel-root/sherpa/models/paraformer-zh.int8.onnx /sdcard/Download/m.onnx adb push /tmp/pixel-root/sherpa/models/paraformer-zh-tokens.txt /sdcard/Download/t.txt adb shell 'su -c " APP_UID=\$(stat -c %u /data/data/com.lemon.lifelog) mkdir -p /data/data/com.lemon.lifelog/files/models/paraformer-zh install -o \$APP_UID -g \$APP_UID -m 600 /sdcard/Download/m.onnx /data/data/com.lemon.lifelog/files/models/paraformer-zh/model.int8.onnx install -o \$APP_UID -g \$APP_UID -m 600 /sdcard/Download/t.txt /data/data/com.lemon.lifelog/files/models/paraformer-zh/tokens.txt chown -R \$APP_UID:\$APP_UID /data/data/com.lemon.lifelog/files/models rm /sdcard/Download/m.onnx /sdcard/Download/t.txt "' ``` --- ## §四 架构链路 **block-based diarization-first(2026-06-07 起)**:录音端只攒块,处理端整块分轮——两端用 `Channel` 解耦,处理不阻塞采集。 ``` RecorderService.onCreate() ├─ startForeground(NOTIFICATION_ID, notification, TYPE_MICROPHONE) ├─ wakeLock.acquire() └─ scope.launch { runPipeline() } │ ├─ ModelDownloader.ensure() — paraformer 232 MB + speaker 28 MB + pyannote 6 MB ├─ ParaformerEngine.load(numThreads=4) — 一次性常驻 ├─ SpeakerEmbedder.load() / SpeakerClusterer().load() ├─ DiarizationEngine.load(pyannote, campplus, thr=0.45) — 常驻(缺则降级单 turn) ├─ BlockProcessor + 处理协程:for (dir in pendingBlocks) processor.process(dir) ├─ BlockBuffer.listPending(filesDir/blocks) → pendingBlocks ← 崩溃恢复 ├─ Vad(silero, 500ms silence, 300ms min, 30s max) ├─ capture.start(scope) — AudioRecord 16k mono PCM └─ capture.frames.collect { samples -> vad.acceptWaveform(samples) while (!vad.empty()) { appendSpeech(vad.front().samples); vad.pop() } if (blockBuffer.shouldClose(now)) closeBlock() ← 静音/超长也要每帧查 } │ appendSpeech(seg): blockBuffer.append(seg, startTs) — 跨静音/超长先关旧块 └─ BlockBuffer:speech-only PCM 追加 filesDir/blocks//audio.pcm + manifest.json 记每段 {ts, off, len}(off→真实时间映射) closeBlock(): blockBuffer.close() → pendingBlocks.trySend(dir) │ BlockProcessor.process(dir): — 整块一次性处理 ├─ BlockBuffer.load() → 整块 FloatArray + runs ├─ diar.diarize(samples) → turns[start,end,speaker](pyannote 分轮) └─ 每 turn 切片: ├─ asr.transcribeFloat(slice) → text ├─ embedder.embed(slice) → 干净单说话人 embedding ├─ clusterer.assign(emb) → 全局 speakerId(L1 命名 anchor 冻结) ├─ segWriter.write(slice) → filesDir/segments/.../.opus └─ repo.segments.insert + repo.transcripts.insert(turn = 时间轴一行) └─ dir.deleteRecursively() — 处理完即删(= 已处理标记) ``` **没有实时字幕**:`RecorderState` 只反映「录音中 / 处理段落…」;时间轴行由 DB Flow 在块处理完后刷新。 VAD 参数(在 `RecorderService.runPipeline` 里调): - `threshold=0.5` — Silero 阈值 - `minSilenceDuration=0.5s` — 500ms 静音才切「段」(段攒进块,非直接入库) - `minSpeechDuration=0.3s` — 短于 300ms 的丢 - `maxSpeechDuration=30s` — 段强切上限 - `windowSize=512` — Silero 输入窗口(32ms) 块边界参数(`BlockBuffer` companion): - `SILENCE_MS=5min` — 距上次语音静音超时切块 - `MAX_MS=10min` — 块超长强切(长会议保活,避免全程不出文字) > ⚠️ 旧的周期 `DiarizationWorker`/`DiarizationScheduler` 已删除(diarize 现在在块关闭时当场做,不再事后整体重跑)。声纹纠偏走「声纹」tab 的「重新聚类」(reclusterAll)。WorkManager 每 worker 有 **10min 硬上限**——这也是把 diarize 放进常驻前台服务而非 WM worker 的原因。 --- ### photolog 全相册自动索引(v0.8,2026-06-09) 把手机**整个相册**自动跑成可搜索内容。核心矛盾:本地 VL(Qwen2.5-VL-3B)真机 **~30-40s/张**,几千张全量 VL 是按天计的长任务 → **分层(tiered)**:快的全量先铺,慢的 VL 排队慢慢补。 ``` GalleryScanner.scan() ── MediaStore 游标遍历全部图片 └─ 只存 content:// uri(不拷文件!全相册拷一份会撑爆私有目录)+ mediaId 去重 + DATE_TAKEN → photos 行 (source='gallery', scanState=0) │ ┌──────────────────────────┴───────────────────────────┐ Tier-0 PhotoFaceWorker (15min, battery-not-low) Tier-1 PhotoVlWorker (15min, +charging) 每轮先 scan() 增量登记,再处理 scanState=0: 处理 scanState=1,优先级队列(有人脸>近期): ├─ ImageUtil.decode(ctx,photo) ← 按 source 解码 ├─ VisionClient.caption(jpeg 448px) → 场景描述 ├─ FaceEngine 人脸+512d emb + FaceMatcher 认人 ├─ EmbedClient.embed(caption) → 语义向量(可选) ├─ ImageUtil.readGps (ACCESS_MEDIA_LOCATION) └─ markCaptionDone → scanState=2 + processedAt └─ markFaceDone → scanState=1 失败 markCaptionSkipped(=2,caption 空) 不卡队列 秒级,几小时铺满全库 → 立刻可按人/时间搜 每张 7min 预算 + retry,避开 WM 10min 上限 ``` **scanState 状态机**:`0 待索引 → 1 人脸/时间已铺 → 2 已出场景描述`。迁移 13→14 把老的已处理照片(`processedAt` 非空)直接标 2 不重跑;imported 源(手动导入,拷进私有目录)走同一管线,只是 `source='imported'` 且 `decode` 走 file path。 **搜索**:① 关键词 `searchByKeyword` = caption + 人名 LIKE 子串(中文子串比 FTS4 unicode61 靠谱,跟 transcript 的 searchTextLike 一致);② 语义 `searchSemantic` = embed query → 全库 `captionVecs()` 余弦 top-K(Kotlin 算,floor 0.20)。语义要 llama-swap 配 `embed` 模型(`--embedding` 的中文 GGUF,如 bge-small-zh),没配 `EmbedClient` 抛 IOException,UI 提示「未配置 embed 端点」,不影响关键词搜。 **VL 控制(PhotoVlState)**:worker 推 `running`/`currentPhotoId`,相册页据此: - 运行中 → 顶部「⏸ 暂停」「⏹ 停止」+ 正在 VL 的那格半透明遮罩转圈; - 空闲 → 「⚡ 立即理解」(暂停且未跑完 → 「▶ 继续理解」); - **暂停** = `paused` 持久化到 prefs,worker 每张前 `isPaused()` 检查 break,**连充电时的周期 worker 也尊重**,需「继续」才恢复;**停止** = cancelVl 取消手动这轮 + 清 paused(之后充电仍自动补完)。 - 格子角标:`👤N` 人脸数 / `📝` 已出描述(可搜) / `待索引·待理解·无描述` 未处理态。 > ⚠️ 关键坑(已修):`ImageUtil.decodeUri` 里 bounds 预解码用 `inJustDecodeBounds=true`,`BitmapFactory.decodeStream` **按设计返回 null**(只填 bounds)——不能拿它的返回值判失败,否则**每张都提前 return null**,人脸全跑空。`?:` 要判 `openInputStream` 的 null。 > ⚠️ WM worker 互斥:periodic + 手动 + retry 可能并发,多 VL worker 打同一 llama-server 会 502 → `PhotoVlWorker`/`PhotoFaceWorker` 各有 `AtomicBoolean isRunning` 守。`PhotoVlState.running` 只由拿到锁那个置位。 > 全相册扫描没有 sqlite3 在设备上:dev 改 photos 表用 `adb exec-out run-as com.lemon.lifelog cat databases/lifelog.db{,-wal,-shm}` 拉到 Mac,`sqlite3 'PRAGMA wal_checkpoint(TRUNCATE);'` 合并 WAL 后改,再 `run-as sh -c 'cat > databases/lifelog.db'` 推回 + 删 wal/shm。 ### 微信朋友圈:端上 root 直读(v0.8,2026-06-09,纯本地不经 Mac) 人物档案「⬇ 下载图片」一键拉该联系人的**头像 + 朋友圈(图 + 文字)**到端上。**全程 app 内 su,不依赖 Mac 导出器**(对比:联系人/聊天仍走 Mac 解密 EnMicroMsg.db→wx_enroll,因为那库 SQLCipher 加密;朋友圈库恰好明文,所以能端上做)。 **代码**:`vision/WeChatSnsImporter.kt`(读取核心)+ `PhotologViewModel.downloadWeChatImages()`(落库)+ `PersonProfileScreen` 的「⬇ 下载图片」按钮 + 朋友圈 tab 的 `MomentRow`(渲染)。 **微信侧数据(root 才能读,都在 `com.tencent.mm` 私有目录)**: | 路径 | 内容 | |---|---| | `/data/user/0/com.tencent.mm/MicroMsg//SnsMicroMsg.db` | **明文 SQLite**(不加密!)。`SnsInfo` 表:`userName`(发布者 wxid) + `createTime`(秒) + `content`(protobuf BLOB,含正文 + 各图 media id 十进制串 + CDN URL) | | `/data/user/0/com.tencent.mm/cache//sns///snsb_` | **明文大图 JPEG**(`snst_`=缩略 PNG,`sight_`=视频)。`` 同 MicroMsg 用户目录哈希。**只有在微信里浏览过的朋友圈才有缓存** | **读取流程**(`importMomentsFor(ctx, wxid, destDir)`): 1. 一条 `su --mount-master -c`(**两个 root 坑见 §七**:mount namespace + 空 PATH)把 `SnsMicroMsg.db`(+wal/shm) 和所有 `snsb_*` 拷进 app 私有目录,`chown` 给本 app uid。 2. 系统 `SQLiteDatabase` 只读打开拷贝,`SELECT createTime, content FROM SnsInfo WHERE userName=?`。 3. **归属**:把缓存文件名里的 media id(`snsb_` 的 ``)当**子串**在该条 `content`(按 ISO-8859-1 字节)里查——命中即这张图属于这条朋友圈。真机实测命中率 100%。 4. **正文**:`momentText()` 从 content 按字符类切候选串、取中文最多的那段(移植 Mac 端 `extract-wechat-chat-moments.py`)。 5. 按「条」返回 `MomentData(createTimeMs, text, images)`。 **落库 / 展示**:朋友圈图**不进相册**(「照片」tab 只放头像 + 真正人脸认出的合照);图按条存 `filesDir/moments//_.jpg`,文字写 `moments` 表(`createTime` 存 ms)。朋友圈 tab 的 `MomentRow` 按 `createTimeMs` glob 回该条的图,渲染成微信式「正文 + 九宫格 + 时间」。幂等:头像 `getByPath` 去重、`moments.clearUser` + 目录清空重灌、开头 `idsByPathLike("%/moment__%")` 清旧版误导进相册的图。 ### 微信聊天:端上 SQLCipher 解密(v0.8,2026-06-10,P2 #6,纯本地不经 Mac) 朋友圈库明文好读,**聊天库 `EnMicroMsg.db` 是 SQLCipher 加密**——过去只能靠 Mac 解密推 `wx_enroll/messages.json`。现在端上直接解密读,**key 纯端上可推导,不用 Frida**。代码 `vision/WeChatChatImporter.kt` + `PhotologViewModel.importChatFor()`。 **key 推导(真机验证 = 旧 Frida 抓到的 `e03ce81`)**: ``` key = MD5(imei + uin).hex()[:7].lower() imei = "1234567890ABCDEF" # 微信默认值:现代 Android(10+)读不到真 IMEI 时的回退,稳定 uin = 215004919 # 从 com.tencent.mm/shared_prefs/*.xml 的 last_login_uin 读 ``` > ⚠️ imei 用**默认值**而非真 IMEI 是关键。真 IMEI 算出来不对。`WeChatChatImporter` 候选 `["1234567890ABCDEF",""]` 逐个试开兜底。 **SQLCipher 参数(微信用旧版)**:`PRAGMA cipher_compatibility = 1` + `PRAGMA cipher_page_size = 1024`(在 `SQLiteDatabaseHook.postKey` 里设)。依赖 `net.zetetic:android-database-sqlcipher:4.5.4`。 **流程**:`su --mount-master`(两坑见 §七)拷 `MicroMsg//EnMicroMsg.db`(+wal/shm) + echo uin → 算 key 候选 → `SQLiteDatabase.openDatabase(path, key, …, OPEN_READONLY, hook)`,能查 `sqlite_master` 即 key 对 → 读 `message` 表 `WHERE type=1 AND talker IN (?…)`(type=1 文本、talker=对方 wxid、isSend 0/1、createTime ms、content)→ 写 `wechat_messages`(`clearTalker` 先清再灌,幂等)。 **接线**:单人「⬇下载图片」顺带解密该人聊天;通讯录「🔄更新」**一次性解密整库**读所有 wxid(避免逐人重复解密整库,慢)。`message` 表结构等见 memory `wechat-android-decrypt`。 --- ## §五 §六 待办(按优先级) ✅ **已完成**: - v0.2 (2026-06):Opus 编码 / BootReceiver / 电池白名单 - v0.3 (2026-06):声纹识别(3dspeaker campplus)/ FTS4 中文搜索 - v0.4 (2026-06):段播放 (MediaPlayer Opus) / bottom sheet 操作菜单 / enroll 后自动回填老段 - v0.5 (2026-06):hermes api_server 端点 + SummaryWorker + 纪要 UI section - 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) - 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 可调 / **photolog 人脉 CRM 层**(人物档案 + 相册人脸聚类 + 微信通讯录/朋友圈/AI 印象) - v0.8 (2026-06-09):**全相册自动索引 + 分层管线**(GalleryScanner 扫 MediaStore 全量、content uri 原地登记不拷文件;Tier-0 人脸/时间秒级全量铺、Tier-1 VL 充电时排队补;Room v14;详见 §四 photolog 段)/ **搜图**(关键词 caption+人名 LIKE / 语义 caption 向量余弦,embed 走 llama-swap)/ **VL 控制**(PhotoVlState:暂停/继续/停止切换 + 单张转圈 + 格子状态角标) 剩下: 1. ✅ **存储 TTL**(2026-06-08 完成):`StorageManager.prune` 按天目录删超期 .opus、文字永留;每天 periodic worker;设置页「存储」段(用量 + 保留时长 + 立即清理),默认永久保留。 2. ✅ **设置页**(基本完成):`SettingsScreen` 已含 纪要模型后端 / 聚类·合并·最短识别阈值 / llama-swap 端点 / 分段间隔 / **定时不录(quiet hours)** / 存储用量+TTL / 电池豁免。 3. ✅ **多 anchor 平均**(2026-06-07 完成,L2):重命名/合并某说话人时 `rebuildAnchor` 用该人名下全部段的均值重建 anchor,取代单段注册。 4. **内部 Transcript 实体 → Person 表**(取代旧「回链 lemonPCRM」):在 **app 内部**从 `Transcript.text`/`Summary` 抽实体(人名/项目,端上 LLM 或规则 NER)→ 匹配本地 `Person` → 建「提及」关系(新表 `mentions(segmentId, personId)`),让人物档案显示「TA 被哪些录音提到」(现仅显示「TA 自己说的」=声纹段)。同段共现的人 → 人↔人边。**不再回链 Mac 端 lemonPCRM**(已废弃)。 5. ✅ **hermes gateway 自启**(2026-06-04 完成):用 Magisk `service.d` 脚本开机拉起(Termux:Boot 未装,root 方案更稳),见下方「§ 自启(Magisk service.d)」。 6. **微信抽取端上化**:消除 photolog 仅剩的 Mac 依赖(微信联系人/头像/聊天/朋友圈现由 Mac 一次性抽取推到 `filesDir/wx_enroll/`)。 ### 端上 CRM 补齐 — TODO(对齐并超越旧 lemonPCRM) > 旧 Mac 端 lemonPCRM 已废弃,能力在 app 内重建。下表是「app 当前缺、旧 lemonPCRM 有」的 6 项, > 按优先级排(P0 先做)。能力对比详见 CLAUDE.md §「App 即 Personal CRM」。 - [ ] **P0 · #5 Transcript 内容 → Person「提及」关系**(最高价值,最贴 app 形态) - 现状:录音只按**声纹**连到人(谁说的);不从转写文本抽人名/项目。 - 做法:块处理后(或事后 worker)对 `Transcript.text` 抽实体(端上 LLM 4B 或规则/词典 NER)→ 匹配本地 `Person`(name/realName/nickname/alias,拼音兜底)→ 写新表 `mentions(segmentId, personId, name, confidence)`(Room 迁移 +1)。 - 出口:`PersonProfileScreen` 加「被提到」tab(现只有「TA 自己说的」=声纹段)。 - 依赖:端上 4B 已就绪(llama-swap 8080)。 - [ ] **P1 · #1 人↔人关系(共现边)** - 做法:基于 #5,同一会话/时间段被同时提到 + 同时在场(声纹)的人 → `relations(personA, personB, kind, weight)` 共现/认识边,weight 随共现次数累加。 - 出口:人物档案「相关的人」;后续可做关系图可视化。 - [ ] **P2 · #6 微信抽取端上化**(消除最后的 Mac 依赖,工程债) - 现状:微信联系人/头像/聊天/朋友圈由 Mac 解密 `EnMicroMsg.db`(SQLCipher)+抠头像,推到 `filesDir/wx_enroll/`。 - 做法:把解密+抽取搬到端上(root + SQLCipher key 端上取),`WeChatEnrollWorker` 直接读微信库;配方见 memory `wechat-android-decrypt.md` / `wechat-avatar-face-enrollment.md`。 - [ ] **P3 · #2 Organization / Project / Event 实体**(产品决策:要不要从「联系人表」长成「图谱」) - 新增节点类型 + 与 Person 的边(属于/参与);可由 #5 的实体抽取顺带产出(项目名/公司名/事件)。 - [ ] **P3 · #3 关系分析**:关系路径(两人间最短链)/ 社群发现(聚类)/ 待跟进(followups:从录音承诺「下周给他发X」抽待办)。依赖 #1 的关系边。 - [ ] **P3 · #4 AI 能对数据做动作(agent/工具编排)**:让对话 AI 不止 RAG 问答,还能调工具建人/连边/改资料(端上轻量 tool-calling,Hermes 或本地模型)。最大、最后做。 > 路线:**P0(#5) → P1(#1) → P2(#6)**,三步把「录音→人→关系」在端上闭环;P3 三项是「要不要把 app 升级成真·知识图谱 + AI agent」的产品选择,按需再开。 ### hermes gateway 设置(v0.5 关键前置) ```bash # 一次性:~/.hermes/.env 末尾追加 API_SERVER_ENABLED=true API_SERVER_HOST=127.0.0.1 API_SERVER_PORT=8642 API_SERVER_KEY= API_SERVER_MODEL_NAME=kimi-for-coding # anthropic SDK:NousResearch hermes-agent 的 .[termux-all] 已自带(v0.15.1 = anthropic 0.87); # 旧版若缺再补:~/.hermes/hermes-agent/venv/bin/pip install 'anthropic>=0.39.0' # 启动:必须用 setsid + disown 才能在 adb shell 之外存活 cat > ~/start_gw.sh <<'EOF' #!/data/data/com.termux/files/usr/bin/bash export PATH=/data/data/com.termux/files/usr/bin:$PATH export HOME=/data/data/com.termux/files/home pkill -f 'hermes.*gateway' 2>/dev/null; sleep 2 set -a; . $HOME/.hermes/.env; set +a : > $HOME/.hermes/logs/gateway.log setsid hermes gateway run $HOME/.hermes/logs/gateway.log 2>&1 & disown $! EOF chmod 755 ~/start_gw.sh bash ~/start_gw.sh ``` lifelog 的 `keys.properties` 里 `hermes.apiUrl`(http://127.0.0.1:8642) / `hermes.apiKey` 要跟 `API_SERVER_KEY` 对得上才能调通。`network_security_config.xml` 已开 127.0.0.1 cleartext 豁免。 > [!IMPORTANT] hermes 的 Kimi provider 配置(v0.5 实测) > hermes-agent(NousResearch)原生支持 Kimi Coding Plan,配成 `model.provider=kimi-coding` + > `model.base_url=https://api.kimi.com/coding` + `model.default=kimi-for-coding`,`.env` 放 > `KIMI_API_KEY=sk-kimi-...`。运行时自动切 Anthropic 协议 + 注入 claude-code UA。 > 完整流程见 `wechat_plain/scripts/local-testkit/docs/HERMES_AGENT_PIXEL_SETUP.md`。 ### ⚠️ 必做:精简 api_server 的 token 开销(2026-06-04 实测) api_server **默认走 hermes 完整 agent**,每次纪要 prompt 注入 tools(37.8KB/17工具) + skills index(9.1KB) = **~12,500 token**,纯属浪费(纪要只要纯文本总结)。只对 api_server 关掉 tools+skills(不影响 CLI/微信 bot): ```yaml # ~/.hermes/config.yaml platform_toolsets: api_server: [] # 关 tools skills: platform_disabled: api_server: [<列出 ~/.hermes/skills 下所有 skill 名>] # 关 skills index ``` 用 hermes 自己的 config 模块改最稳(`load_config`/`save_config`,保留其他配置)。改完重启 gateway。 **实测:prompt 12,500 → 399 token(-97%),纪要正常生成。** ### §六 本地 LLM 栈(llama-swap,v0.6 — 纪要 + 对话的真后端) 零云端推理。**完整部署/坑清单见 memory `pixel-local-llm-stack.md`**,这里只列接手要点: ``` ~/llama.cpp/ — 自编 HEAD(gh-proxy.com clone 绕墙;server-tools 缺 spawn.h → 手写 Bionic 兼容头丢 $PREFIX/include) ~/llama-swap/llama-swap — v223 静态 arm64 binary,监听 127.0.0.1:8080 ~/llama-swap/config.yaml — model "2b"/"4b" → llama-server + Qwen3.5-{2B,4B}-Q4_K_M.gguf,ttl 300 热切换 ~/models/Qwen3.5-{2B,4B}-Q4_K_M.gguf — hf-mirror.com/unsloth 下,2.74GB / 1.28GB ~/start-llama.sh — launcher(起 llama-swap),被 Magisk service.d 调用 ``` - **App 接线**:`ChatConfig` 本地默认 `8080`/`2b`/`4b`(SharedPreferences 可改);`keys.properties` = `8642`(对话的 Hermes 选项 → Kimi)。纪要后端 UI 可选(2B/4B/Hermes),存 `prefs[summary_backend]`,worker 经 `ChatConfig.targetById` 解析端点(与对话共用配置)。 - **⚠️「15min 自动」≠「10min 会话间隔」**(最易混):纪要由 WorkManager periodic **每 15min**(Android periodic 硬下限,做不到更短;改间隔必须用 `ExistingPeriodicWorkPolicy.UPDATE`,KEEP 不覆盖旧排程)自动跑一次,扫**已结束**的会话。UI 里「会话间隔 10min」是**切分规则**——多长静音算另一场,跟跑纪要频率毫无关系。要比 15min 更勤只能换 AlarmManager 精确闹钟。 - **502 坑**:periodic + 手动 + retry 并发打同一个 llama-server 上游 → llama-swap `context canceled` → 502。已用 worker 内 `AtomicBoolean` 互斥 + 7min 单轮预算 + 不 `Result.retry()` 解决(见 SummaryWorker)。 - **Qwen3.5 默认开思考**:纪要 + 本地对话都要传 `chat_template_kwargs:{enable_thinking:false}` 否则 token 被 `` 吞、流式开头吐字面 `"null"`(org.json.optString 坑,ChatClient.parseDelta 已用 `isNull` 防)。 - **G3 实测**(Q4,热降频):2B ~12 t/s 生成 / 65 prefill(一篇纪要 ~48s);4B ~4 / 24(~2.6min)。 - 启动器手测:`su -G 3003 10302 -c 'bash ~/start-llama.sh'`,再 `curl 127.0.0.1:8080/v1/models`。 ### 自启(Magisk service.d) Termux:Boot 未装(且无匹配签名 APK);已 root,用 Magisk service.d 更稳(不需开 Termux app)。 `/data/adb/service.d/start-hermes-gw.sh`(root, 755):等 `boot_completed` → sleep 25 → 动态推导 Termux uid + su 路径(`/system_ext/bin/su`,**不是** `/system/bin/su`)→ 以 Termux 用户 (带 `-G 3003 -G 9997 -G 20 -G 50`)跑 `~/start_gw.sh`。实测手动执行 `launched rc=0`, gateway 以 `u0_a302` 属主起来、api_server 正常。**冷启动慢 ~3–5 分钟**(初始化时连被墙的 openrouter/nous metadata 超时),对开机服务可接受。 > 验证端到端:`adb shell "su -g 10302 -G 3003 -G 9997 -G 20302 -G 50302 10302 -c '…venv/bin/python ~/test_api.py'"` > 或 lifelog 纪要 tab「现在跑一次」(需最近 1h 有 ≥60s 语音)。 ### 存储预算(v0.2 后) - Opus 16 kbps mono = ~2 KB/s - VAD 后实际有效语音 ≈ 8 h/天 - → ~57 MB/天,~1.7 GB/月 - 转写文字(UTF-8 中文)≈ 30 字/秒 × 8h × 3600 ≈ 不到 1 MB/月 ### Android 14+ BOOT_COMPLETED FG service caveat `RecorderService` 的 `foregroundServiceType="microphone"` 是受限 FG type。从 BOOT_COMPLETED 启动**不保证总是成功**——Android 14+ 的 `ForegroundServiceStartNotAllowedException` 可能扔出来(特别是首次重启、还没用过 app 的情况)。`BootReceiver.onReceive` 已 try/catch 兜底,失败时 log + no-op,用户开 app 手动点开始就能恢复。 实测 Pixel 8 Pro Android 16 BP2A 上**重启后能自动续上**(前提:app 已经至少打开过一次 + RECORD_AUDIO 已授)。 --- ## §七 调试陷阱清单(踩过的坑) | 症状 | 原因 | 修法 | |---|---|---| | `Java_com_k2fsa_sherpa_onnx_Vad_newFromAsset` SIGSEGV | onnx asset 在 APK 内被 aapt 压缩,native mmap 不开 | 已加 `androidResources.noCompress.add("onnx")`,但保险起见 VAD 还是走 file path(runPipeline 里 vadFile 那段拷贝) | | `Load model failed: Protobuf parsing failed` | `github.com/snakers4/silero-vad/raw/master/files/silero_vad.onnx` 直接 GET 返回 HTML(GitHub LFS 拦截) | 改用 `https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx` | | paraformer t=8 比 t=4 慢 | Tensor G3 是 1×X3 + 4×A715 + 4×A510,跨集群协调开销大 | `numThreads=4` | | hf-mirror.com 偶尔 truncate | HF mirror 限速 / 中断 | `ModelDownloader` 已带断点续传 | | `am start-foreground-service` 返回 "Requires permission not exported" | 服务 `android:exported="false"`,adb shell 没权 | 用 `su -c "am ..."` 提权 | | `adb shell 'su 10288 -c "..."` 网络全断 | Magisk su 没传 supplementary groups | 加 `-G 3003 -G 9997 -G 20 -G 50` | | 全相册照片人脸全 0、`decode failed (face)` 刷屏 | `decodeUri` bounds 预解码处 `decodeStream(inJustDecodeBounds=true)` **按设计返回 null**,被 `?:` 当失败提前 return | `?:` 改判 `openInputStream` 的 null,不是 `decodeStream` 返回值(已修,见 `ImageUtil.decodeUri`) | | 语义搜 `embed 404: no router for requested model` | llama-swap 没配 `embed` 模型 | config.yaml 加一个 `embed` model(`llama-server --embedding` + 中文 GGUF);不配则只用关键词搜 | | VL 进度不动 / 点「立即理解」像没反应 | 单张 VL ~30-40s(含 llama-swap 热加载 vl 模型),且要 battery-not-low | 正常现象;看「场景描述 N/总」进度条;想自动补完插充电(周期 worker 要 charging) | | **app 内 `su -c` 读不到别 app 的 /data**(root 也报 `No such file or directory`) | app 申请的 Magisk su **默认在本 app 的挂载命名空间**,看不到 `com.tencent.mm` 等别 app 的数据目录(`adb shell` 的 su 在全局 ns 所以能看到,易误判) | `su --mount-master -c "..."`(切全局挂载命名空间)。见 `WeChatSnsImporter.su()` | | **app 内 `su -c` 脚本里 `ls/cp/find/grep` 全失败**(命令算出空值、静默 fail) | app 经 su 起的 shell **PATH 为空**(`adb shell` 继承了 PATH 所以没事) | 脚本开头 `export PATH=/system/bin:/system/xbin:$PATH`;或用绝对路径。别用 `grep -E '{n}'` 区间,改 glob | --- ## §八 重要外部依赖(断网 / 升级时关注) | 资源 | URL | 用途 | 替代 | |---|---|---|---| | sherpa-onnx Android | github.com/k2-fsa/sherpa-onnx/releases/v1.13.2 | jniLibs + Kotlin 绑定 | 自 fork | | paraformer-zh model | hf-mirror.com/csukuangfj/sherpa-onnx-paraformer-zh-2023-09-14 | 主 ASR 模型 | huggingface.co 直连 / modelscope | | silero_vad.onnx | github.com/k2-fsa/sherpa-onnx/releases/asr-models | VAD 模型(已内嵌) | 自 build silero-vad export | | Tsinghua Termux 源 | mirrors.tuna.tsinghua.edu.cn/termux | (Pixel 端 root + Termux 套件用,跟本 app 无关) | bfsu / ustc | --- ## §九 给下一个接手 agent 的 prompt ``` 你接手 lifelog-recorder(Pixel 8 Pro 上的 24/7 录音 + 本地中文 ASR app), 目录 /Users/yibo/Documents/aipcrm/lifelog-recorder/。 第一步按顺序读: 1. HANDOFF.md(本文件)—— 完整地图 + 避雷 2. app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt —— pipeline 主体 3. app/src/main/kotlin/com/lemon/lifelog/asr/ParaformerEngine.kt —— 核心 ASR 环境: - macOS + brew openjdk@17 + Android SDK at /opt/homebrew/share/android-commandlinetools - Pixel 8 Pro 已 root (Magisk),adb 直连 - 用户在 ~/.zshrc 有 CLAUDE_CODE_OAUTH_TOKEN bot 现在跑得起来(v0.1 闭环已通),但还差几件让它能脱离 dev 环境 24/7 跑的事,按 §五 §六 优先级做。改完任何 .kt 要: ./gradlew :app:assembleDebug --no-daemon adb install -r app/build/outputs/apk/debug/app-debug.apk adb logcat -v time -s RecorderService:V BlockBuffer:V BlockProcessor:V ParaformerEngine:V 读完先回我:(a) bot 当前在不在跑(adb shell dumpsys activity services com.lemon.lifelog),(b) 你建议先做 §五 里哪 1-2 项,理由。 ```