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:get/get.dart'; |
|||
import '../controllers/asr_test_controller.dart'; |
|||
import '../controllers/azure_asr_test_controller.dart'; |
|||
|
|||
class AsrTestView extends GetView<AsrTestController> { |
|||
const AsrTestView({Key? key}) : super(key: key); |
|||
class AzureAsrTestView extends GetView<AzureAsrTestController> { |
|||
const AzureAsrTestView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
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