lifelog-recorder — HANDOFF
Pixel 8 Pro 上 24/7 录音 + 本地中文 ASR + 时间轴 lifelog 应用。 接手前先把下面 §零 看完,免得踩坑。
§零 不可以的事 / 必读约束
- 不要把 Whisper 当默认 STT。Tensor G3 上 whisper-small 实测 RTF 1.76(0.57× realtime),跑不动。已确定走 sherpa-onnx + paraformer-zh int8,Tensor G3 实测 RTF 0.04(25× realtime)。
- 不要把模型文件提交到仓库。
model.int8.onnx是 232 MB,首次启动从 hf-mirror 自动下载到app.filesDir/models/paraformer-zh/。app 内只内嵌 silero VAD(629 KB)+ sample.wav(3 MB)。 - 不要从 assets 加载 onnx:sherpa-onnx native 代码对 asset-mmap 有兼容问题(v1.13.2 上 silero VAD 直接 SIGSEGV)。已改成「assets 拷到 filesDir 后用 file path 加载」,见
RecorderService.runPipeline()里 vadFile 那段。 - Tensor G3 线程甜点 = 4。实测 paraformer t=4 比 t=8 快(8 时 1×X3 + 4×A715 协调开销大于收益)。改
ParaformerEngine.load(numThreads=4)之前先 bench。 -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。- keys.properties 是 gitignored。模板在
keys.properties.example。讯飞凭证从 console 申请,写进去就行。 local.properties也 gitignored。sdk.dir=/opt/homebrew/share/android-commandlinetools(macOS brew 路径,换机改这个)。
§一 这是什么
24/7 后台录音 → VAD 切段 → 本地 paraformer 中文 ASR → 在线声纹聚类(自动「说话人N」)→ 入库 → 5-tab UI(录音/对话/时间轴/声纹/纪要)→ 按时间间隔切「会话」→ 本地 Qwen3.5 LLM 出纪要 + App 内对话。
完全本地,零云端依赖(讯飞客户端只作云复核 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 主体 ←★ 改 ASR 流程改这里
│ ├── 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
│ ├── SpeakerEmbedder.kt — sherpa-onnx 3dspeaker campplus 192-d 嵌入 + cosineSim + l2normalize
│ ├── SpeakerClusterer.kt — ★ 方案B 在线聚类:自动发现「说话人N」+ reclusterAll 离线重算
│ ├── SpeakerMatcher.kt — 回填工具:用当前 profile 集重算所有段的 speakerId
│ ├── HermesClient.kt — OpenAI 兼容(纪要 worker 用,指本地 llama-swap;readTimeout 600s)
│ ├── ModelDownloader.kt — 首次启动下模型(paraformer 232 MB + speaker 28 MB)
│ └── XfyunIatClient.kt — 讯飞大模型 WS 客户端,仅 fallback / 云复核用
└── data/
├── Entities.kt — Segment / Transcript / Summary / SpeakerProfile / TranscriptFts / ChatConversation / ChatMessage
├── Daos.kt — observeTimeline + searchTimeline(FTS4) + Speaker/Summary/ChatDao + 范围查询
├── AppDatabase.kt — Room v3,addMigrations(MIGRATION_2_3),★ 已删 fallbackToDestructiveMigration(改 schema 不写迁移会崩,绝不静默清库)
└── Repository.kt
sherpa-onnx Kotlin 绑定:copy 自 k2-fsa/sherpa-onnx v1.13.2 的 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 下 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)。
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 ParaformerEngine:V AudioCapture: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:
# 拷到 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
"'
§四 架构链路
RecorderService.onCreate()
├─ startForeground(NOTIFICATION_ID, notification, TYPE_MICROPHONE)
├─ wakeLock.acquire()
└─ scope.launch { runPipeline() }
│
├─ ModelDownloader.ensure() — 232 MB 模型在 filesDir 就 skip
├─ ParaformerEngine.load(numThreads=4) — 加载 ~1.3s 一次性
├─ 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()) {
val seg = vad.front(); vad.pop()
handleSegment(seg.samples)
}
}
│
handleSegment(samples):
├─ segWriter.write(samples) → WAV 到 filesDir/segments/YYYY-MM-DD/HH-MM-SS-MS.wav
├─ engine.transcribeFloat(samples) → text
├─ repo.segments.insert(Segment(..., audioPath, durationMs))
├─ repo.transcripts.insert(Transcript(segmentId, text, "paraformer-zh-on-device"))
└─ RecorderState.onSegment(text, audioMs, wallMs) → UI 实时刷新
VAD 参数(在 RecorderService.runPipeline 里调):
threshold=0.5— Silero 阈值minSilenceDuration=0.5s— 500ms 静音才切段minSpeechDuration=0.3s— 短于 300ms 的丢maxSpeechDuration=30s— 强切上限windowSize=512— Silero 输入窗口(32ms)
§五 §六 待办(按优先级)
✅ 已完成:
- 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
ss] 点击跳回时间轴、折叠头显示说话人名单)/ App 内对话(本地 2B/4B + Hermes 多端点流式)/ 本地 LLM 栈(llama.cpp HEAD + llama-swap 8080,纪要+对话全本地,见 §六)/ Room v3 迁移(去 destructive 改 fail-loud)
剩下:
- 存储 TTL:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。
- 设置页:存储用量、模型切换、provider 切换、暂停时段配置(v1 定的 MVP 是「一键暂停就够」,但 v2 可能要细化)。
- 多 anchor 平均:现在 enroll 是单段单 anchor,可以「连选多段都标 我」自动算平均 embedding 提高匹配率。
- 回链 lemonPCRM:Transcript 提到的人名 / 项目 → GraphStore
MENTIONED_IN边。 - ✅ hermes gateway 自启(2026-06-04 完成):用 Magisk
service.d脚本开机拉起(Termux:Boot 未装,root 方案更稳),见下方「§ 自启(Magisk service.d)」。
hermes gateway 设置(v0.5 关键前置)
# 一次性:~/.hermes/.env 末尾追加
API_SERVER_ENABLED=true
API_SERVER_HOST=127.0.0.1
API_SERVER_PORT=8642
API_SERVER_KEY=<openssl rand -hex 16>
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 </dev/null > $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):
# ~/.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 被<think>吞、流式开头吐字面"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<L> -G 50<L>)跑 ~/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<n> -G 50<n> |
§八 重要外部依赖(断网 / 升级时关注)
| 资源 | 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 ParaformerEngine:V
读完先回我:(a) bot 当前在不在跑(adb shell dumpsys activity services
com.lemon.lifelog),(b) 你建议先做 §五 里哪 1-2 项,理由。