chore: sync local changes
This commit is contained in:
185
app/main.py
185
app/main.py
@@ -1,18 +1,8 @@
|
||||
"""
|
||||
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 os
|
||||
import queue
|
||||
@@ -32,7 +22,7 @@ except Exception as 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
|
||||
@@ -174,11 +164,10 @@ _CITY_PATTERNS = [
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
"""
|
||||
Обработчик сигнала Ctrl+C.
|
||||
Позволяет корректно завершить работу программы, освободив ресурсы (микрофон, модели).
|
||||
"""
|
||||
"""Обработчик Ctrl+C."""
|
||||
print("\n\n👋 Завершение работы...")
|
||||
print("\n\n👋 Завершение работы...")
|
||||
try:
|
||||
cleanup_wakeword() # Остановка Porcupine
|
||||
@@ -192,13 +181,8 @@ def signal_handler(sig, frame):
|
||||
|
||||
|
||||
def parse_translation_request(text: str):
|
||||
"""
|
||||
Определяет, является ли фраза запросом на перевод.
|
||||
|
||||
Пример: "Переведи на английский привет мир"
|
||||
Возвращает словарь: {'source_lang': 'ru', 'target_lang': 'en', 'text': 'привет мир'}
|
||||
Или None, если это не запрос перевода.
|
||||
"""
|
||||
"""Проверяет, является ли фраза запросом на перевод."""
|
||||
text_lower = text.lower().strip()
|
||||
text_lower = text.lower().strip()
|
||||
# Список префиксов команд перевода и соответствующих направлений языков.
|
||||
# Важно: более длинные префиксы должны проверяться первыми (например,
|
||||
@@ -217,9 +201,8 @@ def parse_translation_request(text: str):
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Основная функция (точка входа).
|
||||
"""
|
||||
"""Точка входа."""
|
||||
print("=" * 50)
|
||||
print("=" * 50)
|
||||
print("🔊 УМНАЯ КОЛОНКА")
|
||||
print("=" * 50)
|
||||
@@ -231,7 +214,6 @@ def main():
|
||||
# Устанавливаем перехватчик Ctrl+C
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# Предварительная инициализация моделей
|
||||
print("⏳ Инициализация моделей...")
|
||||
|
||||
# Инициализация звуковой системы для эффектов (опционально)
|
||||
@@ -262,70 +244,61 @@ def main():
|
||||
cities_game = get_cities_game() # Игра "Города"
|
||||
print()
|
||||
|
||||
# История чата (храним последние 10 обменов репликами для контекста)
|
||||
# История чата
|
||||
chat_history = deque(maxlen=20)
|
||||
|
||||
# Переменная для хранения последнего ответа ассистента
|
||||
# Последний ответ ассистента
|
||||
last_response = None
|
||||
|
||||
# Переменная, указывающая, нужно ли пропускать ожидание wake word
|
||||
# (True = режим диалога, слушаем сразу. False = ждем "Alexandr")
|
||||
# Режим диалога (без wake word)
|
||||
skip_wakeword = False
|
||||
|
||||
# После ответа ассистент ждет продолжение фразы 4 секунды.
|
||||
# Если речи нет, выходим из диалога и снова ждем wake word.
|
||||
followup_idle_timeout_seconds = 4.0
|
||||
|
||||
# Контекст уточнения "на какое время поставить ...".
|
||||
# Может быть: "timer", "alarm".
|
||||
# Контекст уточнения времени для таймера/будильника
|
||||
pending_time_target = None
|
||||
|
||||
# Переменная для отслеживания последней проверки здоровья STT
|
||||
# Проверка здоровья STT
|
||||
last_stt_check = time.time()
|
||||
|
||||
# БЕСКОНЕЧНЫЙ ЦИКЛ РАБОТЫ
|
||||
# ГЛАВНЫЙ ЦИКЛ
|
||||
while True:
|
||||
# Периодическая проверка здоровья STT каждые 10 минут
|
||||
if time.time() - last_stt_check > 600: # 10 минут = 600 секунд
|
||||
# Периодическая проверка STT
|
||||
if time.time() - last_stt_check > 600:
|
||||
try:
|
||||
recognizer = get_recognizer()
|
||||
if hasattr(recognizer, 'check_connection_health'):
|
||||
if hasattr(recognizer, "check_connection_health"):
|
||||
recognizer.check_connection_health()
|
||||
last_stt_check = time.time()
|
||||
except Exception as e:
|
||||
print(f"Ошибка при проверке здоровья STT: {e}")
|
||||
print(f"Ошибка при проверке STT: {e}")
|
||||
try:
|
||||
# Гарантируем, что микрофон детектора wake word освобожден
|
||||
# Освобождаем микрофон wake word
|
||||
stop_wakeword_monitoring()
|
||||
|
||||
# --- Проверка таймеров ---
|
||||
# Проверяем каждую итерацию. Если таймер сработал, он заблокирует выполнение, пока его не выключат.
|
||||
# Проверяем таймеры
|
||||
if timer_manager.check_timers():
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# --- Проверка будильников ---
|
||||
# Проверяем каждую итерацию. Если будильник сработал, он заблокирует выполнение, пока его не выключат.
|
||||
# Проверяем будильники
|
||||
if alarm_clock.check_alarms():
|
||||
# Если будильник прозвенел и был выключен пользователем, сбрасываем режим диалога
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# --- Шаг 1: Активация ---
|
||||
# Ждем wake word
|
||||
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с ждём начало речи, после начала завершаем STT по 3с тишины.
|
||||
# Слушаем команду
|
||||
try:
|
||||
user_text = listen(timeout_seconds=5.0, fast_stop=True)
|
||||
except Exception as e:
|
||||
@@ -338,12 +311,8 @@ def main():
|
||||
print(f"Ошибка переинициализации STT: {init_error}")
|
||||
continue # Продолжаем цикл
|
||||
else:
|
||||
# Режим диалога (Follow-up): ждем продолжения речи без "Alexandr"
|
||||
print(
|
||||
"👂 Слушаю продолжение диалога "
|
||||
f"({followup_idle_timeout_seconds:.0f} сек)..."
|
||||
)
|
||||
# Ждем начала речи 4 сек. Если начали говорить, слушаем до 7 сек.
|
||||
# Follow-up режим — без wake word
|
||||
print(f"👂 Слушаю ({followup_idle_timeout_seconds:.0f} сек)...")
|
||||
try:
|
||||
user_text = listen(
|
||||
timeout_seconds=7.0,
|
||||
@@ -362,13 +331,12 @@ def main():
|
||||
continue
|
||||
|
||||
if not user_text:
|
||||
# Пользователь промолчал — выходим из режима диалога, засыпаем.
|
||||
# Молчание — возвращаемся к ожиданию
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# --- Шаг 2: Анализ распознанного текста ---
|
||||
# Анализ текста
|
||||
if not user_text:
|
||||
# Пустой ввод: без лишних ответов возвращаемся к ожиданию wake word.
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
@@ -384,13 +352,12 @@ def main():
|
||||
skip_wakeword = False
|
||||
continue
|
||||
print("_" * 50)
|
||||
print("💤 Жду 'Alexandr' для активации...")
|
||||
print("💤 Жду 'Alexandr'...")
|
||||
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
|
||||
@@ -400,11 +367,10 @@ def main():
|
||||
speak(last_response)
|
||||
else:
|
||||
speak("Я еще ничего не говорил.")
|
||||
# После повтора остаемся в диалоге
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# Короткие ответы на small-talk ("как дела" и т.п.)
|
||||
# Small-talk
|
||||
smalltalk_response = get_smalltalk_response(user_text)
|
||||
if smalltalk_response:
|
||||
clean_smalltalk = clean_response(smalltalk_response, language="ru")
|
||||
@@ -424,7 +390,7 @@ def main():
|
||||
):
|
||||
command_text = f"будильник {command_text}"
|
||||
|
||||
# Проверка команд таймера ("поставь таймер на 6 минут")
|
||||
# Таймеры
|
||||
stopwatch_response = stopwatch_manager.parse_command(command_text)
|
||||
if stopwatch_response:
|
||||
clean_stopwatch_response = clean_response(
|
||||
@@ -435,7 +401,7 @@ def main():
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# Проверка команд таймера ("поставь таймер на 6 минут")
|
||||
# Таймер
|
||||
timer_response = timer_manager.parse_command(command_text)
|
||||
if timer_response:
|
||||
clean_timer_response = clean_response(timer_response, language="ru")
|
||||
@@ -449,7 +415,7 @@ def main():
|
||||
skip_wakeword = not completed
|
||||
continue
|
||||
|
||||
# Проверка команд будильника ("поставь будильник на 7")
|
||||
# Будильник
|
||||
alarm_response = alarm_clock.parse_command(command_text)
|
||||
if alarm_response:
|
||||
clean_alarm_response = clean_response(alarm_response, language="ru")
|
||||
@@ -461,10 +427,9 @@ def main():
|
||||
skip_wakeword = alarm_response == ASK_ALARM_TIME_PROMPT
|
||||
continue
|
||||
|
||||
# Проверка команды громкости ("громкость 5")
|
||||
# Громкость
|
||||
if user_text.lower().startswith("громкость"):
|
||||
try:
|
||||
# Убираем слово "громкость" и ищем число
|
||||
vol_str = user_text.lower().replace("громкость", "", 1).strip()
|
||||
level = parse_volume_text(vol_str)
|
||||
|
||||
@@ -489,32 +454,31 @@ def main():
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# Проверка команды "Погода"
|
||||
# Проверяем, содержит ли запрос информацию о конкретном городе
|
||||
# Погода
|
||||
requested_city = None
|
||||
user_text_lower = user_text.lower()
|
||||
|
||||
# Проверяем наличие упоминания города в запросе (например, "погода в Нью-Йорке", "какая погода в Москве")
|
||||
for pattern in _CITY_PATTERNS:
|
||||
match = pattern.search(user_text_lower)
|
||||
if match:
|
||||
potential_city = match.group(1).strip()
|
||||
# Проверяем, что это не местоимение или другое слово, а реально название города
|
||||
if (
|
||||
potential_city
|
||||
and len(potential_city) > 1
|
||||
and not any(word in potential_city for word in _CITY_INVALID_WORDS)
|
||||
and not any(
|
||||
word in potential_city for word in _CITY_INVALID_WORDS
|
||||
)
|
||||
):
|
||||
requested_city = potential_city.title() # Приводим к формату "Нью-Йорк", "Москва"
|
||||
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)
|
||||
@@ -522,7 +486,7 @@ def main():
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# Проверка музыкальных команд ("включи музыку", "пауза", и т.д.)
|
||||
# Музыка
|
||||
music_controller = get_music_controller()
|
||||
music_response = music_controller.parse_command(user_text)
|
||||
if music_response:
|
||||
@@ -532,14 +496,14 @@ def main():
|
||||
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 = (
|
||||
"Скажи фразу на английском."
|
||||
@@ -547,7 +511,6 @@ def main():
|
||||
else "Скажи фразу на русском."
|
||||
)
|
||||
speak(prompt)
|
||||
# Слушаем саму фразу на нужном языке
|
||||
try:
|
||||
text_to_translate = listen(
|
||||
timeout_seconds=7.0, detection_timeout=5.0, lang=source_lang
|
||||
@@ -569,31 +532,30 @@ def main():
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# Выполняем перевод через AI
|
||||
# Перевод через 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 # Остаемся в диалоге
|
||||
skip_wakeword = True
|
||||
|
||||
if not completed:
|
||||
print("⏹️ Перевод прерван - слушаю следующий вопрос")
|
||||
print("⏹️ Перевод прерван")
|
||||
continue
|
||||
|
||||
# Игра "Города"
|
||||
cities_response = cities_game.handle(user_text)
|
||||
cities_response = cities_game.handle(user_text)
|
||||
if cities_response:
|
||||
clean_cities_response = clean_response(cities_response, language="ru")
|
||||
speak(clean_cities_response)
|
||||
@@ -601,41 +563,36 @@ def main():
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# --- Шаг 3: Запрос к AI (Streaming) ---
|
||||
# Добавляем сообщение пользователя в историю
|
||||
# AI запрос
|
||||
chat_history.append({"role": "user", "content": user_text})
|
||||
|
||||
# Очередь для предложений, которые нужно озвучить
|
||||
# Очередь для TTS
|
||||
tts_q = queue.Queue()
|
||||
# Флаг прерывания для worker-а
|
||||
interrupt_event = threading.Event()
|
||||
|
||||
def tts_worker():
|
||||
"""Фоновый поток, читающий предложения из очереди и озвучивающий их."""
|
||||
"""Фоновый поток для озвучки."""
|
||||
while True:
|
||||
item = tts_q.get()
|
||||
if item is None: # Poison pill (сигнал остановки)
|
||||
if item is None:
|
||||
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() # Сообщаем всем, что нас перебили
|
||||
interrupt_event.set()
|
||||
|
||||
tts_q.task_done()
|
||||
|
||||
# Запускаем поток озвучки
|
||||
worker_thread = threading.Thread(target=tts_worker, daemon=True)
|
||||
worker_thread.start()
|
||||
|
||||
@@ -643,13 +600,12 @@ def main():
|
||||
buffer = ""
|
||||
|
||||
try:
|
||||
# Получаем генератор потока от AI
|
||||
# Streaming от AI
|
||||
stream_generator = ask_ai_stream(list(chat_history))
|
||||
|
||||
print("🤖 AI говорит: ", end="", flush=True)
|
||||
print("🤖 AI: ", end="", flush=True)
|
||||
|
||||
for chunk in stream_generator:
|
||||
# Если в процессе генерации нас перебили (на ранних фразах), прерываем получение
|
||||
if interrupt_event.is_set():
|
||||
break
|
||||
|
||||
@@ -657,54 +613,43 @@ def main():
|
||||
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}")
|
||||
print(f"\n❌ Ошибка: {e}")
|
||||
speak("Произошла ошибка при получении ответа.")
|
||||
|
||||
# Ждем, пока все договорится
|
||||
# Добавляем poison pill, чтобы поток завершился, когда очередь пуста
|
||||
# Ждем окончания озвучки
|
||||
tts_q.put(None)
|
||||
worker_thread.join()
|
||||
|
||||
print() # Перенос строки после вывода AI
|
||||
print()
|
||||
|
||||
# Добавляем полный ответ 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()
|
||||
print("-" * 30)
|
||||
print()
|
||||
|
||||
# --- Шаг 6: Конец итерации, возврат в начало цикла ---
|
||||
|
||||
except KeyboardInterrupt:
|
||||
signal_handler(None, None)
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user