From 11929d6a0309748532a4070968974e03c21c209a Mon Sep 17 00:00:00 2001 From: shen <> Date: Tue, 15 Sep 2026 22:52:49 +0800 Subject: [PATCH] feat: save repeat audio on mic record and rename button to play repeat --- kouyu_english/lib/core/ai_service.dart | 1 - .../lib/core/sherpa_stt_service.dart | 1 - kouyu_english/lib/core/voice_service.dart | 20 ++- .../lib/features/dialogue/dialogue_flow.dart | 9 +- .../lib/features/lesson/lesson_flow.dart | 150 ++++++++---------- .../lib/features/review/review_page.dart | 8 +- kouyu_english/test/sherpa_stt_test.dart | 1 - kouyu_english/test/widget_test.dart | 17 +- 8 files changed, 113 insertions(+), 94 deletions(-) diff --git a/kouyu_english/lib/core/ai_service.dart b/kouyu_english/lib/core/ai_service.dart index 7d3bf74..d4cd8f8 100644 --- a/kouyu_english/lib/core/ai_service.dart +++ b/kouyu_english/lib/core/ai_service.dart @@ -7,7 +7,6 @@ import 'package:http/http.dart' as http; import 'models.dart'; import 'generated_content.dart'; -import 'a0_core.dart'; class AiConnectionResult { const AiConnectionResult({required this.ok, required this.message}); diff --git a/kouyu_english/lib/core/sherpa_stt_service.dart b/kouyu_english/lib/core/sherpa_stt_service.dart index c35cc8c..f34772c 100644 --- a/kouyu_english/lib/core/sherpa_stt_service.dart +++ b/kouyu_english/lib/core/sherpa_stt_service.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; diff --git a/kouyu_english/lib/core/voice_service.dart b/kouyu_english/lib/core/voice_service.dart index d9f84d2..64bd3c8 100644 --- a/kouyu_english/lib/core/voice_service.dart +++ b/kouyu_english/lib/core/voice_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'dart:io'; import 'package:audioplayers/audioplayers.dart'; @@ -16,6 +18,7 @@ class VoiceService { final SpeechToText _stt = SpeechToText(); final AudioRecorder _recorder = AudioRecorder(); final AudioPlayer _player = AudioPlayer(); + StreamSubscription? _playerSub; bool _speechReady = false; bool _ttsInitialized = false; @@ -92,12 +95,23 @@ class VoiceService { Future stopRecording() => _recorder.stop(); - Future playRecording(String path) async { - await _player.stop(); + Future playRecording(String path, {VoidCallback? onComplete}) async { + await stopRecordingPlayback(); + if (onComplete != null) { + _playerSub = _player.onPlayerComplete.listen((_) { + _playerSub?.cancel(); + _playerSub = null; + onComplete(); + }); + } await _player.play(DeviceFileSource(path)); } - Future stopRecordingPlayback() => _player.stop(); + Future stopRecordingPlayback() async { + await _playerSub?.cancel(); + _playerSub = null; + await _player.stop(); + } Future deleteRecording(String? path) async { if (path == null || path.isEmpty) return; diff --git a/kouyu_english/lib/features/dialogue/dialogue_flow.dart b/kouyu_english/lib/features/dialogue/dialogue_flow.dart index 9ff616a..71eeb8a 100644 --- a/kouyu_english/lib/features/dialogue/dialogue_flow.dart +++ b/kouyu_english/lib/features/dialogue/dialogue_flow.dart @@ -377,6 +377,7 @@ class _DialoguePageState extends State { aiVoiceRecording = false; listening = false; transcribing = true; + recordingPath = path; }); if (path != null) { final config = widget.state.aiConfig; @@ -489,8 +490,12 @@ class _DialoguePageState extends State { final path = recordingPath; if (path == null) return; setState(() => playingRecording = true); - await VoiceService.instance.playRecording(path); - if (mounted) setState(() => playingRecording = false); + await VoiceService.instance.playRecording( + path, + onComplete: () { + if (mounted) setState(() => playingRecording = false); + }, + ); } Future _deleteRecording() async { diff --git a/kouyu_english/lib/features/lesson/lesson_flow.dart b/kouyu_english/lib/features/lesson/lesson_flow.dart index 241ef2c..4ded584 100644 --- a/kouyu_english/lib/features/lesson/lesson_flow.dart +++ b/kouyu_english/lib/features/lesson/lesson_flow.dart @@ -438,9 +438,7 @@ class _SpeakingStep extends StatefulWidget { class _SpeakingStepState extends State<_SpeakingStep> { bool listening = false; - bool recording = false; bool transcribing = false; - bool aiVoiceRecording = false; bool playingRecording = false; String transcript = ''; String? recordingPath; @@ -455,13 +453,13 @@ class _SpeakingStepState extends State<_SpeakingStep> { } Future _toggleMic() async { - if (aiVoiceRecording) { + if (listening) { final path = await VoiceService.instance.stopRecording(); if (!mounted) return; setState(() { - aiVoiceRecording = false; listening = false; transcribing = true; + recordingPath = path; }); if (path != null) { final config = widget.state.aiConfig; @@ -480,7 +478,7 @@ class _SpeakingStepState extends State<_SpeakingStep> { }); if (text == null || text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('未识别到清晰声音,请重试或点击“播放示范音”。')), + const SnackBar(content: Text('未识别到清晰发音,请重试或点击“播放示范音”。')), ); } } @@ -490,83 +488,62 @@ class _SpeakingStepState extends State<_SpeakingStep> { return; } - if (listening) { - await VoiceService.instance.stopListening(); - if (mounted) setState(() => listening = false); - return; + await VoiceService.instance.stopRecordingPlayback(); + if (mounted) setState(() => playingRecording = false); + if (!widget.keepRecording) { + await VoiceService.instance.deleteRecording(recordingPath); } - final ready = await VoiceService.instance.startListening( - (text, _) { - if (mounted) setState(() => transcript = text); - }, - onStatus: (status) { - if (mounted && (status == 'notListening' || status == 'done')) { - setState(() => listening = false); - } - }, - onError: (err) { - if (mounted) { - setState(() => listening = false); + final recordStarted = await VoiceService.instance.startRecording(); + if (mounted) { + setState(() { + listening = recordStarted; + if (recordStarted) { + recordingPath = null; } + }); + if (recordStarted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。')), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')), + ); + } + } + } + + Future _togglePlayRecording() async { + if (playingRecording) { + await VoiceService.instance.stopRecordingPlayback(); + if (mounted) setState(() => playingRecording = false); + return; + } + if (recordingPath == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请先使用麦克风跟读,录音完成后即可播放。')), + ); + return; + } + setState(() => playingRecording = true); + await VoiceService.instance.playRecording( + recordingPath!, + onComplete: () { + if (mounted) setState(() => playingRecording = false); }, ); - - if (!ready) { - final recordStarted = await VoiceService.instance.startRecording(); - if (mounted) { - setState(() { - aiVoiceRecording = recordStarted; - listening = recordStarted; - }); - if (recordStarted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。')), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')), - ); - } - } - return; - } - - if (mounted) setState(() => listening = ready); - } - - Future _toggleRecording() async { - if (recording) { - final path = await VoiceService.instance.stopRecording(); - if (mounted) { - setState(() { - recording = false; - recordingPath = path; - }); - } - return; - } - final ready = await VoiceService.instance.startRecording(); - if (!mounted) return; - setState(() => recording = ready); - if (!ready) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。'))); - } - } - - Future _playRecording() async { - final path = recordingPath; - if (path == null) return; - setState(() => playingRecording = true); - await VoiceService.instance.playRecording(path); - if (mounted) setState(() => playingRecording = false); } Future _deleteRecording() async { + await VoiceService.instance.stopRecordingPlayback(); await VoiceService.instance.deleteRecording(recordingPath); - if (mounted) setState(() => recordingPath = null); + if (mounted) { + setState(() { + recordingPath = null; + playingRecording = false; + }); + } } @override @@ -604,8 +581,8 @@ class _SpeakingStepState extends State<_SpeakingStep> { onPressed: transcribing ? null : _toggleMic, ), SecondaryButton( - label: recording ? '停止本机录音' : '录音后回听', - onPressed: listening ? null : _toggleRecording, + label: playingRecording ? '停止播放' : '播放跟读', + onPressed: (listening || transcribing) ? null : _togglePlayRecording, ), if (recordingPath != null) SectionCard( @@ -613,20 +590,20 @@ class _SpeakingStepState extends State<_SpeakingStep> { child: SpacedColumn( spacing: 8, children: [ - Text(widget.keepRecording ? '录音已保存在本机。' : '本次录音仅在离开此步骤前保留。'), + Text(widget.keepRecording ? '录音已保存在本机。' : '本次跟读录音仅在离开此步骤前保留。'), Row( children: [ Expanded( child: OutlinedButton.icon( - onPressed: playingRecording ? null : _playRecording, - icon: const Icon(Icons.play_arrow), - label: const Text('回听'), + onPressed: (listening || transcribing) ? null : _togglePlayRecording, + icon: Icon(playingRecording ? Icons.stop : Icons.play_arrow), + label: Text(playingRecording ? '停止播放' : '播放跟读'), ), ), const SizedBox(width: 8), IconButton( tooltip: '删除录音', - onPressed: _deleteRecording, + onPressed: (listening || transcribing) ? null : _deleteRecording, icon: const Icon(Icons.delete_outline), ), ], @@ -635,7 +612,9 @@ class _SpeakingStepState extends State<_SpeakingStep> { ), ), if (transcript.isNotEmpty) - SectionCard(child: Text('设备转写:$transcript\n请确认它是否接近你刚才说的内容。')), + SectionCard( + child: Text("设备转写:$transcript\n请确认它是否接近你刚才说的内容。"), + ), const SectionCard( child: Text('转写不确定或与原句不符时,可重说或继续文字练习;这一步只算跟读练习,不作为独立口语证据。'), ), @@ -1036,8 +1015,12 @@ class _IndependentStepState extends State<_IndependentStep> { final path = recordingPath; if (path == null) return; setState(() => playingRecording = true); - await VoiceService.instance.playRecording(path); - if (mounted) setState(() => playingRecording = false); + await VoiceService.instance.playRecording( + path, + onComplete: () { + if (mounted) setState(() => playingRecording = false); + }, + ); } Future _deleteRecording() async { @@ -1053,6 +1036,7 @@ class _IndependentStepState extends State<_IndependentStep> { aiVoiceRecording = false; listening = false; transcribing = true; + recordingPath = path; }); if (path != null) { final config = widget.state.aiConfig; diff --git a/kouyu_english/lib/features/review/review_page.dart b/kouyu_english/lib/features/review/review_page.dart index a6ac056..75b1510 100644 --- a/kouyu_english/lib/features/review/review_page.dart +++ b/kouyu_english/lib/features/review/review_page.dart @@ -540,8 +540,12 @@ class _AdaptiveLessonPageState extends State { final path = recordingPath; if (path == null) return; setState(() => playingRecording = true); - await VoiceService.instance.playRecording(path); - if (mounted) setState(() => playingRecording = false); + await VoiceService.instance.playRecording( + path, + onComplete: () { + if (mounted) setState(() => playingRecording = false); + }, + ); } Future _deleteRecording() async { diff --git a/kouyu_english/test/sherpa_stt_test.dart b/kouyu_english/test/sherpa_stt_test.dart index 94cab5d..a025b04 100644 --- a/kouyu_english/test/sherpa_stt_test.dart +++ b/kouyu_english/test/sherpa_stt_test.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:kouyu_english/core/sherpa_stt_service.dart'; import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx; void main() { diff --git a/kouyu_english/test/widget_test.dart b/kouyu_english/test/widget_test.dart index 38a452e..b8f4d1f 100644 --- a/kouyu_english/test/widget_test.dart +++ b/kouyu_english/test/widget_test.dart @@ -41,4 +41,19 @@ void main() { ); expect(check.onPressed, isNotNull); }); -} + + testWidgets('speaking step displays use mic and play repeat buttons', ( + tester, + ) async { + final state = AppState()..lessonStep = LessonStep.speaking; + await tester.pumpWidget( + MaterialApp( + home: LessonFlow(state: state, onOpenDialogue: () {}, onFinish: () {}), + ), + ); + + expect(find.text('跟读'), findsOneWidget); + expect(find.text('使用麦克风跟读'), findsOneWidget); + expect(find.text('播放跟读'), findsOneWidget); + }); +} \ No newline at end of file