You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

539 lines
14 KiB

import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../../core/utils/logger.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:path_provider/path_provider.dart';
/// 语音合成事件类型
enum SynthesisEventType {
/// 合成开始
synthesisStarted,
/// 合成进度更新
synthesisProgress,
/// 合成完成
synthesisCompleted,
/// 合成取消
synthesisCanceled,
/// 合成错误
error,
}
/// 语音合成事件
class SynthesisEvent {
/// 事件类型
final SynthesisEventType type;
/// 音频文件路径(仅在 synthesisCompleted 类型中有效)
final String? audioFilePath;
/// 进度百分比(仅在 synthesisProgress 类型中有效)
final int? progressPercent;
/// 错误信息(仅在 error 和 synthesisCanceled 类型中有效)
final String? error;
/// 取消原因(仅在 synthesisCanceled 类型中有效)
final String? reason;
/// 错误详情(仅在 synthesisCanceled 类型中有效)
final String? errorDetails;
SynthesisEvent({
required this.type,
this.audioFilePath,
this.progressPercent,
this.error,
this.reason,
this.errorDetails,
});
/// 从原生事件映射创建合成事件
factory SynthesisEvent.fromMap(Map<dynamic, dynamic> map) {
final eventType = map['eventType'] as String;
SynthesisEventType type;
switch (eventType) {
case 'synthesisStarted':
type = SynthesisEventType.synthesisStarted;
break;
case 'synthesisProgress':
type = SynthesisEventType.synthesisProgress;
break;
case 'synthesisCompleted':
type = SynthesisEventType.synthesisCompleted;
break;
case 'synthesisCanceled':
type = SynthesisEventType.synthesisCanceled;
break;
case 'error':
type = SynthesisEventType.error;
break;
default:
throw ArgumentError('未知事件类型: $eventType');
}
return SynthesisEvent(
type: type,
audioFilePath: map['audioFilePath'] as String?,
progressPercent: map['progressPercent'] as int?,
error: map['error'] as String?,
reason: map['reason'] as String?,
errorDetails: map['errorDetails'] as String?,
);
}
@override
String toString() {
return 'SynthesisEvent{type: $type, audioFilePath: $audioFilePath, progressPercent: $progressPercent, error: $error, reason: $reason, errorDetails: $errorDetails}';
}
}
/// TTS 状态
enum TtsState {
/// 空闲
idle,
/// 合成中
synthesizing,
/// 播放中
playing,
/// 已停止
stopped,
}
/// Azure 文本转语音服务
class AzureTtsService extends GetxService {
static AzureTtsService get to => Get.find();
// 方法通道
static const MethodChannel _methodChannel = MethodChannel('com.example.deep_voice/azure_tts');
// 事件通道
static const EventChannel _eventChannel = EventChannel('com.example.deep_voice/azure_tts_events');
// 是否已初始化
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
// 当前TTS状态
final Rx<TtsState> _ttsState = TtsState.idle.obs;
TtsState get ttsState => _ttsState.value;
// 合成相关
StreamController<SynthesisEvent>? _eventStreamController;
StreamSubscription? _eventSubscription;
// 公开的事件流
Stream<SynthesisEvent>? _synthesisStream;
Stream<SynthesisEvent>? get synthesisStream => _synthesisStream;
// 当前语音名称
String _currentVoice = 'zh-CN-XiaoxiaoNeural';
String get currentVoice => _currentVoice;
set currentVoice(String voice) {
_currentVoice = voice;
_setVoice(voice);
}
// 语速 (-100 to 100)
int _rate = 0;
int get rate => _rate;
set rate(int value) {
if (value < -100 || value > 100) {
throw ArgumentError('语速必须在 -100 到 100 之间');
}
_rate = value;
_setSpeechRate(value);
}
// 音调 (-100 to 100)
int _pitch = 0;
int get pitch => _pitch;
set pitch(int value) {
if (value < -100 || value > 100) {
throw ArgumentError('音调必须在 -100 到 100 之间');
}
_pitch = value;
_setPitch(value);
}
// 音量 (0 to 100)
int _volume = 100;
int get volume => _volume;
set volume(int value) {
if (value < 0 || value > 100) {
throw ArgumentError('音量必须在 0 到 100 之间');
}
_volume = value;
_setVolume(value);
}
// 可用的语音列表
final RxList<Map<String, String>> _voices = <Map<String, String>>[].obs;
List<Map<String, String>> get voices => _voices;
/// 初始化 Azure 文本转语音服务
///
/// [subscriptionKey] Azure 语音服务的订阅密钥
/// [serviceRegion] Azure 语音服务的区域
/// [defaultVoice] 默认语音名称,默认为中文女声
Future<bool> initialize({
String? subscriptionKey,
String? serviceRegion,
String? defaultVoice,
}) async {
if (_isInitialized) return true;
try {
// 从环境变量中读取配置,如果没有传入参数
final key = subscriptionKey ?? dotenv.env['AZURE_TTS_SUBSCRIPTION_KEY'] ?? '';
final region = serviceRegion ?? dotenv.env['AZURE_TTS_SERVICE_REGION'] ?? 'eastasia';
final voice = defaultVoice ?? dotenv.env['AZURE_TTS_DEFAULT_VOICE'] ?? 'zh-CN-XiaoxiaoNeural';
// 检查配置是否完整
if (key.isEmpty) {
Logger.warning('Azure 文本转语音服务的订阅密钥未设置,请检查 .env 文件');
return false;
}
// 设置事件监听
_setupEventChannel();
final result = await _methodChannel.invokeMethod<bool>(
'initialize',
{
'subscriptionKey': key,
'serviceRegion': region,
'defaultVoice': voice,
},
);
_isInitialized = result ?? false;
if (_isInitialized) {
Logger.info('Azure 文本转语音服务初始化成功');
_currentVoice = voice;
// 获取可用语音列表
await getAvailableVoices();
} else {
Logger.error('Azure 文本转语音服务初始化失败');
}
return _isInitialized;
} catch (e) {
Logger.error('初始化 Azure 文本转语音服务失败', e);
_isInitialized = false;
return false;
}
}
/// 设置事件通道监听
void _setupEventChannel() {
// 创建事件流控制器
_eventStreamController = StreamController<SynthesisEvent>.broadcast();
// 设置事件监听
_eventSubscription = _eventChannel
.receiveBroadcastStream()
.map<SynthesisEvent>((dynamic event) => SynthesisEvent.fromMap(event))
.listen(
(event) {
_handleSynthesisEvent(event);
_eventStreamController?.add(event);
},
onError: (error) {
Logger.error('Azure 文本转语音事件流错误', error);
_eventStreamController?.addError(error);
},
);
// 设置公开的流
_synthesisStream = _eventStreamController?.stream;
}
/// 处理合成事件
void _handleSynthesisEvent(SynthesisEvent event) {
switch (event.type) {
case SynthesisEventType.synthesisStarted:
_ttsState.value = TtsState.synthesizing;
break;
case SynthesisEventType.synthesisCompleted:
_ttsState.value = TtsState.idle;
break;
case SynthesisEventType.synthesisCanceled:
case SynthesisEventType.error:
_ttsState.value = TtsState.idle;
break;
default:
// 其他事件类型不需要特殊处理
break;
}
}
/// 获取可用的语音列表
Future<List<Map<String, String>>> getAvailableVoices() async {
_checkInitialized();
try {
final result = await _methodChannel.invokeMethod<List<dynamic>>('getAvailableVoices');
if (result != null) {
final voicesList = result.map((item) {
final map = Map<String, dynamic>.from(item as Map);
return {
'name': map['name'] as String,
'locale': map['locale'] as String,
'gender': map['gender'] as String,
'displayName': map['displayName'] as String,
};
}).toList();
_voices.value = List<Map<String, String>>.from(voicesList);
return _voices;
}
return [];
} catch (e) {
Logger.error('获取可用语音列表失败', e);
return [];
}
}
/// 设置语音
Future<bool> _setVoice(String voiceName) async {
_checkInitialized();
try {
final result = await _methodChannel.invokeMethod<bool>(
'setVoice',
{'voiceName': voiceName},
);
return result ?? false;
} catch (e) {
Logger.error('设置语音失败', e);
return false;
}
}
/// 设置语速
Future<bool> _setSpeechRate(int rate) async {
_checkInitialized();
try {
final result = await _methodChannel.invokeMethod<bool>(
'setSpeechRate',
{'rate': rate},
);
return result ?? false;
} catch (e) {
Logger.error('设置语速失败', e);
return false;
}
}
/// 设置音调
Future<bool> _setPitch(int pitch) async {
_checkInitialized();
try {
final result = await _methodChannel.invokeMethod<bool>(
'setPitch',
{'pitch': pitch},
);
return result ?? false;
} catch (e) {
Logger.error('设置音调失败', e);
return false;
}
}
/// 设置音量
Future<bool> _setVolume(int volume) async {
_checkInitialized();
try {
final result = await _methodChannel.invokeMethod<bool>(
'setVolume',
{'volume': volume},
);
return result ?? false;
} catch (e) {
Logger.error('设置音量失败', e);
return false;
}
}
/// 合成文本为语音并保存到文件
///
/// [text] 要合成的文本
/// [fileName] 保存的文件名(不包含路径和扩展名)
///
/// 返回保存的音频文件路径,如果失败则返回 null
Future<String?> synthesizeToFile(String text, String fileName) async {
_checkInitialized();
if (text.isEmpty) {
Logger.warning('合成文本为空');
return null;
}
try {
// 获取临时目录
final tempDir = await getTemporaryDirectory();
final filePath = '${tempDir.path}/$fileName.wav';
// 设置状态
_ttsState.value = TtsState.synthesizing;
final result = await _methodChannel.invokeMethod<String>(
'synthesizeToFile',
{
'text': text,
'filePath': filePath,
},
);
// 合成完成后设置状态
_ttsState.value = TtsState.idle;
return result;
} catch (e) {
Logger.error('合成文本到文件失败', e);
// 发生错误时设置状态
_ttsState.value = TtsState.idle;
return null;
}
}
/// 合成文本并直接播放
///
/// [text] 要合成并播放的文本
///
/// 返回是否成功开始播放
Future<bool> speak(String text) async {
_checkInitialized();
if (text.isEmpty) {
Logger.warning('播放文本为空');
return false;
}
try {
// 设置状态
_ttsState.value = TtsState.synthesizing;
final result = await _methodChannel.invokeMethod<bool>(
'speak',
{'text': text},
);
if (result == true) {
_ttsState.value = TtsState.playing;
} else {
_ttsState.value = TtsState.idle;
}
return result ?? false;
} catch (e) {
Logger.error('合成并播放文本失败', e);
// 发生错误时设置状态
_ttsState.value = TtsState.idle;
return false;
}
}
/// 停止播放
Future<bool> stop() async {
_checkInitialized();
try {
final result = await _methodChannel.invokeMethod<bool>('stop');
if (result == true) {
_ttsState.value = TtsState.stopped;
}
return result ?? false;
} catch (e) {
Logger.error('停止播放失败', e);
return false;
}
}
/// 暂停播放
Future<bool> pause() async {
_checkInitialized();
try {
final result = await _methodChannel.invokeMethod<bool>('pause');
return result ?? false;
} catch (e) {
Logger.error('暂停播放失败', e);
return false;
}
}
/// 恢复播放
Future<bool> resume() async {
_checkInitialized();
try {
final result = await _methodChannel.invokeMethod<bool>('resume');
return result ?? false;
} catch (e) {
Logger.error('恢复播放失败', e);
return false;
}
}
/// 检查是否已初始化
void _checkInitialized() {
if (!_isInitialized) {
throw StateError('Azure 文本转语音服务未初始化');
}
}
/// 释放资源
Future<void> dispose() async {
try {
await _eventSubscription?.cancel();
_eventSubscription = null;
// 只有在控制器存在且未关闭时才关闭
if (_eventStreamController != null && !_eventStreamController!.isClosed) {
await _eventStreamController?.close();
}
_eventStreamController = null;
_synthesisStream = null;
if (_isInitialized) {
await _methodChannel.invokeMethod<bool>('dispose');
}
_isInitialized = false;
Logger.info('Azure 文本转语音服务资源已释放');
} catch (e) {
Logger.error('释放 Azure 文本转语音服务资源失败', e);
}
}
@override
void onClose() {
dispose();
super.onClose();
}
}