feat: save repeat audio on mic record and rename button to play repeat
This commit is contained in:
@@ -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});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<void>? _playerSub;
|
||||
bool _speechReady = false;
|
||||
bool _ttsInitialized = false;
|
||||
|
||||
@@ -92,12 +95,23 @@ class VoiceService {
|
||||
|
||||
Future<String?> stopRecording() => _recorder.stop();
|
||||
|
||||
Future<void> playRecording(String path) async {
|
||||
await _player.stop();
|
||||
Future<void> 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<void> stopRecordingPlayback() => _player.stop();
|
||||
Future<void> stopRecordingPlayback() async {
|
||||
await _playerSub?.cancel();
|
||||
_playerSub = null;
|
||||
await _player.stop();
|
||||
}
|
||||
|
||||
Future<void> deleteRecording(String? path) async {
|
||||
if (path == null || path.isEmpty) return;
|
||||
|
||||
@@ -377,6 +377,7 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
aiVoiceRecording = false;
|
||||
listening = false;
|
||||
transcribing = true;
|
||||
recordingPath = path;
|
||||
});
|
||||
if (path != null) {
|
||||
final config = widget.state.aiConfig;
|
||||
@@ -489,8 +490,12 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
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<void> _deleteRecording() async {
|
||||
|
||||
@@ -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<void> _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<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 {
|
||||
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<void> _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;
|
||||
|
||||
@@ -540,8 +540,12 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
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<void> _deleteRecording() async {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user