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
+122
View File
@@ -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<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({
required Uri uri,
required String model,