Улучшенный будильник, таймер, перевод
This commit is contained in:
@@ -6,3 +6,6 @@ TTS_EN_SPEAKER=en_0
|
|||||||
WEATHER_LAT=63.56
|
WEATHER_LAT=63.56
|
||||||
WEATHER_LON=53.69
|
WEATHER_LON=53.69
|
||||||
WEATHER_CITY=Ухта
|
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)
|
dg_connection.send(data)
|
||||||
chunks_sent += 1
|
chunks_sent += 1
|
||||||
if chunks_sent % 50 == 0:
|
if chunks_sent % 50 == 0:
|
||||||
print(f".", end="", flush=True)
|
print(".", end="", flush=True)
|
||||||
await asyncio.sleep(0.005)
|
await asyncio.sleep(0.005)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ Supports interruption via wake word detection using threading.
|
|||||||
# Использует нейросеть Silero TTS для качественной русской речи.
|
# Использует нейросеть Silero TTS для качественной русской речи.
|
||||||
# Также поддерживает прерывание речи, если пользователь скажет "Alexandr".
|
# Также поддерживает прерывание речи, если пользователь скажет "Alexandr".
|
||||||
|
|
||||||
import torch
|
import re
|
||||||
import sounddevice as sd
|
|
||||||
import numpy as np
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import warnings
|
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 о длинном тексте (мы сами его режем)
|
# Подавляем предупреждения Silero о длинном тексте (мы сами его режем)
|
||||||
warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols")
|
warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols")
|
||||||
@@ -46,6 +48,15 @@ class TextToSpeech:
|
|||||||
if self.model_en:
|
if self.model_en:
|
||||||
return self.model_en
|
return self.model_en
|
||||||
print("📦 Загрузка модели Silero TTS (en)...")
|
print("📦 Загрузка модели Silero TTS (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(
|
model, _ = torch.hub.load(
|
||||||
repo_or_dir="snakers4/silero-models",
|
repo_or_dir="snakers4/silero-models",
|
||||||
model="silero_tts",
|
model="silero_tts",
|
||||||
@@ -71,8 +82,18 @@ class TextToSpeech:
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
"""Предварительная инициализация (прогрев) русской модели."""
|
"""Предварительная инициализация (прогрев) русской и английской моделей."""
|
||||||
self._load_model("ru")
|
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]:
|
def _split_text(self, text: str, max_length: int = 900) -> list[str]:
|
||||||
"""
|
"""
|
||||||
@@ -263,6 +284,7 @@ class TextToSpeech:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
if language == "ru":
|
if language == "ru":
|
||||||
|
text = self._preprocess_text(text)
|
||||||
segments = self._split_mixed_language(text)
|
segments = self._split_mixed_language(text)
|
||||||
if any(lang == "en" for _, lang in segments):
|
if any(lang == "en" for _, lang in segments):
|
||||||
return self._speak_mixed(segments, check_interrupt=check_interrupt)
|
return self._speak_mixed(segments, check_interrupt=check_interrupt)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class WakeWordDetector:
|
|||||||
if self.audio_stream:
|
if self.audio_stream:
|
||||||
try:
|
try:
|
||||||
self.audio_stream.close()
|
self.audio_stream.close()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Открываем поток с параметрами, которые требует Porcupine
|
# Открываем поток с параметрами, которые требует Porcupine
|
||||||
@@ -63,7 +63,7 @@ class WakeWordDetector:
|
|||||||
try:
|
try:
|
||||||
self.audio_stream.stop_stream()
|
self.audio_stream.stop_stream()
|
||||||
self.audio_stream.close()
|
self.audio_stream.close()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._stream_closed = True
|
self._stream_closed = True
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
# Обрабатывает запросы пользователя и переводы.
|
# Обрабатывает запросы пользователя и переводы.
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import re
|
||||||
from .config import PERPLEXITY_API_KEY, PERPLEXITY_MODEL, PERPLEXITY_API_URL
|
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.
|
TRANSLATION_SYSTEM_PROMPT = """You are a translation engine.
|
||||||
Translate from {source} to {target}.
|
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 максимально кратким и естественным, без лишних слов."""
|
Keep the translation максимально кратким и естественным, без лишних слов."""
|
||||||
|
|
||||||
|
|
||||||
@@ -178,8 +181,22 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str:
|
|||||||
|
|
||||||
response = _send_request(
|
response = _send_request(
|
||||||
messages,
|
messages,
|
||||||
max_tokens=400,
|
max_tokens=160,
|
||||||
temperature=0.2, # Низкая температура для точности перевода
|
temperature=0.2, # Низкая температура для точности перевода
|
||||||
error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
|
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):
|
def get_case_from_preposition(prep_token):
|
||||||
"""Определяет падеж по предлогу."""
|
"""Определяет падеж по предлогу."""
|
||||||
@@ -127,7 +130,6 @@ def numbers_to_words(text: str) -> str:
|
|||||||
|
|
||||||
# 1. Обработка годов: "в 1999 году", "2024 год"
|
# 1. Обработка годов: "в 1999 году", "2024 год"
|
||||||
def replace_year_match(match):
|
def replace_year_match(match):
|
||||||
full_str = match.group(0)
|
|
||||||
prep = match.group(1) # Предлог (в, с, к...)
|
prep = match.group(1) # Предлог (в, с, к...)
|
||||||
year_str = match.group(2) # Само число
|
year_str = match.group(2) # Само число
|
||||||
year_word = match.group(3) # Слово "год", "году" и т.д.
|
year_word = match.group(3) # Слово "год", "году" и т.д.
|
||||||
@@ -207,9 +209,10 @@ def numbers_to_words(text: str) -> str:
|
|||||||
|
|
||||||
case = "nominative"
|
case = "nominative"
|
||||||
gender = "m"
|
gender = "m"
|
||||||
|
prep_clean = prep.strip().lower() if prep else None
|
||||||
|
|
||||||
if prep:
|
if prep_clean:
|
||||||
morph_case = get_case_from_preposition(prep.strip())
|
morph_case = get_case_from_preposition(prep_clean)
|
||||||
if morph_case:
|
if morph_case:
|
||||||
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative")
|
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative")
|
||||||
|
|
||||||
@@ -221,6 +224,16 @@ def numbers_to_words(text: str) -> str:
|
|||||||
morph_gender = parsed.tag.gender
|
morph_gender = parsed.tag.gender
|
||||||
gender = PYMORPHY_TO_GENDER.get(morph_gender, "m")
|
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(
|
words = convert_number(
|
||||||
num_str, context_type="cardinal", case=case, gender=gender
|
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 (переменные окружения) и определяет константы.
|
# Он загружает настройки из файла .env (переменные окружения) и определяет константы.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
@@ -40,7 +41,6 @@ CHANNELS = 1
|
|||||||
|
|
||||||
# --- Настройка времени ---
|
# --- Настройка времени ---
|
||||||
# Устанавливаем часовой пояс на Москву, чтобы будильник работал корректно
|
# Устанавливаем часовой пояс на Москву, чтобы будильник работал корректно
|
||||||
import time
|
|
||||||
|
|
||||||
os.environ["TZ"] = "Europe/Moscow"
|
os.environ["TZ"] = "Europe/Moscow"
|
||||||
time.tzset()
|
time.tzset()
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
|
||||||
from ..core.config import BASE_DIR
|
from ..core.config import BASE_DIR
|
||||||
from ..audio.stt import listen
|
from ..audio.stt import listen
|
||||||
|
from ..core.commands import is_stop_command
|
||||||
|
|
||||||
# Файл базы данных будильников
|
# Файл базы данных будильников
|
||||||
ALARM_FILE = BASE_DIR / "data" / "alarms.json"
|
ALARM_FILE = BASE_DIR / "data" / "alarms.json"
|
||||||
@@ -22,6 +22,73 @@ class AlarmClock:
|
|||||||
self.alarms = []
|
self.alarms = []
|
||||||
self.load_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):
|
def load_alarms(self):
|
||||||
"""Загрузка списка будильников из JSON файла."""
|
"""Загрузка списка будильников из JSON файла."""
|
||||||
if ALARM_FILE.exists():
|
if ALARM_FILE.exists():
|
||||||
@@ -42,15 +109,30 @@ class AlarmClock:
|
|||||||
|
|
||||||
def add_alarm(self, hour: int, minute: int):
|
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:
|
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["active"] = True
|
||||||
|
alarm["days"] = days_key
|
||||||
|
alarm["last_triggered"] = None
|
||||||
self.save_alarms()
|
self.save_alarms()
|
||||||
return
|
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()
|
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):
|
def cancel_all_alarms(self):
|
||||||
"""Выключение (деактивация) всех будильников."""
|
"""Выключение (деактивация) всех будильников."""
|
||||||
@@ -59,6 +141,27 @@ class AlarmClock:
|
|||||||
self.save_alarms()
|
self.save_alarms()
|
||||||
print("🔕 Все будильники отменены.")
|
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):
|
def check_alarms(self):
|
||||||
"""
|
"""
|
||||||
Проверка: не пора ли звенеть?
|
Проверка: не пора ли звенеть?
|
||||||
@@ -71,12 +174,30 @@ class AlarmClock:
|
|||||||
for alarm in self.alarms:
|
for alarm in self.alarms:
|
||||||
if alarm["active"]:
|
if alarm["active"]:
|
||||||
if alarm["hour"] == now.hour and alarm["minute"] == now.minute:
|
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(
|
print(
|
||||||
f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}"
|
f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}"
|
||||||
)
|
)
|
||||||
alarm["active"] = (
|
if not days:
|
||||||
False # Одноразовый будильник, выключаем после срабатывания
|
# Одноразовый будильник, выключаем после срабатывания
|
||||||
)
|
alarm["active"] = False
|
||||||
|
alarm["last_triggered"] = now.isoformat()
|
||||||
triggered = True
|
triggered = True
|
||||||
self.trigger_alarm() # Запуск звука и ожидание стоп-слова
|
self.trigger_alarm() # Запуск звука и ожидание стоп-слова
|
||||||
break # Звоним только один за раз
|
break # Звоним только один за раз
|
||||||
@@ -106,21 +227,11 @@ class AlarmClock:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stop_words = [
|
|
||||||
"стоп",
|
|
||||||
"хватит",
|
|
||||||
"тихо",
|
|
||||||
"замолчи",
|
|
||||||
"отмена",
|
|
||||||
"александр стоп",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Цикл ожидания стоп-команды
|
# Цикл ожидания стоп-команды
|
||||||
while True:
|
while True:
|
||||||
text = listen(timeout_seconds=3.0, detection_timeout=3.0)
|
text = listen(timeout_seconds=3.0, detection_timeout=3.0)
|
||||||
if text:
|
if text:
|
||||||
text_lower = text.lower()
|
if is_stop_command(text, mode="lenient"):
|
||||||
if any(word in text_lower for word in stop_words):
|
|
||||||
print(f"🛑 Будильник остановлен по команде: '{text}'")
|
print(f"🛑 Будильник остановлен по команде: '{text}'")
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -144,17 +255,26 @@ class AlarmClock:
|
|||||||
if "будильник" not in text and "разбуди" not in text:
|
if "будильник" not in text and "разбуди" not in text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if "будильник" in text and re.search(
|
||||||
|
r"(какие|какой|список|активн|покажи|сколько|есть ли)", text
|
||||||
|
):
|
||||||
|
return self.describe_alarms()
|
||||||
|
|
||||||
if "отмени" in text:
|
if "отмени" in text:
|
||||||
self.cancel_all_alarms()
|
self.cancel_all_alarms()
|
||||||
return "Хорошо, я отменил все будильники."
|
return "Хорошо, я отменил все будильники."
|
||||||
|
|
||||||
|
days = self._extract_alarm_days(text)
|
||||||
|
|
||||||
# Поиск формата "7:30", "7.30"
|
# Поиск формата "7:30", "7.30"
|
||||||
match = re.search(r"\b(\d{1,2})[:.-](\d{2})\b", text)
|
match = re.search(r"\b(\d{1,2})[:.-](\d{2})\b", text)
|
||||||
if match:
|
if match:
|
||||||
h, m = int(match.group(1)), int(match.group(2))
|
h, m = int(match.group(1)), int(match.group(2))
|
||||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||||
self.add_alarm(h, m)
|
self.add_alarm_with_days(h, m, days=days)
|
||||||
return f"Я установил будильник на {h} часов {m} минут."
|
days_phrase = self._format_days_phrase(days)
|
||||||
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
|
return f"Я установил будильник на {h} часов {m} минут{suffix}."
|
||||||
|
|
||||||
# Поиск формата словами "на 7 часов 15 минут"
|
# Поиск формата словами "на 7 часов 15 минут"
|
||||||
match_time = re.search(
|
match_time = re.search(
|
||||||
@@ -174,8 +294,10 @@ class AlarmClock:
|
|||||||
h = 0
|
h = 0
|
||||||
|
|
||||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||||
self.add_alarm(h, m)
|
self.add_alarm_with_days(h, m, days=days)
|
||||||
return f"Хорошо, разбужу вас в {h}:{m:02d}."
|
days_phrase = self._format_days_phrase(days)
|
||||||
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
|
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
||||||
|
|
||||||
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
|
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 subprocess
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
|
||||||
from ..core.config import BASE_DIR
|
from ..core.config import BASE_DIR
|
||||||
from ..audio.stt import listen
|
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"
|
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:
|
class TimerManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Список активных таймеров: {"end_time": datetime, "label": str}
|
# Список активных таймеров: {"end_time": datetime, "label": str}
|
||||||
self.timers = []
|
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):
|
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.append({"end_time": end_time, "label": label})
|
||||||
# Сортируем, чтобы ближайший был первым
|
# Сортируем, чтобы ближайший был первым
|
||||||
self.timers.sort(key=lambda x: x["end_time"])
|
self.timers.sort(key=lambda x: x["end_time"])
|
||||||
|
self.save_timers()
|
||||||
print(f"⏳ Таймер установлен на {label} (до {end_time.strftime('%H:%M:%S')})")
|
print(f"⏳ Таймер установлен на {label} (до {end_time.strftime('%H:%M:%S')})")
|
||||||
|
|
||||||
def cancel_all_timers(self):
|
def cancel_all_timers(self):
|
||||||
"""Отмена всех таймеров."""
|
"""Отмена всех таймеров."""
|
||||||
count = len(self.timers)
|
count = len(self.timers)
|
||||||
self.timers = []
|
self.timers = []
|
||||||
|
self.save_timers()
|
||||||
print(f"🔕 Все таймеры ({count}) отменены.")
|
print(f"🔕 Все таймеры ({count}) отменены.")
|
||||||
|
|
||||||
def check_timers(self):
|
def check_timers(self):
|
||||||
@@ -52,6 +342,7 @@ class TimerManager:
|
|||||||
# Удаляем его из списка
|
# Удаляем его из списка
|
||||||
label = first_timer["label"]
|
label = first_timer["label"]
|
||||||
self.timers.pop(0)
|
self.timers.pop(0)
|
||||||
|
self.save_timers()
|
||||||
|
|
||||||
print(f"⌛ ТАЙМЕР ИСТЕК: {label}")
|
print(f"⌛ ТАЙМЕР ИСТЕК: {label}")
|
||||||
self.trigger_timer(label)
|
self.trigger_timer(label)
|
||||||
@@ -78,22 +369,11 @@ class TimerManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stop_words = [
|
|
||||||
"стоп",
|
|
||||||
"хватит",
|
|
||||||
"тихо",
|
|
||||||
"замолчи",
|
|
||||||
"отмена",
|
|
||||||
"александр стоп",
|
|
||||||
"спасибо",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Цикл ожидания стоп-команды
|
# Цикл ожидания стоп-команды
|
||||||
while True:
|
while True:
|
||||||
text = listen(timeout_seconds=3.0, detection_timeout=3.0)
|
text = listen(timeout_seconds=3.0, detection_timeout=3.0)
|
||||||
if text:
|
if text:
|
||||||
text_lower = text.lower()
|
if is_stop_command(text, mode="lenient"):
|
||||||
if any(word in text_lower for word in stop_words):
|
|
||||||
print(f"🛑 Таймер остановлен по команде: '{text}'")
|
print(f"🛑 Таймер остановлен по команде: '{text}'")
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -113,12 +393,17 @@ class TimerManager:
|
|||||||
Парсинг команды установки таймера.
|
Парсинг команды установки таймера.
|
||||||
Примеры: "таймер на 5 минут", "засеки 10 секунд".
|
Примеры: "таймер на 5 минут", "засеки 10 секунд".
|
||||||
"""
|
"""
|
||||||
text = text.lower()
|
text = _normalize_timer_text(text.lower())
|
||||||
|
|
||||||
# Ключевые слова для таймера
|
# Ключевые слова для таймера
|
||||||
if not any(word in text for word in ["таймер", "засеки", "поставь таймер"]):
|
if not any(word in text for word in ["таймер", "засеки", "поставь таймер"]):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if "таймер" in text and re.search(
|
||||||
|
r"(какие|какой|список|активн|покажи|сколько|есть ли)", text
|
||||||
|
):
|
||||||
|
return self.describe_timers()
|
||||||
|
|
||||||
if "отмени" in text or "удали" in text:
|
if "отмени" in text or "удали" in text:
|
||||||
self.cancel_all_timers()
|
self.cancel_all_timers()
|
||||||
return "Хорошо, все таймеры отменены."
|
return "Хорошо, все таймеры отменены."
|
||||||
@@ -128,37 +413,69 @@ class TimerManager:
|
|||||||
# Пример: "1 час 30 минут", "5 минут", "30 секунд"
|
# Пример: "1 час 30 минут", "5 минут", "30 секунд"
|
||||||
|
|
||||||
total_seconds = 0
|
total_seconds = 0
|
||||||
found_time = False
|
|
||||||
parts = []
|
parts = []
|
||||||
|
hours = None
|
||||||
|
minutes = None
|
||||||
|
seconds = None
|
||||||
|
has_fractional = False
|
||||||
|
|
||||||
# Часы
|
# Часы
|
||||||
match_hours = re.search(r"(\d+)\s*(?:час|часа|часов)", text)
|
match_hours = re.search(r"(\d+)\s*(?:час|часа|часов)", text)
|
||||||
if match_hours:
|
if match_hours:
|
||||||
h = int(match_hours.group(1))
|
hours = int(match_hours.group(1))
|
||||||
total_seconds += h * 3600
|
|
||||||
parts.append(f"{h} ч")
|
|
||||||
found_time = True
|
|
||||||
|
|
||||||
# Минуты
|
# Минуты
|
||||||
match_minutes = re.search(r"(\d+)\s*(?:мин|минуту|минуты|минут)", text)
|
match_minutes = re.search(r"(\d+)\s*(?:мин|минуту|минуты|минут)", text)
|
||||||
if match_minutes:
|
if match_minutes:
|
||||||
m = int(match_minutes.group(1))
|
minutes = int(match_minutes.group(1))
|
||||||
total_seconds += m * 60
|
|
||||||
parts.append(f"{m} мин")
|
|
||||||
found_time = True
|
|
||||||
|
|
||||||
# Секунды
|
# Секунды
|
||||||
match_seconds = re.search(r"(\d+)\s*(?:сек|секунду|секунды|секунд)", text)
|
match_seconds = re.search(r"(\d+)\s*(?:сек|секунду|секунды|секунд)", text)
|
||||||
if match_seconds:
|
if match_seconds:
|
||||||
s = int(match_seconds.group(1))
|
seconds = int(match_seconds.group(1))
|
||||||
total_seconds += s
|
|
||||||
parts.append(f"{s} сек")
|
# Дополняем числительные словами (например, "одну минуту")
|
||||||
found_time = True
|
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:
|
if found_time and total_seconds > 0:
|
||||||
label = " ".join(parts)
|
label = " ".join(parts)
|
||||||
self.add_timer(total_seconds, label)
|
self.add_timer(total_seconds, label)
|
||||||
return f"Засек {label}."
|
return f"Поставил таймер на {label}."
|
||||||
|
|
||||||
# Если сказали "таймер", но не нашли время
|
# Если сказали "таймер", но не нашли время
|
||||||
return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'."
|
return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'."
|
||||||
|
|||||||
70
app/main.py
70
app/main.py
@@ -47,23 +47,13 @@ from .audio.wakeword import (
|
|||||||
from .audio.wakeword import (
|
from .audio.wakeword import (
|
||||||
stop_monitoring as stop_wakeword_monitoring,
|
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.cleaner import clean_response
|
||||||
|
from .core.commands import is_stop_command
|
||||||
from .features.alarm import get_alarm_clock
|
from .features.alarm import get_alarm_clock
|
||||||
from .features.timer import get_timer_manager
|
from .features.timer import get_timer_manager
|
||||||
from .features.weather import get_weather_report
|
from .features.weather import get_weather_report
|
||||||
|
from .features.music import get_music_controller
|
||||||
# Список стоп-слов, чтобы прервать диалог или остановить ассистента
|
|
||||||
STOP_WORDS = {
|
|
||||||
"стоп",
|
|
||||||
"хватит",
|
|
||||||
"перестань",
|
|
||||||
"замолчи",
|
|
||||||
"прекрати",
|
|
||||||
"тихо",
|
|
||||||
"stop",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
"""
|
"""
|
||||||
@@ -85,8 +75,22 @@ def parse_translation_request(text: str):
|
|||||||
Или None, если это не запрос перевода.
|
Или None, если это не запрос перевода.
|
||||||
"""
|
"""
|
||||||
text_lower = text.lower().strip()
|
text_lower = text.lower().strip()
|
||||||
# Список префиксов команд перевода и соответствующих направлений языков
|
# Список префиксов команд перевода и соответствующих направлений языков.
|
||||||
|
# Важно: более длинные префиксы должны проверяться первыми (например,
|
||||||
|
# "переведи с русского на английский" не должен схватиться как "переведи с русского").
|
||||||
commands = [
|
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"),
|
("переведи на английский", "ru", "en"),
|
||||||
("переведи на русский", "en", "ru"),
|
("переведи на русский", "en", "ru"),
|
||||||
("переведи с английского", "en", "ru"),
|
("переведи с английского", "en", "ru"),
|
||||||
@@ -95,18 +99,25 @@ def parse_translation_request(text: str):
|
|||||||
("как по английски", "ru", "en"),
|
("как по английски", "ru", "en"),
|
||||||
("как по-русски", "en", "ru"),
|
("как по-русски", "en", "ru"),
|
||||||
("как по русски", "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 into english", "ru", "en"),
|
||||||
("translate to russian", "en", "ru"),
|
|
||||||
("translate into 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 english", "en", "ru"),
|
||||||
("translate from russian", "ru", "en"),
|
("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):
|
if text_lower.startswith(prefix):
|
||||||
# Отрезаем команду (префикс), оставляем только текст для перевода
|
# Отрезаем команду (префикс), оставляем только текст для перевода
|
||||||
rest = text[len(prefix) :].strip()
|
rest = text[len(prefix) :].strip()
|
||||||
|
rest = rest.lstrip(" :—-")
|
||||||
return {
|
return {
|
||||||
"source_lang": source_lang,
|
"source_lang": source_lang,
|
||||||
"target_lang": target_lang,
|
"target_lang": target_lang,
|
||||||
@@ -115,21 +126,6 @@ def parse_translation_request(text: str):
|
|||||||
return None
|
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():
|
def main():
|
||||||
"""
|
"""
|
||||||
Основная функция (точка входа).
|
Основная функция (точка входа).
|
||||||
@@ -340,6 +336,16 @@ def main():
|
|||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
continue
|
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)
|
translation_request = parse_translation_request(user_text)
|
||||||
if translation_request:
|
if translation_request:
|
||||||
|
|||||||
@@ -23,5 +23,21 @@
|
|||||||
"hour": 1,
|
"hour": 1,
|
||||||
"minute": 19,
|
"minute": 19,
|
||||||
"active": false
|
"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
|
websockets==15.0.1
|
||||||
yarl==1.22.0
|
yarl==1.22.0
|
||||||
pygame
|
pygame
|
||||||
|
spotipy
|
||||||
|
|||||||
Reference in New Issue
Block a user