Files
English/kouyu_english/lib/core/voice_service.dart
T

209 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,
noiseSuppress: true,
echoCancel: true,
autoGain: true,
),
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;
}