Files
iiEsaywebUIapp/lib/features/chat/providers/text_to_speech_provider.dart
cogwheel0 561e7dd616 feat(tts): server-backed TTS engine selection
Introduce server TTS support and engine selection while keeping
device TTS as the default.

- Add new persistence keys for storing TTS engine and selected
  server voice (ttsEngine, ttsServerVoiceId, ttsServerVoiceName).
- Extend TextToSpeechService to support two engines:
  TtsEngine.device (FlutterTts) and TtsEngine.server (remote audio).
- Wire in an AudioPlayer and optional ApiService to fetch raw
  audio bytes from the server and play them, with event hooks
  mapped to existing lifecycle callbacks.
- Implement fallback to device TTS on server errors or empty
  responses, and ensure player lifecycle (pause/stop/dispose)
  is handled when using server engine.
- Allow engine and preferred voice to be configured before
  initialization and updated at runtime via updateSettings.

This enables selecting a server-side voice and using a remote
TTS provider while preserving compatibility with the existing
device TTS implementation.
2025-10-23 16:31:15 +05:30

312 lines
7.6 KiB
Dart

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/settings_service.dart';
import '../../../core/providers/app_providers.dart';
import '../../../core/utils/markdown_to_text.dart';
import '../services/text_to_speech_service.dart';
enum TtsPlaybackStatus { idle, initializing, loading, speaking, paused, error }
class TextToSpeechState {
final bool initialized;
final bool available;
final TtsPlaybackStatus status;
final String? activeMessageId;
final String? errorMessage;
const TextToSpeechState({
this.initialized = false,
this.available = false,
this.status = TtsPlaybackStatus.idle,
this.activeMessageId,
this.errorMessage,
});
bool get isSpeaking => status == TtsPlaybackStatus.speaking;
bool get isBusy =>
status == TtsPlaybackStatus.loading ||
status == TtsPlaybackStatus.initializing;
TextToSpeechState copyWith({
bool? initialized,
bool? available,
TtsPlaybackStatus? status,
String? activeMessageId,
bool clearActiveMessageId = false,
String? errorMessage,
bool clearErrorMessage = false,
}) {
return TextToSpeechState(
initialized: initialized ?? this.initialized,
available: available ?? this.available,
status: status ?? this.status,
activeMessageId: clearActiveMessageId
? null
: activeMessageId ?? this.activeMessageId,
errorMessage: clearErrorMessage
? null
: errorMessage ?? this.errorMessage,
);
}
}
class TextToSpeechController extends Notifier<TextToSpeechState> {
late final TextToSpeechService _service;
bool _handlersBound = false;
Future<bool>? _initializationFuture;
@override
TextToSpeechState build() {
_service = ref.watch(textToSpeechServiceProvider);
if (!_handlersBound) {
_handlersBound = true;
_service.bindHandlers(
onStart: _handleStart,
onComplete: _handleCompletion,
onCancel: _handleCancellation,
onPause: _handlePause,
onContinue: _handleContinue,
onError: _handleError,
);
ref.onDispose(() {
unawaited(_service.stop());
});
}
// Listen to settings changes and update TTS when initialized
ref.listen<AppSettings>(appSettingsProvider, (previous, next) {
if (_service.isInitialized && _service.isAvailable) {
final selectedVoice = next.ttsEngine == TtsEngine.server
? next.ttsServerVoiceId
: next.ttsVoice;
_service.updateSettings(
voice: selectedVoice,
speechRate: next.ttsSpeechRate,
pitch: next.ttsPitch,
volume: next.ttsVolume,
engine: next.ttsEngine,
);
}
}, fireImmediately: false);
return const TextToSpeechState();
}
Future<bool> _ensureInitialized() {
final existing = _initializationFuture;
if (existing != null) {
return existing;
}
state = state.copyWith(
status: TtsPlaybackStatus.initializing,
clearErrorMessage: true,
);
final settings = ref.read(appSettingsProvider);
final future = _service
.initialize(
voice: settings.ttsEngine == TtsEngine.server
? settings.ttsServerVoiceId
: settings.ttsVoice,
speechRate: settings.ttsSpeechRate,
pitch: settings.ttsPitch,
volume: settings.ttsVolume,
engine: settings.ttsEngine,
)
.then((available) {
if (!ref.mounted) {
return available;
}
state = state.copyWith(
initialized: true,
available: available,
status: TtsPlaybackStatus.idle,
);
return available;
})
.catchError((error, _) {
if (!ref.mounted) {
return false;
}
state = state.copyWith(
initialized: true,
available: false,
status: TtsPlaybackStatus.error,
errorMessage: error.toString(),
clearActiveMessageId: true,
);
return false;
});
_initializationFuture = future;
future.whenComplete(() {
_initializationFuture = null;
});
return future;
}
Future<void> toggleForMessage({
required String messageId,
required String text,
}) async {
if (text.trim().isEmpty) {
return;
}
final isCurrentlyActive =
state.activeMessageId == messageId &&
state.status != TtsPlaybackStatus.idle &&
state.status != TtsPlaybackStatus.error;
if (isCurrentlyActive) {
await stop();
return;
}
final available = await _ensureInitialized();
if (!available) {
if (!ref.mounted) {
return;
}
state = state.copyWith(
status: TtsPlaybackStatus.error,
errorMessage: 'Text-to-speech unavailable',
clearActiveMessageId: true,
);
return;
}
state = state.copyWith(
status: TtsPlaybackStatus.loading,
activeMessageId: messageId,
clearErrorMessage: true,
);
try {
// Convert markdown to clean text for TTS
final cleanText = MarkdownToText.convert(text);
if (cleanText.isEmpty) {
// No speakable content
if (!ref.mounted) {
return;
}
state = state.copyWith(
status: TtsPlaybackStatus.idle,
clearActiveMessageId: true,
);
return;
}
await _service.speak(cleanText);
if (!ref.mounted) {
return;
}
if (state.status == TtsPlaybackStatus.loading) {
state = state.copyWith(status: TtsPlaybackStatus.speaking);
}
} catch (e) {
if (!ref.mounted) {
return;
}
state = state.copyWith(
status: TtsPlaybackStatus.error,
errorMessage: e.toString(),
clearActiveMessageId: true,
);
}
}
Future<void> pause() async {
if (!state.initialized || !state.available) {
return;
}
await _service.pause();
}
Future<void> stop() async {
await _service.stop();
if (!ref.mounted) {
return;
}
state = state.copyWith(
status: TtsPlaybackStatus.idle,
clearActiveMessageId: true,
clearErrorMessage: true,
);
}
void _handleStart() {
if (!ref.mounted) {
return;
}
state = state.copyWith(status: TtsPlaybackStatus.speaking);
}
void _handleCompletion() {
if (!ref.mounted) {
return;
}
state = state.copyWith(
status: TtsPlaybackStatus.idle,
clearActiveMessageId: true,
);
}
void _handleCancellation() {
if (!ref.mounted) {
return;
}
state = state.copyWith(
status: TtsPlaybackStatus.idle,
clearActiveMessageId: true,
);
}
void _handlePause() {
if (!ref.mounted) {
return;
}
state = state.copyWith(status: TtsPlaybackStatus.paused);
}
void _handleContinue() {
if (!ref.mounted) {
return;
}
state = state.copyWith(status: TtsPlaybackStatus.speaking);
}
void _handleError(String message) {
if (!ref.mounted) {
return;
}
state = state.copyWith(
status: TtsPlaybackStatus.error,
errorMessage: message,
clearActiveMessageId: true,
);
}
}
final textToSpeechServiceProvider = Provider<TextToSpeechService>((ref) {
final api = ref.watch(apiServiceProvider);
final service = TextToSpeechService(api: api);
ref.onDispose(() {
unawaited(service.dispose());
});
return service;
});
final textToSpeechControllerProvider =
NotifierProvider<TextToSpeechController, TextToSpeechState>(
TextToSpeechController.new,
);