52 changed files with 3087 additions and 8221 deletions
@ -0,0 +1,105 @@ |
|||||
|
import 'dart:async'; |
||||
|
|
||||
|
/// 语音识别服务接口 |
||||
|
abstract class AsrService { |
||||
|
/// 支持的语言 |
||||
|
List<String> get supportedLanguages; |
||||
|
|
||||
|
/// 初始化语音识别服务 |
||||
|
Future<bool> initialize({ |
||||
|
List<String>? supportedLanguages, |
||||
|
}); |
||||
|
|
||||
|
/// 执行一次性语音识别 |
||||
|
/// |
||||
|
/// 返回识别的最终结果 |
||||
|
Future<RecognitionEvent> recognizeOnce(); |
||||
|
|
||||
|
/// 开始连续语音识别 |
||||
|
/// |
||||
|
/// 返回一个包含识别事件的流 |
||||
|
Future<Stream<RecognitionEvent>> startContinuousRecognition(); |
||||
|
|
||||
|
/// 停止连续语音识别 |
||||
|
Future<bool> stopContinuousRecognition(); |
||||
|
|
||||
|
/// 检查连续识别是否活跃 |
||||
|
bool isContinuousRecognitionActive(); |
||||
|
|
||||
|
/// 释放资源 |
||||
|
Future<void> dispose(); |
||||
|
} |
||||
|
|
||||
|
/// 识别事件类型 |
||||
|
enum RecognitionEventType { |
||||
|
/// 最终识别结果 |
||||
|
finalResult, |
||||
|
|
||||
|
/// 中间识别结果(实时反馈) |
||||
|
intermediateResult, |
||||
|
|
||||
|
/// 会话开始 |
||||
|
sessionStarted, |
||||
|
|
||||
|
/// 会话结束 |
||||
|
sessionStopped, |
||||
|
|
||||
|
/// 识别取消 |
||||
|
canceled, |
||||
|
|
||||
|
/// 识别错误 |
||||
|
error, |
||||
|
} |
||||
|
|
||||
|
/// 识别事件 |
||||
|
class RecognitionEvent { |
||||
|
/// 事件类型 |
||||
|
final RecognitionEventType type; |
||||
|
|
||||
|
/// 识别文本(仅在 finalResult 和 intermediateResult 类型中有效) |
||||
|
final String text; |
||||
|
|
||||
|
/// 检测到的语言 |
||||
|
final String detectedLanguage; |
||||
|
|
||||
|
/// 错误信息(仅在 error 和 canceled 类型中有效) |
||||
|
final String error; |
||||
|
|
||||
|
RecognitionEvent({ |
||||
|
required this.type, |
||||
|
this.text = '', |
||||
|
this.detectedLanguage = '', |
||||
|
this.error = '', |
||||
|
}); |
||||
|
|
||||
|
/// 创建最终结果事件的快捷构造函数 |
||||
|
factory RecognitionEvent.finalResult({ |
||||
|
required String text, |
||||
|
String detectedLanguage = '', |
||||
|
}) { |
||||
|
return RecognitionEvent( |
||||
|
type: RecognitionEventType.finalResult, |
||||
|
text: text, |
||||
|
detectedLanguage: detectedLanguage, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 创建错误事件的快捷构造函数 |
||||
|
factory RecognitionEvent.error(String errorMessage) { |
||||
|
return RecognitionEvent( |
||||
|
type: RecognitionEventType.error, |
||||
|
error: errorMessage, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 检查是否为最终结果 |
||||
|
bool get isFinalResult => type == RecognitionEventType.finalResult; |
||||
|
|
||||
|
/// 检查是否为错误 |
||||
|
bool get isError => type == RecognitionEventType.error || type == RecognitionEventType.canceled; |
||||
|
|
||||
|
@override |
||||
|
String toString() { |
||||
|
return 'RecognitionEvent{type: $type, text: $text, detectedLanguage: $detectedLanguage, error: $error}'; |
||||
|
} |
||||
|
} |
||||
@ -1,110 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
|
|
||||
/// 音频处理服务 |
|
||||
/// |
|
||||
/// 提供回音消除、降噪和语音活动检测等功能 |
|
||||
class AudioProcessingService extends GetxService { |
|
||||
static const MethodChannel _channel = MethodChannel('com.deep_voice.audio_processing'); |
|
||||
|
|
||||
// 状态变量 |
|
||||
final _isEchoCancellationEnabled = false.obs; |
|
||||
final _isNoiseSuppressionEnabled = false.obs; |
|
||||
final _isVoiceActivityDetectionEnabled = false.obs; |
|
||||
|
|
||||
// 获取可观察状态 |
|
||||
RxBool get isEchoCancellationEnabled => _isEchoCancellationEnabled; |
|
||||
RxBool get isNoiseSuppressionEnabled => _isNoiseSuppressionEnabled; |
|
||||
RxBool get isVoiceActivityDetectionEnabled => _isVoiceActivityDetectionEnabled; |
|
||||
|
|
||||
AudioProcessingService() { |
|
||||
_initPlatformState(); |
|
||||
} |
|
||||
|
|
||||
/// 初始化平台状态 |
|
||||
Future<void> _initPlatformState() async { |
|
||||
try { |
|
||||
final bool isEchoCancellationAvailable = await _channel.invokeMethod('isEchoCancellationAvailable') ?? false; |
|
||||
final bool isNoiseSuppressionAvailable = await _channel.invokeMethod('isNoiseSuppressionAvailable') ?? false; |
|
||||
|
|
||||
Logger.info('回音消除可用: $isEchoCancellationAvailable'); |
|
||||
Logger.info('噪声抑制可用: $isNoiseSuppressionAvailable'); |
|
||||
} catch (e) { |
|
||||
Logger.error('初始化音频处理服务失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 为指定的音频会话ID启用回音消除 |
|
||||
Future<bool> enableEchoCancellation(int audioSessionId) async { |
|
||||
try { |
|
||||
final bool result = await _channel.invokeMethod('enableEchoCancellation', { |
|
||||
'audioSessionId': audioSessionId, |
|
||||
}) ?? false; |
|
||||
|
|
||||
_isEchoCancellationEnabled.value = result; |
|
||||
Logger.info('启用回音消除${result ? "成功" : "失败"}'); |
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('启用回音消除失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 禁用回音消除 |
|
||||
Future<bool> disableEchoCancellation() async { |
|
||||
try { |
|
||||
final bool result = await _channel.invokeMethod('disableEchoCancellation') ?? false; |
|
||||
|
|
||||
_isEchoCancellationEnabled.value = !result; |
|
||||
Logger.info('禁用回音消除${result ? "成功" : "失败"}'); |
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('禁用回音消除失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 为指定的音频会话ID启用噪声抑制 |
|
||||
Future<bool> enableNoiseSuppression(int audioSessionId) async { |
|
||||
try { |
|
||||
final bool result = await _channel.invokeMethod('enableNoiseSuppression', { |
|
||||
'audioSessionId': audioSessionId, |
|
||||
}) ?? false; |
|
||||
|
|
||||
_isNoiseSuppressionEnabled.value = result; |
|
||||
Logger.info('启用噪声抑制${result ? "成功" : "失败"}'); |
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('启用噪声抑制失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 禁用噪声抑制 |
|
||||
Future<bool> disableNoiseSuppression() async { |
|
||||
try { |
|
||||
final bool result = await _channel.invokeMethod('disableNoiseSuppression') ?? false; |
|
||||
|
|
||||
_isNoiseSuppressionEnabled.value = !result; |
|
||||
Logger.info('禁用噪声抑制${result ? "成功" : "失败"}'); |
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('禁用噪声抑制失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 获取当前录音的音频会话ID |
|
||||
Future<int?> getAudioSessionId() async { |
|
||||
try { |
|
||||
final int? sessionId = await _channel.invokeMethod('getAudioSessionId'); |
|
||||
Logger.info('获取音频会话ID: $sessionId'); |
|
||||
return sessionId; |
|
||||
} catch (e) { |
|
||||
Logger.error('获取音频会话ID失败: $e'); |
|
||||
return null; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,156 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:audio_service/audio_service.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:synchronized/synchronized.dart'; |
|
||||
import 'my_audio_handler.dart'; |
|
||||
|
|
||||
class AudioServiceManager extends GetxService { |
|
||||
static AudioServiceManager get to => Get.find(); |
|
||||
MyAudioHandler? _audioHandler; |
|
||||
bool _isInitialized = false; |
|
||||
final _lock = Lock(); |
|
||||
|
|
||||
// 添加一个标志,表示是否已经尝试过初始化 |
|
||||
bool _hasAttemptedInit = false; |
|
||||
|
|
||||
// 添加一个计时器,用于定期检查服务状态 |
|
||||
Timer? _serviceCheckTimer; |
|
||||
|
|
||||
Future<AudioServiceManager> init() async { |
|
||||
print('初始化 AudioServiceManager'); |
|
||||
|
|
||||
if (_isInitialized) { |
|
||||
print('AudioServiceManager 已经初始化'); |
|
||||
return this; |
|
||||
} |
|
||||
|
|
||||
// 如果已经尝试过初始化但失败了,不再重试 |
|
||||
if (_hasAttemptedInit) { |
|
||||
print('AudioServiceManager 之前初始化失败,尝试重新初始化'); |
|
||||
// 重置标志,允许重新初始化 |
|
||||
_hasAttemptedInit = false; |
|
||||
} |
|
||||
|
|
||||
_hasAttemptedInit = true; |
|
||||
|
|
||||
try { |
|
||||
// 使用锁确保初始化的原子性 |
|
||||
await _lock.synchronized(() async { |
|
||||
if (_isInitialized) return; |
|
||||
|
|
||||
// 使用 AudioService.init 初始化音频服务 |
|
||||
_audioHandler = await AudioService.init<MyAudioHandler>( |
|
||||
builder: () => MyAudioHandler(), |
|
||||
config: AudioServiceConfig( |
|
||||
androidNotificationChannelId: 'com.example.deep_voice.channel.audio', |
|
||||
androidNotificationChannelName: 'Deep Voice Audio Service', |
|
||||
// 使用应用图标作为通知图标,确保这个图标存在 |
|
||||
androidNotificationIcon: 'mipmap/ic_launcher', |
|
||||
// 修复配置冲突:当androidStopForegroundOnPause为true时,androidNotificationOngoing才能为true |
|
||||
androidNotificationOngoing: false, |
|
||||
// 设置为true,允许在暂停时停止前台服务,避免与androidNotificationOngoing冲突 |
|
||||
androidStopForegroundOnPause: true, |
|
||||
androidShowNotificationBadge: true, |
|
||||
notificationColor: Colors.blue, |
|
||||
fastForwardInterval: const Duration(seconds: 10), |
|
||||
rewindInterval: const Duration(seconds: 10), |
|
||||
preloadArtwork: false, |
|
||||
), |
|
||||
); |
|
||||
|
|
||||
// 注册自定义音频处理器 |
|
||||
await Get.put(_audioHandler!, permanent: true); |
|
||||
|
|
||||
_isInitialized = true; |
|
||||
print('AudioServiceManager 初始化成功'); |
|
||||
|
|
||||
// 启动定期检查服务状态的计时器 |
|
||||
_startServiceCheckTimer(); |
|
||||
}); |
|
||||
|
|
||||
return this; |
|
||||
} catch (e) { |
|
||||
print('AudioServiceManager初始化失败: $e'); |
|
||||
_isInitialized = false; // 确保失败时重置状态 |
|
||||
|
|
||||
// 延迟5秒后尝试重新初始化 |
|
||||
Future.delayed(Duration(seconds: 5), () { |
|
||||
print('尝试重新初始化 AudioServiceManager'); |
|
||||
_hasAttemptedInit = false; |
|
||||
init(); |
|
||||
}); |
|
||||
|
|
||||
return this; // 即使失败也返回实例,避免空指针异常 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 启动定期检查服务状态的计时器 |
|
||||
void _startServiceCheckTimer() { |
|
||||
_serviceCheckTimer?.cancel(); |
|
||||
_serviceCheckTimer = Timer.periodic(Duration(minutes: 5), (timer) { |
|
||||
_checkAndRestartServiceIfNeeded(); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 检查服务状态,如果需要则重启服务 |
|
||||
Future<void> _checkAndRestartServiceIfNeeded() async { |
|
||||
if (!_isInitialized || _audioHandler == null) { |
|
||||
print('服务检查:AudioServiceManager 未初始化,尝试重新初始化'); |
|
||||
_hasAttemptedInit = false; |
|
||||
await init(); |
|
||||
return; |
|
||||
} |
|
||||
print('服务检查:AudioServiceManager 已初始化,服务状态: $_isInitialized'); |
|
||||
} |
|
||||
|
|
||||
// 获取音频处理器 |
|
||||
MyAudioHandler? get audioHandler => _audioHandler; |
|
||||
|
|
||||
// 发送媒体按钮事件 |
|
||||
Future<void> sendMediaButtonEvent() async { |
|
||||
if (_audioHandler != null) { |
|
||||
try { |
|
||||
print('发送媒体按钮事件'); |
|
||||
await _audioHandler!.customAction('media_button'); |
|
||||
} catch (e) { |
|
||||
print('发送媒体按钮事件失败: $e'); |
|
||||
|
|
||||
// 如果发送失败,尝试重新初始化服务 |
|
||||
_isInitialized = false; |
|
||||
_hasAttemptedInit = false; |
|
||||
await init(); |
|
||||
} |
|
||||
} else { |
|
||||
print('AudioHandler 未初始化,无法发送媒体按钮事件'); |
|
||||
// 尝试重新初始化 |
|
||||
_isInitialized = false; |
|
||||
_hasAttemptedInit = false; |
|
||||
await init(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 手动触发媒体按钮事件(用于测试) |
|
||||
Future<void> triggerMediaButtonEvent() async { |
|
||||
print('手动触发媒体按钮事件'); |
|
||||
await sendMediaButtonEvent(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() async { |
|
||||
_isInitialized = false; |
|
||||
_serviceCheckTimer?.cancel(); |
|
||||
|
|
||||
// 停止现有的音频处理器 |
|
||||
if (_audioHandler != null) { |
|
||||
try { |
|
||||
await _audioHandler!.stop(); |
|
||||
} catch (e) { |
|
||||
print('停止 AudioHandler 失败: $e'); |
|
||||
} |
|
||||
_audioHandler = null; |
|
||||
} |
|
||||
|
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,793 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:typed_data'; |
|
||||
import 'dart:io'; |
|
||||
import 'dart:math' as math; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:just_audio/just_audio.dart'; |
|
||||
import 'package:audio_session/audio_session.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
import 'package:flutter_azure_tts/flutter_azure_tts.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
|
|
||||
/// Azure语音服务异常 |
|
||||
class AzureTtsException implements Exception { |
|
||||
final String message; |
|
||||
AzureTtsException(this.message); |
|
||||
|
|
||||
@override |
|
||||
String toString() => message; |
|
||||
} |
|
||||
|
|
||||
/// 自定义音频源,用于从字节数组读取音频数据 |
|
||||
class BytesAudioSource extends StreamAudioSource { |
|
||||
final Uint8List _bytes; |
|
||||
static const int _bufferSize = 4096; // 4KB buffer size for better Android compatibility |
|
||||
|
|
||||
BytesAudioSource(this._bytes); |
|
||||
|
|
||||
@override |
|
||||
Future<StreamAudioResponse> request([int? start, int? end]) async { |
|
||||
start = start ?? 0; |
|
||||
end = end ?? _bytes.length; |
|
||||
|
|
||||
try { |
|
||||
final subData = _bytes.sublist(start, end); |
|
||||
|
|
||||
// 使用固定大小的块进行流式传输 |
|
||||
final chunks = <List<int>>[]; |
|
||||
var offset = 0; |
|
||||
while (offset < subData.length) { |
|
||||
final chunkSize = math.min(_bufferSize, subData.length - offset); |
|
||||
chunks.add(subData.sublist(offset, offset + chunkSize)); |
|
||||
offset += chunkSize; |
|
||||
} |
|
||||
|
|
||||
return StreamAudioResponse( |
|
||||
sourceLength: _bytes.length, |
|
||||
contentLength: subData.length, |
|
||||
offset: start, |
|
||||
stream: Stream.fromIterable(chunks), |
|
||||
contentType: 'audio/mpeg', |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error('BytesAudioSource请求错误: $e'); |
|
||||
rethrow; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<int> get length => Future.value(_bytes.length); |
|
||||
} |
|
||||
|
|
||||
/// Azure语音合成服务 |
|
||||
class AzureFlutterTtsService extends GetxService { |
|
||||
// 配置参数 |
|
||||
final String _subscriptionKey; |
|
||||
final String _region; |
|
||||
final String _voiceLocale; |
|
||||
|
|
||||
// 语音配置 |
|
||||
Voice? _selectedVoice; |
|
||||
final String _audioFormat = AudioOutputFormat.audio16khz32kBitrateMonoMp3; |
|
||||
|
|
||||
// 音频播放器 |
|
||||
late AudioPlayer _audioPlayer; |
|
||||
final ConcatenatingAudioSource _playlist = ConcatenatingAudioSource(children: []); |
|
||||
final isEnabled = true.obs; // 默认启用 |
|
||||
final isPlaying = false.obs; |
|
||||
StreamSubscription? _playerStateSubscription; |
|
||||
bool _playlistInitialized = false; |
|
||||
|
|
||||
bool _isDisposed = false; |
|
||||
|
|
||||
// 句子管理 |
|
||||
String _pendingText = ''; |
|
||||
final List<String> _sentenceQueue = []; |
|
||||
static final _sentenceBreaks = RegExp(r'[。!?.!?,,、]'); // 添加更多标点符号 |
|
||||
bool _isFetching = false; |
|
||||
static const int _minSegmentLength = 10; // 最小分段长度 |
|
||||
static const int _optimalSegmentLength = 30; // 最佳分段长度 |
|
||||
|
|
||||
// 音频数据控制器 - 用于事件通知 |
|
||||
final _eventController = StreamController<Map<String, dynamic>>.broadcast(); |
|
||||
Stream<Map<String, dynamic>> get eventStream => _eventController.stream; |
|
||||
|
|
||||
AzureFlutterTtsService() : |
|
||||
_subscriptionKey = dotenv.env['AZURE_TTS_SUBSCRIPTION_KEY'] ?? '', |
|
||||
_region = dotenv.env['AZURE_TTS_SERVICE_REGION'] ?? '', |
|
||||
_voiceLocale = dotenv.env['AZURE_TTS_DEFAULT_VOICE']?.split('-').take(2).join('-') ?? 'zh-CN' { |
|
||||
if (_subscriptionKey.isEmpty || _region.isEmpty) { |
|
||||
throw Exception('Azure语音配置信息不完整,请检查环境变量'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> onInit() async { |
|
||||
super.onInit(); |
|
||||
try { |
|
||||
// 初始化Azure TTS |
|
||||
await _initAzureTts(); |
|
||||
|
|
||||
// 初始化音频播放器 |
|
||||
await _initAudioPlayer(); |
|
||||
|
|
||||
// 配置音频设备 |
|
||||
await _configureAudioDevice(); |
|
||||
} catch (e, stackTrace) { |
|
||||
Logger.error('初始化Azure TTS服务失败: $e'); |
|
||||
Logger.error('Stack trace: $stackTrace'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _initAzureTts() async { |
|
||||
try { |
|
||||
// 初始化Azure TTS服务 |
|
||||
FlutterAzureTts.init( |
|
||||
subscriptionKey: _subscriptionKey, |
|
||||
region: _region, |
|
||||
withLogs: false, // 生产环境禁用日志 |
|
||||
); |
|
||||
|
|
||||
// 获取可用的语音列表 |
|
||||
// final voicesResponse = await FlutterAzureTts.getAvailableVoices(); |
|
||||
|
|
||||
// if (voicesResponse.voices.isEmpty) { |
|
||||
// throw AzureTtsException('未找到可用的Azure语音'); |
|
||||
// } |
|
||||
|
|
||||
// // 选择指定区域的语音 |
|
||||
// _selectedVoice = voicesResponse.voices |
|
||||
// .where((voice) => voice.locale.startsWith(_voiceLocale)) |
|
||||
// .toList(growable: false) |
|
||||
// .firstOrNull; |
|
||||
|
|
||||
// // 如果没有找到指定区域的语音,使用第一个可用的语音 |
|
||||
// _selectedVoice ??= voicesResponse.voices.first; |
|
||||
|
|
||||
Logger.info('已选择Azure语音: $_voiceLocale'); |
|
||||
} catch (e) { |
|
||||
Logger.error('初始化Azure TTS失败: $e'); |
|
||||
rethrow; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _initAudioPlayer() async { |
|
||||
if (_isDisposed) return; |
|
||||
|
|
||||
_audioPlayer = AudioPlayer(); |
|
||||
|
|
||||
// 配置Android特定的播放参数 |
|
||||
if (Platform.isAndroid) { |
|
||||
await _audioPlayer.setAndroidAudioAttributes(const AndroidAudioAttributes( |
|
||||
contentType: AndroidAudioContentType.speech, |
|
||||
usage: AndroidAudioUsage.media, |
|
||||
flags: AndroidAudioFlags.audibilityEnforced, |
|
||||
)); |
|
||||
} |
|
||||
|
|
||||
// 基本播放器配置 |
|
||||
await _audioPlayer.setVolume(1.0); |
|
||||
await _audioPlayer.setLoopMode(LoopMode.off); |
|
||||
await _audioPlayer.setSpeed(1.0); |
|
||||
|
|
||||
// 设置音频缓冲配置,启用预加载以减少延迟 |
|
||||
await _audioPlayer.setAudioSource( |
|
||||
_playlist, |
|
||||
initialPosition: Duration.zero, |
|
||||
preload: true, |
|
||||
); |
|
||||
|
|
||||
_playlistInitialized = true; |
|
||||
|
|
||||
_setupEventListeners(); |
|
||||
} |
|
||||
|
|
||||
Future<bool> _configureAudioDevice() async { |
|
||||
try { |
|
||||
// 使用AudioSession配置音频设备 |
|
||||
final session = await AudioSession.instance; |
|
||||
|
|
||||
if (Platform.isAndroid) { |
|
||||
// Android平台 - 明确指定只使用默认设备 |
|
||||
await session.configure(AudioSessionConfiguration( |
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playback, |
|
||||
androidAudioAttributes: const AndroidAudioAttributes( |
|
||||
contentType: AndroidAudioContentType.speech, |
|
||||
usage: AndroidAudioUsage.media, |
|
||||
// 移除audibilityEnforced标志,它可能导致多路输出 |
|
||||
flags: AndroidAudioFlags.none, |
|
||||
), |
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain, |
|
||||
androidWillPauseWhenDucked: true, |
|
||||
)); |
|
||||
|
|
||||
// 确保只使用默认音频路由 |
|
||||
if (Platform.isAndroid) { |
|
||||
final audioManager = AndroidAudioManager(); |
|
||||
await audioManager.setSpeakerphoneOn(false); |
|
||||
} |
|
||||
|
|
||||
} else if (Platform.isIOS) { |
|
||||
// iOS平台 - 明确指定只使用默认设备 |
|
||||
await session.configure(AudioSessionConfiguration( |
|
||||
avAudioSessionCategory: AVAudioSessionCategory.soloAmbient, // 使用soloAmbient而不是playback |
|
||||
avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.none, // 不使用任何额外选项 |
|
||||
)); |
|
||||
|
|
||||
// 确保使用默认路由 |
|
||||
if (Platform.isIOS) { |
|
||||
final avAudioSession = AVAudioSession(); |
|
||||
await avAudioSession.overrideOutputAudioPort(AVAudioSessionPortOverride.none); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 激活音频会话 |
|
||||
await session.setActive(true); |
|
||||
|
|
||||
return true; |
|
||||
} catch (e) { |
|
||||
Logger.error('配置音频设备失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> speak(String text) async { |
|
||||
if (!isEnabled.value || text.trim().isEmpty) return; |
|
||||
|
|
||||
try { |
|
||||
_pendingText += text; |
|
||||
_extractFullSentences(); |
|
||||
await _startPreloadIfNeeded(); |
|
||||
} catch (e, stack) { |
|
||||
Logger.error('语音合成失败: $e'); |
|
||||
await _reinitializePlayer(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void _extractFullSentences() { |
|
||||
if (_pendingText.isEmpty) return; |
|
||||
|
|
||||
// 查找所有句子分隔符 |
|
||||
final matches = _sentenceBreaks.allMatches(_pendingText).toList(); |
|
||||
|
|
||||
// 如果没有找到句子分隔符 |
|
||||
if (matches.isEmpty) { |
|
||||
// 如果累积的文本长度超过最佳长度,才进行处理 |
|
||||
if (_pendingText.length > _optimalSegmentLength) { |
|
||||
// 将整个文本添加到队列 |
|
||||
_sentenceQueue.add(_pendingText); |
|
||||
_pendingText = ''; |
|
||||
} |
|
||||
// 否则继续累积文本,等待更多输入 |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 找到了句子分隔符,处理完整的句子 |
|
||||
int lastPos = 0; |
|
||||
|
|
||||
for (final match in matches) { |
|
||||
final end = match.end; |
|
||||
// 提取完整的句子(包含分隔符) |
|
||||
final sentence = _pendingText.substring(lastPos, end).trim(); |
|
||||
|
|
||||
if (sentence.isNotEmpty) { |
|
||||
// 将完整的句子添加到队列 |
|
||||
_sentenceQueue.add(sentence); |
|
||||
} |
|
||||
|
|
||||
lastPos = end; |
|
||||
} |
|
||||
|
|
||||
// 处理剩余的不完整文本(不包含分隔符的部分) |
|
||||
if (lastPos < _pendingText.length) { |
|
||||
final remaining = _pendingText.substring(lastPos).trim(); |
|
||||
|
|
||||
// 如果剩余文本长度超过最佳长度,也将其添加到队列 |
|
||||
if (remaining.length > _optimalSegmentLength) { |
|
||||
_sentenceQueue.add(remaining); |
|
||||
_pendingText = ''; |
|
||||
} else { |
|
||||
// 否则保留在_pendingText中,等待更多输入 |
|
||||
_pendingText = remaining; |
|
||||
} |
|
||||
} else { |
|
||||
// 所有文本都已处理,清空_pendingText |
|
||||
_pendingText = ''; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 按长度分割文本的方法(保留以便其他地方调用) |
|
||||
void _splitByLength(String text) { |
|
||||
if (text.isEmpty) return; |
|
||||
|
|
||||
// 如果文本长度不超过最佳长度,直接添加整个文本 |
|
||||
if (text.length <= _optimalSegmentLength) { |
|
||||
_sentenceQueue.add(text); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
int start = 0; |
|
||||
while (start < text.length) { |
|
||||
int end = math.min(start + _optimalSegmentLength, text.length); |
|
||||
|
|
||||
// 如果剩余文本长度小于最小字符数,直接取到末尾 |
|
||||
if (text.length - start <= _minSegmentLength) { |
|
||||
end = text.length; |
|
||||
} |
|
||||
// 否则尝试在标点或空格处分割 |
|
||||
else if (end < text.length) { |
|
||||
final spacePos = text.lastIndexOf(' ', end); |
|
||||
final breakPos = _sentenceBreaks.firstMatch( |
|
||||
text.substring(start, end) |
|
||||
)?.end; |
|
||||
|
|
||||
if (breakPos != null && breakPos > start) { |
|
||||
end = start + breakPos; |
|
||||
} else if (spacePos > start) { |
|
||||
end = spacePos; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
final segment = text.substring(start, end).trim(); |
|
||||
if (segment.isNotEmpty) { |
|
||||
_sentenceQueue.add(segment); |
|
||||
} |
|
||||
start = end; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _startPreloadIfNeeded() async { |
|
||||
if (_isFetching || _sentenceQueue.isEmpty) return; |
|
||||
_isFetching = true; |
|
||||
await _fetchNextSegment(); |
|
||||
} |
|
||||
|
|
||||
Future<void> _fetchNextSegment() async { |
|
||||
if (_sentenceQueue.isEmpty) { |
|
||||
_isFetching = false; |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 预加载多个片段以实现流畅播放 |
|
||||
final segments = <String>[]; |
|
||||
while (_sentenceQueue.isNotEmpty && segments.length < 2) { // 减少预加载数量以降低内存压力 |
|
||||
segments.add(_sentenceQueue.removeAt(0)); |
|
||||
} |
|
||||
|
|
||||
for (final segment in segments) { |
|
||||
final audioData = await _synthesize(segment); |
|
||||
|
|
||||
if (audioData != null) { |
|
||||
try { |
|
||||
// 添加到播放列表 |
|
||||
await _addToPlaylist(audioData); |
|
||||
} catch (e) { |
|
||||
Logger.error('添加音频源失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 如果队列还有内容,继续获取下一批 |
|
||||
if (_sentenceQueue.isNotEmpty) { |
|
||||
await Future.delayed(const Duration(milliseconds: 100)); // 添加延迟以避免过快请求 |
|
||||
await _fetchNextSegment(); |
|
||||
} else { |
|
||||
_isFetching = false; |
|
||||
// 检查是否还有未处理的文本 |
|
||||
_extractFullSentences(); |
|
||||
if (_sentenceQueue.isNotEmpty) { |
|
||||
await _startPreloadIfNeeded(); |
|
||||
} |
|
||||
} |
|
||||
} catch (e, stackTrace) { |
|
||||
Logger.error('获取音频失败: $e'); |
|
||||
Logger.error('Stack trace: $stackTrace'); |
|
||||
_isFetching = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<Uint8List?> _synthesize(String text) async { |
|
||||
try { |
|
||||
if (_selectedVoice == null) { |
|
||||
// 如果未选择语音,使用默认语音 |
|
||||
Logger.info('未选择语音,使用默认语音'); |
|
||||
await setVoice('zh-CN-XiaoxiaoNeural'); // 使用默认语音 |
|
||||
|
|
||||
// 如果设置后仍然为null,则无法继续 |
|
||||
if (_selectedVoice == null) { |
|
||||
throw AzureTtsException('无法设置默认语音'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 创建TTS参数 |
|
||||
final params = TtsParams( |
|
||||
voice: _selectedVoice!, |
|
||||
audioFormat: _audioFormat, |
|
||||
text: text, |
|
||||
rate: 1.0, // 语速,可以根据需要调整 |
|
||||
); |
|
||||
|
|
||||
// 请求语音合成 |
|
||||
final ttsResponse = await FlutterAzureTts.getTts(params); |
|
||||
|
|
||||
// 获取音频数据 |
|
||||
final audioBytes = ttsResponse.audio.buffer.asUint8List(); |
|
||||
return audioBytes; |
|
||||
} catch (e) { |
|
||||
Logger.error('Azure语音合成请求失败: $e'); |
|
||||
return null; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 将音频数据添加到播放列表 |
|
||||
Future<void> _addToPlaylist(Uint8List audioData) async { |
|
||||
try { |
|
||||
if (audioData.isEmpty) return; |
|
||||
|
|
||||
// 确保播放列表已初始化 |
|
||||
if (!_playlistInitialized) { |
|
||||
await _initAudioPlayer(); |
|
||||
} |
|
||||
|
|
||||
// 创建音频源 |
|
||||
final audioSource = BytesAudioSource(audioData); |
|
||||
|
|
||||
// 添加到播放列表 |
|
||||
await _playlist.add(audioSource); |
|
||||
|
|
||||
// 如果当前没有播放,开始播放 |
|
||||
if (!isPlaying.value) { |
|
||||
await _startPlayback(); |
|
||||
} else if (_audioPlayer.processingState == ProcessingState.completed) { |
|
||||
// 如果播放已完成但状态还没更新,手动前进到下一个 |
|
||||
await _advanceToNextItem(); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('添加音频到播放列表失败: $e'); |
|
||||
// 通知错误 |
|
||||
_notifyEvent('playbackError', '添加音频到播放列表失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 开始播放收集到的音频数据 |
|
||||
Future<void> _startPlayback() async { |
|
||||
try { |
|
||||
if (!_playlistInitialized || _playlist.length == 0) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 检查当前播放状态 |
|
||||
final processingState = _audioPlayer.processingState; |
|
||||
|
|
||||
// 如果已经播放完成,需要重新设置位置到第一个未播放的项目 |
|
||||
if (processingState == ProcessingState.completed) { |
|
||||
// 找到第一个未播放的项目 |
|
||||
int nextIndex = 0; |
|
||||
if (_audioPlayer.currentIndex != null) { |
|
||||
nextIndex = math.min(_audioPlayer.currentIndex! + 1, _playlist.length - 1); |
|
||||
} |
|
||||
|
|
||||
// 跳转到下一个项目 |
|
||||
await _audioPlayer.seek(Duration.zero, index: nextIndex); |
|
||||
} |
|
||||
|
|
||||
// 开始播放 |
|
||||
await _audioPlayer.play(); |
|
||||
|
|
||||
// 手动更新播放状态,避免等待事件流更新可能导致的延迟 |
|
||||
if (_playlist.length > 0 && !isPlaying.value) { |
|
||||
isPlaying.value = true; |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('开始播放失败: $e'); |
|
||||
// 通知错误 |
|
||||
_notifyEvent('playbackError', '开始播放失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 前进到播放列表中的下一个项目 |
|
||||
Future<void> _advanceToNextItem() async { |
|
||||
try { |
|
||||
if (_playlist.length == 0) { |
|
||||
// 如果播放列表为空,确保 isPlaying 为 false |
|
||||
if (isPlaying.value) { |
|
||||
isPlaying.value = false; |
|
||||
} |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 获取当前索引和序列状态 |
|
||||
final currentIndex = _audioPlayer.currentIndex; |
|
||||
final sequenceState = _audioPlayer.sequenceState; |
|
||||
|
|
||||
if (sequenceState == null) { |
|
||||
// 如果没有序列状态,直接开始播放 |
|
||||
await _audioPlayer.seek(Duration.zero, index: 0); |
|
||||
await _audioPlayer.play(); |
|
||||
|
|
||||
// 更新播放状态 |
|
||||
if (!isPlaying.value) { |
|
||||
isPlaying.value = true; |
|
||||
} |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 如果当前索引无效,从头开始播放 |
|
||||
if (currentIndex == null || currentIndex < 0) { |
|
||||
await _audioPlayer.seek(Duration.zero, index: 0); |
|
||||
await _audioPlayer.play(); |
|
||||
|
|
||||
// 更新播放状态 |
|
||||
if (!isPlaying.value) { |
|
||||
isPlaying.value = true; |
|
||||
} |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 计算下一个索引 |
|
||||
final nextIndex = currentIndex + 1; |
|
||||
|
|
||||
// 检查是否还有下一个项目 |
|
||||
if (nextIndex < sequenceState.sequence.length) { |
|
||||
// 跳转到下一个项目并开始播放 |
|
||||
await _audioPlayer.seek(Duration.zero, index: nextIndex); |
|
||||
await _audioPlayer.play(); |
|
||||
|
|
||||
// 更新播放状态 |
|
||||
if (!isPlaying.value) { |
|
||||
isPlaying.value = true; |
|
||||
} |
|
||||
} else { |
|
||||
// 已经是最后一个项目 |
|
||||
// 确保最后一个项目播放完成 |
|
||||
if (_audioPlayer.position < _audioPlayer.duration!) { |
|
||||
await _audioPlayer.seek(_audioPlayer.duration!); |
|
||||
} |
|
||||
|
|
||||
// 如果已经是最后一个项目且已播放完成,更新播放状态为 false |
|
||||
if (isPlaying.value) { |
|
||||
isPlaying.value = false; |
|
||||
} |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('前进到下一个音频项目失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> stop() async { |
|
||||
try { |
|
||||
// 立即更新播放状态,避免等待事件流更新可能导致的延迟 |
|
||||
if (isPlaying.value) { |
|
||||
isPlaying.value = false; |
|
||||
} |
|
||||
|
|
||||
await _audioPlayer.stop(); |
|
||||
await _playlist.clear(); |
|
||||
_pendingText = ''; |
|
||||
_sentenceQueue.clear(); |
|
||||
_isFetching = false; |
|
||||
|
|
||||
// 确保清空所有待处理的音频数据 |
|
||||
try { |
|
||||
// 重置播放器状态 |
|
||||
await _audioPlayer.pause(); |
|
||||
await _audioPlayer.seek(Duration.zero); |
|
||||
} catch (e) { |
|
||||
Logger.error('清空音频缓存时出错: $e'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('停止播放失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 添加一个方法来检查音频播放器是否真正在播放 |
|
||||
bool isActuallyPlaying() { |
|
||||
try { |
|
||||
// 检查播放器状态 |
|
||||
final isPlayerPlaying = _audioPlayer.playing; |
|
||||
|
|
||||
// 检查播放列表是否为空 |
|
||||
final hasAudioSource = _playlist.length > 0; |
|
||||
|
|
||||
// 只有当播放器正在播放且播放列表不为空时,才认为真正在播放 |
|
||||
final actuallyPlaying = isPlayerPlaying && hasAudioSource; |
|
||||
|
|
||||
// 如果状态不一致,记录日志 |
|
||||
if (isPlaying.value != actuallyPlaying) { |
|
||||
Logger.info('音频播放状态不一致: isPlaying.value=${isPlaying.value}, 实际播放状态=$actuallyPlaying (播放器状态=$isPlayerPlaying, 播放列表长度=${_playlist.length})'); |
|
||||
} |
|
||||
|
|
||||
return actuallyPlaying; |
|
||||
} catch (e) { |
|
||||
Logger.error('检查实际播放状态失败: $e'); |
|
||||
// 如果检查失败,返回当前的 isPlaying 值 |
|
||||
return isPlaying.value; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _reinitializePlayer() async { |
|
||||
try { |
|
||||
await stop(); |
|
||||
await _audioPlayer.dispose(); |
|
||||
|
|
||||
// 重新初始化播放器 |
|
||||
_audioPlayer = AudioPlayer(); |
|
||||
await _audioPlayer.setVolume(1.0); |
|
||||
await _audioPlayer.setLoopMode(LoopMode.off); |
|
||||
|
|
||||
// 重新配置Android特定的播放参数 |
|
||||
if (Platform.isAndroid) { |
|
||||
await _audioPlayer.setAndroidAudioAttributes(const AndroidAudioAttributes( |
|
||||
contentType: AndroidAudioContentType.speech, |
|
||||
usage: AndroidAudioUsage.media, |
|
||||
flags: AndroidAudioFlags.audibilityEnforced, |
|
||||
)); |
|
||||
} |
|
||||
|
|
||||
await _audioPlayer.setAudioSource( |
|
||||
_playlist, |
|
||||
initialPosition: Duration.zero, |
|
||||
preload: true, |
|
||||
); |
|
||||
|
|
||||
_playlistInitialized = true; |
|
||||
_setupEventListeners(); |
|
||||
Logger.info('播放器重新初始化成功'); |
|
||||
} catch (e) { |
|
||||
Logger.error('播放器重新初始化失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void _setupEventListeners() { |
|
||||
_playerStateSubscription?.cancel(); |
|
||||
|
|
||||
_playerStateSubscription = _audioPlayer.playerStateStream.listen( |
|
||||
(state) { |
|
||||
if (_isDisposed) return; |
|
||||
|
|
||||
// 更新播放状态 - 使用更准确的判断逻辑 |
|
||||
final isPlayerPlaying = state.playing; |
|
||||
final hasAudioSource = _playlist.length > 0; |
|
||||
final actuallyPlaying = isPlayerPlaying && hasAudioSource; |
|
||||
|
|
||||
// 只有当实际状态与当前状态不同时才更新 |
|
||||
if (isPlaying.value != actuallyPlaying) { |
|
||||
isPlaying.value = actuallyPlaying; |
|
||||
} |
|
||||
|
|
||||
// 如果播放完成,尝试播放下一个 |
|
||||
if (state.processingState == ProcessingState.completed) { |
|
||||
_advanceToNextItem(); |
|
||||
} |
|
||||
}, |
|
||||
onError: (error) { |
|
||||
if (_isDisposed) return; |
|
||||
Logger.error('播放器错误: $error'); |
|
||||
_reinitializePlayer(); |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
void toggleEnabled() { |
|
||||
isEnabled.toggle(); |
|
||||
if (!isEnabled.value) { |
|
||||
stop(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _cleanupResources() async { |
|
||||
_isDisposed = true; |
|
||||
await _playerStateSubscription?.cancel(); |
|
||||
await stop(); |
|
||||
await _audioPlayer.dispose(); |
|
||||
|
|
||||
// 关闭事件控制器 |
|
||||
if (!_eventController.isClosed) { |
|
||||
await _eventController.close(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() async { |
|
||||
Logger.info('关闭Azure TTS服务'); |
|
||||
await _cleanupResources(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
/// 使用系统默认音频设备 |
|
||||
Future<bool> useDefaultAudioDevice() async { |
|
||||
return await _configureAudioDevice(); |
|
||||
} |
|
||||
|
|
||||
/// 更新语音 |
|
||||
void updateVoice(Voice voice) { |
|
||||
_selectedVoice = voice; |
|
||||
Logger.info('已更新Azure语音: ${voice.shortName}'); |
|
||||
} |
|
||||
|
|
||||
/// 根据语音名称设置语音 |
|
||||
Future<void> setVoice(String voiceName) async { |
|
||||
Logger.info('正在设置Azure语音: $voiceName'); |
|
||||
|
|
||||
try { |
|
||||
// 获取可用的语音列表 |
|
||||
final voicesResponse = await FlutterAzureTts.getAvailableVoices(); |
|
||||
|
|
||||
if (voicesResponse.voices.isEmpty) { |
|
||||
Logger.error('未找到可用的Azure语音'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 1. 首先尝试精确匹配 |
|
||||
Voice? matchedVoice; |
|
||||
for (final voice in voicesResponse.voices) { |
|
||||
if (voice.shortName == voiceName) { |
|
||||
matchedVoice = voice; |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 2. 如果没有精确匹配,尝试部分匹配 |
|
||||
if (matchedVoice == null) { |
|
||||
for (final voice in voicesResponse.voices) { |
|
||||
if (voice.shortName.contains(voiceName) || voiceName.contains(voice.shortName)) { |
|
||||
matchedVoice = voice; |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 3. 如果仍然没有匹配,尝试匹配语言区域 |
|
||||
if (matchedVoice == null) { |
|
||||
final localePrefix = voiceName.split('-').take(2).join('-'); |
|
||||
for (final voice in voicesResponse.voices) { |
|
||||
if (voice.locale.startsWith(localePrefix)) { |
|
||||
matchedVoice = voice; |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 4. 如果所有尝试都失败,使用第一个可用的语音 |
|
||||
if (matchedVoice == null && voicesResponse.voices.isNotEmpty) { |
|
||||
matchedVoice = voicesResponse.voices.first; |
|
||||
} |
|
||||
|
|
||||
// 设置选中的语音 |
|
||||
if (matchedVoice != null) { |
|
||||
_selectedVoice = matchedVoice; |
|
||||
Logger.info('已设置Azure语音: ${matchedVoice.shortName}'); |
|
||||
} else { |
|
||||
Logger.error('未能找到合适的语音'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('设置Azure语音失败: $e'); |
|
||||
// 如果获取语音列表失败,创建一个简化的Voice对象 |
|
||||
_selectedVoice = Voice( |
|
||||
shortName: voiceName, |
|
||||
displayName: voiceName, |
|
||||
locale: _voiceLocale, |
|
||||
name: voiceName, |
|
||||
localName: voiceName, |
|
||||
gender: 'Female', |
|
||||
voiceType: 'Neural', |
|
||||
status: 'GA', |
|
||||
sampleRateHertz: '24000', |
|
||||
styles: [], |
|
||||
roles: [], |
|
||||
); |
|
||||
Logger.info('使用简化的Voice对象: $voiceName'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 通知事件 |
|
||||
void _notifyEvent(String eventType, String message) { |
|
||||
if (!_eventController.isClosed) { |
|
||||
_eventController.add({ |
|
||||
'eventType': eventType, |
|
||||
'message': message, |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,491 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
|
|
||||
/// 微软 Text-to-Speech 服务异常 |
|
||||
class AzureTtsException implements Exception { |
|
||||
final String message; |
|
||||
AzureTtsException(this.message); |
|
||||
|
|
||||
@override |
|
||||
String toString() => message; |
|
||||
} |
|
||||
|
|
||||
/// 音频输出设备类型 |
|
||||
enum AudioOutputType { |
|
||||
/// 扬声器 |
|
||||
speaker, |
|
||||
|
|
||||
/// 听筒 |
|
||||
earpiece, |
|
||||
|
|
||||
/// 自动选择(有耳机用耳机,没有则用听筒) |
|
||||
auto |
|
||||
} |
|
||||
|
|
||||
/// 微软 Text-to-Speech 服务 |
|
||||
/// |
|
||||
/// 该服务通过平台通道与 Android 上的 Microsoft Speech SDK 交互, |
|
||||
/// 提供文本转语音功能。 |
|
||||
class AzureTtsService extends GetxService { |
|
||||
static final AzureTtsService to = Get.put(AzureTtsService()); |
|
||||
static const MethodChannel _channel = MethodChannel('com.deep_voice.azure_tts'); |
|
||||
|
|
||||
bool _isInitialized = false; |
|
||||
late final String _subscriptionKey; |
|
||||
late final String _serviceRegion; |
|
||||
|
|
||||
// 当前设置 |
|
||||
String _currentVoice = 'zh-CN-XiaoxiaoNeural'; |
|
||||
int _currentRate = 0; |
|
||||
int _currentPitch = 0; |
|
||||
int _currentVolume = 100; |
|
||||
AudioOutputType _currentAudioOutputType = AudioOutputType.auto; |
|
||||
|
|
||||
// 语音合成队列 |
|
||||
final List<_SpeechItem> _textQueue = []; |
|
||||
bool _isProcessingQueue = false; |
|
||||
bool _isSpeaking = false; |
|
||||
|
|
||||
// 流式输出缓冲区 |
|
||||
String _streamBuffer = ''; |
|
||||
|
|
||||
// 可观察状态 |
|
||||
final isEnabled = true.obs; |
|
||||
final isSpeaking = false.obs; |
|
||||
final audioOutputType = AudioOutputType.auto.obs; |
|
||||
|
|
||||
String get currentVoice => _currentVoice; |
|
||||
AudioOutputType get currentAudioOutputType => _currentAudioOutputType; |
|
||||
|
|
||||
AzureTtsService() { |
|
||||
_loadConfig(); |
|
||||
} |
|
||||
|
|
||||
/// 从环境变量加载配置 |
|
||||
void _loadConfig() { |
|
||||
_subscriptionKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
|
||||
_serviceRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; |
|
||||
} |
|
||||
|
|
||||
|
|
||||
/// 初始化TTS服务 |
|
||||
Future<bool> initialize({String language = 'zh-CN'}) async { |
|
||||
if (_isInitialized) return true; |
|
||||
|
|
||||
try { |
|
||||
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { |
|
||||
Logger.error('未找到Azure语音服务配置'); |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
final result = await _channel.invokeMethod('initialize', { |
|
||||
'subscriptionKey': _subscriptionKey, |
|
||||
'region': _serviceRegion, |
|
||||
'language': language, |
|
||||
}); |
|
||||
|
|
||||
_isInitialized = result; |
|
||||
|
|
||||
// 确保设置默认的音频输出设备类型 |
|
||||
if (result) { |
|
||||
// 不需要显式调用 setAudioOutputType,因为原生层已经设置了默认值 |
|
||||
// 只需要确保 Dart 层的状态与原生层一致 |
|
||||
_currentAudioOutputType = AudioOutputType.auto; |
|
||||
audioOutputType.value = AudioOutputType.auto; |
|
||||
} |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('初始化失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语音 |
|
||||
Future<bool> setVoice(String voiceName) async { |
|
||||
if (!_isInitialized) await initialize(); |
|
||||
if (voiceName == _currentVoice) return true; |
|
||||
|
|
||||
try { |
|
||||
final result = await _channel.invokeMethod('setVoice', { |
|
||||
'voiceName': voiceName, |
|
||||
}); |
|
||||
|
|
||||
if (result) _currentVoice = voiceName; |
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('设置语音失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置音频输出设备类型 |
|
||||
Future<bool> setAudioOutputType(AudioOutputType outputType) async { |
|
||||
if (!_isInitialized) await initialize(); |
|
||||
|
|
||||
try { |
|
||||
final String outputTypeStr = outputType.toString().split('.').last; |
|
||||
final result = await _channel.invokeMethod('setAudioOutputType', { |
|
||||
'outputType': outputTypeStr, |
|
||||
}); |
|
||||
|
|
||||
if (result) { |
|
||||
_currentAudioOutputType = outputType; |
|
||||
audioOutputType.value = outputType; |
|
||||
} |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('设置音频输出设备失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语音参数 |
|
||||
Future<bool> setSpeechParams({int rate = 0, int pitch = 0, int volume = 100}) async { |
|
||||
if (!_isInitialized) await initialize(); |
|
||||
|
|
||||
try { |
|
||||
final result = await _channel.invokeMethod('setSpeechParams', { |
|
||||
'rate': rate, |
|
||||
'pitch': pitch, |
|
||||
'volume': volume, |
|
||||
}); |
|
||||
|
|
||||
if (result) { |
|
||||
_currentRate = rate; |
|
||||
_currentPitch = pitch; |
|
||||
_currentVolume = volume; |
|
||||
} |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('设置语音参数失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 播放文本 |
|
||||
Future<String> speakText(String text) async { |
|
||||
if (!_isInitialized) await initialize(); |
|
||||
if (!isEnabled.value) return "TTS服务已禁用"; |
|
||||
|
|
||||
try { |
|
||||
_isSpeaking = true; |
|
||||
isSpeaking.value = true; |
|
||||
|
|
||||
final result = await _channel.invokeMethod('speakText', {'text': text}); |
|
||||
|
|
||||
_isSpeaking = false; |
|
||||
isSpeaking.value = false; |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
_isSpeaking = false; |
|
||||
isSpeaking.value = false; |
|
||||
Logger.error('语音合成失败: $e'); |
|
||||
return "语音合成失败: $e"; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 播放SSML |
|
||||
Future<String> speakSsml(String ssml) async { |
|
||||
if (!_isInitialized) await initialize(); |
|
||||
if (!isEnabled.value) return "TTS服务已禁用"; |
|
||||
|
|
||||
try { |
|
||||
_isSpeaking = true; |
|
||||
isSpeaking.value = true; |
|
||||
|
|
||||
final result = await _channel.invokeMethod('speakSsml', {'ssml': ssml}); |
|
||||
|
|
||||
_isSpeaking = false; |
|
||||
isSpeaking.value = false; |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
_isSpeaking = false; |
|
||||
isSpeaking.value = false; |
|
||||
Logger.error('SSML语音合成失败: $e'); |
|
||||
return "SSML语音合成失败: $e"; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 添加文本到队列并播放 |
|
||||
Future<bool> speak(String text, {int? rate, int? pitch, int? volume, AudioOutputType? outputType}) async { |
|
||||
if (!isEnabled.value || text.isEmpty) return false; |
|
||||
|
|
||||
// 如果指定了新的音频输出设备类型,先设置 |
|
||||
if (outputType != null && outputType != _currentAudioOutputType) { |
|
||||
await setAudioOutputType(outputType); |
|
||||
} |
|
||||
|
|
||||
_textQueue.add(_SpeechItem( |
|
||||
text: text, |
|
||||
rate: rate ?? _currentRate, |
|
||||
pitch: pitch ?? _currentPitch, |
|
||||
volume: volume ?? _currentVolume, |
|
||||
)); |
|
||||
|
|
||||
if (!_isProcessingQueue) _processQueue(); |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
/// 处理AI流式输出文本 |
|
||||
/// |
|
||||
/// 将流式输出的文本片段添加到缓冲区,并在检测到完整句子时将其送入播放队列 |
|
||||
Future<bool> processStreamText(String textChunk, {AudioOutputType? outputType}) async { |
|
||||
if (!isEnabled.value || textChunk.isEmpty) return false; |
|
||||
|
|
||||
// 如果指定了新的音频输出设备类型,先设置 |
|
||||
if (outputType != null && outputType != _currentAudioOutputType) { |
|
||||
await setAudioOutputType(outputType); |
|
||||
} |
|
||||
|
|
||||
// 添加文本到缓冲区 |
|
||||
_streamBuffer += textChunk; |
|
||||
|
|
||||
// 如果缓冲区为空,直接返回 |
|
||||
if (_streamBuffer.isEmpty) return true; |
|
||||
|
|
||||
// 使用正则表达式匹配句子,包括结束符号 |
|
||||
// 匹配任意字符,直到遇到句子结束符号 |
|
||||
final sentenceRegex = RegExp( |
|
||||
r'([^。.!!??;;:\n\r]+[。.!!??;;::\n\r]|[^。.!!??;;:\n\r]+(?:\.{3,}|…)|[^。.!!??;;:\n\r]+["」』])' |
|
||||
); |
|
||||
|
|
||||
bool hasProcessed = false; |
|
||||
|
|
||||
// 查找所有完整句子 |
|
||||
final matches = sentenceRegex.allMatches(_streamBuffer); |
|
||||
final List<String> sentences = []; |
|
||||
int lastMatchEnd = 0; |
|
||||
|
|
||||
for (final match in matches) { |
|
||||
// 提取完整句子(包含结束符号) |
|
||||
final sentence = match.group(1)?.trim(); |
|
||||
if (sentence != null && sentence.isNotEmpty) { |
|
||||
sentences.add(sentence); |
|
||||
lastMatchEnd = match.end; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 处理找到的句子 |
|
||||
for (final sentence in sentences) { |
|
||||
_textQueue.add(_SpeechItem( |
|
||||
text: sentence, |
|
||||
rate: _currentRate, |
|
||||
pitch: _currentPitch, |
|
||||
volume: _currentVolume, |
|
||||
)); |
|
||||
hasProcessed = true; |
|
||||
} |
|
||||
|
|
||||
// 更新缓冲区,只保留未完成的部分 |
|
||||
if (lastMatchEnd > 0) { |
|
||||
_streamBuffer = _streamBuffer.substring(lastMatchEnd); |
|
||||
} |
|
||||
|
|
||||
// 如果处理了文本并且队列未在处理中,开始处理队列 |
|
||||
if (hasProcessed && !_isProcessingQueue) { |
|
||||
_processQueue(); |
|
||||
} |
|
||||
|
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
/// 强制处理缓冲区中的剩余文本 |
|
||||
/// |
|
||||
/// 在流式输出结束时调用,确保缓冲区中的所有文本都被处理 |
|
||||
Future<bool> flushStreamBuffer({AudioOutputType? outputType}) async { |
|
||||
if (!isEnabled.value || _streamBuffer.isEmpty) return false; |
|
||||
|
|
||||
// 如果指定了新的音频输出设备类型,先设置 |
|
||||
if (outputType != null && outputType != _currentAudioOutputType) { |
|
||||
await setAudioOutputType(outputType); |
|
||||
} |
|
||||
|
|
||||
// 先尝试使用processStreamText处理可能的完整句子 |
|
||||
await processStreamText(''); |
|
||||
|
|
||||
// 如果缓冲区仍有剩余文本,将其添加到播放队列 |
|
||||
if (_streamBuffer.isNotEmpty && _streamBuffer.trim().isNotEmpty) { |
|
||||
_textQueue.add(_SpeechItem( |
|
||||
text: _streamBuffer, |
|
||||
rate: _currentRate, |
|
||||
pitch: _currentPitch, |
|
||||
volume: _currentVolume, |
|
||||
)); |
|
||||
|
|
||||
// 清空缓冲区 |
|
||||
_streamBuffer = ''; |
|
||||
|
|
||||
// 如果队列未在处理中,开始处理队列 |
|
||||
if (!_isProcessingQueue) { |
|
||||
_processQueue(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
/// 连续播放多段文本 |
|
||||
Future<bool> speakMultiple(List<String> texts, {int? rate, int? pitch, int? volume, AudioOutputType? outputType}) async { |
|
||||
if (!isEnabled.value || texts.isEmpty) return false; |
|
||||
|
|
||||
// 如果指定了新的音频输出设备类型,先设置 |
|
||||
if (outputType != null && outputType != _currentAudioOutputType) { |
|
||||
await setAudioOutputType(outputType); |
|
||||
} |
|
||||
|
|
||||
for (final text in texts) { |
|
||||
if (text.isNotEmpty) { |
|
||||
_textQueue.add(_SpeechItem( |
|
||||
text: text, |
|
||||
rate: rate ?? _currentRate, |
|
||||
pitch: pitch ?? _currentPitch, |
|
||||
volume: volume ?? _currentVolume, |
|
||||
)); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (!_isProcessingQueue) _processQueue(); |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
/// 处理语音合成队列 |
|
||||
Future<void> _processQueue() async { |
|
||||
if (_textQueue.isEmpty || _isProcessingQueue) return; |
|
||||
|
|
||||
_isProcessingQueue = true; |
|
||||
|
|
||||
try { |
|
||||
while (_textQueue.isNotEmpty) { |
|
||||
if (!isEnabled.value) { |
|
||||
_textQueue.clear(); |
|
||||
break; |
|
||||
} |
|
||||
|
|
||||
final item = _textQueue.removeAt(0); |
|
||||
|
|
||||
if (item.rate != _currentRate || item.pitch != _currentPitch || item.volume != _currentVolume) { |
|
||||
await setSpeechParams( |
|
||||
rate: item.rate, |
|
||||
pitch: item.pitch, |
|
||||
volume: item.volume, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
await speakText(item.text); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('处理语音队列出错: $e'); |
|
||||
} finally { |
|
||||
_isProcessingQueue = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止当前语音合成并清空队列 |
|
||||
Future<bool> stop() async { |
|
||||
_textQueue.clear(); |
|
||||
_streamBuffer = ''; // 清空流缓冲区 |
|
||||
|
|
||||
if (_isSpeaking) { |
|
||||
try { |
|
||||
final result = await _channel.invokeMethod('stopSpeaking'); |
|
||||
_isSpeaking = false; |
|
||||
isSpeaking.value = false; |
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('停止语音合成出错: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
/// 生成SSML文本 |
|
||||
String generateSsml({ |
|
||||
required String text, |
|
||||
String? voiceName, |
|
||||
int? rate, |
|
||||
int? pitch, |
|
||||
int? volume, |
|
||||
}) { |
|
||||
final voice = voiceName ?? _currentVoice; |
|
||||
final rateValue = (rate ?? _currentRate).clamp(-100, 100); |
|
||||
final pitchValue = (pitch ?? _currentPitch).clamp(-100, 100); |
|
||||
final volumeValue = (volume ?? _currentVolume).clamp(0, 100); |
|
||||
|
|
||||
final rateStr = rateValue == 0 ? '0%' : rateValue < 0 ? '${(rateValue * 0.9).round()}%' : '$rateValue%'; |
|
||||
final pitchStr = pitchValue == 0 ? '0%' : '${(pitchValue * 0.5).round()}%'; |
|
||||
final volumeStr = "$volumeValue%"; |
|
||||
|
|
||||
return ''' |
|
||||
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="zh-CN"> |
|
||||
<voice name="$voice"> |
|
||||
<prosody rate="$rateStr" pitch="$pitchStr" volume="$volumeStr"> |
|
||||
$text |
|
||||
</prosody> |
|
||||
</voice> |
|
||||
</speak> |
|
||||
'''; |
|
||||
} |
|
||||
|
|
||||
/// 切换启用状态 |
|
||||
void toggleEnabled() { |
|
||||
isEnabled.toggle(); |
|
||||
if (!isEnabled.value) stop(); |
|
||||
} |
|
||||
|
|
||||
/// 切换音频输出设备类型 |
|
||||
Future<bool> toggleAudioOutputType() async { |
|
||||
AudioOutputType newType; |
|
||||
|
|
||||
switch (_currentAudioOutputType) { |
|
||||
case AudioOutputType.speaker: |
|
||||
newType = AudioOutputType.earpiece; |
|
||||
break; |
|
||||
case AudioOutputType.earpiece: |
|
||||
newType = AudioOutputType.auto; |
|
||||
break; |
|
||||
case AudioOutputType.auto: |
|
||||
newType = AudioOutputType.speaker; |
|
||||
break; |
|
||||
} |
|
||||
|
|
||||
return await setAudioOutputType(newType); |
|
||||
} |
|
||||
|
|
||||
/// 释放资源 |
|
||||
Future<void> dispose() async { |
|
||||
if (!_isInitialized) return; |
|
||||
|
|
||||
try { |
|
||||
await stop(); |
|
||||
await _channel.invokeMethod('dispose'); |
|
||||
_isInitialized = false; |
|
||||
} catch (e) { |
|
||||
Logger.error('释放资源失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 语音合成项目 |
|
||||
class _SpeechItem { |
|
||||
final String text; |
|
||||
final int rate; |
|
||||
final int pitch; |
|
||||
final int volume; |
|
||||
|
|
||||
_SpeechItem({ |
|
||||
required this.text, |
|
||||
this.rate = 0, |
|
||||
this.pitch = 0, |
|
||||
this.volume = 100, |
|
||||
}); |
|
||||
} |
|
||||
@ -1,377 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter/widgets.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
import '../providers/agent_provider.dart'; |
|
||||
import '../models/agent.dart'; |
|
||||
import '../../modules/chat/controllers/chat_controller.dart'; |
|
||||
import '../../modules/chat/models/message_model.dart'; |
|
||||
import 'volcano_asr_api_service.dart'; |
|
||||
import 'volcano_ai_service.dart'; |
|
||||
import 'azure_flutter_tts_service.dart'; |
|
||||
import 'chat_history_service.dart'; |
|
||||
|
|
||||
/// 后台代理服务,用于在应用处于后台时处理AI交互 |
|
||||
class BackgroundAgentService extends GetxService { |
|
||||
// 单例实例 |
|
||||
static BackgroundAgentService? _instance; |
|
||||
|
|
||||
// 单例工厂方法 |
|
||||
factory BackgroundAgentService() { |
|
||||
_instance ??= BackgroundAgentService._internal(); |
|
||||
return _instance!; |
|
||||
} |
|
||||
|
|
||||
// 私有构造函数 |
|
||||
BackgroundAgentService._internal() { |
|
||||
Logger.info('BackgroundAgentService 已创建'); |
|
||||
} |
|
||||
|
|
||||
// 服务实例 |
|
||||
final VolcanoAsrApiService _asrService = Get.find<VolcanoAsrApiService>(); |
|
||||
final VolcanoAIService _aiService = Get.find<VolcanoAIService>(); |
|
||||
final AzureFlutterTtsService _ttsService = Get.find<AzureFlutterTtsService>(); |
|
||||
final ChatHistoryService _historyService = Get.find<ChatHistoryService>(); |
|
||||
|
|
||||
// 状态控制 |
|
||||
// isProcessing: 表示整个代理交互会话是否处于活跃状态(包括语音识别、AI处理和TTS播放) |
|
||||
final RxBool isProcessing = false.obs; |
|
||||
final RxString recognizedText = ''.obs; |
|
||||
|
|
||||
// 流式响应控制 |
|
||||
StreamSubscription? _recognitionSubscription; |
|
||||
StreamSubscription? _aiResponseSubscription; |
|
||||
|
|
||||
// 定时器,用于检测无活动超时 |
|
||||
Timer? _inactivityTimer; |
|
||||
|
|
||||
// 当前处理的Agent |
|
||||
Agent? _currentAgent; |
|
||||
|
|
||||
// 本地消息历史备份 |
|
||||
final List<Map<String, String>> _localMessageHistory = []; |
|
||||
|
|
||||
// 获取消息历史 - 优先从ChatController获取,否则使用本地存储 |
|
||||
List<Map<String, String>> get messageHistory { |
|
||||
if (Get.isRegistered<ChatController>()) { |
|
||||
final chatController = Get.find<ChatController>(); |
|
||||
if (chatController.agent?.id == 'personal_assistant') { |
|
||||
return chatController.messages.map((m) => { |
|
||||
'role': m.role, |
|
||||
'content': m.content, |
|
||||
}).toList(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (_localMessageHistory.isEmpty) { |
|
||||
_loadChatHistory(); |
|
||||
} |
|
||||
|
|
||||
return _localMessageHistory; |
|
||||
} |
|
||||
|
|
||||
// 语音识别是否活跃 |
|
||||
bool get isListening => _recognitionSubscription != null; |
|
||||
|
|
||||
// TTS是否正在播放 |
|
||||
bool get isTtsSpeaking => _ttsService.isActuallyPlaying(); |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
_cleanupResources(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
/// 清理所有资源 |
|
||||
void _cleanupResources() { |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_aiResponseSubscription?.cancel(); |
|
||||
|
|
||||
if (_ttsService.isActuallyPlaying()) { |
|
||||
_ttsService.stop(); |
|
||||
} |
|
||||
|
|
||||
if (isListening) { |
|
||||
_asrService.stopRecognition(); |
|
||||
} |
|
||||
|
|
||||
_inactivityTimer?.cancel(); |
|
||||
} |
|
||||
|
|
||||
/// 开始代理交互 |
|
||||
Future<void> startAgentInteraction(String agentId) async { |
|
||||
// 防止重复处理 |
|
||||
if (isProcessing.value) { |
|
||||
Logger.warning('已经在处理交互,忽略此次请求'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 先说"我在听" |
|
||||
_ttsService.speak("我在!"); |
|
||||
isProcessing.value = true; |
|
||||
|
|
||||
try { |
|
||||
// 获取Agent |
|
||||
_currentAgent = AgentProvider.getAgentById(agentId); |
|
||||
if (_currentAgent == null) { |
|
||||
Logger.error('无法获取Agent: $agentId'); |
|
||||
isProcessing.value = false; |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 加载该Agent的聊天历史 |
|
||||
_loadChatHistory(); |
|
||||
|
|
||||
// 启动语音识别 |
|
||||
await _startVoiceRecognition(); |
|
||||
|
|
||||
// 启动无活动定时器 |
|
||||
_startInactivityTimer(); |
|
||||
} catch (e) { |
|
||||
Logger.error('启动代理交互失败', e); |
|
||||
isProcessing.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止语音识别 |
|
||||
Future<void> _stopVoiceRecognition() async { |
|
||||
if (!isListening) return; |
|
||||
|
|
||||
await _recognitionSubscription?.cancel(); |
|
||||
_recognitionSubscription = null; |
|
||||
_asrService.stopRecognition(); |
|
||||
recognizedText.value = ''; |
|
||||
} |
|
||||
|
|
||||
/// 开始语音识别 |
|
||||
Future<void> _startVoiceRecognition() async { |
|
||||
if (isListening) return; |
|
||||
|
|
||||
try { |
|
||||
final success = await _asrService.startRecognition(); |
|
||||
|
|
||||
if (success) { |
|
||||
_recognitionSubscription = _asrService.recognitionStream.listen( |
|
||||
(event) { |
|
||||
switch (event.type) { |
|
||||
case RecognitionEventType.recognizing: |
|
||||
if (event.text.isEmpty) return; |
|
||||
|
|
||||
recognizedText.value = event.text; |
|
||||
Logger.info('处理语音识别中间结果:${event.text}'); |
|
||||
|
|
||||
// 检测到用户说话时中断当前响应 |
|
||||
if (_ttsService.isActuallyPlaying() || _aiResponseSubscription != null) { |
|
||||
_interruptCurrentResponse("检测到用户开始讲话,中断当前响应"); |
|
||||
} |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.finalResult: |
|
||||
if (event.text.isEmpty) return; |
|
||||
|
|
||||
recognizedText.value = event.text; |
|
||||
Logger.info('处理语音识别最终结果:${event.text}'); |
|
||||
_processRecognizedText(event.text); |
|
||||
recognizedText.value = ''; |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.error: |
|
||||
Logger.info('语音识别错误: ${event.error}'); |
|
||||
stopInteraction(); |
|
||||
break; |
|
||||
|
|
||||
default: |
|
||||
break; |
|
||||
} |
|
||||
}, |
|
||||
onError: (error) { |
|
||||
Logger.info('语音识别错误:$error'); |
|
||||
stopInteraction(); |
|
||||
}, |
|
||||
); |
|
||||
} else { |
|
||||
Logger.error('启动语音识别失败'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('启动语音识别失败', e); |
|
||||
isProcessing.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 中断当前响应 |
|
||||
void _interruptCurrentResponse(String reason) { |
|
||||
Logger.info(reason); |
|
||||
|
|
||||
if (_ttsService.isActuallyPlaying()) { |
|
||||
_ttsService.stop(); |
|
||||
} |
|
||||
|
|
||||
if (_aiResponseSubscription != null) { |
|
||||
_aiResponseSubscription!.cancel(); |
|
||||
_aiResponseSubscription = null; |
|
||||
Logger.info('已中断AI响应流和TTS播放'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 处理识别到的文本 |
|
||||
Future<void> _processRecognizedText(String text) async { |
|
||||
if (text.trim().isEmpty || _currentAgent == null) return; |
|
||||
|
|
||||
try { |
|
||||
// 中断任何正在进行的响应 |
|
||||
_interruptCurrentResponse("处理新的用户输入,中断当前响应"); |
|
||||
|
|
||||
// 添加用户消息到历史记录 |
|
||||
_addMessageToHistory('user', text); |
|
||||
|
|
||||
String fullResponse = ''; |
|
||||
|
|
||||
// 获取AI响应流 |
|
||||
final responseStream = _aiService.sendMessageStream( |
|
||||
messages: messageHistory, |
|
||||
systemPrompt: _currentAgent!.systemPrompt, |
|
||||
); |
|
||||
|
|
||||
// 订阅响应流 |
|
||||
_aiResponseSubscription = responseStream.listen( |
|
||||
(chunk) { |
|
||||
fullResponse += chunk; |
|
||||
_ttsService.speak(chunk); |
|
||||
_resetInactivityTimer(); |
|
||||
}, |
|
||||
onDone: () { |
|
||||
_addMessageToHistory('assistant', fullResponse); |
|
||||
_aiResponseSubscription = null; |
|
||||
}, |
|
||||
onError: (error) { |
|
||||
Logger.error('AI响应错误', error); |
|
||||
const errorMessage = '抱歉,我遇到了一些问题,无法回应您的请求。'; |
|
||||
_ttsService.speak(errorMessage); |
|
||||
_addMessageToHistory('assistant', errorMessage); |
|
||||
_aiResponseSubscription = null; |
|
||||
}, |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error('处理识别文本失败', e); |
|
||||
const errorMessage = '抱歉,我遇到了一些问题,无法回应您的请求。'; |
|
||||
_ttsService.speak(errorMessage); |
|
||||
_addMessageToHistory('assistant', errorMessage); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 重新启动语音识别 |
|
||||
Future<void> _restartVoiceRecognition() async { |
|
||||
await _stopVoiceRecognition(); |
|
||||
await Future.delayed(Duration(milliseconds: 500)); |
|
||||
await _startVoiceRecognition(); |
|
||||
} |
|
||||
|
|
||||
/// 添加消息到历史记录 |
|
||||
void _addMessageToHistory(String role, String content) { |
|
||||
// 添加到本地历史记录 |
|
||||
_localMessageHistory.add({ |
|
||||
'role': role, |
|
||||
'content': content, |
|
||||
}); |
|
||||
|
|
||||
// 保持本地历史记录不超过10条消息 |
|
||||
if (_localMessageHistory.length > 10) { |
|
||||
_localMessageHistory.removeAt(0); |
|
||||
} |
|
||||
|
|
||||
// 同步消息到ChatController或保存到本地存储 |
|
||||
if (Get.isRegistered<ChatController>()) { |
|
||||
final chatController = Get.find<ChatController>(); |
|
||||
if (chatController.agent?.id == 'personal_assistant') { |
|
||||
chatController.addMessage(Message( |
|
||||
role: role, |
|
||||
content: content, |
|
||||
timestamp: DateTime.now(), |
|
||||
)); |
|
||||
} |
|
||||
} else { |
|
||||
_saveChatHistory(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 加载聊天历史 |
|
||||
void _loadChatHistory() { |
|
||||
if (_currentAgent == null) return; |
|
||||
|
|
||||
try { |
|
||||
final messages = _historyService.loadHistory(_currentAgent!.id); |
|
||||
_localMessageHistory.clear(); |
|
||||
|
|
||||
for (final message in messages) { |
|
||||
_localMessageHistory.add({ |
|
||||
'role': message.role, |
|
||||
'content': message.content, |
|
||||
}); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('加载聊天历史记录失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 保存聊天历史 |
|
||||
void _saveChatHistory() { |
|
||||
if (_currentAgent == null) return; |
|
||||
|
|
||||
try { |
|
||||
final messages = _localMessageHistory.map((m) => Message( |
|
||||
role: m['role']!, |
|
||||
content: m['content']!, |
|
||||
timestamp: DateTime.now(), |
|
||||
)).toList(); |
|
||||
|
|
||||
_historyService.saveHistory(_currentAgent!.id, messages); |
|
||||
} catch (e) { |
|
||||
Logger.error('保存聊天历史记录失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 启动无活动定时器 |
|
||||
void _startInactivityTimer() { |
|
||||
_inactivityTimer?.cancel(); |
|
||||
_inactivityTimer = Timer(Duration(seconds: 30), () { |
|
||||
if (!_ttsService.isActuallyPlaying() && isProcessing.value) { |
|
||||
Logger.info('检测到30秒内无活动,停止语音识别'); |
|
||||
stopInteraction(); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/// 重置无活动定时器 |
|
||||
void _resetInactivityTimer() { |
|
||||
if (_inactivityTimer != null) { |
|
||||
_inactivityTimer!.cancel(); |
|
||||
_startInactivityTimer(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 响应新的媒体事件(双击媒体按钮) |
|
||||
Future<void> respondToNewMediaEvent() async { |
|
||||
_ttsService.speak("我在!"); |
|
||||
|
|
||||
if (!isListening) { |
|
||||
await _restartVoiceRecognition(); |
|
||||
} |
|
||||
|
|
||||
_resetInactivityTimer(); |
|
||||
} |
|
||||
|
|
||||
/// 停止当前交互 |
|
||||
Future<void> stopInteraction() async { |
|
||||
try { |
|
||||
_saveChatHistory(); |
|
||||
_cleanupResources(); |
|
||||
|
|
||||
isProcessing.value = false; |
|
||||
_currentAgent = null; |
|
||||
|
|
||||
Logger.info('已停止后台代理交互'); |
|
||||
} catch (e) { |
|
||||
Logger.error('停止交互失败', e); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,142 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:flutter/foundation.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
// 移除 speech_to_text 依赖 |
|
||||
// import 'package:speech_to_text/speech_to_text.dart'; |
|
||||
// import 'package:speech_to_text/speech_recognition_result.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
|
|
||||
/// ASR状态枚举 |
|
||||
enum AsrState { |
|
||||
notInitialized, // 未初始化 |
|
||||
initialized, // 已初始化 |
|
||||
listening, // 正在监听 |
|
||||
notListening, // 未监听 |
|
||||
error // 错误状态 |
|
||||
} |
|
||||
|
|
||||
/// 监听模式枚举(与 speech_to_text 兼容) |
|
||||
enum ListenMode { |
|
||||
deviceDefault, |
|
||||
dictation, |
|
||||
search, |
|
||||
confirmation, |
|
||||
} |
|
||||
|
|
||||
/// 本地名称类(与 speech_to_text 兼容) |
|
||||
class LocaleName { |
|
||||
final String name; |
|
||||
final String localeId; |
|
||||
|
|
||||
LocaleName(this.name, this.localeId); |
|
||||
} |
|
||||
|
|
||||
/// Flutter ASR服务,负责本地语音转文本功能 |
|
||||
/// 注意:此实现为空实现,不再依赖 speech_to_text 插件 |
|
||||
class FlutterAsrService extends GetxService { |
|
||||
// 当前ASR状态 |
|
||||
final Rx<AsrState> _asrState = AsrState.notInitialized.obs; |
|
||||
AsrState get asrState => _asrState.value; |
|
||||
|
|
||||
// 可用的语音识别语言 |
|
||||
final RxList<LocaleName> _locales = <LocaleName>[].obs; |
|
||||
List<LocaleName> get locales => _locales; |
|
||||
|
|
||||
// 当前选择的语言 |
|
||||
final RxString _currentLocale = ''.obs; |
|
||||
String get currentLocale => _currentLocale.value; |
|
||||
|
|
||||
// 识别结果 |
|
||||
final RxString _lastWords = ''.obs; |
|
||||
String get lastWords => _lastWords.value; |
|
||||
|
|
||||
// 识别准确度 |
|
||||
final RxDouble _confidence = 0.0.obs; |
|
||||
double get confidence => _confidence.value; |
|
||||
|
|
||||
// 是否可用 |
|
||||
final RxBool _isAvailable = false.obs; |
|
||||
bool get isAvailable => _isAvailable.value; |
|
||||
|
|
||||
// 是否正在监听 |
|
||||
bool _isListening = false; |
|
||||
bool get isListening => _isListening; |
|
||||
|
|
||||
// 识别结果流 |
|
||||
final _recognitionResultsController = StreamController<String>.broadcast(); |
|
||||
Stream<String> get recognitionResults => _recognitionResultsController.stream; |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
_initSpeech(); |
|
||||
} |
|
||||
|
|
||||
/// 初始化语音识别 |
|
||||
Future<void> _initSpeech() async { |
|
||||
try { |
|
||||
// 空实现,不再使用 speech_to_text |
|
||||
_isAvailable.value = false; |
|
||||
_asrState.value = AsrState.error; |
|
||||
|
|
||||
// 添加一个默认语言 |
|
||||
_locales.add(LocaleName('中文 (简体)', 'zh-CN')); |
|
||||
_currentLocale.value = 'zh-CN'; |
|
||||
|
|
||||
Logger.info('FlutterAsrService 已初始化(空实现)'); |
|
||||
} catch (e) { |
|
||||
_asrState.value = AsrState.error; |
|
||||
_isAvailable.value = false; |
|
||||
Logger.error('语音识别初始化失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 开始监听 |
|
||||
/// |
|
||||
/// [listenFor] 监听时长,默认30秒 |
|
||||
/// [pauseFor] 暂停时长,默认3秒,超过此时长无语音输入会自动停止 |
|
||||
/// [partialResults] 是否返回部分结果,默认true |
|
||||
/// [onDevice] 是否使用设备本地识别,默认false |
|
||||
/// [listenMode] 监听模式,默认设备默认模式 |
|
||||
Future<bool> startListening({ |
|
||||
Duration listenFor = const Duration(seconds: 30), |
|
||||
Duration pauseFor = const Duration(seconds: 3), |
|
||||
bool partialResults = true, |
|
||||
bool onDevice = false, |
|
||||
ListenMode listenMode = ListenMode.deviceDefault, |
|
||||
}) async { |
|
||||
// 空实现,不再使用 speech_to_text |
|
||||
Logger.error('FlutterAsrService 为空实现,不支持语音识别'); |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
/// 停止监听 |
|
||||
Future<void> stopListening() async { |
|
||||
// 空实现,不再使用 speech_to_text |
|
||||
_isListening = false; |
|
||||
_asrState.value = AsrState.notListening; |
|
||||
} |
|
||||
|
|
||||
/// 取消监听 |
|
||||
Future<void> cancelListening() async { |
|
||||
// 空实现,不再使用 speech_to_text |
|
||||
_isListening = false; |
|
||||
_lastWords.value = ''; |
|
||||
_confidence.value = 0.0; |
|
||||
_asrState.value = AsrState.notListening; |
|
||||
} |
|
||||
|
|
||||
/// 设置当前语言 |
|
||||
void setLocale(String localeId) { |
|
||||
if (_currentLocale.value != localeId) { |
|
||||
_currentLocale.value = localeId; |
|
||||
Logger.info('设置语音识别语言: $localeId'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
_recognitionResultsController.close(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,364 +1,3 @@ |
|||||
import 'dart:io'; |
// 重新导出FlutterTtsService类 |
||||
import 'package:flutter_tts/flutter_tts.dart'; |
// 这个文件作为兼容层,将speech_impl/flutter_tts_service.dart中的服务导出 |
||||
import 'package:get/get.dart'; |
export 'speech_impl/flutter_tts_service.dart'; |
||||
import '../../core/utils/logger.dart'; |
|
||||
|
|
||||
/// Flutter TTS服务,负责本地文本转语音功能 |
|
||||
class FlutterTtsService extends GetxService { |
|
||||
late FlutterTts _flutterTts; |
|
||||
|
|
||||
// 当前TTS状态 |
|
||||
TtsState _ttsState = TtsState.stopped; |
|
||||
TtsState get ttsState => _ttsState; |
|
||||
|
|
||||
// 可用的语音 |
|
||||
List<Map<String, String>> _voices = []; |
|
||||
List<Map<String, String>> get voices => _voices; |
|
||||
|
|
||||
// 当前选择的语音 |
|
||||
String _currentVoice = ''; |
|
||||
String get currentVoice => _currentVoice; |
|
||||
set currentVoice(String voice) => _currentVoice = voice; |
|
||||
|
|
||||
// 语速 (0.1-2.0) |
|
||||
double _rate = 0.5; |
|
||||
double get rate => _rate; |
|
||||
set rate(double value) => _rate = value; |
|
||||
|
|
||||
// 音量 (0.0-1.0) |
|
||||
double _volume = 1.0; |
|
||||
double get volume => _volume; |
|
||||
set volume(double value) => _volume = value; |
|
||||
|
|
||||
// 音调 (0.5-2.0) |
|
||||
double _pitch = 1.0; |
|
||||
double get pitch => _pitch; |
|
||||
set pitch(double value) => _pitch = value; |
|
||||
|
|
||||
// 文本队列 - 仅包含待播放的文本 |
|
||||
List<String> _textQueue = []; |
|
||||
List<String> get textQueue => _textQueue; |
|
||||
|
|
||||
// 当前正在播放的文本 |
|
||||
String _currentText = ''; |
|
||||
String get currentText => _currentText; |
|
||||
|
|
||||
// 是否正在处理队列 |
|
||||
bool _isProcessingQueue = false; |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
_initTts(); |
|
||||
} |
|
||||
|
|
||||
/// 初始化TTS引擎 |
|
||||
Future<void> _initTts() async { |
|
||||
_flutterTts = FlutterTts(); |
|
||||
|
|
||||
// 设置完成回调 |
|
||||
_flutterTts.setCompletionHandler(() { |
|
||||
Logger.info('TTS播放完成: "$_currentText"'); |
|
||||
|
|
||||
// 清空当前播放文本 |
|
||||
_currentText = ''; |
|
||||
|
|
||||
// 处理队列中的下一条文本 |
|
||||
_processNextInQueue(); |
|
||||
|
|
||||
// 只有当队列为空且不再处理队列时才设置为stopped状态 |
|
||||
if (_textQueue.isEmpty && !_isProcessingQueue) { |
|
||||
_ttsState = TtsState.stopped; |
|
||||
Logger.info('TTS队列播放完成,设置为stopped状态'); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
// 设置错误回调 |
|
||||
_flutterTts.setErrorHandler((error) { |
|
||||
Logger.error('Flutter TTS错误: $error'); |
|
||||
|
|
||||
// 清空当前播放文本 |
|
||||
_currentText = ''; |
|
||||
|
|
||||
// 尝试播放下一条文本 |
|
||||
_processNextInQueue(); |
|
||||
|
|
||||
// 只有当队列为空且不再处理队列时才设置为stopped状态 |
|
||||
if (_textQueue.isEmpty && !_isProcessingQueue) { |
|
||||
_ttsState = TtsState.stopped; |
|
||||
Logger.info('TTS队列播放出错且为空,设置为stopped状态'); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
// 设置开始回调 |
|
||||
_flutterTts.setStartHandler(() { |
|
||||
_ttsState = TtsState.playing; |
|
||||
}); |
|
||||
|
|
||||
// 设置取消回调 |
|
||||
_flutterTts.setCancelHandler(() { |
|
||||
// 清空当前播放文本 |
|
||||
_currentText = ''; |
|
||||
_isProcessingQueue = false; |
|
||||
_ttsState = TtsState.stopped; |
|
||||
Logger.info('TTS播放被取消,设置为stopped状态'); |
|
||||
}); |
|
||||
|
|
||||
// 设置暂停回调 |
|
||||
_flutterTts.setPauseHandler(() { |
|
||||
_ttsState = TtsState.paused; |
|
||||
}); |
|
||||
|
|
||||
// 设置继续回调 |
|
||||
_flutterTts.setContinueHandler(() { |
|
||||
_ttsState = TtsState.playing; |
|
||||
}); |
|
||||
|
|
||||
// 加载可用语音 |
|
||||
await _loadVoices(); |
|
||||
|
|
||||
Logger.info('Flutter TTS服务已初始化'); |
|
||||
} |
|
||||
|
|
||||
/// 加载可用的语音 |
|
||||
Future<void> _loadVoices() async { |
|
||||
try { |
|
||||
if (Platform.isAndroid || Platform.isIOS) { |
|
||||
final voices = await _flutterTts.getVoices; |
|
||||
if (voices != null) { |
|
||||
_voices = []; |
|
||||
for (var voice in voices) { |
|
||||
if (voice is Map) { |
|
||||
final Map<String, String> voiceMap = {}; |
|
||||
voice.forEach((key, value) { |
|
||||
if (key is String && value is String) { |
|
||||
voiceMap[key] = value; |
|
||||
} else if (key is String) { |
|
||||
voiceMap[key] = value.toString(); |
|
||||
} |
|
||||
}); |
|
||||
if (voiceMap.containsKey('name')) { |
|
||||
_voices.add(voiceMap); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 如果有可用语音,设置第一个为默认 |
|
||||
if (_voices.isNotEmpty) { |
|
||||
_currentVoice = _voices[0]['name'] ?? ''; |
|
||||
await _flutterTts.setVoice({"name": _currentVoice}); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('加载TTS语音失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语音 |
|
||||
Future<void> setVoice(String voiceName) async { |
|
||||
try { |
|
||||
_currentVoice = voiceName; |
|
||||
await _flutterTts.setVoice({"name": voiceName}); |
|
||||
} catch (e) { |
|
||||
Logger.error('设置TTS语音失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语速 |
|
||||
Future<void> setRate(double rate) async { |
|
||||
try { |
|
||||
_rate = rate; |
|
||||
await _flutterTts.setSpeechRate(rate); |
|
||||
} catch (e) { |
|
||||
Logger.error('设置TTS语速失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置音量 |
|
||||
Future<void> setVolume(double volume) async { |
|
||||
try { |
|
||||
_volume = volume; |
|
||||
await _flutterTts.setVolume(volume); |
|
||||
} catch (e) { |
|
||||
Logger.error('设置TTS音量失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置音调 |
|
||||
Future<void> setPitch(double pitch) async { |
|
||||
try { |
|
||||
_pitch = pitch; |
|
||||
await _flutterTts.setPitch(pitch); |
|
||||
} catch (e) { |
|
||||
Logger.error('设置TTS音调失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语言 |
|
||||
Future<void> setLanguage(String language) async { |
|
||||
try { |
|
||||
await _flutterTts.setLanguage(language); |
|
||||
} catch (e) { |
|
||||
Logger.error('设置TTS语言失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 添加文本到队列并开始处理队列(如果尚未开始) |
|
||||
Future<void> speak(String text) async { |
|
||||
if (text.isEmpty) return; |
|
||||
|
|
||||
// 添加到队列 |
|
||||
_addToQueue(text); |
|
||||
|
|
||||
// 开始处理队列(如果尚未开始) |
|
||||
_startQueueProcessingIfNeeded(); |
|
||||
} |
|
||||
|
|
||||
/// 添加多条文本到队列并开始处理队列(如果尚未开始) |
|
||||
Future<void> speakTexts(List<String> texts) async { |
|
||||
if (texts.isEmpty) return; |
|
||||
|
|
||||
// 添加所有非空文本到队列 |
|
||||
int addedCount = 0; |
|
||||
for (final text in texts) { |
|
||||
if (text.isNotEmpty) { |
|
||||
_textQueue.add(text); |
|
||||
addedCount++; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (addedCount > 0) { |
|
||||
Logger.info('已添加$addedCount条文本到TTS队列'); |
|
||||
|
|
||||
// 开始处理队列(如果尚未开始) |
|
||||
_startQueueProcessingIfNeeded(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 添加文本到队列 |
|
||||
void _addToQueue(String text) { |
|
||||
if (text.isEmpty) return; |
|
||||
|
|
||||
_textQueue.add(text); |
|
||||
Logger.info('已添加文本到TTS队列: "${text.length > 20 ? "${text.substring(0, 20)}..." : text}"'); |
|
||||
} |
|
||||
|
|
||||
/// 开始处理队列(如果尚未开始) |
|
||||
void _startQueueProcessingIfNeeded() { |
|
||||
// 如果队列为空,不需要处理 |
|
||||
if (_textQueue.isEmpty) return; |
|
||||
|
|
||||
// 如果已经在处理队列或正在播放,不需要重新开始 |
|
||||
if (_isProcessingQueue || _ttsState == TtsState.playing) return; |
|
||||
|
|
||||
// 开始处理队列 |
|
||||
_isProcessingQueue = true; |
|
||||
_processNextInQueue(); |
|
||||
} |
|
||||
|
|
||||
/// 处理队列中的下一条文本 |
|
||||
void _processNextInQueue() { |
|
||||
// 如果队列为空,结束处理但不立即设置stopped状态 |
|
||||
if (_textQueue.isEmpty) { |
|
||||
_currentText = ''; |
|
||||
_isProcessingQueue = false; |
|
||||
|
|
||||
// 只有当当前没有文本在播放时才设置为stopped状态 |
|
||||
if (_currentText.isEmpty) { |
|
||||
_ttsState = TtsState.stopped; |
|
||||
Logger.info('TTS队列为空,设置为stopped状态'); |
|
||||
} |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 获取队列中的第一条文本并播放 |
|
||||
_speakNextText(); |
|
||||
} |
|
||||
|
|
||||
/// 播放队列中的下一条文本 |
|
||||
Future<void> _speakNextText() async { |
|
||||
if (_textQueue.isEmpty) { |
|
||||
_isProcessingQueue = false; |
|
||||
|
|
||||
// 只有当当前没有文本在播放时才设置为stopped状态 |
|
||||
if (_currentText.isEmpty) { |
|
||||
_ttsState = TtsState.stopped; |
|
||||
Logger.info('TTS队列播放完成,设置为stopped状态'); |
|
||||
} |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 获取队列中的第一条文本 |
|
||||
final text = _textQueue.removeAt(0); |
|
||||
_currentText = text; |
|
||||
|
|
||||
try { |
|
||||
// 设置TTS参数 |
|
||||
await _flutterTts.setSpeechRate(_rate); |
|
||||
await _flutterTts.setVolume(_volume); |
|
||||
await _flutterTts.setPitch(_pitch); |
|
||||
|
|
||||
if (_currentVoice.isNotEmpty) { |
|
||||
await _flutterTts.setVoice({"name": _currentVoice}); |
|
||||
} |
|
||||
|
|
||||
// 播放文本 |
|
||||
await _flutterTts.speak(text); |
|
||||
Logger.info('正在播放文本,队列中还有${_textQueue.length}条待播放'); |
|
||||
} catch (e) { |
|
||||
Logger.error('TTS播放文本失败', e); |
|
||||
// 清空当前文本 |
|
||||
_currentText = ''; |
|
||||
// 尝试播放下一个 |
|
||||
_processNextInQueue(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止播放并清空队列 |
|
||||
Future<void> stop() async { |
|
||||
try { |
|
||||
await _flutterTts.stop(); |
|
||||
|
|
||||
// 重置队列处理状态 |
|
||||
_textQueue.clear(); |
|
||||
_currentText = ''; |
|
||||
_isProcessingQueue = false; |
|
||||
_ttsState = TtsState.stopped; |
|
||||
|
|
||||
Logger.info('已停止TTS播放并清空队列'); |
|
||||
} catch (e) { |
|
||||
Logger.error('停止TTS播放失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 暂停播放 |
|
||||
Future<void> pause() async { |
|
||||
try { |
|
||||
await _flutterTts.pause(); |
|
||||
_ttsState = TtsState.paused; |
|
||||
} catch (e) { |
|
||||
Logger.error('暂停TTS播放失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 清空队列并停止播放 |
|
||||
Future<void> clearQueue() async { |
|
||||
_textQueue.clear(); |
|
||||
await stop(); |
|
||||
Logger.info('已清空TTS队列'); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
_flutterTts.stop(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// TTS状态枚举 |
|
||||
enum TtsState { |
|
||||
playing, |
|
||||
stopped, |
|
||||
paused, |
|
||||
} |
|
||||
@ -1,352 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:audio_service/audio_service.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:flutter/widgets.dart'; |
|
||||
import 'background_agent_service.dart'; |
|
||||
import '../../data/providers/agent_provider.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
|
|
||||
/// 自定义 AudioHandler,用于捕获媒体按键事件(例如蓝牙耳机按钮) |
|
||||
class MyAudioHandler extends BaseAudioHandler { |
|
||||
bool _isDisposed = false; |
|
||||
|
|
||||
// 添加一个标志,表示是否正在处理交互 |
|
||||
bool _isHandlingInteraction = false; |
|
||||
|
|
||||
// 添加一个计时器,用于定期更新播放状态 |
|
||||
Timer? _statusUpdateTimer; |
|
||||
|
|
||||
// 添加一个变量,记录最后一次媒体按钮事件的时间 |
|
||||
DateTime? _lastMediaButtonEventTime; |
|
||||
|
|
||||
MyAudioHandler() { |
|
||||
_initializeHandler(); |
|
||||
_startStatusUpdateTimer(); |
|
||||
Logger.info('MyAudioHandler 已创建,准备接收媒体按钮事件'); |
|
||||
} |
|
||||
|
|
||||
// 获取BackgroundAgentService实例 |
|
||||
BackgroundAgentService? _getBackgroundAgent() { |
|
||||
try { |
|
||||
if (Get.isRegistered<BackgroundAgentService>()) { |
|
||||
return Get.find<BackgroundAgentService>(); |
|
||||
} else { |
|
||||
return Get.put(BackgroundAgentService(), permanent: true); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('获取 BackgroundAgent 失败', e); |
|
||||
return null; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void _initializeHandler() { |
|
||||
try { |
|
||||
// 设置初始播放状态 |
|
||||
playbackState.add(PlaybackState( |
|
||||
controls: [ |
|
||||
MediaControl.play, |
|
||||
MediaControl.pause, |
|
||||
MediaControl.stop, |
|
||||
MediaControl.skipToPrevious, |
|
||||
MediaControl.skipToNext, |
|
||||
], |
|
||||
systemActions: { |
|
||||
MediaAction.play, |
|
||||
MediaAction.pause, |
|
||||
MediaAction.stop, |
|
||||
MediaAction.skipToPrevious, |
|
||||
MediaAction.skipToNext, |
|
||||
MediaAction.seek, |
|
||||
}, |
|
||||
androidCompactActionIndices: const [0, 1, 2], |
|
||||
processingState: AudioProcessingState.idle, |
|
||||
playing: false, |
|
||||
)); |
|
||||
|
|
||||
// 设置媒体元数据 |
|
||||
final item = MediaItem( |
|
||||
id: 'deep_voice_audio', |
|
||||
title: 'Deep Voice Audio', |
|
||||
album: 'Deep Voice', |
|
||||
artist: 'Deep Voice', |
|
||||
genre: 'Speech', |
|
||||
duration: const Duration(seconds: 1), |
|
||||
artUri: null, // 移除图片URI |
|
||||
playable: true, |
|
||||
displayTitle: 'Deep Voice Audio', |
|
||||
displaySubtitle: 'Voice Assistant', |
|
||||
displayDescription: 'TTS Audio', |
|
||||
extras: const {'source': 'deep_voice'}, |
|
||||
); |
|
||||
|
|
||||
mediaItem.add(item); |
|
||||
Logger.info('AudioHandler 媒体项已设置'); |
|
||||
|
|
||||
// 立即更新播放状态,确保通知显示 |
|
||||
_updatePlaybackState( |
|
||||
playing: true, |
|
||||
processingState: AudioProcessingState.ready, |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error('初始化 AudioHandler 失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 启动定期更新播放状态的计时器 |
|
||||
void _startStatusUpdateTimer() { |
|
||||
_statusUpdateTimer?.cancel(); |
|
||||
_statusUpdateTimer = Timer.periodic(Duration(minutes: 5), (timer) { |
|
||||
try { |
|
||||
// 定期更新播放状态,确保服务保持活跃 |
|
||||
_updatePlaybackState( |
|
||||
playing: true, |
|
||||
processingState: AudioProcessingState.ready, |
|
||||
); |
|
||||
Logger.info('定期更新播放状态成功'); |
|
||||
} catch (e) { |
|
||||
Logger.error('定期更新播放状态失败', e); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 更新播放状态 |
|
||||
void _updatePlaybackState({ |
|
||||
required bool playing, |
|
||||
required AudioProcessingState processingState, |
|
||||
}) { |
|
||||
if (_isDisposed) return; |
|
||||
|
|
||||
try { |
|
||||
playbackState.add(playbackState.value.copyWith( |
|
||||
playing: playing, |
|
||||
processingState: processingState, |
|
||||
updatePosition: Duration.zero, |
|
||||
)); |
|
||||
} catch (e) { |
|
||||
Logger.error('更新播放状态失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _handleInteraction() async { |
|
||||
// 防止重复处理 |
|
||||
if (_isHandlingInteraction) { |
|
||||
Logger.info('已经在处理交互,忽略此次请求'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
_isHandlingInteraction = true; |
|
||||
|
|
||||
try { |
|
||||
// 更新播放状态,确保通知显示 |
|
||||
_updatePlaybackState( |
|
||||
playing: true, |
|
||||
processingState: AudioProcessingState.ready, |
|
||||
); |
|
||||
|
|
||||
// 直接获取 personal_assistant agent |
|
||||
final agent = AgentProvider.getAgentById('personal_assistant'); |
|
||||
if (agent == null) { |
|
||||
Logger.error('无法获取personal_assistant agent,无法激活语音助手'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 检查app是否在前台 |
|
||||
final isInForeground = Get.context != null && |
|
||||
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed; |
|
||||
|
|
||||
final backgroundAgent = _getBackgroundAgent(); |
|
||||
|
|
||||
if (isInForeground) { |
|
||||
// 应用在前台,导航到聊天页面 |
|
||||
Logger.info('应用在前台,导航到聊天页面'); |
|
||||
|
|
||||
// 如果BackgroundAgent正在运行,先停止它 |
|
||||
if (backgroundAgent != null && backgroundAgent.isProcessing.value) { |
|
||||
await backgroundAgent.stopInteraction(); |
|
||||
Logger.info('已停止后台代理服务,切换到前台模式'); |
|
||||
} |
|
||||
|
|
||||
// 导航到聊天页面,并标记需要在进入时播放语音应答 |
|
||||
Get.toNamed('/chat', arguments: { |
|
||||
'agentId': agent.id, |
|
||||
'agentName': agent.name, |
|
||||
'agentVoice': agent.voice, |
|
||||
'agentAvatar': agent.avatarUrl, |
|
||||
'agentSubtitle': agent.description, |
|
||||
'systemPrompt': agent.systemPrompt, |
|
||||
'welcomeMessage': agent.welcomeMessage, |
|
||||
'playVoiceOnEnter': true, |
|
||||
}); |
|
||||
} else if (backgroundAgent != null) { |
|
||||
// 应用在后台,使用 BackgroundAgent 处理交互 |
|
||||
if (backgroundAgent.isProcessing.value) { |
|
||||
Logger.info('检测到媒体按钮再次按下,中断当前AI回复'); |
|
||||
await backgroundAgent.respondToNewMediaEvent(); |
|
||||
} else { |
|
||||
// 首次启动后台服务 |
|
||||
Logger.info('首次启动后台服务'); |
|
||||
await backgroundAgent.startAgentInteraction(agent.id); |
|
||||
} |
|
||||
} else { |
|
||||
Logger.error('无法获取 BackgroundAgent,无法处理交互'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('处理媒体按钮交互失败', e); |
|
||||
} finally { |
|
||||
_isHandlingInteraction = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> play() async { |
|
||||
Logger.info('收到播放命令'); |
|
||||
try { |
|
||||
_updatePlaybackState( |
|
||||
playing: true, |
|
||||
processingState: AudioProcessingState.ready, |
|
||||
); |
|
||||
await _handleInteraction(); |
|
||||
} catch (e) { |
|
||||
Logger.error('处理播放命令失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> pause() async { |
|
||||
Logger.info('收到暂停命令'); |
|
||||
try { |
|
||||
_updatePlaybackState( |
|
||||
playing: false, |
|
||||
processingState: AudioProcessingState.ready, |
|
||||
); |
|
||||
await _handleInteraction(); |
|
||||
} catch (e) { |
|
||||
Logger.error('处理暂停命令失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> stop() async { |
|
||||
Logger.info('停止 AudioHandler'); |
|
||||
_isDisposed = true; |
|
||||
try { |
|
||||
_statusUpdateTimer?.cancel(); |
|
||||
|
|
||||
// 如果有正在运行的后台服务,停止它 |
|
||||
final backgroundAgent = _getBackgroundAgent(); |
|
||||
if (backgroundAgent != null && backgroundAgent.isProcessing.value) { |
|
||||
await backgroundAgent.stopInteraction(); |
|
||||
} |
|
||||
|
|
||||
await super.stop(); |
|
||||
_updatePlaybackState( |
|
||||
playing: false, |
|
||||
processingState: AudioProcessingState.idle, |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error('停止 AudioHandler 时发生错误', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> skipToPrevious() async { |
|
||||
Logger.info('收到上一曲命令'); |
|
||||
try { |
|
||||
_updatePlaybackState( |
|
||||
playing: true, |
|
||||
processingState: AudioProcessingState.ready, |
|
||||
); |
|
||||
await _handleInteraction(); |
|
||||
} catch (e) { |
|
||||
Logger.error('处理上一曲命令失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> skipToNext() async { |
|
||||
Logger.info('收到下一曲命令'); |
|
||||
try { |
|
||||
_updatePlaybackState( |
|
||||
playing: true, |
|
||||
processingState: AudioProcessingState.ready, |
|
||||
); |
|
||||
await _handleInteraction(); |
|
||||
} catch (e) { |
|
||||
Logger.error('处理下一曲命令失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> seek(Duration position) async { |
|
||||
Logger.info('收到跳转命令: $position'); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<dynamic> customAction(String name, |
|
||||
[Map<String, dynamic>? extras]) async { |
|
||||
Logger.info('收到自定义命令: $name'); |
|
||||
try { |
|
||||
if (name == 'media_button') { |
|
||||
Logger.info('收到媒体按钮事件'); |
|
||||
|
|
||||
// 检查是否是重复触发(防止短时间内多次触发) |
|
||||
final now = DateTime.now(); |
|
||||
if (_lastMediaButtonEventTime != null) { |
|
||||
final difference = now.difference(_lastMediaButtonEventTime!); |
|
||||
if (difference.inMilliseconds < 500) { |
|
||||
Logger.info('媒体按钮事件触发过于频繁,忽略此次事件'); |
|
||||
return; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 更新最后一次事件时间 |
|
||||
_lastMediaButtonEventTime = now; |
|
||||
|
|
||||
_updatePlaybackState( |
|
||||
playing: true, |
|
||||
processingState: AudioProcessingState.ready, |
|
||||
); |
|
||||
await _handleInteraction(); |
|
||||
} else if (name == 'ping') { |
|
||||
// 用于检查服务是否响应的测试命令 |
|
||||
Logger.info('收到 ping 命令,服务正常运行'); |
|
||||
return 'pong'; |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('处理自定义命令失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> onTaskRemoved() async { |
|
||||
Logger.info('服务被系统移除'); |
|
||||
try { |
|
||||
// 如果有正在运行的后台服务,停止它 |
|
||||
final backgroundAgent = _getBackgroundAgent(); |
|
||||
if (backgroundAgent != null && backgroundAgent.isProcessing.value) { |
|
||||
await backgroundAgent.stopInteraction(); |
|
||||
} |
|
||||
|
|
||||
await stop(); |
|
||||
} catch (e) { |
|
||||
Logger.error('处理服务移除失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> onNotificationDeleted() async { |
|
||||
Logger.info('通知被用户移除'); |
|
||||
try { |
|
||||
// 如果有正在运行的后台服务,停止它 |
|
||||
final backgroundAgent = _getBackgroundAgent(); |
|
||||
if (backgroundAgent != null && backgroundAgent.isProcessing.value) { |
|
||||
await backgroundAgent.stopInteraction(); |
|
||||
} |
|
||||
|
|
||||
await stop(); |
|
||||
} catch (e) { |
|
||||
Logger.error('处理通知移除失败', e); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,224 +0,0 @@ |
|||||
import 'dart:io'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; |
|
||||
import 'package:device_info_plus/device_info_plus.dart'; |
|
||||
import 'package:permission_handler/permission_handler.dart'; |
|
||||
import '../providers/agent_provider.dart'; |
|
||||
import 'package:flutter/foundation.dart'; |
|
||||
|
|
||||
class NotificationService extends GetxService { |
|
||||
static NotificationService get to => Get.find(); |
|
||||
|
|
||||
FlutterLocalNotificationsPlugin? _flutterLocalNotificationsPlugin; |
|
||||
bool _notificationActive = false; |
|
||||
bool _isRequestingPermission = false; |
|
||||
|
|
||||
String get _defaultAgentName { |
|
||||
final agent = AgentProvider.getAgentById('personal_assistant'); |
|
||||
return agent?.name ?? 'Deep Voice'; |
|
||||
} |
|
||||
|
|
||||
Future<void> init() async { |
|
||||
try { |
|
||||
if (Platform.isIOS || Platform.isAndroid || Platform.isMacOS) { |
|
||||
_flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); |
|
||||
|
|
||||
// 使用应用图标作为通知图标 |
|
||||
const initializationSettingsAndroid = AndroidInitializationSettings('mipmap/ic_launcher'); |
|
||||
|
|
||||
final initializationSettingsDarwin = DarwinInitializationSettings( |
|
||||
requestSoundPermission: false, |
|
||||
requestBadgePermission: false, |
|
||||
requestAlertPermission: false, |
|
||||
); |
|
||||
|
|
||||
final initializationSettings = InitializationSettings( |
|
||||
android: initializationSettingsAndroid, |
|
||||
iOS: initializationSettingsDarwin, |
|
||||
macOS: initializationSettingsDarwin, |
|
||||
); |
|
||||
|
|
||||
await _flutterLocalNotificationsPlugin?.initialize( |
|
||||
initializationSettings, |
|
||||
onDidReceiveNotificationResponse: (details) { |
|
||||
if (Get.currentRoute != '/chat') { |
|
||||
Get.toNamed('/chat'); |
|
||||
} |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 创建通知渠道 - 使用高重要性确保通知在后台可见 |
|
||||
if (Platform.isAndroid) { |
|
||||
await _flutterLocalNotificationsPlugin?.resolvePlatformSpecificImplementation< |
|
||||
AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel( |
|
||||
const AndroidNotificationChannel( |
|
||||
'foreground_service', |
|
||||
'Foreground Service', |
|
||||
description: 'Used for the foreground service notification', |
|
||||
importance: Importance.high, // 提高重要性 |
|
||||
playSound: false, |
|
||||
enableVibration: false, |
|
||||
showBadge: true, |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 请求权限,但避免重复请求 |
|
||||
await _requestPermissionIfNeeded(); |
|
||||
|
|
||||
// 在初始化完成后显示前台服务通知 |
|
||||
if (Platform.isAndroid) { |
|
||||
await showForegroundNotification(); |
|
||||
} |
|
||||
|
|
||||
// Add macOS-specific code for status bar icon |
|
||||
if (Platform.isMacOS) { |
|
||||
await showMacOSStatusBarIcon(); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('通知服务初始化失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> showForegroundNotification() async { |
|
||||
if (_notificationActive) return; |
|
||||
|
|
||||
try { |
|
||||
final androidDetails = AndroidNotificationDetails( |
|
||||
'foreground_service', |
|
||||
'Foreground Service', |
|
||||
channelDescription: 'Used for the foreground service notification', |
|
||||
importance: Importance.high, // 提高重要性 |
|
||||
priority: Priority.high, // 提高优先级 |
|
||||
ongoing: true, |
|
||||
autoCancel: false, |
|
||||
showWhen: false, |
|
||||
enableVibration: false, |
|
||||
playSound: false, |
|
||||
icon: 'mipmap/ic_launcher', // 使用应用图标 |
|
||||
visibility: NotificationVisibility.public, |
|
||||
category: AndroidNotificationCategory.service, |
|
||||
actions: [ |
|
||||
const AndroidNotificationAction( |
|
||||
'chat', |
|
||||
'开始对话', |
|
||||
showsUserInterface: true, |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
|
|
||||
const iosDetails = DarwinNotificationDetails( |
|
||||
presentAlert: false, |
|
||||
presentBadge: false, |
|
||||
presentSound: false, |
|
||||
interruptionLevel: InterruptionLevel.passive, |
|
||||
); |
|
||||
|
|
||||
final notificationDetails = NotificationDetails( |
|
||||
android: androidDetails, |
|
||||
iOS: iosDetails, |
|
||||
); |
|
||||
|
|
||||
await _flutterLocalNotificationsPlugin?.show( |
|
||||
0, |
|
||||
_defaultAgentName, |
|
||||
'点击开始对话', |
|
||||
notificationDetails, |
|
||||
); |
|
||||
|
|
||||
_notificationActive = true; |
|
||||
} catch (e) { |
|
||||
print('显示前台服务通知失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> cancelNotification() async { |
|
||||
if (!_notificationActive) return; |
|
||||
try { |
|
||||
await _flutterLocalNotificationsPlugin?.cancel(0); |
|
||||
_notificationActive = false; |
|
||||
} catch (e) { |
|
||||
print('取消通知失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _requestPermissionIfNeeded() async { |
|
||||
// 避免重复请求权限 |
|
||||
if (_isRequestingPermission) { |
|
||||
print('已有权限请求正在进行中,跳过此次请求'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
_isRequestingPermission = true; |
|
||||
|
|
||||
try { |
|
||||
if (Platform.isMacOS) { |
|
||||
// On macOS, we don't need to request notification permissions through permission_handler |
|
||||
// Instead, we'll use the flutter_local_notifications plugin directly |
|
||||
final bool? granted = await _flutterLocalNotificationsPlugin |
|
||||
?.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>() |
|
||||
?.requestPermissions( |
|
||||
alert: true, |
|
||||
badge: true, |
|
||||
sound: true, |
|
||||
); |
|
||||
if (granted != true) { |
|
||||
debugPrint('MacOS Notifications: Permission request was denied'); |
|
||||
} |
|
||||
} else if (Platform.isAndroid || Platform.isIOS) { |
|
||||
// 检查权限状态,避免不必要的请求 |
|
||||
final status = await Permission.notification.status; |
|
||||
if (status != PermissionStatus.granted) { |
|
||||
await Permission.notification.request(); |
|
||||
} |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('请求通知权限失败: $e'); |
|
||||
} finally { |
|
||||
_isRequestingPermission = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> showNotification({ |
|
||||
required String title, |
|
||||
required String body, |
|
||||
}) async { |
|
||||
if (_flutterLocalNotificationsPlugin == null) return; |
|
||||
|
|
||||
try { |
|
||||
const androidDetails = AndroidNotificationDetails( |
|
||||
'chat_channel', |
|
||||
'聊天消息', |
|
||||
channelDescription: '接收新的聊天消息', |
|
||||
importance: Importance.high, |
|
||||
priority: Priority.high, |
|
||||
icon: 'mipmap/ic_launcher', // 使用应用图标 |
|
||||
); |
|
||||
|
|
||||
const iosDetails = DarwinNotificationDetails( |
|
||||
presentAlert: true, |
|
||||
presentBadge: true, |
|
||||
presentSound: true, |
|
||||
); |
|
||||
|
|
||||
const notificationDetails = NotificationDetails( |
|
||||
android: androidDetails, |
|
||||
iOS: iosDetails, |
|
||||
); |
|
||||
|
|
||||
await _flutterLocalNotificationsPlugin?.show( |
|
||||
0, |
|
||||
title, |
|
||||
body, |
|
||||
notificationDetails, |
|
||||
); |
|
||||
} catch (e) { |
|
||||
print('显示通知失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> showMacOSStatusBarIcon() async { |
|
||||
// Implementation using platform channel to communicate with native macOS code |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,113 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import 'asr_service.dart'; |
||||
|
import 'tts_service.dart'; |
||||
|
import 'speech_impl/azure_asr_service.dart'; |
||||
|
import 'speech_impl/azure_tts_service.dart'; |
||||
|
import 'speech_impl/flutter_asr_service.dart'; |
||||
|
import 'speech_impl/flutter_tts_service.dart'; |
||||
|
import '../../core/utils/logger.dart'; |
||||
|
|
||||
|
/// 语音服务类型 |
||||
|
enum SpeechServiceType { |
||||
|
/// Azure 语音服务 |
||||
|
azure, |
||||
|
|
||||
|
/// Flutter 本地语音服务 |
||||
|
flutter, |
||||
|
} |
||||
|
|
||||
|
/// 语音服务工厂类 |
||||
|
/// |
||||
|
/// 用于根据配置信息获取不同的TTS和ASR实现 |
||||
|
class SpeechFactory extends GetxService { |
||||
|
static SpeechFactory get to => Get.find<SpeechFactory>(); |
||||
|
|
||||
|
|
||||
|
/// 当前使用的语音服务类型 |
||||
|
SpeechServiceType _currentServiceType = SpeechServiceType.azure; |
||||
|
|
||||
|
/// 获取当前使用的语音服务类型 |
||||
|
SpeechServiceType get currentServiceType => _currentServiceType; |
||||
|
|
||||
|
/// 设置语音服务类型 |
||||
|
void setServiceType(SpeechServiceType type) { |
||||
|
if (_currentServiceType != type) { |
||||
|
_currentServiceType = type; |
||||
|
Logger.info('语音服务类型已切换为: ${type.toString().split('.').last}'); |
||||
|
|
||||
|
// 重新注册服务 |
||||
|
_registerServices(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 注册语音服务 |
||||
|
void _registerServices() { |
||||
|
// 注销现有服务 |
||||
|
if (Get.isRegistered<AsrService>()) { |
||||
|
Get.delete<AsrService>(force: true); |
||||
|
} |
||||
|
|
||||
|
if (Get.isRegistered<TtsService>()) { |
||||
|
Get.delete<TtsService>(force: true); |
||||
|
} |
||||
|
|
||||
|
// 根据类型注册新服务 |
||||
|
switch (_currentServiceType) { |
||||
|
case SpeechServiceType.azure: |
||||
|
Get.lazyPut<AsrService>(() => AzureAsrService()); |
||||
|
Get.lazyPut<TtsService>(() => AzureTtsService()); |
||||
|
break; |
||||
|
|
||||
|
case SpeechServiceType.flutter: |
||||
|
Get.lazyPut<AsrService>(() => FlutterAsrService()); |
||||
|
Get.lazyPut<TtsService>(() => FlutterTtsService()); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
final asrService = Get.find<AsrService>(); |
||||
|
final ttsService = Get.find<TtsService>(); |
||||
|
asrService.initialize(); |
||||
|
ttsService.initialize(); |
||||
|
} |
||||
|
|
||||
|
/// 初始化语音服务 |
||||
|
Future<void> initialize({SpeechServiceType? initialType}) async { |
||||
|
try { |
||||
|
// 如果指定了初始类型,则设置 |
||||
|
if (initialType != null) { |
||||
|
_currentServiceType = initialType; |
||||
|
} |
||||
|
|
||||
|
// 注册服务 |
||||
|
_registerServices(); |
||||
|
|
||||
|
// // 初始化服务 |
||||
|
// final asrService = Get.find<AsrService>(); |
||||
|
// final ttsService = Get.find<TtsService>(); |
||||
|
|
||||
|
// await asrService.initialize(); |
||||
|
// await ttsService.initialize(); |
||||
|
|
||||
|
Logger.info('语音服务初始化完成,当前类型: ${_currentServiceType.toString().split('.').last}'); |
||||
|
} catch (e) { |
||||
|
Logger.error('语音服务初始化失败: $e'); |
||||
|
rethrow; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 获取ASR服务 |
||||
|
AsrService getAsrService() { |
||||
|
if (!Get.isRegistered<AsrService>()) { |
||||
|
_registerServices(); |
||||
|
} |
||||
|
return Get.find<AsrService>(); |
||||
|
} |
||||
|
|
||||
|
/// 获取TTS服务 |
||||
|
TtsService getTtsService() { |
||||
|
if (!Get.isRegistered<TtsService>()) { |
||||
|
_registerServices(); |
||||
|
} |
||||
|
return Get.find<TtsService>(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,570 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import '../tts_service.dart'; |
||||
|
|
||||
|
/// 音频输出设备类型 |
||||
|
enum AudioOutputType { |
||||
|
/// 扬声器 |
||||
|
speaker, |
||||
|
|
||||
|
/// 听筒 |
||||
|
earpiece, |
||||
|
|
||||
|
/// 自动选择(有耳机用耳机,没有则用听筒) |
||||
|
auto |
||||
|
} |
||||
|
|
||||
|
/// 微软 Text-to-Speech 服务 |
||||
|
/// |
||||
|
/// 该服务通过平台通道与 Android 上的 Microsoft Speech SDK 交互, |
||||
|
/// 提供文本转语音功能。 |
||||
|
class AzureTtsService extends GetxService implements TtsService { |
||||
|
static final AzureTtsService to = Get.put(AzureTtsService()); |
||||
|
static const MethodChannel _channel = MethodChannel('com.deep_voice.azure_tts'); |
||||
|
|
||||
|
bool _isInitialized = false; |
||||
|
late final String _subscriptionKey; |
||||
|
late final String _serviceRegion; |
||||
|
|
||||
|
// 当前设置 |
||||
|
String _currentVoice = 'zh-CN-XiaoxiaoNeural'; |
||||
|
int _currentRate = 0; |
||||
|
int _currentPitch = 0; |
||||
|
int _currentVolume = 100; |
||||
|
AudioOutputType _currentAudioOutputType = AudioOutputType.auto; |
||||
|
|
||||
|
// 支持的语音列表 |
||||
|
final List<String> _supportedVoices = [ |
||||
|
'zh-CN-XiaoxiaoNeural', |
||||
|
'zh-CN-YunxiNeural', |
||||
|
'zh-CN-YunjianNeural', |
||||
|
'en-US-JennyNeural', |
||||
|
'en-US-GuyNeural' |
||||
|
]; |
||||
|
|
||||
|
// 语音合成队列 |
||||
|
final List<_SpeechItem> _textQueue = []; |
||||
|
bool _isProcessingQueue = false; |
||||
|
|
||||
|
// 事件流控制器 |
||||
|
final StreamController<TtsEvent> _eventController = StreamController<TtsEvent>.broadcast(); |
||||
|
@override |
||||
|
Stream<TtsEvent> get onEvent => _eventController.stream; |
||||
|
|
||||
|
// 可观察状态 |
||||
|
final isEnabled = true.obs; |
||||
|
final _isSpeaking = false.obs; |
||||
|
final audioOutputType = AudioOutputType.auto.obs; |
||||
|
|
||||
|
// 流式文本缓冲区 |
||||
|
String _streamBuffer = ''; |
||||
|
|
||||
|
@override |
||||
|
String get currentVoice => _currentVoice; |
||||
|
|
||||
|
@override |
||||
|
bool get isSpeaking => _isSpeaking.value; |
||||
|
|
||||
|
@override |
||||
|
List<String> get supportedVoices => _supportedVoices; |
||||
|
|
||||
|
AudioOutputType get currentAudioOutputType => _currentAudioOutputType; |
||||
|
|
||||
|
AzureTtsService() { |
||||
|
_loadConfig(); |
||||
|
} |
||||
|
|
||||
|
/// 从环境变量加载配置 |
||||
|
void _loadConfig() { |
||||
|
_subscriptionKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
||||
|
_serviceRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> initialize({ |
||||
|
List<String>? supportedLanguages, |
||||
|
}) async { |
||||
|
if (_isInitialized) return true; |
||||
|
|
||||
|
try { |
||||
|
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { |
||||
|
Logger.error('未找到Azure语音服务配置'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 始终优先使用中文作为默认语言 |
||||
|
final String defaultLanguage = 'zh-CN'; |
||||
|
|
||||
|
// 更新支持的语音列表(如果提供) |
||||
|
if (supportedLanguages != null && supportedLanguages.isNotEmpty) { |
||||
|
// 如果支持中文,优先使用中文,否则使用提供的第一种语言 |
||||
|
String language = supportedLanguages.contains(defaultLanguage) |
||||
|
? defaultLanguage |
||||
|
: supportedLanguages.first; |
||||
|
|
||||
|
final result = await _channel.invokeMethod('initialize', { |
||||
|
'subscriptionKey': _subscriptionKey, |
||||
|
'region': _serviceRegion, |
||||
|
'language': language, |
||||
|
}); |
||||
|
|
||||
|
_isInitialized = result; |
||||
|
|
||||
|
// 确保设置默认的音频输出设备类型 |
||||
|
if (result) { |
||||
|
// 不需要显式调用 setAudioOutputType,因为原生层已经设置了默认值 |
||||
|
// 只需要确保 Dart 层的状态与原生层一致 |
||||
|
_currentAudioOutputType = AudioOutputType.auto; |
||||
|
audioOutputType.value = AudioOutputType.auto; |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} else { |
||||
|
// 使用默认语言初始化 |
||||
|
final result = await _channel.invokeMethod('initialize', { |
||||
|
'subscriptionKey': _subscriptionKey, |
||||
|
'region': _serviceRegion, |
||||
|
'language': 'zh-CN', |
||||
|
}); |
||||
|
|
||||
|
_isInitialized = result; |
||||
|
|
||||
|
// 确保设置默认的音频输出设备类型 |
||||
|
if (result) { |
||||
|
_currentAudioOutputType = AudioOutputType.auto; |
||||
|
audioOutputType.value = AudioOutputType.auto; |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('初始化失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error('TTS初始化失败: $e')); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> setVoice(String voiceName) async { |
||||
|
if (!_isInitialized) await initialize(); |
||||
|
if (voiceName == _currentVoice) return true; |
||||
|
|
||||
|
try { |
||||
|
final result = await _channel.invokeMethod('setVoice', { |
||||
|
'voiceName': voiceName, |
||||
|
}); |
||||
|
|
||||
|
if (result) _currentVoice = voiceName; |
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
Logger.error('设置语音失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error('设置语音失败: $e')); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置音频输出设备类型 |
||||
|
Future<bool> setAudioOutputType(AudioOutputType outputType) async { |
||||
|
if (!_isInitialized) await initialize(); |
||||
|
|
||||
|
try { |
||||
|
final String outputTypeStr = outputType.toString().split('.').last; |
||||
|
final result = await _channel.invokeMethod('setAudioOutputType', { |
||||
|
'outputType': outputTypeStr, |
||||
|
}); |
||||
|
|
||||
|
if (result) { |
||||
|
_currentAudioOutputType = outputType; |
||||
|
audioOutputType.value = outputType; |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
Logger.error('设置音频输出设备失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error('设置音频输出设备失败: $e')); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置语音参数 |
||||
|
Future<bool> setSpeechParams({int rate = 0, int pitch = 0, int volume = 100}) async { |
||||
|
if (!_isInitialized) await initialize(); |
||||
|
|
||||
|
try { |
||||
|
final result = await _channel.invokeMethod('setSpeechParams', { |
||||
|
'rate': rate, |
||||
|
'pitch': pitch, |
||||
|
'volume': volume, |
||||
|
}); |
||||
|
|
||||
|
if (result) { |
||||
|
_currentRate = rate; |
||||
|
_currentPitch = pitch; |
||||
|
_currentVolume = volume; |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
Logger.error('设置语音参数失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error('设置语音参数失败: $e')); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> speakOnce(String text) async { |
||||
|
if (!_isInitialized) await initialize(); |
||||
|
if (!isEnabled.value || text.isEmpty) return false; |
||||
|
|
||||
|
try { |
||||
|
_isSpeaking.value = true; |
||||
|
|
||||
|
// 发送开始事件 |
||||
|
_eventController.add(TtsEvent(type: TtsEventType.started)); |
||||
|
|
||||
|
final result = await _channel.invokeMethod('speakText', {'text': text}); |
||||
|
|
||||
|
_isSpeaking.value = false; |
||||
|
|
||||
|
// 发送完成事件 |
||||
|
_eventController.add(TtsEvent(type: TtsEventType.completed)); |
||||
|
|
||||
|
return result == "OK"; |
||||
|
} catch (e) { |
||||
|
_isSpeaking.value = false; |
||||
|
Logger.error('语音合成失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error('语音合成失败: $e')); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 播放SSML |
||||
|
Future<bool> speakSsmlOnce(String ssml) async { |
||||
|
if (!_isInitialized) await initialize(); |
||||
|
if (!isEnabled.value || ssml.isEmpty) return false; |
||||
|
|
||||
|
try { |
||||
|
_isSpeaking.value = true; |
||||
|
|
||||
|
// 发送开始事件 |
||||
|
_eventController.add(TtsEvent(type: TtsEventType.started)); |
||||
|
|
||||
|
final result = await _channel.invokeMethod('speakSsml', {'ssml': ssml}); |
||||
|
|
||||
|
_isSpeaking.value = false; |
||||
|
|
||||
|
// 发送完成事件 |
||||
|
_eventController.add(TtsEvent(type: TtsEventType.completed)); |
||||
|
|
||||
|
return result == "OK"; |
||||
|
} catch (e) { |
||||
|
_isSpeaking.value = false; |
||||
|
Logger.error('SSML语音合成失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error('SSML语音合成失败: $e')); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> speakStream(String text) async { |
||||
|
if (!isEnabled.value || text.isEmpty) return false; |
||||
|
|
||||
|
try { |
||||
|
// 添加文本到缓冲区 |
||||
|
_streamBuffer += text; |
||||
|
|
||||
|
// 如果缓冲区为空,直接返回 |
||||
|
if (_streamBuffer.isEmpty) return true; |
||||
|
|
||||
|
// 使用正则表达式匹配句子,包括结束符号 |
||||
|
// 匹配任意字符,直到遇到句子结束符号 |
||||
|
final sentenceRegex = RegExp( |
||||
|
r'([^。.!!??;;:\n\r]+[。.!!??;;::\n\r]|[^。.!!??;;:\n\r]+(?:\.{3,}|…)|[^。.!!??;;:\n\r]+["」』])' |
||||
|
); |
||||
|
|
||||
|
bool hasProcessed = false; |
||||
|
|
||||
|
// 查找所有完整句子 |
||||
|
final matches = sentenceRegex.allMatches(_streamBuffer); |
||||
|
final List<String> sentences = []; |
||||
|
int lastMatchEnd = 0; |
||||
|
|
||||
|
for (final match in matches) { |
||||
|
// 提取完整句子(包含结束符号) |
||||
|
final sentence = match.group(1)?.trim(); |
||||
|
if (sentence != null && sentence.isNotEmpty) { |
||||
|
sentences.add(sentence); |
||||
|
lastMatchEnd = match.end; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 处理找到的句子 |
||||
|
for (final sentence in sentences) { |
||||
|
_textQueue.add(_SpeechItem( |
||||
|
text: sentence, |
||||
|
rate: _currentRate, |
||||
|
pitch: _currentPitch, |
||||
|
volume: _currentVolume, |
||||
|
)); |
||||
|
hasProcessed = true; |
||||
|
} |
||||
|
|
||||
|
// 更新缓冲区,只保留未完成的部分 |
||||
|
if (lastMatchEnd > 0) { |
||||
|
_streamBuffer = _streamBuffer.substring(lastMatchEnd); |
||||
|
} |
||||
|
|
||||
|
// 如果处理了文本并且队列未在处理中,开始处理队列 |
||||
|
if (hasProcessed && !_isProcessingQueue) { |
||||
|
_processQueue(); |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('处理流式文本失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error('处理流式文本失败: $e')); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> flushStream() async { |
||||
|
if (_textQueue.isEmpty && _streamBuffer.isEmpty) return true; |
||||
|
|
||||
|
try { |
||||
|
// 处理缓冲区中可能的完整句子 |
||||
|
await speakStream(''); |
||||
|
|
||||
|
// 如果缓冲区仍有剩余文本,将其添加到播放队列 |
||||
|
if (_streamBuffer.isNotEmpty && _streamBuffer.trim().isNotEmpty) { |
||||
|
_textQueue.add(_SpeechItem( |
||||
|
text: _streamBuffer, |
||||
|
rate: _currentRate, |
||||
|
pitch: _currentPitch, |
||||
|
volume: _currentVolume, |
||||
|
)); |
||||
|
|
||||
|
// 清空缓冲区 |
||||
|
_streamBuffer = ''; |
||||
|
|
||||
|
// 如果队列未在处理中,开始处理队列 |
||||
|
if (!_isProcessingQueue) { |
||||
|
_processQueue(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Logger.info('等待TTS队列播放完成,剩余${_textQueue.length}条'); |
||||
|
|
||||
|
// 等待队列处理完成 |
||||
|
while (_isProcessingQueue || _isSpeaking.value) { |
||||
|
await Future.delayed(const Duration(milliseconds: 100)); |
||||
|
} |
||||
|
|
||||
|
Logger.info('TTS队列播放完成'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('刷新TTS流失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error('刷新TTS流失败: $e')); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理语音合成队列 |
||||
|
Future<void> _processQueue() async { |
||||
|
if (_textQueue.isEmpty || _isProcessingQueue) return; |
||||
|
|
||||
|
_isProcessingQueue = true; |
||||
|
|
||||
|
try { |
||||
|
while (_textQueue.isNotEmpty) { |
||||
|
if (!isEnabled.value) { |
||||
|
_textQueue.clear(); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
final item = _textQueue.removeAt(0); |
||||
|
|
||||
|
if (item.rate != _currentRate || item.pitch != _currentPitch || item.volume != _currentVolume) { |
||||
|
await setSpeechParams( |
||||
|
rate: item.rate, |
||||
|
pitch: item.pitch, |
||||
|
volume: item.volume, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 使用speakOnce播放当前项 |
||||
|
await speakOnce(item.text); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('处理语音队列出错: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error('处理语音队列出错: $e')); |
||||
|
} finally { |
||||
|
_isProcessingQueue = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> stop() async { |
||||
|
try { |
||||
|
_textQueue.clear(); |
||||
|
|
||||
|
if (_isSpeaking.value) { |
||||
|
final result = await _channel.invokeMethod('stopSpeaking'); |
||||
|
_isSpeaking.value = false; |
||||
|
|
||||
|
// 发送取消事件 |
||||
|
_eventController.add(TtsEvent(type: TtsEventType.canceled)); |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('停止语音合成出错: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error('停止语音合成出错: $e')); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 生成SSML文本 |
||||
|
String generateSsml({ |
||||
|
required String text, |
||||
|
String? voiceName, |
||||
|
int? rate, |
||||
|
int? pitch, |
||||
|
int? volume, |
||||
|
}) { |
||||
|
final voice = voiceName ?? _currentVoice; |
||||
|
final rateValue = (rate ?? _currentRate).clamp(-100, 100); |
||||
|
final pitchValue = (pitch ?? _currentPitch).clamp(-100, 100); |
||||
|
final volumeValue = (volume ?? _currentVolume).clamp(0, 100); |
||||
|
|
||||
|
final rateStr = rateValue == 0 ? '0%' : rateValue < 0 ? '${(rateValue * 0.9).round()}%' : '$rateValue%'; |
||||
|
final pitchStr = pitchValue == 0 ? '0%' : '${(pitchValue * 0.5).round()}%'; |
||||
|
final volumeStr = "$volumeValue%"; |
||||
|
|
||||
|
return ''' |
||||
|
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="zh-CN"> |
||||
|
<voice name="$voice"> |
||||
|
<prosody rate="$rateStr" pitch="$pitchStr" volume="$volumeStr"> |
||||
|
$text |
||||
|
</prosody> |
||||
|
</voice> |
||||
|
</speak> |
||||
|
'''; |
||||
|
} |
||||
|
|
||||
|
/// 切换启用状态 |
||||
|
void toggleEnabled() { |
||||
|
isEnabled.toggle(); |
||||
|
if (!isEnabled.value) stop(); |
||||
|
} |
||||
|
|
||||
|
/// 切换音频输出设备类型 |
||||
|
Future<bool> toggleAudioOutputType() async { |
||||
|
AudioOutputType newType; |
||||
|
|
||||
|
switch (_currentAudioOutputType) { |
||||
|
case AudioOutputType.speaker: |
||||
|
newType = AudioOutputType.earpiece; |
||||
|
break; |
||||
|
case AudioOutputType.earpiece: |
||||
|
newType = AudioOutputType.auto; |
||||
|
break; |
||||
|
case AudioOutputType.auto: |
||||
|
newType = AudioOutputType.speaker; |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
return await setAudioOutputType(newType); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<void> dispose() async { |
||||
|
if (!_isInitialized) return; |
||||
|
|
||||
|
try { |
||||
|
await stop(); |
||||
|
await _channel.invokeMethod('dispose'); |
||||
|
|
||||
|
// 关闭事件流控制器 |
||||
|
await _eventController.close(); |
||||
|
|
||||
|
_isInitialized = false; |
||||
|
} catch (e) { |
||||
|
Logger.error('释放资源失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理AI返回的文本流,将其转换为语音 |
||||
|
/// |
||||
|
/// [textStream] 文本流 |
||||
|
/// 返回一个Future,当流处理完成时完成 |
||||
|
Future<bool> processAiTextStream(Stream<String> textStream) async { |
||||
|
if (!isEnabled.value) { |
||||
|
Logger.error('TTS服务未启用'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
await for (final text in textStream) { |
||||
|
if (text.isEmpty) continue; |
||||
|
await speakStream(text); |
||||
|
} |
||||
|
|
||||
|
// 处理完流后,刷新缓冲区中的剩余文本 |
||||
|
await flushStream(); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('处理文本流时出错: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 语音合成项目 |
||||
|
class _SpeechItem { |
||||
|
final String text; |
||||
|
final int rate; |
||||
|
final int pitch; |
||||
|
final int volume; |
||||
|
|
||||
|
_SpeechItem({ |
||||
|
required this.text, |
||||
|
this.rate = 0, |
||||
|
this.pitch = 0, |
||||
|
this.volume = 100, |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,439 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:flutter/foundation.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:speech_to_text/speech_to_text.dart' as stt; |
||||
|
import 'package:speech_to_text/speech_recognition_result.dart'; |
||||
|
import 'package:speech_to_text/speech_recognition_error.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import '../asr_service.dart'; |
||||
|
|
||||
|
/// ASR状态枚举 |
||||
|
enum AsrState { |
||||
|
/// 未初始化 |
||||
|
notInitialized, |
||||
|
|
||||
|
/// 已初始化但未开始监听 |
||||
|
initialized, |
||||
|
|
||||
|
/// 正在监听 |
||||
|
listening, |
||||
|
|
||||
|
/// 正在处理识别结果 |
||||
|
processing, |
||||
|
|
||||
|
/// 识别完成 |
||||
|
done, |
||||
|
|
||||
|
/// 发生错误 |
||||
|
error, |
||||
|
} |
||||
|
|
||||
|
/// Flutter ASR服务,负责本地语音转文本功能 |
||||
|
/// 使用speech_to_text插件实现语音识别功能 |
||||
|
class FlutterAsrService extends GetxService implements AsrService { |
||||
|
static final FlutterAsrService to = Get.put(FlutterAsrService()); |
||||
|
|
||||
|
// speech_to_text 实例 |
||||
|
final stt.SpeechToText _speech = stt.SpeechToText(); |
||||
|
|
||||
|
// 可用的语音识别语言 |
||||
|
final List<String> _supportedLanguages = []; |
||||
|
@override |
||||
|
List<String> get supportedLanguages => _supportedLanguages; |
||||
|
|
||||
|
// 当前选择的语言 |
||||
|
final RxString _currentLocale = ''.obs; |
||||
|
String get currentLocale => _currentLocale.value; |
||||
|
|
||||
|
// 是否可用 |
||||
|
final RxBool _isAvailable = false.obs; |
||||
|
bool get isAvailable => _isAvailable.value; |
||||
|
|
||||
|
// 是否正在监听 |
||||
|
bool _isListening = false; |
||||
|
|
||||
|
// 当前ASR状态 |
||||
|
final Rx<AsrState> _asrState = AsrState.notInitialized.obs; |
||||
|
AsrState get asrState => _asrState.value; |
||||
|
|
||||
|
// 连续识别模式的结果流控制器 |
||||
|
StreamController<RecognitionEvent>? _continuousRecognitionController; |
||||
|
|
||||
|
FlutterAsrService() { |
||||
|
_initSpeech(); |
||||
|
} |
||||
|
|
||||
|
/// 初始化语音识别 |
||||
|
Future<void> _initSpeech() async { |
||||
|
try { |
||||
|
_asrState.value = AsrState.notInitialized; |
||||
|
|
||||
|
// 初始化语音识别,使用较短的最终超时时间,提高响应速度 |
||||
|
_isAvailable.value = await _speech.initialize( |
||||
|
onStatus: _onSpeechStatus, |
||||
|
onError: _onSpeechError, |
||||
|
debugLogging: false, |
||||
|
finalTimeout: const Duration(milliseconds: 800), // 更短的最终超时时间,加速结果返回 |
||||
|
); |
||||
|
|
||||
|
if (_isAvailable.value) { |
||||
|
// 获取支持的语言列表 |
||||
|
final systemLocales = await _speech.locales(); |
||||
|
|
||||
|
// 清空并重新填充支持的语言列表 |
||||
|
_supportedLanguages.clear(); |
||||
|
for (var locale in systemLocales) { |
||||
|
if (!_supportedLanguages.contains(locale.localeId)) { |
||||
|
_supportedLanguages.add(locale.localeId); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 设置默认语言(优先使用系统语言或中文) |
||||
|
var systemLocale = await _speech.systemLocale(); |
||||
|
if (systemLocale != null) { |
||||
|
_currentLocale.value = systemLocale.localeId; |
||||
|
} else if (_supportedLanguages.contains('zh-CN')) { |
||||
|
_currentLocale.value = 'zh-CN'; |
||||
|
} else if (_supportedLanguages.isNotEmpty) { |
||||
|
_currentLocale.value = _supportedLanguages.first; |
||||
|
} |
||||
|
|
||||
|
_asrState.value = AsrState.initialized; |
||||
|
Logger.info('语音识别初始化成功,支持 ${_supportedLanguages.length} 种语言'); |
||||
|
} else { |
||||
|
_asrState.value = AsrState.error; |
||||
|
Logger.error('语音识别不可用'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
_isAvailable.value = false; |
||||
|
_asrState.value = AsrState.error; |
||||
|
Logger.error('语音识别初始化失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理语音识别状态变化 |
||||
|
void _onSpeechStatus(String status) { |
||||
|
Logger.info('语音识别状态: $status'); |
||||
|
|
||||
|
switch (status) { |
||||
|
case 'listening': |
||||
|
_asrState.value = AsrState.listening; |
||||
|
_isListening = true; |
||||
|
break; |
||||
|
case 'notListening': |
||||
|
if (_asrState.value == AsrState.listening) { |
||||
|
_asrState.value = AsrState.processing; |
||||
|
} |
||||
|
_isListening = false; |
||||
|
break; |
||||
|
case 'done': |
||||
|
_asrState.value = AsrState.done; |
||||
|
_isListening = false; |
||||
|
|
||||
|
// 在连续识别模式下,当一次识别完成后立即开始下一次识别 |
||||
|
if (_continuousRecognitionController != null && !_continuousRecognitionController!.isClosed) { |
||||
|
_restartContinuousRecognition(); |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理语音识别错误 |
||||
|
void _onSpeechError(SpeechRecognitionError error) { |
||||
|
Logger.error('语音识别错误: ${error.errorMsg} (永久性错误: ${error.permanent})'); |
||||
|
_asrState.value = AsrState.error; |
||||
|
|
||||
|
// 向连续识别控制器发送错误事件(如果存在) |
||||
|
_continuousRecognitionController?.add( |
||||
|
RecognitionEvent.error(error.errorMsg) |
||||
|
); |
||||
|
|
||||
|
// 在连续识别模式下,尝试从错误中恢复(非永久性错误) |
||||
|
if (!error.permanent && _continuousRecognitionController != null && !_continuousRecognitionController!.isClosed) { |
||||
|
_restartContinuousRecognition(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 启动语音识别的统一方法 |
||||
|
Future<bool> _startListening({ |
||||
|
required Function(SpeechRecognitionResult) onResult, |
||||
|
Duration listenFor = const Duration(seconds: 30), |
||||
|
Duration pauseFor = const Duration(seconds: 3), |
||||
|
bool autoPunctuation = true, |
||||
|
}) async { |
||||
|
try { |
||||
|
// 如果当前正在监听,先停止 |
||||
|
if (_speech.isListening) { |
||||
|
await _speech.stop(); |
||||
|
// 短暂延迟确保资源释放 |
||||
|
await Future.delayed(const Duration(milliseconds: 300)); |
||||
|
} |
||||
|
|
||||
|
// 启动语音识别 |
||||
|
return await _speech.listen( |
||||
|
onResult: onResult, |
||||
|
localeId: _currentLocale.value, |
||||
|
listenFor: listenFor, |
||||
|
pauseFor: pauseFor, |
||||
|
listenOptions: stt.SpeechListenOptions( |
||||
|
partialResults: true, |
||||
|
autoPunctuation: autoPunctuation, |
||||
|
), |
||||
|
) ?? false; |
||||
|
} catch (e) { |
||||
|
Logger.error('启动语音识别失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 重新启动连续识别 |
||||
|
Future<void> _restartContinuousRecognition() async { |
||||
|
try { |
||||
|
// 确保控制器有效 |
||||
|
if (_continuousRecognitionController == null || _continuousRecognitionController!.isClosed) { |
||||
|
Logger.info('连续识别控制器已关闭,跳过重启'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 短暂延迟后再重新启动 |
||||
|
await Future.delayed(const Duration(milliseconds: 300)); |
||||
|
|
||||
|
// 使用统一方法启动识别 |
||||
|
final success = await _startListening( |
||||
|
onResult: _onSpeechResult, |
||||
|
); |
||||
|
|
||||
|
if (success) { |
||||
|
Logger.info('连续识别重启成功'); |
||||
|
} else { |
||||
|
Logger.error('连续识别重启失败'); |
||||
|
_continuousRecognitionController?.add( |
||||
|
RecognitionEvent.error('重启语音识别失败') |
||||
|
); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('重启连续识别失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理语音识别结果 |
||||
|
void _onSpeechResult(SpeechRecognitionResult result) { |
||||
|
// 如果是连续识别,发送到连续识别控制器 |
||||
|
if (_continuousRecognitionController != null) { |
||||
|
if (result.finalResult) { |
||||
|
Logger.info('收到最终识别结果: "${result.recognizedWords}"'); |
||||
|
_continuousRecognitionController!.add( |
||||
|
RecognitionEvent.finalResult( |
||||
|
text: result.recognizedWords, |
||||
|
detectedLanguage: currentLocale, |
||||
|
) |
||||
|
); |
||||
|
} else { |
||||
|
_continuousRecognitionController!.add( |
||||
|
RecognitionEvent( |
||||
|
type: RecognitionEventType.intermediateResult, |
||||
|
text: result.recognizedWords, |
||||
|
detectedLanguage: currentLocale, |
||||
|
) |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置当前语言 |
||||
|
void setLocale(String localeId) { |
||||
|
if (_supportedLanguages.contains(localeId)) { |
||||
|
_currentLocale.value = localeId; |
||||
|
Logger.info('设置语音识别语言: $localeId'); |
||||
|
} else { |
||||
|
Logger.error('不支持的语言: $localeId'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> initialize({ |
||||
|
List<String>? supportedLanguages, |
||||
|
}) async { |
||||
|
try { |
||||
|
if (supportedLanguages != null && supportedLanguages.isNotEmpty) { |
||||
|
_supportedLanguages.clear(); |
||||
|
_supportedLanguages.addAll(supportedLanguages); |
||||
|
} |
||||
|
|
||||
|
// 初始化语音识别 |
||||
|
await _initSpeech(); |
||||
|
return _isAvailable.value; |
||||
|
} catch (e) { |
||||
|
Logger.error('语音识别初始化失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<RecognitionEvent> recognizeOnce() async { |
||||
|
try { |
||||
|
if (!_isAvailable.value) { |
||||
|
return RecognitionEvent.error('语音识别不可用'); |
||||
|
} |
||||
|
|
||||
|
if (_isListening) { |
||||
|
await stopContinuousRecognition(); |
||||
|
} |
||||
|
|
||||
|
// 创建Completer来等待最终结果 |
||||
|
final completer = Completer<RecognitionEvent>(); |
||||
|
|
||||
|
// 开始监听 |
||||
|
_asrState.value = AsrState.listening; |
||||
|
|
||||
|
// 定义结果处理函数 |
||||
|
void resultListener(SpeechRecognitionResult result) { |
||||
|
if (result.finalResult) { |
||||
|
// 只有在最终结果时才完成completer |
||||
|
completer.complete(RecognitionEvent.finalResult( |
||||
|
text: result.recognizedWords, |
||||
|
detectedLanguage: currentLocale, |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 使用统一方法启动识别,单次识别使用更短的时间参数 |
||||
|
final success = await _startListening( |
||||
|
onResult: resultListener, |
||||
|
listenFor: const Duration(seconds: 15), |
||||
|
pauseFor: const Duration(seconds: 2), |
||||
|
); |
||||
|
|
||||
|
if (!success) { |
||||
|
return RecognitionEvent.error('启动语音识别失败'); |
||||
|
} |
||||
|
|
||||
|
// 设置较短的超时时间 |
||||
|
return await completer.future.timeout( |
||||
|
const Duration(seconds: 20), // 略长于listenFor以确保获取到结果 |
||||
|
onTimeout: () { |
||||
|
_speech.stop(); |
||||
|
return RecognitionEvent.error('语音识别超时'); |
||||
|
}, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
Logger.error('一次性语音识别失败: $e'); |
||||
|
return RecognitionEvent.error('$e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<Stream<RecognitionEvent>> startContinuousRecognition() async { |
||||
|
try { |
||||
|
if (!_isAvailable.value) { |
||||
|
final controller = StreamController<RecognitionEvent>.broadcast(); |
||||
|
controller.add(RecognitionEvent.error('语音识别不可用')); |
||||
|
controller.close(); |
||||
|
return controller.stream; |
||||
|
} |
||||
|
|
||||
|
// 如果已经在监听,先停止 |
||||
|
if (_isListening) { |
||||
|
await stopContinuousRecognition(); |
||||
|
} |
||||
|
|
||||
|
// 创建新的流控制器 |
||||
|
_continuousRecognitionController = StreamController<RecognitionEvent>.broadcast(); |
||||
|
|
||||
|
// 开始会话 |
||||
|
_continuousRecognitionController!.add( |
||||
|
RecognitionEvent(type: RecognitionEventType.sessionStarted) |
||||
|
); |
||||
|
|
||||
|
// 开始连续监听 |
||||
|
_asrState.value = AsrState.listening; |
||||
|
|
||||
|
// 使用统一方法启动识别,连续识别使用较长的参数 |
||||
|
final success = await _startListening( |
||||
|
onResult: _onSpeechResult, |
||||
|
); |
||||
|
|
||||
|
if (!success) { |
||||
|
// 添加错误事件,但保留流 |
||||
|
_continuousRecognitionController!.add( |
||||
|
RecognitionEvent.error('启动连续语音识别失败') |
||||
|
); |
||||
|
|
||||
|
// 返回已有的流控制器 |
||||
|
return _continuousRecognitionController!.stream; |
||||
|
} |
||||
|
|
||||
|
_isListening = true; |
||||
|
return _continuousRecognitionController!.stream; |
||||
|
} catch (e) { |
||||
|
Logger.error('启动连续语音识别失败: $e'); |
||||
|
|
||||
|
// 如果已经创建了控制器,使用它 |
||||
|
if (_continuousRecognitionController != null) { |
||||
|
_continuousRecognitionController!.add(RecognitionEvent.error('$e')); |
||||
|
return _continuousRecognitionController!.stream; |
||||
|
} else { |
||||
|
// 否则创建新的控制器 |
||||
|
final controller = StreamController<RecognitionEvent>.broadcast(); |
||||
|
controller.add(RecognitionEvent.error('$e')); |
||||
|
return controller.stream; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> stopContinuousRecognition() async { |
||||
|
try { |
||||
|
// 无论是否正在监听,都尝试停止 |
||||
|
if (_speech.isListening) { |
||||
|
await _speech.stop(); |
||||
|
} |
||||
|
|
||||
|
_isListening = false; |
||||
|
|
||||
|
// 发送会话结束事件 |
||||
|
_continuousRecognitionController?.add( |
||||
|
RecognitionEvent(type: RecognitionEventType.sessionStopped) |
||||
|
); |
||||
|
|
||||
|
// 关闭流控制器 |
||||
|
await _continuousRecognitionController?.close(); |
||||
|
_continuousRecognitionController = null; |
||||
|
|
||||
|
_asrState.value = AsrState.done; |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('停止连续语音识别失败: $e'); |
||||
|
|
||||
|
// 强制重置状态 |
||||
|
_isListening = false; |
||||
|
_asrState.value = AsrState.error; |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
bool isContinuousRecognitionActive() { |
||||
|
return _isListening; |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<void> dispose() async { |
||||
|
try { |
||||
|
// 确保停止所有识别活动 |
||||
|
if (_speech.isListening) { |
||||
|
await _speech.stop(); |
||||
|
} |
||||
|
|
||||
|
// 关闭控制器 |
||||
|
await _continuousRecognitionController?.close(); |
||||
|
_continuousRecognitionController = null; |
||||
|
|
||||
|
_isListening = false; |
||||
|
_asrState.value = AsrState.notInitialized; |
||||
|
} catch (e) { |
||||
|
Logger.error('释放语音识别资源失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,484 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:collection'; |
||||
|
import 'package:flutter/foundation.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:flutter_tts/flutter_tts.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import '../tts_service.dart'; |
||||
|
|
||||
|
/// Flutter TTS服务,负责基本的文本转语音功能 |
||||
|
/// |
||||
|
/// 使用flutter_tts插件实现TtsService接口 |
||||
|
class FlutterTtsService extends GetxService implements TtsService { |
||||
|
static final FlutterTtsService to = Get.put(FlutterTtsService()); |
||||
|
|
||||
|
// flutter_tts实例 |
||||
|
late final FlutterTts _flutterTts; |
||||
|
|
||||
|
// 支持的语音列表 |
||||
|
final List<String> _supportedVoices = []; |
||||
|
List<String> get supportedVoices => _supportedVoices; |
||||
|
|
||||
|
// 当前语音 |
||||
|
final RxString _currentVoice = ''.obs; |
||||
|
@override |
||||
|
String get currentVoice => _currentVoice.value; |
||||
|
|
||||
|
// 播放状态 |
||||
|
final RxBool _isSpeaking = false.obs; |
||||
|
@override |
||||
|
bool get isSpeaking => _isSpeaking.value; |
||||
|
|
||||
|
// 文本队列 |
||||
|
final Queue<_SpeechItem> _textQueue = Queue<_SpeechItem>(); |
||||
|
bool _isProcessingQueue = false; |
||||
|
|
||||
|
// 事件流控制器 |
||||
|
final StreamController<TtsEvent> _eventController = StreamController<TtsEvent>.broadcast(); |
||||
|
@override |
||||
|
Stream<TtsEvent> get onEvent => _eventController.stream; |
||||
|
|
||||
|
// 语言 |
||||
|
String _language = 'zh-CN'; |
||||
|
|
||||
|
// 流式文本缓冲区 |
||||
|
String _streamBuffer = ''; |
||||
|
|
||||
|
// 当前参数设置 |
||||
|
double _volume = 1.0; |
||||
|
double _rate = 0.5; |
||||
|
double _pitch = 1.0; |
||||
|
|
||||
|
FlutterTtsService() { |
||||
|
_flutterTts = FlutterTts(); |
||||
|
_initTts(); |
||||
|
} |
||||
|
|
||||
|
/// 初始化TTS |
||||
|
Future<void> _initTts() async { |
||||
|
try { |
||||
|
// 设置完成回调 |
||||
|
_flutterTts.setCompletionHandler(() { |
||||
|
_isSpeaking.value = false; |
||||
|
_eventController.add(TtsEvent(type: TtsEventType.completed)); |
||||
|
}); |
||||
|
|
||||
|
// 设置开始回调 |
||||
|
_flutterTts.setStartHandler(() { |
||||
|
_isSpeaking.value = true; |
||||
|
_eventController.add(TtsEvent(type: TtsEventType.started)); |
||||
|
}); |
||||
|
|
||||
|
// 设置错误回调 |
||||
|
_flutterTts.setErrorHandler((msg) { |
||||
|
_isSpeaking.value = false; |
||||
|
_eventController.add(TtsEvent.error(msg)); |
||||
|
Logger.error('TTS错误: $msg'); |
||||
|
}); |
||||
|
|
||||
|
// 设置取消回调 |
||||
|
_flutterTts.setCancelHandler(() { |
||||
|
_isSpeaking.value = false; |
||||
|
_eventController.add(TtsEvent(type: TtsEventType.canceled)); |
||||
|
}); |
||||
|
|
||||
|
// 设置默认语言 |
||||
|
await _flutterTts.setLanguage(_language); |
||||
|
|
||||
|
// 设置音量 |
||||
|
await _flutterTts.setVolume(_volume); |
||||
|
|
||||
|
// 设置语速 |
||||
|
await _flutterTts.setSpeechRate(_rate); |
||||
|
|
||||
|
// 设置音调 |
||||
|
await _flutterTts.setPitch(_pitch); |
||||
|
|
||||
|
Logger.info('FlutterTtsService 已初始化'); |
||||
|
} catch (e) { |
||||
|
Logger.error('TTS初始化失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> initialize({ |
||||
|
List<String>? supportedLanguages, |
||||
|
}) async { |
||||
|
try { |
||||
|
// 获取可用的语音 |
||||
|
await _loadVoices(); |
||||
|
|
||||
|
// 更新支持的语言列表(如果提供) |
||||
|
if (supportedLanguages != null && supportedLanguages.isNotEmpty) { |
||||
|
_language = supportedLanguages.first; |
||||
|
await _flutterTts.setLanguage(_language); |
||||
|
} |
||||
|
|
||||
|
Logger.info('FlutterTtsService 初始化成功'); |
||||
|
Logger.info('支持的语音: ${_supportedVoices.join(", ")}'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('TTS初始化失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 加载可用的语音 |
||||
|
Future<void> _loadVoices() async { |
||||
|
try { |
||||
|
_supportedVoices.clear(); |
||||
|
|
||||
|
// 获取可用的语音 |
||||
|
final voices = await _flutterTts.getVoices; |
||||
|
|
||||
|
if (voices != null && voices is List) { |
||||
|
for (final voice in voices) { |
||||
|
if (voice is Map && voice.containsKey('name')) { |
||||
|
_supportedVoices.add(voice['name'].toString()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 如果没有找到语音,添加默认语音 |
||||
|
if (_supportedVoices.isEmpty) { |
||||
|
_supportedVoices.add('default'); |
||||
|
} |
||||
|
|
||||
|
// 设置默认语音 |
||||
|
if (_currentVoice.value.isEmpty && _supportedVoices.isNotEmpty) { |
||||
|
_currentVoice.value = _supportedVoices.first; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('加载TTS语音失败: $e'); |
||||
|
|
||||
|
// 添加默认语音 |
||||
|
if (_supportedVoices.isEmpty) { |
||||
|
_supportedVoices.add('default'); |
||||
|
_currentVoice.value = 'default'; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> setVoice(String voiceName) async { |
||||
|
try { |
||||
|
// 检查语音是否存在 |
||||
|
if (!_supportedVoices.contains(voiceName) && voiceName != 'default') { |
||||
|
Logger.warning('语音不存在: $voiceName,使用默认语音'); |
||||
|
voiceName = _supportedVoices.isNotEmpty ? _supportedVoices.first : 'default'; |
||||
|
} |
||||
|
|
||||
|
// 设置语音 |
||||
|
await _flutterTts.setVoice({"name": voiceName}); |
||||
|
_currentVoice.value = voiceName; |
||||
|
|
||||
|
Logger.info('设置TTS语音: $voiceName'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('设置TTS语音失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 设置语音参数 |
||||
|
Future<bool> setSpeechParams({double? rate, double? pitch, double? volume}) async { |
||||
|
try { |
||||
|
if (volume != null) { |
||||
|
await _flutterTts.setVolume(volume); |
||||
|
_volume = volume; |
||||
|
} |
||||
|
|
||||
|
if (rate != null) { |
||||
|
await _flutterTts.setSpeechRate(rate); |
||||
|
_rate = rate; |
||||
|
} |
||||
|
|
||||
|
if (pitch != null) { |
||||
|
await _flutterTts.setPitch(pitch); |
||||
|
_pitch = pitch; |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('设置语音参数失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error('设置语音参数失败: $e')); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> speakOnce(String text) async { |
||||
|
if (text.isEmpty) return false; |
||||
|
|
||||
|
try { |
||||
|
// 如果正在播放,先停止 |
||||
|
if (_isSpeaking.value) { |
||||
|
await stop(); |
||||
|
} |
||||
|
|
||||
|
// 播放文本 |
||||
|
await _flutterTts.speak(text); |
||||
|
|
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error(e.toString())); |
||||
|
|
||||
|
Logger.error('TTS播放失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> speakStream(String text) async { |
||||
|
if (text.isEmpty) return false; |
||||
|
|
||||
|
try { |
||||
|
// 添加文本到缓冲区 |
||||
|
_streamBuffer += text; |
||||
|
|
||||
|
// 如果缓冲区为空,直接返回 |
||||
|
if (_streamBuffer.isEmpty) return true; |
||||
|
|
||||
|
// 使用正则表达式匹配句子,包括结束符号 |
||||
|
// 匹配任意字符,直到遇到句子结束符号 |
||||
|
final sentenceRegex = RegExp( |
||||
|
r'([^。.!!??;;:\n\r]+[。.!!??;;::\n\r]|[^。.!!??;;:\n\r]+(?:\.{3,}|…)|[^。.!!??;;:\n\r]+["」』])' |
||||
|
); |
||||
|
|
||||
|
bool hasProcessed = false; |
||||
|
|
||||
|
// 查找所有完整句子 |
||||
|
final matches = sentenceRegex.allMatches(_streamBuffer); |
||||
|
final List<String> sentences = []; |
||||
|
int lastMatchEnd = 0; |
||||
|
|
||||
|
for (final match in matches) { |
||||
|
// 提取完整句子(包含结束符号) |
||||
|
final sentence = match.group(1)?.trim(); |
||||
|
if (sentence != null && sentence.isNotEmpty) { |
||||
|
sentences.add(sentence); |
||||
|
lastMatchEnd = match.end; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 处理找到的句子 |
||||
|
for (final sentence in sentences) { |
||||
|
_textQueue.add(_SpeechItem( |
||||
|
text: sentence, |
||||
|
rate: _rate, |
||||
|
pitch: _pitch, |
||||
|
volume: _volume, |
||||
|
)); |
||||
|
hasProcessed = true; |
||||
|
} |
||||
|
|
||||
|
// 更新缓冲区,只保留未完成的部分 |
||||
|
if (lastMatchEnd > 0) { |
||||
|
_streamBuffer = _streamBuffer.substring(lastMatchEnd); |
||||
|
} |
||||
|
|
||||
|
// 如果处理了文本并且队列未在处理中,开始处理队列 |
||||
|
if (hasProcessed && !_isProcessingQueue) { |
||||
|
_processQueue(); |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('处理流式文本失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error(e.toString())); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> flushStream() async { |
||||
|
if (_textQueue.isEmpty && _streamBuffer.isEmpty) return true; |
||||
|
|
||||
|
try { |
||||
|
// 处理缓冲区中可能的完整句子 |
||||
|
await speakStream(''); |
||||
|
|
||||
|
// 如果缓冲区仍有剩余文本,将其添加到播放队列 |
||||
|
if (_streamBuffer.isNotEmpty && _streamBuffer.trim().isNotEmpty) { |
||||
|
_textQueue.add(_SpeechItem( |
||||
|
text: _streamBuffer, |
||||
|
rate: _rate, |
||||
|
pitch: _pitch, |
||||
|
volume: _volume, |
||||
|
)); |
||||
|
|
||||
|
// 清空缓冲区 |
||||
|
_streamBuffer = ''; |
||||
|
|
||||
|
// 如果队列未在处理中,开始处理队列 |
||||
|
if (!_isProcessingQueue) { |
||||
|
_processQueue(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Logger.info('等待TTS队列播放完成,剩余${_textQueue.length}条'); |
||||
|
|
||||
|
// 等待队列处理完成 |
||||
|
while (_isProcessingQueue || _isSpeaking.value) { |
||||
|
await Future.delayed(const Duration(milliseconds: 100)); |
||||
|
} |
||||
|
|
||||
|
Logger.info('TTS队列播放完成'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('等待TTS队列播放完成失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error(e.toString())); |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理队列中的文本 |
||||
|
Future<void> _processQueue() async { |
||||
|
if (_isProcessingQueue) return; |
||||
|
|
||||
|
_isProcessingQueue = true; |
||||
|
|
||||
|
try { |
||||
|
while (_textQueue.isNotEmpty) { |
||||
|
if (!_isSpeaking.value) { |
||||
|
final item = _textQueue.removeFirst(); |
||||
|
|
||||
|
// 如果参数有变化,更新参数 |
||||
|
if (item.rate != _rate || item.pitch != _pitch || item.volume != _volume) { |
||||
|
await setSpeechParams( |
||||
|
rate: item.rate, |
||||
|
pitch: item.pitch, |
||||
|
volume: item.volume, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 播放文本 |
||||
|
await _flutterTts.speak(item.text); |
||||
|
|
||||
|
// 等待开始播放 |
||||
|
int attempts = 0; |
||||
|
while (!_isSpeaking.value && attempts < 10) { |
||||
|
await Future.delayed(const Duration(milliseconds: 50)); |
||||
|
attempts++; |
||||
|
} |
||||
|
|
||||
|
// 等待播放完成 |
||||
|
while (_isSpeaking.value) { |
||||
|
await Future.delayed(const Duration(milliseconds: 100)); |
||||
|
} |
||||
|
} else { |
||||
|
// 等待一小段时间再检查队列 |
||||
|
await Future.delayed(const Duration(milliseconds: 100)); |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('TTS处理队列失败: $e'); |
||||
|
|
||||
|
// 发送错误事件 |
||||
|
_eventController.add(TtsEvent.error(e.toString())); |
||||
|
} finally { |
||||
|
_isProcessingQueue = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> stop() async { |
||||
|
try { |
||||
|
Logger.info('尝试停止TTS播放...'); |
||||
|
|
||||
|
// 清空队列 |
||||
|
_textQueue.clear(); |
||||
|
_streamBuffer = ''; |
||||
|
|
||||
|
// 如果正在播放,停止播放 |
||||
|
if (_isSpeaking.value) { |
||||
|
// 先重置状态标志,避免处理队列循环继续等待 |
||||
|
_isProcessingQueue = false; |
||||
|
|
||||
|
// 执行停止 |
||||
|
final result = await _flutterTts.stop(); |
||||
|
Logger.info('TTS停止结果: $result'); |
||||
|
|
||||
|
// 短暂延迟等待状态更新 |
||||
|
await Future.delayed(const Duration(milliseconds: 100)); |
||||
|
|
||||
|
// 强制更新状态 |
||||
|
_isSpeaking.value = false; |
||||
|
|
||||
|
// 发送取消事件 |
||||
|
_eventController.add(TtsEvent(type: TtsEventType.canceled)); |
||||
|
} |
||||
|
|
||||
|
Logger.info('TTS停止播放完成'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('TTS停止播放失败: $e'); |
||||
|
|
||||
|
// 即使出错也尝试强制重置状态 |
||||
|
_isSpeaking.value = false; |
||||
|
_isProcessingQueue = false; |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<void> dispose() async { |
||||
|
try { |
||||
|
await stop(); |
||||
|
_textQueue.clear(); |
||||
|
|
||||
|
// 关闭事件流控制器 |
||||
|
await _eventController.close(); |
||||
|
|
||||
|
Logger.info('TTS资源已释放'); |
||||
|
} catch (e) { |
||||
|
Logger.error('释放TTS资源失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理AI返回的文本流,将其转换为语音 |
||||
|
/// |
||||
|
/// [textStream] 文本流 |
||||
|
/// 返回一个Future,当流处理完成时完成 |
||||
|
Future<bool> processAiTextStream(Stream<String> textStream) async { |
||||
|
try { |
||||
|
await for (final text in textStream) { |
||||
|
if (text.isEmpty) continue; |
||||
|
await speakStream(text); |
||||
|
} |
||||
|
|
||||
|
// 处理完流后,刷新缓冲区中的剩余文本 |
||||
|
await flushStream(); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('处理文本流时出错: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 语音合成项目 |
||||
|
class _SpeechItem { |
||||
|
final String text; |
||||
|
final double rate; |
||||
|
final double pitch; |
||||
|
final double volume; |
||||
|
|
||||
|
_SpeechItem({ |
||||
|
required this.text, |
||||
|
this.rate = 0.5, |
||||
|
this.pitch = 1.0, |
||||
|
this.volume = 1.0, |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,807 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:convert'; |
||||
|
import 'dart:io'; |
||||
|
import 'dart:math'; |
||||
|
import 'dart:typed_data'; |
||||
|
import 'package:crypto/crypto.dart'; |
||||
|
import 'package:flutter/foundation.dart' show kIsWeb; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:uuid/uuid.dart'; |
||||
|
import 'package:web_socket_channel/io.dart'; |
||||
|
import 'package:web_socket_channel/web_socket_channel.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import 'package:record/record.dart'; |
||||
|
import '../asr_service.dart'; |
||||
|
|
||||
|
/// Volcano语音识别服务 |
||||
|
/// |
||||
|
/// 负责与火山引擎实时语音识别服务的通信 |
||||
|
class VolcanoAsrApiService implements AsrService { |
||||
|
static final VolcanoAsrApiService to = Get.find<VolcanoAsrApiService>(); |
||||
|
|
||||
|
// API配置 |
||||
|
late final String _appKey; |
||||
|
late final String _secretKey; |
||||
|
late final String _serviceId; |
||||
|
late final String _hostUrl; |
||||
|
final String _path = '/ws/v1/asr'; |
||||
|
|
||||
|
// 请求相关参数 |
||||
|
final String _algorithm = 'hmac-sha1'; |
||||
|
final Uuid _uuid = const Uuid(); |
||||
|
|
||||
|
// 录音配置 |
||||
|
final int _sampleRate = 16000; |
||||
|
final int _bitsPerSample = 16; |
||||
|
final int _channels = 1; |
||||
|
final String _encoding = 'pcm'; |
||||
|
|
||||
|
// 连接相关 |
||||
|
WebSocketChannel? _channel; |
||||
|
Completer<void>? _connectionCompleter; |
||||
|
StreamSubscription? _connectionSubscription; |
||||
|
Timer? _heartbeatTimer; |
||||
|
|
||||
|
// 语音录音 |
||||
|
final Record? _recorder = null; // TODO: 实现正确的Record实例化 |
||||
|
Timer? _audioTimer; |
||||
|
bool _isRecording = false; |
||||
|
|
||||
|
// 状态控制 |
||||
|
bool _isInitialized = false; |
||||
|
bool _isConnecting = false; |
||||
|
bool _isContinuousRecognitionActive = false; |
||||
|
|
||||
|
// 识别结果控制 |
||||
|
bool _isRecognitionEndRequested = false; |
||||
|
String _currentSessionId = ''; |
||||
|
final List<String> _supportedLanguages = ['zh-CN', 'en-US']; |
||||
|
String _selectedLanguage = 'zh-CN'; |
||||
|
|
||||
|
// 结果流 |
||||
|
StreamController<RecognitionEvent>? _recognitionController; |
||||
|
|
||||
|
// 事件计数器(用于调试) |
||||
|
int _sendPacketCount = 0; |
||||
|
int _receivePacketCount = 0; |
||||
|
|
||||
|
// AsrService接口实现 |
||||
|
@override |
||||
|
List<String> get supportedLanguages => _supportedLanguages; |
||||
|
|
||||
|
@override |
||||
|
Future<bool> initialize({ |
||||
|
List<String>? supportedLanguages, |
||||
|
}) async { |
||||
|
try { |
||||
|
// 如果已经初始化过,先释放资源 |
||||
|
if (_isInitialized) { |
||||
|
await dispose(); |
||||
|
} |
||||
|
|
||||
|
_isInitialized = false; |
||||
|
|
||||
|
// 从环境变量加载配置 |
||||
|
_loadConfig(); |
||||
|
|
||||
|
// 设置支持的语言 |
||||
|
if (supportedLanguages != null && supportedLanguages.isNotEmpty) { |
||||
|
_supportedLanguages.clear(); |
||||
|
_supportedLanguages.addAll(supportedLanguages); |
||||
|
} |
||||
|
|
||||
|
// 初始化录音机 |
||||
|
await _initRecorder(); |
||||
|
|
||||
|
_isInitialized = true; |
||||
|
Logger.info('火山语音API已初始化'); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('初始化火山语音API失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<RecognitionEvent> recognizeOnce() async { |
||||
|
try { |
||||
|
if (!_isInitialized) { |
||||
|
await initialize(); |
||||
|
} |
||||
|
|
||||
|
final resultCompleter = Completer<RecognitionEvent>(); |
||||
|
|
||||
|
// 开始连续识别流程 |
||||
|
final stream = await startContinuousRecognition(); |
||||
|
|
||||
|
// 监听结果 |
||||
|
StreamSubscription? subscription; |
||||
|
subscription = stream.listen((event) { |
||||
|
if (event.type == RecognitionEventType.finalResult) { |
||||
|
// 收到最终结果,完成单次识别 |
||||
|
subscription?.cancel(); |
||||
|
resultCompleter.complete(event); |
||||
|
stopContinuousRecognition(); |
||||
|
} else if (event.type == RecognitionEventType.error) { |
||||
|
// 发生错误,返回错误 |
||||
|
subscription?.cancel(); |
||||
|
resultCompleter.completeError(event.error); |
||||
|
stopContinuousRecognition(); |
||||
|
} |
||||
|
}, onError: (e) { |
||||
|
subscription?.cancel(); |
||||
|
resultCompleter.completeError(e.toString()); |
||||
|
stopContinuousRecognition(); |
||||
|
}, onDone: () { |
||||
|
if (!resultCompleter.isCompleted) { |
||||
|
resultCompleter.complete(RecognitionEvent.error('未收到识别结果')); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// 设置超时 |
||||
|
Timer(const Duration(seconds: 30), () { |
||||
|
if (!resultCompleter.isCompleted) { |
||||
|
subscription?.cancel(); |
||||
|
resultCompleter.complete(RecognitionEvent.error('识别超时')); |
||||
|
stopContinuousRecognition(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return resultCompleter.future; |
||||
|
} catch (e) { |
||||
|
Logger.error('单次语音识别失败: $e'); |
||||
|
return RecognitionEvent.error(e.toString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<Stream<RecognitionEvent>> startContinuousRecognition() async { |
||||
|
if (!_isInitialized) { |
||||
|
await initialize(); |
||||
|
} |
||||
|
|
||||
|
if (_isContinuousRecognitionActive) { |
||||
|
await stopContinuousRecognition(); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// 创建结果控制器 |
||||
|
_recognitionController = StreamController<RecognitionEvent>.broadcast(); |
||||
|
|
||||
|
// 连接WebSocket |
||||
|
await _connectWebSocket(); |
||||
|
|
||||
|
// 开始录音 |
||||
|
await _startRecording(); |
||||
|
|
||||
|
_isContinuousRecognitionActive = true; |
||||
|
_isRecognitionEndRequested = false; |
||||
|
|
||||
|
Logger.info('连续语音识别已开始'); |
||||
|
|
||||
|
return _recognitionController!.stream; |
||||
|
} catch (e) { |
||||
|
Logger.error('开始连续语音识别失败: $e'); |
||||
|
await _cleanupResources(); |
||||
|
|
||||
|
// 返回一个包含错误的流 |
||||
|
final errorController = StreamController<RecognitionEvent>.broadcast(); |
||||
|
errorController.add(RecognitionEvent.error(e.toString())); |
||||
|
errorController.close(); |
||||
|
|
||||
|
return errorController.stream; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<bool> stopContinuousRecognition() async { |
||||
|
if (!_isContinuousRecognitionActive) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
_isRecognitionEndRequested = true; |
||||
|
|
||||
|
// 先停止录音,以确保不会再发送新的音频数据 |
||||
|
await _stopRecording(); |
||||
|
|
||||
|
// 发送结束指令 |
||||
|
await _sendEndSignal(); |
||||
|
|
||||
|
// 等待服务器处理完所有已发送的数据 |
||||
|
await Future.delayed(const Duration(milliseconds: 500)); |
||||
|
|
||||
|
// 清理资源 |
||||
|
await _cleanupResources(); |
||||
|
|
||||
|
_isContinuousRecognitionActive = false; |
||||
|
Logger.info('连续语音识别已停止'); |
||||
|
|
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.error('停止连续语音识别失败: $e'); |
||||
|
|
||||
|
// 确保资源被清理 |
||||
|
await _cleanupResources(); |
||||
|
_isContinuousRecognitionActive = false; |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
bool isContinuousRecognitionActive() { |
||||
|
return _isContinuousRecognitionActive; |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Future<void> dispose() async { |
||||
|
try { |
||||
|
if (_isContinuousRecognitionActive) { |
||||
|
await stopContinuousRecognition(); |
||||
|
} |
||||
|
|
||||
|
// 确保资源被清理 |
||||
|
await _cleanupResources(); |
||||
|
|
||||
|
_isInitialized = false; |
||||
|
Logger.info('火山语音API资源已释放'); |
||||
|
} catch (e) { |
||||
|
Logger.error('释放火山语音API资源失败: $e'); |
||||
|
_isContinuousRecognitionActive = false; |
||||
|
_isInitialized = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 从环境变量加载配置 |
||||
|
void _loadConfig() { |
||||
|
_appKey = dotenv.env['VOLCANO_APP_KEY'] ?? ''; |
||||
|
_secretKey = dotenv.env['VOLCANO_SECRET_KEY'] ?? ''; |
||||
|
_serviceId = dotenv.env['VOLCANO_SERVICE_ID'] ?? ''; |
||||
|
_hostUrl = dotenv.env['VOLCANO_HOST_URL'] ?? 'ws-api-asr.volces.com'; |
||||
|
|
||||
|
if (_appKey.isEmpty || _secretKey.isEmpty || _serviceId.isEmpty) { |
||||
|
throw Exception('未找到火山语音API配置,请在.env文件中设置VOLCANO_APP_KEY, VOLCANO_SECRET_KEY和VOLCANO_SERVICE_ID'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 初始化录音机 |
||||
|
Future<void> _initRecorder() async { |
||||
|
try { |
||||
|
// TODO: 正确实现录音机初始化 |
||||
|
// _recorder = Record(); |
||||
|
|
||||
|
// 检查和请求权限 |
||||
|
// final hasPermission = await _recorder!.hasPermission(); |
||||
|
// if (!hasPermission) { |
||||
|
// throw Exception('没有录音权限'); |
||||
|
// } |
||||
|
|
||||
|
Logger.info('录音机初始化成功'); |
||||
|
} catch (e) { |
||||
|
// _recorder = null; |
||||
|
Logger.error('初始化录音机失败: $e'); |
||||
|
rethrow; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 连接WebSocket |
||||
|
Future<void> _connectWebSocket() async { |
||||
|
if (_isConnecting) { |
||||
|
// 已经有一个连接进程在进行中 |
||||
|
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) { |
||||
|
return _connectionCompleter!.future; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
_connectionCompleter = Completer<void>(); |
||||
|
_isConnecting = true; |
||||
|
|
||||
|
try { |
||||
|
// 关闭现有连接 |
||||
|
await _closeWebSocket(); |
||||
|
|
||||
|
// 生成新的会话ID |
||||
|
_currentSessionId = _uuid.v4(); |
||||
|
|
||||
|
// 构建URL和请求头 |
||||
|
final (url, headers) = _buildConnectionParams(); |
||||
|
|
||||
|
// 创建WebSocket连接 |
||||
|
if (kIsWeb) { |
||||
|
_channel = WebSocketChannel.connect(Uri.parse(url)); |
||||
|
} else { |
||||
|
_channel = IOWebSocketChannel.connect( |
||||
|
Uri.parse(url), |
||||
|
headers: headers, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 处理WebSocket消息 |
||||
|
_connectionSubscription = _channel!.stream.listen( |
||||
|
_handleWebSocketMessage, |
||||
|
onError: _handleWebSocketError, |
||||
|
onDone: _handleWebSocketDone, |
||||
|
); |
||||
|
|
||||
|
// 开始心跳 |
||||
|
_startHeartbeat(); |
||||
|
|
||||
|
// 发送开始识别指令 |
||||
|
await _sendStartCommand(); |
||||
|
|
||||
|
_isConnecting = false; |
||||
|
_connectionCompleter!.complete(); |
||||
|
|
||||
|
Logger.info('WebSocket连接成功'); |
||||
|
} catch (e) { |
||||
|
_isConnecting = false; |
||||
|
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) { |
||||
|
_connectionCompleter!.completeError(e); |
||||
|
} |
||||
|
|
||||
|
Logger.error('连接WebSocket失败: $e'); |
||||
|
rethrow; |
||||
|
} |
||||
|
|
||||
|
return _connectionCompleter!.future; |
||||
|
} |
||||
|
|
||||
|
/// 构建连接参数 |
||||
|
(String, Map<String, String>?) _buildConnectionParams() { |
||||
|
final timestamp = (DateTime.now().millisecondsSinceEpoch / 1000).floor(); |
||||
|
final nonce = _generateNonce(); |
||||
|
|
||||
|
// 构建表单(用于签名和查询参数) |
||||
|
final form = { |
||||
|
'algorithm': _algorithm, |
||||
|
'app_key': _appKey, |
||||
|
'nonce': nonce, |
||||
|
'service_id': _serviceId, |
||||
|
'session_id': _currentSessionId, |
||||
|
'timestamp': timestamp.toString(), |
||||
|
}; |
||||
|
|
||||
|
// 计算签名 |
||||
|
final signature = _calculateSignature(form); |
||||
|
|
||||
|
// 添加签名到表单 |
||||
|
form['signature'] = signature; |
||||
|
|
||||
|
// 构建URL查询参数 |
||||
|
final queryString = form.entries |
||||
|
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}') |
||||
|
.join('&'); |
||||
|
|
||||
|
// 构建完整URL |
||||
|
final url = 'wss://$_hostUrl$_path?$queryString'; |
||||
|
|
||||
|
// 构建请求头 |
||||
|
final headers = <String, String>{ |
||||
|
'User-Agent': 'Deep Voice App/1.0.0', |
||||
|
}; |
||||
|
|
||||
|
return (url, kIsWeb ? null : headers); |
||||
|
} |
||||
|
|
||||
|
/// 生成随机数 |
||||
|
String _generateNonce() { |
||||
|
final random = Random(); |
||||
|
final randoms = List.generate(16, (_) => random.nextInt(256)); |
||||
|
return base64Url.encode(randoms).substring(0, 16); |
||||
|
} |
||||
|
|
||||
|
/// 计算签名 |
||||
|
String _calculateSignature(Map<String, String> form) { |
||||
|
// 按键排序 |
||||
|
final sortedKeys = form.keys.toList()..sort(); |
||||
|
|
||||
|
// 构建待签名字符串 |
||||
|
final signString = sortedKeys.map((key) => '$key=${form[key]}').join(''); |
||||
|
|
||||
|
// 使用HMAC-SHA1进行签名 |
||||
|
final key = utf8.encode(_secretKey); |
||||
|
final bytes = utf8.encode(signString); |
||||
|
final hmacSha1 = Hmac(sha1, key); |
||||
|
final digest = hmacSha1.convert(bytes); |
||||
|
|
||||
|
// 返回Base64编码的签名 |
||||
|
return base64.encode(digest.bytes); |
||||
|
} |
||||
|
|
||||
|
/// 开始心跳定时 |
||||
|
void _startHeartbeat() { |
||||
|
_heartbeatTimer?.cancel(); |
||||
|
_heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (_) { |
||||
|
_sendHeartbeat(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 发送心跳包 |
||||
|
void _sendHeartbeat() { |
||||
|
if (_channel != null && _isContinuousRecognitionActive) { |
||||
|
try { |
||||
|
final heartbeatMsg = { |
||||
|
'type': 'heartbeat', |
||||
|
'sequence': _sendPacketCount++, |
||||
|
}; |
||||
|
|
||||
|
_channel!.sink.add(jsonEncode(heartbeatMsg)); |
||||
|
} catch (e) { |
||||
|
Logger.error('发送心跳包失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 发送开始识别指令 |
||||
|
Future<void> _sendStartCommand() async { |
||||
|
if (_channel == null) return; |
||||
|
|
||||
|
try { |
||||
|
final startCommand = { |
||||
|
'type': 'start', |
||||
|
'sequence': _sendPacketCount++, |
||||
|
'asr_params': { |
||||
|
'sample_rate': _sampleRate, |
||||
|
'bits_per_sample': _bitsPerSample, |
||||
|
'channel': _channels, |
||||
|
'encoding': _encoding, |
||||
|
'audio_format': 'raw', |
||||
|
'lang': _selectedLanguage, |
||||
|
'vad_enable': true, |
||||
|
'vad_pause_time': 500, |
||||
|
'max_seconds': 60, |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
_channel!.sink.add(jsonEncode(startCommand)); |
||||
|
Logger.info('已发送开始识别指令'); |
||||
|
} catch (e) { |
||||
|
Logger.error('发送开始识别指令失败: $e'); |
||||
|
rethrow; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 开始录音 |
||||
|
Future<void> _startRecording() async { |
||||
|
// TODO: 实现正确的录音开始逻辑 |
||||
|
/* |
||||
|
if (_recorder == null) { |
||||
|
await _initRecorder(); |
||||
|
} |
||||
|
|
||||
|
if (_isRecording) { |
||||
|
await _stopRecording(); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// 开始录音 |
||||
|
await _recorder!.start( |
||||
|
encoder: AudioEncoder.pcm16bits, |
||||
|
samplingRate: _sampleRate, |
||||
|
numChannels: _channels, |
||||
|
); |
||||
|
|
||||
|
_isRecording = true; |
||||
|
|
||||
|
// 设置定时器定期获取音频数据 |
||||
|
_audioTimer = Timer.periodic(const Duration(milliseconds: 200), (_) async { |
||||
|
await _captureAndSendAudio(); |
||||
|
}); |
||||
|
|
||||
|
Logger.info('录音已开始'); |
||||
|
} catch (e) { |
||||
|
_isRecording = false; |
||||
|
Logger.error('开始录音失败: $e'); |
||||
|
rethrow; |
||||
|
} |
||||
|
*/ |
||||
|
|
||||
|
// 临时实现,防止错误 |
||||
|
_isRecording = true; |
||||
|
Logger.info('录音已开始(模拟)'); |
||||
|
} |
||||
|
|
||||
|
/// 捕获并发送音频数据 |
||||
|
Future<void> _captureAndSendAudio() async { |
||||
|
// TODO: 实现正确的音频捕获逻辑 |
||||
|
/* |
||||
|
if (!_isRecording || _recorder == null || _channel == null) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
Uint8List? audioData; |
||||
|
|
||||
|
if (Platform.isIOS) { |
||||
|
// iOS 使用文件缓冲区 |
||||
|
final path = await _recorder!.stop(); |
||||
|
if (path == null) return; |
||||
|
|
||||
|
// 读取文件内容 |
||||
|
final file = File(path); |
||||
|
if (await file.exists()) { |
||||
|
audioData = await file.readAsBytes(); |
||||
|
|
||||
|
// 重新开始录音 |
||||
|
await _recorder!.start( |
||||
|
encoder: AudioEncoder.pcm16bits, |
||||
|
samplingRate: _sampleRate, |
||||
|
numChannels: _channels, |
||||
|
); |
||||
|
} |
||||
|
} else { |
||||
|
// Android 使用stream获取PCM数据 |
||||
|
// 还未实现 |
||||
|
} |
||||
|
|
||||
|
// 如果有音频数据,发送给服务器 |
||||
|
if (audioData != null && audioData.isNotEmpty) { |
||||
|
await _sendAudioData(audioData); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('捕获和发送音频失败: $e'); |
||||
|
} |
||||
|
*/ |
||||
|
} |
||||
|
|
||||
|
/// 发送音频数据 |
||||
|
Future<void> _sendAudioData(Uint8List data) async { |
||||
|
if (_channel == null || data.isEmpty) return; |
||||
|
|
||||
|
try { |
||||
|
final audioMessage = { |
||||
|
'type': 'audio', |
||||
|
'sequence': _sendPacketCount++, |
||||
|
'audio': base64.encode(data), |
||||
|
}; |
||||
|
|
||||
|
_channel!.sink.add(jsonEncode(audioMessage)); |
||||
|
} catch (e) { |
||||
|
Logger.error('发送音频数据失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 发送结束信号 |
||||
|
Future<void> _sendEndSignal() async { |
||||
|
if (_channel == null) return; |
||||
|
|
||||
|
try { |
||||
|
final endCommand = { |
||||
|
'type': 'end', |
||||
|
'sequence': _sendPacketCount++, |
||||
|
}; |
||||
|
|
||||
|
_channel!.sink.add(jsonEncode(endCommand)); |
||||
|
Logger.info('已发送结束指令'); |
||||
|
} catch (e) { |
||||
|
Logger.error('发送结束指令失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止录音 |
||||
|
Future<void> _stopRecording() async { |
||||
|
_audioTimer?.cancel(); |
||||
|
_audioTimer = null; |
||||
|
|
||||
|
// TODO: 实现正确的录音停止逻辑 |
||||
|
/* |
||||
|
if (_isRecording && _recorder != null) { |
||||
|
try { |
||||
|
await _recorder!.stop(); |
||||
|
_isRecording = false; |
||||
|
Logger.info('录音已停止'); |
||||
|
} catch (e) { |
||||
|
Logger.error('停止录音失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
*/ |
||||
|
|
||||
|
// 临时实现,防止错误 |
||||
|
_isRecording = false; |
||||
|
Logger.info('录音已停止(模拟)'); |
||||
|
} |
||||
|
|
||||
|
/// 处理WebSocket消息 |
||||
|
void _handleWebSocketMessage(dynamic message) { |
||||
|
try { |
||||
|
_receivePacketCount++; |
||||
|
|
||||
|
if (message is String) { |
||||
|
final Map<String, dynamic> data = jsonDecode(message); |
||||
|
final String type = data['type'] as String? ?? ''; |
||||
|
|
||||
|
switch (type) { |
||||
|
case 'result': |
||||
|
_handleResultMessage(data); |
||||
|
break; |
||||
|
|
||||
|
case 'start_result': |
||||
|
_handleStartResultMessage(data); |
||||
|
break; |
||||
|
|
||||
|
case 'end_result': |
||||
|
_handleEndResultMessage(data); |
||||
|
break; |
||||
|
|
||||
|
case 'error': |
||||
|
_handleErrorMessage(data); |
||||
|
break; |
||||
|
|
||||
|
case 'heartbeat_result': |
||||
|
// 心跳响应,不需要特殊处理 |
||||
|
break; |
||||
|
|
||||
|
default: |
||||
|
Logger.info('收到未知类型的消息: $type'); |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('处理WebSocket消息失败: $e'); |
||||
|
|
||||
|
if (_recognitionController != null && !_recognitionController!.isClosed) { |
||||
|
_recognitionController!.addError('处理消息失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理结果消息 |
||||
|
void _handleResultMessage(Map<String, dynamic> data) { |
||||
|
try { |
||||
|
final Map<String, dynamic>? result = data['result'] as Map<String, dynamic>?; |
||||
|
if (result == null) return; |
||||
|
|
||||
|
final bool isEnd = result['is_end'] as bool? ?? false; |
||||
|
final String text = result['text'] as String? ?? ''; |
||||
|
|
||||
|
if (_recognitionController != null && !_recognitionController!.isClosed) { |
||||
|
if (isEnd) { |
||||
|
// 最终结果 |
||||
|
_recognitionController!.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.finalResult, |
||||
|
text: text, |
||||
|
)); |
||||
|
} else { |
||||
|
// 中间结果 |
||||
|
_recognitionController!.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.intermediateResult, |
||||
|
text: text, |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('处理结果消息失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理开始结果消息 |
||||
|
void _handleStartResultMessage(Map<String, dynamic> data) { |
||||
|
final int code = data['code'] as int? ?? -1; |
||||
|
|
||||
|
if (code == 0) { |
||||
|
// 开始成功 |
||||
|
if (_recognitionController != null && !_recognitionController!.isClosed) { |
||||
|
_recognitionController!.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.sessionStarted, |
||||
|
)); |
||||
|
} |
||||
|
} else { |
||||
|
// 开始失败 |
||||
|
final String message = data['message'] as String? ?? '未知错误'; |
||||
|
|
||||
|
Logger.error('语音识别开始失败: [$code] $message'); |
||||
|
|
||||
|
if (_recognitionController != null && !_recognitionController!.isClosed) { |
||||
|
_recognitionController!.add(RecognitionEvent.error('开始失败: [$code] $message')); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理结束结果消息 |
||||
|
void _handleEndResultMessage(Map<String, dynamic> data) { |
||||
|
final int code = data['code'] as int? ?? -1; |
||||
|
|
||||
|
if (code == 0) { |
||||
|
// 结束成功 |
||||
|
Logger.info('语音识别成功结束'); |
||||
|
} else { |
||||
|
// 结束失败 |
||||
|
final String message = data['message'] as String? ?? '未知错误'; |
||||
|
Logger.error('语音识别结束失败: [$code] $message'); |
||||
|
} |
||||
|
|
||||
|
// 无论成功还是失败,通知会话已结束 |
||||
|
if (_recognitionController != null && !_recognitionController!.isClosed) { |
||||
|
_recognitionController!.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.sessionStopped, |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
// 清理资源 |
||||
|
_cleanupResources(); |
||||
|
} |
||||
|
|
||||
|
/// 处理错误消息 |
||||
|
void _handleErrorMessage(Map<String, dynamic> data) { |
||||
|
final int code = data['code'] as int? ?? -1; |
||||
|
final String message = data['message'] as String? ?? '未知错误'; |
||||
|
|
||||
|
Logger.error('收到错误消息: [$code] $message'); |
||||
|
|
||||
|
if (_recognitionController != null && !_recognitionController!.isClosed) { |
||||
|
_recognitionController!.add(RecognitionEvent.error('[$code] $message')); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 处理WebSocket错误 |
||||
|
void _handleWebSocketError(dynamic error) { |
||||
|
Logger.error('WebSocket错误: $error'); |
||||
|
|
||||
|
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) { |
||||
|
_connectionCompleter!.completeError(error); |
||||
|
} |
||||
|
|
||||
|
if (_recognitionController != null && !_recognitionController!.isClosed) { |
||||
|
_recognitionController!.addError(error); |
||||
|
} |
||||
|
|
||||
|
// 清理资源 |
||||
|
_cleanupResources(); |
||||
|
} |
||||
|
|
||||
|
/// 处理WebSocket连接关闭 |
||||
|
void _handleWebSocketDone() { |
||||
|
Logger.info('WebSocket连接已关闭'); |
||||
|
|
||||
|
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) { |
||||
|
_connectionCompleter!.completeError('WebSocket连接已关闭'); |
||||
|
} |
||||
|
|
||||
|
// 通知会话已结束 |
||||
|
if (_recognitionController != null && !_recognitionController!.isClosed && !_isRecognitionEndRequested) { |
||||
|
_recognitionController!.add(RecognitionEvent( |
||||
|
type: RecognitionEventType.sessionStopped, |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
// 清理资源 |
||||
|
_cleanupResources(); |
||||
|
} |
||||
|
|
||||
|
/// 关闭WebSocket连接 |
||||
|
Future<void> _closeWebSocket() async { |
||||
|
_connectionSubscription?.cancel(); |
||||
|
_connectionSubscription = null; |
||||
|
|
||||
|
_heartbeatTimer?.cancel(); |
||||
|
_heartbeatTimer = null; |
||||
|
|
||||
|
if (_channel != null) { |
||||
|
try { |
||||
|
_channel!.sink.close(); |
||||
|
_channel = null; |
||||
|
} catch (e) { |
||||
|
Logger.error('关闭WebSocket连接失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 清理所有资源 |
||||
|
Future<void> _cleanupResources() async { |
||||
|
// 停止录音 |
||||
|
await _stopRecording(); |
||||
|
|
||||
|
// 关闭WebSocket |
||||
|
await _closeWebSocket(); |
||||
|
|
||||
|
// 关闭结果控制器 |
||||
|
if (_recognitionController != null && !_recognitionController!.isClosed) { |
||||
|
await _recognitionController!.close(); |
||||
|
_recognitionController = null; |
||||
|
} |
||||
|
|
||||
|
_isContinuousRecognitionActive = false; |
||||
|
_isRecognitionEndRequested = false; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,83 @@ |
|||||
|
import 'dart:async'; |
||||
|
|
||||
|
/// 语音合成服务接口 |
||||
|
abstract class TtsService { |
||||
|
|
||||
|
/// 当前选择的语音 |
||||
|
String get currentVoice; |
||||
|
|
||||
|
/// 当前是否正在播放 |
||||
|
bool get isSpeaking; |
||||
|
|
||||
|
/// TTS事件流,用于监听语音合成事件 |
||||
|
Stream<TtsEvent> get onEvent; |
||||
|
|
||||
|
/// 初始化语音合成服务 |
||||
|
Future<bool> initialize({ |
||||
|
List<String>? supportedLanguages, |
||||
|
}); |
||||
|
|
||||
|
/// 设置语音 |
||||
|
Future<bool> setVoice(String voiceName); |
||||
|
|
||||
|
|
||||
|
/// 单次播放文本,完成后返回 |
||||
|
Future<bool> speakOnce(String text); |
||||
|
|
||||
|
/// 将文本加入播放队列,支持连续播放多段文本 |
||||
|
Future<bool> speakStream(String text); |
||||
|
|
||||
|
/// 播放队列中剩余文本 |
||||
|
Future<bool> flushStream(); |
||||
|
|
||||
|
/// 停止播放 |
||||
|
Future<bool> stop(); |
||||
|
|
||||
|
/// 释放资源 |
||||
|
Future<void> dispose(); |
||||
|
} |
||||
|
|
||||
|
/// TTS事件类型 |
||||
|
enum TtsEventType { |
||||
|
/// 开始播放 |
||||
|
started, |
||||
|
|
||||
|
/// 播放完成 |
||||
|
completed, |
||||
|
|
||||
|
/// 播放取消 |
||||
|
canceled, |
||||
|
|
||||
|
/// 播放错误 |
||||
|
error, |
||||
|
} |
||||
|
|
||||
|
/// TTS事件 |
||||
|
class TtsEvent { |
||||
|
/// 事件类型 |
||||
|
final TtsEventType type; |
||||
|
|
||||
|
/// 错误信息(仅在 error 类型中有效) |
||||
|
final String error; |
||||
|
|
||||
|
TtsEvent({ |
||||
|
required this.type, |
||||
|
this.error = '', |
||||
|
}); |
||||
|
|
||||
|
/// 创建错误事件的快捷构造函数 |
||||
|
factory TtsEvent.error(String errorMessage) { |
||||
|
return TtsEvent( |
||||
|
type: TtsEventType.error, |
||||
|
error: errorMessage, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 检查是否为错误 |
||||
|
bool get isError => type == TtsEventType.error; |
||||
|
|
||||
|
@override |
||||
|
String toString() { |
||||
|
return 'TtsEvent{type: $type, error: $error}'; |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -1,743 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:convert'; |
|
||||
import 'dart:typed_data'; |
|
||||
import 'dart:io'; |
|
||||
import 'dart:math' as math; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:just_audio/just_audio.dart'; |
|
||||
import 'package:web_socket_channel/web_socket_channel.dart'; |
|
||||
import 'package:audio_session/audio_session.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
|
|
||||
/// 火山语音服务异常 |
|
||||
class VolcanoTtsException implements Exception { |
|
||||
final String message; |
|
||||
VolcanoTtsException(this.message); |
|
||||
|
|
||||
@override |
|
||||
String toString() => message; |
|
||||
} |
|
||||
|
|
||||
/// 自定义音频源,用于从字节数组读取音频数据 |
|
||||
class BytesAudioSource extends StreamAudioSource { |
|
||||
final Uint8List _bytes; |
|
||||
static const int _bufferSize = 4096; // 4KB buffer size for better Android compatibility |
|
||||
|
|
||||
BytesAudioSource(this._bytes); |
|
||||
|
|
||||
@override |
|
||||
Future<StreamAudioResponse> request([int? start, int? end]) async { |
|
||||
start = start ?? 0; |
|
||||
end = end ?? _bytes.length; |
|
||||
|
|
||||
try { |
|
||||
final subData = _bytes.sublist(start, end); |
|
||||
|
|
||||
// 使用固定大小的块进行流式传输 |
|
||||
final chunks = <List<int>>[]; |
|
||||
var offset = 0; |
|
||||
while (offset < subData.length) { |
|
||||
final chunkSize = math.min(_bufferSize, subData.length - offset); |
|
||||
chunks.add(subData.sublist(offset, offset + chunkSize)); |
|
||||
offset += chunkSize; |
|
||||
} |
|
||||
|
|
||||
return StreamAudioResponse( |
|
||||
sourceLength: _bytes.length, |
|
||||
contentLength: subData.length, |
|
||||
offset: start, |
|
||||
stream: Stream.fromIterable(chunks), |
|
||||
contentType: 'audio/mpeg', |
|
||||
); |
|
||||
} catch (e) { |
|
||||
print('BytesAudioSource request error: $e'); |
|
||||
rethrow; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<int> get length => Future.value(_bytes.length); |
|
||||
} |
|
||||
|
|
||||
/// 火山语音合成服务 |
|
||||
class VolcanoTtsService extends GetxService { |
|
||||
static const String _host = 'openspeech.bytedance.com'; |
|
||||
static const String _apiUrl = 'wss://$_host/api/v1/tts/ws_binary'; |
|
||||
|
|
||||
// 配置参数 |
|
||||
final String _appId; |
|
||||
final String _token; |
|
||||
final String _cluster; |
|
||||
final String _voiceType; |
|
||||
|
|
||||
// 音频播放器 |
|
||||
late AudioPlayer _audioPlayer; |
|
||||
final ConcatenatingAudioSource _playlist = ConcatenatingAudioSource(children: []); |
|
||||
final isEnabled = true.obs; // 默认启用 |
|
||||
final isPlaying = false.obs; |
|
||||
StreamSubscription? _playbackEventSubscription; |
|
||||
StreamSubscription? _playerStateSubscription; |
|
||||
AudioSession? _audioSession; |
|
||||
|
|
||||
// WebSocket 相关 |
|
||||
WebSocketChannel? _channel; |
|
||||
bool _isDisposed = false; |
|
||||
|
|
||||
// 句子管理 |
|
||||
String _pendingText = ''; |
|
||||
final List<String> _sentenceQueue = []; |
|
||||
static final _sentenceBreaks = RegExp(r'[。!?.!?,,、]'); // 添加更多标点符号 |
|
||||
bool _isFetching = false; |
|
||||
static const int _maxSegmentLength = 100; // 最大分段长度 |
|
||||
static const int _optimalSegmentLength = 50; // 最佳分段长度 |
|
||||
|
|
||||
// WebSocket连接配置 |
|
||||
static final Map<String, String> _wsHeaders = { |
|
||||
'User-Agent': 'DeepVoice/1.0', |
|
||||
'Accept': '*/*', |
|
||||
'Accept-Encoding': 'gzip, deflate, br', |
|
||||
'Connection': 'Upgrade', |
|
||||
'Upgrade': 'websocket', |
|
||||
'Sec-WebSocket-Version': '13', |
|
||||
'Sec-WebSocket-Extensions': 'permessage-deflate', |
|
||||
'Origin': 'https://openspeech.bytedance.com', |
|
||||
}; |
|
||||
|
|
||||
// 二进制协议头部字段定义 |
|
||||
static const int PROTOCOL_VERSION = 0x1; // 0b0001 - 版本1 |
|
||||
static const int HEADER_SIZE = 0x1; // 0b0001 - 4字节 |
|
||||
static const int MESSAGE_TYPE = 0x1; // 0b0001 - full client request |
|
||||
static const int MESSAGE_FLAGS = 0x0; // 0b0000 - 无特殊标记 |
|
||||
static const int SERIALIZATION = 0x1; // 0b0001 - JSON |
|
||||
static const int COMPRESSION = 0x0; // 0b0000 - 无压缩 |
|
||||
static const int RESERVED = 0x0; // 0b0000 - 保留字段 |
|
||||
|
|
||||
// 构建二进制协议头 |
|
||||
static Uint8List _buildHeader() { |
|
||||
final header = ByteData(4); // 4字节的头部 |
|
||||
|
|
||||
// 第一个字节: [协议版本(4位) | 报头大小(4位)] |
|
||||
header.setUint8(0, (PROTOCOL_VERSION << 4) | HEADER_SIZE); |
|
||||
|
|
||||
// 第二个字节: [消息类型(4位) | 消息标记(4位)] |
|
||||
header.setUint8(1, (MESSAGE_TYPE << 4) | MESSAGE_FLAGS); |
|
||||
|
|
||||
// 第三个字节: [序列化方法(4位) | 压缩方法(4位)] |
|
||||
header.setUint8(2, (SERIALIZATION << 4) | COMPRESSION); |
|
||||
|
|
||||
// 第四个字节: [保留字段(8位)] |
|
||||
header.setUint8(3, RESERVED); |
|
||||
|
|
||||
return header.buffer.asUint8List(); |
|
||||
} |
|
||||
|
|
||||
VolcanoTtsService() : |
|
||||
_appId = dotenv.env['VOLCANO_TTS_APP_ID'] ?? '', |
|
||||
_token = dotenv.env['VOLCANO_TTS_APP_TOKEN'] ?? '', |
|
||||
_cluster = dotenv.env['VOLCANO_TTS_CLUSTER'] ?? '', |
|
||||
_voiceType = dotenv.env['VOLCANO_TTS_VOICE_TYPE'] ?? 'zh_male_M392_conversation_wvae_bigtts' { |
|
||||
if (_appId.isEmpty || _token.isEmpty || _cluster.isEmpty) { |
|
||||
throw Exception('火山语音配置信息不完整,请检查环境变量'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Future<void> onInit() async { |
|
||||
super.onInit(); |
|
||||
try { |
|
||||
await _initAudioSession(); |
|
||||
await _initAudioPlayer(); |
|
||||
await useDefaultAudioDevice(); |
|
||||
} catch (e, stackTrace) { |
|
||||
print('初始化TTS服务失败: $e'); |
|
||||
print('Stack trace: $stackTrace'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _initAudioSession() async { |
|
||||
_audioSession = await AudioSession.instance; |
|
||||
await _audioSession?.configure(AudioSessionConfiguration( |
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playback, |
|
||||
androidAudioAttributes: const AndroidAudioAttributes( |
|
||||
contentType: AndroidAudioContentType.speech, |
|
||||
usage: AndroidAudioUsage.media, |
|
||||
flags: AndroidAudioFlags.audibilityEnforced, |
|
||||
), |
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain, |
|
||||
androidWillPauseWhenDucked: true, |
|
||||
)); |
|
||||
|
|
||||
// 确保音频会话激活并设置正确的音频模式 |
|
||||
if (Platform.isAndroid) { |
|
||||
await _audioSession?.setActive(true, avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.notifyOthersOnDeactivation); |
|
||||
} else { |
|
||||
await _audioSession?.setActive(true); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _initAudioPlayer() async { |
|
||||
if (_isDisposed) return; |
|
||||
|
|
||||
_audioPlayer = AudioPlayer(); |
|
||||
|
|
||||
// 配置Android特定的播放参数 |
|
||||
if (Platform.isAndroid) { |
|
||||
await _audioPlayer.setAndroidAudioAttributes(const AndroidAudioAttributes( |
|
||||
contentType: AndroidAudioContentType.speech, |
|
||||
usage: AndroidAudioUsage.media, |
|
||||
flags: AndroidAudioFlags.audibilityEnforced, |
|
||||
)); |
|
||||
} |
|
||||
|
|
||||
// 基本播放器配置 |
|
||||
await _audioPlayer.setVolume(1.0); |
|
||||
await _audioPlayer.setLoopMode(LoopMode.off); |
|
||||
await _audioPlayer.setSpeed(1.0); |
|
||||
|
|
||||
// 设置音频缓冲配置,启用预加载以减少延迟 |
|
||||
await _audioPlayer.setAudioSource( |
|
||||
_playlist, |
|
||||
initialPosition: Duration.zero, |
|
||||
preload: true, |
|
||||
); |
|
||||
|
|
||||
_setupEventListeners(); |
|
||||
} |
|
||||
|
|
||||
Future<void> speak(String text) async { |
|
||||
if (!isEnabled.value || text.trim().isEmpty) return; |
|
||||
|
|
||||
try { |
|
||||
_pendingText += text; |
|
||||
_extractFullSentences(); |
|
||||
await _startPreloadIfNeeded(); |
|
||||
} catch (e, stack) { |
|
||||
print('语音合成失败: $e'); |
|
||||
await _reinitializePlayer(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void _extractFullSentences() { |
|
||||
if (_pendingText.isEmpty) return; |
|
||||
|
|
||||
final matches = _sentenceBreaks.allMatches(_pendingText).toList(); |
|
||||
if (matches.isEmpty) { |
|
||||
// 如果没有找到句子分隔符,按长度分割 |
|
||||
_splitByLength(_pendingText); |
|
||||
_pendingText = ''; |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
int lastPos = 0; |
|
||||
String currentBatch = ''; |
|
||||
|
|
||||
for (final match in matches) { |
|
||||
final end = match.end; |
|
||||
final sentence = _pendingText.substring(lastPos, end).trim(); |
|
||||
|
|
||||
if (sentence.isEmpty) { |
|
||||
lastPos = end; |
|
||||
continue; |
|
||||
} |
|
||||
|
|
||||
// 如果当前批次为空,直接添加句子 |
|
||||
if (currentBatch.isEmpty) { |
|
||||
currentBatch = sentence; |
|
||||
} |
|
||||
// 如果当前批次加上新句子不超过30字符,则合并 |
|
||||
else if ((currentBatch + sentence).length <= 30) { |
|
||||
currentBatch += sentence; |
|
||||
} |
|
||||
// 如果超过30字符,将当前批次加入队列,开始新批次 |
|
||||
else { |
|
||||
if (currentBatch.isNotEmpty) { |
|
||||
_sentenceQueue.add(currentBatch); |
|
||||
} |
|
||||
currentBatch = sentence; |
|
||||
} |
|
||||
|
|
||||
lastPos = end; |
|
||||
} |
|
||||
|
|
||||
// 处理最后一个批次 |
|
||||
if (currentBatch.isNotEmpty) { |
|
||||
_sentenceQueue.add(currentBatch); |
|
||||
} |
|
||||
|
|
||||
// 处理剩余的文本 |
|
||||
if (lastPos < _pendingText.length) { |
|
||||
final remaining = _pendingText.substring(lastPos).trim(); |
|
||||
if (remaining.isNotEmpty) { |
|
||||
if (remaining.length <= 30) { |
|
||||
// 尝试与最后一个批次合并 |
|
||||
if (_sentenceQueue.isNotEmpty && |
|
||||
(_sentenceQueue.last + remaining).length <= 30) { |
|
||||
_sentenceQueue[_sentenceQueue.length - 1] += remaining; |
|
||||
} else { |
|
||||
_sentenceQueue.add(remaining); |
|
||||
} |
|
||||
} else { |
|
||||
_splitByLength(remaining); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
_pendingText = ''; |
|
||||
} |
|
||||
|
|
||||
void _splitByLength(String text) { |
|
||||
if (text.isEmpty) return; |
|
||||
|
|
||||
int start = 0; |
|
||||
while (start < text.length) { |
|
||||
int end = math.min(start + 30, text.length); |
|
||||
|
|
||||
// 尝试在标点或空格处分割 |
|
||||
if (end < text.length) { |
|
||||
final spacePos = text.lastIndexOf(' ', end); |
|
||||
final breakPos = _sentenceBreaks.firstMatch( |
|
||||
text.substring(start, end) |
|
||||
)?.end; |
|
||||
|
|
||||
if (breakPos != null && breakPos > start) { |
|
||||
end = start + breakPos; |
|
||||
} else if (spacePos > start) { |
|
||||
end = spacePos; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
final segment = text.substring(start, end).trim(); |
|
||||
if (segment.isNotEmpty) { |
|
||||
// 尝试与前一个批次合并 |
|
||||
if (_sentenceQueue.isNotEmpty && |
|
||||
(_sentenceQueue.last + segment).length <= 30) { |
|
||||
_sentenceQueue[_sentenceQueue.length - 1] += segment; |
|
||||
} else { |
|
||||
_sentenceQueue.add(segment); |
|
||||
} |
|
||||
} |
|
||||
start = end; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _startPreloadIfNeeded() async { |
|
||||
if (_isFetching || _sentenceQueue.isEmpty) return; |
|
||||
_isFetching = true; |
|
||||
await _fetchNextSegment(); |
|
||||
} |
|
||||
|
|
||||
Future<void> _fetchNextSegment() async { |
|
||||
if (_sentenceQueue.isEmpty) { |
|
||||
_isFetching = false; |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// 预加载多个片段以实现流畅播放 |
|
||||
final segments = <String>[]; |
|
||||
while (_sentenceQueue.isNotEmpty && segments.length < 2) { // 减少预加载数量以降低内存压力 |
|
||||
segments.add(_sentenceQueue.removeAt(0)); |
|
||||
} |
|
||||
|
|
||||
for (final segment in segments) { |
|
||||
print('获取音频片段: $segment'); |
|
||||
final audioData = await _synthesize(segment); |
|
||||
|
|
||||
if (audioData != null) { |
|
||||
print('成功获取音频数据: ${audioData.length} bytes'); |
|
||||
try { |
|
||||
final audioSource = BytesAudioSource(audioData); |
|
||||
await _playlist.add(audioSource); |
|
||||
print('音频片段已添加到播放列表'); |
|
||||
|
|
||||
// 如果是第一个片段且播放器未在播放,开始播放 |
|
||||
if (!_audioPlayer.playing && _playlist.length == 1) { |
|
||||
// 确保音频会话处于激活状态 |
|
||||
await _audioSession?.setActive(true); |
|
||||
|
|
||||
// 设置播放参数 |
|
||||
await _audioPlayer.setVolume(1.0); |
|
||||
await _audioPlayer.seek(Duration.zero, index: 0); |
|
||||
|
|
||||
// 开始播放 |
|
||||
await _audioPlayer.play(); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('添加音频源失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 如果队列还有内容,继续获取下一批 |
|
||||
if (_sentenceQueue.isNotEmpty) { |
|
||||
await Future.delayed(const Duration(milliseconds: 100)); // 添加延迟以避免过快请求 |
|
||||
await _fetchNextSegment(); |
|
||||
} else { |
|
||||
_isFetching = false; |
|
||||
// 检查是否还有未处理的文本 |
|
||||
_extractFullSentences(); |
|
||||
if (_sentenceQueue.isNotEmpty) { |
|
||||
await _startPreloadIfNeeded(); |
|
||||
} |
|
||||
} |
|
||||
} catch (e, stackTrace) { |
|
||||
print('获取音频失败: $e'); |
|
||||
print('Stack trace: $stackTrace'); |
|
||||
_isFetching = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<Uint8List?> _synthesize(String text) async { |
|
||||
WebSocket? socket; |
|
||||
StreamSubscription? subscription; |
|
||||
|
|
||||
try { |
|
||||
final requestJson = { |
|
||||
'app': { |
|
||||
'appid': _appId, |
|
||||
'token': _token, |
|
||||
'cluster': _cluster, |
|
||||
}, |
|
||||
'user': { |
|
||||
'uid': '${DateTime.now().millisecondsSinceEpoch}', |
|
||||
}, |
|
||||
'audio': { |
|
||||
'voice_type': _voiceType, |
|
||||
'encoding': 'mp3', |
|
||||
'speed_ratio': 1.0, |
|
||||
'volume_ratio': 1.0, |
|
||||
'pitch_ratio': 1.0, |
|
||||
'language': 'zh', |
|
||||
}, |
|
||||
'request': { |
|
||||
'reqid': '${DateTime.now().millisecondsSinceEpoch}', |
|
||||
'text': text, |
|
||||
'text_type': 'plain', |
|
||||
'operation': 'query', |
|
||||
}, |
|
||||
}; |
|
||||
|
|
||||
final jsonBytes = utf8.encode(json.encode(requestJson)); |
|
||||
final header = _buildHeader(); |
|
||||
final fullRequest = Uint8List(header.length + 4 + jsonBytes.length); |
|
||||
|
|
||||
fullRequest.setAll(0, header); |
|
||||
final lengthBytes = ByteData(4)..setInt32(0, jsonBytes.length, Endian.big); |
|
||||
fullRequest.setAll(header.length, lengthBytes.buffer.asUint8List()); |
|
||||
fullRequest.setAll(header.length + 4, jsonBytes); |
|
||||
|
|
||||
final wsUrl = Uri.parse(_apiUrl); |
|
||||
final completer = Completer<Uint8List?>(); |
|
||||
|
|
||||
socket = await WebSocket.connect( |
|
||||
wsUrl.toString(), |
|
||||
headers: { |
|
||||
..._wsHeaders, |
|
||||
'Authorization': 'Bearer;$_token', |
|
||||
}, |
|
||||
).timeout( |
|
||||
const Duration(seconds: 5), |
|
||||
onTimeout: () { |
|
||||
throw TimeoutException('WebSocket连接超时'); |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
subscription = socket.listen( |
|
||||
(response) { |
|
||||
if (!completer.isCompleted && response is Uint8List) { |
|
||||
if (response.length < 2) return; |
|
||||
|
|
||||
final messageType = response[1] >> 4; |
|
||||
|
|
||||
if (messageType == 0x0B) { |
|
||||
final headerSize = response[0] & 0x0F; |
|
||||
final payloadStart = headerSize * 4; |
|
||||
|
|
||||
if (response.length > payloadStart) { |
|
||||
final audioData = response.sublist(payloadStart); |
|
||||
completer.complete(audioData); |
|
||||
} |
|
||||
} else if (messageType == 0x0F) { |
|
||||
try { |
|
||||
final headerSize = response[0] & 0x0F; |
|
||||
final payloadStart = headerSize * 4; |
|
||||
|
|
||||
if (response.length < payloadStart + 4) { |
|
||||
throw VolcanoTtsException('错误响应数据不完整'); |
|
||||
} |
|
||||
|
|
||||
final jsonData = response.sublist(payloadStart + 4); |
|
||||
final jsonString = utf8.decode(jsonData, allowMalformed: true); |
|
||||
|
|
||||
if (jsonString.trim().startsWith('{')) { |
|
||||
final errorMap = json.decode(jsonString); |
|
||||
throw VolcanoTtsException(errorMap['error'] as String? ?? '未知错误'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
if (e is VolcanoTtsException) rethrow; |
|
||||
throw VolcanoTtsException('服务返回错误: $e'); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
}, |
|
||||
onError: (error) { |
|
||||
if (!completer.isCompleted) { |
|
||||
completer.completeError(error); |
|
||||
} |
|
||||
}, |
|
||||
onDone: () { |
|
||||
if (!completer.isCompleted) { |
|
||||
completer.complete(null); |
|
||||
} |
|
||||
}, |
|
||||
cancelOnError: false, |
|
||||
); |
|
||||
|
|
||||
socket.add(fullRequest); |
|
||||
|
|
||||
return await completer.future.timeout( |
|
||||
const Duration(seconds: 10), |
|
||||
onTimeout: () => null, |
|
||||
); |
|
||||
} catch (e) { |
|
||||
print('合成请求失败: $e'); |
|
||||
return null; |
|
||||
} finally { |
|
||||
await subscription?.cancel(); |
|
||||
await socket?.close(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _playAudio(Uint8List audioData) async { |
|
||||
if (_isDisposed) { |
|
||||
print('TTS服务已销毁,跳过播放'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
print('准备播放音频数据: ${audioData.length} bytes'); |
|
||||
final audioSource = BytesAudioSource(audioData); |
|
||||
|
|
||||
print('清除播放列表'); |
|
||||
await _playlist.clear(); |
|
||||
|
|
||||
print('添加新的音频源到播放列表'); |
|
||||
await _playlist.add(audioSource); |
|
||||
|
|
||||
if (!_audioPlayer.playing) { |
|
||||
print('设置音量和播放位置'); |
|
||||
await _audioPlayer.setVolume(1.0); |
|
||||
await _audioPlayer.seek(Duration.zero, index: 0); |
|
||||
|
|
||||
// 确保音频会话处于激活状态 |
|
||||
await _audioSession?.setActive(true); |
|
||||
|
|
||||
print('等待50ms确保音频准备就绪'); |
|
||||
await Future.delayed(const Duration(milliseconds: 50)); |
|
||||
|
|
||||
print('开始播放'); |
|
||||
await _audioPlayer.play(); |
|
||||
print('播放命令已发送'); |
|
||||
} else { |
|
||||
print('播放器已在播放中'); |
|
||||
} |
|
||||
} catch (e, stack) { |
|
||||
print('播放音频失败: $e'); |
|
||||
print('错误堆栈: $stack'); |
|
||||
await _reinitializePlayer(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> stop() async { |
|
||||
try { |
|
||||
print('停止播放'); |
|
||||
await _audioPlayer.stop(); |
|
||||
await _playlist.clear(); |
|
||||
_pendingText = ''; |
|
||||
_sentenceQueue.clear(); |
|
||||
_isFetching = false; |
|
||||
isPlaying.value = false; |
|
||||
|
|
||||
// 确保清空所有待处理的音频数据 |
|
||||
try { |
|
||||
// 取消所有正在进行的WebSocket连接 |
|
||||
await _channel?.sink.close(); |
|
||||
_channel = null; |
|
||||
|
|
||||
// 重置播放器状态 |
|
||||
await _audioPlayer.pause(); |
|
||||
await _audioPlayer.seek(Duration.zero); |
|
||||
|
|
||||
print('已清空所有待播放的文字和音频'); |
|
||||
} catch (e) { |
|
||||
print('清空音频缓存时出错: $e'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('停止播放失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 添加一个方法来检查音频播放器是否真正在播放 |
|
||||
bool isActuallyPlaying() { |
|
||||
try { |
|
||||
// 检查播放器状态 |
|
||||
final isPlayerPlaying = _audioPlayer.playing; |
|
||||
|
|
||||
// 检查播放列表是否为空 |
|
||||
final hasAudioSource = _playlist.length > 0; |
|
||||
|
|
||||
// 只有当播放器正在播放且播放列表不为空时,才认为真正在播放 |
|
||||
final actuallyPlaying = isPlayerPlaying && hasAudioSource; |
|
||||
|
|
||||
// 如果状态不一致,记录日志 |
|
||||
if (isPlaying.value != actuallyPlaying) { |
|
||||
print('音频播放状态不一致: isPlaying.value=${isPlaying.value}, 实际播放状态=$actuallyPlaying (播放器状态=$isPlayerPlaying, 播放列表长度=${_playlist.length})'); |
|
||||
} |
|
||||
|
|
||||
return actuallyPlaying; |
|
||||
} catch (e) { |
|
||||
print('检查实际播放状态失败: $e'); |
|
||||
// 如果检查失败,返回当前的 isPlaying 值 |
|
||||
return isPlaying.value; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _reinitializePlayer() async { |
|
||||
try { |
|
||||
await stop(); |
|
||||
await _audioPlayer.dispose(); |
|
||||
|
|
||||
// 重新初始化音频会话 |
|
||||
await _initAudioSession(); |
|
||||
|
|
||||
_audioPlayer = AudioPlayer(); |
|
||||
await _audioPlayer.setVolume(1.0); |
|
||||
await _audioPlayer.setLoopMode(LoopMode.off); |
|
||||
|
|
||||
// 重新配置Android特定的播放参数 |
|
||||
await _audioPlayer.setAndroidAudioAttributes(const AndroidAudioAttributes( |
|
||||
contentType: AndroidAudioContentType.speech, |
|
||||
usage: AndroidAudioUsage.media, |
|
||||
flags: AndroidAudioFlags.audibilityEnforced, |
|
||||
)); |
|
||||
|
|
||||
await _audioPlayer.setAudioSource( |
|
||||
_playlist, |
|
||||
initialPosition: Duration.zero, |
|
||||
preload: true, |
|
||||
); |
|
||||
|
|
||||
_setupEventListeners(); |
|
||||
print('播放器重新初始化成功'); |
|
||||
} catch (e) { |
|
||||
print('播放器重新初始化失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void _setupEventListeners() { |
|
||||
_playbackEventSubscription?.cancel(); |
|
||||
_playerStateSubscription?.cancel(); |
|
||||
|
|
||||
_playbackEventSubscription = _audioPlayer.playbackEventStream.listen( |
|
||||
(event) { |
|
||||
if (_isDisposed) return; |
|
||||
if (event.processingState == ProcessingState.completed) { |
|
||||
_tryPlayNext(); |
|
||||
} |
|
||||
}, |
|
||||
onError: (error) { |
|
||||
if (_isDisposed) return; |
|
||||
_reinitializePlayer(); |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
_playerStateSubscription = _audioPlayer.playerStateStream.listen( |
|
||||
(state) { |
|
||||
if (_isDisposed) return; |
|
||||
isPlaying.value = state.playing; |
|
||||
|
|
||||
if (state.processingState == ProcessingState.completed) { |
|
||||
_tryPlayNext(); |
|
||||
} |
|
||||
}, |
|
||||
onError: (error) { |
|
||||
if (_isDisposed) return; |
|
||||
_reinitializePlayer(); |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Future<void> _tryPlayNext() async { |
|
||||
if (_isDisposed || _playlist.length <= 0) return; |
|
||||
|
|
||||
try { |
|
||||
if (!_audioPlayer.playing) { |
|
||||
// 确保音频会话处于激活状态 |
|
||||
await _audioSession?.setActive(true); |
|
||||
|
|
||||
// 减少延迟时间 |
|
||||
await Future.delayed(const Duration(milliseconds: 30)); |
|
||||
|
|
||||
if (!_isDisposed && !_audioPlayer.playing) { |
|
||||
await _audioPlayer.seek(Duration.zero, index: 0); |
|
||||
await _audioPlayer.play(); |
|
||||
} |
|
||||
} |
|
||||
} catch (e) { |
|
||||
if (e is! StateError) { |
|
||||
print('播放错误: $e'); |
|
||||
await _reinitializePlayer(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void toggleEnabled() { |
|
||||
isEnabled.toggle(); |
|
||||
if (!isEnabled.value) { |
|
||||
stop(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _cleanupResources() async { |
|
||||
_isDisposed = true; |
|
||||
await _playbackEventSubscription?.cancel(); |
|
||||
await _playerStateSubscription?.cancel(); |
|
||||
await _audioSession?.setActive(false); |
|
||||
await _channel?.sink.close(); |
|
||||
await stop(); |
|
||||
await _audioPlayer.dispose(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() async { |
|
||||
print('关闭TTS服务'); |
|
||||
await _cleanupResources(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
/// 使用系统默认音频设备 |
|
||||
Future<bool> useDefaultAudioDevice() async { |
|
||||
try { |
|
||||
if (Platform.isAndroid) { |
|
||||
// Android 平台 - 重置为正常模式,让系统自动选择 |
|
||||
final audioManager = AndroidAudioManager(); |
|
||||
await audioManager.setMode(AndroidAudioHardwareMode.normal); |
|
||||
await audioManager.stopBluetoothSco(); |
|
||||
await audioManager.setBluetoothScoOn(false); |
|
||||
// 不显式设置扬声器状态,让系统自动选择 |
|
||||
await audioManager.setSpeakerphoneOn(false); |
|
||||
return true; |
|
||||
} else if (Platform.isIOS) { |
|
||||
// iOS 平台 - 清除任何输出端口覆盖 |
|
||||
final avAudioSession = AVAudioSession(); |
|
||||
await avAudioSession.overrideOutputAudioPort(AVAudioSessionPortOverride.none); |
|
||||
return true; |
|
||||
} |
|
||||
return false; |
|
||||
} catch (e) { |
|
||||
Logger.error('设置默认音频设备失败: $e'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,16 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/azure_asr_test_controller.dart'; |
|
||||
import '../../../data/services/azure_asr_service.dart'; |
|
||||
|
|
||||
class AzureAsrTestBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
// 注册Azure ASR服务(如果尚未注册) |
|
||||
if (!Get.isRegistered<AzureAsrService>()) { |
|
||||
Get.lazyPut<AzureAsrService>(() => AzureAsrService()); |
|
||||
} |
|
||||
|
|
||||
// 注册控制器 |
|
||||
Get.lazyPut<AzureAsrTestController>(() => AzureAsrTestController()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,10 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/azure_tts_test_controller.dart'; |
|
||||
|
|
||||
class AzureTtsTestBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
// Use put instead of lazyPut to ensure the controller is created immediately |
|
||||
Get.put<AzureTtsTestController>(AzureTtsTestController()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,16 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/flutter_asr_test_controller.dart'; |
|
||||
import '../../../data/services/flutter_asr_service.dart'; |
|
||||
|
|
||||
class FlutterAsrTestBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
// 注册ASR服务(如果尚未注册) |
|
||||
if (!Get.isRegistered<FlutterAsrService>()) { |
|
||||
Get.lazyPut<FlutterAsrService>(() => FlutterAsrService()); |
|
||||
} |
|
||||
|
|
||||
// 注册控制器 |
|
||||
Get.lazyPut<FlutterAsrTestController>(() => FlutterAsrTestController()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,10 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/flutter_tts_test_controller.dart'; |
|
||||
|
|
||||
/// Flutter TTS测试绑定 |
|
||||
class FlutterTtsTestBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
Get.lazyPut<FlutterTtsTestController>(() => FlutterTtsTestController()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,25 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/volcano_tts_test_controller.dart'; |
|
||||
import '../controllers/azure_asr_test_controller.dart'; |
|
||||
import '../../../data/services/volcano_tts_api_service.dart'; |
|
||||
|
|
||||
class VolcanoTtsTestBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
// Make sure the VolcanoTtsApiService is registered |
|
||||
if (!Get.isRegistered<VolcanoTtsApiService>()) { |
|
||||
Get.put(VolcanoTtsApiService(), permanent: true); |
|
||||
} |
|
||||
|
|
||||
// Use put instead of lazyPut to ensure the controller is created immediately |
|
||||
Get.put<VolcanoTtsTestController>(VolcanoTtsTestController()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
class AzureAsrTestBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
// Use put instead of lazyPut to ensure the controller is created immediately |
|
||||
Get.put<AzureAsrTestController>(AzureAsrTestController()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,10 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/volcano_asr_test_controller.dart'; |
|
||||
|
|
||||
class VolcanoAsrTestBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
// Use put instead of lazyPut to ensure the controller is created immediately |
|
||||
Get.put<VolcanoAsrTestController>(VolcanoAsrTestController()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,298 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../../../data/services/azure_asr_service.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
|
|
||||
// Azure语音识别状态枚举 |
|
||||
enum AzureAsrState { |
|
||||
notInitialized, // 未初始化 |
|
||||
initialized, // 已初始化 |
|
||||
listening, // 正在监听 |
|
||||
notListening, // 未监听 |
|
||||
error, // 错误 |
|
||||
} |
|
||||
|
|
||||
class AzureAsrTestController extends GetxController { |
|
||||
// 服务引用 |
|
||||
final AzureAsrService _azureAsrService = Get.find<AzureAsrService>(); |
|
||||
|
|
||||
// 状态变量 |
|
||||
final RxBool isListening = false.obs; |
|
||||
final RxString recognizedText = ''.obs; |
|
||||
final RxString partialText = ''.obs; |
|
||||
final RxString detectedLanguage = ''.obs; |
|
||||
final Rx<AzureAsrState> asrState = AzureAsrState.notInitialized.obs; |
|
||||
|
|
||||
// 语言选项 |
|
||||
final RxString selectedLanguage = 'zh-CN'.obs; |
|
||||
final List<String> availableLanguages = [ |
|
||||
'zh-CN', // 中文(中国大陆) |
|
||||
'en-US', // 英语(美国) |
|
||||
'ja-JP', // 日语 |
|
||||
'ko-KR', // 韩语 |
|
||||
'fr-FR', // 法语 |
|
||||
'de-DE', // 德语 |
|
||||
'es-ES', // 西班牙语 |
|
||||
'it-IT', // 意大利语 |
|
||||
'pt-BR', // 葡萄牙语(巴西) |
|
||||
'ru-RU', // 俄语 |
|
||||
]; |
|
||||
|
|
||||
// 自动语言检测 |
|
||||
final RxBool isAutoDetectEnabled = false.obs; |
|
||||
final RxList<String> selectedSupportedLanguages = <String>['zh-CN', 'en-US'].obs; |
|
||||
|
|
||||
// 订阅 |
|
||||
StreamSubscription? _recognitionSubscription; |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
_initializeAsr(); |
|
||||
} |
|
||||
|
|
||||
// 初始化ASR服务 |
|
||||
Future<void> _initializeAsr() async { |
|
||||
try { |
|
||||
// 根据选择的支持语言初始化服务 |
|
||||
final success = await _azureAsrService.initialize( |
|
||||
supportedLanguages: selectedSupportedLanguages, |
|
||||
); |
|
||||
asrState.value = success ? AzureAsrState.initialized : AzureAsrState.error; |
|
||||
} catch (e) { |
|
||||
Logger.error('初始化ASR服务失败: $e'); |
|
||||
asrState.value = AzureAsrState.error; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 开始简单语音识别 |
|
||||
Future<void> startSimpleRecognition() async { |
|
||||
try { |
|
||||
if (asrState.value == AzureAsrState.notInitialized) { |
|
||||
await _initializeAsr(); |
|
||||
} |
|
||||
|
|
||||
// 如果正在进行连续识别,先停止 |
|
||||
if (_azureAsrService.isContinuousRecognitionActive()) { |
|
||||
await stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
partialText.value = ''; |
|
||||
final result = await _azureAsrService.recognizeSpeech(); |
|
||||
if (result.text.isNotEmpty) { |
|
||||
recognizedText.value = result.text; |
|
||||
detectedLanguage.value = result.detectedLanguage; |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('开始简单语音识别出错: $e'); |
|
||||
asrState.value = AzureAsrState.error; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 切换连续语音识别 |
|
||||
Future<void> toggleContinuousRecognition() async { |
|
||||
try { |
|
||||
if (asrState.value == AzureAsrState.notInitialized) { |
|
||||
await _initializeAsr(); |
|
||||
} |
|
||||
|
|
||||
if (_azureAsrService.isContinuousRecognitionActive()) { |
|
||||
await stopContinuousRecognition(); |
|
||||
} else { |
|
||||
await startContinuousRecognition(); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('切换连续语音识别出错: $e'); |
|
||||
asrState.value = AzureAsrState.error; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 开始连续语音识别 |
|
||||
Future<void> startContinuousRecognition() async { |
|
||||
try { |
|
||||
if (_azureAsrService.isContinuousRecognitionActive()) { |
|
||||
await stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_recognitionSubscription = null; |
|
||||
|
|
||||
isListening.value = true; |
|
||||
asrState.value = AzureAsrState.listening; |
|
||||
partialText.value = ''; |
|
||||
|
|
||||
final stream = await _azureAsrService.startContinuousRecognition(); |
|
||||
|
|
||||
_recognitionSubscription = stream.listen( |
|
||||
(event) { |
|
||||
switch (event.type) { |
|
||||
case RecognitionEventType.finalResult: |
|
||||
if (event.text.isNotEmpty) { |
|
||||
recognizedText.value = event.text; |
|
||||
detectedLanguage.value = event.detectedLanguage; |
|
||||
} |
|
||||
partialText.value = ''; |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.intermediateResult: |
|
||||
if (event.text.isNotEmpty) { |
|
||||
partialText.value = event.text; |
|
||||
if (event.detectedLanguage.isNotEmpty) { |
|
||||
detectedLanguage.value = event.detectedLanguage; |
|
||||
} |
|
||||
} |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.sessionStarted: |
|
||||
isListening.value = true; |
|
||||
asrState.value = AzureAsrState.listening; |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.sessionStopped: |
|
||||
isListening.value = false; |
|
||||
asrState.value = AzureAsrState.notListening; |
|
||||
partialText.value = ''; |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.canceled: |
|
||||
isListening.value = false; |
|
||||
asrState.value = AzureAsrState.notListening; |
|
||||
partialText.value = ''; |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.error: |
|
||||
asrState.value = AzureAsrState.error; |
|
||||
isListening.value = false; |
|
||||
partialText.value = ''; |
|
||||
_initializeAsr(); |
|
||||
break; |
|
||||
} |
|
||||
}, |
|
||||
onError: (error) { |
|
||||
asrState.value = AzureAsrState.error; |
|
||||
isListening.value = false; |
|
||||
partialText.value = ''; |
|
||||
}, |
|
||||
onDone: () { |
|
||||
if (!_azureAsrService.isContinuousRecognitionActive()) { |
|
||||
isListening.value = false; |
|
||||
asrState.value = AzureAsrState.notListening; |
|
||||
} |
|
||||
}, |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error('开始连续语音识别出错: $e'); |
|
||||
asrState.value = AzureAsrState.error; |
|
||||
isListening.value = false; |
|
||||
partialText.value = ''; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 停止连续语音识别 |
|
||||
Future<void> stopContinuousRecognition() async { |
|
||||
try { |
|
||||
isListening.value = false; |
|
||||
asrState.value = AzureAsrState.notListening; |
|
||||
|
|
||||
await _azureAsrService.stopContinuousRecognition(); |
|
||||
|
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_recognitionSubscription = null; |
|
||||
|
|
||||
partialText.value = ''; |
|
||||
} catch (e) { |
|
||||
Logger.error('停止连续语音识别出错: $e'); |
|
||||
asrState.value = AzureAsrState.error; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 更改语言 |
|
||||
Future<void> changeLanguage(String language) async { |
|
||||
try { |
|
||||
if (selectedLanguage.value == language) return; |
|
||||
|
|
||||
selectedLanguage.value = language; |
|
||||
|
|
||||
// 停止当前识别 |
|
||||
if (_azureAsrService.isContinuousRecognitionActive()) { |
|
||||
await stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
// 确保新语言在选中支持的语言列表中 |
|
||||
if (!selectedSupportedLanguages.contains(language)) { |
|
||||
// 添加新语言到支持列表 |
|
||||
final newLanguages = [...selectedSupportedLanguages, language]; |
|
||||
selectedSupportedLanguages.value = newLanguages; |
|
||||
|
|
||||
// 重新初始化ASR服务 |
|
||||
await _initializeAsr(); |
|
||||
} |
|
||||
|
|
||||
recognizedText.value = ''; |
|
||||
partialText.value = ''; |
|
||||
detectedLanguage.value = ''; |
|
||||
asrState.value = AzureAsrState.initialized; |
|
||||
} catch (e) { |
|
||||
Logger.error('更改语言出错: $e'); |
|
||||
asrState.value = AzureAsrState.error; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 切换自动语言检测 |
|
||||
Future<void> toggleAutoDetectLanguage() async { |
|
||||
try { |
|
||||
isAutoDetectEnabled.value = !isAutoDetectEnabled.value; |
|
||||
|
|
||||
// 停止当前识别 |
|
||||
if (_azureAsrService.isContinuousRecognitionActive()) { |
|
||||
await stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
// 重新初始化ASR服务 - 自动语言检测现在通过选择多种语言实现 |
|
||||
await _initializeAsr(); |
|
||||
|
|
||||
recognizedText.value = ''; |
|
||||
partialText.value = ''; |
|
||||
detectedLanguage.value = ''; |
|
||||
} catch (e) { |
|
||||
Logger.error('切换自动语言检测出错: $e'); |
|
||||
asrState.value = AzureAsrState.error; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 更新支持的语言列表 |
|
||||
Future<void> updateSupportedLanguages(List<String> languages) async { |
|
||||
try { |
|
||||
if (languages.isEmpty) return; |
|
||||
|
|
||||
selectedSupportedLanguages.value = languages; |
|
||||
|
|
||||
// 停止当前识别 |
|
||||
if (_azureAsrService.isContinuousRecognitionActive()) { |
|
||||
await stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
// 重新初始化ASR服务,使用新的语言列表 |
|
||||
final success = await _azureAsrService.initialize( |
|
||||
supportedLanguages: languages, |
|
||||
); |
|
||||
|
|
||||
asrState.value = success ? AzureAsrState.initialized : AzureAsrState.error; |
|
||||
} catch (e) { |
|
||||
Logger.error('更新支持的语言列表出错: $e'); |
|
||||
asrState.value = AzureAsrState.error; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_recognitionSubscription = null; |
|
||||
|
|
||||
if (_azureAsrService.isContinuousRecognitionActive()) { |
|
||||
_azureAsrService.stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,279 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'dart:async'; |
|
||||
import 'package:flutter/foundation.dart'; |
|
||||
import '../../../data/services/azure_tts_service.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
|
|
||||
class AzureTtsTestController extends GetxController { |
|
||||
// 使用优化后的 AzureTtsService |
|
||||
final AzureTtsService _ttsService = Get.put(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 isLoadingVoices = false.obs; |
|
||||
final RxList<String> availableVoices = <String>[ |
|
||||
'zh-CN-XiaoxiaoNeural', |
|
||||
'zh-CN-YunxiNeural', |
|
||||
'zh-CN-YunyangNeural', |
|
||||
].obs; |
|
||||
final Rx<String> selectedVoice = 'zh-CN-XiaoxiaoNeural'.obs; |
|
||||
|
|
||||
// 语音参数 |
|
||||
final rate = 0.obs; |
|
||||
final pitch = 0.obs; |
|
||||
final volume = 100.obs; |
|
||||
|
|
||||
// 固定的播报内容 |
|
||||
final RxList<String> speechTexts = <String>[ |
|
||||
'这是第一条Azure语音播报测试,请听取合成效果。', |
|
||||
'这是第二条Azure语音播报测试,Azure语音合成支持连续多条播报。', |
|
||||
'这是第三条Azure语音播报测试,可以测试语音合成的流畅度和连贯性。', |
|
||||
'这是第四条Azure语音播报测试,感谢您的使用。', |
|
||||
].obs; |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
|
|
||||
// 初始化UI状态 |
|
||||
isSpeaking.value = false; |
|
||||
isAudioPlaying.value = false; |
|
||||
errorMessage.value = ''; |
|
||||
isLoading.value = false; |
|
||||
|
|
||||
// 监听TTS播放状态 |
|
||||
ever(_ttsService.isSpeaking, (bool playing) { |
|
||||
isAudioPlaying.value = playing; |
|
||||
if (!playing && currentPlayingIndex.value >= 0) { |
|
||||
// 如果播放结束,重置当前播放索引 |
|
||||
currentPlayingIndex.value = -1; |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
// 启动播放状态检查定时器 |
|
||||
_startPlaybackCheckTimer(); |
|
||||
|
|
||||
// 初始化TTS服务 |
|
||||
_initializeTtsService(); |
|
||||
} |
|
||||
|
|
||||
/// 初始化TTS服务 |
|
||||
Future<void> _initializeTtsService() async { |
|
||||
try { |
|
||||
isLoading.value = true; |
|
||||
errorMessage.value = ''; |
|
||||
statusMessage.value = '正在初始化...'; |
|
||||
|
|
||||
// 初始化TTS服务 |
|
||||
final result = await _ttsService.initialize(); |
|
||||
|
|
||||
if (result) { |
|
||||
// 设置默认语音 |
|
||||
await _ttsService.setVoice(selectedVoice.value); |
|
||||
|
|
||||
// 设置默认参数 |
|
||||
await _ttsService.setSpeechParams( |
|
||||
rate: rate.value, |
|
||||
pitch: pitch.value, |
|
||||
volume: volume.value, |
|
||||
); |
|
||||
|
|
||||
statusMessage.value = '已连接'; |
|
||||
} else { |
|
||||
statusMessage.value = '连接失败'; |
|
||||
errorMessage.value = '初始化TTS服务失败'; |
|
||||
} |
|
||||
|
|
||||
isLoading.value = false; |
|
||||
} catch (e) { |
|
||||
isLoading.value = false; |
|
||||
statusMessage.value = '连接失败'; |
|
||||
errorMessage.value = '初始化TTS服务失败: $e'; |
|
||||
Logger.error('初始化TTS服务失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 切换语音 |
|
||||
Future<void> changeVoice(String voice) async { |
|
||||
try { |
|
||||
isLoading.value = true; |
|
||||
errorMessage.value = ''; |
|
||||
|
|
||||
selectedVoice.value = voice; |
|
||||
|
|
||||
// 设置语音 |
|
||||
final result = await _ttsService.setVoice(voice); |
|
||||
|
|
||||
if (result) { |
|
||||
statusMessage.value = '已设置语音: $voice'; |
|
||||
} else { |
|
||||
errorMessage.value = '设置语音失败'; |
|
||||
} |
|
||||
|
|
||||
isLoading.value = false; |
|
||||
} catch (e) { |
|
||||
isLoading.value = false; |
|
||||
errorMessage.value = '设置语音失败: $e'; |
|
||||
Logger.error('设置语音失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语音参数 |
|
||||
Future<void> setSpeechParams({int? newRate, int? newPitch, int? newVolume}) async { |
|
||||
try { |
|
||||
if (newRate != null) rate.value = newRate; |
|
||||
if (newPitch != null) pitch.value = newPitch; |
|
||||
if (newVolume != null) volume.value = newVolume; |
|
||||
|
|
||||
// 设置语音参数 |
|
||||
final result = await _ttsService.setSpeechParams( |
|
||||
rate: rate.value, |
|
||||
pitch: pitch.value, |
|
||||
volume: volume.value, |
|
||||
); |
|
||||
|
|
||||
if (result) { |
|
||||
statusMessage.value = '已设置语音参数'; |
|
||||
} else { |
|
||||
errorMessage.value = '设置语音参数失败'; |
|
||||
} |
|
||||
} catch (e) { |
|
||||
errorMessage.value = '设置语音参数失败: $e'; |
|
||||
Logger.error('设置语音参数失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 更新UI状态 |
|
||||
void updateUIState() { |
|
||||
// 强制更新UI |
|
||||
update(); |
|
||||
} |
|
||||
|
|
||||
/// 播放单条文本 |
|
||||
Future<void> playSingle(int index) async { |
|
||||
if (index < 0 || index >= speechTexts.length) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
isSpeaking.value = true; |
|
||||
isAudioPlaying.value = true; |
|
||||
errorMessage.value = ''; |
|
||||
currentPlayingIndex.value = index; |
|
||||
statusMessage.value = '正在合成...'; |
|
||||
|
|
||||
final text = speechTexts[index]; |
|
||||
|
|
||||
// 播放文本 |
|
||||
await _ttsService.speak(text); |
|
||||
|
|
||||
statusMessage.value = '合成完成'; |
|
||||
|
|
||||
} catch (e) { |
|
||||
errorMessage.value = '播放出错'; |
|
||||
statusMessage.value = '错误'; |
|
||||
Logger.error('播放出错: $e'); |
|
||||
} finally { |
|
||||
isSpeaking.value = false; |
|
||||
if (!_ttsService.isSpeaking.value) { |
|
||||
isAudioPlaying.value = false; |
|
||||
currentPlayingIndex.value = -1; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 启动播放状态检查定时器 |
|
||||
void _startPlaybackCheckTimer() { |
|
||||
_playbackCheckTimer?.cancel(); |
|
||||
_playbackCheckTimer = Timer.periodic(const Duration(milliseconds: 200), (timer) { |
|
||||
// 更新实际播放状态 |
|
||||
final actuallyPlaying = _ttsService.isSpeaking.value; |
|
||||
isAudioPlaying.value = actuallyPlaying; |
|
||||
|
|
||||
// 如果不再播放,确保重置状态 |
|
||||
if (!actuallyPlaying && isSpeaking.value) { |
|
||||
// 播放已完成,重置状态 |
|
||||
isSpeaking.value = false; |
|
||||
currentPlayingIndex.value = -1; |
|
||||
// 更新UI |
|
||||
update(); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/// 连续播放所有文本 |
|
||||
Future<void> speakContinuousTexts() async { |
|
||||
if (isAudioPlaying.value || isLoading.value) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
isSpeaking.value = true; |
|
||||
errorMessage.value = ''; |
|
||||
statusMessage.value = '开始连续播放'; |
|
||||
|
|
||||
// 播放所有文本 |
|
||||
await _ttsService.speakMultiple(speechTexts.toList()); |
|
||||
|
|
||||
statusMessage.value = '连续播放完成'; |
|
||||
|
|
||||
} catch (e) { |
|
||||
errorMessage.value = '连续播放出错'; |
|
||||
statusMessage.value = '错误'; |
|
||||
Logger.error('连续播放出错: $e'); |
|
||||
} finally { |
|
||||
isSpeaking.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止播放 |
|
||||
Future<void> stopSpeaking() async { |
|
||||
try { |
|
||||
final result = await _ttsService.stop(); |
|
||||
|
|
||||
if (result) { |
|
||||
isSpeaking.value = false; |
|
||||
isAudioPlaying.value = false; |
|
||||
currentPlayingIndex.value = -1; |
|
||||
statusMessage.value = '已停止'; |
|
||||
} else { |
|
||||
errorMessage.value = '停止播放失败'; |
|
||||
} |
|
||||
} catch (e) { |
|
||||
errorMessage.value = '停止播放出错'; |
|
||||
Logger.error('停止播放出错: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 切换启用状态 |
|
||||
void toggleEnabled() { |
|
||||
_ttsService.toggleEnabled(); |
|
||||
statusMessage.value = _ttsService.isEnabled.value ? '已启用' : '已禁用'; |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
// 取消定时器 |
|
||||
_playbackCheckTimer?.cancel(); |
|
||||
|
|
||||
// 停止播放 |
|
||||
_ttsService.stop(); |
|
||||
|
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,154 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../../../data/services/flutter_asr_service.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
|
|
||||
/// Flutter ASR测试控制器,演示如何使用FlutterAsrService |
|
||||
class FlutterAsrTestController extends GetxController { |
|
||||
// 获取ASR服务实例 |
|
||||
final FlutterAsrService _asrService = Get.find<FlutterAsrService>(); |
|
||||
|
|
||||
// 当前ASR状态 - 使用Rx包装以便UI可以响应变化 |
|
||||
final Rx<AsrState> _asrState = AsrState.notInitialized.obs; |
|
||||
AsrState get asrState => _asrState.value; |
|
||||
|
|
||||
// 当前识别的文本 - 使用Rx包装以便UI可以响应变化 |
|
||||
final RxString _recognizedText = ''.obs; |
|
||||
String get recognizedText => _recognizedText.value; |
|
||||
|
|
||||
// 识别准确度 - 使用Rx包装以便UI可以响应变化 |
|
||||
final RxDouble _confidence = 0.0.obs; |
|
||||
double get confidence => _confidence.value; |
|
||||
|
|
||||
// 是否正在监听 - 计算属性 |
|
||||
bool get isListening => asrState == AsrState.listening; |
|
||||
|
|
||||
// 是否可用 - 使用Rx包装以便UI可以响应变化 |
|
||||
final RxBool _isAvailable = false.obs; |
|
||||
bool get isAvailable => _isAvailable.value; |
|
||||
|
|
||||
// 可用的语言列表 - 使用Rx包装以便UI可以响应变化 |
|
||||
final RxList<dynamic> _locales = <dynamic>[].obs; |
|
||||
List<dynamic> get locales => _locales; |
|
||||
|
|
||||
// 当前选择的语言 - 使用Rx包装以便UI可以响应变化 |
|
||||
final RxString _currentLocale = ''.obs; |
|
||||
String get currentLocale => _currentLocale.value; |
|
||||
|
|
||||
// 历史记录 - 使用Rx包装以便UI可以响应变化 |
|
||||
final RxList<String> _history = <String>[].obs; |
|
||||
List<String> get history => _history; |
|
||||
|
|
||||
// 连续识别模式 - 使用Rx包装以便UI可以响应变化 |
|
||||
final RxBool _continuousMode = false.obs; |
|
||||
bool get continuousMode => _continuousMode.value; |
|
||||
set continuousMode(bool value) => _continuousMode.value = value; |
|
||||
|
|
||||
// 定时器,用于定期更新状态 |
|
||||
late Timer _updateTimer; |
|
||||
|
|
||||
// 识别结果订阅 |
|
||||
StreamSubscription<String>? _resultSubscription; |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
|
|
||||
// 初始化状态 |
|
||||
_updateState(); |
|
||||
|
|
||||
// 设置定时器,每300毫秒更新一次状态,确保UI能够及时反映服务状态变化 |
|
||||
_updateTimer = Timer.periodic(const Duration(milliseconds: 300), (_) { |
|
||||
_updateState(); |
|
||||
|
|
||||
// 如果启用了连续模式,且当前不在监听状态,且ASR已初始化,则自动重新开始监听 |
|
||||
if (_continuousMode.value && |
|
||||
_asrState.value != AsrState.listening && |
|
||||
_asrState.value != AsrState.notInitialized && |
|
||||
_asrState.value != AsrState.error && |
|
||||
_isAvailable.value) { |
|
||||
_restartListening(); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
// 订阅识别结果流 |
|
||||
_resultSubscription = _asrService.recognitionResults.listen(_onFinalResult); |
|
||||
} |
|
||||
|
|
||||
/// 更新控制器状态,从服务获取最新状态 |
|
||||
void _updateState() { |
|
||||
_asrState.value = _asrService.asrState; |
|
||||
_recognizedText.value = _asrService.lastWords; |
|
||||
_confidence.value = _asrService.confidence; |
|
||||
_isAvailable.value = _asrService.isAvailable; |
|
||||
_locales.assignAll(_asrService.locales); |
|
||||
_currentLocale.value = _asrService.currentLocale; |
|
||||
} |
|
||||
|
|
||||
/// 开始语音识别 |
|
||||
Future<bool> startListening() async { |
|
||||
return await _asrService.startListening(); |
|
||||
} |
|
||||
|
|
||||
/// 停止语音识别 |
|
||||
Future<void> stopListening() async { |
|
||||
await _asrService.stopListening(); |
|
||||
} |
|
||||
|
|
||||
/// 取消语音识别 |
|
||||
Future<void> cancelListening() async { |
|
||||
await _asrService.cancelListening(); |
|
||||
} |
|
||||
|
|
||||
/// 切换连续识别模式 |
|
||||
void toggleContinuousMode() { |
|
||||
_continuousMode.value = !_continuousMode.value; |
|
||||
Logger.info('连续识别模式: ${_continuousMode.value ? '开启' : '关闭'}'); |
|
||||
|
|
||||
// 如果开启连续模式且当前未在监听,则开始监听 |
|
||||
if (_continuousMode.value && !isListening && _isAvailable.value) { |
|
||||
startListening(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 重新开始监听(用于连续模式) |
|
||||
Future<void> _restartListening() async { |
|
||||
// 添加小延迟,避免过于频繁的重启 |
|
||||
await Future.delayed(const Duration(milliseconds: 300)); |
|
||||
if (_continuousMode.value && !isListening && _isAvailable.value) { |
|
||||
Logger.info('连续模式: 重新开始监听'); |
|
||||
await startListening(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置语言 |
|
||||
void setLocale(String localeId) { |
|
||||
_asrService.setLocale(localeId); |
|
||||
_currentLocale.value = localeId; |
|
||||
} |
|
||||
|
|
||||
/// 处理最终识别结果 |
|
||||
void _onFinalResult(String result) { |
|
||||
if (result.isNotEmpty) { |
|
||||
// 添加到历史记录 |
|
||||
_history.insert(0, result); |
|
||||
|
|
||||
// 限制历史记录数量 |
|
||||
if (_history.length > 10) { |
|
||||
_history.removeLast(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 清空历史记录 |
|
||||
void clearHistory() { |
|
||||
_history.clear(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
_updateTimer.cancel(); |
|
||||
_resultSubscription?.cancel(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,163 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../../../data/services/flutter_tts_service.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
import 'dart:async'; |
|
||||
|
|
||||
/// Flutter TTS测试控制器,演示如何使用FlutterTtsService |
|
||||
class FlutterTtsTestController extends GetxController { |
|
||||
// 获取TTS服务实例 |
|
||||
final FlutterTtsService _ttsService = Get.find<FlutterTtsService>(); |
|
||||
|
|
||||
// 当前TTS状态 - 使用Rx包装以便UI可以响应变化 |
|
||||
final Rx<TtsState> _ttsState = TtsState.stopped.obs; |
|
||||
TtsState get ttsState => _ttsState.value; |
|
||||
|
|
||||
// 当前正在播放的文本 - 使用Rx包装以便UI可以响应变化 |
|
||||
final RxString _currentText = ''.obs; |
|
||||
String get currentText => _currentText.value; |
|
||||
|
|
||||
// 是否正在播放 - 计算属性 |
|
||||
bool get isPlaying => ttsState == TtsState.playing; |
|
||||
|
|
||||
// 队列中的文本 - 使用Rx包装以便UI可以响应变化 |
|
||||
final RxList<String> _textQueue = <String>[].obs; |
|
||||
List<String> get textQueue => _textQueue; |
|
||||
|
|
||||
// 可用的语音列表 - 使用Rx包装以便UI可以响应变化 |
|
||||
final RxList<Map<String, String>> _voices = <Map<String, String>>[].obs; |
|
||||
List<Map<String, String>> get voices => _voices; |
|
||||
|
|
||||
// 当前选择的语音 - 使用Rx包装以便UI可以响应变化 |
|
||||
final RxString _currentVoice = ''.obs; |
|
||||
String get currentVoice => _currentVoice.value; |
|
||||
|
|
||||
// 定时器,用于定期更新状态 |
|
||||
late Timer _updateTimer; |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
|
|
||||
// 初始化状态 |
|
||||
_updateState(); |
|
||||
|
|
||||
// 设置定时器,每300毫秒更新一次状态,确保UI能够及时反映服务状态变化 |
|
||||
_updateTimer = Timer.periodic(const Duration(milliseconds: 300), (_) { |
|
||||
_updateState(); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/// 更新控制器状态,从服务获取最新状态 |
|
||||
void _updateState() { |
|
||||
// 更新TTS状态 |
|
||||
final serviceState = _ttsService.ttsState; |
|
||||
if (_ttsState.value != serviceState) { |
|
||||
_ttsState.value = serviceState; |
|
||||
} |
|
||||
|
|
||||
// 更新当前播放文本 |
|
||||
final serviceText = _ttsService.currentText; |
|
||||
if (_currentText.value != serviceText) { |
|
||||
_currentText.value = serviceText; |
|
||||
} |
|
||||
|
|
||||
// 更新队列 |
|
||||
final serviceQueue = _ttsService.textQueue; |
|
||||
if (!listEquals(_textQueue, serviceQueue)) { |
|
||||
_textQueue.value = List.from(serviceQueue); |
|
||||
} |
|
||||
|
|
||||
// 更新可用语音列表 |
|
||||
final serviceVoices = _ttsService.voices; |
|
||||
if (!listEquals(_voices, serviceVoices)) { |
|
||||
_voices.value = List.from(serviceVoices); |
|
||||
} |
|
||||
|
|
||||
// 更新当前选择的语音 |
|
||||
final serviceCurrentVoice = _ttsService.currentVoice; |
|
||||
if (_currentVoice.value != serviceCurrentVoice) { |
|
||||
_currentVoice.value = serviceCurrentVoice; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 比较两个列表是否相等 |
|
||||
bool listEquals<T>(List<T> a, List<T> b) { |
|
||||
if (a.length != b.length) return false; |
|
||||
for (int i = 0; i < a.length; i++) { |
|
||||
if (a[i] != b[i]) return false; |
|
||||
} |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
/// 添加文本到队列并开始播放(如果尚未开始) |
|
||||
Future<void> speak(String text) async { |
|
||||
if (text.isEmpty) { |
|
||||
Logger.warning('文本为空,无法播放'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
await _ttsService.speak(text); |
|
||||
_updateState(); |
|
||||
} |
|
||||
|
|
||||
/// 添加多条文本到队列并开始播放(如果尚未开始) |
|
||||
Future<void> speakTexts(List<String> texts) async { |
|
||||
if (texts.isEmpty) { |
|
||||
Logger.warning('文本列表为空,无法播放'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
await _ttsService.speakTexts(texts); |
|
||||
_updateState(); |
|
||||
} |
|
||||
|
|
||||
/// 停止播放并清空队列 |
|
||||
Future<void> stop() async { |
|
||||
await _ttsService.stop(); |
|
||||
_updateState(); |
|
||||
} |
|
||||
|
|
||||
/// 暂停播放 |
|
||||
Future<void> pause() async { |
|
||||
await _ttsService.pause(); |
|
||||
_updateState(); |
|
||||
} |
|
||||
|
|
||||
/// 设置语音 |
|
||||
Future<void> setVoice(String voiceName) async { |
|
||||
await _ttsService.setVoice(voiceName); |
|
||||
_updateState(); |
|
||||
} |
|
||||
|
|
||||
/// 设置语言 |
|
||||
Future<void> setLanguage(String language) async { |
|
||||
await _ttsService.setLanguage(language); |
|
||||
} |
|
||||
|
|
||||
/// 设置语速 |
|
||||
Future<void> setRate(double rate) async { |
|
||||
await _ttsService.setRate(rate); |
|
||||
} |
|
||||
|
|
||||
/// 设置音量 |
|
||||
Future<void> setVolume(double volume) async { |
|
||||
await _ttsService.setVolume(volume); |
|
||||
} |
|
||||
|
|
||||
/// 设置音调 |
|
||||
Future<void> setPitch(double pitch) async { |
|
||||
await _ttsService.setPitch(pitch); |
|
||||
} |
|
||||
|
|
||||
/// 清空队列并停止播放 |
|
||||
Future<void> clearQueue() async { |
|
||||
await _ttsService.clearQueue(); |
|
||||
_updateState(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
_updateTimer.cancel(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,218 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../../../data/services/volcano_asr_api_service.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
|
|
||||
class VolcanoAsrTestController extends GetxController { |
|
||||
// Observable variables |
|
||||
final isListening = false.obs; |
|
||||
final errorMessage = ''.obs; |
|
||||
final recognitionResults = <String>[].obs; |
|
||||
final isContinuous = false.obs; |
|
||||
final connectionStatus = 'Disconnected'.obs; |
|
||||
|
|
||||
// 当前识别中的文本 |
|
||||
final currentRecognizingText = ''.obs; |
|
||||
|
|
||||
// 当前识别的索引 |
|
||||
int _currentRecognitionIndex = -1; |
|
||||
|
|
||||
// Services |
|
||||
late final VolcanoAsrApiService _asrService; |
|
||||
|
|
||||
// Stream subscriptions |
|
||||
StreamSubscription? _recognitionSubscription; |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
|
|
||||
// Initialize the ASR service |
|
||||
_asrService = Get.find<VolcanoAsrApiService>(); |
|
||||
|
|
||||
// Subscribe to recognition stream |
|
||||
_recognitionSubscription = _asrService.recognitionStream.listen((event) { |
|
||||
switch (event.type) { |
|
||||
case RecognitionEventType.started: |
|
||||
// 开始新的识别 |
|
||||
if (isContinuous.value) { |
|
||||
// 如果是连续模式,检查是否需要创建新条目 |
|
||||
if (_currentRecognitionIndex == -1 || recognitionResults.isEmpty) { |
|
||||
// 如果没有当前识别索引或结果列表为空,添加新条目 |
|
||||
recognitionResults.add(''); |
|
||||
_currentRecognitionIndex = recognitionResults.length - 1; |
|
||||
} |
|
||||
} else { |
|
||||
// 非连续模式,清空结果并添加新条目 |
|
||||
recognitionResults.clear(); |
|
||||
recognitionResults.add(''); |
|
||||
_currentRecognitionIndex = 0; |
|
||||
} |
|
||||
currentRecognizingText.value = ''; |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.recognizing: |
|
||||
// 实时识别结果 |
|
||||
if (event.text.isNotEmpty) { |
|
||||
currentRecognizingText.value = event.text; |
|
||||
|
|
||||
// 确保有一个当前条目可以更新 |
|
||||
if (_currentRecognitionIndex == -1 || _currentRecognitionIndex >= recognitionResults.length) { |
|
||||
recognitionResults.add(''); |
|
||||
_currentRecognitionIndex = recognitionResults.length - 1; |
|
||||
} |
|
||||
|
|
||||
// 更新当前条目 |
|
||||
recognitionResults[_currentRecognitionIndex] = event.text; |
|
||||
} |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.finalResult: |
|
||||
// 最终识别结果 |
|
||||
if (event.text.isNotEmpty) { |
|
||||
// 确保有一个当前条目可以更新 |
|
||||
if (_currentRecognitionIndex == -1 || _currentRecognitionIndex >= recognitionResults.length) { |
|
||||
recognitionResults.add(event.text); |
|
||||
_currentRecognitionIndex = recognitionResults.length - 1; |
|
||||
} else { |
|
||||
// 更新当前条目为最终结果 |
|
||||
recognitionResults[_currentRecognitionIndex] = event.text; |
|
||||
} |
|
||||
|
|
||||
// 收到最终结果后,重置索引,为新句子做准备 |
|
||||
_currentRecognitionIndex = -1; |
|
||||
|
|
||||
// 清空当前识别文本 |
|
||||
currentRecognizingText.value = ''; |
|
||||
} |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.error: |
|
||||
// 错误处理 |
|
||||
if (event.error != null) { |
|
||||
errorMessage.value = event.error!; |
|
||||
|
|
||||
// 如果有错误且正在识别,停止识别 |
|
||||
if (isListening.value) { |
|
||||
isListening.value = false; |
|
||||
connectionStatus.value = 'Disconnected'; |
|
||||
|
|
||||
// 停止语音识别,不尝试重启 |
|
||||
_asrService.stopRecognition(); |
|
||||
|
|
||||
} |
|
||||
} |
|
||||
break; |
|
||||
|
|
||||
// 注意:处理其他事件类型 |
|
||||
default: |
|
||||
// 其他事件类型不做特殊处理 |
|
||||
break; |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
// Update connection status based on service state |
|
||||
ever(_asrService.isListening, (bool listening) { |
|
||||
isListening.value = listening; |
|
||||
connectionStatus.value = listening ? 'Connected' : 'Disconnected'; |
|
||||
}); |
|
||||
|
|
||||
// Update error message from service |
|
||||
if (_asrService.errorMessage.isNotEmpty) { |
|
||||
errorMessage.value = _asrService.errorMessage; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
// Cancel all subscriptions |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
|
|
||||
// Disconnect from the ASR service if still connected |
|
||||
if (_asrService.isConnected) { |
|
||||
stopListening(); |
|
||||
} |
|
||||
|
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
// Start listening for speech |
|
||||
Future<void> startListening() async { |
|
||||
try { |
|
||||
// Clear previous error message |
|
||||
errorMessage.value = ''; |
|
||||
|
|
||||
// 如果是新的识别会话 |
|
||||
if (!isListening.value) { |
|
||||
currentRecognizingText.value = ''; |
|
||||
|
|
||||
// 如果不是连续识别模式,则清空结果列表 |
|
||||
if (!isContinuous.value) { |
|
||||
recognitionResults.clear(); |
|
||||
_currentRecognitionIndex = -1; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 使用麦克风录音功能 |
|
||||
final success = await _asrService.startRecognition( |
|
||||
sampleRate: 16000, |
|
||||
bitsPerSample: 16, |
|
||||
channels: 1, |
|
||||
format: 'pcm', |
|
||||
codec: 'raw', |
|
||||
enablePunc: true, |
|
||||
); |
|
||||
|
|
||||
if (success) { |
|
||||
isListening.value = true; |
|
||||
connectionStatus.value = 'Connected'; |
|
||||
} else { |
|
||||
errorMessage.value = _asrService.errorMessage; |
|
||||
Logger.error('Failed to start recognition: ${_asrService.errorMessage}'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
errorMessage.value = e.toString(); |
|
||||
Logger.error('Error starting recognition: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 重新开始识别(用于连续识别模式) |
|
||||
Future<void> _restartRecognition() async { |
|
||||
// 短暂延迟,确保上一次识别完全结束 |
|
||||
await Future.delayed(const Duration(milliseconds: 500)); |
|
||||
|
|
||||
// 如果仍处于监听状态且是连续识别模式,则开始新的识别 |
|
||||
if (isListening.value && isContinuous.value) { |
|
||||
startListening(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Stop listening for speech |
|
||||
void stopListening() { |
|
||||
try { |
|
||||
connectionStatus.value = 'Disconnecting...'; |
|
||||
|
|
||||
// 停止麦克风录音 |
|
||||
_asrService.stopRecognition().then((_) { |
|
||||
isListening.value = false; |
|
||||
connectionStatus.value = 'Disconnected'; |
|
||||
|
|
||||
// 重置当前识别索引 |
|
||||
_currentRecognitionIndex = -1; |
|
||||
}).catchError((e) { |
|
||||
errorMessage.value = e.toString(); |
|
||||
Logger.error('Error stopping recognition: $e'); |
|
||||
}); |
|
||||
} catch (e) { |
|
||||
errorMessage.value = e.toString(); |
|
||||
Logger.error('Error stopping recognition: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Clear recognition results |
|
||||
void clearResults() { |
|
||||
recognitionResults.clear(); |
|
||||
currentRecognizingText.value = ''; |
|
||||
_currentRecognitionIndex = -1; |
|
||||
} |
|
||||
} |
|
||||
@ -1,194 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'dart:async'; |
|
||||
import 'package:flutter/foundation.dart'; |
|
||||
import '../../../data/services/volcano_tts_service.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
|
|
||||
class VolcanoTtsTestController extends GetxController { |
|
||||
// 使用 VolcanoTtsService 替代 VolcanoTtsApiService |
|
||||
final VolcanoTtsService _ttsService = Get.put(VolcanoTtsService()); |
|
||||
|
|
||||
// 可观察状态 |
|
||||
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_female_meilinvyou_moon_bigtts'.obs; |
|
||||
|
|
||||
// 可用的语音列表 |
|
||||
final List<Map<String, String>> availableVoices = [ |
|
||||
{'id': 'zh_female_meilinvyou_moon_bigtts', 'name': '魅力女友'}, |
|
||||
{'id': 'zh_male_shaonianzixin_moon_bigtts', 'name': '少年梓辛/Brayan'}, |
|
||||
{'id': 'zh_male_beijingxiaoye_moon_bigtts', 'name': '北京小爷'}, |
|
||||
{'id': 'zh_female_daimengchuanmei_moon_bigtts', 'name': '呆萌川妹'}, |
|
||||
{'id': 'zh_female_wanqudashu_moon_bigtts', 'name': '湾区大叔'}, |
|
||||
{'id': 'zh_female_sajiaonvyou_moon_bigtts', 'name': '撒娇女友'}, |
|
||||
{'id': 'zh_male_shenyeboke_moon_bigtts', 'name': '深夜播客'}, |
|
||||
]; |
|
||||
|
|
||||
// 固定的播报内容 |
|
||||
final RxList<String> speechTexts = <String>[ |
|
||||
'这是第一条播报测试,请听取合成效果。', |
|
||||
'这是第二条播报测试,火山语音合成支持连续多条播报。', |
|
||||
'这是第三条播报测试,可以测试语音合成的流畅度和连贯性。', |
|
||||
'这是第四条播报测试,感谢您的使用。', |
|
||||
].obs; |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
|
|
||||
// 初始化UI状态 |
|
||||
isSpeaking.value = false; |
|
||||
isAudioPlaying.value = false; |
|
||||
errorMessage.value = ''; |
|
||||
isLoading.value = false; |
|
||||
|
|
||||
// 监听TTS播放状态 |
|
||||
ever(_ttsService.isPlaying, (bool playing) { |
|
||||
isAudioPlaying.value = playing; |
|
||||
if (!playing && currentPlayingIndex.value >= 0) { |
|
||||
// 如果播放结束,重置当前播放索引 |
|
||||
currentPlayingIndex.value = -1; |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
// 启动播放状态检查定时器 |
|
||||
_startPlaybackCheckTimer(); |
|
||||
} |
|
||||
|
|
||||
/// 更新UI状态 |
|
||||
void updateUIState() { |
|
||||
// 强制更新UI |
|
||||
update(); |
|
||||
} |
|
||||
|
|
||||
/// 播放单条文本 |
|
||||
Future<void> playSingle(int index) async { |
|
||||
if (index < 0 || index >= speechTexts.length) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
isSpeaking.value = true; |
|
||||
isAudioPlaying.value = true; |
|
||||
errorMessage.value = ''; |
|
||||
currentPlayingIndex.value = index; |
|
||||
statusMessage.value = '正在合成...'; |
|
||||
|
|
||||
final text = speechTexts[index]; |
|
||||
|
|
||||
// 播放文本 |
|
||||
await _ttsService.speak(text); |
|
||||
|
|
||||
statusMessage.value = '合成完成'; |
|
||||
|
|
||||
} catch (e) { |
|
||||
errorMessage.value = '播放出错'; |
|
||||
statusMessage.value = '错误'; |
|
||||
Logger.error('播放出错: $e'); |
|
||||
} finally { |
|
||||
isSpeaking.value = false; |
|
||||
if (!_ttsService.isPlaying.value) { |
|
||||
isAudioPlaying.value = false; |
|
||||
currentPlayingIndex.value = -1; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 启动播放状态检查定时器 |
|
||||
void _startPlaybackCheckTimer() { |
|
||||
_playbackCheckTimer?.cancel(); |
|
||||
_playbackCheckTimer = Timer.periodic(const Duration(milliseconds: 200), (timer) { |
|
||||
// 更新实际播放状态 |
|
||||
final actuallyPlaying = _ttsService.isPlaying.value; |
|
||||
isAudioPlaying.value = actuallyPlaying; |
|
||||
|
|
||||
// 如果不再播放,确保重置状态 |
|
||||
if (!actuallyPlaying && isSpeaking.value) { |
|
||||
// 播放已完成,重置状态 |
|
||||
isSpeaking.value = false; |
|
||||
currentPlayingIndex.value = -1; |
|
||||
// 更新UI |
|
||||
update(); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/// 连续播放所有文本 |
|
||||
Future<void> speakContinuousTexts() async { |
|
||||
if (isAudioPlaying.value || isLoading.value) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
isSpeaking.value = true; |
|
||||
errorMessage.value = ''; |
|
||||
statusMessage.value = '开始连续播放'; |
|
||||
|
|
||||
// 连接所有文本 |
|
||||
final allText = speechTexts.join(''); |
|
||||
|
|
||||
// 播放所有文本 |
|
||||
await _ttsService.speak(allText); |
|
||||
|
|
||||
statusMessage.value = '连续播放完成'; |
|
||||
|
|
||||
} catch (e) { |
|
||||
errorMessage.value = '连续播放出错'; |
|
||||
statusMessage.value = '错误'; |
|
||||
Logger.error('连续播放出错: $e'); |
|
||||
} finally { |
|
||||
isSpeaking.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止播放 |
|
||||
Future<void> stopSpeaking() async { |
|
||||
try { |
|
||||
await _ttsService.stop(); |
|
||||
isSpeaking.value = false; |
|
||||
isAudioPlaying.value = false; |
|
||||
currentPlayingIndex.value = -1; |
|
||||
statusMessage.value = '已停止'; |
|
||||
} catch (e) { |
|
||||
errorMessage.value = '停止播放出错'; |
|
||||
Logger.error('停止播放出错: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 切换语音 |
|
||||
void changeVoice(String voiceId) { |
|
||||
currentVoice.value = voiceId; |
|
||||
// VolcanoTtsService 不需要显式设置发音人,在 speak 时会使用环境变量中的默认发音人 |
|
||||
} |
|
||||
|
|
||||
/// 切换启用状态 |
|
||||
void toggleEnabled() { |
|
||||
_ttsService.isEnabled.toggle(); |
|
||||
statusMessage.value = _ttsService.isEnabled.value ? '已启用' : '已禁用'; |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
// 取消定时器 |
|
||||
_playbackCheckTimer?.cancel(); |
|
||||
|
|
||||
// 停止播放 |
|
||||
_ttsService.stop(); |
|
||||
|
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,173 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/azure_asr_test_controller.dart'; |
|
||||
import '../../../data/services/azure_asr_service.dart'; |
|
||||
|
|
||||
class AzureAsrTestView extends GetView<AzureAsrTestController> { |
|
||||
const AzureAsrTestView({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Scaffold( |
|
||||
appBar: AppBar( |
|
||||
title: const Text('Azure语音识别测试'), |
|
||||
actions: [ |
|
||||
_buildLanguageDropdown(), |
|
||||
], |
|
||||
), |
|
||||
body: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|
||||
children: [ |
|
||||
_buildStatusCard(), |
|
||||
const SizedBox(height: 16), |
|
||||
_buildRecognitionResultCard(), |
|
||||
const SizedBox(height: 16), |
|
||||
_buildControlButtons(context), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建状态卡片 |
|
||||
Widget _buildStatusCard() { |
|
||||
return Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'状态信息', |
|
||||
style: TextStyle( |
|
||||
fontSize: 18, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 8), |
|
||||
Obx(() => Text('识别状态: ${_getStateText(controller.asrState.value)}')), |
|
||||
Obx(() => Text('是否正在监听: ${controller.isListening.value ? "是" : "否"}')), |
|
||||
Obx(() => Text('当前语言: ${controller.selectedLanguage.value}')), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建识别结果卡片 |
|
||||
Widget _buildRecognitionResultCard() { |
|
||||
return Expanded( |
|
||||
child: Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'识别结果', |
|
||||
style: TextStyle( |
|
||||
fontSize: 18, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 8), |
|
||||
const Text('部分识别结果:'), |
|
||||
Obx(() => Text( |
|
||||
controller.partialText.value, |
|
||||
style: const TextStyle(color: Colors.blue), |
|
||||
)), |
|
||||
const SizedBox(height: 16), |
|
||||
const Text('最终识别结果:'), |
|
||||
Expanded( |
|
||||
child: Obx(() => SingleChildScrollView( |
|
||||
child: Text( |
|
||||
controller.recognizedText.value, |
|
||||
style: const TextStyle( |
|
||||
fontWeight: FontWeight.bold, |
|
||||
fontSize: 16, |
|
||||
), |
|
||||
), |
|
||||
)), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建控制按钮 |
|
||||
Widget _buildControlButtons(BuildContext context) { |
|
||||
return Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: Obx(() => ElevatedButton( |
|
||||
onPressed: controller.isListening.value |
|
||||
? null |
|
||||
: controller.startSimpleRecognition, |
|
||||
child: const Text('简单识别'), |
|
||||
)), |
|
||||
), |
|
||||
const SizedBox(width: 16), |
|
||||
Expanded( |
|
||||
child: Obx(() => ElevatedButton( |
|
||||
onPressed: controller.toggleContinuousRecognition, |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: controller.isListening.value |
|
||||
? Colors.red |
|
||||
: Theme.of(context).primaryColor, |
|
||||
), |
|
||||
child: Text( |
|
||||
controller.isListening.value ? '停止连续识别' : '开始连续识别', |
|
||||
), |
|
||||
)), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建语言下拉菜单 |
|
||||
Widget _buildLanguageDropdown() { |
|
||||
return Padding( |
|
||||
padding: const EdgeInsets.only(right: 16.0), |
|
||||
child: Obx(() => DropdownButton<String>( |
|
||||
value: controller.selectedLanguage.value, |
|
||||
onChanged: controller.isListening.value |
|
||||
? null |
|
||||
: (value) { |
|
||||
if (value != null) { |
|
||||
controller.changeLanguage(value); |
|
||||
} |
|
||||
}, |
|
||||
items: controller.availableLanguages |
|
||||
.map<DropdownMenuItem<String>>((String value) { |
|
||||
return DropdownMenuItem<String>( |
|
||||
value: value, |
|
||||
child: Text(value), |
|
||||
); |
|
||||
}).toList(), |
|
||||
)), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 获取状态文本 |
|
||||
String _getStateText(AzureAsrState state) { |
|
||||
switch (state) { |
|
||||
case AzureAsrState.notInitialized: |
|
||||
return '未初始化'; |
|
||||
case AzureAsrState.initialized: |
|
||||
return '已初始化'; |
|
||||
case AzureAsrState.listening: |
|
||||
return '正在监听'; |
|
||||
case AzureAsrState.notListening: |
|
||||
return '未监听'; |
|
||||
case AzureAsrState.error: |
|
||||
return '错误'; |
|
||||
default: |
|
||||
return '未知'; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,303 +0,0 @@ |
|||||
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(); |
|
||||
}); |
|
||||
|
|
||||
return Scaffold( |
|
||||
appBar: AppBar( |
|
||||
title: const Text('Azure语音合成测试'), |
|
||||
centerTitle: true, |
|
||||
), |
|
||||
body: SingleChildScrollView( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|
||||
children: [ |
|
||||
// 语音选择 |
|
||||
_buildVoiceSelectionCard(), |
|
||||
|
|
||||
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), |
|
||||
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: 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()), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建语音选择卡片 |
|
||||
Widget _buildVoiceSelectionCard() { |
|
||||
return 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(() { |
|
||||
if (controller.isLoadingVoices.value) { |
|
||||
return const Center( |
|
||||
child: Padding( |
|
||||
padding: EdgeInsets.all(16.0), |
|
||||
child: CircularProgressIndicator(), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
if (controller.availableVoices.isEmpty) { |
|
||||
return const Padding( |
|
||||
padding: EdgeInsets.all(8.0), |
|
||||
child: Text('未找到可用的语音', style: TextStyle(color: Colors.red)), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
return Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text('选择语音:', style: TextStyle(fontWeight: FontWeight.bold)), |
|
||||
const SizedBox(height: 8), |
|
||||
|
|
||||
ListView.builder( |
|
||||
shrinkWrap: true, |
|
||||
physics: const NeverScrollableScrollPhysics(), |
|
||||
itemCount: controller.availableVoices.length, |
|
||||
itemBuilder: (context, index) { |
|
||||
final voice = controller.availableVoices[index]; |
|
||||
final isSelected = controller.selectedVoice.value == voice; |
|
||||
|
|
||||
return RadioListTile<String>( |
|
||||
title: Text(voice), |
|
||||
subtitle: Text(voice), |
|
||||
value: voice, |
|
||||
groupValue: controller.selectedVoice.value, |
|
||||
onChanged: (value) { |
|
||||
if (value != null) { |
|
||||
controller.changeVoice(value); |
|
||||
} |
|
||||
}, |
|
||||
selected: isSelected, |
|
||||
activeColor: Colors.blue, |
|
||||
dense: true, |
|
||||
); |
|
||||
}, |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
}), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -1,476 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/flutter_asr_test_controller.dart'; |
|
||||
import '../../../data/services/flutter_asr_service.dart'; |
|
||||
|
|
||||
/// Flutter ASR测试视图,演示如何使用FlutterAsrService |
|
||||
class FlutterAsrTestView extends GetView<FlutterAsrTestController> { |
|
||||
const FlutterAsrTestView({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Scaffold( |
|
||||
appBar: AppBar( |
|
||||
title: const Text('Flutter ASR Test'), |
|
||||
centerTitle: true, |
|
||||
), |
|
||||
body: SingleChildScrollView( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|
||||
children: [ |
|
||||
// 语音识别控制卡片 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'Speech Recognition Controls', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 16), |
|
||||
// 添加连续识别模式开关 |
|
||||
Obx(() { |
|
||||
final isContinuous = controller.continuousMode; |
|
||||
return SwitchListTile( |
|
||||
title: const Text('连续识别模式'), |
|
||||
subtitle: Text( |
|
||||
isContinuous |
|
||||
? '已开启:识别将自动连续进行' |
|
||||
: '已关闭:识别完成后需手动重新开始', |
|
||||
style: TextStyle( |
|
||||
fontSize: 12, |
|
||||
color: Colors.grey[600], |
|
||||
), |
|
||||
), |
|
||||
value: isContinuous, |
|
||||
onChanged: controller.isAvailable |
|
||||
? (value) => controller.toggleContinuousMode() |
|
||||
: null, |
|
||||
activeColor: Colors.blue, |
|
||||
dense: true, |
|
||||
); |
|
||||
}), |
|
||||
const Divider(height: 24), |
|
||||
Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
|
||||
children: [ |
|
||||
Obx(() { |
|
||||
final isListening = controller.isListening; |
|
||||
return ElevatedButton.icon( |
|
||||
onPressed: isListening || !controller.isAvailable |
|
||||
? null |
|
||||
: () => controller.startListening(), |
|
||||
icon: isListening |
|
||||
? const SizedBox( |
|
||||
width: 20, |
|
||||
height: 20, |
|
||||
child: CircularProgressIndicator(strokeWidth: 2), |
|
||||
) |
|
||||
: const Icon(Icons.mic), |
|
||||
label: Text(isListening ? 'Listening...' : 'Start Listening'), |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Colors.blue, |
|
||||
foregroundColor: Colors.white, |
|
||||
), |
|
||||
); |
|
||||
}), |
|
||||
Obx(() { |
|
||||
final isActive = controller.isListening; |
|
||||
return ElevatedButton.icon( |
|
||||
onPressed: isActive |
|
||||
? () => controller.stopListening() |
|
||||
: null, |
|
||||
icon: const Icon(Icons.stop), |
|
||||
label: const Text('Stop'), |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Colors.red, |
|
||||
foregroundColor: Colors.white, |
|
||||
), |
|
||||
); |
|
||||
}), |
|
||||
], |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 语言选择卡片 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'Language Selection', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
Obx(() { |
|
||||
final locales = controller.locales; |
|
||||
final currentLocale = controller.currentLocale; |
|
||||
|
|
||||
if (locales.isEmpty) { |
|
||||
return const Padding( |
|
||||
padding: EdgeInsets.all(8.0), |
|
||||
child: Text( |
|
||||
'No languages available. Please check ASR settings on your device.', |
|
||||
style: TextStyle( |
|
||||
color: Colors.red, |
|
||||
fontStyle: FontStyle.italic, |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
return DropdownButtonFormField<String>( |
|
||||
decoration: const InputDecoration( |
|
||||
labelText: 'Select Language', |
|
||||
border: OutlineInputBorder(), |
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), |
|
||||
), |
|
||||
value: currentLocale.isNotEmpty ? currentLocale : null, |
|
||||
items: locales.map((locale) { |
|
||||
final name = locale.name ?? 'Unknown'; |
|
||||
final id = locale.localeId ?? ''; |
|
||||
return DropdownMenuItem<String>( |
|
||||
value: id, |
|
||||
child: Text('$name (${id.isNotEmpty ? id : 'Unknown'})'), |
|
||||
); |
|
||||
}).toList(), |
|
||||
onChanged: controller.isListening |
|
||||
? null |
|
||||
: (value) { |
|
||||
if (value != null) { |
|
||||
controller.setLocale(value); |
|
||||
} |
|
||||
}, |
|
||||
isExpanded: true, |
|
||||
hint: const Text('Select a language'), |
|
||||
); |
|
||||
}), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 当前识别结果卡片 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'Current Recognition', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
Obx(() { |
|
||||
final text = controller.recognizedText; |
|
||||
final confidence = controller.confidence; |
|
||||
|
|
||||
return Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Container( |
|
||||
padding: const EdgeInsets.all(16), |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.grey.shade100, |
|
||||
borderRadius: BorderRadius.circular(8), |
|
||||
border: Border.all(color: Colors.grey.shade300), |
|
||||
), |
|
||||
width: double.infinity, |
|
||||
child: Text( |
|
||||
text.isEmpty ? 'Speak to see recognition results here' : text, |
|
||||
style: TextStyle( |
|
||||
fontSize: 16, |
|
||||
fontStyle: text.isEmpty ? FontStyle.italic : FontStyle.normal, |
|
||||
color: text.isEmpty ? Colors.grey : Colors.black, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
if (confidence > 0) |
|
||||
Padding( |
|
||||
padding: const EdgeInsets.only(top: 8.0), |
|
||||
child: Text( |
|
||||
'Confidence: ${(confidence * 100).toStringAsFixed(1)}%', |
|
||||
style: TextStyle( |
|
||||
color: _getConfidenceColor(confidence), |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
}), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 历史记录卡片 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'Recognition History', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
Obx(() { |
|
||||
final hasHistory = controller.history.isNotEmpty; |
|
||||
return TextButton.icon( |
|
||||
onPressed: hasHistory ? () => controller.clearHistory() : null, |
|
||||
icon: const Icon(Icons.clear_all, size: 18), |
|
||||
label: const Text('Clear'), |
|
||||
style: TextButton.styleFrom( |
|
||||
foregroundColor: Colors.red, |
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8), |
|
||||
), |
|
||||
); |
|
||||
}), |
|
||||
], |
|
||||
), |
|
||||
const SizedBox(height: 8), |
|
||||
|
|
||||
Obx(() { |
|
||||
final history = controller.history; |
|
||||
|
|
||||
if (history.isEmpty) { |
|
||||
return const Padding( |
|
||||
padding: EdgeInsets.symmetric(vertical: 16.0), |
|
||||
child: Center( |
|
||||
child: Text( |
|
||||
'No recognition history yet', |
|
||||
style: TextStyle( |
|
||||
fontStyle: FontStyle.italic, |
|
||||
color: Colors.grey, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
return ListView.separated( |
|
||||
shrinkWrap: true, |
|
||||
physics: const NeverScrollableScrollPhysics(), |
|
||||
itemCount: history.length, |
|
||||
separatorBuilder: (context, index) => const Divider(height: 1), |
|
||||
itemBuilder: (context, index) { |
|
||||
final text = history[index]; |
|
||||
|
|
||||
return ListTile( |
|
||||
title: Text(text), |
|
||||
leading: CircleAvatar( |
|
||||
backgroundColor: Colors.blue.shade100, |
|
||||
radius: 18, |
|
||||
child: Text( |
|
||||
'${index + 1}', |
|
||||
style: const TextStyle( |
|
||||
color: Colors.blue, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
dense: true, |
|
||||
); |
|
||||
}, |
|
||||
); |
|
||||
}), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 状态显示卡片 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'Status', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 8), |
|
||||
Obx(() { |
|
||||
final state = controller.asrState; |
|
||||
final stateText = _getAsrStateText(state); |
|
||||
final stateColor = _getAsrStateColor(state); |
|
||||
final isContinuous = controller.continuousMode; |
|
||||
|
|
||||
return Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Container( |
|
||||
padding: const EdgeInsets.all(12), |
|
||||
decoration: BoxDecoration( |
|
||||
color: stateColor.withOpacity(0.1), |
|
||||
borderRadius: BorderRadius.circular(8), |
|
||||
border: Border.all(color: stateColor.withOpacity(0.3)), |
|
||||
), |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
Icon( |
|
||||
_getAsrStateIcon(state), |
|
||||
color: stateColor, |
|
||||
), |
|
||||
const SizedBox(width: 8), |
|
||||
Text( |
|
||||
'Current Status: $stateText', |
|
||||
style: TextStyle( |
|
||||
color: stateColor, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
if (isContinuous) |
|
||||
Padding( |
|
||||
padding: const EdgeInsets.only(top: 8.0), |
|
||||
child: Container( |
|
||||
padding: const EdgeInsets.all(8), |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.blue.withOpacity(0.1), |
|
||||
borderRadius: BorderRadius.circular(4), |
|
||||
border: Border.all(color: Colors.blue.withOpacity(0.3)), |
|
||||
), |
|
||||
child: Row( |
|
||||
children: const [ |
|
||||
Icon( |
|
||||
Icons.repeat, |
|
||||
color: Colors.blue, |
|
||||
size: 16, |
|
||||
), |
|
||||
SizedBox(width: 8), |
|
||||
Text( |
|
||||
'连续识别模式已开启', |
|
||||
style: TextStyle( |
|
||||
color: Colors.blue, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
fontSize: 12, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
}), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 获取ASR状态文本 |
|
||||
String _getAsrStateText(AsrState state) { |
|
||||
switch (state) { |
|
||||
case AsrState.notInitialized: |
|
||||
return 'Not Initialized'; |
|
||||
case AsrState.initialized: |
|
||||
return 'Initialized'; |
|
||||
case AsrState.listening: |
|
||||
return 'Listening'; |
|
||||
case AsrState.notListening: |
|
||||
return 'Not Listening'; |
|
||||
case AsrState.error: |
|
||||
return 'Error'; |
|
||||
default: |
|
||||
return 'Unknown'; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 获取ASR状态颜色 |
|
||||
Color _getAsrStateColor(AsrState state) { |
|
||||
switch (state) { |
|
||||
case AsrState.notInitialized: |
|
||||
return Colors.grey; |
|
||||
case AsrState.initialized: |
|
||||
return Colors.blue; |
|
||||
case AsrState.listening: |
|
||||
return Colors.green; |
|
||||
case AsrState.notListening: |
|
||||
return Colors.orange; |
|
||||
case AsrState.error: |
|
||||
return Colors.red; |
|
||||
default: |
|
||||
return Colors.grey; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 获取ASR状态图标 |
|
||||
IconData _getAsrStateIcon(AsrState state) { |
|
||||
switch (state) { |
|
||||
case AsrState.notInitialized: |
|
||||
return Icons.hourglass_empty; |
|
||||
case AsrState.initialized: |
|
||||
return Icons.check_circle; |
|
||||
case AsrState.listening: |
|
||||
return Icons.mic; |
|
||||
case AsrState.notListening: |
|
||||
return Icons.mic_off; |
|
||||
case AsrState.error: |
|
||||
return Icons.error; |
|
||||
default: |
|
||||
return Icons.help_outline; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 获取置信度颜色 |
|
||||
Color _getConfidenceColor(double confidence) { |
|
||||
if (confidence >= 0.8) { |
|
||||
return Colors.green; |
|
||||
} else if (confidence >= 0.6) { |
|
||||
return Colors.orange; |
|
||||
} else { |
|
||||
return Colors.red; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,334 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/flutter_tts_test_controller.dart'; |
|
||||
import '../../../data/services/flutter_tts_service.dart'; |
|
||||
|
|
||||
/// Flutter TTS测试视图,演示如何使用FlutterTtsService |
|
||||
class FlutterTtsTestView extends GetView<FlutterTtsTestController> { |
|
||||
const FlutterTtsTestView({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Scaffold( |
|
||||
appBar: AppBar( |
|
||||
title: const Text('Flutter TTS Test'), |
|
||||
centerTitle: true, |
|
||||
), |
|
||||
body: SingleChildScrollView( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|
||||
children: [ |
|
||||
// 播放控制卡片 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'Playback Controls', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 16), |
|
||||
Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
|
||||
children: [ |
|
||||
Obx(() { |
|
||||
final isPlaying = controller.isPlaying; |
|
||||
return ElevatedButton.icon( |
|
||||
onPressed: isPlaying |
|
||||
? null |
|
||||
: () => _playAllTexts(), |
|
||||
icon: isPlaying |
|
||||
? const SizedBox( |
|
||||
width: 20, |
|
||||
height: 20, |
|
||||
child: CircularProgressIndicator(strokeWidth: 2), |
|
||||
) |
|
||||
: const Icon(Icons.playlist_play), |
|
||||
label: Text(isPlaying ? 'Playing...' : 'Play All'), |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Colors.green, |
|
||||
foregroundColor: Colors.white, |
|
||||
), |
|
||||
); |
|
||||
}), |
|
||||
Obx(() { |
|
||||
final isActive = controller.ttsState != TtsState.stopped; |
|
||||
return ElevatedButton.icon( |
|
||||
onPressed: isActive |
|
||||
? () => controller.stop() |
|
||||
: null, |
|
||||
icon: const Icon(Icons.stop), |
|
||||
label: const Text('Stop'), |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Colors.red, |
|
||||
foregroundColor: Colors.white, |
|
||||
), |
|
||||
); |
|
||||
}), |
|
||||
], |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 语音选择卡片 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'Voice Selection', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
Obx(() { |
|
||||
final voices = controller.voices; |
|
||||
final currentVoice = controller.currentVoice; |
|
||||
|
|
||||
if (voices.isEmpty) { |
|
||||
return const Padding( |
|
||||
padding: EdgeInsets.all(8.0), |
|
||||
child: Text( |
|
||||
'No voices available. Please check TTS settings on your device.', |
|
||||
style: TextStyle( |
|
||||
color: Colors.red, |
|
||||
fontStyle: FontStyle.italic, |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
return DropdownButtonFormField<String>( |
|
||||
decoration: const InputDecoration( |
|
||||
labelText: 'Select Voice', |
|
||||
border: OutlineInputBorder(), |
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), |
|
||||
), |
|
||||
value: currentVoice.isNotEmpty ? currentVoice : null, |
|
||||
items: voices.map((voice) { |
|
||||
final name = voice['name'] ?? 'Unknown'; |
|
||||
final locale = voice['locale'] ?? ''; |
|
||||
return DropdownMenuItem<String>( |
|
||||
value: name, |
|
||||
child: Text('$name ${locale.isNotEmpty ? '($locale)' : ''}'), |
|
||||
); |
|
||||
}).toList(), |
|
||||
onChanged: controller.isPlaying |
|
||||
? null |
|
||||
: (value) { |
|
||||
if (value != null) { |
|
||||
controller.setVoice(value); |
|
||||
} |
|
||||
}, |
|
||||
isExpanded: true, |
|
||||
hint: const Text('Select a voice'), |
|
||||
); |
|
||||
}), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 播报内容列表卡片 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'Sample Texts', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 预设的播报内容 |
|
||||
ListView.separated( |
|
||||
shrinkWrap: true, |
|
||||
physics: const NeverScrollableScrollPhysics(), |
|
||||
itemCount: _getSampleTexts().length, |
|
||||
separatorBuilder: (context, index) => const Divider(height: 1), |
|
||||
itemBuilder: (context, index) { |
|
||||
final text = _getSampleTexts()[index]; |
|
||||
|
|
||||
return Obx(() { |
|
||||
final isPlaying = controller.isPlaying; |
|
||||
final isCurrentPlaying = controller.isPlaying && |
|
||||
controller.currentText == text; |
|
||||
|
|
||||
return ListTile( |
|
||||
title: Text( |
|
||||
text, |
|
||||
style: TextStyle( |
|
||||
fontWeight: isCurrentPlaying ? FontWeight.bold : FontWeight.normal, |
|
||||
color: isCurrentPlaying ? Colors.blue : null, |
|
||||
), |
|
||||
), |
|
||||
leading: CircleAvatar( |
|
||||
backgroundColor: isCurrentPlaying |
|
||||
? Colors.blue |
|
||||
: Colors.blue.shade100, |
|
||||
radius: 18, |
|
||||
child: Icon( |
|
||||
isCurrentPlaying ? Icons.volume_up : Icons.play_arrow, |
|
||||
color: isCurrentPlaying ? Colors.white : Colors.blue, |
|
||||
size: 20, |
|
||||
), |
|
||||
), |
|
||||
onTap: isPlaying |
|
||||
? null |
|
||||
: () => controller.speak(text), |
|
||||
enabled: !isPlaying, |
|
||||
tileColor: isCurrentPlaying ? Colors.blue.withOpacity(0.1) : null, |
|
||||
shape: RoundedRectangleBorder( |
|
||||
borderRadius: BorderRadius.circular(8), |
|
||||
side: isCurrentPlaying |
|
||||
? const BorderSide(color: Colors.blue, width: 1) |
|
||||
: BorderSide.none, |
|
||||
), |
|
||||
); |
|
||||
}); |
|
||||
}, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 16), |
|
||||
|
|
||||
// 状态显示卡片 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'Status', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(height: 8), |
|
||||
Obx(() { |
|
||||
final state = controller.ttsState; |
|
||||
final stateText = _getTtsStateText(state); |
|
||||
final stateColor = _getTtsStateColor(state); |
|
||||
|
|
||||
return Container( |
|
||||
padding: const EdgeInsets.all(12), |
|
||||
decoration: BoxDecoration( |
|
||||
color: stateColor.withOpacity(0.1), |
|
||||
borderRadius: BorderRadius.circular(8), |
|
||||
border: Border.all(color: stateColor.withOpacity(0.3)), |
|
||||
), |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
Icon( |
|
||||
_getTtsStateIcon(state), |
|
||||
color: stateColor, |
|
||||
), |
|
||||
const SizedBox(width: 8), |
|
||||
Text( |
|
||||
'Current Status: $stateText', |
|
||||
style: TextStyle( |
|
||||
color: stateColor, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
}), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 连续播放所有文本 |
|
||||
void _playAllTexts() { |
|
||||
// 使用控制器的连续播放功能播放所有文本 |
|
||||
controller.speakTexts(_getSampleTexts()); |
|
||||
} |
|
||||
|
|
||||
// 获取TTS状态文本 |
|
||||
String _getTtsStateText(TtsState state) { |
|
||||
switch (state) { |
|
||||
case TtsState.playing: |
|
||||
return 'Playing'; |
|
||||
case TtsState.paused: |
|
||||
return 'Paused'; |
|
||||
case TtsState.stopped: |
|
||||
return 'Stopped'; |
|
||||
default: |
|
||||
return 'Unknown'; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 获取TTS状态颜色 |
|
||||
Color _getTtsStateColor(TtsState state) { |
|
||||
switch (state) { |
|
||||
case TtsState.playing: |
|
||||
return Colors.green; |
|
||||
case TtsState.paused: |
|
||||
return Colors.orange; |
|
||||
case TtsState.stopped: |
|
||||
return Colors.grey; |
|
||||
default: |
|
||||
return Colors.grey; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 获取TTS状态图标 |
|
||||
IconData _getTtsStateIcon(TtsState state) { |
|
||||
switch (state) { |
|
||||
case TtsState.playing: |
|
||||
return Icons.volume_up; |
|
||||
case TtsState.paused: |
|
||||
return Icons.pause; |
|
||||
case TtsState.stopped: |
|
||||
return Icons.stop; |
|
||||
default: |
|
||||
return Icons.help_outline; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 获取示例文本 |
|
||||
List<String> _getSampleTexts() { |
|
||||
return [ |
|
||||
'This is a Flutter TTS test. Please listen to the synthesis effect.', |
|
||||
'这是Flutter TTS测试,请听取合成效果。', |
|
||||
'Flutter TTS supports various voices and parameter adjustments.', |
|
||||
'Flutter TTS支持多种语音和参数调节,可以根据需要进行设置。', |
|
||||
]; |
|
||||
} |
|
||||
} |
|
||||
@ -1,234 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/volcano_asr_test_controller.dart'; |
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||
|
|
||||
class VolcanoAsrTestView extends GetView<VolcanoAsrTestController> { |
|
||||
const VolcanoAsrTestView({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Scaffold( |
|
||||
appBar: AppBar( |
|
||||
title: const Text('火山语音识别测试'), |
|
||||
centerTitle: true, |
|
||||
), |
|
||||
body: Padding( |
|
||||
padding: EdgeInsets.all(16.0.r), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|
||||
children: [ |
|
||||
// 识别模式选择 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: EdgeInsets.all(16.0.r), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Text( |
|
||||
'识别模式', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 8.h), |
|
||||
Obx(() => Row( |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: RadioListTile<bool>( |
|
||||
title: const Text('一次性识别'), |
|
||||
value: false, |
|
||||
groupValue: controller.isContinuous.value, |
|
||||
onChanged: (value) { |
|
||||
if (value != null) { |
|
||||
controller.isContinuous.value = value; |
|
||||
} |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
Expanded( |
|
||||
child: RadioListTile<bool>( |
|
||||
title: const Text('连续识别'), |
|
||||
value: true, |
|
||||
groupValue: controller.isContinuous.value, |
|
||||
onChanged: (value) { |
|
||||
if (value != null) { |
|
||||
controller.isContinuous.value = value; |
|
||||
} |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
)), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
SizedBox(height: 16.h), |
|
||||
|
|
||||
// 录音控制 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: EdgeInsets.all(16.0.r), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Text( |
|
||||
'录音控制', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 16.h), |
|
||||
Center( |
|
||||
child: Obx(() => controller.isListening.value |
|
||||
? ElevatedButton.icon( |
|
||||
onPressed: () => controller.stopListening(), |
|
||||
icon: const Icon(Icons.stop), |
|
||||
label: const Text('停止录音'), |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Colors.red, |
|
||||
foregroundColor: Colors.white, |
|
||||
padding: EdgeInsets.symmetric( |
|
||||
horizontal: 24.w, |
|
||||
vertical: 12.h, |
|
||||
), |
|
||||
), |
|
||||
) |
|
||||
: ElevatedButton.icon( |
|
||||
onPressed: () => controller.startListening(), |
|
||||
icon: const Icon(Icons.mic), |
|
||||
label: const Text('开始录音'), |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Colors.blue, |
|
||||
foregroundColor: Colors.white, |
|
||||
padding: EdgeInsets.symmetric( |
|
||||
horizontal: 24.w, |
|
||||
vertical: 12.h, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
SizedBox(height: 16.h), |
|
||||
|
|
||||
// 识别结果 |
|
||||
Expanded( |
|
||||
child: Card( |
|
||||
child: Padding( |
|
||||
padding: EdgeInsets.all(16.0.r), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
||||
children: [ |
|
||||
Text( |
|
||||
'识别结果', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
IconButton( |
|
||||
icon: const Icon(Icons.clear), |
|
||||
onPressed: () => controller.clearResults(), |
|
||||
tooltip: '清空结果', |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
SizedBox(height: 8.h), |
|
||||
Expanded( |
|
||||
child: Container( |
|
||||
padding: EdgeInsets.all(8.0.r), |
|
||||
decoration: BoxDecoration( |
|
||||
border: Border.all(color: Colors.grey), |
|
||||
borderRadius: BorderRadius.circular(4.0.r), |
|
||||
), |
|
||||
child: Obx(() => ListView.builder( |
|
||||
itemCount: controller.recognitionResults.length, |
|
||||
itemBuilder: (context, index) { |
|
||||
final result = controller.recognitionResults[index]; |
|
||||
return Padding( |
|
||||
padding: EdgeInsets.symmetric(vertical: 4.0.h), |
|
||||
child: Row( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Text( |
|
||||
'${index + 1}. ', |
|
||||
style: const TextStyle( |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
Expanded( |
|
||||
child: Text(result), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
)), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
SizedBox(height: 16.h), |
|
||||
|
|
||||
// 状态显示 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: EdgeInsets.all(16.0.r), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Text( |
|
||||
'状态', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 8.h), |
|
||||
Obx(() => Text( |
|
||||
controller.connectionStatus.value, |
|
||||
style: TextStyle( |
|
||||
color: controller.connectionStatus.value == 'Connected' |
|
||||
? Colors.green |
|
||||
: (controller.connectionStatus.value == 'Disconnecting...' |
|
||||
? Colors.orange |
|
||||
: Colors.grey), |
|
||||
fontWeight: FontWeight.bold, |
|
||||
), |
|
||||
)), |
|
||||
SizedBox(height: 8.h), |
|
||||
Obx(() => controller.errorMessage.value.isNotEmpty |
|
||||
? Text( |
|
||||
'错误: ${controller.errorMessage.value}', |
|
||||
style: TextStyle( |
|
||||
color: Colors.red, |
|
||||
fontSize: 14.sp, |
|
||||
), |
|
||||
) |
|
||||
: const SizedBox.shrink()), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -1,224 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/volcano_tts_test_controller.dart'; |
|
||||
|
|
||||
class VolcanoTtsTestView extends GetView<VolcanoTtsTestController> { |
|
||||
const VolcanoTtsTestView({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
// 强制更新控制器状态 |
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|
||||
// 在下一帧渲染完成后调用 |
|
||||
controller.updateUIState(); |
|
||||
}); |
|
||||
|
|
||||
return Scaffold( |
|
||||
appBar: AppBar( |
|
||||
title: const Text('火山语音合成测试'), |
|
||||
centerTitle: true, |
|
||||
), |
|
||||
body: SingleChildScrollView( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|
||||
children: [ |
|
||||
// 播放控制 |
|
||||
Card( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16.0), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text( |
|
||||
'播放控制', |
|
||||
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), |
|
||||
// 生成5条可点击的播报内容 |
|
||||
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: 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