Commit 85e1c5139bc9c62f12e13606e9af3fc7500eefce

Authored by yibo
1 parent 95af5fc5

feat(lifelog-recorder): v0.4 — 段播放 + bottom sheet + 声纹回填

闭环 v0.3 的两个自然延伸:

- SegmentPlayer.kt: MediaPlayer 包装,Opus 段原音播放
  Android API 21+ MediaPlayer 原生解码 Ogg-Opus,验证 logcat 见
  「MetaDataBase: OpusHead..  mime: audio/opus  brte: 16000  #chn: 1」
  音频属性设 USAGE_MEDIA + CONTENT_TYPE_SPEECH,单实例复用

- SpeakerMatcher.kt: backfillAll() 遍历所有有 embedding 的段,重算
  最近邻 profile + cosine 距离;threshold 0.5 复用 RecorderService
  里的逻辑;只更新变化的行(避免 Room 全表写)
  enroll 落库后 MainActivity 自动 launch 调用

- MainActivity: 时间轴 tap row 不再直接弹 enroll dialog,改为
  ModalBottomSheet 出「▶ 播放原音 / 记为说话人」二选一
  TimelineRow 加 isPlaying 状态指示,正在播放的段前缀 "▶ "
  DisposableEffect 在 onDispose 调 player.stop() 防泄漏

HANDOFF.md §五 更新(v0.4 移到「已完成」),加 SegmentPlayer.kt +
SpeakerMatcher.kt 到文件清单。

实测(Pixel 8 Pro,电影 audio 输入):
- 段 #11-14 显示 "未匹配 (0.09-0.13)" — 电影角色声纹与 Me anchor
  cosine 远低于 0.5 → 正确拒绝匹配
- 段 #13 19s opus 文件 MediaPlayer 立刻播放,文件 ~38 KB

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
lifelog-recorder/HANDOFF.md
... ... @@ -47,10 +47,12 @@ app/src/main/kotlin/com/lemon/lifelog/
47 47 │ └── BootReceiver.kt — BOOT_COMPLETED 自启 (Android 14 有 caveat 见 §七)
48 48 ├── audio/
49 49 │ ├── AudioCapture.kt — AudioRecord wrapper,emits FloatArray frames
50   -│ └── SegmentWriter.kt — Opus encode via MediaCodec + Ogg muxer,~17× 压缩
  50 +│ ├── SegmentWriter.kt — Opus encode via MediaCodec + Ogg muxer,~17× 压缩
  51 +│ └── SegmentPlayer.kt — MediaPlayer 包装,Opus 段原音播放(系统原生支持)
51 52 ├── asr/
52 53 │ ├── ParaformerEngine.kt — sherpa-onnx paraformer 包装 ←★ 主 ASR
53 54 │ ├── SpeakerEmbedder.kt — sherpa-onnx 3dspeaker campplus 192-d 嵌入 + cosineSim
  55 +│ ├── SpeakerMatcher.kt — 回填工具:用当前 profile 集重算所有段的 speakerId
54 56 │ ├── ModelDownloader.kt — 首次启动下模型(paraformer 232 MB + speaker 28 MB)
55 57 │ └── XfyunIatClient.kt — 讯飞大模型 WS 客户端,仅 fallback / 云复核用
56 58 └── data/
... ... @@ -155,15 +157,15 @@ VAD 参数(在 `RecorderService.runPipeline` 里调):
155 157 ✅ **已完成**:
156 158 - v0.2 (2026-06):Opus 编码 / BootReceiver / 电池白名单
157 159 - v0.3 (2026-06):声纹识别(3dspeaker campplus)/ FTS4 中文搜索
  160 +- v0.4 (2026-06):段播放 (MediaPlayer Opus) / bottom sheet 操作菜单 / enroll 后自动回填老段
158 161  
159 162 剩下:
160 163  
161 164 1. **LLM 纪要 worker**:WorkManager cron。每小时 / 静音 30min 触发,按时间窗 group segments → POST 到 hermes gateway `http://127.0.0.1:8642/v1/chat/completions`(需要先把 hermes api_server platform 配置起来,见 `~/.hermes/config.yaml`)。
162 165 2. **存储 TTL**:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。
163 166 3. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(v1 定的 MVP 是「一键暂停就够」,但 v2 可能要细化)。
164   -4. **段播放**:时间轴每条点开能直接播原音(Opus 系统原生支持,MediaPlayer 就能放)。当前点开是「记录说话人」对话框,需要分离 tap vs long-press 或加二级菜单。
165   -5. **多 anchor 平均 / 声纹回填**:现在 enroll 是单段单 anchor,可以「连选多段都标 我」自动算平均 embedding;并对老段做一次性 backfill 匹配。
166   -6. **回链 lemonPCRM**:Transcript 提到的人名 / 项目 → GraphStore `MENTIONED_IN` 边。
  167 +4. **多 anchor 平均**:现在 enroll 是单段单 anchor,可以「连选多段都标 我」自动算平均 embedding 提高匹配率。
  168 +5. **回链 lemonPCRM**:Transcript 提到的人名 / 项目 → GraphStore `MENTIONED_IN` 边。
167 169  
168 170 ### 存储预算(v0.2 后)
169 171  
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
... ... @@ -30,13 +30,16 @@ import androidx.compose.foundation.text.KeyboardOptions
30 30 import androidx.compose.foundation.verticalScroll
31 31 import androidx.compose.material3.AlertDialog
32 32 import androidx.compose.material3.Button
  33 +import androidx.compose.material3.ExperimentalMaterial3Api
33 34 import androidx.compose.material3.HorizontalDivider
34 35 import androidx.compose.material3.MaterialTheme
  36 +import androidx.compose.material3.ModalBottomSheet
35 37 import androidx.compose.material3.OutlinedTextField
36 38 import androidx.compose.material3.Surface
37 39 import androidx.compose.material3.Text
38 40 import androidx.compose.material3.TextButton
39 41 import androidx.compose.material3.TextField
  42 +import androidx.compose.material3.rememberModalBottomSheetState
40 43 import androidx.compose.runtime.Composable
41 44 import androidx.compose.runtime.collectAsState
42 45 import androidx.compose.runtime.DisposableEffect
... ... @@ -54,10 +57,12 @@ import androidx.core.content.ContextCompat
54 57 import com.lemon.lifelog.asr.ModelDownloader
55 58 import com.lemon.lifelog.asr.ParaformerEngine
56 59 import com.lemon.lifelog.asr.SpeakerEmbedder
  60 +import com.lemon.lifelog.asr.SpeakerMatcher
57 61 import com.lemon.lifelog.asr.XfyunIatClient
58 62 import com.lemon.lifelog.asr.toFloatArrayLe
59 63 import com.lemon.lifelog.asr.toLeBytes
60 64 import com.lemon.lifelog.asr.transcribeWithStats
  65 +import com.lemon.lifelog.audio.SegmentPlayer
61 66 import com.lemon.lifelog.data.Repository
62 67 import com.lemon.lifelog.data.Segment
63 68 import com.lemon.lifelog.data.SegmentRow
... ... @@ -112,8 +117,12 @@ private fun MainScreen() {
112 117 val timeline by timelineFlow.collectAsState(initial = emptyList())
113 118 val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList())
114 119  
115   - // Enrollment dialog state.
  120 + // Sheet / dialog state.
  121 + var actionRow by remember { mutableStateOf<SegmentRow?>(null) }
116 122 var enrollFor by remember { mutableStateOf<SegmentRow?>(null) }
  123 + val player = remember { SegmentPlayer() }
  124 + var nowPlayingId by remember { mutableStateOf<Long?>(null) }
  125 + DisposableEffect(player) { onDispose { player.stop() } }
117 126  
118 127 // Battery optimisation exemption — recompute whenever activity resumes
119 128 // (user may have just returned from the system settings dialog).
... ... @@ -210,7 +219,11 @@ private fun MainScreen() {
210 219 verticalArrangement = Arrangement.spacedBy(4.dp),
211 220 ) {
212 221 items(timeline, key = { it.id }) { row ->
213   - TimelineRow(row, onClick = { enrollFor = row })
  222 + TimelineRow(
  223 + row = row,
  224 + isPlaying = nowPlayingId == row.id,
  225 + onClick = { actionRow = row },
  226 + )
214 227 }
215 228 }
216 229  
... ... @@ -247,6 +260,29 @@ private fun MainScreen() {
247 260 Text(asrLog, style = MaterialTheme.typography.bodySmall)
248 261 }
249 262  
  263 + // Bottom sheet with row actions.
  264 + actionRow?.let { row ->
  265 + SegmentActionSheet(
  266 + row = row,
  267 + isPlaying = nowPlayingId == row.id,
  268 + onDismiss = { actionRow = null },
  269 + onPlay = {
  270 + if (nowPlayingId == row.id) {
  271 + player.stop()
  272 + nowPlayingId = null
  273 + } else {
  274 + nowPlayingId = row.id
  275 + player.play(row.audioPath) { nowPlayingId = null }
  276 + }
  277 + actionRow = null
  278 + },
  279 + onEnroll = {
  280 + enrollFor = row
  281 + actionRow = null
  282 + },
  283 + )
  284 + }
  285 +
250 286 // Speaker enrollment dialog.
251 287 enrollFor?.let { row ->
252 288 EnrollSpeakerDialog(
... ... @@ -258,7 +294,9 @@ private fun MainScreen() {
258 294 enrollFor = null
259 295 if (target != null && name.isNotBlank()) {
260 296 scope.launch {
261   - enrollSpeakerForSegment(activity, repo, target.id, name)
  297 + enrollSpeakerForSegment(repo, target.id, name)
  298 + // Backfill all older segments with embeddings against the new profile.
  299 + SpeakerMatcher.backfillAll(repo)
262 300 }
263 301 }
264 302 },
... ... @@ -266,6 +304,54 @@ private fun MainScreen() {
266 304 }
267 305 }
268 306  
  307 +@OptIn(ExperimentalMaterial3Api::class)
  308 +@Composable
  309 +private fun SegmentActionSheet(
  310 + row: SegmentRow,
  311 + isPlaying: Boolean,
  312 + onDismiss: () -> Unit,
  313 + onPlay: () -> Unit,
  314 + onEnroll: () -> Unit,
  315 +) {
  316 + val sheetState = rememberModalBottomSheetState()
  317 + ModalBottomSheet(
  318 + onDismissRequest = onDismiss,
  319 + sheetState = sheetState,
  320 + ) {
  321 + Column(
  322 + modifier = Modifier.fillMaxWidth().padding(16.dp),
  323 + verticalArrangement = Arrangement.spacedBy(8.dp),
  324 + ) {
  325 + Text(
  326 + "段 #${row.id} · ${"%.1fs".format(row.durationMs / 1000.0)}",
  327 + style = MaterialTheme.typography.titleMedium,
  328 + )
  329 + row.text?.let {
  330 + Text(it, style = MaterialTheme.typography.bodyMedium)
  331 + }
  332 + row.speakerName?.let { name ->
  333 + Text(
  334 + "已识别:$name (sim=${"%.2f".format(row.speakerSim ?: 0f)})",
  335 + style = MaterialTheme.typography.labelMedium,
  336 + )
  337 + }
  338 + Spacer(Modifier.height(8.dp))
  339 + Button(
  340 + onClick = onPlay,
  341 + modifier = Modifier.fillMaxWidth(),
  342 + ) {
  343 + Text(if (isPlaying) "■ 停止" else "▶ 播放原音")
  344 + }
  345 + Button(
  346 + onClick = onEnroll,
  347 + modifier = Modifier.fillMaxWidth(),
  348 + ) {
  349 + Text("记为说话人")
  350 + }
  351 + }
  352 + }
  353 +}
  354 +
269 355 @Composable
270 356 private fun EnrollSpeakerDialog(
271 357 row: SegmentRow,
... ... @@ -317,7 +403,6 @@ private fun EnrollSpeakerDialog(
317 403 * Future segments will be auto-matched against it (see RecorderService.refreshProfiles).
318 404 */
319 405 private suspend fun enrollSpeakerForSegment(
320   - ctx: Context,
321 406 repo: Repository,
322 407 segmentId: Long,
323 408 name: String,
... ... @@ -373,7 +458,7 @@ private fun requestBatteryOptExemption(ctx: Context) {
373 458 private val TIMELINE_FMT = SimpleDateFormat("HH:mm:ss", Locale.US)
374 459  
375 460 @Composable
376   -private fun TimelineRow(row: SegmentRow, onClick: () -> Unit) {
  461 +private fun TimelineRow(row: SegmentRow, isPlaying: Boolean, onClick: () -> Unit) {
377 462 val time = TIMELINE_FMT.format(Date(row.startTs))
378 463 val durSec = "%.1fs".format(row.durationMs / 1000.0)
379 464 val speakerLabel = when {
... ... @@ -382,6 +467,7 @@ private fun TimelineRow(row: SegmentRow, onClick: () -&gt; Unit) {
382 467 row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})"
383 468 else -> "—"
384 469 }
  470 + val playMarker = if (isPlaying) "▶ " else ""
385 471 Column(
386 472 modifier = Modifier
387 473 .fillMaxWidth()
... ... @@ -389,7 +475,7 @@ private fun TimelineRow(row: SegmentRow, onClick: () -&gt; Unit) {
389 475 .padding(vertical = 4.dp),
390 476 ) {
391 477 Text(
392   - "$time · $durSec · #${row.id} · $speakerLabel",
  478 + "$playMarker$time · $durSec · #${row.id} · $speakerLabel",
393 479 style = MaterialTheme.typography.labelSmall,
394 480 )
395 481 Text(
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerMatcher.kt 0 → 100644
  1 +package com.lemon.lifelog.asr
  2 +
  3 +import android.util.Log
  4 +import com.lemon.lifelog.data.Repository
  5 +import kotlinx.coroutines.Dispatchers
  6 +import kotlinx.coroutines.withContext
  7 +
  8 +/**
  9 + * Backfill speakerId / speakerSim for all segments that already have a stored embedding,
  10 + * using the current SpeakerProfile set. Cheap enough to run synchronously after
  11 + * any enroll / delete profile event (192-d cosine × O(segments × profiles)).
  12 + */
  13 +object SpeakerMatcher {
  14 + private const val TAG = "SpeakerMatcher"
  15 + const val MATCH_THRESHOLD = 0.5f
  16 +
  17 + suspend fun backfillAll(repo: Repository): BackfillStats = withContext(Dispatchers.IO) {
  18 + val profiles = repo.speakers.all().map { p ->
  19 + Cached(p.id, p.name, p.embedding.toFloatArrayLe())
  20 + }
  21 + val segments = repo.segments.allWithEmbedding()
  22 + var assigned = 0
  23 + var unassigned = 0
  24 + for (seg in segments) {
  25 + val emb = seg.embedding?.toFloatArrayLe() ?: continue
  26 + val (newId, newSim) = if (profiles.isEmpty()) {
  27 + null to null
  28 + } else {
  29 + var bestId: Long? = null
  30 + var bestSim = -1f
  31 + for (p in profiles) {
  32 + val sim = cosineSim(emb, p.embedding)
  33 + if (sim > bestSim) {
  34 + bestSim = sim
  35 + bestId = p.id
  36 + }
  37 + }
  38 + if (bestSim >= MATCH_THRESHOLD) bestId to bestSim else null to bestSim
  39 + }
  40 + if (newId != seg.speakerId || newSim != seg.speakerSim) {
  41 + repo.segments.updateSpeaker(seg.id, newId, newSim)
  42 + }
  43 + if (newId != null) assigned++ else unassigned++
  44 + }
  45 + Log.i(TAG, "backfill: ${segments.size} segments, $assigned matched, $unassigned unmatched")
  46 + BackfillStats(total = segments.size, matched = assigned, unmatched = unassigned)
  47 + }
  48 +
  49 + private data class Cached(val id: Long, val name: String, val embedding: FloatArray)
  50 +
  51 + data class BackfillStats(val total: Int, val matched: Int, val unmatched: Int)
  52 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/audio/SegmentPlayer.kt 0 → 100644
  1 +package com.lemon.lifelog.audio
  2 +
  3 +import android.media.AudioAttributes
  4 +import android.media.MediaPlayer
  5 +import android.util.Log
  6 +
  7 +/**
  8 + * One-at-a-time MediaPlayer for Opus segments.
  9 + * Android MediaPlayer natively decodes Ogg-Opus (API 21+).
  10 + *
  11 + * Lifecycle: call [play] to start a new clip (any previous one is released);
  12 + * [stop] when navigating away.
  13 + */
  14 +class SegmentPlayer {
  15 + companion object {
  16 + private const val TAG = "SegmentPlayer"
  17 + }
  18 +
  19 + private var player: MediaPlayer? = null
  20 + private var currentPath: String? = null
  21 +
  22 + val nowPlaying: String? get() = currentPath
  23 +
  24 + fun play(path: String, onComplete: () -> Unit) {
  25 + stop()
  26 + currentPath = path
  27 + player = MediaPlayer().apply {
  28 + setAudioAttributes(
  29 + AudioAttributes.Builder()
  30 + .setUsage(AudioAttributes.USAGE_MEDIA)
  31 + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
  32 + .build(),
  33 + )
  34 + try {
  35 + setDataSource(path)
  36 + setOnCompletionListener {
  37 + currentPath = null
  38 + onComplete()
  39 + }
  40 + setOnErrorListener { _, what, extra ->
  41 + Log.w(TAG, "MediaPlayer error what=$what extra=$extra path=$path")
  42 + currentPath = null
  43 + onComplete()
  44 + true
  45 + }
  46 + prepare()
  47 + start()
  48 + Log.i(TAG, "playing $path duration=${duration}ms")
  49 + } catch (t: Throwable) {
  50 + Log.e(TAG, "failed to play $path", t)
  51 + release()
  52 + currentPath = null
  53 + onComplete()
  54 + }
  55 + }
  56 + }
  57 +
  58 + fun stop() {
  59 + player?.let { mp ->
  60 + try {
  61 + if (mp.isPlaying) mp.stop()
  62 + } catch (_: IllegalStateException) {
  63 + }
  64 + mp.release()
  65 + }
  66 + player = null
  67 + currentPath = null
  68 + }
  69 +}
... ...