From 72a56c9accb8a3d5fd16752270b0b2ca6f4d857e Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 7 Jun 2026 19:09:40 +0800 Subject: [PATCH] feat(lifelog): 时间轴两级播放 + 每块存「完整录音」文件(turn 记偏移) --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/audio/SegmentPlayer.kt | 45 +++++++++++++++++++++++++++++++++++++++------ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt | 18 ++++++++++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt | 6 ++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt | 8 ++++++-- 5 files changed, 136 insertions(+), 25 deletions(-) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt index 7867601..05aeea9 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt @@ -478,6 +478,7 @@ private fun TimelineTab( val player = remember { SegmentPlayer() } var nowPlayingId by remember { mutableStateOf(null) } + var nowPlayingPeriod by remember { mutableStateOf(null) } DisposableEffect(player) { onDispose { player.stop() } } // Batch selection state. @@ -581,14 +582,29 @@ private fun TimelineTab( key = { if (it is TlItem.Head) "h${it.period.key}" else "t${(it as TlItem.Turn).row.id}" }, ) { item -> when (item) { - is TlItem.Head -> TimelinePeriodHeader( - period = item.period, - collapsed = collapsed.contains(item.period.key), - onToggle = { - if (collapsed.contains(item.period.key)) collapsed.remove(item.period.key) - else collapsed.add(item.period.key) - }, - ) + is TlItem.Head -> { + val p = item.period + TimelinePeriodHeader( + period = p, + collapsed = collapsed.contains(p.key), + playing = nowPlayingPeriod == p.key, + onToggle = { + if (collapsed.contains(p.key)) collapsed.remove(p.key) + else collapsed.add(p.key) + }, + onPlay = { + if (nowPlayingPeriod == p.key) { + player.stop(); nowPlayingPeriod = null + } else { + nowPlayingId = null + nowPlayingPeriod = p.key + player.playSequence(p.rows.map { it.audioPath }.distinct()) { + nowPlayingPeriod = null + } + } + }, + ) + } is TlItem.Turn -> { val row = item.row TimelineRow( @@ -605,6 +621,22 @@ private fun TimelineTab( actionRow = row } }, + onPlay = { + if (nowPlayingId == row.id) { + player.stop(); nowPlayingId = null + } else { + nowPlayingPeriod = null + nowPlayingId = row.id + scope.launch { + val seg = withContext(Dispatchers.IO) { repo.segments.get(row.id) } + player.play( + seg?.audioPath ?: row.audioPath, + seg?.audioStartMs ?: 0L, + row.durationMs, + ) { nowPlayingId = null } + } + } + }, ) } } @@ -1348,6 +1380,7 @@ private fun TimelineRow( selected: Boolean = false, highlighted: Boolean = false, indent: Boolean = false, + onPlay: (() -> Unit)? = null, ) { val time = TIMELINE_FMT.format(Date(row.startTs)) val speakerLabel = when { @@ -1356,18 +1389,26 @@ private fun TimelineRow( row.speakerSim != null -> "未匹配 (${"%.2f".format(row.speakerSim)})" else -> "—" } - val playMarker = if (isPlaying) "▶ " else "" val bg = if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent Row( modifier = Modifier.fillMaxWidth().background(bg).clickable(onClick = onClick) .padding(start = if (indent) 14.dp else 0.dp, top = 3.dp, bottom = 3.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { if (selecting) { Text(if (selected) "☑" else "☐", style = MaterialTheme.typography.titleMedium) + } else if (onPlay != null) { + // 单句播放(seek 到块内偏移、只播该 turn 时长)。 + Text( + if (isPlaying) "⏹" else "▶", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onPlay).padding(horizontal = 2.dp), + ) } Column(modifier = Modifier.weight(1f)) { - Text("$playMarker$time · $speakerLabel", style = MaterialTheme.typography.labelSmall) + Text("$time · $speakerLabel", style = MaterialTheme.typography.labelSmall) Text( row.text ?: "(无文字)", style = MaterialTheme.typography.bodyMedium, @@ -1440,19 +1481,32 @@ private val PERIOD_HEAD_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) private val PERIOD_END_FMT = SimpleDateFormat("HH:mm", Locale.US) @Composable -private fun TimelinePeriodHeader(period: TimelinePeriod, collapsed: Boolean, onToggle: () -> Unit) { +private fun TimelinePeriodHeader( + period: TimelinePeriod, + collapsed: Boolean, + playing: Boolean, + onToggle: () -> Unit, + onPlay: () -> Unit, +) { val range = "${PERIOD_HEAD_FMT.format(Date(period.startTs))}–${PERIOD_END_FMT.format(Date(period.endTs))}" val spk = if (period.speakers.isEmpty()) "未识别" else period.speakers.joinToString("·") Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(top = 10.dp, bottom = 4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(if (collapsed) "▸" else "▾", style = MaterialTheme.typography.titleSmall) - Column(modifier = Modifier.weight(1f)) { + // 整段播放(把该时间段涉及的各块完整录音依次播)。 + Text( + if (playing) "⏹" else "▶", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onPlay).padding(horizontal = 2.dp), + ) + Column(modifier = Modifier.weight(1f).clickable(onClick = onToggle)) { Text("$range · ${humanDur(period.endTs - period.startTs)}", style = MaterialTheme.typography.titleSmall) Text("${period.rows.size} 段 · $spk", style = MaterialTheme.typography.labelSmall) } + Text(if (collapsed) "▸" else "▾", style = MaterialTheme.typography.titleSmall, modifier = Modifier.clickable(onClick = onToggle)) } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/audio/SegmentPlayer.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/audio/SegmentPlayer.kt index c30d286..08243b5 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/audio/SegmentPlayer.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/audio/SegmentPlayer.kt @@ -2,14 +2,18 @@ package com.lemon.lifelog.audio import android.media.AudioAttributes import android.media.MediaPlayer +import android.os.Handler +import android.os.Looper import android.util.Log /** - * One-at-a-time MediaPlayer for Opus segments. - * Android MediaPlayer natively decodes Ogg-Opus (API 21+). + * One-at-a-time MediaPlayer for Opus 录音。Android MediaPlayer 原生解 Ogg-Opus (API 21+)。 * - * Lifecycle: call [play] to start a new clip (any previous one is released); - * [stop] when navigating away. + * 块级管线下「块完整录音」是一个 .opus,turn 是它内部的一段: + * - [play] 支持 startMs/durMs:seek 到 turn 起点、只播该段时长后自动停(durMs<=0 = 播到结尾)。 + * - [playSequence] 顺序播多段(时间段级播放 = 把该段涉及的各块完整录音依次播)。 + * + * Lifecycle:[play]/[playSequence] 会先停上一个;[stop] 在离开时调用。 */ class SegmentPlayer { companion object { @@ -18,10 +22,13 @@ class SegmentPlayer { private var player: MediaPlayer? = null private var currentPath: String? = null + private val handler = Handler(Looper.getMainLooper()) + private var stopRunnable: Runnable? = null val nowPlaying: String? get() = currentPath - fun play(path: String, onComplete: () -> Unit) { + /** 播放 [path] 从 [startMs] 起的 [durMs](durMs<=0 = 播到文件结尾)。 */ + fun play(path: String, startMs: Long = 0, durMs: Long = 0, onComplete: () -> Unit) { stop() currentPath = path player = MediaPlayer().apply { @@ -35,17 +42,26 @@ class SegmentPlayer { setDataSource(path) setOnCompletionListener { currentPath = null + cancelStopTimer() onComplete() } setOnErrorListener { _, what, extra -> Log.w(TAG, "MediaPlayer error what=$what extra=$extra path=$path") currentPath = null + cancelStopTimer() onComplete() true } prepare() + if (startMs > 0) seekTo(startMs.toInt()) start() - Log.i(TAG, "playing $path duration=${duration}ms") + if (durMs > 0) { + stopRunnable = Runnable { + stop() + onComplete() + }.also { handler.postDelayed(it, durMs) } + } + Log.i(TAG, "playing $path @${startMs}ms for ${if (durMs > 0) "${durMs}ms" else "full"}") } catch (t: Throwable) { Log.e(TAG, "failed to play $path", t) release() @@ -55,7 +71,24 @@ class SegmentPlayer { } } + /** 顺序播放多个完整文件(时间段级:把该时间段涉及的各块完整录音依次播完)。 */ + fun playSequence(paths: List, onComplete: () -> Unit) { + stop() + if (paths.isEmpty()) { onComplete(); return } + fun playFrom(i: Int) { + if (i >= paths.size) { onComplete(); return } + play(paths[i], 0, 0) { playFrom(i + 1) } + } + playFrom(0) + } + + private fun cancelStopTimer() { + stopRunnable?.let { handler.removeCallbacks(it) } + stopRunnable = null + } + fun stop() { + cancelStopTimer() player?.let { mp -> try { if (mp.isPlaying) mp.stop() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt index 3bc572f..1b5b825 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt @@ -22,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase WechatMessage::class, Moment::class, ], - version = 10, + version = 12, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -110,6 +110,20 @@ abstract class AppDatabase : RoomDatabase() { } } + // 块级管线:turn 在「块完整录音」内的起始偏移(ms)。旧逐句段默认 0(整文件即该段)。 + private val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `segments` ADD COLUMN `audioStartMs` INTEGER NOT NULL DEFAULT 0") + } + } + + /** 人物管理:persons.managed —— 区分「微信联系人池」与「我管理的人物」。 */ + private val MIGRATION_11_12 = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `persons` ADD COLUMN `managed` INTEGER NOT NULL DEFAULT 0") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -117,7 +131,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10) + .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) // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 .build() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt index f89b1f8..500c02d 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt @@ -9,7 +9,10 @@ data class Segment( @PrimaryKey(autoGenerate = true) val id: Long = 0, val startTs: Long, val endTs: Long, + /** 块级管线下 = 该 turn 所属「块完整录音」文件路径(块内所有 turn 共享同一文件);旧逐句段 = 自身文件。 */ val audioPath: String, + /** turn 在 [audioPath] 块完整录音内的起始偏移(ms);旧逐句段 = 0(整文件即该段)。 */ + val audioStartMs: Long = 0, val durationMs: Long, val energyDb: Float = 0f, /** 192-dim float embedding packed as little-endian bytes (192*4=768 B). null = 还没算 */ @@ -108,6 +111,9 @@ data class Person( val impression: String? = null, /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */ val lastAccess: Long = 0, + /** 是否纳入「我管理的人物」:微信导入默认 false(只是池子),勾选/标人脸/绑声纹/手动建 → true。 + * 人物 tab 只显示 managed=true 的人;未来每日动态更新也只跑这些人。 */ + val managed: Boolean = false, val createdAt: Long = System.currentTimeMillis(), ) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt index 5bf7fe1..573a24a 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt @@ -57,6 +57,9 @@ class BlockProcessor( turns = listOf(DiarizationEngine.Turn(0f, samples.size.toFloat() / sr, 0)) } + // 整块存「一个完整录音」.opus;各 turn 共享它,只记块内偏移(不再逐句切片)。 + val blockPath = withContext(Dispatchers.IO) { segWriter.write(samples, loaded.startTs, sr) } + var written = 0 for (t in turns) { val a = (t.startSec * sr).toInt().coerceIn(0, samples.size) @@ -65,6 +68,7 @@ class BlockProcessor( val slice = samples.copyOfRange(a, b) val startTs = wallAt(a) val durMs = (b - a).toLong() * 1000 / sr + val offMs = a.toLong() * 1000 / sr // turn 在块完整录音内的偏移 val text = try { asr.transcribeFloat(slice, sr).text @@ -77,13 +81,13 @@ class BlockProcessor( val assign = emb?.let { clusterer.assign(it, durMs) } ?: SpeakerClusterer.Assignment(null, null, null) - val audioPath = withContext(Dispatchers.IO) { segWriter.write(slice, startTs, sr) } withContext(Dispatchers.IO) { val id = repo.segments.insert( Segment( startTs = startTs, endTs = startTs + durMs, - audioPath = audioPath, + audioPath = blockPath, + audioStartMs = offMs, durationMs = durMs, energyDb = 0f, embedding = emb?.toLeBytes(), -- libgit2 0.21.4