diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/AudioPlayer.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/AudioPlayer.kt new file mode 100644 index 0000000..8d6dac6 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/AudioPlayer.kt @@ -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.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 + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/VoiceRecorder.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/VoiceRecorder.kt index 9b91211..de5e35b 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/VoiceRecorder.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/VoiceRecorder.kt @@ -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 = _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 = _isRecording.asStateFlow() + + private val _recordingDuration = MutableStateFlow(0) + val recordingDuration: StateFlow = _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 } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt index fdc9432..f8fe51c 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt @@ -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) { diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketMessage.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketMessage.kt index b961b56..4758d03 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketMessage.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketMessage.kt @@ -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回复开始