feat: refine assistant logic and update docs
This commit is contained in:
140
app/core/ai.py
140
app/core/ai.py
@@ -7,7 +7,12 @@ from typing import Optional
|
||||
import requests
|
||||
|
||||
from .config import (
|
||||
AI_CHAT_MAX_CHARS,
|
||||
AI_PROVIDER,
|
||||
AI_CHAT_MAX_TOKENS,
|
||||
AI_CHAT_TEMPERATURE,
|
||||
AI_INTENT_TEMPERATURE,
|
||||
AI_TRANSLATION_TEMPERATURE,
|
||||
ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_API_URL,
|
||||
ANTHROPIC_API_VERSION,
|
||||
@@ -31,15 +36,25 @@ from .config import (
|
||||
)
|
||||
|
||||
_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)
|
||||
SYSTEM_PROMPT = f"""Ты — умный голосовой ассистент с человеческим поведением.
|
||||
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
|
||||
Твоя главная цель — помогать пользователю и поддерживать интересный диалог.
|
||||
Отвечай кратко и по существу, на русском языке.
|
||||
Отвечай на русском языке кратко и по существу: обычно 1-2 коротких предложения.
|
||||
Если пользователь явно просит подробнее, можно до 4 коротких предложений без повторов и лишних вводных.
|
||||
Избегай длинных списков, сложного форматирования и спецсимволов, так как твои ответы озвучиваются голосом.
|
||||
Не добавляй ссылки, сноски и маркеры источников (например, [1], [2], URL).
|
||||
Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов.
|
||||
Понимай юмор, иронию, сарказм, образные выражения, намеки и переносный смысл фраз.
|
||||
Если пользователь шутит или говорит образно, сначала правильно восстанови его реальное намерение, затем ответь естественно и по смыслу.
|
||||
Если в шутке или метафоре скрыта команда или просьба, трактуй ее по смыслу, а не буквально.
|
||||
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные.
|
||||
Тебя активируют словом "{WAKE_WORD}". Никогда не произноси это слово и его варианты ({_wake_word_aliases_text}) ни в каком ответе.
|
||||
Если пользователь спрашивает, как тебя зовут или как к тебе обращаться, отвечай нейтрально: "Я ваш голосовой ассистент"."""
|
||||
@@ -73,7 +88,18 @@ INTENT_SYSTEM_PROMPT = """Ты NLU-модуль голосовой колонк
|
||||
- Для "что играет" = music_action=current.
|
||||
- Для "включи жанр X" = music_action=play_genre, 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 должен быть пригоден для командного парсера (без лишних слов).
|
||||
- Понимай разговорные, шутливые, переносные, косвенные и ироничные формулировки.
|
||||
- Восстанавливай намерение по смыслу, а не только по буквальным словам.
|
||||
- Если в фразе есть скрытая прикладная команда для колонки, верни соответствующий intent и normalized_command.
|
||||
- Если пользователь просто шутит или разговаривает без прикладной команды, выбирай smalltalk или chat, а не случайную системную команду.
|
||||
- Если уверенность низкая, ставь intent=none, music_action=none, confidence <= 0.4."""
|
||||
|
||||
_PROVIDER_ALIASES = {
|
||||
@@ -442,6 +468,60 @@ def _extract_json_object(raw_text: str) -> Optional[dict]:
|
||||
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):
|
||||
"""
|
||||
Внутренняя функция для отправки HTTP-запроса к выбранному AI-провайдеру.
|
||||
@@ -512,7 +592,7 @@ def interpret_assistant_intent(text: str) -> dict:
|
||||
response = _send_request(
|
||||
messages,
|
||||
max_tokens=220,
|
||||
temperature=0.0,
|
||||
temperature=AI_INTENT_TEMPERATURE,
|
||||
error_text="",
|
||||
)
|
||||
payload = _extract_json_object(response)
|
||||
@@ -596,10 +676,12 @@ def ask_ai(messages_history: list) -> str:
|
||||
|
||||
response = _send_request(
|
||||
messages,
|
||||
max_tokens=500,
|
||||
temperature=1.0, # Высокая температура для более живого общения
|
||||
max_tokens=AI_CHAT_MAX_TOKENS,
|
||||
temperature=AI_CHAT_TEMPERATURE,
|
||||
error_text="Произошла ошибка при обращении к AI. Попробуйте ещё раз.",
|
||||
)
|
||||
response = _sanitize_chat_response(response)
|
||||
response = _truncate_chat_response(response, AI_CHAT_MAX_CHARS)
|
||||
|
||||
if response:
|
||||
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.
|
||||
"""
|
||||
response = None
|
||||
cfg, selection_error = _get_provider_settings()
|
||||
if selection_error:
|
||||
yield selection_error
|
||||
@@ -637,14 +720,46 @@ def ask_ai_stream(messages_history: list):
|
||||
response = _HTTP.post(
|
||||
cfg["api_url"],
|
||||
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,
|
||||
stream=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Для устойчивости TTS сначала собираем поток, затем чистим и аккуратно
|
||||
# ограничиваем длину по границе предложения.
|
||||
raw_parts = []
|
||||
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:
|
||||
yield f"Извините, сервер {cfg['name']} не отвечает. Попробуйте позже."
|
||||
except requests.exceptions.RequestException as error:
|
||||
@@ -653,6 +768,12 @@ def ask_ai_stream(messages_history: list):
|
||||
except Exception as error:
|
||||
print(f"❌ Streaming Error ({cfg['name']}): {error}")
|
||||
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:
|
||||
@@ -683,17 +804,18 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str:
|
||||
response = _send_request(
|
||||
messages,
|
||||
max_tokens=160,
|
||||
temperature=0.2, # Низкая температура для точности перевода
|
||||
temperature=AI_TRANSLATION_TEMPERATURE,
|
||||
error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
|
||||
)
|
||||
cleaned = response.strip()
|
||||
cleaned = _sanitize_chat_response(response).strip()
|
||||
cleaned = re.sub(r"[*_`]+", "", cleaned)
|
||||
if not cleaned:
|
||||
return cleaned
|
||||
|
||||
# Normalize to 2-3 variants separated by " / "
|
||||
parts = []
|
||||
for chunk in re.split(r"(?:\s*/\s*|\n|;|\|)", cleaned):
|
||||
item = chunk.strip(" \t-•")
|
||||
item = chunk.strip(" \t-•\"'“”«»")
|
||||
if item:
|
||||
parts.append(item)
|
||||
if not parts:
|
||||
|
||||
@@ -130,11 +130,9 @@ class AudioManager:
|
||||
if match_idx is not None:
|
||||
return match_idx
|
||||
|
||||
raise RuntimeError(
|
||||
"Audio input initialization failed: could not find an input device "
|
||||
f"matching AUDIO_INPUT_DEVICE_NAME={AUDIO_INPUT_DEVICE_NAME!r}. "
|
||||
"Available input devices:\n"
|
||||
+ self.describe_input_devices()
|
||||
print(
|
||||
"⚠️ AUDIO_INPUT_DEVICE_NAME was set but no matching input device was found: "
|
||||
f"{AUDIO_INPUT_DEVICE_NAME!r}. Falling back to default input selection."
|
||||
)
|
||||
|
||||
# Default input device (if PortAudio has one).
|
||||
@@ -176,11 +174,9 @@ class AudioManager:
|
||||
)
|
||||
if match_idx is not None:
|
||||
return match_idx
|
||||
raise RuntimeError(
|
||||
"Audio output initialization failed: could not find an output device "
|
||||
f"matching AUDIO_OUTPUT_DEVICE_NAME={AUDIO_OUTPUT_DEVICE_NAME!r}. "
|
||||
"Available output devices:\n"
|
||||
+ self.describe_output_devices()
|
||||
print(
|
||||
"⚠️ AUDIO_OUTPUT_DEVICE_NAME was set but no matching output device was found: "
|
||||
f"{AUDIO_OUTPUT_DEVICE_NAME!r}. Falling back to default output selection."
|
||||
)
|
||||
|
||||
default_idx = self._get_default_output_index()
|
||||
|
||||
@@ -342,6 +342,7 @@ def numbers_to_words(text: str) -> str:
|
||||
case = "nominative"
|
||||
gender = "m"
|
||||
prep_clean = prep.strip().lower() if prep else None
|
||||
parsed = None
|
||||
|
||||
if prep_clean:
|
||||
morph_case = get_case_from_preposition(prep_clean)
|
||||
@@ -359,6 +360,7 @@ def numbers_to_words(text: str) -> str:
|
||||
# Спец-случай: "на 1 час"
|
||||
if (
|
||||
prep_clean == "на"
|
||||
and parsed is not None
|
||||
and parsed.normal_form in TIME_UNIT_LEMMAS
|
||||
and parsed.tag.gender in ("masc", "neut")
|
||||
):
|
||||
|
||||
@@ -4,6 +4,9 @@ Command parsing helpers.
|
||||
|
||||
import re
|
||||
|
||||
from .config import WAKE_WORD, WAKE_WORD_ALIASES
|
||||
from ..audio.sound_level import is_volume_command, parse_volume_text
|
||||
|
||||
_STOP_WORDS_STRICT = {
|
||||
"стоп",
|
||||
"хватит",
|
||||
@@ -31,6 +34,28 @@ _STOP_PATTERNS_LENIENT = [
|
||||
r"\bдостаточно\b",
|
||||
]
|
||||
_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:
|
||||
@@ -40,6 +65,13 @@ def _normalize_text(text: str) -> str:
|
||||
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:
|
||||
"""
|
||||
Detect stop commands in text.
|
||||
@@ -64,3 +96,27 @@ def is_stop_command(text: str, mode: str = "strict") -> bool:
|
||||
return True
|
||||
|
||||
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 (переменные окружения) и определяет константы.
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from dotenv import dotenv_values
|
||||
|
||||
# Базовая директория проекта (корневая папка, где лежит .env)
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Загружаем переменные из файла .env в корневом каталоге
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
def _load_project_env(env_path: Path) -> None:
|
||||
"""
|
||||
Загружает .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_PROVIDER опционален. Приоритет у единственного активного AI API key.
|
||||
@@ -29,6 +63,22 @@ OPENROUTER_API_URL = os.getenv(
|
||||
"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_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||
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", "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) ---
|
||||
# Ключ для облачного 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"
|
||||
# Чувствительность wake word (0..1). Выше = ловит легче, но больше ложных срабатываний.
|
||||
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():
|
||||
_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)
|
||||
try:
|
||||
STT_START_SOUND_VOLUME = float(os.getenv("STT_START_SOUND_VOLUME", "0.25"))
|
||||
except Exception:
|
||||
STT_START_SOUND_VOLUME = 0.25
|
||||
STT_START_SOUND_VOLUME = max(0.0, min(1.0, STT_START_SOUND_VOLUME))
|
||||
# Звук старта STT всегда на 100% громкости, чтобы по уровню был как обычный TTS-ответ.
|
||||
STT_START_SOUND_VOLUME = 1.0
|
||||
# Голос для русского языка (eugene - мужской голос)
|
||||
TTS_SPEAKER = "eugene" # Доступные (ru): aidar, baya, kseniya, xenia, eugene
|
||||
# Голос для английского языка
|
||||
TTS_EN_SPEAKER = os.getenv("TTS_EN_SPEAKER", "en_0")
|
||||
# Частота дискретизации для воспроизведения (качество звука)
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user