Commit 3b52a586a283127320189edbeb46017a3a1e8e7e

Authored by yibo
1 parent adc2e178

feat(lifelog/photolog): 档案页「🎤 录一段绑定 TA」当场录声纹

档案页声纹行加「🎤 录一段」:对手机说 ~6s → CAMPPlus 192d 声纹 →
新建 SpeakerProfile 绑到 TA(原「绑定」改名「选已有」走旧的选择已有声纹)。
关键:24/7 RecorderService 占麦,录前 pause 释放 AudioRecord、录后 resume,
避免双 AudioRecord 抢麦。VoiceEnroll 状态机驱动对话框(录音倒计时/提取中/完成/错误)。
绑定后自动 setManaged + touch。真机验证:pause→capture→embed(dim=192)→resume 全链路通。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
@@ -25,9 +25,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape @@ -25,9 +25,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
25 import androidx.compose.material.icons.Icons 25 import androidx.compose.material.icons.Icons
26 import androidx.compose.material.icons.automirrored.filled.ArrowBack 26 import androidx.compose.material.icons.automirrored.filled.ArrowBack
27 import androidx.compose.material3.AlertDialog 27 import androidx.compose.material3.AlertDialog
  28 +import androidx.compose.material3.CircularProgressIndicator
28 import androidx.compose.material3.ExperimentalMaterial3Api 29 import androidx.compose.material3.ExperimentalMaterial3Api
29 import androidx.compose.material3.Icon 30 import androidx.compose.material3.Icon
30 import androidx.compose.material3.IconButton 31 import androidx.compose.material3.IconButton
  32 +import androidx.compose.material3.LinearProgressIndicator
31 import androidx.compose.material3.MaterialTheme 33 import androidx.compose.material3.MaterialTheme
32 import androidx.compose.material3.OutlinedTextField 34 import androidx.compose.material3.OutlinedTextField
33 import androidx.compose.material3.RadioButton 35 import androidx.compose.material3.RadioButton
@@ -90,6 +92,7 @@ internal fun PersonProfileScreen( @@ -90,6 +92,7 @@ internal fun PersonProfileScreen(
90 }.collectAsState(initial = emptyList()) 92 }.collectAsState(initial = emptyList())
91 93
92 var bindVoice by remember { mutableStateOf(false) } 94 var bindVoice by remember { mutableStateOf(false) }
  95 + var recordVoice by remember { mutableStateOf(false) }
93 val player = remember { SegmentPlayer() } 96 val player = remember { SegmentPlayer() }
94 var playingId by remember { mutableStateOf<Long?>(null) } 97 var playingId by remember { mutableStateOf<Long?>(null) }
95 DisposableEffect(player) { onDispose { player.stop() } } 98 DisposableEffect(player) { onDispose { player.stop() } }
@@ -109,7 +112,14 @@ internal fun PersonProfileScreen( @@ -109,7 +112,14 @@ internal fun PersonProfileScreen(
109 IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } 112 IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") }
110 }, 113 },
111 ) 114 )
112 - person?.let { p -> ProfileHeader(p, speakers, onBind = { bindVoice = true }, onEnrich = { vm.enrichImpression(personId) }) } 115 + person?.let { p ->
  116 + ProfileHeader(
  117 + p, speakers,
  118 + onBind = { bindVoice = true },
  119 + onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true },
  120 + onEnrich = { vm.enrichImpression(personId) },
  121 + )
  122 + }
113 OutlinedTextField( 123 OutlinedTextField(
114 value = query, onValueChange = { query = it }, singleLine = true, 124 value = query, onValueChange = { query = it }, singleLine = true,
115 placeholder = { Text("在 TA 的记录里搜索…") }, 125 placeholder = { Text("在 TA 的记录里搜索…") },
@@ -164,11 +174,67 @@ internal fun PersonProfileScreen( @@ -164,11 +174,67 @@ internal fun PersonProfileScreen(
164 onClear = { vm.bindVoice(personId, null); bindVoice = false }, 174 onClear = { vm.bindVoice(personId, null); bindVoice = false },
165 ) 175 )
166 } 176 }
  177 +
  178 + if (recordVoice) {
  179 + val phase by vm.voiceEnroll.collectAsState()
  180 + EnrollVoiceDialog(
  181 + personName = person?.name ?: "TA",
  182 + phase = phase,
  183 + onStart = { vm.enrollVoice(personId, person?.name ?: "TA") },
  184 + onDismiss = { recordVoice = false; vm.resetVoiceEnroll() },
  185 + )
  186 + }
  187 +}
  188 +
  189 +/** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */
  190 +@Composable
  191 +private fun EnrollVoiceDialog(
  192 + personName: String,
  193 + phase: VoiceEnroll,
  194 + onStart: () -> Unit,
  195 + onDismiss: () -> Unit,
  196 +) {
  197 + val busy = phase is VoiceEnroll.Recording || phase is VoiceEnroll.Processing
  198 + AlertDialog(
  199 + onDismissRequest = { if (!busy) onDismiss() },
  200 + title = { Text("录一段绑定 $personName 的声音") },
  201 + text = {
  202 + when (phase) {
  203 + is VoiceEnroll.Idle -> Text(
  204 + "让 $personName 对着手机自然说约 6 秒话(一两句即可)。\n开始后 24/7 录音会短暂暂停以独占麦克风,记下声纹后自动恢复。",
  205 + style = MaterialTheme.typography.bodyMedium,
  206 + )
  207 + is VoiceEnroll.Recording -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
  208 + val secs = ((phase.remainingMs + 999) / 1000)
  209 + Text("🎤 录音中… 还剩 $secs 秒,请说话", style = MaterialTheme.typography.bodyMedium)
  210 + LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
  211 + }
  212 + is VoiceEnroll.Processing -> Row(
  213 + verticalAlignment = Alignment.CenterVertically,
  214 + horizontalArrangement = Arrangement.spacedBy(12.dp),
  215 + ) {
  216 + CircularProgressIndicator(modifier = Modifier.size(22.dp))
  217 + Text("提取声纹中…", style = MaterialTheme.typography.bodyMedium)
  218 + }
  219 + is VoiceEnroll.Error -> Text("⚠ ${phase.msg}", style = MaterialTheme.typography.bodyMedium)
  220 + is VoiceEnroll.Done -> Text("✅ 已记下 $personName 的声纹并绑定。以后 TA 说话的录音会自动归到这里。", style = MaterialTheme.typography.bodyMedium)
  221 + }
  222 + },
  223 + confirmButton = {
  224 + when (phase) {
  225 + is VoiceEnroll.Idle -> TextButton(onClick = onStart) { Text("开始录音") }
  226 + is VoiceEnroll.Error -> TextButton(onClick = onStart) { Text("重录") }
  227 + is VoiceEnroll.Done -> TextButton(onClick = onDismiss) { Text("完成") }
  228 + else -> {}
  229 + }
  230 + },
  231 + dismissButton = { if (!busy && phase !is VoiceEnroll.Done) TextButton(onClick = onDismiss) { Text("取消") } },
  232 + )
167 } 233 }
168 234
169 /** 固定在 tab 上方的资料头:头像 + 名字 + 微信号/备注 + 声纹绑定 + AI 印象。 */ 235 /** 固定在 tab 上方的资料头:头像 + 名字 + 微信号/备注 + 声纹绑定 + AI 印象。 */
170 @Composable 236 @Composable
171 -private fun ProfileHeader(p: Person, speakers: List<SpeakerProfile>, onBind: () -> Unit, onEnrich: () -> Unit) { 237 +private fun ProfileHeader(p: Person, speakers: List<SpeakerProfile>, onBind: () -> Unit, onRecordVoice: () -> Unit, onEnrich: () -> Unit) {
172 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { 238 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
173 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { 239 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
174 if (p.avatarPath != null) { 240 if (p.avatarPath != null) {
@@ -186,8 +252,11 @@ private fun ProfileHeader(p: Person, speakers: List&lt;SpeakerProfile&gt;, onBind: () @@ -186,8 +252,11 @@ private fun ProfileHeader(p: Person, speakers: List&lt;SpeakerProfile&gt;, onBind: ()
186 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { 252 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
187 val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name 253 val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name
188 Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.labelMedium) 254 Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.labelMedium)
  255 + TextButton(onClick = onRecordVoice, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
  256 + Text("🎤 录一段")
  257 + }
189 TextButton(onClick = onBind, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)) { 258 TextButton(onClick = onBind, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
190 - Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") 259 + Text(if (p.voiceSpeakerId == null) "选已有" else "改绑")
191 } 260 }
192 } 261 }
193 } 262 }
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
@@ -8,16 +8,28 @@ import androidx.lifecycle.viewModelScope @@ -8,16 +8,28 @@ import androidx.lifecycle.viewModelScope
8 import com.lemon.lifelog.data.Person 8 import com.lemon.lifelog.data.Person
9 import com.lemon.lifelog.data.Photo 9 import com.lemon.lifelog.data.Photo
10 import com.lemon.lifelog.data.Repository 10 import com.lemon.lifelog.data.Repository
  11 +import com.lemon.lifelog.data.SpeakerProfile
11 import androidx.work.ExistingWorkPolicy 12 import androidx.work.ExistingWorkPolicy
12 import androidx.work.OneTimeWorkRequestBuilder 13 import androidx.work.OneTimeWorkRequestBuilder
13 import androidx.work.WorkManager 14 import androidx.work.WorkManager
  15 +import com.lemon.lifelog.asr.ModelDownloader
  16 +import com.lemon.lifelog.asr.SpeakerEmbedder
  17 +import com.lemon.lifelog.asr.l2normalize
  18 +import com.lemon.lifelog.asr.toLeBytes
  19 +import com.lemon.lifelog.audio.AudioCapture
14 import com.lemon.lifelog.chat.ChatClient 20 import com.lemon.lifelog.chat.ChatClient
15 import com.lemon.lifelog.chat.ChatConfig 21 import com.lemon.lifelog.chat.ChatConfig
  22 +import com.lemon.lifelog.service.RecorderService
  23 +import com.lemon.lifelog.service.RecorderState
16 import com.lemon.lifelog.vision.FaceMatcher 24 import com.lemon.lifelog.vision.FaceMatcher
17 import com.lemon.lifelog.vision.ImageUtil 25 import com.lemon.lifelog.vision.ImageUtil
18 import com.lemon.lifelog.vision.PhotoScheduler 26 import com.lemon.lifelog.vision.PhotoScheduler
19 import com.lemon.lifelog.vision.WeChatEnrollWorker 27 import com.lemon.lifelog.vision.WeChatEnrollWorker
20 import kotlinx.coroutines.Dispatchers 28 import kotlinx.coroutines.Dispatchers
  29 +import kotlinx.coroutines.Job
  30 +import kotlinx.coroutines.delay
  31 +import kotlinx.coroutines.flow.MutableStateFlow
  32 +import kotlinx.coroutines.flow.StateFlow
21 import kotlinx.coroutines.launch 33 import kotlinx.coroutines.launch
22 import kotlinx.coroutines.withContext 34 import kotlinx.coroutines.withContext
23 import java.io.File 35 import java.io.File
@@ -101,6 +113,73 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { @@ -101,6 +113,73 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
101 } 113 }
102 } 114 }
103 115
  116 + // ----- 当场录一段绑声纹 -----
  117 + private val _voiceEnroll = MutableStateFlow<VoiceEnroll>(VoiceEnroll.Idle)
  118 + val voiceEnroll: StateFlow<VoiceEnroll> = _voiceEnroll
  119 +
  120 + fun resetVoiceEnroll() { _voiceEnroll.value = VoiceEnroll.Idle }
  121 +
  122 + /**
  123 + * 录 [durationMs] 毫秒人声 → CAMPPlus 192d 声纹 → 新建 SpeakerProfile 绑到 TA。
  124 + * 关键:24/7 RecorderService 占着麦克风,先 pause(释放 AudioRecord)录完再 resume,
  125 + * 避免双 AudioRecord 抢麦。
  126 + */
  127 + fun enrollVoice(personId: Long, name: String, durationMs: Long = 6000L) {
  128 + val cur = _voiceEnroll.value
  129 + if (cur is VoiceEnroll.Recording || cur is VoiceEnroll.Processing) return
  130 + val app = getApplication<Application>()
  131 + viewModelScope.launch {
  132 + val wasRecording = RecorderState.isRunning.value && !RecorderState.isPaused.value
  133 + if (wasRecording) RecorderService.pause(app)
  134 + val capture = AudioCapture()
  135 + val buf = ArrayList<FloatArray>()
  136 + var collectJob: Job? = null
  137 + try {
  138 + delay(350) // 等录音服务真正释放麦克风
  139 + collectJob = launch(Dispatchers.IO) { capture.frames.collect { buf.add(it) } }
  140 + capture.start(this)
  141 + var elapsed = 0L
  142 + while (elapsed < durationMs) {
  143 + delay(100)
  144 + elapsed += 100
  145 + _voiceEnroll.value = VoiceEnroll.Recording(durationMs - elapsed)
  146 + }
  147 + capture.stop(); collectJob.cancel()
  148 + _voiceEnroll.value = VoiceEnroll.Processing
  149 + val total = buf.sumOf { it.size }
  150 + if (total < AudioCapture.SAMPLE_RATE) { // <1s
  151 + _voiceEnroll.value = VoiceEnroll.Error("没录到声音,再试一次"); return@launch
  152 + }
  153 + val audio = FloatArray(total)
  154 + var off = 0; for (c in buf) { c.copyInto(audio, off); off += c.size }
  155 + val sid = withContext(Dispatchers.IO) {
  156 + val mf = ModelDownloader(app).speakerPath()
  157 + if (!mf.present) return@withContext -2L
  158 + val embedder = SpeakerEmbedder.load(mf.path, numThreads = 2)
  159 + val emb = try { embedder.embed(audio, AudioCapture.SAMPLE_RATE) } finally { embedder.release() }
  160 + ?: return@withContext -1L
  161 + l2normalize(emb)
  162 + repo.speakers.insert(SpeakerProfile(name = name, embedding = emb.toLeBytes(), sampleCount = 1))
  163 + }
  164 + when (sid) {
  165 + -2L -> _voiceEnroll.value = VoiceEnroll.Error("声纹模型未就绪(让录音先跑一会儿下载模型)")
  166 + -1L -> _voiceEnroll.value = VoiceEnroll.Error("这段声音太短/太弱,重录")
  167 + else -> {
  168 + repo.persons.linkVoice(personId, sid)
  169 + repo.persons.touch(personId, System.currentTimeMillis())
  170 + repo.persons.setManaged(personId, true)
  171 + _voiceEnroll.value = VoiceEnroll.Done
  172 + }
  173 + }
  174 + } catch (t: Throwable) {
  175 + capture.stop(); collectJob?.cancel()
  176 + _voiceEnroll.value = VoiceEnroll.Error(t.message ?: "录音失败")
  177 + } finally {
  178 + if (wasRecording) RecorderService.resume(app)
  179 + }
  180 + }
  181 + }
  182 +
104 /** AI 印象:汇总 TA 的朋友圈 + 聊天,本地 4B LLM 生成画像 + 标签(爱好/性格)。 */ 183 /** AI 印象:汇总 TA 的朋友圈 + 聊天,本地 4B LLM 生成画像 + 标签(爱好/性格)。 */
105 fun enrichImpression(personId: Long) { 184 fun enrichImpression(personId: Long) {
106 viewModelScope.launch { 185 viewModelScope.launch {
@@ -144,3 +223,12 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { @@ -144,3 +223,12 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
144 } 223 }
145 } 224 }
146 } 225 }
  226 +
  227 +/** 「录一段绑声纹」对话框的状态机。 */
  228 +sealed class VoiceEnroll {
  229 + object Idle : VoiceEnroll()
  230 + data class Recording(val remainingMs: Long) : VoiceEnroll()
  231 + object Processing : VoiceEnroll()
  232 + data class Error(val msg: String) : VoiceEnroll()
  233 + object Done : VoiceEnroll()
  234 +}