diff --git a/.cursorrules b/.cursorrules index bd3eac47b..00538efc3 100644 --- a/.cursorrules +++ b/.cursorrules @@ -6,4 +6,4 @@ 6. 尽可能避免使用try catch 7. 尽可能避免打印日志,仅在出错时记录日志 8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化 - +9. 请尽可能使用中文回复问题 diff --git a/android/app/src/main/kotlin/com/example/deep_voice/AudioProcessingPlugin.kt b/android/app/src/main/kotlin/com/example/deep_voice/AudioProcessingPlugin.kt new file mode 100644 index 000000000..074880e9a --- /dev/null +++ b/android/app/src/main/kotlin/com/example/deep_voice/AudioProcessingPlugin.kt @@ -0,0 +1,572 @@ +package com.example.deep_voice + +import android.content.Context +import android.media.audiofx.AcousticEchoCanceler +import android.media.audiofx.NoiseSuppressor +import android.media.audiofx.AutomaticGainControl +import android.media.AudioManager +import android.os.Build +import android.util.Log +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import android.app.Application +import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.FlutterInjector + +/** + * 音频处理插件 + * + * 提供回音消除、降噪和自动增益控制等功能 + */ +class AudioProcessingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { + private lateinit var channel: MethodChannel + private lateinit var context: Context + private lateinit var audioManager: AudioManager + + // 音频处理组件 + private var echoCanceler: AcousticEchoCanceler? = null + private var noiseSuppressor: NoiseSuppressor? = null + private var automaticGainControl: AutomaticGainControl? = null + + // 当前音频会话ID + private var currentAudioSessionId: Int = -1 + + companion object { + private const val TAG = "AudioProcessingPlugin" + private const val CHANNEL_NAME = "com.deep_voice.audio_processing" + + // 保存静态实例,确保在任何地方都能访问 + @JvmStatic + private var instance: AudioProcessingPlugin? = null + + // 保存应用Context + @JvmStatic + private var applicationContext: Context? = null + + // 静态初始化块,尝试在类加载时就获取applicationContext + init { + try { + Log.i(TAG, "AudioProcessingPlugin静态初始化块被执行") + // 尝试通过FlutterInjector获取applicationContext + val flutterLoader = FlutterInjector.instance().flutterLoader() + if (flutterLoader.initialized()) { + Log.i(TAG, "FlutterLoader已初始化") + } else { + Log.i(TAG, "FlutterLoader尚未初始化") + } + } catch (e: Exception) { + Log.e(TAG, "静态初始化块执行失败: ${e.message}") + } + } + + // 获取实例的方法 + @JvmStatic + fun getInstance(): AudioProcessingPlugin? { + return instance + } + + // 获取应用Context的方法 + @JvmStatic + fun getApplicationContext(): Context? { + return applicationContext + } + + // 设置应用Context的方法,可以从外部调用 + @JvmStatic + fun setApplicationContext(ctx: Context) { + if (applicationContext == null) { + applicationContext = ctx.applicationContext + Log.i(TAG, "通过setApplicationContext方法设置了applicationContext") + } + } + } + + // 构造函数,尝试获取applicationContext + init { + Log.i(TAG, "AudioProcessingPlugin实例初始化块被执行") + } + + override fun onAttachedToEngine(binding: FlutterPluginBinding) { + Log.i(TAG, "onAttachedToEngine被调用") + context = binding.applicationContext + applicationContext = context + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + channel = MethodChannel(binding.binaryMessenger, CHANNEL_NAME) + channel.setMethodCallHandler(this) + instance = this + Log.i(TAG, "音频处理插件已初始化,context: $context, applicationContext: $applicationContext") + } + + override fun onDetachedFromEngine(binding: FlutterPluginBinding) { + channel.setMethodCallHandler(null) + releaseAudioEffects() + if (instance == this) { + instance = null + } + Log.i(TAG, "音频处理插件已释放") + } + + // 实现ActivityAware接口,获取Activity上下文 + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + Log.i(TAG, "onAttachedToActivity被调用") + // 如果context尚未初始化,使用Activity上下文 + if (!::context.isInitialized) { + context = binding.activity + Log.i(TAG, "使用Activity上下文初始化context") + + // 如果audioManager尚未初始化,初始化它 + if (!::audioManager.isInitialized) { + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + Log.i(TAG, "使用Activity上下文初始化audioManager") + } + } + } + + override fun onDetachedFromActivity() { + Log.i(TAG, "onDetachedFromActivity被调用") + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } + + override fun onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity() + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "isEchoCancellationAvailable" -> { + result.success(AcousticEchoCanceler.isAvailable()) + } + "isNoiseSuppressionAvailable" -> { + result.success(NoiseSuppressor.isAvailable()) + } + "enableEchoCancellation" -> { + val audioSessionId = call.argument("audioSessionId") + if (audioSessionId == null) { + result.error("INVALID_ARGUMENT", "音频会话ID不能为空", null) + return + } + result.success(enableEchoCancellation(audioSessionId)) + } + "disableEchoCancellation" -> { + result.success(disableEchoCancellation()) + } + "enableNoiseSuppression" -> { + val audioSessionId = call.argument("audioSessionId") + if (audioSessionId == null) { + result.error("INVALID_ARGUMENT", "音频会话ID不能为空", null) + return + } + result.success(enableNoiseSuppression(audioSessionId)) + } + "disableNoiseSuppression" -> { + result.success(disableNoiseSuppression()) + } + "getAudioSessionId" -> { + // 尝试获取当前活动的音频会话ID + try { + Log.i(TAG, "getAudioSessionId方法被调用") + + // 检查applicationContext是否已初始化 + if (applicationContext != null) { + Log.i(TAG, "静态applicationContext已初始化") + + // 如果context尚未初始化,使用applicationContext + if (!::context.isInitialized) { + context = applicationContext!! + Log.i(TAG, "使用静态applicationContext初始化context") + } + } else { + Log.w(TAG, "静态applicationContext尚未初始化") + } + + // 检查context是否初始化 + if (!::context.isInitialized) { + Log.e(TAG, "context尚未初始化,尝试从静态实例获取") + + // 尝试从静态实例获取context + val staticInstance = getInstance() + if (staticInstance != null) { + try { + // 尝试访问context,如果未初始化会抛出异常 + val testContext = staticInstance.context + context = testContext + Log.i(TAG, "从静态实例获取context成功") + } catch (e: UninitializedPropertyAccessException) { + Log.e(TAG, "静态实例的context也未初始化: ${e.message}") + + // 尝试使用应用级别的Context + val appContext = getApplicationContext() + if (appContext != null) { + context = appContext + Log.i(TAG, "使用应用级别的Context初始化成功") + } else { + Log.e(TAG, "无法获取任何有效的Context,无法获取音频会话ID") + result.success(-1) + return + } + } + } else { + // 尝试使用应用级别的Context + val appContext = getApplicationContext() + if (appContext != null) { + context = appContext + Log.i(TAG, "使用应用级别的Context初始化成功") + } else { + Log.e(TAG, "无法获取context,无法获取音频会话ID") + result.success(-1) + return + } + } + } + + // 检查audioManager是否初始化,如果没有则初始化 + if (!::audioManager.isInitialized) { + Log.w(TAG, "audioManager尚未初始化,正在初始化") + try { + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + Log.i(TAG, "audioManager初始化成功") + } catch (e: Exception) { + Log.e(TAG, "初始化audioManager失败: ${e.message}") + result.success(-1) + return + } + } + + // 使用ensureValidAudioSessionId方法获取有效的音频会话ID + val sessionId = ensureValidAudioSessionId() + Log.i(TAG, "返回音频会话ID: $sessionId") + result.success(sessionId) + } catch (e: Exception) { + Log.e(TAG, "获取音频会话ID失败: ${e.message}") + result.success(-1) + } + } + else -> { + result.notImplemented() + } + } + } + + /** + * 启用回音消除 + * + * @param audioSessionId 音频会话ID + * @return 是否成功启用 + */ + private fun enableEchoCancellation(audioSessionId: Int): Boolean { + if (!AcousticEchoCanceler.isAvailable()) { + Log.w(TAG, "设备不支持回音消除") + return false + } + + try { + Log.i(TAG, "尝试启用回音消除,音频会话ID: $audioSessionId") + + // 确保context已初始化 + if (!::context.isInitialized) { + Log.w(TAG, "context尚未初始化,尝试获取") + + // 尝试从静态实例或应用级别获取context + val staticInstance = getInstance() + if (staticInstance != null && try { staticInstance.context; true } catch (e: Exception) { false }) { + context = staticInstance.context + } else { + val appContext = getApplicationContext() + if (appContext != null) { + context = appContext + } else { + Log.e(TAG, "无法获取context,无法启用回音消除") + return false + } + } + } + + // 确保audioManager已初始化 + if (!::audioManager.isInitialized) { + try { + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + Log.i(TAG, "audioManager初始化成功") + } catch (e: Exception) { + Log.e(TAG, "初始化audioManager失败: ${e.message}") + return false + } + } + + // 如果已经创建了回音消除器,先释放 + if (echoCanceler != null) { + echoCanceler?.release() + echoCanceler = null + } + + // 创建新的回音消除器 + echoCanceler = AcousticEchoCanceler.create(audioSessionId) + if (echoCanceler == null) { + Log.e(TAG, "创建回音消除器失败") + return false + } + + // 启用回音消除 + echoCanceler?.enabled = true + currentAudioSessionId = audioSessionId + + Log.i(TAG, "回音消除已启用,音频会话ID: $audioSessionId") + return echoCanceler?.enabled == true + } catch (e: Exception) { + Log.e(TAG, "启用回音消除失败: ${e.message}") + return false + } + } + + /** + * 禁用回音消除 + * + * @return 是否成功禁用 + */ + private fun disableEchoCancellation(): Boolean { + if (echoCanceler == null) { + return true + } + + try { + echoCanceler?.enabled = false + echoCanceler?.release() + echoCanceler = null + Log.i(TAG, "回音消除已禁用") + return true + } catch (e: Exception) { + Log.e(TAG, "禁用回音消除失败: ${e.message}") + return false + } + } + + /** + * 启用噪声抑制 + * + * @param audioSessionId 音频会话ID + * @return 是否成功启用 + */ + private fun enableNoiseSuppression(audioSessionId: Int): Boolean { + if (!NoiseSuppressor.isAvailable()) { + Log.w(TAG, "设备不支持噪声抑制") + return false + } + + try { + Log.i(TAG, "尝试启用噪声抑制,音频会话ID: $audioSessionId") + + // 确保context已初始化 + if (!::context.isInitialized) { + Log.w(TAG, "context尚未初始化,尝试获取") + + // 尝试从静态实例或应用级别获取context + val staticInstance = getInstance() + if (staticInstance != null && try { staticInstance.context; true } catch (e: Exception) { false }) { + context = staticInstance.context + } else { + val appContext = getApplicationContext() + if (appContext != null) { + context = appContext + } else { + Log.e(TAG, "无法获取context,无法启用噪声抑制") + return false + } + } + } + + // 确保audioManager已初始化 + if (!::audioManager.isInitialized) { + try { + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + Log.i(TAG, "audioManager初始化成功") + } catch (e: Exception) { + Log.e(TAG, "初始化audioManager失败: ${e.message}") + return false + } + } + + // 如果已经创建了噪声抑制器,先释放 + if (noiseSuppressor != null) { + noiseSuppressor?.release() + noiseSuppressor = null + } + + // 创建新的噪声抑制器 + noiseSuppressor = NoiseSuppressor.create(audioSessionId) + if (noiseSuppressor == null) { + Log.e(TAG, "创建噪声抑制器失败") + return false + } + + // 启用噪声抑制 + noiseSuppressor?.enabled = true + currentAudioSessionId = audioSessionId + + Log.i(TAG, "噪声抑制已启用,音频会话ID: $audioSessionId") + return noiseSuppressor?.enabled == true + } catch (e: Exception) { + Log.e(TAG, "启用噪声抑制失败: ${e.message}") + return false + } + } + + /** + * 禁用噪声抑制 + * + * @return 是否成功禁用 + */ + private fun disableNoiseSuppression(): Boolean { + if (noiseSuppressor == null) { + return true + } + + try { + noiseSuppressor?.enabled = false + noiseSuppressor?.release() + noiseSuppressor = null + Log.i(TAG, "噪声抑制已禁用") + return true + } catch (e: Exception) { + Log.e(TAG, "禁用噪声抑制失败: ${e.message}") + return false + } + } + + /** + * 释放所有音频效果 + */ + private fun releaseAudioEffects() { + try { + echoCanceler?.release() + echoCanceler = null + + noiseSuppressor?.release() + noiseSuppressor = null + + automaticGainControl?.release() + automaticGainControl = null + + currentAudioSessionId = -1 + Log.i(TAG, "所有音频效果已释放") + } catch (e: Exception) { + Log.e(TAG, "释放音频效果失败: ${e.message}") + } + } + + /** + * 检查音频会话ID是否有效,如果无效则创建一个新的 + * + * @return 有效的音频会话ID + */ + private fun ensureValidAudioSessionId(): Int { + // 如果已经有有效的音频会话ID,直接返回 + if (currentAudioSessionId > 0) { + Log.i(TAG, "使用已存在的音频会话ID: $currentAudioSessionId") + return currentAudioSessionId + } + + Log.i(TAG, "尝试创建有效的音频会话ID") + + // 尝试从AudioManager获取音频会话ID + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && ::audioManager.isInitialized) { + try { + val audioAttributes = audioManager.getActivePlaybackConfigurations() + if (audioAttributes.isNotEmpty()) { + val sessionId = audioAttributes[0].audioAttributes.usage + if (sessionId > 0) { + currentAudioSessionId = sessionId + Log.i(TAG, "从AudioManager获取到音频会话ID: $sessionId") + return sessionId + } + } + } catch (e: Exception) { + Log.e(TAG, "从AudioManager获取音频会话ID失败: ${e.message}") + } + } + + // 尝试创建一个临时的AudioTrack来获取会话ID + try { + val audioTrack = android.media.AudioTrack( + android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + android.media.AudioFormat.Builder() + .setSampleRate(16000) + .setChannelMask(android.media.AudioFormat.CHANNEL_OUT_MONO) + .setEncoding(android.media.AudioFormat.ENCODING_PCM_16BIT) + .build(), + 1024, // 最小缓冲区大小 + android.media.AudioTrack.MODE_STREAM, + android.media.AudioManager.AUDIO_SESSION_ID_GENERATE + ) + + val generatedSessionId = audioTrack.audioSessionId + if (generatedSessionId > 0) { + currentAudioSessionId = generatedSessionId + Log.i(TAG, "成功创建临时AudioTrack并获取音频会话ID: $generatedSessionId") + + // 释放AudioTrack资源 + audioTrack.release() + + return generatedSessionId + } else { + Log.w(TAG, "创建的AudioTrack未返回有效的音频会话ID") + audioTrack.release() + } + } catch (e: Exception) { + Log.e(TAG, "创建临时AudioTrack失败: ${e.message}") + } + + // 尝试创建一个临时的AudioRecord来获取会话ID + try { + val minBufferSize = android.media.AudioRecord.getMinBufferSize( + 16000, + android.media.AudioFormat.CHANNEL_IN_MONO, + android.media.AudioFormat.ENCODING_PCM_16BIT + ) + + if (minBufferSize > 0) { + val audioRecord = android.media.AudioRecord( + android.media.MediaRecorder.AudioSource.MIC, + 16000, + android.media.AudioFormat.CHANNEL_IN_MONO, + android.media.AudioFormat.ENCODING_PCM_16BIT, + minBufferSize + ) + + val recordSessionId = audioRecord.audioSessionId + if (recordSessionId > 0) { + currentAudioSessionId = recordSessionId + Log.i(TAG, "成功创建临时AudioRecord并获取音频会话ID: $recordSessionId") + + // 释放AudioRecord资源 + audioRecord.release() + + return recordSessionId + } else { + Log.w(TAG, "创建的AudioRecord未返回有效的音频会话ID") + audioRecord.release() + } + } else { + Log.w(TAG, "无法获取有效的AudioRecord最小缓冲区大小") + } + } catch (e: Exception) { + Log.e(TAG, "创建临时AudioRecord失败: ${e.message}") + } + + // 如果以上方法都失败,使用一个固定的默认值 + val defaultSessionId = 1 + Log.w(TAG, "使用默认音频会话ID: $defaultSessionId") + currentAudioSessionId = defaultSessionId + return defaultSessionId + } +} \ 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 266597ea2..7c2c4ee49 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 @@ -11,28 +11,80 @@ import com.ryanheise.audioservice.AudioServiceActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.EventChannel +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugins.GeneratedPluginRegistrant class MainActivity: AudioServiceActivity() { 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 AUDIO_PROCESSING_CHANNEL = "com.deep_voice.audio_processing" private val TAG = "MainActivity" private lateinit var azureAsrHelper: AzureAsrHelper + // 暂时注释掉AudioProcessingPlugin的实例化 + // private val audioProcessingPlugin: AudioProcessingPlugin by lazy { AudioProcessingPlugin() } private var azureEventSink: EventChannel.EventSink? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // 初始化 Azure ASR 助手 azureAsrHelper = AzureAsrHelper() + + // 暂时注释掉AudioProcessingPlugin的初始化 + // AudioProcessingPlugin.setApplicationContext(applicationContext) + // Log.i(TAG, "在MainActivity.onCreate中初始化AudioProcessingPlugin的applicationContext") } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + // 暂时注释掉AudioProcessingPlugin的初始化 + // AudioProcessingPlugin.setApplicationContext(applicationContext) + // Log.i(TAG, "在configureFlutterEngine中初始化AudioProcessingPlugin的applicationContext") + + // 注册所有插件 + GeneratedPluginRegistrant.registerWith(flutterEngine) + + // 暂时注释掉AudioProcessingPlugin的注册,添加一个空的MethodCallHandler来处理调用 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AUDIO_PROCESSING_CHANNEL).setMethodCallHandler { call, result -> + // 为所有方法返回默认值或成功状态 + when (call.method) { + "isEchoCancellationAvailable" -> { + // 返回false表示不可用 + result.success(false) + } + "isNoiseSuppressionAvailable" -> { + // 返回false表示不可用 + result.success(false) + } + "enableEchoCancellation" -> { + // 返回true表示成功,但实际上不执行任何操作 + result.success(true) + } + "disableEchoCancellation" -> { + // 返回true表示成功 + result.success(true) + } + "enableNoiseSuppression" -> { + // 返回true表示成功,但实际上不执行任何操作 + result.success(true) + } + "disableNoiseSuppression" -> { + // 返回true表示成功 + result.success(true) + } + "getAudioSessionId" -> { + // 返回一个固定的音频会话ID + result.success(1) + } + else -> { + result.notImplemented() + } + } + } // 设置 Azure ASR 方法通道 MethodChannel(flutterEngine.dartExecutor.binaryMessenger, AZURE_ASR_CHANNEL).setMethodCallHandler { call, result -> diff --git a/assets/listening.mp3 b/assets/listening.mp3 new file mode 100644 index 000000000..cff159b45 Binary files /dev/null and b/assets/listening.mp3 differ diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 06813727d..57768f9d0 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -3,49 +3,90 @@ 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_asr_service.dart'; import '../../data/services/volcano_tts_api_service.dart'; import '../../data/services/volcano_translation_service.dart'; import '../../data/services/azure_asr_service.dart'; import '../../data/services/background_agent_service.dart'; import '../../data/services/chat_history_service.dart'; +import '../../data/services/flutter_tts_service.dart'; +import '../../data/services/volcano_asr_api_service.dart'; +// import '../../data/services/audio_processing_service.dart'; import '../../core/utils/logger.dart'; +import '../../core/utils/app_lifecycle_manager.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; /// 初始绑定,用于管理全局依赖 class InitialBinding extends Bindings { @override void dependencies() { - // 初始化所有服务 + // 立即初始化关键服务 + _initializeCriticalServices(); + + // 延时加载其他服务 + _registerLazyServices(); + } + + /// 初始化关键服务(立即加载) + void _initializeCriticalServices() async { + // 应用生命周期管理器是最基础的服务,需要最先初始化 + Get.put(AppLifecycleManager(), permanent: true); + + // 通知服务和权限控制器是关键服务,需要立即初始化 final notificationService = Get.put(NotificationService(), permanent: true); + final permissionController = Get.put(PermissionController(), permanent: true); + + // 音频服务也是基础服务,需要立即初始化 final audioService = Get.put(AudioServiceManager(), permanent: true); - final volcanoAiService = Get.put(VolcanoAIService(), permanent: true); - final voiceRecognitionService = Get.put(VolcanoAsrService(), permanent: true); - final volcanoTtsService = Get.put(VolcanoTtsApiService(), permanent: true); - final volcanoTranslationService = Get.put(VolcanoTranslationService(), permanent: true); - final azureAsrService = Get.put(AzureAsrService(), permanent: true); - // 注册ChatHistoryService (先注册ChatHistoryService) - final chatHistoryService = Get.put(ChatHistoryService(), permanent: true); + // 异步初始化通知和音频服务 + _initializeNotificationAndAudioServices(notificationService, audioService); + } + + /// 注册延时加载的服务 + void _registerLazyServices() { + // 使用lazyPut注册服务,只有在首次访问时才会被实例化 - // 注册后台代理服务 (后注册BackgroundAgentService) - final backgroundAgentService = Get.put(BackgroundAgentService(), permanent: true); + // AI相关服务 + Get.lazyPut(() => VolcanoAIService(), fenix: true); + Get.lazyPut(() { + final service = VolcanoTtsApiService(); + return service; + }, fenix: true); - // 只注册全局控制器 - Get.put(PermissionController(), permanent: true); - - // 异步初始化各个服务,确保顺序执行 - _initializeServices(notificationService, audioService); + // 语音识别服务 + // Get.lazyPut(() { + // final service = AzureAsrService(); + // // 在首次访问时初始化 + // _initializeAzureService(service); + // return service; + // }, fenix: true); + + // 翻译服务 + Get.lazyPut(() { + final service = VolcanoTranslationService(); + // 在首次访问时初始化 + _initializeVolcanoTranslationService(service); + return service; + }, fenix: true); + + // TTS服务 + // Get.lazyPut(() => FlutterTtsService(), fenix: true); - // 初始化 Azure 服务 - _initializeAzureService(azureAsrService); + // 音频处理服务 + // Get.lazyPut(() => AudioProcessingService(), fenix: true); - // 初始化火山翻译服务 - _initializeVolcanoTranslationService(volcanoTranslationService); + // 火山ASR API服务 + Get.lazyPut(() => VolcanoAsrApiService(), fenix: true); + + // 聊天历史服务 + Get.lazyPut(() => ChatHistoryService(), fenix: true); + + // 后台代理服务 (依赖于聊天历史服务) + Get.lazyPut(() => BackgroundAgentService(), fenix: true); } - // 单独提取初始化服务的方法,以便更好地处理错误 - void _initializeServices(NotificationService notificationService, AudioServiceManager audioService) async { + // 初始化通知和音频服务 + void _initializeNotificationAndAudioServices(NotificationService notificationService, AudioServiceManager audioService) async { try { // 首先初始化通知服务 await notificationService.init().catchError((error) { @@ -63,30 +104,13 @@ class InitialBinding extends Bindings { } catch (e) { Logger.error('服务初始化过程中发生错误', e); } - - // 其他服务初始化可以在这里添加 } // 初始化 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, - ); + // 直接调用初始化方法,服务内部会读取环境变量配置 + final success = await azureAsrService.initialize(); if (success) { Logger.info('Azure 语音识别服务初始化成功'); @@ -101,23 +125,8 @@ class InitialBinding extends Bindings { // 初始化火山翻译服务 void _initializeVolcanoTranslationService(VolcanoTranslationService translationService) async { try { - // 从环境变量中获取火山翻译服务的配置参数 - final accessKey = dotenv.env['VOLCANO_TRANSLATION_ACCESS_KEY'] ?? ''; - final secretKey = dotenv.env['VOLCANO_TRANSLATION_SECRET_KEY'] ?? ''; - final region = dotenv.env['VOLCANO_TRANSLATION_REGION'] ?? 'cn-north-1'; - - // 检查配置是否完整 - if (accessKey.isEmpty || secretKey.isEmpty) { - Logger.warning('火山翻译服务的访问密钥未设置,请检查 .env 文件'); - return; - } - - // 初始化火山翻译服务 - final success = await translationService.initialize( - accessKey: accessKey, - secretKey: secretKey, - region: region, - ); + // 直接调用初始化方法,服务内部会读取环境变量配置 + final success = await translationService.initialize(); if (success) { Logger.info('火山翻译服务初始化成功'); diff --git a/lib/core/routes/app_pages.dart b/lib/core/routes/app_pages.dart index 88508b2a2..ea881e161 100644 --- a/lib/core/routes/app_pages.dart +++ b/lib/core/routes/app_pages.dart @@ -9,12 +9,17 @@ import '../../modules/chat/bindings/chat_binding.dart'; 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/views/volcano_asr_test_view.dart'; +import '../../modules/test/views/flutter_tts_test_view.dart'; +import '../../modules/test/views/flutter_asr_test_view.dart'; import '../../modules/test/bindings/test_binding.dart'; +import '../../modules/test/bindings/volcano_asr_test_binding.dart'; +import '../../modules/test/bindings/flutter_tts_test_binding.dart'; +import '../../modules/test/bindings/flutter_asr_test_binding.dart'; import '../../modules/translation/views/translation_view.dart'; import '../../modules/translation/bindings/translation_binding.dart'; import '../../modules/translation/views/translation_history_view.dart'; import '../../modules/translation/bindings/translation_history_binding.dart'; -// import '../../modules/speech_demo/speech_demo_page.dart'; // This file doesn't exist import './app_routes.dart'; abstract class AppPages { @@ -39,11 +44,11 @@ abstract class AppPages { page: () => const ProfileView(), binding: ProfileBinding(), ), - // Commented out because SpeechDemoPage doesn't exist - // GetPage( - // name: Routes.speechDemo, - // page: () => const SpeechDemoPage(), - // ), + GetPage( + name: Routes.speechDemo, + page: () => const VolcanoAsrTestView(), + binding: VolcanoAsrTestBinding(), + ), GetPage( name: Routes.TTS_TEST, page: () => TtsTestView(), @@ -54,6 +59,21 @@ abstract class AppPages { page: () => AsrTestView(), binding: AsrTestBinding(), ), + GetPage( + name: Routes.VOLCANO_ASR_TEST, + page: () => const VolcanoAsrTestView(), + binding: VolcanoAsrTestBinding(), + ), + GetPage( + name: Routes.FLUTTER_TTS_TEST, + page: () => const FlutterTtsTestView(), + binding: FlutterTtsTestBinding(), + ), + GetPage( + name: Routes.FLUTTER_ASR_TEST, + page: () => const FlutterAsrTestView(), + binding: FlutterAsrTestBinding(), + ), GetPage( name: Routes.translation, page: () => const TranslationView(), diff --git a/lib/core/routes/app_routes.dart b/lib/core/routes/app_routes.dart index 2f94c28f5..9173470d6 100644 --- a/lib/core/routes/app_routes.dart +++ b/lib/core/routes/app_routes.dart @@ -7,6 +7,9 @@ abstract class Routes { static const speechDemo = '/speech_demo'; static const TTS_TEST = '/tts_test'; static const ASR_TEST = '/asr_test'; + static const VOLCANO_ASR_TEST = '/volcano_asr_test'; static const translation = '/translation'; static const translationHistory = '/translation_history'; + static const FLUTTER_TTS_TEST = '/flutter_tts_test'; + static const FLUTTER_ASR_TEST = '/flutter_asr_test'; } \ No newline at end of file diff --git a/lib/core/utils/app_lifecycle_manager.dart b/lib/core/utils/app_lifecycle_manager.dart new file mode 100644 index 000000000..ecb3a14a6 --- /dev/null +++ b/lib/core/utils/app_lifecycle_manager.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../data/services/volcano_asr_api_service.dart'; +import '../../data/services/volcano_tts_api_service.dart'; +import 'logger.dart'; + +/// 应用生命周期管理器 +/// +/// 监控应用的前台/后台状态变化,并在状态变化时执行相应操作 +class AppLifecycleManager extends GetxService with WidgetsBindingObserver { + // 当前应用生命周期状态 + final Rx _appLifecycleState = AppLifecycleState.resumed.obs; + AppLifecycleState get appLifecycleState => _appLifecycleState.value; + + // 状态变化流 + final _lifecycleStateStream = AppLifecycleState.resumed.obs; + Stream get lifecycleStateStream => _lifecycleStateStream.stream; + + // 是否在前台 + final RxBool _isInForeground = true.obs; + bool get isInForeground => _isInForeground.value; + + @override + void onInit() { + super.onInit(); + // 注册应用生命周期观察者 + WidgetsBinding.instance.addObserver(this); + Logger.info('AppLifecycleManager 已初始化'); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final previousState = _appLifecycleState.value; + _appLifecycleState.value = state; + _lifecycleStateStream.value = state; + + Logger.info('应用生命周期状态变化: $previousState -> $state'); + + // 根据状态变化更新前台/后台标志 + switch (state) { + case AppLifecycleState.resumed: + _isInForeground.value = true; + _handleAppResumed(); + break; + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + case AppLifecycleState.detached: + _isInForeground.value = false; + _handleAppPaused(state); + break; + default: + break; + } + } + + /// 处理应用进入前台 + void _handleAppResumed() { + Logger.info('应用进入前台'); + // 这里不需要主动恢复服务,因为我们希望用户手动控制录音开始 + // 如果正在录音,停止录音 + final asrService = Get.find(); + final ttsService = Get.find(); + if (asrService.isRecording) { + Logger.info('应用进入后台,停止语音识别'); + asrService.stopRecognition(); + ttsService.stop(); + ttsService.disconnect(); + } + } + + /// 处理应用进入后台 + void _handleAppPaused(AppLifecycleState state) { + Logger.info('应用进入后台: $state'); + + // 获取 VolcanoAsrApiService 实例 + final asrService = Get.find(); + final ttsService = Get.find(); + // 如果正在录音,停止录音 + if (asrService.isRecording) { + Logger.info('应用进入后台,停止语音识别'); + asrService.stopRecognition(); + ttsService.stop(); + ttsService.disconnect(); + + } + } + + @override + void onClose() { + // 移除应用生命周期观察者 + WidgetsBinding.instance.removeObserver(this); + Logger.info('AppLifecycleManager 已关闭'); + super.onClose(); + } +} \ No newline at end of file diff --git a/lib/core/utils/audio_player_utils.dart b/lib/core/utils/audio_player_utils.dart new file mode 100644 index 000000000..3027bf47c --- /dev/null +++ b/lib/core/utils/audio_player_utils.dart @@ -0,0 +1,70 @@ +import 'package:just_audio/just_audio.dart'; +import 'logger.dart'; + +/// 音频播放工具类 +/// 提供播放assets及本地音频文件的功能 +class AudioPlayerUtils { + // 单例模式 + static final AudioPlayerUtils instance = AudioPlayerUtils._internal(); + + // 音频播放器 + final AudioPlayer _audioPlayer = AudioPlayer(); + + // 构造函数 + AudioPlayerUtils._internal() { + _setupAudioPlayer(); + } + + /// 设置音频播放器 + void _setupAudioPlayer() { + // 可以根据需要添加播放状态监听等 + } + + /// 播放assets中的音频文件 + /// [assetPath] assets资源路径,例如 'assets/listening.mp3' + Future playAssetAudio(String assetPath) async { + try { + // 停止当前播放 + await stopPlayback(); + + // 设置音频源并播放 + await _audioPlayer.setAsset(assetPath); + await _audioPlayer.play(); + + Logger.info('播放assets音频: $assetPath'); + } catch (e) { + Logger.error('播放assets音频失败', e); + } + } + + /// 播放本地文件路径的音频 + /// [filePath] 本地文件路径 + Future playLocalAudio(String filePath) async { + try { + // 停止当前播放 + await stopPlayback(); + + // 设置音频源并播放 + await _audioPlayer.setFilePath(filePath); + await _audioPlayer.play(); + + Logger.info('播放本地音频文件: $filePath'); + } catch (e) { + Logger.error('播放本地音频文件失败', e); + } + } + + /// 停止播放 + Future stopPlayback() async { + try { + await _audioPlayer.stop(); + } catch (e) { + Logger.error('停止播放失败', e); + } + } + + /// 释放资源 + void dispose() { + _audioPlayer.dispose(); + } +} \ No newline at end of file diff --git a/lib/data/services/audio_processing_service.dart b/lib/data/services/audio_processing_service.dart new file mode 100644 index 000000000..640f1f854 --- /dev/null +++ b/lib/data/services/audio_processing_service.dart @@ -0,0 +1,110 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import '../../core/utils/logger.dart'; + +/// 音频处理服务 +/// +/// 提供回音消除、降噪和语音活动检测等功能 +class AudioProcessingService extends GetxService { + static const MethodChannel _channel = MethodChannel('com.deep_voice.audio_processing'); + + // 状态变量 + final _isEchoCancellationEnabled = false.obs; + final _isNoiseSuppressionEnabled = false.obs; + final _isVoiceActivityDetectionEnabled = false.obs; + + // 获取可观察状态 + RxBool get isEchoCancellationEnabled => _isEchoCancellationEnabled; + RxBool get isNoiseSuppressionEnabled => _isNoiseSuppressionEnabled; + RxBool get isVoiceActivityDetectionEnabled => _isVoiceActivityDetectionEnabled; + + AudioProcessingService() { + _initPlatformState(); + } + + /// 初始化平台状态 + Future _initPlatformState() async { + try { + final bool isEchoCancellationAvailable = await _channel.invokeMethod('isEchoCancellationAvailable') ?? false; + final bool isNoiseSuppressionAvailable = await _channel.invokeMethod('isNoiseSuppressionAvailable') ?? false; + + Logger.info('回音消除可用: $isEchoCancellationAvailable'); + Logger.info('噪声抑制可用: $isNoiseSuppressionAvailable'); + } catch (e) { + Logger.error('初始化音频处理服务失败: $e'); + } + } + + /// 为指定的音频会话ID启用回音消除 + Future enableEchoCancellation(int audioSessionId) async { + try { + final bool result = await _channel.invokeMethod('enableEchoCancellation', { + 'audioSessionId': audioSessionId, + }) ?? false; + + _isEchoCancellationEnabled.value = result; + Logger.info('启用回音消除${result ? "成功" : "失败"}'); + return result; + } catch (e) { + Logger.error('启用回音消除失败: $e'); + return false; + } + } + + /// 禁用回音消除 + Future disableEchoCancellation() async { + try { + final bool result = await _channel.invokeMethod('disableEchoCancellation') ?? false; + + _isEchoCancellationEnabled.value = !result; + Logger.info('禁用回音消除${result ? "成功" : "失败"}'); + return result; + } catch (e) { + Logger.error('禁用回音消除失败: $e'); + return false; + } + } + + /// 为指定的音频会话ID启用噪声抑制 + Future enableNoiseSuppression(int audioSessionId) async { + try { + final bool result = await _channel.invokeMethod('enableNoiseSuppression', { + 'audioSessionId': audioSessionId, + }) ?? false; + + _isNoiseSuppressionEnabled.value = result; + Logger.info('启用噪声抑制${result ? "成功" : "失败"}'); + return result; + } catch (e) { + Logger.error('启用噪声抑制失败: $e'); + return false; + } + } + + /// 禁用噪声抑制 + Future disableNoiseSuppression() async { + try { + final bool result = await _channel.invokeMethod('disableNoiseSuppression') ?? false; + + _isNoiseSuppressionEnabled.value = !result; + Logger.info('禁用噪声抑制${result ? "成功" : "失败"}'); + return result; + } catch (e) { + Logger.error('禁用噪声抑制失败: $e'); + return false; + } + } + + /// 获取当前录音的音频会话ID + Future getAudioSessionId() async { + try { + final int? sessionId = await _channel.invokeMethod('getAudioSessionId'); + Logger.info('获取音频会话ID: $sessionId'); + return sessionId; + } catch (e) { + Logger.error('获取音频会话ID失败: $e'); + return null; + } + } +} \ No newline at end of file diff --git a/lib/data/services/azure_asr_service.dart b/lib/data/services/azure_asr_service.dart index 3f5fa5f43..498d70cd2 100644 --- a/lib/data/services/azure_asr_service.dart +++ b/lib/data/services/azure_asr_service.dart @@ -131,19 +131,30 @@ class AzureAsrService extends GetxService { /// [serviceRegion] Azure 语音服务的区域 /// [language] 识别语言,默认为中文 Future initialize({ - required String subscriptionKey, - required String serviceRegion, - String language = 'zh-CN', + String? subscriptionKey, + String? serviceRegion, + String? language, }) async { if (_isInitialized) return true; try { + // 从环境变量中读取配置,如果没有传入参数 + final key = subscriptionKey ?? dotenv.env['AZURE_ASR_SUBSCRIPTION_KEY'] ?? ''; + final region = serviceRegion ?? dotenv.env['AZURE_ASR_SERVICE_REGION'] ?? 'eastasia'; + final lang = language ?? dotenv.env['AZURE_ASR_LANGUAGE'] ?? 'zh-CN'; + + // 检查配置是否完整 + if (key.isEmpty) { + Logger.warning('Azure 语音识别服务的订阅密钥未设置,请检查 .env 文件'); + return false; + } + final result = await _methodChannel.invokeMethod( 'initialize', { - 'subscriptionKey': subscriptionKey, - 'serviceRegion': serviceRegion, - 'language': language, + 'subscriptionKey': key, + 'serviceRegion': region, + 'language': lang, }, ); diff --git a/lib/data/services/azure_tts_service.dart b/lib/data/services/azure_tts_service.dart new file mode 100644 index 000000000..05ca1616b --- /dev/null +++ b/lib/data/services/azure_tts_service.dart @@ -0,0 +1,539 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import '../../core/utils/logger.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:path_provider/path_provider.dart'; + +/// 语音合成事件类型 +enum SynthesisEventType { + /// 合成开始 + synthesisStarted, + + /// 合成进度更新 + synthesisProgress, + + /// 合成完成 + synthesisCompleted, + + /// 合成取消 + synthesisCanceled, + + /// 合成错误 + error, +} + +/// 语音合成事件 +class SynthesisEvent { + /// 事件类型 + final SynthesisEventType type; + + /// 音频文件路径(仅在 synthesisCompleted 类型中有效) + final String? audioFilePath; + + /// 进度百分比(仅在 synthesisProgress 类型中有效) + final int? progressPercent; + + /// 错误信息(仅在 error 和 synthesisCanceled 类型中有效) + final String? error; + + /// 取消原因(仅在 synthesisCanceled 类型中有效) + final String? reason; + + /// 错误详情(仅在 synthesisCanceled 类型中有效) + final String? errorDetails; + + SynthesisEvent({ + required this.type, + this.audioFilePath, + this.progressPercent, + this.error, + this.reason, + this.errorDetails, + }); + + /// 从原生事件映射创建合成事件 + factory SynthesisEvent.fromMap(Map map) { + final eventType = map['eventType'] as String; + + SynthesisEventType type; + switch (eventType) { + case 'synthesisStarted': + type = SynthesisEventType.synthesisStarted; + break; + case 'synthesisProgress': + type = SynthesisEventType.synthesisProgress; + break; + case 'synthesisCompleted': + type = SynthesisEventType.synthesisCompleted; + break; + case 'synthesisCanceled': + type = SynthesisEventType.synthesisCanceled; + break; + case 'error': + type = SynthesisEventType.error; + break; + default: + throw ArgumentError('未知事件类型: $eventType'); + } + + return SynthesisEvent( + type: type, + audioFilePath: map['audioFilePath'] as String?, + progressPercent: map['progressPercent'] as int?, + error: map['error'] as String?, + reason: map['reason'] as String?, + errorDetails: map['errorDetails'] as String?, + ); + } + + @override + String toString() { + return 'SynthesisEvent{type: $type, audioFilePath: $audioFilePath, progressPercent: $progressPercent, error: $error, reason: $reason, errorDetails: $errorDetails}'; + } +} + +/// TTS 状态 +enum TtsState { + /// 空闲 + idle, + + /// 合成中 + synthesizing, + + /// 播放中 + playing, + + /// 已停止 + stopped, +} + +/// Azure 文本转语音服务 +class AzureTtsService extends GetxService { + static AzureTtsService get to => Get.find(); + + // 方法通道 + static const MethodChannel _methodChannel = MethodChannel('com.example.deep_voice/azure_tts'); + + // 事件通道 + static const EventChannel _eventChannel = EventChannel('com.example.deep_voice/azure_tts_events'); + + // 是否已初始化 + bool _isInitialized = false; + bool get isInitialized => _isInitialized; + + // 当前TTS状态 + final Rx _ttsState = TtsState.idle.obs; + TtsState get ttsState => _ttsState.value; + + // 合成相关 + StreamController? _eventStreamController; + StreamSubscription? _eventSubscription; + + // 公开的事件流 + Stream? _synthesisStream; + Stream? get synthesisStream => _synthesisStream; + + // 当前语音名称 + String _currentVoice = 'zh-CN-XiaoxiaoNeural'; + String get currentVoice => _currentVoice; + set currentVoice(String voice) { + _currentVoice = voice; + _setVoice(voice); + } + + // 语速 (-100 to 100) + int _rate = 0; + int get rate => _rate; + set rate(int value) { + if (value < -100 || value > 100) { + throw ArgumentError('语速必须在 -100 到 100 之间'); + } + _rate = value; + _setSpeechRate(value); + } + + // 音调 (-100 to 100) + int _pitch = 0; + int get pitch => _pitch; + set pitch(int value) { + if (value < -100 || value > 100) { + throw ArgumentError('音调必须在 -100 到 100 之间'); + } + _pitch = value; + _setPitch(value); + } + + // 音量 (0 to 100) + int _volume = 100; + int get volume => _volume; + set volume(int value) { + if (value < 0 || value > 100) { + throw ArgumentError('音量必须在 0 到 100 之间'); + } + _volume = value; + _setVolume(value); + } + + // 可用的语音列表 + final RxList> _voices = >[].obs; + List> get voices => _voices; + + /// 初始化 Azure 文本转语音服务 + /// + /// [subscriptionKey] Azure 语音服务的订阅密钥 + /// [serviceRegion] Azure 语音服务的区域 + /// [defaultVoice] 默认语音名称,默认为中文女声 + Future initialize({ + String? subscriptionKey, + String? serviceRegion, + String? defaultVoice, + }) async { + if (_isInitialized) return true; + + try { + // 从环境变量中读取配置,如果没有传入参数 + final key = subscriptionKey ?? dotenv.env['AZURE_TTS_SUBSCRIPTION_KEY'] ?? ''; + final region = serviceRegion ?? dotenv.env['AZURE_TTS_SERVICE_REGION'] ?? 'eastasia'; + final voice = defaultVoice ?? dotenv.env['AZURE_TTS_DEFAULT_VOICE'] ?? 'zh-CN-XiaoxiaoNeural'; + + // 检查配置是否完整 + if (key.isEmpty) { + Logger.warning('Azure 文本转语音服务的订阅密钥未设置,请检查 .env 文件'); + return false; + } + + // 设置事件监听 + _setupEventChannel(); + + final result = await _methodChannel.invokeMethod( + 'initialize', + { + 'subscriptionKey': key, + 'serviceRegion': region, + 'defaultVoice': voice, + }, + ); + + _isInitialized = result ?? false; + + if (_isInitialized) { + Logger.info('Azure 文本转语音服务初始化成功'); + _currentVoice = voice; + + // 获取可用语音列表 + await getAvailableVoices(); + } else { + Logger.error('Azure 文本转语音服务初始化失败'); + } + + return _isInitialized; + } catch (e) { + Logger.error('初始化 Azure 文本转语音服务失败', e); + _isInitialized = false; + return false; + } + } + + /// 设置事件通道监听 + void _setupEventChannel() { + // 创建事件流控制器 + _eventStreamController = StreamController.broadcast(); + + // 设置事件监听 + _eventSubscription = _eventChannel + .receiveBroadcastStream() + .map((dynamic event) => SynthesisEvent.fromMap(event)) + .listen( + (event) { + _handleSynthesisEvent(event); + _eventStreamController?.add(event); + }, + onError: (error) { + Logger.error('Azure 文本转语音事件流错误', error); + _eventStreamController?.addError(error); + }, + ); + + // 设置公开的流 + _synthesisStream = _eventStreamController?.stream; + } + + /// 处理合成事件 + void _handleSynthesisEvent(SynthesisEvent event) { + switch (event.type) { + case SynthesisEventType.synthesisStarted: + _ttsState.value = TtsState.synthesizing; + break; + case SynthesisEventType.synthesisCompleted: + _ttsState.value = TtsState.idle; + break; + case SynthesisEventType.synthesisCanceled: + case SynthesisEventType.error: + _ttsState.value = TtsState.idle; + break; + default: + // 其他事件类型不需要特殊处理 + break; + } + } + + /// 获取可用的语音列表 + Future>> getAvailableVoices() async { + _checkInitialized(); + + try { + final result = await _methodChannel.invokeMethod>('getAvailableVoices'); + + if (result != null) { + final voicesList = result.map((item) { + final map = Map.from(item as Map); + return { + 'name': map['name'] as String, + 'locale': map['locale'] as String, + 'gender': map['gender'] as String, + 'displayName': map['displayName'] as String, + }; + }).toList(); + + _voices.value = List>.from(voicesList); + return _voices; + } + + return []; + } catch (e) { + Logger.error('获取可用语音列表失败', e); + return []; + } + } + + /// 设置语音 + Future _setVoice(String voiceName) async { + _checkInitialized(); + + try { + final result = await _methodChannel.invokeMethod( + 'setVoice', + {'voiceName': voiceName}, + ); + return result ?? false; + } catch (e) { + Logger.error('设置语音失败', e); + return false; + } + } + + /// 设置语速 + Future _setSpeechRate(int rate) async { + _checkInitialized(); + + try { + final result = await _methodChannel.invokeMethod( + 'setSpeechRate', + {'rate': rate}, + ); + return result ?? false; + } catch (e) { + Logger.error('设置语速失败', e); + return false; + } + } + + /// 设置音调 + Future _setPitch(int pitch) async { + _checkInitialized(); + + try { + final result = await _methodChannel.invokeMethod( + 'setPitch', + {'pitch': pitch}, + ); + return result ?? false; + } catch (e) { + Logger.error('设置音调失败', e); + return false; + } + } + + /// 设置音量 + Future _setVolume(int volume) async { + _checkInitialized(); + + try { + final result = await _methodChannel.invokeMethod( + 'setVolume', + {'volume': volume}, + ); + return result ?? false; + } catch (e) { + Logger.error('设置音量失败', e); + return false; + } + } + + /// 合成文本为语音并保存到文件 + /// + /// [text] 要合成的文本 + /// [fileName] 保存的文件名(不包含路径和扩展名) + /// + /// 返回保存的音频文件路径,如果失败则返回 null + Future synthesizeToFile(String text, String fileName) async { + _checkInitialized(); + + if (text.isEmpty) { + Logger.warning('合成文本为空'); + return null; + } + + try { + // 获取临时目录 + final tempDir = await getTemporaryDirectory(); + final filePath = '${tempDir.path}/$fileName.wav'; + + // 设置状态 + _ttsState.value = TtsState.synthesizing; + + final result = await _methodChannel.invokeMethod( + 'synthesizeToFile', + { + 'text': text, + 'filePath': filePath, + }, + ); + + // 合成完成后设置状态 + _ttsState.value = TtsState.idle; + + return result; + } catch (e) { + Logger.error('合成文本到文件失败', e); + + // 发生错误时设置状态 + _ttsState.value = TtsState.idle; + + return null; + } + } + + /// 合成文本并直接播放 + /// + /// [text] 要合成并播放的文本 + /// + /// 返回是否成功开始播放 + Future speak(String text) async { + _checkInitialized(); + + if (text.isEmpty) { + Logger.warning('播放文本为空'); + return false; + } + + try { + // 设置状态 + _ttsState.value = TtsState.synthesizing; + + final result = await _methodChannel.invokeMethod( + 'speak', + {'text': text}, + ); + + if (result == true) { + _ttsState.value = TtsState.playing; + } else { + _ttsState.value = TtsState.idle; + } + + return result ?? false; + } catch (e) { + Logger.error('合成并播放文本失败', e); + + // 发生错误时设置状态 + _ttsState.value = TtsState.idle; + + return false; + } + } + + /// 停止播放 + Future stop() async { + _checkInitialized(); + + try { + final result = await _methodChannel.invokeMethod('stop'); + + if (result == true) { + _ttsState.value = TtsState.stopped; + } + + return result ?? false; + } catch (e) { + Logger.error('停止播放失败', e); + return false; + } + } + + /// 暂停播放 + Future pause() async { + _checkInitialized(); + + try { + final result = await _methodChannel.invokeMethod('pause'); + return result ?? false; + } catch (e) { + Logger.error('暂停播放失败', e); + return false; + } + } + + /// 恢复播放 + Future resume() async { + _checkInitialized(); + + try { + final result = await _methodChannel.invokeMethod('resume'); + return result ?? false; + } catch (e) { + Logger.error('恢复播放失败', e); + return false; + } + } + + /// 检查是否已初始化 + void _checkInitialized() { + if (!_isInitialized) { + throw StateError('Azure 文本转语音服务未初始化'); + } + } + + /// 释放资源 + Future dispose() async { + try { + await _eventSubscription?.cancel(); + _eventSubscription = null; + + // 只有在控制器存在且未关闭时才关闭 + if (_eventStreamController != null && !_eventStreamController!.isClosed) { + await _eventStreamController?.close(); + } + _eventStreamController = null; + + _synthesisStream = null; + + if (_isInitialized) { + 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 49e9ce711..3e91c1c2c 100644 --- a/lib/data/services/background_agent_service.dart +++ b/lib/data/services/background_agent_service.dart @@ -6,15 +6,29 @@ import '../providers/agent_provider.dart'; import '../models/agent.dart'; import '../../modules/chat/controllers/chat_controller.dart'; import '../../modules/chat/models/message_model.dart'; -import 'azure_asr_service.dart'; +import 'volcano_asr_api_service.dart'; import 'volcano_ai_service.dart'; import 'volcano_tts_api_service.dart'; import 'chat_history_service.dart'; /// 后台代理服务,用于在应用处于后台时处理AI交互 class BackgroundAgentService extends GetxService { + // 单例实例 + static BackgroundAgentService? _instance; + + // 单例工厂方法 + factory BackgroundAgentService() { + _instance ??= BackgroundAgentService._internal(); + return _instance!; + } + + // 私有构造函数 + BackgroundAgentService._internal() { + Logger.info('BackgroundAgentService 已创建'); + } + // 服务实例 - final AzureAsrService _asrService = Get.find(); + final VolcanoAsrApiService _asrService = Get.find(); final VolcanoAIService _aiService = Get.find(); final VolcanoTtsApiService _ttsService = Get.find(); final ChatHistoryService _historyService = Get.find(); @@ -61,11 +75,6 @@ class BackgroundAgentService extends GetxService { // 本地消息历史备份 final List> _localMessageHistory = []; - // 构造函数 - BackgroundAgentService() { - Logger.info('BackgroundAgentService 已创建'); - } - // TTS是否正在播放,直接从VolcanoTtsApiService获取 bool get isTtsSpeaking => _ttsService.isPlaying; @@ -82,7 +91,7 @@ class BackgroundAgentService extends GetxService { // 停止语音识别 if (isListening.value) { - _asrService.stopContinuousRecognition(); + _asrService.stopRecognition(); } // 取消定时器 @@ -133,38 +142,44 @@ class BackgroundAgentService extends GetxService { } } - /// 启动语音识别 + /// 停止语音识别 + Future _stopVoiceRecognition() async { + if (isListening.value) { + isListening.value = false; + + // 取消订阅 + await _recognitionSubscription?.cancel(); + _recognitionSubscription = null; + + // 停止语音识别 + _asrService.stopRecognition(); + + // 重置状态 + recognizedText.value = ''; + } + } + + /// 开始语音识别 Future _startVoiceRecognition() async { if (isListening.value) { - // 如果已经在监听,不需要重新启动 - Logger.info('语音识别已经在运行,无需重新启动'); return; } try { // 启动语音识别 - final success = await _asrService.startContinuousRecognition(); + final success = await _asrService.startRecognition(); if (success) { isListening.value = true; - recognizedText.value = ''; - // 监听识别事件 - _recognitionSubscription = _asrService.recognitionStream?.listen( + // 监听识别结果 + _recognitionSubscription = _asrService.recognitionStream.listen( (event) { - // 重置无活动定时器 - _resetInactivityTimer(); - switch (event.type) { case RecognitionEventType.recognizing: // 实时更新识别文本 if (event.text.isNotEmpty) { recognizedText.value = event.text; - - // 如果正在播放TTS,检测到用户说话则中断当前播放和AI回复 - if (_ttsService.isPlaying && event.text.trim().length > 3) { - _interruptCurrentResponse("检测到用户说话,中断当前响应"); - } } break; case RecognitionEventType.finalResult: @@ -175,32 +190,34 @@ class BackgroundAgentService extends GetxService { // 处理识别到的文本 _processRecognizedText(event.text); - // 重置识别文本但保持识别状态 + // 重置识别文本 recognizedText.value = ''; } break; - case RecognitionEventType.sessionStopped: - case RecognitionEventType.canceled: - // 会话结束,表示识别在其它地方被启动,直接退出后台服务 - isListening.value = false; - Logger.info('语音识别会话被中止,退出后台代理服务'); + case RecognitionEventType.error: + // 错误处理 + Logger.info('语音识别错误: ${event.error}'); + + // 不再尝试重新启动语音识别,而是直接停止交互 + Logger.info('语音识别出错,停止后台代理交互'); stopInteraction(); break; + case RecognitionEventType.started: default: + // 不做任何处理 break; } }, onError: (error) { - Logger.error('语音识别错误', error); - isListening.value = false; + Logger.info('语音识别错误:$error'); - // 如果出错,尝试重新启动语音识别 - _restartVoiceRecognition(); + // 不再尝试重新启动语音识别,而是直接停止交互 + Logger.info('语音识别流错误,停止后台代理交互'); + stopInteraction(); }, ); } else { Logger.error('启动语音识别失败'); - isProcessing.value = false; } } catch (e) { Logger.error('启动语音识别失败', e); @@ -278,7 +295,7 @@ class BackgroundAgentService extends GetxService { Future _restartVoiceRecognition() async { // 停止当前的语音识别 if (isListening.value) { - await _asrService.stopContinuousRecognition(); + await _asrService.stopRecognition(); isListening.value = false; // 取消当前的订阅 @@ -343,7 +360,6 @@ class BackgroundAgentService extends GetxService { 'content': message.content, }); } - Logger.info('已加载${messages.length}条聊天历史记录,代理: ${_currentAgent!.id}'); } } catch (e) { Logger.error('加载聊天历史记录失败', e); @@ -438,10 +454,9 @@ class BackgroundAgentService extends GetxService { } // 停止语音识别 - // if (isListening.value) { - // await _asrService.stopContinuousRecognition(); - // isListening.value = false; - // } + if (isListening.value) { + await _stopVoiceRecognition(); + } // 取消定时器 _inactivityTimer?.cancel(); diff --git a/lib/data/services/chat_history_service.dart b/lib/data/services/chat_history_service.dart index 181f73851..3f47c4bd5 100644 --- a/lib/data/services/chat_history_service.dart +++ b/lib/data/services/chat_history_service.dart @@ -39,9 +39,7 @@ class ChatHistoryService extends GetxService { final jsonList = messagesToSave.map((m) => m.toJson()).toList(); final jsonString = jsonEncode(jsonList); - // 保存到存储 - await _storage.write(_getHistoryKey(agentId), jsonString); - Logger.info('已保存${messagesToSave.length}条聊天记录,代理: $agentId'); + } catch (e) { Logger.error('保存聊天记录失败', e); } @@ -62,7 +60,6 @@ class ChatHistoryService extends GetxService { final jsonList = jsonDecode(jsonString) as List; final messages = jsonList.map((json) => Message.fromJson(json)).toList(); - Logger.info('已加载${messages.length}条聊天记录,代理: $agentId'); return messages; } catch (e) { Logger.error('加载聊天记录失败', e); diff --git a/lib/data/services/flutter_asr_service.dart b/lib/data/services/flutter_asr_service.dart new file mode 100644 index 000000000..d4cdd8ca7 --- /dev/null +++ b/lib/data/services/flutter_asr_service.dart @@ -0,0 +1,236 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:speech_to_text/speech_to_text.dart'; +import 'package:speech_to_text/speech_recognition_result.dart'; +import '../../core/utils/logger.dart'; + +/// ASR状态枚举 +enum AsrState { + notInitialized, // 未初始化 + initialized, // 已初始化 + listening, // 正在监听 + notListening, // 未监听 + error // 错误状态 +} + +/// Flutter ASR服务,负责本地语音转文本功能 +class FlutterAsrService extends GetxService { + // 语音识别实例 + late SpeechToText _speech; + + // 当前ASR状态 + final Rx _asrState = AsrState.notInitialized.obs; + AsrState get asrState => _asrState.value; + + // 可用的语音识别语言 + final RxList _locales = [].obs; + List get locales => _locales; + + // 当前选择的语言 + final RxString _currentLocale = ''.obs; + String get currentLocale => _currentLocale.value; + + // 识别结果 + final RxString _lastWords = ''.obs; + String get lastWords => _lastWords.value; + + // 识别准确度 + final RxDouble _confidence = 0.0.obs; + double get confidence => _confidence.value; + + // 是否可用 + final RxBool _isAvailable = false.obs; + bool get isAvailable => _isAvailable.value; + + // 是否正在监听 + bool get isListening => _speech.isListening; + + // 识别结果流 + final _recognitionResultsController = StreamController.broadcast(); + Stream get recognitionResults => _recognitionResultsController.stream; + + @override + void onInit() { + super.onInit(); + _initSpeech(); + } + + /// 初始化语音识别 + Future _initSpeech() async { + _speech = SpeechToText(); + + try { + final available = await _speech.initialize( + onStatus: _onStatusChanged, + onError: _onErrorListener, + debugLogging: kDebugMode, + ); + + _isAvailable.value = available; + + if (available) { + _asrState.value = AsrState.initialized; + + // 获取可用的语言 + final locales = await _speech.locales(); + _locales.assignAll(locales); + + // 默认选择系统语言,如果支持的话 + final systemLocale = await _speech.systemLocale(); + if (systemLocale != null) { + _currentLocale.value = systemLocale.localeId; + } else if (_locales.isNotEmpty) { + _currentLocale.value = _locales.first.localeId; + } + + Logger.info('语音识别初始化成功,可用语言数量: ${_locales.length}'); + } else { + _asrState.value = AsrState.error; + Logger.error('语音识别不可用'); + } + } catch (e) { + _asrState.value = AsrState.error; + _isAvailable.value = false; + Logger.error('语音识别初始化失败: $e'); + } + } + + /// 开始监听 + /// + /// [listenFor] 监听时长,默认30秒 + /// [pauseFor] 暂停时长,默认3秒,超过此时长无语音输入会自动停止 + /// [partialResults] 是否返回部分结果,默认true + /// [onDevice] 是否使用设备本地识别,默认false + /// [listenMode] 监听模式,默认设备默认模式 + Future startListening({ + Duration listenFor = const Duration(seconds: 30), + Duration pauseFor = const Duration(seconds: 3), + bool partialResults = true, + bool onDevice = false, + ListenMode listenMode = ListenMode.deviceDefault, + }) async { + if (!_isAvailable.value) { + Logger.error('语音识别不可用,无法开始监听'); + return false; + } + + // 清空上次的识别结果 + _lastWords.value = ''; + _confidence.value = 0.0; + + try { + final started = await _speech.listen( + onResult: _onSpeechResult, + listenFor: listenFor, + pauseFor: pauseFor, + localeId: _currentLocale.value, + listenOptions: SpeechListenOptions( + partialResults: partialResults, + onDevice: onDevice, + listenMode: listenMode, + ), + ); + + if (started) { + _asrState.value = AsrState.listening; + Logger.info('开始语音识别监听'); + } else { + Logger.error('开始语音识别监听失败'); + } + + return started; + } catch (e) { + Logger.error('开始语音识别监听出错: $e'); + return false; + } + } + + /// 停止监听 + Future stopListening() async { + if (!_speech.isListening) return; + + try { + await _speech.stop(); + _asrState.value = AsrState.notListening; + Logger.info('停止语音识别监听'); + } catch (e) { + Logger.error('停止语音识别监听出错: $e'); + } + } + + /// 取消监听 + Future cancelListening() async { + if (!_speech.isListening) return; + + try { + await _speech.cancel(); + _lastWords.value = ''; + _confidence.value = 0.0; + _asrState.value = AsrState.notListening; + Logger.info('取消语音识别监听'); + } catch (e) { + Logger.error('取消语音识别监听出错: $e'); + } + } + + /// 设置当前语言 + void setLocale(String localeId) { + if (_currentLocale.value != localeId) { + _currentLocale.value = localeId; + Logger.info('设置语音识别语言: $localeId'); + } + } + + /// 语音识别结果回调 + void _onSpeechResult(SpeechRecognitionResult result) { + final resultText = result.recognizedWords; + _lastWords.value = resultText; + + if (result.finalResult) { + // 计算置信度 - 仅在最终结果时有效 + if (result.alternates.isNotEmpty) { + _confidence.value = result.alternates.first.confidence; + } + + // 发送最终结果到流 + _recognitionResultsController.add(resultText); + + Logger.info('语音识别最终结果: "$resultText", 置信度: ${_confidence.value}'); + _asrState.value = AsrState.notListening; + } else { + // 部分结果 + Logger.info('语音识别部分结果: "$resultText"'); + } + } + + /// 状态变化回调 + void _onStatusChanged(String status) { + Logger.info('语音识别状态变化: $status'); + + switch (status) { + case 'listening': + _asrState.value = AsrState.listening; + break; + case 'notListening': + _asrState.value = AsrState.notListening; + break; + case 'done': + _asrState.value = AsrState.notListening; + break; + } + } + + /// 错误回调 + void _onErrorListener(dynamic error) { + Logger.error('语音识别错误: ${error.errorMsg}'); + _asrState.value = AsrState.error; + } + + @override + void onClose() { + _speech.cancel(); + _recognitionResultsController.close(); + super.onClose(); + } +} \ No newline at end of file diff --git a/lib/data/services/flutter_tts_service.dart b/lib/data/services/flutter_tts_service.dart new file mode 100644 index 000000000..87eaded74 --- /dev/null +++ b/lib/data/services/flutter_tts_service.dart @@ -0,0 +1,364 @@ +import 'dart:io'; +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:get/get.dart'; +import '../../core/utils/logger.dart'; + +/// Flutter TTS服务,负责本地文本转语音功能 +class FlutterTtsService extends GetxService { + late FlutterTts _flutterTts; + + // 当前TTS状态 + TtsState _ttsState = TtsState.stopped; + TtsState get ttsState => _ttsState; + + // 可用的语音 + List> _voices = []; + List> get voices => _voices; + + // 当前选择的语音 + String _currentVoice = ''; + String get currentVoice => _currentVoice; + set currentVoice(String voice) => _currentVoice = voice; + + // 语速 (0.1-2.0) + double _rate = 0.5; + double get rate => _rate; + set rate(double value) => _rate = value; + + // 音量 (0.0-1.0) + double _volume = 1.0; + double get volume => _volume; + set volume(double value) => _volume = value; + + // 音调 (0.5-2.0) + double _pitch = 1.0; + double get pitch => _pitch; + set pitch(double value) => _pitch = value; + + // 文本队列 - 仅包含待播放的文本 + List _textQueue = []; + List get textQueue => _textQueue; + + // 当前正在播放的文本 + String _currentText = ''; + String get currentText => _currentText; + + // 是否正在处理队列 + bool _isProcessingQueue = false; + + @override + void onInit() { + super.onInit(); + _initTts(); + } + + /// 初始化TTS引擎 + Future _initTts() async { + _flutterTts = FlutterTts(); + + // 设置完成回调 + _flutterTts.setCompletionHandler(() { + Logger.info('TTS播放完成: "$_currentText"'); + + // 清空当前播放文本 + _currentText = ''; + + // 处理队列中的下一条文本 + _processNextInQueue(); + + // 只有当队列为空且不再处理队列时才设置为stopped状态 + if (_textQueue.isEmpty && !_isProcessingQueue) { + _ttsState = TtsState.stopped; + Logger.info('TTS队列播放完成,设置为stopped状态'); + } + }); + + // 设置错误回调 + _flutterTts.setErrorHandler((error) { + Logger.error('Flutter TTS错误: $error'); + + // 清空当前播放文本 + _currentText = ''; + + // 尝试播放下一条文本 + _processNextInQueue(); + + // 只有当队列为空且不再处理队列时才设置为stopped状态 + if (_textQueue.isEmpty && !_isProcessingQueue) { + _ttsState = TtsState.stopped; + Logger.info('TTS队列播放出错且为空,设置为stopped状态'); + } + }); + + // 设置开始回调 + _flutterTts.setStartHandler(() { + _ttsState = TtsState.playing; + }); + + // 设置取消回调 + _flutterTts.setCancelHandler(() { + // 清空当前播放文本 + _currentText = ''; + _isProcessingQueue = false; + _ttsState = TtsState.stopped; + Logger.info('TTS播放被取消,设置为stopped状态'); + }); + + // 设置暂停回调 + _flutterTts.setPauseHandler(() { + _ttsState = TtsState.paused; + }); + + // 设置继续回调 + _flutterTts.setContinueHandler(() { + _ttsState = TtsState.playing; + }); + + // 加载可用语音 + await _loadVoices(); + + Logger.info('Flutter TTS服务已初始化'); + } + + /// 加载可用的语音 + Future _loadVoices() async { + try { + if (Platform.isAndroid || Platform.isIOS) { + final voices = await _flutterTts.getVoices; + if (voices != null) { + _voices = []; + for (var voice in voices) { + if (voice is Map) { + final Map voiceMap = {}; + voice.forEach((key, value) { + if (key is String && value is String) { + voiceMap[key] = value; + } else if (key is String) { + voiceMap[key] = value.toString(); + } + }); + if (voiceMap.containsKey('name')) { + _voices.add(voiceMap); + } + } + } + + // 如果有可用语音,设置第一个为默认 + if (_voices.isNotEmpty) { + _currentVoice = _voices[0]['name'] ?? ''; + await _flutterTts.setVoice({"name": _currentVoice}); + } + } + } + } catch (e) { + Logger.error('加载TTS语音失败', e); + } + } + + /// 设置语音 + Future setVoice(String voiceName) async { + try { + _currentVoice = voiceName; + await _flutterTts.setVoice({"name": voiceName}); + } catch (e) { + Logger.error('设置TTS语音失败', e); + } + } + + /// 设置语速 + Future setRate(double rate) async { + try { + _rate = rate; + await _flutterTts.setSpeechRate(rate); + } catch (e) { + Logger.error('设置TTS语速失败', e); + } + } + + /// 设置音量 + Future setVolume(double volume) async { + try { + _volume = volume; + await _flutterTts.setVolume(volume); + } catch (e) { + Logger.error('设置TTS音量失败', e); + } + } + + /// 设置音调 + Future setPitch(double pitch) async { + try { + _pitch = pitch; + await _flutterTts.setPitch(pitch); + } catch (e) { + Logger.error('设置TTS音调失败', e); + } + } + + /// 设置语言 + Future setLanguage(String language) async { + try { + await _flutterTts.setLanguage(language); + } catch (e) { + Logger.error('设置TTS语言失败', e); + } + } + + /// 添加文本到队列并开始处理队列(如果尚未开始) + Future speak(String text) async { + if (text.isEmpty) return; + + // 添加到队列 + _addToQueue(text); + + // 开始处理队列(如果尚未开始) + _startQueueProcessingIfNeeded(); + } + + /// 添加多条文本到队列并开始处理队列(如果尚未开始) + Future speakTexts(List texts) async { + if (texts.isEmpty) return; + + // 添加所有非空文本到队列 + int addedCount = 0; + for (final text in texts) { + if (text.isNotEmpty) { + _textQueue.add(text); + addedCount++; + } + } + + if (addedCount > 0) { + Logger.info('已添加$addedCount条文本到TTS队列'); + + // 开始处理队列(如果尚未开始) + _startQueueProcessingIfNeeded(); + } + } + + /// 添加文本到队列 + void _addToQueue(String text) { + if (text.isEmpty) return; + + _textQueue.add(text); + Logger.info('已添加文本到TTS队列: "${text.length > 20 ? text.substring(0, 20) + '...' : text}"'); + } + + /// 开始处理队列(如果尚未开始) + void _startQueueProcessingIfNeeded() { + // 如果队列为空,不需要处理 + if (_textQueue.isEmpty) return; + + // 如果已经在处理队列或正在播放,不需要重新开始 + if (_isProcessingQueue || _ttsState == TtsState.playing) return; + + // 开始处理队列 + _isProcessingQueue = true; + _processNextInQueue(); + } + + /// 处理队列中的下一条文本 + void _processNextInQueue() { + // 如果队列为空,结束处理但不立即设置stopped状态 + if (_textQueue.isEmpty) { + _currentText = ''; + _isProcessingQueue = false; + + // 只有当当前没有文本在播放时才设置为stopped状态 + if (_currentText.isEmpty) { + _ttsState = TtsState.stopped; + Logger.info('TTS队列为空,设置为stopped状态'); + } + return; + } + + // 获取队列中的第一条文本并播放 + _speakNextText(); + } + + /// 播放队列中的下一条文本 + Future _speakNextText() async { + if (_textQueue.isEmpty) { + _isProcessingQueue = false; + + // 只有当当前没有文本在播放时才设置为stopped状态 + if (_currentText.isEmpty) { + _ttsState = TtsState.stopped; + Logger.info('TTS队列播放完成,设置为stopped状态'); + } + return; + } + + // 获取队列中的第一条文本 + final text = _textQueue.removeAt(0); + _currentText = text; + + try { + // 设置TTS参数 + await _flutterTts.setSpeechRate(_rate); + await _flutterTts.setVolume(_volume); + await _flutterTts.setPitch(_pitch); + + if (_currentVoice.isNotEmpty) { + await _flutterTts.setVoice({"name": _currentVoice}); + } + + // 播放文本 + await _flutterTts.speak(text); + Logger.info('正在播放文本,队列中还有${_textQueue.length}条待播放'); + } catch (e) { + Logger.error('TTS播放文本失败', e); + // 清空当前文本 + _currentText = ''; + // 尝试播放下一个 + _processNextInQueue(); + } + } + + /// 停止播放并清空队列 + Future stop() async { + try { + await _flutterTts.stop(); + + // 重置队列处理状态 + _textQueue.clear(); + _currentText = ''; + _isProcessingQueue = false; + _ttsState = TtsState.stopped; + + Logger.info('已停止TTS播放并清空队列'); + } catch (e) { + Logger.error('停止TTS播放失败', e); + } + } + + /// 暂停播放 + Future pause() async { + try { + await _flutterTts.pause(); + _ttsState = TtsState.paused; + } catch (e) { + Logger.error('暂停TTS播放失败', e); + } + } + + /// 清空队列并停止播放 + Future clearQueue() async { + _textQueue.clear(); + await stop(); + Logger.info('已清空TTS队列'); + } + + @override + void onClose() { + _flutterTts.stop(); + super.onClose(); + } +} + +/// TTS状态枚举 +enum TtsState { + playing, + stopped, + paused, +} \ No newline at end of file diff --git a/lib/data/services/function_service.dart b/lib/data/services/function_service.dart new file mode 100644 index 000000000..b1e3a1e4d --- /dev/null +++ b/lib/data/services/function_service.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +/// 函数服务 +/// 仅实现各种功能函数,不包含AI交互逻辑 +class FunctionService { + /// 天气查询工具定义 + /// 返回一个可直接用于tools参数的天气查询工具定义 + static Map getWeatherToolDefinition() { + return { + 'type': 'function', + 'function': { + 'name': 'get_current_weather', + 'description': '获取指定地点的当前天气信息', + 'parameters': { + 'type': 'object', + 'properties': { + 'location': { + 'type': 'string', + 'description': '地理位置,例如:北京市', + }, + 'unit': { + 'type': 'string', + 'description': '温度单位', + 'enum': ['celsius', 'fahrenheit'], + }, + }, + 'required': ['location'], + }, + }, + }; + } + + /// 模拟天气API实现 + /// 返回一个模拟的天气信息,实际应用中应该调用真实的天气API + static Future> getWeatherInfo(Map args) async { + final location = args['location'] ?? '未知地点'; + final unit = args['unit'] ?? 'celsius'; + + // 在实际应用中,这里应该调用真实的天气API + return { + 'location': location, + 'temperature': unit == 'celsius' ? '25°C' : '77°F', + 'conditions': '晴转多云', + 'humidity': '55%', + 'wind': '西南风 3级', + 'date': DateTime.now().toString().split(' ')[0], + }; + } + + /// 解析函数调用参数 + /// 从AI响应中提取函数调用参数 + static Map parseFunctionCallArguments(Map functionCallResponse) { + if (functionCallResponse['tool_calls'] == null || + functionCallResponse['tool_calls'].isEmpty) { + throw FunctionServiceException('No tool calls found in response'); + } + + final toolCall = functionCallResponse['tool_calls'][0]; + if (toolCall['function'] == null || toolCall['function']['arguments'] == null) { + throw FunctionServiceException('Invalid function call format'); + } + + try { + return json.decode(toolCall['function']['arguments']); + } catch (e) { + throw FunctionServiceException('Failed to parse function arguments: $e'); + } + } + + /// 获取函数调用ID和名称 + /// 用于构建工具响应消息 + static Map getFunctionCallInfo(Map functionCallResponse) { + if (functionCallResponse['tool_calls'] == null || + functionCallResponse['tool_calls'].isEmpty) { + throw FunctionServiceException('No tool calls found in response'); + } + + final toolCall = functionCallResponse['tool_calls'][0]; + if (toolCall['id'] == null || toolCall['function'] == null || toolCall['function']['name'] == null) { + throw FunctionServiceException('Invalid function call format'); + } + + return { + 'id': toolCall['id'], + 'name': toolCall['function']['name'], + }; + } +} + +/// 函数调用服务异常类 +class FunctionServiceException implements Exception { + final String message; + FunctionServiceException(this.message); + + @override + String toString() => message; +} \ No newline at end of file diff --git a/lib/data/services/my_audio_handler.dart b/lib/data/services/my_audio_handler.dart index 9275095eb..e4eb11ddc 100644 --- a/lib/data/services/my_audio_handler.dart +++ b/lib/data/services/my_audio_handler.dart @@ -132,17 +132,14 @@ class MyAudioHandler extends BaseAudioHandler { updatePosition: Duration.zero, )); - // 如果状态变为播放,确保前台服务启动 - if (playing) { - Logger.info('更新播放状态为播放中,确保前台服务启动'); - } + } catch (e) { Logger.error('更新播放状态失败', e); } } Future _handleInteraction() async { - Logger.info('处理媒体按钮交互'); + // Logger.info('处理媒体按钮交互'); // 防止重复处理 if (_isHandlingInteraction) { diff --git a/lib/data/services/volcano_asr_api_service.dart b/lib/data/services/volcano_asr_api_service.dart new file mode 100644 index 000000000..f62252bb6 --- /dev/null +++ b/lib/data/services/volcano_asr_api_service.dart @@ -0,0 +1,1003 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:get/get.dart'; +import 'package:uuid/uuid.dart'; +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import '../../core/utils/logger.dart'; +import 'package:record/record.dart'; + +/// 识别事件类型 +enum RecognitionEventType { + started, + recognizing, + finalResult, + error, +} + +/// 识别事件 +class RecognitionEvent { + final RecognitionEventType type; + final String text; + final String? error; + + RecognitionEvent({ + required this.type, + this.text = '', + this.error, + }); +} + +/// 火山语音识别API服务 +/// +/// 该服务提供了通过WebSocket与火山语音大模型流式识别API交互的接口 +class VolcanoAsrApiService extends GetxService { + // 协议常量 + static const int _protocolVersion = 0x01; + static const int _defaultHeaderSize = 0x01; + + // 消息类型 + static const int _fullClientRequest = 0x01; + static const int _audioOnlyRequest = 0x02; + static const int _fullServerResponse = 0x09; + static const int _serverAck = 0x0B; + static const int _serverErrorResponse = 0x0F; + + // 消息类型特定标志 + static const int _noSequence = 0x00; + static const int _posSequence = 0x01; + static const int _negSequence = 0x02; + static const int _negWithSequence = 0x03; + + // 消息序列化方法 + static const int _noSerialization = 0x00; + static const int _json = 0x01; + + // 消息压缩方法 + static const int _noCompression = 0x00; + static const int _gzip = 0x01; + + // 配置参数 + late final String _appId; + late final String _token; + late final String _resourceId; + late final String _apiUrl; + + // WebSocket连接 + WebSocketChannel? _webSocketChannel; + + // 状态变量 + final _isListening = false.obs; + final _isConnected = false.obs; + final _errorMessage = ''.obs; + final _recognitionResults = [].obs; + + // 序列号 + int _sequence = 0; + + // 识别结果流控制器 + final _recognitionStreamController = StreamController.broadcast(); + + // 最新的识别结果 + final _latestRecognizedText = ''.obs; + String get latestRecognizedText => _latestRecognizedText.value; + + // 获取可观察状态 + RxBool get isListening => _isListening; + bool get isConnected => _isConnected.value; + String get errorMessage => _errorMessage.value; + List get recognitionResults => _recognitionResults; + + // 识别结果流 + Stream get recognitionStream => _recognitionStreamController.stream; + + // 音频录制相关 + final _audioRecorder = AudioRecorder(); + final _isRecording = false.obs; + bool get isRecording => _isRecording.value; + + // 音频数据缓冲区 + final List _audioBuffer = []; + StreamSubscription? _audioStreamSubscription; + + // 音频发送控制 + DateTime _lastAudioSendTime = DateTime.now(); + static const Duration _minAudioSendInterval = Duration(milliseconds: 100); + static const int _minAudioBufferSize = 3200; // 约200ms的16kHz 16bit音频 + + // 音频录制配置 + static const int _defaultSampleRate = 16000; + static const int _defaultBitsPerSample = 16; + static const int _defaultChannels = 1; + + VolcanoAsrApiService() { + // 从环境变量获取配置 + _appId = dotenv.env['VOLCANO_ASR_APP_ID'] ?? ''; + _token = dotenv.env['VOLCANO_ASR_APP_TOKEN'] ?? ''; + _resourceId = dotenv.env['VOLCANO_ASR_RESOURCE_ID'] ?? 'volc.bigasr.sauc.duration'; + _apiUrl = dotenv.env['VOLCANO_ASR_API_URL'] ?? 'wss://openspeech.bytedance.com/api/v3/sauc/bigmodel'; + + Logger.info('火山语音识别API配置: APP_ID=${_appId.isNotEmpty ? "已设置" : "未设置"}, APP_TOKEN=${_token.isNotEmpty ? "已设置" : "未设置"}'); + Logger.info('资源ID: $_resourceId'); + Logger.info('API URL: $_apiUrl'); + + if (_appId.isEmpty || _token.isEmpty) { + _errorMessage.value = '火山语音识别API配置不完整,请检查环境变量'; + Logger.error(_errorMessage.value); + } + } + + @override + void onInit() { + super.onInit(); + } + + /// 连接到WebSocket服务器 + Future connect({ + required int sampleRate, + required int bitsPerSample, + required int channels, + String format = 'pcm', + String codec = 'raw', + String modelName = 'bigmodel', + bool enablePunc = true, + }) async { + if (_isConnected.value) { + Logger.warning('已经连接到WebSocket服务器'); + return true; + } + + if (_appId.isEmpty || _token.isEmpty) { + _errorMessage.value = '火山语音识别API配置不完整,请检查环境变量'; + Logger.error(_errorMessage.value); + return false; + } + Logger.info('开始连接到ASR WebSocket服务器'); + try { + _errorMessage.value = ''; + _recognitionResults.clear(); + _sequence = 0; + + // 构建请求头 + final Map headers = { + 'X-Api-App-Key': _appId, + 'X-Api-Access-Key': _token, + 'X-Api-Resource-Id': _resourceId, + 'X-Api-Connect-Id': const Uuid().v4(), + }; + + Logger.info('连接到WebSocket服务器: $_apiUrl'); + + // 创建WebSocket连接 + if (kIsWeb) { + // Web平台不支持在连接时添加headers,需要使用其他方式 + // 可以考虑将认证信息添加到URL中 + final Uri uri = Uri.parse(_apiUrl).replace( + queryParameters: { + 'app_key': _appId, + 'access_key': _token, + 'resource_id': _resourceId, + 'connect_id': const Uuid().v4(), + }, + ); + Logger.info('Web平台连接URL: ${uri.toString()}'); + _webSocketChannel = WebSocketChannel.connect(uri); + } else { + // 非Web平台使用IOWebSocketChannel + _webSocketChannel = IOWebSocketChannel.connect( + Uri.parse(_apiUrl), + headers: headers, + pingInterval: const Duration(seconds: 10), // 添加ping间隔,保持连接活跃 + ); + } + + // 监听WebSocket消息 + _webSocketChannel!.stream.listen( + _handleWebSocketMessage, + onError: _handleWebSocketError, + onDone: _handleWebSocketDone, + ); + + // 发送全客户端请求 + await _sendFullClientRequest( + sampleRate: sampleRate, + bitsPerSample: bitsPerSample, + channels: channels, + format: format, + codec: codec, + modelName: modelName, + enablePunc: enablePunc, + ); + + _isConnected.value = true; + _isListening.value = true; + + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.started, + text: '', + )); + + Logger.info('ASR WebSocket连接成功建立'); + + return true; + } catch (e) { + _errorMessage.value = '连接WebSocket服务器失败: $e'; + Logger.error('连接WebSocket服务器失败: $e'); + _cleanupWebSocket(); + return false; + } + } + + /// 发送全客户端请求 + Future _sendFullClientRequest({ + required int sampleRate, + required int bitsPerSample, + required int channels, + required String format, + required String codec, + required String modelName, + required bool enablePunc, + }) async { + // 构建payload + final Map payload = { + 'user': { + 'uid': 'user_${DateTime.now().millisecondsSinceEpoch}', + }, + 'audio': { + 'format': format, + 'sample_rate': sampleRate, + 'bits': bitsPerSample, + 'channel': channels, + 'codec': codec, + }, + 'request': { + 'model_name': modelName, + 'enable_punc': enablePunc, + 'show_utterances': true, // 启用分句信息,用于区分中间结果和最终结果 + 'result_type': 'all', // 返回所有分句结果 + 'enable_words': true, // 启用词级别时间戳 + 'enable_itn': false, // 启用数字转换 + 'continuous_decoding': true, // 启用连续解码,支持连续监听 + 'vad_silence_end': 1000, // 设置静音结束阈值,单位为毫秒,用于判断一句话的结束 + 'vad_max_duration': 60000, // 设置最大语音段时长,单位为毫秒 + }, + }; + + // 序列化并压缩payload + final String payloadStr = jsonEncode(payload); + Logger.info('发送全客户端请求: $payloadStr'); + final List payloadBytes = await _gzipCompress(utf8.encode(payloadStr)); + + // 增加序列号 + _sequence = 1; + + // 构建消息 + final List message = _buildMessage( + messageType: _fullClientRequest, + messageTypeSpecificFlags: _posSequence, + serializationMethod: _json, + compressionType: _gzip, + sequence: _sequence, + payload: payloadBytes, + ); + + // 发送消息 + _webSocketChannel!.sink.add(Uint8List.fromList(message)); + } + + /// 发送音频数据 + Future sendAudioData(List audioData, {bool isLast = false}) async { + if (!_isConnected.value || _webSocketChannel == null) { + return false; + } + + try { + // 如果是最后一个包,直接发送 + if (isLast) { + // 先发送缓冲区中的数据 + if (_audioBuffer.isNotEmpty) { + await _sendAudioDataInternal(_audioBuffer, false); + _audioBuffer.clear(); + } + + // 发送最后一个包 + return await _sendAudioDataInternal([0, 0, 0, 0, 0, 0, 0, 0], true); + } + + // 将新的音频数据添加到缓冲区 + _audioBuffer.addAll(audioData); + + // 检查是否应该发送缓冲区中的数据 + final now = DateTime.now(); + final timeSinceLastSend = now.difference(_lastAudioSendTime); + + if (_audioBuffer.length >= _minAudioBufferSize || timeSinceLastSend >= _minAudioSendInterval) { + // 创建缓冲区的副本并清空缓冲区 + final bufferToSend = List.from(_audioBuffer); + _audioBuffer.clear(); + + // 更新最后发送时间 + _lastAudioSendTime = now; + + // 发送缓冲区中的数据 + return await _sendAudioDataInternal(bufferToSend, false); + } + + return true; + } catch (e) { + Logger.error('发送音频数据失败: $e'); + return false; + } + } + + /// 内部方法:实际发送音频数据 + Future _sendAudioDataInternal(List audioData, bool isLast) async { + if (!_isConnected.value || _webSocketChannel == null) { + return false; + } + + try { + // 增加序列号 + _sequence++; + + // 如果是最后一个音频包,使用负序列号 + final int sequence = isLast ? -_sequence : _sequence; + final int messageTypeSpecificFlags = isLast ? _negWithSequence : _posSequence; + + // 压缩音频数据 + List compressedAudio; + if (audioData.isEmpty && isLast) { + // 对于最后一个空包,使用一个有效的GZIP数据 + compressedAudio = [31, 139, 8, 0, 0, 0, 0, 0, 0, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + } else { + compressedAudio = await _gzipCompress(audioData); + } + + // 构建消息 + final List message = _buildMessage( + messageType: _audioOnlyRequest, + messageTypeSpecificFlags: messageTypeSpecificFlags, + serializationMethod: _noSerialization, + compressionType: _gzip, + sequence: sequence, + payload: compressedAudio, + ); + + // 发送消息 + _webSocketChannel!.sink.add(Uint8List.fromList(message)); + + + return true; + } catch (e) { + Logger.error('发送音频数据失败: $e'); + return false; + } + } + + /// 构建消息 + List _buildMessage({ + required int messageType, + required int messageTypeSpecificFlags, + required int serializationMethod, + required int compressionType, + required int sequence, + required List payload, + }) { + // 构建头部 + final List header = [ + (_protocolVersion << 4) | _defaultHeaderSize, + (messageType << 4) | messageTypeSpecificFlags, + (serializationMethod << 4) | compressionType, + 0, // 保留字段 + ]; + + // 序列号 + final List sequenceBytes = _intToBytes(sequence); + + // payload大小 + final List payloadSizeBytes = _intToBytes(payload.length); + + // 组装消息 + final List message = [ + ...header, + ...sequenceBytes, + ...payloadSizeBytes, + ...payload, + ]; + + return message; + } + + /// 处理WebSocket消息 + void _handleWebSocketMessage(dynamic message) { + if (message is! List) { + // 尝试解析非二进制消息作为JSON错误 + try { + if (message is String) { + final Map errorJson = jsonDecode(message); + if (errorJson.containsKey('error')) { + final String errorMessage = errorJson['error']; + Logger.warning('收到WebSocket错误消息: $errorMessage'); + + _errorMessage.value = '服务器错误: $errorMessage'; + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.error, + error: _errorMessage.value, + )); + + // 如果是严重错误,关闭连接 + if (errorMessage.contains('decode ws request failed') || + errorMessage.contains('unable to ungzip payload')) { + _isListening.value = false; + _isConnected.value = false; + _cleanupWebSocket(); + } + + return; + } + } + } catch (e) { + // 解析失败,使用原始消息 + Logger.warning('收到非二进制WebSocket消息: $message'); + } + return; + } + + try { + final List data = message; + + // 确保消息长度足够 + if (data.length < 12) { + Logger.warning('WebSocket消息长度不足: ${data.length}字节'); + return; + } + + // 解析头部 + final int protocolVersion = (data[0] >> 4) & 0x0F; + final int headerSize = data[0] & 0x0F; + final int messageType = (data[1] >> 4) & 0x0F; + final int messageTypeSpecificFlags = data[1] & 0x0F; + final int serializationMethod = (data[2] >> 4) & 0x0F; + final int compressionType = data[2] & 0x0F; + + // 解析序列号 + final List sequenceBytes = data.sublist(4, 8); + final int sequence = _bytesToInt(sequenceBytes); + + // 解析payload大小 + final List payloadSizeBytes = data.sublist(8, 12); + final int payloadSize = _bytesToInt(payloadSizeBytes); + + // 确保payload长度正确 + if (data.length < 12 + payloadSize) { + Logger.warning('WebSocket消息payload长度不足: 预期${payloadSize}字节,实际${data.length - 12}字节'); + return; + } + + // 解析payload + final List payload = data.sublist(12, 12 + payloadSize); + + // 检查是否是最后一个包(负序列号) + final bool isLastPackage = sequence < 0; + + // 处理不同类型的消息 + if (messageType == _fullServerResponse) { + _handleFullServerResponse(payload, compressionType, isLastPackage); + } else if (messageType == _serverAck) { + _handleServerAck(payload); + } else if (messageType == _serverErrorResponse) { + _handleServerErrorResponse(sequence, payload); + } + + // 如果是最后一个包,记录日志但不断开连接 + if (isLastPackage) { + Logger.info('收到最后一个包,序列号: $sequence,继续监听'); + } + } catch (e) { + Logger.error('处理WebSocket消息失败: $e'); + + _errorMessage.value = '处理WebSocket消息失败: $e'; + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.error, + error: _errorMessage.value, + )); + } + } + + /// 处理全服务器响应 + Future _handleFullServerResponse(List payload, int compressionType, bool isLastPackage) async { + try { + String payloadStr; + + if (compressionType == _gzip) { + final List decompressed = await _gzipDecompress(payload); + payloadStr = utf8.decode(decompressed); + } else { + payloadStr = utf8.decode(payload); + } + + // 解析JSON + final Map response = jsonDecode(payloadStr); + + + + // 处理识别结果 + if (response.containsKey('result')) { + // 处理分句信息 + if (response['result'] is Map && + response['result'].containsKey('utterances') && + response['result']['utterances'] is List && + response['result']['utterances'].isNotEmpty) { + + // 遍历所有分句 + for (final utterance in response['result']['utterances']) { + final String text = utterance['text'] ?? ''; + final bool isDefinite = utterance['definite'] ?? false; + + if (text.isNotEmpty) { + if (isDefinite) { + // 当definite为true时,表示这是一个完整的句子,作为最终结果处理 + if (!_recognitionResults.contains(text)) { + _recognitionResults.add(text); + _latestRecognizedText.value = text; + + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.finalResult, + text: text, + )); + + Logger.info('添加最终识别结果: $text'); + + // 重要:在这里不要断开连接,而是继续监听下一句话 + // 但可以清空当前的中间结果状态,准备接收新的语音输入 + _latestRecognizedText.value = ''; + + // 关键修改:向服务器发送一个短暂的静音包,触发服务器端的分段 + // 这有助于确保下一次识别从新的内容开始 + if (_isConnected.value && _webSocketChannel != null && _isRecording.value) { + // 发送一个极短的静音包,不会中断录音,但会触发服务器重置状态 + sendAudioData([0, 0, 0, 0, 0, 0, 0, 0], isLast: false); + } + } + } else { + // 当definite为false时,表示这是一个中间结果 + // 只有当文本与最新的不同时才发送,避免重复 + if (text != _latestRecognizedText.value) { + _latestRecognizedText.value = text; + + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.recognizing, + text: text, + )); + + Logger.info('添加中间识别结果: $text'); + } + } + } + } + } else if (response['result'] is Map && response['result'].containsKey('text')) { + // 如果没有分句信息但有完整文本,则根据isLastPackage判断是否作为最终结果 + final String fullText = response['result']['text'] ?? ''; + + if (fullText.isNotEmpty && fullText != _latestRecognizedText.value) { + _latestRecognizedText.value = fullText; + + // 如果是最后一个包,则作为最终结果处理 + if (isLastPackage) { + if (!_recognitionResults.contains(fullText)) { + _recognitionResults.add(fullText); + + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.finalResult, + text: fullText, + )); + + Logger.info('添加最终识别结果(最后一个包): $fullText'); + + // 重要:清空当前的中间结果状态,准备接收新的语音输入 + _latestRecognizedText.value = ''; + + // 关键修改:向服务器发送一个短暂的静音包,触发服务器端的分段 + if (_isConnected.value && _webSocketChannel != null && _isRecording.value) { + // 发送一个极短的静音包,不会中断录音,但会触发服务器重置状态 + sendAudioData([0, 0, 0, 0, 0, 0, 0, 0], isLast: false); + } + } + } else { + // 否则作为中间结果处理 + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.recognizing, + text: fullText, + )); + + Logger.info('添加中间识别结果(无分句): $fullText'); + } + } + } + } + + // 如果是最后一个包,记录日志 + if (isLastPackage) { + Logger.info('收到最后一个包,继续监听'); + } + } catch (e) { + Logger.error('处理全服务器响应失败: $e'); + } + } + + /// 处理服务器确认 + void _handleServerAck(List payload) { + try { + final String payloadStr = utf8.decode(payload); + Logger.info('收到服务器确认: $payloadStr'); + } catch (e) { + Logger.error('处理服务器确认失败: $e'); + } + } + + /// 处理服务器错误响应 + void _handleServerErrorResponse(int errorCode, List payload) { + try { + final String errorMessage = utf8.decode(payload); + Logger.error('服务器错误: 错误码=$errorCode, 错误信息=$errorMessage'); + + _errorMessage.value = '服务器错误: $errorMessage (错误码: $errorCode)'; + + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.error, + error: _errorMessage.value, + )); + + // 对于任何服务器错误,主动关闭连接 + _isListening.value = false; + _isConnected.value = false; + _cleanupWebSocket(); + + // 如果正在录音,也停止录音 + if (_isRecording.value) { + stopRecognition(); + } + } catch (e) { + Logger.error('处理服务器错误响应失败: $e'); + } + } + + /// 处理最后一个包 + void _handleLastPackage() { + + // 确保最后的文本被作为最终结果发送 + if (_latestRecognizedText.value.isNotEmpty && + !_recognitionResults.contains(_latestRecognizedText.value)) { + _recognitionResults.add(_latestRecognizedText.value); + + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.finalResult, + text: _latestRecognizedText.value, + )); + + Logger.info('添加最终识别结果(最后一个包): ${_latestRecognizedText.value}'); + } + + _isListening.value = false; + _cleanupWebSocket(); + } + + /// 处理WebSocket错误 + void _handleWebSocketError(dynamic error) { + // 避免重复记录相同的错误 + if (_errorMessage.value.contains(error.toString())) { + return; + } + + // 简化错误消息处理 + final String errorStr = error.toString().toLowerCase(); + String errorMessage; + + if (errorStr.contains('not upgraded to websocket') || errorStr.contains('status code: 400')) { + errorMessage = '认证失败,请检查API密钥'; + } else if (errorStr.contains('connection refused') || errorStr.contains('failed host lookup')) { + errorMessage = '无法连接到服务器,请检查网络'; + } else if (errorStr.contains('timeout')) { + errorMessage = '连接超时'; + } else { + errorMessage = '连接错误: $error'; + } + + _errorMessage.value = errorMessage; + Logger.error('WebSocket错误: $errorMessage'); + + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.error, + error: errorMessage, + )); + + // 清理资源 + _isListening.value = false; + _isConnected.value = false; + _cleanupWebSocket(); + } + + /// 处理WebSocket关闭 + void _handleWebSocketDone() { + Logger.info('ASR WebSocket连接已关闭'); + + _isListening.value = false; + _isConnected.value = false; + _cleanupWebSocket(); + } + + /// 清理WebSocket连接 + void _cleanupWebSocket() { + if (_webSocketChannel == null) { + return; + } + + try { + // 先关闭sink,然后等待stream自然关闭 + _webSocketChannel?.sink.close(WebSocketStatus.normalClosure, '客户端主动关闭连接'); + } catch (e) { + Logger.error('关闭WebSocket连接失败: $e'); + } finally { + _webSocketChannel = null; + } + } + + /// 检查麦克风权限 + Future checkMicrophonePermission() async { + try { + final hasPermission = await _audioRecorder.hasPermission(); + return hasPermission; + } catch (e) { + Logger.error('检查麦克风权限失败: $e'); + return false; + } + } + + /// 开始录音并识别 + Future startRecognition({ + int sampleRate = _defaultSampleRate, + int bitsPerSample = _defaultBitsPerSample, + int channels = _defaultChannels, + String format = 'pcm', + String codec = 'raw', + String modelName = 'bigmodel', + bool enablePunc = true, + }) async { + try { + // 如果已经在录音,先停止 + if (_isRecording.value) { + await stopRecognition(); + // 添加短暂延迟,确保之前的会话完全关闭 + await Future.delayed(const Duration(milliseconds: 500)); + } + + // 检查麦克风权限 + final hasPermission = await checkMicrophonePermission(); + if (!hasPermission) { + _errorMessage.value = '没有麦克风权限'; + Logger.error(_errorMessage.value); + return false; + } + + // 清空识别结果 + _recognitionResults.clear(); + _latestRecognizedText.value = ''; + + // 清空音频缓冲区 + _audioBuffer.clear(); + _lastAudioSendTime = DateTime.now(); + + // 连接到ASR服务 + final connected = await connect( + sampleRate: sampleRate, + bitsPerSample: bitsPerSample, + channels: channels, + format: format, + codec: codec, + modelName: modelName, + enablePunc: enablePunc, + ); + + if (!connected) { + return false; + } + + // 配置录音 + final config = RecordConfig( + encoder: AudioEncoder.pcm16bits, + sampleRate: sampleRate, + numChannels: channels, + bitRate: bitsPerSample * sampleRate * channels, + ); + + Logger.info('开始录音,采样率: $sampleRate Hz, 位深: $bitsPerSample bits, 通道数: $channels'); + + // 开始录音流 + final stream = await _audioRecorder.startStream(config); + + _isRecording.value = true; + + // 订阅音频流 + _audioStreamSubscription = stream.listen( + (data) { + if (_isConnected.value && _isRecording.value && _webSocketChannel != null) { + sendAudioData(data.toList()); + } + }, + onError: (error) { + Logger.error('音频流错误: $error'); + _errorMessage.value = '音频流错误: $error'; + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.error, + error: _errorMessage.value, + )); + }, + onDone: () { + Logger.info('音频流结束'); + if (_isRecording.value) { + stopRecognition(); + } + }, + cancelOnError: false, + ); + + return true; + } catch (e) { + _errorMessage.value = '开始录音失败: $e'; + Logger.error(_errorMessage.value); + + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.error, + error: _errorMessage.value, + )); + + return false; + } + } + + /// 停止录音并完成识别 + Future stopRecognition() async { + if (!_isRecording.value) { + return true; + } + + try { + _isRecording.value = false; + + // 取消音频流订阅 + await _audioStreamSubscription?.cancel(); + _audioStreamSubscription = null; + + // 停止录音 + await _audioRecorder.stop(); + + // 发送最后一个音频包,标记为结束 + if (_isConnected.value && _webSocketChannel != null) { + try { + // 发送最后一个包并等待处理 + await sendAudioData([0, 0, 0, 0, 0, 0, 0, 0], isLast: true); + await Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + Logger.error('发送最后一个音频包失败: $e'); + } + + // 清空当前状态 + _latestRecognizedText.value = ''; + } + + // 断开连接 + await disconnect(); + + return true; + } catch (e) { + Logger.error('停止录音失败: $e'); + await disconnect(); + return false; + } + } + + /// 断开连接 + Future disconnect() async { + Logger.info('断开ASR连接'); + // 避免递归调用 + if (_isConnected.value && _isRecording.value) { + _isRecording.value = false; + + // 取消音频流订阅 + await _audioStreamSubscription?.cancel(); + _audioStreamSubscription = null; + + // 停止录音 + try { + await _audioRecorder.stop(); + } catch (e) { + Logger.error('停止录音失败: $e'); + } + } + + _cleanupWebSocket(); + _isConnected.value = false; + _isListening.value = false; + + // 确保在断开连接时不会有未处理的事件 + if (!_recognitionStreamController.isClosed && _recognitionStreamController.hasListener) { + try { + // 发送一个最终事件,表示连接已断开 + _recognitionStreamController.add(RecognitionEvent( + type: RecognitionEventType.error, + error: '连接已断开', + )); + } catch (e) { + Logger.error('发送断开连接事件失败: $e'); + } + } + } + + /// 整数转字节数组 + List _intToBytes(int value) { + return [ + (value >> 24) & 0xFF, + (value >> 16) & 0xFF, + (value >> 8) & 0xFF, + value & 0xFF, + ]; + } + + /// 字节数组转整数 + int _bytesToInt(List bytes) { + if (bytes.length != 4) { + throw ArgumentError('字节数组长度必须为4'); + } + + return ((bytes[0] & 0xFF) << 24) | + ((bytes[1] & 0xFF) << 16) | + ((bytes[2] & 0xFF) << 8) | + (bytes[3] & 0xFF); + } + + /// GZIP压缩 + Future> _gzipCompress(List data) async { + if (data.isEmpty) { + // 返回一个有效的空GZIP数据流 + return [31, 139, 8, 0, 0, 0, 0, 0, 0, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + } + + try { + return gzip.encode(data); + } catch (e) { + Logger.error('GZIP压缩失败: $e'); + // 返回原始数据,但这可能导致协议错误 + throw Exception('GZIP压缩失败: $e'); + } + } + + /// GZIP解压缩 + Future> _gzipDecompress(List data) async { + if (data.isEmpty) { + return []; + } + + try { + return gzip.decode(data); + } catch (e) { + Logger.error('GZIP解压缩失败: $e,数据长度: ${data.length}'); + // 无法解压缩,返回原始数据 + throw Exception('GZIP解压缩失败: $e'); + } + } + + @override + void onClose() { + stopRecognition(); + disconnect(); + _audioRecorder.dispose(); + _recognitionStreamController.close(); + super.onClose(); + } +} \ No newline at end of file diff --git a/lib/data/services/volcano_asr_service.dart b/lib/data/services/volcano_asr_service.dart deleted file mode 100644 index d7e5c1fa4..000000000 --- a/lib/data/services/volcano_asr_service.dart +++ /dev/null @@ -1,384 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; -import 'package:flutter/services.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:get/get.dart'; -import '../../core/utils/logger.dart'; - -/// 识别事件类型 -enum RecognitionEventType { - started, - recognizing, - finalResult, - error, - completed, -} - -/// 识别事件 -class RecognitionEvent { - final RecognitionEventType type; - final String text; - final String? error; - - RecognitionEvent({ - required this.type, - this.text = '', - this.error, - }); -} - -/// 火山语音识别服务 -/// -/// 该服务提供了通过平台通道与 Android 上的火山语音识别 SDK 交互的接口 -class VolcanoAsrService extends GetxService { - // 平台通道 - static const MethodChannel _channel = MethodChannel('com.example.deep_voice/volcano_asr'); - 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; - final _isInitialized = false.obs; - final _errorMessage = ''.obs; - final _recognitionResults = [].obs; - - // 是否使用标准语音识别SDK - final bool _useStandardASR = true; // 使用标准语音识别SDK (API v2) - - // 添加识别结果流控制器 - final _recognitionStreamController = StreamController.broadcast(); - - // 事件订阅 - StreamSubscription? _eventSubscription; - - // 最新的识别结果 - final _latestRecognizedText = ''.obs; - String get latestRecognizedText => _latestRecognizedText.value; - - /// 检查连续识别是否处于活动状态 - bool isContinuousRecognitionActive() { - return _isListening.value; - } - - VolcanoSrcService() { - // 从环境变量获取配置 - _appId = dotenv.env['VOLCANO_ASR_APP_ID'] ?? ''; - _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; - String get errorMessage => _errorMessage.value; - List get recognitionResults => _recognitionResults; - - // 添加识别结果流getter - Stream get recognitionStream => _recognitionStreamController.stream; - - @override - void onInit() { - super.onInit(); - _setupMethodCallHandler(); - } - - /// 设置方法通道处理器 - Future _setupMethodCallHandler() async { - _channel.setMethodCallHandler((call) async { - switch (call.method) { - case 'onRecognitionResult': - final String result = call.arguments as String; - _onRecognitionResult(result); - break; - case 'onRecognitionError': - final String error = call.arguments as String; - _errorMessage.value = error; - Logger.error('识别错误: $error'); - _recognitionStreamController.add(RecognitionEvent( - type: RecognitionEventType.error, - error: error, - )); - break; - case 'onListeningStateChanged': - final bool isListening = call.arguments as bool; - _isListening.value = isListening; - Logger.info('监听状态变化: $isListening'); - break; - } - }); - } - - /// 处理识别结果 - void _onRecognitionResult(String result) { - if (result.isNotEmpty) { - _recognitionResults.add(result); - // 将结果发送到流 - _recognitionStreamController.add(RecognitionEvent( - type: RecognitionEventType.finalResult, - text: result, - )); - Logger.info('识别结果: $result'); - } - } - - /// 初始化语音识别引擎 - Future initialize() async { - if (_isInitialized.value) return true; - - if (_appId.isEmpty || _token.isEmpty) { - _errorMessage.value = '火山语音识别配置不完整,请检查环境变量'; - Logger.error(_errorMessage.value); - return false; - } - - try { - _errorMessage.value = ''; - Logger.info('开始初始化火山语音识别服务,APP_ID: ${_appId.substring(0, math.min(3, _appId.length))}***,APP_KEY: ${_token.length > 10 ? "${_token.substring(0, 5)}..." : _token}'); - - Logger.info('使用标准语音识别SDK配置 (API v2)'); - Logger.info('集群区域: $_cluster'); - Logger.info('资源ID: $_resourceId'); - - final Map params = { - 'subscriptionKey': _appId, - 'serviceRegion': _token, - 'cluster': _cluster, - 'resourceId': _resourceId, - }; - - final bool result = await _channel.invokeMethod('initialize', params); - - if (result) { - _isInitialized.value = true; - Logger.info('火山语音识别服务初始化成功'); - } else { - _errorMessage.value = '初始化失败,请检查配置'; - Logger.error('火山语音识别服务初始化失败'); - Logger.info('请检查以下可能的问题:'); - Logger.info('1. 确保您的APP_ID和APP_KEY正确'); - Logger.info('2. 确保您的应用ID和密钥正确且有效'); - Logger.info('3. 确保您的应用已被授权使用语音识别服务'); - } - - return result; - } catch (e) { - _errorMessage.value = '初始化异常: $e'; - Logger.error('初始化火山语音识别服务异常: $e'); - Logger.info('请检查以下可能的问题:'); - Logger.info('1. 确保您的网络连接正常'); - Logger.info('2. 确保集群区域设置正确 (当前: $_cluster)'); - Logger.info('3. 确保请求参数格式正确'); - Logger.info('4. 确保Token格式正确,需要添加Bearer;前缀'); - - _isInitialized.value = false; - return false; - } - } - - /// 开始一次性识别 - Future startOneTimeRecognition() async { - if (_isListening.value) { - Logger.warning('已经在进行语音识别,请先停止当前识别'); - return false; - } - - if (!_isInitialized.value) { - try { - final bool initialized = await initialize(); - if (!initialized) { - Logger.error('语音识别服务未初始化,无法开始识别'); - _errorMessage.value = '语音识别服务未初始化,无法开始识别'; - return false; - } - } catch (e) { - Logger.error('初始化语音识别服务失败: $e'); - _errorMessage.value = '初始化语音识别服务失败: $e'; - return false; - } - } - - try { - _errorMessage.value = ''; - _recognitionResults.clear(); - - final bool result = await _channel.invokeMethod('recognizeOnce'); - _isListening.value = result; - - if (result) { - _recognitionStreamController.add(RecognitionEvent( - type: RecognitionEventType.started, - text: '', - )); - } - - return result; - } catch (e) { - Logger.error('开始一次性识别失败: $e'); - _errorMessage.value = e.toString(); - return false; - } - } - - /// 开始连续识别 - Future startContinuousRecognition() async { - - //启动前先尝试停止当前识别 - - if (_isListening.value) { - Logger.warning('已经在进行语音识别,请先停止当前识别'); - return false; - } - - if (!_isInitialized.value) { - try { - final bool initialized = await initialize(); - if (!initialized) { - Logger.error('语音识别服务未初始化,无法开始识别'); - _errorMessage.value = '语音识别服务未初始化,无法开始识别'; - return false; - } - } catch (e) { - Logger.error('初始化语音识别服务失败: $e'); - _errorMessage.value = '初始化语音识别服务失败: $e'; - return false; - } - } - - try { - _errorMessage.value = ''; - _recognitionResults.clear(); - - // 设置事件监听 - _eventSubscription = _eventChannel - .receiveBroadcastStream() - .listen(_handleNativeEvent, onError: (error) { - _errorMessage.value = error.toString(); - _isListening.value = false; - _recognitionStreamController.add(RecognitionEvent( - type: RecognitionEventType.error, - error: error.toString(), - )); - }); - - final bool result = await _channel.invokeMethod('startContinuousRecognition'); - _isListening.value = result; - - if (result) { - _recognitionStreamController.add(RecognitionEvent( - type: RecognitionEventType.started, - text: '', - )); - } - - return result; - } catch (e) { - Logger.error('开始连续识别失败: $e'); - _errorMessage.value = e.toString(); - _cleanupEventStream(); - return false; - } - } - - /// 处理来自原生端的事件 - void _handleNativeEvent(dynamic event) { - if (event is! Map) return; - - final Map eventMap = event; - final String eventType = eventMap['eventType'] as String? ?? ''; - - switch (eventType) { - case 'recognizing': - final String text = eventMap['text'] as String? ?? ''; - _recognitionStreamController.add(RecognitionEvent( - type: RecognitionEventType.recognizing, - text: text, - )); - break; - case 'finalResult': - final String text = eventMap['text'] as String? ?? ''; - _latestRecognizedText.value = text; - _recognitionResults.add(text); - _recognitionStreamController.add(RecognitionEvent( - type: RecognitionEventType.finalResult, - text: text, - )); - break; - case 'error': - final String error = eventMap['error'] as String? ?? '未知错误'; - _errorMessage.value = error; - _isListening.value = false; - _recognitionStreamController.add(RecognitionEvent( - type: RecognitionEventType.error, - error: error, - )); - break; - } - } - - /// 清理事件流 - void _cleanupEventStream() { - _eventSubscription?.cancel(); - _eventSubscription = null; - } - - /// 停止识别 - Future stopRecognition() async { - if (!_isListening.value) { - Logger.warning('当前没有进行语音识别'); - return false; - } - - try { - final bool result = await _channel.invokeMethod('stopContinuousRecognition'); - _isListening.value = !result; - - if (result) { - _cleanupEventStream(); - } - - return result; - } catch (e) { - Logger.error('停止识别失败: $e'); - _errorMessage.value = e.toString(); - return false; - } - } - - /// 清理资源 - Future dispose() async { - try { - if (_isListening.value) { - await stopRecognition(); - } - _cleanupEventStream(); - } catch (e) { - Logger.error('清理语音识别资源失败: $e'); - } - } - - @override - void onClose() { - _eventSubscription?.cancel(); - _recognitionStreamController.close(); - super.onClose(); - } -} \ No newline at end of file diff --git a/lib/data/services/volcano_tts_api_service.dart b/lib/data/services/volcano_tts_api_service.dart index 5ed89f4b9..e4dfb6195 100644 --- a/lib/data/services/volcano_tts_api_service.dart +++ b/lib/data/services/volcano_tts_api_service.dart @@ -11,9 +11,39 @@ 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'; +import '../../core/utils/logger.dart'; /// 火山语音合成双向流式API服务 +/// +/// 提供与火山语音TTS服务的WebSocket交互功能 +/// 用户需要自行管理会话状态,确保在使用完毕后调用 endSession +/// +/// 基本使用流程: +/// 1. 调用 startSession 开始会话 +/// 2. 调用 synthesize 合成文本 +/// 3. 调用 endSession 结束会话 +/// +/// 或者使用 speakSingle 一次性完成上述流程 class VolcanoTtsApiService extends GetxController { + // 单例实例 + static VolcanoTtsApiService? _instance; + + // 单例工厂方法 + factory VolcanoTtsApiService() { + _instance ??= VolcanoTtsApiService._internal(); + return _instance!; + } + + // 私有构造函数 + VolcanoTtsApiService._internal() : + _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'); + } + } + // 协议常量 static const int _protocolVersion = 0x0001; static const int _defaultHeaderSize = 0x0001; @@ -73,7 +103,6 @@ class VolcanoTtsApiService extends GetxController { // 状态控制 bool _isConnected = false; - bool _isSessionActive = false; // 音频数据控制器 final _audioDataController = StreamController.broadcast(); @@ -83,6 +112,9 @@ class VolcanoTtsApiService extends GetxController { final _eventController = StreamController>.broadcast(); Stream> get eventStream => _eventController.stream; + // 控制器状态 + bool _isDisposed = false; + // 当前状态 final isConnecting = false.obs; final isConnected = false.obs; @@ -112,16 +144,6 @@ class VolcanoTtsApiService extends GetxController { 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(); @@ -295,15 +317,15 @@ class VolcanoTtsApiService extends GetxController { /// 连接到火山语音服务 Future connect() async { if (_isConnected) { + Logger.info('TTS已连接,跳过连接'); return true; // 已经连接 } - print('连接'); - + + isConnecting.value = true; + try { - isConnecting.value = true; - + Logger.info('开始TTS连接'); - // 生成连接ID _connectionId = const Uuid().v4(); @@ -312,7 +334,6 @@ class VolcanoTtsApiService extends GetxController { if (!kIsWeb && (io.Platform.isAndroid || io.Platform.isIOS || io.Platform.isMacOS || io.Platform.isLinux || io.Platform.isWindows)) { // 移动平台和桌面平台 - 使用IOWebSocketChannel - _channel = IOWebSocketChannel.connect( uri, @@ -334,13 +355,20 @@ class VolcanoTtsApiService extends GetxController { // 监听消息 _channel!.stream.listen( _handleMessage, - onError: _handleError, + onError: (error) { + // 直接处理错误,不尝试重连 + Logger.error('WebSocket错误: $error'); + if (!_isDisposed && !_eventController.isClosed) { + _eventController.add({ + 'eventType': 'error', + 'error': error.toString() + }); + } + }, onDone: _handleDone, cancelOnError: false, ); - - // 发送开始连接事件 await _startConnection(); @@ -348,32 +376,42 @@ class VolcanoTtsApiService extends GetxController { final completer = Completer(); // 设置超时 - final timeout = Timer(const Duration(seconds: 10), () { + Timer? timeout; + timeout = Timer(const Duration(seconds: 10), () { if (!completer.isCompleted) { + Logger.error('火山TTS连接超时'); completer.complete(false); - _handleError(Exception('连接超时')); + + // 使用 try-catch 包裹错误处理,避免在计时器回调中出现未捕获的异常 + try { + if (!_isDisposed && !_eventController.isClosed) { + _eventController.add({ + 'eventType': 'connectionFailed', + 'error': '连接超时' + }); + } + } catch (e) { + Logger.error('处理TTS连接超时事件失败: $e'); + } } + timeout = null; }); - // 监听连接事件 final subscription = eventStream.listen((event) { - if (event['eventType'] == 'connectionStarted') { if (!completer.isCompleted) { - if (kDebugMode) { - print('连接成功'); - } + Logger.info('火山TTS连接成功'); completer.complete(true); - timeout.cancel(); + timeout?.cancel(); + timeout = null; } } else if (event['eventType'] == 'connectionFailed') { if (!completer.isCompleted) { - if (kDebugMode) { - print('连接失败: ${event['error']}'); - } + Logger.error('火山TTS连接失败: ${event['error']}'); completer.complete(false); - timeout.cancel(); + timeout?.cancel(); + timeout = null; } } }); @@ -385,14 +423,13 @@ class VolcanoTtsApiService extends GetxController { if (result) { _isConnected = true; isConnected.value = true; - + Logger.info('火山TTS连接成功完成'); } + return result; } catch (e) { - if (kDebugMode) { - print('连接过程中发生异常: $e'); - } - _handleError(e); + // 只记录错误,不尝试重连 + Logger.error('连接过程中发生异常: $e'); return false; } finally { isConnecting.value = false; @@ -400,18 +437,22 @@ class VolcanoTtsApiService extends GetxController { } /// 开始TTS会话 + /// + /// 用户需要自行管理会话状态,确保在使用完毕后调用 endSession + /// + /// [speaker] 发音人ID + /// + /// 返回会话是否成功开始 Future startSession(String speaker) async { - print('开始会话'); - if (!_isConnected) { - final connected = await connect(); - if (!connected) return false; - } - - if (_isSessionActive) { - return true; // 会话已经活跃 - } + Logger.info('开始TTS会话'); try { + if (!_isConnected) { + final connected = await connect(); + Logger.info('TTS未连接状态, 开始连接, 连接结果: $connected'); + if (!connected) return false; + } + // 生成会话ID _sessionId = const Uuid().v4().replaceAll('-', ''); @@ -422,24 +463,40 @@ class VolcanoTtsApiService extends GetxController { final completer = Completer(); // 设置超时 - final timeout = Timer(const Duration(seconds: 10), () { + Timer? timeout; + timeout = Timer(const Duration(seconds: 10), () { if (!completer.isCompleted) { + Logger.error('火山TTS会话开始超时'); completer.complete(false); - _handleError(Exception('会话开始超时')); + + // 使用 try-catch 包裹错误处理,避免在计时器回调中出现未捕获的异常 + try { + _eventController.add({ + 'eventType': 'sessionFailed', + 'error': '会话开始超时' + }); + } catch (e) { + Logger.error('处理TTS会话超时事件失败: $e'); + } } + timeout = null; }); // 监听会话事件 final subscription = eventStream.listen((event) { if (event['eventType'] == 'sessionStarted') { if (!completer.isCompleted) { + Logger.info('火山TTS会话开始成功'); completer.complete(true); - timeout.cancel(); + timeout?.cancel(); + timeout = null; } } else if (event['eventType'] == 'sessionFailed') { if (!completer.isCompleted) { + Logger.error('火山TTS会话开始失败: ${event['error']}'); completer.complete(false); - timeout.cancel(); + timeout?.cancel(); + timeout = null; } } }); @@ -449,29 +506,38 @@ class VolcanoTtsApiService extends GetxController { subscription.cancel(); if (result) { - _isSessionActive = true; + Logger.info('火山TTS会话已完全建立'); } return result; } catch (e) { - _handleError(e); + // 只记录错误,不尝试恢复 + Logger.error('开始TTS会话失败: $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; + if (text.isEmpty) { + Logger.warning('合成文本为空,跳过合成'); + return false; } try { + // 确保已连接 + if (!_isConnected) { + Logger.error('TTS未连接,无法合成文本'); + return false; + } + + // 确保会话已开始 + if (_sessionId == null) { + Logger.error('TTS会话未开始,请先调用startSession'); + return false; + } + + // 设置合成状态 isSynthesizing.value = true; // 发送合成请求 @@ -485,11 +551,16 @@ class VolcanoTtsApiService extends GetxController { } /// 结束会话 + /// + /// 结束当前TTS会话,释放相关资源 + /// 用户应在使用完毕后调用此方法 + /// + /// 返回会话是否成功结束 Future endSession() async { - if (!_isSessionActive) { + if (_sessionId == null) { return true; // 没有活跃会话 } - print('结束会话'); + Logger.info('TTS结束会话'); try { // 发送结束会话事件 @@ -519,7 +590,8 @@ class VolcanoTtsApiService extends GetxController { final result = await completer.future; subscription.cancel(); - _isSessionActive = false; + // 清除会话ID + _sessionId = null; isSynthesizing.value = false; return result; @@ -531,15 +603,14 @@ class VolcanoTtsApiService extends GetxController { /// 断开连接 Future disconnect() async { - - print('断开连接'); if (!_isConnected) { return true; // 已经断开 } - + print('断开TTS连接'); + try { // 如果有活跃会话,先结束会话 - if (_isSessionActive) { + if (_sessionId != null) { await endSession(); } @@ -575,7 +646,7 @@ class VolcanoTtsApiService extends GetxController { _channel = null; _isConnected = false; - _isSessionActive = false; + _sessionId = null; isConnected.value = false; isSynthesizing.value = false; @@ -605,7 +676,7 @@ class VolcanoTtsApiService extends GetxController { // 处理事件 final event = response['event'] as int?; - if (event != null) { + if (event != null && !_isDisposed && !_eventController.isClosed) { switch (event) { case eventConnectionStarted: _eventController.add({'eventType': 'connectionStarted'}); @@ -640,7 +711,10 @@ class VolcanoTtsApiService extends GetxController { if (payload != null && payload.isNotEmpty) { try { _eventController.add({'eventType': 'ttsResponse'}); - _audioDataController.add(payload); + + if (!_isDisposed && !_audioDataController.isClosed) { + _audioDataController.add(payload); + } // 将音频数据添加到流中 _addAudioData(payload); @@ -751,10 +825,12 @@ class VolcanoTtsApiService extends GetxController { } } catch (e) { // 直接报告错误,不尝试恢复 - _eventController.add({ - 'eventType': 'playbackError', - 'error': '添加音频到播放列表失败: $e' - }); + if (!_isDisposed && !_eventController.isClosed) { + _eventController.add({ + 'eventType': 'playbackError', + 'error': '添加音频到播放列表失败: $e' + }); + } } } @@ -784,35 +860,47 @@ class VolcanoTtsApiService extends GetxController { await _audioPlayer.play(); } catch (e) { // 直接报告错误,不尝试恢复 - _eventController.add({ - 'eventType': 'playbackError', - 'error': '开始播放失败: $e' - }); + if (!_isDisposed && !_eventController.isClosed) { + _eventController.add({ + 'eventType': 'playbackError', + 'error': '开始播放失败: $e' + }); + } } } /// 处理错误 void _handleError(dynamic error) { - _eventController.add({ - 'eventType': 'error', - 'error': error.toString() - }); - - if (kDebugMode) { - print('火山语音WebSocket错误: $error'); + try { + // 只记录错误,不尝试恢复 + if (!_isDisposed && !_eventController.isClosed) { + _eventController.add({ + 'eventType': 'error', + 'error': error.toString() + }); + } + + Logger.error('火山语音TTS错误: $error'); + } catch (e) { + // 如果在处理错误时发生异常,记录到日志但不再抛出异常 + Logger.error('处理TTS错误时发生异常: $e,原始错误: $error'); } } /// 处理连接关闭 void _handleDone() { + Logger.info('TTS WebSocket连接已关闭'); + + // 重置所有状态 _isConnected = false; - _isSessionActive = false; + _sessionId = null; isConnected.value = false; isSynthesizing.value = false; - _eventController.add({'eventType': 'disconnected'}); - - + // 通知监听者连接已断开 + if (!_isDisposed && !_eventController.isClosed) { + _eventController.add({'eventType': 'disconnected'}); + } } /// 解析响应 @@ -1202,9 +1290,18 @@ class VolcanoTtsApiService extends GetxController { /// 释放资源 @override void onClose() async { + _isDisposed = true; + await disconnect(); - _audioDataController.close(); - _eventController.close(); + + // 关闭控制器 + if (!_audioDataController.isClosed) { + await _audioDataController.close(); + } + + if (!_eventController.isClosed) { + await _eventController.close(); + } // 释放资源 _audioPlayer.dispose(); @@ -1223,11 +1320,6 @@ class VolcanoTtsApiService extends GetxController { // 合成文本 final success = await synthesize(text, actualSpeaker); - if (!success) { - if (kDebugMode) { - print('合成文本失败'); - } - } return success; } catch (e) { @@ -1260,6 +1352,11 @@ class VolcanoTtsApiService extends GetxController { return _isPlaying.value; } + /// 检查会话是否活跃 + bool isSessionActive() { + return _sessionId != null; + } + /// 设置音量 Future setVolume(double volume) async { try { @@ -1290,11 +1387,13 @@ class VolcanoTtsApiService extends GetxController { /// /// [text] 要合成的文本 /// [speaker] 发音人,如果为null则使用默认发音人 - /// [autoDisconnect] 合成完成后是否自动断开连接,默认为false /// /// 返回合成是否成功 Future speakSingle(String text, {String? speaker}) async { - if (text.isEmpty) return false; + if (text.isEmpty) { + Logger.warning('合成文本为空,跳过合成'); + return false; + } try { // 使用默认发音人 @@ -1302,27 +1401,37 @@ class VolcanoTtsApiService extends GetxController { // 1. 确保连接 if (!_isConnected) { + Logger.info('TTS未连接,开始连接'); final connected = await connect(); - if (!connected) return false; + if (!connected) { + Logger.error('TTS连接失败,无法合成文本'); + return false; + } } // 2. 确保会话已开始 - if (!_isSessionActive) { + final bool needNewSession = _sessionId == null; + if (needNewSession) { + Logger.info('TTS会话未开始,开始会话'); final sessionStarted = await startSession(actualSpeaker); - if (!sessionStarted) return false; + if (!sessionStarted) { + Logger.error('TTS会话开始失败,无法合成文本'); + return false; + } } // 3. 合成文本 isSynthesizing.value = true; final success = await synthesize(text, actualSpeaker); - // 不再自动断开连接,让调用者决定何时结束会话和断开连接 - await endSession(); + // 如果是我们开始的新会话,则结束会话 + if (needNewSession) { + await endSession(); + } return success; } catch (e) { _handleError(e); - return false; } } diff --git a/lib/data/services/volcano_tts_service.dart b/lib/data/services/volcano_tts_service.dart deleted file mode 100644 index 21bb902e4..000000000 --- a/lib/data/services/volcano_tts_service.dart +++ /dev/null @@ -1,239 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:collection'; -import 'package:get/get.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flutter/services.dart'; - -/// 火山语音服务异常 -class VolcanoTtsException implements Exception { - final String message; - final dynamic originalError; - - VolcanoTtsException(this.message, [this.originalError]); - - @override - String toString() => originalError != null - ? '$message (原始错误: $originalError)' - : message; -} - -/// 火山语音合成服务 (极简版) -/// -/// 直接使用原生端 VolcanoTtsHelper 的功能,不处理状态回调 -class VolcanoTtsService extends GetxService { - // 平台通道 - static const MethodChannel _channel = MethodChannel('com.example.deep_voice/volcano_tts'); - - // 配置参数 - final String _appId; - final String _token; - final String _cluster; - final String _voiceType; - - // 初始化状态 - bool _isInitialized = false; - bool _isDisposed = false; - - // 服务启用状态 - 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_TOKEN 和 VOLCANO_TTS_CLUSTER'); - } - } - - @override - Future onInit() async { - super.onInit(); - try { - await _initializeTtsEngine(); - } catch (e, stackTrace) { - print('初始化TTS服务失败: $e'); - print('Stack trace: $stackTrace'); - } - } - - /// 初始化TTS引擎 - Future _initializeTtsEngine() async { - try { - print('开始初始化火山语音TTS引擎'); - - final result = await _channel.invokeMethod('initialize', { - 'appId': _appId, - 'token': _token, - 'cluster': _cluster, - }); - - _isInitialized = result ?? false; - print('火山语音TTS引擎初始化${_isInitialized ? '成功' : '失败'}'); - - if (!_isInitialized) { - throw VolcanoTtsException('TTS引擎初始化失败'); - } - - // 设置默认音量和速度 - await setVolume(_defaultVolume); - await setSpeed(_defaultSpeed); - } catch (e) { - print('初始化火山语音TTS引擎失败: $e'); - _isInitialized = false; - throw VolcanoTtsException('初始化失败', e); - } - } - - /// 合成并播放文本 - /// - /// [text] 要合成的文本 - /// [voiceType] 可选的语音类型,如果提供,将覆盖默认语音类型 - /// - /// 返回一个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)'); - } - - // 直接调用原生方法合成并播放语音 - final success = await _channel.invokeMethod('speakText', { - 'text': text, - 'voiceType': voiceType ?? _voiceType, - }); - - return success ?? false; - } catch (e) { - print('语音合成失败: $e'); - return false; - } - } - - /// 停止播放 - Future stop() async { - if (!_isInitialized) { - print('TTS引擎尚未初始化'); - return false; - } - - try { - print('停止播放'); - - // 调用原生方法停止播放 - final success = await _channel.invokeMethod('stopSpeaking'); - - print('已停止播放'); - return success ?? false; - } catch (e) { - print('停止播放失败: $e'); - return false; - } - } - - /// 切换TTS启用状态 - void toggleEnabled() { - isEnabled.toggle(); - if (!isEnabled.value) { - stop(); - } - } - - /// 设置播放速度 - 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 { - // 设置原生播放速度 - final success = await _channel.invokeMethod('setSpeed', { - 'speed': speed, - }); - - return success ?? false; - } catch (e) { - print('设置播放速度失败: $e'); - return false; - } - } - - /// 设置音量 - 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 { - // 设置原生音量 - final success = await _channel.invokeMethod('setVolume', { - 'volume': volume, - }); - - return success ?? false; - } catch (e) { - print('设置音量失败: $e'); - return false; - } - } - - /// 清理资源 - Future _cleanupResources() async { - _isDisposed = true; - await stop(); - - // 释放原生资源 - try { - await _channel.invokeMethod('dispose'); - } catch (e) { - print('释放原生TTS资源失败: $e'); - } - } - - @override - void onClose() async { - print('关闭TTS服务'); - await _cleanupResources(); - super.onClose(); - } - - /// 获取当前语音类型 - String getCurrentVoiceType() { - return _voiceType; - } - - /// 检查是否正在播放 - bool get isPlaying => true; - - /// 检查是否正在合成(简化版,直接返回true) - bool isActuallySynthesizing() { - return true; - } - -} \ No newline at end of file diff --git a/lib/modules/chat/controllers/chat_controller.dart b/lib/modules/chat/controllers/chat_controller.dart index a91fe4c0e..f1dd1da24 100644 --- a/lib/modules/chat/controllers/chat_controller.dart +++ b/lib/modules/chat/controllers/chat_controller.dart @@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart'; import '../models/message_model.dart'; import '../../../data/services/volcano_ai_service.dart'; import '../../../data/services/volcano_tts_api_service.dart'; -import '../../../data/services/azure_asr_service.dart'; +import '../../../data/services/volcano_asr_api_service.dart'; import '../../../data/providers/agent_provider.dart'; import '../../../data/models/agent.dart'; import '../../../core/utils/logger.dart'; @@ -14,7 +14,7 @@ class ChatController extends GetxController { // 服务 final VolcanoAIService _aiService = Get.find(); final VolcanoTtsApiService _ttsService = Get.find(); - final AzureAsrService _asrService = Get.find(); + final VolcanoAsrApiService _asrService = Get.find(); final ChatHistoryService _historyService = Get.find(); // 添加ScrollController用于控制列表滚动 @@ -41,6 +41,8 @@ class ChatController extends GetxController { // 临时消息ID,用于语音识别中的消息 String? _tempRecognitionMessageId; + // 临时消息的索引,用于更新临时消息 + int _tempMessageIndex = -1; // 流式响应控制 StreamSubscription? _aiResponseSubscription; @@ -86,8 +88,8 @@ class ChatController extends GetxController { // 如果playVoiceOnEnter为true,自动激活语音识别 if (args['playVoiceOnEnter'] == true) { - // 延迟一段时间后激活语音识别,等待TTS播放完成 - startVoiceRecognition(); + + startVoiceRecognition(); } // 使用微任务在布局完成后自动滚动到底部 @@ -112,7 +114,7 @@ class ChatController extends GetxController { // 停止语音识别 if (isVoiceRecognitionActive.value) { - _asrService.stopContinuousRecognition(); + _asrService.stopRecognition(); } super.onClose(); @@ -300,8 +302,6 @@ class ChatController extends GetxController { // 开始语音识别 Future startVoiceRecognition() async { - - if (isVoiceRecognitionActive.value) return; // 每次收到新内容后滚动到底部 @@ -323,17 +323,19 @@ class ChatController extends GetxController { try { // 启动语音识别 - final success = await _asrService.startContinuousRecognition(); + final success = await _asrService.startRecognition(); if (success) { isVoiceRecognitionActive.value = true; isListening.value = true; recognizedText.value = ''; - - // 不再添加"正在聆听..."的临时消息 + + // 重置临时消息索引 + _tempMessageIndex = -1; + _tempRecognitionMessageId = null; // 监听识别事件 - _recognitionSubscription = _asrService.recognitionStream?.listen( + _recognitionSubscription = _asrService.recognitionStream.listen( (event) { switch (event.type) { case RecognitionEventType.recognizing: @@ -342,22 +344,18 @@ class ChatController extends GetxController { recognizedText.value = event.text; // 检查是否已经有临时消息 - final tempMessageIndex = messages.indexWhere((m) => - m.role == 'user' && - m.timestamp.millisecondsSinceEpoch > - DateTime.now().millisecondsSinceEpoch - 5000); - - if (tempMessageIndex >= 0) { + if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { // 更新已有的临时消息 - messages[tempMessageIndex] = - messages[tempMessageIndex].copyWith( + messages[_tempMessageIndex] = + messages[_tempMessageIndex].copyWith( content: event.text, ); + messages.refresh(); + // 每次收到新内容后滚动到底部 Future.delayed( Duration(milliseconds: 100), _scrollToBottom); // 使用refresh()方法刷新列表 - messages.refresh(); } else { // 添加新的临时消息,但只有在有实际内容时 final tempMessage = Message( @@ -365,7 +363,13 @@ class ChatController extends GetxController { content: event.text, timestamp: DateTime.now(), ); + // 不使用ID,直接使用索引跟踪临时消息 messages.add(tempMessage); + _tempMessageIndex = messages.length - 1; + messages.refresh(); + + // 滚动到底部显示新消息 + Future.delayed(Duration(milliseconds: 100), _scrollToBottom); } } break; @@ -375,58 +379,67 @@ class ChatController extends GetxController { recognizedText.value = event.text; // 查找并移除临时消息 - final tempMessageIndex = messages.indexWhere((m) => - m.role == 'user' && - m.timestamp.millisecondsSinceEpoch > - DateTime.now().millisecondsSinceEpoch - 5000); - - if (tempMessageIndex >= 0) { - messages.removeAt(tempMessageIndex); + if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { + messages.removeAt(_tempMessageIndex); } + + // 重置临时消息索引 + _tempMessageIndex = -1; + _tempRecognitionMessageId = null; // 发送识别到的文本 sendMessage(event.text); - // 重置识别文本 + // 重置识别文本,但不关闭语音识别框 recognizedText.value = ''; } break; - case RecognitionEventType.sessionStopped: - case RecognitionEventType.canceled: - // 会话结束 + case RecognitionEventType.error: + // 错误处理,关闭语音识别框,不尝试重新启动 + Logger.error('语音识别错误: ${event.error}'); + + // 查找并移除临时消息 + if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { + if (messages[_tempMessageIndex].content.trim().isEmpty) { + messages.removeAt(_tempMessageIndex); + } + } + + // 重置临时消息索引 + _tempMessageIndex = -1; + _tempRecognitionMessageId = null; + + // 停止语音识别 isVoiceRecognitionActive.value = false; isListening.value = false; + _asrService.stopRecognition(); - // 查找并移除临时消息 - final tempMessageIndex = messages.indexWhere((m) => - m.role == 'user' && - m.timestamp.millisecondsSinceEpoch > - DateTime.now().millisecondsSinceEpoch - 5000); - - if (tempMessageIndex >= 0 && - messages[tempMessageIndex].content.trim().isEmpty) { - messages.removeAt(tempMessageIndex); - } break; default: + // 不做任何处理,保持语音识别持续运行 break; } }, onError: (error) { + // 错误处理,关闭语音识别框,不尝试重新启动 Logger.error('语音识别错误', error); - isVoiceRecognitionActive.value = false; - isListening.value = false; - + // 查找并移除临时消息 - final tempMessageIndex = messages.indexWhere((m) => - m.role == 'user' && - m.timestamp.millisecondsSinceEpoch > - DateTime.now().millisecondsSinceEpoch - 5000); - - if (tempMessageIndex >= 0 && - messages[tempMessageIndex].content.trim().isEmpty) { - messages.removeAt(tempMessageIndex); + if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { + if (messages[_tempMessageIndex].content.trim().isEmpty) { + messages.removeAt(_tempMessageIndex); + } } + + // 重置临时消息索引 + _tempMessageIndex = -1; + _tempRecognitionMessageId = null; + + // 停止语音识别 + isVoiceRecognitionActive.value = false; + isListening.value = false; + _asrService.stopRecognition(); + }, ); } else { @@ -444,7 +457,7 @@ class ChatController extends GetxController { if (!isVoiceRecognitionActive.value) return; try { - await _asrService.stopContinuousRecognition(); + await _asrService.stopRecognition(); _recognitionSubscription?.cancel(); _recognitionSubscription = null; @@ -453,15 +466,15 @@ class ChatController extends GetxController { recognizedText.value = ''; // 查找并移除临时消息 - final tempMessageIndex = messages.indexWhere((m) => - m.role == 'user' && - m.timestamp.millisecondsSinceEpoch > - DateTime.now().millisecondsSinceEpoch - 5000); - - if (tempMessageIndex >= 0 && - messages[tempMessageIndex].content.trim().isEmpty) { - messages.removeAt(tempMessageIndex); + if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { + if (messages[_tempMessageIndex].content.trim().isEmpty) { + messages.removeAt(_tempMessageIndex); + } } + + // 重置临时消息索引和ID + _tempMessageIndex = -1; + _tempRecognitionMessageId = null; } catch (e) { Logger.error('停止语音识别失败', e); } diff --git a/lib/modules/chat/views/chat_view.dart b/lib/modules/chat/views/chat_view.dart index 8069a1821..29ec19834 100644 --- a/lib/modules/chat/views/chat_view.dart +++ b/lib/modules/chat/views/chat_view.dart @@ -365,9 +365,9 @@ class ChatView extends GetView { children: List.generate( barCount, (index) { - // 创建动态高度效果 + // 创建动态高度效果,确保高度始终为正值 double height = isRec - ? _getWaveHeight(index, isSpeaking, barCount).clamp(1.h, 22.5.h) + ? _getWaveHeight(index, isSpeaking, barCount).clamp(2.h, 22.5.h) : 2.h; // 动画持续时间 @@ -401,7 +401,7 @@ class ChatView extends GetView { duration: duration, curve: isSpeaking ? Curves.elasticOut : Curves.easeInOut, width: isSpeaking ? 2.8.w : 2.5.w, - height: height, + height: height > 0 ? height : 2.h, // 确保高度始终为正值 margin: EdgeInsets.symmetric(horizontal: 1.2.w), decoration: BoxDecoration( color: barColor, @@ -411,6 +411,7 @@ class ChatView extends GetView { color: AppColors.primary.withOpacity(0.4), blurRadius: 5, spreadRadius: 1.2, + offset: const Offset(0, 0), // 确保偏移量不为null ) ] : null, ), @@ -452,15 +453,19 @@ class ChatView extends GetView { ? 1.0 - (distanceFromMiddle / middleIndex) * 0.2 : 1.0 - (distanceFromMiddle / middleIndex) * 0.5; + // 确保positionFactor不为负值 + positionFactor = max(0.1, positionFactor); + + double result; if (isSpeaking) { // 用户说话时的波形 double timeFactor = 0.9 + sin(milliseconds / 600) * 0.5; // 增强中心突出效果 - double centerBoost = pow(positionFactor, 1.2) * 10.h; + double centerBoost = pow(positionFactor, 1.2).toDouble() * 10.h; - // 增加随机性 - double randomFactor = (Random().nextDouble() * 4 - 2) * 1.75.h; + // 增加随机性,但限制范围 + double randomFactor = (Random().nextDouble() * 3) * 1.75.h; // 增强脉冲效果 double pulseEffect = sin(milliseconds / 300) * 2.5.h * positionFactor; @@ -474,14 +479,17 @@ class ChatView extends GetView { burstEffect = 5.h * positionFactor; } - return baseHeight + (wave1 + wave2 + wave3 + wave4) * timeFactor + + result = baseHeight + (wave1 + wave2 + wave3 + wave4) * timeFactor + centerBoost + randomFactor + pulseEffect + voiceVariation + burstEffect; } else { // 用户不说话时的波形 double idleWave = (wave1 + wave2) * 0.35 * positionFactor; double gentlePulse = sin(milliseconds / 800) * 1.h * positionFactor; - return baseHeight + idleWave + gentlePulse; + result = baseHeight + idleWave + gentlePulse; } + + // 确保返回值始终为正数 + return max(2.h, result); } catch (e) { // 发生错误时返回默认高度 return 2.h; diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index c43ae08d7..589235184 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../controllers/home_controller.dart'; import '../../../core/widgets/common_bottom_nav.dart'; +import '../../../core/routes/app_routes.dart'; class HomeView extends GetView { const HomeView({Key? key}) : super(key: key); @@ -271,6 +272,7 @@ class HomeView extends GetView { ), ), _buildActionButton(), + SizedBox(height: 40.h), ], ), diff --git a/lib/modules/profile/views/profile_view.dart b/lib/modules/profile/views/profile_view.dart index 4abcf7d4a..770960f08 100644 --- a/lib/modules/profile/views/profile_view.dart +++ b/lib/modules/profile/views/profile_view.dart @@ -71,14 +71,7 @@ class ProfileView extends GetView { // TODO: 实现关于页面 }, ), - const Divider(), - _buildMenuItem( - title: '检查更新'.tr, - icon: Icons.system_update, - onTap: () { - // TODO: 实现检查更新 - }, - ), + const Divider(), _buildMenuItem( title: '清除缓存'.tr, @@ -126,7 +119,18 @@ class ProfileView extends GetView { Get.toNamed(Routes.TTS_TEST); }, ), + + const Divider(), + _buildMenuItem( + title: '火山语音识别测试'.tr, + icon: Icons.mic_external_on, + subtitle: '测试火山语音ASR功能', + onTap: () { + Get.toNamed(Routes.VOLCANO_ASR_TEST); + }, + ), + const Divider(), _buildMenuItem( title: 'Azure语音识别测试'.tr, icon: Icons.mic, @@ -135,6 +139,24 @@ class ProfileView extends GetView { Get.toNamed(Routes.ASR_TEST); }, ), + const Divider(), + _buildMenuItem( + title: '本地语音合成测试'.tr, + icon: Icons.speaker_phone, + subtitle: '测试Flutter TTS本地语音合成功能', + onTap: () { + Get.toNamed(Routes.FLUTTER_TTS_TEST); + }, + ), + const Divider(), + _buildMenuItem( + title: '本地语音识别测试'.tr, + icon: Icons.mic_none, + subtitle: '测试Flutter本地语音识别功能', + onTap: () { + Get.toNamed(Routes.FLUTTER_ASR_TEST); + }, + ), ], ), bottomNavigationBar: const CommonBottomNav(currentIndex: 2), diff --git a/lib/modules/test/bindings/flutter_asr_test_binding.dart b/lib/modules/test/bindings/flutter_asr_test_binding.dart new file mode 100644 index 000000000..19bbdb0ea --- /dev/null +++ b/lib/modules/test/bindings/flutter_asr_test_binding.dart @@ -0,0 +1,16 @@ +import 'package:get/get.dart'; +import '../controllers/flutter_asr_test_controller.dart'; +import '../../../data/services/flutter_asr_service.dart'; + +class FlutterAsrTestBinding extends Bindings { + @override + void dependencies() { + // 注册ASR服务(如果尚未注册) + if (!Get.isRegistered()) { + Get.lazyPut(() => FlutterAsrService()); + } + + // 注册控制器 + Get.lazyPut(() => FlutterAsrTestController()); + } +} \ No newline at end of file diff --git a/lib/modules/test/bindings/flutter_tts_test_binding.dart b/lib/modules/test/bindings/flutter_tts_test_binding.dart new file mode 100644 index 000000000..eddb211bb --- /dev/null +++ b/lib/modules/test/bindings/flutter_tts_test_binding.dart @@ -0,0 +1,10 @@ +import 'package:get/get.dart'; +import '../controllers/flutter_tts_test_controller.dart'; + +/// Flutter TTS测试绑定 +class FlutterTtsTestBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => FlutterTtsTestController()); + } +} \ No newline at end of file diff --git a/lib/modules/test/bindings/volcano_asr_test_binding.dart b/lib/modules/test/bindings/volcano_asr_test_binding.dart new file mode 100644 index 000000000..5882e694d --- /dev/null +++ b/lib/modules/test/bindings/volcano_asr_test_binding.dart @@ -0,0 +1,10 @@ +import 'package:get/get.dart'; +import '../controllers/volcano_asr_test_controller.dart'; + +class VolcanoAsrTestBinding extends Bindings { + @override + void dependencies() { + // Use put instead of lazyPut to ensure the controller is created immediately + Get.put(VolcanoAsrTestController()); + } +} \ No newline at end of file diff --git a/lib/modules/test/controllers/flutter_asr_test_controller.dart b/lib/modules/test/controllers/flutter_asr_test_controller.dart new file mode 100644 index 000000000..71b0bc733 --- /dev/null +++ b/lib/modules/test/controllers/flutter_asr_test_controller.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'package:get/get.dart'; +import '../../../data/services/flutter_asr_service.dart'; +import '../../../core/utils/logger.dart'; + +/// Flutter ASR测试控制器,演示如何使用FlutterAsrService +class FlutterAsrTestController extends GetxController { + // 获取ASR服务实例 + final FlutterAsrService _asrService = Get.find(); + + // 当前ASR状态 - 使用Rx包装以便UI可以响应变化 + final Rx _asrState = AsrState.notInitialized.obs; + AsrState get asrState => _asrState.value; + + // 当前识别的文本 - 使用Rx包装以便UI可以响应变化 + final RxString _recognizedText = ''.obs; + String get recognizedText => _recognizedText.value; + + // 识别准确度 - 使用Rx包装以便UI可以响应变化 + final RxDouble _confidence = 0.0.obs; + double get confidence => _confidence.value; + + // 是否正在监听 - 计算属性 + bool get isListening => asrState == AsrState.listening; + + // 是否可用 - 使用Rx包装以便UI可以响应变化 + final RxBool _isAvailable = false.obs; + bool get isAvailable => _isAvailable.value; + + // 可用的语言列表 - 使用Rx包装以便UI可以响应变化 + final RxList _locales = [].obs; + List get locales => _locales; + + // 当前选择的语言 - 使用Rx包装以便UI可以响应变化 + final RxString _currentLocale = ''.obs; + String get currentLocale => _currentLocale.value; + + // 历史记录 - 使用Rx包装以便UI可以响应变化 + final RxList _history = [].obs; + List get history => _history; + + // 连续识别模式 - 使用Rx包装以便UI可以响应变化 + final RxBool _continuousMode = false.obs; + bool get continuousMode => _continuousMode.value; + set continuousMode(bool value) => _continuousMode.value = value; + + // 定时器,用于定期更新状态 + late Timer _updateTimer; + + // 识别结果订阅 + StreamSubscription? _resultSubscription; + + @override + void onInit() { + super.onInit(); + + // 初始化状态 + _updateState(); + + // 设置定时器,每300毫秒更新一次状态,确保UI能够及时反映服务状态变化 + _updateTimer = Timer.periodic(const Duration(milliseconds: 300), (_) { + _updateState(); + + // 如果启用了连续模式,且当前不在监听状态,且ASR已初始化,则自动重新开始监听 + if (_continuousMode.value && + _asrState.value != AsrState.listening && + _asrState.value != AsrState.notInitialized && + _asrState.value != AsrState.error && + _isAvailable.value) { + _restartListening(); + } + }); + + // 订阅识别结果流 + _resultSubscription = _asrService.recognitionResults.listen(_onFinalResult); + } + + /// 更新控制器状态,从服务获取最新状态 + void _updateState() { + _asrState.value = _asrService.asrState; + _recognizedText.value = _asrService.lastWords; + _confidence.value = _asrService.confidence; + _isAvailable.value = _asrService.isAvailable; + _locales.assignAll(_asrService.locales); + _currentLocale.value = _asrService.currentLocale; + } + + /// 开始语音识别 + Future startListening() async { + return await _asrService.startListening(); + } + + /// 停止语音识别 + Future stopListening() async { + await _asrService.stopListening(); + } + + /// 取消语音识别 + Future cancelListening() async { + await _asrService.cancelListening(); + } + + /// 切换连续识别模式 + void toggleContinuousMode() { + _continuousMode.value = !_continuousMode.value; + Logger.info('连续识别模式: ${_continuousMode.value ? '开启' : '关闭'}'); + + // 如果开启连续模式且当前未在监听,则开始监听 + if (_continuousMode.value && !isListening && _isAvailable.value) { + startListening(); + } + } + + /// 重新开始监听(用于连续模式) + Future _restartListening() async { + // 添加小延迟,避免过于频繁的重启 + await Future.delayed(const Duration(milliseconds: 300)); + if (_continuousMode.value && !isListening && _isAvailable.value) { + Logger.info('连续模式: 重新开始监听'); + await startListening(); + } + } + + /// 设置语言 + void setLocale(String localeId) { + _asrService.setLocale(localeId); + _currentLocale.value = localeId; + } + + /// 处理最终识别结果 + void _onFinalResult(String result) { + if (result.isNotEmpty) { + // 添加到历史记录 + _history.insert(0, result); + + // 限制历史记录数量 + if (_history.length > 10) { + _history.removeLast(); + } + } + } + + /// 清空历史记录 + void clearHistory() { + _history.clear(); + } + + @override + void onClose() { + _updateTimer.cancel(); + _resultSubscription?.cancel(); + super.onClose(); + } +} \ No newline at end of file diff --git a/lib/modules/test/controllers/flutter_tts_test_controller.dart b/lib/modules/test/controllers/flutter_tts_test_controller.dart new file mode 100644 index 000000000..f3441c683 --- /dev/null +++ b/lib/modules/test/controllers/flutter_tts_test_controller.dart @@ -0,0 +1,163 @@ +import 'package:get/get.dart'; +import '../../../data/services/flutter_tts_service.dart'; +import '../../../core/utils/logger.dart'; +import 'dart:async'; + +/// Flutter TTS测试控制器,演示如何使用FlutterTtsService +class FlutterTtsTestController extends GetxController { + // 获取TTS服务实例 + final FlutterTtsService _ttsService = Get.find(); + + // 当前TTS状态 - 使用Rx包装以便UI可以响应变化 + final Rx _ttsState = TtsState.stopped.obs; + TtsState get ttsState => _ttsState.value; + + // 当前正在播放的文本 - 使用Rx包装以便UI可以响应变化 + final RxString _currentText = ''.obs; + String get currentText => _currentText.value; + + // 是否正在播放 - 计算属性 + bool get isPlaying => ttsState == TtsState.playing; + + // 队列中的文本 - 使用Rx包装以便UI可以响应变化 + final RxList _textQueue = [].obs; + List get textQueue => _textQueue; + + // 可用的语音列表 - 使用Rx包装以便UI可以响应变化 + final RxList> _voices = >[].obs; + List> get voices => _voices; + + // 当前选择的语音 - 使用Rx包装以便UI可以响应变化 + final RxString _currentVoice = ''.obs; + String get currentVoice => _currentVoice.value; + + // 定时器,用于定期更新状态 + late Timer _updateTimer; + + @override + void onInit() { + super.onInit(); + + // 初始化状态 + _updateState(); + + // 设置定时器,每300毫秒更新一次状态,确保UI能够及时反映服务状态变化 + _updateTimer = Timer.periodic(const Duration(milliseconds: 300), (_) { + _updateState(); + }); + } + + /// 更新控制器状态,从服务获取最新状态 + void _updateState() { + // 更新TTS状态 + final serviceState = _ttsService.ttsState; + if (_ttsState.value != serviceState) { + _ttsState.value = serviceState; + } + + // 更新当前播放文本 + final serviceText = _ttsService.currentText; + if (_currentText.value != serviceText) { + _currentText.value = serviceText; + } + + // 更新队列 + final serviceQueue = _ttsService.textQueue; + if (!listEquals(_textQueue, serviceQueue)) { + _textQueue.value = List.from(serviceQueue); + } + + // 更新可用语音列表 + final serviceVoices = _ttsService.voices; + if (!listEquals(_voices, serviceVoices)) { + _voices.value = List.from(serviceVoices); + } + + // 更新当前选择的语音 + final serviceCurrentVoice = _ttsService.currentVoice; + if (_currentVoice.value != serviceCurrentVoice) { + _currentVoice.value = serviceCurrentVoice; + } + } + + /// 比较两个列表是否相等 + bool listEquals(List a, List b) { + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + /// 添加文本到队列并开始播放(如果尚未开始) + Future speak(String text) async { + if (text.isEmpty) { + Logger.warning('文本为空,无法播放'); + return; + } + + await _ttsService.speak(text); + _updateState(); + } + + /// 添加多条文本到队列并开始播放(如果尚未开始) + Future speakTexts(List texts) async { + if (texts.isEmpty) { + Logger.warning('文本列表为空,无法播放'); + return; + } + + await _ttsService.speakTexts(texts); + _updateState(); + } + + /// 停止播放并清空队列 + Future stop() async { + await _ttsService.stop(); + _updateState(); + } + + /// 暂停播放 + Future pause() async { + await _ttsService.pause(); + _updateState(); + } + + /// 设置语音 + Future setVoice(String voiceName) async { + await _ttsService.setVoice(voiceName); + _updateState(); + } + + /// 设置语言 + Future setLanguage(String language) async { + await _ttsService.setLanguage(language); + } + + /// 设置语速 + Future setRate(double rate) async { + await _ttsService.setRate(rate); + } + + /// 设置音量 + Future setVolume(double volume) async { + await _ttsService.setVolume(volume); + } + + /// 设置音调 + Future setPitch(double pitch) async { + await _ttsService.setPitch(pitch); + } + + /// 清空队列并停止播放 + Future clearQueue() async { + await _ttsService.clearQueue(); + _updateState(); + } + + @override + void onClose() { + _updateTimer.cancel(); + super.onClose(); + } +} \ No newline at end of file diff --git a/lib/modules/test/controllers/volcano_asr_test_controller.dart b/lib/modules/test/controllers/volcano_asr_test_controller.dart new file mode 100644 index 000000000..34ac7ade3 --- /dev/null +++ b/lib/modules/test/controllers/volcano_asr_test_controller.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'package:get/get.dart'; +import '../../../data/services/volcano_asr_api_service.dart'; +import '../../../core/utils/logger.dart'; + +class VolcanoAsrTestController extends GetxController { + // Observable variables + final isListening = false.obs; + final errorMessage = ''.obs; + final recognitionResults = [].obs; + final isContinuous = false.obs; + final connectionStatus = 'Disconnected'.obs; + + // 当前识别中的文本 + final currentRecognizingText = ''.obs; + + // 当前识别的索引 + int _currentRecognitionIndex = -1; + + // Services + late final VolcanoAsrApiService _asrService; + + // Stream subscriptions + StreamSubscription? _recognitionSubscription; + + @override + void onInit() { + super.onInit(); + + // Initialize the ASR service + _asrService = Get.find(); + + // Subscribe to recognition stream + _recognitionSubscription = _asrService.recognitionStream.listen((event) { + switch (event.type) { + case RecognitionEventType.started: + // 开始新的识别 + if (isContinuous.value) { + // 如果是连续模式,检查是否需要创建新条目 + if (_currentRecognitionIndex == -1 || recognitionResults.isEmpty) { + // 如果没有当前识别索引或结果列表为空,添加新条目 + recognitionResults.add(''); + _currentRecognitionIndex = recognitionResults.length - 1; + } + } else { + // 非连续模式,清空结果并添加新条目 + recognitionResults.clear(); + recognitionResults.add(''); + _currentRecognitionIndex = 0; + } + currentRecognizingText.value = ''; + break; + + case RecognitionEventType.recognizing: + // 实时识别结果 + if (event.text.isNotEmpty) { + currentRecognizingText.value = event.text; + + // 确保有一个当前条目可以更新 + if (_currentRecognitionIndex == -1 || _currentRecognitionIndex >= recognitionResults.length) { + recognitionResults.add(''); + _currentRecognitionIndex = recognitionResults.length - 1; + } + + // 更新当前条目 + recognitionResults[_currentRecognitionIndex] = event.text; + } + break; + + case RecognitionEventType.finalResult: + // 最终识别结果 + if (event.text.isNotEmpty) { + // 确保有一个当前条目可以更新 + if (_currentRecognitionIndex == -1 || _currentRecognitionIndex >= recognitionResults.length) { + recognitionResults.add(event.text); + _currentRecognitionIndex = recognitionResults.length - 1; + } else { + // 更新当前条目为最终结果 + recognitionResults[_currentRecognitionIndex] = event.text; + } + + // 收到最终结果后,重置索引,为新句子做准备 + _currentRecognitionIndex = -1; + + // 清空当前识别文本 + currentRecognizingText.value = ''; + } + break; + + case RecognitionEventType.error: + // 错误处理 + if (event.error != null) { + errorMessage.value = event.error!; + + // 如果有错误且正在识别,停止识别 + if (isListening.value) { + isListening.value = false; + connectionStatus.value = 'Disconnected'; + + // 停止语音识别,不尝试重启 + _asrService.stopRecognition(); + + } + } + break; + + // 注意:处理其他事件类型 + default: + // 其他事件类型不做特殊处理 + break; + } + }); + + // Update connection status based on service state + ever(_asrService.isListening, (bool listening) { + isListening.value = listening; + connectionStatus.value = listening ? 'Connected' : 'Disconnected'; + }); + + // Update error message from service + if (_asrService.errorMessage.isNotEmpty) { + errorMessage.value = _asrService.errorMessage; + } + } + + @override + void onClose() { + // Cancel all subscriptions + _recognitionSubscription?.cancel(); + + // Disconnect from the ASR service if still connected + if (_asrService.isConnected) { + stopListening(); + } + + super.onClose(); + } + + // Start listening for speech + Future startListening() async { + try { + // Clear previous error message + errorMessage.value = ''; + + // 如果是新的识别会话 + if (!isListening.value) { + currentRecognizingText.value = ''; + + // 如果不是连续识别模式,则清空结果列表 + if (!isContinuous.value) { + recognitionResults.clear(); + _currentRecognitionIndex = -1; + } + } + + // 使用麦克风录音功能 + final success = await _asrService.startRecognition( + sampleRate: 16000, + bitsPerSample: 16, + channels: 1, + format: 'pcm', + codec: 'raw', + enablePunc: true, + ); + + if (success) { + isListening.value = true; + connectionStatus.value = 'Connected'; + } else { + errorMessage.value = _asrService.errorMessage; + Logger.error('Failed to start recognition: ${_asrService.errorMessage}'); + } + } catch (e) { + errorMessage.value = e.toString(); + Logger.error('Error starting recognition: $e'); + } + } + + // 重新开始识别(用于连续识别模式) + Future _restartRecognition() async { + // 短暂延迟,确保上一次识别完全结束 + await Future.delayed(const Duration(milliseconds: 500)); + + // 如果仍处于监听状态且是连续识别模式,则开始新的识别 + if (isListening.value && isContinuous.value) { + startListening(); + } + } + + // Stop listening for speech + void stopListening() { + try { + connectionStatus.value = 'Disconnecting...'; + + // 停止麦克风录音 + _asrService.stopRecognition().then((_) { + isListening.value = false; + connectionStatus.value = 'Disconnected'; + + // 重置当前识别索引 + _currentRecognitionIndex = -1; + }).catchError((e) { + errorMessage.value = e.toString(); + Logger.error('Error stopping recognition: $e'); + }); + } catch (e) { + errorMessage.value = e.toString(); + Logger.error('Error stopping recognition: $e'); + } + } + + // Clear recognition results + void clearResults() { + recognitionResults.clear(); + currentRecognizingText.value = ''; + _currentRecognitionIndex = -1; + } +} \ No newline at end of file diff --git a/lib/modules/test/views/flutter_asr_test_view.dart b/lib/modules/test/views/flutter_asr_test_view.dart new file mode 100644 index 000000000..e06a5bbc0 --- /dev/null +++ b/lib/modules/test/views/flutter_asr_test_view.dart @@ -0,0 +1,476 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/flutter_asr_test_controller.dart'; +import '../../../data/services/flutter_asr_service.dart'; + +/// Flutter ASR测试视图,演示如何使用FlutterAsrService +class FlutterAsrTestView extends GetView { + const FlutterAsrTestView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Flutter ASR Test'), + centerTitle: true, + ), + 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( + 'Speech Recognition Controls', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + // 添加连续识别模式开关 + Obx(() { + final isContinuous = controller.continuousMode; + return SwitchListTile( + title: const Text('连续识别模式'), + subtitle: Text( + isContinuous + ? '已开启:识别将自动连续进行' + : '已关闭:识别完成后需手动重新开始', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + value: isContinuous, + onChanged: controller.isAvailable + ? (value) => controller.toggleContinuousMode() + : null, + activeColor: Colors.blue, + dense: true, + ); + }), + const Divider(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Obx(() { + final isListening = controller.isListening; + return ElevatedButton.icon( + onPressed: isListening || !controller.isAvailable + ? null + : () => controller.startListening(), + icon: isListening + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.mic), + label: Text(isListening ? 'Listening...' : 'Start Listening'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + ); + }), + Obx(() { + final isActive = controller.isListening; + return ElevatedButton.icon( + onPressed: isActive + ? () => controller.stopListening() + : null, + icon: const Icon(Icons.stop), + label: const Text('Stop'), + 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( + 'Language Selection', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + Obx(() { + final locales = controller.locales; + final currentLocale = controller.currentLocale; + + if (locales.isEmpty) { + return const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + 'No languages available. Please check ASR settings on your device.', + style: TextStyle( + color: Colors.red, + fontStyle: FontStyle.italic, + ), + ), + ); + } + + return DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Select Language', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + value: currentLocale.isNotEmpty ? currentLocale : null, + items: locales.map((locale) { + final name = locale.name ?? 'Unknown'; + final id = locale.localeId ?? ''; + return DropdownMenuItem( + value: id, + child: Text('$name (${id.isNotEmpty ? id : 'Unknown'})'), + ); + }).toList(), + onChanged: controller.isListening + ? null + : (value) { + if (value != null) { + controller.setLocale(value); + } + }, + isExpanded: true, + hint: const Text('Select a language'), + ); + }), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // 当前识别结果卡片 + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Current Recognition', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + Obx(() { + final text = controller.recognizedText; + final confidence = controller.confidence; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + width: double.infinity, + child: Text( + text.isEmpty ? 'Speak to see recognition results here' : text, + style: TextStyle( + fontSize: 16, + fontStyle: text.isEmpty ? FontStyle.italic : FontStyle.normal, + color: text.isEmpty ? Colors.grey : Colors.black, + ), + ), + ), + + if (confidence > 0) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + 'Confidence: ${(confidence * 100).toStringAsFixed(1)}%', + style: TextStyle( + color: _getConfidenceColor(confidence), + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ); + }), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // 历史记录卡片 + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Recognition History', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Obx(() { + final hasHistory = controller.history.isNotEmpty; + return TextButton.icon( + onPressed: hasHistory ? () => controller.clearHistory() : null, + icon: const Icon(Icons.clear_all, size: 18), + label: const Text('Clear'), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ); + }), + ], + ), + const SizedBox(height: 8), + + Obx(() { + final history = controller.history; + + if (history.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: Text( + 'No recognition history yet', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Colors.grey, + ), + ), + ), + ); + } + + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: history.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final text = history[index]; + + return ListTile( + title: Text(text), + leading: CircleAvatar( + backgroundColor: Colors.blue.shade100, + radius: 18, + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.blue, + fontWeight: FontWeight.bold, + ), + ), + ), + dense: true, + ); + }, + ); + }), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // 状态显示卡片 + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Status', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Obx(() { + final state = controller.asrState; + final stateText = _getAsrStateText(state); + final stateColor = _getAsrStateColor(state); + final isContinuous = controller.continuousMode; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: stateColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: stateColor.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon( + _getAsrStateIcon(state), + color: stateColor, + ), + const SizedBox(width: 8), + Text( + 'Current Status: $stateText', + style: TextStyle( + color: stateColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + + if (isContinuous) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue.withOpacity(0.3)), + ), + child: Row( + children: const [ + Icon( + Icons.repeat, + color: Colors.blue, + size: 16, + ), + SizedBox(width: 8), + Text( + '连续识别模式已开启', + style: TextStyle( + color: Colors.blue, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ], + ); + }), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + // 获取ASR状态文本 + String _getAsrStateText(AsrState state) { + switch (state) { + case AsrState.notInitialized: + return 'Not Initialized'; + case AsrState.initialized: + return 'Initialized'; + case AsrState.listening: + return 'Listening'; + case AsrState.notListening: + return 'Not Listening'; + case AsrState.error: + return 'Error'; + default: + return 'Unknown'; + } + } + + // 获取ASR状态颜色 + Color _getAsrStateColor(AsrState state) { + switch (state) { + case AsrState.notInitialized: + return Colors.grey; + case AsrState.initialized: + return Colors.blue; + case AsrState.listening: + return Colors.green; + case AsrState.notListening: + return Colors.orange; + case AsrState.error: + return Colors.red; + default: + return Colors.grey; + } + } + + // 获取ASR状态图标 + IconData _getAsrStateIcon(AsrState state) { + switch (state) { + case AsrState.notInitialized: + return Icons.hourglass_empty; + case AsrState.initialized: + return Icons.check_circle; + case AsrState.listening: + return Icons.mic; + case AsrState.notListening: + return Icons.mic_off; + case AsrState.error: + return Icons.error; + default: + return Icons.help_outline; + } + } + + // 获取置信度颜色 + Color _getConfidenceColor(double confidence) { + if (confidence >= 0.8) { + return Colors.green; + } else if (confidence >= 0.6) { + return Colors.orange; + } else { + return Colors.red; + } + } +} \ No newline at end of file diff --git a/lib/modules/test/views/flutter_tts_test_view.dart b/lib/modules/test/views/flutter_tts_test_view.dart new file mode 100644 index 000000000..8a0001633 --- /dev/null +++ b/lib/modules/test/views/flutter_tts_test_view.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/flutter_tts_test_controller.dart'; +import '../../../data/services/flutter_tts_service.dart'; + +/// Flutter TTS测试视图,演示如何使用FlutterTtsService +class FlutterTtsTestView extends GetView { + const FlutterTtsTestView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Flutter TTS Test'), + centerTitle: true, + ), + 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( + 'Playback Controls', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Obx(() { + final isPlaying = controller.isPlaying; + return ElevatedButton.icon( + onPressed: isPlaying + ? null + : () => _playAllTexts(), + icon: isPlaying + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.playlist_play), + label: Text(isPlaying ? 'Playing...' : 'Play All'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + ); + }), + Obx(() { + final isActive = controller.ttsState != TtsState.stopped; + return ElevatedButton.icon( + onPressed: isActive + ? () => controller.stop() + : null, + icon: const Icon(Icons.stop), + label: const Text('Stop'), + 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( + 'Voice Selection', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + Obx(() { + final voices = controller.voices; + final currentVoice = controller.currentVoice; + + if (voices.isEmpty) { + return const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + 'No voices available. Please check TTS settings on your device.', + style: TextStyle( + color: Colors.red, + fontStyle: FontStyle.italic, + ), + ), + ); + } + + return DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Select Voice', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + value: currentVoice.isNotEmpty ? currentVoice : null, + items: voices.map((voice) { + final name = voice['name'] ?? 'Unknown'; + final locale = voice['locale'] ?? ''; + return DropdownMenuItem( + value: name, + child: Text('$name ${locale.isNotEmpty ? '($locale)' : ''}'), + ); + }).toList(), + onChanged: controller.isPlaying + ? null + : (value) { + if (value != null) { + controller.setVoice(value); + } + }, + isExpanded: true, + hint: const Text('Select a voice'), + ); + }), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // 播报内容列表卡片 + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Sample Texts', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + // 预设的播报内容 + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _getSampleTexts().length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final text = _getSampleTexts()[index]; + + return Obx(() { + final isPlaying = controller.isPlaying; + final isCurrentPlaying = controller.isPlaying && + controller.currentText == text; + + return ListTile( + title: Text( + text, + style: TextStyle( + fontWeight: isCurrentPlaying ? FontWeight.bold : FontWeight.normal, + color: isCurrentPlaying ? Colors.blue : null, + ), + ), + leading: CircleAvatar( + backgroundColor: isCurrentPlaying + ? Colors.blue + : Colors.blue.shade100, + radius: 18, + child: Icon( + isCurrentPlaying ? Icons.volume_up : Icons.play_arrow, + color: isCurrentPlaying ? Colors.white : Colors.blue, + size: 20, + ), + ), + onTap: isPlaying + ? null + : () => controller.speak(text), + enabled: !isPlaying, + tileColor: isCurrentPlaying ? Colors.blue.withOpacity(0.1) : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: isCurrentPlaying + ? 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( + 'Status', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Obx(() { + final state = controller.ttsState; + final stateText = _getTtsStateText(state); + final stateColor = _getTtsStateColor(state); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: stateColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: stateColor.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon( + _getTtsStateIcon(state), + color: stateColor, + ), + const SizedBox(width: 8), + Text( + 'Current Status: $stateText', + style: TextStyle( + color: stateColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + }), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + // 连续播放所有文本 + void _playAllTexts() { + // 使用控制器的连续播放功能播放所有文本 + controller.speakTexts(_getSampleTexts()); + } + + // 获取TTS状态文本 + String _getTtsStateText(TtsState state) { + switch (state) { + case TtsState.playing: + return 'Playing'; + case TtsState.paused: + return 'Paused'; + case TtsState.stopped: + return 'Stopped'; + default: + return 'Unknown'; + } + } + + // 获取TTS状态颜色 + Color _getTtsStateColor(TtsState state) { + switch (state) { + case TtsState.playing: + return Colors.green; + case TtsState.paused: + return Colors.orange; + case TtsState.stopped: + return Colors.grey; + default: + return Colors.grey; + } + } + + // 获取TTS状态图标 + IconData _getTtsStateIcon(TtsState state) { + switch (state) { + case TtsState.playing: + return Icons.volume_up; + case TtsState.paused: + return Icons.pause; + case TtsState.stopped: + return Icons.stop; + default: + return Icons.help_outline; + } + } + + // 获取示例文本 + List _getSampleTexts() { + return [ + 'This is a Flutter TTS test. Please listen to the synthesis effect.', + '这是Flutter TTS测试,请听取合成效果。', + 'Flutter TTS supports various voices and parameter adjustments.', + 'Flutter TTS支持多种语音和参数调节,可以根据需要进行设置。', + ]; + } +} \ No newline at end of file diff --git a/lib/modules/test/views/volcano_asr_test_view.dart b/lib/modules/test/views/volcano_asr_test_view.dart new file mode 100644 index 000000000..ecc56da9f --- /dev/null +++ b/lib/modules/test/views/volcano_asr_test_view.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/volcano_asr_test_controller.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class VolcanoAsrTestView extends GetView { + const VolcanoAsrTestView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('火山语音识别测试'), + centerTitle: true, + ), + body: Padding( + padding: EdgeInsets.all(16.0.r), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 识别模式选择 + Card( + child: Padding( + padding: EdgeInsets.all(16.0.r), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '识别模式', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 8.h), + Obx(() => Row( + children: [ + Expanded( + child: RadioListTile( + title: const Text('一次性识别'), + value: false, + groupValue: controller.isContinuous.value, + onChanged: (value) { + if (value != null) { + controller.isContinuous.value = value; + } + }, + ), + ), + Expanded( + child: RadioListTile( + title: const Text('连续识别'), + value: true, + groupValue: controller.isContinuous.value, + onChanged: (value) { + if (value != null) { + controller.isContinuous.value = value; + } + }, + ), + ), + ], + )), + ], + ), + ), + ), + + SizedBox(height: 16.h), + + // 录音控制 + Card( + child: Padding( + padding: EdgeInsets.all(16.0.r), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '录音控制', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 16.h), + Center( + child: Obx(() => controller.isListening.value + ? ElevatedButton.icon( + onPressed: () => controller.stopListening(), + icon: const Icon(Icons.stop), + label: const Text('停止录音'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric( + horizontal: 24.w, + vertical: 12.h, + ), + ), + ) + : ElevatedButton.icon( + onPressed: () => controller.startListening(), + icon: const Icon(Icons.mic), + label: const Text('开始录音'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric( + horizontal: 24.w, + vertical: 12.h, + ), + ), + ), + ), + ), + ], + ), + ), + ), + + SizedBox(height: 16.h), + + // 识别结果 + Expanded( + child: Card( + child: Padding( + padding: EdgeInsets.all(16.0.r), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '识别结果', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.clear), + onPressed: () => controller.clearResults(), + tooltip: '清空结果', + ), + ], + ), + SizedBox(height: 8.h), + Expanded( + child: Container( + padding: EdgeInsets.all(8.0.r), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4.0.r), + ), + child: Obx(() => ListView.builder( + itemCount: controller.recognitionResults.length, + itemBuilder: (context, index) { + final result = controller.recognitionResults[index]; + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.0.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${index + 1}. ', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: Text(result), + ), + ], + ), + ); + }, + )), + ), + ), + ], + ), + ), + ), + ), + + SizedBox(height: 16.h), + + // 状态显示 + Card( + child: Padding( + padding: EdgeInsets.all(16.0.r), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '状态', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 8.h), + Obx(() => Text( + controller.connectionStatus.value, + style: TextStyle( + color: controller.connectionStatus.value == 'Connected' + ? Colors.green + : (controller.connectionStatus.value == 'Disconnecting...' + ? Colors.orange + : Colors.grey), + fontWeight: FontWeight.bold, + ), + )), + SizedBox(height: 8.h), + Obx(() => controller.errorMessage.value.isNotEmpty + ? Text( + '错误: ${controller.errorMessage.value}', + style: TextStyle( + color: Colors.red, + fontSize: 14.sp, + ), + ) + : const SizedBox.shrink()), + ], + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/modules/translation/bindings/translation_binding.dart b/lib/modules/translation/bindings/translation_binding.dart index 55f4fbe4d..d8e9adbc8 100644 --- a/lib/modules/translation/bindings/translation_binding.dart +++ b/lib/modules/translation/bindings/translation_binding.dart @@ -1,8 +1,5 @@ import 'package:get/get.dart'; import '../controllers/translation_controller.dart'; -import '../../../data/services/azure_asr_service.dart'; -import '../../../data/services/volcano_translation_service.dart'; -import '../../../data/services/volcano_tts_service.dart'; class TranslationBinding implements Bindings { @override diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 280388457..697e2594d 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -6,7 +6,7 @@ import 'package:get_storage/get_storage.dart'; import '../../../data/services/azure_asr_service.dart'; import '../../../data/services/volcano_translation_service.dart'; import '../../../data/services/volcano_tts_api_service.dart'; -// import '../../../core/utils/logger.dart'; +import '../../../core/utils/logger.dart'; class TranslationController extends GetxController { // Services @@ -172,11 +172,13 @@ class TranslationController extends GetxController { } break; case RecognitionEventType.error: - Get.snackbar( - 'Error', - 'Recognition error: ${event.error}', - snackPosition: SnackPosition.BOTTOM, - ); + // 错误处理,停止语音识别 + Logger.error('语音识别错误: ${event.error}'); + + // 停止语音识别 + isRecognizing.value = false; + _asrService.stopContinuousRecognition(); + break; default: break; diff --git a/local_plugins/speech_to_text/android/src/main/kotlin/com/csdcorp/speech_to_text/SpeechToTextPlugin.kt b/local_plugins/speech_to_text/android/src/main/kotlin/com/csdcorp/speech_to_text/SpeechToTextPlugin.kt new file mode 100644 index 000000000..b50ba749c --- /dev/null +++ b/local_plugins/speech_to_text/android/src/main/kotlin/com/csdcorp/speech_to_text/SpeechToTextPlugin.kt @@ -0,0 +1,863 @@ +package com.csdcorp.speech_to_text + +import android.Manifest +import android.R.attr.data +import android.annotation.TargetApi +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothHeadset +import android.bluetooth.BluetoothProfile +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.* +import android.speech.SpeechRecognizer.createOnDeviceSpeechRecognizer +import android.speech.SpeechRecognizer.createSpeechRecognizer +import android.util.Log +import androidx.annotation.NonNull +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry +import io.flutter.plugin.common.PluginRegistry.Registrar +import org.json.JSONArray +import org.json.JSONObject +import java.util.* +import java.util.concurrent.Executors + + +enum class SpeechToTextErrors { + multipleRequests, + unimplemented, + noLanguageIntent, + recognizerNotAvailable, + missingOrInvalidArg, + missingContext, + unknown +} + +enum class SpeechToTextCallbackMethods { + textRecognition, + notifyStatus, + notifyError, + soundLevelChange, +} + +enum class SpeechToTextStatus { + listening, + notListening, + unavailable, + available, + done, + doneNoResult, +} + +enum class ListenMode { + deviceDefault, + dictation, + search, + confirmation, +} + +const val pluginChannelName = "plugin.csdcorp.com/speech_to_text" + +@TargetApi(8) +/** SpeechToTextPlugin */ +public class SpeechToTextPlugin : + MethodCallHandler, RecognitionListener, + PluginRegistry.RequestPermissionsResultListener, FlutterPlugin, + ActivityAware { + private var pluginContext: Context? = null + private var channel: MethodChannel? = null + private val minSdkForSpeechSupport = 21 + private val brokenStopSdk = 29 + private val minSdkForOnDeviceSpeechSupport = 31 + private val speechToTextPermissionCode = 28521 + private val missingConfidence: Double = -1.0 + private var speechThresholdRms = 9 + private val logTag = "SpeechToTextPlugin" + private var recognizerStops = true + private var currentActivity: Activity? = null + private var activeResult: Result? = null + private var initializedSuccessfully: Boolean = false + private var permissionToRecordAudio: Boolean = false + private var listening = false + private var debugLogging: Boolean = false + private var alwaysUseStop: Boolean = false + private var intentLookup: Boolean = false + private var noBluetoothOpt: Boolean = false // user-defined option + private var bluetoothDisabled = true // final bluetooth state (combines user-defined option and permissions) + private var resultSent: Boolean = false + private var lastOnDevice: Boolean = false + private var speechRecognizer: SpeechRecognizer? = null + private var recognizerIntent: Intent? = null + private var bluetoothAdapter: android.bluetooth.BluetoothAdapter? = null + private var pairedDevices: Set? = null + private var activeBluetooth: android.bluetooth.BluetoothDevice? = null + private var bluetoothHeadset: BluetoothHeadset? = null + private var previousRecognizerLang: String? = null + private var previousPartialResults: Boolean = true + private var previousListenMode: ListenMode = ListenMode.deviceDefault + private var lastFinalTime: Long = 0 + private var speechStartTime: Long = 0 + private var minRms: Float = 1000.0F + private var maxRms: Float = -100.0F + private val handler: Handler = Handler(Looper.getMainLooper()) + private val defaultLanguageTag: String = Locale.getDefault().toLanguageTag() + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + + onAttachedToEngine(flutterPluginBinding.getApplicationContext(), flutterPluginBinding.getBinaryMessenger()); + } + + // This static function is optional and equivalent to onAttachedToEngine. It supports the old + // pre-Flutter-1.12 Android projects. You are encouraged to continue supporting + // plugin registration via this function while apps migrate to use the new Android APIs + // post-flutter-1.12 via https://flutter.dev/go/android-project-migration. + // + // It is encouraged to share logic between onAttachedToEngine and registerWith to keep + // them functionally equivalent. Only one of onAttachedToEngine or registerWith will be called + // depending on the user's project. onAttachedToEngine or registerWith must both be defined + // in the same class. + companion object { + @JvmStatic + fun registerWith(registrar: Registrar) { + val speechPlugin = SpeechToTextPlugin() + speechPlugin.currentActivity = registrar.activity() + registrar.addRequestPermissionsResultListener(speechPlugin) + speechPlugin.onAttachedToEngine(registrar.context(), registrar.messenger()) + } + } + + private fun onAttachedToEngine(applicationContext: Context, messenger: BinaryMessenger) { + this.pluginContext = applicationContext; + channel = MethodChannel(messenger, pluginChannelName) + channel?.setMethodCallHandler(this) + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + this.pluginContext = null; + channel?.setMethodCallHandler(null) + channel = null + } + + override fun onDetachedFromActivity() { + currentActivity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + currentActivity = binding.activity + binding.addRequestPermissionsResultListener(this) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + currentActivity = binding.activity + binding.addRequestPermissionsResultListener(this) + } + + override fun onDetachedFromActivityForConfigChanges() { + currentActivity = null + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull rawrResult: Result) { + val result = ChannelResultWrapper(rawrResult) + try { + when (call.method) { + "has_permission" -> hasPermission(result) + "initialize" -> { + var dlog = call.argument("debugLogging") + if (null != dlog) { + debugLogging = dlog + } + var ausOpt = call.argument("alwaysUseStop") + if (null != ausOpt) { + alwaysUseStop = ausOpt == true + } + var iOpt = call.argument("intentLookup") + if (null != iOpt) { + intentLookup = iOpt == true + } + var noBtOpt = call.argument("noBluetooth") + if (null != noBtOpt) { + noBluetoothOpt = noBtOpt == true + } + initialize(result) + } + "listen" -> { + var localeId = call.argument("localeId") + if (null == localeId) { + localeId = defaultLanguageTag + } + var partialResults = call.argument("partialResults") + if (null == partialResults) { + partialResults = true + } + var onDevice = call.argument("onDevice") + if ( null == onDevice ) { + onDevice = false + } + val listenModeIndex = call.argument("listenMode") + if ( null == listenModeIndex ) { + result.error(SpeechToTextErrors.missingOrInvalidArg.name, + "listenMode is required", null) + return + } + startListening(result, localeId, partialResults, listenModeIndex, onDevice ) + } + "stop" -> stopListening(result) + "cancel" -> cancelListening(result) + "locales" -> locales(result) + else -> result.notImplemented() + } + } catch (exc: Exception) { + Log.e(logTag, "Unexpected exception", exc) + result.error(SpeechToTextErrors.unknown.name, + "Unexpected exception", exc.localizedMessage) + } + } + + private fun hasPermission(result: Result) { + if (sdkVersionTooLow()) { + result.success(false) + return + } + debugLog("Start has_permission") + val localContext = pluginContext + if (localContext != null) { + val hasPerm = ContextCompat.checkSelfPermission(localContext, + Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + result.success(hasPerm) + } + } + + private fun initialize(result: Result) { + if (sdkVersionTooLow()) { + result.success(false) + return + } + recognizerStops = Build.VERSION.SDK_INT != brokenStopSdk || alwaysUseStop + debugLog("Start initialize") + if (null != activeResult) { + result.error(SpeechToTextErrors.multipleRequests.name, + "Only one initialize at a time", null) + return + } + activeResult = result + initializeIfPermitted(pluginContext) + } + + private fun sdkVersionTooLow(): Boolean { + if (Build.VERSION.SDK_INT < minSdkForSpeechSupport) { + return true; + } + return false; + } + + private fun isNotInitialized(): Boolean { + return !initializedSuccessfully + } + + private fun isListening(): Boolean { + return listening + } + + private fun isNotListening(): Boolean { + return !listening + } + + private fun startListening(result: Result, languageTag: String, partialResults: Boolean, + listenModeIndex: Int, onDevice: Boolean) { + if (sdkVersionTooLow() || isNotInitialized() || isListening()) { + result.success(false) + return + } + resultSent = false + createRecognizer(onDevice) + minRms = 1000.0F + maxRms = -100.0F + debugLog("Start listening") + var listenMode = ListenMode.deviceDefault + if ( listenModeIndex == ListenMode.dictation.ordinal) { + listenMode = ListenMode.dictation + } + optionallyStartBluetooth() + setupRecognizerIntent(languageTag, partialResults, listenMode, onDevice ) + handler.post { + run { + speechRecognizer?.startListening(recognizerIntent) + } + } + speechStartTime = System.currentTimeMillis() + notifyListening(isRecording = true) + result.success(true) + debugLog("Start listening done") + } + + private fun optionallyStartBluetooth() { + if ( bluetoothDisabled ) return + val context = pluginContext + val lbt = bluetoothAdapter + val lpaired = pairedDevices + val lhead = bluetoothHeadset + if (null != lbt && null!= lhead && null != lpaired && lbt.isEnabled) { + for (tryDevice in lpaired) { + //This loop tries to start VoiceRecognition mode on every paired device until it finds one that works(which will be the currently in use bluetooth headset) + if (lhead.startVoiceRecognition(tryDevice)) { + debugLog("Starting bluetooth voice recognition") + activeBluetooth = tryDevice; + break + } + } + } + } + + private fun stopListening(result: Result) { + if (sdkVersionTooLow() || isNotInitialized() || isNotListening()) { + result.success(false) + return + } + debugLog("Stop listening") + handler.post { + run { + speechRecognizer?.stopListening() + } + } + if ( !recognizerStops ) { + destroyRecognizer() + } + notifyListening(isRecording = false) + result.success(true) + debugLog("Stop listening done") + } + + private fun cancelListening(result: Result) { + if (sdkVersionTooLow() || isNotInitialized() || isNotListening()) { + result.success(false) + return + } + debugLog("Cancel listening") + handler.post { + run { + speechRecognizer?.cancel() + } + } + if ( !recognizerStops ) { + destroyRecognizer() + } + notifyListening(isRecording = false) + result.success(true) + debugLog("Cancel listening done") + } + + private fun locales(result: Result) { + if (sdkVersionTooLow()) { + result.success(false) + return + } + var hasPermission = ContextCompat.checkSelfPermission(pluginContext!!, + Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + if (Build.VERSION.SDK_INT >= 33 && hasPermission) { + if ( SpeechRecognizer.isOnDeviceRecognitionAvailable(pluginContext!!)) { + // after much experimentation this was the only working iteration of the + // checkRecognitionSupport that works. + var recognizer = createOnDeviceSpeechRecognizer(pluginContext!!) + var recognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) +// var recognizer = createSpeechRecognizer(pluginContext!!) +// var recognizerIntent = Intent(RecognizerIntent.ACTION_GET_LANGUAGE_DETAILS) + recognizer?.checkRecognitionSupport(recognizerIntent, Executors.newSingleThreadExecutor(), + object : RecognitionSupportCallback { + override fun onSupportResult(recognitionSupport: RecognitionSupport) { + var details = LanguageDetailsChecker( result, debugLogging ) + details.createResponse(recognitionSupport.supportedOnDeviceLanguages ) + recognizer?.destroy() + } + override fun onError(error: Int) { + debugLog("error from checkRecognitionSupport: " + error) + recognizer?.destroy() + } + }) + } + } else { + var detailsIntent = RecognizerIntent.getVoiceDetailsIntent(pluginContext) + if (null == detailsIntent) { + detailsIntent = Intent(RecognizerIntent.ACTION_GET_LANGUAGE_DETAILS) + detailsIntent.setPackage("com.google.android.googlequicksearchbox") + } + pluginContext?.sendOrderedBroadcast( + detailsIntent, null, LanguageDetailsChecker(result, debugLogging), + null, Activity.RESULT_OK, null, null) + } + } + + private fun notifyListening(isRecording: Boolean ) { + if ( listening == isRecording ) return; + listening = isRecording + val status = when (isRecording) { + true -> SpeechToTextStatus.listening.name + false -> SpeechToTextStatus.notListening.name + } + debugLog("Notify status:" + status) + channel?.invokeMethod(SpeechToTextCallbackMethods.notifyStatus.name, status) + if ( !isRecording ) { + val doneStatus = when( resultSent) { + false -> SpeechToTextStatus.doneNoResult.name + else -> SpeechToTextStatus.done.name + } + debugLog("Notify status:" + doneStatus ) + optionallyStopBluetooth(); + channel?.invokeMethod(SpeechToTextCallbackMethods.notifyStatus.name, + doneStatus ) + } + } + + private fun optionallyStopBluetooth() { + if ( bluetoothDisabled ) return + val lactive = activeBluetooth + val lbt = bluetoothHeadset + if (null != lactive && null != lbt ) { + debugLog("Stopping bluetooth voice recognition") + lbt.stopVoiceRecognition(lactive) + activeBluetooth = null + } + } + + private fun updateResults(speechBundle: Bundle?, isFinal: Boolean) { + if (isDuplicateFinal( isFinal )) { + debugLog("Discarding duplicate final") + return + } + val userSaid = speechBundle?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + if (null != userSaid && userSaid.isNotEmpty()) { + val speechResult = JSONObject() + speechResult.put("finalResult", isFinal) + val confidence = speechBundle.getFloatArray(SpeechRecognizer.CONFIDENCE_SCORES) + val alternates = JSONArray() + for (resultIndex in 0..userSaid.size - 1) { + val speechWords = JSONObject() + speechWords.put("recognizedWords", userSaid[resultIndex]) + if (null != confidence && confidence.size >= userSaid.size) { + speechWords.put("confidence", confidence[resultIndex]) + } else { + speechWords.put("confidence", missingConfidence) + } + alternates.put(speechWords) + } + speechResult.put("alternates", alternates) + val jsonResult = speechResult.toString() + debugLog("Calling results callback") + resultSent = true + channel?.invokeMethod(SpeechToTextCallbackMethods.textRecognition.name, + jsonResult) + } else { + debugLog("Results null or empty") + } + } + + private fun isDuplicateFinal( isFinal: Boolean ) : Boolean { + if ( !isFinal ) { + return false + } + val delta = System.currentTimeMillis() - lastFinalTime + lastFinalTime = System.currentTimeMillis() + return delta >= 0 && delta < 100 + } + + private fun initializeIfPermitted(context: Context?) { + val localContext = context + if (null == localContext) { + completeInitialize() + return + } + permissionToRecordAudio = ContextCompat.checkSelfPermission(localContext, + Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val permissionToEnableBluetooth = ContextCompat.checkSelfPermission(localContext, + Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + bluetoothDisabled = !permissionToEnableBluetooth || noBluetoothOpt + debugLog("Checked permission") + if (!permissionToRecordAudio) { + val localActivity = currentActivity + if (null != localActivity) { + debugLog("Requesting permission") + var requiredPermissions = arrayOf(Manifest.permission.RECORD_AUDIO) + if ( !noBluetoothOpt ) { + requiredPermissions = requiredPermissions.plus(Manifest.permission.BLUETOOTH_CONNECT) + } + ActivityCompat.requestPermissions(localActivity, requiredPermissions, speechToTextPermissionCode) + } else { + debugLog("no permission, no activity, completing") + completeInitialize() + } + } else { + debugLog("has permission, completing") + completeInitialize() + } + debugLog("leaving initializeIfPermitted") + } + + private fun completeInitialize() { + + debugLog("completeInitialize") + if (permissionToRecordAudio) { + debugLog("Testing recognition availability") + val localContext = pluginContext + if (localContext != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!SpeechRecognizer.isRecognitionAvailable(localContext) && !SpeechRecognizer.isOnDeviceRecognitionAvailable( + localContext + ) + ) { + Log.e(logTag, "Speech recognition not available on this device") + activeResult?.error( + SpeechToTextErrors.recognizerNotAvailable.name, + "Speech recognition not available on this device", "" + ) + activeResult = null + return + } + } else { + if (!SpeechRecognizer.isRecognitionAvailable(localContext)) { + Log.e(logTag, "Speech recognition not available on this device") + activeResult?.error( + SpeechToTextErrors.recognizerNotAvailable.name, + "Speech recognition not available on this device", "" + ) + activeResult = null + return + } + } + setupBluetooth() + } else { + debugLog("null context during initialization") + activeResult?.success(false) + activeResult?.error( + SpeechToTextErrors.missingContext.name, + "context unexpectedly null, initialization failed", "") + activeResult = null + return + } + } + + initializedSuccessfully = permissionToRecordAudio + debugLog("sending result") + activeResult?.success(permissionToRecordAudio) + debugLog("leaving complete") + activeResult = null + } + + private fun setupBluetooth() { + if ( bluetoothDisabled ) return + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + pairedDevices = bluetoothAdapter?.getBondedDevices() + + val mProfileListener: BluetoothProfile.ServiceListener = object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.HEADSET) { + bluetoothHeadset = proxy as BluetoothHeadset + debugLog("Found a headset: " + bluetoothHeadset.toString()) + } + } + + override fun onServiceDisconnected(profile: Int) { + if (profile == BluetoothProfile.HEADSET) { + debugLog("Clearing headset: ") + bluetoothHeadset = null + } + } + } + bluetoothAdapter?.getProfileProxy(pluginContext, mProfileListener, BluetoothProfile.HEADSET) + } + + private fun Context.findComponentName(): ComponentName? { + val list: List = packageManager.queryIntentServices(Intent(RecognitionService.SERVICE_INTERFACE), 0) + debugLog("RecognitionService, found: ${list.size}") + list.forEach() { it.serviceInfo?.let { it1 -> debugLog("RecognitionService: packageName: ${it1.packageName}, name: ${it1.name}") } } + return list.firstOrNull()?.serviceInfo?.let { ComponentName(it.packageName, it.name) } + } + + private fun createRecognizer(onDevice: Boolean) { + if ( null != speechRecognizer && onDevice == lastOnDevice ) { + return + } + lastOnDevice = onDevice + speechRecognizer?.destroy() + speechRecognizer = null + handler.post { + run { + debugLog("Creating recognizer") + if (intentLookup) { + speechRecognizer = createSpeechRecognizer( + pluginContext, + pluginContext?.findComponentName() + ).apply { + debugLog("Setting listener after intent lookup") + setRecognitionListener(this@SpeechToTextPlugin) + } + } else { + var supportsLocal = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && onDevice) { + supportsLocal = SpeechRecognizer.isOnDeviceRecognitionAvailable(pluginContext!!) + if (supportsLocal ) { + speechRecognizer = createOnDeviceSpeechRecognizer(pluginContext!!).apply { + debugLog("Setting on device listener") + setRecognitionListener(this@SpeechToTextPlugin) + } + } + } + if ( null == speechRecognizer) { + speechRecognizer = createSpeechRecognizer(pluginContext).apply { + debugLog("Setting default listener") + setRecognitionListener(this@SpeechToTextPlugin) + } + } + } + if (null == speechRecognizer) { + Log.e(logTag, "Speech recognizer null") + activeResult?.error( + SpeechToTextErrors.recognizerNotAvailable.name, + "Speech recognizer null", "" + ) + activeResult = null + } + } + } + debugLog("before setup intent") + setupRecognizerIntent(defaultLanguageTag, true, ListenMode.deviceDefault, false ) + debugLog("after setup intent") + } + + private fun setupRecognizerIntent(languageTag: String, partialResults: Boolean, listenMode: ListenMode, onDevice: Boolean ) { + debugLog("setupRecognizerIntent") + if (previousRecognizerLang == null || + previousRecognizerLang != languageTag || + partialResults != previousPartialResults || previousListenMode != listenMode ) { + previousRecognizerLang = languageTag; + previousPartialResults = partialResults + previousListenMode = listenMode + handler.post { + run { + recognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + debugLog("In RecognizerIntent apply") + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + debugLog("put model") + val localContext = pluginContext + if (null != localContext) { + putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, + localContext.applicationInfo.packageName) + } + debugLog("put package") + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, partialResults) + debugLog("put partial") + if (languageTag != Locale.getDefault().toLanguageTag()) { + putExtra(RecognizerIntent.EXTRA_LANGUAGE, languageTag); + debugLog("put languageTag") + } + if ( onDevice ) { + putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, onDevice ); + } + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS,10) + } + } + } + } + } + + private fun destroyRecognizer() { + + handler.postDelayed( { + run { + debugLog("Recognizer destroy") + speechRecognizer?.destroy(); + speechRecognizer = null; + } + }, 50 ) + } + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray): Boolean { + when (requestCode) { + speechToTextPermissionCode -> { + permissionToRecordAudio = grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED + bluetoothDisabled = (grantResults.isEmpty() || grantResults.size == 1 || + grantResults[1] != PackageManager.PERMISSION_GRANTED) || + noBluetoothOpt + completeInitialize() + return true + } + } + return false + } + + + override fun onPartialResults(results: Bundle?) = updateResults(results, false) + override fun onResults(results: Bundle?) = updateResults(results, true) + override fun onEndOfSpeech() = notifyListening(isRecording = false) + + override fun onError(errorCode: Int) { + val delta = System.currentTimeMillis() - speechStartTime + var errorReturn = errorCode + if ( SpeechRecognizer.ERROR_NO_MATCH == errorCode && maxRms < speechThresholdRms ) { + errorReturn = SpeechRecognizer.ERROR_SPEECH_TIMEOUT + } + debugLog( "Error $errorCode after start at $delta $minRms / $maxRms") + val errorMsg = when (errorReturn) { + SpeechRecognizer.ERROR_AUDIO -> "error_audio_error" + SpeechRecognizer.ERROR_CLIENT -> "error_client" + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "error_permission" + SpeechRecognizer.ERROR_NETWORK -> "error_network" + SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "error_network_timeout" + SpeechRecognizer.ERROR_NO_MATCH -> "error_no_match" + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "error_busy" + SpeechRecognizer.ERROR_SERVER -> "error_server" + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "error_speech_timeout" + SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "error_language_not_supported" + SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "error_language_unavailable" + SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "error_server_disconnected" + SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "error_too_many_requests" + else -> "error_unknown ($errorCode)" + } + + sendError(errorMsg) + if ( isListening()) { + notifyListening(false) + } + } + + private fun debugLog( msg: String ) { + if ( debugLogging ) { + Log.d( logTag, msg ) + } + } + + private fun sendError(errorMsg: String) { + val speechError = JSONObject() + speechError.put("errorMsg", errorMsg) + speechError.put("permanent", true) + handler.post { + run { + channel?.invokeMethod(SpeechToTextCallbackMethods.notifyError.name, speechError.toString()) + } + } + } + + override fun onRmsChanged(rmsdB: Float) { + if ( rmsdB < minRms ) { + minRms = rmsdB + } + if ( rmsdB > maxRms ) { + maxRms = rmsdB + } + debugLog("rmsDB $minRms / $maxRms") + handler.post { + run { + channel?.invokeMethod(SpeechToTextCallbackMethods.soundLevelChange.name, rmsdB) + } + } + } + + override fun onReadyForSpeech(p0: Bundle?) {} + override fun onBufferReceived(p0: ByteArray?) {} + override fun onEvent(p0: Int, p1: Bundle?) {} + override fun onBeginningOfSpeech() {} +} + +// See https://stackoverflow.com/questions/10538791/how-to-set-the-language-in-speech-recognition-on-android/10548680#10548680 +class LanguageDetailsChecker(flutterResult: Result, logging: Boolean ) : BroadcastReceiver() { + private val logTag = "SpeechToTextPlugin" + private val result: Result = flutterResult + private val debugLogging: Boolean = logging + private var supportedLanguages: List? = null + + private var languagePreference: String? = null + + override fun onReceive(context: Context, intent: Intent) { + debugLog( "Received extra language broadcast" ) + val results = getResultExtras(true) + if (results.containsKey(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE)) { + languagePreference = results.getString(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE) + } + if (results.containsKey(RecognizerIntent.EXTRA_SUPPORTED_LANGUAGES)) { + debugLog( "Extra supported languages" ) + supportedLanguages = results.getStringArrayList( + RecognizerIntent.EXTRA_SUPPORTED_LANGUAGES) + createResponse(supportedLanguages) + } + else { + debugLog( "No extra supported languages" ) + createResponse( ArrayList()) + } + } + + public fun createResponse(supportedLanguages: List?) { + val currentLocale = Locale.getDefault() + val localeNames = ArrayList() + localeNames.add(buildIdNameForLocale(currentLocale)) + if (null != supportedLanguages) { + for (lang in supportedLanguages) { + if (currentLocale.toLanguageTag() == lang) { + continue + } + val locale = Locale.forLanguageTag(lang) + localeNames.add(buildIdNameForLocale(locale)) + } + } + result.success(localeNames) + + } + + private fun buildIdNameForLocale(locale: Locale): String { + val name = locale.displayName.replace(':', ' ') + return "${locale.language}_${locale.country}:$name" + } + + private fun debugLog( msg: String ) { + if ( debugLogging ) { + Log.d( logTag, msg ) + } + } +} + +private class ChannelResultWrapper(result: Result) : Result { + // Caller handler + val handler: Handler = Handler(Looper.getMainLooper()) + val result: Result = result + + // make sure to respond in the caller thread + override fun success(results: Any?) { + + handler.post { + run { + result.success(results); + } + } + } + + override fun error(errorCode: String, errorMessage: String?, data: Any?) { + handler.post { + run { + result.error(errorCode, errorMessage, data); + } + } + } + + override fun notImplemented() { + handler.post { + run { + result.notImplemented(); + } + } + } +} \ No newline at end of file diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 15270ab6b..80dbc0bc5 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -6,8 +6,12 @@ PODS: - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS - flutter_local_notifications (0.0.1): - FlutterMacOS + - flutter_tts (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - just_audio (0.0.1): - Flutter @@ -17,9 +21,16 @@ PODS: - FlutterMacOS - record_darwin (1.0.0): - FlutterMacOS + - share_plus (0.0.1): + - FlutterMacOS + - speech_to_text (0.0.1): + - Flutter + - FlutterMacOS + - Try - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS + - Try (2.1.1) - window_size (0.0.2): - FlutterMacOS @@ -27,14 +38,22 @@ DEPENDENCIES: - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/darwin`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) + - flutter_tts (from `Flutter/ephemeral/.symlinks/plugins/flutter_tts/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - record_darwin (from `Flutter/ephemeral/.symlinks/plugins/record_darwin/macos`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - speech_to_text (from `Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) +SPEC REPOS: + trunk: + - Try + EXTERNAL SOURCES: audio_service: :path: Flutter/ephemeral/.symlinks/plugins/audio_service/darwin @@ -42,8 +61,12 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos flutter_local_notifications: :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos + flutter_tts: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_tts/macos FlutterMacOS: :path: Flutter/ephemeral just_audio: @@ -52,6 +75,10 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin record_darwin: :path: Flutter/ephemeral/.symlinks/plugins/record_darwin/macos + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + speech_to_text: + :path: Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin sqflite_darwin: :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin window_size: @@ -61,12 +88,17 @@ SPEC CHECKSUMS: audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 + file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 flutter_local_notifications: 13862b132e32eb858dea558a86d45d08daeacfe7 + flutter_tts: ae915565cc6948444b513acc8ee021993281e027 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 record_darwin: 30509266ae213af8afdb09a8ae7467cb64c1377e + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + speech_to_text: 9dc43a5df3cbc2813f8c7cc9bd0fbf94268ed7ac sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + Try: 5ef669ae832617b3cee58cb2c6f99fb767a4ff96 window_size: 4bd15034e6e3d0720fd77928a7c42e5492cfece9 PODFILE CHECKSUM: 8c4442624fc136b0afb378a2502958e01c05d073 diff --git a/pubspec.yaml b/pubspec.yaml index 372521069..03c31084a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,10 +22,12 @@ dependencies: markdown: ^7.1.1 permission_handler: ^11.4.0 audio_service: ^0.18.12 - web_socket_channel: ^2.4.4 + web_socket_channel: ^3.0.2 flutter_dotenv: ^5.1.0 synchronized: ^3.1.0 intl: ^0.19.0 + crypto: ^3.0.3 + flutter_tts: ^4.2.2 window_size: git: url: https://github.com/google/flutter-desktop-embedding.git @@ -37,6 +39,7 @@ dependencies: record: ^5.2.1 share_plus: ^10.0.0 image_picker: ^1.1.2 + speech_to_text: ^7.0.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index e9a97bd05..eb676d5f0 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterTtsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTtsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); RecordWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ce9dfe67d..47ae460e0 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows + flutter_tts permission_handler_windows record_windows share_plus