Commit 8660d567d037516d5dcec67358aca5acdd401c03

Authored by yibo
1 parent 52018e45

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>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
@@ -478,6 +478,7 @@ private fun TimelineTab( @@ -478,6 +478,7 @@ private fun TimelineTab(
478 478
479 val player = remember { SegmentPlayer() } 479 val player = remember { SegmentPlayer() }
480 var nowPlayingId by remember { mutableStateOf<Long?>(null) } 480 var nowPlayingId by remember { mutableStateOf<Long?>(null) }
  481 + var nowPlayingPeriod by remember { mutableStateOf<Long?>(null) }
481 DisposableEffect(player) { onDispose { player.stop() } } 482 DisposableEffect(player) { onDispose { player.stop() } }
482 483
483 // Batch selection state. 484 // Batch selection state.
@@ -581,14 +582,29 @@ private fun TimelineTab( @@ -581,14 +582,29 @@ private fun TimelineTab(
581 key = { if (it is TlItem.Head) "h${it.period.key}" else "t${(it as TlItem.Turn).row.id}" }, 582 key = { if (it is TlItem.Head) "h${it.period.key}" else "t${(it as TlItem.Turn).row.id}" },
582 ) { item -> 583 ) { item ->
583 when (item) { 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 is TlItem.Turn -> { 608 is TlItem.Turn -> {
593 val row = item.row 609 val row = item.row
594 TimelineRow( 610 TimelineRow(
@@ -605,6 +621,22 @@ private fun TimelineTab( @@ -605,6 +621,22 @@ private fun TimelineTab(
605 actionRow = row 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,6 +1380,7 @@ private fun TimelineRow(
1348 selected: Boolean = false, 1380 selected: Boolean = false,
1349 highlighted: Boolean = false, 1381 highlighted: Boolean = false,
1350 indent: Boolean = false, 1382 indent: Boolean = false,
  1383 + onPlay: (() -> Unit)? = null,
1351 ) { 1384 ) {
1352 val time = TIMELINE_FMT.format(Date(row.startTs)) 1385 val time = TIMELINE_FMT.format(Date(row.startTs))
1353 val speakerLabel = when { 1386 val speakerLabel = when {
@@ -1356,18 +1389,26 @@ private fun TimelineRow( @@ -1356,18 +1389,26 @@ private fun TimelineRow(
1356 row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})" 1389 row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})"
1357 else -> "—" 1390 else -> "—"
1358 } 1391 }
1359 - val playMarker = if (isPlaying) "▶ " else ""  
1360 val bg = if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent 1392 val bg = if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent
1361 Row( 1393 Row(
1362 modifier = Modifier.fillMaxWidth().background(bg).clickable(onClick = onClick) 1394 modifier = Modifier.fillMaxWidth().background(bg).clickable(onClick = onClick)
1363 .padding(start = if (indent) 14.dp else 0.dp, top = 3.dp, bottom = 3.dp), 1395 .padding(start = if (indent) 14.dp else 0.dp, top = 3.dp, bottom = 3.dp),
1364 horizontalArrangement = Arrangement.spacedBy(8.dp), 1396 horizontalArrangement = Arrangement.spacedBy(8.dp),
  1397 + verticalAlignment = Alignment.CenterVertically,
1365 ) { 1398 ) {
1366 if (selecting) { 1399 if (selecting) {
1367 Text(if (selected) "☑" else "☐", style = MaterialTheme.typography.titleMedium) 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 Column(modifier = Modifier.weight(1f)) { 1410 Column(modifier = Modifier.weight(1f)) {
1370 - Text("$playMarker$time · $speakerLabel", style = MaterialTheme.typography.labelSmall) 1411 + Text("$time · $speakerLabel", style = MaterialTheme.typography.labelSmall)
1371 Text( 1412 Text(
1372 row.text ?: "(无文字)", 1413 row.text ?: "(无文字)",
1373 style = MaterialTheme.typography.bodyMedium, 1414 style = MaterialTheme.typography.bodyMedium,
@@ -1440,19 +1481,32 @@ private val PERIOD_HEAD_FMT = SimpleDateFormat(&quot;MM-dd HH:mm&quot;, Locale.US) @@ -1440,19 +1481,32 @@ private val PERIOD_HEAD_FMT = SimpleDateFormat(&quot;MM-dd HH:mm&quot;, Locale.US)
1440 private val PERIOD_END_FMT = SimpleDateFormat("HH:mm", Locale.US) 1481 private val PERIOD_END_FMT = SimpleDateFormat("HH:mm", Locale.US)
1441 1482
1442 @Composable 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 val range = "${PERIOD_HEAD_FMT.format(Date(period.startTs))}–${PERIOD_END_FMT.format(Date(period.endTs))}" 1491 val range = "${PERIOD_HEAD_FMT.format(Date(period.startTs))}–${PERIOD_END_FMT.format(Date(period.endTs))}"
1445 val spk = if (period.speakers.isEmpty()) "未识别" else period.speakers.joinToString("·") 1492 val spk = if (period.speakers.isEmpty()) "未识别" else period.speakers.joinToString("·")
1446 Row( 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 verticalAlignment = Alignment.CenterVertically, 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 Text("$range · ${humanDur(period.endTs - period.startTs)}", style = MaterialTheme.typography.titleSmall) 1506 Text("$range · ${humanDur(period.endTs - period.startTs)}", style = MaterialTheme.typography.titleSmall)
1454 Text("${period.rows.size} 段 · $spk", style = MaterialTheme.typography.labelSmall) 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,14 +2,18 @@ package com.lemon.lifelog.audio
2 2
3 import android.media.AudioAttributes 3 import android.media.AudioAttributes
4 import android.media.MediaPlayer 4 import android.media.MediaPlayer
  5 +import android.os.Handler
  6 +import android.os.Looper
5 import android.util.Log 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 class SegmentPlayer { 18 class SegmentPlayer {
15 companion object { 19 companion object {
@@ -18,10 +22,13 @@ class SegmentPlayer { @@ -18,10 +22,13 @@ class SegmentPlayer {
18 22
19 private var player: MediaPlayer? = null 23 private var player: MediaPlayer? = null
20 private var currentPath: String? = null 24 private var currentPath: String? = null
  25 + private val handler = Handler(Looper.getMainLooper())
  26 + private var stopRunnable: Runnable? = null
21 27
22 val nowPlaying: String? get() = currentPath 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 stop() 32 stop()
26 currentPath = path 33 currentPath = path
27 player = MediaPlayer().apply { 34 player = MediaPlayer().apply {
@@ -35,17 +42,26 @@ class SegmentPlayer { @@ -35,17 +42,26 @@ class SegmentPlayer {
35 setDataSource(path) 42 setDataSource(path)
36 setOnCompletionListener { 43 setOnCompletionListener {
37 currentPath = null 44 currentPath = null
  45 + cancelStopTimer()
38 onComplete() 46 onComplete()
39 } 47 }
40 setOnErrorListener { _, what, extra -> 48 setOnErrorListener { _, what, extra ->
41 Log.w(TAG, "MediaPlayer error what=$what extra=$extra path=$path") 49 Log.w(TAG, "MediaPlayer error what=$what extra=$extra path=$path")
42 currentPath = null 50 currentPath = null
  51 + cancelStopTimer()
43 onComplete() 52 onComplete()
44 true 53 true
45 } 54 }
46 prepare() 55 prepare()
  56 + if (startMs > 0) seekTo(startMs.toInt())
47 start() 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 } catch (t: Throwable) { 65 } catch (t: Throwable) {
50 Log.e(TAG, "failed to play $path", t) 66 Log.e(TAG, "failed to play $path", t)
51 release() 67 release()
@@ -55,7 +71,24 @@ class SegmentPlayer { @@ -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 fun stop() { 90 fun stop() {
  91 + cancelStopTimer()
59 player?.let { mp -> 92 player?.let { mp ->
60 try { 93 try {
61 if (mp.isPlaying) mp.stop() 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,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
22 WechatMessage::class, 22 WechatMessage::class,
23 Moment::class, 23 Moment::class,
24 ], 24 ],
25 - version = 10, 25 + version = 12,
26 exportSchema = false, 26 exportSchema = false,
27 ) 27 )
28 abstract class AppDatabase : RoomDatabase() { 28 abstract class AppDatabase : RoomDatabase() {
@@ -110,6 +110,20 @@ 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 fun get(ctx: Context): AppDatabase = 127 fun get(ctx: Context): AppDatabase =
114 instance ?: synchronized(this) { 128 instance ?: synchronized(this) {
115 instance ?: Room.databaseBuilder( 129 instance ?: Room.databaseBuilder(
@@ -117,7 +131,7 @@ abstract class AppDatabase : RoomDatabase() { @@ -117,7 +131,7 @@ abstract class AppDatabase : RoomDatabase() {
117 AppDatabase::class.java, 131 AppDatabase::class.java,
118 "lifelog.db", 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 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, 135 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration,
122 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 136 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。
123 .build() 137 .build()
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
@@ -9,7 +9,10 @@ data class Segment( @@ -9,7 +9,10 @@ data class Segment(
9 @PrimaryKey(autoGenerate = true) val id: Long = 0, 9 @PrimaryKey(autoGenerate = true) val id: Long = 0,
10 val startTs: Long, 10 val startTs: Long,
11 val endTs: Long, 11 val endTs: Long,
  12 + /** 块级管线下 = 该 turn 所属「块完整录音」文件路径(块内所有 turn 共享同一文件);旧逐句段 = 自身文件。 */
12 val audioPath: String, 13 val audioPath: String,
  14 + /** turn 在 [audioPath] 块完整录音内的起始偏移(ms);旧逐句段 = 0(整文件即该段)。 */
  15 + val audioStartMs: Long = 0,
13 val durationMs: Long, 16 val durationMs: Long,
14 val energyDb: Float = 0f, 17 val energyDb: Float = 0f,
15 /** 192-dim float embedding packed as little-endian bytes (192*4=768 B). null = 还没算 */ 18 /** 192-dim float embedding packed as little-endian bytes (192*4=768 B). null = 还没算 */
@@ -108,6 +111,9 @@ data class Person( @@ -108,6 +111,9 @@ data class Person(
108 val impression: String? = null, 111 val impression: String? = null,
109 /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */ 112 /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */
110 val lastAccess: Long = 0, 113 val lastAccess: Long = 0,
  114 + /** 是否纳入「我管理的人物」:微信导入默认 false(只是池子),勾选/标人脸/绑声纹/手动建 → true。
  115 + * 人物 tab 只显示 managed=true 的人;未来每日动态更新也只跑这些人。 */
  116 + val managed: Boolean = false,
111 val createdAt: Long = System.currentTimeMillis(), 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,6 +57,9 @@ class BlockProcessor(
57 turns = listOf(DiarizationEngine.Turn(0f, samples.size.toFloat() / sr, 0)) 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 var written = 0 63 var written = 0
61 for (t in turns) { 64 for (t in turns) {
62 val a = (t.startSec * sr).toInt().coerceIn(0, samples.size) 65 val a = (t.startSec * sr).toInt().coerceIn(0, samples.size)
@@ -65,6 +68,7 @@ class BlockProcessor( @@ -65,6 +68,7 @@ class BlockProcessor(
65 val slice = samples.copyOfRange(a, b) 68 val slice = samples.copyOfRange(a, b)
66 val startTs = wallAt(a) 69 val startTs = wallAt(a)
67 val durMs = (b - a).toLong() * 1000 / sr 70 val durMs = (b - a).toLong() * 1000 / sr
  71 + val offMs = a.toLong() * 1000 / sr // turn 在块完整录音内的偏移
68 72
69 val text = try { 73 val text = try {
70 asr.transcribeFloat(slice, sr).text 74 asr.transcribeFloat(slice, sr).text
@@ -77,13 +81,13 @@ class BlockProcessor( @@ -77,13 +81,13 @@ class BlockProcessor(
77 val assign = emb?.let { clusterer.assign(it, durMs) } 81 val assign = emb?.let { clusterer.assign(it, durMs) }
78 ?: SpeakerClusterer.Assignment(null, null, null) 82 ?: SpeakerClusterer.Assignment(null, null, null)
79 83
80 - val audioPath = withContext(Dispatchers.IO) { segWriter.write(slice, startTs, sr) }  
81 withContext(Dispatchers.IO) { 84 withContext(Dispatchers.IO) {
82 val id = repo.segments.insert( 85 val id = repo.segments.insert(
83 Segment( 86 Segment(
84 startTs = startTs, 87 startTs = startTs,
85 endTs = startTs + durMs, 88 endTs = startTs + durMs,
86 - audioPath = audioPath, 89 + audioPath = blockPath,
  90 + audioStartMs = offMs,
87 durationMs = durMs, 91 durationMs = durMs,
88 energyDb = 0f, 92 energyDb = 0f,
89 embedding = emb?.toLeBytes(), 93 embedding = emb?.toLeBytes(),