chore: sync local changes

This commit is contained in:
2026-03-01 12:55:17 +03:00
parent 27ee32be38
commit f1bc254c6b
8 changed files with 192 additions and 292 deletions

View File

@@ -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: