feat: 长辈模式(大字大按钮 + 偏好按账号隔离)+ 语音输入(按住说话 + 实时转写 + 后端代理 DashScope ASR + 患者端专用)+ 健康仪表盘背景渐变
This commit is contained in:
268
health_app/lib/services/realtime_speech_service.dart
Normal file
268
health_app/lib/services/realtime_speech_service.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user