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("ё", "е")))
|
||||||
|
|||||||
@@ -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,8 +318,7 @@ 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)
|
||||||
@@ -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,6 +535,36 @@ class SpeechRecognizer:
|
|||||||
and effective_detection_timeout > 0
|
and effective_detection_timeout > 0
|
||||||
and not stop_event.is_set()
|
and not stop_event.is_set()
|
||||||
):
|
):
|
||||||
|
# Важно: не считаем пользователя "молчаливым", пока WS-соединение
|
||||||
|
# с Deepgram еще не поднялось.
|
||||||
|
connect_ready_deadline = time.monotonic() + max(
|
||||||
|
effective_detection_timeout + 0.25,
|
||||||
|
DEEPGRAM_CONNECT_WAIT_SECONDS + 0.75,
|
||||||
|
)
|
||||||
|
while (
|
||||||
|
not stop_event.is_set()
|
||||||
|
and not connection_ready_event.is_set()
|
||||||
|
and time.monotonic() < connect_ready_deadline
|
||||||
|
):
|
||||||
|
if connection_failed_event.is_set():
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
if (
|
||||||
|
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()
|
||||||
|
|
||||||
|
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())
|
speech_wait_task = asyncio.create_task(speech_started_event.wait())
|
||||||
stop_wait_task = asyncio.create_task(stop_event.wait())
|
stop_wait_task = asyncio.create_task(stop_event.wait())
|
||||||
try:
|
try:
|
||||||
@@ -568,7 +582,7 @@ class SpeechRecognizer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not done:
|
if not done:
|
||||||
# Если за detection_timeout никто не начал говорить, выходим
|
# Если за detection_timeout после поднятия WS никто не начал говорить, выходим.
|
||||||
request_stop()
|
request_stop()
|
||||||
|
|
||||||
# 2. После старта речи завершаем только по тишине POST_SPEECH_SILENCE_TIMEOUT_SECONDS.
|
# 2. После старта речи завершаем только по тишине POST_SPEECH_SILENCE_TIMEOUT_SECONDS.
|
||||||
@@ -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()
|
||||||
|
|||||||
164
app/audio/tts.py
164
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,15 +72,6 @@ class TextToSpeech:
|
|||||||
if self.model_en:
|
if self.model_en:
|
||||||
return self.model_en
|
return self.model_en
|
||||||
print("📦 Загрузка модели Silero TTS (en)...")
|
print("📦 Загрузка модели Silero TTS (en)...")
|
||||||
try:
|
|
||||||
model, _ = torch.hub.load(
|
|
||||||
repo_or_dir="snakers4/silero-models",
|
|
||||||
model="silero_tts",
|
|
||||||
language="en",
|
|
||||||
speaker="v5_en",
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"⚠️ Не удалось загрузить v5_en, пробую v3_en: {exc}")
|
|
||||||
model, _ = torch.hub.load(
|
model, _ = torch.hub.load(
|
||||||
repo_or_dir="snakers4/silero-models",
|
repo_or_dir="snakers4/silero-models",
|
||||||
model="silero_tts",
|
model="silero_tts",
|
||||||
@@ -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,16 +394,22 @@ 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:
|
||||||
|
now = time.time()
|
||||||
|
if self._accept_porcupine_hit(pcm, now, during_tts=False):
|
||||||
print("✅ Wake word обнаружен!")
|
print("✅ Wake word обнаружен!")
|
||||||
# Важно: закрываем поток, чтобы освободить микрофон для STT (Deepgram)
|
# Важно: закрываем поток, чтобы освободить микрофон для STT (Deepgram)
|
||||||
self.stop_monitoring()
|
self.stop_monitoring()
|
||||||
return True
|
return True
|
||||||
|
if self._check_fallback_wakeword(pcm):
|
||||||
|
self.stop_monitoring()
|
||||||
|
return True
|
||||||
|
|
||||||
def check_wakeword_once(self) -> bool:
|
def check_wakeword_once(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -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):
|
||||||
|
cancel_time = _extract_alarm_time(text)
|
||||||
|
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()
|
self.cancel_all_alarms()
|
||||||
return "Хорошо, я отменил все будильники."
|
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)
|
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 _ALARM_CREATE_RE.search(text) or text.strip() in {
|
||||||
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)
|
|
||||||
suffix = f" {days_phrase}" if days_phrase else ""
|
|
||||||
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
|
||||||
|
|
||||||
if re.search(r"(постав|установ|запусти|включи|разбуди)", 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:
|
||||||
|
|||||||
669
app/main.py
669
app/main.py
@@ -6,6 +6,7 @@ import re
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -22,11 +23,12 @@ else:
|
|||||||
_MIXER_IMPORT_ERROR = None
|
_MIXER_IMPORT_ERROR = None
|
||||||
|
|
||||||
# Наши модули
|
# Наши модули
|
||||||
from .audio.sound_level import parse_volume_text, set_volume
|
from .audio.sound_level import is_volume_command, parse_volume_text, set_volume
|
||||||
from .audio.stt import cleanup as cleanup_stt
|
from .audio.stt import cleanup as cleanup_stt
|
||||||
from .audio.stt import get_recognizer, listen
|
from .audio.stt import get_recognizer, listen
|
||||||
from .audio.tts import initialize as init_tts
|
from .audio.tts import initialize as init_tts
|
||||||
from .audio.tts import speak
|
from .audio.tts import speak
|
||||||
|
from .audio.tts import was_interrupted as was_tts_interrupted
|
||||||
from .audio.wakeword import (
|
from .audio.wakeword import (
|
||||||
check_wakeword_once,
|
check_wakeword_once,
|
||||||
wait_for_wakeword,
|
wait_for_wakeword,
|
||||||
@@ -43,6 +45,7 @@ from .core.config import (
|
|||||||
STT_START_SOUND_PATH,
|
STT_START_SOUND_PATH,
|
||||||
STT_START_SOUND_VOLUME,
|
STT_START_SOUND_VOLUME,
|
||||||
WAKE_WORD,
|
WAKE_WORD,
|
||||||
|
WAKE_WORD_ALIASES,
|
||||||
)
|
)
|
||||||
from .core.cleaner import clean_response
|
from .core.cleaner import clean_response
|
||||||
from .core.commands import is_stop_command
|
from .core.commands import is_stop_command
|
||||||
@@ -89,6 +92,50 @@ _TRANSLATION_COMMANDS = [
|
|||||||
_TRANSLATION_COMMANDS_SORTED = sorted(
|
_TRANSLATION_COMMANDS_SORTED = sorted(
|
||||||
_TRANSLATION_COMMANDS, key=lambda item: len(item[0]), reverse=True
|
_TRANSLATION_COMMANDS, key=lambda item: len(item[0]), reverse=True
|
||||||
)
|
)
|
||||||
|
_TRANSLATION_QUERY_RULES = [
|
||||||
|
(
|
||||||
|
re.compile(
|
||||||
|
r"^как\s+перевод(?:ится|ить)\s+(.+?)\s+с\s+английского(?:\s+на\s+русский)?[?.!]*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
"en",
|
||||||
|
"ru",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
re.compile(
|
||||||
|
r"^как\s+перевод(?:ится|ить)\s+(.+?)\s+с\s+русского(?:\s+на\s+английский)?[?.!]*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
"ru",
|
||||||
|
"en",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
re.compile(
|
||||||
|
r"^как\s+будет\s+(.+?)\s+на\s+английском(?:\s+языке)?[?.!]*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
"ru",
|
||||||
|
"en",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
re.compile(
|
||||||
|
r"^как\s+будет\s+(.+?)\s+на\s+русском(?:\s+языке)?[?.!]*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
"en",
|
||||||
|
"ru",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
re.compile(r"^что\s+значит\s+(.+?)\s+по[-\s]?английски[?.!]*$", re.IGNORECASE),
|
||||||
|
"ru",
|
||||||
|
"en",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
re.compile(r"^что\s+значит\s+(.+?)\s+по[-\s]?русски[?.!]*$", re.IGNORECASE),
|
||||||
|
"en",
|
||||||
|
"ru",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
_REPEAT_PHRASES = {
|
_REPEAT_PHRASES = {
|
||||||
"еще раз",
|
"еще раз",
|
||||||
@@ -109,6 +156,8 @@ _REPEAT_PHRASES = {
|
|||||||
_WEATHER_TRIGGERS = (
|
_WEATHER_TRIGGERS = (
|
||||||
"погода",
|
"погода",
|
||||||
"погоду",
|
"погоду",
|
||||||
|
"погоде",
|
||||||
|
"по погоде",
|
||||||
"что на улице",
|
"что на улице",
|
||||||
"какая температура",
|
"какая температура",
|
||||||
"сколько градусов",
|
"сколько градусов",
|
||||||
@@ -117,6 +166,7 @@ _WEATHER_TRIGGERS = (
|
|||||||
"нужен ли зонт",
|
"нужен ли зонт",
|
||||||
"брать ли зонт",
|
"брать ли зонт",
|
||||||
"прогноз погоды",
|
"прогноз погоды",
|
||||||
|
"прогноз",
|
||||||
"че там на улице",
|
"че там на улице",
|
||||||
"что там на улице",
|
"что там на улице",
|
||||||
"как на улице",
|
"как на улице",
|
||||||
@@ -151,27 +201,64 @@ _CITY_INVALID_WORDS = {
|
|||||||
|
|
||||||
_CITY_PATTERNS = [
|
_CITY_PATTERNS = [
|
||||||
re.compile(
|
re.compile(
|
||||||
r"в\s+городе\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)",
|
r"\b(?:в|во)\s+город(?:е|у)?\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
re.compile(
|
re.compile(
|
||||||
r"в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)",
|
r"\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
re.compile(
|
re.compile(
|
||||||
r"погода\s+в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)",
|
r"\bпогода\s+\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
re.compile(
|
re.compile(
|
||||||
r"погода\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)\s+(?:какая|сейчас|там)",
|
r"\bпогода\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})\s+(?:какая|сейчас|там|сегодня|завтра)\b",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
re.compile(
|
re.compile(
|
||||||
r"(?:какая|как)\s+погода\s+в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)",
|
r"\b(?:какая|как)\s+(?:сейчас\s+)?погода\s+\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
re.compile(
|
||||||
|
r"\b(?:какой|какова)\s+прогноз(?:\s+погоды)?\s+\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_WEATHER_TRIGGER_PATTERNS = [
|
||||||
|
re.compile(r"\bпогод(?:а|у|е|ы|ой)\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bпрогноз(?:\s+погоды)?\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\b(?:что|как|че)\s+там\s+на\s+улице\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\b(?:какая\s+температура|сколько\s+градусов)\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\b(?:нужен|брать)\s+ли\s+зонт\b", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
|
||||||
|
_TIME_TRIGGER_PATTERNS = [
|
||||||
|
re.compile(r"\bкотор(ый|ого)\s+час\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bсколько\s+времени\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bкакое\s+сейчас\s+время\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bкоторый\s+сейчас\s+час\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bwhat\s+time\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bcurrent\s+time\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\btime\s+is\s+it\b", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
|
||||||
|
_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,
|
||||||
|
)
|
||||||
|
|
||||||
|
_CITY_TRAILING_STOP_WORDS = {
|
||||||
|
"сейчас",
|
||||||
|
"сегодня",
|
||||||
|
"завтра",
|
||||||
|
"там",
|
||||||
|
"теперь",
|
||||||
|
"вообще",
|
||||||
|
"пожалуйста",
|
||||||
|
}
|
||||||
|
|
||||||
_SEMANTIC_INTENT_MIN_CONFIDENCE = 0.55
|
_SEMANTIC_INTENT_MIN_CONFIDENCE = 0.55
|
||||||
_SEMANTIC_MUSIC_MIN_CONFIDENCE = 0.45
|
_SEMANTIC_MUSIC_MIN_CONFIDENCE = 0.45
|
||||||
_SEMANTIC_REPEAT_STOP_MIN_CONFIDENCE = 0.72
|
_SEMANTIC_REPEAT_STOP_MIN_CONFIDENCE = 0.72
|
||||||
@@ -194,8 +281,10 @@ def signal_handler(sig, frame):
|
|||||||
|
|
||||||
def parse_translation_request(text: str):
|
def parse_translation_request(text: str):
|
||||||
"""Проверяет, является ли фраза запросом на перевод."""
|
"""Проверяет, является ли фраза запросом на перевод."""
|
||||||
text_lower = text.lower().strip()
|
text = str(text or "").strip()
|
||||||
text_lower = text.lower().strip()
|
if not text:
|
||||||
|
return None
|
||||||
|
text_lower = text.lower()
|
||||||
# Список префиксов команд перевода и соответствующих направлений языков.
|
# Список префиксов команд перевода и соответствующих направлений языков.
|
||||||
# Важно: более длинные префиксы должны проверяться первыми (например,
|
# Важно: более длинные префиксы должны проверяться первыми (например,
|
||||||
# "переведи с русского на английский" не должен схватиться как "переведи с русского").
|
# "переведи с русского на английский" не должен схватиться как "переведи с русского").
|
||||||
@@ -209,7 +298,132 @@ def parse_translation_request(text: str):
|
|||||||
"target_lang": target_lang,
|
"target_lang": target_lang,
|
||||||
"text": rest,
|
"text": rest,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _clean_payload(raw: str) -> str:
|
||||||
|
return str(raw or "").strip().lstrip(" :—-").strip(" \"'“”«»")
|
||||||
|
|
||||||
|
for pattern, source_lang, target_lang in _TRANSLATION_QUERY_RULES:
|
||||||
|
match = pattern.match(text)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
payload = _clean_payload(match.group(1))
|
||||||
|
if not payload:
|
||||||
return None
|
return None
|
||||||
|
return {
|
||||||
|
"source_lang": source_lang,
|
||||||
|
"target_lang": target_lang,
|
||||||
|
"text": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Универсальный fallback: "переведи <текст>" / "translate <text>".
|
||||||
|
generic_match = re.match(
|
||||||
|
r"^(?:переведи|перевод|translate)\s+(.+)$",
|
||||||
|
text,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if generic_match:
|
||||||
|
payload = _clean_payload(generic_match.group(1))
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Явное направление, если есть.
|
||||||
|
if re.search(r"\b(?:на|в|по)\s+англий", text_lower):
|
||||||
|
source_lang, target_lang = "ru", "en"
|
||||||
|
payload = re.sub(
|
||||||
|
r"\s+(?:на|в|по)\s+англий(?:ский|ском|ски)(?:\s+язык(?:е)?)?\s*$",
|
||||||
|
"",
|
||||||
|
payload,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
).strip()
|
||||||
|
elif re.search(r"\b(?:на|в|по)\s+рус", text_lower):
|
||||||
|
source_lang, target_lang = "en", "ru"
|
||||||
|
payload = re.sub(
|
||||||
|
r"\s+(?:на|в|по)\s+рус(?:ский|ском|ски)(?:\s+язык(?:е)?)?\s*$",
|
||||||
|
"",
|
||||||
|
payload,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
).strip()
|
||||||
|
else:
|
||||||
|
has_cyrillic = bool(re.search(r"[а-яё]", payload, flags=re.IGNORECASE))
|
||||||
|
has_latin = bool(re.search(r"[a-z]", payload, flags=re.IGNORECASE))
|
||||||
|
if has_latin and not has_cyrillic:
|
||||||
|
source_lang, target_lang = "en", "ru"
|
||||||
|
elif has_cyrillic and not has_latin:
|
||||||
|
source_lang, target_lang = "ru", "en"
|
||||||
|
elif has_latin and has_cyrillic:
|
||||||
|
source_lang, target_lang = "en", "ru"
|
||||||
|
else:
|
||||||
|
source_lang, target_lang = "ru", "en"
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source_lang": source_lang,
|
||||||
|
"target_lang": target_lang,
|
||||||
|
"text": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_wakeword_prefix(text: str) -> str:
|
||||||
|
normalized = str(text or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
return _WAKEWORD_PREFIX_RE.sub("", normalized, count=1).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_requested_city(text: str) -> str | None:
|
||||||
|
lowered = _strip_wakeword_prefix(text).lower().replace("ё", "е")
|
||||||
|
lowered = re.sub(r"[.!?,;:…]+", " ", lowered)
|
||||||
|
lowered = re.sub(r"\s+", " ", lowered).strip()
|
||||||
|
if not lowered:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for pattern in _CITY_PATTERNS:
|
||||||
|
match = pattern.search(lowered)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate = match.group(1).strip(" -")
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = [part for part in candidate.split() if part]
|
||||||
|
while parts and parts[-1] in _CITY_TRAILING_STOP_WORDS:
|
||||||
|
parts.pop()
|
||||||
|
while parts and parts[0] in _CITY_INVALID_WORDS:
|
||||||
|
parts.pop(0)
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
if any(part in _CITY_INVALID_WORDS for part in parts):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cleaned_candidate = " ".join(parts)
|
||||||
|
if len(cleaned_candidate) <= 1:
|
||||||
|
continue
|
||||||
|
return cleaned_candidate
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_time_request(text: str) -> bool:
|
||||||
|
cleaned = _strip_wakeword_prefix(text).strip()
|
||||||
|
if not cleaned:
|
||||||
|
return False
|
||||||
|
for pattern in _TIME_TRIGGER_PATTERNS:
|
||||||
|
if pattern.search(cleaned):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_weather_trigger(text: str) -> bool:
|
||||||
|
lowered = _strip_wakeword_prefix(text).lower().replace("ё", "е")
|
||||||
|
if any(trigger in lowered for trigger in _WEATHER_TRIGGERS):
|
||||||
|
return True
|
||||||
|
return any(pattern.search(lowered) for pattern in _WEATHER_TRIGGER_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -295,12 +509,129 @@ def main():
|
|||||||
pass
|
pass
|
||||||
return _play_stt_start_sfx_fallback()
|
return _play_stt_start_sfx_fallback()
|
||||||
|
|
||||||
|
text_mode = False
|
||||||
|
text_mode_reason = ""
|
||||||
|
response_wakeword_interrupted = False
|
||||||
|
play_followup_activation_sfx = False
|
||||||
|
output_interrupt_guard_seconds = 0.0
|
||||||
|
audio_settle_after_tts_seconds = 0.35
|
||||||
|
last_tts_finished_at = 0.0
|
||||||
|
wake_interrupt_hits_required = 1
|
||||||
|
wake_interrupt_hit_window_seconds = 0.22
|
||||||
|
|
||||||
|
try:
|
||||||
get_recognizer().initialize() # Подключение к Deepgram
|
get_recognizer().initialize() # Подключение к Deepgram
|
||||||
|
except Exception as exc:
|
||||||
|
# На некоторых системах PipeWire/Pulse доступны, но PortAudio не видит вход.
|
||||||
|
# Чтобы ассистент не падал, включаем текстовый режим через stdin.
|
||||||
|
lowered = str(exc).lower()
|
||||||
|
if "no input devices found" in lowered or "audio input initialization failed" in lowered:
|
||||||
|
text_mode = True
|
||||||
|
text_mode_reason = str(exc)
|
||||||
|
print("⚠️ Микрофон недоступен для PortAudio. Включен текстовый режим.")
|
||||||
|
print(f" Причина: {text_mode_reason}")
|
||||||
|
print(" Вводите команды в терминале (без wake word).")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def output_response(
|
||||||
|
text: str,
|
||||||
|
check_interrupt=None,
|
||||||
|
language: str = "ru",
|
||||||
|
allow_wake_interrupt: bool = True,
|
||||||
|
interrupt_guard_seconds: float | None = None,
|
||||||
|
) -> bool:
|
||||||
|
nonlocal response_wakeword_interrupted
|
||||||
|
nonlocal play_followup_activation_sfx
|
||||||
|
nonlocal last_tts_finished_at
|
||||||
|
if text_mode:
|
||||||
|
cleaned = clean_response(text, language=language)
|
||||||
|
if cleaned:
|
||||||
|
print(f"🤖 {cleaned}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if check_interrupt is not None:
|
||||||
|
effective_interrupt = check_interrupt
|
||||||
|
elif allow_wake_interrupt:
|
||||||
|
effective_interrupt = check_wakeword_once
|
||||||
|
else:
|
||||||
|
effective_interrupt = None
|
||||||
|
if effective_interrupt is None:
|
||||||
|
# Важно: None внутри TTS включает дефолтный wakeword-checker.
|
||||||
|
# Здесь нужно полностью отключить прерывания.
|
||||||
|
completed = speak(
|
||||||
|
text,
|
||||||
|
check_interrupt=lambda: False,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
last_tts_finished_at = time.monotonic()
|
||||||
|
return completed
|
||||||
|
|
||||||
|
guard_seconds = output_interrupt_guard_seconds
|
||||||
|
if interrupt_guard_seconds is not None:
|
||||||
|
try:
|
||||||
|
guard_seconds = max(0.0, float(interrupt_guard_seconds))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
guard_seconds = output_interrupt_guard_seconds
|
||||||
|
|
||||||
|
arm_interrupt_at = time.monotonic() + guard_seconds
|
||||||
|
wake_hits = 0
|
||||||
|
wake_last_hit_at = 0.0
|
||||||
|
|
||||||
|
def guarded_interrupt():
|
||||||
|
nonlocal wake_hits, wake_last_hit_at
|
||||||
|
if time.monotonic() < arm_interrupt_at:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
detected = bool(effective_interrupt())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not detected:
|
||||||
|
if (
|
||||||
|
wake_last_hit_at > 0
|
||||||
|
and time.monotonic() - wake_last_hit_at
|
||||||
|
> wake_interrupt_hit_window_seconds
|
||||||
|
):
|
||||||
|
wake_hits = 0
|
||||||
|
wake_last_hit_at = 0.0
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if (
|
||||||
|
wake_last_hit_at > 0
|
||||||
|
and now - wake_last_hit_at <= wake_interrupt_hit_window_seconds
|
||||||
|
):
|
||||||
|
wake_hits += 1
|
||||||
|
else:
|
||||||
|
wake_hits = 1
|
||||||
|
wake_last_hit_at = now
|
||||||
|
|
||||||
|
return wake_hits >= wake_interrupt_hits_required
|
||||||
|
|
||||||
|
completed = speak(text, check_interrupt=guarded_interrupt, language=language)
|
||||||
|
last_tts_finished_at = time.monotonic()
|
||||||
|
if not completed and was_tts_interrupted():
|
||||||
|
response_wakeword_interrupted = True
|
||||||
|
play_followup_activation_sfx = True
|
||||||
|
return completed
|
||||||
|
|
||||||
|
def settle_audio_after_tts() -> None:
|
||||||
|
nonlocal last_tts_finished_at
|
||||||
|
if last_tts_finished_at <= 0:
|
||||||
|
return
|
||||||
|
elapsed = time.monotonic() - last_tts_finished_at
|
||||||
|
remaining = audio_settle_after_tts_seconds - elapsed
|
||||||
|
if remaining > 0:
|
||||||
|
time.sleep(remaining)
|
||||||
|
|
||||||
init_tts() # Загрузка нейросети для синтеза речи (Silero)
|
init_tts() # Загрузка нейросети для синтеза речи (Silero)
|
||||||
alarm_clock = get_alarm_clock() # Загрузка будильников
|
alarm_clock = get_alarm_clock() # Загрузка будильников
|
||||||
stopwatch_manager = get_stopwatch_manager() # Загрузка секундомеров
|
stopwatch_manager = get_stopwatch_manager() # Загрузка секундомеров
|
||||||
timer_manager = get_timer_manager() # Загрузка таймеров
|
timer_manager = get_timer_manager() # Загрузка таймеров
|
||||||
cities_game = get_cities_game() # Игра "Города"
|
cities_game = get_cities_game() # Игра "Города"
|
||||||
|
music_controller = get_music_controller() # Контроллер музыки
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# История чата
|
# История чата
|
||||||
@@ -331,9 +662,18 @@ def main():
|
|||||||
last_stt_check = time.time()
|
last_stt_check = time.time()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ошибка при проверке STT: {e}")
|
print(f"Ошибка при проверке STT: {e}")
|
||||||
|
wakeword_music_ducked = False
|
||||||
try:
|
try:
|
||||||
# Освобождаем микрофон wake word
|
if text_mode:
|
||||||
stop_wakeword_monitoring()
|
try:
|
||||||
|
user_text = input("⌨️ Команда> ").strip()
|
||||||
|
except EOFError:
|
||||||
|
print("\nВвод закрыт, завершаю работу.")
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
signal_handler(None, None)
|
||||||
|
if not user_text:
|
||||||
|
continue
|
||||||
|
|
||||||
# Проверяем таймеры
|
# Проверяем таймеры
|
||||||
if timer_manager.check_timers():
|
if timer_manager.check_timers():
|
||||||
@@ -345,6 +685,12 @@ def main():
|
|||||||
skip_wakeword = False
|
skip_wakeword = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not text_mode:
|
||||||
|
if response_wakeword_interrupted:
|
||||||
|
skip_wakeword = True
|
||||||
|
response_wakeword_interrupted = False
|
||||||
|
wakeword_music_ducked = music_controller.duck_for_wakeword()
|
||||||
|
|
||||||
# Ждем wake word
|
# Ждем wake word
|
||||||
if not skip_wakeword:
|
if not skip_wakeword:
|
||||||
detected = wait_for_wakeword(timeout=0.5)
|
detected = wait_for_wakeword(timeout=0.5)
|
||||||
@@ -353,12 +699,20 @@ def main():
|
|||||||
if not detected:
|
if not detected:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
wakeword_music_ducked = music_controller.duck_for_wakeword()
|
||||||
|
|
||||||
# Звук активации
|
# Звук активации
|
||||||
play_stt_start_sfx()
|
play_stt_start_sfx()
|
||||||
|
|
||||||
# Слушаем команду
|
# Слушаем команду
|
||||||
try:
|
try:
|
||||||
user_text = listen(timeout_seconds=5.0, fast_stop=True)
|
stop_wakeword_monitoring()
|
||||||
|
settle_audio_after_tts()
|
||||||
|
user_text = listen(
|
||||||
|
timeout_seconds=5.0,
|
||||||
|
detection_timeout=7.0,
|
||||||
|
fast_stop=True,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ошибка при прослушивании: {e}")
|
print(f"Ошибка при прослушивании: {e}")
|
||||||
print("Переинициализация STT...")
|
print("Переинициализация STT...")
|
||||||
@@ -372,6 +726,12 @@ def main():
|
|||||||
# Follow-up режим — без wake word
|
# Follow-up режим — без wake word
|
||||||
print(f"👂 Слушаю ({followup_idle_timeout_seconds:.1f} сек)...")
|
print(f"👂 Слушаю ({followup_idle_timeout_seconds:.1f} сек)...")
|
||||||
try:
|
try:
|
||||||
|
stop_wakeword_monitoring()
|
||||||
|
settle_audio_after_tts()
|
||||||
|
# Тот же сигнал, что используется при wake word:
|
||||||
|
# теперь всегда перед стартом STT в продолжении диалога.
|
||||||
|
play_stt_start_sfx()
|
||||||
|
play_followup_activation_sfx = False
|
||||||
user_text = listen(
|
user_text = listen(
|
||||||
timeout_seconds=7.0,
|
timeout_seconds=7.0,
|
||||||
detection_timeout=followup_idle_timeout_seconds,
|
detection_timeout=followup_idle_timeout_seconds,
|
||||||
@@ -396,6 +756,13 @@ def main():
|
|||||||
|
|
||||||
# Анализ текста
|
# Анализ текста
|
||||||
if not user_text:
|
if not user_text:
|
||||||
|
if not text_mode:
|
||||||
|
output_response(
|
||||||
|
"Не расслышал, повторите, пожалуйста.",
|
||||||
|
allow_wake_interrupt=False,
|
||||||
|
)
|
||||||
|
skip_wakeword = True
|
||||||
|
else:
|
||||||
skip_wakeword = False
|
skip_wakeword = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -411,7 +778,7 @@ def main():
|
|||||||
clean_stopwatch_stop_response = clean_response(
|
clean_stopwatch_stop_response = clean_response(
|
||||||
stopwatch_stop_response, language="ru"
|
stopwatch_stop_response, language="ru"
|
||||||
)
|
)
|
||||||
speak(clean_stopwatch_stop_response)
|
output_response(clean_stopwatch_stop_response)
|
||||||
last_response = clean_stopwatch_stop_response
|
last_response = clean_stopwatch_stop_response
|
||||||
skip_wakeword = False
|
skip_wakeword = False
|
||||||
continue
|
continue
|
||||||
@@ -428,9 +795,9 @@ def main():
|
|||||||
):
|
):
|
||||||
if last_response:
|
if last_response:
|
||||||
print(f"🔁 Повторяю: {last_response}")
|
print(f"🔁 Повторяю: {last_response}")
|
||||||
speak(last_response)
|
output_response(last_response)
|
||||||
else:
|
else:
|
||||||
speak("Я еще ничего не говорил.")
|
output_response("Я еще ничего не говорил.")
|
||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -463,7 +830,7 @@ def main():
|
|||||||
clean_stopwatch_stop_response = clean_response(
|
clean_stopwatch_stop_response = clean_response(
|
||||||
stopwatch_stop_response, language="ru"
|
stopwatch_stop_response, language="ru"
|
||||||
)
|
)
|
||||||
speak(clean_stopwatch_stop_response)
|
output_response(clean_stopwatch_stop_response)
|
||||||
last_response = clean_stopwatch_stop_response
|
last_response = clean_stopwatch_stop_response
|
||||||
skip_wakeword = False
|
skip_wakeword = False
|
||||||
continue
|
continue
|
||||||
@@ -478,9 +845,9 @@ def main():
|
|||||||
):
|
):
|
||||||
if last_response:
|
if last_response:
|
||||||
print(f"🔁 Повторяю: {last_response}")
|
print(f"🔁 Повторяю: {last_response}")
|
||||||
speak(last_response)
|
output_response(last_response)
|
||||||
else:
|
else:
|
||||||
speak("Я еще ничего не говорил.")
|
output_response("Я еще ничего не говорил.")
|
||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -497,7 +864,7 @@ def main():
|
|||||||
clean_music_response = clean_response(
|
clean_music_response = clean_response(
|
||||||
semantic_music_response, language="ru"
|
semantic_music_response, language="ru"
|
||||||
)
|
)
|
||||||
speak(clean_music_response)
|
output_response(clean_music_response)
|
||||||
last_response = clean_music_response
|
last_response = clean_music_response
|
||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
continue
|
continue
|
||||||
@@ -523,12 +890,12 @@ def main():
|
|||||||
smalltalk_response = get_smalltalk_response(effective_text)
|
smalltalk_response = get_smalltalk_response(effective_text)
|
||||||
if smalltalk_response:
|
if smalltalk_response:
|
||||||
clean_smalltalk = clean_response(smalltalk_response, language="ru")
|
clean_smalltalk = clean_response(smalltalk_response, language="ru")
|
||||||
speak(clean_smalltalk)
|
output_response(clean_smalltalk)
|
||||||
last_response = clean_smalltalk
|
last_response = clean_smalltalk
|
||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
command_text = effective_text
|
command_text = _strip_wakeword_prefix(effective_text) or effective_text
|
||||||
command_text_lower = command_text.lower()
|
command_text_lower = command_text.lower()
|
||||||
if pending_time_target == "timer" and "таймер" not in command_text_lower:
|
if pending_time_target == "timer" and "таймер" not in command_text_lower:
|
||||||
command_text = f"таймер {command_text}"
|
command_text = f"таймер {command_text}"
|
||||||
@@ -538,6 +905,15 @@ def main():
|
|||||||
and "разбуди" not in command_text_lower
|
and "разбуди" not in command_text_lower
|
||||||
):
|
):
|
||||||
command_text = f"будильник {command_text}"
|
command_text = f"будильник {command_text}"
|
||||||
|
elif (
|
||||||
|
semantic_type == "alarm"
|
||||||
|
and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE
|
||||||
|
and re.search(r"\b(будильник\w*|разбуд\w*)\b", command_text_lower)
|
||||||
|
is None
|
||||||
|
):
|
||||||
|
# Для AI-нормализованных фраз без явного слова "будильник"
|
||||||
|
# добавляем маркер, чтобы гарантированно пройти в alarm parser.
|
||||||
|
command_text = f"будильник {command_text}"
|
||||||
|
|
||||||
# Таймеры
|
# Таймеры
|
||||||
stopwatch_response = stopwatch_manager.parse_command(command_text)
|
stopwatch_response = stopwatch_manager.parse_command(command_text)
|
||||||
@@ -545,7 +921,7 @@ def main():
|
|||||||
clean_stopwatch_response = clean_response(
|
clean_stopwatch_response = clean_response(
|
||||||
stopwatch_response, language="ru"
|
stopwatch_response, language="ru"
|
||||||
)
|
)
|
||||||
speak(clean_stopwatch_response)
|
output_response(clean_stopwatch_response)
|
||||||
last_response = clean_stopwatch_response
|
last_response = clean_stopwatch_response
|
||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
continue
|
continue
|
||||||
@@ -554,7 +930,7 @@ def main():
|
|||||||
timer_response = timer_manager.parse_command(command_text)
|
timer_response = timer_manager.parse_command(command_text)
|
||||||
if timer_response:
|
if timer_response:
|
||||||
clean_timer_response = clean_response(timer_response, language="ru")
|
clean_timer_response = clean_response(timer_response, language="ru")
|
||||||
completed = speak(
|
completed = output_response(
|
||||||
clean_timer_response, check_interrupt=check_wakeword_once
|
clean_timer_response, check_interrupt=check_wakeword_once
|
||||||
)
|
)
|
||||||
last_response = clean_timer_response
|
last_response = clean_timer_response
|
||||||
@@ -568,7 +944,7 @@ def main():
|
|||||||
alarm_response = alarm_clock.parse_command(command_text)
|
alarm_response = alarm_clock.parse_command(command_text)
|
||||||
if alarm_response:
|
if alarm_response:
|
||||||
clean_alarm_response = clean_response(alarm_response, language="ru")
|
clean_alarm_response = clean_response(alarm_response, language="ru")
|
||||||
speak(clean_alarm_response)
|
output_response(clean_alarm_response)
|
||||||
last_response = clean_alarm_response
|
last_response = clean_alarm_response
|
||||||
pending_time_target = (
|
pending_time_target = (
|
||||||
"alarm" if alarm_response == ASK_ALARM_TIME_PROMPT else None
|
"alarm" if alarm_response == ASK_ALARM_TIME_PROMPT else None
|
||||||
@@ -577,21 +953,23 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Громкость
|
# Громкость
|
||||||
if command_text.lower().startswith("громкость"):
|
if (
|
||||||
|
semantic_type == "volume"
|
||||||
|
and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE
|
||||||
|
) or is_volume_command(command_text):
|
||||||
try:
|
try:
|
||||||
vol_str = command_text.lower().replace("громкость", "", 1).strip()
|
level = parse_volume_text(command_text)
|
||||||
level = parse_volume_text(vol_str)
|
|
||||||
|
|
||||||
if level is not None:
|
if level is not None:
|
||||||
if set_volume(level):
|
if set_volume(level):
|
||||||
msg = f"Громкость установлена на {level}"
|
msg = f"Уровень громкости {level}"
|
||||||
clean_msg = clean_response(msg, language="ru")
|
clean_msg = clean_response(msg, language="ru")
|
||||||
speak(clean_msg)
|
output_response(clean_msg)
|
||||||
last_response = clean_msg
|
last_response = clean_msg
|
||||||
else:
|
else:
|
||||||
speak("Не удалось установить громкость.")
|
output_response("Не удалось установить громкость.")
|
||||||
else:
|
else:
|
||||||
speak(
|
output_response(
|
||||||
"Я не понял число громкости. Скажите число от одного до десяти."
|
"Я не понял число громкости. Скажите число от одного до десяти."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -599,54 +977,64 @@ def main():
|
|||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка громкости: {e}")
|
print(f"❌ Ошибка громкости: {e}")
|
||||||
speak("Не удалось изменить громкость.")
|
output_response("Не удалось изменить громкость.")
|
||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Погода
|
# Погода
|
||||||
requested_city = None
|
requested_city = _extract_requested_city(command_text)
|
||||||
user_text_lower = command_text.lower()
|
has_weather_trigger = _has_weather_trigger(command_text)
|
||||||
|
|
||||||
for pattern in _CITY_PATTERNS:
|
|
||||||
match = pattern.search(user_text_lower)
|
|
||||||
if match:
|
|
||||||
potential_city = match.group(1).strip()
|
|
||||||
if (
|
|
||||||
potential_city
|
|
||||||
and len(potential_city) > 1
|
|
||||||
and not any(
|
|
||||||
word in potential_city for word in _CITY_INVALID_WORDS
|
|
||||||
)
|
|
||||||
):
|
|
||||||
requested_city = potential_city.title()
|
|
||||||
break
|
|
||||||
|
|
||||||
has_weather_trigger = any(
|
|
||||||
trigger in user_text_lower for trigger in _WEATHER_TRIGGERS
|
|
||||||
)
|
|
||||||
|
|
||||||
if has_weather_trigger:
|
if has_weather_trigger:
|
||||||
from .features.weather import get_weather_report
|
from .features.weather import get_weather_report
|
||||||
|
|
||||||
weather_report = get_weather_report(requested_city)
|
weather_report = get_weather_report(requested_city)
|
||||||
clean_report = clean_response(weather_report, language="ru")
|
clean_report = clean_response(weather_report, language="ru")
|
||||||
speak(clean_report)
|
output_response(clean_report, interrupt_guard_seconds=0.0)
|
||||||
last_response = clean_report
|
last_response = clean_report
|
||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Время
|
||||||
|
if _is_time_request(command_text):
|
||||||
|
now = datetime.now()
|
||||||
|
time_response = f"Сейчас {now.strftime('%H:%M')}"
|
||||||
|
clean_time_response = clean_response(time_response, language="ru")
|
||||||
|
output_response(clean_time_response)
|
||||||
|
last_response = clean_time_response
|
||||||
|
skip_wakeword = True
|
||||||
|
continue
|
||||||
|
|
||||||
# Музыка
|
# Музыка
|
||||||
music_controller = get_music_controller()
|
music_controller = get_music_controller()
|
||||||
music_response = music_controller.parse_command(command_text)
|
music_response = music_controller.parse_command(command_text)
|
||||||
if music_response:
|
if music_response:
|
||||||
clean_music_response = clean_response(music_response, language="ru")
|
clean_music_response = clean_response(music_response, language="ru")
|
||||||
speak(clean_music_response)
|
output_response(clean_music_response)
|
||||||
last_response = clean_music_response
|
last_response = clean_music_response
|
||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Перевод
|
# Перевод
|
||||||
translation_request = parse_translation_request(command_text)
|
translation_request = parse_translation_request(command_text)
|
||||||
|
if (
|
||||||
|
not translation_request
|
||||||
|
and semantic_type == "translation"
|
||||||
|
and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE
|
||||||
|
):
|
||||||
|
# Fallback для AI-интента "translation", если нормализатор
|
||||||
|
# вернул неканоничную фразу без явного префикса "переведи".
|
||||||
|
translation_request = parse_translation_request(
|
||||||
|
_strip_wakeword_prefix(user_text)
|
||||||
|
)
|
||||||
|
if not translation_request and semantic_command:
|
||||||
|
translation_request = parse_translation_request(semantic_command)
|
||||||
|
if not translation_request:
|
||||||
|
fallback_payload = (semantic_command or command_text).strip()
|
||||||
|
if fallback_payload:
|
||||||
|
translation_request = parse_translation_request(
|
||||||
|
f"переведи {fallback_payload}"
|
||||||
|
)
|
||||||
if translation_request:
|
if translation_request:
|
||||||
source_lang = translation_request["source_lang"]
|
source_lang = translation_request["source_lang"]
|
||||||
target_lang = translation_request["target_lang"]
|
target_lang = translation_request["target_lang"]
|
||||||
@@ -659,10 +1047,17 @@ def main():
|
|||||||
if source_lang == "en"
|
if source_lang == "en"
|
||||||
else "Скажи фразу на русском."
|
else "Скажи фразу на русском."
|
||||||
)
|
)
|
||||||
speak(prompt)
|
output_response(prompt)
|
||||||
|
if text_mode:
|
||||||
|
text_to_translate = input("⌨️ Текст для перевода> ").strip()
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
|
stop_wakeword_monitoring()
|
||||||
|
settle_audio_after_tts()
|
||||||
text_to_translate = listen(
|
text_to_translate = listen(
|
||||||
timeout_seconds=7.0, detection_timeout=5.0, lang=source_lang
|
timeout_seconds=7.0,
|
||||||
|
detection_timeout=5.0,
|
||||||
|
lang=source_lang,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ошибка при прослушивании для перевода: {e}")
|
print(f"Ошибка при прослушивании для перевода: {e}")
|
||||||
@@ -672,12 +1067,12 @@ def main():
|
|||||||
get_recognizer().initialize()
|
get_recognizer().initialize()
|
||||||
except Exception as init_error:
|
except Exception as init_error:
|
||||||
print(f"Ошибка переинициализации STT: {init_error}")
|
print(f"Ошибка переинициализации STT: {init_error}")
|
||||||
speak("Произошла ошибка при распознавании речи.")
|
output_response("Произошла ошибка при распознавании речи.")
|
||||||
skip_wakeword = False
|
skip_wakeword = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not text_to_translate:
|
if not text_to_translate:
|
||||||
speak("Я не расслышал текст для перевода.")
|
output_response("Я не расслышал текст для перевода.")
|
||||||
skip_wakeword = False
|
skip_wakeword = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -690,9 +1085,9 @@ def main():
|
|||||||
last_response = clean_text
|
last_response = clean_text
|
||||||
|
|
||||||
# Озвучиваем
|
# Озвучиваем
|
||||||
completed = speak(
|
completed = output_response(
|
||||||
clean_text,
|
clean_text,
|
||||||
check_interrupt=check_wakeword_once,
|
check_interrupt=None,
|
||||||
language=target_lang,
|
language=target_lang,
|
||||||
)
|
)
|
||||||
stop_wakeword_monitoring()
|
stop_wakeword_monitoring()
|
||||||
@@ -706,7 +1101,7 @@ def main():
|
|||||||
cities_response = cities_game.handle(command_text)
|
cities_response = cities_game.handle(command_text)
|
||||||
if cities_response:
|
if cities_response:
|
||||||
clean_cities_response = clean_response(cities_response, language="ru")
|
clean_cities_response = clean_response(cities_response, language="ru")
|
||||||
speak(clean_cities_response)
|
output_response(clean_cities_response)
|
||||||
last_response = clean_cities_response
|
last_response = clean_cities_response
|
||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
continue
|
continue
|
||||||
@@ -717,41 +1112,81 @@ def main():
|
|||||||
full_response = ""
|
full_response = ""
|
||||||
interrupted = False
|
interrupted = False
|
||||||
|
|
||||||
# Streaming TTS: читаем SSE без блокировок, а озвучиваем в отдельном потоке по предложениям.
|
# Streaming TTS: читаем SSE без блокировок, а озвучиваем по 2 предложения.
|
||||||
tts_queue: "queue.Queue[str | None]" = queue.Queue()
|
tts_queue: "queue.Queue[str | None]" = queue.Queue()
|
||||||
|
ai_queue: "queue.Queue[str | None]" = queue.Queue()
|
||||||
stop_streaming_event = threading.Event()
|
stop_streaming_event = threading.Event()
|
||||||
|
ai_stream_done = threading.Event()
|
||||||
|
stream_generator_holder = {"generator": None}
|
||||||
|
|
||||||
def _split_speakable(text: str) -> tuple[str, str]:
|
def _find_sentence_boundaries(text: str) -> list[int]:
|
||||||
|
"""Ищет индексы концов предложений в тексте."""
|
||||||
|
boundaries = []
|
||||||
|
i = 0
|
||||||
|
while i < len(text):
|
||||||
|
ch = text[i]
|
||||||
|
|
||||||
|
# Перенос строки считаем безопасной границей фразы.
|
||||||
|
if ch == "\n":
|
||||||
|
boundaries.append(i)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch in ".!?":
|
||||||
|
# Не режем десятичные числа, например 3.14.
|
||||||
|
prev_is_digit = i > 0 and text[i - 1].isdigit()
|
||||||
|
next_is_digit = i + 1 < len(text) and text[i + 1].isdigit()
|
||||||
|
if ch == "." and prev_is_digit and next_is_digit:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
boundary = i
|
||||||
|
# Поглощаем подряд идущие знаки конца предложения (?!...)
|
||||||
|
while boundary + 1 < len(text) and text[boundary + 1] in ".!?":
|
||||||
|
boundary += 1
|
||||||
|
# И закрывающие кавычки/скобки.
|
||||||
|
while boundary + 1 < len(text) and text[
|
||||||
|
boundary + 1
|
||||||
|
] in "\"'”»)]}":
|
||||||
|
boundary += 1
|
||||||
|
|
||||||
|
boundaries.append(boundary)
|
||||||
|
i = boundary + 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return boundaries
|
||||||
|
|
||||||
|
def _split_speakable(text: str, force: bool = False) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Возвращает (готовое_для_озвучивания, остаток).
|
Возвращает (готовое_для_озвучивания, остаток).
|
||||||
Стараемся говорить по предложениям, но не режем слишком мелко.
|
Основной режим: по 2 предложения на фрагмент.
|
||||||
|
force=True: дожимаем хвост в конце стрима.
|
||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return "", ""
|
return "", ""
|
||||||
|
|
||||||
# Ждем хотя бы немного текста, чтобы не "пиликать" по 1-2 словам.
|
|
||||||
min_chars = 55
|
min_chars = 55
|
||||||
hard_flush_chars = 220
|
hard_flush_chars = 220
|
||||||
|
target_sentences = 2
|
||||||
|
|
||||||
if len(text) < min_chars and "\n" not in text:
|
# Не озвучиваем слишком короткие куски во время потока.
|
||||||
|
if not force and len(text) < min_chars and "\n" not in text:
|
||||||
return "", text
|
return "", text
|
||||||
|
|
||||||
# Находим границу предложения.
|
boundaries = _find_sentence_boundaries(text)
|
||||||
boundary = -1
|
boundary = -1
|
||||||
for i, ch in enumerate(text):
|
|
||||||
if ch == "\n":
|
|
||||||
boundary = i
|
|
||||||
elif ch in ".!?":
|
|
||||||
# Не режем 3.14 и похожие случаи.
|
|
||||||
prev_is_digit = i > 0 and text[i - 1].isdigit()
|
|
||||||
next_is_digit = i + 1 < len(text) and text[i + 1].isdigit()
|
|
||||||
if ch == "." and prev_is_digit and next_is_digit:
|
|
||||||
continue
|
|
||||||
boundary = i
|
|
||||||
|
|
||||||
if boundary == -1:
|
if len(boundaries) >= target_sentences:
|
||||||
if len(text) >= hard_flush_chars:
|
boundary = boundaries[target_sentences - 1]
|
||||||
boundary = hard_flush_chars - 1
|
elif len(boundaries) == 1 and (force or len(text) >= hard_flush_chars):
|
||||||
|
boundary = boundaries[0]
|
||||||
|
elif len(boundaries) == 0 and not force and len(text) >= hard_flush_chars:
|
||||||
|
split_idx = text.rfind(" ", 0, hard_flush_chars)
|
||||||
|
boundary = split_idx if split_idx > 0 else hard_flush_chars - 1
|
||||||
|
elif force:
|
||||||
|
boundary = len(text) - 1
|
||||||
else:
|
else:
|
||||||
return "", text
|
return "", text
|
||||||
|
|
||||||
@@ -772,9 +1207,8 @@ def main():
|
|||||||
if not clean_part.strip():
|
if not clean_part.strip():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ok = speak(
|
ok = output_response(
|
||||||
clean_part,
|
clean_part,
|
||||||
check_interrupt=check_wakeword_once,
|
|
||||||
language="ru",
|
language="ru",
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -791,16 +1225,49 @@ def main():
|
|||||||
tts_thread = threading.Thread(target=_tts_worker, daemon=True)
|
tts_thread = threading.Thread(target=_tts_worker, daemon=True)
|
||||||
tts_thread.start()
|
tts_thread.start()
|
||||||
|
|
||||||
|
def _ai_stream_worker():
|
||||||
|
generator = None
|
||||||
|
try:
|
||||||
|
generator = ask_ai_stream(list(chat_history))
|
||||||
|
stream_generator_holder["generator"] = generator
|
||||||
|
for chunk in generator:
|
||||||
|
if stop_streaming_event.is_set():
|
||||||
|
break
|
||||||
|
if chunk:
|
||||||
|
ai_queue.put(chunk)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"\n❌ Ошибка: {exc}")
|
||||||
|
if not stop_streaming_event.is_set():
|
||||||
|
ai_queue.put("Произошла ошибка при получении ответа.")
|
||||||
|
finally:
|
||||||
|
if generator is not None:
|
||||||
|
try:
|
||||||
|
generator.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ai_stream_done.set()
|
||||||
|
ai_queue.put(None)
|
||||||
|
|
||||||
|
ai_thread = threading.Thread(target=_ai_stream_worker, daemon=True)
|
||||||
|
ai_thread.start()
|
||||||
|
|
||||||
print("🤖 AI: ", end="", flush=True)
|
print("🤖 AI: ", end="", flush=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stream_generator = ask_ai_stream(list(chat_history))
|
|
||||||
buffer = ""
|
buffer = ""
|
||||||
for chunk in stream_generator:
|
while True:
|
||||||
if stop_streaming_event.is_set():
|
if stop_streaming_event.is_set():
|
||||||
break
|
break
|
||||||
if not chunk:
|
try:
|
||||||
|
chunk = ai_queue.get(timeout=0.1)
|
||||||
|
except queue.Empty:
|
||||||
|
if ai_stream_done.is_set():
|
||||||
|
break
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if chunk is None:
|
||||||
|
break
|
||||||
|
|
||||||
full_response += chunk
|
full_response += chunk
|
||||||
buffer += chunk
|
buffer += chunk
|
||||||
print(chunk, end="", flush=True)
|
print(chunk, end="", flush=True)
|
||||||
@@ -814,17 +1281,31 @@ def main():
|
|||||||
print(f"\n❌ Ошибка: {e}")
|
print(f"\n❌ Ошибка: {e}")
|
||||||
tts_queue.put("Произошла ошибка при получении ответа.")
|
tts_queue.put("Произошла ошибка при получении ответа.")
|
||||||
finally:
|
finally:
|
||||||
|
generator = stream_generator_holder.get("generator")
|
||||||
|
if interrupted:
|
||||||
|
stop_streaming_event.set()
|
||||||
|
if generator is not None and interrupted:
|
||||||
|
try:
|
||||||
|
generator.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Договорим остаток, если не было прерывания.
|
# Договорим остаток, если не было прерывания.
|
||||||
if not stop_streaming_event.is_set():
|
if not interrupted:
|
||||||
tail = buffer.strip()
|
while True:
|
||||||
if tail:
|
tail_part, buffer = _split_speakable(buffer, force=True)
|
||||||
tts_queue.put(tail)
|
if not tail_part:
|
||||||
|
break
|
||||||
|
tts_queue.put(tail_part)
|
||||||
tts_queue.put(None)
|
tts_queue.put(None)
|
||||||
tts_thread.join(timeout=20)
|
ai_thread.join(timeout=1.0)
|
||||||
|
# Для длинных ответов не обрываем ожидание по таймауту:
|
||||||
|
# дожидаемся полного завершения TTS worker.
|
||||||
|
tts_thread.join()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Сохраняем ответ
|
# Сохраняем только завершенный ответ, чтобы не засорять контекст обрезанным хвостом.
|
||||||
|
if full_response and not interrupted:
|
||||||
chat_history.append({"role": "assistant", "content": full_response})
|
chat_history.append({"role": "assistant", "content": full_response})
|
||||||
last_response = clean_response(full_response, language="ru")
|
last_response = clean_response(full_response, language="ru")
|
||||||
|
|
||||||
@@ -842,8 +1323,14 @@ def main():
|
|||||||
signal_handler(None, None)
|
signal_handler(None, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка: {e}")
|
print(f"❌ Ошибка: {e}")
|
||||||
speak("Произошла ошибка. Попробуйте ещё раз.")
|
output_response("Произошла ошибка. Попробуйте ещё раз.")
|
||||||
skip_wakeword = False
|
skip_wakeword = False
|
||||||
|
finally:
|
||||||
|
if wakeword_music_ducked:
|
||||||
|
try:
|
||||||
|
music_controller.restore_after_wakeword()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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