Files
smart-speaker/app/main.py
2026-04-09 21:03:02 +03:00

1338 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Smart Speaker - Main Application
"""
import re
import signal
import sys
import time
from datetime import datetime
from collections import deque
from pathlib import Path
import subprocess
import queue
import threading
# Для воспроизведения звуков (mp3)
try:
from pygame import mixer
except Exception as exc:
mixer = None
_MIXER_IMPORT_ERROR = exc
else:
_MIXER_IMPORT_ERROR = None
# Наши модули
from .audio.sound_level import is_volume_command, parse_volume_text, set_volume
from .audio.stt import cleanup as cleanup_stt
from .audio.stt import get_recognizer, listen
from .audio.tts import initialize as init_tts
from .audio.tts import speak
from .audio.tts import was_interrupted as was_tts_interrupted
from .audio.wakeword import (
check_wakeword_once,
wait_for_wakeword,
)
from .audio.wakeword import (
cleanup as cleanup_wakeword,
)
from .audio.wakeword import (
stop_monitoring as stop_wakeword_monitoring,
)
from .core.ai import ask_ai_stream, interpret_assistant_intent, translate_text
from .core.config import (
BASE_DIR,
STT_START_SOUND_PATH,
STT_START_SOUND_VOLUME,
WAKE_WORD,
WAKE_WORD_ALIASES,
)
from .core.cleaner import clean_response
from .core.commands import is_stop_command
from .core.smalltalk import get_smalltalk_response
from .features.alarm import ASK_ALARM_TIME_PROMPT, get_alarm_clock
from .features.stopwatch import get_stopwatch_manager
from .features.timer import ASK_TIMER_TIME_PROMPT, get_timer_manager
from .features.weather import get_weather_report
from .features.music import get_music_controller
from .features.cities_game import get_cities_game
_TRANSLATION_COMMANDS = [
("переведи на английский с русского", "ru", "en"),
("переведи на русский с английского", "en", "ru"),
("переведи на английский язык с русского", "ru", "en"),
("переведи на русский язык с английского", "en", "ru"),
("переведи с русского на английский", "ru", "en"),
("переведи с русского в английский", "ru", "en"),
("переведи с английского на русский", "en", "ru"),
("переведи с английского в русский", "en", "ru"),
("переведи с русского языка", "ru", "en"),
("переведи с английского языка", "en", "ru"),
("переведи на английский язык", "ru", "en"),
("переведи на русский язык", "en", "ru"),
("переведи на английский", "ru", "en"),
("переведи на русский", "en", "ru"),
("переведи с английского", "en", "ru"),
("переведи с русского", "ru", "en"),
("как по-английски", "ru", "en"),
("как по английски", "ru", "en"),
("как по-русски", "en", "ru"),
("как по русски", "en", "ru"),
("translate to english from russian", "ru", "en"),
("translate to russian from english", "en", "ru"),
("translate from russian to english", "ru", "en"),
("translate from english to russian", "en", "ru"),
("translate into english", "ru", "en"),
("translate into russian", "en", "ru"),
("translate to english", "ru", "en"),
("translate to russian", "en", "ru"),
("translate from english", "en", "ru"),
("translate from russian", "ru", "en"),
]
_TRANSLATION_COMMANDS_SORTED = sorted(
_TRANSLATION_COMMANDS, key=lambda item: len(item[0]), reverse=True
)
_TRANSLATION_QUERY_RULES = [
(
re.compile(
r"^как\s+перевод(?:ится|ить)\s+(.+?)\s+с\s+английского(?:\s+на\s+русский)?[?.!]*$",
re.IGNORECASE,
),
"en",
"ru",
),
(
re.compile(
r"^как\s+перевод(?:ится|ить)\s+(.+?)\s+с\s+русского(?:\s+на\s+английский)?[?.!]*$",
re.IGNORECASE,
),
"ru",
"en",
),
(
re.compile(
r"^как\s+будет\s+(.+?)\s+на\s+английском(?:\s+языке)?[?.!]*$",
re.IGNORECASE,
),
"ru",
"en",
),
(
re.compile(
r"^как\s+будет\s+(.+?)\s+на\s+русском(?:\s+языке)?[?.!]*$",
re.IGNORECASE,
),
"en",
"ru",
),
(
re.compile(r"^что\s+значит\s+(.+?)\s+по[-\s]?английски[?.!]*$", re.IGNORECASE),
"ru",
"en",
),
(
re.compile(r"^что\s+значит\s+(.+?)\s+по[-\s]?русски[?.!]*$", re.IGNORECASE),
"en",
"ru",
),
]
_REPEAT_PHRASES = {
"еще раз",
"повтори",
"скажи еще раз",
"что ты сказал",
"повтори пожалуйста",
"waltron еще раз",
"еще раз waltron",
"waltron повтори",
"повтори waltron",
"волтрон еще раз",
"еще раз волтрон",
"волтрон повтори",
"повтори волтрон",
}
_WEATHER_TRIGGERS = (
"погода",
"погоду",
"погоде",
"по погоде",
"что на улице",
"какая температура",
"сколько градусов",
"холодно ли",
"жарко ли",
"нужен ли зонт",
"брать ли зонт",
"прогноз погоды",
"прогноз",
"че там на улице",
"что там на улице",
"как на улице",
"как на улице-то",
)
_CITY_INVALID_WORDS = {
"этом",
"том",
"той",
"тут",
"здесь",
"там",
"всё",
"все",
"всей",
"всего",
"всем",
"всеми",
"городе",
"город",
"село",
"деревня",
"посёлок",
"аул",
"станция",
"область",
"район",
"край",
"республика",
}
_CITY_PATTERNS = [
re.compile(
r"\b(?:в|во)\s+город(?:е|у)?\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})",
re.IGNORECASE,
),
re.compile(
r"\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})",
re.IGNORECASE,
),
re.compile(
r"\bпогода\s+\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})",
re.IGNORECASE,
),
re.compile(
r"\bпогода\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})\s+(?:какая|сейчас|там|сегодня|завтра)\b",
re.IGNORECASE,
),
re.compile(
r"\b(?:какая|как)\s+(?:сейчас\s+)?погода\s+\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})",
re.IGNORECASE,
),
re.compile(
r"\b(?:какой|какова)\s+прогноз(?:\s+погоды)?\s+\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})",
re.IGNORECASE,
),
]
_WEATHER_TRIGGER_PATTERNS = [
re.compile(r"\bпогод(?:а|у|е|ы|ой)\b", re.IGNORECASE),
re.compile(r"\bпрогноз(?:\s+погоды)?\b", re.IGNORECASE),
re.compile(r"\b(?:что|как|че)\s+там\s+на\s+улице\b", re.IGNORECASE),
re.compile(r"\b(?:какая\s+температура|сколько\s+градусов)\b", re.IGNORECASE),
re.compile(r"\b(?:нужен|брать)\s+ли\s+зонт\b", re.IGNORECASE),
]
_TIME_TRIGGER_PATTERNS = [
re.compile(r"\отор(ый|ого)\s+час\b", re.IGNORECASE),
re.compile(r"\bсколько\s+времени\b", re.IGNORECASE),
re.compile(r"\акое\s+сейчас\s+время\b", re.IGNORECASE),
re.compile(r"\оторый\s+сейчас\s+час\b", re.IGNORECASE),
re.compile(r"\bwhat\s+time\b", re.IGNORECASE),
re.compile(r"\bcurrent\s+time\b", re.IGNORECASE),
re.compile(r"\btime\s+is\s+it\b", re.IGNORECASE),
]
_WAKEWORD_PREFIX_RE = re.compile(
rf"^(?:{'|'.join(re.escape(alias) for alias in sorted({WAKE_WORD.lower(), *WAKE_WORD_ALIASES}, key=len, reverse=True))})(?:[\s,.:;!?-]+|$)",
re.IGNORECASE,
)
_CITY_TRAILING_STOP_WORDS = {
"сейчас",
"сегодня",
"завтра",
"там",
"теперь",
"вообще",
"пожалуйста",
}
_SEMANTIC_INTENT_MIN_CONFIDENCE = 0.55
_SEMANTIC_MUSIC_MIN_CONFIDENCE = 0.45
_SEMANTIC_REPEAT_STOP_MIN_CONFIDENCE = 0.72
def signal_handler(sig, frame):
"""Обработчик Ctrl+C."""
print("\n\n👋 Завершение работы...")
print("\n\n👋 Завершение работы...")
try:
cleanup_wakeword() # Остановка Porcupine
except Exception as e:
print(f"Ошибка при остановке wakeword: {e}")
try:
cleanup_stt() # Остановка Deepgram
except Exception as e:
print(f"Ошибка при остановке STT: {e}")
sys.exit(0)
def parse_translation_request(text: str):
"""Проверяет, является ли фраза запросом на перевод."""
text = str(text or "").strip()
if not text:
return None
text_lower = text.lower()
# Список префиксов команд перевода и соответствующих направлений языков.
# Важно: более длинные префиксы должны проверяться первыми (например,
# "переведи с русского на английский" не должен схватиться как "переведи с русского").
for prefix, source_lang, target_lang in _TRANSLATION_COMMANDS_SORTED:
if text_lower.startswith(prefix):
# Отрезаем команду (префикс), оставляем только текст для перевода
rest = text[len(prefix) :].strip()
rest = rest.lstrip(" :—-")
return {
"source_lang": source_lang,
"target_lang": target_lang,
"text": rest,
}
def _clean_payload(raw: str) -> str:
return str(raw or "").strip().lstrip(" :—-").strip(" \"'“”«»")
for pattern, source_lang, target_lang in _TRANSLATION_QUERY_RULES:
match = pattern.match(text)
if not match:
continue
payload = _clean_payload(match.group(1))
if not payload:
return None
return {
"source_lang": source_lang,
"target_lang": target_lang,
"text": payload,
}
# Универсальный fallback: "переведи <текст>" / "translate <text>".
generic_match = re.match(
r"^(?:переведи|перевод|translate)\s+(.+)$",
text,
flags=re.IGNORECASE,
)
if generic_match:
payload = _clean_payload(generic_match.group(1))
if not payload:
return None
# Явное направление, если есть.
if re.search(r"\b(?:на|в|по)\s+англий", text_lower):
source_lang, target_lang = "ru", "en"
payload = re.sub(
r"\s+(?:на|в|по)\s+англий(?:ский|ском|ски)(?:\s+язык(?:е)?)?\s*$",
"",
payload,
flags=re.IGNORECASE,
).strip()
elif re.search(r"\b(?:на|в|по)\s+рус", text_lower):
source_lang, target_lang = "en", "ru"
payload = re.sub(
r"\s+(?:на|в|по)\s+рус(?:ский|ском|ски)(?:\s+язык(?:е)?)?\s*$",
"",
payload,
flags=re.IGNORECASE,
).strip()
else:
has_cyrillic = bool(re.search(r"[а-яё]", payload, flags=re.IGNORECASE))
has_latin = bool(re.search(r"[a-z]", payload, flags=re.IGNORECASE))
if has_latin and not has_cyrillic:
source_lang, target_lang = "en", "ru"
elif has_cyrillic and not has_latin:
source_lang, target_lang = "ru", "en"
elif has_latin and has_cyrillic:
source_lang, target_lang = "en", "ru"
else:
source_lang, target_lang = "ru", "en"
if not payload:
return None
return {
"source_lang": source_lang,
"target_lang": target_lang,
"text": payload,
}
return None
def _strip_wakeword_prefix(text: str) -> str:
normalized = str(text or "").strip()
if not normalized:
return ""
return _WAKEWORD_PREFIX_RE.sub("", normalized, count=1).strip()
def _extract_requested_city(text: str) -> str | None:
lowered = _strip_wakeword_prefix(text).lower().replace("ё", "е")
lowered = re.sub(r"[.!?,;:…]+", " ", lowered)
lowered = re.sub(r"\s+", " ", lowered).strip()
if not lowered:
return None
for pattern in _CITY_PATTERNS:
match = pattern.search(lowered)
if not match:
continue
candidate = match.group(1).strip(" -")
if not candidate:
continue
parts = [part for part in candidate.split() if part]
while parts and parts[-1] in _CITY_TRAILING_STOP_WORDS:
parts.pop()
while parts and parts[0] in _CITY_INVALID_WORDS:
parts.pop(0)
if not parts:
continue
if any(part in _CITY_INVALID_WORDS for part in parts):
continue
cleaned_candidate = " ".join(parts)
if len(cleaned_candidate) <= 1:
continue
return cleaned_candidate
return None
def _is_time_request(text: str) -> bool:
cleaned = _strip_wakeword_prefix(text).strip()
if not cleaned:
return False
for pattern in _TIME_TRIGGER_PATTERNS:
if pattern.search(cleaned):
return True
return False
def _has_weather_trigger(text: str) -> bool:
lowered = _strip_wakeword_prefix(text).lower().replace("ё", "е")
if any(trigger in lowered for trigger in _WEATHER_TRIGGERS):
return True
return any(pattern.search(lowered) for pattern in _WEATHER_TRIGGER_PATTERNS)
def main():
"""Точка входа."""
print("=" * 50)
print("=" * 50)
print("🔊 УМНАЯ КОЛОНКА")
print("=" * 50)
print(f"Скажите '{WAKE_WORD}' для активации")
print("Нажмите Ctrl+C для выхода")
print("=" * 50)
print()
# Устанавливаем перехватчик Ctrl+C
signal.signal(signal.SIGINT, signal_handler)
print("⏳ Инициализация моделей...")
# Инициализация звуковой системы для эффектов (опционально)
ding_sound = None
stt_start_sound_path = Path(STT_START_SOUND_PATH).expanduser()
if mixer is None:
print(
"Warning: pygame mixer not available; sound effects disabled."
f" ({_MIXER_IMPORT_ERROR})"
)
else:
try:
mixer.init()
except Exception as exc:
print(f"Warning: pygame mixer init failed; sound effects disabled. ({exc})")
else:
# Приоритет: внешний звук для старта STT (обычно в ~/Music),
# fallback: assets/sounds/ding.wav
candidate_paths = [
stt_start_sound_path,
BASE_DIR / "assets" / "sounds" / "ding.wav",
]
for candidate in candidate_paths:
if not candidate or not Path(candidate).exists():
continue
try:
ding_sound = mixer.Sound(str(candidate))
ding_sound.set_volume(float(STT_START_SOUND_VOLUME))
break
except Exception as exc:
print(f"⚠️ Не удалось загрузить звук {candidate}: {exc}")
ding_sound = None
def _play_stt_start_sfx_fallback():
"""Fallback для систем без pygame/mixer или без поддержки mp3."""
if not stt_start_sound_path.exists():
return False
# mpg123 scale factor: 0..32768 (примерно). Делаем тихо.
scale = int(32768 * float(STT_START_SOUND_VOLUME))
scale = max(0, min(32768, scale))
try:
subprocess.run(
["mpg123", "-q", "-f", str(scale), str(stt_start_sound_path)],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return True
except FileNotFoundError:
return False
except Exception:
return False
def play_stt_start_sfx():
"""Проиграть короткий звук старта STT синхронно (чтобы не попасть в распознавание)."""
if ding_sound is not None:
try:
ch = ding_sound.play()
# Если pygame не вернул канал, ничего не ждем.
if ch is None:
return True
# Ждем завершения звука.
while ch.get_busy():
time.sleep(0.01)
return True
except Exception:
pass
return _play_stt_start_sfx_fallback()
text_mode = False
text_mode_reason = ""
response_wakeword_interrupted = False
play_followup_activation_sfx = False
output_interrupt_guard_seconds = 0.0
audio_settle_after_tts_seconds = 0.35
last_tts_finished_at = 0.0
wake_interrupt_hits_required = 1
wake_interrupt_hit_window_seconds = 0.22
try:
get_recognizer().initialize() # Подключение к Deepgram
except Exception as exc:
# На некоторых системах PipeWire/Pulse доступны, но PortAudio не видит вход.
# Чтобы ассистент не падал, включаем текстовый режим через stdin.
lowered = str(exc).lower()
if "no input devices found" in lowered or "audio input initialization failed" in lowered:
text_mode = True
text_mode_reason = str(exc)
print("⚠️ Микрофон недоступен для PortAudio. Включен текстовый режим.")
print(f" Причина: {text_mode_reason}")
print(" Вводите команды в терминале (без wake word).")
else:
raise
def output_response(
text: str,
check_interrupt=None,
language: str = "ru",
allow_wake_interrupt: bool = True,
interrupt_guard_seconds: float | None = None,
) -> bool:
nonlocal response_wakeword_interrupted
nonlocal play_followup_activation_sfx
nonlocal last_tts_finished_at
if text_mode:
cleaned = clean_response(text, language=language)
if cleaned:
print(f"🤖 {cleaned}")
return True
if check_interrupt is not None:
effective_interrupt = check_interrupt
elif allow_wake_interrupt:
effective_interrupt = check_wakeword_once
else:
effective_interrupt = None
if effective_interrupt is None:
# Важно: None внутри TTS включает дефолтный wakeword-checker.
# Здесь нужно полностью отключить прерывания.
completed = speak(
text,
check_interrupt=lambda: False,
language=language,
)
last_tts_finished_at = time.monotonic()
return completed
guard_seconds = output_interrupt_guard_seconds
if interrupt_guard_seconds is not None:
try:
guard_seconds = max(0.0, float(interrupt_guard_seconds))
except (TypeError, ValueError):
guard_seconds = output_interrupt_guard_seconds
arm_interrupt_at = time.monotonic() + guard_seconds
wake_hits = 0
wake_last_hit_at = 0.0
def guarded_interrupt():
nonlocal wake_hits, wake_last_hit_at
if time.monotonic() < arm_interrupt_at:
return False
try:
detected = bool(effective_interrupt())
except Exception:
return False
if not detected:
if (
wake_last_hit_at > 0
and time.monotonic() - wake_last_hit_at
> wake_interrupt_hit_window_seconds
):
wake_hits = 0
wake_last_hit_at = 0.0
return False
now = time.monotonic()
if (
wake_last_hit_at > 0
and now - wake_last_hit_at <= wake_interrupt_hit_window_seconds
):
wake_hits += 1
else:
wake_hits = 1
wake_last_hit_at = now
return wake_hits >= wake_interrupt_hits_required
completed = speak(text, check_interrupt=guarded_interrupt, language=language)
last_tts_finished_at = time.monotonic()
if not completed and was_tts_interrupted():
response_wakeword_interrupted = True
play_followup_activation_sfx = True
return completed
def settle_audio_after_tts() -> None:
nonlocal last_tts_finished_at
if last_tts_finished_at <= 0:
return
elapsed = time.monotonic() - last_tts_finished_at
remaining = audio_settle_after_tts_seconds - elapsed
if remaining > 0:
time.sleep(remaining)
init_tts() # Загрузка нейросети для синтеза речи (Silero)
alarm_clock = get_alarm_clock() # Загрузка будильников
stopwatch_manager = get_stopwatch_manager() # Загрузка секундомеров
timer_manager = get_timer_manager() # Загрузка таймеров
cities_game = get_cities_game() # Игра "Города"
music_controller = get_music_controller() # Контроллер музыки
print()
# История чата
chat_history = deque(maxlen=20)
# Последний ответ ассистента
last_response = None
# Режим диалога (без wake word)
skip_wakeword = False
followup_idle_timeout_seconds = 3.7
# Контекст уточнения времени для таймера/будильника
pending_time_target = None
# Проверка здоровья STT
last_stt_check = time.time()
# ГЛАВНЫЙ ЦИКЛ
while True:
# Периодическая проверка STT
if time.time() - last_stt_check > 600:
try:
recognizer = get_recognizer()
if hasattr(recognizer, "check_connection_health"):
recognizer.check_connection_health()
last_stt_check = time.time()
except Exception as e:
print(f"Ошибка при проверке STT: {e}")
wakeword_music_ducked = False
try:
if text_mode:
try:
user_text = input("⌨️ Команда> ").strip()
except EOFError:
print("\nВвод закрыт, завершаю работу.")
break
except KeyboardInterrupt:
signal_handler(None, None)
if not user_text:
continue
# Проверяем таймеры
if timer_manager.check_timers():
skip_wakeword = False
continue
# Проверяем будильники
if alarm_clock.check_alarms():
skip_wakeword = False
continue
if not text_mode:
if response_wakeword_interrupted:
skip_wakeword = True
response_wakeword_interrupted = False
wakeword_music_ducked = music_controller.duck_for_wakeword()
# Ждем wake word
if not skip_wakeword:
detected = wait_for_wakeword(timeout=0.5)
# Если время вышло — проверяем будильники
if not detected:
continue
wakeword_music_ducked = music_controller.duck_for_wakeword()
# Звук активации
play_stt_start_sfx()
# Слушаем команду
try:
stop_wakeword_monitoring()
settle_audio_after_tts()
user_text = listen(
timeout_seconds=5.0,
detection_timeout=7.0,
fast_stop=True,
)
except Exception as e:
print(f"Ошибка при прослушивании: {e}")
print("Переинициализация STT...")
try:
cleanup_stt()
get_recognizer().initialize()
except Exception as init_error:
print(f"Ошибка переинициализации STT: {init_error}")
continue # Продолжаем цикл
else:
# Follow-up режим — без wake word
print(f"👂 Слушаю ({followup_idle_timeout_seconds:.1f} сек)...")
try:
stop_wakeword_monitoring()
settle_audio_after_tts()
# Тот же сигнал, что используется при wake word:
# теперь всегда перед стартом STT в продолжении диалога.
play_stt_start_sfx()
play_followup_activation_sfx = False
user_text = listen(
timeout_seconds=7.0,
detection_timeout=followup_idle_timeout_seconds,
fast_stop=True,
)
except Exception as e:
print(f"Ошибка при прослушивании: {e}")
print("Переинициализация STT...")
try:
cleanup_stt()
get_recognizer().initialize()
except Exception as init_error:
print(f"Ошибка переинициализации STT: {init_error}")
skip_wakeword = False
continue
if not user_text:
# Молчание — возвращаемся к ожиданию
print("user was not talking")
skip_wakeword = False
continue
# Анализ текста
if not user_text:
if not text_mode:
output_response(
"Не расслышал, повторите, пожалуйста.",
allow_wake_interrupt=False,
)
skip_wakeword = True
else:
skip_wakeword = False
continue
# Проверка на команду "Стоп"
if is_stop_command(user_text):
music_controller = get_music_controller()
music_stop_response = music_controller.pause_for_stop_word()
if music_stop_response:
print(f"🎵 {music_stop_response}")
if stopwatch_manager.has_running_stopwatches():
stopwatch_stop_response = stopwatch_manager.pause_stopwatches()
clean_stopwatch_stop_response = clean_response(
stopwatch_stop_response, language="ru"
)
output_response(clean_stopwatch_stop_response)
last_response = clean_stopwatch_stop_response
skip_wakeword = False
continue
print("_" * 50)
print(f"💤 Жду '{WAKE_WORD}'...")
skip_wakeword = False
continue
# Проверка на "Повтори"
user_text_lower = user_text.lower().strip()
if user_text_lower in _REPEAT_PHRASES or (
user_text_lower.startswith("повтори")
and "за мной" not in user_text_lower
):
if last_response:
print(f"🔁 Повторяю: {last_response}")
output_response(last_response)
else:
output_response("Я еще ничего не говорил.")
skip_wakeword = True
continue
effective_text = user_text
semantic_intent = interpret_assistant_intent(user_text)
semantic_type = str(semantic_intent.get("intent", "none")).strip().lower()
try:
semantic_confidence = float(
semantic_intent.get("confidence", 0.0) or 0.0
)
except (TypeError, ValueError):
semantic_confidence = 0.0
semantic_command = str(semantic_intent.get("normalized_command", "")).strip()
semantic_music_action = (
str(semantic_intent.get("music_action", "none")).strip().lower()
)
semantic_music_query = str(semantic_intent.get("music_query", "")).strip()
if (
semantic_type == "stop"
and semantic_confidence >= _SEMANTIC_REPEAT_STOP_MIN_CONFIDENCE
):
music_controller = get_music_controller()
music_stop_response = music_controller.pause_for_stop_word()
if music_stop_response:
print(f"🎵 {music_stop_response}")
if stopwatch_manager.has_running_stopwatches():
stopwatch_stop_response = stopwatch_manager.pause_stopwatches()
clean_stopwatch_stop_response = clean_response(
stopwatch_stop_response, language="ru"
)
output_response(clean_stopwatch_stop_response)
last_response = clean_stopwatch_stop_response
skip_wakeword = False
continue
print("_" * 50)
print(f"💤 Жду '{WAKE_WORD}'...")
skip_wakeword = False
continue
if (
semantic_type == "repeat"
and semantic_confidence >= _SEMANTIC_REPEAT_STOP_MIN_CONFIDENCE
):
if last_response:
print(f"🔁 Повторяю: {last_response}")
output_response(last_response)
else:
output_response("Я еще ничего не говорил.")
skip_wakeword = True
continue
if (
semantic_type == "music"
and semantic_confidence >= _SEMANTIC_MUSIC_MIN_CONFIDENCE
):
music_controller = get_music_controller()
semantic_music_response = music_controller.handle_semantic_action(
semantic_music_action,
semantic_music_query,
)
if semantic_music_response:
clean_music_response = clean_response(
semantic_music_response, language="ru"
)
output_response(clean_music_response)
last_response = clean_music_response
skip_wakeword = True
continue
if (
semantic_command
and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE
and semantic_type
in {
"music",
"timer",
"alarm",
"weather",
"volume",
"translation",
"cities",
}
):
effective_text = semantic_command
print(f"🧠 Команда: '{user_text}' -> '{effective_text}'")
# Small-talk
smalltalk_response = get_smalltalk_response(effective_text)
if smalltalk_response:
clean_smalltalk = clean_response(smalltalk_response, language="ru")
output_response(clean_smalltalk)
last_response = clean_smalltalk
skip_wakeword = True
continue
command_text = _strip_wakeword_prefix(effective_text) or effective_text
command_text_lower = command_text.lower()
if pending_time_target == "timer" and "таймер" not in command_text_lower:
command_text = f"таймер {command_text}"
elif (
pending_time_target == "alarm"
and "будильник" not in command_text_lower
and "разбуди" not in command_text_lower
):
command_text = f"будильник {command_text}"
elif (
semantic_type == "alarm"
and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE
and re.search(r"\b(будильник\w*|разбуд\w*)\b", command_text_lower)
is None
):
# Для AI-нормализованных фраз без явного слова "будильник"
# добавляем маркер, чтобы гарантированно пройти в alarm parser.
command_text = f"будильник {command_text}"
# Таймеры
stopwatch_response = stopwatch_manager.parse_command(command_text)
if stopwatch_response:
clean_stopwatch_response = clean_response(
stopwatch_response, language="ru"
)
output_response(clean_stopwatch_response)
last_response = clean_stopwatch_response
skip_wakeword = True
continue
# Таймер
timer_response = timer_manager.parse_command(command_text)
if timer_response:
clean_timer_response = clean_response(timer_response, language="ru")
completed = output_response(
clean_timer_response, check_interrupt=check_wakeword_once
)
last_response = clean_timer_response
pending_time_target = (
"timer" if timer_response == ASK_TIMER_TIME_PROMPT else None
)
skip_wakeword = not completed
continue
# Будильник
alarm_response = alarm_clock.parse_command(command_text)
if alarm_response:
clean_alarm_response = clean_response(alarm_response, language="ru")
output_response(clean_alarm_response)
last_response = clean_alarm_response
pending_time_target = (
"alarm" if alarm_response == ASK_ALARM_TIME_PROMPT else None
)
skip_wakeword = alarm_response == ASK_ALARM_TIME_PROMPT
continue
# Громкость
if (
semantic_type == "volume"
and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE
) or is_volume_command(command_text):
try:
level = parse_volume_text(command_text)
if level is not None:
if set_volume(level):
msg = f"Уровень громкости {level}"
clean_msg = clean_response(msg, language="ru")
output_response(clean_msg)
last_response = clean_msg
else:
output_response("Не удалось установить громкость.")
else:
output_response(
"Я не понял число громкости. Скажите число от одного до десяти."
)
skip_wakeword = True
continue
except Exception as e:
print(f"❌ Ошибка громкости: {e}")
output_response("Не удалось изменить громкость.")
skip_wakeword = True
continue
# Погода
requested_city = _extract_requested_city(command_text)
has_weather_trigger = _has_weather_trigger(command_text)
if has_weather_trigger:
from .features.weather import get_weather_report
weather_report = get_weather_report(requested_city)
clean_report = clean_response(weather_report, language="ru")
output_response(clean_report, interrupt_guard_seconds=0.0)
last_response = clean_report
skip_wakeword = True
continue
# Время
if _is_time_request(command_text):
now = datetime.now()
time_response = f"Сейчас {now.strftime('%H:%M')}"
clean_time_response = clean_response(time_response, language="ru")
output_response(clean_time_response)
last_response = clean_time_response
skip_wakeword = True
continue
# Музыка
music_controller = get_music_controller()
music_response = music_controller.parse_command(command_text)
if music_response:
clean_music_response = clean_response(music_response, language="ru")
output_response(clean_music_response)
last_response = clean_music_response
skip_wakeword = True
continue
# Перевод
translation_request = parse_translation_request(command_text)
if (
not translation_request
and semantic_type == "translation"
and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE
):
# Fallback для AI-интента "translation", если нормализатор
# вернул неканоничную фразу без явного префикса "переведи".
translation_request = parse_translation_request(
_strip_wakeword_prefix(user_text)
)
if not translation_request and semantic_command:
translation_request = parse_translation_request(semantic_command)
if not translation_request:
fallback_payload = (semantic_command or command_text).strip()
if fallback_payload:
translation_request = parse_translation_request(
f"переведи {fallback_payload}"
)
if translation_request:
source_lang = translation_request["source_lang"]
target_lang = translation_request["target_lang"]
text_to_translate = translation_request["text"]
# Если сказано только "переведи" — спрашиваем
if not text_to_translate:
prompt = (
"Скажи фразу на английском."
if source_lang == "en"
else "Скажи фразу на русском."
)
output_response(prompt)
if text_mode:
text_to_translate = input("⌨️ Текст для перевода> ").strip()
else:
try:
stop_wakeword_monitoring()
settle_audio_after_tts()
text_to_translate = listen(
timeout_seconds=7.0,
detection_timeout=5.0,
lang=source_lang,
)
except Exception as e:
print(f"Ошибка при прослушивании для перевода: {e}")
print("Переинициализация STT...")
try:
cleanup_stt()
get_recognizer().initialize()
except Exception as init_error:
print(f"Ошибка переинициализации STT: {init_error}")
output_response("Произошла ошибка при распознавании речи.")
skip_wakeword = False
continue
if not text_to_translate:
output_response("Я не расслышал текст для перевода.")
skip_wakeword = False
continue
# Перевод через AI
translated_text = translate_text(
text_to_translate, source_lang, target_lang
)
clean_text = clean_response(translated_text, language=target_lang)
last_response = clean_text
# Озвучиваем
completed = output_response(
clean_text,
check_interrupt=None,
language=target_lang,
)
stop_wakeword_monitoring()
skip_wakeword = True
if not completed:
print("⏹️ Перевод прерван")
continue
# Игра "Города"
cities_response = cities_game.handle(command_text)
if cities_response:
clean_cities_response = clean_response(cities_response, language="ru")
output_response(clean_cities_response)
last_response = clean_cities_response
skip_wakeword = True
continue
# AI запрос
chat_history.append({"role": "user", "content": user_text})
full_response = ""
interrupted = False
# Streaming TTS: читаем SSE без блокировок, а озвучиваем по 2 предложения.
tts_queue: "queue.Queue[str | None]" = queue.Queue()
ai_queue: "queue.Queue[str | None]" = queue.Queue()
stop_streaming_event = threading.Event()
ai_stream_done = threading.Event()
stream_generator_holder = {"generator": None}
def _find_sentence_boundaries(text: str) -> list[int]:
"""Ищет индексы концов предложений в тексте."""
boundaries = []
i = 0
while i < len(text):
ch = text[i]
# Перенос строки считаем безопасной границей фразы.
if ch == "\n":
boundaries.append(i)
i += 1
continue
if ch in ".!?":
# Не режем десятичные числа, например 3.14.
prev_is_digit = i > 0 and text[i - 1].isdigit()
next_is_digit = i + 1 < len(text) and text[i + 1].isdigit()
if ch == "." and prev_is_digit and next_is_digit:
i += 1
continue
boundary = i
# Поглощаем подряд идущие знаки конца предложения (?!...)
while boundary + 1 < len(text) and text[boundary + 1] in ".!?":
boundary += 1
# И закрывающие кавычки/скобки.
while boundary + 1 < len(text) and text[
boundary + 1
] in "\"'”»)]}":
boundary += 1
boundaries.append(boundary)
i = boundary + 1
continue
i += 1
return boundaries
def _split_speakable(text: str, force: bool = False) -> tuple[str, str]:
"""
Возвращает (готовое_для_озвучивания, остаток).
Основной режим: по 2 предложения на фрагмент.
force=True: дожимаем хвост в конце стрима.
"""
if not text:
return "", ""
min_chars = 55
hard_flush_chars = 220
target_sentences = 2
# Не озвучиваем слишком короткие куски во время потока.
if not force and len(text) < min_chars and "\n" not in text:
return "", text
boundaries = _find_sentence_boundaries(text)
boundary = -1
if len(boundaries) >= target_sentences:
boundary = boundaries[target_sentences - 1]
elif len(boundaries) == 1 and (force or len(text) >= hard_flush_chars):
boundary = boundaries[0]
elif len(boundaries) == 0 and not force and len(text) >= hard_flush_chars:
split_idx = text.rfind(" ", 0, hard_flush_chars)
boundary = split_idx if split_idx > 0 else hard_flush_chars - 1
elif force:
boundary = len(text) - 1
else:
return "", text
speak_part = text[: boundary + 1].strip()
rest = text[boundary + 1 :].lstrip()
return speak_part, rest
def _tts_worker():
nonlocal interrupted
while True:
item = tts_queue.get()
if item is None:
return
if stop_streaming_event.is_set():
continue
clean_part = clean_response(item, language="ru")
if not clean_part.strip():
continue
ok = output_response(
clean_part,
language="ru",
)
if not ok:
interrupted = True
stop_streaming_event.set()
# Опустошим очередь, чтобы не озвучивать "хвост" после прерывания.
try:
while True:
tts_queue.get_nowait()
except queue.Empty:
pass
return
tts_thread = threading.Thread(target=_tts_worker, daemon=True)
tts_thread.start()
def _ai_stream_worker():
generator = None
try:
generator = ask_ai_stream(list(chat_history))
stream_generator_holder["generator"] = generator
for chunk in generator:
if stop_streaming_event.is_set():
break
if chunk:
ai_queue.put(chunk)
except Exception as exc:
print(f"\n❌ Ошибка: {exc}")
if not stop_streaming_event.is_set():
ai_queue.put("Произошла ошибка при получении ответа.")
finally:
if generator is not None:
try:
generator.close()
except Exception:
pass
ai_stream_done.set()
ai_queue.put(None)
ai_thread = threading.Thread(target=_ai_stream_worker, daemon=True)
ai_thread.start()
print("🤖 AI: ", end="", flush=True)
try:
buffer = ""
while True:
if stop_streaming_event.is_set():
break
try:
chunk = ai_queue.get(timeout=0.1)
except queue.Empty:
if ai_stream_done.is_set():
break
continue
if chunk is None:
break
full_response += chunk
buffer += chunk
print(chunk, end="", flush=True)
while True:
speak_part, buffer = _split_speakable(buffer)
if not speak_part:
break
tts_queue.put(speak_part)
except Exception as e:
print(f"\n❌ Ошибка: {e}")
tts_queue.put("Произошла ошибка при получении ответа.")
finally:
generator = stream_generator_holder.get("generator")
if interrupted:
stop_streaming_event.set()
if generator is not None and interrupted:
try:
generator.close()
except Exception:
pass
# Договорим остаток, если не было прерывания.
if not interrupted:
while True:
tail_part, buffer = _split_speakable(buffer, force=True)
if not tail_part:
break
tts_queue.put(tail_part)
tts_queue.put(None)
ai_thread.join(timeout=1.0)
# Для длинных ответов не обрываем ожидание по таймауту:
# дожидаемся полного завершения TTS worker.
tts_thread.join()
print()
# Сохраняем только завершенный ответ, чтобы не засорять контекст обрезанным хвостом.
if full_response and not interrupted:
chat_history.append({"role": "assistant", "content": full_response})
last_response = clean_response(full_response, language="ru")
stop_wakeword_monitoring()
skip_wakeword = True
if interrupted:
print("⏹️ Ответ прерван")
print()
print("-" * 30)
print()
except KeyboardInterrupt:
signal_handler(None, None)
except Exception as e:
print(f"❌ Ошибка: {e}")
output_response("Произошла ошибка. Попробуйте ещё раз.")
skip_wakeword = False
finally:
if wakeword_music_ducked:
try:
music_controller.restore_after_wakeword()
except Exception:
pass
if __name__ == "__main__":
main()