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.
316 lines
8.9 KiB
316 lines
8.9 KiB
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
import 'package:get/get.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:flutter_azure_tts/flutter_azure_tts.dart';
|
|
import 'package:audio_session/audio_session.dart';
|
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
|
|
/// A custom AudioSource that reads audio data from a byte array
|
|
class BytesAudioSource extends StreamAudioSource {
|
|
final Uint8List _bytes;
|
|
static const int _recommendedBufferSize = 8192; // 使用推荐的缓冲区大小
|
|
|
|
BytesAudioSource(this._bytes);
|
|
|
|
@override
|
|
Future<StreamAudioResponse> request([int? start, int? end]) async {
|
|
start = start ?? 0;
|
|
end = end ?? _bytes.length;
|
|
|
|
try {
|
|
// 确保缓冲区大小至少为推荐值
|
|
final bufferSize = (end - start) < _recommendedBufferSize
|
|
? _recommendedBufferSize
|
|
: end - start;
|
|
|
|
final subData = _bytes.sublist(start, end);
|
|
|
|
return StreamAudioResponse(
|
|
sourceLength: _bytes.length,
|
|
contentLength: subData.length,
|
|
offset: start,
|
|
stream: Stream.value(subData),
|
|
contentType: 'audio/mpeg',
|
|
);
|
|
} catch (e) {
|
|
print('BytesAudioSource request error: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<int> get length => Future.value(_bytes.length);
|
|
}
|
|
|
|
/// Azure TTS Service,支持将文本转换为语音并以流式方式边下载边播放
|
|
class AzureTtsService extends GetxService {
|
|
static bool _isInitialized = false;
|
|
final _audioPlayer = AudioPlayer();
|
|
final ConcatenatingAudioSource _playlist = ConcatenatingAudioSource(children: []);
|
|
final isEnabled = false.obs;
|
|
final isPlaying = false.obs;
|
|
StreamSubscription? _playbackEventSubscription;
|
|
StreamSubscription? _playerStateSubscription;
|
|
AudioSession? _audioSession;
|
|
|
|
// Azure 配置
|
|
final String _azureKey;
|
|
final String _azureRegion;
|
|
|
|
// 句子管理
|
|
String _pendingText = '';
|
|
final List<String> _sentenceQueue = [];
|
|
static final _sentenceBreaks = RegExp(r'[。!?.!?]');
|
|
|
|
// 状态
|
|
bool _isFetching = false;
|
|
late Voice _selectedVoice;
|
|
bool _isDisposed = false;
|
|
|
|
AzureTtsService() :
|
|
_azureKey = dotenv.env['AZURE_TTS_KEY'] ?? '',
|
|
_azureRegion = dotenv.env['AZURE_TTS_REGION'] ?? '' {
|
|
if (_azureKey.isEmpty || _azureRegion.isEmpty) {
|
|
throw Exception('Azure TTS配置信息不完整,请检查环境变量');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> onInit() async {
|
|
super.onInit();
|
|
try {
|
|
// 初始化音频会话
|
|
_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,
|
|
));
|
|
|
|
// 设置音频播放器
|
|
if (!_isDisposed) {
|
|
await _audioPlayer.setVolume(1.0);
|
|
await _audioPlayer.setLoopMode(LoopMode.off);
|
|
await _audioPlayer.setAudioSource(
|
|
_playlist,
|
|
initialPosition: Duration.zero,
|
|
preload: false,
|
|
);
|
|
|
|
_playbackEventSubscription = _audioPlayer.playbackEventStream.listen(
|
|
(event) {
|
|
if (_isDisposed) return;
|
|
print('播放事件: $event');
|
|
if (event.processingState == ProcessingState.completed) {
|
|
_tryPlayNext();
|
|
}
|
|
},
|
|
onError: (error) {
|
|
if (_isDisposed) return;
|
|
print('播放错误: $error');
|
|
_handlePlaybackError(error);
|
|
},
|
|
);
|
|
|
|
_playerStateSubscription = _audioPlayer.playerStateStream.listen((state) {
|
|
if (_isDisposed) return;
|
|
print('播放器状态变化: playing=${state.playing},processingState=${state.processingState}');
|
|
isPlaying.value = state.playing;
|
|
|
|
if (state.processingState == ProcessingState.completed) {
|
|
_tryPlayNext();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 初始化 Azure TTS (只初始化一次)
|
|
if (!_isInitialized && !_isDisposed) {
|
|
FlutterAzureTts.init(
|
|
subscriptionKey: _azureKey,
|
|
region: _azureRegion,
|
|
withLogs: true,
|
|
);
|
|
_isInitialized = true;
|
|
}
|
|
|
|
// 获取可用语音列表并设置默认语音
|
|
if (!_isDisposed) {
|
|
final voicesResponse = await FlutterAzureTts.getAvailableVoices();
|
|
_selectedVoice = voicesResponse.voices.firstWhere(
|
|
(v) => v.shortName == 'zh-CN-YunxiNeural',
|
|
orElse: () => voicesResponse.voices.first,
|
|
);
|
|
print('已选择语音: ${_selectedVoice.shortName}');
|
|
}
|
|
} catch (e, stackTrace) {
|
|
print('初始化TTS服务失败: $e');
|
|
print('Stack trace: $stackTrace');
|
|
}
|
|
}
|
|
|
|
Future<void> _cleanupResources() async {
|
|
_isDisposed = true;
|
|
await _playbackEventSubscription?.cancel();
|
|
await _playerStateSubscription?.cancel();
|
|
await _audioSession?.setActive(false);
|
|
await stop();
|
|
await _audioPlayer.dispose();
|
|
}
|
|
|
|
@override
|
|
void onClose() async {
|
|
print('关闭TTS服务');
|
|
await _cleanupResources();
|
|
super.onClose();
|
|
}
|
|
|
|
void _handlePlaybackError(dynamic error) {
|
|
print('处理播放错误: $error');
|
|
// 尝试重新初始化播放器
|
|
_reinitializePlayer();
|
|
}
|
|
|
|
Future<void> _reinitializePlayer() async {
|
|
try {
|
|
await _audioPlayer.stop();
|
|
await _playlist.clear();
|
|
await _audioPlayer.setAudioSource(_playlist);
|
|
print('播放器重新初始化成功');
|
|
} catch (e) {
|
|
print('播放器重新初始化失败: $e');
|
|
}
|
|
}
|
|
|
|
void _tryPlayNext() async {
|
|
if (_playlist.length > 0 && !_audioPlayer.playing) {
|
|
try {
|
|
await _audioPlayer.seek(Duration.zero, index: 0);
|
|
await _audioPlayer.play();
|
|
} catch (e) {
|
|
print('尝试播放下一段失败: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> speak(String text) async {
|
|
if (!isEnabled.value) {
|
|
print('TTS 未启用,跳过播放。');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
print('准备播放文本: $text');
|
|
_pendingText += text;
|
|
_extractFullSentences();
|
|
await _startPreloadIfNeeded();
|
|
|
|
if (!_audioPlayer.playing && _playlist.length > 0) {
|
|
print('开始播放音频');
|
|
await _audioPlayer.play();
|
|
}
|
|
} catch (e) {
|
|
print('播放文本失败: $e');
|
|
}
|
|
}
|
|
|
|
void _extractFullSentences() {
|
|
if (_pendingText.isEmpty) return;
|
|
|
|
final matches = _sentenceBreaks.allMatches(_pendingText).toList();
|
|
if (matches.isEmpty) 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 > 0) {
|
|
_pendingText = _pendingText.substring(lastPos);
|
|
}
|
|
}
|
|
|
|
Future<void> _startPreloadIfNeeded() async {
|
|
if (_isFetching) return;
|
|
if (_sentenceQueue.isEmpty) return;
|
|
|
|
_isFetching = true;
|
|
await _fetchNextSegment();
|
|
}
|
|
|
|
Future<void> _fetchNextSegment() async {
|
|
if (_sentenceQueue.isEmpty) {
|
|
_isFetching = false;
|
|
return;
|
|
}
|
|
|
|
final sentence = _sentenceQueue.removeAt(0);
|
|
print('获取音频片段: $sentence');
|
|
|
|
try {
|
|
final params = TtsParams(
|
|
voice: _selectedVoice,
|
|
audioFormat: AudioOutputFormat.audio16khz32kBitrateMonoMp3,
|
|
rate: 1.0,
|
|
text: sentence,
|
|
);
|
|
|
|
final ttsResponse = await FlutterAzureTts.getTts(params);
|
|
if (ttsResponse.audio != null && ttsResponse.audio.isNotEmpty) {
|
|
print('成功获取音频数据: ${ttsResponse.audio.length} bytes');
|
|
|
|
try {
|
|
final audioSource = BytesAudioSource(ttsResponse.audio);
|
|
await _playlist.add(audioSource);
|
|
print('音频片段已添加到播放列表');
|
|
|
|
if (!_audioPlayer.playing && _playlist.length == 1) {
|
|
await _audioPlayer.setVolume(1.0);
|
|
await _audioPlayer.seek(Duration.zero, index: 0);
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
await _audioPlayer.play();
|
|
}
|
|
} catch (e) {
|
|
print('添加音频源失败: $e');
|
|
}
|
|
} else {
|
|
print('获取音频数据失败: 返回为空或长度为0');
|
|
}
|
|
} catch (e, stackTrace) {
|
|
print('获取音频失败: $e');
|
|
print('Stack trace: $stackTrace');
|
|
}
|
|
|
|
await _fetchNextSegment();
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
try {
|
|
print('停止播放');
|
|
await _audioPlayer.stop();
|
|
await _playlist.clear();
|
|
_pendingText = '';
|
|
_sentenceQueue.clear();
|
|
_isFetching = false;
|
|
isPlaying.value = false;
|
|
} catch (e) {
|
|
print('停止播放失败: $e');
|
|
}
|
|
}
|
|
|
|
void toggleEnabled() {
|
|
isEnabled.toggle();
|
|
if (!isEnabled.value) {
|
|
stop();
|
|
}
|
|
}
|
|
}
|