34 changed files with 4659 additions and 1836 deletions
@ -0,0 +1,351 @@ |
|||||
|
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, "连续识别已开始") |
||||
|
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) { |
||||
|
callback.onError("SpeechRecognizer 未初始化") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
if (!isContinuousRecognitionActive) { |
||||
|
callback.onError("连续识别未在进行中") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// 停止连续识别 |
||||
|
recognizer?.stopContinuousRecognitionAsync() |
||||
|
|
||||
|
// 等待停止完成 |
||||
|
stopRecognitionSemaphore.acquire() |
||||
|
|
||||
|
isContinuousRecognitionActive = false |
||||
|
Log.d(TAG, "连续识别已停止") |
||||
|
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,35 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
|
||||
|
class AppColors { |
||||
|
// Background colors |
||||
|
static const Color darkBackground = Color(0xFF121212); |
||||
|
static const Color cardBackground = Color(0xFF1E1E1E); |
||||
|
|
||||
|
// Primary colors |
||||
|
static const Color primary = Color(0xFF6200EE); |
||||
|
static const Color primaryVariant = Color(0xFF3700B3); |
||||
|
|
||||
|
// Accent colors |
||||
|
static const Color accent = Color(0xFF03DAC6); |
||||
|
static const Color accentVariant = Color(0xFF018786); |
||||
|
|
||||
|
// Text colors |
||||
|
static const Color textPrimary = Color(0xFFFFFFFF); |
||||
|
static const Color textSecondary = Color(0xB3FFFFFF); // 70% white |
||||
|
static const Color textHint = Color(0x80FFFFFF); // 50% white |
||||
|
|
||||
|
// Status colors |
||||
|
static const Color success = Color(0xFF4CAF50); |
||||
|
static const Color warning = Color(0xFFFFC107); |
||||
|
static const Color error = Color(0xFFB00020); |
||||
|
|
||||
|
// Gradient colors |
||||
|
static const List<Color> primaryGradient = [ |
||||
|
Color(0xFF6200EE), |
||||
|
Color(0xFF9C27B0), |
||||
|
]; |
||||
|
|
||||
|
// Disabled colors |
||||
|
static const Color disabled = Color(0xFF757575); |
||||
|
static const Color disabledBackground = Color(0xFF2D2D2D); |
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'app_colors.dart'; |
||||
|
|
||||
|
class TextStyles { |
||||
|
// Headings |
||||
|
static const TextStyle heading1 = TextStyle( |
||||
|
fontSize: 28, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: AppColors.textPrimary, |
||||
|
height: 1.2, |
||||
|
); |
||||
|
|
||||
|
static const TextStyle heading2 = TextStyle( |
||||
|
fontSize: 24, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: AppColors.textPrimary, |
||||
|
height: 1.2, |
||||
|
); |
||||
|
|
||||
|
static const TextStyle heading3 = TextStyle( |
||||
|
fontSize: 20, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: AppColors.textPrimary, |
||||
|
height: 1.2, |
||||
|
); |
||||
|
|
||||
|
// Title and subtitle |
||||
|
static const TextStyle title = TextStyle( |
||||
|
fontSize: 18, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: AppColors.textPrimary, |
||||
|
height: 1.3, |
||||
|
); |
||||
|
|
||||
|
static const TextStyle subtitle = TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: AppColors.textPrimary, |
||||
|
height: 1.3, |
||||
|
); |
||||
|
|
||||
|
// Body text |
||||
|
static const TextStyle body = TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.normal, |
||||
|
color: AppColors.textPrimary, |
||||
|
height: 1.5, |
||||
|
); |
||||
|
|
||||
|
static const TextStyle bodySmall = TextStyle( |
||||
|
fontSize: 14, |
||||
|
fontWeight: FontWeight.normal, |
||||
|
color: AppColors.textPrimary, |
||||
|
height: 1.5, |
||||
|
); |
||||
|
|
||||
|
// Button text |
||||
|
static const TextStyle button = TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: AppColors.textPrimary, |
||||
|
letterSpacing: 0.5, |
||||
|
); |
||||
|
|
||||
|
// Caption and overline |
||||
|
static const TextStyle caption = TextStyle( |
||||
|
fontSize: 12, |
||||
|
fontWeight: FontWeight.normal, |
||||
|
color: AppColors.textSecondary, |
||||
|
height: 1.4, |
||||
|
); |
||||
|
|
||||
|
static const TextStyle overline = TextStyle( |
||||
|
fontSize: 10, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: AppColors.textSecondary, |
||||
|
letterSpacing: 1.5, |
||||
|
height: 1.4, |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,376 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../core/utils/logger.dart'; |
||||
|
|
||||
|
/// 识别事件类型 |
||||
|
enum RecognitionEventType { |
||||
|
/// 最终识别结果 |
||||
|
finalResult, |
||||
|
|
||||
|
/// 中间识别结果(实时反馈) |
||||
|
recognizing, |
||||
|
|
||||
|
/// 会话开始 |
||||
|
sessionStarted, |
||||
|
|
||||
|
/// 会话结束 |
||||
|
sessionStopped, |
||||
|
|
||||
|
/// 识别取消 |
||||
|
canceled, |
||||
|
|
||||
|
/// 无匹配结果 |
||||
|
noMatch, |
||||
|
|
||||
|
/// 识别错误 |
||||
|
error, |
||||
|
} |
||||
|
|
||||
|
/// 识别事件 |
||||
|
class RecognitionEvent { |
||||
|
/// 事件类型 |
||||
|
final RecognitionEventType type; |
||||
|
|
||||
|
/// 识别文本(仅在 finalResult 和 recognizing 类型中有效) |
||||
|
final String text; |
||||
|
|
||||
|
/// 错误信息(仅在 error 和 canceled 类型中有效) |
||||
|
final String? error; |
||||
|
|
||||
|
/// 取消原因(仅在 canceled 类型中有效) |
||||
|
final String? reason; |
||||
|
|
||||
|
/// 错误详情(仅在 canceled 类型中有效) |
||||
|
final String? errorDetails; |
||||
|
|
||||
|
RecognitionEvent({ |
||||
|
required this.type, |
||||
|
this.text = '', |
||||
|
this.error, |
||||
|
this.reason, |
||||
|
this.errorDetails, |
||||
|
}); |
||||
|
|
||||
|
/// 从原生事件映射创建识别事件 |
||||
|
factory RecognitionEvent.fromMap(Map<dynamic, dynamic> map) { |
||||
|
final eventType = map['eventType'] as String; |
||||
|
|
||||
|
RecognitionEventType type; |
||||
|
switch (eventType) { |
||||
|
case 'recognizing': |
||||
|
type = RecognitionEventType.recognizing; |
||||
|
break; |
||||
|
case 'finalResult': |
||||
|
type = RecognitionEventType.finalResult; |
||||
|
break; |
||||
|
case 'sessionStarted': |
||||
|
type = RecognitionEventType.sessionStarted; |
||||
|
break; |
||||
|
case 'sessionStopped': |
||||
|
type = RecognitionEventType.sessionStopped; |
||||
|
break; |
||||
|
case 'canceled': |
||||
|
type = RecognitionEventType.canceled; |
||||
|
break; |
||||
|
case 'noMatch': |
||||
|
type = RecognitionEventType.noMatch; |
||||
|
break; |
||||
|
case 'error': |
||||
|
type = RecognitionEventType.error; |
||||
|
break; |
||||
|
default: |
||||
|
throw ArgumentError('Unknown event type: $eventType'); |
||||
|
} |
||||
|
|
||||
|
return RecognitionEvent( |
||||
|
type: type, |
||||
|
text: map['text'] as String? ?? '', |
||||
|
error: map['error'] as String?, |
||||
|
reason: map['reason'] as String?, |
||||
|
errorDetails: map['errorDetails'] as String?, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
String toString() { |
||||
|
return 'RecognitionEvent{type: $type, text: $text, error: $error, reason: $reason, errorDetails: $errorDetails}'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// Azure 语音识别服务 |
||||
|
class AzureAsrService extends GetxService { |
||||
|
static AzureAsrService get to => Get.find(); |
||||
|
|
||||
|
// 方法通道 |
||||
|
static const MethodChannel _methodChannel = MethodChannel('com.example.deep_voice/azure_asr'); |
||||
|
|
||||
|
// 事件通道 |
||||
|
static const EventChannel _eventChannel = EventChannel('com.example.deep_voice/azure_asr_events'); |
||||
|
|
||||
|
// 是否已初始化 |
||||
|
bool _isInitialized = false; |
||||
|
|
||||
|
// 连续识别相关 |
||||
|
bool _isContinuousRecognitionActive = false; |
||||
|
StreamController<RecognitionEvent>? _eventStreamController; |
||||
|
StreamSubscription? _eventSubscription; |
||||
|
|
||||
|
// 公开的事件流 |
||||
|
Stream<RecognitionEvent>? _recognitionStream; |
||||
|
Stream<RecognitionEvent>? get recognitionStream => _recognitionStream; |
||||
|
|
||||
|
// 最新的识别结果 |
||||
|
String _latestRecognizedText = ''; |
||||
|
String get latestRecognizedText => _latestRecognizedText; |
||||
|
|
||||
|
/// 初始化 Azure 语音识别服务 |
||||
|
/// |
||||
|
/// [subscriptionKey] Azure 语音服务的订阅密钥 |
||||
|
/// [serviceRegion] Azure 语音服务的区域 |
||||
|
/// [language] 识别语言,默认为中文 |
||||
|
Future<bool> initialize({ |
||||
|
required String subscriptionKey, |
||||
|
required String serviceRegion, |
||||
|
String language = 'zh-CN', |
||||
|
}) async { |
||||
|
if (_isInitialized) return true; |
||||
|
|
||||
|
try { |
||||
|
final result = await _methodChannel.invokeMethod<bool>( |
||||
|
'initialize', |
||||
|
{ |
||||
|
'subscriptionKey': subscriptionKey, |
||||
|
'serviceRegion': serviceRegion, |
||||
|
'language': language, |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
_isInitialized = result ?? false; |
||||
|
|
||||
|
if (_isInitialized) { |
||||
|
Logger.info('Azure 语音识别服务初始化成功'); |
||||
|
} else { |
||||
|
Logger.error('Azure 语音识别服务初始化失败'); |
||||
|
} |
||||
|
|
||||
|
return _isInitialized; |
||||
|
} catch (e) { |
||||
|
Logger.error('初始化 Azure 语音识别服务失败', e); |
||||
|
_isInitialized = false; |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 重置识别器 |
||||
|
/// 在切换识别模式前调用,确保资源被正确释放 |
||||
|
Future<bool> _resetRecognizer() async { |
||||
|
try { |
||||
|
// 如果连续识别正在进行,先停止它 |
||||
|
if (_isContinuousRecognitionActive) { |
||||
|
await stopContinuousRecognition(); |
||||
|
} |
||||
|
|
||||
|
// 清理事件流资源 |
||||
|
_cleanupEventStream(); |
||||
|
|
||||
|
// 调用原生方法重置识别器 |
||||
|
await _methodChannel.invokeMethod<bool>('resetRecognizer'); |
||||
|
|
||||
|
Logger.info('Azure 语音识别器已重置'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('重置 Azure 语音识别器失败', e); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 执行一次性语音识别 |
||||
|
Future<String> recognizeOnce() async { |
||||
|
_checkInitialized(); |
||||
|
|
||||
|
try { |
||||
|
// 重置识别器,确保资源被正确释放 |
||||
|
await _resetRecognizer(); |
||||
|
|
||||
|
final result = await _methodChannel.invokeMethod<String>('recognizeOnce'); |
||||
|
final text = result ?? ''; |
||||
|
_latestRecognizedText = text; |
||||
|
return text; |
||||
|
} catch (e) { |
||||
|
Logger.error('一次性语音识别失败', e); |
||||
|
rethrow; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 开始连续语音识别 |
||||
|
/// |
||||
|
/// 返回一个布尔值,表示是否成功启动连续识别 |
||||
|
Future<bool> startContinuousRecognition() async { |
||||
|
_checkInitialized(); |
||||
|
|
||||
|
if (_isContinuousRecognitionActive) { |
||||
|
Logger.warning('连续识别已经在进行中'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// 重置识别器,确保资源被正确释放 |
||||
|
await _resetRecognizer(); |
||||
|
|
||||
|
// 创建事件流控制器 |
||||
|
_eventStreamController = StreamController<RecognitionEvent>.broadcast(); |
||||
|
|
||||
|
// 设置事件监听 |
||||
|
_eventSubscription = _eventChannel |
||||
|
.receiveBroadcastStream() |
||||
|
.map<RecognitionEvent>((dynamic event) => RecognitionEvent.fromMap(event)) |
||||
|
.listen( |
||||
|
(event) { |
||||
|
_handleRecognitionEvent(event); |
||||
|
_eventStreamController?.add(event); |
||||
|
}, |
||||
|
onError: (error) { |
||||
|
Logger.error('Azure 语音识别事件流错误', error); |
||||
|
_eventStreamController?.addError(error); |
||||
|
_cleanupEventStream(); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
// 开始连续识别 |
||||
|
final result = await _methodChannel.invokeMethod<bool>('startContinuousRecognition'); |
||||
|
_isContinuousRecognitionActive = result ?? false; |
||||
|
|
||||
|
// 设置公开的流 |
||||
|
_recognitionStream = _eventStreamController?.stream; |
||||
|
|
||||
|
return _isContinuousRecognitionActive; |
||||
|
} catch (e) { |
||||
|
Logger.error('开始连续语音识别失败', e); |
||||
|
_cleanupEventStream(); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止连续语音识别 |
||||
|
Future<bool> stopContinuousRecognition() async { |
||||
|
_checkInitialized(); |
||||
|
|
||||
|
if (!_isContinuousRecognitionActive) { |
||||
|
Logger.warning('连续识别未在进行中'); |
||||
|
return true; // 已经停止,直接返回成功 |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
final result = await _methodChannel.invokeMethod<bool>('stopContinuousRecognition'); |
||||
|
final success = result ?? false; |
||||
|
|
||||
|
if (success) { |
||||
|
_isContinuousRecognitionActive = false; |
||||
|
// 不立即清理事件流,等待 sessionStopped 事件 |
||||
|
} |
||||
|
|
||||
|
return success; |
||||
|
} catch (e) { |
||||
|
Logger.error('停止连续语音识别失败', e); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置识别语言 |
||||
|
Future<bool> setRecognitionLanguage(String language) async { |
||||
|
_checkInitialized(); |
||||
|
|
||||
|
try { |
||||
|
final result = await _methodChannel.invokeMethod<bool>( |
||||
|
'setRecognitionLanguage', |
||||
|
{'language': language}, |
||||
|
); |
||||
|
return result ?? false; |
||||
|
} catch (e) { |
||||
|
Logger.error('设置识别语言失败', e); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 检查连续识别是否活跃 |
||||
|
bool isContinuousRecognitionActive() { |
||||
|
return _isContinuousRecognitionActive; |
||||
|
} |
||||
|
|
||||
|
/// 处理来自原生端的识别事件 |
||||
|
void _handleRecognitionEvent(RecognitionEvent event) { |
||||
|
switch (event.type) { |
||||
|
case RecognitionEventType.finalResult: |
||||
|
_latestRecognizedText = event.text; |
||||
|
break; |
||||
|
case RecognitionEventType.sessionStopped: |
||||
|
_isContinuousRecognitionActive = false; |
||||
|
// 延迟清理事件流,确保所有事件都被处理 |
||||
|
Future.delayed(Duration(milliseconds: 500), _cleanupEventStream); |
||||
|
break; |
||||
|
case RecognitionEventType.canceled: |
||||
|
_isContinuousRecognitionActive = false; |
||||
|
// 延迟清理事件流,确保所有事件都被处理 |
||||
|
Future.delayed(Duration(milliseconds: 500), _cleanupEventStream); |
||||
|
break; |
||||
|
default: |
||||
|
// 其他事件类型不需要特殊处理 |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 清理事件流资源 |
||||
|
void _cleanupEventStream() { |
||||
|
_eventSubscription?.cancel(); |
||||
|
_eventSubscription = null; |
||||
|
|
||||
|
// 只有在控制器存在且未关闭时才关闭 |
||||
|
if (_eventStreamController != null && !_eventStreamController!.isClosed) { |
||||
|
_eventStreamController?.close(); |
||||
|
} |
||||
|
_eventStreamController = null; |
||||
|
|
||||
|
_recognitionStream = null; |
||||
|
} |
||||
|
|
||||
|
/// 检查是否已初始化 |
||||
|
void _checkInitialized() { |
||||
|
if (!_isInitialized) { |
||||
|
throw StateError('Azure 语音识别服务未初始化'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 释放资源 |
||||
|
Future<void> dispose() async { |
||||
|
try { |
||||
|
if (_isContinuousRecognitionActive) { |
||||
|
await stopContinuousRecognition(); |
||||
|
} |
||||
|
|
||||
|
await _eventSubscription?.cancel(); |
||||
|
_eventSubscription = null; |
||||
|
|
||||
|
// 只有在控制器存在且未关闭时才关闭 |
||||
|
if (_eventStreamController != null && !_eventStreamController!.isClosed) { |
||||
|
await _eventStreamController?.close(); |
||||
|
} |
||||
|
_eventStreamController = null; |
||||
|
|
||||
|
_recognitionStream = null; |
||||
|
|
||||
|
await _methodChannel.invokeMethod<bool>('dispose'); |
||||
|
|
||||
|
_isInitialized = false; |
||||
|
Logger.info('Azure 语音识别服务资源已释放'); |
||||
|
} catch (e) { |
||||
|
Logger.error('释放 Azure 语音识别服务资源失败', e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
dispose(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
} |
||||
@ -1,281 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
|
|
||||
/// 微软语音识别服务 |
|
||||
/// |
|
||||
/// 该服务提供了通过平台通道与 Android 上的 Microsoft Speech SDK 交互的接口 |
|
||||
class VoiceRecognitionService { |
|
||||
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; |
|
||||
|
|
||||
// 最新的识别结果 |
|
||||
String _latestRecognizedText = ''; |
|
||||
String get latestRecognizedText => _latestRecognizedText; |
|
||||
|
|
||||
VoiceRecognitionService() { |
|
||||
_loadConfig(); |
|
||||
} |
|
||||
|
|
||||
/// 从环境变量加载配置 |
|
||||
void _loadConfig() { |
|
||||
_subscriptionKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
|
||||
_serviceRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; |
|
||||
|
|
||||
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { |
|
||||
throw Exception('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 初始化微软语音识别 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) { |
|
||||
print('语音识别初始化失败: ${e.message}'); |
|
||||
_isInitialized = false; |
|
||||
throw e; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 执行一次性语音识别 |
|
||||
/// |
|
||||
/// 返回识别的文本,如果识别失败则抛出 PlatformException |
|
||||
Future<String> recognizeSpeech() async { |
|
||||
if (!_isInitialized) { |
|
||||
throw Exception('语音识别服务未初始化,请先调用 initialize()'); |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
final String result = await _channel.invokeMethod('recognizeOnce'); |
|
||||
return result; |
|
||||
} on PlatformException catch (e) { |
|
||||
print('语音识别失败: ${e.message}'); |
|
||||
throw e; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 开始连续语音识别 |
|
||||
/// |
|
||||
/// 返回一个包含识别事件的流,如果开始失败则抛出 PlatformException |
|
||||
Future<Stream<RecognitionEvent>> startContinuousRecognition() async { |
|
||||
if (!_isInitialized) { |
|
||||
throw Exception('语音识别服务未初始化,请先调用 initialize()'); |
|
||||
} |
|
||||
|
|
||||
if (_isContinuousRecognitionActive) { |
|
||||
throw Exception('连续语音识别已经在进行中'); |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 创建事件流控制器 |
|
||||
_eventStreamController = StreamController<RecognitionEvent>.broadcast(); |
|
||||
|
|
||||
// 设置事件监听 |
|
||||
_eventSubscription = _eventChannel |
|
||||
.receiveBroadcastStream() |
|
||||
.listen(_handleRecognitionEvent, onError: _handleRecognitionError); |
|
||||
|
|
||||
// 开始连续识别 |
|
||||
final bool result = await _channel.invokeMethod('startContinuousRecognition'); |
|
||||
_isContinuousRecognitionActive = result; |
|
||||
|
|
||||
// 设置公开的流 |
|
||||
_recognitionStream = _eventStreamController!.stream; |
|
||||
|
|
||||
return _recognitionStream!; |
|
||||
} on PlatformException catch (e) { |
|
||||
print('开始连续语音识别失败: ${e.message}'); |
|
||||
_cleanupEventStream(); |
|
||||
throw e; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止连续语音识别 |
|
||||
/// |
|
||||
/// 返回 true 表示停止成功,否则抛出 PlatformException |
|
||||
Future<bool> stopContinuousRecognition() async { |
|
||||
if (!_isInitialized) { |
|
||||
throw Exception('语音识别服务未初始化,请先调用 initialize()'); |
|
||||
} |
|
||||
|
|
||||
if (!_isContinuousRecognitionActive) { |
|
||||
return true; // 已经停止,直接返回成功 |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
final bool result = await _channel.invokeMethod('stopContinuousRecognition'); |
|
||||
_isContinuousRecognitionActive = !result; |
|
||||
|
|
||||
// 清理事件流 |
|
||||
_cleanupEventStream(); |
|
||||
|
|
||||
return result; |
|
||||
} on PlatformException catch (e) { |
|
||||
print('停止连续语音识别失败: ${e.message}'); |
|
||||
throw e; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 检查连续识别是否活跃 |
|
||||
bool isContinuousRecognitionActive() { |
|
||||
return _isContinuousRecognitionActive; |
|
||||
} |
|
||||
|
|
||||
/// 处理来自原生端的识别事件 |
|
||||
void _handleRecognitionEvent(dynamic event) { |
|
||||
if (event is! Map) return; |
|
||||
|
|
||||
final Map<dynamic, dynamic> eventMap = event; |
|
||||
final String eventType = eventMap['eventType'] as String? ?? ''; |
|
||||
|
|
||||
switch (eventType) { |
|
||||
case 'finalResult': |
|
||||
final String text = eventMap['text'] as String? ?? ''; |
|
||||
_latestRecognizedText = text; |
|
||||
_eventStreamController?.add(RecognitionEvent( |
|
||||
type: RecognitionEventType.finalResult, |
|
||||
text: text, |
|
||||
)); |
|
||||
break; |
|
||||
|
|
||||
case 'intermediateResult': |
|
||||
final String text = eventMap['text'] as String? ?? ''; |
|
||||
_eventStreamController?.add(RecognitionEvent( |
|
||||
type: RecognitionEventType.intermediateResult, |
|
||||
text: text, |
|
||||
)); |
|
||||
break; |
|
||||
|
|
||||
case 'sessionStarted': |
|
||||
_eventStreamController?.add(RecognitionEvent( |
|
||||
type: RecognitionEventType.sessionStarted, |
|
||||
)); |
|
||||
break; |
|
||||
|
|
||||
case 'sessionStopped': |
|
||||
_isContinuousRecognitionActive = false; |
|
||||
_eventStreamController?.add(RecognitionEvent( |
|
||||
type: RecognitionEventType.sessionStopped, |
|
||||
)); |
|
||||
break; |
|
||||
|
|
||||
case 'canceled': |
|
||||
_isContinuousRecognitionActive = false; |
|
||||
final String reason = eventMap['reason'] as String? ?? ''; |
|
||||
final String errorDetails = eventMap['errorDetails'] as String? ?? ''; |
|
||||
_eventStreamController?.add(RecognitionEvent( |
|
||||
type: RecognitionEventType.canceled, |
|
||||
error: '$reason: $errorDetails', |
|
||||
)); |
|
||||
break; |
|
||||
|
|
||||
case 'error': |
|
||||
final String error = eventMap['error'] as String? ?? ''; |
|
||||
_eventStreamController?.add(RecognitionEvent( |
|
||||
type: RecognitionEventType.error, |
|
||||
error: error, |
|
||||
)); |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 处理识别事件流错误 |
|
||||
void _handleRecognitionError(Object error) { |
|
||||
_eventStreamController?.addError(error); |
|
||||
_cleanupEventStream(); |
|
||||
} |
|
||||
|
|
||||
/// 清理事件流资源 |
|
||||
void _cleanupEventStream() { |
|
||||
_eventSubscription?.cancel(); |
|
||||
_eventSubscription = null; |
|
||||
|
|
||||
_eventStreamController?.close(); |
|
||||
_eventStreamController = null; |
|
||||
|
|
||||
_recognitionStream = null; |
|
||||
_isContinuousRecognitionActive = false; |
|
||||
} |
|
||||
|
|
||||
/// 释放资源 |
|
||||
Future<void> dispose() async { |
|
||||
try { |
|
||||
if (_isContinuousRecognitionActive) { |
|
||||
await stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
await _channel.invokeMethod('dispose'); |
|
||||
_cleanupEventStream(); |
|
||||
_isInitialized = false; |
|
||||
} catch (e) { |
|
||||
print('释放语音识别资源失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 识别事件类型 |
|
||||
enum RecognitionEventType { |
|
||||
/// 最终识别结果 |
|
||||
finalResult, |
|
||||
|
|
||||
/// 中间识别结果(实时反馈) |
|
||||
intermediateResult, |
|
||||
|
|
||||
/// 会话开始 |
|
||||
sessionStarted, |
|
||||
|
|
||||
/// 会话结束 |
|
||||
sessionStopped, |
|
||||
|
|
||||
/// 识别取消 |
|
||||
canceled, |
|
||||
|
|
||||
/// 识别错误 |
|
||||
error, |
|
||||
} |
|
||||
|
|
||||
/// 识别事件 |
|
||||
class RecognitionEvent { |
|
||||
/// 事件类型 |
|
||||
final RecognitionEventType type; |
|
||||
|
|
||||
/// 识别文本(仅在 finalResult 和 intermediateResult 类型中有效) |
|
||||
final String text; |
|
||||
|
|
||||
/// 错误信息(仅在 error 和 canceled 类型中有效) |
|
||||
final String error; |
|
||||
|
|
||||
RecognitionEvent({ |
|
||||
required this.type, |
|
||||
this.text = '', |
|
||||
this.error = '', |
|
||||
}); |
|
||||
|
|
||||
@override |
|
||||
String toString() { |
|
||||
return 'RecognitionEvent{type: $type, text: $text, error: $error}'; |
|
||||
} |
|
||||
} |
|
||||
File diff suppressed because it is too large
@ -1,120 +1,297 @@ |
|||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import '../../../data/services/volcano_tts_service.dart'; |
import 'dart:async'; |
||||
|
import 'package:flutter/foundation.dart'; |
||||
|
import '../../../data/services/volcano_tts_api_service.dart'; |
||||
|
|
||||
class TtsTestController extends GetxController { |
class TtsTestController extends GetxController { |
||||
final VolcanoTtsService _ttsService = Get.find<VolcanoTtsService>(); |
final VolcanoTtsApiService _ttsService = Get.find<VolcanoTtsApiService>(); |
||||
|
|
||||
// 文本控制器 |
|
||||
final TextEditingController textController = TextEditingController(); |
|
||||
|
|
||||
// 可观察状态 |
// 可观察状态 |
||||
final isPlaying = false.obs; |
final isSpeaking = false.obs; |
||||
final errorMessage = ''.obs; |
final errorMessage = ''.obs; |
||||
final selectedVoiceType = ''.obs; |
|
||||
final voiceTypes = <String>[].obs; |
|
||||
final isLoading = false.obs; |
final isLoading = false.obs; |
||||
|
final statusMessage = '未连接'.obs; |
||||
|
|
||||
|
// 当前播放的索引 |
||||
|
final currentPlayingIndex = RxInt(-1); |
||||
|
|
||||
|
// 固定的播报内容 |
||||
|
final RxList<String> speechTexts = <String>[ |
||||
|
'这是第一条播报测试,请听取合成效果。', |
||||
|
'这是第二条播报测试,火山语音合成支持连续多条播报。', |
||||
|
'这是第三条播报测试,可以测试语音合成的流畅度和连贯性。', |
||||
|
'这是第四条播报测试,感谢您的使用。', |
||||
|
].obs; |
||||
|
|
||||
|
// 事件订阅 |
||||
|
StreamSubscription? _eventSubscription; |
||||
|
|
||||
@override |
@override |
||||
void onInit() { |
void onInit() { |
||||
super.onInit(); |
super.onInit(); |
||||
|
|
||||
|
// 初始化UI状态 |
||||
|
isSpeaking.value = false; |
||||
|
errorMessage.value = ''; |
||||
|
isLoading.value = false; |
||||
|
|
||||
|
// 监听TTS服务状态 |
||||
|
ever(_ttsService.isConnected, (bool connected) { |
||||
|
if (connected) { |
||||
|
statusMessage.value = '已连接'; |
||||
|
} else { |
||||
|
statusMessage.value = '未连接'; |
||||
|
isSpeaking.value = false; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// 监听TTS合成状态 |
||||
|
ever(_ttsService.isSynthesizing, (bool synthesizing) { |
||||
|
isSpeaking.value = synthesizing; |
||||
|
}); |
||||
|
|
||||
// 监听TTS播放状态 |
// 监听TTS播放状态 |
||||
_ttsService.playingStateStream.listen((playing) { |
ever(_ttsService.isPlaying.obs, (bool playing) { |
||||
isPlaying.value = playing; |
isSpeaking.value = playing; |
||||
if (!playing) { |
if (!playing && currentPlayingIndex.value >= 0) { |
||||
errorMessage.value = ''; |
// 如果播放结束,重置当前播放索引 |
||||
|
currentPlayingIndex.value = -1; |
||||
} |
} |
||||
}); |
}); |
||||
|
|
||||
// 设置默认文本 |
// 设置事件监听 |
||||
textController.text = '这是一个火山语音合成测试,请点击播放按钮听取合成效果。'; |
_setupEventListener(); |
||||
|
|
||||
// 设置可用的语音类型 |
// 连接到TTS服务 |
||||
voiceTypes.value = [ |
_connectToTtsService(); |
||||
// 趣味方言 |
} |
||||
'zh_female_wanqudashu_moon_bigtts', // 湾区大叔 |
|
||||
'zh_female_daimengchuanmei_moon_bigtts', // 呆萌川妹 |
// 设置事件监听 |
||||
'zh_male_guozhoudege_moon_bigtts', // 广州德哥 |
void _setupEventListener() { |
||||
'zh_male_beijingxiaoye_moon_bigtts', // 北京小爷 |
_eventSubscription?.cancel(); |
||||
'zh_male_haoyuxiaoge_moon_bigtts', // 浩宇小哥 |
_eventSubscription = _ttsService.eventStream.listen(_handleEvent); |
||||
|
} |
||||
// 通用场景 |
|
||||
'zh_male_shaonianzixin_moon_bigtts', // 少年梓辛/Brayan |
// 处理事件 |
||||
|
void _handleEvent(Map<String, dynamic> event) { |
||||
// 角色扮演 |
final eventType = event['eventType']; |
||||
'zh_female_meilinvyou_moon_bigtts', // 魅力女友 |
|
||||
'zh_male_shenyeboke_moon_bigtts', // 深夜播客 |
|
||||
'zh_female_sajiaonvyou_moon_bigtts', // 柔美女友 |
|
||||
'zh_female_yuanqinvyou_moon_bigtts', // 撒娇学妹 |
|
||||
|
|
||||
]; |
|
||||
|
|
||||
// 设置当前选中的语音类型 |
switch (eventType) { |
||||
selectedVoiceType.value = _ttsService.getCurrentVoiceType(); |
case 'connectionStarted': |
||||
|
statusMessage.value = '连接成功'; |
||||
|
errorMessage.value = ''; |
||||
|
break; |
||||
|
case 'connectionFailed': |
||||
|
statusMessage.value = '连接失败'; |
||||
|
errorMessage.value = event['error'] ?? '未知错误'; |
||||
|
break; |
||||
|
case 'sessionStarted': |
||||
|
statusMessage.value = '会话开始'; |
||||
|
errorMessage.value = ''; |
||||
|
break; |
||||
|
case 'sessionFailed': |
||||
|
statusMessage.value = '会话失败'; |
||||
|
errorMessage.value = event['error'] ?? '未知错误'; |
||||
|
currentPlayingIndex.value = -1; |
||||
|
break; |
||||
|
case 'ttsSentenceStart': |
||||
|
statusMessage.value = '开始合成'; |
||||
|
break; |
||||
|
case 'ttsSentenceEnd': |
||||
|
statusMessage.value = '合成完成'; |
||||
|
break; |
||||
|
case 'sessionFinished': |
||||
|
statusMessage.value = '会话结束'; |
||||
|
currentPlayingIndex.value = -1; |
||||
|
break; |
||||
|
case 'connectionFinished': |
||||
|
statusMessage.value = '连接关闭'; |
||||
|
currentPlayingIndex.value = -1; |
||||
|
break; |
||||
|
case 'error': |
||||
|
statusMessage.value = '错误'; |
||||
|
errorMessage.value = event['error'] ?? '未知错误'; |
||||
|
break; |
||||
|
case 'disconnected': |
||||
|
statusMessage.value = '连接已断开'; |
||||
|
currentPlayingIndex.value = -1; |
||||
|
break; |
||||
|
default: |
||||
|
statusMessage.value = '事件: $eventType'; |
||||
|
break; |
||||
|
} |
||||
|
|
||||
// 如果当前语音类型不在列表中,添加到列表 |
// 强制更新UI |
||||
if (!voiceTypes.contains(selectedVoiceType.value)) { |
update(); |
||||
voiceTypes.add(selectedVoiceType.value); |
} |
||||
|
|
||||
|
// 连接到TTS服务 |
||||
|
Future<void> _connectToTtsService() async { |
||||
|
try { |
||||
|
isLoading.value = true; |
||||
|
statusMessage.value = '正在连接...'; |
||||
|
|
||||
|
final success = await _ttsService.connect(); |
||||
|
|
||||
|
if (!success) { |
||||
|
statusMessage.value = '连接失败'; |
||||
|
errorMessage.value = '无法连接到火山语音服务'; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
statusMessage.value = '连接错误'; |
||||
|
errorMessage.value = e.toString(); |
||||
|
} finally { |
||||
|
isLoading.value = false; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 播放文本 |
/// 更新UI状态 |
||||
void speakText() async { |
void updateUIState() { |
||||
final text = textController.text.trim(); |
// 强制更新UI |
||||
if (text.isEmpty) { |
update(); |
||||
errorMessage.value = '请输入要合成的文本'; |
} |
||||
|
|
||||
|
/// 播放单条文本 |
||||
|
Future<void> speakText(int index) async { |
||||
|
if (index < 0 || index >= speechTexts.length || isSpeaking.value) { |
||||
return; |
return; |
||||
} |
} |
||||
|
|
||||
errorMessage.value = ''; |
|
||||
isLoading.value = true; |
|
||||
|
|
||||
try { |
try { |
||||
// 获取当前语音类型 |
isLoading.value = true; |
||||
final currentVoiceType = selectedVoiceType.value; |
errorMessage.value = ''; |
||||
|
currentPlayingIndex.value = index; |
||||
|
|
||||
// 直接使用选定的语音类型,不进行可用性检查 |
final text = speechTexts[index]; |
||||
|
const speaker = 'zh_female_shuangkuaisisi_moon_bigtts'; |
||||
|
|
||||
|
// 确保TTS服务已连接 |
||||
|
if (!_ttsService.isConnected.value) { |
||||
|
await _connectToTtsService(); |
||||
|
} |
||||
|
|
||||
|
// 开始会话 |
||||
|
await _ttsService.startSession(speaker); |
||||
|
|
||||
|
// 播放文本 |
||||
|
await _ttsService.speak(text, speaker: speaker); |
||||
|
|
||||
|
// 等待播放完成 |
||||
|
while (_ttsService.isActuallyPlaying()) { |
||||
|
await Future.delayed(const Duration(milliseconds: 100)); |
||||
|
} |
||||
|
|
||||
|
// 结束会话 |
||||
|
await _ttsService.endSession(); |
||||
|
|
||||
// 播放文本,传递选定的语音类型 |
|
||||
await _ttsService.speak(text, voiceType: currentVoiceType); |
|
||||
} catch (e) { |
} catch (e) { |
||||
errorMessage.value = e.toString(); |
errorMessage.value = '播放出错'; |
||||
isPlaying.value = false; |
if (kDebugMode) { |
||||
|
print('播放出错: $e'); |
||||
// 如果错误包含资源授权相关信息,提供更具体的提示 |
|
||||
final errorMsg = e.toString().toLowerCase(); |
|
||||
if (errorMsg.contains('resource not granted') || |
|
||||
errorMsg.contains('403') || |
|
||||
errorMsg.contains('授权') || |
|
||||
errorMsg.contains('3001')) { |
|
||||
errorMessage.value = '语音类型授权错误: 您可能没有权限使用当前选择的语音类型,请尝试使用基础语音类型'; |
|
||||
} |
} |
||||
} finally { |
} finally { |
||||
isLoading.value = false; |
isLoading.value = false; |
||||
|
currentPlayingIndex.value = -1; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 停止播放 |
/// 连续播放所有文本 |
||||
void stopSpeaking() { |
Future<void> speakContinuousTexts() async { |
||||
|
if (isSpeaking.value || isLoading.value) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
try { |
try { |
||||
_ttsService.stop(); |
isLoading.value = true; |
||||
|
isSpeaking.value = true; |
||||
|
errorMessage.value = ''; |
||||
|
|
||||
|
// 确保TTS服务已连接 |
||||
|
if (!_ttsService.isConnected.value) { |
||||
|
await _connectToTtsService(); |
||||
|
} |
||||
|
|
||||
|
const speaker = 'zh_female_shuangkuaisisi_moon_bigtts'; |
||||
|
|
||||
|
// 停止之前的播放 |
||||
|
await _ttsService.stop(); |
||||
|
|
||||
|
// 开始会话 |
||||
|
await _ttsService.startSession(speaker); |
||||
|
|
||||
|
// 连续播放所有文本 |
||||
|
for (int i = 0; i < speechTexts.length; i++) { |
||||
|
// 如果播放被中断,退出循环 |
||||
|
if (!isSpeaking.value) break; |
||||
|
|
||||
|
currentPlayingIndex.value = i; |
||||
|
statusMessage.value = '正在播放第 ${i + 1}/${speechTexts.length} 条文本'; |
||||
|
|
||||
|
// 播放当前文本 |
||||
|
await _ttsService.speak(speechTexts[i], speaker: speaker); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
// 结束会话 |
||||
|
await _ttsService.endSession(); |
||||
|
|
||||
|
statusMessage.value = '播放完成'; |
||||
|
|
||||
} catch (e) { |
} catch (e) { |
||||
errorMessage.value = e.toString(); |
errorMessage.value = '播放出错'; |
||||
|
if (kDebugMode) { |
||||
|
print('连续播放出错: $e'); |
||||
|
} |
||||
|
|
||||
|
// 尝试结束会话,忽略可能的错误 |
||||
|
try { |
||||
|
await _ttsService.endSession(); |
||||
|
} catch (_) { |
||||
|
// 忽略结束会话时的错误 |
||||
|
} |
||||
|
} finally { |
||||
|
currentPlayingIndex.value = -1; |
||||
|
isSpeaking.value = false; |
||||
|
isLoading.value = false; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/// 切换语音类型 |
/// 停止播放 |
||||
void changeVoiceType(String voiceType) { |
void stopSpeaking() async { |
||||
if (voiceType != selectedVoiceType.value) { |
try { |
||||
selectedVoiceType.value = voiceType; |
isSpeaking.value = false; |
||||
errorMessage.value = ''; |
statusMessage.value = '正在停止播放...'; |
||||
|
|
||||
|
// 停止播放 |
||||
|
await _ttsService.stop(); |
||||
|
|
||||
|
// 结束会话 |
||||
|
await _ttsService.endSession(); |
||||
|
|
||||
|
// 重置UI状态 |
||||
|
currentPlayingIndex.value = -1; |
||||
|
statusMessage.value = '已停止播放'; |
||||
|
|
||||
|
} catch (e) { |
||||
|
if (kDebugMode) { |
||||
|
print('停止播放出错: $e'); |
||||
|
} |
||||
|
errorMessage.value = '停止播放出错'; |
||||
|
} finally { |
||||
|
isSpeaking.value = false; |
||||
|
isLoading.value = false; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
@override |
@override |
||||
void onClose() { |
void onClose() { |
||||
textController.dispose(); |
// 取消事件订阅 |
||||
|
_eventSubscription?.cancel(); |
||||
|
|
||||
|
// 断开TTS服务连接 |
||||
|
_ttsService.disconnect(); |
||||
|
|
||||
super.onClose(); |
super.onClose(); |
||||
} |
} |
||||
} |
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/translation_controller.dart'; |
||||
|
|
||||
|
class TranslationBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<TranslationController>(() => TranslationController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,334 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../data/services/volcano_asr_service.dart'; |
||||
|
import '../../../data/services/volcano_tts_api_service.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import '../models/translation_message_model.dart'; |
||||
|
|
||||
|
class TranslationController extends GetxController { |
||||
|
// Observable states |
||||
|
final isListening = false.obs; |
||||
|
final isTranslating = false.obs; |
||||
|
final sourceLanguage = "Chinese".obs; |
||||
|
final targetLanguage = "English".obs; |
||||
|
final originalText = "".obs; |
||||
|
final translatedText = "".obs; |
||||
|
final isUserSpeaking = false.obs; |
||||
|
final messages = <TranslationMessage>[].obs; |
||||
|
|
||||
|
// TTS启用状态 |
||||
|
final isTtsEnabled = true.obs; |
||||
|
|
||||
|
// Services |
||||
|
late final VolcanoAsrService _voiceService; |
||||
|
late final VolcanoTtsApiService _ttsService; |
||||
|
|
||||
|
// Stream subscriptions |
||||
|
StreamSubscription? _recognitionSubscription; |
||||
|
|
||||
|
// Timers |
||||
|
Timer? _silenceTimer; |
||||
|
Timer? _speakingCheckTimer; |
||||
|
|
||||
|
// State tracking |
||||
|
bool _hasRecognizedSpeech = false; |
||||
|
bool _hasFinalResult = false; |
||||
|
DateTime? _lastSpeechTime; |
||||
|
Completer<String>? _recognitionCompleter; |
||||
|
|
||||
|
// TTS事件订阅 |
||||
|
StreamSubscription? _ttsEventSubscription; |
||||
|
bool _isTtsSpeaking = false; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
_initializeServices(); |
||||
|
_startSpeakingCheckTimer(); |
||||
|
|
||||
|
_ttsService = Get.find<VolcanoTtsApiService>(); |
||||
|
|
||||
|
// 设置TTS事件监听 |
||||
|
_setupTtsEventListener(); |
||||
|
} |
||||
|
|
||||
|
Future<void> _initializeServices() async { |
||||
|
try { |
||||
|
_voiceService = Get.find<VolcanoAsrService>(); |
||||
|
_ttsService = Get.find<VolcanoTtsApiService>(); |
||||
|
|
||||
|
await _voiceService.initialize(); |
||||
|
await startListening(); |
||||
|
} catch (e) { |
||||
|
Logger.error('Failed to initialize translation services', e); |
||||
|
Get.snackbar( |
||||
|
'Error', |
||||
|
'Failed to initialize translation services: $e', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> startListening() async { |
||||
|
if (isListening.value) return; |
||||
|
|
||||
|
try { |
||||
|
isListening.value = true; |
||||
|
originalText.value = ''; |
||||
|
translatedText.value = ''; |
||||
|
_hasRecognizedSpeech = false; |
||||
|
_hasFinalResult = false; |
||||
|
|
||||
|
_recognitionCompleter = Completer<String>(); |
||||
|
|
||||
|
final success = await _voiceService.startContinuousRecognition(); |
||||
|
if (!success) { |
||||
|
isListening.value = false; |
||||
|
throw Exception('Failed to start voice recognition'); |
||||
|
} |
||||
|
|
||||
|
_recognitionSubscription = _voiceService.recognitionStream?.listen( |
||||
|
(event) { |
||||
|
switch (event.type) { |
||||
|
case RecognitionEventType.recognizing: |
||||
|
if (event.text.isNotEmpty) { |
||||
|
originalText.value = event.text; |
||||
|
_hasRecognizedSpeech = true; |
||||
|
_lastSpeechTime = DateTime.now(); |
||||
|
isUserSpeaking.value = true; |
||||
|
|
||||
|
// Cancel previous silence timer |
||||
|
_silenceTimer?.cancel(); |
||||
|
|
||||
|
// Set new silence timer - if no new recognition results in 2 seconds, |
||||
|
// consider the user has stopped speaking |
||||
|
_silenceTimer = Timer(const Duration(seconds: 2), () { |
||||
|
if (_hasRecognizedSpeech && !_hasFinalResult) { |
||||
|
_processTranslation(event.text); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// If TTS is playing and user starts speaking, stop TTS |
||||
|
if (_isTtsSpeaking) { |
||||
|
_ttsService.endSession(); |
||||
|
} |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
case RecognitionEventType.finalResult: |
||||
|
_hasFinalResult = true; |
||||
|
isUserSpeaking.value = false; |
||||
|
|
||||
|
if (event.text.isNotEmpty) { |
||||
|
originalText.value = event.text; |
||||
|
_hasRecognizedSpeech = true; |
||||
|
_lastSpeechTime = DateTime.now(); |
||||
|
|
||||
|
_processTranslation(event.text); |
||||
|
} else if (_hasRecognizedSpeech) { |
||||
|
// If final result is empty but we had intermediate results |
||||
|
_processTranslation(originalText.value); |
||||
|
} |
||||
|
|
||||
|
// Reset for next utterance |
||||
|
_hasRecognizedSpeech = false; |
||||
|
_hasFinalResult = false; |
||||
|
break; |
||||
|
|
||||
|
case RecognitionEventType.error: |
||||
|
Logger.error('Voice recognition error', event.error); |
||||
|
break; |
||||
|
|
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
}, |
||||
|
onError: (error) { |
||||
|
Logger.error('Voice recognition stream error', error); |
||||
|
isListening.value = false; |
||||
|
restartListening(); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
} catch (e) { |
||||
|
Logger.error('Failed to start listening', e); |
||||
|
isListening.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _processTranslation(String text) async { |
||||
|
if (text.isEmpty) return; |
||||
|
|
||||
|
try { |
||||
|
isTranslating.value = true; |
||||
|
|
||||
|
// In a real implementation, you would call your translation service here |
||||
|
// For now, we'll simulate translation with a delay |
||||
|
await Future.delayed(const Duration(milliseconds: 300)); |
||||
|
|
||||
|
// Simulate translation (in a real app, call your translation API here) |
||||
|
final translated = await _simulateTranslation(text); |
||||
|
translatedText.value = translated; |
||||
|
|
||||
|
// Add to messages |
||||
|
messages.add(TranslationMessage( |
||||
|
original: text, |
||||
|
translated: translated, |
||||
|
timestamp: DateTime.now(), |
||||
|
isFromUser: true, |
||||
|
)); |
||||
|
|
||||
|
// Speak the translation if TTS is enabled |
||||
|
if (isTtsEnabled.value) { |
||||
|
await _synthesizeText(translated); |
||||
|
} |
||||
|
|
||||
|
} catch (e) { |
||||
|
Logger.error('Translation error', e); |
||||
|
} finally { |
||||
|
isTranslating.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// This is a placeholder - in a real app, you would call your translation API |
||||
|
Future<String> _simulateTranslation(String text) async { |
||||
|
// This is just a simulation - replace with actual translation API call |
||||
|
if (sourceLanguage.value == "Chinese" && targetLanguage.value == "English") { |
||||
|
// Simulate Chinese to English translation |
||||
|
if (text.contains('测试')) return 'I am now starting to test the function of translating spoken Chinese into English.'; |
||||
|
return 'Translated from Chinese: $text'; |
||||
|
} else { |
||||
|
// Simulate English to Chinese translation |
||||
|
return '翻译自英语: $text'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void switchLanguages() { |
||||
|
final temp = sourceLanguage.value; |
||||
|
sourceLanguage.value = targetLanguage.value; |
||||
|
targetLanguage.value = temp; |
||||
|
|
||||
|
// Clear current messages when switching languages |
||||
|
messages.clear(); |
||||
|
} |
||||
|
|
||||
|
Future<void> restartListening() async { |
||||
|
await stopListening(); |
||||
|
await Future.delayed(const Duration(milliseconds: 500)); |
||||
|
await startListening(); |
||||
|
} |
||||
|
|
||||
|
Future<void> stopListening() async { |
||||
|
if (!isListening.value) return; |
||||
|
|
||||
|
try { |
||||
|
await _recognitionSubscription?.cancel(); |
||||
|
_recognitionSubscription = null; |
||||
|
|
||||
|
_silenceTimer?.cancel(); |
||||
|
_silenceTimer = null; |
||||
|
|
||||
|
if (_voiceService.isContinuousRecognitionActive()) { |
||||
|
await _voiceService.stopRecognition(); |
||||
|
} |
||||
|
|
||||
|
isListening.value = false; |
||||
|
isUserSpeaking.value = false; |
||||
|
_hasRecognizedSpeech = false; |
||||
|
_hasFinalResult = false; |
||||
|
} catch (e) { |
||||
|
Logger.error('Failed to stop listening', e); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _startSpeakingCheckTimer() { |
||||
|
_speakingCheckTimer?.cancel(); |
||||
|
_speakingCheckTimer = Timer.periodic(const Duration(milliseconds: 200), (_) { |
||||
|
_checkIfUserIsSpeaking(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
void _checkIfUserIsSpeaking() { |
||||
|
if (_hasRecognizedSpeech && !_hasFinalResult && _lastSpeechTime != null) { |
||||
|
final timeSinceLastSpeech = DateTime.now().difference(_lastSpeechTime!); |
||||
|
if (timeSinceLastSpeech.inMilliseconds < 1000) { |
||||
|
isUserSpeaking.value = true; |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
isUserSpeaking.value = false; |
||||
|
} |
||||
|
|
||||
|
void _setupTtsEventListener() { |
||||
|
_ttsEventSubscription = _ttsService.eventStream.listen(_handleTtsEvent); |
||||
|
} |
||||
|
|
||||
|
void _handleTtsEvent(Map<String, dynamic> event) { |
||||
|
final String eventType = event['type'] as String? ?? 'unknown'; |
||||
|
|
||||
|
switch (eventType) { |
||||
|
case 'ttsSentenceStart': |
||||
|
_isTtsSpeaking = true; |
||||
|
break; |
||||
|
case 'ttsSentenceEnd': |
||||
|
case 'sessionFinished': |
||||
|
case 'connectionFinished': |
||||
|
_isTtsSpeaking = false; |
||||
|
break; |
||||
|
case 'connectionFailed': |
||||
|
case 'sessionFailed': |
||||
|
_isTtsSpeaking = false; |
||||
|
print('TTS错误: ${event['message']}'); |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
stopListening(); |
||||
|
_speakingCheckTimer?.cancel(); |
||||
|
|
||||
|
// 取消TTS事件订阅 |
||||
|
_ttsEventSubscription?.cancel(); |
||||
|
|
||||
|
// 结束TTS会话 |
||||
|
_ttsService.endSession(); |
||||
|
|
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
// Add a method to synthesize text using the new TTS service |
||||
|
Future<void> _synthesizeText(String text) async { |
||||
|
if (text.isEmpty || !isTtsEnabled.value) return; |
||||
|
|
||||
|
try { |
||||
|
// 如果当前有正在进行的TTS会话,先结束它 |
||||
|
if (_isTtsSpeaking) { |
||||
|
await _ttsService.endSession(); |
||||
|
} |
||||
|
|
||||
|
// 确保连接已建立 |
||||
|
if (!_ttsService.isConnected.value) { |
||||
|
final connected = await _ttsService.connect(); |
||||
|
if (!connected) { |
||||
|
print('无法连接到TTS服务'); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 开始合成 |
||||
|
await _ttsService.synthesize(text, 'zh_female_1'); // 使用默认说话人 |
||||
|
} catch (e) { |
||||
|
print('TTS播放出错: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Add a method to toggle TTS |
||||
|
void toggleTTS() { |
||||
|
isTtsEnabled.toggle(); |
||||
|
if (!isTtsEnabled.value) { |
||||
|
_ttsService.endSession(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
class TranslationMessage { |
||||
|
final String original; |
||||
|
final String translated; |
||||
|
final DateTime timestamp; |
||||
|
final bool isFromUser; |
||||
|
|
||||
|
TranslationMessage({ |
||||
|
required this.original, |
||||
|
required this.translated, |
||||
|
required this.timestamp, |
||||
|
required this.isFromUser, |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,302 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/translation_controller.dart'; |
||||
|
import '../../../core/theme/app_colors.dart'; |
||||
|
import '../../../core/theme/text_styles.dart'; |
||||
|
import '../widgets/translation_message_item.dart'; |
||||
|
|
||||
|
class TranslationView extends GetView<TranslationController> { |
||||
|
const TranslationView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
appBar: AppBar( |
||||
|
title: _buildLanguageSelector(), |
||||
|
centerTitle: true, |
||||
|
backgroundColor: Colors.white, |
||||
|
elevation: 0, |
||||
|
leading: IconButton( |
||||
|
icon: const Icon(Icons.arrow_back, color: Colors.black), |
||||
|
onPressed: () => Get.back(), |
||||
|
), |
||||
|
actions: [ |
||||
|
IconButton( |
||||
|
icon: const Icon(Icons.settings_outlined, color: Colors.black), |
||||
|
onPressed: () { |
||||
|
// Navigate to settings |
||||
|
}, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
body: Container( |
||||
|
color: Colors.white, |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: _buildMessagesList(), |
||||
|
), |
||||
|
_buildBottomSection(), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildLanguageSelector() { |
||||
|
return Obx(() { |
||||
|
return Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Text( |
||||
|
controller.sourceLanguage.value, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: Colors.black, |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
icon: const Icon(Icons.swap_horiz, color: Color(0xFF4A6FE5)), |
||||
|
onPressed: controller.switchLanguages, |
||||
|
), |
||||
|
Text( |
||||
|
controller.targetLanguage.value, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: Colors.black, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
Widget _buildMessagesList() { |
||||
|
return Obx(() { |
||||
|
if (controller.messages.isEmpty) { |
||||
|
return _buildWelcomeMessage(); |
||||
|
} |
||||
|
|
||||
|
return ListView.builder( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), |
||||
|
itemCount: controller.messages.length, |
||||
|
reverse: true, |
||||
|
itemBuilder: (context, index) { |
||||
|
final message = controller.messages[controller.messages.length - 1 - index]; |
||||
|
return TranslationMessageItem(message: message); |
||||
|
}, |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
Widget _buildWelcomeMessage() { |
||||
|
return Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
// First welcome message |
||||
|
Container( |
||||
|
width: double.infinity, |
||||
|
padding: const EdgeInsets.all(20), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey[100], |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
border: Border.all( |
||||
|
color: Colors.grey[300]!, |
||||
|
width: 0.5, |
||||
|
), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
"Welcome to Felo Translator. 👆 Click the button above to switch languages", |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: Colors.grey[700], |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
"Welcome to Felo Translator. 👆 Click the button above to switch languages", |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: Colors.grey[800], |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
Icon(Icons.volume_up, color: Colors.grey[600]), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 20), |
||||
|
|
||||
|
// Second instruction message |
||||
|
Container( |
||||
|
width: double.infinity, |
||||
|
padding: const EdgeInsets.all(20), |
||||
|
decoration: BoxDecoration( |
||||
|
color: const Color(0xFFEEF4FF), |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
border: Border.all( |
||||
|
color: const Color(0xFFD0DFFF), |
||||
|
width: 0.5, |
||||
|
), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
"👇 Click the button below ▶️ to start speaking, and Felo will automatically recognize the language for translation", |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: Colors.grey[700], |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
"👇 Click the button below ▶️ to start speaking, and Felo will automatically recognize the language for translation", |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: Colors.grey[800], |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
Icon(Icons.volume_up, color: Colors.grey[600]), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildBottomSection() { |
||||
|
return Container( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 4, |
||||
|
offset: const Offset(0, -2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
_buildRecognitionStatus(), |
||||
|
const SizedBox(height: 8), |
||||
|
_buildControlButtons(), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildRecognitionStatus() { |
||||
|
return Obx(() { |
||||
|
final isListening = controller.isListening.value; |
||||
|
final isUserSpeaking = controller.isUserSpeaking.value; |
||||
|
|
||||
|
if (!isListening) { |
||||
|
return const SizedBox.shrink(); |
||||
|
} |
||||
|
|
||||
|
return Container( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey[100], |
||||
|
borderRadius: BorderRadius.circular(24), |
||||
|
border: Border.all( |
||||
|
color: Colors.grey[300]!, |
||||
|
width: 0.5, |
||||
|
), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
if (isUserSpeaking) |
||||
|
_buildSpeakingIndicator() |
||||
|
else |
||||
|
Icon(Icons.mic, color: Colors.grey[600]), |
||||
|
const SizedBox(width: 12), |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
isUserSpeaking |
||||
|
? "Listening..." |
||||
|
: "Tap the mic button to start speaking", |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: Colors.grey[700], |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
Widget _buildSpeakingIndicator() { |
||||
|
return SizedBox( |
||||
|
width: 24, |
||||
|
height: 24, |
||||
|
child: Stack( |
||||
|
alignment: Alignment.center, |
||||
|
children: [ |
||||
|
Icon(Icons.mic, color: const Color(0xFF4A6FE5)), |
||||
|
Positioned.fill( |
||||
|
child: CircularProgressIndicator( |
||||
|
strokeWidth: 2, |
||||
|
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF4A6FE5)), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildControlButtons() { |
||||
|
return Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Obx(() { |
||||
|
final isListening = controller.isListening.value; |
||||
|
|
||||
|
return FloatingActionButton( |
||||
|
onPressed: isListening |
||||
|
? controller.stopListening |
||||
|
: controller.startListening, |
||||
|
backgroundColor: isListening ? Colors.red : const Color(0xFF4A6FE5), |
||||
|
child: Icon( |
||||
|
isListening ? Icons.stop : Icons.mic, |
||||
|
size: 24, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
); |
||||
|
}), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,82 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import '../models/translation_message_model.dart'; |
||||
|
|
||||
|
class TranslationMessageItem extends StatelessWidget { |
||||
|
final TranslationMessage message; |
||||
|
|
||||
|
const TranslationMessageItem({ |
||||
|
Key? key, |
||||
|
required this.message, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Container( |
||||
|
margin: const EdgeInsets.symmetric(vertical: 8), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
// Original text |
||||
|
Container( |
||||
|
width: double.infinity, |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
decoration: BoxDecoration( |
||||
|
color: const Color(0xFFF5F5F5), |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
border: Border.all( |
||||
|
color: Colors.grey[300]!, |
||||
|
width: 0.5, |
||||
|
), |
||||
|
), |
||||
|
child: Text( |
||||
|
message.original, |
||||
|
style: TextStyle( |
||||
|
color: Colors.grey[800], |
||||
|
fontSize: 14, |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 8), |
||||
|
|
||||
|
// Translated text |
||||
|
Container( |
||||
|
width: double.infinity, |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
decoration: BoxDecoration( |
||||
|
color: const Color(0xFFEEF4FF), |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
border: Border.all( |
||||
|
color: const Color(0xFFD0DFFF), |
||||
|
width: 0.5, |
||||
|
), |
||||
|
), |
||||
|
child: Row( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
message.translated, |
||||
|
style: TextStyle( |
||||
|
color: Colors.black87, |
||||
|
fontSize: 14, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
height: 1.5, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
Icon( |
||||
|
Icons.volume_up, |
||||
|
color: Colors.grey[600], |
||||
|
size: 20, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -1,259 +0,0 @@ |
|||||
import 'dart:io'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
import 'package:http/http.dart' as http; |
|
||||
import 'dart:convert'; |
|
||||
|
|
||||
/// 检查火山语音识别服务配置 |
|
||||
/// |
|
||||
/// 该工具用于验证火山语音识别服务的配置是否正确,包括: |
|
||||
/// 1. 检查环境变量是否设置 |
|
||||
/// 2. 检查网络连接 |
|
||||
/// 3. 检查认证是否有效 |
|
||||
void main() async { |
|
||||
// 加载环境变量 |
|
||||
await dotenv.load(); |
|
||||
|
|
||||
print('======== 火山语音识别服务配置检查 ========'); |
|
||||
|
|
||||
// 检查环境变量 |
|
||||
final appId = dotenv.env['VOLCANO_APP_ID']; |
|
||||
final appKey = dotenv.env['VOLCANO_APP_KEY']; |
|
||||
final cluster = dotenv.env['VOLCANO_CLUSTER']; |
|
||||
|
|
||||
print('\n1. 检查环境变量:'); |
|
||||
|
|
||||
if (appId == null || appId.isEmpty) { |
|
||||
print('❌ VOLCANO_APP_ID 未设置'); |
|
||||
} else { |
|
||||
print('✅ VOLCANO_APP_ID: ${appId.substring(0, 3)}***${appId.substring(appId.length - 3)} (长度: ${appId.length})'); |
|
||||
} |
|
||||
|
|
||||
if (appKey == null || appKey.isEmpty) { |
|
||||
print('❌ VOLCANO_APP_KEY 未设置'); |
|
||||
} else { |
|
||||
print('✅ VOLCANO_APP_KEY: ${appKey.substring(0, 3)}***${appKey.substring(appKey.length - 3)} (长度: ${appKey.length})'); |
|
||||
} |
|
||||
|
|
||||
if (cluster == null || cluster.isEmpty) { |
|
||||
print('❌ VOLCANO_CLUSTER 未设置,将使用默认值 cn-beijing'); |
|
||||
} else { |
|
||||
print('✅ VOLCANO_CLUSTER: $cluster'); |
|
||||
} |
|
||||
|
|
||||
// 使用默认值 |
|
||||
final effectiveCluster = cluster ?? 'cn-beijing'; |
|
||||
|
|
||||
if (appId == null || appId.isEmpty || appKey == null || appKey.isEmpty) { |
|
||||
print('\n❌ 环境变量配置不完整,请检查 .env 文件'); |
|
||||
exit(1); |
|
||||
} |
|
||||
|
|
||||
// 检查网络连接 |
|
||||
print('\n2. 检查网络连接:'); |
|
||||
|
|
||||
try { |
|
||||
final result = await InternetAddress.lookup('openspeech.bytedance.com'); |
|
||||
if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { |
|
||||
print('✅ 网络连接正常,可以访问 openspeech.bytedance.com'); |
|
||||
} else { |
|
||||
print('❌ 无法连接到 openspeech.bytedance.com'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('❌ 网络连接异常: $e'); |
|
||||
} |
|
||||
|
|
||||
// 检查认证是否有效 |
|
||||
print('\n3. 检查认证有效性:'); |
|
||||
|
|
||||
http.Response? authResponse; |
|
||||
|
|
||||
try { |
|
||||
// 构建请求URL - 更新为大模型流式识别API路径 |
|
||||
final url = 'https://openspeech.bytedance.com/api/v3/sauc/bigmodel'; |
|
||||
|
|
||||
// 构建请求头 - 不再添加Bearer前缀 |
|
||||
final headers = { |
|
||||
'Content-Type': 'application/json', |
|
||||
'Authorization': appKey, // 不再添加Bearer前缀 |
|
||||
}; |
|
||||
|
|
||||
// 构建请求体 - 添加resourceId参数 |
|
||||
final body = jsonEncode({ |
|
||||
'app_id': appId, |
|
||||
'cluster': effectiveCluster, |
|
||||
'resource_id': appId, // 资源ID暂时使用与APP_ID相同的值 |
|
||||
'ping': true, // 只是ping服务,不进行实际识别 |
|
||||
}); |
|
||||
|
|
||||
print('正在发送测试请求...'); |
|
||||
print('请求URL: $url'); |
|
||||
print('请求头: Authorization=${appKey.substring(0, 3)}***'); |
|
||||
print('集群区域: $effectiveCluster'); |
|
||||
|
|
||||
// 发送请求 |
|
||||
authResponse = await http.post( |
|
||||
Uri.parse(url), |
|
||||
headers: headers, |
|
||||
body: body, |
|
||||
).timeout(const Duration(seconds: 5)); |
|
||||
|
|
||||
// 检查响应 |
|
||||
if (authResponse.statusCode == 200) { |
|
||||
print('✅ 认证有效,服务响应正常'); |
|
||||
print('响应内容: ${authResponse.body}'); |
|
||||
} else { |
|
||||
print('❌ 认证无效或服务异常,状态码: ${authResponse.statusCode}'); |
|
||||
print('错误信息: ${authResponse.body}'); |
|
||||
|
|
||||
// 解析错误信息 |
|
||||
try { |
|
||||
final errorJson = jsonDecode(authResponse.body); |
|
||||
final errorCode = errorJson['code']; |
|
||||
final errorMsg = errorJson['message']; |
|
||||
|
|
||||
if (errorCode == 401) { |
|
||||
print('\n认证错误,可能的原因:'); |
|
||||
print('1. APP_KEY 格式不正确'); |
|
||||
print('2. APP_ID 与 APP_KEY 不匹配'); |
|
||||
print('3. 账户未开通大模型流式语音识别服务或服务已过期'); |
|
||||
print('4. 资源ID不正确或未授权'); |
|
||||
} else if (errorCode == 400) { |
|
||||
print('\nWebSocket握手错误,可能的原因:'); |
|
||||
print('1. 集群区域设置不正确 (当前: $effectiveCluster)'); |
|
||||
print('2. 请求参数格式不正确'); |
|
||||
print('3. 尝试使用不同的集群区域,如 cn-shanghai 或 cn-guangzhou'); |
|
||||
} else { |
|
||||
print('\n未知错误:'); |
|
||||
print('错误码: $errorCode'); |
|
||||
print('错误信息: $errorMsg'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('\n解析错误信息失败: $e'); |
|
||||
|
|
||||
if (authResponse.statusCode == 400) { |
|
||||
print('\nWebSocket握手错误,可能的原因:'); |
|
||||
print('1. 集群区域设置不正确 (当前: $effectiveCluster)'); |
|
||||
print('2. 请求参数格式不正确'); |
|
||||
print('3. 尝试使用不同的集群区域,如 cn-shanghai 或 cn-guangzhou'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('❌ 请求异常: $e'); |
|
||||
} |
|
||||
|
|
||||
print('\n======== 检查完成 ========'); |
|
||||
print('\n提示: 如果您使用的是大模型流式识别SDK,请确保:'); |
|
||||
print('1. 使用了正确的API路径: /api/v3/sauc/bigmodel'); |
|
||||
print('2. 不要在Token前添加Bearer前缀'); |
|
||||
print('3. 设置了正确的资源ID'); |
|
||||
print('4. 设置了协议类型为PROTOCOL_TYPE_SEED'); |
|
||||
print('5. 设置了正确的集群区域 (当前: $effectiveCluster)'); |
|
||||
|
|
||||
// 尝试其他集群区域 |
|
||||
if (authResponse != null && authResponse.statusCode == 400) { |
|
||||
print('\n尝试其他集群区域:'); |
|
||||
final alternativeClusters = [ |
|
||||
'cn-shanghai', |
|
||||
'cn-guangzhou', |
|
||||
'cn-hongkong', |
|
||||
'ap-singapore', |
|
||||
'us-east-1', |
|
||||
'us-west-1' |
|
||||
]; |
|
||||
|
|
||||
bool foundWorkingCluster = false; |
|
||||
|
|
||||
for (final altCluster in alternativeClusters) { |
|
||||
if (altCluster != effectiveCluster) { |
|
||||
print('\n尝试集群区域: $altCluster'); |
|
||||
final success = await _testCluster(appId, appKey, altCluster); |
|
||||
if (success) { |
|
||||
foundWorkingCluster = true; |
|
||||
print('\n✅ 找到可用的集群区域: $altCluster'); |
|
||||
print('建议在 .env 文件中设置 VOLCANO_CLUSTER=$altCluster'); |
|
||||
|
|
||||
// 尝试更新.env文件 |
|
||||
try { |
|
||||
await _updateEnvFile(altCluster); |
|
||||
} catch (e) { |
|
||||
print('无法自动更新.env文件: $e'); |
|
||||
} |
|
||||
|
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (!foundWorkingCluster) { |
|
||||
print('\n❌ 所有集群区域测试均失败'); |
|
||||
print('请联系火山引擎技术支持获取正确的集群区域'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 测试不同的集群区域 |
|
||||
Future<bool> _testCluster(String appId, String appKey, String cluster) async { |
|
||||
try { |
|
||||
final url = 'https://openspeech.bytedance.com/api/v3/sauc/bigmodel'; |
|
||||
|
|
||||
final headers = { |
|
||||
'Content-Type': 'application/json', |
|
||||
'Authorization': appKey, |
|
||||
}; |
|
||||
|
|
||||
final body = jsonEncode({ |
|
||||
'app_id': appId, |
|
||||
'cluster': cluster, |
|
||||
'resource_id': appId, |
|
||||
'ping': true, |
|
||||
}); |
|
||||
|
|
||||
final response = await http.post( |
|
||||
Uri.parse(url), |
|
||||
headers: headers, |
|
||||
body: body, |
|
||||
).timeout(const Duration(seconds: 5)); |
|
||||
|
|
||||
if (response.statusCode == 200) { |
|
||||
print('✅ 集群区域 $cluster 可用,认证有效'); |
|
||||
return true; |
|
||||
} else { |
|
||||
print('❌ 集群区域 $cluster 不可用,状态码: ${response.statusCode}'); |
|
||||
return false; |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('❌ 测试集群区域 $cluster 时出错: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 尝试更新.env文件 |
|
||||
Future<void> _updateEnvFile(String newCluster) async { |
|
||||
try { |
|
||||
final file = File('.env'); |
|
||||
if (!await file.exists()) { |
|
||||
print('❌ .env文件不存在,无法自动更新'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
String content = await file.readAsString(); |
|
||||
|
|
||||
// 检查是否已有VOLCANO_CLUSTER |
|
||||
final clusterRegex = RegExp(r'VOLCANO_CLUSTER=.*'); |
|
||||
if (clusterRegex.hasMatch(content)) { |
|
||||
// 替换现有的VOLCANO_CLUSTER |
|
||||
content = content.replaceAll(clusterRegex, 'VOLCANO_CLUSTER=$newCluster'); |
|
||||
} else { |
|
||||
// 添加新的VOLCANO_CLUSTER |
|
||||
content += '\nVOLCANO_CLUSTER=$newCluster'; |
|
||||
} |
|
||||
|
|
||||
// 写入文件 |
|
||||
await file.writeAsString(content); |
|
||||
print('✅ 已自动更新.env文件中的VOLCANO_CLUSTER=$newCluster'); |
|
||||
} catch (e) { |
|
||||
print('❌ 更新.env文件失败: $e'); |
|
||||
throw e; |
|
||||
} |
|
||||
} |
|
||||
Loading…
Reference in new issue