136 lines
3.8 KiB
Dart
136 lines
3.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:audioplayers/audioplayers.dart';
|
|
import 'package:cyberhybridhub/config/api_config.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_tts/flutter_tts.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'spelling_tts_backend.dart';
|
|
|
|
SpellingTtsBackend createSpellingTtsBackend() =>
|
|
_FlutterDictionaryFallbackBackend(_FlutterTtsBackend());
|
|
|
|
class _FlutterDictionaryFallbackBackend implements SpellingTtsBackend {
|
|
_FlutterDictionaryFallbackBackend(this._fallback);
|
|
|
|
final SpellingTtsBackend _fallback;
|
|
final AudioPlayer _player = AudioPlayer();
|
|
Completer<void>? _completer;
|
|
StreamSubscription<void>? _onCompleteSubscription;
|
|
|
|
Future<Uint8List?> _fetchAudioBytes(String word) async {
|
|
final String query = word.trim().toLowerCase();
|
|
if (query.isEmpty) return null;
|
|
try {
|
|
final String encoded = Uri.encodeComponent(query);
|
|
final String url = '$apiBaseUrl/v1/spelling/audio/$encoded.mp3';
|
|
final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 8));
|
|
if (response.statusCode == 200) {
|
|
return response.bodyBytes;
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Client audio fetch failed: $e');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Future<void> speak(String text, {String? title}) async {
|
|
await stop();
|
|
final String trimmed = text.trim();
|
|
if (trimmed.isEmpty) return;
|
|
|
|
final Uint8List? bytes = await _fetchAudioBytes(trimmed);
|
|
if (bytes != null) {
|
|
try {
|
|
final Completer<void> completer = Completer<void>();
|
|
_completer = completer;
|
|
|
|
_onCompleteSubscription = _player.onPlayerComplete.listen(
|
|
(_) {
|
|
if (!completer.isCompleted) completer.complete();
|
|
},
|
|
onError: (Object err) {
|
|
if (!completer.isCompleted) completer.completeError(err);
|
|
},
|
|
onDone: () {
|
|
if (!completer.isCompleted) completer.complete();
|
|
},
|
|
);
|
|
|
|
await _player.play(BytesSource(bytes));
|
|
await completer.future.timeout(
|
|
const Duration(seconds: 10),
|
|
onTimeout: () => debugPrint('Native audio playback timed out'),
|
|
);
|
|
} catch (e) {
|
|
debugPrint('Native audio play failed, falling back to TTS: $e');
|
|
await _fallback.speak(trimmed, title: title);
|
|
} finally {
|
|
await _onCompleteSubscription?.cancel();
|
|
_onCompleteSubscription = null;
|
|
_completer = null;
|
|
}
|
|
} else {
|
|
await _fallback.speak(trimmed, title: title);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
final Completer<void>? completer = _completer;
|
|
_completer = null;
|
|
await _onCompleteSubscription?.cancel();
|
|
_onCompleteSubscription = null;
|
|
try {
|
|
await _player.stop();
|
|
} catch (e) {
|
|
debugPrint('Failed to stop audio player: $e');
|
|
}
|
|
if (completer != null && !completer.isCompleted) {
|
|
completer.complete();
|
|
}
|
|
await _fallback.stop();
|
|
}
|
|
}
|
|
|
|
|
|
class _FlutterTtsBackend implements SpellingTtsBackend {
|
|
final FlutterTts _tts = FlutterTts();
|
|
bool _configured = false;
|
|
|
|
Future<void> _ensureConfigured() async {
|
|
if (_configured) {
|
|
return;
|
|
}
|
|
await _tts.setLanguage('en-US');
|
|
await _tts.setSpeechRate(0.42);
|
|
await _tts.setPitch(1.0);
|
|
await _tts.awaitSpeakCompletion(true);
|
|
_configured = true;
|
|
}
|
|
|
|
@override
|
|
Future<void> speak(String text, {String? title}) async {
|
|
final String trimmed = text.trim();
|
|
if (trimmed.isEmpty) {
|
|
return;
|
|
}
|
|
await _ensureConfigured();
|
|
await _tts.stop();
|
|
if (title != null && title.isNotEmpty) {
|
|
debugPrint('Spelling TTS utterance title: $title');
|
|
}
|
|
await _tts.speak(trimmed);
|
|
}
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
try {
|
|
await _tts.stop();
|
|
} catch (_) {}
|
|
}
|
|
}
|