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
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();
|
|
}
|
|
}
|