1338 lines
53 KiB
Python
1338 lines
53 KiB
Python
"""
|
||
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"\bкотор(ый|ого)\s+час\b", re.IGNORECASE),
|
||
re.compile(r"\bсколько\s+времени\b", re.IGNORECASE),
|
||
re.compile(r"\bкакое\s+сейчас\s+время\b", re.IGNORECASE),
|
||
re.compile(r"\bкоторый\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()
|