Улучшенный будильник, таймер, перевод

This commit is contained in:
2026-02-01 19:59:18 +03:00
parent 49dbaad122
commit d0b12009b3
15 changed files with 1013 additions and 105 deletions

View File

@@ -186,7 +186,7 @@ class SpeechRecognizer:
dg_connection.send(data)
chunks_sent += 1
if chunks_sent % 50 == 0:
print(f".", end="", flush=True)
print(".", end="", flush=True)
await asyncio.sleep(0.005)
except Exception as e:

View File

@@ -8,14 +8,16 @@ Supports interruption via wake word detection using threading.
# Использует нейросеть Silero TTS для качественной русской речи.
# Также поддерживает прерывание речи, если пользователь скажет "Alexandr".
import torch
import sounddevice as sd
import numpy as np
import re
import threading
import time
import warnings
import re
from ..core.config import TTS_SPEAKER, TTS_EN_SPEAKER, TTS_SAMPLE_RATE
import numpy as np
import sounddevice as sd
import torch
from ..core.config import TTS_EN_SPEAKER, TTS_SAMPLE_RATE, TTS_SPEAKER
# Подавляем предупреждения Silero о длинном тексте (мы сами его режем)
warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols")
@@ -46,12 +48,21 @@ class TextToSpeech:
if self.model_en:
return self.model_en
print("📦 Загрузка модели Silero TTS (en)...")
model, _ = torch.hub.load(
repo_or_dir="snakers4/silero-models",
model="silero_tts",
language="en",
speaker="v3_en",
)
try:
model, _ = torch.hub.load(
repo_or_dir="snakers4/silero-models",
model="silero_tts",
language="en",
speaker="v5_en",
)
except Exception as exc:
print(f"⚠️ Не удалось загрузить v5_en, пробую v3_en: {exc}")
model, _ = torch.hub.load(
repo_or_dir="snakers4/silero-models",
model="silero_tts",
language="en",
speaker="v3_en",
)
model.to(device)
self.model_en = model
return model
@@ -71,8 +82,18 @@ class TextToSpeech:
return model
def initialize(self):
"""Предварительная инициализация (прогрев) русской модели."""
"""Предварительная инициализация (прогрев) русской и английской моделей."""
self._load_model("ru")
self._load_model("en")
def _preprocess_text(self, text: str) -> str:
"""
Предварительная обработка текста перед озвучкой.
Заменяет тире между цифрами на слово "тире" для корректного чтения.
"""
# Замена 18-43 на "18 тире 43"
text = re.sub(r"(\d+)-(\d+)", r"\1 тире \2", text)
return text
def _split_text(self, text: str, max_length: int = 900) -> list[str]:
"""
@@ -263,6 +284,7 @@ class TextToSpeech:
return True
if language == "ru":
text = self._preprocess_text(text)
segments = self._split_mixed_language(text)
if any(lang == "en" for _, lang in segments):
return self._speak_mixed(segments, check_interrupt=check_interrupt)

View File

@@ -44,7 +44,7 @@ class WakeWordDetector:
if self.audio_stream:
try:
self.audio_stream.close()
except:
except Exception:
pass
# Открываем поток с параметрами, которые требует Porcupine
@@ -63,7 +63,7 @@ class WakeWordDetector:
try:
self.audio_stream.stop_stream()
self.audio_stream.close()
except:
except Exception:
pass
self._stream_closed = True

View File

@@ -4,6 +4,7 @@
# Обрабатывает запросы пользователя и переводы.
import requests
import re
from .config import PERPLEXITY_API_KEY, PERPLEXITY_MODEL, PERPLEXITY_API_URL
@@ -21,7 +22,9 @@ SYSTEM_PROMPT = """Ты — Александр, умный голосовой а
# Требует возвращать ТОЛЬКО перевод, без лишних слов ("Конечно, вот перевод...").
TRANSLATION_SYSTEM_PROMPT = """You are a translation engine.
Translate from {source} to {target}.
Return only the translated text, without quotes, comments, or explanations.
Return 2-3 short translation variants only.
No explanations, no quotes, no comments.
Separate variants with " / " (space slash space).
Keep the translation максимально кратким и естественным, без лишних слов."""
@@ -178,8 +181,22 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str:
response = _send_request(
messages,
max_tokens=400,
max_tokens=160,
temperature=0.2, # Низкая температура для точности перевода
error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
)
return response.strip()
cleaned = response.strip()
if not cleaned:
return cleaned
# Normalize to 2-3 variants separated by " / "
parts = []
for chunk in re.split(r"(?:\s*/\s*|\n|;|\|)", cleaned):
item = chunk.strip(" \t-•")
if item:
parts.append(item)
if not parts:
return cleaned
parts = parts[:3]
return " / ".join(parts)

View File

@@ -91,6 +91,9 @@ MONTHS_GENITIVE = [
"декабря",
]
# Леммы единиц времени (для корректного падежа числительных)
TIME_UNIT_LEMMAS = {"час", "минута", "секунда"}
def get_case_from_preposition(prep_token):
"""Определяет падеж по предлогу."""
@@ -127,7 +130,6 @@ def numbers_to_words(text: str) -> str:
# 1. Обработка годов: "в 1999 году", "2024 год"
def replace_year_match(match):
full_str = match.group(0)
prep = match.group(1) # Предлог (в, с, к...)
year_str = match.group(2) # Само число
year_word = match.group(3) # Слово "год", "году" и т.д.
@@ -207,9 +209,10 @@ def numbers_to_words(text: str) -> str:
case = "nominative"
gender = "m"
prep_clean = prep.strip().lower() if prep else None
if prep:
morph_case = get_case_from_preposition(prep.strip())
if prep_clean:
morph_case = get_case_from_preposition(prep_clean)
if morph_case:
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative")
@@ -221,6 +224,16 @@ def numbers_to_words(text: str) -> str:
morph_gender = parsed.tag.gender
gender = PYMORPHY_TO_GENDER.get(morph_gender, "m")
# Спец-случай: "на 1 час" -> "на один час" (не "одного")
# Для неодушевленных муж./ср. рода в винительном падеже
# числительные должны совпадать с именительным.
if (
prep_clean == "на"
and parsed.normal_form in TIME_UNIT_LEMMAS
and parsed.tag.gender in ("masc", "neut")
):
case = "nominative"
words = convert_number(
num_str, context_type="cardinal", case=case, gender=gender
)

66
app/core/commands.py Normal file
View File

@@ -0,0 +1,66 @@
"""
Command parsing helpers.
"""
import re
_STOP_WORDS_STRICT = {
"стоп",
"хватит",
"перестань",
"замолчи",
"прекрати",
"тихо",
"stop",
}
_STOP_PATTERNS_LENIENT = [
r"\bстоп\w*\b",
r"\bstop\b",
r"\bхватит\b",
r"\bперестан\w*\b",
r"\bпрекрат\w*\b",
r"\амолч\w*\b",
r"\bтише\b",
r"\bтихо\b",
r"\bвыключ\w*\b",
r"\bотключ\w*\b",
r"\bостанов\w*\b",
r"\bотмен\w*\b",
r"\bпауза\b",
r"\остаточно\b",
]
_STOP_PATTERNS_LENIENT_COMPILED = [re.compile(p) for p in _STOP_PATTERNS_LENIENT]
def _normalize_text(text: str) -> str:
text = text.lower().replace("ё", "е")
text = re.sub(r"[^\w\s]+", " ", text, flags=re.UNICODE)
text = re.sub(r"\s+", " ", text, flags=re.UNICODE).strip()
return text
def is_stop_command(text: str, mode: str = "strict") -> bool:
"""
Detect stop commands in text.
mode:
- "strict": only exact stop words.
- "lenient": broader patterns for noisy recognition.
"""
if not text:
return False
normalized = _normalize_text(text)
if not normalized:
return False
if mode == "strict":
words = normalized.split()
return any(word in _STOP_WORDS_STRICT for word in words)
for pattern in _STOP_PATTERNS_LENIENT_COMPILED:
if pattern.search(normalized):
return True
return False

View File

@@ -7,6 +7,7 @@ Loads environment variables from .env file.
# Он загружает настройки из файла .env (переменные окружения) и определяет константы.
import os
import time
from pathlib import Path
from dotenv import load_dotenv
@@ -40,7 +41,6 @@ CHANNELS = 1
# --- Настройка времени ---
# Устанавливаем часовой пояс на Москву, чтобы будильник работал корректно
import time
os.environ["TZ"] = "Europe/Moscow"
time.tzset()

View File

@@ -7,9 +7,9 @@ import json
import subprocess
import re
from datetime import datetime
from pathlib import Path
from ..core.config import BASE_DIR
from ..audio.stt import listen
from ..core.commands import is_stop_command
# Файл базы данных будильников
ALARM_FILE = BASE_DIR / "data" / "alarms.json"
@@ -22,6 +22,73 @@ class AlarmClock:
self.alarms = []
self.load_alarms()
def _normalize_days(self, days):
if not days:
return None
unique = sorted({int(day) for day in days})
return unique
def _days_key(self, days):
normalized = self._normalize_days(days)
return tuple(normalized) if normalized else None
def _format_days_phrase(self, days):
normalized = self._normalize_days(days)
if not normalized:
return ""
day_set = set(normalized)
if day_set == {0, 1, 2, 3, 4}:
return "по будням"
if day_set == {5, 6}:
return "по выходным"
if len(day_set) == 7:
return "каждый день"
names = {
0: "понедельникам",
1: "вторникам",
2: "средам",
3: "четвергам",
4: "пятницам",
5: "субботам",
6: "воскресеньям",
}
ordered = [names[d] for d in normalized if d in names]
if not ordered:
return ""
if len(ordered) == 1:
return f"по {ordered[0]}"
return "по " + ", ".join(ordered[:-1]) + " и " + ordered[-1]
def _extract_alarm_days(self, text: str):
text = text.lower().replace("ё", "е")
days = set()
if re.search(r"\b(каждый день|ежедневно)\b", text):
return [0, 1, 2, 3, 4, 5, 6]
if re.search(r"\b(по будн|в будн|будние)\b", text):
days.update([0, 1, 2, 3, 4])
if re.search(r"\b(по выходн|в выходн|выходные)\b", text):
days.update([5, 6])
day_patterns = {
0: r"\bпонедельн\w*\b",
1: r"\bвторник\w*\b",
2: r"\bсред\w*\b",
3: r"\етверг\w*\b",
4: r"\bпятниц\w*\b",
5: r"\bсуббот\w*\b",
6: r"\оскресен\w*\b",
}
for day_idx, pattern in day_patterns.items():
if re.search(pattern, text):
days.add(day_idx)
return self._normalize_days(days)
def load_alarms(self):
"""Загрузка списка будильников из JSON файла."""
if ALARM_FILE.exists():
@@ -42,15 +109,30 @@ class AlarmClock:
def add_alarm(self, hour: int, minute: int):
"""Добавление нового будильника (или обновление существующего)."""
return self.add_alarm_with_days(hour, minute, days=None)
def add_alarm_with_days(self, hour: int, minute: int, days=None):
"""Добавление нового будильника (или обновление существующего) с днями недели."""
days_key = self._days_key(days)
for alarm in self.alarms:
if alarm["hour"] == hour and alarm["minute"] == minute:
if (
alarm.get("hour") == hour
and alarm.get("minute") == minute
and self._days_key(alarm.get("days")) == days_key
):
alarm["active"] = True
alarm["days"] = days_key
alarm["last_triggered"] = None
self.save_alarms()
return
self.alarms.append({"hour": hour, "minute": minute, "active": True})
self.alarms.append(
{"hour": hour, "minute": minute, "active": True, "days": days_key}
)
self.save_alarms()
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}")
days_phrase = self._format_days_phrase(days_key)
suffix = f" {days_phrase}" if days_phrase else ""
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}{suffix}")
def cancel_all_alarms(self):
"""Выключение (деактивация) всех будильников."""
@@ -59,6 +141,27 @@ class AlarmClock:
self.save_alarms()
print("🔕 Все будильники отменены.")
def describe_alarms(self) -> str:
"""Возвращает текстовое описание активных будильников."""
active = [
alarm
for alarm in self.alarms
if alarm.get("active") and "hour" in alarm and "minute" in alarm
]
if not active:
return "Активных будильников нет."
active.sort(key=lambda a: (a["hour"], a["minute"]))
items = []
for alarm in active:
time_str = f"{alarm['hour']:02d}:{alarm['minute']:02d}"
days_phrase = self._format_days_phrase(alarm.get("days"))
if days_phrase:
items.append(f"{days_phrase} в {time_str}")
else:
items.append(time_str)
return "Активные будильники: " + ", ".join(items) + "."
def check_alarms(self):
"""
Проверка: не пора ли звенеть?
@@ -71,12 +174,30 @@ class AlarmClock:
for alarm in self.alarms:
if alarm["active"]:
if alarm["hour"] == now.hour and alarm["minute"] == now.minute:
days = self._normalize_days(alarm.get("days"))
if days and now.weekday() not in days:
continue
last_triggered = alarm.get("last_triggered")
if last_triggered:
try:
last_dt = datetime.fromisoformat(last_triggered)
if (
last_dt.date() == now.date()
and last_dt.hour == now.hour
and last_dt.minute == now.minute
):
continue
except Exception:
pass
print(
f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}"
)
alarm["active"] = (
False # Одноразовый будильник, выключаем после срабатывания
)
if not days:
# Одноразовый будильник, выключаем после срабатывания
alarm["active"] = False
alarm["last_triggered"] = now.isoformat()
triggered = True
self.trigger_alarm() # Запуск звука и ожидание стоп-слова
break # Звоним только один за раз
@@ -106,21 +227,11 @@ class AlarmClock:
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):
if is_stop_command(text, mode="lenient"):
print(f"🛑 Будильник остановлен по команде: '{text}'")
break
@@ -144,17 +255,26 @@ class AlarmClock:
if "будильник" not in text and "разбуди" not in text:
return None
if "будильник" in text and re.search(
r"(какие|какой|список|активн|покажи|сколько|есть ли)", text
):
return self.describe_alarms()
if "отмени" in text:
self.cancel_all_alarms()
return "Хорошо, я отменил все будильники."
days = self._extract_alarm_days(text)
# Поиск формата "7:30", "7.30"
match = re.search(r"\b(\d{1,2})[:.-](\d{2})\b", text)
if match:
h, m = int(match.group(1)), int(match.group(2))
if 0 <= h <= 23 and 0 <= m <= 59:
self.add_alarm(h, m)
return f"Я установил будильник на {h} часов {m} минут."
self.add_alarm_with_days(h, m, days=days)
days_phrase = self._format_days_phrase(days)
suffix = f" {days_phrase}" if days_phrase else ""
return f"Я установил будильник на {h} часов {m} минут{suffix}."
# Поиск формата словами "на 7 часов 15 минут"
match_time = re.search(
@@ -174,8 +294,10 @@ class AlarmClock:
h = 0
if 0 <= h <= 23 and 0 <= m <= 59:
self.add_alarm(h, m)
return f"Хорошо, разбужу вас в {h}:{m:02d}."
self.add_alarm_with_days(h, m, days=days)
days_phrase = self._format_days_phrase(days)
suffix = f" {days_phrase}" if days_phrase else ""
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."

324
app/features/music.py Normal file
View File

@@ -0,0 +1,324 @@
"""
Spotify Music Controller
Модуль для управления воспроизведением музыки через Spotify API.
Поддерживаемые команды:
- "включи музыку" / "play music" - воспроизведение топ-треков или по запросу
- "пауза" / "стоп музыка" - пауза
- "продолжи" / "дальше" - возобновление
- "следующий трек" / "next" - следующий трек
- "предыдущий трек" / "previous" - предыдущий трек
- "что играет" / "какая песня" - информация о текущем треке
"""
import os
import re
from typing import Optional
try:
import spotipy
from spotipy.oauth2 import SpotifyOAuth
except ImportError:
spotipy = None
SpotifyOAuth = None
# Singleton instance
_music_controller = None
class SpotifyMusicController:
"""Контроллер для управления Spotify воспроизведением."""
# Scopes для Spotify API
SCOPES = [
"user-read-playback-state",
"user-modify-playback-state",
"user-read-currently-playing",
"user-top-read",
"streaming",
]
def __init__(self):
"""Инициализация контроллера Spotify."""
self.sp = None
self._initialized = False
self._init_error = None
def initialize(self) -> bool:
"""
Инициализация подключения к Spotify.
Возвращает True при успехе, False при ошибке.
"""
if self._initialized:
return True
if spotipy is None:
self._init_error = "Библиотека spotipy не установлена. Установите: pip install spotipy"
print(f"⚠️ Spotify: {self._init_error}")
return False
# Проверяем наличие ключей
client_id = os.getenv("SPOTIFY_CLIENT_ID")
client_secret = os.getenv("SPOTIFY_CLIENT_SECRET")
redirect_uri = os.getenv("SPOTIFY_REDIRECT_URI", "http://localhost:8888/callback")
if not client_id or not client_secret:
self._init_error = "Не заданы SPOTIFY_CLIENT_ID и SPOTIFY_CLIENT_SECRET в .env"
print(f"⚠️ Spotify: {self._init_error}")
return False
try:
auth_manager = SpotifyOAuth(
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
scope=" ".join(self.SCOPES),
cache_path=".spotify_cache",
)
self.sp = spotipy.Spotify(auth_manager=auth_manager)
# Проверяем подключение
self.sp.current_user()
self._initialized = True
print("✅ Spotify: подключено")
return True
except Exception as e:
self._init_error = str(e)
print(f"❌ Spotify: ошибка инициализации - {e}")
return False
def _ensure_initialized(self) -> bool:
"""Проверяет и инициализирует при необходимости."""
if not self._initialized:
return self.initialize()
return True
def _get_active_device(self) -> Optional[str]:
"""Получить ID активного устройства воспроизведения."""
try:
devices = self.sp.devices()
if devices and devices.get("devices"):
# Ищем активное устройство или берем первое
for device in devices["devices"]:
if device.get("is_active"):
return device["id"]
# Если нет активного, берем первое
return devices["devices"][0]["id"]
except Exception:
pass
return None
def play_music(self, query: Optional[str] = None) -> str:
"""
Включить музыку.
Args:
query: Поисковый запрос (название песни/артиста).
Если None, включает топ-треки пользователя.
Returns:
Сообщение для озвучки.
"""
if not self._ensure_initialized():
return f"Не удалось подключиться к Spotify. {self._init_error or ''}"
try:
device_id = self._get_active_device()
if query:
# Поиск по запросу
results = self.sp.search(q=query, type="track", limit=1)
tracks = results.get("tracks", {}).get("items", [])
if not tracks:
return f"Не нашёл песню '{query}'"
track = tracks[0]
track_name = track["name"]
artist_name = track["artists"][0]["name"]
track_uri = track["uri"]
self.sp.start_playback(device_id=device_id, uris=[track_uri])
return f"Включаю {track_name} от {artist_name}"
else:
# Включаем топ-треки пользователя
top_tracks = self.sp.current_user_top_tracks(limit=20, time_range="medium_term")
if top_tracks and top_tracks.get("items"):
uris = [track["uri"] for track in top_tracks["items"]]
self.sp.start_playback(device_id=device_id, uris=uris)
first_track = top_tracks["items"][0]
return f"Включаю вашу музыку. Сейчас играет {first_track['name']}"
else:
# Если нет топ-треков, ставим что-нибудь популярное
self.sp.start_playback(device_id=device_id)
return "Включаю музыку"
except spotipy.SpotifyException as e:
if "NO_ACTIVE_DEVICE" in str(e) or "Player command failed" in str(e):
return "Нет активного устройства Spotify. Откройте Spotify на телефоне или компьютере."
elif "PREMIUM_REQUIRED" in str(e):
return "Для управления воспроизведением нужен Spotify Premium."
return f"Ошибка Spotify: {e.reason if hasattr(e, 'reason') else str(e)}"
except Exception as e:
return f"Ошибка воспроизведения: {e}"
def pause_music(self) -> str:
"""Поставить на паузу."""
if not self._ensure_initialized():
return "Spotify не подключён"
try:
self.sp.pause_playback()
return "Музыка на паузе"
except spotipy.SpotifyException as e:
if "NO_ACTIVE_DEVICE" in str(e):
return "Нет активного устройства Spotify"
return f"Не удалось поставить на паузу: {e}"
except Exception as e:
return f"Ошибка: {e}"
def resume_music(self) -> str:
"""Продолжить воспроизведение."""
if not self._ensure_initialized():
return "Spotify не подключён"
try:
self.sp.start_playback()
return "Продолжаю воспроизведение"
except spotipy.SpotifyException as e:
if "NO_ACTIVE_DEVICE" in str(e):
return "Нет активного устройства Spotify"
return f"Ошибка: {e}"
except Exception as e:
return f"Ошибка: {e}"
def next_track(self) -> str:
"""Следующий трек."""
if not self._ensure_initialized():
return "Spotify не подключён"
try:
self.sp.next_track()
# Небольшая задержка для обновления состояния
import time
time.sleep(0.5)
return self.get_current_track() or "Переключаю на следующий трек"
except Exception as e:
return f"Не удалось переключить трек: {e}"
def previous_track(self) -> str:
"""Предыдущий трек."""
if not self._ensure_initialized():
return "Spotify не подключён"
try:
self.sp.previous_track()
import time
time.sleep(0.5)
return self.get_current_track() or "Переключаю на предыдущий трек"
except Exception as e:
return f"Не удалось переключить трек: {e}"
def get_current_track(self) -> Optional[str]:
"""Получить информацию о текущем треке."""
if not self._ensure_initialized():
return "Spotify не подключён"
try:
current = self.sp.current_playback()
if current and current.get("item"):
track = current["item"]
name = track["name"]
artists = ", ".join(a["name"] for a in track["artists"])
is_playing = current.get("is_playing", False)
status = "Сейчас играет" if is_playing else "На паузе"
return f"{status}: {name} от {artists}"
return "Сейчас ничего не играет"
except Exception as e:
return f"Не удалось получить информацию: {e}"
def parse_command(self, text: str) -> Optional[str]:
"""
Распознать музыкальную команду и выполнить её.
Args:
text: Текст команды от пользователя.
Returns:
Ответ для озвучки или None, если это не музыкальная команда.
"""
text_lower = text.lower().strip()
# Команды паузы
pause_patterns = [
r"^(поставь на паузу|пауза|стоп музык|останови музык|выключи музык)",
r"(pause|stop music)",
]
for pattern in pause_patterns:
if re.search(pattern, text_lower):
return self.pause_music()
# Команды продолжения
resume_patterns = [
r"^(продолжи|продолжай|возобнови|сними с паузы|дальше|играй дальше)",
r"(resume|continue|play)",
]
for pattern in resume_patterns:
if re.search(pattern, text_lower):
return self.resume_music()
# Следующий трек
next_patterns = [
r"(следующ|дальше|скип|пропусти|next|skip)",
]
for pattern in next_patterns:
if re.search(pattern, text_lower) and "трек" in text_lower or "песн" in text_lower or "skip" in text_lower or "next" in text_lower:
return self.next_track()
# Предыдущий трек
prev_patterns = [
r"(предыдущ|назад|верни|previous|back)",
]
for pattern in prev_patterns:
if re.search(pattern, text_lower) and ("трек" in text_lower or "песн" in text_lower or "previous" in text_lower or "back" in text_lower):
return self.previous_track()
# Что играет
current_patterns = [
r"(что (сейчас )?играет|как(ая|ой) (песня|трек)|что за (песня|трек|музыка))",
r"(what.*(play|song)|current track)",
]
for pattern in current_patterns:
if re.search(pattern, text_lower):
return self.get_current_track()
# Включить музыку (с возможным запросом)
play_patterns = [
(r"^включи\s+музыку$", None), # Просто "включи музыку"
(r"^включи\s+(.+)$", 1), # "включи [что-то]"
(r"^поставь\s+(.+)$", 1), # "поставь [что-то]"
(r"^играй\s+(.+)$", 1), # "играй [что-то]"
(r"^play\s+(.+)$", 1), # "play [something]"
(r"^(play music|включи музыку|поставь музыку)$", None),
]
for pattern, group in play_patterns:
match = re.search(pattern, text_lower)
if match:
query = None
if group:
query = match.group(group).strip()
# Убираем слова "музыку", "песню", "трек" из запроса
query = re.sub(r"(музыку|песню|трек|песня|song|track)\s*", "", query).strip()
if not query:
query = None
return self.play_music(query)
return None
def get_music_controller() -> SpotifyMusicController:
"""Получить singleton экземпляр контроллера музыки."""
global _music_controller
if _music_controller is None:
_music_controller = SpotifyMusicController()
return _music_controller

View File

@@ -5,19 +5,307 @@
import subprocess
import re
import json
from datetime import datetime, timedelta
from pathlib import Path
from ..core.config import BASE_DIR
from ..audio.stt import listen
from ..core.commands import is_stop_command
# Morphological analysis for better recognition of number words.
try:
import pymorphy3
_MORPH = pymorphy3.MorphAnalyzer()
except Exception:
_MORPH = None
# Звуковой файл сигнала (используем тот же, что и для будильника)
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
TIMER_FILE = BASE_DIR / "data" / "timers.json"
# --- Number words parsing helpers (ru) ---
_NUMBER_UNITS = {
"ноль": 0,
"один": 1,
"два": 2,
"три": 3,
"четыре": 4,
"пять": 5,
"шесть": 6,
"семь": 7,
"восемь": 8,
"девять": 9,
}
_NUMBER_TEENS = {
"десять": 10,
"одиннадцать": 11,
"двенадцать": 12,
"тринадцать": 13,
"четырнадцать": 14,
"пятнадцать": 15,
"шестнадцать": 16,
"семнадцать": 17,
"восемнадцать": 18,
"девятнадцать": 19,
}
_NUMBER_TENS = {
"двадцать": 20,
"тридцать": 30,
"сорок": 40,
"пятьдесят": 50,
"шестьдесят": 60,
"семьдесят": 70,
"восемьдесят": 80,
"девяносто": 90,
}
_NUMBER_HUNDREDS = {
"сто": 100,
"двести": 200,
"триста": 300,
"четыреста": 400,
"пятьсот": 500,
"шестьсот": 600,
"семьсот": 700,
"восемьсот": 800,
"девятьсот": 900,
}
_NUMBER_SPECIAL = {
"пол": 0.5,
"полтора": 1.5,
"полторы": 1.5,
}
_NUMBER_LEMMAS = (
set(_NUMBER_UNITS)
| set(_NUMBER_TEENS)
| set(_NUMBER_TENS)
| set(_NUMBER_HUNDREDS)
| set(_NUMBER_SPECIAL)
)
_IGNORED_LEMMAS = {
"на",
"в",
"во",
"за",
"через",
"по",
"к",
"ко",
"с",
"со",
"и",
}
_UNIT_LEMMAS = {
"час": "hours",
"минута": "minutes",
"секунда": "seconds",
"мин": "minutes",
"сек": "seconds",
}
_UNIT_FORMS = {
"hours": ("час", "часа", "часов"),
"minutes": ("минуту", "минуты", "минут"),
"seconds": ("секунду", "секунды", "секунд"),
}
# Optional ordinal formatting for list numbering.
try:
from num2words import num2words
except Exception:
num2words = None
def _lemmatize(token: str) -> str:
if _MORPH is None:
return token
return _MORPH.parse(token)[0].normal_form
def _tokenize_with_lemmas(text: str):
tokens = []
for match in re.finditer(r"[a-zA-Zа-яА-ЯёЁ]+|\d+", text.lower()):
raw = match.group(0)
lemma = _lemmatize(raw) if not raw.isdigit() else raw
tokens.append({"raw": raw, "lemma": lemma})
return tokens
def _parse_number_lemmas(lemmas):
if not lemmas:
return None
if len(lemmas) == 1 and lemmas[0] in _NUMBER_SPECIAL:
return _NUMBER_SPECIAL[lemmas[0]]
total = 0
idx = 0
if lemmas[idx] in _NUMBER_HUNDREDS:
total += _NUMBER_HUNDREDS[lemmas[idx]]
idx += 1
if idx < len(lemmas) and lemmas[idx] in _NUMBER_TEENS:
total += _NUMBER_TEENS[lemmas[idx]]
idx += 1
else:
if idx < len(lemmas) and lemmas[idx] in _NUMBER_TENS:
total += _NUMBER_TENS[lemmas[idx]]
idx += 1
if idx < len(lemmas) and lemmas[idx] in _NUMBER_UNITS:
total += _NUMBER_UNITS[lemmas[idx]]
idx += 1
if total == 0 and lemmas[0] in _NUMBER_UNITS:
return _NUMBER_UNITS[lemmas[0]]
return total if total > 0 else None
def _normalize_timer_text(text: str) -> str:
# Split "полчаса/полминуты/полсекунды" into "пол часа" for easier parsing.
return re.sub(
r"(?i)\bпол(?=(?:час|часа|минут|минуты|минуту|секунд|секунды|секунду|мин|сек)\b)",
"пол ",
text,
)
def _find_word_number_before_unit(tokens, unit_index):
collected = []
idx = unit_index - 1
while idx >= 0 and len(collected) < 4:
lemma = tokens[idx]["lemma"]
if lemma in _IGNORED_LEMMAS:
idx -= 1
continue
if lemma in _NUMBER_LEMMAS:
collected.insert(0, lemma)
idx -= 1
continue
break
return _parse_number_lemmas(collected)
def _extract_word_time_values(text: str):
tokens = _tokenize_with_lemmas(text)
values = {"hours": None, "minutes": None, "seconds": None}
for idx, token in enumerate(tokens):
lemma = token["lemma"]
key = _UNIT_LEMMAS.get(lemma)
if not key:
continue
value = _find_word_number_before_unit(tokens, idx)
if value is not None:
values[key] = value
return values
def _format_unit(value: int, unit_key: str) -> str:
if unit_key not in _UNIT_FORMS:
return f"{value}"
one, few, many = _UNIT_FORMS[unit_key]
n = abs(int(value))
if n % 100 in (11, 12, 13, 14):
word = many
elif n % 10 == 1:
word = one
elif n % 10 in (2, 3, 4):
word = few
else:
word = many
return f"{value} {word}"
def _format_duration(total_seconds: float) -> str:
total_seconds = int(round(total_seconds))
if total_seconds < 0:
total_seconds = 0
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
parts = []
if hours:
parts.append(_format_unit(hours, "hours"))
if minutes:
parts.append(_format_unit(minutes, "minutes"))
if seconds or not parts:
parts.append(_format_unit(seconds, "seconds"))
return " ".join(parts)
def _format_ordinal_index(index: int) -> str:
if num2words is None:
return f"{index}"
try:
return num2words(index, lang="ru", to="ordinal", case="nominative", gender="m")
except Exception:
return f"{index}"
class TimerManager:
def __init__(self):
# Список активных таймеров: {"end_time": datetime, "label": str}
self.timers = []
self.load_timers()
def load_timers(self):
"""Загрузка списка таймеров из JSON файла."""
if TIMER_FILE.exists():
try:
with open(TIMER_FILE, "r", encoding="utf-8") as f:
raw = json.load(f)
except Exception as e:
print(f"❌ Ошибка загрузки таймеров: {e}")
return
timers = []
for item in raw:
try:
end_time = datetime.fromisoformat(item["end_time"])
except Exception:
continue
label = item.get("label", "")
timers.append({"end_time": end_time, "label": label})
self.timers = sorted(timers, key=lambda x: x["end_time"])
def save_timers(self):
"""Сохранение списка таймеров в JSON файл."""
payload = [
{"end_time": t["end_time"].isoformat(), "label": t.get("label", "")}
for t in self.timers
]
try:
with open(TIMER_FILE, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=4)
except Exception as e:
print(f"❌ Ошибка сохранения таймеров: {e}")
def describe_timers(self) -> str:
"""Возвращает текстовое описание активных таймеров."""
if not self.timers:
return "Активных таймеров нет."
now = datetime.now()
items = []
for idx, timer in enumerate(self.timers, start=1):
remaining = (timer["end_time"] - now).total_seconds()
label = timer.get("label", "").strip()
ordinal = _format_ordinal_index(idx)
if remaining <= 0:
status = "сработает сейчас"
else:
status = f"осталось {_format_duration(remaining)}"
if label:
items.append(f"{ordinal}) {label}{status}")
else:
items.append(f"{ordinal}) {status}")
return "Активные таймеры: " + "; ".join(items) + "."
def add_timer(self, seconds: int, label: str):
"""Добавление нового таймера."""
@@ -25,12 +313,14 @@ class TimerManager:
self.timers.append({"end_time": end_time, "label": label})
# Сортируем, чтобы ближайший был первым
self.timers.sort(key=lambda x: x["end_time"])
self.save_timers()
print(f"⏳ Таймер установлен на {label} (до {end_time.strftime('%H:%M:%S')})")
def cancel_all_timers(self):
"""Отмена всех таймеров."""
count = len(self.timers)
self.timers = []
self.save_timers()
print(f"🔕 Все таймеры ({count}) отменены.")
def check_timers(self):
@@ -52,6 +342,7 @@ class TimerManager:
# Удаляем его из списка
label = first_timer["label"]
self.timers.pop(0)
self.save_timers()
print(f"⌛ ТАЙМЕР ИСТЕК: {label}")
self.trigger_timer(label)
@@ -78,22 +369,11 @@ class TimerManager:
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):
if is_stop_command(text, mode="lenient"):
print(f"🛑 Таймер остановлен по команде: '{text}'")
break
@@ -113,12 +393,17 @@ class TimerManager:
Парсинг команды установки таймера.
Примеры: "таймер на 5 минут", "засеки 10 секунд".
"""
text = text.lower()
text = _normalize_timer_text(text.lower())
# Ключевые слова для таймера
if not any(word in text for word in ["таймер", "засеки", "поставь таймер"]):
return None
if "таймер" in text and re.search(
r"(какие|какой|список|активн|покажи|сколько|есть ли)", text
):
return self.describe_timers()
if "отмени" in text or "удали" in text:
self.cancel_all_timers()
return "Хорошо, все таймеры отменены."
@@ -128,37 +413,69 @@ class TimerManager:
# Пример: "1 час 30 минут", "5 минут", "30 секунд"
total_seconds = 0
found_time = False
parts = []
hours = None
minutes = None
seconds = None
has_fractional = False
# Часы
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
hours = int(match_hours.group(1))
# Минуты
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
minutes = int(match_minutes.group(1))
# Секунды
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
seconds = int(match_seconds.group(1))
# Дополняем числительные словами (например, "одну минуту")
word_values = _extract_word_time_values(text)
if hours is None and word_values["hours"] is not None:
hours = word_values["hours"]
if minutes is None and word_values["minutes"] is not None:
minutes = word_values["minutes"]
if seconds is None and word_values["seconds"] is not None:
seconds = word_values["seconds"]
if hours is not None and isinstance(hours, float) and hours % 1 != 0:
has_fractional = True
if minutes is not None and isinstance(minutes, float) and minutes % 1 != 0:
has_fractional = True
if seconds is not None and isinstance(seconds, float) and seconds % 1 != 0:
has_fractional = True
found_time = any(value is not None for value in [hours, minutes, seconds])
if found_time:
total_seconds = (
(hours or 0) * 3600 + (minutes or 0) * 60 + (seconds or 0)
)
if has_fractional:
total_seconds = int(round(total_seconds))
h = total_seconds // 3600
m = (total_seconds % 3600) // 60
s = total_seconds % 60
else:
h = int(hours) if hours is not None else 0
m = int(minutes) if minutes is not None else 0
s = int(seconds) if seconds is not None else 0
if h:
parts.append(_format_unit(h, "hours"))
if m:
parts.append(_format_unit(m, "minutes"))
if s or not parts:
parts.append(_format_unit(s, "seconds"))
if found_time and total_seconds > 0:
label = " ".join(parts)
self.add_timer(total_seconds, label)
return f"Засек {label}."
return f"Поставил таймер на {label}."
# Если сказали "таймер", но не нашли время
return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'."
@@ -172,4 +489,4 @@ def get_timer_manager():
global _timer_manager
if _timer_manager is None:
_timer_manager = TimerManager()
return _timer_manager
return _timer_manager

View File

@@ -47,23 +47,13 @@ from .audio.wakeword import (
from .audio.wakeword import (
stop_monitoring as stop_wakeword_monitoring,
)
from .core.ai import ask_ai, ask_ai_stream, translate_text
from .core.ai import ask_ai_stream, translate_text
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
# Список стоп-слов, чтобы прервать диалог или остановить ассистента
STOP_WORDS = {
"стоп",
"хватит",
"перестань",
"замолчи",
"прекрати",
"тихо",
"stop",
}
from .features.music import get_music_controller
def signal_handler(sig, frame):
"""
@@ -85,8 +75,22 @@ def parse_translation_request(text: str):
Или 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"),
@@ -95,18 +99,25 @@ def parse_translation_request(text: str):
("как по английски", "ru", "en"),
("как по-русски", "en", "ru"),
("как по русски", "en", "ru"),
("translate to english", "ru", "en"),
("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 to russian", "en", "ru"),
("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 commands:
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,
@@ -115,21 +126,6 @@ def parse_translation_request(text: str):
return None
def is_stop_command(text: str) -> bool:
"""
Проверяет, содержится ли в тексте команда остановки.
Удаляет знаки препинания и ищет слова из списка STOP_WORDS.
"""
text_lower = text.lower()
for ch in ",.!?:;":
text_lower = text_lower.replace(ch, " ")
words = text_lower.split()
for word in words:
if word in STOP_WORDS:
return True
return False
def main():
"""
Основная функция (точка входа).
@@ -340,6 +336,16 @@ def main():
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: