对话页、AI 补练页、课程跟读步骤、独立表达步骤和评估页各自复制了一套 "录音 → AI 转写 → 填入答案"的流程,其中 3 页还复制了"录音回听/播放/删除" 的按钮和方法。现在统一到 lib/widgets/voice_answer.dart: - VoiceAnswerMixin:startVoiceInput / finishVoiceInput / toggleRecording / playRecording / deleteRecording / disposeVoiceAnswer,状态字段名沿用各页原名, 页面的 build 代码基本不动; - RecordingControls:录音回听按钮行。 用户可见的变化: - "未识别到语音"提示统一为「未识别到清晰语音,请再试一次或直接输入文字。」 (对话页、补练页、独立表达步骤、评估页原本各有不同说法;跟读步骤保留 原来提示"播放示范音"的文案)。麦克风不可用、开始录音的提示保持各页原样。 - 对话页的录音回听按钮与播放按钮之间增加 8px 间距,与其他页面一致。 - 删除录音时先停止正在播放的录音(原来只有跟读步骤这样做)。 - 离开页面时,如果还在录音或设备识别中,会先停止;不保留录音时一并删除 这段录音文件。原来课程两个步骤离开时不停止录音,各页离开时也不停止 "录音回听"的录音。 flutter analyze 无问题,flutter test 142 个测试通过;现有测试未覆盖麦克风流程。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
6.3 KiB
Dart
206 lines
6.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../core/ai_service.dart';
|
|
import '../core/app_state.dart';
|
|
import '../core/voice_service.dart';
|
|
|
|
/// Microphone handling shared by pages where the learner speaks an answer.
|
|
///
|
|
/// Two independent flows use the microphone:
|
|
/// * voice input records an answer and transcribes it into text
|
|
/// ([aiVoiceRecording], [listening], [transcribing]);
|
|
/// * the playback recorder keeps an attempt to listen back to
|
|
/// ([recording], [playingRecording], [recordingPath]).
|
|
mixin VoiceAnswerMixin<T extends StatefulWidget> on State<T> {
|
|
static const noSpeechMessage = '未识别到清晰语音,请再试一次或直接输入文字。';
|
|
static const micUnavailableMessage = '无法访问麦克风,请检查手机录音权限。';
|
|
|
|
/// Supplies the AI configuration used for transcription.
|
|
AppState get voiceState;
|
|
|
|
/// The microphone is recording an answer to transcribe. A page that also
|
|
/// uses device speech recognition sets [listening] alone for that.
|
|
bool aiVoiceRecording = false;
|
|
bool listening = false;
|
|
bool transcribing = false;
|
|
bool recording = false;
|
|
bool playingRecording = false;
|
|
String? recordingPath;
|
|
|
|
void showVoiceMessage(String message) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(message)));
|
|
}
|
|
|
|
/// Starts recording an answer for transcription and reports whether the
|
|
/// microphone started.
|
|
Future<bool> startVoiceInput({
|
|
String unavailableMessage = micUnavailableMessage,
|
|
}) async {
|
|
final started = await VoiceService.instance.startRecording();
|
|
if (!mounted) return started;
|
|
if (started) {
|
|
setState(() {
|
|
aiVoiceRecording = true;
|
|
listening = true;
|
|
});
|
|
} else {
|
|
showVoiceMessage(unavailableMessage);
|
|
}
|
|
return started;
|
|
}
|
|
|
|
/// Stops the answer recording and transcribes it.
|
|
///
|
|
/// [onTranscript] runs inside `setState` with the trimmed, non-empty text.
|
|
/// [afterTranscribe] runs once transcription has finished, whether or not
|
|
/// anything was recognized. With [keepAudio] the answer audio also becomes
|
|
/// the playback [recordingPath].
|
|
Future<void> finishVoiceInput({
|
|
required void Function(String text) onTranscript,
|
|
VoidCallback? afterTranscribe,
|
|
bool keepAudio = true,
|
|
String noSpeech = noSpeechMessage,
|
|
}) async {
|
|
final path = await VoiceService.instance.stopRecording();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
aiVoiceRecording = false;
|
|
listening = false;
|
|
transcribing = path != null;
|
|
if (keepAudio) recordingPath = path;
|
|
});
|
|
if (path == null) return;
|
|
final config = voiceState.aiConfig;
|
|
final transcribed = await AiService.instance.transcribeAudio(
|
|
filePath: path,
|
|
provider: config.provider,
|
|
endpoint: config.endpoint,
|
|
model: config.model,
|
|
);
|
|
if (!mounted) return;
|
|
final text = transcribed?.trim() ?? '';
|
|
setState(() {
|
|
transcribing = false;
|
|
if (text.isNotEmpty) onTranscript(text);
|
|
});
|
|
afterTranscribe?.call();
|
|
if (text.isEmpty) showVoiceMessage(noSpeech);
|
|
}
|
|
|
|
/// Starts a playback recording, replacing the previous one, or stops it.
|
|
Future<void> toggleRecording() async {
|
|
if (recording) {
|
|
final path = await VoiceService.instance.stopRecording();
|
|
if (mounted) {
|
|
setState(() {
|
|
recording = false;
|
|
recordingPath = path;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
await VoiceService.instance.deleteRecording(recordingPath);
|
|
final ready = await VoiceService.instance.startRecording();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
recording = ready;
|
|
if (ready) recordingPath = null;
|
|
});
|
|
if (!ready) showVoiceMessage('无法使用麦克风录音;请检查系统权限。');
|
|
}
|
|
|
|
Future<void> playRecording() async {
|
|
final path = recordingPath;
|
|
if (path == null) return;
|
|
setState(() => playingRecording = true);
|
|
await VoiceService.instance.playRecording(
|
|
path,
|
|
onComplete: () {
|
|
if (mounted) setState(() => playingRecording = false);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> deleteRecording() async {
|
|
await VoiceService.instance.stopRecordingPlayback();
|
|
await VoiceService.instance.deleteRecording(recordingPath);
|
|
if (mounted) {
|
|
setState(() {
|
|
recordingPath = null;
|
|
playingRecording = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Releases the microphone and player; call from `dispose`. Unless
|
|
/// [keepRecording], the recorded attempt is deleted.
|
|
void disposeVoiceAnswer({required bool keepRecording}) {
|
|
final voice = VoiceService.instance;
|
|
voice.stopRecordingPlayback();
|
|
if (listening && !aiVoiceRecording) voice.stopListening();
|
|
if (aiVoiceRecording || recording) {
|
|
voice.stopRecording().then((path) {
|
|
if (!keepRecording) voice.deleteRecording(path);
|
|
});
|
|
}
|
|
if (!keepRecording) voice.deleteRecording(recordingPath);
|
|
}
|
|
}
|
|
|
|
/// Record / play back / delete buttons for [VoiceAnswerMixin]'s playback
|
|
/// recorder.
|
|
class RecordingControls extends StatelessWidget {
|
|
const RecordingControls({
|
|
super.key,
|
|
required this.recording,
|
|
required this.playing,
|
|
required this.hasRecording,
|
|
required this.onToggleRecording,
|
|
required this.onPlay,
|
|
required this.onDelete,
|
|
});
|
|
|
|
final bool recording;
|
|
final bool playing;
|
|
final bool hasRecording;
|
|
|
|
/// Null disables the record button.
|
|
final VoidCallback? onToggleRecording;
|
|
final VoidCallback onPlay;
|
|
final VoidCallback onDelete;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: onToggleRecording,
|
|
icon: Icon(
|
|
recording
|
|
? Icons.stop_circle_outlined
|
|
: Icons.fiber_manual_record,
|
|
),
|
|
label: Text(recording ? '停止录音' : '录音回听'),
|
|
),
|
|
),
|
|
if (hasRecording) ...[
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
tooltip: playing ? '正在播放' : '回听录音',
|
|
onPressed: playing ? null : onPlay,
|
|
icon: const Icon(Icons.play_arrow),
|
|
),
|
|
IconButton(
|
|
tooltip: '删除录音',
|
|
onPressed: onDelete,
|
|
icon: const Icon(Icons.delete_outline),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|