Files
English/kouyu_english/lib/widgets/voice_answer.dart
T
shenleiandClaude Opus 5 b454e1856c fix: AI 复习变式请求字段与校验一致;转写后删除不保留的录音
- generateReviewVariant 的指令要求 stimulus/answer/acceptedAnswers 等字段,
  而 decodeGeneratedReviewVariant 只接受 schemaVersion、variantId、
  targetItemId、prompt、expectedAnswer 五个字段,导致 AI 变式始终校验失败。
  现在指令只要求这五个字段,并补充了请求与解码一致性的测试。
- finishVoiceInput(keepAudio: false) 转写完成(或页面已关闭)后删除音频,
  补练页和测评页的语音输入不再在本机残留录音文件。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:29:56 +09:00

211 lines
6.5 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]; otherwise it is deleted once transcribed.
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) {
if (!keepAudio) await VoiceService.instance.deleteRecording(path);
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,
);
// Without [keepAudio] the audio only served transcription.
if (!keepAudio) await VoiceService.instance.deleteRecording(path);
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),
),
],
],
);
}
}