fix: 优化语音识别功能,增加转写中状态和错误处理

This commit is contained in:
qzl
2026-03-10 17:43:28 +08:00
parent f30bfc2006
commit 2ec0965322
2 changed files with 153 additions and 24 deletions
@@ -29,6 +29,8 @@ const _inputRadius = 24.0;
const _scrollDurationMs = 300; const _scrollDurationMs = 300;
const _rippleDurationMs = 1200; const _rippleDurationMs = 1200;
const _recordingDotSize = 10.0; const _recordingDotSize = 10.0;
const _transcribingSpinnerSize = 18.0;
const _transcribingStrokeWidth = 2.0;
/// 颜色常量 /// 颜色常量
const _chatBgColor = Color(0xFFF8FAFC); const _chatBgColor = Color(0xFFF8FAFC);
@@ -446,6 +448,8 @@ class _HomeScreenState extends State<HomeScreen>
Expanded( Expanded(
child: _isRecording child: _isRecording
? _buildListeningIndicator() ? _buildListeningIndicator()
: _isTranscribing
? _buildTranscribingIndicator()
: TextField( : TextField(
controller: _messageController, controller: _messageController,
minLines: 1, minLines: 1,
@@ -474,7 +478,16 @@ class _HomeScreenState extends State<HomeScreen>
: _hasMessage : _hasMessage
? () => _sendMessage(context) ? () => _sendMessage(context)
: _startRecording, : _startRecording,
child: Icon( child: _isTranscribing
? const SizedBox(
width: _transcribingSpinnerSize,
height: _transcribingSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: _transcribingStrokeWidth,
color: AppColors.blue600,
),
)
: Icon(
_isRecording || _hasMessage _isRecording || _hasMessage
? LucideIcons.send ? LucideIcons.send
: LucideIcons.mic, : LucideIcons.mic,
@@ -496,6 +509,7 @@ class _HomeScreenState extends State<HomeScreen>
Future<void> _sendMessage(BuildContext context) async { Future<void> _sendMessage(BuildContext context) async {
final content = _messageController.text.trim(); final content = _messageController.text.trim();
if (content.isEmpty) return; if (content.isEmpty) return;
FocusScope.of(context).unfocus();
_messageController.clear(); _messageController.clear();
context.read<ChatBloc>().sendMessage(content); context.read<ChatBloc>().sendMessage(content);
@@ -548,6 +562,27 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
Widget _buildTranscribingIndicator() {
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 180),
builder: (context, value, child) {
return Opacity(opacity: value, child: child);
},
child: const SizedBox(
key: ValueKey('transcribing_indicator'),
height: _inputMinHeight,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'语音识别中...',
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
),
),
);
}
Widget _buildWaveDot({required double scale}) { Widget _buildWaveDot({required double scale}) {
return Transform.scale( return Transform.scale(
scale: scale, scale: scale,
@@ -599,13 +634,18 @@ class _HomeScreenState extends State<HomeScreen>
if (!mounted) { if (!mounted) {
return; return;
} }
final normalizedTranscript = transcript.trim();
if (normalizedTranscript.isEmpty) {
Toast.show(context, '未识别到有效语音,请靠近麦克风并连续说话后重试', type: ToastType.error);
return;
}
_messageController.text = transcript; _messageController.text = transcript;
_messageController.selection = TextSelection.fromPosition( _messageController.selection = TextSelection.fromPosition(
TextPosition(offset: transcript.length), TextPosition(offset: transcript.length),
); );
if (autoSendAfterTranscribe && transcript.trim().isNotEmpty) { if (autoSendAfterTranscribe) {
_messageController.clear(); _messageController.clear();
await _autoSendTranscript(transcript.trim()); await _autoSendTranscript(normalizedTranscript);
} }
} catch (error) { } catch (error) {
if (!mounted) { if (!mounted) {
@@ -613,12 +653,16 @@ class _HomeScreenState extends State<HomeScreen>
} }
Toast.show(context, _readableError(error), type: ToastType.error); Toast.show(context, _readableError(error), type: ToastType.error);
} finally { } finally {
try {
if (audioPath != null) { if (audioPath != null) {
final file = File(audioPath); final file = File(audioPath);
if (await file.exists()) { if (await file.exists()) {
await file.delete(); await file.delete();
} }
} }
} catch (_) {
// Ignore temp file cleanup errors to avoid blocking UI state recovery.
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_isTranscribing = false; _isTranscribing = false;
@@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
@@ -17,7 +20,8 @@ class _FakeVoiceRecorder implements VoiceRecorder {
@override @override
Future<String?> stop() async { Future<String?> stop() async {
started = false; started = false;
stoppedPath = '/tmp/test-audio.wav'; stoppedPath ??=
'${Directory.systemTemp.path}/test-audio-${DateTime.now().microsecondsSinceEpoch}.wav';
return stoppedPath; return stoppedPath;
} }
@@ -90,7 +94,7 @@ void main() {
voiceRecorder: fakeRecorder, voiceRecorder: fakeRecorder,
autoLoadHistory: false, autoLoadHistory: false,
onTranscribeAudio: (filePath) async { onTranscribeAudio: (filePath) async {
expect(filePath, '/tmp/test-audio.wav'); expect(filePath.endsWith('.wav'), true);
return '语音自动发送'; return '语音自动发送';
}, },
onAutoSendTranscript: (transcript) async { onAutoSendTranscript: (transcript) async {
@@ -104,13 +108,13 @@ void main() {
await tester.tap(find.byIcon(LucideIcons.mic)); await tester.tap(find.byIcon(LucideIcons.mic));
await tester.pump(); await tester.pump();
await tester.tap(find.byIcon(LucideIcons.send)); await tester.tap(find.byIcon(LucideIcons.send));
await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 300));
expect(sentTranscript, '语音自动发送'); expect(sentTranscript, '语音自动发送');
expect(find.byIcon(LucideIcons.plus), findsOneWidget); expect(find.byIcon(LucideIcons.plus), findsOneWidget);
}); });
testWidgets('tap stop transcribes audio and fills input', ( testWidgets('tap stop enters transcribing state', (
WidgetTester tester, WidgetTester tester,
) async { ) async {
final fakeRecorder = _FakeVoiceRecorder(); final fakeRecorder = _FakeVoiceRecorder();
@@ -120,7 +124,7 @@ void main() {
voiceRecorder: fakeRecorder, voiceRecorder: fakeRecorder,
autoLoadHistory: false, autoLoadHistory: false,
onTranscribeAudio: (filePath) async { onTranscribeAudio: (filePath) async {
expect(filePath, '/tmp/test-audio.wav'); expect(filePath.endsWith('.wav'), true);
return '语音转文字结果'; return '语音转文字结果';
}, },
), ),
@@ -131,10 +135,10 @@ void main() {
await tester.tap(find.byIcon(LucideIcons.mic)); await tester.tap(find.byIcon(LucideIcons.mic));
await tester.pump(); await tester.pump();
await tester.tap(find.byIcon(LucideIcons.square)); await tester.tap(find.byIcon(LucideIcons.square));
await tester.pumpAndSettle(); await tester.pump();
expect(find.text('语音转文字结果'), findsOneWidget); expect(find.text('语音识别中...'), findsOneWidget);
expect(find.byIcon(LucideIcons.plus), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget);
}); });
testWidgets('tap stop shows readable unauthorized message', ( testWidgets('tap stop shows readable unauthorized message', (
@@ -157,10 +161,91 @@ void main() {
await tester.tap(find.byIcon(LucideIcons.mic)); await tester.tap(find.byIcon(LucideIcons.mic));
await tester.pump(); await tester.pump();
await tester.tap(find.byIcon(LucideIcons.square)); await tester.tap(find.byIcon(LucideIcons.square));
await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 300));
expect(find.text('请重新登录'), findsOneWidget); expect(find.text('请重新登录'), findsOneWidget);
await tester.pump(const Duration(seconds: 3)); await tester.pump(const Duration(seconds: 3));
}); });
testWidgets('tap stop shows message when transcript is empty', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(
voiceRecorder: fakeRecorder,
autoLoadHistory: false,
onTranscribeAudio: (_) async => '',
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.square));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('未识别到有效语音,请靠近麦克风并连续说话后重试'), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
});
testWidgets('shows transcribing indicator while waiting ASR result', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
final completer = Completer<String>();
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(
voiceRecorder: fakeRecorder,
autoLoadHistory: false,
onTranscribeAudio: (_) => completer.future,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.square));
await tester.pump();
expect(find.text('语音识别中...'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
completer.complete('识别完成');
});
testWidgets('tap send unfocuses text input after sending', (
WidgetTester tester,
) async {
await tester.pumpWidget(
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(TextField));
await tester.pump();
await tester.enterText(find.byType(TextField), 'hello');
await tester.pump();
final editableBefore = tester.state<EditableTextState>(
find.byType(EditableText),
);
expect(editableBefore.widget.focusNode.hasFocus, isTrue);
await tester.tap(find.byIcon(LucideIcons.send));
await tester.pump();
final editableAfter = tester.state<EditableTextState>(
find.byType(EditableText),
);
expect(editableAfter.widget.focusNode.hasFocus, isFalse);
await tester.pump(const Duration(milliseconds: 300));
});
}); });
} }