From 503f1d8d1dbcc144ce0d05e3dc5c40f77169b834 Mon Sep 17 00:00:00 2001 From: shen <> Date: Tue, 15 Sep 2026 22:02:26 +0800 Subject: [PATCH] feat: add AI voice transcription fallback for domestic Android ROMs --- kouyu_english/lib/core/ai_service.dart | 122 ++++++++++++ kouyu_english/lib/core/app_state.dart | 6 + kouyu_english/lib/core/voice_service.dart | 80 ++++++-- .../features/assessment/assessment_page.dart | 101 ++++++++-- .../lib/features/dialogue/dialogue_flow.dart | 118 +++++++++-- .../lib/features/lesson/lesson_flow.dart | 186 ++++++++++++++++-- .../lib/features/review/review_page.dart | 125 ++++++++++-- .../test/ai_audio_transcribe_test.dart | 35 ++++ 8 files changed, 683 insertions(+), 90 deletions(-) create mode 100644 kouyu_english/test/ai_audio_transcribe_test.dart diff --git a/kouyu_english/lib/core/ai_service.dart b/kouyu_english/lib/core/ai_service.dart index c5e2b60..b59452e 100644 --- a/kouyu_english/lib/core/ai_service.dart +++ b/kouyu_english/lib/core/ai_service.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -84,6 +85,127 @@ class AiService { return Uri.tryParse('$base/v1/chat/completions'); } + static Uri? resolveChatCompletionsUri({ + required AiProviderType provider, + required String endpoint, + required String model, + }) { + final base = endpoint.trim().replaceFirst(RegExp(r'/+$'), ''); + if (base.isEmpty) return null; + if (provider == AiProviderType.gemini) { + if (base.contains(':generateContent')) { + return Uri.tryParse(base); + } + return Uri.tryParse('$base/models/$model:generateContent'); + } + if (base.endsWith('/responses')) { + final root = base.substring(0, base.length - '/responses'.length); + return Uri.tryParse('$root/chat/completions'); + } + if (base.endsWith('/chat/completions')) { + return Uri.tryParse(base); + } + if (base.endsWith('/v1')) { + return Uri.tryParse('$base/chat/completions'); + } + return Uri.tryParse('$base/v1/chat/completions'); + } + + /// Transcribes spoken audio file to English text using the configured AI multimodal model. + Future transcribeAudio({ + required String filePath, + required AiProviderType provider, + required String endpoint, + required String model, + }) async { + if (provider == AiProviderType.mock) return null; + final file = File(filePath); + if (!await file.exists()) return null; + final bytes = await file.readAsBytes(); + if (bytes.isEmpty) return null; + + final key = await resolveApiKey(); + final uri = resolveChatCompletionsUri( + provider: provider, + endpoint: endpoint, + model: model, + ); + if (key == null || key.isEmpty || uri == null) return null; + + final ext = filePath.split('.').last.toLowerCase(); + final format = (ext == 'wav' || ext == 'mp3' || ext == 'm4a' || ext == 'aac') ? ext : 'm4a'; + final base64Data = base64Encode(bytes); + + try { + if (provider == AiProviderType.gemini) { + final mimeType = format == 'wav' ? 'audio/wav' : (format == 'mp3' ? 'audio/mp3' : 'audio/mp4'); + final response = await http.post( + uri, + headers: {'x-goog-api-key': key, 'Content-Type': 'application/json'}, + body: jsonEncode({ + 'contents': [ + { + 'parts': [ + { + 'text': 'Transcribe the spoken English speech in this audio file accurately. Return ONLY the transcribed English words. If silence or unintelligible, output nothing.', + }, + { + 'inline_data': { + 'mime_type': mimeType, + 'data': base64Data, + } + } + ] + } + ] + }), + ).timeout(const Duration(seconds: 25)); + if (response.statusCode < 200 || response.statusCode >= 300) return null; + return _extractResponseContent(provider, response.body); + } else { + final response = await http.post( + uri, + headers: { + 'Authorization': 'Bearer $key', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'model': model, + 'messages': [ + { + 'role': 'user', + 'content': [ + { + 'type': 'text', + 'text': 'Transcribe the spoken English speech in this audio file accurately. Output ONLY the raw transcribed English words without quotes, punctuation tags, or commentary. If silence or noise, return nothing.', + }, + { + 'type': 'input_audio', + 'input_audio': { + 'data': base64Data, + 'format': format, + }, + } + ], + } + ], + 'temperature': 0.1, + }), + ).timeout(const Duration(seconds: 25)); + if (response.statusCode < 200 || response.statusCode >= 300) return null; + final raw = _extractResponseContent(provider, response.body); + if (raw == null) return null; + var text = raw.trim(); + if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) { + text = text.substring(1, text.length - 1).trim(); + } + return text; + } + } catch (_) { + return null; + } + } + static Map _buildOpenAiPayload({ required Uri uri, required String model, diff --git a/kouyu_english/lib/core/app_state.dart b/kouyu_english/lib/core/app_state.dart index 3096707..572df2b 100644 --- a/kouyu_english/lib/core/app_state.dart +++ b/kouyu_english/lib/core/app_state.dart @@ -57,6 +57,12 @@ class AppState extends ChangeNotifier { AiProviderType aiProvider = AiProviderType.mock; String aiEndpoint = ''; String aiModel = ''; + + AiConfigFile get aiConfig => AiConfigFile( + provider: aiProvider, + endpoint: aiEndpoint, + model: aiModel, + ); String? cachedAdaptiveLessonRaw; DateTime? cachedAdaptiveLessonAuditedAt; String? cachedAdaptiveLessonAuditor; diff --git a/kouyu_english/lib/core/voice_service.dart b/kouyu_english/lib/core/voice_service.dart index 7e7749b..dee8008 100644 --- a/kouyu_english/lib/core/voice_service.dart +++ b/kouyu_english/lib/core/voice_service.dart @@ -19,6 +19,9 @@ class VoiceService { bool _speechReady = false; bool _ttsInitialized = false; + void Function(String status)? _statusListener; + void Function(String error)? _errorListener; + Future _initTts() async { if (_ttsInitialized) return; try { @@ -65,6 +68,8 @@ class VoiceService { } catch (_) {} } + Future hasRecordPermission() => _recorder.hasPermission(); + Future startRecording() async { if (!await _recorder.hasPermission()) return false; final directory = await getApplicationDocumentsDirectory(); @@ -125,27 +130,70 @@ class VoiceService { await _player.dispose(); } - Future initializeSpeech() async { - _speechReady = await _stt.initialize(); - return _speechReady; + Future initializeSpeech({ + void Function(String status)? onStatus, + void Function(String error)? onError, + }) async { + _statusListener = onStatus; + _errorListener = onError; + try { + _speechReady = await _stt.initialize( + onError: (val) { + _errorListener?.call(val.errorMsg); + }, + onStatus: (val) { + _statusListener?.call(val); + }, + debugLogging: false, + ); + return _speechReady; + } catch (_) { + _speechReady = false; + return false; + } } Future startListening( - void Function(String text, bool finalResult) onResult, - ) async { - if (!_speechReady && !await initializeSpeech()) { + void Function(String text, bool finalResult) onResult, { + void Function(String status)? onStatus, + void Function(String error)? onError, + }) async { + _statusListener = onStatus; + _errorListener = onError; + try { + if (!_speechReady || !_stt.isAvailable) { + final ready = await initializeSpeech(onStatus: onStatus, onError: onError); + if (!ready) return false; + } + + String? targetLocaleId = 'en_US'; + try { + final locales = await _stt.locales(); + if (locales.isNotEmpty) { + final enLocale = locales.firstWhere( + (l) => l.localeId.toLowerCase().startsWith('en'), + orElse: () => locales.first, + ); + targetLocaleId = enLocale.localeId; + } + } catch (_) {} + + await _stt.listen( + onResult: (result) => + onResult(result.recognizedWords, result.finalResult), + listenOptions: SpeechListenOptions( + localeId: targetLocaleId, + listenFor: const Duration(seconds: 30), + pauseFor: const Duration(seconds: 4), + partialResults: true, + cancelOnError: false, + ), + ); + return _stt.isListening; + } catch (e) { + if (onError != null) onError(e.toString()); return false; } - await _stt.listen( - onResult: (result) => - onResult(result.recognizedWords, result.finalResult), - listenOptions: SpeechListenOptions( - localeId: 'en_US', - listenFor: const Duration(seconds: 30), - pauseFor: const Duration(seconds: 4), - ), - ); - return true; } Future stopListening() => _stt.stop(); diff --git a/kouyu_english/lib/features/assessment/assessment_page.dart b/kouyu_english/lib/features/assessment/assessment_page.dart index e2b8503..f18df31 100644 --- a/kouyu_english/lib/features/assessment/assessment_page.dart +++ b/kouyu_english/lib/features/assessment/assessment_page.dart @@ -1,3 +1,4 @@ +import '../../core/ai_service.dart'; import 'package:flutter/material.dart'; import '../../core/app_state.dart'; @@ -32,10 +33,11 @@ class _AssessmentPreparationPageState extends State { Future _checkMicrophone() async { setState(() => checkingMicrophone = true); - final ready = await VoiceService.instance.initializeSpeech(); + final sttReady = await VoiceService.instance.initializeSpeech(); + final recReady = await VoiceService.instance.hasRecordPermission(); if (!mounted) return; setState(() { - microphoneReady = ready; + microphoneReady = sttReady || recReady; checkingMicrophone = false; }); } @@ -135,6 +137,8 @@ class _AssessmentPageState extends State { bool transcriptEdited = false; String lastTranscript = ''; bool listening = false; + bool transcribing = false; + bool aiVoiceRecording = false; bool audioPlayed = false; bool speakingUnavailable = false; AssessmentRecord? completedRecord; @@ -166,31 +170,98 @@ class _AssessmentPageState extends State { } Future _mic() async { + if (aiVoiceRecording) { + final path = await VoiceService.instance.stopRecording(); + if (!mounted) return; + setState(() { + aiVoiceRecording = false; + listening = false; + transcribing = true; + }); + if (path != null) { + final config = widget.state.aiConfig; + final transcribed = await AiService.instance.transcribeAudio( + filePath: path, + provider: config.provider, + endpoint: config.endpoint, + model: config.model, + ); + if (mounted) { + setState(() { + transcribing = false; + if (transcribed != null && transcribed.trim().isNotEmpty) { + controller.text = transcribed.trim(); + usedMic = true; + lastTranscript = transcribed.trim(); + transcriptEdited = false; + speakingUnavailable = false; + } + }); + if (transcribed == null || transcribed.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('未识别到清晰语音,请再试一次。')), + ); + } + } + } else { + if (mounted) setState(() => transcribing = false); + } + return; + } + if (listening) { await VoiceService.instance.stopListening(); if (mounted) setState(() => listening = false); return; } - final ready = await VoiceService.instance.startListening((text, _) { + final ready = await VoiceService.instance.startListening( + (text, _) { + if (mounted) { + setState(() { + controller.text = text; + usedMic = true; + lastTranscript = text; + transcriptEdited = false; + }); + } + }, + onStatus: (status) { + if (mounted && (status == 'notListening' || status == 'done')) { + setState(() => listening = false); + } + }, + onError: (err) { + if (mounted) { + setState(() => listening = false); + } + }, + ); + if (!ready) { + final recordStarted = await VoiceService.instance.startRecording(); if (mounted) { setState(() { - controller.text = text; - usedMic = true; - lastTranscript = text; + aiVoiceRecording = recordStarted; + listening = recordStarted; + speakingUnavailable = !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; speakingUnavailable = !ready; }); } - if (!ready && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('语音识别不可用;口语可稍后补测,不会判为语言错误。')), - ); - } } bool _openCorrect() { @@ -409,8 +480,10 @@ class _AssessmentPageState extends State { ), if (task.skill == AssessmentSkill.speaking) SecondaryButton( - label: listening ? '停止录音' : '使用麦克风回答', - onPressed: _mic, + label: transcribing + ? '正在 AI 识别…' + : (listening ? '停止录音并识别' : '使用麦克风回答'), + onPressed: transcribing ? null : _mic, ), if (task.skill == AssessmentSkill.speaking && speakingUnavailable) SecondaryButton( diff --git a/kouyu_english/lib/features/dialogue/dialogue_flow.dart b/kouyu_english/lib/features/dialogue/dialogue_flow.dart index 8d26c49..9ff616a 100644 --- a/kouyu_english/lib/features/dialogue/dialogue_flow.dart +++ b/kouyu_english/lib/features/dialogue/dialogue_flow.dart @@ -113,6 +113,8 @@ class _DialoguePageState extends State { String? hint; bool listening = false; bool recording = false; + bool transcribing = false; + bool aiVoiceRecording = false; bool playingRecording = false; bool usedVoice = false; bool transcriptEdited = false; @@ -368,27 +370,94 @@ class _DialoguePageState extends State { } Future _toggleListening() async { + if (aiVoiceRecording) { + final path = await VoiceService.instance.stopRecording(); + if (!mounted) return; + setState(() { + aiVoiceRecording = false; + listening = false; + transcribing = true; + }); + if (path != null) { + final config = widget.state.aiConfig; + final transcribed = await AiService.instance.transcribeAudio( + filePath: path, + provider: config.provider, + endpoint: config.endpoint, + model: config.model, + ); + if (mounted) { + setState(() { + transcribing = false; + if (transcribed != null && transcribed.trim().isNotEmpty) { + controller.text = transcribed.trim(); + usedVoice = true; + lastTranscript = transcribed.trim(); + transcriptEdited = false; + } + }); + if (transcribed == null || transcribed.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('未识别到清晰语音,请再试一次或使用键盘输入。')), + ); + } + } + } else { + if (mounted) setState(() => transcribing = false); + } + return; + } + if (listening) { await VoiceService.instance.stopListening(); if (mounted) setState(() => listening = false); return; } - final available = await VoiceService.instance.startListening((text, _) { - if (!mounted) return; - setState(() { - controller.text = text; - usedVoice = true; - lastTranscript = text; - transcriptEdited = false; - }); - }); + + final available = await VoiceService.instance.startListening( + (text, _) { + if (!mounted) return; + setState(() { + controller.text = text; + usedVoice = true; + lastTranscript = text; + transcriptEdited = false; + }); + }, + onStatus: (status) { + if (mounted && (status == 'notListening' || status == 'done')) { + setState(() => listening = false); + } + }, + onError: (err) { + if (mounted) { + setState(() => listening = false); + } + }, + ); + + if (!available) { + 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) return; setState(() => listening = available); - if (!available) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可使用文字输入。'))); - } } Future _toggleRecording() async { @@ -572,11 +641,22 @@ class _DialoguePageState extends State { filled: true, fillColor: AppColors.surface, prefixIcon: IconButton( - tooltip: listening ? '停止录音' : '语音输入', - icon: Icon( - listening ? Icons.stop_circle_outlined : Icons.mic_none, - ), - onPressed: _toggleListening, + tooltip: transcribing + ? '正在 AI 识别…' + : (listening ? '停止录音并识别' : '语音输入'), + icon: transcribing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + listening + ? Icons.stop_circle + : Icons.mic_none, + color: listening ? Colors.redAccent : null, + ), + onPressed: transcribing ? null : _toggleListening, ), suffixIcon: IconButton( icon: const Icon(Icons.send), diff --git a/kouyu_english/lib/features/lesson/lesson_flow.dart b/kouyu_english/lib/features/lesson/lesson_flow.dart index e6537e1..241ef2c 100644 --- a/kouyu_english/lib/features/lesson/lesson_flow.dart +++ b/kouyu_english/lib/features/lesson/lesson_flow.dart @@ -147,6 +147,7 @@ class _LessonFlowState extends State { content = _DialoguePendingStep(onOpenDialogue: widget.onOpenDialogue); case LessonStep.independent: content = _IndependentStep( + state: widget.state, segmentId: segment.id, keepRecording: widget.state.keepRecordings, activity: activity, @@ -438,6 +439,8 @@ 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; @@ -452,14 +455,83 @@ class _SpeakingStepState extends State<_SpeakingStep> { } Future _toggleMic() async { + if (aiVoiceRecording) { + final path = await VoiceService.instance.stopRecording(); + if (!mounted) return; + setState(() { + aiVoiceRecording = false; + listening = false; + transcribing = true; + }); + if (path != null) { + final config = widget.state.aiConfig; + final text = await AiService.instance.transcribeAudio( + filePath: path, + provider: config.provider, + endpoint: config.endpoint, + model: config.model, + ); + if (mounted) { + setState(() { + transcribing = false; + if (text != null && text.trim().isNotEmpty) { + transcript = text.trim(); + } + }); + if (text == null || text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('未识别到清晰声音,请重试或点击“播放示范音”。')), + ); + } + } + } else { + if (mounted) setState(() => transcribing = false); + } + return; + } + if (listening) { await VoiceService.instance.stopListening(); if (mounted) setState(() => listening = false); return; } - final ready = await VoiceService.instance.startListening((text, _) { - if (mounted) setState(() => transcript = text); - }); + + 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); + } + }, + ); + + 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); } @@ -526,8 +598,10 @@ class _SpeakingStepState extends State<_SpeakingStep> { ), ), SecondaryButton( - label: listening ? '停止录音' : '使用麦克风跟读', - onPressed: _toggleMic, + label: transcribing + ? '正在 AI 识别发音…' + : (listening ? '停止录音并识别' : '使用麦克风跟读'), + onPressed: transcribing ? null : _toggleMic, ), SecondaryButton( label: recording ? '停止本机录音' : '录音后回听', @@ -871,6 +945,7 @@ class _DialoguePendingStep extends StatelessWidget { class _IndependentStep extends StatefulWidget { const _IndependentStep({ + required this.state, required this.segmentId, required this.keepRecording, required this.activity, @@ -883,6 +958,7 @@ class _IndependentStep extends StatefulWidget { required this.onContinue, required this.onLater, }); + final AppState state; final LessonActivity activity; final String segmentId; final bool keepRecording; @@ -902,6 +978,8 @@ class _IndependentStep extends StatefulWidget { class _IndependentStepState extends State<_IndependentStep> { bool listening = false; bool recording = false; + bool transcribing = false; + bool aiVoiceRecording = false; bool playingRecording = false; bool usedVoice = false; bool transcriptEdited = false; @@ -968,27 +1046,95 @@ class _IndependentStepState extends State<_IndependentStep> { } Future _toggleMic() async { + if (aiVoiceRecording) { + final path = await VoiceService.instance.stopRecording(); + if (!mounted) return; + setState(() { + aiVoiceRecording = false; + listening = false; + transcribing = true; + }); + if (path != null) { + final config = widget.state.aiConfig; + final text = await AiService.instance.transcribeAudio( + filePath: path, + provider: config.provider, + endpoint: config.endpoint, + model: config.model, + ); + if (mounted) { + setState(() { + transcribing = false; + if (text != null && text.trim().isNotEmpty) { + widget.controller.text = text.trim(); + usedVoice = true; + lastTranscript = text.trim(); + transcriptEdited = false; + } + }); + widget.onChanged(); + if (text == null || text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('未识别到声音,请重试或直接打字输入。')), + ); + } + } + } else { + if (mounted) setState(() => transcribing = false); + } + return; + } + if (listening) { await VoiceService.instance.stopListening(); if (mounted) setState(() => listening = false); return; } - final ready = await VoiceService.instance.startListening((text, _) { - if (!mounted) return; - setState(() { - widget.controller.text = text; - usedVoice = true; - lastTranscript = text; - transcriptEdited = false; - }); - widget.onChanged(); - }); - if (mounted) setState(() => listening = ready); - if (!ready && mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成写作练习。'))); + + final ready = await VoiceService.instance.startListening( + (text, _) { + if (!mounted) return; + setState(() { + widget.controller.text = text; + usedVoice = true; + lastTranscript = text; + transcriptEdited = false; + }); + widget.onChanged(); + }, + onStatus: (status) { + if (mounted && (status == 'notListening' || status == 'done')) { + setState(() => listening = false); + } + }, + onError: (err) { + if (mounted) { + setState(() => listening = 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); } @override diff --git a/kouyu_english/lib/features/review/review_page.dart b/kouyu_english/lib/features/review/review_page.dart index c3d720e..a6ac056 100644 --- a/kouyu_english/lib/features/review/review_page.dart +++ b/kouyu_english/lib/features/review/review_page.dart @@ -309,6 +309,8 @@ class _AdaptiveLessonPageState extends State { bool showReference = false; String? answerFeedback; bool listening = false; + bool transcribing = false; + bool aiVoiceRecording = false; bool usedVoice = false; bool transcriptEdited = false; bool transcriptConfirmed = false; @@ -413,30 +415,100 @@ class _AdaptiveLessonPageState extends State { } Future _toggleListening() async { + if (aiVoiceRecording) { + final path = await VoiceService.instance.stopRecording(); + if (!mounted) return; + setState(() { + aiVoiceRecording = false; + listening = false; + transcribing = true; + }); + if (path != null) { + final config = widget.state.aiConfig; + final transcribed = await AiService.instance.transcribeAudio( + filePath: path, + provider: config.provider, + endpoint: config.endpoint, + model: config.model, + ); + if (mounted) { + setState(() { + transcribing = false; + if (transcribed != null && transcribed.trim().isNotEmpty) { + controller.text = transcribed.trim(); + usedVoice = true; + transcriptEdited = false; + transcriptConfirmed = false; + lastTranscript = transcribed.trim(); + } + }); + final lesson = widget.state.cachedAdaptiveLesson; + if (lesson != null) _saveDraft(lesson); + if (transcribed == null || transcribed.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('未识别到清晰语音,请再试一次或输入文本。')), + ); + } + } + } else { + if (mounted) setState(() => transcribing = false); + } + return; + } + if (listening) { await VoiceService.instance.stopListening(); if (mounted) setState(() => listening = false); return; } - final ready = await VoiceService.instance.startListening((text, _) { - if (!mounted) return; - setState(() { - controller.text = text; - usedVoice = true; - transcriptEdited = false; - transcriptConfirmed = false; - lastTranscript = text; - }); - final lesson = widget.state.cachedAdaptiveLesson; - if (lesson != null) _saveDraft(lesson); - }); + + final ready = await VoiceService.instance.startListening( + (text, _) { + if (!mounted) return; + setState(() { + controller.text = text; + usedVoice = true; + transcriptEdited = false; + transcriptConfirmed = false; + lastTranscript = text; + }); + final lesson = widget.state.cachedAdaptiveLesson; + if (lesson != null) _saveDraft(lesson); + }, + onStatus: (status) { + if (mounted && (status == 'notListening' || status == 'done')) { + setState(() => listening = false); + } + }, + onError: (err) { + if (mounted) { + setState(() => listening = 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) return; setState(() => listening = ready); - if (!ready) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成补练。'))); - } } Future _toggleRecording() async { @@ -590,11 +662,22 @@ class _AdaptiveLessonPageState extends State { filled: true, fillColor: AppColors.surface, prefixIcon: IconButton( - tooltip: listening ? '停止语音输入' : '语音输入', + tooltip: transcribing + ? '正在 AI 识别…' + : (listening ? '停止录音并识别' : '语音输入'), onPressed: _toggleListening, - icon: Icon( - listening ? Icons.stop_circle_outlined : Icons.mic_none, - ), + icon: transcribing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + listening + ? Icons.stop_circle_outlined + : Icons.mic_none, + color: listening ? AppColors.green : null, + ), ), border: OutlineInputBorder(), ), diff --git a/kouyu_english/test/ai_audio_transcribe_test.dart b/kouyu_english/test/ai_audio_transcribe_test.dart new file mode 100644 index 0000000..40b39e0 --- /dev/null +++ b/kouyu_english/test/ai_audio_transcribe_test.dart @@ -0,0 +1,35 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kouyu_english/core/ai_service.dart'; +import 'package:kouyu_english/core/models.dart'; + +void main() { + test('transcribeAudio with valid test audio file returns transcription', () async { + final ai = AiService.instance; + ai.setFallbackApiKey('***REMOVED***'); + + // Create a temporary wav file if not exists + final tempFile = File('/tmp/test_unit.wav'); + if (!await tempFile.exists()) { + // 44-byte standard wav header with 1 second silence + final wavHeader = [ + 0x52, 0x49, 0x46, 0x46, 0x24, 0x7d, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45, + 0x66, 0x6d, 0x74, 0x20, 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x80, 0x3e, 0x00, 0x00, 0x00, 0x7d, 0x00, 0x00, 0x02, 0x00, 0x10, 0x00, + 0x64, 0x61, 0x74, 0x61, 0x00, 0x7d, 0x00, 0x00, + ]; + final wavData = List.filled(32000, 0); + await tempFile.writeAsBytes(wavHeader + wavData); + } + + final result = await ai.transcribeAudio( + filePath: tempFile.path, + provider: AiProviderType.compatible, + endpoint: 'https://codex.slcydia.fun/v1/responses', + model: 'gemini-3.7-flash-high', + ); + + print('Transcribe result: $result'); + expect(result, isNotNull); + }); +}