tencent_asr_client.dart 10.7 KB
import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

/// 腾讯云实时语音识别(WebSocket)客户端。
///
/// 协议说明:https://cloud.tencent.com/document/product/1093/48982
class TencentAsrClient {
  TencentAsrClient({
    required this.secretId,
    required this.secretKey,
    required this.appId,
    this.engineModelType = '16k_zh',
  });

  final String secretId;
  final String secretKey;
  final String appId;
  final String engineModelType;

  static const int _frameBytes = 6400;

  WebSocketChannel? _channel;
  StreamSubscription<dynamic>? _socketSub;
  StreamSubscription<Uint8List>? _audioSub;
  Timer? _frameTimer;
  final List<int> _pcmBuffer = <int>[];
  Completer<void>? _handshake;
  Completer<String>? _result;

  final List<String> _sentences = <String>[];
  String _partial = '';
  void Function(String text)? _onPartial;
  bool _handshakeDone = false;

  String _combinedText() => (_sentences.join() + _partial).trim();

  /// 建立连接并等待云端握手成功。失败时抛出 [TencentAsrException]。
  Future<void> connect() async {
    final String voiceId = _newVoiceId();
    final Uri uri = _buildUri(voiceId);
    final WebSocketChannel channel = WebSocketChannel.connect(uri);
    _channel = channel;

    try {
      await channel.ready.timeout(const Duration(seconds: 8));
    } catch (error) {
      await _closeSocket();
      throw const TencentAsrException(0, '无法连接腾讯云语音服务,请检查网络');
    }

    _handshake = Completer<void>();
    _result = Completer<String>();
    _socketSub = channel.stream.listen(
      _onMessage,
      onError: (Object error) {
        _fail(error);
      },
      onDone: () {
        _completeResult();
      },
    );

    try {
      await _handshake!.future.timeout(const Duration(seconds: 8));
      _handshakeDone = true;
      debugPrint('Tencent ASR connected');
    } catch (error) {
      await _teardown();
      if (error is TencentAsrException) rethrow;
      throw const TencentAsrException(0, '语音服务握手超时');
    }
  }

  /// 开始转发 PCM 音频流。按腾讯云协议要求,以 200ms/6400 字节为
  /// 一个数据包发送;音频流结束时自动发送 `{"type":"end"}`。
  void startForwarding(Stream<Uint8List> audio) {
    _audioSub = audio.listen(
      (Uint8List chunk) => _pcmBuffer.addAll(chunk),
      onError: (Object error) => _fail(error),
      onDone: _onAudioDone,
      cancelOnError: true,
    );
    _frameTimer = Timer.periodic(const Duration(milliseconds: 200), (_) {
      _flushFrames();
    });
  }

  void _flushFrames() {
    if (_pcmBuffer.isEmpty) return;
    final int length =
        _pcmBuffer.length < _frameBytes ? _pcmBuffer.length : _frameBytes;
    final List<int> frame = _pcmBuffer.sublist(0, length);
    _pcmBuffer.removeRange(0, length);
    _send(frame);
  }

  void _onAudioDone() {
    _frameTimer?.cancel();
    _frameTimer = null;
    while (_pcmBuffer.isNotEmpty) {
      final int length =
          _pcmBuffer.length < _frameBytes ? _pcmBuffer.length : _frameBytes;
      final List<int> frame = _pcmBuffer.sublist(0, length);
      _pcmBuffer.removeRange(0, length);
      _send(frame);
    }
    _sendText(jsonEncode(<String, String>{'type': 'end'}));
  }

  /// 停止后等待最终识别结果。
  Future<String?> awaitResult() async {
    final Completer<String>? result = _result;
    if (result == null) return null;
    try {
      return await result.future.timeout(const Duration(seconds: 6));
    } on TimeoutException {
      return _combinedText();
    } on TencentAsrException catch (error) {
      debugPrint('Tencent ASR error: $error');
      return null;
    } finally {
      await _teardown();
    }
  }

  /// 取消当前识别。
  Future<void> cancel() async {
    _frameTimer?.cancel();
    _frameTimer = null;
    _pcmBuffer.clear();
    await _audioSub?.cancel();
    _audioSub = null;
    if (_result != null && !_result!.isCompleted) {
      _result!.complete('');
    }
    await _teardown();
  }

  void setOnPartial(void Function(String text)? callback) {
    _onPartial = callback;
  }

  void _onMessage(dynamic message) {
    try {
      final Map<String, dynamic> json =
          jsonDecode(message.toString()) as Map<String, dynamic>;
      final int? code = json['code'] as int?;
      if (code != null && code != 0) {
        _fail(
          TencentAsrException(
            code,
            json['message']?.toString() ?? '语音识别失败',
          ),
        );
        return;
      }

      if (!_handshakeDone) {
        _handshakeDone = true;
        if (_handshake != null && !_handshake!.isCompleted) {
          _handshake!.complete();
        }
      }

      final Map<String, dynamic>? result =
          json['result'] as Map<String, dynamic>?;
      if (result != null) {
        final int? sliceType = result['slice_type'] as int?;
        final String text = (result['voice_text_str'] ?? '').toString();
        if (sliceType == 2) {
          final int index = (result['index'] as int?) ?? _sentences.length;
          while (_sentences.length <= index) {
            _sentences.add('');
          }
          _sentences[index] = text;
          _partial = '';
        } else if (sliceType == 1) {
          _partial = text;
        }
        _onPartial?.call(_combinedText());
      }

      if (json['final'] == 1) {
        _completeResult();
      }
    } catch (_) {
      // 忽略无法解析的单条消息。
    }
  }

  void _completeResult() {
    final Completer<String>? result = _result;
    if (result != null && !result.isCompleted) {
      result.complete(_combinedText());
    }
  }

  void _fail(Object error) {
    final Completer<void>? handshake = _handshake;
    if (handshake != null && !handshake.isCompleted) {
      handshake.completeError(error);
    }
    final Completer<String>? result = _result;
    if (result != null && !result.isCompleted) {
      result.completeError(error);
    }
  }

  void _send(List<int> bytes) {
    try {
      _channel?.sink.add(bytes);
    } catch (_) {}
  }

  void _sendText(String text) {
    try {
      _channel?.sink.add(text);
    } catch (_) {}
  }

  Future<void> _teardown() async {
    _frameTimer?.cancel();
    _frameTimer = null;
    _pcmBuffer.clear();
    await _audioSub?.cancel();
    _audioSub = null;
    await _socketSub?.cancel();
    _socketSub = null;
    await _closeSocket();
  }

  Future<void> _closeSocket() async {
    final WebSocketChannel? channel = _channel;
    _channel = null;
    if (channel == null) return;
    try {
      await channel.sink.close();
    } catch (_) {}
  }

  Uri _buildUri(String voiceId) {
    final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
    final Map<String, String> params = <String, String>{
      'secretid': secretId,
      'timestamp': '$now',
      'expired': '${now + 86400}',
      'nonce': '${_randomInt(999999999)}',
      'engine_model_type': engineModelType,
      'voice_id': voiceId,
      'voice_format': '1',
      'needvad': '1',
      'filter_modal': '1',
      'filter_punc': '1',
      'convert_num_mode': '1',
    };

    final List<MapEntry<String, String>> sorted = params.entries.toList()
      ..sort((MapEntry<String, String> a, MapEntry<String, String> b) =>
          a.key.compareTo(b.key));

    final String canonical = sorted
        .map((MapEntry<String, String> e) => '${e.key}=${e.value}')
        .join('&');
    final String signText = 'asr.cloud.tencent.com/asr/v2/$appId?$canonical';

    final List<int> digest = Hmac(sha1, utf8.encode(secretKey))
        .convert(utf8.encode(signText))
        .bytes;
    final String signature = base64.encode(digest);

    final String query = sorted
        .map((MapEntry<String, String> e) =>
            '${e.key}=${Uri.encodeQueryComponent(e.value)}')
        .join('&');

    return Uri.parse(
      'wss://asr.cloud.tencent.com/asr/v2/$appId?$query'
      '&signature=${Uri.encodeQueryComponent(signature)}',
    );
  }

  String _newVoiceId() {
    final int now = DateTime.now().microsecondsSinceEpoch;
    final int rand = _randomInt(0x7fffffff);
    return 'voice_${now}_$rand';
  }

  int _randomInt(int max) {
    final int seed = DateTime.now().microsecondsSinceEpoch;
    return (seed % max).abs() + 1;
  }
}

class TencentAsrException implements Exception {
  const TencentAsrException(this.code, this.message);

  final int code;
  final String message;

  @override
  String toString() => 'TencentAsrException($code, $message)';
}

/// 负责录音并把 PCM 流交给 [TencentAsrClient] 的会话封装。
class TencentAsrSession {
  TencentAsrSession({
    required this.secretId,
    required this.secretKey,
    required this.appId,
    this.onPartial,
  });

  final String secretId;
  final String secretKey;
  final String appId;
  final void Function(String text)? onPartial;

  final AudioRecorder _recorder = AudioRecorder();
  TencentAsrClient? _client;
  bool _recording = false;

  /// 检查权限、建立连接并完成云端握手,此时尚未开始录音。
  Future<void> prepare() async {
    if (!await _recorder.hasPermission()) {
      final PermissionStatus status = await Permission.microphone.request();
      if (!status.isGranted) {
        throw const TencentAsrException(0, '未获得麦克风权限');
      }
    }
    final TencentAsrClient client = TencentAsrClient(
      secretId: secretId,
      secretKey: secretKey,
      appId: appId,
    );
    _client = client;
    await client.connect();
  }

  /// 开始录音并发送音频。
  Future<void> startAudio() async {
    final TencentAsrClient? client = _client;
    if (client == null) {
      throw const TencentAsrException(0, '语音会话未建立');
    }

    final Stream<Uint8List> audio = await _recorder.startStream(
      const RecordConfig(
        encoder: AudioEncoder.pcm16bits,
        sampleRate: 16000,
        numChannels: 1,
      ),
    );
    _recording = true;
    client
      ..setOnPartial((String text) {
        if (_recording) onPartial?.call(text);
      })
      ..startForwarding(audio);
  }

  /// 停止录音,返回最终识别文本。
  Future<String?> stop() async {
    if (!_recording) return null;
    _recording = false;
    try {
      await _recorder.stop();
    } catch (_) {}
    return _client?.awaitResult();
  }

  /// 取消本次识别,丢弃结果。
  Future<void> cancel() async {
    _recording = false;
    try {
      await _recorder.stop();
    } catch (_) {}
    final TencentAsrClient? client = _client;
    _client = null;
    try {
      await client?.cancel();
    } catch (_) {}
  }

  Future<void> dispose() async {
    await cancel();
    await _recorder.dispose();
  }
}