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 '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';
+17 -3
View File
@@ -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 {