import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:audio_session/audio_session.dart'; import 'package:chivox_aiengine/chivox_aiengine.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import '../core/app_consts.dart'; /// 将驰声 Flutter SDK 适配为原评测桥接的事件协议,业务页面无需感知 SDK 差异。 class ChivoxEvaluationChannel { ChivoxAiengine? _engine; Future? _initializing; Future Function(MethodCall)? _methodCallHandler; int _sessionId = 0; bool _active = false; bool _stopping = false; bool _stopNotified = false; String? _recordFilePath; void setMethodCallHandler( Future Function(MethodCall)? methodCallHandler) { _methodCallHandler = methodCallHandler; } Future invokeMethod(String method, [dynamic arguments]) async { final params = _asStringMap(arguments); switch (method) { case 'initVoiceSdk': await _initialize(); break; case 'startVoice': await _startInnerRecorder(params); break; case 'startLocalVoice': await _evaluateWaveFile(params); break; case 'stopVoice': await _stop(); break; case 'cancelVoice': await _cancel(notify: true); break; default: throw MissingPluginException('Unsupported evaluation method: $method'); } return null; } Future dispose() async { _sessionId++; await _cancel(notify: false); final engine = _engine; _engine = null; _initializing = null; await engine?.destroy(); _methodCallHandler = null; } Future _initialize() { if (_engine != null) return Future.value(); return _initializing ??= _createEngine().catchError((Object error) { _initializing = null; throw error; }); } Future _createEngine() async { final supportDirectory = await getApplicationSupportDirectory(); final resourceDirectory = Directory('${supportDirectory.path}/chivox'); if (!await resourceDirectory.exists()) { await resourceDirectory.create(recursive: true); } final provisionPath = await _copyAssetIfNeeded( 'assets/chivox/aiengine.provision', '${resourceDirectory.path}/aiengine.provision', ); final vadPath = await _copyAssetIfNeeded( 'assets/chivox/vad.0.13.bin', '${resourceDirectory.path}/vad.0.13.bin', ); final config = jsonEncode({ 'appKey': AppConsts.chivoxAppKey, 'secretKey': AppConsts.chivoxSecretKey, 'provision': provisionPath, 'vad': { 'enable': 1, 'res': vadPath, 'speechLowSeek': 125, 'sampleRate': 16000, 'strip': 0, }, 'cloud': {'enable': 1}, }); _engine = await ChivoxAiengine.create(config); } Future _startInnerRecorder(Map params) async { await _prepareAudioSession(); await _startEvaluation( params, audioSourceBuilder: (recordFilePath) => { 'srcType': 'innerRecorder', 'innerRecorderParam': { 'channel': 1, 'sampleBytes': 2, 'sampleRate': 16000, 'saveFile': recordFilePath, }, }, ); } Future _evaluateWaveFile(Map params) async { final wavePath = params['voicePath'] ?? ''; if (wavePath.isEmpty || !await File(wavePath).exists()) { await _emit('voiceFail', { 'code': -1, 'message': '录音文件不存在', }); return; } await _startEvaluation( params, fallbackRecordPath: wavePath, audioSourceBuilder: (_) => {'srcType': 'outerFeed'}, afterStarted: (sessionId) async { final pcmBytes = await _readWavePcm(File(wavePath)); const chunkSize = 8192; for (var offset = 0; offset < pcmBytes.length && _isActiveSession(sessionId); offset += chunkSize) { final end = (offset + chunkSize).clamp(0, pcmBytes.length); final chunk = Uint8List.sublistView(pcmBytes, offset, end); await _engine?.feed(chunk, chunk.length); } if (_isActiveSession(sessionId)) await _stop(); }, ); } Future _startEvaluation( Map params, { required Map Function(String recordFilePath) audioSourceBuilder, String? fallbackRecordPath, Future Function(int sessionId)? afterStarted, }) async { try { await _initialize(); if (_active) await _cancel(notify: false); final text = _normalizeReferenceText(params['word'] ?? ''); if (text.isEmpty) { throw const FormatException('评测文本不能为空'); } final sessionId = ++_sessionId; _active = true; _stopping = false; _stopNotified = false; _recordFilePath = fallbackRecordPath ?? await _newRecordFilePath(); final request = jsonEncode({ 'coreProvideType': 'cloud', 'vad': { 'vadEnable': fallbackRecordPath == null ? 1 : 0, 'refDuration': 3, 'speechLowSeek': 125, }, 'app': {'userId': params['userId'] ?? 'guest'}, 'audio': { 'audioType': 'wav', 'channel': 1, 'sampleBytes': 2, 'sampleRate': 16000, 'compress': 'speex', }, 'request': { 'coreType': 'en.sent.score', 'refText': text, 'rank': 100, 'attachAudioUrl': 0, 'result': { 'details': {'gop_adjust': 0} }, }, }); final listener = ChivoxAiengineResultListener( onEvalResult: (result) => _handleResult(sessionId, text, result), onError: (result) => unawaited(_handleError(sessionId, result)), onVad: (result) => _handleVad(sessionId, result), ); await _engine!.start( audioSourceBuilder(_recordFilePath!), request, listener, ); if (!_isActiveSession(sessionId)) return; await _emit('voiceStart', null); await afterStarted?.call(sessionId); } catch (error) { _active = false; _stopping = false; try { await _engine?.cancel(); } catch (_) {} await _emit('voiceFail', { 'code': error is PlatformException ? error.code : -1, 'message': error.toString(), }); } } void _handleResult( int sessionId, String referenceText, ChivoxAiengineResult result) { if (!_isActiveSession(sessionId)) return; try { final raw = jsonDecode(result.text ?? '{}') as Map; final resultJson = raw['result'] as Map? ?? const {}; final detailsJson = resultJson['details'] as List? ?? const []; final details = detailsJson .whereType() .map((detail) { return { 'char': detail['char']?.toString() ?? '', 'score': _asScore(detail['score']), }; }) .where((detail) => (detail['char'] as String).isNotEmpty) .toList(); _active = false; _stopping = false; unawaited(_emit('voiceResult', { 'result': { 'overall': _asScore(resultJson['overall']), 'details': details, 'refText': referenceText, }, 'audioUrl': result.recFilePath ?? _recordFilePath ?? '', })); } catch (error) { unawaited(_handleError(sessionId, null, error: error)); } } Future _handleError( int sessionId, ChivoxAiengineResult? result, { Object? error, }) async { if (!_isActiveSession(sessionId)) return; _active = false; _stopping = false; try { await _engine?.cancel(); } catch (_) {} var code = -1; var message = error?.toString() ?? result?.text ?? '评测失败'; try { final errorJson = jsonDecode(result?.text ?? '') as Map; code = _asScore(errorJson['errId']); message = errorJson['error']?.toString() ?? message; } catch (_) {} await _emit('voiceFail', {'code': code, 'message': message}); } void _handleVad(int sessionId, ChivoxAiengineResult result) { if (!_isActiveSession(sessionId) || _stopping) return; try { final vad = jsonDecode(result.text ?? '{}') as Map; if (_asScore(vad['vad_status']) == 2) unawaited(_stop()); } catch (_) {} } Future _stop() async { if (!_active || _stopping) return; _stopping = true; try { await _engine?.stop(); if (!_stopNotified) { _stopNotified = true; await _emit('voiceEnd', null); } } catch (error) { _active = false; _stopping = false; _sessionId++; try { await _engine?.cancel(); } catch (_) {} await _emit('voiceFail', { 'code': error is PlatformException ? error.code : -1, 'message': error.toString(), }); } } Future _cancel({required bool notify}) async { final wasActive = _active; _active = false; _stopping = false; _sessionId++; try { await _engine?.cancel(); } catch (_) {} if (notify && wasActive) await _emit('voiceCancel', null); } bool _isActiveSession(int sessionId) => _active && sessionId == _sessionId; Future _prepareAudioSession() async { final session = await AudioSession.instance; await session.configure(AudioSessionConfiguration( avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.defaultToSpeaker | AVAudioSessionCategoryOptions.allowBluetooth, avAudioSessionMode: AVAudioSessionMode.spokenAudio, androidAudioAttributes: const AndroidAudioAttributes( contentType: AndroidAudioContentType.speech, usage: AndroidAudioUsage.voiceCommunication, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gain, androidWillPauseWhenDucked: true, )); await session.setActive(true); } Future _newRecordFilePath() async { final tempDirectory = await getTemporaryDirectory(); final directory = Directory('${tempDirectory.path}/chivox_records'); if (!await directory.exists()) await directory.create(recursive: true); return '${directory.path}/${DateTime.now().millisecondsSinceEpoch}.wav'; } Future _copyAssetIfNeeded(String assetPath, String targetPath) async { final data = await rootBundle.load(assetPath); final file = File(targetPath); if (!await file.exists() || await file.length() != data.lengthInBytes) { await file.writeAsBytes(data.buffer.asUint8List(), flush: true); } return file.path; } Future _readWavePcm(File file) async { final bytes = await file.readAsBytes(); if (bytes.length < 12 || ascii.decode(bytes.sublist(0, 4)) != 'RIFF') { return bytes; } var offset = 12; final byteData = ByteData.sublistView(bytes); while (offset + 8 <= bytes.length) { final chunkName = ascii.decode(bytes.sublist(offset, offset + 4), allowInvalid: true); final chunkLength = byteData.getUint32(offset + 4, Endian.little); final dataStart = offset + 8; final dataEnd = (dataStart + chunkLength).clamp(0, bytes.length); if (chunkName == 'data') { return Uint8List.sublistView(bytes, dataStart, dataEnd); } offset = dataEnd + (chunkLength.isOdd ? 1 : 0); } throw const FormatException('无效的 WAV 文件'); } Future _emit(String method, dynamic arguments) async { await _methodCallHandler?.call(MethodCall(method, arguments)); } Map _asStringMap(dynamic arguments) { if (arguments is! Map) return const {}; return arguments .map((key, value) => MapEntry(key.toString(), value?.toString() ?? '')); } int _asScore(dynamic value) { if (value is num) return value.round(); return double.tryParse(value?.toString() ?? '')?.round() ?? 0; } String _normalizeReferenceText(String text) => text .trim() .replaceAll('’', "'") .replaceAll('‘', "'") .replaceAll('“', '"') .replaceAll('”', '"') .replaceAll(',', ',') .replaceAll('。', '.') .replaceAll('?', '?') .replaceAll('!', '!'); }