Commit 72a56c9accb8a3d5fd16752270b0b2ca6f4d857e
1 parent
49a85ff3
feat(lifelog): 时间轴两级播放 + 每块存「完整录音」文件(turn 记偏移)
录音文件改造:BlockProcessor 不再把块切成逐句小 .opus,整块 speech 存成「一个 完整录音」.opus;每个 turn 只记在块内的起始偏移(audioStartMs)+时长,共享块文件。 省空间(不重复存),且真正有「完整录音文件」可回放。 时间轴 UI: - 时间段块头加 ▶:playSequence 把该段涉及的各块完整录音依次播。 - 每条 turn 加 ▶:seek 到块内偏移、只播该 turn 时长(SegmentPlayer 支持 startMs/durMs)。 - 旧逐句段 audioStartMs=0、audioPath=自身文件,照常整段播放,兼容。 Schema:Segment +audioStartMs(MIGRATION_10_11)。注:本提交的 Entities/AppDatabase 同时含并行开发中的 persons.managed(MIGRATION_11_12,version 12)——两者在同一 schema 文件内经 version 号耦合,无法拆分提交,故一并带上。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
5 changed files
with
136 additions
and
25 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
| ... | ... | @@ -478,6 +478,7 @@ private fun TimelineTab( |
| 478 | 478 | |
| 479 | 479 | val player = remember { SegmentPlayer() } |
| 480 | 480 | var nowPlayingId by remember { mutableStateOf<Long?>(null) } |
| 481 | + var nowPlayingPeriod by remember { mutableStateOf<Long?>(null) } | |
| 481 | 482 | DisposableEffect(player) { onDispose { player.stop() } } |
| 482 | 483 | |
| 483 | 484 | // Batch selection state. |
| ... | ... | @@ -581,14 +582,29 @@ private fun TimelineTab( |
| 581 | 582 | key = { if (it is TlItem.Head) "h${it.period.key}" else "t${(it as TlItem.Turn).row.id}" }, |
| 582 | 583 | ) { item -> |
| 583 | 584 | when (item) { |
| 584 | - is TlItem.Head -> TimelinePeriodHeader( | |
| 585 | - period = item.period, | |
| 586 | - collapsed = collapsed.contains(item.period.key), | |
| 587 | - onToggle = { | |
| 588 | - if (collapsed.contains(item.period.key)) collapsed.remove(item.period.key) | |
| 589 | - else collapsed.add(item.period.key) | |
| 590 | - }, | |
| 591 | - ) | |
| 585 | + is TlItem.Head -> { | |
| 586 | + val p = item.period | |
| 587 | + TimelinePeriodHeader( | |
| 588 | + period = p, | |
| 589 | + collapsed = collapsed.contains(p.key), | |
| 590 | + playing = nowPlayingPeriod == p.key, | |
| 591 | + onToggle = { | |
| 592 | + if (collapsed.contains(p.key)) collapsed.remove(p.key) | |
| 593 | + else collapsed.add(p.key) | |
| 594 | + }, | |
| 595 | + onPlay = { | |
| 596 | + if (nowPlayingPeriod == p.key) { | |
| 597 | + player.stop(); nowPlayingPeriod = null | |
| 598 | + } else { | |
| 599 | + nowPlayingId = null | |
| 600 | + nowPlayingPeriod = p.key | |
| 601 | + player.playSequence(p.rows.map { it.audioPath }.distinct()) { | |
| 602 | + nowPlayingPeriod = null | |
| 603 | + } | |
| 604 | + } | |
| 605 | + }, | |
| 606 | + ) | |
| 607 | + } | |
| 592 | 608 | is TlItem.Turn -> { |
| 593 | 609 | val row = item.row |
| 594 | 610 | TimelineRow( |
| ... | ... | @@ -605,6 +621,22 @@ private fun TimelineTab( |
| 605 | 621 | actionRow = row |
| 606 | 622 | } |
| 607 | 623 | }, |
| 624 | + onPlay = { | |
| 625 | + if (nowPlayingId == row.id) { | |
| 626 | + player.stop(); nowPlayingId = null | |
| 627 | + } else { | |
| 628 | + nowPlayingPeriod = null | |
| 629 | + nowPlayingId = row.id | |
| 630 | + scope.launch { | |
| 631 | + val seg = withContext(Dispatchers.IO) { repo.segments.get(row.id) } | |
| 632 | + player.play( | |
| 633 | + seg?.audioPath ?: row.audioPath, | |
| 634 | + seg?.audioStartMs ?: 0L, | |
| 635 | + row.durationMs, | |
| 636 | + ) { nowPlayingId = null } | |
| 637 | + } | |
| 638 | + } | |
| 639 | + }, | |
| 608 | 640 | ) |
| 609 | 641 | } |
| 610 | 642 | } |
| ... | ... | @@ -1348,6 +1380,7 @@ private fun TimelineRow( |
| 1348 | 1380 | selected: Boolean = false, |
| 1349 | 1381 | highlighted: Boolean = false, |
| 1350 | 1382 | indent: Boolean = false, |
| 1383 | + onPlay: (() -> Unit)? = null, | |
| 1351 | 1384 | ) { |
| 1352 | 1385 | val time = TIMELINE_FMT.format(Date(row.startTs)) |
| 1353 | 1386 | val speakerLabel = when { |
| ... | ... | @@ -1356,18 +1389,26 @@ private fun TimelineRow( |
| 1356 | 1389 | row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})" |
| 1357 | 1390 | else -> "—" |
| 1358 | 1391 | } |
| 1359 | - val playMarker = if (isPlaying) "▶ " else "" | |
| 1360 | 1392 | val bg = if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent |
| 1361 | 1393 | Row( |
| 1362 | 1394 | modifier = Modifier.fillMaxWidth().background(bg).clickable(onClick = onClick) |
| 1363 | 1395 | .padding(start = if (indent) 14.dp else 0.dp, top = 3.dp, bottom = 3.dp), |
| 1364 | 1396 | horizontalArrangement = Arrangement.spacedBy(8.dp), |
| 1397 | + verticalAlignment = Alignment.CenterVertically, | |
| 1365 | 1398 | ) { |
| 1366 | 1399 | if (selecting) { |
| 1367 | 1400 | Text(if (selected) "☑" else "☐", style = MaterialTheme.typography.titleMedium) |
| 1401 | + } else if (onPlay != null) { | |
| 1402 | + // 单句播放(seek 到块内偏移、只播该 turn 时长)。 | |
| 1403 | + Text( | |
| 1404 | + if (isPlaying) "⏹" else "▶", | |
| 1405 | + style = MaterialTheme.typography.bodyLarge, | |
| 1406 | + color = MaterialTheme.colorScheme.primary, | |
| 1407 | + modifier = Modifier.clickable(onClick = onPlay).padding(horizontal = 2.dp), | |
| 1408 | + ) | |
| 1368 | 1409 | } |
| 1369 | 1410 | Column(modifier = Modifier.weight(1f)) { |
| 1370 | - Text("$playMarker$time · $speakerLabel", style = MaterialTheme.typography.labelSmall) | |
| 1411 | + Text("$time · $speakerLabel", style = MaterialTheme.typography.labelSmall) | |
| 1371 | 1412 | Text( |
| 1372 | 1413 | row.text ?: "(无文字)", |
| 1373 | 1414 | style = MaterialTheme.typography.bodyMedium, |
| ... | ... | @@ -1440,19 +1481,32 @@ private val PERIOD_HEAD_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) |
| 1440 | 1481 | private val PERIOD_END_FMT = SimpleDateFormat("HH:mm", Locale.US) |
| 1441 | 1482 | |
| 1442 | 1483 | @Composable |
| 1443 | -private fun TimelinePeriodHeader(period: TimelinePeriod, collapsed: Boolean, onToggle: () -> Unit) { | |
| 1484 | +private fun TimelinePeriodHeader( | |
| 1485 | + period: TimelinePeriod, | |
| 1486 | + collapsed: Boolean, | |
| 1487 | + playing: Boolean, | |
| 1488 | + onToggle: () -> Unit, | |
| 1489 | + onPlay: () -> Unit, | |
| 1490 | +) { | |
| 1444 | 1491 | val range = "${PERIOD_HEAD_FMT.format(Date(period.startTs))}–${PERIOD_END_FMT.format(Date(period.endTs))}" |
| 1445 | 1492 | val spk = if (period.speakers.isEmpty()) "未识别" else period.speakers.joinToString("·") |
| 1446 | 1493 | Row( |
| 1447 | - modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(top = 10.dp, bottom = 4.dp), | |
| 1448 | - horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 1494 | + modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp), | |
| 1495 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 1449 | 1496 | verticalAlignment = Alignment.CenterVertically, |
| 1450 | 1497 | ) { |
| 1451 | - Text(if (collapsed) "▸" else "▾", style = MaterialTheme.typography.titleSmall) | |
| 1452 | - Column(modifier = Modifier.weight(1f)) { | |
| 1498 | + // 整段播放(把该时间段涉及的各块完整录音依次播)。 | |
| 1499 | + Text( | |
| 1500 | + if (playing) "⏹" else "▶", | |
| 1501 | + style = MaterialTheme.typography.titleMedium, | |
| 1502 | + color = MaterialTheme.colorScheme.primary, | |
| 1503 | + modifier = Modifier.clickable(onClick = onPlay).padding(horizontal = 2.dp), | |
| 1504 | + ) | |
| 1505 | + Column(modifier = Modifier.weight(1f).clickable(onClick = onToggle)) { | |
| 1453 | 1506 | Text("$range · ${humanDur(period.endTs - period.startTs)}", style = MaterialTheme.typography.titleSmall) |
| 1454 | 1507 | Text("${period.rows.size} 段 · $spk", style = MaterialTheme.typography.labelSmall) |
| 1455 | 1508 | } |
| 1509 | + Text(if (collapsed) "▸" else "▾", style = MaterialTheme.typography.titleSmall, modifier = Modifier.clickable(onClick = onToggle)) | |
| 1456 | 1510 | } |
| 1457 | 1511 | } |
| 1458 | 1512 | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/audio/SegmentPlayer.kt
| ... | ... | @@ -2,14 +2,18 @@ package com.lemon.lifelog.audio |
| 2 | 2 | |
| 3 | 3 | import android.media.AudioAttributes |
| 4 | 4 | import android.media.MediaPlayer |
| 5 | +import android.os.Handler | |
| 6 | +import android.os.Looper | |
| 5 | 7 | import android.util.Log |
| 6 | 8 | |
| 7 | 9 | /** |
| 8 | - * One-at-a-time MediaPlayer for Opus segments. | |
| 9 | - * Android MediaPlayer natively decodes Ogg-Opus (API 21+). | |
| 10 | + * One-at-a-time MediaPlayer for Opus 录音。Android MediaPlayer 原生解 Ogg-Opus (API 21+)。 | |
| 10 | 11 | * |
| 11 | - * Lifecycle: call [play] to start a new clip (any previous one is released); | |
| 12 | - * [stop] when navigating away. | |
| 12 | + * 块级管线下「块完整录音」是一个 .opus,turn 是它内部的一段: | |
| 13 | + * - [play] 支持 startMs/durMs:seek 到 turn 起点、只播该段时长后自动停(durMs<=0 = 播到结尾)。 | |
| 14 | + * - [playSequence] 顺序播多段(时间段级播放 = 把该段涉及的各块完整录音依次播)。 | |
| 15 | + * | |
| 16 | + * Lifecycle:[play]/[playSequence] 会先停上一个;[stop] 在离开时调用。 | |
| 13 | 17 | */ |
| 14 | 18 | class SegmentPlayer { |
| 15 | 19 | companion object { |
| ... | ... | @@ -18,10 +22,13 @@ class SegmentPlayer { |
| 18 | 22 | |
| 19 | 23 | private var player: MediaPlayer? = null |
| 20 | 24 | private var currentPath: String? = null |
| 25 | + private val handler = Handler(Looper.getMainLooper()) | |
| 26 | + private var stopRunnable: Runnable? = null | |
| 21 | 27 | |
| 22 | 28 | val nowPlaying: String? get() = currentPath |
| 23 | 29 | |
| 24 | - fun play(path: String, onComplete: () -> Unit) { | |
| 30 | + /** 播放 [path] 从 [startMs] 起的 [durMs](durMs<=0 = 播到文件结尾)。 */ | |
| 31 | + fun play(path: String, startMs: Long = 0, durMs: Long = 0, onComplete: () -> Unit) { | |
| 25 | 32 | stop() |
| 26 | 33 | currentPath = path |
| 27 | 34 | player = MediaPlayer().apply { |
| ... | ... | @@ -35,17 +42,26 @@ class SegmentPlayer { |
| 35 | 42 | setDataSource(path) |
| 36 | 43 | setOnCompletionListener { |
| 37 | 44 | currentPath = null |
| 45 | + cancelStopTimer() | |
| 38 | 46 | onComplete() |
| 39 | 47 | } |
| 40 | 48 | setOnErrorListener { _, what, extra -> |
| 41 | 49 | Log.w(TAG, "MediaPlayer error what=$what extra=$extra path=$path") |
| 42 | 50 | currentPath = null |
| 51 | + cancelStopTimer() | |
| 43 | 52 | onComplete() |
| 44 | 53 | true |
| 45 | 54 | } |
| 46 | 55 | prepare() |
| 56 | + if (startMs > 0) seekTo(startMs.toInt()) | |
| 47 | 57 | start() |
| 48 | - Log.i(TAG, "playing $path duration=${duration}ms") | |
| 58 | + if (durMs > 0) { | |
| 59 | + stopRunnable = Runnable { | |
| 60 | + stop() | |
| 61 | + onComplete() | |
| 62 | + }.also { handler.postDelayed(it, durMs) } | |
| 63 | + } | |
| 64 | + Log.i(TAG, "playing $path @${startMs}ms for ${if (durMs > 0) "${durMs}ms" else "full"}") | |
| 49 | 65 | } catch (t: Throwable) { |
| 50 | 66 | Log.e(TAG, "failed to play $path", t) |
| 51 | 67 | release() |
| ... | ... | @@ -55,7 +71,24 @@ class SegmentPlayer { |
| 55 | 71 | } |
| 56 | 72 | } |
| 57 | 73 | |
| 74 | + /** 顺序播放多个完整文件(时间段级:把该时间段涉及的各块完整录音依次播完)。 */ | |
| 75 | + fun playSequence(paths: List<String>, onComplete: () -> Unit) { | |
| 76 | + stop() | |
| 77 | + if (paths.isEmpty()) { onComplete(); return } | |
| 78 | + fun playFrom(i: Int) { | |
| 79 | + if (i >= paths.size) { onComplete(); return } | |
| 80 | + play(paths[i], 0, 0) { playFrom(i + 1) } | |
| 81 | + } | |
| 82 | + playFrom(0) | |
| 83 | + } | |
| 84 | + | |
| 85 | + private fun cancelStopTimer() { | |
| 86 | + stopRunnable?.let { handler.removeCallbacks(it) } | |
| 87 | + stopRunnable = null | |
| 88 | + } | |
| 89 | + | |
| 58 | 90 | fun stop() { |
| 91 | + cancelStopTimer() | |
| 59 | 92 | player?.let { mp -> |
| 60 | 93 | try { |
| 61 | 94 | if (mp.isPlaying) mp.stop() | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| ... | ... | @@ -22,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase |
| 22 | 22 | WechatMessage::class, |
| 23 | 23 | Moment::class, |
| 24 | 24 | ], |
| 25 | - version = 10, | |
| 25 | + version = 12, | |
| 26 | 26 | exportSchema = false, |
| 27 | 27 | ) |
| 28 | 28 | abstract class AppDatabase : RoomDatabase() { |
| ... | ... | @@ -110,6 +110,20 @@ abstract class AppDatabase : RoomDatabase() { |
| 110 | 110 | } |
| 111 | 111 | } |
| 112 | 112 | |
| 113 | + // 块级管线:turn 在「块完整录音」内的起始偏移(ms)。旧逐句段默认 0(整文件即该段)。 | |
| 114 | + private val MIGRATION_10_11 = object : Migration(10, 11) { | |
| 115 | + override fun migrate(db: SupportSQLiteDatabase) { | |
| 116 | + db.execSQL("ALTER TABLE `segments` ADD COLUMN `audioStartMs` INTEGER NOT NULL DEFAULT 0") | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + /** 人物管理:persons.managed —— 区分「微信联系人池」与「我管理的人物」。 */ | |
| 121 | + private val MIGRATION_11_12 = object : Migration(11, 12) { | |
| 122 | + override fun migrate(db: SupportSQLiteDatabase) { | |
| 123 | + db.execSQL("ALTER TABLE `persons` ADD COLUMN `managed` INTEGER NOT NULL DEFAULT 0") | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 113 | 127 | fun get(ctx: Context): AppDatabase = |
| 114 | 128 | instance ?: synchronized(this) { |
| 115 | 129 | instance ?: Room.databaseBuilder( |
| ... | ... | @@ -117,7 +131,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 117 | 131 | AppDatabase::class.java, |
| 118 | 132 | "lifelog.db", |
| 119 | 133 | ) |
| 120 | - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10) | |
| 134 | + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12) | |
| 121 | 135 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 122 | 136 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 123 | 137 | .build() | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| ... | ... | @@ -9,7 +9,10 @@ data class Segment( |
| 9 | 9 | @PrimaryKey(autoGenerate = true) val id: Long = 0, |
| 10 | 10 | val startTs: Long, |
| 11 | 11 | val endTs: Long, |
| 12 | + /** 块级管线下 = 该 turn 所属「块完整录音」文件路径(块内所有 turn 共享同一文件);旧逐句段 = 自身文件。 */ | |
| 12 | 13 | val audioPath: String, |
| 14 | + /** turn 在 [audioPath] 块完整录音内的起始偏移(ms);旧逐句段 = 0(整文件即该段)。 */ | |
| 15 | + val audioStartMs: Long = 0, | |
| 13 | 16 | val durationMs: Long, |
| 14 | 17 | val energyDb: Float = 0f, |
| 15 | 18 | /** 192-dim float embedding packed as little-endian bytes (192*4=768 B). null = 还没算 */ |
| ... | ... | @@ -108,6 +111,9 @@ data class Person( |
| 108 | 111 | val impression: String? = null, |
| 109 | 112 | /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */ |
| 110 | 113 | val lastAccess: Long = 0, |
| 114 | + /** 是否纳入「我管理的人物」:微信导入默认 false(只是池子),勾选/标人脸/绑声纹/手动建 → true。 | |
| 115 | + * 人物 tab 只显示 managed=true 的人;未来每日动态更新也只跑这些人。 */ | |
| 116 | + val managed: Boolean = false, | |
| 111 | 117 | val createdAt: Long = System.currentTimeMillis(), |
| 112 | 118 | ) |
| 113 | 119 | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt
| ... | ... | @@ -57,6 +57,9 @@ class BlockProcessor( |
| 57 | 57 | turns = listOf(DiarizationEngine.Turn(0f, samples.size.toFloat() / sr, 0)) |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | + // 整块存「一个完整录音」.opus;各 turn 共享它,只记块内偏移(不再逐句切片)。 | |
| 61 | + val blockPath = withContext(Dispatchers.IO) { segWriter.write(samples, loaded.startTs, sr) } | |
| 62 | + | |
| 60 | 63 | var written = 0 |
| 61 | 64 | for (t in turns) { |
| 62 | 65 | val a = (t.startSec * sr).toInt().coerceIn(0, samples.size) |
| ... | ... | @@ -65,6 +68,7 @@ class BlockProcessor( |
| 65 | 68 | val slice = samples.copyOfRange(a, b) |
| 66 | 69 | val startTs = wallAt(a) |
| 67 | 70 | val durMs = (b - a).toLong() * 1000 / sr |
| 71 | + val offMs = a.toLong() * 1000 / sr // turn 在块完整录音内的偏移 | |
| 68 | 72 | |
| 69 | 73 | val text = try { |
| 70 | 74 | asr.transcribeFloat(slice, sr).text |
| ... | ... | @@ -77,13 +81,13 @@ class BlockProcessor( |
| 77 | 81 | val assign = emb?.let { clusterer.assign(it, durMs) } |
| 78 | 82 | ?: SpeakerClusterer.Assignment(null, null, null) |
| 79 | 83 | |
| 80 | - val audioPath = withContext(Dispatchers.IO) { segWriter.write(slice, startTs, sr) } | |
| 81 | 84 | withContext(Dispatchers.IO) { |
| 82 | 85 | val id = repo.segments.insert( |
| 83 | 86 | Segment( |
| 84 | 87 | startTs = startTs, |
| 85 | 88 | endTs = startTs + durMs, |
| 86 | - audioPath = audioPath, | |
| 89 | + audioPath = blockPath, | |
| 90 | + audioStartMs = offMs, | |
| 87 | 91 | durationMs = durMs, |
| 88 | 92 | energyDb = 0f, |
| 89 | 93 | embedding = emb?.toLeBytes(), | ... | ... |