24 changed files with 1291 additions and 1432 deletions
@ -1,353 +0,0 @@ |
|||||
package com.example.deep_voice |
|
||||
|
|
||||
import android.util.Log |
|
||||
import com.microsoft.cognitiveservices.speech.* |
|
||||
import com.microsoft.cognitiveservices.speech.audio.* |
|
||||
import com.microsoft.cognitiveservices.speech.util.EventHandler |
|
||||
import java.util.concurrent.ExecutionException |
|
||||
import java.util.concurrent.Semaphore |
|
||||
|
|
||||
/** |
|
||||
* Azure 语音识别助手类 |
|
||||
* 提供 Azure 语音识别服务的封装,支持一次性识别和连续识别 |
|
||||
*/ |
|
||||
class AzureAsrHelper { |
|
||||
private var recognizer: SpeechRecognizer? = null |
|
||||
private var audioConfig: AudioConfig? = null |
|
||||
private var speechConfig: SpeechConfig? = null |
|
||||
private val TAG = "AzureAsrHelper" |
|
||||
private var isContinuousRecognitionActive = false |
|
||||
|
|
||||
// 用于同步的信号量 |
|
||||
private val stopRecognitionSemaphore = Semaphore(0) |
|
||||
|
|
||||
// 存储最后一次初始化的参数 |
|
||||
private var lastSubscriptionKey: String = "" |
|
||||
private var lastServiceRegion: String = "" |
|
||||
private var lastLanguage: String = "zh-CN" |
|
||||
|
|
||||
/** |
|
||||
* 初始化 Azure 语音识别服务 |
|
||||
* @param subscriptionKey Azure 语音服务的订阅密钥 |
|
||||
* @param serviceRegion Azure 语音服务的区域 |
|
||||
* @param language 识别语言,默认为中文 |
|
||||
* @return 是否初始化成功 |
|
||||
*/ |
|
||||
fun initialize( |
|
||||
subscriptionKey: String, |
|
||||
serviceRegion: String, |
|
||||
language: String = "zh-CN" |
|
||||
): Boolean { |
|
||||
try { |
|
||||
// 保存初始化参数,以便重置时使用 |
|
||||
lastSubscriptionKey = subscriptionKey |
|
||||
lastServiceRegion = serviceRegion |
|
||||
lastLanguage = language |
|
||||
|
|
||||
// 释放之前的资源 |
|
||||
dispose() |
|
||||
|
|
||||
// 创建语音配置 |
|
||||
speechConfig = SpeechConfig.fromSubscription(subscriptionKey, serviceRegion) |
|
||||
speechConfig?.speechRecognitionLanguage = language |
|
||||
|
|
||||
// 创建音频配置,使用默认麦克风 |
|
||||
audioConfig = AudioConfig.fromDefaultMicrophoneInput() |
|
||||
|
|
||||
// 创建识别器 |
|
||||
recognizer = SpeechRecognizer(speechConfig, audioConfig) |
|
||||
|
|
||||
Log.d(TAG, "Azure 语音识别服务初始化成功") |
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "Azure 语音识别服务初始化失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 重置识别器 |
|
||||
* 在切换识别模式前调用,确保资源被正确释放 |
|
||||
* @return 是否成功重置 |
|
||||
*/ |
|
||||
fun resetRecognizer(): Boolean { |
|
||||
try { |
|
||||
Log.d(TAG, "开始重置 Azure 语音识别器") |
|
||||
|
|
||||
// 如果连续识别正在进行,先停止它 |
|
||||
if (isContinuousRecognitionActive) { |
|
||||
try { |
|
||||
recognizer?.stopContinuousRecognitionAsync()?.get() |
|
||||
isContinuousRecognitionActive = false |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "停止连续识别失败: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 关闭并释放当前的识别器 |
|
||||
recognizer?.close() |
|
||||
recognizer = null |
|
||||
|
|
||||
audioConfig?.close() |
|
||||
audioConfig = null |
|
||||
|
|
||||
speechConfig?.close() |
|
||||
speechConfig = null |
|
||||
|
|
||||
// 使用上次的参数重新创建识别器 |
|
||||
if (lastSubscriptionKey.isNotEmpty() && lastServiceRegion.isNotEmpty()) { |
|
||||
speechConfig = SpeechConfig.fromSubscription(lastSubscriptionKey, lastServiceRegion) |
|
||||
speechConfig?.speechRecognitionLanguage = lastLanguage |
|
||||
|
|
||||
audioConfig = AudioConfig.fromDefaultMicrophoneInput() |
|
||||
|
|
||||
recognizer = SpeechRecognizer(speechConfig, audioConfig) |
|
||||
|
|
||||
Log.d(TAG, "Azure 语音识别器重置成功") |
|
||||
return true |
|
||||
} else { |
|
||||
Log.e(TAG, "无法重置 Azure 语音识别器:缺少初始化参数") |
|
||||
return false |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "重置 Azure 语音识别器失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 执行一次性语音识别 |
|
||||
* @param callback 识别结果回调 |
|
||||
*/ |
|
||||
fun recognizeOnce(callback: RecognizeCallback) { |
|
||||
if (recognizer == null) { |
|
||||
callback.onError("SpeechRecognizer 未初始化") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 使用同步方式调用,避免 CompletableFuture 的兼容性问题 |
|
||||
val result = recognizer?.recognizeOnceAsync()?.get() |
|
||||
|
|
||||
if (result != null) { |
|
||||
when (result.reason) { |
|
||||
ResultReason.RecognizedSpeech -> { |
|
||||
callback.onResult(result.text) |
|
||||
} |
|
||||
ResultReason.NoMatch -> { |
|
||||
callback.onError("未能识别语音") |
|
||||
} |
|
||||
ResultReason.Canceled -> { |
|
||||
val cancellation = CancellationDetails.fromResult(result) |
|
||||
callback.onError("识别被取消,原因: ${cancellation.reason}, 错误详情: ${cancellation.errorDetails}") |
|
||||
} |
|
||||
else -> { |
|
||||
callback.onError("识别失败,原因: ${result.reason}") |
|
||||
} |
|
||||
} |
|
||||
} else { |
|
||||
callback.onError("识别结果为空") |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
when (e) { |
|
||||
is InterruptedException, is ExecutionException -> { |
|
||||
Log.e(TAG, "识别异常: ${e.message}") |
|
||||
callback.onError("识别异常: ${e.message}") |
|
||||
} |
|
||||
else -> { |
|
||||
Log.e(TAG, "未知异常: ${e.message}") |
|
||||
callback.onError("未知异常: ${e.message}") |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 开始连续语音识别 |
|
||||
* @param callback 连续识别结果回调 |
|
||||
* @return 是否成功启动连续识别 |
|
||||
*/ |
|
||||
fun startContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { |
|
||||
if (recognizer == null) { |
|
||||
callback.onError("SpeechRecognizer 未初始化") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
if (isContinuousRecognitionActive) { |
|
||||
callback.onError("连续识别已经在进行中") |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 设置识别事件处理 |
|
||||
recognizer?.let { recognizer -> |
|
||||
// 设置识别事件处理(最终结果) |
|
||||
recognizer.recognized.addEventListener( |
|
||||
EventHandler<SpeechRecognitionEventArgs> { _, event -> |
|
||||
if (event.result.reason == ResultReason.RecognizedSpeech) { |
|
||||
callback.onResult(event.result.text) |
|
||||
} else if (event.result.reason == ResultReason.NoMatch) { |
|
||||
callback.onNoMatch() |
|
||||
} |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
// 设置识别中事件处理(实时反馈) |
|
||||
recognizer.recognizing.addEventListener( |
|
||||
EventHandler<SpeechRecognitionEventArgs> { _, event -> |
|
||||
if (event.result.reason == ResultReason.RecognizingSpeech) { |
|
||||
callback.onRecognizing(event.result.text) |
|
||||
} |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
// 设置会话开始事件处理 |
|
||||
recognizer.sessionStarted.addEventListener( |
|
||||
EventHandler<SessionEventArgs> { _, _ -> |
|
||||
callback.onSessionStarted() |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
// 设置会话结束事件处理 |
|
||||
recognizer.sessionStopped.addEventListener( |
|
||||
EventHandler<SessionEventArgs> { _, _ -> |
|
||||
isContinuousRecognitionActive = false |
|
||||
callback.onSessionStopped() |
|
||||
stopRecognitionSemaphore.release() |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
// 设置取消事件处理 |
|
||||
recognizer.canceled.addEventListener( |
|
||||
EventHandler<SpeechRecognitionCanceledEventArgs> { _, event -> |
|
||||
val reason = event.reason |
|
||||
val errorDetails = if (reason == CancellationReason.Error) event.errorDetails else "" |
|
||||
callback.onCanceled(reason.toString(), errorDetails) |
|
||||
isContinuousRecognitionActive = false |
|
||||
stopRecognitionSemaphore.release() |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
// 开始连续识别 |
|
||||
recognizer.startContinuousRecognitionAsync().get() |
|
||||
isContinuousRecognitionActive = true |
|
||||
Log.d(TAG, "连续识别已开始kt") |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
return false |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "开始连续识别失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
callback.onError("开始连续识别失败: ${e.message}") |
|
||||
isContinuousRecognitionActive = false |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 停止连续语音识别 |
|
||||
* @param callback 连续识别结果回调 |
|
||||
* @return 是否成功停止连续识别 |
|
||||
*/ |
|
||||
fun stopContinuousRecognition(callback: ContinuousRecognizeCallback): Boolean { |
|
||||
if (recognizer == null) { |
|
||||
Log.w(TAG, "SpeechRecognizer 未初始化,无需停止") |
|
||||
callback.onSessionStopped() // 通知已停止 |
|
||||
return true // 未初始化,视为已停止 |
|
||||
} |
|
||||
|
|
||||
if (!isContinuousRecognitionActive) { |
|
||||
Log.w(TAG, "连续识别未在进行中,无需停止") |
|
||||
callback.onSessionStopped() // 通知已停止 |
|
||||
return true // 已经不在进行中,视为成功 |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 停止连续识别 |
|
||||
recognizer?.stopContinuousRecognitionAsync() |
|
||||
|
|
||||
// 等待停止完成 |
|
||||
stopRecognitionSemaphore.acquire() |
|
||||
|
|
||||
isContinuousRecognitionActive = false |
|
||||
Log.d(TAG, "连续识别已停止kt") |
|
||||
callback.onSessionStopped() |
|
||||
return true |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "停止连续识别失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
callback.onError("停止连续识别失败: ${e.message}") |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 检查连续识别是否活跃 |
|
||||
* @return 连续识别是否活跃 |
|
||||
*/ |
|
||||
fun isContinuousRecognitionActive(): Boolean { |
|
||||
return isContinuousRecognitionActive |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 设置识别语言 |
|
||||
* @param language 语言代码,例如 "zh-CN", "en-US" |
|
||||
*/ |
|
||||
fun setRecognitionLanguage(language: String) { |
|
||||
speechConfig?.speechRecognitionLanguage = language |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 释放资源 |
|
||||
*/ |
|
||||
fun dispose() { |
|
||||
try { |
|
||||
if (isContinuousRecognitionActive) { |
|
||||
try { |
|
||||
recognizer?.stopContinuousRecognitionAsync()?.get() |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "停止连续识别失败: ${e.message}") |
|
||||
} |
|
||||
isContinuousRecognitionActive = false |
|
||||
} |
|
||||
|
|
||||
recognizer?.close() |
|
||||
recognizer = null |
|
||||
|
|
||||
audioConfig?.close() |
|
||||
audioConfig = null |
|
||||
|
|
||||
speechConfig?.close() |
|
||||
speechConfig = null |
|
||||
|
|
||||
Log.d(TAG, "Azure 语音识别资源已释放") |
|
||||
} catch (e: Exception) { |
|
||||
Log.e(TAG, "释放资源失败: ${e.message}") |
|
||||
e.printStackTrace() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 一次性识别回调接口 |
|
||||
*/ |
|
||||
interface RecognizeCallback { |
|
||||
fun onResult(result: String) |
|
||||
fun onError(error: String) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* 连续识别回调接口 |
|
||||
*/ |
|
||||
interface ContinuousRecognizeCallback { |
|
||||
fun onResult(text: String) |
|
||||
fun onRecognizing(recognizing: String) |
|
||||
fun onSessionStarted() |
|
||||
fun onSessionStopped() |
|
||||
fun onCanceled(reason: String, errorDetails: String) |
|
||||
fun onNoMatch() |
|
||||
fun onError(error: String) |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,161 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:logger/logger.dart'; |
||||
|
import '../data/services/azure_tts_service.dart'; |
||||
|
|
||||
|
class AzureTtsTestController extends GetxController { |
||||
|
final AzureTtsService _ttsService = Get.find<AzureTtsService>(); |
||||
|
final Logger _logger = Logger(); |
||||
|
|
||||
|
// 状态变量 |
||||
|
final RxBool isInitialized = false.obs; |
||||
|
final RxBool isProcessing = false.obs; |
||||
|
final RxString statusMessage = ''.obs; |
||||
|
final RxString lastFilePath = ''.obs; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
|
||||
|
// 监听TTS状态 |
||||
|
_ttsService.ttsStatus.listen((status) { |
||||
|
final statusStr = status['status'] as String?; |
||||
|
|
||||
|
switch (statusStr) { |
||||
|
case 'started': |
||||
|
isProcessing.value = true; |
||||
|
statusMessage.value = '正在合成语音...'; |
||||
|
break; |
||||
|
case 'completed': |
||||
|
isProcessing.value = false; |
||||
|
statusMessage.value = '语音合成完成'; |
||||
|
|
||||
|
if (status.containsKey('filePath')) { |
||||
|
lastFilePath.value = status['filePath'] as String; |
||||
|
} |
||||
|
break; |
||||
|
case 'error': |
||||
|
isProcessing.value = false; |
||||
|
statusMessage.value = '语音合成失败: ${status['message'] ?? '未知错误'}'; |
||||
|
break; |
||||
|
default: |
||||
|
statusMessage.value = '未知状态: $statusStr'; |
||||
|
break; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 初始化Azure TTS服务 |
||||
|
Future<void> initialize({ |
||||
|
required String subscriptionKey, |
||||
|
required String region, |
||||
|
}) async { |
||||
|
try { |
||||
|
isProcessing.value = true; |
||||
|
statusMessage.value = '正在初始化Azure TTS...'; |
||||
|
|
||||
|
await _ttsService.initialize( |
||||
|
subscriptionKey: subscriptionKey, |
||||
|
region: region, |
||||
|
); |
||||
|
|
||||
|
isInitialized.value = _ttsService.isInitialized; |
||||
|
|
||||
|
if (isInitialized.value) { |
||||
|
statusMessage.value = 'Azure TTS初始化成功'; |
||||
|
} else { |
||||
|
statusMessage.value = 'Azure TTS初始化失败'; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
isInitialized.value = false; |
||||
|
statusMessage.value = '初始化异常: $e'; |
||||
|
_logger.e('初始化异常: $e'); |
||||
|
} finally { |
||||
|
isProcessing.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置语音 |
||||
|
Future<void> setVoice(String voiceName) async { |
||||
|
if (!isInitialized.value) { |
||||
|
statusMessage.value = 'Azure TTS未初始化,请先初始化'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
isProcessing.value = true; |
||||
|
statusMessage.value = '正在设置语音...'; |
||||
|
|
||||
|
final success = await _ttsService.setVoice(voiceName); |
||||
|
|
||||
|
if (success) { |
||||
|
statusMessage.value = '语音设置成功: $voiceName'; |
||||
|
} else { |
||||
|
statusMessage.value = '语音设置失败'; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
statusMessage.value = '设置语音异常: $e'; |
||||
|
_logger.e('设置语音异常: $e'); |
||||
|
} finally { |
||||
|
isProcessing.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 合成语音到扬声器 |
||||
|
Future<void> speakText(String text) async { |
||||
|
if (!isInitialized.value) { |
||||
|
statusMessage.value = 'Azure TTS未初始化,请先初始化'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (text.isEmpty) { |
||||
|
statusMessage.value = '文本不能为空'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
isProcessing.value = true; |
||||
|
statusMessage.value = '正在合成语音...'; |
||||
|
|
||||
|
final success = await _ttsService.synthesizeToSpeaker(text); |
||||
|
|
||||
|
if (!success) { |
||||
|
statusMessage.value = '语音合成失败'; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
statusMessage.value = '语音合成异常: $e'; |
||||
|
_logger.e('语音合成异常: $e'); |
||||
|
isProcessing.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 合成语音到文件 |
||||
|
Future<void> synthesizeToFile(String text, {String? fileName}) async { |
||||
|
if (!isInitialized.value) { |
||||
|
statusMessage.value = 'Azure TTS未初始化,请先初始化'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (text.isEmpty) { |
||||
|
statusMessage.value = '文本不能为空'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
isProcessing.value = true; |
||||
|
statusMessage.value = '正在合成语音到文件...'; |
||||
|
|
||||
|
final filePath = await _ttsService.synthesizeToFile(text, fileName: fileName); |
||||
|
|
||||
|
if (filePath != null) { |
||||
|
lastFilePath.value = filePath; |
||||
|
} else { |
||||
|
statusMessage.value = '语音合成到文件失败'; |
||||
|
isProcessing.value = false; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
statusMessage.value = '语音合成到文件异常: $e'; |
||||
|
_logger.e('语音合成到文件异常: $e'); |
||||
|
isProcessing.value = false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,539 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:io'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
import 'package:path_provider/path_provider.dart'; |
|
||||
|
|
||||
/// 语音合成事件类型 |
|
||||
enum SynthesisEventType { |
|
||||
/// 合成开始 |
|
||||
synthesisStarted, |
|
||||
|
|
||||
/// 合成进度更新 |
|
||||
synthesisProgress, |
|
||||
|
|
||||
/// 合成完成 |
|
||||
synthesisCompleted, |
|
||||
|
|
||||
/// 合成取消 |
|
||||
synthesisCanceled, |
|
||||
|
|
||||
/// 合成错误 |
|
||||
error, |
|
||||
} |
|
||||
|
|
||||
/// 语音合成事件 |
|
||||
class SynthesisEvent { |
|
||||
/// 事件类型 |
|
||||
final SynthesisEventType type; |
|
||||
|
|
||||
/// 音频文件路径(仅在 synthesisCompleted 类型中有效) |
|
||||
final String? audioFilePath; |
|
||||
|
|
||||
/// 进度百分比(仅在 synthesisProgress 类型中有效) |
|
||||
final int? progressPercent; |
|
||||
|
|
||||
/// 错误信息(仅在 error 和 synthesisCanceled 类型中有效) |
|
||||
final String? error; |
|
||||
|
|
||||
/// 取消原因(仅在 synthesisCanceled 类型中有效) |
|
||||
final String? reason; |
|
||||
|
|
||||
/// 错误详情(仅在 synthesisCanceled 类型中有效) |
|
||||
final String? errorDetails; |
|
||||
|
|
||||
SynthesisEvent({ |
|
||||
required this.type, |
|
||||
this.audioFilePath, |
|
||||
this.progressPercent, |
|
||||
this.error, |
|
||||
this.reason, |
|
||||
this.errorDetails, |
|
||||
}); |
|
||||
|
|
||||
/// 从原生事件映射创建合成事件 |
|
||||
factory SynthesisEvent.fromMap(Map<dynamic, dynamic> map) { |
|
||||
final eventType = map['eventType'] as String; |
|
||||
|
|
||||
SynthesisEventType type; |
|
||||
switch (eventType) { |
|
||||
case 'synthesisStarted': |
|
||||
type = SynthesisEventType.synthesisStarted; |
|
||||
break; |
|
||||
case 'synthesisProgress': |
|
||||
type = SynthesisEventType.synthesisProgress; |
|
||||
break; |
|
||||
case 'synthesisCompleted': |
|
||||
type = SynthesisEventType.synthesisCompleted; |
|
||||
break; |
|
||||
case 'synthesisCanceled': |
|
||||
type = SynthesisEventType.synthesisCanceled; |
|
||||
break; |
|
||||
case 'error': |
|
||||
type = SynthesisEventType.error; |
|
||||
break; |
|
||||
default: |
|
||||
throw ArgumentError('未知事件类型: $eventType'); |
|
||||
} |
|
||||
|
|
||||
return SynthesisEvent( |
|
||||
type: type, |
|
||||
audioFilePath: map['audioFilePath'] as String?, |
|
||||
progressPercent: map['progressPercent'] as int?, |
|
||||
error: map['error'] as String?, |
|
||||
reason: map['reason'] as String?, |
|
||||
errorDetails: map['errorDetails'] as String?, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
String toString() { |
|
||||
return 'SynthesisEvent{type: $type, audioFilePath: $audioFilePath, progressPercent: $progressPercent, error: $error, reason: $reason, errorDetails: $errorDetails}'; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// TTS 状态 |
|
||||
enum TtsState { |
|
||||
/// 空闲 |
|
||||
idle, |
|
||||
|
|
||||
/// 合成中 |
|
||||
synthesizing, |
|
||||
|
|
||||
/// 播放中 |
|
||||
playing, |
|
||||
|
|
||||
/// 已停止 |
|
||||
stopped, |
|
||||
} |
|
||||
|
|
||||
/// Azure 文本转语音服务 |
|
||||
class AzureTtsService extends GetxService { |
|
||||
static AzureTtsService get to => Get.find(); |
|
||||
|
|
||||
// 方法通道 |
|
||||
static const MethodChannel _methodChannel = MethodChannel('com.example.deep_voice/azure_tts'); |
|
||||
|
|
||||
// 事件通道 |
|
||||
static const EventChannel _eventChannel = EventChannel('com.example.deep_voice/azure_tts_events'); |
|
||||
|
|
||||
// 是否已初始化 |
|
||||
bool _isInitialized = false; |
|
||||
bool get isInitialized => _isInitialized; |
|
||||
|
|
||||
// 当前TTS状态 |
|
||||
final Rx<TtsState> _ttsState = TtsState.idle.obs; |
|
||||
TtsState get ttsState => _ttsState.value; |
|
||||
|
|
||||
// 合成相关 |
|
||||
StreamController<SynthesisEvent>? _eventStreamController; |
|
||||
StreamSubscription? _eventSubscription; |
|
||||
|
|
||||
// 公开的事件流 |
|
||||
Stream<SynthesisEvent>? _synthesisStream; |
|
||||
Stream<SynthesisEvent>? get synthesisStream => _synthesisStream; |
|
||||
|
|
||||
// 当前语音名称 |
|
||||
String _currentVoice = 'zh-CN-XiaoxiaoNeural'; |
|
||||
String get currentVoice => _currentVoice; |
|
||||
set currentVoice(String voice) { |
|
||||
_currentVoice = voice; |
|
||||
_setVoice(voice); |
|
||||
} |
|
||||
|
|
||||
// 语速 (-100 to 100) |
|
||||
int _rate = 0; |
|
||||
int get rate => _rate; |
|
||||
set rate(int value) { |
|
||||
if (value < -100 || value > 100) { |
|
||||
throw ArgumentError('语速必须在 -100 到 100 之间'); |
|
||||
} |
|
||||
_rate = value; |
|
||||
_setSpeechRate(value); |
|
||||
} |
|
||||
|
|
||||
// 音调 (-100 to 100) |
|
||||
int _pitch = 0; |
|
||||
int get pitch => _pitch; |
|
||||
set pitch(int value) { |
|
||||
if (value < -100 || value > 100) { |
|
||||
throw ArgumentError('音调必须在 -100 到 100 之间'); |
|
||||
} |
|
||||
_pitch = value; |
|
||||
_setPitch(value); |
|
||||
} |
|
||||
|
|
||||
// 音量 (0 to 100) |
|
||||
int _volume = 100; |
|
||||
int get volume => _volume; |
|
||||
set volume(int value) { |
|
||||
if (value < 0 || value > 100) { |
|
||||
throw ArgumentError('音量必须在 0 到 100 之间'); |
|
||||
} |
|
||||
_volume = value; |
|
||||
_setVolume(value); |
|
||||
} |
|
||||
|
|
||||
// 可用的语音列表 |
|
||||
final RxList<Map<String, String>> _voices = <Map<String, String>>[].obs; |
|
||||
List<Map<String, String>> get voices => _voices; |
|
||||
|
|
||||
/// 初始化 Azure 文本转语音服务 |
|
||||
/// |
|
||||
/// [subscriptionKey] Azure 语音服务的订阅密钥 |
|
||||
/// [serviceRegion] Azure 语音服务的区域 |
|
||||
/// [defaultVoice] 默认语音名称,默认为中文女声 |
|
||||
Future<bool> initialize({ |
|
||||
String? subscriptionKey, |
|
||||
String? serviceRegion, |
|
||||
String? defaultVoice, |
|
||||
}) async { |
|
||||
if (_isInitialized) return true; |
|
||||
|
|
||||
try { |
|
||||
// 从环境变量中读取配置,如果没有传入参数 |
|
||||
final key = subscriptionKey ?? dotenv.env['AZURE_TTS_SUBSCRIPTION_KEY'] ?? ''; |
|
||||
final region = serviceRegion ?? dotenv.env['AZURE_TTS_SERVICE_REGION'] ?? 'eastasia'; |
|
||||
final voice = defaultVoice ?? dotenv.env['AZURE_TTS_DEFAULT_VOICE'] ?? 'zh-CN-XiaoxiaoNeural'; |
|
||||
|
|
||||
// 检查配置是否完整 |
|
||||
if (key.isEmpty) { |
|
||||
Logger.warning('Azure 文本转语音服务的订阅密钥未设置,请检查 .env 文件'); |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
// 设置事件监听 |
|
||||
_setupEventChannel(); |
|
||||
|
|
||||
final result = await _methodChannel.invokeMethod<bool>( |
|
||||
'initialize', |
|
||||
{ |
|
||||
'subscriptionKey': key, |
|
||||
'serviceRegion': region, |
|
||||
'defaultVoice': voice, |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
_isInitialized = result ?? false; |
|
||||
|
|
||||
if (_isInitialized) { |
|
||||
Logger.info('Azure 文本转语音服务初始化成功'); |
|
||||
_currentVoice = voice; |
|
||||
|
|
||||
// 获取可用语音列表 |
|
||||
await getAvailableVoices(); |
|
||||
} else { |
|
||||
Logger.error('Azure 文本转语音服务初始化失败'); |
|
||||
} |
|
||||
|
|
||||
return _isInitialized; |
|
||||
} catch (e) { |
|
||||
Logger.error('初始化 Azure 文本转语音服务失败', e); |
|
||||
_isInitialized = false; |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置事件通道监听 |
|
||||
void _setupEventChannel() { |
|
||||
// 创建事件流控制器 |
|
||||
_eventStreamController = StreamController<SynthesisEvent>.broadcast(); |
|
||||
|
|
||||
// 设置事件监听 |
|
||||
_eventSubscription = _eventChannel |
|
||||
.receiveBroadcastStream() |
|
||||
.map<SynthesisEvent>((dynamic event) => SynthesisEvent.fromMap(event)) |
|
||||
.listen( |
|
||||
(event) { |
|
||||
_handleSynthesisEvent(event); |
|
||||
_eventStreamController?.add(event); |
|
||||
}, |
|
||||
onError: (error) { |
|
||||
Logger.error('Azure 文本转语音事件流错误', error); |
|
||||
_eventStreamController?.addError(error); |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
// 设置公开的流 |
|
||||
_synthesisStream = _eventStreamController?.stream; |
|
||||
} |
|
||||
|
|
||||
/// 处理合成事件 |
|
||||
void _handleSynthesisEvent(SynthesisEvent event) { |
|
||||
switch (event.type) { |
|
||||
case SynthesisEventType.synthesisStarted: |
|
||||
_ttsState.value = TtsState.synthesizing; |
|
||||
break; |
|
||||
case SynthesisEventType.synthesisCompleted: |
|
||||
_ttsState.value = TtsState.idle; |
|
||||
break; |
|
||||
case SynthesisEventType.synthesisCanceled: |
|
||||
case SynthesisEventType.error: |
|
||||
_ttsState.value = TtsState.idle; |
|
||||
break; |
|
||||
default: |
|
||||
// 其他事件类型不需要特殊处理 |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 获取可用的语音列表 |
|
||||
Future<List<Map<String, String>>> getAvailableVoices() async { |
|
||||
_checkInitialized(); |
|
||||
|
|
||||
try { |
|
||||
final result = await _methodChannel.invokeMethod<List<dynamic>>('getAvailableVoices'); |
|
||||
|
|
||||
if (result != null) { |
|
||||
final voicesList = result.map((item) { |
|
||||
final map = Map<String, dynamic>.from(item as Map); |
|
||||
return { |
|
||||
'name': map['name'] as String, |
|
||||
'locale': map['locale'] as String, |
|
||||
'gender': map['gender'] as String, |
|
||||
'displayName': map['displayName'] as String, |
|
||||
}; |
|
||||
}).toList(); |
|
||||
|
|
||||
_voices.value = List<Map<String, String>>.from(voicesList); |
|
||||
return _voices; |
|
||||
} |
|
||||
|
|
||||
return []; |
|
||||
} catch (e) { |
|
||||
Logger.error('获取可用语音列表失败', e); |
|
||||
return []; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语音 |
|
||||
Future<bool> _setVoice(String voiceName) async { |
|
||||
_checkInitialized(); |
|
||||
|
|
||||
try { |
|
||||
final result = await _methodChannel.invokeMethod<bool>( |
|
||||
'setVoice', |
|
||||
{'voiceName': voiceName}, |
|
||||
); |
|
||||
return result ?? false; |
|
||||
} catch (e) { |
|
||||
Logger.error('设置语音失败', e); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语速 |
|
||||
Future<bool> _setSpeechRate(int rate) async { |
|
||||
_checkInitialized(); |
|
||||
|
|
||||
try { |
|
||||
final result = await _methodChannel.invokeMethod<bool>( |
|
||||
'setSpeechRate', |
|
||||
{'rate': rate}, |
|
||||
); |
|
||||
return result ?? false; |
|
||||
} catch (e) { |
|
||||
Logger.error('设置语速失败', e); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置音调 |
|
||||
Future<bool> _setPitch(int pitch) async { |
|
||||
_checkInitialized(); |
|
||||
|
|
||||
try { |
|
||||
final result = await _methodChannel.invokeMethod<bool>( |
|
||||
'setPitch', |
|
||||
{'pitch': pitch}, |
|
||||
); |
|
||||
return result ?? false; |
|
||||
} catch (e) { |
|
||||
Logger.error('设置音调失败', e); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置音量 |
|
||||
Future<bool> _setVolume(int volume) async { |
|
||||
_checkInitialized(); |
|
||||
|
|
||||
try { |
|
||||
final result = await _methodChannel.invokeMethod<bool>( |
|
||||
'setVolume', |
|
||||
{'volume': volume}, |
|
||||
); |
|
||||
return result ?? false; |
|
||||
} catch (e) { |
|
||||
Logger.error('设置音量失败', e); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 合成文本为语音并保存到文件 |
|
||||
/// |
|
||||
/// [text] 要合成的文本 |
|
||||
/// [fileName] 保存的文件名(不包含路径和扩展名) |
|
||||
/// |
|
||||
/// 返回保存的音频文件路径,如果失败则返回 null |
|
||||
Future<String?> synthesizeToFile(String text, String fileName) async { |
|
||||
_checkInitialized(); |
|
||||
|
|
||||
if (text.isEmpty) { |
|
||||
Logger.warning('合成文本为空'); |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 获取临时目录 |
|
||||
final tempDir = await getTemporaryDirectory(); |
|
||||
final filePath = '${tempDir.path}/$fileName.wav'; |
|
||||
|
|
||||
// 设置状态 |
|
||||
_ttsState.value = TtsState.synthesizing; |
|
||||
|
|
||||
final result = await _methodChannel.invokeMethod<String>( |
|
||||
'synthesizeToFile', |
|
||||
{ |
|
||||
'text': text, |
|
||||
'filePath': filePath, |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
// 合成完成后设置状态 |
|
||||
_ttsState.value = TtsState.idle; |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('合成文本到文件失败', e); |
|
||||
|
|
||||
// 发生错误时设置状态 |
|
||||
_ttsState.value = TtsState.idle; |
|
||||
|
|
||||
return null; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 合成文本并直接播放 |
|
||||
/// |
|
||||
/// [text] 要合成并播放的文本 |
|
||||
/// |
|
||||
/// 返回是否成功开始播放 |
|
||||
Future<bool> speak(String text) async { |
|
||||
_checkInitialized(); |
|
||||
|
|
||||
if (text.isEmpty) { |
|
||||
Logger.warning('播放文本为空'); |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 设置状态 |
|
||||
_ttsState.value = TtsState.synthesizing; |
|
||||
|
|
||||
final result = await _methodChannel.invokeMethod<bool>( |
|
||||
'speak', |
|
||||
{'text': text}, |
|
||||
); |
|
||||
|
|
||||
if (result == true) { |
|
||||
_ttsState.value = TtsState.playing; |
|
||||
} else { |
|
||||
_ttsState.value = TtsState.idle; |
|
||||
} |
|
||||
|
|
||||
return result ?? false; |
|
||||
} catch (e) { |
|
||||
Logger.error('合成并播放文本失败', e); |
|
||||
|
|
||||
// 发生错误时设置状态 |
|
||||
_ttsState.value = TtsState.idle; |
|
||||
|
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止播放 |
|
||||
Future<bool> stop() async { |
|
||||
_checkInitialized(); |
|
||||
|
|
||||
try { |
|
||||
final result = await _methodChannel.invokeMethod<bool>('stop'); |
|
||||
|
|
||||
if (result == true) { |
|
||||
_ttsState.value = TtsState.stopped; |
|
||||
} |
|
||||
|
|
||||
return result ?? false; |
|
||||
} catch (e) { |
|
||||
Logger.error('停止播放失败', e); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 暂停播放 |
|
||||
Future<bool> pause() async { |
|
||||
_checkInitialized(); |
|
||||
|
|
||||
try { |
|
||||
final result = await _methodChannel.invokeMethod<bool>('pause'); |
|
||||
return result ?? false; |
|
||||
} catch (e) { |
|
||||
Logger.error('暂停播放失败', e); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 恢复播放 |
|
||||
Future<bool> resume() async { |
|
||||
_checkInitialized(); |
|
||||
|
|
||||
try { |
|
||||
final result = await _methodChannel.invokeMethod<bool>('resume'); |
|
||||
return result ?? false; |
|
||||
} catch (e) { |
|
||||
Logger.error('恢复播放失败', e); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 检查是否已初始化 |
|
||||
void _checkInitialized() { |
|
||||
if (!_isInitialized) { |
|
||||
throw StateError('Azure 文本转语音服务未初始化'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 释放资源 |
|
||||
Future<void> dispose() async { |
|
||||
try { |
|
||||
await _eventSubscription?.cancel(); |
|
||||
_eventSubscription = null; |
|
||||
|
|
||||
// 只有在控制器存在且未关闭时才关闭 |
|
||||
if (_eventStreamController != null && !_eventStreamController!.isClosed) { |
|
||||
await _eventStreamController?.close(); |
|
||||
} |
|
||||
_eventStreamController = null; |
|
||||
|
|
||||
_synthesisStream = null; |
|
||||
|
|
||||
if (_isInitialized) { |
|
||||
await _methodChannel.invokeMethod<bool>('dispose'); |
|
||||
} |
|
||||
|
|
||||
_isInitialized = false; |
|
||||
Logger.info('Azure 文本转语音服务资源已释放'); |
|
||||
} catch (e) { |
|
||||
Logger.error('释放 Azure 文本转语音服务资源失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
dispose(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,11 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/azure_tts_test_controller.dart'; |
||||
|
|
||||
|
class AzureTtsTestBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<AzureTtsTestController>( |
||||
|
() => AzureTtsTestController(), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,315 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter/foundation.dart'; |
||||
|
import 'package:flutter/widgets.dart'; |
||||
|
import '../../../data/services/azure_tts_service.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
|
||||
|
// 导入 TtsState 枚举 |
||||
|
import '../../../data/services/azure_tts_service.dart' show TtsState; |
||||
|
|
||||
|
class AzureTtsTestController extends GetxController { |
||||
|
final AzureTtsService _ttsService = Get.find<AzureTtsService>(); |
||||
|
|
||||
|
// 可观察状态 |
||||
|
final isSpeaking = false.obs; |
||||
|
final errorMessage = ''.obs; |
||||
|
final isLoading = false.obs; |
||||
|
final statusMessage = '未连接'.obs; |
||||
|
|
||||
|
// 添加实际音频播放状态 |
||||
|
final isAudioPlaying = false.obs; |
||||
|
|
||||
|
// 播放状态检查定时器 |
||||
|
Timer? _playbackCheckTimer; |
||||
|
|
||||
|
// 当前播放的索引 |
||||
|
final currentPlayingIndex = RxInt(-1); |
||||
|
|
||||
|
// 当前使用的语音 |
||||
|
final currentVoice = 'zh-CN-XiaoxiaoNeural'.obs; |
||||
|
|
||||
|
// 可用的语音列表 |
||||
|
final List<Map<String, String>> availableVoices = [ |
||||
|
{'id': 'zh-CN-XiaoxiaoNeural', 'name': '晓晓(女声)'}, |
||||
|
{'id': 'zh-CN-YunxiNeural', 'name': '云希(男声)'}, |
||||
|
{'id': 'zh-CN-YunyangNeural', 'name': '云扬(男声)'}, |
||||
|
{'id': 'zh-CN-XiaohanNeural', 'name': '晓涵(女声)'}, |
||||
|
{'id': 'zh-CN-XiaomoNeural', 'name': '晓墨(女声)'}, |
||||
|
{'id': 'zh-CN-XiaoxuanNeural', 'name': '晓萱(女声)'}, |
||||
|
{'id': 'en-US-AriaNeural', 'name': 'Aria(英语女声)'}, |
||||
|
]; |
||||
|
|
||||
|
// 固定的播报内容 |
||||
|
final RxList<String> speechTexts = <String>[ |
||||
|
'这是第一条播报测试,请听取Azure合成效果。', |
||||
|
'这是第二条播报测试,Azure语音合成支持连续多条播报。', |
||||
|
'这是第三条播报测试,可以测试语音合成的流畅度和连贯性。', |
||||
|
'This is an English test sentence. Azure Text-to-Speech supports multiple languages.', |
||||
|
].obs; |
||||
|
|
||||
|
// 事件订阅 |
||||
|
StreamSubscription? _eventSubscription; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
|
||||
|
// 初始化UI状态 |
||||
|
isSpeaking.value = false; |
||||
|
isAudioPlaying.value = false; |
||||
|
errorMessage.value = ''; |
||||
|
isLoading.value = false; |
||||
|
|
||||
|
// 监听TTS服务状态 |
||||
|
_setupEventListener(); |
||||
|
|
||||
|
// 启动播放状态检查定时器 |
||||
|
_startPlaybackCheckTimer(); |
||||
|
} |
||||
|
|
||||
|
// 设置事件监听 |
||||
|
void _setupEventListener() { |
||||
|
_eventSubscription?.cancel(); |
||||
|
_eventSubscription = _ttsService.synthesisStream?.listen(_handleEvent); |
||||
|
} |
||||
|
|
||||
|
// 处理事件 |
||||
|
void _handleEvent(SynthesisEvent event) { |
||||
|
switch (event.type) { |
||||
|
case SynthesisEventType.synthesisStarted: |
||||
|
statusMessage.value = '合成开始'; |
||||
|
errorMessage.value = ''; |
||||
|
break; |
||||
|
case SynthesisEventType.synthesisCompleted: |
||||
|
statusMessage.value = '合成完成'; |
||||
|
errorMessage.value = ''; |
||||
|
break; |
||||
|
case SynthesisEventType.synthesisCanceled: |
||||
|
statusMessage.value = '合成取消'; |
||||
|
errorMessage.value = event.reason ?? '未知原因'; |
||||
|
currentPlayingIndex.value = -1; |
||||
|
break; |
||||
|
case SynthesisEventType.error: |
||||
|
statusMessage.value = '错误'; |
||||
|
errorMessage.value = event.error ?? '未知错误'; |
||||
|
currentPlayingIndex.value = -1; |
||||
|
break; |
||||
|
default: |
||||
|
statusMessage.value = '事件: ${event.type}'; |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
// 强制更新UI |
||||
|
update(); |
||||
|
} |
||||
|
|
||||
|
/// 更新UI状态 |
||||
|
void updateUIState() { |
||||
|
// 强制更新UI |
||||
|
update(); |
||||
|
} |
||||
|
|
||||
|
// 启动播放状态检查定时器 |
||||
|
void _startPlaybackCheckTimer() { |
||||
|
_playbackCheckTimer?.cancel(); |
||||
|
_playbackCheckTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) { |
||||
|
// 更新实际播放状态 |
||||
|
final actuallyPlaying = _ttsService.ttsState == TtsState.playing || _ttsService.ttsState == TtsState.synthesizing; |
||||
|
|
||||
|
// 只有状态变化时才更新 |
||||
|
if (isAudioPlaying.value != actuallyPlaying) { |
||||
|
isAudioPlaying.value = actuallyPlaying; |
||||
|
// 更新UI |
||||
|
update(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 连续播放所有文本 |
||||
|
Future<void> speakContinuousTexts() async { |
||||
|
if (isLoading.value) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
isLoading.value = true; |
||||
|
isSpeaking.value = true; |
||||
|
errorMessage.value = ''; |
||||
|
|
||||
|
final speaker = currentVoice.value; |
||||
|
|
||||
|
// 设置语音参数 |
||||
|
_ttsService.currentVoice = speaker; |
||||
|
|
||||
|
|
||||
|
// 连续播放所有文本 |
||||
|
for (int i = 0; i < speechTexts.length; i++) { |
||||
|
// 如果播放被中断,退出循环 |
||||
|
if (!isSpeaking.value) break; |
||||
|
|
||||
|
// 在主线程上更新UI |
||||
|
WidgetsBinding.instance.addPostFrameCallback((_) { |
||||
|
currentPlayingIndex.value = i; |
||||
|
statusMessage.value = '正在播放第 ${i + 1}/${speechTexts.length} 条文本'; |
||||
|
update(); |
||||
|
}); |
||||
|
|
||||
|
// 播放当前文本,不等待完成 |
||||
|
_ttsService.speak(speechTexts[i]); |
||||
|
|
||||
|
// 短暂延迟,确保文本被送入队列 |
||||
|
await Future.delayed(const Duration(milliseconds: 300)); |
||||
|
} |
||||
|
|
||||
|
// 在主线程上更新UI |
||||
|
WidgetsBinding.instance.addPostFrameCallback((_) { |
||||
|
statusMessage.value = '所有文本已送入播放队列'; |
||||
|
update(); |
||||
|
}); |
||||
|
|
||||
|
|
||||
|
} catch (e) { |
||||
|
errorMessage.value = '播放出错'; |
||||
|
if (kDebugMode) { |
||||
|
print('连续播放出错: $e'); |
||||
|
} |
||||
|
} finally { |
||||
|
isLoading.value = false; |
||||
|
|
||||
|
// 强制更新UI |
||||
|
update(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 播放单条文本 |
||||
|
Future<void> playSingle(int index) async { |
||||
|
if (index < 0 || index >= speechTexts.length) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
isSpeaking.value = true; |
||||
|
errorMessage.value = ''; |
||||
|
currentPlayingIndex.value = index; |
||||
|
statusMessage.value = '准备播放...'; |
||||
|
|
||||
|
final text = speechTexts[index]; |
||||
|
final speaker = currentVoice.value; |
||||
|
|
||||
|
// 设置语音参数 |
||||
|
_ttsService.currentVoice = speaker; |
||||
|
|
||||
|
// 使用异步操作,避免阻塞主线程 |
||||
|
Future(() { |
||||
|
// 播放文本,不等待完成 |
||||
|
_ttsService.speak(text); |
||||
|
|
||||
|
// 在主线程上更新UI |
||||
|
WidgetsBinding.instance.addPostFrameCallback((_) { |
||||
|
statusMessage.value = '文本已送入播放队列'; |
||||
|
update(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
} catch (e) { |
||||
|
errorMessage.value = '播放出错'; |
||||
|
if (kDebugMode) { |
||||
|
print('播放出错: $e'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止播放 |
||||
|
void stopSpeaking() { |
||||
|
try { |
||||
|
isSpeaking.value = false; |
||||
|
statusMessage.value = '正在停止播放...'; |
||||
|
|
||||
|
// 使用异步操作,避免阻塞主线程 |
||||
|
Future(() async { |
||||
|
// 停止播放 |
||||
|
await _ttsService.stop(); |
||||
|
|
||||
|
// 在主线程上更新UI |
||||
|
WidgetsBinding.instance.addPostFrameCallback((_) { |
||||
|
// 重置UI状态 |
||||
|
currentPlayingIndex.value = -1; |
||||
|
statusMessage.value = '已停止播放'; |
||||
|
update(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
} catch (e) { |
||||
|
if (kDebugMode) { |
||||
|
print('停止播放出错: $e'); |
||||
|
} |
||||
|
errorMessage.value = '停止播放出错'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 设置当前语音 |
||||
|
void setVoice(String voiceId) { |
||||
|
currentVoice.value = voiceId; |
||||
|
} |
||||
|
|
||||
|
// 获取当前语音名称 |
||||
|
String getCurrentVoiceName() { |
||||
|
final voice = availableVoices.firstWhere( |
||||
|
(v) => v['id'] == currentVoice.value, |
||||
|
orElse: () => {'id': currentVoice.value, 'name': '未知语音'}, |
||||
|
); |
||||
|
return voice['name'] ?? '未知语音'; |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
// 取消事件订阅 |
||||
|
_eventSubscription?.cancel(); |
||||
|
|
||||
|
// 取消播放状态检查定时器 |
||||
|
_playbackCheckTimer?.cancel(); |
||||
|
|
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
/// 初始化Azure TTS服务 |
||||
|
Future<void> initialize({ |
||||
|
String? subscriptionKey, |
||||
|
String? region, |
||||
|
}) async { |
||||
|
try { |
||||
|
isLoading.value = true; |
||||
|
statusMessage.value = '正在初始化Azure TTS...'; |
||||
|
|
||||
|
if (subscriptionKey == null || region == null) { |
||||
|
statusMessage.value = '尝试从环境变量读取配置...'; |
||||
|
} |
||||
|
|
||||
|
await _ttsService.initialize( |
||||
|
subscriptionKey: subscriptionKey, |
||||
|
region: region, |
||||
|
); |
||||
|
|
||||
|
// 检查是否初始化成功 |
||||
|
final isSuccess = _ttsService.isInitialized; |
||||
|
|
||||
|
if (isSuccess) { |
||||
|
isSpeaking.value = false; |
||||
|
statusMessage.value = 'Azure TTS初始化成功'; |
||||
|
} else { |
||||
|
statusMessage.value = 'Azure TTS初始化失败'; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
statusMessage.value = '初始化异常: $e'; |
||||
|
errorMessage.value = e.toString(); |
||||
|
if (kDebugMode) { |
||||
|
print('初始化异常: $e'); |
||||
|
} |
||||
|
} finally { |
||||
|
isLoading.value = false; |
||||
|
// 强制更新UI |
||||
|
update(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,9 +1,9 @@ |
|||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import '../controllers/asr_test_controller.dart'; |
import '../controllers/azure_asr_test_controller.dart'; |
||||
|
|
||||
class AsrTestView extends GetView<AsrTestController> { |
class AzureAsrTestView extends GetView<AzureAsrTestController> { |
||||
const AsrTestView({Key? key}) : super(key: key); |
const AzureAsrTestView({Key? key}) : super(key: key); |
||||
|
|
||||
@override |
@override |
||||
Widget build(BuildContext context) { |
Widget build(BuildContext context) { |
||||
@ -0,0 +1,280 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/azure_tts_test_controller.dart'; |
||||
|
|
||||
|
class AzureTtsTestView extends GetView<AzureTtsTestController> { |
||||
|
const AzureTtsTestView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
// 强制更新控制器状态 |
||||
|
WidgetsBinding.instance.addPostFrameCallback((_) { |
||||
|
// 在下一帧渲染完成后调用 |
||||
|
controller.updateUIState(); |
||||
|
|
||||
|
// 尝试从环境变量初始化 |
||||
|
controller.initialize(); |
||||
|
}); |
||||
|
|
||||
|
return Scaffold( |
||||
|
appBar: AppBar( |
||||
|
title: const Text('Azure 语音合成测试'), |
||||
|
centerTitle: true, |
||||
|
actions: [ |
||||
|
// 添加初始化按钮 |
||||
|
IconButton( |
||||
|
icon: const Icon(Icons.refresh), |
||||
|
tooltip: '从环境变量初始化', |
||||
|
onPressed: () => controller.initialize(), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
body: SingleChildScrollView( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.stretch, |
||||
|
children: [ |
||||
|
// 播放控制 |
||||
|
Card( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'播放控制', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
||||
|
children: [ |
||||
|
Obx(() { |
||||
|
return ElevatedButton.icon( |
||||
|
onPressed: controller.isAudioPlaying.value || controller.isLoading.value |
||||
|
? null |
||||
|
: () => controller.speakContinuousTexts(), |
||||
|
icon: controller.isAudioPlaying.value |
||||
|
? const SizedBox( |
||||
|
width: 20, |
||||
|
height: 20, |
||||
|
child: CircularProgressIndicator(strokeWidth: 2) |
||||
|
) |
||||
|
: const Icon(Icons.playlist_play), |
||||
|
label: Text(controller.isAudioPlaying.value ? '播放中...' : '全部播放'), |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue, |
||||
|
foregroundColor: Colors.white, |
||||
|
), |
||||
|
); |
||||
|
}), |
||||
|
Obx(() => ElevatedButton.icon( |
||||
|
onPressed: controller.isAudioPlaying.value |
||||
|
? () => controller.stopSpeaking() |
||||
|
: null, |
||||
|
icon: const Icon(Icons.stop), |
||||
|
label: const Text('停止'), |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.red, |
||||
|
foregroundColor: Colors.white, |
||||
|
), |
||||
|
)), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// 播报内容列表 |
||||
|
Card( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'播报内容列表', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
// 生成可点击的播报内容 |
||||
|
Obx(() => ListView.separated( |
||||
|
shrinkWrap: true, |
||||
|
physics: const NeverScrollableScrollPhysics(), |
||||
|
itemCount: controller.speechTexts.length, |
||||
|
separatorBuilder: (context, index) => const Divider(height: 1), |
||||
|
itemBuilder: (context, index) { |
||||
|
final isPlaying = controller.isAudioPlaying.value && |
||||
|
controller.currentPlayingIndex.value == index; |
||||
|
|
||||
|
return ListTile( |
||||
|
title: Text( |
||||
|
controller.speechTexts[index], |
||||
|
style: TextStyle( |
||||
|
fontWeight: isPlaying ? FontWeight.bold : FontWeight.normal, |
||||
|
color: isPlaying ? Colors.blue : null, |
||||
|
), |
||||
|
), |
||||
|
leading: CircleAvatar( |
||||
|
backgroundColor: isPlaying ? Colors.blue : Colors.grey.shade200, |
||||
|
radius: 18, |
||||
|
child: Icon( |
||||
|
isPlaying ? Icons.volume_up : Icons.play_arrow, |
||||
|
color: isPlaying ? Colors.white : Colors.black, |
||||
|
size: 20, |
||||
|
), |
||||
|
), |
||||
|
trailing: isPlaying |
||||
|
? const SizedBox( |
||||
|
width: 20, |
||||
|
height: 20, |
||||
|
child: CircularProgressIndicator(strokeWidth: 2), |
||||
|
) |
||||
|
: null, |
||||
|
onTap: controller.isAudioPlaying.value || controller.isLoading.value |
||||
|
? null |
||||
|
: () => controller.playSingle(index), |
||||
|
enabled: !(controller.isAudioPlaying.value || controller.isLoading.value), |
||||
|
tileColor: isPlaying ? Colors.blue.withOpacity(0.1) : null, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
side: isPlaying |
||||
|
? const BorderSide(color: Colors.blue, width: 1) |
||||
|
: BorderSide.none, |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
)), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// 语音选择 |
||||
|
Card( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'语音选择', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Wrap( |
||||
|
spacing: 8.0, |
||||
|
runSpacing: 8.0, |
||||
|
children: controller.availableVoices.map((voice) { |
||||
|
final id = voice['id'] ?? ''; |
||||
|
final name = voice['name'] ?? ''; |
||||
|
return Obx(() => ChoiceChip( |
||||
|
label: Text(name), |
||||
|
selected: controller.currentVoice.value == id, |
||||
|
onSelected: (selected) { |
||||
|
if (selected) { |
||||
|
controller.setVoice(id); |
||||
|
} |
||||
|
}, |
||||
|
selectedColor: Colors.blue, |
||||
|
labelStyle: TextStyle( |
||||
|
color: controller.currentVoice.value == id |
||||
|
? Colors.white |
||||
|
: Colors.black, |
||||
|
), |
||||
|
)); |
||||
|
}).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), |
||||
|
Obx(() => Text( |
||||
|
controller.statusMessage.value, |
||||
|
style: TextStyle( |
||||
|
color: controller.isLoading.value |
||||
|
? Colors.orange |
||||
|
: controller.isAudioPlaying.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), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
) |
||||
|
: const SizedBox.shrink()), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue