205 lines
5.4 KiB
Dart
205 lines
5.4 KiB
Dart
import 'dart:async';
|
|
import 'dart:js_interop';
|
|
|
|
import 'package:cyberhybridhub/config/api_config.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'spelling_tts_backend.dart';
|
|
|
|
SpellingTtsBackend createSpellingTtsBackend() =>
|
|
_WebDictionaryFallbackBackend(_WebSpeechBackend());
|
|
|
|
@JS('Audio')
|
|
extension type JSAudio._(JSObject _) implements JSObject {
|
|
external JSAudio([String? src]);
|
|
external JSPromise play();
|
|
external void pause();
|
|
external void addEventListener(String type, JSFunction listener);
|
|
external void removeEventListener(String type, JSFunction listener);
|
|
}
|
|
|
|
class _WebDictionaryFallbackBackend implements SpellingTtsBackend {
|
|
_WebDictionaryFallbackBackend(this._fallback);
|
|
|
|
final SpellingTtsBackend _fallback;
|
|
JSAudio? _currentAudio;
|
|
Completer<void>? _audioPlayCompleter;
|
|
|
|
@override
|
|
Future<void> speak(String text, {String? title}) async {
|
|
await stop();
|
|
final String trimmed = text.trim();
|
|
if (trimmed.isEmpty) return;
|
|
|
|
final String query = trimmed.toLowerCase();
|
|
final String encoded = Uri.encodeComponent(query);
|
|
final String audioUrl = '$apiBaseUrl/v1/spelling/audio/$encoded.mp3';
|
|
|
|
try {
|
|
final JSAudio audio = JSAudio(audioUrl);
|
|
_currentAudio = audio;
|
|
final Completer<void> completer = Completer<void>();
|
|
_audioPlayCompleter = completer;
|
|
|
|
bool hasError = false;
|
|
final JSFunction onEnded = (() {
|
|
if (!completer.isCompleted) completer.complete();
|
|
}).toJS;
|
|
|
|
final JSFunction onError = ((JSAny event) {
|
|
hasError = true;
|
|
if (!completer.isCompleted) completer.complete();
|
|
}).toJS;
|
|
|
|
audio.addEventListener('ended', onEnded);
|
|
audio.addEventListener('error', onError);
|
|
|
|
try {
|
|
await audio.play().toDart;
|
|
await completer.future.timeout(
|
|
const Duration(seconds: 10),
|
|
onTimeout: () => debugPrint('Web audio playback timed out'),
|
|
);
|
|
if (hasError) {
|
|
throw Exception('HTMLAudio element error event fired');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Web HTMLAudio play failed, falling back to TTS: $e');
|
|
await _fallback.speak(trimmed, title: title);
|
|
} finally {
|
|
audio.removeEventListener('ended', onEnded);
|
|
audio.removeEventListener('error', onError);
|
|
}
|
|
} catch (e, st) {
|
|
debugPrint('Web audio playback setup error: $e\n$st');
|
|
await _fallback.speak(trimmed, title: title);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
final Completer<void>? completer = _audioPlayCompleter;
|
|
_audioPlayCompleter = null;
|
|
final JSAudio? audio = _currentAudio;
|
|
_currentAudio = null;
|
|
if (audio != null) {
|
|
try {
|
|
audio.pause();
|
|
} catch (e) {
|
|
debugPrint('Failed to pause web audio: $e');
|
|
}
|
|
}
|
|
if (completer != null && !completer.isCompleted) {
|
|
completer.complete();
|
|
}
|
|
await _fallback.stop();
|
|
}
|
|
}
|
|
|
|
|
|
@JS('speechSynthesis')
|
|
external SpeechSynthesis get _speechSynthesis;
|
|
|
|
@JS()
|
|
extension type SpeechSynthesis._(JSObject _) implements JSObject {
|
|
external void speak(SpeechSynthesisUtterance utterance);
|
|
external void cancel();
|
|
}
|
|
|
|
@JS()
|
|
extension type SpeechSynthesisUtterance._(JSObject _) implements JSObject {
|
|
external SpeechSynthesisUtterance();
|
|
|
|
external String text;
|
|
external String lang;
|
|
external double rate;
|
|
external double pitch;
|
|
|
|
@JS('onend')
|
|
external set onEnd(JSFunction? listener);
|
|
|
|
@JS('onerror')
|
|
external set onError(JSFunction? listener);
|
|
}
|
|
|
|
class _WebSpeechBackend implements SpellingTtsBackend {
|
|
Completer<void>? _pending;
|
|
|
|
@override
|
|
Future<void> speak(String text, {String? title}) async {
|
|
final String trimmed = text.trim();
|
|
if (trimmed.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
if (title != null && title.isNotEmpty) {
|
|
debugPrint('Spelling TTS utterance title: $title');
|
|
}
|
|
|
|
await stop();
|
|
|
|
final Completer<void> done = Completer<void>();
|
|
_pending = done;
|
|
|
|
final SpeechSynthesisUtterance utterance = SpeechSynthesisUtterance();
|
|
utterance.text = trimmed;
|
|
utterance.lang = 'en-US';
|
|
utterance.rate = 0.85;
|
|
utterance.pitch = 1.0;
|
|
utterance.onEnd = (() {
|
|
if (!done.isCompleted) {
|
|
done.complete();
|
|
}
|
|
}).toJS;
|
|
utterance.onError = ((JSAny event) {
|
|
String details = 'unknown';
|
|
if (event.isA<JSObject>()) {
|
|
try {
|
|
final errEvent = event as JSObject;
|
|
details = (errEvent as SpeechSynthesisErrorEvent).error;
|
|
} catch (_) {}
|
|
}
|
|
debugPrint('Spelling web TTS error: $details ($event)');
|
|
if (!done.isCompleted) {
|
|
done.complete();
|
|
}
|
|
}).toJS;
|
|
|
|
try {
|
|
_speechSynthesis.speak(utterance);
|
|
await done.future.timeout(
|
|
const Duration(seconds: 30),
|
|
onTimeout: () {
|
|
debugPrint('Spelling web TTS timed out');
|
|
},
|
|
);
|
|
} catch (e, st) {
|
|
debugPrint('Spelling web TTS failed: $e\n$st');
|
|
if (!done.isCompleted) {
|
|
done.complete();
|
|
}
|
|
} finally {
|
|
if (identical(_pending, done)) {
|
|
_pending = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
final Completer<void>? pending = _pending;
|
|
_pending = null;
|
|
try {
|
|
_speechSynthesis.cancel();
|
|
} catch (e, st) {
|
|
debugPrint('Spelling web TTS stop failed: $e\n$st');
|
|
}
|
|
pending?.complete();
|
|
}
|
|
}
|
|
|
|
@JS()
|
|
extension type SpeechSynthesisErrorEvent._(JSObject _) implements JSObject {
|
|
external String get error;
|
|
}
|