feat: refine assistant logic and update docs

This commit is contained in:
future
2026-04-09 21:03:02 +03:00
parent ebe79c3692
commit 42c064a274
19 changed files with 1958 additions and 492 deletions

View File

@@ -6,6 +6,11 @@ AI_PROVIDER=
# OPENROUTER_API_KEY=your_openrouter_api_key_here
OPENROUTER_MODEL=openai/gpt-4o-mini
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_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
PORCUPINE_ACCESS_KEY=your_porcupine_access_key_here
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)
# AUDIO_INPUT_DEVICE_NAME=pulse
# AUDIO_INPUT_DEVICE_INDEX=2

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ env.bak/
venv.bak/
.qwen
qwen.md
.tmp/
# AI configs

View File

@@ -1,7 +1,12 @@
.PHONY: run check qwen-context
PYTHON := python3
ifneq ($(wildcard .venv/bin/python),)
PYTHON := .venv/bin/python
endif
run:
python run.py
$(PYTHON) run.py
check:
./scripts/qwen-check.sh

View File

@@ -48,7 +48,7 @@ flowchart TD
F --> G[Follow-up режим или ожидание wake word]
```
## Что Важно В Этой Реализации
## Что важно в этой реализации
- Контекст диалога хранится в памяти текущей сессии, поэтому после первого вопроса можно продолжать разговор без потери нити.
- Системная роль ассистента и `ROLE_JSON` сохраняются для всех поддерживаемых AI-провайдеров.
@@ -68,7 +68,7 @@ sudo apt-get install -y portaudio19-dev libasound2-dev mpg123 mpv pulseaudio-uti
### 2) Установка Python-зависимостей
```bash
git clone <URL_ВАШЕГО_РЕПОЗИТОРИЯ>
git clone https://gitea.futuree.ru/future/alexander_smart-speaker.git
cd alexander_smart-speaker
python3 -m venv venv
source venv/bin/activate
@@ -157,7 +157,7 @@ python run.py
| `AUDIO_OUTPUT_DEVICE_NAME` | Нет | auto | Подстрока имени динамика/выхода (например `pulse`) |
| `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_VOLUME` | Нет | `0.25` | Громкость звука старта STT (0..1) |
| `STT_START_SOUND_VOLUME` | Нет | `1.0` | Громкость звука старта STT (в текущей версии фиксирована на 100%) |
| `TTS_EN_SPEAKER` | Нет | `en_0` | Английский голос TTS |
| `WEATHER_LAT` | Нет | - | Широта города по умолчанию |
| `WEATHER_LON` | Нет | - | Долгота города по умолчанию |

View File

@@ -11,20 +11,57 @@ import re
import platform
from ..core.roman import replace_roman_numerals
try:
import pymorphy3
_MORPH = pymorphy3.MorphAnalyzer()
except Exception:
_MORPH = None
# Карта для перевода слов в цифры ("пять" -> 5)
NUMBER_MAP = {
"ноль": 0,
"один": 1,
"одна": 1,
"раз": 1,
"единица": 1,
"единичка": 1,
"два": 2,
"две": 2,
"двойка": 2,
"двоечка": 2,
"три": 3,
"тройка": 3,
"троечка": 3,
"четыре": 4,
"четверка": 4,
"четверочка": 4,
"пять": 5,
"пятерка": 5,
"пятерочка": 5,
"шесть": 6,
"шестерка": 6,
"шестерочка": 6,
"семь": 7,
"семерка": 7,
"семерочка": 7,
"восемь": 8,
"восьмерка": 8,
"восьмерочка": 8,
"девять": 9,
"девятка": 9,
"девяточка": 9,
"десять": 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):
@@ -149,16 +186,25 @@ def parse_volume_text(text: str) -> int | None:
Пытается найти число громкости в тексте.
Понимает и цифры ("5"), и слова ("пять").
"""
text = replace_roman_numerals(text.lower())
text = replace_roman_numerals(text.lower().replace("ё", "е"))
# 1. Ищем цифры (1-10)
num_match = re.search(r"\b(10|[1-9])\b", text)
if num_match:
return int(num_match.group())
# 1. Ищем цифры в любом месте фразы.
for match in re.finditer(r"\d+", text):
value = int(match.group())
if 1 <= value <= 10:
return value
# 2. Ищем слова из словаря
for word, value in NUMBER_MAP.items():
if word in text:
# 2. Ищем числительные и разговорные формы по леммам:
# "семерку", "десяточку", "на двух" -> 7, 10, 2.
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 None
def is_volume_command(text: str) -> bool:
if not text:
return False
return bool(_VOLUME_COMMAND_RE.search(text.lower().replace("ё", "е")))

View File

@@ -8,14 +8,13 @@ Supports Russian (default) and English.
# Использует Deepgram API через веб-сокеты для потокового распознавания в реальном времени.
import asyncio
import re
import time
import pyaudio
import logging
import contextlib
import threading
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 (
DeepgramClient,
DeepgramClientOptions,
@@ -25,13 +24,14 @@ from deepgram import (
import deepgram.clients.common.v1.abstract_sync_websocket as sdk_ws
import websockets.sync.client
from ..core.audio_manager import get_audio_manager
from ..core.commands import is_fast_command
# --- Патч (исправление) для библиотеки websockets ---
# Явно задаём таймауты подключения, чтобы не зависать на долгом handshake.
_original_connect = websockets.sync.client.connect
DEEPGRAM_CONNECT_TIMEOUT_SECONDS = 3.0
DEEPGRAM_CONNECT_WAIT_SECONDS = 4.0
DEEPGRAM_CONNECT_TIMEOUT_SECONDS = 5.0
DEEPGRAM_CONNECT_WAIT_SECONDS = 6.5
DEEPGRAM_CONNECT_POLL_SECONDS = 0.001
SENDER_STOP_WAIT_SECONDS = 2.5
SENDER_FORCE_RELEASE_WAIT_SECONDS = 2.5
@@ -62,28 +62,6 @@ POST_SPEECH_SILENCE_TIMEOUT_SECONDS = 2.0
# Фактическое завершение происходит примерно после 2.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:
"""Класс распознавания речи через Deepgram."""
@@ -280,7 +258,7 @@ class SpeechRecognizer:
dg_connection: Активное соединение с Deepgram.
timeout_seconds: Аварийный лимит длительности активной речи.
detection_timeout: Время ожидания начала речи.
fast_stop: Если True, короткая стоп-фраза завершает STT после 1с тишины.
fast_stop: Если True, короткие системные команды завершают STT раньше.
"""
self.transcript = ""
transcript_parts = []
@@ -296,6 +274,8 @@ class SpeechRecognizer:
# События для синхронизации
stop_event = asyncio.Event() # Пора останавливаться
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()
first_speech_activity_at = None
session_error = {"message": None}
@@ -338,8 +318,7 @@ class SpeechRecognizer:
except RuntimeError:
pass
if fast_stop:
if _is_fast_stop_utterance(sentence):
if fast_stop and is_fast_command(sentence):
self.transcript = sentence
try:
loop.call_soon_threadsafe(request_stop)
@@ -470,6 +449,7 @@ class SpeechRecognizer:
print(
f"⏰ Timeout connecting to Deepgram ({DEEPGRAM_CONNECT_WAIT_SECONDS:.1f}s)"
)
connection_failed_event.set()
loop.call_soon_threadsafe(request_stop)
return
@@ -479,15 +459,18 @@ class SpeechRecognizer:
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)
return
if connect_result["ok"] is False:
mark_session_error("Failed to start Deepgram connection")
print("Failed to start Deepgram connection")
connection_failed_event.set()
loop.call_soon_threadsafe(request_stop)
return
connection_ready_event.set()
print(f"🚀 Connected! Sending buffer ({len(audio_buffer)} chunks)...")
# 3. Отправляем накопленный буфер
@@ -522,6 +505,7 @@ class SpeechRecognizer:
except Exception as e:
mark_session_error(f"Audio send error: {e}")
print(f"Audio send error: {e}")
connection_failed_event.set()
with contextlib.suppress(RuntimeError):
loop.call_soon_threadsafe(request_stop)
finally:
@@ -551,6 +535,36 @@ class SpeechRecognizer:
and effective_detection_timeout > 0
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())
stop_wait_task = asyncio.create_task(stop_event.wait())
try:
@@ -568,7 +582,7 @@ class SpeechRecognizer:
)
if not done:
# Если за detection_timeout никто не начал говорить, выходим
# Если за detection_timeout после поднятия WS никто не начал говорить, выходим.
request_stop()
# 2. После старта речи завершаем только по тишине POST_SPEECH_SILENCE_TIMEOUT_SECONDS.
@@ -687,7 +701,7 @@ class SpeechRecognizer:
timeout_seconds: Защитный лимит длительности активной речи.
detection_timeout: Сколько ждать начала речи перед тем как сдаться.
lang: Язык ("ru" или "en").
fast_stop: Быстрое завершение для коротких stop-команд.
fast_stop: Быстрое завершение для коротких системных команд.
"""
if not self.dg_client:
self.initialize()

View File

@@ -19,12 +19,14 @@ import sounddevice as sd
import torch
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 о длинном тексте (мы сами его режем)
warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols")
_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:
@@ -34,6 +36,7 @@ class TextToSpeech:
self.model_ru = None
self.model_en = None
self.sample_rate = TTS_SAMPLE_RATE
self.speed_factor = float(TTS_SPEED)
self.speaker_ru = TTS_SPEAKER
self.speaker_en = TTS_EN_SPEAKER
self._interrupted = False
@@ -41,6 +44,23 @@ class TextToSpeech:
self._audio_manager = 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):
"""
Загрузка и кэширование модели Silero TTS.
@@ -52,15 +72,6 @@ class TextToSpeech:
if self.model_en:
return self.model_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(
repo_or_dir="snakers4/silero-models",
model="silero_tts",
@@ -185,28 +196,7 @@ class TextToSpeech:
if not text.strip():
return True
# Выбор модели
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]
model, speaker = self._get_model_and_speaker(language)
# Разбиваем текст на куски
chunks = self._split_text(text)
@@ -233,7 +223,7 @@ class TextToSpeech:
)
# Конвертация в numpy массив для sounddevice
audio_np = audio.numpy()
audio_np = self._apply_speed(audio.numpy())
if check_interrupt:
if not self._play_audio_with_interrupt(audio_np, check_interrupt):
@@ -256,10 +246,104 @@ class TextToSpeech:
else:
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(
self, segments: list[tuple[str, str]], check_interrupt=None
) -> bool:
"""Озвучивание текста с переключением 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:
if not segment.strip():
continue
@@ -390,6 +474,7 @@ class TextToSpeech:
return
except Exception:
pass
time.sleep(_INTERRUPT_POLL_SECONDS)
def _play_with_interrupt_sounddevice(
self, audio_np: np.ndarray, check_interrupt
@@ -407,11 +492,18 @@ class TextToSpeech:
# Запускаем воспроизведение (неблокирующее)
sd.play(audio_np, self.sample_rate)
# Ждем окончания воспроизведения в цикле
while sd.get_stream().active:
# Ждем окончания воспроизведения в цикле.
while True:
if self._interrupted:
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:
# Сообщаем потоку-наблюдателю, что пора завершаться

View File

@@ -9,12 +9,26 @@ Listens for the configured wake word.
import pvporcupine
import pyaudio
import struct
import io
import wave
import time
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 (
DEEPGRAM_API_KEY,
PORCUPINE_ACCESS_KEY,
PORCUPINE_KEYWORD_PATH,
PORCUPINE_SENSITIVITY,
WAKEWORD_HIT_COOLDOWN_SECONDS,
WAKEWORD_ENABLE_FALLBACK_STT,
WAKEWORD_MIN_RMS,
WAKEWORD_REOPEN_GRACE_SECONDS,
WAKEWORD_RMS_MULTIPLIER,
WAKE_WORD,
WAKE_WORD_ALIASES,
)
from ..core.audio_manager import get_audio_manager
@@ -33,6 +47,19 @@ class WakeWordDetector:
self._resampled_pcm_buffer = np.array([], dtype=np.int16)
self._stream_closed = True # Флаг состояния потока (закрыт/открыт)
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):
"""Инициализация Porcupine и PyAudio."""
@@ -87,6 +114,211 @@ class WakeWordDetector:
)
self._resampled_pcm_buffer = np.array([], dtype=np.int16)
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):
"""Явная остановка и закрытие потока (чтобы освободить микрофон для других задач)."""
@@ -97,6 +329,8 @@ class WakeWordDetector:
except Exception:
pass
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:
target_rate = int(self.porcupine.sample_rate)
@@ -160,16 +394,22 @@ class WakeWordDetector:
# Читаем небольшой кусочек аудио (frame)
pcm = self._read_porcupine_frame()
self._remember_rms(self._compute_rms(pcm))
# Обрабатываем фрейм через Porcupine
keyword_index = self.porcupine.process(pcm.tolist())
# Если keyword_index >= 0, значит ключевое слово обнаружено
if keyword_index >= 0:
now = time.time()
if self._accept_porcupine_hit(pcm, now, during_tts=False):
print("✅ Wake word обнаружен!")
# Важно: закрываем поток, чтобы освободить микрофон для STT (Deepgram)
self.stop_monitoring()
return True
if self._check_fallback_wakeword(pcm):
self.stop_monitoring()
return True
def check_wakeword_once(self) -> bool:
"""
@@ -189,15 +429,25 @@ class WakeWordDetector:
self._open_stream()
pcm = self._read_porcupine_frame()
self._remember_rms(self._compute_rms(pcm))
keyword_index = self.porcupine.process(pcm.tolist())
if keyword_index >= 0:
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
self._last_hit_ts = now
print("🛑 Wake word обнаружен во время ответа!")
return True
if self._check_fallback_wakeword(
pcm, during_tts=True, ignore_hit_cooldown=True
):
print("🛑 Wake word обнаружен fallback STT во время ответа!")
return True
return False
except Exception:
return False

View File

@@ -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:

View File

@@ -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()

View File

@@ -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")
):

View File

@@ -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",
]
_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

View File

@@ -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")

View File

@@ -54,6 +54,16 @@ _PARTS_OF_DAY = {"утра", "дня", "вечера", "ночи"}
_FILLER_WORDS = {"мне", "меня", "пожалуйста", "на", "в", "во", "к", "и"}
_HOUR_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):
@@ -97,10 +107,9 @@ def _apply_part_of_day(hour: int, part_of_day: str | None) -> int:
def _extract_alarm_time_words(text: str):
tokens = re.findall(r"[a-zа-я0-9]+", text.lower().replace("ё", "е"))
markers = {"будильник", "разбуди", "поставь", "установи", "включи", "на", "в", "к"}
for index, token in enumerate(tokens):
if token not in markers:
if token not in _ALARM_MARKERS:
continue
current = index + 1
@@ -134,6 +143,40 @@ def _extract_alarm_time_words(text: str):
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:
def __init__(self):
self.alarms = []
@@ -229,7 +272,14 @@ class AlarmClock:
return self.add_alarm_with_days(hour, minute, 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)
for alarm in self.alarms:
if (
@@ -237,11 +287,13 @@ class AlarmClock:
and alarm.get("minute") == minute
and self._days_key(alarm.get("days")) == days_key
):
if alarm.get("active"):
return "already_active"
alarm["active"] = True
alarm["days"] = days_key
alarm["last_triggered"] = None
self.save_alarms()
return
return "reactivated"
self.alarms.append(
{"hour": hour, "minute": minute, "active": True, "days": days_key}
@@ -250,6 +302,7 @@ class AlarmClock:
days_phrase = self._format_days_phrase(days_key)
suffix = f" {days_phrase}" if days_phrase else ""
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}{suffix}")
return "created"
def cancel_all_alarms(self):
"""Выключение (деактивация) всех будильников."""
@@ -258,6 +311,33 @@ class AlarmClock:
self.save_alarms()
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:
"""Возвращает текстовое описание активных будильников."""
active = [
@@ -365,73 +445,60 @@ class AlarmClock:
def parse_command(self, text: str) -> str | None:
"""
Парсинг команды установки будильника из текста.
Примеры: "разбуди в 7:30", "будильник на 8 утра".
Парсинг команд управления будильниками.
Примеры: "разбуди в 7:30", "удали будильник на 8:00", "какие будильники".
"""
text = replace_roman_numerals(text.lower())
if "будильник" not in text and "разбуди" not in text:
text = replace_roman_numerals(text.lower().replace("ё", "е"))
if not re.search(r"\b(будильник\w*|разбуд\w*)\b", text):
return None
if "будильник" in text and re.search(
r"(какие|какой|список|активн|покажи|сколько|есть ли)", text
):
if _ALARM_LIST_RE.search(text):
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()
return "Хорошо, я отменил все будильники."
return (
"Скажите время будильника, который нужно удалить. "
"Например: удалите будильник на 7:30."
)
days = self._extract_alarm_days(text)
# Поиск формата "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:
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)
alarm_time = _extract_alarm_time(text)
if alarm_time:
h, m = alarm_time
add_status = self.add_alarm_with_days(h, m, days=days)
if add_status == "already_active":
return "Такой будильник уже установлен."
days_phrase = self._format_days_phrase(days)
suffix = f" {days_phrase}" if days_phrase else ""
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
# Поиск формата словами: "в семь утра", "будильник семь тридцать"
word_time = _extract_alarm_time_words(text)
if word_time:
h, m = word_time
self.add_alarm_with_days(h, m, days=days)
days_phrase = self._format_days_phrase(days)
suffix = f" {days_phrase}" if days_phrase else ""
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
if re.search(r"(постав|установ|запусти|включи|разбуди)", text) or text.strip() in {
if _ALARM_CREATE_RE.search(text) or text.strip() in {
"будильник",
"поставь будильник",
"создай будильник",
"добавь будильник",
}:
return ASK_ALARM_TIME_PROMPT
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
return (
"Я не понял команду для будильника. "
"Скажите, например: поставь на 7:30, покажи будильники или удали будильник на 7:30."
)
# Глобальный экземпляр

View File

@@ -20,7 +20,7 @@ from urllib.parse import urlencode
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:
import spotipy
@@ -97,6 +97,8 @@ class SpotifyProvider:
self.sp = None
self._initialized = False
self._init_error = None
self._duck_prev_volume: Optional[int] = None
self._duck_active = False
def initialize(self) -> bool:
if self._initialized:
@@ -249,6 +251,71 @@ class SpotifyProvider:
except Exception as 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:
"""Primary provider using Navidrome + MPV IPC."""
@@ -269,6 +336,8 @@ class NavidromeProvider:
self._folder_index_built_at = 0.0
self._autonext_lock = threading.Lock()
self._autonext_suppress_until = 0.0
self._duck_prev_volume: Optional[float] = None
self._duck_active = False
self._snapshot_stop = threading.Event()
self._snapshot_thread = threading.Thread(
@@ -764,6 +833,53 @@ class NavidromeProvider:
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:
while not self._snapshot_stop.wait(1.0):
try:
@@ -793,6 +909,7 @@ class NavidromeProvider:
self._mpv_process = None
self._remove_stale_socket()
self.clear_duck_state()
def _start_song(self, song: Song, start_sec: float = 0.0) -> None:
self._ensure_initialized()
@@ -1024,6 +1141,8 @@ class MusicController:
def __init__(self) -> None:
self.navidrome = NavidromeProvider()
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(
{
alias.lower().replace("ё", "е").strip()
@@ -1058,6 +1177,41 @@ class MusicController:
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]:
"""
Pause music for generic stop-words ("стоп", "хватит", etc).
@@ -1187,10 +1341,7 @@ class MusicController:
lambda: self.navidrome.play_query(normalized_query),
lambda: self.spotify.play_music(normalized_query),
)
return self._with_fallback(
lambda: self.navidrome.play_random(contextual_resume=True),
lambda: self.spotify.play_music(None),
)
return self._play_default()
if normalized_action in {"play_query", "search"}:
if not normalized_query:
@@ -1286,10 +1437,7 @@ class MusicController:
lambda: self.navidrome.play_query(play_query),
lambda: self.spotify.play_music(play_query),
)
return self._with_fallback(
lambda: self.navidrome.play_random(contextual_resume=True),
lambda: self.spotify.play_music(None),
)
return self._play_default()
return None

View File

@@ -3,11 +3,120 @@ Weather feature module.
Fetches weather data from Open-Meteo API.
"""
import re
import requests
from datetime import datetime
from ..core.config import WEATHER_LAT, WEATHER_LON, WEATHER_CITY
_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:
"""Decodes WMO weather code to Russian description."""
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.
Handles common Russian grammatical cases (падежи) for city names.
"""
# Convert to lowercase for comparison
lower_city = city_name.lower()
lowered = str(city_name or "").lower().replace("ё", "е").strip()
if not lowered:
return city_name
# Remove common Russian location descriptors that might be included by mistake
# For example, if someone says "в городе Волгоград", the city_name might be "городе волгоград"
# So we want to extract just "волгоград"
if 'городе' in lower_city:
# 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()
lowered = _CITY_PREFIX_RE.sub("", lowered)
lowered = _CITY_SPACING_RE.sub(" ", lowered).strip(" -")
if not lowered:
return city_name
# Common endings for different cases in Russian
# Prepositional case endings (-е, -и, -у, etc.)
prepositional_endings = ['е', 'и', 'у', 'о', 'й']
genitive_endings = ['а', 'я', 'ов', 'ев', 'ин', 'ын']
instrumental_endings = ['ом', 'ем', 'ой', 'ей']
exact_match = _KNOWN_CITY_VARIATIONS.get(lowered)
if exact_match:
return exact_match
# If the city ends with a prepositional ending, try removing it to get the base form
if lower_city.endswith(tuple(prepositional_endings)):
# Try to remove the ending and see if we get a valid base form
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
single_word_match = _SINGLE_WORD_CITY_VARIATIONS.get(lowered)
if single_word_match:
return single_word_match
# Special handling for common patterns
if base_form.endswith('йорке'): # "нью-йорке" -> "нью-йорк"
base_form = base_form[:-1] + 'к'
elif base_form.endswith('ске'): # "москве" -> "москва", "париже" -> "париж"
# 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] + 'ия'
spaced = lowered.replace("-", " ")
exact_match = _KNOWN_CITY_VARIATIONS.get(spaced)
if exact_match:
return exact_match
# Capitalize appropriately
if base_form != lower_city:
return base_form.capitalize()
if " " not in spaced:
for suffix, replacement in (
("ом", ""),
("ем", ""),
("ой", "а"),
("ей", "а"),
("е", ""),
("у", "а"),
("ю", "я"),
):
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
case_variations = {
"нью-йорке": "Нью-Йорк",
"нью-йорка": "Нью-Йорк",
"нью-йорком": "Нью-Йорк",
"москве": "Москва",
"москвы": "Москва",
"москвой": "Москва",
"москву": "Москва",
"лондоне": "Лондон",
"лондона": "Лондон",
"лондоном": "Лондон",
"париже": "Париж",
"парижа": "Париж",
"парижем": "Париж",
"берлине": "Берлин",
"берлина": "Берлин",
"берлином": "Берлин",
"пекине": "Пекин",
"пекина": "Пекин",
"пекином": "Пекин",
"роме": "Рим",
"рима": "Рим",
"римом": "Рим",
"мадриде": "Мадрид",
"мадрида": "Мадрид",
"мадридом": "Мадрид",
"сиднее": "Сидней",
"сиднея": "Сидней",
"сиднеем": "Сидней",
"вашингтоне": "Вашингтон",
"вашингтона": "Вашингтон",
"вашингтоном": "Вашингтон",
"лос-анджелесе": "Лос-Анджелес",
"лос-анджелеса": "Лос-Анджелес",
"лос-анджелесом": "Лос-Анджелес",
"сиэтле": "Сиэтл",
"сиэтла": "Сиэтл",
"сиэтлом": "Сиэтл",
"бостоне": "Бостон",
"бостона": "Бостон",
"бостоном": "Бостон",
"денвере": "Денвер",
"денвера": "Денвер",
"денвером": "Денвер",
"хьюстоне": "Хьюстон",
"хьюстона": "Хьюстон",
"хьюстоном": "Хьюстон",
"фениксе": "Феникс",
"феникса": "Феникс",
"фениксом": "Феникс",
"атланте": "Атланта",
"атланты": "Атланта",
"атлантой": "Атланта",
"портленде": "Портленд",
"портленда": "Портленд",
"портлендом": "Портленд",
"остине": "Остин",
"остина": "Остин",
"остином": "Остин",
"нэшвилле": "Нэшвилл",
"нэшвилла": "Нэшвилл",
"нэшвиллом": "Нэшвилл",
"сан-франциско": "Сан-Франциско",
"токио": "Токио",
"торонто": "Торонто",
"чикаго": "Чикаго",
"майами": "Майами",
}
return case_variations.get(lower_city, city_name)
return _smart_title_city(lowered)
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
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)
normalized_lower = str(normalized_city or city_name).lower().replace("ё", "е").strip()
# Also try with English version if it's a known translation
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())
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)
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
for name_to_try in try_names:

View File

@@ -6,6 +6,7 @@ import re
import signal
import sys
import time
from datetime import datetime
from collections import deque
from pathlib import Path
import subprocess
@@ -22,11 +23,12 @@ else:
_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 get_recognizer, listen
from .audio.tts import initialize as init_tts
from .audio.tts import speak
from .audio.tts import was_interrupted as was_tts_interrupted
from .audio.wakeword import (
check_wakeword_once,
wait_for_wakeword,
@@ -43,6 +45,7 @@ from .core.config import (
STT_START_SOUND_PATH,
STT_START_SOUND_VOLUME,
WAKE_WORD,
WAKE_WORD_ALIASES,
)
from .core.cleaner import clean_response
from .core.commands import is_stop_command
@@ -89,6 +92,50 @@ _TRANSLATION_COMMANDS = [
_TRANSLATION_COMMANDS_SORTED = sorted(
_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 = {
"еще раз",
@@ -109,6 +156,8 @@ _REPEAT_PHRASES = {
_WEATHER_TRIGGERS = (
"погода",
"погоду",
"погоде",
"по погоде",
"что на улице",
"какая температура",
"сколько градусов",
@@ -117,6 +166,7 @@ _WEATHER_TRIGGERS = (
"нужен ли зонт",
"брать ли зонт",
"прогноз погоды",
"прогноз",
"че там на улице",
"что там на улице",
"как на улице",
@@ -151,27 +201,64 @@ _CITY_INVALID_WORDS = {
_CITY_PATTERNS = [
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.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.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.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.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,
),
]
_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"\отор(ый|ого)\s+час\b", re.IGNORECASE),
re.compile(r"\bсколько\s+времени\b", re.IGNORECASE),
re.compile(r"\акое\s+сейчас\s+время\b", re.IGNORECASE),
re.compile(r"\оторый\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_MUSIC_MIN_CONFIDENCE = 0.45
_SEMANTIC_REPEAT_STOP_MIN_CONFIDENCE = 0.72
@@ -194,8 +281,10 @@ def signal_handler(sig, frame):
def parse_translation_request(text: str):
"""Проверяет, является ли фраза запросом на перевод."""
text_lower = text.lower().strip()
text_lower = text.lower().strip()
text = str(text or "").strip()
if not text:
return None
text_lower = text.lower()
# Список префиксов команд перевода и соответствующих направлений языков.
# Важно: более длинные префиксы должны проверяться первыми (например,
# "переведи с русского на английский" не должен схватиться как "переведи с русского").
@@ -209,7 +298,132 @@ def parse_translation_request(text: str):
"target_lang": target_lang,
"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 {
"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():
@@ -295,12 +509,129 @@ def main():
pass
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
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)
alarm_clock = get_alarm_clock() # Загрузка будильников
stopwatch_manager = get_stopwatch_manager() # Загрузка секундомеров
timer_manager = get_timer_manager() # Загрузка таймеров
cities_game = get_cities_game() # Игра "Города"
music_controller = get_music_controller() # Контроллер музыки
print()
# История чата
@@ -331,9 +662,18 @@ def main():
last_stt_check = time.time()
except Exception as e:
print(f"Ошибка при проверке STT: {e}")
wakeword_music_ducked = False
try:
# Освобождаем микрофон wake word
stop_wakeword_monitoring()
if text_mode:
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():
@@ -345,6 +685,12 @@ def main():
skip_wakeword = False
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
if not skip_wakeword:
detected = wait_for_wakeword(timeout=0.5)
@@ -353,12 +699,20 @@ def main():
if not detected:
continue
wakeword_music_ducked = music_controller.duck_for_wakeword()
# Звук активации
play_stt_start_sfx()
# Слушаем команду
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:
print(f"Ошибка при прослушивании: {e}")
print("Переинициализация STT...")
@@ -372,6 +726,12 @@ def main():
# Follow-up режим — без wake word
print(f"👂 Слушаю ({followup_idle_timeout_seconds:.1f} сек)...")
try:
stop_wakeword_monitoring()
settle_audio_after_tts()
# Тот же сигнал, что используется при wake word:
# теперь всегда перед стартом STT в продолжении диалога.
play_stt_start_sfx()
play_followup_activation_sfx = False
user_text = listen(
timeout_seconds=7.0,
detection_timeout=followup_idle_timeout_seconds,
@@ -396,6 +756,13 @@ def main():
# Анализ текста
if not user_text:
if not text_mode:
output_response(
"Не расслышал, повторите, пожалуйста.",
allow_wake_interrupt=False,
)
skip_wakeword = True
else:
skip_wakeword = False
continue
@@ -411,7 +778,7 @@ def main():
clean_stopwatch_stop_response = clean_response(
stopwatch_stop_response, language="ru"
)
speak(clean_stopwatch_stop_response)
output_response(clean_stopwatch_stop_response)
last_response = clean_stopwatch_stop_response
skip_wakeword = False
continue
@@ -428,9 +795,9 @@ def main():
):
if last_response:
print(f"🔁 Повторяю: {last_response}")
speak(last_response)
output_response(last_response)
else:
speak("Я еще ничего не говорил.")
output_response("Я еще ничего не говорил.")
skip_wakeword = True
continue
@@ -463,7 +830,7 @@ def main():
clean_stopwatch_stop_response = clean_response(
stopwatch_stop_response, language="ru"
)
speak(clean_stopwatch_stop_response)
output_response(clean_stopwatch_stop_response)
last_response = clean_stopwatch_stop_response
skip_wakeword = False
continue
@@ -478,9 +845,9 @@ def main():
):
if last_response:
print(f"🔁 Повторяю: {last_response}")
speak(last_response)
output_response(last_response)
else:
speak("Я еще ничего не говорил.")
output_response("Я еще ничего не говорил.")
skip_wakeword = True
continue
@@ -497,7 +864,7 @@ def main():
clean_music_response = clean_response(
semantic_music_response, language="ru"
)
speak(clean_music_response)
output_response(clean_music_response)
last_response = clean_music_response
skip_wakeword = True
continue
@@ -523,12 +890,12 @@ def main():
smalltalk_response = get_smalltalk_response(effective_text)
if smalltalk_response:
clean_smalltalk = clean_response(smalltalk_response, language="ru")
speak(clean_smalltalk)
output_response(clean_smalltalk)
last_response = clean_smalltalk
skip_wakeword = True
continue
command_text = effective_text
command_text = _strip_wakeword_prefix(effective_text) or effective_text
command_text_lower = command_text.lower()
if pending_time_target == "timer" and "таймер" not in command_text_lower:
command_text = f"таймер {command_text}"
@@ -538,6 +905,15 @@ def main():
and "разбуди" not in command_text_lower
):
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)
@@ -545,7 +921,7 @@ def main():
clean_stopwatch_response = clean_response(
stopwatch_response, language="ru"
)
speak(clean_stopwatch_response)
output_response(clean_stopwatch_response)
last_response = clean_stopwatch_response
skip_wakeword = True
continue
@@ -554,7 +930,7 @@ def main():
timer_response = timer_manager.parse_command(command_text)
if timer_response:
clean_timer_response = clean_response(timer_response, language="ru")
completed = speak(
completed = output_response(
clean_timer_response, check_interrupt=check_wakeword_once
)
last_response = clean_timer_response
@@ -568,7 +944,7 @@ def main():
alarm_response = alarm_clock.parse_command(command_text)
if alarm_response:
clean_alarm_response = clean_response(alarm_response, language="ru")
speak(clean_alarm_response)
output_response(clean_alarm_response)
last_response = clean_alarm_response
pending_time_target = (
"alarm" if alarm_response == ASK_ALARM_TIME_PROMPT else None
@@ -577,21 +953,23 @@ def main():
continue
# Громкость
if command_text.lower().startswith("громкость"):
if (
semantic_type == "volume"
and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE
) or is_volume_command(command_text):
try:
vol_str = command_text.lower().replace("громкость", "", 1).strip()
level = parse_volume_text(vol_str)
level = parse_volume_text(command_text)
if level is not None:
if set_volume(level):
msg = f"Громкость установлена на {level}"
msg = f"Уровень громкости {level}"
clean_msg = clean_response(msg, language="ru")
speak(clean_msg)
output_response(clean_msg)
last_response = clean_msg
else:
speak("Не удалось установить громкость.")
output_response("Не удалось установить громкость.")
else:
speak(
output_response(
"Я не понял число громкости. Скажите число от одного до десяти."
)
@@ -599,54 +977,64 @@ def main():
continue
except Exception as e:
print(f"❌ Ошибка громкости: {e}")
speak("Не удалось изменить громкость.")
output_response("Не удалось изменить громкость.")
skip_wakeword = True
continue
# Погода
requested_city = None
user_text_lower = command_text.lower()
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
)
requested_city = _extract_requested_city(command_text)
has_weather_trigger = _has_weather_trigger(command_text)
if has_weather_trigger:
from .features.weather import get_weather_report
weather_report = get_weather_report(requested_city)
clean_report = clean_response(weather_report, language="ru")
speak(clean_report)
output_response(clean_report, interrupt_guard_seconds=0.0)
last_response = clean_report
skip_wakeword = True
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_response = music_controller.parse_command(command_text)
if music_response:
clean_music_response = clean_response(music_response, language="ru")
speak(clean_music_response)
output_response(clean_music_response)
last_response = clean_music_response
skip_wakeword = True
continue
# Перевод
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:
source_lang = translation_request["source_lang"]
target_lang = translation_request["target_lang"]
@@ -659,10 +1047,17 @@ def main():
if source_lang == "en"
else "Скажи фразу на русском."
)
speak(prompt)
output_response(prompt)
if text_mode:
text_to_translate = input("⌨️ Текст для перевода> ").strip()
else:
try:
stop_wakeword_monitoring()
settle_audio_after_tts()
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:
print(f"Ошибка при прослушивании для перевода: {e}")
@@ -672,12 +1067,12 @@ def main():
get_recognizer().initialize()
except Exception as init_error:
print(f"Ошибка переинициализации STT: {init_error}")
speak("Произошла ошибка при распознавании речи.")
output_response("Произошла ошибка при распознавании речи.")
skip_wakeword = False
continue
if not text_to_translate:
speak("Я не расслышал текст для перевода.")
output_response("Я не расслышал текст для перевода.")
skip_wakeword = False
continue
@@ -690,9 +1085,9 @@ def main():
last_response = clean_text
# Озвучиваем
completed = speak(
completed = output_response(
clean_text,
check_interrupt=check_wakeword_once,
check_interrupt=None,
language=target_lang,
)
stop_wakeword_monitoring()
@@ -706,7 +1101,7 @@ def main():
cities_response = cities_game.handle(command_text)
if cities_response:
clean_cities_response = clean_response(cities_response, language="ru")
speak(clean_cities_response)
output_response(clean_cities_response)
last_response = clean_cities_response
skip_wakeword = True
continue
@@ -717,41 +1112,81 @@ def main():
full_response = ""
interrupted = False
# Streaming TTS: читаем SSE без блокировок, а озвучиваем в отдельном потоке по предложениям.
# Streaming TTS: читаем SSE без блокировок, а озвучиваем по 2 предложения.
tts_queue: "queue.Queue[str | None]" = queue.Queue()
ai_queue: "queue.Queue[str | None]" = queue.Queue()
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:
return "", ""
# Ждем хотя бы немного текста, чтобы не "пиликать" по 1-2 словам.
min_chars = 55
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
# Находим границу предложения.
boundaries = _find_sentence_boundaries(text)
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(text) >= hard_flush_chars:
boundary = hard_flush_chars - 1
if len(boundaries) >= target_sentences:
boundary = boundaries[target_sentences - 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:
return "", text
@@ -772,9 +1207,8 @@ def main():
if not clean_part.strip():
continue
ok = speak(
ok = output_response(
clean_part,
check_interrupt=check_wakeword_once,
language="ru",
)
if not ok:
@@ -791,16 +1225,49 @@ def main():
tts_thread = threading.Thread(target=_tts_worker, daemon=True)
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)
try:
stream_generator = ask_ai_stream(list(chat_history))
buffer = ""
for chunk in stream_generator:
while True:
if stop_streaming_event.is_set():
break
if not chunk:
try:
chunk = ai_queue.get(timeout=0.1)
except queue.Empty:
if ai_stream_done.is_set():
break
continue
if chunk is None:
break
full_response += chunk
buffer += chunk
print(chunk, end="", flush=True)
@@ -814,17 +1281,31 @@ def main():
print(f"\n❌ Ошибка: {e}")
tts_queue.put("Произошла ошибка при получении ответа.")
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():
tail = buffer.strip()
if tail:
tts_queue.put(tail)
if not interrupted:
while True:
tail_part, buffer = _split_speakable(buffer, force=True)
if not tail_part:
break
tts_queue.put(tail_part)
tts_queue.put(None)
tts_thread.join(timeout=20)
ai_thread.join(timeout=1.0)
# Для длинных ответов не обрываем ожидание по таймауту:
# дожидаемся полного завершения TTS worker.
tts_thread.join()
print()
# Сохраняем ответ
# Сохраняем только завершенный ответ, чтобы не засорять контекст обрезанным хвостом.
if full_response and not interrupted:
chat_history.append({"role": "assistant", "content": full_response})
last_response = clean_response(full_response, language="ru")
@@ -842,8 +1323,14 @@ def main():
signal_handler(None, None)
except Exception as e:
print(f"❌ Ошибка: {e}")
speak("Произошла ошибка. Попробуйте ещё раз.")
output_response("Произошла ошибка. Попробуйте ещё раз.")
skip_wakeword = False
finally:
if wakeword_music_ducked:
try:
music_controller.restore_after_wakeword()
except Exception:
pass
if __name__ == "__main__":

View File

@@ -39,5 +39,53 @@
"days": [
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"
}
]

View File

@@ -4,8 +4,13 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
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"
python -m compileall app run.py >/dev/null
"$PYTHON_BIN" -m compileall app run.py >/dev/null
echo "[qwen-check] Optional ruff check"
if command -v ruff >/dev/null 2>&1; then