feat: save repeat audio on mic record and rename button to play repeat

This commit is contained in:
shen
2026-09-15 22:52:49 +08:00
parent 2a2668829d
commit 11929d6a03
8 changed files with 113 additions and 94 deletions
-1
View File
@@ -7,7 +7,6 @@ import 'package:http/http.dart' as http;
import 'models.dart'; import 'models.dart';
import 'generated_content.dart'; import 'generated_content.dart';
import 'a0_core.dart';
class AiConnectionResult { class AiConnectionResult {
const AiConnectionResult({required this.ok, required this.message}); const AiConnectionResult({required this.ok, required this.message});
@@ -1,5 +1,4 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
+17 -3
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'dart:io'; import 'dart:io';
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
@@ -16,6 +18,7 @@ class VoiceService {
final SpeechToText _stt = SpeechToText(); final SpeechToText _stt = SpeechToText();
final AudioRecorder _recorder = AudioRecorder(); final AudioRecorder _recorder = AudioRecorder();
final AudioPlayer _player = AudioPlayer(); final AudioPlayer _player = AudioPlayer();
StreamSubscription<void>? _playerSub;
bool _speechReady = false; bool _speechReady = false;
bool _ttsInitialized = false; bool _ttsInitialized = false;
@@ -92,12 +95,23 @@ class VoiceService {
Future<String?> stopRecording() => _recorder.stop(); Future<String?> stopRecording() => _recorder.stop();
Future<void> playRecording(String path) async { Future<void> playRecording(String path, {VoidCallback? onComplete}) async {
await _player.stop(); await stopRecordingPlayback();
if (onComplete != null) {
_playerSub = _player.onPlayerComplete.listen((_) {
_playerSub?.cancel();
_playerSub = null;
onComplete();
});
}
await _player.play(DeviceFileSource(path)); await _player.play(DeviceFileSource(path));
} }
Future<void> stopRecordingPlayback() => _player.stop(); Future<void> stopRecordingPlayback() async {
await _playerSub?.cancel();
_playerSub = null;
await _player.stop();
}
Future<void> deleteRecording(String? path) async { Future<void> deleteRecording(String? path) async {
if (path == null || path.isEmpty) return; if (path == null || path.isEmpty) return;
@@ -377,6 +377,7 @@ class _DialoguePageState extends State<DialoguePage> {
aiVoiceRecording = false; aiVoiceRecording = false;
listening = false; listening = false;
transcribing = true; transcribing = true;
recordingPath = path;
}); });
if (path != null) { if (path != null) {
final config = widget.state.aiConfig; final config = widget.state.aiConfig;
@@ -489,8 +490,12 @@ class _DialoguePageState extends State<DialoguePage> {
final path = recordingPath; final path = recordingPath;
if (path == null) return; if (path == null) return;
setState(() => playingRecording = true); setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path); await VoiceService.instance.playRecording(
if (mounted) setState(() => playingRecording = false); path,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
},
);
} }
Future<void> _deleteRecording() async { Future<void> _deleteRecording() async {
@@ -438,9 +438,7 @@ class _SpeakingStep extends StatefulWidget {
class _SpeakingStepState extends State<_SpeakingStep> { class _SpeakingStepState extends State<_SpeakingStep> {
bool listening = false; bool listening = false;
bool recording = false;
bool transcribing = false; bool transcribing = false;
bool aiVoiceRecording = false;
bool playingRecording = false; bool playingRecording = false;
String transcript = ''; String transcript = '';
String? recordingPath; String? recordingPath;
@@ -455,13 +453,13 @@ class _SpeakingStepState extends State<_SpeakingStep> {
} }
Future<void> _toggleMic() async { Future<void> _toggleMic() async {
if (aiVoiceRecording) { if (listening) {
final path = await VoiceService.instance.stopRecording(); final path = await VoiceService.instance.stopRecording();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
aiVoiceRecording = false;
listening = false; listening = false;
transcribing = true; transcribing = true;
recordingPath = path;
}); });
if (path != null) { if (path != null) {
final config = widget.state.aiConfig; final config = widget.state.aiConfig;
@@ -480,7 +478,7 @@ class _SpeakingStepState extends State<_SpeakingStep> {
}); });
if (text == null || text.trim().isEmpty) { if (text == null || text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未识别到清晰音,请重试或点击“播放示范音”。')), const SnackBar(content: Text('未识别到清晰音,请重试或点击“播放示范音”。')),
); );
} }
} }
@@ -490,83 +488,62 @@ class _SpeakingStepState extends State<_SpeakingStep> {
return; return;
} }
if (listening) { await VoiceService.instance.stopRecordingPlayback();
await VoiceService.instance.stopListening(); if (mounted) setState(() => playingRecording = false);
if (mounted) setState(() => listening = false); if (!widget.keepRecording) {
return; await VoiceService.instance.deleteRecording(recordingPath);
} }
final ready = await VoiceService.instance.startListening( final recordStarted = await VoiceService.instance.startRecording();
(text, _) { if (mounted) {
if (mounted) setState(() => transcript = text); setState(() {
}, listening = recordStarted;
onStatus: (status) { if (recordStarted) {
if (mounted && (status == 'notListening' || status == 'done')) { recordingPath = null;
setState(() => listening = false);
}
},
onError: (err) {
if (mounted) {
setState(() => listening = false);
} }
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
}
}
Future<void> _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<void> _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<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path);
if (mounted) setState(() => playingRecording = false);
} }
Future<void> _deleteRecording() async { Future<void> _deleteRecording() async {
await VoiceService.instance.stopRecordingPlayback();
await VoiceService.instance.deleteRecording(recordingPath); await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null); if (mounted) {
setState(() {
recordingPath = null;
playingRecording = false;
});
}
} }
@override @override
@@ -604,8 +581,8 @@ class _SpeakingStepState extends State<_SpeakingStep> {
onPressed: transcribing ? null : _toggleMic, onPressed: transcribing ? null : _toggleMic,
), ),
SecondaryButton( SecondaryButton(
label: recording ? '停止本机录音' : '录音后回听', label: playingRecording ? '停止播放' : '播放跟读',
onPressed: listening ? null : _toggleRecording, onPressed: (listening || transcribing) ? null : _togglePlayRecording,
), ),
if (recordingPath != null) if (recordingPath != null)
SectionCard( SectionCard(
@@ -613,20 +590,20 @@ class _SpeakingStepState extends State<_SpeakingStep> {
child: SpacedColumn( child: SpacedColumn(
spacing: 8, spacing: 8,
children: [ children: [
Text(widget.keepRecording ? '录音已保存在本机。' : '本次录音仅在离开此步骤前保留。'), Text(widget.keepRecording ? '录音已保存在本机。' : '本次跟读录音仅在离开此步骤前保留。'),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: playingRecording ? null : _playRecording, onPressed: (listening || transcribing) ? null : _togglePlayRecording,
icon: const Icon(Icons.play_arrow), icon: Icon(playingRecording ? Icons.stop : Icons.play_arrow),
label: const Text('回听'), label: Text(playingRecording ? '停止播放' : '播放跟读'),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton( IconButton(
tooltip: '删除录音', tooltip: '删除录音',
onPressed: _deleteRecording, onPressed: (listening || transcribing) ? null : _deleteRecording,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
), ),
], ],
@@ -635,7 +612,9 @@ class _SpeakingStepState extends State<_SpeakingStep> {
), ),
), ),
if (transcript.isNotEmpty) if (transcript.isNotEmpty)
SectionCard(child: Text('设备转写:$transcript\n请确认它是否接近你刚才说的内容。')), SectionCard(
child: Text("设备转写:$transcript\n请确认它是否接近你刚才说的内容。"),
),
const SectionCard( const SectionCard(
child: Text('转写不确定或与原句不符时,可重说或继续文字练习;这一步只算跟读练习,不作为独立口语证据。'), child: Text('转写不确定或与原句不符时,可重说或继续文字练习;这一步只算跟读练习,不作为独立口语证据。'),
), ),
@@ -1036,8 +1015,12 @@ class _IndependentStepState extends State<_IndependentStep> {
final path = recordingPath; final path = recordingPath;
if (path == null) return; if (path == null) return;
setState(() => playingRecording = true); setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path); await VoiceService.instance.playRecording(
if (mounted) setState(() => playingRecording = false); path,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
},
);
} }
Future<void> _deleteRecording() async { Future<void> _deleteRecording() async {
@@ -1053,6 +1036,7 @@ class _IndependentStepState extends State<_IndependentStep> {
aiVoiceRecording = false; aiVoiceRecording = false;
listening = false; listening = false;
transcribing = true; transcribing = true;
recordingPath = path;
}); });
if (path != null) { if (path != null) {
final config = widget.state.aiConfig; final config = widget.state.aiConfig;
@@ -540,8 +540,12 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
final path = recordingPath; final path = recordingPath;
if (path == null) return; if (path == null) return;
setState(() => playingRecording = true); setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path); await VoiceService.instance.playRecording(
if (mounted) setState(() => playingRecording = false); path,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
},
);
} }
Future<void> _deleteRecording() async { Future<void> _deleteRecording() async {
-1
View File
@@ -1,6 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; 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; import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx;
void main() { void main() {
+16 -1
View File
@@ -41,4 +41,19 @@ void main() {
); );
expect(check.onPressed, isNotNull); 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);
});
}