Commit fcc1f8ac9e082d2c61af3ae200ccb29d51732c50
1 parent
894eb7c0
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,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: () -> Unit) { | @@ -382,6 +467,7 @@ private fun TimelineRow(row: SegmentRow, onClick: () -> 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: () -> Unit) { | @@ -389,7 +475,7 @@ private fun TimelineRow(row: SegmentRow, onClick: () -> 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 | +} |