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
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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回复开始
|
||||||
|
|||||||
Reference in New Issue
Block a user