This commit is contained in:
2026-01-24 23:43:02 +03:00
parent 8f44fd2460
commit d6181cccb7
4 changed files with 219 additions and 8 deletions

8
.env.example Normal file
View File

@@ -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=Ухта

View File

@@ -8,6 +8,7 @@
- Озвучка (Silero TTS, RU/EN). - Озвучка (Silero TTS, RU/EN).
- Перевод RU↔EN (Perplexity). - Перевод RU↔EN (Perplexity).
- Будильник с голосовым отключением. - Будильник с голосовым отключением.
- Таймер с голосовым отключением.
- Управление громкостью (ALSA amixer). - Управление громкостью (ALSA amixer).
## Требования ## Требования
@@ -44,7 +45,9 @@ python main.py
- Перевод: «переведи с английского» → сказать фразу на английском - Перевод: «переведи с английского» → сказать фразу на английском
- Громкость: «громкость 5» - Громкость: «громкость 5»
- Будильник: «будильник на 7:30», «разбуди на 8 15» - Будильник: «будильник на 7:30», «разбуди на 8 15»
- Таймер: «таймер на 6 минут», «поставь будильник через 6 минут»
- Отмена будильников: «отмени будильник» - Отмена будильников: «отмени будильник»
- Отмена таймеров: «отмени таймер»
- Стоп/сброс: «стоп», «хватит» - Стоп/сброс: «стоп», «хватит»
## Объяснение работы ## Объяснение работы

175
app/features/timer.py Normal file
View File

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

View File

@@ -14,12 +14,12 @@ Flow:
# Главный файл приложения (`main.py`). # Главный файл приложения (`main.py`).
# Здесь находится основной бесконечный цикл, который связывает все компоненты воедино. # Здесь находится основной бесконечный цикл, который связывает все компоненты воедино.
import os
import queue
import re
import signal import signal
import sys import sys
import threading import threading
import queue
import re
import os
from collections import deque from collections import deque
# Для воспроизведения звуков (mp3) # Для воспроизведения звуков (mp3)
@@ -32,18 +32,25 @@ else:
_MIXER_IMPORT_ERROR = None _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 ( from .audio.wakeword import (
wait_for_wakeword,
cleanup as cleanup_wakeword,
check_wakeword_once, check_wakeword_once,
wait_for_wakeword,
)
from .audio.wakeword import (
cleanup as cleanup_wakeword,
)
from .audio.wakeword import (
stop_monitoring as stop_wakeword_monitoring, 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.ai import ask_ai, ask_ai_stream, translate_text
from .core.cleaner import clean_response 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.alarm import get_alarm_clock
from .features.timer import get_timer_manager
from .features.weather import get_weather_report from .features.weather import get_weather_report
# Список стоп-слов, чтобы прервать диалог или остановить ассистента # Список стоп-слов, чтобы прервать диалог или остановить ассистента
@@ -164,6 +171,7 @@ def main():
get_recognizer().initialize() # Подключение к Deepgram get_recognizer().initialize() # Подключение к Deepgram
init_tts() # Загрузка нейросети для синтеза речи (Silero) init_tts() # Загрузка нейросети для синтеза речи (Silero)
alarm_clock = get_alarm_clock() # Загрузка будильников alarm_clock = get_alarm_clock() # Загрузка будильников
timer_manager = get_timer_manager() # Загрузка таймеров
print() print()
# История чата (храним последние 10 обменов репликами для контекста) # История чата (храним последние 10 обменов репликами для контекста)
@@ -182,6 +190,12 @@ def main():
# Гарантируем, что микрофон детектора wake word освобожден # Гарантируем, что микрофон детектора wake word освобожден
stop_wakeword_monitoring() stop_wakeword_monitoring()
# --- Проверка таймеров ---
# Проверяем каждую итерацию. Если таймер сработал, он заблокирует выполнение, пока его не выключат.
if timer_manager.check_timers():
skip_wakeword = False
continue
# --- Проверка будильников --- # --- Проверка будильников ---
# Проверяем каждую итерацию. Если будильник сработал, он заблокирует выполнение, пока его не выключат. # Проверяем каждую итерацию. Если будильник сработал, он заблокирует выполнение, пока его не выключат.
if alarm_clock.check_alarms(): if alarm_clock.check_alarms():
@@ -256,6 +270,17 @@ def main():
skip_wakeword = True skip_wakeword = True
continue 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") # Проверка команд будильника ("поставь будильник на 7")
alarm_response = alarm_clock.parse_command(user_text) alarm_response = alarm_clock.parse_command(user_text)
if alarm_response: if alarm_response: