diff --git a/.env.example b/.env.example
index 4bef9cc..88594d0 100644
--- a/.env.example
+++ b/.env.example
@@ -2,6 +2,7 @@ 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
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b81a8fe
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,10 @@
+.PHONY: run check qwen-context
+
+run:
+ python run.py
+
+check:
+ ./scripts/qwen-check.sh
+
+qwen-context:
+ ./scripts/qwen-context.sh
diff --git a/QWEN.md b/QWEN.md
new file mode 100644
index 0000000..cd1f0f8
--- /dev/null
+++ b/QWEN.md
@@ -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.
diff --git a/README.md b/README.md
index 3bc8899..4ebee0a 100644
--- a/README.md
+++ b/README.md
@@ -1,146 +1,122 @@
-# ποΈ Alexander Smart Speaker
+# Alexander Smart Speaker
-
+ΠΠΎΠ»ΠΎΡΠΎΠ²ΠΎΠΉ Π°ΡΡΠΈΡΡΠ΅Π½Ρ Π΄Π»Ρ 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`).
+
+## Π£ΡΡΠ°Π½ΠΎΠ²ΠΊΠ°
-
-
----
-
-## β¨ 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
-# Clone the repository
git clone https://github.com/your-username/alexander_smart-speaker.git
cd alexander_smart-speaker
-# Create virtual environment
python -m venv venv
source venv/bin/activate
-
-# Install dependencies
pip install -r requirements.txt
```
-### 3. Configuration
-Create a `.env` file based on the example:
+## ΠΠ°ΡΡΡΠΎΠΉΠΊΠ° `.env`
+
```bash
cp .env.example .env
```
-Fill in your API keys in `.env`:
+ΠΠΈΠ½ΠΈΠΌΠ°Π»ΡΠ½ΠΎ ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»ΡΠ½ΡΠ΅ ΠΏΠ΅ΡΠ΅ΠΌΠ΅Π½Π½ΡΠ΅:
+
```ini
-# AI & Speech APIs
-PERPLEXITY_API_KEY=pplx-...
+PERPLEXITY_API_KEY=...
DEEPGRAM_API_KEY=...
PORCUPINE_ACCESS_KEY=...
-
-# TTS Settings
-TTS_EN_SPEAKER=en_0
-TTS_RU_SPEAKER=eugene
-
-# Weather Location (Your City Coordinates)
-WEATHER_LAT=63.56
-WEATHER_LON=53.69
-WEATHER_CITY=Ukhta
```
----
+ΠΠΎΠ»Π½ΡΠΉ ΠΏΡΠΈΠΌΠ΅Ρ (ΠΊΠ°ΠΊ Π² `.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
+make run
+# ΠΈΠ»ΠΈ
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 |
+## ΠΠΎΠ»Π΅Π·Π½ΡΠ΅ ΠΊΠΎΠΌΠ°Π½Π΄Ρ
----
-
-## ποΈ Architecture
-
-```mermaid
-graph TD
- Mic[π€ Microphone] --> Wake[Wake Word
Porcupine]
- Wake -->|Activated| STT[STT
Deepgram]
-
- STT --> Router{Command Router}
-
- Router -->|Forecast| Weather[β
Weather
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
Silero]
-
- TTS --> Speaker[π Speaker]
+```bash
+make run # Π·Π°ΠΏΡΡΠΊ Π°ΡΡΠΈΡΡΠ΅Π½ΡΠ°
+make check # Π±Π°Π·ΠΎΠ²Π°Ρ ΠΏΡΠΎΠ²Π΅ΡΠΊΠ° ΠΏΡΠΎΠ΅ΠΊΡΠ°
+make qwen-context # ΡΠΎΠ±ΡΠ°ΡΡ ΠΊΠΎΠ½ΡΠ΅ΠΊΡΡ ΠΏΡΠΎΠ΅ΠΊΡΠ°
```
-## π Project Structure
-* `app/main.py` β Entry point, main event loop.
-* `app/audio/` β Audio processing modules (STT, TTS, Wake Word).
-* `app/core/` β AI logic, configuration, text cleaning.
-* `app/features/` β Skills (Weather, Alarm, Timer).
-* `assets/` β Models (Porcupine) and sound effects.
-* `data/` β Persistent state (alarms).
+## Π‘ΡΡΡΠΊΡΡΡΠ° ΠΏΡΠΎΠ΅ΠΊΡΠ°
+- `run.py` - ΡΠΎΡΠΊΠ° Π²Ρ
ΠΎΠ΄Π°.
+- `app/main.py` - Π³Π»Π°Π²Π½ΡΠΉ ΡΠΈΠΊΠ» Π°ΡΡΠΈΡΡΠ΅Π½ΡΠ°.
+- `app/audio/` - wake word, STT, TTS, Π³ΡΠΎΠΌΠΊΠΎΡΡΡ.
+- `app/core/` - ΠΊΠΎΠ½ΡΠΈΠ³, AI, ΡΠΎΡΡΠΈΠ½Π³ ΠΊΠΎΠΌΠ°Π½Π΄, ΡΡΠΈΠ»ΠΈΡΡ.
+- `app/features/` - ΠΏΠΎΠ³ΠΎΠ΄Π°, Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊ, ΡΠ°ΠΉΠΌΠ΅Ρ, ΡΠ΅ΠΊΡΠ½Π΄ΠΎΠΌΠ΅Ρ, ΠΌΡΠ·ΡΠΊΠ°, Π³ΠΎΡΠΎΠ΄Π°.
+- `assets/` - ΠΌΠΎΠ΄Π΅Π»ΠΈ ΠΈ Π·Π²ΡΠΊΠΈ.
+- `data/` - ΡΠΎΡ
ΡΠ°Π½Π΅Π½Π½ΡΠ΅ Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊΠΈ/ΡΠ°ΠΉΠΌΠ΅ΡΡ/ΡΠ΅ΠΊΡΠ½Π΄ΠΎΠΌΠ΅ΡΡ.
----
+## ΠΠΈΠ°Π³Π½ΠΎΡΡΠΈΠΊΠ° ΠΏΡΠΎΠ±Π»Π΅ΠΌ
+- ΠΡΠΈΠ±ΠΊΠΈ STT/AI: ΠΏΡΠΎΠ²Π΅ΡΡΡΠ΅ ΠΊΠ»ΡΡΠΈ Π² `.env`.
+- ΠΠ΅Ρ Π·Π²ΡΠΊΠ°: ΠΏΡΠΎΠ²Π΅ΡΡΡΠ΅ ΡΠΈΡΡΠ΅ΠΌΠ½ΠΎΠ΅ ΡΡΡΡΠΎΠΉΡΡΠ²ΠΎ Π²ΡΠ²ΠΎΠ΄Π° ΠΈ ΡΡΠΈΠ»ΠΈΡΡ `pactl`/`amixer`.
+- ΠΠ΅ ΠΈΠ³ΡΠ°Π΅Ρ Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊ/ΡΠ°ΠΉΠΌΠ΅Ρ: ΡΠ±Π΅Π΄ΠΈΡΠ΅ΡΡ, ΡΡΠΎ ΡΡΡΠ°Π½ΠΎΠ²Π»Π΅Π½ `mpg123`.
+- Spotify Π½Π΅ ΡΠΏΡΠ°Π²Π»ΡΠ΅ΡΡΡ: ΠΏΡΠΎΠ²Π΅ΡΡΡΠ΅ `SPOTIFY_*`, Π°Π²ΡΠΎΡΠΈΠ·Π°ΡΠΈΡ ΠΈ Π½Π°Π»ΠΈΡΠΈΠ΅ Π°ΠΊΡΠΈΠ²Π½ΠΎΠ³ΠΎ ΡΡΡΡΠΎΠΉΡΡΠ²Π°.
-## π οΈ Troubleshooting
-* **Deepgram Error 400**: Check your API key balance and validity in `.env`.
-* **No Sound**: Ensure `amixer` is installed and the default audio output is correctly configured in your OS.
-* **Alarm not playing**: Verify that `mpg123` is installed (`sudo apt install mpg123`).
-
-## π License
-MIT License. See `LICENSE.txt` for details.
+## ΠΠΈΡΠ΅Π½Π·ΠΈΡ
+MIT, ΡΠΌ. `LICENSE.txt`.
diff --git a/app/audio/sound_level.py b/app/audio/sound_level.py
index 9939d49..096d385 100644
--- a/app/audio/sound_level.py
+++ b/app/audio/sound_level.py
@@ -9,6 +9,7 @@ Regulates system volume on a scale from 1 to 10.
import subprocess
import re
import platform
+from ..core.roman import replace_roman_numerals
# ΠΠ°ΡΡΠ° Π΄Π»Ρ ΠΏΠ΅ΡΠ΅Π²ΠΎΠ΄Π° ΡΠ»ΠΎΠ² Π² ΡΠΈΡΡΡ ("ΠΏΡΡΡ" -> 5)
NUMBER_MAP = {
@@ -148,7 +149,7 @@ def parse_volume_text(text: str) -> int | None:
ΠΡΡΠ°Π΅ΡΡΡ Π½Π°ΠΉΡΠΈ ΡΠΈΡΠ»ΠΎ Π³ΡΠΎΠΌΠΊΠΎΡΡΠΈ Π² ΡΠ΅ΠΊΡΡΠ΅.
ΠΠΎΠ½ΠΈΠΌΠ°Π΅Ρ ΠΈ ΡΠΈΡΡΡ ("5"), ΠΈ ΡΠ»ΠΎΠ²Π° ("ΠΏΡΡΡ").
"""
- text = text.lower()
+ text = replace_roman_numerals(text.lower())
# 1. ΠΡΠ΅ΠΌ ΡΠΈΡΡΡ (1-10)
num_match = re.search(r"\b(10|[1-9])\b", text)
diff --git a/app/audio/stt.py b/app/audio/stt.py
index 7523f42..f245282 100644
--- a/app/audio/stt.py
+++ b/app/audio/stt.py
@@ -8,6 +8,7 @@ Supports Russian (default) and English.
# ΠΡΠΏΠΎΠ»ΡΠ·ΡΠ΅Ρ Deepgram API ΡΠ΅ΡΠ΅Π· Π²Π΅Π±-ΡΠΎΠΊΠ΅ΡΡ Π΄Π»Ρ ΠΏΠΎΡΠΎΠΊΠΎΠ²ΠΎΠ³ΠΎ ΡΠ°ΡΠΏΠΎΠ·Π½Π°Π²Π°Π½ΠΈΡ Π² ΡΠ΅Π°Π»ΡΠ½ΠΎΠΌ Π²ΡΠ΅ΠΌΠ΅Π½ΠΈ.
import asyncio
+import re
import time
import pyaudio
import logging
@@ -24,16 +25,19 @@ import websockets.sync.client
from ..core.audio_manager import get_audio_manager
# --- ΠΠ°ΡΡ (ΠΈΡΠΏΡΠ°Π²Π»Π΅Π½ΠΈΠ΅) Π΄Π»Ρ Π±ΠΈΠ±Π»ΠΈΠΎΡΠ΅ΠΊΠΈ websockets ---
-# ΠΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ Deepgram SDK ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΠ΅Ρ ΡΠ»ΠΈΡΠΊΠΎΠΌ ΠΊΠΎΡΠΎΡΠΊΠΈΠΉ ΡΠ°ΠΉΠΌΠ°ΡΡ ΠΏΠΎΠ΄ΠΊΠ»ΡΡΠ΅Π½ΠΈΡ.
-# ΠΡΠΎ ΡΠ°ΡΡΠΎ Π²ΡΠ·ΡΠ²Π°Π΅Ρ ΠΎΡΠΈΠ±ΠΊΠΈ ΠΏΡΠΈ ΠΌΠ΅Π΄Π»Π΅Π½Π½ΠΎΠΌ SSL ΡΡΠΊΠΎΠΏΠΎΠΆΠ°ΡΠΈΠΈ.
-# ΠΡ ΠΏΠΎΠ΄ΠΌΠ΅Π½ΡΠ΅ΠΌ ΡΡΠ½ΠΊΡΠΈΡ connect, ΡΡΠΎΠ±Ρ ΡΠ²Π΅Π»ΠΈΡΠΈΡΡ ΡΠ°ΠΉΠΌΠ°ΡΡ Π΄ΠΎ 30 ΡΠ΅ΠΊΡΠ½Π΄.
+# Π―Π²Π½ΠΎ Π·Π°Π΄Π°ΡΠΌ ΡΠ°ΠΉΠΌΠ°ΡΡΡ ΠΏΠΎΠ΄ΠΊΠ»ΡΡΠ΅Π½ΠΈΡ, ΡΡΠΎΠ±Ρ Π½Π΅ Π·Π°Π²ΠΈΡΠ°ΡΡ Π½Π° Π΄ΠΎΠ»Π³ΠΎΠΌ handshake.
_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):
- kwargs.setdefault("open_timeout", 30)
- kwargs.setdefault("ping_timeout", 30)
- kwargs.setdefault("close_timeout", 30)
+ # ΠΡΠΈΠ½ΡΠ΄ΠΈΡΠ΅Π»ΡΠ½ΠΎ Π·Π°Π΄Π°ΡΠΌ ΠΊΠΎΡΠΎΡΠΊΠΈΠ΅ ΡΠ°ΠΉΠΌΠ°ΡΡΡ, Π΄Π°ΠΆΠ΅ Π΅ΡΠ»ΠΈ SDK ΠΏΠ΅ΡΠ΅Π΄Π°Π» ΡΠ²ΠΎΠΈ (Π½Π°ΠΏΡΠΈΠΌΠ΅Ρ, 30Ρ).
+ kwargs["open_timeout"] = DEEPGRAM_CONNECT_TIMEOUT_SECONDS
+ kwargs["ping_timeout"] = DEEPGRAM_CONNECT_TIMEOUT_SECONDS
+ kwargs["close_timeout"] = DEEPGRAM_CONNECT_TIMEOUT_SECONDS
print(f"DEBUG: Connecting to Deepgram with timeout={kwargs.get('open_timeout')}s")
return _original_connect(*args, **kwargs)
@@ -44,6 +48,34 @@ sdk_ws.connect = _patched_connect
# ΠΡΠΊΠ»ΡΡΠ°Π΅ΠΌ Π»ΠΈΡΠ½ΠΈΠΉ ΠΌΡΡΠΎΡ Π² Π»ΠΎΠ³Π°Ρ
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:
"""ΠΠ»Π°ΡΡ ΡΠ°ΡΠΏΠΎΠ·Π½Π°Π²Π°Π½ΠΈΡ ΡΠ΅ΡΠΈ ΡΠ΅ΡΠ΅Π· Deepgram."""
@@ -105,24 +137,42 @@ class SpeechRecognizer:
)
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:
dg_connection: ΠΠΊΡΠΈΠ²Π½ΠΎΠ΅ ΡΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ Ρ Deepgram.
- timeout_seconds: ΠΠ±ΡΠ΅Π΅ Π²ΡΠ΅ΠΌΡ ΠΏΡΠΎΡΠ»ΡΡΠΈΠ²Π°Π½ΠΈΡ.
+ timeout_seconds: ΠΠ²Π°ΡΠΈΠΉΠ½ΡΠΉ Π»ΠΈΠΌΠΈΡ Π΄Π»ΠΈΡΠ΅Π»ΡΠ½ΠΎΡΡΠΈ Π°ΠΊΡΠΈΠ²Π½ΠΎΠΉ ΡΠ΅ΡΠΈ.
detection_timeout: ΠΡΠ΅ΠΌΡ ΠΎΠΆΠΈΠ΄Π°Π½ΠΈΡ Π½Π°ΡΠ°Π»Π° ΡΠ΅ΡΠΈ.
+ fast_stop: ΠΡΠ»ΠΈ True, ΠΊΠΎΡΠΎΡΠΊΠ°Ρ ΡΡΠΎΠΏ-ΡΡΠ°Π·Π° Π·Π°Π²Π΅ΡΡΠ°Π΅Ρ STT ΠΏΠΎΡΠ»Π΅ 1Ρ ΡΠΈΡΠΈΠ½Ρ.
"""
self.transcript = ""
transcript_parts = []
loop = asyncio.get_running_loop()
stream = self._get_stream()
+ effective_detection_timeout = (
+ detection_timeout
+ if detection_timeout is not None
+ else INITIAL_SILENCE_TIMEOUT_SECONDS
+ )
# Π‘ΠΎΠ±ΡΡΠΈΡ Π΄Π»Ρ ΡΠΈΠ½Ρ
ΡΠΎΠ½ΠΈΠ·Π°ΡΠΈΠΈ
stop_event = asyncio.Event() # ΠΠΎΡΠ° ΠΎΡΡΠ°Π½Π°Π²Π»ΠΈΠ²Π°ΡΡΡΡ
speech_started_event = asyncio.Event() # Π Π΅ΡΡ ΠΎΠ±Π½Π°ΡΡΠΆΠ΅Π½Π° (VAD)
+ 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 ---
def on_transcript(unused_self, result, **kwargs):
@@ -130,6 +180,20 @@ class SpeechRecognizer:
sentence = result.channel.alternatives[0].transcript
if len(sentence) == 0:
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:
# Π‘ΠΎΠ±ΠΈΡΠ°Π΅ΠΌ ΡΠΎΠ»ΡΠΊΠΎ ΡΠΈΠ½Π°Π»ΡΠ½ΡΠ΅ (ΠΏΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄Π΅Π½Π½ΡΠ΅) ΡΡΠ°Π·Ρ
transcript_parts.append(sentence)
@@ -138,18 +202,16 @@ class SpeechRecognizer:
def on_speech_started(unused_self, speech_started, **kwargs):
"""ΠΡΠ·ΡΠ²Π°Π΅ΡΡΡ, ΠΊΠΎΠ³Π΄Π° VAD (Voice Activity Detection) ΡΠ»ΡΡΠΈΡ Π³ΠΎΠ»ΠΎΡ."""
try:
- loop.call_soon_threadsafe(speech_started_event.set)
+ loop.call_soon_threadsafe(mark_speech_activity)
except RuntimeError:
# Event loop might be closed, ignore
pass
def on_utterance_end(unused_self, utterance_end, **kwargs):
"""ΠΡΠ·ΡΠ²Π°Π΅ΡΡΡ, ΠΊΠΎΠ³Π΄Π° Deepgram ΡΠ΅ΡΠ°Π΅Ρ, ΡΡΠΎ ΡΡΠ°Π·Π° Π·Π°ΠΊΠΎΠ½ΡΠΈΠ»Π°ΡΡ (ΠΏΠ°ΡΠ·Π°)."""
- try:
- loop.call_soon_threadsafe(stop_event.set)
- except RuntimeError:
- # Event loop might be closed, ignore
- pass
+ # ΠΠ΅ ΠΎΡΡΠ°Π½Π°Π²Π»ΠΈΠ²Π°Π΅ΠΌΡΡ ΠΌΠ³Π½ΠΎΠ²Π΅Π½Π½ΠΎ Π½Π° ΡΠΎΠ±ΡΡΠΈΠΈ Deepgram.
+ # ΠΡΡΠ°Π½ΠΎΠ²ΠΊΠ° ΡΠΏΡΠ°Π²Π»ΡΠ΅ΡΡΡ Π»ΠΎΠΊΠ°Π»ΡΠ½ΡΠΌ ΠΏΠΎΡΠΎΠ³ΠΎΠΌ ΡΠΈΡΠΈΠ½Ρ POST_SPEECH_SILENCE_TIMEOUT_SECONDS.
+ return
def on_error(unused_self, error, **kwargs):
print(f"Deepgram Error: {error}")
@@ -174,10 +236,10 @@ class SpeechRecognizer:
channels=1,
sample_rate=SAMPLE_RATE,
interim_results=True,
- utterance_end_ms=1000, # ΠΠ°ΡΠ·Π° 1.0Ρ ΡΡΠΈΡΠ°Π΅ΡΡΡ ΠΊΠΎΠ½ΡΠΎΠΌ ΡΡΠ°Π·Ρ (Π±ΡΠ»ΠΎ 1.2)
+ utterance_end_ms=int(POST_SPEECH_SILENCE_TIMEOUT_SECONDS * 1000),
vad_events=True,
- # ΠΠΎΠ±Π°Π²Π»ΡΠ΅ΠΌ ΠΏΠ°ΡΠ°ΠΌΠ΅ΡΡΡ ΡΠ°ΠΉΠΌΠ°ΡΡΠ° Π΄Π»Ρ Π΄ΠΎΠ»Π³ΠΎΠΉ ΡΠ°Π±ΠΎΡΡ
- endpointing=300, # Π’Π°ΠΉΠΌΠ°ΡΡ Π² ΠΌΠΈΠ»Π»ΠΈΡΠ΅ΠΊΡΠ½Π΄Π°Ρ
Π΄Π»Ρ Π°Π²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΎΠ³ΠΎ Π·Π°Π²Π΅ΡΡΠ΅Π½ΠΈΡ
+ # Π‘Π³Π»Π°ΠΆΠ΅Π½Π½ΡΠΉ ΠΏΠΎΡΠΎΠ³ endpointing, ΡΡΠΎΠ±Ρ Π½Π΅ ΡΠ΅Π·Π°ΡΡ ΡΠ΅ΡΡ Π½Π° ΠΊΠΎΡΠΎΡΠΊΠΈΡ
ΠΏΠ°ΡΠ·Π°Ρ
.
+ endpointing=int(POST_SPEECH_SILENCE_TIMEOUT_SECONDS * 1000),
)
# --- ΠΠ°Π΄Π°ΡΠ° ΠΎΡΠΏΡΠ°Π²ΠΊΠΈ Π°ΡΠ΄ΠΈΠΎ Ρ Π±ΡΡΠ΅ΡΠΈΠ·Π°ΡΠΈΠ΅ΠΉ ---
@@ -198,24 +260,29 @@ class SpeechRecognizer:
None, lambda: dg_connection.start(options)
)
- # ΠΠΎΠΊΠ° ΠΏΠΎΠ΄ΠΊΠ»ΡΡΠ°Π΅ΠΌΡΡ, ΠΊΠΎΠΏΠΈΠΌ Π΄Π°Π½Π½ΡΠ΅
- timeout_count = 0
- max_timeout = 5000 # ΠΠ°ΠΊΡΠΈΠΌΠ°Π»ΡΠ½ΠΎΠ΅ ΠΊΠΎΠ»ΠΈΡΠ΅ΡΡΠ²ΠΎ ΠΈΡΠ΅ΡΠ°ΡΠΈΠΉ ΠΎΠΆΠΈΠ΄Π°Π½ΠΈΡ (ΠΎΠΊΠΎΠ»ΠΎ 2.5 ΡΠ΅ΠΊΡΠ½Π΄ ΠΏΡΠΈ 0.0005 Π·Π°Π΄Π΅ΡΠΆΠΊΠ΅)
-
- while not connect_future.done() and timeout_count < max_timeout:
+ # ΠΠΎΠΊΠ° ΠΏΠΎΠ΄ΠΊΠ»ΡΡΠ°Π΅ΠΌΡΡ, ΠΊΠΎΠΏΠΈΠΌ Π΄Π°Π½Π½ΡΠ΅.
+ # ΠΠ΄ΡΠΌ ΠΊΠΎΡΠΎΡΠΊΠΎ: Π΅ΡΠ»ΠΈ ΡΠ΅ΡΡ ΠΏΠΎΠ΄Π²ΠΈΡΠ»Π°, Π±ΡΡΡΡΠ΅Π΅ ΠΏΠ΅ΡΠ΅Π·Π°ΠΏΡΡΠΊΠ°Π΅ΠΌ ΠΏΠΎΠΏΡΡΠΊΡ.
+ connect_deadline = time.monotonic() + DEEPGRAM_CONNECT_WAIT_SECONDS
+ while (
+ not connect_future.done()
+ and time.monotonic() < connect_deadline
+ ):
if stream.is_active():
data = stream.read(4096, exception_on_overflow=False)
audio_buffer.append(data)
- await asyncio.sleep(0.0005) # Π£ΠΌΠ΅Π½ΡΡΠ°Π΅ΠΌ Π·Π°Π΄Π΅ΡΠΆΠΊΡ Π΄Π»Ρ Π±ΠΎΠ»Π΅Π΅ Π±ΡΡΡΡΠΎΠΉ ΠΎΠ±ΡΠ°Π±ΠΎΡΠΊΠΈ
- timeout_count += 1
+ await asyncio.sleep(DEEPGRAM_CONNECT_POLL_SECONDS)
- if timeout_count >= max_timeout:
- print("β° Timeout connecting to Deepgram")
+ if not connect_future.done():
+ print(
+ f"β° Timeout connecting to Deepgram ({DEEPGRAM_CONNECT_WAIT_SECONDS:.1f}s)"
+ )
+ stop_event.set()
return
# ΠΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ ΡΠ΅Π·ΡΠ»ΡΡΠ°Ρ ΠΏΠΎΠ΄ΠΊΠ»ΡΡΠ΅Π½ΠΈΡ
if connect_future.result() is False:
print("Failed to start Deepgram connection")
+ stop_event.set()
return
print(f"π Connected! Sending buffer ({len(audio_buffer)} chunks)...")
@@ -227,11 +294,8 @@ class SpeechRecognizer:
audio_buffer = None # ΠΡΠ²ΠΎΠ±ΠΎΠΆΠ΄Π°Π΅ΠΌ ΠΏΠ°ΠΌΡΡΡ
- # 4. ΠΡΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅ΠΌ ΡΡΡΠΈΠΌΠΈΡΡ Π² ΡΠ΅Π°Π»ΡΠ½ΠΎΠΌ Π²ΡΠ΅ΠΌΠ΅Π½ΠΈ
- stream_timeout = 0
- max_stream_timeout = int(timeout_seconds / 0.002) # ΠΡΠΈΠΌΠ΅ΡΠ½ΡΠΉ ΡΠ°ΠΉΠΌΠ°ΡΡ Π² Π·Π°Π²ΠΈΡΠΈΠΌΠΎΡΡΠΈ ΠΎΡ timeout_seconds
-
- while not stop_event.is_set() and stream_timeout < max_stream_timeout:
+ # 4. ΠΡΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅ΠΌ ΡΡΡΠΈΠΌΠΈΡΡ Π² ΡΠ΅Π°Π»ΡΠ½ΠΎΠΌ Π²ΡΠ΅ΠΌΠ΅Π½ΠΈ Π΄ΠΎ ΡΠΎΠ±ΡΡΠΈΡ ΠΎΡΡΠ°Π½ΠΎΠ²ΠΊΠΈ.
+ while not stop_event.is_set():
if stream.is_active():
data = stream.read(4096, exception_on_overflow=False)
dg_connection.send(data)
@@ -239,7 +303,6 @@ class SpeechRecognizer:
if chunks_sent % 50 == 0:
print(".", end="", flush=True)
await asyncio.sleep(0.002) # Π£ΠΌΠ΅Π½ΡΡΠ°Π΅ΠΌ Π·Π°Π΄Π΅ΡΠΆΠΊΡ Π΄Π»Ρ Π±ΠΎΠ»Π΅Π΅ Π±ΡΡΡΡΠΎΠ³ΠΎ ΡΠ΅Π°Π³ΠΈΡΠΎΠ²Π°Π½ΠΈΡ
- stream_timeout += 1
except Exception as e:
print(f"Audio send error: {e}")
@@ -255,19 +318,60 @@ class SpeechRecognizer:
try:
# 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:
- await asyncio.wait_for(
- speech_started_event.wait(), timeout=detection_timeout
+ done, pending = await asyncio.wait(
+ {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 Π½ΠΈΠΊΡΠΎ Π½Π΅ Π½Π°ΡΠ°Π» Π³ΠΎΠ²ΠΎΡΠΈΡΡ, Π²ΡΡ
ΠΎΠ΄ΠΈΠΌ
stop_event.set()
- # 2. ΠΡΠ»ΠΈ ΡΠ΅ΡΡ Π½Π°ΡΠ°Π»Π°ΡΡ (ΠΈΠ»ΠΈ ΡΠ°ΠΉΠΌΠ°ΡΡΠ° Π½Π΅Ρ), ΠΆΠ΄Π΅ΠΌ Π·Π°Π²Π΅ΡΡΠ΅Π½ΠΈΡ (stop_event)
- # stop_event ΡΡΠ°Π±ΠΎΡΠ°Π΅Ρ Π»ΠΈΠ±ΠΎ ΠΏΠΎ UtteranceEnd (ΠΏΠ°ΡΠ·Π°), Π»ΠΈΠ±ΠΎ ΠΏΠΎ ΠΎΠ±ΡΠ΅ΠΌΡ ΡΠ°ΠΉΠΌΠ°ΡΡΡ
+ # 2. ΠΠΎΡΠ»Π΅ ΡΡΠ°ΡΡΠ° ΡΠ΅ΡΠΈ Π·Π°Π²Π΅ΡΡΠ°Π΅ΠΌ ΡΠΎΠ»ΡΠΊΠΎ ΠΏΠΎ ΡΠΈΡΠΈΠ½Π΅ POST_SPEECH_SILENCE_TIMEOUT_SECONDS.
+ # ΠΠΎΠ±Π°Π²Π»ΡΠ΅ΠΌ Π΄Π»ΠΈΠ½Π½ΡΠΉ Π·Π°ΡΠΈΡΠ½ΡΠΉ Π»ΠΈΠΌΠΈΡ, ΡΡΠΎΠ±Ρ ΡΠ΅ΡΡΠΈΡ Π½Π΅ Π·Π°Π²ΠΈΡΠ»Π° Π½Π°Π²ΡΠ΅Π³Π΄Π°.
if not stop_event.is_set():
- await asyncio.wait_for(stop_event.wait(), timeout=timeout_seconds)
+ max_active_speech_seconds = max(
+ timeout_seconds if timeout_seconds else 0.0,
+ MAX_ACTIVE_SPEECH_SECONDS,
+ )
+
+ while not stop_event.is_set():
+ now = time.monotonic()
+
+ if speech_started_event.is_set():
+ if (
+ now - last_speech_activity
+ >= POST_SPEECH_SILENCE_TIMEOUT_SECONDS
+ ):
+ 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:
pass # ΠΠ±ΡΠΈΠΉ ΡΠ°ΠΉΠΌΠ°ΡΡ Π²ΡΡΠ΅Π»
@@ -291,16 +395,18 @@ class SpeechRecognizer:
def listen(
self,
timeout_seconds: float = 7.0,
- detection_timeout: float = None,
+ detection_timeout: float = INITIAL_SILENCE_TIMEOUT_SECONDS,
lang: str = "ru",
+ fast_stop: bool = False,
) -> str:
"""
ΠΡΠ½ΠΎΠ²Π½ΠΎΠΉ ΠΌΠ΅ΡΠΎΠ΄: ΡΠ»ΡΡΠ°Π΅Ρ ΠΌΠΈΠΊΡΠΎΡΠΎΠ½ ΠΈ Π²ΠΎΠ·Π²ΡΠ°ΡΠ°Π΅Ρ ΡΠ΅ΠΊΡΡ.
Args:
- timeout_seconds: ΠΠ°ΠΊΡΠΈΠΌΠ°Π»ΡΠ½Π°Ρ Π΄Π»ΠΈΡΠ΅Π»ΡΠ½ΠΎΡΡΡ ΡΡΠ°Π·Ρ.
+ timeout_seconds: ΠΠ°ΡΠΈΡΠ½ΡΠΉ Π»ΠΈΠΌΠΈΡ Π΄Π»ΠΈΡΠ΅Π»ΡΠ½ΠΎΡΡΠΈ Π°ΠΊΡΠΈΠ²Π½ΠΎΠΉ ΡΠ΅ΡΠΈ.
detection_timeout: Π‘ΠΊΠΎΠ»ΡΠΊΠΎ ΠΆΠ΄Π°ΡΡ Π½Π°ΡΠ°Π»Π° ΡΠ΅ΡΠΈ ΠΏΠ΅ΡΠ΅Π΄ ΡΠ΅ΠΌ ΠΊΠ°ΠΊ ΡΠ΄Π°ΡΡΡΡ.
lang: Π―Π·ΡΠΊ ("ru" ΠΈΠ»ΠΈ "en").
+ fast_stop: ΠΡΡΡΡΠΎΠ΅ Π·Π°Π²Π΅ΡΡΠ΅Π½ΠΈΠ΅ Π΄Π»Ρ ΠΊΠΎΡΠΎΡΠΊΠΈΡ
stop-ΠΊΠΎΠΌΠ°Π½Π΄.
"""
if not self.dg_client:
self.initialize()
@@ -323,7 +429,7 @@ class SpeechRecognizer:
# ΠΠ°ΠΏΡΡΠΊΠ°Π΅ΠΌ Π°ΡΠΈΠ½Ρ
ΡΠΎΠ½Π½ΡΠΉ ΠΏΡΠΎΡΠ΅ΡΡ ΠΎΠ±ΡΠ°Π±ΠΎΡΠΊΠΈ
transcript = asyncio.run(
self._process_audio(
- dg_connection, timeout_seconds, detection_timeout
+ dg_connection, timeout_seconds, detection_timeout, fast_stop
)
)
final_text = transcript.strip() if transcript else ""
@@ -389,10 +495,13 @@ def get_recognizer() -> SpeechRecognizer:
def listen(
- timeout_seconds: float = 7.0, detection_timeout: float = None, lang: str = "ru"
+ timeout_seconds: float = 7.0,
+ detection_timeout: float = INITIAL_SILENCE_TIMEOUT_SECONDS,
+ lang: str = "ru",
+ fast_stop: bool = False,
) -> str:
"""ΠΠ½Π΅ΡΠ½ΡΡ ΡΡΠ½ΠΊΡΠΈΡ Π΄Π»Ρ ΠΏΡΠΎΡΠ»ΡΡΠΈΠ²Π°Π½ΠΈΡ."""
- return get_recognizer().listen(timeout_seconds, detection_timeout, lang)
+ return get_recognizer().listen(timeout_seconds, detection_timeout, lang, fast_stop)
def cleanup():
diff --git a/app/audio/wakeword.py b/app/audio/wakeword.py
index 8efbcea..fc12ce6 100644
--- a/app/audio/wakeword.py
+++ b/app/audio/wakeword.py
@@ -9,7 +9,11 @@ Listens for the "Alexandr" wake word.
import pvporcupine
import pyaudio
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
@@ -27,13 +31,15 @@ class WakeWordDetector:
"""ΠΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΡ Porcupine ΠΈ PyAudio."""
# Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ ΡΠΊΠ·Π΅ΠΌΠΏΠ»ΡΡ Porcupine Ρ Π½Π°ΡΠΈΠΌ ΠΊΠ»ΡΡΠΎΠΌ Π΄ΠΎΡΡΡΠΏΠ° ΠΈ ΡΠ°ΠΉΠ»ΠΎΠΌ ΠΌΠΎΠ΄Π΅Π»ΠΈ (.ppn)
self.porcupine = pvporcupine.create(
- access_key=PORCUPINE_ACCESS_KEY, keyword_paths=[str(PORCUPINE_KEYWORD_PATH)]
+ access_key=PORCUPINE_ACCESS_KEY,
+ keyword_paths=[str(PORCUPINE_KEYWORD_PATH)],
+ sensitivities=[PORCUPINE_SENSITIVITY],
)
# ΠΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΠΌ ΠΎΠ±ΡΠΈΠΉ ΡΠΊΠ·Π΅ΠΌΠΏΠ»ΡΡ PyAudio
self.pa = get_audio_manager().get_pyaudio()
self._open_stream()
- print("π€ ΠΠΆΠΈΠ΄Π°Π½ΠΈΠ΅ wake word 'Alexandr'...")
+ print(f"π€ ΠΠΆΠΈΠ΄Π°Π½ΠΈΠ΅ wake word 'Alexandr' (sens={PORCUPINE_SENSITIVITY:.2f})...")
def _open_stream(self):
"""ΠΡΠΊΡΡΡΠΈΠ΅ Π°ΡΠ΄ΠΈΠΎΠΏΠΎΡΠΎΠΊΠ° Ρ ΠΌΠΈΠΊΡΠΎΡΠΎΠ½Π°."""
diff --git a/app/core/cleaner.py b/app/core/cleaner.py
index 58fb2e7..659be73 100644
--- a/app/core/cleaner.py
+++ b/app/core/cleaner.py
@@ -12,6 +12,7 @@ Handles complex number-to-text conversion for Russian language.
import re
import pymorphy3
from num2words import num2words
+from .roman import roman_to_int
# ΠΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΡ ΠΌΠΎΡΡΠΎΠ»ΠΎΠ³ΠΈΡΠ΅ΡΠΊΠΎΠ³ΠΎ Π°Π½Π°Π»ΠΈΠ·Π°ΡΠΎΡΠ° (Π΄Π»Ρ ΠΎΠΏΡΠ΅Π΄Π΅Π»Π΅Π½ΠΈΡ ΠΏΠ°Π΄Π΅ΠΆΠ΅ΠΉ)
morph = pymorphy3.MorphAnalyzer()
@@ -334,6 +335,50 @@ def numbers_to_words(text: str) -> str:
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:
"""
ΠΡΠ½ΠΎΠ²Π½Π°Ρ ΡΡΠ½ΠΊΡΠΈΡ ΠΎΡΠΈΡΡΠΊΠΈ.
@@ -408,9 +453,11 @@ def clean_response(text: str, language: str = "ru") -> str:
flags=re.IGNORECASE | re.MULTILINE,
)
- # Convert numbers to words only for Russian, and only if digits exist
- if language == "ru" and re.search(r"\d", text):
- text = numbers_to_words(text)
+ # Convert Roman numerals and Arabic digits to words for Russian.
+ if language == "ru":
+ text = roman_numerals_to_words(text)
+ if re.search(r"\d", text):
+ text = numbers_to_words(text)
# Remove extra whitespace
text = re.sub(r"\n{3,}", "\n\n", text)
diff --git a/app/core/config.py b/app/core/config.py
index e520126..5947576 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -33,6 +33,8 @@ DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY")
PORCUPINE_ACCESS_KEY = os.getenv("PORCUPINE_ACCESS_KEY")
# ΠΡΡΡ ΠΊ ΡΠ°ΠΉΠ»Ρ ΠΌΠΎΠ΄Π΅Π»ΠΈ ΠΊΠ»ΡΡΠ΅Π²ΠΎΠ³ΠΎ ΡΠ»ΠΎΠ²Π° (.ppn), ΠΊΠΎΡΠΎΡΡΠΉ Π»Π΅ΠΆΠΈΡ Π² ΠΏΠ°ΠΏΠΊΠ΅ assets/models
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"))
# --- ΠΠ°ΡΠ°ΠΌΠ΅ΡΡΡ Π°ΡΠ΄ΠΈΠΎ ---
# Π§Π°ΡΡΠΎΡΠ° Π΄ΠΈΡΠΊΡΠ΅ΡΠΈΠ·Π°ΡΠΈΠΈ Π΄Π»Ρ ΠΌΠΈΠΊΡΠΎΡΠΎΠ½Π° (ΡΡΠ°Π½Π΄Π°ΡΡ Π΄Π»Ρ ΡΠ°ΡΠΏΠΎΠ·Π½Π°Π²Π°Π½ΠΈΡ ΡΠ΅ΡΠΈ)
diff --git a/app/core/roman.py b/app/core/roman.py
new file mode 100644
index 0000000..ade8155
--- /dev/null
+++ b/app/core/roman.py
@@ -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"(? 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)
diff --git a/app/features/alarm.py b/app/features/alarm.py
index 5fca5a3..b7cc29f 100644
--- a/app/features/alarm.py
+++ b/app/features/alarm.py
@@ -10,11 +10,13 @@ from datetime import datetime
from ..core.config import BASE_DIR
from ..audio.stt import listen
from ..core.commands import is_stop_command
+from ..core.roman import replace_roman_numerals
# Π€Π°ΠΉΠ» Π±Π°Π·Ρ Π΄Π°Π½Π½ΡΡ
Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊΠΎΠ²
ALARM_FILE = BASE_DIR / "data" / "alarms.json"
# ΠΠ²ΡΠΊΠΎΠ²ΠΎΠΉ ΡΠ°ΠΉΠ» ΡΠΈΠ³Π½Π°Π»Π°
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
+ASK_ALARM_TIME_PROMPT = "ΠΠ° ΠΊΠ°ΠΊΠΎΠ΅ Π²ΡΠ΅ΠΌΡ ΠΌΠ½Π΅ ΠΏΠΎΡΡΠ°Π²ΠΈΡΡ Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊ?"
class AlarmClock:
@@ -229,7 +231,7 @@ class AlarmClock:
try:
# Π¦ΠΈΠΊΠ» ΠΎΠΆΠΈΠ΄Π°Π½ΠΈΡ ΡΡΠΎΠΏ-ΠΊΠΎΠΌΠ°Π½Π΄Ρ
while True:
- text = listen(timeout_seconds=3.0, detection_timeout=3.0)
+ text = listen(timeout_seconds=3.0, detection_timeout=3.0, fast_stop=True)
if text:
if is_stop_command(text, mode="lenient"):
print(f"π ΠΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊ ΠΎΡΡΠ°Π½ΠΎΠ²Π»Π΅Π½ ΠΏΠΎ ΠΊΠΎΠΌΠ°Π½Π΄Π΅: '{text}'")
@@ -251,7 +253,7 @@ class AlarmClock:
ΠΠ°ΡΡΠΈΠ½Π³ ΠΊΠΎΠΌΠ°Π½Π΄Ρ ΡΡΡΠ°Π½ΠΎΠ²ΠΊΠΈ Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊΠ° ΠΈΠ· ΡΠ΅ΠΊΡΡΠ°.
ΠΡΠΈΠΌΠ΅ΡΡ: "ΡΠ°Π·Π±ΡΠ΄ΠΈ Π² 7:30", "Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊ Π½Π° 8 ΡΡΡΠ°".
"""
- text = text.lower()
+ text = replace_roman_numerals(text.lower())
if "Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊ" not in text and "ΡΠ°Π·Π±ΡΠ΄ΠΈ" not in text:
return None
@@ -299,6 +301,12 @@ class AlarmClock:
suffix = f" {days_phrase}" if days_phrase else ""
return f"Π₯ΠΎΡΠΎΡΠΎ, ΡΠ°Π·Π±ΡΠΆΡ Π²Π°Ρ Π² {h}:{m:02d}{suffix}."
+ if re.search(r"(ΠΏΠΎΡΡΠ°Π²|ΡΡΡΠ°Π½ΠΎΠ²|Π·Π°ΠΏΡΡΡΠΈ|Π²ΠΊΠ»ΡΡΠΈ|ΡΠ°Π·Π±ΡΠ΄ΠΈ)", text) or text.strip() in {
+ "Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊ",
+ "ΠΏΠΎΡΡΠ°Π²Ρ Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊ",
+ }:
+ return ASK_ALARM_TIME_PROMPT
+
return "Π― Π½Π΅ ΠΏΠΎΠ½ΡΠ» Π²ΡΠ΅ΠΌΡ Π΄Π»Ρ Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊΠ°. ΠΠΎΠΆΠ°Π»ΡΠΉΡΡΠ°, ΡΠΊΠ°ΠΆΠΈΡΠ΅ ΡΠΎΡΠ½ΠΎΠ΅ Π²ΡΠ΅ΠΌΡ, Π½Π°ΠΏΡΠΈΠΌΠ΅Ρ 'ΡΠ΅ΠΌΡ ΡΡΠΈΠ΄ΡΠ°ΡΡ'."
diff --git a/app/features/music.py b/app/features/music.py
index 254032a..54ff668 100644
--- a/app/features/music.py
+++ b/app/features/music.py
@@ -9,6 +9,7 @@ Spotify Music Controller
- "ΡΠ»Π΅Π΄ΡΡΡΠΈΠΉ ΡΡΠ΅ΠΊ" / "next" - ΡΠ»Π΅Π΄ΡΡΡΠΈΠΉ ΡΡΠ΅ΠΊ
- "ΠΏΡΠ΅Π΄ΡΠ΄ΡΡΠΈΠΉ ΡΡΠ΅ΠΊ" / "previous" - ΠΏΡΠ΅Π΄ΡΠ΄ΡΡΠΈΠΉ ΡΡΠ΅ΠΊ
- "ΡΡΠΎ ΠΈΠ³ΡΠ°Π΅Ρ" / "ΠΊΠ°ΠΊΠ°Ρ ΠΏΠ΅ΡΠ½Ρ" - ΠΈΠ½ΡΠΎΡΠΌΠ°ΡΠΈΡ ΠΎ ΡΠ΅ΠΊΡΡΠ΅ΠΌ ΡΡΠ΅ΠΊΠ΅
+- "ΡΠ³Π°Π΄Π°ΠΉ ΠΏΠ΅ΡΠ½Ρ" / "ΡΠ°ΡΠΏΠΎΠ·Π½Π°ΠΉ ΠΌΡΠ·ΡΠΊΡ" - ΡΠ°ΡΠΏΠΎΠ·Π½Π°Π²Π°Π½ΠΈΠ΅ ΡΠ΅ΠΊΡΡΠ΅Π³ΠΎ ΡΡΠ΅ΠΊΠ°
"""
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):
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 = [
r"(ΡΡΠΎ (ΡΠ΅ΠΉΡΠ°Ρ )?ΠΈΠ³ΡΠ°Π΅Ρ|ΠΊΠ°ΠΊ(Π°Ρ|ΠΎΠΉ) (ΠΏΠ΅ΡΠ½Ρ|ΡΡΠ΅ΠΊ)|ΡΡΠΎ Π·Π° (ΠΏΠ΅ΡΠ½Ρ|ΡΡΠ΅ΠΊ|ΠΌΡΠ·ΡΠΊΠ°))",
diff --git a/app/features/stopwatch.py b/app/features/stopwatch.py
new file mode 100644
index 0000000..b8716f0
--- /dev/null
+++ b/app/features/stopwatch.py
@@ -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
diff --git a/app/features/timer.py b/app/features/timer.py
index f5f2fd1..3d5c23f 100644
--- a/app/features/timer.py
+++ b/app/features/timer.py
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta
from ..core.config import BASE_DIR
from ..audio.stt import listen
from ..core.commands import is_stop_command
+from ..core.roman import replace_roman_numerals
# Morphological analysis for better recognition of number words.
try:
@@ -22,6 +23,7 @@ except Exception:
# ΠΠ²ΡΠΊΠΎΠ²ΠΎΠΉ ΡΠ°ΠΉΠ» ΡΠΈΠ³Π½Π°Π»Π° (ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΠΌ ΡΠΎΡ ΠΆΠ΅, ΡΡΠΎ ΠΈ Π΄Π»Ρ Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊΠ°)
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
TIMER_FILE = BASE_DIR / "data" / "timers.json"
+ASK_TIMER_TIME_PROMPT = "ΠΠ° ΠΊΠ°ΠΊΠΎΠ΅ Π²ΡΠ΅ΠΌΡ ΠΌΠ½Π΅ ΠΏΠΎΡΡΠ°Π²ΠΈΡΡ ΡΠ°ΠΉΠΌΠ΅Ρ?"
# --- Number words parsing helpers (ru) ---
_NUMBER_UNITS = {
@@ -162,11 +164,13 @@ def _parse_number_lemmas(lemmas):
def _normalize_timer_text(text: str) -> str:
# Split "ΠΏΠΎΠ»ΡΠ°ΡΠ°/ΠΏΠΎΠ»ΠΌΠΈΠ½ΡΡΡ/ΠΏΠΎΠ»ΡΠ΅ΠΊΡΠ½Π΄Ρ" into "ΠΏΠΎΠ» ΡΠ°ΡΠ°" for easier parsing.
- return re.sub(
+ text = re.sub(
r"(?i)\bΠΏΠΎΠ»(?=(?:ΡΠ°Ρ|ΡΠ°ΡΠ°|ΠΌΠΈΠ½ΡΡ|ΠΌΠΈΠ½ΡΡΡ|ΠΌΠΈΠ½ΡΡΡ|ΡΠ΅ΠΊΡΠ½Π΄|ΡΠ΅ΠΊΡΠ½Π΄Ρ|ΡΠ΅ΠΊΡΠ½Π΄Ρ|ΠΌΠΈΠ½|ΡΠ΅ΠΊ)\b)",
"ΠΏΠΎΠ» ",
text,
)
+ # Support commands like "ΡΠ°ΠΉΠΌΠ΅Ρ Π½Π° X ΠΌΠΈΠ½ΡΡ".
+ return replace_roman_numerals(text)
def _find_word_number_before_unit(tokens, unit_index):
@@ -371,7 +375,7 @@ class TimerManager:
try:
# Π¦ΠΈΠΊΠ» ΠΎΠΆΠΈΠ΄Π°Π½ΠΈΡ ΡΡΠΎΠΏ-ΠΊΠΎΠΌΠ°Π½Π΄Ρ
while True:
- text = listen(timeout_seconds=3.0, detection_timeout=3.0)
+ text = listen(timeout_seconds=3.0, detection_timeout=3.0, fast_stop=True)
if text:
if is_stop_command(text, mode="lenient"):
print(f"π Π’Π°ΠΉΠΌΠ΅Ρ ΠΎΡΡΠ°Π½ΠΎΠ²Π»Π΅Π½ ΠΏΠΎ ΠΊΠΎΠΌΠ°Π½Π΄Π΅: '{text}'")
@@ -477,7 +481,14 @@ class TimerManager:
self.add_timer(total_seconds, label)
return f"ΠΠΎΡΡΠ°Π²ΠΈΠ» ΡΠ°ΠΉΠΌΠ΅Ρ Π½Π° {label}."
- # ΠΡΠ»ΠΈ ΡΠΊΠ°Π·Π°Π»ΠΈ "ΡΠ°ΠΉΠΌΠ΅Ρ", Π½ΠΎ Π½Π΅ Π½Π°ΡΠ»ΠΈ Π²ΡΠ΅ΠΌΡ
+ # ΠΡΠ»ΠΈ ΠΏΠΎΠΏΡΠΎΡΠΈΠ»ΠΈ ΠΏΠΎΡΡΠ°Π²ΠΈΡΡ ΡΠ°ΠΉΠΌΠ΅Ρ, Π½ΠΎ Π½Π΅ Π½Π°Π·Π²Π°Π»ΠΈ Π²ΡΠ΅ΠΌΡ β Π·Π°Π΄Π°Π΅ΠΌ ΡΡΠΎΡΠ½ΡΡΡΠΈΠΉ Π²ΠΎΠΏΡΠΎΡ.
+ if re.search(r"(ΠΏΠΎΡΡΠ°Π²|ΡΡΡΠ°Π½ΠΎΠ²|Π·Π°ΠΏΡΡΡΠΈ|Π²ΠΊΠ»ΡΡΠΈ|Π·Π°ΡΠ΅ΠΊΠΈ)", text) or text.strip() in {
+ "ΡΠ°ΠΉΠΌΠ΅Ρ",
+ "ΠΏΠΎΡΡΠ°Π²Ρ ΡΠ°ΠΉΠΌΠ΅Ρ",
+ }:
+ return ASK_TIMER_TIME_PROMPT
+
+ # ΠΡΠ»ΠΈ ΡΠΊΠ°Π·Π°Π»ΠΈ "ΡΠ°ΠΉΠΌΠ΅Ρ", Π½ΠΎ Π½Π΅ Π½Π°ΡΠ»ΠΈ Π²ΡΠ΅ΠΌΡ.
return "Π― Π½Π΅ ΠΏΠΎΠ½ΡΠ», Π½Π° ΡΠΊΠΎΠ»ΡΠΊΠΎ ΠΏΠΎΡΡΠ°Π²ΠΈΡΡ ΡΠ°ΠΉΠΌΠ΅Ρ. Π‘ΠΊΠ°ΠΆΠΈΡΠ΅, Π½Π°ΠΏΡΠΈΠΌΠ΅Ρ, 'ΡΠ°ΠΉΠΌΠ΅Ρ Π½Π° 5 ΠΌΠΈΠ½ΡΡ'."
diff --git a/app/main.py b/app/main.py
index 08cfcc3..1800cf1 100644
--- a/app/main.py
+++ b/app/main.py
@@ -53,8 +53,9 @@ from .core.config import BASE_DIR
from .core.cleaner import clean_response
from .core.commands import is_stop_command
from .core.smalltalk import get_smalltalk_response
-from .features.alarm import get_alarm_clock
-from .features.timer import get_timer_manager
+from .features.alarm import ASK_ALARM_TIME_PROMPT, get_alarm_clock
+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.music import get_music_controller
from .features.cities_game import get_cities_game
@@ -256,6 +257,7 @@ def main():
get_recognizer().initialize() # ΠΠΎΠ΄ΠΊΠ»ΡΡΠ΅Π½ΠΈΠ΅ ΠΊ Deepgram
init_tts() # ΠΠ°Π³ΡΡΠ·ΠΊΠ° Π½Π΅ΠΉΡΠΎΡΠ΅ΡΠΈ Π΄Π»Ρ ΡΠΈΠ½ΡΠ΅Π·Π° ΡΠ΅ΡΠΈ (Silero)
alarm_clock = get_alarm_clock() # ΠΠ°Π³ΡΡΠ·ΠΊΠ° Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊΠΎΠ²
+ stopwatch_manager = get_stopwatch_manager() # ΠΠ°Π³ΡΡΠ·ΠΊΠ° ΡΠ΅ΠΊΡΠ½Π΄ΠΎΠΌΠ΅ΡΠΎΠ²
timer_manager = get_timer_manager() # ΠΠ°Π³ΡΡΠ·ΠΊΠ° ΡΠ°ΠΉΠΌΠ΅ΡΠΎΠ²
cities_game = get_cities_game() # ΠΠ³ΡΠ° "ΠΠΎΡΠΎΠ΄Π°"
print()
@@ -270,6 +272,10 @@ def main():
# (True = ΡΠ΅ΠΆΠΈΠΌ Π΄ΠΈΠ°Π»ΠΎΠ³Π°, ΡΠ»ΡΡΠ°Π΅ΠΌ ΡΡΠ°Π·Ρ. False = ΠΆΠ΄Π΅ΠΌ "Alexandr")
skip_wakeword = False
+ # ΠΠΎΠ½ΡΠ΅ΠΊΡΡ ΡΡΠΎΡΠ½Π΅Π½ΠΈΡ "Π½Π° ΠΊΠ°ΠΊΠΎΠ΅ Π²ΡΠ΅ΠΌΡ ΠΏΠΎΡΡΠ°Π²ΠΈΡΡ ...".
+ # ΠΠΎΠΆΠ΅Ρ Π±ΡΡΡ: "timer", "alarm".
+ pending_time_target = None
+
# ΠΠ΅ΡΠ΅ΠΌΠ΅Π½Π½Π°Ρ Π΄Π»Ρ ΠΎΡΡΠ»Π΅ΠΆΠΈΠ²Π°Π½ΠΈΡ ΠΏΠΎΡΠ»Π΅Π΄Π½Π΅ΠΉ ΠΏΡΠΎΠ²Π΅ΡΠΊΠΈ Π·Π΄ΠΎΡΠΎΠ²ΡΡ STT
last_stt_check = time.time()
@@ -314,9 +320,10 @@ def main():
if ding_sound:
ding_sound.play()
- # Π€ΡΠ°Π·Π° ΡΡΠ»ΡΡΠ°Π½Π°! Π‘Π»ΡΡΠ°Π΅ΠΌ ΠΊΠΎΠΌΠ°Π½Π΄Ρ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ (5 ΡΠ΅ΠΊΡΠ½Π΄ ΡΠΈΡΠΈΠ½Ρ ΠΌΠ°ΠΊΡ)
+ # Π€ΡΠ°Π·Π° Π°ΠΊΡΠΈΠ²Π°ΡΠΈΠΈ ΡΡΠ»ΡΡΠ°Π½Π°:
+ # Π΄ΠΎ 5Ρ ΠΆΠ΄ΡΠΌ Π½Π°ΡΠ°Π»ΠΎ ΡΠ΅ΡΠΈ, ΠΏΠΎΡΠ»Π΅ Π½Π°ΡΠ°Π»Π° Π·Π°Π²Π΅ΡΡΠ°Π΅ΠΌ STT ΠΏΠΎ 3Ρ ΡΠΈΡΠΈΠ½Ρ.
try:
- user_text = listen(timeout_seconds=5.0)
+ user_text = listen(timeout_seconds=5.0, fast_stop=True)
except Exception as e:
print(f"ΠΡΠΈΠ±ΠΊΠ° ΠΏΡΠΈ ΠΏΡΠΎΡΠ»ΡΡΠΈΠ²Π°Π½ΠΈΠΈ: {e}")
print("ΠΠ΅ΡΠ΅ΠΈΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΡ STT...")
@@ -328,10 +335,12 @@ def main():
continue # ΠΡΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅ΠΌ ΡΠΈΠΊΠ»
else:
# Π Π΅ΠΆΠΈΠΌ Π΄ΠΈΠ°Π»ΠΎΠ³Π° (Follow-up): ΠΆΠ΄Π΅ΠΌ ΠΏΡΠΎΠ΄ΠΎΠ»ΠΆΠ΅Π½ΠΈΡ ΡΠ΅ΡΠΈ Π±Π΅Π· "Alexandr"
- print("π Π‘Π»ΡΡΠ°Ρ ΠΏΡΠΎΠ΄ΠΎΠ»ΠΆΠ΅Π½ΠΈΠ΅ Π΄ΠΈΠ°Π»ΠΎΠ³Π° (3 ΡΠ΅ΠΊ)...")
- # ΠΠ΄Π΅ΠΌ Π½Π°ΡΠ°Π»Π° ΡΠ΅ΡΠΈ 3 ΡΠ΅ΠΊ. ΠΡΠ»ΠΈ Π½Π°ΡΠ°Π»ΠΈ Π³ΠΎΠ²ΠΎΡΠΈΡΡ, ΡΠ»ΡΡΠ°Π΅ΠΌ Π΄ΠΎ 7 ΡΠ΅ΠΊ.
+ print("π Π‘Π»ΡΡΠ°Ρ ΠΏΡΠΎΠ΄ΠΎΠ»ΠΆΠ΅Π½ΠΈΠ΅ Π΄ΠΈΠ°Π»ΠΎΠ³Π° (5 ΡΠ΅ΠΊ)...")
+ # ΠΠ΄Π΅ΠΌ Π½Π°ΡΠ°Π»Π° ΡΠ΅ΡΠΈ 5 ΡΠ΅ΠΊ. ΠΡΠ»ΠΈ Π½Π°ΡΠ°Π»ΠΈ Π³ΠΎΠ²ΠΎΡΠΈΡΡ, ΡΠ»ΡΡΠ°Π΅ΠΌ Π΄ΠΎ 7 ΡΠ΅ΠΊ.
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:
print(f"ΠΡΠΈΠ±ΠΊΠ° ΠΏΡΠΈ ΠΏΡΠΎΡΠ»ΡΡΠΈΠ²Π°Π½ΠΈΠΈ: {e}")
print("ΠΠ΅ΡΠ΅ΠΈΠ½ΠΈΡΠΈΠ°Π»ΠΈΠ·Π°ΡΠΈΡ STT...")
@@ -350,13 +359,21 @@ def main():
# --- Π¨Π°Π³ 2: ΠΠ½Π°Π»ΠΈΠ· ΡΠ°ΡΠΏΠΎΠ·Π½Π°Π½Π½ΠΎΠ³ΠΎ ΡΠ΅ΠΊΡΡΠ° ---
if not user_text:
- # ΠΡΠ»Π° Π°ΠΊΡΠΈΠ²Π°ΡΠΈΡ, Π½ΠΎ ΡΠ΅ΡΡ Π½Π΅ ΡΠ°ΡΠΏΠΎΠ·Π½Π°Π½Π°
- speak("ΠΠ·Π²ΠΈΠ½ΠΈΡΠ΅, Ρ Π²Π°Ρ Π½Π΅ ΡΠ°ΡΡΠ»ΡΡΠ°Π». ΠΠΎΠΏΡΠΎΠ±ΡΠΉΡΠ΅ Π΅ΡΡ ΡΠ°Π·.")
- skip_wakeword = False # ΠΠΎΠ·Π²ΡΠ°ΡΠ°Π΅ΠΌΡΡ Π² ΡΠ΅ΠΆΠΈΠΌ ΠΎΠΆΠΈΠ΄Π°Π½ΠΈΡ ΠΈΠΌΠ΅Π½ΠΈ
+ # ΠΡΡΡΠΎΠΉ Π²Π²ΠΎΠ΄: Π±Π΅Π· Π»ΠΈΡΠ½ΠΈΡ
ΠΎΡΠ²Π΅ΡΠΎΠ² Π²ΠΎΠ·Π²ΡΠ°ΡΠ°Π΅ΠΌΡΡ ΠΊ ΠΎΠΆΠΈΠ΄Π°Π½ΠΈΡ wake word.
+ skip_wakeword = False
continue
# ΠΡΠΎΠ²Π΅ΡΠΊΠ° Π½Π° ΠΊΠΎΠΌΠ°Π½Π΄Ρ "Π‘ΡΠΎΠΏ"
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("π€ ΠΠ΄Ρ 'Alexandr' Π΄Π»Ρ Π°ΠΊΡΠΈΠ²Π°ΡΠΈΠΈ...")
skip_wakeword = False
@@ -387,24 +404,52 @@ def main():
skip_wakeword = True
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 ΠΌΠΈΠ½ΡΡ")
- 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:
clean_timer_response = clean_response(timer_response, language="ru")
completed = speak(
clean_timer_response, check_interrupt=check_wakeword_once
)
last_response = clean_timer_response
+ pending_time_target = (
+ "timer" if timer_response == ASK_TIMER_TIME_PROMPT else None
+ )
skip_wakeword = not completed
continue
# ΠΡΠΎΠ²Π΅ΡΠΊΠ° ΠΊΠΎΠΌΠ°Π½Π΄ Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊΠ° ("ΠΏΠΎΡΡΠ°Π²Ρ Π±ΡΠ΄ΠΈΠ»ΡΠ½ΠΈΠΊ Π½Π° 7")
- alarm_response = alarm_clock.parse_command(user_text)
+ alarm_response = alarm_clock.parse_command(command_text)
if alarm_response:
clean_alarm_response = clean_response(alarm_response, language="ru")
speak(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
# ΠΡΠΎΠ²Π΅ΡΠΊΠ° ΠΊΠΎΠΌΠ°Π½Π΄Ρ Π³ΡΠΎΠΌΠΊΠΎΡΡΠΈ ("Π³ΡΠΎΠΌΠΊΠΎΡΡΡ 5")
diff --git a/data/stopwatches.json b/data/stopwatches.json
new file mode 100644
index 0000000..f03273b
--- /dev/null
+++ b/data/stopwatches.json
@@ -0,0 +1,9 @@
+[
+ {
+ "id": 1,
+ "name": "",
+ "elapsed": 92.426419,
+ "running": false,
+ "started_at": null
+ }
+]
\ No newline at end of file
diff --git a/scripts/qwen-check.sh b/scripts/qwen-check.sh
new file mode 100755
index 0000000..7a7c762
--- /dev/null
+++ b/scripts/qwen-check.sh
@@ -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"
diff --git a/scripts/qwen-context.sh b/scripts/qwen-context.sh
new file mode 100755
index 0000000..29566a5
--- /dev/null
+++ b/scripts/qwen-context.sh
@@ -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"
diff --git a/ssp.py b/ssp.py
new file mode 100644
index 0000000..84f35de
--- /dev/null
+++ b/ssp.py
@@ -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)