Улучшенный будильник, таймер, перевод
This commit is contained in:
@@ -6,3 +6,6 @@ TTS_EN_SPEAKER=en_0
|
||||
WEATHER_LAT=63.56
|
||||
WEATHER_LON=53.69
|
||||
WEATHER_CITY=Ухта
|
||||
SPOTIFY_CLIENT_ID=your_spotify_client_id
|
||||
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
|
||||
SPOTIFY_REDIRECT_URI=http://localhost:8888/callback
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
66
app/core/commands.py
Normal 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"\bзамолч\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достаточно\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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"\bчетверг\w*\b",
|
||||
4: r"\bпятниц\w*\b",
|
||||
5: r"\bсуббот\w*\b",
|
||||
6: r"\bвоскресен\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
324
app/features/music.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
70
app/main.py
70
app/main.py
@@ -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:
|
||||
|
||||
@@ -23,5 +23,21 @@
|
||||
"hour": 1,
|
||||
"minute": 19,
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"hour": 18,
|
||||
"minute": 15,
|
||||
"active": false,
|
||||
"days": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"hour": 18,
|
||||
"minute": 30,
|
||||
"active": false,
|
||||
"days": [
|
||||
1
|
||||
]
|
||||
}
|
||||
]
|
||||
1
data/timers.json
Normal file
1
data/timers.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -71,3 +71,4 @@ urllib3==2.6.2
|
||||
websockets==15.0.1
|
||||
yarl==1.22.0
|
||||
pygame
|
||||
spotipy
|
||||
|
||||
Reference in New Issue
Block a user