feat: 扩展前端WebSocket和语音录制能力

- 扩展WebSocketClient和WebSocketMessage支持语音
- 优化VoiceRecorder语音录制器
- 新增AudioPlayer音频播放器

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
iammm0
2026-02-03 11:29:46 +08:00
parent 76fd7da3c9
commit 6dfa010b28
4 changed files with 403 additions and 10 deletions

View File

@@ -0,0 +1,219 @@
package com.huaga.life_echo.feature.voice
import android.content.Context
import android.media.MediaPlayer
import kotlinx.coroutines.CoroutineScope
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.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
/**
* 播放状态
*/
enum class PlaybackState {
IDLE, // 空闲
PLAYING, // 播放中
PAUSED // 暂停
}
/**
* 播放信息
*/
data class PlaybackInfo(
val state: PlaybackState = PlaybackState.IDLE,
val currentMessageId: String? = null,
val progress: Float = 0f, // 0-1
val currentPosition: Int = 0, // 毫秒
val duration: Int = 0 // 毫秒
)
/**
* 音频播放器
* 管理语音消息的播放,同一时刻只能播放一条
*/
class AudioPlayer(private val context: Context) {
private var mediaPlayer: MediaPlayer? = null
private var progressJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main)
private val _playbackInfo = MutableStateFlow(PlaybackInfo())
val playbackInfo: StateFlow<PlaybackInfo> = _playbackInfo.asStateFlow()
/**
* 播放音频文件
* @param messageId 消息 ID用于标识当前播放的消息
* @param filePath 音频文件路径
*/
fun play(messageId: String, filePath: String) {
// 如果正在播放同一条消息,则暂停
if (_playbackInfo.value.currentMessageId == messageId &&
_playbackInfo.value.state == PlaybackState.PLAYING) {
pause()
return
}
// 如果暂停的是同一条消息,则继续播放
if (_playbackInfo.value.currentMessageId == messageId &&
_playbackInfo.value.state == PlaybackState.PAUSED) {
resume()
return
}
// 停止当前播放
stop()
try {
val file = File(filePath)
if (!file.exists()) {
return
}
mediaPlayer = MediaPlayer().apply {
setDataSource(filePath)
prepare()
setOnCompletionListener {
onPlaybackComplete()
}
setOnErrorListener { _, _, _ ->
stop()
true
}
start()
}
val duration = mediaPlayer?.duration ?: 0
_playbackInfo.value = PlaybackInfo(
state = PlaybackState.PLAYING,
currentMessageId = messageId,
progress = 0f,
currentPosition = 0,
duration = duration
)
startProgressTracking()
} catch (e: Exception) {
e.printStackTrace()
stop()
}
}
/**
* 暂停播放
*/
fun pause() {
mediaPlayer?.let {
if (it.isPlaying) {
it.pause()
_playbackInfo.value = _playbackInfo.value.copy(
state = PlaybackState.PAUSED
)
stopProgressTracking()
}
}
}
/**
* 继续播放
*/
fun resume() {
mediaPlayer?.let {
it.start()
_playbackInfo.value = _playbackInfo.value.copy(
state = PlaybackState.PLAYING
)
startProgressTracking()
}
}
/**
* 停止播放
*/
fun stop() {
stopProgressTracking()
mediaPlayer?.apply {
try {
if (isPlaying) {
stop()
}
release()
} catch (e: Exception) {
e.printStackTrace()
}
}
mediaPlayer = null
_playbackInfo.value = PlaybackInfo()
}
/**
* 跳转到指定位置
* @param progress 进度 0-1
*/
fun seekTo(progress: Float) {
mediaPlayer?.let {
val position = (it.duration * progress).toInt()
it.seekTo(position)
_playbackInfo.value = _playbackInfo.value.copy(
progress = progress,
currentPosition = position
)
}
}
/**
* 释放资源
*/
fun release() {
stop()
}
private fun onPlaybackComplete() {
stopProgressTracking()
_playbackInfo.value = _playbackInfo.value.copy(
state = PlaybackState.IDLE,
progress = 0f,
currentPosition = 0
)
mediaPlayer?.apply {
try {
seekTo(0)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun startProgressTracking() {
stopProgressTracking()
progressJob = scope.launch {
while (isActive && mediaPlayer?.isPlaying == true) {
mediaPlayer?.let { mp ->
val current = mp.currentPosition
val duration = mp.duration
val progress = if (duration > 0) current.toFloat() / duration else 0f
_playbackInfo.value = _playbackInfo.value.copy(
progress = progress,
currentPosition = current
)
}
delay(100) // 每 100ms 更新一次
}
}
}
private fun stopProgressTracking() {
progressJob?.cancel()
progressJob = null
}
}

View File

@@ -1,38 +1,100 @@
package com.huaga.life_echo.feature.voice
import android.content.Context
import android.media.MediaMetadataRetriever
import android.media.MediaRecorder
import android.os.Build
import androidx.annotation.RequiresApi
import kotlinx.coroutines.CoroutineScope
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.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
import java.util.UUID
/**
* 录音结果
*/
data class RecordingResult(
val audioBytes: ByteArray,
val filePath: String,
val durationSeconds: Int
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as RecordingResult
if (!audioBytes.contentEquals(other.audioBytes)) return false
if (filePath != other.filePath) return false
if (durationSeconds != other.durationSeconds) return false
return true
}
override fun hashCode(): Int {
var result = audioBytes.contentHashCode()
result = 31 * result + filePath.hashCode()
result = 31 * result + durationSeconds
return result
}
}
/**
* 语音录音器
*/
class VoiceRecorder(private val context: Context) {
private var mediaRecorder: MediaRecorder? = null
private var audioFile: File? = null
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording
private var recordingStartTime: Long = 0
private var durationJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main)
@RequiresApi(Build.VERSION_CODES.S)
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
private val _recordingDuration = MutableStateFlow(0)
val recordingDuration: StateFlow<Int> = _recordingDuration.asStateFlow()
// 最大录音时长(秒)
var maxDuration: Int = 60
// 录音超时回调
var onMaxDurationReached: (() -> Unit)? = null
/**
* 开始录音。支持 API 26+O
*/
fun startRecording(): File? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return null
}
return try {
val outputDir = context.cacheDir
audioFile = File.createTempFile("audio_${UUID.randomUUID()}", ".m4a", outputDir)
mediaRecorder =
MediaRecorder(context)
.apply {
mediaRecorder = MediaRecorder(context).apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setOutputFile(audioFile?.absolutePath)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setAudioEncodingBitRate(128000)
setAudioSamplingRate(44100)
}
prepare()
start()
}
_isRecording.value = true
_recordingDuration.value = 0
recordingStartTime = System.currentTimeMillis()
// 启动计时器
startDurationTimer()
audioFile
} catch (e: Exception) {
e.printStackTrace()
@@ -40,8 +102,14 @@ class VoiceRecorder(private val context: Context) {
}
}
fun stopRecording(): ByteArray? {
/**
* 停止录音并返回结果
* @return 录音结果,包含音频字节、文件路径和时长
*/
fun stopRecording(): RecordingResult? {
return try {
stopDurationTimer()
mediaRecorder?.apply {
stop()
release()
@@ -50,18 +118,98 @@ class VoiceRecorder(private val context: Context) {
_isRecording.value = false
audioFile?.readBytes()
val file = audioFile ?: return null
val bytes = file.readBytes()
val duration = getAudioDuration(file.absolutePath)
RecordingResult(
audioBytes = bytes,
filePath = file.absolutePath,
durationSeconds = duration
)
} catch (e: Exception) {
e.printStackTrace()
_isRecording.value = false
null
}
}
/**
* 取消录音
*/
fun cancelRecording() {
try {
stopDurationTimer()
mediaRecorder?.apply {
stop()
release()
}
mediaRecorder = null
// 删除临时文件
audioFile?.delete()
audioFile = null
_isRecording.value = false
_recordingDuration.value = 0
} catch (e: Exception) {
e.printStackTrace()
_isRecording.value = false
}
}
/**
* 获取音频文件时长(秒)
*/
private fun getAudioDuration(filePath: String): Int {
return try {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(filePath)
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0
retriever.release()
(durationMs / 1000).toInt()
} catch (e: Exception) {
e.printStackTrace()
_recordingDuration.value
}
}
/**
* 获取当前录音文件路径
*/
fun getCurrentFilePath(): String? = audioFile?.absolutePath
fun release() {
stopDurationTimer()
mediaRecorder?.release()
mediaRecorder = null
audioFile?.delete()
audioFile = null
_isRecording.value = false
_recordingDuration.value = 0
}
private fun startDurationTimer() {
stopDurationTimer()
durationJob = scope.launch {
while (isActive && _isRecording.value) {
delay(1000)
val newDuration = _recordingDuration.value + 1
_recordingDuration.value = newDuration
// 检查是否达到最大时长
if (newDuration >= maxDuration) {
onMaxDurationReached?.invoke()
break
}
}
}
}
private fun stopDurationTimer() {
durationJob?.cancel()
durationJob = null
}
}

View File

@@ -209,6 +209,31 @@ class WebSocketClient {
))
}
/**
* 发送完整的音频消息(类似微信语音消息)
* @param audioBytes 音频文件字节数组
* @param conversationId 对话 ID
* @param duration 音频时长(秒)
*/
suspend fun sendAudioMessage(audioBytes: ByteArray, conversationId: String, duration: Int) {
Log.d(TAG, "准备发送音频消息,大小: ${audioBytes.size} 字节, 时长: $duration")
if (!isConnected) {
Log.w(TAG, "WebSocket未连接无法发送音频消息")
throw Exception("WebSocket未连接请先建立连接")
}
val base64Audio = android.util.Base64.encodeToString(audioBytes, android.util.Base64.NO_WRAP)
sendMessage(WebSocketMessage(
type = MessageType.audio_message,
conversation_id = conversationId,
data = buildJsonObject {
put("audio_base64", JsonPrimitive(base64Audio))
put("duration", JsonPrimitive(duration))
put("format", JsonPrimitive("m4a"))
}
))
}
suspend fun sendTextMessage(text: String, conversationId: String) {
Log.d(TAG, "准备发送文本消息: $text")
if (!isConnected) {

View File

@@ -9,8 +9,9 @@ import kotlinx.serialization.json.buildJsonObject
enum class MessageType {
connect,
audio_chunk,
audio_message, // 完整音频消息(类似微信语音)
text, // 文本消息
transcript,
transcript, // 语音转文字结果
agent_response,
agent_response_chunk, // 流式AI回复片段
agent_response_start, // AI回复开始