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 3942fa5..316c663 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 @@ -85,6 +85,7 @@ import com.lemon.lifelog.audio.SegmentPlayer import com.lemon.lifelog.chat.ChatConfig import com.lemon.lifelog.summary.SummaryProgress import com.lemon.lifelog.summary.SummaryRunLog +import com.lemon.lifelog.data.BlockRow import com.lemon.lifelog.data.Repository import com.lemon.lifelog.data.SegmentRow import com.lemon.lifelog.data.SpeakerProfile @@ -213,7 +214,12 @@ private fun RecordingScreen( } Box(modifier = Modifier.weight(1f)) { when (sub) { - 0 -> HomeTab(activity, repo, onOpenTimeline = { onSub(1) }) + 0 -> HomeTab( + activity, repo, + onOpenTimeline = { onSub(1) }, + // 点录音记录 → 清筛选 + 跳时间轴对应块(按块起始时间定位时间段) + onOpenBlock = { ts -> onFilterSpeakerId(null); onJumpTo(ts); onSub(1) }, + ) 1 -> TimelineTab( repo = repo, speakers = speakers, @@ -240,15 +246,27 @@ private fun RecordingScreen( // ============================================================ Home tab @Composable -private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> Unit) { +private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> Unit, onOpenBlock: (Long) -> Unit) { val running by RecorderState.isRunning.collectAsState() val paused by RecorderState.isPaused.collectAsState() val status by RecorderState.status.collectAsState() val segmentCount by RecorderState.segmentCount.collectAsState() val lastAudioMs by RecorderState.lastAudioMs.collectAsState() val lastWallMs by RecorderState.lastWallMs.collectAsState() + val recStartedAt by RecorderState.recordingStartedAt.collectAsState() + val recAccumMs by RecorderState.recordingAccumMs.collectAsState() val scope = rememberCoroutineScope() - val recent by repo.segments.observeTimeline(5).collectAsState(initial = emptyList()) + val blocks by repo.segments.observeBlocks(50).collectAsState(initial = emptyList()) + + // 已录制秒表:每秒滴答一次,让 elapsed 重算。 + var nowTick by remember { mutableStateOf(0L) } + LaunchedEffect(running) { + while (running) { + nowTick = System.currentTimeMillis() + delay(1000) + } + } + val elapsedMs = recAccumMs + if (recStartedAt > 0) (nowTick - recStartedAt).coerceAtLeast(0) else 0 var asrStatus by remember { mutableStateOf("ASR: idle") } var asrLog by remember { mutableStateOf("") } @@ -285,6 +303,15 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> U } Text("$stateLabel $status") + // 已录制秒表:录音中显示当前这段连续采集的累计时长。 + if (running) { + Text( + "⏱ 已录制 ${fmtClock(elapsedMs)}", + style = MaterialTheme.typography.headlineSmall, + color = if (paused) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary, + ) + } + if (!running) { Button(onClick = { val perms = buildList { @@ -318,23 +345,25 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> U HorizontalDivider() - // 最近转写(实时):让用户在首页就能看到实时进度。 + // 录音记录:每个已处理「录音块」一行(起止/时长/状态/字数/人数),点击跳时间轴看该块详情。 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("最近转写", style = MaterialTheme.typography.titleSmall) + Text("录音记录", style = MaterialTheme.typography.titleSmall) TextButton(onClick = onOpenTimeline) { Text("查看全部 →") } } - if (recent.isEmpty()) { + if (blocks.isEmpty()) { Text( - if (running) "正在聆听…说话后这里会实时出现转写" else "还没有转写。开始录音后这里实时刷新。", + if (running) "正在录音…块在静音≥5min 或满 10min 时收尾,随后出现在这里。" + else "还没有录音记录。开始录音后,处理完的录音块会显示在这里。", style = MaterialTheme.typography.bodySmall, ) } else { - recent.forEach { row -> - TimelineRow(row = row, isPlaying = false, onClick = onOpenTimeline) + blocks.forEach { b -> + BlockRowItem(b = b, onClick = { onOpenBlock(b.startTs) }) + HorizontalDivider() } } @@ -816,6 +845,7 @@ private fun SpeakersTab( val countMap = remember(counts) { counts.associate { it.speakerId to it.count } } var reclustering by remember { mutableStateOf(false) } var reclusterMsg by remember { mutableStateOf("") } + var clearConfirm by remember { mutableStateOf(false) } var renameFor by remember { mutableStateOf(null) } var mergeFor by remember { mutableStateOf(null) } @@ -830,13 +860,21 @@ private fun SpeakersTab( reclustering = true scope.launch { val n = withContext(Dispatchers.IO) { - SpeakerClusterer.reclusterAll(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx)) + SpeakerClusterer.reclusterAll( + repo, + matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx), + minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx), + ) } reclustering = false reclusterMsg = "重新聚类完成:$n 个说话人" } }, ) { Text(if (reclustering) "聚类中…" else "重新聚类") } + OutlinedButton( + enabled = !reclustering, + onClick = { clearConfirm = true }, + ) { Text("清空重识别") } } if (reclusterMsg.isNotEmpty()) { Text(reclusterMsg, style = MaterialTheme.typography.bodySmall) @@ -922,6 +960,29 @@ private fun SpeakersTab( }, ) } + if (clearConfirm) { + ConfirmDialog( + title = "清空全部声纹并重新识别?", + message = "会删除全部说话人(含已命名的),清掉所有段的归属,再按当前阈值/最短识别时长从零重认。段本身和转写不受影响。", + confirmLabel = "清空重识别", + onDismiss = { clearConfirm = false }, + onConfirm = { + clearConfirm = false + reclustering = true + scope.launch { + val n = withContext(Dispatchers.IO) { + SpeakerClusterer.clearAllAndRecluster( + repo, + matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx), + minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(ctx), + ) + } + reclustering = false + reclusterMsg = "已清空并重新识别:$n 个说话人" + } + }, + ) + } } // ============================================================ Summaries tab @@ -1399,6 +1460,37 @@ private fun TimelineRow( } } +/** 录音 tab 的一条「录音块」记录:起止时间 / 时长 / 转写状态 / 字数 / 说话人数。 */ +@Composable +private fun BlockRowItem(b: BlockRow, onClick: () -> Unit) { + val sameDay = BLOCK_DAY_FMT.format(Date(b.startTs)) == BLOCK_DAY_FMT.format(Date(b.endTs)) + val startLabel = BLOCK_FMT.format(Date(b.startTs)) + val endLabel = (if (sameDay) BLOCK_TIME_FMT else BLOCK_FMT).format(Date(b.endTs)) + val done = b.transcribedCount >= b.turnCount && b.charCount > 0 + Column( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("$startLabel – $endLabel", style = MaterialTheme.typography.bodyMedium) + Text("时长 ${fmtClock(b.endTs - b.startTs)}", style = MaterialTheme.typography.bodyMedium) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + if (done) "✓ 已转写" else "… 处理中", + style = MaterialTheme.typography.labelSmall, + color = if (done) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + ) + Text("${b.charCount} 字", style = MaterialTheme.typography.labelSmall) + Text("${b.speakerCount} 人", style = MaterialTheme.typography.labelSmall) + Text("${b.turnCount} 段", style = MaterialTheme.typography.labelSmall) + } + } +} + // ---- 时间段分组(按 5min 静音切,= 录音块边界)---- private const val TIMELINE_GROUP_GAP_MS = 5 * 60 * 1000L @@ -1516,6 +1608,18 @@ private fun exportSegments(ctx: Context, rows: List) { // ============================================================ helpers private val TIMELINE_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) +private val BLOCK_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) +private val BLOCK_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US) +private val BLOCK_DAY_FMT = SimpleDateFormat("yyyy-MM-dd", Locale.US) + +/** ms → mm:ss(不足 1 小时)或 h:mm:ss。 */ +private fun fmtClock(ms: Long): String { + val totalSec = (ms / 1000).coerceAtLeast(0) + val h = totalSec / 3600 + val m = (totalSec % 3600) / 60 + val s = totalSec % 60 + return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) +} private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) private val SUMMARY_TIME_FMT = SimpleDateFormat("HH:mm", Locale.US) private val SUMMARY_DAY_FMT = SimpleDateFormat("yyyy-MM-dd", Locale.US) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt index ecd8cac..14e2b79 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.lemon.lifelog.asr.SpeakerClusterer +import com.lemon.lifelog.service.BlockBuffer import com.lemon.lifelog.chat.ChatConfig import com.lemon.lifelog.summary.SummaryScheduler import com.lemon.lifelog.summary.SummaryWorker @@ -50,6 +51,12 @@ fun SettingsScreen(activity: Context) { var minSpeech by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_MIN_SPEECH_SEC, SummaryWorker.DEFAULT_MIN_SPEECH_SEC)) } var backendId by remember { mutableStateOf(prefs.getString(SummaryWorker.KEY_BACKEND, ChatConfig.ID_2B) ?: ChatConfig.ID_2B) } var threshold by remember { mutableStateOf(SpeakerClusterer.thresholdFromPrefs(activity)) } + var minRecognizeSec by remember { + mutableStateOf(prefs.getInt(SpeakerClusterer.KEY_MIN_RECOGNIZE_SEC, (SpeakerClusterer.MIN_RECOGNIZE_MS / 1000).toInt())) + } + var blockSilenceMin by remember { + mutableStateOf(prefs.getInt(BlockBuffer.KEY_SILENCE_MIN, (BlockBuffer.SILENCE_MS / 60_000).toInt())) + } val backendTargets = remember { ChatConfig.targets(activity) } var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) } @@ -91,6 +98,10 @@ fun SettingsScreen(activity: Context) { listOf(0.35f, 0.40f, 0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) { threshold = it; prefs.edit().putFloat(SpeakerClusterer.KEY_THRESHOLD, it).apply() } + DropdownSetting("最短识别时长(短于此不认声纹)", "$minRecognizeSec 秒", + listOf(1, 2, 3, 5, 8).map { "$it 秒" to it }) { + minRecognizeSec = it; prefs.edit().putInt(SpeakerClusterer.KEY_MIN_RECOGNIZE_SEC, it).apply() + } Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。", style = MaterialTheme.typography.labelSmall) @@ -114,6 +125,11 @@ fun SettingsScreen(activity: Context) { HorizontalDivider() Text("录音 / 电池", style = MaterialTheme.typography.titleMedium) + DropdownSetting("分段间隔(静音多久收尾一段)", "$blockSilenceMin 分钟", + listOf(1, 2, 3, 5, 10, 15).map { "$it 分钟" to it }) { + blockSilenceMin = it; prefs.edit().putInt(BlockBuffer.KEY_SILENCE_MIN, it).apply() + } + Text("改完下次录音服务启动生效(停止再开始 / 重启 app)。", style = MaterialTheme.typography.labelSmall) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)", style = MaterialTheme.typography.bodySmall) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt index 48c3abc..adc3ed8 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt @@ -27,6 +27,7 @@ class SpeakerClusterer( private val repo: Repository, private val matchThreshold: Float = MATCH_THRESHOLD, private val minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, + private val minRecognizeMs: Long = MIN_RECOGNIZE_MS, private val maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID, ) { private val mutex = Mutex() @@ -48,6 +49,9 @@ class SpeakerClusterer( /** 将一个 embedding 归入/新建说话人簇,写穿到 DB。 */ suspend fun assign(emb: FloatArray, durationMs: Long): Assignment = mutex.withLock { + // 太短的段不认声纹:CAMPPlus 在 <3s 上区分度差,强行匹配/开新簇会量产伪说话人。 + if (durationMs < minRecognizeMs) return@withLock Assignment(null, null, null) + var best: Cluster? = null var bestSim = -1f for (c in clusters) { @@ -109,14 +113,23 @@ class SpeakerClusterer( /** 阈值持久化(设置页可调),存在 lifelog_prefs。 */ const val PREFS = "lifelog_prefs" const val KEY_THRESHOLD = "speaker_threshold" + const val KEY_MIN_RECOGNIZE_SEC = "speaker_min_recognize_sec" fun thresholdFromPrefs(ctx: android.content.Context): Float = ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) .getFloat(KEY_THRESHOLD, MATCH_THRESHOLD) + /** 最短识别时长(设置页可调,秒):短于此的段完全跳过声纹识别。 */ + fun minRecognizeMsFromPrefs(ctx: android.content.Context): Long = + ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) + .getInt(KEY_MIN_RECOGNIZE_SEC, (MIN_RECOGNIZE_MS / 1000).toInt()) * 1000L + /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */ const val MIN_NEW_CLUSTER_MS = 1000L + /** 短于此时长的段完全不识别声纹(不匹配、不开新簇)。默认 3s——CAMPPlus 在更短段上不可信。 */ + const val MIN_RECOGNIZE_MS = 3000L + /** 质心滑动均值的样本上限。 */ const val MAX_SAMPLES_PER_CENTROID = 50 @@ -156,6 +169,7 @@ class SpeakerClusterer( repo: Repository, matchThreshold: Float = MATCH_THRESHOLD, minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, + minRecognizeMs: Long = MIN_RECOGNIZE_MS, maxSamplesPerCentroid: Int = MAX_SAMPLES_PER_CENTROID, ): Int = withContext(Dispatchers.IO) { // 1. 删掉自动簇,保留人工命名簇作种子。 @@ -174,6 +188,11 @@ class SpeakerClusterer( val segs = repo.segments.allWithEmbedding().sortedBy { it.startTs } for (seg in segs) { val emb = seg.embedding?.toFloatArrayLe() ?: continue + // 太短的段不认声纹:清掉旧归属,跳过(与在线 assign 同口径)。 + if (seg.durationMs < minRecognizeMs) { + repo.segments.updateSpeaker(seg.id, null, null) + continue + } var best: Cluster? = null var bestSim = -1f for (c in clusters) { @@ -218,6 +237,23 @@ class SpeakerClusterer( Log.i(TAG, "reclusterAll: ${segs.size} segments → ${clusters.size} clusters") clusters.size } + + /** + * 彻底清空声纹库(含人工命名 anchor)+ 清掉所有段的归属,然后从零重新聚类。 + * 用途:用户想推倒重来(如调过阈值/最短识别时长后,让历史段按新规则重认)。 + * @return 跑完后的簇总数 + */ + suspend fun clearAllAndRecluster( + repo: Repository, + matchThreshold: Float = MATCH_THRESHOLD, + minNewClusterMs: Long = MIN_NEW_CLUSTER_MS, + minRecognizeMs: Long = MIN_RECOGNIZE_MS, + ): Int = withContext(Dispatchers.IO) { + repo.segments.clearAllSpeakerAssignments() + for (p in repo.speakers.all()) repo.speakers.delete(p.id) + Log.i(TAG, "clearAllAndRecluster: wiped all profiles + assignments") + reclusterAll(repo, matchThreshold, minNewClusterMs, minRecognizeMs) + } } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 88d00e5..5348c0f 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -21,6 +21,26 @@ data class SpeakerCount( val count: Int, ) +/** + * 一个「录音块」的聚合行(录音 tab 的录音记录列表用)。 + * 块没有独立表——同一块的所有 turn 共享同一份「块完整录音」文件,故按 [audioPath] 分组还原。 + */ +data class BlockRow( + val audioPath: String, + /** 块内最早 turn 的起始墙钟 */ + val startTs: Long, + /** 块内最晚 turn 的结束墙钟(startTs + durationMs 的最大值) */ + val endTs: Long, + /** 块内 turn 数 */ + val turnCount: Int, + /** 已出转写文本的 turn 数(= transcripts 命中数) */ + val transcribedCount: Int, + /** 块内转写总字数 */ + val charCount: Int, + /** 块内出现的不同说话人数(已归属的去重计数) */ + val speakerCount: Int, +) + @Dao interface SegmentDao { @Insert @@ -49,6 +69,10 @@ interface SegmentDao { @Query("UPDATE segments SET speakerId = NULL, speakerSim = NULL WHERE speakerId = :speakerId") suspend fun clearSpeakerAssignments(speakerId: Long) + /** 清空全部段的说话人归属(清空声纹库重识别时用)。 */ + @Query("UPDATE segments SET speakerId = NULL, speakerSim = NULL") + suspend fun clearAllSpeakerAssignments() + /** 合并说话人:把 [from] 名下段落改派给 [into]。 */ @Query("UPDATE segments SET speakerId = :into WHERE speakerId = :from") suspend fun reassignSpeaker(from: Long, into: Long) @@ -109,6 +133,28 @@ interface SegmentDao { ) fun observeTimeline(limit: Int = 100): Flow> + /** + * 按「录音块」聚合(录音 tab 的录音记录列表)。同块 turn 共享 audioPath → GROUP BY 还原块。 + * 旧逐句段每段自成一文件,会各自成 1-turn 块(可接受)。 + */ + @Query( + """ + SELECT s.audioPath AS audioPath, + MIN(s.startTs) AS startTs, + MAX(s.startTs + s.durationMs) AS endTs, + COUNT(*) AS turnCount, + COUNT(t.segmentId) AS transcribedCount, + COALESCE(SUM(LENGTH(t.text)), 0) AS charCount, + COUNT(DISTINCT s.speakerId) AS speakerCount + FROM segments s + LEFT JOIN transcripts t ON t.segmentId = s.id + GROUP BY s.audioPath + ORDER BY startTs DESC + LIMIT :limit + """, + ) + fun observeBlocks(limit: Int = 50): Flow> + /** 某声纹(说话人)名下的录音段(人物档案页"录音列表"用)。 */ @Query( """ diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockBuffer.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockBuffer.kt index 5105aab..8a23e35 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockBuffer.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockBuffer.kt @@ -21,7 +21,12 @@ import org.json.JSONObject * [close] 落 manifest(closed=true) 后交给 BlockProcessor;崩溃时块目录仍在,重启 [listPending] 重扫。 * 只存 speech(静音不落盘)→ 内存/磁盘按语音时长计,不按墙钟。 */ -class BlockBuffer(private val root: File, private val sampleRate: Int = 16000) { +class BlockBuffer( + private val root: File, + private val sampleRate: Int = 16000, + /** 静音多久收尾一段(ms);设置页可调,默认 5min。 */ + private val silenceMs: Long = SILENCE_MS, +) { data class Run(val ts: Long, val off: Int, val len: Int) @@ -56,7 +61,7 @@ class BlockBuffer(private val root: File, private val sampleRate: Int = 16000) { /** 在 [ts] 这一刻是否该关块(静音超时或块超长)。块未开恒 false。 */ fun shouldClose(ts: Long): Boolean { if (!isOpen) return false - return (ts - lastSpeechEndTs >= SILENCE_MS) || (ts - blockStartTs >= MAX_MS) + return (ts - lastSpeechEndTs >= silenceMs) || (ts - blockStartTs >= MAX_MS) } /** 关块:落 manifest(closed=true),返回块目录;空块直接删并返回 null。 */ @@ -99,9 +104,18 @@ class BlockBuffer(private val root: File, private val sampleRate: Int = 16000) { companion object { private const val TAG = "BlockBuffer" - const val SILENCE_MS = 5 * 60 * 1000L // 静音 ≥5min 切块 + const val SILENCE_MS = 5 * 60 * 1000L // 静音 ≥5min 切块(默认) const val MAX_MS = 10 * 60 * 1000L // 块 ≥10min 强切(长会议保活) + /** 分段静音间隔持久化(设置页可调,分钟),存在 lifelog_prefs。 */ + const val PREFS = "lifelog_prefs" + const val KEY_SILENCE_MIN = "block_silence_min" + + /** 从 prefs 读分段间隔(ms),默认 5min。改完下次服务启动生效。 */ + fun silenceMsFromPrefs(ctx: android.content.Context): Long = + ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) + .getInt(KEY_SILENCE_MIN, (SILENCE_MS / 60_000).toInt()) * 60_000L + data class Loaded(val startTs: Long, val sampleRate: Int, val samples: FloatArray, val runs: List) /** 启动时列出所有未处理块目录(崩溃恢复:处理后目录会被删除)。 */ diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt index e702878..da845d6 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt @@ -88,7 +88,7 @@ class RecorderService : Service() { RecorderState.reset() repo = Repository(this) segWriter = SegmentWriter(File(filesDir, "segments")) - blockBuffer = BlockBuffer(File(filesDir, "blocks")) + blockBuffer = BlockBuffer(File(filesDir, "blocks"), silenceMs = BlockBuffer.silenceMsFromPrefs(this)) capture = AudioCapture() pipelineJob = scope.launch { runPipeline() } @@ -107,6 +107,7 @@ class RecorderService : Service() { paused = true capture.stop() wakeLock?.takeIf { it.isHeld }?.release() + RecorderState.onCapturePaused() RecorderState.setPaused(true) RecorderState.setStatus("已暂停") notify("已暂停") @@ -118,6 +119,7 @@ class RecorderService : Service() { paused = false acquireWakeLock() capture.start(scope) + RecorderState.onCapturing() RecorderState.setPaused(false) RecorderState.setStatus("录音中") notify("录音中") @@ -212,7 +214,11 @@ class RecorderService : Service() { } // 2d. Online speaker clusterer (auto-discovers 说话人N; persists as speaker_profiles). - clusterer = SpeakerClusterer(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(this)).also { it.load() } + clusterer = SpeakerClusterer( + repo, + matchThreshold = SpeakerClusterer.thresholdFromPrefs(this), + minRecognizeMs = SpeakerClusterer.minRecognizeMsFromPrefs(this), + ).also { it.load() } // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer. // load() is mutex-serialised vs assign() and idempotent, so reloading on every // speaker_profiles change (incl. the clusterer's own write-throughs) is safe. @@ -256,6 +262,7 @@ class RecorderService : Service() { // 3. Start capturing (unless paused arrived during model load). if (!paused) { capture.start(scope) + RecorderState.onCapturing() RecorderState.setStatus("录音中") } Log.i(TAG, "pipeline running (block-based diarization-first)") diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt index 5bdb533..eeb2edc 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt @@ -27,6 +27,14 @@ object RecorderState { private val _status = MutableStateFlow("idle") val status: StateFlow = _status + /** 当前这段连续采集的开始墙钟(ms);0 = 未在采集(已停止或暂停)。UI 据此计「已录制」秒表。 */ + private val _recordingStartedAt = MutableStateFlow(0L) + val recordingStartedAt: StateFlow = _recordingStartedAt + + /** 暂停前已累计的录音时长(ms)。已录制 = accum + (now - startedAt)。 */ + private val _recordingAccumMs = MutableStateFlow(0L) + val recordingAccumMs: StateFlow = _recordingAccumMs + fun setRunning(v: Boolean) { _isRunning.value = v if (!v) _isPaused.value = false @@ -36,6 +44,18 @@ object RecorderState { _isPaused.value = v } + /** 采集开始/恢复:开表。 */ + fun onCapturing() { + _recordingStartedAt.value = System.currentTimeMillis() + } + + /** 采集暂停:累加本段时长后停表。 */ + fun onCapturePaused() { + val start = _recordingStartedAt.value + if (start > 0) _recordingAccumMs.value += System.currentTimeMillis() - start + _recordingStartedAt.value = 0 + } + fun onSegment(text: String, audioMs: Long, wallMs: Long) { _segmentCount.value++ _lastTranscript.value = text @@ -48,6 +68,8 @@ object RecorderState { _lastTranscript.value = "" _lastAudioMs.value = 0 _lastWallMs.value = 0 + _recordingStartedAt.value = 0 + _recordingAccumMs.value = 0 } fun setStatus(s: String) {