Commit 85e1c5139bc9c62f12e13606e9af3fc7500eefce
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>
Showing
4 changed files
with
219 additions
and
10 deletions
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: () -> 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: () -> 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 | +} | ... | ... |