Commit 0fb3a95cdf203cb652bbb56d033ce03e59e4a53d
1 parent
f8729bee
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>
Showing
2 changed files
with
160 additions
and
3 deletions
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<SpeakerProfile>, 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 | +} | ... | ... |