Compare commits

..

37 Commits

Author SHA1 Message Date
future
42c064a274 feat: refine assistant logic and update docs 2026-04-09 21:03:02 +03:00
ebe79c3692 Treat Deepgram 1006 disconnect as silence 2026-03-15 17:30:40 +03:00
59a607ba57 Bundle STT start sound asset and default to repo path 2026-03-15 17:29:23 +03:00
2088f366b6 Stabilize Deepgram session cleanup 2026-03-15 16:36:23 +03:00
715d7b0ee0 Speak AI responses during OpenRouter streaming 2026-03-15 16:28:31 +03:00
4b442795f8 Play STT start sound after wake word 2026-03-15 16:27:02 +03:00
3df24e27ae Add Ollama local model provider 2026-03-15 16:24:25 +03:00
6add70fcd2 Fix TTS time phrases and STT cleanup 2026-03-15 16:22:00 +03:00
cb54a9ee75 feat: improve semantic voice control and music playback 2026-03-15 14:40:33 +03:00
e1a94c68db feat: switch wake word to waltron 2026-03-15 02:59:13 +03:00
6c2702d5e3 feat: harden audio device compatibility across machines 2026-03-12 14:08:20 +03:00
e9f26f8050 fix: select audio input device via env 2026-03-12 13:03:50 +03:00
6769486e83 fix: improve streaming, alarms, and AI TTS 2026-03-12 12:38:35 +03:00
167ddc9264 Add OpenRouter provider and remove Perplexity 2026-03-07 19:59:06 +03:00
bed4ba36d7 chore: ignore ai configs 2026-03-07 19:59:06 +03:00
3947fdf59f Update .gitignore 2026-03-01 02:01:36 -08:00
c85e0267cd Delete .qwen/settings.json 2026-03-01 02:00:40 -08:00
974f99ea8f Delete 11.py 2026-03-01 02:00:09 -08:00
f1bc254c6b chore: sync local changes 2026-03-01 12:55:17 +03:00
27ee32be38 Revert "Add configurable input device selection"
This reverts commit 7ca6958488.
2026-03-01 12:51:08 +03:00
7ca6958488 Add configurable input device selection 2026-03-01 12:49:39 +03:00
a87840c78d Improve AI module comments and README 2026-02-28 16:43:05 +03:00
ff52b75073 Add multi-provider AI config safeguards 2026-02-28 16:35:59 +03:00
ea3ab4ff84 Delete AGENTS.md 2026-02-28 01:13:06 -08:00
e832f751bc Delete QWEN.md 2026-02-28 01:11:55 -08:00
2b40cf7d26 Allow 3-second pause inside utterances 2026-02-16 19:15:26 +03:00
0d61b92760 Sleep after 4s of follow-up silence 2026-02-16 19:12:58 +03:00
182361c547 Tighten smalltalk matching to avoid false positives 2026-02-16 19:06:56 +03:00
756cc340dc Keep dialogue active after wake word 2026-02-16 19:04:05 +03:00
d36d4be95f docs: redesign project README 2026-02-15 13:00:02 +03:00
1a79af7058 Fix syntax errors in Mermaid diagrams
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-15 12:56:17 +03:00
03d7dc01c2 Add flow diagram from wake word to STT and functions
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-15 12:53:13 +03:00
551f890b3c Update README.md with detailed function names in architecture diagram
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-15 12:50:13 +03:00
df61febe28 Update README.md with architecture diagram
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-15 12:48:33 +03:00
ca8ebd6657 Update assistant features and docs 2026-02-12 14:12:37 +03:00
bb3133a1c0 Expand ordinal suffix handling 2026-02-05 17:15:09 +03:00
875ff7d2c4 Normalize year suffixes for TTS 2026-02-05 17:07:04 +03:00
29 changed files with 6114 additions and 1212 deletions

View File

@@ -1,11 +1,70 @@
PERPLEXITY_API_KEY=your_perplexity_api_key_here
PERPLEXITY_MODEL=llama-3.1-sonar-small-128k-chat
# Оставьте незакомментированным только один AI API KEY.
# Если одновременно указать несколько AI ключей, колонка выдаст ошибку.
AI_PROVIDER=
# OpenRouter
# 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
OPENAI_MODEL=gpt-4o-mini
OPENAI_API_URL=https://api.openai.com/v1/chat/completions
# Gemini
# GEMINI_API_KEY=your_gemini_api_key_here
GEMINI_MODEL=gemini-2.5-flash
GEMINI_API_URL=https://generativelanguage.googleapis.com/v1beta/openai/chat/completions
# Z.ai
# ZAI_API_KEY=your_zai_api_key_here
ZAI_MODEL=glm-5
ZAI_API_URL=https://api.z.ai/api/paas/v4/chat/completions
# Anthropic Claude
# ANTHROPIC_API_KEY=your_anthropic_api_key_here
ANTHROPIC_MODEL=claude-sonnet-4-20250514
ANTHROPIC_API_URL=https://api.anthropic.com/v1/messages
ANTHROPIC_API_VERSION=2023-06-01
# Ollama (локально; без API key)
# AI_PROVIDER=ollama
OLLAMA_MODEL=llama3.1:8b
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
# AUDIO_OUTPUT_DEVICE_NAME=pulse
# AUDIO_OUTPUT_DEVICE_INDEX=5
# STT start sound (played after wake word before listening)
# STT_START_SOUND_PATH=assets/sounds/alisa-golosovoj-pomoschnik.mp3
# STT_START_SOUND_VOLUME=0.25
TTS_EN_SPEAKER=en_0
WEATHER_LAT=63.56
WEATHER_LON=53.69
WEATHER_CITY=Ухта
# Navidrome (приоритетный источник музыки; при ошибке — fallback на Spotify)
NAVIDROME_URL=https://navidrome.example.com
NAVIDROME_USERNAME=your_navidrome_username
NAVIDROME_PASSWORD=your_navidrome_password
SPOTIFY_CLIENT_ID=your_spotify_client_id
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
SPOTIFY_REDIRECT_URI=http://localhost:8888/callback

12
.gitignore vendored
View File

@@ -11,6 +11,15 @@ venv/
ENV/
env.bak/
venv.bak/
.qwen
qwen.md
.tmp/
# AI configs
11.py
.qwen/
QWEN.md
# Distribution / packaging
build/
@@ -38,6 +47,9 @@ vosk-model-*/
# VS Code
.vscode/
# Runtime state
data/music_state.json
.beads
.gitattributes

View File

@@ -1,40 +0,0 @@
# Agent Instructions
This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started.
## Quick Reference
```bash
bd ready # Find available work
bd show <id> # View issue details
bd update <id> --status in_progress # Claim work
bd close <id> # Complete work
bd sync # Sync with git
```
## Landing the Plane (Session Completion)
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
**MANDATORY WORKFLOW:**
1. **File issues for remaining work** - Create issues for anything that needs follow-up
2. **Run quality gates** (if code changed) - Tests, linters, builds
3. **Update issue status** - Close finished work, update in-progress items
4. **PUSH TO REMOTE** - This is MANDATORY:
```bash
git pull --rebase
bd sync
git push
git status # MUST show "up to date with origin"
```
5. **Clean up** - Clear stashes, prune remote branches
6. **Verify** - All changes committed AND pushed
7. **Hand off** - Provide context for next session
**CRITICAL RULES:**
- Work is NOT complete until `git push` succeeds
- NEVER stop before pushing - that leaves work stranded locally
- NEVER say "ready to push when you are" - YOU must push
- If push fails, resolve and retry until it succeeds

15
Makefile Normal file
View File

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

292
README.md
View File

@@ -1,146 +1,240 @@
# 🎙️ Alexander Smart Speaker
# Alexander Smart Speaker
<div align="center">
![Python](https://img.shields.io/badge/Python-3.9%2B-3776AB?logo=python&logoColor=white&style=for-the-badge)
![Platform](https://img.shields.io/badge/Platform-Linux-FCC624?logo=linux&logoColor=black&style=for-the-badge)
![License](https://img.shields.io/badge/License-MIT-45a163?style=for-the-badge)
Голосовой ассистент для Linux: wake word, STT/TTS, AI-диалог и полезные голосовые навыки.
**Alexander** is a personal voice assistant for Linux that leverages modern AI technologies to create natural conversations. It listens, understands context, translates languages, checks the weather, and manages your time.
[Features](#-features) • [Installation](#-installation) • [Usage](#-usage) • [Architecture](#-architecture)
[![Python 3.9+](https://img.shields.io/badge/Python-3.9%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
[![Linux](https://img.shields.io/badge/Platform-Linux-FCC624?logo=linux&logoColor=111)](https://www.linux.org/)
[![MIT License](https://img.shields.io/badge/License-MIT-2ea44f)](LICENSE.txt)
[![Wake Word](https://img.shields.io/badge/Wake%20Word-Porcupine-0b7285)](https://picovoice.ai/platform/porcupine/)
[![STT](https://img.shields.io/badge/STT-Deepgram-4c6ef5)](https://deepgram.com/)
[![TTS](https://img.shields.io/badge/TTS-Silero-7950f2)](https://github.com/snakers4/silero-models)
</div>
---
## Что это
## ✨ Features
`Alexander Smart Speaker` слушает ключевое слово `Waltron`, распознает речь, маршрутизирует команду в нужный модуль и озвучивает ответ.
Проект оптимизирован под русский язык, но поддерживает RU/EN сценарии (включая перевод и mixed-language TTS).
### 🧠 Artificial Intelligence
* **Smart Dialogue**: Context-aware conversations powered by **Perplexity AI** (Llama 3.1).
* **Translator**: Instant bidirectional translation (RU ↔ EN) with native pronunciation.
Проект собран как локальная голосовая колонка под Linux: активация по wake word, распознавание речи, маршрутизация команд, ответ через AI или встроенные модули и затем озвучка результата.
### 🗣️ Voice Interface
* **Wake Word**: Activates on the phrase **"Alexander"** (powered by Porcupine).
* **Speech Recognition**: Fast and accurate Speech-to-Text via **Deepgram**.
* **Text-to-Speech**: Natural sounding offline voice synthesis using **Silero TTS**.
## Возможности
### 🛠️ Tools
* **⛅ Weather**: Detailed forecasts (current, daily range, hourly) via Open-Meteo.
* **⏰ Alarm & Timer**: Voice-controlled alarms and timers.
* **🔊 System Control**: Adjust system volume via voice commands.
- Активация по wake word `Waltron` (Porcupine).
- Follow-up окно 4 секунды после ответа: если пользователь молчит, ассистент возвращается к ожиданию wake word.
- Распознавание речи через Deepgram (WebSocket, VAD, fast stop).
- Озвучка через Silero TTS (RU + EN, с прерыванием по wake word).
- AI-диалог через OpenRouter, OpenAI, Gemini, Z.ai и Anthropic Claude API со streaming-ответом и контекстом.
- Перевод RU -> EN и EN -> RU.
- Погода: текущий прогноз по городу по умолчанию или по названию города.
- Таймеры, будильники (включая будни/выходные), секундомеры.
- Управление громкостью системы (через `pactl`/`amixer`).
- Управление музыкой через Navidrome (приоритет) с fallback на Spotify.
- Persistent resume: `пауза`/`продолжи` продолжают с сохранённой позиции даже после перезапуска колонки.
- Мини-игра "Города".
---
## Как это работает
## ⚙️ Installation
```mermaid
flowchart TD
A[Wake Word: Waltron] --> B[STT: Deepgram]
B --> C{Маршрутизация команды}
C --> D[Feature modules]
C --> E[AI/Translation]
D --> F[TTS: Silero]
E --> F
F --> G[Follow-up режим или ожидание wake word]
```
### 1. Prerequisites
* **OS**: Linux
* **Python**: 3.9+
* **System Libraries**:
```bash
sudo apt-get install portaudio19-dev libasound2-dev mpg123
```
## Что важно в этой реализации
- Контекст диалога хранится в памяти текущей сессии, поэтому после первого вопроса можно продолжать разговор без потери нити.
- Системная роль ассистента и `ROLE_JSON` сохраняются для всех поддерживаемых AI-провайдеров.
- Для AI используется строго один активный API key. Если в `.env` оставить несколько ключей, ассистент покажет ошибку конфигурации вместо случайного выбора.
- Поддержка провайдеров сделана внутри одного модуля, но с разным форматом запросов для OpenAI-compatible API и Anthropic.
- Локальные модели через Ollama поддерживаются без API key (через OpenAI-compatible endpoint).
## Быстрый старт
### 1) Системные зависимости (Ubuntu/Debian)
### 2. Setup
```bash
# Clone the repository
git clone https://github.com/your-username/alexander_smart-speaker.git
sudo apt-get update
sudo apt-get install -y portaudio19-dev libasound2-dev mpg123 mpv pulseaudio-utils alsa-utils
```
### 2) Установка Python-зависимостей
```bash
git clone https://gitea.futuree.ru/future/alexander_smart-speaker.git
cd alexander_smart-speaker
# Create virtual environment
python -m venv venv
python3 -m venv venv
source venv/bin/activate
# Install dependencies
pip install -r requirements.txt
```
### 3. Configuration
Create a `.env` file based on the example:
### 3) Настройка `.env`
```bash
cp .env.example .env
```
Fill in your API keys in `.env`:
Минимально обязательные переменные:
```ini
# AI & Speech APIs
PERPLEXITY_API_KEY=pplx-...
AI_PROVIDER= # опционально; можно оставить пустым
# Раскомментируйте только один AI API KEY:
# OPENROUTER_API_KEY=...
# OPENAI_API_KEY=...
# GEMINI_API_KEY=...
# ZAI_API_KEY=...
# ANTHROPIC_API_KEY=...
DEEPGRAM_API_KEY=...
PORCUPINE_ACCESS_KEY=...
# TTS Settings
TTS_EN_SPEAKER=en_0
TTS_RU_SPEAKER=eugene
# Weather Location (Your City Coordinates)
WEATHER_LAT=63.56
WEATHER_LON=53.69
WEATHER_CITY=Ukhta
```
---
Если одновременно оставить несколько AI API key, ассистент вернет ошибку: он не будет выбирать провайдера наугад.
## 🚀 Usage
Пример:
```ini
# правильно
OPENAI_API_KEY=sk-...
# GEMINI_API_KEY=...
# ANTHROPIC_API_KEY=...
# неправильно
OPENAI_API_KEY=sk-...
GEMINI_API_KEY=AIza...
```
### 4) Запуск
Start the assistant:
```bash
make run
# или
python run.py
```
### Command Examples
После запуска ассистент перейдет в режим ожидания фразы `Waltron`.
| Category | User Command (RU) | Action |
|----------|-------------------|--------|
| **Activation** | "Alexander" | Assistant starts listening |
| **Dialogue** | "Почему небо голубое?" | Ask AI with context retention |
| **Weather** | "Какая сейчас погода?", "Нужен ли зонт?" | Get weather forecast |
| **Translation** | "Переведи на английский: привет, как дела?" | Translate and speak in EN |
| **Alarm** | "Разбуди меня в 7:30", "Поставь таймер на 5 минут" | Set alarm or timer |
| **Volume** | "Громкость 5", "Громкость 8" | Set system volume level |
| **Control** | "Стоп", "Хватит", "Повтори" | Stop speech or repeat last phrase |
### Кросс-платформенный аудио режим
---
- Приложение автоматически подбирает рабочий микрофон/динамик через PortAudio.
- Если основное аудио-устройство не подходит, включается fallback по другим устройствам и sample rate.
- При проблемах можно явно задать устройство через `.env` (`AUDIO_*_DEVICE_NAME` или `AUDIO_*_DEVICE_INDEX`).
## 🏗️ Architecture
## Конфигурация `.env`
```mermaid
graph TD
Mic[🎤 Microphone] --> Wake[Wake Word<br/>Porcupine]
Wake -->|Activated| STT[STT<br/>Deepgram]
STT --> Router{Command Router}
Router -->|Forecast| Weather[⛅ Weather<br/>Open-Meteo]
Router -->|Time| Alarm[⏰ Alarm/Timer]
Router -->|Settings| Vol[🔊 Volume]
Router -->|Translate| Translator[A↔B Translator]
Router -->|Query| AI[🧠 Perplexity AI]
| Переменная | Обязательно | По умолчанию | Назначение |
|---|---|---|---|
| `AI_PROVIDER` | Нет | `openrouter` | Опциональный провайдер AI (`openrouter`, `openai`, `gemini`, `zai`, `anthropic`, `ollama`; также понимает `claude`) |
| `OPENROUTER_API_KEY` | Да* | - | Ключ OpenRouter API (*если выбран OpenRouter и только этот AI ключ активен) |
| `OPENROUTER_MODEL` | Нет | `openai/gpt-4o-mini` | Модель OpenRouter |
| `OPENROUTER_API_URL` | Нет | `https://openrouter.ai/api/v1/chat/completions` | Endpoint OpenRouter Chat Completions |
| `OPENAI_API_KEY` | Да* | - | Ключ OpenAI API (*если выбран OpenAI и только этот AI ключ активен) |
| `OPENAI_MODEL` | Нет | `gpt-4o-mini` | Модель OpenAI |
| `OPENAI_API_URL` | Нет | `https://api.openai.com/v1/chat/completions` | Endpoint OpenAI Chat Completions |
| `GEMINI_API_KEY` | Да* | - | Ключ Google Gemini API (*если выбран Gemini и только этот AI ключ активен) |
| `GEMINI_MODEL` | Нет | `gemini-2.5-flash` | Модель Gemini |
| `GEMINI_API_URL` | Нет | `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions` | OpenAI-compatible endpoint Gemini |
| `ZAI_API_KEY` | Да* | - | Ключ Z.ai API (*если выбран Z.ai и только этот AI ключ активен) |
| `ZAI_MODEL` | Нет | `glm-5` | Модель Z.ai |
| `ZAI_API_URL` | Нет | `https://api.z.ai/api/paas/v4/chat/completions` | Endpoint Z.ai Chat Completions |
| `ANTHROPIC_API_KEY` | Да* | - | Ключ Anthropic API (*если выбран Anthropic и только этот AI ключ активен) |
| `ANTHROPIC_MODEL` | Нет | `claude-sonnet-4-20250514` | Модель Claude |
| `ANTHROPIC_API_URL` | Нет | `https://api.anthropic.com/v1/messages` | Endpoint Anthropic Messages API |
| `ANTHROPIC_API_VERSION` | Нет | `2023-06-01` | Версия Anthropic API |
| `OLLAMA_MODEL` | Нет | `llama3.1:8b` | Модель Ollama (локально) |
| `OLLAMA_API_URL` | Нет | `http://localhost:11434/v1/chat/completions` | OpenAI-compatible endpoint Ollama |
| `DEEPGRAM_API_KEY` | Да | - | Ключ Deepgram STT |
| `PORCUPINE_ACCESS_KEY` | Да | - | Ключ PicoVoice Porcupine |
| `PORCUPINE_SENSITIVITY` | Нет | `0.8` | Чувствительность wake word |
| `AUDIO_INPUT_DEVICE_NAME` | Нет | auto | Подстрока имени микрофона (например `pulse`), если нужно выбрать конкретный input device |
| `AUDIO_INPUT_DEVICE_INDEX` | Нет | auto | Индекс PortAudio для микрофона (приоритетнее `AUDIO_INPUT_DEVICE_NAME`) |
| `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` | Нет | `1.0` | Громкость звука старта STT (в текущей версии фиксирована на 100%) |
| `TTS_EN_SPEAKER` | Нет | `en_0` | Английский голос TTS |
| `WEATHER_LAT` | Нет | - | Широта города по умолчанию |
| `WEATHER_LON` | Нет | - | Долгота города по умолчанию |
| `WEATHER_CITY` | Нет | `Ухта` | Город по умолчанию для погоды |
| `NAVIDROME_URL` | Нет | - | URL Navidrome (например `https://navidrome.example.com`) |
| `NAVIDROME_USERNAME` | Нет | - | Логин Navidrome |
| `NAVIDROME_PASSWORD` | Нет | - | Пароль Navidrome |
| `SPOTIFY_CLIENT_ID` | Нет | - | Spotify OAuth Client ID |
| `SPOTIFY_CLIENT_SECRET` | Нет | - | Spotify OAuth Client Secret |
| `SPOTIFY_REDIRECT_URI` | Нет | `http://localhost:8888/callback` | Redirect URI для Spotify |
Weather --> TTS
Alarm --> TTS
Vol --> TTS
Translator --> TTS
AI --> Cleaner[Text Cleaner]
Cleaner --> TTS[🗣️ TTS<br/>Silero]
## Примеры голосовых команд
TTS --> Speaker[🔊 Speaker]
| Категория | Примеры |
|---|---|
| Активация | `Waltron` |
| AI-диалог | `Почему небо голубое?` |
| Перевод | `Переведи на английский: как дела` |
| Погода | `Какая погода?`, `Погода в Москве` |
| Таймер | `Поставь таймер на 5 минут` |
| Будильник | `Поставь будильник на 7:30`, `Будильник по будням в 8:00` |
| Секундомер | `Запусти секундомер`, `Покажи активные секундомеры` |
| Громкость | `Громкость 7` |
| Музыка (Navidrome first) | `Включи музыку`, `Пауза`, `Продолжи`, `Следующий`, `Предыдущий`, `Что играет`, `Включи жанр electronic`, `Включи папку crystal castles` |
| Игра | `Давай сыграем в города` |
| Управление диалогом | `Повтори`, `Стоп`, `Хватит` |
Память текущего диалога, история сообщений и `ROLE_JSON` системной роли сохраняются для всех поддерживаемых AI-провайдеров.
## Как Выбирается AI-Провайдер
1. Приложение проверяет, какие AI API key реально активны в `.env`.
2. Если активен ровно один ключ, используется именно он.
3. Если активны несколько ключей, ассистент возвращает ошибку конфигурации.
4. Если активных ключей нет, приложение ориентируется на `AI_PROVIDER`, но без ключа работать не сможет.
Такое поведение сделано специально, чтобы конфигурация была предсказуемой и при демонстрации не возникало скрытого переключения между сервисами.
## Полезные команды
| Команда | Что делает |
|---|---|
| `make run` | Запуск ассистента |
| `make check` | Локальная проверка проекта (`scripts/qwen-check.sh`) |
| `make qwen-context` | Сбор контекста проекта (`scripts/qwen-context.sh`) |
## Структура проекта
```text
alexander_smart-speaker/
├── run.py
├── app/
│ ├── main.py
│ ├── audio/ # wakeword, stt, tts, volume
│ ├── core/ # config, ai, command helpers, cleaner
│ └── features/ # weather, timer, alarm, stopwatch, music, cities game
├── assets/
│ ├── models/ # Porcupine keyword model (.ppn)
│ └── sounds/ # звуки уведомлений и будильника
├── data/ # persisted JSON: alarms, timers, stopwatches
└── scripts/
```
## 📂 Project Structure
* `app/main.py` — Entry point, main event loop.
* `app/audio/` — Audio processing modules (STT, TTS, Wake Word).
* `app/core/` — AI logic, configuration, text cleaning.
* `app/features/` — Skills (Weather, Alarm, Timer).
* `assets/` — Models (Porcupine) and sound effects.
* `data/` — Persistent state (alarms).
## Диагностика
---
| Проблема | Что проверить |
|---|---|
| Не реагирует на `Waltron` | `PORCUPINE_ACCESS_KEY`, микрофон, чувствительность `PORCUPINE_SENSITIVITY` |
| STT не распознает речь | `DEEPGRAM_API_KEY`, сетевой доступ, выбранный микрофон |
| Нет звука | корректное аудиоустройство и доступность `pactl`/`amixer` |
| `Audio input/output initialization failed` | проверить, что звук-сервер запущен (PipeWire/PulseAudio), и при необходимости задать `AUDIO_INPUT_DEVICE_NAME`/`AUDIO_OUTPUT_DEVICE_NAME` |
| Будильник/таймер не звонит | наличие `mpg123` в системе |
| Ошибка про несколько AI API | в `.env` должен остаться только один незакомментированный AI ключ |
| Navidrome не воспроизводит | заполнены `NAVIDROME_*`, доступен `NAVIDROME_URL`, установлен `mpv` |
| Fallback ушёл в Spotify | проверить доступность Navidrome, SSL и корректность `NAVIDROME_USERNAME`/`NAVIDROME_PASSWORD` |
| Spotify не управляется | заполнены `SPOTIFY_*`, есть активное устройство, Premium-аккаунт |
## 🛠️ Troubleshooting
* **Deepgram Error 400**: Check your API key balance and validity in `.env`.
* **No Sound**: Ensure `amixer` is installed and the default audio output is correctly configured in your OS.
* **Alarm not playing**: Verify that `mpg123` is installed (`sudo apt install mpg123`).
## Лицензия
## 📄 License
MIT License. See `LICENSE.txt` for details.
Проект распространяется по лицензии MIT. См. `LICENSE.txt`.

View File

@@ -9,21 +9,59 @@ Regulates system volume on a scale from 1 to 10.
import subprocess
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):
@@ -148,16 +186,25 @@ def parse_volume_text(text: str) -> int | None:
Пытается найти число громкости в тексте.
Понимает и цифры ("5"), и слова ("пять").
"""
text = 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

@@ -11,6 +11,8 @@ import asyncio
import time
import pyaudio
import logging
import contextlib
import threading
from datetime import datetime, timedelta
from ..core.config import DEEPGRAM_API_KEY, SAMPLE_RATE
from deepgram import (
@@ -22,18 +24,27 @@ 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 ---
# По умолчанию Deepgram SDK использует слишком короткий таймаут подключения.
# Это часто вызывает ошибки при медленном SSL рукопожатии.
# Мы подменяем функцию connect, чтобы увеличить таймаут до 30 секунд.
# Явно задаём таймауты подключения, чтобы не зависать на долгом handshake.
_original_connect = websockets.sync.client.connect
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
DEEPGRAM_FINALIZE_TIMEOUT_SECONDS = 1.5
DEEPGRAM_FINALIZATION_GRACE_SECONDS = 0.35
DEEPGRAM_FINISH_TIMEOUT_SECONDS = 4.0
def _patched_connect(*args, **kwargs):
kwargs.setdefault("open_timeout", 30)
kwargs.setdefault("ping_timeout", 30)
kwargs.setdefault("close_timeout", 30)
# Принудительно задаём короткие таймауты, даже если SDK передал свои (например, 30с).
kwargs["open_timeout"] = DEEPGRAM_CONNECT_TIMEOUT_SECONDS
kwargs["ping_timeout"] = DEEPGRAM_CONNECT_TIMEOUT_SECONDS
kwargs["close_timeout"] = DEEPGRAM_CONNECT_TIMEOUT_SECONDS
print(f"DEBUG: Connecting to Deepgram with timeout={kwargs.get('open_timeout')}s")
return _original_connect(*args, **kwargs)
@@ -44,6 +55,12 @@ sdk_ws.connect = _patched_connect
# Отключаем лишний мусор в логах
logging.getLogger("deepgram").setLevel(logging.WARNING)
# Базовые пороги для остановки STT
INITIAL_SILENCE_TIMEOUT_SECONDS = 5.0
POST_SPEECH_SILENCE_TIMEOUT_SECONDS = 2.0
# Длинный защитный предел, чтобы не обрывать обычную длинную фразу.
# Фактическое завершение происходит примерно после 2.0 сек тишины после речи.
MAX_ACTIVE_SPEECH_SECONDS = 300.0
class SpeechRecognizer:
"""Класс распознавания речи через Deepgram."""
@@ -51,9 +68,12 @@ class SpeechRecognizer:
def __init__(self):
self.dg_client = None
self.pa = None
self.audio_manager = None
self.stream = None
self.transcript = ""
self.last_successful_operation = datetime.now()
self._input_device_index = None
self._stream_sample_rate = SAMPLE_RATE
def initialize(self):
"""Инициализация клиента Deepgram и PyAudio."""
@@ -70,7 +90,9 @@ class SpeechRecognizer:
print(f"❌ Ошибка при создании клиента Deepgram: {e}")
raise
self.pa = get_audio_manager().get_pyaudio()
self.audio_manager = get_audio_manager()
self.pa = self.audio_manager.get_pyaudio()
self._input_device_index = self.audio_manager.get_input_device_index()
print("✅ Deepgram клиент готов")
# Обновляем время последней успешной операции
self.last_successful_operation = datetime.now()
@@ -96,65 +118,243 @@ class SpeechRecognizer:
def _get_stream(self):
"""Открывает аудиопоток PyAudio, если он еще не открыт."""
if self.stream is None:
self.stream = self.pa.open(
rate=SAMPLE_RATE,
channels=1,
format=pyaudio.paInt16,
input=True,
frames_per_buffer=4096,
if self.audio_manager is None:
self.audio_manager = get_audio_manager()
self.stream, self._input_device_index, self._stream_sample_rate = (
self.audio_manager.open_input_stream(
rate=SAMPLE_RATE,
channels=1,
format=pyaudio.paInt16,
frames_per_buffer=4096,
preferred_index=self._input_device_index,
fallback_rates=[48000, 44100, 32000, 22050, 16000, 8000],
)
)
if self._stream_sample_rate != SAMPLE_RATE:
print(
f"⚠️ STT mic stream uses fallback rate={self._stream_sample_rate} "
f"(requested {SAMPLE_RATE})"
)
return self.stream
async def _process_audio(self, dg_connection, timeout_seconds, detection_timeout):
def _open_stream_for_session(self):
"""Открывает отдельный входной поток для одной STT-сессии."""
if self.audio_manager is None:
self.audio_manager = get_audio_manager()
stream, self._input_device_index, sample_rate = self.audio_manager.open_input_stream(
rate=SAMPLE_RATE,
channels=1,
format=pyaudio.paInt16,
frames_per_buffer=4096,
preferred_index=self._input_device_index,
fallback_rates=[48000, 44100, 32000, 22050, 16000, 8000],
)
if sample_rate != SAMPLE_RATE:
print(
f"⚠️ STT mic stream uses fallback rate={sample_rate} "
f"(requested {SAMPLE_RATE})"
)
return stream, int(sample_rate)
def _stop_stream_quietly(self):
if not self.stream:
return
try:
if self.stream.is_active():
self.stream.stop_stream()
except Exception:
pass
def _release_stream(self):
if not self.stream:
return
self._stop_stream_quietly()
try:
self.stream.close()
except Exception:
pass
self.stream = None
async def _wait_for_thread(self, thread, timeout_seconds: float) -> bool:
"""Асинхронно ждет завершения daemon-thread без блокировки event loop."""
deadline = time.monotonic() + timeout_seconds
while thread.is_alive() and time.monotonic() < deadline:
await asyncio.sleep(0.05)
return not thread.is_alive()
async def _run_blocking_cleanup(
self, func, timeout_seconds: float, label: str, quiet: bool = False
) -> bool:
"""Запускает потенциально подвисающий cleanup в daemon-thread и ждет ограниченное время."""
done_event = threading.Event()
error_holder = {}
def runner():
try:
func()
except Exception as exc:
error_holder["error"] = exc
finally:
done_event.set()
thread = threading.Thread(target=runner, daemon=True, name=label)
thread.start()
deadline = time.monotonic() + timeout_seconds
while not done_event.is_set() and time.monotonic() < deadline:
await asyncio.sleep(0.05)
if not done_event.is_set():
if not quiet:
print(f"⚠️ {label} timed out; continuing cleanup.")
return False
error = error_holder.get("error")
if error is not None:
if not quiet:
print(f"⚠️ {label} failed: {error}")
return False
return True
def _run_blocking_cleanup_sync(
self, func, timeout_seconds: float, label: str, quiet: bool = False
) -> bool:
"""Sync-версия _run_blocking_cleanup() для use-case в listen()."""
done_event = threading.Event()
error_holder = {}
def runner():
try:
func()
except Exception as exc:
error_holder["error"] = exc
finally:
done_event.set()
thread = threading.Thread(target=runner, daemon=True, name=label)
thread.start()
done_event.wait(timeout=max(0.0, float(timeout_seconds)))
if not done_event.is_set():
if not quiet:
print(f"⚠️ {label} timed out; continuing cleanup.")
return False
error = error_holder.get("error")
if error is not None:
if not quiet:
print(f"⚠️ {label} failed: {error}")
return False
return True
async def _process_audio(
self, dg_connection, timeout_seconds, detection_timeout, fast_stop
):
"""
Асинхронная функция для отправки аудио и получения текста.
Args:
dg_connection: Активное соединение с Deepgram.
timeout_seconds: Общее время прослушивания.
timeout_seconds: Аварийный лимит длительности активной речи.
detection_timeout: Время ожидания начала речи.
fast_stop: Если True, короткие системные команды завершают STT раньше.
"""
self.transcript = ""
transcript_parts = []
latest_interim = ""
loop = asyncio.get_running_loop()
stream = self._get_stream()
effective_detection_timeout = (
detection_timeout
if detection_timeout is not None
else INITIAL_SILENCE_TIMEOUT_SECONDS
)
# События для синхронизации
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}
def mark_speech_activity():
nonlocal last_speech_activity, first_speech_activity_at
now = time.monotonic()
last_speech_activity = now
if first_speech_activity_at is None:
first_speech_activity_at = now
speech_started_event.set()
def mark_session_error(message: str):
if not session_error["message"]:
session_error["message"] = str(message)
def is_benign_disconnect(message: str) -> bool:
if not message:
return False
lowered = message.lower()
return (
"connectionclosed" in lowered
or "code 1006" in lowered
or "no_close_frame" in lowered
or "websocket" in lowered
)
# --- Обработчики событий Deepgram ---
def on_transcript(unused_self, result, **kwargs):
"""Вызывается, когда приходит часть текста."""
nonlocal latest_interim
sentence = result.channel.alternatives[0].transcript
if len(sentence) == 0:
return
sentence = sentence.strip()
if not sentence:
return
try:
loop.call_soon_threadsafe(mark_speech_activity)
except RuntimeError:
pass
if fast_stop and is_fast_command(sentence):
self.transcript = sentence
try:
loop.call_soon_threadsafe(request_stop)
except RuntimeError:
pass
return
if result.is_final:
# Собираем только финальные (подтвержденные) фразы
transcript_parts.append(sentence)
self.transcript = " ".join(transcript_parts).strip()
latest_interim = ""
else:
# Fallback: некоторые сессии завершаются без is_final.
latest_interim = sentence
def on_speech_started(unused_self, speech_started, **kwargs):
"""Вызывается, когда VAD (Voice Activity Detection) слышит голос."""
try:
loop.call_soon_threadsafe(speech_started_event.set)
loop.call_soon_threadsafe(mark_speech_activity)
except RuntimeError:
# Event loop might be closed, ignore
pass
def on_utterance_end(unused_self, utterance_end, **kwargs):
"""Вызывается, когда Deepgram решает, что фраза закончилась (пауза)."""
try:
loop.call_soon_threadsafe(stop_event.set)
except RuntimeError:
# Event loop might be closed, ignore
pass
# Не останавливаемся мгновенно на событии Deepgram.
# Остановка управляется локальным порогом тишины POST_SPEECH_SILENCE_TIMEOUT_SECONDS.
return
def on_error(unused_self, error, **kwargs):
if stop_event.is_set():
return
print(f"Deepgram Error: {error}")
try:
loop.call_soon_threadsafe(stop_event.set)
loop.call_soon_threadsafe(request_stop)
except RuntimeError:
# Event loop might be closed, ignore
pass
@@ -165,27 +365,36 @@ class SpeechRecognizer:
dg_connection.on(LiveTranscriptionEvents.UtteranceEnd, on_utterance_end)
dg_connection.on(LiveTranscriptionEvents.Error, on_error)
# Параметры распознавания
options = LiveOptions(
model="nova-2", # Самая быстрая и точная модель
language=self.current_lang,
smart_format=True, # Расстановка знаков препинания
encoding="linear16",
channels=1,
sample_rate=SAMPLE_RATE,
interim_results=True,
utterance_end_ms=1000, # Пауза 1.0с считается концом фразы (было 1.2)
vad_events=True,
# Добавляем параметры таймаута для долгой работы
endpointing=300, # Таймаут в миллисекундах для автоматического завершения
)
# --- Задача отправки аудио с буферизацией ---
async def send_audio():
sender_stop_event = threading.Event()
stream_holder = {"stream": None}
def request_stop():
stop_event.set()
sender_stop_event.set()
def send_audio():
chunks_sent = 0
audio_buffer = [] # Буфер для накопления звука во время подключения
stream = None
try:
stream, stream_sample_rate = self._open_stream_for_session()
stream_holder["stream"] = stream
options = LiveOptions(
model="nova-2", # Самая быстрая и точная модель
language=self.current_lang,
smart_format=True, # Расстановка знаков препинания
encoding="linear16",
channels=1,
sample_rate=stream_sample_rate,
interim_results=True,
utterance_end_ms=int(POST_SPEECH_SILENCE_TIMEOUT_SECONDS * 1000),
vad_events=True,
# Сглаженный порог endpointing, чтобы не резать речь на коротких паузах.
endpointing=int(POST_SPEECH_SILENCE_TIMEOUT_SECONDS * 1000),
)
# 1. Сразу начинаем захват звука, не дожидаясь сети!
stream.start_stream()
print("🎤 Stream started (buffering)...")
@@ -193,31 +402,75 @@ class SpeechRecognizer:
# 2. Запускаем подключение к Deepgram в фоне (через ThreadPool, т.к. start() блокирующий)
# Но в данном SDK start() возвращает bool, он может быть блокирующим.
# Deepgram Python SDK v3+ start() делает handshake.
connect_result = {"done": False, "ok": None, "error": None}
connect_future = loop.run_in_executor(
None, lambda: dg_connection.start(options)
def start_connection():
try:
connect_result["ok"] = dg_connection.start(options)
except Exception as exc:
connect_result["error"] = exc
finally:
connect_result["done"] = True
connect_thread = threading.Thread(
target=start_connection, daemon=True
)
connect_thread.start()
# Пока подключаемся, копим данные
timeout_count = 0
max_timeout = 5000 # Максимальное количество итераций ожидания (около 2.5 секунд при 0.0005 задержке)
while not connect_future.done() and timeout_count < max_timeout:
# Пока подключаемся, копим данные.
# Ждём коротко: если сеть подвисла, быстрее перезапускаем попытку.
connect_deadline = time.monotonic() + DEEPGRAM_CONNECT_WAIT_SECONDS
while (
not connect_result["done"]
and time.monotonic() < connect_deadline
and not sender_stop_event.is_set()
):
if stream.is_active():
data = stream.read(4096, exception_on_overflow=False)
try:
data = stream.read(4096, exception_on_overflow=False)
except Exception as read_error:
if sender_stop_event.is_set():
return
mark_session_error(f"Audio read error during connect: {read_error}")
print(f"Audio read error during connect: {read_error}")
with contextlib.suppress(RuntimeError):
loop.call_soon_threadsafe(request_stop)
return
audio_buffer.append(data)
await asyncio.sleep(0.0005) # Уменьшаем задержку для более быстрой обработки
timeout_count += 1
time.sleep(DEEPGRAM_CONNECT_POLL_SECONDS)
if timeout_count >= max_timeout:
print("⏰ Timeout connecting to Deepgram")
if sender_stop_event.is_set():
return
if not connect_result["done"]:
mark_session_error(
f"Timeout connecting to Deepgram ({DEEPGRAM_CONNECT_WAIT_SECONDS:.1f}s)"
)
print(
f"⏰ Timeout connecting to Deepgram ({DEEPGRAM_CONNECT_WAIT_SECONDS:.1f}s)"
)
connection_failed_event.set()
loop.call_soon_threadsafe(request_stop)
return
# Проверяем результат подключения
if connect_future.result() is False:
print("Failed to start Deepgram connection")
if connect_result["error"] is not None:
mark_session_error(
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. Отправляем накопленный буфер
@@ -227,80 +480,228 @@ class SpeechRecognizer:
audio_buffer = None # Освобождаем память
# 4. Продолжаем стримить в реальном времени
stream_timeout = 0
max_stream_timeout = int(timeout_seconds / 0.002) # Примерный таймаут в зависимости от timeout_seconds
while not stop_event.is_set() and stream_timeout < max_stream_timeout:
if stream.is_active():
# 4. Продолжаем стримить в реальном времени до события остановки.
while not sender_stop_event.is_set():
if not stream.is_active():
break
try:
data = stream.read(4096, exception_on_overflow=False)
dg_connection.send(data)
chunks_sent += 1
if chunks_sent % 50 == 0:
print(".", end="", flush=True)
await asyncio.sleep(0.002) # Уменьшаем задержку для более быстрого реагирования
stream_timeout += 1
except Exception as read_error:
if sender_stop_event.is_set():
break
mark_session_error(f"Audio read error: {read_error}")
print(f"Audio read error: {read_error}")
with contextlib.suppress(RuntimeError):
loop.call_soon_threadsafe(request_stop)
break
if sender_stop_event.is_set():
break
dg_connection.send(data)
chunks_sent += 1
if chunks_sent % 50 == 0:
print(".", end="", flush=True)
time.sleep(0.002) # Уменьшаем задержку для более быстрого реагирования
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:
if stream.is_active():
stream.stop_stream()
with contextlib.suppress(Exception):
if stream and stream.is_active():
stream.stop_stream()
with contextlib.suppress(Exception):
if stream:
stream.close()
stream_holder["stream"] = None
print(f"\n🛑 Stream stopped. Chunks sent: {chunks_sent}")
sender_task = asyncio.create_task(send_audio())
sender_thread = threading.Thread(
target=send_audio,
daemon=True,
name="deepgram-audio-sender",
)
sender_thread.start()
if False: # dg_connection.start(options) перенесен внутрь send_audio
pass
try:
# 1. Ждем начала речи (если задан detection_timeout)
if detection_timeout:
try:
await asyncio.wait_for(
speech_started_event.wait(), timeout=detection_timeout
)
except asyncio.TimeoutError:
# Если за detection_timeout никто не начал говорить, выходим
stop_event.set()
if (
effective_detection_timeout
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)
# 2. Если речь началась (или таймаута нет), ждем завершения (stop_event)
# stop_event сработает либо по UtteranceEnd (пауза), либо по общему таймауту
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:
done, pending = await asyncio.wait(
{speech_wait_task, stop_wait_task},
timeout=effective_detection_timeout,
return_when=asyncio.FIRST_COMPLETED,
)
finally:
for task in (speech_wait_task, stop_wait_task):
if not task.done():
task.cancel()
await asyncio.gather(
speech_wait_task, stop_wait_task, return_exceptions=True
)
if not done:
# Если за detection_timeout после поднятия WS никто не начал говорить, выходим.
request_stop()
# 2. После старта речи завершаем только по тишине POST_SPEECH_SILENCE_TIMEOUT_SECONDS.
# Добавляем длинный защитный лимит, чтобы сессия не зависла навсегда.
if not stop_event.is_set():
await asyncio.wait_for(stop_event.wait(), timeout=timeout_seconds)
max_active_speech_seconds = max(
timeout_seconds if timeout_seconds else 0.0,
MAX_ACTIVE_SPEECH_SECONDS,
)
while not stop_event.is_set():
now = time.monotonic()
if speech_started_event.is_set():
if (
now - last_speech_activity
>= POST_SPEECH_SILENCE_TIMEOUT_SECONDS
):
request_stop()
break
if (
first_speech_activity_at is not None
and now - first_speech_activity_at
>= max_active_speech_seconds
):
print("⏱️ Достигнут защитный лимит активного прослушивания.")
request_stop()
break
await asyncio.sleep(0.05)
except asyncio.TimeoutError:
pass # Общий таймаут вышел
except Exception as e:
print(f"Error in waiting for events: {e}")
stop_event.set()
try:
await sender_task
except Exception as e:
print(f"Error waiting for sender task: {e}")
request_stop()
heard_speech = speech_started_event.is_set()
sender_stopped = await self._wait_for_thread(
sender_thread,
timeout_seconds=max(SENDER_STOP_WAIT_SECONDS, SENDER_FORCE_RELEASE_WAIT_SECONDS),
)
cleanup_unhealthy = False
if not sender_stopped:
def force_close_stream():
stream = stream_holder.get("stream")
if not stream:
return
with contextlib.suppress(Exception):
if stream.is_active():
stream.stop_stream()
with contextlib.suppress(Exception):
stream.close()
stream_holder["stream"] = None
await self._run_blocking_cleanup(
force_close_stream,
timeout_seconds=SENDER_FORCE_RELEASE_WAIT_SECONDS,
label="STT audio stream force close",
quiet=True,
)
# Дадим шанс потоку выйти после принудительного закрытия.
sender_stopped = await self._wait_for_thread(sender_thread, timeout_seconds=0.6)
if not sender_stopped:
cleanup_unhealthy = True
# Сначала мягко просим Deepgram дослать хвост распознавания.
if heard_speech:
await self._run_blocking_cleanup(
dg_connection.finalize,
timeout_seconds=DEEPGRAM_FINALIZE_TIMEOUT_SECONDS,
label="Deepgram finalize",
quiet=True,
)
await asyncio.sleep(DEEPGRAM_FINALIZATION_GRACE_SECONDS)
# Завершаем соединение и ждем последние результаты
try:
dg_connection.finish()
except Exception as e:
print(f"Error finishing connection: {e}")
finish_ok = await self._run_blocking_cleanup(
dg_connection.finish,
timeout_seconds=DEEPGRAM_FINISH_TIMEOUT_SECONDS,
label="Deepgram finish",
quiet=True,
)
if not finish_ok:
cleanup_unhealthy = True
return self.transcript
final_text = self.transcript.strip()
if not final_text:
final_text = latest_interim.strip()
self.transcript = final_text
if session_error["message"] and not final_text:
# Частый случай после музыки: соединение Deepgram закрывается (1006)
# до начала речи. Это штатное завершение, не ошибка.
if not heard_speech and is_benign_disconnect(session_error["message"]):
return ""
raise RuntimeError(session_error["message"])
if cleanup_unhealthy:
# Если cleanup подвис, не валим текущую команду и не запускаем ложный retry.
# Просто пересоздаем клиента перед следующим прослушиванием.
self.dg_client = None
return final_text
def listen(
self,
timeout_seconds: float = 7.0,
detection_timeout: float = None,
detection_timeout: float = INITIAL_SILENCE_TIMEOUT_SECONDS,
lang: str = "ru",
fast_stop: bool = False,
) -> str:
"""
Основной метод: слушает микрофон и возвращает текст.
Args:
timeout_seconds: Максимальная длительность фразы.
timeout_seconds: Защитный лимит длительности активной речи.
detection_timeout: Сколько ждать начала речи перед тем как сдаться.
lang: Язык ("ru" или "en").
fast_stop: Быстрое завершение для коротких системных команд.
"""
if not self.dg_client:
self.initialize()
@@ -323,7 +724,7 @@ class SpeechRecognizer:
# Запускаем асинхронный процесс обработки
transcript = asyncio.run(
self._process_audio(
dg_connection, timeout_seconds, detection_timeout
dg_connection, timeout_seconds, detection_timeout, fast_stop
)
)
final_text = transcript.strip() if transcript else ""
@@ -345,10 +746,21 @@ class SpeechRecognizer:
# Закрываем соединение, если оно было создано
if dg_connection:
try:
dg_connection.finish()
self._run_blocking_cleanup_sync(
dg_connection.finish,
timeout_seconds=DEEPGRAM_FINISH_TIMEOUT_SECONDS,
label="Deepgram finish (error cleanup)",
quiet=True,
)
except:
pass # Игнорируем ошибки при завершении
# Принудительно сбрасываем клиента, чтобы след. попытка не унаследовала
# подвисшее соединение SDK.
self.dg_client = None
with contextlib.suppress(Exception):
self.initialize()
if attempt < 2: # Не ждем после последней попытки
print(f"⚠️ Не удалось подключиться к Deepgram, попытка {attempt + 1}/3, повторяю...")
time.sleep(1) # Уменьшаем задержку между попытками
@@ -389,10 +801,13 @@ def get_recognizer() -> SpeechRecognizer:
def listen(
timeout_seconds: float = 7.0, detection_timeout: float = None, lang: str = "ru"
timeout_seconds: float = 7.0,
detection_timeout: float = INITIAL_SILENCE_TIMEOUT_SECONDS,
lang: str = "ru",
fast_stop: bool = False,
) -> str:
"""Внешняя функция для прослушивания."""
return get_recognizer().listen(timeout_seconds, detection_timeout, lang)
return get_recognizer().listen(timeout_seconds, detection_timeout, lang, fast_stop)
def cleanup():

View File

@@ -6,7 +6,7 @@ Supports interruption via wake word detection using threading.
# Модуль синтеза речи (TTS - Text-to-Speech).
# Использует нейросеть Silero TTS для качественной русской речи.
# Также поддерживает прерывание речи, если пользователь скажет "Alexandr".
# Также поддерживает прерывание речи по wake word.
import re
import threading
@@ -14,15 +14,19 @@ import time
import warnings
import numpy as np
import pyaudio
import sounddevice as sd
import torch
from ..core.config import TTS_EN_SPEAKER, TTS_SAMPLE_RATE, TTS_SPEAKER
from ..core.audio_manager import get_audio_manager
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:
@@ -32,10 +36,30 @@ 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
self._stop_flag = threading.Event()
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):
"""
@@ -48,21 +72,12 @@ 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",
language="en",
speaker="v3_en",
)
model, _ = torch.hub.load(
repo_or_dir="snakers4/silero-models",
model="silero_tts",
language="en",
speaker="v3_en",
)
model.to(device)
self.model_en = model
return model
@@ -181,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)
@@ -229,17 +223,16 @@ class TextToSpeech:
)
# Конвертация в numpy массив для sounddevice
audio_np = audio.numpy()
audio_np = self._apply_speed(audio.numpy())
if check_interrupt:
# Воспроизведение с проверкой прерывания (сложная логика)
if not self._play_with_interrupt(audio_np, check_interrupt):
if not self._play_audio_with_interrupt(audio_np, check_interrupt):
success = False
break
else:
# Обычное воспроизведение (блокирующее)
sd.play(audio_np, self.sample_rate)
sd.wait()
if not self._play_audio_blocking(audio_np):
success = False
break
except Exception as e:
print(f"❌ Ошибка TTS (часть {i + 1}/{total_chunks}): {e}")
@@ -253,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
@@ -283,6 +370,9 @@ class TextToSpeech:
if not text.strip():
return True
if check_interrupt is None:
check_interrupt = self._default_interrupt_checker()
if language == "ru":
text = self._preprocess_text(text)
segments = self._split_mixed_language(text)
@@ -293,6 +383,83 @@ class TextToSpeech:
text, check_interrupt=check_interrupt, language=language
)
def _default_interrupt_checker(self):
try:
from .wakeword import check_wakeword_once
return check_wakeword_once
except Exception:
return None
def _resample_audio(self, audio_np: np.ndarray, src_rate: int, dst_rate: int):
if src_rate == dst_rate:
return audio_np.astype(np.float32, copy=False)
if audio_np.size == 0:
return np.asarray([], dtype=np.float32)
target_length = max(1, int(round(audio_np.size * dst_rate / src_rate)))
x_old = np.arange(audio_np.size, dtype=np.float32)
x_new = np.linspace(0.0, float(max(0, audio_np.size - 1)), target_length)
resampled = np.interp(x_new, x_old, audio_np.astype(np.float32))
return np.asarray(resampled, dtype=np.float32)
def _play_audio_blocking(self, audio_np: np.ndarray) -> bool:
try:
sd.play(audio_np, self.sample_rate)
sd.wait()
return True
except Exception as exc:
print(f"⚠️ sounddevice playback failed, fallback to PyAudio: {exc}")
return self._play_with_pyaudio(audio_np, check_interrupt=None)
def _play_audio_with_interrupt(self, audio_np: np.ndarray, check_interrupt) -> bool:
try:
return self._play_with_interrupt_sounddevice(audio_np, check_interrupt)
except Exception as exc:
print(
"⚠️ sounddevice playback-with-interrupt failed, fallback to PyAudio: "
f"{exc}"
)
return self._play_with_pyaudio(audio_np, check_interrupt=check_interrupt)
def _play_with_pyaudio(self, audio_np: np.ndarray, check_interrupt=None) -> bool:
if self._audio_manager is None:
self._audio_manager = get_audio_manager()
output_stream = None
try:
output_stream, self._output_device_index, out_rate = (
self._audio_manager.open_output_stream(
rate=self.sample_rate,
channels=1,
format=pyaudio.paFloat32,
preferred_index=self._output_device_index,
fallback_rates=[48000, 44100, 32000, 22050],
)
)
pcm = self._resample_audio(audio_np, self.sample_rate, out_rate)
chunk_size = max(256, int(out_rate * 0.03))
for offset in range(0, len(pcm), chunk_size):
if check_interrupt and check_interrupt():
self._interrupted = True
return False
output_stream.write(pcm[offset : offset + chunk_size].tobytes())
return True
except Exception as exc:
print(f"❌ PyAudio playback failed: {exc}")
return False
finally:
if output_stream is not None:
try:
output_stream.stop_stream()
except Exception:
pass
try:
output_stream.close()
except Exception:
pass
def _check_interrupt_worker(self, check_interrupt):
"""
Фоновая функция для потока: постоянно опрашивает check_interrupt.
@@ -307,8 +474,11 @@ class TextToSpeech:
return
except Exception:
pass
time.sleep(_INTERRUPT_POLL_SECONDS)
def _play_with_interrupt(self, audio_np: np.ndarray, check_interrupt) -> bool:
def _play_with_interrupt_sounddevice(
self, audio_np: np.ndarray, check_interrupt
) -> bool:
"""
Воспроизводит аудио, параллельно проверяя условие прерывания в отдельном потоке.
"""
@@ -322,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

@@ -1,15 +1,35 @@
"""
Wake word detection module using Porcupine.
Listens for the "Alexandr" wake word.
Listens for the configured wake word.
"""
# Этот модуль отвечает за "уши" ассистента в режиме ожидания.
# Он использует библиотеку Porcupine для эффективного (мало CPU) обнаружения ключевой фразы "Alexandr".
# Он использует библиотеку Porcupine для эффективного (мало CPU) обнаружения ключевой фразы.
import pvporcupine
import pyaudio
import struct
from ..core.config import PORCUPINE_ACCESS_KEY, PORCUPINE_KEYWORD_PATH
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
@@ -20,20 +40,44 @@ class WakeWordDetector:
self.porcupine = None
self.audio_stream = None
self.pa = None
self._audio_manager = None
self._input_device_index = None
self._capture_sample_rate = None
self._capture_frame_length = None
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."""
# Создаем экземпляр Porcupine с нашим ключом доступа и файлом модели (.ppn)
self.porcupine = pvporcupine.create(
access_key=PORCUPINE_ACCESS_KEY, keyword_paths=[str(PORCUPINE_KEYWORD_PATH)]
access_key=PORCUPINE_ACCESS_KEY,
keyword_paths=[str(PORCUPINE_KEYWORD_PATH)],
sensitivities=[PORCUPINE_SENSITIVITY],
)
# Используем общий экземпляр PyAudio
self.pa = get_audio_manager().get_pyaudio()
self._audio_manager = get_audio_manager()
self.pa = self._audio_manager.get_pyaudio()
self._open_stream()
print("🎤 Ожидание wake word 'Alexandr'...")
print(
f"🎤 Ожидание wake word '{WAKE_WORD}' "
f"(sens={PORCUPINE_SENSITIVITY:.2f}, mic_rate={self._capture_sample_rate})..."
)
def _open_stream(self):
"""Открытие аудиопотока с микрофона."""
@@ -47,15 +91,234 @@ class WakeWordDetector:
except Exception:
pass
# Открываем поток с параметрами, которые требует Porcupine
self.audio_stream = self.pa.open(
rate=self.porcupine.sample_rate,
target_rate = int(self.porcupine.sample_rate)
fallback_rates = [48000, 44100, 32000, 22050, 16000]
self.audio_stream, self._input_device_index, actual_rate = self._audio_manager.open_input_stream(
rate=target_rate,
channels=1,
format=pyaudio.paInt16,
input=True,
frames_per_buffer=self.porcupine.frame_length,
preferred_index=self._input_device_index,
fallback_rates=fallback_rates,
)
self._capture_sample_rate = int(actual_rate)
self._capture_frame_length = max(
64,
int(
round(
self.porcupine.frame_length
* self._capture_sample_rate
/ target_rate
)
),
)
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):
"""Явная остановка и закрытие потока (чтобы освободить микрофон для других задач)."""
@@ -66,10 +329,46 @@ 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)
source_rate = int(self._capture_sample_rate or target_rate)
if source_rate == target_rate:
return pcm
if pcm.size == 0:
return np.array([], dtype=np.int16)
target_length = max(1, int(round(pcm.size * target_rate / source_rate)))
x_old = np.arange(pcm.size, dtype=np.float32)
x_new = np.linspace(0.0, float(max(0, pcm.size - 1)), target_length)
resampled = np.interp(x_new, x_old, pcm.astype(np.float32))
return np.asarray(resampled, dtype=np.int16)
def _read_porcupine_frame(self):
target_length = int(self.porcupine.frame_length)
if self._capture_sample_rate == self.porcupine.sample_rate:
pcm = self.audio_stream.read(target_length, exception_on_overflow=False)
return np.asarray(struct.unpack_from("h" * target_length, pcm), dtype=np.int16)
while self._resampled_pcm_buffer.size < target_length:
raw = self.audio_stream.read(
self._capture_frame_length, exception_on_overflow=False
)
captured = np.frombuffer(raw, dtype=np.int16)
converted = self._resample_to_target_rate(captured)
if converted.size:
self._resampled_pcm_buffer = np.concatenate(
(self._resampled_pcm_buffer, converted)
)
frame = self._resampled_pcm_buffer[:target_length]
self._resampled_pcm_buffer = self._resampled_pcm_buffer[target_length:]
return frame
def wait_for_wakeword(self, timeout: float = None) -> bool:
"""
Блокирующая функция: ждет, пока не будет услышана фраза "Alexandr"
Блокирующая функция: ждет, пока не будет услышана wake word
или пока не истечет timeout.
Args:
@@ -94,19 +393,21 @@ class WakeWordDetector:
return False
# Читаем небольшой кусочек аудио (frame)
pcm = self.audio_stream.read(
self.porcupine.frame_length, exception_on_overflow=False
)
# Конвертируем байты в кортеж чисел (требование Porcupine)
pcm = struct.unpack_from("h" * self.porcupine.frame_length, pcm)
pcm = self._read_porcupine_frame()
self._remember_rms(self._compute_rms(pcm))
# Обрабатываем фрейм через Porcupine
keyword_index = self.porcupine.process(pcm)
keyword_index = self.porcupine.process(pcm.tolist())
# Если keyword_index >= 0, значит ключевое слово обнаружено
if keyword_index >= 0:
print("✅ Wake word обнаружен!")
# Важно: закрываем поток, чтобы освободить микрофон для STT (Deepgram)
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
@@ -127,19 +428,26 @@ class WakeWordDetector:
try:
self._open_stream()
pcm = self.audio_stream.read(
self.porcupine.frame_length, exception_on_overflow=False
)
pcm = struct.unpack_from("h" * self.porcupine.frame_length, pcm)
pcm = self._read_porcupine_frame()
self._remember_rms(self._compute_rms(pcm))
keyword_index = self.porcupine.process(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

@@ -1,30 +1,69 @@
"""AI module for Perplexity API integration."""
"""AI module."""
# Модуль общения с искусственным интеллектом (Perplexity API).
# Обрабатывает запросы пользователя и переводы.
import json
import re
from typing import Optional
import requests
import re
from .config import PERPLEXITY_API_KEY, PERPLEXITY_MODEL, PERPLEXITY_API_URL
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,
ANTHROPIC_MODEL,
GEMINI_API_KEY,
GEMINI_API_URL,
GEMINI_MODEL,
OLLAMA_API_URL,
OLLAMA_MODEL,
OPENAI_API_KEY,
OPENAI_API_URL,
OPENAI_MODEL,
OPENROUTER_API_KEY,
OPENROUTER_API_URL,
OPENROUTER_MODEL,
WAKE_WORD,
WAKE_WORD_ALIASES,
ZAI_API_KEY,
ZAI_API_URL,
ZAI_MODEL,
)
_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+")
# Системный промпт (инструкция) для AI.
# Задает личность ассистента: имя "Александр", стиль общения, краткость.
SYSTEM_PROMPT = """Ты — Александр, умный голосовой ассистент с человеческим поведением.
# Системный промпт
_wake_word_aliases_text = ", ".join(WAKE_WORD_ALIASES)
SYSTEM_PROMPT = f"""Ты — умный голосовой ассистент с человеческим поведением.
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
Твоя главная цель — помогать пользователю и поддерживать интересный диалог.
Отвечай кратко и по существу, на русском языке.
Отвечай на русском языке кратко и по существу: обычно 1-2 коротких предложения.
Если пользователь явно просит подробнее, можно до 4 коротких предложений без повторов и лишних вводных.
Избегай длинных списков, сложного форматирования и спецсимволов, так как твои ответы озвучиваются голосом.
Не добавляй ссылки, сноски и маркеры источников (например, [1], [2], URL).
Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов.
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные."""
Понимай юмор, иронию, сарказм, образные выражения, намеки и переносный смысл фраз.
Если пользователь шутит или говорит образно, сначала правильно восстанови его реальное намерение, затем ответь естественно и по смыслу.
Если в шутке или метафоре скрыта команда или просьба, трактуй ее по смыслу, а не буквально.
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные.
Тебя активируют словом "{WAKE_WORD}". Никогда не произноси это слово и его варианты ({_wake_word_aliases_text}) ни в каком ответе.
Если пользователь спрашивает, как тебя зовут или как к тебе обращаться, отвечай нейтрально: "Я ваш голосовой ассистент"."""
SYSTEM_PROMPT += (
'\nROLE_JSON: {"name":"Александр","role":"умный голосовой ассистент",'
'\nROLE_JSON: {"name":"голосовой ассистент","role":"умный голосовой ассистент",'
'"language":"ru","style":["дружелюбный","естественный","краткий"],"format":"plain"}'
)
# Системный промпт для режима переводчика.
# Требует возвращать ТОЛЬКО перевод, без лишних слов ("Конечно, вот перевод...").
# Промпт для перевода
TRANSLATION_SYSTEM_PROMPT = """You are a translation engine.
Translate from {source} to {target}.
Return 2-3 short translation variants only.
@@ -32,10 +71,460 @@ No explanations, no quotes, no comments.
Separate variants with " / " (space slash space).
Keep the translation максимально кратким и естественным, без лишних слов."""
INTENT_SYSTEM_PROMPT = """Ты NLU-модуль голосовой колонки.
Твоя задача: распознать намерение пользователя и вернуть СТРОГО JSON без markdown и пояснений.
Всегда возвращай объект c ключами:
{
"intent": "none|music|timer|alarm|weather|volume|translation|cities|repeat|stop|smalltalk|chat",
"normalized_command": "<краткая нормализованная команда на русском или пусто>",
"music_action": "none|play|pause|resume|next|previous|current|play_genre|play_folder|play_query",
"music_query": "<запрос для музыки/жанра/папки или пусто>",
"confidence": 0.0
}
Правила:
- Если это музыка, ставь intent=music и выбирай music_action.
- "Включи музыку" и любые эквиваленты = music_action=play.
- Для "пауза/останови музыку/выключи музыку" = music_action=pause.
- Для "что играет" = 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 = {
"": "openrouter",
"anthropic": "anthropic",
"claude": "anthropic",
"claude_anthropic": "anthropic",
"gemini": "gemini",
"google": "gemini",
"olama": "ollama",
"ollama": "ollama",
"openai": "openai",
"openrouter": "openrouter",
"z.ai": "zai",
"z-ai": "zai",
"z_ai": "zai",
"zai": "zai",
}
# В .env нужен только один AI-ключ
_PROVIDER_SETTINGS = {
"openrouter": {
"provider": "openrouter",
"protocol": "openai_compatible",
"api_key": OPENROUTER_API_KEY,
"model": OPENROUTER_MODEL,
"api_url": OPENROUTER_API_URL,
"name": "OpenRouter",
"key_var": "OPENROUTER_API_KEY",
"model_var": "OPENROUTER_MODEL",
},
"openai": {
"provider": "openai",
"protocol": "openai_compatible",
"api_key": OPENAI_API_KEY,
"model": OPENAI_MODEL,
"api_url": OPENAI_API_URL,
"name": "OpenAI",
"key_var": "OPENAI_API_KEY",
"model_var": "OPENAI_MODEL",
},
"gemini": {
"provider": "gemini",
"protocol": "openai_compatible",
"api_key": GEMINI_API_KEY,
"model": GEMINI_MODEL,
"api_url": GEMINI_API_URL,
"name": "Gemini",
"key_var": "GEMINI_API_KEY",
"model_var": "GEMINI_MODEL",
},
"zai": {
"provider": "zai",
"protocol": "openai_compatible",
"api_key": ZAI_API_KEY,
"model": ZAI_MODEL,
"api_url": ZAI_API_URL,
"name": "Z.ai",
"key_var": "ZAI_API_KEY",
"model_var": "ZAI_MODEL",
"extra_headers": {
"Accept-Language": "en-US,en",
},
},
"anthropic": {
"provider": "anthropic",
"protocol": "anthropic",
"api_key": ANTHROPIC_API_KEY,
"model": ANTHROPIC_MODEL,
"api_url": ANTHROPIC_API_URL,
"api_version": ANTHROPIC_API_VERSION,
"name": "Anthropic Claude",
"key_var": "ANTHROPIC_API_KEY",
"model_var": "ANTHROPIC_MODEL",
},
"ollama": {
"provider": "ollama",
"protocol": "openai_compatible",
# Ollama обычно локальный и не требует API key.
"api_key": None,
"requires_api_key": False,
"model": OLLAMA_MODEL,
"api_url": OLLAMA_API_URL,
"name": "Ollama",
"key_var": "OLLAMA_API_KEY",
"model_var": "OLLAMA_MODEL",
},
}
def _has_active_api_key(value) -> bool:
return bool(str(value or "").strip())
def _normalize_provider_name(provider_name: str) -> str:
normalized = (provider_name or "").strip().lower()
return _PROVIDER_ALIASES.get(normalized, normalized)
def _get_provider_settings():
"""Определяет какой AI провайдер использовать."""
# Ищем активные ключи
configured = [
cfg
for cfg in _PROVIDER_SETTINGS.values()
if _has_active_api_key(cfg.get("api_key"))
]
if len(configured) == 1:
cfg = configured[0]
requested = _normalize_provider_name(AI_PROVIDER)
if (
requested
and requested in _PROVIDER_SETTINGS
and requested != cfg["provider"]
):
print(
f"⚠️ AI_PROVIDER={AI_PROVIDER!r} не совпадает с единственным "
f"активным ключом {cfg['name']}. Используем {cfg['name']}."
)
return cfg, None
if len(configured) > 1:
names = ", ".join(cfg["name"] for cfg in configured)
return None, (
"Обнаружено несколько AI API ключей. Оставьте незакомментированным "
f"только один ключ. Сейчас активны: {names}. "
"Колонка не знает, какой AI использовать."
)
provider = _normalize_provider_name(AI_PROVIDER)
cfg = _PROVIDER_SETTINGS.get(provider)
if cfg:
return cfg, None
supported = ", ".join(sorted(_PROVIDER_SETTINGS))
print(
f"⚠️ Неизвестный AI_PROVIDER={AI_PROVIDER!r}, используем OpenRouter. "
f"Поддерживаются: {supported}."
)
return _PROVIDER_SETTINGS["openrouter"], None
def _content_to_text(content) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
if text:
parts.append(str(text))
elif item is not None:
parts.append(str(item))
return "".join(parts)
if content is None:
return ""
return str(content)
def _get_provider_config_error(cfg) -> Optional[str]:
if not cfg:
return "Не настроен AI-провайдер. Проверьте файл .env."
if cfg.get("requires_api_key", True) and not cfg.get("api_key"):
return f"Не настроен {cfg['key_var']}. Проверьте файл .env."
if not cfg["model"]:
return f"Не настроен {cfg['model_var']}. Проверьте файл .env."
return None
def _build_headers(cfg):
if cfg["protocol"] == "anthropic":
return {
"x-api-key": cfg["api_key"],
"anthropic-version": cfg["api_version"],
"Content-Type": "application/json",
}
headers = {"Content-Type": "application/json"}
if cfg.get("api_key"):
headers["Authorization"] = f"Bearer {cfg['api_key']}"
headers.update(cfg.get("extra_headers") or {})
return headers
def _split_system_messages(messages):
"""Извлекает system prompt из списка сообщений."""
system_parts = []
chat_messages = []
for message in messages:
role = str(message.get("role") or "").strip().lower()
content = _content_to_text(message.get("content"))
if role == "system":
if content:
system_parts.append(content)
continue
if role not in ("user", "assistant"):
role = "user"
chat_messages.append({"role": role, "content": content})
return "\n\n".join(system_parts), chat_messages
def _build_payload(cfg, messages, max_tokens, temperature, stream):
if cfg["protocol"] == "anthropic":
# У Claude схема чуть отличается: system не живет внутри messages.
system_prompt, chat_messages = _split_system_messages(messages)
payload = {
"model": cfg["model"],
"messages": chat_messages,
"max_tokens": max_tokens,
"temperature": temperature,
"stream": stream,
}
if system_prompt:
payload["system"] = system_prompt
return payload
return {
"model": cfg["model"],
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"stream": stream,
}
def _extract_openai_compatible_content(data: dict) -> str:
choices = data.get("choices") or []
if not choices:
return ""
message = choices[0].get("message") or {}
return _content_to_text(message.get("content"))
def _extract_anthropic_content(data: dict) -> str:
return _content_to_text(data.get("content"))
def _extract_response_content(cfg, data: dict) -> str:
if cfg["protocol"] == "anthropic":
return _extract_anthropic_content(data)
return _extract_openai_compatible_content(data)
def _iter_openai_compatible_stream(response):
for data_str in _iter_sse_data_lines(response):
if data_str == "[DONE]":
break
try:
data_json = json.loads(data_str)
except json.JSONDecodeError:
continue
choices = data_json.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content", "")
if isinstance(content, str):
if content:
yield content
continue
# Некоторые OpenAI-compatible API присылают контент кусками-объектами.
if isinstance(content, list):
for item in content:
if isinstance(item, dict):
text = item.get("text")
if text:
yield str(text)
def _iter_anthropic_stream(response):
for data_str in _iter_sse_data_lines(response):
if data_str == "[DONE]":
break
try:
data_json = json.loads(data_str)
except json.JSONDecodeError:
continue
chunk_type = data_json.get("type")
# Claude отдает поток событиями разных типов, нас интересует только текст.
if chunk_type == "content_block_start":
content_block = data_json.get("content_block") or {}
text = content_block.get("text")
if text:
yield str(text)
elif chunk_type == "content_block_delta":
delta = data_json.get("delta") or {}
text = delta.get("text")
if text:
yield str(text)
def _iter_sse_data_lines(response):
"""
Читает SSE-стрим и возвращает только payload после "data:".
Явно декодируем как UTF-8, чтобы избежать mojibake вида "Пр...".
"""
for raw_line in response.iter_lines(decode_unicode=False):
if not raw_line:
continue
if isinstance(raw_line, bytes):
line = raw_line.decode("utf-8", errors="replace")
else:
line = str(raw_line)
if not line.startswith("data:"):
continue
data_str = line[5:].strip()
if data_str:
yield data_str
def _iter_stream_chunks(cfg, response):
if cfg["protocol"] == "anthropic":
yield from _iter_anthropic_stream(response)
return
yield from _iter_openai_compatible_stream(response)
def _log_request_exception(cfg, error: Exception):
details = ""
response = getattr(error, "response", None)
if response is not None:
body = (response.text or "").strip()
if body:
details = f" | body={body[:400]}"
print(f"❌ Ошибка API ({cfg['name']}): {error}{details}")
def _extract_json_object(raw_text: str) -> Optional[dict]:
text = str(raw_text or "").strip()
if not text:
return None
try:
payload = json.loads(text)
if isinstance(payload, dict):
return payload
except json.JSONDecodeError:
pass
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
if not match:
return None
candidate = match.group(0).strip()
try:
payload = json.loads(candidate)
except json.JSONDecodeError:
return None
if isinstance(payload, dict):
return payload
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-запроса к Perplexity API.
Внутренняя функция для отправки HTTP-запроса к выбранному AI-провайдеру.
Args:
messages: Список сообщений (история чата).
@@ -43,37 +532,129 @@ def _send_request(messages, max_tokens, temperature, error_text):
temperature: "Креативность" (0.2 - строго, 1.0 - креативно).
error_text: Текст ошибки для пользователя в случае сбоя.
"""
if not PERPLEXITY_API_KEY:
return "Не настроен PERPLEXITY_API_KEY. Проверьте файл .env."
headers = {
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": PERPLEXITY_MODEL,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"stream": False # Убираем стриминг для более быстрого ответа
}
cfg, selection_error = _get_provider_settings()
if selection_error:
return selection_error
config_error = _get_provider_config_error(cfg)
if config_error:
return config_error
try:
# Обычный запрос нужен для перевода и мест, где стриминг не требуется.
response = _HTTP.post(
PERPLEXITY_API_URL, headers=headers, json=payload, timeout=15 # Уменьшаем таймаут
cfg["api_url"],
headers=_build_headers(cfg),
json=_build_payload(cfg, messages, max_tokens, temperature, stream=False),
timeout=15,
)
response.raise_for_status() # Проверка на ошибки HTTP (4xx, 5xx)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
content = _extract_response_content(cfg, data)
if not content:
return "Не удалось обработать ответ от AI."
return content
except requests.exceptions.Timeout:
return "Извините, сервер не отвечает. Попробуйте позже."
except requests.exceptions.RequestException as e:
print(f"❌ Ошибка API: {e}")
return f"Извините, сервер {cfg['name']} не отвечает. Попробуйте позже."
except requests.exceptions.RequestException as error:
_log_request_exception(cfg, error)
return error_text
except (KeyError, IndexError) as e:
print(f"❌ Ошибка парсинга ответа: {e}")
except Exception as error:
print(f"❌ Ошибка парсинга ответа ({cfg['name']}): {error}")
return "Не удалось обработать ответ от AI."
def interpret_assistant_intent(text: str) -> dict:
"""
Interprets voice command semantics for downstream command routers.
Returns a normalized dict even when AI is unavailable.
"""
result = {
"intent": "none",
"normalized_command": "",
"music_action": "none",
"music_query": "",
"confidence": 0.0,
}
cleaned_text = str(text or "").strip()
if not cleaned_text:
return result
cfg, selection_error = _get_provider_settings()
if selection_error:
return result
if _get_provider_config_error(cfg):
return result
messages = [
{"role": "system", "content": INTENT_SYSTEM_PROMPT},
{"role": "user", "content": cleaned_text},
]
response = _send_request(
messages,
max_tokens=220,
temperature=AI_INTENT_TEMPERATURE,
error_text="",
)
payload = _extract_json_object(response)
if not payload:
return result
allowed_intents = {
"none",
"music",
"timer",
"alarm",
"weather",
"volume",
"translation",
"cities",
"repeat",
"stop",
"smalltalk",
"chat",
}
allowed_music_actions = {
"none",
"play",
"pause",
"resume",
"next",
"previous",
"current",
"play_genre",
"play_folder",
"play_query",
}
intent = str(payload.get("intent", "none")).strip().lower()
if intent not in allowed_intents:
intent = "none"
music_action = str(payload.get("music_action", "none")).strip().lower()
if music_action not in allowed_music_actions:
music_action = "none"
try:
confidence = float(payload.get("confidence", 0.0))
except (TypeError, ValueError):
confidence = 0.0
confidence = max(0.0, min(1.0, confidence))
normalized_command = str(payload.get("normalized_command", "")).strip()
music_query = str(payload.get("music_query", "")).strip()
result.update(
{
"intent": intent,
"normalized_command": normalized_command,
"music_action": music_action,
"music_query": music_query,
"confidence": confidence,
}
)
return result
def ask_ai(messages_history: list) -> str:
"""
Запрос к AI в режиме чата.
@@ -95,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]}...")
@@ -109,8 +692,14 @@ def ask_ai_stream(messages_history: list):
"""
Generator that yields chunks of the AI response as they arrive.
"""
if not PERPLEXITY_API_KEY:
yield "Не настроен ключ PERPLEXITY_API_KEY. Проверьте файл .env."
response = None
cfg, selection_error = _get_provider_settings()
if selection_error:
yield selection_error
return
config_error = _get_provider_config_error(cfg)
if config_error:
yield config_error
return
if not messages_history:
yield "Извините, я не расслышал вашу команду."
@@ -122,47 +711,69 @@ def ask_ai_stream(messages_history: list):
if msg["role"] == "user":
last_user_message = msg["content"]
break
print(f"🤖 Запрос к AI (Stream): {last_user_message}")
print(f"🤖 Запрос к AI ({cfg['name']}, Stream): {last_user_message}")
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + list(messages_history)
headers = {
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": PERPLEXITY_MODEL,
"messages": messages,
"max_tokens": 500,
"temperature": 1.0,
"stream": True, # Enable streaming
}
try:
# В голосовом режиме удобнее говорить частями, как только они приходят от API.
response = _HTTP.post(
PERPLEXITY_API_URL, headers=headers, json=payload, timeout=15, stream=True # Уменьшаем таймаут
cfg["api_url"],
headers=_build_headers(cfg),
json=_build_payload(
cfg,
messages,
AI_CHAT_MAX_TOKENS,
AI_CHAT_TEMPERATURE,
stream=True,
),
timeout=15,
stream=True,
)
response.raise_for_status()
import json
# Для устойчивости TTS сначала собираем поток, затем чистим и аккуратно
# ограничиваем длину по границе предложения.
raw_parts = []
for chunk in _iter_stream_chunks(cfg, response):
if chunk:
raw_parts.append(chunk)
for line in response.iter_lines(decode_unicode=True):
if line:
line_text = line
if line_text.startswith("data: "):
data_str = line_text[6:] # Skip "data: "
if data_str == "[DONE]":
break
try:
data_json = json.loads(data_str)
content = data_json["choices"][0]["delta"].get("content", "")
if content:
yield content
except json.JSONDecodeError:
continue
except Exception as e:
print(f"❌ Streaming Error: {e}")
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:
_log_request_exception(cfg, error)
yield "Произошла ошибка связи."
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:
@@ -193,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

@@ -1,6 +1,13 @@
import pyaudio
import threading
from .config import (
AUDIO_INPUT_DEVICE_INDEX,
AUDIO_INPUT_DEVICE_NAME,
AUDIO_OUTPUT_DEVICE_INDEX,
AUDIO_OUTPUT_DEVICE_NAME,
)
class AudioManager:
_instance = None
@@ -11,12 +18,351 @@ class AudioManager:
if cls._instance is None:
cls._instance = super(AudioManager, cls).__new__(cls)
cls._instance.pa = pyaudio.PyAudio()
cls._instance._input_device_index = None
cls._instance._output_device_index = None
cls._instance._input_device_resolved = False
cls._instance._output_device_resolved = False
print("🔊 AudioManager: PyAudio initialized (Global)")
return cls._instance
def get_pyaudio(self):
return self.pa
def get_input_device_index(self):
"""
Returns PortAudio input device index or None (let PortAudio pick default).
Raises a RuntimeError with a helpful message if no input devices exist.
"""
if self._input_device_resolved:
return self._input_device_index
self._input_device_index = self._resolve_input_device_index()
self._input_device_resolved = True
return self._input_device_index
def get_output_device_index(self):
"""
Returns PortAudio output device index or None (let PortAudio pick default).
Raises a RuntimeError with a helpful message if no output devices exist.
"""
if self._output_device_resolved:
return self._output_device_index
self._output_device_index = self._resolve_output_device_index()
self._output_device_resolved = True
return self._output_device_index
def _get_device_count(self) -> int:
if self.pa is None:
return 0
return int(self.pa.get_device_count() or 0)
def _is_input_device(self, idx: int) -> bool:
try:
info = self.pa.get_device_info_by_index(idx)
except Exception:
return False
return int(info.get("maxInputChannels") or 0) > 0
def _is_output_device(self, idx: int) -> bool:
try:
info = self.pa.get_device_info_by_index(idx)
except Exception:
return False
return int(info.get("maxOutputChannels") or 0) > 0
def _find_device_by_name(self, needle: str, input_kind: bool):
if not needle:
return None
lowered = needle.lower()
count = self._get_device_count()
for idx in range(count):
if input_kind and not self._is_input_device(idx):
continue
if not input_kind and not self._is_output_device(idx):
continue
try:
name = str(self.pa.get_device_info_by_index(idx).get("name") or "")
except Exception:
continue
if lowered in name.lower():
return idx
return None
def _get_default_input_index(self):
try:
info = self.pa.get_default_input_device_info()
idx = int(info.get("index"))
if self._is_input_device(idx):
return idx
except Exception:
pass
return None
def _get_default_output_index(self):
try:
info = self.pa.get_default_output_device_info()
idx = int(info.get("index"))
if self._is_output_device(idx):
return idx
except Exception:
pass
return None
def _resolve_input_device_index(self):
if self.pa is None:
return None
device_count = self._get_device_count()
if AUDIO_INPUT_DEVICE_INDEX is not None:
idx = int(AUDIO_INPUT_DEVICE_INDEX)
if 0 <= idx < device_count and self._is_input_device(idx):
return idx
raise RuntimeError(
"Audio input initialization failed: invalid AUDIO_INPUT_DEVICE_INDEX="
f"{AUDIO_INPUT_DEVICE_INDEX}. Available input devices:\n"
+ self.describe_input_devices()
)
if AUDIO_INPUT_DEVICE_NAME:
match_idx = self._find_device_by_name(AUDIO_INPUT_DEVICE_NAME, input_kind=True)
if match_idx is not None:
return match_idx
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).
default_idx = self._get_default_input_index()
if default_idx is not None:
return default_idx
# Fallback: first input device.
for idx in range(device_count):
if self._is_input_device(idx):
return idx
raise RuntimeError(
"Audio input initialization failed: no input devices found. "
"Check microphone connection and PipeWire/PulseAudio. "
"PortAudio devices:\n"
+ self.describe_input_devices()
)
def _resolve_output_device_index(self):
if self.pa is None:
return None
device_count = self._get_device_count()
if AUDIO_OUTPUT_DEVICE_INDEX is not None:
idx = int(AUDIO_OUTPUT_DEVICE_INDEX)
if 0 <= idx < device_count and self._is_output_device(idx):
return idx
raise RuntimeError(
"Audio output initialization failed: invalid AUDIO_OUTPUT_DEVICE_INDEX="
f"{AUDIO_OUTPUT_DEVICE_INDEX}. Available output devices:\n"
+ self.describe_output_devices()
)
if AUDIO_OUTPUT_DEVICE_NAME:
match_idx = self._find_device_by_name(
AUDIO_OUTPUT_DEVICE_NAME, input_kind=False
)
if match_idx is not None:
return match_idx
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()
if default_idx is not None:
return default_idx
for idx in range(device_count):
if self._is_output_device(idx):
return idx
raise RuntimeError(
"Audio output initialization failed: no output devices found. "
"Check speaker connection and PipeWire/PulseAudio. "
"PortAudio devices:\n"
+ self.describe_output_devices()
)
def _ordered_input_candidates(self, preferred_index=None):
candidates = []
def add(idx):
if idx not in candidates:
candidates.append(idx)
if preferred_index is not None:
add(preferred_index)
else:
try:
add(self.get_input_device_index())
except Exception:
pass
add(self._get_default_input_index())
add(None) # Let PortAudio decide default path.
for idx in range(self._get_device_count()):
if self._is_input_device(idx):
add(idx)
return [idx for idx in candidates if idx is None or self._is_input_device(idx)]
def _ordered_output_candidates(self, preferred_index=None):
candidates = []
def add(idx):
if idx not in candidates:
candidates.append(idx)
if preferred_index is not None:
add(preferred_index)
else:
try:
add(self.get_output_device_index())
except Exception:
pass
add(self._get_default_output_index())
add(None) # Let PortAudio decide default path.
for idx in range(self._get_device_count()):
if self._is_output_device(idx):
add(idx)
return [idx for idx in candidates if idx is None or self._is_output_device(idx)]
def open_input_stream(
self,
*,
rate: int,
channels: int,
format,
frames_per_buffer: int,
preferred_index=None,
fallback_rates=None,
):
if self.pa is None:
raise RuntimeError("PyAudio is not initialized")
fallback_rates = fallback_rates or []
rates = [int(rate)] + [int(r) for r in fallback_rates if int(r) > 0 and int(r) != int(rate)]
errors = []
for device_idx in self._ordered_input_candidates(preferred_index):
for attempt_rate in rates:
fb = max(
64, int(round(frames_per_buffer * attempt_rate / max(1, int(rate))))
)
kwargs = {
"rate": attempt_rate,
"channels": channels,
"format": format,
"input": True,
"frames_per_buffer": fb,
}
if device_idx is not None:
kwargs["input_device_index"] = device_idx
try:
stream = self.pa.open(**kwargs)
return stream, device_idx, attempt_rate
except Exception as exc:
errors.append(
f"device={device_idx!r}, rate={attempt_rate}: {exc}"
)
joined_errors = "\n".join(errors[:12])
raise RuntimeError(
"Audio input initialization failed. Tried multiple devices/rates.\n"
f"{joined_errors}\nAvailable input devices:\n{self.describe_input_devices()}"
)
def open_output_stream(
self,
*,
rate: int,
channels: int,
format,
preferred_index=None,
fallback_rates=None,
):
if self.pa is None:
raise RuntimeError("PyAudio is not initialized")
fallback_rates = fallback_rates or []
rates = [int(rate)] + [int(r) for r in fallback_rates if int(r) > 0 and int(r) != int(rate)]
errors = []
for device_idx in self._ordered_output_candidates(preferred_index):
for attempt_rate in rates:
kwargs = {
"rate": attempt_rate,
"channels": channels,
"format": format,
"output": True,
}
if device_idx is not None:
kwargs["output_device_index"] = device_idx
try:
stream = self.pa.open(**kwargs)
return stream, device_idx, attempt_rate
except Exception as exc:
errors.append(
f"device={device_idx!r}, rate={attempt_rate}: {exc}"
)
joined_errors = "\n".join(errors[:12])
raise RuntimeError(
"Audio output initialization failed. Tried multiple devices/rates.\n"
f"{joined_errors}\nAvailable output devices:\n{self.describe_output_devices()}"
)
def describe_input_devices(self, limit: int = 20) -> str:
if self.pa is None:
return "<PyAudio not initialized>"
items = []
count = self._get_device_count()
for idx in range(count):
try:
info = self.pa.get_device_info_by_index(idx)
except Exception:
continue
max_in = int(info.get("maxInputChannels") or 0)
if max_in <= 0:
continue
name = str(info.get("name") or "").strip()
items.append(f"[{idx}] {name} (in={max_in})")
if len(items) >= limit:
break
return "\n".join(items) if items else "<no input devices>"
def describe_output_devices(self, limit: int = 20) -> str:
if self.pa is None:
return "<PyAudio not initialized>"
items = []
count = self._get_device_count()
for idx in range(count):
try:
info = self.pa.get_device_info_by_index(idx)
except Exception:
continue
max_out = int(info.get("maxOutputChannels") or 0)
if max_out <= 0:
continue
name = str(info.get("name") or "").strip()
items.append(f"[{idx}] {name} (out={max_out})")
if len(items) >= limit:
break
return "\n".join(items) if items else "<no output devices>"
def cleanup(self):
if self.pa:
self.pa.terminate()

View File

@@ -1,23 +1,14 @@
"""
Response cleaner module.
Removes markdown formatting and special characters from AI responses.
Handles complex number-to-text conversion for Russian language.
"""
# Модуль очистки текста перед озвучкой.
# 1. Убирает Markdown (жирный шрифт, ссылки), который генерирует AI, чтобы робот не читал спецсимволы.
# 2. Преобразует числа в слова ("5 мая" -> "пятого мая", "5 рублей" -> "пять рублей").
# Это критически важно для качественного русского TTS.
"""Text cleaner for TTS."""
import re
import pymorphy3
from num2words import num2words
from .config import WAKE_WORD, WAKE_WORD_ALIASES
from .roman import roman_to_int
# Инициализация морфологического анализатора (для определения падежей)
morph = pymorphy3.MorphAnalyzer()
# Карта предлогов и падежей.
# Помогает понять, в какой падеж ставить число после предлога.
# Предлоги и падежи
PREPOSITION_CASES = {
"в": "loct", # В ком/чем? (Предложный) или Винительный. Часто loct для годов.
"во": "loct",
@@ -54,7 +45,7 @@ PREPOSITION_CASES = {
"про": "accs",
}
# Соответствие падежей pymorphy и библиотеки num2words
# Соответствие падежей
PYMORPHY_TO_NUM2WORDS = {
"nomn": "nominative",
"gent": "genitive",
@@ -68,14 +59,14 @@ PYMORPHY_TO_NUM2WORDS = {
"loc2": "prepositional",
}
# Соответствие родов pymorphy и num2words
# Роды
PYMORPHY_TO_GENDER = {
"masc": "m",
"femn": "f",
"neut": "n",
}
# Названия месяцев в родительном падеже (для поиска дат в тексте)
# Месяца
MONTHS_GENITIVE = [
"января",
"февраля",
@@ -91,25 +82,53 @@ MONTHS_GENITIVE = [
"декабря",
]
# Леммы единиц времени (для корректного падежа числительных)
# Время
TIME_UNIT_LEMMAS = {"час", "минута", "секунда"}
WAKE_WORD_BLOCKED_PATTERNS = [
re.compile(rf"\b{re.escape(alias)}\b", flags=re.IGNORECASE)
for alias in set(WAKE_WORD_ALIASES) | {WAKE_WORD.lower()}
]
# Суффиксы порядковых
_ORDINAL_SUFFIX_MAP = {
# Masculine
"ого": ("genitive", "m"),
"его": ("genitive", "m"),
"ому": ("dative", "m"),
"ему": ("dative", "m"),
"ым": ("instrumental", "m"),
"им": ("instrumental", "m"),
"ом": ("prepositional", "m"),
"ем": ("prepositional", "m"),
"ый": ("nominative", "m"),
"ий": ("nominative", "m"),
"й": ("nominative", "m"),
"го": ("genitive", "m"),
"му": ("dative", "m"),
"м": ("prepositional", "m"),
# Feminine
"ая": ("nominative", "f"),
"яя": ("nominative", "f"),
"ую": ("accusative", "f"),
"юю": ("accusative", "f"),
"ой": ("genitive", "f"),
"ей": ("genitive", "f"),
# Neuter
"ое": ("nominative", "n"),
"ее": ("nominative", "n"),
}
def get_case_from_preposition(prep_token):
"""Определяет падеж по предлогу."""
"""Падеж по предлогу."""
if not prep_token:
return None
return PREPOSITION_CASES.get(prep_token.lower())
def convert_number(number_str, context_type="cardinal", case="nominative", gender="m"):
"""
Обертка над num2words для конвертации числа в строку.
cardinal - количественное (один, два)
ordinal - порядковое (первый, второй)
"""
"""Число в слова."""
try:
# Обработка дробей (замена запятой на точку)
if "." in number_str or "," in number_str:
num_val = float(number_str.replace(",", "."))
else:
@@ -122,31 +141,144 @@ def convert_number(number_str, context_type="cardinal", case="nominative", gende
def numbers_to_words(text: str) -> str:
"""
Интеллектуальная замена цифр на слова с учетом контекста (даты, года, падежи).
"""
"""Замена цифр на слова."""
if not text:
return ""
# 1. Обработка годов: "в 1999 году", "2024 год"
def replace_year_match(match):
prep = match.group(1) # Предлог (в, с, к...)
year_str = match.group(2) # Само число
year_word = match.group(3) # Слово "год", "году" и т.д.
preps_list = "|".join(map(re.escape, PREPOSITION_CASES.keys()))
# Определяем падеж слова "год" через pymorphy
# Время вида "в 7:00" / "во 7:00" / "к 7:05" / "07:00" -> человеческая русская форма.
# Важно: "в семь" (не "в семи"), "к семи" (дательный).
def _minute_words(minute_val: int) -> str:
if minute_val == 0:
return "ровно"
if minute_val < 10:
return "ноль " + convert_number(
str(minute_val), context_type="cardinal", case="nominative", gender="m"
)
return convert_number(str(minute_val), context_type="cardinal", case="nominative", gender="m")
def replace_time_match(match):
prep = match.group(1) or ""
hour_str = match.group(2)
minute_str = match.group(3)
try:
hour_val = int(hour_str)
minute_val = int(minute_str)
except Exception:
return match.group(0)
if not (0 <= hour_val <= 23 and 0 <= minute_val <= 59):
return match.group(0)
prep_clean = prep.strip().lower()
if prep_clean in {"в", "во"}:
hour_case = "accusative"
elif prep_clean in {"к", "ко"}:
hour_case = "dative"
else:
hour_case = "nominative"
hour_words = convert_number(str(hour_val), context_type="cardinal", case=hour_case, gender="m")
minute_words = _minute_words(minute_val)
prefix = f"{prep} " if prep else ""
return f"{prefix}{hour_words} {minute_words}"
def replace_time_no_prep_match(match):
hour_str = match.group(1)
minute_str = match.group(2)
try:
hour_val = int(hour_str)
minute_val = int(minute_str)
except Exception:
return match.group(0)
if not (0 <= hour_val <= 23 and 0 <= minute_val <= 59):
return match.group(0)
hour_words = convert_number(str(hour_val), context_type="cardinal", case="nominative", gender="m")
minute_words = _minute_words(minute_val)
return f"{hour_words} {minute_words}"
text = re.sub(
r"(?i)\b(в|во|к|ко)\s+(\d{1,2})\s*:\s*(\d{2})\b",
replace_time_match,
text,
)
text = re.sub(
r"\b(\d{1,2})\s*:\s*(\d{2})\b",
replace_time_no_prep_match,
text,
)
# Года с суффиксом
def replace_year_suffix_match(match):
prep = match.group(1)
year_str = match.group(2)
suffix = match.group(3)
year_word = match.group(4)
case = None
gender = None
if prep:
morph_case = get_case_from_preposition(prep.strip().lower())
if morph_case:
case = PYMORPHY_TO_NUM2WORDS.get(morph_case)
suffix_key = suffix.lower()
suffix_case, suffix_gender = _ORDINAL_SUFFIX_MAP.get(suffix_key, (None, None))
if not case and suffix_case:
case = suffix_case
if year_word:
gender = "m"
elif suffix_gender:
gender = suffix_gender
if not case:
case = "nominative"
if not gender:
gender = "m"
words = convert_number(
year_str, context_type="ordinal", case=case, gender=gender
)
prefix = f"{prep} " if prep else ""
if year_word:
return f"{prefix}{words} {year_word}"
return f"{prefix}{words}"
text = re.sub(
rf"(?i)\b((?:{preps_list})\s+)?(\d{{3,4}})[-‑–—]"
r"(ого|его|ому|ему|ым|им|ом|ем|ый|ий|ая|яя|ую|юю|ой|ей|ое|ее|й|го|му|м)\b"
r"(?:\s+(год[а-я]*))?",
replace_year_suffix_match,
text,
)
# Года
def replace_year_match(match):
prep = match.group(1)
year_str = match.group(2)
year_word = match.group(3)
# Падеж
parsed = morph.parse(year_word)[0]
case_tag = parsed.tag.case
nw_case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative")
# FIX: Pymorphy часто определяет "год" как accs (винительный), что для num2words
# превращается в родительный (для одушевленных?), давая "2024 года".
# Если предлога нет, принудительно ставим именительный.
# Без предлога - именительный
if not prep and year_word.lower().startswith("год"):
nw_case = "nominative"
# Конвертируем число в порядковое числительное (тысяча девятьсот девяносто девятом)
# Конвертируем
words = convert_number(
year_str, context_type="ordinal", case=nw_case, gender="m"
)
@@ -161,7 +293,7 @@ def numbers_to_words(text: str) -> str:
text,
)
# 2. Обработка дат: "25 июня", "с 1 мая"
# Даты
month_regex = "|".join(MONTHS_GENITIVE)
def replace_date_match(match):
@@ -169,7 +301,7 @@ def numbers_to_words(text: str) -> str:
day_str = match.group(2)
month_word = match.group(3)
# По умолчанию родительный падеж ("двадцать пятого июня")
# По умолчанию родительный
case = "genitive"
if prep:
@@ -188,7 +320,7 @@ def numbers_to_words(text: str) -> str:
if morph_case:
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "genitive")
# Используем средний род ('n') для дат (число - средний род: пятое, пятого)
# Средний род для дат
words = convert_number(day_str, context_type="ordinal", case=case, gender="n")
prefix = f"{prep} " if prep else ""
@@ -201,7 +333,7 @@ def numbers_to_words(text: str) -> str:
text,
)
# 3. Обработка всех остальных чисел (Количественные: пять столов, десять минут)
# Остальные числа
def replace_cardinal_match(match):
prep = match.group(1)
num_str = match.group(2)
@@ -210,13 +342,14 @@ 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)
if morph_case:
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative")
# Если есть следующее слово, проверяем его род (для "2 минуты" -> "две")
# Проверяем род
if next_word:
word_clean = next_word.strip()
parsed = morph.parse(word_clean)[0]
@@ -224,21 +357,20 @@ def numbers_to_words(text: str) -> str:
morph_gender = parsed.tag.gender
gender = PYMORPHY_TO_GENDER.get(morph_gender, "m")
# Спец-случай: "на 1 час" -> "на один час" (не "одного")
# Для неодушевленных муж./ср. рода в винительном падеже
# числительные должны совпадать с именительным.
if (
prep_clean == "на"
and parsed.normal_form in TIME_UNIT_LEMMAS
and parsed.tag.gender in ("masc", "neut")
):
case = "nominative"
# Спец-случай: "на 1 час"
if (
prep_clean == "на"
and parsed is not None
and parsed.normal_form in TIME_UNIT_LEMMAS
and parsed.tag.gender in ("masc", "neut")
):
case = "nominative"
words = convert_number(
num_str, context_type="cardinal", case=case, gender=gender
)
# Если конвертация вернула пустую строку (сбой?), возвращаем цифры
# Если конвертация не удалась - возвращаем цифры
if not words:
words = num_str
@@ -248,7 +380,6 @@ def numbers_to_words(text: str) -> str:
# Регулярка теперь захватывает (опционально) следующее слово для определения рода
preps_list = "|".join(map(re.escape, PREPOSITION_CASES.keys()))
text = re.sub(
rf"(?i)(?<!\w)((?:{preps_list})\s+)?([+-]?\d+(?:[.,]\d+)?)(?=(\s+[а-яА-ЯёЁ]+))?\b",
replace_cardinal_match,
@@ -258,64 +389,103 @@ def numbers_to_words(text: str) -> str:
return text
def clean_response(text: str, language: str = "ru") -> str:
"""
Основная функция очистки.
Убирает Markdown, ссылки, мусор и преобразует числа.
Args:
text: Сырой текст от AI.
language: Язык (для конвертации чисел, работает только для ru).
"""
def roman_numerals_to_words(text: str) -> str:
"""Римские в слова."""
if not text:
return ""
# Удаление ссылок на источники [1], [citation needed]
def replace_roman_match(match):
prev_word = match.group(1)
roman = match.group(2)
number = roman_to_int(roman)
if number is None:
return match.group(0)
case = "nominative"
gender = "m"
try:
parsed = morph.parse(prev_word)[0]
case_tag = parsed.tag.case
gender_tag = parsed.tag.gender
if case_tag:
case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative")
if gender_tag:
gender = PYMORPHY_TO_GENDER.get(gender_tag, "m")
except Exception:
pass
ordinal = convert_number(
str(number), context_type="ordinal", case=case, gender=gender
)
return f"{prev_word} {ordinal}"
return re.sub(
r"(?i)\b([А-Яа-яЁё]+)\s+([IVXLCDM]+)\b",
replace_roman_match,
text,
)
def clean_response(text: str, language: str = "ru") -> str:
"""Очистка текста для TTS."""
if not text:
return ""
# Удаление ссылок
text = re.sub(r"\x5B\d+\x5D", "", text)
text = re.sub(r"\x5Bcitation\s*needed\x5D", "", text, flags=re.IGNORECASE)
text = re.sub(r"\x5Bsource\x5D", "", text, flags=re.IGNORECASE)
# Удаление жирного шрифта **text** и __text__
# Удаление жирного
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"__(.+?)__", r"\1", text)
# Удаление курсива *text* и _text_
# Удаление курсива
text = re.sub(r"\*(.+?)\*", r"\1", text)
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
# Удаление зачеркнутого ~~text~~
# Удаление зачеркнутого
text = re.sub(r"~~(.+?)~~", r"\1", text)
# Удаление заголовков Markdown (# Header)
# Заголовки
text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
# Удаление картинок ![alt](url) -> удаляем полностью
# Картинки
text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", text)
# Удаление ссылок [text](url) -> оставляем только text
# \x5B = [, \x5D = ]
# Ссылки
text = re.sub(r"\x5B([^\x5D]+)\x5D\([^)]+\)", r"\1", text)
# Удаление inline кода `code`
# Код
text = re.sub(r"`([^`]+)`", r"\1", text)
# Удаление блоков кода ```code```
text = re.sub(r"```[\s\S]*?```", "", text)
# Удаление маркеров списков (-, *, 1.)
# Списки
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE)
# Удаление цитат >
# Цитаты
text = re.sub(r"^\s*>\s*", "", text, flags=re.MULTILINE)
# Удаление горизонтальных линий ---
# Линии
text = re.sub(r"^[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
# Удаление HTML тегов
# HTML теги
text = re.sub(r"<[^>]+>", "", text)
# Remove informal slang greetings at the beginning of sentences/responses
# Корректировки
text = re.sub(
r"([—-])\s*это,\s*скорее\s*всего\b\s*,?\s*",
r"\1 ",
text,
flags=re.IGNORECASE,
)
text = re.sub(r"[—-]\s*([.!?])", r"\1", text)
# Удаление сленга
text = re.sub(
r"^(Эй|Хэй|Слушай|Так|Ну|Короче|В\s+общем)[,!?:]?\s*",
"",
@@ -323,11 +493,17 @@ def clean_response(text: str, language: str = "ru") -> str:
flags=re.IGNORECASE | re.MULTILINE,
)
# Convert numbers to words only for Russian, and only if digits exist
if language == "ru" and re.search(r"\d", text):
text = numbers_to_words(text)
# Запрет на произнесение wake word в любых ответах ассистента.
for pattern in WAKE_WORD_BLOCKED_PATTERNS:
text = pattern.sub("ассистент", text)
# Remove extra whitespace
# Числа в слова
if language == "ru":
text = roman_numerals_to_words(text)
if re.search(r"\d", text):
text = numbers_to_words(text)
# Чистка пробелов
text = re.sub(r"\n{3,}", "\n\n", text)
text = re.sub(r" +", " ", text)

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,22 +7,121 @@ 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
# --- Настройки AI (Perplexity) ---
# API ключ для доступа к нейросети
PERPLEXITY_API_KEY = os.getenv("PERPLEXITY_API_KEY")
# Модель, которую будем использовать (по умолчанию llama-3.1-sonar-small-128k-chat)
PERPLEXITY_MODEL = os.getenv("PERPLEXITY_MODEL", "llama-3.1-sonar-small-128k-chat")
PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"
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.
# Если активных ключей несколько, AI-модуль вернет ошибку конфигурации.
AI_PROVIDER = os.getenv("AI_PROVIDER", "openrouter").strip().lower()
# OpenRouter
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini")
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")
OPENAI_API_URL = os.getenv(
"OPENAI_API_URL", "https://api.openai.com/v1/chat/completions"
)
# Gemini (через официальный OpenAI-compatible endpoint Google)
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
GEMINI_API_URL = os.getenv(
"GEMINI_API_URL",
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
)
# Z.ai
ZAI_API_KEY = os.getenv("ZAI_API_KEY")
ZAI_MODEL = os.getenv("ZAI_MODEL", "glm-5")
ZAI_API_URL = os.getenv(
"ZAI_API_URL", "https://api.z.ai/api/paas/v4/chat/completions"
)
# Anthropic Claude
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514")
ANTHROPIC_API_URL = os.getenv(
"ANTHROPIC_API_URL", "https://api.anthropic.com/v1/messages"
)
ANTHROPIC_API_VERSION = os.getenv("ANTHROPIC_API_VERSION", "2023-06-01")
# Ollama (локальные модели; OpenAI-compatible endpoint)
# Обычно Ollama слушает http://localhost:11434 и предоставляет /v1/chat/completions.
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)
@@ -31,14 +130,87 @@ DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY")
# --- Настройки активации голосом (Porcupine) ---
# Ключ доступа PicoVoice
PORCUPINE_ACCESS_KEY = os.getenv("PORCUPINE_ACCESS_KEY")
# Wake word label and common ASR aliases.
WAKE_WORD = "Waltron"
WAKE_WORD_ALIASES = (
"waltron",
"voltron",
"волтрон",
"уолтрон",
"валтрон",
)
# Путь к файлу модели ключевого слова (.ppn), который лежит в папке assets/models
PORCUPINE_KEYWORD_PATH = BASE_DIR / "assets" / "models" / "Alexandr_en_linux_v4_0_0.ppn"
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
# --- Параметры аудио ---
# Частота дискретизации для микрофона (стандарт для распознавания речи)
SAMPLE_RATE = 16000
CHANNELS = 1
# Выбор устройства ввода (микрофона).
# Если не задано, используем default input device PortAudio (если есть).
# Пример:
# - AUDIO_INPUT_DEVICE_NAME=pulse
# - AUDIO_INPUT_DEVICE_INDEX=2
AUDIO_INPUT_DEVICE_NAME = os.getenv("AUDIO_INPUT_DEVICE_NAME", "").strip() or None
_audio_index_raw = os.getenv("AUDIO_INPUT_DEVICE_INDEX", "").strip()
try:
AUDIO_INPUT_DEVICE_INDEX = int(_audio_index_raw) if _audio_index_raw else None
except Exception:
AUDIO_INPUT_DEVICE_INDEX = None
# Выбор устройства вывода (динамик).
# Если не задано, используем default output device PortAudio (если есть).
# Пример:
# - AUDIO_OUTPUT_DEVICE_NAME=pulse
# - AUDIO_OUTPUT_DEVICE_INDEX=5
AUDIO_OUTPUT_DEVICE_NAME = os.getenv("AUDIO_OUTPUT_DEVICE_NAME", "").strip() or None
_audio_out_index_raw = os.getenv("AUDIO_OUTPUT_DEVICE_INDEX", "").strip()
try:
AUDIO_OUTPUT_DEVICE_INDEX = (
int(_audio_out_index_raw) if _audio_out_index_raw else None
)
except Exception:
AUDIO_OUTPUT_DEVICE_INDEX = None
# --- Настройка времени ---
# Устанавливаем часовой пояс на Москву, чтобы будильник работал корректно
@@ -46,14 +218,33 @@ os.environ["TZ"] = "Europe/Moscow"
time.tzset()
# --- Настройки синтеза речи (TTS) ---
# --- Sound effects (SFX) ---
# Короткий "beep" после wake word и перед запуском STT, чтобы пользователь понял:
# можно начинать говорить. Поддерживает wav/mp3 (если pygame mixer поддерживает mp3),
# иначе будет использован mpg123 как fallback.
_stt_sfx_default = BASE_DIR / "assets" / "sounds" / "alisa-golosovoj-pomoschnik.mp3"
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)
# Звук старта 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")
WEATHER_LON = os.getenv("WEATHER_LON")
WEATHER_CITY = os.getenv("WEATHER_CITY", "Ухта")
# --- Настройки Navidrome (музыка) ---
NAVIDROME_URL = os.getenv("NAVIDROME_URL", "").strip().rstrip("/")
NAVIDROME_USERNAME = os.getenv("NAVIDROME_USERNAME", "").strip()
NAVIDROME_PASSWORD = os.getenv("NAVIDROME_PASSWORD", "")

43
app/core/roman.py Normal file
View File

@@ -0,0 +1,43 @@
"""Roman numeral parsing helpers."""
import re
_ROMAN_VALID_RE = re.compile(
r"^M{0,3}(CM|CD|D?C{0,3})"
r"(XC|XL|L?X{0,3})"
r"(IX|IV|V?I{0,3})$"
)
_ROMAN_TOKEN_RE = re.compile(r"(?<![A-Za-zА-Яа-яЁё0-9])[IVXLCDMivxlcdm]+(?![A-Za-zА-Яа-яЁё0-9])")
_ROMAN_VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
def roman_to_int(token: str) -> int | None:
if not token:
return None
roman = token.strip().upper()
if not roman or not _ROMAN_VALID_RE.fullmatch(roman):
return None
total = 0
prev = 0
for char in reversed(roman):
value = _ROMAN_VALUES[char]
if value < prev:
total -= value
else:
total += value
prev = value
return total
def replace_roman_numerals(text: str) -> str:
if not text:
return text
def _repl(match: re.Match) -> str:
token = match.group(0)
value = roman_to_int(token)
return str(value) if value is not None else token
return _ROMAN_TOKEN_RE.sub(_repl, text)

View File

@@ -1,6 +1,4 @@
"""
Short, human-like responses for small talk.
"""
"""Small talk responses."""
from __future__ import annotations
@@ -9,18 +7,19 @@ import re
from typing import Optional
_SMALLTALK_PATTERNS = [
re.compile(r"\bкак дела\b", re.IGNORECASE),
re.compile(r"\bкак делишки\b", re.IGNORECASE),
re.compile(r"\bкак поживаешь\b", re.IGNORECASE),
re.compile(r"\bкак жизнь\b", re.IGNORECASE),
re.compile(r"\bкак ты\b", re.IGNORECASE),
re.compile(r"\bкак сам\b", re.IGNORECASE),
re.compile(r"\bкак себя чувствуешь\b", re.IGNORECASE),
re.compile(r"\bкак настроение\b", re.IGNORECASE),
re.compile(r"\bчто нового\b", re.IGNORECASE),
re.compile(r"\bкак оно\b", re.IGNORECASE),
]
_SMALLTALK_PHRASES = {
"как дела",
"как делишки",
"как поживаешь",
"как жизнь",
"как ты",
"как сам",
"как себя чувствуешь",
"как настроение",
"что нового",
"как оно",
"как дела у тебя",
}
_SMALLTALK_RESPONSES = [
"Все нормально, спасибо. А у тебя?",
@@ -31,13 +30,19 @@ _SMALLTALK_RESPONSES = [
]
def _normalize_smalltalk_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 get_smalltalk_response(text: str) -> Optional[str]:
if not text:
return None
normalized = text.lower().replace("ё", "е")
for pattern in _SMALLTALK_PATTERNS:
if pattern.search(normalized):
return random.choice(_SMALLTALK_RESPONSES)
normalized = _normalize_smalltalk_text(text)
if normalized in _SMALLTALK_PHRASES:
return random.choice(_SMALLTALK_RESPONSES)
return None

View File

@@ -10,11 +10,171 @@ from datetime import datetime
from ..core.config import BASE_DIR
from ..audio.stt import listen
from ..core.commands import is_stop_command
from ..core.roman import replace_roman_numerals
# Файл базы данных будильников
ALARM_FILE = BASE_DIR / "data" / "alarms.json"
# Звуковой файл сигнала
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
ASK_ALARM_TIME_PROMPT = "На какое время мне поставить будильник?"
_NUMBER_UNITS = {
"ноль": 0,
"один": 1,
"одна": 1,
"два": 2,
"две": 2,
"три": 3,
"четыре": 4,
"пять": 5,
"шесть": 6,
"семь": 7,
"восемь": 8,
"девять": 9,
}
_NUMBER_TEENS = {
"десять": 10,
"одиннадцать": 11,
"двенадцать": 12,
"тринадцать": 13,
"четырнадцать": 14,
"пятнадцать": 15,
"шестнадцать": 16,
"семнадцать": 17,
"восемнадцать": 18,
"девятнадцать": 19,
}
_NUMBER_TENS = {
"двадцать": 20,
"тридцать": 30,
"сорок": 40,
"пятьдесят": 50,
}
_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):
if start_index >= len(tokens):
return None, 0
token = tokens[start_index]
if token.isdigit():
return int(token), 1
if token in _NUMBER_TEENS:
return _NUMBER_TEENS[token], 1
if token in _NUMBER_TENS:
value = _NUMBER_TENS[token]
if start_index + 1 < len(tokens):
next_token = tokens[start_index + 1]
if next_token in _NUMBER_UNITS:
value += _NUMBER_UNITS[next_token]
return value, 2
return value, 1
if token in _NUMBER_UNITS:
return _NUMBER_UNITS[token], 1
return None, 0
def _apply_part_of_day(hour: int, part_of_day: str | None) -> int:
if not part_of_day:
return hour
if part_of_day == "утра":
return 0 if hour == 12 else hour
if part_of_day == "ночи":
return 0 if hour == 12 else hour
if part_of_day in {"дня", "вечера"} and hour < 12:
return hour + 12
return hour
def _extract_alarm_time_words(text: str):
tokens = re.findall(r"[a-zа-я0-9]+", text.lower().replace("ё", "е"))
for index, token in enumerate(tokens):
if token not in _ALARM_MARKERS:
continue
current = index + 1
while current < len(tokens) and tokens[current] in _FILLER_WORDS:
current += 1
hour, consumed = _parse_number_tokens(tokens, current)
if hour is None:
continue
current += consumed
if current < len(tokens) and tokens[current] in _HOUR_WORDS:
current += 1
minute = 0
if current < len(tokens) and tokens[current] not in _PARTS_OF_DAY:
parsed_minute, minute_consumed = _parse_number_tokens(tokens, current)
if parsed_minute is not None:
minute = parsed_minute
current += minute_consumed
if current < len(tokens) and tokens[current] in _MINUTE_WORDS:
current += 1
part_of_day = None
if current < len(tokens) and tokens[current] in _PARTS_OF_DAY:
part_of_day = tokens[current]
if 0 <= hour <= 23 and 0 <= minute <= 59:
return _apply_part_of_day(hour, part_of_day), minute
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:
@@ -68,10 +228,10 @@ class AlarmClock:
if re.search(r"\b(каждый день|ежедневно)\b", text):
return [0, 1, 2, 3, 4, 5, 6]
if re.search(r"\b(по будн|в будн|будние)\b", text):
if re.search(r"\b(?:по\s+будн\w*|в\s+будн\w*|будн\w*)\b", text):
days.update([0, 1, 2, 3, 4])
if re.search(r"\b(по выходн|в выходн|выходные)\b", text):
if re.search(r"\b(?:по\s+выходн\w*|в\s+выходн\w*|выходн\w*)\b", text):
days.update([5, 6])
day_patterns = {
@@ -112,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 (
@@ -120,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}
@@ -133,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):
"""Выключение (деактивация) всех будильников."""
@@ -141,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 = [
@@ -229,7 +426,7 @@ class AlarmClock:
try:
# Цикл ожидания стоп-команды
while True:
text = listen(timeout_seconds=3.0, detection_timeout=3.0)
text = listen(timeout_seconds=3.0, detection_timeout=3.0, fast_stop=True)
if text:
if is_stop_command(text, mode="lenient"):
print(f"🛑 Будильник остановлен по команде: '{text}'")
@@ -248,59 +445,61 @@ class AlarmClock:
def parse_command(self, text: str) -> str | None:
"""
Парсинг команды установки будильника из текста.
Примеры: "разбуди в 7:30", "будильник на 8 утра".
Парсинг команд управления будильниками.
Примеры: "разбуди в 7:30", "удали будильник на 8:00", "какие будильники".
"""
text = 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:
self.cancel_all_alarms()
return "Хорошо, я отменил все будильники."
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)
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}."
# Поиск формата "7:30", "7.30"
match = re.search(r"\b(\d{1,2})[:.-](\d{2})\b", text)
if match:
h, m = int(match.group(1)), int(match.group(2))
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}."
if _ALARM_CREATE_RE.search(text) or text.strip() in {
"будильник",
"поставь будильник",
"создай будильник",
"добавь будильник",
}:
return ASK_ALARM_TIME_PROMPT
# Поиск формата словами "на 7 часов 15 минут"
match_time = re.search(
r"на\s+(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?",
text,
return (
"Я не понял команду для будильника. "
"Скажите, например: поставь на 7:30, покажи будильники или удали будильник на 7:30."
)
if match_time:
h = int(match_time.group(1))
m = int(match_time.group(2)) if match_time.group(2) else 0
# Умная коррекция времени (если говорят "в 8", а сейчас 9, то это скорее 8 вечера или 8 утра завтра)
# Здесь простая логика AM/PM
if "вечера" in text and h < 12:
h += 12
elif "утра" in text and h == 12:
h = 0
if 0 <= h <= 23 and 0 <= m <= 59:
self.add_alarm_with_days(h, m, days=days)
days_phrase = self._format_days_phrase(days)
suffix = f" {days_phrase}" if days_phrase else ""
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
# Глобальный экземпляр
_alarm_clock = None

File diff suppressed because it is too large Load Diff

267
app/features/stopwatch.py Normal file
View File

@@ -0,0 +1,267 @@
"""Stopwatch module."""
import json
import re
from datetime import datetime
from ..core.config import BASE_DIR
STOPWATCH_FILE = BASE_DIR / "data" / "stopwatches.json"
# Optional ordinal formatting for list numbering.
try:
from num2words import num2words
except Exception:
num2words = None
def _format_ordinal_index(index: int) -> str:
if num2words is None:
return f"{index}"
try:
return num2words(index, lang="ru", to="ordinal", case="nominative", gender="m")
except Exception:
return f"{index}"
def _format_duration(seconds: float) -> str:
total = int(round(max(0, seconds)))
hours = total // 3600
minutes = (total % 3600) // 60
sec = total % 60
parts = []
if hours:
parts.append(f"{hours} ч")
if minutes:
parts.append(f"{minutes} мин")
parts.append(f"{sec} сек")
return " ".join(parts)
class StopwatchManager:
def __init__(self):
self.stopwatches = []
self.load_stopwatches()
def load_stopwatches(self):
if not STOPWATCH_FILE.exists():
return
try:
with open(STOPWATCH_FILE, "r", encoding="utf-8") as f:
raw = json.load(f)
except Exception as e:
print(f"❌ Ошибка загрузки секундомеров: {e}")
return
items = []
for item in raw:
try:
stopwatch_id = int(item["id"])
except Exception:
continue
items.append(
{
"id": stopwatch_id,
"name": str(item.get("name", "")).strip(),
"elapsed": float(item.get("elapsed", 0)),
"running": bool(item.get("running", False)),
"started_at": item.get("started_at"),
}
)
self.stopwatches = sorted(items, key=lambda x: x["id"])
def save_stopwatches(self):
payload = [
{
"id": sw["id"],
"name": sw.get("name", ""),
"elapsed": sw.get("elapsed", 0),
"running": sw.get("running", False),
"started_at": sw.get("started_at"),
}
for sw in self.stopwatches
]
try:
with open(STOPWATCH_FILE, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=4)
except Exception as e:
print(f"❌ Ошибка сохранения секундомеров: {e}")
def _next_id(self) -> int:
if not self.stopwatches:
return 1
return max(sw["id"] for sw in self.stopwatches) + 1
def _now_iso(self) -> str:
return datetime.now().isoformat()
def _elapsed_now(self, stopwatch: dict) -> float:
elapsed = float(stopwatch.get("elapsed", 0))
if not stopwatch.get("running"):
return elapsed
started_at = stopwatch.get("started_at")
if not started_at:
return elapsed
try:
started_dt = datetime.fromisoformat(started_at)
except Exception:
return elapsed
delta = (datetime.now() - started_dt).total_seconds()
return elapsed + max(0, delta)
def _running(self):
return [sw for sw in self.stopwatches if sw.get("running")]
def _paused(self):
return [sw for sw in self.stopwatches if not sw.get("running")]
def has_running_stopwatches(self) -> bool:
return bool(self._running())
def describe_active_stopwatches(self) -> str:
running = self._running()
if not running:
return "Активных секундомеров нет."
running.sort(key=lambda sw: sw["id"])
items = []
for idx, sw in enumerate(running, start=1):
ordinal = _format_ordinal_index(idx)
duration = _format_duration(self._elapsed_now(sw))
name = sw.get("name", "")
if name:
items.append(f"{ordinal}) {name}{duration}")
else:
items.append(f"{ordinal}) {duration}")
return "Активные секундомеры: " + "; ".join(items) + "."
def start_stopwatch(self, name: str = "") -> str:
stopwatch = {
"id": self._next_id(),
"name": name.strip(),
"elapsed": 0.0,
"running": True,
"started_at": self._now_iso(),
}
self.stopwatches.append(stopwatch)
self.save_stopwatches()
if stopwatch["name"]:
return f"Запустил секундомер «{stopwatch['name']}»."
return "Запустил секундомер."
def pause_stopwatches(self) -> str:
running = self._running()
if not running:
return "Сейчас нет активных секундомеров."
elapsed_items = []
for sw in running:
elapsed_now = self._elapsed_now(sw)
elapsed_items.append(
{
"id": sw["id"],
"name": sw.get("name", ""),
"elapsed": elapsed_now,
}
)
sw["elapsed"] = elapsed_now
sw["running"] = False
sw["started_at"] = None
self.save_stopwatches()
count = len(running)
if count == 1:
elapsed_text = _format_duration(elapsed_items[0]["elapsed"])
return f"Остановил секундомер. Он работал {elapsed_text}."
details = []
for idx, item in enumerate(sorted(elapsed_items, key=lambda x: x["id"]), start=1):
ordinal = _format_ordinal_index(idx)
elapsed_text = _format_duration(item["elapsed"])
name = item.get("name", "")
if name:
details.append(f"{ordinal} «{name}» — {elapsed_text}")
else:
details.append(f"{ordinal}{elapsed_text}")
return f"Остановил секундомеры: {count} шт. Время: " + "; ".join(details) + "."
def resume_stopwatches(self) -> str:
paused = self._paused()
if not paused:
return "Пауза не активна: секундомеры уже запущены или отсутствуют."
for sw in paused:
sw["running"] = True
sw["started_at"] = self._now_iso()
self.save_stopwatches()
count = len(paused)
if count == 1:
return "Продолжил секундомер."
return f"Продолжил секундомеры: {count} шт."
def reset_stopwatches(self) -> str:
if not self.stopwatches:
return "Секундомеров для сброса нет."
count = len(self.stopwatches)
self.stopwatches = []
self.save_stopwatches()
if count == 1:
return "Секундомер сброшен."
return f"Сбросил секундомеры: {count} шт."
def parse_command(self, text: str) -> str | None:
text = text.lower().strip()
has_stopwatch_word = any(
word in text
for word in [
"секундомер",
"секундомеры",
"секундомером",
"секундомера",
"секундомеру",
]
)
if not has_stopwatch_word:
return None
if re.search(r"(какие|какой|список|активн|покажи|сколько|есть ли)", text):
return self.describe_active_stopwatches()
if any(word in text for word in ["сброс", "удали", "отмени", "очист"]):
return self.reset_stopwatches()
if any(word in text for word in ["продолж", "возобнов"]):
return self.resume_stopwatches()
if any(word in text for word in ["стоп", "останов", "пауза"]):
return self.pause_stopwatches()
if "постав" in text or "установ" in text:
return self.start_stopwatch()
if any(word in text for word in ["запусти", "включи", "старт", "начни"]):
return self.start_stopwatch()
# Если пользователь просто сказал "секундомер", трактуем как запуск.
if text in {"секундомер", "запусти секундомер", "включи секундомер"}:
return self.start_stopwatch()
return "Я понял команду про секундомер, но не распознал действие. Скажите: запусти, стоп, продолжи, сбрось или покажи активные секундомеры."
_stopwatch_manager = None
def get_stopwatch_manager():
global _stopwatch_manager
if _stopwatch_manager is None:
_stopwatch_manager = StopwatchManager()
return _stopwatch_manager

View File

@@ -1,8 +1,5 @@
"""Timer module."""
# Модуль таймера.
# Отвечает за установку таймеров (в оперативной памяти), их проверку и воспроизведение звука.
import subprocess
import re
import json
@@ -10,6 +7,7 @@ from datetime import datetime, timedelta
from ..core.config import BASE_DIR
from ..audio.stt import listen
from ..core.commands import is_stop_command
from ..core.roman import replace_roman_numerals
# Morphological analysis for better recognition of number words.
try:
@@ -22,8 +20,9 @@ except Exception:
# Звуковой файл сигнала (используем тот же, что и для будильника)
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
TIMER_FILE = BASE_DIR / "data" / "timers.json"
ASK_TIMER_TIME_PROMPT = "На какое время мне поставить таймер?"
# --- Number words parsing helpers (ru) ---
# Числа словами
_NUMBER_UNITS = {
"ноль": 0,
"один": 1,
@@ -101,13 +100,19 @@ _UNIT_LEMMAS = {
"мин": "minutes",
"сек": "seconds",
}
_UNIT_LEMMAS = {
"час": "hours",
"минута": "minutes",
"секунда": "seconds",
"мин": "minutes",
"сек": "seconds",
}
_UNIT_FORMS = {
"hours": ("час", "часа", "часов"),
"minutes": ("минуту", "минуты", "минут"),
"seconds": ("секунду", "секунды", "секунд"),
}
# Optional ordinal formatting for list numbering.
try:
from num2words import num2words
except Exception:
@@ -162,11 +167,13 @@ def _parse_number_lemmas(lemmas):
def _normalize_timer_text(text: str) -> str:
# Split "полчаса/полминуты/полсекунды" into "пол часа" for easier parsing.
return re.sub(
text = re.sub(
r"(?i)\bпол(?=(?:час|часа|минут|минуты|минуту|секунд|секунды|секунду|мин|сек)\b)",
"пол ",
text,
)
# Support commands like "таймер на X минут".
return replace_roman_numerals(text)
def _find_word_number_before_unit(tokens, unit_index):
@@ -247,12 +254,11 @@ def _format_ordinal_index(index: int) -> str:
class TimerManager:
def __init__(self):
# Список активных таймеров: {"end_time": datetime, "label": str}
self.timers = []
self.load_timers()
def load_timers(self):
"""Загрузка списка таймеров из JSON файла."""
"""Загрузка из файла."""
if TIMER_FILE.exists():
try:
with open(TIMER_FILE, "r", encoding="utf-8") as f:
@@ -273,7 +279,7 @@ class TimerManager:
self.timers = sorted(timers, key=lambda x: x["end_time"])
def save_timers(self):
"""Сохранение списка таймеров в JSON файл."""
"""Сохранение в файл."""
payload = [
{"end_time": t["end_time"].isoformat(), "label": t.get("label", "")}
for t in self.timers
@@ -285,7 +291,7 @@ class TimerManager:
print(f"❌ Ошибка сохранения таймеров: {e}")
def describe_timers(self) -> str:
"""Возвращает текстовое описание активных таймеров."""
"""Описание активных таймеров."""
if not self.timers:
return "Активных таймеров нет."
@@ -308,38 +314,29 @@ class TimerManager:
return "Активные таймеры: " + "; ".join(items) + "."
def add_timer(self, seconds: int, label: str):
"""Добавление нового таймера."""
"""Добавить таймер."""
end_time = datetime.now() + timedelta(seconds=seconds)
self.timers.append({"end_time": end_time, "label": label})
# Сортируем, чтобы ближайший был первым
self.timers.sort(key=lambda x: x["end_time"])
self.save_timers()
print(f"⏳ Таймер установлен на {label} (до {end_time.strftime('%H:%M:%S')})")
print(f"⏳ Таймер: {label} (до {end_time.strftime('%H:%M:%S')})")
def cancel_all_timers(self):
"""Отмена всех таймеров."""
"""Отменить все таймеры."""
count = len(self.timers)
self.timers = []
self.save_timers()
print(f"🔕 Все таймеры ({count}) отменены.")
print(f"🔕 Таймеры отменены: {count}")
def check_timers(self):
"""
Проверка: не истек ли какой-то таймер?
Вызывается в главном цикле.
Возвращает True, если таймер сработал (и был обработан).
"""
"""Проверка таймеров. Возвращает True если сработал."""
if not self.timers:
return False
now = datetime.now()
# Смотрим первый (самый ранний) таймер
# Используем индекс 0, так как список отсортирован
first_timer = self.timers[0]
if now >= first_timer["end_time"]:
# Таймер сработал!
# Удаляем его из списка
label = first_timer["label"]
self.timers.pop(0)
self.save_timers()
@@ -351,36 +348,30 @@ class TimerManager:
return False
def trigger_timer(self, label: str):
"""
Логика срабатывания таймера.
Запускает воспроизведение MP3 и слушает команду "Стоп".
"""
print(f"🔔 ТАЙМЕР НА {label} СРАБОТАЛ! (Скажите 'Стоп')")
"""Срабатывание таймера."""
print(f"🔔 ТАЙМЕР {label}!")
# Запуск плеера mpg123 в бесконечном цикле
cmd = ["mpg123", "-q", "--loop", "-1", str(ALARM_SOUND)]
try:
process = subprocess.Popen(cmd)
except FileNotFoundError:
print(
"❌ Ошибка: mpg123 не найден. Установите его: sudo apt install mpg123"
)
print("❌ mpg123 не найден. Установите: sudo apt install mpg123")
return
try:
# Цикл ожидания стоп-команды
while True:
text = listen(timeout_seconds=3.0, detection_timeout=3.0)
text = listen(
timeout_seconds=3.0, detection_timeout=3.0, fast_stop=True
)
if text:
if is_stop_command(text, mode="lenient"):
print(f"🛑 Таймер остановлен по команде: '{text}'")
print(f"🛑 Остановлен: '{text}'")
break
except Exception as e:
print(f"❌ Ошибка во время таймера: {e}")
print(f"❌ Ошибка: {e}")
finally:
# Обязательно убиваем процесс плеера
process.terminate()
try:
process.wait(timeout=1)
@@ -389,12 +380,9 @@ class TimerManager:
print("🔕 Таймер выключен.")
def parse_command(self, text: str) -> str | None:
"""
Парсинг команды установки таймера.
Примеры: "таймер на 5 минут", "засеки 10 секунд".
"""
"""Парсинг команды таймера."""
text = _normalize_timer_text(text.lower())
# Ключевые слова для таймера
if not any(word in text for word in ["таймер", "засеки", "поставь таймер"]):
return None
@@ -409,9 +397,6 @@ class TimerManager:
return "Хорошо, все таймеры отменены."
# Поиск времени
# Ищем комбинации: число + (час/мин/сек)
# Пример: "1 час 30 минут", "5 минут", "30 секунд"
total_seconds = 0
parts = []
hours = None
@@ -434,7 +419,7 @@ class TimerManager:
if match_seconds:
seconds = int(match_seconds.group(1))
# Дополняем числительные словами (например, "одну минуту")
# Числа словами
word_values = _extract_word_time_values(text)
if hours is None and word_values["hours"] is not None:
hours = word_values["hours"]
@@ -452,9 +437,7 @@ class TimerManager:
found_time = any(value is not None for value in [hours, minutes, seconds])
if found_time:
total_seconds = (
(hours or 0) * 3600 + (minutes or 0) * 60 + (seconds or 0)
)
total_seconds = (hours or 0) * 3600 + (minutes or 0) * 60 + (seconds or 0)
if has_fractional:
total_seconds = int(round(total_seconds))
h = total_seconds // 3600
@@ -476,9 +459,17 @@ class TimerManager:
label = " ".join(parts)
self.add_timer(total_seconds, label)
return f"Поставил таймер на {label}."
# Если сказали "таймер", но не нашли время
return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'."
# Если время не названо — спрашиваем
if re.search(
r"(постав|установ|запусти|включи|засеки)", text
) or text.strip() in {
"таймер",
"поставь таймер",
}:
return ASK_TIMER_TIME_PROMPT
return "Я не понял, на сколько поставить таймер."
# Глобальный экземпляр

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()
# 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()
# Common endings for different cases in Russian
# Prepositional case endings (-е, -и, -у, etc.)
prepositional_endings = ['е', 'и', 'у', 'о', 'й']
genitive_endings = ['а', 'я', 'ов', 'ев', 'ин', 'ын']
instrumental_endings = ['ом', 'ем', 'ой', 'ей']
# 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
# 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] + 'ия'
# Capitalize appropriately
if base_form != lower_city:
return base_form.capitalize()
# Dictionary mapping specific known variations
case_variations = {
"нью-йорке": "Нью-Йорк",
"нью-йорка": "Нью-Йорк",
"нью-йорком": "Нью-Йорк",
"москве": "Москва",
"москвы": "Москва",
"москвой": "Москва",
"москву": "Москва",
"лондоне": "Лондон",
"лондона": "Лондон",
"лондоном": "Лондон",
"париже": "Париж",
"парижа": "Париж",
"парижем": "Париж",
"берлине": "Берлин",
"берлина": "Берлин",
"берлином": "Берлин",
"пекине": "Пекин",
"пекина": "Пекин",
"пекином": "Пекин",
"роме": "Рим",
"рима": "Рим",
"римом": "Рим",
"мадриде": "Мадрид",
"мадрида": "Мадрид",
"мадридом": "Мадрид",
"сиднее": "Сидней",
"сиднея": "Сидней",
"сиднеем": "Сидней",
"вашингтоне": "Вашингтон",
"вашингтона": "Вашингтон",
"вашингтоном": "Вашингтон",
"лос-анджелесе": "Лос-Анджелес",
"лос-анджелеса": "Лос-Анджелес",
"лос-анджелесом": "Лос-Анджелес",
"сиэтле": "Сиэтл",
"сиэтла": "Сиэтл",
"сиэтлом": "Сиэтл",
"бостоне": "Бостон",
"бостона": "Бостон",
"бостоном": "Бостон",
"денвере": "Денвер",
"денвера": "Денвер",
"денвером": "Денвер",
"хьюстоне": "Хьюстон",
"хьюстона": "Хьюстон",
"хьюстоном": "Хьюстон",
"фениксе": "Феникс",
"феникса": "Феникс",
"фениксом": "Феникс",
"атланте": "Атланта",
"атланты": "Атланта",
"атлантой": "Атланта",
"портленде": "Портленд",
"портленда": "Портленд",
"портлендом": "Портленд",
"остине": "Остин",
"остина": "Остин",
"остином": "Остин",
"нэшвилле": "Нэшвилл",
"нэшвилла": "Нэшвилл",
"нэшвиллом": "Нэшвилл",
"сан-франциско": "Сан-Франциско",
"токио": "Токио",
"торонто": "Торонто",
"чикаго": "Чикаго",
"майами": "Майами",
}
return case_variations.get(lower_city, city_name)
lowered = str(city_name or "").lower().replace("ё", "е").strip()
if not lowered:
return city_name
lowered = _CITY_PREFIX_RE.sub("", lowered)
lowered = _CITY_SPACING_RE.sub(" ", lowered).strip(" -")
if not lowered:
return city_name
exact_match = _KNOWN_CITY_VARIATIONS.get(lowered)
if exact_match:
return exact_match
single_word_match = _SINGLE_WORD_CITY_VARIATIONS.get(lowered)
if single_word_match:
return single_word_match
spaced = lowered.replace("-", " ")
exact_match = _KNOWN_CITY_VARIATIONS.get(spaced)
if exact_match:
return exact_match
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
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:

File diff suppressed because it is too large Load Diff

1
assets/models/LICENSE.txt Executable file
View File

@@ -0,0 +1 @@
A copy of license terms is available at https://picovoice.ai/docs/terms-of-use/

Binary file not shown.

Binary file not shown.

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"
}
]

9
data/stopwatches.json Normal file
View File

@@ -0,0 +1,9 @@
[
{
"id": 1,
"name": "",
"elapsed": 92.426419,
"running": false,
"started_at": null
}
]

29
scripts/qwen-check.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
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_BIN" -m compileall app run.py >/dev/null
echo "[qwen-check] Optional ruff check"
if command -v ruff >/dev/null 2>&1; then
ruff check app run.py
else
echo "[qwen-check] ruff not installed, skipping"
fi
echo "[qwen-check] Optional pytest"
if command -v pytest >/dev/null 2>&1 && [ -d tests ]; then
pytest -q
else
echo "[qwen-check] tests/ or pytest not found, skipping"
fi
echo "[qwen-check] Done"

22
scripts/qwen-context.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT="$ROOT/.qwen/project-context.txt"
{
echo "Project: alexander_smart-speaker"
echo "Generated: $(date -Iseconds)"
echo
echo "Top-level files:"
find "$ROOT" -maxdepth 2 -type f \
! -path '*/.git/*' \
! -path '*/venv/*' \
! -path '*/__pycache__/*' \
| sed "s|$ROOT/||" | sort
echo
echo "Python modules:"
find "$ROOT/app" -type f -name '*.py' | sed "s|$ROOT/||" | sort
} > "$OUT"
echo "[qwen-context] Wrote $OUT"