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 670260f8e2
commit 35b493e652
8 changed files with 683 additions and 90 deletions
@@ -147,6 +147,7 @@ class _LessonFlowState extends State<LessonFlow> {
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<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) {
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<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) {
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