From 3b52a586a283127320189edbeb46017a3a1e8e7e Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 7 Jun 2026 20:16:04 +0800 Subject: [PATCH] feat(lifelog/photolog): 档案页「🎤 录一段绑定 TA」当场录声纹 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt index 779fe91..7ab8256 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt @@ -25,9 +25,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton @@ -90,6 +92,7 @@ internal fun PersonProfileScreen( }.collectAsState(initial = emptyList()) var bindVoice by remember { mutableStateOf(false) } + var recordVoice by remember { mutableStateOf(false) } val player = remember { SegmentPlayer() } var playingId by remember { mutableStateOf(null) } DisposableEffect(player) { onDispose { player.stop() } } @@ -109,7 +112,14 @@ internal fun PersonProfileScreen( IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } }, ) - person?.let { p -> ProfileHeader(p, speakers, onBind = { bindVoice = true }, onEnrich = { vm.enrichImpression(personId) }) } + person?.let { p -> + ProfileHeader( + p, speakers, + onBind = { bindVoice = true }, + onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, + onEnrich = { vm.enrichImpression(personId) }, + ) + } OutlinedTextField( value = query, onValueChange = { query = it }, singleLine = true, placeholder = { Text("在 TA 的记录里搜索…") }, @@ -164,11 +174,67 @@ internal fun PersonProfileScreen( onClear = { vm.bindVoice(personId, null); bindVoice = false }, ) } + + if (recordVoice) { + val phase by vm.voiceEnroll.collectAsState() + EnrollVoiceDialog( + personName = person?.name ?: "TA", + phase = phase, + onStart = { vm.enrollVoice(personId, person?.name ?: "TA") }, + onDismiss = { recordVoice = false; vm.resetVoiceEnroll() }, + ) + } +} + +/** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */ +@Composable +private fun EnrollVoiceDialog( + personName: String, + phase: VoiceEnroll, + onStart: () -> Unit, + onDismiss: () -> Unit, +) { + val busy = phase is VoiceEnroll.Recording || phase is VoiceEnroll.Processing + AlertDialog( + onDismissRequest = { if (!busy) onDismiss() }, + title = { Text("录一段绑定 $personName 的声音") }, + text = { + when (phase) { + is VoiceEnroll.Idle -> Text( + "让 $personName 对着手机自然说约 6 秒话(一两句即可)。\n开始后 24/7 录音会短暂暂停以独占麦克风,记下声纹后自动恢复。", + style = MaterialTheme.typography.bodyMedium, + ) + is VoiceEnroll.Recording -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + val secs = ((phase.remainingMs + 999) / 1000) + Text("🎤 录音中… 还剩 $secs 秒,请说话", style = MaterialTheme.typography.bodyMedium) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + is VoiceEnroll.Processing -> Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(22.dp)) + Text("提取声纹中…", style = MaterialTheme.typography.bodyMedium) + } + is VoiceEnroll.Error -> Text("⚠ ${phase.msg}", style = MaterialTheme.typography.bodyMedium) + is VoiceEnroll.Done -> Text("✅ 已记下 $personName 的声纹并绑定。以后 TA 说话的录音会自动归到这里。", style = MaterialTheme.typography.bodyMedium) + } + }, + confirmButton = { + when (phase) { + is VoiceEnroll.Idle -> TextButton(onClick = onStart) { Text("开始录音") } + is VoiceEnroll.Error -> TextButton(onClick = onStart) { Text("重录") } + is VoiceEnroll.Done -> TextButton(onClick = onDismiss) { Text("完成") } + else -> {} + } + }, + dismissButton = { if (!busy && phase !is VoiceEnroll.Done) TextButton(onClick = onDismiss) { Text("取消") } }, + ) } /** 固定在 tab 上方的资料头:头像 + 名字 + 微信号/备注 + 声纹绑定 + AI 印象。 */ @Composable -private fun ProfileHeader(p: Person, speakers: List, onBind: () -> Unit, onEnrich: () -> Unit) { +private fun ProfileHeader(p: Person, speakers: List, onBind: () -> Unit, onRecordVoice: () -> Unit, onEnrich: () -> Unit) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (p.avatarPath != null) { @@ -186,8 +252,11 @@ private fun ProfileHeader(p: Person, speakers: List, onBind: () Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { val sName = speakers.firstOrNull { it.id == p.voiceSpeakerId }?.name Text("声纹:${sName ?: "未绑定"}", style = MaterialTheme.typography.labelMedium) + TextButton(onClick = onRecordVoice, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)) { + Text("🎤 录一段") + } TextButton(onClick = onBind, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 0.dp)) { - Text(if (p.voiceSpeakerId == null) "绑定" else "改绑") + Text(if (p.voiceSpeakerId == null) "选已有" else "改绑") } } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index 5cbebcc..44302cd 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -8,16 +8,28 @@ import androidx.lifecycle.viewModelScope import com.lemon.lifelog.data.Person import com.lemon.lifelog.data.Photo import com.lemon.lifelog.data.Repository +import com.lemon.lifelog.data.SpeakerProfile import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager +import com.lemon.lifelog.asr.ModelDownloader +import com.lemon.lifelog.asr.SpeakerEmbedder +import com.lemon.lifelog.asr.l2normalize +import com.lemon.lifelog.asr.toLeBytes +import com.lemon.lifelog.audio.AudioCapture import com.lemon.lifelog.chat.ChatClient import com.lemon.lifelog.chat.ChatConfig +import com.lemon.lifelog.service.RecorderService +import com.lemon.lifelog.service.RecorderState import com.lemon.lifelog.vision.FaceMatcher import com.lemon.lifelog.vision.ImageUtil import com.lemon.lifelog.vision.PhotoScheduler import com.lemon.lifelog.vision.WeChatEnrollWorker import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @@ -101,6 +113,73 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { } } + // ----- 当场录一段绑声纹 ----- + private val _voiceEnroll = MutableStateFlow(VoiceEnroll.Idle) + val voiceEnroll: StateFlow = _voiceEnroll + + fun resetVoiceEnroll() { _voiceEnroll.value = VoiceEnroll.Idle } + + /** + * 录 [durationMs] 毫秒人声 → CAMPPlus 192d 声纹 → 新建 SpeakerProfile 绑到 TA。 + * 关键:24/7 RecorderService 占着麦克风,先 pause(释放 AudioRecord)录完再 resume, + * 避免双 AudioRecord 抢麦。 + */ + fun enrollVoice(personId: Long, name: String, durationMs: Long = 6000L) { + val cur = _voiceEnroll.value + if (cur is VoiceEnroll.Recording || cur is VoiceEnroll.Processing) return + val app = getApplication() + viewModelScope.launch { + val wasRecording = RecorderState.isRunning.value && !RecorderState.isPaused.value + if (wasRecording) RecorderService.pause(app) + val capture = AudioCapture() + val buf = ArrayList() + var collectJob: Job? = null + try { + delay(350) // 等录音服务真正释放麦克风 + collectJob = launch(Dispatchers.IO) { capture.frames.collect { buf.add(it) } } + capture.start(this) + var elapsed = 0L + while (elapsed < durationMs) { + delay(100) + elapsed += 100 + _voiceEnroll.value = VoiceEnroll.Recording(durationMs - elapsed) + } + capture.stop(); collectJob.cancel() + _voiceEnroll.value = VoiceEnroll.Processing + val total = buf.sumOf { it.size } + if (total < AudioCapture.SAMPLE_RATE) { // <1s + _voiceEnroll.value = VoiceEnroll.Error("没录到声音,再试一次"); return@launch + } + val audio = FloatArray(total) + var off = 0; for (c in buf) { c.copyInto(audio, off); off += c.size } + val sid = withContext(Dispatchers.IO) { + val mf = ModelDownloader(app).speakerPath() + if (!mf.present) return@withContext -2L + val embedder = SpeakerEmbedder.load(mf.path, numThreads = 2) + val emb = try { embedder.embed(audio, AudioCapture.SAMPLE_RATE) } finally { embedder.release() } + ?: return@withContext -1L + l2normalize(emb) + repo.speakers.insert(SpeakerProfile(name = name, embedding = emb.toLeBytes(), sampleCount = 1)) + } + when (sid) { + -2L -> _voiceEnroll.value = VoiceEnroll.Error("声纹模型未就绪(让录音先跑一会儿下载模型)") + -1L -> _voiceEnroll.value = VoiceEnroll.Error("这段声音太短/太弱,重录") + else -> { + repo.persons.linkVoice(personId, sid) + repo.persons.touch(personId, System.currentTimeMillis()) + repo.persons.setManaged(personId, true) + _voiceEnroll.value = VoiceEnroll.Done + } + } + } catch (t: Throwable) { + capture.stop(); collectJob?.cancel() + _voiceEnroll.value = VoiceEnroll.Error(t.message ?: "录音失败") + } finally { + if (wasRecording) RecorderService.resume(app) + } + } + } + /** AI 印象:汇总 TA 的朋友圈 + 聊天,本地 4B LLM 生成画像 + 标签(爱好/性格)。 */ fun enrichImpression(personId: Long) { viewModelScope.launch { @@ -144,3 +223,12 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { } } } + +/** 「录一段绑声纹」对话框的状态机。 */ +sealed class VoiceEnroll { + object Idle : VoiceEnroll() + data class Recording(val remainingMs: Long) : VoiceEnroll() + object Processing : VoiceEnroll() + data class Error(val msg: String) : VoiceEnroll() + object Done : VoiceEnroll() +} -- libgit2 0.21.4