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 '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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,6 +19,9 @@ class VoiceService {
|
||||
bool _speechReady = false;
|
||||
bool _ttsInitialized = false;
|
||||
|
||||
void Function(String status)? _statusListener;
|
||||
void Function(String error)? _errorListener;
|
||||
|
||||
Future<void> _initTts() async {
|
||||
if (_ttsInitialized) return;
|
||||
try {
|
||||
@@ -65,6 +68,8 @@ class VoiceService {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<bool> hasRecordPermission() => _recorder.hasPermission();
|
||||
|
||||
Future<bool> startRecording() async {
|
||||
if (!await _recorder.hasPermission()) return false;
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
@@ -125,27 +130,70 @@ class VoiceService {
|
||||
await _player.dispose();
|
||||
}
|
||||
|
||||
Future<bool> initializeSpeech() async {
|
||||
_speechReady = await _stt.initialize();
|
||||
return _speechReady;
|
||||
Future<bool> 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<bool> 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<void> stopListening() => _stt.stop();
|
||||
|
||||
Reference in New Issue
Block a user