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,10 +47,12 @@ app/src/main/kotlin/com/lemon/lifelog/
47 │ └── BootReceiver.kt — BOOT_COMPLETED 自启 (Android 14 有 caveat 见 §七) 47 │ └── BootReceiver.kt — BOOT_COMPLETED 自启 (Android 14 有 caveat 见 §七)
48 ├── audio/ 48 ├── audio/
49 │ ├── AudioCapture.kt — AudioRecord wrapper,emits FloatArray frames 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 ├── asr/ 52 ├── asr/
52 │ ├── ParaformerEngine.kt — sherpa-onnx paraformer 包装 ←★ 主 ASR 53 │ ├── ParaformerEngine.kt — sherpa-onnx paraformer 包装 ←★ 主 ASR
53 │ ├── SpeakerEmbedder.kt — sherpa-onnx 3dspeaker campplus 192-d 嵌入 + cosineSim 54 │ ├── SpeakerEmbedder.kt — sherpa-onnx 3dspeaker campplus 192-d 嵌入 + cosineSim
  55 +│ ├── SpeakerMatcher.kt — 回填工具:用当前 profile 集重算所有段的 speakerId
54 │ ├── ModelDownloader.kt — 首次启动下模型(paraformer 232 MB + speaker 28 MB) 56 │ ├── ModelDownloader.kt — 首次启动下模型(paraformer 232 MB + speaker 28 MB)
55 │ └── XfyunIatClient.kt — 讯飞大模型 WS 客户端,仅 fallback / 云复核用 57 │ └── XfyunIatClient.kt — 讯飞大模型 WS 客户端,仅 fallback / 云复核用
56 └── data/ 58 └── data/
@@ -155,15 +157,15 @@ VAD 参数(在 `RecorderService.runPipeline` 里调): @@ -155,15 +157,15 @@ VAD 参数(在 `RecorderService.runPipeline` 里调):
155 ✅ **已完成**: 157 ✅ **已完成**:
156 - v0.2 (2026-06):Opus 编码 / BootReceiver / 电池白名单 158 - v0.2 (2026-06):Opus 编码 / BootReceiver / 电池白名单
157 - v0.3 (2026-06):声纹识别(3dspeaker campplus)/ FTS4 中文搜索 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 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`)。 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 2. **存储 TTL**:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。 165 2. **存储 TTL**:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。
163 3. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(v1 定的 MVP 是「一键暂停就够」,但 v2 可能要细化)。 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 ### 存储预算(v0.2 后) 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,13 +30,16 @@ import androidx.compose.foundation.text.KeyboardOptions
30 import androidx.compose.foundation.verticalScroll 30 import androidx.compose.foundation.verticalScroll
31 import androidx.compose.material3.AlertDialog 31 import androidx.compose.material3.AlertDialog
32 import androidx.compose.material3.Button 32 import androidx.compose.material3.Button
  33 +import androidx.compose.material3.ExperimentalMaterial3Api
33 import androidx.compose.material3.HorizontalDivider 34 import androidx.compose.material3.HorizontalDivider
34 import androidx.compose.material3.MaterialTheme 35 import androidx.compose.material3.MaterialTheme
  36 +import androidx.compose.material3.ModalBottomSheet
35 import androidx.compose.material3.OutlinedTextField 37 import androidx.compose.material3.OutlinedTextField
36 import androidx.compose.material3.Surface 38 import androidx.compose.material3.Surface
37 import androidx.compose.material3.Text 39 import androidx.compose.material3.Text
38 import androidx.compose.material3.TextButton 40 import androidx.compose.material3.TextButton
39 import androidx.compose.material3.TextField 41 import androidx.compose.material3.TextField
  42 +import androidx.compose.material3.rememberModalBottomSheetState
40 import androidx.compose.runtime.Composable 43 import androidx.compose.runtime.Composable
41 import androidx.compose.runtime.collectAsState 44 import androidx.compose.runtime.collectAsState
42 import androidx.compose.runtime.DisposableEffect 45 import androidx.compose.runtime.DisposableEffect
@@ -54,10 +57,12 @@ import androidx.core.content.ContextCompat @@ -54,10 +57,12 @@ import androidx.core.content.ContextCompat
54 import com.lemon.lifelog.asr.ModelDownloader 57 import com.lemon.lifelog.asr.ModelDownloader
55 import com.lemon.lifelog.asr.ParaformerEngine 58 import com.lemon.lifelog.asr.ParaformerEngine
56 import com.lemon.lifelog.asr.SpeakerEmbedder 59 import com.lemon.lifelog.asr.SpeakerEmbedder
  60 +import com.lemon.lifelog.asr.SpeakerMatcher
57 import com.lemon.lifelog.asr.XfyunIatClient 61 import com.lemon.lifelog.asr.XfyunIatClient
58 import com.lemon.lifelog.asr.toFloatArrayLe 62 import com.lemon.lifelog.asr.toFloatArrayLe
59 import com.lemon.lifelog.asr.toLeBytes 63 import com.lemon.lifelog.asr.toLeBytes
60 import com.lemon.lifelog.asr.transcribeWithStats 64 import com.lemon.lifelog.asr.transcribeWithStats
  65 +import com.lemon.lifelog.audio.SegmentPlayer
61 import com.lemon.lifelog.data.Repository 66 import com.lemon.lifelog.data.Repository
62 import com.lemon.lifelog.data.Segment 67 import com.lemon.lifelog.data.Segment
63 import com.lemon.lifelog.data.SegmentRow 68 import com.lemon.lifelog.data.SegmentRow
@@ -112,8 +117,12 @@ private fun MainScreen() { @@ -112,8 +117,12 @@ private fun MainScreen() {
112 val timeline by timelineFlow.collectAsState(initial = emptyList()) 117 val timeline by timelineFlow.collectAsState(initial = emptyList())
113 val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList()) 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 var enrollFor by remember { mutableStateOf<SegmentRow?>(null) } 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 // Battery optimisation exemption — recompute whenever activity resumes 127 // Battery optimisation exemption — recompute whenever activity resumes
119 // (user may have just returned from the system settings dialog). 128 // (user may have just returned from the system settings dialog).
@@ -210,7 +219,11 @@ private fun MainScreen() { @@ -210,7 +219,11 @@ private fun MainScreen() {
210 verticalArrangement = Arrangement.spacedBy(4.dp), 219 verticalArrangement = Arrangement.spacedBy(4.dp),
211 ) { 220 ) {
212 items(timeline, key = { it.id }) { row -> 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,6 +260,29 @@ private fun MainScreen() {
247 Text(asrLog, style = MaterialTheme.typography.bodySmall) 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 // Speaker enrollment dialog. 286 // Speaker enrollment dialog.
251 enrollFor?.let { row -> 287 enrollFor?.let { row ->
252 EnrollSpeakerDialog( 288 EnrollSpeakerDialog(
@@ -258,7 +294,9 @@ private fun MainScreen() { @@ -258,7 +294,9 @@ private fun MainScreen() {
258 enrollFor = null 294 enrollFor = null
259 if (target != null && name.isNotBlank()) { 295 if (target != null && name.isNotBlank()) {
260 scope.launch { 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,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 @Composable 355 @Composable
270 private fun EnrollSpeakerDialog( 356 private fun EnrollSpeakerDialog(
271 row: SegmentRow, 357 row: SegmentRow,
@@ -317,7 +403,6 @@ private fun EnrollSpeakerDialog( @@ -317,7 +403,6 @@ private fun EnrollSpeakerDialog(
317 * Future segments will be auto-matched against it (see RecorderService.refreshProfiles). 403 * Future segments will be auto-matched against it (see RecorderService.refreshProfiles).
318 */ 404 */
319 private suspend fun enrollSpeakerForSegment( 405 private suspend fun enrollSpeakerForSegment(
320 - ctx: Context,  
321 repo: Repository, 406 repo: Repository,
322 segmentId: Long, 407 segmentId: Long,
323 name: String, 408 name: String,
@@ -373,7 +458,7 @@ private fun requestBatteryOptExemption(ctx: Context) { @@ -373,7 +458,7 @@ private fun requestBatteryOptExemption(ctx: Context) {
373 private val TIMELINE_FMT = SimpleDateFormat("HH:mm:ss", Locale.US) 458 private val TIMELINE_FMT = SimpleDateFormat("HH:mm:ss", Locale.US)
374 459
375 @Composable 460 @Composable
376 -private fun TimelineRow(row: SegmentRow, onClick: () -> Unit) { 461 +private fun TimelineRow(row: SegmentRow, isPlaying: Boolean, onClick: () -> Unit) {
377 val time = TIMELINE_FMT.format(Date(row.startTs)) 462 val time = TIMELINE_FMT.format(Date(row.startTs))
378 val durSec = "%.1fs".format(row.durationMs / 1000.0) 463 val durSec = "%.1fs".format(row.durationMs / 1000.0)
379 val speakerLabel = when { 464 val speakerLabel = when {
@@ -382,6 +467,7 @@ private fun TimelineRow(row: SegmentRow, onClick: () -&gt; Unit) { @@ -382,6 +467,7 @@ private fun TimelineRow(row: SegmentRow, onClick: () -&gt; Unit) {
382 row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})" 467 row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})"
383 else -> "—" 468 else -> "—"
384 } 469 }
  470 + val playMarker = if (isPlaying) "▶ " else ""
385 Column( 471 Column(
386 modifier = Modifier 472 modifier = Modifier
387 .fillMaxWidth() 473 .fillMaxWidth()
@@ -389,7 +475,7 @@ private fun TimelineRow(row: SegmentRow, onClick: () -&gt; Unit) { @@ -389,7 +475,7 @@ private fun TimelineRow(row: SegmentRow, onClick: () -&gt; Unit) {
389 .padding(vertical = 4.dp), 475 .padding(vertical = 4.dp),
390 ) { 476 ) {
391 Text( 477 Text(
392 - "$time · $durSec · #${row.id} · $speakerLabel", 478 + "$playMarker$time · $durSec · #${row.id} · $speakerLabel",
393 style = MaterialTheme.typography.labelSmall, 479 style = MaterialTheme.typography.labelSmall,
394 ) 480 )
395 Text( 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 +}