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