diff --git a/.env.example b/.env.example index 4d02a56..ef66883 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,7 @@ AUDIO_SINK= # TTS (ElevenLabs) ELEVENLABS_API_KEY=your_elevenlabs_api_key_here -ELEVENLABS_MODEL=eleven_flash_v2_5 +ELEVENLABS_MODEL=eleven_turbo_v2_5 COSMO_TTS_VOICE=your_cosmo_voice_id LUSYA_TTS_VOICE=your_lusya_voice_id diff --git a/requirements.txt b/requirements.txt index a510367..9243be7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ requests python-dotenv -numpy +numpy<2 # Audio I/O pyaudio sounddevice -scipy +scipy<1.15 # STT через облако groq diff --git a/satellite/config.py b/satellite/config.py index 612250b..1ef6d17 100644 --- a/satellite/config.py +++ b/satellite/config.py @@ -49,6 +49,7 @@ AGENTS = { "token": GATEWAY_TOKEN, "agent": AGENT, "voice_model": VOICE_MODEL, + "session_key": os.getenv("COSMO_SESSION_KEY", "voice:home:cosmo"), "tts_voice": os.getenv("COSMO_TTS_VOICE", ""), "session": _make_session(GATEWAY_TOKEN), }, @@ -58,6 +59,7 @@ AGENTS = { "token": LUSYA_GATEWAY_TOKEN, "agent": LUSYA_AGENT, "voice_model": LUSYA_VOICE_MODEL, + "session_key": os.getenv("LUSYA_SESSION_KEY", "voice:home:lusya"), "tts_voice": os.getenv("LUSYA_TTS_VOICE", ""), "session": _make_session(LUSYA_GATEWAY_TOKEN), }, diff --git a/satellite/llm.py b/satellite/llm.py index 4646f5f..73bdb84 100644 --- a/satellite/llm.py +++ b/satellite/llm.py @@ -8,8 +8,18 @@ from .config import AGENTS, log from .text import clean_for_speech, find_sentence_end from .tts import speak, play_error_sound -SYSTEM_PROMPT = "Отвечай кратко, 1-2 предложения, без markdown, без эмодзи." +SYSTEM_PROMPT = ( + "Отвечай кратко, 1-2 предложения, без markdown, без эмодзи. " + "Ответ будет озвучен голосом, поэтому: " + "числа пиши прописью (двадцать три, а не 23), " + "единицы измерения пиши полностью (километров в час, а не км/ч), " + "не используй спецсимволы (+, -, /, %, °) — заменяй словами (плюс, минус, из, процентов, градусов). " + "Температуру пиши так: 'плюс девять градусов', а не '+9°C'." +) MAX_HISTORY = int(os.getenv("MAX_HISTORY", "20")) +# "stream" — режем по предложениям (быстро, но рваная интонация) +# "full" — собираем весь ответ, потом TTS (естественно, но пауза перед началом) +TTS_MODE = os.getenv("TTS_MODE", "full") RESET_PATTERNS = re.compile( r"(начни|начать|создай|открой|давай).{0,10}(новую|новый|чистую|чистый).{0,10}(сессию|сессия|диалог|разговор|чат)" @@ -65,7 +75,10 @@ def ask_agent_stream(text: str, conv: "Conversation | None" = None, agent_id: st try: resp = session.post( f"{gateway_url}/v1/chat/completions", - headers={"x-openclaw-model": cfg["voice_model"]}, + headers={ + "x-openclaw-model": cfg["voice_model"], + "x-openclaw-session-key": cfg["session_key"], + }, json={ "model": agent, "stream": True, @@ -115,13 +128,14 @@ def ask_agent_stream(text: str, conv: "Conversation | None" = None, agent_id: st full_text += delta buffer += delta - last_punct = find_sentence_end(buffer, min_len=60) - if last_punct > -1: - sentence = clean_for_speech(buffer[:last_punct + 1]) - if sentence.strip(): - print(f"🔊 Говорю: {sentence}") - speak(sentence, agent_id) - buffer = buffer[last_punct + 1:].lstrip() + if TTS_MODE == "stream": + last_punct = find_sentence_end(buffer, min_len=120) + if last_punct > -1: + sentence = clean_for_speech(buffer[:last_punct + 1]) + if sentence.strip(): + print(f"🔊 Говорю: {sentence}") + speak(sentence, agent_id) + buffer = buffer[last_punct + 1:].lstrip() except (json.JSONDecodeError, KeyError, IndexError): continue @@ -129,17 +143,24 @@ def ask_agent_stream(text: str, conv: "Conversation | None" = None, agent_id: st log.exception("Ошибка при чтении стрима") print(f"⚠️ Стрим прервался: {e}") - # Остаток - if buffer.strip(): - sentence = clean_for_speech(buffer) - if sentence: - speak(sentence, agent_id) - if not full_text: msg = "Не получил ответ, попробуй ещё раз." speak(msg, agent_id) return msg result = clean_for_speech(full_text) + + if TTS_MODE == "full": + # LLM уже доcтримил — озвучиваем весь ответ одним куском с цельной интонацией + if result.strip(): + print(f"🔊 Говорю: {result}") + speak(result, agent_id) + else: + # остаток буфера в stream-режиме + if buffer.strip(): + tail = clean_for_speech(buffer) + if tail: + speak(tail, agent_id) + conv.add_assistant(full_text) return result diff --git a/satellite/text.py b/satellite/text.py index 6a189ff..7a9e3ae 100644 --- a/satellite/text.py +++ b/satellite/text.py @@ -1,12 +1,65 @@ import re +# Единицы измерения со слэшем — раскрываем до чтения слэша +UNIT_SLASH = [ + (r'\bкм\s*/\s*ч\b', 'километров в час'), + (r'\bм\s*/\s*с\b', 'метров в секунду'), + (r'\bкм\s*/\s*с\b', 'километров в секунду'), + (r'\bмб\s*/\s*с\b', 'мегабит в секунду'), + (r'\bгб\s*/\s*с\b', 'гигабит в секунду'), + (r'\bруб\s*/\s*мес\b', 'рублей в месяц'), + (r'\bр\s*/\s*мес\b', 'рублей в месяц'), +] + + def clean_for_speech(text: str) -> str: text = re.sub(r'\*+', '', text) # убрать **жирный** text = re.sub(r'#+\s', '', text) # убрать ## заголовки text = re.sub(r'- ', '', text) # убрать тире списков text = re.sub(r'\[.*?\]\(.*?\)', '', text) # убрать ссылки text = re.sub(r'\n+', '. ', text) # переносы → точки + + # составные единицы со слэшем — до общей замены `/` + for pat, repl in UNIT_SLASH: + text = re.sub(pat, repl, text, flags=re.IGNORECASE) + + # знаки перед числом: "+9", "-3", "±2" + text = re.sub(r'(^|\s)\+(\d)', r'\1плюс \2', text) + text = re.sub(r'(^|\s)-(\d)', r'\1минус \2', text) + text = re.sub(r'±(\d)', r'плюс-минус \1', text) + + # дроби и отношения "12/15" → "12 из 15", "5/10" → "5 из 10" + text = re.sub(r'(\d+)\s*/\s*(\d+)', r'\1 из \2', text) + # одиночный слэш — как союз "или" + text = text.replace('/', ' или ') + + # градусы и прочие символы + text = re.sub(r'°\s*C\b', ' градусов', text, flags=re.IGNORECASE) + text = re.sub(r'°\s*F\b', ' градусов Фаренгейта', text, flags=re.IGNORECASE) + text = text.replace('°', ' градусов') + text = text.replace('%', ' процентов') + text = text.replace('№', ' номер ') + text = text.replace('&', ' и ') + text = text.replace('@', ' собака ') + text = text.replace('×', ' на ') + + # распространённые сокращения в полную форму — иначе TTS буквоедит + abbr = [ + (r'\bт\.\s*е\.', 'то есть'), + (r'\bт\.\s*к\.', 'так как'), + (r'\bт\.\s*д\.', 'так далее'), + (r'\bт\.\s*п\.', 'тому подобное'), + (r'\bи\s*т\.\s*д\.', 'и так далее'), + (r'\bи\s*т\.\s*п\.', 'и тому подобное'), + (r'\bпо-\s*русски\b', 'по-русски'), + ] + for pat, repl in abbr: + text = re.sub(pat, repl, text, flags=re.IGNORECASE) + + # схлопываем дубли пунктуации + text = re.sub(r'([:;,!?])\s*\.', r'\1', text) + text = re.sub(r'\.\s*\.+', '.', text) text = re.sub(r'\s+', ' ', text) # лишние пробелы text = re.sub(r'(\d+)\.(\s)', r'\1\2', text) return text.strip() diff --git a/satellite/tts.py b/satellite/tts.py index 23c40e7..bba7135 100644 --- a/satellite/tts.py +++ b/satellite/tts.py @@ -70,11 +70,11 @@ def _speak_elevenlabs(text: str, agent_id: str): return voice_settings = VoiceSettings( - stability=0.5, - similarity_boost=0.75, - style=0.0, + stability=0.65, # ниже = живее интонация (для multilingual_v2) + similarity_boost=0.6, + style=0.45, # выше = эмоциональнее use_speaker_boost=True, - speed=1.1 # Значение от 0.7 до 1.2. 1.1 — это ускорение на 10% + speed=1.05 ) audio_stream = client.text_to_speech.convert(