diff --git a/lib/features/chat/services/text_to_speech_service.dart b/lib/features/chat/services/text_to_speech_service.dart index 54e4507..c807475 100644 --- a/lib/features/chat/services/text_to_speech_service.dart +++ b/lib/features/chat/services/text_to_speech_service.dart @@ -689,43 +689,66 @@ class TextToSpeechService { } List _splitForTts(String text) { - // Normalize whitespace - final normalized = text.replaceAll(RegExp(r"\s+"), ' ').trim(); - if (normalized.isEmpty) return const []; + // Mirrors OpenWebUI's extractSentencesForAudio implementation + // See: src/lib/utils/index.ts lines 953-970, 907-928 - // Split on sentence-ending punctuation while keeping the delimiter - final parts = []; - final sentenceRegex = RegExp(r"(.+?[\.!?]+)(\s+|\$)"); - int index = 0; - for (final match in sentenceRegex.allMatches('$normalized ')) { - final s = match.group(1) ?? ''; - if (s.trim().isNotEmpty) parts.add(s.trim()); - index = match.end; - } - if (index < normalized.length) { - final tail = normalized.substring(index).trim(); - if (tail.isNotEmpty) parts.add(tail); - } + // 1. Preserve code blocks (replace with placeholders) + final codeBlocks = []; + var processed = text; + var codeBlockIndex = 0; - // Fallback to length-based splits for very long segments - const maxLen = 300; - final chunks = []; - for (final p in parts.isEmpty ? [normalized] : parts) { - if (p.length <= maxLen) { - chunks.add(p); + // Match triple backticks code blocks + final codeBlockRegex = RegExp(r'```[\s\S]*?```', multiLine: true); + processed = processed.replaceAllMapped(codeBlockRegex, (match) { + final placeholder = '\u0000$codeBlockIndex\u0000'; + codeBlocks.add(match.group(0)!); + codeBlockIndex++; + return placeholder; + }); + + // 2. Split on sentence-ending punctuation: .!? + // OpenWebUI uses: /(?<=[.!?])\s+/ + final sentences = processed + .split(RegExp(r'(?<=[.!?])\s+')) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + // 3. Restore code blocks from placeholders + final restoredSentences = sentences + .map((sentence) { + return sentence.replaceAllMapped(RegExp(r'\u0000(\d+)\u0000'), ( + match, + ) { + final idx = int.parse(match.group(1)!); + return idx < codeBlocks.length ? codeBlocks[idx] : ''; + }); + }) + .where((s) => s.isNotEmpty) + .toList(); + + // 4. Merge short sentences (< 4 words OR < 50 chars) + // OpenWebUI logic from extractSentencesForAudio + final mergedChunks = []; + for (final sentence in restoredSentences) { + if (mergedChunks.isEmpty) { + mergedChunks.add(sentence); } else { - // Try splitting on commas/spaces - var remaining = p; - while (remaining.length > maxLen) { - int cut = remaining.lastIndexOf(RegExp(r",\s|\s"), maxLen); - cut = cut <= 0 ? maxLen : cut; - chunks.add(remaining.substring(0, cut).trim()); - remaining = remaining.substring(cut).trim(); + final lastIndex = mergedChunks.length - 1; + final previousText = mergedChunks[lastIndex]; + final wordCount = previousText.split(RegExp(r'\s+')).length; + final charCount = previousText.length; + + // Merge if previous chunk is too short + if (wordCount < 4 || charCount < 50) { + mergedChunks[lastIndex] = '$previousText $sentence'; + } else { + mergedChunks.add(sentence); } - if (remaining.isNotEmpty) chunks.add(remaining); } } - return chunks; + + return mergedChunks.isEmpty ? [text.trim()] : mergedChunks; } Future _configurePreferredVoice() async { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8d1798c..a2c974f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1844,6 +1844,18 @@ abstract class AppLocalizations { /// **'Connect to a server with transcription enabled to use this option.'** String get sttServerUnavailableWarning; + /// Label for the silence duration setting in server speech-to-text. + /// + /// In en, this message translates to: + /// **'Silence Duration'** + String get sttSilenceDuration; + + /// Description for the silence duration slider in server speech-to-text settings. + /// + /// In en, this message translates to: + /// **'Time to wait after silence before auto-stopping recording'** + String get sttSilenceDurationDescription; + /// Label for selecting the text-to-speech engine. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 655f1d3..58fb7f9 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -966,6 +966,13 @@ class AppLocalizationsDe extends AppLocalizations { String get sttServerUnavailableWarning => 'Verbinde dich mit einem Server mit aktivierter Transkription, um diese Option zu nutzen.'; + @override + String get sttSilenceDuration => 'Stille-Dauer'; + + @override + String get sttSilenceDurationDescription => + 'Zeit nach Stille warten, bevor die Aufnahme automatisch gestoppt wird'; + @override String get ttsEngineLabel => 'Engine'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c163b7c..7f2baba 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -960,6 +960,13 @@ class AppLocalizationsEn extends AppLocalizations { String get sttServerUnavailableWarning => 'Connect to a server with transcription enabled to use this option.'; + @override + String get sttSilenceDuration => 'Silence Duration'; + + @override + String get sttSilenceDurationDescription => + 'Time to wait after silence before auto-stopping recording'; + @override String get ttsEngineLabel => 'Engine'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 9871042..97abbd8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -975,6 +975,13 @@ class AppLocalizationsFr extends AppLocalizations { String get sttServerUnavailableWarning => 'Connectez-vous à un serveur avec la transcription activée pour utiliser cette option.'; + @override + String get sttSilenceDuration => 'Durée du silence'; + + @override + String get sttSilenceDurationDescription => + 'Temps d\'attente après le silence avant d\'arrêter automatiquement l\'enregistrement'; + @override String get ttsEngineLabel => 'Moteur'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 5a731ae..f47fe4d 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -964,6 +964,13 @@ class AppLocalizationsIt extends AppLocalizations { String get sttServerUnavailableWarning => 'Collegati a un server con la trascrizione abilitata per usare questa opzione.'; + @override + String get sttSilenceDuration => 'Durata del silenzio'; + + @override + String get sttSilenceDurationDescription => + 'Tempo di attesa dopo il silenzio prima di fermare automaticamente la registrazione'; + @override String get ttsEngineLabel => 'Motore';