main
dart 1,307 lines 39.9 KB
Raw
1 import 'dart:async';
2 import 'dart:io';
3 import 'dart:isolate';
4 import 'dart:typed_data';
5
6 import 'package:audio_session/audio_session.dart';
7 import 'package:file_picker/file_picker.dart';
8 import 'package:just_audio/just_audio.dart';
9 import 'package:just_audio_background/just_audio_background.dart';
10 import 'package:flutter_riverpod/flutter_riverpod.dart';
11 import 'package:flutter_tts/flutter_tts.dart';
12
13 import '../../../core/logger/app_logger.dart';
14 import '../../../core/models/enums.dart';
15 import '../../../core/providers/app_providers.dart';
16 import '../../../core/utils/safe_file_picker.dart';
17 import '../utils/tts_text_processor.dart';
18 import 'tts_model_providers.dart';
19 import '../data/piper_tts_model.dart';
20 import '../data/system_tts_configuration.dart';
21 import 'tts_worker.dart';
22 import '../utils/wav_merge.dart';
23
24 final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(() {
25 return TtsNotifier();
26 });
27
28 class TtsState {
29 final bool isSpeaking;
30 final bool isPaused;
31 final bool isInitializing;
32 final String? error;
33 final EngineId? activeEngine;
34 final String? playingContent;
35 final String? playingMessageId;
36 final String? playingConversationId;
37 final bool isPreview;
38 final Duration position;
39 final Duration duration;
40 final bool canSeek;
41 final bool isSeekPending;
42 final double playbackSpeed;
43
44 /// Text chunks currently being read aloud, in order. Cleared when
45 /// playback stops. Useful for word-level progress UI.
46 final List<String> chunks;
47
48 /// Index of the chunk currently being read. -1 when nothing is playing.
49 final int currentChunkIndex;
50
51 /// Character offset currently reported by a TTS engine with native
52 /// word-progress support. Falls back to an estimated offset for engines
53 /// that only expose playback time.
54 final int spokenCharOffset;
55
56 const TtsState({
57 this.isSpeaking = false,
58 this.isPaused = false,
59 this.isInitializing = false,
60 this.error,
61 this.activeEngine,
62 this.playingContent,
63 this.playingMessageId,
64 this.playingConversationId,
65 this.isPreview = false,
66 this.position = Duration.zero,
67 this.duration = Duration.zero,
68 this.canSeek = false,
69 this.isSeekPending = false,
70 this.playbackSpeed = 1.0,
71 this.chunks = const [],
72 this.currentChunkIndex = -1,
73 this.spokenCharOffset = -1,
74 });
75
76 TtsState copyWith({
77 bool? isSpeaking,
78 bool? isPaused,
79 bool? isInitializing,
80 String? error,
81 EngineId? activeEngine,
82 String? playingContent,
83 String? playingMessageId,
84 String? playingConversationId,
85 bool? isPreview,
86 Duration? position,
87 Duration? duration,
88 bool? canSeek,
89 bool? isSeekPending,
90 double? playbackSpeed,
91 bool clearPlayingTarget = false,
92 bool resetProgress = false,
93 List<String>? chunks,
94 int? currentChunkIndex,
95 int? spokenCharOffset,
96 }) {
97 return TtsState(
98 isSpeaking: isSpeaking ?? this.isSpeaking,
99 isPaused: isPaused ?? this.isPaused,
100 isInitializing: isInitializing ?? this.isInitializing,
101 error: error,
102 activeEngine: activeEngine ?? this.activeEngine,
103 playingContent: playingContent,
104 playingMessageId: clearPlayingTarget
105 ? null
106 : (playingMessageId ?? this.playingMessageId),
107 playingConversationId: clearPlayingTarget
108 ? null
109 : (playingConversationId ?? this.playingConversationId),
110 isPreview: isPreview ?? this.isPreview,
111 position: resetProgress ? Duration.zero : (position ?? this.position),
112 duration: resetProgress ? Duration.zero : (duration ?? this.duration),
113 canSeek: canSeek ?? this.canSeek,
114 isSeekPending: isSeekPending ?? this.isSeekPending,
115 playbackSpeed: playbackSpeed ?? this.playbackSpeed,
116 chunks: chunks ?? this.chunks,
117 currentChunkIndex: currentChunkIndex ?? this.currentChunkIndex,
118 spokenCharOffset: resetProgress
119 ? -1
120 : (spokenCharOffset ?? this.spokenCharOffset),
121 );
122 }
123 }
124
125 class TtsNotifier extends Notifier<TtsState> {
126 FlutterTts? _flutterTts;
127 final _systemTtsConfiguration = SystemTtsConfiguration();
128 final AudioPlayer _player = AudioPlayer();
129 bool _isPlayerDisposed = false;
130 bool _isStopping = false;
131 List<String> _chunks = [];
132 int _currentChunkIndex = 0;
133 EngineId? _currentEngine;
134 String? _currentVoice;
135 String? _currentModelPath;
136 String? _currentVoicesPath;
137 String? _currentTokensPath;
138 String? _currentDataDir;
139 String? _currentLexicon;
140
141 // Isolate worker fields
142 Isolate? _workerIsolate;
143 SendPort? _workerSendPort;
144 final ReceivePort _mainReceivePort = ReceivePort();
145 final Map<int, Uint8List> _synthesizedChunks = {};
146 int _lastEnqueuedChunk = -1;
147 static const int _lookAheadCount = 2;
148 Completer<void>? _initCompleter;
149 bool _isWorkerInitializing = false;
150 int _currentSessionId = 0;
151
152 StreamSubscription<void>? _playerCompleteSubscription;
153 int _playbackSessionId = 0;
154 Completer<SendPort>? _workerPortCompleter;
155
156 bool _isPreview = false;
157 final Map<int, AudioSource> _playlistBuffer = {};
158 int _nextPlaylistIndexToAdd = 0;
159 StreamSubscription<int?>? _currentIndexSubscription;
160 StreamSubscription<PlaybackEvent>? _playerEventSubscription;
161 StreamSubscription<Duration>? _positionSubscription;
162 StreamSubscription<Duration?>? _durationSubscription;
163 Timer? _systemProgressTimer;
164
165 // Progress tracking
166 final Map<int, Duration> _chunkDurations = {};
167 Duration _totalDuration = Duration.zero;
168 Duration _chunkOffset = Duration.zero;
169 final StreamController<void> _onChunkChanged =
170 StreamController<void>.broadcast();
171
172 // System TTS approximate seek
173 String? _systemFullText;
174 Duration _systemSkippedOffset = Duration.zero;
175 DateTime? _systemSpeakStart;
176 bool _systemHasNativeProgress = false;
177 int _systemSpeakGeneration = 0;
178
179 _MessageAudioCache? _messageAudioCache;
180
181 static const playbackSpeedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0];
182
183 AudioPlayer get player => _player;
184 Stream<Duration> get onPositionChanged => _player.positionStream;
185 Duration get totalDuration => _totalDuration;
186 Duration get chunkOffset => _chunkOffset;
187 Stream<void> get onChunkChanged => _onChunkChanged.stream;
188
189 @override
190 TtsState build() {
191 ref.keepAlive();
192
193 ref.listen(settingsProvider, (previous, next) {
194 if (previous?.ttsEngine != next.ttsEngine) {
195 state = state.copyWith(activeEngine: next.ttsEngine);
196 }
197 });
198
199 _playerCompleteSubscription = _player.playerStateStream.listen((
200 playerState,
201 ) {
202 if (playerState.processingState != ProcessingState.completed) return;
203 if (_isStopping || state.isInitializing) return;
204 if (_currentSessionId != _playbackSessionId) return;
205 if (_nextPlaylistIndexToAdd < _chunks.length) return;
206 if (_lastEnqueuedChunk < _chunks.length - 1) return;
207 _onPlaybackFinished();
208 });
209
210 _playerEventSubscription = _player.playbackEventStream.listen(
211 (_) {},
212 onError: (Object error) {
213 Log.warning('TTS player event error: $error');
214 if (state.isSpeaking && !_isStopping) {
215 _onPlaybackFinished();
216 }
217 },
218 );
219
220 _currentIndexSubscription = _player.currentIndexStream.listen((index) {
221 if (index != null && index != _currentChunkIndex) {
222 _currentChunkIndex = index;
223 state = state.copyWith(currentChunkIndex: index);
224 _updateChunkOffset();
225 _onChunkChanged.add(null);
226 _enqueueNextChunks();
227 _updatePlaybackProgress();
228 }
229 });
230
231 _positionSubscription = _player.positionStream.listen((_) {
232 _updatePlaybackProgress();
233 });
234
235 _durationSubscription = _player.durationStream.listen((_) {
236 _updatePlaybackProgress();
237 });
238
239 _setupWorkerListener();
240
241 ref.onDispose(() {
242 _workerPortCompleter?.completeError(StateError('Disposed'));
243 _workerPortCompleter = null;
244 _workerIsolate?.kill();
245 _mainReceivePort.close();
246 _playerCompleteSubscription?.cancel();
247 _playerEventSubscription?.cancel();
248 _currentIndexSubscription?.cancel();
249 _positionSubscription?.cancel();
250 _durationSubscription?.cancel();
251 _systemProgressTimer?.cancel();
252 _onChunkChanged.close();
253 if (!_isPlayerDisposed) {
254 _isPlayerDisposed = true;
255 try {
256 _player.dispose();
257 } catch (_) {}
258 }
259 });
260
261 final settings = ref.read(settingsProvider);
262 return TtsState(activeEngine: settings.ttsEngine);
263 }
264
265 void _setupWorkerListener() {
266 _mainReceivePort.listen((message) async {
267 if (message is SendPort) {
268 _workerSendPort = message;
269 _workerPortCompleter?.complete(message);
270 _workerPortCompleter = null;
271 } else if (message is TtsWorkerResponse) {
272 if (message.isInitResponse) {
273 _initCompleter?.complete();
274 _initCompleter = null;
275 } else if (message.chunkIndex != null) {
276 if (message.sessionId != _currentSessionId) {
277 Log.debug('Ignoring stale chunk ${message.chunkIndex}');
278 return;
279 }
280 if (message.wavBytes != null) {
281 _synthesizedChunks[message.chunkIndex!] = message.wavBytes!;
282 _computeChunkDuration(message.chunkIndex!, message.wavBytes!);
283 await _addChunkToPlaylist(message.chunkIndex!, message.wavBytes!);
284 _updatePlaybackProgress();
285 } else {
286 Log.error(
287 'Worker error for chunk ${message.chunkIndex}: ${message.error}',
288 );
289 _synthesizedChunks[message.chunkIndex!] = Uint8List(0);
290 await _addChunkToPlaylist(message.chunkIndex!, Uint8List(0));
291 }
292 } else if (message.error != null) {
293 Log.error('Worker general error: ${message.error}');
294 _initCompleter?.completeError(message.error!);
295 _initCompleter = null;
296 }
297 }
298 });
299 }
300
301 Future<void> _ensureWorker() async {
302 if (_workerIsolate != null) return;
303
304 Log.info('Spawning TTS worker isolate...');
305 _workerPortCompleter = Completer<SendPort>();
306 _workerIsolate = await Isolate.spawn(
307 ttsWorkerEntry,
308 _mainReceivePort.sendPort,
309 );
310
311 await _workerPortCompleter!.future.timeout(const Duration(seconds: 10));
312 }
313
314 void _computeChunkDuration(int index, Uint8List wavBytes) {
315 if (wavBytes.length < 44) return;
316 final data = ByteData.view(wavBytes.buffer, wavBytes.offsetInBytes, 44);
317 final sampleRate = data.getUint32(24, Endian.little);
318 final dataSize = data.getUint32(40, Endian.little);
319 final sampleCount = dataSize ~/ 2;
320 final durationMs = (sampleCount * 1000) ~/ sampleRate;
321 _chunkDurations[index] = Duration(milliseconds: durationMs);
322 _totalDuration = Duration.zero;
323 for (final d in _chunkDurations.values) {
324 _totalDuration += d;
325 }
326 _updatePlaybackProgress();
327 }
328
329 void _updateChunkOffset() {
330 var offset = Duration.zero;
331 for (var i = 0; i < _currentChunkIndex; i++) {
332 offset += _chunkDurations[i] ?? Duration.zero;
333 }
334 _chunkOffset = offset;
335 }
336
337 Duration _averageChunkDuration() {
338 if (_chunkDurations.isEmpty) return const Duration(seconds: 3);
339 final totalMs = _chunkDurations.values.fold<int>(
340 0,
341 (sum, d) => sum + d.inMilliseconds,
342 );
343 return Duration(milliseconds: totalMs ~/ _chunkDurations.length);
344 }
345
346 Duration _estimatedTotalDuration() {
347 if (_chunks.isEmpty) return Duration.zero;
348 if (_chunkDurations.length >= _chunks.length) return _totalDuration;
349 final avg = _averageChunkDuration();
350 return Duration(milliseconds: avg.inMilliseconds * _chunks.length);
351 }
352
353 Duration _computeGlobalPosition() {
354 if (state.activeEngine == EngineId.system) {
355 return _systemEstimatedPosition();
356 }
357 _updateChunkOffset();
358 return _chunkOffset + _player.position;
359 }
360
361 void _updatePlaybackProgress() {
362 if (!state.isSpeaking || state.isPreview) return;
363
364 if (state.activeEngine == EngineId.system) {
365 return;
366 }
367
368 final position = _computeGlobalPosition();
369 final duration = _estimatedTotalDuration();
370 state = state.copyWith(
371 position: position,
372 duration: duration,
373 canSeek:
374 !state.isInitializing &&
375 duration > Duration.zero &&
376 _nextPlaylistIndexToAdd >= _chunks.length,
377 );
378 }
379
380 Duration _systemEstimatedDuration() {
381 if (_systemFullText == null) return Duration.zero;
382 final cps = 12.0 * ref.read(settingsProvider).ttsSpeed;
383 return Duration(
384 milliseconds: (_systemFullText!.length / cps * 1000).round(),
385 );
386 }
387
388 Duration _systemEstimatedPosition() {
389 if (_systemSpeakStart == null) return _systemSkippedOffset;
390 final pos =
391 _systemSkippedOffset + DateTime.now().difference(_systemSpeakStart!);
392 final total = _systemEstimatedDuration();
393 return pos > total ? total : pos;
394 }
395
396 void _stopSystemProgressTimer() {
397 _systemProgressTimer?.cancel();
398 _systemProgressTimer = null;
399 }
400
401 void _startSystemProgressTimer(int speakGeneration) {
402 _stopSystemProgressTimer();
403 _systemProgressTimer = Timer.periodic(const Duration(milliseconds: 80), (
404 timer,
405 ) {
406 if (_isStopping ||
407 speakGeneration != _systemSpeakGeneration ||
408 !state.isSpeaking ||
409 state.activeEngine != EngineId.system) {
410 timer.cancel();
411 if (identical(_systemProgressTimer, timer)) {
412 _systemProgressTimer = null;
413 }
414 return;
415 }
416
417 final duration = _systemEstimatedDuration();
418 final position = _systemEstimatedPosition();
419 final text = _systemFullText ?? '';
420 int? estimatedCharOffset;
421 if (!_systemHasNativeProgress && text.isNotEmpty) {
422 final durationMicros = duration.inMicroseconds;
423 final progress = durationMicros <= 0
424 ? 0.0
425 : position.inMicroseconds / durationMicros;
426 estimatedCharOffset = (progress * text.length).floor().clamp(
427 0,
428 text.length - 1,
429 );
430 }
431
432 state = state.copyWith(
433 position: position,
434 duration: duration,
435 spokenCharOffset: estimatedCharOffset,
436 playingContent: state.playingContent,
437 );
438 });
439 }
440
441 void _resetSystemSeekState() {
442 _systemFullText = null;
443 _systemSkippedOffset = Duration.zero;
444 _systemSpeakStart = null;
445 _systemHasNativeProgress = false;
446 _stopSystemProgressTimer();
447 }
448
449 void _resetProgress() {
450 _chunkDurations.clear();
451 _totalDuration = Duration.zero;
452 _chunkOffset = Duration.zero;
453 _resetSystemSeekState();
454 }
455
456 void _deleteChunkFile(int sessionId, int chunkIndex) {
457 if (chunkIndex < 0) return;
458 Future.microtask(() async {
459 try {
460 final tempFile = File(
461 '${Directory.systemTemp.path}/tts_chunk_${sessionId}_$chunkIndex.wav',
462 );
463 if (await tempFile.exists()) {
464 await tempFile.delete();
465 }
466 } catch (_) {}
467 });
468 }
469
470 void _cleanupSessionFiles(int sessionId, int chunkCount) {
471 for (var i = 0; i < chunkCount; i++) {
472 _deleteChunkFile(sessionId, i);
473 }
474 }
475
476 void _onPlaybackFinished() {
477 if (_isStopping) return;
478 if (_currentSessionId != _playbackSessionId) return;
479 _saveMessageAudioCache();
480 _cleanupSessionFiles(_currentSessionId, _chunks.length);
481 _resetProgress();
482 state = state.copyWith(
483 isSpeaking: false,
484 isPaused: false,
485 isInitializing: false,
486 playingContent: null,
487 isPreview: false,
488 clearPlayingTarget: true,
489 resetProgress: true,
490 chunks: const [],
491 currentChunkIndex: -1,
492 canSeek: false,
493 );
494 // Hand the iOS audio session back to record so a follow-up
495 // STT startListening() can capture audio immediately.
496 _configureAudioSessionForSpeech();
497 }
498
499 void _configureAudioSessionForSpeech() {
500 try {
501 AudioSession.instance.then((session) {
502 session.configure(const AudioSessionConfiguration.speech());
503 });
504 } catch (e) {
505 Log.error('Error configuring audio session for speech: $e');
506 }
507 }
508
509 List<String> _splitText(String text) {
510 if (text.length <= 250) return [text];
511
512 final regex = RegExp(r'(?<=[.!?])\s+');
513 final sentences = text.split(regex);
514
515 final List<String> chunks = [];
516 String currentChunk = "";
517
518 for (final sentence in sentences) {
519 if ((currentChunk.length + sentence.length) > 250 &&
520 currentChunk.isNotEmpty) {
521 chunks.add(currentChunk.trim());
522 currentChunk = "";
523 }
524
525 if (sentence.length > 250) {
526 final subRegex = RegExp(r'(?<=[,;])\s+');
527 final parts = sentence.split(subRegex);
528 for (final part in parts) {
529 if ((currentChunk.length + part.length) > 250 &&
530 currentChunk.isNotEmpty) {
531 chunks.add(currentChunk.trim());
532 currentChunk = "";
533 }
534 currentChunk += "$part ";
535 }
536 } else {
537 currentChunk += "$sentence ";
538 }
539 }
540
541 if (currentChunk.trim().isNotEmpty) {
542 chunks.add(currentChunk.trim());
543 }
544
545 return chunks;
546 }
547
548 Future<void> _initEngine(EngineId engineId, {String? voiceId}) async {
549 await _ensureWorker();
550
551 if (_isWorkerInitializing && _initCompleter != null) {
552 await _initCompleter!.future.timeout(const Duration(seconds: 30));
553 }
554
555 String modelPath = '';
556 String voicesPath = '';
557 String tokensPath = '';
558 String dataDir = '';
559 String lexicon = '';
560
561 switch (engineId) {
562 case EngineId.kitten:
563 final variant = ref.read(settingsProvider).kittenTtsModelVariant;
564 final kittenDl = ref.read(kittenTtsDownloaderProvider);
565 final mPath = await kittenDl.getModelPath(variant);
566 final vPath = await kittenDl.getVoicesPath(variant);
567 final tPath = await kittenDl.getTokensPath(variant);
568 final dDir = await kittenDl.getDataDir(variant);
569 if (mPath == null || vPath == null || tPath == null) {
570 throw StateError(
571 'Kitten model files not found for ${variant.name}. Please download them in the Model Manager.',
572 );
573 }
574 modelPath = mPath;
575 voicesPath = vPath;
576 tokensPath = tPath;
577 dataDir = dDir.path;
578 break;
579 case EngineId.piper:
580 final selectedVoiceId =
581 voiceId ??
582 ref.read(settingsProvider).ttsVoiceId ??
583 piperVoices.first.id;
584 final requestedVariant = PiperTtsModelVariant.values.firstWhere(
585 (v) => v.id == selectedVoiceId,
586 orElse: () => PiperTtsModelVariant.enUsLessacMedium,
587 );
588 final downloader = ref.read(piperTtsDownloaderProvider);
589 final downloadedVariants = await downloader.getDownloadedVariants();
590 final variant = downloadedVariants.contains(requestedVariant)
591 ? requestedVariant
592 : downloadedVariants.isNotEmpty
593 ? downloadedVariants.first
594 : requestedVariant;
595 final mPath = await downloader.getModelPath(variant);
596 final tPath = await downloader.getTokensPath(variant);
597 final dDir = await downloader.getDataDir(variant);
598 if (mPath == null || tPath == null) {
599 throw StateError(
600 'Piper voice files not found for ${variant.displayName}. Please download them in the Model Manager.',
601 );
602 }
603 modelPath = mPath;
604 tokensPath = tPath;
605 dataDir = dDir.path;
606 break;
607 default:
608 return;
609 }
610
611 if (_currentEngine == engineId &&
612 !_isWorkerInitializing &&
613 _currentModelPath == modelPath &&
614 _currentVoicesPath == voicesPath &&
615 _currentTokensPath == tokensPath &&
616 _currentDataDir == dataDir &&
617 _currentLexicon == lexicon) {
618 return;
619 }
620
621 _isWorkerInitializing = true;
622
623 try {
624 _initCompleter = Completer<void>();
625 _workerSendPort!.send(
626 TtsInitRequest(
627 engineId: engineId,
628 modelPath: modelPath,
629 voicesPath: voicesPath,
630 tokensPath: tokensPath,
631 dataDir: dataDir,
632 lexicon: lexicon,
633 ),
634 );
635
636 await _initCompleter!.future.timeout(const Duration(seconds: 30));
637 _currentEngine = engineId;
638 _currentModelPath = modelPath;
639 _currentVoicesPath = voicesPath;
640 _currentTokensPath = tokensPath;
641 _currentDataDir = dataDir;
642 _currentLexicon = lexicon;
643 } finally {
644 _isWorkerInitializing = false;
645 }
646 }
647
648 Future<void> speak(
649 String text, {
650 EngineId? engineId,
651 String? voiceId,
652 bool isPreview = false,
653 String? messageId,
654 String? conversationId,
655 }) async {
656 if (text.trim().isEmpty) return;
657
658 final settings = ref.read(settingsProvider);
659 if (settings.ttsProcessMarkdown) {
660 text = TtsTextProcessor.process(text, stripMarkdown: true);
661 if (text.trim().isEmpty) return;
662 }
663
664 engineId ??= settings.ttsEngine;
665 final resolvedEngine = engineId;
666
667 final availableVoices = voicesForEngine(resolvedEngine);
668 final resolvedVoiceId =
669 voiceId ??
670 settings.ttsVoiceId ??
671 (availableVoices.isNotEmpty ? availableVoices.first.id : null);
672
673 Future<bool> tryPlayCached() async {
674 if (isPreview ||
675 messageId == null ||
676 _messageAudioCache == null ||
677 _messageAudioCache!.messageId != messageId ||
678 _messageAudioCache!.text != text ||
679 _messageAudioCache!.engine != resolvedEngine ||
680 _messageAudioCache!.voice != resolvedVoiceId) {
681 return false;
682 }
683 await _playFromCache(
684 isPreview: isPreview,
685 messageId: messageId,
686 conversationId: conversationId,
687 engineId: resolvedEngine,
688 text: text,
689 );
690 return true;
691 }
692
693 if (await tryPlayCached()) return;
694
695 _isStopping = false;
696 if (state.isSpeaking) {
697 await stop();
698 await Future.delayed(const Duration(milliseconds: 100));
699 _isStopping = false;
700 }
701
702 if (await tryPlayCached()) return;
703
704 if (resolvedEngine == EngineId.system) {
705 _flutterTts ??= FlutterTts();
706 _resetSystemSeekState();
707 final speakGeneration = ++_systemSpeakGeneration;
708 _systemFullText = text;
709 _systemSkippedOffset = Duration.zero;
710 _systemSpeakStart = null;
711 _systemHasNativeProgress = false;
712 final estimatedDuration = _systemEstimatedDuration();
713 state = state.copyWith(
714 isSpeaking: true,
715 isPaused: false,
716 isInitializing: false,
717 error: null,
718 activeEngine: EngineId.system,
719 playingContent: text,
720 playingMessageId: isPreview ? null : messageId,
721 playingConversationId: isPreview ? null : conversationId,
722 isPreview: isPreview,
723 canSeek: false,
724 position: Duration.zero,
725 duration: estimatedDuration,
726 chunks: [text],
727 currentChunkIndex: 0,
728 spokenCharOffset: 0,
729 );
730 try {
731 await _systemTtsConfiguration.prepare(
732 _flutterTts!,
733 isAndroid: Platform.isAndroid,
734 );
735 if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
736 await _applySystemSpeechRate();
737 _flutterTts!.setStartHandler(() {
738 if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
739 _systemSpeakStart = DateTime.now();
740 _startSystemProgressTimer(speakGeneration);
741 });
742 _flutterTts!.setProgressHandler((spokenText, start, end, word) {
743 if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
744 if (text.isEmpty) return;
745
746 _systemHasNativeProgress = true;
747 final safeStart = start.clamp(0, text.length - 1);
748 final duration = _systemEstimatedDuration();
749 final position = Duration(
750 microseconds: (duration.inMicroseconds * (safeStart / text.length))
751 .round(),
752 );
753 state = state.copyWith(
754 position: position,
755 duration: duration,
756 spokenCharOffset: safeStart,
757 playingContent: state.playingContent,
758 );
759 });
760 _flutterTts!.setCompletionHandler(() {
761 if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
762 _resetSystemSeekState();
763 state = state.copyWith(
764 isSpeaking: false,
765 isPaused: false,
766 playingContent: null,
767 clearPlayingTarget: true,
768 resetProgress: true,
769 canSeek: false,
770 chunks: const [],
771 currentChunkIndex: -1,
772 );
773 });
774 _flutterTts!.setErrorHandler((msg) {
775 if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
776 _resetSystemSeekState();
777 state = state.copyWith(
778 isSpeaking: false,
779 isPaused: false,
780 playingContent: null,
781 error: msg.toString(),
782 clearPlayingTarget: true,
783 resetProgress: true,
784 canSeek: false,
785 chunks: const [],
786 currentChunkIndex: -1,
787 );
788 });
789 _flutterTts!.setCancelHandler(() {
790 if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
791 });
792 await _flutterTts!.awaitSpeakCompletion(true);
793 if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
794 _systemSpeakStart = DateTime.now();
795 _startSystemProgressTimer(speakGeneration);
796 final result = await _flutterTts!.speak(text);
797 if (result == 0 || result == -1) {
798 throw StateError('The selected TTS engine could not speak');
799 }
800 } catch (e) {
801 if (_isStopping || speakGeneration != _systemSpeakGeneration) return;
802 _resetSystemSeekState();
803 state = state.copyWith(
804 isSpeaking: false,
805 isPaused: false,
806 error: 'System TTS unavailable',
807 playingContent: null,
808 clearPlayingTarget: true,
809 resetProgress: true,
810 canSeek: false,
811 chunks: const [],
812 currentChunkIndex: -1,
813 );
814 rethrow;
815 }
816 return;
817 }
818
819 _resetProgress();
820 _chunks = _splitText(text);
821 _currentChunkIndex = 0;
822 state = state.copyWith(chunks: _chunks, currentChunkIndex: 0);
823 _currentVoice = resolvedVoiceId;
824 _synthesizedChunks.clear();
825 _lastEnqueuedChunk = -1;
826 _currentSessionId++;
827 _playbackSessionId = _currentSessionId;
828
829 _isPreview = isPreview;
830 _playlistBuffer.clear();
831 _nextPlaylistIndexToAdd = 0;
832 await _player.stop();
833 await _player.clearAudioSources();
834
835 try {
836 AudioSession.instance.then((session) {
837 session.configure(const AudioSessionConfiguration.speech());
838 });
839 } catch (e) {
840 Log.error('Error configuring audio session: $e');
841 }
842
843 state = state.copyWith(
844 isSpeaking: true,
845 isPaused: false,
846 isInitializing: true,
847 error: null,
848 activeEngine: engineId,
849 playingContent: text,
850 playingMessageId: isPreview ? null : messageId,
851 playingConversationId: isPreview ? null : conversationId,
852 isPreview: isPreview,
853 spokenCharOffset: -1,
854 );
855
856 try {
857 await _initEngine(resolvedEngine, voiceId: resolvedVoiceId);
858 await _player.setSpeed(state.playbackSpeed);
859 state = state.copyWith(
860 isInitializing: false,
861 canSeek: false,
862 duration: _estimatedTotalDuration(),
863 );
864 _enqueueNextChunks();
865 } catch (e, st) {
866 if (_isPlayerDisposed) return;
867 Log.error('TTS speak error: $e\n$st');
868 state = state.copyWith(
869 isSpeaking: false,
870 isInitializing: false,
871 error: e.toString(),
872 playingContent: null,
873 clearPlayingTarget: true,
874 );
875 }
876 }
877
878 Future<void> _addChunkToPlaylist(int chunkIndex, Uint8List wavBytes) async {
879 if (_isStopping || chunkIndex >= _chunks.length) return;
880
881 final tempDir = Directory.systemTemp;
882 final tempFile = File(
883 '${tempDir.path}/tts_chunk_${_currentSessionId}_$chunkIndex.wav',
884 );
885 await tempFile.writeAsBytes(wavBytes);
886
887 final source = AudioSource.file(
888 tempFile.path,
889 tag: MediaItem(
890 id: 'tts_chunk_${_currentSessionId}_$chunkIndex',
891 album: _isPreview ? 'Voice Preview' : 'LocalMind TTS',
892 title: _chunks[chunkIndex],
893 artist: _currentEngine == EngineId.kitten ? 'Kitten TTS' : 'Piper TTS',
894 ),
895 );
896
897 _playlistBuffer[chunkIndex] = source;
898 while (_playlistBuffer.containsKey(_nextPlaylistIndexToAdd)) {
899 final indexBeingAdded = _nextPlaylistIndexToAdd;
900 final src = _playlistBuffer.remove(indexBeingAdded)!;
901 if (indexBeingAdded == 0) {
902 await _player.setAudioSources([src]);
903 } else {
904 await _player.addAudioSource(src);
905 }
906
907 if (indexBeingAdded == 0 && !state.isPaused) {
908 await _player.setSpeed(state.playbackSpeed);
909 _player.play();
910 }
911
912 _nextPlaylistIndexToAdd++;
913
914 if (_nextPlaylistIndexToAdd >= _chunks.length) {
915 state = state.copyWith(isInitializing: false, canSeek: true);
916 _saveMessageAudioCache();
917 } else {
918 state = state.copyWith(isInitializing: false);
919 }
920 }
921 }
922
923 void _enqueueChunk(int chunkIndex) {
924 if (_workerSendPort == null || _currentEngine == null) return;
925 if (chunkIndex >= _chunks.length) return;
926
927 final settings = ref.read(settingsProvider);
928 final availableVoices = voicesForEngine(_currentEngine!);
929 final voiceId =
930 _currentVoice ??
931 settings.ttsVoiceId ??
932 (availableVoices.isNotEmpty ? availableVoices.first.id : null);
933
934 while (_lastEnqueuedChunk < chunkIndex) {
935 _lastEnqueuedChunk++;
936 final index = _lastEnqueuedChunk;
937 Log.debug('Enqueuing synthesis for chunk $index');
938 _workerSendPort!.send(
939 TtsSynthesizeRequest(
940 text: _chunks[index],
941 voiceId: voiceId ?? '',
942 speed: settings.ttsSpeed,
943 chunkIndex: index,
944 sessionId: _currentSessionId,
945 ),
946 );
947 }
948 }
949
950 Future<void> _ensurePlaylistThrough(int targetIndex) async {
951 if (targetIndex < 0 || targetIndex >= _chunks.length) return;
952 _enqueueChunk(targetIndex);
953
954 final deadline = DateTime.now().add(const Duration(seconds: 20));
955 while (_nextPlaylistIndexToAdd <= targetIndex &&
956 DateTime.now().isBefore(deadline)) {
957 await Future.delayed(const Duration(milliseconds: 50));
958 if (_lastEnqueuedChunk < targetIndex) {
959 _enqueueChunk(targetIndex);
960 }
961 }
962 }
963
964 int _chunkIndexForPosition(Duration target) {
965 var accumulated = Duration.zero;
966 for (var i = 0; i < _chunks.length; i++) {
967 final chunkDuration = _chunkDurations[i] ?? _averageChunkDuration();
968 if (accumulated + chunkDuration > target) return i;
969 accumulated += chunkDuration;
970 }
971 return _chunks.isEmpty ? 0 : _chunks.length - 1;
972 }
973
974 Future<void> seekTo(Duration target) async {
975 if (!state.isSpeaking ||
976 state.isPreview ||
977 state.activeEngine == EngineId.system) {
978 return;
979 }
980
981 state = state.copyWith(isSeekPending: true);
982 try {
983 final maxDuration = _estimatedTotalDuration();
984 if (maxDuration <= Duration.zero) return;
985
986 var clamped = target;
987 if (clamped.isNegative) clamped = Duration.zero;
988 if (clamped > maxDuration) clamped = maxDuration;
989
990 final targetChunk = _chunkIndexForPosition(clamped);
991 await _ensurePlaylistThrough(targetChunk);
992
993 var chunkStart = Duration.zero;
994 for (var i = 0; i < targetChunk; i++) {
995 chunkStart += _chunkDurations[i] ?? _averageChunkDuration();
996 }
997 final localPosition = clamped - chunkStart;
998
999 if (_player.currentIndex != targetChunk) {
1000 await _player.seek(Duration.zero, index: targetChunk);
1001 }
1002 if (localPosition > Duration.zero) {
1003 await _player.seek(localPosition);
1004 } else {
1005 await _player.seek(Duration.zero);
1006 }
1007 _currentChunkIndex = targetChunk;
1008 state = state.copyWith(currentChunkIndex: targetChunk);
1009 _updateChunkOffset();
1010 _updatePlaybackProgress();
1011 } finally {
1012 state = state.copyWith(isSeekPending: false);
1013 }
1014 }
1015
1016 Future<void> skipForward() async {
1017 if (state.activeEngine == EngineId.system) return;
1018 if (!state.isSpeaking && !state.isPaused) return;
1019 final skip = Duration(seconds: ref.read(settingsProvider).ttsSkipSeconds);
1020 await seekTo(_computeGlobalPosition() + skip);
1021 }
1022
1023 Future<void> skipBackward() async {
1024 if (state.activeEngine == EngineId.system) return;
1025 if (!state.isSpeaking && !state.isPaused) return;
1026 final skip = Duration(seconds: ref.read(settingsProvider).ttsSkipSeconds);
1027 await seekTo(_computeGlobalPosition() - skip);
1028 }
1029
1030 void _enqueueNextChunks() {
1031 if (_workerSendPort == null || _currentEngine == null) return;
1032
1033 final settings = ref.read(settingsProvider);
1034 final availableVoices = voicesForEngine(_currentEngine!);
1035 final voiceId =
1036 _currentVoice ??
1037 settings.ttsVoiceId ??
1038 (availableVoices.isNotEmpty ? availableVoices.first.id : null);
1039
1040 while (_lastEnqueuedChunk < _chunks.length - 1 &&
1041 _lastEnqueuedChunk < _currentChunkIndex + _lookAheadCount) {
1042 _lastEnqueuedChunk++;
1043 final chunkIndex = _lastEnqueuedChunk;
1044 final text = _chunks[chunkIndex];
1045
1046 Log.debug('Enqueuing synthesis for chunk $chunkIndex');
1047 _workerSendPort!.send(
1048 TtsSynthesizeRequest(
1049 text: text,
1050 voiceId: voiceId ?? '',
1051 speed: settings.ttsSpeed,
1052 chunkIndex: chunkIndex,
1053 sessionId: _currentSessionId,
1054 ),
1055 );
1056 }
1057 }
1058
1059 void pause() {
1060 if (state.isPaused || !state.isSpeaking) return;
1061 if (state.activeEngine == EngineId.system) {
1062 _flutterTts?.pause();
1063 } else {
1064 _player.pause();
1065 }
1066 state = state.copyWith(isPaused: true);
1067 }
1068
1069 void resume() {
1070 if (!state.isPaused) return;
1071 if (state.activeEngine == EngineId.system) {
1072 unawaited(
1073 _flutterTts?.speak(_systemFullText ?? state.playingContent ?? ''),
1074 );
1075 } else {
1076 _player.play();
1077 }
1078 state = state.copyWith(isPaused: false);
1079 }
1080
1081 void togglePauseResume() {
1082 if (state.isPaused) {
1083 resume();
1084 } else if (state.isSpeaking) {
1085 pause();
1086 }
1087 }
1088
1089 Future<void> stop() async {
1090 _isStopping = true;
1091 _systemSpeakGeneration++;
1092 if (_isPlayerDisposed) {
1093 _isStopping = false;
1094 return;
1095 }
1096 try {
1097 await _player.stop();
1098 await _player.clearAudioSources();
1099 } catch (_) {}
1100 await _flutterTts?.stop();
1101 _cleanupSessionFiles(_currentSessionId, _chunks.length);
1102 _resetProgress();
1103 state = state.copyWith(
1104 isSpeaking: false,
1105 isPaused: false,
1106 isInitializing: false,
1107 playingContent: null,
1108 isPreview: false,
1109 clearPlayingTarget: true,
1110 resetProgress: true,
1111 chunks: const [],
1112 currentChunkIndex: -1,
1113 canSeek: false,
1114 );
1115 _configureAudioSessionForSpeech();
1116 _isStopping = false;
1117 }
1118
1119 Future<void> setPlaybackSpeed(double speed) async {
1120 if (!playbackSpeedOptions.contains(speed)) return;
1121 state = state.copyWith(playbackSpeed: speed);
1122 if (state.activeEngine == EngineId.system) {
1123 await _applySystemSpeechRate();
1124 return;
1125 }
1126 if (!state.isSpeaking && !state.isPaused) return;
1127 try {
1128 await _player.setSpeed(speed);
1129 } catch (e) {
1130 Log.error('Failed to set playback speed: $e');
1131 }
1132 }
1133
1134 void cyclePlaybackSpeed() {
1135 final options = playbackSpeedOptions;
1136 final currentIndex = options.indexOf(state.playbackSpeed);
1137 final nextIndex = currentIndex < 0
1138 ? 2
1139 : (currentIndex + 1) % options.length;
1140 unawaited(setPlaybackSpeed(options[nextIndex]));
1141 }
1142
1143 Future<void> _applySystemSpeechRate() async {
1144 if (_flutterTts == null) return;
1145 final settings = ref.read(settingsProvider);
1146 await _flutterTts!.setSpeechRate(
1147 (settings.ttsSpeed * state.playbackSpeed * 0.5).clamp(0.0, 1.0),
1148 );
1149 }
1150
1151 Future<void> _playFromCache({
1152 required bool isPreview,
1153 required String messageId,
1154 required String? conversationId,
1155 required EngineId engineId,
1156 required String text,
1157 }) async {
1158 final cache = _messageAudioCache!;
1159 _resetProgress();
1160 _chunks = List<String>.from(cache.chunks);
1161 _currentChunkIndex = 0;
1162 state = state.copyWith(chunks: _chunks, currentChunkIndex: 0);
1163 _currentVoice = cache.voice;
1164 _synthesizedChunks.clear();
1165 _synthesizedChunks.addAll(cache.synthesizedChunks);
1166 _chunkDurations.clear();
1167 _chunkDurations.addAll(cache.chunkDurations);
1168 _totalDuration = Duration.zero;
1169 for (final duration in _chunkDurations.values) {
1170 _totalDuration += duration;
1171 }
1172 _lastEnqueuedChunk = _chunks.length - 1;
1173 _currentSessionId++;
1174 _playbackSessionId = _currentSessionId;
1175 _isPreview = isPreview;
1176 _playlistBuffer.clear();
1177 _nextPlaylistIndexToAdd = 0;
1178 await _player.stop();
1179
1180 state = state.copyWith(
1181 isSpeaking: true,
1182 isPaused: false,
1183 isInitializing: true,
1184 error: null,
1185 activeEngine: engineId,
1186 playingContent: text,
1187 playingMessageId: messageId,
1188 playingConversationId: conversationId,
1189 isPreview: isPreview,
1190 duration: _totalDuration,
1191 );
1192
1193 for (var i = 0; i < _chunks.length; i++) {
1194 final bytes = _synthesizedChunks[i];
1195 if (bytes != null && bytes.isNotEmpty) {
1196 await _addChunkToPlaylist(i, bytes);
1197 }
1198 }
1199
1200 state = state.copyWith(isInitializing: false, canSeek: true);
1201 if (!state.isPaused) {
1202 await _player.play();
1203 }
1204 }
1205
1206 void _saveMessageAudioCache() {
1207 final messageId = state.playingMessageId;
1208 final text = state.playingContent;
1209 if (state.isPreview || messageId == null || text == null) return;
1210 if (_chunks.isEmpty) return;
1211
1212 final settings = ref.read(settingsProvider);
1213 final availableVoices = voicesForEngine(
1214 state.activeEngine ?? _currentEngine ?? EngineId.kitten,
1215 );
1216 final resolvedVoice =
1217 _currentVoice ??
1218 settings.ttsVoiceId ??
1219 (availableVoices.isNotEmpty ? availableVoices.first.id : null);
1220
1221 for (var i = 0; i < _chunks.length; i++) {
1222 final bytes = _synthesizedChunks[i];
1223 if (bytes == null || bytes.isEmpty) return;
1224 }
1225
1226 _messageAudioCache = _MessageAudioCache(
1227 messageId: messageId,
1228 text: text,
1229 engine: state.activeEngine ?? _currentEngine ?? EngineId.kitten,
1230 voice: resolvedVoice,
1231 chunks: List<String>.from(_chunks),
1232 synthesizedChunks: Map<int, Uint8List>.from(_synthesizedChunks),
1233 chunkDurations: Map<int, Duration>.from(_chunkDurations),
1234 );
1235 }
1236
1237 Future<bool> downloadCurrentAudio() async {
1238 if (state.activeEngine == EngineId.system || state.isPreview) {
1239 return false;
1240 }
1241 if (!state.isSpeaking && !state.isPaused) return false;
1242
1243 try {
1244 final wavFiles = <Uint8List>[];
1245 for (var i = 0; i < _chunks.length; i++) {
1246 final cached = _synthesizedChunks[i];
1247 if (cached != null && cached.isNotEmpty) {
1248 wavFiles.add(cached);
1249 continue;
1250 }
1251 final tempFile = File(
1252 '${Directory.systemTemp.path}/tts_chunk_${_currentSessionId}_$i.wav',
1253 );
1254 if (tempFile.existsSync()) {
1255 wavFiles.add(await tempFile.readAsBytes());
1256 }
1257 }
1258
1259 if (wavFiles.isEmpty) return false;
1260
1261 final merged = mergeWavFiles(wavFiles);
1262 final timestamp = DateTime.now().millisecondsSinceEpoch;
1263 final saved = await SafeFilePicker.saveFile(
1264 dialogTitle: 'Save TTS audio',
1265 fileName: 'localmind_tts_$timestamp.wav',
1266 type: FileType.custom,
1267 allowedExtensions: const ['wav'],
1268 bytes: merged,
1269 );
1270 return saved != null;
1271 } catch (e, st) {
1272 Log.warning('Failed to download TTS audio: $e\n$st');
1273 return false;
1274 }
1275 }
1276
1277 Future<void> previewVoice(Voice voice) async {
1278 await speak(
1279 ttsPreviewSample,
1280 engineId: voice.engine,
1281 voiceId: voice.id,
1282 isPreview: true,
1283 );
1284 }
1285 }
1286
1287 const ttsPreviewSample = "Hello, this is a preview of the selected voice";
1288
1289 class _MessageAudioCache {
1290 const _MessageAudioCache({
1291 required this.messageId,
1292 required this.text,
1293 required this.engine,
1294 required this.voice,
1295 required this.chunks,
1296 required this.synthesizedChunks,
1297 required this.chunkDurations,
1298 });
1299
1300 final String messageId;
1301 final String text;
1302 final EngineId engine;
1303 final String? voice;
1304 final List<String> chunks;
1305 final Map<int, Uint8List> synthesizedChunks;
1306 final Map<int, Duration> chunkDurations;
1307 }