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 25 import androidx.compose.material.icons.Icons
26 26 import androidx.compose.material.icons.automirrored.filled.ArrowBack
27 27 import androidx.compose.material3.AlertDialog
  28 +import androidx.compose.material3.CircularProgressIndicator
28 29 import androidx.compose.material3.ExperimentalMaterial3Api
29 30 import androidx.compose.material3.Icon
30 31 import androidx.compose.material3.IconButton
  32 +import androidx.compose.material3.LinearProgressIndicator
31 33 import androidx.compose.material3.MaterialTheme
32 34 import androidx.compose.material3.OutlinedTextField
33 35 import androidx.compose.material3.RadioButton
... ... @@ -90,6 +92,7 @@ internal fun PersonProfileScreen(
90 92 }.collectAsState(initial = emptyList())
91 93  
92 94 var bindVoice by remember { mutableStateOf(false) }
  95 + var recordVoice by remember { mutableStateOf(false) }
93 96 val player = remember { SegmentPlayer() }
94 97 var playingId by remember { mutableStateOf<Long?>(null) }
95 98 DisposableEffect(player) { onDispose { player.stop() } }
... ... @@ -109,7 +112,14 @@ internal fun PersonProfileScreen(
109 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 123 OutlinedTextField(
114 124 value = query, onValueChange = { query = it }, singleLine = true,
115 125 placeholder = { Text("在 TA 的记录里搜索…") },
... ... @@ -164,11 +174,67 @@ internal fun PersonProfileScreen(
164 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 235 /** 固定在 tab 上方的资料头:头像 + 名字 + 微信号/备注 + 声纹绑定 + AI 印象。 */
170 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 238 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
173 239 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
174 240 if (p.avatarPath != null) {
... ... @@ -186,8 +252,11 @@ private fun ProfileHeader(p: Person, speakers: List&lt;SpeakerProfile&gt;, onBind: ()
186 252 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
187 253 val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name
188 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 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 8 import com.lemon.lifelog.data.Person
9 9 import com.lemon.lifelog.data.Photo
10 10 import com.lemon.lifelog.data.Repository
  11 +import com.lemon.lifelog.data.SpeakerProfile
11 12 import androidx.work.ExistingWorkPolicy
12 13 import androidx.work.OneTimeWorkRequestBuilder
13 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 20 import com.lemon.lifelog.chat.ChatClient
15 21 import com.lemon.lifelog.chat.ChatConfig
  22 +import com.lemon.lifelog.service.RecorderService
  23 +import com.lemon.lifelog.service.RecorderState
16 24 import com.lemon.lifelog.vision.FaceMatcher
17 25 import com.lemon.lifelog.vision.ImageUtil
18 26 import com.lemon.lifelog.vision.PhotoScheduler
19 27 import com.lemon.lifelog.vision.WeChatEnrollWorker
20 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 33 import kotlinx.coroutines.launch
22 34 import kotlinx.coroutines.withContext
23 35 import java.io.File
... ... @@ -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 183 /** AI 印象:汇总 TA 的朋友圈 + 聊天,本地 4B LLM 生成画像 + 标签(爱好/性格)。 */
105 184 fun enrichImpression(personId: Long) {
106 185 viewModelScope.launch {
... ... @@ -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 +}
... ...