Commit 8660d567d037516d5dcec67358aca5acdd401c03
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>
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,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("MM-dd HH:mm", Locale.US) | @@ -1440,19 +1481,32 @@ private val PERIOD_HEAD_FMT = SimpleDateFormat("MM-dd HH:mm", 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(), |