206 lines
6.1 KiB
Dart
206 lines
6.1 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:audioplayers/audioplayers.dart';
|
|
import 'package:flutter_tts/flutter_tts.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:record/record.dart';
|
|
import 'package:speech_to_text/speech_to_text.dart';
|
|
|
|
/// Device speech facilities are optional. Callers must keep a text fallback
|
|
/// because availability depends on device language packs and permissions.
|
|
class VoiceService {
|
|
VoiceService._();
|
|
static final instance = VoiceService._();
|
|
|
|
final FlutterTts _tts = FlutterTts();
|
|
final SpeechToText _stt = SpeechToText();
|
|
final AudioRecorder _recorder = AudioRecorder();
|
|
final AudioPlayer _player = AudioPlayer();
|
|
bool _speechReady = false;
|
|
bool _ttsInitialized = false;
|
|
|
|
void Function(String status)? _statusListener;
|
|
void Function(String error)? _errorListener;
|
|
|
|
Future<void> _initTts() async {
|
|
if (_ttsInitialized) return;
|
|
try {
|
|
if (Platform.isIOS) {
|
|
await _tts.setIosAudioCategory(
|
|
IosTextToSpeechAudioCategory.playback,
|
|
[
|
|
IosTextToSpeechAudioCategoryOptions.allowBluetooth,
|
|
IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP,
|
|
IosTextToSpeechAudioCategoryOptions.mixWithOthers,
|
|
],
|
|
);
|
|
}
|
|
await _tts.setVolume(1.0);
|
|
await _tts.setPitch(1.0);
|
|
_ttsInitialized = true;
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> speak(String text, {bool slow = false}) async {
|
|
try {
|
|
await _initTts();
|
|
await _tts.stop();
|
|
try {
|
|
final isAvailable = await _tts.isLanguageAvailable('en-US');
|
|
if (isAvailable == true) {
|
|
await _tts.setLanguage('en-US');
|
|
} else {
|
|
await _tts.setLanguage('en');
|
|
}
|
|
} catch (_) {
|
|
try {
|
|
await _tts.setLanguage('en-US');
|
|
} catch (_) {}
|
|
}
|
|
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
|
|
await _tts.speak(text);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> stopSpeaking() async {
|
|
try {
|
|
await _tts.stop();
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<bool> hasRecordPermission() => _recorder.hasPermission();
|
|
|
|
Future<bool> startRecording() async {
|
|
if (!await _recorder.hasPermission()) return false;
|
|
final directory = await getApplicationDocumentsDirectory();
|
|
final recordings = Directory('${directory.path}/recordings');
|
|
if (!await recordings.exists()) await recordings.create(recursive: true);
|
|
final timestamp = DateTime.now().microsecondsSinceEpoch;
|
|
await _recorder.start(
|
|
const RecordConfig(
|
|
encoder: AudioEncoder.wav,
|
|
sampleRate: 16000,
|
|
numChannels: 1,
|
|
),
|
|
path: '${recordings.path}/practice_$timestamp.wav',
|
|
);
|
|
return true;
|
|
}
|
|
|
|
Future<String?> stopRecording() => _recorder.stop();
|
|
|
|
Future<void> playRecording(String path) async {
|
|
await _player.stop();
|
|
await _player.play(DeviceFileSource(path));
|
|
}
|
|
|
|
Future<void> stopRecordingPlayback() => _player.stop();
|
|
|
|
Future<void> deleteRecording(String? path) async {
|
|
if (path == null || path.isEmpty) return;
|
|
final file = File(path);
|
|
if (await file.exists()) await file.delete();
|
|
}
|
|
|
|
Future<int> deleteAllRecordings() async {
|
|
final directory = await getApplicationDocumentsDirectory();
|
|
final recordings = Directory('${directory.path}/recordings');
|
|
if (!await recordings.exists()) return 0;
|
|
final files = await recordings
|
|
.list()
|
|
.where((item) => item is File)
|
|
.toList();
|
|
for (final file in files.cast<File>()) {
|
|
await file.delete();
|
|
}
|
|
return files.length;
|
|
}
|
|
|
|
Future<List<String>> listRecordingPaths() async {
|
|
final directory = await getApplicationDocumentsDirectory();
|
|
final recordings = Directory('${directory.path}/recordings');
|
|
if (!await recordings.exists()) return const [];
|
|
final files = await recordings
|
|
.list()
|
|
.where((item) => item is File && (item.path.endsWith('.wav') || item.path.endsWith('.m4a')))
|
|
.cast<File>()
|
|
.toList();
|
|
files.sort((left, right) => right.path.compareTo(left.path));
|
|
return files.map((file) => file.path).toList();
|
|
}
|
|
|
|
Future<void> disposeRecording() async {
|
|
await _recorder.dispose();
|
|
await _player.dispose();
|
|
}
|
|
|
|
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, {
|
|
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;
|
|
}
|
|
}
|
|
|
|
Future<void> stopListening() => _stt.stop();
|
|
bool get isListening => _stt.isListening;
|
|
}
|