Files
AI-Health/health_app/lib/services/realtime_speech_service.dart

269 lines
7.7 KiB
Dart

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<RealtimeSpeechService>((ref) {
return RealtimeSpeechService(ref.watch(apiClientProvider));
});
class RealtimeSpeechService {
final ApiClient _apiClient;
const RealtimeSpeechService(this._apiClient);
Future<bool> requestPermission() async {
final recorder = AudioRecorder();
try {
return await recorder.hasPermission();
} finally {
recorder.dispose();
}
}
Future<RealtimeSpeechSession> 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<SpeechRecognitionEvent> _events =
StreamController<SpeechRecognitionEvent>.broadcast();
final Completer<void> _ready = Completer<void>();
final Completer<String> _done = Completer<String>();
WebSocket? _socket;
StreamSubscription<Uint8List>? _audioSubscription;
StreamSubscription<dynamic>? _socketSubscription;
bool _finishing = false;
bool _disposed = false;
String _latestText = '';
RealtimeSpeechSession({required this.endpoint, required this.accessToken}) {
_ready.future.ignore();
_done.future.ignore();
}
Stream<SpeechRecognitionEvent> get events => _events.stream;
String get latestText => _latestText;
Future<void> 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<String> 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<void> 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<void> 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();
}
}