Commit 3b52a586a283127320189edbeb46017a3a1e8e7e
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>
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,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<SpeakerProfile>, onBind: () | @@ -186,8 +252,11 @@ private fun ProfileHeader(p: Person, speakers: List<SpeakerProfile>, 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 | +} |