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 on State { 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 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 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 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 playRecording() async { final path = recordingPath; if (path == null) return; setState(() => playingRecording = true); await VoiceService.instance.playRecording( path, onComplete: () { if (mounted) setState(() => playingRecording = false); }, ); } Future 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), ), ], ], ); } }