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.
386 lines
13 KiB
386 lines
13 KiB
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:sparkchain/sparkchain.dart';
|
|
|
|
import '../../core/utils/logger.dart';
|
|
import '../models/appconfig.dart';
|
|
|
|
/// 端上讯飞翻译。没配密钥就返回 null,调用方继续走原来的服务端翻译。
|
|
class SparkChainSession {
|
|
SparkChainSession._();
|
|
|
|
static const _tag = 'SparkChain';
|
|
static bool _ready = false;
|
|
static bool _eventsAttached = false;
|
|
static bool listening = false;
|
|
static int lastStartCode = 0;
|
|
static int _seq = 0;
|
|
static final Map<String, Completer<Map<String, dynamic>>> _waiters = {};
|
|
static final Map<String, void Function(Uint8List pcm, bool isFinal)> _ttsHandlers = {};
|
|
static final Map<String, Completer<void>> _ttsWaiters = {};
|
|
static final Map<String, void Function(Map<String, dynamic> event)> _llmHandlers = {};
|
|
|
|
/// 听写结果。只在 [startListen] 成功之后、[stopListen] 之前有意义。
|
|
static void Function(Map<String, dynamic> event)? asrListener;
|
|
|
|
/// 同声 AST 的识别、译文和合成音频。
|
|
static void Function(Map<String, dynamic> event)? astListener;
|
|
|
|
static Future<bool> ensure() async {
|
|
if (_ready) return true;
|
|
final appId = AppConfig.cred('sparkchain', 'app_id', 'SPARKCHAIN_APP_ID');
|
|
final apiKey = AppConfig.cred('sparkchain', 'api_key', 'SPARKCHAIN_API_KEY');
|
|
final apiSecret =
|
|
AppConfig.cred('sparkchain', 'api_secret', 'SPARKCHAIN_API_SECRET');
|
|
if (appId == null || apiKey == null || apiSecret == null) {
|
|
final missing = <String>[
|
|
if (appId == null) 'app_id',
|
|
if (apiKey == null) 'api_key',
|
|
if (apiSecret == null) 'api_secret',
|
|
];
|
|
Logger.w(_tag, '密钥未下发,跳过 init: ${missing.join(",")}');
|
|
return false;
|
|
}
|
|
if (!_eventsAttached) {
|
|
_eventsAttached = true;
|
|
SparkChain.events.listen(_onEvent);
|
|
}
|
|
final apiType =
|
|
int.tryParse(AppConfig.cred('sparkchain', 'api_type', 'SPARKCHAIN_API_TYPE') ?? '') ??
|
|
1;
|
|
final code = await SparkChain.init(
|
|
appId: appId,
|
|
apiKey: apiKey,
|
|
apiSecret: apiSecret,
|
|
apiType: apiType,
|
|
);
|
|
Logger.w(_tag, 'init code=$code apiType=$apiType');
|
|
_ready = code == 0;
|
|
return _ready;
|
|
}
|
|
|
|
/// 配置拉下来之后只做 init。不要在这里 startListen 或翻译:
|
|
/// 新加坡节点连不上时 SDK 会在关闭 WebSocket 的线程里 SIGSEGV,启动即闪退。
|
|
static Future<void> warmUp() async {
|
|
await ensure();
|
|
}
|
|
|
|
static void _onEvent(Map<String, dynamic> event) {
|
|
final id = event['id']?.toString() ?? '';
|
|
final kind = event['kind'];
|
|
if (kind == 'its' || (kind == 'error' && event['where'] == 'its')) {
|
|
final waiter = _waiters[id];
|
|
if (waiter != null && !waiter.isCompleted) waiter.complete(event);
|
|
}
|
|
if (kind == 'asr' || (kind == 'error' && event['where'] == 'asr')) {
|
|
asrListener?.call(event);
|
|
}
|
|
if (kind == 'ast' || (kind == 'error' && event['where'] == 'ast')) {
|
|
astListener?.call(event);
|
|
}
|
|
if (kind == 'tts' || (kind == 'error' && event['where'] == 'tts')) {
|
|
_onTts(id, event);
|
|
}
|
|
if (kind == 'llm' || (kind == 'error' && event['where'] == 'llm')) {
|
|
_llmHandlers[id]?.call(event);
|
|
}
|
|
}
|
|
|
|
static void _onTts(String id, Map<String, dynamic> event) {
|
|
if (event['kind'] == 'error') {
|
|
_ttsHandlers.remove(id);
|
|
final waiter = _ttsWaiters.remove(id);
|
|
if (waiter != null && !waiter.isCompleted) {
|
|
waiter.completeError(StateError('${event['code']}'));
|
|
}
|
|
return;
|
|
}
|
|
final raw = event['pcm'];
|
|
final pcm = raw is Uint8List ? raw : Uint8List(0);
|
|
_ttsHandlers[id]?.call(pcm, event['final'] == true);
|
|
if (event['final'] == true) {
|
|
_ttsHandlers.remove(id);
|
|
final waiter = _ttsWaiters.remove(id);
|
|
if (waiter != null && !waiter.isCompleted) waiter.complete();
|
|
}
|
|
}
|
|
|
|
/// 同声:一条微软同传。发音人是短名,例如 JennyNeural。
|
|
static Future<bool> startAst({
|
|
required String lang,
|
|
required String targetLang,
|
|
required String speaker,
|
|
}) async {
|
|
if (!await ensure()) return false;
|
|
final appId = AppConfig.cred('sparkchain', 'app_id', 'SPARKCHAIN_APP_ID');
|
|
final apiKey = AppConfig.cred('sparkchain', 'api_key', 'SPARKCHAIN_API_KEY');
|
|
final apiSecret =
|
|
AppConfig.cred('sparkchain', 'api_secret', 'SPARKCHAIN_API_SECRET');
|
|
// 同传网关认的是自己的 accessKeyId,不是 SparkChain 应用的 apiKey。
|
|
// 后台「同传单独」三项有值就用那一组,空才回退。
|
|
final ownKey = AppConfig.field('sparkchain', 'ast_api_key') != null;
|
|
final astApp = AppConfig.field('sparkchain', 'ast_app_id') ?? appId ?? '';
|
|
final astKey = AppConfig.field('sparkchain', 'ast_api_key') ?? apiKey ?? '';
|
|
final astSecret =
|
|
AppConfig.field('sparkchain', 'ast_api_secret') ?? apiSecret ?? '';
|
|
final code = await SparkChain.startAst(
|
|
id: 'listen',
|
|
lang: lang,
|
|
targetLang: targetLang,
|
|
speaker: speaker,
|
|
astAppId: astApp,
|
|
astApiKey: astKey,
|
|
astApiSecret: astSecret,
|
|
);
|
|
lastStartCode = code;
|
|
listening = code == 0;
|
|
Logger.w(
|
|
_tag,
|
|
'AST 启动 code=$code RegionMSFT lang=$lang target_lang=$targetLang '
|
|
'tts_target_lang=$targetLang tts_speaker=$speaker use_tts=true '
|
|
'同传密钥=${ownKey ? "单独字段" : "回退应用密钥"}',
|
|
);
|
|
return listening;
|
|
}
|
|
|
|
/// 同声拼接:微软实时转写。四个固定参数加 language_name,不走星火听写。
|
|
static Future<bool> startSpliceListen(String locale) async {
|
|
if (!await ensure()) return false;
|
|
final code = await SparkChain.startSpliceListen(id: 'listen', locale: locale);
|
|
lastStartCode = code;
|
|
listening = code == 0;
|
|
_writeFails = 0;
|
|
Logger.w(
|
|
_tag,
|
|
'RTASR 启动 code=$code RegionMSFT lang=msft accent=mandarin '
|
|
'domain=ist_mul_msft dwa=wpgs language_name=$locale',
|
|
);
|
|
return listening;
|
|
}
|
|
|
|
/// 微软区域实时转写,语种用界面语言的区域码。SDK 不自己开麦。
|
|
static Future<bool> startMsftListen(String locale) async {
|
|
if (!await ensure()) return false;
|
|
final code = await SparkChain.startMsftListen(id: 'listen', locale: locale);
|
|
listening = code == 0;
|
|
if (!listening) Logger.w(_tag, '微软听写启动失败 code=$code locale=$locale');
|
|
return listening;
|
|
}
|
|
|
|
/// 只听 [lang] 这一种。ga 走实时转写,其余走听写。SDK 不自己开麦。
|
|
static Future<bool> startListen(String lang) async {
|
|
if (!await ensure()) return false;
|
|
final code = await SparkChain.startListen(id: 'listen', lang: sparkChainShortCode(lang));
|
|
listening = code == 0;
|
|
if (!listening) {
|
|
Logger.w(_tag, 'startListen 失败 code=$code');
|
|
}
|
|
return listening;
|
|
}
|
|
|
|
static var _writeFails = 0;
|
|
|
|
/// 苹果实时转写文档:每 40ms 送 1280 字节。一次把积压全送进去,引擎会出错,
|
|
/// 大约 10 秒后就是 18802。
|
|
static const _paceFrame = 1280;
|
|
static final List<int> _paceBuf = <int>[];
|
|
static Timer? _paceTimer;
|
|
|
|
static void write(Uint8List pcm) {
|
|
if (!listening || pcm.isEmpty) return;
|
|
_paceBuf.addAll(pcm);
|
|
const maxBytes = 32000 * 2;
|
|
if (_paceBuf.length > maxBytes) {
|
|
_paceBuf.removeRange(0, _paceBuf.length - maxBytes);
|
|
}
|
|
_paceTimer ??= Timer.periodic(const Duration(milliseconds: 40), (_) {
|
|
_pumpPace();
|
|
});
|
|
}
|
|
|
|
static void _pumpPace() {
|
|
if (!listening || _paceBuf.isEmpty) {
|
|
_paceTimer?.cancel();
|
|
_paceTimer = null;
|
|
return;
|
|
}
|
|
final n = _paceBuf.length < _paceFrame ? _paceBuf.length : _paceFrame;
|
|
final frame = Uint8List.fromList(_paceBuf.sublist(0, n));
|
|
_paceBuf.removeRange(0, n);
|
|
unawaited(SparkChain.write(frame).then((code) {
|
|
if (code != 0 && _writeFails < 3) {
|
|
_writeFails++;
|
|
Logger.w(_tag, 'write 失败 code=$code 字节=${frame.length}');
|
|
}
|
|
}));
|
|
}
|
|
|
|
static void _stopPace() {
|
|
_paceTimer?.cancel();
|
|
_paceTimer = null;
|
|
_paceBuf.clear();
|
|
}
|
|
|
|
static Future<void> stopListen() async {
|
|
listening = false;
|
|
_stopPace();
|
|
asrListener = null;
|
|
astListener = null;
|
|
// 启动失败时 listening 仍是 false,但原生引擎可能已经占着一条没连上的会话。
|
|
// 不拆掉的话,下一次 start 会在这条会话上一直耗到超时。
|
|
try {
|
|
await SparkChain.stopListen();
|
|
} catch (e) {
|
|
Logger.w(_tag, 'stopListen: $e');
|
|
}
|
|
}
|
|
|
|
/// 微软区域神经语音。PCM 16 kHz,按到达顺序交给 [onPcm]。
|
|
static Future<void> speakNeural({
|
|
required String voice,
|
|
required String text,
|
|
required void Function(Uint8List pcm, bool isFinal) onPcm,
|
|
}) async {
|
|
if (!await ensure()) throw StateError('sparkchain not ready');
|
|
final id = 'tts-${++_seq}';
|
|
final waiter = Completer<void>();
|
|
_ttsWaiters[id] = waiter;
|
|
_ttsHandlers[id] = onPcm;
|
|
final code = await SparkChain.speak(
|
|
id: id,
|
|
lang: 'msft',
|
|
text: text,
|
|
voice: voice,
|
|
msft: true,
|
|
);
|
|
if (code != 0) {
|
|
_ttsHandlers.remove(id);
|
|
_ttsWaiters.remove(id);
|
|
throw StateError('speak $code');
|
|
}
|
|
await waiter.future.timeout(const Duration(seconds: 30));
|
|
}
|
|
|
|
static Future<void> stopSpeak() async {
|
|
try {
|
|
await SparkChain.stopSpeak();
|
|
} catch (e) {
|
|
Logger.w(_tag, 'stopSpeak: $e');
|
|
}
|
|
}
|
|
|
|
/// 端上文本对话。fresh 为 true 时清掉 SDK 里上一轮的上下文。
|
|
static Future<SparkLlmReply> chat({
|
|
required String query,
|
|
required String system,
|
|
required String tools,
|
|
required bool fresh,
|
|
}) async {
|
|
if (!await ensure()) throw StateError('sparkchain not ready');
|
|
final id = 'llm-${++_seq}';
|
|
final waiter = Completer<SparkLlmReply>();
|
|
final text = StringBuffer();
|
|
var functionCall = '';
|
|
_llmHandlers[id] = (event) {
|
|
if (event['kind'] == 'error') {
|
|
if (!waiter.isCompleted) {
|
|
waiter.completeError(StateError('${event['code']}'));
|
|
}
|
|
return;
|
|
}
|
|
text.write(event['text'] ?? '');
|
|
final call = (event['functionCall'] as String?) ?? '';
|
|
if (call.isNotEmpty) functionCall = call;
|
|
if (event['final'] == true && !waiter.isCompleted) {
|
|
waiter.complete(SparkLlmReply(text.toString(), functionCall));
|
|
}
|
|
};
|
|
try {
|
|
final code = await SparkChain.chat(
|
|
id: id,
|
|
query: query,
|
|
system: system,
|
|
tools: tools,
|
|
fresh: fresh,
|
|
);
|
|
if (code != 0) throw StateError('chat $code');
|
|
return await waiter.future.timeout(const Duration(seconds: 25));
|
|
} finally {
|
|
_llmHandlers.remove(id);
|
|
}
|
|
}
|
|
|
|
/// 合成一段。PCM 按到达顺序交给 [onPcm],整段结束或失败才返回。
|
|
static Future<void> speak({
|
|
required String lang,
|
|
required String text,
|
|
required void Function(Uint8List pcm, bool isFinal) onPcm,
|
|
}) async {
|
|
if (!await ensure()) {
|
|
throw StateError('sparkchain not ready');
|
|
}
|
|
final id = 'tts-${++_seq}';
|
|
final waiter = Completer<void>();
|
|
_ttsWaiters[id] = waiter;
|
|
_ttsHandlers[id] = onPcm;
|
|
final code = await SparkChain.speak(
|
|
id: id,
|
|
lang: sparkChainShortCode(lang),
|
|
text: text,
|
|
);
|
|
if (code != 0) {
|
|
_ttsHandlers.remove(id);
|
|
_ttsWaiters.remove(id);
|
|
throw StateError('speak $code');
|
|
}
|
|
await waiter.future.timeout(const Duration(seconds: 20));
|
|
}
|
|
|
|
/// 短码 zh / en / ga。失败返回 null,不抛。
|
|
/// [prepared] 为真时语种码已经是 SDK 要的写法,不再收成 zh / en。
|
|
static Future<String?> translate({
|
|
required String from,
|
|
required String to,
|
|
required String text,
|
|
bool prepared = false,
|
|
}) async {
|
|
if (!await ensure()) return null;
|
|
final id = '${++_seq}';
|
|
final waiter = Completer<Map<String, dynamic>>();
|
|
_waiters[id] = waiter;
|
|
final src = prepared ? from : sparkChainShortCode(from);
|
|
final dst = prepared ? to : sparkChainShortCode(to);
|
|
try {
|
|
final code = await SparkChain.translate(
|
|
id: id,
|
|
from: src,
|
|
to: dst,
|
|
text: text,
|
|
);
|
|
if (code != 0) {
|
|
Logger.w(_tag, 'translate 启动失败 code=$code');
|
|
return null;
|
|
}
|
|
final event = await waiter.future.timeout(const Duration(seconds: 20));
|
|
if (event['kind'] != 'its') {
|
|
Logger.w(_tag, 'translate 失败 code=${event['code']} sid=${event['sid']}');
|
|
return null;
|
|
}
|
|
Logger.w(_tag, 'translate engine=${event['engine']}');
|
|
final out = (event['text'] as String?)?.trim() ?? '';
|
|
return out.isEmpty ? null : out;
|
|
} on TimeoutException {
|
|
Logger.w(_tag, 'translate 超时');
|
|
return null;
|
|
} finally {
|
|
_waiters.remove(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
class SparkLlmReply {
|
|
SparkLlmReply(this.text, this.functionCall);
|
|
final String text;
|
|
final String functionCall;
|
|
}
|
|
|