26 changed files with 2623 additions and 2154 deletions
@ -1,165 +0,0 @@ |
|||||
package com.example.deep_voice |
|
||||
|
|
||||
import android.util.Log |
|
||||
import com.microsoft.cognitiveservices.speech.* |
|
||||
import java.util.concurrent.Future |
|
||||
|
|
||||
/** |
|
||||
* Microsoft Text-to-Speech Helper |
|
||||
* |
|
||||
* 该类封装了微软语音 SDK 的 TTS 功能,提供简单的接口供 Flutter 调用 |
|
||||
*/ |
|
||||
class TextToSpeechHelper { |
|
||||
private val TAG = "TextToSpeechHelper" |
|
||||
private var speechConfig: SpeechConfig? = null |
|
||||
private var synthesizer: SpeechSynthesizer? = null |
|
||||
private var isInitialized = false |
|
||||
|
|
||||
/** |
|
||||
* 初始化 TTS 引擎 |
|
||||
* |
|
||||
* @param subscriptionKey Azure 语音服务订阅密钥 |
|
||||
* @param serviceRegion Azure 语音服务区域 |
|
||||
*/ |
|
||||
fun initialize(subscriptionKey: String, serviceRegion: String) { |
|
||||
try { |
|
||||
speechConfig = SpeechConfig.fromSubscription(subscriptionKey, serviceRegion) |
|
||||
// 默认设置中文女声 |
|
||||
speechConfig?.setSpeechSynthesisVoiceName("zh-CN-XiaoxiaoNeural") |
|
||||
synthesizer = SpeechSynthesizer(speechConfig) |
|
||||
isInitialized = true |
|
||||
Log.d(TAG, "TTS 引擎初始化成功") |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "TTS 引擎初始化失败: ${e.message}") |
|
||||
throw e |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置语音 |
|
||||
* |
|
||||
* @param voiceName 语音名称,例如 "zh-CN-XiaoxiaoNeural" |
|
||||
*/ |
|
||||
fun setVoice(voiceName: String) { |
|
||||
if (!isInitialized) { |
|
||||
throw Exception("TTS 引擎尚未初始化") |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
speechConfig?.setSpeechSynthesisVoiceName(voiceName) |
|
||||
// 重新创建合成器以应用新的语音设置 |
|
||||
synthesizer?.close() |
|
||||
synthesizer = SpeechSynthesizer(speechConfig) |
|
||||
Log.d(TAG, "已设置语音: $voiceName") |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "设置语音失败: ${e.message}") |
|
||||
throw e |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 合成文本为语音并播放 |
|
||||
* |
|
||||
* @param text 要合成的文本 |
|
||||
* @param callback 回调接口,用于返回结果或错误 |
|
||||
*/ |
|
||||
fun speakText(text: String, callback: TTSCallback) { |
|
||||
if (!isInitialized) { |
|
||||
callback.onError("TTS 引擎尚未初始化") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
Log.d(TAG, "开始合成文本: $text") |
|
||||
val task: Future<SpeechSynthesisResult> = synthesizer!!.SpeakTextAsync(text) |
|
||||
|
|
||||
// 异步获取结果 |
|
||||
val result = task.get() |
|
||||
|
|
||||
when (result.reason) { |
|
||||
ResultReason.SynthesizingAudioCompleted -> { |
|
||||
Log.d(TAG, "语音合成完成") |
|
||||
callback.onSuccess("语音合成完成") |
|
||||
} |
|
||||
ResultReason.Canceled -> { |
|
||||
val cancellation = SpeechSynthesisCancellationDetails.fromResult(result) |
|
||||
Log.e(TAG, "语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}") |
|
||||
callback.onError("语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}") |
|
||||
} |
|
||||
else -> { |
|
||||
Log.e(TAG, "语音合成失败: ${result.reason}") |
|
||||
callback.onError("语音合成失败: ${result.reason}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
result.close() |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "语音合成异常: ${e.message}") |
|
||||
callback.onError("语音合成异常: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 合成 SSML 为语音并播放 |
|
||||
* |
|
||||
* @param ssml SSML 格式的文本 |
|
||||
* @param callback 回调接口,用于返回结果或错误 |
|
||||
*/ |
|
||||
fun speakSsml(ssml: String, callback: TTSCallback) { |
|
||||
if (!isInitialized) { |
|
||||
callback.onError("TTS 引擎尚未初始化") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
Log.d(TAG, "开始合成 SSML") |
|
||||
val task: Future<SpeechSynthesisResult> = synthesizer!!.SpeakSsmlAsync(ssml) |
|
||||
|
|
||||
// 异步获取结果 |
|
||||
val result = task.get() |
|
||||
|
|
||||
when (result.reason) { |
|
||||
ResultReason.SynthesizingAudioCompleted -> { |
|
||||
Log.d(TAG, "语音合成完成") |
|
||||
callback.onSuccess("语音合成完成") |
|
||||
} |
|
||||
ResultReason.Canceled -> { |
|
||||
val cancellation = SpeechSynthesisCancellationDetails.fromResult(result) |
|
||||
Log.e(TAG, "语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}") |
|
||||
callback.onError("语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}") |
|
||||
} |
|
||||
else -> { |
|
||||
Log.e(TAG, "语音合成失败: ${result.reason}") |
|
||||
callback.onError("语音合成失败: ${result.reason}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
result.close() |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "语音合成异常: ${e.message}") |
|
||||
callback.onError("语音合成异常: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 释放资源 |
|
||||
*/ |
|
||||
fun dispose() { |
|
||||
try { |
|
||||
synthesizer?.close() |
|
||||
speechConfig?.close() |
|
||||
isInitialized = false |
|
||||
Log.d(TAG, "TTS 引擎已释放") |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "释放 TTS 引擎失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* TTS 回调接口 |
|
||||
*/ |
|
||||
interface TTSCallback { |
|
||||
fun onSuccess(message: String) |
|
||||
fun onError(error: String) |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,412 @@ |
|||||
|
package com.example.deep_voice |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.util.Log |
||||
|
import com.bytedance.speech.speechengine.SpeechEngine |
||||
|
import com.bytedance.speech.speechengine.SpeechEngineDefines |
||||
|
import com.bytedance.speech.speechengine.SpeechEngineGenerator |
||||
|
import java.util.concurrent.CountDownLatch |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
|
||||
|
/** |
||||
|
* 火山语音合成Helper类 (SDK版本) |
||||
|
* |
||||
|
* 该类封装了火山语音SDK的TTS功能,提供简单的接口供Flutter调用 |
||||
|
*/ |
||||
|
class VolcanoTtsHelper : SpeechEngine.SpeechListener { |
||||
|
private val TAG = "VolcanoTtsHelper" |
||||
|
private var mSpeechEngine: SpeechEngine? = null |
||||
|
private var mSpeechEngineHandler: Long = -1 |
||||
|
private var isInitialized = false |
||||
|
private var applicationContext: Context? = null |
||||
|
|
||||
|
// 同步合成相关变量 |
||||
|
private var mSynthesisLatch: CountDownLatch? = null |
||||
|
private var mSynthesisAudioData: ByteArray? = null |
||||
|
private var mSynthesisError: String? = null |
||||
|
|
||||
|
// 当前回调 |
||||
|
private var mCurrentCallback: VolcanoTtsCallback? = null |
||||
|
|
||||
|
/** |
||||
|
* 初始化TTS引擎 |
||||
|
* |
||||
|
* @param appId 火山语音服务AppID |
||||
|
* @param token 火山语音服务Token |
||||
|
* @param cluster 火山语音服务集群 |
||||
|
*/ |
||||
|
fun initialize(appId: String, token: String, cluster: String): Boolean { |
||||
|
try { |
||||
|
// 确保应用上下文已设置 |
||||
|
if (applicationContext == null) { |
||||
|
Log.e(TAG, "应用上下文未设置,请先调用setContext方法") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 准备环境 |
||||
|
SpeechEngineGenerator.PrepareEnvironment(applicationContext, null) |
||||
|
|
||||
|
// 获取语音引擎实例 |
||||
|
mSpeechEngine = SpeechEngineGenerator.getInstance() |
||||
|
mSpeechEngineHandler = mSpeechEngine?.createEngine() ?: -1 |
||||
|
|
||||
|
if (mSpeechEngineHandler == -1L) { |
||||
|
Log.e(TAG, "创建语音引擎失败") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 设置上下文 |
||||
|
mSpeechEngine?.setContext(applicationContext) |
||||
|
|
||||
|
// 设置引擎类型为TTS |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, |
||||
|
SpeechEngineDefines.TTS_ENGINE |
||||
|
) |
||||
|
|
||||
|
// 设置日志级别 |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, |
||||
|
SpeechEngineDefines.LOG_LEVEL_WARN |
||||
|
) |
||||
|
|
||||
|
// 设置用户ID (必需) |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_UID_STRING, |
||||
|
"deep_voice_user" |
||||
|
) |
||||
|
|
||||
|
// 设置设备ID (可选) |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, |
||||
|
"deep_voice_device" |
||||
|
) |
||||
|
|
||||
|
// 设置授权信息 |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, |
||||
|
appId |
||||
|
) |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, |
||||
|
"Bearer;$token" |
||||
|
) |
||||
|
|
||||
|
// 设置集群 |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_CLUSTER_STRING, |
||||
|
cluster |
||||
|
) |
||||
|
|
||||
|
// 设置合成场景为单次合成 |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, |
||||
|
SpeechEngineDefines.TTS_SCENARIO_TYPE_NORMAL |
||||
|
) |
||||
|
|
||||
|
// 设置合成策略为在线合成 |
||||
|
mSpeechEngine?.setOptionInt( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_WORK_MODE_INT, |
||||
|
SpeechEngineDefines.TTS_WORK_MODE_ONLINE |
||||
|
) |
||||
|
|
||||
|
// 设置在线请求资源配置 |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_ADDRESS_STRING, |
||||
|
"wss://openspeech.bytedance.com" |
||||
|
) |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_URI_STRING, |
||||
|
"/api/v1/tts/ws_binary" |
||||
|
) |
||||
|
|
||||
|
// 启用播放器 |
||||
|
mSpeechEngine?.setOptionBoolean( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_ENABLE_PLAYER_BOOL, |
||||
|
true |
||||
|
) |
||||
|
|
||||
|
// 设置音频流类型为媒体 |
||||
|
mSpeechEngine?.setOptionInt( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_AUDIO_STREAM_TYPE_INT, |
||||
|
SpeechEngineDefines.AUDIO_STREAM_TYPE_MEDIA |
||||
|
) |
||||
|
|
||||
|
// 设置音频格式为PCM |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
"tts_audio_format", |
||||
|
"pcm" |
||||
|
) |
||||
|
|
||||
|
// 初始化引擎 |
||||
|
val ret = mSpeechEngine?.initEngine(mSpeechEngineHandler) ?: -1 |
||||
|
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { |
||||
|
Log.e(TAG, "初始化引擎失败: $ret") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 设置监听器 |
||||
|
mSpeechEngine?.setListener(this) |
||||
|
|
||||
|
isInitialized = true |
||||
|
Log.d(TAG, "火山语音TTS引擎初始化成功") |
||||
|
return true |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "火山语音TTS引擎初始化失败: ${e.message}") |
||||
|
e.printStackTrace() |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置应用上下文 |
||||
|
*/ |
||||
|
fun setContext(context: Context) { |
||||
|
applicationContext = context.applicationContext |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 合成文本为语音 |
||||
|
* |
||||
|
* @param text 要合成的文本 |
||||
|
* @param voiceType 语音类型 |
||||
|
* @param callback 回调接口,用于返回结果或错误 |
||||
|
*/ |
||||
|
fun synthesize(text: String, voiceType: String, callback: VolcanoTtsCallback) { |
||||
|
if (!isInitialized) { |
||||
|
callback.onError("TTS引擎尚未初始化") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
Log.d(TAG, "开始合成文本: $text") |
||||
|
|
||||
|
// 保存回调以便在onMessage中使用 |
||||
|
mCurrentCallback = callback |
||||
|
|
||||
|
// 设置发音人 |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, |
||||
|
voiceType |
||||
|
) |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_TYPE_ONLINE_STRING, |
||||
|
"common" |
||||
|
) |
||||
|
|
||||
|
// 设置文本 |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, |
||||
|
text |
||||
|
) |
||||
|
|
||||
|
// 设置音频格式为PCM |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
"tts_audio_format", |
||||
|
"pcm" |
||||
|
) |
||||
|
|
||||
|
// 启用音频数据回调 |
||||
|
mSpeechEngine?.setOptionInt( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_DATA_CALLBACK_MODE_INT, |
||||
|
2 |
||||
|
) |
||||
|
|
||||
|
// 先停止引擎,避免SDK内部异步线程带来的问题 |
||||
|
mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") |
||||
|
|
||||
|
// 启动引擎,在单次合成场景下,这会自动开始合成 |
||||
|
val ret = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") |
||||
|
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { |
||||
|
callback.onError("启动引擎失败: $ret") |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "语音合成异常: ${e.message}") |
||||
|
callback.onError("语音合成异常: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 同步合成文本为语音 |
||||
|
* |
||||
|
* @param text 要合成的文本 |
||||
|
* @param voiceType 语音类型 |
||||
|
* @return 合成的音频数据,如果失败则返回null |
||||
|
*/ |
||||
|
fun synthesizeSync(text: String, voiceType: String): ByteArray? { |
||||
|
if (!isInitialized) { |
||||
|
Log.e(TAG, "TTS引擎尚未初始化") |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
Log.d(TAG, "开始同步合成文本: $text") |
||||
|
|
||||
|
// 重置同步变量 |
||||
|
mSynthesisLatch = CountDownLatch(1) |
||||
|
mSynthesisAudioData = null |
||||
|
mSynthesisError = null |
||||
|
|
||||
|
// 设置发音人 |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, |
||||
|
voiceType |
||||
|
) |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_TYPE_ONLINE_STRING, |
||||
|
"common" |
||||
|
) |
||||
|
|
||||
|
// 设置文本 |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, |
||||
|
text |
||||
|
) |
||||
|
|
||||
|
// 设置音频格式为PCM |
||||
|
mSpeechEngine?.setOptionString( |
||||
|
mSpeechEngineHandler, |
||||
|
"tts_audio_format", |
||||
|
"pcm" |
||||
|
) |
||||
|
|
||||
|
// 启用音频数据回调 |
||||
|
mSpeechEngine?.setOptionInt( |
||||
|
mSpeechEngineHandler, |
||||
|
SpeechEngineDefines.PARAMS_KEY_TTS_DATA_CALLBACK_MODE_INT, |
||||
|
2 |
||||
|
) |
||||
|
|
||||
|
// 先停止引擎,避免SDK内部异步线程带来的问题 |
||||
|
mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") |
||||
|
|
||||
|
// 启动引擎,在单次合成场景下,这会自动开始合成 |
||||
|
val ret = mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") |
||||
|
if (ret != SpeechEngineDefines.ERR_NO_ERROR) { |
||||
|
Log.e(TAG, "启动引擎失败: $ret") |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
// 等待合成完成,最多等待10秒 |
||||
|
mSynthesisLatch?.await(10, TimeUnit.SECONDS) |
||||
|
|
||||
|
if (mSynthesisError != null) { |
||||
|
Log.e(TAG, "同步语音合成失败: ${mSynthesisError}") |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
if (mSynthesisAudioData == null) { |
||||
|
Log.e(TAG, "同步语音合成超时或返回空数据") |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
Log.d(TAG, "同步语音合成成功,音频数据大小: ${mSynthesisAudioData?.size} bytes") |
||||
|
return mSynthesisAudioData |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "同步语音合成异常: ${e.message}") |
||||
|
return null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 释放资源 |
||||
|
*/ |
||||
|
fun dispose() { |
||||
|
try { |
||||
|
if (mSpeechEngineHandler != -1L) { |
||||
|
mSpeechEngine?.sendDirective(mSpeechEngineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") |
||||
|
mSpeechEngine?.destroyEngine(mSpeechEngineHandler) |
||||
|
mSpeechEngineHandler = -1 |
||||
|
} |
||||
|
mSpeechEngine = null |
||||
|
isInitialized = false |
||||
|
Log.d(TAG, "火山语音TTS引擎已释放") |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "释放火山语音TTS引擎失败: ${e.message}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 实现SpeechListener接口 |
||||
|
override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { |
||||
|
val stdData = String(data) |
||||
|
|
||||
|
when (type) { |
||||
|
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { |
||||
|
Log.d(TAG, "引擎启动成功: $stdData") |
||||
|
} |
||||
|
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { |
||||
|
Log.d(TAG, "引擎已停止: $stdData") |
||||
|
} |
||||
|
SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { |
||||
|
Log.e(TAG, "引擎错误: $stdData") |
||||
|
mCurrentCallback?.onError("引擎错误: $stdData") |
||||
|
mSynthesisError = "引擎错误: $stdData" |
||||
|
mSynthesisLatch?.countDown() |
||||
|
} |
||||
|
SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_BEGIN -> { |
||||
|
Log.d(TAG, "合成开始: $stdData") |
||||
|
} |
||||
|
SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_END -> { |
||||
|
Log.d(TAG, "合成结束: $stdData") |
||||
|
} |
||||
|
SpeechEngineDefines.MESSAGE_TYPE_TTS_START_PLAYING -> { |
||||
|
Log.d(TAG, "开始播放: $stdData") |
||||
|
} |
||||
|
SpeechEngineDefines.MESSAGE_TYPE_TTS_FINISH_PLAYING -> { |
||||
|
Log.d(TAG, "播放结束: $stdData") |
||||
|
} |
||||
|
SpeechEngineDefines.MESSAGE_TYPE_TTS_AUDIO_DATA -> { |
||||
|
Log.d(TAG, "收到音频数据") |
||||
|
// 处理音频数据 |
||||
|
if (data.isNotEmpty()) { |
||||
|
try { |
||||
|
mCurrentCallback?.onSuccess(data) |
||||
|
mSynthesisAudioData = data |
||||
|
mSynthesisLatch?.countDown() |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "处理音频数据失败: ${e.message}") |
||||
|
mCurrentCallback?.onError("处理音频数据失败: ${e.message}") |
||||
|
mSynthesisError = "处理音频数据失败: ${e.message}" |
||||
|
mSynthesisLatch?.countDown() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
SpeechEngineDefines.MESSAGE_TYPE_TTS_PLAYBACK_PROGRESS -> { |
||||
|
Log.d(TAG, "播放进度: $stdData") |
||||
|
} |
||||
|
SpeechEngineDefines.MESSAGE_TYPE_TTS_SYNTHESIS_MODE_TOGGLE -> { |
||||
|
Log.d(TAG, "合成模式切换: $stdData") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 火山语音TTS回调接口 |
||||
|
*/ |
||||
|
interface VolcanoTtsCallback { |
||||
|
fun onSuccess(audioData: ByteArray) |
||||
|
fun onError(error: String) |
||||
|
} |
||||
|
} |
||||
@ -1,84 +0,0 @@ |
|||||
Flutter crash report. |
|
||||
Please report a bug at https://github.com/flutter/flutter/issues. |
|
||||
|
|
||||
## command |
|
||||
|
|
||||
flutter run --machine --start-paused -d 3063725814005V5 --devtools-server-address http://127.0.0.1:9100/ --target /Users/wolfplus/Developer/deep_voice/lib/main.dart |
|
||||
|
|
||||
## exception |
|
||||
|
|
||||
RPCError: setIsolatePauseMode: (112) Service has disappeared |
|
||||
|
|
||||
``` |
|
||||
#0 new _OutstandingRequest (package:vm_service/src/vm_service.dart:265:34) |
|
||||
#1 VmService._call.<anonymous closure> (package:vm_service/src/vm_service.dart:1921:25) |
|
||||
#2 VmService._call (package:vm_service/src/vm_service.dart:1933:8) |
|
||||
#3 VmService.setIsolatePauseMode (package:vm_service/src/vm_service.dart:1685:7) |
|
||||
#4 HotRunner._restartFromSources.<anonymous closure> (package:flutter_tools/src/run_hot.dart:662:43) |
|
||||
#5 _rootRunUnary (dart:async/zone.dart:1538:47) |
|
||||
#6 Future._propagateToListeners.handleValueCallback (dart:async/future_impl.dart:932:45) |
|
||||
#7 Future._propagateToListeners (dart:async/future_impl.dart:961:13) |
|
||||
#8 Future._completeWithValue (dart:async/future_impl.dart:712:5) |
|
||||
#9 Future._asyncCompleteWithValue.<anonymous closure> (dart:async/future_impl.dart:792:7) |
|
||||
#10 _rootRun (dart:async/zone.dart:1525:13) |
|
||||
#11 _CustomZone.run (dart:async/zone.dart:1422:19) |
|
||||
#12 _CustomZone.runGuarded (dart:async/zone.dart:1321:7) |
|
||||
#13 _CustomZone.bindCallbackGuarded.<anonymous closure> (dart:async/zone.dart:1362:23) |
|
||||
#14 _microtaskLoop (dart:async/schedule_microtask.dart:40:21) |
|
||||
#15 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5) |
|
||||
#16 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:128:13) |
|
||||
#17 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:195:5) |
|
||||
``` |
|
||||
|
|
||||
## flutter doctor |
|
||||
|
|
||||
``` |
|
||||
[!] Flutter (Channel stable, 3.29.0, on macOS 15.3.1 24D70 darwin-arm64, locale en-US) [179ms] |
|
||||
• Flutter version 3.29.0 on channel stable at /Users/wolfplus/Developer/SDKs/flutter |
|
||||
! The flutter binary is not on your path. Consider adding /Users/wolfplus/Developer/SDKs/flutter/bin to your path. |
|
||||
! The dart binary is not on your path. Consider adding /Users/wolfplus/Developer/SDKs/flutter/bin to your path. |
|
||||
• Upstream repository https://github.com/flutter/flutter.git |
|
||||
• Framework revision 35c388afb5 (2 weeks ago), 2025-02-10 12:48:41 -0800 |
|
||||
• Engine revision f73bfc4522 |
|
||||
• Dart version 3.7.0 |
|
||||
• DevTools version 2.42.2 |
|
||||
• If those were intentional, you can disregard the above warnings; however it is recommended to use "git" directly to perform update checks and upgrades. |
|
||||
|
|
||||
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.1) [1,851ms] |
|
||||
• Android SDK at /Users/wolfplus/Library/Android/sdk |
|
||||
• Platform android-35, build-tools 35.0.1 |
|
||||
• ANDROID_HOME = /Users/wolfplus/Library/Android/sdk |
|
||||
• Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java |
|
||||
This is the JDK bundled with the latest Android Studio installation on this machine. |
|
||||
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`. |
|
||||
• Java version OpenJDK Runtime Environment (build 21.0.4+-12422083-b607.1) |
|
||||
• All Android licenses accepted. |
|
||||
|
|
||||
[✓] Xcode - develop for iOS and macOS (Xcode 16.2) [663ms] |
|
||||
• Xcode at /Applications/Xcode.app/Contents/Developer |
|
||||
• Build 16C5032a |
|
||||
• CocoaPods version 1.16.2 |
|
||||
|
|
||||
[✓] Chrome - develop for the web [2ms] |
|
||||
• Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome |
|
||||
|
|
||||
[✓] Android Studio (version 2024.2) [2ms] |
|
||||
• Android Studio at /Applications/Android Studio.app/Contents |
|
||||
• Flutter plugin can be installed from: |
|
||||
🔨 https://plugins.jetbrains.com/plugin/9212-flutter |
|
||||
• Dart plugin can be installed from: |
|
||||
🔨 https://plugins.jetbrains.com/plugin/6351-dart |
|
||||
• Java version OpenJDK Runtime Environment (build 21.0.4+-12422083-b607.1) |
|
||||
|
|
||||
[✓] Connected device (5 available) [5.6s] |
|
||||
• V2049A (mobile) • 3063725814005V5 • android-arm64 • Android 14 (API 34) |
|
||||
• Chris’s iPhone16 (wireless) (mobile) • 00008140-0006402C3CF3001C • ios • iOS 18.2.1 22C161 |
|
||||
• macOS (desktop) • macos • darwin-arm64 • macOS 15.3.1 24D70 darwin-arm64 |
|
||||
• Mac Designed for iPad (desktop) • mac-designed-for-ipad • darwin • macOS 15.3.1 24D70 darwin-arm64 |
|
||||
• Chrome (web) • chrome • web-javascript • Google Chrome 133.0.6943.127 |
|
||||
|
|
||||
[✓] Network resources [550ms] |
|
||||
• All expected network resources are available. |
|
||||
|
|
||||
! Doctor found issues in 1 category. |
|
||||
``` |
|
||||
@ -0,0 +1,62 @@ |
|||||
|
import 'dart:developer' as developer; |
||||
|
|
||||
|
/// 日志级别 |
||||
|
enum LogLevel { |
||||
|
debug, |
||||
|
info, |
||||
|
warning, |
||||
|
error, |
||||
|
} |
||||
|
|
||||
|
/// 日志工具类 |
||||
|
/// |
||||
|
/// 提供统一的日志记录接口,方便后续扩展和管理 |
||||
|
class Logger { |
||||
|
/// 当前日志级别,低于此级别的日志不会被输出 |
||||
|
static LogLevel _currentLevel = LogLevel.debug; |
||||
|
|
||||
|
/// 设置日志级别 |
||||
|
static void setLevel(LogLevel level) { |
||||
|
_currentLevel = level; |
||||
|
} |
||||
|
|
||||
|
/// 输出调试日志 |
||||
|
static void debug(String message) { |
||||
|
if (_currentLevel.index <= LogLevel.debug.index) { |
||||
|
_log('DEBUG', message); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 输出信息日志 |
||||
|
static void info(String message) { |
||||
|
if (_currentLevel.index <= LogLevel.info.index) { |
||||
|
_log('INFO', message); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 输出警告日志 |
||||
|
static void warning(String message) { |
||||
|
if (_currentLevel.index <= LogLevel.warning.index) { |
||||
|
_log('WARNING', message); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 输出错误日志 |
||||
|
static void error(String message, [dynamic error, StackTrace? stackTrace]) { |
||||
|
if (_currentLevel.index <= LogLevel.error.index) { |
||||
|
_log('ERROR', message); |
||||
|
if (error != null) { |
||||
|
_log('ERROR', 'Original error: $error'); |
||||
|
} |
||||
|
if (stackTrace != null) { |
||||
|
_log('ERROR', 'Stack trace: $stackTrace'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 内部日志输出方法 |
||||
|
static void _log(String level, String message) { |
||||
|
final timestamp = DateTime.now().toString(); |
||||
|
developer.log('[$timestamp] $level: $message'); |
||||
|
} |
||||
|
} |
||||
@ -1,354 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:typed_data'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:just_audio/just_audio.dart'; |
|
||||
import 'package:flutter_azure_tts/flutter_azure_tts.dart'; |
|
||||
import 'package:audio_session/audio_session.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
|
|
||||
/// A custom AudioSource that reads audio data from a byte array |
|
||||
class BytesAudioSource extends StreamAudioSource { |
|
||||
final Uint8List _bytes; |
|
||||
static const int _recommendedBufferSize = 8192; // 使用推荐的缓冲区大小 |
|
||||
|
|
||||
BytesAudioSource(this._bytes); |
|
||||
|
|
||||
@override |
|
||||
Future<StreamAudioResponse> request([int? start, int? end]) async { |
|
||||
start = start ?? 0; |
|
||||
end = end ?? _bytes.length; |
|
||||
|
|
||||
try { |
|
||||
// 确保缓冲区大小至少为推荐值 |
|
||||
final bufferSize = (end - start) < _recommendedBufferSize |
|
||||
? _recommendedBufferSize |
|
||||
: end - start; |
|
||||
|
|
||||
final subData = _bytes.sublist(start, end); |
|
||||
|
|
||||
return StreamAudioResponse( |
|
||||
sourceLength: _bytes.length, |
|
||||
contentLength: subData.length, |
|
||||
offset: start, |
|
||||
stream: Stream.value(subData), |
|
||||
contentType: 'audio/mpeg', |
|
||||
); |
|
||||
} catch (e) { |
|
||||
print('BytesAudioSource request error: $e'); |
|
||||
rethrow; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<int> get length => Future.value(_bytes.length); |
|
||||
} |
|
||||
|
|
||||
/// Azure TTS Service,支持将文本转换为语音并以流式方式边下载边播放 |
|
||||
class AzureTtsService extends GetxService { |
|
||||
static bool _isInitialized = false; |
|
||||
final _audioPlayer = AudioPlayer(); |
|
||||
final ConcatenatingAudioSource _playlist = ConcatenatingAudioSource(children: []); |
|
||||
final isEnabled = false.obs; |
|
||||
|
|
||||
// 播放状态变化通知流 |
|
||||
final _playingStateController = StreamController<bool>.broadcast(); |
|
||||
Stream<bool> get playingStateStream => _playingStateController.stream; |
|
||||
|
|
||||
StreamSubscription? _playbackEventSubscription; |
|
||||
StreamSubscription? _playerStateSubscription; |
|
||||
AudioSession? _audioSession; |
|
||||
|
|
||||
// Azure 配置 |
|
||||
final String _azureKey; |
|
||||
final String _azureRegion; |
|
||||
|
|
||||
// 句子管理 |
|
||||
String _pendingText = ''; |
|
||||
final List<String> _sentenceQueue = []; |
|
||||
static final _sentenceBreaks = RegExp(r'[。!?.!?]'); |
|
||||
|
|
||||
// 状态 |
|
||||
bool _isFetching = false; |
|
||||
late Voice _selectedVoice; |
|
||||
bool _isDisposed = false; |
|
||||
|
|
||||
AzureTtsService() : |
|
||||
_azureKey = dotenv.env['AZURE_TTS_KEY'] ?? '', |
|
||||
_azureRegion = dotenv.env['AZURE_TTS_REGION'] ?? '' { |
|
||||
if (_azureKey.isEmpty || _azureRegion.isEmpty) { |
|
||||
throw Exception('Azure TTS配置信息不完整,请检查环境变量'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> onInit() async { |
|
||||
super.onInit(); |
|
||||
try { |
|
||||
// 初始化音频会话 |
|
||||
_audioSession = await AudioSession.instance; |
|
||||
await _audioSession?.configure(AudioSessionConfiguration( |
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playback, |
|
||||
androidAudioAttributes: const AndroidAudioAttributes( |
|
||||
contentType: AndroidAudioContentType.speech, |
|
||||
usage: AndroidAudioUsage.media, |
|
||||
flags: AndroidAudioFlags.audibilityEnforced, |
|
||||
), |
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain, |
|
||||
)); |
|
||||
|
|
||||
// 设置音频播放器 |
|
||||
if (!_isDisposed) { |
|
||||
await _audioPlayer.setVolume(1.0); |
|
||||
await _audioPlayer.setLoopMode(LoopMode.off); |
|
||||
await _audioPlayer.setAudioSource( |
|
||||
_playlist, |
|
||||
initialPosition: Duration.zero, |
|
||||
preload: false, |
|
||||
); |
|
||||
|
|
||||
_playbackEventSubscription = _audioPlayer.playbackEventStream.listen( |
|
||||
(event) { |
|
||||
if (_isDisposed) return; |
|
||||
print('播放事件: $event'); |
|
||||
if (event.processingState == ProcessingState.completed) { |
|
||||
_tryPlayNext(); |
|
||||
} |
|
||||
}, |
|
||||
onError: (error) { |
|
||||
if (_isDisposed) return; |
|
||||
print('播放错误: $error'); |
|
||||
_handlePlaybackError(error); |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
_playerStateSubscription = _audioPlayer.playerStateStream.listen((state) { |
|
||||
if (_isDisposed) return; |
|
||||
print('播放器状态变化: playing=${state.playing},processingState=${state.processingState}'); |
|
||||
|
|
||||
// 通知播放状态变化 |
|
||||
_notifyPlayingStateChanged(state.playing && _playlist.length > 0); |
|
||||
|
|
||||
if (state.processingState == ProcessingState.completed) { |
|
||||
_tryPlayNext(); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 初始化 Azure TTS (只初始化一次) |
|
||||
if (!_isInitialized && !_isDisposed) { |
|
||||
FlutterAzureTts.init( |
|
||||
subscriptionKey: _azureKey, |
|
||||
region: _azureRegion, |
|
||||
withLogs: true, |
|
||||
); |
|
||||
_isInitialized = true; |
|
||||
} |
|
||||
|
|
||||
// 获取可用语音列表并设置默认语音 |
|
||||
if (!_isDisposed) { |
|
||||
final voicesResponse = await FlutterAzureTts.getAvailableVoices(); |
|
||||
_selectedVoice = voicesResponse.voices.firstWhere( |
|
||||
(v) => v.shortName == 'zh-CN-YunxiNeural', |
|
||||
orElse: () => voicesResponse.voices.first, |
|
||||
); |
|
||||
print('已选择语音: ${_selectedVoice.shortName}'); |
|
||||
} |
|
||||
} catch (e, stackTrace) { |
|
||||
print('初始化TTS服务失败: $e'); |
|
||||
print('Stack trace: $stackTrace'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _cleanupResources() async { |
|
||||
_isDisposed = true; |
|
||||
await _playbackEventSubscription?.cancel(); |
|
||||
await _playerStateSubscription?.cancel(); |
|
||||
await _audioSession?.setActive(false); |
|
||||
await stop(); |
|
||||
await _audioPlayer.dispose(); |
|
||||
await _playingStateController.close(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() async { |
|
||||
print('关闭TTS服务'); |
|
||||
await _cleanupResources(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
void _handlePlaybackError(dynamic error) { |
|
||||
print('处理播放错误: $error'); |
|
||||
// 尝试重新初始化播放器 |
|
||||
_reinitializePlayer(); |
|
||||
} |
|
||||
|
|
||||
Future<void> _reinitializePlayer() async { |
|
||||
try { |
|
||||
await _audioPlayer.stop(); |
|
||||
await _playlist.clear(); |
|
||||
await _audioPlayer.setAudioSource(_playlist); |
|
||||
|
|
||||
// 通知播放状态变化 |
|
||||
_notifyPlayingStateChanged(false); |
|
||||
|
|
||||
print('播放器重新初始化成功'); |
|
||||
} catch (e) { |
|
||||
print('播放器重新初始化失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void _tryPlayNext() async { |
|
||||
if (_playlist.length > 0 && !_audioPlayer.playing) { |
|
||||
try { |
|
||||
await _audioPlayer.seek(Duration.zero, index: 0); |
|
||||
await _audioPlayer.play(); |
|
||||
} catch (e) { |
|
||||
print('尝试播放下一段失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> speak(String text) async { |
|
||||
if (!isEnabled.value) { |
|
||||
print('TTS 未启用,跳过播放。'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
print('准备播放文本: $text'); |
|
||||
_pendingText += text; |
|
||||
_extractFullSentences(); |
|
||||
await _startPreloadIfNeeded(); |
|
||||
|
|
||||
if (!_audioPlayer.playing && _playlist.length > 0) { |
|
||||
print('开始播放音频'); |
|
||||
await _audioPlayer.play(); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('播放文本失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 检查音频播放器是否真正在播放 |
|
||||
bool isActuallyPlaying() { |
|
||||
try { |
|
||||
// 检查播放器状态 |
|
||||
final isPlayerPlaying = _audioPlayer.playing; |
|
||||
|
|
||||
// 检查播放列表是否为空 |
|
||||
final hasAudioSource = _playlist.length > 0; |
|
||||
|
|
||||
// 只有当播放器正在播放且播放列表不为空时,才认为真正在播放 |
|
||||
return isPlayerPlaying && hasAudioSource; |
|
||||
} catch (e) { |
|
||||
print('检查实际播放状态失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 通知播放状态变化 |
|
||||
void _notifyPlayingStateChanged(bool isPlaying) { |
|
||||
try { |
|
||||
_playingStateController.add(isPlaying); |
|
||||
} catch (e) { |
|
||||
print('通知播放状态变化失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void _extractFullSentences() { |
|
||||
if (_pendingText.isEmpty) return; |
|
||||
|
|
||||
final matches = _sentenceBreaks.allMatches(_pendingText).toList(); |
|
||||
if (matches.isEmpty) return; |
|
||||
|
|
||||
int lastPos = 0; |
|
||||
for (final match in matches) { |
|
||||
final end = match.end; |
|
||||
final sentence = _pendingText.substring(lastPos, end).trim(); |
|
||||
if (sentence.isNotEmpty) { |
|
||||
_sentenceQueue.add(sentence); |
|
||||
} |
|
||||
lastPos = end; |
|
||||
} |
|
||||
|
|
||||
if (lastPos > 0) { |
|
||||
_pendingText = _pendingText.substring(lastPos); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _startPreloadIfNeeded() async { |
|
||||
if (_isFetching) return; |
|
||||
if (_sentenceQueue.isEmpty) return; |
|
||||
|
|
||||
_isFetching = true; |
|
||||
await _fetchNextSegment(); |
|
||||
} |
|
||||
|
|
||||
Future<void> _fetchNextSegment() async { |
|
||||
if (_sentenceQueue.isEmpty) { |
|
||||
_isFetching = false; |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
final sentence = _sentenceQueue.removeAt(0); |
|
||||
print('获取音频片段: $sentence'); |
|
||||
|
|
||||
try { |
|
||||
final params = TtsParams( |
|
||||
voice: _selectedVoice, |
|
||||
audioFormat: AudioOutputFormat.audio16khz32kBitrateMonoMp3, |
|
||||
rate: 1.0, |
|
||||
text: sentence, |
|
||||
); |
|
||||
|
|
||||
final ttsResponse = await FlutterAzureTts.getTts(params); |
|
||||
if (ttsResponse.audio != null && ttsResponse.audio.isNotEmpty) { |
|
||||
print('成功获取音频数据: ${ttsResponse.audio.length} bytes'); |
|
||||
|
|
||||
try { |
|
||||
final audioSource = BytesAudioSource(ttsResponse.audio); |
|
||||
await _playlist.add(audioSource); |
|
||||
print('音频片段已添加到播放列表'); |
|
||||
|
|
||||
if (!_audioPlayer.playing && _playlist.length == 1) { |
|
||||
await _audioPlayer.setVolume(1.0); |
|
||||
await _audioPlayer.seek(Duration.zero, index: 0); |
|
||||
await Future.delayed(const Duration(milliseconds: 100)); |
|
||||
await _audioPlayer.play(); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('添加音频源失败: $e'); |
|
||||
} |
|
||||
} else { |
|
||||
print('获取音频数据失败: 返回为空或长度为0'); |
|
||||
} |
|
||||
} catch (e, stackTrace) { |
|
||||
print('获取音频失败: $e'); |
|
||||
print('Stack trace: $stackTrace'); |
|
||||
} |
|
||||
|
|
||||
await _fetchNextSegment(); |
|
||||
} |
|
||||
|
|
||||
Future<void> stop() async { |
|
||||
try { |
|
||||
await _audioPlayer.stop(); |
|
||||
await _playlist.clear(); |
|
||||
_pendingText = ''; |
|
||||
_sentenceQueue.clear(); |
|
||||
_isFetching = false; |
|
||||
|
|
||||
// 通知播放状态变化 |
|
||||
_notifyPlayingStateChanged(false); |
|
||||
} catch (e) { |
|
||||
print('停止播放失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void toggleEnabled() { |
|
||||
isEnabled.toggle(); |
|
||||
if (!isEnabled.value) { |
|
||||
stop(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,346 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
|
|
||||
/// 微软 Text-to-Speech 服务异常 |
|
||||
class MicrosoftTtsException implements Exception { |
|
||||
final String message; |
|
||||
MicrosoftTtsException(this.message); |
|
||||
|
|
||||
@override |
|
||||
String toString() => message; |
|
||||
} |
|
||||
|
|
||||
/// 微软 Text-to-Speech 服务 |
|
||||
/// |
|
||||
/// 该服务通过平台通道与 Android 上的 Microsoft Speech SDK 交互, |
|
||||
/// 提供文本转语音功能。 |
|
||||
class MicrosoftTtsService extends GetxService { |
|
||||
static const MethodChannel _channel = MethodChannel('com.example.deep_voice/text_to_speech'); |
|
||||
|
|
||||
bool _isInitialized = false; |
|
||||
late final String _subscriptionKey; |
|
||||
late final String _serviceRegion; |
|
||||
|
|
||||
// 当前使用的语音 |
|
||||
String _currentVoice = 'zh-CN-XiaoxiaoNeural'; |
|
||||
String get currentVoice => _currentVoice; |
|
||||
|
|
||||
// 语音合成队列 |
|
||||
final List<String> _textQueue = []; |
|
||||
bool _isProcessingQueue = false; |
|
||||
bool _isSpeaking = false; |
|
||||
|
|
||||
// 可观察状态 |
|
||||
final isEnabled = true.obs; |
|
||||
final isSpeaking = false.obs; |
|
||||
|
|
||||
MicrosoftTtsService() { |
|
||||
_loadConfig(); |
|
||||
} |
|
||||
|
|
||||
/// 从环境变量加载配置 |
|
||||
void _loadConfig() { |
|
||||
_subscriptionKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
|
||||
_serviceRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; |
|
||||
|
|
||||
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { |
|
||||
throw MicrosoftTtsException('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 初始化微软 TTS SDK |
|
||||
/// |
|
||||
/// 返回 true 表示初始化成功,否则抛出 PlatformException |
|
||||
Future<bool> initialize() async { |
|
||||
if (_isInitialized) return true; |
|
||||
|
|
||||
try { |
|
||||
final bool result = await _channel.invokeMethod('initialize', { |
|
||||
'subscriptionKey': _subscriptionKey, |
|
||||
'serviceRegion': _serviceRegion, |
|
||||
}); |
|
||||
|
|
||||
_isInitialized = result; |
|
||||
return result; |
|
||||
} on PlatformException catch (e) { |
|
||||
throw MicrosoftTtsException('初始化失败: ${e.message}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语音 |
|
||||
/// |
|
||||
/// [voiceName] 语音名称,例如 "zh-CN-XiaoxiaoNeural" |
|
||||
/// |
|
||||
/// 返回 true 表示设置成功,否则抛出 PlatformException |
|
||||
Future<bool> setVoice(String voiceName) async { |
|
||||
if (!_isInitialized) { |
|
||||
await initialize(); |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
final bool result = await _channel.invokeMethod('setVoice', { |
|
||||
'voiceName': voiceName, |
|
||||
}); |
|
||||
|
|
||||
if (result) { |
|
||||
_currentVoice = voiceName; |
|
||||
} |
|
||||
|
|
||||
return result; |
|
||||
} on PlatformException catch (e) { |
|
||||
throw MicrosoftTtsException('设置语音失败: ${e.message}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 将文本转换为语音并播放 |
|
||||
/// |
|
||||
/// [text] 要转换的文本 |
|
||||
/// |
|
||||
/// 返回合成结果消息,否则抛出 PlatformException |
|
||||
Future<String> speakText(String text) async { |
|
||||
if (!_isInitialized) { |
|
||||
await initialize(); |
|
||||
} |
|
||||
|
|
||||
if (!isEnabled.value) { |
|
||||
return "TTS 服务已禁用"; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
_isSpeaking = true; |
|
||||
isSpeaking.value = true; |
|
||||
|
|
||||
final String result = await _channel.invokeMethod('speakText', { |
|
||||
'text': text, |
|
||||
}); |
|
||||
|
|
||||
_isSpeaking = false; |
|
||||
isSpeaking.value = false; |
|
||||
|
|
||||
return result; |
|
||||
} on PlatformException catch (e) { |
|
||||
_isSpeaking = false; |
|
||||
isSpeaking.value = false; |
|
||||
throw MicrosoftTtsException('语音合成失败: ${e.message}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 将 SSML 转换为语音并播放 |
|
||||
/// |
|
||||
/// [ssml] SSML 格式的文本 |
|
||||
/// |
|
||||
/// 返回合成结果消息,否则抛出 PlatformException |
|
||||
Future<String> speakSsml(String ssml) async { |
|
||||
if (!_isInitialized) { |
|
||||
await initialize(); |
|
||||
} |
|
||||
|
|
||||
if (!isEnabled.value) { |
|
||||
return "TTS 服务已禁用"; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
_isSpeaking = true; |
|
||||
isSpeaking.value = true; |
|
||||
|
|
||||
final String result = await _channel.invokeMethod('speakSsml', { |
|
||||
'ssml': ssml, |
|
||||
}); |
|
||||
|
|
||||
_isSpeaking = false; |
|
||||
isSpeaking.value = false; |
|
||||
|
|
||||
return result; |
|
||||
} on PlatformException catch (e) { |
|
||||
_isSpeaking = false; |
|
||||
isSpeaking.value = false; |
|
||||
throw MicrosoftTtsException('SSML 语音合成失败: ${e.message}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 添加文本到队列并开始处理 |
|
||||
/// |
|
||||
/// [text] 要添加到队列的文本 |
|
||||
/// [rate] 可选,语速,范围 -100 到 100,默认为 0 |
|
||||
/// [pitch] 可选,音调,范围 -100 到 100,默认为 0 |
|
||||
/// |
|
||||
/// 返回 true 表示成功添加到队列 |
|
||||
Future<bool> speak(String text, {int rate = 0, int pitch = 0}) async { |
|
||||
if (!isEnabled.value) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
if (text.isEmpty) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
// 生成 SSML |
|
||||
final ssml = generateSsml( |
|
||||
text: text, |
|
||||
rate: rate, |
|
||||
pitch: pitch, |
|
||||
); |
|
||||
|
|
||||
// 添加到队列 |
|
||||
_textQueue.add(ssml); |
|
||||
|
|
||||
// 如果队列未在处理中,开始处理 |
|
||||
if (!_isProcessingQueue) { |
|
||||
_processQueue(); |
|
||||
} |
|
||||
|
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
/// 连续播放多段文本 |
|
||||
/// |
|
||||
/// [texts] 要连续播放的文本列表 |
|
||||
/// [rate] 可选,语速,范围 -100 到 100,默认为 0 |
|
||||
/// [pitch] 可选,音调,范围 -100 到 100,默认为 0 |
|
||||
/// |
|
||||
/// 返回 true 表示成功添加到队列 |
|
||||
Future<bool> speakMultiple(List<String> texts, {int rate = 0, int pitch = 0}) async { |
|
||||
if (!isEnabled.value) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
if (texts.isEmpty) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
// 将所有文本添加到队列 |
|
||||
for (final text in texts) { |
|
||||
if (text.isNotEmpty) { |
|
||||
final ssml = generateSsml( |
|
||||
text: text, |
|
||||
rate: rate, |
|
||||
pitch: pitch, |
|
||||
); |
|
||||
_textQueue.add(ssml); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 如果队列未在处理中,开始处理 |
|
||||
if (!_isProcessingQueue) { |
|
||||
_processQueue(); |
|
||||
} |
|
||||
|
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
/// 处理语音合成队列 |
|
||||
Future<void> _processQueue() async { |
|
||||
if (_textQueue.isEmpty || _isProcessingQueue) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
_isProcessingQueue = true; |
|
||||
|
|
||||
try { |
|
||||
while (_textQueue.isNotEmpty) { |
|
||||
// 如果服务被禁用,清空队列并退出 |
|
||||
if (!isEnabled.value) { |
|
||||
_textQueue.clear(); |
|
||||
break; |
|
||||
} |
|
||||
|
|
||||
// 获取队列中的下一个 SSML |
|
||||
final ssml = _textQueue.removeAt(0); |
|
||||
|
|
||||
// 播放 SSML |
|
||||
await speakSsml(ssml); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('处理语音队列时出错: $e'); |
|
||||
} finally { |
|
||||
_isProcessingQueue = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止当前语音合成并清空队列 |
|
||||
Future<void> stop() async { |
|
||||
// 清空队列 |
|
||||
_textQueue.clear(); |
|
||||
|
|
||||
// 如果当前正在播放,尝试停止 |
|
||||
if (_isSpeaking) { |
|
||||
try { |
|
||||
await _channel.invokeMethod('dispose'); |
|
||||
await initialize(); // 重新初始化以确保资源正确释放和重建 |
|
||||
_isSpeaking = false; |
|
||||
isSpeaking.value = false; |
|
||||
} catch (e) { |
|
||||
print('停止语音合成时出错: $e'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 生成 SSML 文本 |
|
||||
/// |
|
||||
/// [text] 要转换的文本 |
|
||||
/// [voiceName] 可选,语音名称,默认使用当前设置的语音 |
|
||||
/// [rate] 可选,语速,范围 -100 到 100,默认为 0 |
|
||||
/// [pitch] 可选,音调,范围 -100 到 100,默认为 0 |
|
||||
/// |
|
||||
/// 返回 SSML 格式的文本 |
|
||||
String generateSsml({ |
|
||||
required String text, |
|
||||
String? voiceName, |
|
||||
int rate = 0, |
|
||||
int pitch = 0, |
|
||||
}) { |
|
||||
final voice = voiceName ?? _currentVoice; |
|
||||
final rateValue = rate.clamp(-100, 100); |
|
||||
final pitchValue = pitch.clamp(-100, 100); |
|
||||
|
|
||||
// 将 rate 和 pitch 转换为 SSML 格式的值 |
|
||||
final String rateStr = _convertRateToSsml(rateValue); |
|
||||
final String pitchStr = _convertPitchToSsml(pitchValue); |
|
||||
|
|
||||
return ''' |
|
||||
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="zh-CN"> |
|
||||
<voice name="$voice"> |
|
||||
<prosody rate="$rateStr" pitch="$pitchStr"> |
|
||||
$text |
|
||||
</prosody> |
|
||||
</voice> |
|
||||
</speak> |
|
||||
'''; |
|
||||
} |
|
||||
|
|
||||
/// 将 rate 值转换为 SSML 格式 |
|
||||
String _convertRateToSsml(int rate) { |
|
||||
if (rate == 0) return '0%'; |
|
||||
|
|
||||
// 将 -100 到 100 的范围映射到 -90% 到 100% |
|
||||
if (rate < 0) { |
|
||||
// 负值映射到 -90% 到 0% |
|
||||
return '${(rate * 0.9).round()}%'; |
|
||||
} else { |
|
||||
// 正值映射到 0% 到 100% |
|
||||
return '${rate}%'; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 将 pitch 值转换为 SSML 格式 |
|
||||
String _convertPitchToSsml(int pitch) { |
|
||||
if (pitch == 0) return '0%'; |
|
||||
|
|
||||
// 将 -100 到 100 的范围映射到 -50% 到 50% |
|
||||
return '${(pitch * 0.5).round()}%'; |
|
||||
} |
|
||||
|
|
||||
/// 释放资源 |
|
||||
Future<void> dispose() async { |
|
||||
if (!_isInitialized) return; |
|
||||
|
|
||||
try { |
|
||||
await _channel.invokeMethod('dispose'); |
|
||||
_isInitialized = false; |
|
||||
} on PlatformException catch (e) { |
|
||||
throw MicrosoftTtsException('释放资源失败: ${e.message}'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
File diff suppressed because it is too large
@ -0,0 +1,381 @@ |
|||||
|
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 VolcanoVoiceRecognitionService extends GetxService { |
||||
|
static const MethodChannel _channel = MethodChannel('com.example.deep_voice/speech_recognition'); |
||||
|
static const EventChannel _eventChannel = EventChannel('com.example.deep_voice/speech_recognition_events'); |
||||
|
|
||||
|
bool _isInitialized = false; |
||||
|
late final String _subscriptionKey; |
||||
|
late final String _serviceRegion; |
||||
|
|
||||
|
// 连续识别相关 |
||||
|
bool _isContinuousRecognitionActive = false; |
||||
|
StreamController<RecognitionEvent>? _eventStreamController; |
||||
|
StreamSubscription? _eventSubscription; |
||||
|
|
||||
|
// 公开的事件流 |
||||
|
Stream<RecognitionEvent>? _recognitionStream; |
||||
|
Stream<RecognitionEvent>? get recognitionStream => _recognitionStream; |
||||
|
|
||||
|
// 最新的识别结果 |
||||
|
final _latestRecognizedText = ''.obs; |
||||
|
String get latestRecognizedText => _latestRecognizedText.value; |
||||
|
|
||||
|
// 识别状态 |
||||
|
final isListening = false.obs; |
||||
|
|
||||
|
// 识别结果列表 |
||||
|
final RxList<String> _recognitionResults = <String>[].obs; |
||||
|
List<String> get recognitionResults => _recognitionResults; |
||||
|
|
||||
|
// 错误信息 |
||||
|
final RxString _errorMessage = ''.obs; |
||||
|
String get errorMessage => _errorMessage.value; |
||||
|
|
||||
|
VolcanoVoiceRecognitionService() { |
||||
|
_loadConfig(); |
||||
|
} |
||||
|
|
||||
|
/// 从环境变量加载配置 |
||||
|
void _loadConfig() { |
||||
|
// 只使用统一的APP_ID和APP_KEY |
||||
|
_subscriptionKey = dotenv.env['VOLCANO_APP_ID'] ?? ''; |
||||
|
_serviceRegion = dotenv.env['VOLCANO_APP_KEY'] ?? ''; |
||||
|
|
||||
|
Logger.info('火山语音识别配置: APP_ID=${_subscriptionKey.isNotEmpty ? "已设置" : "未设置"}, APP_KEY=${_serviceRegion.isNotEmpty ? "已设置" : "未设置"}'); |
||||
|
|
||||
|
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { |
||||
|
throw Exception('未找到火山语音服务配置。请在 .env 文件中设置 VOLCANO_APP_ID 和 VOLCANO_APP_KEY'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
_setupMethodCallHandler(); |
||||
|
} |
||||
|
|
||||
|
/// 设置方法通道处理器 |
||||
|
void _setupMethodCallHandler() { |
||||
|
_channel.setMethodCallHandler((call) async { |
||||
|
switch (call.method) { |
||||
|
case 'onRecognitionResult': |
||||
|
final String result = call.arguments as String; |
||||
|
_handleRecognitionResult(result); |
||||
|
break; |
||||
|
case 'onRecognitionError': |
||||
|
final String error = call.arguments as String; |
||||
|
_handleRecognitionError(error); |
||||
|
break; |
||||
|
case 'onRecognitionComplete': |
||||
|
_handleRecognitionComplete(); |
||||
|
break; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 处理识别结果 |
||||
|
void _handleRecognitionResult(String result) { |
||||
|
Logger.debug('Recognition result: $result'); |
||||
|
_recognitionResults.add(result); |
||||
|
_latestRecognizedText.value = result; |
||||
|
|
||||
|
if (_eventStreamController != null) { |
||||
|
_eventStreamController!.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.finalResult, |
||||
|
text: result, |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理识别错误 |
||||
|
void _handleRecognitionError(String error) { |
||||
|
Logger.error('Recognition error: $error'); |
||||
|
_errorMessage.value = error; |
||||
|
isListening.value = false; |
||||
|
|
||||
|
if (_eventStreamController != null) { |
||||
|
_eventStreamController!.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.error, |
||||
|
text: '', |
||||
|
error: error, |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理识别完成 |
||||
|
void _handleRecognitionComplete() { |
||||
|
Logger.debug('Recognition complete'); |
||||
|
isListening.value = false; |
||||
|
_isContinuousRecognitionActive = false; |
||||
|
|
||||
|
if (_eventStreamController != null) { |
||||
|
_eventStreamController!.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.completed, |
||||
|
text: '', |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 初始化语音识别引擎 |
||||
|
Future<bool> initialize() async { |
||||
|
if (_isInitialized) return true; |
||||
|
|
||||
|
try { |
||||
|
Logger.info('开始初始化火山语音识别服务,APP_ID: ${_subscriptionKey.substring(0, math.min(3, _subscriptionKey.length))}***,APP_KEY: ${_serviceRegion.length > 10 ? "${_serviceRegion.substring(0, 5)}..." : _serviceRegion}'); |
||||
|
|
||||
|
final bool result = await _channel.invokeMethod('initialize', { |
||||
|
'subscriptionKey': _subscriptionKey, |
||||
|
'serviceRegion': _serviceRegion, |
||||
|
}); |
||||
|
|
||||
|
_isInitialized = result; |
||||
|
Logger.info('火山语音识别服务初始化${result ? '成功' : '失败'}'); |
||||
|
return result; |
||||
|
} on PlatformException catch (e) { |
||||
|
// 检查是否是资源授权错误 |
||||
|
if (e.code == 'INITIALIZATION_ERROR' && |
||||
|
(e.message?.contains('资源授权错误') == true || |
||||
|
e.message?.contains('requested resource not granted') == true || |
||||
|
e.message?.contains('requested grant not found') == true)) { |
||||
|
Logger.error('火山语音识别初始化失败: 资源授权错误', e, StackTrace.current); |
||||
|
Logger.info('请检查以下几点:'); |
||||
|
Logger.info('1. 确保您的火山引擎账户已开通语音识别服务'); |
||||
|
Logger.info('2. 确保您的应用ID和密钥正确且有效'); |
||||
|
Logger.info('3. 确保您的应用已被授权使用语音识别服务'); |
||||
|
_isInitialized = false; |
||||
|
throw PlatformException( |
||||
|
code: 'RESOURCE_AUTHORIZATION_ERROR', |
||||
|
message: '语音识别服务授权失败: 请确保应用已开通语音识别服务并且密钥有效', |
||||
|
details: e.message |
||||
|
); |
||||
|
} else { |
||||
|
Logger.error('火山语音识别初始化失败: ${e.message}', e, StackTrace.current); |
||||
|
_isInitialized = false; |
||||
|
throw e; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('火山语音识别初始化发生未知错误', e, StackTrace.current); |
||||
|
_isInitialized = false; |
||||
|
throw Exception('初始化火山语音识别服务失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 开始一次性识别 |
||||
|
Future<bool> startOneTimeRecognition() async { |
||||
|
if (isListening.value) { |
||||
|
Logger.warning('已经在进行语音识别,请先停止当前识别'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (!_isInitialized) { |
||||
|
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('startOneTimeRecognition'); |
||||
|
isListening.value = result; |
||||
|
|
||||
|
if (result) { |
||||
|
_eventStreamController?.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) { |
||||
|
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(); |
||||
|
|
||||
|
// 创建事件流控制器 |
||||
|
_eventStreamController = StreamController<RecognitionEvent>.broadcast(); |
||||
|
_recognitionStream = _eventStreamController?.stream; |
||||
|
|
||||
|
// 设置事件监听 |
||||
|
_eventSubscription = _eventChannel |
||||
|
.receiveBroadcastStream() |
||||
|
.listen(_handleNativeEvent, onError: (error) { |
||||
|
_handleRecognitionError(error.toString()); |
||||
|
}); |
||||
|
|
||||
|
final bool result = await _channel.invokeMethod('startContinuousRecognition'); |
||||
|
isListening.value = result; |
||||
|
_isContinuousRecognitionActive = result; |
||||
|
|
||||
|
if (result) { |
||||
|
_eventStreamController?.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? ?? ''; |
||||
|
_eventStreamController?.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.recognizing, |
||||
|
text: text, |
||||
|
)); |
||||
|
break; |
||||
|
case 'finalResult': |
||||
|
final String text = eventMap['text'] as String? ?? ''; |
||||
|
_latestRecognizedText.value = text; |
||||
|
_recognitionResults.add(text); |
||||
|
_eventStreamController?.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.finalResult, |
||||
|
text: text, |
||||
|
)); |
||||
|
break; |
||||
|
case 'error': |
||||
|
final String error = eventMap['error'] as String? ?? '未知错误'; |
||||
|
_handleRecognitionError(error); |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 清理事件流 |
||||
|
void _cleanupEventStream() { |
||||
|
_eventSubscription?.cancel(); |
||||
|
_eventSubscription = null; |
||||
|
_eventStreamController?.close(); |
||||
|
_eventStreamController = null; |
||||
|
_recognitionStream = null; |
||||
|
} |
||||
|
|
||||
|
/// 停止识别 |
||||
|
Future<bool> stopRecognition() async { |
||||
|
if (!isListening.value) { |
||||
|
Logger.warning('当前没有进行语音识别'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
final bool result = await _channel.invokeMethod('stopRecognition'); |
||||
|
isListening.value = !result; |
||||
|
_isContinuousRecognitionActive = !result; |
||||
|
|
||||
|
if (result) { |
||||
|
_cleanupEventStream(); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
Logger.error('停止识别失败: $e'); |
||||
|
_errorMessage.value = e.toString(); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查连续识别是否活跃 |
||||
|
bool isContinuousRecognitionActive() { |
||||
|
return _isContinuousRecognitionActive; |
||||
|
} |
||||
|
|
||||
|
/// 清理资源 |
||||
|
Future<void> dispose() async { |
||||
|
try { |
||||
|
if (isListening.value) { |
||||
|
await stopRecognition(); |
||||
|
} |
||||
|
_cleanupEventStream(); |
||||
|
} catch (e) { |
||||
|
Logger.error('清理语音识别资源失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
dispose(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
} |
||||
@ -1,308 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../data/services/microsoft_tts_service.dart'; |
|
||||
|
|
||||
/// 微软 TTS 连续语音输出示例页面 |
|
||||
class MicrosoftTtsContinuousExample extends StatefulWidget { |
|
||||
const MicrosoftTtsContinuousExample({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
State<MicrosoftTtsContinuousExample> createState() => _MicrosoftTtsContinuousExampleState(); |
|
||||
} |
|
||||
|
|
||||
class _MicrosoftTtsContinuousExampleState extends State<MicrosoftTtsContinuousExample> { |
|
||||
final MicrosoftTtsService _ttsService = Get.find<MicrosoftTtsService>(); |
|
||||
|
|
||||
// 语音列表 |
|
||||
final List<Map<String, String>> _voices = [ |
|
||||
{'name': '晓晓(女声)', 'value': 'zh-CN-XiaoxiaoNeural'}, |
|
||||
{'name': '云扬(男声)', 'value': 'zh-CN-YunyangNeural'}, |
|
||||
{'name': '晓双(女声)', 'value': 'zh-CN-XiaoshuangNeural'}, |
|
||||
{'name': '云皓(男声)', 'value': 'zh-CN-YunhaoNeural'}, |
|
||||
{'name': '晓墨(女声)', 'value': 'zh-CN-XiaomoNeural'}, |
|
||||
{'name': '云泽(男声)', 'value': 'zh-CN-YunzeNeural'}, |
|
||||
]; |
|
||||
|
|
||||
String _selectedVoice = 'zh-CN-XiaoxiaoNeural'; |
|
||||
double _rate = 0; |
|
||||
double _pitch = 0; |
|
||||
bool _isLoading = false; |
|
||||
String _statusMessage = ''; |
|
||||
|
|
||||
// 预设的连续语音文本 |
|
||||
final List<String> _presetTexts = [ |
|
||||
'欢迎使用微软语音合成服务,这是连续语音输出的第一段文本。', |
|
||||
'这是第二段文本,用于测试连续语音输出功能。', |
|
||||
'现在是第三段文本,我们正在测试微软语音合成服务的连续合成能力。', |
|
||||
'最后一段测试文本,感谢您的收听。', |
|
||||
]; |
|
||||
|
|
||||
// 自定义文本列表 |
|
||||
final List<TextEditingController> _textControllers = []; |
|
||||
|
|
||||
@override |
|
||||
void initState() { |
|
||||
super.initState(); |
|
||||
// 初始化文本控制器 |
|
||||
for (final text in _presetTexts) { |
|
||||
_textControllers.add(TextEditingController(text: text)); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void dispose() { |
|
||||
// 释放文本控制器 |
|
||||
for (final controller in _textControllers) { |
|
||||
controller.dispose(); |
|
||||
} |
|
||||
super.dispose(); |
|
||||
} |
|
||||
|
|
||||
/// 播放连续文本 |
|
||||
Future<void> _speakContinuous() async { |
|
||||
final texts = _textControllers.map((controller) => controller.text).toList(); |
|
||||
|
|
||||
if (texts.every((text) => text.isEmpty)) { |
|
||||
_showSnackBar('请至少输入一段文本'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
setState(() { |
|
||||
_isLoading = true; |
|
||||
_statusMessage = '正在合成语音...'; |
|
||||
}); |
|
||||
|
|
||||
try { |
|
||||
// 设置语音 |
|
||||
await _ttsService.setVoice(_selectedVoice); |
|
||||
|
|
||||
// 停止之前的播放 |
|
||||
await _ttsService.stop(); |
|
||||
|
|
||||
// 连续播放多段文本 |
|
||||
final result = await _ttsService.speakMultiple( |
|
||||
texts.where((text) => text.isNotEmpty).toList(), |
|
||||
rate: _rate.round(), |
|
||||
pitch: _pitch.round(), |
|
||||
); |
|
||||
|
|
||||
setState(() { |
|
||||
_statusMessage = result ? '语音合成已加入队列' : '语音合成失败'; |
|
||||
}); |
|
||||
} catch (e) { |
|
||||
_showSnackBar('语音合成失败: $e'); |
|
||||
} finally { |
|
||||
setState(() { |
|
||||
_isLoading = false; |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止播放 |
|
||||
Future<void> _stopSpeaking() async { |
|
||||
try { |
|
||||
await _ttsService.stop(); |
|
||||
setState(() { |
|
||||
_statusMessage = '语音合成已停止'; |
|
||||
}); |
|
||||
} catch (e) { |
|
||||
_showSnackBar('停止语音合成失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 添加文本输入框 |
|
||||
void _addTextInput() { |
|
||||
setState(() { |
|
||||
_textControllers.add(TextEditingController()); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/// 删除文本输入框 |
|
||||
void _removeTextInput(int index) { |
|
||||
if (_textControllers.length <= 1) { |
|
||||
_showSnackBar('至少需要保留一个文本输入框'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
setState(() { |
|
||||
_textControllers[index].dispose(); |
|
||||
_textControllers.removeAt(index); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/// 显示提示信息 |
|
||||
void _showSnackBar(String message) { |
|
||||
ScaffoldMessenger.of(context).showSnackBar( |
|
||||
SnackBar(content: Text(message)), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Scaffold( |
|
||||
appBar: AppBar( |
|
||||
title: const Text('微软连续语音合成示例'), |
|
||||
), |
|
||||
body: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: ListView( |
|
||||
children: [ |
|
||||
// 语音选择 |
|
||||
DropdownButtonFormField<String>( |
|
||||
value: _selectedVoice, |
|
||||
decoration: const InputDecoration( |
|
||||
labelText: '选择语音', |
|
||||
border: OutlineInputBorder(), |
|
||||
), |
|
||||
items: _voices.map((voice) { |
|
||||
return DropdownMenuItem<String>( |
|
||||
value: voice['value'], |
|
||||
child: Text(voice['name']!), |
|
||||
); |
|
||||
}).toList(), |
|
||||
onChanged: (value) { |
|
||||
if (value != null) { |
|
||||
setState(() { |
|
||||
_selectedVoice = value; |
|
||||
}); |
|
||||
} |
|
||||
}, |
|
||||
), |
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 语速调节 |
|
||||
Row( |
|
||||
children: [ |
|
||||
const Text('语速:'), |
|
||||
Expanded( |
|
||||
child: Slider( |
|
||||
min: -100, |
|
||||
max: 100, |
|
||||
divisions: 20, |
|
||||
value: _rate, |
|
||||
label: _rate.round().toString(), |
|
||||
onChanged: (value) { |
|
||||
setState(() { |
|
||||
_rate = value; |
|
||||
}); |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
Text('${_rate.round()}%'), |
|
||||
], |
|
||||
), |
|
||||
|
|
||||
// 音调调节 |
|
||||
Row( |
|
||||
children: [ |
|
||||
const Text('音调:'), |
|
||||
Expanded( |
|
||||
child: Slider( |
|
||||
min: -100, |
|
||||
max: 100, |
|
||||
divisions: 20, |
|
||||
value: _pitch, |
|
||||
label: _pitch.round().toString(), |
|
||||
onChanged: (value) { |
|
||||
setState(() { |
|
||||
_pitch = value; |
|
||||
}); |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
Text('${_pitch.round()}%'), |
|
||||
], |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 文本输入列表标题 |
|
||||
Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'连续语音文本', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
ElevatedButton.icon( |
|
||||
onPressed: _addTextInput, |
|
||||
icon: const Icon(Icons.add), |
|
||||
label: const Text('添加文本'), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 8), |
|
||||
|
|
||||
// 文本输入列表 |
|
||||
...List.generate(_textControllers.length, (index) { |
|
||||
return Padding( |
|
||||
padding: const EdgeInsets.only(bottom: 8.0), |
|
||||
child: Row( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: TextField( |
|
||||
controller: _textControllers[index], |
|
||||
maxLines: 3, |
|
||||
decoration: InputDecoration( |
|
||||
labelText: '文本 ${index + 1}', |
|
||||
border: const OutlineInputBorder(), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
IconButton( |
|
||||
icon: const Icon(Icons.delete), |
|
||||
onPressed: () => _removeTextInput(index), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
}), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 操作按钮 |
|
||||
Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: ElevatedButton.icon( |
|
||||
onPressed: _isLoading ? null : _speakContinuous, |
|
||||
icon: const Icon(Icons.play_arrow), |
|
||||
label: const Text('播放连续语音'), |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(width: 8), |
|
||||
Expanded( |
|
||||
child: ElevatedButton.icon( |
|
||||
onPressed: _stopSpeaking, |
|
||||
icon: const Icon(Icons.stop), |
|
||||
label: const Text('停止'), |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Colors.red, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 状态信息 |
|
||||
Obx(() => Text( |
|
||||
_ttsService.isSpeaking.value |
|
||||
? '正在播放语音...' |
|
||||
: _statusMessage, |
|
||||
style: const TextStyle(fontStyle: FontStyle.italic), |
|
||||
textAlign: TextAlign.center, |
|
||||
)), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -1,202 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../data/services/microsoft_tts_service.dart'; |
|
||||
|
|
||||
/// 微软 TTS 示例页面 |
|
||||
class MicrosoftTtsExample extends StatefulWidget { |
|
||||
const MicrosoftTtsExample({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
State<MicrosoftTtsExample> createState() => _MicrosoftTtsExampleState(); |
|
||||
} |
|
||||
|
|
||||
class _MicrosoftTtsExampleState extends State<MicrosoftTtsExample> { |
|
||||
final TextEditingController _textController = TextEditingController(); |
|
||||
final MicrosoftTtsService _ttsService = Get.find<MicrosoftTtsService>(); |
|
||||
|
|
||||
// 语音列表 |
|
||||
final List<Map<String, String>> _voices = [ |
|
||||
{'name': '晓晓(女声)', 'value': 'zh-CN-XiaoxiaoNeural'}, |
|
||||
{'name': '云扬(男声)', 'value': 'zh-CN-YunyangNeural'}, |
|
||||
{'name': '晓双(女声)', 'value': 'zh-CN-XiaoshuangNeural'}, |
|
||||
{'name': '云皓(男声)', 'value': 'zh-CN-YunhaoNeural'}, |
|
||||
{'name': '晓墨(女声)', 'value': 'zh-CN-XiaomoNeural'}, |
|
||||
{'name': '云泽(男声)', 'value': 'zh-CN-YunzeNeural'}, |
|
||||
]; |
|
||||
|
|
||||
String _selectedVoice = 'zh-CN-XiaoxiaoNeural'; |
|
||||
double _rate = 0; |
|
||||
double _pitch = 0; |
|
||||
bool _isLoading = false; |
|
||||
String _statusMessage = ''; |
|
||||
|
|
||||
@override |
|
||||
void initState() { |
|
||||
super.initState(); |
|
||||
_textController.text = '欢迎使用微软语音合成服务,这是一个示例文本。'; |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void dispose() { |
|
||||
_textController.dispose(); |
|
||||
super.dispose(); |
|
||||
} |
|
||||
|
|
||||
/// 播放文本 |
|
||||
Future<void> _speakText() async { |
|
||||
if (_textController.text.isEmpty) { |
|
||||
_showSnackBar('请输入要合成的文本'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
setState(() { |
|
||||
_isLoading = true; |
|
||||
_statusMessage = '正在合成语音...'; |
|
||||
}); |
|
||||
|
|
||||
try { |
|
||||
// 设置语音 |
|
||||
await _ttsService.setVoice(_selectedVoice); |
|
||||
|
|
||||
// 生成 SSML |
|
||||
final ssml = _ttsService.generateSsml( |
|
||||
text: _textController.text, |
|
||||
rate: _rate.round(), |
|
||||
pitch: _pitch.round(), |
|
||||
); |
|
||||
|
|
||||
// 播放 SSML |
|
||||
final result = await _ttsService.speakSsml(ssml); |
|
||||
|
|
||||
setState(() { |
|
||||
_statusMessage = result; |
|
||||
}); |
|
||||
} catch (e) { |
|
||||
_showSnackBar('语音合成失败: $e'); |
|
||||
} finally { |
|
||||
setState(() { |
|
||||
_isLoading = false; |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 显示提示信息 |
|
||||
void _showSnackBar(String message) { |
|
||||
ScaffoldMessenger.of(context).showSnackBar( |
|
||||
SnackBar(content: Text(message)), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Scaffold( |
|
||||
appBar: AppBar( |
|
||||
title: const Text('微软语音合成示例'), |
|
||||
), |
|
||||
body: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|
||||
children: [ |
|
||||
// 文本输入框 |
|
||||
TextField( |
|
||||
controller: _textController, |
|
||||
maxLines: 5, |
|
||||
decoration: const InputDecoration( |
|
||||
labelText: '输入要合成的文本', |
|
||||
border: OutlineInputBorder(), |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 语音选择 |
|
||||
DropdownButtonFormField<String>( |
|
||||
value: _selectedVoice, |
|
||||
decoration: const InputDecoration( |
|
||||
labelText: '选择语音', |
|
||||
border: OutlineInputBorder(), |
|
||||
), |
|
||||
items: _voices.map((voice) { |
|
||||
return DropdownMenuItem<String>( |
|
||||
value: voice['value'], |
|
||||
child: Text(voice['name']!), |
|
||||
); |
|
||||
}).toList(), |
|
||||
onChanged: (value) { |
|
||||
if (value != null) { |
|
||||
setState(() { |
|
||||
_selectedVoice = value; |
|
||||
}); |
|
||||
} |
|
||||
}, |
|
||||
), |
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 语速调节 |
|
||||
Row( |
|
||||
children: [ |
|
||||
const Text('语速:'), |
|
||||
Expanded( |
|
||||
child: Slider( |
|
||||
min: -100, |
|
||||
max: 100, |
|
||||
divisions: 20, |
|
||||
value: _rate, |
|
||||
label: _rate.round().toString(), |
|
||||
onChanged: (value) { |
|
||||
setState(() { |
|
||||
_rate = value; |
|
||||
}); |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
Text('${_rate.round()}%'), |
|
||||
], |
|
||||
), |
|
||||
|
|
||||
// 音调调节 |
|
||||
Row( |
|
||||
children: [ |
|
||||
const Text('音调:'), |
|
||||
Expanded( |
|
||||
child: Slider( |
|
||||
min: -100, |
|
||||
max: 100, |
|
||||
divisions: 20, |
|
||||
value: _pitch, |
|
||||
label: _pitch.round().toString(), |
|
||||
onChanged: (value) { |
|
||||
setState(() { |
|
||||
_pitch = value; |
|
||||
}); |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
Text('${_pitch.round()}%'), |
|
||||
], |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 播放按钮 |
|
||||
ElevatedButton( |
|
||||
onPressed: _isLoading ? null : _speakText, |
|
||||
child: _isLoading |
|
||||
? const CircularProgressIndicator() |
|
||||
: const Text('播放'), |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 状态信息 |
|
||||
Text( |
|
||||
_statusMessage, |
|
||||
style: const TextStyle(fontStyle: FontStyle.italic), |
|
||||
textAlign: TextAlign.center, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -1,184 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:developer' as developer; |
|
||||
import 'dart:io'; |
|
||||
|
|
||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:permission_handler/permission_handler.dart'; |
|
||||
import 'package:device_info_plus/device_info_plus.dart'; |
|
||||
import 'package:path_provider/path_provider.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
|
|
||||
import '../../data/services/voice_recognition_service.dart'; |
|
||||
|
|
||||
class SpeechDemoPage extends StatefulWidget { |
|
||||
const SpeechDemoPage({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
State<SpeechDemoPage> createState() => _SpeechDemoPageState(); |
|
||||
} |
|
||||
|
|
||||
class _SpeechDemoPageState extends State<SpeechDemoPage> { |
|
||||
late final VoiceRecognitionService _voiceService; |
|
||||
bool _isInitialized = false; |
|
||||
bool _isRecognizing = false; |
|
||||
String _recognizedText = ''; |
|
||||
String _statusMessage = '准备就绪'; |
|
||||
|
|
||||
@override |
|
||||
void initState() { |
|
||||
super.initState(); |
|
||||
_initializeService(); |
|
||||
} |
|
||||
|
|
||||
Future<void> _initializeService() async { |
|
||||
setState(() { |
|
||||
_statusMessage = '正在初始化语音服务...'; |
|
||||
}); |
|
||||
|
|
||||
try { |
|
||||
// 创建服务实例(这一步会自动从环境变量加载配置) |
|
||||
_voiceService = VoiceRecognitionService(); |
|
||||
|
|
||||
// 初始化语音识别服务 |
|
||||
final result = await _voiceService.initialize(); |
|
||||
|
|
||||
setState(() { |
|
||||
_isInitialized = result; |
|
||||
_statusMessage = '语音服务初始化成功,可以开始识别'; |
|
||||
}); |
|
||||
} catch (e) { |
|
||||
setState(() { |
|
||||
_isInitialized = false; |
|
||||
_statusMessage = '初始化失败: $e'; |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _startVoiceRecognition() async { |
|
||||
if (!_isInitialized) { |
|
||||
setState(() { |
|
||||
_statusMessage = '语音服务未初始化,请先初始化'; |
|
||||
}); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
setState(() { |
|
||||
_isRecognizing = true; |
|
||||
_statusMessage = '正在聆听...'; |
|
||||
}); |
|
||||
|
|
||||
try { |
|
||||
final result = await _voiceService.recognizeSpeech(); |
|
||||
setState(() { |
|
||||
_recognizedText = result; |
|
||||
_isRecognizing = false; |
|
||||
_statusMessage = '识别完成'; |
|
||||
}); |
|
||||
} catch (e) { |
|
||||
setState(() { |
|
||||
_isRecognizing = false; |
|
||||
_statusMessage = '识别失败: $e'; |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Scaffold( |
|
||||
appBar: AppBar( |
|
||||
title: const Text('语音识别演示'), |
|
||||
), |
|
||||
body: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|
||||
children: [ |
|
||||
// 状态信息 |
|
||||
Container( |
|
||||
padding: const EdgeInsets.all(12), |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.grey[200], |
|
||||
borderRadius: BorderRadius.circular(8), |
|
||||
), |
|
||||
child: Text( |
|
||||
_statusMessage, |
|
||||
style: TextStyle( |
|
||||
color: _isInitialized ? Colors.green[700] : Colors.red[700], |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 24), |
|
||||
|
|
||||
// 识别结果显示区域 |
|
||||
Expanded( |
|
||||
child: Container( |
|
||||
padding: const EdgeInsets.all(16), |
|
||||
decoration: BoxDecoration( |
|
||||
border: Border.all(color: Colors.grey[300]!), |
|
||||
borderRadius: BorderRadius.circular(8), |
|
||||
), |
|
||||
child: _recognizedText.isEmpty |
|
||||
? const Center( |
|
||||
child: Text( |
|
||||
'识别结果将显示在这里', |
|
||||
style: TextStyle(color: Colors.grey), |
|
||||
), |
|
||||
) |
|
||||
: SingleChildScrollView( |
|
||||
child: Text( |
|
||||
_recognizedText, |
|
||||
style: const TextStyle( |
|
||||
fontSize: 18, |
|
||||
height: 1.5, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 24), |
|
||||
|
|
||||
// 操作按钮 |
|
||||
Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
|
||||
children: [ |
|
||||
ElevatedButton( |
|
||||
onPressed: _isRecognizing ? null : _initializeService, |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), |
|
||||
), |
|
||||
child: const Text('重新初始化'), |
|
||||
), |
|
||||
ElevatedButton( |
|
||||
onPressed: _isRecognizing || !_isInitialized ? null : _startVoiceRecognition, |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), |
|
||||
backgroundColor: Colors.blue[700], |
|
||||
), |
|
||||
child: Text(_isRecognizing ? '正在识别...' : '开始语音识别'), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 提示信息 |
|
||||
const Text( |
|
||||
'提示:请在安静的环境中使用,并确保已授予应用录音权限。', |
|
||||
style: TextStyle( |
|
||||
fontSize: 12, |
|
||||
fontStyle: FontStyle.italic, |
|
||||
color: Colors.grey, |
|
||||
), |
|
||||
textAlign: TextAlign.center, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,21 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/tts_test_controller.dart'; |
||||
|
import '../controllers/asr_test_controller.dart'; |
||||
|
|
||||
|
class TtsTestBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<TtsTestController>( |
||||
|
() => TtsTestController(), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class AsrTestBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<AsrTestController>( |
||||
|
() => AsrTestController(), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,103 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../data/services/volcano_voice_recognition_service.dart'; |
||||
|
|
||||
|
class AsrTestController extends GetxController { |
||||
|
final VolcanoVoiceRecognitionService _voiceService = Get.find<VolcanoVoiceRecognitionService>(); |
||||
|
|
||||
|
// 可观察状态 |
||||
|
final isListening = false.obs; |
||||
|
final errorMessage = ''.obs; |
||||
|
final isContinuous = true.obs; |
||||
|
final recognitionResults = <String>[].obs; |
||||
|
|
||||
|
// 连续识别相关 |
||||
|
StreamSubscription? _recognitionSubscription; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
|
||||
|
// 监听语音识别服务的状态 |
||||
|
_voiceService.isListening.listen((listening) { |
||||
|
isListening.value = listening; |
||||
|
if (!listening) { |
||||
|
errorMessage.value = ''; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 开始录音 |
||||
|
void startListening() async { |
||||
|
try { |
||||
|
errorMessage.value = ''; |
||||
|
|
||||
|
if (isContinuous.value) { |
||||
|
// 连续识别模式 |
||||
|
final success = await _voiceService.startContinuousRecognition(); |
||||
|
if (!success) { |
||||
|
errorMessage.value = '无法启动语音识别'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
_recognitionSubscription = _voiceService.recognitionStream?.listen( |
||||
|
(event) { |
||||
|
switch (event.type) { |
||||
|
case RecognitionEventType.finalResult: |
||||
|
if (event.text.isNotEmpty) { |
||||
|
recognitionResults.add(event.text); |
||||
|
} |
||||
|
break; |
||||
|
case RecognitionEventType.error: |
||||
|
errorMessage.value = event.error ?? '未知错误'; |
||||
|
break; |
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
}, |
||||
|
onError: (error) { |
||||
|
errorMessage.value = error.toString(); |
||||
|
isListening.value = false; |
||||
|
}, |
||||
|
); |
||||
|
} else { |
||||
|
// 一次性识别模式 |
||||
|
final success = await _voiceService.startOneTimeRecognition(); |
||||
|
if (!success) { |
||||
|
errorMessage.value = '无法启动语音识别'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 一次性识别模式下,结果会通过服务的recognitionResults获取 |
||||
|
// 在onInit中我们已经监听了isListening状态,当识别完成时会自动更新UI |
||||
|
} |
||||
|
} catch (e) { |
||||
|
errorMessage.value = e.toString(); |
||||
|
isListening.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止录音 |
||||
|
void stopListening() async { |
||||
|
try { |
||||
|
if (isContinuous.value) { |
||||
|
await _voiceService.stopRecognition(); |
||||
|
await _recognitionSubscription?.cancel(); |
||||
|
_recognitionSubscription = null; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
errorMessage.value = e.toString(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 清空结果 |
||||
|
void clearResults() { |
||||
|
recognitionResults.clear(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
_recognitionSubscription?.cancel(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,123 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../data/services/volcano_tts_service.dart'; |
||||
|
|
||||
|
class TtsTestController extends GetxController { |
||||
|
final VolcanoTtsService _ttsService = Get.find<VolcanoTtsService>(); |
||||
|
|
||||
|
// 文本控制器 |
||||
|
final TextEditingController textController = TextEditingController(); |
||||
|
|
||||
|
// 可观察状态 |
||||
|
final isPlaying = false.obs; |
||||
|
final errorMessage = ''.obs; |
||||
|
final selectedVoiceType = ''.obs; |
||||
|
final voiceTypes = <String>[].obs; |
||||
|
final isLoading = false.obs; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
|
||||
|
// 监听TTS播放状态 |
||||
|
_ttsService.playingStateStream.listen((playing) { |
||||
|
isPlaying.value = playing; |
||||
|
if (!playing) { |
||||
|
errorMessage.value = ''; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// 设置默认文本 |
||||
|
textController.text = '这是一个火山语音合成测试,请点击播放按钮听取合成效果。'; |
||||
|
|
||||
|
// 设置可用的语音类型 |
||||
|
voiceTypes.value = [ |
||||
|
// 趣味方言 |
||||
|
'zh_female_wanqudashu_moon_bigtts', // 湾区大叔 |
||||
|
'zh_female_daimengchuanmei_moon_bigtts', // 呆萌川妹 |
||||
|
'zh_male_guozhoudege_moon_bigtts', // 广州德哥 |
||||
|
'zh_male_beijingxiaoye_moon_bigtts', // 北京小爷 |
||||
|
'zh_male_haoyuxiaoge_moon_bigtts', // 浩宇小哥 |
||||
|
|
||||
|
// 通用场景 |
||||
|
'zh_male_shaonianzixin_moon_bigtts', // 少年梓辛/Brayan |
||||
|
|
||||
|
// 角色扮演 |
||||
|
'zh_female_meilinvyou_moon_bigtts', // 魅力女友 |
||||
|
'zh_male_shenyeboke_moon_bigtts', // 深夜播客 |
||||
|
'zh_female_sajiaonvyou_moon_bigtts', // 柔美女友 |
||||
|
'zh_female_yuanqinvyou_moon_bigtts', // 撒娇学妹 |
||||
|
|
||||
|
]; |
||||
|
|
||||
|
// 设置当前选中的语音类型 |
||||
|
selectedVoiceType.value = _ttsService.getCurrentVoiceType(); |
||||
|
|
||||
|
// 如果当前语音类型不在列表中,添加到列表 |
||||
|
if (!voiceTypes.contains(selectedVoiceType.value)) { |
||||
|
voiceTypes.add(selectedVoiceType.value); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 播放文本 |
||||
|
void speakText() async { |
||||
|
final text = textController.text.trim(); |
||||
|
if (text.isEmpty) { |
||||
|
errorMessage.value = '请输入要合成的文本'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
errorMessage.value = ''; |
||||
|
isLoading.value = true; |
||||
|
|
||||
|
try { |
||||
|
// 获取当前语音类型 |
||||
|
final currentVoiceType = selectedVoiceType.value; |
||||
|
|
||||
|
// 检查语音类型是否可能可用 |
||||
|
if (!_ttsService.isVoiceTypeLikelyAvailable(currentVoiceType)) { |
||||
|
print('警告: 选择的语音类型 $currentVoiceType 可能不可用,但仍将尝试使用'); |
||||
|
} |
||||
|
|
||||
|
// 播放文本,传递选定的语音类型 |
||||
|
await _ttsService.speak(text, voiceType: currentVoiceType); |
||||
|
} catch (e) { |
||||
|
errorMessage.value = e.toString(); |
||||
|
isPlaying.value = false; |
||||
|
|
||||
|
// 如果错误包含资源授权相关信息,提供更具体的提示 |
||||
|
final errorMsg = e.toString().toLowerCase(); |
||||
|
if (errorMsg.contains('resource not granted') || |
||||
|
errorMsg.contains('403') || |
||||
|
errorMsg.contains('授权') || |
||||
|
errorMsg.contains('3001')) { |
||||
|
errorMessage.value = '语音类型授权错误: 您可能没有权限使用当前选择的语音类型,请尝试使用基础语音类型'; |
||||
|
} |
||||
|
} finally { |
||||
|
isLoading.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止播放 |
||||
|
void stopSpeaking() { |
||||
|
try { |
||||
|
_ttsService.stop(); |
||||
|
} catch (e) { |
||||
|
errorMessage.value = e.toString(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 切换语音类型 |
||||
|
void changeVoiceType(String voiceType) { |
||||
|
if (voiceType != selectedVoiceType.value) { |
||||
|
selectedVoiceType.value = voiceType; |
||||
|
errorMessage.value = ''; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
textController.dispose(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,232 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/asr_test_controller.dart'; |
||||
|
|
||||
|
class AsrTestView extends GetView<AsrTestController> { |
||||
|
const AsrTestView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
appBar: AppBar( |
||||
|
title: const Text('火山语音识别测试'), |
||||
|
centerTitle: true, |
||||
|
), |
||||
|
body: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.stretch, |
||||
|
children: [ |
||||
|
// 识别模式选择 |
||||
|
Card( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'识别模式', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Obx(() => 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; |
||||
|
} |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
)), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// 录音控制 |
||||
|
Card( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'录音控制', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
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: const EdgeInsets.symmetric( |
||||
|
horizontal: 24, |
||||
|
vertical: 12, |
||||
|
), |
||||
|
), |
||||
|
) |
||||
|
: ElevatedButton.icon( |
||||
|
onPressed: () => controller.startListening(), |
||||
|
icon: const Icon(Icons.mic), |
||||
|
label: const Text('开始录音'), |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue, |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: const EdgeInsets.symmetric( |
||||
|
horizontal: 24, |
||||
|
vertical: 12, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// 识别结果 |
||||
|
Expanded( |
||||
|
child: Card( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'识别结果', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
icon: const Icon(Icons.clear), |
||||
|
onPressed: () => controller.clearResults(), |
||||
|
tooltip: '清空结果', |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Expanded( |
||||
|
child: Container( |
||||
|
padding: const EdgeInsets.all(8.0), |
||||
|
decoration: BoxDecoration( |
||||
|
border: Border.all(color: Colors.grey), |
||||
|
borderRadius: BorderRadius.circular(4.0), |
||||
|
), |
||||
|
child: Obx(() => ListView.builder( |
||||
|
itemCount: controller.recognitionResults.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final result = controller.recognitionResults[index]; |
||||
|
return Padding( |
||||
|
padding: const EdgeInsets.symmetric(vertical: 4.0), |
||||
|
child: Row( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'${index + 1}. ', |
||||
|
style: const TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
Expanded( |
||||
|
child: Text(result), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
)), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// 状态显示 |
||||
|
Card( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'状态', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Obx(() => Text( |
||||
|
controller.isListening.value |
||||
|
? '正在录音...' |
||||
|
: '就绪', |
||||
|
style: TextStyle( |
||||
|
color: controller.isListening.value |
||||
|
? Colors.green |
||||
|
: Colors.grey, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
)), |
||||
|
const SizedBox(height: 8), |
||||
|
Obx(() => controller.errorMessage.value.isNotEmpty |
||||
|
? Text( |
||||
|
'错误: ${controller.errorMessage.value}', |
||||
|
style: const TextStyle( |
||||
|
color: Colors.red, |
||||
|
), |
||||
|
) |
||||
|
: const SizedBox.shrink()), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,267 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/tts_test_controller.dart'; |
||||
|
|
||||
|
class TtsTestView extends GetView<TtsTestController> { |
||||
|
const TtsTestView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
appBar: AppBar( |
||||
|
title: const Text('火山语音合成测试'), |
||||
|
centerTitle: true, |
||||
|
), |
||||
|
body: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.stretch, |
||||
|
children: [ |
||||
|
// 语音类型选择 |
||||
|
Card( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'语音类型', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Obx(() => DropdownButton<String>( |
||||
|
isExpanded: true, |
||||
|
value: controller.selectedVoiceType.value, |
||||
|
onChanged: (String? newValue) { |
||||
|
if (newValue != null) { |
||||
|
controller.selectedVoiceType.value = newValue; |
||||
|
} |
||||
|
}, |
||||
|
items: controller.voiceTypes.map<DropdownMenuItem<String>>((String value) { |
||||
|
// 根据语音类型ID获取友好名称 |
||||
|
String displayName = _getVoiceTypeDisplayName(value); |
||||
|
return DropdownMenuItem<String>( |
||||
|
value: value, |
||||
|
child: Text(displayName), |
||||
|
); |
||||
|
}).toList(), |
||||
|
)), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// 文本输入 |
||||
|
Card( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'输入要合成的文本', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
TextField( |
||||
|
controller: controller.textController, |
||||
|
maxLines: 5, |
||||
|
decoration: const InputDecoration( |
||||
|
hintText: '请输入要转换为语音的文本...', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// 播放控制 |
||||
|
Card( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'播放控制', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
||||
|
children: [ |
||||
|
Obx(() => ElevatedButton.icon( |
||||
|
onPressed: controller.isPlaying.value || controller.isLoading.value |
||||
|
? null |
||||
|
: () => controller.speakText(), |
||||
|
icon: controller.isLoading.value |
||||
|
? const SizedBox( |
||||
|
width: 20, |
||||
|
height: 20, |
||||
|
child: CircularProgressIndicator(strokeWidth: 2) |
||||
|
) |
||||
|
: const Icon(Icons.play_arrow), |
||||
|
label: Text(controller.isLoading.value ? '准备中...' : '播放'), |
||||
|
)), |
||||
|
ElevatedButton.icon( |
||||
|
onPressed: controller.isPlaying.value |
||||
|
? () => controller.stopSpeaking() |
||||
|
: null, |
||||
|
icon: const Icon(Icons.stop), |
||||
|
label: const Text('停止'), |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.red, |
||||
|
foregroundColor: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// 状态显示 |
||||
|
Card( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'状态', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Obx(() => Text( |
||||
|
controller.isLoading.value |
||||
|
? '准备中...' |
||||
|
: controller.isPlaying.value |
||||
|
? '正在播放...' |
||||
|
: '就绪', |
||||
|
style: TextStyle( |
||||
|
color: controller.isLoading.value |
||||
|
? Colors.orange |
||||
|
: controller.isPlaying.value |
||||
|
? Colors.green |
||||
|
: Colors.grey, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
)), |
||||
|
const SizedBox(height: 8), |
||||
|
Obx(() => controller.errorMessage.value.isNotEmpty |
||||
|
? Container( |
||||
|
padding: const EdgeInsets.all(8), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.red.shade50, |
||||
|
borderRadius: BorderRadius.circular(4), |
||||
|
border: Border.all(color: Colors.red.shade200), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Icon(Icons.error_outline, color: Colors.red, size: 16), |
||||
|
SizedBox(width: 8), |
||||
|
Text( |
||||
|
'错误', |
||||
|
style: TextStyle( |
||||
|
color: Colors.red, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 4), |
||||
|
Text( |
||||
|
controller.errorMessage.value, |
||||
|
style: TextStyle(color: Colors.red.shade800), |
||||
|
), |
||||
|
if (controller.errorMessage.value.contains('授权错误')) |
||||
|
Padding( |
||||
|
padding: const EdgeInsets.only(top: 8.0), |
||||
|
child: Text( |
||||
|
'提示: 请尝试选择基础语音类型,如"zh_male_qingse_common"或"zh_female_qingse_common"', |
||||
|
style: TextStyle( |
||||
|
fontStyle: FontStyle.italic, |
||||
|
color: Colors.red.shade700, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
) |
||||
|
: const SizedBox.shrink()), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 根据语音类型ID获取友好名称 |
||||
|
String _getVoiceTypeDisplayName(String voiceTypeId) { |
||||
|
switch (voiceTypeId) { |
||||
|
// 趣味方言 |
||||
|
case 'zh_female_wanqudashu_moon_bigtts': |
||||
|
return '湾区大叔 (趣味方言)'; |
||||
|
case 'zh_female_daimengchuanmei_moon_bigtts': |
||||
|
return '呆萌川妹 (趣味方言)'; |
||||
|
case 'zh_male_guozhoudege_moon_bigtts': |
||||
|
return '广州德哥 (趣味方言)'; |
||||
|
case 'zh_male_beijingxiaoye_moon_bigtts': |
||||
|
return '北京小爷 (趣味方言)'; |
||||
|
case 'zh_male_haoyuxiaoge_moon_bigtts': |
||||
|
return '浩宇小哥 (趣味方言)'; |
||||
|
|
||||
|
// 通用场景 |
||||
|
case 'zh_male_shaonianzixin_moon_bigtts': |
||||
|
return '少年梓辛/Brayan (通用场景)'; |
||||
|
|
||||
|
// 角色扮演 |
||||
|
case 'zh_female_meilinvyou_moon_bigtts': |
||||
|
return '魅力女友 (角色扮演)'; |
||||
|
case 'zh_male_shenyeboke_moon_bigtts': |
||||
|
return '深夜播客 (角色扮演)'; |
||||
|
case 'zh_female_sajiaonvyou_moon_bigtts': |
||||
|
return '柔美女友 (角色扮演)'; |
||||
|
case 'zh_female_yuanqinvyou_moon_bigtts': |
||||
|
return '撒娇学妹 (角色扮演)'; |
||||
|
|
||||
|
// 基础语音类型 |
||||
|
case 'zh_male_qingse_common': |
||||
|
return '基础男声'; |
||||
|
case 'zh_female_qingse_common': |
||||
|
return '基础女声'; |
||||
|
case 'zh_male_M392_conversation_wvae_bigtts': |
||||
|
return '高级男声'; |
||||
|
case 'zh_female_F392_conversation_wvae_bigtts': |
||||
|
return '高级女声'; |
||||
|
default: |
||||
|
return voiceTypeId; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue