2025-08-10 01:20:45 +05:30
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
import 'package:record/record.dart';
|
2025-08-22 13:54:58 +05:30
|
|
|
import 'package:flutter/widgets.dart';
|
2025-08-10 01:20:45 +05:30
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:io' show Platform;
|
2025-08-25 20:56:33 +05:30
|
|
|
// Removed path imports as server transcription fallback was removed
|
2025-08-25 20:04:04 +05:30
|
|
|
import 'package:stts/stts.dart';
|
|
|
|
|
|
|
|
|
|
// Lightweight replacement for previous stt.LocaleName used across the UI
|
|
|
|
|
class LocaleName {
|
|
|
|
|
final String localeId;
|
|
|
|
|
final String name;
|
|
|
|
|
const LocaleName(this.localeId, this.name);
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
class VoiceInputService {
|
|
|
|
|
final AudioRecorder _recorder = AudioRecorder();
|
2025-08-25 20:04:04 +05:30
|
|
|
final Stt _speech = Stt();
|
2025-08-10 01:20:45 +05:30
|
|
|
bool _isInitialized = false;
|
|
|
|
|
bool _isListening = false;
|
2025-08-22 13:54:58 +05:30
|
|
|
bool _localSttAvailable = false;
|
|
|
|
|
String? _selectedLocaleId;
|
2025-08-25 20:04:04 +05:30
|
|
|
List<LocaleName> _locales = const [];
|
2025-08-10 01:20:45 +05:30
|
|
|
StreamController<String>? _textStreamController;
|
|
|
|
|
String _currentText = '';
|
|
|
|
|
// Public stream for UI waveform visualization (emits partial text length as proxy)
|
|
|
|
|
StreamController<int>? _intensityController;
|
|
|
|
|
Stream<int> get intensityStream =>
|
|
|
|
|
_intensityController?.stream ?? const Stream<int>.empty();
|
2025-08-25 21:53:41 +05:30
|
|
|
int _lastIntensity = 0;
|
|
|
|
|
Timer? _intensityDecayTimer;
|
2025-08-25 20:04:04 +05:30
|
|
|
|
|
|
|
|
/// Public stream of partial/final transcript strings and special audio tokens.
|
|
|
|
|
Stream<String> get textStream =>
|
|
|
|
|
_textStreamController?.stream ?? const Stream<String>.empty();
|
2025-08-10 01:20:45 +05:30
|
|
|
Timer? _autoStopTimer;
|
|
|
|
|
StreamSubscription<Amplitude>? _ampSub;
|
2025-08-25 20:04:04 +05:30
|
|
|
StreamSubscription<SttRecognition>? _sttResultSub;
|
|
|
|
|
StreamSubscription<SttState>? _sttStateSub;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
bool get isSupportedPlatform => Platform.isAndroid || Platform.isIOS;
|
|
|
|
|
|
|
|
|
|
Future<bool> initialize() async {
|
|
|
|
|
if (_isInitialized) return true;
|
|
|
|
|
if (!isSupportedPlatform) return false;
|
2025-08-22 13:54:58 +05:30
|
|
|
// Prepare local speech recognizer
|
|
|
|
|
try {
|
2025-08-25 20:04:04 +05:30
|
|
|
// Check permission and supported status
|
|
|
|
|
_localSttAvailable = await _speech.isSupported();
|
2025-08-22 13:54:58 +05:30
|
|
|
if (_localSttAvailable) {
|
|
|
|
|
try {
|
2025-08-25 20:04:04 +05:30
|
|
|
final langs = await _speech.getLanguages();
|
|
|
|
|
_locales = langs.map((l) => LocaleName(l, l)).toList();
|
2025-08-22 13:54:58 +05:30
|
|
|
final deviceTag = WidgetsBinding.instance.platformDispatcher.locale
|
|
|
|
|
.toLanguageTag();
|
|
|
|
|
final match = _locales.firstWhere(
|
|
|
|
|
(l) => l.localeId.toLowerCase() == deviceTag.toLowerCase(),
|
|
|
|
|
orElse: () {
|
2025-08-25 10:35:48 +05:30
|
|
|
final primary = deviceTag
|
|
|
|
|
.split(RegExp('[-_]'))
|
|
|
|
|
.first
|
|
|
|
|
.toLowerCase();
|
2025-08-22 13:54:58 +05:30
|
|
|
return _locales.firstWhere(
|
|
|
|
|
(l) => l.localeId.toLowerCase().startsWith('$primary-'),
|
|
|
|
|
orElse: () => _locales.isNotEmpty
|
|
|
|
|
? _locales.first
|
2025-08-25 20:04:04 +05:30
|
|
|
: LocaleName('en_US', 'en_US'),
|
2025-08-22 13:54:58 +05:30
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
_selectedLocaleId = match.localeId;
|
2025-08-25 10:35:48 +05:30
|
|
|
} catch (e) {
|
2025-08-25 20:04:04 +05:30
|
|
|
// ignore locale load errors
|
2025-08-22 13:54:58 +05:30
|
|
|
_selectedLocaleId = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
_localSttAvailable = false;
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
_isInitialized = true;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<bool> checkPermissions() async {
|
|
|
|
|
try {
|
2025-08-25 20:04:04 +05:30
|
|
|
// Prefer stts permission check which will request microphone permission
|
|
|
|
|
final mic = await _speech.hasPermission();
|
|
|
|
|
if (mic) return true;
|
2025-08-10 01:20:45 +05:30
|
|
|
return await _recorder.hasPermission();
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool get isListening => _isListening;
|
2025-08-22 13:54:58 +05:30
|
|
|
bool get isAvailable => _isInitialized; // service usable (local or fallback)
|
|
|
|
|
bool get hasLocalStt => _localSttAvailable;
|
2025-08-25 10:35:48 +05:30
|
|
|
|
|
|
|
|
// Add a method to check if on-device STT is properly supported
|
|
|
|
|
Future<bool> checkOnDeviceSupport() async {
|
|
|
|
|
if (!isSupportedPlatform || !_isInitialized) return false;
|
|
|
|
|
try {
|
2025-08-25 20:04:04 +05:30
|
|
|
final supported = await _speech.isSupported();
|
|
|
|
|
return supported;
|
2025-08-25 10:35:48 +05:30
|
|
|
} catch (e) {
|
2025-08-25 20:04:04 +05:30
|
|
|
// ignore errors checking on-device support
|
2025-08-25 10:35:48 +05:30
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Test method to verify on-device STT functionality
|
|
|
|
|
Future<String> testOnDeviceStt() async {
|
|
|
|
|
try {
|
2025-08-25 20:04:04 +05:30
|
|
|
// starting on-device STT test
|
2025-08-25 10:35:48 +05:30
|
|
|
|
|
|
|
|
// First ensure we're initialized
|
|
|
|
|
await initialize();
|
|
|
|
|
|
2025-08-25 20:04:04 +05:30
|
|
|
if (!_localSttAvailable) {
|
|
|
|
|
return 'Local STT not available. Available: $_localSttAvailable';
|
2025-08-25 10:35:48 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check microphone permission
|
|
|
|
|
final hasMic = await checkPermissions();
|
|
|
|
|
if (!hasMic) {
|
|
|
|
|
return 'Microphone permission not granted';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Test if speech recognition is available
|
2025-08-25 20:04:04 +05:30
|
|
|
final supported = await _speech.isSupported();
|
2025-09-02 20:43:57 +05:30
|
|
|
if (!supported) {
|
2025-08-25 10:35:48 +05:30
|
|
|
return 'Speech recognition service is not available on this device';
|
2025-09-02 20:43:57 +05:30
|
|
|
}
|
2025-08-25 10:35:48 +05:30
|
|
|
|
2025-08-25 20:04:04 +05:30
|
|
|
// Set language if available, then start and stop quickly
|
|
|
|
|
if (_selectedLocaleId != null) {
|
|
|
|
|
try {
|
|
|
|
|
await _speech.setLanguage(_selectedLocaleId!);
|
|
|
|
|
} catch (_) {}
|
2025-08-25 10:35:48 +05:30
|
|
|
}
|
2025-08-25 20:04:04 +05:30
|
|
|
await _speech.start(SttRecognitionOptions(punctuation: true));
|
2025-08-25 10:35:48 +05:30
|
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
2025-08-25 20:04:04 +05:30
|
|
|
await _speech.stop();
|
2025-08-25 10:35:48 +05:30
|
|
|
|
|
|
|
|
return 'On-device STT test completed successfully. Local STT available: $_localSttAvailable, Selected locale: $_selectedLocaleId';
|
|
|
|
|
} catch (e) {
|
2025-08-25 20:04:04 +05:30
|
|
|
// on-device STT test failed
|
2025-08-25 10:35:48 +05:30
|
|
|
return 'On-device STT test failed: $e';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-22 13:54:58 +05:30
|
|
|
String? get selectedLocaleId => _selectedLocaleId;
|
2025-08-25 20:04:04 +05:30
|
|
|
List<LocaleName> get locales => _locales;
|
2025-08-22 13:54:58 +05:30
|
|
|
|
|
|
|
|
void setLocale(String? localeId) {
|
|
|
|
|
_selectedLocaleId = localeId;
|
|
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
Stream<String> startListening() {
|
|
|
|
|
if (!_isInitialized) {
|
|
|
|
|
throw Exception('Voice input not initialized');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_isListening) {
|
|
|
|
|
stopListening();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_textStreamController = StreamController<String>.broadcast();
|
|
|
|
|
_currentText = '';
|
|
|
|
|
_isListening = true;
|
|
|
|
|
_intensityController = StreamController<int>.broadcast();
|
2025-08-25 21:53:41 +05:30
|
|
|
_lastIntensity = 0;
|
|
|
|
|
|
|
|
|
|
// Begin a gentle decay timer so the UI level bars fall when silent
|
|
|
|
|
_intensityDecayTimer?.cancel();
|
|
|
|
|
_intensityDecayTimer = Timer.periodic(const Duration(milliseconds: 120), (
|
|
|
|
|
t,
|
|
|
|
|
) {
|
|
|
|
|
if (!_isListening) return;
|
|
|
|
|
if (_lastIntensity <= 0) return;
|
|
|
|
|
_lastIntensity = (_lastIntensity - 1).clamp(0, 10);
|
|
|
|
|
try {
|
|
|
|
|
_intensityController?.add(_lastIntensity);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
});
|
2025-08-10 01:20:45 +05:30
|
|
|
|
2025-08-25 10:35:48 +05:30
|
|
|
// Check if speech recognition is available before trying to use it
|
2025-08-25 20:04:04 +05:30
|
|
|
if (_localSttAvailable) {
|
2025-08-25 10:35:48 +05:30
|
|
|
// Schedule a check for speech recognition availability
|
|
|
|
|
Future.microtask(() async {
|
|
|
|
|
try {
|
2025-08-25 20:04:04 +05:30
|
|
|
final isStillAvailable = await _speech.isSupported();
|
2025-08-25 10:35:48 +05:30
|
|
|
if (!isStillAvailable && _isListening) {
|
2025-08-25 20:56:33 +05:30
|
|
|
// Speech recognition no longer available; stop listening
|
2025-08-25 10:35:48 +05:30
|
|
|
_localSttAvailable = false;
|
2025-08-25 20:56:33 +05:30
|
|
|
_stopListening();
|
2025-08-25 10:35:48 +05:30
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2025-08-25 20:04:04 +05:30
|
|
|
// ignore availability check errors
|
2025-08-25 10:35:48 +05:30
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-22 13:54:58 +05:30
|
|
|
// Local on-device STT path
|
|
|
|
|
_autoStopTimer?.cancel();
|
|
|
|
|
_autoStopTimer = Timer(const Duration(seconds: 60), () {
|
|
|
|
|
if (_isListening) {
|
|
|
|
|
_stopListening();
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-08-25 20:04:04 +05:30
|
|
|
|
|
|
|
|
// Listen for results and state changes; keep subscriptions so we can cancel later
|
|
|
|
|
_sttResultSub = _speech.onResultChanged.listen((SttRecognition result) {
|
|
|
|
|
if (!_isListening) return;
|
2025-08-25 21:53:41 +05:30
|
|
|
final prevLen = _currentText.length;
|
2025-08-25 20:04:04 +05:30
|
|
|
_currentText = result.text;
|
|
|
|
|
_textStreamController?.add(_currentText);
|
2025-08-25 21:53:41 +05:30
|
|
|
// Map number of new characters to a rough 0..10 intensity
|
|
|
|
|
final delta = (_currentText.length - prevLen).clamp(0, 50);
|
|
|
|
|
final mapped = (delta / 5.0).ceil(); // 0 chars -> 0, 1-5 -> 1, ...
|
|
|
|
|
_lastIntensity = mapped.clamp(0, 10);
|
|
|
|
|
try {
|
|
|
|
|
_intensityController?.add(_lastIntensity);
|
|
|
|
|
} catch (_) {}
|
2025-08-25 20:04:04 +05:30
|
|
|
if (result.isFinal) {
|
|
|
|
|
_stopListening();
|
|
|
|
|
}
|
|
|
|
|
}, onError: (_) {});
|
|
|
|
|
|
|
|
|
|
_sttStateSub = _speech.onStateChanged.listen((_) {}, onError: (_) {});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (_selectedLocaleId != null) {
|
|
|
|
|
_speech.setLanguage(_selectedLocaleId!).catchError((_) {});
|
|
|
|
|
}
|
|
|
|
|
// Start recognition (no await blocking the sync flow)
|
|
|
|
|
_speech.start(SttRecognitionOptions(punctuation: true)).catchError((_) {
|
2025-08-25 20:56:33 +05:30
|
|
|
// On-device STT failed; stop listening entirely as server transcription is removed
|
2025-08-25 20:04:04 +05:30
|
|
|
_localSttAvailable = false;
|
2025-08-25 20:56:33 +05:30
|
|
|
_stopListening();
|
2025-08-25 20:04:04 +05:30
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_localSttAvailable = false;
|
2025-08-25 20:56:33 +05:30
|
|
|
_stopListening();
|
2025-08-25 20:04:04 +05:30
|
|
|
}
|
2025-08-22 13:54:58 +05:30
|
|
|
} else {
|
2025-08-25 20:56:33 +05:30
|
|
|
// No local STT available; stop immediately since server transcription is removed
|
|
|
|
|
_stopListening();
|
2025-08-22 13:54:58 +05:30
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
return _textStreamController!.stream;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-28 19:48:35 +05:30
|
|
|
/// Centralized entry point to begin voice recognition.
|
|
|
|
|
/// Ensures initialization and microphone permission before starting.
|
|
|
|
|
Future<Stream<String>> beginListening() async {
|
|
|
|
|
// Ensure service is ready
|
|
|
|
|
await initialize();
|
|
|
|
|
// Ensure microphone permission (triggers OS prompt if needed)
|
|
|
|
|
final hasMic = await checkPermissions();
|
|
|
|
|
if (!hasMic) {
|
|
|
|
|
throw Exception('Microphone permission not granted');
|
|
|
|
|
}
|
|
|
|
|
// Start listening and return the transcript stream
|
|
|
|
|
return startListening();
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 01:20:45 +05:30
|
|
|
Future<void> stopListening() async {
|
|
|
|
|
await _stopListening();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _stopListening() async {
|
|
|
|
|
if (!_isListening) return;
|
|
|
|
|
|
|
|
|
|
_isListening = false;
|
2025-08-25 20:04:04 +05:30
|
|
|
if (_localSttAvailable) {
|
|
|
|
|
try {
|
|
|
|
|
await _speech.stop();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
// Cancel STT subscriptions
|
2025-08-22 13:54:58 +05:30
|
|
|
try {
|
2025-08-25 20:04:04 +05:30
|
|
|
_sttResultSub?.cancel();
|
2025-08-22 13:54:58 +05:30
|
|
|
} catch (_) {}
|
2025-08-25 20:04:04 +05:30
|
|
|
_sttResultSub = null;
|
|
|
|
|
try {
|
|
|
|
|
_sttStateSub?.cancel();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
_sttStateSub = null;
|
2025-08-22 13:54:58 +05:30
|
|
|
}
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
_autoStopTimer?.cancel();
|
|
|
|
|
_autoStopTimer = null;
|
|
|
|
|
_ampSub?.cancel();
|
|
|
|
|
_ampSub = null;
|
2025-08-25 21:53:41 +05:30
|
|
|
_intensityDecayTimer?.cancel();
|
|
|
|
|
_intensityDecayTimer = null;
|
|
|
|
|
_lastIntensity = 0;
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
if (_currentText.isNotEmpty) {
|
|
|
|
|
_textStreamController?.add(_currentText);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_textStreamController?.close();
|
|
|
|
|
_textStreamController = null;
|
|
|
|
|
_intensityController?.close();
|
|
|
|
|
_intensityController = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void dispose() {
|
|
|
|
|
stopListening();
|
2025-08-22 13:54:58 +05:30
|
|
|
try {
|
2025-08-25 20:04:04 +05:30
|
|
|
_speech.dispose().catchError((_) {});
|
2025-08-22 13:54:58 +05:30
|
|
|
} catch (_) {}
|
2025-08-10 01:20:45 +05:30
|
|
|
}
|
|
|
|
|
|
2025-08-25 20:56:33 +05:30
|
|
|
// Recording fallback removed; only on-device STT is supported now
|
2025-08-10 01:20:45 +05:30
|
|
|
|
|
|
|
|
// Native locales not used in server transcription mode
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final voiceInputServiceProvider = Provider<VoiceInputService>((ref) {
|
|
|
|
|
return VoiceInputService();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final voiceInputAvailableProvider = FutureProvider<bool>((ref) async {
|
|
|
|
|
final service = ref.watch(voiceInputServiceProvider);
|
|
|
|
|
if (!service.isSupportedPlatform) return false;
|
|
|
|
|
final initialized = await service.initialize();
|
|
|
|
|
if (!initialized) return false;
|
2025-08-22 13:54:58 +05:30
|
|
|
// If local STT exists, we consider it available; otherwise ensure mic permission for fallback
|
|
|
|
|
if (service.hasLocalStt) return true;
|
2025-08-10 01:20:45 +05:30
|
|
|
final hasPermission = await service.checkPermissions();
|
|
|
|
|
if (!hasPermission) return false;
|
|
|
|
|
return service.isAvailable;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final voiceInputStreamProvider = StreamProvider<String>((ref) {
|
2025-08-25 20:04:04 +05:30
|
|
|
final service = ref.watch(voiceInputServiceProvider);
|
|
|
|
|
return service.textStream;
|
2025-08-10 01:20:45 +05:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Stream of crude voice intensity for waveform visuals
|
|
|
|
|
final voiceIntensityStreamProvider = StreamProvider<int>((ref) {
|
2025-08-25 20:04:04 +05:30
|
|
|
final service = ref.watch(voiceInputServiceProvider);
|
|
|
|
|
return service.intensityStream;
|
2025-08-10 01:20:45 +05:30
|
|
|
});
|