596 lines
29 KiB
Python
596 lines
29 KiB
Python
"""
|
||
Smart Speaker - Main Application
|
||
Голосовой ассистент с wake word detection, STT, AI и TTS.
|
||
|
||
Flow:
|
||
1. Wait for wake word ("Alexandr")
|
||
2. Listen to user speech (STT)
|
||
3. Send query to AI (Perplexity)
|
||
4. Clean response from markdown
|
||
5. Speak response (TTS)
|
||
6. Loop back to step 1
|
||
"""
|
||
|
||
# Главный файл приложения (`main.py`).
|
||
# Здесь находится основной бесконечный цикл, который связывает все компоненты воедино.
|
||
|
||
import os
|
||
import queue
|
||
import re
|
||
import signal
|
||
import sys
|
||
import threading
|
||
import time
|
||
from collections import deque
|
||
|
||
# Для воспроизведения звуков (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 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.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, translate_text
|
||
from .core.config import BASE_DIR
|
||
from .core.cleaner import clean_response
|
||
from .core.commands import is_stop_command
|
||
from .features.alarm import get_alarm_clock
|
||
from .features.timer import get_timer_manager
|
||
from .features.weather import get_weather_report
|
||
from .features.music import get_music_controller
|
||
|
||
def signal_handler(sig, frame):
|
||
"""
|
||
Обработчик сигнала Ctrl+C.
|
||
Позволяет корректно завершить работу программы, освободив ресурсы (микрофон, модели).
|
||
"""
|
||
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):
|
||
"""
|
||
Определяет, является ли фраза запросом на перевод.
|
||
|
||
Пример: "Переведи на английский привет мир"
|
||
Возвращает словарь: {'source_lang': 'ru', 'target_lang': 'en', 'text': 'привет мир'}
|
||
Или None, если это не запрос перевода.
|
||
"""
|
||
text_lower = text.lower().strip()
|
||
# Список префиксов команд перевода и соответствующих направлений языков.
|
||
# Важно: более длинные префиксы должны проверяться первыми (например,
|
||
# "переведи с русского на английский" не должен схватиться как "переведи с русского").
|
||
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"),
|
||
]
|
||
|
||
for prefix, source_lang, target_lang in sorted(
|
||
commands, key=lambda item: len(item[0]), reverse=True
|
||
):
|
||
if text_lower.startswith(prefix):
|
||
# Отрезаем команду (префикс), оставляем только текст для перевода
|
||
rest = text[len(prefix) :].strip()
|
||
rest = rest.lstrip(" :—-")
|
||
return {
|
||
"source_lang": source_lang,
|
||
"target_lang": target_lang,
|
||
"text": rest,
|
||
}
|
||
return None
|
||
|
||
|
||
def main():
|
||
"""
|
||
Основная функция (точка входа).
|
||
"""
|
||
print("=" * 50)
|
||
print("🔊 УМНАЯ КОЛОНКА")
|
||
print("=" * 50)
|
||
print("Скажите 'Alexandr' для активации")
|
||
print("Нажмите Ctrl+C для выхода")
|
||
print("=" * 50)
|
||
print()
|
||
|
||
# Устанавливаем перехватчик Ctrl+C
|
||
signal.signal(signal.SIGINT, signal_handler)
|
||
|
||
# Предварительная инициализация моделей
|
||
print("⏳ Инициализация моделей...")
|
||
|
||
# Инициализация звуковой системы для эффектов (опционально)
|
||
ding_sound = None
|
||
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:
|
||
ding_sound_path = BASE_DIR / "assets" / "sounds" / "ding.wav"
|
||
if ding_sound_path.exists():
|
||
ding_sound = mixer.Sound(str(ding_sound_path))
|
||
ding_sound.set_volume(0.3)
|
||
else:
|
||
print(f"⚠️ Звук {ding_sound_path} не найден")
|
||
|
||
get_recognizer().initialize() # Подключение к Deepgram
|
||
init_tts() # Загрузка нейросети для синтеза речи (Silero)
|
||
alarm_clock = get_alarm_clock() # Загрузка будильников
|
||
timer_manager = get_timer_manager() # Загрузка таймеров
|
||
print()
|
||
|
||
# История чата (храним последние 10 обменов репликами для контекста)
|
||
chat_history = deque(maxlen=20)
|
||
|
||
# Переменная для хранения последнего ответа ассистента
|
||
last_response = None
|
||
|
||
# Переменная, указывающая, нужно ли пропускать ожидание wake word
|
||
# (True = режим диалога, слушаем сразу. False = ждем "Alexandr")
|
||
skip_wakeword = False
|
||
|
||
# Переменная для отслеживания последней проверки здоровья STT
|
||
last_stt_check = time.time()
|
||
|
||
# БЕСКОНЕЧНЫЙ ЦИКЛ РАБОТЫ
|
||
while True:
|
||
# Периодическая проверка здоровья STT каждые 10 минут
|
||
if time.time() - last_stt_check > 600: # 10 минут = 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}")
|
||
try:
|
||
# Гарантируем, что микрофон детектора wake word освобожден
|
||
stop_wakeword_monitoring()
|
||
|
||
# --- Проверка таймеров ---
|
||
# Проверяем каждую итерацию. Если таймер сработал, он заблокирует выполнение, пока его не выключат.
|
||
if timer_manager.check_timers():
|
||
skip_wakeword = False
|
||
continue
|
||
|
||
# --- Проверка будильников ---
|
||
# Проверяем каждую итерацию. Если будильник сработал, он заблокирует выполнение, пока его не выключат.
|
||
if alarm_clock.check_alarms():
|
||
# Если будильник прозвенел и был выключен пользователем, сбрасываем режим диалога
|
||
skip_wakeword = False
|
||
continue
|
||
|
||
# --- Шаг 1: Активация ---
|
||
if not skip_wakeword:
|
||
# Ожидание фразы "Alexandr". Используем таймаут 0.5 сек, чтобы чаще проверять будильники.
|
||
detected = wait_for_wakeword(timeout=0.5)
|
||
|
||
# Если время вышло, а фразы не было — начинаем цикл заново (проверяем будильники)
|
||
if not detected:
|
||
continue
|
||
|
||
# Воспроизводим звук активации
|
||
if ding_sound:
|
||
ding_sound.play()
|
||
|
||
# Фраза услышана! Слушаем команду пользователя (5 секунд тишины макс)
|
||
try:
|
||
user_text = listen(timeout_seconds=5.0)
|
||
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): ждем продолжения речи без "Alexandr"
|
||
print("👂 Слушаю продолжение диалога (3 сек)...")
|
||
# Ждем начала речи 3 сек. Если начали говорить, слушаем до 7 сек.
|
||
try:
|
||
user_text = listen(timeout_seconds=7.0, detection_timeout=3.0)
|
||
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:
|
||
# Пользователь промолчал — выходим из режима диалога, засыпаем.
|
||
skip_wakeword = False
|
||
continue
|
||
|
||
# --- Шаг 2: Анализ распознанного текста ---
|
||
if not user_text:
|
||
# Была активация, но речь не распознана
|
||
speak("Извините, я вас не расслышал. Попробуйте ещё раз.")
|
||
skip_wakeword = False # Возвращаемся в режим ожидания имени
|
||
continue
|
||
|
||
# Проверка на команду "Стоп"
|
||
if is_stop_command(user_text):
|
||
print("_" * 50)
|
||
print("💤 Жду 'Alexandr' для активации...")
|
||
skip_wakeword = False
|
||
continue
|
||
|
||
# Проверка на команду "Повтори" / "Еще раз"
|
||
user_text_lower = user_text.lower().strip()
|
||
repeat_phrases = [
|
||
"еще раз",
|
||
"повтори",
|
||
"скажи еще раз",
|
||
"что ты сказал",
|
||
"повтори пожалуйста",
|
||
"александр еще раз",
|
||
"еще раз александр",
|
||
"александр повтори",
|
||
"повтори александр",
|
||
]
|
||
# Проверяем точное совпадение или если фраза начинается с "повтори" (но не "повтори за мной")
|
||
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}")
|
||
speak(last_response)
|
||
else:
|
||
speak("Я еще ничего не говорил.")
|
||
# После повтора остаемся в диалоге
|
||
skip_wakeword = True
|
||
continue
|
||
|
||
# Проверка команд таймера ("поставь таймер на 6 минут")
|
||
timer_response = timer_manager.parse_command(user_text)
|
||
if timer_response:
|
||
clean_timer_response = clean_response(timer_response, language="ru")
|
||
completed = speak(
|
||
clean_timer_response, check_interrupt=check_wakeword_once
|
||
)
|
||
last_response = clean_timer_response
|
||
skip_wakeword = not completed
|
||
continue
|
||
|
||
# Проверка команд будильника ("поставь будильник на 7")
|
||
alarm_response = alarm_clock.parse_command(user_text)
|
||
if alarm_response:
|
||
clean_alarm_response = clean_response(alarm_response, language="ru")
|
||
speak(clean_alarm_response)
|
||
last_response = clean_alarm_response
|
||
skip_wakeword = False
|
||
continue
|
||
|
||
# Проверка команды громкости ("громкость 5")
|
||
if user_text.lower().startswith("громкость"):
|
||
try:
|
||
# Убираем слово "громкость" и ищем число
|
||
vol_str = user_text.lower().replace("громкость", "", 1).strip()
|
||
level = parse_volume_text(vol_str)
|
||
|
||
if level is not None:
|
||
if set_volume(level):
|
||
msg = f"Громкость установлена на {level}"
|
||
clean_msg = clean_response(msg, language="ru")
|
||
speak(clean_msg)
|
||
last_response = clean_msg
|
||
else:
|
||
speak("Не удалось установить громкость.")
|
||
else:
|
||
speak(
|
||
"Я не понял число громкости. Скажите число от одного до десяти."
|
||
)
|
||
|
||
skip_wakeword = True
|
||
continue
|
||
except Exception as e:
|
||
print(f"❌ Ошибка громкости: {e}")
|
||
speak("Не удалось изменить громкость.")
|
||
skip_wakeword = True
|
||
continue
|
||
|
||
# Проверка команды "Погода"
|
||
weather_triggers = [
|
||
"погода",
|
||
"погоду",
|
||
"что на улице",
|
||
"какая температура",
|
||
"сколько градусов",
|
||
"холодно ли",
|
||
"жарко ли",
|
||
"нужен ли зонт",
|
||
"брать ли зонт",
|
||
"прогноз погоды",
|
||
"че там на улице",
|
||
"что там на улице",
|
||
"как на улице",
|
||
"как на улице-то",
|
||
]
|
||
|
||
# Проверяем, содержит ли запрос информацию о конкретном городе
|
||
requested_city = None
|
||
user_text_lower = user_text.lower()
|
||
|
||
# Проверяем наличие упоминания города в запросе (например, "погода в Нью-Йорке", "какая погода в Москве")
|
||
import re
|
||
city_patterns = [
|
||
r"в\s+городе\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", # "в городе Волгоград"
|
||
r"в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", # "в Нью-Йорке" - улучшенный паттерн для составных названий
|
||
r"погода\s+в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", # "погода в Москве" - улучшенный паттерн
|
||
r"погода\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)\s+(?:какая|сейчас|там)", # "погода Москва какая"
|
||
r"(?:какая|как)\s+погода\s+в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", # "какая погода в Москве"
|
||
]
|
||
|
||
for pattern in city_patterns:
|
||
match = re.search(pattern, user_text_lower, re.IGNORECASE)
|
||
if match:
|
||
potential_city = match.group(1).strip()
|
||
# Проверяем, что это не местоимение или другое слово, а реально название города
|
||
invalid_words = ["этом", "том", "той", "тут", "здесь", "там", "всё", "все", "всей", "всего", "всем", "всеми", "городе", "город", "село", "деревня", "посёлок", "аул", "станция", "область", "район", "край", "республика"]
|
||
if potential_city and len(potential_city) > 1 and not any(word in potential_city for word in invalid_words):
|
||
requested_city = potential_city.title() # Приводим к формату "Нью-Йорк", "Москва"
|
||
break
|
||
|
||
# Проверяем, содержит ли запрос одну из погодных команд
|
||
has_weather_trigger = any(trigger in user_text_lower for trigger in weather_triggers)
|
||
|
||
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")
|
||
speak(clean_report)
|
||
last_response = clean_report
|
||
skip_wakeword = True
|
||
continue
|
||
|
||
# Проверка музыкальных команд ("включи музыку", "пауза", и т.д.)
|
||
music_controller = get_music_controller()
|
||
music_response = music_controller.parse_command(user_text)
|
||
if music_response:
|
||
clean_music_response = clean_response(music_response, language="ru")
|
||
speak(clean_music_response)
|
||
last_response = clean_music_response
|
||
skip_wakeword = True
|
||
continue
|
||
|
||
# Проверка запроса на перевод
|
||
translation_request = parse_translation_request(user_text)
|
||
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 "Скажи фразу на русском."
|
||
)
|
||
speak(prompt)
|
||
# Слушаем саму фразу на нужном языке
|
||
try:
|
||
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}")
|
||
speak("Произошла ошибка при распознавании речи.")
|
||
skip_wakeword = False
|
||
continue
|
||
|
||
if not text_to_translate:
|
||
speak("Я не расслышал текст для перевода.")
|
||
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 = speak(
|
||
clean_text,
|
||
check_interrupt=check_wakeword_once,
|
||
language=target_lang,
|
||
)
|
||
stop_wakeword_monitoring()
|
||
skip_wakeword = True # Остаемся в диалоге
|
||
|
||
if not completed:
|
||
print("⏹️ Перевод прерван - слушаю следующий вопрос")
|
||
continue
|
||
|
||
# --- Шаг 3: Запрос к AI (Streaming) ---
|
||
# Добавляем сообщение пользователя в историю
|
||
chat_history.append({"role": "user", "content": user_text})
|
||
|
||
# Очередь для предложений, которые нужно озвучить
|
||
tts_q = queue.Queue()
|
||
# Флаг прерывания для worker-а
|
||
interrupt_event = threading.Event()
|
||
|
||
def tts_worker():
|
||
"""Фоновый поток, читающий предложения из очереди и озвучивающий их."""
|
||
while True:
|
||
item = tts_q.get()
|
||
if item is None: # Poison pill (сигнал остановки)
|
||
tts_q.task_done()
|
||
break
|
||
|
||
text, lang = item
|
||
|
||
# Если уже было прерывание, просто пропускаем (чистим очередь)
|
||
if interrupt_event.is_set():
|
||
tts_q.task_done()
|
||
continue
|
||
|
||
# Озвучиваем
|
||
completed = speak(
|
||
text, check_interrupt=check_wakeword_once, language=lang
|
||
)
|
||
|
||
if not completed:
|
||
interrupt_event.set() # Сообщаем всем, что нас перебили
|
||
|
||
tts_q.task_done()
|
||
|
||
# Запускаем поток озвучки
|
||
worker_thread = threading.Thread(target=tts_worker, daemon=True)
|
||
worker_thread.start()
|
||
|
||
full_response = ""
|
||
buffer = ""
|
||
|
||
try:
|
||
# Получаем генератор потока от AI
|
||
stream_generator = ask_ai_stream(list(chat_history))
|
||
|
||
print("🤖 AI говорит: ", end="", flush=True)
|
||
|
||
for chunk in stream_generator:
|
||
# Если в процессе генерации нас перебили (на ранних фразах), прерываем получение
|
||
if interrupt_event.is_set():
|
||
break
|
||
|
||
buffer += chunk
|
||
full_response += chunk
|
||
print(chunk, end="", flush=True)
|
||
|
||
# Проверяем на конец предложения (. ! ? + пробел или конец строки)
|
||
# Эвристика: ищем знаки препинания, после которых идет пробел или перевод строки
|
||
if re.search(r"[.!?\n]+(?:\s|$)", buffer):
|
||
# Очищаем и отправляем в очередь
|
||
clean_chunk = clean_response(buffer, language="ru")
|
||
if clean_chunk.strip():
|
||
tts_q.put((clean_chunk, "ru"))
|
||
buffer = ""
|
||
|
||
# Отправляем остаток (если есть)
|
||
if buffer.strip() and not interrupt_event.is_set():
|
||
clean_chunk = clean_response(buffer, language="ru")
|
||
if clean_chunk.strip():
|
||
tts_q.put((clean_chunk, "ru"))
|
||
|
||
except Exception as e:
|
||
print(f"\n❌ Ошибка стриминга: {e}")
|
||
speak("Произошла ошибка при получении ответа.")
|
||
|
||
# Ждем, пока все договорится
|
||
# Добавляем poison pill, чтобы поток завершился, когда очередь пуста
|
||
tts_q.put(None)
|
||
worker_thread.join()
|
||
|
||
print() # Перенос строки после вывода AI
|
||
|
||
# Добавляем полный ответ AI в историю
|
||
chat_history.append({"role": "assistant", "content": full_response})
|
||
|
||
# Сохраняем для "повтори"
|
||
last_response = clean_response(full_response, language="ru")
|
||
|
||
# После озвучки обязательно закрываем поток микрофона
|
||
stop_wakeword_monitoring()
|
||
|
||
# Включаем режим диалога (следующий запрос можно говорить без имени)
|
||
skip_wakeword = True
|
||
|
||
if interrupt_event.is_set():
|
||
print("⏹️ Ответ прерван - слушаю следующий вопрос")
|
||
# Если перебили, цикл перезапустится и skip_wakeword уже True
|
||
|
||
print()
|
||
print("-" * 30)
|
||
print()
|
||
|
||
# --- Шаг 6: Конец итерации, возврат в начало цикла ---
|
||
|
||
except KeyboardInterrupt:
|
||
signal_handler(None, None)
|
||
except Exception as e:
|
||
print(f"❌ Ошибка: {e}")
|
||
speak("Произошла ошибка. Попробуйте ещё раз.")
|
||
skip_wakeword = False
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|