|
|
@@ -4,22 +4,19 @@ import 'package:flutter/foundation.dart';
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
|
|
-import 'package:asr_client/common/constants.dart';
|
|
|
import 'package:asr_client/models/conversation.dart';
|
|
|
import 'package:asr_client/models/websocket_message.dart';
|
|
|
-import 'package:asr_client/providers/audio_providers.dart';
|
|
|
+import 'package:asr_client/providers/livekit_providers.dart';
|
|
|
+import 'package:asr_client/services/livekit_service.dart';
|
|
|
import 'package:asr_client/providers/settings_providers.dart';
|
|
|
-import 'package:asr_client/providers/websocket_providers.dart';
|
|
|
-import 'package:asr_client/services/audio_capture_service.dart';
|
|
|
-import 'package:asr_client/services/websocket_service.dart';
|
|
|
|
|
|
enum RecordingStatus { idle, recording, paused, ending }
|
|
|
|
|
|
@immutable
|
|
|
-const _unset = _Unset();
|
|
|
final class _Unset {
|
|
|
const _Unset();
|
|
|
}
|
|
|
+const _unset = _Unset();
|
|
|
|
|
|
final class RecordingState {
|
|
|
const RecordingState({
|
|
|
@@ -28,6 +25,7 @@ final class RecordingState {
|
|
|
this.audioLevel = 0.0,
|
|
|
this.items = const [],
|
|
|
this.liveUtterance,
|
|
|
+ this.liveReply,
|
|
|
this.isConnected = false,
|
|
|
this.errorMessage,
|
|
|
this.sessionId,
|
|
|
@@ -41,6 +39,7 @@ final class RecordingState {
|
|
|
final double audioLevel;
|
|
|
final List<ConversationItem> items;
|
|
|
final String? liveUtterance;
|
|
|
+ final String? liveReply;
|
|
|
final bool isConnected;
|
|
|
final String? errorMessage;
|
|
|
final String? sessionId;
|
|
|
@@ -54,6 +53,7 @@ final class RecordingState {
|
|
|
double? audioLevel,
|
|
|
List<ConversationItem>? items,
|
|
|
Object? liveUtterance = _unset,
|
|
|
+ Object? liveReply = _unset,
|
|
|
bool? isConnected,
|
|
|
Object? errorMessage = _unset,
|
|
|
Object? sessionId = _unset,
|
|
|
@@ -69,15 +69,22 @@ final class RecordingState {
|
|
|
liveUtterance: identical(liveUtterance, _unset)
|
|
|
? this.liveUtterance
|
|
|
: liveUtterance as String?,
|
|
|
+ liveReply: identical(liveReply, _unset)
|
|
|
+ ? this.liveReply
|
|
|
+ : liveReply as String?,
|
|
|
isConnected: isConnected ?? this.isConnected,
|
|
|
- errorMessage:
|
|
|
- identical(errorMessage, _unset) ? this.errorMessage : errorMessage as String?,
|
|
|
- sessionId:
|
|
|
- identical(sessionId, _unset) ? this.sessionId : sessionId as String?,
|
|
|
+ errorMessage: identical(errorMessage, _unset)
|
|
|
+ ? this.errorMessage
|
|
|
+ : errorMessage as String?,
|
|
|
+ sessionId: identical(sessionId, _unset)
|
|
|
+ ? this.sessionId
|
|
|
+ : sessionId as String?,
|
|
|
projectName: identical(projectName, _unset)
|
|
|
? this.projectName
|
|
|
: projectName as String?,
|
|
|
- taskName: identical(taskName, _unset) ? this.taskName : taskName as String?,
|
|
|
+ taskName: identical(taskName, _unset)
|
|
|
+ ? this.taskName
|
|
|
+ : taskName as String?,
|
|
|
templateName: identical(templateName, _unset)
|
|
|
? this.templateName
|
|
|
: templateName as String?,
|
|
|
@@ -95,165 +102,71 @@ final recordingNotifierProvider =
|
|
|
|
|
|
final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
|
|
|
Timer? _timer;
|
|
|
- Timer? _transcribeTimer;
|
|
|
- Timer? _audioSendTimer;
|
|
|
final _uuid = const Uuid();
|
|
|
DateTime? _startTime;
|
|
|
Duration _accumulated = Duration.zero;
|
|
|
- var _isSendingAudio = false;
|
|
|
+ StreamSubscription<ServerMessage>? _messageSub;
|
|
|
|
|
|
- WebSocketService get _webSocketService => ref.read(webSocketServiceProvider);
|
|
|
-
|
|
|
- AudioCaptureService get _audioCaptureService =>
|
|
|
- ref.read(audioCaptureServiceProvider);
|
|
|
+ LiveKitService get _liveKit => ref.read(liveKitServiceProvider);
|
|
|
|
|
|
@override
|
|
|
FutureOr<RecordingState> build() async {
|
|
|
ref.onDispose(() {
|
|
|
_timer?.cancel();
|
|
|
- _transcribeTimer?.cancel();
|
|
|
- _audioSendTimer?.cancel();
|
|
|
+ _messageSub?.cancel();
|
|
|
});
|
|
|
|
|
|
+ final roomName = 'asr-test';
|
|
|
+ final identity = 'user-${_uuid.v4().substring(0, 6)}';
|
|
|
+ final lkUrl = await ref.read(settingsServiceProvider).getLiveKitUrl();
|
|
|
+
|
|
|
final initialState = const RecordingState(
|
|
|
- sessionId: 'LC-20260707-03',
|
|
|
projectName: 'G15 沈海高速改扩建 · K1120+300',
|
|
|
taskName: '混凝土坍落度试验',
|
|
|
templateName: '模板 · 普通混凝土坍落度 GB/T 50080',
|
|
|
);
|
|
|
|
|
|
try {
|
|
|
- await _connectWebSocket();
|
|
|
+ await _liveKit.connect(url: lkUrl, room: roomName, identity: identity);
|
|
|
} on Exception catch (e) {
|
|
|
- state = AsyncData(
|
|
|
- initialState.copyWith(
|
|
|
- status: RecordingStatus.idle,
|
|
|
- errorMessage: '无法连接服务器: $e',
|
|
|
- ),
|
|
|
+ return initialState.copyWith(
|
|
|
+ errorMessage: '无法连接 LiveKit: $e',
|
|
|
);
|
|
|
- return state.value ?? initialState;
|
|
|
- }
|
|
|
- if (_webSocketService.currentState != ConnectionState.connected) {
|
|
|
- state = AsyncData(
|
|
|
- initialState.copyWith(
|
|
|
- status: RecordingStatus.idle,
|
|
|
- errorMessage: '无法连接服务器,请检查服务端地址与网络后重试',
|
|
|
- ),
|
|
|
- );
|
|
|
- return state.value ?? initialState;
|
|
|
- }
|
|
|
- await _startRecording(initialState);
|
|
|
- // Also set isConnected from the actual WebSocket state, because the
|
|
|
- // server's 'connected' message may have arrived before the listener was
|
|
|
- // attached (broadcast stream doesn't buffer).
|
|
|
- if (_webSocketService.currentState == ConnectionState.connected) {
|
|
|
- _updateState((s) => s.copyWith(isConnected: true));
|
|
|
}
|
|
|
- return state.value ?? initialState;
|
|
|
- }
|
|
|
|
|
|
- Future<void> _connectWebSocket() async {
|
|
|
- final url = await ref.read(serverUrlProvider.future);
|
|
|
- await _webSocketService.connect(url);
|
|
|
+ _listenToMessages();
|
|
|
+ return _startedState(initialState);
|
|
|
}
|
|
|
|
|
|
- Future<void> _startRecording(RecordingState initialState) async {
|
|
|
- try {
|
|
|
- final hasPermission = await _audioCaptureService.requestPermission();
|
|
|
- if (!hasPermission) {
|
|
|
- final permanentlyDenied =
|
|
|
- await _audioCaptureService.isPermissionPermanentlyDenied;
|
|
|
- if (permanentlyDenied) {
|
|
|
- await _audioCaptureService.openSettings();
|
|
|
- }
|
|
|
- state = AsyncData(
|
|
|
- initialState.copyWith(
|
|
|
- status: RecordingStatus.idle,
|
|
|
- errorMessage: permanentlyDenied
|
|
|
- ? '麦克风权限被拒绝,请在系统设置中开启后重试'
|
|
|
- : '需要麦克风权限才能进行录音',
|
|
|
- ),
|
|
|
- );
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- await _audioCaptureService.startRecording();
|
|
|
- state = AsyncData(
|
|
|
- initialState.copyWith(status: RecordingStatus.recording),
|
|
|
- );
|
|
|
- _startTimer();
|
|
|
- _startTranscribeTimer();
|
|
|
- _startAudioSendTimer();
|
|
|
- _listenToAudioLevel();
|
|
|
- _listenToWebSocketMessages();
|
|
|
- } on Exception catch (e) {
|
|
|
- state = AsyncData(
|
|
|
- initialState.copyWith(
|
|
|
- status: RecordingStatus.idle,
|
|
|
- errorMessage: '录音启动失败: $e',
|
|
|
- ),
|
|
|
- );
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- void _startTimer() {
|
|
|
+ RecordingState _startedState(RecordingState s) {
|
|
|
_startTime = DateTime.now();
|
|
|
- _timer?.cancel();
|
|
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
|
final now = DateTime.now();
|
|
|
- final elapsed =
|
|
|
- _accumulated +
|
|
|
+ final elapsed = _accumulated +
|
|
|
(_startTime != null ? now.difference(_startTime!) : Duration.zero);
|
|
|
- _updateState((s) => s.copyWith(elapsed: elapsed));
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- void _startTranscribeTimer() {
|
|
|
- _transcribeTimer?.cancel();
|
|
|
- _transcribeTimer = Timer.periodic(AppConstants.transcribeInterval, (_) {
|
|
|
- if (state.value?.isRecording ?? false) {
|
|
|
- _webSocketService.send(const TranscribeMessage());
|
|
|
- }
|
|
|
+ _updateState((current) => current.copyWith(elapsed: elapsed));
|
|
|
});
|
|
|
+ return s.copyWith(status: RecordingStatus.recording, isConnected: true);
|
|
|
}
|
|
|
|
|
|
- void _startAudioSendTimer() {
|
|
|
- _audioSendTimer?.cancel();
|
|
|
- _audioSendTimer = Timer.periodic(AppConstants.audioSendInterval, (_) {
|
|
|
- if (!(state.value?.isRecording ?? false) || _isSendingAudio) return;
|
|
|
- _isSendingAudio = true;
|
|
|
- try {
|
|
|
- final bytes = _audioCaptureService.takeBuffer();
|
|
|
- if (bytes != null && bytes.isNotEmpty) {
|
|
|
- _webSocketService.sendBinary(bytes);
|
|
|
- }
|
|
|
- } on Exception catch (_) {
|
|
|
- } finally {
|
|
|
- _isSendingAudio = false;
|
|
|
- }
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- void _listenToAudioLevel() {
|
|
|
- _audioCaptureService.audioLevelStream.listen(
|
|
|
- (level) => _updateState((s) => s.copyWith(audioLevel: level)),
|
|
|
- onError: (_) {},
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- void _listenToWebSocketMessages() {
|
|
|
- _webSocketService.messageStream.listen((message) {
|
|
|
+ void _listenToMessages() {
|
|
|
+ _messageSub = _liveKit.messageStream.listen((message) {
|
|
|
+ debugPrint('[notifier] msg: ${message.runtimeType}');
|
|
|
switch (message) {
|
|
|
- case ConnectedMessage():
|
|
|
- _updateState((s) => s.copyWith(isConnected: true));
|
|
|
case PartialMessage(:final text):
|
|
|
_updateState((s) => s.copyWith(liveUtterance: text));
|
|
|
case UtteranceMessage(:final text):
|
|
|
_finalizeBubble(text);
|
|
|
+ case ReplyMessage(:final text):
|
|
|
+ _addAiReply(text);
|
|
|
+ case ReplyPartialMessage(:final text):
|
|
|
+ _updateState((s) => s.copyWith(liveReply: text));
|
|
|
case ErrorMessage(:final message):
|
|
|
_updateState((s) => s.copyWith(errorMessage: message));
|
|
|
case ClearedMessage():
|
|
|
break;
|
|
|
+ case ConnectedMessage():
|
|
|
+ _updateState((s) => s.copyWith(isConnected: true));
|
|
|
}
|
|
|
}, onError: (_) {});
|
|
|
}
|
|
|
@@ -272,15 +185,26 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
|
|
|
));
|
|
|
}
|
|
|
|
|
|
+ void _addAiReply(String text) {
|
|
|
+ final item = ConversationItem(
|
|
|
+ id: _uuid.v4(),
|
|
|
+ type: ConversationItemType.aiCard,
|
|
|
+ timestamp: DateTime.now(),
|
|
|
+ text: text,
|
|
|
+ );
|
|
|
+ _updateState((s) => s.copyWith(items: [...s.items, item]));
|
|
|
+ }
|
|
|
+
|
|
|
void pauseRecording() {
|
|
|
if (state.value?.status != RecordingStatus.recording) return;
|
|
|
_accumulated = state.value!.elapsed;
|
|
|
_timer?.cancel();
|
|
|
- _transcribeTimer?.cancel();
|
|
|
- _audioSendTimer?.cancel();
|
|
|
- _audioCaptureService.stopRecording();
|
|
|
- _webSocketService.send(const FinalizeMessage());
|
|
|
- // Let any final utterance message from server arrive before clearing UI.
|
|
|
+ // Finalize any pending live utterance before disconnecting.
|
|
|
+ final live = state.value?.liveUtterance?.trim();
|
|
|
+ if (live != null && live.isNotEmpty) {
|
|
|
+ _finalizeBubble(live);
|
|
|
+ }
|
|
|
+ _liveKit.disconnect();
|
|
|
_updateState((s) => s.copyWith(
|
|
|
status: RecordingStatus.paused,
|
|
|
liveUtterance: null,
|
|
|
@@ -290,18 +214,12 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
|
|
|
Future<void> resumeRecording() async {
|
|
|
if (state.value?.status != RecordingStatus.paused) return;
|
|
|
try {
|
|
|
- await _audioCaptureService.startRecording();
|
|
|
- _startTimer();
|
|
|
- _startTranscribeTimer();
|
|
|
- _startAudioSendTimer();
|
|
|
- _updateState((s) => s.copyWith(status: RecordingStatus.recording));
|
|
|
+ final roomName = 'asr-test';
|
|
|
+ final lkUrl = await ref.read(settingsServiceProvider).getLiveKitUrl();
|
|
|
+ await _liveKit.connect(url: lkUrl, room: roomName, identity: 'user-${_uuid.v4().substring(0, 6)}');
|
|
|
+ _updateState((s) => _startedState(s));
|
|
|
} on Exception catch (e) {
|
|
|
- _updateState(
|
|
|
- (s) => s.copyWith(
|
|
|
- status: RecordingStatus.paused,
|
|
|
- errorMessage: '继续录音失败: $e',
|
|
|
- ),
|
|
|
- );
|
|
|
+ _updateState((s) => s.copyWith(errorMessage: '无法恢复录音: $e'));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -311,28 +229,21 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
|
|
|
|
|
|
Future<void> confirmEnd() async {
|
|
|
_timer?.cancel();
|
|
|
- _transcribeTimer?.cancel();
|
|
|
- _audioSendTimer?.cancel();
|
|
|
- _webSocketService.send(const FinalizeMessage());
|
|
|
- await _audioCaptureService.stopRecording();
|
|
|
- _webSocketService.disconnect();
|
|
|
- _updateState(
|
|
|
- (s) => s.copyWith(
|
|
|
- status: RecordingStatus.idle,
|
|
|
- elapsed: Duration.zero,
|
|
|
- items: [],
|
|
|
- liveUtterance: null,
|
|
|
- isConnected: false,
|
|
|
- ),
|
|
|
- );
|
|
|
+ _messageSub?.cancel();
|
|
|
+ await _liveKit.disconnect();
|
|
|
+ _updateState((s) => s.copyWith(
|
|
|
+ status: RecordingStatus.idle,
|
|
|
+ elapsed: Duration.zero,
|
|
|
+ items: [],
|
|
|
+ liveUtterance: null,
|
|
|
+ isConnected: false,
|
|
|
+ ));
|
|
|
}
|
|
|
|
|
|
void cancelEnd() {
|
|
|
- _updateState(
|
|
|
- (s) => s.copyWith(
|
|
|
- status: s.isPaused ? RecordingStatus.paused : RecordingStatus.recording,
|
|
|
- ),
|
|
|
- );
|
|
|
+ _updateState((s) => s.copyWith(
|
|
|
+ status: s.isPaused ? RecordingStatus.paused : RecordingStatus.recording,
|
|
|
+ ));
|
|
|
}
|
|
|
|
|
|
void clearError() {
|