From efb8df2fea8d0ad334b2ec1ac1e0d51224520509 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Fri, 28 Feb 2025 17:43:45 +0000 Subject: [PATCH] add --- android/app/build.gradle.kts | 4 +- .../com/example/deep_voice/AzureAsrHelper.kt | 351 ++++ .../com/example/deep_voice/MainActivity.kt | 368 +++- ...cognitionHelper.kt => VolcanoAsrHelper.kt} | 59 +- .../example/deep_voice/VolcanoTtsHelper.kt | 344 ++-- android/settings.gradle.kts | 13 +- lib/core/bindings/initial_binding.dart | 45 +- lib/core/routes/app_pages.dart | 7 + lib/core/routes/app_routes.dart | 1 + lib/core/theme/app_colors.dart | 35 + lib/core/theme/text_styles.dart | 80 + lib/data/services/azure_asr_service.dart | 376 ++++ .../services/background_agent_service.dart | 165 +- .../services/voice_recognition_service.dart | 281 --- ..._service.dart => volcano_asr_service.dart} | 25 +- .../services/volcano_tts_api_service.dart | 1570 +++++++++++++++++ lib/data/services/volcano_tts_service.dart | 714 +------- lib/modules/chat/bindings/chat_binding.dart | 6 +- .../chat/controllers/chat_controller.dart | 148 +- .../controllers/voice_input_controller.dart | 69 +- lib/modules/chat/views/chat_view.dart | 12 +- lib/modules/explore/views/explore_view.dart | 8 + lib/modules/profile/views/profile_view.dart | 4 +- lib/modules/test/bindings/test_binding.dart | 6 + .../test/controllers/asr_test_controller.dart | 53 +- .../test/controllers/tts_test_controller.dart | 319 +++- lib/modules/test/views/asr_test_view.dart | 2 +- lib/modules/test/views/tts_test_view.dart | 431 ++--- .../bindings/translation_binding.dart | 9 + .../controllers/translation_controller.dart | 334 ++++ .../models/translation_message_model.dart | 13 + .../translation/views/translation_view.dart | 302 ++++ .../widgets/translation_message_item.dart | 82 + lib/tools/check_volcano_asr_config.dart | 259 --- 34 files changed, 4659 insertions(+), 1836 deletions(-) create mode 100644 android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt rename android/app/src/main/kotlin/com/example/deep_voice/{SpeechRecognitionHelper.kt => VolcanoAsrHelper.kt} (90%) create mode 100644 lib/core/theme/app_colors.dart create mode 100644 lib/core/theme/text_styles.dart create mode 100644 lib/data/services/azure_asr_service.dart delete mode 100644 lib/data/services/voice_recognition_service.dart rename lib/data/services/{volcano_voice_recognition_service.dart => volcano_asr_service.dart} (94%) create mode 100644 lib/data/services/volcano_tts_api_service.dart create mode 100644 lib/modules/translation/bindings/translation_binding.dart create mode 100644 lib/modules/translation/controllers/translation_controller.dart create mode 100644 lib/modules/translation/models/translation_message_model.dart create mode 100644 lib/modules/translation/views/translation_view.dart create mode 100644 lib/modules/translation/widgets/translation_message_item.dart delete mode 100644 lib/tools/check_volcano_asr_config.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 819591ade..7bfee02b7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -52,8 +52,8 @@ android { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") - // Microsoft 语音识别SDK(可以移除,因为我们现在使用火山语音识别SDK) - // implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.42.0") + // Microsoft 语音识别SDK + implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.42.0") // // 添加火山语音合成SDK依赖 // implementation("com.bytedance.speechengine:speechengine_tts_tob:5.4.8") diff --git a/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt b/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt new file mode 100644 index 000000000..909e130f2 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt @@ -0,0 +1,351 @@ +package com.example.deep_voice + +import android.util.Log +import com.microsoft.cognitiveservices.speech.* +import com.microsoft.cognitiveservices.speech.audio.* +import com.microsoft.cognitiveservices.speech.util.EventHandler +import java.util.concurrent.ExecutionException +import java.util.concurrent.Semaphore + +/** + * Azure 语音识别助手类 + * 提供 Azure 语音识别服务的封装,支持一次性识别和连续识别 + */ +class AzureAsrHelper { + private var recognizer: SpeechRecognizer? = null + private var audioConfig: AudioConfig? = null + private var speechConfig: SpeechConfig? = null + private val TAG = "AzureAsrHelper" + private var isContinuousRecognitionActive = false + + // 用于同步的信号量 + private val stopRecognitionSemaphore = Semaphore(0) + + // 存储最后一次初始化的参数 + private var lastSubscriptionKey: String = "" + private var lastServiceRegion: String = "" + private var lastLanguage: String = "zh-CN" + + /** + * 初始化 Azure 语音识别服务 + * @param subscriptionKey Azure 语音服务的订阅密钥 + * @param serviceRegion Azure 语音服务的区域 + * @param language 识别语言,默认为中文 + * @return 是否初始化成功 + */ + fun initialize( + subscriptionKey: String, + serviceRegion: String, + language: String = "zh-CN" + ): Boolean { + try { + // 保存初始化参数,以便重置时使用 + lastSubscriptionKey = subscriptionKey + lastServiceRegion = serviceRegion + lastLanguage = language + + // 释放之前的资源 + dispose() + + // 创建语音配置 + speechConfig = SpeechConfig.fromSubscription(subscriptionKey, serviceRegion) + speechConfig?.speechRecognitionLanguage = language + + // 创建音频配置,使用默认麦克风 + audioConfig = AudioConfig.fromDefaultMicrophoneInput() + + // 创建识别器 + recognizer = SpeechRecognizer(speechConfig, audioConfig) + + Log.d(TAG, "Azure 语音识别服务初始化成功") + return true + } catch (e: Exception) { + Log.e(TAG, "Azure 语音识别服务初始化失败: ${e.message}") + e.printStackTrace() + return false + } + } + + /** + * 重置识别器 + * 在切换识别模式前调用,确保资源被正确释放 + * @return 是否成功重置 + */ + fun resetRecognizer(): Boolean { + try { + Log.d(TAG, "开始重置 Azure 语音识别器") + + // 如果连续识别正在进行,先停止它 + if (isContinuousRecognitionActive) { + try { + recognizer?.stopContinuousRecognitionAsync()?.get() + isContinuousRecognitionActive = false + } catch (e: Exception) { + Log.e(TAG, "停止连续识别失败: ${e.message}") + } + } + + // 关闭并释放当前的识别器 + recognizer?.close() + recognizer = null + + audioConfig?.close() + audioConfig = null + + speechConfig?.close() + speechConfig = null + + // 使用上次的参数重新创建识别器 + if (lastSubscriptionKey.isNotEmpty() && lastServiceRegion.isNotEmpty()) { + speechConfig = SpeechConfig.fromSubscription(lastSubscriptionKey, lastServiceRegion) + speechConfig?.speechRecognitionLanguage = lastLanguage + + audioConfig = AudioConfig.fromDefaultMicrophoneInput() + + recognizer = SpeechRecognizer(speechConfig, audioConfig) + + Log.d(TAG, "Azure 语音识别器重置成功") + return true + } else { + Log.e(TAG, "无法重置 Azure 语音识别器:缺少初始化参数") + return false + } + } catch (e: Exception) { + Log.e(TAG, "重置 Azure 语音识别器失败: ${e.message}") + e.printStackTrace() + return false + } + } + + /** + * 执行一次性语音识别 + * @param callback 识别结果回调 + */ + fun recognizeOnce(callback: RecognizeCallback) { + if (recognizer == null) { + callback.onError("SpeechRecognizer 未初始化") + return + } + + try { + // 使用同步方式调用,避免 CompletableFuture 的兼容性问题 + val result = recognizer?.recognizeOnceAsync()?.get() + + if (result != null) { + when (result.reason) { + ResultReason.RecognizedSpeech -> { + callback.onResult(result.text) + } + ResultReason.NoMatch -> { + callback.onError("未能识别语音") + } + ResultReason.Canceled -> { + val cancellation = CancellationDetails.fromResult(result) + callback.onError("识别被取消,原因: ${cancellation.reason}, 错误详情: ${cancellation.errorDetails}") + } + else -> { + callback.onError("识别失败,原因: ${result.reason}") + } + } + } else { + callback.onError("识别结果为空") + } + } catch (e: Exception) { + when (e) { + is InterruptedException, is ExecutionException -> { + Log.e(TAG, "识别异常: ${e.message}") + callback.onError("识别异常: ${e.message}") + } + else -> { + Log.e(TAG, "未知异常: ${e.message}") + callback.onError("未知异常: ${e.message}") + } + } + } + } + + /** + * 开始连续语音识别 + * @param callback 连续识别结果回调 + * @return 是否成功启动连续识别 + */ + fun startContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { + if (recognizer == null) { + callback.onError("SpeechRecognizer 未初始化") + return false + } + + if (isContinuousRecognitionActive) { + callback.onError("连续识别已经在进行中") + return false + } + + try { + // 设置识别事件处理 + recognizer?.let { recognizer -> + // 设置识别事件处理(最终结果) + recognizer.recognized.addEventListener( + EventHandler { _, event -> + if (event.result.reason == ResultReason.RecognizedSpeech) { + callback.onResult(event.result.text) + } else if (event.result.reason == ResultReason.NoMatch) { + callback.onNoMatch() + } + } + ) + + // 设置识别中事件处理(实时反馈) + recognizer.recognizing.addEventListener( + EventHandler { _, event -> + if (event.result.reason == ResultReason.RecognizingSpeech) { + callback.onRecognizing(event.result.text) + } + } + ) + + // 设置会话开始事件处理 + recognizer.sessionStarted.addEventListener( + EventHandler { _, _ -> + callback.onSessionStarted() + } + ) + + // 设置会话结束事件处理 + recognizer.sessionStopped.addEventListener( + EventHandler { _, _ -> + isContinuousRecognitionActive = false + callback.onSessionStopped() + stopRecognitionSemaphore.release() + } + ) + + // 设置取消事件处理 + recognizer.canceled.addEventListener( + EventHandler { _, event -> + val reason = event.reason + val errorDetails = if (reason == CancellationReason.Error) event.errorDetails else "" + callback.onCanceled(reason.toString(), errorDetails) + isContinuousRecognitionActive = false + stopRecognitionSemaphore.release() + } + ) + + // 开始连续识别 + recognizer.startContinuousRecognitionAsync().get() + isContinuousRecognitionActive = true + Log.d(TAG, "连续识别已开始") + return true + } + + return false + } catch (e: Exception) { + Log.e(TAG, "开始连续识别失败: ${e.message}") + e.printStackTrace() + callback.onError("开始连续识别失败: ${e.message}") + isContinuousRecognitionActive = false + return false + } + } + + /** + * 停止连续语音识别 + * @param callback 连续识别结果回调 + * @return 是否成功停止连续识别 + */ + fun stopContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { + if (recognizer == null) { + callback.onError("SpeechRecognizer 未初始化") + return false + } + + if (!isContinuousRecognitionActive) { + callback.onError("连续识别未在进行中") + return false + } + + try { + // 停止连续识别 + recognizer?.stopContinuousRecognitionAsync() + + // 等待停止完成 + stopRecognitionSemaphore.acquire() + + isContinuousRecognitionActive = false + Log.d(TAG, "连续识别已停止") + callback.onSessionStopped() + return true + } catch (e: Exception) { + Log.e(TAG, "停止连续识别失败: ${e.message}") + e.printStackTrace() + callback.onError("停止连续识别失败: ${e.message}") + return false + } + } + + /** + * 检查连续识别是否活跃 + * @return 连续识别是否活跃 + */ + fun isContinuousRecognitionActive(): Boolean { + return isContinuousRecognitionActive + } + + /** + * 设置识别语言 + * @param language 语言代码,例如 "zh-CN", "en-US" + */ + fun setRecognitionLanguage(language: String) { + speechConfig?.speechRecognitionLanguage = language + } + + /** + * 释放资源 + */ + fun dispose() { + try { + if (isContinuousRecognitionActive) { + try { + recognizer?.stopContinuousRecognitionAsync()?.get() + } catch (e: Exception) { + Log.e(TAG, "停止连续识别失败: ${e.message}") + } + isContinuousRecognitionActive = false + } + + recognizer?.close() + recognizer = null + + audioConfig?.close() + audioConfig = null + + speechConfig?.close() + speechConfig = null + + Log.d(TAG, "Azure 语音识别资源已释放") + } catch (e: Exception) { + Log.e(TAG, "释放资源失败: ${e.message}") + e.printStackTrace() + } + } + + /** + * 一次性识别回调接口 + */ + interface RecognizeCallback { + fun onResult(result: String) + fun onError(error: String) + } + + /** + * 连续识别回调接口 + */ + interface ContinuousRecognizeCallback { + fun onResult(text: String) + fun onRecognizing(recognizing: String) + fun onSessionStarted() + fun onSessionStopped() + fun onCanceled(reason: String, errorDetails: String) + fun onNoMatch() + fun onError(error: String) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt b/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt index 2435c5ef8..e58303205 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt @@ -13,22 +13,33 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.EventChannel class MainActivity: AudioServiceActivity() { - private val SPEECH_RECOGNITION_CHANNEL = "com.example.deep_voice/speech_recognition" - private val SPEECH_RECOGNITION_EVENT_CHANNEL = "com.example.deep_voice/speech_recognition_events" + private val SPEECH_RECOGNITION_CHANNEL = "com.example.deep_voice/volcano_asr" + private val SPEECH_RECOGNITION_EVENT_CHANNEL = "com.example.deep_voice/volcano_events" private val VOLCANO_TTS_CHANNEL = "com.example.deep_voice/volcano_tts" + private val VOLCANO_TTS_EVENT_CHANNEL = "com.example.deep_voice/volcano_tts_events" + private val AZURE_ASR_CHANNEL = "com.example.deep_voice/azure_asr" + private val AZURE_ASR_EVENT_CHANNEL = "com.example.deep_voice/azure_asr_events" + private val TAG = "MainActivity" - private lateinit var speechHelper: SpeechRecognitionHelper + private lateinit var speechHelper: VolcanoAsrHelper private lateinit var volcanoTtsHelper: VolcanoTtsHelper + private lateinit var azureAsrHelper: AzureAsrHelper + private var eventSink: EventChannel.EventSink? = null + private var azureEventSink: EventChannel.EventSink? = null + private var ttsEventSink: EventChannel.EventSink? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Initialize speechHelper with context - speechHelper = SpeechRecognitionHelper(applicationContext) + speechHelper = VolcanoAsrHelper(applicationContext) // Initialize volcanoTtsHelper with context volcanoTtsHelper = VolcanoTtsHelper(applicationContext) + + // 初始化 Azure ASR 助手 + azureAsrHelper = AzureAsrHelper() } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { @@ -55,7 +66,7 @@ class MainActivity: AudioServiceActivity() { Log.d(TAG, "资源ID: ${resourceId ?: subscriptionKey}") Log.d(TAG, "集群区域: $cluster") - val success = speechHelper.initialize(subscriptionKey, serviceRegion, cluster) + val success = speechHelper.initialize(subscriptionKey, serviceRegion, cluster, resourceId) Log.d(TAG, "语音识别服务初始化${if (success) "成功" else "失败"}") result.success(success) } catch (e: Exception) { @@ -65,7 +76,7 @@ class MainActivity: AudioServiceActivity() { } "recognizeOnce" -> { try { - speechHelper.recognizeOnce(object : SpeechRecognitionHelper.RecognizeCallback { + speechHelper.recognizeOnce(object : VolcanoAsrHelper.RecognizeCallback { override fun onResult(text: String) { result.success(text) } @@ -85,7 +96,7 @@ class MainActivity: AudioServiceActivity() { return@setMethodCallHandler } - val success = speechHelper.startContinuousRecognition(object : SpeechRecognitionHelper.ContinuousRecognizeCallback { + val success = speechHelper.startContinuousRecognition(object : VolcanoAsrHelper.ContinuousRecognizeCallback { override fun onResult(text: String) { sendEvent(mapOf( "eventType" to "finalResult", @@ -139,7 +150,7 @@ class MainActivity: AudioServiceActivity() { return@setMethodCallHandler } - val success = speechHelper.stopContinuousRecognition(object : SpeechRecognitionHelper.ContinuousRecognizeCallback { + val success = speechHelper.stopContinuousRecognition(object : VolcanoAsrHelper.ContinuousRecognizeCallback { override fun onResult(text: String) {} override fun onRecognizing(recognizing: String) {} override fun onSessionStarted() {} @@ -188,6 +199,43 @@ class MainActivity: AudioServiceActivity() { try { val success = volcanoTtsHelper.initialize(appId, token, cluster) + + // 设置播放状态回调 + volcanoTtsHelper.setPlaybackCallback(object : VolcanoTtsHelper.PlaybackCallback { + override fun onPlaybackStarted() { + sendTtsEvent(mapOf( + "eventType" to "playbackStarted" + )) + } + + override fun onPlaybackCompleted() { + sendTtsEvent(mapOf( + "eventType" to "playbackCompleted" + )) + } + + override fun onPlaybackError(error: String) { + sendTtsEvent(mapOf( + "eventType" to "playbackError", + "error" to error + )) + } + + // 实现合成开始回调 + override fun onSynthesisBegin() { + sendTtsEvent(mapOf( + "eventType" to "synthesisBegin" + )) + } + + // 实现合成结束回调 + override fun onSynthesisEnd() { + sendTtsEvent(mapOf( + "eventType" to "synthesisEnd" + )) + } + }) + result.success(success) } catch (e: Exception) { result.error("INITIALIZATION_ERROR", e.message, null) @@ -202,15 +250,18 @@ class MainActivity: AudioServiceActivity() { return@setMethodCallHandler } - volcanoTtsHelper.synthesize(text, voiceType, object : VolcanoTtsHelper.VolcanoTtsCallback { - override fun onSuccess(audioData: ByteArray) { - result.success(audioData) - } - - override fun onError(error: String) { - result.error("TTS_ERROR", error, null) + // Since synthesize method was removed, we'll use speakText instead + // and inform the caller that we no longer support direct audio data return + try { + val success = volcanoTtsHelper.speakText(text, voiceType) + if (success) { + result.success(ByteArray(0)) // Return empty byte array as placeholder + } else { + result.error("TTS_ERROR", "Failed to synthesize text", null) } - }) + } catch (e: Exception) { + result.error("TTS_ERROR", e.message, null) + } } "synthesizeSync" -> { val text = call.argument("text") @@ -221,10 +272,12 @@ class MainActivity: AudioServiceActivity() { return@setMethodCallHandler } + // Since synthesizeSync method was removed, we'll use speakText instead + // and inform the caller that we no longer support direct audio data return try { - val audioData = volcanoTtsHelper.synthesizeSync(text, voiceType) - if (audioData != null) { - result.success(audioData) + val success = volcanoTtsHelper.speakText(text, voiceType) + if (success) { + result.success(ByteArray(0)) // Return empty byte array as placeholder } else { result.error("TTS_ERROR", "Failed to synthesize text", null) } @@ -232,6 +285,74 @@ class MainActivity: AudioServiceActivity() { result.error("TTS_ERROR", e.message, null) } } + "speakText" -> { + val text = call.argument("text") + val voiceType = call.argument("voiceType") + + if (text == null || voiceType == null) { + result.error("INVALID_ARGUMENTS", "text and voiceType are required", null) + return@setMethodCallHandler + } + + try { + Log.d(TAG, "准备播放文本: $text") + + // 先停止当前播放 + try { + volcanoTtsHelper.stopSpeaking() + // 添加短暂延迟,确保引擎状态已重置 + Thread.sleep(100) + } catch (e: Exception) { + Log.e(TAG, "停止当前播放失败: ${e.message}") + // 继续尝试新的播放 + } + + val success = volcanoTtsHelper.speakText(text, voiceType) + Log.d(TAG, if (success) "成功开始播放" else "播放失败") + result.success(success) + } catch (e: Exception) { + Log.e(TAG, "播放文本失败", e) + result.error("SPEAK_ERROR", e.message, null) + } + } + "stopSpeaking" -> { + try { + val success = volcanoTtsHelper.stopSpeaking() + result.success(success) + } catch (e: Exception) { + result.error("TTS_ERROR", e.message, null) + } + } + "setSpeed" -> { + val speed = call.argument("speed") + + if (speed == null) { + result.error("INVALID_ARGUMENTS", "speed is required", null) + return@setMethodCallHandler + } + + try { + val success = volcanoTtsHelper.setSpeed(speed.toFloat()) + result.success(success) + } catch (e: Exception) { + result.error("TTS_ERROR", e.message, null) + } + } + "setVolume" -> { + val volume = call.argument("volume") + + if (volume == null) { + result.error("INVALID_ARGUMENTS", "volume is required", null) + return@setMethodCallHandler + } + + try { + val success = volcanoTtsHelper.setVolume(volume.toFloat()) + result.success(success) + } catch (e: Exception) { + result.error("TTS_ERROR", e.message, null) + } + } "dispose" -> { try { volcanoTtsHelper.dispose() @@ -246,6 +367,174 @@ class MainActivity: AudioServiceActivity() { } } + // 设置 Azure ASR 方法通道 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AZURE_ASR_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "initialize" -> { + val subscriptionKey = call.argument("subscriptionKey") + val serviceRegion = call.argument("serviceRegion") + val language = call.argument("language") ?: "zh-CN" + + if (subscriptionKey == null || serviceRegion == null) { + result.error("INVALID_ARGUMENTS", "subscriptionKey and serviceRegion are required", null) + return@setMethodCallHandler + } + + try { + Log.d(TAG, "初始化 Azure 语音识别服务,区域: $serviceRegion") + val success = azureAsrHelper.initialize(subscriptionKey, serviceRegion, language) + Log.d(TAG, "Azure 语音识别服务初始化${if (success) "成功" else "失败"}") + result.success(success) + } catch (e: Exception) { + Log.e(TAG, "初始化 Azure 语音识别服务失败", e) + result.error("INITIALIZATION_ERROR", e.message, null) + } + } + "recognizeOnce" -> { + try { + azureAsrHelper.recognizeOnce(object : AzureAsrHelper.RecognizeCallback { + override fun onResult(text: String) { + result.success(text) + } + + override fun onError(error: String) { + result.error("RECOGNITION_ERROR", error, null) + } + }) + } catch (e: Exception) { + result.error("RECOGNITION_ERROR", e.message, null) + } + } + "startContinuousRecognition" -> { + try { + if (azureAsrHelper.isContinuousRecognitionActive()) { + result.error("ALREADY_ACTIVE", "连续识别已经在进行中", null) + return@setMethodCallHandler + } + + val success = azureAsrHelper.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { + override fun onResult(text: String) { + sendAzureEvent(mapOf( + "eventType" to "finalResult", + "text" to text + )) + } + + override fun onRecognizing(recognizing: String) { + sendAzureEvent(mapOf( + "eventType" to "recognizing", + "text" to recognizing + )) + } + + override fun onSessionStarted() { + sendAzureEvent(mapOf( + "eventType" to "sessionStarted" + )) + } + + override fun onSessionStopped() { + sendAzureEvent(mapOf( + "eventType" to "sessionStopped" + )) + } + + override fun onCanceled(reason: String, errorDetails: String) { + sendAzureEvent(mapOf( + "eventType" to "canceled", + "reason" to reason, + "errorDetails" to errorDetails + )) + } + + override fun onNoMatch() { + sendAzureEvent(mapOf( + "eventType" to "noMatch" + )) + } + + override fun onError(error: String) { + sendAzureEvent(mapOf( + "eventType" to "error", + "error" to error + )) + } + }) + result.success(success) + } catch (e: Exception) { + result.error("RECOGNITION_ERROR", e.message, null) + } + } + "stopContinuousRecognition" -> { + try { + val success = azureAsrHelper.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { + override fun onResult(text: String) { + // 不需要处理 + } + + override fun onRecognizing(recognizing: String) { + // 不需要处理 + } + + override fun onSessionStarted() { + // 不需要处理 + } + + override fun onSessionStopped() { + sendAzureEvent(mapOf( + "eventType" to "sessionStopped" + )) + } + + override fun onCanceled(reason: String, errorDetails: String) { + // 不需要处理 + } + + override fun onNoMatch() { + // 不需要处理 + } + + override fun onError(error: String) { + result.error("STOP_ERROR", error, null) + return + } + }) + result.success(success) + } catch (e: Exception) { + result.error("STOP_ERROR", e.message, null) + } + } + "resetRecognizer" -> { + try { + val success = azureAsrHelper.resetRecognizer() + result.success(success) + } catch (e: Exception) { + result.error("RESET_ERROR", "重置识别器失败: ${e.message}", null) + } + } + "setRecognitionLanguage" -> { + try { + val language = call.argument("language") ?: "zh-CN" + azureAsrHelper.setRecognitionLanguage(language) + result.success(true) + } catch (e: Exception) { + result.error("LANGUAGE_ERROR", e.message, null) + } + } + "dispose" -> { + try { + azureAsrHelper.dispose() + result.success(true) + } catch (e: Exception) { + result.error("DISPOSE_ERROR", e.message, null) + } + } + else -> { + result.notImplemented() + } + } + } + // 设置语音识别事件通道 EventChannel(flutterEngine.dartExecutor.binaryMessenger, SPEECH_RECOGNITION_EVENT_CHANNEL).setStreamHandler( object : EventChannel.StreamHandler { @@ -258,6 +547,32 @@ class MainActivity: AudioServiceActivity() { } } ) + + // 设置 TTS 事件通道 + EventChannel(flutterEngine.dartExecutor.binaryMessenger, VOLCANO_TTS_EVENT_CHANNEL).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ttsEventSink = events + } + + override fun onCancel(arguments: Any?) { + ttsEventSink = null + } + } + ) + + // 设置 Azure ASR 事件通道 + EventChannel(flutterEngine.dartExecutor.binaryMessenger, AZURE_ASR_EVENT_CHANNEL).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + azureEventSink = events + } + + override fun onCancel(arguments: Any?) { + azureEventSink = null + } + } + ) } private fun sendEvent(event: Map) { @@ -266,9 +581,22 @@ class MainActivity: AudioServiceActivity() { } } + private fun sendTtsEvent(event: Map) { + runOnUiThread { + ttsEventSink?.success(event) + } + } + + private fun sendAzureEvent(event: Map) { + runOnUiThread { + azureEventSink?.success(event) + } + } + override fun onDestroy() { speechHelper.dispose() volcanoTtsHelper.dispose() + azureAsrHelper.dispose() super.onDestroy() } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/com/example/deep_voice/SpeechRecognitionHelper.kt b/android/app/src/main/kotlin/com/example/deep_voice/VolcanoAsrHelper.kt similarity index 90% rename from android/app/src/main/kotlin/com/example/deep_voice/SpeechRecognitionHelper.kt rename to android/app/src/main/kotlin/com/example/deep_voice/VolcanoAsrHelper.kt index 19f3324cf..639ed5c1a 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/SpeechRecognitionHelper.kt +++ b/android/app/src/main/kotlin/com/example/deep_voice/VolcanoAsrHelper.kt @@ -15,8 +15,8 @@ import java.util.concurrent.TimeUnit * * 该类封装了火山语音SDK的语音识别功能,提供简单的接口供Flutter调用 */ -class SpeechRecognitionHelper(context: Context) : SpeechEngine.SpeechListener { - private val TAG = "SpeechRecognitionHelper" +class VolcanoAsrHelper(context: Context) : SpeechEngine.SpeechListener { + private val TAG = "VolcanoAsrHelper" private var mSpeechEngine: SpeechEngine? = null private var mSpeechEngineHandler: Long = -1 private var isInitialized = false @@ -44,9 +44,10 @@ class SpeechRecognitionHelper(context: Context) : SpeechEngine.SpeechListener { * @param appId 应用ID * @param token 应用密钥/令牌 * @param cluster 集群区域 + * @param resourceId 资源ID,用于大模型流式识别API鉴权 * @return 初始化是否成功 */ - fun initialize(appId: String, token: String, cluster: String): Boolean { + fun initialize(appId: String, token: String, cluster: String, resourceId: String? = null): Boolean { try { // 设置基本参数 this.appId = appId @@ -54,6 +55,7 @@ class SpeechRecognitionHelper(context: Context) : SpeechEngine.SpeechListener { this.cluster = cluster Log.d(TAG, "初始化参数: APP_ID=${appId}, APP_KEY长度=${token.length}, 集群区域=${cluster}") + Log.d(TAG, "资源ID: ${resourceId ?: appId}") // 确保应用上下文已设置 if (applicationContext == null) { @@ -111,28 +113,54 @@ class SpeechRecognitionHelper(context: Context) : SpeechEngine.SpeechListener { appId ) - // 设置授权信息 - APP_TOKEN (需要添加Bearer;前缀) + + // 使用大模型流式识别SDK (API v3) + Log.d(TAG, "使用大模型流式识别SDK配置 (API v3)") + + // 设置API路径为大模型流式识别API + mSpeechEngine?.setOptionString( + mSpeechEngineHandler, + SpeechEngineDefines.PARAMS_KEY_ASR_URI_STRING, + "/api/v3/sauc/bigmodel" + ) + + // 设置授权信息 - APP_TOKEN (不需要Bearer前缀) mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, - "Bearer;$token" // 添加Bearer;前缀 + token ) - // 设置网络配置 + // 设置资源ID,用于大模型流式识别API鉴权 mSpeechEngine?.setOptionString( mSpeechEngineHandler, - SpeechEngineDefines.PARAMS_KEY_ASR_ADDRESS_STRING, - "wss://openspeech.bytedance.com" + SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, + resourceId ) - // 使用标准API路径 + // 设置协议类型为SEED + mSpeechEngine?.setOptionInt( + mSpeechEngineHandler, + SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, + SpeechEngineDefines.PROTOCOL_TYPE_SEED + ) + + // 设置ASR请求参数 mSpeechEngine?.setOptionString( mSpeechEngineHandler, - SpeechEngineDefines.PARAMS_KEY_ASR_URI_STRING, - "/api/v2/asr" + SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, + "{\"force_to_speech_time\":0, \"end_window_size\":800}" + ) + + + // 设置网络配置 + mSpeechEngine?.setOptionString( + mSpeechEngineHandler, + SpeechEngineDefines.PARAMS_KEY_ASR_ADDRESS_STRING, + "wss://openspeech.bytedance.com" ) - // 设置集群区域 + // 设置集群区域 - 这对于WebSocket连接非常重要 mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_CLUSTER_STRING, @@ -176,15 +204,16 @@ class SpeechRecognitionHelper(context: Context) : SpeechEngine.SpeechListener { mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_RESULT_TYPE_STRING, - SpeechEngineDefines.ASR_RESULT_TYPE_FULL + SpeechEngineDefines.ASR_RESULT_TYPE_SINGLE ) // 记录所有配置参数,便于调试 Log.d(TAG, "配置参数汇总:") Log.d(TAG, "APP_ID: $appId") - Log.d(TAG, "APP_TOKEN: Bearer;${token.take(5)}***") + Log.d(TAG, "APP_TOKEN: ${token.take(5)}***") Log.d(TAG, "CLUSTER: $cluster") - Log.d(TAG, "API_PATH: /api/v2/asr") + Log.d(TAG, "RESOURCE_ID: ${resourceId ?: appId}") + Log.d(TAG, "API_PATH: ${if (resourceId != null) "/api/v3/sauc/bigmodel" else "/api/v2/asr"}") // 初始化引擎 val ret = mSpeechEngine?.initEngine(mSpeechEngineHandler) ?: -1 diff --git a/android/app/src/main/kotlin/com/example/deep_voice/VolcanoTtsHelper.kt b/android/app/src/main/kotlin/com/example/deep_voice/VolcanoTtsHelper.kt index 7e6a90a13..15c78d494 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/VolcanoTtsHelper.kt +++ b/android/app/src/main/kotlin/com/example/deep_voice/VolcanoTtsHelper.kt @@ -5,13 +5,12 @@ import android.util.Log import com.bytedance.speech.speechengine.SpeechEngine import com.bytedance.speech.speechengine.SpeechEngineDefines import com.bytedance.speech.speechengine.SpeechEngineGenerator -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit /** - * 火山语音合成Helper类 (SDK版本) + * 火山语音合成Helper类 * * 该类封装了火山语音SDK的TTS功能,提供简单的接口供Flutter调用 + * 实现基于官方文档的标准接入流程 */ class VolcanoTtsHelper(context: Context) : SpeechEngine.SpeechListener { private val TAG = "VolcanoTtsHelper" @@ -20,13 +19,8 @@ class VolcanoTtsHelper(context: Context) : SpeechEngine.SpeechListener { private var isInitialized = false private var applicationContext: Context? = context.applicationContext - // 同步合成相关变量 - private var mSynthesisLatch: CountDownLatch? = null - private var mSynthesisAudioData: ByteArray? = null - private var mSynthesisError: String? = null - - // 当前回调 - private var mCurrentCallback: VolcanoTtsCallback? = null + // 播放状态回调 + private var mPlaybackCallback: PlaybackCallback? = null /** * 初始化TTS引擎 @@ -39,16 +33,16 @@ class VolcanoTtsHelper(context: Context) : SpeechEngine.SpeechListener { try { // 确保应用上下文已设置 if (applicationContext == null) { - Log.e(TAG, "应用上下文未设置,请先调用setContext方法") + Log.e(TAG, "应用上下文未设置") return false } Log.d(TAG, "初始化火山语音TTS引擎,APP_ID: ${appId.take(3)}***,Token长度: ${token.length},集群区域: $cluster") - // 准备环境 + // 1. 准备环境 SpeechEngineGenerator.PrepareEnvironment(applicationContext, null) - // 获取语音引擎实例 + // 2. 创建引擎实例 mSpeechEngine = SpeechEngineGenerator.getInstance() mSpeechEngineHandler = mSpeechEngine?.createEngine() ?: -1 @@ -59,37 +53,36 @@ class VolcanoTtsHelper(context: Context) : SpeechEngine.SpeechListener { // 设置上下文 mSpeechEngine?.setContext(applicationContext) - Log.d(TAG, "成功设置应用上下文") - // 设置引擎类型为TTS + // 3. 参数配置 + // 3.1 引擎类型 mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, SpeechEngineDefines.TTS_ENGINE ) - // 设置日志级别 + // 3.2 日志级别 mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_WARN ) - // 设置用户ID (必需) + // 3.3 线上问题定位 mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "deep_voice_user" ) - // 设置设备ID (可选) mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "deep_voice_device" ) - // 设置授权信息 + // 3.4 授权 mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, @@ -101,28 +94,21 @@ class VolcanoTtsHelper(context: Context) : SpeechEngine.SpeechListener { "Bearer;$token" ) - // 设置集群 - mSpeechEngine?.setOptionString( - mSpeechEngineHandler, - SpeechEngineDefines.PARAMS_KEY_TTS_CLUSTER_STRING, - cluster - ) - - // 设置合成场景为单次合成 + // 3.5 合成场景 mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, - SpeechEngineDefines.TTS_SCENARIO_TYPE_NORMAL + SpeechEngineDefines.TTS_SCENARIO_TYPE_NOVEL ) - // 设置合成策略为在线合成 + // 3.6 合成策略 mSpeechEngine?.setOptionInt( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WORK_MODE_INT, SpeechEngineDefines.TTS_WORK_MODE_ONLINE ) - // 设置在线请求资源配置 + // 3.7 在线请求资源配置 mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_ADDRESS_STRING, @@ -133,36 +119,41 @@ class VolcanoTtsHelper(context: Context) : SpeechEngine.SpeechListener { SpeechEngineDefines.PARAMS_KEY_TTS_URI_STRING, "/api/v1/tts/ws_binary" ) + mSpeechEngine?.setOptionString( + mSpeechEngineHandler, + SpeechEngineDefines.PARAMS_KEY_TTS_CLUSTER_STRING, + cluster + ) - // 启用播放器 + // 3.8 启用内建播放器 mSpeechEngine?.setOptionBoolean( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_ENABLE_PLAYER_BOOL, true ) - // 设置音频流类型为媒体 + // 3.9 配置内建播放器的音源 mSpeechEngine?.setOptionInt( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_AUDIO_STREAM_TYPE_INT, SpeechEngineDefines.AUDIO_STREAM_TYPE_MEDIA ) - // 设置音频格式为PCM - mSpeechEngine?.setOptionString( + // 3.10 返回音频数据 + mSpeechEngine?.setOptionInt( mSpeechEngineHandler, - "tts_audio_format", - "pcm" + SpeechEngineDefines.PARAMS_KEY_TTS_DATA_CALLBACK_MODE_INT, + 2 ) - // 初始化引擎 + // 4. 初始化引擎实例 val ret = mSpeechEngine?.initEngine(mSpeechEngineHandler) ?: -1 if (ret != SpeechEngineDefines.ERR_NO_ERROR) { Log.e(TAG, "初始化引擎失败: $ret") return false } - // 设置监听器 + // 设置回调监听器 mSpeechEngine?.setListener(this) isInitialized = true @@ -174,37 +165,45 @@ class VolcanoTtsHelper(context: Context) : SpeechEngine.SpeechListener { return false } } - + /** - * 合成文本为语音 + * 直接合成并播放文本 * * @param text 要合成的文本 * @param voiceType 语音类型 - * @param callback 回调接口,用于返回结果或错误 + * @return 是否成功开始播放 */ - fun synthesize(text: String, voiceType: String, callback: VolcanoTtsCallback) { + fun speakText(text: String, voiceType: String): Boolean { if (!isInitialized) { Log.e(TAG, "TTS引擎尚未初始化") - callback.onError("TTS引擎尚未初始化") - return + return false } try { - Log.d(TAG, "开始合成文本: $text,使用语音类型: $voiceType") - - // 保存回调以便在onMessage中使用 - mCurrentCallback = callback + Log.d(TAG, "开始直接播放文本: $text,使用语音类型: $voiceType") + + // 先停止当前播放 + try { + Log.d(TAG, "停止当前播放") + stopSpeaking() + + // 添加短暂延迟,确保引擎状态已重置 + Thread.sleep(100) + } catch (e: Exception) { + Log.e(TAG, "停止当前播放失败: ${e.message}") + // 继续尝试新的播放 + } // 设置发音人 mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, - voiceType + "other" ) mSpeechEngine?.setOptionString( mSpeechEngineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_TYPE_ONLINE_STRING, - "common" + voiceType ) // 设置文本 @@ -214,121 +213,147 @@ class VolcanoTtsHelper(context: Context) : SpeechEngine.SpeechListener { text ) - // 设置音频格式为PCM - mSpeechEngine?.setOptionString( - mSpeechEngineHandler, - "tts_audio_format", - "pcm" - ) - - // 启用音频数据回调 - mSpeechEngine?.setOptionInt( - mSpeechEngineHandler, - SpeechEngineDefines.PARAMS_KEY_TTS_DATA_CALLBACK_MODE_INT, - 2 - ) - - // 先停止引擎,避免SDK内部异步线程带来的问题 - mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") - - // 启动引擎,在单次合成场景下,这会自动开始合成 + // 启动引擎,开始合成并播放 val ret = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - val errorMsg = "启动引擎失败: $ret" - Log.e(TAG, errorMsg) - callback.onError(errorMsg) + Log.e(TAG, "启动引擎失败: $ret") + return false } + + Log.d(TAG, "成功开始播放") + return true } catch (e: Exception) { - val errorMsg = "语音合成异常: ${e.message}" - Log.e(TAG, errorMsg) - callback.onError(errorMsg) + Log.e(TAG, "直接播放文本异常: ${e.message}") + return false } } - + /** - * 同步合成文本为语音 + * 停止播放 * - * @param text 要合成的文本 - * @param voiceType 语音类型 - * @return 合成的音频数据,如果失败则返回null + * @return 是否成功停止播放 */ - fun synthesizeSync(text: String, voiceType: String): ByteArray? { + fun stopSpeaking(): Boolean { if (!isInitialized) { Log.e(TAG, "TTS引擎尚未初始化") - return null + return false } try { - Log.d(TAG, "开始同步合成文本: $text,使用语音类型: $voiceType") - - // 重置同步变量 - mSynthesisLatch = CountDownLatch(1) - mSynthesisAudioData = null - mSynthesisError = null - - // 设置发音人 - mSpeechEngine?.setOptionString( - mSpeechEngineHandler, - SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, - "other" - ) - mSpeechEngine?.setOptionString( - mSpeechEngineHandler, - SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_TYPE_ONLINE_STRING, - "$voiceType" - ) - - // 设置文本 - mSpeechEngine?.setOptionString( - mSpeechEngineHandler, - SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, - text - ) + Log.d(TAG, "停止播放") - // 设置音频格式为PCM - mSpeechEngine?.setOptionString( - mSpeechEngineHandler, - "tts_audio_format", - "pcm" - ) + // 停止引擎 + val ret = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + Log.e(TAG, "停止引擎失败: $ret") + + // 尝试再次停止引擎 + try { + Log.d(TAG, "尝试再次停止引擎") + mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + Log.d(TAG, "引擎已停止") + } catch (e: Exception) { + Log.e(TAG, "再次停止引擎失败: ${e.message}") + } + + return false + } - // 启用音频数据回调 - mSpeechEngine?.setOptionInt( - mSpeechEngineHandler, - SpeechEngineDefines.PARAMS_KEY_TTS_DATA_CALLBACK_MODE_INT, - 2 - ) + // 确保引擎完全停止 + try { + Log.d(TAG, "确保引擎完全停止") + val stopAgainRet = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + if (stopAgainRet != SpeechEngineDefines.ERR_NO_ERROR) { + Log.e(TAG, "再次停止引擎失败: $stopAgainRet") + } else { + Log.d(TAG, "引擎已完全停止") + } + } catch (e: Exception) { + Log.e(TAG, "再次停止引擎异常: ${e.message}") + } - // 先停止引擎,避免SDK内部异步线程带来的问题 - mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") + return true + } catch (e: Exception) { + Log.e(TAG, "停止播放异常: ${e.message}") + e.printStackTrace() - // 启动引擎,在单次合成场景下,这会自动开始合成 - val ret = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") - if (ret != SpeechEngineDefines.ERR_NO_ERROR) { - Log.e(TAG, "启动引擎失败: $ret") - return null + // 尝试再次停止引擎 + try { + Log.d(TAG, "尝试再次停止引擎") + mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + Log.d(TAG, "引擎已停止") + } catch (e: Exception) { + Log.e(TAG, "再次停止引擎失败: ${e.message}") } - // 等待合成完成,最多等待10秒 - mSynthesisLatch?.await(10, TimeUnit.SECONDS) + return false + } + } + + /** + * 设置播放速度 + * + * @param speed 播放速度,范围0.5-2.0 + * @return 是否设置成功 + */ + fun setSpeed(speed: Float): Boolean { + if (!isInitialized) { + Log.e(TAG, "TTS引擎尚未初始化") + return false + } + + try { + Log.d(TAG, "设置播放速度: $speed") + + // 设置播放速度 (使用新版本的API) + mSpeechEngine?.setOptionDouble( + SpeechEngineDefines.PARAMS_KEY_TTS_SPEED_RATIO_DOUBLE, + speed.toDouble() + ) - if (mSynthesisError != null) { - Log.e(TAG, "同步语音合成失败: ${mSynthesisError}") - return null - } + return true + } catch (e: Exception) { + Log.e(TAG, "设置播放速度异常: ${e.message}") + return false + } + } + + /** + * 设置播放音量 + * + * @param volume 播放音量,范围0.0-1.0 + * @return 是否设置成功 + */ + fun setVolume(volume: Float): Boolean { + if (!isInitialized) { + Log.e(TAG, "TTS引擎尚未初始化") + return false + } + + try { + Log.d(TAG, "设置播放音量: $volume") - if (mSynthesisAudioData == null) { - Log.e(TAG, "同步语音合成超时或返回空数据") - return null - } + // 设置播放音量 (使用新版本的API) + mSpeechEngine?.setOptionDouble( + SpeechEngineDefines.PARAMS_KEY_TTS_VOLUME_RATIO_DOUBLE, + volume.toDouble() + ) - Log.d(TAG, "同步语音合成成功,音频数据大小: ${mSynthesisAudioData?.size} bytes") - return mSynthesisAudioData + return true } catch (e: Exception) { - Log.e(TAG, "同步语音合成异常: ${e.message}") - return null + Log.e(TAG, "设置播放音量异常: ${e.message}") + return false } } + + /** + * 设置播放状态回调 + * + * @param callback 播放状态回调 + */ + fun setPlaybackCallback(callback: PlaybackCallback) { + mPlaybackCallback = callback + } /** * 释放资源 @@ -361,37 +386,31 @@ class VolcanoTtsHelper(context: Context) : SpeechEngine.SpeechListener { } SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { Log.e(TAG, "引擎错误: $stdData") - mCurrentCallback?.onError("引擎错误: $stdData") - mSynthesisError = "引擎错误: $stdData" - mSynthesisLatch?.countDown() + // 通知播放错误 + mPlaybackCallback?.onPlaybackError("引擎错误: $stdData") } SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_BEGIN -> { Log.d(TAG, "合成开始: $stdData") + // 通知合成开始 + mPlaybackCallback?.onSynthesisBegin() } SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_END -> { Log.d(TAG, "合成结束: $stdData") + // 通知合成结束 + mPlaybackCallback?.onSynthesisEnd() } SpeechEngineDefines.MESSAGE_TYPE_TTS_START_PLAYING -> { Log.d(TAG, "开始播放: $stdData") + // 通知播放开始 + mPlaybackCallback?.onPlaybackStarted() } SpeechEngineDefines.MESSAGE_TYPE_TTS_FINISH_PLAYING -> { Log.d(TAG, "播放结束: $stdData") + // 通知播放结束 + mPlaybackCallback?.onPlaybackCompleted() } SpeechEngineDefines.MESSAGE_TYPE_TTS_AUDIO_DATA -> { Log.d(TAG, "收到音频数据") - // 处理音频数据 - if (data.isNotEmpty()) { - try { - mCurrentCallback?.onSuccess(data) - mSynthesisAudioData = data - mSynthesisLatch?.countDown() - } catch (e: Exception) { - Log.e(TAG, "处理音频数据失败: ${e.message}") - mCurrentCallback?.onError("处理音频数据失败: ${e.message}") - mSynthesisError = "处理音频数据失败: ${e.message}" - mSynthesisLatch?.countDown() - } - } } SpeechEngineDefines.MESSAGE_TYPE_TTS_PLAYBACK_PROGRESS -> { Log.d(TAG, "播放进度: $stdData") @@ -403,10 +422,17 @@ class VolcanoTtsHelper(context: Context) : SpeechEngine.SpeechListener { } /** - * 火山语音TTS回调接口 + * 播放状态回调接口 */ - interface VolcanoTtsCallback { - fun onSuccess(audioData: ByteArray) - fun onError(error: String) + interface PlaybackCallback { + fun onPlaybackStarted() + fun onPlaybackCompleted() + fun onPlaybackError(error: String) + + // 新增合成开始回调 + fun onSynthesisBegin() + + // 新增合成结束回调 + fun onSynthesisEnd() } } \ No newline at end of file diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index a439442c2..6296e34ed 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1,9 +1,15 @@ +// This file configures the Flutter project +// Used by the Flutter tool to generate GeneratedPluginRegistrant.java + pluginManagement { val flutterSdkPath = run { val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } + val localPropertiesFile = file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use { properties.load(it) } + } val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + requireNotNull(flutterSdkPath) { "flutter.sdk not set in local.properties" } flutterSdkPath } @@ -17,9 +23,10 @@ pluginManagement { } plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("dev.flutter.flutter-plugin-loader") id("com.android.application") version "8.7.0" apply false id("org.jetbrains.kotlin.android") version "1.8.22" apply false } include(":app") + diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 0ea83b235..054bb7858 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -3,9 +3,11 @@ import '../../core/controllers/permission_controller.dart'; import '../../data/services/audio_service.dart'; import '../../data/services/notification_service.dart'; import '../../data/services/volcano_ai_service.dart'; -import '../../data/services/volcano_voice_recognition_service.dart'; -import '../../data/services/volcano_tts_service.dart'; +import '../../data/services/volcano_asr_service.dart'; +import '../../data/services/volcano_tts_api_service.dart'; +import '../../data/services/azure_asr_service.dart'; import '../../core/utils/logger.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; /// 初始绑定,用于管理全局依赖 class InitialBinding extends Bindings { @@ -15,14 +17,18 @@ class InitialBinding extends Bindings { final notificationService = Get.put(NotificationService(), permanent: true); final audioService = Get.put(AudioServiceManager(), permanent: true); final volcanoAiService = Get.put(VolcanoAIService(), permanent: true); - final voiceRecognitionService = Get.put(VolcanoVoiceRecognitionService(), permanent: true); - final volcanoTtsService = Get.put(VolcanoTtsService(), permanent: true); + final voiceRecognitionService = Get.put(VolcanoAsrService(), permanent: true); + final volcanoTtsService = Get.put(VolcanoTtsApiService(), permanent: true); + final azureAsrService = Get.put(AzureAsrService(), permanent: true); // 只注册全局控制器 Get.put(PermissionController(), permanent: true); // 异步初始化各个服务,确保顺序执行 _initializeServices(notificationService, audioService); + + // 初始化 Azure 服务 + _initializeAzureService(azureAsrService); } // 单独提取初始化服务的方法,以便更好地处理错误 @@ -47,4 +53,35 @@ class InitialBinding extends Bindings { // 其他服务初始化可以在这里添加 } + + // 初始化 Azure 语音识别服务 + void _initializeAzureService(AzureAsrService azureAsrService) async { + try { + // 从环境变量中获取 Azure 语音识别服务的配置参数 + final subscriptionKey = dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? ''; + final serviceRegion = dotenv.env['AZURE_ASR_SERVICE_REGION'] ?? 'eastasia'; + final language = dotenv.env['AZURE_ASR_LANGUAGE'] ?? 'zh-CN'; + + // 检查配置是否完整 + if (subscriptionKey.isEmpty) { + Logger.warning('Azure 语音识别服务的订阅密钥未设置,请检查 .env 文件'); + return; + } + + // 初始化 Azure ASR 服务 + final success = await azureAsrService.initialize( + subscriptionKey: subscriptionKey, + serviceRegion: serviceRegion, + language: language, + ); + + if (success) { + Logger.info('Azure 语音识别服务初始化成功'); + } else { + Logger.error('Azure 语音识别服务初始化失败'); + } + } catch (e) { + Logger.error('初始化 Azure 语音识别服务失败', e); + } + } } \ No newline at end of file diff --git a/lib/core/routes/app_pages.dart b/lib/core/routes/app_pages.dart index 784c85da8..95a3f01a1 100644 --- a/lib/core/routes/app_pages.dart +++ b/lib/core/routes/app_pages.dart @@ -10,6 +10,8 @@ import '../../modules/chat/views/chat_view.dart'; import '../../modules/test/views/tts_test_view.dart'; import '../../modules/test/views/asr_test_view.dart'; import '../../modules/test/bindings/test_binding.dart'; +import '../../modules/translation/views/translation_view.dart'; +import '../../modules/translation/bindings/translation_binding.dart'; // import '../../modules/speech_demo/speech_demo_page.dart'; // This file doesn't exist import './app_routes.dart'; @@ -50,5 +52,10 @@ abstract class AppPages { page: () => AsrTestView(), binding: AsrTestBinding(), ), + GetPage( + name: Routes.translation, + page: () => const TranslationView(), + binding: TranslationBinding(), + ), ]; } \ No newline at end of file diff --git a/lib/core/routes/app_routes.dart b/lib/core/routes/app_routes.dart index a1f31d92b..fdfecf76a 100644 --- a/lib/core/routes/app_routes.dart +++ b/lib/core/routes/app_routes.dart @@ -7,4 +7,5 @@ abstract class Routes { static const speechDemo = '/speech_demo'; static const TTS_TEST = '/tts_test'; static const ASR_TEST = '/asr_test'; + static const translation = '/translation'; } \ No newline at end of file diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 000000000..6341da360 --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +class AppColors { + // Background colors + static const Color darkBackground = Color(0xFF121212); + static const Color cardBackground = Color(0xFF1E1E1E); + + // Primary colors + static const Color primary = Color(0xFF6200EE); + static const Color primaryVariant = Color(0xFF3700B3); + + // Accent colors + static const Color accent = Color(0xFF03DAC6); + static const Color accentVariant = Color(0xFF018786); + + // Text colors + static const Color textPrimary = Color(0xFFFFFFFF); + static const Color textSecondary = Color(0xB3FFFFFF); // 70% white + static const Color textHint = Color(0x80FFFFFF); // 50% white + + // Status colors + static const Color success = Color(0xFF4CAF50); + static const Color warning = Color(0xFFFFC107); + static const Color error = Color(0xFFB00020); + + // Gradient colors + static const List primaryGradient = [ + Color(0xFF6200EE), + Color(0xFF9C27B0), + ]; + + // Disabled colors + static const Color disabled = Color(0xFF757575); + static const Color disabledBackground = Color(0xFF2D2D2D); +} \ No newline at end of file diff --git a/lib/core/theme/text_styles.dart b/lib/core/theme/text_styles.dart new file mode 100644 index 000000000..4d4fb263a --- /dev/null +++ b/lib/core/theme/text_styles.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'app_colors.dart'; + +class TextStyles { + // Headings + static const TextStyle heading1 = TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + height: 1.2, + ); + + static const TextStyle heading2 = TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + height: 1.2, + ); + + static const TextStyle heading3 = TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + height: 1.2, + ); + + // Title and subtitle + static const TextStyle title = TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + height: 1.3, + ); + + static const TextStyle subtitle = TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + height: 1.3, + ); + + // Body text + static const TextStyle body = TextStyle( + fontSize: 16, + fontWeight: FontWeight.normal, + color: AppColors.textPrimary, + height: 1.5, + ); + + static const TextStyle bodySmall = TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: AppColors.textPrimary, + height: 1.5, + ); + + // Button text + static const TextStyle button = TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + letterSpacing: 0.5, + ); + + // Caption and overline + static const TextStyle caption = TextStyle( + fontSize: 12, + fontWeight: FontWeight.normal, + color: AppColors.textSecondary, + height: 1.4, + ); + + static const TextStyle overline = TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + letterSpacing: 1.5, + height: 1.4, + ); +} \ No newline at end of file diff --git a/lib/data/services/azure_asr_service.dart b/lib/data/services/azure_asr_service.dart new file mode 100644 index 000000000..54e3530f2 --- /dev/null +++ b/lib/data/services/azure_asr_service.dart @@ -0,0 +1,376 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import '../../core/utils/logger.dart'; + +/// 识别事件类型 +enum RecognitionEventType { + /// 最终识别结果 + finalResult, + + /// 中间识别结果(实时反馈) + recognizing, + + /// 会话开始 + sessionStarted, + + /// 会话结束 + sessionStopped, + + /// 识别取消 + canceled, + + /// 无匹配结果 + noMatch, + + /// 识别错误 + error, +} + +/// 识别事件 +class RecognitionEvent { + /// 事件类型 + final RecognitionEventType type; + + /// 识别文本(仅在 finalResult 和 recognizing 类型中有效) + final String text; + + /// 错误信息(仅在 error 和 canceled 类型中有效) + final String? error; + + /// 取消原因(仅在 canceled 类型中有效) + final String? reason; + + /// 错误详情(仅在 canceled 类型中有效) + final String? errorDetails; + + RecognitionEvent({ + required this.type, + this.text = '', + this.error, + this.reason, + this.errorDetails, + }); + + /// 从原生事件映射创建识别事件 + factory RecognitionEvent.fromMap(Map map) { + final eventType = map['eventType'] as String; + + RecognitionEventType type; + switch (eventType) { + case 'recognizing': + type = RecognitionEventType.recognizing; + break; + case 'finalResult': + type = RecognitionEventType.finalResult; + break; + case 'sessionStarted': + type = RecognitionEventType.sessionStarted; + break; + case 'sessionStopped': + type = RecognitionEventType.sessionStopped; + break; + case 'canceled': + type = RecognitionEventType.canceled; + break; + case 'noMatch': + type = RecognitionEventType.noMatch; + break; + case 'error': + type = RecognitionEventType.error; + break; + default: + throw ArgumentError('Unknown event type: $eventType'); + } + + return RecognitionEvent( + type: type, + text: map['text'] as String? ?? '', + error: map['error'] as String?, + reason: map['reason'] as String?, + errorDetails: map['errorDetails'] as String?, + ); + } + + @override + String toString() { + return 'RecognitionEvent{type: $type, text: $text, error: $error, reason: $reason, errorDetails: $errorDetails}'; + } +} + +/// Azure 语音识别服务 +class AzureAsrService extends GetxService { + static AzureAsrService get to => Get.find(); + + // 方法通道 + static const MethodChannel _methodChannel = MethodChannel('com.example.deep_voice/azure_asr'); + + // 事件通道 + static const EventChannel _eventChannel = EventChannel('com.example.deep_voice/azure_asr_events'); + + // 是否已初始化 + bool _isInitialized = false; + + // 连续识别相关 + bool _isContinuousRecognitionActive = false; + StreamController? _eventStreamController; + StreamSubscription? _eventSubscription; + + // 公开的事件流 + Stream? _recognitionStream; + Stream? get recognitionStream => _recognitionStream; + + // 最新的识别结果 + String _latestRecognizedText = ''; + String get latestRecognizedText => _latestRecognizedText; + + /// 初始化 Azure 语音识别服务 + /// + /// [subscriptionKey] Azure 语音服务的订阅密钥 + /// [serviceRegion] Azure 语音服务的区域 + /// [language] 识别语言,默认为中文 + Future initialize({ + required String subscriptionKey, + required String serviceRegion, + String language = 'zh-CN', + }) async { + if (_isInitialized) return true; + + try { + final result = await _methodChannel.invokeMethod( + 'initialize', + { + 'subscriptionKey': subscriptionKey, + 'serviceRegion': serviceRegion, + 'language': language, + }, + ); + + _isInitialized = result ?? false; + + if (_isInitialized) { + Logger.info('Azure 语音识别服务初始化成功'); + } else { + Logger.error('Azure 语音识别服务初始化失败'); + } + + return _isInitialized; + } catch (e) { + Logger.error('初始化 Azure 语音识别服务失败', e); + _isInitialized = false; + return false; + } + } + + /// 重置识别器 + /// 在切换识别模式前调用,确保资源被正确释放 + Future _resetRecognizer() async { + try { + // 如果连续识别正在进行,先停止它 + if (_isContinuousRecognitionActive) { + await stopContinuousRecognition(); + } + + // 清理事件流资源 + _cleanupEventStream(); + + // 调用原生方法重置识别器 + await _methodChannel.invokeMethod('resetRecognizer'); + + Logger.info('Azure 语音识别器已重置'); + return true; + } catch (e) { + Logger.error('重置 Azure 语音识别器失败', e); + return false; + } + } + + /// 执行一次性语音识别 + Future recognizeOnce() async { + _checkInitialized(); + + try { + // 重置识别器,确保资源被正确释放 + await _resetRecognizer(); + + final result = await _methodChannel.invokeMethod('recognizeOnce'); + final text = result ?? ''; + _latestRecognizedText = text; + return text; + } catch (e) { + Logger.error('一次性语音识别失败', e); + rethrow; + } + } + + /// 开始连续语音识别 + /// + /// 返回一个布尔值,表示是否成功启动连续识别 + Future startContinuousRecognition() async { + _checkInitialized(); + + if (_isContinuousRecognitionActive) { + Logger.warning('连续识别已经在进行中'); + return false; + } + + try { + // 重置识别器,确保资源被正确释放 + await _resetRecognizer(); + + // 创建事件流控制器 + _eventStreamController = StreamController.broadcast(); + + // 设置事件监听 + _eventSubscription = _eventChannel + .receiveBroadcastStream() + .map((dynamic event) => RecognitionEvent.fromMap(event)) + .listen( + (event) { + _handleRecognitionEvent(event); + _eventStreamController?.add(event); + }, + onError: (error) { + Logger.error('Azure 语音识别事件流错误', error); + _eventStreamController?.addError(error); + _cleanupEventStream(); + }, + ); + + // 开始连续识别 + final result = await _methodChannel.invokeMethod('startContinuousRecognition'); + _isContinuousRecognitionActive = result ?? false; + + // 设置公开的流 + _recognitionStream = _eventStreamController?.stream; + + return _isContinuousRecognitionActive; + } catch (e) { + Logger.error('开始连续语音识别失败', e); + _cleanupEventStream(); + return false; + } + } + + /// 停止连续语音识别 + Future stopContinuousRecognition() async { + _checkInitialized(); + + if (!_isContinuousRecognitionActive) { + Logger.warning('连续识别未在进行中'); + return true; // 已经停止,直接返回成功 + } + + try { + final result = await _methodChannel.invokeMethod('stopContinuousRecognition'); + final success = result ?? false; + + if (success) { + _isContinuousRecognitionActive = false; + // 不立即清理事件流,等待 sessionStopped 事件 + } + + return success; + } catch (e) { + Logger.error('停止连续语音识别失败', e); + return false; + } + } + + /// 设置识别语言 + Future setRecognitionLanguage(String language) async { + _checkInitialized(); + + try { + final result = await _methodChannel.invokeMethod( + 'setRecognitionLanguage', + {'language': language}, + ); + return result ?? false; + } catch (e) { + Logger.error('设置识别语言失败', e); + return false; + } + } + + /// 检查连续识别是否活跃 + bool isContinuousRecognitionActive() { + return _isContinuousRecognitionActive; + } + + /// 处理来自原生端的识别事件 + void _handleRecognitionEvent(RecognitionEvent event) { + switch (event.type) { + case RecognitionEventType.finalResult: + _latestRecognizedText = event.text; + break; + case RecognitionEventType.sessionStopped: + _isContinuousRecognitionActive = false; + // 延迟清理事件流,确保所有事件都被处理 + Future.delayed(Duration(milliseconds: 500), _cleanupEventStream); + break; + case RecognitionEventType.canceled: + _isContinuousRecognitionActive = false; + // 延迟清理事件流,确保所有事件都被处理 + Future.delayed(Duration(milliseconds: 500), _cleanupEventStream); + break; + default: + // 其他事件类型不需要特殊处理 + break; + } + } + + /// 清理事件流资源 + void _cleanupEventStream() { + _eventSubscription?.cancel(); + _eventSubscription = null; + + // 只有在控制器存在且未关闭时才关闭 + if (_eventStreamController != null && !_eventStreamController!.isClosed) { + _eventStreamController?.close(); + } + _eventStreamController = null; + + _recognitionStream = null; + } + + /// 检查是否已初始化 + void _checkInitialized() { + if (!_isInitialized) { + throw StateError('Azure 语音识别服务未初始化'); + } + } + + /// 释放资源 + Future dispose() async { + try { + if (_isContinuousRecognitionActive) { + await stopContinuousRecognition(); + } + + await _eventSubscription?.cancel(); + _eventSubscription = null; + + // 只有在控制器存在且未关闭时才关闭 + if (_eventStreamController != null && !_eventStreamController!.isClosed) { + await _eventStreamController?.close(); + } + _eventStreamController = null; + + _recognitionStream = null; + + await _methodChannel.invokeMethod('dispose'); + + _isInitialized = false; + Logger.info('Azure 语音识别服务资源已释放'); + } catch (e) { + Logger.error('释放 Azure 语音识别服务资源失败', e); + } + } + + @override + void onClose() { + dispose(); + super.onClose(); + } +} \ No newline at end of file diff --git a/lib/data/services/background_agent_service.dart b/lib/data/services/background_agent_service.dart index 6c3e119ed..ef8f3e7db 100644 --- a/lib/data/services/background_agent_service.dart +++ b/lib/data/services/background_agent_service.dart @@ -1,23 +1,28 @@ import 'package:get/get.dart'; import 'dart:async'; import 'volcano_ai_service.dart'; -import 'volcano_tts_service.dart'; -import 'volcano_voice_recognition_service.dart'; +import 'azure_asr_service.dart'; +import 'volcano_tts_api_service.dart'; import '../../modules/chat/models/message_model.dart'; import '../../core/utils/logger.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; class BackgroundAgentService extends GetxService { static BackgroundAgentService get to => Get.find(); final VolcanoAIService _aiService; - final VolcanoTtsService _ttsService; - final VolcanoVoiceRecognitionService _voiceRecognitionService; + final VolcanoTtsApiService _ttsService; + final AzureAsrService _voiceRecognitionService; final List _messageHistory = []; String _pendingTtsText = ''; static const int _minTtsLength = 20; bool _isProcessing = false; bool _isListening = false; StreamSubscription? _recognitionSubscription; + + // TTS相关订阅 + StreamSubscription? _ttsEventSubscription; + bool _isTtsSpeaking = false; // 可观察的状态 final RxBool isListening = false.obs; @@ -55,9 +60,32 @@ class BackgroundAgentService extends GetxService { BackgroundAgentService() : _aiService = VolcanoAIService(), - _ttsService = Get.find(), - _voiceRecognitionService = Get.find() { - // 简化构造函数,不需要定期检查TTS状态 + _ttsService = Get.find(), + _voiceRecognitionService = Get.find() { + // 设置TTS事件监听 + _setupTtsEventListener(); + } + + // 设置TTS事件监听 + void _setupTtsEventListener() { + _ttsEventSubscription?.cancel(); + _ttsEventSubscription = _ttsService.eventStream.listen(_handleTtsEvent); + } + + // 处理TTS事件 + void _handleTtsEvent(Map event) { + final eventType = event['eventType']; + + switch (eventType) { + case 'ttsSentenceStart': + _isTtsSpeaking = true; + break; + case 'ttsSentenceEnd': + case 'sessionFinished': + case 'error': + _isTtsSpeaking = false; + break; + } } // 启动无语音超时计时器 @@ -65,24 +93,16 @@ class BackgroundAgentService extends GetxService { // 取消之前的计时器 _noSpeechTimer?.cancel(); - // 设置15秒的超时计时器 + // 设置30秒的超时计时器 _noSpeechTimer = Timer(const Duration(seconds: 30), () { // 检查TTS是否正在播放 - bool isTtsPlaying = false; - try { - isTtsPlaying = _ttsService.isActuallyPlaying(); - } catch (e) { - Logger.error('检查TTS播放状态失败', e); - isTtsPlaying = false; - } - - if (isTtsPlaying) { + if (_isTtsSpeaking) { // 如果TTS正在播放,重置定时器 - Logger.info('TTS正在播放,重置15秒超时计时器'); + Logger.info('TTS正在播放,重置30秒超时计时器'); _startNoSpeechTimer(); } else { - // 如果TTS不在播放,且15秒内没有检测到用户输入,退出交互 - Logger.info('15秒内没有检测到用户输入,退出交互'); + // 如果TTS不在播放,且30秒内没有检测到用户输入,退出交互 + Logger.info('30秒内没有检测到用户输入,退出交互'); _exitInteraction(); } }); @@ -94,17 +114,17 @@ class BackgroundAgentService extends GetxService { if (_recognitionCompleter != null && !_recognitionCompleter!.isCompleted) { _recognitionCompleter!.complete(''); } - // 设置标志,停止循环交互 + // 设置标志,停止循环交互 _shouldContinueInteraction = false; _isProcessing = false; // 播放退出提示 try { - await _ttsService.speak("没有听到您说话,已退出语音交互"); + // 使用默认语音类型 + const speaker = 'zh_female_shuangkuaisisi_moon_bigtts'; + await _ttsService.synthesize("没有听到您说话,已退出语音交互", speaker); } catch (e) { print('播放退出提示失败: $e'); } - - } // 处理蓝牙耳机按钮触发的交互 @@ -114,6 +134,16 @@ class BackgroundAgentService extends GetxService { return; } + // 确保TTS服务已连接 + if (!_ttsService.isConnected.value) { + try { + await _ttsService.connect(); + } catch (e) { + print('连接TTS服务失败: $e'); + return; + } + } + // 设置循环交互标志为 true _shouldContinueInteraction = true; @@ -135,7 +165,11 @@ class BackgroundAgentService extends GetxService { // 重置语音识别服务 try { - await _voiceRecognitionService.initialize(); + await _voiceRecognitionService.initialize( + subscriptionKey: dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? '', + serviceRegion: dotenv.env['AZURE_ASR_SERVICE_REGION'] ?? 'eastasia', + language: dotenv.env['AZURE_ASR_LANGUAGE'] ?? 'zh-CN', + ); } catch (e) { print('重置语音识别服务失败: $e'); } @@ -151,7 +185,9 @@ class BackgroundAgentService extends GetxService { try { // 先播放一个简短的提示音或提示语,表示开始监听 try { - await _ttsService.speak("我在听"); + // 使用默认语音类型 + const speaker = 'zh_female_shuangkuaisisi_moon_bigtts'; + await _ttsService.synthesize("我在听", speaker); } catch (e) { print('播放提示音失败: $e'); // 继续执行,不要因为提示音失败而中断整个流程 @@ -238,18 +274,23 @@ class BackgroundAgentService extends GetxService { // 累积文本并处理TTS _pendingTtsText += chunk; - if (_ttsService.isEnabled.value) { - if (_pendingTtsText.length >= _minTtsLength) { - int lastSentenceEnd = _findLastSentenceEnd(_pendingTtsText); - if (lastSentenceEnd > 0) { - String textToSpeak = _pendingTtsText.substring(0, lastSentenceEnd + 1); - try { - _ttsService.speak(textToSpeak); - } catch (e) { - print('播放TTS失败: $e'); - } - _pendingTtsText = _pendingTtsText.substring(lastSentenceEnd + 1); + + // 查找最后一个完整句子的结束位置 + int lastSentenceEnd = _findLastSentenceEnd(_pendingTtsText); + if (lastSentenceEnd > 0) { + // 提取完整的句子 + String sentenceToSpeak = _pendingTtsText.substring(0, lastSentenceEnd + 1); + // 只有当句子长度超过最小长度时才播放 + if (sentenceToSpeak.length >= _minTtsLength) { + try { + // 使用默认语音类型 + const speaker = 'zh_female_shuangkuaisisi_moon_bigtts'; + _ttsService.synthesize(sentenceToSpeak, speaker); + } catch (e) { + print('播放TTS失败: $e'); } + // 更新待处理文本,移除已播放的部分 + _pendingTtsText = _pendingTtsText.substring(lastSentenceEnd + 1); } } }, @@ -262,9 +303,11 @@ class BackgroundAgentService extends GetxService { // 如果已取消,不处理剩余的文本 if (!isCancelled && !_shouldCancelAiResponse) { // 处理剩余的文本 - if (_ttsService.isEnabled.value && _pendingTtsText.isNotEmpty) { + if (_pendingTtsText.isNotEmpty) { try { - _ttsService.speak(_pendingTtsText); + // 使用默认语音类型 + const speaker = 'zh_female_shuangkuaisisi_moon_bigtts'; + _ttsService.synthesize(_pendingTtsText, speaker); } catch (e) { print('播放剩余TTS失败: $e'); } @@ -288,7 +331,9 @@ class BackgroundAgentService extends GetxService { // 播放错误提示 try { - await _ttsService.speak(fullResponse); + // 使用默认语音类型 + const speaker = 'zh_female_shuangkuaisisi_moon_bigtts'; + await _ttsService.synthesize(fullResponse, speaker); } catch (_) {} // 启动超时计时器 @@ -318,7 +363,9 @@ class BackgroundAgentService extends GetxService { } catch (e) { print('交互过程出错: $e'); try { - await _ttsService.speak("抱歉,出现了一些问题"); + // 使用默认语音类型 + const speaker = 'zh_female_shuangkuaisisi_moon_bigtts'; + await _ttsService.synthesize("抱歉,出现了一些问题", speaker); } catch (_) {} // 发生错误时停止循环交互 @@ -342,10 +389,18 @@ class BackgroundAgentService extends GetxService { try { // 确保语音识别服务已初始化 - if (!await _voiceRecognitionService.initialize()) { + if (!await _voiceRecognitionService.initialize( + subscriptionKey: dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? '', + serviceRegion: dotenv.env['AZURE_ASR_SERVICE_REGION'] ?? 'eastasia', + language: dotenv.env['AZURE_ASR_LANGUAGE'] ?? 'zh-CN', + )) { // 尝试重新初始化 await Future.delayed(const Duration(milliseconds: 500)); - if (!await _voiceRecognitionService.initialize()) { + if (!await _voiceRecognitionService.initialize( + subscriptionKey: dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? '', + serviceRegion: dotenv.env['AZURE_ASR_SERVICE_REGION'] ?? 'eastasia', + language: dotenv.env['AZURE_ASR_LANGUAGE'] ?? 'zh-CN', + )) { throw Exception('无法初始化语音识别服务'); } } @@ -398,15 +453,8 @@ class BackgroundAgentService extends GetxService { _startNoSpeechTimer(); // 如果系统正在播放TTS或接收AI响应,检测到用户开始说话时立即中断 - bool isTtsPlaying = false; - try { - isTtsPlaying = _ttsService.isActuallyPlaying(); - } catch (e) { - isTtsPlaying = false; - } - - if (isTtsPlaying && event.text.trim().isNotEmpty) { - _ttsService.stop(); // 停止当前TTS播放 + if (_isTtsSpeaking && event.text.trim().isNotEmpty) { + _ttsService.endSession(); // 结束当前TTS会话 // 设置标志,表示应该取消当前的AI响应 _shouldCancelAiResponse = true; @@ -476,7 +524,7 @@ class BackgroundAgentService extends GetxService { _recognitionSubscription = null; // 停止语音识别 - await _voiceRecognitionService.stopRecognition(); + await _voiceRecognitionService.stopContinuousRecognition(); // 获取最终识别结果 final result = recognizedText.value; @@ -493,7 +541,11 @@ class BackgroundAgentService extends GetxService { // 尝试强制重置语音识别服务 try { - await _voiceRecognitionService.initialize(); + await _voiceRecognitionService.initialize( + subscriptionKey: dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? '', + serviceRegion: dotenv.env['AZURE_ASR_SERVICE_REGION'] ?? 'eastasia', + language: dotenv.env['AZURE_ASR_LANGUAGE'] ?? 'zh-CN', + ); } catch (e) { print('强制重置语音识别服务失败: $e'); } @@ -527,7 +579,12 @@ class BackgroundAgentService extends GetxService { _silenceTimer?.cancel(); _noSpeechTimer?.cancel(); _aiResponseSubscription?.cancel(); + _ttsEventSubscription?.cancel(); _shouldContinueInteraction = false; + + // 断开TTS服务连接 + _ttsService.disconnect(); + super.onClose(); } } \ No newline at end of file diff --git a/lib/data/services/voice_recognition_service.dart b/lib/data/services/voice_recognition_service.dart deleted file mode 100644 index 904dc0281..000000000 --- a/lib/data/services/voice_recognition_service.dart +++ /dev/null @@ -1,281 +0,0 @@ -import 'dart:async'; -import 'package:flutter/services.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; - -/// 微软语音识别服务 -/// -/// 该服务提供了通过平台通道与 Android 上的 Microsoft Speech SDK 交互的接口 -class VoiceRecognitionService { - static const MethodChannel _channel = MethodChannel('com.example.deep_voice/speech_recognition'); - static const EventChannel _eventChannel = EventChannel('com.example.deep_voice/speech_recognition_events'); - - bool _isInitialized = false; - late final String _subscriptionKey; - late final String _serviceRegion; - - // 连续识别相关 - bool _isContinuousRecognitionActive = false; - StreamController? _eventStreamController; - StreamSubscription? _eventSubscription; - - // 公开的事件流 - Stream? _recognitionStream; - Stream? get recognitionStream => _recognitionStream; - - // 最新的识别结果 - String _latestRecognizedText = ''; - String get latestRecognizedText => _latestRecognizedText; - - VoiceRecognitionService() { - _loadConfig(); - } - - /// 从环境变量加载配置 - void _loadConfig() { - _subscriptionKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; - _serviceRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; - - if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { - throw Exception('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); - } - } - - /// 初始化微软语音识别 SDK - /// - /// 返回 true 表示初始化成功,否则抛出 PlatformException - Future initialize() async { - if (_isInitialized) return true; - - try { - final bool result = await _channel.invokeMethod('initialize', { - 'subscriptionKey': _subscriptionKey, - 'serviceRegion': _serviceRegion, - }); - _isInitialized = result; - return result; - } on PlatformException catch (e) { - print('语音识别初始化失败: ${e.message}'); - _isInitialized = false; - throw e; - } - } - - /// 执行一次性语音识别 - /// - /// 返回识别的文本,如果识别失败则抛出 PlatformException - Future recognizeSpeech() async { - if (!_isInitialized) { - throw Exception('语音识别服务未初始化,请先调用 initialize()'); - } - - try { - final String result = await _channel.invokeMethod('recognizeOnce'); - return result; - } on PlatformException catch (e) { - print('语音识别失败: ${e.message}'); - throw e; - } - } - - /// 开始连续语音识别 - /// - /// 返回一个包含识别事件的流,如果开始失败则抛出 PlatformException - Future> startContinuousRecognition() async { - if (!_isInitialized) { - throw Exception('语音识别服务未初始化,请先调用 initialize()'); - } - - if (_isContinuousRecognitionActive) { - throw Exception('连续语音识别已经在进行中'); - } - - try { - // 创建事件流控制器 - _eventStreamController = StreamController.broadcast(); - - // 设置事件监听 - _eventSubscription = _eventChannel - .receiveBroadcastStream() - .listen(_handleRecognitionEvent, onError: _handleRecognitionError); - - // 开始连续识别 - final bool result = await _channel.invokeMethod('startContinuousRecognition'); - _isContinuousRecognitionActive = result; - - // 设置公开的流 - _recognitionStream = _eventStreamController!.stream; - - return _recognitionStream!; - } on PlatformException catch (e) { - print('开始连续语音识别失败: ${e.message}'); - _cleanupEventStream(); - throw e; - } - } - - /// 停止连续语音识别 - /// - /// 返回 true 表示停止成功,否则抛出 PlatformException - Future stopContinuousRecognition() async { - if (!_isInitialized) { - throw Exception('语音识别服务未初始化,请先调用 initialize()'); - } - - if (!_isContinuousRecognitionActive) { - return true; // 已经停止,直接返回成功 - } - - try { - final bool result = await _channel.invokeMethod('stopContinuousRecognition'); - _isContinuousRecognitionActive = !result; - - // 清理事件流 - _cleanupEventStream(); - - return result; - } on PlatformException catch (e) { - print('停止连续语音识别失败: ${e.message}'); - throw e; - } - } - - /// 检查连续识别是否活跃 - bool isContinuousRecognitionActive() { - return _isContinuousRecognitionActive; - } - - /// 处理来自原生端的识别事件 - void _handleRecognitionEvent(dynamic event) { - if (event is! Map) return; - - final Map eventMap = event; - final String eventType = eventMap['eventType'] as String? ?? ''; - - switch (eventType) { - case 'finalResult': - final String text = eventMap['text'] as String? ?? ''; - _latestRecognizedText = text; - _eventStreamController?.add(RecognitionEvent( - type: RecognitionEventType.finalResult, - text: text, - )); - break; - - case 'intermediateResult': - final String text = eventMap['text'] as String? ?? ''; - _eventStreamController?.add(RecognitionEvent( - type: RecognitionEventType.intermediateResult, - text: text, - )); - break; - - case 'sessionStarted': - _eventStreamController?.add(RecognitionEvent( - type: RecognitionEventType.sessionStarted, - )); - break; - - case 'sessionStopped': - _isContinuousRecognitionActive = false; - _eventStreamController?.add(RecognitionEvent( - type: RecognitionEventType.sessionStopped, - )); - break; - - case 'canceled': - _isContinuousRecognitionActive = false; - final String reason = eventMap['reason'] as String? ?? ''; - final String errorDetails = eventMap['errorDetails'] as String? ?? ''; - _eventStreamController?.add(RecognitionEvent( - type: RecognitionEventType.canceled, - error: '$reason: $errorDetails', - )); - break; - - case 'error': - final String error = eventMap['error'] as String? ?? ''; - _eventStreamController?.add(RecognitionEvent( - type: RecognitionEventType.error, - error: error, - )); - break; - } - } - - /// 处理识别事件流错误 - void _handleRecognitionError(Object error) { - _eventStreamController?.addError(error); - _cleanupEventStream(); - } - - /// 清理事件流资源 - void _cleanupEventStream() { - _eventSubscription?.cancel(); - _eventSubscription = null; - - _eventStreamController?.close(); - _eventStreamController = null; - - _recognitionStream = null; - _isContinuousRecognitionActive = false; - } - - /// 释放资源 - Future dispose() async { - try { - if (_isContinuousRecognitionActive) { - await stopContinuousRecognition(); - } - - await _channel.invokeMethod('dispose'); - _cleanupEventStream(); - _isInitialized = false; - } catch (e) { - print('释放语音识别资源失败: $e'); - } - } -} - -/// 识别事件类型 -enum RecognitionEventType { - /// 最终识别结果 - finalResult, - - /// 中间识别结果(实时反馈) - intermediateResult, - - /// 会话开始 - sessionStarted, - - /// 会话结束 - sessionStopped, - - /// 识别取消 - canceled, - - /// 识别错误 - error, -} - -/// 识别事件 -class RecognitionEvent { - /// 事件类型 - final RecognitionEventType type; - - /// 识别文本(仅在 finalResult 和 intermediateResult 类型中有效) - final String text; - - /// 错误信息(仅在 error 和 canceled 类型中有效) - final String error; - - RecognitionEvent({ - required this.type, - this.text = '', - this.error = '', - }); - - @override - String toString() { - return 'RecognitionEvent{type: $type, text: $text, error: $error}'; - } -} \ No newline at end of file diff --git a/lib/data/services/volcano_voice_recognition_service.dart b/lib/data/services/volcano_asr_service.dart similarity index 94% rename from lib/data/services/volcano_voice_recognition_service.dart rename to lib/data/services/volcano_asr_service.dart index ddd697883..72c54f5f4 100644 --- a/lib/data/services/volcano_voice_recognition_service.dart +++ b/lib/data/services/volcano_asr_service.dart @@ -30,15 +30,16 @@ class RecognitionEvent { /// 火山语音识别服务 /// /// 该服务提供了通过平台通道与 Android 上的火山语音识别 SDK 交互的接口 -class VolcanoVoiceRecognitionService extends GetxService { +class VolcanoAsrService extends GetxService { // 平台通道 static const MethodChannel _channel = MethodChannel('com.example.deep_voice/volcano_asr'); - static const EventChannel _eventChannel = EventChannel('com.example.deep_voice/speech_recognition_events'); + static const EventChannel _eventChannel = EventChannel('com.example.deep_voice/volcano_asr_events'); // 配置参数 late final String _appId; late final String _token; late final String _cluster; // 集群区域 + late final String? _resourceId; // 资源ID,用于大模型流式识别API鉴权 // 状态变量 final _isListening = false.obs; @@ -64,23 +65,27 @@ class VolcanoVoiceRecognitionService extends GetxService { return _isListening.value; } - VolcanoVoiceRecognitionService() { + VolcanoSrcService() { // 从环境变量获取配置 _appId = dotenv.env['VOLCANO_ASR_APP_ID'] ?? ''; - _token = dotenv.env['VOLCANO_ASR_APP_KEY'] ?? ''; + _token = dotenv.env['VOLCANO_ASR_APP_TOKEN'] ?? ''; // 从环境变量获取集群区域 _cluster = dotenv.env['VOLCANO_ASR_CLUSTER'] ?? ''; + // 从环境变量获取资源ID + _resourceId = dotenv.env['VOLCANO_ASR_RESOURCE_ID']; Logger.info('火山语音识别配置: APP_ID=${_appId.isNotEmpty ? "已设置" : "未设置"}, APP_KEY=${_token.isNotEmpty ? "已设置" : "未设置"}'); Logger.info('使用标准语音识别SDK: $_useStandardASR'); Logger.info('集群区域: $_cluster'); + Logger.info('资源ID: $_resourceId'); if (_appId.isEmpty || _token.isEmpty) { _errorMessage.value = '火山语音识别配置不完整,请检查环境变量'; Logger.error(_errorMessage.value); } } - + + // 获取可观察状态 RxBool get isListening => _isListening; bool get isInitialized => _isInitialized.value; @@ -151,11 +156,13 @@ class VolcanoVoiceRecognitionService extends GetxService { Logger.info('使用标准语音识别SDK配置 (API v2)'); Logger.info('集群区域: $_cluster'); + Logger.info('资源ID: $_resourceId'); final Map params = { - 'appId': _appId, - 'token': _token, + 'subscriptionKey': _appId, + 'serviceRegion': _token, 'cluster': _cluster, + 'resourceId': _resourceId, }; final bool result = await _channel.invokeMethod('initialize', params); @@ -213,7 +220,7 @@ class VolcanoVoiceRecognitionService extends GetxService { _errorMessage.value = ''; _recognitionResults.clear(); - final bool result = await _channel.invokeMethod('startOneTimeRecognition'); + final bool result = await _channel.invokeMethod('recognizeOnce'); _isListening.value = result; if (result) { @@ -338,7 +345,7 @@ class VolcanoVoiceRecognitionService extends GetxService { } try { - final bool result = await _channel.invokeMethod('stopRecognition'); + final bool result = await _channel.invokeMethod('stopContinuousRecognition'); _isListening.value = !result; if (result) { diff --git a/lib/data/services/volcano_tts_api_service.dart b/lib/data/services/volcano_tts_api_service.dart new file mode 100644 index 000000000..87959d5e9 --- /dev/null +++ b/lib/data/services/volcano_tts_api_service.dart @@ -0,0 +1,1570 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:io' as io; +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/io.dart'; +import 'package:uuid/uuid.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:path_provider/path_provider.dart'; + +/// 火山语音合成双向流式API服务 +class VolcanoTtsApiService extends GetxController { + // 协议常量 + static const int _protocolVersion = 0x0001; + static const int _defaultHeaderSize = 0x0001; + + // 消息类型 + static const int _fullClientRequest = 0x0001; + static const int _audioOnlyResponse = 0x000B; + static const int _fullServerResponse = 0x0009; + static const int _errorInformation = 0x000F; + + // 消息类型特定标志 + static const int _msgTypeFlagNoSeq = 0x0000; + static const int _msgTypeFlagPositiveSeq = 0x0001; + static const int _msgTypeFlagLastNoSeq = 0x0002; + static const int _msgTypeFlagNegativeSeq = 0x0003; + static const int _msgTypeFlagWithEvent = 0x0004; + + // 消息序列化 + static const int _noSerialization = 0x0000; + static const int _jsonSerialization = 0x0001; + + // 消息压缩 + static const int _compressionNo = 0x0000; + static const int _compressionGzip = 0x0001; + + // 事件类型 + static const int eventNone = 0; + static const int eventStartConnection = 1; + static const int eventFinishConnection = 2; + static const int eventConnectionStarted = 50; + static const int eventConnectionFailed = 51; + static const int eventConnectionFinished = 52; + static const int eventStartSession = 100; + static const int eventFinishSession = 102; + static const int eventSessionStarted = 150; + static const int eventSessionFinished = 152; + static const int eventSessionFailed = 153; + static const int eventTaskRequest = 200; + static const int eventTtsSentenceStart = 350; + static const int eventTtsSentenceEnd = 351; + static const int eventTtsResponse = 352; + + // 服务URL + static const String _serviceUrl = 'wss://openspeech.bytedance.com/api/v3/tts/bidirection'; + + // 配置参数 + final String _appId; + final String _token; + final String _resourceId; + + // WebSocket连接 + WebSocketChannel? _channel; + + // 会话ID + String? _sessionId; + String? _connectionId; + + // 状态控制 + bool _isConnected = false; + bool _isSessionActive = false; + + // 音频数据控制器 + final _audioDataController = StreamController.broadcast(); + Stream get audioStream => _audioDataController.stream; + + // 事件控制器 + final _eventController = StreamController>.broadcast(); + Stream> get eventStream => _eventController.stream; + + // 当前状态 + final isConnecting = false.obs; + final isConnected = false.obs; + final isSynthesizing = false.obs; + + // 音频播放器 + final AudioPlayer _audioPlayer = AudioPlayer(); + + // 播放列表 - 用于连续播放 + final ConcatenatingAudioSource _playlist = ConcatenatingAudioSource(children: []); + bool _playlistInitialized = false; + + // 音频数据控制器 - 用于流式播放 + final StreamController> _audioStreamController = StreamController>(); + + // 是否正在播放 + final RxBool _isPlaying = false.obs; + bool get isPlaying => _isPlaying.value; + + // 默认发音人 + final String defaultSpeaker = 'zh_female_1'; + + // 音频缓冲区 + final List _audioBuffer = []; + + // 检查是否需要准备下一个音频项目 + DateTime? _lastCheckTime; + int? _lastCheckedIndex; + + // 构造函数 + VolcanoTtsApiService() : + _appId = dotenv.env['VOLCANO_TTS_APP_ID'] ?? '', + _token = dotenv.env['VOLCANO_TTS_APP_TOKEN'] ?? '', + _resourceId = dotenv.env['VOLCANO_TTS_RESOURCE_ID'] ?? '' { + if (_appId.isEmpty || _token.isEmpty) { + throw Exception('火山语音配置信息不完整,请检查环境变量 VOLCANO_TTS_APP_ID 和 VOLCANO_TTS_APP_TOKEN'); + } + } + + @override + void onInit() { + super.onInit(); + // 初始化时不自动连接,等待需要时再连接 + + // 设置音频播放器状态监听 + _setupAudioPlayerListeners(); + + // 初始化播放列表 + _initializePlaylist(); + } + + /// 初始化播放列表 + Future _initializePlaylist() async { + try { + if (!_playlistInitialized) { + // 设置音频属性,启用无缝播放 + await _audioPlayer.setLoopMode(LoopMode.off); + await _audioPlayer.setShuffleModeEnabled(false); + + // 设置自动连续播放 + await _audioPlayer.setAutomaticallyWaitsToMinimizeStalling(false); + + // 设置音频源 + await _audioPlayer.setAudioSource(_playlist, initialIndex: 0, initialPosition: Duration.zero); + + _playlistInitialized = true; + if (kDebugMode) { + print('播放列表初始化成功'); + } + } + } catch (e) { + if (kDebugMode) { + print('初始化播放列表失败: $e'); + } + // 不抛出异常,避免中断整个流程 + } + } + + /// 设置音频播放器状态监听 + void _setupAudioPlayerListeners() { + _audioPlayer.playerStateStream.listen((state) { + if (state.playing) { + _isPlaying.value = true; + } else { + _isPlaying.value = false; + } + }); + + _audioPlayer.processingStateStream.listen((state) { + if (state == ProcessingState.completed) { + if (kDebugMode) { + print('播放完成,处理状态: $state'); + } + + // 播放完成后检查播放列表是否为空 + if (_playlist.length > 0) { + final currentIndex = _audioPlayer.currentIndex; + if (kDebugMode) { + print('播放列表中还有 ${_playlist.length} 个项目,当前索引: $currentIndex'); + } + + // 自动播放下一个项目 + _advanceToNextItem(); + } else { + // 播放列表为空,标记为未播放状态 + _isPlaying.value = false; + } + } + }); + + // 监听序列状态变化 + _audioPlayer.sequenceStateStream.listen((sequenceState) { + if (sequenceState == null) return; + + if (kDebugMode) { + print('序列状态变化: 当前索引=${sequenceState.currentIndex}, 序列长度=${sequenceState.sequence.length}'); + } + + // 如果当前是最后一个项目且播放已完成,标记播放完成 + if (sequenceState.currentIndex == sequenceState.sequence.length - 1 && + _audioPlayer.processingState == ProcessingState.completed) { + if (kDebugMode) { + print('播放列表播放完成'); + } + _isPlaying.value = false; + } + }); + + // 监听位置变化,但减少检查频率 + DateTime _lastPositionCheck = DateTime.now(); + _audioPlayer.positionStream.listen((position) { + // 限制检查频率,每500毫秒检查一次 + final now = DateTime.now(); + if (now.difference(_lastPositionCheck).inMilliseconds >= 500) { + _lastPositionCheck = now; + _checkForNextItem(position); + } + }); + + // 监听播放错误 + _audioPlayer.playbackEventStream.listen((event) { + if (event.processingState == ProcessingState.completed) { + // 播放完成,已在上面处理 + } else if (event.processingState == ProcessingState.idle && _audioPlayer.playerState.playing == false) { + // 播放器空闲且未播放,可能是出错了 + if (kDebugMode) { + print('播放出错: ${_audioPlayer.playerState.processingState}'); + } + // 直接报告错误,不尝试恢复 + _eventController.add({ + 'eventType': 'playbackError', + 'error': '音频播放出错' + }); + } + }); + } + + /// 处理播放错误 - 已不再使用,保留方法签名以避免引用错误 + Future _handlePlaybackError() async { + // 不再尝试自动恢复,直接报告错误 + if (kDebugMode) { + print('播放出错,不尝试自动恢复'); + } + } + + /// 检查是否需要准备下一个音频项目 + Future _checkForNextItem(Duration position) async { + try { + // 只有在播放中且有下一个项目时才检查 + if (!_isPlaying.value || _audioPlayer.currentIndex == null) return; + + final currentIndex = _audioPlayer.currentIndex!; + final sequenceState = _audioPlayer.sequenceState; + + // 如果没有序列状态,则不需要准备 + if (sequenceState == null) return; + + // 防抖动:如果在短时间内(200毫秒)对同一个索引进行了检查,则跳过 + final now = DateTime.now(); + if (_lastCheckTime != null && + _lastCheckedIndex == currentIndex && + now.difference(_lastCheckTime!) < const Duration(milliseconds: 200)) { + return; + } + + // 更新最后检查时间和索引 + _lastCheckTime = now; + _lastCheckedIndex = currentIndex; + + // 获取当前项目的总时长 + final duration = _audioPlayer.duration; + if (duration == null) return; + + // 如果是最后一个项目,不需要预加载 + final isLastItem = currentIndex >= sequenceState.sequence.length - 1; + + // 如果接近结束(剩余时间小于200毫秒),准备下一个项目或完成播放 + if (duration - position <= const Duration(milliseconds: 200)) { + if (!isLastItem) { + // 如果不是最后一个项目,预加载下一个 + if (kDebugMode) { + print('当前音频(索引 $currentIndex)接近结束,准备下一个项目'); + } + + // 不再执行预加载操作,因为这可能导致播放不稳定 + // 让播放器自然过渡到下一个项目 + } else { + // 如果是最后一个项目,确保它能够完成播放 + if (kDebugMode) { + print('最后一个音频项目(索引 $currentIndex)接近结束'); + } + } + } + } catch (e) { + // 忽略错误,不影响正常播放 + if (kDebugMode) { + print('检查下一个项目时发生错误: $e'); + } + } + } + + /// 前进到播放列表中的下一个项目 + Future _advanceToNextItem() async { + try { + if (_playlist.length == 0) return; + + // 获取当前索引和序列状态 + final currentIndex = _audioPlayer.currentIndex; + final sequenceState = _audioPlayer.sequenceState; + + if (sequenceState == null) { + // 如果没有序列状态,直接开始播放 + if (kDebugMode) { + print('没有序列状态,从头开始播放'); + } + await _audioPlayer.seek(Duration.zero, index: 0); + await _audioPlayer.play(); + _isPlaying.value = true; + return; + } + + // 如果当前索引无效,从头开始播放 + if (currentIndex == null || currentIndex < 0) { + if (kDebugMode) { + print('当前索引无效,从头开始播放'); + } + await _audioPlayer.seek(Duration.zero, index: 0); + await _audioPlayer.play(); + _isPlaying.value = true; + return; + } + + // 计算下一个索引 + final nextIndex = currentIndex + 1; + + // 检查是否还有下一个项目 + if (nextIndex < sequenceState.sequence.length) { + if (kDebugMode) { + print('前进到下一个音频项目,索引: $nextIndex'); + } + + // 跳转到下一个项目并开始播放 + await _audioPlayer.seek(Duration.zero, index: nextIndex); + await _audioPlayer.play(); + _isPlaying.value = true; + } else { + // 已经是最后一个项目 + if (kDebugMode) { + print('已经是最后一个音频项目,播放完成'); + } + + // 确保最后一个项目播放完成 + if (_audioPlayer.position < _audioPlayer.duration!) { + await _audioPlayer.seek(_audioPlayer.duration!); + } + + // 标记为未播放状态 + _isPlaying.value = false; + } + } catch (e) { + if (kDebugMode) { + print('前进到下一个项目时发生错误: $e'); + } + } + } + + /// 连接到火山语音服务 + Future connect() async { + if (_isConnected) { + return true; // 已经连接 + } + + try { + isConnecting.value = true; + + if (kDebugMode) { + print('正在连接到火山语音服务...'); + } + + // 生成连接ID + _connectionId = const Uuid().v4(); + + // 创建WebSocket连接 + final uri = Uri.parse(_serviceUrl); + + if (!kIsWeb && (io.Platform.isAndroid || io.Platform.isIOS || io.Platform.isMacOS || io.Platform.isLinux || io.Platform.isWindows)) { + // 移动平台和桌面平台 - 使用IOWebSocketChannel + if (kDebugMode) { + print('使用IOWebSocketChannel连接: $uri'); + } + + _channel = IOWebSocketChannel.connect( + uri, + headers: { + 'X-Api-App-Key': _appId, + 'X-Api-Access-Key': _token, + 'X-Api-Resource-Id': _resourceId, + 'X-Api-Connect-Id': _connectionId!, + }, + ); + } else { + // Web平台 - 使用WebSocketChannel + // 注意:在Web平台上,我们无法直接设置WebSocket头部 + // 这可能会导致认证失败,需要与服务提供商确认Web平台的认证方式 + if (kDebugMode) { + print('警告:在Web平台上无法设置WebSocket头部,可能导致认证失败'); + print('使用WebSocketChannel连接: $uri'); + } + _channel = WebSocketChannel.connect(uri); + } + + // 监听消息 + _channel!.stream.listen( + _handleMessage, + onError: _handleError, + onDone: _handleDone, + cancelOnError: false, + ); + + if (kDebugMode) { + print('WebSocket连接已建立,发送开始连接事件...'); + } + + // 发送开始连接事件 + await _startConnection(); + + // 等待连接成功 + final completer = Completer(); + + // 设置超时 + final timeout = Timer(const Duration(seconds: 10), () { + if (!completer.isCompleted) { + if (kDebugMode) { + print('等待连接响应超时'); + } + completer.complete(false); + _handleError(Exception('连接超时')); + } + }); + + if (kDebugMode) { + print('等待连接响应...'); + } + + // 监听连接事件 + final subscription = eventStream.listen((event) { + if (kDebugMode) { + print('收到事件: ${event['eventType']}'); + } + + if (event['eventType'] == 'connectionStarted') { + if (!completer.isCompleted) { + if (kDebugMode) { + print('连接成功'); + } + completer.complete(true); + timeout.cancel(); + } + } else if (event['eventType'] == 'connectionFailed') { + if (!completer.isCompleted) { + if (kDebugMode) { + print('连接失败: ${event['error']}'); + } + completer.complete(false); + timeout.cancel(); + } + } + }); + + // 等待连接结果 + final result = await completer.future; + subscription.cancel(); + + if (result) { + _isConnected = true; + isConnected.value = true; + if (kDebugMode) { + print('火山语音服务连接成功'); + } + } else { + if (kDebugMode) { + print('火山语音服务连接失败'); + } + } + + return result; + } catch (e) { + if (kDebugMode) { + print('连接过程中发生异常: $e'); + } + _handleError(e); + return false; + } finally { + isConnecting.value = false; + } + } + + /// 开始TTS会话 + Future startSession(String speaker) async { + if (!_isConnected) { + final connected = await connect(); + if (!connected) return false; + } + + if (_isSessionActive) { + return true; // 会话已经活跃 + } + + try { + // 生成会话ID + _sessionId = const Uuid().v4().replaceAll('-', ''); + + // 发送开始会话事件 + await _startTtsSession(speaker); + + // 等待会话开始 + final completer = Completer(); + + // 设置超时 + final timeout = Timer(const Duration(seconds: 10), () { + if (!completer.isCompleted) { + completer.complete(false); + _handleError(Exception('会话开始超时')); + } + }); + + // 监听会话事件 + final subscription = eventStream.listen((event) { + if (event['eventType'] == 'sessionStarted') { + if (!completer.isCompleted) { + completer.complete(true); + timeout.cancel(); + } + } else if (event['eventType'] == 'sessionFailed') { + if (!completer.isCompleted) { + completer.complete(false); + timeout.cancel(); + } + } + }); + + // 等待会话结果 + final result = await completer.future; + subscription.cancel(); + + if (result) { + _isSessionActive = true; + } + + return result; + } catch (e) { + _handleError(e); + return false; + } + } + + /// 合成文本 + Future synthesize(String text, String speaker) async { + if (!_isConnected) { + final connected = await connect(); + if (!connected) return false; + } + + if (!_isSessionActive) { + final sessionStarted = await startSession(speaker); + if (!sessionStarted) return false; + } + + try { + isSynthesizing.value = true; + + // 发送合成请求 + await _sendTtsMessage(speaker, text); + + return true; + } catch (e) { + _handleError(e); + return false; + } + } + + /// 结束会话 + Future endSession() async { + if (!_isSessionActive) { + return true; // 没有活跃会话 + } + + try { + // 发送结束会话事件 + await _finishSession(); + + // 等待会话结束 + final completer = Completer(); + + // 设置超时 + final timeout = Timer(const Duration(seconds: 5), () { + if (!completer.isCompleted) { + completer.complete(true); // 超时也认为成功 + } + }); + + // 监听会话事件 + final subscription = eventStream.listen((event) { + if (event['eventType'] == 'sessionFinished') { + if (!completer.isCompleted) { + completer.complete(true); + timeout.cancel(); + } + } + }); + + // 等待会话结果 + final result = await completer.future; + subscription.cancel(); + + _isSessionActive = false; + isSynthesizing.value = false; + + return result; + } catch (e) { + _handleError(e); + return false; + } + } + + /// 断开连接 + Future disconnect() async { + if (!_isConnected) { + return true; // 已经断开 + } + + try { + // 如果有活跃会话,先结束会话 + if (_isSessionActive) { + await endSession(); + } + + // 发送结束连接事件 + await _finishConnection(); + + // 等待连接结束 + final completer = Completer(); + + // 设置超时 + final timeout = Timer(const Duration(seconds: 5), () { + if (!completer.isCompleted) { + completer.complete(true); // 超时也认为成功 + } + }); + + // 监听连接事件 + final subscription = eventStream.listen((event) { + if (event['eventType'] == 'connectionFinished') { + if (!completer.isCompleted) { + completer.complete(true); + timeout.cancel(); + } + } + }); + + // 等待连接结果 + final result = await completer.future; + subscription.cancel(); + + // 关闭WebSocket连接 + await _channel?.sink.close(); + _channel = null; + + _isConnected = false; + _isSessionActive = false; + isConnected.value = false; + isSynthesizing.value = false; + + return result; + } catch (e) { + _handleError(e); + return false; + } + } + + /// 处理接收到的消息 + void _handleMessage(dynamic message) { + try { + if (kDebugMode) { + print('收到WebSocket消息: ${message.runtimeType}'); + } + + if (message is! List) { + _handleError(Exception('收到非二进制消息: $message')); + return; + } + + // 转换为Uint8List以提高处理效率 + final messageData = Uint8List.fromList(message); + + final response = _parseResponse(messageData); + if (response == null) { + _handleError(Exception('解析响应失败')); + return; + } + + // 处理事件 + final event = response['event'] as int?; + if (event != null) { + if (kDebugMode) { + print('处理事件: $event'); + } + + switch (event) { + case eventConnectionStarted: + if (kDebugMode) { + print('连接已建立 (eventConnectionStarted)'); + } + _eventController.add({'eventType': 'connectionStarted'}); + break; + case eventConnectionFailed: + if (kDebugMode) { + print('连接失败 (eventConnectionFailed): ${response['errorDetails']}'); + } + _eventController.add({ + 'eventType': 'connectionFailed', + 'error': response['errorDetails'] ?? '连接失败' + }); + break; + case eventSessionStarted: + if (kDebugMode) { + print('会话已开始 (eventSessionStarted)'); + } + _eventController.add({'eventType': 'sessionStarted'}); + break; + case eventSessionFailed: + if (kDebugMode) { + print('会话失败 (eventSessionFailed): ${response['errorDetails']}'); + } + _eventController.add({ + 'eventType': 'sessionFailed', + 'error': response['errorDetails'] ?? '会话失败' + }); + break; + case eventTtsSentenceStart: + if (kDebugMode) { + print('TTS句子开始 (eventTtsSentenceStart)'); + } + _eventController.add({'eventType': 'ttsSentenceStart'}); + // 准备新的音频流 + _prepareAudioStream(); + break; + case eventTtsSentenceEnd: + if (kDebugMode) { + print('TTS句子结束 (eventTtsSentenceEnd)'); + } + _eventController.add({'eventType': 'ttsSentenceEnd'}); + // 结束音频流 + _finishAudioStream(); + break; + case eventTtsResponse: + // 处理音频数据 + final payload = response['payload'] as Uint8List?; + if (payload != null && payload.isNotEmpty) { + try { + if (kDebugMode) { + print('收到TTS音频数据 (eventTtsResponse): ${payload.length} 字节'); + } + _eventController.add({'eventType': 'ttsResponse'}); + _audioDataController.add(payload); + + // 将音频数据添加到流中 + _addAudioData(payload); + } catch (audioError) { + if (kDebugMode) { + print('处理音频数据时发生错误: $audioError'); + } + // 继续处理,不中断整个流程 + } + } + break; + case eventSessionFinished: + if (kDebugMode) { + print('会话已结束 (eventSessionFinished)'); + } + _eventController.add({'eventType': 'sessionFinished'}); + break; + case eventConnectionFinished: + if (kDebugMode) { + print('连接已关闭 (eventConnectionFinished)'); + } + _eventController.add({'eventType': 'connectionFinished'}); + break; + default: + if (kDebugMode) { + print('未知事件: $event'); + } + _eventController.add({'eventType': 'unknown', 'eventCode': event}); + break; + } + } + } catch (e) { + if (kDebugMode) { + print('处理WebSocket消息时发生异常: $e'); + } + _handleError(e); + } + } + + /// 准备音频流 + void _prepareAudioStream() { + try { + // 清空音频缓冲区 + _audioBuffer.clear(); + if (kDebugMode) { + print('准备新的音频流'); + } + } catch (e) { + if (kDebugMode) { + print('准备音频流时发生错误: $e'); + } + } + } + + /// 添加音频数据到流 + void _addAudioData(Uint8List data) { + try { + // 将音频数据添加到缓冲区 + if (data.isNotEmpty) { + _audioBuffer.add(data); + if (kDebugMode && _audioBuffer.length % 5 == 0) { + print('音频缓冲区现有 ${_audioBuffer.length} 块数据,总大小: ${_audioBuffer.fold(0, (sum, data) => sum + data.length)} 字节'); + } + } + } catch (e) { + if (kDebugMode) { + print('添加音频数据到流时发生错误: $e'); + } + // 不抛出异常,继续处理 + } + } + + /// 结束音频流 + void _finishAudioStream() { + try { + if (_audioBuffer.isNotEmpty) { + if (kDebugMode) { + final totalSize = _audioBuffer.fold(0, (sum, data) => sum + data.length); + print('音频流已结束,准备播放 ${_audioBuffer.length} 块数据,总大小: $totalSize 字节'); + } + + // 合并所有音频数据 + final totalSize = _audioBuffer.fold(0, (sum, data) => sum + data.length); + final mergedData = Uint8List(totalSize); + + int offset = 0; + for (final data in _audioBuffer) { + mergedData.setRange(offset, offset + data.length, data); + offset += data.length; + } + + // 清空缓冲区 + _audioBuffer.clear(); + + // 将合并后的数据添加到播放列表 + _addToPlaylist(mergedData); + } else { + if (kDebugMode) { + print('音频流已结束,但没有收集到音频数据'); + } + } + } catch (e) { + if (kDebugMode) { + print('结束音频流时发生错误: $e'); + } + // 不抛出异常,避免中断整个流程 + } + } + + /// 将音频数据添加到播放列表 + Future _addToPlaylist(Uint8List audioData) async { + try { + if (audioData.isEmpty) return; + + if (kDebugMode) { + print('将音频数据添加到播放列表,大小: ${audioData.length} 字节'); + } + + // 确保播放列表已初始化 + if (!_playlistInitialized) { + await _initializePlaylist(); + } + + // 创建音频源 + final audioSource = BytesAudioSource(audioData); + + // 记录当前播放状态和位置 + final wasPlaying = _isPlaying.value; + final currentPosition = await _audioPlayer.position; + final currentIndex = _audioPlayer.currentIndex; + + // 添加到播放列表 + await _playlist.add(audioSource); + + if (kDebugMode) { + print('音频已添加到播放列表,当前列表长度: ${_playlist.length}'); + } + + // 如果当前没有播放,开始播放 + if (!_isPlaying.value) { + await _startPlayback(); + } else if (wasPlaying && _audioPlayer.processingState == ProcessingState.completed) { + // 如果播放已完成但状态还没更新,手动前进到下一个 + await _advanceToNextItem(); + } + } catch (e) { + if (kDebugMode) { + print('添加音频到播放列表失败: $e'); + } + // 直接报告错误,不尝试恢复 + _eventController.add({ + 'eventType': 'playbackError', + 'error': '添加音频到播放列表失败: $e' + }); + } + } + + /// 重新初始化播放列表 - 已不再使用自动恢复功能,保留方法签名以避免引用错误 + Future _reinitializePlaylist() async { + if (kDebugMode) { + print('重新初始化播放列表方法已被调用,但不再执行自动恢复'); + } + + // 直接报告错误 + _eventController.add({ + 'eventType': 'playbackError', + 'error': '播放列表初始化失败' + }); + + // 标记为未初始化 + _playlistInitialized = false; + throw Exception('播放列表初始化失败,不再尝试自动恢复'); + } + + /// 开始播放收集到的音频数据 + Future _startPlayback() async { + try { + if (!_playlistInitialized || _playlist.length == 0) { + if (kDebugMode) { + print('播放列表为空或未初始化,无法播放'); + } + return; + } + + if (kDebugMode) { + print('开始播放音频列表,列表长度: ${_playlist.length}'); + } + + // 检查当前播放状态 + final processingState = _audioPlayer.processingState; + + // 如果已经播放完成,需要重新设置位置到第一个未播放的项目 + if (processingState == ProcessingState.completed) { + // 找到第一个未播放的项目 + int nextIndex = 0; + if (_audioPlayer.currentIndex != null) { + nextIndex = math.min(_audioPlayer.currentIndex! + 1, _playlist.length - 1); + } + + if (kDebugMode) { + print('播放已完成,跳转到索引 $nextIndex'); + } + + // 跳转到下一个项目 + await _audioPlayer.seek(Duration.zero, index: nextIndex); + } + + // 开始播放 + await _audioPlayer.play(); + + // 更新播放状态 + _isPlaying.value = true; + } catch (e) { + if (kDebugMode) { + print('开始播放时发生错误: $e'); + } + + // 直接报告错误,不尝试恢复 + _eventController.add({ + 'eventType': 'playbackError', + 'error': '开始播放失败: $e' + }); + + // 更新播放状态 + _isPlaying.value = false; + } + } + + /// 处理错误 + void _handleError(dynamic error) { + _eventController.add({ + 'eventType': 'error', + 'error': error.toString() + }); + + if (kDebugMode) { + print('火山语音WebSocket错误: $error'); + } + } + + /// 处理连接关闭 + void _handleDone() { + _isConnected = false; + _isSessionActive = false; + isConnected.value = false; + isSynthesizing.value = false; + + _eventController.add({'eventType': 'disconnected'}); + + if (kDebugMode) { + print('火山语音WebSocket连接已关闭'); + } + } + + /// 解析响应 + Map? _parseResponse(Uint8List data) { + try { + if (data.length < 4) { + return null; + } + + // 解析头部 + final header = { + 'protocol_version': (data[0] >> 4) & 0x0F, + 'header_size': data[0] & 0x0F, + 'message_type': (data[1] >> 4) & 0x0F, + 'message_type_specific_flags': data[1] & 0x0F, + 'serialization_method': (data[2] >> 4) & 0x0F, + 'message_compression': data[2] & 0x0F, + 'reserved': data[3], + }; + + final result = { + 'header': header, + }; + + int offset = 4; + + // 解析事件 + if (header['message_type_specific_flags'] == _msgTypeFlagWithEvent) { + if (data.length < offset + 4) { + return result; + } + + final event = _bytesToInt(data.sublist(offset, offset + 4)); + result['event'] = event; + offset += 4; + } + + // 根据消息类型和事件类型解析剩余数据 + final messageType = header['message_type']; + final event = result['event'] as int?; + + if (messageType == _fullServerResponse || messageType == _audioOnlyResponse) { + if (event != null) { + switch (event) { + case eventConnectionStarted: + if (data.length < offset + 4) break; + + final connectionIdSize = _bytesToInt(data.sublist(offset, offset + 4)); + offset += 4; + + if (data.length < offset + connectionIdSize) break; + + final connectionId = String.fromCharCodes(data.sublist(offset, offset + connectionIdSize)); + result['connectionId'] = connectionId; + offset += connectionIdSize; + break; + + case eventConnectionFailed: + if (data.length < offset + 4) break; + + final metaSize = _bytesToInt(data.sublist(offset, offset + 4)); + offset += 4; + + if (data.length < offset + metaSize) break; + + final metaJson = String.fromCharCodes(data.sublist(offset, offset + metaSize)); + result['meta'] = metaJson; + offset += metaSize; + + try { + final meta = jsonDecode(metaJson); + result['errorDetails'] = meta['error_msg'] ?? 'Unknown error'; + } catch (_) {} + break; + + case eventSessionStarted: + case eventSessionFailed: + case eventSessionFinished: + // 读取会话ID + if (data.length < offset + 4) break; + + final sessionIdSize = _bytesToInt(data.sublist(offset, offset + 4)); + offset += 4; + + if (data.length < offset + sessionIdSize) break; + + final sessionId = String.fromCharCodes(data.sublist(offset, offset + sessionIdSize)); + result['sessionId'] = sessionId; + offset += sessionIdSize; + + // 读取元数据 + if (data.length < offset + 4) break; + + final metaSize = _bytesToInt(data.sublist(offset, offset + 4)); + offset += 4; + + if (data.length < offset + metaSize) break; + + final metaJson = String.fromCharCodes(data.sublist(offset, offset + metaSize)); + result['meta'] = metaJson; + offset += metaSize; + + try { + final meta = jsonDecode(metaJson); + if (meta['error_msg'] != null) { + result['errorDetails'] = meta['error_msg']; + } + } catch (_) {} + break; + + default: + // 读取会话ID + if (data.length < offset + 4) break; + + final sessionIdSize = _bytesToInt(data.sublist(offset, offset + 4)); + offset += 4; + + if (data.length < offset + sessionIdSize) break; + + final sessionId = String.fromCharCodes(data.sublist(offset, offset + sessionIdSize)); + result['sessionId'] = sessionId; + offset += sessionIdSize; + + // 读取序列号 + if (header['message_type_specific_flags'] == _msgTypeFlagNegativeSeq || + header['message_type_specific_flags'] == _msgTypeFlagPositiveSeq) { + if (data.length < offset + 4) break; + + final sequence = _bytesToInt(data.sublist(offset, offset + 4)); + result['sequence'] = sequence; + offset += 4; + } + + // 读取负载 + if (data.length < offset + 4) break; + + final payloadSize = _bytesToInt(data.sublist(offset, offset + 4)); + offset += 4; + + if (data.length < offset + payloadSize) break; + + final payload = data.sublist(offset, offset + payloadSize); + result['payload'] = payload; + + if (messageType == _fullServerResponse) { + try { + final payloadStr = String.fromCharCodes(payload); + result['payloadJson'] = payloadStr; + } catch (_) {} + } + break; + } + } + } else if (messageType == _errorInformation) { + if (data.length < offset + 4) { + return result; + } + + final errorCode = _bytesToInt(data.sublist(offset, offset + 4)); + result['errorCode'] = errorCode; + offset += 4; + + // 读取负载 + if (data.length < offset + 4) { + return result; + } + + final payloadSize = _bytesToInt(data.sublist(offset, offset + 4)); + offset += 4; + + if (data.length < offset + payloadSize) { + return result; + } + + final payload = data.sublist(offset, offset + payloadSize); + try { + final payloadStr = String.fromCharCodes(payload); + result['errorDetails'] = payloadStr; + } catch (_) {} + } + + return result; + } catch (e) { + if (kDebugMode) { + print('解析响应失败: $e'); + } + return null; + } + } + + /// 开始连接 + Future _startConnection() async { + final header = _createHeader( + _fullClientRequest, + _msgTypeFlagWithEvent, + _jsonSerialization, + _compressionNo + ); + + final optional = _createOptional(eventStartConnection, null, -1); + final payload = utf8.encode('{}'); + + await _sendEvent(header, optional, payload); + } + + /// 结束连接 + Future _finishConnection() async { + final header = _createHeader( + _fullClientRequest, + _msgTypeFlagWithEvent, + _jsonSerialization, + _compressionNo + ); + + final optional = _createOptional(eventFinishConnection, null, -1); + final payload = utf8.encode('{}'); + + await _sendEvent(header, optional, payload); + } + + /// 开始TTS会话 + Future _startTtsSession(String speaker) async { + final header = _createHeader( + _fullClientRequest, + _msgTypeFlagWithEvent, + _jsonSerialization, + _compressionNo + ); + + final optional = _createOptional(eventStartSession, _sessionId, -1); + + // 创建会话参数 + final payloadMap = { + 'user': {'uid': '123456'}, + 'event': eventStartSession, + 'namespace': 'BidirectionalTTS', + 'req_params': { + 'speaker': speaker, + 'audio_params': { + 'format': 'mp3', + 'sample_rate': 24000 + } + } + }; + + final payload = utf8.encode(jsonEncode(payloadMap)); + + await _sendEvent(header, optional, payload); + } + + /// 发送TTS消息 + Future _sendTtsMessage(String speaker, String text) async { + final header = _createHeader( + _fullClientRequest, + _msgTypeFlagWithEvent, + _jsonSerialization, + _compressionNo + ); + + final optional = _createOptional(eventTaskRequest, _sessionId, -1); + + // 创建任务参数 + final payloadMap = { + 'user': {'uid': '123456'}, + 'event': eventTaskRequest, + 'namespace': 'BidirectionalTTS', + 'req_params': { + 'text': text, + 'speaker': speaker, + 'audio_params': { + 'format': 'mp3', + 'sample_rate': 24000 + } + } + }; + + final payload = utf8.encode(jsonEncode(payloadMap)); + + await _sendEvent(header, optional, payload); + } + + /// 结束会话 + Future _finishSession() async { + final header = _createHeader( + _fullClientRequest, + _msgTypeFlagWithEvent, + _jsonSerialization, + _compressionNo + ); + + final optional = _createOptional(eventFinishSession, _sessionId, -1); + final payload = utf8.encode('{}'); + + await _sendEvent(header, optional, payload); + } + + /// 创建头部 + Uint8List _createHeader( + int messageType, + int messageTypeSpecificFlags, + int serializationMethod, + int messageCompression + ) { + return Uint8List.fromList([ + // Protocol version | Header size + ((_protocolVersion << 4) | _defaultHeaderSize) & 0xFF, + // Message type | Message type specific flags + ((messageType << 4) | messageTypeSpecificFlags) & 0xFF, + // Serialization method | Compression method + ((serializationMethod << 4) | messageCompression) & 0xFF, + // Reserved + 0 + ]); + } + + /// 创建可选部分 + Uint8List _createOptional(int event, String? sessionId, int sequence) { + final buffer = BytesBuilder(); + + // 添加事件 + if (event != eventNone) { + buffer.add(_intToBytes(event)); + } + + // 添加会话ID + if (sessionId != null) { + final sessionIdBytes = utf8.encode(sessionId); + buffer.add(_intToBytes(sessionIdBytes.length)); + buffer.add(sessionIdBytes); + } + + // 添加序列号 + if (sequence >= 0) { + buffer.add(_intToBytes(sequence)); + } + + return buffer.toBytes(); + } + + /// 发送事件 + Future _sendEvent(Uint8List header, Uint8List optional, List payload) async { + if (_channel == null) { + throw Exception('WebSocket未连接'); + } + + final buffer = BytesBuilder(); + + // 添加头部 + buffer.add(header); + + // 添加可选部分 + if (optional.isNotEmpty) { + buffer.add(optional); + } + + // 添加负载大小 + buffer.add(_intToBytes(payload.length)); + + // 添加负载 + buffer.add(payload); + + // 发送数据 + _channel!.sink.add(buffer.toBytes()); + } + + /// 整数转字节数组 + Uint8List _intToBytes(int value) { + return Uint8List(4) + ..[0] = (value >> 24) & 0xFF + ..[1] = (value >> 16) & 0xFF + ..[2] = (value >> 8) & 0xFF + ..[3] = value & 0xFF; + } + + /// 字节数组转整数 + int _bytesToInt(Uint8List bytes) { + if (bytes.length != 4) { + throw ArgumentError('字节数组长度必须为4'); + } + + return ((bytes[0] & 0xFF) << 24) | + ((bytes[1] & 0xFF) << 16) | + ((bytes[2] & 0xFF) << 8) | + (bytes[3] & 0xFF); + } + + /// 释放资源 + @override + void onClose() async { + await disconnect(); + _audioDataController.close(); + _eventController.close(); + + // 释放资源 + _audioPlayer.dispose(); + + super.onClose(); + } + + /// 合成并播放文本 + Future speak(String text, {String? speaker}) async { + if (text.isEmpty) return false; + + try { + // 使用默认发音人 + final actualSpeaker = speaker ?? defaultSpeaker; + + // 合成文本 + final success = await synthesize(text, actualSpeaker); + + if (!success) { + if (kDebugMode) { + print('合成文本失败'); + } + } + + return success; + } catch (e) { + _handleError(e); + return false; + } + } + + /// 停止播放 + Future stop() async { + try { + // 停止音频播放 + await _audioPlayer.stop(); + + // 清空播放列表 + await _playlist.clear(); + + // 结束当前会话 + await endSession(); + + // 清空音频缓冲区 + _audioBuffer.clear(); + } catch (e) { + _handleError(e); + } + } + + /// 检查是否正在播放 + bool isActuallyPlaying() { + return _isPlaying.value; + } + + /// 设置音量 + Future setVolume(double volume) async { + try { + await _audioPlayer.setVolume(volume.clamp(0.0, 1.0)); + } catch (e) { + _handleError(e); + } + } + + /// 设置播放速度 + Future setSpeed(double speed) async { + try { + await _audioPlayer.setSpeed(speed.clamp(0.5, 2.0)); + } catch (e) { + _handleError(e); + } + } + + /// 切换启用状态 + void toggleEnabled() { + // 这个方法在当前实现中不需要特殊处理 + } + + /// 重新初始化音频播放器 - 已不再使用自动恢复功能,保留方法签名以避免引用错误 + Future _reinitializeAudioPlayer() async { + if (kDebugMode) { + print('重新初始化音频播放器方法已被调用,但不再执行自动恢复'); + } + + // 直接报告错误 + _eventController.add({ + 'eventType': 'playbackError', + 'error': '音频播放器初始化失败' + }); + + throw Exception('音频播放器初始化失败,不再尝试自动恢复'); + } +} + +/// 使用 BytesAudioSource 进行音频播放 +/// 这种方式更简单可靠,适合已经收集完整的音频数据 +class BytesAudioSource extends StreamAudioSource { + final Uint8List _bytes; + final String _contentType; + static const int _bufferSize = 4096; // 4KB buffer size for better Android compatibility + + BytesAudioSource(List bytes, {String contentType = 'audio/mpeg'}) + : _bytes = bytes is Uint8List ? bytes : Uint8List.fromList(bytes), + _contentType = contentType; + + @override + Future request([int? start, int? end]) async { + try { + start = start ?? 0; + end = end ?? _bytes.length; + + // 确保范围有效 + final int validStart = math.min(math.max(0, start), _bytes.length); + final int validEnd = math.min(math.max(validStart, end), _bytes.length); + + // 提取请求的数据范围 + final data = _bytes.sublist(validStart, validEnd); + + if (kDebugMode && data.length > 1024) { + print('BytesAudioSource 请求数据: $validStart-$validEnd (${data.length} 字节)'); + } + + // 使用固定大小的块进行流式传输,提高兼容性 + final chunks = >[]; + var offset = 0; + while (offset < data.length) { + final chunkSize = math.min(_bufferSize, data.length - offset); + chunks.add(data.sublist(offset, offset + chunkSize)); + offset += chunkSize; + } + + return StreamAudioResponse( + sourceLength: _bytes.length, + contentLength: data.length, + offset: validStart, + stream: Stream.fromIterable(chunks), + contentType: _contentType, + ); + } catch (e) { + if (kDebugMode) { + print('BytesAudioSource 请求处理错误: $e'); + } + // 返回一个空响应而不是抛出异常,提高容错性 + return StreamAudioResponse( + sourceLength: _bytes.length, + contentLength: 0, + offset: 0, + stream: Stream.value(Uint8List(0)), + contentType: _contentType, + ); + } + } + + @override + Future get length => Future.value(_bytes.length); +} \ No newline at end of file diff --git a/lib/data/services/volcano_tts_service.dart b/lib/data/services/volcano_tts_service.dart index 425f42bf1..aae6b1586 100644 --- a/lib/data/services/volcano_tts_service.dart +++ b/lib/data/services/volcano_tts_service.dart @@ -1,10 +1,7 @@ import 'dart:async'; -import 'dart:typed_data'; import 'dart:io'; -import 'dart:math' as math; +import 'dart:collection'; import 'package:get/get.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:audio_session/audio_session.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter/services.dart'; @@ -21,124 +18,9 @@ class VolcanoTtsException implements Exception { : message; } -/// 自定义音频源,用于从字节数组读取音频数据 -class BytesAudioSource extends StreamAudioSource { - final Uint8List _bytes; - static const int _bufferSize = 4096; // 4KB buffer size for better Android compatibility - - BytesAudioSource(this._bytes); - - // 添加 PCM 头信息,转换为 WAV 格式 - Uint8List _addWavHeader(Uint8List pcmData) { - // PCM 参数 - const int sampleRate = 16000; // 采样率 - const int numChannels = 1; // 单声道 - const int bitsPerSample = 16; // 16位采样 - - // 计算数据大小 - final int dataSize = pcmData.length; - final int fileSize = 36 + dataSize; - - // 创建 WAV 头 - final ByteData header = ByteData(44); - - // RIFF 头 - header.setUint8(0, 'R'.codeUnitAt(0)); - header.setUint8(1, 'I'.codeUnitAt(0)); - header.setUint8(2, 'F'.codeUnitAt(0)); - header.setUint8(3, 'F'.codeUnitAt(0)); - - // 文件大小 - header.setUint32(4, fileSize, Endian.little); - - // WAVE 标识 - header.setUint8(8, 'W'.codeUnitAt(0)); - header.setUint8(9, 'A'.codeUnitAt(0)); - header.setUint8(10, 'V'.codeUnitAt(0)); - header.setUint8(11, 'E'.codeUnitAt(0)); - - // fmt 子块 - header.setUint8(12, 'f'.codeUnitAt(0)); - header.setUint8(13, 'm'.codeUnitAt(0)); - header.setUint8(14, 't'.codeUnitAt(0)); - header.setUint8(15, ' '.codeUnitAt(0)); - - // 子块大小 - header.setUint32(16, 16, Endian.little); - - // 音频格式 (PCM = 1) - header.setUint16(20, 1, Endian.little); - - // 声道数 - header.setUint16(22, numChannels, Endian.little); - - // 采样率 - header.setUint32(24, sampleRate, Endian.little); - - // 字节率 = 采样率 * 声道数 * 采样位数 / 8 - header.setUint32(28, sampleRate * numChannels * bitsPerSample ~/ 8, Endian.little); - - // 块对齐 = 声道数 * 采样位数 / 8 - header.setUint16(32, numChannels * bitsPerSample ~/ 8, Endian.little); - - // 采样位数 - header.setUint16(34, bitsPerSample, Endian.little); - - // data 子块 - header.setUint8(36, 'd'.codeUnitAt(0)); - header.setUint8(37, 'a'.codeUnitAt(0)); - header.setUint8(38, 't'.codeUnitAt(0)); - header.setUint8(39, 'a'.codeUnitAt(0)); - - // 数据大小 - header.setUint32(40, dataSize, Endian.little); - - // 创建完整的 WAV 数据 - final Uint8List wavData = Uint8List(44 + dataSize); - wavData.setRange(0, 44, header.buffer.asUint8List()); - wavData.setRange(44, 44 + dataSize, pcmData); - - return wavData; - } - - @override - Future request([int? start, int? end]) async { - // 添加 WAV 头信息 - final Uint8List wavData = _addWavHeader(_bytes); - - start = start ?? 0; - end = end ?? wavData.length; - - try { - final subData = wavData.sublist(start, end); - - // 使用固定大小的块进行流式传输 - final chunks = >[]; - var offset = 0; - while (offset < subData.length) { - final chunkSize = math.min(_bufferSize, subData.length - offset); - chunks.add(subData.sublist(offset, offset + chunkSize)); - offset += chunkSize; - } - - return StreamAudioResponse( - sourceLength: wavData.length, - contentLength: subData.length, - offset: start, - stream: Stream.fromIterable(chunks), - contentType: 'audio/wav', - ); - } catch (e) { - print('BytesAudioSource request error: $e'); - rethrow; - } - } - - @override - Future get length => Future.value(_bytes.length + 44); // PCM 数据长度 + WAV 头长度 -} - -/// 火山语音合成服务 (SDK版本) +/// 火山语音合成服务 (极简版) +/// +/// 直接使用原生端 VolcanoTtsHelper 的功能,不处理状态回调 class VolcanoTtsService extends GetxService { // 平台通道 static const MethodChannel _channel = MethodChannel('com.example.deep_voice/volcano_tts'); @@ -149,65 +31,31 @@ class VolcanoTtsService extends GetxService { final String _cluster; final String _voiceType; - // 音频播放器 - late AudioPlayer _audioPlayer; - final ConcatenatingAudioSource _playlist = ConcatenatingAudioSource(children: []); - final isEnabled = true.obs; // 默认启用 - - // 播放状态变化通知流 - final _playingStateController = StreamController.broadcast(); - Stream get playingStateStream => _playingStateController.stream; - - StreamSubscription? _playbackEventSubscription; - StreamSubscription? _playerStateSubscription; - AudioSession? _audioSession; - - // 句子管理 - String _pendingText = ''; - final List _sentenceQueue = []; - - // 优化的句子分割正则表达式,包含更多中英文标点 - static final _sentenceBreaks = RegExp(r'[。!?.!?;;::,,、]'); - - // 优化的句子分割参数 - static const int _maxSegmentLength = 150; // 最大分段长度 - static const int _optimalSegmentLength = 80; // 最佳分段长度 - static const int _minSegmentLength = 5; // 最小分段长度 - - bool _isFetching = false; - int _consecutiveErrorCount = 0; - static const int _maxConsecutiveErrors = 3; - // 初始化状态 bool _isInitialized = false; bool _isDisposed = false; - // 性能优化参数 - final int _preloadCount = 2; // 预加载片段数量 - final int _maxRetryAttempts = 1; // 不进行重试 + // 服务启用状态 + final isEnabled = true.obs; // 默认启用 // 音频质量参数 final double _defaultVolume = 1.0; final double _defaultSpeed = 1.0; VolcanoTtsService() : - // 使用新的环境变量命名 _appId = dotenv.env['VOLCANO_TTS_APP_ID'] ?? '', _token = dotenv.env['VOLCANO_TTS_APP_TOKEN'] ?? '', _cluster = dotenv.env['VOLCANO_TTS_CLUSTER'] ?? '', _voiceType = dotenv.env['VOLCANO_TTS_VOICE_TYPE'] ?? '' { if (_appId.isEmpty || _token.isEmpty || _cluster.isEmpty) { - throw VolcanoTtsException('火山语音配置信息不完整,请检查环境变量 VOLCANO_TTS_APP_ID, VOLCANO_TTS_APP_KEY 和 VOLCANO_TTS_CLUSTER'); + throw VolcanoTtsException('火山语音配置信息不完整,请检查环境变量 VOLCANO_TTS_APP_ID, VOLCANO_TTS_APP_TOKEN 和 VOLCANO_TTS_CLUSTER'); } } - @override Future onInit() async { super.onInit(); try { - await _initAudioSession(); - await _initAudioPlayer(); await _initializeTtsEngine(); } catch (e, stackTrace) { print('初始化TTS服务失败: $e'); @@ -218,7 +66,7 @@ class VolcanoTtsService extends GetxService { /// 初始化TTS引擎 Future _initializeTtsEngine() async { try { - print('开始初始化火山语音TTS引擎,APP_ID: ${_appId.substring(0, math.min(3, _appId.length))}***,APP_KEY: ${_token.length > 10 ? "${_token.substring(0, 5)}..." : _token}'); + print('开始初始化火山语音TTS引擎'); final result = await _channel.invokeMethod('initialize', { 'appId': _appId, @@ -232,6 +80,10 @@ class VolcanoTtsService extends GetxService { if (!_isInitialized) { throw VolcanoTtsException('TTS引擎初始化失败'); } + + // 设置默认音量和速度 + await setVolume(_defaultVolume); + await setSpeed(_defaultSpeed); } catch (e) { print('初始化火山语音TTS引擎失败: $e'); _isInitialized = false; @@ -239,500 +91,61 @@ class VolcanoTtsService extends GetxService { } } - Future _initAudioSession() async { - _audioSession = await AudioSession.instance; - - // 针对不同平台优化音频会话配置 - if (Platform.isAndroid) { - await _audioSession?.configure(AudioSessionConfiguration( - avAudioSessionCategory: AVAudioSessionCategory.playback, - androidAudioAttributes: const AndroidAudioAttributes( - contentType: AndroidAudioContentType.speech, - usage: AndroidAudioUsage.media, - flags: AndroidAudioFlags.audibilityEnforced, - ), - androidAudioFocusGainType: AndroidAudioFocusGainType.gain, - androidWillPauseWhenDucked: true, - )); - } else if (Platform.isIOS) { - await _audioSession?.configure(AudioSessionConfiguration( - avAudioSessionCategory: AVAudioSessionCategory.playback, - avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.duckOthers, - avAudioSessionMode: AVAudioSessionMode.spokenAudio, - avAudioSessionRouteSharingPolicy: AVAudioSessionRouteSharingPolicy.defaultPolicy, - avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.notifyOthersOnDeactivation, - )); - } - - // 确保音频会话激活并设置正确的音频模式 - if (Platform.isAndroid) { - await _audioSession?.setActive(true, avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.notifyOthersOnDeactivation); - } else { - await _audioSession?.setActive(true); - } - } - - Future _initAudioPlayer() async { - if (_isDisposed) return; - - _audioPlayer = AudioPlayer(); - - // 配置Android特定的播放参数 - if (Platform.isAndroid) { - await _audioPlayer.setAndroidAudioAttributes(const AndroidAudioAttributes( - contentType: AndroidAudioContentType.speech, - usage: AndroidAudioUsage.media, - flags: AndroidAudioFlags.audibilityEnforced, - )); - } - - // 基本播放器配置 - await _audioPlayer.setVolume(_defaultVolume); - await _audioPlayer.setLoopMode(LoopMode.off); - await _audioPlayer.setSpeed(_defaultSpeed); - - // 设置音频缓冲配置,启用预加载以减少延迟 - await _audioPlayer.setAudioSource( - _playlist, - initialPosition: Duration.zero, - preload: true, - ); - - _setupEventListeners(); - } - /// 合成并播放文本 /// /// [text] 要合成的文本 /// [voiceType] 可选的语音类型,如果提供,将覆盖默认语音类型 - Future speak(String text, {String? voiceType}) async { - if (!isEnabled.value || text.trim().isEmpty) return; - + /// + /// 返回一个Future,表示是否成功开始播放 + Future speak(String text, {String? voiceType}) async { + if (text.trim().isEmpty) return false; + if (!isEnabled.value) return false; + if (!_isInitialized) { + print('TTS引擎尚未初始化'); + return false; + } + try { + print('开始播放文本: "${text.length > 20 ? text.substring(0, 20) + '...' : text}"'); + // 如果提供了语音类型,记录日志 if (voiceType != null && voiceType != _voiceType) { print('使用临时语音类型: $voiceType (默认: $_voiceType)'); } - _pendingText += text; - _extractSentences(); - await _startPreloadIfNeeded(voiceType: voiceType); - } catch (e, stack) { - print('语音合成失败: $e'); - print('Stack trace: $stack'); - - _consecutiveErrorCount++; - if (_consecutiveErrorCount >= _maxConsecutiveErrors) { - print('连续错误次数过多,尝试重新初始化播放器'); - await _reinitializePlayer(); - _consecutiveErrorCount = 0; - } - } - } - - /// 优化的文本分段算法 - void _extractSentences() { - if (_pendingText.isEmpty) return; - - // 查找所有句子分隔符 - final matches = _sentenceBreaks.allMatches(_pendingText).toList(); - - if (matches.isEmpty) { - // 如果没有找到句子分隔符,按长度分割 - _splitByLength(_pendingText); - _pendingText = ''; - return; - } - - int lastPos = 0; - String currentBatch = ''; - - for (final match in matches) { - final end = match.end; - final sentence = _pendingText.substring(lastPos, end).trim(); - - if (sentence.isEmpty) { - lastPos = end; - continue; - } - - // 如果当前批次为空,直接添加句子 - if (currentBatch.isEmpty) { - currentBatch = sentence; - } - // 如果当前批次加上新句子不超过最佳长度,则合并 - else if ((currentBatch + sentence).length <= _optimalSegmentLength) { - currentBatch += sentence; - } - // 如果超过最佳长度,将当前批次加入队列,开始新批次 - else { - if (currentBatch.isNotEmpty) { - _sentenceQueue.add(currentBatch); - } - currentBatch = sentence; - } - - lastPos = end; - } - - // 处理最后一个批次 - if (currentBatch.isNotEmpty) { - _sentenceQueue.add(currentBatch); - } - - // 处理剩余的文本 - if (lastPos < _pendingText.length) { - final remaining = _pendingText.substring(lastPos).trim(); - if (remaining.isNotEmpty) { - if (remaining.length <= _optimalSegmentLength) { - // 尝试与最后一个批次合并 - if (_sentenceQueue.isNotEmpty && - (_sentenceQueue.last + remaining).length <= _optimalSegmentLength) { - _sentenceQueue[_sentenceQueue.length - 1] += remaining; - } else { - _sentenceQueue.add(remaining); - } - } else { - _splitByLength(remaining); - } - } - } - - _pendingText = ''; - } - - /// 按长度分割文本,优先在空格或标点处分割 - void _splitByLength(String text) { - if (text.isEmpty) return; - - int start = 0; - while (start < text.length) { - // 计算理想的结束位置 - int end = math.min(start + _optimalSegmentLength, text.length); - - // 如果没有到达文本末尾,尝试在标点或空格处分割 - if (end < text.length) { - // 在理想长度附近查找标点 - int searchStart = math.max(start + _minSegmentLength, end - 20); - int searchEnd = math.min(text.length, end + 20); - - String searchText = text.substring(searchStart, searchEnd); - final breakMatch = _sentenceBreaks.firstMatch(searchText); - - if (breakMatch != null) { - end = searchStart + breakMatch.end; - } else { - // 如果没有找到标点,尝试在空格处分割 - final spacePos = text.lastIndexOf(' ', end); - if (spacePos > start && spacePos > end - 20) { - end = spacePos + 1; // 包含空格 - } - } - } - - final segment = text.substring(start, end).trim(); - if (segment.isNotEmpty) { - // 尝试与前一个批次合并 - if (_sentenceQueue.isNotEmpty && - (_sentenceQueue.last + segment).length <= _optimalSegmentLength) { - _sentenceQueue[_sentenceQueue.length - 1] += segment; - } else { - _sentenceQueue.add(segment); - } - } - start = end; - } - } - - Future _startPreloadIfNeeded({String? voiceType}) async { - if (_isFetching || _sentenceQueue.isEmpty) return; - _isFetching = true; - await _fetchNextSegment(voiceType: voiceType); - } - - Future _fetchNextSegment({String? voiceType}) async { - if (_sentenceQueue.isEmpty) { - _isFetching = false; - return; - } - - try { - // 预加载多个片段以实现流畅播放 - final segments = []; - while (_sentenceQueue.isNotEmpty && segments.length < _preloadCount) { - segments.add(_sentenceQueue.removeAt(0)); - } - - for (final segment in segments) { - print('获取音频片段: $segment'); - - try { - // 使用提供的语音类型或默认语音类型,只尝试一次 - final audioData = await _synthesize(segment, voiceType: voiceType); - - if (audioData != null) { - print('成功获取音频数据: ${audioData.length} bytes'); - try { - final audioSource = BytesAudioSource(audioData); - await _playlist.add(audioSource); - print('音频片段已添加到播放列表'); - - // 如果是第一个片段且播放器未在播放,开始播放 - if (!_audioPlayer.playing && _playlist.length == 1) { - print('开始播放'); - await _audioPlayer.play(); - } - } catch (e) { - print('添加音频到播放列表失败: $e'); - } - } else { - print('获取音频失败: 合成返回空数据'); - } - } catch (e) { - print('获取音频失败: $e'); - // 不重试,继续处理下一个片段 - } - } - } catch (e) { - print('预加载音频片段失败: $e'); - } finally { - _isFetching = false; - } - } - - /// 合成文本为音频数据 - Future _synthesize(String text, {String? voiceType}) async { - if (text.isEmpty) { - return null; - } - - // 使用指定的语音类型,不使用任何回退 - final effectiveVoiceType = voiceType ?? _voiceType; - - try { - // 调用原生方法合成语音 - final result = await _channel.invokeMethod('synthesizeSync', { + // 直接调用原生方法合成并播放语音 + final success = await _channel.invokeMethod('speakText', { 'text': text, - 'voiceType': effectiveVoiceType, + 'voiceType': voiceType ?? _voiceType, }); - if (result != null && result.isNotEmpty) { - return result; - } - - // 如果没有结果,返回null - return null; + return success ?? false; } catch (e) { - // 不处理授权错误,直接抛出所有错误 - print('调用原生合成方法失败: $e'); - throw VolcanoTtsException('调用原生合成方法失败', e); + print('语音合成失败: $e'); + return false; } } - /// 停止播放并清空队列 - Future stop() async { + /// 停止播放 + Future stop() async { + if (!_isInitialized) { + print('TTS引擎尚未初始化'); + return false; + } + try { print('停止播放'); - await _audioPlayer.stop(); - await _playlist.clear(); - _pendingText = ''; - _sentenceQueue.clear(); - _isFetching = false; - // 通知播放状态变化 - _notifyPlayingStateChanged(false); + // 调用原生方法停止播放 + final success = await _channel.invokeMethod('stopSpeaking'); - // 确保清空所有待处理的音频数据 - try { - // 重置播放器状态 - await _audioPlayer.pause(); - await _audioPlayer.seek(Duration.zero); - - print('已清空所有待播放的文字和音频'); - } catch (e) { - print('清空音频缓存时出错: $e'); - } + print('已停止播放'); + return success ?? false; } catch (e) { print('停止播放失败: $e'); - } - } - - /// 检查是否实际正在播放 - bool isActuallyPlaying() { - try { - // 检查是否有活跃的播放任务 - final hasActiveTask = _sentenceQueue.isNotEmpty || _isFetching; - - // 检查播放器状态 - final isPlayerPlaying = _audioPlayer.playing; - - // 检查是否有待处理的文本 - final hasPendingText = _pendingText.isNotEmpty; - - // 检查播放列表是否为空 - final hasAudioSource = _playlist.length > 0; - - // 检查当前播放位置 - final currentPosition = _audioPlayer.position; - final currentDuration = _audioPlayer.duration ?? Duration.zero; - - // 检查是否已经播放到末尾但播放器状态未更新 - final isAtEnd = currentDuration.inMilliseconds > 0 && - currentPosition.inMilliseconds >= currentDuration.inMilliseconds - 100; - - // 如果播放器显示正在播放,但已经到达音频末尾,则认为实际上没有播放 - if (isPlayerPlaying && isAtEnd) { - // 如果检测到这种情况,尝试自动修复播放器状态 - _fixPlayerState(); - return false; - } - - // 如果有活跃任务、播放器正在播放且未到末尾、有待处理文本,则认为正在播放 - return hasActiveTask || (isPlayerPlaying && !isAtEnd) || hasPendingText; - } catch (e) { - print('检查TTS实际播放状态出错: $e'); return false; } } - - /// 修复播放器状态 - void _fixPlayerState() { - try { - // 如果播放器显示正在播放,但实际上已经到达音频末尾,尝试修复状态 - Future.microtask(() async { - try { - // 如果没有更多内容要播放,停止播放器 - if (_sentenceQueue.isEmpty && _pendingText.isEmpty && !_isFetching) { - await _audioPlayer.stop(); - - // 如果播放列表中只有已播放完的项目,清空播放列表 - if (_playlist.length > 0) { - await _playlist.clear(); - } - - // 通知播放状态变化 - _notifyPlayingStateChanged(false); - } else { - // 如果还有内容要播放,尝试播放下一个 - _tryPlayNext(); - } - } catch (e) { - print('修复播放器状态失败: $e'); - } - }); - } catch (e) { - print('尝试修复播放器状态时出错: $e'); - } - } - - /// 通知播放状态变化 - void _notifyPlayingStateChanged(bool isPlaying) { - try { - _playingStateController.add(isPlaying); - } catch (e) { - print('通知播放状态变化失败: $e'); - } - } - - /// 重新初始化播放器 - Future _reinitializePlayer() async { - try { - await stop(); - await _audioPlayer.dispose(); - - // 重新初始化音频会话 - await _initAudioSession(); - - _audioPlayer = AudioPlayer(); - await _audioPlayer.setVolume(_defaultVolume); - await _audioPlayer.setLoopMode(LoopMode.off); - await _audioPlayer.setSpeed(_defaultSpeed); - - // 重新配置Android特定的播放参数 - if (Platform.isAndroid) { - await _audioPlayer.setAndroidAudioAttributes(const AndroidAudioAttributes( - contentType: AndroidAudioContentType.speech, - usage: AndroidAudioUsage.media, - flags: AndroidAudioFlags.audibilityEnforced, - )); - } - - await _audioPlayer.setAudioSource( - _playlist, - initialPosition: Duration.zero, - preload: true, - ); - - _setupEventListeners(); - print('播放器重新初始化成功'); - } catch (e) { - print('播放器重新初始化失败: $e'); - } - } - - /// 设置事件监听器 - void _setupEventListeners() { - _playbackEventSubscription?.cancel(); - _playerStateSubscription?.cancel(); - - _playbackEventSubscription = _audioPlayer.playbackEventStream.listen( - (event) { - if (_isDisposed) return; - if (event.processingState == ProcessingState.completed) { - _tryPlayNext(); - } - }, - onError: (error) { - if (_isDisposed) return; - print('播放事件流错误: $error'); - _reinitializePlayer(); - }, - ); - - _playerStateSubscription = _audioPlayer.playerStateStream.listen( - (state) { - if (_isDisposed) return; - - // 通知播放状态变化 - final isPlaying = state.playing && _playlist.length > 0; - _notifyPlayingStateChanged(isPlaying); - - if (state.processingState == ProcessingState.completed) { - _tryPlayNext(); - } - }, - onError: (error) { - if (_isDisposed) return; - print('播放状态流错误: $error'); - _reinitializePlayer(); - }, - ); - } - - /// 尝试播放下一个音频片段 - Future _tryPlayNext() async { - if (_isDisposed || _playlist.length <= 0) return; - - try { - if (!_audioPlayer.playing) { - // 确保音频会话处于激活状态 - await _audioSession?.setActive(true); - - // 减少延迟时间 - await Future.delayed(const Duration(milliseconds: 20)); - - if (!_isDisposed && !_audioPlayer.playing) { - await _audioPlayer.seek(Duration.zero, index: 0); - await _audioPlayer.play(); - } - } - } catch (e) { - if (e is! StateError) { - print('播放错误: $e'); - await _reinitializePlayer(); - } - } - } /// 切换TTS启用状态 void toggleEnabled() { @@ -743,40 +156,57 @@ class VolcanoTtsService extends GetxService { } /// 设置播放速度 - Future setSpeed(double speed) async { + Future setSpeed(double speed) async { + if (!_isInitialized) { + print('TTS引擎尚未初始化'); + return false; + } + if (speed < 0.5 || speed > 2.0) { throw VolcanoTtsException('播放速度必须在0.5到2.0之间'); } try { - await _audioPlayer.setSpeed(speed); + // 设置原生播放速度 + final success = await _channel.invokeMethod('setSpeed', { + 'speed': speed, + }); + + return success ?? false; } catch (e) { print('设置播放速度失败: $e'); + return false; } } /// 设置音量 - Future setVolume(double volume) async { + Future setVolume(double volume) async { + if (!_isInitialized) { + print('TTS引擎尚未初始化'); + return false; + } + if (volume < 0.0 || volume > 1.0) { throw VolcanoTtsException('音量必须在0.0到1.0之间'); } try { - await _audioPlayer.setVolume(volume); + // 设置原生音量 + final success = await _channel.invokeMethod('setVolume', { + 'volume': volume, + }); + + return success ?? false; } catch (e) { print('设置音量失败: $e'); + return false; } } /// 清理资源 Future _cleanupResources() async { _isDisposed = true; - await _playbackEventSubscription?.cancel(); - await _playerStateSubscription?.cancel(); - await _audioSession?.setActive(false); await stop(); - await _audioPlayer.dispose(); - await _playingStateController.close(); // 释放原生资源 try { @@ -798,4 +228,14 @@ class VolcanoTtsService extends GetxService { return _voiceType; } + /// 检查是否正在播放(简化版,直接返回true) + bool isActuallyPlaying() { + return true; + } + + /// 检查是否正在合成(简化版,直接返回true) + bool isActuallySynthesizing() { + return true; + } + } \ No newline at end of file diff --git a/lib/modules/chat/bindings/chat_binding.dart b/lib/modules/chat/bindings/chat_binding.dart index ec1f32443..87b7c9d34 100644 --- a/lib/modules/chat/bindings/chat_binding.dart +++ b/lib/modules/chat/bindings/chat_binding.dart @@ -1,13 +1,13 @@ import 'package:get/get.dart'; import '../controllers/chat_controller.dart'; -import '../../../data/services/volcano_tts_service.dart'; +import '../../../data/services/volcano_tts_api_service.dart'; class ChatBinding implements Bindings { @override void dependencies() { // Ensure TTS service is registered first - if (!Get.isRegistered()) { - Get.put(VolcanoTtsService()); + if (!Get.isRegistered()) { + Get.put(VolcanoTtsApiService()); } // Create new controller instance diff --git a/lib/modules/chat/controllers/chat_controller.dart b/lib/modules/chat/controllers/chat_controller.dart index 1dc491185..26b1390ad 100644 --- a/lib/modules/chat/controllers/chat_controller.dart +++ b/lib/modules/chat/controllers/chat_controller.dart @@ -4,9 +4,10 @@ import 'package:get_storage/get_storage.dart'; import 'dart:convert'; import 'package:flutter/rendering.dart'; import '../../../data/services/volcano_ai_service.dart'; -import '../../../data/services/volcano_tts_service.dart'; +import '../../../data/services/volcano_tts_api_service.dart'; import '../models/message_model.dart'; import '../controllers/voice_input_controller.dart'; +import 'dart:async'; class ChatController extends GetxController { String agentId = ''; @@ -18,7 +19,7 @@ class ChatController extends GetxController { bool playVoiceOnEnter = false; final VolcanoAIService _aiService = VolcanoAIService(); - late final VolcanoTtsService _ttsService; + late final VolcanoTtsApiService _ttsService; static const String _storagePrefix = 'chat_history_'; final messages = [].obs; @@ -27,6 +28,9 @@ class ChatController extends GetxController { late final ScrollController scrollController; final _storage = GetStorage(); + // TTS启用状态 + final isTtsEnabled = true.obs; + // 用于存储当前正在流式生成的消息 final RxString currentStreamMessage = ''.obs; Message? _currentAssistantMessage; @@ -34,6 +38,10 @@ class ChatController extends GetxController { static const int _minTtsLength = 20; bool _isDisposed = false; + // TTS事件订阅 + StreamSubscription? _ttsEventSubscription; + bool _isTtsSpeaking = false; + // 语音输入相关状态 final isRecording = false.obs; final recordingText = ''.obs; @@ -65,13 +73,13 @@ class ChatController extends GetxController { // 辅助方法:管理TTS状态 void _manageTtsState(bool enable) { - if (!enable && _ttsService.isEnabled.value) { + if (!enable && isTtsEnabled.value) { // 需要禁用TTS - _ttsService.stop(); // 先停止当前播放 - _ttsService.isEnabled.value = false; - } else if (enable && !_ttsService.isEnabled.value) { + _ttsService.endSession(); // 先停止当前播放 + isTtsEnabled.value = false; + } else if (enable && !isTtsEnabled.value) { // 需要启用TTS - _ttsService.isEnabled.value = true; + isTtsEnabled.value = true; } } @@ -84,7 +92,10 @@ class ChatController extends GetxController { Get.delete(); } - // 不再需要禁用TTS,因为已启用回声消除 + // 如果TTS正在播放,先停止它 + if (_isTtsSpeaking) { + _ttsService.endSession(); + } isVoiceInputVisible.value = true; isVoiceConnecting.value = true; @@ -298,6 +309,13 @@ class ChatController extends GetxController { ); messages.add(_currentAssistantMessage!); _pendingTtsText = ''; + + // 确保在开始新的合成前停止当前播放 + if (isTtsEnabled.value) { + await _ttsService.endSession(); + // 添加短暂延迟确保停止完成 + await Future.delayed(const Duration(milliseconds: 200)); + } await for (final chunk in _aiService.sendMessageStream( messages: messages @@ -315,7 +333,7 @@ class ChatController extends GetxController { // 累积文本并合成 _pendingTtsText += chunk; - if (!_isDisposed && _ttsService.isEnabled.value) { + if (!_isDisposed && isTtsEnabled.value) { // 检查是否达到最小长度 if (_pendingTtsText.length >= _minTtsLength) { // 找到最后一个句子结束的位置 @@ -324,7 +342,10 @@ class ChatController extends GetxController { // 播放到最后一个句子结束的位置 String textToSpeak = _pendingTtsText.substring(0, lastSentenceEnd + 1); - _ttsService.speak(textToSpeak); + + // 使用新方法替代直接调用speak + _speakText(textToSpeak); + // 保留剩余的文本 _pendingTtsText = _pendingTtsText.substring(lastSentenceEnd + 1); } @@ -334,9 +355,10 @@ class ChatController extends GetxController { // 处理剩余的文本 if (!_isDisposed && - _ttsService.isEnabled.value && + isTtsEnabled.value && _pendingTtsText.isNotEmpty) { - _ttsService.speak(_pendingTtsText); + // 使用新方法替代直接调用speak + _speakText(_pendingTtsText); } _pendingTtsText = ''; @@ -363,6 +385,32 @@ class ChatController extends GetxController { } } + // 播放文本并使用WebSocket流式合成 + Future _speakText(String text) async { + if (text.isEmpty || !isTtsEnabled.value) return; + + try { + // 如果当前有正在进行的TTS会话,先结束它 + if (_isTtsSpeaking) { + await _ttsService.endSession(); + } + + // 确保连接已建立 + if (!_ttsService.isConnected.value) { + final connected = await _ttsService.connect(); + if (!connected) { + print('无法连接到TTS服务'); + return; + } + } + + // 开始合成 + await _ttsService.synthesize(text, 'zh_female_1'); // 使用默认说话人 + } catch (e) { + print('TTS播放出错: $e'); + } + } + // 确保滚动到底部的方法,使用多种策略确保成功 void _ensureScrollToBottom() { // 立即尝试滚动 @@ -465,8 +513,37 @@ class ChatController extends GetxController { } void _initTtsService() { - _ttsService = Get.find(); - _ttsService.isEnabled.value = true; + _ttsService = Get.find(); + isTtsEnabled.value = true; + + // 设置TTS事件监听 + _setupTtsEventListener(); + } + + void _setupTtsEventListener() { + _ttsEventSubscription = _ttsService.eventStream.listen(_handleTtsEvent); + } + + void _handleTtsEvent(Map event) { + if (_isDisposed) return; + + final String eventType = event['type'] as String? ?? 'unknown'; + + switch (eventType) { + case 'ttsSentenceStart': + _isTtsSpeaking = true; + break; + case 'ttsSentenceEnd': + case 'sessionFinished': + case 'connectionFinished': + _isTtsSpeaking = false; + break; + case 'connectionFailed': + case 'sessionFailed': + _isTtsSpeaking = false; + print('TTS错误: ${event['message']}'); + break; + } } Future _initializeController() async { @@ -511,8 +588,9 @@ class ChatController extends GetxController { } catch (e) { print('显示欢迎消息失败: $e'); // 如果生成问候语失败,确保至少显示静态欢迎消息 - if (_ttsService.isEnabled.value) { - _ttsService.speak(welcomeMessage); + if (isTtsEnabled.value) { + // 使用新方法替代直接调用speak + _speakText(welcomeMessage); } // 即使问候语生成失败,如果playVoiceOnEnter为true,仍然启动语音输入 @@ -561,10 +639,9 @@ class ChatController extends GetxController { } // 完整问候语生成完毕后,一次性播放 - if (!_isDisposed && - _ttsService.isEnabled.value && - fullGreeting.isNotEmpty) { - await _ttsService.speak(fullGreeting); + if (!_isDisposed && isTtsEnabled.value && fullGreeting.isNotEmpty) { + // 使用新方法替代直接调用speak + await _speakText(fullGreeting); } } catch (e) { print('生成问候语失败: $e'); @@ -588,7 +665,11 @@ class ChatController extends GetxController { stopVoiceInput(); // 先停止所有正在进行的操作 - _ttsService.stop(); + _ttsService.endSession(); // 使用endSession替代stop + + // 取消TTS事件订阅 + _ttsEventSubscription?.cancel(); + currentStreamMessage.value = ''; _currentAssistantMessage = null; _pendingTtsText = ''; @@ -737,7 +818,7 @@ class ChatController extends GetxController { // 累积文本并合成 _pendingTtsText += chunk; - if (!_isDisposed && _ttsService.isEnabled.value) { + if (!_isDisposed && isTtsEnabled.value) { // 检查是否达到最小长度 if (_pendingTtsText.length >= _minTtsLength) { // 找到最后一个句子结束的位置 @@ -746,7 +827,8 @@ class ChatController extends GetxController { // 播放到最后一个句子结束的位置 String textToSpeak = _pendingTtsText.substring(0, lastSentenceEnd + 1); - _ttsService.speak(textToSpeak); + // 使用新方法替代直接调用speak + _speakText(textToSpeak); // 保留剩余的文本 _pendingTtsText = _pendingTtsText.substring(lastSentenceEnd + 1); } @@ -755,10 +837,9 @@ class ChatController extends GetxController { } // 处理剩余的文本 - if (!_isDisposed && - _ttsService.isEnabled.value && - _pendingTtsText.isNotEmpty) { - _ttsService.speak(_pendingTtsText); + if (!_isDisposed && isTtsEnabled.value && _pendingTtsText.isNotEmpty) { + // 使用新方法替代直接调用speak + _speakText(_pendingTtsText); } _pendingTtsText = ''; @@ -816,10 +897,10 @@ class ChatController extends GetxController { } void toggleTTS() { - // 使用 VolcanoTtsService 的 toggleEnabled 方法 - _ttsService.toggleEnabled(); - if (!_ttsService.isEnabled.value) { - _ttsService.stop(); + // 切换TTS启用状态 + isTtsEnabled.toggle(); + if (!isTtsEnabled.value) { + _ttsService.endSession(); // 使用endSession替代stop } } @@ -856,8 +937,9 @@ class ChatController extends GetxController { } // 完整问候语生成完毕后,一次性播放 - if (!_isDisposed && _ttsService.isEnabled.value && fullGreeting.isNotEmpty) { - await _ttsService.speak(fullGreeting); + if (!_isDisposed && isTtsEnabled.value && fullGreeting.isNotEmpty) { + // 使用新方法替代直接调用speak + await _speakText(fullGreeting); } } catch (e) { print('生成问候语失败: $e'); diff --git a/lib/modules/chat/controllers/voice_input_controller.dart b/lib/modules/chat/controllers/voice_input_controller.dart index 47ab0a165..4275c7c0d 100644 --- a/lib/modules/chat/controllers/voice_input_controller.dart +++ b/lib/modules/chat/controllers/voice_input_controller.dart @@ -2,9 +2,10 @@ import 'dart:async'; import 'dart:math'; import 'package:get/get.dart'; import 'package:flutter/foundation.dart'; -import '../../../data/services/volcano_voice_recognition_service.dart'; -import '../../../data/services/volcano_tts_service.dart'; +import '../../../data/services/azure_asr_service.dart'; +import '../../../data/services/volcano_tts_api_service.dart'; import '../../../core/utils/logger.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; class VoiceInputController extends GetxController { // Observable states @@ -17,12 +18,16 @@ class VoiceInputController extends GetxController { final isUserSpeaking = false.obs; // 语音识别服务 - late final VolcanoVoiceRecognitionService _voiceService; - late final VolcanoTtsService _ttsService; + late final AzureAsrService _voiceService; + late final VolcanoTtsApiService _ttsService; // 连续识别相关 StreamSubscription? _recognitionSubscription; + // TTS相关订阅 + StreamSubscription? _ttsEventSubscription; + bool _isTtsSpeaking = false; + // 添加一个标志,表示是否已经识别到语音 bool _hasRecognizedSpeech = false; @@ -60,19 +65,48 @@ class VoiceInputController extends GetxController { void onInit() { super.onInit(); _initializeVoiceService(); - _ttsService = Get.find(); + _ttsService = Get.find(); + + // 设置TTS事件监听 + _setupTtsEventListener(); // 启动定期检查用户是否正在说话的计时器 _startSpeakingCheckTimer(); } + // 设置TTS事件监听 + void _setupTtsEventListener() { + _ttsEventSubscription?.cancel(); + _ttsEventSubscription = _ttsService.eventStream.listen(_handleTtsEvent); + } + + // 处理TTS事件 + void _handleTtsEvent(Map event) { + final eventType = event['eventType']; + + switch (eventType) { + case 'ttsSentenceStart': + _isTtsSpeaking = true; + break; + case 'ttsSentenceEnd': + case 'sessionFinished': + case 'error': + _isTtsSpeaking = false; + break; + } + } + Future _initializeVoiceService() async { try { // 获取语音识别服务实例 - _voiceService = Get.find(); + _voiceService = Get.find(); // 初始化语音识别服务 - await _voiceService.initialize(); + await _voiceService.initialize( + subscriptionKey: dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? '', + serviceRegion: dotenv.env['AZURE_ASR_SERVICE_REGION'] ?? 'eastasia', + language: dotenv.env['AZURE_ASR_LANGUAGE'] ?? 'zh-CN', + ); // 初始化完成后更新状态 isConnecting.value = false; @@ -124,18 +158,9 @@ class VoiceInputController extends GetxController { isUserSpeaking.value = true; // 如果系统正在播放TTS,检测到用户开始说话时立即中断 - bool isSpeaking = false; - try { - isSpeaking = _ttsService.isActuallyPlaying(); - } catch (e) { - print('获取 TTS 播放状态失败: $e'); - // 如果获取失败,假设为false - isSpeaking = false; - } - - if (isSpeaking && event.text.trim().isNotEmpty) { + if (_isTtsSpeaking && event.text.trim().isNotEmpty) { print('检测到用户开始说话,中断TTS播放'); - _ttsService.stop(); // 停止当前TTS播放 + _ttsService.endSession(); // 结束当前TTS会话 } // 取消之前的静默计时器 @@ -201,7 +226,6 @@ class VoiceInputController extends GetxController { break; case RecognitionEventType.error: - // 处理错误 print('语音识别错误: ${event.error}'); Get.snackbar( 'Error', @@ -212,7 +236,7 @@ class VoiceInputController extends GetxController { restartRecognition(); break; - case RecognitionEventType.completed: + case RecognitionEventType.sessionStopped: // 会话结束,尝试重新启动 isRecording.value = false; restartRecognition(); @@ -280,7 +304,7 @@ class VoiceInputController extends GetxController { // 停止连续识别 if (_voiceService.isContinuousRecognitionActive()) { - await _voiceService.stopRecognition(); + await _voiceService.stopContinuousRecognition(); } isRecording.value = false; @@ -345,6 +369,9 @@ class VoiceInputController extends GetxController { // 取消计时器 _speakingCheckTimer?.cancel(); + // 取消TTS事件订阅 + _ttsEventSubscription?.cancel(); + // 重置状态 isConnecting.value = false; isMuted.value = false; diff --git a/lib/modules/chat/views/chat_view.dart b/lib/modules/chat/views/chat_view.dart index 99e024d81..87d138589 100644 --- a/lib/modules/chat/views/chat_view.dart +++ b/lib/modules/chat/views/chat_view.dart @@ -3,7 +3,7 @@ import 'package:get/get.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../controllers/chat_controller.dart'; import '../models/message_model.dart'; -import '../../../data/services/volcano_tts_service.dart'; +import '../../../data/services/volcano_tts_api_service.dart'; import '../widgets/voice_input_panel.dart'; class ChatView extends GetView { @@ -14,8 +14,8 @@ class ChatView extends GetView { return WillPopScope( onWillPop: () async { // 停止TTS服务 - final tts = Get.find(); - await tts.stop(); + final tts = Get.find(); + await tts.endSession(); // 停止语音输入 if (controller.isVoiceInputVisible.value) { @@ -53,8 +53,8 @@ class ChatView extends GetView { ), onPressed: () async { // 停止TTS服务 - final tts = Get.find(); - await tts.stop(); + final tts = Get.find(); + await tts.endSession(); // 停止语音输入 if (controller.isVoiceInputVisible.value) { @@ -90,7 +90,7 @@ class ChatView extends GetView { actions: [ IconButton( icon: Obx(() => Icon( - Get.find().isEnabled.value + controller.isTtsEnabled.value ? Icons.volume_up : Icons.volume_off, color: Colors.black, diff --git a/lib/modules/explore/views/explore_view.dart b/lib/modules/explore/views/explore_view.dart index a1ca0fa5e..237999295 100644 --- a/lib/modules/explore/views/explore_view.dart +++ b/lib/modules/explore/views/explore_view.dart @@ -6,6 +6,7 @@ import '../../chat/controllers/chat_controller.dart'; import '../../chat/views/chat_view.dart'; import '../../../data/providers/agent_provider.dart'; import '../../../core/widgets/common_bottom_nav.dart'; +import '../../../core/routes/app_routes.dart'; class ExploreView extends GetView { const ExploreView({Key? key}) : super(key: key); @@ -22,6 +23,13 @@ class ExploreView extends GetView { return; } + // 检查是否是同声翻译官 + if (agent.id == 'simultaneous_interpreter') { + // 导航到翻译页面 + Get.toNamed(Routes.translation); + return; + } + // 导航到聊天页面,让 binding 来处理控制器的创建 Get.toNamed('/chat', arguments: { 'agentId': agent.id, diff --git a/lib/modules/profile/views/profile_view.dart b/lib/modules/profile/views/profile_view.dart index b4d6f264d..4abcf7d4a 100644 --- a/lib/modules/profile/views/profile_view.dart +++ b/lib/modules/profile/views/profile_view.dart @@ -128,9 +128,9 @@ class ProfileView extends GetView { ), const Divider(), _buildMenuItem( - title: '火山语音识别测试'.tr, + title: 'Azure语音识别测试'.tr, icon: Icons.mic, - subtitle: '测试火山语音ASR功能', + subtitle: '测试Azure语音ASR功能', onTap: () { Get.toNamed(Routes.ASR_TEST); }, diff --git a/lib/modules/test/bindings/test_binding.dart b/lib/modules/test/bindings/test_binding.dart index 1f38e629d..bf9431cd5 100644 --- a/lib/modules/test/bindings/test_binding.dart +++ b/lib/modules/test/bindings/test_binding.dart @@ -1,10 +1,16 @@ import 'package:get/get.dart'; import '../controllers/tts_test_controller.dart'; import '../controllers/asr_test_controller.dart'; +import '../../../data/services/volcano_tts_api_service.dart'; class TtsTestBinding extends Bindings { @override void dependencies() { + // Make sure the VolcanoTtsApiService is registered + if (!Get.isRegistered()) { + Get.put(VolcanoTtsApiService(), permanent: true); + } + Get.lazyPut( () => TtsTestController(), ); diff --git a/lib/modules/test/controllers/asr_test_controller.dart b/lib/modules/test/controllers/asr_test_controller.dart index 609a5b0ac..e55589c8f 100644 --- a/lib/modules/test/controllers/asr_test_controller.dart +++ b/lib/modules/test/controllers/asr_test_controller.dart @@ -1,9 +1,10 @@ import 'dart:async'; import 'package:get/get.dart'; -import '../../../data/services/volcano_voice_recognition_service.dart'; +import '../../../data/services/azure_asr_service.dart'; +import '../../../core/utils/logger.dart'; class AsrTestController extends GetxController { - final VolcanoVoiceRecognitionService _voiceService = Get.find(); + final AzureAsrService _azureService = Get.find(); // 可观察状态 final isListening = false.obs; @@ -18,13 +19,7 @@ class AsrTestController extends GetxController { void onInit() { super.onInit(); - // 监听语音识别服务的状态 - _voiceService.isListening.listen((listening) { - isListening.value = listening; - if (!listening) { - errorMessage.value = ''; - } - }); + // 由于 AzureAsrService 没有公开的 isListening 属性,我们需要自己管理状态 } /// 开始录音 @@ -34,13 +29,15 @@ class AsrTestController extends GetxController { if (isContinuous.value) { // 连续识别模式 - final success = await _voiceService.startContinuousRecognition(); + final success = await _azureService.startContinuousRecognition(); if (!success) { errorMessage.value = '无法启动语音识别'; return; } - _recognitionSubscription = _voiceService.recognitionStream?.listen( + isListening.value = true; + + _recognitionSubscription = _azureService.recognitionStream?.listen( (event) { switch (event.type) { case RecognitionEventType.finalResult: @@ -50,6 +47,14 @@ class AsrTestController extends GetxController { break; case RecognitionEventType.error: errorMessage.value = event.error ?? '未知错误'; + isListening.value = false; + break; + case RecognitionEventType.canceled: + errorMessage.value = '识别已取消: ${event.reason ?? "未知原因"}'; + isListening.value = false; + break; + case RecognitionEventType.sessionStopped: + isListening.value = false; break; default: break; @@ -62,16 +67,20 @@ class AsrTestController extends GetxController { ); } else { // 一次性识别模式 - final success = await _voiceService.startOneTimeRecognition(); - if (!success) { - errorMessage.value = '无法启动语音识别'; - return; + isListening.value = true; + try { + final result = await _azureService.recognizeOnce(); + if (result.isNotEmpty) { + recognitionResults.add(result); + } + } catch (e) { + errorMessage.value = '识别失败: $e'; + } finally { + isListening.value = false; } - - // 一次性识别模式下,结果会通过服务的recognitionResults获取 - // 在onInit中我们已经监听了isListening状态,当识别完成时会自动更新UI } } catch (e) { + Logger.error('启动语音识别失败', e); errorMessage.value = e.toString(); isListening.value = false; } @@ -81,12 +90,18 @@ class AsrTestController extends GetxController { void stopListening() async { try { if (isContinuous.value) { - await _voiceService.stopRecognition(); + final success = await _azureService.stopContinuousRecognition(); + if (!success) { + errorMessage.value = '停止识别失败'; + } await _recognitionSubscription?.cancel(); _recognitionSubscription = null; + isListening.value = false; } } catch (e) { + Logger.error('停止语音识别失败', e); errorMessage.value = e.toString(); + isListening.value = false; } } diff --git a/lib/modules/test/controllers/tts_test_controller.dart b/lib/modules/test/controllers/tts_test_controller.dart index 4ab2a76e2..e148b085e 100644 --- a/lib/modules/test/controllers/tts_test_controller.dart +++ b/lib/modules/test/controllers/tts_test_controller.dart @@ -1,120 +1,297 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import '../../../data/services/volcano_tts_service.dart'; +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import '../../../data/services/volcano_tts_api_service.dart'; class TtsTestController extends GetxController { - final VolcanoTtsService _ttsService = Get.find(); - - // 文本控制器 - final TextEditingController textController = TextEditingController(); + final VolcanoTtsApiService _ttsService = Get.find(); // 可观察状态 - final isPlaying = false.obs; + final isSpeaking = false.obs; final errorMessage = ''.obs; - final selectedVoiceType = ''.obs; - final voiceTypes = [].obs; final isLoading = false.obs; + final statusMessage = '未连接'.obs; + + // 当前播放的索引 + final currentPlayingIndex = RxInt(-1); + + // 固定的播报内容 + final RxList speechTexts = [ + '这是第一条播报测试,请听取合成效果。', + '这是第二条播报测试,火山语音合成支持连续多条播报。', + '这是第三条播报测试,可以测试语音合成的流畅度和连贯性。', + '这是第四条播报测试,感谢您的使用。', + ].obs; + + // 事件订阅 + StreamSubscription? _eventSubscription; @override void onInit() { super.onInit(); + // 初始化UI状态 + isSpeaking.value = false; + errorMessage.value = ''; + isLoading.value = false; + + // 监听TTS服务状态 + ever(_ttsService.isConnected, (bool connected) { + if (connected) { + statusMessage.value = '已连接'; + } else { + statusMessage.value = '未连接'; + isSpeaking.value = false; + } + }); + + // 监听TTS合成状态 + ever(_ttsService.isSynthesizing, (bool synthesizing) { + isSpeaking.value = synthesizing; + }); + // 监听TTS播放状态 - _ttsService.playingStateStream.listen((playing) { - isPlaying.value = playing; - if (!playing) { - errorMessage.value = ''; + ever(_ttsService.isPlaying.obs, (bool playing) { + isSpeaking.value = playing; + if (!playing && currentPlayingIndex.value >= 0) { + // 如果播放结束,重置当前播放索引 + currentPlayingIndex.value = -1; } }); - // 设置默认文本 - textController.text = '这是一个火山语音合成测试,请点击播放按钮听取合成效果。'; + // 设置事件监听 + _setupEventListener(); - // 设置可用的语音类型 - voiceTypes.value = [ - // 趣味方言 - 'zh_female_wanqudashu_moon_bigtts', // 湾区大叔 - 'zh_female_daimengchuanmei_moon_bigtts', // 呆萌川妹 - 'zh_male_guozhoudege_moon_bigtts', // 广州德哥 - 'zh_male_beijingxiaoye_moon_bigtts', // 北京小爷 - 'zh_male_haoyuxiaoge_moon_bigtts', // 浩宇小哥 - - // 通用场景 - 'zh_male_shaonianzixin_moon_bigtts', // 少年梓辛/Brayan - - // 角色扮演 - 'zh_female_meilinvyou_moon_bigtts', // 魅力女友 - 'zh_male_shenyeboke_moon_bigtts', // 深夜播客 - 'zh_female_sajiaonvyou_moon_bigtts', // 柔美女友 - 'zh_female_yuanqinvyou_moon_bigtts', // 撒娇学妹 - - ]; + // 连接到TTS服务 + _connectToTtsService(); + } + + // 设置事件监听 + void _setupEventListener() { + _eventSubscription?.cancel(); + _eventSubscription = _ttsService.eventStream.listen(_handleEvent); + } + + // 处理事件 + void _handleEvent(Map event) { + final eventType = event['eventType']; - // 设置当前选中的语音类型 - selectedVoiceType.value = _ttsService.getCurrentVoiceType(); + switch (eventType) { + case 'connectionStarted': + statusMessage.value = '连接成功'; + errorMessage.value = ''; + break; + case 'connectionFailed': + statusMessage.value = '连接失败'; + errorMessage.value = event['error'] ?? '未知错误'; + break; + case 'sessionStarted': + statusMessage.value = '会话开始'; + errorMessage.value = ''; + break; + case 'sessionFailed': + statusMessage.value = '会话失败'; + errorMessage.value = event['error'] ?? '未知错误'; + currentPlayingIndex.value = -1; + break; + case 'ttsSentenceStart': + statusMessage.value = '开始合成'; + break; + case 'ttsSentenceEnd': + statusMessage.value = '合成完成'; + break; + case 'sessionFinished': + statusMessage.value = '会话结束'; + currentPlayingIndex.value = -1; + break; + case 'connectionFinished': + statusMessage.value = '连接关闭'; + currentPlayingIndex.value = -1; + break; + case 'error': + statusMessage.value = '错误'; + errorMessage.value = event['error'] ?? '未知错误'; + break; + case 'disconnected': + statusMessage.value = '连接已断开'; + currentPlayingIndex.value = -1; + break; + default: + statusMessage.value = '事件: $eventType'; + break; + } - // 如果当前语音类型不在列表中,添加到列表 - if (!voiceTypes.contains(selectedVoiceType.value)) { - voiceTypes.add(selectedVoiceType.value); + // 强制更新UI + update(); + } + + // 连接到TTS服务 + Future _connectToTtsService() async { + try { + isLoading.value = true; + statusMessage.value = '正在连接...'; + + final success = await _ttsService.connect(); + + if (!success) { + statusMessage.value = '连接失败'; + errorMessage.value = '无法连接到火山语音服务'; + } + } catch (e) { + statusMessage.value = '连接错误'; + errorMessage.value = e.toString(); + } finally { + isLoading.value = false; } } - /// 播放文本 - void speakText() async { - final text = textController.text.trim(); - if (text.isEmpty) { - errorMessage.value = '请输入要合成的文本'; + /// 更新UI状态 + void updateUIState() { + // 强制更新UI + update(); + } + + /// 播放单条文本 + Future speakText(int index) async { + if (index < 0 || index >= speechTexts.length || isSpeaking.value) { return; } - errorMessage.value = ''; - isLoading.value = true; - try { - // 获取当前语音类型 - final currentVoiceType = selectedVoiceType.value; + isLoading.value = true; + errorMessage.value = ''; + currentPlayingIndex.value = index; - // 直接使用选定的语音类型,不进行可用性检查 + final text = speechTexts[index]; + const speaker = 'zh_female_shuangkuaisisi_moon_bigtts'; + + // 确保TTS服务已连接 + if (!_ttsService.isConnected.value) { + await _connectToTtsService(); + } + + // 开始会话 + await _ttsService.startSession(speaker); + + // 播放文本 + await _ttsService.speak(text, speaker: speaker); + + // 等待播放完成 + while (_ttsService.isActuallyPlaying()) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + // 结束会话 + await _ttsService.endSession(); - // 播放文本,传递选定的语音类型 - await _ttsService.speak(text, voiceType: currentVoiceType); } catch (e) { - errorMessage.value = e.toString(); - isPlaying.value = false; - - // 如果错误包含资源授权相关信息,提供更具体的提示 - final errorMsg = e.toString().toLowerCase(); - if (errorMsg.contains('resource not granted') || - errorMsg.contains('403') || - errorMsg.contains('授权') || - errorMsg.contains('3001')) { - errorMessage.value = '语音类型授权错误: 您可能没有权限使用当前选择的语音类型,请尝试使用基础语音类型'; + errorMessage.value = '播放出错'; + if (kDebugMode) { + print('播放出错: $e'); } } finally { isLoading.value = false; + currentPlayingIndex.value = -1; } } - /// 停止播放 - void stopSpeaking() { + /// 连续播放所有文本 + Future speakContinuousTexts() async { + if (isSpeaking.value || isLoading.value) { + return; + } + try { - _ttsService.stop(); + isLoading.value = true; + isSpeaking.value = true; + errorMessage.value = ''; + + // 确保TTS服务已连接 + if (!_ttsService.isConnected.value) { + await _connectToTtsService(); + } + + const speaker = 'zh_female_shuangkuaisisi_moon_bigtts'; + + // 停止之前的播放 + await _ttsService.stop(); + + // 开始会话 + await _ttsService.startSession(speaker); + + // 连续播放所有文本 + for (int i = 0; i < speechTexts.length; i++) { + // 如果播放被中断,退出循环 + if (!isSpeaking.value) break; + + currentPlayingIndex.value = i; + statusMessage.value = '正在播放第 ${i + 1}/${speechTexts.length} 条文本'; + + // 播放当前文本 + await _ttsService.speak(speechTexts[i], speaker: speaker); + + } + + // 结束会话 + await _ttsService.endSession(); + + statusMessage.value = '播放完成'; + } catch (e) { - errorMessage.value = e.toString(); + errorMessage.value = '播放出错'; + if (kDebugMode) { + print('连续播放出错: $e'); + } + + // 尝试结束会话,忽略可能的错误 + try { + await _ttsService.endSession(); + } catch (_) { + // 忽略结束会话时的错误 + } + } finally { + currentPlayingIndex.value = -1; + isSpeaking.value = false; + isLoading.value = false; } } - /// 切换语音类型 - void changeVoiceType(String voiceType) { - if (voiceType != selectedVoiceType.value) { - selectedVoiceType.value = voiceType; - errorMessage.value = ''; + /// 停止播放 + void stopSpeaking() async { + try { + isSpeaking.value = false; + statusMessage.value = '正在停止播放...'; + + // 停止播放 + await _ttsService.stop(); + + // 结束会话 + await _ttsService.endSession(); + + // 重置UI状态 + currentPlayingIndex.value = -1; + statusMessage.value = '已停止播放'; + + } catch (e) { + if (kDebugMode) { + print('停止播放出错: $e'); + } + errorMessage.value = '停止播放出错'; + } finally { + isSpeaking.value = false; + isLoading.value = false; } } @override void onClose() { - textController.dispose(); + // 取消事件订阅 + _eventSubscription?.cancel(); + + // 断开TTS服务连接 + _ttsService.disconnect(); + super.onClose(); } } \ No newline at end of file diff --git a/lib/modules/test/views/asr_test_view.dart b/lib/modules/test/views/asr_test_view.dart index f93ae2879..d902e6725 100644 --- a/lib/modules/test/views/asr_test_view.dart +++ b/lib/modules/test/views/asr_test_view.dart @@ -9,7 +9,7 @@ class AsrTestView extends GetView { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('火山语音识别测试'), + title: const Text('Azure 语音识别测试'), centerTitle: true, ), body: Padding( diff --git a/lib/modules/test/views/tts_test_view.dart b/lib/modules/test/views/tts_test_view.dart index 83152399c..fa3646f0f 100644 --- a/lib/modules/test/views/tts_test_view.dart +++ b/lib/modules/test/views/tts_test_view.dart @@ -7,261 +7,218 @@ class TtsTestView extends GetView { @override Widget build(BuildContext context) { + // 强制更新控制器状态 + WidgetsBinding.instance.addPostFrameCallback((_) { + // 在下一帧渲染完成后调用 + controller.updateUIState(); + }); + return Scaffold( appBar: AppBar( - title: const Text('火山语音合成测试'), + title: const Text('语音合成测试'), centerTitle: true, ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 语音类型选择 - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '语音类型', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Obx(() => DropdownButton( - isExpanded: true, - value: controller.selectedVoiceType.value, - onChanged: (String? newValue) { - if (newValue != null) { - controller.selectedVoiceType.value = newValue; - } - }, - items: controller.voiceTypes.map>((String value) { - // 根据语音类型ID获取友好名称 - String displayName = _getVoiceTypeDisplayName(value); - return DropdownMenuItem( - value: value, - child: Text(displayName), - ); - }).toList(), - )), - ], - ), - ), - ), - - const SizedBox(height: 16), - - // 文本输入 - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '输入要合成的文本', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 播放控制 + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '播放控制', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), - ), - const SizedBox(height: 8), - TextField( - controller: controller.textController, - maxLines: 5, - decoration: const InputDecoration( - hintText: '请输入要转换为语音的文本...', - border: OutlineInputBorder(), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Obx(() { + return ElevatedButton.icon( + onPressed: controller.isSpeaking.value || controller.isLoading.value + ? null + : () => controller.speakContinuousTexts(), + icon: controller.isSpeaking.value + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2) + ) + : const Icon(Icons.playlist_play), + label: Text(controller.isSpeaking.value ? '播放中...' : '全部播放'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + ); + }), + Obx(() => ElevatedButton.icon( + onPressed: controller.isSpeaking.value + ? () => controller.stopSpeaking() + : null, + icon: const Icon(Icons.stop), + label: const Text('停止'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + )), + ], ), - ), - ], + ], + ), ), ), - ), - - const SizedBox(height: 16), - - // 播放控制 - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '播放控制', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Obx(() => ElevatedButton.icon( - onPressed: controller.isPlaying.value || controller.isLoading.value - ? null - : () => controller.speakText(), - icon: controller.isLoading.value - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2) - ) - : const Icon(Icons.play_arrow), - label: Text(controller.isLoading.value ? '准备中...' : '播放'), - )), - ElevatedButton.icon( - onPressed: controller.isPlaying.value - ? () => controller.stopSpeaking() - : null, - icon: const Icon(Icons.stop), - label: const Text('停止'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), + + const SizedBox(height: 16), + + // 播报内容列表 + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '播报内容列表', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, ), - ], - ), - ], + ), + const SizedBox(height: 16), + // 生成5条可点击的播报内容 + Obx(() => ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.speechTexts.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final isPlaying = controller.isSpeaking.value && + controller.currentPlayingIndex.value == index; + + return ListTile( + title: Text( + controller.speechTexts[index], + style: TextStyle( + fontWeight: isPlaying ? FontWeight.bold : FontWeight.normal, + color: isPlaying ? Colors.blue : null, + ), + ), + leading: CircleAvatar( + backgroundColor: isPlaying ? Colors.blue : Colors.grey.shade200, + radius: 18, + child: Icon( + isPlaying ? Icons.volume_up : Icons.play_arrow, + color: isPlaying ? Colors.white : Colors.black, + size: 20, + ), + ), + trailing: isPlaying + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + onTap: controller.isSpeaking.value || controller.isLoading.value + ? null + : () => controller.speakText(index), + enabled: !(controller.isSpeaking.value || controller.isLoading.value), + tileColor: isPlaying ? Colors.blue.withOpacity(0.1) : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: isPlaying + ? const BorderSide(color: Colors.blue, width: 1) + : BorderSide.none, + ), + ); + }, + )), + ], + ), ), ), - ), - - const SizedBox(height: 16), - - // 状态显示 - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '状态', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Obx(() => Text( - controller.isLoading.value - ? '准备中...' - : controller.isPlaying.value - ? '正在播放...' - : '就绪', - style: TextStyle( - color: controller.isLoading.value - ? Colors.orange - : controller.isPlaying.value - ? Colors.green - : Colors.grey, - fontWeight: FontWeight.bold, + + const SizedBox(height: 16), + + // 状态显示 + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '状态', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), - )), - const SizedBox(height: 8), - Obx(() => controller.errorMessage.value.isNotEmpty - ? Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.red.shade50, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.red.shade200), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.error_outline, color: Colors.red, size: 16), - SizedBox(width: 8), - Text( - '错误', - style: TextStyle( - color: Colors.red, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - SizedBox(height: 4), - Text( - controller.errorMessage.value, - style: TextStyle(color: Colors.red.shade800), - ), - if (controller.errorMessage.value.contains('授权错误')) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - '提示: 请尝试选择基础语音类型,如"zh_male_qingse_common"或"zh_female_qingse_common"', - style: TextStyle( - fontStyle: FontStyle.italic, - color: Colors.red.shade700, + const SizedBox(height: 8), + Obx(() => Text( + controller.statusMessage.value, + style: TextStyle( + color: controller.isLoading.value + ? Colors.orange + : controller.isSpeaking.value + ? Colors.green + : Colors.grey, + fontWeight: FontWeight.bold, + ), + )), + const SizedBox(height: 8), + Obx(() => controller.errorMessage.value.isNotEmpty + ? Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.red.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.error_outline, color: Colors.red, size: 16), + SizedBox(width: 8), + Text( + '错误', + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), ), - ), + ], ), - ], - ), - ) - : const SizedBox.shrink()), - ], + SizedBox(height: 4), + Text( + controller.errorMessage.value, + style: TextStyle(color: Colors.red.shade800), + ), + ], + ), + ) + : const SizedBox.shrink()), + ], + ), ), ), - ), - ], + ], + ), ), ), ); } - - // 根据语音类型ID获取友好名称 - String _getVoiceTypeDisplayName(String voiceTypeId) { - switch (voiceTypeId) { - // 趣味方言 - case 'zh_female_wanqudashu_moon_bigtts': - return '湾区大叔 (趣味方言)'; - case 'zh_female_daimengchuanmei_moon_bigtts': - return '呆萌川妹 (趣味方言)'; - case 'zh_male_guozhoudege_moon_bigtts': - return '广州德哥 (趣味方言)'; - case 'zh_male_beijingxiaoye_moon_bigtts': - return '北京小爷 (趣味方言)'; - case 'zh_male_haoyuxiaoge_moon_bigtts': - return '浩宇小哥 (趣味方言)'; - - // 通用场景 - case 'zh_male_shaonianzixin_moon_bigtts': - return '少年梓辛/Brayan (通用场景)'; - - // 角色扮演 - case 'zh_female_meilinvyou_moon_bigtts': - return '魅力女友 (角色扮演)'; - case 'zh_male_shenyeboke_moon_bigtts': - return '深夜播客 (角色扮演)'; - case 'zh_female_sajiaonvyou_moon_bigtts': - return '柔美女友 (角色扮演)'; - case 'zh_female_yuanqinvyou_moon_bigtts': - return '撒娇学妹 (角色扮演)'; - - // 基础语音类型 - case 'zh_male_qingse_common': - return '基础男声'; - case 'zh_female_qingse_common': - return '基础女声'; - case 'zh_male_M392_conversation_wvae_bigtts': - return '高级男声'; - case 'zh_female_F392_conversation_wvae_bigtts': - return '高级女声'; - default: - return voiceTypeId; - } - } } \ No newline at end of file diff --git a/lib/modules/translation/bindings/translation_binding.dart b/lib/modules/translation/bindings/translation_binding.dart new file mode 100644 index 000000000..3bdd43ff4 --- /dev/null +++ b/lib/modules/translation/bindings/translation_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import '../controllers/translation_controller.dart'; + +class TranslationBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => TranslationController()); + } +} \ No newline at end of file diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart new file mode 100644 index 000000000..5d935cbf8 --- /dev/null +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -0,0 +1,334 @@ +import 'dart:async'; +import 'package:get/get.dart'; +import '../../../data/services/volcano_asr_service.dart'; +import '../../../data/services/volcano_tts_api_service.dart'; +import '../../../core/utils/logger.dart'; +import '../models/translation_message_model.dart'; + +class TranslationController extends GetxController { + // Observable states + final isListening = false.obs; + final isTranslating = false.obs; + final sourceLanguage = "Chinese".obs; + final targetLanguage = "English".obs; + final originalText = "".obs; + final translatedText = "".obs; + final isUserSpeaking = false.obs; + final messages = [].obs; + + // TTS启用状态 + final isTtsEnabled = true.obs; + + // Services + late final VolcanoAsrService _voiceService; + late final VolcanoTtsApiService _ttsService; + + // Stream subscriptions + StreamSubscription? _recognitionSubscription; + + // Timers + Timer? _silenceTimer; + Timer? _speakingCheckTimer; + + // State tracking + bool _hasRecognizedSpeech = false; + bool _hasFinalResult = false; + DateTime? _lastSpeechTime; + Completer? _recognitionCompleter; + + // TTS事件订阅 + StreamSubscription? _ttsEventSubscription; + bool _isTtsSpeaking = false; + + @override + void onInit() { + super.onInit(); + _initializeServices(); + _startSpeakingCheckTimer(); + + _ttsService = Get.find(); + + // 设置TTS事件监听 + _setupTtsEventListener(); + } + + Future _initializeServices() async { + try { + _voiceService = Get.find(); + _ttsService = Get.find(); + + await _voiceService.initialize(); + await startListening(); + } catch (e) { + Logger.error('Failed to initialize translation services', e); + Get.snackbar( + 'Error', + 'Failed to initialize translation services: $e', + snackPosition: SnackPosition.BOTTOM, + ); + } + } + + Future startListening() async { + if (isListening.value) return; + + try { + isListening.value = true; + originalText.value = ''; + translatedText.value = ''; + _hasRecognizedSpeech = false; + _hasFinalResult = false; + + _recognitionCompleter = Completer(); + + final success = await _voiceService.startContinuousRecognition(); + if (!success) { + isListening.value = false; + throw Exception('Failed to start voice recognition'); + } + + _recognitionSubscription = _voiceService.recognitionStream?.listen( + (event) { + switch (event.type) { + case RecognitionEventType.recognizing: + if (event.text.isNotEmpty) { + originalText.value = event.text; + _hasRecognizedSpeech = true; + _lastSpeechTime = DateTime.now(); + isUserSpeaking.value = true; + + // Cancel previous silence timer + _silenceTimer?.cancel(); + + // Set new silence timer - if no new recognition results in 2 seconds, + // consider the user has stopped speaking + _silenceTimer = Timer(const Duration(seconds: 2), () { + if (_hasRecognizedSpeech && !_hasFinalResult) { + _processTranslation(event.text); + } + }); + + // If TTS is playing and user starts speaking, stop TTS + if (_isTtsSpeaking) { + _ttsService.endSession(); + } + } + break; + + case RecognitionEventType.finalResult: + _hasFinalResult = true; + isUserSpeaking.value = false; + + if (event.text.isNotEmpty) { + originalText.value = event.text; + _hasRecognizedSpeech = true; + _lastSpeechTime = DateTime.now(); + + _processTranslation(event.text); + } else if (_hasRecognizedSpeech) { + // If final result is empty but we had intermediate results + _processTranslation(originalText.value); + } + + // Reset for next utterance + _hasRecognizedSpeech = false; + _hasFinalResult = false; + break; + + case RecognitionEventType.error: + Logger.error('Voice recognition error', event.error); + break; + + default: + break; + } + }, + onError: (error) { + Logger.error('Voice recognition stream error', error); + isListening.value = false; + restartListening(); + } + ); + + } catch (e) { + Logger.error('Failed to start listening', e); + isListening.value = false; + } + } + + Future _processTranslation(String text) async { + if (text.isEmpty) return; + + try { + isTranslating.value = true; + + // In a real implementation, you would call your translation service here + // For now, we'll simulate translation with a delay + await Future.delayed(const Duration(milliseconds: 300)); + + // Simulate translation (in a real app, call your translation API here) + final translated = await _simulateTranslation(text); + translatedText.value = translated; + + // Add to messages + messages.add(TranslationMessage( + original: text, + translated: translated, + timestamp: DateTime.now(), + isFromUser: true, + )); + + // Speak the translation if TTS is enabled + if (isTtsEnabled.value) { + await _synthesizeText(translated); + } + + } catch (e) { + Logger.error('Translation error', e); + } finally { + isTranslating.value = false; + } + } + + // This is a placeholder - in a real app, you would call your translation API + Future _simulateTranslation(String text) async { + // This is just a simulation - replace with actual translation API call + if (sourceLanguage.value == "Chinese" && targetLanguage.value == "English") { + // Simulate Chinese to English translation + if (text.contains('测试')) return 'I am now starting to test the function of translating spoken Chinese into English.'; + return 'Translated from Chinese: $text'; + } else { + // Simulate English to Chinese translation + return '翻译自英语: $text'; + } + } + + void switchLanguages() { + final temp = sourceLanguage.value; + sourceLanguage.value = targetLanguage.value; + targetLanguage.value = temp; + + // Clear current messages when switching languages + messages.clear(); + } + + Future restartListening() async { + await stopListening(); + await Future.delayed(const Duration(milliseconds: 500)); + await startListening(); + } + + Future stopListening() async { + if (!isListening.value) return; + + try { + await _recognitionSubscription?.cancel(); + _recognitionSubscription = null; + + _silenceTimer?.cancel(); + _silenceTimer = null; + + if (_voiceService.isContinuousRecognitionActive()) { + await _voiceService.stopRecognition(); + } + + isListening.value = false; + isUserSpeaking.value = false; + _hasRecognizedSpeech = false; + _hasFinalResult = false; + } catch (e) { + Logger.error('Failed to stop listening', e); + } + } + + void _startSpeakingCheckTimer() { + _speakingCheckTimer?.cancel(); + _speakingCheckTimer = Timer.periodic(const Duration(milliseconds: 200), (_) { + _checkIfUserIsSpeaking(); + }); + } + + void _checkIfUserIsSpeaking() { + if (_hasRecognizedSpeech && !_hasFinalResult && _lastSpeechTime != null) { + final timeSinceLastSpeech = DateTime.now().difference(_lastSpeechTime!); + if (timeSinceLastSpeech.inMilliseconds < 1000) { + isUserSpeaking.value = true; + return; + } + } + + isUserSpeaking.value = false; + } + + void _setupTtsEventListener() { + _ttsEventSubscription = _ttsService.eventStream.listen(_handleTtsEvent); + } + + void _handleTtsEvent(Map event) { + final String eventType = event['type'] as String? ?? 'unknown'; + + switch (eventType) { + case 'ttsSentenceStart': + _isTtsSpeaking = true; + break; + case 'ttsSentenceEnd': + case 'sessionFinished': + case 'connectionFinished': + _isTtsSpeaking = false; + break; + case 'connectionFailed': + case 'sessionFailed': + _isTtsSpeaking = false; + print('TTS错误: ${event['message']}'); + break; + } + } + + @override + void onClose() { + stopListening(); + _speakingCheckTimer?.cancel(); + + // 取消TTS事件订阅 + _ttsEventSubscription?.cancel(); + + // 结束TTS会话 + _ttsService.endSession(); + + super.onClose(); + } + + // Add a method to synthesize text using the new TTS service + Future _synthesizeText(String text) async { + if (text.isEmpty || !isTtsEnabled.value) return; + + try { + // 如果当前有正在进行的TTS会话,先结束它 + if (_isTtsSpeaking) { + await _ttsService.endSession(); + } + + // 确保连接已建立 + if (!_ttsService.isConnected.value) { + final connected = await _ttsService.connect(); + if (!connected) { + print('无法连接到TTS服务'); + return; + } + } + + // 开始合成 + await _ttsService.synthesize(text, 'zh_female_1'); // 使用默认说话人 + } catch (e) { + print('TTS播放出错: $e'); + } + } + + // Add a method to toggle TTS + void toggleTTS() { + isTtsEnabled.toggle(); + if (!isTtsEnabled.value) { + _ttsService.endSession(); + } + } +} \ No newline at end of file diff --git a/lib/modules/translation/models/translation_message_model.dart b/lib/modules/translation/models/translation_message_model.dart new file mode 100644 index 000000000..24ea7fd11 --- /dev/null +++ b/lib/modules/translation/models/translation_message_model.dart @@ -0,0 +1,13 @@ +class TranslationMessage { + final String original; + final String translated; + final DateTime timestamp; + final bool isFromUser; + + TranslationMessage({ + required this.original, + required this.translated, + required this.timestamp, + required this.isFromUser, + }); +} \ No newline at end of file diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart new file mode 100644 index 000000000..2c5de867a --- /dev/null +++ b/lib/modules/translation/views/translation_view.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/translation_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/text_styles.dart'; +import '../widgets/translation_message_item.dart'; + +class TranslationView extends GetView { + const TranslationView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: _buildLanguageSelector(), + centerTitle: true, + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => Get.back(), + ), + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined, color: Colors.black), + onPressed: () { + // Navigate to settings + }, + ), + ], + ), + body: Container( + color: Colors.white, + child: Column( + children: [ + Expanded( + child: _buildMessagesList(), + ), + _buildBottomSection(), + ], + ), + ), + ); + } + + Widget _buildLanguageSelector() { + return Obx(() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + controller.sourceLanguage.value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + ), + IconButton( + icon: const Icon(Icons.swap_horiz, color: Color(0xFF4A6FE5)), + onPressed: controller.switchLanguages, + ), + Text( + controller.targetLanguage.value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + ), + ], + ); + }); + } + + Widget _buildMessagesList() { + return Obx(() { + if (controller.messages.isEmpty) { + return _buildWelcomeMessage(); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: controller.messages.length, + reverse: true, + itemBuilder: (context, index) { + final message = controller.messages[controller.messages.length - 1 - index]; + return TranslationMessageItem(message: message); + }, + ); + }); + } + + Widget _buildWelcomeMessage() { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // First welcome message + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.grey[300]!, + width: 0.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Welcome to Felo Translator. 👆 Click the button above to switch languages", + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + height: 1.5, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Text( + "Welcome to Felo Translator. 👆 Click the button above to switch languages", + style: TextStyle( + fontSize: 14, + color: Colors.grey[800], + height: 1.5, + ), + ), + ), + const SizedBox(width: 8), + Icon(Icons.volume_up, color: Colors.grey[600]), + ], + ), + ], + ), + ), + + const SizedBox(height: 20), + + // Second instruction message + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFEEF4FF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFD0DFFF), + width: 0.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "👇 Click the button below ▶️ to start speaking, and Felo will automatically recognize the language for translation", + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + height: 1.5, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Text( + "👇 Click the button below ▶️ to start speaking, and Felo will automatically recognize the language for translation", + style: TextStyle( + fontSize: 14, + color: Colors.grey[800], + height: 1.5, + ), + ), + ), + const SizedBox(width: 8), + Icon(Icons.volume_up, color: Colors.grey[600]), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildBottomSection() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: Column( + children: [ + _buildRecognitionStatus(), + const SizedBox(height: 8), + _buildControlButtons(), + ], + ), + ); + } + + Widget _buildRecognitionStatus() { + return Obx(() { + final isListening = controller.isListening.value; + final isUserSpeaking = controller.isUserSpeaking.value; + + if (!isListening) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Colors.grey[300]!, + width: 0.5, + ), + ), + child: Row( + children: [ + if (isUserSpeaking) + _buildSpeakingIndicator() + else + Icon(Icons.mic, color: Colors.grey[600]), + const SizedBox(width: 12), + Expanded( + child: Text( + isUserSpeaking + ? "Listening..." + : "Tap the mic button to start speaking", + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + height: 1.5, + ), + ), + ), + ], + ), + ); + }); + } + + Widget _buildSpeakingIndicator() { + return SizedBox( + width: 24, + height: 24, + child: Stack( + alignment: Alignment.center, + children: [ + Icon(Icons.mic, color: const Color(0xFF4A6FE5)), + Positioned.fill( + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: const AlwaysStoppedAnimation(Color(0xFF4A6FE5)), + ), + ), + ], + ), + ); + } + + Widget _buildControlButtons() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Obx(() { + final isListening = controller.isListening.value; + + return FloatingActionButton( + onPressed: isListening + ? controller.stopListening + : controller.startListening, + backgroundColor: isListening ? Colors.red : const Color(0xFF4A6FE5), + child: Icon( + isListening ? Icons.stop : Icons.mic, + size: 24, + color: Colors.white, + ), + ); + }), + ], + ); + } +} \ No newline at end of file diff --git a/lib/modules/translation/widgets/translation_message_item.dart b/lib/modules/translation/widgets/translation_message_item.dart new file mode 100644 index 000000000..615f4ee26 --- /dev/null +++ b/lib/modules/translation/widgets/translation_message_item.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import '../models/translation_message_model.dart'; + +class TranslationMessageItem extends StatelessWidget { + final TranslationMessage message; + + const TranslationMessageItem({ + Key? key, + required this.message, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Original text + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.grey[300]!, + width: 0.5, + ), + ), + child: Text( + message.original, + style: TextStyle( + color: Colors.grey[800], + fontSize: 14, + height: 1.5, + ), + ), + ), + + const SizedBox(height: 8), + + // Translated text + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFEEF4FF), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFD0DFFF), + width: 0.5, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + message.translated, + style: TextStyle( + color: Colors.black87, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.5, + ), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.volume_up, + color: Colors.grey[600], + size: 20, + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/tools/check_volcano_asr_config.dart b/lib/tools/check_volcano_asr_config.dart deleted file mode 100644 index 0c5f8028a..000000000 --- a/lib/tools/check_volcano_asr_config.dart +++ /dev/null @@ -1,259 +0,0 @@ -import 'dart:io'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:http/http.dart' as http; -import 'dart:convert'; - -/// 检查火山语音识别服务配置 -/// -/// 该工具用于验证火山语音识别服务的配置是否正确,包括: -/// 1. 检查环境变量是否设置 -/// 2. 检查网络连接 -/// 3. 检查认证是否有效 -void main() async { - // 加载环境变量 - await dotenv.load(); - - print('======== 火山语音识别服务配置检查 ========'); - - // 检查环境变量 - final appId = dotenv.env['VOLCANO_APP_ID']; - final appKey = dotenv.env['VOLCANO_APP_KEY']; - final cluster = dotenv.env['VOLCANO_CLUSTER']; - - print('\n1. 检查环境变量:'); - - if (appId == null || appId.isEmpty) { - print('❌ VOLCANO_APP_ID 未设置'); - } else { - print('✅ VOLCANO_APP_ID: ${appId.substring(0, 3)}***${appId.substring(appId.length - 3)} (长度: ${appId.length})'); - } - - if (appKey == null || appKey.isEmpty) { - print('❌ VOLCANO_APP_KEY 未设置'); - } else { - print('✅ VOLCANO_APP_KEY: ${appKey.substring(0, 3)}***${appKey.substring(appKey.length - 3)} (长度: ${appKey.length})'); - } - - if (cluster == null || cluster.isEmpty) { - print('❌ VOLCANO_CLUSTER 未设置,将使用默认值 cn-beijing'); - } else { - print('✅ VOLCANO_CLUSTER: $cluster'); - } - - // 使用默认值 - final effectiveCluster = cluster ?? 'cn-beijing'; - - if (appId == null || appId.isEmpty || appKey == null || appKey.isEmpty) { - print('\n❌ 环境变量配置不完整,请检查 .env 文件'); - exit(1); - } - - // 检查网络连接 - print('\n2. 检查网络连接:'); - - try { - final result = await InternetAddress.lookup('openspeech.bytedance.com'); - if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { - print('✅ 网络连接正常,可以访问 openspeech.bytedance.com'); - } else { - print('❌ 无法连接到 openspeech.bytedance.com'); - } - } catch (e) { - print('❌ 网络连接异常: $e'); - } - - // 检查认证是否有效 - print('\n3. 检查认证有效性:'); - - http.Response? authResponse; - - try { - // 构建请求URL - 更新为大模型流式识别API路径 - final url = 'https://openspeech.bytedance.com/api/v3/sauc/bigmodel'; - - // 构建请求头 - 不再添加Bearer前缀 - final headers = { - 'Content-Type': 'application/json', - 'Authorization': appKey, // 不再添加Bearer前缀 - }; - - // 构建请求体 - 添加resourceId参数 - final body = jsonEncode({ - 'app_id': appId, - 'cluster': effectiveCluster, - 'resource_id': appId, // 资源ID暂时使用与APP_ID相同的值 - 'ping': true, // 只是ping服务,不进行实际识别 - }); - - print('正在发送测试请求...'); - print('请求URL: $url'); - print('请求头: Authorization=${appKey.substring(0, 3)}***'); - print('集群区域: $effectiveCluster'); - - // 发送请求 - authResponse = await http.post( - Uri.parse(url), - headers: headers, - body: body, - ).timeout(const Duration(seconds: 5)); - - // 检查响应 - if (authResponse.statusCode == 200) { - print('✅ 认证有效,服务响应正常'); - print('响应内容: ${authResponse.body}'); - } else { - print('❌ 认证无效或服务异常,状态码: ${authResponse.statusCode}'); - print('错误信息: ${authResponse.body}'); - - // 解析错误信息 - try { - final errorJson = jsonDecode(authResponse.body); - final errorCode = errorJson['code']; - final errorMsg = errorJson['message']; - - if (errorCode == 401) { - print('\n认证错误,可能的原因:'); - print('1. APP_KEY 格式不正确'); - print('2. APP_ID 与 APP_KEY 不匹配'); - print('3. 账户未开通大模型流式语音识别服务或服务已过期'); - print('4. 资源ID不正确或未授权'); - } else if (errorCode == 400) { - print('\nWebSocket握手错误,可能的原因:'); - print('1. 集群区域设置不正确 (当前: $effectiveCluster)'); - print('2. 请求参数格式不正确'); - print('3. 尝试使用不同的集群区域,如 cn-shanghai 或 cn-guangzhou'); - } else { - print('\n未知错误:'); - print('错误码: $errorCode'); - print('错误信息: $errorMsg'); - } - } catch (e) { - print('\n解析错误信息失败: $e'); - - if (authResponse.statusCode == 400) { - print('\nWebSocket握手错误,可能的原因:'); - print('1. 集群区域设置不正确 (当前: $effectiveCluster)'); - print('2. 请求参数格式不正确'); - print('3. 尝试使用不同的集群区域,如 cn-shanghai 或 cn-guangzhou'); - } - } - } - } catch (e) { - print('❌ 请求异常: $e'); - } - - print('\n======== 检查完成 ========'); - print('\n提示: 如果您使用的是大模型流式识别SDK,请确保:'); - print('1. 使用了正确的API路径: /api/v3/sauc/bigmodel'); - print('2. 不要在Token前添加Bearer前缀'); - print('3. 设置了正确的资源ID'); - print('4. 设置了协议类型为PROTOCOL_TYPE_SEED'); - print('5. 设置了正确的集群区域 (当前: $effectiveCluster)'); - - // 尝试其他集群区域 - if (authResponse != null && authResponse.statusCode == 400) { - print('\n尝试其他集群区域:'); - final alternativeClusters = [ - 'cn-shanghai', - 'cn-guangzhou', - 'cn-hongkong', - 'ap-singapore', - 'us-east-1', - 'us-west-1' - ]; - - bool foundWorkingCluster = false; - - for (final altCluster in alternativeClusters) { - if (altCluster != effectiveCluster) { - print('\n尝试集群区域: $altCluster'); - final success = await _testCluster(appId, appKey, altCluster); - if (success) { - foundWorkingCluster = true; - print('\n✅ 找到可用的集群区域: $altCluster'); - print('建议在 .env 文件中设置 VOLCANO_CLUSTER=$altCluster'); - - // 尝试更新.env文件 - try { - await _updateEnvFile(altCluster); - } catch (e) { - print('无法自动更新.env文件: $e'); - } - - break; - } - } - } - - if (!foundWorkingCluster) { - print('\n❌ 所有集群区域测试均失败'); - print('请联系火山引擎技术支持获取正确的集群区域'); - } - } -} - -/// 测试不同的集群区域 -Future _testCluster(String appId, String appKey, String cluster) async { - try { - final url = 'https://openspeech.bytedance.com/api/v3/sauc/bigmodel'; - - final headers = { - 'Content-Type': 'application/json', - 'Authorization': appKey, - }; - - final body = jsonEncode({ - 'app_id': appId, - 'cluster': cluster, - 'resource_id': appId, - 'ping': true, - }); - - final response = await http.post( - Uri.parse(url), - headers: headers, - body: body, - ).timeout(const Duration(seconds: 5)); - - if (response.statusCode == 200) { - print('✅ 集群区域 $cluster 可用,认证有效'); - return true; - } else { - print('❌ 集群区域 $cluster 不可用,状态码: ${response.statusCode}'); - return false; - } - } catch (e) { - print('❌ 测试集群区域 $cluster 时出错: $e'); - return false; - } -} - -/// 尝试更新.env文件 -Future _updateEnvFile(String newCluster) async { - try { - final file = File('.env'); - if (!await file.exists()) { - print('❌ .env文件不存在,无法自动更新'); - return; - } - - String content = await file.readAsString(); - - // 检查是否已有VOLCANO_CLUSTER - final clusterRegex = RegExp(r'VOLCANO_CLUSTER=.*'); - if (clusterRegex.hasMatch(content)) { - // 替换现有的VOLCANO_CLUSTER - content = content.replaceAll(clusterRegex, 'VOLCANO_CLUSTER=$newCluster'); - } else { - // 添加新的VOLCANO_CLUSTER - content += '\nVOLCANO_CLUSTER=$newCluster'; - } - - // 写入文件 - await file.writeAsString(content); - print('✅ 已自动更新.env文件中的VOLCANO_CLUSTER=$newCluster'); - } catch (e) { - print('❌ 更新.env文件失败: $e'); - throw e; - } -} \ No newline at end of file