269 lines
7.7 KiB
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();
|
|
}
|
|
}
|