paraformer_asr_client.dart 10.9 KB
import 'dart:async';
import 'dart:convert';
import 'dart:math';

import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

/// 阿里云百炼 Paraformer 实时语音识别(WebSocket)客户端。
///
/// 协议说明:
/// https://help.aliyun.com/zh/model-studio/websocket-for-paraformer-real-time-service
class ParaformerAsrClient {
  ParaformerAsrClient({
    required this.apiKey,
    this.model = 'paraformer-realtime-v2',
  });

  /// 统一复用百炼 API Key(与图片识别共用一个 Key)。
  final String apiKey;
  final String model;

  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;
  String _taskId = '';

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

  /// 建立连接、发送 run-task 并等待服务端 task-started。
  /// 失败时抛出 [ParaformerAsrException]。
  Future<void> connect() async {
    _taskId = _newUuidV4();
    final WebSocketChannel channel = IOWebSocketChannel.connect(
      Uri.parse('wss://dashscope.aliyuncs.com/api-ws/v1/inference'),
      headers: <String, dynamic>{'Authorization': 'Bearer $apiKey'},
    );
    _channel = channel;

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

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

    _sendText(jsonEncode(_runTaskPayload()));

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

  /// 开始转发 PCM 音频流。按协议以 200ms/6400 字节为一个数据包发送;
  /// 音频流结束时自动发送 finish-task。
  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, Object>{
      'header': <String, Object>{
        'action': 'finish-task',
        'task_id': _taskId,
        'streaming': 'duplex',
      },
      'payload': <String, Object>{'input': <String, Object>{}},
    }));
  }

  /// 停止后等待最终识别结果。
  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 ParaformerAsrException catch (error) {
      debugPrint('Paraformer 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;
  }

  Map<String, Object> _runTaskPayload() => <String, Object>{
        'header': <String, Object>{
          'action': 'run-task',
          'task_id': _taskId,
          'streaming': 'duplex',
        },
        'payload': <String, Object>{
          'task_group': 'audio',
          'task': 'asr',
          'function': 'recognition',
          'model': model,
          'parameters': <String, Object>{
            'format': 'pcm',
            'sample_rate': 16000,
            'language_hints': <String>['zh'],
            'disfluency_removal_enabled': false,
            'semantic_punctuation_enabled': false,
            'max_sentence_silence': 600,
            'punctuation_prediction_enabled': true,
            'inverse_text_normalization_enabled': true,
          },
          'input': <String, Object>{},
        },
      };

  void _onMessage(dynamic message) {
    if (message is! String) return;
    try {
      final Map<String, dynamic> json =
          jsonDecode(message) as Map<String, dynamic>;
      final Map<String, dynamic>? header =
          json['header'] as Map<String, dynamic>?;
      final String? event = header?['event'] as String?;
      switch (event) {
        case 'task-started':
          if (_handshake != null && !_handshake!.isCompleted) {
            _handshake!.complete();
          }
          return;
        case 'result-generated':
          final Map<String, dynamic>? payload =
              json['payload'] as Map<String, dynamic>?;
          final Map<String, dynamic>? output =
              payload?['output'] as Map<String, dynamic>?;
          final Map<String, dynamic>? sentence =
              output?['sentence'] as Map<String, dynamic>?;
          if (sentence == null) return;
          if (sentence['heartbeat'] == true) return;
          final String text = (sentence['text'] ?? '').toString();
          final bool sentenceEnd = sentence['sentence_end'] == true;
          if (sentenceEnd) {
            _sentences.add(text);
            _partial = '';
          } else {
            _partial = text;
          }
          _onPartial?.call(_combinedText());
          return;
        case 'task-finished':
          _completeResult();
          return;
        case 'task-failed':
          _fail(
            ParaformerAsrException(
              0,
              (header?['error_message'] ?? '语音识别失败').toString(),
            ),
          );
          return;
      }
    } 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 (_) {}
  }

  String _newUuidV4() {
    final Random random = Random.secure();
    final List<int> bytes = List<int>.generate(16, (_) => random.nextInt(256));
    bytes[6] = (bytes[6] & 0x0f) | 0x40;
    bytes[8] = (bytes[8] & 0x3f) | 0x80;
    final String hex =
        bytes.map((int byte) => byte.toRadixString(16).padLeft(2, '0')).join();
    return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
        '${hex.substring(12, 16)}-${hex.substring(16, 20)}-'
        '${hex.substring(20)}';
  }
}

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

  final int code;
  final String message;

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

/// 负责录音并把 PCM 流交给 [ParaformerAsrClient] 的会话封装。
class ParaformerAsrSession {
  ParaformerAsrSession({
    required this.apiKey,
    this.onPartial,
  });

  final String apiKey;
  final void Function(String text)? onPartial;

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

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

  /// 开始录音并发送音频。
  Future<void> startAudio() async {
    final ParaformerAsrClient? client = _client;
    if (client == null) {
      throw const ParaformerAsrException(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 ParaformerAsrClient? client = _client;
    _client = null;
    try {
      await client?.cancel();
    } catch (_) {}
  }

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