Compare commits
39 Commits
b6178f0952
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42c064a274 | ||
| ebe79c3692 | |||
| 59a607ba57 | |||
| 2088f366b6 | |||
| 715d7b0ee0 | |||
| 4b442795f8 | |||
| 3df24e27ae | |||
| 6add70fcd2 | |||
| cb54a9ee75 | |||
| e1a94c68db | |||
| 6c2702d5e3 | |||
| e9f26f8050 | |||
| 6769486e83 | |||
| 167ddc9264 | |||
| bed4ba36d7 | |||
| 3947fdf59f | |||
| c85e0267cd | |||
| 974f99ea8f | |||
| f1bc254c6b | |||
| 27ee32be38 | |||
| 7ca6958488 | |||
| a87840c78d | |||
| ff52b75073 | |||
| ea3ab4ff84 | |||
| e832f751bc | |||
| 2b40cf7d26 | |||
| 0d61b92760 | |||
| 182361c547 | |||
| 756cc340dc | |||
| d36d4be95f | |||
| 1a79af7058 | |||
| 03d7dc01c2 | |||
| 551f890b3c | |||
| df61febe28 | |||
| ca8ebd6657 | |||
| bb3133a1c0 | |||
| 875ff7d2c4 | |||
| a342b05875 | |||
| 69f7fce606 |
63
.env.example
63
.env.example
@@ -1,11 +1,70 @@
|
|||||||
PERPLEXITY_API_KEY=your_perplexity_api_key_here
|
# Оставьте незакомментированным только один AI API KEY.
|
||||||
PERPLEXITY_MODEL=llama-3.1-sonar-small-128k-chat
|
# Если одновременно указать несколько 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
|
DEEPGRAM_API_KEY=your_deepgram_api_key_here
|
||||||
PORCUPINE_ACCESS_KEY=your_porcupine_access_key_here
|
PORCUPINE_ACCESS_KEY=your_porcupine_access_key_here
|
||||||
|
PORCUPINE_SENSITIVITY=0.8
|
||||||
|
# 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
|
TTS_EN_SPEAKER=en_0
|
||||||
WEATHER_LAT=63.56
|
WEATHER_LAT=63.56
|
||||||
WEATHER_LON=53.69
|
WEATHER_LON=53.69
|
||||||
WEATHER_CITY=Ухта
|
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_ID=your_spotify_client_id
|
||||||
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
|
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
|
||||||
SPOTIFY_REDIRECT_URI=http://localhost:8888/callback
|
SPOTIFY_REDIRECT_URI=http://localhost:8888/callback
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -11,6 +11,15 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
.qwen
|
||||||
|
qwen.md
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
|
||||||
|
# AI configs
|
||||||
|
11.py
|
||||||
|
.qwen/
|
||||||
|
QWEN.md
|
||||||
|
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
build/
|
build/
|
||||||
@@ -38,6 +47,9 @@ vosk-model-*/
|
|||||||
# VS Code
|
# VS Code
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Runtime state
|
||||||
|
data/music_state.json
|
||||||
|
|
||||||
|
|
||||||
.beads
|
.beads
|
||||||
.gitattributes
|
.gitattributes
|
||||||
|
|||||||
40
AGENTS.md
40
AGENTS.md
@@ -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
15
Makefile
Normal 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
|
||||||
288
README.md
288
README.md
@@ -1,146 +1,240 @@
|
|||||||
# 🎙️ Alexander Smart Speaker
|
# Alexander Smart Speaker
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|
Голосовой ассистент для 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.
|
[](https://www.python.org/)
|
||||||
|
[](https://www.linux.org/)
|
||||||
[Features](#-features) • [Installation](#-installation) • [Usage](#-usage) • [Architecture](#-architecture)
|
[](LICENSE.txt)
|
||||||
|
[](https://picovoice.ai/platform/porcupine/)
|
||||||
|
[](https://deepgram.com/)
|
||||||
|
[](https://github.com/snakers4/silero-models)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
## Что это
|
||||||
|
|
||||||
## ✨ Features
|
`Alexander Smart Speaker` слушает ключевое слово `Waltron`, распознает речь, маршрутизирует команду в нужный модуль и озвучивает ответ.
|
||||||
|
Проект оптимизирован под русский язык, но поддерживает RU/EN сценарии (включая перевод и mixed-language TTS).
|
||||||
|
|
||||||
### 🧠 Artificial Intelligence
|
Проект собран как локальная голосовая колонка под Linux: активация по wake word, распознавание речи, маршрутизация команд, ответ через AI или встроенные модули и затем озвучка результата.
|
||||||
* **Smart Dialogue**: Context-aware conversations powered by **Perplexity AI** (Llama 3.1).
|
|
||||||
* **Translator**: Instant bidirectional translation (RU ↔ EN) with native pronunciation.
|
|
||||||
|
|
||||||
### 🗣️ 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
|
- Активация по wake word `Waltron` (Porcupine).
|
||||||
* **⛅ Weather**: Detailed forecasts (current, daily range, hourly) via Open-Meteo.
|
- Follow-up окно 4 секунды после ответа: если пользователь молчит, ассистент возвращается к ожиданию wake word.
|
||||||
* **⏰ Alarm & Timer**: Voice-controlled alarms and timers.
|
- Распознавание речи через Deepgram (WebSocket, VAD, fast stop).
|
||||||
* **🔊 System Control**: Adjust system volume via voice commands.
|
- Озвучка через 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
|
||||||
### 1. Prerequisites
|
A[Wake Word: Waltron] --> B[STT: Deepgram]
|
||||||
* **OS**: Linux
|
B --> C{Маршрутизация команды}
|
||||||
* **Python**: 3.9+
|
C --> D[Feature modules]
|
||||||
* **System Libraries**:
|
C --> E[AI/Translation]
|
||||||
```bash
|
D --> F[TTS: Silero]
|
||||||
sudo apt-get install portaudio19-dev libasound2-dev mpg123
|
E --> F
|
||||||
|
F --> G[Follow-up режим или ожидание wake word]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Setup
|
## Что важно в этой реализации
|
||||||
|
|
||||||
|
- Контекст диалога хранится в памяти текущей сессии, поэтому после первого вопроса можно продолжать разговор без потери нити.
|
||||||
|
- Системная роль ассистента и `ROLE_JSON` сохраняются для всех поддерживаемых AI-провайдеров.
|
||||||
|
- Для AI используется строго один активный API key. Если в `.env` оставить несколько ключей, ассистент покажет ошибку конфигурации вместо случайного выбора.
|
||||||
|
- Поддержка провайдеров сделана внутри одного модуля, но с разным форматом запросов для OpenAI-compatible API и Anthropic.
|
||||||
|
- Локальные модели через Ollama поддерживаются без API key (через OpenAI-compatible endpoint).
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### 1) Системные зависимости (Ubuntu/Debian)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
sudo apt-get update
|
||||||
git clone https://github.com/your-username/alexander_smart-speaker.git
|
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
|
cd alexander_smart-speaker
|
||||||
|
python3 -m venv venv
|
||||||
# Create virtual environment
|
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Configuration
|
### 3) Настройка `.env`
|
||||||
Create a `.env` file based on the example:
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Fill in your API keys in `.env`:
|
Минимально обязательные переменные:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
# AI & Speech APIs
|
AI_PROVIDER= # опционально; можно оставить пустым
|
||||||
PERPLEXITY_API_KEY=pplx-...
|
# Раскомментируйте только один AI API KEY:
|
||||||
|
# OPENROUTER_API_KEY=...
|
||||||
|
# OPENAI_API_KEY=...
|
||||||
|
# GEMINI_API_KEY=...
|
||||||
|
# ZAI_API_KEY=...
|
||||||
|
# ANTHROPIC_API_KEY=...
|
||||||
DEEPGRAM_API_KEY=...
|
DEEPGRAM_API_KEY=...
|
||||||
PORCUPINE_ACCESS_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
|
```bash
|
||||||
|
make run
|
||||||
|
# или
|
||||||
python run.py
|
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]
|
| `AI_PROVIDER` | Нет | `openrouter` | Опциональный провайдер AI (`openrouter`, `openai`, `gemini`, `zai`, `anthropic`, `ollama`; также понимает `claude`) |
|
||||||
Wake -->|Activated| STT[STT<br/>Deepgram]
|
| `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 |
|
||||||
|
|
||||||
STT --> Router{Command Router}
|
## Примеры голосовых команд
|
||||||
|
|
||||||
Router -->|Forecast| Weather[⛅ Weather<br/>Open-Meteo]
|
| Категория | Примеры |
|
||||||
Router -->|Time| Alarm[⏰ Alarm/Timer]
|
|---|---|
|
||||||
Router -->|Settings| Vol[🔊 Volume]
|
| Активация | `Waltron` |
|
||||||
Router -->|Translate| Translator[A↔B Translator]
|
| AI-диалог | `Почему небо голубое?` |
|
||||||
Router -->|Query| AI[🧠 Perplexity AI]
|
| Перевод | `Переведи на английский: как дела` |
|
||||||
|
| Погода | `Какая погода?`, `Погода в Москве` |
|
||||||
|
| Таймер | `Поставь таймер на 5 минут` |
|
||||||
|
| Будильник | `Поставь будильник на 7:30`, `Будильник по будням в 8:00` |
|
||||||
|
| Секундомер | `Запусти секундомер`, `Покажи активные секундомеры` |
|
||||||
|
| Громкость | `Громкость 7` |
|
||||||
|
| Музыка (Navidrome first) | `Включи музыку`, `Пауза`, `Продолжи`, `Следующий`, `Предыдущий`, `Что играет`, `Включи жанр electronic`, `Включи папку crystal castles` |
|
||||||
|
| Игра | `Давай сыграем в города` |
|
||||||
|
| Управление диалогом | `Повтори`, `Стоп`, `Хватит` |
|
||||||
|
|
||||||
Weather --> TTS
|
Память текущего диалога, история сообщений и `ROLE_JSON` системной роли сохраняются для всех поддерживаемых AI-провайдеров.
|
||||||
Alarm --> TTS
|
|
||||||
Vol --> TTS
|
|
||||||
Translator --> TTS
|
|
||||||
AI --> Cleaner[Text Cleaner]
|
|
||||||
Cleaner --> TTS[🗣️ TTS<br/>Silero]
|
|
||||||
|
|
||||||
TTS --> Speaker[🔊 Speaker]
|
## Как Выбирается 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.txt`.
|
||||||
MIT License. See `LICENSE.txt` for details.
|
|
||||||
|
|||||||
@@ -9,21 +9,59 @@ Regulates system volume on a scale from 1 to 10.
|
|||||||
import subprocess
|
import subprocess
|
||||||
import re
|
import re
|
||||||
import platform
|
import platform
|
||||||
|
from ..core.roman import replace_roman_numerals
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pymorphy3
|
||||||
|
|
||||||
|
_MORPH = pymorphy3.MorphAnalyzer()
|
||||||
|
except Exception:
|
||||||
|
_MORPH = None
|
||||||
|
|
||||||
# Карта для перевода слов в цифры ("пять" -> 5)
|
# Карта для перевода слов в цифры ("пять" -> 5)
|
||||||
NUMBER_MAP = {
|
NUMBER_MAP = {
|
||||||
|
"ноль": 0,
|
||||||
"один": 1,
|
"один": 1,
|
||||||
|
"одна": 1,
|
||||||
"раз": 1,
|
"раз": 1,
|
||||||
|
"единица": 1,
|
||||||
|
"единичка": 1,
|
||||||
"два": 2,
|
"два": 2,
|
||||||
|
"две": 2,
|
||||||
|
"двойка": 2,
|
||||||
|
"двоечка": 2,
|
||||||
"три": 3,
|
"три": 3,
|
||||||
|
"тройка": 3,
|
||||||
|
"троечка": 3,
|
||||||
"четыре": 4,
|
"четыре": 4,
|
||||||
|
"четверка": 4,
|
||||||
|
"четверочка": 4,
|
||||||
"пять": 5,
|
"пять": 5,
|
||||||
|
"пятерка": 5,
|
||||||
|
"пятерочка": 5,
|
||||||
"шесть": 6,
|
"шесть": 6,
|
||||||
|
"шестерка": 6,
|
||||||
|
"шестерочка": 6,
|
||||||
"семь": 7,
|
"семь": 7,
|
||||||
|
"семерка": 7,
|
||||||
|
"семерочка": 7,
|
||||||
"восемь": 8,
|
"восемь": 8,
|
||||||
|
"восьмерка": 8,
|
||||||
|
"восьмерочка": 8,
|
||||||
"девять": 9,
|
"девять": 9,
|
||||||
|
"девятка": 9,
|
||||||
|
"девяточка": 9,
|
||||||
"десять": 10,
|
"десять": 10,
|
||||||
|
"десятка": 10,
|
||||||
|
"десяточка": 10,
|
||||||
}
|
}
|
||||||
|
_VOLUME_COMMAND_RE = re.compile(r"\b(громкост\w*|звук\w*|volume)\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _lemmatize(token: str) -> str:
|
||||||
|
if _MORPH is None:
|
||||||
|
return token
|
||||||
|
return _MORPH.parse(token)[0].normal_form.replace("ё", "е")
|
||||||
|
|
||||||
|
|
||||||
def _get_volume_command(level: int):
|
def _get_volume_command(level: int):
|
||||||
@@ -148,16 +186,25 @@ def parse_volume_text(text: str) -> int | None:
|
|||||||
Пытается найти число громкости в тексте.
|
Пытается найти число громкости в тексте.
|
||||||
Понимает и цифры ("5"), и слова ("пять").
|
Понимает и цифры ("5"), и слова ("пять").
|
||||||
"""
|
"""
|
||||||
text = text.lower()
|
text = replace_roman_numerals(text.lower().replace("ё", "е"))
|
||||||
|
|
||||||
# 1. Ищем цифры (1-10)
|
# 1. Ищем цифры в любом месте фразы.
|
||||||
num_match = re.search(r"\b(10|[1-9])\b", text)
|
for match in re.finditer(r"\d+", text):
|
||||||
if num_match:
|
value = int(match.group())
|
||||||
return int(num_match.group())
|
if 1 <= value <= 10:
|
||||||
|
return value
|
||||||
|
|
||||||
# 2. Ищем слова из словаря
|
# 2. Ищем числительные и разговорные формы по леммам:
|
||||||
for word, value in NUMBER_MAP.items():
|
# "семерку", "десяточку", "на двух" -> 7, 10, 2.
|
||||||
if word in text:
|
for token in re.findall(r"[a-zA-Zа-яА-ЯёЁ]+", text):
|
||||||
|
value = NUMBER_MAP.get(_lemmatize(token))
|
||||||
|
if value is not None and 1 <= value <= 10:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_volume_command(text: str) -> bool:
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
return bool(_VOLUME_COMMAND_RE.search(text.lower().replace("ё", "е")))
|
||||||
|
|||||||
573
app/audio/stt.py
573
app/audio/stt.py
@@ -11,6 +11,8 @@ import asyncio
|
|||||||
import time
|
import time
|
||||||
import pyaudio
|
import pyaudio
|
||||||
import logging
|
import logging
|
||||||
|
import contextlib
|
||||||
|
import threading
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from ..core.config import DEEPGRAM_API_KEY, SAMPLE_RATE
|
from ..core.config import DEEPGRAM_API_KEY, SAMPLE_RATE
|
||||||
from deepgram import (
|
from deepgram import (
|
||||||
@@ -22,18 +24,27 @@ from deepgram import (
|
|||||||
import deepgram.clients.common.v1.abstract_sync_websocket as sdk_ws
|
import deepgram.clients.common.v1.abstract_sync_websocket as sdk_ws
|
||||||
import websockets.sync.client
|
import websockets.sync.client
|
||||||
from ..core.audio_manager import get_audio_manager
|
from ..core.audio_manager import get_audio_manager
|
||||||
|
from ..core.commands import is_fast_command
|
||||||
|
|
||||||
# --- Патч (исправление) для библиотеки websockets ---
|
# --- Патч (исправление) для библиотеки websockets ---
|
||||||
# По умолчанию Deepgram SDK использует слишком короткий таймаут подключения.
|
# Явно задаём таймауты подключения, чтобы не зависать на долгом handshake.
|
||||||
# Это часто вызывает ошибки при медленном SSL рукопожатии.
|
|
||||||
# Мы подменяем функцию connect, чтобы увеличить таймаут до 30 секунд.
|
|
||||||
_original_connect = websockets.sync.client.connect
|
_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):
|
def _patched_connect(*args, **kwargs):
|
||||||
kwargs.setdefault("open_timeout", 30)
|
# Принудительно задаём короткие таймауты, даже если SDK передал свои (например, 30с).
|
||||||
kwargs.setdefault("ping_timeout", 30)
|
kwargs["open_timeout"] = DEEPGRAM_CONNECT_TIMEOUT_SECONDS
|
||||||
kwargs.setdefault("close_timeout", 30)
|
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")
|
print(f"DEBUG: Connecting to Deepgram with timeout={kwargs.get('open_timeout')}s")
|
||||||
return _original_connect(*args, **kwargs)
|
return _original_connect(*args, **kwargs)
|
||||||
|
|
||||||
@@ -44,6 +55,12 @@ sdk_ws.connect = _patched_connect
|
|||||||
# Отключаем лишний мусор в логах
|
# Отключаем лишний мусор в логах
|
||||||
logging.getLogger("deepgram").setLevel(logging.WARNING)
|
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:
|
class SpeechRecognizer:
|
||||||
"""Класс распознавания речи через Deepgram."""
|
"""Класс распознавания речи через Deepgram."""
|
||||||
@@ -51,9 +68,12 @@ class SpeechRecognizer:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.dg_client = None
|
self.dg_client = None
|
||||||
self.pa = None
|
self.pa = None
|
||||||
|
self.audio_manager = None
|
||||||
self.stream = None
|
self.stream = None
|
||||||
self.transcript = ""
|
self.transcript = ""
|
||||||
self.last_successful_operation = datetime.now()
|
self.last_successful_operation = datetime.now()
|
||||||
|
self._input_device_index = None
|
||||||
|
self._stream_sample_rate = SAMPLE_RATE
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
"""Инициализация клиента Deepgram и PyAudio."""
|
"""Инициализация клиента Deepgram и PyAudio."""
|
||||||
@@ -70,7 +90,9 @@ class SpeechRecognizer:
|
|||||||
print(f"❌ Ошибка при создании клиента Deepgram: {e}")
|
print(f"❌ Ошибка при создании клиента Deepgram: {e}")
|
||||||
raise
|
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 клиент готов")
|
print("✅ Deepgram клиент готов")
|
||||||
# Обновляем время последней успешной операции
|
# Обновляем время последней успешной операции
|
||||||
self.last_successful_operation = datetime.now()
|
self.last_successful_operation = datetime.now()
|
||||||
@@ -96,65 +118,243 @@ class SpeechRecognizer:
|
|||||||
def _get_stream(self):
|
def _get_stream(self):
|
||||||
"""Открывает аудиопоток PyAudio, если он еще не открыт."""
|
"""Открывает аудиопоток PyAudio, если он еще не открыт."""
|
||||||
if self.stream is None:
|
if self.stream is None:
|
||||||
self.stream = self.pa.open(
|
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,
|
rate=SAMPLE_RATE,
|
||||||
channels=1,
|
channels=1,
|
||||||
format=pyaudio.paInt16,
|
format=pyaudio.paInt16,
|
||||||
input=True,
|
|
||||||
frames_per_buffer=4096,
|
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
|
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:
|
Args:
|
||||||
dg_connection: Активное соединение с Deepgram.
|
dg_connection: Активное соединение с Deepgram.
|
||||||
timeout_seconds: Общее время прослушивания.
|
timeout_seconds: Аварийный лимит длительности активной речи.
|
||||||
detection_timeout: Время ожидания начала речи.
|
detection_timeout: Время ожидания начала речи.
|
||||||
|
fast_stop: Если True, короткие системные команды завершают STT раньше.
|
||||||
"""
|
"""
|
||||||
self.transcript = ""
|
self.transcript = ""
|
||||||
transcript_parts = []
|
transcript_parts = []
|
||||||
|
latest_interim = ""
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
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() # Пора останавливаться
|
stop_event = asyncio.Event() # Пора останавливаться
|
||||||
speech_started_event = asyncio.Event() # Речь обнаружена (VAD)
|
speech_started_event = asyncio.Event() # Речь обнаружена (VAD)
|
||||||
|
connection_ready_event = threading.Event() # WS с Deepgram готов
|
||||||
|
connection_failed_event = threading.Event() # WS с Deepgram завершился ошибкой
|
||||||
|
last_speech_activity = time.monotonic()
|
||||||
|
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 ---
|
# --- Обработчики событий Deepgram ---
|
||||||
def on_transcript(unused_self, result, **kwargs):
|
def on_transcript(unused_self, result, **kwargs):
|
||||||
"""Вызывается, когда приходит часть текста."""
|
"""Вызывается, когда приходит часть текста."""
|
||||||
|
nonlocal latest_interim
|
||||||
sentence = result.channel.alternatives[0].transcript
|
sentence = result.channel.alternatives[0].transcript
|
||||||
if len(sentence) == 0:
|
if len(sentence) == 0:
|
||||||
return
|
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:
|
if result.is_final:
|
||||||
# Собираем только финальные (подтвержденные) фразы
|
# Собираем только финальные (подтвержденные) фразы
|
||||||
transcript_parts.append(sentence)
|
transcript_parts.append(sentence)
|
||||||
self.transcript = " ".join(transcript_parts).strip()
|
self.transcript = " ".join(transcript_parts).strip()
|
||||||
|
latest_interim = ""
|
||||||
|
else:
|
||||||
|
# Fallback: некоторые сессии завершаются без is_final.
|
||||||
|
latest_interim = sentence
|
||||||
|
|
||||||
def on_speech_started(unused_self, speech_started, **kwargs):
|
def on_speech_started(unused_self, speech_started, **kwargs):
|
||||||
"""Вызывается, когда VAD (Voice Activity Detection) слышит голос."""
|
"""Вызывается, когда VAD (Voice Activity Detection) слышит голос."""
|
||||||
try:
|
try:
|
||||||
loop.call_soon_threadsafe(speech_started_event.set)
|
loop.call_soon_threadsafe(mark_speech_activity)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# Event loop might be closed, ignore
|
# Event loop might be closed, ignore
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_utterance_end(unused_self, utterance_end, **kwargs):
|
def on_utterance_end(unused_self, utterance_end, **kwargs):
|
||||||
"""Вызывается, когда Deepgram решает, что фраза закончилась (пауза)."""
|
"""Вызывается, когда Deepgram решает, что фраза закончилась (пауза)."""
|
||||||
try:
|
# Не останавливаемся мгновенно на событии Deepgram.
|
||||||
loop.call_soon_threadsafe(stop_event.set)
|
# Остановка управляется локальным порогом тишины POST_SPEECH_SILENCE_TIMEOUT_SECONDS.
|
||||||
except RuntimeError:
|
return
|
||||||
# Event loop might be closed, ignore
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_error(unused_self, error, **kwargs):
|
def on_error(unused_self, error, **kwargs):
|
||||||
|
if stop_event.is_set():
|
||||||
|
return
|
||||||
print(f"Deepgram Error: {error}")
|
print(f"Deepgram Error: {error}")
|
||||||
try:
|
try:
|
||||||
loop.call_soon_threadsafe(stop_event.set)
|
loop.call_soon_threadsafe(request_stop)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# Event loop might be closed, ignore
|
# Event loop might be closed, ignore
|
||||||
pass
|
pass
|
||||||
@@ -165,27 +365,36 @@ class SpeechRecognizer:
|
|||||||
dg_connection.on(LiveTranscriptionEvents.UtteranceEnd, on_utterance_end)
|
dg_connection.on(LiveTranscriptionEvents.UtteranceEnd, on_utterance_end)
|
||||||
dg_connection.on(LiveTranscriptionEvents.Error, on_error)
|
dg_connection.on(LiveTranscriptionEvents.Error, on_error)
|
||||||
|
|
||||||
# Параметры распознавания
|
# --- Задача отправки аудио с буферизацией ---
|
||||||
|
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(
|
options = LiveOptions(
|
||||||
model="nova-2", # Самая быстрая и точная модель
|
model="nova-2", # Самая быстрая и точная модель
|
||||||
language=self.current_lang,
|
language=self.current_lang,
|
||||||
smart_format=True, # Расстановка знаков препинания
|
smart_format=True, # Расстановка знаков препинания
|
||||||
encoding="linear16",
|
encoding="linear16",
|
||||||
channels=1,
|
channels=1,
|
||||||
sample_rate=SAMPLE_RATE,
|
sample_rate=stream_sample_rate,
|
||||||
interim_results=True,
|
interim_results=True,
|
||||||
utterance_end_ms=1000, # Пауза 1.0с считается концом фразы (было 1.2)
|
utterance_end_ms=int(POST_SPEECH_SILENCE_TIMEOUT_SECONDS * 1000),
|
||||||
vad_events=True,
|
vad_events=True,
|
||||||
# Добавляем параметры таймаута для долгой работы
|
# Сглаженный порог endpointing, чтобы не резать речь на коротких паузах.
|
||||||
endpointing=300, # Таймаут в миллисекундах для автоматического завершения
|
endpointing=int(POST_SPEECH_SILENCE_TIMEOUT_SECONDS * 1000),
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Задача отправки аудио с буферизацией ---
|
|
||||||
async def send_audio():
|
|
||||||
chunks_sent = 0
|
|
||||||
audio_buffer = [] # Буфер для накопления звука во время подключения
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. Сразу начинаем захват звука, не дожидаясь сети!
|
# 1. Сразу начинаем захват звука, не дожидаясь сети!
|
||||||
stream.start_stream()
|
stream.start_stream()
|
||||||
print("🎤 Stream started (buffering)...")
|
print("🎤 Stream started (buffering)...")
|
||||||
@@ -193,31 +402,75 @@ class SpeechRecognizer:
|
|||||||
# 2. Запускаем подключение к Deepgram в фоне (через ThreadPool, т.к. start() блокирующий)
|
# 2. Запускаем подключение к Deepgram в фоне (через ThreadPool, т.к. start() блокирующий)
|
||||||
# Но в данном SDK start() возвращает bool, он может быть блокирующим.
|
# Но в данном SDK start() возвращает bool, он может быть блокирующим.
|
||||||
# Deepgram Python SDK v3+ start() делает handshake.
|
# Deepgram Python SDK v3+ start() делает handshake.
|
||||||
|
connect_result = {"done": False, "ok": None, "error": None}
|
||||||
|
|
||||||
connect_future = loop.run_in_executor(
|
def start_connection():
|
||||||
None, lambda: dg_connection.start(options)
|
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 задержке)
|
connect_deadline = time.monotonic() + DEEPGRAM_CONNECT_WAIT_SECONDS
|
||||||
|
while (
|
||||||
while not connect_future.done() and timeout_count < max_timeout:
|
not connect_result["done"]
|
||||||
|
and time.monotonic() < connect_deadline
|
||||||
|
and not sender_stop_event.is_set()
|
||||||
|
):
|
||||||
if stream.is_active():
|
if stream.is_active():
|
||||||
|
try:
|
||||||
data = stream.read(4096, exception_on_overflow=False)
|
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)
|
audio_buffer.append(data)
|
||||||
await asyncio.sleep(0.0005) # Уменьшаем задержку для более быстрой обработки
|
time.sleep(DEEPGRAM_CONNECT_POLL_SECONDS)
|
||||||
timeout_count += 1
|
|
||||||
|
|
||||||
if timeout_count >= max_timeout:
|
if sender_stop_event.is_set():
|
||||||
print("⏰ Timeout connecting to Deepgram")
|
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
|
return
|
||||||
|
|
||||||
# Проверяем результат подключения
|
# Проверяем результат подключения
|
||||||
if connect_future.result() is False:
|
if connect_result["error"] is not None:
|
||||||
print("Failed to start Deepgram connection")
|
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
|
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)...")
|
print(f"🚀 Connected! Sending buffer ({len(audio_buffer)} chunks)...")
|
||||||
|
|
||||||
# 3. Отправляем накопленный буфер
|
# 3. Отправляем накопленный буфер
|
||||||
@@ -227,80 +480,228 @@ class SpeechRecognizer:
|
|||||||
|
|
||||||
audio_buffer = None # Освобождаем память
|
audio_buffer = None # Освобождаем память
|
||||||
|
|
||||||
# 4. Продолжаем стримить в реальном времени
|
# 4. Продолжаем стримить в реальном времени до события остановки.
|
||||||
stream_timeout = 0
|
while not sender_stop_event.is_set():
|
||||||
max_stream_timeout = int(timeout_seconds / 0.002) # Примерный таймаут в зависимости от timeout_seconds
|
if not stream.is_active():
|
||||||
|
break
|
||||||
while not stop_event.is_set() and stream_timeout < max_stream_timeout:
|
try:
|
||||||
if stream.is_active():
|
|
||||||
data = stream.read(4096, exception_on_overflow=False)
|
data = stream.read(4096, exception_on_overflow=False)
|
||||||
|
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)
|
dg_connection.send(data)
|
||||||
chunks_sent += 1
|
chunks_sent += 1
|
||||||
if chunks_sent % 50 == 0:
|
if chunks_sent % 50 == 0:
|
||||||
print(".", end="", flush=True)
|
print(".", end="", flush=True)
|
||||||
await asyncio.sleep(0.002) # Уменьшаем задержку для более быстрого реагирования
|
time.sleep(0.002) # Уменьшаем задержку для более быстрого реагирования
|
||||||
stream_timeout += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
mark_session_error(f"Audio send error: {e}")
|
||||||
print(f"Audio send error: {e}")
|
print(f"Audio send error: {e}")
|
||||||
|
connection_failed_event.set()
|
||||||
|
with contextlib.suppress(RuntimeError):
|
||||||
|
loop.call_soon_threadsafe(request_stop)
|
||||||
finally:
|
finally:
|
||||||
if stream.is_active():
|
with contextlib.suppress(Exception):
|
||||||
|
if stream and stream.is_active():
|
||||||
stream.stop_stream()
|
stream.stop_stream()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
if stream:
|
||||||
|
stream.close()
|
||||||
|
stream_holder["stream"] = None
|
||||||
print(f"\n🛑 Stream stopped. Chunks sent: {chunks_sent}")
|
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
|
if False: # dg_connection.start(options) перенесен внутрь send_audio
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Ждем начала речи (если задан detection_timeout)
|
# 1. Ждем начала речи (если задан detection_timeout)
|
||||||
if detection_timeout:
|
if (
|
||||||
try:
|
effective_detection_timeout
|
||||||
await asyncio.wait_for(
|
and effective_detection_timeout > 0
|
||||||
speech_started_event.wait(), timeout=detection_timeout
|
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,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
while (
|
||||||
# Если за detection_timeout никто не начал говорить, выходим
|
not stop_event.is_set()
|
||||||
stop_event.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)
|
if (
|
||||||
# stop_event сработает либо по UtteranceEnd (пауза), либо по общему таймауту
|
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():
|
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:
|
except asyncio.TimeoutError:
|
||||||
pass # Общий таймаут вышел
|
pass # Общий таймаут вышел
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in waiting for events: {e}")
|
print(f"Error in waiting for events: {e}")
|
||||||
|
|
||||||
stop_event.set()
|
request_stop()
|
||||||
try:
|
heard_speech = speech_started_event.is_set()
|
||||||
await sender_task
|
sender_stopped = await self._wait_for_thread(
|
||||||
except Exception as e:
|
sender_thread,
|
||||||
print(f"Error waiting for sender task: {e}")
|
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:
|
finish_ok = await self._run_blocking_cleanup(
|
||||||
dg_connection.finish()
|
dg_connection.finish,
|
||||||
except Exception as e:
|
timeout_seconds=DEEPGRAM_FINISH_TIMEOUT_SECONDS,
|
||||||
print(f"Error finishing connection: {e}")
|
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(
|
def listen(
|
||||||
self,
|
self,
|
||||||
timeout_seconds: float = 7.0,
|
timeout_seconds: float = 7.0,
|
||||||
detection_timeout: float = None,
|
detection_timeout: float = INITIAL_SILENCE_TIMEOUT_SECONDS,
|
||||||
lang: str = "ru",
|
lang: str = "ru",
|
||||||
|
fast_stop: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Основной метод: слушает микрофон и возвращает текст.
|
Основной метод: слушает микрофон и возвращает текст.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
timeout_seconds: Максимальная длительность фразы.
|
timeout_seconds: Защитный лимит длительности активной речи.
|
||||||
detection_timeout: Сколько ждать начала речи перед тем как сдаться.
|
detection_timeout: Сколько ждать начала речи перед тем как сдаться.
|
||||||
lang: Язык ("ru" или "en").
|
lang: Язык ("ru" или "en").
|
||||||
|
fast_stop: Быстрое завершение для коротких системных команд.
|
||||||
"""
|
"""
|
||||||
if not self.dg_client:
|
if not self.dg_client:
|
||||||
self.initialize()
|
self.initialize()
|
||||||
@@ -323,7 +724,7 @@ class SpeechRecognizer:
|
|||||||
# Запускаем асинхронный процесс обработки
|
# Запускаем асинхронный процесс обработки
|
||||||
transcript = asyncio.run(
|
transcript = asyncio.run(
|
||||||
self._process_audio(
|
self._process_audio(
|
||||||
dg_connection, timeout_seconds, detection_timeout
|
dg_connection, timeout_seconds, detection_timeout, fast_stop
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
final_text = transcript.strip() if transcript else ""
|
final_text = transcript.strip() if transcript else ""
|
||||||
@@ -345,10 +746,21 @@ class SpeechRecognizer:
|
|||||||
# Закрываем соединение, если оно было создано
|
# Закрываем соединение, если оно было создано
|
||||||
if dg_connection:
|
if dg_connection:
|
||||||
try:
|
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:
|
except:
|
||||||
pass # Игнорируем ошибки при завершении
|
pass # Игнорируем ошибки при завершении
|
||||||
|
|
||||||
|
# Принудительно сбрасываем клиента, чтобы след. попытка не унаследовала
|
||||||
|
# подвисшее соединение SDK.
|
||||||
|
self.dg_client = None
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self.initialize()
|
||||||
|
|
||||||
if attempt < 2: # Не ждем после последней попытки
|
if attempt < 2: # Не ждем после последней попытки
|
||||||
print(f"⚠️ Не удалось подключиться к Deepgram, попытка {attempt + 1}/3, повторяю...")
|
print(f"⚠️ Не удалось подключиться к Deepgram, попытка {attempt + 1}/3, повторяю...")
|
||||||
time.sleep(1) # Уменьшаем задержку между попытками
|
time.sleep(1) # Уменьшаем задержку между попытками
|
||||||
@@ -389,10 +801,13 @@ def get_recognizer() -> SpeechRecognizer:
|
|||||||
|
|
||||||
|
|
||||||
def listen(
|
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:
|
) -> str:
|
||||||
"""Внешняя функция для прослушивания."""
|
"""Внешняя функция для прослушивания."""
|
||||||
return get_recognizer().listen(timeout_seconds, detection_timeout, lang)
|
return get_recognizer().listen(timeout_seconds, detection_timeout, lang, fast_stop)
|
||||||
|
|
||||||
|
|
||||||
def cleanup():
|
def cleanup():
|
||||||
|
|||||||
263
app/audio/tts.py
263
app/audio/tts.py
@@ -6,7 +6,7 @@ Supports interruption via wake word detection using threading.
|
|||||||
|
|
||||||
# Модуль синтеза речи (TTS - Text-to-Speech).
|
# Модуль синтеза речи (TTS - Text-to-Speech).
|
||||||
# Использует нейросеть Silero TTS для качественной русской речи.
|
# Использует нейросеть Silero TTS для качественной русской речи.
|
||||||
# Также поддерживает прерывание речи, если пользователь скажет "Alexandr".
|
# Также поддерживает прерывание речи по wake word.
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
@@ -14,15 +14,19 @@ import time
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pyaudio
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
import torch
|
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 о длинном тексте (мы сами его режем)
|
# Подавляем предупреждения Silero о длинном тексте (мы сами его режем)
|
||||||
warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols")
|
warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols")
|
||||||
|
|
||||||
_EN_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9'-]*")
|
_EN_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9'-]*")
|
||||||
|
_MIXED_TTS_BUFFERED_SWITCHES = 3
|
||||||
|
_INTERRUPT_POLL_SECONDS = 0.01
|
||||||
|
|
||||||
|
|
||||||
class TextToSpeech:
|
class TextToSpeech:
|
||||||
@@ -32,10 +36,30 @@ class TextToSpeech:
|
|||||||
self.model_ru = None
|
self.model_ru = None
|
||||||
self.model_en = None
|
self.model_en = None
|
||||||
self.sample_rate = TTS_SAMPLE_RATE
|
self.sample_rate = TTS_SAMPLE_RATE
|
||||||
|
self.speed_factor = float(TTS_SPEED)
|
||||||
self.speaker_ru = TTS_SPEAKER
|
self.speaker_ru = TTS_SPEAKER
|
||||||
self.speaker_en = TTS_EN_SPEAKER
|
self.speaker_en = TTS_EN_SPEAKER
|
||||||
self._interrupted = False
|
self._interrupted = False
|
||||||
self._stop_flag = threading.Event()
|
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):
|
def _load_model(self, language: str):
|
||||||
"""
|
"""
|
||||||
@@ -48,15 +72,6 @@ class TextToSpeech:
|
|||||||
if self.model_en:
|
if self.model_en:
|
||||||
return self.model_en
|
return self.model_en
|
||||||
print("📦 Загрузка модели Silero TTS (en)...")
|
print("📦 Загрузка модели Silero TTS (en)...")
|
||||||
try:
|
|
||||||
model, _ = torch.hub.load(
|
|
||||||
repo_or_dir="snakers4/silero-models",
|
|
||||||
model="silero_tts",
|
|
||||||
language="en",
|
|
||||||
speaker="v5_en",
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"⚠️ Не удалось загрузить v5_en, пробую v3_en: {exc}")
|
|
||||||
model, _ = torch.hub.load(
|
model, _ = torch.hub.load(
|
||||||
repo_or_dir="snakers4/silero-models",
|
repo_or_dir="snakers4/silero-models",
|
||||||
model="silero_tts",
|
model="silero_tts",
|
||||||
@@ -181,28 +196,7 @@ class TextToSpeech:
|
|||||||
if not text.strip():
|
if not text.strip():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Выбор модели
|
model, speaker = self._get_model_and_speaker(language)
|
||||||
if language == "en":
|
|
||||||
model = self._load_model("en")
|
|
||||||
speaker = self.speaker_en
|
|
||||||
else:
|
|
||||||
model = self._load_model("ru")
|
|
||||||
speaker = self.speaker_ru
|
|
||||||
|
|
||||||
# Проверка наличия спикера в модели (защита от ошибок конфига).
|
|
||||||
# Для русского языка сохраняем мужской голос по умолчанию.
|
|
||||||
if hasattr(model, "speakers") and model.speakers:
|
|
||||||
if language == "ru":
|
|
||||||
male_speakers = ("eugene", "aidar")
|
|
||||||
if speaker not in model.speakers or speaker not in male_speakers:
|
|
||||||
for candidate in male_speakers:
|
|
||||||
if candidate in model.speakers:
|
|
||||||
speaker = candidate
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
speaker = model.speakers[0]
|
|
||||||
elif speaker not in model.speakers:
|
|
||||||
speaker = model.speakers[0]
|
|
||||||
|
|
||||||
# Разбиваем текст на куски
|
# Разбиваем текст на куски
|
||||||
chunks = self._split_text(text)
|
chunks = self._split_text(text)
|
||||||
@@ -229,17 +223,16 @@ class TextToSpeech:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Конвертация в numpy массив для sounddevice
|
# Конвертация в numpy массив для sounddevice
|
||||||
audio_np = audio.numpy()
|
audio_np = self._apply_speed(audio.numpy())
|
||||||
|
|
||||||
if check_interrupt:
|
if check_interrupt:
|
||||||
# Воспроизведение с проверкой прерывания (сложная логика)
|
if not self._play_audio_with_interrupt(audio_np, check_interrupt):
|
||||||
if not self._play_with_interrupt(audio_np, check_interrupt):
|
|
||||||
success = False
|
success = False
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# Обычное воспроизведение (блокирующее)
|
if not self._play_audio_blocking(audio_np):
|
||||||
sd.play(audio_np, self.sample_rate)
|
success = False
|
||||||
sd.wait()
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка TTS (часть {i + 1}/{total_chunks}): {e}")
|
print(f"❌ Ошибка TTS (часть {i + 1}/{total_chunks}): {e}")
|
||||||
@@ -253,10 +246,104 @@ class TextToSpeech:
|
|||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _get_model_and_speaker(self, language: str):
|
||||||
|
"""Возвращает модель и подходящий голос для языка."""
|
||||||
|
# Выбор модели
|
||||||
|
if language == "en":
|
||||||
|
model = self._load_model("en")
|
||||||
|
speaker = self.speaker_en
|
||||||
|
else:
|
||||||
|
model = self._load_model("ru")
|
||||||
|
speaker = self.speaker_ru
|
||||||
|
|
||||||
|
# Проверка наличия спикера в модели (защита от ошибок конфига).
|
||||||
|
# Для русского языка сохраняем мужской голос по умолчанию.
|
||||||
|
if hasattr(model, "speakers") and model.speakers:
|
||||||
|
if language == "ru":
|
||||||
|
male_speakers = ("eugene", "aidar")
|
||||||
|
if speaker not in model.speakers or speaker not in male_speakers:
|
||||||
|
for candidate in male_speakers:
|
||||||
|
if candidate in model.speakers:
|
||||||
|
speaker = candidate
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
speaker = model.speakers[0]
|
||||||
|
elif speaker not in model.speakers:
|
||||||
|
speaker = model.speakers[0]
|
||||||
|
|
||||||
|
return model, speaker
|
||||||
|
|
||||||
|
def _synthesize_language_audio(self, text: str, language: str) -> np.ndarray | None:
|
||||||
|
"""Собирает аудио для одного языка без промежуточного воспроизведения."""
|
||||||
|
if not text.strip():
|
||||||
|
return np.asarray([], dtype=np.float32)
|
||||||
|
|
||||||
|
model, speaker = self._get_model_and_speaker(language)
|
||||||
|
chunks = self._split_text(text)
|
||||||
|
audio_parts = []
|
||||||
|
|
||||||
|
for chunk in chunks:
|
||||||
|
if self._interrupted:
|
||||||
|
return None
|
||||||
|
audio = model.apply_tts(text=chunk, speaker=speaker, sample_rate=self.sample_rate)
|
||||||
|
audio_parts.append(self._apply_speed(audio.numpy()))
|
||||||
|
|
||||||
|
if not audio_parts:
|
||||||
|
return np.asarray([], dtype=np.float32)
|
||||||
|
|
||||||
|
return np.concatenate(audio_parts)
|
||||||
|
|
||||||
|
def _count_language_switches(self, segments: list[tuple[str, str]]) -> int:
|
||||||
|
if len(segments) < 2:
|
||||||
|
return 0
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for idx in range(1, len(segments))
|
||||||
|
if segments[idx - 1][1] != segments[idx][1]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _speak_mixed_buffered(
|
||||||
|
self, segments: list[tuple[str, str]], check_interrupt=None
|
||||||
|
) -> bool:
|
||||||
|
"""Сначала собирает mixed RU/EN аудио, затем проигрывает единым потоком."""
|
||||||
|
print(f"🔊 Mixed TTS: буферизация сегментов ({len(segments)} шт.)")
|
||||||
|
self._interrupted = False
|
||||||
|
self._stop_flag.clear()
|
||||||
|
|
||||||
|
audio_parts = []
|
||||||
|
for idx, (segment, lang) in enumerate(segments, start=1):
|
||||||
|
if not segment.strip():
|
||||||
|
continue
|
||||||
|
if check_interrupt and check_interrupt():
|
||||||
|
self._interrupted = True
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
audio_np = self._synthesize_language_audio(segment, language=lang)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"❌ Ошибка mixed TTS (сегмент {idx}/{len(segments)}): {exc}")
|
||||||
|
return False
|
||||||
|
if audio_np is None:
|
||||||
|
return False
|
||||||
|
if audio_np.size:
|
||||||
|
audio_parts.append(audio_np)
|
||||||
|
|
||||||
|
if not audio_parts:
|
||||||
|
return True
|
||||||
|
|
||||||
|
full_audio = np.concatenate(audio_parts)
|
||||||
|
if check_interrupt:
|
||||||
|
return self._play_audio_with_interrupt(full_audio, check_interrupt)
|
||||||
|
return self._play_audio_blocking(full_audio)
|
||||||
|
|
||||||
def _speak_mixed(
|
def _speak_mixed(
|
||||||
self, segments: list[tuple[str, str]], check_interrupt=None
|
self, segments: list[tuple[str, str]], check_interrupt=None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Озвучивание текста с переключением RU/EN по сегментам."""
|
"""Озвучивание текста с переключением RU/EN по сегментам."""
|
||||||
|
if self._count_language_switches(segments) >= _MIXED_TTS_BUFFERED_SWITCHES:
|
||||||
|
return self._speak_mixed_buffered(
|
||||||
|
segments, check_interrupt=check_interrupt
|
||||||
|
)
|
||||||
|
|
||||||
for segment, lang in segments:
|
for segment, lang in segments:
|
||||||
if not segment.strip():
|
if not segment.strip():
|
||||||
continue
|
continue
|
||||||
@@ -283,6 +370,9 @@ class TextToSpeech:
|
|||||||
if not text.strip():
|
if not text.strip():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
if check_interrupt is None:
|
||||||
|
check_interrupt = self._default_interrupt_checker()
|
||||||
|
|
||||||
if language == "ru":
|
if language == "ru":
|
||||||
text = self._preprocess_text(text)
|
text = self._preprocess_text(text)
|
||||||
segments = self._split_mixed_language(text)
|
segments = self._split_mixed_language(text)
|
||||||
@@ -293,6 +383,83 @@ class TextToSpeech:
|
|||||||
text, check_interrupt=check_interrupt, language=language
|
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):
|
def _check_interrupt_worker(self, check_interrupt):
|
||||||
"""
|
"""
|
||||||
Фоновая функция для потока: постоянно опрашивает check_interrupt.
|
Фоновая функция для потока: постоянно опрашивает check_interrupt.
|
||||||
@@ -307,8 +474,11 @@ class TextToSpeech:
|
|||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)
|
sd.play(audio_np, self.sample_rate)
|
||||||
|
|
||||||
# Ждем окончания воспроизведения в цикле
|
# Ждем окончания воспроизведения в цикле.
|
||||||
while sd.get_stream().active:
|
while True:
|
||||||
if self._interrupted:
|
if self._interrupted:
|
||||||
break
|
break
|
||||||
time.sleep(0.02) # Уменьшаем задержку для более быстрого реагирования
|
stream = sd.get_stream()
|
||||||
|
if stream is None or not stream.active:
|
||||||
|
break
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
if not self._interrupted:
|
||||||
|
# Добираем хвост буфера даже если stream.active мигнул в False чуть раньше.
|
||||||
|
sd.wait()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Сообщаем потоку-наблюдателю, что пора завершаться
|
# Сообщаем потоку-наблюдателю, что пора завершаться
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
"""
|
"""
|
||||||
Wake word detection module using Porcupine.
|
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 pvporcupine
|
||||||
import pyaudio
|
import pyaudio
|
||||||
import struct
|
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
|
from ..core.audio_manager import get_audio_manager
|
||||||
|
|
||||||
|
|
||||||
@@ -20,20 +40,44 @@ class WakeWordDetector:
|
|||||||
self.porcupine = None
|
self.porcupine = None
|
||||||
self.audio_stream = None
|
self.audio_stream = None
|
||||||
self.pa = 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._stream_closed = True # Флаг состояния потока (закрыт/открыт)
|
||||||
self._last_hit_ts = 0.0
|
self._last_hit_ts = 0.0
|
||||||
|
self._fallback_dg_client = None
|
||||||
|
self._fallback_pre_roll = deque(maxlen=4)
|
||||||
|
self._fallback_frames = []
|
||||||
|
self._fallback_active = False
|
||||||
|
self._fallback_silence_frames = 0
|
||||||
|
self._fallback_last_attempt_ts = 0.0
|
||||||
|
self._fallback_last_error_ts = 0.0
|
||||||
|
self._stream_opened_ts = 0.0
|
||||||
|
self._rms_history = deque(maxlen=220)
|
||||||
|
self._wakeword_aliases_compact = {
|
||||||
|
self._compact_text(WAKE_WORD),
|
||||||
|
*(self._compact_text(alias) for alias in WAKE_WORD_ALIASES),
|
||||||
|
}
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
"""Инициализация Porcupine и PyAudio."""
|
"""Инициализация Porcupine и PyAudio."""
|
||||||
# Создаем экземпляр Porcupine с нашим ключом доступа и файлом модели (.ppn)
|
# Создаем экземпляр Porcupine с нашим ключом доступа и файлом модели (.ppn)
|
||||||
self.porcupine = pvporcupine.create(
|
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
|
# Используем общий экземпляр PyAudio
|
||||||
self.pa = get_audio_manager().get_pyaudio()
|
self._audio_manager = get_audio_manager()
|
||||||
|
self.pa = self._audio_manager.get_pyaudio()
|
||||||
self._open_stream()
|
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):
|
def _open_stream(self):
|
||||||
"""Открытие аудиопотока с микрофона."""
|
"""Открытие аудиопотока с микрофона."""
|
||||||
@@ -47,15 +91,234 @@ class WakeWordDetector:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Открываем поток с параметрами, которые требует Porcupine
|
target_rate = int(self.porcupine.sample_rate)
|
||||||
self.audio_stream = self.pa.open(
|
fallback_rates = [48000, 44100, 32000, 22050, 16000]
|
||||||
rate=self.porcupine.sample_rate,
|
self.audio_stream, self._input_device_index, actual_rate = self._audio_manager.open_input_stream(
|
||||||
|
rate=target_rate,
|
||||||
channels=1,
|
channels=1,
|
||||||
format=pyaudio.paInt16,
|
format=pyaudio.paInt16,
|
||||||
input=True,
|
|
||||||
frames_per_buffer=self.porcupine.frame_length,
|
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_closed = False
|
||||||
|
self._stream_opened_ts = time.time()
|
||||||
|
self._reset_fallback_state()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_rms(pcm: np.ndarray) -> float:
|
||||||
|
if pcm.size == 0:
|
||||||
|
return 0.0
|
||||||
|
as_float = pcm.astype(np.float32)
|
||||||
|
return float(np.sqrt(np.mean(as_float * as_float)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compact_text(text: str) -> str:
|
||||||
|
text = str(text or "").lower().replace("ё", "е")
|
||||||
|
return "".join(ch for ch in text if ch.isalnum())
|
||||||
|
|
||||||
|
def _remember_rms(self, rms: float):
|
||||||
|
if rms <= 0:
|
||||||
|
return
|
||||||
|
self._rms_history.append(float(rms))
|
||||||
|
|
||||||
|
def _noise_floor_rms(self) -> float:
|
||||||
|
if not self._rms_history:
|
||||||
|
return 0.0
|
||||||
|
# Низкий процентиль устойчив к редким всплескам/голосу.
|
||||||
|
return float(np.percentile(np.asarray(self._rms_history, dtype=np.float32), 20))
|
||||||
|
|
||||||
|
def _wakeword_rms_threshold(self) -> float:
|
||||||
|
floor = self._noise_floor_rms()
|
||||||
|
dynamic = floor * float(WAKEWORD_RMS_MULTIPLIER)
|
||||||
|
# Защитный максимум, чтобы в очень шумном окружении не "убить" детект полностью.
|
||||||
|
dynamic = min(dynamic, float(WAKEWORD_MIN_RMS) * 4.0)
|
||||||
|
return max(float(WAKEWORD_MIN_RMS), dynamic)
|
||||||
|
|
||||||
|
def _is_hit_in_guard_window(
|
||||||
|
self, now_ts: float, *, ignore_hit_cooldown: bool = False
|
||||||
|
) -> bool:
|
||||||
|
if (
|
||||||
|
not ignore_hit_cooldown
|
||||||
|
and now_ts - self._last_hit_ts < float(WAKEWORD_HIT_COOLDOWN_SECONDS)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
if (
|
||||||
|
self._stream_opened_ts > 0
|
||||||
|
and now_ts - self._stream_opened_ts < float(WAKEWORD_REOPEN_GRACE_SECONDS)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _accept_porcupine_hit(
|
||||||
|
self,
|
||||||
|
pcm: np.ndarray,
|
||||||
|
now_ts: float,
|
||||||
|
*,
|
||||||
|
ignore_hit_cooldown: bool = False,
|
||||||
|
during_tts: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
if self._is_hit_in_guard_window(
|
||||||
|
now_ts, ignore_hit_cooldown=ignore_hit_cooldown
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
rms = self._compute_rms(pcm)
|
||||||
|
# Для "чистого" Porcupine оставляем мягкий амплитудный фильтр:
|
||||||
|
# он отсеивает тишину/щелчки и ложные фаны от фонового шума.
|
||||||
|
# Во время TTS делаем фильтр строже, чтобы собственная колонка
|
||||||
|
# не "будила" ассистента.
|
||||||
|
factor = 0.95 if during_tts else 0.75
|
||||||
|
threshold = max(80.0, self._wakeword_rms_threshold() * factor)
|
||||||
|
if rms < threshold:
|
||||||
|
return False
|
||||||
|
self._last_hit_ts = now_ts
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _reset_fallback_state(self):
|
||||||
|
self._fallback_pre_roll.clear()
|
||||||
|
self._fallback_frames = []
|
||||||
|
self._fallback_active = False
|
||||||
|
self._fallback_silence_frames = 0
|
||||||
|
|
||||||
|
def _get_fallback_client(self):
|
||||||
|
if not WAKEWORD_ENABLE_FALLBACK_STT:
|
||||||
|
return None
|
||||||
|
if not DEEPGRAM_API_KEY:
|
||||||
|
return None
|
||||||
|
if self._fallback_dg_client is None:
|
||||||
|
self._fallback_dg_client = DeepgramClient(DEEPGRAM_API_KEY)
|
||||||
|
return self._fallback_dg_client
|
||||||
|
|
||||||
|
def _pcm_to_wav_bytes(self, pcm: np.ndarray) -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with wave.open(buffer, "wb") as wav_file:
|
||||||
|
wav_file.setnchannels(1)
|
||||||
|
wav_file.setsampwidth(2)
|
||||||
|
wav_file.setframerate(int(self.porcupine.sample_rate))
|
||||||
|
wav_file.writeframes(np.asarray(pcm, dtype=np.int16).tobytes())
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
def _transcribe_wakeword_candidate(self, pcm: np.ndarray) -> bool:
|
||||||
|
client = self._get_fallback_client()
|
||||||
|
if client is None or pcm.size == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.listen.rest.v("1").transcribe_file(
|
||||||
|
{"buffer": self._pcm_to_wav_bytes(pcm)},
|
||||||
|
PrerecordedOptions(
|
||||||
|
model="nova-2",
|
||||||
|
language="ru",
|
||||||
|
smart_format=False,
|
||||||
|
punctuate=False,
|
||||||
|
utterances=False,
|
||||||
|
numerals=False,
|
||||||
|
),
|
||||||
|
timeout=httpx.Timeout(2.2, connect=2.2, read=2.2, write=2.2),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
now = time.time()
|
||||||
|
if now - self._fallback_last_error_ts >= 30.0:
|
||||||
|
print(f"⚠️ Wake word fallback STT failed: {exc}")
|
||||||
|
self._fallback_last_error_ts = now
|
||||||
|
return False
|
||||||
|
|
||||||
|
transcript = ""
|
||||||
|
confidence = None
|
||||||
|
try:
|
||||||
|
channels = response.results.channels or []
|
||||||
|
if channels and channels[0].alternatives:
|
||||||
|
first_alt = channels[0].alternatives[0]
|
||||||
|
transcript = str(first_alt.transcript or "").strip()
|
||||||
|
try:
|
||||||
|
confidence = float(first_alt.confidence)
|
||||||
|
except Exception:
|
||||||
|
confidence = None
|
||||||
|
except Exception:
|
||||||
|
transcript = ""
|
||||||
|
confidence = None
|
||||||
|
|
||||||
|
compact = self._compact_text(transcript)
|
||||||
|
if confidence is not None and confidence < 0.62:
|
||||||
|
return False
|
||||||
|
if compact in self._wakeword_aliases_compact:
|
||||||
|
print(f"✅ Wake word обнаружен fallback STT: {transcript}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _check_fallback_wakeword(
|
||||||
|
self,
|
||||||
|
pcm: np.ndarray,
|
||||||
|
*,
|
||||||
|
during_tts: bool = False,
|
||||||
|
ignore_hit_cooldown: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
if not WAKEWORD_ENABLE_FALLBACK_STT:
|
||||||
|
return False
|
||||||
|
if self.porcupine is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rms = self._compute_rms(pcm)
|
||||||
|
base_threshold = self._wakeword_rms_threshold()
|
||||||
|
speech_factor = 1.1 if during_tts else 0.85
|
||||||
|
speech_threshold = max(170.0, base_threshold * speech_factor)
|
||||||
|
silence_threshold = max(95.0, speech_threshold * 0.55)
|
||||||
|
silence_frames_to_finalize = 10 if during_tts else 8
|
||||||
|
min_frames = 10 if during_tts else 7
|
||||||
|
max_frames = 40
|
||||||
|
min_attempt_interval = 2.5 if during_tts else 1.0
|
||||||
|
|
||||||
|
if rms >= speech_threshold:
|
||||||
|
if not self._fallback_active:
|
||||||
|
self._fallback_active = True
|
||||||
|
self._fallback_frames = list(self._fallback_pre_roll)
|
||||||
|
self._fallback_silence_frames = 0
|
||||||
|
self._fallback_frames.append(np.asarray(pcm, dtype=np.int16))
|
||||||
|
elif self._fallback_active:
|
||||||
|
self._fallback_frames.append(np.asarray(pcm, dtype=np.int16))
|
||||||
|
if rms <= silence_threshold:
|
||||||
|
self._fallback_silence_frames += 1
|
||||||
|
else:
|
||||||
|
self._fallback_silence_frames = 0
|
||||||
|
|
||||||
|
if len(self._fallback_frames) > max_frames:
|
||||||
|
self._reset_fallback_state()
|
||||||
|
elif self._fallback_silence_frames >= silence_frames_to_finalize:
|
||||||
|
candidate = np.concatenate(self._fallback_frames) if self._fallback_frames else np.asarray([], dtype=np.int16)
|
||||||
|
self._reset_fallback_state()
|
||||||
|
if len(candidate) >= min_frames * int(self.porcupine.frame_length):
|
||||||
|
now = time.time()
|
||||||
|
candidate_rms = self._compute_rms(candidate)
|
||||||
|
candidate_threshold = self._wakeword_rms_threshold() * (
|
||||||
|
0.95 if during_tts else 0.75
|
||||||
|
)
|
||||||
|
candidate_threshold = max(float(WAKEWORD_MIN_RMS), candidate_threshold)
|
||||||
|
if (
|
||||||
|
now - self._fallback_last_attempt_ts >= min_attempt_interval
|
||||||
|
and not self._is_hit_in_guard_window(
|
||||||
|
now, ignore_hit_cooldown=ignore_hit_cooldown
|
||||||
|
)
|
||||||
|
and candidate_rms >= candidate_threshold
|
||||||
|
):
|
||||||
|
self._fallback_last_attempt_ts = now
|
||||||
|
if self._transcribe_wakeword_candidate(candidate):
|
||||||
|
self._last_hit_ts = now
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._fallback_pre_roll.append(np.asarray(pcm, dtype=np.int16))
|
||||||
|
return False
|
||||||
|
|
||||||
def stop_monitoring(self):
|
def stop_monitoring(self):
|
||||||
"""Явная остановка и закрытие потока (чтобы освободить микрофон для других задач)."""
|
"""Явная остановка и закрытие потока (чтобы освободить микрофон для других задач)."""
|
||||||
@@ -66,10 +329,46 @@ class WakeWordDetector:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._stream_closed = True
|
self._stream_closed = True
|
||||||
|
self._stream_opened_ts = 0.0
|
||||||
|
self._reset_fallback_state()
|
||||||
|
|
||||||
|
def _resample_to_target_rate(self, pcm: np.ndarray) -> np.ndarray:
|
||||||
|
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:
|
def wait_for_wakeword(self, timeout: float = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Блокирующая функция: ждет, пока не будет услышана фраза "Alexandr"
|
Блокирующая функция: ждет, пока не будет услышана wake word
|
||||||
или пока не истечет timeout.
|
или пока не истечет timeout.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -94,21 +393,23 @@ class WakeWordDetector:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Читаем небольшой кусочек аудио (frame)
|
# Читаем небольшой кусочек аудио (frame)
|
||||||
pcm = self.audio_stream.read(
|
pcm = self._read_porcupine_frame()
|
||||||
self.porcupine.frame_length, exception_on_overflow=False
|
self._remember_rms(self._compute_rms(pcm))
|
||||||
)
|
|
||||||
# Конвертируем байты в кортеж чисел (требование Porcupine)
|
|
||||||
pcm = struct.unpack_from("h" * self.porcupine.frame_length, pcm)
|
|
||||||
|
|
||||||
# Обрабатываем фрейм через Porcupine
|
# Обрабатываем фрейм через Porcupine
|
||||||
keyword_index = self.porcupine.process(pcm)
|
keyword_index = self.porcupine.process(pcm.tolist())
|
||||||
|
|
||||||
# Если keyword_index >= 0, значит ключевое слово обнаружено
|
# Если keyword_index >= 0, значит ключевое слово обнаружено
|
||||||
if keyword_index >= 0:
|
if keyword_index >= 0:
|
||||||
|
now = time.time()
|
||||||
|
if self._accept_porcupine_hit(pcm, now, during_tts=False):
|
||||||
print("✅ Wake word обнаружен!")
|
print("✅ Wake word обнаружен!")
|
||||||
# Важно: закрываем поток, чтобы освободить микрофон для STT (Deepgram)
|
# Важно: закрываем поток, чтобы освободить микрофон для STT (Deepgram)
|
||||||
self.stop_monitoring()
|
self.stop_monitoring()
|
||||||
return True
|
return True
|
||||||
|
if self._check_fallback_wakeword(pcm):
|
||||||
|
self.stop_monitoring()
|
||||||
|
return True
|
||||||
|
|
||||||
def check_wakeword_once(self) -> bool:
|
def check_wakeword_once(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -127,19 +428,26 @@ class WakeWordDetector:
|
|||||||
try:
|
try:
|
||||||
self._open_stream()
|
self._open_stream()
|
||||||
|
|
||||||
pcm = self.audio_stream.read(
|
pcm = self._read_porcupine_frame()
|
||||||
self.porcupine.frame_length, exception_on_overflow=False
|
self._remember_rms(self._compute_rms(pcm))
|
||||||
)
|
|
||||||
pcm = struct.unpack_from("h" * self.porcupine.frame_length, pcm)
|
|
||||||
|
|
||||||
keyword_index = self.porcupine.process(pcm)
|
keyword_index = self.porcupine.process(pcm.tolist())
|
||||||
if keyword_index >= 0:
|
if keyword_index >= 0:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - self._last_hit_ts < 0.2: # Уменьшаем интервал для более быстрой реакции
|
if not self._accept_porcupine_hit(
|
||||||
|
pcm,
|
||||||
|
now,
|
||||||
|
ignore_hit_cooldown=True,
|
||||||
|
during_tts=True,
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
self._last_hit_ts = now
|
|
||||||
print("🛑 Wake word обнаружен во время ответа!")
|
print("🛑 Wake word обнаружен во время ответа!")
|
||||||
return True
|
return True
|
||||||
|
if self._check_fallback_wakeword(
|
||||||
|
pcm, during_tts=True, ignore_hit_cooldown=True
|
||||||
|
):
|
||||||
|
print("🛑 Wake word обнаружен fallback STT во время ответа!")
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|||||||
762
app/core/ai.py
762
app/core/ai.py
@@ -1,26 +1,69 @@
|
|||||||
"""AI module for Perplexity API integration."""
|
"""AI module."""
|
||||||
|
|
||||||
# Модуль общения с искусственным интеллектом (Perplexity API).
|
import json
|
||||||
# Обрабатывает запросы пользователя и переводы.
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
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()
|
_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.
|
# Системный промпт
|
||||||
# Задает личность ассистента: имя "Александр", стиль общения, краткость.
|
_wake_word_aliases_text = ", ".join(WAKE_WORD_ALIASES)
|
||||||
SYSTEM_PROMPT = """Ты — Александр, умный голосовой ассистент с человеческим поведением.
|
SYSTEM_PROMPT = f"""Ты — умный голосовой ассистент с человеческим поведением.
|
||||||
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
|
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
|
||||||
Твоя главная цель — помогать пользователю и поддерживать интересный диалог.
|
Твоя главная цель — помогать пользователю и поддерживать интересный диалог.
|
||||||
Отвечай кратко и по существу, на русском языке.
|
Отвечай на русском языке кратко и по существу: обычно 1-2 коротких предложения.
|
||||||
|
Если пользователь явно просит подробнее, можно до 4 коротких предложений без повторов и лишних вводных.
|
||||||
Избегай длинных списков, сложного форматирования и спецсимволов, так как твои ответы озвучиваются голосом.
|
Избегай длинных списков, сложного форматирования и спецсимволов, так как твои ответы озвучиваются голосом.
|
||||||
|
Не добавляй ссылки, сноски и маркеры источников (например, [1], [2], URL).
|
||||||
Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов.
|
Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов.
|
||||||
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные."""
|
Понимай юмор, иронию, сарказм, образные выражения, намеки и переносный смысл фраз.
|
||||||
|
Если пользователь шутит или говорит образно, сначала правильно восстанови его реальное намерение, затем ответь естественно и по смыслу.
|
||||||
|
Если в шутке или метафоре скрыта команда или просьба, трактуй ее по смыслу, а не буквально.
|
||||||
|
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные.
|
||||||
|
Тебя активируют словом "{WAKE_WORD}". Никогда не произноси это слово и его варианты ({_wake_word_aliases_text}) ни в каком ответе.
|
||||||
|
Если пользователь спрашивает, как тебя зовут или как к тебе обращаться, отвечай нейтрально: "Я ваш голосовой ассистент"."""
|
||||||
|
SYSTEM_PROMPT += (
|
||||||
|
'\nROLE_JSON: {"name":"голосовой ассистент","role":"умный голосовой ассистент",'
|
||||||
|
'"language":"ru","style":["дружелюбный","естественный","краткий"],"format":"plain"}'
|
||||||
|
)
|
||||||
|
|
||||||
# Системный промпт для режима переводчика.
|
# Промпт для перевода
|
||||||
# Требует возвращать ТОЛЬКО перевод, без лишних слов ("Конечно, вот перевод...").
|
|
||||||
TRANSLATION_SYSTEM_PROMPT = """You are a translation engine.
|
TRANSLATION_SYSTEM_PROMPT = """You are a translation engine.
|
||||||
Translate from {source} to {target}.
|
Translate from {source} to {target}.
|
||||||
Return 2-3 short translation variants only.
|
Return 2-3 short translation variants only.
|
||||||
@@ -28,10 +71,460 @@ No explanations, no quotes, no comments.
|
|||||||
Separate variants with " / " (space slash space).
|
Separate variants with " / " (space slash space).
|
||||||
Keep the translation максимально кратким и естественным, без лишних слов."""
|
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):
|
def _send_request(messages, max_tokens, temperature, error_text):
|
||||||
"""
|
"""
|
||||||
Внутренняя функция для отправки HTTP-запроса к Perplexity API.
|
Внутренняя функция для отправки HTTP-запроса к выбранному AI-провайдеру.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: Список сообщений (история чата).
|
messages: Список сообщений (история чата).
|
||||||
@@ -39,35 +532,127 @@ def _send_request(messages, max_tokens, temperature, error_text):
|
|||||||
temperature: "Креативность" (0.2 - строго, 1.0 - креативно).
|
temperature: "Креативность" (0.2 - строго, 1.0 - креативно).
|
||||||
error_text: Текст ошибки для пользователя в случае сбоя.
|
error_text: Текст ошибки для пользователя в случае сбоя.
|
||||||
"""
|
"""
|
||||||
if not PERPLEXITY_API_KEY:
|
cfg, selection_error = _get_provider_settings()
|
||||||
return "Не настроен PERPLEXITY_API_KEY. Проверьте файл .env."
|
if selection_error:
|
||||||
headers = {
|
return selection_error
|
||||||
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
|
config_error = _get_provider_config_error(cfg)
|
||||||
"Content-Type": "application/json",
|
if config_error:
|
||||||
}
|
return config_error
|
||||||
payload = {
|
|
||||||
"model": PERPLEXITY_MODEL,
|
|
||||||
"messages": messages,
|
|
||||||
"max_tokens": max_tokens,
|
|
||||||
"temperature": temperature,
|
|
||||||
"stream": False # Убираем стриминг для более быстрого ответа
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Обычный запрос нужен для перевода и мест, где стриминг не требуется.
|
||||||
response = _HTTP.post(
|
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()
|
data = response.json()
|
||||||
return data["choices"][0]["message"]["content"]
|
content = _extract_response_content(cfg, data)
|
||||||
except requests.exceptions.Timeout:
|
if not content:
|
||||||
return "Извините, сервер не отвечает. Попробуйте позже."
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
print(f"❌ Ошибка API: {e}")
|
|
||||||
return error_text
|
|
||||||
except (KeyError, IndexError) as e:
|
|
||||||
print(f"❌ Ошибка парсинга ответа: {e}")
|
|
||||||
return "Не удалось обработать ответ от AI."
|
return "Не удалось обработать ответ от AI."
|
||||||
|
return content
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return f"Извините, сервер {cfg['name']} не отвечает. Попробуйте позже."
|
||||||
|
except requests.exceptions.RequestException as error:
|
||||||
|
_log_request_exception(cfg, error)
|
||||||
|
return error_text
|
||||||
|
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:
|
def ask_ai(messages_history: list) -> str:
|
||||||
@@ -91,10 +676,12 @@ def ask_ai(messages_history: list) -> str:
|
|||||||
|
|
||||||
response = _send_request(
|
response = _send_request(
|
||||||
messages,
|
messages,
|
||||||
max_tokens=500,
|
max_tokens=AI_CHAT_MAX_TOKENS,
|
||||||
temperature=1.0, # Высокая температура для более живого общения
|
temperature=AI_CHAT_TEMPERATURE,
|
||||||
error_text="Произошла ошибка при обращении к AI. Попробуйте ещё раз.",
|
error_text="Произошла ошибка при обращении к AI. Попробуйте ещё раз.",
|
||||||
)
|
)
|
||||||
|
response = _sanitize_chat_response(response)
|
||||||
|
response = _truncate_chat_response(response, AI_CHAT_MAX_CHARS)
|
||||||
|
|
||||||
if response:
|
if response:
|
||||||
print(f"💬 Ответ AI: {response[:100]}...")
|
print(f"💬 Ответ AI: {response[:100]}...")
|
||||||
@@ -105,8 +692,14 @@ def ask_ai_stream(messages_history: list):
|
|||||||
"""
|
"""
|
||||||
Generator that yields chunks of the AI response as they arrive.
|
Generator that yields chunks of the AI response as they arrive.
|
||||||
"""
|
"""
|
||||||
if not PERPLEXITY_API_KEY:
|
response = None
|
||||||
yield "Не настроен ключ PERPLEXITY_API_KEY. Проверьте файл .env."
|
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
|
return
|
||||||
if not messages_history:
|
if not messages_history:
|
||||||
yield "Извините, я не расслышал вашу команду."
|
yield "Извините, я не расслышал вашу команду."
|
||||||
@@ -118,47 +711,69 @@ def ask_ai_stream(messages_history: list):
|
|||||||
if msg["role"] == "user":
|
if msg["role"] == "user":
|
||||||
last_user_message = msg["content"]
|
last_user_message = msg["content"]
|
||||||
break
|
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)
|
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:
|
try:
|
||||||
|
# В голосовом режиме удобнее говорить частями, как только они приходят от API.
|
||||||
response = _HTTP.post(
|
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()
|
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):
|
full_text = _sanitize_chat_response("".join(raw_parts))
|
||||||
if line:
|
full_text = _truncate_chat_response(full_text, AI_CHAT_MAX_CHARS)
|
||||||
line_text = line
|
if not full_text:
|
||||||
if line_text.startswith("data: "):
|
return
|
||||||
data_str = line_text[6:] # Skip "data: "
|
|
||||||
if data_str == "[DONE]":
|
# Отдаем кусками по предложениям, чтобы main.py мог начинать озвучку раньше.
|
||||||
break
|
parts = _SENTENCE_BOUNDARY_RE.split(full_text)
|
||||||
try:
|
if not parts:
|
||||||
data_json = json.loads(data_str)
|
yield full_text
|
||||||
content = data_json["choices"][0]["delta"].get("content", "")
|
return
|
||||||
if content:
|
|
||||||
yield content
|
sentence = ""
|
||||||
except json.JSONDecodeError:
|
for part in parts:
|
||||||
|
if not part:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
sentence += part
|
||||||
print(f"❌ Streaming Error: {e}")
|
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 "Произошла ошибка связи."
|
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:
|
def translate_text(text: str, source_lang: str, target_lang: str) -> str:
|
||||||
@@ -189,17 +804,18 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str:
|
|||||||
response = _send_request(
|
response = _send_request(
|
||||||
messages,
|
messages,
|
||||||
max_tokens=160,
|
max_tokens=160,
|
||||||
temperature=0.2, # Низкая температура для точности перевода
|
temperature=AI_TRANSLATION_TEMPERATURE,
|
||||||
error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
|
error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
|
||||||
)
|
)
|
||||||
cleaned = response.strip()
|
cleaned = _sanitize_chat_response(response).strip()
|
||||||
|
cleaned = re.sub(r"[*_`]+", "", cleaned)
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
# Normalize to 2-3 variants separated by " / "
|
# Normalize to 2-3 variants separated by " / "
|
||||||
parts = []
|
parts = []
|
||||||
for chunk in re.split(r"(?:\s*/\s*|\n|;|\|)", cleaned):
|
for chunk in re.split(r"(?:\s*/\s*|\n|;|\|)", cleaned):
|
||||||
item = chunk.strip(" \t-•")
|
item = chunk.strip(" \t-•\"'“”«»")
|
||||||
if item:
|
if item:
|
||||||
parts.append(item)
|
parts.append(item)
|
||||||
if not parts:
|
if not parts:
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import pyaudio
|
import pyaudio
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
AUDIO_INPUT_DEVICE_INDEX,
|
||||||
|
AUDIO_INPUT_DEVICE_NAME,
|
||||||
|
AUDIO_OUTPUT_DEVICE_INDEX,
|
||||||
|
AUDIO_OUTPUT_DEVICE_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AudioManager:
|
class AudioManager:
|
||||||
_instance = None
|
_instance = None
|
||||||
@@ -11,12 +18,351 @@ class AudioManager:
|
|||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = super(AudioManager, cls).__new__(cls)
|
cls._instance = super(AudioManager, cls).__new__(cls)
|
||||||
cls._instance.pa = pyaudio.PyAudio()
|
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)")
|
print("🔊 AudioManager: PyAudio initialized (Global)")
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def get_pyaudio(self):
|
def get_pyaudio(self):
|
||||||
return self.pa
|
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):
|
def cleanup(self):
|
||||||
if self.pa:
|
if self.pa:
|
||||||
self.pa.terminate()
|
self.pa.terminate()
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
"""
|
"""Text cleaner for TTS."""
|
||||||
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.
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import pymorphy3
|
import pymorphy3
|
||||||
from num2words import num2words
|
from num2words import num2words
|
||||||
|
from .config import WAKE_WORD, WAKE_WORD_ALIASES
|
||||||
|
from .roman import roman_to_int
|
||||||
|
|
||||||
# Инициализация морфологического анализатора (для определения падежей)
|
|
||||||
morph = pymorphy3.MorphAnalyzer()
|
morph = pymorphy3.MorphAnalyzer()
|
||||||
|
|
||||||
# Карта предлогов и падежей.
|
# Предлоги и падежи
|
||||||
# Помогает понять, в какой падеж ставить число после предлога.
|
|
||||||
PREPOSITION_CASES = {
|
PREPOSITION_CASES = {
|
||||||
"в": "loct", # В ком/чем? (Предложный) или Винительный. Часто loct для годов.
|
"в": "loct", # В ком/чем? (Предложный) или Винительный. Часто loct для годов.
|
||||||
"во": "loct",
|
"во": "loct",
|
||||||
@@ -54,7 +45,7 @@ PREPOSITION_CASES = {
|
|||||||
"про": "accs",
|
"про": "accs",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Соответствие падежей pymorphy и библиотеки num2words
|
# Соответствие падежей
|
||||||
PYMORPHY_TO_NUM2WORDS = {
|
PYMORPHY_TO_NUM2WORDS = {
|
||||||
"nomn": "nominative",
|
"nomn": "nominative",
|
||||||
"gent": "genitive",
|
"gent": "genitive",
|
||||||
@@ -68,14 +59,14 @@ PYMORPHY_TO_NUM2WORDS = {
|
|||||||
"loc2": "prepositional",
|
"loc2": "prepositional",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Соответствие родов pymorphy и num2words
|
# Роды
|
||||||
PYMORPHY_TO_GENDER = {
|
PYMORPHY_TO_GENDER = {
|
||||||
"masc": "m",
|
"masc": "m",
|
||||||
"femn": "f",
|
"femn": "f",
|
||||||
"neut": "n",
|
"neut": "n",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Названия месяцев в родительном падеже (для поиска дат в тексте)
|
# Месяца
|
||||||
MONTHS_GENITIVE = [
|
MONTHS_GENITIVE = [
|
||||||
"января",
|
"января",
|
||||||
"февраля",
|
"февраля",
|
||||||
@@ -91,25 +82,53 @@ MONTHS_GENITIVE = [
|
|||||||
"декабря",
|
"декабря",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Леммы единиц времени (для корректного падежа числительных)
|
# Время
|
||||||
TIME_UNIT_LEMMAS = {"час", "минута", "секунда"}
|
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):
|
def get_case_from_preposition(prep_token):
|
||||||
"""Определяет падеж по предлогу."""
|
"""Падеж по предлогу."""
|
||||||
if not prep_token:
|
if not prep_token:
|
||||||
return None
|
return None
|
||||||
return PREPOSITION_CASES.get(prep_token.lower())
|
return PREPOSITION_CASES.get(prep_token.lower())
|
||||||
|
|
||||||
|
|
||||||
def convert_number(number_str, context_type="cardinal", case="nominative", gender="m"):
|
def convert_number(number_str, context_type="cardinal", case="nominative", gender="m"):
|
||||||
"""
|
"""Число в слова."""
|
||||||
Обертка над num2words для конвертации числа в строку.
|
|
||||||
cardinal - количественное (один, два)
|
|
||||||
ordinal - порядковое (первый, второй)
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Обработка дробей (замена запятой на точку)
|
|
||||||
if "." in number_str or "," in number_str:
|
if "." in number_str or "," in number_str:
|
||||||
num_val = float(number_str.replace(",", "."))
|
num_val = float(number_str.replace(",", "."))
|
||||||
else:
|
else:
|
||||||
@@ -122,31 +141,144 @@ def convert_number(number_str, context_type="cardinal", case="nominative", gende
|
|||||||
|
|
||||||
|
|
||||||
def numbers_to_words(text: str) -> str:
|
def numbers_to_words(text: str) -> str:
|
||||||
"""
|
"""Замена цифр на слова."""
|
||||||
Интеллектуальная замена цифр на слова с учетом контекста (даты, года, падежи).
|
|
||||||
"""
|
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# 1. Обработка годов: "в 1999 году", "2024 год"
|
preps_list = "|".join(map(re.escape, PREPOSITION_CASES.keys()))
|
||||||
def replace_year_match(match):
|
|
||||||
prep = match.group(1) # Предлог (в, с, к...)
|
|
||||||
year_str = match.group(2) # Само число
|
|
||||||
year_word = match.group(3) # Слово "год", "году" и т.д.
|
|
||||||
|
|
||||||
# Определяем падеж слова "год" через 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]
|
parsed = morph.parse(year_word)[0]
|
||||||
case_tag = parsed.tag.case
|
case_tag = parsed.tag.case
|
||||||
|
|
||||||
nw_case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative")
|
nw_case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative")
|
||||||
|
|
||||||
# FIX: Pymorphy часто определяет "год" как accs (винительный), что для num2words
|
# Без предлога - именительный
|
||||||
# превращается в родительный (для одушевленных?), давая "2024 года".
|
|
||||||
# Если предлога нет, принудительно ставим именительный.
|
|
||||||
if not prep and year_word.lower().startswith("год"):
|
if not prep and year_word.lower().startswith("год"):
|
||||||
nw_case = "nominative"
|
nw_case = "nominative"
|
||||||
|
|
||||||
# Конвертируем число в порядковое числительное (тысяча девятьсот девяносто девятом)
|
# Конвертируем
|
||||||
words = convert_number(
|
words = convert_number(
|
||||||
year_str, context_type="ordinal", case=nw_case, gender="m"
|
year_str, context_type="ordinal", case=nw_case, gender="m"
|
||||||
)
|
)
|
||||||
@@ -161,7 +293,7 @@ def numbers_to_words(text: str) -> str:
|
|||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Обработка дат: "25 июня", "с 1 мая"
|
# Даты
|
||||||
month_regex = "|".join(MONTHS_GENITIVE)
|
month_regex = "|".join(MONTHS_GENITIVE)
|
||||||
|
|
||||||
def replace_date_match(match):
|
def replace_date_match(match):
|
||||||
@@ -169,7 +301,7 @@ def numbers_to_words(text: str) -> str:
|
|||||||
day_str = match.group(2)
|
day_str = match.group(2)
|
||||||
month_word = match.group(3)
|
month_word = match.group(3)
|
||||||
|
|
||||||
# По умолчанию родительный падеж ("двадцать пятого июня")
|
# По умолчанию родительный
|
||||||
case = "genitive"
|
case = "genitive"
|
||||||
|
|
||||||
if prep:
|
if prep:
|
||||||
@@ -188,7 +320,7 @@ def numbers_to_words(text: str) -> str:
|
|||||||
if morph_case:
|
if morph_case:
|
||||||
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "genitive")
|
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "genitive")
|
||||||
|
|
||||||
# Используем средний род ('n') для дат (число - средний род: пятое, пятого)
|
# Средний род для дат
|
||||||
words = convert_number(day_str, context_type="ordinal", case=case, gender="n")
|
words = convert_number(day_str, context_type="ordinal", case=case, gender="n")
|
||||||
|
|
||||||
prefix = f"{prep} " if prep else ""
|
prefix = f"{prep} " if prep else ""
|
||||||
@@ -201,7 +333,7 @@ def numbers_to_words(text: str) -> str:
|
|||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Обработка всех остальных чисел (Количественные: пять столов, десять минут)
|
# Остальные числа
|
||||||
def replace_cardinal_match(match):
|
def replace_cardinal_match(match):
|
||||||
prep = match.group(1)
|
prep = match.group(1)
|
||||||
num_str = match.group(2)
|
num_str = match.group(2)
|
||||||
@@ -210,13 +342,14 @@ def numbers_to_words(text: str) -> str:
|
|||||||
case = "nominative"
|
case = "nominative"
|
||||||
gender = "m"
|
gender = "m"
|
||||||
prep_clean = prep.strip().lower() if prep else None
|
prep_clean = prep.strip().lower() if prep else None
|
||||||
|
parsed = None
|
||||||
|
|
||||||
if prep_clean:
|
if prep_clean:
|
||||||
morph_case = get_case_from_preposition(prep_clean)
|
morph_case = get_case_from_preposition(prep_clean)
|
||||||
if morph_case:
|
if morph_case:
|
||||||
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative")
|
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative")
|
||||||
|
|
||||||
# Если есть следующее слово, проверяем его род (для "2 минуты" -> "две")
|
# Проверяем род
|
||||||
if next_word:
|
if next_word:
|
||||||
word_clean = next_word.strip()
|
word_clean = next_word.strip()
|
||||||
parsed = morph.parse(word_clean)[0]
|
parsed = morph.parse(word_clean)[0]
|
||||||
@@ -224,11 +357,10 @@ def numbers_to_words(text: str) -> str:
|
|||||||
morph_gender = parsed.tag.gender
|
morph_gender = parsed.tag.gender
|
||||||
gender = PYMORPHY_TO_GENDER.get(morph_gender, "m")
|
gender = PYMORPHY_TO_GENDER.get(morph_gender, "m")
|
||||||
|
|
||||||
# Спец-случай: "на 1 час" -> "на один час" (не "одного")
|
# Спец-случай: "на 1 час"
|
||||||
# Для неодушевленных муж./ср. рода в винительном падеже
|
|
||||||
# числительные должны совпадать с именительным.
|
|
||||||
if (
|
if (
|
||||||
prep_clean == "на"
|
prep_clean == "на"
|
||||||
|
and parsed is not None
|
||||||
and parsed.normal_form in TIME_UNIT_LEMMAS
|
and parsed.normal_form in TIME_UNIT_LEMMAS
|
||||||
and parsed.tag.gender in ("masc", "neut")
|
and parsed.tag.gender in ("masc", "neut")
|
||||||
):
|
):
|
||||||
@@ -238,7 +370,7 @@ def numbers_to_words(text: str) -> str:
|
|||||||
num_str, context_type="cardinal", case=case, gender=gender
|
num_str, context_type="cardinal", case=case, gender=gender
|
||||||
)
|
)
|
||||||
|
|
||||||
# Если конвертация вернула пустую строку (сбой?), возвращаем цифры
|
# Если конвертация не удалась - возвращаем цифры
|
||||||
if not words:
|
if not words:
|
||||||
words = num_str
|
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(
|
text = re.sub(
|
||||||
rf"(?i)(?<!\w)((?:{preps_list})\s+)?([+-]?\d+(?:[.,]\d+)?)(?=(\s+[а-яА-ЯёЁ]+))?\b",
|
rf"(?i)(?<!\w)((?:{preps_list})\s+)?([+-]?\d+(?:[.,]\d+)?)(?=(\s+[а-яА-ЯёЁ]+))?\b",
|
||||||
replace_cardinal_match,
|
replace_cardinal_match,
|
||||||
@@ -258,64 +389,103 @@ def numbers_to_words(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def clean_response(text: str, language: str = "ru") -> str:
|
def roman_numerals_to_words(text: str) -> str:
|
||||||
"""
|
"""Римские в слова."""
|
||||||
Основная функция очистки.
|
|
||||||
Убирает Markdown, ссылки, мусор и преобразует числа.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Сырой текст от AI.
|
|
||||||
language: Язык (для конвертации чисел, работает только для ru).
|
|
||||||
"""
|
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
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"\x5B\d+\x5D", "", text)
|
||||||
text = re.sub(r"\x5Bcitation\s*needed\x5D", "", text, flags=re.IGNORECASE)
|
text = re.sub(r"\x5Bcitation\s*needed\x5D", "", text, flags=re.IGNORECASE)
|
||||||
text = re.sub(r"\x5Bsource\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 = 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"\*(.+?)\*", r"\1", text)
|
||||||
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
|
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
|
||||||
|
|
||||||
# Удаление зачеркнутого ~~text~~
|
# Удаление зачеркнутого
|
||||||
text = re.sub(r"~~(.+?)~~", r"\1", text)
|
text = re.sub(r"~~(.+?)~~", r"\1", text)
|
||||||
|
|
||||||
# Удаление заголовков Markdown (# Header)
|
# Заголовки
|
||||||
text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
|
text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
|
||||||
|
|
||||||
# Удаление картинок  -> удаляем полностью
|
# Картинки
|
||||||
text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", text)
|
text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", text)
|
||||||
|
|
||||||
# Удаление ссылок [text](url) -> оставляем только text
|
# Ссылки
|
||||||
# \x5B = [, \x5D = ]
|
|
||||||
text = re.sub(r"\x5B([^\x5D]+)\x5D\([^)]+\)", r"\1", text)
|
text = re.sub(r"\x5B([^\x5D]+)\x5D\([^)]+\)", r"\1", text)
|
||||||
|
|
||||||
# Удаление inline кода `code`
|
# Код
|
||||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||||
|
|
||||||
# Удаление блоков кода ```code```
|
|
||||||
text = re.sub(r"```[\s\S]*?```", "", text)
|
text = re.sub(r"```[\s\S]*?```", "", text)
|
||||||
|
|
||||||
# Удаление маркеров списков (-, *, 1.)
|
# Списки
|
||||||
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
|
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*\d+\.\s+", "", text, flags=re.MULTILINE)
|
||||||
|
|
||||||
# Удаление цитат >
|
# Цитаты
|
||||||
text = re.sub(r"^\s*>\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)
|
text = re.sub(r"^[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
|
||||||
|
|
||||||
# Удаление HTML тегов
|
# HTML теги
|
||||||
text = re.sub(r"<[^>]+>", "", text)
|
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(
|
text = re.sub(
|
||||||
r"^(Эй|Хэй|Слушай|Так|Ну|Короче|В\s+общем)[,!?:]?\s*",
|
r"^(Эй|Хэй|Слушай|Так|Ну|Короче|В\s+общем)[,!?:]?\s*",
|
||||||
"",
|
"",
|
||||||
@@ -323,11 +493,17 @@ def clean_response(text: str, language: str = "ru") -> str:
|
|||||||
flags=re.IGNORECASE | re.MULTILINE,
|
flags=re.IGNORECASE | re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert numbers to words only for Russian, and only if digits exist
|
# Запрет на произнесение wake word в любых ответах ассистента.
|
||||||
if language == "ru" and re.search(r"\d", text):
|
for pattern in WAKE_WORD_BLOCKED_PATTERNS:
|
||||||
|
text = pattern.sub("ассистент", text)
|
||||||
|
|
||||||
|
# Числа в слова
|
||||||
|
if language == "ru":
|
||||||
|
text = roman_numerals_to_words(text)
|
||||||
|
if re.search(r"\d", text):
|
||||||
text = numbers_to_words(text)
|
text = numbers_to_words(text)
|
||||||
|
|
||||||
# Remove extra whitespace
|
# Чистка пробелов
|
||||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
text = re.sub(r" +", " ", text)
|
text = re.sub(r" +", " ", text)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ Command parsing helpers.
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from .config import WAKE_WORD, WAKE_WORD_ALIASES
|
||||||
|
from ..audio.sound_level import is_volume_command, parse_volume_text
|
||||||
|
|
||||||
_STOP_WORDS_STRICT = {
|
_STOP_WORDS_STRICT = {
|
||||||
"стоп",
|
"стоп",
|
||||||
"хватит",
|
"хватит",
|
||||||
@@ -31,6 +34,28 @@ _STOP_PATTERNS_LENIENT = [
|
|||||||
r"\bдостаточно\b",
|
r"\bдостаточно\b",
|
||||||
]
|
]
|
||||||
_STOP_PATTERNS_LENIENT_COMPILED = [re.compile(p) for p in _STOP_PATTERNS_LENIENT]
|
_STOP_PATTERNS_LENIENT_COMPILED = [re.compile(p) for p in _STOP_PATTERNS_LENIENT]
|
||||||
|
_FAST_WEATHER_PHRASES = {
|
||||||
|
"какая погода",
|
||||||
|
"какая погода на улице",
|
||||||
|
"какая сейчас погода",
|
||||||
|
"какая сейчас погода на улице",
|
||||||
|
"что по погоде",
|
||||||
|
"погода",
|
||||||
|
"погода на улице",
|
||||||
|
"что на улице",
|
||||||
|
"что там на улице",
|
||||||
|
"че там на улице",
|
||||||
|
}
|
||||||
|
_FAST_MUSIC_PHRASES = {
|
||||||
|
"включи музыку",
|
||||||
|
"поставь музыку",
|
||||||
|
"играй музыку",
|
||||||
|
"play music",
|
||||||
|
}
|
||||||
|
_WAKEWORD_PREFIX_RE = re.compile(
|
||||||
|
rf"^(?:{'|'.join(re.escape(alias) for alias in sorted({WAKE_WORD.lower(), *WAKE_WORD_ALIASES}, key=len, reverse=True))})(?:\s+|$)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_text(text: str) -> str:
|
def _normalize_text(text: str) -> str:
|
||||||
@@ -40,6 +65,13 @@ def _normalize_text(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_command_text(text: str) -> str:
|
||||||
|
normalized = _normalize_text(text)
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
return _WAKEWORD_PREFIX_RE.sub("", normalized, count=1).strip()
|
||||||
|
|
||||||
|
|
||||||
def is_stop_command(text: str, mode: str = "strict") -> bool:
|
def is_stop_command(text: str, mode: str = "strict") -> bool:
|
||||||
"""
|
"""
|
||||||
Detect stop commands in text.
|
Detect stop commands in text.
|
||||||
@@ -64,3 +96,27 @@ def is_stop_command(text: str, mode: str = "strict") -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_fast_command(text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Detect short commands that can stop STT early without waiting
|
||||||
|
for full utterance finalization.
|
||||||
|
"""
|
||||||
|
normalized = normalize_command_text(text)
|
||||||
|
if not normalized:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if is_stop_command(normalized, mode="strict"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if normalized in _FAST_WEATHER_PHRASES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if normalized in _FAST_MUSIC_PHRASES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if is_volume_command(normalized) and parse_volume_text(normalized) is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|||||||
@@ -7,22 +7,121 @@ Loads environment variables from .env file.
|
|||||||
# Он загружает настройки из файла .env (переменные окружения) и определяет константы.
|
# Он загружает настройки из файла .env (переменные окружения) и определяет константы.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
# Базовая директория проекта (корневая папка, где лежит .env)
|
# Базовая директория проекта (корневая папка, где лежит .env)
|
||||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
# Загружаем переменные из файла .env в корневом каталоге
|
def _load_project_env(env_path: Path) -> None:
|
||||||
load_dotenv(BASE_DIR / ".env")
|
"""
|
||||||
|
Загружает .env, игнорируя строковый "шум" без формата KEY=VALUE.
|
||||||
|
Это делает конфиг устойчивым к человеческим комментариям без символа '#'.
|
||||||
|
"""
|
||||||
|
if not env_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
# --- Настройки AI (Perplexity) ---
|
raw_text = env_path.read_text(encoding="utf-8")
|
||||||
# API ключ для доступа к нейросети
|
sanitized_lines = []
|
||||||
PERPLEXITY_API_KEY = os.getenv("PERPLEXITY_API_KEY")
|
|
||||||
# Модель, которую будем использовать (по умолчанию llama-3.1-sonar-small-128k-chat)
|
for line in raw_text.splitlines():
|
||||||
PERPLEXITY_MODEL = os.getenv("PERPLEXITY_MODEL", "llama-3.1-sonar-small-128k-chat")
|
stripped = line.strip()
|
||||||
PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"
|
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) ---
|
# --- Настройки распознавания речи (Deepgram) ---
|
||||||
# Ключ для облачного STT (Speech-to-Text)
|
# Ключ для облачного STT (Speech-to-Text)
|
||||||
@@ -31,14 +130,87 @@ DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY")
|
|||||||
# --- Настройки активации голосом (Porcupine) ---
|
# --- Настройки активации голосом (Porcupine) ---
|
||||||
# Ключ доступа PicoVoice
|
# Ключ доступа PicoVoice
|
||||||
PORCUPINE_ACCESS_KEY = os.getenv("PORCUPINE_ACCESS_KEY")
|
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
|
# Путь к файлу модели ключевого слова (.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
|
SAMPLE_RATE = 16000
|
||||||
CHANNELS = 1
|
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()
|
time.tzset()
|
||||||
|
|
||||||
# --- Настройки синтеза речи (TTS) ---
|
# --- Настройки синтеза речи (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 - мужской голос)
|
# Голос для русского языка (eugene - мужской голос)
|
||||||
TTS_SPEAKER = "eugene" # Доступные (ru): aidar, baya, kseniya, xenia, eugene
|
TTS_SPEAKER = "eugene" # Доступные (ru): aidar, baya, kseniya, xenia, eugene
|
||||||
# Голос для английского языка
|
# Голос для английского языка
|
||||||
TTS_EN_SPEAKER = os.getenv("TTS_EN_SPEAKER", "en_0")
|
TTS_EN_SPEAKER = os.getenv("TTS_EN_SPEAKER", "en_0")
|
||||||
# Частота дискретизации для воспроизведения (качество звука)
|
# Частота дискретизации для воспроизведения (качество звука)
|
||||||
TTS_SAMPLE_RATE = 48000
|
TTS_SAMPLE_RATE = 48000
|
||||||
|
# Скорость TTS: 1.0 = обычная, <1.0 = медленнее, >1.0 = быстрее.
|
||||||
|
# По умолчанию чуть медленнее для более разборчивой речи.
|
||||||
|
TTS_SPEED = _read_clamped_float_env("TTS_SPEED", "0.96", 0.85, 1.15)
|
||||||
|
|
||||||
# --- Настройки погоды ---
|
# --- Настройки погоды ---
|
||||||
WEATHER_LAT = os.getenv("WEATHER_LAT")
|
WEATHER_LAT = os.getenv("WEATHER_LAT")
|
||||||
WEATHER_LON = os.getenv("WEATHER_LON")
|
WEATHER_LON = os.getenv("WEATHER_LON")
|
||||||
WEATHER_CITY = os.getenv("WEATHER_CITY", "Ухта")
|
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
43
app/core/roman.py
Normal 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)
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
"""
|
"""Small talk responses."""
|
||||||
Short, human-like responses for small talk.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,18 +7,19 @@ import re
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
_SMALLTALK_PATTERNS = [
|
_SMALLTALK_PHRASES = {
|
||||||
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_RESPONSES = [
|
_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]:
|
def get_smalltalk_response(text: str) -> Optional[str]:
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
normalized = text.lower().replace("ё", "е")
|
normalized = _normalize_smalltalk_text(text)
|
||||||
for pattern in _SMALLTALK_PATTERNS:
|
if normalized in _SMALLTALK_PHRASES:
|
||||||
if pattern.search(normalized):
|
|
||||||
return random.choice(_SMALLTALK_RESPONSES)
|
return random.choice(_SMALLTALK_RESPONSES)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -10,11 +10,171 @@ from datetime import datetime
|
|||||||
from ..core.config import BASE_DIR
|
from ..core.config import BASE_DIR
|
||||||
from ..audio.stt import listen
|
from ..audio.stt import listen
|
||||||
from ..core.commands import is_stop_command
|
from ..core.commands import is_stop_command
|
||||||
|
from ..core.roman import replace_roman_numerals
|
||||||
|
|
||||||
# Файл базы данных будильников
|
# Файл базы данных будильников
|
||||||
ALARM_FILE = BASE_DIR / "data" / "alarms.json"
|
ALARM_FILE = BASE_DIR / "data" / "alarms.json"
|
||||||
# Звуковой файл сигнала
|
# Звуковой файл сигнала
|
||||||
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
|
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:
|
class AlarmClock:
|
||||||
@@ -68,10 +228,10 @@ class AlarmClock:
|
|||||||
if re.search(r"\b(каждый день|ежедневно)\b", text):
|
if re.search(r"\b(каждый день|ежедневно)\b", text):
|
||||||
return [0, 1, 2, 3, 4, 5, 6]
|
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])
|
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])
|
days.update([5, 6])
|
||||||
|
|
||||||
day_patterns = {
|
day_patterns = {
|
||||||
@@ -112,7 +272,14 @@ class AlarmClock:
|
|||||||
return self.add_alarm_with_days(hour, minute, days=None)
|
return self.add_alarm_with_days(hour, minute, days=None)
|
||||||
|
|
||||||
def add_alarm_with_days(self, hour: int, minute: int, days=None):
|
def add_alarm_with_days(self, hour: int, minute: int, days=None):
|
||||||
"""Добавление нового будильника (или обновление существующего) с днями недели."""
|
"""
|
||||||
|
Добавление нового будильника (или обновление существующего) с днями недели.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"created" - создан новый будильник
|
||||||
|
"reactivated" - найден существующий неактивный, включён обратно
|
||||||
|
"already_active" - такой будильник уже активен
|
||||||
|
"""
|
||||||
days_key = self._days_key(days)
|
days_key = self._days_key(days)
|
||||||
for alarm in self.alarms:
|
for alarm in self.alarms:
|
||||||
if (
|
if (
|
||||||
@@ -120,11 +287,13 @@ class AlarmClock:
|
|||||||
and alarm.get("minute") == minute
|
and alarm.get("minute") == minute
|
||||||
and self._days_key(alarm.get("days")) == days_key
|
and self._days_key(alarm.get("days")) == days_key
|
||||||
):
|
):
|
||||||
|
if alarm.get("active"):
|
||||||
|
return "already_active"
|
||||||
alarm["active"] = True
|
alarm["active"] = True
|
||||||
alarm["days"] = days_key
|
alarm["days"] = days_key
|
||||||
alarm["last_triggered"] = None
|
alarm["last_triggered"] = None
|
||||||
self.save_alarms()
|
self.save_alarms()
|
||||||
return
|
return "reactivated"
|
||||||
|
|
||||||
self.alarms.append(
|
self.alarms.append(
|
||||||
{"hour": hour, "minute": minute, "active": True, "days": days_key}
|
{"hour": hour, "minute": minute, "active": True, "days": days_key}
|
||||||
@@ -133,6 +302,7 @@ class AlarmClock:
|
|||||||
days_phrase = self._format_days_phrase(days_key)
|
days_phrase = self._format_days_phrase(days_key)
|
||||||
suffix = f" {days_phrase}" if days_phrase else ""
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}{suffix}")
|
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}{suffix}")
|
||||||
|
return "created"
|
||||||
|
|
||||||
def cancel_all_alarms(self):
|
def cancel_all_alarms(self):
|
||||||
"""Выключение (деактивация) всех будильников."""
|
"""Выключение (деактивация) всех будильников."""
|
||||||
@@ -141,6 +311,33 @@ class AlarmClock:
|
|||||||
self.save_alarms()
|
self.save_alarms()
|
||||||
print("🔕 Все будильники отменены.")
|
print("🔕 Все будильники отменены.")
|
||||||
|
|
||||||
|
def remove_alarms(self, hour: int, minute: int, days=None) -> int:
|
||||||
|
"""
|
||||||
|
Удаляет будильники по времени.
|
||||||
|
Если переданы days, удаляются только будильники с совпадающими днями.
|
||||||
|
"""
|
||||||
|
days_key = self._days_key(days)
|
||||||
|
kept = []
|
||||||
|
removed = 0
|
||||||
|
|
||||||
|
for alarm in self.alarms:
|
||||||
|
alarm_hour = alarm.get("hour")
|
||||||
|
alarm_minute = alarm.get("minute")
|
||||||
|
if alarm_hour != hour or alarm_minute != minute:
|
||||||
|
kept.append(alarm)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if days_key is not None and self._days_key(alarm.get("days")) != days_key:
|
||||||
|
kept.append(alarm)
|
||||||
|
continue
|
||||||
|
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
self.alarms = kept
|
||||||
|
self.save_alarms()
|
||||||
|
return removed
|
||||||
|
|
||||||
def describe_alarms(self) -> str:
|
def describe_alarms(self) -> str:
|
||||||
"""Возвращает текстовое описание активных будильников."""
|
"""Возвращает текстовое описание активных будильников."""
|
||||||
active = [
|
active = [
|
||||||
@@ -229,7 +426,7 @@ class AlarmClock:
|
|||||||
try:
|
try:
|
||||||
# Цикл ожидания стоп-команды
|
# Цикл ожидания стоп-команды
|
||||||
while True:
|
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 text:
|
||||||
if is_stop_command(text, mode="lenient"):
|
if is_stop_command(text, mode="lenient"):
|
||||||
print(f"🛑 Будильник остановлен по команде: '{text}'")
|
print(f"🛑 Будильник остановлен по команде: '{text}'")
|
||||||
@@ -248,58 +445,60 @@ class AlarmClock:
|
|||||||
|
|
||||||
def parse_command(self, text: str) -> str | None:
|
def parse_command(self, text: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Парсинг команды установки будильника из текста.
|
Парсинг команд управления будильниками.
|
||||||
Примеры: "разбуди в 7:30", "будильник на 8 утра".
|
Примеры: "разбуди в 7:30", "удали будильник на 8:00", "какие будильники".
|
||||||
"""
|
"""
|
||||||
text = text.lower()
|
text = replace_roman_numerals(text.lower().replace("ё", "е"))
|
||||||
if "будильник" not in text and "разбуди" not in text:
|
if not re.search(r"\b(будильник\w*|разбуд\w*)\b", text):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if "будильник" in text and re.search(
|
if _ALARM_LIST_RE.search(text):
|
||||||
r"(какие|какой|список|активн|покажи|сколько|есть ли)", text
|
|
||||||
):
|
|
||||||
return self.describe_alarms()
|
return self.describe_alarms()
|
||||||
|
|
||||||
if "отмени" in text:
|
if _ALARM_CANCEL_RE.search(text):
|
||||||
|
cancel_time = _extract_alarm_time(text)
|
||||||
|
cancel_days = self._extract_alarm_days(text)
|
||||||
|
if cancel_time:
|
||||||
|
h, m = cancel_time
|
||||||
|
removed = self.remove_alarms(h, m, days=cancel_days)
|
||||||
|
if removed:
|
||||||
|
days_phrase = self._format_days_phrase(cancel_days)
|
||||||
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
|
return f"Удалил {removed} будильник(а) на {h:02d}:{m:02d}{suffix}."
|
||||||
|
return f"Не нашел будильник на {h:02d}:{m:02d}."
|
||||||
|
|
||||||
|
if re.search(r"\b(все|всех)\b", text) or "будильники" in text:
|
||||||
self.cancel_all_alarms()
|
self.cancel_all_alarms()
|
||||||
return "Хорошо, я отменил все будильники."
|
return "Хорошо, я отменил все будильники."
|
||||||
|
|
||||||
days = self._extract_alarm_days(text)
|
return (
|
||||||
|
"Скажите время будильника, который нужно удалить. "
|
||||||
# Поиск формата "7:30", "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}."
|
|
||||||
|
|
||||||
# Поиск формата словами "на 7 часов 15 минут"
|
|
||||||
match_time = re.search(
|
|
||||||
r"на\s+(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?",
|
|
||||||
text,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if match_time:
|
days = self._extract_alarm_days(text)
|
||||||
h = int(match_time.group(1))
|
alarm_time = _extract_alarm_time(text)
|
||||||
m = int(match_time.group(2)) if match_time.group(2) else 0
|
if alarm_time:
|
||||||
|
h, m = alarm_time
|
||||||
# Умная коррекция времени (если говорят "в 8", а сейчас 9, то это скорее 8 вечера или 8 утра завтра)
|
add_status = self.add_alarm_with_days(h, m, days=days)
|
||||||
# Здесь простая логика AM/PM
|
if add_status == "already_active":
|
||||||
if "вечера" in text and h < 12:
|
return "Такой будильник уже установлен."
|
||||||
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)
|
days_phrase = self._format_days_phrase(days)
|
||||||
suffix = f" {days_phrase}" if days_phrase else ""
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
||||||
|
|
||||||
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
|
if _ALARM_CREATE_RE.search(text) or text.strip() in {
|
||||||
|
"будильник",
|
||||||
|
"поставь будильник",
|
||||||
|
"создай будильник",
|
||||||
|
"добавь будильник",
|
||||||
|
}:
|
||||||
|
return ASK_ALARM_TIME_PROMPT
|
||||||
|
|
||||||
|
return (
|
||||||
|
"Я не понял команду для будильника. "
|
||||||
|
"Скажите, например: поставь на 7:30, покажи будильники или удали будильник на 7:30."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Глобальный экземпляр
|
# Глобальный экземпляр
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
267
app/features/stopwatch.py
Normal file
267
app/features/stopwatch.py
Normal 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
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
"""Timer module."""
|
"""Timer module."""
|
||||||
|
|
||||||
# Модуль таймера.
|
|
||||||
# Отвечает за установку таймеров (в оперативной памяти), их проверку и воспроизведение звука.
|
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
@@ -10,6 +7,7 @@ from datetime import datetime, timedelta
|
|||||||
from ..core.config import BASE_DIR
|
from ..core.config import BASE_DIR
|
||||||
from ..audio.stt import listen
|
from ..audio.stt import listen
|
||||||
from ..core.commands import is_stop_command
|
from ..core.commands import is_stop_command
|
||||||
|
from ..core.roman import replace_roman_numerals
|
||||||
|
|
||||||
# Morphological analysis for better recognition of number words.
|
# Morphological analysis for better recognition of number words.
|
||||||
try:
|
try:
|
||||||
@@ -22,8 +20,9 @@ except Exception:
|
|||||||
# Звуковой файл сигнала (используем тот же, что и для будильника)
|
# Звуковой файл сигнала (используем тот же, что и для будильника)
|
||||||
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
|
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
|
||||||
TIMER_FILE = BASE_DIR / "data" / "timers.json"
|
TIMER_FILE = BASE_DIR / "data" / "timers.json"
|
||||||
|
ASK_TIMER_TIME_PROMPT = "На какое время мне поставить таймер?"
|
||||||
|
|
||||||
# --- Number words parsing helpers (ru) ---
|
# Числа словами
|
||||||
_NUMBER_UNITS = {
|
_NUMBER_UNITS = {
|
||||||
"ноль": 0,
|
"ноль": 0,
|
||||||
"один": 1,
|
"один": 1,
|
||||||
@@ -101,13 +100,19 @@ _UNIT_LEMMAS = {
|
|||||||
"мин": "minutes",
|
"мин": "minutes",
|
||||||
"сек": "seconds",
|
"сек": "seconds",
|
||||||
}
|
}
|
||||||
|
_UNIT_LEMMAS = {
|
||||||
|
"час": "hours",
|
||||||
|
"минута": "minutes",
|
||||||
|
"секунда": "seconds",
|
||||||
|
"мин": "minutes",
|
||||||
|
"сек": "seconds",
|
||||||
|
}
|
||||||
_UNIT_FORMS = {
|
_UNIT_FORMS = {
|
||||||
"hours": ("час", "часа", "часов"),
|
"hours": ("час", "часа", "часов"),
|
||||||
"minutes": ("минуту", "минуты", "минут"),
|
"minutes": ("минуту", "минуты", "минут"),
|
||||||
"seconds": ("секунду", "секунды", "секунд"),
|
"seconds": ("секунду", "секунды", "секунд"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Optional ordinal formatting for list numbering.
|
|
||||||
try:
|
try:
|
||||||
from num2words import num2words
|
from num2words import num2words
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -162,11 +167,13 @@ def _parse_number_lemmas(lemmas):
|
|||||||
|
|
||||||
def _normalize_timer_text(text: str) -> str:
|
def _normalize_timer_text(text: str) -> str:
|
||||||
# Split "полчаса/полминуты/полсекунды" into "пол часа" for easier parsing.
|
# Split "полчаса/полминуты/полсекунды" into "пол часа" for easier parsing.
|
||||||
return re.sub(
|
text = re.sub(
|
||||||
r"(?i)\bпол(?=(?:час|часа|минут|минуты|минуту|секунд|секунды|секунду|мин|сек)\b)",
|
r"(?i)\bпол(?=(?:час|часа|минут|минуты|минуту|секунд|секунды|секунду|мин|сек)\b)",
|
||||||
"пол ",
|
"пол ",
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
# Support commands like "таймер на X минут".
|
||||||
|
return replace_roman_numerals(text)
|
||||||
|
|
||||||
|
|
||||||
def _find_word_number_before_unit(tokens, unit_index):
|
def _find_word_number_before_unit(tokens, unit_index):
|
||||||
@@ -247,12 +254,11 @@ def _format_ordinal_index(index: int) -> str:
|
|||||||
|
|
||||||
class TimerManager:
|
class TimerManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Список активных таймеров: {"end_time": datetime, "label": str}
|
|
||||||
self.timers = []
|
self.timers = []
|
||||||
self.load_timers()
|
self.load_timers()
|
||||||
|
|
||||||
def load_timers(self):
|
def load_timers(self):
|
||||||
"""Загрузка списка таймеров из JSON файла."""
|
"""Загрузка из файла."""
|
||||||
if TIMER_FILE.exists():
|
if TIMER_FILE.exists():
|
||||||
try:
|
try:
|
||||||
with open(TIMER_FILE, "r", encoding="utf-8") as f:
|
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"])
|
self.timers = sorted(timers, key=lambda x: x["end_time"])
|
||||||
|
|
||||||
def save_timers(self):
|
def save_timers(self):
|
||||||
"""Сохранение списка таймеров в JSON файл."""
|
"""Сохранение в файл."""
|
||||||
payload = [
|
payload = [
|
||||||
{"end_time": t["end_time"].isoformat(), "label": t.get("label", "")}
|
{"end_time": t["end_time"].isoformat(), "label": t.get("label", "")}
|
||||||
for t in self.timers
|
for t in self.timers
|
||||||
@@ -285,7 +291,7 @@ class TimerManager:
|
|||||||
print(f"❌ Ошибка сохранения таймеров: {e}")
|
print(f"❌ Ошибка сохранения таймеров: {e}")
|
||||||
|
|
||||||
def describe_timers(self) -> str:
|
def describe_timers(self) -> str:
|
||||||
"""Возвращает текстовое описание активных таймеров."""
|
"""Описание активных таймеров."""
|
||||||
if not self.timers:
|
if not self.timers:
|
||||||
return "Активных таймеров нет."
|
return "Активных таймеров нет."
|
||||||
|
|
||||||
@@ -308,38 +314,29 @@ class TimerManager:
|
|||||||
return "Активные таймеры: " + "; ".join(items) + "."
|
return "Активные таймеры: " + "; ".join(items) + "."
|
||||||
|
|
||||||
def add_timer(self, seconds: int, label: str):
|
def add_timer(self, seconds: int, label: str):
|
||||||
"""Добавление нового таймера."""
|
"""Добавить таймер."""
|
||||||
end_time = datetime.now() + timedelta(seconds=seconds)
|
end_time = datetime.now() + timedelta(seconds=seconds)
|
||||||
self.timers.append({"end_time": end_time, "label": label})
|
self.timers.append({"end_time": end_time, "label": label})
|
||||||
# Сортируем, чтобы ближайший был первым
|
|
||||||
self.timers.sort(key=lambda x: x["end_time"])
|
self.timers.sort(key=lambda x: x["end_time"])
|
||||||
self.save_timers()
|
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):
|
def cancel_all_timers(self):
|
||||||
"""Отмена всех таймеров."""
|
"""Отменить все таймеры."""
|
||||||
count = len(self.timers)
|
count = len(self.timers)
|
||||||
self.timers = []
|
self.timers = []
|
||||||
self.save_timers()
|
self.save_timers()
|
||||||
print(f"🔕 Все таймеры ({count}) отменены.")
|
print(f"🔕 Таймеры отменены: {count}")
|
||||||
|
|
||||||
def check_timers(self):
|
def check_timers(self):
|
||||||
"""
|
"""Проверка таймеров. Возвращает True если сработал."""
|
||||||
Проверка: не истек ли какой-то таймер?
|
|
||||||
Вызывается в главном цикле.
|
|
||||||
Возвращает True, если таймер сработал (и был обработан).
|
|
||||||
"""
|
|
||||||
if not self.timers:
|
if not self.timers:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
# Смотрим первый (самый ранний) таймер
|
|
||||||
# Используем индекс 0, так как список отсортирован
|
|
||||||
first_timer = self.timers[0]
|
first_timer = self.timers[0]
|
||||||
|
|
||||||
if now >= first_timer["end_time"]:
|
if now >= first_timer["end_time"]:
|
||||||
# Таймер сработал!
|
|
||||||
# Удаляем его из списка
|
|
||||||
label = first_timer["label"]
|
label = first_timer["label"]
|
||||||
self.timers.pop(0)
|
self.timers.pop(0)
|
||||||
self.save_timers()
|
self.save_timers()
|
||||||
@@ -351,36 +348,30 @@ class TimerManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def trigger_timer(self, label: str):
|
def trigger_timer(self, label: str):
|
||||||
"""
|
"""Срабатывание таймера."""
|
||||||
Логика срабатывания таймера.
|
print(f"🔔 ТАЙМЕР {label}!")
|
||||||
Запускает воспроизведение MP3 и слушает команду "Стоп".
|
|
||||||
"""
|
|
||||||
print(f"🔔 ТАЙМЕР НА {label} СРАБОТАЛ! (Скажите 'Стоп')")
|
|
||||||
|
|
||||||
# Запуск плеера mpg123 в бесконечном цикле
|
|
||||||
cmd = ["mpg123", "-q", "--loop", "-1", str(ALARM_SOUND)]
|
cmd = ["mpg123", "-q", "--loop", "-1", str(ALARM_SOUND)]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(cmd)
|
process = subprocess.Popen(cmd)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print(
|
print("❌ mpg123 не найден. Установите: sudo apt install mpg123")
|
||||||
"❌ Ошибка: mpg123 не найден. Установите его: sudo apt install mpg123"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Цикл ожидания стоп-команды
|
|
||||||
while True:
|
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 text:
|
||||||
if is_stop_command(text, mode="lenient"):
|
if is_stop_command(text, mode="lenient"):
|
||||||
print(f"🛑 Таймер остановлен по команде: '{text}'")
|
print(f"🛑 Остановлен: '{text}'")
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка во время таймера: {e}")
|
print(f"❌ Ошибка: {e}")
|
||||||
finally:
|
finally:
|
||||||
# Обязательно убиваем процесс плеера
|
|
||||||
process.terminate()
|
process.terminate()
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=1)
|
process.wait(timeout=1)
|
||||||
@@ -389,10 +380,7 @@ class TimerManager:
|
|||||||
print("🔕 Таймер выключен.")
|
print("🔕 Таймер выключен.")
|
||||||
|
|
||||||
def parse_command(self, text: str) -> str | None:
|
def parse_command(self, text: str) -> str | None:
|
||||||
"""
|
"""Парсинг команды таймера."""
|
||||||
Парсинг команды установки таймера.
|
|
||||||
Примеры: "таймер на 5 минут", "засеки 10 секунд".
|
|
||||||
"""
|
|
||||||
text = _normalize_timer_text(text.lower())
|
text = _normalize_timer_text(text.lower())
|
||||||
|
|
||||||
# Ключевые слова для таймера
|
# Ключевые слова для таймера
|
||||||
@@ -409,9 +397,6 @@ class TimerManager:
|
|||||||
return "Хорошо, все таймеры отменены."
|
return "Хорошо, все таймеры отменены."
|
||||||
|
|
||||||
# Поиск времени
|
# Поиск времени
|
||||||
# Ищем комбинации: число + (час/мин/сек)
|
|
||||||
# Пример: "1 час 30 минут", "5 минут", "30 секунд"
|
|
||||||
|
|
||||||
total_seconds = 0
|
total_seconds = 0
|
||||||
parts = []
|
parts = []
|
||||||
hours = None
|
hours = None
|
||||||
@@ -434,7 +419,7 @@ class TimerManager:
|
|||||||
if match_seconds:
|
if match_seconds:
|
||||||
seconds = int(match_seconds.group(1))
|
seconds = int(match_seconds.group(1))
|
||||||
|
|
||||||
# Дополняем числительные словами (например, "одну минуту")
|
# Числа словами
|
||||||
word_values = _extract_word_time_values(text)
|
word_values = _extract_word_time_values(text)
|
||||||
if hours is None and word_values["hours"] is not None:
|
if hours is None and word_values["hours"] is not None:
|
||||||
hours = word_values["hours"]
|
hours = word_values["hours"]
|
||||||
@@ -452,9 +437,7 @@ class TimerManager:
|
|||||||
|
|
||||||
found_time = any(value is not None for value in [hours, minutes, seconds])
|
found_time = any(value is not None for value in [hours, minutes, seconds])
|
||||||
if found_time:
|
if found_time:
|
||||||
total_seconds = (
|
total_seconds = (hours or 0) * 3600 + (minutes or 0) * 60 + (seconds or 0)
|
||||||
(hours or 0) * 3600 + (minutes or 0) * 60 + (seconds or 0)
|
|
||||||
)
|
|
||||||
if has_fractional:
|
if has_fractional:
|
||||||
total_seconds = int(round(total_seconds))
|
total_seconds = int(round(total_seconds))
|
||||||
h = total_seconds // 3600
|
h = total_seconds // 3600
|
||||||
@@ -477,8 +460,16 @@ class TimerManager:
|
|||||||
self.add_timer(total_seconds, label)
|
self.add_timer(total_seconds, label)
|
||||||
return f"Поставил таймер на {label}."
|
return f"Поставил таймер на {label}."
|
||||||
|
|
||||||
# Если сказали "таймер", но не нашли время
|
# Если время не названо — спрашиваем
|
||||||
return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'."
|
if re.search(
|
||||||
|
r"(постав|установ|запусти|включи|засеки)", text
|
||||||
|
) or text.strip() in {
|
||||||
|
"таймер",
|
||||||
|
"поставь таймер",
|
||||||
|
}:
|
||||||
|
return ASK_TIMER_TIME_PROMPT
|
||||||
|
|
||||||
|
return "Я не понял, на сколько поставить таймер."
|
||||||
|
|
||||||
|
|
||||||
# Глобальный экземпляр
|
# Глобальный экземпляр
|
||||||
|
|||||||
@@ -3,11 +3,120 @@ Weather feature module.
|
|||||||
Fetches weather data from Open-Meteo API.
|
Fetches weather data from Open-Meteo API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from ..core.config import WEATHER_LAT, WEATHER_LON, WEATHER_CITY
|
from ..core.config import WEATHER_LAT, WEATHER_LON, WEATHER_CITY
|
||||||
|
|
||||||
_HTTP = requests.Session()
|
_HTTP = requests.Session()
|
||||||
|
_CITY_PREFIX_RE = re.compile(
|
||||||
|
r"^(?:в|во)\s+(?:город(?:е|у)?\s+)?",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_CITY_SPACING_RE = re.compile(r"\s+")
|
||||||
|
_KNOWN_CITY_VARIATIONS = {
|
||||||
|
"нью йорк": "Нью-Йорк",
|
||||||
|
"нью-йорк": "Нью-Йорк",
|
||||||
|
"нью йорке": "Нью-Йорк",
|
||||||
|
"нью-йорке": "Нью-Йорк",
|
||||||
|
"нью йорка": "Нью-Йорк",
|
||||||
|
"нью-йорка": "Нью-Йорк",
|
||||||
|
"нью йорком": "Нью-Йорк",
|
||||||
|
"нью-йорком": "Нью-Йорк",
|
||||||
|
"санкт петербург": "Санкт-Петербург",
|
||||||
|
"санкт-петербург": "Санкт-Петербург",
|
||||||
|
"санкт петербурге": "Санкт-Петербург",
|
||||||
|
"санкт-петербурге": "Санкт-Петербург",
|
||||||
|
"санкт петербурга": "Санкт-Петербург",
|
||||||
|
"санкт-петербурга": "Санкт-Петербург",
|
||||||
|
"санкт петербургом": "Санкт-Петербург",
|
||||||
|
"санкт-петербургом": "Санкт-Петербург",
|
||||||
|
"нижний новгород": "Нижний Новгород",
|
||||||
|
"нижнем новгороде": "Нижний Новгород",
|
||||||
|
"нижнего новгорода": "Нижний Новгород",
|
||||||
|
"ростов на дону": "Ростов-на-Дону",
|
||||||
|
"ростове на дону": "Ростов-на-Дону",
|
||||||
|
"ростова на дону": "Ростов-на-Дону",
|
||||||
|
"лос анджелес": "Лос-Анджелес",
|
||||||
|
"лос-анджелес": "Лос-Анджелес",
|
||||||
|
"лос анджелесе": "Лос-Анджелес",
|
||||||
|
"лос-анджелесе": "Лос-Анджелес",
|
||||||
|
"сан франциско": "Сан-Франциско",
|
||||||
|
"сан-франциско": "Сан-Франциско",
|
||||||
|
"улан удэ": "Улан-Удэ",
|
||||||
|
"улан-удэ": "Улан-Удэ",
|
||||||
|
}
|
||||||
|
_SINGLE_WORD_CITY_VARIATIONS = {
|
||||||
|
"москве": "Москва",
|
||||||
|
"москвы": "Москва",
|
||||||
|
"москвой": "Москва",
|
||||||
|
"москву": "Москва",
|
||||||
|
"лондоне": "Лондон",
|
||||||
|
"лондона": "Лондон",
|
||||||
|
"лондоном": "Лондон",
|
||||||
|
"париже": "Париж",
|
||||||
|
"парижа": "Париж",
|
||||||
|
"парижем": "Париж",
|
||||||
|
"берлине": "Берлин",
|
||||||
|
"берлина": "Берлин",
|
||||||
|
"берлином": "Берлин",
|
||||||
|
"пекине": "Пекин",
|
||||||
|
"пекина": "Пекин",
|
||||||
|
"пекином": "Пекин",
|
||||||
|
"роме": "Рим",
|
||||||
|
"рима": "Рим",
|
||||||
|
"римом": "Рим",
|
||||||
|
"мадриде": "Мадрид",
|
||||||
|
"мадрида": "Мадрид",
|
||||||
|
"мадридом": "Мадрид",
|
||||||
|
"сиднее": "Сидней",
|
||||||
|
"сиднея": "Сидней",
|
||||||
|
"сиднеем": "Сидней",
|
||||||
|
"вашингтоне": "Вашингтон",
|
||||||
|
"вашингтона": "Вашингтон",
|
||||||
|
"вашингтоном": "Вашингтон",
|
||||||
|
"сиэтле": "Сиэтл",
|
||||||
|
"сиэтла": "Сиэтл",
|
||||||
|
"сиэтлом": "Сиэтл",
|
||||||
|
"бостоне": "Бостон",
|
||||||
|
"бостона": "Бостон",
|
||||||
|
"бостоном": "Бостон",
|
||||||
|
"денвере": "Денвер",
|
||||||
|
"денвера": "Денвер",
|
||||||
|
"денвером": "Денвер",
|
||||||
|
"хьюстоне": "Хьюстон",
|
||||||
|
"хьюстона": "Хьюстон",
|
||||||
|
"хьюстоном": "Хьюстон",
|
||||||
|
"фениксе": "Феникс",
|
||||||
|
"феникса": "Феникс",
|
||||||
|
"фениксом": "Феникс",
|
||||||
|
"атланте": "Атланта",
|
||||||
|
"атланты": "Атланта",
|
||||||
|
"атлантой": "Атланта",
|
||||||
|
"портленде": "Портленд",
|
||||||
|
"портленда": "Портленд",
|
||||||
|
"портлендом": "Портленд",
|
||||||
|
"остине": "Остин",
|
||||||
|
"остина": "Остин",
|
||||||
|
"остином": "Остин",
|
||||||
|
"нэшвилле": "Нэшвилл",
|
||||||
|
"нэшвилла": "Нэшвилл",
|
||||||
|
"нэшвиллом": "Нэшвилл",
|
||||||
|
"токио": "Токио",
|
||||||
|
"торонто": "Торонто",
|
||||||
|
"чикаго": "Чикаго",
|
||||||
|
"майами": "Майами",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _smart_title_city(text: str) -> str:
|
||||||
|
parts = []
|
||||||
|
for word in text.split():
|
||||||
|
hyphen_parts = [part.capitalize() for part in word.split("-") if part]
|
||||||
|
parts.append("-".join(hyphen_parts))
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def get_wmo_description(code: int) -> str:
|
def get_wmo_description(code: int) -> str:
|
||||||
"""Decodes WMO weather code to Russian description."""
|
"""Decodes WMO weather code to Russian description."""
|
||||||
codes = {
|
codes = {
|
||||||
@@ -72,143 +181,45 @@ def normalize_city_name(city_name: str) -> str:
|
|||||||
Converts city names from various grammatical cases to the base form for geocoding.
|
Converts city names from various grammatical cases to the base form for geocoding.
|
||||||
Handles common Russian grammatical cases (падежи) for city names.
|
Handles common Russian grammatical cases (падежи) for city names.
|
||||||
"""
|
"""
|
||||||
# Convert to lowercase for comparison
|
lowered = str(city_name or "").lower().replace("ё", "е").strip()
|
||||||
lower_city = city_name.lower()
|
if not lowered:
|
||||||
|
return city_name
|
||||||
|
|
||||||
# Remove common Russian location descriptors that might be included by mistake
|
lowered = _CITY_PREFIX_RE.sub("", lowered)
|
||||||
# For example, if someone says "в городе Волгоград", the city_name might be "городе волгоград"
|
lowered = _CITY_SPACING_RE.sub(" ", lowered).strip(" -")
|
||||||
# So we want to extract just "волгоград"
|
if not lowered:
|
||||||
if 'городе' in lower_city:
|
return city_name
|
||||||
# Extract the part after "городе"
|
|
||||||
parts = lower_city.split('городе')
|
|
||||||
if len(parts) > 1:
|
|
||||||
lower_city = parts[1].strip()
|
|
||||||
elif 'город' in lower_city:
|
|
||||||
# Extract the part after "город"
|
|
||||||
parts = lower_city.split('город')
|
|
||||||
if len(parts) > 1:
|
|
||||||
lower_city = parts[1].strip()
|
|
||||||
|
|
||||||
# Common endings for different cases in Russian
|
exact_match = _KNOWN_CITY_VARIATIONS.get(lowered)
|
||||||
# Prepositional case endings (-е, -и, -у, etc.)
|
if exact_match:
|
||||||
prepositional_endings = ['е', 'и', 'у', 'о', 'й']
|
return exact_match
|
||||||
genitive_endings = ['а', 'я', 'ов', 'ев', 'ин', 'ын']
|
|
||||||
instrumental_endings = ['ом', 'ем', 'ой', 'ей']
|
|
||||||
|
|
||||||
# If the city ends with a prepositional ending, try removing it to get the base form
|
single_word_match = _SINGLE_WORD_CITY_VARIATIONS.get(lowered)
|
||||||
if lower_city.endswith(tuple(prepositional_endings)):
|
if single_word_match:
|
||||||
# Try to remove the ending and see if we get a valid base form
|
return single_word_match
|
||||||
base_form = lower_city
|
|
||||||
# Try removing 1-2 characters to get the base form
|
|
||||||
for i in range(2, 0, -1): # Try removing 2 chars, then 1 char
|
|
||||||
if len(base_form) > i:
|
|
||||||
potential_base = base_form[:-i]
|
|
||||||
# Check if the removed part is a common ending
|
|
||||||
if base_form[-i:] in ['ке', 'ме', 'не', 'ве', 'ге', 'де', 'те']:
|
|
||||||
base_form = potential_base
|
|
||||||
break
|
|
||||||
elif base_form[-1] in prepositional_endings:
|
|
||||||
base_form = base_form[:-1]
|
|
||||||
break
|
|
||||||
|
|
||||||
# Special handling for common patterns
|
spaced = lowered.replace("-", " ")
|
||||||
if base_form.endswith('йорке'): # "нью-йорке" -> "нью-йорк"
|
exact_match = _KNOWN_CITY_VARIATIONS.get(spaced)
|
||||||
base_form = base_form[:-1] + 'к'
|
if exact_match:
|
||||||
elif base_form.endswith('ске'): # "москве" -> "москва", "париже" -> "париж"
|
return exact_match
|
||||||
# This is more complex, but for "москве" -> "москва", "париже" -> "париж"
|
|
||||||
# We'll handle the most common cases
|
|
||||||
if base_form == 'москве':
|
|
||||||
base_form = 'москва'
|
|
||||||
elif base_form == 'париже':
|
|
||||||
base_form = 'париж'
|
|
||||||
elif base_form == 'лондоне':
|
|
||||||
base_form = 'лондон'
|
|
||||||
elif base_form == 'берлине':
|
|
||||||
base_form = 'берлин'
|
|
||||||
elif base_form == 'токио': # токио stays токио
|
|
||||||
base_form = 'токио'
|
|
||||||
else:
|
|
||||||
# General rule: replace -е with -а or -ь
|
|
||||||
if base_form.endswith('ске'):
|
|
||||||
base_form = base_form[:-1] + 'а'
|
|
||||||
elif base_form.endswith('ие'):
|
|
||||||
base_form = base_form[:-2] + 'ия'
|
|
||||||
|
|
||||||
# Capitalize appropriately
|
if " " not in spaced:
|
||||||
if base_form != lower_city:
|
for suffix, replacement in (
|
||||||
return base_form.capitalize()
|
("ом", ""),
|
||||||
|
("ем", ""),
|
||||||
|
("ой", "а"),
|
||||||
|
("ей", "а"),
|
||||||
|
("е", ""),
|
||||||
|
("у", "а"),
|
||||||
|
("ю", "я"),
|
||||||
|
):
|
||||||
|
if spaced.endswith(suffix) and len(spaced) > len(suffix) + 2:
|
||||||
|
candidate = spaced[: -len(suffix)] + replacement
|
||||||
|
mapped = _SINGLE_WORD_CITY_VARIATIONS.get(candidate)
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
|
||||||
# Dictionary mapping specific known variations
|
return _smart_title_city(lowered)
|
||||||
case_variations = {
|
|
||||||
"нью-йорке": "Нью-Йорк",
|
|
||||||
"нью-йорка": "Нью-Йорк",
|
|
||||||
"нью-йорком": "Нью-Йорк",
|
|
||||||
"москве": "Москва",
|
|
||||||
"москвы": "Москва",
|
|
||||||
"москвой": "Москва",
|
|
||||||
"москву": "Москва",
|
|
||||||
"лондоне": "Лондон",
|
|
||||||
"лондона": "Лондон",
|
|
||||||
"лондоном": "Лондон",
|
|
||||||
"париже": "Париж",
|
|
||||||
"парижа": "Париж",
|
|
||||||
"парижем": "Париж",
|
|
||||||
"берлине": "Берлин",
|
|
||||||
"берлина": "Берлин",
|
|
||||||
"берлином": "Берлин",
|
|
||||||
"пекине": "Пекин",
|
|
||||||
"пекина": "Пекин",
|
|
||||||
"пекином": "Пекин",
|
|
||||||
"роме": "Рим",
|
|
||||||
"рима": "Рим",
|
|
||||||
"римом": "Рим",
|
|
||||||
"мадриде": "Мадрид",
|
|
||||||
"мадрида": "Мадрид",
|
|
||||||
"мадридом": "Мадрид",
|
|
||||||
"сиднее": "Сидней",
|
|
||||||
"сиднея": "Сидней",
|
|
||||||
"сиднеем": "Сидней",
|
|
||||||
"вашингтоне": "Вашингтон",
|
|
||||||
"вашингтона": "Вашингтон",
|
|
||||||
"вашингтоном": "Вашингтон",
|
|
||||||
"лос-анджелесе": "Лос-Анджелес",
|
|
||||||
"лос-анджелеса": "Лос-Анджелес",
|
|
||||||
"лос-анджелесом": "Лос-Анджелес",
|
|
||||||
"сиэтле": "Сиэтл",
|
|
||||||
"сиэтла": "Сиэтл",
|
|
||||||
"сиэтлом": "Сиэтл",
|
|
||||||
"бостоне": "Бостон",
|
|
||||||
"бостона": "Бостон",
|
|
||||||
"бостоном": "Бостон",
|
|
||||||
"денвере": "Денвер",
|
|
||||||
"денвера": "Денвер",
|
|
||||||
"денвером": "Денвер",
|
|
||||||
"хьюстоне": "Хьюстон",
|
|
||||||
"хьюстона": "Хьюстон",
|
|
||||||
"хьюстоном": "Хьюстон",
|
|
||||||
"фениксе": "Феникс",
|
|
||||||
"феникса": "Феникс",
|
|
||||||
"фениксом": "Феникс",
|
|
||||||
"атланте": "Атланта",
|
|
||||||
"атланты": "Атланта",
|
|
||||||
"атлантой": "Атланта",
|
|
||||||
"портленде": "Портленд",
|
|
||||||
"портленда": "Портленд",
|
|
||||||
"портлендом": "Портленд",
|
|
||||||
"остине": "Остин",
|
|
||||||
"остина": "Остин",
|
|
||||||
"остином": "Остин",
|
|
||||||
"нэшвилле": "Нэшвилл",
|
|
||||||
"нэшвилла": "Нэшвилл",
|
|
||||||
"нэшвиллом": "Нэшвилл",
|
|
||||||
"сан-франциско": "Сан-Франциско",
|
|
||||||
"токио": "Токио",
|
|
||||||
"торонто": "Торонто",
|
|
||||||
"чикаго": "Чикаго",
|
|
||||||
"майами": "Майами",
|
|
||||||
}
|
|
||||||
|
|
||||||
return case_variations.get(lower_city, city_name)
|
|
||||||
|
|
||||||
def get_coordinates_by_city(city_name: str) -> tuple:
|
def get_coordinates_by_city(city_name: str) -> tuple:
|
||||||
"""
|
"""
|
||||||
@@ -220,8 +231,9 @@ def get_coordinates_by_city(city_name: str) -> tuple:
|
|||||||
|
|
||||||
# Add normalized version
|
# Add normalized version
|
||||||
normalized_city = normalize_city_name(city_name)
|
normalized_city = normalize_city_name(city_name)
|
||||||
if normalized_city != city_name:
|
if normalized_city and normalized_city not in try_names:
|
||||||
try_names.append(normalized_city)
|
try_names.append(normalized_city)
|
||||||
|
normalized_lower = str(normalized_city or city_name).lower().replace("ё", "е").strip()
|
||||||
|
|
||||||
# Also try with English version if it's a known translation
|
# Also try with English version if it's a known translation
|
||||||
city_to_eng = {
|
city_to_eng = {
|
||||||
@@ -334,8 +346,18 @@ def get_coordinates_by_city(city_name: str) -> tuple:
|
|||||||
}
|
}
|
||||||
|
|
||||||
eng_name = city_to_eng.get(city_name.lower())
|
eng_name = city_to_eng.get(city_name.lower())
|
||||||
if eng_name:
|
normalized_eng_name = city_to_eng.get(normalized_lower)
|
||||||
|
if eng_name and eng_name not in try_names:
|
||||||
try_names.append(eng_name)
|
try_names.append(eng_name)
|
||||||
|
if normalized_eng_name and normalized_eng_name not in try_names:
|
||||||
|
try_names.append(normalized_eng_name)
|
||||||
|
|
||||||
|
if normalized_city:
|
||||||
|
hyphen_variant = normalized_city.replace(" ", "-")
|
||||||
|
space_variant = normalized_city.replace("-", " ")
|
||||||
|
for variant in (hyphen_variant, space_variant):
|
||||||
|
if variant and variant not in try_names:
|
||||||
|
try_names.append(variant)
|
||||||
|
|
||||||
# Try each name in sequence
|
# Try each name in sequence
|
||||||
for name_to_try in try_names:
|
for name_to_try in try_names:
|
||||||
|
|||||||
1132
app/main.py
1132
app/main.py
File diff suppressed because it is too large
Load Diff
1
assets/models/LICENSE.txt
Executable file
1
assets/models/LICENSE.txt
Executable file
@@ -0,0 +1 @@
|
|||||||
|
A copy of license terms is available at https://picovoice.ai/docs/terms-of-use/
|
||||||
BIN
assets/models/Waltron_en_linux_v4_0_0.ppn
Normal file
BIN
assets/models/Waltron_en_linux_v4_0_0.ppn
Normal file
Binary file not shown.
BIN
assets/sounds/alisa-golosovoj-pomoschnik.mp3
Normal file
BIN
assets/sounds/alisa-golosovoj-pomoschnik.mp3
Normal file
Binary file not shown.
@@ -39,5 +39,53 @@
|
|||||||
"days": [
|
"days": [
|
||||||
1
|
1
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hour": 8,
|
||||||
|
"minute": 0,
|
||||||
|
"active": false,
|
||||||
|
"days": [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"last_triggered": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hour": 7,
|
||||||
|
"minute": 0,
|
||||||
|
"active": true,
|
||||||
|
"days": [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"last_triggered": "2026-04-07T07:00:00.445214"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hour": 7,
|
||||||
|
"minute": 0,
|
||||||
|
"active": false,
|
||||||
|
"days": [
|
||||||
|
5
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hour": 9,
|
||||||
|
"minute": 30,
|
||||||
|
"active": false,
|
||||||
|
"days": null,
|
||||||
|
"last_triggered": "2026-04-04T09:30:00.423048"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hour": 17,
|
||||||
|
"minute": 30,
|
||||||
|
"active": false,
|
||||||
|
"days": null,
|
||||||
|
"last_triggered": "2026-04-04T17:30:00.113480"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
9
data/stopwatches.json
Normal file
9
data/stopwatches.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "",
|
||||||
|
"elapsed": 92.426419,
|
||||||
|
"running": false,
|
||||||
|
"started_at": null
|
||||||
|
}
|
||||||
|
]
|
||||||
29
scripts/qwen-check.sh
Executable file
29
scripts/qwen-check.sh
Executable 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
22
scripts/qwen-context.sh
Executable 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"
|
||||||
Reference in New Issue
Block a user