cyberhybridhub/lib/services/spelling_tts/spelling_tts_web.dart
nathanAndrinoid a521a74214
Some checks failed
Admin portal tests / test (push) Has been cancelled
balance controled working spelling and market questions.
2026-06-17 04:20:45 +00:00

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;
}