feat: refine assistant logic and update docs
This commit is contained in:
12
.env.example
12
.env.example
@@ -6,6 +6,11 @@ AI_PROVIDER=
|
|||||||
# OPENROUTER_API_KEY=your_openrouter_api_key_here
|
# OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||||
OPENROUTER_MODEL=openai/gpt-4o-mini
|
OPENROUTER_MODEL=openai/gpt-4o-mini
|
||||||
OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
|
OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
|
||||||
|
AI_CHAT_TEMPERATURE=0.9
|
||||||
|
AI_CHAT_MAX_TOKENS=160
|
||||||
|
AI_CHAT_MAX_CHARS=240
|
||||||
|
AI_INTENT_TEMPERATURE=0.0
|
||||||
|
AI_TRANSLATION_TEMPERATURE=0.2
|
||||||
|
|
||||||
# OpenAI
|
# OpenAI
|
||||||
# OPENAI_API_KEY=your_openai_api_key_here
|
# OPENAI_API_KEY=your_openai_api_key_here
|
||||||
@@ -35,6 +40,13 @@ OLLAMA_API_URL=http://localhost:11434/v1/chat/completions
|
|||||||
DEEPGRAM_API_KEY=your_deepgram_api_key_here
|
DEEPGRAM_API_KEY=your_deepgram_api_key_here
|
||||||
PORCUPINE_ACCESS_KEY=your_porcupine_access_key_here
|
PORCUPINE_ACCESS_KEY=your_porcupine_access_key_here
|
||||||
PORCUPINE_SENSITIVITY=0.8
|
PORCUPINE_SENSITIVITY=0.8
|
||||||
|
# Anti-phantom wake word filter (RMS gate).
|
||||||
|
# Increase values if random activations persist; lower them if wake word becomes too hard to trigger.
|
||||||
|
# If the mic reopens and instantly re-triggers, keep RMS as-is and raise WAKEWORD_REOPEN_GRACE_SECONDS.
|
||||||
|
# WAKEWORD_MIN_RMS=120
|
||||||
|
# WAKEWORD_RMS_MULTIPLIER=1.7
|
||||||
|
# WAKEWORD_HIT_COOLDOWN_SECONDS=1.2
|
||||||
|
# WAKEWORD_REOPEN_GRACE_SECONDS=0.45
|
||||||
# Optional audio device overrides (substring match by name or exact PortAudio index)
|
# Optional audio device overrides (substring match by name or exact PortAudio index)
|
||||||
# AUDIO_INPUT_DEVICE_NAME=pulse
|
# AUDIO_INPUT_DEVICE_NAME=pulse
|
||||||
# AUDIO_INPUT_DEVICE_INDEX=2
|
# AUDIO_INPUT_DEVICE_INDEX=2
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@ env.bak/
|
|||||||
venv.bak/
|
venv.bak/
|
||||||
.qwen
|
.qwen
|
||||||
qwen.md
|
qwen.md
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
|
||||||
# AI configs
|
# AI configs
|
||||||
|
|||||||
7
Makefile
7
Makefile
@@ -1,7 +1,12 @@
|
|||||||
.PHONY: run check qwen-context
|
.PHONY: run check qwen-context
|
||||||
|
|
||||||
|
PYTHON := python3
|
||||||
|
ifneq ($(wildcard .venv/bin/python),)
|
||||||
|
PYTHON := .venv/bin/python
|
||||||
|
endif
|
||||||
|
|
||||||
run:
|
run:
|
||||||
python run.py
|
$(PYTHON) run.py
|
||||||
|
|
||||||
check:
|
check:
|
||||||
./scripts/qwen-check.sh
|
./scripts/qwen-check.sh
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ flowchart TD
|
|||||||
F --> G[Follow-up режим или ожидание wake word]
|
F --> G[Follow-up режим или ожидание wake word]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Что Важно В Этой Реализации
|
## Что важно в этой реализации
|
||||||
|
|
||||||
- Контекст диалога хранится в памяти текущей сессии, поэтому после первого вопроса можно продолжать разговор без потери нити.
|
- Контекст диалога хранится в памяти текущей сессии, поэтому после первого вопроса можно продолжать разговор без потери нити.
|
||||||
- Системная роль ассистента и `ROLE_JSON` сохраняются для всех поддерживаемых AI-провайдеров.
|
- Системная роль ассистента и `ROLE_JSON` сохраняются для всех поддерживаемых AI-провайдеров.
|
||||||
@@ -68,7 +68,7 @@ sudo apt-get install -y portaudio19-dev libasound2-dev mpg123 mpv pulseaudio-uti
|
|||||||
### 2) Установка Python-зависимостей
|
### 2) Установка Python-зависимостей
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <URL_ВАШЕГО_РЕПОЗИТОРИЯ>
|
git clone https://gitea.futuree.ru/future/alexander_smart-speaker.git
|
||||||
cd alexander_smart-speaker
|
cd alexander_smart-speaker
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
@@ -157,7 +157,7 @@ python run.py
|
|||||||
| `AUDIO_OUTPUT_DEVICE_NAME` | Нет | auto | Подстрока имени динамика/выхода (например `pulse`) |
|
| `AUDIO_OUTPUT_DEVICE_NAME` | Нет | auto | Подстрока имени динамика/выхода (например `pulse`) |
|
||||||
| `AUDIO_OUTPUT_DEVICE_INDEX` | Нет | auto | Индекс PortAudio для вывода (приоритетнее `AUDIO_OUTPUT_DEVICE_NAME`) |
|
| `AUDIO_OUTPUT_DEVICE_INDEX` | Нет | auto | Индекс PortAudio для вывода (приоритетнее `AUDIO_OUTPUT_DEVICE_NAME`) |
|
||||||
| `STT_START_SOUND_PATH` | Нет | `assets/sounds/alisa-golosovoj-pomoschnik.mp3` | Короткий звук после wake word и перед стартом STT (wav/mp3) |
|
| `STT_START_SOUND_PATH` | Нет | `assets/sounds/alisa-golosovoj-pomoschnik.mp3` | Короткий звук после wake word и перед стартом STT (wav/mp3) |
|
||||||
| `STT_START_SOUND_VOLUME` | Нет | `0.25` | Громкость звука старта STT (0..1) |
|
| `STT_START_SOUND_VOLUME` | Нет | `1.0` | Громкость звука старта STT (в текущей версии фиксирована на 100%) |
|
||||||
| `TTS_EN_SPEAKER` | Нет | `en_0` | Английский голос TTS |
|
| `TTS_EN_SPEAKER` | Нет | `en_0` | Английский голос TTS |
|
||||||
| `WEATHER_LAT` | Нет | - | Широта города по умолчанию |
|
| `WEATHER_LAT` | Нет | - | Широта города по умолчанию |
|
||||||
| `WEATHER_LON` | Нет | - | Долгота города по умолчанию |
|
| `WEATHER_LON` | Нет | - | Долгота города по умолчанию |
|
||||||
|
|||||||
@@ -11,20 +11,57 @@ import re
|
|||||||
import platform
|
import platform
|
||||||
from ..core.roman import replace_roman_numerals
|
from ..core.roman import replace_roman_numerals
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pymorphy3
|
||||||
|
|
||||||
|
_MORPH = pymorphy3.MorphAnalyzer()
|
||||||
|
except Exception:
|
||||||
|
_MORPH = None
|
||||||
|
|
||||||
# Карта для перевода слов в цифры ("пять" -> 5)
|
# Карта для перевода слов в цифры ("пять" -> 5)
|
||||||
NUMBER_MAP = {
|
NUMBER_MAP = {
|
||||||
|
"ноль": 0,
|
||||||
"один": 1,
|
"один": 1,
|
||||||
|
"одна": 1,
|
||||||
"раз": 1,
|
"раз": 1,
|
||||||
|
"единица": 1,
|
||||||
|
"единичка": 1,
|
||||||
"два": 2,
|
"два": 2,
|
||||||
|
"две": 2,
|
||||||
|
"двойка": 2,
|
||||||
|
"двоечка": 2,
|
||||||
"три": 3,
|
"три": 3,
|
||||||
|
"тройка": 3,
|
||||||
|
"троечка": 3,
|
||||||
"четыре": 4,
|
"четыре": 4,
|
||||||
|
"четверка": 4,
|
||||||
|
"четверочка": 4,
|
||||||
"пять": 5,
|
"пять": 5,
|
||||||
|
"пятерка": 5,
|
||||||
|
"пятерочка": 5,
|
||||||
"шесть": 6,
|
"шесть": 6,
|
||||||
|
"шестерка": 6,
|
||||||
|
"шестерочка": 6,
|
||||||
"семь": 7,
|
"семь": 7,
|
||||||
|
"семерка": 7,
|
||||||
|
"семерочка": 7,
|
||||||
"восемь": 8,
|
"восемь": 8,
|
||||||
|
"восьмерка": 8,
|
||||||
|
"восьмерочка": 8,
|
||||||
"девять": 9,
|
"девять": 9,
|
||||||
|
"девятка": 9,
|
||||||
|
"девяточка": 9,
|
||||||
"десять": 10,
|
"десять": 10,
|
||||||
|
"десятка": 10,
|
||||||
|
"десяточка": 10,
|
||||||
}
|
}
|
||||||
|
_VOLUME_COMMAND_RE = re.compile(r"\b(громкост\w*|звук\w*|volume)\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _lemmatize(token: str) -> str:
|
||||||
|
if _MORPH is None:
|
||||||
|
return token
|
||||||
|
return _MORPH.parse(token)[0].normal_form.replace("ё", "е")
|
||||||
|
|
||||||
|
|
||||||
def _get_volume_command(level: int):
|
def _get_volume_command(level: int):
|
||||||
@@ -149,16 +186,25 @@ def parse_volume_text(text: str) -> int | None:
|
|||||||
Пытается найти число громкости в тексте.
|
Пытается найти число громкости в тексте.
|
||||||
Понимает и цифры ("5"), и слова ("пять").
|
Понимает и цифры ("5"), и слова ("пять").
|
||||||
"""
|
"""
|
||||||
text = replace_roman_numerals(text.lower())
|
text = replace_roman_numerals(text.lower().replace("ё", "е"))
|
||||||
|
|
||||||
# 1. Ищем цифры (1-10)
|
# 1. Ищем цифры в любом месте фразы.
|
||||||
num_match = re.search(r"\b(10|[1-9])\b", text)
|
for match in re.finditer(r"\d+", text):
|
||||||
if num_match:
|
value = int(match.group())
|
||||||
return int(num_match.group())
|
if 1 <= value <= 10:
|
||||||
|
return value
|
||||||
|
|
||||||
# 2. Ищем слова из словаря
|
# 2. Ищем числительные и разговорные формы по леммам:
|
||||||
for word, value in NUMBER_MAP.items():
|
# "семерку", "десяточку", "на двух" -> 7, 10, 2.
|
||||||
if word in text:
|
for token in re.findall(r"[a-zA-Zа-яА-ЯёЁ]+", text):
|
||||||
|
value = NUMBER_MAP.get(_lemmatize(token))
|
||||||
|
if value is not None and 1 <= value <= 10:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_volume_command(text: str) -> bool:
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
return bool(_VOLUME_COMMAND_RE.search(text.lower().replace("ё", "е")))
|
||||||
|
|||||||
120
app/audio/stt.py
120
app/audio/stt.py
@@ -8,14 +8,13 @@ Supports Russian (default) and English.
|
|||||||
# Использует Deepgram API через веб-сокеты для потокового распознавания в реальном времени.
|
# Использует Deepgram API через веб-сокеты для потокового распознавания в реальном времени.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
import pyaudio
|
import pyaudio
|
||||||
import logging
|
import logging
|
||||||
import contextlib
|
import contextlib
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from ..core.config import DEEPGRAM_API_KEY, SAMPLE_RATE, WAKE_WORD_ALIASES
|
from ..core.config import DEEPGRAM_API_KEY, SAMPLE_RATE
|
||||||
from deepgram import (
|
from deepgram import (
|
||||||
DeepgramClient,
|
DeepgramClient,
|
||||||
DeepgramClientOptions,
|
DeepgramClientOptions,
|
||||||
@@ -25,13 +24,14 @@ from deepgram import (
|
|||||||
import deepgram.clients.common.v1.abstract_sync_websocket as sdk_ws
|
import deepgram.clients.common.v1.abstract_sync_websocket as sdk_ws
|
||||||
import websockets.sync.client
|
import websockets.sync.client
|
||||||
from ..core.audio_manager import get_audio_manager
|
from ..core.audio_manager import get_audio_manager
|
||||||
|
from ..core.commands import is_fast_command
|
||||||
|
|
||||||
# --- Патч (исправление) для библиотеки websockets ---
|
# --- Патч (исправление) для библиотеки websockets ---
|
||||||
# Явно задаём таймауты подключения, чтобы не зависать на долгом handshake.
|
# Явно задаём таймауты подключения, чтобы не зависать на долгом handshake.
|
||||||
_original_connect = websockets.sync.client.connect
|
_original_connect = websockets.sync.client.connect
|
||||||
|
|
||||||
DEEPGRAM_CONNECT_TIMEOUT_SECONDS = 3.0
|
DEEPGRAM_CONNECT_TIMEOUT_SECONDS = 5.0
|
||||||
DEEPGRAM_CONNECT_WAIT_SECONDS = 4.0
|
DEEPGRAM_CONNECT_WAIT_SECONDS = 6.5
|
||||||
DEEPGRAM_CONNECT_POLL_SECONDS = 0.001
|
DEEPGRAM_CONNECT_POLL_SECONDS = 0.001
|
||||||
SENDER_STOP_WAIT_SECONDS = 2.5
|
SENDER_STOP_WAIT_SECONDS = 2.5
|
||||||
SENDER_FORCE_RELEASE_WAIT_SECONDS = 2.5
|
SENDER_FORCE_RELEASE_WAIT_SECONDS = 2.5
|
||||||
@@ -62,28 +62,6 @@ POST_SPEECH_SILENCE_TIMEOUT_SECONDS = 2.0
|
|||||||
# Фактическое завершение происходит примерно после 2.0 сек тишины после речи.
|
# Фактическое завершение происходит примерно после 2.0 сек тишины после речи.
|
||||||
MAX_ACTIVE_SPEECH_SECONDS = 300.0
|
MAX_ACTIVE_SPEECH_SECONDS = 300.0
|
||||||
|
|
||||||
_FAST_STOP_UTTERANCE_RE = re.compile(
|
|
||||||
r"^(?:(?:" + "|".join(re.escape(alias) for alias in WAKE_WORD_ALIASES) + r")\s+)?"
|
|
||||||
r"(?:стоп|хватит|перестань|прекрати|замолчи|тихо|пауза)"
|
|
||||||
r"(?:\s+(?:пожалуйста|please))?$",
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_command_text(text: str) -> str:
|
|
||||||
normalized = text.lower().replace("ё", "е")
|
|
||||||
normalized = re.sub(r"[^\w\s]+", " ", normalized, flags=re.UNICODE)
|
|
||||||
normalized = re.sub(r"\s+", " ", normalized, flags=re.UNICODE).strip()
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _is_fast_stop_utterance(text: str) -> bool:
|
|
||||||
normalized = _normalize_command_text(text)
|
|
||||||
if not normalized:
|
|
||||||
return False
|
|
||||||
return _FAST_STOP_UTTERANCE_RE.fullmatch(normalized) is not None
|
|
||||||
|
|
||||||
|
|
||||||
class SpeechRecognizer:
|
class SpeechRecognizer:
|
||||||
"""Класс распознавания речи через Deepgram."""
|
"""Класс распознавания речи через Deepgram."""
|
||||||
|
|
||||||
@@ -280,7 +258,7 @@ class SpeechRecognizer:
|
|||||||
dg_connection: Активное соединение с Deepgram.
|
dg_connection: Активное соединение с Deepgram.
|
||||||
timeout_seconds: Аварийный лимит длительности активной речи.
|
timeout_seconds: Аварийный лимит длительности активной речи.
|
||||||
detection_timeout: Время ожидания начала речи.
|
detection_timeout: Время ожидания начала речи.
|
||||||
fast_stop: Если True, короткая стоп-фраза завершает STT после 1с тишины.
|
fast_stop: Если True, короткие системные команды завершают STT раньше.
|
||||||
"""
|
"""
|
||||||
self.transcript = ""
|
self.transcript = ""
|
||||||
transcript_parts = []
|
transcript_parts = []
|
||||||
@@ -296,6 +274,8 @@ class SpeechRecognizer:
|
|||||||
# События для синхронизации
|
# События для синхронизации
|
||||||
stop_event = asyncio.Event() # Пора останавливаться
|
stop_event = asyncio.Event() # Пора останавливаться
|
||||||
speech_started_event = asyncio.Event() # Речь обнаружена (VAD)
|
speech_started_event = asyncio.Event() # Речь обнаружена (VAD)
|
||||||
|
connection_ready_event = threading.Event() # WS с Deepgram готов
|
||||||
|
connection_failed_event = threading.Event() # WS с Deepgram завершился ошибкой
|
||||||
last_speech_activity = time.monotonic()
|
last_speech_activity = time.monotonic()
|
||||||
first_speech_activity_at = None
|
first_speech_activity_at = None
|
||||||
session_error = {"message": None}
|
session_error = {"message": None}
|
||||||
@@ -338,14 +318,13 @@ class SpeechRecognizer:
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if fast_stop:
|
if fast_stop and is_fast_command(sentence):
|
||||||
if _is_fast_stop_utterance(sentence):
|
self.transcript = sentence
|
||||||
self.transcript = sentence
|
try:
|
||||||
try:
|
loop.call_soon_threadsafe(request_stop)
|
||||||
loop.call_soon_threadsafe(request_stop)
|
except RuntimeError:
|
||||||
except RuntimeError:
|
pass
|
||||||
pass
|
return
|
||||||
return
|
|
||||||
|
|
||||||
if result.is_final:
|
if result.is_final:
|
||||||
# Собираем только финальные (подтвержденные) фразы
|
# Собираем только финальные (подтвержденные) фразы
|
||||||
@@ -470,6 +449,7 @@ class SpeechRecognizer:
|
|||||||
print(
|
print(
|
||||||
f"⏰ Timeout connecting to Deepgram ({DEEPGRAM_CONNECT_WAIT_SECONDS:.1f}s)"
|
f"⏰ Timeout connecting to Deepgram ({DEEPGRAM_CONNECT_WAIT_SECONDS:.1f}s)"
|
||||||
)
|
)
|
||||||
|
connection_failed_event.set()
|
||||||
loop.call_soon_threadsafe(request_stop)
|
loop.call_soon_threadsafe(request_stop)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -479,15 +459,18 @@ class SpeechRecognizer:
|
|||||||
f"Failed to start Deepgram connection: {connect_result['error']}"
|
f"Failed to start Deepgram connection: {connect_result['error']}"
|
||||||
)
|
)
|
||||||
print(f"Failed to start Deepgram connection: {connect_result['error']}")
|
print(f"Failed to start Deepgram connection: {connect_result['error']}")
|
||||||
|
connection_failed_event.set()
|
||||||
loop.call_soon_threadsafe(request_stop)
|
loop.call_soon_threadsafe(request_stop)
|
||||||
return
|
return
|
||||||
|
|
||||||
if connect_result["ok"] is False:
|
if connect_result["ok"] is False:
|
||||||
mark_session_error("Failed to start Deepgram connection")
|
mark_session_error("Failed to start Deepgram connection")
|
||||||
print("Failed to start Deepgram connection")
|
print("Failed to start Deepgram connection")
|
||||||
|
connection_failed_event.set()
|
||||||
loop.call_soon_threadsafe(request_stop)
|
loop.call_soon_threadsafe(request_stop)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
connection_ready_event.set()
|
||||||
print(f"🚀 Connected! Sending buffer ({len(audio_buffer)} chunks)...")
|
print(f"🚀 Connected! Sending buffer ({len(audio_buffer)} chunks)...")
|
||||||
|
|
||||||
# 3. Отправляем накопленный буфер
|
# 3. Отправляем накопленный буфер
|
||||||
@@ -522,6 +505,7 @@ class SpeechRecognizer:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
mark_session_error(f"Audio send error: {e}")
|
mark_session_error(f"Audio send error: {e}")
|
||||||
print(f"Audio send error: {e}")
|
print(f"Audio send error: {e}")
|
||||||
|
connection_failed_event.set()
|
||||||
with contextlib.suppress(RuntimeError):
|
with contextlib.suppress(RuntimeError):
|
||||||
loop.call_soon_threadsafe(request_stop)
|
loop.call_soon_threadsafe(request_stop)
|
||||||
finally:
|
finally:
|
||||||
@@ -551,26 +535,56 @@ class SpeechRecognizer:
|
|||||||
and effective_detection_timeout > 0
|
and effective_detection_timeout > 0
|
||||||
and not stop_event.is_set()
|
and not stop_event.is_set()
|
||||||
):
|
):
|
||||||
speech_wait_task = asyncio.create_task(speech_started_event.wait())
|
# Важно: не считаем пользователя "молчаливым", пока WS-соединение
|
||||||
stop_wait_task = asyncio.create_task(stop_event.wait())
|
# с Deepgram еще не поднялось.
|
||||||
try:
|
connect_ready_deadline = time.monotonic() + max(
|
||||||
done, pending = await asyncio.wait(
|
effective_detection_timeout + 0.25,
|
||||||
{speech_wait_task, stop_wait_task},
|
DEEPGRAM_CONNECT_WAIT_SECONDS + 0.75,
|
||||||
timeout=effective_detection_timeout,
|
)
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
while (
|
||||||
)
|
not stop_event.is_set()
|
||||||
finally:
|
and not connection_ready_event.is_set()
|
||||||
for task in (speech_wait_task, stop_wait_task):
|
and time.monotonic() < connect_ready_deadline
|
||||||
if not task.done():
|
):
|
||||||
task.cancel()
|
if connection_failed_event.is_set():
|
||||||
await asyncio.gather(
|
break
|
||||||
speech_wait_task, stop_wait_task, return_exceptions=True
|
await asyncio.sleep(0.05)
|
||||||
)
|
|
||||||
|
|
||||||
if not done:
|
if (
|
||||||
# Если за detection_timeout никто не начал говорить, выходим
|
not stop_event.is_set()
|
||||||
|
and not connection_ready_event.is_set()
|
||||||
|
and not connection_failed_event.is_set()
|
||||||
|
):
|
||||||
|
mark_session_error("Deepgram connection was not ready before speech timeout.")
|
||||||
request_stop()
|
request_stop()
|
||||||
|
|
||||||
|
if (
|
||||||
|
stop_event.is_set()
|
||||||
|
or connection_failed_event.is_set()
|
||||||
|
or not connection_ready_event.is_set()
|
||||||
|
):
|
||||||
|
request_stop()
|
||||||
|
else:
|
||||||
|
speech_wait_task = asyncio.create_task(speech_started_event.wait())
|
||||||
|
stop_wait_task = asyncio.create_task(stop_event.wait())
|
||||||
|
try:
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{speech_wait_task, stop_wait_task},
|
||||||
|
timeout=effective_detection_timeout,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for task in (speech_wait_task, stop_wait_task):
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(
|
||||||
|
speech_wait_task, stop_wait_task, return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not done:
|
||||||
|
# Если за detection_timeout после поднятия WS никто не начал говорить, выходим.
|
||||||
|
request_stop()
|
||||||
|
|
||||||
# 2. После старта речи завершаем только по тишине POST_SPEECH_SILENCE_TIMEOUT_SECONDS.
|
# 2. После старта речи завершаем только по тишине POST_SPEECH_SILENCE_TIMEOUT_SECONDS.
|
||||||
# Добавляем длинный защитный лимит, чтобы сессия не зависла навсегда.
|
# Добавляем длинный защитный лимит, чтобы сессия не зависла навсегда.
|
||||||
if not stop_event.is_set():
|
if not stop_event.is_set():
|
||||||
@@ -687,7 +701,7 @@ class SpeechRecognizer:
|
|||||||
timeout_seconds: Защитный лимит длительности активной речи.
|
timeout_seconds: Защитный лимит длительности активной речи.
|
||||||
detection_timeout: Сколько ждать начала речи перед тем как сдаться.
|
detection_timeout: Сколько ждать начала речи перед тем как сдаться.
|
||||||
lang: Язык ("ru" или "en").
|
lang: Язык ("ru" или "en").
|
||||||
fast_stop: Быстрое завершение для коротких stop-команд.
|
fast_stop: Быстрое завершение для коротких системных команд.
|
||||||
"""
|
"""
|
||||||
if not self.dg_client:
|
if not self.dg_client:
|
||||||
self.initialize()
|
self.initialize()
|
||||||
|
|||||||
176
app/audio/tts.py
176
app/audio/tts.py
@@ -19,12 +19,14 @@ import sounddevice as sd
|
|||||||
import torch
|
import torch
|
||||||
|
|
||||||
from ..core.audio_manager import get_audio_manager
|
from ..core.audio_manager import get_audio_manager
|
||||||
from ..core.config import TTS_EN_SPEAKER, TTS_SAMPLE_RATE, TTS_SPEAKER
|
from ..core.config import TTS_EN_SPEAKER, TTS_SAMPLE_RATE, TTS_SPEAKER, TTS_SPEED
|
||||||
|
|
||||||
# Подавляем предупреждения Silero о длинном тексте (мы сами его режем)
|
# Подавляем предупреждения Silero о длинном тексте (мы сами его режем)
|
||||||
warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols")
|
warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols")
|
||||||
|
|
||||||
_EN_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9'-]*")
|
_EN_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9'-]*")
|
||||||
|
_MIXED_TTS_BUFFERED_SWITCHES = 3
|
||||||
|
_INTERRUPT_POLL_SECONDS = 0.01
|
||||||
|
|
||||||
|
|
||||||
class TextToSpeech:
|
class TextToSpeech:
|
||||||
@@ -34,6 +36,7 @@ class TextToSpeech:
|
|||||||
self.model_ru = None
|
self.model_ru = None
|
||||||
self.model_en = None
|
self.model_en = None
|
||||||
self.sample_rate = TTS_SAMPLE_RATE
|
self.sample_rate = TTS_SAMPLE_RATE
|
||||||
|
self.speed_factor = float(TTS_SPEED)
|
||||||
self.speaker_ru = TTS_SPEAKER
|
self.speaker_ru = TTS_SPEAKER
|
||||||
self.speaker_en = TTS_EN_SPEAKER
|
self.speaker_en = TTS_EN_SPEAKER
|
||||||
self._interrupted = False
|
self._interrupted = False
|
||||||
@@ -41,6 +44,23 @@ class TextToSpeech:
|
|||||||
self._audio_manager = None
|
self._audio_manager = None
|
||||||
self._output_device_index = None
|
self._output_device_index = None
|
||||||
|
|
||||||
|
def _apply_speed(self, audio_np: np.ndarray) -> np.ndarray:
|
||||||
|
"""Применяет небольшой time-stretch без изменения остальной логики TTS."""
|
||||||
|
audio = np.asarray(audio_np, dtype=np.float32)
|
||||||
|
if audio.size == 0:
|
||||||
|
return audio
|
||||||
|
|
||||||
|
speed = max(0.85, min(1.15, float(self.speed_factor)))
|
||||||
|
if abs(speed - 1.0) < 0.01:
|
||||||
|
return audio
|
||||||
|
|
||||||
|
# speed < 1.0 -> медленнее (длина массива больше), speed > 1.0 -> быстрее.
|
||||||
|
target_length = max(1, int(round(audio.size / speed)))
|
||||||
|
x_old = np.arange(audio.size, dtype=np.float32)
|
||||||
|
x_new = np.linspace(0.0, float(max(0, audio.size - 1)), target_length)
|
||||||
|
stretched = np.interp(x_new, x_old, audio)
|
||||||
|
return np.asarray(stretched, dtype=np.float32)
|
||||||
|
|
||||||
def _load_model(self, language: str):
|
def _load_model(self, language: str):
|
||||||
"""
|
"""
|
||||||
Загрузка и кэширование модели Silero TTS.
|
Загрузка и кэширование модели Silero TTS.
|
||||||
@@ -52,21 +72,12 @@ 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(
|
||||||
model, _ = torch.hub.load(
|
repo_or_dir="snakers4/silero-models",
|
||||||
repo_or_dir="snakers4/silero-models",
|
model="silero_tts",
|
||||||
model="silero_tts",
|
language="en",
|
||||||
language="en",
|
speaker="v3_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)
|
model.to(device)
|
||||||
self.model_en = model
|
self.model_en = model
|
||||||
return model
|
return model
|
||||||
@@ -185,28 +196,7 @@ class TextToSpeech:
|
|||||||
if not text.strip():
|
if not text.strip():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Выбор модели
|
model, speaker = self._get_model_and_speaker(language)
|
||||||
if language == "en":
|
|
||||||
model = self._load_model("en")
|
|
||||||
speaker = self.speaker_en
|
|
||||||
else:
|
|
||||||
model = self._load_model("ru")
|
|
||||||
speaker = self.speaker_ru
|
|
||||||
|
|
||||||
# Проверка наличия спикера в модели (защита от ошибок конфига).
|
|
||||||
# Для русского языка сохраняем мужской голос по умолчанию.
|
|
||||||
if hasattr(model, "speakers") and model.speakers:
|
|
||||||
if language == "ru":
|
|
||||||
male_speakers = ("eugene", "aidar")
|
|
||||||
if speaker not in model.speakers or speaker not in male_speakers:
|
|
||||||
for candidate in male_speakers:
|
|
||||||
if candidate in model.speakers:
|
|
||||||
speaker = candidate
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
speaker = model.speakers[0]
|
|
||||||
elif speaker not in model.speakers:
|
|
||||||
speaker = model.speakers[0]
|
|
||||||
|
|
||||||
# Разбиваем текст на куски
|
# Разбиваем текст на куски
|
||||||
chunks = self._split_text(text)
|
chunks = self._split_text(text)
|
||||||
@@ -233,7 +223,7 @@ class TextToSpeech:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Конвертация в numpy массив для sounddevice
|
# Конвертация в numpy массив для sounddevice
|
||||||
audio_np = audio.numpy()
|
audio_np = self._apply_speed(audio.numpy())
|
||||||
|
|
||||||
if check_interrupt:
|
if check_interrupt:
|
||||||
if not self._play_audio_with_interrupt(audio_np, check_interrupt):
|
if not self._play_audio_with_interrupt(audio_np, check_interrupt):
|
||||||
@@ -256,10 +246,104 @@ class TextToSpeech:
|
|||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _get_model_and_speaker(self, language: str):
|
||||||
|
"""Возвращает модель и подходящий голос для языка."""
|
||||||
|
# Выбор модели
|
||||||
|
if language == "en":
|
||||||
|
model = self._load_model("en")
|
||||||
|
speaker = self.speaker_en
|
||||||
|
else:
|
||||||
|
model = self._load_model("ru")
|
||||||
|
speaker = self.speaker_ru
|
||||||
|
|
||||||
|
# Проверка наличия спикера в модели (защита от ошибок конфига).
|
||||||
|
# Для русского языка сохраняем мужской голос по умолчанию.
|
||||||
|
if hasattr(model, "speakers") and model.speakers:
|
||||||
|
if language == "ru":
|
||||||
|
male_speakers = ("eugene", "aidar")
|
||||||
|
if speaker not in model.speakers or speaker not in male_speakers:
|
||||||
|
for candidate in male_speakers:
|
||||||
|
if candidate in model.speakers:
|
||||||
|
speaker = candidate
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
speaker = model.speakers[0]
|
||||||
|
elif speaker not in model.speakers:
|
||||||
|
speaker = model.speakers[0]
|
||||||
|
|
||||||
|
return model, speaker
|
||||||
|
|
||||||
|
def _synthesize_language_audio(self, text: str, language: str) -> np.ndarray | None:
|
||||||
|
"""Собирает аудио для одного языка без промежуточного воспроизведения."""
|
||||||
|
if not text.strip():
|
||||||
|
return np.asarray([], dtype=np.float32)
|
||||||
|
|
||||||
|
model, speaker = self._get_model_and_speaker(language)
|
||||||
|
chunks = self._split_text(text)
|
||||||
|
audio_parts = []
|
||||||
|
|
||||||
|
for chunk in chunks:
|
||||||
|
if self._interrupted:
|
||||||
|
return None
|
||||||
|
audio = model.apply_tts(text=chunk, speaker=speaker, sample_rate=self.sample_rate)
|
||||||
|
audio_parts.append(self._apply_speed(audio.numpy()))
|
||||||
|
|
||||||
|
if not audio_parts:
|
||||||
|
return np.asarray([], dtype=np.float32)
|
||||||
|
|
||||||
|
return np.concatenate(audio_parts)
|
||||||
|
|
||||||
|
def _count_language_switches(self, segments: list[tuple[str, str]]) -> int:
|
||||||
|
if len(segments) < 2:
|
||||||
|
return 0
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for idx in range(1, len(segments))
|
||||||
|
if segments[idx - 1][1] != segments[idx][1]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _speak_mixed_buffered(
|
||||||
|
self, segments: list[tuple[str, str]], check_interrupt=None
|
||||||
|
) -> bool:
|
||||||
|
"""Сначала собирает mixed RU/EN аудио, затем проигрывает единым потоком."""
|
||||||
|
print(f"🔊 Mixed TTS: буферизация сегментов ({len(segments)} шт.)")
|
||||||
|
self._interrupted = False
|
||||||
|
self._stop_flag.clear()
|
||||||
|
|
||||||
|
audio_parts = []
|
||||||
|
for idx, (segment, lang) in enumerate(segments, start=1):
|
||||||
|
if not segment.strip():
|
||||||
|
continue
|
||||||
|
if check_interrupt and check_interrupt():
|
||||||
|
self._interrupted = True
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
audio_np = self._synthesize_language_audio(segment, language=lang)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"❌ Ошибка mixed TTS (сегмент {idx}/{len(segments)}): {exc}")
|
||||||
|
return False
|
||||||
|
if audio_np is None:
|
||||||
|
return False
|
||||||
|
if audio_np.size:
|
||||||
|
audio_parts.append(audio_np)
|
||||||
|
|
||||||
|
if not audio_parts:
|
||||||
|
return True
|
||||||
|
|
||||||
|
full_audio = np.concatenate(audio_parts)
|
||||||
|
if check_interrupt:
|
||||||
|
return self._play_audio_with_interrupt(full_audio, check_interrupt)
|
||||||
|
return self._play_audio_blocking(full_audio)
|
||||||
|
|
||||||
def _speak_mixed(
|
def _speak_mixed(
|
||||||
self, segments: list[tuple[str, str]], check_interrupt=None
|
self, segments: list[tuple[str, str]], check_interrupt=None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Озвучивание текста с переключением RU/EN по сегментам."""
|
"""Озвучивание текста с переключением RU/EN по сегментам."""
|
||||||
|
if self._count_language_switches(segments) >= _MIXED_TTS_BUFFERED_SWITCHES:
|
||||||
|
return self._speak_mixed_buffered(
|
||||||
|
segments, check_interrupt=check_interrupt
|
||||||
|
)
|
||||||
|
|
||||||
for segment, lang in segments:
|
for segment, lang in segments:
|
||||||
if not segment.strip():
|
if not segment.strip():
|
||||||
continue
|
continue
|
||||||
@@ -390,6 +474,7 @@ class TextToSpeech:
|
|||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
time.sleep(_INTERRUPT_POLL_SECONDS)
|
||||||
|
|
||||||
def _play_with_interrupt_sounddevice(
|
def _play_with_interrupt_sounddevice(
|
||||||
self, audio_np: np.ndarray, check_interrupt
|
self, audio_np: np.ndarray, check_interrupt
|
||||||
@@ -407,11 +492,18 @@ class TextToSpeech:
|
|||||||
# Запускаем воспроизведение (неблокирующее)
|
# Запускаем воспроизведение (неблокирующее)
|
||||||
sd.play(audio_np, self.sample_rate)
|
sd.play(audio_np, self.sample_rate)
|
||||||
|
|
||||||
# Ждем окончания воспроизведения в цикле
|
# Ждем окончания воспроизведения в цикле.
|
||||||
while sd.get_stream().active:
|
while True:
|
||||||
if self._interrupted:
|
if self._interrupted:
|
||||||
break
|
break
|
||||||
time.sleep(0.02) # Уменьшаем задержку для более быстрого реагирования
|
stream = sd.get_stream()
|
||||||
|
if stream is None or not stream.active:
|
||||||
|
break
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
if not self._interrupted:
|
||||||
|
# Добираем хвост буфера даже если stream.active мигнул в False чуть раньше.
|
||||||
|
sd.wait()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Сообщаем потоку-наблюдателю, что пора завершаться
|
# Сообщаем потоку-наблюдателю, что пора завершаться
|
||||||
|
|||||||
@@ -9,12 +9,26 @@ Listens for the configured wake word.
|
|||||||
import pvporcupine
|
import pvporcupine
|
||||||
import pyaudio
|
import pyaudio
|
||||||
import struct
|
import struct
|
||||||
|
import io
|
||||||
|
import wave
|
||||||
|
import time
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import httpx
|
||||||
|
from collections import deque
|
||||||
|
from deepgram import DeepgramClient
|
||||||
|
from deepgram.clients.listen.v1.rest.options import PrerecordedOptions
|
||||||
from ..core.config import (
|
from ..core.config import (
|
||||||
|
DEEPGRAM_API_KEY,
|
||||||
PORCUPINE_ACCESS_KEY,
|
PORCUPINE_ACCESS_KEY,
|
||||||
PORCUPINE_KEYWORD_PATH,
|
PORCUPINE_KEYWORD_PATH,
|
||||||
PORCUPINE_SENSITIVITY,
|
PORCUPINE_SENSITIVITY,
|
||||||
|
WAKEWORD_HIT_COOLDOWN_SECONDS,
|
||||||
|
WAKEWORD_ENABLE_FALLBACK_STT,
|
||||||
|
WAKEWORD_MIN_RMS,
|
||||||
|
WAKEWORD_REOPEN_GRACE_SECONDS,
|
||||||
|
WAKEWORD_RMS_MULTIPLIER,
|
||||||
WAKE_WORD,
|
WAKE_WORD,
|
||||||
|
WAKE_WORD_ALIASES,
|
||||||
)
|
)
|
||||||
from ..core.audio_manager import get_audio_manager
|
from ..core.audio_manager import get_audio_manager
|
||||||
|
|
||||||
@@ -33,6 +47,19 @@ class WakeWordDetector:
|
|||||||
self._resampled_pcm_buffer = np.array([], dtype=np.int16)
|
self._resampled_pcm_buffer = np.array([], dtype=np.int16)
|
||||||
self._stream_closed = True # Флаг состояния потока (закрыт/открыт)
|
self._stream_closed = True # Флаг состояния потока (закрыт/открыт)
|
||||||
self._last_hit_ts = 0.0
|
self._last_hit_ts = 0.0
|
||||||
|
self._fallback_dg_client = None
|
||||||
|
self._fallback_pre_roll = deque(maxlen=4)
|
||||||
|
self._fallback_frames = []
|
||||||
|
self._fallback_active = False
|
||||||
|
self._fallback_silence_frames = 0
|
||||||
|
self._fallback_last_attempt_ts = 0.0
|
||||||
|
self._fallback_last_error_ts = 0.0
|
||||||
|
self._stream_opened_ts = 0.0
|
||||||
|
self._rms_history = deque(maxlen=220)
|
||||||
|
self._wakeword_aliases_compact = {
|
||||||
|
self._compact_text(WAKE_WORD),
|
||||||
|
*(self._compact_text(alias) for alias in WAKE_WORD_ALIASES),
|
||||||
|
}
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
"""Инициализация Porcupine и PyAudio."""
|
"""Инициализация Porcupine и PyAudio."""
|
||||||
@@ -87,6 +114,211 @@ class WakeWordDetector:
|
|||||||
)
|
)
|
||||||
self._resampled_pcm_buffer = np.array([], dtype=np.int16)
|
self._resampled_pcm_buffer = np.array([], dtype=np.int16)
|
||||||
self._stream_closed = False
|
self._stream_closed = False
|
||||||
|
self._stream_opened_ts = time.time()
|
||||||
|
self._reset_fallback_state()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_rms(pcm: np.ndarray) -> float:
|
||||||
|
if pcm.size == 0:
|
||||||
|
return 0.0
|
||||||
|
as_float = pcm.astype(np.float32)
|
||||||
|
return float(np.sqrt(np.mean(as_float * as_float)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compact_text(text: str) -> str:
|
||||||
|
text = str(text or "").lower().replace("ё", "е")
|
||||||
|
return "".join(ch for ch in text if ch.isalnum())
|
||||||
|
|
||||||
|
def _remember_rms(self, rms: float):
|
||||||
|
if rms <= 0:
|
||||||
|
return
|
||||||
|
self._rms_history.append(float(rms))
|
||||||
|
|
||||||
|
def _noise_floor_rms(self) -> float:
|
||||||
|
if not self._rms_history:
|
||||||
|
return 0.0
|
||||||
|
# Низкий процентиль устойчив к редким всплескам/голосу.
|
||||||
|
return float(np.percentile(np.asarray(self._rms_history, dtype=np.float32), 20))
|
||||||
|
|
||||||
|
def _wakeword_rms_threshold(self) -> float:
|
||||||
|
floor = self._noise_floor_rms()
|
||||||
|
dynamic = floor * float(WAKEWORD_RMS_MULTIPLIER)
|
||||||
|
# Защитный максимум, чтобы в очень шумном окружении не "убить" детект полностью.
|
||||||
|
dynamic = min(dynamic, float(WAKEWORD_MIN_RMS) * 4.0)
|
||||||
|
return max(float(WAKEWORD_MIN_RMS), dynamic)
|
||||||
|
|
||||||
|
def _is_hit_in_guard_window(
|
||||||
|
self, now_ts: float, *, ignore_hit_cooldown: bool = False
|
||||||
|
) -> bool:
|
||||||
|
if (
|
||||||
|
not ignore_hit_cooldown
|
||||||
|
and now_ts - self._last_hit_ts < float(WAKEWORD_HIT_COOLDOWN_SECONDS)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
if (
|
||||||
|
self._stream_opened_ts > 0
|
||||||
|
and now_ts - self._stream_opened_ts < float(WAKEWORD_REOPEN_GRACE_SECONDS)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _accept_porcupine_hit(
|
||||||
|
self,
|
||||||
|
pcm: np.ndarray,
|
||||||
|
now_ts: float,
|
||||||
|
*,
|
||||||
|
ignore_hit_cooldown: bool = False,
|
||||||
|
during_tts: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
if self._is_hit_in_guard_window(
|
||||||
|
now_ts, ignore_hit_cooldown=ignore_hit_cooldown
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
rms = self._compute_rms(pcm)
|
||||||
|
# Для "чистого" Porcupine оставляем мягкий амплитудный фильтр:
|
||||||
|
# он отсеивает тишину/щелчки и ложные фаны от фонового шума.
|
||||||
|
# Во время TTS делаем фильтр строже, чтобы собственная колонка
|
||||||
|
# не "будила" ассистента.
|
||||||
|
factor = 0.95 if during_tts else 0.75
|
||||||
|
threshold = max(80.0, self._wakeword_rms_threshold() * factor)
|
||||||
|
if rms < threshold:
|
||||||
|
return False
|
||||||
|
self._last_hit_ts = now_ts
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _reset_fallback_state(self):
|
||||||
|
self._fallback_pre_roll.clear()
|
||||||
|
self._fallback_frames = []
|
||||||
|
self._fallback_active = False
|
||||||
|
self._fallback_silence_frames = 0
|
||||||
|
|
||||||
|
def _get_fallback_client(self):
|
||||||
|
if not WAKEWORD_ENABLE_FALLBACK_STT:
|
||||||
|
return None
|
||||||
|
if not DEEPGRAM_API_KEY:
|
||||||
|
return None
|
||||||
|
if self._fallback_dg_client is None:
|
||||||
|
self._fallback_dg_client = DeepgramClient(DEEPGRAM_API_KEY)
|
||||||
|
return self._fallback_dg_client
|
||||||
|
|
||||||
|
def _pcm_to_wav_bytes(self, pcm: np.ndarray) -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with wave.open(buffer, "wb") as wav_file:
|
||||||
|
wav_file.setnchannels(1)
|
||||||
|
wav_file.setsampwidth(2)
|
||||||
|
wav_file.setframerate(int(self.porcupine.sample_rate))
|
||||||
|
wav_file.writeframes(np.asarray(pcm, dtype=np.int16).tobytes())
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
def _transcribe_wakeword_candidate(self, pcm: np.ndarray) -> bool:
|
||||||
|
client = self._get_fallback_client()
|
||||||
|
if client is None or pcm.size == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.listen.rest.v("1").transcribe_file(
|
||||||
|
{"buffer": self._pcm_to_wav_bytes(pcm)},
|
||||||
|
PrerecordedOptions(
|
||||||
|
model="nova-2",
|
||||||
|
language="ru",
|
||||||
|
smart_format=False,
|
||||||
|
punctuate=False,
|
||||||
|
utterances=False,
|
||||||
|
numerals=False,
|
||||||
|
),
|
||||||
|
timeout=httpx.Timeout(2.2, connect=2.2, read=2.2, write=2.2),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
now = time.time()
|
||||||
|
if now - self._fallback_last_error_ts >= 30.0:
|
||||||
|
print(f"⚠️ Wake word fallback STT failed: {exc}")
|
||||||
|
self._fallback_last_error_ts = now
|
||||||
|
return False
|
||||||
|
|
||||||
|
transcript = ""
|
||||||
|
confidence = None
|
||||||
|
try:
|
||||||
|
channels = response.results.channels or []
|
||||||
|
if channels and channels[0].alternatives:
|
||||||
|
first_alt = channels[0].alternatives[0]
|
||||||
|
transcript = str(first_alt.transcript or "").strip()
|
||||||
|
try:
|
||||||
|
confidence = float(first_alt.confidence)
|
||||||
|
except Exception:
|
||||||
|
confidence = None
|
||||||
|
except Exception:
|
||||||
|
transcript = ""
|
||||||
|
confidence = None
|
||||||
|
|
||||||
|
compact = self._compact_text(transcript)
|
||||||
|
if confidence is not None and confidence < 0.62:
|
||||||
|
return False
|
||||||
|
if compact in self._wakeword_aliases_compact:
|
||||||
|
print(f"✅ Wake word обнаружен fallback STT: {transcript}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _check_fallback_wakeword(
|
||||||
|
self,
|
||||||
|
pcm: np.ndarray,
|
||||||
|
*,
|
||||||
|
during_tts: bool = False,
|
||||||
|
ignore_hit_cooldown: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
if not WAKEWORD_ENABLE_FALLBACK_STT:
|
||||||
|
return False
|
||||||
|
if self.porcupine is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rms = self._compute_rms(pcm)
|
||||||
|
base_threshold = self._wakeword_rms_threshold()
|
||||||
|
speech_factor = 1.1 if during_tts else 0.85
|
||||||
|
speech_threshold = max(170.0, base_threshold * speech_factor)
|
||||||
|
silence_threshold = max(95.0, speech_threshold * 0.55)
|
||||||
|
silence_frames_to_finalize = 10 if during_tts else 8
|
||||||
|
min_frames = 10 if during_tts else 7
|
||||||
|
max_frames = 40
|
||||||
|
min_attempt_interval = 2.5 if during_tts else 1.0
|
||||||
|
|
||||||
|
if rms >= speech_threshold:
|
||||||
|
if not self._fallback_active:
|
||||||
|
self._fallback_active = True
|
||||||
|
self._fallback_frames = list(self._fallback_pre_roll)
|
||||||
|
self._fallback_silence_frames = 0
|
||||||
|
self._fallback_frames.append(np.asarray(pcm, dtype=np.int16))
|
||||||
|
elif self._fallback_active:
|
||||||
|
self._fallback_frames.append(np.asarray(pcm, dtype=np.int16))
|
||||||
|
if rms <= silence_threshold:
|
||||||
|
self._fallback_silence_frames += 1
|
||||||
|
else:
|
||||||
|
self._fallback_silence_frames = 0
|
||||||
|
|
||||||
|
if len(self._fallback_frames) > max_frames:
|
||||||
|
self._reset_fallback_state()
|
||||||
|
elif self._fallback_silence_frames >= silence_frames_to_finalize:
|
||||||
|
candidate = np.concatenate(self._fallback_frames) if self._fallback_frames else np.asarray([], dtype=np.int16)
|
||||||
|
self._reset_fallback_state()
|
||||||
|
if len(candidate) >= min_frames * int(self.porcupine.frame_length):
|
||||||
|
now = time.time()
|
||||||
|
candidate_rms = self._compute_rms(candidate)
|
||||||
|
candidate_threshold = self._wakeword_rms_threshold() * (
|
||||||
|
0.95 if during_tts else 0.75
|
||||||
|
)
|
||||||
|
candidate_threshold = max(float(WAKEWORD_MIN_RMS), candidate_threshold)
|
||||||
|
if (
|
||||||
|
now - self._fallback_last_attempt_ts >= min_attempt_interval
|
||||||
|
and not self._is_hit_in_guard_window(
|
||||||
|
now, ignore_hit_cooldown=ignore_hit_cooldown
|
||||||
|
)
|
||||||
|
and candidate_rms >= candidate_threshold
|
||||||
|
):
|
||||||
|
self._fallback_last_attempt_ts = now
|
||||||
|
if self._transcribe_wakeword_candidate(candidate):
|
||||||
|
self._last_hit_ts = now
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._fallback_pre_roll.append(np.asarray(pcm, dtype=np.int16))
|
||||||
|
return False
|
||||||
|
|
||||||
def stop_monitoring(self):
|
def stop_monitoring(self):
|
||||||
"""Явная остановка и закрытие потока (чтобы освободить микрофон для других задач)."""
|
"""Явная остановка и закрытие потока (чтобы освободить микрофон для других задач)."""
|
||||||
@@ -97,6 +329,8 @@ class WakeWordDetector:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._stream_closed = True
|
self._stream_closed = True
|
||||||
|
self._stream_opened_ts = 0.0
|
||||||
|
self._reset_fallback_state()
|
||||||
|
|
||||||
def _resample_to_target_rate(self, pcm: np.ndarray) -> np.ndarray:
|
def _resample_to_target_rate(self, pcm: np.ndarray) -> np.ndarray:
|
||||||
target_rate = int(self.porcupine.sample_rate)
|
target_rate = int(self.porcupine.sample_rate)
|
||||||
@@ -160,14 +394,20 @@ class WakeWordDetector:
|
|||||||
|
|
||||||
# Читаем небольшой кусочек аудио (frame)
|
# Читаем небольшой кусочек аудио (frame)
|
||||||
pcm = self._read_porcupine_frame()
|
pcm = self._read_porcupine_frame()
|
||||||
|
self._remember_rms(self._compute_rms(pcm))
|
||||||
|
|
||||||
# Обрабатываем фрейм через Porcupine
|
# Обрабатываем фрейм через Porcupine
|
||||||
keyword_index = self.porcupine.process(pcm.tolist())
|
keyword_index = self.porcupine.process(pcm.tolist())
|
||||||
|
|
||||||
# Если keyword_index >= 0, значит ключевое слово обнаружено
|
# Если keyword_index >= 0, значит ключевое слово обнаружено
|
||||||
if keyword_index >= 0:
|
if keyword_index >= 0:
|
||||||
print("✅ Wake word обнаружен!")
|
now = time.time()
|
||||||
# Важно: закрываем поток, чтобы освободить микрофон для STT (Deepgram)
|
if self._accept_porcupine_hit(pcm, now, during_tts=False):
|
||||||
|
print("✅ Wake word обнаружен!")
|
||||||
|
# Важно: закрываем поток, чтобы освободить микрофон для STT (Deepgram)
|
||||||
|
self.stop_monitoring()
|
||||||
|
return True
|
||||||
|
if self._check_fallback_wakeword(pcm):
|
||||||
self.stop_monitoring()
|
self.stop_monitoring()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -189,15 +429,25 @@ class WakeWordDetector:
|
|||||||
self._open_stream()
|
self._open_stream()
|
||||||
|
|
||||||
pcm = self._read_porcupine_frame()
|
pcm = self._read_porcupine_frame()
|
||||||
|
self._remember_rms(self._compute_rms(pcm))
|
||||||
|
|
||||||
keyword_index = self.porcupine.process(pcm.tolist())
|
keyword_index = self.porcupine.process(pcm.tolist())
|
||||||
if keyword_index >= 0:
|
if keyword_index >= 0:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - self._last_hit_ts < 0.2: # Уменьшаем интервал для более быстрой реакции
|
if not self._accept_porcupine_hit(
|
||||||
|
pcm,
|
||||||
|
now,
|
||||||
|
ignore_hit_cooldown=True,
|
||||||
|
during_tts=True,
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
self._last_hit_ts = now
|
|
||||||
print("🛑 Wake word обнаружен во время ответа!")
|
print("🛑 Wake word обнаружен во время ответа!")
|
||||||
return True
|
return True
|
||||||
|
if self._check_fallback_wakeword(
|
||||||
|
pcm, during_tts=True, ignore_hit_cooldown=True
|
||||||
|
):
|
||||||
|
print("🛑 Wake word обнаружен fallback STT во время ответа!")
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|||||||
140
app/core/ai.py
140
app/core/ai.py
@@ -7,7 +7,12 @@ from typing import Optional
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
|
AI_CHAT_MAX_CHARS,
|
||||||
AI_PROVIDER,
|
AI_PROVIDER,
|
||||||
|
AI_CHAT_MAX_TOKENS,
|
||||||
|
AI_CHAT_TEMPERATURE,
|
||||||
|
AI_INTENT_TEMPERATURE,
|
||||||
|
AI_TRANSLATION_TEMPERATURE,
|
||||||
ANTHROPIC_API_KEY,
|
ANTHROPIC_API_KEY,
|
||||||
ANTHROPIC_API_URL,
|
ANTHROPIC_API_URL,
|
||||||
ANTHROPIC_API_VERSION,
|
ANTHROPIC_API_VERSION,
|
||||||
@@ -31,15 +36,25 @@ from .config import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_HTTP = requests.Session()
|
_HTTP = requests.Session()
|
||||||
|
_CITATION_SQUARE_RE = re.compile(r"(?:\s*\[\d+\])+")
|
||||||
|
_CITATION_FULLWIDTH_RE = re.compile(r"【\d+[^】]*】")
|
||||||
|
_PUNCT_SPACING_RE = re.compile(r"\s+([,.;:!?…])")
|
||||||
|
_SENTENCE_BOUNDARY_RE = re.compile(r"([.!?…])\s+")
|
||||||
|
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?…])\s+")
|
||||||
|
|
||||||
# Системный промпт
|
# Системный промпт
|
||||||
_wake_word_aliases_text = ", ".join(WAKE_WORD_ALIASES)
|
_wake_word_aliases_text = ", ".join(WAKE_WORD_ALIASES)
|
||||||
SYSTEM_PROMPT = f"""Ты — умный голосовой ассистент с человеческим поведением.
|
SYSTEM_PROMPT = f"""Ты — умный голосовой ассистент с человеческим поведением.
|
||||||
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
|
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
|
||||||
Твоя главная цель — помогать пользователю и поддерживать интересный диалог.
|
Твоя главная цель — помогать пользователю и поддерживать интересный диалог.
|
||||||
Отвечай кратко и по существу, на русском языке.
|
Отвечай на русском языке кратко и по существу: обычно 1-2 коротких предложения.
|
||||||
|
Если пользователь явно просит подробнее, можно до 4 коротких предложений без повторов и лишних вводных.
|
||||||
Избегай длинных списков, сложного форматирования и спецсимволов, так как твои ответы озвучиваются голосом.
|
Избегай длинных списков, сложного форматирования и спецсимволов, так как твои ответы озвучиваются голосом.
|
||||||
|
Не добавляй ссылки, сноски и маркеры источников (например, [1], [2], URL).
|
||||||
Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов.
|
Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов.
|
||||||
|
Понимай юмор, иронию, сарказм, образные выражения, намеки и переносный смысл фраз.
|
||||||
|
Если пользователь шутит или говорит образно, сначала правильно восстанови его реальное намерение, затем ответь естественно и по смыслу.
|
||||||
|
Если в шутке или метафоре скрыта команда или просьба, трактуй ее по смыслу, а не буквально.
|
||||||
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные.
|
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные.
|
||||||
Тебя активируют словом "{WAKE_WORD}". Никогда не произноси это слово и его варианты ({_wake_word_aliases_text}) ни в каком ответе.
|
Тебя активируют словом "{WAKE_WORD}". Никогда не произноси это слово и его варианты ({_wake_word_aliases_text}) ни в каком ответе.
|
||||||
Если пользователь спрашивает, как тебя зовут или как к тебе обращаться, отвечай нейтрально: "Я ваш голосовой ассистент"."""
|
Если пользователь спрашивает, как тебя зовут или как к тебе обращаться, отвечай нейтрально: "Я ваш голосовой ассистент"."""
|
||||||
@@ -73,7 +88,18 @@ INTENT_SYSTEM_PROMPT = """Ты NLU-модуль голосовой колонк
|
|||||||
- Для "что играет" = music_action=current.
|
- Для "что играет" = music_action=current.
|
||||||
- Для "включи жанр X" = music_action=play_genre, music_query=X.
|
- Для "включи жанр X" = music_action=play_genre, music_query=X.
|
||||||
- Для "включи папку X" = music_action=play_folder, music_query=X.
|
- Для "включи папку X" = music_action=play_folder, music_query=X.
|
||||||
|
- Если это будильник, ставь intent=alarm и нормализуй команду в одну из форм:
|
||||||
|
1) Создание/изменение: "поставь будильник на HH:MM [по будням|по выходным|каждый день|по <дням>]"
|
||||||
|
2) Показ списка: "покажи активные будильники"
|
||||||
|
3) Удаление конкретного: "удали будильник на HH:MM [по будням|по выходным|по <дням>]"
|
||||||
|
4) Удаление всех: "отмени все будильники"
|
||||||
|
- Если пользователь просит поставить/удалить будильник, но время не названо, normalized_command должен быть:
|
||||||
|
"поставь будильник" или "удали будильник".
|
||||||
- normalized_command должен быть пригоден для командного парсера (без лишних слов).
|
- normalized_command должен быть пригоден для командного парсера (без лишних слов).
|
||||||
|
- Понимай разговорные, шутливые, переносные, косвенные и ироничные формулировки.
|
||||||
|
- Восстанавливай намерение по смыслу, а не только по буквальным словам.
|
||||||
|
- Если в фразе есть скрытая прикладная команда для колонки, верни соответствующий intent и normalized_command.
|
||||||
|
- Если пользователь просто шутит или разговаривает без прикладной команды, выбирай smalltalk или chat, а не случайную системную команду.
|
||||||
- Если уверенность низкая, ставь intent=none, music_action=none, confidence <= 0.4."""
|
- Если уверенность низкая, ставь intent=none, music_action=none, confidence <= 0.4."""
|
||||||
|
|
||||||
_PROVIDER_ALIASES = {
|
_PROVIDER_ALIASES = {
|
||||||
@@ -442,6 +468,60 @@ def _extract_json_object(raw_text: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_chat_response(text: str) -> str:
|
||||||
|
cleaned = str(text or "")
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
cleaned = _CITATION_SQUARE_RE.sub("", cleaned)
|
||||||
|
cleaned = _CITATION_FULLWIDTH_RE.sub("", cleaned)
|
||||||
|
cleaned = _PUNCT_SPACING_RE.sub(r"\1", cleaned)
|
||||||
|
cleaned = re.sub(r"[ \t]+", " ", cleaned)
|
||||||
|
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_chat_response(text: str, max_chars: int) -> str:
|
||||||
|
cleaned = str(text or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
safe_limit = max(120, int(max_chars))
|
||||||
|
if len(cleaned) <= safe_limit:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
sentences = [part.strip() for part in _SENTENCE_SPLIT_RE.split(cleaned) if part.strip()]
|
||||||
|
if sentences:
|
||||||
|
selected = []
|
||||||
|
current_length = 0
|
||||||
|
for sentence in sentences:
|
||||||
|
projected = current_length + len(sentence) + (1 if selected else 0)
|
||||||
|
if projected > safe_limit:
|
||||||
|
break
|
||||||
|
selected.append(sentence)
|
||||||
|
current_length = projected
|
||||||
|
|
||||||
|
if selected:
|
||||||
|
result = " ".join(selected).rstrip(" ,;:-")
|
||||||
|
if result and result[-1] not in ".!?…":
|
||||||
|
result += "."
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Если первое предложение слишком длинное, режем аккуратно по слову.
|
||||||
|
first = sentences[0]
|
||||||
|
else:
|
||||||
|
first = cleaned
|
||||||
|
|
||||||
|
clipped = first[:safe_limit].rstrip()
|
||||||
|
word_boundary = clipped.rfind(" ")
|
||||||
|
if word_boundary >= int(safe_limit * 0.6):
|
||||||
|
clipped = clipped[:word_boundary].rstrip()
|
||||||
|
clipped = clipped.rstrip(" ,;:-")
|
||||||
|
if clipped.endswith((".", "!", "?", "…")):
|
||||||
|
return clipped
|
||||||
|
return f"{clipped}..."
|
||||||
|
|
||||||
|
|
||||||
def _send_request(messages, max_tokens, temperature, error_text):
|
def _send_request(messages, max_tokens, temperature, error_text):
|
||||||
"""
|
"""
|
||||||
Внутренняя функция для отправки HTTP-запроса к выбранному AI-провайдеру.
|
Внутренняя функция для отправки HTTP-запроса к выбранному AI-провайдеру.
|
||||||
@@ -512,7 +592,7 @@ def interpret_assistant_intent(text: str) -> dict:
|
|||||||
response = _send_request(
|
response = _send_request(
|
||||||
messages,
|
messages,
|
||||||
max_tokens=220,
|
max_tokens=220,
|
||||||
temperature=0.0,
|
temperature=AI_INTENT_TEMPERATURE,
|
||||||
error_text="",
|
error_text="",
|
||||||
)
|
)
|
||||||
payload = _extract_json_object(response)
|
payload = _extract_json_object(response)
|
||||||
@@ -596,10 +676,12 @@ def ask_ai(messages_history: list) -> str:
|
|||||||
|
|
||||||
response = _send_request(
|
response = _send_request(
|
||||||
messages,
|
messages,
|
||||||
max_tokens=500,
|
max_tokens=AI_CHAT_MAX_TOKENS,
|
||||||
temperature=1.0, # Высокая температура для более живого общения
|
temperature=AI_CHAT_TEMPERATURE,
|
||||||
error_text="Произошла ошибка при обращении к AI. Попробуйте ещё раз.",
|
error_text="Произошла ошибка при обращении к AI. Попробуйте ещё раз.",
|
||||||
)
|
)
|
||||||
|
response = _sanitize_chat_response(response)
|
||||||
|
response = _truncate_chat_response(response, AI_CHAT_MAX_CHARS)
|
||||||
|
|
||||||
if response:
|
if response:
|
||||||
print(f"💬 Ответ AI: {response[:100]}...")
|
print(f"💬 Ответ AI: {response[:100]}...")
|
||||||
@@ -610,6 +692,7 @@ def ask_ai_stream(messages_history: list):
|
|||||||
"""
|
"""
|
||||||
Generator that yields chunks of the AI response as they arrive.
|
Generator that yields chunks of the AI response as they arrive.
|
||||||
"""
|
"""
|
||||||
|
response = None
|
||||||
cfg, selection_error = _get_provider_settings()
|
cfg, selection_error = _get_provider_settings()
|
||||||
if selection_error:
|
if selection_error:
|
||||||
yield selection_error
|
yield selection_error
|
||||||
@@ -637,14 +720,46 @@ def ask_ai_stream(messages_history: list):
|
|||||||
response = _HTTP.post(
|
response = _HTTP.post(
|
||||||
cfg["api_url"],
|
cfg["api_url"],
|
||||||
headers=_build_headers(cfg),
|
headers=_build_headers(cfg),
|
||||||
json=_build_payload(cfg, messages, 500, 1.0, stream=True),
|
json=_build_payload(
|
||||||
|
cfg,
|
||||||
|
messages,
|
||||||
|
AI_CHAT_MAX_TOKENS,
|
||||||
|
AI_CHAT_TEMPERATURE,
|
||||||
|
stream=True,
|
||||||
|
),
|
||||||
timeout=15,
|
timeout=15,
|
||||||
stream=True,
|
stream=True,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Для устойчивости TTS сначала собираем поток, затем чистим и аккуратно
|
||||||
|
# ограничиваем длину по границе предложения.
|
||||||
|
raw_parts = []
|
||||||
for chunk in _iter_stream_chunks(cfg, response):
|
for chunk in _iter_stream_chunks(cfg, response):
|
||||||
yield chunk
|
if chunk:
|
||||||
|
raw_parts.append(chunk)
|
||||||
|
|
||||||
|
full_text = _sanitize_chat_response("".join(raw_parts))
|
||||||
|
full_text = _truncate_chat_response(full_text, AI_CHAT_MAX_CHARS)
|
||||||
|
if not full_text:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Отдаем кусками по предложениям, чтобы main.py мог начинать озвучку раньше.
|
||||||
|
parts = _SENTENCE_BOUNDARY_RE.split(full_text)
|
||||||
|
if not parts:
|
||||||
|
yield full_text
|
||||||
|
return
|
||||||
|
|
||||||
|
sentence = ""
|
||||||
|
for part in parts:
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
sentence += part
|
||||||
|
if part in ".!?…":
|
||||||
|
yield sentence.strip() + " "
|
||||||
|
sentence = ""
|
||||||
|
if sentence.strip():
|
||||||
|
yield sentence.strip()
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
yield f"Извините, сервер {cfg['name']} не отвечает. Попробуйте позже."
|
yield f"Извините, сервер {cfg['name']} не отвечает. Попробуйте позже."
|
||||||
except requests.exceptions.RequestException as error:
|
except requests.exceptions.RequestException as error:
|
||||||
@@ -653,6 +768,12 @@ def ask_ai_stream(messages_history: list):
|
|||||||
except Exception as error:
|
except Exception as error:
|
||||||
print(f"❌ Streaming Error ({cfg['name']}): {error}")
|
print(f"❌ Streaming Error ({cfg['name']}): {error}")
|
||||||
yield "Произошла ошибка связи."
|
yield "Произошла ошибка связи."
|
||||||
|
finally:
|
||||||
|
if response is not None:
|
||||||
|
try:
|
||||||
|
response.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def translate_text(text: str, source_lang: str, target_lang: str) -> str:
|
def translate_text(text: str, source_lang: str, target_lang: str) -> str:
|
||||||
@@ -683,17 +804,18 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str:
|
|||||||
response = _send_request(
|
response = _send_request(
|
||||||
messages,
|
messages,
|
||||||
max_tokens=160,
|
max_tokens=160,
|
||||||
temperature=0.2, # Низкая температура для точности перевода
|
temperature=AI_TRANSLATION_TEMPERATURE,
|
||||||
error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
|
error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
|
||||||
)
|
)
|
||||||
cleaned = response.strip()
|
cleaned = _sanitize_chat_response(response).strip()
|
||||||
|
cleaned = re.sub(r"[*_`]+", "", cleaned)
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
# Normalize to 2-3 variants separated by " / "
|
# Normalize to 2-3 variants separated by " / "
|
||||||
parts = []
|
parts = []
|
||||||
for chunk in re.split(r"(?:\s*/\s*|\n|;|\|)", cleaned):
|
for chunk in re.split(r"(?:\s*/\s*|\n|;|\|)", cleaned):
|
||||||
item = chunk.strip(" \t-•")
|
item = chunk.strip(" \t-•\"'“”«»")
|
||||||
if item:
|
if item:
|
||||||
parts.append(item)
|
parts.append(item)
|
||||||
if not parts:
|
if not parts:
|
||||||
|
|||||||
@@ -130,11 +130,9 @@ class AudioManager:
|
|||||||
if match_idx is not None:
|
if match_idx is not None:
|
||||||
return match_idx
|
return match_idx
|
||||||
|
|
||||||
raise RuntimeError(
|
print(
|
||||||
"Audio input initialization failed: could not find an input device "
|
"⚠️ AUDIO_INPUT_DEVICE_NAME was set but no matching input device was found: "
|
||||||
f"matching AUDIO_INPUT_DEVICE_NAME={AUDIO_INPUT_DEVICE_NAME!r}. "
|
f"{AUDIO_INPUT_DEVICE_NAME!r}. Falling back to default input selection."
|
||||||
"Available input devices:\n"
|
|
||||||
+ self.describe_input_devices()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default input device (if PortAudio has one).
|
# Default input device (if PortAudio has one).
|
||||||
@@ -176,11 +174,9 @@ class AudioManager:
|
|||||||
)
|
)
|
||||||
if match_idx is not None:
|
if match_idx is not None:
|
||||||
return match_idx
|
return match_idx
|
||||||
raise RuntimeError(
|
print(
|
||||||
"Audio output initialization failed: could not find an output device "
|
"⚠️ AUDIO_OUTPUT_DEVICE_NAME was set but no matching output device was found: "
|
||||||
f"matching AUDIO_OUTPUT_DEVICE_NAME={AUDIO_OUTPUT_DEVICE_NAME!r}. "
|
f"{AUDIO_OUTPUT_DEVICE_NAME!r}. Falling back to default output selection."
|
||||||
"Available output devices:\n"
|
|
||||||
+ self.describe_output_devices()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
default_idx = self._get_default_output_index()
|
default_idx = self._get_default_output_index()
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ def numbers_to_words(text: str) -> str:
|
|||||||
case = "nominative"
|
case = "nominative"
|
||||||
gender = "m"
|
gender = "m"
|
||||||
prep_clean = prep.strip().lower() if prep else None
|
prep_clean = prep.strip().lower() if prep else None
|
||||||
|
parsed = None
|
||||||
|
|
||||||
if prep_clean:
|
if prep_clean:
|
||||||
morph_case = get_case_from_preposition(prep_clean)
|
morph_case = get_case_from_preposition(prep_clean)
|
||||||
@@ -359,6 +360,7 @@ def numbers_to_words(text: str) -> str:
|
|||||||
# Спец-случай: "на 1 час"
|
# Спец-случай: "на 1 час"
|
||||||
if (
|
if (
|
||||||
prep_clean == "на"
|
prep_clean == "на"
|
||||||
|
and parsed is not None
|
||||||
and parsed.normal_form in TIME_UNIT_LEMMAS
|
and parsed.normal_form in TIME_UNIT_LEMMAS
|
||||||
and parsed.tag.gender in ("masc", "neut")
|
and parsed.tag.gender in ("masc", "neut")
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ Command parsing helpers.
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from .config import WAKE_WORD, WAKE_WORD_ALIASES
|
||||||
|
from ..audio.sound_level import is_volume_command, parse_volume_text
|
||||||
|
|
||||||
_STOP_WORDS_STRICT = {
|
_STOP_WORDS_STRICT = {
|
||||||
"стоп",
|
"стоп",
|
||||||
"хватит",
|
"хватит",
|
||||||
@@ -31,6 +34,28 @@ _STOP_PATTERNS_LENIENT = [
|
|||||||
r"\bдостаточно\b",
|
r"\bдостаточно\b",
|
||||||
]
|
]
|
||||||
_STOP_PATTERNS_LENIENT_COMPILED = [re.compile(p) for p in _STOP_PATTERNS_LENIENT]
|
_STOP_PATTERNS_LENIENT_COMPILED = [re.compile(p) for p in _STOP_PATTERNS_LENIENT]
|
||||||
|
_FAST_WEATHER_PHRASES = {
|
||||||
|
"какая погода",
|
||||||
|
"какая погода на улице",
|
||||||
|
"какая сейчас погода",
|
||||||
|
"какая сейчас погода на улице",
|
||||||
|
"что по погоде",
|
||||||
|
"погода",
|
||||||
|
"погода на улице",
|
||||||
|
"что на улице",
|
||||||
|
"что там на улице",
|
||||||
|
"че там на улице",
|
||||||
|
}
|
||||||
|
_FAST_MUSIC_PHRASES = {
|
||||||
|
"включи музыку",
|
||||||
|
"поставь музыку",
|
||||||
|
"играй музыку",
|
||||||
|
"play music",
|
||||||
|
}
|
||||||
|
_WAKEWORD_PREFIX_RE = re.compile(
|
||||||
|
rf"^(?:{'|'.join(re.escape(alias) for alias in sorted({WAKE_WORD.lower(), *WAKE_WORD_ALIASES}, key=len, reverse=True))})(?:\s+|$)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_text(text: str) -> str:
|
def _normalize_text(text: str) -> str:
|
||||||
@@ -40,6 +65,13 @@ def _normalize_text(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_command_text(text: str) -> str:
|
||||||
|
normalized = _normalize_text(text)
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
return _WAKEWORD_PREFIX_RE.sub("", normalized, count=1).strip()
|
||||||
|
|
||||||
|
|
||||||
def is_stop_command(text: str, mode: str = "strict") -> bool:
|
def is_stop_command(text: str, mode: str = "strict") -> bool:
|
||||||
"""
|
"""
|
||||||
Detect stop commands in text.
|
Detect stop commands in text.
|
||||||
@@ -64,3 +96,27 @@ def is_stop_command(text: str, mode: str = "strict") -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_fast_command(text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Detect short commands that can stop STT early without waiting
|
||||||
|
for full utterance finalization.
|
||||||
|
"""
|
||||||
|
normalized = normalize_command_text(text)
|
||||||
|
if not normalized:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if is_stop_command(normalized, mode="strict"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if normalized in _FAST_WEATHER_PHRASES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if normalized in _FAST_MUSIC_PHRASES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if is_volume_command(normalized) and parse_volume_text(normalized) is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|||||||
@@ -7,15 +7,49 @@ Loads environment variables from .env file.
|
|||||||
# Он загружает настройки из файла .env (переменные окружения) и определяет константы.
|
# Он загружает настройки из файла .env (переменные окружения) и определяет константы.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
# Базовая директория проекта (корневая папка, где лежит .env)
|
# Базовая директория проекта (корневая папка, где лежит .env)
|
||||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
# Загружаем переменные из файла .env в корневом каталоге
|
def _load_project_env(env_path: Path) -> None:
|
||||||
load_dotenv(BASE_DIR / ".env")
|
"""
|
||||||
|
Загружает .env, игнорируя строковый "шум" без формата KEY=VALUE.
|
||||||
|
Это делает конфиг устойчивым к человеческим комментариям без символа '#'.
|
||||||
|
"""
|
||||||
|
if not env_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_text = env_path.read_text(encoding="utf-8")
|
||||||
|
sanitized_lines = []
|
||||||
|
|
||||||
|
for line in raw_text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
sanitized_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "=" in line:
|
||||||
|
key = line.split("=", 1)[0].strip()
|
||||||
|
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key):
|
||||||
|
sanitized_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Игнорируем невалидные строки, чтобы dotenv не шумел warning'ами.
|
||||||
|
sanitized_lines.append(f"# ignored invalid env line: {line}")
|
||||||
|
|
||||||
|
parsed = dotenv_values(stream=StringIO("\n".join(sanitized_lines)))
|
||||||
|
for key, value in parsed.items():
|
||||||
|
if key and value is not None and os.getenv(key) is None:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
# Загружаем переменные из .env в корневом каталоге
|
||||||
|
_load_project_env(BASE_DIR / ".env")
|
||||||
|
|
||||||
# --- Настройки AI ---
|
# --- Настройки AI ---
|
||||||
# AI_PROVIDER опционален. Приоритет у единственного активного AI API key.
|
# AI_PROVIDER опционален. Приоритет у единственного активного AI API key.
|
||||||
@@ -29,6 +63,22 @@ OPENROUTER_API_URL = os.getenv(
|
|||||||
"OPENROUTER_API_URL", "https://openrouter.ai/api/v1/chat/completions"
|
"OPENROUTER_API_URL", "https://openrouter.ai/api/v1/chat/completions"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_clamped_float_env(name: str, default: str, minimum: float, maximum: float) -> float:
|
||||||
|
try:
|
||||||
|
value = float(os.getenv(name, default))
|
||||||
|
except Exception:
|
||||||
|
value = float(default)
|
||||||
|
return max(minimum, min(maximum, value))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_clamped_int_env(name: str, default: str, minimum: int, maximum: int) -> int:
|
||||||
|
try:
|
||||||
|
value = int(os.getenv(name, default))
|
||||||
|
except Exception:
|
||||||
|
value = int(default)
|
||||||
|
return max(minimum, min(maximum, value))
|
||||||
|
|
||||||
# OpenAI
|
# OpenAI
|
||||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||||
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
||||||
@@ -65,6 +115,13 @@ OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1:8b")
|
|||||||
OLLAMA_API_URL = os.getenv(
|
OLLAMA_API_URL = os.getenv(
|
||||||
"OLLAMA_API_URL", "http://localhost:11434/v1/chat/completions"
|
"OLLAMA_API_URL", "http://localhost:11434/v1/chat/completions"
|
||||||
)
|
)
|
||||||
|
AI_CHAT_TEMPERATURE = _read_clamped_float_env("AI_CHAT_TEMPERATURE", "0.9", 0.0, 2.0)
|
||||||
|
AI_CHAT_MAX_TOKENS = _read_clamped_int_env("AI_CHAT_MAX_TOKENS", "220", 80, 700)
|
||||||
|
AI_CHAT_MAX_CHARS = _read_clamped_int_env("AI_CHAT_MAX_CHARS", "320", 120, 1200)
|
||||||
|
AI_INTENT_TEMPERATURE = _read_clamped_float_env("AI_INTENT_TEMPERATURE", "0.0", 0.0, 1.0)
|
||||||
|
AI_TRANSLATION_TEMPERATURE = _read_clamped_float_env(
|
||||||
|
"AI_TRANSLATION_TEMPERATURE", "0.2", 0.0, 1.0
|
||||||
|
)
|
||||||
|
|
||||||
# --- Настройки распознавания речи (Deepgram) ---
|
# --- Настройки распознавания речи (Deepgram) ---
|
||||||
# Ключ для облачного STT (Speech-to-Text)
|
# Ключ для облачного STT (Speech-to-Text)
|
||||||
@@ -86,6 +143,42 @@ WAKE_WORD_ALIASES = (
|
|||||||
PORCUPINE_KEYWORD_PATH = BASE_DIR / "assets" / "models" / "Waltron_en_linux_v4_0_0.ppn"
|
PORCUPINE_KEYWORD_PATH = BASE_DIR / "assets" / "models" / "Waltron_en_linux_v4_0_0.ppn"
|
||||||
# Чувствительность wake word (0..1). Выше = ловит легче, но больше ложных срабатываний.
|
# Чувствительность wake word (0..1). Выше = ловит легче, но больше ложных срабатываний.
|
||||||
PORCUPINE_SENSITIVITY = float(os.getenv("PORCUPINE_SENSITIVITY", "0.8"))
|
PORCUPINE_SENSITIVITY = float(os.getenv("PORCUPINE_SENSITIVITY", "0.8"))
|
||||||
|
# Антифантомный фильтр wake word по RMS-сигналу.
|
||||||
|
# Чем выше WAKEWORD_MIN_RMS / WAKEWORD_RMS_MULTIPLIER, тем меньше ложных срабатываний,
|
||||||
|
# но тем выше риск не распознать очень тихую активацию.
|
||||||
|
try:
|
||||||
|
WAKEWORD_MIN_RMS = float(os.getenv("WAKEWORD_MIN_RMS", "120"))
|
||||||
|
except Exception:
|
||||||
|
WAKEWORD_MIN_RMS = 120.0
|
||||||
|
WAKEWORD_MIN_RMS = max(0.0, WAKEWORD_MIN_RMS)
|
||||||
|
try:
|
||||||
|
WAKEWORD_RMS_MULTIPLIER = float(os.getenv("WAKEWORD_RMS_MULTIPLIER", "1.7"))
|
||||||
|
except Exception:
|
||||||
|
WAKEWORD_RMS_MULTIPLIER = 1.7
|
||||||
|
WAKEWORD_RMS_MULTIPLIER = max(1.0, WAKEWORD_RMS_MULTIPLIER)
|
||||||
|
try:
|
||||||
|
WAKEWORD_HIT_COOLDOWN_SECONDS = float(
|
||||||
|
os.getenv("WAKEWORD_HIT_COOLDOWN_SECONDS", "1.2")
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
WAKEWORD_HIT_COOLDOWN_SECONDS = 1.2
|
||||||
|
WAKEWORD_HIT_COOLDOWN_SECONDS = max(0.0, WAKEWORD_HIT_COOLDOWN_SECONDS)
|
||||||
|
try:
|
||||||
|
WAKEWORD_REOPEN_GRACE_SECONDS = float(
|
||||||
|
os.getenv("WAKEWORD_REOPEN_GRACE_SECONDS", "0.45")
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
WAKEWORD_REOPEN_GRACE_SECONDS = 0.45
|
||||||
|
WAKEWORD_REOPEN_GRACE_SECONDS = max(0.0, WAKEWORD_REOPEN_GRACE_SECONDS)
|
||||||
|
WAKEWORD_ENABLE_FALLBACK_STT = (
|
||||||
|
os.getenv("WAKEWORD_ENABLE_FALLBACK_STT", "0").strip().lower()
|
||||||
|
in {"1", "true", "yes", "on"}
|
||||||
|
)
|
||||||
|
# При активации wake word музыка приглушается до указанного процента от текущего уровня.
|
||||||
|
WAKEWORD_MUSIC_DUCK_PERCENT = _read_clamped_int_env(
|
||||||
|
"WAKEWORD_MUSIC_DUCK_PERCENT", "20", 1, 100
|
||||||
|
)
|
||||||
|
WAKEWORD_MUSIC_DUCK_RATIO = WAKEWORD_MUSIC_DUCK_PERCENT / 100.0
|
||||||
|
|
||||||
# --- Параметры аудио ---
|
# --- Параметры аудио ---
|
||||||
# Частота дискретизации для микрофона (стандарт для распознавания речи)
|
# Частота дискретизации для микрофона (стандарт для распознавания речи)
|
||||||
@@ -134,17 +227,17 @@ _stt_sfx_default = BASE_DIR / "assets" / "sounds" / "alisa-golosovoj-pomoschnik.
|
|||||||
if not _stt_sfx_default.exists():
|
if not _stt_sfx_default.exists():
|
||||||
_stt_sfx_default = Path.home() / "Music" / "alisa-golosovoj-pomoschnik.mp3"
|
_stt_sfx_default = Path.home() / "Music" / "alisa-golosovoj-pomoschnik.mp3"
|
||||||
STT_START_SOUND_PATH = os.getenv("STT_START_SOUND_PATH", "").strip() or str(_stt_sfx_default)
|
STT_START_SOUND_PATH = os.getenv("STT_START_SOUND_PATH", "").strip() or str(_stt_sfx_default)
|
||||||
try:
|
# Звук старта STT всегда на 100% громкости, чтобы по уровню был как обычный TTS-ответ.
|
||||||
STT_START_SOUND_VOLUME = float(os.getenv("STT_START_SOUND_VOLUME", "0.25"))
|
STT_START_SOUND_VOLUME = 1.0
|
||||||
except Exception:
|
|
||||||
STT_START_SOUND_VOLUME = 0.25
|
|
||||||
STT_START_SOUND_VOLUME = max(0.0, min(1.0, STT_START_SOUND_VOLUME))
|
|
||||||
# Голос для русского языка (eugene - мужской голос)
|
# Голос для русского языка (eugene - мужской голос)
|
||||||
TTS_SPEAKER = "eugene" # Доступные (ru): aidar, baya, kseniya, xenia, eugene
|
TTS_SPEAKER = "eugene" # Доступные (ru): aidar, baya, kseniya, xenia, eugene
|
||||||
# Голос для английского языка
|
# Голос для английского языка
|
||||||
TTS_EN_SPEAKER = os.getenv("TTS_EN_SPEAKER", "en_0")
|
TTS_EN_SPEAKER = os.getenv("TTS_EN_SPEAKER", "en_0")
|
||||||
# Частота дискретизации для воспроизведения (качество звука)
|
# Частота дискретизации для воспроизведения (качество звука)
|
||||||
TTS_SAMPLE_RATE = 48000
|
TTS_SAMPLE_RATE = 48000
|
||||||
|
# Скорость TTS: 1.0 = обычная, <1.0 = медленнее, >1.0 = быстрее.
|
||||||
|
# По умолчанию чуть медленнее для более разборчивой речи.
|
||||||
|
TTS_SPEED = _read_clamped_float_env("TTS_SPEED", "0.96", 0.85, 1.15)
|
||||||
|
|
||||||
# --- Настройки погоды ---
|
# --- Настройки погоды ---
|
||||||
WEATHER_LAT = os.getenv("WEATHER_LAT")
|
WEATHER_LAT = os.getenv("WEATHER_LAT")
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ _PARTS_OF_DAY = {"утра", "дня", "вечера", "ночи"}
|
|||||||
_FILLER_WORDS = {"мне", "меня", "пожалуйста", "на", "в", "во", "к", "и"}
|
_FILLER_WORDS = {"мне", "меня", "пожалуйста", "на", "в", "во", "к", "и"}
|
||||||
_HOUR_WORDS = {"час", "часа", "часов"}
|
_HOUR_WORDS = {"час", "часа", "часов"}
|
||||||
_MINUTE_WORDS = {"минута", "минуту", "минуты", "минут"}
|
_MINUTE_WORDS = {"минута", "минуту", "минуты", "минут"}
|
||||||
|
_ALARM_MARKERS = {"будильник", "разбуди", "поставь", "установи", "включи", "на", "в", "к"}
|
||||||
|
_ALARM_LIST_RE = re.compile(
|
||||||
|
r"\b(какие|какой|список|активн|покажи|показать|сколько|есть ли|перечисли)\b"
|
||||||
|
)
|
||||||
|
_ALARM_CANCEL_RE = re.compile(
|
||||||
|
r"\b(отмени|отмена|удали|удалить|выключи|отключи|деактивир|сбрось|очисти)\b"
|
||||||
|
)
|
||||||
|
_ALARM_CREATE_RE = re.compile(
|
||||||
|
r"\b(постав|установ|запусти|включи|разбуди|создай|добавь|измени|перенес|назнач)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_number_tokens(tokens, start_index: int):
|
def _parse_number_tokens(tokens, start_index: int):
|
||||||
@@ -97,10 +107,9 @@ def _apply_part_of_day(hour: int, part_of_day: str | None) -> int:
|
|||||||
|
|
||||||
def _extract_alarm_time_words(text: str):
|
def _extract_alarm_time_words(text: str):
|
||||||
tokens = re.findall(r"[a-zа-я0-9]+", text.lower().replace("ё", "е"))
|
tokens = re.findall(r"[a-zа-я0-9]+", text.lower().replace("ё", "е"))
|
||||||
markers = {"будильник", "разбуди", "поставь", "установи", "включи", "на", "в", "к"}
|
|
||||||
|
|
||||||
for index, token in enumerate(tokens):
|
for index, token in enumerate(tokens):
|
||||||
if token not in markers:
|
if token not in _ALARM_MARKERS:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
current = index + 1
|
current = index + 1
|
||||||
@@ -134,6 +143,40 @@ def _extract_alarm_time_words(text: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_alarm_time(text: str):
|
||||||
|
# Формат "7:30", "7.30", "7-30" и варианты с "в/на/к".
|
||||||
|
match = re.search(r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})[:.-](\d{2})\b", text)
|
||||||
|
if match:
|
||||||
|
h, m = int(match.group(1)), int(match.group(2))
|
||||||
|
period_match = re.search(
|
||||||
|
r"\b(?:на|в|во|к)?\s*"
|
||||||
|
+ re.escape(match.group(0).strip())
|
||||||
|
+ r"\s+(утра|дня|вечера|ночи)\b",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
part_of_day = period_match.group(1) if period_match else None
|
||||||
|
h = _apply_part_of_day(h, part_of_day)
|
||||||
|
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||||
|
return h, m
|
||||||
|
|
||||||
|
# Формат цифрами: "в 7 утра", "на 7", "к 6 30".
|
||||||
|
match_time = re.search(
|
||||||
|
r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})(?:\s*(?:часов|часа|час))?"
|
||||||
|
r"(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?"
|
||||||
|
r"(?:\s+(утра|дня|вечера|ночи))?\b",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
if match_time:
|
||||||
|
h = int(match_time.group(1))
|
||||||
|
m = int(match_time.group(2)) if match_time.group(2) else 0
|
||||||
|
h = _apply_part_of_day(h, match_time.group(3))
|
||||||
|
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||||
|
return h, m
|
||||||
|
|
||||||
|
# Формат словами: "в семь утра", "будильник семь тридцать".
|
||||||
|
return _extract_alarm_time_words(text)
|
||||||
|
|
||||||
|
|
||||||
class AlarmClock:
|
class AlarmClock:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.alarms = []
|
self.alarms = []
|
||||||
@@ -229,7 +272,14 @@ class AlarmClock:
|
|||||||
return self.add_alarm_with_days(hour, minute, days=None)
|
return self.add_alarm_with_days(hour, minute, days=None)
|
||||||
|
|
||||||
def add_alarm_with_days(self, hour: int, minute: int, days=None):
|
def add_alarm_with_days(self, hour: int, minute: int, days=None):
|
||||||
"""Добавление нового будильника (или обновление существующего) с днями недели."""
|
"""
|
||||||
|
Добавление нового будильника (или обновление существующего) с днями недели.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"created" - создан новый будильник
|
||||||
|
"reactivated" - найден существующий неактивный, включён обратно
|
||||||
|
"already_active" - такой будильник уже активен
|
||||||
|
"""
|
||||||
days_key = self._days_key(days)
|
days_key = self._days_key(days)
|
||||||
for alarm in self.alarms:
|
for alarm in self.alarms:
|
||||||
if (
|
if (
|
||||||
@@ -237,11 +287,13 @@ class AlarmClock:
|
|||||||
and alarm.get("minute") == minute
|
and alarm.get("minute") == minute
|
||||||
and self._days_key(alarm.get("days")) == days_key
|
and self._days_key(alarm.get("days")) == days_key
|
||||||
):
|
):
|
||||||
|
if alarm.get("active"):
|
||||||
|
return "already_active"
|
||||||
alarm["active"] = True
|
alarm["active"] = True
|
||||||
alarm["days"] = days_key
|
alarm["days"] = days_key
|
||||||
alarm["last_triggered"] = None
|
alarm["last_triggered"] = None
|
||||||
self.save_alarms()
|
self.save_alarms()
|
||||||
return
|
return "reactivated"
|
||||||
|
|
||||||
self.alarms.append(
|
self.alarms.append(
|
||||||
{"hour": hour, "minute": minute, "active": True, "days": days_key}
|
{"hour": hour, "minute": minute, "active": True, "days": days_key}
|
||||||
@@ -250,6 +302,7 @@ class AlarmClock:
|
|||||||
days_phrase = self._format_days_phrase(days_key)
|
days_phrase = self._format_days_phrase(days_key)
|
||||||
suffix = f" {days_phrase}" if days_phrase else ""
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}{suffix}")
|
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}{suffix}")
|
||||||
|
return "created"
|
||||||
|
|
||||||
def cancel_all_alarms(self):
|
def cancel_all_alarms(self):
|
||||||
"""Выключение (деактивация) всех будильников."""
|
"""Выключение (деактивация) всех будильников."""
|
||||||
@@ -258,6 +311,33 @@ class AlarmClock:
|
|||||||
self.save_alarms()
|
self.save_alarms()
|
||||||
print("🔕 Все будильники отменены.")
|
print("🔕 Все будильники отменены.")
|
||||||
|
|
||||||
|
def remove_alarms(self, hour: int, minute: int, days=None) -> int:
|
||||||
|
"""
|
||||||
|
Удаляет будильники по времени.
|
||||||
|
Если переданы days, удаляются только будильники с совпадающими днями.
|
||||||
|
"""
|
||||||
|
days_key = self._days_key(days)
|
||||||
|
kept = []
|
||||||
|
removed = 0
|
||||||
|
|
||||||
|
for alarm in self.alarms:
|
||||||
|
alarm_hour = alarm.get("hour")
|
||||||
|
alarm_minute = alarm.get("minute")
|
||||||
|
if alarm_hour != hour or alarm_minute != minute:
|
||||||
|
kept.append(alarm)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if days_key is not None and self._days_key(alarm.get("days")) != days_key:
|
||||||
|
kept.append(alarm)
|
||||||
|
continue
|
||||||
|
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
self.alarms = kept
|
||||||
|
self.save_alarms()
|
||||||
|
return removed
|
||||||
|
|
||||||
def describe_alarms(self) -> str:
|
def describe_alarms(self) -> str:
|
||||||
"""Возвращает текстовое описание активных будильников."""
|
"""Возвращает текстовое описание активных будильников."""
|
||||||
active = [
|
active = [
|
||||||
@@ -365,73 +445,60 @@ class AlarmClock:
|
|||||||
|
|
||||||
def parse_command(self, text: str) -> str | None:
|
def parse_command(self, text: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Парсинг команды установки будильника из текста.
|
Парсинг команд управления будильниками.
|
||||||
Примеры: "разбуди в 7:30", "будильник на 8 утра".
|
Примеры: "разбуди в 7:30", "удали будильник на 8:00", "какие будильники".
|
||||||
"""
|
"""
|
||||||
text = replace_roman_numerals(text.lower())
|
text = replace_roman_numerals(text.lower().replace("ё", "е"))
|
||||||
if "будильник" not in text and "разбуди" not in text:
|
if not re.search(r"\b(будильник\w*|разбуд\w*)\b", text):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if "будильник" in text and re.search(
|
if _ALARM_LIST_RE.search(text):
|
||||||
r"(какие|какой|список|активн|покажи|сколько|есть ли)", text
|
|
||||||
):
|
|
||||||
return self.describe_alarms()
|
return self.describe_alarms()
|
||||||
|
|
||||||
if "отмени" in text:
|
if _ALARM_CANCEL_RE.search(text):
|
||||||
self.cancel_all_alarms()
|
cancel_time = _extract_alarm_time(text)
|
||||||
return "Хорошо, я отменил все будильники."
|
cancel_days = self._extract_alarm_days(text)
|
||||||
|
if cancel_time:
|
||||||
|
h, m = cancel_time
|
||||||
|
removed = self.remove_alarms(h, m, days=cancel_days)
|
||||||
|
if removed:
|
||||||
|
days_phrase = self._format_days_phrase(cancel_days)
|
||||||
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
|
return f"Удалил {removed} будильник(а) на {h:02d}:{m:02d}{suffix}."
|
||||||
|
return f"Не нашел будильник на {h:02d}:{m:02d}."
|
||||||
|
|
||||||
|
if re.search(r"\b(все|всех)\b", text) or "будильники" in text:
|
||||||
|
self.cancel_all_alarms()
|
||||||
|
return "Хорошо, я отменил все будильники."
|
||||||
|
|
||||||
|
return (
|
||||||
|
"Скажите время будильника, который нужно удалить. "
|
||||||
|
"Например: удалите будильник на 7:30."
|
||||||
|
)
|
||||||
|
|
||||||
days = self._extract_alarm_days(text)
|
days = self._extract_alarm_days(text)
|
||||||
|
alarm_time = _extract_alarm_time(text)
|
||||||
# Поиск формата "7:30", "7.30" и вариантов с "в/на/к".
|
if alarm_time:
|
||||||
match = re.search(r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})[:.-](\d{2})\b", text)
|
h, m = alarm_time
|
||||||
if match:
|
add_status = self.add_alarm_with_days(h, m, days=days)
|
||||||
h, m = int(match.group(1)), int(match.group(2))
|
if add_status == "already_active":
|
||||||
period_match = re.search(
|
return "Такой будильник уже установлен."
|
||||||
r"\b(?:на|в|во|к)?\s*" + re.escape(match.group(0).strip()) + r"\s+(утра|дня|вечера|ночи)\b",
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
part_of_day = period_match.group(1) if period_match else None
|
|
||||||
h = _apply_part_of_day(h, part_of_day)
|
|
||||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
|
||||||
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 утра", "на 7", "к 6 30"
|
|
||||||
match_time = re.search(
|
|
||||||
r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?(?:\s+(утра|дня|вечера|ночи))?\b",
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
|
|
||||||
if match_time:
|
|
||||||
h = int(match_time.group(1))
|
|
||||||
m = int(match_time.group(2)) if match_time.group(2) else 0
|
|
||||||
h = _apply_part_of_day(h, match_time.group(3))
|
|
||||||
|
|
||||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
|
||||||
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}."
|
|
||||||
|
|
||||||
# Поиск формата словами: "в семь утра", "будильник семь тридцать"
|
|
||||||
word_time = _extract_alarm_time_words(text)
|
|
||||||
if word_time:
|
|
||||||
h, m = word_time
|
|
||||||
self.add_alarm_with_days(h, m, days=days)
|
|
||||||
days_phrase = self._format_days_phrase(days)
|
days_phrase = self._format_days_phrase(days)
|
||||||
suffix = f" {days_phrase}" if days_phrase else ""
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
||||||
|
|
||||||
if re.search(r"(постав|установ|запусти|включи|разбуди)", text) or text.strip() in {
|
if _ALARM_CREATE_RE.search(text) or text.strip() in {
|
||||||
"будильник",
|
"будильник",
|
||||||
"поставь будильник",
|
"поставь будильник",
|
||||||
|
"создай будильник",
|
||||||
|
"добавь будильник",
|
||||||
}:
|
}:
|
||||||
return ASK_ALARM_TIME_PROMPT
|
return ASK_ALARM_TIME_PROMPT
|
||||||
|
|
||||||
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
|
return (
|
||||||
|
"Я не понял команду для будильника. "
|
||||||
|
"Скажите, например: поставь на 7:30, покажи будильники или удали будильник на 7:30."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Глобальный экземпляр
|
# Глобальный экземпляр
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from urllib.parse import urlencode
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from ..core.config import BASE_DIR, WAKE_WORD_ALIASES
|
from ..core.config import BASE_DIR, WAKE_WORD_ALIASES, WAKEWORD_MUSIC_DUCK_RATIO
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import spotipy
|
import spotipy
|
||||||
@@ -97,6 +97,8 @@ class SpotifyProvider:
|
|||||||
self.sp = None
|
self.sp = None
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
self._init_error = None
|
self._init_error = None
|
||||||
|
self._duck_prev_volume: Optional[int] = None
|
||||||
|
self._duck_active = False
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
def initialize(self) -> bool:
|
||||||
if self._initialized:
|
if self._initialized:
|
||||||
@@ -249,6 +251,71 @@ class SpotifyProvider:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"Spotify: {exc}"
|
return f"Spotify: {exc}"
|
||||||
|
|
||||||
|
def duck_volume(self, ratio: float) -> bool:
|
||||||
|
if self._duck_active:
|
||||||
|
return True
|
||||||
|
if not self._ensure_initialized():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
current = self.sp.current_playback()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not current or not current.get("is_playing"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
device = current.get("device") or {}
|
||||||
|
device_id = device.get("id")
|
||||||
|
volume_percent = device.get("volume_percent")
|
||||||
|
if volume_percent is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_volume = int(volume_percent)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
target_volume = int(round(current_volume * float(ratio)))
|
||||||
|
target_volume = max(0, min(100, target_volume))
|
||||||
|
if target_volume >= current_volume:
|
||||||
|
target_volume = max(0, current_volume - 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.sp.volume(target_volume, device_id=device_id)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._duck_prev_volume = current_volume
|
||||||
|
self._duck_active = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def restore_duck_volume(self) -> bool:
|
||||||
|
if not self._duck_active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
previous_volume = self._duck_prev_volume
|
||||||
|
self._duck_prev_volume = None
|
||||||
|
self._duck_active = False
|
||||||
|
|
||||||
|
if previous_volume is None:
|
||||||
|
return False
|
||||||
|
if not self._ensure_initialized():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
current = self.sp.current_playback() or {}
|
||||||
|
device = current.get("device") or {}
|
||||||
|
device_id = device.get("id")
|
||||||
|
self.sp.volume(int(max(0, min(100, previous_volume))), device_id=device_id)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_duck_state(self) -> None:
|
||||||
|
self._duck_prev_volume = None
|
||||||
|
self._duck_active = False
|
||||||
|
|
||||||
|
|
||||||
class NavidromeProvider:
|
class NavidromeProvider:
|
||||||
"""Primary provider using Navidrome + MPV IPC."""
|
"""Primary provider using Navidrome + MPV IPC."""
|
||||||
@@ -269,6 +336,8 @@ class NavidromeProvider:
|
|||||||
self._folder_index_built_at = 0.0
|
self._folder_index_built_at = 0.0
|
||||||
self._autonext_lock = threading.Lock()
|
self._autonext_lock = threading.Lock()
|
||||||
self._autonext_suppress_until = 0.0
|
self._autonext_suppress_until = 0.0
|
||||||
|
self._duck_prev_volume: Optional[float] = None
|
||||||
|
self._duck_active = False
|
||||||
|
|
||||||
self._snapshot_stop = threading.Event()
|
self._snapshot_stop = threading.Event()
|
||||||
self._snapshot_thread = threading.Thread(
|
self._snapshot_thread = threading.Thread(
|
||||||
@@ -764,6 +833,53 @@ class NavidromeProvider:
|
|||||||
|
|
||||||
self._set_state(paused=paused, position_sec=max(0.0, position))
|
self._set_state(paused=paused, position_sec=max(0.0, position))
|
||||||
|
|
||||||
|
def duck_volume(self, ratio: float) -> bool:
|
||||||
|
if self._duck_active:
|
||||||
|
return True
|
||||||
|
if not self._is_mpv_alive():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
paused = bool(self._mpv_ipc(["get_property", "pause"]))
|
||||||
|
if paused:
|
||||||
|
return False
|
||||||
|
current_volume = float(self._mpv_ipc(["get_property", "volume"]) or 100.0)
|
||||||
|
target_volume = max(0.0, min(100.0, current_volume * float(ratio)))
|
||||||
|
if target_volume >= current_volume:
|
||||||
|
target_volume = max(0.0, current_volume - 1.0)
|
||||||
|
self._mpv_ipc(["set_property", "volume", target_volume])
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._duck_prev_volume = current_volume
|
||||||
|
self._duck_active = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def restore_duck_volume(self) -> bool:
|
||||||
|
if not self._duck_active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
previous_volume = self._duck_prev_volume
|
||||||
|
self._duck_prev_volume = None
|
||||||
|
self._duck_active = False
|
||||||
|
|
||||||
|
if previous_volume is None:
|
||||||
|
return False
|
||||||
|
if not self._is_mpv_alive():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._mpv_ipc(
|
||||||
|
["set_property", "volume", max(0.0, min(100.0, float(previous_volume)))]
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_duck_state(self) -> None:
|
||||||
|
self._duck_prev_volume = None
|
||||||
|
self._duck_active = False
|
||||||
|
|
||||||
def _snapshot_loop(self) -> None:
|
def _snapshot_loop(self) -> None:
|
||||||
while not self._snapshot_stop.wait(1.0):
|
while not self._snapshot_stop.wait(1.0):
|
||||||
try:
|
try:
|
||||||
@@ -793,6 +909,7 @@ class NavidromeProvider:
|
|||||||
self._mpv_process = None
|
self._mpv_process = None
|
||||||
|
|
||||||
self._remove_stale_socket()
|
self._remove_stale_socket()
|
||||||
|
self.clear_duck_state()
|
||||||
|
|
||||||
def _start_song(self, song: Song, start_sec: float = 0.0) -> None:
|
def _start_song(self, song: Song, start_sec: float = 0.0) -> None:
|
||||||
self._ensure_initialized()
|
self._ensure_initialized()
|
||||||
@@ -1024,6 +1141,8 @@ class MusicController:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.navidrome = NavidromeProvider()
|
self.navidrome = NavidromeProvider()
|
||||||
self.spotify = SpotifyProvider()
|
self.spotify = SpotifyProvider()
|
||||||
|
self._wakeword_duck_ratio = max(0.01, min(1.0, float(WAKEWORD_MUSIC_DUCK_RATIO)))
|
||||||
|
self._duck_active_provider: Optional[str] = None
|
||||||
aliases = sorted(
|
aliases = sorted(
|
||||||
{
|
{
|
||||||
alias.lower().replace("ё", "е").strip()
|
alias.lower().replace("ё", "е").strip()
|
||||||
@@ -1058,6 +1177,41 @@ class MusicController:
|
|||||||
f" (Причина: {exc})"
|
f" (Причина: {exc})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _play_default(self) -> str:
|
||||||
|
"""Default voice command ("включи музыку") with provider fallback."""
|
||||||
|
return self._with_fallback(
|
||||||
|
lambda: self.navidrome.play_random(contextual_resume=True),
|
||||||
|
lambda: self.spotify.play_music(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def duck_for_wakeword(self) -> bool:
|
||||||
|
if self._duck_active_provider in {"navidrome", "spotify"}:
|
||||||
|
return True
|
||||||
|
if self._wakeword_duck_ratio >= 1.0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.navidrome.duck_volume(self._wakeword_duck_ratio):
|
||||||
|
self._duck_active_provider = "navidrome"
|
||||||
|
return True
|
||||||
|
if self.spotify.duck_volume(self._wakeword_duck_ratio):
|
||||||
|
self._duck_active_provider = "spotify"
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def restore_after_wakeword(self) -> bool:
|
||||||
|
provider = self._duck_active_provider
|
||||||
|
self._duck_active_provider = None
|
||||||
|
|
||||||
|
if provider == "navidrome":
|
||||||
|
return self.navidrome.restore_duck_volume()
|
||||||
|
if provider == "spotify":
|
||||||
|
return self.spotify.restore_duck_volume()
|
||||||
|
|
||||||
|
# Защитная очистка на случай рассинхронизации состояния.
|
||||||
|
self.navidrome.clear_duck_state()
|
||||||
|
self.spotify.clear_duck_state()
|
||||||
|
return False
|
||||||
|
|
||||||
def pause_for_stop_word(self) -> Optional[str]:
|
def pause_for_stop_word(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Pause music for generic stop-words ("стоп", "хватит", etc).
|
Pause music for generic stop-words ("стоп", "хватит", etc).
|
||||||
@@ -1187,10 +1341,7 @@ class MusicController:
|
|||||||
lambda: self.navidrome.play_query(normalized_query),
|
lambda: self.navidrome.play_query(normalized_query),
|
||||||
lambda: self.spotify.play_music(normalized_query),
|
lambda: self.spotify.play_music(normalized_query),
|
||||||
)
|
)
|
||||||
return self._with_fallback(
|
return self._play_default()
|
||||||
lambda: self.navidrome.play_random(contextual_resume=True),
|
|
||||||
lambda: self.spotify.play_music(None),
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized_action in {"play_query", "search"}:
|
if normalized_action in {"play_query", "search"}:
|
||||||
if not normalized_query:
|
if not normalized_query:
|
||||||
@@ -1286,10 +1437,7 @@ class MusicController:
|
|||||||
lambda: self.navidrome.play_query(play_query),
|
lambda: self.navidrome.play_query(play_query),
|
||||||
lambda: self.spotify.play_music(play_query),
|
lambda: self.spotify.play_music(play_query),
|
||||||
)
|
)
|
||||||
return self._with_fallback(
|
return self._play_default()
|
||||||
lambda: self.navidrome.play_random(contextual_resume=True),
|
|
||||||
lambda: self.spotify.play_music(None),
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,120 @@ Weather feature module.
|
|||||||
Fetches weather data from Open-Meteo API.
|
Fetches weather data from Open-Meteo API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from ..core.config import WEATHER_LAT, WEATHER_LON, WEATHER_CITY
|
from ..core.config import WEATHER_LAT, WEATHER_LON, WEATHER_CITY
|
||||||
|
|
||||||
_HTTP = requests.Session()
|
_HTTP = requests.Session()
|
||||||
|
_CITY_PREFIX_RE = re.compile(
|
||||||
|
r"^(?:в|во)\s+(?:город(?:е|у)?\s+)?",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_CITY_SPACING_RE = re.compile(r"\s+")
|
||||||
|
_KNOWN_CITY_VARIATIONS = {
|
||||||
|
"нью йорк": "Нью-Йорк",
|
||||||
|
"нью-йорк": "Нью-Йорк",
|
||||||
|
"нью йорке": "Нью-Йорк",
|
||||||
|
"нью-йорке": "Нью-Йорк",
|
||||||
|
"нью йорка": "Нью-Йорк",
|
||||||
|
"нью-йорка": "Нью-Йорк",
|
||||||
|
"нью йорком": "Нью-Йорк",
|
||||||
|
"нью-йорком": "Нью-Йорк",
|
||||||
|
"санкт петербург": "Санкт-Петербург",
|
||||||
|
"санкт-петербург": "Санкт-Петербург",
|
||||||
|
"санкт петербурге": "Санкт-Петербург",
|
||||||
|
"санкт-петербурге": "Санкт-Петербург",
|
||||||
|
"санкт петербурга": "Санкт-Петербург",
|
||||||
|
"санкт-петербурга": "Санкт-Петербург",
|
||||||
|
"санкт петербургом": "Санкт-Петербург",
|
||||||
|
"санкт-петербургом": "Санкт-Петербург",
|
||||||
|
"нижний новгород": "Нижний Новгород",
|
||||||
|
"нижнем новгороде": "Нижний Новгород",
|
||||||
|
"нижнего новгорода": "Нижний Новгород",
|
||||||
|
"ростов на дону": "Ростов-на-Дону",
|
||||||
|
"ростове на дону": "Ростов-на-Дону",
|
||||||
|
"ростова на дону": "Ростов-на-Дону",
|
||||||
|
"лос анджелес": "Лос-Анджелес",
|
||||||
|
"лос-анджелес": "Лос-Анджелес",
|
||||||
|
"лос анджелесе": "Лос-Анджелес",
|
||||||
|
"лос-анджелесе": "Лос-Анджелес",
|
||||||
|
"сан франциско": "Сан-Франциско",
|
||||||
|
"сан-франциско": "Сан-Франциско",
|
||||||
|
"улан удэ": "Улан-Удэ",
|
||||||
|
"улан-удэ": "Улан-Удэ",
|
||||||
|
}
|
||||||
|
_SINGLE_WORD_CITY_VARIATIONS = {
|
||||||
|
"москве": "Москва",
|
||||||
|
"москвы": "Москва",
|
||||||
|
"москвой": "Москва",
|
||||||
|
"москву": "Москва",
|
||||||
|
"лондоне": "Лондон",
|
||||||
|
"лондона": "Лондон",
|
||||||
|
"лондоном": "Лондон",
|
||||||
|
"париже": "Париж",
|
||||||
|
"парижа": "Париж",
|
||||||
|
"парижем": "Париж",
|
||||||
|
"берлине": "Берлин",
|
||||||
|
"берлина": "Берлин",
|
||||||
|
"берлином": "Берлин",
|
||||||
|
"пекине": "Пекин",
|
||||||
|
"пекина": "Пекин",
|
||||||
|
"пекином": "Пекин",
|
||||||
|
"роме": "Рим",
|
||||||
|
"рима": "Рим",
|
||||||
|
"римом": "Рим",
|
||||||
|
"мадриде": "Мадрид",
|
||||||
|
"мадрида": "Мадрид",
|
||||||
|
"мадридом": "Мадрид",
|
||||||
|
"сиднее": "Сидней",
|
||||||
|
"сиднея": "Сидней",
|
||||||
|
"сиднеем": "Сидней",
|
||||||
|
"вашингтоне": "Вашингтон",
|
||||||
|
"вашингтона": "Вашингтон",
|
||||||
|
"вашингтоном": "Вашингтон",
|
||||||
|
"сиэтле": "Сиэтл",
|
||||||
|
"сиэтла": "Сиэтл",
|
||||||
|
"сиэтлом": "Сиэтл",
|
||||||
|
"бостоне": "Бостон",
|
||||||
|
"бостона": "Бостон",
|
||||||
|
"бостоном": "Бостон",
|
||||||
|
"денвере": "Денвер",
|
||||||
|
"денвера": "Денвер",
|
||||||
|
"денвером": "Денвер",
|
||||||
|
"хьюстоне": "Хьюстон",
|
||||||
|
"хьюстона": "Хьюстон",
|
||||||
|
"хьюстоном": "Хьюстон",
|
||||||
|
"фениксе": "Феникс",
|
||||||
|
"феникса": "Феникс",
|
||||||
|
"фениксом": "Феникс",
|
||||||
|
"атланте": "Атланта",
|
||||||
|
"атланты": "Атланта",
|
||||||
|
"атлантой": "Атланта",
|
||||||
|
"портленде": "Портленд",
|
||||||
|
"портленда": "Портленд",
|
||||||
|
"портлендом": "Портленд",
|
||||||
|
"остине": "Остин",
|
||||||
|
"остина": "Остин",
|
||||||
|
"остином": "Остин",
|
||||||
|
"нэшвилле": "Нэшвилл",
|
||||||
|
"нэшвилла": "Нэшвилл",
|
||||||
|
"нэшвиллом": "Нэшвилл",
|
||||||
|
"токио": "Токио",
|
||||||
|
"торонто": "Торонто",
|
||||||
|
"чикаго": "Чикаго",
|
||||||
|
"майами": "Майами",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _smart_title_city(text: str) -> str:
|
||||||
|
parts = []
|
||||||
|
for word in text.split():
|
||||||
|
hyphen_parts = [part.capitalize() for part in word.split("-") if part]
|
||||||
|
parts.append("-".join(hyphen_parts))
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def get_wmo_description(code: int) -> str:
|
def get_wmo_description(code: int) -> str:
|
||||||
"""Decodes WMO weather code to Russian description."""
|
"""Decodes WMO weather code to Russian description."""
|
||||||
codes = {
|
codes = {
|
||||||
@@ -72,143 +181,45 @@ def normalize_city_name(city_name: str) -> str:
|
|||||||
Converts city names from various grammatical cases to the base form for geocoding.
|
Converts city names from various grammatical cases to the base form for geocoding.
|
||||||
Handles common Russian grammatical cases (падежи) for city names.
|
Handles common Russian grammatical cases (падежи) for city names.
|
||||||
"""
|
"""
|
||||||
# Convert to lowercase for comparison
|
lowered = str(city_name or "").lower().replace("ё", "е").strip()
|
||||||
lower_city = city_name.lower()
|
if not lowered:
|
||||||
|
return city_name
|
||||||
|
|
||||||
# Remove common Russian location descriptors that might be included by mistake
|
lowered = _CITY_PREFIX_RE.sub("", lowered)
|
||||||
# For example, if someone says "в городе Волгоград", the city_name might be "городе волгоград"
|
lowered = _CITY_SPACING_RE.sub(" ", lowered).strip(" -")
|
||||||
# So we want to extract just "волгоград"
|
if not lowered:
|
||||||
if 'городе' in lower_city:
|
return city_name
|
||||||
# Extract the part after "городе"
|
|
||||||
parts = lower_city.split('городе')
|
|
||||||
if len(parts) > 1:
|
|
||||||
lower_city = parts[1].strip()
|
|
||||||
elif 'город' in lower_city:
|
|
||||||
# Extract the part after "город"
|
|
||||||
parts = lower_city.split('город')
|
|
||||||
if len(parts) > 1:
|
|
||||||
lower_city = parts[1].strip()
|
|
||||||
|
|
||||||
# Common endings for different cases in Russian
|
exact_match = _KNOWN_CITY_VARIATIONS.get(lowered)
|
||||||
# Prepositional case endings (-е, -и, -у, etc.)
|
if exact_match:
|
||||||
prepositional_endings = ['е', 'и', 'у', 'о', 'й']
|
return exact_match
|
||||||
genitive_endings = ['а', 'я', 'ов', 'ев', 'ин', 'ын']
|
|
||||||
instrumental_endings = ['ом', 'ем', 'ой', 'ей']
|
|
||||||
|
|
||||||
# If the city ends with a prepositional ending, try removing it to get the base form
|
single_word_match = _SINGLE_WORD_CITY_VARIATIONS.get(lowered)
|
||||||
if lower_city.endswith(tuple(prepositional_endings)):
|
if single_word_match:
|
||||||
# Try to remove the ending and see if we get a valid base form
|
return single_word_match
|
||||||
base_form = lower_city
|
|
||||||
# Try removing 1-2 characters to get the base form
|
|
||||||
for i in range(2, 0, -1): # Try removing 2 chars, then 1 char
|
|
||||||
if len(base_form) > i:
|
|
||||||
potential_base = base_form[:-i]
|
|
||||||
# Check if the removed part is a common ending
|
|
||||||
if base_form[-i:] in ['ке', 'ме', 'не', 'ве', 'ге', 'де', 'те']:
|
|
||||||
base_form = potential_base
|
|
||||||
break
|
|
||||||
elif base_form[-1] in prepositional_endings:
|
|
||||||
base_form = base_form[:-1]
|
|
||||||
break
|
|
||||||
|
|
||||||
# Special handling for common patterns
|
spaced = lowered.replace("-", " ")
|
||||||
if base_form.endswith('йорке'): # "нью-йорке" -> "нью-йорк"
|
exact_match = _KNOWN_CITY_VARIATIONS.get(spaced)
|
||||||
base_form = base_form[:-1] + 'к'
|
if exact_match:
|
||||||
elif base_form.endswith('ске'): # "москве" -> "москва", "париже" -> "париж"
|
return exact_match
|
||||||
# This is more complex, but for "москве" -> "москва", "париже" -> "париж"
|
|
||||||
# We'll handle the most common cases
|
|
||||||
if base_form == 'москве':
|
|
||||||
base_form = 'москва'
|
|
||||||
elif base_form == 'париже':
|
|
||||||
base_form = 'париж'
|
|
||||||
elif base_form == 'лондоне':
|
|
||||||
base_form = 'лондон'
|
|
||||||
elif base_form == 'берлине':
|
|
||||||
base_form = 'берлин'
|
|
||||||
elif base_form == 'токио': # токио stays токио
|
|
||||||
base_form = 'токио'
|
|
||||||
else:
|
|
||||||
# General rule: replace -е with -а or -ь
|
|
||||||
if base_form.endswith('ске'):
|
|
||||||
base_form = base_form[:-1] + 'а'
|
|
||||||
elif base_form.endswith('ие'):
|
|
||||||
base_form = base_form[:-2] + 'ия'
|
|
||||||
|
|
||||||
# Capitalize appropriately
|
if " " not in spaced:
|
||||||
if base_form != lower_city:
|
for suffix, replacement in (
|
||||||
return base_form.capitalize()
|
("ом", ""),
|
||||||
|
("ем", ""),
|
||||||
|
("ой", "а"),
|
||||||
|
("ей", "а"),
|
||||||
|
("е", ""),
|
||||||
|
("у", "а"),
|
||||||
|
("ю", "я"),
|
||||||
|
):
|
||||||
|
if spaced.endswith(suffix) and len(spaced) > len(suffix) + 2:
|
||||||
|
candidate = spaced[: -len(suffix)] + replacement
|
||||||
|
mapped = _SINGLE_WORD_CITY_VARIATIONS.get(candidate)
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
|
||||||
# Dictionary mapping specific known variations
|
return _smart_title_city(lowered)
|
||||||
case_variations = {
|
|
||||||
"нью-йорке": "Нью-Йорк",
|
|
||||||
"нью-йорка": "Нью-Йорк",
|
|
||||||
"нью-йорком": "Нью-Йорк",
|
|
||||||
"москве": "Москва",
|
|
||||||
"москвы": "Москва",
|
|
||||||
"москвой": "Москва",
|
|
||||||
"москву": "Москва",
|
|
||||||
"лондоне": "Лондон",
|
|
||||||
"лондона": "Лондон",
|
|
||||||
"лондоном": "Лондон",
|
|
||||||
"париже": "Париж",
|
|
||||||
"парижа": "Париж",
|
|
||||||
"парижем": "Париж",
|
|
||||||
"берлине": "Берлин",
|
|
||||||
"берлина": "Берлин",
|
|
||||||
"берлином": "Берлин",
|
|
||||||
"пекине": "Пекин",
|
|
||||||
"пекина": "Пекин",
|
|
||||||
"пекином": "Пекин",
|
|
||||||
"роме": "Рим",
|
|
||||||
"рима": "Рим",
|
|
||||||
"римом": "Рим",
|
|
||||||
"мадриде": "Мадрид",
|
|
||||||
"мадрида": "Мадрид",
|
|
||||||
"мадридом": "Мадрид",
|
|
||||||
"сиднее": "Сидней",
|
|
||||||
"сиднея": "Сидней",
|
|
||||||
"сиднеем": "Сидней",
|
|
||||||
"вашингтоне": "Вашингтон",
|
|
||||||
"вашингтона": "Вашингтон",
|
|
||||||
"вашингтоном": "Вашингтон",
|
|
||||||
"лос-анджелесе": "Лос-Анджелес",
|
|
||||||
"лос-анджелеса": "Лос-Анджелес",
|
|
||||||
"лос-анджелесом": "Лос-Анджелес",
|
|
||||||
"сиэтле": "Сиэтл",
|
|
||||||
"сиэтла": "Сиэтл",
|
|
||||||
"сиэтлом": "Сиэтл",
|
|
||||||
"бостоне": "Бостон",
|
|
||||||
"бостона": "Бостон",
|
|
||||||
"бостоном": "Бостон",
|
|
||||||
"денвере": "Денвер",
|
|
||||||
"денвера": "Денвер",
|
|
||||||
"денвером": "Денвер",
|
|
||||||
"хьюстоне": "Хьюстон",
|
|
||||||
"хьюстона": "Хьюстон",
|
|
||||||
"хьюстоном": "Хьюстон",
|
|
||||||
"фениксе": "Феникс",
|
|
||||||
"феникса": "Феникс",
|
|
||||||
"фениксом": "Феникс",
|
|
||||||
"атланте": "Атланта",
|
|
||||||
"атланты": "Атланта",
|
|
||||||
"атлантой": "Атланта",
|
|
||||||
"портленде": "Портленд",
|
|
||||||
"портленда": "Портленд",
|
|
||||||
"портлендом": "Портленд",
|
|
||||||
"остине": "Остин",
|
|
||||||
"остина": "Остин",
|
|
||||||
"остином": "Остин",
|
|
||||||
"нэшвилле": "Нэшвилл",
|
|
||||||
"нэшвилла": "Нэшвилл",
|
|
||||||
"нэшвиллом": "Нэшвилл",
|
|
||||||
"сан-франциско": "Сан-Франциско",
|
|
||||||
"токио": "Токио",
|
|
||||||
"торонто": "Торонто",
|
|
||||||
"чикаго": "Чикаго",
|
|
||||||
"майами": "Майами",
|
|
||||||
}
|
|
||||||
|
|
||||||
return case_variations.get(lower_city, city_name)
|
|
||||||
|
|
||||||
def get_coordinates_by_city(city_name: str) -> tuple:
|
def get_coordinates_by_city(city_name: str) -> tuple:
|
||||||
"""
|
"""
|
||||||
@@ -220,8 +231,9 @@ def get_coordinates_by_city(city_name: str) -> tuple:
|
|||||||
|
|
||||||
# Add normalized version
|
# Add normalized version
|
||||||
normalized_city = normalize_city_name(city_name)
|
normalized_city = normalize_city_name(city_name)
|
||||||
if normalized_city != city_name:
|
if normalized_city and normalized_city not in try_names:
|
||||||
try_names.append(normalized_city)
|
try_names.append(normalized_city)
|
||||||
|
normalized_lower = str(normalized_city or city_name).lower().replace("ё", "е").strip()
|
||||||
|
|
||||||
# Also try with English version if it's a known translation
|
# Also try with English version if it's a known translation
|
||||||
city_to_eng = {
|
city_to_eng = {
|
||||||
@@ -334,8 +346,18 @@ def get_coordinates_by_city(city_name: str) -> tuple:
|
|||||||
}
|
}
|
||||||
|
|
||||||
eng_name = city_to_eng.get(city_name.lower())
|
eng_name = city_to_eng.get(city_name.lower())
|
||||||
if eng_name:
|
normalized_eng_name = city_to_eng.get(normalized_lower)
|
||||||
|
if eng_name and eng_name not in try_names:
|
||||||
try_names.append(eng_name)
|
try_names.append(eng_name)
|
||||||
|
if normalized_eng_name and normalized_eng_name not in try_names:
|
||||||
|
try_names.append(normalized_eng_name)
|
||||||
|
|
||||||
|
if normalized_city:
|
||||||
|
hyphen_variant = normalized_city.replace(" ", "-")
|
||||||
|
space_variant = normalized_city.replace("-", " ")
|
||||||
|
for variant in (hyphen_variant, space_variant):
|
||||||
|
if variant and variant not in try_names:
|
||||||
|
try_names.append(variant)
|
||||||
|
|
||||||
# Try each name in sequence
|
# Try each name in sequence
|
||||||
for name_to_try in try_names:
|
for name_to_try in try_names:
|
||||||
|
|||||||
787
app/main.py
787
app/main.py
File diff suppressed because it is too large
Load Diff
@@ -39,5 +39,53 @@
|
|||||||
"days": [
|
"days": [
|
||||||
1
|
1
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hour": 8,
|
||||||
|
"minute": 0,
|
||||||
|
"active": false,
|
||||||
|
"days": [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"last_triggered": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hour": 7,
|
||||||
|
"minute": 0,
|
||||||
|
"active": true,
|
||||||
|
"days": [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"last_triggered": "2026-04-07T07:00:00.445214"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hour": 7,
|
||||||
|
"minute": 0,
|
||||||
|
"active": false,
|
||||||
|
"days": [
|
||||||
|
5
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hour": 9,
|
||||||
|
"minute": 30,
|
||||||
|
"active": false,
|
||||||
|
"days": null,
|
||||||
|
"last_triggered": "2026-04-04T09:30:00.423048"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hour": 17,
|
||||||
|
"minute": 30,
|
||||||
|
"active": false,
|
||||||
|
"days": null,
|
||||||
|
"last_triggered": "2026-04-04T17:30:00.113480"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -4,8 +4,13 @@ set -euo pipefail
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
|
PYTHON_BIN="python3"
|
||||||
|
if [ -x "$ROOT/.venv/bin/python" ]; then
|
||||||
|
PYTHON_BIN="$ROOT/.venv/bin/python"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[qwen-check] Python syntax compile check"
|
echo "[qwen-check] Python syntax compile check"
|
||||||
python -m compileall app run.py >/dev/null
|
"$PYTHON_BIN" -m compileall app run.py >/dev/null
|
||||||
|
|
||||||
echo "[qwen-check] Optional ruff check"
|
echo "[qwen-check] Optional ruff check"
|
||||||
if command -v ruff >/dev/null 2>&1; then
|
if command -v ruff >/dev/null 2>&1; then
|
||||||
|
|||||||
Reference in New Issue
Block a user