Update assistant features and docs
This commit is contained in:
@@ -2,6 +2,7 @@ PERPLEXITY_API_KEY=your_perplexity_api_key_here
|
|||||||
PERPLEXITY_MODEL=llama-3.1-sonar-small-128k-chat
|
PERPLEXITY_MODEL=llama-3.1-sonar-small-128k-chat
|
||||||
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
|
||||||
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
|
||||||
|
|||||||
10
Makefile
Normal file
10
Makefile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.PHONY: run check qwen-context
|
||||||
|
|
||||||
|
run:
|
||||||
|
python run.py
|
||||||
|
|
||||||
|
check:
|
||||||
|
./scripts/qwen-check.sh
|
||||||
|
|
||||||
|
qwen-context:
|
||||||
|
./scripts/qwen-context.sh
|
||||||
32
QWEN.md
Normal file
32
QWEN.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Qwen Context: alexander_smart-speaker
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Voice assistant for Linux with wake word, STT/TTS, AI dialogue, weather, timer/alarm/stopwatch and volume control.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- Entry: `run.py` -> `app/main.py`
|
||||||
|
- Audio layer: `app/audio/` (`wakeword.py`, `stt.py`, `tts.py`, `sound_level.py`)
|
||||||
|
- Core logic: `app/core/` (`commands.py`, `ai.py`, `config.py`, `cleaner.py`)
|
||||||
|
- Features: `app/features/` (weather, timer, stopwatch, alarm, music, cities game)
|
||||||
|
- State: `data/*.json`
|
||||||
|
|
||||||
|
## High-Value Files
|
||||||
|
- `app/core/commands.py` for intent routing
|
||||||
|
- `app/main.py` for event loop and orchestration
|
||||||
|
- `app/core/config.py` for env configuration
|
||||||
|
|
||||||
|
## How To Work In This Repo
|
||||||
|
1. Keep edits minimal and local.
|
||||||
|
2. Prefer fixes with clear fallback behavior (microphone/API failures).
|
||||||
|
3. Do not hardcode secrets; use `.env` and `.env.example`.
|
||||||
|
4. Update README when behavior/commands change.
|
||||||
|
|
||||||
|
## Quick Checks
|
||||||
|
```bash
|
||||||
|
./scripts/qwen-check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes For Agent
|
||||||
|
- If touching audio code, keep Linux compatibility first.
|
||||||
|
- For command parsing, add/adjust tests when test infra exists.
|
||||||
|
- Preserve Russian command phrases compatibility.
|
||||||
198
README.md
198
README.md
@@ -1,146 +1,122 @@
|
|||||||
# 🎙️ Alexander Smart Speaker
|
# Alexander Smart Speaker
|
||||||
|
|
||||||
<div align="center">
|
Голосовой ассистент для Linux с wake word, STT/TTS и набором голосовых навыков.
|
||||||
|
|
||||||

|
## Что умеет
|
||||||

|
- Активация по ключевому слову `Alexandr`.
|
||||||

|
- Диалог с AI (Perplexity) с сохранением контекста.
|
||||||
|
- Перевод RU ↔ EN с озвучиванием.
|
||||||
|
- Погода по умолчанию и по названию города.
|
||||||
|
- Будильники, таймеры и секундомеры.
|
||||||
|
- Управление громкостью системы.
|
||||||
|
- Управление Spotify (play/pause/next/current track).
|
||||||
|
- Игра в города.
|
||||||
|
|
||||||
**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.
|
## Технологии
|
||||||
|
- Wake word: `pvporcupine`
|
||||||
|
- STT: `deepgram-sdk`
|
||||||
|
- TTS: `Silero` (`torch`, `torchaudio`)
|
||||||
|
- AI: Perplexity API
|
||||||
|
- Погода: Open-Meteo
|
||||||
|
- Музыка: Spotify Web API (`spotipy`)
|
||||||
|
|
||||||
[Features](#-features) • [Installation](#-installation) • [Usage](#-usage) • [Architecture](#-architecture)
|
## Требования
|
||||||
|
- Linux
|
||||||
|
- Python 3.9+
|
||||||
|
- Системные пакеты:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y portaudio19-dev libasound2-dev mpg123
|
||||||
|
```
|
||||||
|
|
||||||
|
Для управления громкостью нужен `pactl` или `amixer` (обычно из `pulseaudio-utils`/`alsa-utils`).
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Features
|
|
||||||
|
|
||||||
### 🧠 Artificial Intelligence
|
|
||||||
* **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
|
|
||||||
* **⛅ Weather**: Detailed forecasts (current, daily range, hourly) via Open-Meteo.
|
|
||||||
* **⏰ Alarm & Timer**: Voice-controlled alarms and timers.
|
|
||||||
* **🔊 System Control**: Adjust system volume via voice commands.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Installation
|
|
||||||
|
|
||||||
### 1. Prerequisites
|
|
||||||
* **OS**: Linux
|
|
||||||
* **Python**: 3.9+
|
|
||||||
* **System Libraries**:
|
|
||||||
```bash
|
|
||||||
sudo apt-get install portaudio19-dev libasound2-dev mpg123
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Setup
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
|
||||||
git clone https://github.com/your-username/alexander_smart-speaker.git
|
git clone https://github.com/your-username/alexander_smart-speaker.git
|
||||||
cd alexander_smart-speaker
|
cd alexander_smart-speaker
|
||||||
|
|
||||||
# Create virtual environment
|
|
||||||
python -m venv venv
|
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
|
## Настройка `.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
|
PERPLEXITY_API_KEY=...
|
||||||
PERPLEXITY_API_KEY=pplx-...
|
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Полный пример (как в `.env.example`):
|
||||||
|
|
||||||
## 🚀 Usage
|
```ini
|
||||||
|
PERPLEXITY_API_KEY=your_perplexity_api_key_here
|
||||||
|
PERPLEXITY_MODEL=llama-3.1-sonar-small-128k-chat
|
||||||
|
DEEPGRAM_API_KEY=your_deepgram_api_key_here
|
||||||
|
PORCUPINE_ACCESS_KEY=your_porcupine_access_key_here
|
||||||
|
PORCUPINE_SENSITIVITY=0.8
|
||||||
|
TTS_EN_SPEAKER=en_0
|
||||||
|
WEATHER_LAT=63.56
|
||||||
|
WEATHER_LON=53.69
|
||||||
|
WEATHER_CITY=Ухта
|
||||||
|
SPOTIFY_CLIENT_ID=your_spotify_client_id
|
||||||
|
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
|
||||||
|
SPOTIFY_REDIRECT_URI=http://localhost:8888/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
Start the assistant:
|
|
||||||
```bash
|
```bash
|
||||||
|
make run
|
||||||
|
# или
|
||||||
python run.py
|
python run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Command Examples
|
## Примеры голосовых команд
|
||||||
|
- Активация: `Alexandr`
|
||||||
|
- Диалог: `Почему небо голубое?`
|
||||||
|
- Погода: `Какая сейчас погода?`, `Погода в Москве`
|
||||||
|
- Перевод: `Переведи на английский: как дела`
|
||||||
|
- Таймер: `Поставь таймер на 5 минут`
|
||||||
|
- Будильник: `Поставь будильник на 7:30`, `Будильник по будням в 8:00`
|
||||||
|
- Секундомер: `Запусти секундомер`, `Покажи активные секундомеры`
|
||||||
|
- Громкость: `Громкость 5`
|
||||||
|
- Spotify: `Включи музыку`, `Пауза`, `Что сейчас играет`
|
||||||
|
- Игра: `Давай сыграем в города`
|
||||||
|
- Остановка/прерывание: `Стоп`, `Хватит`, `Повтори`
|
||||||
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
make run # запуск ассистента
|
||||||
## 🏗️ Architecture
|
make check # базовая проверка проекта
|
||||||
|
make qwen-context # собрать контекст проекта
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
Mic[🎤 Microphone] --> Wake[Wake Word<br/>Porcupine]
|
|
||||||
Wake -->|Activated| STT[STT<br/>Deepgram]
|
|
||||||
|
|
||||||
STT --> Router{Command Router}
|
|
||||||
|
|
||||||
Router -->|Forecast| Weather[⛅ Weather<br/>Open-Meteo]
|
|
||||||
Router -->|Time| Alarm[⏰ Alarm/Timer]
|
|
||||||
Router -->|Settings| Vol[🔊 Volume]
|
|
||||||
Router -->|Translate| Translator[A↔B Translator]
|
|
||||||
Router -->|Query| AI[🧠 Perplexity AI]
|
|
||||||
|
|
||||||
Weather --> TTS
|
|
||||||
Alarm --> TTS
|
|
||||||
Vol --> TTS
|
|
||||||
Translator --> TTS
|
|
||||||
AI --> Cleaner[Text Cleaner]
|
|
||||||
Cleaner --> TTS[🗣️ TTS<br/>Silero]
|
|
||||||
|
|
||||||
TTS --> Speaker[🔊 Speaker]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📂 Project Structure
|
## Структура проекта
|
||||||
* `app/main.py` — Entry point, main event loop.
|
- `run.py` - точка входа.
|
||||||
* `app/audio/` — Audio processing modules (STT, TTS, Wake Word).
|
- `app/main.py` - главный цикл ассистента.
|
||||||
* `app/core/` — AI logic, configuration, text cleaning.
|
- `app/audio/` - wake word, STT, TTS, громкость.
|
||||||
* `app/features/` — Skills (Weather, Alarm, Timer).
|
- `app/core/` - конфиг, AI, роутинг команд, утилиты.
|
||||||
* `assets/` — Models (Porcupine) and sound effects.
|
- `app/features/` - погода, будильник, таймер, секундомер, музыка, города.
|
||||||
* `data/` — Persistent state (alarms).
|
- `assets/` - модели и звуки.
|
||||||
|
- `data/` - сохраненные будильники/таймеры/секундомеры.
|
||||||
|
|
||||||
---
|
## Диагностика проблем
|
||||||
|
- Ошибки STT/AI: проверьте ключи в `.env`.
|
||||||
|
- Нет звука: проверьте системное устройство вывода и утилиты `pactl`/`amixer`.
|
||||||
|
- Не играет будильник/таймер: убедитесь, что установлен `mpg123`.
|
||||||
|
- Spotify не управляется: проверьте `SPOTIFY_*`, авторизацию и наличие активного устройства.
|
||||||
|
|
||||||
## 🛠️ Troubleshooting
|
## Лицензия
|
||||||
* **Deepgram Error 400**: Check your API key balance and validity in `.env`.
|
MIT, см. `LICENSE.txt`.
|
||||||
* **No Sound**: Ensure `amixer` is installed and the default audio output is correctly configured in your OS.
|
|
||||||
* **Alarm not playing**: Verify that `mpg123` is installed (`sudo apt install mpg123`).
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
MIT License. See `LICENSE.txt` for details.
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
|
|
||||||
# Карта для перевода слов в цифры ("пять" -> 5)
|
# Карта для перевода слов в цифры ("пять" -> 5)
|
||||||
NUMBER_MAP = {
|
NUMBER_MAP = {
|
||||||
@@ -148,7 +149,7 @@ def parse_volume_text(text: str) -> int | None:
|
|||||||
Пытается найти число громкости в тексте.
|
Пытается найти число громкости в тексте.
|
||||||
Понимает и цифры ("5"), и слова ("пять").
|
Понимает и цифры ("5"), и слова ("пять").
|
||||||
"""
|
"""
|
||||||
text = text.lower()
|
text = replace_roman_numerals(text.lower())
|
||||||
|
|
||||||
# 1. Ищем цифры (1-10)
|
# 1. Ищем цифры (1-10)
|
||||||
num_match = re.search(r"\b(10|[1-9])\b", text)
|
num_match = re.search(r"\b(10|[1-9])\b", text)
|
||||||
|
|||||||
197
app/audio/stt.py
197
app/audio/stt.py
@@ -8,6 +8,7 @@ Supports Russian (default) and English.
|
|||||||
# Использует Deepgram API через веб-сокеты для потокового распознавания в реальном времени.
|
# Использует Deepgram API через веб-сокеты для потокового распознавания в реальном времени.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import pyaudio
|
import pyaudio
|
||||||
import logging
|
import logging
|
||||||
@@ -24,16 +25,19 @@ import websockets.sync.client
|
|||||||
from ..core.audio_manager import get_audio_manager
|
from ..core.audio_manager import get_audio_manager
|
||||||
|
|
||||||
# --- Патч (исправление) для библиотеки websockets ---
|
# --- Патч (исправление) для библиотеки websockets ---
|
||||||
# По умолчанию Deepgram SDK использует слишком короткий таймаут подключения.
|
# Явно задаём таймауты подключения, чтобы не зависать на долгом handshake.
|
||||||
# Это часто вызывает ошибки при медленном SSL рукопожатии.
|
|
||||||
# Мы подменяем функцию connect, чтобы увеличить таймаут до 30 секунд.
|
|
||||||
_original_connect = websockets.sync.client.connect
|
_original_connect = websockets.sync.client.connect
|
||||||
|
|
||||||
|
DEEPGRAM_CONNECT_TIMEOUT_SECONDS = 3.0
|
||||||
|
DEEPGRAM_CONNECT_WAIT_SECONDS = 1.5
|
||||||
|
DEEPGRAM_CONNECT_POLL_SECONDS = 0.001
|
||||||
|
|
||||||
|
|
||||||
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 +48,34 @@ 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 = 3.0
|
||||||
|
# Длинный защитный предел, чтобы не обрывать обычную длинную фразу.
|
||||||
|
# Фактическое завершение происходит по 3 сек тишины после речи.
|
||||||
|
MAX_ACTIVE_SPEECH_SECONDS = 300.0
|
||||||
|
|
||||||
|
_FAST_STOP_UTTERANCE_RE = re.compile(
|
||||||
|
r"^(?:(?:александр|алесандр|alexander|alexandr)\s+)?"
|
||||||
|
r"(?:стоп|хватит|перестань|прекрати|замолчи|тихо|пауза)"
|
||||||
|
r"(?:\s+(?:пожалуйста|please))?$",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_command_text(text: str) -> str:
|
||||||
|
normalized = text.lower().replace("ё", "е")
|
||||||
|
normalized = re.sub(r"[^\w\s]+", " ", normalized, flags=re.UNICODE)
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized, flags=re.UNICODE).strip()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _is_fast_stop_utterance(text: str) -> bool:
|
||||||
|
normalized = _normalize_command_text(text)
|
||||||
|
if not normalized:
|
||||||
|
return False
|
||||||
|
return _FAST_STOP_UTTERANCE_RE.fullmatch(normalized) is not None
|
||||||
|
|
||||||
|
|
||||||
class SpeechRecognizer:
|
class SpeechRecognizer:
|
||||||
"""Класс распознавания речи через Deepgram."""
|
"""Класс распознавания речи через Deepgram."""
|
||||||
@@ -105,24 +137,42 @@ class SpeechRecognizer:
|
|||||||
)
|
)
|
||||||
return self.stream
|
return self.stream
|
||||||
|
|
||||||
async def _process_audio(self, dg_connection, timeout_seconds, detection_timeout):
|
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 после 1с тишины.
|
||||||
"""
|
"""
|
||||||
self.transcript = ""
|
self.transcript = ""
|
||||||
transcript_parts = []
|
transcript_parts = []
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
stream = self._get_stream()
|
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)
|
||||||
|
last_speech_activity = time.monotonic()
|
||||||
|
first_speech_activity_at = 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()
|
||||||
|
|
||||||
# --- Обработчики событий Deepgram ---
|
# --- Обработчики событий Deepgram ---
|
||||||
def on_transcript(unused_self, result, **kwargs):
|
def on_transcript(unused_self, result, **kwargs):
|
||||||
@@ -130,6 +180,20 @@ class SpeechRecognizer:
|
|||||||
sentence = result.channel.alternatives[0].transcript
|
sentence = result.channel.alternatives[0].transcript
|
||||||
if len(sentence) == 0:
|
if len(sentence) == 0:
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
|
loop.call_soon_threadsafe(mark_speech_activity)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if fast_stop:
|
||||||
|
if _is_fast_stop_utterance(sentence):
|
||||||
|
self.transcript = sentence.strip()
|
||||||
|
try:
|
||||||
|
loop.call_soon_threadsafe(stop_event.set)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
if result.is_final:
|
if result.is_final:
|
||||||
# Собираем только финальные (подтвержденные) фразы
|
# Собираем только финальные (подтвержденные) фразы
|
||||||
transcript_parts.append(sentence)
|
transcript_parts.append(sentence)
|
||||||
@@ -138,18 +202,16 @@ class SpeechRecognizer:
|
|||||||
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):
|
||||||
print(f"Deepgram Error: {error}")
|
print(f"Deepgram Error: {error}")
|
||||||
@@ -174,10 +236,10 @@ class SpeechRecognizer:
|
|||||||
channels=1,
|
channels=1,
|
||||||
sample_rate=SAMPLE_RATE,
|
sample_rate=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),
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Задача отправки аудио с буферизацией ---
|
# --- Задача отправки аудио с буферизацией ---
|
||||||
@@ -198,24 +260,29 @@ class SpeechRecognizer:
|
|||||||
None, lambda: dg_connection.start(options)
|
None, lambda: dg_connection.start(options)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Пока подключаемся, копим данные
|
# Пока подключаемся, копим данные.
|
||||||
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_future.done()
|
||||||
|
and time.monotonic() < connect_deadline
|
||||||
|
):
|
||||||
if stream.is_active():
|
if stream.is_active():
|
||||||
data = stream.read(4096, exception_on_overflow=False)
|
data = stream.read(4096, exception_on_overflow=False)
|
||||||
audio_buffer.append(data)
|
audio_buffer.append(data)
|
||||||
await asyncio.sleep(0.0005) # Уменьшаем задержку для более быстрой обработки
|
await asyncio.sleep(DEEPGRAM_CONNECT_POLL_SECONDS)
|
||||||
timeout_count += 1
|
|
||||||
|
|
||||||
if timeout_count >= max_timeout:
|
if not connect_future.done():
|
||||||
print("⏰ Timeout connecting to Deepgram")
|
print(
|
||||||
|
f"⏰ Timeout connecting to Deepgram ({DEEPGRAM_CONNECT_WAIT_SECONDS:.1f}s)"
|
||||||
|
)
|
||||||
|
stop_event.set()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Проверяем результат подключения
|
# Проверяем результат подключения
|
||||||
if connect_future.result() is False:
|
if connect_future.result() is False:
|
||||||
print("Failed to start Deepgram connection")
|
print("Failed to start Deepgram connection")
|
||||||
|
stop_event.set()
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"🚀 Connected! Sending buffer ({len(audio_buffer)} chunks)...")
|
print(f"🚀 Connected! Sending buffer ({len(audio_buffer)} chunks)...")
|
||||||
@@ -227,11 +294,8 @@ class SpeechRecognizer:
|
|||||||
|
|
||||||
audio_buffer = None # Освобождаем память
|
audio_buffer = None # Освобождаем память
|
||||||
|
|
||||||
# 4. Продолжаем стримить в реальном времени
|
# 4. Продолжаем стримить в реальном времени до события остановки.
|
||||||
stream_timeout = 0
|
while not stop_event.is_set():
|
||||||
max_stream_timeout = int(timeout_seconds / 0.002) # Примерный таймаут в зависимости от timeout_seconds
|
|
||||||
|
|
||||||
while not stop_event.is_set() and stream_timeout < max_stream_timeout:
|
|
||||||
if stream.is_active():
|
if stream.is_active():
|
||||||
data = stream.read(4096, exception_on_overflow=False)
|
data = stream.read(4096, exception_on_overflow=False)
|
||||||
dg_connection.send(data)
|
dg_connection.send(data)
|
||||||
@@ -239,7 +303,6 @@ class SpeechRecognizer:
|
|||||||
if chunks_sent % 50 == 0:
|
if chunks_sent % 50 == 0:
|
||||||
print(".", end="", flush=True)
|
print(".", end="", flush=True)
|
||||||
await asyncio.sleep(0.002) # Уменьшаем задержку для более быстрого реагирования
|
await asyncio.sleep(0.002) # Уменьшаем задержку для более быстрого реагирования
|
||||||
stream_timeout += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Audio send error: {e}")
|
print(f"Audio send error: {e}")
|
||||||
@@ -255,19 +318,60 @@ class SpeechRecognizer:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Ждем начала речи (если задан detection_timeout)
|
# 1. Ждем начала речи (если задан detection_timeout)
|
||||||
if detection_timeout:
|
if (
|
||||||
|
effective_detection_timeout
|
||||||
|
and effective_detection_timeout > 0
|
||||||
|
and not stop_event.is_set()
|
||||||
|
):
|
||||||
|
speech_wait_task = asyncio.create_task(speech_started_event.wait())
|
||||||
|
stop_wait_task = asyncio.create_task(stop_event.wait())
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(
|
done, pending = await asyncio.wait(
|
||||||
speech_started_event.wait(), timeout=detection_timeout
|
{speech_wait_task, stop_wait_task},
|
||||||
|
timeout=effective_detection_timeout,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
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 никто не начал говорить, выходим
|
# Если за detection_timeout никто не начал говорить, выходим
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
|
|
||||||
# 2. Если речь началась (или таймаута нет), ждем завершения (stop_event)
|
# 2. После старта речи завершаем только по тишине POST_SPEECH_SILENCE_TIMEOUT_SECONDS.
|
||||||
# stop_event сработает либо по UtteranceEnd (пауза), либо по общему таймауту
|
# Добавляем длинный защитный лимит, чтобы сессия не зависла навсегда.
|
||||||
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
|
||||||
|
):
|
||||||
|
stop_event.set()
|
||||||
|
break
|
||||||
|
|
||||||
|
if (
|
||||||
|
first_speech_activity_at is not None
|
||||||
|
and now - first_speech_activity_at
|
||||||
|
>= max_active_speech_seconds
|
||||||
|
):
|
||||||
|
print("⏱️ Достигнут защитный лимит активного прослушивания.")
|
||||||
|
stop_event.set()
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass # Общий таймаут вышел
|
pass # Общий таймаут вышел
|
||||||
@@ -291,16 +395,18 @@ class SpeechRecognizer:
|
|||||||
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: Быстрое завершение для коротких stop-команд.
|
||||||
"""
|
"""
|
||||||
if not self.dg_client:
|
if not self.dg_client:
|
||||||
self.initialize()
|
self.initialize()
|
||||||
@@ -323,7 +429,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 ""
|
||||||
@@ -389,10 +495,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():
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ Listens for the "Alexandr" wake word.
|
|||||||
import pvporcupine
|
import pvporcupine
|
||||||
import pyaudio
|
import pyaudio
|
||||||
import struct
|
import struct
|
||||||
from ..core.config import PORCUPINE_ACCESS_KEY, PORCUPINE_KEYWORD_PATH
|
from ..core.config import (
|
||||||
|
PORCUPINE_ACCESS_KEY,
|
||||||
|
PORCUPINE_KEYWORD_PATH,
|
||||||
|
PORCUPINE_SENSITIVITY,
|
||||||
|
)
|
||||||
from ..core.audio_manager import get_audio_manager
|
from ..core.audio_manager import get_audio_manager
|
||||||
|
|
||||||
|
|
||||||
@@ -27,13 +31,15 @@ class WakeWordDetector:
|
|||||||
"""Инициализация 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.pa = get_audio_manager().get_pyaudio()
|
||||||
self._open_stream()
|
self._open_stream()
|
||||||
print("🎤 Ожидание wake word 'Alexandr'...")
|
print(f"🎤 Ожидание wake word 'Alexandr' (sens={PORCUPINE_SENSITIVITY:.2f})...")
|
||||||
|
|
||||||
def _open_stream(self):
|
def _open_stream(self):
|
||||||
"""Открытие аудиопотока с микрофона."""
|
"""Открытие аудиопотока с микрофона."""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Handles complex number-to-text conversion for Russian language.
|
|||||||
import re
|
import re
|
||||||
import pymorphy3
|
import pymorphy3
|
||||||
from num2words import num2words
|
from num2words import num2words
|
||||||
|
from .roman import roman_to_int
|
||||||
|
|
||||||
# Инициализация морфологического анализатора (для определения падежей)
|
# Инициализация морфологического анализатора (для определения падежей)
|
||||||
morph = pymorphy3.MorphAnalyzer()
|
morph = pymorphy3.MorphAnalyzer()
|
||||||
@@ -334,6 +335,50 @@ def numbers_to_words(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def roman_numerals_to_words(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Преобразует римские цифры в порядковые числительные с учетом
|
||||||
|
морфологии предыдущего слова.
|
||||||
|
Пример: "Ивана III" -> "Ивана третьего".
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
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:
|
def clean_response(text: str, language: str = "ru") -> str:
|
||||||
"""
|
"""
|
||||||
Основная функция очистки.
|
Основная функция очистки.
|
||||||
@@ -408,8 +453,10 @@ 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
|
# Convert Roman numerals and Arabic digits to words for Russian.
|
||||||
if language == "ru" and re.search(r"\d", 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
|
# Remove extra whitespace
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY")
|
|||||||
PORCUPINE_ACCESS_KEY = os.getenv("PORCUPINE_ACCESS_KEY")
|
PORCUPINE_ACCESS_KEY = os.getenv("PORCUPINE_ACCESS_KEY")
|
||||||
# Путь к файлу модели ключевого слова (.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" / "Alexandr_en_linux_v4_0_0.ppn"
|
||||||
|
# Чувствительность wake word (0..1). Выше = ловит легче, но больше ложных срабатываний.
|
||||||
|
PORCUPINE_SENSITIVITY = float(os.getenv("PORCUPINE_SENSITIVITY", "0.8"))
|
||||||
|
|
||||||
# --- Параметры аудио ---
|
# --- Параметры аудио ---
|
||||||
# Частота дискретизации для микрофона (стандарт для распознавания речи)
|
# Частота дискретизации для микрофона (стандарт для распознавания речи)
|
||||||
|
|||||||
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)
|
||||||
@@ -10,11 +10,13 @@ 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 = "На какое время мне поставить будильник?"
|
||||||
|
|
||||||
|
|
||||||
class AlarmClock:
|
class AlarmClock:
|
||||||
@@ -229,7 +231,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}'")
|
||||||
@@ -251,7 +253,7 @@ class AlarmClock:
|
|||||||
Парсинг команды установки будильника из текста.
|
Парсинг команды установки будильника из текста.
|
||||||
Примеры: "разбуди в 7:30", "будильник на 8 утра".
|
Примеры: "разбуди в 7:30", "будильник на 8 утра".
|
||||||
"""
|
"""
|
||||||
text = text.lower()
|
text = replace_roman_numerals(text.lower())
|
||||||
if "будильник" not in text and "разбуди" not in text:
|
if "будильник" not in text and "разбуди" not in text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -299,6 +301,12 @@ class AlarmClock:
|
|||||||
suffix = f" {days_phrase}" if days_phrase else ""
|
suffix = f" {days_phrase}" if days_phrase else ""
|
||||||
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}."
|
||||||
|
|
||||||
|
if re.search(r"(постав|установ|запусти|включи|разбуди)", text) or text.strip() in {
|
||||||
|
"будильник",
|
||||||
|
"поставь будильник",
|
||||||
|
}:
|
||||||
|
return ASK_ALARM_TIME_PROMPT
|
||||||
|
|
||||||
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
|
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Spotify Music Controller
|
|||||||
- "следующий трек" / "next" - следующий трек
|
- "следующий трек" / "next" - следующий трек
|
||||||
- "предыдущий трек" / "previous" - предыдущий трек
|
- "предыдущий трек" / "previous" - предыдущий трек
|
||||||
- "что играет" / "какая песня" - информация о текущем треке
|
- "что играет" / "какая песня" - информация о текущем треке
|
||||||
|
- "угадай песню" / "распознай музыку" - распознавание текущего трека
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -287,6 +288,16 @@ class SpotifyMusicController:
|
|||||||
if re.search(pattern, text_lower) and ("трек" in text_lower or "песн" in text_lower or "previous" in text_lower or "back" in text_lower):
|
if re.search(pattern, text_lower) and ("трек" in text_lower or "песн" in text_lower or "previous" in text_lower or "back" in text_lower):
|
||||||
return self.previous_track()
|
return self.previous_track()
|
||||||
|
|
||||||
|
# Явные команды распознавания музыки (типа "угадай песню")
|
||||||
|
recognize_patterns = [
|
||||||
|
r"((александр|александра|алесандр|alexander)\s+)?(угадай|распознай|определи)\s+(мелод|музык|песн|трек)",
|
||||||
|
r"((александр|александра|алесандр|alexander)\s+)?(что за|какая это)\s+(музык|песн|трек)",
|
||||||
|
r"(identify|recognize)\s+(song|music|track)",
|
||||||
|
]
|
||||||
|
for pattern in recognize_patterns:
|
||||||
|
if re.search(pattern, text_lower):
|
||||||
|
return self.get_current_track()
|
||||||
|
|
||||||
# Что играет
|
# Что играет
|
||||||
current_patterns = [
|
current_patterns = [
|
||||||
r"(что (сейчас )?играет|как(ая|ой) (песня|трек)|что за (песня|трек|музыка))",
|
r"(что (сейчас )?играет|как(ая|ой) (песня|трек)|что за (песня|трек|музыка))",
|
||||||
|
|||||||
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
|
||||||
@@ -10,6 +10,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,6 +23,7 @@ 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 words parsing helpers (ru) ---
|
||||||
_NUMBER_UNITS = {
|
_NUMBER_UNITS = {
|
||||||
@@ -162,11 +164,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):
|
||||||
@@ -371,7 +375,7 @@ class TimerManager:
|
|||||||
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}'")
|
||||||
@@ -477,7 +481,14 @@ class TimerManager:
|
|||||||
self.add_timer(total_seconds, label)
|
self.add_timer(total_seconds, label)
|
||||||
return f"Поставил таймер на {label}."
|
return f"Поставил таймер на {label}."
|
||||||
|
|
||||||
# Если сказали "таймер", но не нашли время
|
# Если попросили поставить таймер, но не назвали время — задаем уточняющий вопрос.
|
||||||
|
if re.search(r"(постав|установ|запусти|включи|засеки)", text) or text.strip() in {
|
||||||
|
"таймер",
|
||||||
|
"поставь таймер",
|
||||||
|
}:
|
||||||
|
return ASK_TIMER_TIME_PROMPT
|
||||||
|
|
||||||
|
# Если сказали "таймер", но не нашли время.
|
||||||
return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'."
|
return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'."
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
71
app/main.py
71
app/main.py
@@ -53,8 +53,9 @@ from .core.config import BASE_DIR
|
|||||||
from .core.cleaner import clean_response
|
from .core.cleaner import clean_response
|
||||||
from .core.commands import is_stop_command
|
from .core.commands import is_stop_command
|
||||||
from .core.smalltalk import get_smalltalk_response
|
from .core.smalltalk import get_smalltalk_response
|
||||||
from .features.alarm import get_alarm_clock
|
from .features.alarm import ASK_ALARM_TIME_PROMPT, get_alarm_clock
|
||||||
from .features.timer import get_timer_manager
|
from .features.stopwatch import get_stopwatch_manager
|
||||||
|
from .features.timer import ASK_TIMER_TIME_PROMPT, get_timer_manager
|
||||||
from .features.weather import get_weather_report
|
from .features.weather import get_weather_report
|
||||||
from .features.music import get_music_controller
|
from .features.music import get_music_controller
|
||||||
from .features.cities_game import get_cities_game
|
from .features.cities_game import get_cities_game
|
||||||
@@ -256,6 +257,7 @@ def main():
|
|||||||
get_recognizer().initialize() # Подключение к Deepgram
|
get_recognizer().initialize() # Подключение к Deepgram
|
||||||
init_tts() # Загрузка нейросети для синтеза речи (Silero)
|
init_tts() # Загрузка нейросети для синтеза речи (Silero)
|
||||||
alarm_clock = get_alarm_clock() # Загрузка будильников
|
alarm_clock = get_alarm_clock() # Загрузка будильников
|
||||||
|
stopwatch_manager = get_stopwatch_manager() # Загрузка секундомеров
|
||||||
timer_manager = get_timer_manager() # Загрузка таймеров
|
timer_manager = get_timer_manager() # Загрузка таймеров
|
||||||
cities_game = get_cities_game() # Игра "Города"
|
cities_game = get_cities_game() # Игра "Города"
|
||||||
print()
|
print()
|
||||||
@@ -270,6 +272,10 @@ def main():
|
|||||||
# (True = режим диалога, слушаем сразу. False = ждем "Alexandr")
|
# (True = режим диалога, слушаем сразу. False = ждем "Alexandr")
|
||||||
skip_wakeword = False
|
skip_wakeword = False
|
||||||
|
|
||||||
|
# Контекст уточнения "на какое время поставить ...".
|
||||||
|
# Может быть: "timer", "alarm".
|
||||||
|
pending_time_target = None
|
||||||
|
|
||||||
# Переменная для отслеживания последней проверки здоровья STT
|
# Переменная для отслеживания последней проверки здоровья STT
|
||||||
last_stt_check = time.time()
|
last_stt_check = time.time()
|
||||||
|
|
||||||
@@ -314,9 +320,10 @@ def main():
|
|||||||
if ding_sound:
|
if ding_sound:
|
||||||
ding_sound.play()
|
ding_sound.play()
|
||||||
|
|
||||||
# Фраза услышана! Слушаем команду пользователя (5 секунд тишины макс)
|
# Фраза активации услышана:
|
||||||
|
# до 5с ждём начало речи, после начала завершаем STT по 3с тишины.
|
||||||
try:
|
try:
|
||||||
user_text = listen(timeout_seconds=5.0)
|
user_text = listen(timeout_seconds=5.0, fast_stop=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ошибка при прослушивании: {e}")
|
print(f"Ошибка при прослушивании: {e}")
|
||||||
print("Переинициализация STT...")
|
print("Переинициализация STT...")
|
||||||
@@ -328,10 +335,12 @@ def main():
|
|||||||
continue # Продолжаем цикл
|
continue # Продолжаем цикл
|
||||||
else:
|
else:
|
||||||
# Режим диалога (Follow-up): ждем продолжения речи без "Alexandr"
|
# Режим диалога (Follow-up): ждем продолжения речи без "Alexandr"
|
||||||
print("👂 Слушаю продолжение диалога (3 сек)...")
|
print("👂 Слушаю продолжение диалога (5 сек)...")
|
||||||
# Ждем начала речи 3 сек. Если начали говорить, слушаем до 7 сек.
|
# Ждем начала речи 5 сек. Если начали говорить, слушаем до 7 сек.
|
||||||
try:
|
try:
|
||||||
user_text = listen(timeout_seconds=7.0, detection_timeout=3.0)
|
user_text = listen(
|
||||||
|
timeout_seconds=7.0, detection_timeout=5.0, fast_stop=True
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ошибка при прослушивании: {e}")
|
print(f"Ошибка при прослушивании: {e}")
|
||||||
print("Переинициализация STT...")
|
print("Переинициализация STT...")
|
||||||
@@ -350,13 +359,21 @@ def main():
|
|||||||
|
|
||||||
# --- Шаг 2: Анализ распознанного текста ---
|
# --- Шаг 2: Анализ распознанного текста ---
|
||||||
if not user_text:
|
if not user_text:
|
||||||
# Была активация, но речь не распознана
|
# Пустой ввод: без лишних ответов возвращаемся к ожиданию wake word.
|
||||||
speak("Извините, я вас не расслышал. Попробуйте ещё раз.")
|
skip_wakeword = False
|
||||||
skip_wakeword = False # Возвращаемся в режим ожидания имени
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Проверка на команду "Стоп"
|
# Проверка на команду "Стоп"
|
||||||
if is_stop_command(user_text):
|
if is_stop_command(user_text):
|
||||||
|
if stopwatch_manager.has_running_stopwatches():
|
||||||
|
stopwatch_stop_response = stopwatch_manager.pause_stopwatches()
|
||||||
|
clean_stopwatch_stop_response = clean_response(
|
||||||
|
stopwatch_stop_response, language="ru"
|
||||||
|
)
|
||||||
|
speak(clean_stopwatch_stop_response)
|
||||||
|
last_response = clean_stopwatch_stop_response
|
||||||
|
skip_wakeword = False
|
||||||
|
continue
|
||||||
print("_" * 50)
|
print("_" * 50)
|
||||||
print("💤 Жду 'Alexandr' для активации...")
|
print("💤 Жду 'Alexandr' для активации...")
|
||||||
skip_wakeword = False
|
skip_wakeword = False
|
||||||
@@ -387,24 +404,52 @@ def main():
|
|||||||
skip_wakeword = True
|
skip_wakeword = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
command_text = user_text
|
||||||
|
command_text_lower = command_text.lower()
|
||||||
|
if pending_time_target == "timer" and "таймер" not in command_text_lower:
|
||||||
|
command_text = f"таймер {command_text}"
|
||||||
|
elif (
|
||||||
|
pending_time_target == "alarm"
|
||||||
|
and "будильник" not in command_text_lower
|
||||||
|
and "разбуди" not in command_text_lower
|
||||||
|
):
|
||||||
|
command_text = f"будильник {command_text}"
|
||||||
|
|
||||||
# Проверка команд таймера ("поставь таймер на 6 минут")
|
# Проверка команд таймера ("поставь таймер на 6 минут")
|
||||||
timer_response = timer_manager.parse_command(user_text)
|
stopwatch_response = stopwatch_manager.parse_command(command_text)
|
||||||
|
if stopwatch_response:
|
||||||
|
clean_stopwatch_response = clean_response(
|
||||||
|
stopwatch_response, language="ru"
|
||||||
|
)
|
||||||
|
speak(clean_stopwatch_response)
|
||||||
|
last_response = clean_stopwatch_response
|
||||||
|
skip_wakeword = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Проверка команд таймера ("поставь таймер на 6 минут")
|
||||||
|
timer_response = timer_manager.parse_command(command_text)
|
||||||
if timer_response:
|
if timer_response:
|
||||||
clean_timer_response = clean_response(timer_response, language="ru")
|
clean_timer_response = clean_response(timer_response, language="ru")
|
||||||
completed = speak(
|
completed = speak(
|
||||||
clean_timer_response, check_interrupt=check_wakeword_once
|
clean_timer_response, check_interrupt=check_wakeword_once
|
||||||
)
|
)
|
||||||
last_response = clean_timer_response
|
last_response = clean_timer_response
|
||||||
|
pending_time_target = (
|
||||||
|
"timer" if timer_response == ASK_TIMER_TIME_PROMPT else None
|
||||||
|
)
|
||||||
skip_wakeword = not completed
|
skip_wakeword = not completed
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Проверка команд будильника ("поставь будильник на 7")
|
# Проверка команд будильника ("поставь будильник на 7")
|
||||||
alarm_response = alarm_clock.parse_command(user_text)
|
alarm_response = alarm_clock.parse_command(command_text)
|
||||||
if alarm_response:
|
if alarm_response:
|
||||||
clean_alarm_response = clean_response(alarm_response, language="ru")
|
clean_alarm_response = clean_response(alarm_response, language="ru")
|
||||||
speak(clean_alarm_response)
|
speak(clean_alarm_response)
|
||||||
last_response = clean_alarm_response
|
last_response = clean_alarm_response
|
||||||
skip_wakeword = False
|
pending_time_target = (
|
||||||
|
"alarm" if alarm_response == ASK_ALARM_TIME_PROMPT else None
|
||||||
|
)
|
||||||
|
skip_wakeword = alarm_response == ASK_ALARM_TIME_PROMPT
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Проверка команды громкости ("громкость 5")
|
# Проверка команды громкости ("громкость 5")
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
]
|
||||||
24
scripts/qwen-check.sh
Executable file
24
scripts/qwen-check.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo "[qwen-check] Python syntax compile check"
|
||||||
|
python -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