Explorar o código

feat(recording): add real-time speech-to-text display and finalize utterance logic

实现了实时语音转文字的预览效果,新增了根据静默时长自动提交完整对话内容的逻辑,优化了对话列表的展示结构,支持显示录音过程中的实时转录结果和录音状态动画。
wenhongquan hai 1 mes
pai
achega
01b300300b

+ 81 - 69
flutter_asr_client/lib/pages/recording/notifiers/recording_notifier.dart

@@ -2,7 +2,6 @@ import 'dart:async';
 
 import 'package:flutter/foundation.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:intl/intl.dart';
 import 'package:uuid/uuid.dart';
 
 import 'package:asr_client/common/constants.dart';
@@ -28,6 +27,7 @@ final class RecordingState {
     this.elapsed = Duration.zero,
     this.audioLevel = 0.0,
     this.items = const [],
+    this.liveUtterance,
     this.isConnected = false,
     this.errorMessage,
     this.sessionId,
@@ -40,6 +40,7 @@ final class RecordingState {
   final Duration elapsed;
   final double audioLevel;
   final List<ConversationItem> items;
+  final String? liveUtterance;
   final bool isConnected;
   final String? errorMessage;
   final String? sessionId;
@@ -52,6 +53,7 @@ final class RecordingState {
     Duration? elapsed,
     double? audioLevel,
     List<ConversationItem>? items,
+    Object? liveUtterance = _unset,
     bool? isConnected,
     Object? errorMessage = _unset,
     Object? sessionId = _unset,
@@ -64,6 +66,9 @@ final class RecordingState {
       elapsed: elapsed ?? this.elapsed,
       audioLevel: audioLevel ?? this.audioLevel,
       items: items ?? this.items,
+      liveUtterance: identical(liveUtterance, _unset)
+          ? this.liveUtterance
+          : liveUtterance as String?,
       isConnected: isConnected ?? this.isConnected,
       errorMessage:
           identical(errorMessage, _unset) ? this.errorMessage : errorMessage as String?,
@@ -97,6 +102,19 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
   Duration _accumulated = Duration.zero;
   var _isSendingAudio = false;
 
+  /// Tracks the last raw transcript from server.
+  String _lastSeenText = '';
+
+  /// Accumulated full utterance, built by merging overlapping transcripts.
+  String _accumulatedText = '';
+
+  /// Consecutive times the same text was received (no ASR change).
+  int _sameCount = 0;
+
+  Timer? _utteranceTimer;
+
+  static const _utteranceSilence = Duration(seconds: 3);
+
   WebSocketService get _webSocketService => ref.read(webSocketServiceProvider);
 
   AudioCaptureService get _audioCaptureService =>
@@ -108,6 +126,7 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
       _timer?.cancel();
       _transcribeTimer?.cancel();
       _audioSendTimer?.cancel();
+      _utteranceTimer?.cancel();
     });
 
     final initialState = const RecordingState(
@@ -254,81 +273,65 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
   }
 
   void _addTranscript(String text) {
-    final now = DateTime.now();
-    final timeStr = DateFormat('HH:mm').format(now);
+    if (text.isEmpty) return;
 
-    _updateState(
-      (s) => s.copyWith(
-        items: [
-          ...s.items,
-          ConversationItem(
-            id: _uuid.v4(),
-            type: ConversationItemType.userBubble,
-            timestamp: now,
-            text: text,
-          ),
-          _buildAiResponse(text, now, timeStr),
-        ],
-      ),
-    );
-  }
+    _accumulatedText = _stitch(_accumulatedText, text);
+    _updateState((s) => s.copyWith(liveUtterance: _accumulatedText));
 
-  ConversationItem _buildAiResponse(
-    String text,
-    DateTime time,
-    String timeStr,
-  ) {
-    if (text.contains('坍落度')) {
-      return ConversationItem(
-        id: _uuid.v4(),
-        type: ConversationItemType.aiCard,
-        timestamp: time,
-        aiLabel: 'AI',
-        aiSubLabel: '结构化 · 已校验',
-        fields: [
-          const AiField(key: '指标', value: '坍落度'),
-          const AiField(key: '仪器', value: '坍落度筒'),
-          const AiField(key: '实测', value: '180 mm'),
-          const AiField(key: '合格区间', value: '160 – 220 mm'),
-        ],
-        verdict: '✓ 合格 · 落在标准区间内',
-        verdictStatus: AiVerdictStatus.ok,
-        actions: const [
-          AiAction(label: '采纳入库', isPrimary: true),
-          AiAction(label: '修正'),
-        ],
-      );
+    if (text == _lastSeenText) {
+      _sameCount++;
+      if (_sameCount >= 2 && _utteranceTimer == null) {
+        // Text hasn't changed for 2+ intervals → user paused.
+        _utteranceTimer = Timer(_utteranceSilence, _finalizeUtterance);
+      }
+      return;
     }
+    // New or corrected text → user still speaking.
+    _sameCount = 0;
+    _lastSeenText = text;
+    _utteranceTimer?.cancel();
+    _utteranceTimer = null;
+  }
 
-    if (text.contains('扩展度')) {
-      return ConversationItem(
-        id: _uuid.v4(),
-        type: ConversationItemType.aiCard,
-        timestamp: time,
-        aiLabel: 'AI',
-        aiSubLabel: '结构化 · 需复核',
-        fields: [
-          const AiField(key: '指标', value: '扩展度'),
-          const AiField(key: '实测', value: '545 mm'),
-          const AiField(key: '单位', value: '未口述,已默认 mm', highlight: true),
-        ],
-        verdict: '! 请确认单位,或补充坍落度等级判定',
-        verdictStatus: AiVerdictStatus.warn,
-        actions: const [
-          AiAction(label: '确认 mm', isPrimary: true),
-          AiAction(label: '修正'),
-        ],
-      );
+  /// Stitches [previous] with [next] by finding their longest suffix–prefix
+  /// overlap. If no overlap exists, [next] is a new utterance — finalize
+  /// [previous] and return [next] alone.
+  String _stitch(String previous, String next) {
+    if (previous.isEmpty) return next;
+    if (next.startsWith(previous)) return next;
+
+    // Find the longest suffix of `previous` that matches a prefix of `next`.
+    for (var n = next.length; n > 0; n--) {
+      final needle = next.substring(0, n);
+      if (previous.endsWith(needle)) {
+        return previous + next.substring(n);
+      }
     }
 
-    return ConversationItem(
+    // No overlap: ASR reset. Finalize old, start new.
+    _finalizeUtterance();
+    return next;
+  }
+
+  void _finalizeUtterance() {
+    _utteranceTimer?.cancel();
+    _utteranceTimer = null;
+    final text = _accumulatedText.trim();
+    _accumulatedText = '';
+    _lastSeenText = '';
+    _sameCount = 0;
+    if (text.isEmpty) return;
+
+    final item = ConversationItem(
       id: _uuid.v4(),
-      type: ConversationItemType.aiCard,
-      timestamp: time,
-      aiLabel: 'AI',
-      aiSubLabel: '已识别 · 上下文绑定',
-      text: '已收到语音输入:$text',
+      type: ConversationItemType.userBubble,
+      timestamp: DateTime.now(),
+      text: text,
     );
+    _updateState((s) => s.copyWith(
+      items: [...s.items, item],
+      liveUtterance: null,
+    ));
   }
 
   void pauseRecording() {
@@ -338,7 +341,11 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
     _transcribeTimer?.cancel();
     _audioSendTimer?.cancel();
     _audioCaptureService.stopRecording();
-    _updateState((s) => s.copyWith(status: RecordingStatus.paused));
+    _finalizeUtterance();
+    _updateState((s) => s.copyWith(
+      status: RecordingStatus.paused,
+      liveUtterance: null,
+    ));
   }
 
   Future<void> resumeRecording() async {
@@ -367,6 +374,10 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
     _timer?.cancel();
     _transcribeTimer?.cancel();
     _audioSendTimer?.cancel();
+    final live = state.value?.liveUtterance?.trim();
+    if (live != null && live.isNotEmpty) {
+      _finalizeUtterance();
+    }
     await _audioCaptureService.stopRecording();
     _webSocketService.disconnect();
     _updateState(
@@ -374,6 +385,7 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
         status: RecordingStatus.idle,
         elapsed: Duration.zero,
         items: [],
+        liveUtterance: null,
         isConnected: false,
       ),
     );

+ 5 - 1
flutter_asr_client/lib/pages/recording/recording_page.dart

@@ -224,7 +224,11 @@ class _RecordingBody extends ConsumerWidget {
                   templateName: state.templateName ?? '',
                 ),
                 const SizedBox(height: 8),
-                ConversationList(items: state.items),
+                ConversationList(
+                  items: state.items,
+                  liveUtterance: state.liveUtterance,
+                  isRecording: state.isRecording,
+                ),
               ],
             ),
           ),

+ 109 - 34
flutter_asr_client/lib/pages/recording/widgets/conversation_list.dart

@@ -1,52 +1,127 @@
 import 'package:flutter/material.dart';
 import 'package:asr_client/models/conversation.dart';
-import 'package:asr_client/pages/recording/widgets/ai_response_card.dart';
 import 'package:asr_client/pages/recording/widgets/user_bubble.dart';
+import 'package:asr_client/theme/app_colors.dart';
 
 class ConversationList extends StatelessWidget {
-  const ConversationList({super.key, required this.items});
+  const ConversationList({
+    super.key,
+    required this.items,
+    this.liveUtterance,
+    required this.isRecording,
+  });
 
   final List<ConversationItem> items;
+  final String? liveUtterance;
+  final bool isRecording;
 
   @override
   Widget build(BuildContext context) {
     return Column(
-      children: items.map((item) {
-        return Padding(
-          padding: const EdgeInsets.only(bottom: 10),
-          child: AnimatedSwitcher(
-            duration: const Duration(milliseconds: 300),
-            transitionBuilder: (child, animation) {
-              return FadeTransition(
-                opacity: animation,
-                child: SlideTransition(
-                  position:
-                      Tween<Offset>(
-                        begin: const Offset(0, 0.25),
-                        end: Offset.zero,
-                      ).animate(
-                        CurvedAnimation(
-                          parent: animation,
-                          curve: Curves.easeOutCubic,
-                        ),
-                      ),
-                  child: child,
+      children: [
+        for (final item in items)
+          Padding(
+            padding: const EdgeInsets.only(bottom: 10),
+            child: UserBubble(key: ValueKey(item.id), item: item),
+          ),
+        if (liveUtterance != null && liveUtterance!.trim().isNotEmpty)
+          _LiveBubble(text: liveUtterance!, isRecording: isRecording),
+      ],
+    );
+  }
+}
+
+class _LiveBubble extends StatelessWidget {
+  const _LiveBubble({required this.text, required this.isRecording});
+
+  final String text;
+  final bool isRecording;
+
+  @override
+  Widget build(BuildContext context) {
+    return Align(
+      alignment: Alignment.centerRight,
+      child: ConstrainedBox(
+        constraints: BoxConstraints(
+          maxWidth: MediaQuery.of(context).size.width * 0.78,
+        ),
+        child: Container(
+          padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
+          decoration: BoxDecoration(
+            color: AppColors.app,
+            borderRadius: const BorderRadius.only(
+              topLeft: Radius.circular(16),
+              topRight: Radius.circular(16),
+              bottomLeft: Radius.circular(16),
+              bottomRight: Radius.circular(4),
+            ),
+          ),
+          child: Row(
+            mainAxisSize: MainAxisSize.min,
+            crossAxisAlignment: CrossAxisAlignment.center,
+            children: [
+              Flexible(
+                child: Text(
+                  text,
+                  style: const TextStyle(
+                    fontSize: 13.5,
+                    color: Colors.white,
+                    height: 1.4,
+                  ),
                 ),
-              );
-            },
-            child: switch (item.type) {
-              ConversationItemType.userBubble => UserBubble(
-                key: ValueKey(item.id),
-                item: item,
-              ),
-              ConversationItemType.aiCard => AiResponseCard(
-                key: ValueKey(item.id),
-                item: item,
               ),
-            },
+              if (isRecording) ...[
+                const SizedBox(width: 6),
+                const _PulsingDots(),
+              ],
+            ],
           ),
+        ),
+      ),
+    );
+  }
+}
+
+class _PulsingDots extends StatefulWidget {
+  const _PulsingDots();
+
+  @override
+  State<_PulsingDots> createState() => _PulsingDotsState();
+}
+
+class _PulsingDotsState extends State<_PulsingDots>
+    with SingleTickerProviderStateMixin {
+  late final AnimationController _controller;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = AnimationController(
+      duration: const Duration(milliseconds: 1200),
+      vsync: this,
+    )..repeat();
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedBuilder(
+      animation: _controller,
+      builder: (_, child) {
+        return Opacity(
+          opacity: _controller.value < 0.5 ? 0.3 : 1.0,
+          child: child,
         );
-      }).toList(),
+      },
+      child: const Text(
+        '● ● ●',
+        style: TextStyle(fontSize: 6, color: Colors.white70, letterSpacing: 2),
+      ),
     );
   }
 }