Update assistant features and docs

This commit is contained in:
2026-02-12 14:12:37 +03:00
parent bb3133a1c0
commit ca8ebd6657
19 changed files with 814 additions and 180 deletions

View File

@@ -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
View 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
View 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
View File

@@ -1,146 +1,122 @@
# 🎙️ Alexander Smart Speaker # Alexander Smart Speaker
<div align="center"> Голосовой ассистент для Linux с wake word, STT/TTS и набором голосовых навыков.
![Python](https://img.shields.io/badge/Python-3.9%2B-3776AB?logo=python&logoColor=white&style=for-the-badge) ## Что умеет
![Platform](https://img.shields.io/badge/Platform-Linux-FCC624?logo=linux&logoColor=black&style=for-the-badge) - Активация по ключевому слову `Alexandr`.
![License](https://img.shields.io/badge/License-MIT-45a163?style=for-the-badge) - Диалог с 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.

View File

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

View File

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

View File

@@ -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):
"""Открытие аудиопотока с микрофона.""" """Открытие аудиопотока с микрофона."""

View File

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

View File

@@ -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
View File

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

View File

@@ -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 "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."

View File

@@ -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
View File

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

View File

@@ -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 минут'."

View File

@@ -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
View File

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

24
scripts/qwen-check.sh Executable file
View 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
View File

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

10
ssp.py Normal file
View File

@@ -0,0 +1,10 @@
maxi = 0
for i in range(84052, 84131):
k = 0
for j in range(1, i + 1):
if i % j == 0:
k += 1
if maxi < k:
maxi = k
f = i
print(maxi, f)