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 package com.huaga.life_echo.feature.voice
import android.content.Context import android.content.Context
import android.media.MediaMetadataRetriever
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.util.UUID 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) { class VoiceRecorder(private val context: Context) {
private var mediaRecorder: MediaRecorder? = null private var mediaRecorder: MediaRecorder? = null
private var audioFile: File? = null private var audioFile: File? = null
private val _isRecording = MutableStateFlow(false) private var recordingStartTime: Long = 0
val isRecording: StateFlow<Boolean> = _isRecording 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? { fun startRecording(): File? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return null
}
return try { return try {
val outputDir = context.cacheDir val outputDir = context.cacheDir
audioFile = File.createTempFile("audio_${UUID.randomUUID()}", ".m4a", outputDir) audioFile = File.createTempFile("audio_${UUID.randomUUID()}", ".m4a", outputDir)
mediaRecorder = mediaRecorder = MediaRecorder(context).apply {
MediaRecorder(context)
.apply {
setAudioSource(MediaRecorder.AudioSource.MIC) setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setOutputFile(audioFile?.absolutePath) setOutputFile(audioFile?.absolutePath)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setAudioEncodingBitRate(128000)
setAudioSamplingRate(44100)
}
prepare() prepare()
start() start()
} }
_isRecording.value = true _isRecording.value = true
_recordingDuration.value = 0
recordingStartTime = System.currentTimeMillis()
// 启动计时器
startDurationTimer()
audioFile audioFile
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -40,8 +102,14 @@ class VoiceRecorder(private val context: Context) {
} }
} }
fun stopRecording(): ByteArray? { /**
* 停止录音并返回结果
* @return 录音结果,包含音频字节、文件路径和时长
*/
fun stopRecording(): RecordingResult? {
return try { return try {
stopDurationTimer()
mediaRecorder?.apply { mediaRecorder?.apply {
stop() stop()
release() release()
@@ -50,18 +118,98 @@ class VoiceRecorder(private val context: Context) {
_isRecording.value = false _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) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
_isRecording.value = false
null 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() { fun release() {
stopDurationTimer()
mediaRecorder?.release() mediaRecorder?.release()
mediaRecorder = null mediaRecorder = null
audioFile?.delete() audioFile?.delete()
audioFile = null 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) { suspend fun sendTextMessage(text: String, conversationId: String) {
Log.d(TAG, "准备发送文本消息: $text") Log.d(TAG, "准备发送文本消息: $text")
if (!isConnected) { if (!isConnected) {

View File

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