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 022451c..6d80d66 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 @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -104,6 +105,9 @@ import kotlinx.coroutines.withContext class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // edge-to-edge:让 IME / 系统栏 insets 传到 Compose(Android 15 targetSdk35 起必须), + // 否则键盘弹出会盖住聊天内容。Scaffold contentWindowInsets 默认 systemBars,会补回状态栏/导航栏 padding。 + androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false) setContent { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { @@ -120,6 +124,7 @@ private enum class Tab(val label: String, val icon: String) { Timeline("时间轴", "≣"), Speakers("声纹", "👤"), Summaries("纪要", "✎"), + Settings("设置", "⚙"), } enum class SortMode(val label: String) { @@ -149,6 +154,8 @@ private fun MainScreen() { val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList()) Scaffold( + // imePadding:键盘弹出时整个 Scaffold(含底部 5 个 tab)抬到键盘上方,tab 仍可见可点。 + modifier = Modifier.imePadding(), bottomBar = { NavigationBar { Tab.entries.forEach { t -> @@ -191,6 +198,7 @@ private fun MainScreen() { tab = Tab.Timeline }, ) + Tab.Settings -> SettingsScreen(activity) } } } @@ -707,6 +715,7 @@ private fun SpeakersTab( onFilterBySpeaker: (Long) -> Unit, ) { val scope = rememberCoroutineScope() + val ctx = LocalContext.current val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList()) val countMap = remember(counts) { counts.associate { it.speakerId to it.count } } var reclustering by remember { mutableStateOf(false) } @@ -724,7 +733,9 @@ private fun SpeakersTab( onClick = { reclustering = true scope.launch { - val n = withContext(Dispatchers.IO) { SpeakerClusterer.reclusterAll(repo) } + val n = withContext(Dispatchers.IO) { + SpeakerClusterer.reclusterAll(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx)) + } reclustering = false reclusterMsg = "重新聚类完成:$n 个说话人" } 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 new file mode 100644 index 0000000..aad8b51 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt @@ -0,0 +1,173 @@ +package com.lemon.lifelog + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.chat.ChatConfig +import com.lemon.lifelog.summary.SummaryScheduler +import com.lemon.lifelog.summary.SummaryWorker + +/** 统一配置页:纪要 / 声纹 / 本地端点 / 录音电池。改完即存 SharedPreferences,下次跑生效。 */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(activity: Context) { + val prefs = remember { activity.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) } + + var gapMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN)) } + var periodMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_PERIOD_MIN, SummaryWorker.DEFAULT_PERIOD_MIN)) } + 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)) } + val backendTargets = remember { ChatConfig.targets(activity) } + + var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) } + var m2b by remember { mutableStateOf(ChatConfig.model2b(activity)) } + var m4b by remember { mutableStateOf(ChatConfig.model4b(activity)) } + var endpointMsg by remember { mutableStateOf("") } + + var batteryExempt by remember { mutableStateOf(isBatteryExempt(activity)) } + + Column( + modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("设置", style = MaterialTheme.typography.headlineSmall) + + Text("纪要", style = MaterialTheme.typography.titleMedium) + DropdownSetting("会话间隔(多久静音算另一场)", "$gapMin 分钟", + listOf(2, 5, 10, 15, 20, 30).map { "$it 分钟" to it }) { + gapMin = it; prefs.edit().putInt(SummaryWorker.KEY_GAP_MIN, it).apply() + } + DropdownSetting("自动跑纪要周期(≥15min 硬下限)", "$periodMin 分钟", + listOf(15, 30, 60).map { "$it 分钟" to it }) { + periodMin = it + prefs.edit().putInt(SummaryWorker.KEY_PERIOD_MIN, it).apply() + SummaryScheduler.schedulePeriodic(activity) // 立即重排 + } + DropdownSetting("会话最短语音(短于此跳过)", "$minSpeech 秒", + listOf(10, 30, 60, 120).map { "$it 秒" to it }) { + minSpeech = it; prefs.edit().putInt(SummaryWorker.KEY_MIN_SPEECH_SEC, it).apply() + } + DropdownSetting("纪要模型后端", backendTargets.firstOrNull { it.id == backendId }?.label ?: "本地 2B", + backendTargets.map { it.label to it.id }) { + backendId = it; prefs.edit().putString(SummaryWorker.KEY_BACKEND, it).apply() + } + + HorizontalDivider() + Text("声纹", style = MaterialTheme.typography.titleMedium) + DropdownSetting("聚类阈值(越大分越细)", "%.2f".format(threshold), + listOf(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() + } + Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。", + style = MaterialTheme.typography.labelSmall) + + HorizontalDivider() + Text("本地模型端点(对话 + 纪要共用 llama-swap)", style = MaterialTheme.typography.titleMedium) + OutlinedTextField(value = localBase, onValueChange = { localBase = it }, + label = { Text("baseUrl") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = m2b, onValueChange = { m2b = it }, + label = { Text("2B model 名") }, singleLine = true, modifier = Modifier.weight(1f)) + OutlinedTextField(value = m4b, onValueChange = { m4b = it }, + label = { Text("4B model 名") }, singleLine = true, modifier = Modifier.weight(1f)) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + ChatConfig.save(activity, localBase, m2b, m4b) + endpointMsg = "已保存" + }) { Text("保存端点") } + if (endpointMsg.isNotEmpty()) Text(endpointMsg, style = MaterialTheme.typography.labelSmall) + } + + HorizontalDivider() + Text("录音 / 电池", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)", + style = MaterialTheme.typography.bodySmall) + if (!batteryExempt) { + Button(onClick = { + requestBatteryExempt(activity) + batteryExempt = isBatteryExempt(activity) + }) { Text("申请豁免") } + } + } + + Spacer(Modifier.height(24.dp)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DropdownSetting( + label: String, + current: String, + options: List>, + onPick: (T) -> Unit, +) { + var open by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Box { + FilterChip(selected = true, onClick = { open = true }, label = { Text(current) }) + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + options.forEach { (lbl, value) -> + DropdownMenuItem(text = { Text(lbl) }, onClick = { onPick(value); open = false }) + } + } + } + } +} + +private fun isBatteryExempt(ctx: Context): Boolean { + val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager + return pm.isIgnoringBatteryOptimizations(ctx.packageName) +} + +@SuppressLint("BatteryLife") +private fun requestBatteryExempt(ctx: Context) { + runCatching { + ctx.startActivity( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${ctx.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } +} 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 64c8254..26066a1 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 @@ -97,10 +97,18 @@ class SpeakerClusterer( /** 自动簇命名前缀。重命名后不再以此开头,即视为「人工命名」。 */ const val AUTO_PREFIX = "说话人" - /** CAMPPlus 余弦阈值:同人通常 > 0.55,异人 < 0.45。0.5 偏松易过度合并 - * (4B 测试时「说话人4」吃了 23 段),收紧到 0.55 分得更细。再细可上 0.6。 */ + /** CAMPPlus 余弦阈值默认值:同人通常 > 0.55,异人 < 0.45。0.5 偏松易过度合并 + * (4B 测试时「说话人4」吃了 23 段),0.55 分得更细。设置页可调,再细可上 0.6。 */ const val MATCH_THRESHOLD = 0.55f + /** 阈值持久化(设置页可调),存在 lifelog_prefs。 */ + const val PREFS = "lifelog_prefs" + const val KEY_THRESHOLD = "speaker_threshold" + + fun thresholdFromPrefs(ctx: android.content.Context): Float = + ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) + .getFloat(KEY_THRESHOLD, MATCH_THRESHOLD) + /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */ const val MIN_NEW_CLUSTER_MS = 1000L 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 2e0164b..8d3030b 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 @@ -202,7 +202,7 @@ class RecorderService : Service() { } // 2d. Online speaker clusterer (auto-discovers 说话人N; persists as speaker_profiles). - clusterer = SpeakerClusterer(repo).also { it.load() } + clusterer = SpeakerClusterer(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(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. diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt index f997f29..9b19274 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt @@ -20,9 +20,11 @@ object SummaryScheduler { .setRequiredNetworkType(NetworkType.NOT_REQUIRED) // 后端都在本机 .setRequiresBatteryNotLow(true) .build() - // 15 分钟是 WorkManager periodic 的硬下限(做不到正好 10min)。会话关闭后 - // 最多 ~15min 出纪要。worker 自带并发互斥 + 时间预算,频繁触发也不会堆积。 - val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + // 周期从设置页读(默认 15min);WorkManager periodic 硬下限 15,做不到更短。 + val periodMin = ctx.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) + .getInt(SummaryWorker.KEY_PERIOD_MIN, SummaryWorker.DEFAULT_PERIOD_MIN) + .coerceAtLeast(15) + val request = PeriodicWorkRequestBuilder(periodMin.toLong(), TimeUnit.MINUTES) .setConstraints(constraints) .build() WorkManager.getInstance(ctx).enqueueUniquePeriodicWork( diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt index d620bae..310fd78 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt @@ -51,6 +51,14 @@ class SummaryWorker( * 端点/model 由 ChatConfig 解析(与对话共用配置)。 */ const val KEY_BACKEND = "summary_backend" + /** 自动跑纪要周期(分钟)。WorkManager periodic 硬下限 15。 */ + const val KEY_PERIOD_MIN = "summary_period_min" + const val DEFAULT_PERIOD_MIN = 15 + + /** 会话最短语音(秒):低于此跳过不总结。 */ + const val KEY_MIN_SPEECH_SEC = "summary_min_speech_sec" + const val DEFAULT_MIN_SPEECH_SEC = 30 + /** 防并发:periodic + one-shot + RETRY 可能同时触发,多个 worker 并发打同一个 * llama-server 会把上游搞 502(实测)。进程内 flag 保证同时只跑一个 worker body。 */ private val isRunning = java.util.concurrent.atomic.AtomicBoolean(false) @@ -95,6 +103,7 @@ class SummaryWorker( val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 } ?: (prefs.getInt(KEY_GAP_MIN, DEFAULT_GAP_MIN) * 60_000L) + val minSpeechMs = prefs.getInt(KEY_MIN_SPEECH_SEC, DEFAULT_MIN_SPEECH_SEC) * 1000L // 解析后端(UI 选的 ChatTarget:local-2b / local-4b / hermes)。 val target = com.lemon.lifelog.chat.ChatConfig.targetById( @@ -127,7 +136,7 @@ class SummaryWorker( // 未结束的会话不动(可能还会有新段并入)。 if (now - end <= gapMs) { skipped++; continue } // 太短跳过。 - if (session.sumOf { it.durationMs } < MIN_SESSION_SPEECH_MS) { skipped++; continue } + if (session.sumOf { it.durationMs } < minSpeechMs) { skipped++; continue } // 已总结过(时间范围重叠)跳过。 if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) { skipped++; continue }