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.
676 lines
20 KiB
676 lines
20 KiB
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';
|
|
|
|
/// 火山语音服务异常
|
|
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_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();
|
|
} 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;
|
|
} catch (e) {
|
|
print('停止播放失败: $e');
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|