diff --git a/preload.py b/preload.py index 0c8118e49..2494a7091 100644 --- a/preload.py +++ b/preload.py @@ -1,6 +1,7 @@ import asyncio from python.helpers import runtime, whisper, settings from python.helpers.print_style import PrintStyle +from python.helpers import kokoro_tts import models PrintStyle().print("Running preload...") @@ -24,17 +25,22 @@ async def preload(): try: # Use the new LiteLLM-based model system emb_mod = models.get_embedding_model( - models.ModelProvider.HUGGINGFACE, - set["embed_model_name"] + models.ModelProvider.HUGGINGFACE, set["embed_model_name"] ) emb_txt = await emb_mod.aembed_query("test") return emb_txt except Exception as e: PrintStyle().error(f"Error in preload_embedding: {e}") + # preload kokoro tts model if enabled + async def preload_kokoro(): + try: + return await kokoro_tts.preload() + except Exception as e: + PrintStyle().error(f"Error in preload_kokoro: {e}") # async tasks to preload - tasks = [preload_whisper(), preload_embedding()] + tasks = [preload_whisper(), preload_embedding(), preload_kokoro()] await asyncio.gather(*tasks, return_exceptions=True) PrintStyle().print("Preload completed") diff --git a/python/api/synthesize.py b/python/api/synthesize.py new file mode 100644 index 000000000..4d33254f8 --- /dev/null +++ b/python/api/synthesize.py @@ -0,0 +1,92 @@ +# api/synthesize.py + +import re +from python.helpers.api import ApiHandler +from flask import Request, Response + +from python.helpers import runtime, settings, kokoro_tts + +class Synthesize(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: + text = input.get("text", "") + ctxid = input.get("ctxid", "") + + context = self.get_context(ctxid) + if await kokoro_tts.is_downloading(): + context.log.log(type="info", content="Kokoro TTS model is currently being downloaded, please wait...") + + try: + # Clean and chunk text for long responses + cleaned_text = self._clean_text(text) + chunks = self._chunk_text(cleaned_text) + + if len(chunks) == 1: + # Single chunk - return as before + audio = await kokoro_tts.synthesize_sentences(chunks) + return {"audio": audio, "success": True} + else: + # Multiple chunks - return as sequence + audio_parts = [] + for chunk in chunks: + chunk_audio = await kokoro_tts.synthesize_sentences([chunk]) + audio_parts.append(chunk_audio) + return {"audio_parts": audio_parts, "success": True} + except Exception as e: + return {"error": str(e), "success": False} + + def _clean_text(self, text: str) -> str: + """Clean text by removing markdown, tables, code blocks, and other formatting""" + # Remove code blocks + text = re.sub(r'```[\s\S]*?```', '', text) + text = re.sub(r'`[^`]*`', '', text) + + # Remove markdown links + text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text) + + # Remove markdown formatting + text = re.sub(r'[*_#]+', '', text) + + # Remove tables (basic cleanup) + text = re.sub(r'\|[^\n]*\|', '', text) + + # Remove extra whitespace and newlines + text = re.sub(r'\n+', ' ', text) + text = re.sub(r'\s+', ' ', text) + + # Remove URLs + text = re.sub(r'https?://[^\s]+', '', text) + + # Remove email addresses + text = re.sub(r'\S+@\S+', '', text) + + return text.strip() + + def _chunk_text(self, text: str) -> list[str]: + """Split text into manageable chunks for TTS""" + # If text is short enough, return as single chunk + if len(text) <= 300: + return [text] + + # Split into sentences first + sentences = re.split(r'(?<=[.!?])\s+', text) + + chunks = [] + current_chunk = "" + + for sentence in sentences: + sentence = sentence.strip() + if not sentence: + continue + + # If adding this sentence would make chunk too long, start new chunk + if current_chunk and len(current_chunk + " " + sentence) > 300: + chunks.append(current_chunk.strip()) + current_chunk = sentence + else: + current_chunk += (" " if current_chunk else "") + sentence + + # Add the last chunk if it has content + if current_chunk.strip(): + chunks.append(current_chunk.strip()) + + return chunks if chunks else [text] \ No newline at end of file diff --git a/python/helpers/kokoro_tts.py b/python/helpers/kokoro_tts.py new file mode 100644 index 000000000..20f6036f0 --- /dev/null +++ b/python/helpers/kokoro_tts.py @@ -0,0 +1,91 @@ +# kokoro_tts.py + +import base64 +import io +import warnings +import asyncio +import soundfile as sf +from python.helpers import runtime +from python.helpers.print_style import PrintStyle + +warnings.filterwarnings("ignore", category=FutureWarning) + +_pipeline = None +_voice = "am_puck,am_onyx" +_speed = 1.1 +is_updating_model = False + +async def preload(): + try: + return await runtime.call_development_function(_preload) + except Exception as e: + if not runtime.is_development(): + raise e + # Fallback to direct execution if RFC fails in development + PrintStyle.standard("RFC failed, falling back to direct execution...") + return await _preload() + +async def _preload(): + global _pipeline, is_updating_model + + while is_updating_model: + await asyncio.sleep(0.1) + + try: + is_updating_model = True + if not _pipeline: + PrintStyle.standard("Loading Kokoro TTS model...") + from kokoro import KPipeline + _pipeline = KPipeline(lang_code='a') + finally: + is_updating_model = False + +async def is_downloading(): + try: + return await runtime.call_development_function(_is_downloading) + except Exception as e: + if not runtime.is_development(): + raise e + # Fallback to direct execution if RFC fails in development + return _is_downloading() + +def _is_downloading(): + return is_updating_model + +async def synthesize_sentences(sentences: list[str]): + """Generate audio for multiple sentences and return concatenated base64 audio""" + try: + return await runtime.call_development_function(_synthesize_sentences, sentences) + except Exception as e: + if not runtime.is_development(): + raise e + # Fallback to direct execution if RFC fails in development + return await _synthesize_sentences(sentences) + +async def _synthesize_sentences(sentences: list[str]): + await _preload() + + combined_audio = [] + + try: + for sentence in sentences: + if sentence.strip(): + segments = _pipeline(sentence.strip(), voice=_voice, speed=_speed) + segment_list = list(segments) + + for segment in segment_list: + audio_tensor = segment.audio + audio_numpy = audio_tensor.detach().cpu().numpy() + combined_audio.extend(audio_numpy) + + # Convert combined audio to bytes + buffer = io.BytesIO() + sf.write(buffer, combined_audio, 24000, format='WAV') + audio_bytes = buffer.getvalue() + + # Return base64 encoded audio + return base64.b64encode(audio_bytes).decode('utf-8') + + except Exception as e: + PrintStyle.error(f"Error in Kokoro TTS synthesis: {e}") + raise \ No newline at end of file diff --git a/python/helpers/settings.py b/python/helpers/settings.py index 1b806de76..2b1ba258f 100644 --- a/python/helpers/settings.py +++ b/python/helpers/settings.py @@ -692,10 +692,12 @@ def convert_out(settings: Settings) -> SettingsOutput: ) stt_section: SettingsSection = { - "id": "stt", - "title": "Speech to Text", - "description": "Voice transcription preferences and server turn detection settings.", - "fields": stt_fields, + # TTS fields + tts_fields: list[SettingsField] = [] + + tts_fields.append( + { + "id": "tts_enabled", "tab": "agent", } @@ -825,6 +827,7 @@ def convert_out(settings: Settings) -> SettingsOutput: browser_model_section, embed_model_section, stt_section, + speech_section, api_keys_section, auth_section, mcp_client_section, @@ -1016,6 +1019,7 @@ def get_default_settings() -> Settings: stt_silence_threshold=0.3, stt_silence_duration=1000, stt_waiting_timeout=2000, + tts_enabled=False, mcp_servers='{\n "mcpServers": {}\n}', mcp_client_init_timeout=10, mcp_client_tool_timeout=120, diff --git a/requirements.txt b/requirements.txt index 2a8f994b9..38e741d80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,3 +38,5 @@ pdf2image==1.17.0 crontab==1.0.1 pathspec>=0.12.1 psutil>=7.0.0 +kokoro>=0.9.2 +soundfile \ No newline at end of file diff --git a/webui/index.html b/webui/index.html index b782da944..d07691fbd 100644 --- a/webui/index.html +++ b/webui/index.html @@ -100,7 +100,6 @@ - @@ -361,15 +360,12 @@ -