chivox_evaluation_channel.dart 12.2 KB
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<void>? _initializing;
  Future<dynamic> Function(MethodCall)? _methodCallHandler;
  int _sessionId = 0;
  bool _active = false;
  bool _stopping = false;
  bool _stopNotified = false;
  String? _recordFilePath;

  void setMethodCallHandler(
      Future<dynamic> Function(MethodCall)? methodCallHandler) {
    _methodCallHandler = methodCallHandler;
  }

  Future<T?> invokeMethod<T>(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<void> dispose() async {
    _sessionId++;
    await _cancel(notify: false);
    final engine = _engine;
    _engine = null;
    _initializing = null;
    await engine?.destroy();
    _methodCallHandler = null;
  }

  Future<void> _initialize() {
    if (_engine != null) return Future.value();
    return _initializing ??= _createEngine().catchError((Object error) {
      _initializing = null;
      throw error;
    });
  }

  Future<void> _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<void> _startInnerRecorder(Map<String, String> params) async {
    await _prepareAudioSession();
    await _startEvaluation(
      params,
      audioSourceBuilder: (recordFilePath) => {
        'srcType': 'innerRecorder',
        'innerRecorderParam': {
          'channel': 1,
          'sampleBytes': 2,
          'sampleRate': 16000,
          'saveFile': recordFilePath,
        },
      },
    );
  }

  Future<void> _evaluateWaveFile(Map<String, String> 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<void> _startEvaluation(
    Map<String, String> params, {
    required Map<String, dynamic> Function(String recordFilePath)
        audioSourceBuilder,
    String? fallbackRecordPath,
    Future<void> 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<String, dynamic>;
      final resultJson = raw['result'] as Map<String, dynamic>? ?? const {};
      final detailsJson = resultJson['details'] as List<dynamic>? ?? const [];
      final details = detailsJson
          .whereType<Map>()
          .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<void> _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<String, dynamic>;
      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<String, dynamic>;
      if (_asScore(vad['vad_status']) == 2) unawaited(_stop());
    } catch (_) {}
  }

  Future<void> _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<void> _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<void> _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<String> _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<String> _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<Uint8List> _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<void> _emit(String method, dynamic arguments) async {
    await _methodCallHandler?.call(MethodCall(method, arguments));
  }

  Map<String, String> _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('!', '!');
}