feat: add AI voice transcription fallback for domestic Android ROMs

This commit is contained in:
shen
2026-09-15 22:02:26 +08:00
parent c8964b9b60
commit 503f1d8d1d
8 changed files with 683 additions and 90 deletions
+122
View File
@@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -84,6 +85,127 @@ class AiService {
return Uri.tryParse('$base/v1/chat/completions'); 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<String?> 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<String, dynamic> _buildOpenAiPayload({ static Map<String, dynamic> _buildOpenAiPayload({
required Uri uri, required Uri uri,
required String model, required String model,
+6
View File
@@ -57,6 +57,12 @@ class AppState extends ChangeNotifier {
AiProviderType aiProvider = AiProviderType.mock; AiProviderType aiProvider = AiProviderType.mock;
String aiEndpoint = ''; String aiEndpoint = '';
String aiModel = ''; String aiModel = '';
AiConfigFile get aiConfig => AiConfigFile(
provider: aiProvider,
endpoint: aiEndpoint,
model: aiModel,
);
String? cachedAdaptiveLessonRaw; String? cachedAdaptiveLessonRaw;
DateTime? cachedAdaptiveLessonAuditedAt; DateTime? cachedAdaptiveLessonAuditedAt;
String? cachedAdaptiveLessonAuditor; String? cachedAdaptiveLessonAuditor;
+64 -16
View File
@@ -19,6 +19,9 @@ class VoiceService {
bool _speechReady = false; bool _speechReady = false;
bool _ttsInitialized = false; bool _ttsInitialized = false;
void Function(String status)? _statusListener;
void Function(String error)? _errorListener;
Future<void> _initTts() async { Future<void> _initTts() async {
if (_ttsInitialized) return; if (_ttsInitialized) return;
try { try {
@@ -65,6 +68,8 @@ class VoiceService {
} catch (_) {} } catch (_) {}
} }
Future<bool> hasRecordPermission() => _recorder.hasPermission();
Future<bool> startRecording() async { Future<bool> startRecording() async {
if (!await _recorder.hasPermission()) return false; if (!await _recorder.hasPermission()) return false;
final directory = await getApplicationDocumentsDirectory(); final directory = await getApplicationDocumentsDirectory();
@@ -125,27 +130,70 @@ class VoiceService {
await _player.dispose(); await _player.dispose();
} }
Future<bool> initializeSpeech() async { Future<bool> initializeSpeech({
_speechReady = await _stt.initialize(); void Function(String status)? onStatus,
return _speechReady; 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<bool> startListening( Future<bool> startListening(
void Function(String text, bool finalResult) onResult, void Function(String text, bool finalResult) onResult, {
) async { void Function(String status)? onStatus,
if (!_speechReady && !await initializeSpeech()) { 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; 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<void> stopListening() => _stt.stop(); Future<void> stopListening() => _stt.stop();
@@ -1,3 +1,4 @@
import '../../core/ai_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/app_state.dart'; import '../../core/app_state.dart';
@@ -32,10 +33,11 @@ class _AssessmentPreparationPageState extends State<AssessmentPreparationPage> {
Future<void> _checkMicrophone() async { Future<void> _checkMicrophone() async {
setState(() => checkingMicrophone = true); setState(() => checkingMicrophone = true);
final ready = await VoiceService.instance.initializeSpeech(); final sttReady = await VoiceService.instance.initializeSpeech();
final recReady = await VoiceService.instance.hasRecordPermission();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
microphoneReady = ready; microphoneReady = sttReady || recReady;
checkingMicrophone = false; checkingMicrophone = false;
}); });
} }
@@ -135,6 +137,8 @@ class _AssessmentPageState extends State<AssessmentPage> {
bool transcriptEdited = false; bool transcriptEdited = false;
String lastTranscript = ''; String lastTranscript = '';
bool listening = false; bool listening = false;
bool transcribing = false;
bool aiVoiceRecording = false;
bool audioPlayed = false; bool audioPlayed = false;
bool speakingUnavailable = false; bool speakingUnavailable = false;
AssessmentRecord? completedRecord; AssessmentRecord? completedRecord;
@@ -166,31 +170,98 @@ class _AssessmentPageState extends State<AssessmentPage> {
} }
Future<void> _mic() async { Future<void> _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) { if (listening) {
await VoiceService.instance.stopListening(); await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false); if (mounted) setState(() => listening = false);
return; 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) { if (mounted) {
setState(() { setState(() {
controller.text = text; aiVoiceRecording = recordStarted;
usedMic = true; listening = recordStarted;
lastTranscript = text; 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) { if (mounted) {
setState(() { setState(() {
listening = ready; listening = ready;
speakingUnavailable = !ready; speakingUnavailable = !ready;
}); });
} }
if (!ready && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('语音识别不可用;口语可稍后补测,不会判为语言错误。')),
);
}
} }
bool _openCorrect() { bool _openCorrect() {
@@ -409,8 +480,10 @@ class _AssessmentPageState extends State<AssessmentPage> {
), ),
if (task.skill == AssessmentSkill.speaking) if (task.skill == AssessmentSkill.speaking)
SecondaryButton( SecondaryButton(
label: listening ? '停止录音' : '使用麦克风回答', label: transcribing
onPressed: _mic, ? '正在 AI 识别…'
: (listening ? '停止录音并识别' : '使用麦克风回答'),
onPressed: transcribing ? null : _mic,
), ),
if (task.skill == AssessmentSkill.speaking && speakingUnavailable) if (task.skill == AssessmentSkill.speaking && speakingUnavailable)
SecondaryButton( SecondaryButton(
@@ -113,6 +113,8 @@ class _DialoguePageState extends State<DialoguePage> {
String? hint; String? hint;
bool listening = false; bool listening = false;
bool recording = false; bool recording = false;
bool transcribing = false;
bool aiVoiceRecording = false;
bool playingRecording = false; bool playingRecording = false;
bool usedVoice = false; bool usedVoice = false;
bool transcriptEdited = false; bool transcriptEdited = false;
@@ -368,27 +370,94 @@ class _DialoguePageState extends State<DialoguePage> {
} }
Future<void> _toggleListening() async { Future<void> _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) { if (listening) {
await VoiceService.instance.stopListening(); await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false); if (mounted) setState(() => listening = false);
return; return;
} }
final available = await VoiceService.instance.startListening((text, _) {
if (!mounted) return; final available = await VoiceService.instance.startListening(
setState(() { (text, _) {
controller.text = text; if (!mounted) return;
usedVoice = true; setState(() {
lastTranscript = text; controller.text = text;
transcriptEdited = false; 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; if (!mounted) return;
setState(() => listening = available); setState(() => listening = available);
if (!available) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可使用文字输入。')));
}
} }
Future<void> _toggleRecording() async { Future<void> _toggleRecording() async {
@@ -572,11 +641,22 @@ class _DialoguePageState extends State<DialoguePage> {
filled: true, filled: true,
fillColor: AppColors.surface, fillColor: AppColors.surface,
prefixIcon: IconButton( prefixIcon: IconButton(
tooltip: listening ? '停止录音' : '语音输入', tooltip: transcribing
icon: Icon( ? '正在 AI 识别…'
listening ? Icons.stop_circle_outlined : Icons.mic_none, : (listening ? '停止录音并识别' : '语音输入'),
), icon: transcribing
onPressed: _toggleListening, ? 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( suffixIcon: IconButton(
icon: const Icon(Icons.send), icon: const Icon(Icons.send),
@@ -147,6 +147,7 @@ class _LessonFlowState extends State<LessonFlow> {
content = _DialoguePendingStep(onOpenDialogue: widget.onOpenDialogue); content = _DialoguePendingStep(onOpenDialogue: widget.onOpenDialogue);
case LessonStep.independent: case LessonStep.independent:
content = _IndependentStep( content = _IndependentStep(
state: widget.state,
segmentId: segment.id, segmentId: segment.id,
keepRecording: widget.state.keepRecordings, keepRecording: widget.state.keepRecordings,
activity: activity, activity: activity,
@@ -438,6 +439,8 @@ class _SpeakingStep extends StatefulWidget {
class _SpeakingStepState extends State<_SpeakingStep> { class _SpeakingStepState extends State<_SpeakingStep> {
bool listening = false; bool listening = false;
bool recording = false; bool recording = false;
bool transcribing = false;
bool aiVoiceRecording = false;
bool playingRecording = false; bool playingRecording = false;
String transcript = ''; String transcript = '';
String? recordingPath; String? recordingPath;
@@ -452,14 +455,83 @@ class _SpeakingStepState extends State<_SpeakingStep> {
} }
Future<void> _toggleMic() async { Future<void> _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) { if (listening) {
await VoiceService.instance.stopListening(); await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false); if (mounted) setState(() => listening = false);
return; 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); if (mounted) setState(() => listening = ready);
} }
@@ -526,8 +598,10 @@ class _SpeakingStepState extends State<_SpeakingStep> {
), ),
), ),
SecondaryButton( SecondaryButton(
label: listening ? '停止录音' : '使用麦克风跟读', label: transcribing
onPressed: _toggleMic, ? '正在 AI 识别发音…'
: (listening ? '停止录音并识别' : '使用麦克风跟读'),
onPressed: transcribing ? null : _toggleMic,
), ),
SecondaryButton( SecondaryButton(
label: recording ? '停止本机录音' : '录音后回听', label: recording ? '停止本机录音' : '录音后回听',
@@ -871,6 +945,7 @@ class _DialoguePendingStep extends StatelessWidget {
class _IndependentStep extends StatefulWidget { class _IndependentStep extends StatefulWidget {
const _IndependentStep({ const _IndependentStep({
required this.state,
required this.segmentId, required this.segmentId,
required this.keepRecording, required this.keepRecording,
required this.activity, required this.activity,
@@ -883,6 +958,7 @@ class _IndependentStep extends StatefulWidget {
required this.onContinue, required this.onContinue,
required this.onLater, required this.onLater,
}); });
final AppState state;
final LessonActivity activity; final LessonActivity activity;
final String segmentId; final String segmentId;
final bool keepRecording; final bool keepRecording;
@@ -902,6 +978,8 @@ class _IndependentStep extends StatefulWidget {
class _IndependentStepState extends State<_IndependentStep> { class _IndependentStepState extends State<_IndependentStep> {
bool listening = false; bool listening = false;
bool recording = false; bool recording = false;
bool transcribing = false;
bool aiVoiceRecording = false;
bool playingRecording = false; bool playingRecording = false;
bool usedVoice = false; bool usedVoice = false;
bool transcriptEdited = false; bool transcriptEdited = false;
@@ -968,27 +1046,95 @@ class _IndependentStepState extends State<_IndependentStep> {
} }
Future<void> _toggleMic() async { Future<void> _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) { if (listening) {
await VoiceService.instance.stopListening(); await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false); if (mounted) setState(() => listening = false);
return; return;
} }
final ready = await VoiceService.instance.startListening((text, _) {
if (!mounted) return; final ready = await VoiceService.instance.startListening(
setState(() { (text, _) {
widget.controller.text = text; if (!mounted) return;
usedVoice = true; setState(() {
lastTranscript = text; widget.controller.text = text;
transcriptEdited = false; usedVoice = true;
}); lastTranscript = text;
widget.onChanged(); transcriptEdited = false;
}); });
if (mounted) setState(() => listening = ready); widget.onChanged();
if (!ready && mounted) { },
ScaffoldMessenger.of( onStatus: (status) {
context, if (mounted && (status == 'notListening' || status == 'done')) {
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成写作练习。'))); 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 @override
@@ -309,6 +309,8 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
bool showReference = false; bool showReference = false;
String? answerFeedback; String? answerFeedback;
bool listening = false; bool listening = false;
bool transcribing = false;
bool aiVoiceRecording = false;
bool usedVoice = false; bool usedVoice = false;
bool transcriptEdited = false; bool transcriptEdited = false;
bool transcriptConfirmed = false; bool transcriptConfirmed = false;
@@ -413,30 +415,100 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
} }
Future<void> _toggleListening() async { Future<void> _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) { if (listening) {
await VoiceService.instance.stopListening(); await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false); if (mounted) setState(() => listening = false);
return; return;
} }
final ready = await VoiceService.instance.startListening((text, _) {
if (!mounted) return; final ready = await VoiceService.instance.startListening(
setState(() { (text, _) {
controller.text = text; if (!mounted) return;
usedVoice = true; setState(() {
transcriptEdited = false; controller.text = text;
transcriptConfirmed = false; usedVoice = true;
lastTranscript = text; transcriptEdited = false;
}); transcriptConfirmed = false;
final lesson = widget.state.cachedAdaptiveLesson; lastTranscript = text;
if (lesson != null) _saveDraft(lesson); });
}); 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; if (!mounted) return;
setState(() => listening = ready); setState(() => listening = ready);
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成补练。')));
}
} }
Future<void> _toggleRecording() async { Future<void> _toggleRecording() async {
@@ -590,11 +662,22 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
filled: true, filled: true,
fillColor: AppColors.surface, fillColor: AppColors.surface,
prefixIcon: IconButton( prefixIcon: IconButton(
tooltip: listening ? '停止语音输入' : '语音输入', tooltip: transcribing
? '正在 AI 识别…'
: (listening ? '停止录音并识别' : '语音输入'),
onPressed: _toggleListening, onPressed: _toggleListening,
icon: Icon( icon: transcribing
listening ? Icons.stop_circle_outlined : Icons.mic_none, ? 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(), border: OutlineInputBorder(),
), ),
@@ -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 = <int>[
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<int>.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);
});
}