diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..52356ab --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +PERPLEXITY_API_KEY=your_perplexity_api_key_here +PERPLEXITY_MODEL=llama-3.1-sonar-small-128k-chat +DEEPGRAM_API_KEY=your_deepgram_api_key_here +PORCUPINE_ACCESS_KEY=your_porcupine_access_key_here +TTS_EN_SPEAKER=en_0 +WEATHER_LAT=63.56 +WEATHER_LON=53.69 +WEATHER_CITY=Ухта diff --git a/README.md b/README.md index bbc37c0..78beb30 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - Озвучка (Silero TTS, RU/EN). - Перевод RU↔EN (Perplexity). - Будильник с голосовым отключением. +- Таймер с голосовым отключением. - Управление громкостью (ALSA amixer). ## Требования @@ -44,7 +45,9 @@ python main.py - Перевод: «переведи с английского» → сказать фразу на английском - Громкость: «громкость 5» - Будильник: «будильник на 7:30», «разбуди на 8 15» +- Таймер: «таймер на 6 минут», «поставь будильник через 6 минут» - Отмена будильников: «отмени будильник» +- Отмена таймеров: «отмени таймер» - Стоп/сброс: «стоп», «хватит» ## Объяснение работы diff --git a/app/features/timer.py b/app/features/timer.py new file mode 100644 index 0000000..1b86da9 --- /dev/null +++ b/app/features/timer.py @@ -0,0 +1,175 @@ +"""Timer module.""" + +# Модуль таймера. +# Отвечает за установку таймеров (в оперативной памяти), их проверку и воспроизведение звука. + +import subprocess +import re +from datetime import datetime, timedelta +from pathlib import Path +from ..core.config import BASE_DIR +from ..audio.stt import listen + +# Звуковой файл сигнала (используем тот же, что и для будильника) +ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3" + + +class TimerManager: + def __init__(self): + # Список активных таймеров: {"end_time": datetime, "label": str} + self.timers = [] + + def add_timer(self, seconds: int, label: str): + """Добавление нового таймера.""" + end_time = datetime.now() + timedelta(seconds=seconds) + self.timers.append({"end_time": end_time, "label": label}) + # Сортируем, чтобы ближайший был первым + self.timers.sort(key=lambda x: x["end_time"]) + print(f"⏳ Таймер установлен на {label} (до {end_time.strftime('%H:%M:%S')})") + + def cancel_all_timers(self): + """Отмена всех таймеров.""" + count = len(self.timers) + self.timers = [] + print(f"🔕 Все таймеры ({count}) отменены.") + + def check_timers(self): + """ + Проверка: не истек ли какой-то таймер? + Вызывается в главном цикле. + Возвращает True, если таймер сработал (и был обработан). + """ + if not self.timers: + return False + + now = datetime.now() + # Смотрим первый (самый ранний) таймер + # Используем индекс 0, так как список отсортирован + first_timer = self.timers[0] + + if now >= first_timer["end_time"]: + # Таймер сработал! + # Удаляем его из списка + label = first_timer["label"] + self.timers.pop(0) + + print(f"⌛ ТАЙМЕР ИСТЕК: {label}") + self.trigger_timer(label) + return True + + return False + + def trigger_timer(self, label: str): + """ + Логика срабатывания таймера. + Запускает воспроизведение MP3 и слушает команду "Стоп". + """ + print(f"🔔 ТАЙМЕР НА {label} СРАБОТАЛ! (Скажите 'Стоп')") + + # Запуск плеера mpg123 в бесконечном цикле + cmd = ["mpg123", "-q", "--loop", "-1", str(ALARM_SOUND)] + + try: + process = subprocess.Popen(cmd) + except FileNotFoundError: + print( + "❌ Ошибка: mpg123 не найден. Установите его: sudo apt install mpg123" + ) + return + + try: + stop_words = [ + "стоп", + "хватит", + "тихо", + "замолчи", + "отмена", + "александр стоп", + "спасибо", + ] + + # Цикл ожидания стоп-команды + while True: + text = listen(timeout_seconds=3.0, detection_timeout=3.0) + if text: + text_lower = text.lower() + if any(word in text_lower for word in stop_words): + print(f"🛑 Таймер остановлен по команде: '{text}'") + break + + except Exception as e: + print(f"❌ Ошибка во время таймера: {e}") + finally: + # Обязательно убиваем процесс плеера + process.terminate() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + print("🔕 Таймер выключен.") + + def parse_command(self, text: str) -> str | None: + """ + Парсинг команды установки таймера. + Примеры: "таймер на 5 минут", "засеки 10 секунд". + """ + text = text.lower() + + # Ключевые слова для таймера + if not any(word in text for word in ["таймер", "засеки", "поставь таймер"]): + return None + + if "отмени" in text or "удали" in text: + self.cancel_all_timers() + return "Хорошо, все таймеры отменены." + + # Поиск времени + # Ищем комбинации: число + (час/мин/сек) + # Пример: "1 час 30 минут", "5 минут", "30 секунд" + + total_seconds = 0 + found_time = False + parts = [] + + # Часы + match_hours = re.search(r"(\d+)\s*(?:час|часа|часов)", text) + if match_hours: + h = int(match_hours.group(1)) + total_seconds += h * 3600 + parts.append(f"{h} ч") + found_time = True + + # Минуты + match_minutes = re.search(r"(\d+)\s*(?:мин|минуту|минуты|минут)", text) + if match_minutes: + m = int(match_minutes.group(1)) + total_seconds += m * 60 + parts.append(f"{m} мин") + found_time = True + + # Секунды + match_seconds = re.search(r"(\d+)\s*(?:сек|секунду|секунды|секунд)", text) + if match_seconds: + s = int(match_seconds.group(1)) + total_seconds += s + parts.append(f"{s} сек") + found_time = True + + if found_time and total_seconds > 0: + label = " ".join(parts) + self.add_timer(total_seconds, label) + return f"Засек {label}." + + # Если сказали "таймер", но не нашли время + return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'." + + +# Глобальный экземпляр +_timer_manager = None + + +def get_timer_manager(): + global _timer_manager + if _timer_manager is None: + _timer_manager = TimerManager() + return _timer_manager \ No newline at end of file diff --git a/app/main.py b/app/main.py index c14e108..84088d7 100644 --- a/app/main.py +++ b/app/main.py @@ -14,12 +14,12 @@ Flow: # Главный файл приложения (`main.py`). # Здесь находится основной бесконечный цикл, который связывает все компоненты воедино. +import os +import queue +import re import signal import sys import threading -import queue -import re -import os from collections import deque # Для воспроизведения звуков (mp3) @@ -32,18 +32,25 @@ 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 ( - wait_for_wakeword, - cleanup as cleanup_wakeword, 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 .audio.stt import listen, cleanup as cleanup_stt, get_recognizer from .core.ai import ask_ai, ask_ai_stream, translate_text from .core.cleaner import clean_response -from .audio.tts import speak, initialize as init_tts -from .audio.sound_level import set_volume, parse_volume_text from .features.alarm import get_alarm_clock +from .features.timer import get_timer_manager from .features.weather import get_weather_report # Список стоп-слов, чтобы прервать диалог или остановить ассистента @@ -164,6 +171,7 @@ def main(): get_recognizer().initialize() # Подключение к Deepgram init_tts() # Загрузка нейросети для синтеза речи (Silero) alarm_clock = get_alarm_clock() # Загрузка будильников + timer_manager = get_timer_manager() # Загрузка таймеров print() # История чата (храним последние 10 обменов репликами для контекста) @@ -182,6 +190,12 @@ def main(): # Гарантируем, что микрофон детектора wake word освобожден stop_wakeword_monitoring() + # --- Проверка таймеров --- + # Проверяем каждую итерацию. Если таймер сработал, он заблокирует выполнение, пока его не выключат. + if timer_manager.check_timers(): + skip_wakeword = False + continue + # --- Проверка будильников --- # Проверяем каждую итерацию. Если будильник сработал, он заблокирует выполнение, пока его не выключат. if alarm_clock.check_alarms(): @@ -256,6 +270,17 @@ def main(): 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: