feat: 扩展前端WebSocket和语音录制能力
- 扩展WebSocketClient和WebSocketMessage支持语音 - 优化VoiceRecorder语音录制器 - 新增AudioPlayer音频播放器 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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回复开始
|
||||
|
||||
Reference in New Issue
Block a user