import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:record/record.dart'; import '../core/api_client.dart'; import '../providers/auth_provider.dart'; enum SpeechRecognitionEventType { partial, done, error } class SpeechRecognitionEvent { final SpeechRecognitionEventType type; final String text; const SpeechRecognitionEvent(this.type, this.text); } class SpeechRecognitionException implements Exception { final String message; const SpeechRecognitionException(this.message); @override String toString() => message; } final realtimeSpeechServiceProvider = Provider((ref) { return RealtimeSpeechService(ref.watch(apiClientProvider)); }); class RealtimeSpeechService { final ApiClient _apiClient; const RealtimeSpeechService(this._apiClient); Future requestPermission() async { final recorder = AudioRecorder(); try { return await recorder.hasPermission(); } finally { recorder.dispose(); } } Future start() async { final token = await _apiClient.accessToken; if (token == null || token.isEmpty) { throw const SpeechRecognitionException('登录状态已失效,请重新登录'); } final session = RealtimeSpeechSession( endpoint: buildRealtimeSpeechEndpoint(baseUrl), accessToken: token, ); try { await session.start(); return session; } catch (_) { await session.dispose(); rethrow; } } } Uri buildRealtimeSpeechEndpoint(String apiBaseUrl) { final apiUri = Uri.parse(apiBaseUrl); final basePath = apiUri.path.endsWith('/') ? apiUri.path.substring(0, apiUri.path.length - 1) : apiUri.path; return apiUri.replace( scheme: apiUri.scheme == 'https' ? 'wss' : 'ws', path: '$basePath/api/speech/realtime', query: null, fragment: null, ); } class RealtimeSpeechSession { final Uri endpoint; final String accessToken; final AudioRecorder _recorder = AudioRecorder(); final StreamController _events = StreamController.broadcast(); final Completer _ready = Completer(); final Completer _done = Completer(); WebSocket? _socket; StreamSubscription? _audioSubscription; StreamSubscription? _socketSubscription; bool _finishing = false; bool _disposed = false; String _latestText = ''; RealtimeSpeechSession({required this.endpoint, required this.accessToken}) { _ready.future.ignore(); _done.future.ignore(); } Stream get events => _events.stream; String get latestText => _latestText; Future start() async { if (!await _recorder.hasPermission()) { throw const SpeechRecognitionException('没有麦克风权限,请在系统设置中允许后重试'); } try { _socket = await WebSocket.connect( endpoint.toString(), headers: {'Authorization': 'Bearer $accessToken'}, ).timeout(const Duration(seconds: 10)); } on TimeoutException { throw const SpeechRecognitionException('连接语音服务超时,请检查网络'); } on WebSocketException { throw const SpeechRecognitionException('无法连接语音服务,请稍后重试'); } _socketSubscription = _socket!.listen( _handleSocketMessage, onError: _handleSocketError, onDone: _handleSocketDone, cancelOnError: false, ); try { await _ready.future.timeout(const Duration(seconds: 10)); } on TimeoutException { throw const SpeechRecognitionException('语音服务准备超时,请重试'); } final audioStream = await _recorder.startStream( const RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: 16000, numChannels: 1, autoGain: true, noiseSuppress: true, echoCancel: true, streamBufferSize: 3200, ), ); _audioSubscription = audioStream.listen( (chunk) { final socket = _socket; if (!_finishing && socket?.readyState == WebSocket.open) { socket!.add(chunk); } }, onError: (Object error, StackTrace stackTrace) { _completeError('麦克风录音失败,请重试'); }, ); } Future finish() async { if (_disposed) return _latestText; if (_finishing) return _done.future; _finishing = true; await _recorder.stop(); await _audioSubscription?.cancel(); _audioSubscription = null; // 后端可能在用户松手前已主动结束(例如达到 60 秒上限), // 此时 _done 已完成,直接返回结果即可,不需要再发 finish。 if (_done.isCompleted) return _done.future; final socket = _socket; if (socket?.readyState != WebSocket.open) { throw const SpeechRecognitionException('语音连接已断开,请重试'); } socket!.add(jsonEncode({'action': 'finish'})); try { return await _done.future.timeout(const Duration(seconds: 12)); } on TimeoutException { throw const SpeechRecognitionException('识别结果返回超时,请重试'); } } Future cancel() async { if (_disposed) return; if (!_finishing) { _finishing = true; final socket = _socket; if (socket?.readyState == WebSocket.open) { socket!.add(jsonEncode({'action': 'cancel'})); } } await _recorder.cancel(); await dispose(); } void _handleSocketMessage(dynamic raw) { if (raw is! String) return; try { final decoded = jsonDecode(raw); if (decoded is! Map) return; final type = decoded['type']?.toString(); if (type == 'ready') { if (!_ready.isCompleted) _ready.complete(); return; } if (type == 'partial') { _latestText = decoded['text']?.toString() ?? _latestText; _events.add( SpeechRecognitionEvent( SpeechRecognitionEventType.partial, _latestText, ), ); return; } if (type == 'done') { _latestText = decoded['text']?.toString() ?? _latestText; _events.add( SpeechRecognitionEvent(SpeechRecognitionEventType.done, _latestText), ); if (!_done.isCompleted) _done.complete(_latestText); return; } if (type == 'error') { _completeError(decoded['message']?.toString() ?? '语音识别失败,请重试'); } } catch (_) { _completeError('语音服务返回了无法识别的数据'); } } void _handleSocketError(Object error, StackTrace stackTrace) { _completeError('语音连接异常,请检查网络'); } void _handleSocketDone() { if (!_ready.isCompleted) { _ready.completeError(const SpeechRecognitionException('语音服务连接失败,请重试')); } else if (!_done.isCompleted && !_disposed) { _completeError('语音连接已断开,请重试'); } } void _completeError(String message) { final error = SpeechRecognitionException(message); if (!_ready.isCompleted) _ready.completeError(error); if (!_done.isCompleted) _done.completeError(error); if (!_events.isClosed) { _events.add( SpeechRecognitionEvent(SpeechRecognitionEventType.error, message), ); } } Future dispose() async { if (_disposed) return; _disposed = true; await _audioSubscription?.cancel(); await _socketSubscription?.cancel(); final socket = _socket; if (socket != null && socket.readyState == WebSocket.open) { await socket.close(WebSocketStatus.normalClosure, 'completed'); } await _events.close(); _recorder.dispose(); } }