42 changed files with 6172 additions and 935 deletions
@ -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<Int>("audioSessionId") |
|||
if (audioSessionId == null) { |
|||
result.error("INVALID_ARGUMENT", "音频会话ID不能为空", null) |
|||
return |
|||
} |
|||
result.success(enableEchoCancellation(audioSessionId)) |
|||
} |
|||
"disableEchoCancellation" -> { |
|||
result.success(disableEchoCancellation()) |
|||
} |
|||
"enableNoiseSuppression" -> { |
|||
val audioSessionId = call.argument<Int>("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 |
|||
} |
|||
} |
|||
Binary file not shown.
@ -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 = AppLifecycleState.resumed.obs; |
|||
AppLifecycleState get appLifecycleState => _appLifecycleState.value; |
|||
|
|||
// 状态变化流 |
|||
final _lifecycleStateStream = AppLifecycleState.resumed.obs; |
|||
Stream<AppLifecycleState> 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<VolcanoAsrApiService>(); |
|||
final ttsService = Get.find<VolcanoTtsApiService>(); |
|||
if (asrService.isRecording) { |
|||
Logger.info('应用进入后台,停止语音识别'); |
|||
asrService.stopRecognition(); |
|||
ttsService.stop(); |
|||
ttsService.disconnect(); |
|||
} |
|||
} |
|||
|
|||
/// 处理应用进入后台 |
|||
void _handleAppPaused(AppLifecycleState state) { |
|||
Logger.info('应用进入后台: $state'); |
|||
|
|||
// 获取 VolcanoAsrApiService 实例 |
|||
final asrService = Get.find<VolcanoAsrApiService>(); |
|||
final ttsService = Get.find<VolcanoTtsApiService>(); |
|||
// 如果正在录音,停止录音 |
|||
if (asrService.isRecording) { |
|||
Logger.info('应用进入后台,停止语音识别'); |
|||
asrService.stopRecognition(); |
|||
ttsService.stop(); |
|||
ttsService.disconnect(); |
|||
|
|||
} |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
// 移除应用生命周期观察者 |
|||
WidgetsBinding.instance.removeObserver(this); |
|||
Logger.info('AppLifecycleManager 已关闭'); |
|||
super.onClose(); |
|||
} |
|||
} |
|||
@ -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<void> 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<void> playLocalAudio(String filePath) async { |
|||
try { |
|||
// 停止当前播放 |
|||
await stopPlayback(); |
|||
|
|||
// 设置音频源并播放 |
|||
await _audioPlayer.setFilePath(filePath); |
|||
await _audioPlayer.play(); |
|||
|
|||
Logger.info('播放本地音频文件: $filePath'); |
|||
} catch (e) { |
|||
Logger.error('播放本地音频文件失败', e); |
|||
} |
|||
} |
|||
|
|||
/// 停止播放 |
|||
Future<void> stopPlayback() async { |
|||
try { |
|||
await _audioPlayer.stop(); |
|||
} catch (e) { |
|||
Logger.error('停止播放失败', e); |
|||
} |
|||
} |
|||
|
|||
/// 释放资源 |
|||
void dispose() { |
|||
_audioPlayer.dispose(); |
|||
} |
|||
} |
|||
@ -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<void> _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<bool> 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<bool> 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<bool> 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<bool> 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<int?> getAudioSessionId() async { |
|||
try { |
|||
final int? sessionId = await _channel.invokeMethod('getAudioSessionId'); |
|||
Logger.info('获取音频会话ID: $sessionId'); |
|||
return sessionId; |
|||
} catch (e) { |
|||
Logger.error('获取音频会话ID失败: $e'); |
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
@ -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<dynamic, dynamic> 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 = TtsState.idle.obs; |
|||
TtsState get ttsState => _ttsState.value; |
|||
|
|||
// 合成相关 |
|||
StreamController<SynthesisEvent>? _eventStreamController; |
|||
StreamSubscription? _eventSubscription; |
|||
|
|||
// 公开的事件流 |
|||
Stream<SynthesisEvent>? _synthesisStream; |
|||
Stream<SynthesisEvent>? 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<Map<String, String>> _voices = <Map<String, String>>[].obs; |
|||
List<Map<String, String>> get voices => _voices; |
|||
|
|||
/// 初始化 Azure 文本转语音服务 |
|||
/// |
|||
/// [subscriptionKey] Azure 语音服务的订阅密钥 |
|||
/// [serviceRegion] Azure 语音服务的区域 |
|||
/// [defaultVoice] 默认语音名称,默认为中文女声 |
|||
Future<bool> 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<bool>( |
|||
'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<SynthesisEvent>.broadcast(); |
|||
|
|||
// 设置事件监听 |
|||
_eventSubscription = _eventChannel |
|||
.receiveBroadcastStream() |
|||
.map<SynthesisEvent>((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<List<Map<String, String>>> getAvailableVoices() async { |
|||
_checkInitialized(); |
|||
|
|||
try { |
|||
final result = await _methodChannel.invokeMethod<List<dynamic>>('getAvailableVoices'); |
|||
|
|||
if (result != null) { |
|||
final voicesList = result.map((item) { |
|||
final map = Map<String, dynamic>.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<Map<String, String>>.from(voicesList); |
|||
return _voices; |
|||
} |
|||
|
|||
return []; |
|||
} catch (e) { |
|||
Logger.error('获取可用语音列表失败', e); |
|||
return []; |
|||
} |
|||
} |
|||
|
|||
/// 设置语音 |
|||
Future<bool> _setVoice(String voiceName) async { |
|||
_checkInitialized(); |
|||
|
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'setVoice', |
|||
{'voiceName': voiceName}, |
|||
); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
Logger.error('设置语音失败', e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 设置语速 |
|||
Future<bool> _setSpeechRate(int rate) async { |
|||
_checkInitialized(); |
|||
|
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'setSpeechRate', |
|||
{'rate': rate}, |
|||
); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
Logger.error('设置语速失败', e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 设置音调 |
|||
Future<bool> _setPitch(int pitch) async { |
|||
_checkInitialized(); |
|||
|
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'setPitch', |
|||
{'pitch': pitch}, |
|||
); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
Logger.error('设置音调失败', e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 设置音量 |
|||
Future<bool> _setVolume(int volume) async { |
|||
_checkInitialized(); |
|||
|
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'setVolume', |
|||
{'volume': volume}, |
|||
); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
Logger.error('设置音量失败', e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 合成文本为语音并保存到文件 |
|||
/// |
|||
/// [text] 要合成的文本 |
|||
/// [fileName] 保存的文件名(不包含路径和扩展名) |
|||
/// |
|||
/// 返回保存的音频文件路径,如果失败则返回 null |
|||
Future<String?> 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<String>( |
|||
'synthesizeToFile', |
|||
{ |
|||
'text': text, |
|||
'filePath': filePath, |
|||
}, |
|||
); |
|||
|
|||
// 合成完成后设置状态 |
|||
_ttsState.value = TtsState.idle; |
|||
|
|||
return result; |
|||
} catch (e) { |
|||
Logger.error('合成文本到文件失败', e); |
|||
|
|||
// 发生错误时设置状态 |
|||
_ttsState.value = TtsState.idle; |
|||
|
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/// 合成文本并直接播放 |
|||
/// |
|||
/// [text] 要合成并播放的文本 |
|||
/// |
|||
/// 返回是否成功开始播放 |
|||
Future<bool> speak(String text) async { |
|||
_checkInitialized(); |
|||
|
|||
if (text.isEmpty) { |
|||
Logger.warning('播放文本为空'); |
|||
return false; |
|||
} |
|||
|
|||
try { |
|||
// 设置状态 |
|||
_ttsState.value = TtsState.synthesizing; |
|||
|
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'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<bool> stop() async { |
|||
_checkInitialized(); |
|||
|
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('stop'); |
|||
|
|||
if (result == true) { |
|||
_ttsState.value = TtsState.stopped; |
|||
} |
|||
|
|||
return result ?? false; |
|||
} catch (e) { |
|||
Logger.error('停止播放失败', e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 暂停播放 |
|||
Future<bool> pause() async { |
|||
_checkInitialized(); |
|||
|
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('pause'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
Logger.error('暂停播放失败', e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 恢复播放 |
|||
Future<bool> resume() async { |
|||
_checkInitialized(); |
|||
|
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('resume'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
Logger.error('恢复播放失败', e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 检查是否已初始化 |
|||
void _checkInitialized() { |
|||
if (!_isInitialized) { |
|||
throw StateError('Azure 文本转语音服务未初始化'); |
|||
} |
|||
} |
|||
|
|||
/// 释放资源 |
|||
Future<void> 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<bool>('dispose'); |
|||
} |
|||
|
|||
_isInitialized = false; |
|||
Logger.info('Azure 文本转语音服务资源已释放'); |
|||
} catch (e) { |
|||
Logger.error('释放 Azure 文本转语音服务资源失败', e); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
dispose(); |
|||
super.onClose(); |
|||
} |
|||
} |
|||
@ -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 = AsrState.notInitialized.obs; |
|||
AsrState get asrState => _asrState.value; |
|||
|
|||
// 可用的语音识别语言 |
|||
final RxList<LocaleName> _locales = <LocaleName>[].obs; |
|||
List<LocaleName> 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<String>.broadcast(); |
|||
Stream<String> get recognitionResults => _recognitionResultsController.stream; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_initSpeech(); |
|||
} |
|||
|
|||
/// 初始化语音识别 |
|||
Future<void> _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<bool> 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<void> stopListening() async { |
|||
if (!_speech.isListening) return; |
|||
|
|||
try { |
|||
await _speech.stop(); |
|||
_asrState.value = AsrState.notListening; |
|||
Logger.info('停止语音识别监听'); |
|||
} catch (e) { |
|||
Logger.error('停止语音识别监听出错: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 取消监听 |
|||
Future<void> 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(); |
|||
} |
|||
} |
|||
@ -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<Map<String, String>> _voices = []; |
|||
List<Map<String, String>> 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<String> _textQueue = []; |
|||
List<String> get textQueue => _textQueue; |
|||
|
|||
// 当前正在播放的文本 |
|||
String _currentText = ''; |
|||
String get currentText => _currentText; |
|||
|
|||
// 是否正在处理队列 |
|||
bool _isProcessingQueue = false; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_initTts(); |
|||
} |
|||
|
|||
/// 初始化TTS引擎 |
|||
Future<void> _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<void> _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<String, String> 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<void> setVoice(String voiceName) async { |
|||
try { |
|||
_currentVoice = voiceName; |
|||
await _flutterTts.setVoice({"name": voiceName}); |
|||
} catch (e) { |
|||
Logger.error('设置TTS语音失败', e); |
|||
} |
|||
} |
|||
|
|||
/// 设置语速 |
|||
Future<void> setRate(double rate) async { |
|||
try { |
|||
_rate = rate; |
|||
await _flutterTts.setSpeechRate(rate); |
|||
} catch (e) { |
|||
Logger.error('设置TTS语速失败', e); |
|||
} |
|||
} |
|||
|
|||
/// 设置音量 |
|||
Future<void> setVolume(double volume) async { |
|||
try { |
|||
_volume = volume; |
|||
await _flutterTts.setVolume(volume); |
|||
} catch (e) { |
|||
Logger.error('设置TTS音量失败', e); |
|||
} |
|||
} |
|||
|
|||
/// 设置音调 |
|||
Future<void> setPitch(double pitch) async { |
|||
try { |
|||
_pitch = pitch; |
|||
await _flutterTts.setPitch(pitch); |
|||
} catch (e) { |
|||
Logger.error('设置TTS音调失败', e); |
|||
} |
|||
} |
|||
|
|||
/// 设置语言 |
|||
Future<void> setLanguage(String language) async { |
|||
try { |
|||
await _flutterTts.setLanguage(language); |
|||
} catch (e) { |
|||
Logger.error('设置TTS语言失败', e); |
|||
} |
|||
} |
|||
|
|||
/// 添加文本到队列并开始处理队列(如果尚未开始) |
|||
Future<void> speak(String text) async { |
|||
if (text.isEmpty) return; |
|||
|
|||
// 添加到队列 |
|||
_addToQueue(text); |
|||
|
|||
// 开始处理队列(如果尚未开始) |
|||
_startQueueProcessingIfNeeded(); |
|||
} |
|||
|
|||
/// 添加多条文本到队列并开始处理队列(如果尚未开始) |
|||
Future<void> speakTexts(List<String> 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<void> _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<void> stop() async { |
|||
try { |
|||
await _flutterTts.stop(); |
|||
|
|||
// 重置队列处理状态 |
|||
_textQueue.clear(); |
|||
_currentText = ''; |
|||
_isProcessingQueue = false; |
|||
_ttsState = TtsState.stopped; |
|||
|
|||
Logger.info('已停止TTS播放并清空队列'); |
|||
} catch (e) { |
|||
Logger.error('停止TTS播放失败', e); |
|||
} |
|||
} |
|||
|
|||
/// 暂停播放 |
|||
Future<void> pause() async { |
|||
try { |
|||
await _flutterTts.pause(); |
|||
_ttsState = TtsState.paused; |
|||
} catch (e) { |
|||
Logger.error('暂停TTS播放失败', e); |
|||
} |
|||
} |
|||
|
|||
/// 清空队列并停止播放 |
|||
Future<void> clearQueue() async { |
|||
_textQueue.clear(); |
|||
await stop(); |
|||
Logger.info('已清空TTS队列'); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_flutterTts.stop(); |
|||
super.onClose(); |
|||
} |
|||
} |
|||
|
|||
/// TTS状态枚举 |
|||
enum TtsState { |
|||
playing, |
|||
stopped, |
|||
paused, |
|||
} |
|||
@ -0,0 +1,97 @@ |
|||
import 'dart:convert'; |
|||
|
|||
/// 函数服务 |
|||
/// 仅实现各种功能函数,不包含AI交互逻辑 |
|||
class FunctionService { |
|||
/// 天气查询工具定义 |
|||
/// 返回一个可直接用于tools参数的天气查询工具定义 |
|||
static Map<String, dynamic> 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<Map<String, dynamic>> getWeatherInfo(Map<String, dynamic> 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<String, dynamic> parseFunctionCallArguments(Map<String, dynamic> 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<String, String> getFunctionCallInfo(Map<String, dynamic> 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; |
|||
} |
|||
File diff suppressed because it is too large
@ -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 = <String>[].obs; |
|||
|
|||
// 是否使用标准语音识别SDK |
|||
final bool _useStandardASR = true; // 使用标准语音识别SDK (API v2) |
|||
|
|||
// 添加识别结果流控制器 |
|||
final _recognitionStreamController = StreamController<RecognitionEvent>.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<String> get recognitionResults => _recognitionResults; |
|||
|
|||
// 添加识别结果流getter |
|||
Stream<RecognitionEvent> get recognitionStream => _recognitionStreamController.stream; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_setupMethodCallHandler(); |
|||
} |
|||
|
|||
/// 设置方法通道处理器 |
|||
Future<void> _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<bool> 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<String, dynamic> 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<bool> 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<bool> 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<dynamic, dynamic> 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<bool> 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<void> dispose() async { |
|||
try { |
|||
if (_isListening.value) { |
|||
await stopRecognition(); |
|||
} |
|||
_cleanupEventStream(); |
|||
} catch (e) { |
|||
Logger.error('清理语音识别资源失败: $e'); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_eventSubscription?.cancel(); |
|||
_recognitionStreamController.close(); |
|||
super.onClose(); |
|||
} |
|||
} |
|||
@ -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<void> onInit() async { |
|||
super.onInit(); |
|||
try { |
|||
await _initializeTtsEngine(); |
|||
} catch (e, stackTrace) { |
|||
print('初始化TTS服务失败: $e'); |
|||
print('Stack trace: $stackTrace'); |
|||
} |
|||
} |
|||
|
|||
/// 初始化TTS引擎 |
|||
Future<void> _initializeTtsEngine() async { |
|||
try { |
|||
print('开始初始化火山语音TTS引擎'); |
|||
|
|||
final result = await _channel.invokeMethod<bool>('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<bool> 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<bool>('speakText', { |
|||
'text': text, |
|||
'voiceType': voiceType ?? _voiceType, |
|||
}); |
|||
|
|||
return success ?? false; |
|||
} catch (e) { |
|||
print('语音合成失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 停止播放 |
|||
Future<bool> stop() async { |
|||
if (!_isInitialized) { |
|||
print('TTS引擎尚未初始化'); |
|||
return false; |
|||
} |
|||
|
|||
try { |
|||
print('停止播放'); |
|||
|
|||
// 调用原生方法停止播放 |
|||
final success = await _channel.invokeMethod<bool>('stopSpeaking'); |
|||
|
|||
print('已停止播放'); |
|||
return success ?? false; |
|||
} catch (e) { |
|||
print('停止播放失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 切换TTS启用状态 |
|||
void toggleEnabled() { |
|||
isEnabled.toggle(); |
|||
if (!isEnabled.value) { |
|||
stop(); |
|||
} |
|||
} |
|||
|
|||
/// 设置播放速度 |
|||
Future<bool> 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<bool>('setSpeed', { |
|||
'speed': speed, |
|||
}); |
|||
|
|||
return success ?? false; |
|||
} catch (e) { |
|||
print('设置播放速度失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 设置音量 |
|||
Future<bool> 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<bool>('setVolume', { |
|||
'volume': volume, |
|||
}); |
|||
|
|||
return success ?? false; |
|||
} catch (e) { |
|||
print('设置音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 清理资源 |
|||
Future<void> _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; |
|||
} |
|||
|
|||
} |
|||
@ -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<FlutterAsrService>()) { |
|||
Get.lazyPut<FlutterAsrService>(() => FlutterAsrService()); |
|||
} |
|||
|
|||
// 注册控制器 |
|||
Get.lazyPut<FlutterAsrTestController>(() => FlutterAsrTestController()); |
|||
} |
|||
} |
|||
@ -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>(() => FlutterTtsTestController()); |
|||
} |
|||
} |
|||
@ -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>(VolcanoAsrTestController()); |
|||
} |
|||
} |
|||
@ -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<FlutterAsrService>(); |
|||
|
|||
// 当前ASR状态 - 使用Rx包装以便UI可以响应变化 |
|||
final Rx<AsrState> _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<dynamic> _locales = <dynamic>[].obs; |
|||
List<dynamic> get locales => _locales; |
|||
|
|||
// 当前选择的语言 - 使用Rx包装以便UI可以响应变化 |
|||
final RxString _currentLocale = ''.obs; |
|||
String get currentLocale => _currentLocale.value; |
|||
|
|||
// 历史记录 - 使用Rx包装以便UI可以响应变化 |
|||
final RxList<String> _history = <String>[].obs; |
|||
List<String> 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<String>? _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<bool> startListening() async { |
|||
return await _asrService.startListening(); |
|||
} |
|||
|
|||
/// 停止语音识别 |
|||
Future<void> stopListening() async { |
|||
await _asrService.stopListening(); |
|||
} |
|||
|
|||
/// 取消语音识别 |
|||
Future<void> cancelListening() async { |
|||
await _asrService.cancelListening(); |
|||
} |
|||
|
|||
/// 切换连续识别模式 |
|||
void toggleContinuousMode() { |
|||
_continuousMode.value = !_continuousMode.value; |
|||
Logger.info('连续识别模式: ${_continuousMode.value ? '开启' : '关闭'}'); |
|||
|
|||
// 如果开启连续模式且当前未在监听,则开始监听 |
|||
if (_continuousMode.value && !isListening && _isAvailable.value) { |
|||
startListening(); |
|||
} |
|||
} |
|||
|
|||
/// 重新开始监听(用于连续模式) |
|||
Future<void> _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(); |
|||
} |
|||
} |
|||
@ -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<FlutterTtsService>(); |
|||
|
|||
// 当前TTS状态 - 使用Rx包装以便UI可以响应变化 |
|||
final Rx<TtsState> _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<String> _textQueue = <String>[].obs; |
|||
List<String> get textQueue => _textQueue; |
|||
|
|||
// 可用的语音列表 - 使用Rx包装以便UI可以响应变化 |
|||
final RxList<Map<String, String>> _voices = <Map<String, String>>[].obs; |
|||
List<Map<String, String>> 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<T>(List<T> a, List<T> 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<void> speak(String text) async { |
|||
if (text.isEmpty) { |
|||
Logger.warning('文本为空,无法播放'); |
|||
return; |
|||
} |
|||
|
|||
await _ttsService.speak(text); |
|||
_updateState(); |
|||
} |
|||
|
|||
/// 添加多条文本到队列并开始播放(如果尚未开始) |
|||
Future<void> speakTexts(List<String> texts) async { |
|||
if (texts.isEmpty) { |
|||
Logger.warning('文本列表为空,无法播放'); |
|||
return; |
|||
} |
|||
|
|||
await _ttsService.speakTexts(texts); |
|||
_updateState(); |
|||
} |
|||
|
|||
/// 停止播放并清空队列 |
|||
Future<void> stop() async { |
|||
await _ttsService.stop(); |
|||
_updateState(); |
|||
} |
|||
|
|||
/// 暂停播放 |
|||
Future<void> pause() async { |
|||
await _ttsService.pause(); |
|||
_updateState(); |
|||
} |
|||
|
|||
/// 设置语音 |
|||
Future<void> setVoice(String voiceName) async { |
|||
await _ttsService.setVoice(voiceName); |
|||
_updateState(); |
|||
} |
|||
|
|||
/// 设置语言 |
|||
Future<void> setLanguage(String language) async { |
|||
await _ttsService.setLanguage(language); |
|||
} |
|||
|
|||
/// 设置语速 |
|||
Future<void> setRate(double rate) async { |
|||
await _ttsService.setRate(rate); |
|||
} |
|||
|
|||
/// 设置音量 |
|||
Future<void> setVolume(double volume) async { |
|||
await _ttsService.setVolume(volume); |
|||
} |
|||
|
|||
/// 设置音调 |
|||
Future<void> setPitch(double pitch) async { |
|||
await _ttsService.setPitch(pitch); |
|||
} |
|||
|
|||
/// 清空队列并停止播放 |
|||
Future<void> clearQueue() async { |
|||
await _ttsService.clearQueue(); |
|||
_updateState(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_updateTimer.cancel(); |
|||
super.onClose(); |
|||
} |
|||
} |
|||
@ -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 = <String>[].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<VolcanoAsrApiService>(); |
|||
|
|||
// 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<void> 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<void> _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; |
|||
} |
|||
} |
|||
@ -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<FlutterAsrTestController> { |
|||
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<String>( |
|||
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<String>( |
|||
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; |
|||
} |
|||
} |
|||
} |
|||
@ -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<FlutterTtsTestController> { |
|||
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<String>( |
|||
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<String>( |
|||
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<String> _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支持多种语音和参数调节,可以根据需要进行设置。', |
|||
]; |
|||
} |
|||
} |
|||
@ -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<VolcanoAsrTestController> { |
|||
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<bool>( |
|||
title: const Text('一次性识别'), |
|||
value: false, |
|||
groupValue: controller.isContinuous.value, |
|||
onChanged: (value) { |
|||
if (value != null) { |
|||
controller.isContinuous.value = value; |
|||
} |
|||
}, |
|||
), |
|||
), |
|||
Expanded( |
|||
child: RadioListTile<bool>( |
|||
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()), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -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<android.bluetooth.BluetoothDevice>? = 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<Boolean>("debugLogging") |
|||
if (null != dlog) { |
|||
debugLogging = dlog |
|||
} |
|||
var ausOpt = call.argument<Boolean>("alwaysUseStop") |
|||
if (null != ausOpt) { |
|||
alwaysUseStop = ausOpt == true |
|||
} |
|||
var iOpt = call.argument<Boolean>("intentLookup") |
|||
if (null != iOpt) { |
|||
intentLookup = iOpt == true |
|||
} |
|||
var noBtOpt = call.argument<Boolean>("noBluetooth") |
|||
if (null != noBtOpt) { |
|||
noBluetoothOpt = noBtOpt == true |
|||
} |
|||
initialize(result) |
|||
} |
|||
"listen" -> { |
|||
var localeId = call.argument<String>("localeId") |
|||
if (null == localeId) { |
|||
localeId = defaultLanguageTag |
|||
} |
|||
var partialResults = call.argument<Boolean>("partialResults") |
|||
if (null == partialResults) { |
|||
partialResults = true |
|||
} |
|||
var onDevice = call.argument<Boolean>("onDevice") |
|||
if ( null == onDevice ) { |
|||
onDevice = false |
|||
} |
|||
val listenModeIndex = call.argument<Int>("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<ResolveInfo> = 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<out String>, 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<String>? = 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<String>()) |
|||
} |
|||
} |
|||
|
|||
public fun createResponse(supportedLanguages: List<String>?) { |
|||
val currentLocale = Locale.getDefault() |
|||
val localeNames = ArrayList<String>() |
|||
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(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue