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.
1392 lines
39 KiB
1392 lines
39 KiB
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
import 'dart:io' as io;
|
|
import 'dart:math' as math;
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
import 'package:web_socket_channel/io.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
/// 火山语音合成双向流式API服务
|
|
class VolcanoTtsApiService extends GetxController {
|
|
// 协议常量
|
|
static const int _protocolVersion = 0x0001;
|
|
static const int _defaultHeaderSize = 0x0001;
|
|
|
|
// 消息类型
|
|
static const int _fullClientRequest = 0x0001;
|
|
static const int _audioOnlyResponse = 0x000B;
|
|
static const int _fullServerResponse = 0x0009;
|
|
static const int _errorInformation = 0x000F;
|
|
|
|
// 消息类型特定标志
|
|
static const int _msgTypeFlagNoSeq = 0x0000;
|
|
static const int _msgTypeFlagPositiveSeq = 0x0001;
|
|
static const int _msgTypeFlagLastNoSeq = 0x0002;
|
|
static const int _msgTypeFlagNegativeSeq = 0x0003;
|
|
static const int _msgTypeFlagWithEvent = 0x0004;
|
|
|
|
// 消息序列化
|
|
static const int _noSerialization = 0x0000;
|
|
static const int _jsonSerialization = 0x0001;
|
|
|
|
// 消息压缩
|
|
static const int _compressionNo = 0x0000;
|
|
static const int _compressionGzip = 0x0001;
|
|
|
|
// 事件类型
|
|
static const int eventNone = 0;
|
|
static const int eventStartConnection = 1;
|
|
static const int eventFinishConnection = 2;
|
|
static const int eventConnectionStarted = 50;
|
|
static const int eventConnectionFailed = 51;
|
|
static const int eventConnectionFinished = 52;
|
|
static const int eventStartSession = 100;
|
|
static const int eventFinishSession = 102;
|
|
static const int eventSessionStarted = 150;
|
|
static const int eventSessionFinished = 152;
|
|
static const int eventSessionFailed = 153;
|
|
static const int eventTaskRequest = 200;
|
|
static const int eventTtsSentenceStart = 350;
|
|
static const int eventTtsSentenceEnd = 351;
|
|
static const int eventTtsResponse = 352;
|
|
|
|
// 服务URL
|
|
static const String _serviceUrl = 'wss://openspeech.bytedance.com/api/v3/tts/bidirection';
|
|
|
|
// 配置参数
|
|
final String _appId;
|
|
final String _token;
|
|
final String _resourceId;
|
|
|
|
// WebSocket连接
|
|
WebSocketChannel? _channel;
|
|
|
|
// 会话ID
|
|
String? _sessionId;
|
|
String? _connectionId;
|
|
|
|
// 状态控制
|
|
bool _isConnected = false;
|
|
bool _isSessionActive = false;
|
|
|
|
// 音频数据控制器
|
|
final _audioDataController = StreamController<Uint8List>.broadcast();
|
|
Stream<Uint8List> get audioStream => _audioDataController.stream;
|
|
|
|
// 事件控制器
|
|
final _eventController = StreamController<Map<String, dynamic>>.broadcast();
|
|
Stream<Map<String, dynamic>> get eventStream => _eventController.stream;
|
|
|
|
// 当前状态
|
|
final isConnecting = false.obs;
|
|
final isConnected = false.obs;
|
|
final isSynthesizing = false.obs;
|
|
|
|
// 音频播放器
|
|
final AudioPlayer _audioPlayer = AudioPlayer();
|
|
|
|
// 播放列表 - 用于连续播放
|
|
final ConcatenatingAudioSource _playlist = ConcatenatingAudioSource(children: []);
|
|
bool _playlistInitialized = false;
|
|
|
|
// 音频数据控制器 - 用于流式播放
|
|
final StreamController<List<int>> _audioStreamController = StreamController<List<int>>();
|
|
|
|
// 是否正在播放
|
|
final RxBool _isPlaying = false.obs;
|
|
bool get isPlaying => _isPlaying.value;
|
|
|
|
// 默认发音人 - 使用预定义的值
|
|
final String defaultSpeaker = 'zh_female_yuanqinvyou_moon_bigtts';
|
|
|
|
// 音频缓冲区
|
|
final List<Uint8List> _audioBuffer = [];
|
|
|
|
// 检查是否需要准备下一个音频项目
|
|
DateTime? _lastCheckTime;
|
|
int? _lastCheckedIndex;
|
|
|
|
// 构造函数
|
|
VolcanoTtsApiService() :
|
|
_appId = dotenv.env['VOLCANO_TTS_APP_ID'] ?? '',
|
|
_token = dotenv.env['VOLCANO_TTS_APP_TOKEN'] ?? '',
|
|
_resourceId = dotenv.env['VOLCANO_TTS_RESOURCE_ID'] ?? '' {
|
|
if (_appId.isEmpty || _token.isEmpty) {
|
|
throw Exception('火山语音配置信息不完整,请检查环境变量 VOLCANO_TTS_APP_ID 和 VOLCANO_TTS_APP_TOKEN');
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
// 初始化时不自动连接,等待需要时再连接
|
|
|
|
// 设置音频播放器状态监听
|
|
_setupAudioPlayerListeners();
|
|
|
|
// 初始化播放列表
|
|
_initializePlaylist();
|
|
}
|
|
|
|
/// 初始化播放列表
|
|
Future<void> _initializePlaylist() async {
|
|
try {
|
|
if (!_playlistInitialized) {
|
|
// 设置音频属性,启用无缝播放
|
|
await _audioPlayer.setLoopMode(LoopMode.off);
|
|
await _audioPlayer.setShuffleModeEnabled(false);
|
|
|
|
// 设置自动连续播放
|
|
await _audioPlayer.setAutomaticallyWaitsToMinimizeStalling(false);
|
|
|
|
// 设置音频源
|
|
await _audioPlayer.setAudioSource(_playlist, initialIndex: 0, initialPosition: Duration.zero);
|
|
|
|
_playlistInitialized = true;
|
|
if (kDebugMode) {
|
|
print('播放列表初始化成功');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
print('初始化播放列表失败: $e');
|
|
}
|
|
// 不抛出异常,避免中断整个流程
|
|
}
|
|
}
|
|
|
|
/// 设置音频播放器状态监听
|
|
void _setupAudioPlayerListeners() {
|
|
_audioPlayer.playerStateStream.listen((state) {
|
|
final processingState = state.processingState;
|
|
final playing = state.playing;
|
|
|
|
if (processingState == ProcessingState.ready && playing) {
|
|
_isPlaying.value = true;
|
|
} else {
|
|
_isPlaying.value = false;
|
|
}
|
|
});
|
|
|
|
_audioPlayer.processingStateStream.listen((state) {
|
|
if (state == ProcessingState.completed) {
|
|
// 播放完成后检查播放列表是否为空
|
|
if (_playlist.length > 0) {
|
|
final currentIndex = _audioPlayer.currentIndex;
|
|
|
|
// 自动播放下一个项目
|
|
_advanceToNextItem();
|
|
} else {
|
|
// 播放列表为空,标记为未播放状态
|
|
_isPlaying.value = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
// 监听序列状态变化
|
|
_audioPlayer.sequenceStateStream.listen((sequenceState) {
|
|
if (sequenceState == null) return;
|
|
|
|
// 如果当前是最后一个项目且播放已完成,标记播放完成
|
|
if (sequenceState.currentIndex == sequenceState.sequence.length - 1 &&
|
|
_audioPlayer.processingState == ProcessingState.completed) {
|
|
_isPlaying.value = false;
|
|
}
|
|
});
|
|
|
|
// 监听位置变化,但减少检查频率
|
|
DateTime _lastPositionCheck = DateTime.now();
|
|
_audioPlayer.positionStream.listen((position) {
|
|
// 限制检查频率,每500毫秒检查一次
|
|
final now = DateTime.now();
|
|
if (now.difference(_lastPositionCheck).inMilliseconds >= 500) {
|
|
_lastPositionCheck = now;
|
|
_checkForNextItem(position);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 检查是否需要准备下一个音频项目
|
|
Future<void> _checkForNextItem(Duration position) async {
|
|
// 只有在播放中且有下一个项目时才检查
|
|
if (!_isPlaying.value || _audioPlayer.currentIndex == null) return;
|
|
|
|
final currentIndex = _audioPlayer.currentIndex!;
|
|
final sequenceState = _audioPlayer.sequenceState;
|
|
|
|
// 如果没有序列状态,则不需要准备
|
|
if (sequenceState == null) return;
|
|
|
|
// 防抖动:如果在短时间内(200毫秒)对同一个索引进行了检查,则跳过
|
|
final now = DateTime.now();
|
|
if (_lastCheckTime != null &&
|
|
_lastCheckedIndex == currentIndex &&
|
|
now.difference(_lastCheckTime!) < const Duration(milliseconds: 200)) {
|
|
return;
|
|
}
|
|
|
|
// 更新最后检查时间和索引
|
|
_lastCheckTime = now;
|
|
_lastCheckedIndex = currentIndex;
|
|
|
|
// 获取当前项目的总时长
|
|
final duration = _audioPlayer.duration;
|
|
if (duration == null) return;
|
|
|
|
// 如果是最后一个项目,不需要预加载
|
|
final isLastItem = currentIndex >= sequenceState.sequence.length - 1;
|
|
|
|
// 如果接近结束(剩余时间小于200毫秒),准备下一个项目或完成播放
|
|
if (duration - position <= const Duration(milliseconds: 200) && !isLastItem) {
|
|
// 让播放器自然过渡到下一个项目
|
|
}
|
|
}
|
|
|
|
/// 前进到播放列表中的下一个项目
|
|
Future<void> _advanceToNextItem() async {
|
|
try {
|
|
if (_playlist.length == 0) return;
|
|
|
|
// 获取当前索引和序列状态
|
|
final currentIndex = _audioPlayer.currentIndex;
|
|
final sequenceState = _audioPlayer.sequenceState;
|
|
|
|
if (sequenceState == null) {
|
|
// 如果没有序列状态,直接开始播放
|
|
await _audioPlayer.seek(Duration.zero, index: 0);
|
|
await _audioPlayer.play();
|
|
return;
|
|
}
|
|
|
|
// 如果当前索引无效,从头开始播放
|
|
if (currentIndex == null || currentIndex < 0) {
|
|
await _audioPlayer.seek(Duration.zero, index: 0);
|
|
await _audioPlayer.play();
|
|
return;
|
|
}
|
|
|
|
// 计算下一个索引
|
|
final nextIndex = currentIndex + 1;
|
|
|
|
// 检查是否还有下一个项目
|
|
if (nextIndex < sequenceState.sequence.length) {
|
|
// 跳转到下一个项目并开始播放
|
|
await _audioPlayer.seek(Duration.zero, index: nextIndex);
|
|
await _audioPlayer.play();
|
|
} else {
|
|
// 已经是最后一个项目
|
|
|
|
// 确保最后一个项目播放完成
|
|
if (_audioPlayer.position < _audioPlayer.duration!) {
|
|
await _audioPlayer.seek(_audioPlayer.duration!);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// 简单记录错误,不做复杂处理
|
|
}
|
|
}
|
|
|
|
/// 连接到火山语音服务
|
|
Future<bool> connect() async {
|
|
if (_isConnected) {
|
|
return true; // 已经连接
|
|
}
|
|
print('连接');
|
|
|
|
try {
|
|
isConnecting.value = true;
|
|
|
|
|
|
|
|
// 生成连接ID
|
|
_connectionId = const Uuid().v4();
|
|
|
|
// 创建WebSocket连接
|
|
final uri = Uri.parse(_serviceUrl);
|
|
|
|
if (!kIsWeb && (io.Platform.isAndroid || io.Platform.isIOS || io.Platform.isMacOS || io.Platform.isLinux || io.Platform.isWindows)) {
|
|
// 移动平台和桌面平台 - 使用IOWebSocketChannel
|
|
|
|
|
|
_channel = IOWebSocketChannel.connect(
|
|
uri,
|
|
headers: {
|
|
'X-Api-App-Key': _appId,
|
|
'X-Api-Access-Key': _token,
|
|
'X-Api-Resource-Id': _resourceId,
|
|
'X-Api-Connect-Id': _connectionId!,
|
|
},
|
|
);
|
|
} else {
|
|
// Web平台 - 使用WebSocketChannel
|
|
// 注意:在Web平台上,我们无法直接设置WebSocket头部
|
|
// 这可能会导致认证失败,需要与服务提供商确认Web平台的认证方式
|
|
|
|
_channel = WebSocketChannel.connect(uri);
|
|
}
|
|
|
|
// 监听消息
|
|
_channel!.stream.listen(
|
|
_handleMessage,
|
|
onError: _handleError,
|
|
onDone: _handleDone,
|
|
cancelOnError: false,
|
|
);
|
|
|
|
|
|
|
|
// 发送开始连接事件
|
|
await _startConnection();
|
|
|
|
// 等待连接成功
|
|
final completer = Completer<bool>();
|
|
|
|
// 设置超时
|
|
final timeout = Timer(const Duration(seconds: 10), () {
|
|
if (!completer.isCompleted) {
|
|
completer.complete(false);
|
|
_handleError(Exception('连接超时'));
|
|
}
|
|
});
|
|
|
|
|
|
// 监听连接事件
|
|
final subscription = eventStream.listen((event) {
|
|
|
|
if (event['eventType'] == 'connectionStarted') {
|
|
if (!completer.isCompleted) {
|
|
if (kDebugMode) {
|
|
print('连接成功');
|
|
}
|
|
completer.complete(true);
|
|
timeout.cancel();
|
|
}
|
|
} else if (event['eventType'] == 'connectionFailed') {
|
|
if (!completer.isCompleted) {
|
|
if (kDebugMode) {
|
|
print('连接失败: ${event['error']}');
|
|
}
|
|
completer.complete(false);
|
|
timeout.cancel();
|
|
}
|
|
}
|
|
});
|
|
|
|
// 等待连接结果
|
|
final result = await completer.future;
|
|
subscription.cancel();
|
|
|
|
if (result) {
|
|
_isConnected = true;
|
|
isConnected.value = true;
|
|
|
|
}
|
|
return result;
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
print('连接过程中发生异常: $e');
|
|
}
|
|
_handleError(e);
|
|
return false;
|
|
} finally {
|
|
isConnecting.value = false;
|
|
}
|
|
}
|
|
|
|
/// 开始TTS会话
|
|
Future<bool> startSession(String speaker) async {
|
|
print('开始会话');
|
|
if (!_isConnected) {
|
|
final connected = await connect();
|
|
if (!connected) return false;
|
|
}
|
|
|
|
if (_isSessionActive) {
|
|
return true; // 会话已经活跃
|
|
}
|
|
|
|
try {
|
|
// 生成会话ID
|
|
_sessionId = const Uuid().v4().replaceAll('-', '');
|
|
|
|
// 发送开始会话事件
|
|
await _startTtsSession(speaker);
|
|
|
|
// 等待会话开始
|
|
final completer = Completer<bool>();
|
|
|
|
// 设置超时
|
|
final timeout = Timer(const Duration(seconds: 10), () {
|
|
if (!completer.isCompleted) {
|
|
completer.complete(false);
|
|
_handleError(Exception('会话开始超时'));
|
|
}
|
|
});
|
|
|
|
// 监听会话事件
|
|
final subscription = eventStream.listen((event) {
|
|
if (event['eventType'] == 'sessionStarted') {
|
|
if (!completer.isCompleted) {
|
|
completer.complete(true);
|
|
timeout.cancel();
|
|
}
|
|
} else if (event['eventType'] == 'sessionFailed') {
|
|
if (!completer.isCompleted) {
|
|
completer.complete(false);
|
|
timeout.cancel();
|
|
}
|
|
}
|
|
});
|
|
|
|
// 等待会话结果
|
|
final result = await completer.future;
|
|
subscription.cancel();
|
|
|
|
if (result) {
|
|
_isSessionActive = true;
|
|
}
|
|
|
|
return result;
|
|
} catch (e) {
|
|
_handleError(e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 合成文本
|
|
Future<bool> synthesize(String text, String speaker) async {
|
|
if (!_isConnected) {
|
|
final connected = await connect();
|
|
if (!connected) return false;
|
|
}
|
|
|
|
if (!_isSessionActive) {
|
|
final sessionStarted = await startSession(speaker);
|
|
if (!sessionStarted) return false;
|
|
}
|
|
|
|
try {
|
|
isSynthesizing.value = true;
|
|
|
|
// 发送合成请求
|
|
await _sendTtsMessage(speaker, text);
|
|
|
|
return true;
|
|
} catch (e) {
|
|
_handleError(e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 结束会话
|
|
Future<bool> endSession() async {
|
|
if (!_isSessionActive) {
|
|
return true; // 没有活跃会话
|
|
}
|
|
print('结束会话');
|
|
|
|
try {
|
|
// 发送结束会话事件
|
|
await _finishSession();
|
|
|
|
// 等待会话结束
|
|
final completer = Completer<bool>();
|
|
|
|
// 设置超时
|
|
final timeout = Timer(const Duration(seconds: 5), () {
|
|
if (!completer.isCompleted) {
|
|
completer.complete(true); // 超时也认为成功
|
|
}
|
|
});
|
|
|
|
// 监听会话事件
|
|
final subscription = eventStream.listen((event) {
|
|
if (event['eventType'] == 'sessionFinished') {
|
|
if (!completer.isCompleted) {
|
|
completer.complete(true);
|
|
timeout.cancel();
|
|
}
|
|
}
|
|
});
|
|
|
|
// 等待会话结果
|
|
final result = await completer.future;
|
|
subscription.cancel();
|
|
|
|
_isSessionActive = false;
|
|
isSynthesizing.value = false;
|
|
|
|
return result;
|
|
} catch (e) {
|
|
_handleError(e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 断开连接
|
|
Future<bool> disconnect() async {
|
|
|
|
print('断开连接');
|
|
if (!_isConnected) {
|
|
return true; // 已经断开
|
|
}
|
|
|
|
try {
|
|
// 如果有活跃会话,先结束会话
|
|
if (_isSessionActive) {
|
|
await endSession();
|
|
}
|
|
|
|
// 发送结束连接事件
|
|
await _finishConnection();
|
|
|
|
// 等待连接结束
|
|
final completer = Completer<bool>();
|
|
|
|
// 设置超时
|
|
final timeout = Timer(const Duration(seconds: 5), () {
|
|
if (!completer.isCompleted) {
|
|
completer.complete(true); // 超时也认为成功
|
|
}
|
|
});
|
|
|
|
// 监听连接事件
|
|
final subscription = eventStream.listen((event) {
|
|
if (event['eventType'] == 'connectionFinished') {
|
|
if (!completer.isCompleted) {
|
|
completer.complete(true);
|
|
timeout.cancel();
|
|
}
|
|
}
|
|
});
|
|
|
|
// 等待连接结果
|
|
final result = await completer.future;
|
|
subscription.cancel();
|
|
|
|
// 关闭WebSocket连接
|
|
await _channel?.sink.close();
|
|
_channel = null;
|
|
|
|
_isConnected = false;
|
|
_isSessionActive = false;
|
|
isConnected.value = false;
|
|
isSynthesizing.value = false;
|
|
|
|
return result;
|
|
} catch (e) {
|
|
_handleError(e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 处理接收到的消息
|
|
void _handleMessage(dynamic message) {
|
|
try {
|
|
if (message is! List<int>) {
|
|
_handleError(Exception('收到非二进制消息: $message'));
|
|
return;
|
|
}
|
|
|
|
// 转换为Uint8List以提高处理效率
|
|
final messageData = Uint8List.fromList(message);
|
|
|
|
final response = _parseResponse(messageData);
|
|
if (response == null) {
|
|
_handleError(Exception('解析响应失败'));
|
|
return;
|
|
}
|
|
|
|
// 处理事件
|
|
final event = response['event'] as int?;
|
|
if (event != null) {
|
|
switch (event) {
|
|
case eventConnectionStarted:
|
|
_eventController.add({'eventType': 'connectionStarted'});
|
|
break;
|
|
case eventConnectionFailed:
|
|
_eventController.add({
|
|
'eventType': 'connectionFailed',
|
|
'error': response['errorDetails'] ?? '连接失败'
|
|
});
|
|
break;
|
|
case eventSessionStarted:
|
|
_eventController.add({'eventType': 'sessionStarted'});
|
|
break;
|
|
case eventSessionFailed:
|
|
_eventController.add({
|
|
'eventType': 'sessionFailed',
|
|
'error': response['errorDetails'] ?? '会话失败'
|
|
});
|
|
break;
|
|
case eventTtsSentenceStart:
|
|
_eventController.add({'eventType': 'ttsSentenceStart'});
|
|
_prepareAudioStream();
|
|
break;
|
|
case eventTtsSentenceEnd:
|
|
_eventController.add({'eventType': 'ttsSentenceEnd'});
|
|
// 结束音频流
|
|
_finishAudioStream();
|
|
break;
|
|
case eventTtsResponse:
|
|
// 处理音频数据
|
|
final payload = response['payload'] as Uint8List?;
|
|
if (payload != null && payload.isNotEmpty) {
|
|
try {
|
|
_eventController.add({'eventType': 'ttsResponse'});
|
|
_audioDataController.add(payload);
|
|
|
|
// 将音频数据添加到流中
|
|
_addAudioData(payload);
|
|
} catch (audioError) {
|
|
// 继续处理,不中断整个流程
|
|
}
|
|
}
|
|
break;
|
|
case eventSessionFinished:
|
|
_eventController.add({'eventType': 'sessionFinished'});
|
|
break;
|
|
case eventConnectionFinished:
|
|
_eventController.add({'eventType': 'connectionFinished'});
|
|
break;
|
|
default:
|
|
_eventController.add({'eventType': 'unknown', 'eventCode': event});
|
|
break;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_handleError(e);
|
|
}
|
|
}
|
|
|
|
/// 准备音频流
|
|
void _prepareAudioStream() {
|
|
try {
|
|
// 清空音频缓冲区
|
|
_audioBuffer.clear();
|
|
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
print('准备音频流时发生错误: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 添加音频数据到流
|
|
void _addAudioData(Uint8List data) {
|
|
try {
|
|
// 将音频数据添加到缓冲区
|
|
if (data.isNotEmpty) {
|
|
_audioBuffer.add(data);
|
|
|
|
}
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
print('添加音频数据到流时发生错误: $e');
|
|
}
|
|
// 不抛出异常,继续处理
|
|
}
|
|
}
|
|
|
|
/// 结束音频流
|
|
void _finishAudioStream() {
|
|
try {
|
|
if (_audioBuffer.isNotEmpty) {
|
|
|
|
|
|
// 合并所有音频数据
|
|
final totalSize = _audioBuffer.fold<int>(0, (sum, data) => sum + data.length);
|
|
final mergedData = Uint8List(totalSize);
|
|
|
|
int offset = 0;
|
|
for (final data in _audioBuffer) {
|
|
mergedData.setRange(offset, offset + data.length, data);
|
|
offset += data.length;
|
|
}
|
|
|
|
// 清空缓冲区
|
|
_audioBuffer.clear();
|
|
|
|
// 将合并后的数据添加到播放列表
|
|
_addToPlaylist(mergedData);
|
|
} else {
|
|
|
|
}
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
print('结束音频流时发生错误: $e');
|
|
}
|
|
// 不抛出异常,避免中断整个流程
|
|
}
|
|
}
|
|
|
|
/// 将音频数据添加到播放列表
|
|
Future<void> _addToPlaylist(Uint8List audioData) async {
|
|
try {
|
|
if (audioData.isEmpty) return;
|
|
|
|
// 确保播放列表已初始化
|
|
if (!_playlistInitialized) {
|
|
await _initializePlaylist();
|
|
}
|
|
|
|
// 创建音频源
|
|
final audioSource = BytesAudioSource(audioData);
|
|
|
|
// 添加到播放列表
|
|
await _playlist.add(audioSource);
|
|
|
|
// 如果当前没有播放,开始播放
|
|
if (!_isPlaying.value) {
|
|
await _startPlayback();
|
|
} else if (_audioPlayer.processingState == ProcessingState.completed) {
|
|
// 如果播放已完成但状态还没更新,手动前进到下一个
|
|
await _advanceToNextItem();
|
|
}
|
|
} catch (e) {
|
|
// 直接报告错误,不尝试恢复
|
|
_eventController.add({
|
|
'eventType': 'playbackError',
|
|
'error': '添加音频到播放列表失败: $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();
|
|
} catch (e) {
|
|
// 直接报告错误,不尝试恢复
|
|
_eventController.add({
|
|
'eventType': 'playbackError',
|
|
'error': '开始播放失败: $e'
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 处理错误
|
|
void _handleError(dynamic error) {
|
|
_eventController.add({
|
|
'eventType': 'error',
|
|
'error': error.toString()
|
|
});
|
|
|
|
if (kDebugMode) {
|
|
print('火山语音WebSocket错误: $error');
|
|
}
|
|
}
|
|
|
|
/// 处理连接关闭
|
|
void _handleDone() {
|
|
_isConnected = false;
|
|
_isSessionActive = false;
|
|
isConnected.value = false;
|
|
isSynthesizing.value = false;
|
|
|
|
_eventController.add({'eventType': 'disconnected'});
|
|
|
|
|
|
}
|
|
|
|
/// 解析响应
|
|
Map<String, dynamic>? _parseResponse(Uint8List data) {
|
|
try {
|
|
if (data.length < 4) {
|
|
return null;
|
|
}
|
|
|
|
// 解析头部
|
|
final header = {
|
|
'protocol_version': (data[0] >> 4) & 0x0F,
|
|
'header_size': data[0] & 0x0F,
|
|
'message_type': (data[1] >> 4) & 0x0F,
|
|
'message_type_specific_flags': data[1] & 0x0F,
|
|
'serialization_method': (data[2] >> 4) & 0x0F,
|
|
'message_compression': data[2] & 0x0F,
|
|
'reserved': data[3],
|
|
};
|
|
|
|
final result = <String, dynamic>{
|
|
'header': header,
|
|
};
|
|
|
|
int offset = 4;
|
|
|
|
// 解析事件
|
|
if (header['message_type_specific_flags'] == _msgTypeFlagWithEvent) {
|
|
if (data.length < offset + 4) {
|
|
return result;
|
|
}
|
|
|
|
final event = _bytesToInt(data.sublist(offset, offset + 4));
|
|
result['event'] = event;
|
|
offset += 4;
|
|
}
|
|
|
|
// 根据消息类型和事件类型解析剩余数据
|
|
final messageType = header['message_type'];
|
|
final event = result['event'] as int?;
|
|
|
|
if (messageType == _fullServerResponse || messageType == _audioOnlyResponse) {
|
|
if (event != null) {
|
|
switch (event) {
|
|
case eventConnectionStarted:
|
|
if (data.length < offset + 4) break;
|
|
|
|
final connectionIdSize = _bytesToInt(data.sublist(offset, offset + 4));
|
|
offset += 4;
|
|
|
|
if (data.length < offset + connectionIdSize) break;
|
|
|
|
final connectionId = String.fromCharCodes(data.sublist(offset, offset + connectionIdSize));
|
|
result['connectionId'] = connectionId;
|
|
offset += connectionIdSize;
|
|
break;
|
|
|
|
case eventConnectionFailed:
|
|
if (data.length < offset + 4) break;
|
|
|
|
final metaSize = _bytesToInt(data.sublist(offset, offset + 4));
|
|
offset += 4;
|
|
|
|
if (data.length < offset + metaSize) break;
|
|
|
|
final metaJson = String.fromCharCodes(data.sublist(offset, offset + metaSize));
|
|
result['meta'] = metaJson;
|
|
offset += metaSize;
|
|
|
|
try {
|
|
final meta = jsonDecode(metaJson);
|
|
result['errorDetails'] = meta['error_msg'] ?? 'Unknown error';
|
|
} catch (_) {}
|
|
break;
|
|
|
|
case eventSessionStarted:
|
|
case eventSessionFailed:
|
|
case eventSessionFinished:
|
|
// 读取会话ID
|
|
if (data.length < offset + 4) break;
|
|
|
|
final sessionIdSize = _bytesToInt(data.sublist(offset, offset + 4));
|
|
offset += 4;
|
|
|
|
if (data.length < offset + sessionIdSize) break;
|
|
|
|
final sessionId = String.fromCharCodes(data.sublist(offset, offset + sessionIdSize));
|
|
result['sessionId'] = sessionId;
|
|
offset += sessionIdSize;
|
|
|
|
// 读取元数据
|
|
if (data.length < offset + 4) break;
|
|
|
|
final metaSize = _bytesToInt(data.sublist(offset, offset + 4));
|
|
offset += 4;
|
|
|
|
if (data.length < offset + metaSize) break;
|
|
|
|
final metaJson = String.fromCharCodes(data.sublist(offset, offset + metaSize));
|
|
result['meta'] = metaJson;
|
|
offset += metaSize;
|
|
|
|
try {
|
|
final meta = jsonDecode(metaJson);
|
|
if (meta['error_msg'] != null) {
|
|
result['errorDetails'] = meta['error_msg'];
|
|
}
|
|
} catch (_) {}
|
|
break;
|
|
|
|
default:
|
|
// 读取会话ID
|
|
if (data.length < offset + 4) break;
|
|
|
|
final sessionIdSize = _bytesToInt(data.sublist(offset, offset + 4));
|
|
offset += 4;
|
|
|
|
if (data.length < offset + sessionIdSize) break;
|
|
|
|
final sessionId = String.fromCharCodes(data.sublist(offset, offset + sessionIdSize));
|
|
result['sessionId'] = sessionId;
|
|
offset += sessionIdSize;
|
|
|
|
// 读取序列号
|
|
if (header['message_type_specific_flags'] == _msgTypeFlagNegativeSeq ||
|
|
header['message_type_specific_flags'] == _msgTypeFlagPositiveSeq) {
|
|
if (data.length < offset + 4) break;
|
|
|
|
final sequence = _bytesToInt(data.sublist(offset, offset + 4));
|
|
result['sequence'] = sequence;
|
|
offset += 4;
|
|
}
|
|
|
|
// 读取负载
|
|
if (data.length < offset + 4) break;
|
|
|
|
final payloadSize = _bytesToInt(data.sublist(offset, offset + 4));
|
|
offset += 4;
|
|
|
|
if (data.length < offset + payloadSize) break;
|
|
|
|
final payload = data.sublist(offset, offset + payloadSize);
|
|
result['payload'] = payload;
|
|
|
|
if (messageType == _fullServerResponse) {
|
|
try {
|
|
final payloadStr = String.fromCharCodes(payload);
|
|
result['payloadJson'] = payloadStr;
|
|
} catch (_) {}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} else if (messageType == _errorInformation) {
|
|
if (data.length < offset + 4) {
|
|
return result;
|
|
}
|
|
|
|
final errorCode = _bytesToInt(data.sublist(offset, offset + 4));
|
|
result['errorCode'] = errorCode;
|
|
offset += 4;
|
|
|
|
// 读取负载
|
|
if (data.length < offset + 4) {
|
|
return result;
|
|
}
|
|
|
|
final payloadSize = _bytesToInt(data.sublist(offset, offset + 4));
|
|
offset += 4;
|
|
|
|
if (data.length < offset + payloadSize) {
|
|
return result;
|
|
}
|
|
|
|
final payload = data.sublist(offset, offset + payloadSize);
|
|
try {
|
|
final payloadStr = String.fromCharCodes(payload);
|
|
result['errorDetails'] = payloadStr;
|
|
} catch (_) {}
|
|
}
|
|
|
|
return result;
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
print('解析响应失败: $e');
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 开始连接
|
|
Future<void> _startConnection() async {
|
|
final header = _createHeader(
|
|
_fullClientRequest,
|
|
_msgTypeFlagWithEvent,
|
|
_jsonSerialization,
|
|
_compressionNo
|
|
);
|
|
|
|
final optional = _createOptional(eventStartConnection, null, -1);
|
|
final payload = utf8.encode('{}');
|
|
|
|
await _sendEvent(header, optional, payload);
|
|
}
|
|
|
|
/// 结束连接
|
|
Future<void> _finishConnection() async {
|
|
final header = _createHeader(
|
|
_fullClientRequest,
|
|
_msgTypeFlagWithEvent,
|
|
_jsonSerialization,
|
|
_compressionNo
|
|
);
|
|
|
|
final optional = _createOptional(eventFinishConnection, null, -1);
|
|
final payload = utf8.encode('{}');
|
|
|
|
await _sendEvent(header, optional, payload);
|
|
}
|
|
|
|
/// 开始TTS会话
|
|
Future<void> _startTtsSession(String speaker) async {
|
|
final header = _createHeader(
|
|
_fullClientRequest,
|
|
_msgTypeFlagWithEvent,
|
|
_jsonSerialization,
|
|
_compressionNo
|
|
);
|
|
|
|
final optional = _createOptional(eventStartSession, _sessionId, -1);
|
|
|
|
// 创建会话参数
|
|
final payloadMap = {
|
|
'user': {'uid': '123456'},
|
|
'event': eventStartSession,
|
|
'namespace': 'BidirectionalTTS',
|
|
'req_params': {
|
|
'speaker': speaker,
|
|
'audio_params': {
|
|
'format': 'mp3',
|
|
'sample_rate': 24000
|
|
}
|
|
}
|
|
};
|
|
|
|
final payload = utf8.encode(jsonEncode(payloadMap));
|
|
|
|
await _sendEvent(header, optional, payload);
|
|
}
|
|
|
|
/// 发送TTS消息
|
|
Future<void> _sendTtsMessage(String speaker, String text) async {
|
|
final header = _createHeader(
|
|
_fullClientRequest,
|
|
_msgTypeFlagWithEvent,
|
|
_jsonSerialization,
|
|
_compressionNo
|
|
);
|
|
|
|
final optional = _createOptional(eventTaskRequest, _sessionId, -1);
|
|
|
|
// 创建任务参数
|
|
final payloadMap = {
|
|
'user': {'uid': '123456'},
|
|
'event': eventTaskRequest,
|
|
'namespace': 'BidirectionalTTS',
|
|
'req_params': {
|
|
'text': text,
|
|
'speaker': speaker,
|
|
'audio_params': {
|
|
'format': 'mp3',
|
|
'sample_rate': 24000
|
|
}
|
|
}
|
|
};
|
|
|
|
final payload = utf8.encode(jsonEncode(payloadMap));
|
|
|
|
await _sendEvent(header, optional, payload);
|
|
}
|
|
|
|
/// 结束会话
|
|
Future<void> _finishSession() async {
|
|
final header = _createHeader(
|
|
_fullClientRequest,
|
|
_msgTypeFlagWithEvent,
|
|
_jsonSerialization,
|
|
_compressionNo
|
|
);
|
|
|
|
final optional = _createOptional(eventFinishSession, _sessionId, -1);
|
|
final payload = utf8.encode('{}');
|
|
|
|
await _sendEvent(header, optional, payload);
|
|
}
|
|
|
|
/// 创建头部
|
|
Uint8List _createHeader(
|
|
int messageType,
|
|
int messageTypeSpecificFlags,
|
|
int serializationMethod,
|
|
int messageCompression
|
|
) {
|
|
return Uint8List.fromList([
|
|
// Protocol version | Header size
|
|
((_protocolVersion << 4) | _defaultHeaderSize) & 0xFF,
|
|
// Message type | Message type specific flags
|
|
((messageType << 4) | messageTypeSpecificFlags) & 0xFF,
|
|
// Serialization method | Compression method
|
|
((serializationMethod << 4) | messageCompression) & 0xFF,
|
|
// Reserved
|
|
0
|
|
]);
|
|
}
|
|
|
|
/// 创建可选部分
|
|
Uint8List _createOptional(int event, String? sessionId, int sequence) {
|
|
final buffer = BytesBuilder();
|
|
|
|
// 添加事件
|
|
if (event != eventNone) {
|
|
buffer.add(_intToBytes(event));
|
|
}
|
|
|
|
// 添加会话ID
|
|
if (sessionId != null) {
|
|
final sessionIdBytes = utf8.encode(sessionId);
|
|
buffer.add(_intToBytes(sessionIdBytes.length));
|
|
buffer.add(sessionIdBytes);
|
|
}
|
|
|
|
// 添加序列号
|
|
if (sequence >= 0) {
|
|
buffer.add(_intToBytes(sequence));
|
|
}
|
|
|
|
return buffer.toBytes();
|
|
}
|
|
|
|
/// 发送事件
|
|
Future<void> _sendEvent(Uint8List header, Uint8List optional, List<int> payload) async {
|
|
if (_channel == null) {
|
|
throw Exception('WebSocket未连接');
|
|
}
|
|
|
|
final buffer = BytesBuilder();
|
|
|
|
// 添加头部
|
|
buffer.add(header);
|
|
|
|
// 添加可选部分
|
|
if (optional.isNotEmpty) {
|
|
buffer.add(optional);
|
|
}
|
|
|
|
// 添加负载大小
|
|
buffer.add(_intToBytes(payload.length));
|
|
|
|
// 添加负载
|
|
buffer.add(payload);
|
|
|
|
// 发送数据
|
|
_channel!.sink.add(buffer.toBytes());
|
|
}
|
|
|
|
/// 整数转字节数组
|
|
Uint8List _intToBytes(int value) {
|
|
return Uint8List(4)
|
|
..[0] = (value >> 24) & 0xFF
|
|
..[1] = (value >> 16) & 0xFF
|
|
..[2] = (value >> 8) & 0xFF
|
|
..[3] = value & 0xFF;
|
|
}
|
|
|
|
/// 字节数组转整数
|
|
int _bytesToInt(Uint8List bytes) {
|
|
if (bytes.length != 4) {
|
|
throw ArgumentError('字节数组长度必须为4');
|
|
}
|
|
|
|
return ((bytes[0] & 0xFF) << 24) |
|
|
((bytes[1] & 0xFF) << 16) |
|
|
((bytes[2] & 0xFF) << 8) |
|
|
(bytes[3] & 0xFF);
|
|
}
|
|
|
|
/// 释放资源
|
|
@override
|
|
void onClose() async {
|
|
await disconnect();
|
|
_audioDataController.close();
|
|
_eventController.close();
|
|
|
|
// 释放资源
|
|
_audioPlayer.dispose();
|
|
|
|
super.onClose();
|
|
}
|
|
|
|
/// 合成并播放文本
|
|
Future<bool> speak(String text, {String? speaker}) async {
|
|
if (text.isEmpty) return false;
|
|
|
|
try {
|
|
// 使用默认发音人
|
|
final actualSpeaker = speaker ?? defaultSpeaker;
|
|
|
|
// 合成文本
|
|
final success = await synthesize(text, actualSpeaker);
|
|
|
|
if (!success) {
|
|
if (kDebugMode) {
|
|
print('合成文本失败');
|
|
}
|
|
}
|
|
|
|
return success;
|
|
} catch (e) {
|
|
_handleError(e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 停止播放
|
|
Future<void> stop() async {
|
|
try {
|
|
// 停止音频播放
|
|
await _audioPlayer.stop();
|
|
|
|
// 清空播放列表
|
|
await _playlist.clear();
|
|
|
|
// 结束当前会话
|
|
await endSession();
|
|
|
|
// 清空音频缓冲区
|
|
_audioBuffer.clear();
|
|
} catch (e) {
|
|
_handleError(e);
|
|
}
|
|
}
|
|
|
|
/// 检查是否正在播放
|
|
bool isActuallyPlaying() {
|
|
return _isPlaying.value;
|
|
}
|
|
|
|
/// 设置音量
|
|
Future<void> setVolume(double volume) async {
|
|
try {
|
|
await _audioPlayer.setVolume(volume.clamp(0.0, 1.0));
|
|
} catch (e) {
|
|
_handleError(e);
|
|
}
|
|
}
|
|
|
|
/// 设置播放速度
|
|
Future<void> setSpeed(double speed) async {
|
|
try {
|
|
await _audioPlayer.setSpeed(speed.clamp(0.5, 2.0));
|
|
} catch (e) {
|
|
_handleError(e);
|
|
}
|
|
}
|
|
|
|
/// 切换启用状态
|
|
void toggleEnabled() {
|
|
// 这个方法在当前实现中不需要特殊处理
|
|
}
|
|
|
|
/// 单句合成辅助函数
|
|
///
|
|
/// 一次性完成连接、会话创建、合成和播放的过程
|
|
/// 适合单句或少量文本的快速合成场景
|
|
///
|
|
/// [text] 要合成的文本
|
|
/// [speaker] 发音人,如果为null则使用默认发音人
|
|
/// [autoDisconnect] 合成完成后是否自动断开连接,默认为false
|
|
///
|
|
/// 返回合成是否成功
|
|
Future<bool> speakSingle(String text, {String? speaker}) async {
|
|
if (text.isEmpty) return false;
|
|
|
|
try {
|
|
// 使用默认发音人
|
|
final actualSpeaker = speaker ?? defaultSpeaker;
|
|
|
|
// 1. 确保连接
|
|
if (!_isConnected) {
|
|
final connected = await connect();
|
|
if (!connected) return false;
|
|
}
|
|
|
|
// 2. 确保会话已开始
|
|
if (!_isSessionActive) {
|
|
final sessionStarted = await startSession(actualSpeaker);
|
|
if (!sessionStarted) return false;
|
|
}
|
|
|
|
// 3. 合成文本
|
|
isSynthesizing.value = true;
|
|
final success = await synthesize(text, actualSpeaker);
|
|
|
|
// 不再自动断开连接,让调用者决定何时结束会话和断开连接
|
|
await endSession();
|
|
|
|
return success;
|
|
} catch (e) {
|
|
_handleError(e);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 使用 BytesAudioSource 进行音频播放
|
|
/// 这种方式更简单可靠,适合已经收集完整的音频数据
|
|
class BytesAudioSource extends StreamAudioSource {
|
|
final Uint8List _bytes;
|
|
final String _contentType;
|
|
static const int _bufferSize = 4096; // 4KB buffer size for better Android compatibility
|
|
|
|
BytesAudioSource(List<int> bytes, {String contentType = 'audio/mpeg'})
|
|
: _bytes = bytes is Uint8List ? bytes : Uint8List.fromList(bytes),
|
|
_contentType = contentType;
|
|
|
|
@override
|
|
Future<StreamAudioResponse> request([int? start, int? end]) async {
|
|
try {
|
|
start = start ?? 0;
|
|
end = end ?? _bytes.length;
|
|
|
|
// 确保范围有效
|
|
final int validStart = math.min(math.max(0, start), _bytes.length);
|
|
final int validEnd = math.min(math.max(validStart, end), _bytes.length);
|
|
|
|
// 提取请求的数据范围
|
|
final data = _bytes.sublist(validStart, validEnd);
|
|
|
|
if (kDebugMode && data.length > 1024) {
|
|
print('BytesAudioSource 请求数据: $validStart-$validEnd (${data.length} 字节)');
|
|
}
|
|
|
|
// 使用固定大小的块进行流式传输,提高兼容性
|
|
final chunks = <List<int>>[];
|
|
var offset = 0;
|
|
while (offset < data.length) {
|
|
final chunkSize = math.min(_bufferSize, data.length - offset);
|
|
chunks.add(data.sublist(offset, offset + chunkSize));
|
|
offset += chunkSize;
|
|
}
|
|
|
|
return StreamAudioResponse(
|
|
sourceLength: _bytes.length,
|
|
contentLength: data.length,
|
|
offset: validStart,
|
|
stream: Stream.fromIterable(chunks),
|
|
contentType: _contentType,
|
|
);
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
print('BytesAudioSource 请求处理错误: $e');
|
|
}
|
|
// 返回一个空响应而不是抛出异常,提高容错性
|
|
return StreamAudioResponse(
|
|
sourceLength: _bytes.length,
|
|
contentLength: 0,
|
|
offset: 0,
|
|
stream: Stream.value(Uint8List(0)),
|
|
contentType: _contentType,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<int> get length => Future.value(_bytes.length);
|
|
}
|