feat: 客户端语音回放 长录音客户端切片,并行转录

This commit is contained in:
Kevin
2026-03-12 15:57:45 +08:00
parent ec7107cbf0
commit f15046c1c1
2 changed files with 84 additions and 35 deletions

View File

@@ -10,8 +10,11 @@ import com.huaga.life_echo.data.repository.ChapterRepository
import com.huaga.life_echo.data.repository.MessageRepository
import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort
import com.huaga.life_echo.feature.conversation.ports.ConversationRealtimePort
import com.huaga.life_echo.feature.conversation.ports.AudioSegmentRequest
import com.huaga.life_echo.feature.voice.AudioPlayer
import com.huaga.life_echo.feature.voice.AudioSegmenter
import com.huaga.life_echo.feature.voice.PlaybackInfo
import com.huaga.life_echo.feature.voice.PendingVoiceSegmentBatchBuilder
import com.huaga.life_echo.feature.voice.RecordingCoordinator
import com.huaga.life_echo.feature.voice.RecordingFinishResult
import com.huaga.life_echo.feature.voice.RecordingStartResult
@@ -20,7 +23,10 @@ import com.huaga.life_echo.network.MessageType
import com.huaga.life_echo.model.MessageDto
import com.huaga.life_echo.data.database.Chapter
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -44,6 +50,7 @@ class CreateMemoryViewModel(
companion object {
private const val TAG = "CreateMemoryViewModel"
private const val MIN_RECORDING_DURATION = 1
private const val SEGMENT_DURATION_SECONDS = 60
}
private val audioPlayer = AudioPlayer(context)
@@ -272,13 +279,11 @@ class CreateMemoryViewModel(
Log.w(TAG, "录音时间太短: ${capture.durationSeconds}s")
return@launch
}
val audioBytes = try {
File(capture.session.filePath).readBytes()
} catch (e: Exception) {
Log.e(TAG, "读取录音文件失败: ${e.message}")
return@launch
}
sendAudioMessage(audioBytes, capture.session.filePath, capture.durationSeconds)
sendRecordingSegments(
filePath = capture.session.filePath,
voiceSessionId = capture.session.voiceSessionId,
durationSeconds = capture.durationSeconds,
)
}
is RecordingFinishResult.Failed -> {
Log.e(TAG, "停止录音失败: ${result.capture.cause.message}")
@@ -345,8 +350,16 @@ class CreateMemoryViewModel(
}
}
private suspend fun sendAudioMessage(audioBytes: ByteArray, filePath: String, durationSeconds: Int) {
Log.d(TAG, "准备发送音频消息,大小: ${audioBytes.size}, 时长: ${durationSeconds}s")
/**
* 将录音文件按 [SEGMENT_DURATION_SECONDS] 切片,逐段通过 [sendAudioSegment] 发送。
* 短录音(<= 段时长)不会真正切片,直接整文件复制为单段。
*/
private suspend fun sendRecordingSegments(
filePath: String,
voiceSessionId: String,
durationSeconds: Int,
) {
Log.d(TAG, "准备切片并发送,时长: ${durationSeconds}s, 段长: ${SEGMENT_DURATION_SECONDS}s")
if (conversationId.value == null) {
Log.d(TAG, "对话ID为空开始创建新对话")
@@ -358,35 +371,71 @@ class CreateMemoryViewModel(
return
}
conversationId.value?.let { id ->
val tempMessageId = "audio_user_${System.currentTimeMillis()}"
val id = conversationId.value ?: return
val tempMessage = MessageDto(
id = tempMessageId,
conversationId = id,
content = "[语音消息]",
senderType = "user",
timestamp = System.currentTimeMillis(),
messageType = "audio"
val tempMessageId = "audio_user_${System.currentTimeMillis()}"
val tempMessage = MessageDto(
id = tempMessageId,
conversationId = id,
content = "[语音消息]",
senderType = "user",
timestamp = System.currentTimeMillis(),
messageType = "audio"
)
historyMessages.value = historyMessages.value + tempMessage
_audioFilePaths.value = _audioFilePaths.value + (tempMessageId to filePath)
_audioDurations.value = _audioDurations.value + (tempMessageId to durationSeconds)
val segmentFiles = try {
AudioSegmenter.split(
inputPath = filePath,
segmentDurationSeconds = SEGMENT_DURATION_SECONDS,
cacheDir = context.cacheDir,
)
historyMessages.value = historyMessages.value + tempMessage
} catch (e: Exception) {
Log.e(TAG, "音频切片失败: ${e.message}", e)
connectionStatus.value = "音频处理失败: ${e.message}"
return
}
_audioFilePaths.value = _audioFilePaths.value + (tempMessageId to filePath)
_audioDurations.value = _audioDurations.value + (tempMessageId to durationSeconds)
try {
isTyping.value = true
conversationRealtime.sendAudioMessage(audioBytes, id, durationSeconds)
Log.d(TAG, "音频消息发送成功")
} catch (e: Exception) {
isTyping.value = false
Log.e(TAG, "音频消息发送失败: ${e.message}", e)
connectionStatus.value = "发送失败: ${e.message}"
errorMessages.value = (errorMessages.value + "发送失败: ${e.message}").takeLast(10)
historyMessages.value = historyMessages.value.filter { it.id != tempMessageId }
_audioFilePaths.value = _audioFilePaths.value - tempMessageId
_audioDurations.value = _audioDurations.value - tempMessageId
try {
val segments = PendingVoiceSegmentBatchBuilder.build(
segmentFiles = segmentFiles,
conversationId = id,
voiceSessionId = voiceSessionId,
)
isTyping.value = true
Log.d(TAG, "并行发送 ${segments.size} 个音频段,服务端按 segmentIndex 排序拼接")
coroutineScope {
segments.map { segment ->
async {
conversationRealtime.sendAudioSegment(
AudioSegmentRequest(
audioBytes = segment.audioBytes,
conversationId = segment.conversationId,
voiceSessionId = segment.voiceSessionId,
segmentIndex = segment.segmentIndex,
duration = segment.durationSeconds,
isLast = segment.isLast,
clientSegmentId = segment.clientSegmentId,
)
)
Log.d(TAG, "已发送段 ${segment.segmentIndex}/${segments.size - 1}, last=${segment.isLast}")
}
}.awaitAll()
}
Log.d(TAG, "全部音频段发送完成")
} catch (e: Exception) {
isTyping.value = false
Log.e(TAG, "音频段发送失败: ${e.message}", e)
connectionStatus.value = "发送失败: ${e.message}"
errorMessages.value = (errorMessages.value + "发送失败: ${e.message}").takeLast(10)
historyMessages.value = historyMessages.value.filter { it.id != tempMessageId }
_audioFilePaths.value = _audioFilePaths.value - tempMessageId
_audioDurations.value = _audioDurations.value - tempMessageId
} finally {
segmentFiles.forEach { it.file.delete() }
}
}

View File

@@ -24,7 +24,7 @@ class ViewModelFactory(private val context: Context) : ViewModelProvider.Factory
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(CreateMemoryViewModel::class.java) -> {
val recorder = VoiceRecorder(context).apply { recordingLimit = 60 }
val recorder = VoiceRecorder(context).apply { recordingLimit = 600 }
CreateMemoryViewModel(
conversationRepository = container.conversationRepository,
chapterRepository = container.chapterRepository,