feat: add AI voice transcription fallback for domestic Android ROMs
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user